@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.
Files changed (39) hide show
  1. package/README.md +8 -5
  2. package/dist/index.d.ts +255 -12
  3. package/dist/index.js +599 -44
  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 +3 -8
  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 +331 -85
  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 +11 -41
  33. package/src/components/ui/listboxModel.ts +1 -6
  34. package/src/components/ui/tableSort.ts +114 -0
  35. package/src/components/ui/tableWindow.ts +96 -0
  36. package/src/fonts/fonts.css +1 -1
  37. package/src/index.ts +1 -8
  38. package/src/styles.css +17 -7
  39. package/src/theme.ts +2 -7
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { AlertTriangle, 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";
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, 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";
@@ -767,6 +767,9 @@ function SkeletonRows({ rows = 3, className }) {
767
767
  * A `role="progressbar"` with `aria-valuenow` in percent. Its accessible name is
768
768
  * `aria-label` if given, else `label`, else "Progress" — pass one that names the job when
769
769
  * a screen shows more than one bar.
770
+ *
771
+ * @deprecated Use `Progress`: a value out of a `max` with a "412 of 2,556" value text, an
772
+ * indeterminate state, and a track that follows the density.
770
773
  */
771
774
  function ProgressBar({ fraction, label, "aria-label": ariaLabel, className }) {
772
775
  const percent = Math.round(Math.min(Math.max(fraction, 0), 1) * 100);
@@ -885,6 +888,131 @@ function Field({ label, description, error, required = false, annotation, as = "
885
888
  });
886
889
  }
887
890
  //#endregion
891
+ //#region src/components/ui/highlightSpans.ts
892
+ /** The kinds in the order their styles stack: a later one wins where two would clash. */
893
+ const HIGHLIGHT_KINDS = [
894
+ "tag",
895
+ "protected-term",
896
+ "placeholder",
897
+ "mismatch"
898
+ ];
899
+ /**
900
+ * Cuts `text` into runs that are each covered by one fixed set of spans. Spans are clamped
901
+ * to the text; empty, inverted and non-finite ones are dropped. Overlapping spans are kept:
902
+ * the overlap is a run covered by both.
903
+ *
904
+ * @param text - The string, shown as text.
905
+ * @param spans - The highlights, in any order.
906
+ * @returns The runs, in order, together covering the whole text.
907
+ */
908
+ function segmentText(text, spans) {
909
+ const length = text.length;
910
+ const clamp = (offset) => Math.floor(Math.max(0, Math.min(length, offset)));
911
+ const valid = spans.map((span) => ({
912
+ span,
913
+ start: clamp(span.start),
914
+ end: clamp(span.end)
915
+ })).filter(({ start, end }) => Number.isFinite(start) && Number.isFinite(end) && end > start);
916
+ const cuts = /* @__PURE__ */ new Set([0, length]);
917
+ for (const { start, end } of valid) {
918
+ cuts.add(start);
919
+ cuts.add(end);
920
+ }
921
+ const points = [...cuts].sort((a, b) => a - b);
922
+ const segments = [];
923
+ for (let i = 0; i + 1 < points.length; i++) {
924
+ const start = points[i];
925
+ const end = points[i + 1];
926
+ const covering = valid.filter((v) => v.start <= start && v.end >= end).map((v) => v.span);
927
+ segments.push({
928
+ text: text.slice(start, end),
929
+ start,
930
+ end,
931
+ spans: covering
932
+ });
933
+ }
934
+ return segments;
935
+ }
936
+ /**
937
+ * Spans for every match of every rule: `%1`, `<b>`, a glossary term. Empty matches are
938
+ * skipped. The result is sorted by start, then longest first.
939
+ *
940
+ * @param text - The string to search.
941
+ * @param rules - Each pattern and the kind its matches get.
942
+ * @returns The spans, ready for `HighlightedText`.
943
+ *
944
+ * @example
945
+ * ```ts
946
+ * findSpans("Open %1 in <b>ParamsManager</b>", [
947
+ * { pattern: /%\d+/, kind: "placeholder" },
948
+ * { pattern: /<\/?[a-z][^>]*>/i, kind: "tag" },
949
+ * { pattern: /\bParamsManager\b/, kind: "protected-term" },
950
+ * ]);
951
+ * ```
952
+ */
953
+ function findSpans(text, rules) {
954
+ const spans = [];
955
+ for (const rule of rules) {
956
+ const flags = rule.pattern.flags.includes("g") ? rule.pattern.flags : `${rule.pattern.flags}g`;
957
+ for (const match of text.matchAll(new RegExp(rule.pattern.source, flags))) {
958
+ if (match[0] === "") continue;
959
+ const start = match.index;
960
+ spans.push({
961
+ start,
962
+ end: start + match[0].length,
963
+ kind: rule.kind,
964
+ ...rule.label === void 0 ? {} : { label: rule.label }
965
+ });
966
+ }
967
+ }
968
+ return spans.sort((a, b) => a.start - b.start || b.end - a.end);
969
+ }
970
+ //#endregion
971
+ //#region src/components/ui/HighlightedText.tsx
972
+ /** What a screen reader hears after a span of each kind, by default. */
973
+ const LABELS = {
974
+ placeholder: "placeholder",
975
+ tag: "markup",
976
+ "protected-term": "protected term",
977
+ mismatch: "mismatch"
978
+ };
979
+ /** The look of each kind; listed in `HIGHLIGHT_KINDS` order, so a later kind wins a clash. */
980
+ const STYLES = {
981
+ tag: "rounded-sm bg-raised px-px font-mono text-fg-muted",
982
+ "protected-term": "underline decoration-fg-subtle decoration-dotted decoration-1 underline-offset-[3px]",
983
+ placeholder: "rounded-sm bg-raised px-px font-mono text-fg ring-1 ring-line-strong ring-inset",
984
+ mismatch: "bg-defect/12 text-fg underline decoration-defect decoration-wavy decoration-1 underline-offset-[3px]"
985
+ };
986
+ /**
987
+ * A string with highlighted spans: placeholders (`%1`, `{count}`), markup tags (`<b>`),
988
+ * protected terms (`ParamsManager`) and mismatches, each drawn in its own shape. The caller
989
+ * supplies the spans, or finds them with `findSpans`.
990
+ *
991
+ * The text is rendered as text, never as HTML. Each span is a `<mark>` with `data-kind` (the
992
+ * kinds covering it, space-separated), followed by its label for screen readers ("%1,
993
+ * placeholder"), which a copy of the text leaves out.
994
+ */
995
+ function HighlightedText({ text, spans = [], labels, className, ...rest }) {
996
+ const segments = segmentText(text, spans);
997
+ return /* @__PURE__ */ jsx("span", {
998
+ ...rest,
999
+ className: cn("break-words whitespace-pre-wrap", className),
1000
+ children: segments.map((segment) => {
1001
+ if (segment.spans.length === 0) return segment.text;
1002
+ const kinds = HIGHLIGHT_KINDS.filter((kind) => segment.spans.some((span) => span.kind === kind));
1003
+ 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 !== "");
1004
+ return /* @__PURE__ */ jsxs("mark", {
1005
+ "data-kind": kinds.join(" "),
1006
+ className: cn("bg-transparent text-inherit", ...kinds.map((kind) => STYLES[kind])),
1007
+ children: [segment.text, said.length > 0 && /* @__PURE__ */ jsx("span", {
1008
+ className: "sr-only select-none",
1009
+ children: `, ${[...new Set(said)].join(", ")}`
1010
+ })]
1011
+ }, segment.start);
1012
+ })
1013
+ });
1014
+ }
1015
+ //#endregion
888
1016
  //#region src/components/ui/numberText.ts
889
1017
  /**
890
1018
  * A number at a fixed number of decimals, without a negative zero (`-0.000` reads as a sign
@@ -1074,17 +1202,6 @@ function UnitNumberInput({ unit, style, "aria-describedby": describedBy, ...rest
1074
1202
  })]
1075
1203
  });
1076
1204
  }
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
1205
  //#endregion
1089
1206
  //#region src/components/ui/listboxModel.ts
1090
1207
  /**
@@ -1361,6 +1478,52 @@ function ReadoutStrip({ items, className }) {
1361
1478
  });
1362
1479
  }
1363
1480
  //#endregion
1481
+ //#region src/components/ui/Progress.tsx
1482
+ /** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
1483
+ const count = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
1484
+ /**
1485
+ * The accessible value text of a progress: `"412 of 2,556"`, or `null` while indeterminate.
1486
+ *
1487
+ * @param value - Done so far; `null` or `undefined` for an unknown amount.
1488
+ * @param max - The whole. Defaults to 100.
1489
+ * @returns The text, with the value clamped to `[0, max]`.
1490
+ */
1491
+ function progressText(value, max = 100) {
1492
+ if (value === null || value === void 0 || !Number.isFinite(value)) return null;
1493
+ const whole = max > 0 ? max : 100;
1494
+ return `${count.format(Math.min(Math.max(value, 0), whole))} of ${count.format(whole)}`;
1495
+ }
1496
+ /**
1497
+ * A progress bar: determinate when it has a `value` (a `max`, 100 by default), indeterminate
1498
+ * without one. Name it with `aria-label` or `aria-labelledby`.
1499
+ *
1500
+ * A `progressbar` with `aria-valuenow`, `aria-valuemax` and `aria-valuetext` ("412 of 2,556",
1501
+ * or your `valueText`); indeterminate, it has none of them, as the ARIA pattern asks.
1502
+ * The track follows the density in force. State is exposed as `data-state`
1503
+ * (`"indeterminate"`, `"loading"` or `"complete"`). With reduced motion the indeterminate bar
1504
+ * holds still.
1505
+ */
1506
+ function Progress({ value, max = 100, valueText, className, ...rest }) {
1507
+ const density = useDensity();
1508
+ const whole = max > 0 ? max : 100;
1509
+ const text = progressText(value, whole);
1510
+ const done = text === null ? null : Math.min(Math.max(value, 0), whole);
1511
+ return /* @__PURE__ */ jsx("div", {
1512
+ role: "progressbar",
1513
+ "aria-valuemin": done === null ? void 0 : 0,
1514
+ "aria-valuemax": done === null ? void 0 : whole,
1515
+ "aria-valuenow": done ?? void 0,
1516
+ "aria-valuetext": done === null ? void 0 : valueText ?? text ?? void 0,
1517
+ "data-state": done === null ? "indeterminate" : done >= whole ? "complete" : "loading",
1518
+ ...rest,
1519
+ className: cn("relative w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className),
1520
+ 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", {
1521
+ className: "h-full rounded-full bg-signal transition-[width] duration-300",
1522
+ style: { width: `${done / whole * 100}%` }
1523
+ })
1524
+ });
1525
+ }
1526
+ //#endregion
1364
1527
  //#region src/components/ui/Popover.tsx
