@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
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
|
+
}
|
package/src/Popover.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Progress.tsx
ADDED
|
@@ -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
|
+
|