Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(multi-action-button, split-button): add programmatic focus support #7193

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/multi-action-button/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export { default } from "./multi-action-button.component";
export type { MultiActionButtonProps } from "./multi-action-button.component";
export type {
MultiActionButtonHandle,
MultiActionButtonProps,
} from "./multi-action-button.component";
219 changes: 121 additions & 98 deletions src/components/multi-action-button/multi-action-button.component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import React, { useRef, forwardRef, useImperativeHandle } from "react";
import { WidthProps } from "styled-system";
import { flip, offset } from "@floating-ui/dom";

Expand Down Expand Up @@ -26,111 +26,134 @@ export interface MultiActionButtonProps
subtext?: string;
}

export const MultiActionButton = ({
align = "left",
position = "left",
disabled,
buttonType,
size,
children,
text,
subtext,
width,
onClick,
"data-element": dataElement,
"data-role": dataRole,
...rest
}: MultiActionButtonProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
export type MultiActionButtonHandle = {
/** Programmatically focus the main button */
focusMainButton: () => void;
} | null;

const {
showAdditionalButtons,
showButtons,
hideButtons,
buttonNode,
handleToggleButtonKeyDown,
wrapperProps,
contextValue,
} = useChildButtons(buttonRef);
export const MultiActionButton = forwardRef<
MultiActionButtonHandle,
MultiActionButtonProps
>(
(
{
align = "left",
position = "left",
disabled,
buttonType,
size,
children,
text,
subtext,
width,
onClick,
"data-element": dataElement,
"data-role": dataRole,
...rest
},
ref,
) => {
const buttonRef = useRef<HTMLButtonElement>(null);

const handleInsideClick = useClickAwayListener(hideButtons);
useImperativeHandle<MultiActionButtonHandle, MultiActionButtonHandle>(
ref,
() => ({
focusMainButton() {
buttonRef.current?.focus();
},
}),
[],
);

const handleClick = (
ev: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
showButtons();
handleInsideClick();
if (onClick) {
onClick(ev as React.MouseEvent<HTMLButtonElement>);
}
};
const {
showAdditionalButtons,
showButtons,
hideButtons,
buttonNode,
handleToggleButtonKeyDown,
wrapperProps,
contextValue,
} = useChildButtons(buttonRef);

const mainButtonProps = {
disabled,
displayed: showAdditionalButtons,
onTouchStart: showButtons,
onKeyDown: handleToggleButtonKeyDown,
onClick: handleClick,
buttonType,
size,
subtext,
...filterOutStyledSystemSpacingProps(rest),
};
const handleInsideClick = useClickAwayListener(hideButtons);

const renderAdditionalButtons = () => (
<Popover
disablePortal
placement={
position === "left"
? "bottom-start"
: /* istanbul ignore next */ "bottom-end"
const handleClick = (
ev: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
showButtons();
handleInsideClick();
if (onClick) {
onClick(ev as React.MouseEvent<HTMLButtonElement>);
}
reference={buttonNode}
popoverStrategy="fixed"
middleware={[
offset(6),
flip({
fallbackStrategy: "initialPlacement",
}),
]}
>
<StyledButtonChildrenContainer {...wrapperProps} align={align}>
<SplitButtonContext.Provider value={contextValue}>
{React.Children.map(children, (child) => (
<li>{child}</li>
))}
</SplitButtonContext.Provider>
</StyledButtonChildrenContainer>
</Popover>
);
};

const mainButtonProps = {
disabled,
displayed: showAdditionalButtons,
onTouchStart: showButtons,
onKeyDown: handleToggleButtonKeyDown,
onClick: handleClick,
buttonType,
size,
subtext,
...filterOutStyledSystemSpacingProps(rest),
};

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

const marginProps = filterStyledSystemMarginProps(rest);
const marginProps = filterStyledSystemMarginProps(rest);

return (
<StyledMultiActionButton
ref={buttonNode}
data-component="multi-action-button"
data-element={dataElement}
data-role={dataRole}
displayed={showAdditionalButtons}
width={width}
{...marginProps}
>
<Button
aria-haspopup="true"
aria-expanded={showAdditionalButtons}
data-element="toggle-button"
key="toggle-button"
{...mainButtonProps}
ref={buttonRef}
iconPosition="after"
iconType="dropdown"
return (
<StyledMultiActionButton
ref={buttonNode}
data-component="multi-action-button"
data-element={dataElement}
data-role={dataRole}
displayed={showAdditionalButtons}
width={width}
{...marginProps}
>
{text}
</Button>
{showAdditionalButtons && renderAdditionalButtons()}
</StyledMultiActionButton>
);
};
<Button
aria-haspopup="true"
aria-expanded={showAdditionalButtons}
data-element="toggle-button"
key="toggle-button"
{...mainButtonProps}
ref={buttonRef}
iconPosition="after"
iconType="dropdown"
>
{text}
</Button>
{showAdditionalButtons && renderAdditionalButtons()}
</StyledMultiActionButton>
);
},
);

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

<Canvas of={MultiActionButtonStories.DefaultStory} />

### Focusing Main Button Programmatically

```javascript
import { MultiActionButtonHandle } from "carbon-react/lib/components/multi-action-button";
```

The `MultiActionButtonHandle` type provides an imperative handle for programmatic control over `MultiActionButton`.
Using a `ref`, you can access its `focusMainButton()` method to set focus on the main button as needed.

<Canvas of={MultiActionButtonStories.ProgrammaticFocus} />

### Disabled

<Canvas of={MultiActionButtonStories.Disabled} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React from "react";
import React, { useRef } from "react";
import { Meta, StoryObj } from "@storybook/react";

import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props";

import MultiActionButton, { MultiActionButtonProps } from ".";
import MultiActionButton, {
MultiActionButtonProps,
MultiActionButtonHandle,
} from ".";
import Button from "../button";
import Box from "../box";
import { Accordion } from "../accordion";
Expand Down Expand Up @@ -46,6 +49,30 @@ export const DefaultStory: Story = {
parameters: { chromatic: { disableSnapshot: true } },
};

export const ProgrammaticFocus: Story = () => {
const multiActionButtonHandle = useRef<MultiActionButtonHandle>(null);

return (
<Box display="flex" gap={2}>
<Button
onClick={() => multiActionButtonHandle.current?.focusMainButton()}
>
Focus Button
</Button>
<MultiActionButton
ref={multiActionButtonHandle}
text="Multi Action Button"
>
<Button>Button 1</Button>
<Button>Button 2</Button>
<Button>Button 3</Button>
</MultiActionButton>
</Box>
);
};
ProgrammaticFocus.storyName = "Focusing Main Button Programmatically";
ProgrammaticFocus.parameters = { chromatic: { disableSnapshot: true } };

export const Disabled: Story = {
...DefaultStory,
args: { ...DefaultStory.args, text: "Multi Action Button", disabled: true },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import MultiActionButton from "./multi-action-button.component";
import MultiActionButton, { MultiActionButtonHandle } from ".";
import Button from "../button";
import { testStyledSystemMargin } from "../../__spec_helper__/__internal__/test-utils";

Expand Down Expand Up @@ -75,6 +75,35 @@ test("should call 'onClick' when the main button is clicked", async () => {
expect(onClick).toHaveBeenCalledTimes(1);
});

test("should focus the main button when the focusMainButton on the ref handle is invoked", async () => {
const MockComponent = () => {
const multiActionButtonHandle = React.useRef<MultiActionButtonHandle>(null);
return (
<div>
<MultiActionButton ref={multiActionButtonHandle} text="Main Button">
<span>First</span>
</MultiActionButton>
,
<Button
onClick={() => multiActionButtonHandle.current?.focusMainButton()}
>
Press me to focus on MultiActionButton
</Button>
</div>
);
};

const user = userEvent.setup();
render(<MockComponent />);
const button = screen.getByRole("button", {
name: "Press me to focus on MultiActionButton",
});

await user.click(button);

expect(screen.getByRole("button", { name: "Main Button" })).toHaveFocus();
});

test("should open additional buttons when the main button is clicked and focus on the first child", async () => {
const user = userEvent.setup();
render(
Expand Down
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";
Loading
Loading