@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.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,70 @@
1
+ import { dayPeriods, fieldName, hour12, isComplete, isEmpty, isSegment, ymd, parseDate, parseTime, press, segmentParts, segmentText, segmentValueText, settle, writeParts, type SegmentKind, type SegmentOptions, type SegmentPart, type IsoDate,formReset } from '@weft-js/warp-core'
2
+ import { derived, flush, state, untrack, watch } from '@weft-js/core'
3
+ import { use } from '@weft-js/warp-core/weft'
4
+
5
+ const keyOf = (p: SegmentPart) => (isSegment(p) ? p.type : `l${p.at}`)
6
+
7
+ export type SegmentState = 'empty' | 'partial' | 'complete'
8
+
9
+ /**
10
+ * A segment group (04 §1.3): each part a role=spinbutton, ordered by the locale. `onChange` hears every edit with the
11
+ * group's state; the group only takes `value` when that value differs from what it holds, so a half-typed date stays.
12
+ */
13
+ export function Segments({ kind, locale, value, options, label, today, clears, required, disabled, readonly, onEdit, onBlurPartial }: { kind: SegmentKind; locale: string; value: string | null; options?: SegmentOptions | undefined; label?: string | undefined; today: string; clears?: number | undefined; required?: boolean | undefined; disabled?: boolean | undefined; readonly?: boolean | undefined; onEdit: (state: SegmentState, parts: SegmentPart[]) => void; onBlurPartial?: (() => unknown) | undefined }) {
14
+ const make = () => writeParts(segmentParts(untrack(() => kind), untrack(() => locale), untrack(() => options)), untrack(() => kind), untrack(() => value))
15
+ const parts = state(make())
16
+ const shape = derived(() => { const l = locale; return { periods: dayPeriods(l), h12: hour12(l, options?.hourCycle), l } })
17
+ let group: HTMLDivElement | undefined
18
+ let edited = false
19
+ const year = () => ymd(untrack(() => today))[0]
20
+ const spins = () => [...group!.querySelectorAll<HTMLElement>('[data-part="segment"]')]
21
+ const update = (next: SegmentPart[]) => {
22
+ if (next === parts.value) return
23
+ parts.value = next
24
+ // the value this edit makes (null for a partial or impossible date) comes back through `value`: not a new value
25
+ edited = true
26
+ queueMicrotask(() => (edited = false))
27
+ const st: SegmentState = isEmpty(next) ? 'empty' : isComplete(next) ? 'complete' : 'partial'
28
+ onEdit(st, next)
29
+ }
30
+ // a new locale reorders the parts; a value from outside fills them
31
+ watch(() => locale, (l, prev) => { if (prev !== undefined && l !== prev) parts.value = make() })
32
+ watch(() => value, v => { if (!edited) parts.value = writeParts(parts.value, untrack(() => kind), v) })
33
+ watch(() => clears, (n, prev) => { if (prev !== undefined && n !== prev) parts.value = writeParts(parts.value, untrack(() => kind), null) })
34
+ // a half-typed date has no value to go back through
35
+ use(formReset, () => group, () => () => { parts.value = writeParts(parts.value, untrack(() => kind), untrack(() => value)) })
36
+ const onKey = (e: KeyboardEvent) => {
37
+ if (readonly || disabled) return
38
+ const at = spins().indexOf(e.target as HTMLElement)
39
+ if (at < 0) return
40
+ const r = press(parts.value, at, e.key, { year: year(), meta: e.metaKey || e.ctrlKey, rtl: getComputedStyle(group!).direction === 'rtl' })
41
+ if (!r.handled) return
42
+ e.preventDefault()
43
+ update(r.parts)
44
+ if (r.move !== undefined) spins()[r.move]?.focus()
45
+ }
46
+ const onLeave = (e: FocusEvent) => {
47
+ const at = spins().indexOf(e.target as HTMLElement)
48
+ if (at >= 0) update(settle(parts.value, at, year()))
49
+ if (group!.contains(e.relatedTarget as Node)) return
50
+ const st = parts.value.filter(isSegment)
51
+ if (st.some(s => s.value !== null) && st.some(s => s.value === null)) onBlurPartial?.()
52
+ }
53
+ const onPaste = (e: ClipboardEvent) => {
54
+ if (readonly || disabled) return
55
+ e.preventDefault()
56
+ const text = e.clipboardData?.getData('text') ?? ''
57
+ const k = untrack(() => kind)
58
+ const v = k === 'time' ? parseTime(text) : k === 'date' ? parseDate(text, locale, today as IsoDate) : /^\d{4}-\d{2}/.exec(text)?.[0] ?? null
59
+ if (v) update(writeParts(parts.value, k, v))
60
+ }
61
+ return (
62
+ <div ref={n => (group = n)} data-part="segments" role={label ? 'group' : undefined} aria-label={label} onKeyDown={onKey} onFocusOut={onLeave} onPaste={onPaste}>
63
+ {parts.value.map(p => (isSegment(p)
64
+ ? <span key={keyOf(p)} data-part="segment" data-type={p.type} role={readonly ? undefined : 'spinbutton'} tabindex={disabled ? undefined : '0'} inputmode="numeric" aria-label={fieldName(p.type, shape.value.l)} aria-valuemin={readonly ? undefined : p.min} aria-valuemax={readonly ? undefined : p.max} aria-valuenow={readonly || p.value === null ? undefined : p.value} aria-valuetext={readonly ? undefined : segmentValueText(p, shape.value.periods, shape.value.l, shape.value.h12)} aria-required={required && !readonly ? 'true' : undefined} aria-disabled={disabled ? 'true' : undefined} data-placeholder={p.value === null && !p.buf ? '' : undefined}>{segmentText(p, shape.value.periods, shape.value.h12)}</span>
65
+ : <span key={keyOf(p)} data-part="literal" aria-hidden="true">{p.literal}</span>))}
66
+ </div>
67
+ )
68
+ }
69
+
70
+ export const afterRender = (fn: () => void) => { flush(); fn() }
package/src/Select.tsx ADDED
@@ -0,0 +1,192 @@
1
+ import { DownIcon } from '@weft-js/warp-icons'
2
+ import { flush, derived, omit, untrack, type State } from '@weft-js/core'
3
+ import { listbox, uid, type ChangeReason, type ListItem, type Presentation } from '@weft-js/warp-core'
4
+ import { bindable } from './bind'
5
+ import { Field, type FieldProps } from './Field'
6
+ import { use } from '@weft-js/warp-core/weft'
7
+ import './fields'
8
+ import '@weft-js/warp-core/styles/components/listbox.css'
9
+ import '@weft-js/warp-core/styles/inputs/choice.css' // the select and the field's chevron
10
+
11
+ /** One Select choice. */
12
+ export type Option = { value: string; label: string; disabled?: boolean; /** A second, quieter line of text in the row. */ hint?: string }
13
+ /** A labelled group of Select choices. */
14
+ export type OptionGroup = { group: string; options: Option[] }
15
+
16
+ /** Past this many options a Select searches (03 §4); `searchable` says so either way. */
17
+ const LONG = 15
18
+
19
+ const Item = ({ o, on }: { o: Option; on: string }) => <option value={o.value} disabled={o.disabled} selected={o.value === on}>{o.label}</option>
20
+
21
+ function Choice({ o, on }: { o: Option | OptionGroup; on: string }) {
22
+ return <>{'group' in o
23
+ ? <optgroup label={o.group}>{o.options.map(x => <Item key={x.value} o={x} on={on} />)}</optgroup>
24
+ : <Item o={o} on={on} />}</>
25
+ }
26
+
27
+ interface ListProps {
28
+ label: string
29
+ options: (Option | OptionGroup)[]
30
+ placeholder?: string
31
+ value?: State<string | null> | string | null
32
+ defaultValue?: string | null
33
+ /** Called with the picked option's value, and whether a pointer, a key or code picked it. */
34
+ onValueChange?: (value: string, reason: ChangeReason) => void
35
+ open?: State<boolean> | boolean
36
+ /** Starts open, when the component keeps its own open state. */
37
+ defaultOpen?: boolean
38
+ /** Called with `true` when the list opens and `false` when it closes. */
39
+ onOpenChange?: (open: boolean) => void
40
+ /** `auto` opens a popover, or a sheet when the viewport is under 600px wide; `popover` and `sheet` force one. */
41
+ presentation?: Presentation
42
+ /** Puts a search field at the top of the list. On by itself past 15 options. */
43
+ searchable?: boolean
44
+ }
45
+
46
+ const OWN = ['options', 'placeholder', 'value', 'defaultValue', 'onValueChange', 'open', 'defaultOpen', 'onOpenChange', 'presentation', 'searchable']
47
+
48
+ /**
49
+ * What a Select holds, with or without a form behind it: the value, the row list and its action on the trigger. The
50
+ * carrier, when there is one, is a real <select>: `onChange`, `selectedOptions`, `required`, reset and autofill all stay
51
+ * true on it. A pick writes it and fires its events, and whatever a handler then writes to it stands, so code that puts
52
+ * `el.value` back still works.
53
+ */
54
+ function useList(props: ListProps) {
55
+ const v = bindable<string | null>(() => props.value, () => props.defaultValue ?? undefined, null, () => props.onValueChange as ((v: string | null, r: ChangeReason) => void) | undefined)
56
+ const shown = bindable<boolean>(() => props.open, () => props.defaultOpen, false, () => props.onOpenChange)
57
+ const items = derived<ListItem[]>(() => [
58
+ ...(props.placeholder !== undefined ? [{ value: '', label: props.placeholder }] : []),
59
+ ...props.options.flatMap<ListItem>(o => ('group' in o ? o.options.map(x => ({ value: x.value, label: x.label, disabled: x.disabled, hint: x.hint, group: o.group })) : [o])),
60
+ ])
61
+ // with no value the first option shows, as a select does
62
+ const current = derived(() => v.value ?? items.value.find(i => !i.disabled)?.value ?? '')
63
+ const text = derived(() => { const c = current.value; return c === '' ? '' : items.value.find(i => i.value === c)?.label ?? '' })
64
+ let trigger: HTMLButtonElement | undefined
65
+ let carrier: HTMLSelectElement | undefined
66
+ let picking = false
67
+ const hold = () => { if (carrier && carrier.value !== current.value) carrier.value = current.value }
68
+ const read = () => { if (carrier && !picking && carrier.value !== current.value) v.set(carrier.value, 'programmatic') }
69
+ const pick = (next: string, reason: ChangeReason) => {
70
+ if (!carrier) return v.set(next, reason)
71
+ picking = true
72
+ carrier.value = next
73
+ carrier.dispatchEvent(new Event('input', { bubbles: true }))
74
+ carrier.dispatchEvent(new Event('change', { bubbles: true }))
75
+ picking = false
76
+ v.set(carrier.value, reason)
77
+ flush()
78
+ hold() // a value the binding holds stands
79
+ }
80
+ use(listbox, () => trigger, () => ({
81
+ open: props.open !== undefined || props.defaultOpen ? shown.value : undefined,
82
+ label: props.label,
83
+ items: items.value,
84
+ value: current.value,
85
+ presentation: props.presentation,
86
+ search: props.searchable ?? items.value.length > LONG,
87
+ onPick: pick,
88
+ onOpenChange: (o: boolean) => { if (o !== untrack(() => shown.value)) shown.set(o) },
89
+ }))
90
+ use((sel: HTMLSelectElement) => {
91
+ // autofill, and code that writes the select, arrive here
92
+ sel.addEventListener('change', read)
93
+ hold()
94
+ return { update: hold, destroy() { sel.removeEventListener('change', read) } }
95
+ }, () => carrier, () => [current.value, items.value])
96
+ return {
97
+ current,
98
+ text,
99
+ /** Code that wrote the select with no event: the trigger catches up on focus. */
100
+ refresh: read,
101
+ onReset: () => {
102
+ if (untrack(() => props.value) === undefined) v.reset()
103
+ hold()
104
+ },
105
+ trigger: (n: HTMLButtonElement) => (trigger = n),
106
+ carrier: (n: HTMLSelectElement) => (carrier = n),
107
+ focusTrigger: () => trigger?.focus(),
108
+ }
109
+ }
110
+
111
+ const Chevron = () => <span data-part="indicator" aria-hidden="true"><DownIcon /></span>
112
+ const FOR_CARRIER = ['name', 'form', 'required', 'autocomplete', 'onChange', 'onInput', 'readonly']
113
+
114
+ /**
115
+ * A select in the field shell, drawn by warp on every device: a popover on a desktop, a sheet on a phone (03 §4), a search
116
+ * input once the list is long. The trigger is a button (APG select-only combobox), and a hidden real <select> is the form
117
+ * value, so a form posts, resets and validates as it always did, and with scripting off that select is what shows.
118
+ * Handlers read `e.currentTarget.value` as usual; `onValueChange` hears the value and why.
119
+ */
120
+ export function Select(props: Omit<FieldProps, 'value' | 'defaultValue'> & ListProps & { onChange?: (e: Event & { currentTarget: HTMLSelectElement }) => void }) {
121
+ const list = useList(untrack(() => props)) // the props object is live; only its reads track
122
+ return (
123
+ <Field
124
+ {...omit(props, OWN)}
125
+ label={props.label}
126
+ filled
127
+ labelled
128
+ reset={list.onReset}
129
+ end={<Chevron />}
130
+ control={p => (
131
+ <>
132
+ <button
133
+ ref={list.trigger}
134
+ type="button"
135
+ data-part="input"
136
+ id={p.id}
137
+ role="combobox"
138
+ aria-haspopup="listbox"
139
+ aria-expanded="false"
140
+ aria-labelledby={`${p.id}-l`}
141
+ aria-describedby={p.describedby}
142
+ aria-invalid={p.invalid ? 'true' : undefined}
143
+ aria-required={p.rest.required ? 'true' : undefined}
144
+ aria-readonly={p.rest.readonly ? 'true' : undefined}
145
+ onFocus={list.refresh}
146
+ {...omit(p.rest, FOR_CARRIER)}
147
+ >
148
+ <span data-part="value" data-placeholder={props.placeholder}>{list.text.value}</span>
149
+ </button>
150
+ <select
151
+ ref={list.carrier}
152
+ data-part="carrier"
153
+ tabindex="-1"
154
+ aria-hidden="true"
155
+ name={p.rest.name}
156
+ form={p.rest.form}
157
+ required={p.rest.required}
158
+ autocomplete={p.rest.autocomplete}
159
+ disabled={p.rest.disabled}
160
+ onChange={(e: Event) => (p.rest.onChange as ((e: Event) => void) | undefined)?.(e)}
161
+ onInput={(e: Event) => (p.rest.onInput as ((e: Event) => void) | undefined)?.(e)}
162
+ onFocus={list.focusTrigger}
163
+ >
164
+ {props.placeholder !== undefined && <option value="" selected={untrack(() => list.current.value) === ''}>{props.placeholder}</option>}
165
+ {props.options.map(o => <Choice key={'group' in o ? o.group : o.value} o={o} on={untrack(() => list.current.value)} />)}
166
+ </select>
167
+ </>
168
+ )}
169
+ />
170
+ )
171
+ }
172
+
173
+ /**
174
+ * The Select of a table row, a pager or a toolbar (03 §4): the same list with no floating label, `label` its accessible
175
+ * name. It carries no <option> until it opens, and no <select> at all unless `name` puts it in a form.
176
+ */
177
+ export function CompactSelect(props: ListProps & { name?: string | undefined; id?: string | undefined; disabled?: boolean | undefined; describedby?: string | undefined }) {
178
+ const list = useList(untrack(() => props)) // the props object is live; only its reads track
179
+ const id = untrack(() => props.id) ?? uid('sel')
180
+ return (
181
+ <div data-scope="field" data-variant="classic" data-size="sm" data-compact="">
182
+ <div data-part="control" data-filled="" data-sweep="">
183
+ <span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
184
+ <button ref={list.trigger} type="button" data-part="input" id={id} role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-label={props.label} aria-describedby={props.describedby} disabled={props.disabled}>
185
+ <span data-part="value" data-placeholder={props.placeholder}>{list.text.value}</span>
186
+ </button>
187
+ {props.name && <input type="hidden" name={props.name} value={list.current.value} />}
188
+ <Chevron />
189
+ </div>
190
+ </div>
191
+ )
192
+ }
package/src/Shape.tsx ADDED
@@ -0,0 +1,20 @@
1
+ import { skeletonShape, isPart, type ShapeSpec, type SkeletonBox, type SkeletonPart } from '@weft-js/warp-core'
2
+ import './skeleton-css'
3
+
4
+ const part = (p: SkeletonPart) => <span data-scope="skeleton" data-shape={p.shape === 'text' ? undefined : p.shape} style={p.style}></span>
5
+ const box = (b: SkeletonBox): unknown => <div style={b.style}>{b.children.map(c => (isPart(c) ? part(c) : box(c)))}</div>
6
+
7
+ /**
8
+ * A skeleton shaped like the content a `<Boundary>` is waiting for (research 26 §4): the spec's Skeleton parts in a
9
+ * SkeletonScope region (05 §5), sized by warp-core's `skeletonShape()`. Use it as `pending`:
10
+ * `<Boundary pending={<Shape kind="list" rows={5} />}>`. Give it the content's counts so the swap shifts nothing
11
+ * (X031 measures it in dev).
12
+ */
13
+ export function Shape({ label = 'Loading', ...spec }: ShapeSpec & { label?: string | undefined }) {
14
+ const root = skeletonShape(spec)
15
+ return (
16
+ <div data-scope="skeleton-scope" aria-busy="true" role="status" aria-label={label} inert style={root.style}>
17
+ {root.children.map(c => (isPart(c) ? part(c) : box(c)))}
18
+ </div>
19
+ )
20
+ }
package/src/Shell.tsx ADDED
@@ -0,0 +1,308 @@
1
+ import { CheckIcon, type IconComponent, DownIcon, LeftIcon, MoreIcon, PanelIcon, RightIcon, SearchIcon, XIcon } from '@weft-js/warp-icons'
2
+ import { dialog, menu, shell, uid } from '@weft-js/warp-core'
3
+ import { derived, state, type State, untrack } from '@weft-js/core'
4
+ import { Icon, type IconRef } from './Icon'
5
+ import { use } from '@weft-js/warp-core/weft'
6
+ import './styles'
7
+ import '@weft-js/warp-core/styles/components/avatar.css'
8
+ import '@weft-js/warp-core/styles/components/button.css'
9
+ import '@weft-js/warp-core/styles/components/menu.css'
10
+ import '@weft-js/warp-core/styles/layout/tokens.css'
11
+ import '@weft-js/warp-core/styles/layout/base.css'
12
+ import '@weft-js/warp-core/styles/layout/shell.css'
13
+ import '@weft-js/warp-core/styles/layout/breadcrumb.css'
14
+
15
+ /** A nav glyph: an icon, or a function that draws one. */
16
+ export type NavIcon = IconRef | (() => unknown)
17
+ /** One line of the Shell's navigation. */
18
+ export interface NavItem {
19
+ id: string
20
+ label: string
21
+ icon?: NavIcon | undefined
22
+ href?: string | undefined
23
+ onSelect?: (() => void) | undefined
24
+ count?: number | undefined
25
+ /** Spoken after the count. Default " new". */
26
+ countLabel?: string | undefined
27
+ /** One of the phone bottom bar's tabs. Default the first group's first four. */
28
+ tab?: boolean | undefined
29
+ }
30
+ /** A run of nav items under an optional label. */
31
+ export type NavGroup = { label?: string | undefined; items: NavItem[] }
32
+ /** One step of the breadcrumb. */
33
+ export type Crumb = { label: string; href?: string }
34
+ /** One line of the account menu. */
35
+ export type MenuEntry = { label: string; icon?: NavIcon | undefined; href?: string; onSelect?: () => void; shortcut?: string; keys?: string; tone?: 'critical' | undefined } | 'separator'
36
+ /** An entry in the workspace switcher. */
37
+ export type Workspace = { id: string; name: string; initials: string; meta?: string | undefined }
38
+
39
+ /** Props for Shell. */
40
+ export interface ShellProps {
41
+ /** app (default) · focus: a wizard or checkout, with no nav. */
42
+ variant?: 'app' | 'focus' | undefined
43
+ brand?: { name: string; href?: string; mark?: unknown } | undefined
44
+ /** The phone bar's title; always shown in focus. */
45
+ title?: unknown
46
+ /** The tablet and desktop header's path. The last is the current page. */
47
+ crumbs?: Crumb[]
48
+ /** `child` puts a back button in the brand's place on a phone, for a page under another. */
49
+ depth?: 'root' | 'child'
50
+ /** Phone, depth child: the back button in the brand's place. */
51
+ back?: { label: string; href?: string; onClick?: (() => void) | undefined }
52
+ /** Focus variant: Close at the start and the step count at the end. */
53
+ close?: { label: string; href?: string; onClick?: (() => void) | undefined }
54
+ step?: unknown
55
+ command?: { label?: string | undefined; hint?: string; shortcut?: string; onOpen: () => void }
56
+ /** Header actions, between the command field and the account button. */
57
+ actions?: unknown
58
+ account?: { name: string; email?: string; initials: string; items?: MenuEntry[] } | undefined
59
+ switcher?: { current: string; workspaces: Workspace[]; onSwitch: (id: string) => void } | undefined
60
+ /** The first group is the main list; the rest are saved views and the like (sidebar only, the More sheet below). */
61
+ nav?: NavGroup[] | undefined
62
+ /** The nav's pinned items, above the collapse toggle. */
63
+ foot?: NavItem[]
64
+ current?: string
65
+ /** Names the navigation landmark for screen readers. */
66
+ navLabel?: string
67
+ /** false · true: the rail · 'hidden': no rail either, the edge tab opens the sidebar. A box, so the shell can write it. */
68
+ collapsed?: State<boolean | 'hidden'> | undefined
69
+ /** Names main when `island`, or the region around a preview. */
70
+ label?: string | undefined
71
+ /** A shell inside a page (a preview, a story): main becomes a named region, as a document has one main. */
72
+ island?: boolean | undefined
73
+ dock?: unknown
74
+ /** Dialogs, toasts and palettes: outside main, in the top layer or fixed, so nothing here takes up room. */
75
+ overlays?: unknown
76
+ children?: unknown
77
+ }
78
+
79
+ const sweepParts = () => <><span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span></>
80
+ const draw = (i: NavIcon | undefined) => (typeof i === 'function' ? i({}) : i ? <Icon name={i} /> : null)
81
+ const TABS = 4
82
+
83
+ function Item({ item, current, quiet, onPick }: { item: NavItem; current?: string | undefined; quiet?: boolean | undefined; onPick: () => void }) {
84
+ const it = untrack(() => item)
85
+ const press = (e: MouseEvent) => {
86
+ if (!it.href) e.preventDefault()
87
+ it.onSelect?.()
88
+ onPick()
89
+ }
90
+ return (
91
+ <a href={it.href ?? '#'} data-part="nav-item" data-sweep="" aria-current={current === it.id ? 'page' : undefined} onClick={press}>
92
+ <span data-part="icon">{draw(it.icon)}{sweepParts()}</span>
93
+ <span data-part="nav-label">{it.label}</span>
94
+ {it.count !== undefined && <span data-part="nav-count" style={quiet ? 'background:none;color:var(--warp-color-text-subtle)' : undefined}>{it.count}<span data-part="nav-count-label">{it.countLabel ?? ' new'}</span></span>}
95
+ </a>
96
+ )
97
+ }
98
+
99
+ function Crumbs({ items }: { items: Crumb[] }) {
100
+ // the first and last two stay below 1000px; the ellipsis stands for the rest
101
+ const rows = derived(() => items.flatMap((c, n) => {
102
+ const row = { key: c.label, crumb: c, collapse: n > 0 && n < items.length - 2, last: n === items.length - 1 }
103
+ return n === 1 && items.length > 3 ? [{ key: '…', crumb: items[items.length - 3], collapse: false, last: false, hidden: items.length - 3 }, row] : [row]
104
+ }))
105
+ return (
106
+ <nav data-part="crumbs" data-scope="breadcrumb" aria-label="Breadcrumb">
107
+ <ol>
108
+ {rows.value.map(r => (
109
+ <li key={r.key} data-part={'hidden' in r ? 'ellipsis' : undefined} data-collapse={r.collapse ? '' : undefined}>
110
+ {'hidden' in r
111
+ ? <a href={r.crumb!.href ?? '#'} aria-label={`Show ${r.hidden} more`}>…</a>
112
+ : r.last ? <span aria-current="page">{r.crumb.label}</span> : <a href={r.crumb.href ?? '#'}>{r.crumb.label}</a>}
113
+ </li>
114
+ ))}
115
+ </ol>
116
+ </nav>
117
+ )
118
+ }
119
+
120
+ function Entry({ entry, onPick }: { entry: MenuEntry; onPick: () => void }) {
121
+ const e = untrack(() => entry)
122
+ if (e === 'separator') return <div data-part="separator" role="separator"></div>
123
+ return (
124
+ <a data-part="item" role="menuitem" tabindex="-1" style="text-decoration:none" href={e.href ?? '#'} data-tone={e.tone} aria-keyshortcuts={e.keys} onClick={(ev: MouseEvent) => { if (!e.href) ev.preventDefault(); e.onSelect?.(); onPick() }}>
125
+ {draw(e.icon)}
126
+ <span data-part="item-label">{e.label}</span>
127
+ {e.shortcut && <kbd data-part="shortcut" aria-hidden="true">{e.shortcut}</kbd>}
128
+ </a>
129
+ )
130
+ }
131
+
132
+ /**
133
+ * The app shell (02 §5-6; layout.html:874-906): one element tree, three presentations, chosen by the shell's own
134
+ * width: phone a top bar and bottom tabs, tablet a rail that opens as an overlay, desktop a sidebar that collapses to
135
+ * the rail (`[`). DOM order is the focus order: skip link, brand, back, crumbs, command, actions, account, nav items,
136
+ * foot, main, dock. Parts are props, not children, so that order and the overlays' place outside main hold whatever
137
+ * an app passes. The account menu is an APG menu button; the More sheet is modal on a native <dialog>.
138
+ */
139
+ export function Shell({ variant = 'app', brand, title, crumbs, depth, back, close, step, command, actions, account, switcher, nav, foot, current, navLabel = 'Main', collapsed, label, island, dock, overlays, children }: ShellProps) {
140
+ const base = uid('shell')
141
+ const main = `${base}-main`, navId = `${base}-nav`, menuId = `${base}-menu`, sheetId = `${base}-sheet`
142
+ const focus = derived(() => variant === 'focus')
143
+ const folded = untrack(() => collapsed) ?? state<boolean | 'hidden'>(false)
144
+ const navOpen = state(false)
145
+ const wide = state(false)
146
+ const menuOpen = state(false)
147
+ const moreOpen = state(false)
148
+ let from: HTMLElement | null = null
149
+ let at: 'first' | 'last' = 'first'
150
+ let root: HTMLDivElement | undefined
151
+ let menuEl: HTMLDivElement | undefined
152
+ let sheetEl: HTMLDialogElement | undefined
153
+
154
+ const groups = derived(() => nav ?? [])
155
+ const primary = derived(() => {
156
+ const items = groups.value[0]?.items ?? []
157
+ const marked = items.some(i => i.tab)
158
+ return items.map((item, n) => ({ item, tab: marked ? !!item.tab : n < TABS }))
159
+ })
160
+ const extra = derived(() => groups.value.slice(1))
161
+ const more = derived(() => [...primary.value.filter(p => !p.tab).map(p => p.item), ...extra.value.flatMap(g => g.items), ...(foot ?? [])])
162
+
163
+ const workspace = derived(() => switcher?.workspaces.find(w => w.id === switcher.current))
164
+ const entries = derived(() => (account?.items ?? []).map((entry, n) => ({ entry, at: n })))
165
+
166
+ const toggle = () => {
167
+ if (wide.value) folded.value = !folded.value
168
+ else navOpen.value = !navOpen.value
169
+ }
170
+ const closeNav = () => (navOpen.value = false)
171
+ const toggleExpanded = derived(() => (wide.value ? !folded.value : navOpen.value))
172
+ const toggleLabel = derived(() => (wide.value ? (folded.value ? 'Expand sidebar' : 'Collapse sidebar') : navOpen.value ? 'Collapse navigation' : 'Expand navigation'))
173
+
174
+ const openMenu = (e: Event, where: 'first' | 'last' = 'first') => {
175
+ from = e.currentTarget as HTMLElement
176
+ at = where
177
+ menuOpen.value = !menuOpen.value
178
+ }
179
+ const menuKey = (e: KeyboardEvent) => {
180
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
181
+ e.preventDefault()
182
+ if (!menuOpen.value) openMenu(e, e.key === 'ArrowUp' ? 'last' : 'first')
183
+ }
184
+ const shut = () => (menuOpen.value = false)
185
+
186
+ use(shell, () => root, () => ({
187
+ navOpen: navOpen.value, current, onToggle: toggle, onCloseNav: closeNav,
188
+ onWide: (w: boolean) => { wide.value = w; if (w) navOpen.value = folded.value === 'hidden' && navOpen.value },
189
+ onCommand: command?.onOpen,
190
+ }))
191
+ use(menu, () => menuEl, () => ({ open: menuOpen.value, trigger: () => from, at, onClose: shut }))
192
+ use(dialog, () => sheetEl, () => ({ open: moreOpen.value, onClose: () => (moreOpen.value = false) }))
193
+
194
+ const button = (d: { label: string; href?: string; onClick?: (() => void) | undefined }, part: string | undefined, icon: IconComponent) => (
195
+ d.href
196
+ ? <a href={d.href} data-scope="button" data-emphasis="ghost" data-tone="neutral" data-icon-only="" data-part={part} aria-label={d.label}>{icon({})}</a>
197
+ : <button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-icon-only="" data-part={part} aria-label={d.label} onClick={d.onClick!}>{icon({})}</button>
198
+ )
199
+ const page = <div data-part="page">{children}</div>
200
+ return (
201
+ <div ref={n => (root = n)} data-scope="shell" data-part="root" data-variant={focus.value ? 'focus' : undefined} data-depth={depth} data-collapsed={folded.value === 'hidden' ? 'hidden' : folded.value ? '' : undefined} data-nav-open={navOpen.value ? '' : undefined}>
202
+ <a data-part="skip" href={`#${main}`}>Skip to content</a>
203
+ {folded.value === 'hidden' && <button type="button" data-part="edge-tab" aria-controls={navId} aria-expanded={navOpen.value ? 'true' : 'false'} aria-label={navOpen.value ? 'Hide navigation' : 'Show navigation'} onClick={() => (navOpen.value = !navOpen.value)}><RightIcon /></button>}
204
+ <div data-part="layout">
205
+ <header data-part="header">
206
+ {focus.value && close && button(close, undefined, XIcon)}
207
+ {!focus.value && back && button(back, 'back', LeftIcon)}
208
+ {!focus.value && brand && <a href={brand.href ?? '/'} data-part="brand" aria-label={`${brand.name}, home`}>{brand.mark}<span data-part="brand-name">{brand.name}</span></a>}
209
+ {title !== undefined && <p data-part="title">{title}</p>}
210
+ {!focus.value && crumbs && <Crumbs items={crumbs} />}
211
+ <div data-part="actions">
212
+ {focus.value && step !== undefined && <span style="white-space:nowrap;font-size:var(--warp-text-caption-size);color:var(--warp-color-text-subtle)">{step}</span>}
213
+ {!focus.value && command && (
214
+ <button type="button" data-part="command" data-sweep="" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K" onClick={command.onOpen}>
215
+ {sweepParts()}<SearchIcon />
216
+ <span data-part="command-label">{command.label ?? 'Search'}</span>
217
+ <span data-part="command-hint">{command.hint ?? 'Jump to, or run…'}</span>
218
+ <kbd aria-hidden="true">{command.shortcut ?? '⌘K'}</kbd>
219
+ </button>
220
+ )}
221
+ {!focus.value && actions}
222
+ {!focus.value && account && (
223
+ <button type="button" style="display:inline-grid;place-items:center;inline-size:var(--warp-control-md);block-size:var(--warp-control-md);border:0;background:none;padding:0;cursor:pointer;border-radius:var(--warp-radius-round)" aria-haspopup="menu" aria-expanded={menuOpen.value ? 'true' : 'false'} aria-controls={menuId} onClick={(e: Event) => openMenu(e)} onKeyDown={menuKey}>
224
+ <span data-scope="avatar" aria-hidden="true">{account.initials}</span><span data-part="sr-only">Account: {account.name}</span>
225
+ </button>
226
+ )}
227
+ </div>
228
+ </header>
229
+ {!focus.value && (
230
+ <nav data-part="nav" aria-label={navLabel} id={navId} onClick={(e: MouseEvent) => { if ((e.target as Element).closest?.('a')) closeNav() }}>
231
+ {groups.value[0]?.label !== undefined && <p data-part="nav-group" id={`${navId}-g`}>{groups.value[0].label}</p>}
232
+ {switcher && (
233
+ <button type="button" data-part="switcher" data-sweep="" aria-haspopup="menu" aria-expanded={menuOpen.value ? 'true' : 'false'} aria-controls={menuId} onClick={(e: Event) => openMenu(e)} onKeyDown={menuKey}>
234
+ {sweepParts()}
235
+ <span data-scope="avatar" data-shape="square" data-size="sm" aria-hidden="true">{workspace.value?.initials}</span>
236
+ <span data-part="sr-only">Workspace: </span>
237
+ <span data-part="org"><b>{workspace.value?.name}</b><small>{workspace.value?.meta}</small></span>
238
+ <span data-part="sr-only">. Switch workspace</span>
239
+ <DownIcon />
240
+ </button>
241
+ )}
242
+ <ul data-part="nav-list" aria-labelledby={groups.value[0]?.label !== undefined ? `${navId}-g` : undefined}>
243
+ {primary.value.map(p => <li key={p.item.id} data-tab={p.tab ? '' : undefined}><Item item={p.item} current={current} onPick={closeNav} /></li>)}
244
+ {more.value.length > 0 && <li data-tab="" data-part="nav-more"><button type="button" data-part="nav-item" data-sweep="" aria-haspopup="dialog" aria-expanded={moreOpen.value ? 'true' : 'false'} onClick={() => (moreOpen.value = true)}><span data-part="icon"><MoreIcon />{sweepParts()}</span><span data-part="nav-label">More</span></button></li>}
245
+ </ul>
246
+ {extra.value.length > 0 && (
247
+ <div data-part="nav-extra">
248
+ {extra.value.map(g => (
249
+ <div key={g.label}>
250
+ <p data-part="nav-group">{g.label}</p>
251
+ <ul data-part="nav-list" aria-label={g.label}>{g.items.map(i => <li key={i.id}><Item item={i} current={current} quiet onPick={closeNav} /></li>)}</ul>
252
+ </div>
253
+ ))}
254
+ </div>
255
+ )}
256
+ <div data-part="nav-foot">
257
+ {foot?.map(i => <Item key={i.id} item={i} current={current} onPick={closeNav} />)}
258
+ <button type="button" data-part="nav-toggle" aria-controls={navId} aria-expanded={toggleExpanded.value ? 'true' : 'false'} aria-label={toggleLabel.value} aria-keyshortcuts="[" onClick={toggle}><PanelIcon /></button>
259
+ </div>
260
+ </nav>
261
+ )}
262
+ {island
263
+ ? <div data-part="main" id={main} tabindex="-1" role="region" aria-label={label ?? 'Page content'}>{page}{dock !== undefined && <div data-part="dock">{dock}</div>}</div>
264
+ : <main data-part="main" id={main} tabindex="-1">{page}{dock !== undefined && <div data-part="dock">{dock}</div>}</main>}
265
+ </div>
266
+ <div data-part="scrim" data-for="nav" hidden={!navOpen.value} onClick={closeNav}></div>
267
+ {!focus.value && account && (
268
+ <aside aria-label="Account" hidden={!menuOpen.value}>
269
+ <div ref={n => (menuEl = n)} data-scope="menu" data-part="menu" role="menu" id={menuId} aria-label={`Account: ${account.name}${account.email ? `, ${account.email}` : ''}`} hidden={!menuOpen.value}>
270
+ <div aria-hidden="true" style="display:flex;gap:10px;align-items:center;padding:10px">
271
+ <span data-scope="avatar">{account.initials}</span>
272
+ <div><b style="display:block;font-weight:600">{account.name}</b><span style="font-size:var(--warp-text-caption-size);color:var(--warp-color-text-subtle)">{account.email}</span></div>
273
+ </div>
274
+ {switcher && (
275
+ <>
276
+ <div data-part="separator" role="separator"></div>
277
+ <div role="group" aria-label="Workspace">
278
+ <p data-part="group-label" aria-hidden="true" style="margin:0">Workspace</p>
279
+ {switcher.workspaces.map(w => (
280
+ <button key={w.id} type="button" data-part="item" role="menuitemradio" tabindex="-1" aria-checked={w.id === switcher.current ? 'true' : 'false'} style="inline-size:100%;border:0;background:none;font:inherit;text-align:start" onClick={() => { switcher.onSwitch(w.id); shut(); from?.focus() }}>
281
+ <span data-scope="avatar" data-shape="square" data-size="sm" aria-hidden="true">{w.initials}</span>
282
+ <span data-part="item-label">{w.name}</span>
283
+ <span data-part="indicator"><CheckIcon /></span>
284
+ </button>
285
+ ))}
286
+ </div>
287
+ </>
288
+ )}
289
+ {entries.value.map(x => <Entry key={x.at} entry={x.entry} onPick={() => { shut(); from?.focus() }} />)}
290
+ </div>
291
+ </aside>
292
+ )}
293
+ {!focus.value && (
294
+ <dialog ref={n => (sheetEl = n)} data-part="sheet" aria-labelledby={sheetId} hidden={!moreOpen.value} style="margin:0;max-inline-size:none">
295
+ <div style="display:flex;align-items:center;justify-content:space-between">
296
+ <p data-part="title" id={sheetId} style="margin:0;font-weight:600">More</p>
297
+ <button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-icon-only="" aria-label="Close" data-close="" autofocus><XIcon /></button>
298
+ </div>
299
+ <div data-scope="menu">
300
+ {more.value.map(i => <a key={i.id} data-part="item" style="text-decoration:none" href={i.href ?? '#'} aria-current={current === i.id ? 'page' : undefined} onClick={(e: MouseEvent) => { if (!i.href) e.preventDefault(); i.onSelect?.(); moreOpen.value = false }}>{draw(i.icon)}<span data-part="item-label">{i.label}</span>{i.count !== undefined && <span data-part="shortcut">{i.count}</span>}</a>)}
301
+ </div>
302
+ </dialog>
303
+ )}
304
+ <div data-scope="announcer" role="status" aria-live="polite"></div>
305
+ {overlays}
306
+ </div>
307
+ )
308
+ }