@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/Button.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { derived } from '@weft-js/core'
|
|
2
|
+
import type { Interaction } from '@weft-js/core/interaction'
|
|
3
|
+
import { pressOrigin } from '@weft-js/warp-core'
|
|
4
|
+
import { Icon, type IconRef } from './Icon'
|
|
5
|
+
import { useUi } from './use-ui'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/components/button.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/components/spinner.css'
|
|
9
|
+
|
|
10
|
+
/** The colour role a control speaks in. */
|
|
11
|
+
export type Tone = 'neutral' | 'accent' | 'positive' | 'caution' | 'critical' | 'info'
|
|
12
|
+
/** How much weight a control carries, from solid down to a plain link. */
|
|
13
|
+
export type Emphasis = 'solid' | 'soft' | 'outline' | 'ghost' | 'frame' | 'link'
|
|
14
|
+
|
|
15
|
+
/** Props for Button; anything else lands on the <button>. */
|
|
16
|
+
export interface ButtonProps {
|
|
17
|
+
/** solid (default) · soft · outline · ghost · frame (the signature) · link. Fixed at creation: it picks the anatomy. */
|
|
18
|
+
emphasis?: Emphasis | undefined
|
|
19
|
+
tone?: Tone | undefined
|
|
20
|
+
size?: 'sm' | 'md' | 'lg' | undefined
|
|
21
|
+
/** A frame button with the solid fill sweeping in. */
|
|
22
|
+
fill?: boolean
|
|
23
|
+
/** At vivid the fill spreads from where the pointer arrives and parts back out, instead of wiping in from the left. Not on frame or link. */
|
|
24
|
+
follow?: boolean
|
|
25
|
+
full?: boolean
|
|
26
|
+
/** Icon only: `label` is then required (it is the accessible name). */
|
|
27
|
+
icon?: IconRef | undefined
|
|
28
|
+
/** With `icon` and text: on a phone (or a tablet and a phone) only the icon shows, and the text stays the accessible name. */
|
|
29
|
+
collapse?: 'phone' | 'tablet' | undefined
|
|
30
|
+
ref?: ((el: HTMLButtonElement) => unknown) | undefined
|
|
31
|
+
onClick?: ((e: MouseEvent) => unknown) | undefined
|
|
32
|
+
label?: string | undefined
|
|
33
|
+
/** Busy: the label stays the accessible name (hidden by opacity), presses are swallowed, focus stays. */
|
|
34
|
+
loading?: boolean | undefined
|
|
35
|
+
/** `interaction()`'s row: real events fill it, and it draws whoever set it. */
|
|
36
|
+
ui?: Interaction
|
|
37
|
+
children?: unknown
|
|
38
|
+
[attr: string]: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const swept = (e: Emphasis) => e !== 'link'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A native button (05 §2): all but link and frame append one [data-part="sweep"], and ::before paints the fill (the wipe,
|
|
45
|
+
* or with `follow` at vivid the two blobs that close on where the hand arrived); frame carries the six-part signature anatomy (SIGNATURE §1b).
|
|
46
|
+
*/
|
|
47
|
+
export function Button({ emphasis = 'solid', tone, size, fill, follow, full, icon, collapse, label, loading, type = 'button', onClick, ui, ref, children, ...rest }: ButtonProps) {
|
|
48
|
+
pressOrigin() // one listener for the page; it only moves buttons that ask to follow
|
|
49
|
+
let host: HTMLButtonElement | undefined
|
|
50
|
+
useUi(() => ui, () => host)
|
|
51
|
+
const canLoad = derived(() => loading !== undefined)
|
|
52
|
+
const glyph = derived(() => icon)
|
|
53
|
+
// a loading or aria-disabled button swallows presses (components.html guard())
|
|
54
|
+
const press = (e: MouseEvent) => {
|
|
55
|
+
const el = e.currentTarget as HTMLElement
|
|
56
|
+
if (el.hasAttribute('data-loading') || el.getAttribute('aria-disabled') === 'true') return e.preventDefault()
|
|
57
|
+
onClick?.(e)
|
|
58
|
+
}
|
|
59
|
+
const content = () => <>{glyph.value && <Icon name={glyph.value} />}{collapse && glyph.value && children ? <span data-part="collapse">{children}</span> : children}</>
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
type={type}
|
|
63
|
+
data-scope="button"
|
|
64
|
+
data-emphasis={emphasis === 'solid' ? undefined : emphasis}
|
|
65
|
+
data-tone={tone}
|
|
66
|
+
data-size={size === 'md' ? undefined : size}
|
|
67
|
+
data-fill={emphasis === 'frame' && fill ? '' : undefined}
|
|
68
|
+
data-follow={follow && emphasis !== 'frame' && emphasis !== 'link' ? '' : undefined}
|
|
69
|
+
data-full={full ? '' : undefined}
|
|
70
|
+
data-icon-only={glyph.value && !children ? '' : undefined}
|
|
71
|
+
data-collapse={glyph.value && children ? collapse : undefined}
|
|
72
|
+
aria-label={label}
|
|
73
|
+
data-sweep={swept(emphasis) ? '' : undefined}
|
|
74
|
+
|
|
75
|
+
data-loading={loading ? '' : undefined}
|
|
76
|
+
aria-busy={loading ? 'true' : undefined}
|
|
77
|
+
aria-disabled={loading ? 'true' : undefined}
|
|
78
|
+
onClick={press}
|
|
79
|
+
{...rest}
|
|
80
|
+
ref={n => { host = n; ref?.(n) }}
|
|
81
|
+
>
|
|
82
|
+
{emphasis === 'frame' ? (
|
|
83
|
+
<>
|
|
84
|
+
{fill && <span data-part="sweep-fill" aria-hidden="true"></span>}
|
|
85
|
+
<span data-part="frame-corners" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
86
|
+
<span data-part="sweep-corners" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
87
|
+
<span data-part="sweep-outline" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
|
88
|
+
<span data-part="sweep-label">{content()}</span>
|
|
89
|
+
<span data-part="sweep-content" aria-hidden="true">{content()}</span>
|
|
90
|
+
</>
|
|
91
|
+
) : canLoad.value ? (
|
|
92
|
+
<span data-part="label">{content()}</span>
|
|
93
|
+
) : (
|
|
94
|
+
content()
|
|
95
|
+
)}
|
|
96
|
+
{loading && <span data-scope="spinner" data-part="spinner" aria-hidden="true"></span>}
|
|
97
|
+
{emphasis !== 'frame' && emphasis !== 'link' && <span data-part="sweep" aria-hidden="true"></span>}
|
|
98
|
+
</button>
|
|
99
|
+
)
|
|
100
|
+
}
|
package/src/Calendar.tsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { LeftIcon, RightIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { addMonths, dayState, fmt, gridKey, isoWeek, isWeekend, isUnavailable, messages, monthEnd, monthStart, monthWeeks, ymd, weekStart, weekdays, type CalendarContext, type CalendarMode, type CalendarValue, type ChangeReason, type DateRange, type IsoDate } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, state, untrack, watch } from '@weft-js/core'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { bindable, uid, usePickerEnv, type Bindable } from './Picker'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/calendar-grid.css'
|
|
8
|
+
|
|
9
|
+
export interface GridProps {
|
|
10
|
+
mode: CalendarMode
|
|
11
|
+
months?: number
|
|
12
|
+
value: () => CalendarValue
|
|
13
|
+
compare?: () => DateRange | null | undefined
|
|
14
|
+
locale: string
|
|
15
|
+
today: string
|
|
16
|
+
min?: string | undefined
|
|
17
|
+
max?: string | undefined
|
|
18
|
+
isDateUnavailable?: ((d: IsoDate) => boolean) | undefined
|
|
19
|
+
firstDayOfWeek?: number | undefined
|
|
20
|
+
weekNumbers?: boolean | undefined
|
|
21
|
+
/** Marks the focused day `data-initial`, so a panel opens onto it. */
|
|
22
|
+
autofocus?: boolean
|
|
23
|
+
onPick: (iso: IsoDate, reason: ChangeReason) => void
|
|
24
|
+
onHover?: (iso: IsoDate | null) => void
|
|
25
|
+
onUnavailable?: (iso: IsoDate) => void
|
|
26
|
+
/** A cursor from outside (a preset moved the months). */
|
|
27
|
+
goTo?: () => string | null | undefined
|
|
28
|
+
children?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const startOf = (v: CalendarValue, today: string, mode: CalendarMode) => (mode === 'range' ? (v as DateRange)?.start : mode === 'multiple' ? [...((v as IsoDate[]) ?? [])].sort()[0] : mode === 'week' ? null : (v as string | null)) ?? today
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The APG date grid (04 §1.4): one table per month, one tab stop on the cursor's day. Blank cells stand for days of
|
|
35
|
+
* other months. A day is never named "14" alone: every button carries the full date and its state.
|
|
36
|
+
*/
|
|
37
|
+
export function CalendarGrid(p: GridProps) {
|
|
38
|
+
const firstDay = derived(() => p.firstDayOfWeek ?? weekStart(p.locale))
|
|
39
|
+
const count = () => p.months ?? 1
|
|
40
|
+
const cursor = state(untrack(() => startOf(p.value(), p.today, p.mode)))
|
|
41
|
+
const view = state(monthStart(untrack(() => cursor.value)))
|
|
42
|
+
const hover = state<string | null>(null)
|
|
43
|
+
const anim = state<'next' | 'prev' | undefined>(undefined)
|
|
44
|
+
let box: HTMLDivElement | undefined
|
|
45
|
+
const shown = derived(() => Array.from({ length: count() }, (_, n) => ({ month: addMonths(view.value, n) })))
|
|
46
|
+
const heads = derived(() => weekdays(firstDay.value, p.locale))
|
|
47
|
+
const t = derived(() => messages(p.locale))
|
|
48
|
+
const context = (): CalendarContext => ({ mode: p.mode, value: p.value(), compare: p.compare?.(), today: p.today, locale: p.locale, hover: hover.value, min: p.min, max: p.max, isDateUnavailable: p.isDateUnavailable })
|
|
49
|
+
const lastDay = () => monthEnd(addMonths(view.value, count() - 1))
|
|
50
|
+
const moveTo = (iso: string) => {
|
|
51
|
+
if (iso < view.value) { anim.value = 'prev'; view.value = monthStart(iso) }
|
|
52
|
+
else if (iso > lastDay()) { anim.value = 'next'; view.value = monthStart(addMonths(iso, -(count() - 1))) }
|
|
53
|
+
cursor.value = iso
|
|
54
|
+
}
|
|
55
|
+
const focusCursor = () => { flush(); box?.querySelector<HTMLElement>('[data-part="day"][tabindex="0"]')?.focus() }
|
|
56
|
+
const pick = (iso: string, reason: ChangeReason) => {
|
|
57
|
+
if (isUnavailable(iso, context())) return p.onUnavailable?.(iso as IsoDate)
|
|
58
|
+
cursor.value = iso
|
|
59
|
+
hover.value = null
|
|
60
|
+
p.onPick(iso as IsoDate, reason)
|
|
61
|
+
}
|
|
62
|
+
const onKey = (e: KeyboardEvent) => {
|
|
63
|
+
if (!(e.target as Element).closest?.('[data-part="day"]')) return
|
|
64
|
+
const to = gridKey(e.key, e.shiftKey, cursor.value, firstDay.value, getComputedStyle(box!).direction === 'rtl')
|
|
65
|
+
if (!to) return
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
if (to === 'pick') return pick(cursor.value, 'keyboard')
|
|
68
|
+
if (p.mode === 'range') hover.value = to
|
|
69
|
+
moveTo(to)
|
|
70
|
+
focusCursor()
|
|
71
|
+
}
|
|
72
|
+
const onClick = (e: MouseEvent) => {
|
|
73
|
+
const b = (e.target as Element).closest?.<HTMLElement>('[data-part="day"]')
|
|
74
|
+
if (b) pick(b.dataset.date!, 'pointer')
|
|
75
|
+
}
|
|
76
|
+
const onOver = (e: PointerEvent) => {
|
|
77
|
+
const b = (e.target as Element).closest?.<HTMLElement>('[data-part="day"]')
|
|
78
|
+
if (p.mode !== 'range' || !b || hover.value === b.dataset.date) return
|
|
79
|
+
hover.value = b.dataset.date!
|
|
80
|
+
p.onHover?.(hover.value as IsoDate)
|
|
81
|
+
}
|
|
82
|
+
const onLeave = () => { if (p.mode === 'range') { hover.value = null; p.onHover?.(null) } }
|
|
83
|
+
const page = (d: number) => { anim.value = d > 0 ? 'next' : 'prev'; view.value = addMonths(view.value, d); cursor.value = addMonths(cursor.value, d) }
|
|
84
|
+
watch(() => p.goTo?.(), iso => { if (iso) moveTo(iso) })
|
|
85
|
+
return (
|
|
86
|
+
<div ref={n => (box = n)} data-scope="calendar" data-part="root">
|
|
87
|
+
<Button emphasis="ghost" size="sm" icon={LeftIcon} label={t.value.prev} data-part="nav" data-dir="prev" data-icon="" onClick={() => page(-1)} />
|
|
88
|
+
<Button emphasis="ghost" size="sm" icon={RightIcon} label={t.value.next} data-part="nav" data-dir="next" data-icon="" onClick={() => page(1)} />
|
|
89
|
+
<div data-part="months" data-anim={anim.value} onKeyDown={onKey} onClick={onClick} onPointerOver={onOver} onPointerLeave={onLeave}>
|
|
90
|
+
{shown.value.map(m => <Month key={m.month} month={m.month} heads={heads.value} first={firstDay.value} weekNumbers={p.weekNumbers} locale={p.locale} wholeWeek={p.mode === 'week'} ctx={context} cursor={cursor.value} initial={p.autofocus} />)}
|
|
91
|
+
</div>
|
|
92
|
+
{p.children}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Month({ month, heads, first, weekNumbers, locale, wholeWeek, ctx, cursor, initial }: { month: string; heads: readonly (readonly [string, string, number])[]; first: number; weekNumbers?: boolean | undefined; locale: string; wholeWeek: boolean; ctx: () => CalendarContext; cursor: string; initial?: boolean | undefined }) {
|
|
98
|
+
const cap = uid('cap')
|
|
99
|
+
const weeks = derived(() => monthWeeks(month, first).map(days => ({ days: days.map((iso, c) => ({ iso, k: iso ?? `b${c}`, weekday: (first + c) % 7 })), k: days.find(Boolean)!, week: isoWeek(days.find(Boolean)!).week })))
|
|
100
|
+
return (
|
|
101
|
+
<div data-part="month">
|
|
102
|
+
<div data-part="caption" id={cap} aria-live="polite">{fmt(month, { month: 'long', year: 'numeric' }, locale)}</div>
|
|
103
|
+
<table role="grid" aria-labelledby={cap}>
|
|
104
|
+
<thead>
|
|
105
|
+
<tr>
|
|
106
|
+
{weekNumbers && <th scope="col" abbr={messages(locale).week}><span class="sr">{messages(locale).week}</span></th>}
|
|
107
|
+
{heads.map(h => <th key={h[0]} scope="col" abbr={h[0]}>{h[1]}</th>)}
|
|
108
|
+
</tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody>
|
|
111
|
+
{weeks.value.map(w => (
|
|
112
|
+
<tr key={w.k}>
|
|
113
|
+
{weekNumbers && <th scope="row" data-part="week">{w.week}</th>}
|
|
114
|
+
{w.days.map(d => (d.iso ? <Day key={d.k} iso={d.iso} weekend={isWeekend(d.weekday, locale)} locale={locale} wholeWeek={wholeWeek} ctx={ctx} cursor={cursor} initial={initial} /> : <td key={d.k} role="gridcell"></td>))}
|
|
115
|
+
</tr>
|
|
116
|
+
))}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Day({ iso, weekend, locale, ctx, cursor, initial }: { iso: IsoDate; weekend: boolean; locale: string; wholeWeek: boolean; ctx: () => CalendarContext; cursor: string; initial?: boolean | undefined }) {
|
|
124
|
+
const s = derived(() => dayState(iso, ctx()))
|
|
125
|
+
const n = derived(() => new Intl.NumberFormat(locale).format(ymd(iso)[2]))
|
|
126
|
+
return (
|
|
127
|
+
<td role="gridcell" aria-selected={s.value.cellSelected ? 'true' : 'false'} data-range={s.value.range} data-preview={s.value.preview ? '' : undefined} data-compare={s.value.compare ? '' : undefined}>
|
|
128
|
+
<button type="button" data-part="day" data-date={iso} tabindex={iso === cursor ? '0' : '-1'} data-initial={initial && iso === cursor ? '' : undefined} data-weekend={weekend ? '' : undefined} data-selected={s.value.selected ? '' : undefined} data-booked={s.value.unavailable && s.value.booked ? '' : undefined} aria-current={s.value.today ? 'date' : undefined} aria-disabled={s.value.unavailable ? 'true' : undefined} aria-label={s.value.name}>{n.value}</button>
|
|
129
|
+
</td>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type InlineCalendarValue = IsoDate | IsoDate[] | DateRange | null
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* InlineCalendar (04 §2): the same grid, standalone, for availability and booking. 1–3 months; `single`, `multiple`
|
|
137
|
+
* (toggles days, announces the count) or `range`. No field shell: `aria-label` names it.
|
|
138
|
+
*/
|
|
139
|
+
export function InlineCalendar({ selection = 'single', months = 1, value, defaultValue, onValueChange, min, max, isDateUnavailable, firstDayOfWeek, weekNumbers, locale, today, ...rest }: Bindable<InlineCalendarValue> & { selection?: 'single' | 'multiple' | 'range'; months?: 1 | 2 | 3; min?: string | undefined; max?: string | undefined; isDateUnavailable?: ((d: IsoDate) => boolean) | undefined; firstDayOfWeek?: number | undefined; weekNumbers?: boolean | undefined; locale?: string | undefined; today?: string | undefined; 'aria-label': string }) {
|
|
140
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
141
|
+
const empty = (): InlineCalendarValue => (selection === 'multiple' ? [] : selection === 'range' ? { start: null, end: null } : null)
|
|
142
|
+
const v = bindable<InlineCalendarValue>(() => value, () => defaultValue, untrack(empty), () => onValueChange)
|
|
143
|
+
const said = state('')
|
|
144
|
+
const onPick = (iso: IsoDate, reason: ChangeReason) => {
|
|
145
|
+
if (selection === 'single') return v.set(iso, reason)
|
|
146
|
+
if (selection === 'multiple') {
|
|
147
|
+
const now = (v.value as IsoDate[]) ?? []
|
|
148
|
+
const next = now.includes(iso) ? now.filter(d => d !== iso) : [...now, iso]
|
|
149
|
+
v.set(next, reason)
|
|
150
|
+
said.value = next.length === 1 ? '1 day selected' : `${next.length} days selected`
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const r = v.value as DateRange
|
|
154
|
+
v.set(!r?.start || r.end ? { start: iso, end: null } : iso < r.start ? { start: iso, end: r.start } : { start: r.start, end: iso }, reason)
|
|
155
|
+
}
|
|
156
|
+
return (
|
|
157
|
+
<div role="group" {...rest}>
|
|
158
|
+
<CalendarGrid mode={selection} months={months} value={() => v.value} locale={env.locale.value} today={env.today.value} min={min} max={max} isDateUnavailable={isDateUnavailable} firstDayOfWeek={firstDayOfWeek} weekNumbers={weekNumbers} onPick={onPick} />
|
|
159
|
+
<span class="sr" role="status">{said.value}</span>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
package/src/Canvas.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { surface, type Frame, type SurfaceOptions } from '@weft-js/warp-core/canvas'
|
|
2
|
+
import { state, untrack } from '@weft-js/core'
|
|
3
|
+
import { uid } from '@weft-js/warp-core'
|
|
4
|
+
import { floatStage, stage, stageSelect, type StageOptions } from '@weft-js/warp-core/stage'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/canvas/canvas.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/canvas/stage.css'
|
|
9
|
+
|
|
10
|
+
export type { Frame, Pointer } from '@weft-js/warp-core/canvas'
|
|
11
|
+
export type { Piece as StagePiece, Press, Scene, Scroll, StageLayout, StagePainter, Word } from '@weft-js/warp-core/stage'
|
|
12
|
+
|
|
13
|
+
/** Props for Canvas. */
|
|
14
|
+
export interface CanvasProps {
|
|
15
|
+
/** Paints one frame: `ctx` is already scaled to CSS pixels. Return true to be called again without `animate`. */
|
|
16
|
+
draw: (frame: Frame) => boolean | void
|
|
17
|
+
/** Moves at balanced and vivid. Quiet and reduced motion paint one frame, with `still` set. */
|
|
18
|
+
animate?: boolean
|
|
19
|
+
/** Caps the frame rate while it animates. Uncapped by default, at the display's rate. */
|
|
20
|
+
fps?: number
|
|
21
|
+
/** Whole-cell mode: `width` and `height` count cells of this many CSS pixels, scaled up without smoothing. */
|
|
22
|
+
cell?: number
|
|
23
|
+
/** Settings for the 2D context, such as `{ alpha: false }`. Read once, when the canvas attaches. */
|
|
24
|
+
context?: SurfaceOptions['context']
|
|
25
|
+
/** Called before the repaint when a theme, tier or motion dial changes around the canvas. Read colours again here. */
|
|
26
|
+
onTheme?: () => void
|
|
27
|
+
/** Track the pointer into `frame.pointer`, and repaint when it moves over the canvas. */
|
|
28
|
+
pointer?: boolean
|
|
29
|
+
/** Names the drawing (role="img"). Without one it is decorative. */
|
|
30
|
+
label?: string | undefined
|
|
31
|
+
[attr: string]: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A canvas that handles pixel ratio, resize, off-screen and reduced motion, for anything warp doesn't draw itself (08 §6.7). */
|
|
35
|
+
export function Canvas({ draw, animate, fps, cell, context, onTheme, pointer, label, ...rest }: CanvasProps) {
|
|
36
|
+
let node: HTMLCanvasElement | undefined
|
|
37
|
+
use(surface, () => node, () => ({ draw, animate, fps, cell, context, onTheme, pointer }))
|
|
38
|
+
return <div {...rest} data-scope="canvas"><canvas ref={n => (node = n)} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : 'true'}></canvas></div>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Props for CanvasStage. */
|
|
42
|
+
export interface CanvasStageProps extends StageOptions {
|
|
43
|
+
/** `box` paints its own block; `scroll` is a scroller whose view it paints; `page` paints the viewport, for a whole page. */
|
|
44
|
+
layout?: StageOptions['layout']
|
|
45
|
+
/** A modal's stage: the dialog spans the viewport and the stage centres its panel. */
|
|
46
|
+
veil?: boolean | undefined
|
|
47
|
+
children?: unknown
|
|
48
|
+
[attr: string]: unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A canvas that paints the page laid out inside it (08 §6.8). Mark any element `data-skin="button"`, `card`, or a name of
|
|
53
|
+
* your own: it keeps its layout, focus, typing and semantics, and your painters draw it with a 2D context, states and all.
|
|
54
|
+
*/
|
|
55
|
+
export function CanvasStage({ layout = 'box', painters, background, overlay, paint, animate, cell, fps, rail, context, onTheme, veil, children, ...rest }: CanvasStageProps) {
|
|
56
|
+
let node: HTMLCanvasElement | undefined
|
|
57
|
+
use(stage, () => node, () => ({ layout, painters, background, overlay, paint, animate, cell, fps, rail, context, onTheme }))
|
|
58
|
+
return (
|
|
59
|
+
<div {...rest} data-scope="stage" data-layout={layout} data-rail={rail ? '' : undefined} data-veil={veil ? '' : undefined}>
|
|
60
|
+
<div data-part="paint" aria-hidden="true"><canvas ref={n => (node = n)}></canvas></div>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One choice of a StageSelect: a string is its own value and label. */
|
|
67
|
+
export type StageOption = string | { value: string; label: string; disabled?: boolean | undefined }
|
|
68
|
+
|
|
69
|
+
/** Props for StageSelect. */
|
|
70
|
+
export interface StageSelectProps {
|
|
71
|
+
options: StageOption[]
|
|
72
|
+
/** The value it starts on; the first option when left out. */
|
|
73
|
+
value?: string | undefined
|
|
74
|
+
/** Puts the value in a form, through a hidden input. */
|
|
75
|
+
name?: string | undefined
|
|
76
|
+
disabled?: boolean | undefined
|
|
77
|
+
/** Hears each value picked. */
|
|
78
|
+
onChange?: ((value: string) => void) | undefined
|
|
79
|
+
[attr: string]: unknown
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A select a stage draws, list and all (APG select-only combobox). Its trigger is a `field` skin; its list floats in the
|
|
84
|
+
* top layer as a stage of its own, in the look of the one around it, with `menu` and `option` skins. Wrap it in a
|
|
85
|
+
* `<label>` to name it.
|
|
86
|
+
*/
|
|
87
|
+
export function StageSelect({ options, value, name, disabled, onChange, ...rest }: StageSelectProps) {
|
|
88
|
+
const items = untrack(() => options).map(o => (typeof o === 'string' ? { value: o, label: o } : o))
|
|
89
|
+
const current = state(untrack(() => value) ?? items[0]?.value ?? '')
|
|
90
|
+
const id = uid('stage-sel')
|
|
91
|
+
let trigger: HTMLButtonElement | undefined, paint: HTMLCanvasElement | undefined
|
|
92
|
+
use(stageSelect, () => trigger, () => ({ value: current.value, onPick: (v: string) => { current.value = v; onChange?.(v) } }))
|
|
93
|
+
use(floatStage, () => paint)
|
|
94
|
+
return (
|
|
95
|
+
<span data-part="select">
|
|
96
|
+
<button {...rest} ref={n => (trigger = n)} type="button" data-skin="field" role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls={id} disabled={disabled}>
|
|
97
|
+
{items.find(i => i.value === current.value)?.label ?? ''}
|
|
98
|
+
</button>
|
|
99
|
+
<div popover="manual" data-scope="stage" data-layout="box" data-part="float">
|
|
100
|
+
<div data-part="paint" aria-hidden="true"><canvas ref={n => (paint = n)}></canvas></div>
|
|
101
|
+
<div role="listbox" id={id} data-skin="menu">
|
|
102
|
+
{items.map((o, i) => (
|
|
103
|
+
<div key={o.value} role="option" id={`${id}-${i}`} data-skin="option" data-value={o.value} aria-selected={current.value === o.value ? 'true' : 'false'} aria-disabled={o.disabled ? 'true' : undefined}>{o.label}</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
{name && <input type="hidden" name={name} value={current.value} />}
|
|
108
|
+
</span>
|
|
109
|
+
)
|
|
110
|
+
}
|
package/src/Card.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { untrack } from '@weft-js/core'
|
|
2
|
+
import type { Interaction } from '@weft-js/core/interaction'
|
|
3
|
+
import { useUi } from './use-ui'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/components/surface.css'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A surface (05 §1, §6: Card, Panel). Interactive cards (`href` or `onClick`) are links or buttons and always carry
|
|
9
|
+
* the sweep at every stop; static ones carry `data-sweep="surface"`, which only `data-flair="sweep"` shows.
|
|
10
|
+
*/
|
|
11
|
+
export function Card({ eyebrow, title, href, onClick, sweep = true, ui, ref, children, ...rest }: { ref?: ((el: HTMLElement) => void) | undefined; eyebrow?: unknown; title?: unknown; href?: string; onClick?: ((e: MouseEvent) => void) | undefined; sweep?: boolean; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
|
|
12
|
+
let host: HTMLElement | undefined
|
|
13
|
+
const took = (n: HTMLElement) => { host = n; ref?.(n) }
|
|
14
|
+
useUi(() => ui, () => host)
|
|
15
|
+
const interactive = untrack(() => !!(href || onClick))
|
|
16
|
+
const swept = untrack(() => sweep)
|
|
17
|
+
const parts = () => (
|
|
18
|
+
<>
|
|
19
|
+
{eyebrow !== undefined && <span data-part="eyebrow">{eyebrow}</span>}
|
|
20
|
+
{title !== undefined && <span data-part="title">{title}</span>}
|
|
21
|
+
{children}
|
|
22
|
+
{swept && <span data-part="sweep" aria-hidden="true"></span>}
|
|
23
|
+
</>
|
|
24
|
+
)
|
|
25
|
+
const sweepAttr = swept ? (interactive ? '' : 'surface') : undefined
|
|
26
|
+
if (untrack(() => href)) return <a href={href} data-scope="surface" data-sweep={sweepAttr} onClick={onClick!} {...rest} ref={took}>{parts()}</a>
|
|
27
|
+
if (interactive) return <button type="button" data-scope="surface" data-sweep={sweepAttr} onClick={onClick!} {...rest} ref={took}>{parts()}</button>
|
|
28
|
+
return <div data-scope="surface" data-sweep={sweepAttr} {...rest} ref={took}>{parts()}</div>
|
|
29
|
+
}
|
package/src/Chart.tsx
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { uid } from '@weft-js/warp-core'
|
|
2
|
+
import { chart, SSR_RADIUS, SSR_WIDTH, tableHTML, toHTML, type Ctx, type Form } from '@weft-js/warp-core/charts'
|
|
3
|
+
import { derived, untrack } from '@weft-js/core'
|
|
4
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
5
|
+
import './styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/data/tokens.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/data/base.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/data/chart.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/data/legend.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/data/tooltip.css'
|
|
11
|
+
|
|
12
|
+
/** What every chart shares around its plot: the caption, the toolbar and the loading, empty and error states. */
|
|
13
|
+
export type ChartFrame = {
|
|
14
|
+
title: string
|
|
15
|
+
/** The title's heading level: one below the heading the chart sits under. Default 3, a chart in a page section. */
|
|
16
|
+
level?: 2 | 3 | 4 | 5 | 6
|
|
17
|
+
description?: unknown
|
|
18
|
+
/** Beside the view toggle: a layout switch, a range picker. */
|
|
19
|
+
toolbar?: unknown
|
|
20
|
+
/** Replaces the plot, in the same frame (06 §12). With no data the frame shows `empty` on its own. */
|
|
21
|
+
state?: 'loading' | 'empty' | 'error' | undefined
|
|
22
|
+
loading?: string | undefined
|
|
23
|
+
/** The title and detail shown in place of the plot when there is no data. */
|
|
24
|
+
empty?: { title: string; detail?: unknown } | undefined
|
|
25
|
+
error?: { title: string; detail?: unknown } | undefined
|
|
26
|
+
/** The next step under an empty or error state: a Retry button. */
|
|
27
|
+
action?: unknown
|
|
28
|
+
}
|
|
29
|
+
type Props<C> = ChartFrame & C & { form: Form<C> }
|
|
30
|
+
|
|
31
|
+
// props that are the frame's, not a form's: a JSX prop builds its element each time it's read, so these are read once
|
|
32
|
+
const FRAME = new Set(['form', 'level', 'description', 'toolbar', 'action', 'empty', 'error', 'loading'])
|
|
33
|
+
|
|
34
|
+
const Drawn = ({ stroke }: { stroke: string }) => (
|
|
35
|
+
<svg width="72" height="40" viewBox="0 0 72 40" aria-hidden="true">
|
|
36
|
+
<path d="M8 0V40M22 0V40M36 0V40M50 0V40M64 0V40" stroke="var(--warp-color-border-strong)" />
|
|
37
|
+
<path d={stroke === 'var(--warp-color-critical-solid)' ? 'M0 20H30' : 'M0 14H72M0 26H72'} stroke={stroke} stroke-width="2" />
|
|
38
|
+
</svg>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
function Standby({ state, title, text, action }: { state: 'loading' | 'empty' | 'error'; title: string; text?: { title: string; detail?: unknown } | undefined; action?: unknown }) {
|
|
42
|
+
if (untrack(() => state) === 'loading')
|
|
43
|
+
return (
|
|
44
|
+
<div data-part="state" role="status" aria-busy="true">
|
|
45
|
+
<div aria-hidden="true" style="display:flex;align-items:end;gap:10px;block-size:118px;inline-size:100%;padding-inline:6px">
|
|
46
|
+
{[40, 70, 55, 90, 64, 80, 96].map(h => <span key={h} data-part="skeleton" style={`--h:${h}px`}></span>)}
|
|
47
|
+
</div>
|
|
48
|
+
{text?.title ?? `Loading ${title}…`}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
const failed = untrack(() => state) === 'error'
|
|
52
|
+
return (
|
|
53
|
+
<div data-part="state" role={failed ? 'alert' : 'status'}>
|
|
54
|
+
<Drawn stroke={failed ? 'var(--warp-color-critical-solid)' : 'var(--warp-color-accent-solid)'} />
|
|
55
|
+
<b style="color:var(--warp-color-text)">{text?.title ?? (failed ? `Couldn’t load ${title}` : `No ${title}`)}</b>
|
|
56
|
+
{text?.detail}
|
|
57
|
+
{action}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Ready<C>(p: { form: Form<C>; cfg: () => C; id: string; root: () => HTMLElement | undefined }) {
|
|
63
|
+
const id = () => p.id
|
|
64
|
+
// the first layout, once: from here on `chart` owns these nodes, so nothing below re-renders them
|
|
65
|
+
const first = derived(() => untrack(() => p.form.plot(p.cfg(), SSR_WIDTH, { id: p.id, hidden: new Set(), r: SSR_RADIUS } satisfies Ctx)))
|
|
66
|
+
const legend = derived(() => p.form.legend?.(p.cfg()) ?? [])
|
|
67
|
+
const table = derived(() => tableHTML(p.form.table(p.cfg())))
|
|
68
|
+
const summary = derived(() => {
|
|
69
|
+
const c = p.cfg() as C & { summary?: string | ((c: C) => string) | undefined }
|
|
70
|
+
return typeof c.summary === 'function' ? c.summary(c) : c.summary ?? p.form.summary(c)
|
|
71
|
+
})
|
|
72
|
+
use(chart<C>, () => p.root(), () => ({ form: p.form, cfg: p.cfg(), id: p.id }))
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{p.form.ramp !== undefined
|
|
76
|
+
? <ul data-scope="legend" aria-label="Scale"><li style="display:flex;align-items:center;gap:6px;font-size:var(--warp-text-caption-size);color:var(--warp-color-text-subtle);padding-inline:6px">Fewer {[0, 1, 2, 3, 4, 5].slice(p.form.ramp).map(b => <i key={b} style={`display:inline-block;inline-size:11px;block-size:11px;border-radius:2px;background:var(--warp-color-data-seq-${b})`}></i>)} More</li></ul>
|
|
77
|
+
: legend.value.length > 0 && (
|
|
78
|
+
<ul data-scope="legend" aria-label="Series">
|
|
79
|
+
{legend.value.map((s, i) => <li key={s.name}><button type="button" aria-pressed="true" data-si={i} style={`--_dc:${s.color}`}><i data-shape={p.form.swatch ?? 'box'}></i>{s.name}{s.value !== undefined && <> <b>{s.value}</b></>}</button></li>)}
|
|
80
|
+
</ul>
|
|
81
|
+
)}
|
|
82
|
+
<div data-part="plot">
|
|
83
|
+
<div data-scope="tooltip" data-part="chart-tip" aria-hidden="true"></div>
|
|
84
|
+
<svg data-part="svg" viewBox={`0 0 ${first.value.w ?? SSR_WIDTH} ${first.value.h}`} width={first.value.w ?? SSR_WIDTH} height={first.value.h} style={first.value.style} role="group" aria-roledescription={p.form.kind} aria-labelledby={`${id()}-t`} aria-describedby={`${id()}-s`}
|
|
85
|
+
// oxlint-disable-next-line weft/no-html-sink -- toHTML escapes every text and attribute of the scene
|
|
86
|
+
innerHTML={toHTML(first.value.body)}></svg>
|
|
87
|
+
</div>
|
|
88
|
+
<div data-part="table" tabindex="0" role="region" aria-labelledby={`${id()}-t`}
|
|
89
|
+
// oxlint-disable-next-line weft/no-html-sink -- tableHTML escapes every cell
|
|
90
|
+
innerHTML={table.value}></div>
|
|
91
|
+
<p data-part="summary" id={`${id()}-s`}>{summary.value}</p>
|
|
92
|
+
</>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The frame every chart shares (06 §12): figure > head · legend · plot (svg, tooltip) · table · summary. The plot is
|
|
98
|
+
* laid out once here, so the server sends a whole chart, and from then on warp-core's `chart` owns it: width and data
|
|
99
|
+
* changes rewrite attributes on the same nodes, and the legend and the view toggle are delegated from the figure.
|
|
100
|
+
*/
|
|
101
|
+
export function Chart<C extends { data: unknown; title: string }>(props: Props<C>) {
|
|
102
|
+
const id = uid('chart')
|
|
103
|
+
let figure: HTMLElement | undefined
|
|
104
|
+
const cfg = () => {
|
|
105
|
+
const c: Record<string, unknown> = {}
|
|
106
|
+
for (const k of Object.keys(props)) if (!FRAME.has(k)) c[k] = (props as Record<string, unknown>)[k]
|
|
107
|
+
return c as C
|
|
108
|
+
}
|
|
109
|
+
const hasData = () => { const d = props.data; return d !== undefined && d !== null && (!Array.isArray(d) || d.length > 0) }
|
|
110
|
+
const status = () => props.state ?? (hasData() ? undefined : 'empty')
|
|
111
|
+
return (
|
|
112
|
+
<figure ref={n => (figure = n)} data-scope="chart" data-view="chart" data-enter="wait">
|
|
113
|
+
<div data-part="head">
|
|
114
|
+
<div>
|
|
115
|
+
<h3 id={`${id}-t`} aria-level={props.level === 3 ? undefined : props.level} style="margin:0;font-family:var(--warp-text-heading-font);font-size:calc(var(--warp-text-heading-size) - 3px);font-weight:var(--warp-text-heading-weight);letter-spacing:var(--warp-text-heading-tracking)">{props.title}</h3>
|
|
116
|
+
{props.description !== undefined && <p style="margin:2px 0 0;font-size:var(--warp-text-caption-size);color:var(--warp-color-text-subtle)">{props.description}</p>}
|
|
117
|
+
</div>
|
|
118
|
+
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
|
119
|
+
{props.toolbar}
|
|
120
|
+
{status() === undefined && (
|
|
121
|
+
<div data-part="view-toggle" role="group" aria-label="View">
|
|
122
|
+
<button type="button" aria-pressed="true" data-view="chart" aria-label="Chart view"><svg aria-hidden="true" focusable="false" viewBox="0 0 16 16"><path d="M2 13.5h12M3.5 10.5l3-3.5 2.5 2 3.5-4.5" /></svg></button>
|
|
123
|
+
<button type="button" aria-pressed="false" data-view="table" aria-label="Table view"><svg aria-hidden="true" focusable="false" viewBox="0 0 16 16"><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M2 6.5h12M2 10h12M6.5 6.5v7" /></svg></button>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
{status() === undefined
|
|
129
|
+
? <Ready form={props.form} cfg={cfg} id={id} root={() => figure} />
|
|
130
|
+
: <Standby state={status()!} title={props.title} text={status() === 'loading' ? (props.loading === undefined ? undefined : { title: props.loading }) : status() === 'error' ? props.error : props.empty} action={props.action} />}
|
|
131
|
+
</figure>
|
|
132
|
+
)
|
|
133
|
+
}
|
package/src/Charts.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The chart forms (06 §12), one line each: a form is a pure layout in warp-core, and `Chart` is the frame around it.
|
|
2
|
+
// Nothing here runs at import, so an app pays only for the forms it renders.
|
|
3
|
+
import { spark as sparkAction, sparkScene, SSR_WIDTH, toHTML, bar, box, brush, bubble, candlestick, combo, donut, funnel, gauge, heat, histogram, line, radar, radial, sankey, scatter, stream, sunburst, tile, treemap, waterfall, type BarConfig, type BoxConfig, type BrushConfig, type CandleConfig, type ComboConfig, type DonutConfig, type FunnelConfig, type GaugeConfig, type HeatConfig, type HistConfig, type LineConfig, type RadarConfig, type RadialConfig, type SankeyConfig, type ScatterConfig, type StreamConfig, type SunConfig, type TileConfig, type TreemapConfig, type WaterConfig } from '@weft-js/warp-core/charts'
|
|
4
|
+
import { state, untrack } from '@weft-js/core'
|
|
5
|
+
import { Button } from './Button'
|
|
6
|
+
import { Chart, type ChartFrame } from './Chart'
|
|
7
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
8
|
+
import './styles'
|
|
9
|
+
import '@weft-js/warp-core/styles/data/tokens.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/data/base.css'
|
|
11
|
+
|
|
12
|
+
/** Lines over a shared x axis. */
|
|
13
|
+
export const LineChart = (p: ChartFrame & LineConfig) => <Chart form={line} {...p} />
|
|
14
|
+
/** Bars, grouped or stacked. */
|
|
15
|
+
export const BarChart = (p: ChartFrame & BarConfig) => <Chart form={bar} {...p} />
|
|
16
|
+
/** Bars and lines on one plot. */
|
|
17
|
+
export const ComboChart = (p: ChartFrame & ComboConfig) => <Chart form={combo} {...p} />
|
|
18
|
+
/** A ring of parts of a whole. */
|
|
19
|
+
export const DonutChart = (p: ChartFrame & DonutConfig) => <Chart form={donut} {...p} />
|
|
20
|
+
/** Concentric arcs, one per value. */
|
|
21
|
+
export const RadialChart = (p: ChartFrame & RadialConfig) => <Chart form={radial} {...p} />
|
|
22
|
+
/** Stages narrowing from first to last. */
|
|
23
|
+
export const FunnelChart = (p: ChartFrame & FunnelConfig) => <Chart form={funnel} {...p} />
|
|
24
|
+
/** Dots at x and y. */
|
|
25
|
+
export const ScatterChart = (p: ChartFrame & ScatterConfig) => <Chart form={scatter} {...p} />
|
|
26
|
+
/** Dots at x and y, sized by a third value. */
|
|
27
|
+
export const BubbleChart = (p: ChartFrame & ScatterConfig) => <Chart form={bubble} {...p} />
|
|
28
|
+
/** Values binned into equal ranges. */
|
|
29
|
+
export const Histogram = (p: ChartFrame & HistConfig) => <Chart form={histogram} {...p} />
|
|
30
|
+
/** A grid of cells coloured by value. */
|
|
31
|
+
export const HeatGrid = (p: ChartFrame & HeatConfig) => <Chart form={heat} {...p} />
|
|
32
|
+
/** Series on axes spread around a circle. */
|
|
33
|
+
export const RadarChart = (p: ChartFrame & RadarConfig) => <Chart form={radar} {...p} />
|
|
34
|
+
/** Nested rectangles sized by value. */
|
|
35
|
+
export const Treemap = (p: ChartFrame & TreemapConfig) => <Chart form={treemap} {...p} />
|
|
36
|
+
/** Nested rings sized by value. */
|
|
37
|
+
export const Sunburst = (p: ChartFrame & SunConfig) => <Chart form={sunburst} {...p} />
|
|
38
|
+
/** Flows between stages, band width showing the amount. */
|
|
39
|
+
export const SankeyChart = (p: ChartFrame & SankeyConfig) => <Chart form={sankey} {...p} />
|
|
40
|
+
/** Open, high, low and close per period. */
|
|
41
|
+
export const CandlestickChart = (p: ChartFrame & CandleConfig) => <Chart form={candlestick} {...p} />
|
|
42
|
+
/** Quartiles and whiskers per group. */
|
|
43
|
+
export const BoxPlot = (p: ChartFrame & BoxConfig) => <Chart form={box} {...p} />
|
|
44
|
+
/** A running total, each step a rise or a fall. */
|
|
45
|
+
export const WaterfallChart = (p: ChartFrame & WaterConfig) => <Chart form={waterfall} {...p} />
|
|
46
|
+
/** A long series with a window dragged over it. */
|
|
47
|
+
export const BrushChart = (p: ChartFrame & BrushConfig) => <Chart form={brush} {...p} />
|
|
48
|
+
/** Regions drawn as equal tiles. */
|
|
49
|
+
export const TileMap = (p: ChartFrame & TileConfig) => <Chart form={tile} {...p} />
|
|
50
|
+
/** One value on an arc between its bounds. */
|
|
51
|
+
export const GaugeChart = (p: ChartFrame & GaugeConfig) => <Chart form={gauge} {...p} />
|
|
52
|
+
|
|
53
|
+
/** A rolling window (06 §12): one `d` write per tick on a fixed domain. Pausing holds the window and shows it as a table. */
|
|
54
|
+
export function StreamChart(p: ChartFrame & Omit<StreamConfig, 'paused'>) {
|
|
55
|
+
const paused = state(false)
|
|
56
|
+
let held = untrack(() => p.data)
|
|
57
|
+
const flip = () => { held = p.data; paused.value = !paused.value }
|
|
58
|
+
return <Chart form={stream} {...p} data={paused.value ? held : p.data} paused={paused.value} toolbar={<Button emphasis="outline" onClick={flip}>{paused.value ? 'Resume' : 'Pause'}</Button>} />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** No axes, end dot, `aria-hidden`: a sparkline is context inside a labelled parent. */
|
|
62
|
+
/** `format` says a hovered point the way the headline beside it does (`v => \`${v}%\``), so the unit carries over. */
|
|
63
|
+
export function Sparkline({ values, color, format }: { values: number[]; color?: string | undefined; format?: ((value: number) => string) | undefined }) {
|
|
64
|
+
let svg: SVGSVGElement | undefined
|
|
65
|
+
use(sparkAction, () => svg, () => (format ? { values, format } : values))
|
|
66
|
+
return <svg ref={n => (svg = n)} data-part="spark" viewBox={`0 0 ${SSR_WIDTH} 36`} aria-hidden="true" preserveAspectRatio="none" style={`display:block;inline-size:100%;block-size:36px;overflow:visible${color ? `;--_dc:${color}` : ''}`}
|
|
67
|
+
// oxlint-disable-next-line weft/no-html-sink -- toHTML escapes every text and attribute of the scene
|
|
68
|
+
innerHTML={toHTML(sparkScene(untrack(() => values), SSR_WIDTH))}></svg>
|
|
69
|
+
}
|
package/src/Chip.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CheckIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import type { Interaction } from '@weft-js/core/interaction'
|
|
4
|
+
import { Icon, type IconRef } from './Icon'
|
|
5
|
+
import { useUi } from './use-ui'
|
|
6
|
+
import './fields'
|
|
7
|
+
import '@weft-js/warp-core/styles/inputs/choice.css'
|
|
8
|
+
// the inputs form sits on the components chip (inputs.html loads both): pressed fill, forced colours
|
|
9
|
+
import '@weft-js/warp-core/styles/components/chip.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/components/toggle-button.css'
|
|
11
|
+
|
|
12
|
+
const sweepParts = () => <><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span></>
|
|
13
|
+
|
|
14
|
+
/** A filter chip (03 §4 Chips): a toggle button; pressed pins the sweep on accent-soft and draws the check. */
|
|
15
|
+
export function Chip({ pressed, count, icon, ui, ref, children, ...rest }: { ref?: ((el: HTMLButtonElement) => unknown) | undefined; pressed?: boolean; count?: unknown; icon?: IconRef | undefined; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
|
|
16
|
+
let host: HTMLButtonElement | undefined
|
|
17
|
+
useUi(() => ui, () => host)
|
|
18
|
+
const glyph = derived(() => icon)
|
|
19
|
+
return (
|
|
20
|
+
<button type="button" data-scope="chip" data-sweep="" aria-pressed={pressed ? 'true' : 'false'} {...rest} ref={n => { host = n; ref?.(n) }}>
|
|
21
|
+
{sweepParts()}
|
|
22
|
+
{pressed ? <CheckIcon data-part="check" /> : glyph.value && <Icon name={glyph.value} />}
|
|
23
|
+
{children}
|
|
24
|
+
{count !== undefined && <span data-part="count">{count}</span>}
|
|
25
|
+
</button>
|
|
26
|
+
)
|
|
27
|
+
}
|