Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 23 additions & 29 deletions console/src/components/ui.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ReactNode } from 'react';
import { useMemo, useState } from 'react';

import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
export { ToggleGroup, ToggleGroupItem };

export type TableSortDirection = 'asc' | 'desc';

export interface TableSortState<Key extends string> {
Expand Down Expand Up @@ -176,38 +179,27 @@ export function SegmentedToggle(props: {
}>;
onChange: (value: string) => void;
disabled?: boolean;
size?: 'sm' | 'default';
}) {
return (
<fieldset
className={
props.className ? `binary-toggle ${props.className}` : 'binary-toggle'
}
aria-label={props.ariaLabel}
<ToggleGroup
value={props.value}
onValueChange={props.onChange}
ariaLabel={props.ariaLabel}
className={props.className}
disabled={props.disabled}
size={props.size}
>
{props.options.map((option) => {
const active = option.value === props.value;
return (
<button
key={option.value}
className={
active
? `binary-toggle-button active ${option.activeTone ?? 'is-on'}`
: 'binary-toggle-button'
}
type="button"
disabled={props.disabled}
aria-pressed={active}
onClick={() => {
if (!active) {
props.onChange(option.value);
}
}}
>
{option.label}
</button>
);
})}
</fieldset>
{props.options.map((opt) => (
<ToggleGroupItem
key={opt.value}
value={opt.value}
activeTone={opt.activeTone}
>
{opt.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}

Expand Down Expand Up @@ -244,12 +236,14 @@ export function BooleanToggle(props: {
falseLabel?: string;
disabled?: boolean;
ariaLabel: string;
size?: 'sm' | 'default';
}) {
return (
<SegmentedToggle
className="boolean-toggle"
ariaLabel={props.ariaLabel}
value={props.value ? 'true' : 'false'}
size={props.size}
options={[
{
value: 'true',
Expand Down
101 changes: 101 additions & 0 deletions console/src/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createContext, useContext, useMemo } from 'react';
import { cx } from '../../lib/cx';

type ToggleGroupContextValue = {
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
};

const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);

function useToggleGroupContext(): ToggleGroupContextValue {
const ctx = useContext(ToggleGroupContext);
if (!ctx) {
throw new Error('<ToggleGroupItem> must be used inside <ToggleGroup>');
}
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 disabled = props.disabled ?? false;
const ctx = useMemo(
() => ({ value: props.value, onValueChange: props.onValueChange, disabled }),
[props.value, props.onValueChange, disabled],
);

return (
<ToggleGroupContext.Provider value={ctx}>
{/* fieldset carries implicit role="group" — biome-compliant semantic element */}
<fieldset
aria-label={props.ariaLabel}
className={cx(
'binary-toggle',
props.size === 'sm' && 'toggle-group--sm',
props.className,
)}
data-disabled={disabled || undefined}
>
{props.children}
</fieldset>
</ToggleGroupContext.Provider>
);
}

// ── 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 (
<button
type="button"
aria-pressed={active}
data-state={dataState}
className={cx('binary-toggle-button', active && 'active', active && tone)}
disabled={isDisabled}
data-disabled={isDisabled || undefined}
onClick={() => {
if (!active && !isDisabled) {
ctx.onValueChange(props.value);
}
}}
>
{props.children}
</button>
);
}
1 change: 1 addition & 0 deletions console/src/routes/skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,7 @@ export function SkillsPage() {
<BooleanToggle
value={skill.enabled}
ariaLabel={`${skill.name} status`}
size="sm"
disabled={
toggleMutation.isPending ||
(!skill.available && !skill.enabled)
Expand Down
18 changes: 18 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,24 @@ th {
opacity: 0.65;
}

/* ToggleGroup data-state selectors — mirrors .active.is-on / .active.is-off
and provides a data-attribute hook for CSS transitions going forward. */
.binary-toggle-button[data-state="on"].is-on {
background: var(--success-soft);
color: var(--success);
}

.binary-toggle-button[data-state="on"].is-off {
background: var(--panel-muted);
color: var(--text);
}

/* ToggleGroup sm size variant — compact padding for dense table rows. */
.toggle-group--sm .binary-toggle-button {
padding: 4px 8px;
font-size: 0.8rem;
}

.config-section {
padding: 16px;
border-radius: var(--radius-md);
Expand Down
Loading