@adecore/ui 0.26.3 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,8 @@ export interface ColorSwatchProps extends ComponentProps<'button'> {
8
8
  color?: string;
9
9
  picked?: boolean;
10
10
  on?: keyof typeof PICKED_RING;
11
+ tick?: boolean;
11
12
  render?: useRender.RenderProp;
12
13
  }
13
- export declare function ColorSwatch({ color, picked, on, render, className, style, ref, children, ...props }: ColorSwatchProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
14
+ export declare function ColorSwatch({ color, picked, on, tick, render, className, style, ref, children, ...props }: ColorSwatchProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
14
15
  export {};
@@ -14,7 +14,7 @@ const PICKED_RING = {
14
14
  * One color, drawn as the color itself. Its name belongs in a tooltip, so the circle carries a tick
15
15
  * alone when it is picked. Children replace the tick, such as the ellipsis of a "more colors" swatch.
16
16
  */
17
- export function ColorSwatch({ color, picked = false, on = 'surface', render, className, style, ref, children, ...props }) {
17
+ export function ColorSwatch({ color, picked = false, on = 'surface', tick = true, render, className, style, ref, children, ...props }) {
18
18
  return useRender({
19
19
  render: render ?? _jsx("button", { type: "button" }),
20
20
  ref,
@@ -22,7 +22,7 @@ export function ColorSwatch({ color, picked = false, on = 'surface', render, cla
22
22
  ...props,
23
23
  className: clsx(SWATCH, color === undefined ? 'border border-border-strong text-text-muted' : picked && PICKED_RING[on], className),
24
24
  style: color === undefined ? style : { ...style, background: color },
25
- children: children ?? (picked && _jsx(Icon, { icon: Check, size: 12 }))
25
+ children: children ?? (picked && tick && _jsx(Icon, { icon: Check, size: 12 }))
26
26
  }
27
27
  });
28
28
  }
@@ -0,0 +1,16 @@
1
+ import { type Ref } from 'react';
2
+ export interface ColorWheelPickerProps<Id extends string> {
3
+ value: Id;
4
+ onValueChange(id: Id): void;
5
+ onPreviewChange?(id: Id | null): void;
6
+ label: string;
7
+ rings: readonly (readonly Id[])[];
8
+ center?: Id;
9
+ colorOf(id: Id): string;
10
+ labelOf(id: Id): string;
11
+ disabled?: boolean;
12
+ align?: 'start' | 'end';
13
+ className?: string;
14
+ ref?: Ref<HTMLButtonElement>;
15
+ }
16
+ export declare function ColorWheelPicker<Id extends string>({ value, onValueChange, onPreviewChange, label, rings, center, colorOf, labelOf, disabled, align, className, ref }: ColorWheelPickerProps<Id>): import("react").JSX.Element;
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from 'react';
3
+ import { ChevronDown } from 'lucide-react';
4
+ import { ColorSwatch } from './ColorSwatch.js';
5
+ import { Icon } from './Icon.js';
6
+ import { PopoverPopup, PopoverRoot, PopoverTrigger } from './popover/parts.js';
7
+ import { triggerClassName } from './select-shared.js';
8
+ const SWATCH_SIZE = 24;
9
+ /* The outer ring stands this far from the middle, further when its swatches would not keep a gap between them otherwise. */
10
+ const MIN_RADIUS = 97;
11
+ const MIN_GAP = 8;
12
+ /* Room past the outer ring for the ring a picked swatch wears. */
13
+ const EDGE = 7;
14
+ /* Every swatch's top left corner in whole pixels, the rings evenly apart from the outer one to the middle, and the size of the wheel. */
15
+ function layout(rings, center) {
16
+ const outer = rings[0]?.length ?? 0;
17
+ const radius = Math.max(MIN_RADIUS, outer > 1 ? Math.ceil((SWATCH_SIZE + MIN_GAP) / (2 * Math.sin(Math.PI / outer))) : 0);
18
+ const size = 2 * (radius + SWATCH_SIZE / 2 + EDGE);
19
+ const middle = (size - SWATCH_SIZE) / 2;
20
+ const spot = (id, turn, distance) => {
21
+ const angle = turn * 2 * Math.PI - Math.PI / 2;
22
+ return { id, left: Math.round(middle + distance * Math.cos(angle)), top: Math.round(middle + distance * Math.sin(angle)) };
23
+ };
24
+ const spots = rings.flatMap((ring, depth) => ring.map((id, i) => spot(id, i / ring.length, (radius * (rings.length - depth)) / rings.length)));
25
+ return { size, spots: center === undefined ? spots : [...spots, spot(center, 0, 0)] };
26
+ }
27
+ const STEPS = {
28
+ ArrowRight: (index, count) => (index + 1) % count,
29
+ ArrowDown: (index, count) => (index + 1) % count,
30
+ ArrowLeft: (index, count) => (index - 1 + count) % count,
31
+ ArrowUp: (index, count) => (index - 1 + count) % count,
32
+ Home: () => 0,
33
+ End: (_, count) => count - 1
34
+ };
35
+ /*
36
+ * A color picked from a wheel behind a trigger that holds the one in use and lines up with a select.
37
+ * While the wheel is open the swatch under the pointer or the focus is reported as a preview, so the
38
+ * caller can show it in place; its name stands under the wheel, which is why a swatch carries no
39
+ * tooltip. The wheel is one tab stop: the arrows walk it in its order and only Enter, Space or a click
40
+ * picks. Every preview ends with null, after a pick as well as on closing without one or on going away while open.
41
+ */
42
+ export function ColorWheelPicker({ value, onValueChange, onPreviewChange, label, rings, center, colorOf, labelOf, disabled, align = 'start', className, ref }) {
43
+ const labelId = useId();
44
+ const valueId = useId();
45
+ const [open, setOpen] = useState(false);
46
+ const [looking, setLooking] = useState(null);
47
+ const [focused, setFocused] = useState(null);
48
+ const wheel = useRef(null);
49
+ const { size, spots } = layout(rings, center);
50
+ const tabStop = spots.find((spot) => spot.id === (focused ?? value))?.id ?? spots[0]?.id;
51
+ // Read by the cleanup below, which runs after the state and the props of its render are gone.
52
+ const preview = useRef({ looking: false, onPreviewChange });
53
+ useEffect(() => {
54
+ preview.current.onPreviewChange = onPreviewChange;
55
+ });
56
+ const look = (id) => {
57
+ setLooking(id);
58
+ preview.current.looking = id !== null;
59
+ onPreviewChange?.(id);
60
+ };
61
+ // A picker taken away while a color is previewed (its dialog closed under it) gets no close of its
62
+ // popover, so it ends the preview itself rather than leave the caller showing a color nobody picked.
63
+ useEffect(() => () => {
64
+ if (preview.current.looking) {
65
+ preview.current.onPreviewChange?.(null);
66
+ }
67
+ }, []);
68
+ const close = () => {
69
+ setOpen(false);
70
+ setFocused(null);
71
+ look(null);
72
+ };
73
+ const step = (event) => {
74
+ const next = STEPS[event.key];
75
+ const swatches = [...(wheel.current?.querySelectorAll('[role="radio"]') ?? [])];
76
+ const index = swatches.indexOf(event.target);
77
+ if (next === undefined || index < 0) {
78
+ return;
79
+ }
80
+ event.preventDefault();
81
+ swatches[next(index, swatches.length)]?.focus();
82
+ };
83
+ return (_jsxs(PopoverRoot, { open: open, onOpenChange: (next) => {
84
+ if (next) {
85
+ setOpen(true);
86
+ }
87
+ else {
88
+ close();
89
+ }
90
+ }, children: [_jsx("span", { id: labelId, hidden: true, children: label }), _jsxs(PopoverTrigger, { ref: ref, disabled: disabled, "aria-labelledby": `${labelId} ${valueId}`, className: triggerClassName('md', 'outlined', className), children: [_jsx("span", { className: "h-3.5 w-3.5 shrink-0 rounded-full", style: { background: colorOf(value) }, "aria-hidden": true }), _jsx("span", { id: valueId, className: "truncate", children: labelOf(value) }), _jsx("span", { className: "ml-auto flex shrink-0 pl-1", children: _jsx(Icon, { icon: ChevronDown, size: 14 }) })] }), _jsx(PopoverPopup, { align: align, initialFocus: () => wheel.current?.querySelector('[aria-checked="true"]') ?? false, children: _jsxs("div", { className: "flex flex-col items-center gap-3 p-3", children: [_jsx("div", { ref: wheel, role: "radiogroup", "aria-label": label, className: "relative", style: { width: size, height: size }, onPointerLeave: () => look(null), onKeyDown: step, children: spots.map(({ id, left, top }) => (_jsx(ColorSwatch, { role: "radio", "aria-checked": value === id, "aria-label": labelOf(id), tabIndex: id === tabStop ? 0 : -1, color: colorOf(id), picked: value === id, tick: false, on: "popup", className: "absolute", style: { left, top }, onPointerEnter: () => look(id), onFocus: () => {
91
+ setFocused(id);
92
+ look(id);
93
+ }, onClick: () => {
94
+ onValueChange(id);
95
+ close();
96
+ } }, id))) }), _jsx("span", { className: "text-xs text-text-muted", children: labelOf(looking ?? value) })] }) })] }));
97
+ }
package/dist/Select.js CHANGED
@@ -7,7 +7,7 @@ import { Icon } from './Icon.js';
7
7
  import { MENU_LABEL } from './menu/parts.js';
8
8
  import { renderPositioner } from './popup-layer.js';
9
9
  import { RowContent } from './select-row.js';
10
- import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT } from './select-shared.js';
10
+ import { isGroup, rowClassName, triggerClassName } from './select-shared.js';
11
11
  import { SelectSearch } from './SelectSearch.js';
12
12
  function Row({ item, picked }) {
13
13
  return (_jsx(BaseSelect.Item, { className: rowClassName(item), value: item.value, label: item.label, disabled: item.disabled, children: _jsx(RowContent, { item: item, picked: picked, text: _jsx(BaseSelect.ItemText, { children: item.label }) }) }));
@@ -27,7 +27,7 @@ function PlainSelect({ value, onValueChange, items, label, placeholder, size = '
27
27
  const groups = items.length > 0 && isGroup(items[0]) ? items : null;
28
28
  const flat = groups ? groups.flatMap((group) => group.items) : items;
29
29
  const selected = flat.find((item) => item.value === value) ?? null;
30
- return (_jsxs(BaseSelect.Root, { items: flat.map((item) => ({ value: item.value, label: item.label })), value: value, disabled: disabled, onValueChange: (next) => onValueChange(next), children: [_jsxs(BaseSelect.Trigger, { ref: ref, "aria-label": label, className: clsx('flex shrink-0 items-center whitespace-nowrap text-xs', TRIGGER_SIZE[size], TRIGGER_VARIANT[variant], TRIGGER_DISABLED, className), children: [selected?.icon, _jsx(BaseSelect.Value, { className: clsx(truncateValue && 'truncate'), children: () => selected?.label ?? placeholder ?? t('action.select') }), _jsx(BaseSelect.Icon, { className: "ml-auto flex shrink-0 pl-1", children: _jsx(Icon, { icon: ChevronDown, size: 14 }) })] }), _jsx(BaseSelect.Portal, { children: _jsx(BaseSelect.Positioner, { className: "popup-positioner", render: renderPositioner, side: "bottom", align: align, sideOffset: 6, alignItemWithTrigger: false, children: _jsx(BaseSelect.Popup, { className: "menu-popup", children: _jsx(BaseSelect.List, { className: "max-h-80 overflow-auto", children: groups
30
+ return (_jsxs(BaseSelect.Root, { items: flat.map((item) => ({ value: item.value, label: item.label })), value: value, disabled: disabled, onValueChange: (next) => onValueChange(next), children: [_jsxs(BaseSelect.Trigger, { ref: ref, "aria-label": label, className: triggerClassName(size, variant, className), children: [selected?.icon, _jsx(BaseSelect.Value, { className: clsx(truncateValue && 'truncate'), children: () => selected?.label ?? placeholder ?? t('action.select') }), _jsx(BaseSelect.Icon, { className: "ml-auto flex shrink-0 pl-1", children: _jsx(Icon, { icon: ChevronDown, size: 14 }) })] }), _jsx(BaseSelect.Portal, { children: _jsx(BaseSelect.Positioner, { className: "popup-positioner", render: renderPositioner, side: "bottom", align: align, sideOffset: 6, alignItemWithTrigger: false, children: _jsx(BaseSelect.Popup, { className: "menu-popup", children: _jsx(BaseSelect.List, { className: "max-h-80 overflow-auto", children: groups
31
31
  ? groups.map((group) => (_jsxs(BaseSelect.Group, { children: [_jsx(BaseSelect.GroupLabel, { className: MENU_LABEL, children: group.label }), group.items.map((item) => (_jsx(Row, { item: item, picked: item.value === value }, item.value)))] }, group.label)))
32
32
  : flat.map((item) => _jsx(Row, { item: item, picked: item.value === value }, item.value)) }) }) }) })] }));
33
33
  }
@@ -8,7 +8,7 @@ import { Icon } from './Icon.js';
8
8
  import { MENU_LABEL } from './menu/parts.js';
9
9
  import { renderPositioner } from './popup-layer.js';
10
10
  import { RowContent } from './select-row.js';
11
- import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT } from './select-shared.js';
11
+ import { isGroup, rowClassName, triggerClassName } from './select-shared.js';
12
12
  /* The `Select` with a search field on top of its options, for lists too long to scan. A combobox of Base UI brings the filtering and the keyboard. */
13
13
  export function SelectSearch({ value, onValueChange, items, label, placeholder, searchPlaceholder, size = 'md', variant = 'outlined', disabled, align = 'start', truncateValue = true, className, ref }) {
14
14
  const { t } = useTranslation('ui');
@@ -33,7 +33,7 @@ export function SelectSearch({ value, onValueChange, items, label, placeholder,
33
33
  if (!open) {
34
34
  setQuery('');
35
35
  }
36
- }, children: [_jsxs(Combobox.Trigger, { ref: ref, "aria-label": label, className: clsx('flex shrink-0 items-center whitespace-nowrap text-xs', TRIGGER_SIZE[size], TRIGGER_VARIANT[variant], TRIGGER_DISABLED, className), children: [selected?.icon, _jsx("span", { className: clsx(truncateValue && 'truncate'), children: selected?.label ?? placeholder ?? t('action.select') }), _jsx("span", { className: "ml-auto flex shrink-0 pl-1", children: _jsx(Icon, { icon: ChevronDown, size: 14 }) })] }), _jsx(Combobox.Portal, { children: _jsx(Combobox.Positioner, { className: "popup-positioner", render: renderPositioner, side: "bottom", align: align, sideOffset: 6, children: _jsxs(Combobox.Popup, { className: "menu-popup flex max-h-96 flex-col", children: [_jsxs("label", { className: "flex shrink-0 items-center gap-2 border-b border-border px-3", children: [_jsx(Icon, { icon: Search, size: 14, className: "shrink-0 text-text-faint" }), _jsx(Combobox.Input, { "aria-label": searchLabel, placeholder: searchLabel, className: "h-9 min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint" })] }), _jsx(Combobox.Empty, { className: "px-3 py-4 text-center text-xs text-text-faint empty:hidden", children: t('select.noMatch', { query }) }), _jsx(Combobox.List, { className: "min-h-0 overflow-auto", children: groups
36
+ }, children: [_jsxs(Combobox.Trigger, { ref: ref, "aria-label": label, className: triggerClassName(size, variant, className), children: [selected?.icon, _jsx("span", { className: clsx(truncateValue && 'truncate'), children: selected?.label ?? placeholder ?? t('action.select') }), _jsx("span", { className: "ml-auto flex shrink-0 pl-1", children: _jsx(Icon, { icon: ChevronDown, size: 14 }) })] }), _jsx(Combobox.Portal, { children: _jsx(Combobox.Positioner, { className: "popup-positioner", render: renderPositioner, side: "bottom", align: align, sideOffset: 6, children: _jsxs(Combobox.Popup, { className: "menu-popup flex max-h-96 flex-col", children: [_jsxs("label", { className: "flex shrink-0 items-center gap-2 border-b border-border px-3", children: [_jsx(Icon, { icon: Search, size: 14, className: "shrink-0 text-text-faint" }), _jsx(Combobox.Input, { "aria-label": searchLabel, placeholder: searchLabel, className: "h-9 min-w-0 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-faint" })] }), _jsx(Combobox.Empty, { className: "px-3 py-4 text-center text-xs text-text-faint empty:hidden", children: t('select.noMatch', { query }) }), _jsx(Combobox.List, { className: "min-h-0 overflow-auto", children: groups
37
37
  ? (group) => (_jsxs(Combobox.Group, { items: group.items, children: [_jsx(Combobox.GroupLabel, { className: MENU_LABEL, children: group.value }), _jsx(Combobox.Collection, { children: renderRow })] }, group.value))
38
38
  : renderRow })] }) }) })] }));
39
39
  }
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export { Checkbox, type CheckboxProps } from './Checkbox.tsx';
20
20
  export { ChoiceCards, type Choice, type ChoiceCardsProps } from './ChoiceCards.tsx';
21
21
  export { CloseButton, type CloseButtonProps } from './CloseButton.tsx';
22
22
  export { ColorSwatch, type ColorSwatchProps } from './ColorSwatch.tsx';
23
+ export { ColorWheelPicker, type ColorWheelPickerProps } from './ColorWheelPicker.tsx';
23
24
  export { ColumnResizeHandle, type ColumnResizeHandleProps } from './ColumnResizeHandle.tsx';
24
25
  export { DisabledReason, type DisabledReasonProps } from './DisabledReason.tsx';
25
26
  export { DockShell, type DockShellProps } from './DockShell.tsx';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export { Checkbox } from './Checkbox.js';
19
19
  export { ChoiceCards } from './ChoiceCards.js';
20
20
  export { CloseButton } from './CloseButton.js';
21
21
  export { ColorSwatch } from './ColorSwatch.js';
22
+ export { ColorWheelPicker } from './ColorWheelPicker.js';
22
23
  export { ColumnResizeHandle } from './ColumnResizeHandle.js';
23
24
  export { DisabledReason } from './DisabledReason.js';
24
25
  export { DockShell } from './DockShell.js';
@@ -20,4 +20,5 @@ export declare const TRIGGER_VARIANT: {
20
20
  readonly ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted';
21
21
  };
22
22
  export declare const TRIGGER_DISABLED = "data-disabled:cursor-default data-disabled:opacity-50";
23
+ export declare const triggerClassName: (size: keyof typeof TRIGGER_SIZE, variant: keyof typeof TRIGGER_VARIANT, className?: string) => string;
23
24
  export declare const rowClassName: (item: SelectItem<string>) => string;
@@ -10,5 +10,7 @@ export const TRIGGER_VARIANT = {
10
10
  };
11
11
  /* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
12
12
  export const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
13
+ /* The trigger of every select, and of any other picker that stands in a row of selects and has to line up with them. */
14
+ export const triggerClassName = (size, variant, className) => clsx('flex shrink-0 items-center whitespace-nowrap text-xs', TRIGGER_SIZE[size], TRIGGER_VARIANT[variant], TRIGGER_DISABLED, className);
13
15
  /* A description makes the row two lines high; the box keeps the icon on the label's line. */
14
16
  export const rowClassName = (item) => clsx('menu-item', item.description && 'items-start');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.26.3",
3
+ "version": "0.27.1",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
@@ -19,6 +19,8 @@ export interface ColorSwatchProps extends ComponentProps<'button'> {
19
19
  picked?: boolean;
20
20
  /* On the surface behind a popup, or inside the popup itself. */
21
21
  on?: keyof typeof PICKED_RING;
22
+ /* Whether a picked swatch draws its tick. Off where the ring alone marks it, such as on a swatch of two tones, where a tick reads as a glitch. */
23
+ tick?: boolean;
22
24
  /* Another element to be the swatch, such as a menu trigger, or `<Menu.Item unstyled />` so the row's padding and highlight stay off it. */
23
25
  render?: useRender.RenderProp;
24
26
  }
@@ -27,7 +29,7 @@ export interface ColorSwatchProps extends ComponentProps<'button'> {
27
29
  * One color, drawn as the color itself. Its name belongs in a tooltip, so the circle carries a tick
28
30
  * alone when it is picked. Children replace the tick, such as the ellipsis of a "more colors" swatch.
29
31
  */
30
- export function ColorSwatch({ color, picked = false, on = 'surface', render, className, style, ref, children, ...props }: ColorSwatchProps) {
32
+ export function ColorSwatch({ color, picked = false, on = 'surface', tick = true, render, className, style, ref, children, ...props }: ColorSwatchProps) {
31
33
  return useRender({
32
34
  render: render ?? <button type="button" />,
33
35
  ref,
@@ -35,7 +37,7 @@ export function ColorSwatch({ color, picked = false, on = 'surface', render, cla
35
37
  ...props,
36
38
  className: clsx(SWATCH, color === undefined ? 'border border-border-strong text-text-muted' : picked && PICKED_RING[on], className),
37
39
  style: color === undefined ? style : { ...style, background: color },
38
- children: children ?? (picked && <Icon icon={Check} size={12} />)
40
+ children: children ?? (picked && tick && <Icon icon={Check} size={12} />)
39
41
  }
40
42
  });
41
43
  }
@@ -0,0 +1,193 @@
1
+ import { useEffect, useId, useRef, useState, type KeyboardEvent, type Ref } from 'react';
2
+ import { ChevronDown } from 'lucide-react';
3
+ import { ColorSwatch } from './ColorSwatch.tsx';
4
+ import { Icon } from './Icon.tsx';
5
+ import { PopoverPopup, PopoverRoot, PopoverTrigger } from './popover/parts.tsx';
6
+ import { triggerClassName } from './select-shared.ts';
7
+
8
+ const SWATCH_SIZE = 24;
9
+ /* The outer ring stands this far from the middle, further when its swatches would not keep a gap between them otherwise. */
10
+ const MIN_RADIUS = 97;
11
+ const MIN_GAP = 8;
12
+ /* Room past the outer ring for the ring a picked swatch wears. */
13
+ const EDGE = 7;
14
+
15
+ export interface ColorWheelPickerProps<Id extends string> {
16
+ value: Id;
17
+ onValueChange(id: Id): void;
18
+ /* The one under the pointer or the focus while the wheel is open, so the caller can show it before it is picked; null once none is looked at. */
19
+ onPreviewChange?(id: Id | null): void;
20
+ /* The accessible name of the trigger and of the wheel; the row the picker stands in usually carries the visible one. */
21
+ label: string;
22
+ /* The swatches round the wheel, a ring at a time from the outside in, each in its own order from the top. */
23
+ rings: readonly (readonly Id[])[];
24
+ /* The one in the middle, if any. */
25
+ center?: Id;
26
+ /* Any CSS background, a gradient too. */
27
+ colorOf(id: Id): string;
28
+ labelOf(id: Id): string;
29
+ disabled?: boolean;
30
+ align?: 'start' | 'end';
31
+ className?: string;
32
+ ref?: Ref<HTMLButtonElement>;
33
+ }
34
+
35
+ interface Spot<Id> {
36
+ id: Id;
37
+ left: number;
38
+ top: number;
39
+ }
40
+
41
+ /* Every swatch's top left corner in whole pixels, the rings evenly apart from the outer one to the middle, and the size of the wheel. */
42
+ function layout<Id>(rings: readonly (readonly Id[])[], center: Id | undefined): { size: number; spots: Spot<Id>[] } {
43
+ const outer = rings[0]?.length ?? 0;
44
+ const radius = Math.max(MIN_RADIUS, outer > 1 ? Math.ceil((SWATCH_SIZE + MIN_GAP) / (2 * Math.sin(Math.PI / outer))) : 0);
45
+ const size = 2 * (radius + SWATCH_SIZE / 2 + EDGE);
46
+ const middle = (size - SWATCH_SIZE) / 2;
47
+ const spot = (id: Id, turn: number, distance: number): Spot<Id> => {
48
+ const angle = turn * 2 * Math.PI - Math.PI / 2;
49
+ return { id, left: Math.round(middle + distance * Math.cos(angle)), top: Math.round(middle + distance * Math.sin(angle)) };
50
+ };
51
+ const spots = rings.flatMap((ring, depth) => ring.map((id, i) => spot(id, i / ring.length, (radius * (rings.length - depth)) / rings.length)));
52
+ return { size, spots: center === undefined ? spots : [...spots, spot(center, 0, 0)] };
53
+ }
54
+
55
+ const STEPS: Record<string, (index: number, count: number) => number> = {
56
+ ArrowRight: (index, count) => (index + 1) % count,
57
+ ArrowDown: (index, count) => (index + 1) % count,
58
+ ArrowLeft: (index, count) => (index - 1 + count) % count,
59
+ ArrowUp: (index, count) => (index - 1 + count) % count,
60
+ Home: () => 0,
61
+ End: (_, count) => count - 1
62
+ };
63
+
64
+ /*
65
+ * A color picked from a wheel behind a trigger that holds the one in use and lines up with a select.
66
+ * While the wheel is open the swatch under the pointer or the focus is reported as a preview, so the
67
+ * caller can show it in place; its name stands under the wheel, which is why a swatch carries no
68
+ * tooltip. The wheel is one tab stop: the arrows walk it in its order and only Enter, Space or a click
69
+ * picks. Every preview ends with null, after a pick as well as on closing without one or on going away while open.
70
+ */
71
+ export function ColorWheelPicker<Id extends string>({
72
+ value,
73
+ onValueChange,
74
+ onPreviewChange,
75
+ label,
76
+ rings,
77
+ center,
78
+ colorOf,
79
+ labelOf,
80
+ disabled,
81
+ align = 'start',
82
+ className,
83
+ ref
84
+ }: ColorWheelPickerProps<Id>) {
85
+ const labelId = useId();
86
+ const valueId = useId();
87
+ const [open, setOpen] = useState(false);
88
+ const [looking, setLooking] = useState<Id | null>(null);
89
+ const [focused, setFocused] = useState<Id | null>(null);
90
+ const wheel = useRef<HTMLDivElement>(null);
91
+ const { size, spots } = layout(rings, center);
92
+ const tabStop = spots.find((spot) => spot.id === (focused ?? value))?.id ?? spots[0]?.id;
93
+ // Read by the cleanup below, which runs after the state and the props of its render are gone.
94
+ const preview = useRef({ looking: false, onPreviewChange });
95
+ useEffect(() => {
96
+ preview.current.onPreviewChange = onPreviewChange;
97
+ });
98
+ const look = (id: Id | null): void => {
99
+ setLooking(id);
100
+ preview.current.looking = id !== null;
101
+ onPreviewChange?.(id);
102
+ };
103
+ // A picker taken away while a color is previewed (its dialog closed under it) gets no close of its
104
+ // popover, so it ends the preview itself rather than leave the caller showing a color nobody picked.
105
+ useEffect(
106
+ () => () => {
107
+ if (preview.current.looking) {
108
+ preview.current.onPreviewChange?.(null);
109
+ }
110
+ },
111
+ []
112
+ );
113
+ const close = (): void => {
114
+ setOpen(false);
115
+ setFocused(null);
116
+ look(null);
117
+ };
118
+ const step = (event: KeyboardEvent<HTMLDivElement>): void => {
119
+ const next = STEPS[event.key];
120
+ const swatches = [...(wheel.current?.querySelectorAll<HTMLElement>('[role="radio"]') ?? [])];
121
+ const index = swatches.indexOf(event.target as HTMLElement);
122
+ if (next === undefined || index < 0) {
123
+ return;
124
+ }
125
+ event.preventDefault();
126
+ swatches[next(index, swatches.length)]?.focus();
127
+ };
128
+ return (
129
+ <PopoverRoot
130
+ open={open}
131
+ onOpenChange={(next) => {
132
+ if (next) {
133
+ setOpen(true);
134
+ } else {
135
+ close();
136
+ }
137
+ }}
138
+ >
139
+ <span id={labelId} hidden>
140
+ {label}
141
+ </span>
142
+ <PopoverTrigger ref={ref} disabled={disabled} aria-labelledby={`${labelId} ${valueId}`} className={triggerClassName('md', 'outlined', className)}>
143
+ <span className="h-3.5 w-3.5 shrink-0 rounded-full" style={{ background: colorOf(value) }} aria-hidden />
144
+ <span id={valueId} className="truncate">
145
+ {labelOf(value)}
146
+ </span>
147
+ <span className="ml-auto flex shrink-0 pl-1">
148
+ <Icon icon={ChevronDown} size={14} />
149
+ </span>
150
+ </PopoverTrigger>
151
+ {/* The swatch in use takes the focus: the first would otherwise, and a swatch with the focus is previewed, so the caller would show a color nobody asked for. */}
152
+ <PopoverPopup align={align} initialFocus={() => wheel.current?.querySelector<HTMLElement>('[aria-checked="true"]') ?? false}>
153
+ <div className="flex flex-col items-center gap-3 p-3">
154
+ <div
155
+ ref={wheel}
156
+ role="radiogroup"
157
+ aria-label={label}
158
+ className="relative"
159
+ style={{ width: size, height: size }}
160
+ onPointerLeave={() => look(null)}
161
+ onKeyDown={step}
162
+ >
163
+ {spots.map(({ id, left, top }) => (
164
+ <ColorSwatch
165
+ key={id}
166
+ role="radio"
167
+ aria-checked={value === id}
168
+ aria-label={labelOf(id)}
169
+ tabIndex={id === tabStop ? 0 : -1}
170
+ color={colorOf(id)}
171
+ picked={value === id}
172
+ tick={false}
173
+ on="popup"
174
+ className="absolute"
175
+ style={{ left, top }}
176
+ onPointerEnter={() => look(id)}
177
+ onFocus={() => {
178
+ setFocused(id);
179
+ look(id);
180
+ }}
181
+ onClick={() => {
182
+ onValueChange(id);
183
+ close();
184
+ }}
185
+ />
186
+ ))}
187
+ </div>
188
+ <span className="text-xs text-text-muted">{labelOf(looking ?? value)}</span>
189
+ </div>
190
+ </PopoverPopup>
191
+ </PopoverRoot>
192
+ );
193
+ }
package/src/Select.tsx CHANGED
@@ -7,7 +7,7 @@ import { Icon } from './Icon.tsx';
7
7
  import { MENU_LABEL } from './menu/parts.tsx';
8
8
  import { renderPositioner } from './popup-layer.ts';
9
9
  import { RowContent } from './select-row.tsx';
10
- import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT, type SelectGroup, type SelectItem } from './select-shared.ts';
10
+ import { isGroup, rowClassName, triggerClassName, type SelectGroup, type SelectItem } from './select-shared.ts';
11
11
  import { SelectSearch } from './SelectSearch.tsx';
12
12
 
13
13
  export type { SelectGroup, SelectItem };
@@ -79,17 +79,7 @@ function PlainSelect<T extends string>({
79
79
  disabled={disabled}
80
80
  onValueChange={(next) => onValueChange(next as T)}
81
81
  >
82
- <BaseSelect.Trigger
83
- ref={ref}
84
- aria-label={label}
85
- className={clsx(
86
- 'flex shrink-0 items-center whitespace-nowrap text-xs',
87
- TRIGGER_SIZE[size],
88
- TRIGGER_VARIANT[variant],
89
- TRIGGER_DISABLED,
90
- className
91
- )}
92
- >
82
+ <BaseSelect.Trigger ref={ref} aria-label={label} className={triggerClassName(size, variant, className)}>
93
83
  {selected?.icon}
94
84
  <BaseSelect.Value className={clsx(truncateValue && 'truncate')}>{() => selected?.label ?? placeholder ?? t('action.select')}</BaseSelect.Value>
95
85
  <BaseSelect.Icon className="ml-auto flex shrink-0 pl-1">
@@ -8,7 +8,7 @@ import { MENU_LABEL } from './menu/parts.tsx';
8
8
  import { renderPositioner } from './popup-layer.ts';
9
9
  import type { SelectProps } from './Select.tsx';
10
10
  import { RowContent } from './select-row.tsx';
11
- import { isGroup, rowClassName, TRIGGER_DISABLED, TRIGGER_SIZE, TRIGGER_VARIANT, type SelectGroup, type SelectItem } from './select-shared.ts';
11
+ import { isGroup, rowClassName, triggerClassName, type SelectGroup, type SelectItem } from './select-shared.ts';
12
12
 
13
13
  /* The `Select` with a search field on top of its options, for lists too long to scan. A combobox of Base UI brings the filtering and the keyboard. */
14
14
  export function SelectSearch<T extends string>({
@@ -65,17 +65,7 @@ export function SelectSearch<T extends string>({
65
65
  }
66
66
  }}
67
67
  >
68
- <Combobox.Trigger
69
- ref={ref}
70
- aria-label={label}
71
- className={clsx(
72
- 'flex shrink-0 items-center whitespace-nowrap text-xs',
73
- TRIGGER_SIZE[size],
74
- TRIGGER_VARIANT[variant],
75
- TRIGGER_DISABLED,
76
- className
77
- )}
78
- >
68
+ <Combobox.Trigger ref={ref} aria-label={label} className={triggerClassName(size, variant, className)}>
79
69
  {selected?.icon}
80
70
  <span className={clsx(truncateValue && 'truncate')}>{selected?.label ?? placeholder ?? t('action.select')}</span>
81
71
  <span className="ml-auto flex shrink-0 pl-1">
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export { Checkbox, type CheckboxProps } from './Checkbox.tsx';
35
35
  export { ChoiceCards, type Choice, type ChoiceCardsProps } from './ChoiceCards.tsx';
36
36
  export { CloseButton, type CloseButtonProps } from './CloseButton.tsx';
37
37
  export { ColorSwatch, type ColorSwatchProps } from './ColorSwatch.tsx';
38
+ export { ColorWheelPicker, type ColorWheelPickerProps } from './ColorWheelPicker.tsx';
38
39
  export { ColumnResizeHandle, type ColumnResizeHandleProps } from './ColumnResizeHandle.tsx';
39
40
  export { DisabledReason, type DisabledReasonProps } from './DisabledReason.tsx';
40
41
  export { DockShell, type DockShellProps } from './DockShell.tsx';
@@ -30,5 +30,9 @@ export const TRIGGER_VARIANT = {
30
30
  /* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
31
31
  export const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
32
32
 
33
+ /* The trigger of every select, and of any other picker that stands in a row of selects and has to line up with them. */
34
+ export const triggerClassName = (size: keyof typeof TRIGGER_SIZE, variant: keyof typeof TRIGGER_VARIANT, className?: string): string =>
35
+ clsx('flex shrink-0 items-center whitespace-nowrap text-xs', TRIGGER_SIZE[size], TRIGGER_VARIANT[variant], TRIGGER_DISABLED, className);
36
+
33
37
  /* A description makes the row two lines high; the box keeps the icon on the label's line. */
34
38
  export const rowClassName = (item: SelectItem<string>): string => clsx('menu-item', item.description && 'items-start');