@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,221 @@
|
|
|
1
|
+
import { CalendarIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { addDays, daysIn, fmt, fmtRange, isoOf, isUnavailable, pickRange, readDate, toIso, daysBetween, defaultPresets, listKey, weekStart, type ChangeReason, type DateRange, type IsoDate, type RangePreset, type SegmentPart } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { CalendarGrid } from './Calendar'
|
|
6
|
+
import { Composite, Hidden, isSheet, messages, Panel, Trigger, bindable, useOpen, usePickerEnv, type Bindable, type Openable, type RichFieldProps } from './Picker'
|
|
7
|
+
import { Segments, type SegmentState } from './Segments'
|
|
8
|
+
|
|
9
|
+
/** Props for DateField. */
|
|
10
|
+
export type DateFieldProps = RichFieldProps & Bindable<IsoDate | null> & Openable & {
|
|
11
|
+
min?: IsoDate | undefined
|
|
12
|
+
max?: IsoDate | undefined
|
|
13
|
+
isDateUnavailable?: ((d: IsoDate) => boolean) | undefined
|
|
14
|
+
/** The day each calendar row starts on, 0 for Sunday to 6. The locale's otherwise. */
|
|
15
|
+
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined
|
|
16
|
+
/** Shows the week number at the start of each calendar row. */
|
|
17
|
+
weekNumbers?: boolean | undefined
|
|
18
|
+
parse?: ((text: string, today: IsoDate) => IsoDate | null) | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DateField (04 §2): segments in the field shell and a calendar in the popover (a sheet on phones), Today and Clear in
|
|
23
|
+
* the foot. `onValueChange` fires only with a complete, valid date or null; a half-typed date stays in the segments
|
|
24
|
+
* and `error` explains it on blur. The shell's `error` prop, when given, wins.
|
|
25
|
+
*/
|
|
26
|
+
export function DateField({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, min, max, isDateUnavailable, firstDayOfWeek, weekNumbers, parse: _parse, locale, today, name, presentation, label, hint, error, disabled, readonly, ...rest }: DateFieldProps) {
|
|
27
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
28
|
+
const v = bindable<IsoDate | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
29
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
30
|
+
const own = state<string | null>(null)
|
|
31
|
+
const started = state(false)
|
|
32
|
+
const clears = state(0)
|
|
33
|
+
const t = derived(() => messages(env.locale.value))
|
|
34
|
+
let trigger: HTMLButtonElement | undefined
|
|
35
|
+
const ctx = () => ({ mode: 'single' as const, value: v.value, today: env.today.value, locale: env.locale.value, min, max, isDateUnavailable })
|
|
36
|
+
const onEdit = (st: SegmentState, parts: SegmentPart[]) => {
|
|
37
|
+
started.value = st !== 'empty'
|
|
38
|
+
own.value = null
|
|
39
|
+
const take = (iso: IsoDate | null) => { if (iso !== untrack(() => v.value)) v.set(iso, 'keyboard') }
|
|
40
|
+
if (st !== 'complete') return take(null)
|
|
41
|
+
const d = readDate(parts)
|
|
42
|
+
if (d.day! > daysIn(d.year!, d.month!)) { own.value = `${fmt(toIso(d.year!, d.month!, 1), { month: 'long', year: 'numeric' }, env.locale.value)} has ${daysIn(d.year!, d.month!)} days.`; return take(null) }
|
|
43
|
+
const iso = isoOf(d)
|
|
44
|
+
if (isUnavailable(iso, ctx())) { own.value = `${fmt(iso, { dateStyle: 'medium' }, env.locale.value)} is ${t.value.unavailable}.`; return take(null) }
|
|
45
|
+
take(iso)
|
|
46
|
+
}
|
|
47
|
+
const choose = (iso: IsoDate | null, reason: ChangeReason) => { own.value = null; v.set(iso, reason); pop.set(false); if (iso === null) { clears.value++; started.value = false } }
|
|
48
|
+
return (
|
|
49
|
+
<Composite
|
|
50
|
+
{...rest}
|
|
51
|
+
label={label}
|
|
52
|
+
hint={hint}
|
|
53
|
+
error={error ?? own.value ?? undefined}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
readonly={readonly}
|
|
56
|
+
bound={v}
|
|
57
|
+
onReset={() => { own.value = null; started.value = false }}
|
|
58
|
+
filled={v.value !== null || started.value}
|
|
59
|
+
hold={pop.value}
|
|
60
|
+
attrs={{ onClick: (e: MouseEvent) => { if (!disabled && isSheet(e.currentTarget as Element, presentation) && (e.target as Element).closest?.('[data-part="segment"]')) pop.set(true) } }}
|
|
61
|
+
control={() => (
|
|
62
|
+
<>
|
|
63
|
+
<Segments required={rest.required} kind="date" locale={env.locale.value} value={v.value} today={env.today.value} clears={clears.value} disabled={disabled} readonly={readonly} onEdit={onEdit} onBlurPartial={() => (own.value = 'Enter a complete date, or pick one from the calendar.')} />
|
|
64
|
+
<Trigger el={(n: HTMLButtonElement) => (trigger = n)} icon={CalendarIcon} label={t.value.choose + (v.value ? `, ${fmt(v.value, { dateStyle: 'full' }, env.locale.value)}` : '')} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} />
|
|
65
|
+
<Hidden name={name} value={() => v.value ?? ''} />
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
end={
|
|
69
|
+
<Panel label={t.value.choose} open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
70
|
+
{() => (
|
|
71
|
+
<CalendarGrid mode="single" value={() => v.value} locale={env.locale.value} today={env.today.value} min={min} max={max} isDateUnavailable={isDateUnavailable} firstDayOfWeek={firstDayOfWeek} weekNumbers={weekNumbers} autofocus onPick={choose}>
|
|
72
|
+
<div data-part="foot">
|
|
73
|
+
<Button emphasis="ghost" size="sm" onClick={() => choose(env.today.value as IsoDate, 'pointer')}>{t.value.todayBtn}</Button>
|
|
74
|
+
<Button emphasis="ghost" size="sm" onClick={() => choose(null, 'pointer')}>{t.value.clear}</Button>
|
|
75
|
+
</div>
|
|
76
|
+
</CalendarGrid>
|
|
77
|
+
)}
|
|
78
|
+
</Panel>
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type RangeCompare = 'previous-period' | 'previous-year' | DateRange
|
|
85
|
+
/** Props for DateRangeField. */
|
|
86
|
+
export type DateRangeFieldProps = RichFieldProps & Bindable<DateRange> & Openable & {
|
|
87
|
+
presets?: RangePreset[] | 'default' | false
|
|
88
|
+
/** The id of the chosen preset, such as "Last 7 days". Null when the dates were picked by hand. */
|
|
89
|
+
preset?: string | null
|
|
90
|
+
/** Called with the preset's id when one is picked, and null when the dates are changed by hand. */
|
|
91
|
+
onPresetChange?: (id: string | null) => void
|
|
92
|
+
min?: IsoDate | undefined
|
|
93
|
+
max?: IsoDate | undefined
|
|
94
|
+
maxDays?: number
|
|
95
|
+
/** Return true for a day that can't start or end the range: it shows struck through. */
|
|
96
|
+
isDateUnavailable?: (d: IsoDate) => boolean
|
|
97
|
+
months?: 1 | 2 | 'auto'
|
|
98
|
+
compare?: RangeCompare | null | undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The comparison range of a selection, for a dashboard's "vs" (04 DateRangeField). */
|
|
102
|
+
export function compareRange(v: DateRange, c: RangeCompare | null | undefined): DateRange | null {
|
|
103
|
+
if (!c || !v.start || !v.end) return typeof c === 'object' ? c : null
|
|
104
|
+
if (typeof c === 'object') return c
|
|
105
|
+
if (c === 'previous-year') return { start: `${+v.start.slice(0, 4) - 1}${v.start.slice(4)}` as IsoDate, end: `${+v.end.slice(0, 4) - 1}${v.end.slice(4)}` as IsoDate }
|
|
106
|
+
const n = daysBetween(v.start, v.end) + 1
|
|
107
|
+
return { start: addDays(v.start, -n), end: addDays(v.start, -1) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* DateRangeField (04 §2): two segment groups and one trigger; the popover shows two months (one when narrow) beside a
|
|
112
|
+
* preset rail. The chosen preset is kept apart from the frozen dates, so a dashboard's "Last 7 days" can roll forward.
|
|
113
|
+
*/
|
|
114
|
+
export function DateRangeField({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, presets = 'default', preset, onPresetChange, min, max, maxDays, isDateUnavailable, months = 'auto', compare, locale, today, name, presentation, label, hint, error, disabled, readonly, ...rest }: DateRangeFieldProps) {
|
|
115
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
116
|
+
const v = bindable<DateRange>(() => value, () => defaultValue, { start: null, end: null }, () => onValueChange)
|
|
117
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
118
|
+
const chosen = state<string | null>(untrack(() => preset) ?? null)
|
|
119
|
+
const jump = state<string | null>(null)
|
|
120
|
+
const started = state(false)
|
|
121
|
+
const narrow = state(false)
|
|
122
|
+
const t = derived(() => messages(env.locale.value))
|
|
123
|
+
const list = derived(() => (presets === false ? [] : presets === 'default' ? defaultPresets(env.locale.value) : presets))
|
|
124
|
+
const count = derived(() => (months === 'auto' ? (narrow.value ? 1 : 2) : months))
|
|
125
|
+
const compared = derived(() => compareRange(v.value, compare))
|
|
126
|
+
const trouble = derived(() => {
|
|
127
|
+
const r = v.value
|
|
128
|
+
if (r.start && r.end && r.end < r.start) return 'The end is before the start.'
|
|
129
|
+
if (r.start && r.end && maxDays && daysBetween(r.start, r.end) + 1 > maxDays) return `Choose at most ${maxDays} days.`
|
|
130
|
+
return undefined
|
|
131
|
+
})
|
|
132
|
+
const human = (d: string) => fmt(d, { dateStyle: 'medium' }, env.locale.value)
|
|
133
|
+
const summary = derived(() => {
|
|
134
|
+
const r = v.value
|
|
135
|
+
if (!r.start) return 'Pick a start date'
|
|
136
|
+
if (!r.end) return 'Pick an end date'
|
|
137
|
+
const n = daysBetween(r.start, r.end) + 1
|
|
138
|
+
return `${r.start > r.end ? `${human(r.start)} – ${human(r.end)}` : fmtRange(r.start, r.end, env.locale.value)} · ${new Intl.NumberFormat(env.locale.value, { style: 'unit', unit: 'day', unitDisplay: 'long' }).format(n)}`
|
|
139
|
+
})
|
|
140
|
+
const said = state('')
|
|
141
|
+
let trigger: HTMLButtonElement | undefined
|
|
142
|
+
const set = (r: DateRange, reason: ChangeReason, id: string | null = null) => {
|
|
143
|
+
chosen.value = id
|
|
144
|
+
onPresetChange?.(id)
|
|
145
|
+
v.set(r, reason)
|
|
146
|
+
}
|
|
147
|
+
const edit = (which: 'start' | 'end') => (st: SegmentState, parts: SegmentPart[]) => {
|
|
148
|
+
started.value = st !== 'empty' || !!(which === 'start' ? v.value.end : v.value.start)
|
|
149
|
+
if (st === 'empty') return set({ ...v.value, [which]: null }, 'keyboard')
|
|
150
|
+
if (st !== 'complete') return
|
|
151
|
+
const d = readDate(parts)
|
|
152
|
+
if (d.day! <= daysIn(d.year!, d.month!)) set({ ...v.value, [which]: isoOf(d) }, 'keyboard')
|
|
153
|
+
}
|
|
154
|
+
const editStart = edit('start'), editEnd = edit('end')
|
|
155
|
+
const applyPreset = (p: RangePreset) => {
|
|
156
|
+
const r = p.range(env.today.value as IsoDate, weekStart(env.locale.value))
|
|
157
|
+
set(r, 'pointer', p.id)
|
|
158
|
+
jump.value = r.end
|
|
159
|
+
said.value = `${p.label}: ${summary.value}`
|
|
160
|
+
}
|
|
161
|
+
const onRail = (e: KeyboardEvent) => {
|
|
162
|
+
const opts = list.value
|
|
163
|
+
const at = opts.findIndex(p => p.id === (e.target as HTMLElement).dataset.id)
|
|
164
|
+
if (at < 0) return
|
|
165
|
+
const step = narrow.value ? ({ ArrowRight: 'ArrowDown', ArrowLeft: 'ArrowUp' } as Record<string, string>)[e.key] ?? e.key : e.key
|
|
166
|
+
const m = listKey(step, at, opts.length)
|
|
167
|
+
if (!m.handled) return
|
|
168
|
+
e.preventDefault()
|
|
169
|
+
if (m.pick) return applyPreset(opts[at]!)
|
|
170
|
+
;(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-id="${opts[m.to]!.id}"]`)?.focus()
|
|
171
|
+
}
|
|
172
|
+
const toggle = () => {
|
|
173
|
+
narrow.value = isSheet(trigger!, presentation) || (trigger!.ownerDocument.defaultView?.innerWidth ?? 1024) < 640
|
|
174
|
+
pop.set(!pop.value)
|
|
175
|
+
}
|
|
176
|
+
return (
|
|
177
|
+
<Composite
|
|
178
|
+
{...rest}
|
|
179
|
+
label={label}
|
|
180
|
+
hint={hint}
|
|
181
|
+
error={error ?? trouble.value}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
readonly={readonly}
|
|
184
|
+
bound={v}
|
|
185
|
+
onReset={() => { started.value = false; chosen.value = untrack(() => preset) ?? null }}
|
|
186
|
+
filled={!!(v.value.start || v.value.end) || started.value}
|
|
187
|
+
hold={pop.value}
|
|
188
|
+
control={() => (
|
|
189
|
+
<>
|
|
190
|
+
<Segments required={rest.required} kind="date" label="Start" locale={env.locale.value} value={v.value.start} today={env.today.value} disabled={disabled} readonly={readonly} onEdit={editStart} />
|
|
191
|
+
<span data-part="range-sep" aria-hidden="true">–</span>
|
|
192
|
+
<Segments required={rest.required} kind="date" label="End" locale={env.locale.value} value={v.value.end} today={env.today.value} disabled={disabled} readonly={readonly} onEdit={editEnd} />
|
|
193
|
+
<Trigger el={(n: HTMLButtonElement) => (trigger = n)} icon={CalendarIcon} label={t.value.chooseRange + (v.value.start && v.value.end ? `, ${summary.value}` : '')} open={pop.value} disabled={disabled} onToggle={toggle} />
|
|
194
|
+
<Hidden name={name} value={() => (v.value.start && v.value.end ? `${v.value.start}/${v.value.end}` : '')} />
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
end={
|
|
198
|
+
<Panel label={t.value.chooseRange} open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
199
|
+
{() => (
|
|
200
|
+
<div data-scope="range-panel" data-narrow={narrow.value ? '' : undefined}>
|
|
201
|
+
{list.value.length > 0 && (
|
|
202
|
+
<ul role="listbox" aria-label="Presets" aria-orientation={narrow.value ? 'horizontal' : 'vertical'} onKeyDown={onRail}>
|
|
203
|
+
{list.value.map(p => <li key={p.id} role="option" data-id={p.id} tabindex={p.id === (chosen.value ?? list.value[0]!.id) ? '0' : '-1'} aria-selected={p.id === chosen.value ? 'true' : 'false'} onClick={() => applyPreset(p)}>{p.label}</li>)}
|
|
204
|
+
</ul>
|
|
205
|
+
)}
|
|
206
|
+
<div>
|
|
207
|
+
<CalendarGrid mode="range" months={count.value} value={() => v.value} compare={() => compared.value} goTo={() => jump.value} locale={env.locale.value} today={env.today.value} min={min} max={max} isDateUnavailable={isDateUnavailable} autofocus onPick={(iso, r) => set(pickRange(v.value, iso), r)}>
|
|
208
|
+
<div data-part="foot">
|
|
209
|
+
<span aria-live="polite">{summary.value}</span>
|
|
210
|
+
<Button emphasis="ghost" size="sm" onClick={() => set({ start: null, end: null }, 'pointer')}>{t.value.clear}</Button>
|
|
211
|
+
</div>
|
|
212
|
+
</CalendarGrid>
|
|
213
|
+
</div>
|
|
214
|
+
<span class="sr" role="status">{said.value}</span>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</Panel>
|
|
218
|
+
}
|
|
219
|
+
/>
|
|
220
|
+
)
|
|
221
|
+
}
|
package/src/Dither.tsx
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ditherField, ditherImage, ditherStage, type Algorithm, type FieldOptions, type Gradient, type MaskSource, type Ramp, type DitherStageOptions } from '@weft-js/warp-core/dither'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/canvas/dither.css'
|
|
6
|
+
import '@weft-js/warp-core/styles/canvas/stage.css'
|
|
7
|
+
|
|
8
|
+
export type { Algorithm, Brush, DitherPainter, DitherPiece, Field as FieldFn, FieldName, Gradient, MaskSource, Pattern, PointerMode, Ramp, Skin } from '@weft-js/warp-core/dither'
|
|
9
|
+
export { LEVEL, PAINTERS } from '@weft-js/warp-core/dither'
|
|
10
|
+
|
|
11
|
+
const box = (ratio: string | number | undefined, style: unknown) => [ratio === undefined ? undefined : `aspect-ratio:${ratio}`, style].filter(Boolean).join(';') || undefined
|
|
12
|
+
|
|
13
|
+
/** Props for DitherField. */
|
|
14
|
+
export interface DitherFieldProps extends FieldOptions {
|
|
15
|
+
/** Fills the nearest positioned ancestor: a section's background. */
|
|
16
|
+
fill?: boolean
|
|
17
|
+
/** CSS aspect-ratio. Default 16 / 9 unless `fill`. */
|
|
18
|
+
ratio?: string | number
|
|
19
|
+
/** Names the field (role="img"). Without one it is decorative. */
|
|
20
|
+
label?: string | undefined
|
|
21
|
+
children?: unknown
|
|
22
|
+
[attr: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A live dithered field (08 §6.7): moves at vivid and balanced, holds still at quiet and under reduced motion. Children sit over it. */
|
|
26
|
+
export function DitherField({ kind, smooth, ramp, spark, pattern, cell, inks, speed, gain, seed, spread, pointer, mask, fade, reveal, fps, fill, ratio, label, children, style, ...rest }: DitherFieldProps) {
|
|
27
|
+
let node: HTMLCanvasElement | undefined
|
|
28
|
+
use(ditherField, () => node, () => ({ kind, smooth, ramp, spark, pattern, cell, inks, speed, gain, seed, spread, pointer, mask, fade, reveal, fps }))
|
|
29
|
+
return (
|
|
30
|
+
<div {...rest} data-scope="dither" data-fill={fill ? '' : undefined} role={label ? 'img' : undefined} aria-label={label} style={box(ratio ?? (fill ? undefined : '16 / 9'), style)}>
|
|
31
|
+
<canvas ref={n => (node = n)} aria-hidden="true"></canvas>
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Props for Dither. */
|
|
38
|
+
export interface DitherProps {
|
|
39
|
+
/** An image to dither. The real <img> stays underneath, for alt text, for no script and for a cross-origin failure. */
|
|
40
|
+
src?: string
|
|
41
|
+
/** The image's alt text, kept on the real <img> underneath. */
|
|
42
|
+
alt?: string
|
|
43
|
+
/** The image's CORS mode. A cross-origin image needs it to be read and dithered. */
|
|
44
|
+
crossorigin?: 'anonymous' | 'use-credentials'
|
|
45
|
+
/** With no `src`: the gradient to dither. Default a soft diagonal. */
|
|
46
|
+
gradient?: Gradient | undefined
|
|
47
|
+
/** `atkinson` by default for images, `bayer8` for gradients. */
|
|
48
|
+
algorithm?: Algorithm
|
|
49
|
+
/** The inks: a tone such as `accent`, or your own list of colours. */
|
|
50
|
+
ramp?: Ramp
|
|
51
|
+
/** CSS pixels per dither cell. Larger cells give a coarser, chunkier picture. */
|
|
52
|
+
cell?: number
|
|
53
|
+
/** How many inks of the ramp to use. Default: the tier's. */
|
|
54
|
+
inks?: number
|
|
55
|
+
contrast?: number
|
|
56
|
+
invert?: boolean
|
|
57
|
+
/** Fills the nearest positioned parent instead of keeping a ratio, for a section background. */
|
|
58
|
+
fill?: boolean
|
|
59
|
+
ratio?: string | number
|
|
60
|
+
label?: string | undefined
|
|
61
|
+
[attr: string]: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A still image or gradient, dithered to warp's inks once, and again only when its size or theme changes (08 §6.7). */
|
|
65
|
+
export function Dither({ src, alt, crossorigin, gradient, algorithm, ramp, cell, inks, contrast, invert, fill, ratio, label, style, ...rest }: DitherProps) {
|
|
66
|
+
let node: HTMLCanvasElement | undefined
|
|
67
|
+
let img: HTMLImageElement | undefined
|
|
68
|
+
use(ditherImage, () => node, () => ({ source: img, gradient, algorithm, ramp, cell, inks, contrast, invert }))
|
|
69
|
+
return (
|
|
70
|
+
<div {...rest} data-scope="dither" data-fill={fill ? '' : undefined} role={label ? 'img' : undefined} aria-label={label} style={box(ratio ?? (fill ? undefined : '16 / 9'), style)}>
|
|
71
|
+
{src && <img ref={n => (img = n)} src={src} alt={alt ?? ''} crossorigin={crossorigin} loading="lazy" decoding="async" />}
|
|
72
|
+
<canvas ref={n => (node = n)} aria-hidden="true"></canvas>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Props for DitherGlyph. */
|
|
78
|
+
export interface DitherGlyphProps extends Omit<FieldOptions, 'mask'> {
|
|
79
|
+
/** The word to set. It stays in the page as real text for readers and for search. */
|
|
80
|
+
text?: string | undefined
|
|
81
|
+
/** Or an SVG path and the size of its box, for a logo or an icon. */
|
|
82
|
+
path?: string
|
|
83
|
+
box?: [width: number, height: number]
|
|
84
|
+
/** Stroke width in the box's units, for a path made of lines. */
|
|
85
|
+
stroke?: number | undefined
|
|
86
|
+
/** Or any drawing, in cells. */
|
|
87
|
+
draw?: (ctx: CanvasRenderingContext2D, cols: number, rows: number) => void
|
|
88
|
+
/** Names a `path` or `draw` glyph. */
|
|
89
|
+
label?: string | undefined
|
|
90
|
+
font?: string
|
|
91
|
+
weight?: number | string
|
|
92
|
+
/** Share of the box the shape fills, 0–1. */
|
|
93
|
+
fit?: number | undefined
|
|
94
|
+
ratio?: string | number
|
|
95
|
+
[attr: string]: unknown
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A word or a logo woven out of a dithered field (08 §6.7). */
|
|
99
|
+
export function DitherGlyph({ text, path, box: viewBox, stroke, draw, label, font, weight, fit, cell = 4, ratio, kind = 'flow', ...rest }: DitherGlyphProps) {
|
|
100
|
+
const mask = derived<MaskSource>(() => draw ?? (path ? { path, box: viewBox ?? [24, 24], fit, stroke } : { text: text ?? '', font, weight, fit }))
|
|
101
|
+
return (
|
|
102
|
+
<DitherField {...rest} kind={kind} cell={cell} mask={mask.value} ratio={ratio ?? '3 / 1'} label={text ? undefined : label}>
|
|
103
|
+
{text && <span data-part="glyph-text">{text}</span>}
|
|
104
|
+
</DitherField>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Props for DitherStage. */
|
|
109
|
+
export interface DitherStageProps extends DitherStageOptions {
|
|
110
|
+
/** `box` paints its own block; `scroll` is a scroller whose view it paints; `page` paints the viewport, for a whole page. */
|
|
111
|
+
layout?: DitherStageOptions['layout']
|
|
112
|
+
children?: unknown
|
|
113
|
+
[attr: string]: unknown
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A CanvasStage in the dither look (08 §6.8). Mark any element `data-skin="button"`, `card`, `field` and so on: it keeps
|
|
118
|
+
* its layout, focus, typing and semantics, and the stage draws it in dithered cells, states and all.
|
|
119
|
+
*/
|
|
120
|
+
export function DitherStage({ layout = 'box', ramp, kind, ground, pattern, cell, speed, drift, rail, veil, ripple, painters, fps, children, ...rest }: DitherStageProps) {
|
|
121
|
+
let node: HTMLCanvasElement | undefined
|
|
122
|
+
use(ditherStage, () => node, () => ({ layout, ramp, kind, ground, pattern, cell, speed, drift, rail, veil, ripple, painters, fps }))
|
|
123
|
+
return (
|
|
124
|
+
<div {...rest} data-scope="stage" data-paint="dither" data-layout={layout} data-rail={rail ? '' : undefined} data-veil={veil ? '' : undefined}>
|
|
125
|
+
<div data-part="paint" aria-hidden="true"><canvas ref={n => (node = n)}></canvas></div>
|
|
126
|
+
{children}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
package/src/Drawer.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { dialog, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, type State, untrack } from '@weft-js/core'
|
|
4
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
5
|
+
import './styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/components/button.css' // the close button's markup
|
|
7
|
+
import '@weft-js/warp-core/styles/components/sheet.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/components/dialog.css' // the backdrop and the parts a sheet shares with the dialog
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A sheet (05 §3 Drawer/Sheet; components.html:1092-1106): on a native <dialog>, at the side on wide screens and from
|
|
12
|
+
* the bottom on phones (`side="auto"`: bottom under 600px, decided when it opens). The handle shows only at the
|
|
13
|
+
* bottom. Initial focus goes to [autofocus], else the title, never the handle. `inline` draws it open in the page's flow,
|
|
14
|
+
* for docs and previews: a non-modal <dialog open> at its side's shape, with no top layer, scrim or focus trap.
|
|
15
|
+
*/
|
|
16
|
+
export function Drawer({ open, title, description, footer, side = 'auto', inline, children }: { open: State<boolean>; title: unknown; description?: unknown; footer?: unknown; side?: 'auto' | 'right' | 'left' | 'bottom' | 'top'; /** Drawn open in the page's flow, not over it: no top layer, scrim or focus trap. For docs and previews. */ inline?: boolean; children?: unknown }) {
|
|
17
|
+
const id = uid('sheet')
|
|
18
|
+
const hasDescription = derived(() => description !== undefined)
|
|
19
|
+
const foot = derived(() => footer) // one build of the footer, however often it is read
|
|
20
|
+
let el: HTMLDialogElement | undefined
|
|
21
|
+
const placed = () => (side === 'auto' ? (typeof innerWidth === 'number' && innerWidth < 600 ? 'bottom' : 'right') : side)
|
|
22
|
+
const flat = untrack(() => !!inline)
|
|
23
|
+
if (!flat) use(dialog, () => el, () => {
|
|
24
|
+
const on = open.value
|
|
25
|
+
if (on && el) el.dataset.side = placed()
|
|
26
|
+
return { open: on, onClose: () => (open.value = false) }
|
|
27
|
+
})
|
|
28
|
+
const closer = (e: Event) => { if (flat && (e.target as Element).closest?.('[data-close]')) open.value = false }
|
|
29
|
+
return (
|
|
30
|
+
<dialog ref={n => (el = n)} open={flat ? open.value : undefined} data-presentation={flat ? 'inline' : undefined} onClick={closer} data-scope="sheet" aria-labelledby={`${id}-t`} aria-describedby={hasDescription.value ? `${id}-d` : undefined} data-side={untrack(() => (side === 'auto' ? 'right' : side))}>
|
|
31
|
+
<button type="button" data-part="handle" aria-label="Sheet height: tall. Press to shrink."></button>
|
|
32
|
+
<div data-part="header">
|
|
33
|
+
<h2 data-part="title" id={`${id}-t`}>{title}</h2>
|
|
34
|
+
{hasDescription.value && <p data-part="description" id={`${id}-d`}>{description}</p>}
|
|
35
|
+
<button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-icon-only="" aria-label="Close" data-part="close" data-close=""><XIcon /></button>
|
|
36
|
+
</div>
|
|
37
|
+
<div data-part="body">{children}</div>
|
|
38
|
+
{foot.value !== undefined && <div data-part="footer">{foot.value}</div>}
|
|
39
|
+
</dialog>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { TimerIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { balance, formatDuration, pad, parseDuration, totalSeconds, type DurationParts, type IsoDuration } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { bindable, Composite, Hidden, usePickerEnv, type Bindable, type RichFieldProps } from './Picker'
|
|
5
|
+
|
|
6
|
+
type Unit = 'd' | 'h' | 'm' | 's'
|
|
7
|
+
const INTL = { d: 'day', h: 'hour', m: 'minute', s: 'second' } as const
|
|
8
|
+
const NAMES = { d: 'Days', h: 'Hours', m: 'Minutes', s: 'Seconds' } as const
|
|
9
|
+
const SHORT = { d: 'd', h: 'h', m: 'min', s: 's' } as const
|
|
10
|
+
const SIZE = { d: 86400, h: 3600, m: 60, s: 1 } as const
|
|
11
|
+
const MAX = { d: 99, h: 99, m: 59, s: 59 } as const
|
|
12
|
+
|
|
13
|
+
/** Props for DurationField. */
|
|
14
|
+
export type DurationFieldProps = RichFieldProps & Bindable<IsoDuration | null> & {
|
|
15
|
+
units?: Unit[]
|
|
16
|
+
max?: IsoDuration | undefined
|
|
17
|
+
step?: Partial<Record<'h' | 'm' | 's', number>>
|
|
18
|
+
balance?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DurationField (04 §2): a spinbutton for each allowed unit; ↑/↓ step with carry into the next unit (Shift for three
|
|
23
|
+
* steps), and digits fill. The value is an ISO duration, balanced (`PT90M` → `PT1H30M`) unless `balance: false`.
|
|
24
|
+
*/
|
|
25
|
+
export function DurationField({ value, defaultValue, onValueChange, units = ['h', 'm'], max, step, balance: carry = true, locale, today, name, label, hint, error, disabled, ...rest }: DurationFieldProps) {
|
|
26
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
27
|
+
const v = bindable<IsoDuration | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
28
|
+
const parts = derived(() => parseDuration(v.value))
|
|
29
|
+
const limit = untrack(() => (max ? totalSeconds(parseDuration(max)) : Infinity))
|
|
30
|
+
const buf = state('')
|
|
31
|
+
let group: HTMLDivElement | undefined
|
|
32
|
+
const say = (u: Unit, n: number) => new Intl.NumberFormat(env.locale.value, { style: 'unit', unit: INTL[u], unitDisplay: 'long' }).format(n)
|
|
33
|
+
const words = derived(() => {
|
|
34
|
+
const p = parts.value
|
|
35
|
+
const on = units.filter(u => p[u])
|
|
36
|
+
return on.length ? on.map(u => say(u, p[u])).join(' ') : 'No time'
|
|
37
|
+
})
|
|
38
|
+
const write = (next: DurationParts, reason: 'keyboard' = 'keyboard') => {
|
|
39
|
+
const t = Math.max(0, Math.min(limit, totalSeconds(next)))
|
|
40
|
+
v.set(formatDuration(carry ? balance(t, units) : next), reason)
|
|
41
|
+
}
|
|
42
|
+
const onKey = (e: KeyboardEvent) => {
|
|
43
|
+
const el = (e.target as HTMLElement).closest?.<HTMLElement>('[data-u]')
|
|
44
|
+
if (!el) return
|
|
45
|
+
const u = el.dataset.u as Unit
|
|
46
|
+
const at = units.indexOf(u)
|
|
47
|
+
const p = parts.value
|
|
48
|
+
const size = (step?.[u as 'h'] ?? (u === 'm' ? 5 : 1)) * SIZE[u] * (e.shiftKey ? 3 : 1)
|
|
49
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
50
|
+
e.preventDefault()
|
|
51
|
+
buf.value = ''
|
|
52
|
+
write(balance(totalSeconds(p) + (e.key === 'ArrowUp' ? size : -size), units))
|
|
53
|
+
} else if (/^\d$/.test(e.key)) {
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
const b = (buf.value + e.key).slice(-2)
|
|
56
|
+
buf.value = b
|
|
57
|
+
const n = +b
|
|
58
|
+
write(n > MAX[u] && !carry ? p : { ...p, [u]: n })
|
|
59
|
+
if (b.length === 2 && at < units.length - 1) { buf.value = ''; (group!.querySelector(`[data-u="${units[at + 1]}"]`) as HTMLElement).focus() }
|
|
60
|
+
} else if (e.key === 'Backspace') {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
buf.value = ''
|
|
63
|
+
write({ ...p, [u]: 0 })
|
|
64
|
+
} else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
65
|
+
const to = units[at + (e.key === 'ArrowRight' ? 1 : -1)]
|
|
66
|
+
if (to) { e.preventDefault(); (group!.querySelector(`[data-u="${to}"]`) as HTMLElement).focus() }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<Composite
|
|
71
|
+
icon={TimerIcon}
|
|
72
|
+
{...rest}
|
|
73
|
+
label={label}
|
|
74
|
+
hint={hint ?? words.value}
|
|
75
|
+
error={error}
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
bound={v}
|
|
78
|
+
onReset={() => (buf.value = '')}
|
|
79
|
+
filled={totalSeconds(parts.value) > 0}
|
|
80
|
+
control={() => (
|
|
81
|
+
<>
|
|
82
|
+
<div ref={n => (group = n)} data-part="segments" onKeyDown={onKey} onFocusOut={() => (buf.value = '')}>
|
|
83
|
+
{units.map(u => {
|
|
84
|
+
const n = derived(() => parts.value[u])
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<span key={u} data-part="segment" data-u={u} role="spinbutton" tabindex={disabled ? undefined : '0'} aria-label={NAMES[u]} aria-required={rest.required ? 'true' : undefined} aria-valuemin="0" aria-valuemax={MAX[u]} aria-valuenow={n.value} aria-valuetext={say(u, n.value)}>{u === units[0] ? n.value : pad(n.value)}</span>
|
|
88
|
+
<span data-part="literal" class="unit" aria-hidden="true">{SHORT[u]}</span>
|
|
89
|
+
</>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
<Hidden name={name} value={() => v.value ?? ''} />
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
/>
|
|
97
|
+
)
|
|
98
|
+
}
|
package/src/Feed.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { untrack } from '@weft-js/core'
|
|
2
|
+
import { VirtualList, type VirtualControl } from './VirtualList'
|
|
3
|
+
import './styles'
|
|
4
|
+
import '@weft-js/warp-core/styles/data/timeline.css'
|
|
5
|
+
|
|
6
|
+
/** One row of a Feed. */
|
|
7
|
+
export interface FeedItem {
|
|
8
|
+
id: string | number
|
|
9
|
+
/** Actor, verb and object: the bold line. */
|
|
10
|
+
what: unknown
|
|
11
|
+
when?: string
|
|
12
|
+
/** The body, measured. */
|
|
13
|
+
detail?: string
|
|
14
|
+
tone?: string | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Props for Feed. */
|
|
18
|
+
export interface FeedProps<T extends FeedItem> {
|
|
19
|
+
items: readonly T[]
|
|
20
|
+
label: string
|
|
21
|
+
height?: string
|
|
22
|
+
busy?: boolean | undefined
|
|
23
|
+
/** Near the end: load more. */
|
|
24
|
+
onEnd?: () => void
|
|
25
|
+
/** Called when an item's measured height differs from the estimate, with both heights. For tuning. */
|
|
26
|
+
onDrift?: (index: number, predicted: number, actual: number) => void
|
|
27
|
+
/** Hands over a control to scroll to, focus or move between items from outside. */
|
|
28
|
+
control?: (c: VirtualControl) => void
|
|
29
|
+
/** An item's whole content. Default: knot, what, when, detail. */
|
|
30
|
+
children?: (item: () => T) => unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A long activity feed (07 §4): the APG feed pattern on a window. Page Down and Up move between articles. */
|
|
34
|
+
export function Feed<T extends FeedItem>(props: FeedProps<T>) {
|
|
35
|
+
const render = untrack(() => props.children)
|
|
36
|
+
const keys = (e: KeyboardEvent, c: VirtualControl) => {
|
|
37
|
+
if (e.key === 'PageDown' || e.key === 'PageUp') {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
c.move(e.key === 'PageDown' ? 1 : -1)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return (
|
|
43
|
+
<VirtualList
|
|
44
|
+
tag="ol"
|
|
45
|
+
inner
|
|
46
|
+
role="feed"
|
|
47
|
+
rowRole="article"
|
|
48
|
+
listScope="timeline"
|
|
49
|
+
label={props.label}
|
|
50
|
+
height={props.height ?? '480px'}
|
|
51
|
+
items={props.items}
|
|
52
|
+
rowKey={i => i.id}
|
|
53
|
+
text={i => i.detail ?? ''}
|
|
54
|
+
estimate={56}
|
|
55
|
+
busy={props.busy}
|
|
56
|
+
onEnd={props.onEnd}
|
|
57
|
+
onDrift={props.onDrift}
|
|
58
|
+
control={props.control}
|
|
59
|
+
roving
|
|
60
|
+
onRowKey={keys}
|
|
61
|
+
>
|
|
62
|
+
{item => (render
|
|
63
|
+
? render(item)
|
|
64
|
+
: (
|
|
65
|
+
<>
|
|
66
|
+
<span data-part="knot" data-tone={item().tone}></span>
|
|
67
|
+
<span data-part="what">{item().what}</span>
|
|
68
|
+
<time data-part="when">{item().when}</time>
|
|
69
|
+
<span data-part="detail" data-measure="">{item().detail}</span>
|
|
70
|
+
</>
|
|
71
|
+
))}
|
|
72
|
+
</VirtualList>
|
|
73
|
+
)
|
|
74
|
+
}
|