@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
package/src/Picker.tsx ADDED
@@ -0,0 +1,120 @@
1
+ import { isSheet, messages, pickerPanel, reportMissing, todayIso, uid, type Presentation } from '@weft-js/warp-core'
2
+ import { onMount, state, untrack, watch, type State } from '@weft-js/core'
3
+ import { bindable } from './bind'
4
+ import { Button } from './Button'
5
+ import { Field, type Control, type FieldShell } from './Field'
6
+ import type { IconRef } from './Icon'
7
+ import { Icon } from './Icon'
8
+ import { use } from '@weft-js/warp-core/weft'
9
+ import './fields'
10
+ import '@weft-js/warp-core/styles/rich/body.css'
11
+ import '@weft-js/warp-core/styles/rich/helpers.css'
12
+ import '@weft-js/warp-core/styles/rich/pick.css'
13
+ import '@weft-js/warp-core/styles/rich/segments.css'
14
+ import '@weft-js/warp-core/styles/rich/popover.css'
15
+
16
+ export { bindable, type Bindable, type Openable } from './bind'
17
+ export type RichFieldProps = FieldShell & {
18
+ required?: boolean | undefined
19
+ name?: string | undefined
20
+ locale?: string | undefined
21
+ /** Pins "today" (server render, tests); the browser's clock otherwise. */
22
+ today?: string | undefined
23
+ /** `auto` opens a popover, or a sheet when the viewport is under 600px wide; `popover` and `sheet` force one. */
24
+ presentation?: Presentation | undefined
25
+ }
26
+
27
+ /**
28
+ * The locale and today a picker renders with. The server and the hydrating client must agree, so both start from the
29
+ * prop (or en-US and the server's clock, which weft carries over in the state payload); after mount the document's lang
30
+ * and the browser's clock take over, a microtask later so hydration itself writes nothing.
31
+ */
32
+ export function usePickerEnv(locale: () => string | undefined, today: () => string | undefined) {
33
+ const loc = state(untrack(locale) ?? 'en-US')
34
+ const now = state(untrack(today) ?? todayIso())
35
+ onMount(() => {
36
+ queueMicrotask(() => {
37
+ if (!untrack(locale)) loc.value = document.documentElement.lang || navigator.language || 'en-US'
38
+ if (!untrack(today)) now.value = todayIso()
39
+ })
40
+ watch(locale, l => { if (l) loc.value = l })
41
+ })
42
+ return { locale: loc, today: now }
43
+ }
44
+
45
+ /** The open flag of a panel, from `open`, `defaultOpen` and the panel's first render: contents are built on first open. */
46
+ export function useOpen(open: () => State<boolean> | boolean | undefined, defaultOpen: () => boolean | undefined, onChange: () => ((o: boolean) => void) | undefined) {
47
+ const flag = bindable<boolean>(open, defaultOpen, false, () => (o: boolean) => untrack(onChange)?.(o))
48
+ const built = state(untrack(() => flag.value))
49
+ return {
50
+ get value() { return flag.value },
51
+ get built() { return built.value },
52
+ set(v: boolean) { if (v) built.value = true; flag.set(v) },
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The panel of a picker: a child of the control, so the label stays up and the sweep stays in while it is open (04
58
+ * "Popovers stay attached"). Anatomy from the specimen: sweep · handle · sheet-head (title, Done) · content.
59
+ */
60
+ export function Panel({ label, open, trigger, presentation, align, kind, children }: { label: string; open: { value: boolean; built: boolean; set(v: boolean): void }; trigger: () => HTMLElement; presentation?: Presentation | undefined; align?: 'start' | 'end'; kind?: string | undefined; children: () => unknown }) {
61
+ let panel: HTMLDialogElement | undefined
62
+ const done = () => open.set(false)
63
+ use(pickerPanel, () => panel, () => ({ open: open.value, trigger: trigger(), presentation, align, onClose: done }))
64
+ return (
65
+ <dialog ref={n => (panel = n)} data-scope="popover" data-kind={kind} data-align={align} data-sweep="" popover="manual" aria-label={label}>
66
+ <span data-part="sweep" aria-hidden="true"></span>
67
+ <div data-part="handle" aria-hidden="true"></div>
68
+ <div data-part="sheet-head"><span>{label}</span><Button emphasis="soft" size="sm" data-part="done" onClick={done}>Done</Button></div>
69
+ {open.built && children()}
70
+ </dialog>
71
+ )
72
+ }
73
+
74
+ /** The trigger at the end of a composite: a calendar or clock icon button (aria-haspopup, aria-expanded). */
75
+ export function Trigger({ el, icon, label, open, disabled, onToggle }: { el?: (el: HTMLButtonElement) => void; icon: IconRef; label: string; open: boolean; disabled?: boolean | undefined; onToggle: () => void }) {
76
+ return (
77
+ <button ref={n => el?.(n)} type="button" data-part="trigger" aria-haspopup="dialog" aria-expanded={open ? 'true' : 'false'} aria-label={label} disabled={disabled} onClick={onToggle}>
78
+ <Icon name={icon} />
79
+ </button>
80
+ )
81
+ }
82
+
83
+ /** A hidden input with the ISO value, so a native form posts it. */
84
+ export function Hidden({ name, value }: { name?: string | undefined; value: () => string }) {
85
+ return <>{name && <input type="hidden" name={name} value={value()} />}</>
86
+ }
87
+
88
+ /**
89
+ * A composite in the field shell: a body role=group named by the visible label; segments keep their short names. `bound`
90
+ * is its value: a form's reset goes back to the first one (`onReset` clears what the composite holds beside it), and
91
+ * `required` is a hidden text input (a type=hidden one is never validated) that is empty while the value is, so the
92
+ * form won't submit; the browser's own message shows as the field's error.
93
+ */
94
+ export function Composite({ control, bound, onReset, ...props }: FieldShell & { control: Control; bound: { readonly value: unknown; reset(): void }; onReset?: (() => void) | undefined; required?: boolean | undefined }) {
95
+ const missing = state<string | undefined>(undefined)
96
+ const blank = () => { const v = bound.value; return v === null || v === '' || (typeof v === 'object' && Object.values(v).includes(null)) }
97
+ let proxy: HTMLInputElement | undefined
98
+ const reset = () => {
99
+ bound.reset()
100
+ onReset?.()
101
+ missing.value = undefined
102
+ if (proxy) proxy.value = blank() ? '' : 'set' // the native reset emptied it, and the value may not have changed
103
+ }
104
+ return (
105
+ <Field
106
+ {...props}
107
+ error={props.error ?? (blank() ? missing.value : undefined)}
108
+ group
109
+ reset={reset}
110
+ control={p => (
111
+ <>
112
+ {untrack(() => control(p))}
113
+ {props.required && <input ref={n => (proxy = n)} onInvalid={(e: Event) => (missing.value = reportMissing(e))} data-part="sr-only" tabindex="-1" aria-hidden="true" required disabled={props.disabled} readonly={props.readonly} value={blank() ? '' : 'set'} />}
114
+ </>
115
+ )}
116
+ />
117
+ )
118
+ }
119
+
120
+ export { isSheet, messages, uid }
@@ -0,0 +1,126 @@
1
+ import { PinIcon } from '@weft-js/warp-icons'
2
+ import { addressFormat, addressPart, formReset, withAddressPart, type Address, type AddressPart, type TimeZoneId } from '@weft-js/warp-core'
3
+ import { derived, state, untrack } from '@weft-js/core'
4
+ import { Chip } from './Chip'
5
+ import { ComboField } from './Combobox'
6
+ import { Input } from './Field'
7
+ import { bindable, type Bindable, type RichFieldProps } from './Picker'
8
+ import { CountrySelect } from './RichSelect'
9
+ import { use } from '@weft-js/warp-core/weft'
10
+ import '@weft-js/warp-core/styles/rich/address.css'
11
+
12
+ /** A place a lookup returned. */
13
+ export type Place = { displayName: string; lat: number; lng: number; timezone?: TimeZoneId; category?: string; id?: string }
14
+ /** A filter chip that nudges a place lookup. */
15
+ export type PlaceBias = { id: string; label: string; default?: boolean }
16
+
17
+ /** Props for PlaceLookupInput. */
18
+ export type PlaceLookupInputProps = RichFieldProps & Bindable<Place | null> & {
19
+ search: (q: string, o: { bias: Record<string, boolean>; signal: AbortSignal }) => Promise<Place[]>
20
+ biases?: PlaceBias[]
21
+ minChars?: number
22
+ }
23
+
24
+ /** PlaceLookupInput (04 §3): "a place", not a postal address. warp ships no geocoder: the app supplies `search()`. */
25
+ export function PlaceLookupInput({ value, defaultValue, onValueChange, search, biases, minChars = 2, label, hint, ...rest }: PlaceLookupInputProps) {
26
+ const v = bindable<Place | null>(() => value, () => defaultValue, null, () => onValueChange)
27
+ const text = state(untrack(() => v.value?.displayName ?? ''))
28
+ const bias = state<Record<string, boolean>>(untrack(() => Object.fromEntries((biases ?? []).map(b => [b.id, !!b.default]))))
29
+ const list = derived(() => biases ?? [])
30
+ const chosen = derived(() => { const p = v.value; return p ? `${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}${p.timezone ? ` · ${p.timezone}` : ''}` : undefined })
31
+ let box: HTMLDivElement | undefined
32
+ const reset = () => {
33
+ v.reset()
34
+ text.value = v.value?.displayName ?? ''
35
+ box!.querySelector('input')!.value = text.value // the native reset emptied it, and the text may not have changed
36
+ }
37
+ return (
38
+ <div ref={n => (box = n)} class="stack">
39
+ <ComboField
40
+ icon={PinIcon}
41
+ {...rest}
42
+ reset={reset}
43
+ label={label}
44
+ hint={hint}
45
+ minChars={minChars}
46
+ text={() => text.value}
47
+ status={() => chosen.value}
48
+ find={(q, signal) => search(q, { bias: untrack(() => bias.value), signal })}
49
+ row={(p: Place) => ({ main: p.displayName, sub: p.category, meta: `${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}` })}
50
+ onText={t => { text.value = t; if (v.value) v.set(null, 'keyboard') }}
51
+ onPick={(p: Place) => { text.value = p.displayName; v.set(p, 'keyboard') }}
52
+ />
53
+ {list.value.length > 0 && (
54
+ <div class="chips" role="group" aria-label="Prefer">
55
+ {list.value.map(b => <Chip key={b.id} pressed={!!(bias.get(b.id) as boolean | undefined)} onClick={() => (bias.value = { ...bias.value, [b.id]: !bias.value[b.id] })}>{b.label}</Chip>)}
56
+ </div>
57
+ )}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ /** What an address lookup needs from a provider. */
63
+ export interface AddressProvider {
64
+ suggest(q: string, o: { country?: string; signal: AbortSignal }): Promise<Array<{ id: string; label: string; secondary?: string }>>
65
+ resolve(id: string): Promise<Address>
66
+ }
67
+ /** Props for AddressField. */
68
+ export type AddressFieldProps = Bindable<Address | null> & { label?: string | undefined; provider?: AddressProvider | undefined; countries?: string[] | undefined; defaultCountry?: string; locale?: string | undefined; name?: string | undefined; errors?: Partial<Record<AddressPart, string>> | undefined }
69
+
70
+ /**
71
+ * AddressField (04 §3): the country comes first and decides the fields. Line 1 is a combobox when there is a `provider`;
72
+ * manual entry is one click away, and automatic when the provider fails or takes over 1.5s.
73
+ */
74
+ export function AddressField({ value, defaultValue, onValueChange, label = 'Address', provider, countries, defaultCountry = 'US', locale, name, errors }: AddressFieldProps) {
75
+ const v = bindable<Address | null>(() => value, () => defaultValue, null, () => onValueChange)
76
+ const country = state<string | null>(untrack(() => v.value?.country ?? defaultCountry))
77
+ const format = derived(() => addressFormat(country.value!))
78
+ const manual = state(false)
79
+ const note = state('')
80
+ const touched = state<Record<string, boolean>>({})
81
+ const address = () => v.value ?? { country: country.value!, lines: [] }
82
+ const write = (part: AddressPart, text: string) => v.set(withAddressPart(address(), part, text), 'keyboard')
83
+ const fill = async (id: string) => {
84
+ try {
85
+ const a = await provider!.resolve(id)
86
+ country.value = a.country
87
+ v.set(a, 'keyboard')
88
+ note.value = 'Filled from suggestion. Check line 2.'
89
+ } catch { goManual('Suggestions are unavailable. Enter the address here.') }
90
+ }
91
+ const goManual = (say: string) => { manual.value = true; note.value = say }
92
+ const postal = derived(() => {
93
+ const f = format.value, t = addressPart(v.value, 'postalCode').trim()
94
+ return f.postal && t && touched.value.postal && !f.postal.test(t) ? f.postalMessage : undefined
95
+ })
96
+ let root: HTMLDivElement | undefined
97
+ use(formReset, () => root, () => () => {
98
+ v.reset()
99
+ country.value = v.value?.country ?? defaultCountry
100
+ manual.value = false
101
+ note.value = ''
102
+ touched.value = {}
103
+ })
104
+ return (
105
+ <div ref={n => (root = n)} role="group" aria-label={label} class="stack">
106
+ {name && <input type="hidden" name={name} value={v.value ? JSON.stringify(v.value) : ''} />}
107
+ <div class="addr-grid">
108
+ <CountrySelect label="Country or region" value={country} options={countries} locale={locale} onValueChange={c => { if (v.value) v.set({ ...v.value, country: c! }, 'pointer') }} />
109
+ {format.value.fields.map(([part, title, span, auto]) => {
110
+ const problem = derived(() => errors?.[part])
111
+ return (
112
+ <div key={`${country.value}:${part}`} data-span={span}>
113
+ {part === 'line1' && provider && !manual.value
114
+ ? <ComboField icon={PinIcon} label={title} autocomplete={auto} text={() => addressPart(v.value, 'line1')} timeout={1500} find={(q, signal) => provider.suggest(q, { country: country.value!, signal })} row={s => ({ main: s.label, sub: s.secondary, icon: PinIcon })} onText={t => write('line1', t)} onPick={s => fill(s.id)} onFail={() => goManual('Suggestions are unavailable. Enter the address here.')} />
115
+ : <Input label={title} autocomplete={auto} icon={part === 'line1' ? 'pin' : undefined} value={addressPart(v.value, part)} error={problem.value ?? (part === 'postalCode' ? postal.value : undefined) ?? (errors ? '' : undefined)} onInput={e => write(part, e.currentTarget.value)} onBlur={part === 'postalCode' ? () => (touched.value = { ...touched.value, postal: true }) : undefined} />}
116
+ </div>
117
+ )
118
+ })}
119
+ </div>
120
+ <p class="addr-foot">
121
+ {provider && !manual.value && <button type="button" class="linkish" onClick={() => goManual('Manual entry.')}>Enter address manually</button>}
122
+ {' '}<span aria-live="polite">{note.value}</span>
123
+ </p>
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,34 @@
1
+ import { popover, uid, type Align, type Side } from '@weft-js/warp-core'
2
+ import { state, untrack } from '@weft-js/core'
3
+ import { Button, type ButtonProps } from './Button'
4
+ import { use } from '@weft-js/warp-core/weft'
5
+ import './styles'
6
+ import '@weft-js/warp-core/styles/components/popover.css'
7
+
8
+ /**
9
+ * A button that opens a non-modal dialog panel (05 §3; components.html:574-586, 1557-1571): the outline trigger holds
10
+ * its sweep while open (aria-expanded); focus moves into the panel and back to the trigger; Escape, a press outside
11
+ * or focus leaving closes it; [data-close-popover] buttons inside close it. Named by its title, else by its trigger.
12
+ * `inline` draws the panel open under its trigger in the page's flow, for docs and previews: no top layer, no placement,
13
+ * no light dismiss; the trigger still shows and hides it.
14
+ */
15
+ export function Popover({ label, title, description, side, align, trigger, inline, children }: { label: unknown; title?: unknown; description?: unknown; side?: Side; align?: Align; trigger?: ButtonProps; /** Drawn open under the trigger, in the page's flow: no top layer or light dismiss. For docs and previews. */ inline?: boolean; children?: unknown }) {
16
+ const id = uid('pop')
17
+ const flat = untrack(() => !!inline)
18
+ const open = state(flat)
19
+ let button: HTMLButtonElement | undefined
20
+ let panel: HTMLDivElement | undefined
21
+ if (!flat) use(popover, () => panel, () => ({ open: open.value, trigger: button!, side, align, onClose: () => (open.value = false) }))
22
+ return (
23
+ <>
24
+ <Button emphasis="outline" tone="neutral" {...(trigger ?? {})} ref={(el: HTMLButtonElement) => (button = el)} id={`${id}-b`} aria-haspopup="dialog" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} onClick={() => (open.value = !open.value)}>
25
+ {label}
26
+ </Button>
27
+ <div ref={n => (panel = n)} data-scope="popover" data-part="content" role="dialog" popover={flat ? undefined : 'manual'} data-presentation={flat ? 'inline' : undefined} data-state={flat ? (open.value ? 'open' : 'closed') : undefined} hidden={flat && !open.value} id={id} aria-labelledby={title !== undefined ? `${id}-t` : `${id}-b`}>
28
+ {title !== undefined && <p data-part="title" id={`${id}-t`}>{title}</p>}
29
+ {description !== undefined && <p data-part="description">{description}</p>}
30
+ {children}
31
+ </div>
32
+ </>
33
+ )
34
+ }
@@ -0,0 +1,26 @@
1
+ import { untrack } from '@weft-js/core'
2
+ import type { Tone } from './Button'
3
+ import './styles'
4
+ import '@weft-js/warp-core/styles/components/progress.css'
5
+
6
+ /**
7
+ * Progress (05 §5): linear, or `shape="circle"`. Without a `value` it is indeterminate. The range moves through
8
+ * the rail with clip-path on the sweep curve; `--_v` is the only thing a value change writes.
9
+ */
10
+ export function Progress({ label, value, shape, tone, showValue = true }: { label: string; value?: number; shape?: 'linear' | 'circle' | undefined; tone?: Tone | undefined; showValue?: boolean }) {
11
+ const pct = () => (value === undefined ? undefined : Math.max(0, Math.min(100, value)))
12
+ const v = () => (pct() === undefined ? undefined : `--_v:${pct()! / 100}`)
13
+ if (untrack(() => shape) === 'circle')
14
+ return (
15
+ <div data-scope="progress" data-shape="circle" data-tone={tone} role="progressbar" aria-label={label} aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct() === undefined ? undefined : Math.round(pct()!)} data-state={value === undefined ? 'indeterminate' : undefined} style={v()}>
16
+ <svg viewBox="0 0 36 36" aria-hidden="true"><circle data-part="track" cx="18" cy="18" r="15.9155" pathLength="100" /><circle data-part="range" cx="18" cy="18" r="15.9155" pathLength="100" /></svg>
17
+ {showValue && value !== undefined && <span data-part="value-text">{Math.round(pct()!)}</span>}
18
+ </div>
19
+ )
20
+ return (
21
+ <div data-scope="progress" data-part="root" data-tone={tone} role="progressbar" aria-label={label} aria-valuemin={value === undefined ? undefined : '0'} aria-valuemax={value === undefined ? undefined : '100'} aria-valuenow={pct() === undefined ? undefined : Math.round(pct()!)} aria-valuetext={value === undefined ? label : undefined} data-state={value === undefined ? 'indeterminate' : undefined} style={v()}>
22
+ <div data-part="label-row"><span>{label}</span><span data-part="value-text">{value === undefined ? '—' : `${Math.round(pct()!)}%`}</span></div>
23
+ <div data-part="track"><div data-part="range"></div></div>
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,126 @@
1
+ import { addDays, fmt, formReset, occurrences, parseRule, ruleSentence, stringifyRule, weekOrder, weekdayName, weekdayOrdinal, DAYS, dow, ymd, type Frequency, type IsoDate, type Rule } from '@weft-js/warp-core'
2
+ import { derived, flush, state } from '@weft-js/core'
3
+ import { Chip } from './Chip'
4
+ import { DateField } from './DateField'
5
+ import { Textarea } from './Field'
6
+ import { NumberField } from './NumberField'
7
+ import { bindable, usePickerEnv, type Bindable } from './Picker'
8
+ import { use } from '@weft-js/warp-core/weft'
9
+ import '@weft-js/warp-core/styles/rich/recurrence.css'
10
+
11
+ const FREQS: Frequency[] = ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']
12
+ const titled = (s: string) => s[0] + s.slice(1).toLowerCase()
13
+
14
+ /** Chips as a radio group (one tab stop, arrows move and select, every chip has aria-checked). */
15
+ function ChipRadios<V extends string>({ label, value, options, onChange }: { label: string; value: V; options: { value: V; label: string }[]; onChange: (v: V) => void }) {
16
+ let box: HTMLDivElement | undefined
17
+ const move = (e: KeyboardEvent) => {
18
+ const d = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key]
19
+ if (!d) return
20
+ e.preventDefault()
21
+ const at = options.findIndex(o => o.value === value)
22
+ onChange(options[(at + d + options.length) % options.length]!.value)
23
+ flush()
24
+ box?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
25
+ }
26
+ return (
27
+ <div ref={n => (box = n)} role="radiogroup" aria-label={label} class="chips" onKeyDown={move}>
28
+ {options.map(o => <button key={o.value} type="button" data-scope="chip" role="radio" aria-checked={o.value === value ? 'true' : 'false'} tabindex={o.value === value ? '0' : '-1'} onClick={() => onChange(o.value)}>{o.label}</button>)}
29
+ </div>
30
+ )
31
+ }
32
+
33
+ /** Props for RecurrenceField. */
34
+ export type RecurrenceFieldProps = Bindable<string | null> & {
35
+ label: string
36
+ dtstart: IsoDate
37
+ preview?: number
38
+ frequencies?: Frequency[]
39
+ locale?: string | undefined
40
+ name?: string | undefined
41
+ }
42
+
43
+ /**
44
+ * RecurrenceField (04 §2): the summary leads and the controls sit below it. An RRULE with parts this field can't edit
45
+ * is never rewritten: it becomes read-only "Custom rule" with the raw text and an Edit as text escape.
46
+ */
47
+ export function RecurrenceField({ value, defaultValue, onValueChange, label, dtstart, preview = 5, frequencies = FREQS, locale, name }: RecurrenceFieldProps) {
48
+ const env = usePickerEnv(() => locale, () => undefined)
49
+ const v = bindable<string | null>(() => value, () => defaultValue, 'FREQ=WEEKLY', () => onValueChange)
50
+ const rule = derived(() => parseRule(v.value))
51
+ const asText = state(false)
52
+ const custom = derived(() => rule.value.unknown.length > 0)
53
+ const endMode = derived<'never' | 'count' | 'until'>(() => (rule.value.COUNT ? 'count' : rule.value.UNTIL ? 'until' : 'never'))
54
+ const set = (patch: Partial<Rule>) => v.set(stringifyRule({ ...rule.value, ...patch }), 'pointer')
55
+ const next = derived(() => occurrences(rule.value, dtstart, preview))
56
+ const order = derived(() => weekOrder(env.locale.value))
57
+ const monthly = derived(() => {
58
+ const [, , d] = ymd(dtstart)
59
+ const { nth, last } = weekdayOrdinal(dtstart)
60
+ const day = DAYS[dow(dtstart)]!
61
+ const dayName = weekdayName(day, env.locale.value)
62
+ return [
63
+ { value: 'day', label: `On day ${d}`, patch: { BYMONTHDAY: d, BYDAY: [] } },
64
+ { value: 'nth', label: `On the ${['', 'first', 'second', 'third', 'fourth', 'fifth'][nth]} ${dayName}`, patch: { BYDAY: [`${nth}${day}`], BYMONTHDAY: null } },
65
+ ...(last ? [{ value: 'last', label: `On the last ${dayName}`, patch: { BYDAY: [`-1${day}`], BYMONTHDAY: null } }] : []),
66
+ { value: 'end', label: 'On the last day', patch: { BYMONTHDAY: -1, BYDAY: [] } },
67
+ ]
68
+ })
69
+ const monthlyNow = derived(() => {
70
+ const r = rule.value
71
+ return r.BYMONTHDAY === -1 ? 'end' : r.BYDAY[0]?.startsWith('-1') ? 'last' : r.BYDAY.length ? 'nth' : 'day'
72
+ })
73
+ const toggleDay = (d: string) => {
74
+ const on = rule.value.BYDAY
75
+ set({ BYDAY: on.includes(d) ? (on.length > 1 ? on.filter(x => x !== d) : on) : [...on, d] })
76
+ }
77
+ const setEnd = (m: 'never' | 'count' | 'until') => set(m === 'count' ? { COUNT: 10, UNTIL: null } : m === 'until' ? { COUNT: null, UNTIL: addDays(dtstart, 60) } : { COUNT: null, UNTIL: null })
78
+ let root: HTMLDivElement | undefined
79
+ use(formReset, () => root, () => () => { v.reset(); asText.value = false })
80
+ return (
81
+ <div ref={n => (root = n)} role="group" aria-label={label} class="stack">
82
+ {name && <input type="hidden" name={name} value={v.value ?? ''} />}
83
+ {custom.value || asText.value ? (
84
+ <>
85
+ <p class="rr-sentence" aria-live="polite">Custom rule</p>
86
+ {asText.value
87
+ ? <Textarea label="RRULE" value={v.value ?? ''} onInput={e => v.set(e.currentTarget.value, 'keyboard')} />
88
+ : <output class="readout"><span>RRULE</span><b>{v.value}</b></output>}
89
+ <div class="chips"><button type="button" class="linkish" onClick={() => (asText.value = !asText.value)}>{asText.value ? 'Done' : 'Edit as text'}</button></div>
90
+ </>
91
+ ) : (
92
+ <>
93
+ <p class="rr-sentence" aria-live="polite">{ruleSentence(rule.value, dtstart, env.locale.value)}.</p>
94
+ <div class="rr-grid">
95
+ <div class="stack">
96
+ <ChipRadios label="Frequency" value={rule.value.FREQ} options={frequencies.map(f => ({ value: f, label: titled(f) }))} onChange={f => set({ FREQ: f, BYDAY: f === 'WEEKLY' ? rule.value.BYDAY : [], BYMONTHDAY: null })} />
97
+ <NumberField label="Every (interval)" min={1} max={99} value={rule.value.INTERVAL} onValueChange={n => set({ INTERVAL: n ?? 1 })} />
98
+ {rule.value.FREQ === 'WEEKLY' && (
99
+ <div role="group" aria-label="On these days" class="chips">
100
+ {order.value.map(d => <Chip key={d} data-day={d} pressed={rule.value.BYDAY.includes(d)} onClick={() => toggleDay(d)}><span aria-hidden="true">{weekdayName(d, env.locale.value, 'short')}</span><span class="sr">{weekdayName(d, env.locale.value)}</span></Chip>)}
101
+ </div>
102
+ )}
103
+ {rule.value.FREQ === 'MONTHLY' && <ChipRadios label="On" value={monthlyNow.value} options={monthly.value} onChange={m => set(monthly.value.find(o => o.value === m)!.patch)} />}
104
+ <ChipRadios label="Ends" value={endMode.value} options={[{ value: 'never', label: 'Never ends' }, { value: 'count', label: 'After…' }, { value: 'until', label: 'On date…' }]} onChange={setEnd} />
105
+ {endMode.value === 'count' && <NumberField label="Occurrences" min={1} max={999} value={rule.value.COUNT} onValueChange={n => set({ COUNT: n ?? 1 })} />}
106
+ {endMode.value === 'until' && <DateField label="Last date" value={rule.value.UNTIL ?? null} onValueChange={d => { if (d) set({ UNTIL: d }) }} />}
107
+ </div>
108
+ <div class="stack">
109
+ <p class="dt-cap">Next {preview}</p>
110
+ <ol class="rr-next">
111
+ {next.value.map(d => <li key={d}>{fmt(d, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }, env.locale.value)}</li>)}
112
+ {!next.value.length && <li>No occurrences.</li>}
113
+ </ol>
114
+ <details>
115
+ <summary>Advanced</summary>
116
+ <output class="readout"><span>RRULE</span><b>{v.value}</b></output>
117
+ <button type="button" class="linkish" onClick={() => (asText.value = true)}>Edit as text</button>
118
+ </details>
119
+ </div>
120
+ </div>
121
+ </>
122
+ )}
123
+ </div>
124
+ )
125
+ }
126
+