@vitavision/ui 0.6.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.
@@ -0,0 +1,112 @@
1
+ /*
2
+ * A choice among two or three, shown as two or three things.
3
+ *
4
+ * The control for a closed set the app can enumerate up front -- `Literal["small", "medium"]`, an axis choice -- shown all at once, no popup, no typing, and no way to enter
5
+ * a value the backend or algorithm would reject.
6
+ *
7
+ * Built on native radios rather than a headless package: a radio group in one `name` gets
8
+ * roving focus and arrow-key navigation from the platform, which is the entire reason to
9
+ * reach for a library here.
10
+ *
11
+ * **`""` means unset**, and this control keeps that true without showing it. The segment
12
+ * highlighted is always the *effective* value, so the reader sees what will happen;
13
+ * choosing the segment that matches the default stores `""` rather than the value, so the
14
+ * default is still sent by nobody and defined only in one place. See `api/schemaForm.ts`
15
+ * for why that rule is worth this much care.
16
+ */
17
+
18
+ import { useId } from "react";
19
+
20
+ import { useFieldDescription } from "./Field";
21
+ import { cn } from "./cn";
22
+
23
+ /**
24
+ * A choice among two or three, shown as a strip of segments (native radios in one group, so
25
+ * arrow keys move between them).
26
+ *
27
+ * **`""` means unset.** The highlighted segment is always the *effective* value — `value`,
28
+ * or `defaultValue` while `value` is `""` — and choosing the segment that matches
29
+ * `defaultValue` reports `""`, so the default stays defined in one place. Each segment
30
+ * carries `data-state="checked"` or `"unchecked"`; the group carries `data-disabled`.
31
+ */
32
+ export function SegmentedControl({
33
+ value,
34
+ defaultValue,
35
+ options,
36
+ onValueChange,
37
+ disabled = false,
38
+ "aria-label": ariaLabel,
39
+ className,
40
+ }: {
41
+ /** `""` when the field is untouched. */
42
+ value: string;
43
+ /** What applies when nothing is sent. Highlighted while `value` is `""`. */
44
+ defaultValue?: string | undefined;
45
+ /** The segments, in order. */
46
+ options: { value: string; label: string }[];
47
+ /** Called with the chosen value, or `""` when the default's segment is chosen. */
48
+ onValueChange: (value: string) => void;
49
+ /** Blocks every segment. */
50
+ disabled?: boolean | undefined;
51
+ /** Names the radio group. */
52
+ "aria-label"?: string | undefined;
53
+ /** Merged with the strip's own classes through `cn`. */
54
+ className?: string | undefined;
55
+ }) {
56
+ const nameId = useId();
57
+ const described = useFieldDescription();
58
+ const effective = value === "" ? defaultValue : value;
59
+
60
+ return (
61
+ <div
62
+ role="radiogroup"
63
+ aria-label={ariaLabel}
64
+ aria-describedby={described["aria-describedby"]}
65
+ data-disabled={disabled ? "" : undefined}
66
+ className={cn(
67
+ // `shrink-0` because this is a control, not filler: in a crowded toolbar a flex item at
68
+ // its default `1 1 auto` gives up width first, and the segment labels are what pay for
69
+ // it. Let the row scroll instead of squeezing the choice.
70
+ "inline-flex w-fit max-w-full shrink-0 items-center gap-0.5 rounded-control border border-line-strong bg-raised p-0.5",
71
+ disabled && "opacity-50",
72
+ className,
73
+ )}
74
+ >
75
+ {options.map((option) => {
76
+ const checked = option.value === effective;
77
+ return (
78
+ <label
79
+ key={option.value}
80
+ data-state={checked ? "checked" : "unchecked"}
81
+ className={cn(
82
+ // `whitespace-nowrap` or a two-word label's min-content width is its longest
83
+ // *word*, and "Side by side" is free to stack onto three lines inside a
84
+ // fixed-height strip that then clips it.
85
+ "relative cursor-pointer whitespace-nowrap rounded-[0.3rem] px-2.5 py-1 text-xs font-medium transition-colors",
86
+ "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal",
87
+ checked
88
+ ? "bg-surface text-fg shadow-sm"
89
+ : "text-fg-muted hover:text-fg",
90
+ disabled && "cursor-not-allowed",
91
+ )}
92
+ >
93
+ <input
94
+ type="radio"
95
+ name={nameId}
96
+ value={option.value}
97
+ checked={checked}
98
+ disabled={disabled}
99
+ onChange={() =>
100
+ // Selecting the default returns the field to unset: same effective value,
101
+ // nothing sent, and the default keeps living in exactly one place.
102
+ onValueChange(option.value === defaultValue ? "" : option.value)
103
+ }
104
+ className="absolute inset-0 cursor-pointer opacity-0"
105
+ />
106
+ {option.label}
107
+ </label>
108
+ );
109
+ })}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,168 @@
1
+ /*
2
+ * A picker with a list you can actually see.
3
+ *
4
+ * A native `<select>` cannot style its option list on macOS, so the popup arrives in the OS
5
+ * palette in the middle of an otherwise deliberate instrument panel, and it cannot render a
6
+ * two-line option or a disabled one with a reason.
7
+ *
8
+ * The one contract worth reading before changing this: **`""` means unset.** Radix reserves
9
+ * the empty string internally -- an `Item` may not carry it -- so an explicit "use the
10
+ * default" entry travels under a sentinel and is mapped back at the boundary. Callers only
11
+ * ever see `""`.
12
+ */
13
+
14
+ import * as RadixSelect from "@radix-ui/react-select";
15
+ import { Check, ChevronDown } from "lucide-react";
16
+ import { useState } from "react";
17
+
18
+ import { useFieldDescription } from "./Field";
19
+ import { cn, focusRingInset } from "./cn";
20
+
21
+ const UNSET_SENTINEL = "__unset__";
22
+
23
+ /** One entry of a `Select`'s list. */
24
+ export type SelectOption = {
25
+ /** What `onValueChange` reports. Must not be `""`, which means unset. */
26
+ value: string;
27
+ /** What the list and the trigger show. */
28
+ label: string;
29
+ /** Shown quietly after the label -- a strategy, a count, a reason it is unavailable. */
30
+ note?: string;
31
+ /** Shown but not choosable; say why in `note`. */
32
+ disabled?: boolean;
33
+ };
34
+
35
+ /**
36
+ * A picker whose option list is styled with the app, with room for a quiet note per option.
37
+ *
38
+ * Controlled, and **`""` means unset**: the trigger then shows `placeholder`, and the
39
+ * `unsetLabel` entry (when given) reports `""`. The trigger carries Radix's `data-state`
40
+ * (`open`/`closed`), `data-placeholder` while unset and `data-disabled`; inside a `Field` it
41
+ * is described by the field's description and error.
42
+ */
43
+ export function Select({
44
+ value,
45
+ onValueChange,
46
+ options,
47
+ placeholder = "Choose…",
48
+ unsetLabel,
49
+ disabled = false,
50
+ "aria-label": ariaLabel,
51
+ "aria-describedby": ownDescribedBy,
52
+ className,
53
+ }: {
54
+ /** The chosen option's value, or `""` for unset. */
55
+ value: string;
56
+ /** Called with the chosen value, or `""` for the unset entry. */
57
+ onValueChange: (value: string) => void;
58
+ /** The list, in order. */
59
+ options: SelectOption[];
60
+ /** Shown on the trigger while the value is unset. Defaults to "Choose…". */
61
+ placeholder?: string | undefined;
62
+ /** When given, adds a leading entry that returns the field to unset. */
63
+ unsetLabel?: string | undefined;
64
+ /** Blocks the trigger. */
65
+ disabled?: boolean | undefined;
66
+ /** Names the trigger, where no `<label>` does. */
67
+ "aria-label"?: string | undefined;
68
+ /** Ids of elements describing the trigger, merged with a surrounding `Field`'s. */
69
+ "aria-describedby"?: string | undefined;
70
+ /** Merged with the trigger's own classes through `cn`. */
71
+ className?: string | undefined;
72
+ }) {
73
+ const [open, setOpen] = useState(false);
74
+ const described = useFieldDescription(ownDescribedBy);
75
+
76
+ return (
77
+ <RadixSelect.Root
78
+ // Controlled for its whole lifetime: Radix treats `value=""` as "no selection" and shows
79
+ // the placeholder, which is exactly this component's unset.
80
+ value={value}
81
+ onValueChange={(next) => onValueChange(next === UNSET_SENTINEL ? "" : next)}
82
+ open={open}
83
+ onOpenChange={setOpen}
84
+ disabled={disabled}
85
+ >
86
+ <RadixSelect.Trigger
87
+ aria-label={ariaLabel}
88
+ {...described}
89
+ // While the list is open Radix hides everything outside it from assistive technology
90
+ // (`aria-hidden`), this trigger included; a hidden element must not stay in the tab
91
+ // order. Focus returns to it on close all the same.
92
+ tabIndex={open ? -1 : undefined}
93
+ className={cn(
94
+ "flex h-8 w-full items-center justify-between gap-2 rounded-control border border-line-strong bg-raised px-2.5 text-sm text-fg",
95
+ "transition-colors hover:border-fg-subtle",
96
+ "disabled:cursor-not-allowed disabled:opacity-50",
97
+ "data-[placeholder]:text-fg-subtle",
98
+ focusRingInset,
99
+ className,
100
+ )}
101
+ >
102
+ <RadixSelect.Value placeholder={placeholder} />
103
+ <RadixSelect.Icon className="shrink-0 text-fg-subtle">
104
+ <ChevronDown className="size-3.5" />
105
+ </RadixSelect.Icon>
106
+ </RadixSelect.Trigger>
107
+
108
+ <RadixSelect.Portal>
109
+ <RadixSelect.Content
110
+ position="popper"
111
+ sideOffset={4}
112
+ className={cn(
113
+ "z-50 max-h-72 min-w-(--radix-select-trigger-width) overflow-hidden",
114
+ "rounded-panel border border-line bg-overlay shadow-lg shadow-black/25",
115
+ )}
116
+ >
117
+ <RadixSelect.Viewport className="p-1">
118
+ {unsetLabel !== undefined && (
119
+ <SelectItem value={UNSET_SENTINEL} label={unsetLabel} muted />
120
+ )}
121
+ {options.map((option) => (
122
+ <SelectItem
123
+ key={option.value}
124
+ value={option.value}
125
+ label={option.label}
126
+ note={option.note}
127
+ disabled={option.disabled}
128
+ />
129
+ ))}
130
+ </RadixSelect.Viewport>
131
+ </RadixSelect.Content>
132
+ </RadixSelect.Portal>
133
+ </RadixSelect.Root>
134
+ );
135
+ }
136
+
137
+ function SelectItem({
138
+ value,
139
+ label,
140
+ note,
141
+ disabled,
142
+ muted = false,
143
+ }: {
144
+ value: string;
145
+ label: string;
146
+ note?: string | undefined;
147
+ disabled?: boolean | undefined;
148
+ muted?: boolean;
149
+ }) {
150
+ return (
151
+ <RadixSelect.Item
152
+ value={value}
153
+ disabled={disabled ?? false}
154
+ className={cn(
155
+ "relative flex cursor-default items-center gap-2 rounded-control py-1.5 pr-2 pl-7 text-sm outline-none select-none",
156
+ "data-highlighted:bg-raised data-highlighted:text-fg",
157
+ "data-disabled:pointer-events-none data-disabled:opacity-45",
158
+ muted ? "text-fg-muted" : "text-fg",
159
+ )}
160
+ >
161
+ <RadixSelect.ItemIndicator className="absolute left-2 text-signal">
162
+ <Check className="size-3.5" />
163
+ </RadixSelect.ItemIndicator>
164
+ <RadixSelect.ItemText>{label}</RadixSelect.ItemText>
165
+ {note && <span className="ml-auto font-mono text-xs text-fg-subtle">{note}</span>}
166
+ </RadixSelect.Item>
167
+ );
168
+ }
@@ -0,0 +1,96 @@
1
+ /*
2
+ * A continuous value, with its number always on screen.
3
+ *
4
+ * A slider that drives what you are looking at -- a results threshold, an overlay opacity,
5
+ * a caliper search range -- is a measurement instrument: the number matters as much as the
6
+ * position, so it is rendered beside the track rather than left to a separate line of prose.
7
+ */
8
+
9
+ import * as RadixSlider from "@radix-ui/react-slider";
10
+ import type { ReactNode } from "react";
11
+
12
+ import { useFieldDescription } from "./Field";
13
+ import { cn, focusRing } from "./cn";
14
+
15
+ /**
16
+ * A continuous value, with its number rendered beside the track.
17
+ *
18
+ * Controlled. The name (`aria-label`) and a surrounding `Field`'s description go on the
19
+ * thumb — the element with `role="slider"` that a keyboard and a screen reader operate.
20
+ * Radix exposes `data-disabled` and `data-orientation` on the parts.
21
+ */
22
+ export function Slider({
23
+ value,
24
+ onValueChange,
25
+ onValueCommit,
26
+ min = 0,
27
+ max = 1,
28
+ step = 0.01,
29
+ readout,
30
+ disabled = false,
31
+ "aria-label": ariaLabel,
32
+ "aria-describedby": ownDescribedBy,
33
+ className,
34
+ }: {
35
+ /** The current value. */
36
+ value: number;
37
+ /** Called on every move. */
38
+ onValueChange: (value: number) => void;
39
+ /** Called once when a drag or a key press ends — for updates too expensive to run per move. */
40
+ onValueCommit?: ((value: number) => void) | undefined;
41
+ /** Defaults to 0. */
42
+ min?: number | undefined;
43
+ /** Defaults to 1. */
44
+ max?: number | undefined;
45
+ /** Defaults to 0.01. */
46
+ step?: number | undefined;
47
+ /** Rendered to the right of the track. Give it a fixed-width mono span for stability. */
48
+ readout?: ReactNode;
49
+ /** Blocks the thumb and dims the track. */
50
+ disabled?: boolean | undefined;
51
+ /** Names the slider (set on the thumb). */
52
+ "aria-label"?: string | undefined;
53
+ /** Ids of elements describing the slider, merged with a surrounding `Field`'s. */
54
+ "aria-describedby"?: string | undefined;
55
+ /** Merged with the row's own classes through `cn`. */
56
+ className?: string | undefined;
57
+ }) {
58
+ const described = useFieldDescription(ownDescribedBy);
59
+
60
+ return (
61
+ // Grows by default: a slider that does not fill the space it is given collapses to a
62
+ // few pixels of track, which is unusable and reads as a rendering fault.
63
+ <div className={cn("flex min-w-0 flex-1 items-center gap-3", className)}>
64
+ <RadixSlider.Root
65
+ value={[value]}
66
+ onValueChange={([next]) => next !== undefined && onValueChange(next)}
67
+ onValueCommit={([next]) => next !== undefined && onValueCommit?.(next)}
68
+ min={min}
69
+ max={max}
70
+ step={step}
71
+ disabled={disabled}
72
+ className={cn(
73
+ "relative flex h-5 min-w-0 flex-1 touch-none items-center select-none",
74
+ "data-disabled:opacity-50",
75
+ )}
76
+ >
77
+ <RadixSlider.Track className="relative h-1 grow overflow-hidden rounded-full bg-line">
78
+ <RadixSlider.Range className="absolute h-full bg-signal" />
79
+ </RadixSlider.Track>
80
+ <RadixSlider.Thumb
81
+ aria-label={ariaLabel}
82
+ {...described}
83
+ className={cn(
84
+ "block size-3.5 rounded-full border-2 border-signal bg-surface transition-shadow",
85
+ "hover:shadow-[0_0_0_4px] hover:shadow-signal/20",
86
+ "disabled:cursor-not-allowed",
87
+ focusRing,
88
+ )}
89
+ />
90
+ </RadixSlider.Root>
91
+ {readout && (
92
+ <span className="shrink-0 font-mono text-xs text-fg tabular-nums">{readout}</span>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,162 @@
1
+ /*
2
+ * A table.
3
+ *
4
+ * `numeric` is the column property that matters here. Quantities are set in mono with
5
+ * tabular figures and aligned right, so a column of scores or measurements can be compared
6
+ * by eye down its decimal point rather than read one row at a time.
7
+ */
8
+
9
+ import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
10
+
11
+ import { byDensity, useDensity } from "./Density";
12
+ import { cn } from "./cn";
13
+
14
+ /** One column of a `Table`: its header and how to render a row's cell. */
15
+ export type Column<Row> = {
16
+ /** Unique among the columns; the React key of its header and cells. */
17
+ key: string;
18
+ /** The `<th>` content. */
19
+ header: ReactNode;
20
+ /** Right-aligned, mono, tabular. Use for anything that is a quantity. */
21
+ numeric?: boolean;
22
+ /** A CSS width for the column, e.g. `"6rem"`. */
23
+ width?: string;
24
+ /** The cell's content for a row. */
25
+ cell: (row: Row) => ReactNode;
26
+ };
27
+
28
+ /**
29
+ * A table of rows, with numeric columns set in mono and right-aligned.
30
+ *
31
+ * With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
32
+ * their table semantics. The active row carries `aria-current` and `data-state="active"`;
33
+ * padding follows the density in force. With no rows it renders the `empty` message instead.
34
+ */
35
+ export function Table<Row>({
36
+ columns,
37
+ rows,
38
+ rowKey,
39
+ empty = "Nothing here.",
40
+ caption,
41
+ className,
42
+ onRowClick,
43
+ isRowActive,
44
+ onRowHover,
45
+ }: {
46
+ /** The columns, in order. */
47
+ columns: Column<Row>[];
48
+ /** The rows, in order. */
49
+ rows: Row[];
50
+ /** A stable key per row. */
51
+ rowKey: (row: Row, index: number) => string | number;
52
+ /** Shown instead of the table when there are no rows. Defaults to "Nothing here.". */
53
+ empty?: ReactNode;
54
+ /** Names the table for assistive technology (a visually hidden `<caption>`). */
55
+ caption?: string | undefined;
56
+ /** Merged with the scroll wrapper's (or the empty message's) own classes through `cn`. */
57
+ className?: string | undefined;
58
+ /** Makes rows activatable. Keyboard-reachable, so a table used as a list of
59
+ * destinations is not a mouse-only control. The event is passed so a caller can read
60
+ * modifiers — a list kept in step with a canvas needs shift-range and meta-toggle to mean
61
+ * the same thing in both places. */
62
+ onRowClick?: (
63
+ row: Row,
64
+ index: number,
65
+ event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
66
+ ) => void;
67
+ /** Marks the row that is current — the selected match, the open frame. Rendered as
68
+ * `aria-current`, so it is announced rather than only tinted. */
69
+ isRowActive?: (row: Row, index: number) => boolean;
70
+ /** Pointer enter/leave, for tables kept in step with a canvas overlay.
71
+ * `null` on leave. */
72
+ onRowHover?: (row: Row | null, index: number | null) => void;
73
+ }) {
74
+ const density = useDensity();
75
+
76
+ if (rows.length === 0) {
77
+ return (
78
+ <p
79
+ className={cn(
80
+ "text-center text-fg-muted",
81
+ byDensity(density, "py-6 text-sm", "py-3 text-xs"),
82
+ className,
83
+ )}
84
+ >
85
+ {empty}
86
+ </p>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <div className={cn("w-full overflow-x-auto", className)}>
92
+ <table className={cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]"))}>
93
+ {caption && <caption className="sr-only">{caption}</caption>}
94
+ <thead>
95
+ <tr className="border-b border-line">
96
+ {columns.map((column) => (
97
+ <th
98
+ key={column.key}
99
+ scope="col"
100
+ style={column.width ? { width: column.width } : undefined}
101
+ className={cn(
102
+ "font-medium text-fg-muted",
103
+ byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"),
104
+ column.numeric && "text-right",
105
+ )}
106
+ >
107
+ {column.header}
108
+ </th>
109
+ ))}
110
+ </tr>
111
+ </thead>
112
+ <tbody>
113
+ {rows.map((row, index) => (
114
+ <tr
115
+ key={rowKey(row, index)}
116
+ data-state={isRowActive?.(row, index) ? "active" : undefined}
117
+ className={cn(
118
+ "border-b border-line/60 last:border-0",
119
+ onRowClick && "cursor-pointer hover:bg-raised",
120
+ isRowActive?.(row, index) && "bg-signal/10",
121
+ )}
122
+ tabIndex={onRowClick ? 0 : undefined}
123
+ // Deliberately *not* `role="button"`. Overriding a `<tr>`'s implicit `row`
124
+ // makes its `<td>`s' implicit `cell` invalid too — the cells have no row to
125
+ // belong to — so a screen reader stops announcing the table as a table and a
126
+ // reader loses the column each value came from, which on a table of
127
+ // quantities is the whole content. `tabIndex` plus the Enter/Space handler
128
+ // below is what makes the row reachable; the role stays what the element is.
129
+ aria-current={isRowActive?.(row, index) ? true : undefined}
130
+ onClick={onRowClick ? (event) => onRowClick(row, index, event) : undefined}
131
+ onKeyDown={
132
+ onRowClick
133
+ ? (event) => {
134
+ if (event.key === "Enter" || event.key === " ") {
135
+ event.preventDefault();
136
+ onRowClick(row, index, event);
137
+ }
138
+ }
139
+ : undefined
140
+ }
141
+ onPointerEnter={onRowHover ? () => onRowHover(row, index) : undefined}
142
+ onPointerLeave={onRowHover ? () => onRowHover(null, null) : undefined}
143
+ >
144
+ {columns.map((column) => (
145
+ <td
146
+ key={column.key}
147
+ className={cn(
148
+ "align-middle text-fg last:pr-0",
149
+ byDensity(density, "py-2 pr-3", "py-0.5 pr-2"),
150
+ column.numeric && "text-right font-mono tabular-nums",
151
+ )}
152
+ >
153
+ {column.cell(row)}
154
+ </td>
155
+ ))}
156
+ </tr>
157
+ ))}
158
+ </tbody>
159
+ </table>
160
+ </div>
161
+ );
162
+ }