@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,269 @@
1
+ import { useRef, useState, type ReactNode, type TableHTMLAttributes } from "react";
2
+ import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
3
+ import { z } from "zod";
4
+ import { TableHeaderCell } from "../data-table/TableHeaderCell";
5
+ import { useTableState } from "../data-table/useTableState";
6
+ import {
7
+ clearFilter,
8
+ deriveTableView,
9
+ hasFilter,
10
+ filterSummary,
11
+ type ActiveCell,
12
+ } from "../data-table/table-state";
13
+ import { TableCell } from "../data-table/TableCell";
14
+ import { useDocumentBlockScope } from "../block-state/useBlockState";
15
+ import { blockStateKey, type BlockStateScope } from "../block-state/storage";
16
+ import { columnSchema, unitFactor } from "../data-table/model";
17
+
18
+ export const dataTableProps = z.object({
19
+ id: z
20
+ .string()
21
+ .trim()
22
+ .min(1)
23
+ .describe(
24
+ "Required stable ID, unique within this document. Retain it across edits to preserve local sorting, filters and units.",
25
+ ),
26
+ caption: z.string().optional(),
27
+ columns: z
28
+ .array(columnSchema)
29
+ .min(1)
30
+ .describe(
31
+ "Ordered columns: key, label, type; optional group and bars. Time/bytes require a base unit. Percent data uses fractions (0.15 displays 15%). Currency requires format(value).",
32
+ ),
33
+ data: z.array(z.record(z.string(), z.union([z.string(), z.number().finite(), z.null()]))),
34
+ });
35
+
36
+ export type DataTableProps = z.infer<typeof dataTableProps>;
37
+ type InternalProps = DataTableProps & {
38
+ renderCell?: (row: number, key: string) => ReactNode;
39
+ renderHeader?: (key: string) => ReactNode;
40
+ tableProps?: Omit<TableHTMLAttributes<HTMLTableElement>, "children">;
41
+ persistState?: boolean;
42
+ };
43
+
44
+ function validateTableProps(props: InternalProps) {
45
+ const parsed = dataTableProps.parse(props);
46
+ const keys = new Set(parsed.columns.map((column) => column.key));
47
+ if (keys.size !== parsed.columns.length) throw new Error("DataTable column keys must be unique.");
48
+ for (const column of parsed.columns) {
49
+ if (column.type === "time" || column.type === "bytes") unitFactor(column.type, column.unit);
50
+ if (
51
+ parsed.data.some(
52
+ (row) =>
53
+ row[column.key] !== undefined &&
54
+ row[column.key] !== null &&
55
+ typeof row[column.key] !== (column.type === "text" ? "string" : "number"),
56
+ )
57
+ )
58
+ throw new Error(
59
+ `DataTable: ${column.key} requires ${column.type === "text" ? "text" : "numeric"} values.`,
60
+ );
61
+ }
62
+
63
+ return parsed;
64
+ }
65
+
66
+ export default function DataTable(props: InternalProps) {
67
+ const parsed = validateTableProps(props);
68
+
69
+ const scope = useDocumentBlockScope({ kind: "table", version: 1, blockId: parsed.id });
70
+
71
+ return (
72
+ <TableView
73
+ key={blockStateKey(scope)}
74
+ {...parsed}
75
+ renderCell={props.renderCell}
76
+ renderHeader={props.renderHeader}
77
+ tableProps={props.tableProps}
78
+ persistState={props.persistState}
79
+ scope={scope}
80
+ />
81
+ );
82
+ }
83
+
84
+ function TableView({
85
+ id,
86
+ caption,
87
+ columns,
88
+ data,
89
+ renderCell,
90
+ renderHeader,
91
+ tableProps,
92
+ persistState = true,
93
+ scope,
94
+ }: InternalProps & { scope: BlockStateScope }) {
95
+ const [state, setState] = useTableState(scope, columns, persistState);
96
+ const [openColumn, setOpenColumn] = useState<string | null>(null);
97
+ const [active, setActive] = useState<ActiveCell>(null);
98
+ const tableRef = useRef<HTMLTableElement>(null);
99
+ const reducedMotion = useReducedMotion();
100
+
101
+ const { units, errors, rows, bounds, groups } = deriveTableView(columns, data, state);
102
+ const activeVisible =
103
+ active &&
104
+ (active.row === -1 || rows.some(({ index }) => index === active.row)) &&
105
+ active.column < columns.length;
106
+ const filteredColumns = columns.filter((column) => hasFilter(state.filters[column.key]));
107
+
108
+ const headerProps = {
109
+ renderHeader,
110
+ columnCount: columns.length,
111
+ data,
112
+ rows,
113
+ state,
114
+ setState,
115
+ units,
116
+ errors,
117
+ active,
118
+ activeVisible: Boolean(activeVisible),
119
+ setActive,
120
+ openColumn,
121
+ setOpenColumn,
122
+ tableRef,
123
+ };
124
+
125
+ return (
126
+ <div className="data-table not-prose" data-table-id={id} data-table-persist={persistState}>
127
+ <div className="data-table-toolbar">
128
+ <span>{caption ?? "Table"}</span>
129
+ <span className="data-table-count">{data.length} rows</span>
130
+ </div>
131
+ {(state.sort || filteredColumns.length > 0) && (
132
+ <div className="data-table-chips">
133
+ {state.sort && (
134
+ <button
135
+ type="button"
136
+ aria-label="Clear sorting"
137
+ onClick={() => setState((previous) => ({ ...previous, sort: undefined }))}
138
+ >
139
+ {columns.find((column) => column.key === state.sort?.key)?.label}{" "}
140
+ {state.sort.descending ? "↓" : "↑"} <span aria-hidden="true">×</span>
141
+ </button>
142
+ )}
143
+ {filteredColumns.map((column) => (
144
+ <button
145
+ type="button"
146
+ key={column.key}
147
+ aria-label={`Clear ${column.label} filter`}
148
+ onClick={() => setState((previous) => clearFilter(previous, column.key))}
149
+ >
150
+ {column.label}: {filterSummary(column, state.filters[column.key])}{" "}
151
+ <span aria-hidden="true">×</span>
152
+ </button>
153
+ ))}
154
+ </div>
155
+ )}
156
+
157
+ <div className="data-table-scroll">
158
+ <table aria-label={caption ?? "Data table"} {...tableProps} ref={tableRef}>
159
+ <thead>
160
+ {columns.some((column) => column.group) ? (
161
+ <>
162
+ <tr className="data-table-groups">
163
+ {groups.map((group) =>
164
+ group.label ? (
165
+ <th
166
+ key={group.columns[0].key}
167
+ colSpan={group.columns.length}
168
+ scope="colgroup"
169
+ >
170
+ {group.label}
171
+ </th>
172
+ ) : (
173
+ <TableHeaderCell
174
+ key={group.columns[0].key}
175
+ {...headerProps}
176
+ column={group.columns[0]}
177
+ columnIndex={columns.indexOf(group.columns[0])}
178
+ rowSpan={2}
179
+ />
180
+ ),
181
+ )}
182
+ </tr>
183
+ <tr>
184
+ {groups
185
+ .filter((group) => group.label)
186
+ .flatMap((group) =>
187
+ group.columns.map((column) => (
188
+ <TableHeaderCell
189
+ key={column.key}
190
+ {...headerProps}
191
+ column={column}
192
+ columnIndex={columns.indexOf(column)}
193
+ />
194
+ )),
195
+ )}
196
+ </tr>
197
+ </>
198
+ ) : (
199
+ <tr>
200
+ {columns.map((column) => (
201
+ <TableHeaderCell
202
+ key={column.key}
203
+ {...headerProps}
204
+ column={column}
205
+ columnIndex={columns.indexOf(column)}
206
+ />
207
+ ))}
208
+ </tr>
209
+ )}
210
+ </thead>
211
+ <tbody>
212
+ <AnimatePresence initial={false}>
213
+ {rows.map(({ row, index }, rowIndex) => (
214
+ <motion.tr
215
+ key={index}
216
+ layout={!reducedMotion}
217
+ initial={reducedMotion ? false : { opacity: 0 }}
218
+ animate={{ opacity: 1 }}
219
+ exit={{ opacity: 0 }}
220
+ transition={{ duration: reducedMotion ? 0 : 0.18 }}
221
+ >
222
+ {columns.map((column, columnIndex) => (
223
+ <TableCell
224
+ key={column.key}
225
+ column={column}
226
+ columnIndex={columnIndex}
227
+ columnCount={columns.length}
228
+ row={row}
229
+ index={index}
230
+ rowIndex={rowIndex}
231
+ rows={rows}
232
+ unit={units[column.key]}
233
+ limits={bounds[column.key]}
234
+ active={active}
235
+ setActive={setActive}
236
+ tableRef={tableRef}
237
+ renderCell={renderCell}
238
+ />
239
+ ))}
240
+ </motion.tr>
241
+ ))}
242
+ </AnimatePresence>
243
+ {rows.length === 0 && (
244
+ <tr>
245
+ <td colSpan={columns.length}>No matching rows. Adjust or reset filters.</td>
246
+ </tr>
247
+ )}
248
+ </tbody>
249
+ </table>
250
+ </div>
251
+ <div className="data-table-note">
252
+ <span aria-live="polite">
253
+ {rows.length} of {data.length} rows
254
+ </span>
255
+ {columns.some((column) => column.bars) && <span>Lines compare within each column</span>}
256
+ </div>
257
+ {Object.keys(errors).length > 0 && (
258
+ <div role="alert" className="data-table-error">
259
+ Invalid filter:{" "}
260
+ {columns
261
+ .filter((column) => errors[column.key])
262
+ .map((column) => column.label)
263
+ .join(", ")}
264
+ . Open its column menu to correct it.
265
+ </div>
266
+ )}
267
+ </div>
268
+ );
269
+ }
@@ -81,22 +81,34 @@ export function binValues(values: number[], bins?: number): HistogramBin[] {
81
81
  return [{ label: formatBinEdge(min, 1), value: finite.length, start: min, end: max }];
82
82
  }
