SelectControl: 32px trigger scale + option-group divider in the theme picker

This commit is contained in:
2026-06-12 21:46:53 +03:00
parent ca70b3e491
commit d1ba921141
8 changed files with 136 additions and 30 deletions
+6 -2
View File
@@ -100,10 +100,14 @@ function ChartThemeControl() {
// pattern); choosing it opens the builder and leaves the selection alone.
// It closes the custom-themes block — right after the built-ins, BEFORE the
// long preset roster — so it is visible without scrolling and sits next to
// the entries it manages.
// the entries it manages. The roster boundary itself (the divider) is set by
// chartThemeOptions where the order is decided, so the splice is the only
// index this component owns. The first preset is the divider-carrying option,
// so splicing right before it needs no count arithmetic.
// TODO: action row inside a value picker (visual separation? AT surprise?)
// parked for the batched council pass (docs/ux-second-pass.md).
list.splice(2 + customThemes.length, 0, {
const firstPreset = list.findIndex((o) => o.dividerBefore);
list.splice(firstPreset === -1 ? list.length : firstPreset, 0, {
value: EDIT_THEMES,
label: 'Edit themes…',
detail: 'Create and manage custom themes',
+14 -5
View File
@@ -1,17 +1,19 @@
/* SelectControl — the app's value-picker disclosure (replaces native <select>;
arch 10 §5). Trigger + panel mirror SortControl's geometry and tokens. */
arch 10 §5). Trigger + panel mirror SortControl's geometry and tokens: the
32px compact control height (arch 09 §6), value text at the option size. */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
gap: var(--space-2);
height: 32px;
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
font-size: 13px;
white-space: nowrap;
cursor: pointer;
transition:
@@ -44,7 +46,7 @@
}
.caret {
font-size: 9px;
font-size: 10px;
color: var(--text-secondary);
}
@@ -79,6 +81,13 @@
overflow-y: auto;
}
/* Group separator (an option's `dividerBefore`) — spans the panel edge to edge. */
.divider {
height: var(--border-width);
margin: var(--space-2) calc(-1 * var(--space-2));
background: var(--border);
}
.option {
display: flex;
align-items: center;
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, expect, test } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { SelectControl } from './SelectControl';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test('opens to the option list; a dividerBefore option draws a separator above itself', async () => {
await act(async () => {
root.render(
<SelectControl
id="sc-test"
label="Pick"
options={[
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B', dividerBefore: true },
]}
value="a"
onSelect={() => {}}
/>,
);
await Promise.resolve();
});
const trigger = container.querySelector('button')!;
await act(async () => {
trigger.click();
await Promise.resolve();
});
// The panel is portaled to <body>; both options render, with one presentation
// divider sitting between A and B (visual only — not in the keyboard order).
const panel = document.getElementById('sc-test')!;
const labels = Array.from(panel.querySelectorAll('button')).map((b) => b.textContent);
expect(labels.some((t) => t?.includes('A'))).toBe(true);
expect(labels.some((t) => t?.includes('B'))).toBe(true);
const dividers = panel.querySelectorAll('[role="presentation"]');
expect(dividers).toHaveLength(1);
expect(dividers[0].nextElementSibling?.textContent).toContain('B');
});
+24 -14
View File
@@ -22,7 +22,7 @@
* caller intercept the click entirely (the armed-channel fast path).
*/
import { type ReactNode } from 'react';
import { Fragment, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { usePopover } from '../hooks/usePopover';
import styles from './SelectControl.module.css';
@@ -35,6 +35,12 @@ export interface SelectControlOption<V extends string> {
label: string;
/** Optional secondary line (e.g. "replaces Ship Mode" on an occupied channel). */
detail?: string;
/**
* Draw a group separator above this option (purely visual, `role="presentation"`;
* keyboard order is untouched) — e.g. the chart-theme picker's boundary between
* the user's themes and the preset roster.
*/
dividerBefore?: boolean;
}
export interface SelectControlProps<V extends string> {
@@ -150,19 +156,23 @@ export function SelectControl<V extends string>({
{options.map((o) => {
const selected = value !== undefined && o.value === value;
return (
<button
key={o.value}
type="button"
className={`${styles.option} ${selected ? styles.selected : ''}`}
aria-current={selected || undefined}
onClick={() => choose(o.value)}
>
<span className={styles.optionLabel}>
{o.label}
{o.detail !== undefined && <span className={styles.detail}>{o.detail}</span>}
</span>
{selected && <span aria-hidden="true"></span>}
</button>
<Fragment key={o.value}>
{o.dividerBefore && <div className={styles.divider} role="presentation" />}
<button
type="button"
className={`${styles.option} ${selected ? styles.selected : ''}`}
aria-current={selected || undefined}
onClick={() => choose(o.value)}
>
<span className={styles.optionLabel}>
{o.label}
{o.detail !== undefined && (
<span className={styles.detail}>{o.detail}</span>
)}
</span>
{selected && <span aria-hidden="true"></span>}
</button>
</Fragment>
);
})}
</div>