diff --git a/packages/mobile/src/core/componentConfig.ts b/packages/mobile/src/core/componentConfig.ts
index 2c87464323..197b1fa9a8 100644
--- a/packages/mobile/src/core/componentConfig.ts
+++ b/packages/mobile/src/core/componentConfig.ts
@@ -1,4 +1,5 @@
import type { AccordionBaseProps } from '../accordion/Accordion';
+import type { AccordionItemBaseProps } from '../accordion/AccordionItem';
import type { ComboboxBaseProps } from '../alpha/combobox/Combobox';
import type { SelectBaseProps } from '../alpha/select/types';
import type { SelectChipBaseProps } from '../alpha/select-chip/SelectChip';
@@ -90,6 +91,7 @@ export type ConfigResolver = Partial
| ((props: P) => Partial
);
*/
export type ComponentConfig = {
Accordion?: ConfigResolver;
+ AccordionItem?: ConfigResolver;
Alert?: ConfigResolver;
TabbedChips?: ConfigResolver;
Avatar?: ConfigResolver;
diff --git a/packages/mobile/src/tabs/SegmentedTabs.tsx b/packages/mobile/src/tabs/SegmentedTabs.tsx
index 46b8f7d17f..e0eab272de 100644
--- a/packages/mobile/src/tabs/SegmentedTabs.tsx
+++ b/packages/mobile/src/tabs/SegmentedTabs.tsx
@@ -1,4 +1,4 @@
-import React, { memo } from 'react';
+import React, { memo, useMemo } from 'react';
import type { StyleProp, View, ViewStyle } from 'react-native';
import { useComponentConfig } from '../hooks/useComponentConfig';
@@ -7,13 +7,24 @@ import { SegmentedTab } from './SegmentedTab';
import { SegmentedTabsActiveIndicator } from './SegmentedTabsActiveIndicator';
import { Tabs, type TabsBaseProps, type TabsProps } from './Tabs';
+const equalWidthTabContainerStyle: ViewStyle = { flex: 1 };
+const equalWidthTabStyle: ViewStyle = { alignSelf: 'stretch' };
+
// We do Partial/Pick to allow TabComponent and TabsActiveIndicatorComponent to be optional
// We grab 'tabs' from the Omit allowing it to stay required
export type SegmentedTabsBaseProps = Partial<
Pick, 'TabComponent' | 'TabsActiveIndicatorComponent'>
> &
- Omit, 'TabComponent' | 'TabsActiveIndicatorComponent' | 'styles'>;
+ Omit, 'TabComponent' | 'TabsActiveIndicatorComponent' | 'styles'> & {
+ /**
+ * When true, each tab stretches to an equal share of the component width and the component
+ * fills its parent. This is the correct way to achieve distributed tab layout — do NOT use
+ * `justifyContent` for this, as it breaks the active indicator position calculation.
+ * @default false
+ */
+ equalWidth?: boolean;
+ };
export type SegmentedTabsProps = SegmentedTabsBaseProps &
Partial, 'TabComponent' | 'TabsActiveIndicatorComponent'>> &
@@ -22,7 +33,7 @@ export type SegmentedTabsProps = SegmentedTabsBas
styles?: {
/** Root container element */
root?: StyleProp;
- /** Container element wrapping each tab */
+ /** Wrapper View around each tab — use `{ flex: 1 }` for equal-width distribution */
tabContainer?: StyleProp;
/** Tab element */
tab?: StyleProp;
@@ -49,16 +60,31 @@ const SegmentedTabsComponent = memo(
activeBackground = 'bgInverse',
background = 'bgSecondary',
borderRadius = 700,
+ equalWidth,
+ alignSelf = equalWidth ? 'stretch' : undefined,
+ styles,
...props
} = mergedProps;
+
+ const resolvedStyles = useMemo(() => {
+ if (!equalWidth) return styles;
+ return {
+ ...styles,
+ tabContainer: [equalWidthTabContainerStyle, styles?.tabContainer],
+ tab: [equalWidthTabStyle, styles?.tab],
+ };
+ }, [equalWidth, styles]);
+
return (
);
diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx
index 8a9d5bc9db..96ce37693a 100644
--- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx
+++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx
@@ -151,6 +151,28 @@ describe('SegmentedTabs', () => {
expect(screen.getByTestId('custom-indicator')).toBeTruthy();
});
+ it('renders with equalWidth without error', () => {
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId(TEST_ID)).toBeTruthy();
+ });
+
+ it('equalWidth alignSelf can be overridden', () => {
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId(TEST_ID)).toBeTruthy();
+ });
+
it('forwards ref correctly', () => {
const ref = React.createRef();
render(
diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md
index 734a33d5df..e545388c2b 100644
--- a/packages/web/CHANGELOG.md
+++ b/packages/web/CHANGELOG.md
@@ -8,6 +8,18 @@ All notable changes to this project will be documented in this file.
+## 9.15.0 (8/11/2026 PST)
+
+#### 🚀 Updates
+
+[[#839](https://github.com/coinbase/cds/pull/839)]
+
+- Add new prop: variant to Switch component.
+- Add new prop: showItemSeparators to Accordion component.
+- Add new prop: showHeaderBorder to AccordionItem component.
+- Add AccordionItem to ComponentConfig system.
+- Add new prop: equalWidth to SegmentedTabs component.
+
## 9.14.0 (8/11/2026 PST)
#### 🚀 Updates
diff --git a/packages/web/package.json b/packages/web/package.json
index 377864f2c8..06dd2f0857 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
- "version": "9.14.0",
+ "version": "9.15.0",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
diff --git a/packages/web/src/accordion/Accordion.tsx b/packages/web/src/accordion/Accordion.tsx
index 4fa4b68679..eb2c4589d7 100644
--- a/packages/web/src/accordion/Accordion.tsx
+++ b/packages/web/src/accordion/Accordion.tsx
@@ -1,23 +1,61 @@
-import React, { Children } from 'react';
+import React, { Children, memo } from 'react';
import {
AccordionProvider,
type AccordionProviderProps,
} from '@coinbase/cds-common/accordion/AccordionProvider';
+import type { ThemeVars } from '@coinbase/cds-common/core/theme';
import type { SharedProps } from '@coinbase/cds-common/types/SharedProps';
import { join } from '@coinbase/cds-common/utils/join';
+import { cx } from '../cx';
import { useComponentConfig } from '../hooks/useComponentConfig';
import { Divider } from '../layout/Divider';
import { VStack } from '../layout/VStack';
+import type { StylesAndClassNames } from '../types';
-export type AccordionBaseProps = SharedProps & AccordionProviderProps;
+/**
+ * Static class names for Accordion component parts.
+ * Use these selectors to target specific elements with CSS.
+ */
+export const accordionClassNames = {
+ /** Root element */
+ root: 'cds-Accordion',
+} as const;
-export type AccordionProps = AccordionBaseProps & { style?: React.CSSProperties };
+export type AccordionBaseProps = SharedProps &
+ AccordionProviderProps & {
+ /** Border radius of the accordion root container */
+ borderRadius?: ThemeVars.BorderRadius;
+ /**
+ * Show a divider between each accordion item.
+ * @default true
+ */
+ showItemSeparators?: boolean;
+ };
-export const Accordion = (_props: AccordionProps) => {
+export type AccordionProps = AccordionBaseProps &
+ StylesAndClassNames & {
+ className?: string;
+ style?: React.CSSProperties;
+ };
+
+export const Accordion = memo((_props: AccordionProps) => {
const mergedProps = useComponentConfig('Accordion', _props);
- const { activeKey, children, defaultActiveKey, onChange, setActiveKey, testID, style } =
- mergedProps;
+ const {
+ activeKey,
+ children,
+ defaultActiveKey,
+ onChange,
+ setActiveKey,
+ testID,
+ style,
+ styles,
+ className,
+ classNames,
+ borderRadius,
+ showItemSeparators = true,
+ } = mergedProps;
+
return (
{
onChange={onChange}
setActiveKey={setActiveKey}
>
-
- {join(Children.toArray(children), )}
+
+ {showItemSeparators ? join(Children.toArray(children), ) : children}
);
-};
+});
diff --git a/packages/web/src/accordion/AccordionHeader.tsx b/packages/web/src/accordion/AccordionHeader.tsx
index 2aa47a5ed3..5e21a68639 100644
--- a/packages/web/src/accordion/AccordionHeader.tsx
+++ b/packages/web/src/accordion/AccordionHeader.tsx
@@ -1,17 +1,21 @@
-import React, { forwardRef, memo, useCallback } from 'react';
+import React, { forwardRef, memo, useCallback, useMemo } from 'react';
import { useAccordionContext } from '@coinbase/cds-common/accordion/AccordionProvider';
import {
accordionIconHiddenRotate,
accordionIconVisibleRotate,
} from '@coinbase/cds-common/animation/accordion';
+import type { ThemeVars } from '@coinbase/cds-common/core/theme';
import { listHeight } from '@coinbase/cds-common/tokens/cell';
+import type { IconSize } from '@coinbase/cds-common/types/IconSize';
import type { SharedProps } from '@coinbase/cds-common/types/SharedProps';
+import type { PaddingProps } from '@coinbase/cds-common/types/SpacingProps';
import { css } from '@linaria/core';
+import type { CellSpacing } from '../cells/Cell';
import type { CollapsibleBaseProps } from '../collapsible/Collapsible';
import { useCellSpacing } from '../hooks/useCellSpacing';
import { Box } from '../layout/Box';
-import { HStack } from '../layout/HStack';
+import { HStack, type HStackBaseProps } from '../layout/HStack';
import { VStack } from '../layout/VStack';
import { AnimatedCaret } from '../motion/AnimatedCaret';
import { Pressable } from '../system/Pressable';
@@ -39,12 +43,18 @@ export type AccordionTitleBaseProps = {
tertiaryTitle?: string;
};
-export type AccordionIconBaseProps = Pick;
+export type AccordionIconBaseProps = Pick & {
+ /** Size of the caret icon.
+ * @default s
+ */
+ caretSize?: IconSize;
+};
export type AccordionHeaderBaseProps = SharedProps &
AccordionMediaBaseProps &
AccordionTitleBaseProps &
- AccordionIconBaseProps & {
+ AccordionIconBaseProps &
+ Pick & {
/**
* Callback function fired when the accordion item is clicked
*/
@@ -55,8 +65,26 @@ export type AccordionHeaderBaseProps = SharedProps &
* unless you want multiple items to be controlled at the same time.
*/
itemKey: string;
+ /**
+ * Background color of the header pressable.
+ * @default bg
+ */
+ background?: ThemeVars.Color;
};
+export type AccordionHeaderProps = AccordionHeaderBaseProps & {
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+const compactPaddingProps = (
+ paddingProps: Pick,
+): CellSpacing | undefined => {
+ const entries = Object.entries(paddingProps).filter(([, value]) => value !== undefined);
+ if (entries.length === 0) return undefined;
+ return Object.fromEntries(entries) as CellSpacing;
+};
+
const baseCss = css`
margin: 0;
`;
@@ -121,16 +149,17 @@ export const AccordionTitle = memo(({ title, subtitle, tertiaryTitle }: Accordio
type AccordionIconProps = AccordionIconBaseProps;
-export const AccordionIcon = memo(({ collapsed }: AccordionIconProps) => {
+export const AccordionIcon = memo(({ collapsed, caretSize = 's' }: AccordionIconProps) => {
return (
-
+
);
});
-type AccordionHeaderProps = AccordionHeaderBaseProps;
-
/**
* Renders a Pressable element to use as the header to an AccordionItem.
* Composes an Accordion Media, Title, and Icon.
@@ -147,11 +176,36 @@ export const AccordionHeader = memo(
media,
collapsed = false,
testID,
+ className,
+ style,
+ background = 'bg',
+ caretSize,
+ padding,
+ paddingX,
+ paddingY,
+ paddingTop,
+ paddingBottom,
+ paddingStart,
+ paddingEnd,
}: AccordionHeaderProps,
forwardedRef: React.ForwardedRef,
) => {
const { setActiveKey, activeKey } = useAccordionContext();
- const spacing = useCellSpacing();
+ const outerSpacing = useMemo(
+ () =>
+ compactPaddingProps({
+ padding,
+ paddingX,
+ paddingY,
+ paddingTop,
+ paddingBottom,
+ paddingStart,
+ paddingEnd,
+ }),
+ [padding, paddingX, paddingY, paddingTop, paddingBottom, paddingStart, paddingEnd],
+ );
+ // Header padding follows the same outer spacing rules as Cell.
+ const spacing = useCellSpacing({ outerSpacing });
const handleClick = useCallback(() => {
onClick?.(itemKey);
@@ -166,9 +220,11 @@ export const AccordionHeader = memo(
transparentWhileInactive
aria-controls={getAccordionPanelId(itemKey)}
aria-expanded={!collapsed} // a11y guideline: https://www.w3.org/TR/wai-aria-practices/#accordion
- background="bg"
+ background={background}
+ className={className}
id={getAccordionHeaderId(itemKey)}
onClick={handleClick}
+ style={style}
testID={testID}
width="100%"
>
@@ -181,7 +237,7 @@ export const AccordionHeader = memo(
>
{!!media && }
-
+
diff --git a/packages/web/src/accordion/AccordionItem.tsx b/packages/web/src/accordion/AccordionItem.tsx
index 152a073e4c..84410fd873 100644
--- a/packages/web/src/accordion/AccordionItem.tsx
+++ b/packages/web/src/accordion/AccordionItem.tsx
@@ -1,28 +1,57 @@
import React, { memo } from 'react';
import { useAccordionContext } from '@coinbase/cds-common/accordion/AccordionProvider';
+import type { ThemeVars } from '@coinbase/cds-common/core/theme';
import { accordionMinWidth } from '@coinbase/cds-common/tokens/accordion';
+import { cx } from '../cx';
+import { useComponentConfig } from '../hooks/useComponentConfig';
+import { Divider } from '../layout/Divider';
import { VStack } from '../layout/VStack';
+import type { StylesAndClassNames } from '../types';
import { AccordionHeader, type AccordionHeaderBaseProps } from './AccordionHeader';
import { AccordionPanel, type AccordionPanelBaseProps } from './AccordionPanel';
+/**
+ * Static class names for AccordionItem component parts.
+ * Use these selectors to target specific elements with CSS.
+ */
+export const accordionItemClassNames = {
+ /** Root element */
+ root: 'cds-AccordionItem',
+ /** Header pressable element */
+ header: 'cds-AccordionItem-header',
+ /** Collapsible panel container element */
+ panel: 'cds-AccordionItem-panel',
+} as const;
+
export type AccordionItemBaseProps = Omit &
Pick & {
headerRef?: React.RefObject;
panelRef?: React.RefObject;
- style?: React.CSSProperties;
+ /**
+ * Show a border between the header and panel.
+ * @default false
+ */
+ showHeaderBorder?: boolean;
+ /** Border radius of the accordion item root container */
+ borderRadius?: ThemeVars.BorderRadius;
};
-export type AccordionItemProps = AccordionItemBaseProps;
+export type AccordionItemProps = AccordionItemBaseProps &
+ StylesAndClassNames & {
+ className?: string;
+ style?: React.CSSProperties;
+ };
/**
* A component that represents a single item within an Accordion.
* It composes together an AccordionHeader and a collapsible AccordionPanel.
* Accepts a unique `itemKey` prop to uniquely identify one item from another within the same Accordion.
*/
-export const AccordionItem = memo(
- ({
+export const AccordionItem = memo((_props: AccordionItemProps) => {
+ const mergedProps = useComponentConfig('AccordionItem', _props);
+ const {
itemKey,
title,
subtitle,
@@ -35,33 +64,75 @@ export const AccordionItem = memo(
panelRef,
maxHeight,
style,
- }: AccordionItemProps) => {
- const { activeKey } = useAccordionContext();
- const collapsed = activeKey !== itemKey;
+ styles,
+ className,
+ classNames,
+ showHeaderBorder = false,
+ background,
+ caretSize,
+ borderRadius,
+ padding,
+ paddingX,
+ paddingY,
+ paddingTop,
+ paddingBottom,
+ paddingStart,
+ paddingEnd,
+ } = mergedProps;
+ const { activeKey } = useAccordionContext();
+ const collapsed = activeKey !== itemKey;
- return (
-
-
+
+ {showHeaderBorder ? (
+
-
- {children}
-
-
- );
- },
-);
+ ) : null}
+
+ {children}
+
+
+ );
+});
diff --git a/packages/web/src/accordion/AccordionPanel.tsx b/packages/web/src/accordion/AccordionPanel.tsx
index 477ce726a0..d627db1ad4 100644
--- a/packages/web/src/accordion/AccordionPanel.tsx
+++ b/packages/web/src/accordion/AccordionPanel.tsx
@@ -18,7 +18,10 @@ export type AccordionPanelBaseProps = SharedProps &
maxHeight?: CollapsibleBaseProps['maxHeight'];
};
-export type AccordionPanelProps = AccordionPanelBaseProps;
+export type AccordionPanelProps = AccordionPanelBaseProps & {
+ className?: string;
+ style?: React.CSSProperties;
+};
/**
* Renders a collapsible element to use as the primary content container for an AccordionItem.
@@ -33,14 +36,18 @@ export const AccordionPanel = memo(
itemKey,
testID,
maxHeight: maxHeightParam,
+ className,
+ style,
}: AccordionPanelProps,
forwardedRef: React.ForwardedRef,
) => {
return (
void;
@@ -227,6 +229,168 @@ describe('Accordion', () => {
expect(screen.getByTestId('mock-accordion')).toHaveStyle('padding: 20px');
expect(screen.getByTestId('mock-accordion-item1')).toHaveStyle('padding: 30px');
});
+
+ it('applies static class names to Accordion and AccordionItem elements', () => {
+ render();
+
+ const root = screen.getByTestId('mock-accordion');
+ expect(root).toHaveClass(accordionClassNames.root);
+
+ const item = screen.getByTestId('mock-accordion-item1');
+ expect(item).toHaveClass(accordionItemClassNames.root);
+ expect(item.querySelector(`.${accordionItemClassNames.header}`)).toBeInTheDocument();
+ expect(item.querySelector(`.${accordionItemClassNames.panel}`)).toBeInTheDocument();
+ });
+
+ it('applies styles prop to AccordionItem elements', () => {
+ render(
+
+
+
+
+ Accordion Content1
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('mock-accordion-item1')).toHaveStyle('border-width: 2px');
+ expect(screen.getByTestId('mock-accordion-item1-header')).toHaveStyle('opacity: 0.5');
+ });
+
+ it('renders a divider between header and panel when showHeaderBorder is true', () => {
+ render(
+
+
+
+
+ Accordion Content1
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('mock-accordion-item1-divider')).toBeInTheDocument();
+ });
+
+ it('forwards background, caretSize, and spacing props to the header', () => {
+ render(
+
+
+
+
+ Accordion Content1
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('mock-accordion-item1-header')).toBeInTheDocument();
+ expect(screen.getByText('Accordion #1')).toBeInTheDocument();
+ });
+
+ it('applies borderRadius to Accordion and AccordionItem roots', () => {
+ render(
+
+
+
+
+ Content
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('mock-accordion').className).toContain('200');
+ expect(screen.getByTestId('mock-accordion').className).toMatch(/hidden/i);
+ expect(screen.getByTestId('mock-accordion-item1').className).toContain('400');
+ expect(screen.getByTestId('mock-accordion-item1').className).toMatch(/hidden/i);
+ });
+
+ it('renders a separator between items by default, and can be disabled via showItemSeparators', () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('separator')).toBeInTheDocument();
+
+ rerender(
+
+
+
+
+ Content1
+
+
+
+
+ Content2
+
+
+
+ ,
+ );
+
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument();
+ });
+
+ it('applies AccordionItem defaults from ComponentConfigProvider', () => {
+ const config: ComponentConfig = {
+ AccordionItem: {
+ showHeaderBorder: true,
+ caretSize: 'l',
+ borderRadius: 400,
+ paddingX: 3,
+ paddingY: 1,
+ },
+ };
+
+ render(
+
+
+
+
+
+ Content
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('mock-accordion-item1').className).toContain('400');
+ expect(screen.getByTestId('mock-accordion-item1-divider')).toBeInTheDocument();
+ });
});
describe('controlled', () => {
diff --git a/packages/web/src/collapsible/Collapsible.tsx b/packages/web/src/collapsible/Collapsible.tsx
index 68ab93cc4b..75bffb50fd 100644
--- a/packages/web/src/collapsible/Collapsible.tsx
+++ b/packages/web/src/collapsible/Collapsible.tsx
@@ -4,9 +4,9 @@ import type { SharedProps } from '@coinbase/cds-common/types/SharedProps';
import type { PaddingProps } from '@coinbase/cds-common/types/SpacingProps';
import { m as motion } from 'framer-motion';
+import { cx } from '../cx';
import { useComponentConfig } from '../hooks/useComponentConfig';
import { Box, type BoxDefaultElement, type BoxProps } from '../layout/Box';
-import { HStack } from '../layout/HStack';
import { useCollapsibleMotionProps } from './useCollapsibleMotionProps';
@@ -44,7 +44,10 @@ export type CollapsibleBaseProps = SharedProps &
maxWidth?: BoxProps['maxWidth'];
};
-export type CollapsibleProps = CollapsibleBaseProps;
+export type CollapsibleProps = CollapsibleBaseProps & {
+ className?: string;
+ style?: React.CSSProperties;
+};
export const Collapsible = memo(
forwardRef((_props: CollapsibleProps, forwardedRef: React.ForwardedRef) => {
@@ -60,6 +63,8 @@ export const Collapsible = memo(
id,
role = 'region',
dangerouslyDisableOverflowHidden = false,
+ className,
+ style: styleProp,
// Spacing
padding,
paddingBottom,
@@ -100,8 +105,9 @@ export const Collapsible = memo(
}, [collapsed]);
const style = useMemo(() => {
- return isDisplayNone ? { ...motionStyle, display: 'none' } : motionStyle;
- }, [motionStyle, isDisplayNone]);
+ const nextStyle = isDisplayNone ? { ...motionStyle, display: 'none' } : motionStyle;
+ return styleProp ? { ...nextStyle, ...styleProp } : nextStyle;
+ }, [motionStyle, isDisplayNone, styleProp]);
return (
// TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility
@@ -110,7 +116,7 @@ export const Collapsible = memo(
...motionProps,
ref: forwardedRef,
'aria-labelledby': accessibilityLabelledBy,
- className: COMPONENT_STATIC_CLASSNAME,
+ className: cx(COMPONENT_STATIC_CLASSNAME, className),
'data-testid': testID,
id: id,
onAnimationComplete: handleAnimationComplete,
diff --git a/packages/web/src/controls/Switch.tsx b/packages/web/src/controls/Switch.tsx
index 1b3de8ba80..f5fbd0ede9 100644
--- a/packages/web/src/controls/Switch.tsx
+++ b/packages/web/src/controls/Switch.tsx
@@ -55,11 +55,27 @@ const thumbCss = css`
left: calc((var(--controlSize-switchHeight) - var(--controlSize-switchThumbSize)) / 2);
`;
+/** Semantic color variants for Switch, matching the semantics used by other control components. */
+export type SwitchVariant = 'primary' | 'positive' | 'negative';
+
+/** Track background color applied when Switch is checked, keyed by `variant`. */
+const switchVariantBackground: Record = {
+ primary: 'bgPrimary',
+ positive: 'bgPositive',
+ negative: 'bgNegative',
+};
+
export type SwitchBaseProps = ControlBaseProps & {
/** Sets the checked/active color of the control.
* @default bgPrimary
*/
controlColor?: ThemeVars.Color;
+ /**
+ * Semantic color variant applied to the track when checked. The `background` prop always
+ * takes priority over the background set by the variant, as does any custom `style`/`styles.track`.
+ * @default primary
+ */
+ variant?: SwitchVariant;
};
export type SwitchProps = SwitchBaseProps &
@@ -97,7 +113,10 @@ const SwitchWithRef = forwardRef(
disabled,
elevation,
controlColor,
- background = checked ? 'bgPrimary' : 'bgTertiary',
+ variant = 'primary',
+ // The variant only informs the track color while checked; the background prop is more
+ // specific and always wins when provided.
+ background = checked ? switchVariantBackground[variant] : 'bgTertiary',
borderColor,
borderRadius = 1000,
borderWidth,
diff --git a/packages/web/src/controls/__stories__/Switch.stories.tsx b/packages/web/src/controls/__stories__/Switch.stories.tsx
index b023d35e07..490204edb8 100644
--- a/packages/web/src/controls/__stories__/Switch.stories.tsx
+++ b/packages/web/src/controls/__stories__/Switch.stories.tsx
@@ -3,6 +3,7 @@ import { css } from '@linaria/core';
import { useTheme } from '../../hooks/useTheme';
import { Box } from '../../layout/Box';
+import { HStack } from '../../layout/HStack';
import { VStack } from '../../layout/VStack';
import { ThemeProvider } from '../../system/ThemeProvider';
import { defaultTheme } from '../../themes/defaultTheme';
@@ -58,6 +59,23 @@ export const CustomColors = () => {
);
};
+export const Variants = () => (
+
+
+ Primary
+
+
+ Positive
+
+
+ Negative
+
+
+ Background override
+
+
+);
+
export const DarkNormal = () => {
const [checked, setChecked] = useState(false);
return (
diff --git a/packages/web/src/controls/__tests__/Switch.test.tsx b/packages/web/src/controls/__tests__/Switch.test.tsx
index c2c0e7bb29..47b1a4e051 100644
--- a/packages/web/src/controls/__tests__/Switch.test.tsx
+++ b/packages/web/src/controls/__tests__/Switch.test.tsx
@@ -192,4 +192,50 @@ describe('Switch.test', () => {
expect(track.className).toContain('bgTertiary');
});
+
+ it('applies bgPositive background when variant is positive and checked', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('switch-track').className).toContain('bgPositive');
+ });
+
+ it('applies bgNegative background when variant is negative and checked', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('switch-track').className).toContain('bgNegative');
+ });
+
+ it('does not apply variant background when unchecked', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('switch-track').className).toContain('bgTertiary');
+ });
+
+ it('background prop takes priority over variant when checked', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('switch-track').className).toContain('bgWarning');
+ });
});
diff --git a/packages/web/src/core/componentConfig.ts b/packages/web/src/core/componentConfig.ts
index de5a1b230e..c7fd1f8b52 100644
--- a/packages/web/src/core/componentConfig.ts
+++ b/packages/web/src/core/componentConfig.ts
@@ -1,4 +1,5 @@
import type { AccordionBaseProps } from '../accordion/Accordion';
+import type { AccordionItemBaseProps } from '../accordion/AccordionItem';
import type { ComboboxBaseProps } from '../alpha/combobox/Combobox';
import type { SelectBaseProps } from '../alpha/select/types';
import type { SelectChipBaseProps } from '../alpha/select-chip/SelectChip';
@@ -103,6 +104,7 @@ export type ConfigResolver = Partial
| ((props: P) => Partial
);
*/
export type ComponentConfig = {
Accordion?: ConfigResolver;
+ AccordionItem?: ConfigResolver;
Alert?: ConfigResolver;
TabbedChips?: ConfigResolver;
Avatar?: ConfigResolver;
diff --git a/packages/web/src/tabs/SegmentedTabs.tsx b/packages/web/src/tabs/SegmentedTabs.tsx
index 53e36b8015..7dc66db6b9 100644
--- a/packages/web/src/tabs/SegmentedTabs.tsx
+++ b/packages/web/src/tabs/SegmentedTabs.tsx
@@ -1,4 +1,5 @@
-import React, { forwardRef, memo } from 'react';
+import React, { forwardRef, memo, useMemo } from 'react';
+import { css } from '@linaria/core';
import { cx } from '../cx';
import { useComponentConfig } from '../hooks/useComponentConfig';
@@ -7,13 +8,31 @@ import { SegmentedTab } from './SegmentedTab';
import { SegmentedTabsActiveIndicator } from './SegmentedTabsActiveIndicator';
import { Tabs, type TabsBaseProps, type TabsProps } from './Tabs';
+const equalWidthTabContainerCss = css`
+ flex: 1;
+`;
+
+const equalWidthTabCss = css`
+ align-self: stretch;
+`;
+
export type SegmentedTabsBaseProps = Partial<
Pick, 'TabComponent' | 'TabsActiveIndicatorComponent'>
> &
Omit<
TabsBaseProps,
'TabComponent' | 'TabsActiveIndicatorComponent' | 'styles' | 'classNames'
- >;
+ > & {
+ /**
+ * When true, each tab stretches to an equal share of the component width and the component
+ * fills its parent. Also defaults `width` to `100%` (unless a `width` is explicitly provided),
+ * since the component otherwise sizes to its content by default. This is the correct way to
+ * achieve distributed tab layout — do NOT use `justifyContent` for this, as it breaks the
+ * active indicator position calculation.
+ * @default false
+ */
+ equalWidth?: boolean;
+ };
export type SegmentedTabsProps = SegmentedTabsBaseProps &
Partial, 'TabComponent' | 'TabsActiveIndicatorComponent'>> &
@@ -62,24 +81,38 @@ const SegmentedTabsComponent = memo(
activeBackground = 'bgInverse',
background = 'bgSecondary',
borderRadius = 700,
+ equalWidth,
+ alignSelf = equalWidth ? 'stretch' : undefined,
+ width = equalWidth ? '100%' : undefined,
className,
classNames,
style,
styles,
...props
} = mergedProps;
+
+ const resolvedClassNames = useMemo(() => {
+ if (!equalWidth) return classNames;
+ return {
+ ...classNames,
+ tabContainer: cx(equalWidthTabContainerCss, classNames?.tabContainer),
+ tab: cx(equalWidthTabCss, classNames?.tab),
+ };
+ }, [equalWidth, classNames]);
+
return (
);
diff --git a/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx
index 92887b83a8..836501759f 100644
--- a/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx
+++ b/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx
@@ -244,6 +244,56 @@ describe('SegmentedTabs', () => {
expect(screen.queryByTestId(`${TEST_ID}-active-indicator`)).not.toBeInTheDocument();
});
+ it('renders with equalWidth without error', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
+ expect(screen.getByTestId(TEST_ID).className).toContain('stretch');
+ });
+
+ it('equalWidth alignSelf can be overridden', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
+ expect(screen.getByTestId(TEST_ID).className).toContain('flex-start');
+ });
+
+ it('equalWidth defaults width to 100%', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(TEST_ID).getAttribute('style')).toContain('--width: 100%');
+ });
+
+ it('equalWidth width can be overridden', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(TEST_ID).getAttribute('style')).toContain('--width: 300px');
+ });
+
it('positions indicator correctly with horizontal padding', () => {
const mockPaddedData: ReturnType = {
refs: { current: {} },