@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/Menu.tsx ADDED
@@ -0,0 +1,111 @@
1
+ import { menu, moveShuttle, uid, type Align, type Side } from '@weft-js/warp-core'
2
+ import { onCleanup, onMount, state } from '@weft-js/core'
3
+ import { Button, type ButtonProps } from './Button'
4
+ import { Icon, type IconRef } from './Icon'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import './styles'
7
+ import '@weft-js/warp-core/styles/components/menu.css'
8
+
9
+ /** One line of a Menu: an item, a group label or a separator. */
10
+ export type MenuEntry =
11
+ | { label: string; onSelect?: () => void; icon?: IconRef | undefined; shortcut?: string; tone?: 'critical' | undefined; disabled?: boolean | undefined; href?: string }
12
+ | { separator: true }
13
+ | { group: string }
14
+
15
+ const Row = ({ e }: { e: Extract<MenuEntry, { label: string }> }) => (
16
+ <>
17
+ {e.icon && <Icon name={e.icon} />}
18
+ <span data-part="item-label">{e.label}</span>
19
+ {e.shortcut && <span data-part="shortcut">{e.shortcut}</span>}
20
+ </>
21
+ )
22
+
23
+ /** Props for Menu. */
24
+ export interface MenuProps {
25
+ /** The trigger's text. */
26
+ label: unknown
27
+ items: MenuEntry[]
28
+ trigger?: ButtonProps | undefined
29
+ side?: Side | undefined
30
+ align?: Align
31
+ }
32
+
33
+ /**
34
+ * A menu button (05 §3, APG): the trigger holds its sweep while open and the panel opens from it. warp-core's menu() owns
35
+ * focus, ↑/↓, Home/End, typeahead, Escape and Tab; the highlight is one shuttle that follows focus and the pointer.
36
+ */
37
+ export function Menu({ label, items, trigger, side = 'bottom', align = 'start' }: MenuProps) {
38
+ const id = uid('menu')
39
+ const open = state(false)
40
+ const at = state<'first' | 'last'>('first')
41
+ let button: HTMLButtonElement | undefined
42
+ let panel: HTMLDivElement | undefined
43
+ let face: HTMLElement | undefined
44
+ const close = () => (open.value = false)
45
+ const follow = (el: HTMLElement) => {
46
+ moveShuttle(face!, el.offsetTop, el.offsetTop + el.offsetHeight)
47
+ face!.setAttribute('data-on', '')
48
+ }
49
+ onMount(() => {
50
+ const onToggle = (e: ToggleEvent) => {
51
+ if (e.newState === 'open') face!.setAttribute('data-instant', '')
52
+ else {
53
+ face!.removeAttribute('data-on')
54
+ face!.style.removeProperty('--warp-a')
55
+ }
56
+ }
57
+ panel!.addEventListener('beforetoggle', onToggle)
58
+ onCleanup(() => panel!.removeEventListener('beforetoggle', onToggle))
59
+ })
60
+ // the panel is shown before menu() moves focus in, so the first move lands without travelling
61
+ const syncOpen = (el: HTMLElement, o: { open: boolean; at: 'first' | 'last' }) => {
62
+ const opts = () => ({ open: o.open, at: o.at, trigger: () => button, side, align, onClose: close })
63
+ if (o.open) el.showPopover?.()
64
+ const m = menu(el, opts())
65
+ return {
66
+ update(n: typeof o) {
67
+ o = n
68
+ if (n.open && !el.matches(':popover-open')) el.showPopover?.()
69
+ m.update(opts())
70
+ if (!n.open && el.matches(':popover-open')) el.hidePopover?.()
71
+ if (n.open) requestAnimationFrame(() => requestAnimationFrame(() => face?.removeAttribute('data-instant')))
72
+ },
73
+ destroy() { m.destroy() },
74
+ }
75
+ }
76
+ use(syncOpen, () => panel, () => ({ open: open.value, at: at.value }))
77
+ const onKey = (e: KeyboardEvent) => {
78
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
79
+ e.preventDefault()
80
+ at.value = e.key === 'ArrowUp' ? 'last' : 'first'
81
+ open.value = true
82
+ }
83
+ const pick = (e: { onSelect?: () => void }) => { close(); button?.focus(); e.onSelect?.() }
84
+ return (
85
+ <>
86
+ <Button emphasis="outline" tone="neutral" {...(trigger ?? {})} ref={(el: HTMLButtonElement) => (button = el)} id={`${id}-b`} aria-haspopup="menu" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} onClick={() => { at.value = 'first'; open.value = !open.value }} onKeyDown={onKey}>
87
+ {label}
88
+ </Button>
89
+ <div
90
+ ref={n => (panel = n)}
91
+ data-scope="menu"
92
+ data-part="content"
93
+ role="menu"
94
+ popover="manual"
95
+ id={id}
96
+ aria-labelledby={`${id}-b`}
97
+ onFocusIn={(e: FocusEvent) => { const t = (e.target as HTMLElement).closest?.<HTMLElement>('[role="menuitem"]'); if (t) follow(t) }}
98
+ onPointerMove={(e: PointerEvent) => { const t = (e.target as HTMLElement).closest?.<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'); if (t && t !== document.activeElement) t.focus({ preventScroll: true }) }}
99
+ >
100
+ <span ref={n => (face = n)} data-part="shuttle" aria-hidden="true"></span>
101
+ {items.map((e, i) => 'separator' in e
102
+ ? <div key={i} data-part="separator" role="separator"></div>
103
+ : 'group' in e
104
+ ? <div key={i} data-part="group-label">{e.group}</div>
105
+ : e.href && !e.disabled
106
+ ? <a key={i} href={e.href} data-part="item" role="menuitem" tabindex="-1" data-tone={e.tone} onClick={() => pick(e)}><Row e={e} /></a>
107
+ : <div key={i} data-part="item" role="menuitem" tabindex="-1" data-tone={e.tone} aria-disabled={e.disabled ? 'true' : undefined} onClick={() => { if (!e.disabled) pick(e) }}><Row e={e} /></div>)}
108
+ </div>
109
+ </>
110
+ )
111
+ }
package/src/Modal.tsx ADDED
@@ -0,0 +1,40 @@
1
+ import { XIcon } from '@weft-js/warp-icons'
2
+ import { dialog, uid } from '@weft-js/warp-core'
3
+ import { derived, untrack, type State } from '@weft-js/core'
4
+ import type { Tone } from './Button'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import './styles'
7
+ import '@weft-js/warp-core/styles/components/button.css' // the close button's markup
8
+ import '@weft-js/warp-core/styles/components/dialog.css'
9
+
10
+ /**
11
+ * A modal dialog on the native <dialog> (05 §3; components.html:1067-1090): showModal, focus to [autofocus] or the
12
+ * title, back to the opener on every close path, scroll lock by CSS. Named by its title, described by its
13
+ * description. `open` is a box: the dialog writes false when the user closes it. `alert` makes it an alert dialog
14
+ * (no backdrop dismiss; focus Cancel by giving it autofocus). `inline` draws it open in the page's flow, for docs and
15
+ * previews: a non-modal <dialog open> with no top layer, scrim, focus trap or scroll lock; `open` still shows and hides it.
16
+ */
17
+ export function Modal({ open, title, description, footer, size, placement, alert, tone, mark, inline, children }: { open: State<boolean>; title: unknown; description?: unknown; footer?: unknown; size?: 'sm' | 'md' | 'lg' | undefined; /** Where it sits, and the edge it arrives from. */ placement?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'full' | undefined; alert?: boolean | undefined; tone?: Tone | undefined; /** Drawn above the title (09's result mark). */ mark?: unknown; /** Drawn open in the page's flow, not over it: no top layer, scrim or focus trap. For docs and previews. */ inline?: boolean; children?: unknown }) {
18
+ const id = uid('dlg')
19
+ const hasDescription = derived(() => description !== undefined)
20
+ const sign = derived(() => mark)
21
+ const foot = derived(() => footer) // one build of the footer, however often it is read
22
+ let el: HTMLDialogElement | undefined
23
+ const flat = untrack(() => !!inline)
24
+ if (!flat) use(dialog, () => el, () => ({ open: open.value, onClose: () => (open.value = false) }))
25
+ // inline: [data-close] still closes it, by `open`, as the dialog action would
26
+ const closer = (e: Event) => { if (flat && (e.target as Element).closest?.('[data-close]')) open.value = false }
27
+ return (
28
+ <dialog ref={n => (el = n)} open={flat ? open.value : undefined} data-presentation={flat ? 'inline' : undefined} onClick={closer} data-scope="dialog" role={alert ? 'alertdialog' : undefined} data-size={size === 'md' ? undefined : size} data-placement={placement === 'center' ? undefined : placement} data-tone={tone} aria-labelledby={`${id}-t`} aria-describedby={hasDescription.value ? `${id}-d` : undefined}>
29
+ <div data-part="header">
30
+ {sign.value}
31
+ <h2 data-part="title" id={`${id}-t`}>{title}</h2>
32
+ {hasDescription.value && <p data-part="description" id={`${id}-d`}>{description}</p>}
33
+ {/* <Button emphasis="ghost" tone="neutral" icon={XIcon} label="Close"> as its markup: a ghost button needs no JS */}
34
+ <button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-icon-only="" aria-label="Close" data-part="close" data-close=""><XIcon /></button>
35
+ </div>
36
+ <div data-part="body">{children}</div>
37
+ {foot.value !== undefined && <div data-part="footer">{foot.value}</div>}
38
+ </dialog>
39
+ )
40
+ }
@@ -0,0 +1,190 @@
1
+ import { DownIcon, LeftIcon, RightIcon } from '@weft-js/warp-icons'
2
+ import { addDays, dow, fmt, fmtRange, isoWeek, isoWeekStart, pad, toIso, weekOf, ymd, type ChangeReason, type IsoMonth, type IsoWeek } from '@weft-js/warp-core'
3
+ import { derived, flush, state, untrack } from '@weft-js/core'
4
+ import { Button } from './Button'
5
+ import { CalendarGrid } from './Calendar'
6
+ import { bindable, Composite, Hidden, Panel, useOpen, usePickerEnv, type Bindable, type Openable, type RichFieldProps } from './Picker'
7
+ import '@weft-js/warp-core/styles/rich/monthgrid.css'
8
+ import '@weft-js/warp-core/styles/rich/gap-rows.css'
9
+
10
+ export type Cell = { v: string; text: string; name: string }
11
+
12
+ /**
13
+ * The period grid (04 §2): a title with arrows over rows of cells, one tab stop. ←/→ ±1, ↑/↓ ± a row, PageUp/PageDown
14
+ * a page, Enter/Space picks; cells are named in full.
15
+ */
16
+ function PeriodGrid({ title, cells, columns, value, current, prev, next, onPage, onPick, wide }: { title: string; cells: Cell[]; columns: number; value: string | null; current: string; prev: string; next: string; onPage: (d: number) => void; onPick: (v: string, r: ChangeReason) => void; wide?: boolean }) {
17
+ const at = state(untrack(() => value ?? current))
18
+ let box: HTMLDivElement | undefined
19
+ const rows = derived(() => Array.from({ length: Math.ceil(cells.length / columns) }, (_, r) => ({ k: cells[r * columns]!.v, cells: cells.slice(r * columns, r * columns + columns) })))
20
+ const stop = derived(() => (cells.some(c => c.v === at.value) ? at.value : cells[0]!.v))
21
+ const focus = () => { flush(); box?.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]')?.focus() }
22
+ const onKey = (e: KeyboardEvent) => {
23
+ const el = (e.target as Element).closest?.<HTMLElement>('[data-v]')
24
+ if (!el) return
25
+ const i = cells.findIndex(c => c.v === el.dataset.v)
26
+ const d = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -columns, ArrowDown: columns } as Record<string, number>)[e.key]
27
+ if (d) {
28
+ e.preventDefault()
29
+ if (i + d < 0) onPage(-1)
30
+ else if (i + d >= cells.length) onPage(1)
31
+ else at.value = cells[i + d]!.v
32
+ focus()
33
+ } else if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); onPage(e.key === 'PageUp' ? -1 : 1); focus() }
34
+ else if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); at.value = cells[e.key === 'Home' ? 0 : cells.length - 1]!.v; focus() }
35
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(cells[i]!.v, 'keyboard') }
36
+ }
37
+ return (
38
+ <div ref={n => (box = n)} data-scope="monthgrid" onKeyDown={onKey}>
39
+ <div data-part="head">
40
+ <Button emphasis="ghost" size="sm" icon={LeftIcon} label={prev} data-icon="" onClick={() => onPage(-1)} />
41
+ <b aria-live="polite">{title}</b>
42
+ <Button emphasis="ghost" size="sm" icon={RightIcon} label={next} data-icon="" onClick={() => onPage(1)} />
43
+ </div>
44
+ <div role="grid" aria-label={title} class={wide ? 'pg' : undefined} style={wide ? `--c:${columns}` : undefined}>
45
+ {rows.value.map(r => (
46
+ <div key={r.k} role="row">
47
+ {r.cells.map(c => <button key={c.v} type="button" role="gridcell" data-v={c.v} data-initial={c.v === stop.value ? '' : undefined} tabindex={c.v === stop.value ? '0' : '-1'} aria-selected={c.v === value ? 'true' : 'false'} aria-current={c.v === current ? 'date' : undefined} onClick={() => onPick(c.v, 'pointer')}><span aria-hidden="true">{c.text}</span><span class="sr">{c.name + (c.v === current ? ', this period' : '')}</span></button>)}
48
+ </div>
49
+ ))}
50
+ </div>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ const monthCells = (year: number, locale: string): Cell[] => Array.from({ length: 12 }, (_, i) => ({ v: `${year}-${pad(i + 1)}`, text: fmt(toIso(year, i + 1, 1), { month: 'short' }, locale), name: fmt(toIso(year, i + 1, 1), { month: 'long', year: 'numeric' }, locale) }))
56
+ const yearOf = (v: string | null, today: string) => +(v ?? today).slice(0, 4)
57
+
58
+ /** The `pick` button of a field whose whole control opens its panel. */
59
+ function PickButton({ el, text, label, open, disabled, onToggle }: { el: (b: HTMLButtonElement) => void; text: string; label: string; open: boolean; disabled?: boolean | undefined; onToggle: () => void }) {
60
+ return (
61
+ <button ref={n => el(n)} type="button" data-part="pick" aria-haspopup="dialog" aria-expanded={open ? 'true' : 'false'} aria-label={label} disabled={disabled} onClick={onToggle}>
62
+ <span>{text}</span><DownIcon />
63
+ </button>
64
+ )
65
+ }
66
+
67
+ /** Props for MonthPicker. */
68
+ export type MonthPickerProps = RichFieldProps & Bindable<IsoMonth | null> & Openable & { min?: IsoMonth | undefined; max?: IsoMonth | undefined }
69
+
70
+ /** MonthPicker (04 §2): a 3×4 grid of the locale's short month names with year arrows. */
71
+ export function MonthPicker({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, locale, today, name, presentation, label, disabled, ...rest }: MonthPickerProps) {
72
+ const env = usePickerEnv(() => locale, () => today)
73
+ const v = bindable<IsoMonth | null>(() => value, () => defaultValue, null, () => onValueChange)
74
+ const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
75
+ const year = state(yearOf(untrack(() => v.value), untrack(() => env.today.value)))
76
+ const shown = derived(() => (v.value ? fmt(`${v.value}-01`, { month: 'long', year: 'numeric' }, env.locale.value) : ''))
77
+ let trigger: HTMLButtonElement | undefined
78
+ return (
79
+ <Composite
80
+ {...rest}
81
+ label={label}
82
+ disabled={disabled}
83
+ bound={v}
84
+ filled={!!v.value}
85
+ hold={pop.value}
86
+ control={() => <><PickButton el={n => (trigger = n)} text={shown.value} label={`${label}, ${shown.value || 'none'}`} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} /><Hidden name={name} value={() => v.value ?? ''} /></>}
87
+ end={
88
+ <Panel label="Choose a month" open={pop} trigger={() => trigger!} presentation={presentation}>
89
+ {() => <PeriodGrid title={String(year.value)} cells={monthCells(year.value, env.locale.value)} columns={3} value={v.value} current={env.today.value.slice(0, 7)} prev="Previous year" next="Next year" onPage={d => (year.value += d)} onPick={(x, r) => { v.set(x as IsoMonth, r); pop.set(false) }} />}
90
+ </Panel>
91
+ }
92
+ />
93
+ )
94
+ }
95
+
96
+ /** WeekPicker's props: a field whose value is an ISO week, or null. */
97
+ export type WeekPickerProps = RichFieldProps & Bindable<IsoWeek | null> & Openable & {
98
+ /** The day each grid row starts on: `iso` (the default) starts on Monday, so a row is one ISO 8601 week; `locale` starts on the locale's first day of the week. The value is an ISO week either way. */
99
+ numbering?: 'iso' | 'locale'
100
+ }
101
+
102
+ const weekName = (w: string, locale: string) => {
103
+ const s = isoWeekStart(w)
104
+ return `Week ${+w.slice(6)}, ${fmtRange(s, addDays(s, 6), locale, { month: 'long', day: 'numeric', year: 'numeric' })}`
105
+ }
106
+
107
+ /** WeekPicker (04 §2): the month grid with a leading week number; selection covers the whole row. */
108
+ export function WeekPicker({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, numbering = 'iso', locale, today, name, presentation, label, disabled, ...rest }: WeekPickerProps) {
109
+ const env = usePickerEnv(() => locale, () => today)
110
+ const v = bindable<IsoWeek | null>(() => value, () => defaultValue, null, () => onValueChange)
111
+ const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
112
+ const shown = derived(() => (v.value ? `Week ${+v.value.slice(6)}, ${v.value.slice(0, 4)}` : ''))
113
+ let trigger: HTMLButtonElement | undefined
114
+ return (
115
+ <Composite
116
+ {...rest}
117
+ label={label}
118
+ disabled={disabled}
119
+ bound={v}
120
+ filled={!!v.value}
121
+ hold={pop.value}
122
+ control={() => <><PickButton el={n => (trigger = n)} text={shown.value} label={v.value ? `${label}, ${shown.value}, ${weekName(v.value, env.locale.value)}` : `${label}, none`} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} /><Hidden name={name} value={() => v.value ?? ''} /></>}
123
+ end={
124
+ <Panel label="Choose a week" open={pop} trigger={() => trigger!} presentation={presentation}>
125
+ {() => <CalendarGrid mode="week" value={() => v.value} firstDayOfWeek={numbering === 'iso' ? 1 : undefined} weekNumbers locale={env.locale.value} today={env.today.value} autofocus onPick={(iso, r) => { v.set(weekOf(iso), r); pop.set(false) }} />}
126
+ </Panel>
127
+ }
128
+ />
129
+ )
130
+ }
131
+
132
+ export type Granularity = 'year' | 'quarter' | 'month' | 'week'
133
+ /** Props for PeriodPicker. */
134
+ export type PeriodPickerProps = RichFieldProps & Bindable<string | null> & Openable & { granularity?: Granularity }
135
+
136
+ const granularityOf = (v: string | null | undefined): Granularity => (!v ? 'month' : /-W/.test(v) ? 'week' : /-Q/.test(v) ? 'quarter' : /-/.test(v) ? 'month' : 'year')
137
+
138
+ /** A period value in words (`Q4 2026`, `October 2026`, `Week 44, 2026`). */
139
+ function periodText(v: string, locale: string) {
140
+ const g = granularityOf(v)
141
+ return g === 'year' ? v : g === 'quarter' ? `Q${v.slice(-1)} ${v.slice(0, 4)}` : g === 'month' ? fmt(`${v}-01`, { month: 'long', year: 'numeric' }, locale) : `Week ${+v.slice(6)}, ${v.slice(0, 4)}`
142
+ }
143
+
144
+ function periodCells(g: Granularity, year: number, locale: string): Cell[] {
145
+ if (g === 'year') { const b = Math.floor(year / 12) * 12; return Array.from({ length: 12 }, (_, i) => ({ v: `${b + i}`, text: `${b + i}`, name: `${b + i}` })) }
146
+ if (g === 'quarter') return [1, 2, 3, 4].map(q => ({ v: `${year}-Q${q}`, text: `Q${q}`, name: `Q${q} ${year}, ${fmt(toIso(year, q * 3 - 2, 1), { month: 'short' }, locale)} to ${fmt(toIso(year, q * 3, 1), { month: 'short' }, locale)}` }))
147
+ if (g === 'month') return monthCells(year, locale)
148
+ const out: Cell[] = []
149
+ let d = toIso(year, 1, 4) as string
150
+ d = addDays(d, -((dow(d) + 6) % 7))
151
+ for (let i = 0; i < 53; i++, d = addDays(d, 7)) {
152
+ const w = isoWeek(d)
153
+ if (w.year !== year) break
154
+ out.push({ v: `${w.year}-W${pad(w.week)}`, text: `W${w.week}`, name: `Week ${w.week}, ${fmt(d, { month: 'short', day: 'numeric' }, locale)} to ${fmt(addDays(d, 6), { month: 'short', day: 'numeric', year: 'numeric' }, locale)}` })
155
+ }
156
+ return out
157
+ }
158
+
159
+ /** PeriodPicker (04 §2): one trigger, one grid whose granularity switches. Values: `2026`, `2026-Q4`, `2026-10`, `2026-W44`. */
160
+ export function PeriodPicker({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, granularity, locale, today, name, presentation, label, disabled, ...rest }: PeriodPickerProps) {
161
+ const env = usePickerEnv(() => locale, () => today)
162
+ const v = bindable<string | null>(() => value, () => defaultValue, null, () => onValueChange)
163
+ const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
164
+ const g = derived(() => granularity ?? granularityOf(v.value))
165
+ const year = state(yearOf(untrack(() => v.value), untrack(() => env.today.value)))
166
+ const shown = derived(() => (v.value ? periodText(v.value, env.locale.value) : ''))
167
+ const span = derived(() => { const b = Math.floor(year.value / 12) * 12; return g.value === 'year' ? `${b}–${b + 11}` : String(year.value) })
168
+ const current = derived(() => {
169
+ const t = env.today.value
170
+ const [y, m] = ymd(t)
171
+ return { year: `${y}`, quarter: `${y}-Q${Math.ceil(m / 3)}`, month: t.slice(0, 7), week: weekOf(t) }[g.value]
172
+ })
173
+ let trigger: HTMLButtonElement | undefined
174
+ return (
175
+ <Composite
176
+ {...rest}
177
+ label={label}
178
+ disabled={disabled}
179
+ bound={v}
180
+ filled={!!v.value}
181
+ hold={pop.value}
182
+ control={() => <><PickButton el={n => (trigger = n)} text={shown.value} label={`${label}, ${shown.value || 'none'}`} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} /><Hidden name={name} value={() => v.value ?? ''} /></>}
183
+ end={
184
+ <Panel label="Choose a period" open={pop} trigger={() => trigger!} presentation={presentation}>
185
+ {() => <PeriodGrid wide title={span.value} cells={periodCells(g.value, year.value, env.locale.value)} columns={g.value === 'quarter' ? 4 : g.value === 'week' ? 7 : 3} value={v.value} current={current.value} prev="Previous" next="Next" onPage={d => (year.value += d * (g.value === 'year' ? 12 : 1))} onPick={(x, r) => { v.set(x, r); pop.set(false) }} />}
186
+ </Panel>
187
+ }
188
+ />
189
+ )
190
+ }
package/src/Nav.tsx ADDED
@@ -0,0 +1,20 @@
1
+ import { navIndicator } from '@weft-js/warp-core'
2
+ import { use } from '@weft-js/warp-core/weft'
3
+ import './styles'
4
+ import '@weft-js/warp-core/styles/components/nav.css'
5
+
6
+ /**
7
+ * Links with warp's sliding bar. It grows in from an item's edge, slides as the route or the pointer moves, and grows
8
+ * out through the far edge. Mark the current link with `aria-current` (`Link` from `@weft-js/start` does). `spy` follows
9
+ * the headings of a table of contents. The bar is on the current link in the server's HTML, so nothing moves on load.
10
+ */
11
+ export function Nav({ label, axis = 'x', spy, children, ...rest }: { label: string; axis?: 'x' | 'y'; spy?: boolean | undefined; children?: unknown; [attr: string]: unknown }) {
12
+ let el: HTMLElement | undefined
13
+ use(navIndicator, () => el, () => ({ axis, spy }))
14
+ return (
15
+ <nav {...rest} ref={n => (el = n)} data-scope="nav" data-axis={axis} aria-label={label}>
16
+ {children}
17
+ <span data-part="indicator" aria-hidden="true"></span>
18
+ </nav>
19
+ )
20
+ }
@@ -0,0 +1,78 @@
1
+ import { DownIcon } from '@weft-js/warp-icons'
2
+ import { derived, untrack } from '@weft-js/core'
3
+ import { bindable, type Bindable } from './bind'
4
+ import { Field, type FieldShell } from './Field'
5
+ import '@weft-js/warp-core/styles/inputs/text.css'
6
+
7
+ /** Props for NumberField. */
8
+ export type NumberFieldProps = FieldShell & Bindable<number | null> & { min?: number; max?: number; step?: number; name?: string; required?: boolean }
9
+
10
+ const PAGE = 10
11
+
12
+ /**
13
+ * A number in the field shell (03: `inputmode` over `type="number"`, which changes on a wheel, takes "e" and has its own
14
+ * locale bugs). The input stays real text, so editing, paste and the numeric keypad are the OS's; warp adds APG spinbutton
15
+ * keys (↑/↓, Page keys, Home/End with a bound) and two steppers. Text that isn't a number leaves the value alone until blur.
16
+ */
17
+ export function NumberField({ value, defaultValue, onValueChange, min, max, step = 1, name, disabled, readonly, ...rest }: NumberFieldProps) {
18
+ const v = bindable<number | null>(() => value, () => defaultValue, null, () => onValueChange)
19
+ const fit = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n))
20
+ const shown = derived(() => (v.value === null ? '' : String(v.value)))
21
+ let input: HTMLInputElement | undefined
22
+ const write = (n: number | null) => {
23
+ if (n !== untrack(() => v.value)) v.set(n, 'keyboard')
24
+ if (input) input.value = n === null ? '' : String(n)
25
+ }
26
+ const nudge = (by: number) => write(fit((untrack(() => v.value) ?? min ?? 0) + by))
27
+ const onKey = (e: KeyboardEvent) => {
28
+ if (disabled || readonly) return
29
+ const by = ({ ArrowUp: step, ArrowDown: -step, PageUp: step * PAGE, PageDown: -step * PAGE } as Record<string, number>)[e.key]
30
+ if (by) { e.preventDefault(); return nudge(by) }
31
+ if (e.key === 'Home' && min !== undefined) { e.preventDefault(); write(min) }
32
+ if (e.key === 'End' && max !== undefined) { e.preventDefault(); write(max) }
33
+ }
34
+ const onInput = (e: Event) => {
35
+ const text = (e.currentTarget as HTMLInputElement).value.trim()
36
+ const n = Number(text)
37
+ if (text === '') v.set(null, 'keyboard')
38
+ else if (Number.isFinite(n)) v.set(fit(n), 'keyboard')
39
+ }
40
+ const onBlur = () => { if (input) input.value = untrack(() => shown.value) }
41
+ const stepper = (label: string, by: number, off: boolean) => (
42
+ <button type="button" data-part="step" tabindex="-1" aria-label={label} disabled={disabled || readonly || off} onMouseDown={(e: Event) => e.preventDefault()} onClick={() => nudge(by)}><DownIcon /></button>
43
+ )
44
+ return (
45
+ <Field
46
+ {...rest}
47
+ disabled={disabled}
48
+ readonly={readonly}
49
+ filled={shown.value !== ''}
50
+ end={<span data-part="steps">{stepper('Increase', step, max !== undefined && v.value !== null && v.value >= max)}{stepper('Decrease', -step, min !== undefined && v.value !== null && v.value <= min)}</span>}
51
+ control={p => (
52
+ <input
53
+ ref={n => (input = n)}
54
+ data-part="input"
55
+ id={p.id}
56
+ role="spinbutton"
57
+ type="text"
58
+ inputmode={step % 1 || (min ?? 0) < 0 ? 'decimal' : 'numeric'}
59
+ autocomplete="off"
60
+ name={name}
61
+ placeholder=" "
62
+ required={p.rest.required}
63
+ disabled={disabled}
64
+ readonly={readonly}
65
+ value={shown.value}
66
+ aria-valuenow={v.value ?? undefined}
67
+ aria-valuemin={min}
68
+ aria-valuemax={max}
69
+ aria-describedby={p.describedby}
70
+ aria-invalid={p.invalid ? 'true' : undefined}
71
+ onInput={onInput}
72
+ onKeyDown={onKey}
73
+ onBlur={onBlur}
74
+ />
75
+ )}
76
+ />
77
+ )
78
+ }
@@ -0,0 +1,122 @@
1
+ import { infiniteScroll, pageList, uid } from '@weft-js/warp-core'
2
+ import { derived, flush, untrack, type State } from '@weft-js/core'
3
+ import { Button } from './Button'
4
+ import { bindable } from './bind'
5
+ import { CompactSelect } from './Select'
6
+ import { use } from '@weft-js/warp-core/weft'
7
+ import './styles'
8
+ import '@weft-js/warp-core/styles/data/tokens.css'
9
+ import '@weft-js/warp-core/styles/data/base.css'
10
+ import '@weft-js/warp-core/styles/data/pagination.css'
11
+ import '@weft-js/warp-core/styles/data/load-more.css'
12
+
13
+ /** Props for PaginationBar. */
14
+ export type PaginationBarProps = {
15
+ /** The nav landmark's name; give each bar on a page its own. */
16
+ label?: string
17
+ /** offset (default): numbered pages. cursor: Previous and Next, for a list with no page count. */
18
+ mode?: 'offset' | 'cursor'
19
+ /** 1-based. */
20
+ page?: State<number> | number
21
+ /** The page it opens on when `page` isn't given. */
22
+ defaultPage?: number
23
+ /** Hears the new page (offset) or page index (cursor) on every move. */
24
+ onPageChange?: (page: number) => void
25
+ /** offset: how many rows there are. */
26
+ total?: number
27
+ /** Rows per page: a State to share it, or a number. 25 without it. */
28
+ size?: State<number> | number
29
+ /** The choices in the rows-per-page menu. */
30
+ sizes?: number[]
31
+ /** Hears a new rows-per-page choice; the bar goes back to page 1. */
32
+ onSizeChange?: (size: number) => void
33
+ /** cursor: there is a page after this one, so Next is on. */
34
+ hasMore?: boolean
35
+ /** cursor: there is a page before this one, so Previous is on. */
36
+ hasPrev?: boolean
37
+ /** cursor: the 0-based page showing, which `onPageChange` steps from. */
38
+ pageIndex?: number
39
+ /** cursor: how many rows there are in all, when known ("Showing 50 of 1,200"). */
40
+ supply?: number
41
+ /** cursor: how many rows have loaded so far; `(pageIndex + 1) * size` without it. */
42
+ totalFetched?: number
43
+ /** The locale its numbers are written in. en-US without it. */
44
+ locale?: string
45
+ }
46
+
47
+ /**
48
+ * The pagination bar (06 §8; data.html:1893-1913). Offset mode: rows per page, the range, ‹ 1 … 4 5 6 … 100 › and Go to;
49
+ * under 520px of its own width only ‹ current › remain and the range says the rest. Cursor mode: Previous and Next,
50
+ * and how many are showing. A press keeps focus on the current page, whichever button that now is.
51
+ */
52
+ export function PaginationBar({ label = 'Pagination', mode = 'offset', page, defaultPage, onPageChange, total = 0, size, sizes = [10, 25, 50, 100], onSizeChange, hasMore, hasPrev, pageIndex = 0, supply, totalFetched, locale }: PaginationBarProps) {
53
+ const at = bindable<number>(() => page, () => defaultPage, 1, () => p => onPageChange?.(p))
54
+ const per = bindable<number>(() => size, () => undefined, 25, () => s => onSizeChange?.(s))
55
+ const pages = derived(() => Math.max(1, Math.ceil(total / per.value)))
56
+ const current = derived(() => Math.min(pages.value, Math.max(1, at.value)))
57
+ const fmt = (n: number) => n.toLocaleString(locale ?? 'en-US')
58
+ let nav: HTMLElement | undefined
59
+ const go = (p: number) => {
60
+ at.set(Math.min(pages.value, Math.max(1, p)))
61
+ flush()
62
+ nav?.querySelector<HTMLElement>('[aria-current="page"]')?.focus()
63
+ }
64
+ const jump = (e: KeyboardEvent) => {
65
+ if (e.key !== 'Enter') return
66
+ e.preventDefault()
67
+ const box = e.currentTarget as HTMLInputElement
68
+ const n = parseInt(box.value, 10)
69
+ if (n) at.set(Math.min(pages.value, Math.max(1, n)))
70
+ box.value = ''
71
+ }
72
+ if (untrack(() => mode) === 'cursor') {
73
+ return (
74
+ <nav data-scope="pagination" aria-label={label}>
75
+ <span>{`Showing ${fmt(totalFetched ?? (pageIndex + 1) * per.value)}${supply !== undefined ? ` of ${fmt(supply)}` : ''}${hasMore ? ' · more available' : ''}`}</span>
76
+ <span data-part="spacer"></span>
77
+ <ol>
78
+ <li><Button emphasis="outline" tone="neutral" size="sm" disabled={!hasPrev} onClick={() => onPageChange?.(pageIndex - 1)}>Previous</Button></li>
79
+ <li><Button emphasis="outline" tone="neutral" size="sm" disabled={!hasMore} onClick={() => onPageChange?.(pageIndex + 1)}>Next</Button></li>
80
+ </ol>
81
+ </nav>
82
+ )
83
+ }
84
+ const id = uid('pg')
85
+ const first = derived(() => (total ? (current.value - 1) * per.value + 1 : 0))
86
+ // "Go to" and its box are one item, so the bar never wraps between them: the pair moves to the next line together
87
+ return (
88
+ <nav ref={n => (nav = n)} data-scope="pagination" aria-label={label}>
89
+ <span data-part="field">
90
+ <span aria-hidden="true">Rows<span data-part="long"> per page</span></span>
91
+ <CompactSelect label="Rows per page" value={String(per.value)} options={sizes.map(s => ({ value: String(s), label: String(s) }))} onValueChange={v => { per.set(+v); at.set(1) }} />
92
+ </span>
93
+ <span>{`${fmt(first.value)}–${fmt(Math.min(total, current.value * per.value))} of ${fmt(total)}`}</span>
94
+ <span data-part="spacer"></span>
95
+ <ol>
96
+ <li><button type="button" data-part="page" data-edge="" disabled={current.value === 1} aria-label="Previous page" onClick={() => go(current.value - 1)}>‹</button></li>
97
+ {pageList(current.value, pages.value).map((p, i) => <li key={p === 'gap' ? `gap${i}` : p} data-part={p === 'gap' ? 'gap' : undefined} aria-hidden={p === 'gap' ? 'true' : undefined}>{p === 'gap' ? '…' : <button type="button" data-part="page" aria-current={p === current.value ? 'page' : undefined} aria-label={`Page ${p}`} onClick={() => go(p)}>{p}</button>}</li>)}
98
+ <li><button type="button" data-part="page" data-edge="" disabled={current.value === pages.value} aria-label="Next page" onClick={() => go(current.value + 1)}>›</button></li>
99
+ </ol>
100
+ <span data-part="field" data-goto="">
101
+ <label for={`${id}-go`}>Go to<span data-part="sr-only"> page</span></label>
102
+ <input id={`${id}-go`} data-part="jump" inputmode="numeric" placeholder={String(pages.value)} onKeyDown={jump} />
103
+ </span>
104
+ </nav>
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Load more (06 §8): a sentinel watched with a 120px margin, and a button that is always there as the fallback. Set
110
+ * `loading` while a page is on its way (the list shows its skeletons and `aria-busy`); `done` swaps in the end message.
111
+ */
112
+ export function LoadMore({ onLoad, loading, done, message, auto = true, root }: { onLoad: () => void; loading?: boolean | undefined; done?: boolean; message?: unknown; auto?: boolean; root?: (() => HTMLElement | null) | undefined }) {
113
+ let el: HTMLDivElement | undefined
114
+ const reach = () => { if (auto && !loading && !done) onLoad() }
115
+ use(infiniteScroll, () => el, () => ({ onReach: reach, root: root?.() }))
116
+ return (
117
+ <div ref={n => (el = n)} data-scope="load-more">
118
+ <span aria-live="polite">{loading ? 'Loading…' : message}</span>
119
+ {!done && <Button emphasis="outline" tone="neutral" disabled={!!loading} onClick={onLoad}>Load more</Button>}
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,63 @@
1
+ import { CardIcon } from '@weft-js/warp-icons'
2
+ import { cardBrand, cardMask } from '@weft-js/warp-core'
3
+ import { derived, state, untrack } from '@weft-js/core'
4
+ import { Field, type FieldShell } from './Field'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import '@weft-js/warp-core/styles/rich/payment.css'
7
+
8
+ /** The card field a payment provider mounts, as far as warp talks to it. */
9
+ export interface ProviderField { on(e: 'focus' | 'blur' | 'change', f: (ev: { complete?: boolean | undefined; error?: string | undefined }) => void): void; destroy(): void }
10
+ /** The field styles warp hands a provider, since its iframe can't read ours. */
11
+ export interface ProviderAppearance { fontFamily: string; fontSize: string; color: string; errorColor: string; borderRadius: string; borderColor: string }
12
+ /** Props for PaymentCardInput. */
13
+ export type PaymentCardInputProps = FieldShell & { mount: (el: HTMLElement, appearance: ProviderAppearance) => ProviderField }
14
+
15
+ /**
16
+ * PaymentCardInput (04 §5): the field shell around the provider's hosted field, driven by its events, with warp's
17
+ * tokens passed into its appearance API. warp never renders an input for a raw card number.
18
+ */
19
+ export function PaymentCardInput({ mount, label, hint, error, ...rest }: PaymentCardInputProps) {
20
+ const focused = state(false)
21
+ const filled = state(false)
22
+ const failed = state<string | undefined>(undefined)
23
+ let host: HTMLDivElement | undefined
24
+ const attach = (el: HTMLDivElement) => {
25
+ const css = getComputedStyle(el)
26
+ const token = (t: string) => css.getPropertyValue(`--warp-${t}`).trim()
27
+ const field = untrack(() => mount)(el, { fontFamily: css.fontFamily, fontSize: token('text-control-size'), color: token('color-text'), errorColor: token('color-critical-text'), borderRadius: token('radius-control'), borderColor: token('color-border-control') })
28
+ field.on('focus', () => (focused.value = true))
29
+ field.on('blur', () => (focused.value = false))
30
+ field.on('change', ev => { filled.value = ev.complete ?? filled.value; failed.value = ev.error })
31
+ return { update() {}, destroy: () => field.destroy() }
32
+ }
33
+ use(attach, () => host)
34
+ return (
35
+ <Field
36
+ icon={CardIcon}
37
+ {...rest}
38
+ group
39
+ label={label}
40
+ hint={hint ?? 'Secured by your payment provider. warp never sees the number.'}
41
+ error={error ?? failed.value}
42
+ filled={filled.value}
43
+ hold={focused.value}
44
+ control={() => <div ref={n => (host = n)} data-part="provider" role="group" aria-label={`${label}, hosted by the payment provider`}></div>}
45
+ />
46
+ )
47
+ }
48
+
49
+ /** Props for CardNumberText. */
50
+ export type CardNumberTextProps = { last4: string; bin?: string; brand?: string; expiry?: { month: number; year: number } }
51
+
52
+ /** CardNumberText (04 §5): the display of a saved card, `•••• 4242`, with the brand from the BIN and expiry as MM/YY. */
53
+ export function CardNumberText({ last4, bin, brand, expiry }: CardNumberTextProps) {
54
+ const name = derived(() => brand ?? cardBrand(bin ?? ''))
55
+ const exp = derived(() => (expiry ? `${String(expiry.month).padStart(2, '0')}/${String(expiry.year % 100).padStart(2, '0')}` : ''))
56
+ // a span can't be named (aria-prohibited-attr): the mask is hidden from screen readers and the words are said instead
57
+ return (
58
+ <span data-card-text="">
59
+ <span aria-hidden="true">{name.value ? `${name.value} ` : ''}{cardMask(last4, name.value)}{exp.value ? ` · ${exp.value}` : ''}</span>
60
+ <span data-part="sr-only">{`${name.value || 'Card'} ending ${last4}`}{exp.value ? `, expires ${exp.value}` : ''}</span>
61
+ </span>
62
+ )
63
+ }