foldkit-mixins-data-grid 0.2.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/fillDrag.d.ts +30 -0
  2. package/dist/fillDrag.d.ts.map +1 -0
  3. package/dist/fillDrag.js +178 -0
  4. package/dist/fillDrag.js.map +1 -0
  5. package/dist/headerDrag.d.ts +34 -0
  6. package/dist/headerDrag.d.ts.map +1 -0
  7. package/dist/headerDrag.js +112 -0
  8. package/dist/headerDrag.js.map +1 -0
  9. package/dist/holdFocus.d.ts +11 -0
  10. package/dist/holdFocus.d.ts.map +1 -0
  11. package/dist/holdFocus.js +31 -0
  12. package/dist/holdFocus.js.map +1 -0
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.js +6 -0
  15. package/dist/keepFocus.d.ts +9 -0
  16. package/dist/keepFocus.d.ts.map +1 -0
  17. package/dist/keepFocus.js +20 -0
  18. package/dist/keepFocus.js.map +1 -0
  19. package/dist/marks.d.ts +69 -0
  20. package/dist/marks.d.ts.map +1 -0
  21. package/dist/marks.js +90 -0
  22. package/dist/marks.js.map +1 -0
  23. package/dist/moreOnScroll.d.ts +12 -0
  24. package/dist/moreOnScroll.d.ts.map +1 -0
  25. package/dist/moreOnScroll.js +17 -0
  26. package/dist/moreOnScroll.js.map +1 -0
  27. package/dist/nearEnd.d.ts +15 -0
  28. package/dist/nearEnd.d.ts.map +1 -0
  29. package/dist/nearEnd.js +33 -0
  30. package/dist/nearEnd.js.map +1 -0
  31. package/dist/press.d.ts +31 -0
  32. package/dist/press.d.ts.map +1 -0
  33. package/dist/press.js +53 -0
  34. package/dist/press.js.map +1 -0
  35. package/dist/slots.d.ts +85 -0
  36. package/dist/slots.d.ts.map +1 -0
  37. package/dist/slots.js +289 -0
  38. package/dist/slots.js.map +1 -0
  39. package/dist/style.d.ts +555 -0
  40. package/dist/style.d.ts.map +1 -0
  41. package/dist/style.js +224 -0
  42. package/dist/style.js.map +1 -0
  43. package/dist/view.d.ts +140 -0
  44. package/dist/view.d.ts.map +1 -0
  45. package/dist/{index.mjs → view.js} +10 -995
  46. package/dist/view.js.map +1 -0
  47. package/package.json +12 -12
  48. package/dist/index.d.mts +0 -321
  49. package/dist/index.d.mts.map +0 -1
  50. package/dist/index.mjs.map +0 -1
@@ -1,906 +1,14 @@
1
- import { Attr, Behavior, Capability, Event, Layers, Slot, SlotView, Slots, Style } from "foldkit-mixins";
2
- import { Theme } from "foldkit-mixins/theme";
1
+ import { Match, Option } from "effect";
3
2
  import * as Mount from "foldkit/mount";
4
- import { Effect, Match, Option, Queue, Schema, Stream } from "effect";
3
+ import { GridSlots } from "./slots.js";
4
+ import { SlotView } from "foldkit-mixins";
5
+ import { fillHandleAttribute } from "./fillDrag.js";
5
6
  import { CellEditor, Clipboard, Columns, Fill, GridFocus, GridSelection, GridViewport, RowCount, RowStatus, VirtualGrid, addressableRows } from "foldkit-data-grid";
6
7
  import { Move } from "foldkit-primitives/dom";
