@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,108 @@
|
|
|
1
|
+
import { announce, coachSpot, tourSpot, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, state, untrack, watch, type State } from '@weft-js/core'
|
|
3
|
+
import { Button } from '../Button'
|
|
4
|
+
import { bindable } from '../Picker'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
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/components/tour.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/flows/tour.css'
|
|
11
|
+
|
|
12
|
+
/** One stop of a tour. */
|
|
13
|
+
export interface TourStep {
|
|
14
|
+
/** A CSS selector: `[data-tour=new]`. */
|
|
15
|
+
target: string
|
|
16
|
+
title: unknown
|
|
17
|
+
body: unknown
|
|
18
|
+
/** The target stays pressable ("Try it: click New"). */
|
|
19
|
+
interactive?: boolean | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Props for Tour. */
|
|
23
|
+
export interface TourProps {
|
|
24
|
+
id?: string
|
|
25
|
+
steps: TourStep[]
|
|
26
|
+
open?: State<boolean> | boolean
|
|
27
|
+
/** Ran to the end. */
|
|
28
|
+
onDone?: () => void
|
|
29
|
+
/** Skip or Escape. */
|
|
30
|
+
onSkip?: () => void
|
|
31
|
+
onStepChange?: ((index: number) => void) | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Running({ steps, onEnd, onStepChange }: { steps: TourStep[]; onEnd: (done: boolean) => void; onStepChange?: ((i: number) => void) | undefined }) {
|
|
35
|
+
const id = uid('tour')
|
|
36
|
+
const list = untrack(() => steps)
|
|
37
|
+
const at = state(0)
|
|
38
|
+
const way = state(1)
|
|
39
|
+
const step = derived(() => list[at.value])
|
|
40
|
+
const many = list.length > 1
|
|
41
|
+
const go = (i: number, dir = 1) => {
|
|
42
|
+
way.value = dir
|
|
43
|
+
if (i < 0) return
|
|
44
|
+
if (i >= list.length) return onEnd(true)
|
|
45
|
+
at.value = i
|
|
46
|
+
onStepChange?.(i)
|
|
47
|
+
}
|
|
48
|
+
watch(() => at.value, i => { if (typeof document === 'object') announce(document, `${many ? `Step ${i + 1} of ${list.length}. ` : ''}${typeof list[i]!.title === 'string' ? list[i]!.title : ''}`) })
|
|
49
|
+
let root: HTMLDivElement | undefined
|
|
50
|
+
use(tourSpot, () => root, () => ({
|
|
51
|
+
target: step.value!.target, step: at.value, interactive: step.value!.interactive,
|
|
52
|
+
onNext: () => go(at.value + 1), onPrev: () => go(at.value - 1, -1), onClose: () => onEnd(false),
|
|
53
|
+
onMissing: () => (way.value < 0 && at.value === 0 ? onEnd(false) : go(at.value + way.value, way.value)),
|
|
54
|
+
onBlocked: () => onEnd(false),
|
|
55
|
+
}))
|
|
56
|
+
return (
|
|
57
|
+
<div ref={n => (root = n)} data-scope="tour">
|
|
58
|
+
<div data-part="spotlight" data-sweep="" data-hold=""><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span></div>
|
|
59
|
+
<div data-part="card" role="dialog" aria-modal="false" aria-labelledby={`${id}-t`} aria-describedby={`${id}-d`}>
|
|
60
|
+
<span data-part="step-count">{many ? `Step ${at.value + 1} of ${list.length}` : 'Tip'}</span>
|
|
61
|
+
<h4 id={`${id}-t`} tabindex="-1" aria-level="2">{step.value!.title}</h4>
|
|
62
|
+
<p id={`${id}-d`}>{step.value!.body}</p>
|
|
63
|
+
<footer>
|
|
64
|
+
<span data-part="pips" aria-hidden="true">{many && list.map((s, i) => <i key={s.target} data-on={i === at.value ? '' : undefined}></i>)}</span>
|
|
65
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={() => onEnd(false)}>Skip tour</Button>
|
|
66
|
+
{at.value > 0 && <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => go(at.value - 1, -1)}>Back</Button>}
|
|
67
|
+
<Button size="sm" onClick={() => go(at.value + 1)}>{at.value === list.length - 1 ? 'Done' : 'Next'}</Button>
|
|
68
|
+
</footer>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The product tour (09 §3.2): a spotlight cut by the scrim, a ring that draws on arrival, a card beside the target
|
|
76
|
+
* (docked on a phone). A missing target skips its step; it starts only on request and never over another overlay; Esc
|
|
77
|
+
* ends it and focus returns to where it began.
|
|
78
|
+
*/
|
|
79
|
+
export function Tour({ steps, open, onDone, onSkip, onStepChange }: TourProps) {
|
|
80
|
+
const on = bindable<boolean>(() => open, () => false, false)
|
|
81
|
+
const end = (done: boolean) => {
|
|
82
|
+
on.set(false)
|
|
83
|
+
;(done ? onDone : onSkip)?.()
|
|
84
|
+
}
|
|
85
|
+
return <>{on.value && <Running steps={steps} onEnd={end} onStepChange={onStepChange} />}</>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A coachmark (09 §3.2): one tip with no scrim: a beacon on the target, the card on a press. Persisting the dismissal is the app's (`onDismiss`). */
|
|
89
|
+
export function Coachmark({ target, title, body, onDismiss }: { target: string; title: unknown; body: unknown; onDismiss?: () => void }) {
|
|
90
|
+
const id = uid('coach')
|
|
91
|
+
const open = state(false)
|
|
92
|
+
const gone = state(false)
|
|
93
|
+
let root: HTMLDivElement | undefined
|
|
94
|
+
use(coachSpot, () => root, () => ({ target, open: open.value, onClose: () => (open.value = false) }))
|
|
95
|
+
return (
|
|
96
|
+
<div ref={n => (root = n)} data-scope="tour" hidden={gone.value || undefined}>
|
|
97
|
+
<button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-size="sm" data-icon-only="" data-beacon="" aria-label="Show tip" aria-expanded={open.value ? 'true' : 'false'} onClick={() => (open.value = !open.value)}><span data-part="beacon" aria-hidden="true"></span></button>
|
|
98
|
+
{open.value && (
|
|
99
|
+
<div data-part="card" role="dialog" aria-modal="false" aria-labelledby={`${id}-t`}>
|
|
100
|
+
<span data-part="step-count">Tip</span>
|
|
101
|
+
<h4 id={`${id}-t`} tabindex="-1" aria-level="2">{title}</h4>
|
|
102
|
+
<p>{body}</p>
|
|
103
|
+
<footer><span data-part="pips"></span><Button size="sm" onClick={() => { open.value = false; gone.value = true; onDismiss?.() }}>Got it</Button></footer>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The flow pack: see docs/design/warp. One file per component family; each imports only its own CSS.
|
|
2
|
+
export { Flow, type FlowProps, type FlowStep } from './Flow'
|
|
3
|
+
export { AuthScreen } from './AuthScreen'
|
|
4
|
+
export { SignIn, type SignInProps, type SignInValues } from './SignIn'
|
|
5
|
+
export { SignUp, PasswordMeter, type SignUpProps, type SignUpValues } from './SignUp'
|
|
6
|
+
export { OtpField, OtpStep, type OtpFieldProps, type OtpStepProps, type OtpValues } from './Otp'
|
|
7
|
+
export { MagicLinkSent, ResetPassword, InviteAccept, AuthDone, type ResetPasswordProps, type ResetValues } from './AuthSteps'
|
|
8
|
+
export { ReauthDialog, type ReauthProps } from './ReauthDialog'
|
|
9
|
+
export { PasskeyButton, SsoButtons, type SsoProvider } from './Sso'
|
|
10
|
+
export { MfaSetup, type MfaSetupProps } from './MfaSetup'
|
|
11
|
+
export { SignGate, SignGateBadge, type SignGateProps, type SignStatus } from './SignGate'
|
|
12
|
+
export { Checklist, type ChecklistProps, type ChecklistItem } from './Checklist'
|
|
13
|
+
export { Tour, Coachmark, type TourProps, type TourStep } from './Tour'
|
|
14
|
+
export { SystemPage, type SystemPageProps, type AccessRequest, type SwitchAccount } from './SystemPage'
|
|
15
|
+
export { ConnBanner, RateLimited, type ConnBannerProps, type RateLimitedProps } from './ConnBanner'
|
|
16
|
+
export { ShortcutsOverlay } from './ShortcutsOverlay'
|
|
17
|
+
export { useCooldown, type FlowForm, type FormLike } from './kit'
|
package/src/flow/kit.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// What the flow screens share (09 §1): the title, mark and rail parts, the step's form frame and its error surfaces.
|
|
2
|
+
// Internal, except what the barrel names.
|
|
3
|
+
import { announce, mapFlowError, secondsClock, type FlowError, type FlowErrorOptions } from '@weft-js/warp-core'
|
|
4
|
+
import { derived, flush, onCleanup, onMount, state, untrack, watch } from '@weft-js/core'
|
|
5
|
+
import { Alert } from '../Alert'
|
|
6
|
+
import { Button, type ButtonProps } from '../Button'
|
|
7
|
+
import { Banner } from '../parts'
|
|
8
|
+
import { toast } from '../Toast'
|
|
9
|
+
import '../styles'
|
|
10
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/flows/meter.css' // the result mark
|
|
13
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
14
|
+
|
|
15
|
+
export type Heading = 'h1' | 'h2' | 'h3'
|
|
16
|
+
|
|
17
|
+
/** A step's title: focus lands here on arrival (09 §1.1). */
|
|
18
|
+
export function Title({ as, id, children }: { as?: Heading | undefined; id?: string; children?: unknown }) {
|
|
19
|
+
const level = derived(() => as ?? 'h1')
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{level.value === 'h3' ? <h3 data-part="title" data-part-title="" tabindex="-1" id={id}>{children}</h3>
|
|
23
|
+
: level.value === 'h2' ? <h2 data-part="title" data-part-title="" tabindex="-1" id={id}>{children}</h2>
|
|
24
|
+
: <h1 data-part="title" data-part-title="" tabindex="-1" id={id}>{children}</h1>}
|
|
25
|
+
</>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The result mark; `draw` strokes a check in. */
|
|
30
|
+
export function Mark({ tone, draw, children }: { tone?: 'positive' | 'caution' | 'critical' | undefined; draw?: boolean; children?: unknown }) {
|
|
31
|
+
return <span data-part="mark" data-tone={tone}>{draw ? <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> : children}</span>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The flows rail: `value` in percent. */
|
|
35
|
+
export function Rail({ value, on = true, ...rest }: { value: number; on?: boolean; [attr: string]: unknown }) {
|
|
36
|
+
return <span data-part="rail" data-on={on ? '' : undefined} style={`--v:${value}%`} {...rest}><span data-part="rail-fill"></span></span>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Seconds counting down to zero while `seconds()` holds a number; a new number restarts it. Nothing runs on the server
|
|
41
|
+
* or before mount, so the first paint is the full count.
|
|
42
|
+
*/
|
|
43
|
+
export function useCooldown(seconds: () => number | undefined, onDone?: () => void) {
|
|
44
|
+
const left = state(untrack(seconds) ?? 0)
|
|
45
|
+
let stop: (() => void) | undefined
|
|
46
|
+
const start = (n: number | undefined) => {
|
|
47
|
+
stop?.()
|
|
48
|
+
stop = undefined
|
|
49
|
+
left.value = n ?? 0
|
|
50
|
+
if (n) stop = secondsClock(n, s => (left.value = s), onDone)
|
|
51
|
+
}
|
|
52
|
+
onMount(() => {
|
|
53
|
+
// a fresh object each run: a restart with the same seconds still restarts
|
|
54
|
+
watch(() => ({ n: seconds() }), v => start(v.n))
|
|
55
|
+
onCleanup(() => stop?.())
|
|
56
|
+
})
|
|
57
|
+
return left
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What `<Form of={mutation}>` gives its children; a step reads it, so warp never imports the server's Form. */
|
|
61
|
+
export interface FormLike {
|
|
62
|
+
readonly pending: { readonly value: boolean }
|
|
63
|
+
readonly errors: { readonly value: Record<string, string> }
|
|
64
|
+
readonly message: { readonly value: string | undefined }
|
|
65
|
+
value?(name: string): unknown
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How a screen is driven: inside `<Form of>` (pass `form`), or on its own with `onSubmit` and `errors`. */
|
|
69
|
+
export interface FlowForm<V> {
|
|
70
|
+
/** The `<Form of={mutation}>` render argument: the screen then renders no <form> of its own. */
|
|
71
|
+
form?: FormLike
|
|
72
|
+
/** Called with the form's values once its own checks pass. */
|
|
73
|
+
onSubmit?: (values: V, e: SubmitEvent) => void
|
|
74
|
+
/** Messages by field name. */
|
|
75
|
+
errors?: Record<string, string> | undefined
|
|
76
|
+
/** The failure: anything `mapFlowError` takes (a `FnError`, a typed error, a string). */
|
|
77
|
+
error?: unknown
|
|
78
|
+
/** Options for `mapFlowError`: copy by code, and `online`. */
|
|
79
|
+
errorOptions?: FlowErrorOptions | undefined
|
|
80
|
+
pending?: boolean | undefined
|
|
81
|
+
/** The Retry on a network failure's toast. */
|
|
82
|
+
onRetry?: (() => void) | undefined
|
|
83
|
+
/** The title's level (default h1: an auth step is the page). */
|
|
84
|
+
heading?: Heading
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One step's form state: errors from all three sources, the mapped failure and its cooldown, its surfaces' side effects. */
|
|
88
|
+
export function useStepForm<V>(p: FlowForm<V>, check?: (values: Record<string, string>) => Record<string, string | undefined>) {
|
|
89
|
+
const own = state<Record<string, string>>({})
|
|
90
|
+
const failure = derived(() => {
|
|
91
|
+
const e = p.form ? p.form.message.value : p.error
|
|
92
|
+
return e ? mapFlowError(e, untrack(() => p.errorOptions)) : undefined
|
|
93
|
+
})
|
|
94
|
+
const errors = derived<Record<string, string>>(() => ({ ...own.value, ...failure.value?.fields, ...(p.form ? p.form.errors.value : p.errors) }))
|
|
95
|
+
const pending = derived(() => !!(p.form ? p.form.pending.value : p.pending))
|
|
96
|
+
const cooling = useCooldown(() => failure.value?.retryAfter, () => announce(document, 'You can try again now.'))
|
|
97
|
+
let root: HTMLElement | undefined
|
|
98
|
+
const focusInvalid = (say?: string) => {
|
|
99
|
+
flush()
|
|
100
|
+
root?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
|
|
101
|
+
if (say) announce(document, say)
|
|
102
|
+
}
|
|
103
|
+
onMount(() => {
|
|
104
|
+
watch(() => failure.value, (m: FlowError | undefined) => {
|
|
105
|
+
if (!m) return
|
|
106
|
+
if (m.surface === 'field') focusInvalid(m.summary)
|
|
107
|
+
if (m.surface === 'alert') { flush(); root?.querySelector<HTMLElement>('[data-part="formerr"]')?.focus() }
|
|
108
|
+
if (m.surface === 'rate-limit') announce(document, `${m.title} Try again in ${m.retryAfter}s.`)
|
|
109
|
+
if (m.surface === 'toast') toast.show({ title: m.title, description: m.description, tone: 'critical', action: p.onRetry && { label: 'Retry', onClick: () => p.onRetry!() } })
|
|
110
|
+
})
|
|
111
|
+
let sent = 0
|
|
112
|
+
watch(() => Object.keys(p.form ? p.form.errors.value : (p.errors ?? {})).length, n => {
|
|
113
|
+
if (n && n !== sent) focusInvalid()
|
|
114
|
+
sent = n
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
const submit = (e: SubmitEvent) => {
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
const f = e.currentTarget as HTMLFormElement
|
|
120
|
+
const values = Object.fromEntries(new FormData(f)) as Record<string, string>
|
|
121
|
+
const bad = Object.fromEntries(Object.entries(check?.(values) ?? {}).filter(([, m]) => m)) as Record<string, string>
|
|
122
|
+
own.value = bad
|
|
123
|
+
const n = Object.keys(bad).length
|
|
124
|
+
if (n) return focusInvalid(n > 1 ? `${n} fields need attention` : undefined)
|
|
125
|
+
p.onSubmit?.(values as V, e)
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
errors, failure, pending, cooling, submit,
|
|
129
|
+
/** Set one field's message from the client (format on blur), or clear it. */
|
|
130
|
+
flag(name: string, message: string | undefined) {
|
|
131
|
+
const { [name]: _, ...rest } = own.value
|
|
132
|
+
own.value = message ? { ...rest, [name]: message } : rest
|
|
133
|
+
},
|
|
134
|
+
bind(n: HTMLElement) { root = n },
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export type StepForm = ReturnType<typeof useStepForm>
|
|
138
|
+
|
|
139
|
+
/** The step's frame: a <form> of its own, or a plain stack inside `<Form of>`. */
|
|
140
|
+
export function StepFrame({ ctl, embedded, labelledby, children }: { ctl: StepForm; embedded: boolean; labelledby?: string; children?: unknown }) {
|
|
141
|
+
const plain = derived(() => embedded)
|
|
142
|
+
return (
|
|
143
|
+
<>
|
|
144
|
+
{plain.value
|
|
145
|
+
? <div ref={n => ctl.bind(n)} data-scope="stack" role="group" aria-labelledby={labelledby}>{children}</div>
|
|
146
|
+
: <form ref={n => ctl.bind(n)} data-scope="stack" novalidate aria-labelledby={labelledby} onSubmit={ctl.submit}>{children}</form>}
|
|
147
|
+
</>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The failure at the top of the step (09 §1.3): the Alert takes focus; a rate limit counts down; offline is a Banner. */
|
|
152
|
+
export function FormError({ ctl, actions }: { ctl: StepForm; actions?: unknown }) {
|
|
153
|
+
const m = derived(() => ctl.failure.value)
|
|
154
|
+
return (
|
|
155
|
+
<div data-part="formerr" tabindex="-1">
|
|
156
|
+
{m.value?.surface === 'alert' && <Alert tone="critical" live title={m.value.title} actions={actions}>{m.value.description}</Alert>}
|
|
157
|
+
{m.value?.surface === 'rate-limit' && <Alert tone="caution" title={m.value.title}>{ctl.cooling.value > 0 ? `Try again in ${ctl.cooling.value}s.` : 'You can try again now.'}</Alert>}
|
|
158
|
+
{m.value?.surface === 'banner' && <Banner tone="caution" live={m.value.live}><b>{m.value.title}</b> {m.value.description}</Banner>}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The one solid: keeps its label while pending; a rate limit disables it and says why. */
|
|
164
|
+
export function Submit({ ctl, disabled, children, ...rest }: { ctl: StepForm; disabled?: boolean | undefined; children?: unknown } & ButtonProps) {
|
|
165
|
+
const wait = derived(() => ctl.cooling.value)
|
|
166
|
+
return <Button type="submit" size="lg" full loading={ctl.pending.value} disabled={disabled || wait.value > 0} {...rest}>{wait.value > 0 ? `Try again in ${wait.value}s` : children}</Button>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A text button in a step's row. */
|
|
170
|
+
export const TextLink = ({ children, ...rest }: ButtonProps) => <Button emphasis="link" data-hit="text" {...rest}>{children}</Button>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @weft-js/warp: the weft binding of warp. Each component renders the spec's specimen markup with weft's compiled
|
|
2
|
+
// templates and attaches warp-core's behaviour; each imports only its own CSS (warp-core/styles, split per component).
|
|
3
|
+
export { Stack, Row, Grid, Split, Center, Cover, Frame, Spacer, Inset, type Step } from './Layout'
|
|
4
|
+
export { Heading, Text, Link } from './Type'
|
|
5
|
+
export { Button, type ButtonProps, type Tone, type Emphasis } from './Button'
|
|
6
|
+
export { Toggle, type ToggleProps } from './Toggle'
|
|
7
|
+
export { Field, Input, Search, Textarea, type FieldProps } from './Field'
|
|
8
|
+
export { NumberField, type NumberFieldProps } from './NumberField'
|
|
9
|
+
export { CompactSelect, Select, type Option, type OptionGroup } from './Select'
|
|
10
|
+
export { Switch, Checkbox, Radio, RadioGroup, Segmented, type SegmentedOption, type RadioOption } from './Choice'
|
|
11
|
+
export { Slider, type SliderProps } from './Slider'
|
|
12
|
+
export { Accordion, type AccordionItem, type AccordionProps } from './Accordion'
|
|
13
|
+
export { Ticker, type TickerProps } from './Ticker'
|
|
14
|
+
export { Menu, type MenuEntry as MenuItem, type MenuProps } from './Menu'
|
|
15
|
+
export { Chip } from './Chip'
|
|
16
|
+
export { Tabs, type TabOption } from './Tabs'
|
|
17
|
+
export { Nav } from './Nav'
|
|
18
|
+
export { Card } from './Card'
|
|
19
|
+
export { Badge } from './Badge'
|
|
20
|
+
export { Tag } from './Tag'
|
|
21
|
+
export { Kbd } from './Kbd'
|
|
22
|
+
export { Alert } from './Alert'
|
|
23
|
+
export { Progress } from './Progress'
|
|
24
|
+
export { Spinner } from './Spinner'
|
|
25
|
+
export { Skeleton } from './Skeleton'
|
|
26
|
+
export { Shape } from './Shape'
|
|
27
|
+
export { Tooltip } from './Tooltip'
|
|
28
|
+
export { Popover } from './Popover'
|
|
29
|
+
export { Modal } from './Modal'
|
|
30
|
+
export { Drawer } from './Drawer'
|
|
31
|
+
export { Toaster, toast } from './Toast'
|
|
32
|
+
export { Table, type Column, type TableProps, type TableSort, type TableStatus, type ListPage } from './Table'
|
|
33
|
+
export { DataGrid, PivotTable, type DataGridProps, type GridColumn, type GridView, type GridTree, type GridPaste, type PivotMeasure } from './DataGrid'
|
|
34
|
+
export { PaginationBar, LoadMore, type PaginationBarProps } from './Pagination'
|
|
35
|
+
export { CommandPalette, type Command, type CommandMode, type CommandSource, type CommandPaletteProps } from './CommandPalette'
|
|
36
|
+
export { MentionsInput, type MentionsInputProps } from './MentionsInput'
|
|
37
|
+
export { CodeBlock, Code, Snippet, type CodeBlockProps } from './CodeBlock'
|
|
38
|
+
export { CodeInput, JsonEditor, type CodeInputProps, type JsonEditorProps } from './CodeInput'
|
|
39
|
+
export { KeyValueEditor, type KeyValue, type KeyValueEditorProps } from './KeyValueEditor'
|
|
40
|
+
export { RichTextEditor, type RichTextEditorProps, type SlashCommand } from './RichTextEditor'
|
|
41
|
+
export { Markdown, MarkdownEditor, type MarkdownEditorProps } from './Markdown'
|
|
42
|
+
export { Shell, type ShellProps, type NavItem, type NavGroup, type NavIcon, type Crumb, type MenuEntry, type Workspace } from './Shell'
|
|
43
|
+
/** A docs sidebar: one left panel whose sections fold and remember; docked from 900px, opened over the page on phones. */
|
|
44
|
+
export { SideNav, type SideNavProps, type SideNavSection, type SideNavLink } from './SideNav'
|
|
45
|
+
export { Icon, type IconName, type IconRef } from './Icon'
|
|
46
|
+
export { Chart, type ChartFrame } from './Chart'
|
|
47
|
+
export { LineChart, BarChart, ComboChart, DonutChart, RadialChart, FunnelChart, ScatterChart, BubbleChart, Histogram, HeatGrid, RadarChart, Treemap, Sunburst, SankeyChart, CandlestickChart, BoxPlot, WaterfallChart, StreamChart, BrushChart, TileMap, GaugeChart, Sparkline } from './Charts'
|
|
48
|
+
export { type Bindable, type Openable } from './Picker'
|
|
49
|
+
export { InlineCalendar } from './Calendar'
|
|
50
|
+
export { DateField, DateRangeField, compareRange, type DateFieldProps, type DateRangeFieldProps } from './DateField'
|
|
51
|
+
export { TimeField, DateTimeField, type TimeFieldProps, type DateTimeFieldProps } from './TimeField'
|
|
52
|
+
export { MonthPicker, WeekPicker, PeriodPicker, type MonthPickerProps, type WeekPickerProps, type PeriodPickerProps } from './MonthPicker'
|
|
53
|
+
export { TimeZoneSelect, CountrySelect, LanguageSelect, CurrencySelect, PhoneField, type TimeZoneSelectProps, type PhoneFieldProps } from './RichSelect'
|
|
54
|
+
export { DurationField, type DurationFieldProps } from './DurationField'
|
|
55
|
+
export { RecurrenceField, type RecurrenceFieldProps } from './RecurrenceField'
|
|
56
|
+
export { PlaceLookupInput, AddressField, type Place, type PlaceBias, type PlaceLookupInputProps, type AddressFieldProps, type AddressProvider } from './PlaceField'
|
|
57
|
+
export { PaymentCardInput, CardNumberText, type PaymentCardInputProps, type ProviderField, type ProviderAppearance, type CardNumberTextProps } from './PaymentCard'
|
|
58
|
+
export { SignaturePad, type SignaturePadProps, type SignatureValue } from './SignaturePad'
|
|
59
|
+
export { Canvas, CanvasStage, StageSelect, type CanvasProps, type CanvasStageProps, type StageOption, type StageSelectProps, type Frame as CanvasFrame, type Press, type Scene, type Scroll, type StageLayout, type StagePainter, type StagePiece, type Word } from './Canvas'
|
|
60
|
+
export { Dither, DitherField, DitherGlyph, DitherStage, LEVEL, PAINTERS, type DitherProps, type DitherFieldProps, type DitherGlyphProps, type DitherStageProps, type Algorithm, type Brush, type DitherPainter, type DitherPiece, type FieldFn, type FieldName, type Gradient, type MaskSource, type Pattern, type PointerMode, type Ramp, type Skin } from './Dither'
|
|
61
|
+
export * from './agent/index'
|
|
62
|
+
export * from './schedule/index'
|
|
63
|
+
export * from './live/index'
|
|
64
|
+
export * from './collab/index'
|
|
65
|
+
export { Thread, Kanban } from './collab/index' // the one chat and the one board: both are windows (VirtualList)
|
|
66
|
+
export * from './flow/index'
|
|
67
|
+
export * from './admin/index'
|
|
68
|
+
export { VirtualList, type VirtualListProps, type VirtualControl } from './VirtualList'
|
|
69
|
+
export { Feed, type FeedProps, type FeedItem } from './Feed'
|
|
70
|
+
export { ScrollGrid, type ScrollGridProps, type GridColumn as ScrollGridColumn } from './ScrollGrid'
|
|
71
|
+
export type { Address, MentionItem } from '@weft-js/warp-core'
|
|
72
|
+
export type { Peer, CursorPeer, IntegrityStats, ChatMessage, MessageReaction } from '@weft-js/warp-core'
|
|
73
|
+
export type { IsoDate, IsoTime, IsoDateTime, IsoMonth, IsoWeek, IsoDuration, IsoZoned, TimeZoneId, DateRange, ChangeReason } from '@weft-js/warp-core'
|
|
74
|
+
export { sweep, setDial, getDial } from '@weft-js/warp-core'
|
package/src/layout.css
ADDED
package/src/links.css
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/* A bare `<a>` is never the browser's blue: warp's link ink and hairline underline (05 §4), in warp.base so every component's own link and app CSS win. */
|
|
2
|
+
@layer warp.base {
|
|
3
|
+
:where(a:any-link) { color: var(--warp-color-accent-text); text-decoration-thickness: var(--warp-border-hairline); text-underline-offset: 0.22em; }
|
|
4
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { arrivals, groupByDay, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { Avatar, type LiveState } from '../parts'
|
|
4
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
5
|
+
import { avatarTint, G, val } from './shared'
|
|
6
|
+
import '../styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/app/base.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/activity.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/activity-feed.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/sync.css'
|
|
11
|
+
|
|
12
|
+
/** One line of the activity feed. */
|
|
13
|
+
export interface ActivityItem { id: string; actor: string; verb: string; object?: string; detail?: string; time: string; datetime?: string; day?: string }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Activity feed (07 §4.2): actor, verb, object, time, grouped per day on a hairline spine, the quiet sibling of the inbox
|
|
17
|
+
* row. The header shows a "Live" check while `live` is live; new ids arrive.
|
|
18
|
+
*/
|
|
19
|
+
export function ActivityFeed({ items, live, title = 'Activity', level = 3 }: { items: ActivityItem[] | { value: ActivityItem[] } | (() => ActivityItem[]); live?: LiveState | { value: LiveState } | (() => LiveState) | undefined; title?: string; /** The title's heading level: one below the heading the feed sits under. */ level?: 2 | 3 | 4 | 5 | 6 }) {
|
|
20
|
+
const id = uid('activity')
|
|
21
|
+
const data = derived(() => (val<ActivityItem[]>(items) ?? []))
|
|
22
|
+
const days = derived(() => groupByDay(data.value))
|
|
23
|
+
const many = derived(() => days.value.length > 1 || data.value.some(n => n.day))
|
|
24
|
+
const on = derived(() => val<LiveState | undefined>(live) === 'live')
|
|
25
|
+
let root: HTMLElement | undefined
|
|
26
|
+
use(arrivals, () => root, () => ({ items: '[data-scope="activity"] > li', ids: data.value.map(n => n.id) }))
|
|
27
|
+
return (
|
|
28
|
+
<section ref={n => (root = n)} data-scope="activity-feed" aria-labelledby={`${id}-t`}>
|
|
29
|
+
<header>
|
|
30
|
+
<h3 id={`${id}-t`} aria-level={level === 3 ? undefined : level}>{title}</h3>
|
|
31
|
+
{on.value && <span data-scope="sync" data-state="saved"><G name="check" /> Live</span>}
|
|
32
|
+
</header>
|
|
33
|
+
{days.value.map(g => (
|
|
34
|
+
<div key={g.day}>
|
|
35
|
+
{many.value && <span data-part="eyebrow">{g.day}</span>}
|
|
36
|
+
<ol data-scope="activity">
|
|
37
|
+
{g.rows.map(n => (
|
|
38
|
+
<li key={n.id} data-id={n.id}>
|
|
39
|
+
<Avatar name={n.actor} size="sm" style={avatarTint(n.actor)} />
|
|
40
|
+
<span><b>{n.actor}</b> {n.verb}{n.object ? ' ' : ''}{n.object && <b>{n.object}</b>}{n.detail ? ` ${n.detail}` : ''}</span>
|
|
41
|
+
<time datetime={n.datetime}>{n.time}</time>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ol>
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</section>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { bellCount, bellLabel, bump, popover, uid, type Align, type Side } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, onMount, state, untrack, watch, type State } from '@weft-js/core'
|
|
3
|
+
import { Button } from '../Button'
|
|
4
|
+
import { Badge } from '../Badge'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import { G, val } from './shared'
|
|
7
|
+
import '../styles'
|
|
8
|
+
import '@weft-js/warp-core/styles/components/popover.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/bell.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/badge.css'
|
|
11
|
+
|
|
12
|
+
/** Props for Bell. */
|
|
13
|
+
export interface BellProps {
|
|
14
|
+
/** Unread count: a number, a State or a getter. */
|
|
15
|
+
count?: number | { value: number } | (() => number | undefined)
|
|
16
|
+
onOpen?: () => void
|
|
17
|
+
/** The panel, usually `open => <Inbox variant="popover" open={open.value} … />`, shown in a Popover from the bell. */
|
|
18
|
+
inbox?: (open: State<boolean>) => unknown
|
|
19
|
+
/** On a phone the inbox is a page, not a popover (07 §4.3): below 600px the bell calls this instead of opening. */
|
|
20
|
+
route?: () => void
|
|
21
|
+
side?: Side | undefined
|
|
22
|
+
align?: Align
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Bell (07 §4.1): icon button and count badge, which bumps once per batch; its name says the count (§4.5). */
|
|
26
|
+
export function Bell({ count, onOpen, inbox, route, side = 'bottom', align = 'end' }: BellProps) {
|
|
27
|
+
const id = uid('bell')
|
|
28
|
+
const n = derived(() => Number(val<unknown>(count) ?? 0))
|
|
29
|
+
const open = state(false)
|
|
30
|
+
const panel = untrack(() => inbox)
|
|
31
|
+
let button: HTMLButtonElement | undefined
|
|
32
|
+
let box: HTMLDivElement | undefined
|
|
33
|
+
let badge: HTMLElement | undefined
|
|
34
|
+
use(popover, () => box, () => ({ open: open.value, trigger: button!, side, align, onClose: () => (open.value = false) }))
|
|
35
|
+
onMount(() => watch(() => n.value, (v, prev) => { if (prev !== undefined && v > prev && badge) bump(badge) }))
|
|
36
|
+
const press = () => {
|
|
37
|
+
const toPage = route && button!.ownerDocument.defaultView!.matchMedia?.('(max-width: 599.98px)').matches
|
|
38
|
+
onOpen?.()
|
|
39
|
+
if (toPage) route()
|
|
40
|
+
else if (panel) open.value = !open.value
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
<span data-scope="bell">
|
|
44
|
+
<Button
|
|
45
|
+
emphasis="ghost"
|
|
46
|
+
tone="neutral"
|
|
47
|
+
data-icon-only=""
|
|
48
|
+
ref={(el: HTMLButtonElement) => (button = el)}
|
|
49
|
+
id={`${id}-b`}
|
|
50
|
+
aria-label={bellLabel(n.value)}
|
|
51
|
+
aria-haspopup={panel ? 'dialog' : undefined}
|
|
52
|
+
aria-expanded={panel ? (open.value ? 'true' : 'false') : undefined}
|
|
53
|
+
aria-controls={panel ? id : undefined}
|
|
54
|
+
onClick={press}
|
|
55
|
+
>
|
|
56
|
+
<G name="bell" />
|
|
57
|
+
</Button>
|
|
58
|
+
<Badge emphasis="solid" count ref={(el: HTMLElement) => (badge = el)} hidden={n.value === 0} aria-hidden="true">{bellCount(n.value)}</Badge>
|
|
59
|
+
{panel && <div ref={el => (box = el)} data-scope="popover" data-part="content" role="dialog" popover="manual" id={id} aria-label="Notifications">{panel(open)}</div>}
|
|
60
|
+
</span>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { announce, connectionAnnouncement, liveState, type LiveState } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, onCleanup, onMount, state, untrack, watch } from '@weft-js/core'
|
|
3
|
+
import { Banner, LiveStatus } from '../parts'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { val } from './shared'
|
|
6
|
+
|
|
7
|
+
const TEXT: Record<LiveState, string> = { connecting: 'Connecting…', live: 'Live', reconnecting: 'Reconnecting…', offline: 'Offline' }
|
|
8
|
+
|
|
9
|
+
type Status = string | undefined | { value: string } | (() => string | undefined)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The one pill (07 §0, §4.4): `state` is `.live`, `stream().status` or `channel.status`, mapped to the four states. It
|
|
13
|
+
* announces each change once; `queued` is the count in "Back online. 4 updates."
|
|
14
|
+
*/
|
|
15
|
+
export function ConnectionPill({ state: status, label = 'Connection', queued = 0 }: { state: Status; label?: string | undefined; queued?: number }) {
|
|
16
|
+
const now = derived(() => liveState(val<string | undefined>(status)))
|
|
17
|
+
const words = derived(() => TEXT[now.value])
|
|
18
|
+
let root: HTMLSpanElement | undefined
|
|
19
|
+
onMount(() => watch(() => now.value, (next, prev) => {
|
|
20
|
+
const say = connectionAnnouncement(prev, next, untrack(() => queued))
|
|
21
|
+
if (say && root) announce(root.ownerDocument, say.text, say.assertive)
|
|
22
|
+
}))
|
|
23
|
+
return <span ref={n => (root = n)}><LiveStatus state={now.value} label={label}>{words.value}</LiveStatus></span>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The trouble banner (07 §4.4): nothing while live or connecting, after `delay` ms of reconnecting (data stays, edits
|
|
28
|
+
* queue), at once when offline, with Retry. Quiet for assistive tech: the pill does the announcing.
|
|
29
|
+
*/
|
|
30
|
+
export function ConnectionBanner({ state: status, delay = 3000, onRetry, offline = "You're offline. Showing what was saved; new notifications will arrive when you reconnect.", reconnecting = 'Reconnecting… changes you make are queued.' }: { state: Status; delay?: number; onRetry?: (() => void) | undefined; offline?: unknown; reconnecting?: unknown }) {
|
|
31
|
+
const now = derived(() => liveState(val<string | undefined>(status)))
|
|
32
|
+
const late = state(false)
|
|
33
|
+
const retry = untrack(() => onRetry)
|
|
34
|
+
onMount(() => {
|
|
35
|
+
let t: ReturnType<typeof setTimeout> | undefined
|
|
36
|
+
watch(() => now.value, next => {
|
|
37
|
+
clearTimeout(t)
|
|
38
|
+
if (next !== 'reconnecting') { late.value = false; return }
|
|
39
|
+
t = setTimeout(() => (late.value = true), untrack(() => delay))
|
|
40
|
+
})
|
|
41
|
+
onCleanup(() => clearTimeout(t))
|
|
42
|
+
})
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
{now.value === 'offline' && <Banner tone="critical" actions={retry ? () => <Button emphasis="outline" tone="neutral" size="sm" onClick={retry}>Retry now</Button> : undefined}>{offline}</Banner>}
|
|
46
|
+
{now.value === 'reconnecting' && late.value && <Banner tone="caution">{reconnecting}</Banner>}
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|