@weft-js/warp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +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/Table.tsx
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { ColumnsIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { checkState, filterRows, hideRule, pageSlice, popover, pushLayer, rangeOf, sortRows, table, uid, type SortDir } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, onCleanup, onMount, state, type Derived, type Resource, type State, untrack, watch } from '@weft-js/core'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { Checkbox } from './Choice'
|
|
6
|
+
import { Search } from './Field'
|
|
7
|
+
import { Select } from './Select'
|
|
8
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
9
|
+
import './styles'
|
|
10
|
+
import '@weft-js/warp-core/styles/components/popover.css' // the Columns panel
|
|
11
|
+
import '@weft-js/warp-core/styles/data/tokens.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/data/base.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/data/table.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/data/desc.css'
|
|
15
|
+
|
|
16
|
+
/** One Table column. */
|
|
17
|
+
export interface Column<T> {
|
|
18
|
+
id: string
|
|
19
|
+
/** Text: it also labels the cell on a phone card and the Columns checkbox. */
|
|
20
|
+
header: string
|
|
21
|
+
/** What the cell holds, sorts by and filters on. Default `row[id]`. */
|
|
22
|
+
value?: ((row: T) => unknown) | undefined
|
|
23
|
+
/** How the cell draws it. Default the value as text. */
|
|
24
|
+
cell?: ((row: T) => unknown) | undefined
|
|
25
|
+
/** `true` sorts by `value`; a function sorts by what it returns. */
|
|
26
|
+
sort?: (boolean | ((row: T) => unknown)) | undefined
|
|
27
|
+
numeric?: boolean | undefined
|
|
28
|
+
/** Sticky at the inline start. */
|
|
29
|
+
pin?: boolean | undefined
|
|
30
|
+
rowHeader?: boolean | undefined
|
|
31
|
+
/** 2 hides under 760px of table width, 3 under 1000px; a row's details still list it. */
|
|
32
|
+
priority?: 2 | 3 | undefined
|
|
33
|
+
/** Where it goes on a phone card. */
|
|
34
|
+
card?: 'title' | 'badge' | 'hide' | undefined
|
|
35
|
+
resize?: boolean | undefined
|
|
36
|
+
/** Inline edit: `true` commits the text, a function turns the text into the value. */
|
|
37
|
+
edit?: (boolean | ((text: string) => unknown)) | undefined
|
|
38
|
+
/** `true`, or the message to show: an invalid value never commits. */
|
|
39
|
+
validate?: ((value: unknown) => string | boolean) | undefined
|
|
40
|
+
/** A number that ticks: the cell flashes on change and the row is never rebuilt. */
|
|
41
|
+
live?: boolean | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The V2 list envelope. `next`/`prev` are cursors; absent means there is no such page. */
|
|
45
|
+
export type ListPage<T> = { items: T[]; total?: number; next?: unknown; prev?: unknown }
|
|
46
|
+
/** The column a table is sorted by, and which way. */
|
|
47
|
+
export type TableSort = { id: string; dir: SortDir }
|
|
48
|
+
/** What a table is showing in place of, or along with, its rows. */
|
|
49
|
+
export type TableStatus = 'ready' | 'loading' | 'empty' | 'error'
|
|
50
|
+
|
|
51
|
+
/** Props for Table. */
|
|
52
|
+
export interface TableProps<T> {
|
|
53
|
+
rows: readonly T[] | State<readonly T[]> | Derived<readonly T[]> | Resource<readonly T[] | ListPage<T>>
|
|
54
|
+
/** Names the table (required, 06 §5). It stays in the accessibility tree when `captionHidden`. */
|
|
55
|
+
caption: string
|
|
56
|
+
/** Hides the caption on screen. Screen readers still hear it. */
|
|
57
|
+
captionHidden?: boolean | undefined
|
|
58
|
+
columns: Column<T>[]
|
|
59
|
+
/** The field that identifies a row, or how to get it. Default `id`. */
|
|
60
|
+
rowKey?: (string | ((row: T) => string)) | undefined
|
|
61
|
+
/** `cards` stacks each row in a container under 480px wide; `columns` scrolls sideways instead. */
|
|
62
|
+
phone?: 'cards' | 'columns' | undefined
|
|
63
|
+
density?: 'compact' | 'cozy' | 'comfortable' | undefined
|
|
64
|
+
/** Show the filter field, labelled this. */
|
|
65
|
+
filter?: string | undefined
|
|
66
|
+
/** A state holding the filter text, so you can read or set it. */
|
|
67
|
+
query?: State<string> | undefined
|
|
68
|
+
/** A state holding the sorted column and direction, so you can read or set it. */
|
|
69
|
+
sort?: State<TableSort | undefined>
|
|
70
|
+
/** Called with the column and direction when someone sorts. */
|
|
71
|
+
onSortChange?: ((sort: TableSort) => void) | undefined
|
|
72
|
+
/** The rows arrive sorted, filtered and paged (a Resource's are): the table only reports what's asked. */
|
|
73
|
+
server?: boolean | undefined
|
|
74
|
+
/** Checkboxes, Shift-click ranges and the bulk bar. */
|
|
75
|
+
selection?: boolean | State<Record<string, boolean>> | undefined
|
|
76
|
+
/** Called with the keys of every selected row after each change. */
|
|
77
|
+
onSelectionChange?: ((keys: string[]) => void) | undefined
|
|
78
|
+
/** The bulk bar's actions. */
|
|
79
|
+
bulk?: ((keys: string[]) => unknown) | undefined
|
|
80
|
+
page?: { size: number } | undefined
|
|
81
|
+
/** Called on Next or Previous, with the page or server cursor to load. */
|
|
82
|
+
onPageChange?: ((to: unknown, reason: 'next' | 'prev') => void) | undefined
|
|
83
|
+
/** A row's details. Default a list of every column. */
|
|
84
|
+
expand?: ((row: T) => unknown) | undefined
|
|
85
|
+
/** inline (default): they slide open in place at every width. sheet: a panel over the table below 1000px. */
|
|
86
|
+
details?: 'inline' | 'sheet' | undefined
|
|
87
|
+
/** Called with the row, the column's id and the new value when a cell edit is committed. */
|
|
88
|
+
onEdit?: ((row: T, column: string, value: unknown) => void) | undefined
|
|
89
|
+
/** The toolbar's own actions. */
|
|
90
|
+
actions?: unknown
|
|
91
|
+
/** The Columns disclosure. */
|
|
92
|
+
picker?: boolean | undefined
|
|
93
|
+
/** Columns hidden from the start, in the server's HTML too. Bind a State to keep what people choose. */
|
|
94
|
+
hiddenColumns?: readonly string[] | State<readonly string[]> | undefined
|
|
95
|
+
/** Forces a state; a Resource's own is used otherwise. */
|
|
96
|
+
status?: TableStatus | undefined
|
|
97
|
+
/** What to show when there are no rows: a title, a line of text and an action. */
|
|
98
|
+
empty?: { title?: unknown; text?: unknown; action?: unknown } | undefined
|
|
99
|
+
error?: { title?: unknown; text?: unknown } | undefined
|
|
100
|
+
/** Called by the error state's Retry. A Resource refreshes itself as well. */
|
|
101
|
+
onRetry?: (() => void) | undefined
|
|
102
|
+
/** A CSS height past which the body scrolls under a sticky header, such as `480px`. */
|
|
103
|
+
maxHeight?: string | undefined
|
|
104
|
+
/** The outline level of the table's state and sheet headings, under the page's h1. */
|
|
105
|
+
heading?: number | undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const chevron = () => <svg aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 3.5 10.5 8 6 12.5" /></svg>
|
|
109
|
+
export const sortIcon = () => <svg data-part="sort-icon" viewBox="0 0 10 10" aria-hidden="true"><path d="M5 1.5v7M2 5.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.4" /></svg>
|
|
110
|
+
export const columnsIcon = () => <ColumnsIcon />
|
|
111
|
+
const SKELETON_ROWS = 6
|
|
112
|
+
// Table width. warp-data.css draws cards below 600 for data-phone="cards", which also catches a desktop column; from
|
|
113
|
+
// here up the root says "columns" once it's measured, so cards are for a container a table really can't fill.
|
|
114
|
+
const CARDS_BELOW = 480
|
|
115
|
+
const DETAILS_FROM = 1000 // table width: below it `details="sheet"` opens the sheet (06 §5)
|
|
116
|
+
|
|
117
|
+
const isResource = (r: unknown): r is Resource<unknown> => typeof (r as Resource<unknown> | null | undefined)?.refresh === 'function'
|
|
118
|
+
const isState = (r: object | null | undefined): r is State<unknown> => r != null && !Array.isArray(r) && 'value' in r
|
|
119
|
+
|
|
120
|
+
type Ctx<T> = {
|
|
121
|
+
id: string
|
|
122
|
+
level: number
|
|
123
|
+
cols: () => Column<T>[]
|
|
124
|
+
keyOf: (row: T) => string
|
|
125
|
+
read: (c: Column<T>, row: T) => unknown
|
|
126
|
+
sel: State<Record<string, boolean>> | undefined
|
|
127
|
+
open: State<Record<string, boolean>>
|
|
128
|
+
details: boolean
|
|
129
|
+
span: () => number
|
|
130
|
+
title: (row: T) => string
|
|
131
|
+
pick: (key: string, e: MouseEvent) => void
|
|
132
|
+
toggle: (row: T, from: HTMLElement) => void
|
|
133
|
+
body: (row: T) => unknown
|
|
134
|
+
edit?: ((row: T, column: string, value: unknown) => void) | undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Every column as a list: what a sheet and a detail row show by default. */
|
|
138
|
+
function Fields<T>({ row, ctx }: { row: T; ctx: Ctx<T> }) {
|
|
139
|
+
return <dl data-scope="desc">{ctx.cols().map(c => <div key={c.id}><dt>{c.header}</dt><dd>{(c.cell ?? ((r: T) => ctx.read(c, r)))(row)}</dd></div>)}</dl>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function Editor({ label, start, onDone, onLeave, invalid, describedby }: { label: string; start: string; onDone: (save: boolean, text: string) => void; onLeave?: (save: boolean, text: string) => void; invalid?: boolean; describedby?: string | undefined }) {
|
|
143
|
+
let input: HTMLInputElement | undefined
|
|
144
|
+
onMount(() => { input?.focus(); input?.select() })
|
|
145
|
+
return (
|
|
146
|
+
<label>
|
|
147
|
+
<span data-part="sr-only">{label}</span>
|
|
148
|
+
<input
|
|
149
|
+
ref={n => (input = n)}
|
|
150
|
+
data-part="editor"
|
|
151
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
152
|
+
aria-describedby={describedby}
|
|
153
|
+
value={start}
|
|
154
|
+
onKeyDown={(e: KeyboardEvent) => {
|
|
155
|
+
if (e.key !== 'Enter' && e.key !== 'Escape') return
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
e.stopPropagation()
|
|
158
|
+
onDone(e.key === 'Enter', (e.currentTarget as HTMLInputElement).value)
|
|
159
|
+
}}
|
|
160
|
+
onBlur={(e: FocusEvent) => (onLeave ?? onDone)(true, (e.currentTarget as HTMLInputElement).value)}
|
|
161
|
+
/>
|
|
162
|
+
</label>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// its own component: a new value patches this one text node, and flipping `editing` is the only thing that swaps it
|
|
167
|
+
function Shown<T>({ row, col, value }: { row: T; col: Column<T>; value: () => unknown }) {
|
|
168
|
+
const c = untrack(() => col)
|
|
169
|
+
if (c.live) return <><span data-part="dir" aria-hidden="true"></span>{c.cell ? c.cell(row) : value()}</>
|
|
170
|
+
return <>{c.cell ? c.cell(row) : value()}</>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function Cell<T>({ row, col, ctx }: { row: T; col: Column<T>; ctx: Ctx<T> }) {
|
|
174
|
+
const c = untrack(() => col)
|
|
175
|
+
const editing = state(false)
|
|
176
|
+
const problem = state('')
|
|
177
|
+
const delta = state<string | undefined>(undefined)
|
|
178
|
+
const message = () => `${ctx.id}-${ctx.keyOf(row)}-${c.id}-e`
|
|
179
|
+
const value = () => ctx.read(c, row)
|
|
180
|
+
let td: HTMLElement | undefined
|
|
181
|
+
if (c.live) watch(value, (now, was) => {
|
|
182
|
+
if (was === undefined || now === was) return
|
|
183
|
+
const dir = (now as number) > (was as number) ? 'up' : 'down'
|
|
184
|
+
delta.value = delta.value === dir ? `${dir}2` : dir // two names per direction, so the flash replays
|
|
185
|
+
})
|
|
186
|
+
let seed = ''
|
|
187
|
+
const begin = () => { if (c.edit && !editing.value) { seed = String((untrack(value) as string | number | null | undefined) ?? ''); problem.value = ''; editing.value = true } }
|
|
188
|
+
const done = (save: boolean, text: string) => {
|
|
189
|
+
if (!editing.value) return
|
|
190
|
+
if (save) {
|
|
191
|
+
const next = typeof c.edit === 'function' ? c.edit(text) : text
|
|
192
|
+
const ok = c.validate?.(next) ?? true
|
|
193
|
+
if (ok !== true) { problem.value = typeof ok === 'string' ? ok : 'That value is not valid'; return }
|
|
194
|
+
ctx.edit?.(row, c.id, next)
|
|
195
|
+
}
|
|
196
|
+
problem.value = ''
|
|
197
|
+
editing.value = false
|
|
198
|
+
td?.focus()
|
|
199
|
+
}
|
|
200
|
+
return (
|
|
201
|
+
<td
|
|
202
|
+
ref={n => (td = n)}
|
|
203
|
+
role={c.rowHeader ? 'rowheader' : 'cell'}
|
|
204
|
+
data-col={c.id}
|
|
205
|
+
data-label={c.header}
|
|
206
|
+
data-numeric={c.numeric ? '' : undefined}
|
|
207
|
+
data-priority={c.priority}
|
|
208
|
+
data-card={c.card}
|
|
209
|
+
data-pinned={c.pin ? 'last' : undefined}
|
|
210
|
+
data-live={c.live ? '' : undefined}
|
|
211
|
+
data-delta={delta.value}
|
|
212
|
+
data-editable={c.edit ? '' : undefined}
|
|
213
|
+
data-invalid={problem.value ? '' : undefined}
|
|
214
|
+
tabindex={c.edit ? '0' : undefined}
|
|
215
|
+
aria-description={c.edit ? 'Press Enter to edit' : undefined}
|
|
216
|
+
onDblClick={c.edit ? begin : undefined}
|
|
217
|
+
onKeyDown={(c.edit ? (e: KeyboardEvent) => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === 'F2')) { e.preventDefault(); begin() } } : undefined)!}
|
|
218
|
+
>
|
|
219
|
+
{editing.value
|
|
220
|
+
? <><Editor label={`${c.header} for ${ctx.title(row)}`} start={seed} onDone={done} />{problem.value && <span data-part="sr-only" id={message()} role="alert">{problem.value}</span>}</>
|
|
221
|
+
: <Shown row={row} col={c} value={value} />}
|
|
222
|
+
</td>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function Row<T>({ row, ctx }: { row: T; ctx: Ctx<T> }) {
|
|
227
|
+
const key = () => ctx.keyOf(row)
|
|
228
|
+
const on = () => !!ctx.sel?.get(key())
|
|
229
|
+
const opened = () => !!(ctx.open.get(key()) as boolean | undefined)
|
|
230
|
+
const detail = () => `${ctx.id}-d-${key()}`
|
|
231
|
+
// a closing row stays until it has slid shut. A timer, because transitionend never comes
|
|
232
|
+
// when the transition is skipped, and that would strand an empty row
|
|
233
|
+
const mounted = state(untrack(opened))
|
|
234
|
+
let tr: HTMLElement | undefined
|
|
235
|
+
const settle = () => { if (!untrack(opened)) mounted.value = false }
|
|
236
|
+
watch(opened, now => {
|
|
237
|
+
if (now) return void (mounted.value = true)
|
|
238
|
+
if (!mounted.value) return
|
|
239
|
+
queueMicrotask(() => {
|
|
240
|
+
const slide = tr?.nextElementSibling?.querySelector('[data-part="slide"]')
|
|
241
|
+
setTimeout(settle, slide ? parseFloat(getComputedStyle(slide).transitionDuration) * 1000 : 0)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
const click = (e: MouseEvent) => {
|
|
245
|
+
if ((e.target as Element).closest?.('button, input, select, a, label, [data-editable]')) return
|
|
246
|
+
ctx.toggle(row, (e.currentTarget as HTMLElement).querySelector<HTMLElement>('[data-part="expand"]')!)
|
|
247
|
+
}
|
|
248
|
+
return (
|
|
249
|
+
<>
|
|
250
|
+
<tr ref={n => (tr = n)} role="row" data-id={key()} data-selected={on() ? '' : undefined} data-clickable={ctx.details ? '' : undefined} onClick={(ctx.details ? click : undefined)!}>
|
|
251
|
+
{ctx.sel && <td role="cell" data-part="select" data-pinned="first"><label data-scope="checkbox"><input type="checkbox" checked={ctx.sel.get(key())} aria-label={`Select ${ctx.title(row)}`} onClick={(e: MouseEvent) => ctx.pick(key(), e)} /><span data-part="control"><svg viewBox="0 0 24 24" aria-hidden="true"><path data-part="check" pathLength="1" d="M5 12.5 L9.5 17 L19 7" /></svg></span></label></td>}
|
|
252
|
+
{ctx.details && <td role="cell" style="padding-inline:4px"><button type="button" data-part="expand" aria-expanded={opened() ? 'true' : 'false'} aria-controls={mounted.value ? detail() : undefined} aria-label={`Details for ${ctx.title(row)}`} onClick={(e: MouseEvent) => ctx.toggle(row, e.currentTarget as HTMLElement)}>{chevron()}</button></td>}
|
|
253
|
+
{ctx.cols().map(c => <Cell key={c.id} row={row} col={c} ctx={ctx} />)}
|
|
254
|
+
</tr>
|
|
255
|
+
{mounted.value && <tr data-part="detail" role="row" id={detail()}><td role="cell" colspan={ctx.span()}><div data-part="slide" data-closing={opened() ? undefined : ''}><div><div>{ctx.body(row)}</div></div></div></td></tr>}
|
|
256
|
+
</>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The tablet detail sheet (06 §5): non-modal, Escape closes it from anywhere, focus moves to Close and back to the row. */
|
|
261
|
+
function Sheet<T>({ row, ctx, onClose }: { row: T; ctx: Ctx<T>; onClose: (restore?: boolean) => void }) {
|
|
262
|
+
let close: HTMLElement | undefined
|
|
263
|
+
let el: HTMLElement | undefined
|
|
264
|
+
onMount(() => {
|
|
265
|
+
close?.focus()
|
|
266
|
+
onCleanup(pushLayer({ el: el!, close: reason => onClose(reason === 'escape') }))
|
|
267
|
+
})
|
|
268
|
+
const id = () => `${ctx.id}-sh`
|
|
269
|
+
return (
|
|
270
|
+
<div ref={n => (el = n)} data-part="sheet" role="dialog" aria-modal="false" aria-labelledby={id()}>
|
|
271
|
+
<header>
|
|
272
|
+
<h4 id={id()} aria-level={ctx.level} style="margin:0">{ctx.title(row)}</h4>
|
|
273
|
+
<Button emphasis="ghost" tone="neutral" ref={n => (close = n)} onClick={() => onClose(true)}>Close</Button>
|
|
274
|
+
</header>
|
|
275
|
+
<div tabindex="0" role="group" aria-labelledby={id()}>{ctx.body(row)}</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function ColumnsPanel<T>({ id, open, trigger, items, shown, flip }: { id: string; open: State<boolean>; trigger: () => HTMLElement; items: Column<T>[]; shown: (c: Column<T>) => boolean; flip: (c: Column<T>, shown: boolean) => void }) {
|
|
281
|
+
let panel: HTMLDivElement | undefined
|
|
282
|
+
use(popover, () => panel, () => ({ open: open.value, trigger: trigger(), side: 'bottom' as const, align: 'end' as const, onClose: () => (open.value = false) }))
|
|
283
|
+
return (
|
|
284
|
+
<div ref={n => (panel = n)} data-scope="popover" data-part="content" popover="manual" id={id} role="group" aria-label="Visible columns">
|
|
285
|
+
{items.map(c => <div key={c.id}><Checkbox checked={shown(c)} onChange={(e: Event) => flip(c, (e.currentTarget as HTMLInputElement).checked)}>{c.header}</Checkbox></div>)}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A data table (06 §5; data.html:1095-1124): a semantic <table> with explicit ARIA roles, so they survive the phone
|
|
292
|
+
* cards' display change. Sort, filter, Columns, resize, pin, selection with a bulk bar, details, inline edit and live
|
|
293
|
+
* cells, in the states ready, loading, empty and error, inside one frame. Rows are keyed: a sort moves nodes, a new
|
|
294
|
+
* value patches a cell. Given `T[]` it sorts, filters and pages by itself; a Resource or `server` leaves that to the
|
|
295
|
+
* server and reports what was asked. Queries its own width, never the window.
|
|
296
|
+
*/
|
|
297
|
+
export function Table<T>({ rows, caption, captionHidden, columns, rowKey = 'id', phone = 'cards', density, filter, query, sort, onSortChange, server, selection, onSelectionChange, bulk, page, onPageChange, expand, details = 'inline', onEdit, actions, picker = true, hiddenColumns, status, empty, error, onRetry, maxHeight, heading = 2 }: TableProps<T>) {
|
|
298
|
+
const id = uid('tbl')
|
|
299
|
+
const level = untrack(() => heading)
|
|
300
|
+
const cols = derived(() => columns)
|
|
301
|
+
const keyOf = (r: T) => String(typeof rowKey === 'function' ? rowKey(r) : (r as Record<string, unknown>)[rowKey])
|
|
302
|
+
const read = (c: Column<T>, r: T) => (c.value ? c.value(r) : (r as Record<string, unknown>)[c.id])
|
|
303
|
+
const named = derived(() => cols.value.find(c => c.rowHeader) ?? cols.value[0])
|
|
304
|
+
const title = (r: T) => String((read(named.value!, r) as string | number | null | undefined) ?? '')
|
|
305
|
+
|
|
306
|
+
const typed = untrack(() => query) ?? state('')
|
|
307
|
+
const order = untrack(() => sort) ?? state<TableSort | undefined>(undefined)
|
|
308
|
+
const given = untrack(() => selection)
|
|
309
|
+
const sel = given ? (given === true ? state<Record<string, boolean>>({}) : given) : undefined
|
|
310
|
+
const open = state<Record<string, boolean>>({})
|
|
311
|
+
const startHidden = untrack(() => hiddenColumns)
|
|
312
|
+
const hidden: State<readonly string[]> = isState(startHidden) ? startHidden : state<readonly string[]>(startHidden ?? [])
|
|
313
|
+
const at = state(0)
|
|
314
|
+
const sheet = state<{ row: T; from: HTMLElement } | undefined>(undefined)
|
|
315
|
+
const narrow = () => (el?.clientWidth ?? DETAILS_FROM) < DETAILS_FROM
|
|
316
|
+
let anchor: string | undefined
|
|
317
|
+
let el: HTMLDivElement | undefined
|
|
318
|
+
let columnsButton: HTMLElement | undefined
|
|
319
|
+
const picking = state(false)
|
|
320
|
+
// the panel is built on first open and kept, so a closed table carries none of its checkboxes
|
|
321
|
+
const picked = state(false)
|
|
322
|
+
const width = state<number | undefined>(undefined)
|
|
323
|
+
const small = derived(() => width.value !== undefined && width.value < CARDS_BELOW)
|
|
324
|
+
const shape = derived(() => (phone === 'cards' && (width.value ?? 0) >= CARDS_BELOW ? 'columns' : phone))
|
|
325
|
+
|
|
326
|
+
const source = () => (isResource(rows) ? rows.value : isState(rows) ? rows.value : rows) as readonly T[] | ListPage<T> | undefined
|
|
327
|
+
const remote = derived(() => !!server || isResource(rows))
|
|
328
|
+
const list = derived<readonly T[]>(() => { const v = source(); return Array.isArray(v) ? v as readonly T[] : (v as ListPage<T> | undefined)?.items ?? [] })
|
|
329
|
+
const envelope = derived<ListPage<T> | undefined>(() => { const v = source(); return v && !Array.isArray(v) ? v as ListPage<T> : undefined })
|
|
330
|
+
const shown = derived(() => {
|
|
331
|
+
if (remote.value) return list.value
|
|
332
|
+
const c = cols.value
|
|
333
|
+
const found = filterRows(list.value, typed.value, r => c.map(x => read(x, r)))
|
|
334
|
+
const s = order.value
|
|
335
|
+
const by = s && c.find(x => x.id === s.id)
|
|
336
|
+
if (!s || !by) return found
|
|
337
|
+
return sortRows(found, typeof by.sort === 'function' ? by.sort : r => read(by, r), s.dir)
|
|
338
|
+
})
|
|
339
|
+
const paged = derived(() => {
|
|
340
|
+
const size = page?.size
|
|
341
|
+
return size && !remote.value ? pageSlice(shown.value, at.value, size) : undefined
|
|
342
|
+
})
|
|
343
|
+
const visible = derived(() => paged.value?.rows ?? shown.value)
|
|
344
|
+
const phase = derived<TableStatus>(() => {
|
|
345
|
+
if (status) return status
|
|
346
|
+
const r = rows
|
|
347
|
+
if (isResource(r)) {
|
|
348
|
+
if (r.error) return 'error'
|
|
349
|
+
if (r.value === undefined) return 'loading'
|
|
350
|
+
}
|
|
351
|
+
return visible.value.length ? 'ready' : 'empty'
|
|
352
|
+
})
|
|
353
|
+
watch(() => typed.value, (_, was) => { if (was !== undefined) at.value = 0 })
|
|
354
|
+
|
|
355
|
+
const keys = derived(() => visible.value.map(keyOf))
|
|
356
|
+
const chosen = () => Object.keys(sel!.value).filter(k => sel!.value[k])
|
|
357
|
+
const write = (next: Record<string, boolean>) => { sel!.value = next; onSelectionChange?.(Object.keys(next).filter(k => next[k])) }
|
|
358
|
+
const pick = (key: string, e: MouseEvent) => {
|
|
359
|
+
const next = { ...sel!.value }
|
|
360
|
+
if (e.shiftKey && anchor !== undefined) for (const k of rangeOf(keys.value, anchor, key)) next[k] = true
|
|
361
|
+
else if (next[key]) delete next[key]
|
|
362
|
+
else next[key] = true
|
|
363
|
+
anchor = key
|
|
364
|
+
write(next)
|
|
365
|
+
}
|
|
366
|
+
const headerCheck = derived(() => (sel ? checkState(keys.value, k => !!(sel.get(k) as boolean | undefined)) : 'none'))
|
|
367
|
+
const pickAll = () => {
|
|
368
|
+
const next = { ...sel!.value }
|
|
369
|
+
const off = headerCheck.value === 'all'
|
|
370
|
+
for (const k of keys.value) { if (off) delete next[k]; else next[k] = true }
|
|
371
|
+
write(next)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const askSort = (c: Column<T>, dir?: SortDir) => {
|
|
375
|
+
const now = order.value
|
|
376
|
+
const next = { id: c.id, dir: dir ?? (now?.id === c.id && now.dir === 'ascending' ? 'descending' : 'ascending') }
|
|
377
|
+
order.value = next
|
|
378
|
+
onSortChange?.(next)
|
|
379
|
+
}
|
|
380
|
+
const sortable = derived(() => cols.value.filter(c => c.sort))
|
|
381
|
+
|
|
382
|
+
const hasDetails = untrack(() => expand !== undefined || columns.some(c => c.priority))
|
|
383
|
+
const toggle = (row: T, from: HTMLElement) => {
|
|
384
|
+
if (details === 'sheet' && narrow()) { sheet.value = { row, from }; return }
|
|
385
|
+
const k = keyOf(row)
|
|
386
|
+
const next = { ...open.value }
|
|
387
|
+
if (next[k]) delete next[k]
|
|
388
|
+
else next[k] = true
|
|
389
|
+
open.value = next
|
|
390
|
+
}
|
|
391
|
+
const closeSheet = (restore?: boolean) => { const from = sheet.value?.from; sheet.value = undefined; if (restore) from?.focus() }
|
|
392
|
+
|
|
393
|
+
const ctx: Ctx<T> = {
|
|
394
|
+
id, level, cols: () => cols.value, keyOf, read, sel, open, details: hasDetails, title, pick, toggle,
|
|
395
|
+
span: () => cols.value.length + (sel ? 1 : 0) + (hasDetails ? 1 : 0),
|
|
396
|
+
body: row => (expand ? expand(row) : <Fields row={row} ctx={ctx} />),
|
|
397
|
+
edit: (r, c, v) => onEdit?.(r, c, v),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const go = (reason: 'next' | 'prev') => {
|
|
401
|
+
if (remote.value) return onPageChange?.(reason === 'next' ? envelope.value?.next : envelope.value?.prev, reason)
|
|
402
|
+
at.value = paged.value!.page + (reason === 'next' ? 1 : -1)
|
|
403
|
+
onPageChange?.(at.value, reason)
|
|
404
|
+
}
|
|
405
|
+
const hasPrev = derived(() => phase.value === 'ready' && (remote.value ? envelope.value?.prev != null : (paged.value?.page ?? 0) > 0))
|
|
406
|
+
const hasNext = derived(() => phase.value === 'ready' && (remote.value ? envelope.value?.next != null : !!paged.value && paged.value.page < paged.value.pages - 1))
|
|
407
|
+
const range = derived(() => {
|
|
408
|
+
if (phase.value !== 'ready') return ''
|
|
409
|
+
const p = paged.value
|
|
410
|
+
if (p) return `${p.from}–${p.to} of ${shown.value.length}`
|
|
411
|
+
const total = envelope.value?.total
|
|
412
|
+
return total === undefined ? '' : `${visible.value.length} of ${total}`
|
|
413
|
+
})
|
|
414
|
+
const count = derived(() => (phase.value === 'ready' && !remote.value ? `${shown.value.length} of ${list.value.length}` : ''))
|
|
415
|
+
const footer = derived(() => page !== undefined || isResource(rows))
|
|
416
|
+
|
|
417
|
+
const retry = () => { onRetry?.(); if (isResource(rows)) rows.refresh() }
|
|
418
|
+
const clear = () => (typed.value = '')
|
|
419
|
+
const noticeTitle = derived(() => (phase.value === 'error' ? error?.title ?? 'This couldn’t load' : typed.value ? `No rows match “${typed.value}”` : empty?.title ?? 'Nothing here yet'))
|
|
420
|
+
const noticeText = derived(() => {
|
|
421
|
+
if (phase.value === 'error') { return error?.text ?? (isResource(rows) && rows.error instanceof Error ? rows.error.message : 'Try again in a moment.') }
|
|
422
|
+
return typed.value ? 'Try a shorter term, or clear the filter.' : empty?.text
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
use(table, () => el, () => ({ hidden: hidden.value, rows: visible.value, onWidth: w => (width.value = w) }))
|
|
426
|
+
onCleanup(() => (sheet.value = undefined))
|
|
427
|
+
|
|
428
|
+
const toolbar = derived(() => filter !== undefined || actions !== undefined || !!(picker as unknown))
|
|
429
|
+
const hideable = derived(() => cols.value.filter(c => !c.pin))
|
|
430
|
+
const flip = (c: Column<T>, show: boolean) => { hidden.value = show ? hidden.value.filter(x => x !== c.id) : [...hidden.value, c.id] }
|
|
431
|
+
return (
|
|
432
|
+
<div ref={n => (el = n)} data-scope="table" data-phone={shape.value} data-state={phase.value} data-density={density} id={id}>
|
|
433
|
+
{untrack(() => hidden.value.length > 0) && <style data-part="hide"
|
|
434
|
+
// oxlint-disable-next-line weft/no-html-sink -- hideRule builds a CSS rule from escaped column ids, no markup
|
|
435
|
+
innerHTML={untrack(() => hideRule(id, hidden.value))}></style>}
|
|
436
|
+
{toolbar.value && (
|
|
437
|
+
<div data-part="toolbar">
|
|
438
|
+
{filter !== undefined && <Search label={filter} value={typed.value} onInput={(e: Event) => (typed.value = (e.currentTarget as HTMLInputElement).value)} />}
|
|
439
|
+
<span data-part="count" aria-live="polite">{count.value}</span>
|
|
440
|
+
<span data-part="spacer"></span>
|
|
441
|
+
{actions}
|
|
442
|
+
{picker && (
|
|
443
|
+
<div>
|
|
444
|
+
<Button emphasis="outline" tone="neutral" ref={n => (columnsButton = n)} aria-expanded={picking.value ? 'true' : 'false'} aria-controls={picked.value ? `${id}-cols` : undefined} onClick={() => { picked.value = true; picking.value = !picking.value }}>{columnsIcon()}Columns</Button>
|
|
445
|
+
{picked.value && <ColumnsPanel id={`${id}-cols`} open={picking} trigger={() => columnsButton!} items={hideable.value} shown={c => !hidden.value.includes(c.id)} flip={flip} />}
|
|
446
|
+
</div>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
{sel && (
|
|
451
|
+
<div data-part="bulk" hidden={chosen().length === 0} role="region" aria-label="Bulk actions">
|
|
452
|
+
<b>{chosen().length} selected</b>
|
|
453
|
+
{bulk?.(chosen())}
|
|
454
|
+
<Button emphasis="ghost" onClick={() => write({})}>Clear</Button>
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
{phone === 'cards' && small.value && sortable.value.length > 0 && (
|
|
458
|
+
<div data-part="sortbar">
|
|
459
|
+
<Select label="Sort by" size="sm" value={order.value?.id ?? ''} placeholder="None" options={sortable.value.map(c => ({ value: c.id, label: c.header }))} onChange={(e: Event) => { const c = cols.value.find(x => x.id === (e.currentTarget as HTMLInputElement).value); if (c) askSort(c, order.value?.dir ?? 'ascending') }} />
|
|
460
|
+
<Button emphasis="outline" tone="neutral" size="sm" aria-label="Toggle sort direction" disabled={!order.value} onClick={() => { const c = cols.value.find(x => x.id === order.value?.id); if (c) askSort(c, order.value!.dir === 'ascending' ? 'descending' : 'ascending') }}>{order.value?.dir === 'ascending' ? '↑' : '↓'}</Button>
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
{/* a scroller (wide on a phone, or `maxHeight`) a keyboard can reach: focus it and the arrows scroll, named by the caption */}
|
|
464
|
+
<div data-part="viewport" tabindex="0" role="region" aria-labelledby={`${id}-cap`} style={maxHeight ? `--warp-table-max-h:${maxHeight}` : undefined}>
|
|
465
|
+
<table role="table" aria-labelledby={`${id}-cap`} aria-rowcount={shown.value.length + 1}>
|
|
466
|
+
<caption id={`${id}-cap`} data-part={captionHidden ? 'sr-only' : undefined}>{caption}</caption>
|
|
467
|
+
<thead>
|
|
468
|
+
<tr role="row">
|
|
469
|
+
{sel && <th role="columnheader" scope="col" data-part="select" data-pinned="first"><Checkbox checked={headerCheck.value === 'all'} indeterminate={headerCheck.value === 'some'} aria-label="Select all rows on this page" onClick={pickAll} /></th>}
|
|
470
|
+
{hasDetails && <th role="columnheader" scope="col" style="inline-size:1px;padding-inline:4px"><span data-part="sr-only">Details</span></th>}
|
|
471
|
+
{cols.value.map(c => (
|
|
472
|
+
<th
|
|
473
|
+
key={c.id}
|
|
474
|
+
role="columnheader"
|
|
475
|
+
scope="col"
|
|
476
|
+
data-col={c.id}
|
|
477
|
+
data-numeric={c.numeric ? '' : undefined}
|
|
478
|
+
data-priority={c.priority}
|
|
479
|
+
data-pinned={c.pin ? 'last' : undefined}
|
|
480
|
+
aria-sort={order.value?.id === c.id ? order.value.dir : undefined}
|
|
481
|
+
>
|
|
482
|
+
{c.sort ? <button type="button" data-part="sort" onClick={() => askSort(c)}>{c.header}{sortIcon()}</button> : c.header}
|
|
483
|
+
{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>}
|
|
484
|
+
</th>
|
|
485
|
+
))}
|
|
486
|
+
</tr>
|
|
487
|
+
</thead>
|
|
488
|
+
<tbody aria-busy={phase.value === 'loading' ? 'true' : undefined}>
|
|
489
|
+
{phase.value === 'loading'
|
|
490
|
+
? Array.from({ length: SKELETON_ROWS }, (_, n) => n).map(n => (
|
|
491
|
+
<tr key={n} role="row">
|
|
492
|
+
{sel && <td role="cell" data-part="select"></td>}
|
|
493
|
+
{hasDetails && <td role="cell"></td>}
|
|
494
|
+
{cols.value.map(c => <td key={c.id} role="cell" data-col={c.id} data-priority={c.priority}><span data-part="skeleton" style={`--w:${30 + ((n * 17 + c.id.length * 9) % 55)}%`}></span></td>)}
|
|
495
|
+
</tr>
|
|
496
|
+
))
|
|
497
|
+
: phase.value === 'ready'
|
|
498
|
+
? visible.value.map(r => <Row key={keyOf(r)} row={r} ctx={ctx} />)
|
|
499
|
+
: (
|
|
500
|
+
<tr role="row">
|
|
501
|
+
<td role="cell" colspan={ctx.span()} style="white-space:normal;block-size:auto">
|
|
502
|
+
<div data-part="state" data-kind={phase.value} role={phase.value === 'error' ? 'alert' : 'status'}>
|
|
503
|
+
<svg viewBox="0 0 72 40" aria-hidden="true"><line x1="8" y1="0" x2="8" y2="40" /><line x1="22" y1="0" x2="22" y2="40" /><line x1="36" y1="0" x2="36" y2="40" /><line x1="50" y1="0" x2="50" y2="40" /><line x1="64" y1="0" x2="64" y2="40" /><path d={phase.value === 'error' ? 'M0 20H30' : 'M0 14H72M0 26H72'} /></svg>
|
|
504
|
+
<h4 aria-level={level}>{noticeTitle.value}</h4>
|
|
505
|
+
<p>{noticeText.value}</p>
|
|
506
|
+
{phase.value === 'error'
|
|
507
|
+
? <Button emphasis="outline" tone="neutral" onClick={retry}>Retry</Button>
|
|
508
|
+
: typed.value ? <Button emphasis="outline" tone="neutral" onClick={clear}>Clear filter</Button> : empty?.action}
|
|
509
|
+
</div>
|
|
510
|
+
</td>
|
|
511
|
+
</tr>
|
|
512
|
+
)}
|
|
513
|
+
</tbody>
|
|
514
|
+
</table>
|
|
515
|
+
</div>
|
|
516
|
+
{footer.value && (
|
|
517
|
+
<div data-part="footer">
|
|
518
|
+
<span>{range.value}</span>
|
|
519
|
+
<div style="display:flex;gap:8px">
|
|
520
|
+
<Button emphasis="outline" tone="neutral" size="sm" disabled={!hasPrev.value} onClick={() => go('prev')}>Previous</Button>
|
|
521
|
+
<Button emphasis="outline" tone="neutral" size="sm" disabled={!hasNext.value} onClick={() => go('next')}>Next</Button>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
{sheet.value && <Sheet row={sheet.value.row} ctx={ctx} onClose={closeSheet} />}
|
|
526
|
+
</div>
|
|
527
|
+
)
|
|
528
|
+
}
|
package/src/Tabs.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { indicator, nextIndex, overflow, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { untrack } from '@weft-js/core'
|
|
3
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/components/tabs.css'
|
|
6
|
+
|
|
7
|
+
/** One tab. */
|
|
8
|
+
export type TabOption<V extends string> = { value: V; label: unknown; disabled?: boolean | undefined }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tabs (05 §4; components.html:660-706, 1821-1849): line (default) · pill · enclosed. APG automatic activation:
|
|
12
|
+
* ←/→ move and select (wrapping), Home/End, one tab stop; every tab controls a tabpanel. The indicator travels
|
|
13
|
+
* through the rail on the sweep curve; a list wider than its box scrolls with edge fades and keeps the selected tab
|
|
14
|
+
* in view. `children(value)` renders a panel; a pill strip filters one shared panel.
|
|
15
|
+
*/
|
|
16
|
+
export function Tabs<V extends string>({ label, value, onChange, options, variant, children }: { label: string; value: V; onChange: (v: V) => void; options: TabOption<V>[]; variant?: 'line' | 'pill' | 'enclosed' | undefined; children?: (value: V) => unknown }) {
|
|
17
|
+
const base = uid('tabs')
|
|
18
|
+
const kind = untrack(() => variant)
|
|
19
|
+
const tab = (i: number) => `${base}-t${i}`
|
|
20
|
+
const panel = (i: number) => (kind === 'pill' ? `${base}-p0` : `${base}-p${i}`)
|
|
21
|
+
let list: HTMLDivElement | undefined
|
|
22
|
+
use(indicator, () => list, () => ({ selected: '[aria-selected="true"]', at: value }))
|
|
23
|
+
use(overflow, () => list, () => list?.querySelector<HTMLElement>('[aria-selected="true"]') ?? null)
|
|
24
|
+
const onKey = (e: KeyboardEvent) => {
|
|
25
|
+
const tabs = options.filter(o => !o.disabled)
|
|
26
|
+
const at = tabs.findIndex(o => o.value === value)
|
|
27
|
+
const rtl = getComputedStyle(e.currentTarget as Element).direction === 'rtl'
|
|
28
|
+
const to = nextIndex(e.key, at, tabs.length, { rtl })
|
|
29
|
+
if (to < 0) return
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
onChange(tabs[to]!.value)
|
|
32
|
+
;(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role="tab"]')[options.findIndex(o => o.value === tabs[to]!.value)]?.focus() // by position: a hydrated server id isn't this side's
|
|
33
|
+
}
|
|
34
|
+
const selected = () => options.findIndex(o => o.value === value)
|
|
35
|
+
return (
|
|
36
|
+
<div data-scope="tabs" data-part="root" data-variant={kind === 'line' ? undefined : kind}>
|
|
37
|
+
<div ref={n => (list = n)} data-part="list" role="tablist" aria-label={label} onKeyDown={onKey}>
|
|
38
|
+
{options.map((o, i) => (
|
|
39
|
+
<button
|
|
40
|
+
key={o.value}
|
|
41
|
+
data-part="trigger"
|
|
42
|
+
role="tab"
|
|
43
|
+
type="button"
|
|
44
|
+
id={tab(i)}
|
|
45
|
+
aria-controls={panel(i)}
|
|
46
|
+
aria-selected={o.value === value ? 'true' : 'false'}
|
|
47
|
+
tabindex={o.value === value ? undefined : '-1'}
|
|
48
|
+
disabled={o.disabled}
|
|
49
|
+
onClick={() => onChange(o.value)}
|
|
50
|
+
>
|
|
51
|
+
{o.label}
|
|
52
|
+
</button>
|
|
53
|
+
))}
|
|
54
|
+
<span data-part="indicator" aria-hidden="true" data-instant=""></span>
|
|
55
|
+
</div>
|
|
56
|
+
{kind === 'pill' ? (
|
|
57
|
+
<div data-part="content" role="tabpanel" id={panel(0)} aria-labelledby={tab(selected())} tabindex="0" hidden={!children}>{children?.(value)}</div>
|
|
58
|
+
) : (
|
|
59
|
+
options.map((o, i) => (
|
|
60
|
+
<div key={o.value} data-part="content" role="tabpanel" id={panel(i)} aria-labelledby={tab(i)} tabindex="0" hidden={o.value !== value}>
|
|
61
|
+
{children?.(o.value)}
|
|
62
|
+
</div>
|
|
63
|
+
))
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
package/src/Tag.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import type { Tone } from './Button'
|
|
3
|
+
import './styles'
|
|
4
|
+
import '@weft-js/warp-core/styles/components/tag.css'
|
|
5
|
+
|
|
6
|
+
/** A tag: user content (05 §6). `onRemove` adds the remove button, named "Remove {label}". */
|
|
7
|
+
export function Tag({ tone, swatch, onRemove, label, children, ...rest }: { tone?: Tone | undefined; swatch?: boolean; onRemove?: () => void; /** The name for the remove button, when `children` is an expression (the compiler hands those over as a thunk). */ label?: string | undefined; children: unknown; [attr: string]: unknown }) {
|
|
8
|
+
return (
|
|
9
|
+
<span data-scope="tag" data-tone={tone} {...rest}>
|
|
10
|
+
{swatch && <span data-part="swatch"></span>}
|
|
11
|
+
{children}
|
|
12
|
+
{onRemove && <button type="button" data-part="remove" aria-label={`Remove ${(label ?? children) as string}`} onClick={onRemove}><XIcon /></button>}
|
|
13
|
+
</span>
|
|
14
|
+
)
|
|
15
|
+
}
|
package/src/Ticker.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { derived } from '@weft-js/core'
|
|
2
|
+
import './styles'
|
|
3
|
+
import '@weft-js/warp-core/styles/components/ticker.css'
|
|
4
|
+
|
|
5
|
+
/** Props for Ticker. */
|
|
6
|
+
export interface TickerProps {
|
|
7
|
+
value: number
|
|
8
|
+
/** Passed to Intl.NumberFormat: `{ style: 'currency', currency: 'USD' }`. */
|
|
9
|
+
format?: Intl.NumberFormatOptions | undefined
|
|
10
|
+
locale?: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A number that rolls (05 §5): each digit is a column of 0–9 drawn by CSS and moved by one custom property, so a change
|
|
15
|
+
* writes one attribute per digit that moved. Digits are keyed from the right, so a new leading digit arrives on its own.
|
|
16
|
+
* The real value is in the DOM for readers; under reduced motion it swaps.
|
|
17
|
+
*/
|
|
18
|
+
export function Ticker({ value, format, locale }: TickerProps) {
|
|
19
|
+
const fmt = derived(() => new Intl.NumberFormat(locale, format)) // apart from the text: a tick mustn't rebuild it
|
|
20
|
+
const text = derived(() => fmt.value.format(value))
|
|
21
|
+
const parts = derived(() => {
|
|
22
|
+
// oxlint-disable-next-line typescript/no-misused-spread -- code points are what it wants: a glyph per step, and Intl.Segmenter would cost a hot loop
|
|
23
|
+
const s = [...text.value]
|
|
24
|
+
return s.map((c, i) => ({ c, at: s.length - i }))
|
|
25
|
+
})
|
|
26
|
+
return (
|
|
27
|
+
<span data-scope="ticker">
|
|
28
|
+
<span data-part="sr">{text.value}</span>
|
|
29
|
+
<span aria-hidden="true">
|
|
30
|
+
{parts.value.map(p => /\d/.test(p.c)
|
|
31
|
+
? <span key={p.at} data-part="digit" style={`--_d:${p.c};--_at:${p.at}`}></span>
|
|
32
|
+
: <span key={p.at} data-part="sym">{p.c}</span>)}
|
|
33
|
+
</span>
|
|
34
|
+
</span>
|
|
35
|
+
)
|
|
36
|
+
}
|