@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.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
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
+ }