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