1365
1528
  /**
1366
1529
  * Floating content anchored to `trigger`, on the `overlay` surface with a shadow (floating
@@ -1775,62 +1938,454 @@ function Slider({ value, onValueChange, onValueCommit, min = 0, max = 1, step =
1775
1938
  });
1776
1939
  }
1777
1940
  //#endregion
1941
+ //#region src/components/ui/tableSort.ts
1942
+ /** Strings by a fixed English collation that reads digit runs as numbers: "frame 9" before "frame 10". */
1943
+ const collator = new Intl.Collator("en", { numeric: true });
1944
+ /**
1945
+ * Whether a column's header sorts the table: `sortable` if given, otherwise whether the column
1946
+ * says how to order (`sortValue` or `compare`).
1947
+ */
1948
+ function isSortable(column) {
1949
+ return column.sortable ?? (column.sortValue !== void 0 || column.compare !== void 0);
1950
+ }
1951
+ /** The sort after a click on `column`'s header: its first direction, then the other, then none. */
1952
+ function nextSort(current, column) {
1953
+ const first = column.firstSort ?? "ascending";
1954
+ if (current?.key !== column.key) return {
1955
+ key: column.key,
1956
+ direction: first
1957
+ };
1958
+ if (current.direction !== first) return null;
1959
+ return {
1960
+ key: column.key,
1961
+ direction: first === "ascending" ? "descending" : "ascending"
1962
+ };
1963
+ }
1964
+ /** A value with no place in the order: `null`, `undefined` or `NaN`. */
1965
+ function missing(value) {
1966
+ return value === null || value === void 0 || Number.isNaN(value);
1967
+ }
1968
+ /**
1969
+ * Two present values in ascending order: numbers by value (compared, not subtracted, so two
1970
+ * infinities tie), strings by the collator, and every number before every string.
1971
+ */
1972
+ function compareValues(a, b) {
1973
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
1974
+ if (typeof a === "string" && typeof b === "string") return collator.compare(a, b);
1975
+ return typeof a === "number" ? -1 : 1;
1976
+ }
1977
+ /**
1978
+ * The display order of `rows` under `sort`, as indices into `rows`. `Table` renders through it
1979
+ * so each row keeps its original index for the row callbacks.
1980
+ */
1981
+ function sortOrder(rows, columns, sort) {
1982
+ const order = rows.map((_, index) => index);
1983
+ if (!sort) return order;
1984
+ const column = columns.find((candidate) => candidate.key === sort.key);
1985
+ if (!column || !isSortable(column)) return order;
1986
+ const sign = sort.direction === "descending" ? -1 : 1;
1987
+ const { compare, sortValue } = column;
1988
+ if (compare) return order.sort((a, b) => sign * compare(rows[a], rows[b]) || a - b);
1989
+ if (!sortValue) return order;
1990
+ const values = rows.map((row) => sortValue(row));
1991
+ return order.sort((a, b) => {
1992
+ const left = values[a];
1993
+ const right = values[b];
1994
+ const leftMissing = missing(left);
1995
+ const rightMissing = missing(right);
1996
+ if (leftMissing || rightMissing) return leftMissing === rightMissing ? a - b : leftMissing ? 1 : -1;
1997
+ return sign * compareValues(left, right) || a - b;
1998
+ });
1999
+ }
2000
+ /**
2001
+ * The rows in the order a `Table` with these columns shows them under `sort`.
2002
+ *
2003
+ * The sort is stable in both directions: rows that compare equal keep their order in `rows`.
2004
+ * With the column's `compare`, that function decides (reversed for `"descending"`). With its
2005
+ * `sortValue`, numbers compare by value and strings by a fixed English collation that reads
2006
+ * digit runs as numbers ("frame 9" before "frame 10"), the same on a server as in any
2007
+ * browser; numbers come before strings; and `null`, `undefined` and `NaN` go last in both
2008
+ * directions. A `null` sort, a `key` that names no column, and a column that is not sortable or
2009
+ * has neither function leave the order as given.
2010
+ *
2011
+ * @param rows - The rows, in their own order. Not modified.
2012
+ * @param columns - The table's columns; only `key`, `sortable`, `sortValue` and `compare` are read.
2013
+ * @param sort - The column and direction, or `null` for the rows' own order.
2014
+ * @returns A new array of the same rows, sorted.
2015
+ */
2016
+ function sortRows(rows, columns, sort) {
2017
+ return sortOrder(rows, columns, sort).map((index) => rows[index]);
2018
+ }
2019
+ //#endregion
2020
+ //#region src/components/ui/tableWindow.ts
2021
+ /**
2022
+ * The rows that intersect the visible part of the body, plus `overscan` on each side.
2023
+ * Degenerate input (no rows, a row height ≤ 0, NaN) gives an empty window.
2024
+ */
2025
+ function rowWindow({ rowCount, rowHeight, scrollTop, viewportHeight, headerHeight, overscan }) {
2026
+ const count = Number.isFinite(rowCount) ? Math.max(0, Math.floor(rowCount)) : 0;
2027
+ if (count === 0 || !(rowHeight > 0)) return {
2028
+ start: 0,
2029
+ end: 0,
2030
+ before: 0,
2031
+ after: 0
2032
+ };
2033
+ const extra = Math.max(0, Math.floor(overscan) || 0);
2034
+ const top = Math.max(0, scrollTop || 0);
2035
+ const bottom = Math.max(top, top + Math.max(0, viewportHeight || 0) - Math.max(0, headerHeight || 0));
2036
+ const first = Math.min(count, Math.floor(top / rowHeight));
2037
+ const last = Math.min(count, Math.max(first + 1, Math.ceil(bottom / rowHeight)));
2038
+ const start = Math.max(0, first - extra);
2039
+ const end = Math.min(count, last + extra);
2040
+ return {
2041
+ start,
2042
+ end,
2043
+ before: start * rowHeight,
2044
+ after: (count - end) * rowHeight
2045
+ };
2046
+ }
2047
+ /**
2048
+ * The `scrollTop` that shows the row at `position` in full below the sticky header, moving
2049
+ * as little as possible: unchanged when it is already in view, else aligned to the nearer
2050
+ * edge. A viewport shorter than one row aligns the row's top.
2051
+ */
2052
+ function scrollTopFor(position, { rowHeight, scrollTop, viewportHeight, headerHeight }) {
2053
+ const rowTop = position * rowHeight;
2054
+ const visible = viewportHeight - headerHeight;
2055
+ if (rowTop < scrollTop || visible < rowHeight) return rowTop;
2056
+ if (rowTop + rowHeight > scrollTop + visible) return rowTop + rowHeight - visible;
2057
+ return scrollTop;
2058
+ }
2059
+ /**
2060
+ * The position a navigation key moves to from `position`, among `rowCount` rows, `page` rows
2061
+ * to a screen; `null` for a key that does not navigate.
2062
+ */
2063
+ function navigate(key, position, rowCount, page) {
2064
+ const last = rowCount - 1;
2065
+ if (last < 0) return null;
2066
+ const step = Math.max(1, Math.floor(page));
2067
+ const clamp = (p) => Math.min(last, Math.max(0, p));
2068
+ switch (key) {
2069
+ case "ArrowDown": return clamp(position + 1);
2070
+ case "ArrowUp": return clamp(position - 1);
2071
+ case "PageDown": return clamp(position + step);
2072
+ case "PageUp": return clamp(position - step);
2073
+ case "Home": return 0;
2074
+ case "End": return last;
2075
+ default: return null;
2076
+ }
2077
+ }
2078
+ //#endregion
1778
2079
  //#region src/components/ui/Table.tsx
