@karimsa/mdxserve 0.2.0 → 0.4.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 (57) hide show
  1. package/README.md +60 -10
  2. package/client/CodeBlock.tsx +24 -0
  3. package/client/DocView.tsx +9 -2
  4. package/client/ListingView.tsx +14 -10
  5. package/client/MarkdownTable.tsx +190 -0
  6. package/client/MdSection.tsx +56 -21
  7. package/client/MdSectionEditor.tsx +149 -2
  8. package/client/Mermaid.tsx +33 -7
  9. package/client/Settings.css +17 -0
  10. package/client/Settings.tsx +132 -0
  11. package/client/Table.tsx +2 -14
  12. package/client/app.css +339 -1
  13. package/client/block-state/README.md +39 -0
  14. package/client/block-state/storage.ts +73 -0
  15. package/client/block-state/useBlockState.ts +120 -0
  16. package/client/builtins/DataTable.tsx +269 -0
  17. package/client/builtins/chart-data.ts +22 -10
  18. package/client/builtins/index.ts +10 -0
  19. package/client/data-table/ColumnMenu.tsx +168 -0
  20. package/client/data-table/ColumnOptions.tsx +141 -0
  21. package/client/data-table/TableCell.tsx +76 -0
  22. package/client/data-table/TableHeaderCell.tsx +94 -0
  23. package/client/data-table/model.ts +194 -0
  24. package/client/data-table/navigation.ts +34 -0
  25. package/client/data-table/table-state.ts +196 -0
  26. package/client/data-table/useColumnMenu.ts +84 -0
  27. package/client/data-table/useTableState.ts +36 -0
  28. package/client/design/base/prose.css +4 -0
  29. package/client/diagrams/Dialog.tsx +577 -0
  30. package/client/diagrams/EditDialog.tsx +67 -0
  31. package/client/diagrams/Preferences.tsx +213 -0
  32. package/client/diagrams/edit-block.ts +93 -0
  33. package/client/diagrams/edit-context.ts +4 -0
  34. package/client/diagrams/session-id.ts +8 -0
  35. package/client/diagrams/slash.ts +94 -0
  36. package/client/diagrams/source.ts +73 -0
  37. package/client/diagrams/style.css +599 -0
  38. package/client/entry.tsx +2 -0
  39. package/client/mdx-components-base.ts +2 -2
  40. package/client/router.ts +8 -1
  41. package/client/shell/AppShell.tsx +38 -15
  42. package/client/shell/Sidebar.tsx +20 -2
  43. package/client/shell/StandaloneShell.tsx +19 -6
  44. package/client/shell/TocRail.tsx +4 -3
  45. package/client/shell/TopBar.tsx +17 -5
  46. package/client/state.ts +14 -0
  47. package/client/ui/ConfirmDeleteDialog.tsx +48 -91
  48. package/client/ui/ExpandModal.tsx +63 -104
  49. package/client/ui/Modal.tsx +123 -0
  50. package/client/ui/ResizeHandle.tsx +1 -1
  51. package/client/ui/SearchDialog.tsx +81 -88
  52. package/client/ui/modal.css +42 -0
  53. package/dist/cli.js +2191 -778
  54. package/dist/diagram-worker.js +17 -0
  55. package/dist/registry.json +297 -0
  56. package/package.json +42 -2
  57. package/skills/mdxserve/SKILL.md +56 -1
