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..70f9693 --- /dev/null +++ b/src/components/split-button/SplitButton.stories.tsx @@ -0,0 +1,250 @@ +import { mdiAccount, mdiContentSave } from '@mdi/js' +import Icon from '@mdi/react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useState } from 'react' +import { SplitButton } from '.' + +const meta: Meta = { + title: 'components/Button/SplitButton', + component: SplitButton, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + argTypes: { + disabled: { + type: 'boolean', + options: [false, true], + control: { type: 'radio' }, + }, + variant: { + control: { type: 'select' }, + options: ['elevated', 'filled', 'tonal', 'outlined'], + }, + size: { + control: { type: 'select' }, + options: ['XS', 'S', 'M', 'L', 'XL'], + }, + }, +} + +export default meta +type Story = StoryObj + +export const Elevated: Story = { + args: { + variant: 'elevated', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const Filled: Story = { + args: { + variant: 'filled', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const Tonal: Story = { + args: { + variant: 'tonal', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const Outlined: Story = { + args: { + variant: 'outlined', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const WithIcon: Story = { + args: { + variant: 'filled', + icon: , + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const IconOnly: Story = { + args: { + variant: 'filled', + icon: , + onClick: () => console.log('Action clicked'), + onMenuClick: () => console.log('Menu clicked'), + 'aria-label': 'User actions', + }, +} + +export const SizeXS: Story = { + args: { + variant: 'filled', + size: 'XS', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const SizeS: Story = { + args: { + variant: 'filled', + size: 'S', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const SizeM: Story = { + args: { + variant: 'filled', + size: 'M', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const SizeL: Story = { + args: { + variant: 'filled', + size: 'L', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const SizeXL: Story = { + args: { + variant: 'filled', + size: 'XL', + label: 'Save', + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const Disabled: Story = { + args: { + variant: 'filled', + label: 'Save', + disabled: true, + onClick: () => console.log('Save clicked'), + onMenuClick: () => console.log('Menu clicked'), + }, +} + +export const Interactive: Story = { + render: () => { + const [menuOpen, setMenuOpen] = useState(false) + return ( +
+ } + onClick={() => console.log('Save clicked')} + onMenuClick={() => { + setMenuOpen(!menuOpen) + console.log('Menu clicked') + }} + menuSelected={menuOpen} + /> + {menuOpen && ( +
+ Menu is open (click menu button to toggle) +
+ )} +
+ ) + }, +} + +export const AllVariants: Story = { + render: () => ( +
+ console.log('Elevated clicked')} + onMenuClick={() => console.log('Elevated menu clicked')} + /> + console.log('Filled clicked')} + onMenuClick={() => console.log('Filled menu clicked')} + /> + console.log('Tonal clicked')} + onMenuClick={() => console.log('Tonal menu clicked')} + /> + console.log('Outlined clicked')} + onMenuClick={() => console.log('Outlined menu clicked')} + /> +
+ ), +} + +export const AllSizes: Story = { + render: () => ( +
+ console.log('XS clicked')} + onMenuClick={() => console.log('XS menu clicked')} + /> + console.log('S clicked')} + onMenuClick={() => console.log('S menu clicked')} + /> + console.log('M clicked')} + onMenuClick={() => console.log('M menu clicked')} + /> + console.log('L clicked')} + onMenuClick={() => console.log('L menu clicked')} + /> + console.log('XL clicked')} + onMenuClick={() => console.log('XL menu clicked')} + /> +
+ ), +} diff --git a/src/components/split-button/SplitButton.tsx b/src/components/split-button/SplitButton.tsx new file mode 100644 index 0000000..0ba6909 --- /dev/null +++ b/src/components/split-button/SplitButton.tsx @@ -0,0 +1,150 @@ +import './split-button.scss' +import clsx from 'clsx' +import { forwardRef } 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 + /** + * Icon to display before the label in the leading button + */ + icon?: React.ReactNode + /** + * Label text for the leading button + */ + label?: React.ReactNode + /** + * Click handler for the leading (action) button + */ + onClick?: VoidFunction + /** + * Click handler for the trailing (menu) button + */ + onMenuClick?: VoidFunction + /** + * Menu icon for the trailing button + * @default chevron-down icon + */ + menuIcon?: React.ReactNode + /** + * Whether the menu is currently open/selected + */ + menuSelected?: boolean + /** + * Accessible label for the leading button + */ + 'aria-label'?: string + /** + * Accessible label for the trailing menu button + */ + 'aria-label-menu'?: string + }, + HTMLDivElement + > +>(function SplitButton( + { + variant = 'filled', + size = 'M', + disabled, + icon, + label, + onClick, + onMenuClick, + menuIcon, + menuSelected = false, + className, + 'aria-label': ariaLabel, + 'aria-label-menu': ariaLabelMenu, + ...props + }, + ref, +) { + // Default chevron down icon (simple SVG implementation) + const defaultMenuIcon = ( + + + + ) + + return ( +
+ !disabled && onClick?.()} + onKeyDown={(e) => !disabled && e.key === 'Enter' && onClick?.()} + data-sd-disabled={disabled} + aria-disabled={disabled} + aria-label={ariaLabel} + > +
+ {icon && ( +
{icon}
+ )} + {label && ( +
{label}
+ )} +
+
+
+ !disabled && onMenuClick?.()} + onKeyDown={(e) => + !disabled && e.key === 'Enter' && onMenuClick?.() + } + data-sd-disabled={disabled} + data-sd-selected={menuSelected} + aria-disabled={disabled} + aria-expanded={menuSelected} + aria-haspopup="menu" + aria-label={ariaLabelMenu || 'Open menu'} + > +
+ {menuIcon || defaultMenuIcon} +
+
+
+ ) +}) 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..65a2fb3 --- /dev/null +++ b/src/components/split-button/split-button.scss @@ -0,0 +1,437 @@ +// https://m3.material.io/components/split-button/specs + +.sd-split_button { + display: inline-flex; + align-items: stretch; + position: relative; + vertical-align: middle; + user-select: none; + -webkit-tap-highlight-color: transparent; + + // Base button styles + button { + border: none; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 500; + font-size: 14px; + line-height: 20px; + overflow: hidden; + } + + // Divider between buttons (2dp spacing per spec) + &-divider { + width: 2px; + background: transparent; + } + + // Content wrapper for icon and label + &-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + &-icon { + display: inline-flex; + align-items: center; + justify-content: center; + svg { + width: 20px; + height: 20px; + } + } + + &-label { + display: inline-flex; + align-items: center; + white-space: nowrap; + } + + &-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.2s; + svg { + width: 20px; + height: 20px; + } + } + + // Disabled state + &[data-sd-disabled='true'] { + pointer-events: none; + button { + color: var(--md-sys-color-on-surface); + opacity: 0.38; + pointer-events: none; + } + } + + // Size configurations - XS + &-XS { + height: 24px; + border-radius: 12px; + + button { + height: 24px; + } + + .sd-split_button-leading { + min-width: 40px; + padding: 0 12px; + border-radius: 12px 0 0 12px; + } + + .sd-split_button-trailing { + width: 24px; + padding: 0; + border-radius: 0 12px 12px 0; + + // Menu icon offset when unselected: -1dp from center + &:not([data-sd-selected='true']) .sd-split_button-menu-icon { + transform: translateX(-1px); + } + } + + // Inner corner radius: 4dp for XS + .sd-split_button-leading { + &:hover, + &:focus-visible, + &:active { + border-radius: 12px 4px 4px 12px; + } + } + + .sd-split_button-trailing { + &:hover, + &:focus-visible, + &:active { + border-radius: 4px 12px 12px 4px; + } + } + } + + // Size configurations - S + &-S { + height: 32px; + border-radius: 16px; + + button { + height: 32px; + } + + .sd-split_button-leading { + min-width: 48px; + padding: 0 16px; + border-radius: 16px 0 0 16px; + } + + .sd-split_button-trailing { + width: 32px; + padding: 0; + border-radius: 0 16px 16px 0; + + // Menu icon offset when unselected: -1dp from center + &:not([data-sd-selected='true']) .sd-split_button-menu-icon { + transform: translateX(-1px); + } + } + + // Inner corner radius: 4dp for S + .sd-split_button-leading { + &:hover, + &:focus-visible, + &:active { + border-radius: 16px 4px 4px 16px; + } + } + + .sd-split_button-trailing { + &:hover, + &:focus-visible, + &:active { + border-radius: 4px 16px 16px 4px; + } + } + } + + // Size configurations - M (default) + &-M { + height: 40px; + border-radius: 20px; + + button { + height: 40px; + } + + .sd-split_button-leading { + min-width: 56px; + padding: 0 20px; + border-radius: 20px 0 0 20px; + } + + .sd-split_button-trailing { + width: 40px; + padding: 0; + border-radius: 0 20px 20px 0; + + // Menu icon offset when unselected: -2dp from center + &:not([data-sd-selected='true']) .sd-split_button-menu-icon { + transform: translateX(-1px); + } + } + + // Inner corner radius: 4dp for M + .sd-split_button-leading { + &:hover, + &:focus-visible, + &:active { + border-radius: 20px 4px 4px 20px; + } + } + + .sd-split_button-trailing { + &:hover, + &:focus-visible, + &:active { + border-radius: 4px 20px 20px 4px; + } + } + } + + // Size configurations - L + &-L { + height: 48px; + border-radius: 24px; + + button { + height: 48px; + } + + .sd-split_button-leading { + min-width: 64px; + padding: 0 24px; + border-radius: 24px 0 0 24px; + } + + .sd-split_button-trailing { + width: 48px; + padding: 0; + border-radius: 0 24px 24px 0; + + // Menu icon offset when unselected: -3dp from center + &:not([data-sd-selected='true']) .sd-split_button-menu-icon { + transform: translateX(-3px); + } + } + + // Inner corner radius: 8dp for L + .sd-split_button-leading { + &:hover, + &:focus-visible, + &:active { + border-radius: 24px 8px 8px 24px; + } + } + + .sd-split_button-trailing { + &:hover, + &:focus-visible, + &:active { + border-radius: 8px 24px 24px 8px; + } + } + } + + // Size configurations - XL + &-XL { + height: 56px; + border-radius: 28px; + + button { + height: 56px; + } + + .sd-split_button-leading { + min-width: 72px; + padding: 0 28px; + border-radius: 28px 0 0 28px; + } + + .sd-split_button-trailing { + width: 56px; + padding: 0; + border-radius: 0 28px 28px 0; + + // Menu icon offset when unselected: -6dp from center + &:not([data-sd-selected='true']) .sd-split_button-menu-icon { + transform: translateX(-6px); + } + } + + // Inner corner radius: 12dp for XL + .sd-split_button-leading { + &:hover, + &:focus-visible, + &:active { + border-radius: 28px 12px 12px 28px; + } + } + + .sd-split_button-trailing { + &:hover, + &:focus-visible, + &:active { + border-radius: 12px 28px 28px 12px; + } + } + } + + // Elevated variant + &-elevated { + .sd-split_button-leading, + .sd-split_button-trailing { + background: var(--md-sys-color-surface-container-low); + color: var(--md-sys-color-primary); + @include elevation-level1; + } + + .sd-split_button-divider { + background: var(--md-sys-color-outline-variant); + } + + .sd-split_button-leading, + .sd-split_button-trailing { + @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%); + } + } + + .sd-split_button-trailing[data-sd-selected='true'] { + @include background-focus(var(--md-sys-color-primary)); + } + } + + // Filled variant + &-filled { + .sd-split_button-leading, + .sd-split_button-trailing { + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + } + + .sd-split_button-divider { + background: var(--md-sys-color-on-primary); + opacity: 0.12; + } + + .sd-split_button-leading, + .sd-split_button-trailing { + @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%); + } + } + + .sd-split_button-trailing[data-sd-selected='true'] { + @include background-focus(var(--md-sys-color-on-primary)); + } + } + + // Tonal variant + &-tonal { + .sd-split_button-leading, + .sd-split_button-trailing { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + } + + .sd-split_button-divider { + background: var(--md-sys-color-on-secondary-container); + opacity: 0.12; + } + + .sd-split_button-leading, + .sd-split_button-trailing { + @media (any-hover: hover) { + &:hover { + @include elevation-level1; + filter: brightness(96%); + } + } + &:active { + filter: brightness(92%); + } + &:focus-visible { + outline: none; + filter: brightness(88%); + } + } + + .sd-split_button-trailing[data-sd-selected='true'] { + @include background-focus(var(--md-sys-color-on-secondary-container)); + } + } + + // Outlined variant + &-outlined { + .sd-split_button-leading, + .sd-split_button-trailing { + background: var(--md-sys-color-surface); + color: var(--md-sys-color-primary); + @include outline; + } + + .sd-split_button-divider { + background: var(--md-sys-color-outline); + } + + .sd-split_button-leading, + .sd-split_button-trailing { + @media (any-hover: hover) { + &:hover { + filter: brightness(96%); + } + } + &:active { + filter: brightness(92%); + } + &:focus-visible { + outline: 1px solid var(--md-sys-color-outline); + filter: brightness(88%); + } + } + + .sd-split_button-trailing[data-sd-selected='true'] { + @include background-focus(var(--md-sys-color-primary)); + } + } +}