2080
+ /** Rows per screen before a windowed table has measured itself (a server render, the first paint). */
2081
+ const INITIAL_ROWS = 20;
2082
+ /** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
2083
+ const SORT_ICONS = {
2084
+ ascending: ArrowUp,
2085
+ descending: ArrowDown,
2086
+ none: ArrowUpDown
2087
+ };
1779
2088
  /**
1780
2089
  * A table of rows, with numeric columns set in mono and right-aligned.
1781
2090
  *
1782
2091
  * With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
1783
2092
  * their table semantics. The active row carries `aria-current` and `data-state="active"`;
1784
2093
  * padding follows the density in force. With no rows it renders the `empty` message instead.
2094
+ *
2095
+ * A column with a `sortValue` or a `compare` gets a header button: each click moves its sort
2096
+ * from the column's `firstSort` direction to the other one, then back to the rows' own order.
2097
+ * The sorted header carries `aria-sort` and `data-sort` (`"ascending"` or `"descending"`);
2098
+ * other sortable headers carry `data-sort="none"`. The sort is kept by the table (from
2099
+ * `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
2100
+ * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
2101
+ * whatever the order on screen.
2102
+ *
2103
+ * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End.
2104
+ * With `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
2105
+ * rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
1785
2106
  */
1786
- function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover }) {
2107
+ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false, windowed }) {
1787
2108
  const density = useDensity();
2109
+ const [ownSort, setOwnSort] = useState(defaultSort);
2110
+ const controlled = controlledSort !== void 0;
2111
+ const sort = controlled ? controlledSort : ownSort;
2112
+ const order = useMemo(() => manualSort ? rows.map((_, index) => index) : sortOrder(rows, columns, sort), [
2113
+ rows,
2114
+ columns,
2115
+ sort,
2116
+ manualSort
2117
+ ]);
2118
+ const changeSort = (column) => {
2119
+ const next = nextSort(sort, column);
2120
+ if (!controlled) setOwnSort(next);
2121
+ onSortChange?.(next);
2122
+ };
2123
+ const rowHeight = windowed?.rowHeight ?? 0;
2124
+ const scrollRef = useRef(null);
2125
+ const headRef = useRef(null);
2126
+ const bodyRef = useRef(null);
2127
+ const pendingFocus = useRef(null);
2128
+ const [scrollTop, setScrollTop] = useState(0);
2129
+ const [measured, setMeasured] = useState(null);
2130
+ const viewportHeight = measured?.viewport ?? windowed?.height ?? INITIAL_ROWS * rowHeight;
2131
+ const headerHeight = measured?.header ?? 0;
2132
+ const range = windowed ? rowWindow({
2133
+ rowCount: order.length,
2134
+ rowHeight,
2135
+ scrollTop,
2136
+ viewportHeight,
2137
+ headerHeight,
2138
+ overscan: windowed.overscan ?? 8
2139
+ }) : {
2140
+ start: 0,
2141
+ end: order.length,
2142
+ before: 0,
2143
+ after: 0
2144
+ };
2145
+ const isWindowed = windowed !== void 0;
2146
+ useLayoutEffect(() => {
2147
+ const scroller = scrollRef.current;
2148
+ if (!isWindowed || !scroller || typeof ResizeObserver === "undefined") return;
2149
+ const measure = () => {
2150
+ const next = {
2151
+ viewport: scroller.clientHeight,
2152
+ header: headRef.current?.offsetHeight ?? 0
2153
+ };
2154
+ setMeasured((last) => last?.viewport === next.viewport && last.header === next.header ? last : next);
2155
+ };
2156
+ measure();
2157
+ const observer = new ResizeObserver(measure);
2158
+ observer.observe(scroller);
2159
+ return () => observer.disconnect();
2160
+ }, [isWindowed]);
2161
+ useLayoutEffect(() => {
2162
+ const target = pendingFocus.current;
2163
+ if (target === null) return;
2164
+ const row = bodyRef.current?.querySelector(`tr[data-position="${target}"]`);
2165
+ if (!row) return;
2166
+ pendingFocus.current = null;
2167
+ row.focus({ preventScroll: isWindowed });
2168
+ });
2169
+ /** Moves focus from the row at `position` for a navigation key; `true` when the key was one. */
2170
+ const moveFocus = (key, position) => {
2171
+ const page = windowed ? Math.floor((viewportHeight - headerHeight) / rowHeight) : 10;
2172
+ const target = navigate(key, position, order.length, page);
2173
+ if (target === null) return false;
2174
+ pendingFocus.current = target;
2175
+ const scroller = scrollRef.current;
2176
+ if (windowed && scroller) {
2177
+ scroller.scrollTop = scrollTopFor(target, {
2178
+ rowHeight,
2179
+ scrollTop: scroller.scrollTop,
2180
+ viewportHeight,
2181
+ headerHeight
2182
+ });
2183
+ setScrollTop(scroller.scrollTop);
2184
+ }
2185
+ const row = bodyRef.current?.querySelector(`tr[data-position="${target}"]`);
2186
+ if (row && !windowed) {
2187
+ pendingFocus.current = null;
2188
+ row.focus();
2189
+ }
2190
+ return true;
2191
+ };
1788
2192
  if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
1789
2193
  className: cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className),
