@weft-js/warp 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,186 @@
1
+ import { CheckIcon, TrashIcon, UsersIcon } from '@weft-js/warp-icons'
2
+ import { isLastOwner, plural, uid } from '@weft-js/warp-core'
3
+ import { derived, state, type State } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { Button } from '../Button'
6
+ import { Checkbox } from '../Choice'
7
+ import { Avatar } from '../parts'
8
+ import { Table, type Column } from '../Table'
9
+ import { toast } from '../Toast'
10
+ import { ConfirmDialog } from './Danger'
11
+ import { InviteDialog } from './InviteDialog'
12
+ import { PlainTable } from './PlainTable'
13
+ import { InlineSelect, read } from './atoms'
14
+ import '../styles'
15
+ import '@weft-js/warp-core/styles/flows/tokens.css'
16
+ import '@weft-js/warp-core/styles/flows/base.css'
17
+ import '@weft-js/warp-core/styles/rich/helpers.css'
18
+ import '@weft-js/warp-core/styles/layout/tokens.css'
19
+ import '@weft-js/warp-core/styles/layout/row.css'
20
+ import '@weft-js/warp-core/styles/layout/stack.css'
21
+ import '@weft-js/warp-core/styles/flows/meter.css'
22
+ import '@weft-js/warp-core/styles/flows/roles-matrix.css'
23
+
24
+ /** A role a member can hold. */
25
+ export type Role = { value: string; label: string; /** Can reach this page; demoting yourself below it warns. */ manages?: boolean }
26
+ /** One member of the team. */
27
+ export type Member = { id: string; name: string; email: string; role: string; lastActive?: string | undefined }
28
+ /** An invite still waiting for an answer. */
29
+ export type Invite = { id: string; email: string; role: string; sent: string }
30
+
31
+ /** "7 of 10 seats used" over a rail that fills with the count; pending invites count (09 §4.2). */
32
+ export function SeatsMeter({ used, total }: { used: number; total: number }) {
33
+ const left = derived(() => Math.min(100, (used / total) * 100))
34
+ return (
35
+ <div data-scope="meter" data-tone={used >= total ? 'caution' : undefined}>
36
+ <span data-part="note">{used} of {total} seats used</span>
37
+ <span data-part="rail" data-on="" aria-hidden="true" style={`--v:${left.value}%`}><span data-part="rail-fill"></span></span>
38
+ </div>
39
+ )
40
+ }
41
+
42
+ /** Props for MembersTable. */
43
+ export interface MembersTableProps {
44
+ /** Everyone in the workspace, one row each. */
45
+ members: readonly Member[] | State<readonly Member[]>
46
+ /** Pending invitations, listed under the members. */
47
+ invites?: readonly Invite[] | State<readonly Invite[]>
48
+ /** The signed-in member's id. */
49
+ me: string
50
+ roles: Role[]
51
+ owner?: string
52
+ seats: number
53
+ /** Called with the member and the role picked for them. */
54
+ onRoleChange: (member: Member, role: string) => void
55
+ onRemove: (members: Member[]) => Promise<void> | void
56
+ /** Undo for a removal. */
57
+ onRestore?: (members: Member[]) => void
58
+ onInvite: (emails: string[], role: string) => Promise<void> | void
59
+ /** Called with the invite when Resend is pressed. */
60
+ onResend?: (invite: Invite) => void
61
+ onRevoke?: (invite: Invite) => void
62
+ }
63
+
64
+ /**
65
+ * Members (09 §4.2): DataTable with an inline role select, a bulk bar, and pending invites below. Role changes apply at
66
+ * once with Undo; removals ask, with one Undo for the batch. The last owner can't be demoted or removed, and the
67
+ * select says why; demoting yourself warns first.
68
+ */
69
+ export function MembersTable({ members, invites, me, roles, owner = 'owner', seats, onRoleChange, onRemove, onRestore, onInvite, onResend, onRevoke }: MembersTableProps) {
70
+ const inviting = state(false)
71
+ const confirming = state(false)
72
+ const demoting = state(false)
73
+ const leaving = state<Member[]>([])
74
+ const selected = state<Record<string, boolean>>({})
75
+ let demote: { m: Member; role: string } | undefined
76
+ const people = derived(() => read(members))
77
+ const pending = derived(() => read(invites ?? []))
78
+ const used = derived(() => people.value.length + pending.value.length)
79
+ const label = (v: string) => roles.find(r => r.value === v)?.label ?? v
80
+ const locked = (m: Member) => isLastOwner(people.value as Member[], m, owner)
81
+ const assign = (m: Member, role: string) => {
82
+ const prev = m.role
83
+ onRoleChange(m, role)
84
+ toast.show({ title: `${m.name} is now ${label(role)}`, action: { label: 'Undo', onClick: () => onRoleChange({ ...m, role }, prev) } })
85
+ }
86
+ const change = (m: Member, role: string) => {
87
+ if (locked(m)) {
88
+ return void toast.show({ title: m.id === me ? 'You’re the only owner' : `${m.name} is the only owner`, description: 'Make someone else owner first, then change this role.', tone: 'critical' })
89
+ }
90
+ if (m.id === me && roles.find(r => r.value === m.role)?.manages && !roles.find(r => r.value === role)?.manages) {
91
+ demote = { m, role }
92
+ demoting.value = true
93
+ return
94
+ }
95
+ assign(m, role)
96
+ }
97
+ const ask = (list: Member[]) => { leaving.value = list; confirming.value = true }
98
+ const remove = async () => {
99
+ const gone = leaving.value
100
+ await onRemove(gone)
101
+ selected.value = {}
102
+ toast.show({ title: gone.length > 1 ? `${gone.length} members removed` : `${gone[0]!.name} removed`, action: onRestore && { label: 'Undo', onClick: () => onRestore(gone) } })
103
+ }
104
+ const removable = (keys: string[]) => people.value.filter(m => keys.includes(m.id) && m.id !== me && !locked(m))
105
+ const count = derived(() => leaving.value.length)
106
+ const columns: Column<Member>[] = [
107
+ {
108
+ id: 'name', header: 'Name', rowHeader: true, card: 'title', sort: true,
109
+ cell: m => <span data-scope="row" data-wrap="none"><Avatar name={m.name} size="sm" /><span><b>{m.name}</b>{m.id === me && <>{' '}<Badge>You</Badge></>}<br /><span class="muted">{m.email}</span></span></span>,
110
+ },
111
+ { id: 'role', header: 'Role', cell: m => { const held = derived(() => locked(m)); return <RoleCell member={m} label={`Role for ${m.name}`} roles={roles} locked={held.value} onChange={r => change(m, r)} /> } },
112
+ { id: 'lastActive', header: 'Last active', card: 'hide' },
113
+ {
114
+ id: 'menu', header: 'Actions', value: () => '',
115
+ cell: m => (m.id === me || locked(m) ? '' : <Button emphasis="ghost" tone="critical" size="sm" data-icon-only="" label={`Remove ${m.name}`} onClick={() => ask([m])}><TrashIcon /></Button>),
116
+ },
117
+ ]
118
+ const inviteColumns: Column<Invite>[] = [
119
+ { id: 'email', header: 'Email', rowHeader: true, card: 'title', cell: v => <span data-scope="row" data-wrap="none"><Avatar name={v.email} size="sm" /><span><b>{v.email}</b><br /><span class="muted">Invite pending, sent {v.sent}</span></span></span> },
120
+ { id: 'role', header: 'Role', cell: v => <Badge>{label(v.role)}</Badge> },
121
+ {
122
+ id: 'actions', header: 'Actions', value: () => '',
123
+ cell: v => <span data-scope="row"><Button emphasis="link" aria-label={`Resend invite to ${v.email}`} onClick={() => { onResend?.(v); toast.show({ title: `Invite resent to ${v.email}`, tone: 'positive' }) }}>Resend</Button><Button emphasis="link" tone="critical" aria-label={`Revoke invite to ${v.email}`} onClick={() => onRevoke?.(v)}>Revoke</Button></span>,
124
+ },
125
+ ]
126
+ return (
127
+ <div data-scope="stack">
128
+ <div data-scope="row" data-justify="between">
129
+ <SeatsMeter used={used.value} total={seats} />
130
+ <Button onClick={() => (inviting.value = true)}>Invite people</Button>
131
+ </div>
132
+ <Table
133
+ caption="Workspace members"
134
+ captionHidden
135
+ rows={people}
136
+ columns={columns}
137
+ picker={false}
138
+ heading={3}
139
+ selection={selected}
140
+ bulk={keys => {
141
+ const list = removable(keys)
142
+ return <Button emphasis="soft" tone="critical" size="sm" disabled={!list.length} onClick={() => ask(list)}>{list.length ? `Remove ${plural(list.length, 'member')}` : 'Can’t remove these'}</Button>
143
+ }}
144
+ />
145
+ {pending.value.length > 0 && <Table caption="Pending invites" rows={pending} columns={inviteColumns} picker={false} heading={3} />}
146
+ <InviteDialog open={inviting} roles={roles.filter(r => r.value !== owner)} seatsLeft={seats - used.value} onSend={onInvite} defaultRole="member" />
147
+ <ConfirmDialog open={confirming} glyph={UsersIcon} tone="critical" title={count.value > 1 ? `Remove ${count.value} members?` : `Remove ${leaving.value[0]?.name}?`} description={`${leaving.value.map(m => m.name).join(', ')} ${count.value > 1 ? 'lose' : 'loses'} access right away. Their work stays.`} action={count.value > 1 ? `Remove ${count.value} members` : 'Remove member'} onConfirm={remove} />
148
+ <ConfirmDialog open={demoting} tone="critical" title="Change your own role?" description="You’ll lose access to this page, and someone else will have to give it back." action="Change my role" onConfirm={() => { const d = demote!; assign(d.m, d.role) }} />
149
+ </div>
150
+ )
151
+ }
152
+
153
+ function RoleCell({ member, label, roles, locked, onChange }: { member: Member; label: string; roles: Role[]; locked: boolean; onChange: (role: string) => void }) {
154
+ const id = uid('role')
155
+ return (
156
+ <>
157
+ <InlineSelect label={label} value={member.role} options={roles} describedby={locked ? `${id}-why` : undefined} onValueChange={onChange} />
158
+ {locked && <span data-part="sr-only" id={`${id}-why`}>This is the only owner. Make someone else owner first.</span>}
159
+ </>
160
+ )
161
+ }
162
+
163
+ /** One row of the permissions matrix, with the roles that have it. */
164
+ export type Permission = { id: string; label: string; roles: string[] }
165
+
166
+ /** What each role can do (09 §4.2): rows are permissions, columns are roles. Read-only unless `onToggle` is given. */
167
+ export function RolesMatrix({ roles, permissions, onToggle }: { roles: Role[]; permissions: Permission[]; onToggle?: (permission: Permission, role: string, on: boolean) => void }) {
168
+ return (
169
+ <PlainTable label="Roles matrix" scope="roles-matrix" caption="Permissions by role" head={<tr><th scope="col">Permission</th>{roles.map(r => <th key={r.value} scope="col">{r.label}</th>)}</tr>}>
170
+ {permissions.map(p => (
171
+ <tr key={p.id}>
172
+ <th scope="row">{p.label}</th>
173
+ {roles.map(r => (
174
+ <td key={r.value}>
175
+ {onToggle
176
+ ? <Checkbox checked={p.roles.includes(r.value)} aria-label={`${r.label}: ${p.label}`} onChange={(e: Event) => onToggle(p, r.value, (e.currentTarget as HTMLInputElement).checked)} />
177
+ : p.roles.includes(r.value)
178
+ ? <span data-part="yes"><CheckIcon /><span data-part="sr-only">Yes</span></span>
179
+ : <><span data-part="no" aria-hidden="true">–</span><span data-part="sr-only">No</span></>}
180
+ </td>
181
+ ))}
182
+ </tr>
183
+ ))}
184
+ </PlainTable>
185
+ )
186
+ }
@@ -0,0 +1,63 @@
1
+ import { announce } from '@weft-js/warp-core'
2
+ import { derived, onMount } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import '../styles'
5
+ import '@weft-js/warp-core/styles/flows/tokens.css'
6
+ import '@weft-js/warp-core/styles/flows/base.css'
7
+ import '@weft-js/warp-core/styles/rich/helpers.css'
8
+ import '@weft-js/warp-core/styles/layout/tokens.css'
9
+ import '@weft-js/warp-core/styles/layout/row.css'
10
+ import '@weft-js/warp-core/styles/layout/stack.css'
11
+ import '@weft-js/warp-core/styles/flows/meter.css'
12
+ import '@weft-js/warp-core/styles/flows/confirmation.css'
13
+ import '@weft-js/warp-core/styles/flows/tracker.css'
14
+
15
+ /** How far an order has got. */
16
+ export type OrderStage = 'placed' | 'packed' | 'shipped' | 'delivered'
17
+ const STAGES: { id: OrderStage; title: string }[] = [{ id: 'placed', title: 'Placed' }, { id: 'packed', title: 'Packed' }, { id: 'shipped', title: 'Shipped' }, { id: 'delivered', title: 'Delivered' }]
18
+
19
+ /** The order tracker (09 §6.3): ordered → packed → shipped → delivered, the current stop filled. */
20
+ export function OrderTracker({ stage = 'placed' }: { stage?: OrderStage | undefined }) {
21
+ const at = derived(() => STAGES.findIndex(s => s.id === stage))
22
+ return (
23
+ <ol data-scope="tracker" aria-label="Order status">
24
+ {STAGES.flatMap((s, i) => [
25
+ ...(i ? [<li key={`r-${s.id}`} aria-hidden="true"><span data-part="rail" data-on={i <= at.value ? '' : undefined} style={`--v:100%;--dl:${i * 300}ms`}><span data-part="rail-fill"></span></span></li>] : []),
26
+ <li key={s.id}><span data-part="stop" data-on={i <= at.value ? '' : undefined} aria-current={i === at.value ? 'step' : undefined} style={`--dl:${i * 300}ms`}><i></i>{s.title}</span></li>,
27
+ ])}
28
+ </ol>
29
+ )
30
+ }
31
+
32
+ /** Props for OrderConfirmation. */
33
+ export interface OrderConfirmationProps {
34
+ orderId: string
35
+ email?: string
36
+ note?: string
37
+ stage?: OrderStage | undefined
38
+ summary?: unknown
39
+ titleId?: string
40
+ onTrack?: () => void
41
+ onContinue?: (() => void) | undefined
42
+ }
43
+
44
+ /** The confirmation (09 §6.3): the mark strokes in, the order number, where the receipt went, the tracker. Announced politely. */
45
+ export function OrderConfirmation({ orderId, email, note, stage, summary, titleId, onTrack, onContinue }: OrderConfirmationProps) {
46
+ let root: HTMLElement | undefined
47
+ onMount(() => { queueMicrotask(() => { if (root) announce(root.ownerDocument, `Order ${orderId} confirmed`) }) })
48
+ return (
49
+ <div ref={n => (root = n)} data-scope="confirmation">
50
+ <span data-part="mark" data-tone="positive"><svg viewBox="0 0 24 24" aria-hidden="true"><path pathLength="1" data-draw="" d="m5 12.5 4.5 4.5L19 7.5" /></svg></span>
51
+ <div>
52
+ <h2 data-part="step-title" id={titleId} tabindex="-1">Order #{orderId} confirmed</h2>
53
+ <span class="muted">{email ? `A receipt is on its way to ${email}.` : 'A receipt is on its way.'} {note}</span>
54
+ </div>
55
+ <OrderTracker stage={stage} />
56
+ {summary}
57
+ <div data-scope="row">
58
+ {onTrack && <Button onClick={onTrack}>Track order</Button>}
59
+ {onContinue && <Button emphasis="frame" onClick={onContinue}>Continue shopping</Button>}
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,22 @@
1
+ import '../styles'
2
+ import '@weft-js/warp-core/styles/data/tokens.css'
3
+ import '@weft-js/warp-core/styles/data/base.css'
4
+ import '@weft-js/warp-core/styles/data/table.css'
5
+
6
+ /**
7
+ * The look of 06's table for a fixed matrix that isn't a DataTable (a roles matrix, CSV mapping and preview): no sort,
8
+ * no cards. It scrolls inline on a phone (09 §8), and the viewport takes focus so a keyboard can scroll it.
9
+ */
10
+ export function PlainTable({ label, scope, caption, head, children }: { label: string; scope: string; caption: string; head: unknown; children: unknown }) {
11
+ return (
12
+ <div data-scope="table" data-phone="columns" data-state="ready">
13
+ <div data-part="viewport" role="region" aria-label={label} tabindex="0">
14
+ <table data-scope={scope}>
15
+ <caption data-part="sr-only">{caption}</caption>
16
+ <thead>{head}</thead>
17
+ <tbody>{children}</tbody>
18
+ </table>
19
+ </div>
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,167 @@
1
+ import { meterParts, money, planPrice, prorate, uid, type BillingInterval, type Proration } from '@weft-js/warp-core'
2
+ import { derived, state, type State, untrack } from '@weft-js/core'
3
+ import { Alert } from '../Alert'
4
+ import { Badge } from '../Badge'
5
+ import { Button } from '../Button'
6
+ import { Modal } from '../Modal'
7
+ import { Segmented } from '../Choice'
8
+ import { toast } from '../Toast'
9
+ import { Sweep, latch } from './atoms'
10
+ import '../styles'
11
+ import '@weft-js/warp-core/styles/flows/tokens.css'
12
+ import '@weft-js/warp-core/styles/flows/base.css'
13
+ import '@weft-js/warp-core/styles/rich/helpers.css'
14
+ import '@weft-js/warp-core/styles/layout/tokens.css'
15
+ import '@weft-js/warp-core/styles/layout/row.css'
16
+ import '@weft-js/warp-core/styles/layout/stack.css'
17
+ import '@weft-js/warp-core/styles/flows/plans.css'
18
+ import '@weft-js/warp-core/styles/flows/plan-card.css'
19
+ import '@weft-js/warp-core/styles/flows/lines.css'
20
+ import '@weft-js/warp-core/styles/flows/usage.css'
21
+ import '@weft-js/warp-core/styles/flows/meter.css'
22
+ import '@weft-js/warp-core/styles/flows/confirm.css'
23
+
24
+ /** A plan on offer. */
25
+ export type BillingPlan = { id: string; name: string; /** Per seat, per month. */ price: number; description?: string | undefined; features: string[]; limits?: Record<string, number> }
26
+
27
+ /** Props for PlanPicker. */
28
+ export interface PlanPickerProps {
29
+ /** The plans to compare, one card each. */
30
+ plans: BillingPlan[]
31
+ current: string
32
+ /** The checked card; a usage meter can preselect the next tier through it. */
33
+ selected?: State<string>
34
+ interval?: State<BillingInterval>
35
+ seats: number
36
+ daysLeft: number
37
+ periodDays?: number
38
+ /** The ISO 4217 code prices are shown in, such as `EUR`. */
39
+ currency?: string
40
+ locale?: string
41
+ /** The server's proration. Default: `prorate` from the numbers above. */
42
+ preview?: (plan: BillingPlan, interval: BillingInterval) => Promise<Proration> | Proration
43
+ /** What stops working on a downgrade, listed in the dialog. */
44
+ losses?: (plan: BillingPlan) => string[]
45
+ /** Resolve with a message (card declined, seat limit) to keep the dialog with an Alert. */
46
+ onChange: (plan: BillingPlan, interval: BillingInterval) => Promise<string | void> | string | void
47
+ }
48
+
49
+ /**
50
+ * The plan picker (09 §4.3): radio cards in a radiogroup, the checked card's sweep pinned, Monthly/Annual above. Arrow
51
+ * keys only compare; the primary opens the proration dialog: line items, the new total, when it applies, Confirm.
52
+ */
53
+ export function PlanPicker({ plans, current, selected, interval, seats, daysLeft, periodDays = 30, currency, locale, preview, losses, onChange }: PlanPickerProps) {
54
+ const group = uid('plan')
55
+ const picked = untrack(() => selected) ?? state(untrack(() => current))
56
+ const cycle = untrack(() => interval) ?? state<BillingInterval>('month')
57
+ const open = state(false)
58
+ const armed = latch(open)
59
+ const quote = state<Proration | undefined>(undefined)
60
+ const busy = state(false)
61
+ const problem = state('')
62
+ const find = (id: string) => plans.find(p => p.id === id)!
63
+ const cost = (n: number) => money(n, currency, locale)
64
+ const now = derived(() => find(current))
65
+ const next = derived(() => find(picked.value))
66
+ const up = derived(() => next.value.price > now.value.price)
67
+ const same = derived(() => picked.value === current)
68
+ const per = (p: BillingPlan) => (!p.price ? 'free forever' : cycle.value === 'year' ? 'per seat / month, billed yearly' : 'per seat / month')
69
+ const pitch = derived(() => {
70
+ if (same.value) return `You’re on ${now.value.name}. Pick a plan to compare.`
71
+ return up.value ? `${next.value.name} adds more. You pay the difference for the rest of this period.` : `${next.value.name} starts at the end of this period.`
72
+ })
73
+ const review = async () => {
74
+ const p = next.value
75
+ const c = cycle.value
76
+ problem.value = ''
77
+ quote.value = preview ? await preview(p, c) : prorate({ from: { name: now.value.name, price: planPrice(now.value.price, c) }, to: { name: p.name, price: planPrice(p.price, c) }, seats, daysLeft, periodDays })
78
+ open.value = true
79
+ }
80
+ const confirm = async () => {
81
+ if (busy.value) return
82
+ busy.value = true
83
+ try {
84
+ const msg = await onChange(next.value, cycle.value)
85
+ if (msg) problem.value = msg
86
+ else {
87
+ open.value = false
88
+ toast.show({ title: `You’re on ${next.value.name}`, description: up.value ? 'New limits are live. A receipt is on its way.' : 'The change applies at the end of this period.', tone: 'positive' })
89
+ }
90
+ } catch {
91
+ toast.show({ title: 'The plan didn’t change', description: 'Try again in a moment.', tone: 'critical', action: { label: 'Retry', onClick: confirm } })
92
+ } finally { busy.value = false }
93
+ }
94
+ const lost = derived(() => (up.value || same.value ? [] : losses?.(next.value) ?? []))
95
+ return (
96
+ <div data-scope="stack">
97
+ <div data-scope="row"><Segmented label="Billing period" value={cycle.value} onChange={v => (cycle.value = v)} size="sm" options={[{ value: 'month', label: 'Monthly' }, { value: 'year', label: 'Annual · save 20%' }]} /></div>
98
+ <div data-scope="plans">
99
+ <div data-part="grid" role="radiogroup" aria-label="Plan">
100
+ {plans.map(p => (
101
+ <label key={p.id} data-scope="plan-card" data-sweep="">
102
+ <Sweep />
103
+ <input type="radio" name={group} value={p.id} checked={p.id === picked.value} onChange={() => (picked.value = p.id)} />
104
+ <span data-part="name"><span class="row"><span data-part="radio" aria-hidden="true"></span>{p.name}</span>{p.id === current && <Badge tone="neutral">Current</Badge>}</span>
105
+ <span data-part="price"><b>{cost(planPrice(p.price, cycle.value))}</b><span>{per(p)}</span></span>
106
+ {p.description && <span class="muted">{p.description}</span>}
107
+ <ul>{p.features.map(f => <li key={f}>{f}</li>)}</ul>
108
+ </label>
109
+ ))}
110
+ </div>
111
+ <div data-scope="row" data-justify="between">
112
+ <span class="muted" role="status">{pitch.value}</span>
113
+ {same.value && <Button emphasis="soft" tone="neutral" disabled>Current plan</Button>}
114
+ {!same.value && up.value && <Button onClick={review}>Upgrade to {next.value.name}</Button>}
115
+ {!same.value && !up.value && <Button emphasis="frame" onClick={review}>Switch to {next.value.name}</Button>}
116
+ </div>
117
+ </div>
118
+ {armed.value && (
119
+ <Modal open={open} size="sm" title={`${up.value ? 'Upgrade' : 'Switch'} to ${next.value.name}`} description={up.value ? 'Your new limits apply as soon as you confirm.' : 'This applies at the end of this period.'} footer={<><Button emphasis="frame" data-close="">Cancel</Button><Button autofocus loading={busy.value} onClick={confirm}>{up.value ? 'Confirm upgrade' : 'Confirm switch'}</Button></>}>
120
+ <div data-scope="stack">
121
+ {problem.value && <Alert tone="critical" live title="The plan didn’t change">{problem.value}</Alert>}
122
+ <dl data-scope="lines">
123
+ {(quote.value?.lines ?? []).map(l => <div key={l.label}><dt>{l.label}</dt><dd>{l.amount < 0 ? `−${cost(-l.amount)}` : cost(l.amount)}</dd></div>)}
124
+ <div data-part="total"><dt>{quote.value?.due === 'today' ? 'Due today' : 'Due next period'}</dt><dd>{cost(quote.value?.total ?? 0)}</dd></div>
125
+ </dl>
126
+ {lost.value.length > 0 && <div data-scope="confirm"><span class="muted">These stop working:</span><ul>{lost.value.map(t => <li key={t}>{t}</li>)}</ul></div>}
127
+ <span class="muted">Charged to the default card.</span>
128
+ </div>
129
+ </Modal>
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ /** Props for UsageMeter. */
136
+ export interface UsageMeterProps {
137
+ label: string
138
+ value: number
139
+ limit: number
140
+ format?: ((n: number) => string) | undefined
141
+ /** Past the limit: "+ $12.40 overage". */
142
+ overage?: string
143
+ note?: string
144
+ /** A link to the plan picker preselected on the next tier. */
145
+ action?: unknown
146
+ }
147
+
148
+ /** A usage meter (09 §4.3): the warning tick at 80%, caution from 80%, critical at 100%, stripes past the limit. */
149
+ export function UsageMeter({ label, value, limit, format = String, overage, note, action }: UsageMeterProps) {
150
+ const m = derived(() => meterParts(value, limit))
151
+ const said = derived(() => (m.value.over ? overage ?? `${m.value.percent}% used.` : `${m.value.percent}% used.${m.value.tone && note ? ` ${note}` : ''}`))
152
+ return (
153
+ <div data-scope="meter" data-tone={m.value.tone}>
154
+ <div data-part="head"><b>{label}</b><span>{format(value)} / {format(limit)}</span></div>
155
+ <span data-part="rail" data-on="" role="meter" aria-label={label} aria-valuemin="0" aria-valuemax={limit} aria-valuenow={Math.min(value, limit)} aria-valuetext={`${format(value)} of ${format(limit)}`} style={`--v:${m.value.fill}%;--over:${m.value.over}%;--warn:${m.value.warn}%`}>
156
+ <span data-part="rail-fill"></span>
157
+ {m.value.over > 0 && <span data-part="over"></span>}
158
+ <span data-part="tick" aria-hidden="true"></span>
159
+ </span>
160
+ <p data-part="note">{said.value}</p>
161
+ {action}
162
+ </div>
163
+ )
164
+ }
165
+
166
+ /** The group of usage meters. */
167
+ export const UsageMeters = ({ children }: { children: unknown }) => <div data-scope="usage">{children}</div>
@@ -0,0 +1,112 @@
1
+ import { dirtyKeys, leaveGuard, plural } from '@weft-js/warp-core'
2
+ import { derived, onCleanup, state, type State, untrack } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { toast } from '../Toast'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import { ConfirmDialog } from './Danger'
7
+ import { afterFlush } from './atoms'
8
+ import '../styles'
9
+ import '@weft-js/warp-core/styles/flows/tokens.css'
10
+ import '@weft-js/warp-core/styles/flows/base.css'
11
+ import '@weft-js/warp-core/styles/rich/helpers.css'
12
+ import '@weft-js/warp-core/styles/layout/tokens.css'
13
+ import '@weft-js/warp-core/styles/layout/row.css'
14
+ import '@weft-js/warp-core/styles/layout/stack.css'
15
+ import '@weft-js/warp-core/styles/flows/save-bar.css'
16
+
17
+ const SAVED_MS = 1500
18
+
19
+ /** Messages by field name. */
20
+ export type FieldErrors = Record<string, string>
21
+ /** Props for SaveBar. */
22
+ export interface SaveBarProps<T extends Record<string, unknown>> {
23
+ /** The live form values. The bar snapshots them at mount and after each save. */
24
+ values: State<T>
25
+ /** Field errors, written here for the fields to read. */
26
+ errors?: State<FieldErrors> | undefined
27
+ /** Client checks; anything returned stops the save. */
28
+ validate?: ((values: T) => FieldErrors) | undefined
29
+ /** Resolve with field errors to keep the bar; throw on a transport failure. */
30
+ onSave: (values: T) => Promise<FieldErrors | void> | FieldErrors | void
31
+ /** Called after Discard has put the snapshot back. */
32
+ onDiscard?: () => void
33
+ /** Where a confirmed leave goes. Default: the browser navigates. */
34
+ onLeave?: (href: string) => void
35
+ }
36
+
37
+ /**
38
+ * The save bar (09 §4.1): a dock that rises when the values differ from their snapshot. Save keeps it up with a
39
+ * pending button, then says Saved for 1.5 s; field errors keep it and focus the first invalid field. Leaving while
40
+ * dirty asks. Enter in the enclosing form saves.
41
+ */
42
+ export function SaveBar<T extends Record<string, unknown>>({ values, errors, validate, onSave, onDiscard, onLeave }: SaveBarProps<T>) {
43
+ const box = untrack(() => values)
44
+ const base = state<T>(untrack(() => box.value))
45
+ const phase = state<'idle' | 'pending' | 'saved'>('idle')
46
+ const fixes = state(0)
47
+ const leaving = state(false)
48
+ let target = ''
49
+ let el: HTMLDivElement | undefined
50
+ let timer: ReturnType<typeof setTimeout> | undefined
51
+ onCleanup(() => clearTimeout(timer))
52
+
53
+ const changed = derived(() => dirtyKeys(base.value, box.value))
54
+ const dirty = derived(() => changed.value.length > 0)
55
+ const message = derived(() => (phase.value === 'saved' ? 'Saved' : fixes.value ? `Fix ${plural(fixes.value, 'field')} to save` : 'You have unsaved changes'))
56
+ const fail = (found: FieldErrors) => {
57
+ if (errors) errors.value = found
58
+ fixes.value = Object.keys(found).length
59
+ afterFlush(() => el?.closest('form')?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus())
60
+ }
61
+ const save = async () => {
62
+ if (phase.value === 'pending' || !dirty.value) return
63
+ const now = box.value
64
+ const found = validate?.(now)
65
+ if (found && Object.keys(found).length) return fail(found)
66
+ phase.value = 'pending'
67
+ try {
68
+ const back = await onSave(now)
69
+ if (back && Object.keys(back).length) { phase.value = 'idle'; return fail(back) }
70
+ } catch {
71
+ phase.value = 'idle'
72
+ toast.show({ title: 'That didn’t save', description: 'Your changes are still here.', tone: 'critical', action: { label: 'Retry', onClick: save } })
73
+ return
74
+ }
75
+ if (errors) errors.value = {}
76
+ fixes.value = 0
77
+ base.value = now
78
+ phase.value = 'saved'
79
+ timer = setTimeout(() => (phase.value = 'idle'), SAVED_MS)
80
+ }
81
+ const discard = () => {
82
+ box.value = base.value
83
+ if (errors) errors.value = {}
84
+ fixes.value = 0
85
+ onDiscard?.()
86
+ }
87
+ const leave = () => {
88
+ leaving.value = false
89
+ discard()
90
+ if (onLeave) onLeave(target)
91
+ else el?.ownerDocument.defaultView?.location.assign(target)
92
+ }
93
+ use(leaveGuard, () => el, () => ({ dirty: dirty.value, onAttempt: (href: string) => { target = href; leaving.value = true } }))
94
+ use((node: HTMLDivElement) => {
95
+ const form = node.closest('form')
96
+ const submit = (e: Event) => { e.preventDefault(); void save() }
97
+ form?.addEventListener('submit', submit)
98
+ return { update() {}, destroy: () => form?.removeEventListener('submit', submit) }
99
+ }, () => el)
100
+ return (
101
+ <>
102
+ <div ref={n => (el = n)} data-scope="save-bar" data-open={dirty.value || phase.value !== 'idle' ? '' : undefined} role="region" aria-label="Unsaved changes">
103
+ <p data-part="msg" role="status">{message.value}</p>
104
+ <div data-part="actions">
105
+ <Button emphasis="ghost" tone="neutral" size="sm" style="--_ink:var(--warp-color-on-inverse)" disabled={phase.value === 'pending'} onClick={discard}>Discard</Button>
106
+ <Button size="sm" loading={phase.value === 'pending'} onClick={save}>Save changes</Button>
107
+ </div>
108
+ </div>
109
+ <ConfirmDialog open={leaving} title="Leave without saving?" description="Your changes on this page will be lost." action="Discard and leave" cancel="Stay" tone="critical" onConfirm={leave} />
110
+ </>
111
+ )
112
+ }
@@ -0,0 +1,37 @@
1
+ import { state, type State, watch } from '@weft-js/core'
2
+ import { Button } from '../Button'
3
+ import { Checkbox } from '../Choice'
4
+ import { Input } from '../Field'
5
+ import { toast } from '../Toast'
6
+ import '../styles'
7
+ import '@weft-js/warp-core/styles/flows/tokens.css'
8
+ import '@weft-js/warp-core/styles/flows/base.css'
9
+ import '@weft-js/warp-core/styles/rich/helpers.css'
10
+ import '@weft-js/warp-core/styles/layout/tokens.css'
11
+ import '@weft-js/warp-core/styles/layout/row.css'
12
+ import '@weft-js/warp-core/styles/layout/stack.css'
13
+
14
+ /**
15
+ * A secret shown once (09 §4.2, §4.6): a read-only field with Copy, and a checkbox the Done button waits on. Shared by
16
+ * API keys and webhook signing secrets. `done` goes true when it's been acknowledged.
17
+ */
18
+ export function SecretReveal({ label, secret, done, ack }: { label: string; secret: string; done: State<boolean>; ack: string }) {
19
+ const copy = () => {
20
+ navigator.clipboard?.writeText(secret).catch(() => {})
21
+ toast.show({ title: 'Copied', description: 'It’s on your clipboard. Paste it once, then clear it.' })
22
+ }
23
+ return (
24
+ <div data-scope="stack" style="padding-block-start:var(--warp-space-related)">
25
+ <Input label={label} readonly value={secret} autofocus spellcheck="false" onFocus={(e: Event) => (e.currentTarget as HTMLInputElement).select()} />
26
+ <div><Button emphasis="frame" onClick={copy}>Copy</Button></div>
27
+ <Checkbox checked={done.value} onChange={(e: Event) => (done.value = (e.currentTarget as HTMLInputElement).checked)}>{ack}</Checkbox>
28
+ </div>
29
+ )
30
+ }
31
+
32
+ export const useReveal = (open: State<boolean>) => {
33
+ const secret = state('')
34
+ const seen = state(false)
35
+ watch(() => open.value, on => { if (!on) { secret.value = ''; seen.value = false } })
36
+ return { secret, seen }
37
+ }