ajo-ui 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.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. package/src/virtual.ts +385 -0
@@ -0,0 +1,475 @@
1
+ import type { IntrinsicElements, Stateful } from 'ajo'
2
+ import { announce, dom, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
3
+ import { Checkbox } from './checkbox'
4
+ import type { DataTableArgs, DataTableColumn, DataTableData, DataTableKey, DataTableLabels } from './data-table-contract'
5
+ import {
6
+ createDataTableModel,
7
+ dataTableDefaultLabels,
8
+ type DataTableRowView,
9
+ type DataTableView,
10
+ } from './data-table-model'
11
+ import {
12
+ Menu,
13
+ MenuCheckboxItem,
14
+ MenuContent,
15
+ MenuLabel,
16
+ MenuSeparator,
17
+ MenuTrigger,
18
+ } from './menu'
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectList,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ } from './select'
27
+ import { Toolbar } from './toolbar'
28
+ import { syncCheckedState } from './utils'
29
+
30
+ export type { DataTableArgs, DataTableColumn } from './data-table-contract'
31
+
32
+ type DataTableRootArgs<T extends DataTableData, Key extends DataTableKey> = Pick<
33
+ DataTableArgs<T, Key>,
34
+ 'columns' | 'empty' | 'getRowKey' | 'label' | 'labels' | 'pagination' | 'rows' | 'search' | 'selection'
35
+ >
36
+
37
+ type FocusTarget = { column?: string; row: string }
38
+
39
+ const validLabel = (value: unknown, name: string) => {
40
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`DataTable invalid ${name}`)
41
+ return value
42
+ }
43
+
44
+ const align = (value: DataTableColumn<any>['align']) => value ?? 'left'
45
+
46
+ const activeElement = (host: HTMLElement): HTMLElement | null => {
47
+ const active = host.ownerDocument.activeElement
48
+ return active instanceof HTMLElement && host.contains(active) ? active : null
49
+ }
50
+
51
+ const coordinate = (host: HTMLElement): FocusTarget | undefined => {
52
+ let node = activeElement(host)
53
+ let column: string | undefined
54
+ while (node && node !== host) {
55
+ column ??= node.dataset.columnId
56
+ if (node.dataset.rowId) return { column, row: node.dataset.rowId }
57
+ node = node.parentElement
58
+ }
59
+ }
60
+
61
+ const findCoordinate = (host: HTMLElement, target: FocusTarget) => {
62
+ for (const row of host.querySelectorAll<HTMLElement>('[data-row-id]')) {
63
+ if (row.dataset.rowId !== target.row) continue
64
+ if (!target.column) return row
65
+ for (const cell of row.querySelectorAll<HTMLElement>('[data-column-id]')) {
66
+ if (cell.dataset.columnId === target.column) {
67
+ return cell.querySelector<HTMLElement>('button,input,[tabindex]:not([tabindex="-1"])') ?? cell
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ const restoreCheckbox = (event: Event, checked: boolean, indeterminate = false) => {
74
+ const input = event.currentTarget as HTMLInputElement
75
+ input.checked = checked
76
+ input.indeterminate = indeterminate
77
+ syncCheckedState(input, input.closest<HTMLElement>('[data-slot="checkbox"]'))
78
+ }
79
+
80
+ const DataTableRoot: Stateful<DataTableRootArgs<any, DataTableKey>> = function* () {
81
+ let model: ReturnType<typeof createDataTableModel<any, DataTableKey>> | undefined
82
+ let table: HTMLTableElement | null = null
83
+ let resultsTimer: ReturnType<typeof setTimeout> | undefined
84
+ let composing = false
85
+ let announceAfterRender: 'deferred' | 'immediate' | undefined
86
+ const live = announce(this)
87
+
88
+ const preserveFocus = () => {
89
+ const active = dom(this) ? activeElement(this) : null
90
+ const target = active ? coordinate(this) : undefined
91
+ if (!active) return
92
+ queueMicrotask(() => {
93
+ if (!dom(this) || this.signal.aborted) return
94
+ const current = this.ownerDocument.activeElement
95
+ if (
96
+ current
97
+ && current !== this.ownerDocument.body
98
+ && current !== this.ownerDocument.documentElement
99
+ && !current.matches(':disabled')
100
+ ) return
101
+ const next = target ? findCoordinate(this, target) : undefined
102
+ const fallback = next ?? table
103
+ fallback?.focus({ preventScroll: true })
104
+ })
105
+ }
106
+
107
+ this.signal.addEventListener('abort', () => {
108
+ if (resultsTimer !== undefined) clearTimeout(resultsTimer)
109
+ table = null
110
+ model = undefined
111
+ }, { once: true })
112
+
113
+ for (const args of this) {
114
+ preserveFocus()
115
+ model ??= createDataTableModel(this, args)
116
+ const view = model.sync(args) as DataTableView<any, DataTableKey>
117
+ const labels = { ...dataTableDefaultLabels, ...args.labels }
118
+ const text = <Name extends keyof DataTableLabels>(name: Name) => validLabel(labels[name], `label ${name}`)
119
+ const call = <Name extends keyof DataTableLabels>(
120
+ name: Name,
121
+ ...values: DataTableLabels[Name] extends (...args: infer Params) => string ? Params : never
122
+ ) => validLabel(((labels[name] as unknown) as (...args: typeof values) => string)(...values), `label ${name}`)
123
+ const visibleColumns = view.columns.filter(column => column.visible)
124
+ const visibleCount = visibleColumns.length
125
+ const controlledSelection = args.selection?.value !== undefined
126
+ const selectAllLabel = () => view.selection.all
127
+ ? text(view.page.enabled ? 'deselectPage' : 'deselectResults')
128
+ : text(view.page.enabled ? 'selectPage' : 'selectResults')
129
+
130
+ if (announceAfterRender) {
131
+ const message = call('results', view.filteredCount)
132
+ if (resultsTimer !== undefined) clearTimeout(resultsTimer)
133
+ if (announceAfterRender === 'immediate') live.polite(message)
134
+ else resultsTimer = setTimeout(() => live.polite(message), 200)
135
+ announceAfterRender = undefined
136
+ }
137
+
138
+ const announceResults = (mode: 'deferred' | 'immediate') => {
139
+ announceAfterRender = mode
140
+ }
141
+
142
+ const selectLabel = (row: DataTableRowView<any, DataTableKey>) => {
143
+ const selection = args.selection!
144
+ const rowLabel = validLabel(selection.getRowLabel(row.original, row.sourceIndex), 'row label')
145
+ return row.selected ? call('deselectRow', rowLabel) : call('selectRow', rowLabel)
146
+ }
147
+
148
+ yield (
149
+ <>
150
+ {args.search || view.columns.some(column => column.column.facet?.options.length) || view.visibility ? (
151
+ <Toolbar
152
+ aria-label={call('toolbar', args.label)}
153
+ data-slot="data-table-toolbar"
154
+ >
155
+ <div data-slot="data-table-toolbar-controls">
156
+ {args.search ? (
157
+ <input
158
+ aria-label={text('search')}
159
+ data-slot="data-table-search"
160
+ placeholder={args.search.placeholder ?? text('search')}
161
+ set:oncompositionend={(event: Event) => {
162
+ composing = false
163
+ announceResults('deferred')
164
+ model!.setQuery((event.currentTarget as HTMLInputElement).value, event)
165
+ }}
166
+ set:oncompositionstart={() => { composing = true }}
167
+ set:oninput={(event: Event) => {
168
+ if (!composing) announceResults('deferred')
169
+ model!.setQuery((event.currentTarget as HTMLInputElement).value, event)
170
+ }}
171
+ set:onkeydown={(event: KeyboardEvent) => {
172
+ if (event.key !== 'Enter' || event.isComposing) return
173
+ announceAfterRender = undefined
174
+ if (resultsTimer !== undefined) clearTimeout(resultsTimer)
175
+ resultsTimer = undefined
176
+ live.polite(call('results', view.filteredCount))
177
+ }}
178
+ set:value={view.query}
179
+ type="search"
180
+ />
181
+ ) : null}
182
+
183
+ {view.columns.flatMap(column => {
184
+ const facet = column.column.facet
185
+ if (!facet?.options.length) return []
186
+ return [(
187
+ <Menu key={column.id}>
188
+ <MenuTrigger data-slot="data-table-facet">
189
+ <span aria-hidden="true" data-slot="data-table-facet-icon" />
190
+ {facet.label}
191
+ {column.active.length ? (
192
+ <span data-slot="data-table-facet-count">{column.active.length}</span>
193
+ ) : null}
194
+ </MenuTrigger>
195
+ <MenuContent data-slot="data-table-facet-content">
196
+ <MenuLabel>{facet.label}</MenuLabel>
197
+ <MenuSeparator />
198
+ {facet.options.map(option => (
199
+ <MenuCheckboxItem
200
+ key={option.value}
201
+ checked={column.active.includes(option.value)}
202
+ onCheckedChange={(checked, event) => {
203
+ announceResults('immediate')
204
+ model!.setFacet(column.id, option.value, checked, event)
205
+ }}
206
+ textValue={option.label}
207
+ >
208
+ {option.icon ? <span aria-hidden="true" data-slot="data-table-facet-option-icon">{option.icon}</span> : null}
209
+ {option.label}
210
+ </MenuCheckboxItem>
211
+ ))}
212
+ </MenuContent>
213
+ </Menu>
214
+ )]
215
+ })}
216
+
217
+ {view.hasFilters ? (
218
+ <button
219
+ data-slot="data-table-reset"
220
+ set:onclick={(event: Event) => {
221
+ announceResults('immediate')
222
+ model!.reset(event)
223
+ }}
224
+ type="button"
225
+ >
226
+ {text('reset')}
227
+ <span aria-hidden="true" data-slot="data-table-reset-icon" />
228
+ </button>
229
+ ) : null}
230
+ </div>
231
+
232
+ {view.visibility ? (
233
+ <Menu placement="bottom-end">
234
+ <MenuTrigger data-slot="data-table-columns">
235
+ {text('columns')}
236
+ <span aria-hidden="true" data-slot="data-table-columns-icon" />
237
+ </MenuTrigger>
238
+ <MenuContent data-slot="data-table-columns-content">
239
+ <MenuLabel>{text('columns')}</MenuLabel>
240
+ <MenuSeparator />
241
+ {view.columns.filter(column => column.column.hideable !== false).map(column => (
242
+ <MenuCheckboxItem
243
+ key={column.id}
244
+ checked={column.visible}
245
+ disabled={column.visible && visibleCount === 1}
246
+ onCheckedChange={checked => model!.toggleColumn(column.id, checked)}
247
+ textValue={column.column.label}
248
+ >
249
+ {column.column.label}
250
+ </MenuCheckboxItem>
251
+ ))}
252
+ </MenuContent>
253
+ </Menu>
254
+ ) : null}
255
+ </Toolbar>
256
+ ) : null}
257
+
258
+ <div data-slot="data-table-container">
259
+ <table
260
+ aria-label={args.label}
261
+ data-slot="table"
262
+ ref={element => table = element}
263
+ tabindex={-1}
264
+ >
265
+ <thead data-slot="table-header">
266
+ <tr data-slot="table-row">
267
+ {view.selection.enabled ? (
268
+ <th data-slot="table-head" scope="col">
269
+ <Checkbox
270
+ aria-label={selectAllLabel()}
271
+ disabled={!view.rows.length}
272
+ set:checked={view.selection.all}
273
+ set:indeterminate={view.selection.some}
274
+ onCheckedChange={(checked, event) => {
275
+ try { model!.togglePage(checked, event) } finally {
276
+ if (controlledSelection && model) {
277
+ const selection = model.selection()
278
+ restoreCheckbox(event, selection.all, selection.some)
279
+ }
280
+ }
281
+ }}
282
+ />
283
+ </th>
284
+ ) : null}
285
+ {visibleColumns.map(column => {
286
+ const next = column.sorted === false ? 'ascending' : column.sorted === 'asc' ? 'descending' : 'none'
287
+ const sortable = column.column.value !== undefined && column.column.sort !== false
288
+ return (
289
+ <th
290
+ key={column.id}
291
+ aria-sort={column.sorted === 'asc' ? 'ascending' : column.sorted === 'desc' ? 'descending' : undefined}
292
+ data-align={align(column.column.align)}
293
+ data-column-id={column.id}
294
+ data-slot="table-head"
295
+ scope="col"
296
+ >
297
+ {sortable ? (
298
+ <button
299
+ aria-label={call('sort', column.column.label, next)}
300
+ data-slot="data-table-sort-trigger"
301
+ set:onclick={(event: Event) => model!.sort(column.id, event)}
302
+ type="button"
303
+ >
304
+ <span>{column.column.header ?? column.column.label}</span>
305
+ <span aria-hidden="true" data-slot="data-table-sort-icon" data-sort={column.sorted || 'none'} />
306
+ </button>
307
+ ) : column.column.header ?? column.column.label}
308
+ </th>
309
+ )
310
+ })}
311
+ </tr>
312
+ </thead>
313
+ <tbody data-slot="table-body">
314
+ {view.rows.length ? view.rows.map(row => (
315
+ <tr
316
+ key={row.id}
317
+ data-row-id={row.id}
318
+ data-slot="table-row"
319
+ data-state={row.selected ? 'selected' : undefined}
320
+ >
321
+ {view.selection.enabled ? (
322
+ <td data-column-id="\0selection" data-slot="table-cell">
323
+ <Checkbox
324
+ aria-label={selectLabel(row)}
325
+ set:checked={row.selected}
326
+ onCheckedChange={(checked, event) => {
327
+ try { model!.toggleRow(row.id, checked, event) } finally {
328
+ if (controlledSelection && model) restoreCheckbox(event, model.selected(row.id))
329
+ }
330
+ }}
331
+ />
332
+ </td>
333
+ ) : null}
334
+ {row.cells.map(cell => (
335
+ <td
336
+ key={cell.column.id}
337
+ data-align={align(cell.column.column.align)}
338
+ data-column-id={cell.column.id}
339
+ data-slot="table-cell"
340
+ >
341
+ {model!.cell(cell, row)}
342
+ </td>
343
+ ))}
344
+ </tr>
345
+ )) : (
346
+ <tr data-slot="table-row">
347
+ <td
348
+ colspan={visibleCount + (view.selection.enabled ? 1 : 0)}
349
+ data-slot="data-table-empty"
350
+ >
351
+ {args.empty ?? 'No results.'}
352
+ </td>
353
+ </tr>
354
+ )}
355
+ </tbody>
356
+ </table>
357
+ </div>
358
+
359
+ {view.selection.enabled || view.page.enabled ? (
360
+ <div data-slot="data-table-footer">
361
+ {view.selection.enabled ? (
362
+ <div aria-live="polite" data-slot="data-table-selection-summary" role="status">
363
+ {call('selected', view.selectedCount, view.sourceCount)}
364
+ </div>
365
+ ) : null}
366
+ {view.page.enabled ? (
367
+ <nav aria-label={call('pagination', args.label)} data-slot="data-table-pagination">
368
+ <div data-slot="data-table-page-size">
369
+ <span>{text('rowsPerPage')}</span>
370
+ <Select<number>
371
+ value={view.page.size}
372
+ onValueChange={value => { if (value !== null) model!.setPageSize(value) }}
373
+ >
374
+ <SelectTrigger aria-label={text('rowsPerPage')}>
375
+ <SelectValue />
376
+ </SelectTrigger>
377
+ <SelectContent>
378
+ <SelectList>
379
+ {view.page.sizes.map(size => <SelectItem key={size} value={size}>{size}</SelectItem>)}
380
+ </SelectList>
381
+ </SelectContent>
382
+ </Select>
383
+ </div>
384
+ <div data-slot="data-table-page-indicator">
385
+ {call('page', view.page.index + 1, view.page.count)}
386
+ </div>
387
+ <div data-slot="data-table-pagination-actions">
388
+ <button
389
+ aria-label={text('firstPage')}
390
+ data-action="first"
391
+ data-slot="data-table-pagination-action"
392
+ disabled={view.page.index === 0}
393
+ set:onclick={() => model!.firstPage()}
394
+ type="button"
395
+ ><span aria-hidden="true" /></button>
396
+ <button
397
+ aria-label={text('previousPage')}
398
+ data-action="previous"
399
+ data-slot="data-table-pagination-action"
400
+ disabled={view.page.index === 0}
401
+ set:onclick={() => model!.previousPage()}
402
+ type="button"
403
+ ><span aria-hidden="true" /></button>
404
+ <button
405
+ aria-label={text('nextPage')}
406
+ data-action="next"
407
+ data-slot="data-table-pagination-action"
408
+ disabled={view.page.index >= view.page.count - 1}
409
+ set:onclick={() => model!.nextPage()}
410
+ type="button"
411
+ ><span aria-hidden="true" /></button>
412
+ <button
413
+ aria-label={text('lastPage')}
414
+ data-action="last"
415
+ data-slot="data-table-pagination-action"
416
+ disabled={view.page.index >= view.page.count - 1}
417
+ set:onclick={() => model!.lastPage()}
418
+ type="button"
419
+ ><span aria-hidden="true" /></button>
420
+ </div>
421
+ </nav>
422
+ ) : null}
423
+ </div>
424
+ ) : null}
425
+ </>
426
+ )
427
+ }
428
+ }
429
+
430
+ const safeRootAttrs = (attrs: Record<string, unknown>) => {
431
+ const result = { ...rootAttrs(attrs) }
432
+ for (const name of [
433
+ 'aria-label',
434
+ 'aria-labelledby',
435
+ 'attr:aria-label',
436
+ 'attr:aria-labelledby',
437
+ 'set:ariaLabel',
438
+ 'set:ariaLabelledByElements',
439
+ ]) delete result[name]
440
+ return result
441
+ }
442
+
443
+ /** Native, Ajo-owned client DataTable powered by a private TanStack Table v9 model. */
444
+ const DataTable = <T extends DataTableData, Key extends DataTableKey = DataTableKey>({
445
+ children: _children,
446
+ class: classes,
447
+ columns,
448
+ empty,
449
+ getRowKey,
450
+ label,
451
+ labels,
452
+ pagination,
453
+ rows,
454
+ search,
455
+ selection,
456
+ ...attrs
457
+ }: DataTableArgs<T, Key>) => (
458
+ <DataTableRoot
459
+ {...safeRootAttrs(attrs as Record<string, unknown>) as IntrinsicElements['div']}
460
+ columns={columns}
461
+ empty={empty}
462
+ getRowKey={getRowKey}
463
+ label={label}
464
+ labels={labels}
465
+ pagination={pagination}
466
+ rows={rows}
467
+ search={search}
468
+ selection={selection as DataTableRootArgs<any, DataTableKey>['selection']}
469
+ attr:class={classes}
470
+ attr:data-slot="data-table"
471
+ />
472
+ )
473
+
474
+ export { DataTable }
475
+ export default DataTable