1790
2194
  children: empty
1791
2195
  });
2196
+ const spacer = (height) => /* @__PURE__ */ jsx("tr", {
2197
+ "aria-hidden": "true",
2198
+ style: { height },
2199
+ children: /* @__PURE__ */ jsx("td", {
2200
+ colSpan: columns.length,
2201
+ className: "p-0"
2202
+ })
2203
+ });
2204
+ const scrollStop = windowed !== void 0 && !onRowClick && !columns.some(isSortable);
1792
2205
  return /* @__PURE__ */ jsx("div", {
1793
- className: cn("w-full overflow-x-auto", className),
2206
+ ref: scrollRef,
2207
+ className: cn("w-full", windowed ? scrollStop && "focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-signal" : "overflow-x-auto", className),
2208
+ style: windowed ? {
2209
+ overflow: "auto",
2210
+ height: windowed.height ?? "100%"
2211
+ } : void 0,
2212
+ onScroll: windowed ? (event) => setScrollTop(event.currentTarget.scrollTop) : void 0,
2213
+ ...scrollStop ? {
2214
+ tabIndex: 0,
2215
+ role: "region",
2216
+ "aria-label": caption ?? "Table"
2217
+ } : {},
1794
2218
  children: /* @__PURE__ */ jsxs("table", {
1795
2219
  className: cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]")),
2220
+ style: windowed ? { tableLayout: "fixed" } : void 0,
2221
+ "aria-rowcount": windowed ? order.length + 1 : void 0,
1796
2222
  children: [
1797
2223
  caption && /* @__PURE__ */ jsx("caption", {
1798
2224
  className: "sr-only",
1799
2225
  children: caption
1800
2226
  }),
1801
- /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", {
1802
- className: "border-b border-line",
1803
- children: columns.map((column) => /* @__PURE__ */ jsx("th", {
1804
- scope: "col",
1805
- style: column.width ? { width: column.width } : void 0,
1806
- className: cn("font-medium text-fg-muted", byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"), column.numeric && "text-right"),
1807
- children: column.header
1808
- }, column.key))
1809
- }) }),
1810
- /* @__PURE__ */ jsx("tbody", { children: rows.map((row, index) => /* @__PURE__ */ jsx("tr", {
1811
- "data-state": isRowActive?.(row, index) ? "active" : void 0,
1812
- className: cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-raised", isRowActive?.(row, index) && "bg-signal/10"),
1813
- tabIndex: onRowClick ? 0 : void 0,
1814
- "aria-current": isRowActive?.(row, index) ? true : void 0,
1815
- onClick: onRowClick ? (event) => onRowClick(row, index, event) : void 0,
1816
- onKeyDown: onRowClick ? (event) => {
1817
- if (event.key === "Enter" || event.key === " ") {
1818
- event.preventDefault();
1819
- onRowClick(row, index, event);
1820
- }
1821
- } : void 0,
1822
- onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
1823
- onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
1824
- children: columns.map((column) => /* @__PURE__ */ jsx("td", {
1825
- 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"),
1826
- children: column.cell(row)
1827
- }, column.key))
1828
- }, rowKey(row, index))) })
2227
+ /* @__PURE__ */ jsx("thead", {
2228
+ ref: headRef,
2229
+ children: /* @__PURE__ */ jsx("tr", {
2230
+ className: "border-b border-line",
2231
+ "aria-rowindex": windowed ? 1 : void 0,
2232
+ children: columns.map((column) => {
2233
+ const sortable = isSortable(column);
2234
+ const direction = sortable && sort?.key === column.key ? sort.direction : void 0;
2235
+ const SortIcon = SORT_ICONS[direction ?? "none"];
2236
+ return /* @__PURE__ */ jsx("th", {
2237
+ scope: "col",
2238
+ style: windowed ? {
2239
+ position: "sticky",
2240
+ top: 0,
2241
+ zIndex: 1,
2242
+ ...column.width ? { width: column.width } : {}
2243
+ } : column.width ? { width: column.width } : void 0,
2244
+ "aria-sort": direction,
2245
+ "data-sort": sortable ? direction ?? "none" : void 0,
2246
+ 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)]"),
2247
+ children: sortable ? /* @__PURE__ */ jsxs("button", {
2248
+ type: "button",
2249
+ onClick: () => changeSort(column),
2250
+ 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"),
2251
+ children: [column.header, /* @__PURE__ */ jsx(SortIcon, {
2252
+ "aria-hidden": true,
2253
+ className: cn("shrink-0", byDensity(density, "size-3", "size-2.5"), !direction && "text-fg-subtle")
2254
+ })]
2255
+ }) : column.header
2256
+ }, column.key);
2257
+ })
2258
+ })
2259
+ }),
2260
+ /* @__PURE__ */ jsxs("tbody", {
2261
+ ref: bodyRef,
2262
+ children: [
2263
+ range.before > 0 && spacer(range.before),
2264
+ order.slice(range.start, range.end).map((index, offset) => {
2265
+ const row = rows[index];
2266
+ const active = isRowActive?.(row, index) ?? false;
2267
+ const position = range.start + offset;
2268
+ return /* @__PURE__ */ jsx("tr", {
2269
+ "data-position": position,
2270
+ "aria-rowindex": windowed ? position + 2 : void 0,
2271
+ style: windowed ? { height: rowHeight } : void 0,
2272
+ "data-state": active ? "active" : void 0,
2273
+ className: cn(!windowed && "border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-raised", active && "bg-signal/10"),
2274
+ tabIndex: onRowClick ? 0 : void 0,
2275
+ "aria-current": active ? true : void 0,
2276
+ onClick: onRowClick ? (event) => onRowClick(row, index, event) : void 0,
2277
+ onKeyDown: onRowClick ? (event) => {
2278
+ if (event.key === "Enter" || event.key === " ") {
2279
+ event.preventDefault();
2280
+ onRowClick(row, index, event);
2281
+ } else if (event.target === event.currentTarget && moveFocus(event.key, position)) event.preventDefault();
2282
+ } : void 0,
2283
+ onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
2284
+ onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
2285
+ children: columns.map((column) => /* @__PURE__ */ jsx("td", {
2286
+ 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)]"),
2287
+ style: windowed ? {
2288
+ whiteSpace: "nowrap",
2289
+ overflow: "hidden",
2290
+ textOverflow: "ellipsis"
2291
+ } : void 0,
2292
+ children: column.cell(row)
2293
+ }, column.key))
2294
+ }, rowKey(row, index));
2295
+ }),
2296
+ range.after > 0 && spacer(range.after)
2297
+ ]
2298
+ })
1829
2299
  ]