7
- // 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
- // src/style.ts
293
- /**
294
- * The grid's default look, for a page that ships `foldkit-mixins/theme`'s
295
- * tokens and palette. It only paints: the geometry the virtual window
296
- * depends on is the view's, and the slots refuse a Style that sets it.
297
- */
298
- /** The tokens the grid's styles draw with; the marks' too (`marks.ts`). */
299
- const ref = Theme.ref(Theme.standard());
300
- const line = `${ref.border.thin} solid ${ref.outline.subtle}`;
301
- const faint = `${ref.border.thin} solid color-mix(in oklch, ${ref.outline.subtle} 55%, transparent)`;
302
- 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}` })];
303
- const field = Style.compose(Style.self({
304
- background: ref.surface.base,
305
- border: "none",
306
- caretColor: ref.outline.focus,
307
- color: "inherit",
308
- font: "inherit",
309
- outline: `${ref.border.thick} solid ${ref.outline.focus}`,
310
- outlineOffset: `calc(-1 * ${ref.border.thick})`,
311
- paddingInline: ref.space.sm
312
- }), Style.media("(pointer: coarse)", { fontSize: "max(1rem, 1em)" }));
313
- const refused = Style.nest("&[aria-invalid=\"true\"]", {
314
- outlineColor: ref.error.default,
315
- caretColor: ref.error.default
316
- });
317
- /** Every piece is in the `components` layer, so an application's own layer wins. */
318
- const GridStyle = Style.forSlots(GridSlots)({
319
- root: Style.compose(Style.self({
320
- background: ref.surface.base,
321
- color: ref.text.default,
322
- fontVariantNumeric: "tabular-nums"
323
- }), Style.pseudo(":focus-visible", { outline: `${ref.border.thick} solid ${ref.outline.focus}` })),
324
- header: Style.self({
325
- zIndex: "2",
326
- background: ref.surface.muted
327
- }),
328
- headerCell: Style.compose(Style.self({
329
- alignItems: "center",
330
- background: ref.surface.muted,
331
- borderBlockEnd: line,
332
- display: "flex",
333
- fontSize: ref.size.sm,
334
- fontWeight: ref.weight.semibold,
335
- paddingInline: ref.space.sm,
336
- whiteSpace: "nowrap",
337
- userSelect: "none"
338
- }), Style.nest("&[data-pinned]", { zIndex: "1" }), ...pinnedEdge, Style.nest("&[aria-readonly=\"true\"]::before", {
339
- background: ref.text.muted,
340
- content: "\"\"",
341
- flex: "none",
342
- height: "0.75em",
343
- marginInlineEnd: ref.space["2xs"],
344
- mask: `${ref.icon.lock} center / contain no-repeat`,
345
- width: "0.75em"
346
- }), Style.nest("&[data-dragging]", {
347
- zIndex: "3",
348
- background: ref.surface.default,
349
- opacity: "0.9",
350
- cursor: "grabbing"
351
- }), 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}` })),
352
- cell: Style.compose(Style.self({
353
- alignItems: "center",
354
- borderBlockEnd: line,
355
- borderInlineEnd: faint,
356
- display: "flex",
357
- overflow: "hidden",
358
- paddingInline: ref.space.sm,
359
- textOverflow: "ellipsis",
360
- whiteSpace: "nowrap"
361
- }), Style.nest("&[data-pinned]", {
362
- background: ref.surface.base,
363
- zIndex: "1"
364
- }), ...pinnedEdge, Style.nest("&[data-editable]", { cursor: "text" }), Style.nest("&[aria-readonly=\"true\"]", {
365
- color: ref.text.muted,
366
- cursor: "default"
367
- }), Style.nest("&[data-editing]", {
368
- boxShadow: ref.shadow.md,
369
- overflow: "visible",
370
- paddingInline: "0",
371
- zIndex: "2"
372
- }), Style.nest("&[data-focused=\"true\"]", {
373
- outline: `${ref.border.thick} solid ${ref.outline.focus}`,
374
- outlineOffset: `calc(-1 * ${ref.border.thick})`
375
- }), Style.nest("&[data-fill=\"target\"]", {
376
- outline: `${ref.border.thin} dashed ${ref.outline.focus}`,
377
- outlineOffset: `calc(-1 * ${ref.border.thin})`
378
- }), Style.nest("&[data-mark]", { backgroundImage: `radial-gradient(circle at calc(100% - 6px) 6px, ${ref.outline.focus} 3px, transparent 3.5px)` })),
379
- editor: Style.compose(field, refused),
380
- choice: Style.compose(field, refused, Style.self({
381
- display: "flex",
382
- alignItems: "center",
383
- cursor: "default"
384
- })),
385
- choiceList: Style.compose(Style.self({
386
- zIndex: "3",
387
- minWidth: "100%",
388
- maxHeight: "16rem",
389
- overflowY: "auto",
390
- margin: "0",
391
- padding: ref.space["3xs"],
392
- listStyle: "none",
393
- background: ref.surface.base,
394
- border: `${ref.border.thin} solid ${ref.outline.default}`,
395
- borderRadius: ref.radius.md,
396
- boxShadow: ref.shadow.lg
397
- })),
398
- choiceOption: Style.compose(Style.self({
399
- padding: `${ref.space["2xs"]} ${ref.space.sm}`,
400
- borderRadius: ref.radius.sm,
401
- cursor: "pointer",
402
- whiteSpace: "nowrap"
403
- }), Style.nest("&:hover", { background: ref.surface.subtle }), Style.nest("&[aria-selected=\"true\"]", {
404
- background: ref.accent.subtle,
405
- color: ref.accent.ink
406
- }), Style.media("(pointer: coarse)", {
407
- minHeight: "44px",
408
- display: "flex",
409
- alignItems: "center"
410
- })),
411
- editorError: Style.self({
412
- background: ref.error.default,
413
- borderRadius: ref.radius.md,
414
- boxShadow: ref.shadow.lg,
415
- color: ref.error["on-fill"],
416
- fontSize: ref.size.xs,
417
- fontWeight: ref.weight.medium,
418
- insetInlineStart: "0",
419
- paddingBlock: ref.space["3xs"],
420
- paddingInline: ref.space.xs,
421
- pointerEvents: "none",
422
- position: "absolute",
423
- top: `calc(100% + ${ref.space["3xs"]})`,
424
- whiteSpace: "nowrap",
425
- zIndex: "5"
426
- }),
427
- resizeHandle: Style.compose(Style.self({
428
- background: `linear-gradient(to right, transparent calc(100% - 1px), color-mix(in oklch, ${ref.outline.subtle} 70%, transparent) calc(100% - 1px))`,
429
- WebkitTouchCallout: "none"
430
- }), Style.pseudo(":hover", { background: `linear-gradient(to right, transparent calc(100% - 2px), ${ref.outline.focus} calc(100% - 2px))` }), Style.at("@media (pointer: coarse)", Style.nest("&::before", {
431
- content: "\"\"",
432
- position: "absolute",
433
- insetBlock: "0",
434
- insetInlineStart: "-12px",
435
- insetInlineEnd: "-4px"
436
- }))),
437
- fillHandle: Style.compose(Style.self({
438
- width: "7px",
439
- height: "7px",
440
- background: ref.outline.focus,
441
- border: `1px solid ${ref.surface.base}`,
442
- cursor: "crosshair",
443
- zIndex: "3",
444
- touchAction: "none",
445
- WebkitTouchCallout: "none"
446
- }), Style.at("@media (pointer: coarse)", Style.nest("&::before", {
447
- content: "\"\"",
448
- position: "absolute",
449
- inset: "-18px"
450
- }))),
451
- menuButton: Style.compose(Style.self({
452
- background: "none",
453
- border: "none",
454
- color: ref.text.muted,
455
- cursor: "pointer",
456
- marginInlineStart: "auto",
457
- paddingInline: ref.space.xs
458
- }), Style.nest("&[aria-expanded=\"true\"]", { color: ref.text.default })),
459
- menu: Style.compose(Style.self({
460
- background: ref.surface.base,
461
- border: line,
462
- borderRadius: ref.radius.md,
463
- fontWeight: ref.weight.normal,
464
- minWidth: "10rem",
465
- paddingBlock: ref.space.xs,
466
- zIndex: "4"
467
- }), Style.pseudo(":focus-visible", { outline: `${ref.border.thick} solid ${ref.outline.focus}` })),
468
- menuItem: Style.compose(Style.self({
469
- cursor: "pointer",
470
- paddingBlock: ref.space.xs,
471
- paddingInline: ref.space.sm
472
- }), Style.nest("&[data-active=\"true\"]", { background: ref.surface.muted })),
473
- sort: Style.compose(Style.self({
474
- alignItems: "center",
475
- background: "none",
476
- border: "none",
477
- color: "inherit",
478
- cursor: "pointer",
479
- display: "inline-flex",
480
- font: "inherit",
481
- gap: ref.space.xs,
482
- minWidth: "0",
483
- overflow: "hidden",
484
- padding: "0",
485
- textOverflow: "ellipsis",
486
- whiteSpace: "nowrap"
487
- }), Style.nest("&::after", {
488
- borderBlockEnd: `1.5px solid currentColor`,
489
- borderInlineEnd: `1.5px solid currentColor`,
490
- content: "\"\"",
491
- flex: "none",
492
- height: "0.4em",
493
- opacity: "0",
494
- transform: "translateY(-0.15em) rotate(45deg)",
495
- transition: `opacity ${ref.motion.fast} ${ref.motion.ease}, transform calc(${ref.motion.fast} * ${ref.knob.travel}) ${ref.motion.ease}`,
496
- width: "0.4em"
497
- }), Style.nest("&:hover::after", { opacity: "0.35" }), Style.nest("&[data-sort=\"desc\"]::after", { opacity: "1" }), Style.nest("&[data-sort=\"asc\"]::after", {
498
- opacity: "1",
499
- transform: "translateY(0.1em) rotate(-135deg)"
500
- })),
501
- placeholder: Style.self({ background: ref.surface.subtle }),
502
- status: Style.self({
503
- color: ref.text.muted,
504
- padding: ref.space.md
505
- })
506
- }, { layer: Layers.standard.layer("components") });
507
- // src/nearEnd.ts
508
- const NearEnd = Schema.TaggedStruct("NearEnd", {});
509
- /** How far ahead of the end the next rows are asked for, so they arrive before they are needed. */
510
- const ahead = "200px";
511
- /**
512
- * The element coming within 200px of the grid's visible box: one report each
513
- * time it comes in, measured against the scroll container it sits in, not
514
- * the page. Not `watching` (a load in flight), or no observer (SSR, an old
515
- * browser), it reports nothing.
516
- */
517
- const Nearing = Mount.defineStream("DataGridNearEnd", {
518
- args: { watching: Schema.Boolean },
519
- messages: [NearEnd],
520
- execute: ({ element, watching }) => Stream.callback((queue) => Effect.gen(function* () {
521
- const Observer = globalThis.IntersectionObserver;
522
- if (!watching || Observer === void 0) return;
523
- yield* Effect.acquireRelease(Effect.sync(() => {
524
- const observer = new Observer((entries) => {
525
- if (entries.some((entry) => entry.isIntersecting)) Queue.offerUnsafe(queue, NearEnd.make({}));
526
- }, {
527
- root: element.closest("[role=\"grid\"]"),
528
- rootMargin: ahead
529
- });
530
- observer.observe(element);
531
- return observer;
532
- }), (observer) => Effect.sync(() => observer.disconnect()));
533
- }))
534
- });
535
- // src/fillDrag.ts
536
- const FillDragStarted = Schema.TaggedStruct("FillDragStarted", {});
537
- const FillDraggedOver = Schema.TaggedStruct("FillDraggedOver", {
538
- /** The DOM id of the gridcell under the pointer. */
539
- cell: Schema.String });
540
- const FillDragEnded = Schema.TaggedStruct("FillDragEnded", {
541
- /** `false` when the pointer was cancelled or capture was lost. */
542
- completed: Schema.Boolean });
543
- /** The attribute that marks the fill handle, which the body's listener looks for. */
544
- const fillHandleAttribute = "data-fill-handle";
545
- /** How far a press moves before it is a fill, so a stray click fills nothing. */
546
- const threshold$1 = 4;
547
- /** How near the grid's edge the pointer brings a scroll, and the fastest step a frame. */
548
- const edge$1 = 24;
549
- const fastest = 20;
550
- /**
551
- * The fill handle dragged with the pointer, from the element that holds the
552
- * grid's cells, so the drag outlives the handle's own cell scrolling out of
553
- * the window. A press on the handle is kept from the cells (a press there
554
- * would select one instead). Once it has moved 4px the pointer is captured,
555
- * so the cell under it is found by where it is (`elementFromPoint`), not by
556
- * the event's target, which capture makes this element. While the pointer is
557
- * within 24px of the grid's edge, or past it, the grid scrolls toward it. The
558
- * listeners that follow the pointer, and the scroll, live for one press.
559
- *
560
- * Returns the cleanup, and whether a click now is the one a drag ended with.
561
- */
562
- const fillDragOn = (element, offer) => {
563
- const page = element.ownerDocument;
564
- const view = page.defaultView;
565
- let press = Option.none();
566
- let dragEnded = false;
567
- const pointerOf = (event) => event.pointerId ?? 0;
568
- const grid = () => Option.fromNullishOr(element.closest("[role=\"grid\"]"));
569
- const probe = ({ x, y }) => Option.match(grid(), {
570
- onNone: () => ({
571
- x,
572
- y,
573
- toward: {
574
- x,
575
- y
576
- }
577
- }),
578
- onSome: (box) => {
579
- const rect = box.getBoundingClientRect();
580
- const header = box.querySelector("[role=\"row\"][aria-rowindex=\"1\"]");
581
- const top = header === null ? rect.top : header.getBoundingClientRect().bottom;
582
- const clamp = (at, from, to) => Math.max(from, Math.min(to, at));
583
- const right = rect.left + box.clientWidth - 2;
584
- const bottom = rect.top + box.clientHeight - 2;
585
- return {
586
- x: clamp(x, rect.left + 1, right),
587
- y: clamp(y, top + 1, bottom),
588
- toward: {
589
- x: (rect.left + right) / 2,
590
- y: (top + bottom) / 2
591
- }
592
- };
593
- }
594
- });
595
- const cellAt = ({ x, y, toward }) => {
596
- const steps = 48;
597
- for (let step = 0; step <= steps; step++) {
598
- const found = page.elementFromPoint(x + (toward.x - x) * step / steps, y + (toward.y - y) * step / steps)?.closest("[role=\"gridcell\"]");
599
- if (found !== null && found !== void 0 && found.id !== "") return Option.some(found.id);
600
- }
601
- return Option.none();
602
- };
603
- const report = (current) => {
604
- const found = cellAt(probe(current.at));
605
- if (Option.isNone(found) || found.value === current.over) return;
606
- current.over = found.value;
607
- offer(FillDraggedOver.make({ cell: found.value }));
608
- };
609
- const stepOf = (at, start, end) => {
610
- if (at < start + edge$1) return Math.max(-20, at - (start + edge$1));
611
- if (at > end - edge$1) return Math.min(fastest, at - (end - edge$1));
612
- return 0;
613
- };
614
- const scroll = (current) => {
615
- current.frame = Option.none();
616
- Option.match(grid(), {
617
- onNone: () => {},
618
- onSome: (box) => {
619
- const rect = box.getBoundingClientRect();
620
- const down = stepOf(current.at.y, rect.top, rect.bottom);
621
- const across = stepOf(current.at.x, rect.left, rect.right);
622
- if (down === 0 && across === 0) return;
623
- report(current);
624
- box.scrollBy(across, down);
625
- if (view !== null) current.frame = Option.some(view.requestAnimationFrame(() => scroll(current)));
626
- }
627
- });
628
- };
629
- const release = (current) => {
630
- page.removeEventListener("pointermove", onMove);
631
- page.removeEventListener("pointerup", onUp);
632
- page.removeEventListener("pointercancel", onCancel);
633
- element.removeEventListener("lostpointercapture", onCancel);
634
- if (Option.isSome(current.frame)) view?.cancelAnimationFrame(current.frame.value);
635
- current.frame = Option.none();
636
- if (!current.dragging) return;
637
- try {
638
- element.releasePointerCapture(current.pointerId);
639
- } catch {}
640
- };
641
- const finish = (event, completed) => {
642
- const found = Option.filter(press, (current) => current.pointerId === pointerOf(event));
643
- if (Option.isNone(found)) return;
644
- press = Option.none();
645
- release(found.value);
646
- if (!found.value.dragging) return;
647
- dragEnded = true;
648
- view?.setTimeout(() => dragEnded = false, 0);
649
- offer(FillDragEnded.make({ completed }));
650
- };
651
- const onUp = (event) => finish(event, true);
652
- const onCancel = (event) => finish(event, false);
653
- const onMove = (event) => {
654
- const found = Option.filter(press, (current) => current.pointerId === pointerOf(event));
655
- if (Option.isNone(found)) return;
656
- const current = found.value;
657
- const pointer = event;
658
- current.at = {
659
- x: pointer.clientX ?? 0,
660
- y: pointer.clientY ?? 0
661
- };
662
- if (!current.dragging) {
663
- if (Math.max(Math.abs(current.at.x - current.x), Math.abs(current.at.y - current.y)) < threshold$1) return;
664
- current.dragging = true;
665
- try {
666
- element.setPointerCapture(current.pointerId);
667
- } catch {}
668
- offer(FillDragStarted.make({}));
669
- }
670
- report(current);
671
- if (Option.isNone(current.frame)) scroll(current);
672
- };
673
- const onDown = (event) => {
674
- const pointer = event;
675
- const from = event.target;
676
- if (!(from instanceof Element) || from.closest(`[data-fill-handle]`) === null) return;
677
- event.stopPropagation();
678
- if ((pointer.button ?? 0) !== 0 || Option.isSome(press)) return;
679
- event.preventDefault();
680
- const x = pointer.clientX ?? 0;
681
- const y = pointer.clientY ?? 0;
682
- press = Option.some({
683
- pointerId: pointerOf(event),
684
- x,
685
- y,
686
- at: {
687
- x,
688
- y
689
- },
690
- dragging: false,
691
- over: "",
692
- frame: Option.none()
693
- });
694
- page.addEventListener("pointermove", onMove);
695
- page.addEventListener("pointerup", onUp);
696
- page.addEventListener("pointercancel", onCancel);
697
- element.addEventListener("lostpointercapture", onCancel);
698
- };
699
- element.addEventListener("pointerdown", onDown);
700
- return {
701
- dispose: () => {
702
- element.removeEventListener("pointerdown", onDown);
703
- if (Option.isSome(press)) release(press.value);
704
- press = Option.none();
705
- },
706
- owns: (event) => dragEnded || event.target instanceof Element && event.target.closest(`[data-fill-handle]`) !== null
707
- };
708
- };
709
- // src/holdFocus.ts
710
- /**
711
- * Focuses what the grid draws over itself while it is open (a cell's editor,
712
- * with the caret after its text, or a column's menu) and hands focus back to
713
- * the grid when it goes, so closing it leaves the keyboard on the grid rather
714
- * than on the page's body. Focus the user moved elsewhere stays there. A
715
- * choice's `select` also opens its list, so a double-click on the cell shows
716
- * the choices at once rather than asking for another click.
717
- */
718
- const HoldFocus = Mount.defineStream("DataGridHoldFocus", {
719
- messages: [Schema.Never],
720
- execute: ({ element }) => Stream.callback(() => Effect.acquireRelease(Effect.sync(() => {
721
- const editor = element;
722
- const grid = element.closest("[role=\"grid\"]");
723
- editor.focus?.();
724
- const end = editor.value?.length ?? 0;
725
- editor.setSelectionRange?.(end, end);
726
- if (element.tagName === "SELECT") try {
727
- editor.showPicker?.();
728
- } catch {}
729
- return grid;
730
- }), (grid) => Effect.sync(() => {
731
- const active = typeof document === "undefined" ? null : document.activeElement;
732
- if (active === null || active === element || active === document.body) grid?.focus?.();
733
- })))
734
- });
735
- // src/keepFocus.ts
736
- /**
737
- * Keeps focus where it is when a press lands on the element: a choice's list
738
- * sits beside its combobox, so a press on an option would otherwise blur the
739
- * combobox, which commits the old draft before the option's click arrives.
740
- * Attached only while the list is open.
741
- */
742
- const KeepFocus = Mount.defineStream("DataGridKeepFocus", {
743
- messages: [Schema.Never],
744
- execute: ({ element }) => Stream.callback(() => Effect.acquireRelease(Effect.sync(() => {
745
- const keep = (event) => event.preventDefault();
746
- element.addEventListener("mousedown", keep);
747
- return keep;
748
- }), (keep) => Effect.sync(() => element.removeEventListener("mousedown", keep))))
749
- });
750
- // src/headerDrag.ts
751
- const HeaderDragStarted = Schema.TaggedStruct("HeaderDragStarted", {
752
- /** The dragged columnheader's DOM id. */
753
- header: Schema.String });
754
- const HeaderDragged = Schema.TaggedStruct("HeaderDragged", {
755
- /** From where the pointer went down, in CSS pixels. */
756
- deltaX: Schema.Number });
757
- const HeaderDragEnded = Schema.TaggedStruct("HeaderDragEnded", {
758
- /** `false` when the pointer was cancelled or capture was lost. */
759
- completed: Schema.Boolean });
760
- /** How far a press moves before it is a drag, so a click on a header stays a click. */
761
- const threshold = 4;
762
- /**
763
- * Column headers dragged with the pointer, from one listener on the row that
764
- * holds them. A press on a header becomes a drag once it has moved 4px, and
765
- * only then is the pointer captured, so a click (on a sort button, say) stays
766
- * a click. A press on a resize handle, or in a column's open menu, is not a
767
- * drag. The listeners that
768
- * follow the pointer are attached for the length of a press. Where a drag
769
- * lands is the grid's update.
770
- */
771
- const HeaderDrag = Mount.defineStream("DataGridHeaderDrag", {
772
- messages: [
773
- HeaderDragStarted,
774
- HeaderDragged,
775
- HeaderDragEnded
776
- ],
777
- execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
778
- const page = element.ownerDocument;
779
- let press = Option.none();
780
- const offer = (fact) => Queue.offerUnsafe(queue, fact);
781
- const swallow = (event) => {
782
- event.stopPropagation();
783
- event.preventDefault();
784
- };
785
- const release = (current) => {
786
- page.removeEventListener("pointermove", onMove);
787
- page.removeEventListener("pointerup", onUp);
788
- page.removeEventListener("pointercancel", onCancel);
789
- element.removeEventListener("lostpointercapture", onCancel);
790
- if (!current.dragging) return;
791
- try {
792
- element.releasePointerCapture(current.pointerId);
793
- } catch {}
794
- };
795
- const pressOf = (event) => Option.filter(press, (current) => current.pointerId === (event.pointerId ?? 0));
796
- const finish = (event, completed) => {
797
- const found = pressOf(event);
798
- if (Option.isNone(found)) return;
799
- const current = found.value;
800
- press = Option.none();
801
- release(current);
802
- if (!current.dragging) return;
803
- offer(HeaderDragEnded.make({ completed }));
804
- element.addEventListener("click", swallow, {
805
- capture: true,
806
- once: true
807
- });
808
- setTimeout(() => element.removeEventListener("click", swallow, true), 0);
809
- };
810
- const onUp = (event) => finish(event, true);
811
- const onCancel = (event) => finish(event, false);
812
- const onMove = (event) => {
813
- const found = pressOf(event);
814
- if (Option.isNone(found)) return;
815
- const current = found.value;
816
- const deltaX = (event.clientX ?? 0) - current.x;
817
- if (!current.dragging) {
818
- if (Math.abs(deltaX) < threshold) return;
819
- current.dragging = true;
820
- try {
821
- element.setPointerCapture(current.pointerId);
822
- } catch {}
823
- offer(HeaderDragStarted.make({ header: current.header }));
824
- }
825
- offer(HeaderDragged.make({ deltaX }));
826
- };
827
- const onDown = (event) => {
828
- const pointer = event;
829
- const from = event.target;
830
- if ((pointer.button ?? 0) !== 0 || Option.isSome(press)) return;
831
- if (!(from instanceof Element) || from.closest("[role=\"separator\"], [role=\"menu\"]")) return;
832
- const header = from.closest("[role=\"columnheader\"]");
833
- if (header === null || !element.contains(header) || header.id === "") return;
834
- press = Option.some({
835
- pointerId: pointer.pointerId ?? 0,
836
- x: pointer.clientX ?? 0,
837
- header: header.id,
838
- dragging: false
839
- });
840
- page.addEventListener("pointermove", onMove);
841
- page.addEventListener("pointerup", onUp);
842
- page.addEventListener("pointercancel", onCancel);
843
- element.addEventListener("lostpointercapture", onCancel);
844
- };
845
- element.addEventListener("pointerdown", onDown);
846
- return {
847
- onDown,
848
- current: () => press,
849
- release
850
- };
851
- }), ({ onDown, current, release }) => Effect.sync(() => {
852
- element.removeEventListener("pointerdown", onDown);
853
- const left = current();
854
- if (Option.isSome(left)) release(left.value);
855
- })))
856
- });
857
- // src/press.ts
858
- const CellClicked = Schema.TaggedStruct("CellClicked", {
859
- /** The clicked gridcell's DOM id. */
860
- cell: Schema.String,
861
- shiftKey: Schema.Boolean,
862
- /** Ctrl, or Meta on a Mac: a toggle. */
863
- toggleKey: Schema.Boolean
864
- });
865
- /**
866
- * Clicks on a grid's cells, with their modifier keys, and the fill handle's
867
- * drag (`fillDragOn`), from listeners on the element that holds the cells
868
- * rather than one per cell: one Mount, since an element holds one. A click
869
- * outside a gridcell, on the fill handle, or ending a fill reports nothing;
870
- * what a click means is the grid's update.
871
- */
872
- const CellPress = Mount.defineStream("DataGridCellPress", {
873
- messages: [
874
- CellClicked,
875
- FillDragStarted,
876
- FillDraggedOver,
877
- FillDragEnded
878
- ],
879
- execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
880
- const fill = fillDragOn(element, (fact) => Queue.offerUnsafe(queue, fact));
881
- const listener = (event) => {
882
- if (fill.owns(event)) return;
883
- const from = event.target;
884
- if (!(from instanceof Element)) return;
885
- const cell = from.closest("[role=\"gridcell\"]");
886
- if (cell === null || !element.contains(cell) || cell.id === "") return;
887
- const modified = event;
888
- Queue.offerUnsafe(queue, CellClicked.make({
889
- cell: cell.id,
890
- shiftKey: modified.shiftKey === true,
891
- toggleKey: modified.ctrlKey === true || modified.metaKey === true
892
- }));
893
- };
894
- element.addEventListener("click", listener);
895
- return {
896
- listener,
897
- fill
898
- };
899
- }), ({ listener, fill }) => Effect.sync(() => {
900
- element.removeEventListener("click", listener);
901
- fill.dispose();
902
- })))
903
- });
8
+ import { HoldFocus } from "./holdFocus.js";
9
+ import { KeepFocus } from "./keepFocus.js";
10
+ import { HeaderDrag } from "./headerDrag.js";
11
+ import { CellPress } from "./press.js";
904
12
  // src/view.ts
