@vitavision/ui 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The vitavision *instrument* design system: true-neutral greys, one accent, and verdict colours
4
4
  reserved for verdicts. Tailwind v4 design tokens, a theme controller, and the React primitives
5
- every lab app shares. React 19, no CSS-in-JS, no runtime theme engine.
5
+ every vitavision app shares. React 19, no CSS-in-JS, no runtime theme engine.
6
6
 
7
7
  ## Install
8
8
 
@@ -31,7 +31,7 @@ Each stylesheet ships **unprocessed** — Tailwind v4 *source*, resolved by your
31
31
  declares its own `@source "./"`, which Tailwind resolves against that stylesheet: the package's
32
32
  class names are found without an `@source` line of yours.
33
33
 
34
- `fonts.css` serves the type pair, IBM Plex Sans and IBM Plex Mono (ADR-0003), from files in this
34
+ `fonts.css` serves the type pair, IBM Plex Sans and IBM Plex Mono, from files in this
35
35
  package (SIL Open Font License). Its Plex Mono is IBM's own build, which keeps the `zero` feature,
36
36
  so values get a slashed zero; `unicode-range` means a page fetches only the subsets its text
37
37
  uses (about 80 KB for Latin). Drop any fontsource or Google Fonts Plex you loaded before — their
package/dist/index.d.ts CHANGED
@@ -161,7 +161,10 @@ export declare function CountRun({ counts, className }: {
161
161
  * destroys something.
162
162
  */
163
163
  type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
164
- /** A button's height and padding. `md` is the comfortable density's size, `sm` the compact one's. */
164
+ /**
165
+ * A control's height and padding, shared by `Button`, `SegmentedControl` and `ToggleChip`. For a
166
+ * button, `md` is the comfortable density's size and `sm` the compact one's.
167
+ */
165
168
  type ButtonSize = "sm" | "md";
166
169
  /**
167
170
  * The classes that make a control look like a button, for the one element that has to be
@@ -890,8 +893,11 @@ export declare function PoseInput({ value, onValueChange, rotationView, translat
890
893
  * or `defaultValue` while `value` is `""` — and choosing the segment that matches
891
894
  * `defaultValue` reports `""`, so the default stays defined in one place. Each segment
892
895
  * carries `data-state="checked"` or `"unchecked"`; the group carries `data-disabled`.
896
+ *
897
+ * Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
898
+ * `Button`, pass `size="md"` and the strip's outer height matches theirs.
893
899
  */
894
- export declare function SegmentedControl({ value, defaultValue, options, onValueChange, disabled, "aria-label": ariaLabel, className }: {
900
+ export declare function SegmentedControl({ value, defaultValue, options, onValueChange, disabled, "aria-label": ariaLabel, size, className }: {
895
901
  /** `""` when the field is untouched. */
896
902
  value: string;
897
903
  /** What applies when nothing is sent. Highlighted while `value` is `""`. */
@@ -907,6 +913,13 @@ export declare function SegmentedControl({ value, defaultValue, options, onValue
907
913
  disabled?: boolean | undefined;
908
914
  /** Names the radio group. */
909
915
  "aria-label"?: string | undefined;
916
+ /**
917
+ * The strip's height: `sm` (the default) is the toolbar size, about 30px with `text-xs`
918
+ * labels; `md` is exactly 32px (`h-8`) outside, border included, with `text-sm` labels, the
919
+ * height of a comfortable-density `Input` or `Button`. Not derived from the density in
920
+ * force, so a consumer that never passes it renders as it always did.
921
+ */
922
+ size?: ButtonSize | undefined;
910
923
  /** Merged with the strip's own classes through `cn`. */
911
924
  className?: string | undefined;
912
925
  }): import("react").JSX.Element;
@@ -985,6 +998,34 @@ export declare function Slider({ value, onValueChange, onValueCommit, min, max,
985
998
  className?: string | undefined;
986
999
  }): import("react").JSX.Element;
987
1000
  //#endregion
1001
+ //#region src/components/ui/tableSort.d.ts
1002
+ /** Which way a column is sorted. Named as `aria-sort` names them: `"ascending"` puts the smallest first. */
1003
+ type SortDirection = "ascending" | "descending";
1004
+ /** The column a table is sorted by, and which way. Where a sort is expected, `null` means the rows' own order. */
1005
+ interface TableSort {
1006
+ /** The sorted column's `key`. */
1007
+ key: string;
1008
+ /** Which way. */
1009
+ direction: SortDirection;
1010
+ }
1011
+ /**
1012
+ * The rows in the order a `Table` with these columns shows them under `sort`.
1013
+ *
1014
+ * The sort is stable in both directions: rows that compare equal keep their order in `rows`.
1015
+ * With the column's `compare`, that function decides (reversed for `"descending"`). With its
1016
+ * `sortValue`, numbers compare by value and strings by a fixed English collation that reads
1017
+ * digit runs as numbers ("frame 9" before "frame 10"), the same on a server as in any
1018
+ * browser; numbers come before strings; and `null`, `undefined` and `NaN` go last in both
1019
+ * directions. A `null` sort, a `key` that names no column, and a column that is not sortable or
1020
+ * has neither function leave the order as given.
1021
+ *
1022
+ * @param rows - The rows, in their own order. Not modified.
1023
+ * @param columns - The table's columns; only `key`, `sortable`, `sortValue` and `compare` are read.
1024
+ * @param sort - The column and direction, or `null` for the rows' own order.
1025
+ * @returns A new array of the same rows, sorted.
1026
+ */
1027
+ export declare function sortRows<Row>(rows: readonly Row[], columns: readonly Pick<Column<Row>, "key" | "sortable" | "sortValue" | "compare">[], sort: TableSort | null | undefined): Row[];
1028
+ //#endregion
988
1029
  //#region src/components/ui/Table.d.ts
989
1030
  /** One column of a `Table`: its header and how to render a row's cell. */
990
1031
  type Column<Row> = {
@@ -998,6 +1039,30 @@ type Column<Row> = {
998
1039
  width?: string;
999
1040
  /** The cell's content for a row. */
1000
1041
  cell: (row: Row) => ReactNode;
1042
+ /**
1043
+ * Whether the header sorts the table. Defaults to `true` when the column has a `sortValue`
1044
+ * or a `compare`. Set it `true` with neither for a column the app sorts itself (see the
1045
+ * table's `manualSort`), or `false` to keep a column with a `sortValue` unsortable.
1046
+ */
1047
+ sortable?: boolean | undefined;
1048
+ /**
1049
+ * The value the column sorts by. Numbers compare by value; strings by a fixed English
1050
+ * collation that reads digit runs as numbers ("frame 9" before "frame 10"), so a server
1051
+ * render and the browser agree; numbers come before strings. `null`, `undefined` and `NaN`
1052
+ * go last in both directions.
1053
+ */
1054
+ sortValue?: ((row: Row) => number | string | null | undefined) | undefined;
1055
+ /**
1056
+ * A full ascending comparison, for an order one value cannot express (two fields, a rank):
1057
+ * negative when `a` comes first. Takes precedence over `sortValue`. Reversed for
1058
+ * `"descending"`; rows that compare equal keep their order in `rows` either way.
1059
+ */
1060
+ compare?: ((a: Row, b: Row) => number) | undefined;
1061
+ /**
1062
+ * The direction of the first click on the header. Defaults to `"ascending"`; `"descending"`
1063
+ * suits a score, where the best comes first.
1064
+ */
1065
+ firstSort?: SortDirection | undefined;
1001
1066
  };
1002
1067
  /**
1003
1068
  * A table of rows, with numeric columns set in mono and right-aligned.
@@ -1005,13 +1070,21 @@ type Column<Row> = {
1005
1070
  * With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
1006
1071
  * their table semantics. The active row carries `aria-current` and `data-state="active"`;
1007
1072
  * padding follows the density in force. With no rows it renders the `empty` message instead.
1008
- */
1009
- export declare function Table<Row>({ columns, rows, rowKey, empty, caption, className, onRowClick, isRowActive, onRowHover }: {
1073
+ *
1074
+ * A column with a `sortValue` or a `compare` gets a header button: each click moves its sort
1075
+ * from the column's `firstSort` direction to the other one, then back to the rows' own order.
1076
+ * The sorted header carries `aria-sort` and `data-sort` (`"ascending"` or `"descending"`);
1077
+ * other sortable headers carry `data-sort="none"`. The sort is kept by the table (from
1078
+ * `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
1079
+ * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
1080
+ * whatever the order on screen.
1081
+ */
1082
+ export declare function Table<Row>({ columns, rows, rowKey, empty, caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort, onSortChange, manualSort }: {
1010
1083
  /** The columns, in order. */
1011
1084
  columns: Column<Row>[];
1012
- /** The rows, in order. */
1085
+ /** The rows, in their own order: the order shown while the table is not sorted. */
1013
1086
  rows: Row[];
1014
- /** A stable key per row. */
1087
+ /** A stable key per row. `index` is the row's position in `rows`, whatever the sort. */
1015
1088
  rowKey: (row: Row, index: number) => string | number;
1016
1089
  /** Shown instead of the table when there are no rows. Defaults to "Nothing here.". */
1017
1090
  empty?: ReactNode;
@@ -1022,14 +1095,33 @@ export declare function Table<Row>({ columns, rows, rowKey, empty, caption, clas
1022
1095
  /** Makes rows activatable. Keyboard-reachable, so a table used as a list of
1023
1096
  * destinations is not a mouse-only control. The event is passed so a caller can read
1024
1097
  * modifiers — a list kept in step with a canvas needs shift-range and meta-toggle to mean
1025
- * the same thing in both places. */
1098
+ * the same thing in both places. `index` is the row's position in `rows`, whatever the
1099
+ * sort. */
1026
1100
  onRowClick?: (row: Row, index: number, event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>) => void;
1027
1101
  /** Marks the row that is current — the selected match, the open frame. Rendered as
1028
- * `aria-current`, so it is announced rather than only tinted. */
1102
+ * `aria-current`, so it is announced rather than only tinted. `index` is the row's
1103
+ * position in `rows`, whatever the sort. */
1029
1104
  isRowActive?: (row: Row, index: number) => boolean;
1030
1105
  /** Pointer enter/leave, for tables kept in step with a canvas overlay.
1031
- * `null` on leave. */
1106
+ * `null` on leave. `index` is the row's position in `rows`, whatever the sort. */
1032
1107
  onRowHover?: (row: Row | null, index: number | null) => void;
1108
+ /**
1109
+ * The sort, controlled: a column `key` and a direction, or `null` for the rows' own order.
1110
+ * Leave it undefined and the table keeps the sort itself, starting from `defaultSort`.
1111
+ */
1112
+ sort?: TableSort | null | undefined;
1113
+ /** The sort the table starts with when `sort` is not given. Defaults to `null`, the rows' own order. */
1114
+ defaultSort?: TableSort | null | undefined;
1115
+ /**
1116
+ * Called on a header click with the new sort: the column's `firstSort` direction, then the
1117
+ * other, then `null`. A click on another column starts that column at its `firstSort`.
1118
+ */
1119
+ onSortChange?: ((sort: TableSort | null) => void) | undefined;
1120
+ /**
1121
+ * The app orders the rows (on a server, say): the headers show the sort and report clicks,
1122
+ * and the rows render exactly as given. Mark the columns it can sort `sortable: true`.
1123
+ */
1124
+ manualSort?: boolean | undefined;
1033
1125
  }): import("react").JSX.Element;
1034
1126
  //#endregion
1035
1127
  //#region src/components/ui/Toggle.d.ts
@@ -1091,8 +1183,11 @@ export declare function Checkbox({ checked, onCheckedChange, onClick, label, des
1091
1183
  * colour: a toolbar-sized `Switch` (`role="switch"`, `aria-checked`).
1092
1184
  *
1093
1185
  * Controlled. State is exposed as `data-state` (`checked`/`unchecked`).
1186
+ *
1187
+ * Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
1188
+ * `Button`, pass `size="md"` and the chip's outer height matches theirs.
1094
1189
  */
1095
- export declare function ToggleChip({ checked, onCheckedChange, children, swatch, disabled, title, className }: {
1190
+ export declare function ToggleChip({ checked, onCheckedChange, children, swatch, disabled, title, size, className }: {
1096
1191
  /** Whether the layer is shown. */
1097
1192
  checked: boolean;
1098
1193
  /** Called with the new state. */
@@ -1105,6 +1200,14 @@ export declare function ToggleChip({ checked, onCheckedChange, children, swatch,
1105
1200
  disabled?: boolean | undefined;
1106
1201
  /** Why the control is disabled, or what the layer is. Never the only explanation. */
1107
1202
  title?: string | undefined;
1203
+ /**
1204
+ * The chip's height: `sm` (the default) is the toolbar size, about 26px with `text-xs`
1205
+ * text; `md` is exactly 32px (`h-8`) outside, border included, with `text-sm` text and a
1206
+ * slightly larger swatch, the height of a comfortable-density `Input` or `Button`. Not
1207
+ * derived from the density in force, so a consumer that never passes it renders as it
1208
+ * always did.
1209
+ */
1210
+ size?: ButtonSize | undefined;
1108
1211
  /** Merged with the chip's own classes through `cn`. */
1109
1212
  className?: string | undefined;
1110
1213
  }): import("react").JSX.Element;
@@ -1319,5 +1422,5 @@ export declare function Tabs<Id extends string>({ items, active, onSelect, label
1319
1422
  className?: string | undefined;
1320
1423
  }): import("react").JSX.Element;
1321
1424
  //#endregion
1322
- export type { BackLink, ButtonSize, ButtonVariant, CalloutTone, Column, Density, DropdownMenuProps, ListboxOption, ListboxOptionState, ListboxProps, MeasureTone, MenuCheckboxItemProps, MenuItemProps, NumberInputProps, PopoverProps, PoseInputProps, PoseValue, Quaternion, ReadoutItem, RotationView, SelectOption, TabItem, ThemeChoice, ToastOptions, ToastRecord, ToastStore, ToastTone, ToasterProps, Tone, Vec3, VectorInputProps };
1425
+ export type { BackLink, ButtonSize, ButtonVariant, CalloutTone, Column, Density, DropdownMenuProps, ListboxOption, ListboxOptionState, ListboxProps, MeasureTone, MenuCheckboxItemProps, MenuItemProps, NumberInputProps, PopoverProps, PoseInputProps, PoseValue, Quaternion, ReadoutItem, RotationView, SelectOption, SortDirection, TabItem, TableSort, ThemeChoice, ToastOptions, ToastRecord, ToastStore, ToastTone, ToasterProps, Tone, Vec3, VectorInputProps };
1323
1426
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, Check, CheckCircle2, ChevronDown, ChevronRight, CircleCheck, CircleX, HelpCircle, Info, Loader2, Minus, Moon, Sun, SunMoon, TriangleAlert, X, XCircle } from "lucide-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
2
  import { createContext, isValidElement, use, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import * as RadixTooltip from "@radix-ui/react-tooltip";
4
4
  import { clsx } from "clsx";
@@ -353,7 +353,7 @@ const VARIANT_CLASSES = {
353
353
  ghost: "text-fg-muted hover:bg-raised hover:text-fg",
354
354
  danger: "bg-defect/10 text-defect ring-1 ring-inset ring-defect/30 hover:bg-defect/15"
355
355
  };
356
- const SIZE_CLASSES = {
356
+ const SIZE_CLASSES$1 = {
357
357
  sm: "h-7 gap-1.5 px-2.5 text-xs",
358
358
  md: "h-8 gap-2 px-3 text-sm"
359
359
  };
@@ -367,7 +367,7 @@ const SIZE_CLASSES = {
367
367
  * @returns The class string.
368
368
  */
369
369
  function buttonClasses({ variant = "secondary", size = "md", className } = {}) {
370
- return cn("inline-flex shrink-0 items-center justify-center rounded-control font-medium transition-colors", "disabled:cursor-not-allowed disabled:opacity-45", SIZE_CLASSES[size], VARIANT_CLASSES[variant], focusRing, className);
370
+ return cn("inline-flex shrink-0 items-center justify-center rounded-control font-medium transition-colors", "disabled:cursor-not-allowed disabled:opacity-45", SIZE_CLASSES$1[size], VARIANT_CLASSES[variant], focusRing, className);
371
371
  }
372
372
  function ButtonIcon({ icon }) {
373
373
  return /* @__PURE__ */ jsx("span", {
@@ -1621,6 +1621,16 @@ function PoseInput({ value, onValueChange, rotationView, translationUnit = "m",
1621
1621
  //#endregion
1622
1622
  //#region src/components/ui/SegmentedControl.tsx
1623
1623
  /**
1624
+ * The segment's own box, by size. `sm` is the toolbar size and the default. `md` sets the
1625
+ * height explicitly rather than leaving it to padding plus line-height: the strip around it
1626
+ * is `h-8` with a 1px border and `p-0.5`, which leaves 26px of content box, and `h-full`
1627
+ * fills exactly that whatever the font's line height turns out to be.
1628
+ */
1629
+ const SEGMENT_SIZE_CLASSES = {
1630
+ sm: "px-2.5 py-1 text-xs",
1631
+ md: "flex h-full items-center px-3.5 text-sm"
1632
+ };
1633
+ /**
1624
1634
  * A choice among two or three, shown as a strip of segments (native radios in one group, so
1625
1635
  * arrow keys move between them).
1626
1636
  *
@@ -1628,8 +1638,11 @@ function PoseInput({ value, onValueChange, rotationView, translationUnit = "m",
1628
1638
  * or `defaultValue` while `value` is `""` — and choosing the segment that matches
1629
1639
  * `defaultValue` reports `""`, so the default stays defined in one place. Each segment
1630
1640
  * carries `data-state="checked"` or `"unchecked"`; the group carries `data-disabled`.
1641
+ *
1642
+ * Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
1643
+ * `Button`, pass `size="md"` and the strip's outer height matches theirs.
1631
1644
  */
1632
- function SegmentedControl({ value, defaultValue, options, onValueChange, disabled = false, "aria-label": ariaLabel, className }) {
1645
+ function SegmentedControl({ value, defaultValue, options, onValueChange, disabled = false, "aria-label": ariaLabel, size = "sm", className }) {
1633
1646
  const nameId = useId();
1634
1647
  const described = useFieldDescription();
1635
1648
  const effective = value === "" ? defaultValue : value;
@@ -1638,12 +1651,12 @@ function SegmentedControl({ value, defaultValue, options, onValueChange, disable
1638
1651
  "aria-label": ariaLabel,
1639
1652
  "aria-describedby": described["aria-describedby"],
1640
1653
  "data-disabled": disabled ? "" : void 0,
1641
- className: cn("inline-flex w-fit max-w-full shrink-0 items-center gap-0.5 rounded-control border border-line-strong bg-raised p-0.5", disabled && "opacity-50", className),
1654
+ className: cn("inline-flex w-fit max-w-full shrink-0 items-center gap-0.5 rounded-control border border-line-strong bg-raised p-0.5", size === "md" && "h-8", disabled && "opacity-50", className),
1642
1655
  children: options.map((option) => {
1643
1656
  const checked = option.value === effective;
1644
1657
  return /* @__PURE__ */ jsxs("label", {
1645
1658
  "data-state": checked ? "checked" : "unchecked",
1646
- className: cn("relative cursor-pointer whitespace-nowrap rounded-[0.3rem] px-2.5 py-1 text-xs font-medium transition-colors", "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal", checked ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg", disabled && "cursor-not-allowed"),
1659
+ className: cn("relative cursor-pointer whitespace-nowrap rounded-[0.3rem]", SEGMENT_SIZE_CLASSES[size], "font-medium transition-colors", "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal", checked ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg", disabled && "cursor-not-allowed"),
1647
1660
  children: [/* @__PURE__ */ jsx("input", {
1648
1661
  type: "radio",
1649
1662
  name: nameId,
@@ -1762,16 +1775,123 @@ function Slider({ value, onValueChange, onValueCommit, min = 0, max = 1, step =
1762
1775
  });
1763
1776
  }
1764
1777
  //#endregion
1778
+ //#region src/components/ui/tableSort.ts
1779
+ /** Strings by a fixed English collation that reads digit runs as numbers: "frame 9" before "frame 10". */
1780
+ const collator = new Intl.Collator("en", { numeric: true });
1781
+ /**
1782
+ * Whether a column's header sorts the table: `sortable` if given, otherwise whether the column
1783
+ * says how to order (`sortValue` or `compare`).
1784
+ */
1785
+ function isSortable(column) {
1786
+ return column.sortable ?? (column.sortValue !== void 0 || column.compare !== void 0);
1787
+ }
1788
+ /** The sort after a click on `column`'s header: its first direction, then the other, then none. */
1789
+ function nextSort(current, column) {
1790
+ const first = column.firstSort ?? "ascending";
1791
+ if (current?.key !== column.key) return {
1792
+ key: column.key,
1793
+ direction: first
1794
+ };
1795
+ if (current.direction !== first) return null;
1796
+ return {
1797
+ key: column.key,
1798
+ direction: first === "ascending" ? "descending" : "ascending"
1799
+ };
1800
+ }
1801
+ /** A value with no place in the order: `null`, `undefined` or `NaN`. */
1802
+ function missing(value) {
1803
+ return value === null || value === void 0 || Number.isNaN(value);
1804
+ }
1805
+ /**
1806
+ * Two present values in ascending order: numbers by value (compared, not subtracted, so two
1807
+ * infinities tie), strings by the collator, and every number before every string.
1808
+ */
1809
+ function compareValues(a, b) {
1810
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
1811
+ if (typeof a === "string" && typeof b === "string") return collator.compare(a, b);
1812
+ return typeof a === "number" ? -1 : 1;
1813
+ }
1814
+ /**
1815
+ * The display order of `rows` under `sort`, as indices into `rows`. `Table` renders through it
1816
+ * so each row keeps its original index for the row callbacks.
1817
+ */
1818
+ function sortOrder(rows, columns, sort) {
1819
+ const order = rows.map((_, index) => index);
1820
+ if (!sort) return order;
1821
+ const column = columns.find((candidate) => candidate.key === sort.key);
1822
+ if (!column || !isSortable(column)) return order;
1823
+ const sign = sort.direction === "descending" ? -1 : 1;
1824
+ const { compare, sortValue } = column;
1825
+ if (compare) return order.sort((a, b) => sign * compare(rows[a], rows[b]) || a - b);
1826
+ if (!sortValue) return order;
1827
+ const values = rows.map((row) => sortValue(row));
1828
+ return order.sort((a, b) => {
1829
+ const left = values[a];
1830
+ const right = values[b];
1831
+ const leftMissing = missing(left);
1832
+ const rightMissing = missing(right);
1833
+ if (leftMissing || rightMissing) return leftMissing === rightMissing ? a - b : leftMissing ? 1 : -1;
1834
+ return sign * compareValues(left, right) || a - b;
1835
+ });
1836
+ }
1837
+ /**
1838
+ * The rows in the order a `Table` with these columns shows them under `sort`.
1839
+ *
1840
+ * The sort is stable in both directions: rows that compare equal keep their order in `rows`.
1841
+ * With the column's `compare`, that function decides (reversed for `"descending"`). With its
1842
+ * `sortValue`, numbers compare by value and strings by a fixed English collation that reads
1843
+ * digit runs as numbers ("frame 9" before "frame 10"), the same on a server as in any
1844
+ * browser; numbers come before strings; and `null`, `undefined` and `NaN` go last in both
1845
+ * directions. A `null` sort, a `key` that names no column, and a column that is not sortable or
1846
+ * has neither function leave the order as given.
1847
+ *
1848
+ * @param rows - The rows, in their own order. Not modified.
1849
+ * @param columns - The table's columns; only `key`, `sortable`, `sortValue` and `compare` are read.
1850
+ * @param sort - The column and direction, or `null` for the rows' own order.
1851
+ * @returns A new array of the same rows, sorted.
1852
+ */
1853
+ function sortRows(rows, columns, sort) {
1854
+ return sortOrder(rows, columns, sort).map((index) => rows[index]);
1855
+ }
1856
+ //#endregion
1765
1857
  //#region src/components/ui/Table.tsx
1858
+ /** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
1859
+ const SORT_ICONS = {
1860
+ ascending: ArrowUp,
1861
+ descending: ArrowDown,
1862
+ none: ArrowUpDown
1863
+ };
1766
1864
  /**
1767
1865
  * A table of rows, with numeric columns set in mono and right-aligned.
1768
1866
  *
1769
1867
  * With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
1770
1868
  * their table semantics. The active row carries `aria-current` and `data-state="active"`;
1771
1869
  * padding follows the density in force. With no rows it renders the `empty` message instead.
1772
- */
1773
- function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover }) {
1870
+ *
1871
+ * A column with a `sortValue` or a `compare` gets a header button: each click moves its sort
1872
+ * from the column's `firstSort` direction to the other one, then back to the rows' own order.
1873
+ * The sorted header carries `aria-sort` and `data-sort` (`"ascending"` or `"descending"`);
1874
+ * other sortable headers carry `data-sort="none"`. The sort is kept by the table (from
1875
+ * `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
1876
+ * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
1877
+ * whatever the order on screen.
1878
+ */
1879
+ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, className, onRowClick, isRowActive, onRowHover, sort: controlledSort, defaultSort = null, onSortChange, manualSort = false }) {
1774
1880
  const density = useDensity();
1881
+ const [ownSort, setOwnSort] = useState(defaultSort);
1882
+ const controlled = controlledSort !== void 0;
1883
+ const sort = controlled ? controlledSort : ownSort;
1884
+ const order = useMemo(() => manualSort ? rows.map((_, index) => index) : sortOrder(rows, columns, sort), [
1885
+ rows,
1886
+ columns,
1887
+ sort,
1888
+ manualSort
1889
+ ]);
1890
+ const changeSort = (column) => {
1891
+ const next = nextSort(sort, column);
1892
+ if (!controlled) setOwnSort(next);
1893
+ onSortChange?.(next);
1894
+ };
1775
1895
  if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
1776
1896
  className: cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className),
1777
1897
  children: empty
@@ -1787,32 +1907,51 @@ function Table({ columns, rows, rowKey, empty = "Nothing here.", caption, classN
1787
1907
  }),
1788
1908
  /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", {
1789
1909
  className: "border-b border-line",
1790
- children: columns.map((column) => /* @__PURE__ */ jsx("th", {
1791
- scope: "col",
1792
- style: column.width ? { width: column.width } : void 0,
1793
- 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"),
1794
- children: column.header
1795
- }, column.key))
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);
1930
+ })
1796
1931
  }) }),
1797
- /* @__PURE__ */ jsx("tbody", { children: rows.map((row, index) => /* @__PURE__ */ jsx("tr", {
1798
- "data-state": isRowActive?.(row, index) ? "active" : void 0,
1799
- className: cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-raised", isRowActive?.(row, index) && "bg-signal/10"),
1800
- tabIndex: onRowClick ? 0 : void 0,
1801
- "aria-current": isRowActive?.(row, index) ? true : void 0,
1802
- onClick: onRowClick ? (event) => onRowClick(row, index, event) : void 0,
1803
- onKeyDown: onRowClick ? (event) => {
1804
- if (event.key === "Enter" || event.key === " ") {
1805
- event.preventDefault();
1806
- onRowClick(row, index, event);
1807
- }
1808
- } : void 0,
1809
- onPointerEnter: onRowHover ? () => onRowHover(row, index) : void 0,
1810
- onPointerLeave: onRowHover ? () => onRowHover(null, null) : void 0,
1811
- children: columns.map((column) => /* @__PURE__ */ jsx("td", {
1812
- 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"),
1813
- children: column.cell(row)
1814
- }, column.key))
1815
- }, rowKey(row, index))) })
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
+ }) })
1816
1955
  ]
