@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,237 @@
|
|
|
1
|
+
import { CalendarIcon, ClockIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { daysIn, dayPeriods, fieldName, fmt, fmtTime, fromMinutes, hour12, isoOf, listKey, minutes, pad, readDate, readTime, uid, wallTimeIssue, type ChangeReason, type IsoDate, type IsoDateTime, type IsoTime, type SegmentPart, type TimeZoneId } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, onMount, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { CalendarGrid } from './Calendar'
|
|
5
|
+
import { Composite, Hidden, isSheet, messages, Panel, Trigger, bindable, useOpen, usePickerEnv, type Bindable, type Openable, type RichFieldProps } from './Picker'
|
|
6
|
+
import { Segments, type SegmentState } from './Segments'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/listbox.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/rich/datetime.css'
|
|
9
|
+
|
|
10
|
+
/** The slot list (04 TimeField): a listbox with an active option, not a row of tab stops. */
|
|
11
|
+
function Slots({ slots, selected, locale, hourCycle, label, onPick, meta }: { slots: string[]; selected: string | null; locale: string; hourCycle?: string | undefined; label: string; onPick: (t: string, r: ChangeReason) => void; meta?: boolean | undefined }) {
|
|
12
|
+
const id = uid('slots')
|
|
13
|
+
const near = () => Math.max(0, slots.findIndex(x => x >= (untrack(() => selected) ?? '09:00')))
|
|
14
|
+
const active = state(untrack(near))
|
|
15
|
+
let list: HTMLUListElement | undefined
|
|
16
|
+
const show = (block: ScrollLogicalPosition) => list?.querySelector<HTMLElement>('[data-active]')?.scrollIntoView?.({ block })
|
|
17
|
+
const onKey = (e: KeyboardEvent) => {
|
|
18
|
+
const m = listKey(e.key, active.value, slots.length)
|
|
19
|
+
if (!m.handled) return
|
|
20
|
+
e.preventDefault()
|
|
21
|
+
if (m.pick) return onPick(slots[active.value]!, 'keyboard')
|
|
22
|
+
active.value = m.to
|
|
23
|
+
flush()
|
|
24
|
+
show('nearest')
|
|
25
|
+
}
|
|
26
|
+
onMount(() => show('center'))
|
|
27
|
+
return (
|
|
28
|
+
<ul ref={n => (list = n)} data-part="listbox" role="listbox" tabindex="0" data-initial="" aria-label={label} aria-activedescendant={`${id}-${active.value}`} onKeyDown={onKey}>
|
|
29
|
+
{slots.map((x, n) => <Slot key={x} id={`${id}-${n}`} time={x} text={fmtTime(x, locale, hourCycle)} selected={x === selected} active={n === active.value} meta={meta} onPick={onPick} />)}
|
|
30
|
+
</ul>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
const Slot = ({ id, time, text, selected, active, meta, onPick }: { id: string; time: string; text: string; selected: boolean; active: boolean; meta?: boolean | undefined; onPick: (t: string, r: ChangeReason) => void }) => (
|
|
34
|
+
<li role="option" id={id} aria-selected={selected ? 'true' : 'false'} data-active={active ? '' : undefined} onClick={() => onPick(time, 'pointer')}>{text}{meta && <span data-part="opt-meta">{time}</span>}</li>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** The wheel sheet of a phone (04 §1.2): scroll-snap columns; arrows and presses work too. */
|
|
38
|
+
function Wheels({ value, locale, hourCycle, onChange }: { value: string | null; locale: string; hourCycle?: 'h12' | 'h23' | 'auto' | undefined; onChange: (t: IsoTime) => void }) {
|
|
39
|
+
const h12 = untrack(() => hour12(locale, hourCycle))
|
|
40
|
+
const start = untrack(() => value) ?? '09:30'
|
|
41
|
+
const h0 = +start.slice(0, 2), m0 = Math.round(+start.slice(3, 5) / 5) * 5 % 60
|
|
42
|
+
const periods = untrack(() => dayPeriods(locale))
|
|
43
|
+
const cols = [
|
|
44
|
+
{ key: 'hour', name: untrack(() => fieldName('hour', locale)), vals: h12 ? [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] : Array.from({ length: 24 }, (_, i) => i), cur: state(h12 ? h0 % 12 || 12 : h0), show: (v: number) => (h12 ? String(v) : pad(v)) },
|
|
45
|
+
{ key: 'minute', name: untrack(() => fieldName('minute', locale)), vals: Array.from({ length: 12 }, (_, i) => i * 5), cur: state(m0), show: (v: number) => pad(v) },
|
|
46
|
+
...(h12 ? [{ key: 'dayPeriod', name: untrack(() => fieldName('dayPeriod', locale)), vals: [0, 1], cur: state(h0 >= 12 ? 1 : 0), show: (v: number) => periods[v] }] : []),
|
|
47
|
+
]
|
|
48
|
+
const commit = () => {
|
|
49
|
+
let h = cols[0]!.cur.value
|
|
50
|
+
if (h12) h = h % 12 + (cols[2]!.cur.value ? 12 : 0)
|
|
51
|
+
onChange(`${pad(h)}:${pad(cols[1]!.cur.value)}` as IsoTime)
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<div data-scope="wheels">
|
|
55
|
+
{cols.map(c => {
|
|
56
|
+
let list: HTMLUListElement | undefined
|
|
57
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
58
|
+
const row = () => (list!.firstElementChild as HTMLElement | null)?.getBoundingClientRect().height || 40
|
|
59
|
+
const select = (i: number, smooth: boolean) => {
|
|
60
|
+
i = Math.max(0, Math.min(c.vals.length - 1, i))
|
|
61
|
+
c.cur.value = c.vals[i]!
|
|
62
|
+
list?.scrollTo?.({ top: i * row(), behavior: smooth ? 'smooth' : 'instant' })
|
|
63
|
+
commit()
|
|
64
|
+
}
|
|
65
|
+
onMount(() => list?.scrollTo?.({ top: c.vals.indexOf(c.cur.value) * row(), behavior: 'instant' as ScrollBehavior }))
|
|
66
|
+
const onScroll = () => {
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
timer = setTimeout(() => {
|
|
69
|
+
const i = Math.round(list!.scrollTop / row())
|
|
70
|
+
if (c.vals[i] !== undefined && c.vals[i] !== c.cur.value) { c.cur.value = c.vals[i]; commit() }
|
|
71
|
+
}, 90)
|
|
72
|
+
}
|
|
73
|
+
const onKey = (e: KeyboardEvent) => {
|
|
74
|
+
const d = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
|
|
75
|
+
if (!d) return
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
select(c.vals.indexOf(c.cur.value) + d, true)
|
|
78
|
+
}
|
|
79
|
+
return (
|
|
80
|
+
<ul key={c.key} ref={n => (list = n)} role="listbox" tabindex="0" aria-label={c.name} onScroll={onScroll} onKeyDown={onKey}>
|
|
81
|
+
{c.vals.map(v => <li key={v} role="option" aria-selected={c.cur.value === v ? 'true' : 'false'} onClick={() => select(c.vals.indexOf(v), true)}>{c.show(v)}</li>)}
|
|
82
|
+
</ul>
|
|
83
|
+
)
|
|
84
|
+
})}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const stepSlots = (step: number, min?: string , max?: string ) => {
|
|
90
|
+
const from = min ? minutes(min) : 0, to = max ? minutes(max) : 1439
|
|
91
|
+
const out: string[] = []
|
|
92
|
+
for (let m = from; m <= to; m += step) out.push(fromMinutes(m))
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Props for TimeField. */
|
|
97
|
+
export type TimeFieldProps = RichFieldProps & Bindable<IsoTime | null> & Openable & {
|
|
98
|
+
/** `h12` shows AM and PM, `h23` a 24-hour clock, `auto` the locale's. */
|
|
99
|
+
hourCycle?: 'h12' | 'h23' | 'auto' | undefined
|
|
100
|
+
step?: number | undefined
|
|
101
|
+
/** Adds a seconds segment. */
|
|
102
|
+
seconds?: boolean | undefined
|
|
103
|
+
min?: IsoTime | undefined
|
|
104
|
+
max?: IsoTime | undefined
|
|
105
|
+
slots?: IsoTime[] | undefined
|
|
106
|
+
/** Only for the DST hint. */
|
|
107
|
+
date?: IsoDate | undefined
|
|
108
|
+
timeZone?: TimeZoneId | undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* TimeField (04 §2): segments in the locale's order and cycle; the trigger opens a slot list (wheels in a phone
|
|
113
|
+
* sheet). With `date` and `timeZone`, a wall time that doesn't exist or happens twice gets a hint, and a missing one
|
|
114
|
+
* resolves with `disambiguation: "compatible"`.
|
|
115
|
+
*/
|
|
116
|
+
export function TimeField({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, hourCycle, step = 15, seconds, min, max, slots, date, timeZone, locale, today, name, presentation, label, hint, error, disabled, readonly, ...rest }: TimeFieldProps) {
|
|
117
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
118
|
+
const v = bindable<IsoTime | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
119
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
120
|
+
const started = state(false)
|
|
121
|
+
const sheet = state(false)
|
|
122
|
+
const t = derived(() => messages(env.locale.value))
|
|
123
|
+
const options = derived(() => slots ?? stepSlots(step, min, max))
|
|
124
|
+
let trigger: HTMLButtonElement | undefined
|
|
125
|
+
const dst = derived(() => {
|
|
126
|
+
if (!v.value || !date || !timeZone) return undefined
|
|
127
|
+
const issue = wallTimeIssue(date, v.value, timeZone)
|
|
128
|
+
if (!issue) return undefined
|
|
129
|
+
const day = fmt(date, { month: 'short', day: 'numeric' }, env.locale.value)
|
|
130
|
+
const city = timeZone.split('/').pop()!.replace(/_/g, ' ')
|
|
131
|
+
const at = fmtTime(v.value, env.locale.value, hourCycle)
|
|
132
|
+
return issue.kind === 'gap' ? `${at} doesn't exist on ${day} in ${city}; using ${fmtTime(issue.resolved, env.locale.value, hourCycle)}.` : `${at} happens twice on ${day} in ${city}; using the first.`
|
|
133
|
+
})
|
|
134
|
+
const onEdit = (st: SegmentState, parts: SegmentPart[]) => {
|
|
135
|
+
started.value = st !== 'empty'
|
|
136
|
+
if (st === 'empty') return v.set(null, 'keyboard')
|
|
137
|
+
if (st === 'complete') { const x = readTime(parts); if (x) v.set(x, 'keyboard') }
|
|
138
|
+
}
|
|
139
|
+
const open_ = () => { sheet.value = isSheet(trigger!, presentation); pop.set(true) }
|
|
140
|
+
return (
|
|
141
|
+
<Composite
|
|
142
|
+
{...rest}
|
|
143
|
+
label={label}
|
|
144
|
+
hint={dst.value ?? hint}
|
|
145
|
+
error={error}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
readonly={readonly}
|
|
148
|
+
bound={v}
|
|
149
|
+
onReset={() => (started.value = false)}
|
|
150
|
+
filled={v.value !== null || started.value}
|
|
151
|
+
hold={pop.value}
|
|
152
|
+
control={() => (
|
|
153
|
+
<>
|
|
154
|
+
<Segments required={rest.required} kind="time" options={{ hourCycle, seconds }} locale={env.locale.value} value={v.value} today={env.today.value} disabled={disabled} readonly={readonly} onEdit={onEdit} />
|
|
155
|
+
<Trigger el={(n: HTMLButtonElement) => (trigger = n)} icon={ClockIcon} label={t.value.chooseTime + (v.value ? `, ${fmtTime(v.value, env.locale.value, hourCycle)}` : '')} open={pop.value} disabled={disabled} onToggle={() => (pop.value ? pop.set(false) : open_())} />
|
|
156
|
+
<Hidden name={name} value={() => v.value ?? ''} />
|
|
157
|
+
</>
|
|
158
|
+
)}
|
|
159
|
+
end={
|
|
160
|
+
<Panel label={t.value.chooseTime} open={pop} trigger={() => trigger!} presentation={presentation} align="end">
|
|
161
|
+
{() => (sheet.value
|
|
162
|
+
? <Wheels value={v.value} locale={env.locale.value} hourCycle={hourCycle} onChange={x => v.set(x, 'pointer')} />
|
|
163
|
+
: <Slots slots={options.value} selected={v.value} locale={env.locale.value} hourCycle={hourCycle} label={t.value.chooseTime} meta onPick={(x, r) => { v.set(x as IsoTime, r); pop.set(false) }} />)}
|
|
164
|
+
</Panel>
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Props for DateTimeField. */
|
|
171
|
+
export type DateTimeFieldProps = RichFieldProps & Bindable<IsoDateTime | null> & Openable & {
|
|
172
|
+
min?: IsoDate | undefined
|
|
173
|
+
max?: IsoDate | undefined
|
|
174
|
+
/** Return true for a day that can't be picked: it shows struck through. */
|
|
175
|
+
isDateUnavailable?: ((d: IsoDate) => boolean) | undefined
|
|
176
|
+
/** The day each calendar row starts on, 0 for Sunday to 6. The locale's otherwise. */
|
|
177
|
+
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined
|
|
178
|
+
step?: number | undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* DateTimeField (04 §2): a date group and a time group in one control and one popover, calendar beside the slot list.
|
|
183
|
+
* The value is a wall time with no zone; the hint spells it out in full.
|
|
184
|
+
*/
|
|
185
|
+
export function DateTimeField({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, min, max, isDateUnavailable, firstDayOfWeek, step = 30, locale, today, name, presentation, label, hint, error, disabled, readonly, ...rest }: DateTimeFieldProps) {
|
|
186
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
187
|
+
const v = bindable<IsoDateTime | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
188
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
189
|
+
const date = state<string | null>(untrack(() => v.value?.slice(0, 10) ?? null))
|
|
190
|
+
const time = state<string | null>(untrack(() => v.value?.slice(11) ?? null))
|
|
191
|
+
let trigger: HTMLButtonElement | undefined
|
|
192
|
+
const emit = (r: ChangeReason) => v.set(date.value && time.value ? `${date.value}T${time.value}` as IsoDateTime : null, r)
|
|
193
|
+
const said = derived(() => (v.value ? `${fmt(v.value, { dateStyle: 'full', timeStyle: 'short' }, env.locale.value)} · wall clock, no zone` : hint))
|
|
194
|
+
const onDate = (st: SegmentState, parts: SegmentPart[]) => {
|
|
195
|
+
const d = readDate(parts)
|
|
196
|
+
date.value = st === 'complete' && d.day! <= daysIn(d.year!, d.month!) ? isoOf(d) : null
|
|
197
|
+
emit('keyboard')
|
|
198
|
+
}
|
|
199
|
+
const onTime = (st: SegmentState, parts: SegmentPart[]) => { time.value = st === 'complete' ? readTime(parts) : null; emit('keyboard') }
|
|
200
|
+
return (
|
|
201
|
+
<Composite
|
|
202
|
+
{...rest}
|
|
203
|
+
label={label}
|
|
204
|
+
hint={said.value}
|
|
205
|
+
error={error}
|
|
206
|
+
disabled={disabled}
|
|
207
|
+
readonly={readonly}
|
|
208
|
+
bound={v}
|
|
209
|
+
onReset={() => { date.value = v.value?.slice(0, 10) ?? null; time.value = v.value?.slice(11) ?? null }}
|
|
210
|
+
filled={!!(date.value || time.value)}
|
|
211
|
+
hold={pop.value}
|
|
212
|
+
control={() => (
|
|
213
|
+
<>
|
|
214
|
+
<Segments required={rest.required} kind="date" label="Date" locale={env.locale.value} value={date.value} today={env.today.value} disabled={disabled} readonly={readonly} onEdit={onDate} />
|
|
215
|
+
<span data-part="literal" class="dt-sep" aria-hidden="true">·</span>
|
|
216
|
+
<Segments required={rest.required} kind="time" label="Time" locale={env.locale.value} value={time.value} today={env.today.value} disabled={disabled} readonly={readonly} onEdit={onTime} />
|
|
217
|
+
<Trigger el={(n: HTMLButtonElement) => (trigger = n)} icon={CalendarIcon} label="Choose date and time" open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} />
|
|
218
|
+
<Hidden name={name} value={() => v.value ?? ''} />
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
end={
|
|
222
|
+
<Panel label="Choose date and time" open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
223
|
+
{() => (
|
|
224
|
+
<div class="dt-pop">
|
|
225
|
+
<CalendarGrid mode="single" value={() => date.value as IsoDate | null} locale={env.locale.value} today={env.today.value} min={min} max={max} isDateUnavailable={isDateUnavailable} firstDayOfWeek={firstDayOfWeek} autofocus onPick={(iso, r) => { date.value = iso; emit(r) }} />
|
|
226
|
+
<div class="dt-side">
|
|
227
|
+
<p class="dt-cap">Time</p>
|
|
228
|
+
<Slots slots={stepSlots(step)} selected={time.value} locale={env.locale.value} label="Time" onPick={(x, r) => { time.value = x; emit(r); pop.set(false) }} />
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</Panel>
|
|
233
|
+
}
|
|
234
|
+
/>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
package/src/Toast.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { toaster, toastStore, type Toast, type ToastInput, type ToastPromise } from '@weft-js/warp-core'
|
|
3
|
+
import { onCleanup, state } from '@weft-js/core'
|
|
4
|
+
import { Icon, toneIcon } from './Icon'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/components/button.css' // the action and dismiss buttons' markup
|
|
8
|
+
import '@weft-js/warp-core/styles/components/spinner.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/components/toast.css'
|
|
10
|
+
|
|
11
|
+
let store: ReturnType<typeof toastStore> | undefined
|
|
12
|
+
const toasts = () => (store ??= toastStore())
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Show a toast from anywhere (05 §5): `toast.show({ title, description, tone, action })`. Needs one <Toaster />.
|
|
16
|
+
* Life is 5s + 60ms a word, 10s with an action; a critical toast with an action stays until dismissed.
|
|
17
|
+
*/
|
|
18
|
+
export const toast = {
|
|
19
|
+
show: (t: ToastInput) => toasts().show(t),
|
|
20
|
+
dismiss: (id: string) => toasts().dismiss(id),
|
|
21
|
+
/** One toast through a promise's life: `pending`, then `done` or `error`, in place. Returns the promise. */
|
|
22
|
+
promise: <T,>(p: Promise<T>, messages: ToastPromise<T>) => toasts().promise(p, messages),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The toast region and the two pre-mounted live regions (05 §5; components.html:1115-1117). Mount once, near the
|
|
27
|
+
* root. While a modal is open they move into it, or it would make them inert. Newest first; three stack, collapsed, and fan out on hover or focus (which pauses every timer); swipe or
|
|
28
|
+
* Dismiss removes one; F8 focuses the newest.
|
|
29
|
+
*/
|
|
30
|
+
export function Toaster({ position }: { /** Which corner or edge. Bottom right by default. */ position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' } = {}) {
|
|
31
|
+
const list = state<Toast[]>(toasts().get())
|
|
32
|
+
onCleanup(toasts().subscribe(v => (list.value = v)))
|
|
33
|
+
let region: HTMLElement | undefined
|
|
34
|
+
use(toaster, () => region, () => toasts())
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<section ref={n => (region = n)} data-scope="toast" data-part="region" data-position={position === 'bottom-right' ? undefined : position} aria-label="Notifications (F8)" tabindex="-1">
|
|
38
|
+
{list.value.map(t => (
|
|
39
|
+
<div key={t.id} data-scope="toast" data-part="root" data-id={t.id} data-tone={t.tone ?? 'neutral'} data-state={t.state} role="group" aria-label={t.title}>
|
|
40
|
+
{t.loading ? <span data-scope="spinner" data-part="icon" aria-hidden="true"></span> : <Icon name={toneIcon[t.tone ?? 'neutral']} data-part="icon" />}
|
|
41
|
+
<div data-part="title">{t.title}</div>
|
|
42
|
+
{t.description && <div data-part="description">{t.description}</div>}
|
|
43
|
+
<div data-part="actions">
|
|
44
|
+
{/* ghost <Button>s as their markup: they need no JS */}
|
|
45
|
+
{t.action && <button type="button" data-scope="button" data-emphasis="ghost" data-size="sm" data-act="" onClick={() => { t.action!.onClick(); toast.dismiss(t.id) }}>{t.action.label}</button>}
|
|
46
|
+
<button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-size="sm" data-icon-only="" aria-label="Dismiss" data-x="" onClick={() => toast.dismiss(t.id)}><XIcon /></button>
|
|
47
|
+
</div>
|
|
48
|
+
{t.life > 0 && <span data-part="progress" aria-hidden="true"></span>}
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</section>
|
|
52
|
+
<div data-scope="announcer" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
53
|
+
<div data-scope="announcer" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
|
54
|
+
</>
|
|
55
|
+
)
|
|
56
|
+
}
|
package/src/Toggle.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { derived } from '@weft-js/core'
|
|
2
|
+
import type { Interaction } from '@weft-js/core/interaction'
|
|
3
|
+
import { Icon, type IconRef } from './Icon'
|
|
4
|
+
import { useUi } from './use-ui'
|
|
5
|
+
import './styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/components/toggle-button.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/components/chip.css' // the pressed toggle's forced-colours rule lives with the chip
|
|
8
|
+
|
|
9
|
+
/** Props for Toggle. */
|
|
10
|
+
export type ToggleProps = {
|
|
11
|
+
ref?: ((el: HTMLButtonElement) => void) | undefined
|
|
12
|
+
onClick?: ((e: MouseEvent) => void) | undefined
|
|
13
|
+
/** Called as the pointer comes over the button. */
|
|
14
|
+
onPointerEnter?: ((e: PointerEvent) => void) | undefined
|
|
15
|
+
pressed: boolean
|
|
16
|
+
icon?: IconRef | undefined
|
|
17
|
+
label?: string | undefined
|
|
18
|
+
size?: 'sm' | 'md' | 'lg' | undefined
|
|
19
|
+
ui?: Interaction
|
|
20
|
+
children?: unknown
|
|
21
|
+
[attr: string]: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A toggle button (05 §2): `aria-pressed`, the sweep held while pressed. */
|
|
25
|
+
export function Toggle({ pressed, icon, label, size, ui, ref, children, onClick, onPointerEnter, ...rest }: ToggleProps) {
|
|
26
|
+
let host: HTMLButtonElement | undefined
|
|
27
|
+
useUi(() => ui, () => host)
|
|
28
|
+
const glyph = derived(() => icon)
|
|
29
|
+
// Released from pressed, the fill goes at once; the held stroke goes with it when the pointer and focus leave, so it
|
|
30
|
+
// doesn't retract across the face as a stray bracket. A plain hover keeps the sweep's own exit.
|
|
31
|
+
const click = (e: MouseEvent) => {
|
|
32
|
+
if (host?.getAttribute('aria-pressed') === 'true') host.dataset.released = ''
|
|
33
|
+
else delete host?.dataset.released
|
|
34
|
+
onClick?.(e)
|
|
35
|
+
}
|
|
36
|
+
const enter = (e: PointerEvent) => { if (host?.getAttribute('aria-pressed') !== 'true') delete host?.dataset.released; onPointerEnter?.(e) }
|
|
37
|
+
return (
|
|
38
|
+
<button type="button" data-scope="toggle" aria-pressed={pressed ? 'true' : 'false'} aria-label={label} data-size={size === 'md' ? undefined : size} data-sweep="" {...rest} onClick={click} onPointerEnter={enter} ref={n => { host = n; ref?.(n) }}>
|
|
39
|
+
{glyph.value && <Icon name={glyph.value} />}{children}<span data-part="sweep" aria-hidden="true"></span>
|
|
40
|
+
</button>
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/Tooltip.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { tooltip, uid, type Align, type Side } from '@weft-js/warp-core'
|
|
2
|
+
import { untrack } from '@weft-js/core'
|
|
3
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/components/tooltip.css'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A hint on hover and keyboard focus, never essential (05 §3, §10 H1): `children` is the trigger (one focusable
|
|
9
|
+
* element); the tip follows it in the DOM as a manual popover in the top layer. The trigger is described by the tip
|
|
10
|
+
* unless its accessible name already says the same words (an icon button named "Settings" with tip "Settings").
|
|
11
|
+
* `inline` draws the tip shown, under its trigger in the page's flow, for docs and previews: no top layer, no hover wait.
|
|
12
|
+
*/
|
|
13
|
+
export function Tooltip({ content, kbd, side, align, inline, children }: { content: string; kbd?: string; side?: Side; align?: Align; /** Shown under the trigger, in the page's flow: no top layer or hover wait. For docs and previews. */ inline?: boolean; children: unknown }) {
|
|
14
|
+
const id = uid('tip')
|
|
15
|
+
const flat = untrack(() => !!inline)
|
|
16
|
+
let tip: HTMLDivElement | undefined
|
|
17
|
+
// the trigger is the element before the tip; it is described by the tip unless its name already says the same
|
|
18
|
+
const attach = (el: HTMLElement, o: { side?: Side | undefined; align?: Align | undefined }) => {
|
|
19
|
+
const trigger = el.previousElementSibling as HTMLElement
|
|
20
|
+
if (trigger.getAttribute('aria-label') !== el.textContent) trigger.setAttribute('aria-describedby', [trigger.getAttribute('aria-describedby'), id].filter(Boolean).join(' '))
|
|
21
|
+
if (flat) return { update() {}, destroy() {} } // shown in flow: nothing to place, open or close
|
|
22
|
+
const action = tooltip(el, { trigger, side: o.side, align: o.align })
|
|
23
|
+
return { update: (n: { side?: Side | undefined; align?: Align | undefined }) => action.update({ trigger, side: n.side, align: n.align }), destroy: () => action.destroy() }
|
|
24
|
+
}
|
|
25
|
+
use(attach, () => tip, () => ({ side, align }))
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
{children}
|
|
29
|
+
<div ref={n => (tip = n)} data-scope="tooltip" data-part="content" role="tooltip" popover={flat ? undefined : 'manual'} data-presentation={flat ? 'inline' : undefined} id={id}>{content}{kbd && <kbd>{kbd}</kbd>}</div>
|
|
30
|
+
</>
|
|
31
|
+
)
|
|
32
|
+
}
|
package/src/Type.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Type by role (01 §2, PLAN §6): the spec defines the roles as tokens and leaves the element to the app, so these
|
|
2
|
+
// are what an app reads them through. A size, weight or colour is never a prop.
|
|
3
|
+
import { untrack } from '@weft-js/core'
|
|
4
|
+
import type { TextRole, Tone } from '@weft-js/core/ds'
|
|
5
|
+
import './styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/components/link.css'
|
|
7
|
+
import './type.css'
|
|
8
|
+
|
|
9
|
+
type Open = { children?: unknown; [attr: string]: unknown }
|
|
10
|
+
|
|
11
|
+
type HeadingRole = 'display' | 'title' | 'heading' | 'label'
|
|
12
|
+
|
|
13
|
+
/** The role each level takes by default, so a page's `h1` is one size everywhere (`weft check` keeps the same table: D006). */
|
|
14
|
+
const ROLE: Record<number, HeadingRole> = { 1: 'title', 2: 'heading', 3: 'heading', 4: 'label', 5: 'label', 6: 'label' }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A real heading: `level` is the document outline (default 2) and picks the role: `title` for h1, `heading` for h2 and
|
|
18
|
+
* h3, `label` below. `role` overrides the look, so the two can differ, and `weft check` flags a level that renders at
|
|
19
|
+
* two roles in one app (D006). It follows `level` for skips too (D035).
|
|
20
|
+
*/
|
|
21
|
+
export function Heading({ level = 2, role, children, ...rest }: Open & { level?: 1 | 2 | 3 | 4 | 5 | 6 | undefined; role?: HeadingRole | undefined }) {
|
|
22
|
+
const n = untrack(() => level)
|
|
23
|
+
const look = () => role ?? ROLE[n]
|
|
24
|
+
if (n === 1) return <h1 data-scope="text" data-role={look()} {...rest}>{children}</h1>
|
|
25
|
+
if (n === 2) return <h2 data-scope="text" data-role={look()} {...rest}>{children}</h2>
|
|
26
|
+
if (n === 3) return <h3 data-scope="text" data-role={look()} {...rest}>{children}</h3>
|
|
27
|
+
if (n === 4) return <h4 data-scope="text" data-role={look()} {...rest}>{children}</h4>
|
|
28
|
+
if (n === 5) return <h5 data-scope="text" data-role={look()} {...rest}>{children}</h5>
|
|
29
|
+
return <h6 data-scope="text" data-role={look()} {...rest}>{children}</h6>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Text in a role. A paragraph by default; `inline` makes it a span. `tone` colours it, `subtle` and `faint` quieten it. */
|
|
33
|
+
export function Text({ role, tone, subtle, faint, inline, children, ...rest }: Open & { role?: TextRole; tone?: Tone | undefined; subtle?: boolean; faint?: boolean; inline?: boolean }) {
|
|
34
|
+
const ink = () => (subtle ? 'subtle' : faint ? 'faint' : undefined)
|
|
35
|
+
if (untrack(() => inline)) return <span data-scope="text" data-role={role} data-tone={tone} data-ink={ink()} {...rest}>{children}</span>
|
|
36
|
+
return <p data-scope="text" data-role={role} data-tone={tone} data-ink={ink()} {...rest}>{children}</p>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A link (05 §4): underlined, with the underline sweep. `subtle` takes the surrounding ink, and `external` opens a new
|
|
41
|
+
* tab and says so. A bare `<a>` gets warp's colour from the base layer; this adds the rest, and D007 points to it.
|
|
42
|
+
*/
|
|
43
|
+
export function Link({ href, subtle, external, children, ...rest }: Open & { href: string; subtle?: boolean | undefined; external?: boolean | undefined }) {
|
|
44
|
+
if (untrack(() => external)) return <a data-scope="link" data-variant={subtle ? 'subtle' : undefined} href={href} target="_blank" rel="noopener noreferrer" {...rest}>{children}<span class="vh"> (opens in a new tab)</span></a>
|
|
45
|
+
return <a data-scope="link" data-variant={subtle ? 'subtle' : undefined} href={href} {...rest}>{children}</a>
|
|
46
|
+
}
|