@vitavision/ui 0.8.0 → 0.10.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitavision/ui",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "The vitavision instrument design system: Tailwind v4 tokens, theme, and React primitives.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -50,6 +50,8 @@
50
50
  "dependencies": {
51
51
  "@radix-ui/react-checkbox": "^1.3.11",
52
52
  "@radix-ui/react-dialog": "^1.1.23",
53
+ "@radix-ui/react-dropdown-menu": "^2.1.24",
54
+ "@radix-ui/react-popover": "^1.1.23",
53
55
  "@radix-ui/react-select": "^2.3.7",
54
56
  "@radix-ui/react-slider": "^1.4.7",
55
57
  "@radix-ui/react-slot": "^1.3.3",
@@ -0,0 +1,179 @@
1
+ /*
2
+ * A menu of commands or toggles behind a trigger: a layers menu, an overflow menu.
3
+ *
4
+ * Use it for things a person *does* or *switches*. To pick one value from a set use `Select`,
5
+ * or a `Listbox` in a `Popover` when the options need more than a label.
6
+ */
7
+
8
+ import * as RadixMenu from "@radix-ui/react-dropdown-menu";
9
+ import { Check } from "lucide-react";
10
+ import type { ReactNode } from "react";
11
+
12
+ import { Kbd } from "./Kbd";
13
+ import { cn } from "./cn";
14
+
15
+ /** Props of `DropdownMenu`. */
16
+ export interface DropdownMenuProps {
17
+ /** The element that opens it: one focusable element that accepts a ref (Radix `asChild`). */
18
+ trigger: ReactNode;
19
+ /** `MenuItem`, `MenuCheckboxItem`, `MenuLabel` and `MenuSeparator` elements. */
20
+ children: ReactNode;
21
+ /** Controlled open state. */
22
+ open?: boolean | undefined;
23
+ /** Called when it opens or closes. */
24
+ onOpenChange?: ((open: boolean) => void) | undefined;
25
+ /** Its alignment along the trigger. Defaults to `start`. */
26
+ align?: "start" | "center" | "end" | undefined;
27
+ /** The trigger's side it opens on. Defaults to `bottom`. */
28
+ side?: "top" | "right" | "bottom" | "left" | undefined;
29
+ /** Merged with the menu's own classes through `cn`. */
30
+ className?: string | undefined;
31
+ }
32
+
33
+ /**
34
+ * A menu (`role="menu"`) that opens from `trigger`, with arrow-key navigation, typeahead,
35
+ * Escape or a press outside to close, and focus returned to the trigger. It is non-modal: the
36
+ * page behind stays readable and operable. Toggling a `MenuCheckboxItem` keeps the menu open,
37
+ * so several layers can be switched in one visit.
38
+ */
39
+ export function DropdownMenu({
40
+ trigger,
41
+ children,
42
+ open,
43
+ onOpenChange,
44
+ align = "start",
45
+ side = "bottom",
46
+ className,
47
+ }: DropdownMenuProps) {
48
+ return (
49
+ <RadixMenu.Root
50
+ // Non-modal: a modal menu hides the rest of the page from assistive technology while
51
+ // its trigger stays focusable under it, and a layers menu is used next to the canvas
52
+ // it controls.
53
+ modal={false}
54
+ {...(open === undefined ? {} : { open })}
55
+ {...(onOpenChange === undefined ? {} : { onOpenChange })}
56
+ >
57
+ <RadixMenu.Trigger asChild>{trigger}</RadixMenu.Trigger>
58
+ <RadixMenu.Portal>
59
+ <RadixMenu.Content
60
+ side={side}
61
+ align={align}
62
+ sideOffset={6}
63
+ collisionPadding={8}
64
+ className={cn(
65
+ "z-50 min-w-44 rounded-panel border border-line bg-overlay p-1 text-sm text-fg shadow-lg shadow-black/25",
66
+ className,
67
+ )}
68
+ >
69
+ {children}
70
+ </RadixMenu.Content>
71
+ </RadixMenu.Portal>
72
+ </RadixMenu.Root>
73
+ );
74
+ }
75
+
76
+ const itemClasses = cn(
77
+ "relative flex h-7 cursor-default items-center gap-2 rounded-control pr-2 pl-7 text-sm outline-none select-none",
78
+ "data-[highlighted]:bg-raised data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
79
+ );
80
+
81
+ /** Props of `MenuItem`. */
82
+ export interface MenuItemProps {
83
+ /** The item's text. */
84
+ children: ReactNode;
85
+ /** Runs the command. */
86
+ onSelect?: (() => void) | undefined;
87
+ /** An icon before the text, in the indicator column. */
88
+ icon?: ReactNode;
89
+ /** `defect` for a destructive command. */
90
+ tone?: "defect" | undefined;
91
+ /** A shortcut shown at the end of the row, as a `Kbd`. Display only: binding it is the app's. */
92
+ shortcut?: string | undefined;
93
+ /** Blocks the item. */
94
+ disabled?: boolean | undefined;
95
+ }
96
+
97
+ /**
98
+ * A command. `onSelect` runs when it is clicked or chosen with Enter, and the menu then
99
+ * closes. `tone="defect"` marks a destructive command.
100
+ */
101
+ export function MenuItem({ children, onSelect, shortcut, disabled, icon, tone }: MenuItemProps) {
102
+ return (
103
+ <RadixMenu.Item
104
+ {...(disabled === undefined ? {} : { disabled })}
105
+ onSelect={() => onSelect?.()}
106
+ className={cn(itemClasses, tone === "defect" && "text-defect")}
107
+ >
108
+ {icon && (
109
+ <span aria-hidden className="absolute left-2 flex size-3.5 items-center [&>svg]:size-3.5">
110
+ {icon}
111
+ </span>
112
+ )}
113
+ <span className="min-w-0 flex-1 truncate">{children}</span>
114
+ {shortcut && <Kbd>{shortcut}</Kbd>}
115
+ </RadixMenu.Item>
116
+ );
117
+ }
118
+
119
+ /** Props of `MenuCheckboxItem`. */
120
+ export interface MenuCheckboxItemProps {
121
+ /** The item's text. */
122
+ children: ReactNode;
123
+ /** Whether it is on. */
124
+ checked: boolean;
125
+ /** Called with the new state. */
126
+ onCheckedChange: (checked: boolean) => void;
127
+ /** A shortcut shown at the end of the row, as a `Kbd`. Display only: binding it is the app's. */
128
+ shortcut?: string | undefined;
129
+ /** Blocks the item. */
130
+ disabled?: boolean | undefined;
131
+ }
132
+
133
+ /**
134
+ * A toggle (`role="menuitemcheckbox"`): a layer on or off. A check marks it on. Choosing it
135
+ * leaves the menu open.
136
+ */
137
+ export function MenuCheckboxItem({
138
+ children,
139
+ checked,
140
+ onCheckedChange,
141
+ shortcut,
142
+ disabled,
143
+ }: MenuCheckboxItemProps) {
144
+ return (
145
+ <RadixMenu.CheckboxItem
146
+ checked={checked}
147
+ onCheckedChange={onCheckedChange}
148
+ {...(disabled === undefined ? {} : { disabled })}
149
+ // A toggle in a menu of toggles: keep the menu open for the next one.
150
+ onSelect={(event) => event.preventDefault()}
151
+ className={itemClasses}
152
+ >
153
+ <RadixMenu.ItemIndicator className="absolute left-2 flex items-center text-signal">
154
+ <Check className="size-3.5" aria-hidden />
155
+ </RadixMenu.ItemIndicator>
156
+ <span className="min-w-0 flex-1 truncate">{children}</span>
157
+ {shortcut && <Kbd>{shortcut}</Kbd>}
158
+ </RadixMenu.CheckboxItem>
159
+ );
160
+ }
161
+
162
+ /** A heading over a group of items, in the eyebrow role. Not focusable. */
163
+ export function MenuLabel({
164
+ children,
165
+ }: {
166
+ /** The heading. */
167
+ children: ReactNode;
168
+ }) {
169
+ return (
170
+ <RadixMenu.Label className="px-2 pt-1.5 pb-1 text-[11px] font-semibold tracking-wider text-fg-muted uppercase">
171
+ {children}
172
+ </RadixMenu.Label>
173
+ );
174
+ }
175
+
176
+ /** A divider between groups of items. */
177
+ export function MenuSeparator() {
178
+ return <RadixMenu.Separator className="my-1 h-px bg-line" />;
179
+ }
@@ -5,11 +5,12 @@
5
5
  * can still pick up the shared palette and focus treatment.
6
6
  */
7
7
 
8
- import { useId, type ComponentProps } from "react";
8
+ import { useId, useState, type ComponentProps, type KeyboardEvent } from "react";
9
9
 
10
10
  import { byDensity, useDensity } from "./Density";
11
11
  import { useFieldDescription } from "./Field";
12
12
  import { cn, focusRingInset } from "./cn";
13
+ import { formatNumber, parseNumber } from "./numberText";
13
14
 
14
15
  /**
15
16
  * The shared look of a text-like control — border, palette, placeholder, hover, disabled and
@@ -54,8 +55,40 @@ export function Input({ className, "aria-describedby": describedBy, ...rest }: C
54
55
  );
55
56
  }
56
57
 
57
- /** Props of `NumberInput`: every `<input>` prop, `ref` included, with numeric bounds and a `unit`. */
58
- export type NumberInputProps = Omit<ComponentProps<"input">, "min" | "max"> & {
58
+ /**
59
+ * Props of `NumberInput`: every `<input>` prop, `ref` included, with numeric bounds, a `unit`,
60
+ * and an optional number-valued API (`onValueChange`, `precision`).
61
+ */
62
+ export type NumberInputProps = Omit<ComponentProps<"input">, "min" | "max" | "value"> & {
63
+ /**
64
+ * The value. As text, it is the native input's `value`. With `onValueChange` it is a
65
+ * number, and `null` shows an empty field.
66
+ */
67
+ value?: string | number | readonly string[] | null | undefined;
68
+ /**
69
+ * Makes the field number-valued.
70
+ *
71
+ * - **While focused** it keeps the text as typed. A keystroke is reported only when the
72
+ * text is a finite number inside `[min, max]` and differs from `value`. Empty, partial
73
+ * (`-`, `1e`) and out-of-range text is never reported, so clearing a field to retype it
74
+ * does not pass through 0, and typing `5` on the way to `50` does not report a value under
75
+ * `min`.
76
+ * - **Enter** shows the value at `precision`. **Escape** restores the value the field had
77
+ * on focus.
78
+ * - **On leaving**, the field shows `value` again.
79
+ */
80
+ onValueChange?: ((value: number) => void) | undefined;
81
+ /**
82
+ * Called when a number-valued field is committed empty (left, or Enter pressed, with no
83
+ * text), for a quantity that may be unset. Without it, emptying the field and leaving it
84
+ * restores `value`.
85
+ */
86
+ onClear?: (() => void) | undefined;
87
+ /**
88
+ * Decimals shown when a number-valued field is not being edited. Without it the number is
89
+ * shown as JavaScript prints it.
90
+ */
91
+ precision?: number | undefined;
59
92
  /** The smallest accepted value. */
60
93
  min?: number | undefined;
61
94
  /** The largest accepted value. */
@@ -80,12 +113,104 @@ export type NumberInputProps = Omit<ComponentProps<"input">, "min" | "max"> & {
80
113
  * With a `unit`, the input sits in a `relative` full-width wrapper carrying `data-unit`, keeps
81
114
  * room on its right for the unit, and is described by it; size such a field through its
82
115
  * container. Without one — or with `""` — the markup is exactly the plain input.
116
+ *
117
+ * With `onValueChange` the field is number-valued: `value` is a number (`null` shows empty), the
118
+ * typed text survives while the field has focus, and only finite numbers inside `[min, max]`
119
+ * are reported (see `onValueChange`; `onClear` reports an emptied field). Without it the field
120
+ * is the native text-valued input.
83
121
  */
84
- export function NumberInput({ unit, ...props }: NumberInputProps) {
122
+ export function NumberInput({ onValueChange, onClear, precision, ...props }: NumberInputProps) {
123
+ if (onValueChange) {
124
+ return <ValueNumberInput {...props} onValueChange={onValueChange} onClear={onClear} precision={precision} />;
125
+ }
126
+ const { unit, ...rest } = props;
127
+ // A `null` text value is an empty field, not a switch to uncontrolled.
128
+ const text = (rest.value === null ? { ...rest, value: "" } : rest) as TextNumberInputProps;
85
129
  return unit === undefined || unit === "" ? (
86
- <PlainNumberInput {...props} />
130
+ <PlainNumberInput {...text} />
87
131
  ) : (
88
- <UnitNumberInput unit={unit} {...props} />
132
+ <UnitNumberInput unit={unit} {...text} />
133
+ );
134
+ }
135
+
136
+ type TextNumberInputProps = Omit<NumberInputProps, "unit" | "value" | "onValueChange" | "onClear" | "precision"> & {
137
+ value?: string | number | readonly string[] | undefined;
138
+ };
139
+
140
+ /** The number-valued field: formatted at rest, the typed text while focused. */
141
+ function ValueNumberInput({
142
+ value,
143
+ onValueChange,
144
+ onClear,
145
+ precision,
146
+ step,
147
+ min,
148
+ max,
149
+ onFocus,
150
+ onBlur,
151
+ onChange,
152
+ onKeyDown,
153
+ ...rest
154
+ }: Omit<NumberInputProps, "onValueChange"> & { onValueChange: (value: number) => void }) {
155
+ const numeric = typeof value === "number" ? value : null;
156
+ const show = (v: number | null) =>
157
+ v === null ? "" : precision === undefined ? String(v) : formatNumber(v, precision);
158
+ // Non-null while the field is being edited: the text as typed, and the value on focus.
159
+ const [draft, setDraft] = useState<{ text: string; initial: number | null } | null>(null);
160
+
161
+ /** The number `text` commits, or `null` while it is empty, partial or out of range. */
162
+ const parse = (text: string) => {
163
+ const parsed = parseNumber(text);
164
+ if (parsed === null) return null;
165
+ if ((min !== undefined && parsed < min) || (max !== undefined && parsed > max)) return null;
166
+ return parsed;
167
+ };
168
+
169
+ /** An emptied field, committed: cleared if the caller allows it and it was not already empty. */
170
+ const commitEmpty = (text: string) => {
171
+ if (text.trim() === "" && numeric !== null) onClear?.();
172
+ };
173
+
174
+ const keyDown = (event: KeyboardEvent<HTMLInputElement>) => {
175
+ if (draft && event.key === "Enter") {
176
+ commitEmpty(draft.text);
177
+ setDraft({ ...draft, text: draft.text.trim() === "" && onClear ? "" : show(numeric) });
178
+ } else if (draft && event.key === "Escape") {
179
+ event.preventDefault();
180
+ if (draft.initial !== numeric) {
181
+ if (draft.initial === null) onClear?.();
182
+ else onValueChange(draft.initial);
183
+ }
184
+ setDraft({ ...draft, text: show(draft.initial) });
185
+ }
186
+ onKeyDown?.(event);
187
+ };
188
+
189
+ return (
190
+ <NumberInput
191
+ {...rest}
192
+ value={draft?.text ?? show(numeric)}
193
+ step={step ?? "any"}
194
+ min={min}
195
+ max={max}
196
+ onFocus={(event) => {
197
+ setDraft({ text: show(numeric), initial: numeric });
198
+ onFocus?.(event);
199
+ }}
200
+ onBlur={(event) => {
201
+ if (draft) commitEmpty(draft.text);
202
+ setDraft(null);
203
+ onBlur?.(event);
204
+ }}
205
+ onKeyDown={keyDown}
206
+ onChange={(event) => {
207
+ const text = event.currentTarget.value;
208
+ setDraft((current) => ({ text, initial: current?.initial ?? numeric }));
209
+ const parsed = parse(text);
210
+ if (parsed !== null && parsed !== numeric) onValueChange(parsed);
211
+ onChange?.(event);
212
+ }}
213
+ />
89
214
  );
90
215
  }
91
216
 
@@ -93,7 +218,7 @@ function PlainNumberInput({
93
218
  className,
94
219
  "aria-describedby": describedBy,
95
220
  ...rest
96
- }: Omit<NumberInputProps, "unit">) {
221
+ }: TextNumberInputProps) {
97
222
  return (
98
223
  <input
99
224
  {...useFieldDescription(describedBy)}
@@ -115,7 +240,7 @@ function UnitNumberInput({
115
240
  style,
116
241
  "aria-describedby": describedBy,
117
242
  ...rest
118
- }: Omit<NumberInputProps, "unit"> & { unit: string }) {
243
+ }: TextNumberInputProps & { unit: string }) {
119
244
  const unitId = useId();
120
245
  return (
121
246
  <span data-unit={unit} className="relative flex w-full min-w-0 items-center">
@@ -0,0 +1,27 @@
1
+ /*
2
+ * A key, as the keyboard shows it: the shortcut next to a command, the key a hint refers to.
3
+ */
4
+
5
+ import type { ComponentProps } from "react";
6
+
7
+ import { cn } from "./cn";
8
+
9
+ /**
10
+ * A keyboard key or chord (`F`, `⌘K`, `[`), set in mono on a raised key cap.
11
+ *
12
+ * Renders `<kbd>`, which screen readers announce as text. Write the key as the user's keyboard
13
+ * labels it; for a chord, one `Kbd` with the whole chord reads better than several. Takes
14
+ * every `<kbd>` prop, `ref` included.
15
+ */
16
+ export function Kbd({ className, ...rest }: ComponentProps<"kbd">) {
17
+ return (
18
+ <kbd
19
+ {...rest}
20
+ className={cn(
21
+ "inline-flex min-w-[1.5em] items-center justify-center rounded-control border border-line-strong",
22
+ "bg-raised px-1 font-mono text-[10px] leading-4 text-fg-muted",
23
+ className,
24
+ )}
25
+ />
26
+ );
27
+ }
@@ -0,0 +1,160 @@
1
+ /*
2
+ * Pick one of a list of options that need more than a label: a frame with its thumbnail and
3
+ * size, a model with its point counts.
4
+ *
5
+ * `Select` is the compact choice of a value by name. A `Listbox` shows its options in place —
6
+ * on its own in a panel, or inside a `Popover` behind a trigger — and each option can render
7
+ * whatever identifies it.
8
+ */
9
+
10
+ import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
11
+
12
+ import { cn, focusRing } from "./cn";
13
+ import { moveActive, nearestEnabled, type ListboxKey } from "./listboxModel";
14
+
15
+ /** One option of a `Listbox`. */
16
+ export interface ListboxOption {
17
+ /** Its value, unique in the list. */
18
+ value: string;
19
+ /** Its name: the default rendering, and what a screen reader announces. */
20
+ label: string;
21
+ /** A second line under the label, in the default rendering. */
22
+ description?: string | undefined;
23
+ /** Shown but not choosable. */
24
+ disabled?: boolean | undefined;
25
+ }
26
+
27
+ /** What `renderOption` is told about an option. */
28
+ export interface ListboxOptionState {
29
+ /** It is the value. */
30
+ selected: boolean;
31
+ /** It has the keyboard cursor or the pointer. */
32
+ active: boolean;
33
+ }
34
+
35
+ /** Props of `Listbox`. */
36
+ export interface ListboxProps {
37
+ /** The options, in order. */
38
+ options: readonly ListboxOption[];
39
+ /** The selected value, or `null` for none. */
40
+ value: string | null;
41
+ /** Called with the value of the option chosen by click, Enter or Space. */
42
+ onValueChange?: ((value: string) => void) | undefined;
43
+ /**
44
+ * Renders an option's content (the row keeps its own padding, states and accessibility).
45
+ * Defaults to the label, with the description under it.
46
+ */
47
+ renderOption?: ((option: ListboxOption, state: ListboxOptionState) => ReactNode) | undefined;
48
+ /** Names the list. */
49
+ "aria-label": string;
50
+ /** Focus the list when it mounts, e.g. when it opens in a `Popover`. */
51
+ autoFocus?: boolean | undefined;
52
+ /** Merged with the list's own classes through `cn`. */
53
+ className?: string | undefined;
54
+ }
55
+
56
+ const KEYS = new Set<string>(["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"]);
57
+
58
+ /**
59
+ * A single-select list (`role="listbox"`) of options that each render their own content.
60
+ *
61
+ * The list is one tab stop. The arrow keys, Home, End and Page keys move the active option
62
+ * (`aria-activedescendant`), skipping disabled ones, and Enter or Space choose it. A click
63
+ * chooses the option under the pointer. Controlled: the caller keeps `value`. Each option
64
+ * carries `aria-selected`, `data-selected` and `data-active`; the list scrolls the active
65
+ * option into view.
66
+ */
67
+ export function Listbox({
68
+ options,
69
+ value,
70
+ onValueChange,
71
+ renderOption = defaultRender,
72
+ "aria-label": ariaLabel,
73
+ autoFocus,
74
+ className,
75
+ }: ListboxProps) {
76
+ const id = useId();
77
+ const listRef = useRef<HTMLDivElement>(null);
78
+ const disabled = options.map((option) => option.disabled === true);
79
+ const selectedIndex = options.findIndex((option) => option.value === value);
80
+ const [active, setActive] = useState(() => nearestEnabled(disabled, Math.max(0, selectedIndex)));
81
+ // Options can change under the list; keep the cursor on an enabled option that exists.
82
+ const current = active >= 0 && active < options.length && !disabled[active] ? active : nearestEnabled(disabled, active);
83
+
84
+ const optionId = (index: number) => `${id}-option-${index}`;
85
+
86
+ const activate = (index: number) => {
87
+ setActive(index);
88
+ listRef.current?.querySelector(`#${CSS.escape(optionId(index))}`)?.scrollIntoView({ block: "nearest" });
89
+ };
90
+
91
+ const choose = (index: number) => {
92
+ const option = options[index];
93
+ if (!option || option.disabled) return;
94
+ onValueChange?.(option.value);
95
+ };
96
+
97
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
98
+ if (KEYS.has(event.key)) {
99
+ event.preventDefault();
100
+ const next = moveActive(disabled, current, event.key as ListboxKey);
101
+ if (next >= 0) activate(next);
102
+ } else if (event.key === "Enter" || event.key === " ") {
103
+ event.preventDefault();
104
+ choose(current);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <div
110
+ ref={listRef}
111
+ role="listbox"
112
+ aria-label={ariaLabel}
113
+ tabIndex={0}
114
+ autoFocus={autoFocus}
115
+ aria-activedescendant={current >= 0 ? optionId(current) : undefined}
116
+ onKeyDown={onKeyDown}
117
+ className={cn("flex flex-col gap-px rounded-control p-0.5 outline-none", focusRing, className)}
118
+ >
119
+ {options.map((option, index) => {
120
+ const selected = index === selectedIndex;
121
+ const isActive = index === current;
122
+ return (
123
+ <div
124
+ key={option.value}
125
+ id={optionId(index)}
126
+ role="option"
127
+ aria-selected={selected}
128
+ aria-disabled={option.disabled ? true : undefined}
129
+ data-selected={selected ? "" : undefined}
130
+ data-active={isActive ? "" : undefined}
131
+ onPointerMove={() => {
132
+ if (!option.disabled && !isActive) setActive(index);
133
+ }}
134
+ onClick={() => choose(index)}
135
+ className={cn(
136
+ "flex min-w-0 cursor-default items-center gap-2 rounded-control px-2 py-1 text-sm text-fg",
137
+ // Selected is a bar on the leading edge plus the accent text; active (keyboard
138
+ // cursor or pointer) is the raised fill. The two can coincide and each stays
139
+ // visible, and neither puts muted text on a tint it fails contrast on.
140
+ "data-[selected]:text-signal data-[selected]:shadow-[inset_2px_0_0_var(--color-signal)]",
141
+ "data-[active]:bg-raised",
142
+ option.disabled && "opacity-50",
143
+ )}
144
+ >
145
+ {renderOption(option, { selected, active: isActive })}
146
+ </div>
147
+ );
148
+ })}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ function defaultRender(option: ListboxOption) {
154
+ return (
155
+ <span className="flex min-w-0 flex-col">
156
+ <span className="truncate">{option.label}</span>
157
+ {option.description && <span className="truncate text-xs text-fg-muted">{option.description}</span>}
158
+ </span>
159
+ );
160
+ }
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Floating content anchored to a trigger: a picker, a small form, a list to choose from.
3
+ *
4
+ * A popover is for content a person works in. A *menu* of commands is `DropdownMenu`, and a
5
+ * short explanation is `Tooltip`. It closes on Escape, on a press outside, or when the caller
6
+ * sets `open` to false.
7
+ */
8
+
9
+ import * as RadixPopover from "@radix-ui/react-popover";
10
+ import type { ReactNode } from "react";
11
+
12
+ import { cn } from "./cn";
13
+
14
+ /** Props of `Popover`. */
15
+ export interface PopoverProps {
16
+ /**
17
+ * The element that opens it: one focusable element that accepts a ref (Radix `asChild`).
18
+ * It carries `aria-expanded` and `data-state` (`open` / `closed`).
19
+ */
20
+ trigger: ReactNode;
21
+ /** The content. */
22
+ children: ReactNode;
23
+ /** Controlled open state. */
24
+ open?: boolean | undefined;
25
+ /** Initial open state when uncontrolled. */
26
+ defaultOpen?: boolean | undefined;
27
+ /** Called when it opens or closes. */
28
+ onOpenChange?: ((open: boolean) => void) | undefined;
29
+ /** The trigger's side it opens on. Defaults to `bottom`. */
30
+ side?: "top" | "right" | "bottom" | "left" | undefined;
31
+ /** Its alignment along that side. Defaults to `start`. */
32
+ align?: "start" | "center" | "end" | undefined;
33
+ /** Names the content (`aria-label`), when nothing inside it does. */
34
+ "aria-label"?: string | undefined;
35
+ /** Merged with the content's own classes through `cn`. */
36
+ className?: string | undefined;
37
+ }
38
+
39
+ /**
40
+ * Floating content anchored to `trigger`, on the `overlay` surface with a shadow (floating
41
+ * layers are the only ones that cast one).
42
+ *
43
+ * Controlled (`open` / `onOpenChange`) or not (`defaultOpen`). Focus moves into the content
44
+ * when it opens and back to the trigger when it closes. Escape and a press outside close it.
45
+ * The content carries `data-state` and `data-side`.
46
+ */
47
+ export function Popover({
48
+ trigger,
49
+ children,
50
+ open,
51
+ defaultOpen,
52
+ onOpenChange,
53
+ side = "bottom",
54
+ align = "start",
55
+ "aria-label": ariaLabel,
56
+ className,
57
+ }: PopoverProps) {
58
+ return (
59
+ <RadixPopover.Root
60
+ {...(open === undefined ? {} : { open })}
61
+ {...(defaultOpen === undefined ? {} : { defaultOpen })}
62
+ {...(onOpenChange === undefined ? {} : { onOpenChange })}
63
+ >
64
+ <RadixPopover.Trigger asChild>{trigger}</RadixPopover.Trigger>
65
+ <RadixPopover.Portal>
66
+ <RadixPopover.Content
67
+ side={side}
68
+ align={align}
69
+ sideOffset={6}
70
+ collisionPadding={8}
71
+ aria-label={ariaLabel}
72
+ className={cn(
73
+ "z-50 max-h-[var(--radix-popover-content-available-height)] overflow-y-auto",
74
+ "rounded-panel border border-line bg-overlay p-1 text-sm text-fg shadow-lg shadow-black/25",
75
+ className,
76
+ )}
77
+ >
78
+ {children}
79
+ </RadixPopover.Content>
80
+ </RadixPopover.Portal>
81
+ </RadixPopover.Root>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Closes the surrounding `Popover` when its child is activated. Wraps one element (Radix
87
+ * `asChild`).
88
+ */
89
+ export function PopoverClose({
90
+ children,
91
+ }: {
92
+ /** The one element that closes the popover. */
93
+ children: ReactNode;
94
+ }) {
95
+ return <RadixPopover.Close asChild>{children}</RadixPopover.Close>;
96
+ }