@weft-js/warp 0.0.0-stage → 0.1.1

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 (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,561 @@
1
+ import { XIcon } from '@weft-js/warp-icons'
2
+ import { aggregate, grid, groupRows, leaves, matchFilter, moved, pageSlice, pivotTable, sortRows, table, toCSV, treeRows, uid, type Aggregate, type GridFilter, type GridOptions, type TreeEntry } from '@weft-js/warp-core'
3
+ import { derived, onCleanup, onMount, state, untrack, watch, type State } from '@weft-js/core'
4
+ import { Button } from './Button'
5
+ import { Segmented } from './Choice'
6
+ import { Search } from './Field'
7
+ import { PaginationBar } from './Pagination'
8
+ import { CompactSelect, Select } from './Select'
9
+ import { chevron, ColumnsPanel, columnsIcon, Editor, sortIcon, type Column, type TableSort } from './Table'
10
+ import { use } from '@weft-js/warp-core/weft'
11
+ import '@weft-js/warp-core/styles/data/chips.css'
12
+ import '@weft-js/warp-core/styles/data/chip.css'
13
+
14
+ /** A Table column with what the grid adds: a filter, aggregates and formatted text. */
15
+ export interface GridColumn<T> extends Column<T> {
16
+ /** A control in the filter row: contains, one of `options`, or at least. */
17
+ filter?: GridFilter
18
+ options?: string[] | undefined
19
+ /** What the footer, a group's subtotal and a parent's roll-up show for this column. */
20
+ aggregate?: Aggregate | ((rows: T[]) => unknown)
21
+ /** Text for an aggregate, and for the cell when it has no `cell`. */
22
+ format?: ((value: unknown) => string) | undefined
23
+ }
24
+
25
+ /** A saved view (06 §6). */
26
+ export type GridView = { order: string[]; hidden: string[]; filters: Record<string, string>; sort?: TableSort; group?: boolean | undefined }
27
+
28
+ /** How the grid reads a hierarchy, and loads a branch on demand. */
29
+ export interface GridTree<T> {
30
+ /** What's already here under a row. Default `row.kids`. */
31
+ kids?: (row: T) => readonly T[] | undefined
32
+ /** Fetch a branch on its first open. The filter's query goes along for the server to apply. */
33
+ load?: (row: T, query: string) => Promise<readonly T[]>
34
+ /** Has it children, loaded or not? Default: any in `kids`, or `row.hasChildren`. */
35
+ branch?: ((row: T) => boolean) | undefined
36
+ /** The server's aggregate for a branch that hasn't loaded. */
37
+ rollup?: (row: T, column: string) => unknown
38
+ }
39
+
40
+ /** One cell of a paste, for the grid to accept or refuse. */
41
+ export interface GridPaste<T> { row: T; column: string; value: unknown }
42
+
43
+ /** Props for DataGrid. */
44
+ export interface DataGridProps<T> {
45
+ rows: readonly T[] | State<readonly T[]>
46
+ columns: GridColumn<T>[]
47
+ /** Names the grid (required). It stays in the accessibility tree when `captionHidden`. */
48
+ caption: string
49
+ captionHidden?: boolean | undefined
50
+ rowKey?: (string | ((row: T) => string)) | undefined
51
+ density?: 'compact' | 'cozy' | 'comfortable' | undefined
52
+ /** The named views in the select. */
53
+ views?: Record<string, GridView> | undefined
54
+ /** Starts on this view. */
55
+ view?: string | undefined
56
+ onSaveView?: ((name: string, view: GridView) => void) | undefined
57
+ /** The Group toggle groups by this column. */
58
+ groupBy?: string | undefined
59
+ /** role=treegrid over hierarchical rows. */
60
+ tree?: GridTree<T> | undefined
61
+ /** Show the tree's filter field, labelled this. */
62
+ filter?: string | undefined
63
+ onEdit?: ((row: T, column: string, value: unknown) => void) | undefined
64
+ /** A validated paste, as one change. Default each cell through `onEdit`. */
65
+ onPaste?: ((changes: GridPaste<T>[]) => void) | undefined
66
+ /** A row's details, under it. */
67
+ expand?: ((row: T) => unknown) | undefined
68
+ /** The bulk bar's actions, over the rows in the range. */
69
+ bulk?: ((rows: string[]) => unknown) | undefined
70
+ page?: { size: number } | undefined
71
+ /** The CSV's name; `false` drops Export. */
72
+ exportName?: string | false | undefined
73
+ actions?: unknown
74
+ maxHeight?: string | undefined
75
+ }
76
+
77
+ type Line<T> =
78
+ | { kind: 'row'; id: string; row: T; tree?: TreeEntry<T> | undefined; level?: number; tint?: number | undefined }
79
+ | { kind: 'group'; id: string; label: string; rows: T[]; open: boolean }
80
+ | { kind: 'loading'; id: string; level: number; label: string; tint?: number | undefined }
81
+
82
+ type Ctx<T> = {
83
+ id: string
84
+ cols: () => GridColumn<T>[]
85
+ keyOf: (r: T) => string
86
+ read: (c: GridColumn<T>, r: T) => unknown
87
+ title: (r: T) => string
88
+ treed: () => boolean
89
+ tree?: GridTree<T> | undefined
90
+ query: () => string
91
+ rolled: (c: GridColumn<T>, r: T) => unknown
92
+ kidsOf: (r: T) => readonly T[] | undefined
93
+ commit: (r: T, c: string, v: unknown) => void
94
+ toggle: (key: string, open?: boolean) => void
95
+ editors: Map<HTMLElement, () => void>
96
+ refocus: () => void
97
+ status: (s: string) => void
98
+ expanded: State<Record<string, boolean>>
99
+ expandedTree: (key: string) => boolean
100
+ busy: (key: string) => boolean
101
+ hasDetails: boolean
102
+ flip: (key: string) => void
103
+ span: () => number
104
+ body?: (r: T) => unknown
105
+ }
106
+
107
+ const shownText = <T,>(c: GridColumn<T>, v: unknown) => (c.format ? c.format(v) : v)
108
+ const total = <T,>(c: GridColumn<T>, rows: T[], read: (c: GridColumn<T>, r: T) => unknown) => {
109
+ if (!c.aggregate) return ''
110
+ const v = typeof c.aggregate === 'function' ? c.aggregate(rows) : aggregate(c.aggregate, rows.map(r => read(c, r)))
111
+ return shownText(c, v)
112
+ }
113
+ const REORDER = 'Alt plus left or right arrow moves this column.'
114
+
115
+ function Hits({ text, query }: { text: string; query: string }) {
116
+ const at = derived(() => (query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1))
117
+ return <>{at.value < 0 ? text : <>{text.slice(0, at.value)}<mark data-part="hit">{text.slice(at.value, at.value + query.length)}</mark>{text.slice(at.value + query.length)}</>}</>
118
+ }
119
+
120
+ function GridCell<T>({ line, col, first, ctx }: { line: Extract<Line<T>, { kind: 'row' }>; col: GridColumn<T>; first: boolean; ctx: Ctx<T> }) {
121
+ const c = untrack(() => col)
122
+ const editing = state(false)
123
+ const problem = state('')
124
+ const message = () => `${ctx.id}-${line.id}-${c.id}-e`
125
+ const row = () => line.row
126
+ const value = () => (line.tree?.branch ? ctx.rolled(c, row()) : ctx.read(c, row()))
127
+ let td: HTMLElement | undefined
128
+ let seed = ''
129
+ const begin = () => {
130
+ if (!c.edit || editing.value) return
131
+ seed = String((untrack(() => ctx.read(c, row())) as string | number | null | undefined) ?? '')
132
+ problem.value = ''
133
+ editing.value = true
134
+ }
135
+ onMount(() => { if (c.edit) { ctx.editors.set(td!, begin); onCleanup(() => ctx.editors.delete(td!)) } })
136
+ const done = (save: boolean, text: string) => {
137
+ if (!editing.value) return
138
+ if (save) {
139
+ const next = typeof c.edit === 'function' ? c.edit(text) : text
140
+ const ok = c.validate?.(next) ?? true
141
+ if (ok !== true) {
142
+ problem.value = typeof ok === 'string' ? ok : 'That value is not valid'
143
+ ctx.status(problem.value)
144
+ return
145
+ }
146
+ ctx.commit(row(), c.id, next)
147
+ }
148
+ problem.value = ''
149
+ editing.value = false
150
+ ctx.refocus()
151
+ }
152
+ const blur = (s: boolean, t: string) => { done(s, t); if (editing.value) { problem.value = ''; editing.value = false } } // an invalid value left behind is dropped
153
+ const body = () => (c.cell ? c.cell(row()) : shownText(c, value()))
154
+ const treeCell = derived(() => line.tree !== undefined && first)
155
+ const shown = derived(() => shownText(c, value()))
156
+ return (
157
+ <td
158
+ ref={n => (td = n)}
159
+ role={treeCell.value || c.rowHeader ? 'rowheader' : 'gridcell'}
160
+ tabindex="-1"
161
+ data-col={c.id}
162
+ data-numeric={c.numeric ? '' : undefined}
163
+ data-pinned={c.pin ? 'last' : undefined}
164
+ data-editable={c.edit ? '' : undefined}
165
+ data-invalid={problem.value ? '' : undefined}
166
+ data-part={treeCell.value ? 'tree-cell' : undefined}
167
+ >
168
+ {editing.value
169
+ ? <><Editor label={`${c.header} for ${ctx.title(row())}`} start={seed} invalid={!!problem.value} describedby={problem.value ? message() : undefined} onDone={(save, text) => done(save, text)} onLeave={blur} />{problem.value && <span data-part="sr-only" id={message()} role="alert">{problem.value}</span>}</>
170
+ : treeCell.value
171
+ ? <span data-part="tree-label"><span data-part="toggle" data-leaf={line.tree!.branch ? undefined : ''} aria-hidden="true" onClick={() => ctx.toggle(line.id)}>{chevron()}</span><span data-part="primary">{c.cell ? body() : <Hits text={shown.value as string} query={ctx.query()} />}</span></span>
172
+ : body()}
173
+ </td>
174
+ )
175
+ }
176
+
177
+ function GridRow<T>({ line, ctx }: { line: Extract<Line<T>, { kind: 'row' }>; ctx: Ctx<T> }) {
178
+ const open = () => !!(ctx.expanded.get(line.id) as boolean | undefined)
179
+ const detail = () => `${ctx.id}-x-${line.id}`
180
+ const branch = () => line.tree?.branch
181
+ return (
182
+ <>
183
+ <tr
184
+ role="row"
185
+ data-row={line.id}
186
+ tabindex={ctx.treed() ? '-1' : undefined}
187
+ aria-level={line.level}
188
+ aria-setsize={line.tree?.size}
189
+ aria-posinset={line.tree?.pos}
190
+ aria-expanded={branch() ? (ctx.expandedTree(line.id) ? 'true' : 'false') : undefined}
191
+ aria-busy={ctx.busy(line.id) ? 'true' : undefined}
192
+ data-context={line.tree?.context ? '' : undefined}
193
+ style={line.level ? `--_lvl:${line.level}${line.tint ? `;--_bc:var(--warp-color-data-${line.tint})` : ''}` : undefined}
194
+ >
195
+ {ctx.hasDetails && <td role="gridcell" tabindex="-1" style="padding-inline:4px"><button type="button" tabindex="-1" data-part="expand" aria-expanded={open() ? 'true' : 'false'} aria-controls={open() ? detail() : undefined} aria-label={`Details for ${ctx.title(line.row)}`} onClick={() => ctx.flip(line.id)}>{chevron()}</button></td>}
196
+ {ctx.cols().map((c, at) => <GridCell key={c.id} line={line} col={c} first={at === 0} ctx={ctx} />)}
197
+ </tr>
198
+ {open() && <tr data-part="detail" role="row" id={detail()}><td role="gridcell" colspan={ctx.span()}><div data-part="slide"><div><div>{ctx.body?.(line.row)}</div></div></div></td></tr>}
199
+ </>
200
+ )
201
+ }
202
+
203
+ function GroupRow<T>({ line, ctx }: { line: Extract<Line<T>, { kind: 'group' }>; ctx: Ctx<T> }) {
204
+ const sub = () => {
205
+ const c = ctx.cols().find(x => x.aggregate && x.aggregate !== 'count')
206
+ const n = line.rows.length
207
+ return `${n} ${n === 1 ? 'row' : 'rows'}${c ? ` · ${total(c, line.rows, ctx.read) as string}` : ''}`
208
+ }
209
+ return (
210
+ <tr role="row" data-part="group" data-row={line.id} tabindex="-1" aria-level="1" aria-expanded={line.open ? 'true' : 'false'}>
211
+ <td role="gridcell" tabindex="-1" colspan={ctx.span()}><button type="button" tabindex="-1" aria-expanded={line.open ? 'true' : 'false'} onClick={() => ctx.toggle(line.id)}>{chevron()}<b>{line.label}</b> {sub()}</button></td>
212
+ </tr>
213
+ )
214
+ }
215
+
216
+ /**
217
+ * The data grid (06 §6; data.html:1643-1787, treegrid 2482): a Table's parts as role=grid, or treegrid when grouped or
218
+ * hierarchical, with one tab stop. Arrows move the cell, Shift extends a range, ⌘/Ctrl C copies it as TSV, Enter or F2
219
+ * edits and an invalid value never commits. Headers reorder by dragging or Alt + ←/→. A filter row with applied chips,
220
+ * group and aggregate rows, saved views, CSV, a bulk bar and details. Rows and cells are keyed, so a reorder moves nodes.
221
+ */
222
+ export function DataGrid<T>({ rows, columns, caption, captionHidden, rowKey = 'id', density, views, view, onSaveView, groupBy, tree, filter, onEdit, onPaste, expand, bulk, page, exportName = 'export', actions, maxHeight }: DataGridProps<T>) {
223
+ const id = uid('grid')
224
+ const all = derived(() => columns)
225
+ const keyOf = (r: T) => String(typeof rowKey === 'function' ? rowKey(r) : (r as Record<string, unknown>)[rowKey])
226
+ const read = (c: GridColumn<T>, r: T) => (c.value ? c.value(r) : (r as Record<string, unknown>)[c.id])
227
+ const fresh = (): GridView => ({ order: untrack(() => columns).map(c => c.id), hidden: [], filters: {} })
228
+ const saved = state<Record<string, GridView>>(untrack(() => views) ?? {})
229
+ const named = state(untrack(() => view ?? Object.keys(views ?? {})[0] ?? ''))
230
+ const current = state<GridView>(untrack(() => (view && views?.[view]) || (views && Object.values(views)[0]) || fresh()))
231
+ const change = (patch: Partial<GridView>) => (current.value = { ...current.value, ...patch })
232
+
233
+ const cols = derived(() => current.value.order.map(k => all.value.find(c => c.id === k)).filter((c): c is GridColumn<T> => !!c && !current.value.hidden.includes(c.id)))
234
+ const showFilters = state(false)
235
+ const canFilter = derived(() => all.value.some(c => c.filter))
236
+ const canGroup = untrack(() => groupBy !== undefined)
237
+ const closed = state<Record<string, boolean>>({})
238
+ const expanded = state<Record<string, boolean>>({})
239
+ const open = state<Record<string, boolean>>({})
240
+ const closedHere = state<Record<string, boolean>>({})
241
+ const loaded = state<Record<string, readonly T[]>>({})
242
+ const busy = state<Record<string, boolean>>({})
243
+ const query = state('')
244
+ const at = state(1)
245
+ const picked = state<string[]>([])
246
+ const status = state('')
247
+ const picking = state(false)
248
+ const pickerBuilt = state(false)
249
+ let columnsButton: HTMLElement | undefined
250
+ let el: HTMLDivElement | undefined
251
+ let behaviour: { refocus(): void } | undefined
252
+
253
+ const source = (): readonly T[] => (Array.isArray(rows) ? rows as readonly T[] : (rows as State<readonly T[]>).value) // a plain list or a State
254
+ const kidsOf = (r: T): readonly T[] | undefined => loaded.get(keyOf(r)) ?? (tree?.kids ?? ((x: T) => (x as { kids?: readonly T[] }).kids))(r)
255
+ const isBranch = (r: T) => (tree?.branch ? tree.branch(r) : !!(kidsOf(r)?.length || (r as { hasChildren?: boolean }).hasChildren))
256
+ const filtered = derived(() => {
257
+ const f = current.value.filters
258
+ return (source()).filter(r => all.value.every(c => matchFilter(c.filter, f[c.id] ?? '', read(c, r))))
259
+ })
260
+ // what a tree's cell shows for a row: a parent's own field is often empty, and its number is what it rolls up
261
+ const rolled = (c: GridColumn<T>, r: T) => {
262
+ const given = tree?.rollup?.(r, c.id)
263
+ if (!c.aggregate) return read(c, r)
264
+ if (kidsOf(r)?.length) return typeof c.aggregate === 'function' ? c.aggregate(leaves(r, kidsOf)) : aggregate(c.aggregate, leaves(r, kidsOf).map(x => read(c, x)))
265
+ return given ?? read(c, r)
266
+ }
267
+ const shownValue = (c: GridColumn<T>, r: T) => (tree && isBranch(r) ? rolled(c, r) : read(c, r))
268
+ const sorted = (list: readonly T[]) => {
269
+ const s = current.value.sort
270
+ const by = s && all.value.find(c => c.id === s.id)
271
+ return by ? sortRows(list, r => shownValue(by, r), s.dir) : list
272
+ }
273
+ const ordered = derived(() => sorted(filtered.value))
274
+ const grouped = derived(() => canGroup && !!current.value.group)
275
+ const treed = derived(() => !!tree || grouped.value)
276
+ const paged = derived(() => (page && !tree ? pageSlice(ordered.value, at.value - 1, page.size) : undefined))
277
+ const lines = derived<Line<T>[]>(() => {
278
+ if (tree) {
279
+ const q = query.value.trim().toLowerCase()
280
+ const under = new Map<T, readonly T[] | undefined>() // treeRows asks more than once, and each level sorts
281
+ const kids = (r: T) => { if (!under.has(r)) { const k = kidsOf(r); under.set(r, k && sorted(k)) } return under.get(r) }
282
+ const entries = treeRows(ordered.value, {
283
+ key: keyOf, kids, branch: isBranch, open: k => !!(open.get(k) as boolean | undefined), closed: k => !!(closedHere.get(k) as boolean | undefined),
284
+ match: q ? r => all.value.some(c => String((read(c, r) as string | number | null | undefined) ?? '').toLowerCase().includes(q)) : undefined,
285
+ })
286
+ const tops = new Map(ordered.value.map((r, i) => [keyOf(r), i % 8 + 1]))
287
+ const out: Line<T>[] = []
288
+ const tint = new Map<string, number | undefined>()
289
+ for (const e of entries) {
290
+ const t = e.parent ? tint.get(e.parent) : tops.get(e.key)
291
+ tint.set(e.key, t)
292
+ out.push({ kind: 'row', id: e.key, row: e.row, tree: e, level: e.level, tint: t })
293
+ if (busy.get(e.key)) out.push({ kind: 'loading', id: `${e.key}/…`, level: e.level + 1, label: String((read(all.value[0]!, e.row) as string | number | null | undefined) ?? ''), tint: t })
294
+ }
295
+ return out
296
+ }
297
+ const list = paged.value?.rows ?? ordered.value
298
+ if (!grouped.value) return list.map(r => ({ kind: 'row' as const, id: keyOf(r), row: r }))
299
+ const by = all.value.find(c => c.id === groupBy)!
300
+ return [...groupRows(list, r => String(read(by, r)))].flatMap(([label, members]): Line<T>[] => {
301
+ const key = `g:${label}`
302
+ const on = !closed.get(key)
303
+ return [{ kind: 'group', id: key, label, rows: members, open: on }, ...(on ? members.map(r => ({ kind: 'row' as const, id: keyOf(r), row: r, level: 2 })) : [])]
304
+ })
305
+ })
306
+ // an open group lists its rows twice, as the group and as lines
307
+ const visible = derived(() => [...new Set(lines.value.flatMap(l => (l.kind === 'row' ? [l.row] : l.kind === 'group' ? l.rows : [])))])
308
+ // a tree's footer counts what its parents roll up, once each, open or not
309
+ const footed = derived(() => (tree ? [...new Set(visible.value.flatMap(r => leaves(r, kidsOf)))] : visible.value))
310
+ const hasDetails = untrack(() => expand !== undefined)
311
+ const say = (s: string) => (status.value = s)
312
+
313
+ const rowOf = (key: string) => (lines.value.find(l => l.id === key && l.kind === 'row') as Extract<Line<T>, { kind: 'row' }> | undefined)?.row
314
+ const fetching = new Map<string, Promise<boolean>>()
315
+ const fetchKids = (key: string, row: T) => {
316
+ const running = fetching.get(key)
317
+ if (running) return running
318
+ const run = (async () => {
319
+ busy.value = { ...busy.value, [key]: true }
320
+ try {
321
+ const got = await tree!.load!(row, query.value)
322
+ loaded.value = { ...loaded.value, [key]: got }
323
+ say(`${ctx.title(row)} expanded, ${got.length} ${got.length === 1 ? 'row' : 'rows'} loaded`)
324
+ return true
325
+ } catch {
326
+ open.value = { ...open.value, [key]: false }
327
+ say(`${ctx.title(row)} couldn’t load`)
328
+ return false
329
+ } finally {
330
+ const { [key]: _, ...rest } = busy.value
331
+ busy.value = rest
332
+ fetching.delete(key)
333
+ }
334
+ })()
335
+ fetching.set(key, run)
336
+ return run
337
+ }
338
+ const setOpen = async (key: string, on: boolean) => {
339
+ const q = query.value.trim()
340
+ if (q) { closedHere.value = { ...closedHere.value, [key]: !on }; return }
341
+ open.value = { ...open.value, [key]: on }
342
+ const row = rowOf(key)
343
+ if (on && row && kidsOf(row) === undefined && tree?.load) await fetchKids(key, row)
344
+ }
345
+ const isOpen = (key: string) => (query.value.trim() ? !closedHere.get(key) : !!(open.get(key) as boolean | undefined))
346
+ const toggle = (key: string, on?: boolean) => {
347
+ if (key.startsWith('g:')) { closed.value = { ...closed.value, [key]: on === undefined ? !closed.get(key) : !on }; return }
348
+ void setOpen(key, on ?? !isOpen(key))
349
+ }
350
+ watch(() => query.value, () => { closedHere.value = {} })
351
+ let expanding = 0 // a run of Expand all; Collapse all, or a newer run, ends it
352
+ const expandAll = (on: boolean) => {
353
+ const run = ++expanding
354
+ if (!on) { open.value = {}; return }
355
+ // a filter's results are the server's, so it only opens what's here; otherwise a branch not loaded yet loads, and so on down
356
+ const lazy = !query.value.trim() && !!tree?.load
357
+ const failed: string[] = []
358
+ const descend = async (list: readonly T[]): Promise<unknown> => {
359
+ const branches = list.filter(isBranch)
360
+ if (run !== expanding || !branches.length) return
361
+ open.value = { ...open.value, ...Object.fromEntries(branches.map(r => [keyOf(r), true])) }
362
+ return Promise.all(branches.map(async r => {
363
+ const key = keyOf(r)
364
+ if (lazy && kidsOf(r) === undefined && !await fetchKids(key, r)) failed.push(ctx.title(r))
365
+ return descend(kidsOf(r) ?? [])
366
+ }))
367
+ }
368
+ // the status holds one line, and a failure outranks the rows that loaded after it
369
+ void descend(ordered.value).then(() => { if (failed.length) say(failed.length === 1 ? `${failed[0]} couldn’t load` : `${failed.length} rows couldn’t load`) })
370
+ }
371
+
372
+ const namedCol = derived(() => all.value.find(c => c.rowHeader) ?? all.value[0])
373
+ const ctx: Ctx<T> = {
374
+ id, cols: () => cols.value, keyOf, read, treed: () => treed.value, get tree() { return tree }, kidsOf,
375
+ title: r => String((read(namedCol.value!, r) as string | number | null | undefined) ?? ''),
376
+ query: () => query.value.trim(),
377
+ rolled,
378
+ commit: (r, c, v) => onEdit?.(r, c, v),
379
+ toggle, editors: new Map(), status: say, expanded, hasDetails, body: r => expand?.(r),
380
+ flip: k => { const { [k]: was, ...rest } = expanded.value; expanded.value = was ? rest : { ...rest, [k]: true } },
381
+ refocus: () => behaviour?.refocus(),
382
+ span: () => cols.value.length + (hasDetails ? 1 : 0),
383
+ expandedTree: isOpen, busy: k => !!(busy.get(k) as boolean | undefined),
384
+ }
385
+
386
+ const reorder = (col: string, to: number) => {
387
+ const order = current.value.order.filter(k => !current.value.hidden.includes(k))
388
+ const placed = moved(order, col, to)
389
+ change({ order: [...placed, ...current.value.order.filter(k => current.value.hidden.includes(k))] })
390
+ const c = all.value.find(x => x.id === col)
391
+ say(`${c?.header} moved to column ${placed.indexOf(col) + 1} of ${placed.length}`)
392
+ queueMicrotask(() => el?.querySelector<HTMLElement>(`thead th[data-col="${col}"] button`)?.focus())
393
+ }
394
+ const paste = (cell: HTMLElement, matrix: string[][]) => {
395
+ const flat = lines.value.filter(l => l.kind === 'row')
396
+ const startRow = flat.findIndex(l => l.id === cell.parentElement!.dataset.row)
397
+ const startCol = cols.value.findIndex(c => c.id === cell.dataset.col)
398
+ const changes: GridPaste<T>[] = []
399
+ let refused = 0
400
+ matrix.forEach((line, r) => line.forEach((text, c) => {
401
+ const row = flat[startRow + r], col = cols.value[startCol + c]
402
+ if (!row || !col?.edit) return void refused++
403
+ const value = typeof col.edit === 'function' ? col.edit(text) : text
404
+ if ((col.validate?.(value) ?? true) !== true) return void refused++
405
+ changes.push({ row: row.row, column: col.id, value })
406
+ }))
407
+ if (changes.length) { if (onPaste) onPaste(changes); else for (const x of changes) onEdit?.(x.row, x.column, x.value) }
408
+ say(`Pasted ${changes.length} ${changes.length === 1 ? 'cell' : 'cells'}${refused ? `, ${refused} refused` : ''}`)
409
+ }
410
+ const toggleHidden = (c: GridColumn<T>, on: boolean) => change({ hidden: on ? current.value.hidden.filter(x => x !== c.id) : [...current.value.hidden, c.id] })
411
+ const askSort = (c: GridColumn<T>) => { const s = current.value.sort; change({ sort: { id: c.id, dir: s?.id === c.id && s.dir === 'ascending' ? 'descending' : 'ascending' } }) }
412
+ const setFilter = (k: string, v: string) => { at.value = 1; const f = { ...current.value.filters }; if (v) f[k] = v; else delete f[k]; change({ filters: f }) }
413
+ const applied = derived(() => Object.entries(current.value.filters).map(([k, v]) => ({ k, v, c: all.value.find(x => x.id === k) })))
414
+ const opOf = (c?: GridColumn<T>) => (c?.filter === 'min' ? '≥' : c?.filter === 'text' ? '~' : '=')
415
+
416
+ const save = () => {
417
+ const name = `My view ${Object.keys(saved.value).filter(k => k.startsWith('My view')).length + 1}`
418
+ saved.value = { ...saved.value, [name]: structuredClone(current.value) }
419
+ named.value = name
420
+ onSaveView?.(name, current.value)
421
+ say(`Saved “${name}”`)
422
+ }
423
+ const useView = (name: string) => {
424
+ const v = saved.value[name]
425
+ if (!v) return
426
+ named.value = name
427
+ closed.value = {}
428
+ at.value = 1
429
+ current.value = structuredClone(v)
430
+ }
431
+ const download = () => {
432
+ const shown = cols.value
433
+ const csv = toCSV(shown.map(c => c.header), visible.value.map(r => shown.map(c => shownText(c, read(c, r)))))
434
+ const a = document.createElement('a')
435
+ a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
436
+ a.download = `${exportName}.csv`
437
+ a.click()
438
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000)
439
+ say(`Exported ${visible.value.length} rows`)
440
+ }
441
+
442
+ use(table, () => el, () => ({ hidden: [], rows: lines.value }))
443
+ use((node: HTMLDivElement, o: GridOptions) => { const g = grid(node, o); behaviour = g; return g }, () => el, () => ({
444
+ tree: treed.value, rows: [lines.value, cols.value],
445
+ onEdit: (c: HTMLElement) => ctx.editors.get(c)?.(),
446
+ onToggle: (r: HTMLElement, on: boolean) => toggle(r.dataset.row!, on),
447
+ onReorder: reorder, onSelect: (r: string[]) => (picked.value = r), onStatus: say, onPaste: paste,
448
+ }))
449
+ const hint = () => `Arrows move · Shift extends · ${all.value.some(c => c.edit) ? 'Enter edits · ' : ''}⌘/Ctrl C copies`
450
+ const hasAggregate = derived(() => all.value.some(c => c.aggregate))
451
+ const chosen = () => picked.value.filter(k => !k.startsWith('g:'))
452
+ return (
453
+ <div ref={n => (el = n)} data-scope="table" data-phone="columns" data-density={density} data-tree={tree ? '' : undefined} id={id}>
454
+ <div data-part="toolbar">
455
+ {filter !== undefined && <Search label={filter} value={query.value} onInput={(e: Event) => (query.value = (e.currentTarget as HTMLInputElement).value)} />}
456
+ {views && <Select label="View" size="sm" value={named.value} options={Object.keys(saved.value).map(v => ({ value: v, label: v }))} onChange={(e: Event) => useView((e.currentTarget as HTMLInputElement).value)} />}
457
+ {views && <Button emphasis="ghost" tone="neutral" onClick={save}>Save view</Button>}
458
+ {applied.value.length > 0 && (
459
+ <div data-scope="chips" role="group" aria-label="Applied filters">
460
+ {applied.value.map(f => <span key={f.k} data-scope="chip">{f.c?.header} {opOf(f.c)} <b>{f.v}</b><button type="button" aria-label={`Remove filter ${f.c?.header}`} onClick={() => setFilter(f.k, '')}><XIcon /></button></span>)}
461
+ <button type="button" data-part="clear" onClick={() => change({ filters: {} })}>Clear all</button>
462
+ </div>
463
+ )}
464
+ <span data-part="spacer"></span>
465
+ {actions}
466
+ {tree && <Button emphasis="outline" tone="neutral" onClick={() => expandAll(true)}>Expand all</Button>}
467
+ {tree && <Button emphasis="outline" tone="neutral" onClick={() => expandAll(false)}>Collapse all</Button>}
468
+ {canFilter.value && <Button emphasis="outline" tone="neutral" aria-pressed={showFilters.value ? 'true' : 'false'} onClick={() => (showFilters.value = !showFilters.value)}>Filters</Button>}
469
+ {canGroup && <Button emphasis="outline" tone="neutral" aria-pressed={current.value.group ? 'true' : 'false'} onClick={() => change({ group: !current.value.group })}>Group</Button>}
470
+ <div>
471
+ <Button emphasis="outline" tone="neutral" ref={n => (columnsButton = n)} aria-expanded={picking.value ? 'true' : 'false'} aria-controls={pickerBuilt.value ? `${id}-cols` : undefined} onClick={() => { pickerBuilt.value = true; picking.value = !picking.value }}>{columnsIcon()}Columns</Button>
472
+ {pickerBuilt.value && <ColumnsPanel id={`${id}-cols`} open={picking} trigger={() => columnsButton!} items={all.value.filter(c => !c.pin)} shown={c => !current.value.hidden.includes(c.id)} flip={toggleHidden} />}
473
+ </div>
474
+ {exportName !== false && <Button emphasis="outline" tone="neutral" onClick={download}>Export CSV</Button>}
475
+ </div>
476
+ {bulk && (
477
+ <div data-part="bulk" hidden={chosen().length === 0} role="region" aria-label="Bulk actions">
478
+ <b>{chosen().length} selected</b>
479
+ {bulk(chosen())}
480
+ </div>
481
+ )}
482
+ <div data-part="viewport" tabindex="-1" style={maxHeight ? `--warp-table-max-h:${maxHeight}` : undefined}>
483
+ <table role={treed.value ? 'treegrid' : 'grid'} aria-labelledby={`${id}-cap`} aria-describedby={`${id}-hint`} aria-multiselectable="true" aria-rowcount={visible.value.length + 1} aria-colcount={ctx.span()}>
484
+ <caption id={`${id}-cap`} data-part={captionHidden ? 'sr-only' : undefined}>{caption}</caption>
485
+ <thead>
486
+ <tr role="row">
487
+ {hasDetails && <th role="columnheader" scope="col" style="inline-size:1px;padding-inline:4px"><span data-part="sr-only">Details</span></th>}
488
+ {cols.value.map(c => (
489
+ <th key={c.id} role="columnheader" scope="col" data-col={c.id} data-numeric={c.numeric ? '' : undefined} data-pinned={c.pin ? 'last' : undefined} draggable="true" aria-sort={current.value.sort?.id === c.id ? current.value.sort.dir : undefined}>
490
+ {c.sort ? <button type="button" data-part="sort" aria-describedby={`${id}-move`} onClick={() => askSort(c)}>{c.header}{sortIcon()}</button> : <button type="button" data-part="sort" aria-describedby={`${id}-move`}>{c.header}</button>}
491
+ {c.resize && <span data-part="resize" role="separator" aria-orientation="vertical" tabindex="0" aria-label={`Resize ${c.header} column`} aria-valuenow="80" aria-valuemin="80" aria-valuemax="480"></span>}
492
+ </th>
493
+ ))}
494
+ </tr>
495
+ {showFilters.value && (
496
+ <tr role="row" data-part="filters">
497
+ {hasDetails && <th role="columnheader"><span data-part="sr-only">Details</span></th>}
498
+ {cols.value.map(c => (
499
+ <th key={c.id} role="columnheader">
500
+ {c.filter === 'select'
501
+ ? <CompactSelect label={`Filter ${c.header}`} placeholder="Any" value={current.value.filters[c.id] ?? ''} options={(c.options ?? []).map(o => ({ value: o, label: o }))} onValueChange={v => setFilter(c.id, v)} />
502
+ : c.filter
503
+ ? <label><span data-part="sr-only">{c.filter === 'min' ? `${c.header} at least` : `Filter ${c.header}`}</span><input value={current.value.filters[c.id] ?? ''} inputmode={c.filter === 'min' ? 'numeric' : undefined} placeholder={c.filter === 'min' ? '≥' : 'Contains…'} onInput={(e: Event) => setFilter(c.id, (e.currentTarget as HTMLInputElement).value)} /></label>
504
+ : <span data-part="sr-only">{`No filter for ${c.header}`}</span>}
505
+ </th>
506
+ ))}
507
+ </tr>
508
+ )}
509
+ </thead>
510
+ <tbody>
511
+ {lines.value.map(l => (l.kind === 'row'
512
+ ? <GridRow key={l.id} line={l} ctx={ctx} />
513
+ : l.kind === 'group'
514
+ ? <GroupRow key={l.id} line={l} ctx={ctx} />
515
+ : <tr key={l.id} role="row" data-part="loading" aria-level={l.level} style={`--_lvl:${l.level}${l.tint ? `;--_bc:var(--warp-color-data-${l.tint})` : ''}`}><td role="gridcell" data-part="tree-cell" colspan={ctx.span()}><span data-part="tree-label"><span data-part="toggle" data-leaf="" aria-hidden="true">{chevron()}</span>{`Loading ${l.label}…`}<span data-part="skeleton" aria-hidden="true"></span></span></td></tr>))}
516
+ </tbody>
517
+ {hasAggregate.value && (
518
+ <tfoot>
519
+ <tr role="row" data-part="aggregate">
520
+ {hasDetails && <td role="gridcell"></td>}
521
+ {cols.value.map((c, n) => <td key={c.id} role="gridcell" data-numeric={c.numeric ? '' : undefined} data-pinned={c.pin ? 'last' : undefined}>{n === 0 && !c.aggregate ? `${footed.value.length} ${footed.value.length === 1 ? 'row' : 'rows'}` : total(c, footed.value, read)}</td>)}
522
+ </tr>
523
+ </tfoot>
524
+ )}
525
+ </table>
526
+ </div>
527
+ <div data-part="footer">
528
+ <span id={`${id}-hint`}>{hint()}</span>
529
+ <span aria-live="polite">{status.value}</span>
530
+ </div>
531
+ {paged.value && <PaginationBar total={ordered.value.length} page={at} size={page!.size} sizes={[page!.size]} />}
532
+ <span data-part="sr-only" id={`${id}-move`}>{REORDER}</span>
533
+ </div>
534
+ )
535
+ }
536
+
537
+ /** One value a pivot totals. */
538
+ export interface PivotMeasure<T> { id: string; label: string; value: (row: T) => number; format?: ((v: number) => string) | undefined }
539
+
540
+ /** A pivot (06 §6; data.html:1790): rows × columns × a measure, heat cells and totals both ways. */
541
+ export function PivotTable<T>({ rows, caption, row, column, rowHeader = '', measures }: { rows: readonly T[]; caption: string; row: (r: T) => string; column: (r: T) => string; rowHeader?: string; measures: PivotMeasure<T>[] }) {
542
+ const measure = state(untrack(() => measures[0]!.id))
543
+ const m = derived(() => measures.find(x => x.id === measure.value) ?? measures[0])
544
+ const p = derived(() => pivotTable(rows, row, column, m.value!.value))
545
+ const f = (v: number) => (m.value!.format ? m.value!.format(v) : String(v))
546
+ return (
547
+ <div data-scope="table" data-phone="columns">
548
+ {measures.length > 1 && <div data-part="toolbar"><span data-part="spacer"></span><Segmented label="Pivot measure" value={measure.value} options={measures.map(x => ({ value: x.id, label: x.label }))} onChange={(v: string) => (measure.value = v)} /></div>}
549
+ <div data-part="viewport" tabindex="0" role="region" aria-label={caption}>
550
+ <table>
551
+ <caption data-part="sr-only">{caption}</caption>
552
+ <thead><tr><th scope="col">{rowHeader || <span data-part="sr-only">Rows</span>}</th>{p.value.colKeys.map(c => <th key={c} scope="col" data-numeric="">{c}</th>)}<th scope="col" data-numeric="">Total</th></tr></thead>
553
+ <tbody>
554
+ {p.value.rowKeys.map((r, i) => <tr key={r}><th scope="row">{r}</th>{p.value.colKeys.map((c, j) => <td key={c} data-numeric="" data-heat="" style={`--v:${p.value.cells[i]![j]!.share.toFixed(2)}`}>{p.value.cells[i]![j]!.value ? f(p.value.cells[i]![j]!.value) : '–'}</td>)}<td data-numeric=""><b>{f(p.value.rowTotals[i]!)}</b></td></tr>)}
555
+ </tbody>
556
+ <tfoot><tr data-part="aggregate"><th scope="row">Total</th>{p.value.colTotals.map((v, j) => <td key={j} data-numeric="">{f(v)}</td>)}<td data-numeric="">{f(p.value.total)}</td></tr></tfoot>
557
+ </table>
558
+ </div>
559
+ </div>
560
+ )
561
+ }