1830
2300
  })
1831
2301
  });
1832
2302
  }
1833
2303
  //#endregion
2304
+ //#region src/components/ui/Textarea.tsx
2305
+ /** Sets a ref of either kind. */
2306
+ function assign(ref, value) {
2307
+ if (typeof ref === "function") ref(value);
2308
+ else if (ref) ref.current = value;
2309
+ }
2310
+ /**
2311
+ * Fits a textarea's height to its content between `minRows` and `maxRows` lines, and scrolls
2312
+ * only past the cap.
2313
+ */
2314
+ function fit(node, minRows, maxRows) {
2315
+ const style = getComputedStyle(node);
2316
+ const line = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.2 || 20;
2317
+ const border = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
2318
+ const chrome = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom) + border;
2319
+ const min = minRows * line + chrome;
2320
+ const max = maxRows === void 0 ? Number.POSITIVE_INFINITY : Math.max(min, maxRows * line + chrome);
2321
+ node.style.height = `${min}px`;
2322
+ const content = node.scrollHeight + border;
2323
+ node.style.height = `${Math.min(Math.max(content, min), max)}px`;
2324
+ node.style.overflowY = content > max + .5 ? "auto" : "hidden";
2325
+ }
2326
+ /**
2327
+ * Multi-line text, on the shared control palette. Takes every `<textarea>` prop, `ref`
2328
+ * included; inside a `Field` it is described by the field's description and error, and
2329
+ * shown invalid while the field has an error.
2330
+ *
2331
+ * With `minRows` or `maxRows` it grows with its content between the two (and scrolls past
2332
+ * `maxRows`); without them it is `rows` tall and the reader can resize it. State is exposed
2333
+ * as `data-invalid`.
2334
+ */
2335
+ function Textarea({ className, "aria-describedby": describedBy, invalid, mono = false, minRows, maxRows, rows, value, onChange, onValueChange, ref, ...rest }) {
2336
+ const field = useFieldDescription(describedBy);
2337
+ const isInvalid = invalid === true || field["aria-invalid"] === true;
2338
+ const grows = minRows !== void 0 || maxRows !== void 0;
2339
+ const floor = Math.max(1, Math.floor(minRows ?? 1));
2340
+ const cap = maxRows === void 0 ? void 0 : Math.max(floor, Math.floor(maxRows));
2341
+ const node = useRef(null);
2342
+ const setNode = useCallback((element) => {
2343
+ node.current = element;
2344
+ assign(ref, element);
2345
+ }, [ref]);
2346
+ useLayoutEffect(() => {
2347
+ if (grows && node.current) fit(node.current, floor, cap);
2348
+ }, [
2349
+ grows,
2350
+ floor,
2351
+ cap,
2352
+ mono,
2353
+ value
2354
+ ]);
2355
+ useLayoutEffect(() => {
2356
+ const element = node.current;
2357
+ if (!grows || !element || typeof ResizeObserver === "undefined") return void 0;
2358
+ let width = element.clientWidth;
2359
+ const observer = new ResizeObserver(() => {
2360
+ if (element.clientWidth === width) return;
2361
+ width = element.clientWidth;
2362
+ fit(element, floor, cap);
2363
+ });
2364
+ observer.observe(element);
2365
+ return () => observer.disconnect();
2366
+ }, [
2367
+ grows,
2368
+ floor,
2369
+ cap
2370
+ ]);
2371
+ const change = (event) => {
2372
+ onChange?.(event);
2373
+ onValueChange?.(event.currentTarget.value);
2374
+ if (grows) fit(event.currentTarget, floor, cap);
2375
+ };
2376
+ return /* @__PURE__ */ jsx("textarea", {
2377
+ ...field,
2378
+ "aria-invalid": isInvalid || void 0,
2379
+ "data-invalid": isInvalid ? "" : void 0,
2380
+ ...rest,
2381
+ ...value === void 0 ? {} : { value },
2382
+ ref: setNode,
2383
+ rows: grows ? floor : rows,
2384
+ onChange: change,
2385
+ className: cn(controlClasses, "py-1.5", grows && "resize-none", mono && "font-mono", "data-invalid:border-defect data-invalid:hover:border-defect", className)
2386
+ });
2387
+ }
2388
+ //#endregion
1834
2389
  //#region src/components/ui/Toggle.tsx
