@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/README.md +17 -5
- package/dist/index.d.ts +262 -9
- package/dist/index.js +432 -34
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/src/components/ui/Button.tsx +4 -1
- package/src/components/ui/DropdownMenu.tsx +179 -0
- package/src/components/ui/Input.tsx +133 -8
- package/src/components/ui/Kbd.tsx +27 -0
- package/src/components/ui/Listbox.tsx +160 -0
- package/src/components/ui/Popover.tsx +96 -0
- package/src/components/ui/SegmentedControl.tsx +27 -1
- package/src/components/ui/ToggleChip.tsx +33 -2
- package/src/components/ui/VectorInput.tsx +6 -2
- package/src/components/ui/index.ts +19 -0
- package/src/components/ui/listboxModel.ts +63 -0
- package/src/components/ui/numberText.ts +2 -2
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({
|
|
828
|
-
|
|
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
|
-
...
|
|
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/
|
|
1364
|
+
//#region src/components/ui/Popover.tsx
|
|
1010
1365
|
/**
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
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
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
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
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
*
|
|
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
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
|
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]
|
|
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
|
|
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(
|
|
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
|