-
Notifications
You must be signed in to change notification settings - Fork 0
Add Split Button component #13
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import { mdiAccount, mdiContentSave } from '@mdi/js' | ||
| import Icon from '@mdi/react' | ||
| import type { Meta, StoryObj } from '@storybook/react-vite' | ||
| import { Menu, MenuItem } from '../menu' | ||
| import { SplitButton } from '.' | ||
|
|
||
| const meta: Meta<typeof SplitButton> = { | ||
| title: 'components/Button/SplitButton', | ||
| component: SplitButton, | ||
| parameters: { layout: 'centered' }, | ||
| tags: ['autodocs'], | ||
| argTypes: { | ||
| variant: { | ||
| control: { type: 'radio' }, | ||
| options: ['elevated', 'filled', 'tonal', 'outlined'], | ||
| }, | ||
| size: { | ||
| control: { type: 'radio' }, | ||
| options: ['xs', 's', 'm', 'l', 'xl'], | ||
| }, | ||
| disabled: { | ||
| control: { type: 'boolean' }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| export default meta | ||
| type Story = StoryObj<typeof meta> | ||
|
|
||
| const SampleMenu = ( | ||
| <Menu> | ||
| <MenuItem>Option 1</MenuItem> | ||
| <MenuItem>Option 2</MenuItem> | ||
| <MenuItem>Option 3</MenuItem> | ||
| </Menu> | ||
| ) | ||
|
|
||
| export const Filled: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'm', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const Elevated: Story = { | ||
| args: { | ||
| variant: 'elevated', | ||
| size: 'm', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const Tonal: Story = { | ||
| args: { | ||
| variant: 'tonal', | ||
| size: 'm', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const Outlined: Story = { | ||
| args: { | ||
| variant: 'outlined', | ||
| size: 'm', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const WithLeadingIcon: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'm', | ||
| leadingIcon: <Icon size="18px" path={mdiContentSave} />, | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const IconOnly: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'm', | ||
| leadingIcon: <Icon size="18px" path={mdiAccount} />, | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| 'aria-label': 'User actions', | ||
| }, | ||
| } | ||
|
|
||
| export const SizeXS: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'xs', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const SizeS: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 's', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const SizeM: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'm', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const SizeL: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'l', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const SizeXL: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'xl', | ||
| children: 'Save', | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } | ||
|
|
||
| export const Disabled: Story = { | ||
| args: { | ||
| variant: 'filled', | ||
| size: 'm', | ||
| children: 'Save', | ||
| disabled: true, | ||
| onAction: () => console.log('Action clicked'), | ||
| menu: SampleMenu, | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| import './split-button.scss' | ||
| import clsx from 'clsx' | ||
| import { forwardRef, useState } from 'react' | ||
| import { Ripple } from '@/ripple/Ripple' | ||
| import { getReversedRippleColor } from '@/ripple/ripple-color' | ||
| import { ExtendProps } from '@/utils/type' | ||
|
|
||
| /** | ||
| * @specs https://m3.material.io/components/split-button/specs | ||
| */ | ||
| export const SplitButton = forwardRef< | ||
| HTMLDivElement, | ||
| ExtendProps< | ||
| { | ||
| /** | ||
| * @default "filled" | ||
| */ | ||
| variant?: 'outlined' | 'filled' | 'elevated' | 'tonal' | ||
| /** | ||
| * @default "m" | ||
| */ | ||
| size?: 'xs' | 's' | 'm' | 'l' | 'xl' | ||
| disabled?: boolean | ||
| /** | ||
| * Leading icon shown before the label | ||
| */ | ||
| leadingIcon?: React.ReactNode | ||
| /** | ||
| * Label text for the leading button | ||
| */ | ||
| children?: React.ReactNode | ||
| /** | ||
| * Click handler for the leading button | ||
| */ | ||
| onAction?: VoidFunction | ||
| /** | ||
| * Menu content shown when trailing button is clicked | ||
| */ | ||
| menu?: React.ReactNode | ||
| /** | ||
| * Menu icon for the trailing button | ||
| * @default arrow_drop_down icon | ||
| */ | ||
| menuIcon?: React.ReactNode | ||
| /** | ||
| * Controlled selected state for trailing button | ||
| */ | ||
| selected?: boolean | ||
| /** | ||
| * Handler called when trailing button is clicked | ||
| */ | ||
| onMenuToggle?: (selected: boolean) => void | ||
| /** | ||
| * Accessible label for the leading button | ||
| */ | ||
| 'aria-label'?: string | ||
| /** | ||
| * Accessible label for the trailing button | ||
| */ | ||
| 'aria-label-menu'?: string | ||
| }, | ||
| HTMLDivElement | ||
| > | ||
| >(function SplitButton( | ||
| { | ||
| variant = 'filled', | ||
| size = 'm', | ||
| disabled, | ||
| leadingIcon, | ||
| children, | ||
| onAction, | ||
| menu, | ||
| menuIcon, | ||
| selected: controlledSelected, | ||
| onMenuToggle, | ||
| className, | ||
| 'aria-label': ariaLabel, | ||
| 'aria-label-menu': ariaLabelMenu, | ||
| ...props | ||
| }, | ||
| ref, | ||
| ) { | ||
| const [internalSelected, setInternalSelected] = useState(false) | ||
| const selected = | ||
| controlledSelected !== undefined ? controlledSelected : internalSelected | ||
|
|
||
| const handleMenuToggle = () => { | ||
| const newSelected = !selected | ||
| if (controlledSelected === undefined) { | ||
| setInternalSelected(newSelected) | ||
| } | ||
| onMenuToggle?.(newSelected) | ||
| } | ||
|
|
||
| const rippleColor = | ||
| variant === 'filled' ? getReversedRippleColor() : undefined | ||
|
|
||
| return ( | ||
| <div | ||
| {...props} | ||
| ref={ref} | ||
| className={clsx( | ||
| 'sd-split_button', | ||
| `sd-split_button-${variant}`, | ||
| `sd-split_button-size-${size}`, | ||
| className, | ||
| )} | ||
| data-sd-disabled={disabled} | ||
| > | ||
| <Ripple | ||
| as="button" | ||
| type="button" | ||
| className="sd-split_button-leading" | ||
| rippleColor={rippleColor} | ||
| onClick={() => !disabled && onAction?.()} | ||
| onKeyDown={(e) => | ||
| !disabled && | ||
| (e.key === 'Enter' || e.key === ' ') && | ||
| onAction?.() | ||
| } | ||
| data-sd-disabled={disabled} | ||
| aria-disabled={disabled} | ||
| aria-label={ariaLabel} | ||
| > | ||
| {leadingIcon && ( | ||
| <span className="sd-split_button-icon">{leadingIcon}</span> | ||
| )} | ||
| {children && ( | ||
| <span className="sd-split_button-label">{children}</span> | ||
| )} | ||
| </Ripple> | ||
|
|
||
| <div className="sd-split_button-divider" /> | ||
|
|
||
| <Ripple | ||
| as="button" | ||
| type="button" | ||
| className="sd-split_button-trailing" | ||
| rippleColor={rippleColor} | ||
| onClick={() => !disabled && handleMenuToggle()} | ||
| onKeyDown={(e) => | ||
| !disabled && | ||
| (e.key === 'Enter' || e.key === ' ') && | ||
| handleMenuToggle() | ||
| } | ||
| data-sd-disabled={disabled} | ||
| data-sd-selected={selected} | ||
| aria-disabled={disabled} | ||
| aria-pressed={selected} | ||
| aria-label={ariaLabelMenu ?? 'Open menu'} | ||
| > | ||
| <span className="sd-split_button-menu_icon"> | ||
| {menuIcon || ( | ||
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| width="100%" | ||
| height="100%" | ||
| > | ||
| <path d="M7 10l5 5 5-5z" fill="currentColor" /> | ||
| </svg> | ||
| )} | ||
| </span> | ||
| </Ripple> | ||
|
|
||
| {menu && selected && ( | ||
| <div className="sd-split_button-menu">{menu}</div> | ||
| )} | ||
|
Comment on lines
+166
to
+168
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add click-outside and Escape key handling to close the menu. The menu opens when the trailing button is clicked but there's no mechanism to close it when clicking outside or pressing Escape. This is expected behavior for dropdown menus. Consider adding:
+import { forwardRef, useState, useEffect, useRef } from 'react'
// Inside the component:
+ const containerRef = useRef<HTMLDivElement>(null)
+
+ useEffect(() => {
+ if (!selected) return
+
+ const handleClickOutside = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ if (controlledSelected === undefined) {
+ setInternalSelected(false)
+ }
+ onMenuToggle?.(false)
+ }
+ }
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (controlledSelected === undefined) {
+ setInternalSelected(false)
+ }
+ onMenuToggle?.(false)
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside)
+ document.addEventListener('keydown', handleEscape)
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ document.removeEventListener('keydown', handleEscape)
+ }
+ }, [selected, controlledSelected, onMenuToggle])
|
||
| </div> | ||
| ) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './SplitButton' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use native
disabledattribute instead ofaria-disabledfor true disabling.Using
aria-disabledand manual!disabledchecks allows the button to still be focusable and technically clickable. For proper disabling, consider using the nativedisabledattribute which prevents interaction entirely.<Ripple as="button" type="button" className="sd-split_button-leading" rippleColor={rippleColor} - onClick={() => !disabled && onAction?.()} - onKeyDown={(e) => - !disabled && - (e.key === 'Enter' || e.key === ' ') && - onAction?.() - } + onClick={onAction} + disabled={disabled} data-sd-disabled={disabled} - aria-disabled={disabled} aria-label={ariaLabel} >If you need to keep focus on disabled buttons for accessibility reasons (e.g., tooltip on disabled), then the current approach is valid but ensure the
onKeyDownhandler also callse.preventDefault()for Space to prevent double-firing.📝 Committable suggestion
🤖 Prompt for AI Agents