Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Nov 2, 2025

Implements Material Design 3 Split Button component with leading action button and trailing menu button separated by a divider.

Implementation

Component structure

  • Leading button: supports icon, label, or both
  • Trailing button: menu toggle with dropdown icon
  • 2dp divider with semi-transparent color-mix separator
  • Controlled/uncontrolled menu state management

Variants & sizes

  • 4 color variants: elevated, filled, tonal, outlined
  • 5 size options: xs (24px), s (32px), m (40px), l (48px), xl (56px)

M3 spec compliance

  • Inner corner radius: 4dp (XS/S/M), 8dp (L), 12dp (XL)
  • Menu icon offset when unselected: -1dp to -6dp based on size
  • State layers for hover/focus/pressed states
  • Ripple effects matching variant color schemes

Accessibility

  • ARIA labels, aria-disabled, and aria-pressed attributes
  • Keyboard support for Enter and Space keys

Usage

import { SplitButton, Menu, MenuItem } from 'soda-material'

<SplitButton
  variant="filled"
  size="m"
  leadingIcon={<Icon path={mdiContentSave} />}
  onAction={() => save()}
  menu={
    <Menu>
      <MenuItem>Save as...</MenuItem>
      <MenuItem>Save copy</MenuItem>
    </Menu>
  }
>
  Save
</SplitButton>

Screenshots

Filled variant with label:
Split Button - Filled

With leading icon:
Split Button - With Icon

Storybook documentation:
Split Button - Documentation

Reference

M3 spec: https://m3.material.io/components/split-button/specs

Original prompt

This section details on the original issue you should resolve

<issue_title>Split buttons</issue_title>
<issue_description># Split buttons
Split buttons open a menu to give people more options related to an action

  • Use to show an action with a menu of related actions
  • Same size range as buttons and icon buttons: XS, S, M, L, XL

Specs: https://m3.material.io/components/split-button/specs

Types

1 type of split button.

Type Original M3 M3 Expressive
Split button -- Available

Configurations

4 colors and 5 sizes of split buttons.

  1. Color configurations: Elevated, filled, tonal, outlined
  2. Size configurations: XS, S, M, L, XL
Category Configuration Original M3 M3 Expressive
Size XS, S, M, L, XL -- Available
Color Elevated, filled, tonal, outlined -- Available

Tokens & specs

Use the table's menu to select a token set. Split button token sets are organized by size. Learn about design tokens


Anatomy

4 elements of a split button.

  1. Leading button
  2. Icon
  3. Label text
  4. Trailing button

The leading button in split buttons can have an icon, label text, or both. The trailing button should always have a menu icon.

3 customizations of the leading button in the split button.

  1. Label + icon
  2. Label
  3. Icon

Color

Color values are implemented through design tokens. Design tokens are the building blocks of all UI elements. The same tokens are used in designs, tools, and code. More on tokens.

Split buttons use the same color schemes as standard buttons. More on buttons. However, unlike toggle buttons, the split button color doesn’t change when selected—only a state layer is applied.

Split buttons use the same colors and state layers as buttons, shown in the following token module. Go to buttons for more details.

4 color roles of the split button when unselected and selected in light and dark theme.

A: Unselected, B: Selected trailing icon

  1. Elevated
  2. Filled
  3. Tonal
  4. Outlined

States

States are visual representations used to communicate the status of a component or an interactive element. More on states

Split button states use the same colors and state layers as buttons and icon buttons. Go to those specs for details.

Leading button shape

The inner corners change shape for hovered, focused, and pressed states.

5 states of the leading button in the split button.

  1. Enabled
  2. Disabled
  3. Hovered
  4. Focused
  5. Pressed, pressed with focus

Trailing button shape

The inner corners change shape for hovered, focused, and pressed states, and the icon becomes centered when selected.

6 states of the trailing menu button in the split button.

  1. Enabled
  2. Disabled
  3. Hovered
  4. Focused
  5. Pressed, pressed with focus
  6. Selected, selected with focus

Measurements

Text and icons are optically centered when the buttons are asymmetrical. They’re centered normally when symmetrical.

Padding and size measurements of the split button.

Menu icon offset when unselected:

  1. XS: -1dp from center
  2. S: -1dp from center
  3. M: -2dp from center
  4. L: -3dp from center
  5. XL: -6dp from center

The inner corner radius changes depending on button sizing. The space should always be 2dp.

