@vitavision/ui 0.11.0 → 0.13.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 +8 -5
- package/dist/index.d.ts +255 -12
- package/dist/index.js +599 -44
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Tabs.tsx +3 -10
- package/src/components/ThemeToggle.tsx +3 -8
- package/src/components/ui/Badge.tsx +1 -4
- package/src/components/ui/Button.tsx +3 -15
- package/src/components/ui/Dialog.tsx +4 -19
- package/src/components/ui/Disclosure.tsx +2 -7
- package/src/components/ui/DropdownMenu.tsx +2 -16
- package/src/components/ui/Feedback.tsx +5 -9
- package/src/components/ui/Field.tsx +4 -15
- package/src/components/ui/HighlightedText.tsx +84 -0
- package/src/components/ui/Input.tsx +5 -43
- package/src/components/ui/Panel.tsx +4 -16
- package/src/components/ui/PoseInput.tsx +7 -5
- package/src/components/ui/Progress.tsx +80 -0
- package/src/components/ui/SegmentedControl.tsx +1 -3
- package/src/components/ui/Select.tsx +2 -10
- package/src/components/ui/Slider.tsx +2 -7
- package/src/components/ui/Table.tsx +331 -85
- package/src/components/ui/Textarea.tsx +149 -0
- package/src/components/ui/Toaster.tsx +3 -19
- package/src/components/ui/Toggle.tsx +1 -5
- package/src/components/ui/ToggleChip.tsx +1 -3
- package/src/components/ui/Tooltip.tsx +1 -5
- package/src/components/ui/VectorInput.tsx +1 -1
- package/src/components/ui/cn.ts +1 -2
- package/src/components/ui/highlightSpans.ts +110 -0
- package/src/components/ui/index.ts +11 -41
- package/src/components/ui/listboxModel.ts +1 -6
- package/src/components/ui/tableSort.ts +114 -0
- package/src/components/ui/tableWindow.ts +96 -0
- package/src/fonts/fonts.css +1 -1
- package/src/index.ts +1 -8
- package/src/styles.css +17 -7
- package/src/theme.ts +2 -7
|
@@ -64,34 +64,18 @@ export function Toaster({ store = defaultToastStore, limit = 5, className }: Toa
|
|
|
64
64
|
onPointerLeave={() => setHovered(false)}
|
|
65
65
|
onFocus={() => setFocused(true)}
|
|
66
66
|
onBlur={onBlur}
|
|
67
|
-
className={cn(
|
|
68
|
-
"pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]",
|
|
69
|
-
className,
|
|
70
|
-
)}
|
|
67
|
+
className={cn("pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]", className)}
|
|
71
68
|
>
|
|
72
69
|
<ol aria-live="polite" className="flex flex-col gap-2">
|
|
73
70
|
{shown.map((record) => (
|
|
74
|
-
<ToastItem
|
|
75
|
-
key={record.id}
|
|
76
|
-
record={record}
|
|
77
|
-
paused={hovered || focused}
|
|
78
|
-
onDismiss={() => store.dismiss(record.id)}
|
|
79
|
-
/>
|
|
71
|
+
<ToastItem key={record.id} record={record} paused={hovered || focused} onDismiss={() => store.dismiss(record.id)} />
|
|
80
72
|
))}
|
|
81
73
|
</ol>
|
|
82
74
|
</section>
|
|
83
75
|
);
|
|
84
76
|
}
|
|
85
77
|
|
|
86
|
-
function ToastItem({
|
|
87
|
-
record,
|
|
88
|
-
paused,
|
|
89
|
-
onDismiss,
|
|
90
|
-
}: {
|
|
91
|
-
record: ToastRecord;
|
|
92
|
-
paused: boolean;
|
|
93
|
-
onDismiss: () => void;
|
|
94
|
-
}) {
|
|
78
|
+
function ToastItem({ record, paused, onDismiss }: { record: ToastRecord; paused: boolean; onDismiss: () => void }) {
|
|
95
79
|
const { border, icon, Icon } = TONE[record.tone];
|
|
96
80
|
// What is left of the countdown, carried across pauses; restarted when the toast is
|
|
97
81
|
// replaced in place (a new record under the same id).
|
|
@@ -136,11 +136,7 @@ export function Checkbox({
|
|
|
136
136
|
)}
|
|
137
137
|
>
|
|
138
138
|
<RadixCheckbox.Indicator className="text-signal-fg">
|
|
139
|
-
{checked === "indeterminate" ?
|
|
140
|
-
<Minus className="size-3" strokeWidth={3} />
|
|
141
|
-
) : (
|
|
142
|
-
<Check className="size-3" strokeWidth={3} />
|
|
143
|
-
)}
|
|
139
|
+
{checked === "indeterminate" ? <Minus className="size-3" strokeWidth={3} /> : <Check className="size-3" strokeWidth={3} />}
|
|
144
140
|
</RadixCheckbox.Indicator>
|
|
145
141
|
</RadixCheckbox.Root>
|
|
146
142
|
);
|
|
@@ -87,9 +87,7 @@ export function ToggleChip({
|
|
|
87
87
|
"inline-flex items-center gap-1.5 rounded-control border",
|
|
88
88
|
SIZE_CLASSES[size],
|
|
89
89
|
"whitespace-nowrap transition-colors",
|
|
90
|
-
checked
|
|
91
|
-
? "border-line-strong bg-raised text-fg"
|
|
92
|
-
: "border-line bg-transparent text-fg-muted hover:text-fg",
|
|
90
|
+
checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg",
|
|
93
91
|
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted",
|
|
94
92
|
focusRing,
|
|
95
93
|
className,
|
|
@@ -87,11 +87,7 @@ export function InfoHint({
|
|
|
87
87
|
}) {
|
|
88
88
|
return (
|
|
89
89
|
<Tooltip content={children}>
|
|
90
|
-
<button
|
|
91
|
-
type="button"
|
|
92
|
-
aria-label={label}
|
|
93
|
-
className={cn("rounded-full text-fg-subtle transition-colors hover:text-fg", focusRing)}
|
|
94
|
-
>
|
|
90
|
+
<button type="button" aria-label={label} className={cn("rounded-full text-fg-subtle transition-colors hover:text-fg", focusRing)}>
|
|
95
91
|
<Icon className="size-3.5" />
|
|
96
92
|
</button>
|
|
97
93
|
</Tooltip>
|
|
@@ -102,7 +102,7 @@ export function VectorInput({
|
|
|
102
102
|
>
|
|
103
103
|
{value.map((component, index) => (
|
|
104
104
|
// The components are positional: index is their identity.
|
|
105
|
-
//
|
|
105
|
+
// oxlint-disable-next-line react/no-array-index-key -- see above
|
|
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
|
package/src/components/ui/cn.ts
CHANGED
|
@@ -31,8 +31,7 @@ export function cn(...inputs: ClassValue[]): string {
|
|
|
31
31
|
*
|
|
32
32
|
* `focus-visible` rather than `focus`, so a mouse click does not leave a ring behind.
|
|
33
33
|
*/
|
|
34
|
-
export const focusRing =
|
|
35
|
-
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal";
|
|
34
|
+
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal";
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
37
|
* The focus treatment for controls whose own box is the hit target, where an offset ring
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The model behind `HighlightedText`: spans over a string, cut into the runs that render
|
|
3
|
+
* alike, and spans found by regular expressions. Pure, so it is tested without a DOM.
|
|
4
|
+
*
|
|
5
|
+
* Offsets are UTF-16 code-unit indices, as `String.prototype.slice` and `RegExp` use them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** What a highlighted span is. Each kind has its own shape on screen, not only its own colour. */
|
|
9
|
+
export type HighlightKind = "placeholder" | "tag" | "protected-term" | "mismatch";
|
|
10
|
+
|
|
11
|
+
/** A highlighted range of the text: `[start, end)` in UTF-16 code units. */
|
|
12
|
+
export interface HighlightSpan {
|
|
13
|
+
/** The offset of the span's first code unit. */
|
|
14
|
+
start: number;
|
|
15
|
+
/** The offset just past the span's last code unit. */
|
|
16
|
+
end: number;
|
|
17
|
+
/** What the span is; it decides the span's shape and its default label. */
|
|
18
|
+
kind: HighlightKind;
|
|
19
|
+
/**
|
|
20
|
+
* What a screen reader hears after the span, instead of the kind's default label: say
|
|
21
|
+
* *why* it is marked ("missing from the translation").
|
|
22
|
+
*/
|
|
23
|
+
label?: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A run of the text covered by one set of spans; two spans side by side are two runs. */
|
|
27
|
+
export interface TextSegment {
|
|
28
|
+
/** The run's characters. */
|
|
29
|
+
text: string;
|
|
30
|
+
/** Where the run starts in the whole text. */
|
|
31
|
+
start: number;
|
|
32
|
+
/** Where it ends. */
|
|
33
|
+
end: number;
|
|
34
|
+
/** The spans covering the run, in their order in the input. Empty for plain text. */
|
|
35
|
+
spans: HighlightSpan[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The kinds in the order their styles stack: a later one wins where two would clash. */
|
|
39
|
+
export const HIGHLIGHT_KINDS: readonly HighlightKind[] = ["tag", "protected-term", "placeholder", "mismatch"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cuts `text` into runs that are each covered by one fixed set of spans. Spans are clamped
|
|
43
|
+
* to the text; empty, inverted and non-finite ones are dropped. Overlapping spans are kept:
|
|
44
|
+
* the overlap is a run covered by both.
|
|
45
|
+
*
|
|
46
|
+
* @param text - The string, shown as text.
|
|
47
|
+
* @param spans - The highlights, in any order.
|
|
48
|
+
* @returns The runs, in order, together covering the whole text.
|
|
49
|
+
*/
|
|
50
|
+
export function segmentText(text: string, spans: readonly HighlightSpan[]): TextSegment[] {
|
|
51
|
+
const length = text.length;
|
|
52
|
+
const clamp = (offset: number) => Math.floor(Math.max(0, Math.min(length, offset)));
|
|
53
|
+
const valid = spans
|
|
54
|
+
.map((span) => ({ span, start: clamp(span.start), end: clamp(span.end) }))
|
|
55
|
+
.filter(({ start, end }) => Number.isFinite(start) && Number.isFinite(end) && end > start);
|
|
56
|
+
const cuts = new Set<number>([0, length]);
|
|
57
|
+
for (const { start, end } of valid) {
|
|
58
|
+
cuts.add(start);
|
|
59
|
+
cuts.add(end);
|
|
60
|
+
}
|
|
61
|
+
const points = [...cuts].sort((a, b) => a - b);
|
|
62
|
+
const segments: TextSegment[] = [];
|
|
63
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
64
|
+
const start = points[i]!;
|
|
65
|
+
const end = points[i + 1]!;
|
|
66
|
+
const covering = valid.filter((v) => v.start <= start && v.end >= end).map((v) => v.span);
|
|
67
|
+
segments.push({ text: text.slice(start, end), start, end, spans: covering });
|
|
68
|
+
}
|
|
69
|
+
return segments;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A pattern whose matches become spans of one kind. */
|
|
73
|
+
export interface HighlightRule {
|
|
74
|
+
/** Matched against the whole text, every match (the `g` flag is added if missing). */
|
|
75
|
+
pattern: RegExp;
|
|
76
|
+
/** The kind every match gets. */
|
|
77
|
+
kind: HighlightKind;
|
|
78
|
+
/** The spans' `label`, if they need one. */
|
|
79
|
+
label?: string | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Spans for every match of every rule: `%1`, `<b>`, a glossary term. Empty matches are
|
|
84
|
+
* skipped. The result is sorted by start, then longest first.
|
|
85
|
+
*
|
|
86
|
+
* @param text - The string to search.
|
|
87
|
+
* @param rules - Each pattern and the kind its matches get.
|
|
88
|
+
* @returns The spans, ready for `HighlightedText`.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* findSpans("Open %1 in <b>ParamsManager</b>", [
|
|
93
|
+
* { pattern: /%\d+/, kind: "placeholder" },
|
|
94
|
+
* { pattern: /<\/?[a-z][^>]*>/i, kind: "tag" },
|
|
95
|
+
* { pattern: /\bParamsManager\b/, kind: "protected-term" },
|
|
96
|
+
* ]);
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function findSpans(text: string, rules: readonly HighlightRule[]): HighlightSpan[] {
|
|
100
|
+
const spans: HighlightSpan[] = [];
|
|
101
|
+
for (const rule of rules) {
|
|
102
|
+
const flags = rule.pattern.flags.includes("g") ? rule.pattern.flags : `${rule.pattern.flags}g`;
|
|
103
|
+
for (const match of text.matchAll(new RegExp(rule.pattern.source, flags))) {
|
|
104
|
+
if (match[0] === "") continue;
|
|
105
|
+
const start = match.index;
|
|
106
|
+
spans.push({ start, end: start + match[0].length, kind: rule.kind, ...(rule.label === undefined ? {} : { label: rule.label }) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
110
|
+
}
|
|
@@ -22,54 +22,24 @@ export {
|
|
|
22
22
|
type MenuCheckboxItemProps,
|
|
23
23
|
type MenuItemProps,
|
|
24
24
|
} from "./DropdownMenu";
|
|
25
|
-
export {
|
|
26
|
-
Callout,
|
|
27
|
-
type CalloutTone,
|
|
28
|
-
Empty,
|
|
29
|
-
ErrorBox,
|
|
30
|
-
ProgressBar,
|
|
31
|
-
Skeleton,
|
|
32
|
-
SkeletonRows,
|
|
33
|
-
} from "./Feedback";
|
|
25
|
+
export { Callout, type CalloutTone, Empty, ErrorBox, ProgressBar, Skeleton, SkeletonRows } from "./Feedback";
|
|
34
26
|
export { Field } from "./Field";
|
|
35
|
-
export {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Textarea,
|
|
39
|
-
controlClasses,
|
|
40
|
-
inputClasses,
|
|
41
|
-
useControlHeight,
|
|
42
|
-
type NumberInputProps,
|
|
43
|
-
} from "./Input";
|
|
27
|
+
export { HighlightedText, type HighlightedTextProps } from "./HighlightedText";
|
|
28
|
+
export { findSpans, type HighlightKind, type HighlightRule, type HighlightSpan } from "./highlightSpans";
|
|
29
|
+
export { Input, NumberInput, controlClasses, inputClasses, useControlHeight, type NumberInputProps } from "./Input";
|
|
44
30
|
export { Kbd } from "./Kbd";
|
|
45
|
-
export {
|
|
46
|
-
Listbox,
|
|
47
|
-
type ListboxOption,
|
|
48
|
-
type ListboxOptionState,
|
|
49
|
-
type ListboxProps,
|
|
50
|
-
} from "./Listbox";
|
|
31
|
+
export { Listbox, type ListboxOption, type ListboxOptionState, type ListboxProps } from "./Listbox";
|
|
51
32
|
export { formatNumber, parseNumber } from "./numberText";
|
|
52
|
-
export {
|
|
53
|
-
|
|
54
|
-
type BackLink,
|
|
55
|
-
Panel,
|
|
56
|
-
ReadoutStrip,
|
|
57
|
-
Section,
|
|
58
|
-
type ReadoutItem,
|
|
59
|
-
} from "./Panel";
|
|
33
|
+
export { PageHeader, type BackLink, Panel, ReadoutStrip, Section, type ReadoutItem } from "./Panel";
|
|
34
|
+
export { Progress, progressText, type ProgressProps } from "./Progress";
|
|
60
35
|
export { Popover, PopoverClose, type PopoverProps } from "./Popover";
|
|
61
|
-
export {
|
|
62
|
-
PoseInput,
|
|
63
|
-
type PoseInputProps,
|
|
64
|
-
type PoseValue,
|
|
65
|
-
type Quaternion,
|
|
66
|
-
type RotationView,
|
|
67
|
-
type Vec3,
|
|
68
|
-
} from "./PoseInput";
|
|
36
|
+
export { PoseInput, type PoseInputProps, type PoseValue, type Quaternion, type RotationView, type Vec3 } from "./PoseInput";
|
|
69
37
|
export { SegmentedControl } from "./SegmentedControl";
|
|
70
38
|
export { Select, type SelectOption } from "./Select";
|
|
71
39
|
export { Slider } from "./Slider";
|
|
72
|
-
export { Table, type Column } from "./Table";
|
|
40
|
+
export { Table, type Column, type TableWindowing } from "./Table";
|
|
41
|
+
export { sortRows, type SortDirection, type TableSort } from "./tableSort";
|
|
42
|
+
export { Textarea, type TextareaProps } from "./Textarea";
|
|
73
43
|
export { Checkbox, Switch } from "./Toggle";
|
|
74
44
|
export { ToggleChip } from "./ToggleChip";
|
|
75
45
|
export { Toaster, type ToasterProps } from "./Toaster";
|
|
@@ -21,12 +21,7 @@ export type ListboxKey = "ArrowDown" | "ArrowUp" | "Home" | "End" | "PageDown" |
|
|
|
21
21
|
* @returns The new active index; `from` when there is nowhere to go; -1 when every option is
|
|
22
22
|
* disabled.
|
|
23
23
|
*/
|
|
24
|
-
export function moveActive(
|
|
25
|
-
disabled: readonly boolean[],
|
|
26
|
-
from: number,
|
|
27
|
-
key: ListboxKey,
|
|
28
|
-
page = 10,
|
|
29
|
-
): number {
|
|
24
|
+
export function moveActive(disabled: readonly boolean[], from: number, key: ListboxKey, page = 10): number {
|
|
30
25
|
const enabled = disabled.flatMap((off, index) => (off ? [] : [index]));
|
|
31
26
|
if (enabled.length === 0) return -1;
|
|
32
27
|
const first = enabled[0]!;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The order of a sorted `Table`, as pure functions so it is tested without a DOM, and so an app
|
|
3
|
+
* can reproduce the table's order (next/previous navigation, an export, a server that sorts the
|
|
4
|
+
* same way) with `sortRows`.
|
|
5
|
+
*
|
|
6
|
+
* The order is deterministic across machines: strings go through one collator with a fixed
|
|
7
|
+
* locale, so a server render and the browser that hydrates it put the rows in the same order.
|
|
8
|
+
* A `Collator` is not DOM, so building it once at module scope keeps the package SSR-safe.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Column } from "./Table";
|
|
12
|
+
|
|
13
|
+
/** Which way a column is sorted. Named as `aria-sort` names them: `"ascending"` puts the smallest first. */
|
|
14
|
+
export type SortDirection = "ascending" | "descending";
|
|
15
|
+
|
|
16
|
+
/** The column a table is sorted by, and which way. Where a sort is expected, `null` means the rows' own order. */
|
|
17
|
+
export interface TableSort {
|
|
18
|
+
/** The sorted column's `key`. */
|
|
19
|
+
key: string;
|
|
20
|
+
/** Which way. */
|
|
21
|
+
direction: SortDirection;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The column fields that decide the order. */
|
|
25
|
+
type SortColumn<Row> = Pick<Column<Row>, "key" | "sortable" | "sortValue" | "compare">;
|
|
26
|
+
|
|
27
|
+
/** Strings by a fixed English collation that reads digit runs as numbers: "frame 9" before "frame 10". */
|
|
28
|
+
const collator = new Intl.Collator("en", { numeric: true });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a column's header sorts the table: `sortable` if given, otherwise whether the column
|
|
32
|
+
* says how to order (`sortValue` or `compare`).
|
|
33
|
+
*/
|
|
34
|
+
export function isSortable<Row>(column: Pick<Column<Row>, "sortable" | "sortValue" | "compare">): boolean {
|
|
35
|
+
return column.sortable ?? (column.sortValue !== undefined || column.compare !== undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The sort after a click on `column`'s header: its first direction, then the other, then none. */
|
|
39
|
+
export function nextSort<Row>(current: TableSort | null, column: Pick<Column<Row>, "key" | "firstSort">): TableSort | null {
|
|
40
|
+
const first = column.firstSort ?? "ascending";
|
|
41
|
+
if (current?.key !== column.key) return { key: column.key, direction: first };
|
|
42
|
+
if (current.direction !== first) return null;
|
|
43
|
+
return { key: column.key, direction: first === "ascending" ? "descending" : "ascending" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A value with no place in the order: `null`, `undefined` or `NaN`. */
|
|
47
|
+
function missing(value: number | string | null | undefined): boolean {
|
|
48
|
+
return value === null || value === undefined || Number.isNaN(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Two present values in ascending order: numbers by value (compared, not subtracted, so two
|
|
53
|
+
* infinities tie), strings by the collator, and every number before every string.
|
|
54
|
+
*/
|
|
55
|
+
function compareValues(a: number | string, b: number | string): number {
|
|
56
|
+
if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
|
|
57
|
+
if (typeof a === "string" && typeof b === "string") return collator.compare(a, b);
|
|
58
|
+
return typeof a === "number" ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The display order of `rows` under `sort`, as indices into `rows`. `Table` renders through it
|
|
63
|
+
* so each row keeps its original index for the row callbacks.
|
|
64
|
+
*/
|
|
65
|
+
export function sortOrder<Row>(rows: readonly Row[], columns: readonly SortColumn<Row>[], sort: TableSort | null | undefined): number[] {
|
|
66
|
+
const order = rows.map((_, index) => index);
|
|
67
|
+
if (!sort) return order;
|
|
68
|
+
const column = columns.find((candidate) => candidate.key === sort.key);
|
|
69
|
+
if (!column || !isSortable(column)) return order;
|
|
70
|
+
const sign = sort.direction === "descending" ? -1 : 1;
|
|
71
|
+
|
|
72
|
+
// Every comparison falls back to the original index, so equal rows keep their order in both
|
|
73
|
+
// directions: descending is not ascending reversed, it is ascending with each run of ties kept.
|
|
74
|
+
const { compare, sortValue } = column;
|
|
75
|
+
if (compare) return order.sort((a, b) => sign * compare(rows[a]!, rows[b]!) || a - b);
|
|
76
|
+
if (!sortValue) return order;
|
|
77
|
+
|
|
78
|
+
// One call per row, not two per comparison: a `sortValue` may parse or look something up.
|
|
79
|
+
const values = rows.map((row) => sortValue(row));
|
|
80
|
+
return order.sort((a, b) => {
|
|
81
|
+
const left = values[a];
|
|
82
|
+
const right = values[b];
|
|
83
|
+
const leftMissing = missing(left);
|
|
84
|
+
const rightMissing = missing(right);
|
|
85
|
+
// Missing values go last whichever way the column is sorted: a reader sorting by score in
|
|
86
|
+
// either direction wants the rows that have one.
|
|
87
|
+
if (leftMissing || rightMissing) return leftMissing === rightMissing ? a - b : leftMissing ? 1 : -1;
|
|
88
|
+
return sign * compareValues(left!, right!) || a - b;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The rows in the order a `Table` with these columns shows them under `sort`.
|
|
94
|
+
*
|
|
95
|
+
* The sort is stable in both directions: rows that compare equal keep their order in `rows`.
|
|
96
|
+
* With the column's `compare`, that function decides (reversed for `"descending"`). With its
|
|
97
|
+
* `sortValue`, numbers compare by value and strings by a fixed English collation that reads
|
|
98
|
+
* digit runs as numbers ("frame 9" before "frame 10"), the same on a server as in any
|
|
99
|
+
* browser; numbers come before strings; and `null`, `undefined` and `NaN` go last in both
|
|
100
|
+
* directions. A `null` sort, a `key` that names no column, and a column that is not sortable or
|
|
101
|
+
* has neither function leave the order as given.
|
|
102
|
+
*
|
|
103
|
+
* @param rows - The rows, in their own order. Not modified.
|
|
104
|
+
* @param columns - The table's columns; only `key`, `sortable`, `sortValue` and `compare` are read.
|
|
105
|
+
* @param sort - The column and direction, or `null` for the rows' own order.
|
|
106
|
+
* @returns A new array of the same rows, sorted.
|
|
107
|
+
*/
|
|
108
|
+
export function sortRows<Row>(
|
|
109
|
+
rows: readonly Row[],
|
|
110
|
+
columns: readonly Pick<Column<Row>, "key" | "sortable" | "sortValue" | "compare">[],
|
|
111
|
+
sort: TableSort | null | undefined,
|
|
112
|
+
): Row[] {
|
|
113
|
+
return sortOrder(rows, columns, sort).map((index) => rows[index]!);
|
|
114
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The arithmetic of a windowed table: which rows to render for a scroll position, and where
|
|
3
|
+
* to scroll to bring a row into view. Pure, so it is tested on its own (`tableWindow.test.ts`,
|
|
4
|
+
* `tableWindow.property.test.ts`) rather than through layout.
|
|
5
|
+
*
|
|
6
|
+
* Every row is `rowHeight` tall. The body starts below a sticky header `headerHeight` tall, and
|
|
7
|
+
* the scroll container shows `viewportHeight` of the whole (header included).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The rows to render, as a half-open range of positions, and the space around them. */
|
|
11
|
+
export interface RowWindow {
|
|
12
|
+
/** The first rendered position. */
|
|
13
|
+
start: number;
|
|
14
|
+
/** One past the last rendered position. */
|
|
15
|
+
end: number;
|
|
16
|
+
/** The height, in px, of the rows before `start`. */
|
|
17
|
+
before: number;
|
|
18
|
+
/** The height, in px, of the rows from `end` on. */
|
|
19
|
+
after: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the window depends on. Lengths in px. */
|
|
23
|
+
export interface WindowInput {
|
|
24
|
+
rowCount: number;
|
|
25
|
+
rowHeight: number;
|
|
26
|
+
/** The scroll container's `scrollTop`. */
|
|
27
|
+
scrollTop: number;
|
|
28
|
+
/** The scroll container's visible height, header included. */
|
|
29
|
+
viewportHeight: number;
|
|
30
|
+
/** The sticky header's height. */
|
|
31
|
+
headerHeight: number;
|
|
32
|
+
/** Rows rendered beyond each edge of the visible ones. */
|
|
33
|
+
overscan: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rows that intersect the visible part of the body, plus `overscan` on each side.
|
|
38
|
+
* Degenerate input (no rows, a row height ≤ 0, NaN) gives an empty window.
|
|
39
|
+
*/
|
|
40
|
+
export function rowWindow({ rowCount, rowHeight, scrollTop, viewportHeight, headerHeight, overscan }: WindowInput): RowWindow {
|
|
41
|
+
const count = Number.isFinite(rowCount) ? Math.max(0, Math.floor(rowCount)) : 0;
|
|
42
|
+
if (count === 0 || !(rowHeight > 0)) return { start: 0, end: 0, before: 0, after: 0 };
|
|
43
|
+
const extra = Math.max(0, Math.floor(overscan) || 0);
|
|
44
|
+
// In the body's own coordinates the visible part is [scrollTop, scrollTop + viewport − header]:
|
|
45
|
+
// the sticky header covers the top of the container.
|
|
46
|
+
const top = Math.max(0, scrollTop || 0);
|
|
47
|
+
const bottom = Math.max(top, top + Math.max(0, viewportHeight || 0) - Math.max(0, headerHeight || 0));
|
|
48
|
+
const first = Math.min(count, Math.floor(top / rowHeight));
|
|
49
|
+
const last = Math.min(count, Math.max(first + 1, Math.ceil(bottom / rowHeight)));
|
|
50
|
+
const start = Math.max(0, first - extra);
|
|
51
|
+
const end = Math.min(count, last + extra);
|
|
52
|
+
return { start, end, before: start * rowHeight, after: (count - end) * rowHeight };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `scrollTop` that shows the row at `position` in full below the sticky header, moving
|
|
57
|
+
* as little as possible: unchanged when it is already in view, else aligned to the nearer
|
|
58
|
+
* edge. A viewport shorter than one row aligns the row's top.
|
|
59
|
+
*/
|
|
60
|
+
export function scrollTopFor(
|
|
61
|
+
position: number,
|
|
62
|
+
{ rowHeight, scrollTop, viewportHeight, headerHeight }: Pick<WindowInput, "rowHeight" | "scrollTop" | "viewportHeight" | "headerHeight">,
|
|
63
|
+
): number {
|
|
64
|
+
const rowTop = position * rowHeight;
|
|
65
|
+
const visible = viewportHeight - headerHeight;
|
|
66
|
+
if (rowTop < scrollTop || visible < rowHeight) return rowTop;
|
|
67
|
+
if (rowTop + rowHeight > scrollTop + visible) return rowTop + rowHeight - visible;
|
|
68
|
+
return scrollTop;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The position a navigation key moves to from `position`, among `rowCount` rows, `page` rows
|
|
73
|
+
* to a screen; `null` for a key that does not navigate.
|
|
74
|
+
*/
|
|
75
|
+
export function navigate(key: string, position: number, rowCount: number, page: number): number | null {
|
|
76
|
+
const last = rowCount - 1;
|
|
77
|
+
if (last < 0) return null;
|
|
78
|
+
const step = Math.max(1, Math.floor(page));
|
|
79
|
+
const clamp = (p: number) => Math.min(last, Math.max(0, p));
|
|
80
|
+
switch (key) {
|
|
81
|
+
case "ArrowDown":
|
|
82
|
+
return clamp(position + 1);
|
|
83
|
+
case "ArrowUp":
|
|
84
|
+
return clamp(position - 1);
|
|
85
|
+
case "PageDown":
|
|
86
|
+
return clamp(position + step);
|
|
87
|
+
case "PageUp":
|
|
88
|
+
return clamp(position - step);
|
|
89
|
+
case "Home":
|
|
90
|
+
return 0;
|
|
91
|
+
case "End":
|
|
92
|
+
return last;
|
|
93
|
+
default:
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/fonts/fonts.css
CHANGED
package/src/index.ts
CHANGED
|
@@ -7,14 +7,7 @@
|
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
export {
|
|
11
|
-
DEFAULT_THEME_STORAGE_KEY,
|
|
12
|
-
initTheme,
|
|
13
|
-
readThemeChoice,
|
|
14
|
-
resolveTheme,
|
|
15
|
-
setThemeChoice,
|
|
16
|
-
type ThemeChoice,
|
|
17
|
-
} from "./theme";
|
|
10
|
+
export { DEFAULT_THEME_STORAGE_KEY, initTheme, readThemeChoice, resolveTheme, setThemeChoice, type ThemeChoice } from "./theme";
|
|
18
11
|
|
|
19
12
|
export { toneColor, type MeasureTone } from "./tone";
|
|
20
13
|
|
package/src/styles.css
CHANGED
|
@@ -5,12 +5,8 @@
|
|
|
5
5
|
@source "./";
|
|
6
6
|
|
|
7
7
|
/*
|
|
8
|
-
* The design token layer
|
|
9
|
-
*
|
|
10
|
-
* Extracted from visual-anomaly-lab's own token layer (its ADR-0021), where it was written
|
|
11
|
-
* to close a real gap: eleven screens with every colour inlined as a raw `slate-*` utility
|
|
12
|
-
* at 250-odd call sites — the same palette restated 250 times rather than defined once.
|
|
13
|
-
* This file is that definition, shared now by every lab app rather than owned by one.
|
|
8
|
+
* The design token layer: every colour, radius and font the components use, defined once
|
|
9
|
+
* here rather than restated as raw palette utilities at each call site.
|
|
14
10
|
*
|
|
15
11
|
* The direction is *instrument*: the chrome is grey so the data can be loud. Anomaly maps,
|
|
16
12
|
* colormaps, grayscale sensor images and vector measurement overlays are what these
|
|
@@ -62,6 +58,20 @@
|
|
|
62
58
|
--radius-panel: 0.625rem;
|
|
63
59
|
}
|
|
64
60
|
|
|
61
|
+
/* `Progress` while the amount is unknown: a third of the track sweeping across it. */
|
|
62
|
+
@theme {
|
|
63
|
+
--animate-progress-sweep: progress-sweep 1.4s ease-in-out infinite;
|
|
64
|
+
|
|
65
|
+
@keyframes progress-sweep {
|
|
66
|
+
from {
|
|
67
|
+
transform: translateX(-100%);
|
|
68
|
+
}
|
|
69
|
+
to {
|
|
70
|
+
transform: translateX(300%);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
/*
|
|
66
76
|
* Light is the default so a document with no class still renders legibly -- a consumer's
|
|
67
77
|
* own no-flash script sets `.dark` before first paint (see `theme.ts` and the README), but
|
|
@@ -70,7 +80,7 @@
|
|
|
70
80
|
* Every value below is held by `contrast.test.ts` (WCAG 2.2 AA, both themes): text ≥ 4.5:1
|
|
71
81
|
* on every surface, including each verdict colour and `signal` on its own /12 tint, and the
|
|
72
82
|
* control boundary `line-strong` and `signal` (focus, checked) ≥ 3:1. `signal` and `normal`
|
|
73
|
-
* are also kept apart for a tritanope
|
|
83
|
+
* are also kept apart for a tritanope — the reason the light
|
|
74
84
|
* accent is a deep slate-teal rather than a brighter one. Change a value only with the tests.
|
|
75
85
|
*/
|
|
76
86
|
:root {
|
package/src/theme.ts
CHANGED
|
@@ -36,9 +36,7 @@ export const DEFAULT_THEME_STORAGE_KEY = "vitavision-theme";
|
|
|
36
36
|
// Every DOM access here is inside a function, and guarded: this module is imported on the
|
|
37
37
|
// server too.
|
|
38
38
|
const query = () =>
|
|
39
|
-
typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
40
|
-
? window.matchMedia("(prefers-color-scheme: dark)")
|
|
41
|
-
: null;
|
|
39
|
+
typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
42
40
|
|
|
43
41
|
/**
|
|
44
42
|
* The stored preference.
|
|
@@ -81,10 +79,7 @@ function paint(choice: ThemeChoice): void {
|
|
|
81
79
|
* @param choice - The new preference.
|
|
82
80
|
* @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
|
|
83
81
|
*/
|
|
84
|
-
export function setThemeChoice(
|
|
85
|
-
choice: ThemeChoice,
|
|
86
|
-
storageKey: string = DEFAULT_THEME_STORAGE_KEY,
|
|
87
|
-
): void {
|
|
82
|
+
export function setThemeChoice(choice: ThemeChoice, storageKey: string = DEFAULT_THEME_STORAGE_KEY): void {
|
|
88
83
|
try {
|
|
89
84
|
window.localStorage.setItem(storageKey, choice);
|
|
90
85
|
} catch {
|