Unify segmented controls on a shared module with grey selection

This commit is contained in:
2026-06-05 13:41:29 +03:00
parent 0f8738cd0c
commit cc0814a596
6 changed files with 65 additions and 82 deletions
+2 -35
View File
@@ -25,41 +25,8 @@
box-sizing: border-box;
}
/* Segmented control — the four fit modes (spec §04). */
.fit {
display: inline-flex;
border: var(--border-width) solid var(--border-strong);
}
.fitOption {
appearance: none;
border: none;
background: var(--bg);
color: var(--text-secondary);
font: inherit;
font-size: 12px;
line-height: 1;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.fitOption + .fitOption {
border-left: var(--border-width) solid var(--border-strong);
}
.fitOption:hover {
background: var(--layer-01);
color: var(--text);
}
.fitActive,
.fitActive:hover {
background: var(--accent);
color: var(--accent-contrast);
}
/* Fit-mode segmented control (spec §04) styling now lives in
SegmentedControl.module.css; the per-mode sizing classes below stay here. */
/*
* Chart sizing. The host (passed to vega-embed) is branded `.vega-embed`
-3
View File
@@ -62,9 +62,6 @@ function FitControl() {
options={FIT_OPTIONS}
value={fitMode}
onChange={setFitMode}
className={styles.fit}
optionClassName={styles.fitOption}
activeClassName={styles.fitActive}
/>
);
}
@@ -0,0 +1,42 @@
/*
* Shared look for every SegmentedControl. The control's appearance is owned here
* — once — so the Draft/Published and fit-mode instances can't drift apart again.
* Selected option uses --layer-02 (a neutral high-contrast fill, per Carbon's
* content switcher); --accent stays reserved for primary actions (e.g. Publish),
* so "current selection" never reads as "the action to take". Call sites may still
* pass className/optionClassName/activeClassName for layout-specific additions.
*/
.group {
display: inline-flex;
border: var(--border-width) solid var(--border-strong);
}
.option {
appearance: none;
border: none;
background: var(--bg);
color: var(--text-secondary);
font: inherit;
font-size: 12px;
line-height: 1;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.option + .option {
border-left: var(--border-width) solid var(--border-strong);
}
.option:hover {
background: var(--layer-01);
color: var(--text);
}
.active,
.active:hover {
background: var(--layer-02);
color: var(--text);
}
+20 -6
View File
@@ -9,10 +9,13 @@
* move focus *and* select. Native `<button>` gives Enter/Space-to-select for
* free. One widget so every segmented control gets the same, documented model.
*
* Styling is injected via class props so each call site keeps its own look.
* The control owns its base look (SegmentedControl.module.css) so every instance
* stays visually consistent; call sites may pass class props for layout-specific
* additions, which compose on top of the base classes.
*/
import { useRef } from 'react';
import styles from './SegmentedControl.module.css';
export interface SegmentedOption<T extends string> {
value: T;
@@ -25,11 +28,11 @@ interface SegmentedControlProps<T extends string> {
options: ReadonlyArray<SegmentedOption<T>>;
value: T;
onChange: (value: T) => void;
/** Class for the radiogroup container. */
/** Extra class for the radiogroup container (composed onto the base look). */
className?: string;
/** Class for each option button. */
/** Extra class for each option button (composed onto the base look). */
optionClassName?: string;
/** Extra class applied to the selected option. */
/** Extra class applied to the selected option (composed onto the base active look). */
activeClassName?: string;
}
@@ -74,7 +77,11 @@ export function SegmentedControl<T extends string>({
};
return (
<div className={className} role="radiogroup" aria-label={label}>
<div
className={[styles.group, className].filter(Boolean).join(' ')}
role="radiogroup"
aria-label={label}
>
{options.map((opt, i) => {
const selected = opt.value === value;
return (
@@ -89,7 +96,14 @@ export function SegmentedControl<T extends string>({
// Roving tabindex: only the selected option is a tab stop; arrows
// move within the group.
tabIndex={selected ? 0 : -1}
className={[optionClassName, selected ? activeClassName : ''].filter(Boolean).join(' ')}
className={[
styles.option,
optionClassName,
selected && styles.active,
selected && activeClassName,
]
.filter(Boolean)
.join(' ')}
onClick={() => onChange(opt.value)}
onKeyDown={(e) => onKeyDown(e, i)}
>
+1 -35
View File
@@ -19,41 +19,7 @@
flex: 1 1 auto;
}
/* Draft / Published segmented toggle. */
.viewToggle {
display: inline-flex;
border: var(--border-width) solid var(--border-strong);
}
.viewOption {
appearance: none;
border: none;
background: var(--bg);
color: var(--text-secondary);
font: inherit;
font-size: 12px;
line-height: 1;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.viewOption + .viewOption {
border-left: var(--border-width) solid var(--border-strong);
}
.viewOption:hover {
background: var(--layer-01);
color: var(--text);
}
.viewActive,
.viewActive:hover {
background: var(--layer-02);
color: var(--text);
}
/* Draft/Published toggle styling now lives in SegmentedControl.module.css. */
/* Publish / Revert buttons. */
.action {
-3
View File
@@ -81,9 +81,6 @@ function EditorToolbar() {
options={VIEW_OPTIONS}
value={editorView}
onChange={setEditorView}
className={styles.viewToggle}
optionClassName={styles.viewOption}
activeClassName={styles.viewActive}
/>
<span className={styles.spacer} />