@vitavision/ui 0.12.0 → 0.14.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 (39) hide show
  1. package/README.md +6 -3
  2. package/dist/index.d.ts +244 -7
  3. package/dist/index.js +590 -63
  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/Breakdown.tsx +139 -0
  10. package/src/components/ui/Button.tsx +3 -15
  11. package/src/components/ui/Dialog.tsx +4 -19
  12. package/src/components/ui/Disclosure.tsx +2 -7
  13. package/src/components/ui/DropdownMenu.tsx +2 -16
  14. package/src/components/ui/Feedback.tsx +5 -9
  15. package/src/components/ui/Field.tsx +4 -15
  16. package/src/components/ui/HighlightedText.tsx +84 -0
  17. package/src/components/ui/Input.tsx +5 -43
  18. package/src/components/ui/Panel.tsx +4 -16
  19. package/src/components/ui/PoseInput.tsx +7 -5
  20. package/src/components/ui/Progress.tsx +80 -0
  21. package/src/components/ui/SegmentedControl.tsx +1 -3
  22. package/src/components/ui/Select.tsx +2 -10
  23. package/src/components/ui/Slider.tsx +2 -7
  24. package/src/components/ui/Table.tsx +182 -32
  25. package/src/components/ui/Textarea.tsx +149 -0
  26. package/src/components/ui/Toaster.tsx +3 -19
  27. package/src/components/ui/Toggle.tsx +1 -5
  28. package/src/components/ui/ToggleChip.tsx +1 -3
  29. package/src/components/ui/Tooltip.tsx +1 -5
  30. package/src/components/ui/VectorInput.tsx +1 -1
  31. package/src/components/ui/cn.ts +1 -2
  32. package/src/components/ui/highlightSpans.ts +110 -0
  33. package/src/components/ui/index.ts +18 -41
  34. package/src/components/ui/listboxModel.ts +1 -6
  35. package/src/components/ui/tableSort.ts +2 -9
  36. package/src/components/ui/tableWindow.ts +96 -0
  37. package/src/index.ts +1 -8
  38. package/src/styles.css +14 -0
  39. package/src/theme.ts +2 -7
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, Check, CheckCircle2, ChevronDown, ChevronRight, CircleCheck, CircleX, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, TriangleAlert, X, XCircle } from "lucide-react";
2
- import { createContext, isValidElement, use, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { createContext, isValidElement, use, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import * as RadixTooltip from "@radix-ui/react-tooltip";
4
4
  import { clsx } from "clsx";
5
5
  import { twMerge } from "tailwind-merge";
@@ -246,7 +246,7 @@ function ThemeToggle({ storageKey = DEFAULT_THEME_STORAGE_KEY, className }) {
246
246
  }
247
247
  //#endregion
248
248
  //#region src/components/ui/Badge.tsx
249
- const TONE_CLASSES = {
249
+ const TONE_CLASSES$1 = {
250
250
  neutral: "bg-raised text-fg-muted ring-line",
251
251
  normal: "bg-normal/12 text-normal ring-normal/25",
252
252
  defect: "bg-defect/12 text-defect ring-defect/25",
@@ -271,7 +271,7 @@ const DOT_CLASSES = {
271
271
  function Badge({ tone = "neutral", className, children }) {
272
272
  return /* @__PURE__ */ jsx("span", {
273
273
  "data-tone": tone,
274
- className: cn("inline-flex items-center gap-1 rounded-control px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset", TONE_CLASSES[tone], className),
274
+ className: cn("inline-flex items-center gap-1 rounded-control px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset", TONE_CLASSES$1[tone], className),
275
275
  children
276
276
  });
277
277
  }
@@ -346,6 +346,93 @@ function DensityProvider({ value, children }) {
346
346
  });
347
347
  }
348
348
  //#endregion
349
+ //#region src/components/ui/Breakdown.tsx
350
+ /** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
351
+ const count$1 = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
352
+ const TONE_CLASSES = {
353
+ normal: "bg-normal",
354
+ warn: "bg-warn",
355
+ defect: "bg-defect",
356
+ neutral: "bg-fg-subtle"
357
+ };
358
+ /** A count as drawn: 0 for anything that is not a finite, positive number. */
359
+ const amount = (value) => Number.isFinite(value) && value > 0 ? value : 0;
360
+ /** The segments that are drawn, the whole, and the rest of it. */
361
+ function measure(segments, total) {
362
+ const shown = segments.filter((segment) => amount(segment.value) > 0);
363
+ const sum = shown.reduce((acc, segment) => acc + amount(segment.value), 0);
364
+ const whole = total !== void 0 && Number.isFinite(total) && total > sum ? total : sum;
365
+ return {
366
+ shown,
367
+ sum,
368
+ whole,
369
+ rest: whole - sum
370
+ };
371
+ }
372
+ /**
373
+ * The counts of a breakdown in words: `"412 done, 20 to check, 3 problems, 131 to do of 566"`.
374
+ *
375
+ * It is the default accessible name of `Breakdown`, and the text for a tooltip or a label printed
376
+ * beside the bar. Segments of 0 are left out; the rest of the total is named only with a
377
+ * `remainderLabel`. With nothing to count it reads `"0 of 566"`.
378
+ *
379
+ * @param segments - The categories, in order.
380
+ * @param options - The `total` and the `remainderLabel`.
381
+ * @returns The text.
382
+ */
383
+ function breakdownText(segments, options = {}) {
384
+ const { shown, whole, rest } = measure(segments, options.total);
385
+ const parts = shown.map((segment) => `${count$1.format(amount(segment.value))} ${segment.label}`);
386
+ if (options.remainderLabel !== void 0 && rest > 0) parts.push(`${count$1.format(rest)} ${options.remainderLabel}`);
387
+ return `${parts.length > 0 ? parts.join(", ") : "0"} of ${count$1.format(whole)}`;
388
+ }
389
+ /**
390
+ * A thin bar of counts by category: 412 done, 20 to check and 3 problems out of 566, with the
391
+ * rest as empty track. Segments are drawn in the order given, each as wide as its share of the
392
+ * total (a non-zero count is never narrower than 2 px); segments of 0 are left out.
393
+ *
394
+ * Its colours are verdicts, so use it only for things the app has judged — never for a share of
395
+ * a continuous quantity or for series side by side (use `Progress` or a chart).
396
+ *
397
+ * A `role="img"` whose name lists every non-zero count, as `breakdownText` writes it: "412 done,
398
+ * 20 to check, 3 problems, 131 to do of 566". `aria-label` or `aria-labelledby` replaces it. The track
399
+ * matches `Progress` and follows the density in force. State is exposed as `data-state`
400
+ * (`"empty"` with nothing counted, `"partial"`, or `"complete"` when the segments fill the
401
+ * total), and each segment's tone as its `data-tone`. Width changes ease over 300 ms, and not at
402
+ * all with reduced motion.
403
+ */
404
+ function Breakdown({ segments, total, remainderLabel, className, "aria-label": ariaLabel, ...rest }) {
405
+ const density = useDensity();
406
+ const { shown, sum, rest: remainder } = measure(segments, total);
407
+ const name = ariaLabel ?? (rest["aria-labelledby"] ? void 0 : breakdownText(segments, {
408
+ total,
409
+ remainderLabel
410
+ }));
411
+ return /* @__PURE__ */ jsxs("div", {
412
+ role: "img",
413
+ "aria-label": name,
414
+ "data-state": sum === 0 ? "empty" : remainder > 0 ? "partial" : "complete",
415
+ ...rest,
416
+ className: cn("w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className),
417
+ style: {
418
+ display: "flex",
419
+ gap: 1,
420
+ ...rest.style
421
+ },
422
+ children: [shown.map((segment) => /* @__PURE__ */ jsx("div", {
423
+ "data-tone": segment.tone,
424
+ className: cn("h-full transition-[flex-grow] duration-300", TONE_CLASSES[segment.tone]),
425
+ style: {
426
+ flex: `${amount(segment.value)} 0 0`,
427
+ minWidth: 2
428
+ }
429
+ }, segment.label)), remainder > 0 && /* @__PURE__ */ jsx("div", {
430
+ className: "h-full transition-[flex-grow] duration-300",
431
+ style: { flex: `${remainder} 0 0` }
432
+ })]
433
+ });
434
+ }
435
+ //#endregion
349
436
  //#region src/components/ui/Button.tsx
350
437
  const VARIANT_CLASSES = {
351
438
  primary: "bg-signal text-signal-fg hover:bg-signal-strong",
@@ -767,6 +854,9 @@ function SkeletonRows({ rows = 3, className }) {
767
854
  * A `role="progressbar"` with `aria-valuenow` in percent. Its accessible name is
768
855
  * `aria-label` if given, else `label`, else "Progress" — pass one that names the job when
769
856
  * a screen shows more than one bar.
857
+ *
858
+ * @deprecated Use `Progress`: a value out of a `max` with a "412 of 2,556" value text, an
859
+ * indeterminate state, and a track that follows the density.
770
860
  */
771
861
  function ProgressBar({ fraction, label, "aria-label": ariaLabel, className }) {
772
862
  const percent = Math.round(Math.min(Math.max(fraction, 0), 1) * 100);
@@ -885,6 +975,131 @@ function Field({ label, description, error, required = false, annotation, as = "
885
975
  });
886
976
  }
887
977
  //#endregion
978
+ //#region src/components/ui/highlightSpans.ts
979
+ /** The kinds in the order their styles stack: a later one wins where two would clash. */
980
+ const HIGHLIGHT_KINDS = [
981
+ "tag",
982
+ "protected-term",
983
+ "placeholder",
984
+ "mismatch"
985
+ ];
986
+ /**
987
+ * Cuts `text` into runs that are each covered by one fixed set of spans. Spans are clamped
988
+ * to the text; empty, inverted and non-finite ones are dropped. Overlapping spans are kept:
989
+ * the overlap is a run covered by both.
990
+ *
991
+ * @param text - The string, shown as text.
992
+ * @param spans - The highlights, in any order.
993
+ * @returns The runs, in order, together covering the whole text.
994
+ */
995
+ function segmentText(text, spans) {
996
+ const length = text.length;
997
+ const clamp = (offset) => Math.floor(Math.max(0, Math.min(length, offset)));
998
+ const valid = spans.map((span) => ({
999
+ span,
1000
+ start: clamp(span.start),
1001
+ end: clamp(span.end)
1002
+ })).filter(({ start, end }) => Number.isFinite(start) && Number.isFinite(end) && end > start);
1003
+ const cuts = /* @__PURE__ */ new Set([0, length]);
1004
+ for (const { start, end } of valid) {
1005
+ cuts.add(start);
1006
+ cuts.add(end);
1007
+ }
1008
+ const points = [...cuts].sort((a, b) => a - b);
1009
+ const segments = [];
1010
+ for (let i = 0; i + 1 < points.length; i++) {
1011
+ const start = points[i];
1012
+ const end = points[i + 1];
1013
+ const covering = valid.filter((v) => v.start <= start && v.end >= end).map((v) => v.span);
1014
+ segments.push({
1015
+ text: text.slice(start, end),
1016
+ start,
1017
+ end,
1018
+ spans: covering
1019
+ });
1020
+ }
1021
+ return segments;
1022
+ }
1023
+ /**
1024
+ * Spans for every match of every rule: `%1`, `<b>`, a glossary term. Empty matches are
1025
+ * skipped. The result is sorted by start, then longest first.
1026
+ *
1027
+ * @param text - The string to search.
1028
+ * @param rules - Each pattern and the kind its matches get.
1029
+ * @returns The spans, ready for `HighlightedText`.
1030
+ *
1031
+ * @example
1032
+ * ```ts
1033
+ * findSpans("Open %1 in <b>ParamsManager</b>", [
1034
+ * { pattern: /%\d+/, kind: "placeholder" },
1035
+ * { pattern: /<\/?[a-z][^>]*>/i, kind: "tag" },
1036
+ * { pattern: /\bParamsManager\b/, kind: "protected-term" },
1037
+ * ]);
1038
+ * ```
1039
+ */
1040
+ function findSpans(text, rules) {
1041
+ const spans = [];
1042
+ for (const rule of rules) {
1043
+ const flags = rule.pattern.flags.includes("g") ? rule.pattern.flags : `${rule.pattern.flags}g`;
1044
+ for (const match of text.matchAll(new RegExp(rule.pattern.source, flags))) {
1045
+ if (match[0] === "") continue;
1046
+ const start = match.index;
1047
+ spans.push({
1048
+ start,
1049
+ end: start + match[0].length,
1050
+ kind: rule.kind,
1051
+ ...rule.label === void 0 ? {} : { label: rule.label }
1052
+ });
1053
+ }
1054
+ }
1055
+ return spans.sort((a, b) => a.start - b.start || b.end - a.end);
1056
+ }
1057
+ //#endregion
1058
+ //#region src/components/ui/HighlightedText.tsx
1059
+ /** What a screen reader hears after a span of each kind, by default. */
1060
+ const LABELS = {
1061
+ placeholder: "placeholder",
1062
+ tag: "markup",
1063
+ "protected-term": "protected term",
1064
+ mismatch: "mismatch"
1065
+ };
1066
+ /** The look of each kind; listed in `HIGHLIGHT_KINDS` order, so a later kind wins a clash. */
1067
+ const STYLES = {
1068
+ tag: "rounded-sm bg-raised px-px font-mono text-fg-muted",
1069
+ "protected-term": "underline decoration-fg-subtle decoration-dotted decoration-1 underline-offset-[3px]",
1070
+ placeholder: "rounded-sm bg-raised px-px font-mono text-fg ring-1 ring-line-strong ring-inset",
1071
+ mismatch: "bg-defect/12 text-fg underline decoration-defect decoration-wavy decoration-1 underline-offset-[3px]"
1072
+ };
1073
+ /**
1074
+ * A string with highlighted spans: placeholders (`%1`, `{count}`), markup tags (`<b>`),
1075
+ * protected terms (`ParamsManager`) and mismatches, each drawn in its own shape. The caller
1076
+ * supplies the spans, or finds them with `findSpans`.
1077
+ *
1078
+ * The text is rendered as text, never as HTML. Each span is a `<mark>` with `data-kind` (the
1079
+ * kinds covering it, space-separated), followed by its label for screen readers ("%1,
1080
+ * placeholder"), which a copy of the text leaves out.
1081
+ */
1082
+ function HighlightedText({ text, spans = [], labels, className, ...rest }) {
1083
+ const segments = segmentText(text, spans);
1084
+ return /* @__PURE__ */ jsx("span", {
1085
+ ...rest,
1086
+ className: cn("break-words whitespace-pre-wrap", className),
1087
+ children: segments.map((segment) => {
1088
+ if (segment.spans.length === 0) return segment.text;
1089
+ const kinds = HIGHLIGHT_KINDS.filter((kind) => segment.spans.some((span) => span.kind === kind));
1090
+ const said = segment.spans.map((span) => span.label ?? (labels?.[span.kind] === void 0 ? LABELS[span.kind] : labels[span.kind])).filter((label) => typeof label === "string" && label !== "");
1091
+ return /* @__PURE__ */ jsxs("mark", {
1092
+ "data-kind": kinds.join(" "),
1093
+ className: cn("bg-transparent text-inherit", ...kinds.map((kind) => STYLES[kind])),
1094
+ children: [segment.text, said.length > 0 && /* @__PURE__ */ jsx("span", {
1095
+ className: "sr-only select-none",
1096
+ children: `, ${[...new Set(said)].join(", ")}`
1097
+ })]
1098
+ }, segment.start);
1099
+ })
1100
+ });
1101
+ }
1102
+ //#endregion
888
1103
  //#region src/components/ui/numberText.ts
889
1104
  /**
890
1105
  * A number at a fixed number of decimals, without a negative zero (`-0.000` reads as a sign
@@ -1074,17 +1289,6 @@ function UnitNumberInput({ unit, style, "aria-describedby": describedBy, ...rest
1074
1289
  })]
1075
1290
  });
1076
1291
  }
1077
- /**
1078
- * Multi-line text — set in mono, for JSON and lists. Takes every `<textarea>` prop, `ref`
1079
- * included.
1080
- */
1081
- function Textarea({ className, "aria-describedby": describedBy, ...rest }) {
1082
- return /* @__PURE__ */ jsx("textarea", {
1083
- ...useFieldDescription(describedBy),
1084
- ...rest,
1085
- className: cn(controlClasses, "py-1.5 font-mono", className)
1086
- });
1087
- }
1088
1292
  //#endregion
1089
1293
  //#region src/components/ui/listboxModel.ts
1090
1294
  /**
@@ -1361,6 +1565,52 @@ function ReadoutStrip({ items, className }) {
1361
1565
  });
1362
1566
  }
1363
1567
  //#endregion
1568
+ //#region src/components/ui/Progress.tsx
1569
+ /** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
1570
+ const count = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
1571
+ /**
1572
+ * The accessible value text of a progress: `"412 of 2,556"`, or `null` while indeterminate.
1573
+ *
1574
+ * @param value - Done so far; `null` or `undefined` for an unknown amount.
1575
+ * @param max - The whole. Defaults to 100.
1576
+ * @returns The text, with the value clamped to `[0, max]`.
1577
+ */
1578
+ function progressText(value, max = 100) {
1579
+ if (value === null || value === void 0 || !Number.isFinite(value)) return null;
1580
+ const whole = max > 0 ? max : 100;
1581
+ return `${count.format(Math.min(Math.max(value, 0), whole))} of ${count.format(whole)}`;
1582
+ }
1583
+ /**
1584
+ * A progress bar: determinate when it has a `value` (a `max`, 100 by default), indeterminate
1585
+ * without one. Name it with `aria-label` or `aria-labelledby`.
1586
+ *
1587
+ * A `progressbar` with `aria-valuenow`, `aria-valuemax` and `aria-valuetext` ("412 of 2,556",
1588
+ * or your `valueText`); indeterminate, it has none of them, as the ARIA pattern asks.
1589
+ * The track follows the density in force. State is exposed as `data-state`
1590
+ * (`"indeterminate"`, `"loading"` or `"complete"`). With reduced motion the indeterminate bar
1591
+ * holds still.
1592
+ */
1593
+ function Progress({ value, max = 100, valueText, className, ...rest }) {
1594
+ const density = useDensity();
1595
+ const whole = max > 0 ? max : 100;
1596
+ const text = progressText(value, whole);
1597
+ const done = text === null ? null : Math.min(Math.max(value, 0), whole);
1598
+ return /* @__PURE__ */ jsx("div", {
1599
+ role: "progressbar",
1600
+ "aria-valuemin": done === null ? void 0 : 0,
1601
+ "aria-valuemax": done === null ? void 0 : whole,
1602
+ "aria-valuenow": done ?? void 0,
1603
+ "aria-valuetext": done === null ? void 0 : valueText ?? text ?? void 0,
1604
+ "data-state": done === null ? "indeterminate" : done >= whole ? "complete" : "loading",
1605
+ ...rest,
1606
+ className: cn("relative w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className),
1607
+ children: done === null ? /* @__PURE__ */ jsx("div", { className: "absolute inset-y-0 left-0 w-1/3 animate-progress-sweep rounded-full bg-signal" }) : /* @__PURE__ */ jsx("div", {
1608
+ className: "h-full rounded-full bg-signal transition-[width] duration-300",
1609
+ style: { width: `${done / whole * 100}%` }
1610
+ })
1611
+ });
1612
+ }
1613
+ //#endregion
1364
1614
  //#region src/components/ui/Popover.tsx
1365
1615
  /**
1366
1616
  * Floating content anchored to `trigger`, on the `overlay` surface with a shadow (floating
@@ -1854,7 +2104,68 @@ function sortRows(rows, columns, sort) {
1854
2104
  return sortOrder(rows, columns, sort).map((index) => rows[index]);
1855
2105
  }
1856
2106
  //#endregion
2107
+ //#region src/components/ui/tableWindow.ts
2108
+ /**
2109
+ * The rows that intersect the visible part of the body, plus `overscan` on each side.
2110
+ * Degenerate input (no rows, a row height ≤ 0, NaN) gives an empty window.
2111
+ */
2112
+ function rowWindow({ rowCount, rowHeight, scrollTop, viewportHeight, headerHeight, overscan }) {
2113
+ const count = Number.isFinite(rowCount) ? Math.max(0, Math.floor(rowCount)) : 0;
2114
+ if (count === 0 || !(rowHeight > 0)) return {
2115
+ start: 0,
2116
+ end: 0,
2117
+ before: 0,
2118
+ after: 0
2119
+ };
2120
+ const extra = Math.max(0, Math.floor(overscan) || 0);
2121
+ const top = Math.max(0, scrollTop || 0);
2122
+ const bottom = Math.max(top, top + Math.max(0, viewportHeight || 0) - Math.max(0, headerHeight || 0));
2123
+ const first = Math.min(count, Math.floor(top / rowHeight));
2124
+ const last = Math.min(count, Math.max(first + 1, Math.ceil(bottom / rowHeight)));
2125
+ const start = Math.max(0, first - extra);
2126
+ const end = Math.min(count, last + extra);
2127
+ return {
2128
+ start,
2129
+ end,
2130
+ before: start * rowHeight,
2131
+ after: (count - end) * rowHeight
2132
+ };
2133
+ }
2134
+ /**
2135
+ * The `scrollTop` that shows the row at `position` in full below the sticky header, moving
2136
+ * as little as possible: unchanged when it is already in view, else aligned to the nearer
2137
+ * edge. A viewport shorter than one row aligns the row's top.
2138
+ */
2139
+ function scrollTopFor(position, { rowHeight, scrollTop, viewportHeight, headerHeight }) {
2140
+ const rowTop = position * rowHeight;
2141
+ const visible = viewportHeight - headerHeight;
2142
+ if (rowTop < scrollTop || visible < rowHeight) return rowTop;
2143
+ if (rowTop + rowHeight > scrollTop + visible) return rowTop + rowHeight - visible;
2144
+ return scrollTop;
2145
+ }
2146
+ /**
2147
+ * The position a navigation key moves to from `position`, among `rowCount` rows, `page` rows
2148
+ * to a screen; `null` for a key that does not navigate.
2149
+ */
2150
+ function navigate(key, position, rowCount, page) {
2151
+ const last = rowCount - 1;
2152
+ if (last < 0) return null;
2153
+ const step = Math.max(1, Math.floor(page));
2154
+ const clamp = (p) => Math.min(last, Math.max(0, p));
2155
+ switch (key) {
2156
+ case "ArrowDown": return clamp(position + 1);
2157
+ case "ArrowUp": return clamp(position - 1);
2158
+ case "PageDown": return clamp(position + step);
2159
+ case "PageUp": return clamp(position - step);
2160
+ case "Home": return 0;
2161
+ case "End": return last;
2162
+ default: return null;
2163
+ }
2164
+ }
2165
+ //#endregion
1857
2166
  //#region src/components/ui/Table.tsx
2167
+ /** Rows per screen before a windowed table has measured itself (a server render, the first paint). */
2168
+ const INITIAL_ROWS = 20;
1858
2169
  /** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
1859
2170
  const SORT_ICONS = {
1860
2171
  ascending: ArrowUp,
@@ -1875,8 +2186,13 @@ const SORT_ICONS = {
1875
2186
  * `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
1876
2187
  * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
1877
2188
  * whatever the order on screen.
2189
+ *
2190
+ * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End;
2191
+ * with `selectionFollowsFocus` each of those moves also activates the row it lands on.
2192
+ * With `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
2193
+ * rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
1878
2194
  */
1879
- function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false }) {
2195
+ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false, windowed, selectionFollowsFocus = false }) {
1880
2196
  const density = useDensity();
1881
2197
  const [ownSort, setOwnSort] = useState(defaultSort);
1882
2198
  const controlled = controlledSort !== void 0;
@@ -1892,71 +2208,282 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
1892
2208
  if (!controlled) setOwnSort(next);
1893
2209
  onSortChange?.(next);
1894
2210
  };
2211
+ const rowHeight = windowed?.rowHeight ?? 0;
2212
+ const scrollRef = useRef(null);
2213
+ const headRef = useRef(null);
2214
+ const bodyRef = useRef(null);
2215
+ const pendingFocus = useRef(null);
2216
+ const [scrollTop, setScrollTop] = useState(0);
2217
+ const [measured, setMeasured] = useState(null);
2218
+ const viewportHeight = measured?.viewport ?? windowed?.height ?? INITIAL_ROWS * rowHeight;
2219
+ const headerHeight = measured?.header ?? 0;
2220
+ const range = windowed ? rowWindow({
2221
+ rowCount: order.length,
2222
+ rowHeight,
2223
+ scrollTop,
2224
+ viewportHeight,
2225
+ headerHeight,
2226
+ overscan: windowed.overscan ?? 8
2227
+ }) : {
2228
+ start: 0,
2229
+ end: order.length,
2230
+ before: 0,
2231
+ after: 0
2232
+ };
2233
+ const isWindowed = windowed !== void 0;
2234
+ const hasRows = rows.length > 0;
2235
+ useLayoutEffect(() => {
2236
+ const scroller = scrollRef.current;
2237
+ if (!isWindowed || !hasRows || !scroller || typeof ResizeObserver === "undefined") return;
2238
+ const measure = () => {
2239
+ if (!scroller.isConnected) return;
2240
+ const next = {
2241
+ viewport: scroller.clientHeight,
2242
+ header: headRef.current?.offsetHeight ?? 0
2243
+ };
2244
+ setMeasured((last) => last?.viewport === next.viewport && last.header === next.header ? last : next);
2245
+ };
2246
+ measure();
2247
+ setScrollTop(scroller.scrollTop);
2248
+ const observer = new ResizeObserver(measure);
2249
+ observer.observe(scroller);
2250
+ return () => observer.disconnect();
2251
+ }, [isWindowed, hasRows]);
2252
+ useLayoutEffect(() => {
2253
+ const target = pendingFocus.current;
2254
+ if (target === null) return;
2255
+ const row = bodyRef.current?.querySelector(`tr[data-position="${target}"]`);
2256
+ if (!row) return;
2257
+ pendingFocus.current = null;
2258
+ row.focus({ preventScroll: isWindowed });
2259
+ });
2260
+ /**
2261
+ * Moves focus from the row at `position` for a navigation key, activating the new row when
2262
+ * selection follows focus; `true` when the key was one.
2263
+ */
2264
+ const moveFocus = (event, position) => {
2265
+ const page = windowed ? Math.floor((viewportHeight - headerHeight) / rowHeight) : 10;
2266
+ const target = navigate(event.key, position, order.length, page);
2267
+ if (target === null) return false;
2268
+ if (selectionFollowsFocus && onRowClick && target !== position) {
2269
+ const index = order[target];
2270
+ onRowClick(rows[index], index, event);
2271
+ }
2272
+ pendingFocus.current = target;
2273
+ const scroller = scrollRef.current;
2274
+ if (windowed && scroller) {
2275
+ scroller.scrollTop = scrollTopFor(target, {
2276
+ rowHeight,
2277
+ scrollTop: scroller.scrollTop,
2278
+ viewportHeight,
2279
+ headerHeight
2280
+ });
2281
+ setScrollTop(scroller.scrollTop);
2282
+ }
2283
+ const row = bodyRef.current?.querySelector(`tr[data-position="${target}"]`);
2284
+ if (row) {
2285
+ pendingFocus.current = null;
2286
+ row.focus({ preventScroll: windowed !== void 0 });
2287
+ }
2288
+ return true;
2289
+ };
1895
2290
  if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
1896
2291
  className: cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className),
1897
2292
  children: empty
1898
2293
  });
2294
+ const spacer = (height) => /* @__PURE__ */ jsx("tr", {
2295
+ "aria-hidden": "true",
2296
+ style: { height },
2297
+ children: /* @__PURE__ */ jsx("td", {
2298
+ colSpan: columns.length,
2299
+ className: "p-0"
2300
+ })
2301
+ });
2302
+ const scrollStop = windowed !== void 0 && !onRowClick && !columns.some(isSortable);
1899
2303
  return /* @__PURE__ */ jsx("div", {
1900
- className: cn("w-full overflow-x-auto", className),
2304
+ ref: scrollRef,
2305
+ className: cn("w-full", windowed ? scrollStop && "focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-signal" : "overflow-x-auto", className),
2306
+ style: windowed ? {
2307
+ overflow: "auto",
2308
+ height: windowed.height ?? "100%"
2309
+ } : void 0,
2310
+ onScroll: windowed ? (event) => setScrollTop(event.currentTarget.scrollTop) : void 0,
2311
+ ...scrollStop ? {
2312
+ tabIndex: 0,
2313
+ role: "region",
2314
+ "aria-label": caption ?? "Table"
2315
+ } : {},
1901
2316
  children: /* @__PURE__ */ jsxs("table", {
1902
2317
  className: cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]")),
2318
+ style: windowed ? { tableLayout: "fixed" } : void 0,
2319
+ "aria-rowcount": windowed ? order.length + 1 : void 0,
1903
2320
  children: [
1904
2321
  caption && /* @__PURE__ */ jsx("caption", {
1905
2322
  className: "sr-only",
1906
2323
  children: caption
1907
2324
  }),
1908
- /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", {
1909
- className: "border-b border-line",
1910
- children: columns.map((column) => {
1911
- const sortable = isSortable(column);
1912
- const direction = sortable && sort?.key === column.key ? sort.direction : void 0;
1913
- const SortIcon = SORT_ICONS[direction ?? "none"];
1914
- return /* @__PURE__ */ jsx("th", {
1915
- scope: "col",
1916
- style: column.width ? { width: column.width } : void 0,
1917
- "aria-sort": direction,
1918
- "data-sort": sortable ? direction ?? "none" : void 0,
1919
- className: cn("font-medium text-fg-muted last:pr-0", byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"), column.numeric && "text-right"),
1920
- children: sortable ? /* @__PURE__ */ jsxs("button", {
1921
- type: "button",
1922
- onClick: () => changeSort(column),
1923
- className: cn("-mx-1 flex w-fit max-w-full cursor-pointer items-center gap-1 rounded-control px-1", "font-medium transition-colors hover:text-fg", focusRing, column.numeric ? "ml-auto flex-row-reverse text-right" : "text-left", direction && "text-fg"),
1924
- children: [column.header, /* @__PURE__ */ jsx(SortIcon, {
1925
- "aria-hidden": true,
1926
- className: cn("shrink-0", byDensity(density, "size-3", "size-2.5"), !direction && "text-fg-subtle")
1927
- })]
1928
- }) : column.header
1929
- }, column.key);
2325
+ /* @__PURE__ */ jsx("thead", {
2326
+ ref: headRef,
2327
+ children: /* @__PURE__ */ jsx("tr", {
2328
+ className: "border-b border-line",
2329
+ "aria-rowindex": windowed ? 1 : void 0,
2330
+ children: columns.map((column) => {
2331
+ const sortable = isSortable(column);
2332
+ const direction = sortable && sort?.key === column.key ? sort.direction : void 0;
2333
+ const SortIcon = SORT_ICONS[direction ?? "none"];
2334
+ return /* @__PURE__ */ jsx("th", {
2335
+ scope: "col",
2336
+ style: windowed ? {
2337
+ position: "sticky",
2338
+ top: 0,
2339
+ zIndex: 1,
2340
+ ...column.width ? { width: column.width } : {}
2341
+ } : column.width ? { width: column.width } : void 0,
2342
+ "aria-sort": direction,
2343
+ "data-sort": sortable ? direction ?? "none" : void 0,
2344
+ className: cn("font-medium text-fg-muted last:pr-0", byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"), column.numeric && "text-right", windowed && "bg-surface shadow-[inset_0_-1px_0_var(--line)]"),
2345
+ children: sortable ? /* @__PURE__ */ jsxs("button", {
2346
+ type: "button",
2347
+ onClick: () => changeSort(column),
2348
+ className: cn("-mx-1 flex w-fit max-w-full cursor-pointer items-center gap-1 rounded-control px-1", "font-medium transition-colors hover:text-fg", focusRing, column.numeric ? "ml-auto flex-row-reverse text-right" : "text-left", direction && "text-fg"),
2349
+ children: [column.header, /* @__PURE__ */ jsx(SortIcon, {
2350
+ "aria-hidden": true,
2351
+ className: cn("shrink-0", byDensity(density, "size-3", "size-2.5"), !direction && "text-fg-subtle")
2352
+ })]
2353
+ }) : column.header
2354
+ }, column.key);
2355
+ })
1930
2356
  })
1931
- }) }),
1932
- /* @__PURE__ */ jsx("tbody", { children: order.map((index) => {
1933
- const row = rows[index];
1934
- const active = isRowActive?.(row, index) ?? false;
1935
- return /* @__PURE__ */ jsx("tr", {
1936
- "data-state": active ? "active" : void 0,
1937
- className: cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-raised", active && "bg-signal/10"),
1938
- tabIndex: onRowClick ? 0 : void 0,
1939
- "aria-current": active ? true : void 0,
1940
- onClick: onRowClick ? (event) => onRowClick(row, index, event) : void 0,
1941
- onKeyDown: onRowClick ? (event) => {
1942
- if (event.key === "Enter" || event.key === " ") {
1943
- event.preventDefault();
1944
- onRowClick(row, index, event);
1945
- }
1946
- } : void 0,
1947
- onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
1948
- onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
1949
- children: columns.map((column) => /* @__PURE__ */ jsx("td", {
1950
- className: cn("align-middle text-fg last:pr-0", byDensity(density, "py-2 pr-3", "py-0.5 pr-2"), column.numeric && "text-right font-mono tabular-nums"),
1951
- children: column.cell(row)
1952
- }, column.key))
1953
- }, rowKey(row, index));
1954
- }) })
2357
+ }),
2358
+ /* @__PURE__ */ jsxs("tbody", {
2359
+ ref: bodyRef,
2360
+ children: [
2361
+ range.before > 0 && spacer(range.before),
2362
+ order.slice(range.start, range.end).map((index, offset) => {
2363
+ const row = rows[index];
2364
+ const active = isRowActive?.(row, index) ?? false;
2365
+ const position = range.start + offset;
2366
+ return /* @__PURE__ */ jsx("tr", {
2367
+ "data-position": position,
2368
+ "aria-rowindex": windowed ? position + 2 : void 0,
2369
+ style: windowed ? { height: rowHeight } : void 0,
2370
+ "data-state": active ? "active" : void 0,
2371
+ className: cn(!windowed && "border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-raised", active && "bg-signal/10"),
2372
+ tabIndex: onRowClick ? 0 : void 0,
2373
+ "aria-current": active ? true : void 0,
2374
+ onClick: onRowClick ? (event) => onRowClick(row, index, event) : void 0,
2375
+ onKeyDown: onRowClick ? (event) => {
2376
+ if (event.key === "Enter" || event.key === " ") {
2377
+ event.preventDefault();
2378
+ onRowClick(row, index, event);
2379
+ } else if (event.target === event.currentTarget && moveFocus(event, position)) event.preventDefault();
2380
+ } : void 0,
2381
+ onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
2382
+ onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
2383
+ children: columns.map((column) => /* @__PURE__ */ jsx("td", {
2384
+ className: cn("align-middle text-fg last:pr-0", byDensity(density, "py-2 pr-3", "py-0.5 pr-2"), column.numeric && "text-right font-mono tabular-nums", windowed && "shadow-[inset_0_-1px_0_color-mix(in_oklab,var(--line)_60%,transparent)]"),
2385
+ style: windowed ? {
2386
+ whiteSpace: "nowrap",
2387
+ overflow: "hidden",
2388
+ textOverflow: "ellipsis"
2389
+ } : void 0,
2390
+ children: column.cell(row)
2391
+ }, column.key))
2392
+ }, rowKey(row, index));
2393
+ }),
2394
+ range.after > 0 && spacer(range.after)
2395
+ ]
2396
+ })
1955
2397
  ]
1956
2398
  })
1957
2399
  });
