@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.
- package/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
package/src/DataGrid.tsx
ADDED
|
@@ -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
|
+
}
|