1817
1956
  })
1818
1957
  });
@@ -1901,12 +2040,28 @@ function Checkbox({ checked, onCheckedChange, onClick, label, description, disab
1901
2040
  //#endregion
1902
2041
  //#region src/components/ui/ToggleChip.tsx
1903
2042
  /**
2043
+ * The chip's box, by size. `sm` is the toolbar size and the default. `md` sets the height
2044
+ * explicitly (`h-8`, border included) rather than leaving it to padding plus line-height.
2045
+ */
2046
+ const SIZE_CLASSES = {
2047
+ sm: "px-2 py-1 text-xs",
2048
+ md: "h-8 px-3 text-sm"
2049
+ };
2050
+ /** The swatch dot, by size: a little larger beside `text-sm` so it keeps its weight against the label. */
2051
+ const SWATCH_SIZE_CLASSES = {
2052
+ sm: "size-2",
2053
+ md: "size-2.5"
2054
+ };
2055
+ /**
1904
2056
  * A dense on/off chip for a layer drawn on screen, with an optional swatch in the layer's
1905
2057
  * colour: a toolbar-sized `Switch` (`role="switch"`, `aria-checked`).
1906
2058
  *
1907
2059
  * Controlled. State is exposed as `data-state` (`checked`/`unchecked`).
2060
+ *
2061
+ * Toolbar-sized by default. In a row with a comfortable-density `Input`, `Select` or
2062
+ * `Button`, pass `size="md"` and the chip's outer height matches theirs.
1908
2063
  */
1909
- function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = false, title, className }) {
2064
+ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = false, title, size = "sm", className }) {
1910
2065
  return /* @__PURE__ */ jsxs("button", {
1911
2066
  type: "button",
1912
2067
  role: "switch",
@@ -1915,10 +2070,10 @@ function ToggleChip({ checked, onCheckedChange, children, swatch, disabled = fal
1915
2070
  disabled,
1916
2071
  title,
1917
2072
  onClick: () => onCheckedChange(!checked),
1918
- className: cn("inline-flex items-center gap-1.5 rounded-control border px-2 py-1 text-xs whitespace-nowrap transition-colors", checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted", focusRing, className),
2073
+ className: cn("inline-flex items-center gap-1.5 rounded-control border", SIZE_CLASSES[size], "whitespace-nowrap transition-colors", checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg", "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted", focusRing, className),
1919
2074
  children: [swatch !== void 0 && /* @__PURE__ */ jsx("span", {
1920
2075
  "aria-hidden": true,
1921
- className: cn("size-2 shrink-0 rounded-full border transition-opacity", checked ? "opacity-100" : "opacity-30"),
2076
+ className: cn(SWATCH_SIZE_CLASSES[size], "shrink-0 rounded-full border transition-opacity", checked ? "opacity-100" : "opacity-30"),
1922
2077
  style: {
1923
2078
  backgroundColor: swatch,
1924
2079
  borderColor: swatch
@@ -2170,6 +2325,6 @@ function Tabs({ items, active, onSelect, label, idPrefix, className }) {
2170
2325
  });
2171
2326
  }
2172
2327
  //#endregion
2173
- 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 };
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 };
2174
2329
 
2175
2330
  //# sourceMappingURL=index.js.map