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/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