foldkit-mixins-data-grid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/dist/index.d.mts +375 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1846 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -0
package/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.
|