Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from './segmented-button'
export * from './sheet'
export * from './slider'
export * from './snackbar'
export * from './split-button'
export * from './switch'
export * from './tabs'
export * from './text-field'
Expand Down
159 changes: 159 additions & 0 deletions src/components/split-button/SplitButton.stories.tsx
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,
},
}
171 changes: 171 additions & 0 deletions src/components/split-button/SplitButton.tsx
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>
Comment on lines +110 to +131
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use native disabled attribute instead of aria-disabled for true disabling.

Using aria-disabled and manual !disabled checks allows the button to still be focusable and technically clickable. For proper disabling, consider using the native disabled attribute 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 onKeyDown handler also calls e.preventDefault() for Space to prevent double-firing.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
<Ripple
as="button"
type="button"
className="sd-split_button-leading"
rippleColor={rippleColor}
onClick={onAction}
disabled={disabled}
data-sd-disabled={disabled}
aria-label={ariaLabel}
>
{leadingIcon && (
<span className="sd-split_button-icon">{leadingIcon}</span>
)}
{children && (
<span className="sd-split_button-label">{children}</span>
)}
</Ripple>
🤖 Prompt for AI Agents
In src/components/split-button/SplitButton.tsx around lines 110 to 131, the
Ripple button uses aria-disabled and manual checks which leaves it
focusable/clickable; replace that with the native disabled attribute on the
underlying button when disabled is true, remove the manual !disabled guards from
onClick and onKeyDown so the browser handles blocking interactions, and keep
aria-disabled only if you must preserve focus for accessibility; if you
deliberately retain aria-disabled instead of native disabled, ensure the
onKeyDown handler calls e.preventDefault() when handling Space to avoid
double-firing and keep click handlers guarded.


<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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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:

  1. A click-outside listener to close the menu
  2. An Escape key handler to close the menu
  3. Optionally, focus management to return focus to the trailing button when closing
+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])

Committable suggestion skipped: line range outside the PR's diff.

</div>
)
})
1 change: 1 addition & 0 deletions src/components/split-button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SplitButton'
Loading