@adecore/ui 0.27.0 → 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.
- package/dist/ColorSwatch.d.ts +2 -1
- package/dist/ColorSwatch.js +2 -2
- package/dist/ColorWheelPicker.d.ts +16 -0
- package/dist/ColorWheelPicker.js +97 -0
- package/dist/Select.js +2 -2
- package/dist/SelectSearch.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/select-shared.d.ts +1 -0
- package/dist/select-shared.js +2 -0
- package/package.json +1 -1
- package/src/ColorSwatch.tsx +4 -2
- package/src/ColorWheelPicker.tsx +193 -0
- package/src/Select.tsx +2 -12
- package/src/SelectSearch.tsx +2 -12
- package/src/index.ts +1 -0
- package/src/select-shared.ts +4 -0
package/dist/ColorSwatch.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/ColorSwatch.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
}
|
package/dist/SelectSearch.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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';
|
package/dist/select-shared.d.ts
CHANGED
|
@@ -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;
|
package/dist/select-shared.js
CHANGED
|
@@ -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
package/src/ColorSwatch.tsx
CHANGED
|
@@ -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,
|
|
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">
|
package/src/SelectSearch.tsx
CHANGED
|
@@ -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,
|
|
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';
|
package/src/select-shared.ts
CHANGED
|
@@ -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');
|