@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/Field.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { AlertIcon, SearchIcon, XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { field, formInvalid, formReset, labelChars, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Icon, type IconRef } from './Icon'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import './fields'
|
|
7
|
+
import '@weft-js/warp-core/styles/inputs/text.css'
|
|
8
|
+
|
|
9
|
+
interface FieldBase {
|
|
10
|
+
/** The accessible name, drawn as the floating border label (or above, classic). Required. */
|
|
11
|
+
label: string
|
|
12
|
+
hint?: unknown
|
|
13
|
+
/** Shown instead of the hint; marks the field invalid. */
|
|
14
|
+
error?: unknown
|
|
15
|
+
/** Leading icon: it sweeps with the border. */
|
|
16
|
+
icon?: IconRef | undefined
|
|
17
|
+
/** Text or an element inside the field before the value, such as `$`. */
|
|
18
|
+
prefix?: unknown
|
|
19
|
+
/** Text or an element inside the field after the value, such as `kg`. */
|
|
20
|
+
suffix?: unknown
|
|
21
|
+
/** floating (default, or the app's `warp({ fields })`) · classic: the label above. */
|
|
22
|
+
variant?: 'floating' | 'classic' | undefined
|
|
23
|
+
/** `line`: only a bottom line under the value, no box or fill. The boxed field otherwise. */
|
|
24
|
+
appearance?: 'line' | undefined
|
|
25
|
+
size?: 'sm' | 'md' | 'lg' | undefined
|
|
26
|
+
/** A clear button (shown once there is a value). */
|
|
27
|
+
clearable?: boolean | undefined
|
|
28
|
+
/** Maximum length, shown as a counter. */
|
|
29
|
+
counter?: number | undefined
|
|
30
|
+
id?: string | undefined
|
|
31
|
+
value?: string | undefined
|
|
32
|
+
disabled?: boolean | undefined
|
|
33
|
+
/** Shows the value and lets it be selected and copied, but not changed. */
|
|
34
|
+
readonly?: boolean | undefined
|
|
35
|
+
onInput?: ((e: InputEvent & { currentTarget: HTMLInputElement & HTMLTextAreaElement }) => void) | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Props for the text fields; anything else lands on the control. */
|
|
39
|
+
export interface FieldProps extends FieldBase { [attr: string]: unknown }
|
|
40
|
+
|
|
41
|
+
/** FieldProps for a component with its own value type: Omit on FieldProps would drop what the index signature hides. */
|
|
42
|
+
export type FieldShell = Omit<FieldBase, 'value' | 'defaultValue'> & { [attr: string]: unknown }
|
|
43
|
+
|
|
44
|
+
// `invalid` is a getter and `rest` the live props: the control is made once and its attributes update in place
|
|
45
|
+
export type Control = (p: { id: string; readonly describedby?: string | undefined; readonly invalid: boolean; rest: Record<string, unknown> }) => unknown
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The field shell (03 §1 anatomy, verbatim order): control (sweep-base, sweep, icon, label, prefix, input, suffix,
|
|
49
|
+
* clear, end slot) then message (hint | error, counter). Everything is in the server HTML, label letters included;
|
|
50
|
+
* after mount warp-core's field() measures the label's per-letter delays and notch and wires clear, counter and focus.
|
|
51
|
+
*/
|
|
52
|
+
export function Field({ label, hint, error, icon, prefix, suffix, variant, appearance, size, clearable, counter, id: given, control, end, filled, attrs, group, labelled, hold, wrap, reset, ...rest }: FieldProps & { control: Control; end?: unknown; filled?: boolean; attrs?: Record<string, unknown>; group?: boolean; /** The control takes its name from `aria-labelledby`, not `for`: a press on the label must not click a button. */ labelled?: boolean; hold?: boolean; wrap?: boolean; /** Puts held state back when the field's form resets. */ reset?: () => void }) {
|
|
53
|
+
const id = untrack(() => given) ?? uid('field')
|
|
54
|
+
// the browser's message for a constraint that failed (03 §1.3): `error` wins over it
|
|
55
|
+
const missing = state<string | undefined>(undefined)
|
|
56
|
+
const problem = derived(() => error ?? missing.value)
|
|
57
|
+
const glyph = derived(() => icon)
|
|
58
|
+
// an error can arrive long after mount, so the block and what points at it follow it
|
|
59
|
+
const message = derived(() => hint !== undefined || !!problem.value || counter !== undefined)
|
|
60
|
+
const msg = derived(() => (message.value ? `${id}-msg` : undefined))
|
|
61
|
+
// keyed by position: a label repeats letters
|
|
62
|
+
const chars = derived(() => labelChars(String((label as string | number | undefined) ?? '')).map((c, at) => ({ c, at })))
|
|
63
|
+
// one omit() for every read: the compiler expands `rest` afresh at each use (the proxy reads stay live)
|
|
64
|
+
const live = untrack(() => rest)
|
|
65
|
+
let root: HTMLDivElement | undefined
|
|
66
|
+
const shell = { id, get describedby() { return msg.value }, get invalid() { return !!problem.value }, rest: live }
|
|
67
|
+
use(field, () => root, () => [live.value, label] as const)
|
|
68
|
+
use(formInvalid, () => root, () => (m: string | undefined) => (missing.value = m))
|
|
69
|
+
if (untrack(() => reset)) use(formReset, () => root, () => () => reset!())
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
ref={n => (root = n)}
|
|
73
|
+
data-scope="field"
|
|
74
|
+
data-variant={variant}
|
|
75
|
+
data-appearance={appearance}
|
|
76
|
+
data-size={size === 'md' ? undefined : size}
|
|
77
|
+
data-counter={counter}
|
|
78
|
+
data-invalid={problem.value ? '' : undefined}
|
|
79
|
+
data-disabled={live.disabled ? '' : undefined}
|
|
80
|
+
data-readonly={live.readonly ? '' : undefined}
|
|
81
|
+
{...attrs}
|
|
82
|
+
>
|
|
83
|
+
<div data-part="control" data-icon={typeof glyph.value === 'string' ? glyph.value : glyph.value ? '' : undefined} data-sweep="" data-filled={filled ?? live.value ? '' : undefined} data-hold={hold ? '' : undefined} data-wrap={wrap ? '' : undefined}>
|
|
84
|
+
<span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
|
|
85
|
+
{glyph.value && <span data-part="icon" aria-hidden="true"><span data-part="sweep-base"><Icon name={glyph.value} /></span><span data-part="sweep"><Icon name={glyph.value} /></span></span>}
|
|
86
|
+
<label data-part="label" for={group || labelled ? undefined : id} id={group || labelled ? `${id}-l` : undefined}>
|
|
87
|
+
<span data-part="label-text">{label}</span>
|
|
88
|
+
<span data-part="label-chars" aria-hidden="true">{chars.value.map(x => <span key={x.at} data-char data-c={x.c}></span>)}</span>
|
|
89
|
+
</label>
|
|
90
|
+
{prefix !== undefined && <span data-part="prefix">{prefix}</span>}
|
|
91
|
+
{group
|
|
92
|
+
? <div data-part="body" role="group" aria-labelledby={`${id}-l`} aria-describedby={msg.value} aria-invalid={problem.value ? 'true' : undefined}>{untrack(() => control(shell))}</div>
|
|
93
|
+
: untrack(() => control(shell))}
|
|
94
|
+
{suffix !== undefined && <span data-part="suffix">{suffix}</span>}
|
|
95
|
+
{clearable && <button type="button" data-part="clear" tabindex="-1" aria-label={`Clear ${label}`}><XIcon /></button>}
|
|
96
|
+
{end}
|
|
97
|
+
</div>
|
|
98
|
+
{message.value && (
|
|
99
|
+
<div data-part="message" id={msg.value}>
|
|
100
|
+
{problem.value ? <span data-part="error" role="alert"><AlertIcon /><span>{problem.value}</span></span> : hint !== undefined && <span data-part="hint">{hint}</span>}
|
|
101
|
+
{counter !== undefined && <span data-part="counter" aria-live="off"></span>}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A text field. Every native input attribute passes through (type, autocomplete, inputmode, onInput…). */
|
|
109
|
+
export function Input({ placeholder, ...props }: FieldProps & { placeholder?: string; type?: string }) {
|
|
110
|
+
return (
|
|
111
|
+
<Field
|
|
112
|
+
{...props}
|
|
113
|
+
control={p => (
|
|
114
|
+
// a placeholder, " " at least: the label floats on :placeholder-shown (03 §1 decision 3)
|
|
115
|
+
<input data-part="input" id={p.id} placeholder={placeholder ?? ' '} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} {...p.rest} />
|
|
116
|
+
)}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A search field: the search icon, type=search, a clear button (03 §3 SearchInput; suggestions come with Combobox). */
|
|
122
|
+
export function Search({ placeholder, ...props }: FieldProps & { placeholder?: string }) {
|
|
123
|
+
return (
|
|
124
|
+
<Field
|
|
125
|
+
icon={SearchIcon}
|
|
126
|
+
clearable
|
|
127
|
+
{...props}
|
|
128
|
+
attrs={{ role: 'search' }}
|
|
129
|
+
control={p => (
|
|
130
|
+
<input data-part="input" id={p.id} type="search" enterkeyhint="search" autocomplete="off" placeholder={placeholder ?? ' '} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} {...p.rest} />
|
|
131
|
+
)}
|
|
132
|
+
/>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A textarea: grows from `rows` lines (3) to 10 or `rows`, Cmd/Ctrl+Enter submits its form (03 §3). */
|
|
137
|
+
export function Textarea({ placeholder, rows = 3, ...props }: FieldProps & { placeholder?: string; rows?: number }) {
|
|
138
|
+
return (
|
|
139
|
+
<Field
|
|
140
|
+
{...props}
|
|
141
|
+
control={p => (
|
|
142
|
+
// field-sizing: content ignores the rows attribute, so the height reads it from --_rows
|
|
143
|
+
<textarea data-part="input" id={p.id} rows={rows} style={`--_rows:${rows}`} enterkeyhint="enter" placeholder={placeholder ?? ' '} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} {...p.rest}>{untrack(() => p.rest.value)}</textarea>
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}
|
package/src/Icon.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// warp's `icon` props: a name, or an icon from @weft-js/warp-icons. warp's own fixed glyphs import their icon directly.
|
|
2
|
+
import './styles'
|
|
3
|
+
import { AlertIcon, AlertCircleIcon, CheckCircleIcon, InfoIcon } from '@weft-js/warp-icons'
|
|
4
|
+
|
|
5
|
+
/** `<svg data-icon aria-hidden focusable=false>`: decorative, never named (03 §7); `label` names it. */
|
|
6
|
+
export { Icon, type IconName, type IconRef } from '@weft-js/warp-icons/icon'
|
|
7
|
+
|
|
8
|
+
export const toneIcon = { info: InfoIcon, neutral: InfoIcon, accent: InfoIcon, positive: CheckCircleIcon, caution: AlertIcon, critical: AlertCircleIcon } as const
|
package/src/Kbd.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { flush, onCleanup, state, untrack } from '@weft-js/core'
|
|
3
|
+
import { formReset } from '@weft-js/warp-core'
|
|
4
|
+
import { bindable, type Bindable } from './bind'
|
|
5
|
+
import { Button } from './Button'
|
|
6
|
+
import { Segmented } from './Choice'
|
|
7
|
+
import { JsonEditor } from './CodeInput'
|
|
8
|
+
import { Field, type FieldShell } from './Field'
|
|
9
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
10
|
+
import '@weft-js/warp-core/styles/rich/body.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/rich/key-value.css'
|
|
12
|
+
|
|
13
|
+
/** One row of a KeyValueEditor. */
|
|
14
|
+
export type KeyValue = { key: string; value: string }
|
|
15
|
+
|
|
16
|
+
/** Props for KeyValueEditor. */
|
|
17
|
+
export type KeyValueEditorProps = FieldShell & Bindable<KeyValue[]> & {
|
|
18
|
+
keyLabel?: string
|
|
19
|
+
valueLabel?: string
|
|
20
|
+
/** A Form ⇄ JSON switch; both keep the value. */
|
|
21
|
+
json?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const toJson = (rows: KeyValue[]) => JSON.stringify(Object.fromEntries(rows.filter(r => r.key).map(r => [r.key, r.value])), null, 2)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A table of `key | value | remove` rows with Add row (04 KeyValueEditor). A duplicate key is a caution, not a block.
|
|
28
|
+
* With `json`, a Form ⇄ JSON switch shows the same value as a JsonEditor. Rows keep their identity, so typing never
|
|
29
|
+
* loses focus; adding focuses the new key, removing the row before.
|
|
30
|
+
*/
|
|
31
|
+
export function KeyValueEditor({ value, defaultValue, onValueChange, keyLabel = 'Key', valueLabel = 'Value', json, hint, ...rest }: KeyValueEditorProps) {
|
|
32
|
+
const held = bindable<KeyValue[]>(() => value, () => defaultValue, [], () => onValueChange)
|
|
33
|
+
let serial = 0
|
|
34
|
+
const ids = state(untrack(() => held.value.map(() => ++serial)))
|
|
35
|
+
const mode = state<'form' | 'json'>('form')
|
|
36
|
+
const text = state('')
|
|
37
|
+
let body: HTMLElement | undefined
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
39
|
+
onCleanup(() => clearTimeout(timer))
|
|
40
|
+
const dup = (rows: KeyValue[], at: number) => !!rows[at]!.key && rows.findIndex(r => r.key === rows[at]!.key) !== at
|
|
41
|
+
const hasDup = () => held.value.some((_, at) => dup(held.value, at))
|
|
42
|
+
const edit = (at: number, part: 'key' | 'value', v: string) => held.set(held.value.map((r, n) => (n === at ? { ...r, [part]: v } : r)), 'keyboard')
|
|
43
|
+
const add = () => {
|
|
44
|
+
held.set([...held.value, { key: '', value: '' }], 'pointer')
|
|
45
|
+
ids.value = [...ids.value, ++serial]
|
|
46
|
+
flush()
|
|
47
|
+
body?.querySelector<HTMLElement>('tbody tr:last-child input')?.focus()
|
|
48
|
+
}
|
|
49
|
+
const remove = (at: number) => {
|
|
50
|
+
held.set(held.value.filter((_, n) => n !== at), 'pointer')
|
|
51
|
+
ids.value = ids.value.filter((_, n) => n !== at)
|
|
52
|
+
flush()
|
|
53
|
+
const left = body?.querySelectorAll<HTMLElement>('[data-part="remove"]')
|
|
54
|
+
;(left?.[Math.min(at, (left?.length ?? 1) - 1)] ?? body?.querySelector<HTMLElement>('[data-part="add"]'))?.focus()
|
|
55
|
+
}
|
|
56
|
+
const flipMode = (m: 'form' | 'json') => {
|
|
57
|
+
if (m === 'json') text.value = toJson(held.value)
|
|
58
|
+
mode.value = m
|
|
59
|
+
}
|
|
60
|
+
const fromJson = (v: string) => {
|
|
61
|
+
text.value = v
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
try {
|
|
65
|
+
const o = JSON.parse(v)
|
|
66
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) return
|
|
67
|
+
const rows = Object.entries(o).map(([key, val]) => ({ key, value: typeof val === 'string' ? val : JSON.stringify(val) }))
|
|
68
|
+
held.set(rows, 'keyboard')
|
|
69
|
+
ids.value = rows.map(() => ++serial)
|
|
70
|
+
} catch { /* the editor shows the error; the last good value stands */ }
|
|
71
|
+
}, 250)
|
|
72
|
+
}
|
|
73
|
+
const switcher = untrack(() => json) ? <Segmented label={`${rest.label} format`} size="sm" value={mode.value} options={[{ value: 'form', label: 'Form' }, { value: 'json', label: 'JSON' }]} onChange={m => flipMode(m)} /> : undefined
|
|
74
|
+
let root: HTMLDivElement | undefined
|
|
75
|
+
use(formReset, () => root, () => () => {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
held.reset()
|
|
78
|
+
ids.value = held.value.map(() => ++serial)
|
|
79
|
+
text.value = toJson(held.value)
|
|
80
|
+
})
|
|
81
|
+
return (
|
|
82
|
+
<div ref={n => (root = n)} data-scope="kv">
|
|
83
|
+
{switcher}
|
|
84
|
+
{mode.value === 'json'
|
|
85
|
+
? <JsonEditor {...rest} value={text.value} onValueChange={fromJson} />
|
|
86
|
+
: (
|
|
87
|
+
<Field
|
|
88
|
+
{...rest}
|
|
89
|
+
group
|
|
90
|
+
filled
|
|
91
|
+
hint={hasDup() ? 'That key is already here: the last one wins.' : hint}
|
|
92
|
+
attrs={{ 'data-multiline': '' }}
|
|
93
|
+
control={() => (
|
|
94
|
+
<div ref={n => (body = n)}>
|
|
95
|
+
<table>
|
|
96
|
+
<thead><tr><th scope="col">{keyLabel}</th><th scope="col">{valueLabel}</th><th scope="col"><span data-part="sr-only">Remove</span></th></tr></thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
{ids.value.map((rowId, at) => (
|
|
99
|
+
<tr key={rowId}>
|
|
100
|
+
<td><input aria-label={`${keyLabel}, row ${at + 1}`} value={held.value[at]?.key ?? ''} data-duplicate={held.value[at] && dup(held.value, at) ? '' : undefined} onInput={(e: Event) => edit(at, 'key', (e.currentTarget as HTMLInputElement).value)} /></td>
|
|
101
|
+
<td><input aria-label={`${valueLabel}, row ${at + 1}`} value={held.value[at]?.value ?? ''} onInput={(e: Event) => edit(at, 'value', (e.currentTarget as HTMLInputElement).value)} /></td>
|
|
102
|
+
<td><button type="button" data-part="remove" aria-label={`Remove row ${at + 1}`} onClick={() => remove(at)}><XIcon /></button></td>
|
|
103
|
+
</tr>
|
|
104
|
+
))}
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
<Button emphasis="ghost" tone="neutral" size="sm" data-part="add" onClick={add}>Add row</Button>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
package/src/Layout.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Layout primitives (02 §3, PLAN §6): each is the spec's `data-scope` box and nothing else. Relationships, not pixels:
|
|
2
|
+
// `gap` is the closed Space scale, widths are named, and there is no breakpoint prop, since the boxes answer to their
|
|
3
|
+
// container. `as` picks the element from the tags below; weft has no dynamic tag, so each is one compiled template.
|
|
4
|
+
import { untrack } from '@weft-js/core'
|
|
5
|
+
import type { Space } from '@weft-js/core/ds'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/layout/base.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/layout/grid.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/layout/split.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/layout/center.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/layout/cover.css'
|
|
15
|
+
import '@weft-js/warp-core/styles/layout/frame.css'
|
|
16
|
+
import '@weft-js/warp-core/styles/layout/spacer.css'
|
|
17
|
+
import '@weft-js/warp-core/styles/layout/inset.css'
|
|
18
|
+
import './layout.css'
|
|
19
|
+
|
|
20
|
+
type Open = { children?: unknown; [attr: string]: unknown }
|
|
21
|
+
type Tag = keyof typeof TAGS
|
|
22
|
+
/** A breakpoint a layout can switch at. */
|
|
23
|
+
export type Step = 'tablet' | 'desktop'
|
|
24
|
+
type Box = Open & { as?: Tag | undefined; gap?: Space | undefined }
|
|
25
|
+
|
|
26
|
+
function Div({ children, ...rest }: Open) {
|
|
27
|
+
return <div {...rest}>{children}</div>
|
|
28
|
+
}
|
|
29
|
+
function Section({ children, ...rest }: Open) {
|
|
30
|
+
return <section {...rest}>{children}</section>
|
|
31
|
+
}
|
|
32
|
+
function Article({ children, ...rest }: Open) {
|
|
33
|
+
return <article {...rest}>{children}</article>
|
|
34
|
+
}
|
|
35
|
+
function Main({ children, ...rest }: Open) {
|
|
36
|
+
return <main {...rest}>{children}</main>
|
|
37
|
+
}
|
|
38
|
+
function Header({ children, ...rest }: Open) {
|
|
39
|
+
return <header {...rest}>{children}</header>
|
|
40
|
+
}
|
|
41
|
+
function Footer({ children, ...rest }: Open) {
|
|
42
|
+
return <footer {...rest}>{children}</footer>
|
|
43
|
+
}
|
|
44
|
+
function Nav({ children, ...rest }: Open) {
|
|
45
|
+
return <nav {...rest}>{children}</nav>
|
|
46
|
+
}
|
|
47
|
+
function Aside({ children, ...rest }: Open) {
|
|
48
|
+
return <aside {...rest}>{children}</aside>
|
|
49
|
+
}
|
|
50
|
+
function Form({ children, ...rest }: Open) {
|
|
51
|
+
return <form {...rest}>{children}</form>
|
|
52
|
+
}
|
|
53
|
+
function Ul({ children, ...rest }: Open) {
|
|
54
|
+
return <ul {...rest}>{children}</ul>
|
|
55
|
+
}
|
|
56
|
+
function Ol({ children, ...rest }: Open) {
|
|
57
|
+
return <ol {...rest}>{children}</ol>
|
|
58
|
+
}
|
|
59
|
+
const TAGS = { div: Div, section: Section, article: Article, main: Main, header: Header, footer: Footer, nav: Nav, aside: Aside, form: Form, ul: Ul, ol: Ol }
|
|
60
|
+
|
|
61
|
+
function Element({ as, ...rest }: Box) {
|
|
62
|
+
const Tag = TAGS[untrack(() => as) ?? 'div']
|
|
63
|
+
return <Tag {...rest} />
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Vertical flow; the gap is the relationship between the children. */
|
|
67
|
+
export function Stack({ gap, align, divided, ...rest }: Box & { align?: 'start' | 'center' | 'end' | undefined; divided?: boolean | undefined }) {
|
|
68
|
+
return <Element data-scope="stack" data-gap={gap} data-align={align} data-divided={divided ? '' : undefined} {...rest} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A horizontal cluster that wraps. `reflow` makes it a Stack below that step; `wrap={false}` keeps it on one line. */
|
|
72
|
+
export function Row({ gap, justify, align, wrap, reflow, ...rest }: Box & { justify?: 'start' | 'between' | 'end' | 'center' | undefined; align?: 'start' | 'baseline' | undefined; wrap?: boolean | undefined; reflow?: Step | undefined }) {
|
|
73
|
+
return <Element data-scope="row" data-gap={gap} data-justify={justify === 'start' ? undefined : justify} data-align={align} data-wrap={wrap === false ? 'none' : undefined} data-reflow={reflow} {...rest} />
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Intrinsic columns: as many as fit at `min`, never more than `cols`. A child with `data-span="full"` takes the row. */
|
|
77
|
+
export function Grid({ gap, min, cols, ...rest }: Box & { min?: 'xs' | 'sm' | 'md' | 'lg' | undefined; cols?: 2 | 3 | 4 | 6 | undefined }) {
|
|
78
|
+
return <Element data-scope="grid" data-gap={gap} data-min={min} data-cols={cols} {...rest} />
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A fixed side and a fluid main, stacked below its step. Each child is a container. */
|
|
82
|
+
export function Split({ gap, side, width, at, ...rest }: Box & { side?: 'start' | 'end' | undefined; width?: 'rail' | 'nav' | 'list' | 'half' | undefined; at?: Step | undefined }) {
|
|
83
|
+
return <Element data-scope="split" data-gap={gap} data-side={side === 'start' ? undefined : side} data-width={width} data-at={at === 'tablet' ? undefined : at} {...rest} />
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A measured column, centred, with page gutters when `gutter` is set. */
|
|
87
|
+
export function Center({ max, gutter, intrinsic, ...rest }: Box & { max?: 'measure' | 'narrow' | 'content' | 'wide' | undefined; gutter?: boolean | undefined; intrinsic?: boolean | undefined }) {
|
|
88
|
+
return <Element data-scope="center" data-max={max === 'content' ? undefined : max} data-gutter={gutter ? '' : undefined} data-intrinsic={intrinsic ? '' : undefined} {...rest} />
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fills the screen: the child with `data-part="center"` is centred, a header and footer hug the edges (auth, error pages). */
|
|
92
|
+
export function Cover({ gap, min, ...rest }: Box & { min?: 'screen' | 'fill' | undefined }) {
|
|
93
|
+
return <Element data-scope="cover" data-gap={gap} data-min={min === 'screen' ? undefined : min} {...rest} />
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A fixed-ratio media box with no chrome of its own. */
|
|
97
|
+
export function Frame({ ratio, ...rest }: Box & { ratio?: 'square' | '4:3' | '3:2' | '16:9' | '21:9' | '3:4' | '9:16' | undefined }) {
|
|
98
|
+
return <Element data-scope="frame" data-ratio={ratio === '16:9' ? undefined : ratio} {...rest} />
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Takes the free space in a Row or Stack, so what follows sits at the far end. */
|
|
102
|
+
export function Spacer(rest: Open) {
|
|
103
|
+
return <div data-scope="spacer" {...rest}></div>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A sunken, padded region. */
|
|
107
|
+
export function Inset({ pad, ...rest }: Box & { pad?: 'group' | 'section' | undefined }) {
|
|
108
|
+
return <Element data-scope="inset" data-pad={pad === 'section' ? pad : undefined} {...rest} />
|
|
109
|
+
}
|
package/src/Markdown.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { markdownToHtml, nextIndex, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, flush, state, untrack } from '@weft-js/core'
|
|
3
|
+
import { bindable, type Bindable } from './bind'
|
|
4
|
+
import { Textarea, type FieldShell } from './Field'
|
|
5
|
+
import './styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/rich/markdown.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/md-editor.css'
|
|
8
|
+
|
|
9
|
+
/** Markdown, rendered (04 Markdown): a small, safe renderer. Raw HTML is escaped and shows as text. */
|
|
10
|
+
export function Markdown({ source, linkTarget, ...rest }: { source: string; linkTarget?: '_self' | '_blank' | undefined; [attr: string]: unknown }) {
|
|
11
|
+
return <article data-scope="markdown" {...rest}
|
|
12
|
+
// oxlint-disable-next-line weft/no-html-sink -- markdownToHtml sanitizes
|
|
13
|
+
innerHTML={markdownToHtml(source, { linkTarget })}></article>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Props for MarkdownEditor. */
|
|
17
|
+
export type MarkdownEditorProps = FieldShell & Bindable<string> & { rows?: number }
|
|
18
|
+
|
|
19
|
+
const WRAPS: Record<string, [string, string]> = { b: ['**', '**'], i: ['_', '_'], k: ['[', '](https://)'] }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Source and preview (04 MarkdownEditor; inputs-rich.html:2299): side by side from 640px of its own width, tabs below
|
|
23
|
+
* that. ⌘B, ⌘I and ⌘K wrap the selection, and stay undoable.
|
|
24
|
+
*/
|
|
25
|
+
export function MarkdownEditor({ value, defaultValue, onValueChange, rows = 12, ...rest }: MarkdownEditorProps) {
|
|
26
|
+
const id = uid('mde')
|
|
27
|
+
const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
|
|
28
|
+
const tab = state<'write' | 'preview'>('write')
|
|
29
|
+
const html = derived(() => markdownToHtml(held.value))
|
|
30
|
+
const onKey = (e: KeyboardEvent) => {
|
|
31
|
+
const wrap = (e.metaKey || e.ctrlKey) && WRAPS[e.key.toLowerCase()]
|
|
32
|
+
if (!wrap) return
|
|
33
|
+
e.preventDefault()
|
|
34
|
+
const a = e.currentTarget as HTMLTextAreaElement
|
|
35
|
+
const { selectionStart: from, selectionEnd: to } = a
|
|
36
|
+
const text = wrap[0] + a.value.slice(from, to) + wrap[1]
|
|
37
|
+
if (!document.execCommand?.('insertText', false, text)) a.setRangeText(text, from, to, 'end')
|
|
38
|
+
a.setSelectionRange(from + wrap[0].length, to + wrap[0].length)
|
|
39
|
+
held.set(a.value, 'keyboard')
|
|
40
|
+
}
|
|
41
|
+
const onTabKey = (e: KeyboardEvent) => {
|
|
42
|
+
const to = nextIndex(e.key, tab.value === 'write' ? 0 : 1, 2)
|
|
43
|
+
if (to < 0) return
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
tab.value = to ? 'preview' : 'write'
|
|
46
|
+
flush()
|
|
47
|
+
;(e.currentTarget as Element).querySelector<HTMLElement>('[aria-selected="true"]')?.focus()
|
|
48
|
+
}
|
|
49
|
+
const button = (name: 'write' | 'preview', text: string) => (
|
|
50
|
+
<button type="button" role="tab" id={`${id}-${name}`} aria-selected={tab.value === name ? 'true' : 'false'} aria-controls={`${id}-${name}-p`} tabindex={tab.value === name ? '0' : '-1'} onClick={() => (tab.value = name)}>{text}</button>
|
|
51
|
+
)
|
|
52
|
+
return (
|
|
53
|
+
<div data-scope="md-editor" data-tab={tab.value}>
|
|
54
|
+
<div data-part="tabs" role="tablist" aria-label={untrack(() => rest.label)} onKeyDown={onTabKey}>{button('write', 'Write')}{button('preview', 'Preview')}</div>
|
|
55
|
+
<div data-part="source" role="tabpanel" id={`${id}-write-p`} aria-labelledby={`${id}-write`}>
|
|
56
|
+
<Textarea {...rest} reset={held.reset} rows={rows} value={held.value} spellcheck={false} onInput={(e: Event) => held.set((e.currentTarget as HTMLInputElement).value, 'keyboard')} onKeyDown={onKey} />
|
|
57
|
+
</div>
|
|
58
|
+
<div data-scope="markdown" role="tabpanel" id={`${id}-preview-p`} aria-labelledby={`${id}-preview`} tabindex="0"
|
|
59
|
+
// oxlint-disable-next-line weft/no-html-sink -- the preview comes from markdownToHtml, which sanitizes
|
|
60
|
+
innerHTML={html.value}></div>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { caretPoint, comboPopup, findTrigger, mentionToken, parseMentions, serializeMentions, shiftMarks, uid, type MentionItem, type MentionMark } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, flush, onCleanup, state, untrack, watch } from '@weft-js/core'
|
|
3
|
+
import { bindable, type Bindable } from './bind'
|
|
4
|
+
import { Field, type FieldShell } from './Field'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import '@weft-js/warp-core/styles/rich/listbox.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/popover.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
9
|
+
|
|
10
|
+
/** Props for MentionsInput. */
|
|
11
|
+
export type MentionsInputProps = FieldShell & Bindable<string> & {
|
|
12
|
+
/** Trigger character → its search. Superseded calls are aborted. */
|
|
13
|
+
triggers: Record<string, (q: string, signal: AbortSignal) => Promise<MentionItem[]>>
|
|
14
|
+
rows?: number
|
|
15
|
+
name?: string | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const initials = (s: string) => s.split(/\s+/).map(w => w[0]).join('').slice(0, 2)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A plain textarea with trigger characters (04 MentionsInput; inputs-rich.html:2367-2401), so paste, autofill and
|
|
22
|
+
* spellcheck still work. The list opens at the caret: ↑/↓ then Enter or Tab inserts, Esc closes, and an insertion is
|
|
23
|
+
* announced. The value keeps tokens as `@[Ada Lovelace](user:ada)`; the textarea shows the name, and editing inside a
|
|
24
|
+
* name turns it back into plain text.
|
|
25
|
+
*/
|
|
26
|
+
export function MentionsInput({ triggers, rows = 3, value, defaultValue, onValueChange, name, hint, ...rest }: MentionsInputProps) {
|
|
27
|
+
const id = uid('men')
|
|
28
|
+
const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
|
|
29
|
+
const start = untrack(() => parseMentions(held.value))
|
|
30
|
+
const text = state(start.text)
|
|
31
|
+
let marks: MentionMark[] = start.marks
|
|
32
|
+
let written = held.value
|
|
33
|
+
const hits = state<MentionItem[]>([])
|
|
34
|
+
const active = state(0)
|
|
35
|
+
const open = state(false)
|
|
36
|
+
const said = state('')
|
|
37
|
+
const empty = state('')
|
|
38
|
+
let at: { trigger: string; query: string; at: number } | undefined
|
|
39
|
+
let area: HTMLTextAreaElement | undefined
|
|
40
|
+
let pop: HTMLDivElement | undefined
|
|
41
|
+
let mark: HTMLSpanElement | undefined
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
43
|
+
let stop: AbortController | undefined
|
|
44
|
+
const cancel = () => { clearTimeout(timer); stop?.abort(); stop = undefined }
|
|
45
|
+
onCleanup(cancel)
|
|
46
|
+
use(comboPopup, () => pop, () => ({ open: open.value, anchor: mark!, onClose: () => (open.value = false) }))
|
|
47
|
+
// a value set from outside: a write of our own compares equal
|
|
48
|
+
watch(() => held.value, v => {
|
|
49
|
+
if (v === written) return
|
|
50
|
+
const next = parseMentions(v)
|
|
51
|
+
written = v
|
|
52
|
+
marks = next.marks
|
|
53
|
+
text.value = next.text
|
|
54
|
+
})
|
|
55
|
+
const publish = (reason: 'keyboard' | 'pointer') => { written = serializeMentions(area!.value, marks); held.set(written, reason) }
|
|
56
|
+
const place = () => {
|
|
57
|
+
const a = area!
|
|
58
|
+
const p = caretPoint(a, at!.at)
|
|
59
|
+
mark!.style.insetInlineStart = `${a.offsetLeft + Math.min(p.x, a.clientWidth - 220)}px`
|
|
60
|
+
mark!.style.insetBlockStart = `${a.offsetTop + p.y}px`
|
|
61
|
+
}
|
|
62
|
+
const scan = () => {
|
|
63
|
+
const a = area!
|
|
64
|
+
at = findTrigger(a.value, a.selectionStart, Object.keys(triggers), marks)
|
|
65
|
+
cancel()
|
|
66
|
+
if (!at) { open.value = false; return }
|
|
67
|
+
const found = at
|
|
68
|
+
// oxlint-disable-next-line typescript/no-misused-promises -- the body catches its own failure, and a sync wrapper would add a closure to a hot field
|
|
69
|
+
timer = setTimeout(async () => {
|
|
70
|
+
const me = (stop = new AbortController())
|
|
71
|
+
try {
|
|
72
|
+
const r = await triggers[found.trigger]!(found.query, me.signal)
|
|
73
|
+
if (me !== stop) return
|
|
74
|
+
hits.value = r
|
|
75
|
+
active.value = 0
|
|
76
|
+
empty.value = r.length ? '' : `No matches for ${found.trigger}${found.query}`
|
|
77
|
+
place()
|
|
78
|
+
open.value = true
|
|
79
|
+
} catch { if (me === stop) open.value = false }
|
|
80
|
+
}, 120)
|
|
81
|
+
}
|
|
82
|
+
const pick = (n: number, reason: 'keyboard' | 'pointer') => {
|
|
83
|
+
const a = area!, it = hits.value[n]
|
|
84
|
+
if (!it || !at) return
|
|
85
|
+
const shown = at.trigger + it.label
|
|
86
|
+
const end = a.selectionStart
|
|
87
|
+
const was = a.value
|
|
88
|
+
a.setRangeText(`${shown} `, at.at, end, 'end')
|
|
89
|
+
marks = [...shiftMarks(marks, was, a.value), { start: at.at, end: at.at + shown.length, token: mentionToken(at.trigger, it) }]
|
|
90
|
+
open.value = false
|
|
91
|
+
text.value = a.value
|
|
92
|
+
publish(reason)
|
|
93
|
+
a.focus()
|
|
94
|
+
said.value = `${it.label} inserted`
|
|
95
|
+
}
|
|
96
|
+
const onInput = () => {
|
|
97
|
+
const a = area!
|
|
98
|
+
marks = shiftMarks(marks, text.value, a.value)
|
|
99
|
+
text.value = a.value
|
|
100
|
+
publish('keyboard')
|
|
101
|
+
scan()
|
|
102
|
+
}
|
|
103
|
+
const onKey = (e: KeyboardEvent) => {
|
|
104
|
+
if (!open.value || e.isComposing) return
|
|
105
|
+
if (e.key === 'Escape') { e.preventDefault(); open.value = false; return }
|
|
106
|
+
const n = hits.value.length
|
|
107
|
+
if (!n) return
|
|
108
|
+
const by = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
|
|
109
|
+
if (by) {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
active.value = (active.value + by + n) % n
|
|
112
|
+
flush()
|
|
113
|
+
pop?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' })
|
|
114
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pick(active.value, 'keyboard') }
|
|
115
|
+
}
|
|
116
|
+
const person = derived(() => at?.trigger === '@')
|
|
117
|
+
return (
|
|
118
|
+
<Field
|
|
119
|
+
{...rest}
|
|
120
|
+
hint={hint}
|
|
121
|
+
attrs={{ 'data-multiline': '' }}
|
|
122
|
+
filled={text.value.length > 0}
|
|
123
|
+
reset={held.reset}
|
|
124
|
+
hold={open.value}
|
|
125
|
+
control={p => (
|
|
126
|
+
<textarea ref={n => (area = n)} {...p.rest} data-part="input" id={p.id} rows={rows} placeholder=" " aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} aria-autocomplete="list" aria-controls={id} aria-activedescendant={open.value && hits.value.length ? `${id}-${active.value}` : undefined} value={text.value} onInput={onInput} onKeyDown={onKey} onClick={scan}>{start.text}</textarea>
|
|
127
|
+
)}
|
|
128
|
+
end={
|
|
129
|
+
<>
|
|
130
|
+
<span ref={n => (mark = n)} data-part="caret-anchor" aria-hidden="true"></span>
|
|
131
|
+
<div ref={n => (pop = n)} data-scope="popover" data-kind="combo" data-sweep="" popover="manual" role="presentation" onPointerDown={e => e.preventDefault()}>
|
|
132
|
+
<span data-part="sweep" aria-hidden="true"></span>
|
|
133
|
+
<ul data-part="listbox" role="listbox" id={id} aria-label="Suggestions">
|
|
134
|
+
{hits.value.map((it, n) => (
|
|
135
|
+
<li key={it.id} role="option" id={`${id}-${n}`} aria-selected={n === active.value ? 'true' : 'false'} data-active={n === active.value ? '' : undefined} onClick={() => pick(n, 'pointer')}>
|
|
136
|
+
{person.value && <span class="av" aria-hidden="true">{initials(it.label)}</span>}
|
|
137
|
+
<span>{it.label}</span>
|
|
138
|
+
{it.meta && <span data-part="opt-meta">{it.meta}</span>}
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
{!hits.value.length && <li role="option" aria-disabled="true" aria-selected="false">{empty.value}</li>}
|
|
142
|
+
</ul>
|
|
143
|
+
</div>
|
|
144
|
+
<span data-part="sr-only" role="status">{said.value}</span>
|
|
145
|
+
{name && <input type="hidden" name={name} value={held.value} />}
|
|
146
|
+
</>
|
|
147
|
+
}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
}
|