![Inner padding and inner corner measurements of the...


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Summary by CodeRabbit

  • New Features

    • Introduced a new SplitButton component supporting multiple visual variants (filled, elevated, tonal, outlined), responsive sizes (xs through xl), optional leading icons, and integrated menu toggle capabilities.
  • Documentation

    • Added comprehensive Storybook documentation with stories for all variants, sizes, icon configurations, and accessibility features including disabled states.

✏️ Tip: You can customize this high-level summary in your review settings.

Copilot AI and others added 2 commits November 2, 2025 11:40
Co-authored-by: YieldRay <24633623+YieldRay@users.noreply.github.com>
Co-authored-by: YieldRay <24633623+YieldRay@users.noreply.github.com>
Copilot AI changed the title [WIP] Add split buttons component with configurations Add Split Button component Nov 2, 2025
Copilot AI requested a review from YieldRay November 2, 2025 11:47
@YieldRay YieldRay closed this Nov 19, 2025
@YieldRay YieldRay reopened this Dec 8, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 8, 2025

Walkthrough

Introduces a new SplitButton component with Material Design 3 compliance, supporting four color variants (outlined, filled, elevated, tonal), five sizes (xs, s, m, l, xl), optional leading icons, labels, and integrated menu rendering. Includes comprehensive styling with per-variant and per-size configurations, accessibility features, and Storybook documentation.

Changes

Cohort / File(s) Summary
Core Component
src/components/split-button/SplitButton.tsx, src/components/split-button/index.ts
New SplitButton component (forwardRef) rendering a two-part button: leading action button with optional icon and label, divider, and trailing menu-toggle button. Manages controlled/uncontrolled selection state, keyboard activation (Enter/Space), ripple color computation, and menu rendering. Exposes aria-label and aria-label-menu for accessibility.
Styling
src/components/split-button/split-button.scss
Comprehensive SCSS module with base container and button styling, per-size configurations (xs–xl) including height, padding, border-radius, font, and icon sizing. Four visual variants (elevated, filled, tonal, outlined) with color tokens, dividers, and interactive state handling (hover, active, focus-visible, selected). Menu icon offset logic by size and menu positioning utility.
Documentation & Stories
src/components/split-button/SplitButton.stories.tsx
Storybook stories showcasing variants (Filled, Elevated, Tonal, Outlined), icons (WithLeadingIcon, IconOnly), all five sizes (SizeXS–SizeXL), and disabled state. Includes SampleMenu composite and Storybook meta configuration with argTypes for variant, size, and disabled controls.
Public API Export
src/components/index.ts
Added module export: export * from './split-button' to expose SplitButton in public API.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • SplitButton.tsx logic: Verify state management (controlled vs. uncontrolled fallback), keyboard event handling (Enter/Space activation), ripple color computation per variant, and menu rendering logic
  • Accessibility implementation: Confirm aria-label and aria-label-menu attributes, aria-pressed state on trailing button, and keyboard activation support
  • split-button.scss coverage: Validate all five size variants, four color variants, per-size icon offsets, divider styling, inner corner radius transitions, and interactive state visual continuity
  • Story completeness: Ensure all story variants and size configurations align with design specifications and component capabilities

Poem

🐰 A button split in two, so neat,
With menu magic, oh so sweet!
Five sizes, four styled hues,
Accessible with keyboard clues—
Your UI now has choice to choose! ✨

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: adding a new Split Button component to the codebase.
Linked Issues check ✅ Passed The implementation fulfills all primary coding objectives from issue #9: supports all four color variants (elevated, filled, tonal, outlined), all five sizes (XS, S, M, L, XL), required anatomy with leading/trailing buttons, controlled/uncontrolled menu state, M3 spec measurements, and accessibility features (ARIA labels, keyboard support).
Out of Scope Changes check ✅ Passed All changes are directly related to adding the Split Button component as specified in issue #9; the changes include the component implementation, styles, Storybook stories, and index exports with no unrelated modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch copilot/add-split-buttons-component

Comment @coderabbitai help to get the list of available commands and usage tips.

@YieldRay
Copy link
Owner

YieldRay commented Dec 8, 2025

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Dec 8, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/components/split-button/split-button.scss (1)

110-134: Consolidate duplicate selectors for cleaner SCSS.

The .sd-split_button-leading and .sd-split_button-trailing selectors appear twice within the same size block (lines 110-113 then 121-124 for leading, lines 115-118 then 126-129 for trailing). This pattern repeats across all size configurations.

Consider merging the padding and border-radius declarations into single selector blocks:

     &-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 {
+            padding-left: 0.25rem;
+            padding-right: 0.25rem;
             border-top-left-radius: 4px;
             border-bottom-left-radius: 4px;
         }
src/components/split-button/SplitButton.stories.tsx (1)

30-36: Consider using a function for SampleMenu to avoid shared instance issues.

SampleMenu is defined as a single React element instance and reused across all stories. While this works for static content, if the Menu or MenuItem components maintain internal state, sharing the same instance could cause unexpected behavior in Storybook.

-const SampleMenu = (
+const SampleMenu = () => (
     <Menu>
         <MenuItem>Option 1</MenuItem>
         <MenuItem>Option 2</MenuItem>
         <MenuItem>Option 3</MenuItem>
     </Menu>
 )

 // Update usage in stories:
 export const Filled: Story = {
     args: {
         variant: 'filled',
         size: 'm',
         children: 'Save',
         onAction: () => console.log('Action clicked'),
-        menu: SampleMenu,
+        menu: <SampleMenu />,
     },
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 76aae77 and 7ae2a0c.

📒 Files selected for processing (5)
  • src/components/index.ts (1 hunks)
  • src/components/split-button/SplitButton.stories.tsx (1 hunks)
  • src/components/split-button/SplitButton.tsx (1 hunks)
  • src/components/split-button/index.ts (1 hunks)
  • src/components/split-button/split-button.scss (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/components/split-button/SplitButton.tsx (3)
src/utils/type.ts (1)
  • ExtendProps (4-7)
src/ripple/ripple-color.ts (1)
  • getReversedRippleColor (13-16)
src/ripple/Ripple.tsx (1)
  • Ripple (30-57)
src/components/split-button/SplitButton.stories.tsx (3)
src/components/split-button/SplitButton.tsx (1)
  • SplitButton (11-171)
src/components/menu/Menu.tsx (1)
  • Menu (11-25)
src/components/menu/MenuItem.tsx (1)
  • MenuItem (18-63)
🔇 Additional comments (5)
src/components/split-button/SplitButton.tsx (1)

83-96: LGTM on controlled/uncontrolled state management.

The pattern for handling both controlled (selected prop) and uncontrolled (internal useState) states is implemented correctly. The handleMenuToggle function properly updates internal state only when uncontrolled, and always calls onMenuToggle when provided.

src/components/index.ts (1)

23-23: LGTM!

The export follows the established alphabetical ordering and consistent pattern with other component exports.

src/components/split-button/index.ts (1)

1-1: LGTM!

Standard barrel export pattern consistent with other component modules.

src/components/split-button/SplitButton.stories.tsx (1)

89-98: Good accessibility practice with aria-label for icon-only variant.

The IconOnly story correctly includes an aria-label prop to ensure screen reader users understand the button's purpose when there's no visible text label.

Consider adding additional stories to demonstrate:

  • Controlled selected state with onMenuToggle
  • Custom menuIcon prop
src/components/split-button/split-button.scss (1)

311-347: The elevation and outline mixins are properly available. The @mixin elevation-level1, @mixin elevation-level2, and @mixin outline are imported centrally in src/style/index.scss via @import '@/style/elevation' and @import '@/style/tokens/baseline', making them available globally to all component SCSS files including split-button.scss through Vite's build system.

Comment on lines +341 to +345
&:focus-visible {
outline: none;
opacity: unset;
filter: brightness(90%);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ensure focus indicators meet accessibility requirements.

Setting outline: none on :focus-visible removes the default focus ring. While filter: brightness() provides some visual feedback, it may not provide sufficient contrast for all users, especially those relying on keyboard navigation.

Consider adding a more visible focus indicator that meets WCAG 2.1 guidelines (3:1 contrast ratio for focus indicators).

             &:focus-visible {
-                outline: none;
-                opacity: unset;
-                filter: brightness(90%);
+                outline: 2px solid var(--md-sys-color-primary);
+                outline-offset: 2px;
+                opacity: unset;
             }
📝 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
&:focus-visible {
outline: none;
opacity: unset;
filter: brightness(90%);
}
&:focus-visible {
outline: 2px solid var(--md-sys-color-primary);
outline-offset: 2px;
opacity: unset;
}
🤖 Prompt for AI Agents
In src/components/split-button/split-button.scss around lines 341–345, the
:focus-visible rule removes the default outline which harms keyboard
accessibility; replace the outline:none with an explicit high‑contrast focus
indicator (e.g., set a 2px solid outline or a visible box-shadow with an
outline-offset) that meets WCAG 2.1 3:1 contrast ratio, keep or combine the
existing filter: brightness(90%) if desired, and ensure the chosen
color/box-shadow has sufficient contrast against the background and works with
the component’s border-radius so keyboard focus is always clearly visible.

Comment on lines +110 to +131
<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>
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.

Comment on lines +166 to +168
{menu && selected && (
<div className="sd-split_button-menu">{menu}</div>
)}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split buttons

2 participants