From 7307c8fea31da5732903be2ae7b26366feea08b8 Mon Sep 17 00:00:00 2001 From: "tom.davies" Date: Thu, 6 Feb 2025 14:13:58 +0000 Subject: [PATCH] feat(split-button): add programmatic focus support for main and toggle buttons --- src/components/split-button/index.ts | 5 +- .../split-button/split-button.component.tsx | 342 ++++++++++-------- src/components/split-button/split-button.mdx | 11 + .../split-button/split-button.stories.tsx | 29 +- .../split-button/split-button.test.tsx | 44 ++- 5 files changed, 271 insertions(+), 160 deletions(-) diff --git a/src/components/split-button/index.ts b/src/components/split-button/index.ts index 3341ece610..1ee55164a3 100644 --- a/src/components/split-button/index.ts +++ b/src/components/split-button/index.ts @@ -1,2 +1,5 @@ export { default } from "./split-button.component"; -export type { SplitButtonProps } from "./split-button.component"; +export type { + SplitButtonHandle, + SplitButtonProps, +} from "./split-button.component"; diff --git a/src/components/split-button/split-button.component.tsx b/src/components/split-button/split-button.component.tsx index ef796c2247..9a28f3b002 100644 --- a/src/components/split-button/split-button.component.tsx +++ b/src/components/split-button/split-button.component.tsx @@ -1,4 +1,9 @@ -import React, { useContext, useRef } from "react"; +import React, { + useContext, + useRef, + forwardRef, + useImperativeHandle, +} from "react"; import { ThemeContext } from "styled-components"; import { MarginProps } from "styled-system"; import { flip, offset } from "@floating-ui/dom"; @@ -53,169 +58,194 @@ export interface SplitButtonProps position?: "left" | "right"; } -export const SplitButton = ({ - align = "left", - position = "right", - buttonType = "secondary", - children, - disabled = false, - iconPosition = "before", - iconType, - onClick, - size = "medium", - subtext, - text, - "data-element": dataElement, - "data-role": dataRole, - "aria-label": ariaLabel, - ...rest -}: SplitButtonProps) => { - const locale = useLocale(); - const theme = useContext(ThemeContext) || baseTheme; - const buttonLabelId = useRef(guid()); - - const mainButtonRef = useRef(null); - const toggleButton = useRef(null); - - const { - showAdditionalButtons, - showButtons, - hideButtons, - buttonNode, - handleToggleButtonKeyDown, - wrapperProps, - contextValue, - } = useChildButtons(toggleButton, CONTENT_WIDTH_RATIO); - - const handleMainClick = ( - ev: React.MouseEvent, - ) => { - // ensure button is focused when clicked (Safari) - mainButtonRef.current?.focus(); - if (onClick) { - onClick(ev as React.MouseEvent); - } - }; - - const mainButtonProps = { - onFocus: hideButtons, - onTouchStart: hideButtons, - iconPosition, - buttonType, - disabled, - iconType, - onClick: handleMainClick, - size, - subtext, - ...filterOutStyledSystemSpacingProps(rest), - }; - - const handleToggleClick = () => { - showButtons(); - }; - - const toggleButtonProps = { - disabled, - displayed: showAdditionalButtons, - onTouchStart: showButtons, - onKeyDown: handleToggleButtonKeyDown, - onClick: handleToggleClick, - buttonType, - size, - }; - - function componentTags() { - return { - "data-component": "split-button", +export type SplitButtonHandle = { + /** Programmatically focus the main button */ + focusMainButton: () => void; + /** Programmatically focus the toggle button. */ + focusToggleButton: () => void; +} | null; + +export const SplitButton = forwardRef( + ( + { + align = "left", + position = "right", + buttonType = "secondary", + children, + disabled = false, + iconPosition = "before", + iconType, + onClick, + size = "medium", + subtext, + text, "data-element": dataElement, "data-role": dataRole, + "aria-label": ariaLabel, + ...rest + }, + ref, + ) => { + const locale = useLocale(); + const theme = useContext(ThemeContext) || baseTheme; + const buttonLabelId = useRef(guid()); + + const mainButtonRef = useRef(null); + const toggleButtonRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + focusMainButton() { + mainButtonRef.current?.focus(); + }, + focusToggleButton() { + toggleButtonRef.current?.focus(); + }, + }), + [], + ); + + const { + showAdditionalButtons, + showButtons, + hideButtons, + buttonNode, + handleToggleButtonKeyDown, + wrapperProps, + contextValue, + } = useChildButtons(toggleButtonRef, CONTENT_WIDTH_RATIO); + + const handleMainClick = ( + ev: React.MouseEvent, + ) => { + // ensure button is focused when clicked (Safari) + mainButtonRef.current?.focus(); + if (onClick) { + onClick(ev as React.MouseEvent); + } }; - } - function getIconColor() { - const colorsMap = { - primary: theme.colors.white, - secondary: theme.colors.primary, + const mainButtonProps = { + onFocus: hideButtons, + onTouchStart: hideButtons, + iconPosition, + buttonType, + disabled, + iconType, + onClick: handleMainClick, + size, + subtext, + ...filterOutStyledSystemSpacingProps(rest), }; - return colorsMap[buttonType]; - } - - function renderMainButton() { - return [ - , - - - , - ]; - } - - function renderAdditionalButtons() { - if (!showAdditionalButtons) return null; + + const handleToggleClick = () => { + showButtons(); + }; + + const toggleButtonProps = { + disabled, + displayed: showAdditionalButtons, + onTouchStart: showButtons, + onKeyDown: handleToggleButtonKeyDown, + onClick: handleToggleClick, + buttonType, + size, + }; + + function componentTags() { + return { + "data-component": "split-button", + "data-element": dataElement, + "data-role": dataRole, + }; + } + + function getIconColor() { + const colorsMap = { + primary: theme.colors.white, + secondary: theme.colors.primary, + }; + return colorsMap[buttonType]; + } + + function renderMainButton() { + return [ + , + + + , + ]; + } + + function renderAdditionalButtons() { + if (!showAdditionalButtons) return null; + + return ( + + + + {React.Children.map(children, (child) => ( +
  • {child}
  • + ))} +
    +
    +
    + ); + } + + const handleClick = useClickAwayListener(hideButtons); + const marginProps = filterStyledSystemMarginProps(rest); return ( - - - - {React.Children.map(children, (child) => ( -
  • {child}
  • - ))} -
    -
    -
    + {renderMainButton()} + {renderAdditionalButtons()} + ); - } - - const handleClick = useClickAwayListener(hideButtons); - const marginProps = filterStyledSystemMarginProps(rest); - - return ( - - {renderMainButton()} - {renderAdditionalButtons()} - - ); -}; + }, +); export default SplitButton; diff --git a/src/components/split-button/split-button.mdx b/src/components/split-button/split-button.mdx index efe0fc17e8..b74b026011 100644 --- a/src/components/split-button/split-button.mdx +++ b/src/components/split-button/split-button.mdx @@ -37,6 +37,17 @@ import SplitButton from "carbon-react/lib/components/split-button"; +### Focusing Main and Toggle Buttons Programmatically + +```javascript +import { SplitButtonHandle } from "carbon-react/lib/components/split-button"; +``` + +The `SplitButtonHandle` type provides an imperative handle for programmatic control over `SplitButton`. +Using a `ref`, you can access its `focusMainButton()` and `focusToggleButton()` methods to set focus on the respective buttons as needed. + + + ### Disabled diff --git a/src/components/split-button/split-button.stories.tsx b/src/components/split-button/split-button.stories.tsx index c1c993c4ab..11feb9cd13 100644 --- a/src/components/split-button/split-button.stories.tsx +++ b/src/components/split-button/split-button.stories.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import { Meta, StoryObj } from "@storybook/react"; import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; @@ -6,7 +6,7 @@ import generateStyledSystemProps from "../../../.storybook/utils/styled-system-p import Button from "../button"; import Box from "../box"; import { Accordion } from "../accordion"; -import SplitButton from "."; +import SplitButton, { SplitButtonHandle } from "."; const styledSystemProps = generateStyledSystemProps({ margin: true, @@ -42,6 +42,31 @@ export const Default: Story = () => { Default.storyName = "Default"; Default.parameters = { chromatic: { disableSnapshot: true } }; +export const ProgrammaticFocus: Story = () => { + const splitButtonHandle = useRef(null); + + return ( + + + + + + + + + + + + ); +}; +ProgrammaticFocus.storyName = + "Focusing Main and Toggle Buttons Programmatically"; +ProgrammaticFocus.parameters = { chromatic: { disableSnapshot: true } }; + export const Disabled: Story = () => { return ( diff --git a/src/components/split-button/split-button.test.tsx b/src/components/split-button/split-button.test.tsx index 46c4c55675..4f32cd8d7f 100644 --- a/src/components/split-button/split-button.test.tsx +++ b/src/components/split-button/split-button.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import SplitButton from "./split-button.component"; +import SplitButton, { SplitButtonHandle } from "./split-button.component"; import Button from "../button"; import { SizeOptions } from "../button/button.component"; import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test-utils"; @@ -52,6 +52,24 @@ testStyledSystemMargin( () => screen.getByTestId("split-button-container"), ); +const MockComponent = () => { + const splitButtonHandle = React.useRef(null); + return ( +
    + + + + , + + +
    + ); +}; + test("renders the main and toggle buttons", () => { render( @@ -92,6 +110,30 @@ test("renders child buttons when toggle button is clicked", async () => { ).toBeVisible(); }); +test("should focus the main button when the focusMainButton on the ref handle is invoked", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { + name: "Press me to focus on the main button", + }); + + await user.click(button); + + expect(screen.getByRole("button", { name: "Main Button" })).toHaveFocus(); +}); + +test("should focus the toggle button when the focusToggleButton on the ref handle is invoked", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button", { + name: "Press me to focus on the toggle button", + }); + + await user.click(button); + + expect(screen.getByRole("button", { name: "Show more" })).toHaveFocus(); +}); + test("focuses first child button when toggle button is clicked", async () => { const user = userEvent.setup(); render(