@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,243 @@
|
|
|
1
|
+
import { DownIcon, GlobeIcon, SearchIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { caretAfter, currencyCodes, currencySymbol, digitsOf, displayName, formatPhone, inferCountry, languageCodes, listKey, localZone, matchZone, nationalLength, PHONE_COUNTRIES, regionCodes, regionOf, uid, zoneCity, zoneRows, type ChangeReason, type CountryCode, type E164, type IsoDate, type PhoneCountry, type TimeZoneId } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, onMount, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Field } from './Field'
|
|
5
|
+
import type { IconRef } from './Icon'
|
|
6
|
+
import { bindable, Composite, Hidden, Panel, useOpen, usePickerEnv, type Bindable, type Openable, type RichFieldProps } from './Picker'
|
|
7
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
8
|
+
import '@weft-js/warp-core/styles/rich/listbox.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/rich/timezone.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/rich/phone.css'
|
|
11
|
+
|
|
12
|
+
export type Row = { key: string; main: string; sub?: string | undefined; meta?: string | undefined; code?: string; pin?: string | undefined; search: string }
|
|
13
|
+
|
|
14
|
+
const SHOWN = 80
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The search list in a picker's panel (APG Combobox with a listbox popup): focus stays in the input, ↑/↓ move the
|
|
18
|
+
* active option, Enter picks. Long lists render their first rows; typing narrows them.
|
|
19
|
+
*/
|
|
20
|
+
function SearchList({ label, placeholder, rows, value, match, onPick, count }: { label: string; placeholder: string; rows: () => Row[]; value: string | null; match?: ((r: Row, q: string) => boolean) | undefined; onPick: (key: string, r: ChangeReason) => void; count?: ((n: number, q: string) => string) | undefined }) {
|
|
21
|
+
const id = uid('list')
|
|
22
|
+
const q = state('')
|
|
23
|
+
const all = derived(() => rows())
|
|
24
|
+
const hits = derived(() => {
|
|
25
|
+
const s = q.value.trim().toLowerCase()
|
|
26
|
+
return s ? all.value.filter(r => (match ? match(r, s) : r.search.includes(s))) : all.value
|
|
27
|
+
})
|
|
28
|
+
const shown = derived(() => hits.value.slice(0, SHOWN))
|
|
29
|
+
const active = state(untrack(() => Math.max(0, all.value.slice(0, SHOWN).findIndex(r => r.key === value))))
|
|
30
|
+
let list: HTMLUListElement | undefined
|
|
31
|
+
const show = () => list?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' })
|
|
32
|
+
const onKey = (e: KeyboardEvent) => {
|
|
33
|
+
const n = shown.value.length
|
|
34
|
+
if (!n) return
|
|
35
|
+
const m = listKey(e.key, active.value, n, { page: 8, wrap: true, typing: true })
|
|
36
|
+
if (!m.handled) return
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
if (m.pick) return onPick(shown.value[active.value]!.key, 'keyboard')
|
|
39
|
+
active.value = m.to
|
|
40
|
+
flush()
|
|
41
|
+
show()
|
|
42
|
+
}
|
|
43
|
+
onMount(show)
|
|
44
|
+
return (
|
|
45
|
+
<div class="tz-pop">
|
|
46
|
+
<label data-part="search">
|
|
47
|
+
<SearchIcon />
|
|
48
|
+
<span class="sr">{label}</span>
|
|
49
|
+
<input type="search" role="combobox" data-initial="" aria-expanded="true" aria-controls={id} aria-autocomplete="list" aria-activedescendant={shown.value.length ? `${id}-${active.value}` : undefined} placeholder={placeholder} onInput={e => { q.value = (e.currentTarget).value; active.value = 0 }} onKeyDown={onKey} />
|
|
50
|
+
</label>
|
|
51
|
+
<ul ref={n => (list = n)} data-part="listbox" role="listbox" id={id} aria-label={label}>
|
|
52
|
+
{shown.value.map((r, n) => <Option key={r.key} id={`${id}-${n}`} row={r} selected={r.key === value} active={n === active.value} onPick={onPick} />)}
|
|
53
|
+
</ul>
|
|
54
|
+
{!shown.value.length && <p class="tz-empty">No match for “{q.value}”.</p>}
|
|
55
|
+
{count && <p class="tz-count" aria-live="polite">{count(hits.value.length, q.value)}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const Option = ({ id, row, selected, active, onPick }: { id: string; row: Row; selected: boolean; active: boolean; onPick: (k: string, r: ChangeReason) => void }) => (
|
|
61
|
+
<li role="option" id={id} aria-selected={selected ? 'true' : 'false'} data-active={active ? '' : undefined} onClick={() => onPick(row.key, 'pointer')}>
|
|
62
|
+
{row.pin && <span class="tz-pin">{row.pin}</span>}
|
|
63
|
+
{row.code && <span data-part="code">{row.code}</span>}
|
|
64
|
+
<span>{row.main}{row.sub && <small> {row.sub}</small>}</span>
|
|
65
|
+
{row.meta && <span data-part="opt-meta">{row.meta}</span>}
|
|
66
|
+
</li>
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
/** A field whose control is one value button over a search list: the frame of TimeZoneSelect and the Intl selects. */
|
|
70
|
+
function ListField({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, icon, text, title, search, rows, match, count, hint, label, name, presentation, disabled, readonly, locale, today, ...rest }: RichFieldProps & Bindable<string | null> & Openable & { icon?: IconRef | undefined; text: (v: string, locale: string) => string; title: string; search: string; rows: (locale: string, today: string) => Row[]; match?: ((r: Row, q: string) => boolean) | undefined; count?: ((n: number, q: string) => string) | undefined; hint?: unknown }) {
|
|
71
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
72
|
+
const v = bindable<string | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
73
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
74
|
+
const shown = derived(() => (v.value ? text(v.value, env.locale.value) : ''))
|
|
75
|
+
let trigger: HTMLButtonElement | undefined
|
|
76
|
+
return (
|
|
77
|
+
<Composite
|
|
78
|
+
{...rest}
|
|
79
|
+
icon={icon}
|
|
80
|
+
label={label}
|
|
81
|
+
hint={hint}
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
readonly={readonly}
|
|
84
|
+
bound={v}
|
|
85
|
+
filled={!!v.value}
|
|
86
|
+
hold={pop.value}
|
|
87
|
+
control={() => (
|
|
88
|
+
<>
|
|
89
|
+
<button ref={n => (trigger = n)} type="button" data-part="pick" aria-haspopup="dialog" aria-expanded={pop.value ? 'true' : 'false'} aria-label={`${label}, ${shown.value || 'none'}`} aria-disabled={readonly ? 'true' : undefined} disabled={disabled} onClick={() => { if (!readonly) pop.set(!pop.value) }}>
|
|
90
|
+
<span>{shown.value}</span><DownIcon />
|
|
91
|
+
</button>
|
|
92
|
+
<Hidden name={name} value={() => v.value ?? ''} />
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
end={
|
|
96
|
+
<Panel label={title} open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
97
|
+
{() => <SearchList label={search} placeholder={search} rows={() => rows(env.locale.value, env.today.value)} match={match} value={v.value} count={count} onPick={(k, r) => { v.set(k, r); pop.set(false) }} />}
|
|
98
|
+
</Panel>
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Props for TimeZoneSelect. */
|
|
105
|
+
export type TimeZoneSelectProps = RichFieldProps & Bindable<TimeZoneId | null> & Openable & { referenceDate?: string | undefined; zones?: TimeZoneId[]; showOffset?: boolean }
|
|
106
|
+
|
|
107
|
+
/** TimeZoneSelect (04 §2): every IANA zone with its offset at `referenceDate`; Local is pinned first. */
|
|
108
|
+
export function TimeZoneSelect({ referenceDate, zones, showOffset = true, ...props }: TimeZoneSelectProps) {
|
|
109
|
+
const local = untrack(localZone)
|
|
110
|
+
const matches = new Map<string, ReturnType<typeof zoneRows>[number]>()
|
|
111
|
+
const rows = (locale: string, today: string): Row[] => {
|
|
112
|
+
const list = zoneRows(locale, (referenceDate ?? today) as IsoDate, zones)
|
|
113
|
+
for (const z of list) matches.set(z.zone, z)
|
|
114
|
+
const row = (z: (typeof list)[number]): Row => ({ key: z.zone, main: z.city, sub: z.region, meta: showOffset ? z.offset : undefined, pin: z.zone === local ? 'Local' : undefined, search: z.search })
|
|
115
|
+
return [...list.filter(z => z.zone === local), ...list.filter(z => z.zone !== local)].map(row)
|
|
116
|
+
}
|
|
117
|
+
return <ListField icon={GlobeIcon} title="Choose a time zone" search="City, zone or +5:30" text={v => zoneCity(v)} rows={rows} match={(r, q) => matchZone(matches.get(r.key)!, q)} count={(n, q) => (q ? `${n} zone${n === 1 ? '' : 's'}` : `${n} zones · offsets for today`)} {...props} />
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type CodeSelectProps = RichFieldProps & Bindable<string | null> & Openable & { options?: string[] | undefined; preferred?: string[] }
|
|
121
|
+
const pinned = (list: string[], first?: string[]) => (first ? [...first, ...list.filter(c => !first.includes(c))] : list)
|
|
122
|
+
|
|
123
|
+
/** CountrySelect (04 §3): names from Intl.DisplayNames in the UI locale; `preferred` pins items. */
|
|
124
|
+
export function CountrySelect({ options, preferred, ...props }: CodeSelectProps) {
|
|
125
|
+
const rows = (locale: string): Row[] => pinned(options ?? regionCodes(), preferred).map(c => { const main = displayName('region', c, locale); return { key: c, code: c, main, search: `${main} ${c}`.toLowerCase() } })
|
|
126
|
+
return <ListField icon={GlobeIcon} title="Choose a country" search="Search countries" text={(v, l) => displayName('region', v, l)} rows={rows} {...props} />
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** LanguageSelect (04 §3): each language with its endonym, because people look for their own language in their own script. */
|
|
130
|
+
export function LanguageSelect({ options, preferred, endonyms = true, ...props }: CodeSelectProps & { endonyms?: boolean }) {
|
|
131
|
+
const rows = (locale: string): Row[] => pinned(options ?? languageCodes(), preferred).map(c => { const main = displayName('language', c, locale), own = displayName('language', c, c); return { key: c, main, sub: endonyms && own !== main ? `· ${own}` : undefined, search: `${main} ${own} ${c}`.toLowerCase() } })
|
|
132
|
+
return <ListField icon={GlobeIcon} title="Choose a language" search="Search languages" text={(v, l) => displayName('language', v, l)} rows={rows} {...props} />
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** CurrencySelect (04 §3): "Euro · EUR · €". */
|
|
136
|
+
export function CurrencySelect({ options, preferred, ...props }: CodeSelectProps) {
|
|
137
|
+
const rows = (locale: string): Row[] => pinned(options ?? currencyCodes(), preferred).map(c => { const main = displayName('currency', c, locale); return { key: c, main, sub: `· ${c}`, meta: currencySymbol(c, locale), search: `${main} ${c}`.toLowerCase() } })
|
|
138
|
+
return <ListField title="Choose a currency" search="Search currencies" text={(v, l) => `${displayName('currency', v, l)} · ${v}`} rows={rows} {...props} />
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A prefix moves where the label rests: onto the input, past the country button (specimen, `restOn`). */
|
|
142
|
+
function restOnInput(el: HTMLInputElement) {
|
|
143
|
+
const control = el.closest<HTMLElement>('[data-part="control"]')!
|
|
144
|
+
const measure = () => {
|
|
145
|
+
if (!control.style.getPropertyValue('--_inset')) control.style.setProperty('--_inset', getComputedStyle(el.parentElement!).paddingInlineStart)
|
|
146
|
+
control.style.setProperty('--_lead', `${Math.round(el.getBoundingClientRect().left - control.getBoundingClientRect().left)}px`)
|
|
147
|
+
}
|
|
148
|
+
measure()
|
|
149
|
+
const size = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
|
|
150
|
+
size?.observe(control)
|
|
151
|
+
return { update() {}, destroy() { size?.disconnect() } }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Props for PhoneField. */
|
|
155
|
+
export type PhoneFieldProps = RichFieldProps & Bindable<E164 | null> & {
|
|
156
|
+
country?: CountryCode
|
|
157
|
+
defaultCountry?: CountryCode
|
|
158
|
+
countries?: CountryCode[] | undefined
|
|
159
|
+
onCountryChange?: (c: CountryCode) => void
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* PhoneField (04 §3): a country button, then a native tel input (the OS keypad and SMS autofill beat anything custom).
|
|
164
|
+
* Digits format as you type and the caret stays behind the same digit; a pasted `+…` infers the country. The value is
|
|
165
|
+
* E.164, and only once the number is the country's length. The country list is a search list.
|
|
166
|
+
*/
|
|
167
|
+
export function PhoneField({ value, defaultValue, onValueChange, country, defaultCountry, countries, onCountryChange, locale, today, name, presentation, label, hint, error, disabled, readonly, ...rest }: PhoneFieldProps) {
|
|
168
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
169
|
+
const v = bindable<E164 | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
170
|
+
const list = untrack(() => (countries ? PHONE_COUNTRIES.filter(c => countries.includes(c.code)) : PHONE_COUNTRIES))
|
|
171
|
+
const find = (code?: string) => list.find(c => c.code === code)
|
|
172
|
+
const code = state<PhoneCountry>(untrack(() => find(country) ?? find(defaultCountry) ?? find(regionOf(env.locale.value)) ?? list[0]!))
|
|
173
|
+
const text = state(untrack(() => {
|
|
174
|
+
const e = v.value
|
|
175
|
+
if (!e) return ''
|
|
176
|
+
const c = inferCountry(e, code.value, list) ?? code.value
|
|
177
|
+
code.value = c
|
|
178
|
+
return formatPhone(e.slice(c.dial.length + 1), c).shown
|
|
179
|
+
}))
|
|
180
|
+
const pop = useOpen(() => undefined, () => false, () => undefined)
|
|
181
|
+
const own = state<string | undefined>(undefined)
|
|
182
|
+
const name_ = derived(() => displayName('region', code.value.code, env.locale.value))
|
|
183
|
+
const placeholder = derived(() => { const c = code.value; return c.pattern.replace(/#/g, '0').replace(/^0/, c.trunk ? '0' : '5') })
|
|
184
|
+
let button: HTMLButtonElement | undefined
|
|
185
|
+
let input: HTMLInputElement | undefined
|
|
186
|
+
const sync = (raw: string, caret: number | null) => {
|
|
187
|
+
const c = code.value
|
|
188
|
+
const before = digitsOf(raw.slice(0, caret ?? raw.length)).length
|
|
189
|
+
const { shown, national } = formatPhone(raw, c)
|
|
190
|
+
text.value = shown
|
|
191
|
+
if (input) { input.value = shown; if (input === input.ownerDocument.activeElement) { const at = caretAfter(shown, before); input.setSelectionRange(at, at) } }
|
|
192
|
+
const need = nationalLength(c)
|
|
193
|
+
own.value = national.length > need ? `${name_.value} numbers have ${need} digits after the country code.` : undefined
|
|
194
|
+
const e = national.length === need ? `+${c.dial}${national}` as E164 : null
|
|
195
|
+
if (e !== untrack(() => v.value)) v.set(e, 'keyboard')
|
|
196
|
+
}
|
|
197
|
+
const choose = (c: PhoneCountry) => { code.value = c; untrack(() => onCountryChange)?.(c.code); pop.set(false); input?.focus(); sync(text.value, null) }
|
|
198
|
+
const onBlur = () => {
|
|
199
|
+
const c = code.value
|
|
200
|
+
const n = formatPhone(text.value, c).national.length, need = nationalLength(c)
|
|
201
|
+
if (n && n < need) own.value = `That's ${n} digits. ${name_.value} numbers have ${need}.`
|
|
202
|
+
}
|
|
203
|
+
use(restOnInput, () => input)
|
|
204
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
205
|
+
if (readonly) return
|
|
206
|
+
const t = e.clipboardData?.getData('text').trim() ?? ''
|
|
207
|
+
if (!t.startsWith('+')) return
|
|
208
|
+
const m = inferCountry(t, code.value, list)
|
|
209
|
+
if (!m) return
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
code.value = m
|
|
212
|
+
sync((m.trunk ?? '') + digitsOf(t).slice(m.dial.length), null)
|
|
213
|
+
}
|
|
214
|
+
return (
|
|
215
|
+
<Field
|
|
216
|
+
{...rest}
|
|
217
|
+
label={label}
|
|
218
|
+
hint={hint}
|
|
219
|
+
error={error ?? own.value}
|
|
220
|
+
disabled={disabled}
|
|
221
|
+
readonly={readonly}
|
|
222
|
+
filled={text.value.length > 0}
|
|
223
|
+
hold={pop.value}
|
|
224
|
+
attrs={{ 'data-phone': '' }}
|
|
225
|
+
reset={() => { v.reset(); own.value = undefined; code.value = inferCountry(v.value ?? '', code.value, list) ?? code.value; text.value = v.value ? formatPhone(v.value.slice(code.value.dial.length + 1), code.value).shown : ''; if (input) input.value = text.value }}
|
|
226
|
+
control={p => (
|
|
227
|
+
<div data-part="body">
|
|
228
|
+
<button ref={n => (button = n)} type="button" data-part="country" aria-haspopup="dialog" aria-expanded={pop.value ? 'true' : 'false'} aria-disabled={readonly ? 'true' : undefined} disabled={disabled} onClick={() => { if (!readonly) pop.set(!pop.value) }}>
|
|
229
|
+
<span aria-hidden="true"><b>{code.value.code}</b>+{code.value.dial}</span><span class="sr">Country: {name_.value}, +{code.value.dial}</span><DownIcon />
|
|
230
|
+
</button>
|
|
231
|
+
<input ref={n => (input = n)} data-part="input" id={p.id} type="tel" inputmode="tel" autocomplete="tel-national" placeholder={placeholder.value} aria-describedby={p.describedby} value={text.value} required={p.rest.required} disabled={disabled} readonly={readonly} onInput={e => { const el = e.currentTarget; sync(el.value, el.selectionStart) }} onBlur={onBlur} onPaste={onPaste} />
|
|
232
|
+
<Hidden name={name} value={() => v.value ?? ''} />
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
end={
|
|
236
|
+
<Panel label="Country" open={pop} trigger={() => button!} presentation={presentation}>
|
|
237
|
+
{() => <SearchList label="Search countries" placeholder="Country or code" value={code.value.code} rows={() => list.map(c => { const main = displayName('region', c.code, env.locale.value); return { key: c.code, code: c.code, main, meta: `+${c.dial}`, search: `${main} ${c.code} ${c.dial}`.toLowerCase() } })} match={(r, q) => r.search.includes(q.replace(/^\+/, ''))} onPick={k => choose(find(k)!)} />}
|
|
238
|
+
</Panel>
|
|
239
|
+
}
|
|
240
|
+
/>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { CodeIcon, CodeBlockIcon, LinkIcon, ListIcon, ListOrderedIcon, QuoteIcon, UndoIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { comboPopup, htmlToMarkdown, keyboardDock, markdownToHtml, nextIndex, richText, sanitizeHtml, uid, type MentionItem, type RichTextHandle, type RichTextOptions } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, onCleanup, state, untrack, watch } from '@weft-js/core'
|
|
4
|
+
import { bindable, type Bindable } from './bind'
|
|
5
|
+
import { Button } from './Button'
|
|
6
|
+
import { Field, type FieldShell } from './Field'
|
|
7
|
+
import { Tooltip } from './Tooltip'
|
|
8
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
9
|
+
import '@weft-js/warp-core/styles/rich/body.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/rich/rich-text.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/rich/listbox.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/rich/popover.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
14
|
+
|
|
15
|
+
/** An entry in the editor's slash menu. */
|
|
16
|
+
export type SlashCommand = { id: string; label: string; hint?: string; shortcut?: string; run: (editor: RichTextHandle) => void }
|
|
17
|
+
type Mark = 'bold' | 'italic' | 'strike' | 'code' | 'link'
|
|
18
|
+
type Block = 'h2' | 'h3' | 'ul' | 'ol' | 'quote' | 'codeblock'
|
|
19
|
+
|
|
20
|
+
/** Props for RichTextEditor. */
|
|
21
|
+
export type RichTextEditorProps = FieldShell & Bindable<string> & {
|
|
22
|
+
format?: 'html' | 'markdown' | undefined
|
|
23
|
+
/** fixed (default) in the frame, bubble over a selection, or none. A phone docks either above the keyboard. */
|
|
24
|
+
toolbar?: 'fixed' | 'bubble' | 'none'
|
|
25
|
+
marks?: Mark[]
|
|
26
|
+
blocks?: Block[]
|
|
27
|
+
commands?: SlashCommand[]
|
|
28
|
+
mentions?: { search: (q: string, signal: AbortSignal) => Promise<MentionItem[]> }
|
|
29
|
+
placeholder?: string
|
|
30
|
+
minRows?: number
|
|
31
|
+
maxRows?: number
|
|
32
|
+
name?: string | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Tool = { cmd: string; label: string; tip: string; glyph: () => unknown; style?: string; toggles?: boolean }
|
|
36
|
+
const TOOLS: Record<Mark | Block, Tool> = {
|
|
37
|
+
h2: { cmd: 'h2', label: 'H2 heading', tip: 'Heading · # ', glyph: () => 'H2', toggles: true },
|
|
38
|
+
h3: { cmd: 'h3', label: 'H3 subheading', tip: 'Subheading · ### ', glyph: () => 'H3', toggles: true },
|
|
39
|
+
bold: { cmd: 'bold', label: 'Bold', tip: 'Bold · ⌘B', glyph: () => 'B', style: 'font-weight:700', toggles: true },
|
|
40
|
+
italic: { cmd: 'italic', label: 'Italic', tip: 'Italic · ⌘I', glyph: () => 'I', style: 'font-style:italic;font-family:Georgia,serif;font-weight:400;font-size:16px', toggles: true },
|
|
41
|
+
strike: { cmd: 'strikeThrough', label: 'Strikethrough', tip: 'Strikethrough', glyph: () => 'S', style: 'text-decoration:line-through;font-weight:500', toggles: true },
|
|
42
|
+
code: { cmd: 'code', label: 'Inline code', tip: 'Code · `text`', glyph: () => <CodeIcon />, toggles: true },
|
|
43
|
+
link: { cmd: 'link', label: 'Link', tip: 'Link · ⌘K', glyph: () => <LinkIcon />, toggles: true },
|
|
44
|
+
ul: { cmd: 'insertUnorderedList', label: 'Bulleted list', tip: 'Bullets · - ', glyph: () => <ListIcon />, toggles: true },
|
|
45
|
+
ol: { cmd: 'insertOrderedList', label: 'Numbered list', tip: 'Numbers · 1. ', glyph: () => <ListOrderedIcon />, toggles: true },
|
|
46
|
+
quote: { cmd: 'blockquote', label: 'Quote', tip: 'Quote · > ', glyph: () => <QuoteIcon />, toggles: true },
|
|
47
|
+
codeblock: { cmd: 'pre', label: 'Code block', tip: 'Code block · ```', glyph: () => <CodeBlockIcon />, toggles: true },
|
|
48
|
+
}
|
|
49
|
+
const SLASH: SlashCommand[] = [
|
|
50
|
+
{ id: 'h2', label: 'Heading', hint: '#', run: e => e.run('h2') },
|
|
51
|
+
{ id: 'h3', label: 'Subheading', hint: '###', run: e => e.run('h3') },
|
|
52
|
+
{ id: 'ul', label: 'Bulleted list', hint: '-', run: e => e.run('insertUnorderedList') },
|
|
53
|
+
{ id: 'ol', label: 'Numbered list', hint: '1.', run: e => e.run('insertOrderedList') },
|
|
54
|
+
{ id: 'quote', label: 'Quote', hint: '>', run: e => e.run('blockquote') },
|
|
55
|
+
{ id: 'code', label: 'Code block', hint: '```', run: e => e.run('pre') },
|
|
56
|
+
{ id: 'divider', label: 'Divider', hint: '---', run: e => e.run('insertHorizontalRule') },
|
|
57
|
+
]
|
|
58
|
+
type MenuRow = { id: string; main: string; meta?: string | undefined; run: () => void }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A rich text field (04 RichTextEditor; inputs-rich.html:1522): the editor frame is the field. The toolbar is an APG
|
|
62
|
+
* Toolbar, one tab stop with ←/→ inside. Markdown shortcuts open a block at the start of a line; `/` on an empty line and
|
|
63
|
+
* `@` open a list at the caret. Everything that enters the editor, a value or a paste, goes through warp-core's allow-list.
|
|
64
|
+
*/
|
|
65
|
+
export function RichTextEditor({ value, defaultValue, onValueChange, format = 'html', toolbar = 'fixed', marks = ['bold', 'italic', 'strike', 'code', 'link'], blocks = ['h2', 'ul', 'ol', 'quote'], commands = SLASH, mentions, placeholder = 'Write, or type / for blocks…', minRows, maxRows, name, hint, ...rest }: RichTextEditorProps) {
|
|
66
|
+
const id = uid('rte')
|
|
67
|
+
const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
|
|
68
|
+
const toHtml = (v: string) => (format === 'markdown' ? sanitizeHtml(markdownToHtml(v)) : sanitizeHtml(v))
|
|
69
|
+
const fromEditor = (html: string) => (format === 'markdown' ? htmlToMarkdown(ed!) : html)
|
|
70
|
+
const start = untrack(() => toHtml(held.value))
|
|
71
|
+
let written = untrack(() => held.value)
|
|
72
|
+
const pressed = state<Record<string, boolean>>({})
|
|
73
|
+
const stop = state(0)
|
|
74
|
+
const linking = state(false)
|
|
75
|
+
const url = state('')
|
|
76
|
+
const items = state<MenuRow[]>([])
|
|
77
|
+
const active = state(0)
|
|
78
|
+
const menuOpen = state(false)
|
|
79
|
+
const said = state('')
|
|
80
|
+
const tools = derived(() => [blocks.filter(b => b === 'h2' || b === 'h3').map(b => TOOLS[b]), marks.map(m => TOOLS[m]), blocks.filter(b => b !== 'h2' && b !== 'h3').map(b => TOOLS[b])].filter(g => g.length))
|
|
81
|
+
const flat = derived(() => tools.value.flat())
|
|
82
|
+
let ed: HTMLDivElement | undefined
|
|
83
|
+
let handle: RichTextHandle | undefined
|
|
84
|
+
let bar: HTMLDivElement | undefined
|
|
85
|
+
let pop: HTMLDivElement | undefined
|
|
86
|
+
let mark: HTMLSpanElement | undefined
|
|
87
|
+
let link: HTMLInputElement | undefined
|
|
88
|
+
let lookup: AbortController | undefined
|
|
89
|
+
onCleanup(() => lookup?.abort())
|
|
90
|
+
const close = () => { menuOpen.value = false; lookup?.abort() }
|
|
91
|
+
const openMenu = (rows: MenuRow[], rect: DOMRect) => {
|
|
92
|
+
items.value = rows
|
|
93
|
+
active.value = 0
|
|
94
|
+
mark!.style.insetBlockStart = `${rect.bottom}px`
|
|
95
|
+
mark!.style.insetInlineStart = `${rect.left}px`
|
|
96
|
+
menuOpen.value = rows.length > 0
|
|
97
|
+
}
|
|
98
|
+
const onQuery = async (q: { trigger: string; query: string; rect: DOMRect } | null) => {
|
|
99
|
+
lookup?.abort()
|
|
100
|
+
if (!q) return void (menuOpen.value = false)
|
|
101
|
+
const drop = () => handle!.replaceQuery(q.query.length + 1)
|
|
102
|
+
if (q.trigger === '/') {
|
|
103
|
+
const low = q.query.toLowerCase()
|
|
104
|
+
return openMenu(commands.filter(c => c.label.toLowerCase().includes(low)).map(c => ({ id: c.id, main: c.label, meta: c.shortcut ?? c.hint, run: () => { drop(); c.run(handle!) } })), q.rect)
|
|
105
|
+
}
|
|
106
|
+
if (!mentions) return
|
|
107
|
+
const me = (lookup = new AbortController())
|
|
108
|
+
try {
|
|
109
|
+
const found = await mentions.search(q.query, me.signal)
|
|
110
|
+
if (me !== lookup) return
|
|
111
|
+
openMenu(found.map(m => ({ id: m.id, main: m.label, meta: m.meta, run: () => {
|
|
112
|
+
drop()
|
|
113
|
+
handle!.insertMention(m.label)
|
|
114
|
+
said.value = `Mention: ${m.label}`
|
|
115
|
+
} })), q.rect)
|
|
116
|
+
} catch { /* superseded */ }
|
|
117
|
+
}
|
|
118
|
+
const choose = (at: number) => { const row = items.value[at]; close(); row?.run() }
|
|
119
|
+
const startLink = () => { url.value = ''; linking.value = true; flush(); link?.focus() }
|
|
120
|
+
const endLink = (apply: boolean) => {
|
|
121
|
+
linking.value = false
|
|
122
|
+
if (apply && url.value.trim()) handle!.run('link', url.value.trim())
|
|
123
|
+
else { ed!.focus(); handle!.restore() }
|
|
124
|
+
}
|
|
125
|
+
use(
|
|
126
|
+
(el: HTMLDivElement, o: RichTextOptions) => (handle = richText(el, o)),
|
|
127
|
+
() => ed,
|
|
128
|
+
() => ({
|
|
129
|
+
onChange: (html: string) => { written = fromEditor(html); held.set(written, 'keyboard') },
|
|
130
|
+
onState: (on: Record<string, boolean>) => (pressed.value = on),
|
|
131
|
+
onQuery, onLink: startLink,
|
|
132
|
+
bubble: toolbar === 'bubble' ? bar : undefined,
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
if (untrack(() => toolbar) !== 'none') use(keyboardDock, () => bar, () => ({ editor: () => ed }))
|
|
136
|
+
use(comboPopup, () => pop, () => ({ open: menuOpen.value, anchor: mark!, onClose: close }))
|
|
137
|
+
watch(() => held.value, v => {
|
|
138
|
+
if (v === written) return
|
|
139
|
+
written = v
|
|
140
|
+
// oxlint-disable-next-line weft/no-html-sink -- toHtml sanitizes
|
|
141
|
+
if (ed) ed.innerHTML = toHtml(v)
|
|
142
|
+
})
|
|
143
|
+
const onToolKey = (e: KeyboardEvent) => {
|
|
144
|
+
const to = nextIndex(e.key, stop.value, flat.value.length, { rtl: getComputedStyle(e.currentTarget as Element).direction === 'rtl' })
|
|
145
|
+
if (to < 0) return
|
|
146
|
+
e.preventDefault()
|
|
147
|
+
stop.value = to
|
|
148
|
+
flush()
|
|
149
|
+
;(e.currentTarget as Element).querySelector<HTMLElement>('[tabindex="0"]')?.focus()
|
|
150
|
+
}
|
|
151
|
+
const onMenuKey = (e: KeyboardEvent) => {
|
|
152
|
+
if (!menuOpen.value || e.isComposing) return
|
|
153
|
+
const n = items.value.length
|
|
154
|
+
const by = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
|
|
155
|
+
if (by) { e.preventDefault(); active.value = (active.value + by + n) % n }
|
|
156
|
+
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); choose(active.value) }
|
|
157
|
+
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
158
|
+
}
|
|
159
|
+
const Tool = ({ tool: t }: { tool: Tool }) => (
|
|
160
|
+
<Tooltip content={t.tip}><button type="button" data-part="tool" tabindex={flat.value[stop.value] === t ? '0' : '-1'} aria-label={t.label} aria-pressed={pressed.get(t.cmd) ? 'true' : 'false'} style={t.style} onClick={() => (t.cmd === 'link' ? (handle!.save(), startLink()) : handle!.run(t.cmd))}>{t.glyph()}</button></Tooltip>
|
|
161
|
+
)
|
|
162
|
+
const rows = () => [minRows && `min-block-size:${minRows * 1.6}em`, maxRows && `max-block-size:${maxRows * 1.6}em`].filter(Boolean).join(';') || undefined
|
|
163
|
+
return (
|
|
164
|
+
<Field
|
|
165
|
+
{...rest}
|
|
166
|
+
hint={hint ?? 'Markdown shortcuts work at the start of a line. Esc closes menus.'}
|
|
167
|
+
group
|
|
168
|
+
filled
|
|
169
|
+
reset={held.reset}
|
|
170
|
+
hold={menuOpen.value}
|
|
171
|
+
attrs={{ 'data-multiline': '' }}
|
|
172
|
+
control={p => (
|
|
173
|
+
<div data-scope="rte">
|
|
174
|
+
<div data-part="frame">
|
|
175
|
+
{toolbar !== 'none' && (
|
|
176
|
+
<div ref={n => (bar = n)} role="toolbar" aria-label="Formatting" aria-controls={`${id}-c`} popover={toolbar === 'bubble' ? 'manual' : undefined} data-bubble={toolbar === 'bubble' ? '' : undefined} onKeyDown={onToolKey} onPointerDown={e => { if ((e.target as Element).closest?.('[data-part="tool"]')) e.preventDefault() }}>
|
|
177
|
+
{tools.value.map((g, i) => <span key={g[0]!.cmd} style="display:contents">{i > 0 && <span data-part="sep"></span>}{g.map(t => <Tool key={t.cmd} tool={t} />)}</span>)}
|
|
178
|
+
<span data-part="sep"></span>
|
|
179
|
+
<Tooltip content="Undo" kbd="⌘Z"><button type="button" data-part="tool" tabindex="-1" aria-label="Undo" onClick={() => handle!.run('undo')}><UndoIcon /></button></Tooltip>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
{linking.value && (
|
|
183
|
+
<div data-part="linkbar">
|
|
184
|
+
<label data-part="sr-only" for={`${id}-url`}>Link address</label>
|
|
185
|
+
<input ref={n => (link = n)} type="url" id={`${id}-url`} placeholder="https://" value={url.value} onInput={(e: Event) => (url.value = (e.currentTarget as HTMLInputElement).value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); endLink(true) } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); endLink(false) } }} />
|
|
186
|
+
<Button size="sm" onClick={() => endLink(true)}>Apply</Button>
|
|
187
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={() => endLink(false)}>Cancel</Button>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
<div ref={n => (ed = n)} data-part="content" id={`${id}-c`} contenteditable="true" tabindex="0" role="textbox" aria-multiline="true" aria-labelledby={`${p.id}-l`} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} aria-autocomplete="list" aria-controls={`${id}-menu`} aria-activedescendant={menuOpen.value ? `${id}-${active.value}` : undefined} data-placeholder={placeholder} spellcheck style={rows()} onKeyDown={onMenuKey}
|
|
191
|
+
// oxlint-disable-next-line weft/no-html-sink -- `start` went through toHtml, which sanitizes
|
|
192
|
+
innerHTML={start}></div>
|
|
193
|
+
</div>
|
|
194
|
+
<span ref={n => (mark = n)} data-part="caret-anchor" style="position:fixed" aria-hidden="true"></span>
|
|
195
|
+
<div ref={n => (pop = n)} data-scope="popover" data-kind="combo" data-sweep="" popover="manual" role="presentation" onPointerDown={e => e.preventDefault()}>
|
|
196
|
+
<span data-part="sweep" aria-hidden="true"></span>
|
|
197
|
+
<ul data-part="listbox" role="listbox" id={`${id}-menu`} aria-label="Insert">
|
|
198
|
+
{items.value.map((r, n) => <li key={r.id} role="option" id={`${id}-${n}`} aria-selected={n === active.value ? 'true' : 'false'} data-active={n === active.value ? '' : undefined} onClick={() => choose(n)}><span>{r.main}</span>{r.meta && <span data-part="opt-meta">{r.meta}</span>}</li>)}
|
|
199
|
+
</ul>
|
|
200
|
+
</div>
|
|
201
|
+
<span data-part="sr-only" role="status">{said.value}</span>
|
|
202
|
+
{name && <input type="hidden" name={name} value={held.value} />}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { derived, onCleanup, onMount, state, untrack } from '@weft-js/core'
|
|
2
|
+
import { measureEngine, type MeasureHandle, type MeasureProvider } from '@weft-js/core/text'
|
|
3
|
+
import { slices, uid, virtualizer, type Virtualizer } from '@weft-js/warp-core'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/data/scroll-grid.css'
|
|
6
|
+
|
|
7
|
+
/** One ScrollGrid column. */
|
|
8
|
+
export interface GridColumn {
|
|
9
|
+
id: string
|
|
10
|
+
header: string
|
|
11
|
+
/** Pixels. Default 104. */
|
|
12
|
+
width?: number | undefined
|
|
13
|
+
/** The text wraps, and rows are as tall as it needs. */
|
|
14
|
+
wrap?: boolean
|
|
15
|
+
}
|
|
16
|
+
/** Props for ScrollGrid. */
|
|
17
|
+
export interface ScrollGridProps {
|
|
18
|
+
label: string
|
|
19
|
+
rows: number
|
|
20
|
+
columns: readonly GridColumn[]
|
|
21
|
+
/** The cell at a row and column. */
|
|
22
|
+
cell: (row: number, column: GridColumn) => unknown
|
|
23
|
+
/** The first column, the row's name. */
|
|
24
|
+
rowHeader: (row: number) => string
|
|
25
|
+
/** The corner cell's text. */
|
|
26
|
+
corner?: string
|
|
27
|
+
/** Default `480px`. */
|
|
28
|
+
height?: string
|
|
29
|
+
/** Each row's height, in pixels. Default 34. */
|
|
30
|
+
rowHeight?: number
|
|
31
|
+
/** The pinned first column's width, in pixels. Default 132. */
|
|
32
|
+
headerWidth?: number
|
|
33
|
+
/** Marks a cell critical (a negative value). */
|
|
34
|
+
negative?: (row: number, column: GridColumn) => boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RH = 34, CW = 104, HW = 132, LEADING = 1.4, PAD = 12, INSET = 20
|
|
38
|
+
const HELP = 'Arrow keys move one cell, Page Up and Page Down one screen, Home and End to the row ends, Control with Home or End to the grid ends.'
|
|
39
|
+
|
|
40
|
+
/** The two-axis grid (06 §6): rows and columns are each a window on one scroller, plus the active cell's row and column. */
|
|
41
|
+
export function ScrollGrid(props: ScrollGridProps) {
|
|
42
|
+
const id = uid('grid')
|
|
43
|
+
let el: HTMLElement | undefined, canvas: HTMLElement | undefined
|
|
44
|
+
let ys: Virtualizer | undefined, xs: Virtualizer | undefined
|
|
45
|
+
let engine: MeasureProvider | null = null
|
|
46
|
+
let stop: (() => void) | undefined
|
|
47
|
+
const rh = untrack(() => props.rowHeight ?? RH), hw = untrack(() => props.headerWidth ?? HW)
|
|
48
|
+
const columns = derived(() => props.columns)
|
|
49
|
+
const rows = state<number[]>(Array.from({ length: Math.min(untrack(() => props.rows), 12) }, (_, i) => i))
|
|
50
|
+
const cols = state<number[]>(Array.from({ length: Math.min(untrack(() => columns.value.length), 12) }, (_, i) => i))
|
|
51
|
+
const active = state<[number, number]>([0, 0])
|
|
52
|
+
const shownCols = derived(() => cols.value.flatMap(c => (columns.value[c] ? [{ c, col: columns.value[c] }] : [])))
|
|
53
|
+
const handles = new Map<string, MeasureHandle>()
|
|
54
|
+
let font = '', line = 0
|
|
55
|
+
const wrapped = derived(() => columns.value.flatMap((c, i) => (c.wrap ? [{ c, i }] : [])))
|
|
56
|
+
const cellId = (r: number, c: number) => `${id}-r${r}-c${c}`
|
|
57
|
+
|
|
58
|
+
const handle = (r: number, c: GridColumn) => {
|
|
59
|
+
const key = `${r}\0${c.id}`
|
|
60
|
+
let h = handles.get(key)
|
|
61
|
+
if (!h) { h = engine!.prepare(String((props.cell(r, c) as string | number | null | undefined) ?? ''), font); handles.set(key, h) }
|
|
62
|
+
return h
|
|
63
|
+
}
|
|
64
|
+
const size = (r: number) => {
|
|
65
|
+
if (!engine || !wrapped.value.length) return rh
|
|
66
|
+
let most = rh
|
|
67
|
+
for (const { c } of wrapped.value) most = Math.max(most, engine.layout(handle(r, c), (c.width ?? CW) - INSET, line).height + PAD)
|
|
68
|
+
return most
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function focusCell([r, c]: [number, number], align = true) {
|
|
72
|
+
active.value = [r, c]
|
|
73
|
+
ys?.update({})
|
|
74
|
+
xs?.update({})
|
|
75
|
+
if (align) reveal(r, c)
|
|
76
|
+
el!.setAttribute('aria-activedescendant', cellId(r, c))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reveal(r: number, c: number) {
|
|
80
|
+
const top = el!.scrollTop, y = ys!.sizes.offset(r), h = ys!.sizes.get(r), room = el!.clientHeight - rh
|
|
81
|
+
if (y < top) el!.scrollTop = y
|
|
82
|
+
else if (y + h! > top + room) el!.scrollTop = y + h! - room
|
|
83
|
+
const left = el!.scrollLeft, x = xs!.sizes.offset(c), w = xs!.sizes.get(c), wide = el!.clientWidth - hw
|
|
84
|
+
if (x < left) el!.scrollLeft = x
|
|
85
|
+
else if (x + w! > left + wide) el!.scrollLeft = x + w! - wide
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
89
|
+
const [r, c] = active.value, lastR = props.rows - 1, lastC = columns.value.length - 1, ctrl = e.ctrlKey || e.metaKey
|
|
90
|
+
const page = Math.max(1, Math.floor((el!.clientHeight - rh) / rh) - 1)
|
|
91
|
+
const to: Record<string, [number, number]> = {
|
|
92
|
+
ArrowDown: [r + 1, c], ArrowUp: [r - 1, c], ArrowRight: [r, c + 1], ArrowLeft: [r, c - 1], PageDown: [r + page, c], PageUp: [r - page, c],
|
|
93
|
+
Home: ctrl ? [0, 0] : [r, 0], End: ctrl ? [lastR, lastC] : [r, lastC],
|
|
94
|
+
}
|
|
95
|
+
const next = to[e.key]
|
|
96
|
+
if (!next) return
|
|
97
|
+
e.preventDefault()
|
|
98
|
+
focusCell([Math.max(0, Math.min(lastR, next[0])), Math.max(0, Math.min(lastC, next[1]))])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onMount(() => {
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
if (!el) return
|
|
104
|
+
const allRows = Array.from({ length: untrack(() => props.rows) }, (_, i) => i)
|
|
105
|
+
ys = virtualizer(el, { keys: allRows, spacer: canvas!, estimate: rh, size, head: rh, sized: true, pinned: () => [active.value[0]], overscan: 200, onRange: i => (rows.value = i) })
|
|
106
|
+
xs = virtualizer(el, { keys: untrack(() => columns.value.map((_, i) => i)), spacer: canvas!, axis: 'x', estimate: i => untrack(() => columns.value[i]!.width ?? CW), head: hw, sized: true, pinned: () => [active.value[1]], overscan: 200, onRange: i => (cols.value = i) })
|
|
107
|
+
for (const [row, key] of held.splice(0)) row.set(ys.attach(row.node, key))
|
|
108
|
+
for (const [cell, key] of heldCols.splice(0)) cell.set(xs.attach(cell.node, key))
|
|
109
|
+
if (!untrack(() => wrapped.value.length)) return
|
|
110
|
+
void measureEngine().then(e => {
|
|
111
|
+
if (!e || !el) return
|
|
112
|
+
const c = getComputedStyle(el)
|
|
113
|
+
font = `${c.fontWeight} ${c.fontSize} ${c.fontFamily}`
|
|
114
|
+
line = parseFloat(c.fontSize) * LEADING
|
|
115
|
+
engine = e
|
|
116
|
+
ys!.refresh(rows.value[0] ?? 0, (rows.value.at(-1) ?? 0) + 1)
|
|
117
|
+
let at = 0, lo = 0
|
|
118
|
+
stop = slices(() => {
|
|
119
|
+
if (at >= allRows.length) { ys!.refresh(lo, at); return false }
|
|
120
|
+
size(at++)
|
|
121
|
+
if (at - lo >= 256) { ys!.refresh(lo, at); lo = at }
|
|
122
|
+
return true
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
onCleanup(() => { stop?.(); ys?.destroy(); xs?.destroy() })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
type Held = { node: HTMLElement; set: (undo: () => void) => void }
|
|
130
|
+
const held: [Held, number][] = [], heldCols: [Held, number][] = []
|
|
131
|
+
const watch = (node: () => HTMLElement, axis: 'x' | 'y', key: number) => onMount(() => {
|
|
132
|
+
let undo: (() => void) | undefined
|
|
133
|
+
const v = axis === 'y' ? ys : xs
|
|
134
|
+
if (v) undo = v.attach(node(), key)
|
|
135
|
+
else (axis === 'y' ? held : heldCols).push([{ node: node(), set: u => (undo = u) }, key])
|
|
136
|
+
onCleanup(() => undo?.())
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function Cell(p: { r: number; entry: { c: number; col: GridColumn } }) {
|
|
140
|
+
let node: HTMLElement | undefined
|
|
141
|
+
const c = untrack(() => p.entry.c)
|
|
142
|
+
watch(() => node!, 'x', c)
|
|
143
|
+
const on = () => active.value[0] === p.r && active.value[1] === c
|
|
144
|
+
const wrap = () => p.entry.col.wrap
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
ref={n => (node = n)} id={cellId(p.r, c)} data-part="cell" role="gridcell" aria-colindex={c + 2} aria-selected={on() ? 'true' : undefined} data-active={on() ? '' : undefined}
|
|
148
|
+
data-neg={props.negative?.(p.r, p.entry.col) ? '' : undefined}
|
|
149
|
+
style={`inline-size:${p.entry.col.width ?? CW}px;block-size:100%;transform:translateX(${hw + c * CW}px)${wrap() ? `;white-space:normal;line-height:${LEADING};justify-content:start;align-items:start;padding-block:${PAD / 2}px` : ''}`}
|
|
150
|
+
onPointerDown={() => focusCell([p.r, c], false)}
|
|
151
|
+
>{props.cell(p.r, p.entry.col)}</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Head(p: { entry: { c: number; col: GridColumn } }) {
|
|
156
|
+
let node: HTMLElement | undefined
|
|
157
|
+
const c = untrack(() => p.entry.c)
|
|
158
|
+
watch(() => node!, 'x', c)
|
|
159
|
+
return <div ref={n => (node = n)} data-part="col-head" role="columnheader" aria-colindex={c + 2} style={`inline-size:${p.entry.col.width ?? CW}px;transform:translateX(${hw + c * CW}px)`}>{p.entry.col.header}</div>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Row(p: { r: number }) {
|
|
163
|
+
let node: HTMLElement | undefined
|
|
164
|
+
const r = untrack(() => p.r)
|
|
165
|
+
watch(() => node!, 'y', r)
|
|
166
|
+
return (
|
|
167
|
+
<div ref={n => (node = n)} data-part="row" role="row" aria-rowindex={r + 2} style={`inline-size:${hw}px;min-inline-size:100%;transform:translateY(${rh + r * rh}px)`}>
|
|
168
|
+
<div data-part="row-head" role="rowheader" aria-colindex="1" style={`position:sticky;inset-inline-start:0;inline-size:${hw}px;block-size:100%`}>{props.rowHeader(r)}</div>
|
|
169
|
+
{shownCols.value.map(e => <Cell key={e.c} r={r} entry={e} />)}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
ref={n => (el = n)} data-scope="scroll-grid" role="grid" id={id} aria-label={props.label} aria-describedby={`${id}-help`} aria-readonly="true"
|
|
177
|
+
aria-rowcount={props.rows + 1} aria-colcount={columns.value.length + 1} aria-activedescendant={cellId(0, 0)} tabindex="0" onKeyDown={onKeyDown}
|
|
178
|
+
style={props.height ? `block-size:${props.height}` : undefined}
|
|
179
|
+
>
|
|
180
|
+
<div ref={n => (canvas = n)} data-part="canvas" role="presentation">
|
|
181
|
+
<div data-part="head-row" role="row" aria-rowindex="1" style="position:sticky;inset-block-start:0">
|
|
182
|
+
<div data-part="corner" role="columnheader" aria-colindex="1" style={`position:sticky;inset-inline-start:0;inline-size:${hw}px`}>{props.corner ?? props.label}</div>
|
|
183
|
+
{shownCols.value.map(e => <Head key={e.c} entry={e} />)}
|
|
184
|
+
</div>
|
|
185
|
+
{rows.value.map(r => <Row key={r} r={r} />)}
|
|
186
|
+
</div>
|
|
187
|
+
<p id={`${id}-help`} class="vh">{HELP}</p>
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|