@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.
- package/README.md +60 -10
- package/client/CodeBlock.tsx +24 -0
- package/client/DocView.tsx +9 -2
- package/client/ListingView.tsx +14 -10
- package/client/MarkdownTable.tsx +190 -0
- package/client/MdSection.tsx +56 -21
- package/client/MdSectionEditor.tsx +149 -2
- package/client/Mermaid.tsx +33 -7
- package/client/Settings.css +17 -0
- package/client/Settings.tsx +132 -0
- package/client/Table.tsx +2 -14
- package/client/app.css +339 -1
- package/client/block-state/README.md +39 -0
- package/client/block-state/storage.ts +73 -0
- package/client/block-state/useBlockState.ts +120 -0
- package/client/builtins/DataTable.tsx +269 -0
- package/client/builtins/chart-data.ts +22 -10
- package/client/builtins/index.ts +10 -0
- package/client/data-table/ColumnMenu.tsx +168 -0
- package/client/data-table/ColumnOptions.tsx +141 -0
- package/client/data-table/TableCell.tsx +76 -0
- package/client/data-table/TableHeaderCell.tsx +94 -0
- package/client/data-table/model.ts +194 -0
- package/client/data-table/navigation.ts +34 -0
- package/client/data-table/table-state.ts +196 -0
- package/client/data-table/useColumnMenu.ts +84 -0
- package/client/data-table/useTableState.ts +36 -0
- package/client/design/base/prose.css +4 -0
- package/client/diagrams/Dialog.tsx +577 -0
- package/client/diagrams/EditDialog.tsx +67 -0
- package/client/diagrams/Preferences.tsx +213 -0
- package/client/diagrams/edit-block.ts +93 -0
- package/client/diagrams/edit-context.ts +4 -0
- package/client/diagrams/session-id.ts +8 -0
- package/client/diagrams/slash.ts +94 -0
- package/client/diagrams/source.ts +73 -0
- package/client/diagrams/style.css +599 -0
- package/client/entry.tsx +2 -0
- package/client/mdx-components-base.ts +2 -2
- package/client/router.ts +8 -1
- package/client/shell/AppShell.tsx +38 -15
- package/client/shell/Sidebar.tsx +20 -2
- package/client/shell/StandaloneShell.tsx +19 -6
- package/client/shell/TocRail.tsx +4 -3
- package/client/shell/TopBar.tsx +17 -5
- package/client/state.ts +14 -0
- package/client/ui/ConfirmDeleteDialog.tsx +48 -91
- package/client/ui/ExpandModal.tsx +63 -104
- package/client/ui/Modal.tsx +123 -0
- package/client/ui/ResizeHandle.tsx +1 -1
- package/client/ui/SearchDialog.tsx +81 -88
- package/client/ui/modal.css +42 -0
- package/dist/cli.js +2191 -778
- package/dist/diagram-worker.js +17 -0
- package/dist/registry.json +297 -0
- package/package.json +42 -2
- 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
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
package/client/builtins/index.ts
CHANGED
|
@@ -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
|
+
}
|