foldkit-mixins-data-grid 0.1.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/LICENSE +21 -0
- package/README.md +275 -0
- package/dist/index.d.mts +375 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1846 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1846 @@
|
|
|
1
|
+
import { Attr, Behavior, Capability, Event, Layers, Slot, SlotView, Slots, Style } from "foldkit-mixins";
|
|
2
|
+
import { Theme } from "foldkit-mixins/theme";
|
|
3
|
+
import * as Mount from "foldkit/mount";
|
|
4
|
+
import { Effect, Match, Option, Queue, Schema, Stream } from "effect";
|
|
5
|
+
import { CellEditor, Clipboard, Columns, Fill, GridFocus, GridSelection, GridViewport, RowCount, RowStatus, VirtualGrid, addressableRows } from "foldkit-data-grid";
|
|
6
|
+
import { Move } from "foldkit-primitives/dom";
|
|
7
|
+
//#region src/slots.ts
|
|
8
|
+
const AriaRowCount = Attr.make("aria-rowcount");
|
|
9
|
+
const AriaColCount = Attr.make("aria-colcount");
|
|
10
|
+
const AriaRowIndex = Attr.make("aria-rowindex");
|
|
11
|
+
const AriaColIndex = Attr.make("aria-colindex");
|
|
12
|
+
const AriaActiveDescendant = Attr.make("aria-activedescendant");
|
|
13
|
+
const Tabindex = Attr.make("tabindex");
|
|
14
|
+
const AriaValueNow = Attr.make("aria-valuenow");
|
|
15
|
+
const AriaValueMin = Attr.make("aria-valuemin");
|
|
16
|
+
const AriaValueMax = Attr.make("aria-valuemax");
|
|
17
|
+
const Id = Attr.make("id");
|
|
18
|
+
const MouseDown = Event.make("mousedown");
|
|
19
|
+
const AriaHasPopup = Attr.make("aria-haspopup");
|
|
20
|
+
const geometry = [
|
|
21
|
+
"boxSizing",
|
|
22
|
+
"width",
|
|
23
|
+
"height",
|
|
24
|
+
"minWidth",
|
|
25
|
+
"maxWidth",
|
|
26
|
+
"flex",
|
|
27
|
+
"position",
|
|
28
|
+
"insetInlineStart",
|
|
29
|
+
"insetInlineEnd",
|
|
30
|
+
"top"
|
|
31
|
+
];
|
|
32
|
+
const GridSlots = Slots.define({
|
|
33
|
+
/** The scroll container: `role="grid"`, the one tab stop, and the keyboard. */
|
|
34
|
+
root: Slot.make({
|
|
35
|
+
capability: Capability.Focusable,
|
|
36
|
+
events: [Event.KeyDown],
|
|
37
|
+
attributes: [
|
|
38
|
+
Attr.Role,
|
|
39
|
+
Attr.AriaLabel,
|
|
40
|
+
AriaRowCount,
|
|
41
|
+
AriaColCount,
|
|
42
|
+
AriaActiveDescendant
|
|
43
|
+
],
|
|
44
|
+
protected: {
|
|
45
|
+
events: [Event.KeyDown],
|
|
46
|
+
attributes: [
|
|
47
|
+
Attr.Role,
|
|
48
|
+
Tabindex,
|
|
49
|
+
Id,
|
|
50
|
+
AriaRowCount,
|
|
51
|
+
AriaColCount,
|
|
52
|
+
AriaActiveDescendant
|
|
53
|
+
],
|
|
54
|
+
style: ["overflow", "position"]
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
/** The header row's group; it stays at the top while the body scrolls. */
|
|
58
|
+
header: Slot.make({
|
|
59
|
+
capability: Capability.Container,
|
|
60
|
+
protected: {
|
|
61
|
+
attributes: [Attr.Role],
|
|
62
|
+
style: ["position", "top"]
|
|
63
|
+
}
|
|
64
|
+
}),
|
|
65
|
+
headerRow: Slot.make({
|
|
66
|
+
capability: Capability.Container,
|
|
67
|
+
protected: {
|
|
68
|
+
attributes: [Attr.Role, AriaRowIndex],
|
|
69
|
+
style: geometry
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
/**
|
|
73
|
+
* One per drawn column. `data-pinned` is `start` or `end` on a pinned
|
|
74
|
+
* column; while a header is dragged it has `data-dragging` and follows the
|
|
75
|
+
* pointer by a `transform`, and the shown neighbour it would land beside
|
|
76
|
+
* has `data-drop`, `before` or `after`.
|
|
77
|
+
*/
|
|
78
|
+
headerCell: Slot.make({
|
|
79
|
+
capability: Capability.Base,
|
|
80
|
+
protected: {
|
|
81
|
+
attributes: [Attr.Role, AriaColIndex],
|
|
82
|
+
style: [...geometry, "transform"]
|
|
83
|
+
}
|
|
84
|
+
}),
|
|
85
|
+
/**
|
|
86
|
+
* A column's resize handle at its end edge: a `role="separator"` with its
|
|
87
|
+
* width as `aria-valuenow`, dragged with the pointer, or stepped with the
|
|
88
|
+
* arrow keys once focused. It is out of the tab order, so the grid stays
|
|
89
|
+
* one tab stop. Only resizable columns have one.
|
|
90
|
+
*/
|
|
91
|
+
resizeHandle: Slot.make({
|
|
92
|
+
capability: Capability.Focusable,
|
|
93
|
+
events: [Event.KeyDown],
|
|
94
|
+
protected: {
|
|
95
|
+
events: [Event.KeyDown],
|
|
96
|
+
attributes: [
|
|
97
|
+
Attr.Role,
|
|
98
|
+
Tabindex,
|
|
99
|
+
AriaValueNow,
|
|
100
|
+
AriaValueMin,
|
|
101
|
+
AriaValueMax
|
|
102
|
+
],
|
|
103
|
+
style: [
|
|
104
|
+
"position",
|
|
105
|
+
"insetInlineEnd",
|
|
106
|
+
"top",
|
|
107
|
+
"bottom"
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
body: Slot.make({
|
|
112
|
+
capability: Capability.Container,
|
|
113
|
+
protected: {
|
|
114
|
+
attributes: [Attr.Role],
|
|
115
|
+
style: geometry
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
/** One per drawn row; `aria-rowindex` is its place among every row, drawn or not. */
|
|
119
|
+
row: Slot.make({
|
|
120
|
+
capability: Capability.Container,
|
|
121
|
+
protected: {
|
|
122
|
+
attributes: [Attr.Role, AriaRowIndex],
|
|
123
|
+
style: geometry
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
/** One per drawn cell; `data-focused` marks the current one, `data-pinned` a pinned one. */
|
|
127
|
+
cell: Slot.make({
|
|
128
|
+
capability: Capability.Base,
|
|
129
|
+
events: [MouseDown],
|
|
130
|
+
protected: {
|
|
131
|
+
events: [MouseDown],
|
|
132
|
+
attributes: [
|
|
133
|
+
Attr.Role,
|
|
134
|
+
Id,
|
|
135
|
+
AriaColIndex
|
|
136
|
+
],
|
|
137
|
+
style: geometry
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
/** A row counted but not loaded yet, drawn at its height for a skeleton to style. */
|
|
141
|
+
placeholder: Slot.make({
|
|
142
|
+
capability: Capability.Base,
|
|
143
|
+
protected: { style: ["height", "width"] }
|
|
144
|
+
}),
|
|
145
|
+
/**
|
|
146
|
+
* The text field a cell is edited in, drawn in place of its content while
|
|
147
|
+
* the edit lasts; `aria-invalid` when the column refused the draft.
|
|
148
|
+
*/
|
|
149
|
+
editor: Slot.make({
|
|
150
|
+
capability: Capability.TextInput,
|
|
151
|
+
events: [
|
|
152
|
+
Event.Input,
|
|
153
|
+
Event.KeyDown,
|
|
154
|
+
Event.Blur
|
|
155
|
+
],
|
|
156
|
+
protected: { events: [
|
|
157
|
+
Event.Input,
|
|
158
|
+
Event.KeyDown,
|
|
159
|
+
Event.Blur
|
|
160
|
+
] }
|
|
161
|
+
}),
|
|
162
|
+
/**
|
|
163
|
+
* Why the column refused a draft, shown below the field while the edit
|
|
164
|
+
* lasts; the field names it with `aria-describedby`.
|
|
165
|
+
*/
|
|
166
|
+
editorError: Slot.make({ capability: Capability.Base }),
|
|
167
|
+
/**
|
|
168
|
+
* The editor of a column whose schema's text is one of a few literals,
|
|
169
|
+
* drawn in place of the cell's content while the edit lasts, like
|
|
170
|
+
* `editor`: a `role="combobox"` showing the draft, its options in
|
|
171
|
+
* `choiceList` (or, with `choiceEditor: 'native'`, a `select`).
|
|
172
|
+
*/
|
|
173
|
+
choice: Slot.make({
|
|
174
|
+
capability: Capability.Focusable,
|
|
175
|
+
events: [
|
|
176
|
+
Event.Change,
|
|
177
|
+
Event.KeyDown,
|
|
178
|
+
Event.Blur
|
|
179
|
+
],
|
|
180
|
+
protected: {
|
|
181
|
+
events: [
|
|
182
|
+
Event.Change,
|
|
183
|
+
Event.KeyDown,
|
|
184
|
+
Event.Blur
|
|
185
|
+
],
|
|
186
|
+
attributes: [
|
|
187
|
+
Attr.Role,
|
|
188
|
+
Tabindex,
|
|
189
|
+
AriaActiveDescendant
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
}),
|
|
193
|
+
/**
|
|
194
|
+
* A choice's options, `role="listbox"`, below the cell, or above it when
|
|
195
|
+
* the cell is low in the view (`data-place="above"`).
|
|
196
|
+
*/
|
|
197
|
+
choiceList: Slot.make({
|
|
198
|
+
capability: Capability.Base,
|
|
199
|
+
protected: {
|
|
200
|
+
attributes: [Attr.Role, Id],
|
|
201
|
+
style: ["position"]
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
/** One literal a `choice` offers: `role="option"`, `aria-selected` on the draft. */
|
|
205
|
+
choiceOption: Slot.make({
|
|
206
|
+
capability: Capability.Base,
|
|
207
|
+
events: [Event.Click],
|
|
208
|
+
protected: {
|
|
209
|
+
events: [Event.Click],
|
|
210
|
+
attributes: [Attr.Role, Id]
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
213
|
+
/** Said in place of rows when there are none: empty, loading, or failed. */
|
|
214
|
+
status: Slot.make({ capability: Capability.Base }),
|
|
215
|
+
/**
|
|
216
|
+
* The button on a column's header that opens its menu, when the view is
|
|
217
|
+
* given `columnMenu`: `aria-haspopup="menu"` and `aria-expanded`, out of
|
|
218
|
+
* the tab order like the grid's other controls.
|
|
219
|
+
*/
|
|
220
|
+
menuButton: Slot.make({
|
|
221
|
+
capability: Capability.Interactive,
|
|
222
|
+
events: [Event.Click],
|
|
223
|
+
attributes: [Attr.AriaLabel],
|
|
224
|
+
protected: {
|
|
225
|
+
events: [Event.Click],
|
|
226
|
+
attributes: [
|
|
227
|
+
AriaHasPopup,
|
|
228
|
+
Attr.AriaExpanded,
|
|
229
|
+
Tabindex
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
}),
|
|
233
|
+
/**
|
|
234
|
+
* A column's open menu, below its header: a `role="menu"` that takes focus
|
|
235
|
+
* and points at its active item with `aria-activedescendant`, as the grid
|
|
236
|
+
* points at its cell. Focus leaving it closes it.
|
|
237
|
+
*/
|
|
238
|
+
menu: Slot.make({
|
|
239
|
+
capability: Capability.Focusable,
|
|
240
|
+
events: [Event.KeyDown],
|
|
241
|
+
protected: {
|
|
242
|
+
events: [Event.KeyDown],
|
|
243
|
+
attributes: [
|
|
244
|
+
Attr.Role,
|
|
245
|
+
Tabindex,
|
|
246
|
+
AriaActiveDescendant
|
|
247
|
+
],
|
|
248
|
+
style: ["position"]
|
|
249
|
+
}
|
|
250
|
+
}),
|
|
251
|
+
/** One of a menu's items; `data-active` marks the one the keyboard is on. */
|
|
252
|
+
menuItem: Slot.make({
|
|
253
|
+
capability: Capability.Base,
|
|
254
|
+
events: [Event.Click],
|
|
255
|
+
protected: {
|
|
256
|
+
events: [Event.Click],
|
|
257
|
+
attributes: [Attr.Role, Id]
|
|
258
|
+
}
|
|
259
|
+
}),
|
|
260
|
+
/**
|
|
261
|
+
* The button on a sortable column's header: a click sends its sort Message.
|
|
262
|
+
* `data-sort` is `asc` or `desc` while the column is sorted.
|
|
263
|
+
*/
|
|
264
|
+
sort: Slot.make({
|
|
265
|
+
capability: Capability.Interactive,
|
|
266
|
+
events: [Event.Click]
|
|
267
|
+
}),
|
|
268
|
+
/**
|
|
269
|
+
* The square at the end corner of the range, or of the focused cell, in a
|
|
270
|
+
* grid that edits: dragged, it fills the cells it is drawn over. Pointer
|
|
271
|
+
* only and hidden from assistive technology; Ctrl+D and Ctrl+R fill by key.
|
|
272
|
+
*/
|
|
273
|
+
fillHandle: Slot.make({
|
|
274
|
+
capability: Capability.Base,
|
|
275
|
+
protected: { style: [
|
|
276
|
+
"position",
|
|
277
|
+
"insetInlineEnd",
|
|
278
|
+
"insetBlockEnd"
|
|
279
|
+
] }
|
|
280
|
+
}),
|
|
281
|
+
/** Below the rows: a failure that left them on screen, and the More button. */
|
|
282
|
+
footer: Slot.make({ capability: Capability.Container }),
|
|
283
|
+
retry: Slot.make({
|
|
284
|
+
capability: Capability.Interactive,
|
|
285
|
+
events: [Event.Click]
|
|
286
|
+
}),
|
|
287
|
+
more: Slot.make({
|
|
288
|
+
capability: Capability.Interactive,
|
|
289
|
+
events: [Event.Click]
|
|
290
|
+
})
|
|
291
|
+
});
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/style.ts
|
|
294
|
+
/**
|
|
295
|
+
* The grid's default look, for a page that ships `foldkit-mixins/theme`'s
|
|
296
|
+
* tokens and palette. It only paints: the geometry the virtual window
|
|
297
|
+
* depends on is the view's, and the slots refuse a Style that sets it.
|
|
298
|
+
*/
|
|
299
|
+
/** The tokens the grid's styles draw with; the marks' too (`marks.ts`). */
|
|
300
|
+
const ref = Theme.ref(Theme.compose(Theme.tokens, Theme.oklch({ accent: {
|
|
301
|
+
h: 0,
|
|
302
|
+
c: 0,
|
|
303
|
+
l: "50%"
|
|
304
|
+
} })));
|
|
305
|
+
const line = `${ref.border.thin} solid ${ref.outline.subtle}`;
|
|
306
|
+
const faint = `${ref.border.thin} solid color-mix(in oklch, ${ref.outline.subtle} 55%, transparent)`;
|
|
307
|
+
const pinnedEdge = [Style.nest("&[data-pinned-edge=\"start\"]", { borderInlineEnd: `${ref.border.thick} solid ${ref.outline.default}` }), Style.nest("&[data-pinned-edge=\"end\"]", { borderInlineStart: `${ref.border.thick} solid ${ref.outline.default}` })];
|
|
308
|
+
const padlock = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 7V5a3 3 0 0 1 6 0v2h.5A1.5 1.5 0 0 1 13 8.5v5a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13.5v-5A1.5 1.5 0 0 1 4.5 7H5zm1.5 0h3V5a1.5 1.5 0 0 0-3 0v2z'/%3E%3C/svg%3E")`;
|
|
309
|
+
const field = Style.self({
|
|
310
|
+
background: ref.surface.base,
|
|
311
|
+
border: "none",
|
|
312
|
+
caretColor: ref.outline.focus,
|
|
313
|
+
color: "inherit",
|
|
314
|
+
font: "inherit",
|
|
315
|
+
outline: `${ref.border.thick} solid ${ref.outline.focus}`,
|
|
316
|
+
outlineOffset: `calc(-1 * ${ref.border.thick})`,
|
|
317
|
+
paddingInline: ref.space.sm
|
|
318
|
+
});
|
|
319
|
+
const refused = Style.nest("&[aria-invalid=\"true\"]", {
|
|
320
|
+
outlineColor: ref.error.default,
|
|
321
|
+
caretColor: ref.error.default
|
|
322
|
+
});
|
|
323
|
+
/** Every piece is in the `components` layer, so an application's own layer wins. */
|
|
324
|
+
const GridStyle = Style.forSlots(GridSlots)({
|
|
325
|
+
root: Style.compose(Style.self({
|
|
326
|
+
background: ref.surface.base,
|
|
327
|
+
color: ref.text.default,
|
|
328
|
+
fontVariantNumeric: "tabular-nums"
|
|
329
|
+
}), Style.pseudo(":focus-visible", { outline: `${ref.border.thick} solid ${ref.outline.focus}` })),
|
|
330
|
+
header: Style.self({
|
|
331
|
+
zIndex: "2",
|
|
332
|
+
background: ref.surface.muted
|
|
333
|
+
}),
|
|
334
|
+
headerCell: Style.compose(Style.self({
|
|
335
|
+
alignItems: "center",
|
|
336
|
+
background: ref.surface.muted,
|
|
337
|
+
borderBlockEnd: line,
|
|
338
|
+
display: "flex",
|
|
339
|
+
fontSize: ref.size.sm,
|
|
340
|
+
fontWeight: ref.weight.semibold,
|
|
341
|
+
paddingInline: ref.space.sm,
|
|
342
|
+
whiteSpace: "nowrap",
|
|
343
|
+
userSelect: "none"
|
|
344
|
+
}), Style.nest("&[data-pinned]", { zIndex: "1" }), ...pinnedEdge, Style.nest("&[aria-readonly=\"true\"]::before", {
|
|
345
|
+
background: ref.text.muted,
|
|
346
|
+
content: "\"\"",
|
|
347
|
+
flex: "none",
|
|
348
|
+
height: "0.75em",
|
|
349
|
+
marginInlineEnd: ref.space["2xs"],
|
|
350
|
+
mask: `${padlock} center / contain no-repeat`,
|
|
351
|
+
width: "0.75em"
|
|
352
|
+
}), Style.nest("&[data-dragging]", {
|
|
353
|
+
zIndex: "3",
|
|
354
|
+
background: ref.surface.default,
|
|
355
|
+
opacity: "0.9",
|
|
356
|
+
cursor: "grabbing"
|
|
357
|
+
}), Style.nest("&[data-drop=\"before\"]", { borderInlineStart: `${ref.border.thick} solid ${ref.outline.focus}` }), Style.nest("&[data-drop=\"after\"]", { borderInlineEnd: `${ref.border.thick} solid ${ref.outline.focus}` })),
|
|
358
|
+
cell: Style.compose(Style.self({
|
|
359
|
+
alignItems: "center",
|
|
360
|
+
borderBlockEnd: line,
|
|
361
|
+
borderInlineEnd: faint,
|
|
362
|
+
display: "flex",
|
|
363
|
+
overflow: "hidden",
|
|
364
|
+
paddingInline: ref.space.sm,
|
|
365
|
+
textOverflow: "ellipsis",
|
|
366
|
+
whiteSpace: "nowrap"
|
|
367
|
+
}), Style.nest("&[data-pinned]", {
|
|
368
|
+
background: ref.surface.base,
|
|
369
|
+
zIndex: "1"
|
|
370
|
+
}), ...pinnedEdge, Style.nest("&[data-editable]", { cursor: "text" }), Style.nest("&[aria-readonly=\"true\"]", {
|
|
371
|
+
color: ref.text.muted,
|
|
372
|
+
cursor: "default"
|
|
373
|
+
}), Style.nest("&[data-editing]", {
|
|
374
|
+
boxShadow: ref.shadow.md,
|
|
375
|
+
overflow: "visible",
|
|
376
|
+
paddingInline: "0",
|
|
377
|
+
zIndex: "2"
|
|
378
|
+
}), Style.nest("&[data-focused=\"true\"]", {
|
|
379
|
+
outline: `${ref.border.thick} solid ${ref.outline.focus}`,
|
|
380
|
+
outlineOffset: `calc(-1 * ${ref.border.thick})`
|
|
381
|
+
}), Style.nest("&[data-fill=\"target\"]", {
|
|
382
|
+
outline: `${ref.border.thin} dashed ${ref.outline.focus}`,
|
|
383
|
+
outlineOffset: `calc(-1 * ${ref.border.thin})`
|
|
384
|
+
}), Style.nest("&[data-mark]", { backgroundImage: `radial-gradient(circle at calc(100% - 6px) 6px, ${ref.outline.focus} 3px, transparent 3.5px)` })),
|
|
385
|
+
editor: Style.compose(field, refused),
|
|
386
|
+
choice: Style.compose(field, refused, Style.self({
|
|
387
|
+
display: "flex",
|
|
388
|
+
alignItems: "center",
|
|
389
|
+
cursor: "default"
|
|
390
|
+
})),
|
|
391
|
+
choiceList: Style.compose(Style.self({
|
|
392
|
+
zIndex: "3",
|
|
393
|
+
minWidth: "100%",
|
|
394
|
+
maxHeight: "16rem",
|
|
395
|
+
overflowY: "auto",
|
|
396
|
+
margin: "0",
|
|
397
|
+
padding: ref.space["3xs"],
|
|
398
|
+
listStyle: "none",
|
|
399
|
+
background: ref.surface.base,
|
|
400
|
+
border: `${ref.border.thin} solid ${ref.outline.default}`,
|
|
401
|
+
borderRadius: ref.radius.md,
|
|
402
|
+
boxShadow: ref.shadow.lg
|
|
403
|
+
})),
|
|
404
|
+
choiceOption: Style.compose(Style.self({
|
|
405
|
+
padding: `${ref.space["2xs"]} ${ref.space.sm}`,
|
|
406
|
+
borderRadius: ref.radius.sm,
|
|
407
|
+
cursor: "pointer",
|
|
408
|
+
whiteSpace: "nowrap"
|
|
409
|
+
}), Style.nest("&:hover", { background: ref.surface.subtle }), Style.nest("&[aria-selected=\"true\"]", {
|
|
410
|
+
background: ref.accent.subtle,
|
|
411
|
+
color: ref.accent.ink
|
|
412
|
+
}), Style.media("(pointer: coarse)", {
|
|
413
|
+
minHeight: "44px",
|
|
414
|
+
display: "flex",
|
|
415
|
+
alignItems: "center"
|
|
416
|
+
})),
|
|
417
|
+
editorError: Style.self({
|
|
418
|
+
background: ref.error.default,
|
|
419
|
+
borderRadius: ref.radius.md,
|
|
420
|
+
boxShadow: ref.shadow.lg,
|
|
421
|
+
color: ref.error["on-fill"],
|
|
422
|
+
fontSize: ref.size.xs,
|
|
423
|
+
fontWeight: ref.weight.medium,
|
|
424
|
+
insetInlineStart: "0",
|
|
425
|
+
paddingBlock: ref.space["3xs"],
|
|
426
|
+
paddingInline: ref.space.xs,
|
|
427
|
+
pointerEvents: "none",
|
|
428
|
+
position: "absolute",
|
|
429
|
+
top: `calc(100% + ${ref.space["3xs"]})`,
|
|
430
|
+
whiteSpace: "nowrap",
|
|
431
|
+
zIndex: "5"
|
|
432
|
+
}),
|
|
433
|
+
resizeHandle: Style.compose(Style.self({ background: `linear-gradient(to right, transparent calc(100% - 1px), color-mix(in oklch, ${ref.outline.subtle} 70%, transparent) calc(100% - 1px))` }), Style.pseudo(":hover", { background: `linear-gradient(to right, transparent calc(100% - 2px), ${ref.outline.focus} calc(100% - 2px))` })),
|
|
434
|
+
fillHandle: Style.self({
|
|
435
|
+
width: "7px",
|
|
436
|
+
height: "7px",
|
|
437
|
+
background: ref.outline.focus,
|
|
438
|
+
border: `1px solid ${ref.surface.base}`,
|
|
439
|
+
cursor: "crosshair",
|
|
440
|
+
zIndex: "3"
|
|
441
|
+
}),
|
|
442
|
+
menuButton: Style.compose(Style.self({
|
|
443
|
+
background: "none",
|
|
444
|
+
border: "none",
|
|
445
|
+
color: ref.text.muted,
|
|
446
|
+
cursor: "pointer",
|
|
447
|
+
marginInlineStart: "auto",
|
|
448
|
+
paddingInline: ref.space.xs
|
|
449
|
+
}), Style.nest("&[aria-expanded=\"true\"]", { color: ref.text.default })),
|
|
450
|
+
menu: Style.compose(Style.self({
|
|
451
|
+
background: ref.surface.base,
|
|
452
|
+
border: line,
|
|
453
|
+
borderRadius: ref.radius.md,
|
|
454
|
+
fontWeight: ref.weight.normal,
|
|
455
|
+
minWidth: "10rem",
|
|
456
|
+
paddingBlock: ref.space.xs,
|
|
457
|
+
zIndex: "4"
|
|
458
|
+
}), Style.pseudo(":focus-visible", { outline: `${ref.border.thick} solid ${ref.outline.focus}` })),
|
|
459
|
+
menuItem: Style.compose(Style.self({
|
|
460
|
+
cursor: "pointer",
|
|
461
|
+
paddingBlock: ref.space.xs,
|
|
462
|
+
paddingInline: ref.space.sm
|
|
463
|
+
}), Style.nest("&[data-active=\"true\"]", { background: ref.surface.muted })),
|
|
464
|
+
sort: Style.compose(Style.self({
|
|
465
|
+
alignItems: "center",
|
|
466
|
+
background: "none",
|
|
467
|
+
border: "none",
|
|
468
|
+
color: "inherit",
|
|
469
|
+
cursor: "pointer",
|
|
470
|
+
display: "inline-flex",
|
|
471
|
+
font: "inherit",
|
|
472
|
+
gap: ref.space.xs,
|
|
473
|
+
minWidth: "0",
|
|
474
|
+
overflow: "hidden",
|
|
475
|
+
padding: "0",
|
|
476
|
+
textOverflow: "ellipsis",
|
|
477
|
+
whiteSpace: "nowrap"
|
|
478
|
+
}), Style.nest("&::after", {
|
|
479
|
+
borderBlockEnd: `1.5px solid currentColor`,
|
|
480
|
+
borderInlineEnd: `1.5px solid currentColor`,
|
|
481
|
+
content: "\"\"",
|
|
482
|
+
flex: "none",
|
|
483
|
+
height: "0.4em",
|
|
484
|
+
opacity: "0",
|
|
485
|
+
transform: "translateY(-0.15em) rotate(45deg)",
|
|
486
|
+
transition: `opacity ${ref.motion.fast} ${ref.motion.ease}, transform ${ref.motion.fast} ${ref.motion.ease}`,
|
|
487
|
+
width: "0.4em"
|
|
488
|
+
}), Style.nest("&:hover::after", { opacity: "0.35" }), Style.nest("&[data-sort=\"desc\"]::after", { opacity: "1" }), Style.nest("&[data-sort=\"asc\"]::after", {
|
|
489
|
+
opacity: "1",
|
|
490
|
+
transform: "translateY(0.1em) rotate(-135deg)"
|
|
491
|
+
})),
|
|
492
|
+
placeholder: Style.self({ background: ref.surface.subtle }),
|
|
493
|
+
status: Style.self({
|
|
494
|
+
color: ref.text.muted,
|
|
495
|
+
padding: ref.space.md
|
|
496
|
+
})
|
|
497
|
+
}, { layer: Layers.standard.layer("components") });
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/nearEnd.ts
|
|
500
|
+
const NearEnd = Schema.TaggedStruct("NearEnd", {});
|
|
501
|
+
/** How far ahead of the end the next rows are asked for, so they arrive before they are needed. */
|
|
502
|
+
const ahead = "200px";
|
|
503
|
+
/**
|
|
504
|
+
* The element coming within 200px of the grid's visible box: one report each
|
|
505
|
+
* time it comes in, measured against the scroll container it sits in, not
|
|
506
|
+
* the page. Not `watching` (a load in flight), or no observer (SSR, an old
|
|
507
|
+
* browser), it reports nothing.
|
|
508
|
+
*/
|
|
509
|
+
const Nearing = Mount.defineStream("DataGridNearEnd", {
|
|
510
|
+
args: { watching: Schema.Boolean },
|
|
511
|
+
messages: [NearEnd],
|
|
512
|
+
execute: ({ element, watching }) => Stream.callback((queue) => Effect.gen(function* () {
|
|
513
|
+
const Observer = globalThis.IntersectionObserver;
|
|
514
|
+
if (!watching || Observer === void 0) return;
|
|
515
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
516
|
+
const observer = new Observer((entries) => {
|
|
517
|
+
if (entries.some((entry) => entry.isIntersecting)) Queue.offerUnsafe(queue, NearEnd.make({}));
|
|
518
|
+
}, {
|
|
519
|
+
root: element.closest("[role=\"grid\"]"),
|
|
520
|
+
rootMargin: ahead
|
|
521
|
+
});
|
|
522
|
+
observer.observe(element);
|
|
523
|
+
return observer;
|
|
524
|
+
}), (observer) => Effect.sync(() => observer.disconnect()));
|
|
525
|
+
}))
|
|
526
|
+
});
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/fillDrag.ts
|
|
529
|
+
const FillDragStarted = Schema.TaggedStruct("FillDragStarted", {});
|
|
530
|
+
const FillDraggedOver = Schema.TaggedStruct("FillDraggedOver", {
|
|
531
|
+
/** The DOM id of the gridcell under the pointer. */
|
|
532
|
+
cell: Schema.String });
|
|
533
|
+
const FillDragEnded = Schema.TaggedStruct("FillDragEnded", {
|
|
534
|
+
/** `false` when the pointer was cancelled or capture was lost. */
|
|
535
|
+
completed: Schema.Boolean });
|
|
536
|
+
/** The attribute that marks the fill handle, which the body's listener looks for. */
|
|
537
|
+
const fillHandleAttribute = "data-fill-handle";
|
|
538
|
+
/** How far a press moves before it is a fill, so a stray click fills nothing. */
|
|
539
|
+
const threshold$1 = 4;
|
|
540
|
+
/** How near the grid's edge the pointer brings a scroll, and the fastest step a frame. */
|
|
541
|
+
const edge$1 = 24;
|
|
542
|
+
const fastest = 20;
|
|
543
|
+
/**
|
|
544
|
+
* The fill handle dragged with the pointer, from the element that holds the
|
|
545
|
+
* grid's cells, so the drag outlives the handle's own cell scrolling out of
|
|
546
|
+
* the window. A press on the handle is kept from the cells (a press there
|
|
547
|
+
* would select one instead). Once it has moved 4px the pointer is captured,
|
|
548
|
+
* so the cell under it is found by where it is (`elementFromPoint`), not by
|
|
549
|
+
* the event's target, which capture makes this element. While the pointer is
|
|
550
|
+
* within 24px of the grid's edge, or past it, the grid scrolls toward it. The
|
|
551
|
+
* listeners that follow the pointer, and the scroll, live for one press.
|
|
552
|
+
*
|
|
553
|
+
* Returns the cleanup, and whether a click now is the one a drag ended with.
|
|
554
|
+
*/
|
|
555
|
+
const fillDragOn = (element, offer) => {
|
|
556
|
+
const page = element.ownerDocument;
|
|
557
|
+
const view = page.defaultView;
|
|
558
|
+
let press = Option.none();
|
|
559
|
+
let dragEnded = false;
|
|
560
|
+
const pointerOf = (event) => event.pointerId ?? 0;
|
|
561
|
+
const grid = () => Option.fromNullishOr(element.closest("[role=\"grid\"]"));
|
|
562
|
+
const probe = ({ x, y }) => Option.match(grid(), {
|
|
563
|
+
onNone: () => ({
|
|
564
|
+
x,
|
|
565
|
+
y,
|
|
566
|
+
toward: {
|
|
567
|
+
x,
|
|
568
|
+
y
|
|
569
|
+
}
|
|
570
|
+
}),
|
|
571
|
+
onSome: (box) => {
|
|
572
|
+
const rect = box.getBoundingClientRect();
|
|
573
|
+
const header = box.querySelector("[role=\"row\"][aria-rowindex=\"1\"]");
|
|
574
|
+
const top = header === null ? rect.top : header.getBoundingClientRect().bottom;
|
|
575
|
+
const clamp = (at, from, to) => Math.max(from, Math.min(to, at));
|
|
576
|
+
const right = rect.left + box.clientWidth - 2;
|
|
577
|
+
const bottom = rect.top + box.clientHeight - 2;
|
|
578
|
+
return {
|
|
579
|
+
x: clamp(x, rect.left + 1, right),
|
|
580
|
+
y: clamp(y, top + 1, bottom),
|
|
581
|
+
toward: {
|
|
582
|
+
x: (rect.left + right) / 2,
|
|
583
|
+
y: (top + bottom) / 2
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
const cellAt = ({ x, y, toward }) => {
|
|
589
|
+
const steps = 48;
|
|
590
|
+
for (let step = 0; step <= steps; step++) {
|
|
591
|
+
const found = page.elementFromPoint(x + (toward.x - x) * step / steps, y + (toward.y - y) * step / steps)?.closest("[role=\"gridcell\"]");
|
|
592
|
+
if (found !== null && found !== void 0 && found.id !== "") return Option.some(found.id);
|
|
593
|
+
}
|
|
594
|
+
return Option.none();
|
|
595
|
+
};
|
|
596
|
+
const report = (current) => {
|
|
597
|
+
const found = cellAt(probe(current.at));
|
|
598
|
+
if (Option.isNone(found) || found.value === current.over) return;
|
|
599
|
+
current.over = found.value;
|
|
600
|
+
offer(FillDraggedOver.make({ cell: found.value }));
|
|
601
|
+
};
|
|
602
|
+
const stepOf = (at, start, end) => {
|
|
603
|
+
if (at < start + edge$1) return Math.max(-20, at - (start + edge$1));
|
|
604
|
+
if (at > end - edge$1) return Math.min(fastest, at - (end - edge$1));
|
|
605
|
+
return 0;
|
|
606
|
+
};
|
|
607
|
+
const scroll = (current) => {
|
|
608
|
+
current.frame = Option.none();
|
|
609
|
+
Option.match(grid(), {
|
|
610
|
+
onNone: () => {},
|
|
611
|
+
onSome: (box) => {
|
|
612
|
+
const rect = box.getBoundingClientRect();
|
|
613
|
+
const down = stepOf(current.at.y, rect.top, rect.bottom);
|
|
614
|
+
const across = stepOf(current.at.x, rect.left, rect.right);
|
|
615
|
+
if (down === 0 && across === 0) return;
|
|
616
|
+
report(current);
|
|
617
|
+
box.scrollBy(across, down);
|
|
618
|
+
if (view !== null) current.frame = Option.some(view.requestAnimationFrame(() => scroll(current)));
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
};
|
|
622
|
+
const release = (current) => {
|
|
623
|
+
page.removeEventListener("pointermove", onMove);
|
|
624
|
+
page.removeEventListener("pointerup", onUp);
|
|
625
|
+
page.removeEventListener("pointercancel", onCancel);
|
|
626
|
+
element.removeEventListener("lostpointercapture", onCancel);
|
|
627
|
+
if (Option.isSome(current.frame)) view?.cancelAnimationFrame(current.frame.value);
|
|
628
|
+
current.frame = Option.none();
|
|
629
|
+
if (!current.dragging) return;
|
|
630
|
+
try {
|
|
631
|
+
element.releasePointerCapture(current.pointerId);
|
|
632
|
+
} catch {}
|
|
633
|
+
};
|
|
634
|
+
const finish = (event, completed) => {
|
|
635
|
+
const found = Option.filter(press, (current) => current.pointerId === pointerOf(event));
|
|
636
|
+
if (Option.isNone(found)) return;
|
|
637
|
+
press = Option.none();
|
|
638
|
+
release(found.value);
|
|
639
|
+
if (!found.value.dragging) return;
|
|
640
|
+
dragEnded = true;
|
|
641
|
+
view?.setTimeout(() => dragEnded = false, 0);
|
|
642
|
+
offer(FillDragEnded.make({ completed }));
|
|
643
|
+
};
|
|
644
|
+
const onUp = (event) => finish(event, true);
|
|
645
|
+
const onCancel = (event) => finish(event, false);
|
|
646
|
+
const onMove = (event) => {
|
|
647
|
+
const found = Option.filter(press, (current) => current.pointerId === pointerOf(event));
|
|
648
|
+
if (Option.isNone(found)) return;
|
|
649
|
+
const current = found.value;
|
|
650
|
+
const pointer = event;
|
|
651
|
+
current.at = {
|
|
652
|
+
x: pointer.clientX ?? 0,
|
|
653
|
+
y: pointer.clientY ?? 0
|
|
654
|
+
};
|
|
655
|
+
if (!current.dragging) {
|
|
656
|
+
if (Math.max(Math.abs(current.at.x - current.x), Math.abs(current.at.y - current.y)) < threshold$1) return;
|
|
657
|
+
current.dragging = true;
|
|
658
|
+
try {
|
|
659
|
+
element.setPointerCapture(current.pointerId);
|
|
660
|
+
} catch {}
|
|
661
|
+
offer(FillDragStarted.make({}));
|
|
662
|
+
}
|
|
663
|
+
report(current);
|
|
664
|
+
if (Option.isNone(current.frame)) scroll(current);
|
|
665
|
+
};
|
|
666
|
+
const onDown = (event) => {
|
|
667
|
+
const pointer = event;
|
|
668
|
+
const from = event.target;
|
|
669
|
+
if (!(from instanceof Element) || from.closest(`[data-fill-handle]`) === null) return;
|
|
670
|
+
event.stopPropagation();
|
|
671
|
+
if ((pointer.button ?? 0) !== 0 || Option.isSome(press)) return;
|
|
672
|
+
event.preventDefault();
|
|
673
|
+
const x = pointer.clientX ?? 0;
|
|
674
|
+
const y = pointer.clientY ?? 0;
|
|
675
|
+
press = Option.some({
|
|
676
|
+
pointerId: pointerOf(event),
|
|
677
|
+
x,
|
|
678
|
+
y,
|
|
679
|
+
at: {
|
|
680
|
+
x,
|
|
681
|
+
y
|
|
682
|
+
},
|
|
683
|
+
dragging: false,
|
|
684
|
+
over: "",
|
|
685
|
+
frame: Option.none()
|
|
686
|
+
});
|
|
687
|
+
page.addEventListener("pointermove", onMove);
|
|
688
|
+
page.addEventListener("pointerup", onUp);
|
|
689
|
+
page.addEventListener("pointercancel", onCancel);
|
|
690
|
+
element.addEventListener("lostpointercapture", onCancel);
|
|
691
|
+
};
|
|
692
|
+
element.addEventListener("pointerdown", onDown);
|
|
693
|
+
return {
|
|
694
|
+
dispose: () => {
|
|
695
|
+
element.removeEventListener("pointerdown", onDown);
|
|
696
|
+
if (Option.isSome(press)) release(press.value);
|
|
697
|
+
press = Option.none();
|
|
698
|
+
},
|
|
699
|
+
owns: (event) => dragEnded || event.target instanceof Element && event.target.closest(`[data-fill-handle]`) !== null
|
|
700
|
+
};
|
|
701
|
+
};
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/holdFocus.ts
|
|
704
|
+
/**
|
|
705
|
+
* Focuses what the grid draws over itself while it is open (a cell's editor,
|
|
706
|
+
* with the caret after its text, or a column's menu) and hands focus back to
|
|
707
|
+
* the grid when it goes, so closing it leaves the keyboard on the grid rather
|
|
708
|
+
* than on the page's body. Focus the user moved elsewhere stays there. A
|
|
709
|
+
* choice's `select` also opens its list, so a double-click on the cell shows
|
|
710
|
+
* the choices at once rather than asking for another click.
|
|
711
|
+
*/
|
|
712
|
+
const HoldFocus = Mount.defineStream("DataGridHoldFocus", {
|
|
713
|
+
messages: [Schema.Never],
|
|
714
|
+
execute: ({ element }) => Stream.callback(() => Effect.acquireRelease(Effect.sync(() => {
|
|
715
|
+
const editor = element;
|
|
716
|
+
const grid = element.closest("[role=\"grid\"]");
|
|
717
|
+
editor.focus?.();
|
|
718
|
+
const end = editor.value?.length ?? 0;
|
|
719
|
+
editor.setSelectionRange?.(end, end);
|
|
720
|
+
if (element.tagName === "SELECT") try {
|
|
721
|
+
editor.showPicker?.();
|
|
722
|
+
} catch {}
|
|
723
|
+
return grid;
|
|
724
|
+
}), (grid) => Effect.sync(() => {
|
|
725
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
726
|
+
if (active === null || active === element || active === document.body) grid?.focus?.();
|
|
727
|
+
})))
|
|
728
|
+
});
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region src/keepFocus.ts
|
|
731
|
+
/**
|
|
732
|
+
* Keeps focus where it is when a press lands on the element: a choice's list
|
|
733
|
+
* sits beside its combobox, so a press on an option would otherwise blur the
|
|
734
|
+
* combobox, which commits the old draft before the option's click arrives.
|
|
735
|
+
* Attached only while the list is open.
|
|
736
|
+
*/
|
|
737
|
+
const KeepFocus = Mount.defineStream("DataGridKeepFocus", {
|
|
738
|
+
messages: [Schema.Never],
|
|
739
|
+
execute: ({ element }) => Stream.callback(() => Effect.acquireRelease(Effect.sync(() => {
|
|
740
|
+
const keep = (event) => event.preventDefault();
|
|
741
|
+
element.addEventListener("mousedown", keep);
|
|
742
|
+
return keep;
|
|
743
|
+
}), (keep) => Effect.sync(() => element.removeEventListener("mousedown", keep))))
|
|
744
|
+
});
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/headerDrag.ts
|
|
747
|
+
const HeaderDragStarted = Schema.TaggedStruct("HeaderDragStarted", {
|
|
748
|
+
/** The dragged columnheader's DOM id. */
|
|
749
|
+
header: Schema.String });
|
|
750
|
+
const HeaderDragged = Schema.TaggedStruct("HeaderDragged", {
|
|
751
|
+
/** From where the pointer went down, in CSS pixels. */
|
|
752
|
+
deltaX: Schema.Number });
|
|
753
|
+
const HeaderDragEnded = Schema.TaggedStruct("HeaderDragEnded", {
|
|
754
|
+
/** `false` when the pointer was cancelled or capture was lost. */
|
|
755
|
+
completed: Schema.Boolean });
|
|
756
|
+
/** How far a press moves before it is a drag, so a click on a header stays a click. */
|
|
757
|
+
const threshold = 4;
|
|
758
|
+
/**
|
|
759
|
+
* Column headers dragged with the pointer, from one listener on the row that
|
|
760
|
+
* holds them. A press on a header becomes a drag once it has moved 4px, and
|
|
761
|
+
* only then is the pointer captured, so a click (on a sort button, say) stays
|
|
762
|
+
* a click. A press on a resize handle, or in a column's open menu, is not a
|
|
763
|
+
* drag. The listeners that
|
|
764
|
+
* follow the pointer are attached for the length of a press. Where a drag
|
|
765
|
+
* lands is the grid's update.
|
|
766
|
+
*/
|
|
767
|
+
const HeaderDrag = Mount.defineStream("DataGridHeaderDrag", {
|
|
768
|
+
messages: [
|
|
769
|
+
HeaderDragStarted,
|
|
770
|
+
HeaderDragged,
|
|
771
|
+
HeaderDragEnded
|
|
772
|
+
],
|
|
773
|
+
execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
|
|
774
|
+
const page = element.ownerDocument;
|
|
775
|
+
let press = Option.none();
|
|
776
|
+
const offer = (fact) => Queue.offerUnsafe(queue, fact);
|
|
777
|
+
const swallow = (event) => {
|
|
778
|
+
event.stopPropagation();
|
|
779
|
+
event.preventDefault();
|
|
780
|
+
};
|
|
781
|
+
const release = (current) => {
|
|
782
|
+
page.removeEventListener("pointermove", onMove);
|
|
783
|
+
page.removeEventListener("pointerup", onUp);
|
|
784
|
+
page.removeEventListener("pointercancel", onCancel);
|
|
785
|
+
element.removeEventListener("lostpointercapture", onCancel);
|
|
786
|
+
if (!current.dragging) return;
|
|
787
|
+
try {
|
|
788
|
+
element.releasePointerCapture(current.pointerId);
|
|
789
|
+
} catch {}
|
|
790
|
+
};
|
|
791
|
+
const pressOf = (event) => Option.filter(press, (current) => current.pointerId === (event.pointerId ?? 0));
|
|
792
|
+
const finish = (event, completed) => {
|
|
793
|
+
const found = pressOf(event);
|
|
794
|
+
if (Option.isNone(found)) return;
|
|
795
|
+
const current = found.value;
|
|
796
|
+
press = Option.none();
|
|
797
|
+
release(current);
|
|
798
|
+
if (!current.dragging) return;
|
|
799
|
+
offer(HeaderDragEnded.make({ completed }));
|
|
800
|
+
element.addEventListener("click", swallow, {
|
|
801
|
+
capture: true,
|
|
802
|
+
once: true
|
|
803
|
+
});
|
|
804
|
+
setTimeout(() => element.removeEventListener("click", swallow, true), 0);
|
|
805
|
+
};
|
|
806
|
+
const onUp = (event) => finish(event, true);
|
|
807
|
+
const onCancel = (event) => finish(event, false);
|
|
808
|
+
const onMove = (event) => {
|
|
809
|
+
const found = pressOf(event);
|
|
810
|
+
if (Option.isNone(found)) return;
|
|
811
|
+
const current = found.value;
|
|
812
|
+
const deltaX = (event.clientX ?? 0) - current.x;
|
|
813
|
+
if (!current.dragging) {
|
|
814
|
+
if (Math.abs(deltaX) < threshold) return;
|
|
815
|
+
current.dragging = true;
|
|
816
|
+
try {
|
|
817
|
+
element.setPointerCapture(current.pointerId);
|
|
818
|
+
} catch {}
|
|
819
|
+
offer(HeaderDragStarted.make({ header: current.header }));
|
|
820
|
+
}
|
|
821
|
+
offer(HeaderDragged.make({ deltaX }));
|
|
822
|
+
};
|
|
823
|
+
const onDown = (event) => {
|
|
824
|
+
const pointer = event;
|
|
825
|
+
const from = event.target;
|
|
826
|
+
if ((pointer.button ?? 0) !== 0 || Option.isSome(press)) return;
|
|
827
|
+
if (!(from instanceof Element) || from.closest("[role=\"separator\"], [role=\"menu\"]")) return;
|
|
828
|
+
const header = from.closest("[role=\"columnheader\"]");
|
|
829
|
+
if (header === null || !element.contains(header) || header.id === "") return;
|
|
830
|
+
press = Option.some({
|
|
831
|
+
pointerId: pointer.pointerId ?? 0,
|
|
832
|
+
x: pointer.clientX ?? 0,
|
|
833
|
+
header: header.id,
|
|
834
|
+
dragging: false
|
|
835
|
+
});
|
|
836
|
+
page.addEventListener("pointermove", onMove);
|
|
837
|
+
page.addEventListener("pointerup", onUp);
|
|
838
|
+
page.addEventListener("pointercancel", onCancel);
|
|
839
|
+
element.addEventListener("lostpointercapture", onCancel);
|
|
840
|
+
};
|
|
841
|
+
element.addEventListener("pointerdown", onDown);
|
|
842
|
+
return {
|
|
843
|
+
onDown,
|
|
844
|
+
current: () => press,
|
|
845
|
+
release
|
|
846
|
+
};
|
|
847
|
+
}), ({ onDown, current, release }) => Effect.sync(() => {
|
|
848
|
+
element.removeEventListener("pointerdown", onDown);
|
|
849
|
+
const left = current();
|
|
850
|
+
if (Option.isSome(left)) release(left.value);
|
|
851
|
+
})))
|
|
852
|
+
});
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/press.ts
|
|
855
|
+
const CellClicked = Schema.TaggedStruct("CellClicked", {
|
|
856
|
+
/** The clicked gridcell's DOM id. */
|
|
857
|
+
cell: Schema.String,
|
|
858
|
+
shiftKey: Schema.Boolean,
|
|
859
|
+
/** Ctrl, or Meta on a Mac: a toggle. */
|
|
860
|
+
toggleKey: Schema.Boolean
|
|
861
|
+
});
|
|
862
|
+
/**
|
|
863
|
+
* Clicks on a grid's cells, with their modifier keys, and the fill handle's
|
|
864
|
+
* drag (`fillDragOn`), from listeners on the element that holds the cells
|
|
865
|
+
* rather than one per cell: one Mount, since an element holds one. A click
|
|
866
|
+
* outside a gridcell, on the fill handle, or ending a fill reports nothing;
|
|
867
|
+
* what a click means is the grid's update.
|
|
868
|
+
*/
|
|
869
|
+
const CellPress = Mount.defineStream("DataGridCellPress", {
|
|
870
|
+
messages: [
|
|
871
|
+
CellClicked,
|
|
872
|
+
FillDragStarted,
|
|
873
|
+
FillDraggedOver,
|
|
874
|
+
FillDragEnded
|
|
875
|
+
],
|
|
876
|
+
execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
|
|
877
|
+
const fill = fillDragOn(element, (fact) => Queue.offerUnsafe(queue, fact));
|
|
878
|
+
const listener = (event) => {
|
|
879
|
+
if (fill.owns(event)) return;
|
|
880
|
+
const from = event.target;
|
|
881
|
+
if (!(from instanceof Element)) return;
|
|
882
|
+
const cell = from.closest("[role=\"gridcell\"]");
|
|
883
|
+
if (cell === null || !element.contains(cell) || cell.id === "") return;
|
|
884
|
+
const modified = event;
|
|
885
|
+
Queue.offerUnsafe(queue, CellClicked.make({
|
|
886
|
+
cell: cell.id,
|
|
887
|
+
shiftKey: modified.shiftKey === true,
|
|
888
|
+
toggleKey: modified.ctrlKey === true || modified.metaKey === true
|
|
889
|
+
}));
|
|
890
|
+
};
|
|
891
|
+
element.addEventListener("click", listener);
|
|
892
|
+
return {
|
|
893
|
+
listener,
|
|
894
|
+
fill
|
|
895
|
+
};
|
|
896
|
+
}), ({ listener, fill }) => Effect.sync(() => {
|
|
897
|
+
element.removeEventListener("click", listener);
|
|
898
|
+
fill.dispose();
|
|
899
|
+
})))
|
|
900
|
+
});
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/view.ts
|
|
903
|
+
const px = (value) => `${value}px`;
|
|
904
|
+
/** Whether the rows' source is reading: the grid says so, and asks for nothing more. */
|
|
905
|
+
const isBusy = (status) => RowStatus.match(status ?? RowStatus.Ready(), {
|
|
906
|
+
Ready: () => false,
|
|
907
|
+
Loading: () => true,
|
|
908
|
+
Refreshing: () => true,
|
|
909
|
+
Failed: () => false
|
|
910
|
+
});
|
|
911
|
+
const ariaSort = (direction) => {
|
|
912
|
+
if (direction === "asc") return "ascending";
|
|
913
|
+
if (direction === "desc") return "descending";
|
|
914
|
+
return "none";
|
|
915
|
+
};
|
|
916
|
+
/** How far one press of an arrow key on a resize handle moves the edge, in pixels. */
|
|
917
|
+
const resizeStep = 16;
|
|
918
|
+
const { textOf } = Columns;
|
|
919
|
+
const copies = /* @__PURE__ */ new WeakMap();
|
|
920
|
+
const remembered = (projection, key, make) => {
|
|
921
|
+
const known = copies.get(projection) ?? /* @__PURE__ */ new Map();
|
|
922
|
+
copies.set(projection, known);
|
|
923
|
+
const text = known.get(key);
|
|
924
|
+
if (text !== void 0) return text;
|
|
925
|
+
const made = make();
|
|
926
|
+
known.set(key, made);
|
|
927
|
+
return made;
|
|
928
|
+
};
|
|
929
|
+
/** The offset of each pinned column from its edge: the widths of the ones before it. */
|
|
930
|
+
const insets = (ids, width) => {
|
|
931
|
+
const offsets = /* @__PURE__ */ new Map();
|
|
932
|
+
let offset = 0;
|
|
933
|
+
for (const id of ids) {
|
|
934
|
+
offsets.set(id, offset);
|
|
935
|
+
offset += Math.max(0, width(id));
|
|
936
|
+
}
|
|
937
|
+
return offsets;
|
|
938
|
+
};
|
|
939
|
+
const view = () => ({
|
|
940
|
+
/**
|
|
941
|
+
* The view of one `DataGrid`. It draws the grid's columns' headers and the
|
|
942
|
+
* window of cells `VirtualGrid.window` picks for the placed viewport, and
|
|
943
|
+
* sends the grid's Messages through `wrap`: `Moved` for a key, with the
|
|
944
|
+
* scroll that reveals the cell, `Focused` for the pointer, `Measured` from
|
|
945
|
+
* the scroll container. Attach Style and Behavior to the result.
|
|
946
|
+
*/
|
|
947
|
+
define: (grid) => {
|
|
948
|
+
const editsAny = Object.values(grid.columns.byId).some((column) => column.edit !== void 0);
|
|
949
|
+
return SlotView.forMessages().define(GridSlots, (input, slots, h) => {
|
|
950
|
+
const { rowHeight, headerHeight, state } = input;
|
|
951
|
+
const projection = grid.project(input.rows, state.columns);
|
|
952
|
+
const width = grid.columnState.widthOf(state.columns);
|
|
953
|
+
const viewport = state.viewport;
|
|
954
|
+
const shown = grid.window(input);
|
|
955
|
+
const stop = GridFocus.tabStop(projection, state.focus.current);
|
|
956
|
+
const pageRows = Math.max(1, Math.floor((viewport.height - headerHeight) / rowHeight));
|
|
957
|
+
const drawn = [
|
|
958
|
+
...shown.start,
|
|
959
|
+
...shown.centerColumns,
|
|
960
|
+
...shown.end
|
|
961
|
+
];
|
|
962
|
+
const indexOf = new Map(projection.columns.map((id, index) => [id, index]));
|
|
963
|
+
const startInsets = insets(shown.start, width);
|
|
964
|
+
const endInsets = insets([...shown.end].reverse(), width);
|
|
965
|
+
const markOf = (address) => input.marks === void 0 ? Option.none() : input.marks(address);
|
|
966
|
+
const pinned = (id) => {
|
|
967
|
+
if (startInsets.has(id)) return Option.some("start");
|
|
968
|
+
if (endInsets.has(id)) return Option.some("end");
|
|
969
|
+
return Option.none();
|
|
970
|
+
};
|
|
971
|
+
const startEdge = shown.start.at(-1);
|
|
972
|
+
const endEdge = shown.end[0];
|
|
973
|
+
const pinnedEdge = (id) => {
|
|
974
|
+
if (id === startEdge) return [h.DataAttribute("pinned-edge", "start")];
|
|
975
|
+
if (id === endEdge) return [h.DataAttribute("pinned-edge", "end")];
|
|
976
|
+
return [];
|
|
977
|
+
};
|
|
978
|
+
const cellStyle = (id) => {
|
|
979
|
+
const base = {
|
|
980
|
+
boxSizing: "border-box",
|
|
981
|
+
width: px(Math.max(0, width(id))),
|
|
982
|
+
flex: "none"
|
|
983
|
+
};
|
|
984
|
+
return Option.match(pinned(id), {
|
|
985
|
+
onNone: () => base,
|
|
986
|
+
onSome: (edge) => edge === "start" ? {
|
|
987
|
+
...base,
|
|
988
|
+
position: "sticky",
|
|
989
|
+
insetInlineStart: px(startInsets.get(id))
|
|
990
|
+
} : {
|
|
991
|
+
...base,
|
|
992
|
+
position: "sticky",
|
|
993
|
+
insetInlineEnd: px(endInsets.get(id))
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
};
|
|
997
|
+
const spacer = (size) => size > 0 ? [h.div([h.AriaHidden(true), h.Style({
|
|
998
|
+
width: px(size),
|
|
999
|
+
flex: "none"
|
|
1000
|
+
})], [])] : [];
|
|
1001
|
+
const across = (drawCell) => [
|
|
1002
|
+
...shown.start.map(drawCell),
|
|
1003
|
+
...spacer(shown.center.before),
|
|
1004
|
+
...shown.centerColumns.map(drawCell),
|
|
1005
|
+
...spacer(shown.center.after),
|
|
1006
|
+
...shown.end.map(drawCell)
|
|
1007
|
+
];
|
|
1008
|
+
const isDrawn = (address) => Option.match(projection.positionOf(address), {
|
|
1009
|
+
onNone: () => false,
|
|
1010
|
+
onSome: ({ row }) => row >= shown.rows.start && row < shown.rows.end && drawn.includes(address.column)
|
|
1011
|
+
});
|
|
1012
|
+
const isRowShown = (address) => Option.isSome(projection.positionOf(address));
|
|
1013
|
+
const onHeader = state.focus.header;
|
|
1014
|
+
const activeDescendant = Option.match(onHeader, {
|
|
1015
|
+
onSome: (column) => drawn.includes(column) ? Option.some(GridFocus.headerId(grid.id, column)) : Option.none(),
|
|
1016
|
+
onNone: () => Option.map(Option.filter(stop, isDrawn), (address) => GridFocus.cellId(grid.id, address))
|
|
1017
|
+
});
|
|
1018
|
+
const revealOf = (address) => Option.flatMap(projection.positionOf(address), (position) => VirtualGrid.reveal({
|
|
1019
|
+
projection,
|
|
1020
|
+
rowHeight,
|
|
1021
|
+
width,
|
|
1022
|
+
headerHeight,
|
|
1023
|
+
viewport,
|
|
1024
|
+
position
|
|
1025
|
+
}));
|
|
1026
|
+
const selection = state.selection;
|
|
1027
|
+
const isSelectedRow = GridSelection.isSelected(selection.rows);
|
|
1028
|
+
const range = Option.flatMap(selection.cells, (cells) => GridSelection.boxOf(projection, cells));
|
|
1029
|
+
const rangeColumns = Option.map(range, (box) => new Set(box.columns));
|
|
1030
|
+
const selectionKey = (key, modifiers) => {
|
|
1031
|
+
const toggle = modifiers.ctrlKey || modifiers.metaKey;
|
|
1032
|
+
if (key === "Escape" && Option.isSome(selection.cells)) return Option.some(grid.Message.CellsCleared());
|
|
1033
|
+
if ((key === "a" || key === "A") && toggle && !modifiers.shiftKey && !modifiers.altKey) {
|
|
1034
|
+
if (grid.rowSelection === "multiple") return Option.some(grid.Message.AllRowsSelected());
|
|
1035
|
+
if (grid.cellSelection) return Option.zipWith(projection.first(), projection.last(), (anchor, far) => grid.Message.CellsSelected({
|
|
1036
|
+
anchor,
|
|
1037
|
+
focus: far,
|
|
1038
|
+
reveal: Option.none()
|
|
1039
|
+
}));
|
|
1040
|
+
return Option.none();
|
|
1041
|
+
}
|
|
1042
|
+
if (key === " " && !toggle && !modifiers.altKey && grid.rowSelection !== void 0) return Option.map(stop, (current) => {
|
|
1043
|
+
if (!modifiers.shiftKey || grid.rowSelection !== "multiple") return grid.Message.RowSelected({ row: current.row });
|
|
1044
|
+
const from = Option.getOrElse(selection.anchor, () => current.row);
|
|
1045
|
+
return grid.Message.RowsExtended({
|
|
1046
|
+
rows: GridSelection.rowsBetween(projection, from, current.row),
|
|
1047
|
+
to: current.row
|
|
1048
|
+
});
|
|
1049
|
+
});
|
|
1050
|
+
if (!grid.cellSelection) return Option.none();
|
|
1051
|
+
return Option.map(GridSelection.extend(projection, {
|
|
1052
|
+
range: selection.cells,
|
|
1053
|
+
current: stop,
|
|
1054
|
+
key,
|
|
1055
|
+
modifiers,
|
|
1056
|
+
pageRows,
|
|
1057
|
+
...input.direction === void 0 ? {} : { direction: input.direction }
|
|
1058
|
+
}), (extended) => grid.Message.CellsSelected({
|
|
1059
|
+
anchor: extended.anchor,
|
|
1060
|
+
focus: extended.focus,
|
|
1061
|
+
reveal: revealOf(extended.focus)
|
|
1062
|
+
}));
|
|
1063
|
+
};
|
|
1064
|
+
const focusKey = (key, modifiers) => Option.map(GridFocus.target(projection, {
|
|
1065
|
+
current: state.focus.current,
|
|
1066
|
+
key,
|
|
1067
|
+
modifiers,
|
|
1068
|
+
pageRows,
|
|
1069
|
+
...input.direction === void 0 ? {} : { direction: input.direction }
|
|
1070
|
+
}), (address) => grid.Message.Moved({
|
|
1071
|
+
address,
|
|
1072
|
+
reveal: revealOf(address)
|
|
1073
|
+
}));
|
|
1074
|
+
const flip = input.direction === "rtl" ? -1 : 1;
|
|
1075
|
+
const sideways = /* @__PURE__ */ new Map([["ArrowRight", flip], ["ArrowLeft", -flip]]);
|
|
1076
|
+
const plainKey = (modifiers) => !modifiers.shiftKey && !modifiers.ctrlKey && !modifiers.altKey && !modifiers.metaKey;
|
|
1077
|
+
const revealHeader = (column) => Option.flatMap(projection.columnIndex(column), (columnIndex) => Option.flatMap(VirtualGrid.reveal({
|
|
1078
|
+
projection,
|
|
1079
|
+
rowHeight,
|
|
1080
|
+
width,
|
|
1081
|
+
headerHeight,
|
|
1082
|
+
viewport,
|
|
1083
|
+
position: {
|
|
1084
|
+
row: shown.rows.start,
|
|
1085
|
+
column: columnIndex
|
|
1086
|
+
}
|
|
1087
|
+
}), ({ left }) => left === viewport.left ? Option.none() : Option.some({
|
|
1088
|
+
top: viewport.top,
|
|
1089
|
+
left
|
|
1090
|
+
})));
|
|
1091
|
+
const headerTo = (column) => grid.Message.HeaderFocused({
|
|
1092
|
+
column,
|
|
1093
|
+
reveal: revealHeader(column)
|
|
1094
|
+
});
|
|
1095
|
+
const headerKey = (column, key, modifiers) => {
|
|
1096
|
+
const columns = projection.columns;
|
|
1097
|
+
const at = columns.indexOf(column);
|
|
1098
|
+
const step = sideways.get(key);
|
|
1099
|
+
const opensMenu = key === "ArrowDown" && modifiers.altKey || key === "F10" && modifiers.shiftKey || key === "ContextMenu";
|
|
1100
|
+
if (input.columnMenu === true && opensMenu) return Option.some(grid.Message.MenuOpened({ column }));
|
|
1101
|
+
if (modifiers.altKey) return Option.none();
|
|
1102
|
+
if (step !== void 0 && modifiers.shiftKey && (modifiers.ctrlKey || modifiers.metaKey)) {
|
|
1103
|
+
const region = [
|
|
1104
|
+
"start",
|
|
1105
|
+
"center",
|
|
1106
|
+
"end"
|
|
1107
|
+
].find((name) => state.columns[name].includes(column));
|
|
1108
|
+
if (region === void 0) return Option.none();
|
|
1109
|
+
const index = state.columns[region].indexOf(column) + step;
|
|
1110
|
+
return Option.some(grid.Message.ColumnMoved({
|
|
1111
|
+
column,
|
|
1112
|
+
region,
|
|
1113
|
+
index
|
|
1114
|
+
}));
|
|
1115
|
+
}
|
|
1116
|
+
if (step !== void 0 && modifiers.shiftKey && !modifiers.ctrlKey && !modifiers.metaKey) return Option.some(grid.Message.ColumnResized({
|
|
1117
|
+
column,
|
|
1118
|
+
width: width(column) + resizeStep * step
|
|
1119
|
+
}));
|
|
1120
|
+
if (!plainKey(modifiers)) return Option.none();
|
|
1121
|
+
if (step !== void 0) {
|
|
1122
|
+
const next = columns[Math.min(Math.max(at + step, 0), columns.length - 1)];
|
|
1123
|
+
return Option.fromUndefinedOr(next).pipe(Option.map(headerTo));
|
|
1124
|
+
}
|
|
1125
|
+
if (key === "Home") return Option.fromUndefinedOr(columns[0]).pipe(Option.map(headerTo));
|
|
1126
|
+
if (key === "End") return Option.fromUndefinedOr(columns[columns.length - 1]).pipe(Option.map(headerTo));
|
|
1127
|
+
if (key === "ArrowDown" || key === "Escape") {
|
|
1128
|
+
const row = Option.orElse(Option.map(Option.filter(stop, isRowShown), (current) => current.row), () => Option.map(projection.first(), (first) => first.row));
|
|
1129
|
+
return Option.map(row, (key) => {
|
|
1130
|
+
const address = {
|
|
1131
|
+
row: key,
|
|
1132
|
+
column
|
|
1133
|
+
};
|
|
1134
|
+
return grid.Message.Moved({
|
|
1135
|
+
address,
|
|
1136
|
+
reveal: revealOf(address)
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return Option.none();
|
|
1141
|
+
};
|
|
1142
|
+
const upToHeader = (key, modifiers) => key === "ArrowUp" && plainKey(modifiers) ? Option.flatMap(Option.filter(stop, (current) => Option.exists(projection.positionOf(current), ({ row }) => row === 0)), (current) => Option.some(headerTo(current.column))) : Option.none();
|
|
1143
|
+
const editing = Option.filter(state.editing, (edit) => isDrawn(edit.address));
|
|
1144
|
+
const draftOf = (address) => grid.draftOf(projection, address);
|
|
1145
|
+
const editKey = (key, modifiers) => Option.flatMap(Option.filter(stop, (address) => grid.columns.byId[address.column].edit !== void 0), (address) => {
|
|
1146
|
+
if ((key === "Enter" || key === "F2") && plainKey(modifiers)) return Option.some(grid.Message.EditStarted({
|
|
1147
|
+
address,
|
|
1148
|
+
draft: draftOf(address)
|
|
1149
|
+
}));
|
|
1150
|
+
if (key === "Escape") return Option.some(grid.Message.EditCancelled());
|
|
1151
|
+
if (!(key.length === 1 && !modifiers.ctrlKey && !modifiers.metaKey && !modifiers.altKey)) return Option.none();
|
|
1152
|
+
return Option.some(grid.Message.EditTyped({
|
|
1153
|
+
address,
|
|
1154
|
+
text: key,
|
|
1155
|
+
from: draftOf(address)
|
|
1156
|
+
}));
|
|
1157
|
+
});
|
|
1158
|
+
const editorKey = (address, key, modifiers) => {
|
|
1159
|
+
if (key === "Escape") return Option.some(grid.Message.EditCancelled());
|
|
1160
|
+
const back = modifiers.shiftKey ? -1 : 1;
|
|
1161
|
+
const offsets = {
|
|
1162
|
+
Enter: { rows: back },
|
|
1163
|
+
Tab: { columns: back * flip }
|
|
1164
|
+
};
|
|
1165
|
+
const offset = Object.hasOwn(offsets, key) ? offsets[key] : void 0;
|
|
1166
|
+
if (offset === void 0 || modifiers.ctrlKey || modifiers.metaKey || modifiers.altKey) return Option.none();
|
|
1167
|
+
const next = projection.moveBy(address, offset);
|
|
1168
|
+
return Option.some(grid.Message.EditCommitted({
|
|
1169
|
+
next,
|
|
1170
|
+
reveal: Option.flatMap(next, revealOf)
|
|
1171
|
+
}));
|
|
1172
|
+
};
|
|
1173
|
+
const choiceSteps = {
|
|
1174
|
+
ArrowDown: grid.Message.EditStepped({ by: "next" }),
|
|
1175
|
+
ArrowUp: grid.Message.EditStepped({ by: "previous" }),
|
|
1176
|
+
Home: grid.Message.EditStepped({ by: "first" }),
|
|
1177
|
+
End: grid.Message.EditStepped({ by: "last" }),
|
|
1178
|
+
PageDown: grid.Message.EditStepped({ by: "pageNext" }),
|
|
1179
|
+
PageUp: grid.Message.EditStepped({ by: "pagePrevious" })
|
|
1180
|
+
};
|
|
1181
|
+
const choiceKey = (address, key, modifiers) => {
|
|
1182
|
+
if (key === "ArrowUp" && modifiers.altKey) return Option.some(grid.Message.EditCommitted({
|
|
1183
|
+
next: Option.none(),
|
|
1184
|
+
reveal: Option.none()
|
|
1185
|
+
}));
|
|
1186
|
+
if (modifiers.ctrlKey || modifiers.metaKey || modifiers.altKey) return editorKey(address, key, modifiers);
|
|
1187
|
+
if (Object.hasOwn(choiceSteps, key)) return Option.some(choiceSteps[key]);
|
|
1188
|
+
if (key.length === 1) return Option.some(grid.Message.EditTyped({
|
|
1189
|
+
address,
|
|
1190
|
+
text: key,
|
|
1191
|
+
from: draftOf(address)
|
|
1192
|
+
}));
|
|
1193
|
+
return editorKey(address, key, modifiers);
|
|
1194
|
+
};
|
|
1195
|
+
const choiceRows = 8;
|
|
1196
|
+
const choiceList = (address, options, draft, shared) => {
|
|
1197
|
+
const cellId = GridFocus.cellId(grid.id, address);
|
|
1198
|
+
const listId = `${cellId}:options`;
|
|
1199
|
+
const optionId = (index) => `${cellId}:option:${index}`;
|
|
1200
|
+
const active = options.indexOf(draft);
|
|
1201
|
+
const top = Option.match(projection.positionOf(address), {
|
|
1202
|
+
onNone: () => 0,
|
|
1203
|
+
onSome: ({ row }) => headerHeight + row * rowHeight - viewport.top
|
|
1204
|
+
});
|
|
1205
|
+
const listHeight = Math.min(options.length, choiceRows) * rowHeight;
|
|
1206
|
+
const above = viewport.height - (top + rowHeight) < listHeight && top - headerHeight >= listHeight;
|
|
1207
|
+
return [h.div(slots.choice.attrs([
|
|
1208
|
+
...shared,
|
|
1209
|
+
h.Role("combobox"),
|
|
1210
|
+
h.Tabindex(0),
|
|
1211
|
+
h.AriaExpanded(true),
|
|
1212
|
+
h.AriaControls(listId),
|
|
1213
|
+
...active === -1 ? [] : [h.AriaActiveDescendant(optionId(active))]
|
|
1214
|
+
]), [draft]), h.ul(slots.choiceList.attrs([
|
|
1215
|
+
h.Id(listId),
|
|
1216
|
+
h.Role("listbox"),
|
|
1217
|
+
h.AriaLabel(grid.columns.byId[address.column].header),
|
|
1218
|
+
h.DataAttribute("place", above ? "above" : "below"),
|
|
1219
|
+
h.OnMount(KeepFocus()),
|
|
1220
|
+
h.Style({
|
|
1221
|
+
position: "absolute",
|
|
1222
|
+
insetInlineStart: "0",
|
|
1223
|
+
...above ? { bottom: "100%" } : { top: "100%" }
|
|
1224
|
+
})
|
|
1225
|
+
]), options.map((option, index) => h.li(slots.choiceOption.attrs([
|
|
1226
|
+
h.Id(optionId(index)),
|
|
1227
|
+
h.Role("option"),
|
|
1228
|
+
h.AriaSelected(index === active),
|
|
1229
|
+
h.OnClick(input.wrap(grid.Message.EditChosen({ draft: option })))
|
|
1230
|
+
]), [option])))];
|
|
1231
|
+
};
|
|
1232
|
+
const editorOf = (address, edit) => {
|
|
1233
|
+
const errorId = `${GridFocus.cellId(grid.id, address)}:error`;
|
|
1234
|
+
const sharedWith = (keys) => [
|
|
1235
|
+
h.Key(`edit:${address.row}:${address.column}`),
|
|
1236
|
+
h.AriaLabel(grid.columns.byId[address.column].header),
|
|
1237
|
+
h.AriaInvalid(Option.isSome(edit.error)),
|
|
1238
|
+
...Option.isSome(edit.error) ? [h.AriaDescribedBy(errorId)] : [],
|
|
1239
|
+
h.OnKeyDownPreventDefault((pressed, modifiers) => Option.map(keys(pressed, modifiers), input.wrap)),
|
|
1240
|
+
h.OnBlur(input.wrap(grid.Message.EditCommitted({
|
|
1241
|
+
next: Option.none(),
|
|
1242
|
+
reveal: Option.none()
|
|
1243
|
+
}))),
|
|
1244
|
+
h.OnMount(HoldFocus()),
|
|
1245
|
+
h.Style({
|
|
1246
|
+
boxSizing: "border-box",
|
|
1247
|
+
width: "100%",
|
|
1248
|
+
height: "100%"
|
|
1249
|
+
})
|
|
1250
|
+
];
|
|
1251
|
+
const shared = sharedWith((pressed, modifiers) => editorKey(address, pressed, modifiers));
|
|
1252
|
+
const changed = (draft) => input.wrap(grid.Message.EditChanged({ draft }));
|
|
1253
|
+
const field = (mode) => h.input(slots.editor.attrs([
|
|
1254
|
+
...shared,
|
|
1255
|
+
h.Type("text"),
|
|
1256
|
+
h.Value(edit.draft),
|
|
1257
|
+
...Option.match(mode, {
|
|
1258
|
+
onNone: () => [],
|
|
1259
|
+
onSome: (decimal) => [h.InputMode(decimal)]
|
|
1260
|
+
}),
|
|
1261
|
+
h.OnInput(changed)
|
|
1262
|
+
]));
|
|
1263
|
+
const message = Option.match(edit.error, {
|
|
1264
|
+
onNone: () => [],
|
|
1265
|
+
onSome: (error) => [h.div(slots.editorError.attrs([h.Id(errorId), h.Role("alert")]), [error])]
|
|
1266
|
+
});
|
|
1267
|
+
return [...Option.match(grid.editorFor(address.column), {
|
|
1268
|
+
onNone: () => [field(Option.none())],
|
|
1269
|
+
onSome: CellEditor.match({
|
|
1270
|
+
Text: () => [field(Option.none())],
|
|
1271
|
+
Number: () => [field(Option.some("decimal"))],
|
|
1272
|
+
Choice: ({ options }) => input.choiceEditor === "native" ? [h.select(slots.choice.attrs([...shared, h.OnChange(changed)]), options.map((option) => h.option(slots.choiceOption.attrs([h.Value(option), ...option === edit.draft ? [h.Selected(true)] : []]), [option])))] : choiceList(address, options, edit.draft, sharedWith((pressed, modifiers) => choiceKey(address, pressed, modifiers)))
|
|
1273
|
+
})
|
|
1274
|
+
}), ...message];
|
|
1275
|
+
};
|
|
1276
|
+
const clipboardBox = Option.filter(Option.orElse(range, () => Option.flatMap(stop, (address) => projection.box(address, address))), () => Option.isNone(editing));
|
|
1277
|
+
const editable = (column) => grid.columns.byId[column].edit !== void 0;
|
|
1278
|
+
const readOnly = (column) => editsAny && !editable(column);
|
|
1279
|
+
const cornerOf = (box) => Option.map(projection.rows.keyAt(box.rows.start), (row) => ({
|
|
1280
|
+
row,
|
|
1281
|
+
column: box.columns[0]
|
|
1282
|
+
}));
|
|
1283
|
+
const clearedOf = (box) => Option.match(cornerOf(box), {
|
|
1284
|
+
onNone: () => [],
|
|
1285
|
+
onSome: (anchor) => Clipboard.pasteAt(projection, anchor, Array.from({ length: box.rows.end - box.rows.start }, () => box.columns.map(() => "")), {
|
|
1286
|
+
editable,
|
|
1287
|
+
from: draftOf
|
|
1288
|
+
})
|
|
1289
|
+
});
|
|
1290
|
+
const clipboard = (box) => {
|
|
1291
|
+
const text = remembered(projection, JSON.stringify(box), () => Clipboard.copy(projection, box, (address) => textOf(Option.match(Option.flatMap(projection.rowIndex(address.row), (index) => projection.rows.rowAt(index)), {
|
|
1292
|
+
onNone: () => "",
|
|
1293
|
+
onSome: (row) => grid.columns.byId[address.column].value(row)
|
|
1294
|
+
}))));
|
|
1295
|
+
const corner = cornerOf(box);
|
|
1296
|
+
return [
|
|
1297
|
+
h.OnCopyText(text),
|
|
1298
|
+
h.OnCutText(text, input.wrap(grid.Message.Pasted({ cells: clearedOf(box) }))),
|
|
1299
|
+
h.OnPastePreventDefault((pasted) => Option.flatMap(corner, (anchor) => {
|
|
1300
|
+
const cells = Clipboard.pasteAt(projection, anchor, Clipboard.parseTsv(pasted), {
|
|
1301
|
+
editable,
|
|
1302
|
+
from: draftOf
|
|
1303
|
+
});
|
|
1304
|
+
return cells.length === 0 ? Option.none() : Option.some(input.wrap(grid.Message.Pasted({ cells })));
|
|
1305
|
+
}))
|
|
1306
|
+
];
|
|
1307
|
+
};
|
|
1308
|
+
const sortOf = (column) => Option.fromUndefinedOr(input.sort?.[column]);
|
|
1309
|
+
const sortKey = (column, key, modifiers) => key === "Enter" && plainKey(modifiers) ? Option.map(sortOf(column), (sorted) => sorted.message) : Option.none();
|
|
1310
|
+
const fillKey = (key, modifiers) => {
|
|
1311
|
+
const pressed = key.toLowerCase();
|
|
1312
|
+
const toggle = modifiers.ctrlKey || modifiers.metaKey;
|
|
1313
|
+
if (!editsAny || !toggle || modifiers.shiftKey || modifiers.altKey) return Option.none();
|
|
1314
|
+
if (pressed !== "d" && pressed !== "r") return Option.none();
|
|
1315
|
+
const box = Option.orElse(range, () => Option.flatMap(stop, (address) => projection.box(address, address)));
|
|
1316
|
+
return Option.flatMap(box, ({ rows, columns }) => {
|
|
1317
|
+
const first = columns[0];
|
|
1318
|
+
const last = columns[columns.length - 1];
|
|
1319
|
+
if (pressed === "d") {
|
|
1320
|
+
const from = rows.end - rows.start > 1 ? rows.start : rows.start - 1;
|
|
1321
|
+
return Option.zipWith(projection.rows.keyAt(from), projection.rows.keyAt(rows.end - 1), (sourceRow, toRow) => grid.Message.FillRequested({
|
|
1322
|
+
source: {
|
|
1323
|
+
anchor: {
|
|
1324
|
+
row: sourceRow,
|
|
1325
|
+
column: first
|
|
1326
|
+
},
|
|
1327
|
+
focus: {
|
|
1328
|
+
row: sourceRow,
|
|
1329
|
+
column: last
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
to: {
|
|
1333
|
+
row: toRow,
|
|
1334
|
+
column: first
|
|
1335
|
+
}
|
|
1336
|
+
}));
|
|
1337
|
+
}
|
|
1338
|
+
const at = projection.columns.indexOf(first);
|
|
1339
|
+
const from = columns.length > 1 ? first : projection.columns[at - 1];
|
|
1340
|
+
return Option.zipWith(Option.fromUndefinedOr(from), Option.zipWith(projection.rows.keyAt(rows.start), projection.rows.keyAt(rows.end - 1), (top, bottom) => ({
|
|
1341
|
+
top,
|
|
1342
|
+
bottom
|
|
1343
|
+
})), (column, { top, bottom }) => grid.Message.FillRequested({
|
|
1344
|
+
source: {
|
|
1345
|
+
anchor: {
|
|
1346
|
+
row: top,
|
|
1347
|
+
column
|
|
1348
|
+
},
|
|
1349
|
+
focus: {
|
|
1350
|
+
row: bottom,
|
|
1351
|
+
column
|
|
1352
|
+
}
|
|
1353
|
+
},
|
|
1354
|
+
to: {
|
|
1355
|
+
row: top,
|
|
1356
|
+
column: last
|
|
1357
|
+
}
|
|
1358
|
+
}));
|
|
1359
|
+
});
|
|
1360
|
+
};
|
|
1361
|
+
const clearKey = (key, modifiers) => (key === "Delete" || key === "Backspace") && plainKey(modifiers) ? Option.flatMap(clipboardBox, (box) => {
|
|
1362
|
+
const cells = clearedOf(box);
|
|
1363
|
+
return cells.length === 0 ? Option.none() : Option.some(grid.Message.Pasted({ cells }));
|
|
1364
|
+
}) : Option.none();
|
|
1365
|
+
const onKey = (key, modifiers) => {
|
|
1366
|
+
if (key === "Escape" && Option.isSome(state.dragging)) return Option.some(input.wrap(grid.Message.ColumnDragEnded({ completed: false })));
|
|
1367
|
+
if (key === "Escape" && Option.isSome(state.filling)) return Option.some(input.wrap(grid.Message.FillEnded({ completed: false })));
|
|
1368
|
+
return keyOf(key, modifiers);
|
|
1369
|
+
};
|
|
1370
|
+
const historyKey = (key, modifiers) => {
|
|
1371
|
+
if (!(modifiers.ctrlKey || modifiers.metaKey) || modifiers.altKey) return Option.none();
|
|
1372
|
+
const pressed = key.toLowerCase();
|
|
1373
|
+
if (pressed === "z") return Option.some(modifiers.shiftKey ? grid.Message.RedoRequested() : grid.Message.UndoRequested());
|
|
1374
|
+
return pressed === "y" && !modifiers.shiftKey ? Option.some(grid.Message.RedoRequested()) : Option.none();
|
|
1375
|
+
};
|
|
1376
|
+
const cellKeys = [
|
|
1377
|
+
historyKey,
|
|
1378
|
+
fillKey,
|
|
1379
|
+
clearKey,
|
|
1380
|
+
selectionKey,
|
|
1381
|
+
editKey,
|
|
1382
|
+
upToHeader,
|
|
1383
|
+
focusKey
|
|
1384
|
+
];
|
|
1385
|
+
const keyOf = (key, modifiers) => Option.match(onHeader, {
|
|
1386
|
+
onSome: (column) => Option.orElse(sortKey(column, key, modifiers), () => Option.map(headerKey(column, key, modifiers), input.wrap)),
|
|
1387
|
+
onNone: () => Option.map(cellKeys.reduce((found, next) => Option.orElse(found, () => next(key, modifiers)), Option.none()), input.wrap)
|
|
1388
|
+
});
|
|
1389
|
+
const pressed = Mount.mapMessage(CellPress(), (fact) => input.wrap(Match.valueTags(fact, {
|
|
1390
|
+
CellClicked: ({ cell, shiftKey, toggleKey }) => grid.Message.CellPressed({
|
|
1391
|
+
cell,
|
|
1392
|
+
shiftKey,
|
|
1393
|
+
toggleKey
|
|
1394
|
+
}),
|
|
1395
|
+
FillDragStarted: () => grid.Message.FillStarted(),
|
|
1396
|
+
FillDraggedOver: ({ cell }) => grid.Message.FillDragged({ cell }),
|
|
1397
|
+
FillDragEnded: ({ completed }) => grid.Message.FillEnded({ completed })
|
|
1398
|
+
})));
|
|
1399
|
+
const measured = Mount.mapMessage(GridViewport.Measure(), (reading) => input.wrap(grid.Message.Measured({
|
|
1400
|
+
top: reading.top,
|
|
1401
|
+
left: reading.left,
|
|
1402
|
+
width: reading.width,
|
|
1403
|
+
height: reading.height
|
|
1404
|
+
})));
|
|
1405
|
+
const rowCount = RowCount.match(projection.rowCount, {
|
|
1406
|
+
Known: ({ total }) => total + 1,
|
|
1407
|
+
Unknown: () => -1
|
|
1408
|
+
});
|
|
1409
|
+
const toward = input.direction === "rtl" ? -1 : 1;
|
|
1410
|
+
const fillTarget = Option.map(Option.flatMap(state.filling, (filling) => Fill.plan(projection, filling)), ({ target }) => ({
|
|
1411
|
+
rows: target.rows,
|
|
1412
|
+
columns: new Set(target.columns)
|
|
1413
|
+
}));
|
|
1414
|
+
const handleAt = Option.filter(Option.orElse(Option.flatMap(range, (box) => Option.map(projection.rows.keyAt(box.rows.end - 1), (row) => ({
|
|
1415
|
+
row,
|
|
1416
|
+
column: box.columns[box.columns.length - 1]
|
|
1417
|
+
}))), () => stop), () => editsAny);
|
|
1418
|
+
const fillHandle = h.span(slots.fillHandle.attrs([
|
|
1419
|
+
h.AriaHidden(true),
|
|
1420
|
+
h.Attribute(fillHandleAttribute, ""),
|
|
1421
|
+
h.Style({
|
|
1422
|
+
position: "absolute",
|
|
1423
|
+
insetInlineEnd: "0",
|
|
1424
|
+
insetBlockEnd: "0"
|
|
1425
|
+
})
|
|
1426
|
+
]), []);
|
|
1427
|
+
const headerDragged = Mount.mapMessage(HeaderDrag(), (fact) => input.wrap(Match.valueTags(fact, {
|
|
1428
|
+
HeaderDragStarted: ({ header }) => grid.Message.ColumnDragStarted({ header }),
|
|
1429
|
+
HeaderDragged: ({ deltaX }) => grid.Message.ColumnDragged({ delta: deltaX * toward }),
|
|
1430
|
+
HeaderDragEnded: ({ completed }) => grid.Message.ColumnDragEnded({ completed })
|
|
1431
|
+
})));
|
|
1432
|
+
const landing = Option.flatMap(state.dragging, ({ column, delta }) => {
|
|
1433
|
+
const columns = grid.columnState;
|
|
1434
|
+
const region = columns.regionOf(state.columns, column);
|
|
1435
|
+
const moved = columns.move(state.columns, column, region, columns.dropAt(state.columns, column, delta));
|
|
1436
|
+
if (moved === state.columns) return Option.none();
|
|
1437
|
+
const hidden = new Set(moved.hidden);
|
|
1438
|
+
const order = moved[region].filter((id) => !hidden.has(id));
|
|
1439
|
+
const at = order.indexOf(column);
|
|
1440
|
+
return at + 1 < order.length ? Option.some({
|
|
1441
|
+
column: order[at + 1],
|
|
1442
|
+
side: "before"
|
|
1443
|
+
}) : Option.some({
|
|
1444
|
+
column: order[at - 1],
|
|
1445
|
+
side: "after"
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
const resizeHandle = (id) => {
|
|
1449
|
+
const column = grid.columns.byId[id];
|
|
1450
|
+
if (column.resizable === false) return [];
|
|
1451
|
+
const now = width(id);
|
|
1452
|
+
const steps = /* @__PURE__ */ new Map([["ArrowRight", resizeStep * toward], ["ArrowLeft", -16 * toward]]);
|
|
1453
|
+
const dragged = Mount.mapMessage(Move(), (fact) => input.wrap(Match.valueTags(fact, {
|
|
1454
|
+
MoveStarted: () => grid.Message.ResizeStarted({ column: id }),
|
|
1455
|
+
Moved: ({ deltaX }) => grid.Message.ResizeMoved({ delta: deltaX * toward }),
|
|
1456
|
+
MoveEnded: ({ completed }) => grid.Message.ResizeEnded({ completed })
|
|
1457
|
+
})));
|
|
1458
|
+
return [h.div(slots.resizeHandle.attrs([
|
|
1459
|
+
h.Key(`resize:${id}:${toward}`),
|
|
1460
|
+
h.Role("separator"),
|
|
1461
|
+
h.AriaOrientation("vertical"),
|
|
1462
|
+
h.AriaLabel(`Resize ${column.header}`),
|
|
1463
|
+
h.AriaValuenow(now),
|
|
1464
|
+
h.AriaValuemin(column.minWidth ?? Columns.minWidth),
|
|
1465
|
+
...column.maxWidth === void 0 ? [] : [h.AriaValuemax(column.maxWidth)],
|
|
1466
|
+
h.Tabindex(-1),
|
|
1467
|
+
h.OnKeyDownSelfPreventDefault((key) => Option.map(Option.fromUndefinedOr(steps.get(key)), (by) => input.wrap(grid.Message.ColumnResized({
|
|
1468
|
+
column: id,
|
|
1469
|
+
width: now + by
|
|
1470
|
+
})))),
|
|
1471
|
+
h.OnMount(dragged),
|
|
1472
|
+
h.Style({
|
|
1473
|
+
position: "absolute",
|
|
1474
|
+
top: "0",
|
|
1475
|
+
bottom: "0",
|
|
1476
|
+
insetInlineEnd: "0",
|
|
1477
|
+
width: px(resizeStep / 2),
|
|
1478
|
+
cursor: "col-resize",
|
|
1479
|
+
touchAction: "none"
|
|
1480
|
+
})
|
|
1481
|
+
]), [])];
|
|
1482
|
+
};
|
|
1483
|
+
const menuPart = (column, part) => [
|
|
1484
|
+
grid.id,
|
|
1485
|
+
column,
|
|
1486
|
+
"menu",
|
|
1487
|
+
part
|
|
1488
|
+
].map(encodeURIComponent).join(":");
|
|
1489
|
+
const say = (template, column) => template.replace("{column}", grid.columns.byId[column].header);
|
|
1490
|
+
const itemLabel = (item) => grid.MenuItem.match(item, {
|
|
1491
|
+
Pin: ({ region }) => ({
|
|
1492
|
+
start: words.pinStart ?? "Pin to start",
|
|
1493
|
+
end: words.pinEnd ?? "Pin to end",
|
|
1494
|
+
center: words.unpin ?? "Unpin"
|
|
1495
|
+
})[region],
|
|
1496
|
+
Hide: () => words.hide ?? "Hide column",
|
|
1497
|
+
Show: ({ column: other }) => say(words.show ?? "Show {column}", other)
|
|
1498
|
+
});
|
|
1499
|
+
const columnMenu = (id) => {
|
|
1500
|
+
if (input.columnMenu !== true) return [];
|
|
1501
|
+
const open = Option.filter(state.menu, (menu) => menu.column === id);
|
|
1502
|
+
const button = h.button(slots.menuButton.attrs([
|
|
1503
|
+
h.Type("button"),
|
|
1504
|
+
h.Tabindex(-1),
|
|
1505
|
+
h.AriaHasPopup("menu"),
|
|
1506
|
+
h.AriaExpanded(Option.isSome(open)),
|
|
1507
|
+
h.AriaLabel(say(words.menu ?? "{column} menu", id)),
|
|
1508
|
+
...grid.columns.byId[id].resizable === false ? [] : [h.Style({ marginInlineEnd: px(resizeStep / 2) })],
|
|
1509
|
+
h.OnClick(input.wrap(Option.match(open, {
|
|
1510
|
+
onNone: () => grid.Message.MenuOpened({ column: id }),
|
|
1511
|
+
onSome: () => grid.Message.MenuClosed()
|
|
1512
|
+
})))
|
|
1513
|
+
]), [String.fromCharCode(8942)]);
|
|
1514
|
+
return Option.match(open, {
|
|
1515
|
+
onNone: () => [button],
|
|
1516
|
+
onSome: ({ active }) => {
|
|
1517
|
+
const items = grid.menuItems(state.columns, id);
|
|
1518
|
+
const count = items.length;
|
|
1519
|
+
const menuKey = (key) => {
|
|
1520
|
+
if (key === "ArrowDown") return Option.some(grid.Message.MenuMoved({ active: (active + 1) % count }));
|
|
1521
|
+
if (key === "ArrowUp") return Option.some(grid.Message.MenuMoved({ active: (active - 1 + count) % count }));
|
|
1522
|
+
if (key === "Home") return Option.some(grid.Message.MenuMoved({ active: 0 }));
|
|
1523
|
+
if (key === "End") return Option.some(grid.Message.MenuMoved({ active: count - 1 }));
|
|
1524
|
+
if (key === "Enter" || key === " ") return Option.some(grid.Message.MenuChosen({ index: active }));
|
|
1525
|
+
if (key === "Escape") return Option.some(grid.Message.MenuClosed());
|
|
1526
|
+
return Option.none();
|
|
1527
|
+
};
|
|
1528
|
+
return [button, h.div(slots.menu.attrs([
|
|
1529
|
+
h.Key(menuPart(id, "list")),
|
|
1530
|
+
h.Role("menu"),
|
|
1531
|
+
h.Id(menuPart(id, "list")),
|
|
1532
|
+
h.Tabindex(-1),
|
|
1533
|
+
h.AriaLabel(grid.columns.byId[id].header),
|
|
1534
|
+
...count === 0 ? [] : [h.AriaActiveDescendant(menuPart(id, String(active)))],
|
|
1535
|
+
h.OnKeyDownPreventDefault((key) => Option.map(menuKey(key), input.wrap)),
|
|
1536
|
+
h.OnFocusLeave(input.wrap(grid.Message.MenuClosed())),
|
|
1537
|
+
h.OnMount(HoldFocus()),
|
|
1538
|
+
h.Style({
|
|
1539
|
+
position: "absolute",
|
|
1540
|
+
top: "100%",
|
|
1541
|
+
insetInlineEnd: "0"
|
|
1542
|
+
})
|
|
1543
|
+
]), items.map((item, index) => h.div(slots.menuItem.attrs([
|
|
1544
|
+
h.Role("menuitem"),
|
|
1545
|
+
h.Id(menuPart(id, String(index))),
|
|
1546
|
+
...index === active ? [h.DataAttribute("active", "true")] : [],
|
|
1547
|
+
h.OnClick(input.wrap(grid.Message.MenuChosen({ index })))
|
|
1548
|
+
]), [itemLabel(item)])))];
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
};
|
|
1552
|
+
const headerCell = (id) => {
|
|
1553
|
+
const dragged = Option.filter(state.dragging, (dragging) => dragging.column === id);
|
|
1554
|
+
return h.div(slots.headerCell.attrs([
|
|
1555
|
+
h.Role("columnheader"),
|
|
1556
|
+
h.Id(GridFocus.headerId(grid.id, id)),
|
|
1557
|
+
...Option.contains(onHeader, id) ? [h.DataAttribute("focused", "true")] : [],
|
|
1558
|
+
h.AriaColindex(indexOf.get(id) + 1),
|
|
1559
|
+
h.Style({
|
|
1560
|
+
...cellStyle(id),
|
|
1561
|
+
...Option.isNone(pinned(id)) ? { position: "relative" } : {},
|
|
1562
|
+
...Option.match(dragged, {
|
|
1563
|
+
onNone: () => ({}),
|
|
1564
|
+
onSome: ({ delta }) => ({ transform: `translateX(${px(delta * toward)})` })
|
|
1565
|
+
})
|
|
1566
|
+
}),
|
|
1567
|
+
...Option.isSome(dragged) ? [h.DataAttribute("dragging", "true")] : [],
|
|
1568
|
+
...Option.match(Option.filter(landing, (place) => place.column === id), {
|
|
1569
|
+
onNone: () => [],
|
|
1570
|
+
onSome: (place) => [h.DataAttribute("drop", place.side)]
|
|
1571
|
+
}),
|
|
1572
|
+
...Option.match(pinned(id), {
|
|
1573
|
+
onNone: () => [],
|
|
1574
|
+
onSome: (edge) => [h.DataAttribute("pinned", edge)]
|
|
1575
|
+
}),
|
|
1576
|
+
...pinnedEdge(id),
|
|
1577
|
+
...readOnly(id) ? [h.AriaReadonly(true), h.Title("Read-only")] : [],
|
|
1578
|
+
...Option.match(sortOf(id), {
|
|
1579
|
+
onNone: () => [],
|
|
1580
|
+
onSome: (sorted) => [h.AriaSort(ariaSort(sorted.direction))]
|
|
1581
|
+
})
|
|
1582
|
+
]), [
|
|
1583
|
+
Option.match(sortOf(id), {
|
|
1584
|
+
onNone: () => grid.columns.byId[id].header,
|
|
1585
|
+
onSome: (sorted) => h.button(slots.sort.attrs([
|
|
1586
|
+
h.Type("button"),
|
|
1587
|
+
h.Tabindex(-1),
|
|
1588
|
+
h.OnClick(sorted.message),
|
|
1589
|
+
...sorted.direction === void 0 ? [] : [h.DataAttribute("sort", sorted.direction)]
|
|
1590
|
+
]), [grid.columns.byId[id].header])
|
|
1591
|
+
}),
|
|
1592
|
+
...columnMenu(id),
|
|
1593
|
+
...resizeHandle(id)
|
|
1594
|
+
]);
|
|
1595
|
+
};
|
|
1596
|
+
const rowAt = (index) => Option.match(Option.zipWith(projection.rows.rowAt(index), projection.rows.keyAt(index), (row, key) => ({
|
|
1597
|
+
row,
|
|
1598
|
+
key
|
|
1599
|
+
})), {
|
|
1600
|
+
onNone: () => h.div(slots.placeholder.attrs([
|
|
1601
|
+
h.AriaHidden(true),
|
|
1602
|
+
h.Key(`unloaded:${index}`),
|
|
1603
|
+
h.Style({
|
|
1604
|
+
height: px(rowHeight),
|
|
1605
|
+
width: px(shown.width)
|
|
1606
|
+
})
|
|
1607
|
+
]), []),
|
|
1608
|
+
onSome: ({ row, key }) => h.div(slots.row.attrs([
|
|
1609
|
+
h.Key(key),
|
|
1610
|
+
h.Role("row"),
|
|
1611
|
+
h.AriaRowindex(index + 2),
|
|
1612
|
+
...grid.rowSelection === void 0 ? [] : [h.AriaSelected(isSelectedRow(key))],
|
|
1613
|
+
h.Style({
|
|
1614
|
+
display: "flex",
|
|
1615
|
+
height: px(rowHeight),
|
|
1616
|
+
width: px(shown.width)
|
|
1617
|
+
})
|
|
1618
|
+
]), across((id) => {
|
|
1619
|
+
const address = {
|
|
1620
|
+
row: key,
|
|
1621
|
+
column: id
|
|
1622
|
+
};
|
|
1623
|
+
const focused = Option.isNone(onHeader) && Option.exists(stop, (current) => current.row === key && current.column === id);
|
|
1624
|
+
const inRange = Option.exists(Option.zipWith(range, rangeColumns, (box, ids) => ({
|
|
1625
|
+
box,
|
|
1626
|
+
ids
|
|
1627
|
+
})), ({ box, ids }) => index >= box.rows.start && index < box.rows.end && ids.has(id));
|
|
1628
|
+
const edited = Option.filter(editing, (edit) => edit.address.row === key && edit.address.column === id);
|
|
1629
|
+
const handled = Option.exists(handleAt, (at) => at.row === key && at.column === id);
|
|
1630
|
+
const filled = Option.exists(fillTarget, (box) => index >= box.rows.start && index < box.rows.end && box.columns.has(id));
|
|
1631
|
+
return h.div(slots.cell.attrs([
|
|
1632
|
+
h.Role("gridcell"),
|
|
1633
|
+
h.Id(GridFocus.cellId(grid.id, address)),
|
|
1634
|
+
h.AriaColindex(indexOf.get(id) + 1),
|
|
1635
|
+
h.Style({
|
|
1636
|
+
...cellStyle(id),
|
|
1637
|
+
...(Option.isSome(edited) || handled) && Option.isNone(pinned(id)) ? { position: "relative" } : {}
|
|
1638
|
+
}),
|
|
1639
|
+
...filled ? [h.DataAttribute("fill", "target")] : [],
|
|
1640
|
+
...grid.cellSelection ? [h.AriaSelected(inRange)] : [],
|
|
1641
|
+
...focused ? [h.DataAttribute("focused", "true")] : [],
|
|
1642
|
+
...Option.match(pinned(id), {
|
|
1643
|
+
onNone: () => [],
|
|
1644
|
+
onSome: (edge) => [h.DataAttribute("pinned", edge)]
|
|
1645
|
+
}),
|
|
1646
|
+
...pinnedEdge(id),
|
|
1647
|
+
...editable(id) ? [h.DataAttribute("editable", "true"), h.OnDoubleClick(input.wrap(grid.Message.EditStarted({
|
|
1648
|
+
address,
|
|
1649
|
+
draft: draftOf(address)
|
|
1650
|
+
})))] : [],
|
|
1651
|
+
...Option.isSome(edited) ? [h.DataAttribute("editing", "true")] : [],
|
|
1652
|
+
...readOnly(id) ? [h.AriaReadonly(true)] : [],
|
|
1653
|
+
...Option.match(markOf(address), {
|
|
1654
|
+
onNone: () => [],
|
|
1655
|
+
onSome: (mark) => [
|
|
1656
|
+
h.DataAttribute("mark", mark.name),
|
|
1657
|
+
h.AriaDescription(mark.description),
|
|
1658
|
+
h.Title(mark.description)
|
|
1659
|
+
]
|
|
1660
|
+
})
|
|
1661
|
+
]), Option.match(edited, {
|
|
1662
|
+
onNone: () => [input.cell?.(id, row, h) ?? textOf(grid.columns.byId[id].value(row)), ...handled ? [fillHandle] : []],
|
|
1663
|
+
onSome: (edit) => editorOf(address, edit)
|
|
1664
|
+
}));
|
|
1665
|
+
}))
|
|
1666
|
+
});
|
|
1667
|
+
const bodyRows = Array.from({ length: shown.rows.end - shown.rows.start }, (_, offset) => rowAt(shown.rows.start + offset));
|
|
1668
|
+
const empty = addressableRows(projection.rowCount) === 0;
|
|
1669
|
+
const status = input.status ?? RowStatus.Ready();
|
|
1670
|
+
const busy = isBusy(status);
|
|
1671
|
+
const words = input.words ?? {};
|
|
1672
|
+
const failure = (message) => [h.p(slots.status.attrs([h.Role("alert")]), [(words.failed ?? "{message}").replaceAll("{message}", message)]), ...input.onRetry === void 0 ? [] : [h.button(slots.retry.attrs([h.Type("button"), h.OnClick(input.onRetry)]), [words.retry ?? "Try again"])]];
|
|
1673
|
+
const emptyBody = RowStatus.match(status, {
|
|
1674
|
+
Loading: () => [h.p(slots.status.attrs([h.Role("status"), h.AriaBusy(true)]), [words.loading ?? "Loading…"])],
|
|
1675
|
+
Refreshing: () => [h.p(slots.status.attrs([h.Role("status")]), [words.empty ?? "No rows."])],
|
|
1676
|
+
Ready: () => [h.p(slots.status.attrs([h.Role("status")]), [words.empty ?? "No rows."])],
|
|
1677
|
+
Failed: ({ message }) => failure(message)
|
|
1678
|
+
});
|
|
1679
|
+
const more = Option.filter(Option.fromUndefinedOr(input.onMore), () => RowCount.match(projection.rowCount, {
|
|
1680
|
+
Known: () => false,
|
|
1681
|
+
Unknown: () => true
|
|
1682
|
+
}));
|
|
1683
|
+
const footer = [...empty ? [] : RowStatus.match(status, {
|
|
1684
|
+
Ready: () => [],
|
|
1685
|
+
Loading: () => [],
|
|
1686
|
+
Refreshing: () => [],
|
|
1687
|
+
Failed: ({ message }) => failure(message)
|
|
1688
|
+
}), ...Option.match(more, {
|
|
1689
|
+
onNone: () => [],
|
|
1690
|
+
onSome: (message) => [h.button(slots.more.attrs([
|
|
1691
|
+
h.Type("button"),
|
|
1692
|
+
h.Disabled(busy),
|
|
1693
|
+
h.OnClick(message),
|
|
1694
|
+
h.Key(`more:${addressableRows(projection.rowCount)}:${busy}`)
|
|
1695
|
+
]), [words.more ?? "More"])]
|
|
1696
|
+
})];
|
|
1697
|
+
return h.div(slots.root.attrs([
|
|
1698
|
+
h.Id(grid.id),
|
|
1699
|
+
h.Role("grid"),
|
|
1700
|
+
...busy ? [h.AriaBusy(true)] : [],
|
|
1701
|
+
h.AriaLabel(input.label),
|
|
1702
|
+
h.Tabindex(0),
|
|
1703
|
+
h.AriaRowcount(rowCount),
|
|
1704
|
+
h.AriaColcount(projection.columns.length),
|
|
1705
|
+
...grid.rowSelection === "multiple" || grid.cellSelection ? [h.AriaMultiSelectable(true)] : [],
|
|
1706
|
+
...Option.match(activeDescendant, {
|
|
1707
|
+
onNone: () => [],
|
|
1708
|
+
onSome: (id) => [h.AriaActiveDescendant(id)]
|
|
1709
|
+
}),
|
|
1710
|
+
h.OnKeyDownSelfPreventDefault(onKey),
|
|
1711
|
+
...Option.match(clipboardBox, {
|
|
1712
|
+
onNone: () => [],
|
|
1713
|
+
onSome: clipboard
|
|
1714
|
+
}),
|
|
1715
|
+
h.OnMount(measured),
|
|
1716
|
+
h.Style({
|
|
1717
|
+
overflow: "auto",
|
|
1718
|
+
position: "relative"
|
|
1719
|
+
})
|
|
1720
|
+
]), [
|
|
1721
|
+
h.div(slots.header.attrs([h.Role("rowgroup"), h.Style({
|
|
1722
|
+
position: "sticky",
|
|
1723
|
+
top: "0"
|
|
1724
|
+
})]), [h.div(slots.headerRow.attrs([
|
|
1725
|
+
h.Role("row"),
|
|
1726
|
+
h.AriaRowindex(1),
|
|
1727
|
+
h.Key(`header-drag:${toward}`),
|
|
1728
|
+
h.OnMount(headerDragged),
|
|
1729
|
+
h.Style({
|
|
1730
|
+
display: "flex",
|
|
1731
|
+
height: px(headerHeight),
|
|
1732
|
+
width: px(shown.width)
|
|
1733
|
+
})
|
|
1734
|
+
]), across(headerCell))]),
|
|
1735
|
+
h.div(slots.body.attrs([
|
|
1736
|
+
h.Role("rowgroup"),
|
|
1737
|
+
h.OnMount(pressed),
|
|
1738
|
+
h.Style({
|
|
1739
|
+
height: px(shown.height - headerHeight),
|
|
1740
|
+
width: px(shown.width)
|
|
1741
|
+
})
|
|
1742
|
+
]), empty ? emptyBody : [...shown.rows.before > 0 ? [h.div([h.AriaHidden(true), h.Style({ height: px(shown.rows.before) })], [])] : [], ...bodyRows]),
|
|
1743
|
+
...footer.length === 0 ? [] : [h.div(slots.footer.attrs(), footer)]
|
|
1744
|
+
]);
|
|
1745
|
+
}, { name: grid.id });
|
|
1746
|
+
} });
|
|
1747
|
+
const DataGridView = view;
|
|
1748
|
+
//#endregion
|
|
1749
|
+
//#region src/moreOnScroll.ts
|
|
1750
|
+
/**
|
|
1751
|
+
* Sends the grid's `onMore` as its More button comes within 200px of the
|
|
1752
|
+
* grid's visible box, and again after each load while the button stays
|
|
1753
|
+
* there; the button stays for the keyboard. Nothing is asked while the rows'
|
|
1754
|
+
* source is busy. Attach it to a grid's view:
|
|
1755
|
+
* `DataGridView<Message>().define(Grid).pipe(MoreOnScroll)`.
|
|
1756
|
+
*/
|
|
1757
|
+
const MoreOnScroll = (view) => Behavior.attach(Behavior.forSlots(GridSlots)({ more: Behavior.slot({ mount: (input) => Mount.mapMessage(Nearing({ watching: !isBusy(input.status) }), () => input.onMore) }) }, { name: "MoreOnScroll" }))(view);
|
|
1758
|
+
//#endregion
|
|
1759
|
+
//#region src/marks.ts
|
|
1760
|
+
/**
|
|
1761
|
+
* The marks an editable grid over a server shows on a cell, by name, styled
|
|
1762
|
+
* on the theme's tokens, with a legend drawn from the same rules: an edit not
|
|
1763
|
+
* sent, one saved that the read rows do not show yet, one the server refused,
|
|
1764
|
+
* one another device's later edit replaced, and another device's focus.
|
|
1765
|
+
* `CellMark.name` stays any string; these are the shared ones.
|
|
1766
|
+
*/
|
|
1767
|
+
const GridMarks = [
|
|
1768
|
+
"pending",
|
|
1769
|
+
"saved",
|
|
1770
|
+
"refused",
|
|
1771
|
+
"replaced",
|
|
1772
|
+
"peer"
|
|
1773
|
+
];
|
|
1774
|
+
/** A dot in the top end corner, filled or hollow: an edit on its way. */
|
|
1775
|
+
const dot = (color, hollow) => ({ backgroundImage: hollow ? `radial-gradient(circle at calc(100% - 6px) 6px, transparent 2px, ${color} 2.5px, ${color} 3.5px, transparent 4px)` : `radial-gradient(circle at calc(100% - 6px) 6px, ${color} 3px, transparent 3.5px)` });
|
|
1776
|
+
/** A 2px inset edge, and a ground: something that needs reading. */
|
|
1777
|
+
const edge = (color, ground) => ({
|
|
1778
|
+
backgroundImage: "none",
|
|
1779
|
+
boxShadow: `inset 0 0 0 ${ref.border.thick} ${color}`,
|
|
1780
|
+
...ground === void 0 ? {} : { background: ground }
|
|
1781
|
+
});
|
|
1782
|
+
const looks = {
|
|
1783
|
+
pending: dot(ref.info.default, false),
|
|
1784
|
+
saved: dot(ref.success.ink, true),
|
|
1785
|
+
refused: edge(ref.error.ink, ref.error.subtle),
|
|
1786
|
+
replaced: edge(ref.warning.ink),
|
|
1787
|
+
peer: edge(`var(--fk-grid-peer, ${ref.secondary.default})`, ref.secondary.subtle)
|
|
1788
|
+
};
|
|
1789
|
+
/** Each mark's rule on an element carrying `data-mark`, and an outline where colour is forced. */
|
|
1790
|
+
const marked = Style.compose(...GridMarks.map((mark) => Style.nest(`&[data-mark="${mark}"]`, looks[mark])), Style.at("@media (forced-colors: active)", Style.nest("&[data-mark]", { outline: `${ref.border.thick} solid CanvasText` })));
|
|
1791
|
+
const GridLegendSlots = Slots.define({
|
|
1792
|
+
/** The list: labelled, so a screen reader names what the marks are. */
|
|
1793
|
+
legend: Slot.make({ attributes: [Attr.AriaLabel] }),
|
|
1794
|
+
/** One mark and its words. */
|
|
1795
|
+
legendItem: Slot.make({}),
|
|
1796
|
+
/** The mark drawn small, as the cells draw it (`data-mark`), hidden from screen readers. */
|
|
1797
|
+
legendSwatch: Slot.make({})
|
|
1798
|
+
});
|
|
1799
|
+
const words = {
|
|
1800
|
+
label: "What the marks on a cell mean",
|
|
1801
|
+
marks: {
|
|
1802
|
+
pending: "Not sent yet",
|
|
1803
|
+
saved: "Saved, not yet in the table",
|
|
1804
|
+
refused: "Not saved",
|
|
1805
|
+
replaced: "Replaced by another device",
|
|
1806
|
+
peer: "Another device is here"
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
/**
|
|
1810
|
+
* A legend of the marks, each beside a swatch drawn by the same rules as the
|
|
1811
|
+
* cells (`GridMarkStyle` styles both), so the two cannot drift apart.
|
|
1812
|
+
*/
|
|
1813
|
+
const GridLegend = () => SlotView.forMessages().define(GridLegendSlots, (input, slots, h) => {
|
|
1814
|
+
const said = input.words ?? words;
|
|
1815
|
+
return h.ul(slots.legend.attrs([h.AriaLabel(said.label)]), (input.marks ?? GridMarks).map((mark) => h.li(slots.legendItem.attrs([]), [h.span(slots.legendSwatch.attrs([h.DataAttribute("mark", mark), h.AriaHidden(true)]), []), said.marks[mark]])));
|
|
1816
|
+
});
|
|
1817
|
+
/** The marks on the grid's cells. Attach after `GridStyle`, whose plain dot it replaces by name. */
|
|
1818
|
+
const GridMarkStyle = Style.forSlots(GridSlots)({ cell: marked }, { layer: Layers.standard.layer("components") });
|
|
1819
|
+
/** The legend's look, its swatches drawn by the cells' own rules. */
|
|
1820
|
+
const GridLegendStyle = Style.forSlots(GridLegendSlots)({
|
|
1821
|
+
legend: {
|
|
1822
|
+
display: "flex",
|
|
1823
|
+
flexWrap: "wrap",
|
|
1824
|
+
gap: `${ref.space["2xs"]} ${ref.space.md}`,
|
|
1825
|
+
listStyle: "none",
|
|
1826
|
+
margin: "0",
|
|
1827
|
+
padding: "0",
|
|
1828
|
+
fontSize: ref.size.xs,
|
|
1829
|
+
color: ref.text.muted
|
|
1830
|
+
},
|
|
1831
|
+
legendItem: {
|
|
1832
|
+
display: "inline-flex",
|
|
1833
|
+
alignItems: "center",
|
|
1834
|
+
gap: ref.space.xs
|
|
1835
|
+
},
|
|
1836
|
+
legendSwatch: Style.compose(Style.inline({
|
|
1837
|
+
display: "inline-block",
|
|
1838
|
+
width: "12px",
|
|
1839
|
+
height: "12px",
|
|
1840
|
+
borderRadius: ref.radius.xs
|
|
1841
|
+
}), marked)
|
|
1842
|
+
}, { layer: Layers.standard.layer("components") });
|
|
1843
|
+
//#endregion
|
|
1844
|
+
export { DataGridView, GridLegend, GridLegendSlots, GridLegendStyle, GridMarkStyle, GridMarks, GridSlots, GridStyle, MoreOnScroll };
|
|
1845
|
+
|
|
1846
|
+
//# sourceMappingURL=index.mjs.map
|