@vitavision/ui 0.12.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.
Files changed (38) hide show
  1. package/README.md +6 -3
  2. package/dist/index.d.ts +171 -7
  3. package/dist/index.js +490 -61
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/components/Tabs.tsx +3 -10
  7. package/src/components/ThemeToggle.tsx +1 -5
  8. package/src/components/ui/Badge.tsx +1 -4
  9. package/src/components/ui/Button.tsx +3 -15
  10. package/src/components/ui/Dialog.tsx +4 -19
  11. package/src/components/ui/Disclosure.tsx +2 -7
  12. package/src/components/ui/DropdownMenu.tsx +2 -16
  13. package/src/components/ui/Feedback.tsx +5 -9
  14. package/src/components/ui/Field.tsx +4 -15
  15. package/src/components/ui/HighlightedText.tsx +84 -0
  16. package/src/components/ui/Input.tsx +5 -43
  17. package/src/components/ui/Panel.tsx +4 -16
  18. package/src/components/ui/PoseInput.tsx +7 -5
  19. package/src/components/ui/Progress.tsx +80 -0
  20. package/src/components/ui/SegmentedControl.tsx +1 -3
  21. package/src/components/ui/Select.tsx +2 -10
  22. package/src/components/ui/Slider.tsx +2 -7
  23. package/src/components/ui/Table.tsx +155 -32
  24. package/src/components/ui/Textarea.tsx +149 -0
  25. package/src/components/ui/Toaster.tsx +3 -19
  26. package/src/components/ui/Toggle.tsx +1 -5
  27. package/src/components/ui/ToggleChip.tsx +1 -3
  28. package/src/components/ui/Tooltip.tsx +1 -5
  29. package/src/components/ui/VectorInput.tsx +1 -1
  30. package/src/components/ui/cn.ts +1 -2
  31. package/src/components/ui/highlightSpans.ts +110 -0
  32. package/src/components/ui/index.ts +10 -41
  33. package/src/components/ui/listboxModel.ts +1 -6
  34. package/src/components/ui/tableSort.ts +2 -9
  35. package/src/components/ui/tableWindow.ts +96 -0
  36. package/src/index.ts +1 -8
  37. package/src/styles.css +14 -0
  38. package/src/theme.ts +2 -7
@@ -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,55 +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
- Input,
37
- NumberInput,
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
- PageHeader,
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";
73
41
  export { sortRows, type SortDirection, type TableSort } from "./tableSort";
42
+ export { Textarea, type TextareaProps } from "./Textarea";
74
43
  export { Checkbox, Switch } from "./Toggle";
75
44
  export { ToggleChip } from "./ToggleChip";
76
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]!;
@@ -36,10 +36,7 @@ export function isSortable<Row>(column: Pick<Column<Row>, "sortable" | "sortValu
36
36
  }
37
37
 
38
38
  /** The sort after a click on `column`'s header: its first direction, then the other, then none. */
39
- export function nextSort<Row>(
40
- current: TableSort | null,
41
- column: Pick<Column<Row>, "key" | "firstSort">,
42
- ): TableSort | null {
39
+ export function nextSort<Row>(current: TableSort | null, column: Pick<Column<Row>, "key" | "firstSort">): TableSort | null {
43
40
  const first = column.firstSort ?? "ascending";
44
41
  if (current?.key !== column.key) return { key: column.key, direction: first };
45
42
  if (current.direction !== first) return null;
@@ -65,11 +62,7 @@ function compareValues(a: number | string, b: number | string): number {
65
62
  * The display order of `rows` under `sort`, as indices into `rows`. `Table` renders through it
66
63
  * so each row keeps its original index for the row callbacks.
67
64
  */
68
- export function sortOrder<Row>(
69
- rows: readonly Row[],
70
- columns: readonly SortColumn<Row>[],
71
- sort: TableSort | null | undefined,
72
- ): number[] {
65
+ export function sortOrder<Row>(rows: readonly Row[], columns: readonly SortColumn<Row>[], sort: TableSort | null | undefined): number[] {
73
66
  const order = rows.map((_, index) => index);
74
67
  if (!sort) return order;
75
68
  const column = columns.find((candidate) => candidate.key === sort.key);
@@ -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/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
@@ -58,6 +58,20 @@
58
58
  --radius-panel: 0.625rem;
59
59
  }
60
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
+
61
75
  /*
62
76
  * Light is the default so a document with no class still renders legibly -- a consumer's
63
77
  * own no-flash script sets `.dark` before first paint (see `theme.ts` and the README), but
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 {