@weft-js/warp 0.0.0-stage → 0.1.1
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/Choice.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { indicator, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { untrack } from '@weft-js/core'
|
|
3
|
+
import type { Interaction } from '@weft-js/core/interaction'
|
|
4
|
+
import { Icon, type IconRef } from './Icon'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import { useUi } from './use-ui'
|
|
7
|
+
import './fields'
|
|
8
|
+
import '@weft-js/warp-core/styles/inputs/choice.css'
|
|
9
|
+
|
|
10
|
+
const sweepParts = () => <><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span></>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A native checkbox with role="switch" (03 §4; SIGNATURE §1c): the rail fills through the sweep as the thumb slides.
|
|
14
|
+
* The whole label is the hover target. Applies at once: never inside a form with Save (03 §8).
|
|
15
|
+
*/
|
|
16
|
+
export function Switch({ children, hint, size, placement = 'end', ui, ...rest }: { checked?: boolean; hint?: unknown; size?: 'sm' | 'md' | undefined; placement?: 'start' | 'end'; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
|
|
17
|
+
let host: HTMLLabelElement | undefined
|
|
18
|
+
useUi(() => ui, () => host)
|
|
19
|
+
return (
|
|
20
|
+
<label ref={n => (host = n)} data-scope="switch" data-placement={placement} data-size={size === 'sm' ? 'sm' : undefined}>
|
|
21
|
+
<input type="checkbox" role="switch" {...rest} />
|
|
22
|
+
<span data-part="track" data-sweep="">
|
|
23
|
+
<span data-part="rail-fill" aria-hidden="true"><span data-part="rail-hover"></span></span>
|
|
24
|
+
{sweepParts()}
|
|
25
|
+
<span data-part="thumb" aria-hidden="true"></span>
|
|
26
|
+
</span>
|
|
27
|
+
<span data-part="label">{children}</span>
|
|
28
|
+
{hint !== undefined && <span data-part="hint">{hint}</span>}
|
|
29
|
+
</label>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A native checkbox (03 §4): the box never fills, the check draws in; `indeterminate` is the real property. */
|
|
34
|
+
export function Checkbox({ children, hint, error, indeterminate, ui, ...rest }: { checked?: boolean; indeterminate?: boolean; hint?: unknown; error?: unknown; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
|
|
35
|
+
let input: HTMLInputElement | undefined
|
|
36
|
+
let host: HTMLLabelElement | undefined
|
|
37
|
+
useUi(() => ui, () => host)
|
|
38
|
+
use((el: HTMLInputElement, on: boolean | undefined) => {
|
|
39
|
+
el.indeterminate = !!on
|
|
40
|
+
return { update: v => void (el.indeterminate = !!v), destroy() {} }
|
|
41
|
+
}, () => input, () => indeterminate)
|
|
42
|
+
return (
|
|
43
|
+
<label ref={n => (host = n)} data-scope="checkbox" data-invalid={error ? '' : undefined}>
|
|
44
|
+
<input ref={n => (input = n)} type="checkbox" aria-invalid={error ? 'true' : undefined} {...rest} />
|
|
45
|
+
<span data-part="control" data-sweep="">
|
|
46
|
+
{sweepParts()}
|
|
47
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path data-part="check" pathLength="1" d="M5 12.5 L9.5 17 L19 7" /><path data-part="dash" pathLength="1" d="M6 12h12" /></svg>
|
|
48
|
+
</span>
|
|
49
|
+
<span data-part="label">{children}</span>
|
|
50
|
+
{(error ?? hint) !== undefined && <span data-part="hint">{error ?? hint}</span>}
|
|
51
|
+
</label>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One radio (03 §4): the native input, the sweep ring, a dot that springs in. Use RadioGroup for a set. */
|
|
56
|
+
export function Radio({ children, hint, ui, ...rest }: { hint?: unknown; ui?: Interaction; children?: unknown; [attr: string]: unknown }) {
|
|
57
|
+
let host: HTMLLabelElement | undefined
|
|
58
|
+
useUi(() => ui, () => host)
|
|
59
|
+
return (
|
|
60
|
+
<label ref={n => (host = n)} data-scope="radio">
|
|
61
|
+
<input type="radio" {...rest} />
|
|
62
|
+
<span data-part="control" data-sweep="">{sweepParts()}</span>
|
|
63
|
+
<span data-part="label">{children}</span>
|
|
64
|
+
{hint !== undefined && <span data-part="hint">{hint}</span>}
|
|
65
|
+
</label>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One RadioGroup choice. */
|
|
70
|
+
export type RadioOption<V extends string> = { value: V; label: unknown; hint?: unknown; disabled?: boolean | undefined }
|
|
71
|
+
|
|
72
|
+
/** A radio group: native radios, so arrow keys, form values and the group name are the platform's. */
|
|
73
|
+
export function RadioGroup<V extends string>({ label, value, onChange, options, name }: { label: string; value: V; onChange: (v: V) => void; options: RadioOption<V>[]; name?: string | undefined }) {
|
|
74
|
+
const group = untrack(() => name) ?? uid('radio')
|
|
75
|
+
return (
|
|
76
|
+
<div role="radiogroup" aria-label={label} style="display:grid;gap:var(--warp-space-tight)">
|
|
77
|
+
{options.map(o => <Radio key={o.value} name={group} value={o.value} hint={o.hint} checked={o.value === value} disabled={o.disabled} onChange={() => onChange(o.value)}>{o.label}</Radio>)}
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One Segmented choice. */
|
|
83
|
+
export type SegmentedOption<V extends string> = { value: V; label: unknown; icon?: IconRef | undefined; count?: unknown; disabled?: boolean | undefined }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Segmented (03 §4, review 01 M8): native radios in a group that rests on its ink border; hovering or focusing an
|
|
87
|
+
* unselected option sweeps a ring round that option only; the selected face slides to it on the sweep curve.
|
|
88
|
+
*/
|
|
89
|
+
export function Segmented<V extends string>({ label, value, onChange, options, size, name }: { label: string; value: V; onChange: (v: V) => void; options: SegmentedOption<V>[]; size?: 'sm' | 'md' | undefined; name?: string | undefined }) {
|
|
90
|
+
const group = untrack(() => name) ?? uid('seg')
|
|
91
|
+
let root: HTMLDivElement | undefined
|
|
92
|
+
use(indicator, () => root, () => ({ selected: ':scope > label:has(input:checked)', at: value }))
|
|
93
|
+
return (
|
|
94
|
+
<div ref={n => (root = n)} data-scope="segmented" role="radiogroup" aria-label={label} data-size={size === 'sm' ? 'sm' : undefined}>
|
|
95
|
+
<span data-part="indicator" aria-hidden="true" data-instant=""></span>
|
|
96
|
+
{options.map(o => (
|
|
97
|
+
<label key={o.value} data-sweep="">
|
|
98
|
+
<span data-part="sweep" aria-hidden="true"></span>
|
|
99
|
+
<input type="radio" name={group} value={o.value} checked={o.value === value} disabled={o.disabled} onChange={() => onChange(o.value)} />
|
|
100
|
+
<span data-part="item">{o.icon && <Icon name={o.icon} />}{o.label}{o.count !== undefined && <span data-part="count">{o.count}</span>}</span>
|
|
101
|
+
</label>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CopyIcon, CheckIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { highlight } from '@weft-js/warp-core'
|
|
3
|
+
import './styles'
|
|
4
|
+
import '@weft-js/warp-core/styles/components/button.css'
|
|
5
|
+
import '@weft-js/warp-core/styles/components/copy.css'
|
|
6
|
+
import '@weft-js/warp-core/styles/rich/codeblock.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/code-tabs.css'
|
|
8
|
+
|
|
9
|
+
/** Props for CodeBlock. */
|
|
10
|
+
export interface CodeBlockProps {
|
|
11
|
+
/** The source to show, colour and copy. */
|
|
12
|
+
code: string
|
|
13
|
+
/** tsx by default; ts, js, json and sh are coloured too, anything else stays plain. */
|
|
14
|
+
language?: string | undefined
|
|
15
|
+
/** A file name shown in the bar above the code. */
|
|
16
|
+
filename?: string | undefined
|
|
17
|
+
/** Line numbers. */
|
|
18
|
+
lines?: boolean | undefined
|
|
19
|
+
/** Wrap long lines instead of scrolling. */
|
|
20
|
+
wrap?: boolean | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function CopyButton({ text }: { text?: string | undefined }) {
|
|
24
|
+
return (
|
|
25
|
+
<button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-size="sm" data-act="copy" data-copy="" data-copy-text={text} aria-label="Copy code">
|
|
26
|
+
<span data-part="indicator"><CopyIcon /><CheckIcon /></span>
|
|
27
|
+
</button>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Code, coloured on the server (04 CodeBlock). It has no state, so it hydrates to nothing: `copyButtons()` from
|
|
33
|
+
* `@weft-js/warp-core`, called once anywhere in the app, runs every copy button in the document.
|
|
34
|
+
*/
|
|
35
|
+
export function CodeBlock({ code, language = 'tsx', filename, lines, wrap }: CodeBlockProps) {
|
|
36
|
+
return (
|
|
37
|
+
<figure data-scope="codeblock" data-wrap={wrap ? '' : undefined}>
|
|
38
|
+
<div data-part="head">
|
|
39
|
+
{filename && <span data-part="file">{filename}</span>}
|
|
40
|
+
<span class="sp"></span>
|
|
41
|
+
<span data-part="lang">{language}</span>
|
|
42
|
+
<CopyButton />
|
|
43
|
+
</div>
|
|
44
|
+
<pre tabindex="0" data-lines={lines ? '' : undefined}><code
|
|
45
|
+
// oxlint-disable-next-line weft/no-html-sink -- highlight escapes the source before it wraps tokens in spans
|
|
46
|
+
innerHTML={highlight(code, language)}></code></pre>
|
|
47
|
+
</figure>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inline code in running text. */
|
|
52
|
+
export function Code({ children }: { children: unknown }) {
|
|
53
|
+
return <code data-scope="code">{children}</code>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One command to copy, with its `$` (04 Snippet). */
|
|
57
|
+
export function Snippet({ code, prompt = '$' }: { code: string; prompt?: string }) {
|
|
58
|
+
return (
|
|
59
|
+
<div data-scope="snippet">
|
|
60
|
+
<span aria-hidden="true">{prompt}</span>
|
|
61
|
+
<code>{code}</code>
|
|
62
|
+
<CopyButton text={code} />
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { codeLayer, indented, jsonProblem, type ChangeReason } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, onCleanup, state, untrack } from '@weft-js/core'
|
|
3
|
+
import { bindable, type Bindable } from './bind'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { Field, type FieldShell } from './Field'
|
|
6
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/body.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/rich/code-input.css'
|
|
9
|
+
|
|
10
|
+
type CodeOwn = Bindable<string> & {
|
|
11
|
+
/** After a form's reset put the value back. */
|
|
12
|
+
onReset?: () => void
|
|
13
|
+
language?: string | undefined
|
|
14
|
+
/** Numbers each line in a gutter. Default `true`; off while `wrap` is on. */
|
|
15
|
+
lineNumbers?: boolean
|
|
16
|
+
tabSize?: 2 | 4
|
|
17
|
+
/** Soft-wraps long lines; line numbers then give way, since a number no longer means one row. */
|
|
18
|
+
wrap?: boolean
|
|
19
|
+
rows?: number
|
|
20
|
+
/** The line to mark in the gutter. */
|
|
21
|
+
errorLine?: number | undefined
|
|
22
|
+
name?: string | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Props for CodeInput: the field shell, the bound text and the editor's own options. */
|
|
26
|
+
export type CodeInputProps = FieldShell & CodeOwn
|
|
27
|
+
|
|
28
|
+
const LEAVE = 'Tab indents · Esc then Tab leaves'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A code field (04 CodeInput; inputs-rich.html:2223-2241): a <textarea> over a layer coloured with the CSS Custom
|
|
32
|
+
* Highlight API, so a key press builds no spans; browsers without it show the textarea's own plain text. Tab indents,
|
|
33
|
+
* Shift+Tab outdents, and Esc then Tab leaves, which the hint says. No autocorrect, spellcheck or autocapitalize.
|
|
34
|
+
*/
|
|
35
|
+
export function CodeInput({ value, defaultValue, onValueChange, language, lineNumbers = true, tabSize = 2, wrap, rows = 8, errorLine, name, hint, onReset, ...rest }: CodeInputProps) {
|
|
36
|
+
const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
|
|
37
|
+
const lines = derived(() => held.value.split('\n').map((_, n) => n + 1))
|
|
38
|
+
const numbered = derived(() => lineNumbers && !wrap)
|
|
39
|
+
let box: HTMLDivElement | undefined
|
|
40
|
+
let leaving = false
|
|
41
|
+
const set = (area: HTMLTextAreaElement) => held.set(area.value, 'keyboard')
|
|
42
|
+
const onKey = (e: KeyboardEvent) => {
|
|
43
|
+
const area = e.currentTarget as HTMLTextAreaElement
|
|
44
|
+
if (e.key === 'Escape') { leaving = true; return }
|
|
45
|
+
if (e.key !== 'Tab') { if (e.key !== 'Shift') leaving = false; return }
|
|
46
|
+
if (leaving) { leaving = false; return }
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
const r = indented(area.value, area.selectionStart, area.selectionEnd, tabSize, e.shiftKey)
|
|
49
|
+
area.setSelectionRange(r.from, r.to)
|
|
50
|
+
// execCommand keeps the browser's undo history, which setRangeText throws away
|
|
51
|
+
if (!document.execCommand?.('insertText', false, r.insert)) area.setRangeText(r.insert, r.from, r.to, 'end')
|
|
52
|
+
area.setSelectionRange(r.start, r.end)
|
|
53
|
+
set(area)
|
|
54
|
+
}
|
|
55
|
+
use(codeLayer, () => box, () => ({ text: held.value, language }))
|
|
56
|
+
return (
|
|
57
|
+
<Field
|
|
58
|
+
{...rest}
|
|
59
|
+
hint={hint ?? LEAVE}
|
|
60
|
+
attrs={{ 'data-multiline': '' }}
|
|
61
|
+
filled={held.value.length > 0}
|
|
62
|
+
reset={() => { held.reset(); onReset?.() }}
|
|
63
|
+
control={p => (
|
|
64
|
+
<div data-part="body">
|
|
65
|
+
<div ref={n => (box = n)} class="code-in" data-wrap={wrap ? '' : undefined} style={`--_tab:${tabSize}`}>
|
|
66
|
+
{numbered.value && <ol data-part="lines" class="ln" aria-hidden="true">{lines.value.map(n => <li key={n} data-error={n === errorLine ? '' : undefined}>{n}</li>)}</ol>}
|
|
67
|
+
<div data-part="surface">
|
|
68
|
+
<pre data-part="layer" aria-hidden="true">{`${held.value}\n`}</pre>
|
|
69
|
+
<textarea {...p.rest} data-part="input" id={p.id} rows={rows} name={name} spellcheck={false} autocapitalize="off" autocomplete="off" wrap={wrap ? 'soft' : 'off'} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} value={held.value} onInput={e => set(e.currentTarget)} onKeyDown={onKey}>{untrack(() => held.value)}</textarea>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Props for JsonEditor: CodeInput with the language fixed to JSON, and a schema check. */
|
|
79
|
+
export type JsonEditorProps = FieldShell & Omit<CodeOwn, 'language' | 'errorLine'> & {
|
|
80
|
+
/** Checks parsed JSON; what it returns shows as the field's error. */
|
|
81
|
+
schema?: (value: unknown) => string | null | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* CodeInput for JSON (04 JsonEditor): parse errors, and an optional schema's, name their line in the field's error and
|
|
86
|
+
* pin the sweep; Format rewrites it with `tabSize`.
|
|
87
|
+
*/
|
|
88
|
+
export function JsonEditor({ value, defaultValue, onValueChange, schema, tabSize = 2, ...rest }: JsonEditorProps) {
|
|
89
|
+
const held = bindable<string>(() => value, () => defaultValue, '', () => onValueChange)
|
|
90
|
+
const checked = state(held.value)
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
92
|
+
onCleanup(() => clearTimeout(timer))
|
|
93
|
+
const change = (v: string, reason: ChangeReason) => {
|
|
94
|
+
held.set(v, reason)
|
|
95
|
+
clearTimeout(timer)
|
|
96
|
+
timer = setTimeout(() => (checked.value = v), 250)
|
|
97
|
+
}
|
|
98
|
+
const problem = derived(() => (checked.value.trim() ? jsonProblem(checked.value) : null))
|
|
99
|
+
const failed = derived(() => {
|
|
100
|
+
const p = problem.value
|
|
101
|
+
if (p) return `Invalid JSON${p.line ? ` on line ${p.line}` : ''}: ${p.message}`
|
|
102
|
+
if (!schema || !checked.value.trim()) return undefined
|
|
103
|
+
return schema(JSON.parse(checked.value)) ?? undefined
|
|
104
|
+
})
|
|
105
|
+
const summary = derived(() => {
|
|
106
|
+
if (!checked.value.trim()) return LEAVE
|
|
107
|
+
const o = JSON.parse(checked.value)
|
|
108
|
+
return `Valid JSON · ${o && typeof o === 'object' ? `${Object.keys(o).length} ${Object.keys(o).length === 1 ? 'key' : 'keys'}` : typeof o} · ${LEAVE}`
|
|
109
|
+
})
|
|
110
|
+
const format = () => {
|
|
111
|
+
if (jsonProblem(held.value)) return
|
|
112
|
+
const next = JSON.stringify(JSON.parse(held.value), null, untrack(() => tabSize))
|
|
113
|
+
held.set(next, 'pointer')
|
|
114
|
+
checked.value = next
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<CodeInput
|
|
118
|
+
{...(rest)}
|
|
119
|
+
tabSize={tabSize}
|
|
120
|
+
language="json"
|
|
121
|
+
value={held.value}
|
|
122
|
+
onValueChange={change}
|
|
123
|
+
onReset={() => { clearTimeout(timer); checked.value = held.value }}
|
|
124
|
+
error={failed.value}
|
|
125
|
+
hint={problem.value ? LEAVE : summary.value}
|
|
126
|
+
errorLine={problem.value?.line}
|
|
127
|
+
end={<span data-part="suffix"><Button emphasis="ghost" tone="neutral" size="sm" disabled={!!problem.value} onClick={format}>Format</Button></span>}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
}
|
package/src/Combobox.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { comboPopup, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, flush, onCleanup, state, untrack } from '@weft-js/core'
|
|
3
|
+
import { Field, type FieldProps } from './Field'
|
|
4
|
+
import { Icon, type IconRef } from './Icon'
|
|
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
|
+
|
|
9
|
+
export type ComboRow = { main: string; sub?: string | undefined; meta?: string | undefined; icon?: IconRef | undefined }
|
|
10
|
+
|
|
11
|
+
export type ComboFieldProps<T> = FieldProps & {
|
|
12
|
+
/** The app's search. Superseded calls are aborted. */
|
|
13
|
+
find: (q: string, signal: AbortSignal) => Promise<T[]>
|
|
14
|
+
row: (hit: T) => ComboRow
|
|
15
|
+
onPick: (hit: T) => unknown
|
|
16
|
+
/** The text changed: by typing, or cleared by a second Esc. */
|
|
17
|
+
onText?: (text: string) => void
|
|
18
|
+
/** The search failed or took longer than `timeout` ms. */
|
|
19
|
+
onFail?: () => void
|
|
20
|
+
text: () => string
|
|
21
|
+
minChars?: number
|
|
22
|
+
timeout?: number
|
|
23
|
+
/** Announced in the hint while there is no search running. */
|
|
24
|
+
status?: () => string | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A text field with a list popup (04 §3, APG Combobox, list autocomplete with automatic selection): focus stays in the
|
|
29
|
+
* input. Typing `minChars` opens the list with the first result active, ↓/↑ wrap, Enter picks, Esc closes and keeps the
|
|
30
|
+
* text, a second Esc clears it, ↓ on a closed combobox searches again. Requests are debounced 120ms and aborted when
|
|
31
|
+
* superseded. Shared by PlaceLookupInput and AddressField's line 1.
|
|
32
|
+
*/
|
|
33
|
+
export function ComboField<T>({ find, row, onPick, onText, onFail, text, minChars = 2, timeout, status, hint, ...rest }: ComboFieldProps<T>) {
|
|
34
|
+
const id = uid('combo')
|
|
35
|
+
const hits = state<T[]>([])
|
|
36
|
+
const active = state(0)
|
|
37
|
+
const open = state(false)
|
|
38
|
+
const busy = state(false)
|
|
39
|
+
const rows = derived(() => hits.value.map((hit, at) => ({ at, r: untrack(() => row)(hit) })))
|
|
40
|
+
let input: HTMLInputElement | undefined
|
|
41
|
+
let pop: HTMLDivElement | undefined
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
43
|
+
let stop: AbortController | undefined
|
|
44
|
+
const cancel = () => { clearTimeout(timer); stop?.abort(); stop = undefined; busy.value = false }
|
|
45
|
+
onCleanup(cancel)
|
|
46
|
+
use(comboPopup, () => pop, () => ({ open: open.value, anchor: (input?.closest('[data-part="control"]') ?? input) as HTMLElement, onClose: () => (open.value = false) }))
|
|
47
|
+
const run = (q: string) => {
|
|
48
|
+
cancel()
|
|
49
|
+
if (q.trim().length < minChars) { open.value = false; hits.value = []; return }
|
|
50
|
+
// 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
|
|
51
|
+
timer = setTimeout(async () => {
|
|
52
|
+
const me = (stop = new AbortController())
|
|
53
|
+
busy.value = true
|
|
54
|
+
const late = timeout ? setTimeout(() => me.abort(), timeout) : undefined
|
|
55
|
+
try {
|
|
56
|
+
const r = await find(q.trim(), me.signal)
|
|
57
|
+
if (me !== stop) return
|
|
58
|
+
hits.value = r
|
|
59
|
+
active.value = 0
|
|
60
|
+
open.value = r.length > 0
|
|
61
|
+
} catch {
|
|
62
|
+
if (me === stop || me.signal.aborted) { open.value = false; onFail?.() }
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(late)
|
|
65
|
+
if (me === stop) busy.value = false
|
|
66
|
+
}
|
|
67
|
+
}, 120)
|
|
68
|
+
}
|
|
69
|
+
const pick = (at: number) => { open.value = false; onPick(hits.value[at]!) }
|
|
70
|
+
const onKey = (e: KeyboardEvent) => {
|
|
71
|
+
const el = e.currentTarget as HTMLInputElement
|
|
72
|
+
if (e.key === 'Escape') {
|
|
73
|
+
if (e.defaultPrevented) return // an overlay took it: this popup, or the dialog around a closed one
|
|
74
|
+
e.preventDefault()
|
|
75
|
+
if (el.value) { cancel(); onText?.('') }
|
|
76
|
+
} else if (e.key === 'ArrowDown' && !open.value) { e.preventDefault(); run(el.value) }
|
|
77
|
+
else if (open.value && hits.value.length) {
|
|
78
|
+
const d = ({ ArrowDown: 1, ArrowUp: -1 } as Record<string, number>)[e.key]
|
|
79
|
+
if (d) {
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
active.value = (active.value + d + hits.value.length) % hits.value.length
|
|
82
|
+
flush()
|
|
83
|
+
pop?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' })
|
|
84
|
+
} else if (e.key === 'Enter') { e.preventDefault(); pick(active.value) }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const shownHint = derived(() => (busy.value ? 'Searching…' : status?.() ?? hint))
|
|
88
|
+
return (
|
|
89
|
+
<Field
|
|
90
|
+
{...rest}
|
|
91
|
+
hint={shownHint.value}
|
|
92
|
+
filled={text().length > 0}
|
|
93
|
+
hold={open.value}
|
|
94
|
+
control={p => (
|
|
95
|
+
<input ref={n => (input = n)} {...p.rest} data-part="input" id={p.id} placeholder=" " role="combobox" aria-autocomplete="list" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} aria-activedescendant={open.value && hits.value.length ? `${id}-${active.value}` : undefined} aria-busy={busy.value ? 'true' : undefined} aria-describedby={p.describedby} aria-invalid={p.invalid ? 'true' : undefined} autocomplete={p.rest.autocomplete ?? 'off'} value={text()} onInput={e => { const v = (e.currentTarget).value; onText?.(v); run(v) }} onKeyDown={onKey} />
|
|
96
|
+
)}
|
|
97
|
+
end={
|
|
98
|
+
<div ref={n => (pop = n)} data-scope="popover" data-kind="combo" data-sweep="" popover="manual" role="presentation" onPointerDown={e => e.preventDefault()}>
|
|
99
|
+
<span data-part="sweep" aria-hidden="true"></span>
|
|
100
|
+
<ul data-part="listbox" role="listbox" id={id} aria-label={rest.label}>
|
|
101
|
+
{rows.value.map(({ at, r }) => (
|
|
102
|
+
<li key={`${at}:${r.main}`} role="option" id={`${id}-${at}`} aria-selected={at === active.value ? 'true' : 'false'} data-active={at === active.value ? '' : undefined} onClick={() => pick(at)}>
|
|
103
|
+
{r.icon && <Icon name={r.icon} />}
|
|
104
|
+
<span>{r.main}{r.sub && <small> {r.sub}</small>}</span>
|
|
105
|
+
{r.meta && <span data-part="opt-meta">{r.meta}</span>}
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
</div>
|
|
110
|
+
}
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
}
|