@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
|
@@ -17,9 +17,21 @@
|
|
|
17
17
|
|
|
18
18
|
import { useId } from "react";
|
|
19
19
|
|
|
20
|
+
import type { ButtonSize } from "./Button";
|
|
20
21
|
import { useFieldDescription } from "./Field";
|
|
21
22
|
import { cn } from "./cn";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The segment's own box, by size. `sm` is the toolbar size and the default. `md` sets the
|
|
26
|
+
* height explicitly rather than leaving it to padding plus line-height: the strip around it
|
|
27
|
+
* is `h-8` with a 1px border and `p-0.5`, which leaves 26px of content box, and `h-full`
|
|
28
|
+
* fills exactly that whatever the font's line height turns out to be.
|
|
29
|
+
*/
|
|
30
|
+
const SEGMENT_SIZE_CLASSES: Record<ButtonSize, string> = {
|
|
31
|
+
sm: "px-2.5 py-1 text-xs",
|
|
32
|
+
md: "flex h-full items-center px-3.5 text-sm",
|
|
33
|
+
};
|
|
34
|
+
|
|
23
35
|
/**
|
|
24
36
|
* A choice among two or three, shown as a strip of segments (native radios in one group, so
|
|
25
37
|
* arrow keys move between them).
|
|
@@ -28,6 +40,9 @@ import { cn } from "./cn";
|
|
|
28
40
|
* or `defaultValue` while `value` is `""` — and choosing the segment that matches
|
|
29
41
|
* `defaultValue` reports `""`, so the default stays defined in one place. Each segment
|
|
30
42
|
* carries `data-state="checked"` or `"unchecked"`; the group carries `data-disabled`.
|
|
43
|
+
*
|
|
44
|
+
* Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
|
|
45
|
+
* `Button`, pass `size="md"` and the strip's outer height matches theirs.
|
|
31
46
|
*/
|
|
32
47
|
export function SegmentedControl({
|
|
33
48
|
value,
|
|
@@ -36,6 +51,7 @@ export function SegmentedControl({
|
|
|
36
51
|
onValueChange,
|
|
37
52
|
disabled = false,
|
|
38
53
|
"aria-label": ariaLabel,
|
|
54
|
+
size = "sm",
|
|
39
55
|
className,
|
|
40
56
|
}: {
|
|
41
57
|
/** `""` when the field is untouched. */
|
|
@@ -50,6 +66,13 @@ export function SegmentedControl({
|
|
|
50
66
|
disabled?: boolean | undefined;
|
|
51
67
|
/** Names the radio group. */
|
|
52
68
|
"aria-label"?: string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* The strip's height: `sm` (the default) is the toolbar size, about 30px with `text-xs`
|
|
71
|
+
* labels; `md` is exactly 32px (`h-8`) outside, border included, with `text-sm` labels, the
|
|
72
|
+
* height of a comfortable-density `Input` or `Button`. Not derived from the density in
|
|
73
|
+
* force, so a consumer that never passes it renders as it always did.
|
|
74
|
+
*/
|
|
75
|
+
size?: ButtonSize | undefined;
|
|
53
76
|
/** Merged with the strip's own classes through `cn`. */
|
|
54
77
|
className?: string | undefined;
|
|
55
78
|
}) {
|
|
@@ -68,6 +91,7 @@ export function SegmentedControl({
|
|
|
68
91
|
// its default `1 1 auto` gives up width first, and the segment labels are what pay for
|
|
69
92
|
// it. Let the row scroll instead of squeezing the choice.
|
|
70
93
|
"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",
|
|
94
|
+
size === "md" && "h-8",
|
|
71
95
|
disabled && "opacity-50",
|
|
72
96
|
className,
|
|
73
97
|
)}
|
|
@@ -82,7 +106,9 @@ export function SegmentedControl({
|
|
|
82
106
|
// `whitespace-nowrap` or a two-word label's min-content width is its longest
|
|
83
107
|
// *word*, and "Side by side" is free to stack onto three lines inside a
|
|
84
108
|
// fixed-height strip that then clips it.
|
|
85
|
-
"relative cursor-pointer whitespace-nowrap rounded-[0.3rem]
|
|
109
|
+
"relative cursor-pointer whitespace-nowrap rounded-[0.3rem]",
|
|
110
|
+
SEGMENT_SIZE_CLASSES[size],
|
|
111
|
+
"font-medium transition-colors",
|
|
86
112
|
"has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal",
|
|
87
113
|
checked
|
|
88
114
|
? "bg-surface text-fg shadow-sm"
|
|
@@ -14,13 +14,32 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ReactNode } from "react";
|
|
16
16
|
|
|
17
|
+
import type { ButtonSize } from "./Button";
|
|
17
18
|
import { cn, focusRing } from "./cn";
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The chip's box, by size. `sm` is the toolbar size and the default. `md` sets the height
|
|
22
|
+
* explicitly (`h-8`, border included) rather than leaving it to padding plus line-height.
|
|
23
|
+
*/
|
|
24
|
+
const SIZE_CLASSES: Record<ButtonSize, string> = {
|
|
25
|
+
sm: "px-2 py-1 text-xs",
|
|
26
|
+
md: "h-8 px-3 text-sm",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The swatch dot, by size: a little larger beside `text-sm` so it keeps its weight against the label. */
|
|
30
|
+
const SWATCH_SIZE_CLASSES: Record<ButtonSize, string> = {
|
|
31
|
+
sm: "size-2",
|
|
32
|
+
md: "size-2.5",
|
|
33
|
+
};
|
|
34
|
+
|
|
19
35
|
/**
|
|
20
36
|
* A dense on/off chip for a layer drawn on screen, with an optional swatch in the layer's
|
|
21
37
|
* colour: a toolbar-sized `Switch` (`role="switch"`, `aria-checked`).
|
|
22
38
|
*
|
|
23
39
|
* Controlled. State is exposed as `data-state` (`checked`/`unchecked`).
|
|
40
|
+
*
|
|
41
|
+
* Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
|
|
42
|
+
* `Button`, pass `size="md"` and the chip's outer height matches theirs.
|
|
24
43
|
*/
|
|
25
44
|
export function ToggleChip({
|
|
26
45
|
checked,
|
|
@@ -29,6 +48,7 @@ export function ToggleChip({
|
|
|
29
48
|
swatch,
|
|
30
49
|
disabled = false,
|
|
31
50
|
title,
|
|
51
|
+
size = "sm",
|
|
32
52
|
className,
|
|
33
53
|
}: {
|
|
34
54
|
/** Whether the layer is shown. */
|
|
@@ -43,6 +63,14 @@ export function ToggleChip({
|
|
|
43
63
|
disabled?: boolean | undefined;
|
|
44
64
|
/** Why the control is disabled, or what the layer is. Never the only explanation. */
|
|
45
65
|
title?: string | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* The chip's height: `sm` (the default) is the toolbar size, about 26px with `text-xs`
|
|
68
|
+
* text; `md` is exactly 32px (`h-8`) outside, border included, with `text-sm` text and a
|
|
69
|
+
* slightly larger swatch, the height of a comfortable-density `Input` or `Button`. Not
|
|
70
|
+
* derived from the density in force, so a consumer that never passes it renders as it
|
|
71
|
+
* always did.
|
|
72
|
+
*/
|
|
73
|
+
size?: ButtonSize | undefined;
|
|
46
74
|
/** Merged with the chip's own classes through `cn`. */
|
|
47
75
|
className?: string | undefined;
|
|
48
76
|
}) {
|
|
@@ -56,7 +84,9 @@ export function ToggleChip({
|
|
|
56
84
|
title={title}
|
|
57
85
|
onClick={() => onCheckedChange(!checked)}
|
|
58
86
|
className={cn(
|
|
59
|
-
"inline-flex items-center gap-1.5 rounded-control border
|
|
87
|
+
"inline-flex items-center gap-1.5 rounded-control border",
|
|
88
|
+
SIZE_CLASSES[size],
|
|
89
|
+
"whitespace-nowrap transition-colors",
|
|
60
90
|
checked
|
|
61
91
|
? "border-line-strong bg-raised text-fg"
|
|
62
92
|
: "border-line bg-transparent text-fg-muted hover:text-fg",
|
|
@@ -69,7 +99,8 @@ export function ToggleChip({
|
|
|
69
99
|
<span
|
|
70
100
|
aria-hidden
|
|
71
101
|
className={cn(
|
|
72
|
-
|
|
102
|
+
SWATCH_SIZE_CLASSES[size],
|
|
103
|
+
"shrink-0 rounded-full border transition-opacity",
|
|
73
104
|
checked ? "opacity-100" : "opacity-30",
|
|
74
105
|
)}
|
|
75
106
|
style={{ backgroundColor: swatch, borderColor: swatch }}
|
|
@@ -52,8 +52,8 @@ export interface VectorInputProps {
|
|
|
52
52
|
* A row of number fields editing one small vector, with the axis labels before the fields
|
|
53
53
|
* and the unit once after them.
|
|
54
54
|
*
|
|
55
|
-
* Controlled. A `role="group"` named by `aria-label
|
|
56
|
-
* described by the unit. While a field has focus it shows what is typed; on blur or Enter it
|
|
55
|
+
* Controlled. A `role="group"` named by `aria-label`. Each field is named by the group and its
|
|
56
|
+
* 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
|
|
57
57
|
* shows the value at `precision`; Escape restores the value it had on focus. With `readOnly`
|
|
58
58
|
* it renders a `ReadoutStrip` (`x 0.100 m · y …`) instead. The row carries `data-readonly`
|
|
59
59
|
* and `data-disabled`.
|
|
@@ -106,6 +106,7 @@ export function VectorInput({
|
|
|
106
106
|
<label key={index} className="flex min-w-0 flex-1 items-center gap-1">
|
|
107
107
|
<span className="shrink-0 font-mono text-[11px] text-fg-subtle">{labelOf(index)}</span>
|
|
108
108
|
<ComponentField
|
|
109
|
+
name={`${ariaLabel} ${labelOf(index)}`}
|
|
109
110
|
value={component}
|
|
110
111
|
precision={precision}
|
|
111
112
|
step={step}
|
|
@@ -128,6 +129,7 @@ export function VectorInput({
|
|
|
128
129
|
|
|
129
130
|
/** One component: formatted at rest, the typed text while focused. */
|
|
130
131
|
function ComponentField({
|
|
132
|
+
name,
|
|
131
133
|
value,
|
|
132
134
|
precision,
|
|
133
135
|
step,
|
|
@@ -137,6 +139,7 @@ function ComponentField({
|
|
|
137
139
|
describedBy,
|
|
138
140
|
onCommit,
|
|
139
141
|
}: {
|
|
142
|
+
name: string;
|
|
140
143
|
value: number;
|
|
141
144
|
precision: number;
|
|
142
145
|
step: number | undefined;
|
|
@@ -167,6 +170,7 @@ function ComponentField({
|
|
|
167
170
|
min={min}
|
|
168
171
|
max={max}
|
|
169
172
|
disabled={disabled}
|
|
173
|
+
aria-label={name}
|
|
170
174
|
aria-describedby={describedBy}
|
|
171
175
|
onFocus={() => setDraft({ text: formatted, initial: value })}
|
|
172
176
|
onBlur={() => setDraft(null)}
|
|
@@ -12,6 +12,16 @@ export { Button, ButtonLink, buttonClasses, type ButtonSize, type ButtonVariant
|
|
|
12
12
|
export { DensityProvider, byDensity, useDensity, type Density } from "./Density";
|
|
13
13
|
export { ConfirmDialog, Dialog, DialogClose } from "./Dialog";
|
|
14
14
|
export { Disclosure } from "./Disclosure";
|
|
15
|
+
export {
|
|
16
|
+
DropdownMenu,
|
|
17
|
+
MenuCheckboxItem,
|
|
18
|
+
MenuItem,
|
|
19
|
+
MenuLabel,
|
|
20
|
+
MenuSeparator,
|
|
21
|
+
type DropdownMenuProps,
|
|
22
|
+
type MenuCheckboxItemProps,
|
|
23
|
+
type MenuItemProps,
|
|
24
|
+
} from "./DropdownMenu";
|
|
15
25
|
export {
|
|
16
26
|
Callout,
|
|
17
27
|
type CalloutTone,
|
|
@@ -31,6 +41,14 @@ export {
|
|
|
31
41
|
useControlHeight,
|
|
32
42
|
type NumberInputProps,
|
|
33
43
|
} from "./Input";
|
|
44
|
+
export { Kbd } from "./Kbd";
|
|
45
|
+
export {
|
|
46
|
+
Listbox,
|
|
47
|
+
type ListboxOption,
|
|
48
|
+
type ListboxOptionState,
|
|
49
|
+
type ListboxProps,
|
|
50
|
+
} from "./Listbox";
|
|
51
|
+
export { formatNumber, parseNumber } from "./numberText";
|
|
34
52
|
export {
|
|
35
53
|
PageHeader,
|
|
36
54
|
type BackLink,
|
|
@@ -39,6 +57,7 @@ export {
|
|
|
39
57
|
Section,
|
|
40
58
|
type ReadoutItem,
|
|
41
59
|
} from "./Panel";
|
|
60
|
+
export { Popover, PopoverClose, type PopoverProps } from "./Popover";
|
|
42
61
|
export {
|
|
43
62
|
PoseInput,
|
|
44
63
|
type PoseInputProps,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Keyboard movement in a single-select listbox, as a pure function so it is tested without a
|
|
3
|
+
* DOM. `Listbox` calls it on every key press.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The keys that move the active option. */
|
|
7
|
+
export type ListboxKey = "ArrowDown" | "ArrowUp" | "Home" | "End" | "PageDown" | "PageUp";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The option a key moves the active one to, skipping disabled options.
|
|
11
|
+
*
|
|
12
|
+
* Arrows move by one and stop at the ends (no wrap: a long list that wraps loses its place).
|
|
13
|
+
* Home and End go to the first and last enabled option. Page keys move by `page` options.
|
|
14
|
+
* From no active option (`-1`), Down and Page Down go to the first enabled option, and Up and
|
|
15
|
+
* Page Up to the last.
|
|
16
|
+
*
|
|
17
|
+
* @param disabled - Per option, whether it is disabled.
|
|
18
|
+
* @param from - The active index, or -1.
|
|
19
|
+
* @param key - The key pressed.
|
|
20
|
+
* @param page - Options per page, for Page Up/Down. Defaults to 10.
|
|
21
|
+
* @returns The new active index; `from` when there is nowhere to go; -1 when every option is
|
|
22
|
+
* disabled.
|
|
23
|
+
*/
|
|
24
|
+
export function moveActive(
|
|
25
|
+
disabled: readonly boolean[],
|
|
26
|
+
from: number,
|
|
27
|
+
key: ListboxKey,
|
|
28
|
+
page = 10,
|
|
29
|
+
): number {
|
|
30
|
+
const enabled = disabled.flatMap((off, index) => (off ? [] : [index]));
|
|
31
|
+
if (enabled.length === 0) return -1;
|
|
32
|
+
const first = enabled[0]!;
|
|
33
|
+
const last = enabled[enabled.length - 1]!;
|
|
34
|
+
if (key === "Home") return first;
|
|
35
|
+
if (key === "End") return last;
|
|
36
|
+
if (from < 0) return key === "ArrowDown" || key === "PageDown" ? first : last;
|
|
37
|
+
|
|
38
|
+
const forward = key === "ArrowDown" || key === "PageDown";
|
|
39
|
+
const steps = key === "PageDown" || key === "PageUp" ? Math.max(1, page) : 1;
|
|
40
|
+
let target = from;
|
|
41
|
+
let moved = 0;
|
|
42
|
+
for (let index = from + (forward ? 1 : -1); index >= 0 && index < disabled.length; index += forward ? 1 : -1) {
|
|
43
|
+
if (disabled[index]) continue;
|
|
44
|
+
target = index;
|
|
45
|
+
moved += 1;
|
|
46
|
+
if (moved === steps) break;
|
|
47
|
+
}
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The first enabled option at or after `index`, or before it when none is after. Used to place
|
|
53
|
+
* the active option when the list opens or changes.
|
|
54
|
+
*
|
|
55
|
+
* @param disabled - Per option, whether it is disabled.
|
|
56
|
+
* @param index - The preferred index.
|
|
57
|
+
* @returns An enabled index, or -1 when every option is disabled.
|
|
58
|
+
*/
|
|
59
|
+
export function nearestEnabled(disabled: readonly boolean[], index: number): number {
|
|
60
|
+
for (let i = Math.max(0, index); i < disabled.length; i += 1) if (!disabled[i]) return i;
|
|
61
|
+
for (let i = Math.min(index, disabled.length) - 1; i >= 0; i -= 1) if (!disabled[i]) return i;
|
|
62
|
+
return -1;
|
|
63
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Numbers as a field shows them and as a person types them.
|
|
3
|
-
* `VectorInput` and `PoseInput
|
|
2
|
+
* Numbers as a field shows them and as a person types them. Used by `NumberInput`'s
|
|
3
|
+
* number-valued mode, `VectorInput` and `PoseInput`, and exported for an app's own fields.
|
|
4
4
|
*
|
|
5
5
|
* A field bound straight to a number re-formats under the cursor: typing `0.` into a field
|
|
6
6
|
* showing `0.000` becomes `0` before the next digit arrives. So the multi-number fields keep
|