Files
deepseek-harness/packages/client/ui-primitives/src/SegmentedControl.tsx
T
Yichen Jiang 7d06fcdc05 fix(settings-models): derive the add panels, lock the switch in flight, pair tabs with panels
The add card's panels are now derived from the shown mode and the modes
visited since it opened, and each renders only while its mode is still
offered, so a refresh that changes which modes are offered — every
configurable row gone, the pi-ai namespace unmounted, a route freed
after the catalog was exhausted — can neither leave the card without a
panel nor keep a form whose write the Host would refuse. The catalog
draft resolves to the chosen row while its namespace can take the
write, else to the first addable row; closing either panel forgets it,
so a later refresh cannot open a row editor unasked.

Both cards report a write or an endpoint interrogation in flight, and
the switch and the catalog select lock while one is, since a switch
underneath it would orphan the answer or the picker it opens. The
SegmentedControl takes the owner's id and pairs each tab with its panel
through `aria-controls` and `aria-labelledby`; a card offering one mode
carries that mode as its title instead of an orphan tabpanel, and a
locked segment names its reason as hover text. `fullWidth` goes: no
consumer asked for it.
2026-09-19 16:04:34 +08:00

141 lines
5.3 KiB
TypeScript

// SegmentedControl: a tablist of two or more equal-width segments with one
// sliding indicator, for switching a card or panel between a few modes. The
// owner holds the selection; `label` is required so the tablist never ships
// without an accessible name, and every segment label is owner-localized.
// Each tab is `<id>-<value>` and controls the panel `<id>-<value>-panel`, the
// element the owner renders for that mode and points back at the tab with
// `aria-labelledby`.
import { useEffect, useRef } from 'react'
import type { CSSProperties, KeyboardEvent } from 'react'
import clsx from 'clsx'
import css from './SegmentedControl.module.css'
/** One segment of a {@link SegmentedControl}. */
export interface SegmentedControlOption<Value extends string> {
/** The value the owner receives when this segment is chosen. */
value: Value
/** Localized segment text. */
label: string
/** Whether the segment refuses selection. */
disabled?: boolean
/** Localized hover text, typically why the segment is locked. */
title?: string
}
/** The keys that walk the tablist, per the WAI-ARIA tabs pattern. */
type WalkKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown' | 'Home' | 'End'
function isWalkKey(key: string): key is WalkKey {
return key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown'
|| key === 'Home' || key === 'End'
}
/**
* The enabled option a walk key lands on from the selected one: arrows step
* to the nearest enabled neighbour and wrap, Home and End jump to the first
* and last enabled option.
*/
function walk<Value extends string>(
options: readonly SegmentedControlOption<Value>[],
from: number,
key: WalkKey,
): SegmentedControlOption<Value> | undefined {
const enabled = options.filter(option => option.disabled !== true)
if (key === 'Home') return enabled[0]
if (key === 'End') return enabled[enabled.length - 1]
const step = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1
const count = options.length
for (let offset = 1; offset < count; offset += 1) {
const candidate = options[((from + step * offset) % count + count) % count]
if (candidate !== undefined && candidate.disabled !== true) return candidate
}
return undefined
}
/**
* Render a segmented control.
* @param props.id - the owner's base id: each tab is `<id>-<value>` and names
* `<id>-<value>-panel` as the panel it controls.
* @param props.value - the selected option's value; the control is fully controlled.
* @param props.options - the segments in display order; at least two.
* @param props.onChange - called with the value a click or a walk key asks for,
* never with the value already selected.
* @param props.label - localized accessible name of the tablist.
* @param props.disabled - lock every segment, typically while the shown panel
* has a write or a fetch in flight that switching would orphan.
* @param props.className - extra class for layout placement.
* @returns the tablist element.
*/
export function SegmentedControl<Value extends string>({
id, value, options, onChange, label, disabled = false, className,
}: {
id: string
value: Value
options: readonly SegmentedControlOption<Value>[]
onChange: (next: Value) => void
label: string
disabled?: boolean
// `| undefined` so a caller can forward an optional class straight through
// under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
className?: string | undefined
}) {
const list = useRef<HTMLDivElement>(null)
const selected = options.findIndex(option => option.value === value)
// A walk key reports the next value and the owner re-renders with it; the
// keyboard then follows the selection so the roving tabindex stays usable.
useEffect(() => {
const root = list.current
/* v8 ignore next -- the ref is attached to the always-rendered root before any effect runs. */
if (root === null) return
if (!root.contains(document.activeElement)) return
root.querySelector<HTMLButtonElement>('[role="tab"][aria-selected="true"]')?.focus()
}, [value])
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (!isWalkKey(event.key)) return
event.preventDefault()
const target = walk(options, selected, event.key)
if (target !== undefined && target.value !== value) onChange(target.value)
}
const indicator = {
'--dsh-segment-count': String(options.length),
'--dsh-segment-index': String(selected),
} as CSSProperties
return (
<div
ref={list}
role="tablist"
aria-label={label}
className={clsx(css.control, className)}
style={indicator}
>
<span aria-hidden="true" className={css.indicator} />
{options.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
id={`${id}-${option.value}`}
type="button"
role="tab"
aria-selected={active}
aria-controls={`${id}-${option.value}-panel`}
tabIndex={active ? 0 : -1}
disabled={disabled || option.disabled === true}
title={option.title}
className={css.tab}
onClick={() => { if (!active) onChange(option.value) }}
onKeyDown={onKeyDown}
>
{option.label}
</button>
)
})}
</div>
)
}