From ffd1d62b46ee38c26d5206aaa0b68d54616b76ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:31:32 +0000 Subject: [PATCH 1/3] Initial plan From 11df996c8daf322fb8c7922cff4deb4a20971d45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:40:11 +0000 Subject: [PATCH 2/3] Add split button component with all variants and sizes Co-authored-by: YieldRay <24633623+YieldRay@users.noreply.github.com> --- src/components/index.ts | 1 + .../split-button/SplitButton.stories.tsx | 159 ++++++ src/components/split-button/SplitButton.tsx | 165 +++++++ src/components/split-button/index.ts | 1 + src/components/split-button/split-button.scss | 462 ++++++++++++++++++ 5 files changed, 788 insertions(+) create mode 100644 src/components/split-button/SplitButton.stories.tsx create mode 100644 src/components/split-button/SplitButton.tsx create mode 100644 src/components/split-button/index.ts create mode 100644 src/components/split-button/split-button.scss diff --git a/src/components/index.ts b/src/components/index.ts index d7dd4e3..3f3a366 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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' diff --git a/src/components/split-button/SplitButton.stories.tsx b/src/components/split-button/SplitButton.stories.tsx new file mode 100644 index 0000000..d40ba0b --- /dev/null +++ b/src/components/split-button/SplitButton.stories.tsx @@ -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 = { + 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 + +const SampleMenu = ( + + Option 1 + Option 2 + Option 3 + +) + +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: , + children: 'Save', + onAction: () => console.log('Action clicked'), + menu: SampleMenu, + }, +} + +export const IconOnly: Story = { + args: { + variant: 'filled', + size: 'm', + leadingIcon: , + 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, + }, +} diff --git a/src/components/split-button/SplitButton.tsx b/src/components/split-button/SplitButton.tsx new file mode 100644 index 0000000..ea87a84 --- /dev/null +++ b/src/components/split-button/SplitButton.tsx @@ -0,0 +1,165 @@ +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 ( +
+ !disabled && onAction?.()} + onKeyDown={(e) => !disabled && e.key === 'Enter' && onAction?.()} + data-sd-disabled={disabled} + aria-disabled={disabled} + aria-label={ariaLabel} + > + {leadingIcon && ( + {leadingIcon} + )} + {children && ( + {children} + )} + + +
+ + !disabled && handleMenuToggle()} + onKeyDown={(e) => + !disabled && e.key === 'Enter' && handleMenuToggle() + } + data-sd-disabled={disabled} + data-sd-selected={selected} + aria-disabled={disabled} + aria-pressed={selected} + aria-label={ariaLabelMenu ?? 'Open menu'} + > + + {menuIcon || ( + + + + )} + + + + {menu && selected && ( +
{menu}
+ )} +
+ ) +}) diff --git a/src/components/split-button/index.ts b/src/components/split-button/index.ts new file mode 100644 index 0000000..be585c1 --- /dev/null +++ b/src/components/split-button/index.ts @@ -0,0 +1 @@ +export * from './SplitButton' diff --git a/src/components/split-button/split-button.scss b/src/components/split-button/split-button.scss new file mode 100644 index 0000000..4f5b57c --- /dev/null +++ b/src/components/split-button/split-button.scss @@ -0,0 +1,462 @@ +// https://m3.material.io/components/split-button/specs + +.sd-split_button { + display: inline-flex; + vertical-align: middle; + position: relative; + user-select: none; + -webkit-tap-highlight-color: transparent; + + &[data-sd-disabled='true'] { + opacity: 0.38; + pointer-events: none; + } + + // Common button styles + button { + border: none; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; + font-size: 14px; + line-height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + + &[data-sd-disabled='true'] { + pointer-events: none; + } + } + + // Leading button + &-leading { + padding-left: 1rem; + padding-right: 1rem; + border-top-left-radius: inherit; + border-bottom-left-radius: inherit; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + min-width: 48px; + } + + // Trailing button + &-trailing { + padding-left: 0.5rem; + padding-right: 0.5rem; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; + min-width: 40px; + } + + // Divider between buttons + &-divider { + width: 2px; + align-self: stretch; + background-color: transparent; + } + + // Icon and label + &-icon { + display: inline-flex; + vertical-align: middle; + margin-right: 0.5rem; + + svg { + width: 18px; + height: 18px; + } + } + + &-label { + display: inline-flex; + vertical-align: middle; + line-height: 100%; + } + + &-menu_icon { + display: inline-flex; + vertical-align: middle; + + svg { + width: 24px; + height: 24px; + } + } + + // Size configurations + &-size-xs { + height: 24px; + border-radius: 12px; + + button { + font-size: 11px; + line-height: 16px; + } + + .sd-split_button-icon svg { + width: 16px; + height: 16px; + } + + .sd-split_button-menu_icon svg { + width: 20px; + height: 20px; + } + + .sd-split_button-leading { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .sd-split_button-trailing { + padding-left: 0.25rem; + padding-right: 0.25rem; + } + + // Inner corner radius for XS + .sd-split_button-leading { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + .sd-split_button-trailing { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + // Menu icon offset when unselected: -1dp from center + .sd-split_button-trailing:not([data-sd-selected='true']) .sd-split_button-menu_icon { + transform: translateX(-1px); + } + } + + &-size-s { + height: 32px; + border-radius: 16px; + + button { + font-size: 12px; + line-height: 18px; + } + + .sd-split_button-icon svg { + width: 16px; + height: 16px; + } + + .sd-split_button-menu_icon svg { + width: 22px; + height: 22px; + } + + .sd-split_button-leading { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .sd-split_button-trailing { + padding-left: 0.375rem; + padding-right: 0.375rem; + } + + // Inner corner radius for S + .sd-split_button-leading { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + .sd-split_button-trailing { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + // Menu icon offset when unselected: -1dp from center + .sd-split_button-trailing:not([data-sd-selected='true']) .sd-split_button-menu_icon { + transform: translateX(-1px); + } + } + + &-size-m { + height: 40px; + border-radius: 20px; + + button { + font-size: 14px; + line-height: 20px; + } + + .sd-split_button-icon svg { + width: 18px; + height: 18px; + } + + .sd-split_button-menu_icon svg { + width: 24px; + height: 24px; + } + + // Inner corner radius for M + .sd-split_button-leading { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + .sd-split_button-trailing { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + // Menu icon offset when unselected: -2dp from center + .sd-split_button-trailing:not([data-sd-selected='true']) .sd-split_button-menu_icon { + transform: translateX(-2px); + } + } + + &-size-l { + height: 48px; + border-radius: 24px; + + button { + font-size: 16px; + line-height: 22px; + } + + .sd-split_button-icon svg { + width: 20px; + height: 20px; + } + + .sd-split_button-menu_icon svg { + width: 26px; + height: 26px; + } + + .sd-split_button-leading { + padding-left: 1.25rem; + padding-right: 1.25rem; + } + + .sd-split_button-trailing { + padding-left: 0.625rem; + padding-right: 0.625rem; + } + + // Inner corner radius for L + .sd-split_button-leading { + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + } + + .sd-split_button-trailing { + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + } + + // Menu icon offset when unselected: -3dp from center + .sd-split_button-trailing:not([data-sd-selected='true']) .sd-split_button-menu_icon { + transform: translateX(-3px); + } + } + + &-size-xl { + height: 56px; + border-radius: 28px; + + button { + font-size: 18px; + line-height: 24px; + } + + .sd-split_button-icon svg { + width: 22px; + height: 22px; + } + + .sd-split_button-menu_icon svg { + width: 28px; + height: 28px; + } + + .sd-split_button-leading { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .sd-split_button-trailing { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + // Inner corner radius for XL + .sd-split_button-leading { + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; + } + + .sd-split_button-trailing { + border-top-left-radius: 12px; + border-bottom-left-radius: 12px; + } + + // Menu icon offset when unselected: -6dp from center + .sd-split_button-trailing:not([data-sd-selected='true']) .sd-split_button-menu_icon { + transform: translateX(-6px); + } + } + + // Elevated variant + &-elevated { + background: var(--md-sys-color-surface-container-low); + color: var(--md-sys-color-primary); + @include elevation-level1; + + .sd-split_button-divider { + background-color: color-mix( + in srgb, + var(--md-sys-color-primary) 12%, + transparent + ); + } + + button { + background: transparent; + color: inherit; + + @media (any-hover: hover) { + &:hover { + opacity: 0.8; + @include elevation-level2; + } + } + + &:active { + opacity: unset; + filter: brightness(95%); + } + + &:focus-visible { + outline: none; + opacity: unset; + filter: brightness(90%); + } + } + } + + // Filled variant + &-filled { + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + + .sd-split_button-divider { + background-color: color-mix( + in srgb, + var(--md-sys-color-on-primary) 12%, + transparent + ); + } + + button { + background: transparent; + color: inherit; + + @media (any-hover: hover) { + &:hover { + @include elevation-level1; + } + } + + &:active { + box-shadow: unset; + filter: contrast(120%); + } + + &:focus-visible { + outline: none; + box-shadow: unset; + filter: contrast(110%); + } + } + } + + // Tonal variant + &-tonal { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + + .sd-split_button-divider { + background-color: color-mix( + in srgb, + var(--md-sys-color-on-secondary-container) 12%, + transparent + ); + } + + button { + background: transparent; + color: inherit; + + @media (any-hover: hover) { + &:hover { + @include elevation-level1; + filter: brightness(96%); + } + } + + &:active { + filter: brightness(92%); + } + + &:focus-visible { + outline: none; + filter: brightness(88%); + } + } + } + + // Outlined variant + &-outlined { + background: var(--md-sys-color-surface); + color: var(--md-sys-color-primary); + @include outline; + + .sd-split_button-divider { + background-color: color-mix( + in srgb, + var(--md-sys-color-primary) 12%, + transparent + ); + } + + button { + background: transparent; + color: inherit; + + @media (any-hover: hover) { + &:hover { + filter: brightness(96%); + } + } + + &:active { + filter: brightness(92%); + } + + &:focus-visible { + outline: none; + filter: brightness(88%); + } + } + } + + // Menu positioning + &-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 1000; + } +} From 7ae2a0c7b3120317835602379240975e4364431e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 11:42:39 +0000 Subject: [PATCH 3/3] Add Space key support for better keyboard accessibility Co-authored-by: YieldRay <24633623+YieldRay@users.noreply.github.com> --- src/components/split-button/SplitButton.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/split-button/SplitButton.tsx b/src/components/split-button/SplitButton.tsx index ea87a84..4fb1218 100644 --- a/src/components/split-button/SplitButton.tsx +++ b/src/components/split-button/SplitButton.tsx @@ -113,7 +113,11 @@ export const SplitButton = forwardRef< className="sd-split_button-leading" rippleColor={rippleColor} onClick={() => !disabled && onAction?.()} - onKeyDown={(e) => !disabled && e.key === 'Enter' && onAction?.()} + onKeyDown={(e) => + !disabled && + (e.key === 'Enter' || e.key === ' ') && + onAction?.() + } data-sd-disabled={disabled} aria-disabled={disabled} aria-label={ariaLabel} @@ -135,7 +139,9 @@ export const SplitButton = forwardRef< rippleColor={rippleColor} onClick={() => !disabled && handleMenuToggle()} onKeyDown={(e) => - !disabled && e.key === 'Enter' && handleMenuToggle() + !disabled && + (e.key === 'Enter' || e.key === ' ') && + handleMenuToggle() } data-sd-disabled={disabled} data-sd-selected={selected}