83
83
  const count = clamp(bins ?? autoBinCount(finite.length), 1, 50);
84
- const step = (max - min) / count;
85
- const counts = new Array(count).fill(0) as number[];
86
- for (const value of finite) {
87
- const index = Math.min(count - 1, Math.floor(((value - min) / (max - min)) * count));
88
- counts[index]++;
89
- }
90
- return counts.map((value, index) => {
91
- const start = min + step * index;
92
- const end = index === count - 1 ? max : min + step * (index + 1);
84
+ const range = max - min;
85
+ const step = range / count;
86
+ const edges = Array.from({ length: count + 1 }, (_element, index) => {
87
+ if (index === 0) return min;
88
+ if (index === count) return max;
89
+ const fraction = index / count;
90
+ // Interpolate the full range so a rounded subnormal step cannot accumulate error.
91
+ const edge = Number.isFinite(range)
92
+ ? min + range * fraction
93
+ : min * (1 - fraction) + max * fraction;
94
+ return clamp(edge, min, max);
95
+ });
96
+ const result = Array.from({ length: count }, (_element, index) => {
97
+ const start = edges[index]!;
98
+ const end = edges[index + 1]!;
93
99
  return {
94
100
  label: `${formatBinEdge(start, step)}–${formatBinEdge(end, step)}`,
95
- value,
101
+ value: 0,
96
102
  start,
97
103
  end,
98
104
  };
99
105
  });
106
+ for (const value of finite) {
107
+ // Use the rounded edges so boundary values agree with the displayed ranges.
108
+ const index = result.findIndex((bin, index) => index === count - 1 || value < bin.end);
109
+ result[index]!.value++;
110
+ }
111
+ return result;
100
112
  }
101
113
 
102
114
  /** A stride-thinned subset of `[0, edgeCount)`, always including the first
@@ -12,6 +12,7 @@ import FileTree, { fileTreeProps } from "./FileTree";
12
12
  import Chart, { chartProps } from "./Chart";
13
13
  import Sparkline, { sparklineProps } from "./Sparkline";
14
14
  import Dropdown, { Option, dropdownProps, optionProps } from "./Dropdown";
15
+ import DataTable, { dataTableProps } from "./DataTable";
15
16
  import Screenshot, { screenshotProps } from "./Screenshot";
16
17
 
17
18
  export interface BuiltinDefinition {
@@ -23,6 +24,15 @@ export interface BuiltinDefinition {
23
24
  }
24
25
 
25
26
  export const builtins: BuiltinDefinition[] = [
27
+ {
28
+ name: "DataTable",
29
+ description:
30
+ "A bordered, sortable, filterable data table with typed columns, unit selection, grouped headers and optional bars.",
31
+ whenToUse:
32
+ "Use for datasets and quantitative comparisons; use native Markdown tables for facts and prose. Required stable document-unique id. Columns have key, label, type (text/number/percent/time/bytes/currency), optional group and bars. Percent values are fractions (0.15 means 15%). Time and bytes require unit; currency requires a format function and numeric data. Bars are opt-in and independently scaled by column. Reader state is local to the document and ID.",
33
+ props: dataTableProps,
34
+ Component: DataTable,
35
+ },
26
36
  {
27
37
  name: "Callout",
28
38
  description:
@@ -0,0 +1,168 @@
1
+ import { useId, useRef, type ReactNode, type KeyboardEvent, type FocusEvent } from "react";
2
+ import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
3
+ import { enterTransition, exitTransition } from "../motion";
4
+ import { createPortal } from "react-dom";
5
+ import { useColumnMenu } from "./useColumnMenu";
6
+ import ChevronDown from "lucide-react/dist/esm/icons/chevron-down.mjs";
7
+
8
+ function closeColumnMenu(onOpenChange: (open: boolean) => void, trigger: HTMLDivElement | null) {
9
+ onOpenChange(false);
10
+ trigger?.focus();
11
+ }
12
+
13
+ function handleHeaderKey(
14
+ event: KeyboardEvent<HTMLDivElement>,
15
+ onSort: () => void,
16
+ onOpenChange: (open: boolean) => void,
17
+ onNavigate: (event: KeyboardEvent<HTMLDivElement>) => void,
18
+ ) {
19
+ if (event.target !== event.currentTarget) return;
20
+
21
+ if (event.key === " ") {
22
+ event.preventDefault();
23
+ onSort();
24
+ } else if (event.key === "Enter") {
25
+ event.preventDefault();
26
+ onOpenChange(true);
27
+ } else {
28
+ onNavigate(event);
29
+ }
30
+ }
31
+
32
+ function handleMenuKey(event: KeyboardEvent<HTMLDivElement>, close: () => void) {
33
+ if (event.key !== "Escape") return;
34
+
35
+ event.preventDefault();
36
+ event.stopPropagation();
37
+ close();
38
+ }
39
+
40
+ function handleMenuBlur(
41
+ event: FocusEvent<HTMLDivElement>,
42
+ trigger: HTMLDivElement | null,
43
+ onOpenChange: (open: boolean) => void,
44
+ ) {
45
+ const nextTarget = event.relatedTarget;
46
+ if (
47
+ !nextTarget ||
48
+ event.currentTarget.contains(nextTarget) ||
49
+ trigger?.parentElement?.contains(nextTarget)
50
+ )
51
+ return;
52
+
53
+ onOpenChange(false);
54
+ }
55
+
56
+ /** Portaled to escape the table scroller, positioned against the triggering header. */
57
+ export function ColumnMenu({
58
+ label,
59
+ headerContent,
60
+ unit,
61
+ indicator,
62
+ open,
63
+ onOpenChange,
64
+ onActivate,
65
+ onSort,
66
+ onNavigate,
67
+ tabIndex,
68
+ children,
69
+ }: {
70
+ label: string;
71
+ headerContent?: ReactNode;
72
+ unit?: string;
73
+ indicator?: string;
74
+ open: boolean;
75
+ onOpenChange: (open: boolean) => void;
76
+ onActivate: () => void;
77
+ onSort: () => void;
78
+ onNavigate: (event: KeyboardEvent<HTMLDivElement>) => void;
79
+ tabIndex: number;
80
+ children: (close: () => void) => ReactNode;
81
+ }) {
82
+ const trigger = useRef<HTMLDivElement>(null);
83
+ const menu = useRef<HTMLDivElement>(null);
84
+ const menuId = useId();
85
+ const reducedMotion = useReducedMotion();
86
+ const positioned = useColumnMenu(open, onOpenChange, trigger, menu);
87
+ const close = () => closeColumnMenu(onOpenChange, trigger.current);
88
+
89
+ return (
90
+ <>
91
+ <div className="data-table-header-control">
92
+ <div
93
+ ref={trigger}
94
+ role="button"
95
+ className="data-table-heading"
96
+ aria-label={`${label} column header`}
97
+ aria-keyshortcuts="Space Enter ArrowUp ArrowDown ArrowLeft ArrowRight"
98
+ tabIndex={tabIndex}
99
+ onFocus={onActivate}
100
+ aria-haspopup="dialog"
101
+ aria-expanded={open}
102
+ aria-controls={open ? menuId : undefined}
103
+ onClick={() => {
104
+ onActivate();
105
+ onOpenChange(false);
106
+ }}
107
+ onKeyDown={(event) => handleHeaderKey(event, onSort, onOpenChange, onNavigate)}
108
+ >
109
+ <span>{headerContent ?? label}</span>
110
+ {unit && <span className="data-table-unit">· {unit}</span>}
111
+ {indicator && <span className="data-table-indicator">{indicator}</span>}
112
+ </div>
113
+ <button
114
+ type="button"
115
+ className="data-table-header-chevron"
116
+ tabIndex={-1}
117
+ aria-label={`${label} column options`}
118
+ aria-haspopup="dialog"
119
+ aria-expanded={open}
120
+ aria-controls={open ? menuId : undefined}
121
+ onClick={() => {
122
+ onActivate();
123
+ if (open) close();
124
+ else onOpenChange(true);
125
+ }}
126
+ >
127
+ <ChevronDown size={12} aria-hidden="true" />
128
+ </button>
129
+ </div>
130
+ {typeof document !== "undefined" &&
131
+ createPortal(
132
+ <AnimatePresence>
133
+ {open && (
134
+ <motion.div
135
+ key="menu"
136
+ initial={{ opacity: 0, y: reducedMotion ? 0 : -4, scale: reducedMotion ? 1 : 0.98 }}
137
+ animate={
138
+ positioned
139
+ ? { opacity: 1, y: 0, scale: 1 }
140
+ : { opacity: 0, y: reducedMotion ? 0 : -4, scale: reducedMotion ? 1 : 0.98 }
141
+ }
142
+ exit={{
143
+ opacity: 0,
144
+ y: reducedMotion ? 0 : -3,
145
+ scale: reducedMotion ? 1 : 0.98,
146
+ transition: reducedMotion ? { duration: 0 } : exitTransition,
147
+ }}
148
+ transition={reducedMotion ? { duration: 0 } : enterTransition}
149
+ id={menuId}
150
+ ref={menu}
151
+ className="data-table-menu"
152
+ role="dialog"
153
+ aria-label={`${label} column options`}
154
+ style={{ transformOrigin: "top left" }}
155
+ onDoubleClick={(event) => event.stopPropagation()}
156
+ onKeyDown={(event) => handleMenuKey(event, close)}
157
+ onBlur={(event) => handleMenuBlur(event, trigger.current, onOpenChange)}
158
+ >
159
+ <div className="data-table-menu-title">{label}</div>
160
+ {children(close)}
161
+ </motion.div>
162
+ )}
163
+ </AnimatePresence>,
164
+ document.body,
165
+ )}
166
+ </>
167
+ );
168
+ }
@@ -0,0 +1,141 @@
1
+ import type { Dispatch, SetStateAction } from "react";
2
+ import { autoUnit, byteUnits, timeUnits, type Column, type Row, type TableState } from "./model";
3
+ import { changeUnit, clearFilter, hasFilter, updateFilter } from "./table-state";
4
+
5
+ interface ColumnOptionsProps {
6
+ column: Column;
7
+ data: Row[];
8
+ state: TableState;
9
+ setState: Dispatch<SetStateAction<TableState>>;
10
+ unit: string;
11
+ error?: string;
12
+ close: () => void;
13
+ }
14
+
15
+ export function ColumnOptions({
16
+ column,
17
+ data,
18
+ state,
19
+ setState,
20
+ unit,
21
+ error,
22
+ close,
23
+ }: ColumnOptionsProps) {
24
+ const sorted = state.sort?.key === column.key;
25
+ const filtered = hasFilter(state.filters[column.key]);
26
+
27
+ return (
28
+ <>
29
+ <div className="data-table-menu-sorting">
30
+ {[false, true].map((descending) => (
31
+ <button
32
+ key={String(descending)}
33
+ type="button"
34
+ aria-label={descending ? "Sort descending" : "Sort ascending"}
35
+ title={descending ? "Sort descending" : "Sort ascending"}
36
+ aria-pressed={sorted && state.sort?.descending === descending}
37
+ onClick={() => {
38
+ setState((previous) => ({
39
+ ...previous,
40
+ sort: { key: column.key, descending },
41
+ }));
42
+ close();
43
+ }}
44
+ >
45
+ <span aria-hidden="true">{descending ? "↓" : "↑"}</span>
46
+ </button>
47
+ ))}
48
+ </div>
49
+
50
+ <div className="data-table-menu-filter">
51
+ {column.type === "text" ? (
52
+ <>
53
+ <label>
54
+ Matches regular expression
55
+ <input
56
+ aria-label={`Filter ${column.label} by regular expression`}
57
+ placeholder="e.g. search|api"
58
+ value={state.filters[column.key]?.pattern ?? ""}
59
+ onChange={(event) =>
60
+ setState((previous) =>
61
+ updateFilter(previous, column.key, { pattern: event.target.value }),
62
+ )
63
+ }
64
+ />
65
+ </label>
66
+ </>
67
+ ) : (
68
+ <div className="data-table-range">
69
+ {(["min", "max"] as const).map((bound) => (
70
+ <label key={bound}>
71
+ {bound === "min" ? "Minimum" : "Maximum"}
72
+ {column.type === "percent"
73
+ ? " (%)"
74
+ : unit
75
+ ? ` (${state.filters[column.key]?.unit ?? unit})`
76
+ : ""}
77
+ <input
78
+ aria-label={`${column.label} ${bound}`}
79
+ placeholder="Any"
80
+ value={state.filters[column.key]?.[bound] ?? ""}
81
+ onChange={(event) =>
82
+ setState((previous) =>
83
+ updateFilter(previous, column.key, {
84
+ [bound]: event.target.value,
85
+ unit: state.filters[column.key]?.unit ?? unit,
86
+ }),
87
+ )
88
+ }
89
+ />
90
+ </label>
91
+ ))}
92
+ </div>
93
+ )}
94
+ {error && (
95
+ <div className="data-table-error" role="alert">
96
+ {error}
97
+ </div>
98
+ )}
99
+ </div>
100
+
101
+ {(column.type === "time" || column.type === "bytes") && (
102
+ <label className="data-table-menu-unit">
103
+ Display unit
104
+ <select
105
+ aria-label={`${column.label} display unit`}
106
+ value={state.units[column.key] ?? "auto"}
107
+ onChange={(event) =>
108
+ setState((previous) => changeUnit(previous, column, event.target.value, data, unit))
109
+ }
110
+ >
111
+ <option value="auto">
112
+ Auto (
113
+ {autoUnit(
114
+ column,
115
+ data.map((row) => row[column.key] ?? null),
116
+ )}
117
+ )
118
+ </option>
119
+ {(column.type === "time" ? timeUnits : byteUnits).map((unit) => (
120
+ <option key={unit}>{unit}</option>
121
+ ))}
122
+ </select>
123
+ </label>
124
+ )}
125
+
126
+ {(filtered || sorted) && (
127
+ <button
128
+ type="button"
129
+ className="data-table-clear"
130
+ onClick={() => {
131
+ setState((previous) => clearFilter(previous, column.key));
132
+ if (sorted) setState((previous) => ({ ...previous, sort: undefined }));
133
+ close();
134
+ }}
135
+ >
136
+ Clear sort and filter
137
+ </button>
138
+ )}
139
+ </>
140
+ );
141
+ }