1958
2400
  }
1959
2401
  //#endregion
2402
+ //#region src/components/ui/Textarea.tsx
2403
+ /** Sets a ref of either kind. */
2404
+ function assign(ref, value) {
2405
+ if (typeof ref === "function") ref(value);
2406
+ else if (ref) ref.current = value;
2407
+ }
2408
+ /**
2409
+ * Fits a textarea's height to its content between `minRows` and `maxRows` lines, and scrolls
2410
+ * only past the cap.
2411
+ */
2412
+ function fit(node, minRows, maxRows) {
2413
+ const style = getComputedStyle(node);
2414
+ const line = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.2 || 20;
2415
+ const border = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
2416
+ const chrome = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom) + border;
2417
+ const min = minRows * line + chrome;
2418
+ const max = maxRows === void 0 ? Number.POSITIVE_INFINITY : Math.max(min, maxRows * line + chrome);
2419
+ node.style.height = `${min}px`;
2420
+ const content = node.scrollHeight + border;
2421
+ node.style.height = `${Math.min(Math.max(content, min), max)}px`;
2422
+ node.style.overflowY = content > max + .5 ? "auto" : "hidden";
2423
+ }
2424
+ /**
2425
+ * Multi-line text, on the shared control palette. Takes every `<textarea>` prop, `ref`
2426
+ * included; inside a `Field` it is described by the field's description and error, and
2427
+ * shown invalid while the field has an error.
2428
+ *
2429
+ * With `minRows` or `maxRows` it grows with its content between the two (and scrolls past
2430
+ * `maxRows`); without them it is `rows` tall and the reader can resize it. State is exposed
2431
+ * as `data-invalid`.
2432
+ */
2433
+ function Textarea({ className, "aria-describedby": describedBy, invalid, mono = false, minRows, maxRows, rows, value, onChange, onValueChange, ref, ...rest }) {
2434
+ const field = useFieldDescription(describedBy);
2435
+ const isInvalid = invalid === true || field["aria-invalid"] === true;
2436
+ const grows = minRows !== void 0 || maxRows !== void 0;
2437
+ const floor = Math.max(1, Math.floor(minRows ?? 1));
2438
+ const cap = maxRows === void 0 ? void 0 : Math.max(floor, Math.floor(maxRows));
2439
+ const node = useRef(null);
2440
+ const setNode = useCallback((element) => {
2441
+ node.current = element;
2442
+ assign(ref, element);
2443
+ }, [ref]);
2444
+ useLayoutEffect(() => {
2445
+ if (grows && node.current) fit(node.current, floor, cap);
2446
+ }, [
2447
+ grows,
2448
+ floor,
2449
+ cap,
2450
+ mono,
2451
+ value
2452
+ ]);
2453
+ useLayoutEffect(() => {
2454
+ const element = node.current;
2455
+ if (!grows || !element || typeof ResizeObserver === "undefined") return void 0;
2456
+ let width = element.clientWidth;
2457
+ const observer = new ResizeObserver(() => {
2458
+ if (element.clientWidth === width) return;
2459
+ width = element.clientWidth;
2460
+ fit(element, floor, cap);
2461
+ });
2462
+ observer.observe(element);
2463
+ return () => observer.disconnect();
2464
+ }, [
2465
+ grows,
2466
+ floor,
2467
+ cap
2468
+ ]);
2469
+ const change = (event) => {
2470
+ onChange?.(event);
2471
+ onValueChange?.(event.currentTarget.value);
2472
+ if (grows) fit(event.currentTarget, floor, cap);
2473
+ };
2474
+ return /* @__PURE__ */ jsx("textarea", {
2475
+ ...field,
2476
+ "aria-invalid": isInvalid || void 0,
2477
+ "data-invalid": isInvalid ? "" : void 0,
2478
+ ...rest,
2479
+ ...value === void 0 ? {} : { value },
2480
+ ref: setNode,
2481
+ rows: grows ? floor : rows,
2482
+ onChange: change,
2483
+ className: cn(controlClasses, "py-1.5", grows && "resize-none", mono && "font-mono", "data-invalid:border-defect data-invalid:hover:border-defect", className)
2484
+ });
2485
+ }
2486
+ //#endregion
1960
2487
  //#region src/components/ui/Toggle.tsx
1961
2488
  /**
1962
2489
  * A setting: changes how the thing behaves from now on, and reads as one ("show the mask").
@@ -2325,6 +2852,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
2325
2852
  });
2326
2853
  }
2327
2854
  //#endregion
2328
- 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, sortRows, toast, toneColor, useControlHeight, useDensity };
2855
+ export { Badge, Breakdown, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, DropdownMenu, Empty, ErrorBox, Field, HighlightedText, InfoHint, Input, Kbd, Listbox, MenuCheckboxItem, MenuItem, MenuLabel, MenuSeparator, NumberInput, PageHeader, Panel, Popover, PopoverClose, PoseInput, Progress, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, breakdownText, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, findSpans, focusRing, focusRingInset, formatNumber, initTheme, inputClasses, parseNumber, progressText, readThemeChoice, resolveTheme, setThemeChoice, sortRows, toast, toneColor, useControlHeight, useDensity };
2329
2856
 
2330
2857
  //# sourceMappingURL=index.js.map