@@ -0,0 +1,76 @@
1
+ import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react";
2
+ import { formatValue, type Column, type Row } from "./model";
3
+ import { navigateTable } from "./navigation";
4
+ import type { ActiveCell, VisibleRow } from "./table-state";
5
+
6
+ interface TableCellProps {
7
+ column: Column;
8
+ columnIndex: number;
9
+ columnCount: number;
10
+ row: Row;
11
+ index: number;
12
+ rowIndex: number;
13
+ rows: VisibleRow[];
14
+ unit: string;
15
+ limits?: number[];
16
+ active: ActiveCell;
17
+ setActive: Dispatch<SetStateAction<ActiveCell>>;
18
+ tableRef: RefObject<HTMLTableElement | null>;
19
+ renderCell?: (row: number, key: string) => ReactNode;
20
+ }
21
+
22
+ export function TableCell({
23
+ column,
24
+ columnIndex,
25
+ columnCount,
26
+ row,
27
+ index,
28
+ rowIndex,
29
+ rows,
30
+ unit,
31
+ limits,
32
+ active,
33
+ setActive,
34
+ tableRef,
35
+ renderCell,
36
+ }: TableCellProps) {
37
+ const value = row[column.key] ?? null;
38
+ const span = limits ? limits[1] - limits[0] : 0;
39
+ const selected = active?.row === index && active.column === columnIndex;
40
+
41
+ return (
42
+ <td
43
+ key={column.key}
44
+ data-row={index}
45
+ data-column={columnIndex}
46
+ data-numeric={column.type !== "text"}
47
+ data-align={column.align ?? (column.type === "text" ? "left" : "right")}
48
+ data-active={selected}
49
+ data-bars={Boolean(limits)}
50
+ tabIndex={selected ? 0 : -1}
51
+ onFocus={() => setActive({ row: index, column: columnIndex })}
52
+ onClick={(event) => {
53
+ setActive({ row: index, column: columnIndex });
54
+ if (!(event.target as HTMLElement).closest("a,button,input,select"))
55
+ event.currentTarget.focus();
56
+ }}
57
+ onKeyDown={(event) =>
58
+ navigateTable(event, rowIndex, columnIndex, rows, columnCount, tableRef.current)
59
+ }
60
+ >
61
+ {limits && typeof value === "number" && span > 0 && (
62
+ <span
63
+ className="data-table-bar"
64
+ aria-hidden="true"
65
+ style={{
66
+ left: `calc(16px + (100% - 32px) * ${(Math.min(0, value) - limits[0]) / span})`,
67
+ width: `calc((100% - 32px) * ${Math.abs(value) / span})`,
68
+ }}
69
+ />
70
+ )}
71
+ <span className="data-table-value">
72
+ {renderCell?.(index, column.key) ?? formatValue(value, column, unit)}
73
+ </span>
74
+ </td>
75
+ );
76
+ }
@@ -0,0 +1,94 @@
1
+ import type { Dispatch, ReactNode, RefObject, SetStateAction } from "react";
2
+ import { ColumnOptions } from "./ColumnOptions";
3
+ import { ColumnMenu } from "./ColumnMenu";
4
+ import { type Column, type Row, type TableState } from "./model";
5
+ import { cycleSort, hasFilter, type ActiveCell, type VisibleRow } from "./table-state";
6
+ import { navigateTable } from "./navigation";
7
+
8
+ export interface TableHeaderCellProps {
9
+ renderHeader?: (key: string) => ReactNode;
10
+ column: Column;
11
+ columnIndex: number;
12
+ rowSpan?: number;
13
+ columnCount: number;
14
+ data: Row[];
15
+ rows: VisibleRow[];
16
+ state: TableState;
17
+ setState: Dispatch<SetStateAction<TableState>>;
18
+ units: Record<string, string>;
19
+ errors: Record<string, string>;
20
+ active: ActiveCell;
21
+ activeVisible: boolean;
22
+ setActive: Dispatch<SetStateAction<ActiveCell>>;
23
+ openColumn: string | null;
24
+ setOpenColumn: Dispatch<SetStateAction<string | null>>;
25
+ tableRef: RefObject<HTMLTableElement | null>;
26
+ }
27
+
28
+ export function TableHeaderCell({
29
+ renderHeader,
30
+ column,
31
+ columnIndex,
32
+ rowSpan = 1,
33
+ columnCount,
34
+ data,
35
+ rows,
36
+ state,
37
+ setState,
38
+ units,
39
+ errors,
40
+ active,
41
+ activeVisible,
42
+ setActive,
43
+ openColumn,
44
+ setOpenColumn,
45
+ tableRef,
46
+ }: TableHeaderCellProps) {
47
+ const filtered = hasFilter(state.filters[column.key]);
48
+ const sorted = state.sort?.key === column.key;
49
+ const selected = active?.row === -1 && active.column === columnIndex;
50
+
51
+ return (
52
+ <th
53
+ key={column.key}
54
+ scope="col"
55
+ rowSpan={rowSpan}
56
+ data-column={columnIndex}
57
+ data-active={selected}
58
+ data-numeric={column.type !== "text"}
59
+ data-align={column.align ?? (column.type === "text" ? "left" : "right")}
60
+ aria-sort={sorted ? (state.sort?.descending ? "descending" : "ascending") : "none"}
61
+ >
62
+ <ColumnMenu
63
+ tabIndex={selected || (!activeVisible && columnIndex === 0) ? 0 : -1}
64
+ onActivate={() => setActive({ row: -1, column: columnIndex })}
65
+ onNavigate={(event) =>
66
+ navigateTable(event, -1, columnIndex, rows, columnCount, tableRef.current)
67
+ }
68
+ onSort={() => setState((previous) => cycleSort(previous, column.key))}
69
+ label={column.label}
70
+ headerContent={renderHeader?.(column.key)}
71
+ unit={units[column.key] || undefined}
72
+ indicator={`${sorted ? (state.sort?.descending ? "↓" : "↑") : ""}${filtered ? " •" : ""}`}
73
+ open={openColumn === column.key}
74
+ onOpenChange={(open) =>
75
+ setOpenColumn((previous) =>
76
+ open ? column.key : previous === column.key ? null : previous,
77
+ )
78
+ }
79
+ >
80
+ {(close) => (
81
+ <ColumnOptions
82
+ column={column}
83
+ data={data}
84
+ state={state}
85
+ setState={setState}
86
+ unit={units[column.key]}
87
+ error={errors[column.key]}
88
+ close={close}
89
+ />
90
+ )}
91
+ </ColumnMenu>
92
+ </th>
93
+ );
94
+ }
@@ -0,0 +1,194 @@
1
+ import ms from "ms";
2
+ import bytes from "bytes";
3
+ import { z } from "zod";
4
+
5
+ const common = {
6
+ key: z.string().min(1),
7
+ label: z.string().min(1),
8
+ group: z.string().optional(),
9
+ align: z
10
+ .enum(["left", "center", "right"])
11
+ .optional()
12
+ .describe("Explicit alignment overrides the column type default."),
13
+ bars: z
14
+ .boolean()
15
+ .optional()
16
+ .describe("Opt-in magnitude bars; zero is always included in the scale."),
17
+ };
18
+ const formatter = z.custom<(value: number) => string>((value) => typeof value === "function");
19
+ export const columnSchema = z.discriminatedUnion("type", [
20
+ z.object({ ...common, type: z.literal("text") }),
21
+ z.object({ ...common, type: z.literal("number") }),
22
+ z.object({ ...common, type: z.literal("percent") }),
23
+ z.object({ ...common, type: z.literal("time"), unit: z.string().min(1) }),
24
+ z.object({ ...common, type: z.literal("bytes"), unit: z.string().min(1) }),
25
+ z.object({
26
+ ...common,
27
+ type: z.literal("currency"),
28
+ format: formatter.describe(
29
+ "Required numeric-to-string currency formatter, e.g. value => `$${value}`",
30
+ ),
31
+ }),
32
+ ]);
33
+ export type Column = z.infer<typeof columnSchema>;
34
+ export type Value = string | number | null;
35
+ export type Row = Record<string, Value>;
36
+ export type Filter = {
37
+ pattern?: string;
38
+ caseSensitive?: boolean;
39
+ min?: string;
40
+ max?: string;
41
+ unit?: string;
42
+ };
43
+ export type TableState = {
44
+ columnTypes?: Record<string, Column["type"]>;
45
+ sort?: { key: string; descending: boolean };
46
+ filters: Record<string, Filter>;
47
+ units: Record<string, string>;
48
+ };
49
+ export const emptyState = (): TableState => ({ filters: {}, units: {} });
50
+ export const timeUnits = ["ms", "s", "m", "h", "d", "w", "y"];
51
+ export const byteUnits = ["B", "KB", "MB", "GB", "TB", "PB"];
52
+ export function unitFactor(type: "time" | "bytes", unit: string): number {
53
+ try {
54
+ if (type === "bytes" && !/^(b|kb|mb|gb|tb|pb)$/i.test(unit))
55
+ throw new Error("Invalid bytes unit");
56
+ const result = type === "time" ? ms(`1 ${unit}` as ms.StringValue) : bytes.parse(`1${unit}`);
57
+ if (typeof result === "number" && Number.isFinite(result) && result > 0) return result;
58
+ } catch {
59
+ /* invalid unit is reported by the component */
60
+ }
61
+ throw new Error(`Unsupported ${type} unit: ${unit}`);
62
+ }
63
+ export function parseBound(
64
+ input: string,
65
+ column: Column,
66
+ displayUnit?: string,
67
+ ): number | undefined {
68
+ if (!input.trim()) return undefined;
69
+ const raw = input.trim();
70
+ const numeric = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
71
+ let result: number | null | undefined;
72
+ if (column.type === "time" || column.type === "bytes") {
73
+ const base = unitFactor(column.type, column.unit);
74
+ if (numeric.test(raw))
75
+ result = (Number(raw) * unitFactor(column.type, displayUnit ?? column.unit)) / base;
76
+ else {
77
+ try {
78
+ result = column.type === "time" ? ms(raw as ms.StringValue) : bytes.parse(raw);
79
+ } catch {
80
+ result = undefined;
81
+ }
82
+ // bytes.parse accepts arbitrary trailing text; require a complete supported unit.
83
+ if (
84
+ column.type === "bytes" &&
85
+ !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)\s*(?:b|kb|mb|gb|tb|pb)$/i.test(raw)
86
+ )
87
+ result = undefined;
88
+ if (typeof result === "number") result /= base;
89
+ }
90
+ } else if (column.type === "percent") {
91
+ const percentage = raw.replace(/%$/, "").trim();
92
+ if (numeric.test(percentage)) result = Number(percentage) / 100;
93
+ } else if (column.type === "currency") {
94
+ const match = raw.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*([kmbt])?$/i);
95
+ if (match)
96
+ result =
97
+ Number(match[1]) * ({ k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[match[2]?.toLowerCase()] ?? 1);
98
+ } else if (numeric.test(raw)) result = Number(raw);
99
+ if (typeof result !== "number" || !Number.isFinite(result))
100
+ throw new Error("Enter a valid number or supported unit.");
101
+ return result;
102
+ }
103
+ export function autoUnit(column: Column, values: Value[]): string {
104
+ if (column.type !== "time" && column.type !== "bytes") return "";
105
+ const magnitude = Math.max(
106
+ 0,
107
+ ...values
108
+ .filter((value): value is number => typeof value === "number")
109
+ .map((value) => Math.abs(value) * unitFactor(column.type as "time" | "bytes", column.unit)),
110
+ );
111
+ const units = column.type === "time" ? timeUnits : byteUnits;
112
+ return (
113
+ [...units]
114
+ .reverse()
115
+ .find((unit) => magnitude >= unitFactor(column.type as "time" | "bytes", unit)) ?? units[0]
116
+ );
117
+ }
118
+ export function formatValue(value: Value, column: Column, unit?: string): string {
119
+ if (value === null) return "—";
120
+ if (typeof value !== "number") return value;
121
+ const numberFormat = new Intl.NumberFormat("en-US", { maximumFractionDigits: 20 });
122
+ if (column.type === "percent")
123
+ return `${new Intl.NumberFormat("en-US", { maximumSignificantDigits: 12 }).format(value * 100)}%`;
124
+ if (column.type === "currency") return column.format(value);
125
+ if (column.type === "time" || column.type === "bytes") {
126
+ const chosen = unit ?? column.unit;
127
+ return `${new Intl.NumberFormat("en-US", { maximumSignificantDigits: 6 }).format((value * unitFactor(column.type, column.unit)) / unitFactor(column.type, chosen))} ${chosen}`;
128
+ }
129
+ return numberFormat.format(value);
130
+ }
131
+ export function filterPredicate(
132
+ column: Column,
133
+ filter: Filter,
134
+ unit?: string,
135
+ ): (value: Value) => boolean {
136
+ if (column.type === "text") {
137
+ const regexp = new RegExp(filter.pattern ?? "", filter.caseSensitive ? "" : "i");
138
+ return (value) => regexp.test(String(value ?? ""));
139
+ }
140
+ const min = parseBound(filter.min ?? "", column, filter.unit ?? unit);
141
+ const max = parseBound(filter.max ?? "", column, filter.unit ?? unit);
142
+ if (min !== undefined && max !== undefined && min > max)
143
+ throw new Error("Minimum must not exceed maximum.");
144
+ return (value) =>
145
+ (min === undefined && max === undefined) ||
146
+ (typeof value === "number" &&
147
+ (min === undefined || value >= min) &&
148
+ (max === undefined || value <= max));
149
+ }
150
+ export function compareValues(left: Value, right: Value, descending: boolean): number {
151
+ if (left === null) return right === null ? 0 : 1;
152
+ if (right === null) return -1;
153
+ const order =
154
+ typeof left === "number" && typeof right === "number"
155
+ ? left - right
156
+ : String(left).localeCompare(String(right), undefined, { numeric: true });
157
+ return descending ? -order : order;
158
+ }
159
+
160
+ /** Infer only when every nonmissing cell agrees; never infer bars from numeric data. */
161
+ export function inferColumn(key: string, label: string, values: string[]): Column {
162
+ const present = values
163
+ .map((value) => value.trim())
164
+ .filter((value) => value !== "" && value !== "—");
165
+ const base = { key, label };
166
+ const numeric = /^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?$/;
167
+ if (present.length && present.every((value) => numeric.test(value)))
168
+ return { ...base, type: "number" };
169
+ if (
170
+ present.length &&
171
+ present.every((value) => value.endsWith("%") && numeric.test(value.slice(0, -1)))
172
+ )
173
+ return { ...base, type: "percent" };
174
+ const size: Column = { ...base, type: "bytes", unit: "B" };
175
+ if (
176
+ present.length &&
177
+ present.every((value) => /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)\s*(b|kb|mb|gb|tb|pb)$/i.test(value))
178
+ )
179
+ return size;
180
+ const duration: Column = { ...base, type: "time", unit: "ms" };
181
+ if (
182
+ present.length &&
183
+ present.every((value) => {
184
+ if (!/[a-z]/i.test(value)) return false;
185
+ try {
186
+ return parseBound(value, duration) !== undefined;
187
+ } catch {
188
+ return false;
189
+ }
190
+ })
191
+ )
192
+ return duration;
193
+ return { ...base, type: "text" };
194
+ }
@@ -0,0 +1,34 @@
1
+ import type { KeyboardEvent } from "react";
2
+ import type { VisibleRow } from "./table-state";
3
+
4
+ export function navigateTable(
5
+ event: KeyboardEvent<HTMLElement>,
6
+ rowIndex: number,
7
+ columnIndex: number,
8
+ rows: VisibleRow[],
9
+ columnCount: number,
10
+ table: HTMLTableElement | null,
11
+ ) {
12
+ if (event.target !== event.currentTarget) return;
13
+ let nextRow = rowIndex;
14
+ let nextColumn = columnIndex;
15
+ if (event.key === "ArrowDown") nextRow++;
16
+ else if (event.key === "ArrowUp") nextRow--;
17
+ else if (event.key === "ArrowLeft") nextColumn--;
18
+ else if (event.key === "ArrowRight") nextColumn++;
19
+ else if (event.key === "Home") {
20
+ nextColumn = 0;
21
+ if (event.ctrlKey || event.metaKey) nextRow = -1;
22
+ } else if (event.key === "End") {
23
+ nextColumn = columnCount - 1;
24
+ if (event.ctrlKey || event.metaKey) nextRow = rows.length - 1;
25
+ } else return;
26
+ event.preventDefault();
27
+ nextRow = Math.max(-1, Math.min(rows.length - 1, nextRow));
28
+ nextColumn = Math.max(0, Math.min(columnCount - 1, nextColumn));
29
+ const target =
30
+ nextRow === -1
31
+ ? `thead th[data-column="${nextColumn}"] .data-table-heading`
32
+ : `tbody [data-row="${rows[nextRow]?.index}"][data-column="${nextColumn}"]`;
33
+ table?.querySelector<HTMLElement>(target)?.focus();
34
+ }
@@ -0,0 +1,196 @@
1
+ import {
2
+ autoUnit,
3
+ byteUnits,
4
+ compareValues,
5
+ filterPredicate,
6
+ parseBound,
7
+ timeUnits,
8
+ unitFactor,
9
+ type Column,
10
+ type Filter,
11
+ type Row,
12
+ type TableState,
13
+ } from "./model";
14
+
15
+ export type ActiveCell = { row: number; column: number } | null;
16
+ export type VisibleRow = { row: Row; index: number };
17
+
18
+ function normalizeFilter(filter: Filter, column: Column): Filter {
19
+ if (column.type === "text") {
20
+ return filter.pattern === undefined ? {} : { pattern: filter.pattern };
21
+ }
22
+
23
+ const options = column.type === "time" ? timeUnits : column.type === "bytes" ? byteUnits : [];
24
+ // A bound recorded in a different measurement must not be silently reinterpreted.
25
+ if (filter.unit && !options.includes(filter.unit)) return {};
26
+
27
+ return {
28
+ ...(filter.min === undefined ? {} : { min: filter.min }),
29
+ ...(filter.max === undefined ? {} : { max: filter.max }),
30
+ ...(filter.unit && options.includes(filter.unit) ? { unit: filter.unit } : {}),
31
+ };
32
+ }
33
+
34
+ /** Reconcile persisted preferences before they reach sorting, filters, or labels. */
35
+ export function normalizeTableState(state: TableState, columns: Column[]): TableState {
36
+ const columnTypes = Object.fromEntries(columns.map((column) => [column.key, column.type]));
37
+ const normalized: TableState = { columnTypes, filters: {}, units: {} };
38
+
39
+ if (state.sort && columns.some((column) => column.key === state.sort?.key)) {
40
+ normalized.sort = state.sort;
41
+ }
42
+
43
+ for (const column of columns) {
44
+ const previousType = state.columnTypes?.[column.key];
45
+ if (previousType && previousType !== column.type) continue;
46
+
47
+ const filter = state.filters[column.key];
48
+ if (filter) {
49
+ const compatible = normalizeFilter(filter, column);
50
+ if (Object.keys(compatible).length) normalized.filters[column.key] = compatible;
51
+ }
52
+
53
+ const options = column.type === "time" ? timeUnits : column.type === "bytes" ? byteUnits : [];
54
+ const unit = state.units[column.key];
55
+ if (options.length && (unit === "auto" || options.includes(unit))) {
56
+ normalized.units[column.key] = unit;
57
+ }
58
+ }
59
+
60
+ // Keep the same reference when already normalized so persistence does not cause render loops.
61
+ return JSON.stringify(normalized) === JSON.stringify(state) ? state : normalized;
62
+ }
63
+
64
+ export function deriveTableView(columns: Column[], data: Row[], state: TableState) {
65
+ const units = Object.fromEntries(
66
+ columns.map((column) => {
67
+ const options = column.type === "time" ? timeUnits : column.type === "bytes" ? byteUnits : [];
68
+ return [
69
+ column.key,
70
+ options.includes(state.units[column.key])
71
+ ? state.units[column.key]
72
+ : autoUnit(
73
+ column,
74
+ data.map((row) => row[column.key] ?? null),
75
+ ),
76
+ ];
77
+ }),
78
+ );
79
+
80
+ const errors: Record<string, string> = {};
81
+ const predicates = columns.map((column) => {
82
+ try {
83
+ return {
84
+ key: column.key,
85
+ match: filterPredicate(
86
+ column,
87
+ { ...state.filters[column.key], caseSensitive: false },
88
+ units[column.key],
89
+ ),
90
+ };
91
+ } catch (error) {
92
+ errors[column.key] = error instanceof Error ? error.message : "Invalid filter";
93
+ return { key: column.key, match: () => true };
94
+ }
95
+ });
96
+
97
+ const rows = data
98
+ .map((row, index) => ({ row, index }))
99
+ .filter(({ row }) => predicates.every(({ key, match }) => match(row[key] ?? null)));
100
+
101
+ const sort = state.sort;
102
+ if (sort && columns.some((column) => column.key === sort.key))
103
+ rows.sort(
104
+ (left, right) =>
105
+ compareValues(left.row[sort.key] ?? null, right.row[sort.key] ?? null, sort.descending) ||
106
+ left.index - right.index,
107
+ );
108
+
109
+ const bounds = Object.fromEntries(
110
+ columns
111
+ .filter((column) => column.bars)
112
+ .map((column) => {
113
+ const values = data
114
+ .map((row) => row[column.key])
115
+ .filter((value): value is number => typeof value === "number");
116
+ return [column.key, [Math.min(0, ...values), Math.max(0, ...values)]];
117
+ }),
118
+ );
119
+
120
+ const groups: { label: string; columns: Column[] }[] = [];
121
+ for (const column of columns) {
122
+ const label = column.group ?? "";
123
+ const last = groups.at(-1);
124
+ if (label && last?.label === label) last.columns.push(column);
125
+ else groups.push({ label, columns: [column] });
126
+ }
127
+
128
+ return { units, errors, rows, bounds, groups };
129
+ }
130
+
131
+ export function updateFilter(state: TableState, key: string, patch: Partial<Filter>): TableState {
132
+ return { ...state, filters: { ...state.filters, [key]: { ...state.filters[key], ...patch } } };
133
+ }
134
+
135
+ export function clearFilter(state: TableState, key: string): TableState {
136
+ return { ...state, filters: { ...state.filters, [key]: {} } };
137
+ }
138
+
139
+ export function cycleSort(state: TableState, key: string): TableState {
140
+ const sort =
141
+ state.sort?.key !== key
142
+ ? { key, descending: false }
143
+ : state.sort.descending
144
+ ? undefined
145
+ : { key, descending: true };
146
+
147
+ return { ...state, sort };
148
+ }
149
+
150
+ export function hasFilter(filter: Filter = {}): boolean {
151
+ return Object.entries(filter).some(
152
+ ([key, value]) => key !== "unit" && typeof value === "string" && value !== "",
153
+ );
154
+ }
155
+
156
+ export function changeUnit(
157
+ state: TableState,
158
+ column: Column,
159
+ selected: string,
160
+ data: Row[],
161
+ displayUnit: string,
162
+ ): TableState {
163
+ if (column.type !== "time" && column.type !== "bytes") return state;
164
+ const nextUnit =
165
+ selected === "auto"
166
+ ? autoUnit(
167
+ column,
168
+ data.map((row) => row[column.key] ?? null),
169
+ )
170
+ : selected;
171
+ const nextFilter = { ...state.filters[column.key] };
172
+ for (const bound of ["min", "max"] as const) {
173
+ try {
174
+ const value = parseBound(nextFilter[bound] ?? "", column, nextFilter.unit ?? displayUnit);
175
+ if (value !== undefined)
176
+ nextFilter[bound] = String(
177
+ (value * unitFactor(column.type, column.unit)) / unitFactor(column.type, nextUnit),
178
+ );
179
+ } catch {
180
+ /* retain invalid input for correction */
181
+ }
182
+ }
183
+ nextFilter.unit = nextUnit;
184
+ return {
185
+ ...state,
186
+ units: { ...state.units, [column.key]: selected },
187
+ filters: { ...state.filters, [column.key]: nextFilter },
188
+ };
189
+ }
190
+
191
+ export function filterSummary(column: Column, filter: Filter = {}): string {
192
+ if (column.type === "text") return filter.pattern ?? "";
193
+
194
+ const unit = column.type === "percent" ? "%" : filter.unit ? ` ${filter.unit}` : "";
195
+ return `${filter.min || "Any"} – ${filter.max || "Any"}${unit}`;
196
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
3
+
4
+ function observeColumnMenu(
5
+ anchor: HTMLDivElement,
6
+ popup: HTMLDivElement,
7
+ onPositioned: () => void,
8
+ onDismiss: () => void,
9
+ ) {
10
+ popup.inert = false;
11
+ popup.style.pointerEvents = "auto";
12
+ let disposed = false;
13
+
14
+ const cleanup = autoUpdate(anchor.closest("th") ?? anchor, popup, () => {
15
+ void computePosition(anchor.closest("th") ?? anchor, popup, {
16
+ placement: "bottom-start",
17
+ strategy: "fixed",
18
+ middleware: [
19
+ offset(6),
20
+ flip(),
21
+ shift({ padding: 8 }),
22
+ size({
23
+ padding: 8,
24
+ apply({ availableHeight }) {
25
+ popup.style.maxHeight = `${Math.max(0, availableHeight)}px`;
26
+ },
27
+ }),
28
+ ],
29
+ }).then(({ x, y }) => {
30
+ if (disposed) return;
31
+ Object.assign(popup.style, { left: `${x}px`, top: `${y}px` });
32
+ onPositioned();
33
+ });
34
+ });
35
+
36
+ const dismiss = (event: PointerEvent) => {
37
+ if (
38
+ !popup.contains(event.target as Node) &&
39
+ !anchor.parentElement?.contains(event.target as Node)
40
+ )
41
+ onDismiss();
42
+ };
43
+
44
+ document.addEventListener("pointerdown", dismiss);
45
+
46
+ return () => {
47
+ disposed = true;
48
+ cleanup();
49
+ document.removeEventListener("pointerdown", dismiss);
50
+ popup.inert = true;
51
+ popup.style.pointerEvents = "none";
52
+ };
53
+ }
54
+
55
+ export function useColumnMenu(
56
+ open: boolean,
57
+ onOpenChange: (open: boolean) => void,
58
+ trigger: RefObject<HTMLDivElement | null>,
59
+ menu: RefObject<HTMLDivElement | null>,
60
+ ) {
61
+ const [positioned, setPositioned] = useState(false);
62
+ const change = useRef(onOpenChange);
63
+ change.current = onOpenChange;
64
+
65
+ useEffect(() => {
66
+ if (!open) {
67
+ setPositioned(false);
68
+ return;
69
+ }
70
+ if (!trigger.current || !menu.current) return;
71
+ return observeColumnMenu(
72
+ trigger.current,
73
+ menu.current,
74
+ () => setPositioned(true),
75
+ () => change.current(false),
76
+ );
77
+ }, [open, trigger, menu]);
78
+
79
+ useEffect(() => {
80
+ if (open && positioned) menu.current?.querySelector<HTMLInputElement>("input")?.focus();
81
+ }, [open, positioned, menu]);
82
+
83
+ return positioned;
84
+ }