foldkit-mixins-data-grid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Glenn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # foldkit-mixins-data-grid
2
+
3
+ Draws a [`foldkit-data-grid`](../data-grid/README.md) grid as an accessible,
4
+ virtualized WAI-ARIA `grid`. Only the rows and columns the viewport shows
5
+ reach the DOM; every drawn cell still says its place among all of them, the
6
+ keyboard moves focus, and every element is a Slot to style.
7
+
8
+ > **Status:** experimental, `0.1.0`, on npm from the 0.15.0 release. Phases 3 to 8 of
9
+ > [the DataGrid design](../../docs/design/data-grid-DESIGN.md): the grid
10
+ > drawn, its header, selection, editing, status and clipboard.
11
+ > [`examples/data-grid`](../../examples/data-grid/README.md) draws 100,000
12
+ > rows with it.
13
+
14
+ ## Who owns what
15
+
16
+ This package draws and holds nothing. The grid's state is the `DataGrid`
17
+ Bundle from `foldkit-data-grid`, placed in the application's Model; the rows
18
+ are the application's. The view reads both and sends the grid's own Messages
19
+ through the placement's wrapper.
20
+
21
+ | Fact | Owner |
22
+ | --- | --- |
23
+ | the rows, their order | the application |
24
+ | the focused cell, the scroll offsets and size | `DataGrid`, in the Model |
25
+ | which cells to draw, and their ARIA | this view, worked out each render |
26
+
27
+ ## Sixty seconds
28
+
29
+ ```ts
30
+ import { Schema } from 'effect'
31
+ import type { HtmlBuilder } from 'foldkit/html'
32
+ import { defineMessageUnion } from 'foldkit/message'
33
+ import { Bundle } from 'foldkit-bundle'
34
+ import { Columns, DataGrid, RowModel } from 'foldkit-data-grid'
35
+ import { Style } from 'foldkit-mixins'
36
+ import { DataGridView, GridStyle } from 'foldkit-mixins-data-grid'
37
+
38
+ interface Item {
39
+ readonly id: string
40
+ readonly name: string
41
+ }
42
+ const items: ReadonlyArray<Item> = [
43
+ { id: 'a', name: 'Anchor' },
44
+ { id: 'b', name: 'Bolt' },
45
+ ]
46
+ const itemKey = (item: Item) => item.id
47
+
48
+ const columns = Columns.define<Item>()({
49
+ id: { header: 'Id', value: item => item.id, pinned: 'start', width: 96 },
50
+ name: { header: 'Name', value: item => item.name, width: 240 },
51
+ })
52
+ const Grid = DataGrid.make({ id: 'items', columns })
53
+
54
+ // The grid's state is one field of the application's Model.
55
+ const Placement = Bundle.declare(Grid.bundle, 'grid')
56
+ const Model = Schema.Struct({ ...Placement.fields })
57
+ type Model = typeof Model.Type
58
+ const Message = defineMessageUnion({ ...Placement.cases })
59
+ type Message = typeof Message.Type
60
+ const application = Bundle.assemble<Model, Message>()([
61
+ // A grid reports edits as an OutMessage; this one edits nothing, so it ignores them.
62
+ Bundle.parent({ Model, Message }).at(Placement, { onOut: Bundle.ignore }),
63
+ ])
64
+
65
+ const ItemsGrid = DataGridView<Message>().define(Grid).pipe(Style.attach(GridStyle))
66
+ const rows = RowModel.fromArray(items, itemKey)
67
+
68
+ const view = (model: Model, h: HtmlBuilder<Message>) =>
69
+ ItemsGrid(
70
+ {
71
+ state: model.grid,
72
+ rows,
73
+ wrap: message => Placement.wrapper.make(message),
74
+ label: 'Items',
75
+ rowHeight: 32,
76
+ headerHeight: 36,
77
+ },
78
+ h,
79
+ )
80
+ ```
81
+
82
+ `application.initial(...)` and `application.update()` run the grid's state
83
+ like any other placement; `view` draws it. Give the grid a height with a
84
+ Style on `root` (`height: '24rem'`, say): it is the scroll container.
85
+
86
+ - **`DataGridView<Message>().define(Grid)`** is a SlotView for that grid. Its
87
+ row and column types come from the grid, so a cell renderer and the focused
88
+ cell are checked against them.
89
+ - **`state`** is the placed `DataGrid` Model. The view reads the focused cell,
90
+ the viewport and the column state from it, and works out the window with
91
+ `VirtualGrid.window`.
92
+ - **`rows`** is the application's rows, in its order; the view draws them
93
+ through the column state (`Grid.project`) and never sorts. Keep the rows
94
+ model between renders, or the projection is rebuilt every time.
95
+ - **`wrap`** lifts the grid's Messages into the application's: keys send
96
+ `Moved` (with the scroll that reveals the cell), a pressed cell sends
97
+ `Focused`, and the scroll container sends `Measured`.
98
+ - **`GridStyle`** is the default look, in the `components` layer.
99
+
100
+ ## What it draws
101
+
102
+ ```text
103
+ root role="grid", tabindex 0, aria-rowcount, aria-colcount, aria-activedescendant
104
+ header role="rowgroup", sticky at the top
105
+ headerRow role="row" aria-rowindex="1"
106
+ headerCell role="columnheader" aria-colindex
107
+ resizeHandle role="separator", its width as aria-valuenow
108
+ body role="rowgroup", the full height of every row
109
+ row role="row" aria-rowindex (2 for the first data row)
110
+ cell role="gridcell" aria-colindex, id for the active descendant
111
+ placeholder a row counted but not loaded yet
112
+ status "No rows." when there are none
113
+ ```
114
+
115
+ - **Counts and indexes are logical.** `aria-rowcount` is every row plus the
116
+ header, or `-1` when the count is unknown; `aria-rowindex` and
117
+ `aria-colindex` are a cell's place among all rows and visible columns, so a
118
+ screen reader says "row 5,002" of a window that holds twenty.
119
+ - **One tab stop.** DOM focus stays on `root`, and `aria-activedescendant`
120
+ names the current cell while it is drawn. A cell scrolled out of the window
121
+ is gone from the DOM, so the attribute is left off until it is back.
122
+ - **Keys** are `GridFocus.target`'s: arrows, Home and End, Ctrl for the
123
+ corners, PageUp and PageDown by the rows the viewport holds. A key the grid
124
+ does not handle (Enter, Tab, anything with Shift, Alt or Meta) keeps its
125
+ default.
126
+ - **Resize handles** sit at each resizable column's end edge. A pointer drag
127
+ resizes from where it began, and a cancelled pointer (`pointercancel`, or
128
+ a lost capture) puts the width back; a focused handle steps 16px with the
129
+ arrow keys, its
130
+ own keys only, so the grid's focus stays put. Handles are out of the tab
131
+ order, so the grid stays one tab stop.
132
+ - **Selection**, when the grid has it: rows say `aria-selected` with
133
+ `rowSelection`, cells in the range say it with `cellSelection`, and the
134
+ grid is `aria-multiselectable` for either kind of several. The pointer
135
+ goes through one listener on the body: a click focuses a cell, Shift with
136
+ a click spans a range from the focused cell, and Ctrl or Meta with a
137
+ click toggles the cell's row. On the keyboard, Shift with an arrow, a page,
138
+ Home or End moves a range's far corner (scrolling it into view), Space
139
+ selects the focused row and Shift+Space extends the rows to it, Ctrl or
140
+ Meta with A selects every row (or every cell), and Escape lets a range go.
141
+ - **Undo and redo keys.** Ctrl or Meta with Z sends `UndoRequested`, with
142
+ Shift (or Ctrl+Y) `RedoRequested`; the application keeps the history. Keys
143
+ in an open editor stay the input's.
144
+ - **Fill**, in a grid that edits. Ctrl or Meta with D carries the range's
145
+ first row down it (a lone cell or one row, the row above), with R its
146
+ first column across it. The `fillHandle` Slot, a square at the end corner
147
+ of the range or the focused cell, is dragged to fill along one axis; the
148
+ cells it would write say `data-fill="target"` until it is let go, and
149
+ Escape lets it go back. The handle is pointer-only and hidden from
150
+ assistive technology, since the keys do the same. Held near the grid's
151
+ edge or past it, the drag scrolls the grid that way and fills on into the
152
+ rows it brings in.
153
+ - **The header row is part of the grid.** ArrowUp from the first row goes up
154
+ to the header (`aria-activedescendant` names the header cell), the arrows
155
+ and Home and End walk it, ArrowDown or Escape go back to the row focus came
156
+ from. On a header, Shift with an arrow resizes the column 16px, and Ctrl or
157
+ Meta with Shift and an arrow moves it within its region; both are mirrored
158
+ in right-to-left text.
159
+ - **The editor follows the column's schema** (`Grid.editorFor`): a schema
160
+ whose text is one of a few literals (`Schema.Literals`) is a choice of
161
+ them: a `role="combobox"` showing the draft (the `choice` slot) beside
162
+ its `role="listbox"` (`choiceList`, `choiceOption`), drawn by the grid and
163
+ styled with the page. It opens on the cell's value, below the cell or
164
+ above it when the cell is low in the view; the arrows, Home, End and the
165
+ page keys walk it, a letter finds the next option it starts, Enter and
166
+ Tab keep the draft and move on, Alt+ArrowUp keeps it in place, Escape
167
+ drops it, and a press on an option chooses it. With `choiceEditor:
168
+ 'native'` it is the platform's `select`, whose picker suits a touch
169
+ screen, its list open at once where the browser allows (`showPicker`).
170
+ One that decodes to a number is a text field with a decimal keypad on
171
+ touch; anything else is text (`editor`).
172
+ - **Editing**, for a column with `edit`: Enter, F2 or a double-click opens a
173
+ text field in the cell on its text, and a printable key opens it on that
174
+ character (`EditTyped`); keys typed before the field is drawn add to it,
175
+ and Escape before then cancels it. In the field, Enter commits and moves down (Shift+Enter up), Tab
176
+ commits and moves across (Shift+Tab back), and Escape cancels; the arrows
177
+ are the field's own. Focus leaving the field (a click elsewhere) commits
178
+ it, as Enter does without moving. A draft the column's `schema` refuses
179
+ stays open with `aria-invalid`, and its error is shown below the field
180
+ (`editorError`, `role="alert"`, which the field names with
181
+ `aria-describedby`), until the draft is fixed or Escape drops it. Focus
182
+ comes back to the grid when the field goes. Clicking another cell commits
183
+ first. In a grid where some columns edit, the others' headers and cells
184
+ carry `aria-readonly`, which `GridStyle` draws as a padlock before the
185
+ header's label and muted text in the cells (a grid where nothing edits
186
+ marks nothing). An
187
+ editable cell carries `data-editable` (`GridStyle` gives it a text cursor),
188
+ and the cell being edited `data-editing`, which `GridStyle` lifts with a
189
+ shadow and hands its padding to the field, outlined in the focus colour
190
+ with a caret of it, so an edit does not look like a cell that only has focus.
191
+ - **The clipboard** works on the range, or the focused cell. Copy puts it on
192
+ the clipboard as tab-separated text, as spreadsheets read it; paste lays
193
+ text from the range's corner onto editable cells and the application
194
+ hears one `Out.Pasted`; cut copies and clears the editable cells, and
195
+ Delete or Backspace clears them without copying. While a
196
+ cell is edited, the field has the clipboard.
197
+ - **Where the rows stand**, from `status` (`GridCrud.status` for a Remote
198
+ page): the grid is `aria-busy` while loading or refreshing; with no rows
199
+ it says loading, or the failure with a retry when `onRetry` is given; with
200
+ rows, a failure is said in the `footer`, below them. `onMore` puts a More
201
+ button in the footer while the count is not known. Attach `MoreOnScroll`
202
+ (`DataGridView<Message>().define(Grid).pipe(MoreOnScroll)`) and the button
203
+ also sends it when it comes within 200px of the grid's visible box, and
204
+ again after each load while it stays there. Nothing is asked while the
205
+ grid is busy, so give `status` too: without it, the button asks again each
206
+ time it comes back into view before the page lands.
207
+ - **The column menu**, with `columnMenu: true`: each header has a button
208
+ (`menuButton`, `aria-haspopup="menu"`) that opens a `role="menu"` below
209
+ it, listing `Grid.menuItems`: pin to the start or the end or unpin, hide,
210
+ and show each hidden column. On a focused header Alt+ArrowDown, Shift+F10
211
+ or the menu key opens it. The menu takes focus and points at its active
212
+ item with `aria-activedescendant`; the arrows, Home and End walk it, Enter
213
+ or Space or a click runs an item, and Escape, a choice, or focus leaving
214
+ closes it, focus going back to the grid unless the user sent it
215
+ elsewhere. Its words are `menu`, `pinStart`, `pinEnd`, `unpin`, `hide`
216
+ and `show` in `words`.
217
+ - **Marks** are the application's states on single cells, such as an edit
218
+ not yet saved: `marks: address => Option.some({ name, description })`.
219
+ The name is drawn as `data-mark` on the cell's Slot, which the default
220
+ style gives a dot in the corner and an application styles by name
221
+ (`&[data-mark="refused"]` in its own `Style.forSlots(GridSlots)`), and the
222
+ description is the cell's `aria-description`, so a screen reader says what
223
+ the dot shows. It is asked only for the cells drawn. Five names are shared,
224
+ for a grid over a server (`GridMarks`: `pending`, `saved`, `refused`,
225
+ `replaced`, `peer`): `GridMarkStyle`, attached after `GridStyle`, draws
226
+ them on the theme's tokens, a shape for each, not only a colour (and an
227
+ outline where colours are forced), with a peer's colour from
228
+ `--fk-grid-peer` when the application sets one. `GridLegend<Message>()`
229
+ draws what they mean, each beside a swatch `GridLegendStyle` paints with
230
+ the cells' own rules, so the two cannot disagree.
231
+ - **Sorting** is the application's: `sort` gives a column its direction and
232
+ the Message that sorts it next (`foldkit-crud`'s `Sort` has this shape).
233
+ The header says `aria-sort`, and its label is a button the pointer sorts
234
+ with, marked `data-sort` (`asc` or `desc`) while sorted, which `GridStyle`
235
+ draws as a small chevron after the label; its place is kept while
236
+ unsorted, so sorting moves nothing, and a header label never wraps. Enter
237
+ on the focused header sends it too. `GridStyle` parts columns with a faint
238
+ line, which on a header is the resize handle, darker under the pointer.
239
+ - **Pinned columns** stick to their edge with `position: sticky` and carry
240
+ `data-pinned="start"` or `"end"`; the pinned column next to the scrolling
241
+ ones also carries `data-pinned-edge`, header and cells, where `GridStyle`
242
+ draws the divider they pass under. The focused cell carries
243
+ `data-focused="true"`.
244
+
245
+ ## Styling
246
+
247
+ Every element above is a Slot. A Style may paint anything, but the geometry
248
+ the window is worked out from is the view's: a Style that sets a cell's
249
+ `width`, `position`, `insetInlineStart` or `boxSizing`, a row's `height`, or
250
+ the root's `overflow` is refused with `slot "cell" protects style property`.
251
+ Cells are `border-box`, so padding stays inside the width the window assumed.
252
+
253
+ ## Limits
254
+
255
+ - Every row is one height.
256
+ - A Shift click over rows does not extend a row range yet: the click's
257
+ Message carries the cell, not the rows between, and the grid's update
258
+ holds no row order. Shift+Space does it from the keyboard.
259
+ - Keys read the state the last render drew, so two keys inside one frame
260
+ both start from the same cell, as `GridNavigation`'s do. Where a Message
261
+ could carry a stale result it carries the intent instead (`EditTyped`,
262
+ `MenuChosen`, a drag's offset), and `update` works it out from the Model;
263
+ see [Messages carry intent](../../docs/state-model.md#messages-carry-intent-not-results-worked-out-from-the-last-frame).
264
+ - A header dragged with the pointer reorders within its region; pinning
265
+ goes through the column menu. On touch the browser's scroll takes the
266
+ gesture.
267
+ - A column menu opens below its header, inside the scroll container: a grid
268
+ shorter than the menu clips it.
269
+ - There is no date editor: a column whose schema decodes to a date is
270
+ edited as text.
271
+ - A copy's text is drawn with the grid, built once per range and rows, so
272
+ copying a very large range costs its size each time the range changes.
273
+ - No fill handle yet.
274
+ - Cells say their value as text unless `cell` draws them; the Display
275
+ vocabulary of `foldkit-crud` arrives with the CRUD adapter in Phase 7.