905
13
  const px = (value) => `${value}px`;
906
14
  /** Whether the rows' source is reading: the grid says so, and asks for nothing more. */
@@ -1748,99 +856,6 @@ define: (grid) => {
1748
856
  }, { name: grid.id });
1749
857
  } });
1750
858
  const DataGridView = view;
1751
- // src/moreOnScroll.ts
1752
- /**
1753
- * Sends the grid's `onMore` as its More button comes within 200px of the
1754
- * grid's visible box, and again after each load while the button stays
1755
- * there; the button stays for the keyboard. Nothing is asked while the rows'
1756
- * source is busy. Attach it to a grid's view:
1757
- * `DataGridView<Message>().define(Grid).pipe(MoreOnScroll)`.
1758
- */
1759
- 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);
1760
- // src/marks.ts
1761
- /**
1762
- * The marks an editable grid over a server shows on a cell, by name, styled
1763
- * on the theme's tokens, with a legend drawn from the same rules: an edit not
1764
- * sent, one saved that the read rows do not show yet, one the server refused,
1765
- * one another device's later edit replaced, and another device's focus.
1766
- * `CellMark.name` stays any string; these are the shared ones.
1767
- */
1768
- const GridMarks = [
1769
- "pending",
1770
- "saved",
1771
- "refused",
1772
- "replaced",
1773
- "peer"
1774
- ];
1775
- /** A dot in the top end corner, filled or hollow: an edit on its way. */
1776
- 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)` });
1777
- /** A 2px inset edge, and a ground: something that needs reading. */
1778
- const edge = (color, ground) => ({
1779
- backgroundImage: "none",
1780
- boxShadow: `inset 0 0 0 ${ref.border.thick} ${color}`,
1781
- ...ground === void 0 ? {} : { background: ground }
1782
- });
1783
- const looks = {
1784
- pending: dot(ref.info.default, false),
1785
- saved: dot(ref.success.ink, true),
1786
- refused: edge(ref.error.ink, ref.error.subtle),
1787
- replaced: edge(ref.warning.ink),
1788
- peer: edge(`var(--fk-grid-peer, ${ref.secondary.default})`, ref.secondary.subtle)
1789
- };
1790
- /** Each mark's rule on an element carrying `data-mark`, and an outline where colour is forced. */
1791
- 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` })));
1792
- const GridLegendSlots = Slots.define({
1793
- /** The list: labelled, so a screen reader names what the marks are. */
1794
- legend: Slot.make({ attributes: [Attr.AriaLabel] }),
1795
- /** One mark and its words. */
1796
- legendItem: Slot.make({}),
1797
- /** The mark drawn small, as the cells draw it (`data-mark`), hidden from screen readers. */
1798
- legendSwatch: Slot.make({})
1799
- });
1800
- const words = {
1801
- label: "What the marks on a cell mean",
1802
- marks: {
1803
- pending: "Not sent yet",
1804
- saved: "Saved, not yet in the table",
1805
- refused: "Not saved",
1806
- replaced: "Replaced by another device",
1807
- peer: "Another device is here"
1808
- }
1809
- };
1810
- /**
1811
- * A legend of the marks, each beside a swatch drawn by the same rules as the
1812
- * cells (`GridMarkStyle` styles both), so the two cannot drift apart.
1813
- */
1814
- const GridLegend = () => SlotView.forMessages().define(GridLegendSlots, (input, slots, h) => {
1815
- const said = input.words ?? words;
1816
- 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]])));
1817
- });
1818
- /** The marks on the grid's cells. Attach after `GridStyle`, whose plain dot it replaces by name. */
1819
- const GridMarkStyle = Style.forSlots(GridSlots)({ cell: marked }, { layer: Layers.standard.layer("components") });
1820
- /** The legend's look, its swatches drawn by the cells' own rules. */
1821
- const GridLegendStyle = Style.forSlots(GridLegendSlots)({
1822
- legend: {
1823
- display: "flex",
1824
- flexWrap: "wrap",
1825
- gap: `${ref.space["2xs"]} ${ref.space.md}`,
1826
- listStyle: "none",
1827
- margin: "0",
1828
- padding: "0",
1829
- fontSize: ref.size.xs,
1830
- color: ref.text.muted
1831
- },
1832
- legendItem: {
1833
- display: "inline-flex",
1834
- alignItems: "center",
1835
- gap: ref.space.xs
1836
- },
1837
- legendSwatch: Style.compose(Style.inline({
1838
- display: "inline-block",
1839
- width: "12px",
1840
- height: "12px",
1841
- borderRadius: ref.radius.xs
1842
- }), marked)
1843
- }, { layer: Layers.standard.layer("components") });
1844
- export { DataGridView, GridLegend, GridLegendSlots, GridLegendStyle, GridMarkStyle, GridMarks, GridSlots, GridStyle, MoreOnScroll };
859
+ export { DataGridView, isBusy };
1845
860
 
1846
- //# sourceMappingURL=index.mjs.map
861
+ //# sourceMappingURL=view.js.map