@vitavision/ui 0.9.0 → 0.11.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/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { twMerge } from "tailwind-merge";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
  import { Slot, Slottable } from "@radix-ui/react-slot";
8
8
  import * as RadixDialog from "@radix-ui/react-dialog";
9
+ import * as RadixMenu from "@radix-ui/react-dropdown-menu";
10
+ import * as RadixPopover from "@radix-ui/react-popover";
9
11
  import * as RadixSelect from "@radix-ui/react-select";
10
12
  import * as RadixSlider from "@radix-ui/react-slider";
11
13
  import * as RadixCheckbox from "@radix-ui/react-checkbox";
@@ -351,7 +353,7 @@ const VARIANT_CLASSES = {
351
353
  ghost: "text-fg-muted hover:bg-raised hover:text-fg",
352
354
  danger: "bg-defect/10 text-defect ring-1 ring-inset ring-defect/30 hover:bg-defect/15"
353
355
  };
354
- const SIZE_CLASSES = {
356
+ const SIZE_CLASSES$1 = {
355
357
  sm: "h-7 gap-1.5 px-2.5 text-xs",
356
358
  md: "h-8 gap-2 px-3 text-sm"
357
359
  };
@@ -365,7 +367,7 @@ const SIZE_CLASSES = {
365
367
  * @returns The class string.
366
368
  */
367
369
  function buttonClasses({ variant = "secondary", size = "md", className } = {}) {
368
- return cn("inline-flex shrink-0 items-center justify-center rounded-control font-medium transition-colors", "disabled:cursor-not-allowed disabled:opacity-45", SIZE_CLASSES[size], VARIANT_CLASSES[variant], focusRing, className);
370
+ return cn("inline-flex shrink-0 items-center justify-center rounded-control font-medium transition-colors", "disabled:cursor-not-allowed disabled:opacity-45", SIZE_CLASSES$1[size], VARIANT_CLASSES[variant], focusRing, className);
369
371
  }
370
372
  function ButtonIcon({ icon }) {
371
373
  return /* @__PURE__ */ jsx("span", {
@@ -563,6 +565,109 @@ function Disclosure({ summary, count, defaultOpen = false, className, children }
563
565
  });
564
566
  }
565
567
  //#endregion
568
+ //#region src/components/ui/Kbd.tsx
569
+ /**
570
+ * A keyboard key or chord (`F`, `⌘K`, `[`), set in mono on a raised key cap.
571
+ *
572
+ * Renders `<kbd>`, which screen readers announce as text. Write the key as the user's keyboard
573
+ * labels it; for a chord, one `Kbd` with the whole chord reads better than several. Takes
574
+ * every `<kbd>` prop, `ref` included.
575
+ */
576
+ function Kbd({ className, ...rest }) {
577
+ return /* @__PURE__ */ jsx("kbd", {
578
+ ...rest,
579
+ className: cn("inline-flex min-w-[1.5em] items-center justify-center rounded-control border border-line-strong", "bg-raised px-1 font-mono text-[10px] leading-4 text-fg-muted", className)
580
+ });
581
+ }
582
+ //#endregion
583
+ //#region src/components/ui/DropdownMenu.tsx
584
+ /**
585
+ * A menu (`role="menu"`) that opens from `trigger`, with arrow-key navigation, typeahead,
586
+ * Escape or a press outside to close, and focus returned to the trigger. It is non-modal: the
587
+ * page behind stays readable and operable. Toggling a `MenuCheckboxItem` keeps the menu open,
588
+ * so several layers can be switched in one visit.
589
+ */
590
+ function DropdownMenu({ trigger, children, open, onOpenChange, align = "start", side = "bottom", className }) {
591
+ return /* @__PURE__ */ jsxs(RadixMenu.Root, {
592
+ modal: false,
593
+ ...open === void 0 ? {} : { open },
594
+ ...onOpenChange === void 0 ? {} : { onOpenChange },
595
+ children: [/* @__PURE__ */ jsx(RadixMenu.Trigger, {
596
+ asChild: true,
597
+ children: trigger
598
+ }), /* @__PURE__ */ jsx(RadixMenu.Portal, { children: /* @__PURE__ */ jsx(RadixMenu.Content, {
599
+ side,
600
+ align,
601
+ sideOffset: 6,
602
+ collisionPadding: 8,
603
+ className: cn("z-50 min-w-44 rounded-panel border border-line bg-overlay p-1 text-sm text-fg shadow-lg shadow-black/25", className),
604
+ children
605
+ }) })]
606
+ });
607
+ }
608
+ const itemClasses = cn("relative flex h-7 cursor-default items-center gap-2 rounded-control pr-2 pl-7 text-sm outline-none select-none", "data-[highlighted]:bg-raised data-[disabled]:pointer-events-none data-[disabled]:opacity-50");
609
+ /**
610
+ * A command. `onSelect` runs when it is clicked or chosen with Enter, and the menu then
611
+ * closes. `tone="defect"` marks a destructive command.
612
+ */
613
+ function MenuItem({ children, onSelect, shortcut, disabled, icon, tone }) {
614
+ return /* @__PURE__ */ jsxs(RadixMenu.Item, {
615
+ ...disabled === void 0 ? {} : { disabled },
616
+ onSelect: () => onSelect?.(),
617
+ className: cn(itemClasses, tone === "defect" && "text-defect"),
618
+ children: [
619
+ icon && /* @__PURE__ */ jsx("span", {
620
+ "aria-hidden": true,
621
+ className: "absolute left-2 flex size-3.5 items-center [&>svg]:size-3.5",
622
+ children: icon
623
+ }),
624
+ /* @__PURE__ */ jsx("span", {
625
+ className: "min-w-0 flex-1 truncate",
626
+ children
627
+ }),
628
+ shortcut && /* @__PURE__ */ jsx(Kbd, { children: shortcut })
629
+ ]
630
+ });
631
+ }
632
+ /**
633
+ * A toggle (`role="menuitemcheckbox"`): a layer on or off. A check marks it on. Choosing it
634
+ * leaves the menu open.
635
+ */
636
+ function MenuCheckboxItem({ children, checked, onCheckedChange, shortcut, disabled }) {
637
+ return /* @__PURE__ */ jsxs(RadixMenu.CheckboxItem, {
638
+ checked,
639
+ onCheckedChange,
640
+ ...disabled === void 0 ? {} : { disabled },
641
+ onSelect: (event) => event.preventDefault(),
642
+ className: itemClasses,
643
+ children: [
644
+ /* @__PURE__ */ jsx(RadixMenu.ItemIndicator, {
645
+ className: "absolute left-2 flex items-center text-signal",
646
+ children: /* @__PURE__ */ jsx(Check, {
647
+ className: "size-3.5",
648
+ "aria-hidden": true
649
+ })
650
+ }),
651
+ /* @__PURE__ */ jsx("span", {
652
+ className: "min-w-0 flex-1 truncate",
653
+ children
654
+ }),
655
+ shortcut && /* @__PURE__ */ jsx(Kbd, { children: shortcut })
656
+ ]
657
+ });
658
+ }
659
+ /** A heading over a group of items, in the eyebrow role. Not focusable. */
660
+ function MenuLabel({ children }) {
661
+ return /* @__PURE__ */ jsx(RadixMenu.Label, {
662
+ className: "px-2 pt-1.5 pb-1 text-[11px] font-semibold tracking-wider text-fg-muted uppercase",
663
+ children
664
+ });
665
+ }
666
+ /** A divider between groups of items. */
667
+ function MenuSeparator() {
668
+ return /* @__PURE__ */ jsx(RadixMenu.Separator, { className: "my-1 h-px bg-line" });
669
+ }
670
+ //#endregion
566
671
  //#region src/components/ui/Feedback.tsx
567
672
  const CALLOUT = {
568
673
  info: {
@@ -780,6 +885,33 @@ function Field({ label, description, error, required = false, annotation, as = "
780
885
  });
781
886
  }
782
887
  //#endregion
888
+ //#region src/components/ui/numberText.ts
889
+ /**
890
+ * A number at a fixed number of decimals, without a negative zero (`-0.000` reads as a sign
891
+ * error in a coordinate readout).
892
+ *
893
+ * @param value - The number. Non-finite values print as `NaN`, `Infinity`, `-Infinity`.
894
+ * @param precision - Decimals, 0–20.
895
+ * @returns The text.
896
+ */
897
+ function formatNumber(value, precision) {
898
+ if (!Number.isFinite(value)) return String(value);
899
+ const text = value.toFixed(precision);
900
+ return /^-0(?:\.0*)?$/.test(text) ? text.slice(1) : text;
901
+ }
902
+ /**
903
+ * The number a field's text means, if it means one.
904
+ *
905
+ * @param text - What is in the field.
906
+ * @returns The number, or `null` for empty, partial (`-`, `1e`) or non-numeric text.
907
+ */
908
+ function parseNumber(text) {
909
+ const trimmed = text.trim();
910
+ if (trimmed === "") return null;
911
+ const value = Number(trimmed);
912
+ return Number.isFinite(value) ? value : null;
913
+ }
914
+ //#endregion
783
915
  //#region src/components/ui/Input.tsx
784
916
  /**
785
917
  * The shared look of a text-like control — border, palette, placeholder, hover, disabled and
@@ -823,11 +955,94 @@ function Input({ className, "aria-describedby": describedBy, ...rest }) {
823
955
  * With a `unit`, the input sits in a `relative` full-width wrapper carrying `data-unit`, keeps
824
956
  * room on its right for the unit, and is described by it; size such a field through its
825
957
  * container. Without one — or with `""` — the markup is exactly the plain input.
958
+ *
959
+ * With `onValueChange` the field is number-valued: `value` is a number (`null` shows empty), the
960
+ * typed text survives while the field has focus, and only finite numbers inside `[min, max]`
961
+ * are reported (see `onValueChange`; `onClear` reports an emptied field). Without it the field
962
+ * is the native text-valued input.
826
963
  */
827
- function NumberInput({ unit, ...props }) {
828
- return unit === void 0 || unit === "" ? /* @__PURE__ */ jsx(PlainNumberInput, { ...props }) : /* @__PURE__ */ jsx(UnitNumberInput, {
964
+ function NumberInput({ onValueChange, onClear, precision, ...props }) {
965
+ if (onValueChange) return /* @__PURE__ */ jsx(ValueNumberInput, {
966
+ ...props,
967
+ onValueChange,
968
+ onClear,
969
+ precision
970
+ });
971
+ const { unit, ...rest } = props;
972
+ const text = rest.value === null ? {
973
+ ...rest,
974
+ value: ""
975
+ } : rest;
976
+ return unit === void 0 || unit === "" ? /* @__PURE__ */ jsx(PlainNumberInput, { ...text }) : /* @__PURE__ */ jsx(UnitNumberInput, {
829
977
  unit,
830
- ...props
978
+ ...text
979
+ });
980
+ }
981
+ /** The number-valued field: formatted at rest, the typed text while focused. */
982
+ function ValueNumberInput({ value, onValueChange, onClear, precision, step, min, max, onFocus, onBlur, onChange, onKeyDown, ...rest }) {
983
+ const numeric = typeof value === "number" ? value : null;
984
+ const show = (v) => v === null ? "" : precision === void 0 ? String(v) : formatNumber(v, precision);
985
+ const [draft, setDraft] = useState(null);
986
+ /** The number `text` commits, or `null` while it is empty, partial or out of range. */
987
+ const parse = (text) => {
988
+ const parsed = parseNumber(text);
989
+ if (parsed === null) return null;
990
+ if (min !== void 0 && parsed < min || max !== void 0 && parsed > max) return null;
991
+ return parsed;
992
+ };
993
+ /** An emptied field, committed: cleared if the caller allows it and it was not already empty. */
994
+ const commitEmpty = (text) => {
995
+ if (text.trim() === "" && numeric !== null) onClear?.();
996
+ };
997
+ const keyDown = (event) => {
998
+ if (draft && event.key === "Enter") {
999
+ commitEmpty(draft.text);
1000
+ setDraft({
1001
+ ...draft,
1002
+ text: draft.text.trim() === "" && onClear ? "" : show(numeric)
1003
+ });
1004
+ } else if (draft && event.key === "Escape") {
1005
+ event.preventDefault();
1006
+ if (draft.initial !== numeric) {
1007
+ if (draft.initial === null) onClear?.();
1008
+ else onValueChange(draft.initial);
1009
+ }
1010
+ setDraft({
1011
+ ...draft,
1012
+ text: show(draft.initial)
1013
+ });
1014
+ }
1015
+ onKeyDown?.(event);
1016
+ };
1017
+ return /* @__PURE__ */ jsx(NumberInput, {
1018
+ ...rest,
1019
+ value: draft?.text ?? show(numeric),
1020
+ step: step ?? "any",
1021
+ min,
1022
+ max,
1023
+ onFocus: (event) => {
1024
+ setDraft({
1025
+ text: show(numeric),
1026
+ initial: numeric
1027
+ });
1028
+ onFocus?.(event);
1029
+ },
1030
+ onBlur: (event) => {
1031
+ if (draft) commitEmpty(draft.text);
1032
+ setDraft(null);
1033
+ onBlur?.(event);
1034
+ },
1035
+ onKeyDown: keyDown,
1036
+ onChange: (event) => {
1037
+ const text = event.currentTarget.value;
1038
+ setDraft((current) => ({
1039
+ text,
1040
+ initial: current?.initial ?? numeric
1041
+ }));
1042
+ const parsed = parse(text);
1043
+ if (parsed !== null && parsed !== numeric) onValueChange(parsed);
1044
+ onChange?.(event);
1045
+ }
831
1046
  });
832
1047
  }
833
1048
  function PlainNumberInput({ className, "aria-describedby": describedBy, ...rest }) {
@@ -871,6 +1086,146 @@ function Textarea({ className, "aria-describedby": describedBy, ...rest }) {
871
1086
  });
872
1087
  }
873
1088
  //#endregion
1089
+ //#region src/components/ui/listboxModel.ts
1090
+ /**
1091
+ * The option a key moves the active one to, skipping disabled options.
1092
+ *
1093
+ * Arrows move by one and stop at the ends (no wrap: a long list that wraps loses its place).
1094
+ * Home and End go to the first and last enabled option. Page keys move by `page` options.
1095
+ * From no active option (`-1`), Down and Page Down go to the first enabled option, and Up and
1096
+ * Page Up to the last.
1097
+ *
1098
+ * @param disabled - Per option, whether it is disabled.
1099
+ * @param from - The active index, or -1.
1100
+ * @param key - The key pressed.
1101
+ * @param page - Options per page, for Page Up/Down. Defaults to 10.
1102
+ * @returns The new active index; `from` when there is nowhere to go; -1 when every option is
1103
+ * disabled.
1104
+ */
1105
+ function moveActive(disabled, from, key, page = 10) {
1106
+ const enabled = disabled.flatMap((off, index) => off ? [] : [index]);
1107
+ if (enabled.length === 0) return -1;
1108
+ const first = enabled[0];
1109
+ const last = enabled[enabled.length - 1];
1110
+ if (key === "Home") return first;
1111
+ if (key === "End") return last;
1112
+ if (from < 0) return key === "ArrowDown" || key === "PageDown" ? first : last;
1113
+ const forward = key === "ArrowDown" || key === "PageDown";
1114
+ const steps = key === "PageDown" || key === "PageUp" ? Math.max(1, page) : 1;
1115
+ let target = from;
1116
+ let moved = 0;
1117
+ for (let index = from + (forward ? 1 : -1); index >= 0 && index < disabled.length; index += forward ? 1 : -1) {
1118
+ if (disabled[index]) continue;
1119
+ target = index;
1120
+ moved += 1;
1121
+ if (moved === steps) break;
1122
+ }
1123
+ return target;
1124
+ }
1125
+ /**
1126
+ * The first enabled option at or after `index`, or before it when none is after. Used to place
1127
+ * the active option when the list opens or changes.
1128
+ *
1129
+ * @param disabled - Per option, whether it is disabled.
1130
+ * @param index - The preferred index.
1131
+ * @returns An enabled index, or -1 when every option is disabled.
1132
+ */
1133
+ function nearestEnabled(disabled, index) {
1134
+ for (let i = Math.max(0, index); i < disabled.length; i += 1) if (!disabled[i]) return i;
1135
+ for (let i = Math.min(index, disabled.length) - 1; i >= 0; i -= 1) if (!disabled[i]) return i;
1136
+ return -1;
1137
+ }
1138
+ //#endregion
1139
+ //#region src/components/ui/Listbox.tsx
1140
+ const KEYS = /* @__PURE__ */ new Set([
1141
+ "ArrowDown",
1142
+ "ArrowUp",
1143
+ "Home",
1144
+ "End",
1145
+ "PageDown",
1146
+ "PageUp"
1147
+ ]);
1148
+ /**
1149
+ * A single-select list (`role="listbox"`) of options that each render their own content.
1150
+ *
1151
+ * The list is one tab stop. The arrow keys, Home, End and Page keys move the active option
1152
+ * (`aria-activedescendant`), skipping disabled ones, and Enter or Space choose it. A click
1153
+ * chooses the option under the pointer. Controlled: the caller keeps `value`. Each option
1154
+ * carries `aria-selected`, `data-selected` and `data-active`; the list scrolls the active
1155
+ * option into view.
1156
+ */
1157
+ function Listbox({ options, value, onValueChange, renderOption = defaultRender, "aria-label": ariaLabel, autoFocus, className }) {
1158
+ const id = useId();
1159
+ const listRef = useRef(null);
1160
+ const disabled = options.map((option) => option.disabled === true);
1161
+ const selectedIndex = options.findIndex((option) => option.value === value);
1162
+ const [active, setActive] = useState(() => nearestEnabled(disabled, Math.max(0, selectedIndex)));
1163
+ const current = active >= 0 && active < options.length && !disabled[active] ? active : nearestEnabled(disabled, active);
1164
+ const optionId = (index) => `${id}-option-${index}`;
1165
+ const activate = (index) => {
1166
+ setActive(index);
1167
+ listRef.current?.querySelector(`#${CSS.escape(optionId(index))}`)?.scrollIntoView({ block: "nearest" });
1168
+ };
1169
+ const choose = (index) => {
1170
+ const option = options[index];
1171
+ if (!option || option.disabled) return;
1172
+ onValueChange?.(option.value);
1173
+ };
1174
+ const onKeyDown = (event) => {
1175
+ if (KEYS.has(event.key)) {
1176
+ event.preventDefault();
1177
+ const next = moveActive(disabled, current, event.key);
1178
+ if (next >= 0) activate(next);
1179
+ } else if (event.key === "Enter" || event.key === " ") {
1180
+ event.preventDefault();
1181
+ choose(current);
1182
+ }
1183
+ };
1184
+ return /* @__PURE__ */ jsx("div", {
1185
+ ref: listRef,
1186
+ role: "listbox",
1187
+ "aria-label": ariaLabel,
1188
+ tabIndex: 0,
1189
+ autoFocus,
1190
+ "aria-activedescendant": current >= 0 ? optionId(current) : void 0,
1191
+ onKeyDown,
1192
+ className: cn("flex flex-col gap-px rounded-control p-0.5 outline-none", focusRing, className),
1193
+ children: options.map((option, index) => {
1194
+ const selected = index === selectedIndex;
1195
+ const isActive = index === current;
1196
+ return /* @__PURE__ */ jsx("div", {
1197
+ id: optionId(index),
1198
+ role: "option",
1199
+ "aria-selected": selected,
1200
+ "aria-disabled": option.disabled ? true : void 0,
1201
+ "data-selected": selected ? "" : void 0,
1202
+ "data-active": isActive ? "" : void 0,
1203
+ onPointerMove: () => {
1204
+ if (!option.disabled && !isActive) setActive(index);
1205
+ },
1206
+ onClick: () => choose(index),
1207
+ className: cn("flex min-w-0 cursor-default items-center gap-2 rounded-control px-2 py-1 text-sm text-fg", "data-[selected]:text-signal data-[selected]:shadow-[inset_2px_0_0_var(--color-signal)]", "data-[active]:bg-raised", option.disabled && "opacity-50"),
1208
+ children: renderOption(option, {
1209
+ selected,
1210
+ active: isActive
1211
+ })
1212
+ }, option.value);
1213
+ })
1214
+ });
1215
+ }
1216
+ function defaultRender(option) {
1217
+ return /* @__PURE__ */ jsxs("span", {
1218
+ className: "flex min-w-0 flex-col",
1219
+ children: [/* @__PURE__ */ jsx("span", {
1220
+ className: "truncate",
1221
+ children: option.label
1222
+ }), option.description && /* @__PURE__ */ jsx("span", {
1223
+ className: "truncate text-xs text-fg-muted",
1224
+ children: option.description
1225
+ })]
1226
+ });
1227
+ }
1228
+ //#endregion
874
1229
  //#region src/components/ui/Panel.tsx
875
1230
  /**
876
1231
  * A bordered surface with an optional header (a title and actions) above a padded body.
@@ -1006,31 +1361,43 @@ function ReadoutStrip({ items, className }) {
1006
1361
  });
1007
1362
  }
1008
1363
  //#endregion
1009
- //#region src/components/ui/numberText.ts
1364
+ //#region src/components/ui/Popover.tsx
1010
1365
  /**
1011
- * A number at a fixed number of decimals, without a negative zero (`-0.000` reads as a sign
1012
- * error in a coordinate readout).
1366
+ * Floating content anchored to `trigger`, on the `overlay` surface with a shadow (floating
1367
+ * layers are the only ones that cast one).
1013
1368
  *
1014
- * @param value - The number. Non-finite values print as `NaN`, `Infinity`, `-Infinity`.
1015
- * @param precision - Decimals, 0–20.
1016
- * @returns The text.
1369
+ * Controlled (`open` / `onOpenChange`) or not (`defaultOpen`). Focus moves into the content
1370
+ * when it opens and back to the trigger when it closes. Escape and a press outside close it.
1371
+ * The content carries `data-state` and `data-side`.
1017
1372
  */
1018
- function formatNumber(value, precision) {
1019
- if (!Number.isFinite(value)) return String(value);
1020
- const text = value.toFixed(precision);
1021
- return /^-0(?:\.0*)?$/.test(text) ? text.slice(1) : text;
1373
+ function Popover({ trigger, children, open, defaultOpen, onOpenChange, side = "bottom", align = "start", "aria-label": ariaLabel, className }) {
1374
+ return /* @__PURE__ */ jsxs(RadixPopover.Root, {
1375
+ ...open === void 0 ? {} : { open },
1376
+ ...defaultOpen === void 0 ? {} : { defaultOpen },
1377
+ ...onOpenChange === void 0 ? {} : { onOpenChange },
1378
+ children: [/* @__PURE__ */ jsx(RadixPopover.Trigger, {
1379
+ asChild: true,
1380
+ children: trigger
1381
+ }), /* @__PURE__ */ jsx(RadixPopover.Portal, { children: /* @__PURE__ */ jsx(RadixPopover.Content, {
1382
+ side,
1383
+ align,
1384
+ sideOffset: 6,
1385
+ collisionPadding: 8,
1386
+ "aria-label": ariaLabel,
1387
+ className: cn("z-50 max-h-[var(--radix-popover-content-available-height)] overflow-y-auto", "rounded-panel border border-line bg-overlay p-1 text-sm text-fg shadow-lg shadow-black/25", className),
1388
+ children
1389
+ }) })]
1390
+ });
1022
1391
  }
1023
1392
  /**
1024
- * The number a field's text means, if it means one.
1025
- *
1026
- * @param text - What is in the field.
1027
- * @returns The number, or `null` for empty, partial (`-`, `1e`) or non-numeric text.
1393
+ * Closes the surrounding `Popover` when its child is activated. Wraps one element (Radix
1394
+ * `asChild`).
1028
1395
  */
1029
- function parseNumber(text) {
1030
- const trimmed = text.trim();
1031
- if (trimmed === "") return null;
1032
- const value = Number(trimmed);
1033
- return Number.isFinite(value) ? value : null;
1396
+ function PopoverClose({ children }) {
1397
+ return /* @__PURE__ */ jsx(RadixPopover.Close, {
1398
+ asChild: true,
1399
+ children
1400
+ });
1034
1401
  }
1035
1402
  //#endregion
1036
1403
  //#region src/components/ui/VectorInput.tsx
@@ -1044,8 +1411,8 @@ const DEFAULT_LABELS = [
1044
1411
  * A row of number fields editing one small vector, with the axis labels before the fields
1045
1412
  * and the unit once after them.
1046
1413
  *
1047
- * Controlled. A `role="group"` named by `aria-label`, each field named by its label and
1048
- * described by the unit. While a field has focus it shows what is typed; on blur or Enter it
1414
+ * Controlled. A `role="group"` named by `aria-label`. Each field is named by the group and its
1415
+ * label ("Translation x"), so two vectors on one screen stay apart, and is described by the unit. While a field has focus it shows what is typed; on blur or Enter it
1049
1416
  * shows the value at `precision`; Escape restores the value it had on focus. With `readOnly`
1050
1417
  * it renders a `ReadoutStrip` (`x 0.100 m · y …`) instead. The row carries `data-readonly`
1051
1418
  * and `data-disabled`.
@@ -1078,6 +1445,7 @@ function VectorInput({ value, onValueChange, labels = DEFAULT_LABELS, unit, step
1078
1445
  className: "shrink-0 font-mono text-[11px] text-fg-subtle",
1079
1446
  children: labelOf(index)
1080
1447
  }), /* @__PURE__ */ jsx(ComponentField, {
1448
+ name: `${ariaLabel} ${labelOf(index)}`,
1081
1449
  value: component,
1082
1450
  precision,
1083
1451
  step,
@@ -1095,7 +1463,7 @@ function VectorInput({ value, onValueChange, labels = DEFAULT_LABELS, unit, step
1095
1463
  });
1096
1464
  }
1097
1465
  /** One component: formatted at rest, the typed text while focused. */
1098
- function ComponentField({ value, precision, step, min, max, disabled, describedBy, onCommit }) {
1466
+ function ComponentField({ name, value, precision, step, min, max, disabled, describedBy, onCommit }) {
1099
1467
  const [draft, setDraft] = useState(null);
1100
1468
  const formatted = formatNumber(value, precision);
1101
1469
  const onKeyDown = (event) => {
@@ -1118,6 +1486,7 @@ function ComponentField({ value, precision, step, min, max, disabled, describedB
1118
1486
  min,
1119
1487
  max,
1120
1488
  disabled,
1489
+ "aria-label": name,
1121
1490
  "aria-describedby": describedBy,
1122
1491
  onFocus: () => setDraft({
1123
1492
  text: formatted,
@@ -1252,6 +1621,16 @@ function PoseInput({ value, onValueChange, rotationView, translationUnit = "m",
1252
1621
  //#endregion
1253
1622
  //#region src/components/ui/SegmentedControl.tsx
1254
1623
  /**
1624
+ * The segment's own box, by size. `sm` is the toolbar size and the default. `md` sets the
1625
+ * height explicitly rather than leaving it to padding plus line-height: the strip around it
1626
+ * is `h-8` with a 1px border and `p-0.5`, which leaves 26px of content box, and `h-full`
1627
+ * fills exactly that whatever the font's line height turns out to be.
1628
+ */
1629
+ const SEGMENT_SIZE_CLASSES = {
1630
+ sm: "px-2.5 py-1 text-xs",
1631
+ md: "flex h-full items-center px-3.5 text-sm"
1632
+ };
1633
+ /**
1255
1634
  * A choice among two or three, shown as a strip of segments (native radios in one group, so
1256
1635
  * arrow keys move between them).
1257
1636
  *
@@ -1259,8 +1638,11 @@ function PoseInput({ value, onValueChange, rotationView, translationUnit = "m",
1259
1638
  * or `defaultValue` while `value` is `""` — and choosing the segment that matches
1260
1639
  * `defaultValue` reports `""`, so the default stays defined in one place. Each segment
1261
1640
  * carries `data-state="checked"` or `"unchecked"`; the group carries `data-disabled`.
1641
+ *
1642
+ * Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
1643
+ * `Button`, pass `size="md"` and the strip's outer height matches theirs.
1262
1644
  */
1263
- function SegmentedControl({ value, defaultValue, options, onValueChange, disabled = false, "aria-label": ariaLabel, className }) {
1645
+ function SegmentedControl({ value, defaultValue, options, onValueChange, disabled = false, "aria-label": ariaLabel, size = "sm", className }) {
1264
1646
  const nameId = useId();
1265
1647
  const described = useFieldDescription();
1266
1648
  const effective = value === "" ? defaultValue : value;
@@ -1269,12 +1651,12 @@ function SegmentedControl({ value, defaultValue, options, onValueChange, disable
1269
1651
  "aria-label": ariaLabel,
1270
1652
  "aria-describedby": described["aria-describedby"],
1271
1653
  "data-disabled": disabled ? "" : void 0,
1272
- className: cn("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", disabled && "opacity-50", className),
1654
+ className: cn("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", size === "md" && "h-8", disabled && "opacity-50", className),
1273
1655
  children: options.map((option) => {
1274
1656
  const checked = option.value === effective;
1275
1657
  return /* @__PURE__ */ jsxs("label", {
1276
1658
  "data-state": checked ? "checked" : "unchecked",
1277
- className: cn("relative cursor-pointer whitespace-nowrap rounded-[0.3rem] px-2.5 py-1 text-xs font-medium transition-colors", "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal", checked ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg", disabled && "cursor-not-allowed"),
1659
+ className: cn("relative cursor-pointer whitespace-nowrap rounded-[0.3rem]", SEGMENT_SIZE_CLASSES[size], "font-medium transition-colors", "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal", checked ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg", disabled && "cursor-not-allowed"),
1278
1660
  children: [/* @__PURE__ */ jsx("input", {
1279
1661
  type: "radio",
1280
1662
  name: nameId,
@@ -1532,12 +1914,28 @@ function Checkbox({ checked, onCheckedChange, onClick, label, description, disab
1532
1914
  //#endregion
1533
1915
  //#region src/components/ui/ToggleChip.tsx
1534
1916
  /**
1917
+ * The chip's box, by size. `sm` is the toolbar size and the default. `md` sets the height
1918
+ * explicitly (`h-8`, border included) rather than leaving it to padding plus line-height.
1919
+ */
1920
+ const SIZE_CLASSES = {
1921
+ sm: "px-2 py-1 text-xs",
1922
+ md: "h-8 px-3 text-sm"
1923
+ };
1924
+ /** The swatch dot, by size: a little larger beside `text-sm` so it keeps its weight against the label. */
1925
+ const SWATCH_SIZE_CLASSES = {
1926
+ sm: "size-2",
1927
+ md: "size-2.5"
1928
+ };
1929
+ /**
1535
1930
  * A dense on/off chip for a layer drawn on screen, with an optional swatch in the layer's
1536
1931
  * colour: a toolbar-sized `Switch` (`role="switch"`, `aria-checked`).
1537
1932
  *
1538
1933
  * Controlled. State is exposed as `data-state` (`checked`/`unchecked`).
1934
+ *
1935
+ * Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
1936
+ * `Button`, pass `size="md"` and the chip's outer height matches theirs.
1539
1937
  */
1540
- function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = false, title, className }) {
1938
+ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = false, title, size = "sm", className }) {
1541
1939
  return /* @__PURE__ */ jsxs("button", {
1542
1940
  type: "button",
1543
1941
  role: "switch",
@@ -1546,10 +1944,10 @@ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = fal
1546
1944
  disabled,
1547
1945
  title,
1548
1946
  onClick: () => onCheckedChange(!checked),
1549
- className: cn("inline-flex items-center gap-1.5 rounded-control border px-2 py-1 text-xs whitespace-nowrap transition-colors", checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted", focusRing, className),
1947
+ className: cn("inline-flex items-center gap-1.5 rounded-control border", SIZE_CLASSES[size], "whitespace-nowrap transition-colors", checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted", focusRing, className),
1550
1948
  children: [swatch !== void 0 && /* @__PURE__ */ jsx("span", {
1551
1949
  "aria-hidden": true,
1552
- className: cn("size-2 shrink-0 rounded-full border transition-opacity", checked ? "opacity-100" : "opacity-30"),
1950
+ className: cn(SWATCH_SIZE_CLASSES[size], "shrink-0 rounded-full border transition-opacity", checked ? "opacity-100" : "opacity-30"),
1553
1951
  style: {
1554
1952
  backgroundColor: swatch,
1555
1953
  borderColor: swatch
@@ -1801,6 +2199,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
1801
2199
  });
1802
2200
  }
1803
2201
  //#endregion
1804
- export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, Empty, ErrorBox, Field, InfoHint, Input, NumberInput, PageHeader, Panel, PoseInput, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, focusRing, focusRingInset, initTheme, inputClasses, readThemeChoice, resolveTheme, setThemeChoice, toast, toneColor, useControlHeight, useDensity };
2202
+ export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, DropdownMenu, Empty, ErrorBox, Field, InfoHint, Input, Kbd, Listbox, MenuCheckboxItem, MenuItem, MenuLabel, MenuSeparator, NumberInput, PageHeader, Panel, Popover, PopoverClose, PoseInput, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, focusRing, focusRingInset, formatNumber, initTheme, inputClasses, parseNumber, readThemeChoice, resolveTheme, setThemeChoice, toast, toneColor, useControlHeight, useDensity };
1805
2203
 
1806
2204
  //# sourceMappingURL=index.js.map