1835
2390
  /**
1836
2391
  * A setting: changes how the thing behaves from now on, and reads as one ("show the mask").
@@ -2199,6 +2754,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
2199
2754
  });
2200
2755
  }
2201
2756
  //#endregion
2202
- export { Badge, Button, ButtonLink, Callout, Checkbox, ConfirmDialog, CountRun, DEFAULT_THEME_STORAGE_KEY, DensityProvider, Dialog, DialogClose, Disclosure, DropdownMenu, Empty, ErrorBox, Field, InfoHint, Input, Kbd, Listbox, MenuCheckboxItem, MenuItem, MenuLabel, MenuSeparator, NumberInput, PageHeader, Panel, Popover, PopoverClose, PoseInput, ProgressBar, ReadoutStrip, Section, SegmentedControl, Select, Skeleton, SkeletonRows, Slider, StatusDot, Switch, Table, Tabs, Textarea, ThemeToggle, Toaster, ToggleChip, Tooltip, TooltipProvider, VectorInput, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, focusRing, focusRingInset, formatNumber, initTheme, inputClasses, parseNumber, readThemeChoice, resolveTheme, setThemeChoice, toast, toneColor, useControlHeight, useDensity };
2757
+ export { Badge, 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, buttonClasses, byDensity, cn, controlClasses, createToastStore, defaultToastStore, findSpans, focusRing, focusRingInset, formatNumber, initTheme, inputClasses, parseNumber, progressText, readThemeChoice, resolveTheme, setThemeChoice, sortRows, toast, toneColor, useControlHeight, useDensity };
2203
2758
 
2204
2759
  //# sourceMappingURL=index.js.map