@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/Segments.tsx
ADDED
|
@@ -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
|
+
}
|