From 6be55920ab6778029a90bad272f3aee4427d758f Mon Sep 17 00:00:00 2001 From: Maximilian Noller Date: Mon, 13 Apr 2026 03:15:34 +0200 Subject: [PATCH 1/2] feat: add ToggleGroup/ToggleGroupItem primitive, refactor binary-toggle stack Introduces a compound-component ToggleGroup + ToggleGroupItem following shadcn/Radix naming and API conventions (context-based, value + onValueChange, data-state on every item): - New file console/src/components/ui/toggle-group.tsx exports ToggleGroup (root,
with implicit role=group) and ToggleGroupItem ( - ); - })} -
+ {props.options.map((opt) => ( + + {opt.label} + + ))} + ); } @@ -244,12 +237,14 @@ export function BooleanToggle(props: { falseLabel?: string; disabled?: boolean; ariaLabel: string; + size?: 'sm' | 'default'; }) { return ( void; + disabled: boolean; + size: 'sm' | 'default'; +}; + +const ToggleGroupContext = createContext(null); + +function useToggleGroupContext(): ToggleGroupContextValue { + const ctx = useContext(ToggleGroupContext); + if (!ctx) { + throw new Error(' must be used inside '); + } + return ctx; +} + +// ── Root ─────────────────────────────────────────────────────────────────── + +export function ToggleGroup(props: { + /** Currently selected value. */ + value: string; + /** Called when the user selects a different item. */ + onValueChange: (value: string) => void; + /** Accessible label for the fieldset. */ + ariaLabel: string; + children: React.ReactNode; + /** 'sm' renders compact padding for dense table rows; 'default' for forms. */ + size?: 'sm' | 'default'; + disabled?: boolean; + className?: string; +}) { + const size = props.size ?? 'default'; + const disabled = props.disabled ?? false; + const sizeClass = size === 'sm' ? ' toggle-group--sm' : ''; + const extraClass = props.className ? ` ${props.className}` : ''; + + return ( + + {/* fieldset carries implicit role="group" — biome-compliant semantic element */} +
+ {props.children} +
+
+ ); +} + +// ── Item ─────────────────────────────────────────────────────────────────── + +export function ToggleGroupItem(props: { + /** The value this item represents. Matched against ToggleGroup.value. */ + value: string; + children: React.ReactNode; + /** + * Controls the selected-state colour. + * 'is-on' → success green (enabled / on / active) + * 'is-off' → muted grey (disabled / off / inactive) + * Defaults to 'is-on'. + */ + activeTone?: 'is-on' | 'is-off'; + /** Overrides the group-level disabled state for this item only. */ + disabled?: boolean; +}) { + const ctx = useToggleGroupContext(); + const active = props.value === ctx.value; + const tone = props.activeTone ?? 'is-on'; + const isDisabled = props.disabled ?? ctx.disabled; + + // data-state mirrors Radix: every item carries the attribute so CSS + // transitions can target [data-state="on"] and [data-state="off"]. + const dataState = active ? (tone === 'is-off' ? 'off' : 'on') : 'off'; + + return ( + + ); +} diff --git a/console/src/routes/skills.tsx b/console/src/routes/skills.tsx index cb7ea5646..c216509fa 100644 --- a/console/src/routes/skills.tsx +++ b/console/src/routes/skills.tsx @@ -878,6 +878,7 @@ export function SkillsPage() { Date: Mon, 13 Apr 2026 03:21:41 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20simplify=20ToggleGroup=20?= =?UTF-8?q?=E2=80=94=20cx(),=20remove=20size=20from=20context,=20useMemo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use cx() from lib/cx instead of hand-rolled class string concatenation - Remove size from ToggleGroupContextValue: items never read it; the size class is applied on the fieldset in ToggleGroup itself - Wrap context value in useMemo to avoid unnecessary re-renders of all ToggleGroupItems when the parent re-renders with unchanged props - Consolidate export+import in ui.tsx to import + export {} (single source) --- console/src/components/ui.tsx | 3 +-- console/src/components/ui/toggle-group.tsx | 30 ++++++++++------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/console/src/components/ui.tsx b/console/src/components/ui.tsx index ca4da2000..5b1e21ead 100644 --- a/console/src/components/ui.tsx +++ b/console/src/components/ui.tsx @@ -1,9 +1,8 @@ import type { ReactNode } from 'react'; import { useMemo, useState } from 'react'; -export { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'; - import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'; +export { ToggleGroup, ToggleGroupItem }; export type TableSortDirection = 'asc' | 'desc'; diff --git a/console/src/components/ui/toggle-group.tsx b/console/src/components/ui/toggle-group.tsx index 3483252a4..cdb174740 100644 --- a/console/src/components/ui/toggle-group.tsx +++ b/console/src/components/ui/toggle-group.tsx @@ -1,15 +1,10 @@ -import { createContext, useContext } from 'react'; - -// ── Context ──────────────────────────────────────────────────────────────── -// -// Mirrors the Radix UI ToggleGroup context pattern: the root sets -// value/onValueChange/disabled/size and each item reads it. +import { createContext, useContext, useMemo } from 'react'; +import { cx } from '../../lib/cx'; type ToggleGroupContextValue = { value: string; onValueChange: (value: string) => void; disabled: boolean; - size: 'sm' | 'default'; }; const ToggleGroupContext = createContext(null); @@ -37,19 +32,22 @@ export function ToggleGroup(props: { disabled?: boolean; className?: string; }) { - const size = props.size ?? 'default'; const disabled = props.disabled ?? false; - const sizeClass = size === 'sm' ? ' toggle-group--sm' : ''; - const extraClass = props.className ? ` ${props.className}` : ''; + const ctx = useMemo( + () => ({ value: props.value, onValueChange: props.onValueChange, disabled }), + [props.value, props.onValueChange, disabled], + ); return ( - + {/* fieldset carries implicit role="group" — biome-compliant semantic element */}
{props.children} @@ -88,9 +86,7 @@ export function ToggleGroupItem(props: { type="button" aria-pressed={active} data-state={dataState} - className={ - active ? `binary-toggle-button active ${tone}` : 'binary-toggle-button' - } + className={cx('binary-toggle-button', active && 'active', active && tone)} disabled={isDisabled} data-disabled={isDisabled || undefined} onClick={() => {