Skip to content

Commit

Permalink
feat(split-button): add programmatic focus support for main and toggl…
Browse files Browse the repository at this point in the history
…e buttons
  • Loading branch information
tomdavies73 committed Feb 7, 2025
1 parent a608616 commit 7307c8f
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 160 deletions.
5 changes: 4 additions & 1 deletion src/components/split-button/index.ts
Original file line number Diff line number Diff line change
@@ -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";
342 changes: 186 additions & 156 deletions src/components/split-button/split-button.component.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<HTMLButtonElement>(null);
const toggleButton = useRef<HTMLButtonElement>(null);

const {
showAdditionalButtons,
showButtons,
hideButtons,
buttonNode,
handleToggleButtonKeyDown,
wrapperProps,
contextValue,
} = useChildButtons(toggleButton, CONTENT_WIDTH_RATIO);

const handleMainClick = (
ev: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
// ensure button is focused when clicked (Safari)
mainButtonRef.current?.focus();
if (onClick) {
onClick(ev as React.MouseEvent<HTMLButtonElement>);
}
};

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<SplitButtonHandle, SplitButtonProps>(
(
{
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<HTMLButtonElement>(null);
const toggleButtonRef = useRef<HTMLButtonElement>(null);

useImperativeHandle<SplitButtonHandle, SplitButtonHandle>(
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<HTMLButtonElement | HTMLAnchorElement>,
) => {
// ensure button is focused when clicked (Safari)
mainButtonRef.current?.focus();
if (onClick) {
onClick(ev as React.MouseEvent<HTMLButtonElement>);
}
};
}

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 [
<Button
data-element="main-button"
key="main-button"
id={buttonLabelId.current}
ref={mainButtonRef}
{...mainButtonProps}
>
{text}
</Button>,
<StyledSplitButtonToggle
aria-haspopup="true"
aria-expanded={showAdditionalButtons}
aria-label={ariaLabel || locale.splitButton.ariaLabel()}
data-element="toggle-button"
key="toggle-button"
type="button"
ref={toggleButton}
{...toggleButtonProps}
>
<Icon
type="dropdown"
color={getIconColor()}
bg="transparent"
disabled={disabled}
/>
</StyledSplitButtonToggle>,
];
}

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 [
<Button
data-element="main-button"
key="main-button"
id={buttonLabelId.current}
ref={mainButtonRef}
{...mainButtonProps}
>
{text}
</Button>,
<StyledSplitButtonToggle
aria-haspopup="true"
aria-expanded={showAdditionalButtons}
aria-label={ariaLabel || locale.splitButton.ariaLabel()}
data-element="toggle-button"
key="toggle-button"
type="button"
ref={toggleButtonRef}
{...toggleButtonProps}
>
<Icon
type="dropdown"
color={getIconColor()}
bg="transparent"
disabled={disabled}
/>
</StyledSplitButtonToggle>,
];
}

function renderAdditionalButtons() {
if (!showAdditionalButtons) return null;

return (
<Popover
disablePortal
placement={
position === "left"
? /* istanbul ignore next */ "bottom-start"
: "bottom-end"
}
popoverStrategy="fixed"
reference={buttonNode}
middleware={[
offset(6),
flip({
fallbackStrategy: "initialPlacement",
}),
]}
>
<StyledSplitButtonChildrenContainer {...wrapperProps} align={align}>
<SplitButtonContext.Provider value={contextValue}>
{React.Children.map(children, (child) => (
<li>{child}</li>
))}
</SplitButtonContext.Provider>
</StyledSplitButtonChildrenContainer>
</Popover>
);
}

const handleClick = useClickAwayListener(hideButtons);
const marginProps = filterStyledSystemMarginProps(rest);

return (
<Popover
disablePortal
placement={
position === "left"
? /* istanbul ignore next */ "bottom-start"
: "bottom-end"
}
popoverStrategy="fixed"
reference={buttonNode}
middleware={[
offset(6),
flip({
fallbackStrategy: "initialPlacement",
}),
]}
<StyledSplitButton
onClick={handleClick}
ref={buttonNode}
{...componentTags()}
{...marginProps}
>
<StyledSplitButtonChildrenContainer {...wrapperProps} align={align}>
<SplitButtonContext.Provider value={contextValue}>
{React.Children.map(children, (child) => (
<li>{child}</li>
))}
</SplitButtonContext.Provider>
</StyledSplitButtonChildrenContainer>
</Popover>
{renderMainButton()}
{renderAdditionalButtons()}
</StyledSplitButton>
);
}

const handleClick = useClickAwayListener(hideButtons);
const marginProps = filterStyledSystemMarginProps(rest);

return (
<StyledSplitButton
onClick={handleClick}
ref={buttonNode}
{...componentTags()}
{...marginProps}
>
{renderMainButton()}
{renderAdditionalButtons()}
</StyledSplitButton>
);
};
},
);

export default SplitButton;
11 changes: 11 additions & 0 deletions src/components/split-button/split-button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ import SplitButton from "carbon-react/lib/components/split-button";

<Canvas of={SplitButtonStories.Default} />

### 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.

<Canvas of={SplitButtonStories.ProgrammaticFocus} />

### Disabled

<Canvas of={SplitButtonStories.Disabled} />
Expand Down
Loading

0 comments on commit 7307c8f

Please sign in to comment.