@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
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";
@@ -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
@@ -1854,7 +2017,68 @@ function sortRows(rows, columns, sort) {
1854
2017
  return sortOrder(rows, columns, sort).map((index) => rows[index]);
1855
2018
  }
1856
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
1857
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;
1858
2082
  /** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
1859
2083
  const SORT_ICONS = {
1860
2084
  ascending: ArrowUp,
@@ -1875,8 +2099,12 @@ const SORT_ICONS = {
1875
2099
  * `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
1876
2100
  * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
1877
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.
1878
2106
  */
1879
- function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false }) {
2107
+ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false, windowed }) {
1880
2108
  const density = useDensity();
1881
2109
  const [ownSort, setOwnSort] = useState(defaultSort);
1882
2110
  const controlled = controlledSort !== void 0;
@@ -1892,71 +2120,272 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
1892
2120
  if (!controlled) setOwnSort(next);
1893
2121
  onSortChange?.(next);
1894
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
+ };
1895
2192
  if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
1896
2193
  className: cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className),
1897
2194
  children: empty
1898
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);
1899
2205
  return /* @__PURE__ */ jsx("div", {
1900
- 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
+ } : {},
1901
2218
  children: /* @__PURE__ */ jsxs("table", {
1902
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,
1903
2222
  children: [
1904
2223
  caption && /* @__PURE__ */ jsx("caption", {
1905
2224
  className: "sr-only",
1906
2225
  children: caption
1907
2226
  }),
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);
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
+ })
1930
2258
  })
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
- }) })
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
+ })
1955
2299
  ]
1956
2300
  })
1957
2301
  });
1958
2302
  }
1959
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
1960
2389
  //#region src/components/ui/Toggle.tsx
1961
2390
  /**
1962
2391
  * A setting: changes how the thing behaves from now on, and reads as one ("show the mask").
@@ -2325,6 +2754,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
2325
2754
  });
2326
2755
  }
2327
2756
  //#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 };
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 };
2329
2758
 
2330
2759
  //# sourceMappingURL=index.js.map