sygnal 5.4.0 → 6.0.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/CHANGELOG.md +646 -0
- package/README.md +24 -9
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +362 -91
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +362 -92
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1431 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1419 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3032 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3032 -296
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +257 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/behaviors.md +312 -0
- package/dist/guide/browser-sources.md +203 -0
- package/dist/guide/drag-and-drop.md +342 -0
- package/dist/guide/element-commands.md +306 -0
- package/dist/guide/error-boundaries.md +169 -0
- package/dist/guide/forms-reference.md +381 -0
- package/dist/guide/forms.md +250 -0
- package/dist/guide/http.md +299 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/carousel.md +155 -0
- package/dist/guide/recipes/charts.md +177 -0
- package/dist/guide/recipes/code-editor.md +135 -0
- package/dist/guide/recipes/data-grid.md +155 -0
- package/dist/guide/recipes/data-table.md +196 -0
- package/dist/guide/recipes/i18n.md +222 -0
- package/dist/guide/recipes/icons.md +115 -0
- package/dist/guide/recipes/overview.md +33 -0
- package/dist/guide/recipes/rich-text.md +154 -0
- package/dist/guide/resources.md +471 -0
- package/dist/guide/ssr.md +255 -0
- package/dist/guide/timers.md +178 -0
- package/dist/guide/ui/accordion.md +93 -0
- package/dist/guide/ui/combobox.md +144 -0
- package/dist/guide/ui/dialog.md +124 -0
- package/dist/guide/ui/disclosure.md +60 -0
- package/dist/guide/ui/menu.md +125 -0
- package/dist/guide/ui/overview.md +166 -0
- package/dist/guide/ui/popover.md +101 -0
- package/dist/guide/ui/select.md +114 -0
- package/dist/guide/ui/tabs.md +161 -0
- package/dist/guide/ui/toaster.md +152 -0
- package/dist/guide/ui/tooltip.md +103 -0
- package/dist/guide/undo.md +178 -0
- package/dist/guide/virtual-collections.md +183 -0
- package/dist/guide/web-components.md +179 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +13178 -5769
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2908 -276
- package/dist/index.esm.js +13149 -5779
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +323 -88
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +323 -89
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +166 -103
- package/package.json +98 -14
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +19 -3
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +151 -0
- package/src/devtools.ts +34 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/backoff.ts +9 -0
- package/src/extra/behaviors.ts +169 -0
- package/src/extra/browserSignals.ts +23 -0
- package/src/extra/browserSources.ts +326 -0
- package/src/extra/command.ts +3 -3
- package/src/extra/controls.ts +47 -0
- package/src/extra/copyAsTest.ts +286 -0
- package/src/extra/devtools.ts +101 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +163 -0
- package/src/extra/diagnostics/checks/index.ts +97 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +98 -7
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +66 -10
- package/src/extra/diagnostics/codes.ts +268 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +268 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +1 -1
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +90 -217
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +2433 -175
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/index.d.ts +2631 -117
- package/src/index.ts +25 -4
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +256 -133
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +48 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/plugin.d.ts +22 -3
- package/src/vite/plugin.ts +222 -24
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
package/src/ui/tabs.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-U (S-4): Tabs, a behavior that wires the host's own markup (WAI-ARIA APG tabs).
|
|
3
|
+
*
|
|
4
|
+
* Settings.uses = { tabs: tabs({ tab: '.tab', selected: 'general' }) }
|
|
5
|
+
* const a = tabsAttrs(state.tabs, uid)
|
|
6
|
+
* <div {...a.list} aria-label="Settings">
|
|
7
|
+
* <button className="tab" {...a.tab('general')}>General</button> …
|
|
8
|
+
* </div>
|
|
9
|
+
* <section {...a.panel('general')}>…</section> …
|
|
10
|
+
*
|
|
11
|
+
* The host renders the tabs and panels; `tabsAttrs` gives each its attributes from the slice:
|
|
12
|
+
* roles, ids (from `uid`, so every instance has its own), aria-selected / aria-controls /
|
|
13
|
+
* aria-labelledby, the roving tabindex, `hidden` on the other panels, and the hooks
|
|
14
|
+
* `data-value` / `data-state` ('active' | 'inactive'). A click on a tab selects it; the arrow
|
|
15
|
+
* keys move the focus between the tabs (and select, unless `activation: 'manual'`), Home / End
|
|
16
|
+
* go to the first / last; disabled tabs are skipped.
|
|
17
|
+
*
|
|
18
|
+
* Options: `tab` (required: the tabs' selector), `selected` (the start; none: the first tab
|
|
19
|
+
* rendered), `orientation` ('horizontal' | 'vertical': which arrow keys), `activation`
|
|
20
|
+
* ('automatic' | 'manual'), `loop` (true), `id` (the ids' prefix; default: the key in `uses`).
|
|
21
|
+
* State: { id, selected, orientation }. Actions: SELECT (a value, or a click), MOVE (a value:
|
|
22
|
+
* focus it, and select it unless manual).
|
|
23
|
+
* G-397: `tabsAttrs(slice, uid, values)` with the tabs' values shows the first one selected when
|
|
24
|
+
* `selected` isn't among them (a removed tab); a host that removes tabs re-selects a neighbour
|
|
25
|
+
* and moves the focus to it in its own reducer (the guide's closable tabs).
|
|
26
|
+
*/
|
|
27
|
+
import {ABORT, defineBehavior} from '../index'
|
|
28
|
+
import {idsOf, keyNav, keyed, lit} from './shared'
|
|
29
|
+
|
|
30
|
+
const base = /*#__PURE__*/ defineBehavior({
|
|
31
|
+
initialState: {id: null as null | string, selected: null as null | string, orientation: 'horizontal'},
|
|
32
|
+
intent: ({DOM}: any, {tab, orientation, loop}: any) => {
|
|
33
|
+
const v = orientation == 'vertical'
|
|
34
|
+
return {
|
|
35
|
+
SELECT: DOM.click(tab).data('value'),
|
|
36
|
+
MOVE: keyNav(DOM, tab, [v ? 'ArrowDown' : 'ArrowRight'], [v ? 'ArrowUp' : 'ArrowLeft'], loop),
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
model: {
|
|
40
|
+
SELECT: (s: any, v: any) => (v == null || String(v) === s.selected ? ABORT : {...s, selected: String(v)}),
|
|
41
|
+
MOVE: {
|
|
42
|
+
STATE: (s: any, v: string, _n: any, _p: any, o: any) => (o.activation == 'manual' || v === s.selected ? ABORT : {...s, selected: v}),
|
|
43
|
+
ELEMENT: (_s: any, v: string, _n: any, _p: any, o: any) => ({focus: o.tab + '[data-value=' + lit(v) + ']'}),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Tabs as a behavior over the host's markup: `uses = { tabs: tabs({ tab: '.tab', selected: 'general' }) }`; render with `tabsAttrs`. */
|
|
49
|
+
export const tabs = (options: any = {}): any =>
|
|
50
|
+
keyed(base({...options, ...(options.selected != null && {selected: String(options.selected)})}))
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The attributes of a tab set, from its slice: `list` (the tablist), `tab(value)` and
|
|
54
|
+
* `panel(value)`. Spread them on the host's elements: `<button className="tab" {...a.tab('general')}>`.
|
|
55
|
+
* `values` (optional): the tabs' values in order (leave disabled ones out). With it, a `selected`
|
|
56
|
+
* that isn't one of them (its tab was removed) shows the first one selected (G-397); without it,
|
|
57
|
+
* the first tab rendered is selected only while nothing is.
|
|
58
|
+
*/
|
|
59
|
+
export const tabsAttrs = (s: any, uid: any, values?: any[]): any => {
|
|
60
|
+
const id = idsOf(s, uid, 'tabs'), state = (b: boolean) => (b ? 'active' : 'inactive')
|
|
61
|
+
const vs = values && values.map(String)
|
|
62
|
+
let first: any = vs?.[0], sel = s?.selected == null ? null : String(s.selected)
|
|
63
|
+
if (vs && sel != null && !vs.includes(sel)) sel = null
|
|
64
|
+
// no selection (or a removed one): the first value, or the first tab (or panel) rendered
|
|
65
|
+
const on = (v: any) => {
|
|
66
|
+
v = String(v)
|
|
67
|
+
if (sel != null) return v === sel
|
|
68
|
+
first ??= v
|
|
69
|
+
return v === first
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
list: {role: 'tablist', 'aria-orientation': s?.orientation || 'horizontal'},
|
|
73
|
+
tab: (v: any) => {
|
|
74
|
+
const b = on(v)
|
|
75
|
+
return {type: 'button', id: id('tab', v), role: 'tab', 'aria-selected': b, 'aria-controls': id('panel', v), tabindex: b ? '0' : '-1', 'data-value': String(v), 'data-state': state(b)}
|
|
76
|
+
},
|
|
77
|
+
panel: (v: any) => {
|
|
78
|
+
const b = on(v)
|
|
79
|
+
return {id: id('panel', v), role: 'tabpanel', 'aria-labelledby': id('tab', v), tabindex: '0', hidden: !b, 'data-state': state(b)}
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-U (T-1, D198, from spike 0-S4): the Toaster component.
|
|
3
|
+
*
|
|
4
|
+
* anywhere: SAVE: { EVENTS: event('TOAST', { text: 'Saved', kind: 'success' }) }
|
|
5
|
+
* once: <Toaster /> (main.js: run(App, { TIMER: makeTimerDriver() }))
|
|
6
|
+
*
|
|
7
|
+
* - TOAST adds { id, text, kind ('info' | 'success' | 'warning' | 'error'; default 'info'),
|
|
8
|
+
* timeoutMs (default 5000; 0 = until dismissed) }. A string is the text. A TOAST with the `id`
|
|
9
|
+
* of a shown toast replaces it in place and restarts its timer ('Saving…' → 'Saved').
|
|
10
|
+
* TOAST_DISMISS removes the toast with that id (no id: all of them). G-398: ids compare as
|
|
11
|
+
* strings (the Collection's keys); an automatic id is 't<n>', never one a shown toast has.
|
|
12
|
+
* - Each toast is a Collection item: its `timers` static auto-dismisses it (GS-7; fake-timer
|
|
13
|
+
* testable), its Dismiss button removes it, Transition animates it (`transition` names the
|
|
14
|
+
* classes, `duration` is how long a leaving toast stays: the leave animation's length).
|
|
15
|
+
* - Two persistent live regions: role="status" (polite) for every kind but errors, role="alert"
|
|
16
|
+
* for errors. They are rendered while empty, so a toast added later is announced.
|
|
17
|
+
* - While the pointer or the focus is in the region, the timers stop (and start over when both
|
|
18
|
+
* have left; G-399: `hover` and `focus` are tracked apart): `pauseOnHover={false}` turns this
|
|
19
|
+
* off. A focused Dismiss button moves the focus to the next toast's (else the previous one's,
|
|
20
|
+
* else where the focus came from) before its toast goes; a focused element removed any other
|
|
21
|
+
* way gets a focusout sent (Chromium sends none), so the region never stays paused.
|
|
22
|
+
* - The region is a `popover="manual"` element (top layer, above the page), re-parented into the
|
|
23
|
+
* topmost open modal <dialog> while one is open and back when it closes or is removed (a
|
|
24
|
+
* MutationObserver in the region's hooks, D198): outside the modal it would be drawn but inert
|
|
25
|
+
* (spike 0-S4). `__sygnalHome` keeps its events in this component (G-356). The region is the
|
|
26
|
+
* only child of a wrapper (`.toaster-home`), so the moved element has no siblings to patch.
|
|
27
|
+
* G-404: the dialogs of the region's own root (a shadow root) are looked at and observed too.
|
|
28
|
+
* G-426: the focus is that root's activeElement (in a shadow root the document's is the host).
|
|
29
|
+
*
|
|
30
|
+
* Class hooks: .toaster-home, .toaster (+ `className`), .toaster-status, .toaster-alert,
|
|
31
|
+
* .toaster-list, .toast, .toast-text, .toast-dismiss. Data attributes: data-kind on .toast,
|
|
32
|
+
* data-paused on .toaster.
|
|
33
|
+
*/
|
|
34
|
+
import {ABORT, Collection, Transition, createElement as h, xs} from '../index'
|
|
35
|
+
|
|
36
|
+
// ── the region's placement (D198) ────────────────────────────────────
|
|
37
|
+
// the open modal dialogs, in the order they became modal (the last one is on top)
|
|
38
|
+
const order: any[] = []
|
|
39
|
+
const modal = (d: any) => { try { return d.matches(':modal') } catch (_) { return false } }
|
|
40
|
+
// G-404: the dialogs of the document and of the region's own root (a shadow root: sygnal/element)
|
|
41
|
+
const topModal = (root: any) => {
|
|
42
|
+
const all = [...document.querySelectorAll('dialog')]
|
|
43
|
+
if (root !== document && root?.querySelectorAll) all.push(...root.querySelectorAll('dialog'))
|
|
44
|
+
const open = all.filter(modal)
|
|
45
|
+
for (let i = order.length; i--;) if (!open.includes(order[i])) order.splice(i, 1)
|
|
46
|
+
for (const d of open) if (!order.includes(d)) order.push(d)
|
|
47
|
+
return order[order.length - 1]
|
|
48
|
+
}
|
|
49
|
+
const show = (el: any) => { try { el.matches(':popover-open') || el.showPopover() } catch (_) {} }
|
|
50
|
+
// the element with the focus in `el`'s root
|
|
51
|
+
const act = (el: any) => (el.getRootNode?.() || document).activeElement
|
|
52
|
+
// G-428: a focusout that leaves `t`: not to an element inside it, and with no relatedTarget not
|
|
53
|
+
// while the focus is still inside (the window lost the focus: it comes back to the same element)
|
|
54
|
+
const left = (t: any, e: any) => !(t && t.contains?.(e.relatedTarget || act(t)))
|
|
55
|
+
// a toast on its way out (its Transition's leave classes)
|
|
56
|
+
const leaving = (b: any) => /-leave-/.test(b.closest?.('.toast')?.className || '')
|
|
57
|
+
|
|
58
|
+
const placement = {
|
|
59
|
+
insert: (v: any) => {
|
|
60
|
+
const el = v.elm, home = el.parentNode, root = el.getRootNode?.() || document
|
|
61
|
+
el.__sygnalHome = home
|
|
62
|
+
show(el)
|
|
63
|
+
// G-399: the focus in the region, and where it came from (where it goes back to). G-432: a
|
|
64
|
+
// focus from nowhere (the region's own refocus after a move, the browser's after a modal
|
|
65
|
+
// closed, the window focused again) keeps the last element it came from. G-458: but not one
|
|
66
|
+
// a press in the region gave: the user comes back from nowhere after a click on the page took
|
|
67
|
+
// the focus, and a mouse Dismiss would focus (and scroll to) an element left long ago. (A focus
|
|
68
|
+
// that comes back from another element names that one; a focus that left to a modal comes
|
|
69
|
+
// back from nowhere when it closes and keeps it: G-432)
|
|
70
|
+
// G-499: the press is its time, and counts for a focus within a second (a press released
|
|
71
|
+
// outside, a right-click or a touch scroll gets no focusin or click to clear it); a cancelled
|
|
72
|
+
// one (the browser took it for a scroll) at once
|
|
73
|
+
el.addEventListener('pointerdown', (e: any) => { el._p = e.timeStamp })
|
|
74
|
+
el.addEventListener('pointercancel', () => { el._p = 0 })
|
|
75
|
+
el.addEventListener('focusin', (e: any) => {
|
|
76
|
+
if (!el.contains(e.relatedTarget)) { el._f = 1; el._from = e.relatedTarget || (el._p && e.timeStamp - el._p < 1e3 ? null : el._from) }
|
|
77
|
+
el._p = 0
|
|
78
|
+
})
|
|
79
|
+
el.addEventListener('focusout', (e: any) => { if (left(el, e)) el._f = 0 })
|
|
80
|
+
// a Dismiss button with the focus (keyboard; a click that focused it): the focus moves to the
|
|
81
|
+
// next toast's Dismiss button, else the previous one's, else back where it came from, before
|
|
82
|
+
// the button goes (a removed focused element gets no focusout in Chromium)
|
|
83
|
+
el.addEventListener('click', (e: any) => {
|
|
84
|
+
el._p = 0
|
|
85
|
+
const b = e.target?.closest?.('.toast-dismiss')
|
|
86
|
+
// G-426: the root's activeElement (in a shadow root, the document's is the host)
|
|
87
|
+
if (!b || !b.contains(act(el))) return
|
|
88
|
+
const bs = [...el.querySelectorAll('.toast-dismiss')].filter((x: any) => x === b || !leaving(x)), i = bs.indexOf(b)
|
|
89
|
+
const to: any = bs[i + 1] || bs[i - 1] || (el._from?.isConnected && !el.contains(el._from) && el._from)
|
|
90
|
+
to ? to.focus() : b.blur()
|
|
91
|
+
})
|
|
92
|
+
if (typeof MutationObserver == 'undefined') return
|
|
93
|
+
const place = () => {
|
|
94
|
+
const to = topModal(root) || home
|
|
95
|
+
if (el.parentNode !== to) {
|
|
96
|
+
// a move blurs a focused Dismiss button: give the focus back
|
|
97
|
+
const a = act(el), f: any = el.contains(a) && a
|
|
98
|
+
to.appendChild(el)
|
|
99
|
+
f && f.focus()
|
|
100
|
+
}
|
|
101
|
+
// a moved popover is closed: show it again (on top of the dialog it is in)
|
|
102
|
+
show(el)
|
|
103
|
+
}
|
|
104
|
+
// `open` flips on showModal / close; a dialog removed while open takes the region with it
|
|
105
|
+
const mo = el._mo = new MutationObserver((rs) => {
|
|
106
|
+
// G-399: the focused element was removed (a toast dismissed some other way): no focusout
|
|
107
|
+
// in Chromium, so the region would stay paused; send one
|
|
108
|
+
if (el._f && !el.contains(act(el))) { el._f = 0; el.dispatchEvent(new FocusEvent('focusout', {bubbles: true})) }
|
|
109
|
+
if (home.isConnected && (!el.isConnected || (el.parentNode !== home && !modal(el.parentNode)) || rs.some((r) => r.type == 'attributes'))) place()
|
|
110
|
+
})
|
|
111
|
+
const opts = {subtree: true, childList: true, attributes: true, attributeFilter: ['open']}
|
|
112
|
+
mo.observe(document.body, opts)
|
|
113
|
+
// G-404: a region in a shadow root sees that root's dialogs open and close too
|
|
114
|
+
if (root !== document && root.nodeType == 11) mo.observe(root, opts)
|
|
115
|
+
place()
|
|
116
|
+
},
|
|
117
|
+
// removed with its wrapper while in a dialog: take it out of the dialog too
|
|
118
|
+
destroy: (v: any) => {
|
|
119
|
+
const el = v.elm
|
|
120
|
+
el._mo?.disconnect()
|
|
121
|
+
el.parentNode !== el.__sygnalHome && el.remove()
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── one toast ──────────────────────────────────────────────────────
|
|
126
|
+
// (statics through a /*#__PURE__*/ Object.assign, so every bundler drops an unused Toaster; a
|
|
127
|
+
// componentName, so diagnostics and DevTools name them in minified builds too)
|
|
128
|
+
const ToastItem = /*#__PURE__*/ Object.assign(function ToastItem({state, dismissLabel, transition, duration}: any) {
|
|
129
|
+
return h(Transition, {name: transition, duration},
|
|
130
|
+
h('div', {className: 'toast', 'data-kind': state.kind},
|
|
131
|
+
h('span', {className: 'toast-text'}, state.text),
|
|
132
|
+
h('button', {type: 'button', className: 'toast-dismiss', 'aria-label': dismissLabel + ': ' + state.text}, dismissLabel)))
|
|
133
|
+
}, {
|
|
134
|
+
componentName: 'ToastItem',
|
|
135
|
+
intent: ({DOM}: any) => ({DISMISS: DOM.click('.toast-dismiss')}),
|
|
136
|
+
// a replaced toast (rev) gets a new timer name, so its timer starts over
|
|
137
|
+
timers: (s: any) => ({['expire' + (s?.rev || '')]: s?.timeoutMs > 0 && !s.paused && {after: s.timeoutMs, action: 'EXPIRE'}}),
|
|
138
|
+
model: {DISMISS: () => undefined, EXPIRE: () => undefined},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ── the toaster ──────────────────────────────────────────────────────
|
|
142
|
+
const polite = (t: any) => t.kind != 'error'
|
|
143
|
+
const urgent = (t: any) => t.kind == 'error'
|
|
144
|
+
// entering or leaving the region (not moving between its children)
|
|
145
|
+
const crossing = (e: any) => !e.ownerTarget?.contains?.(e.relatedTarget)
|
|
146
|
+
const paused = (s: any, p: boolean) => ({...s, paused: p, toasts: s.toasts.map((t: any) => ({...t, paused: p}))})
|
|
147
|
+
// the pointer or the focus came in or left: paused while either is in (unless pauseOnHover={false})
|
|
148
|
+
const pause = (s: any, k: string, on: boolean, p: any) => {
|
|
149
|
+
if (s[k] === on) return ABORT
|
|
150
|
+
const n = {...s, [k]: on}, z = p.pauseOnHover !== false && !!(n.hover || n.focus)
|
|
151
|
+
return z === s.paused ? n : paused(n, z)
|
|
152
|
+
}
|
|
153
|
+
// ids compare as strings (the Collection's keys do: 7 and '7' are one toast)
|
|
154
|
+
const same = (a: any, b: any) => '' + a === '' + b
|
|
155
|
+
|
|
156
|
+
/** Renders the toasts that `event('TOAST', { text, kind, timeoutMs })` sends from anywhere. Render it once: `<Toaster />`. */
|
|
157
|
+
export const Toaster = /*#__PURE__*/ Object.assign(function Toaster({state, label = 'Notifications', dismissLabel = 'Dismiss', transition = 'toast', duration = 200, className}: any) {
|
|
158
|
+
const list = (filter: any) => h('div', {className: 'toaster-list'}, h(Collection, {of: ToastItem, from: 'toasts', filter, dismissLabel, transition, duration}))
|
|
159
|
+
return h('div', {className: 'toaster-home'},
|
|
160
|
+
h('section', {
|
|
161
|
+
className: className ? 'toaster ' + className : 'toaster', 'aria-label': label, attrs: {popover: 'manual'},
|
|
162
|
+
'data-paused': state.paused ? '' : undefined, hook: placement,
|
|
163
|
+
},
|
|
164
|
+
h('div', {className: 'toaster-status', role: 'status', 'aria-live': 'polite'}, list(polite)),
|
|
165
|
+
h('div', {className: 'toaster-alert', role: 'alert'}, list(urgent))))
|
|
166
|
+
}, {
|
|
167
|
+
componentName: 'Toaster',
|
|
168
|
+
isolatedState: true,
|
|
169
|
+
initialState: {toasts: [] as any[], next: 1, paused: false, hover: false, focus: false},
|
|
170
|
+
intent: ({EVENTS, DOM}: any) => ({
|
|
171
|
+
TOAST: EVENTS.select('TOAST'),
|
|
172
|
+
DISMISS: EVENTS.select('TOAST_DISMISS'),
|
|
173
|
+
// G-399: the pointer and the focus apart (the timers stop while either is in the region)
|
|
174
|
+
HOVER: xs.merge(DOM.pointerover('.toaster').filter(crossing).mapTo(true), DOM.pointerout('.toaster').filter(crossing).mapTo(false)),
|
|
175
|
+
FOCUS: xs.merge(DOM.focusin('.toaster').filter(crossing).mapTo(true), DOM.focusout('.toaster').filter((e: any) => left(e.ownerTarget, e)).mapTo(false)),
|
|
176
|
+
}),
|
|
177
|
+
model: {
|
|
178
|
+
TOAST: (s: any, d: any) => {
|
|
179
|
+
const t = typeof d == 'string' ? {text: d} : d || {}
|
|
180
|
+
// G-398: an automatic id is 't<n>', one no shown toast has (a user's 2 or '2' never meets it)
|
|
181
|
+
const i = t.id == null ? -1 : s.toasts.findIndex((x: any) => same(x.id, t.id))
|
|
182
|
+
let n = s.next, id = t.id
|
|
183
|
+
if (id == null) { while (s.toasts.some((x: any) => same(x.id, 't' + n))) n++; id = 't' + n++ }
|
|
184
|
+
const toast = {...t, id, kind: t.kind || 'info', timeoutMs: t.timeoutMs ?? 5000, paused: s.paused, rev: i < 0 ? 0 : s.toasts[i].rev + 1}
|
|
185
|
+
return {
|
|
186
|
+
...s,
|
|
187
|
+
next: n,
|
|
188
|
+
toasts: i < 0 ? [...s.toasts, toast] : s.toasts.map((x: any, j: number) => (j == i ? toast : x)),
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
DISMISS: (s: any, id: any) => {
|
|
192
|
+
const toasts = id == null ? [] : s.toasts.filter((x: any) => !same(x.id, id))
|
|
193
|
+
return toasts.length == s.toasts.length ? ABORT : {...s, toasts}
|
|
194
|
+
},
|
|
195
|
+
HOVER: (s: any, on: boolean, _n: any, p: any) => pause(s, 'hover', on, p),
|
|
196
|
+
FOCUS: (s: any, on: boolean, _n: any, p: any) => pause(s, 'focus', on, p),
|
|
197
|
+
},
|
|
198
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-U (S-3, from spike 0-S3): Tooltip, a manual popover placed by CSS anchor positioning,
|
|
3
|
+
* shown on hover / focus after a delay and hidden after a delay (D197 `timers`).
|
|
4
|
+
*
|
|
5
|
+
* Toolbar.uses = { saveTip: tooltip({ trigger: '.save', tip: '.save-tip' }) }
|
|
6
|
+
*
|
|
7
|
+
* The host renders the trigger with aria-describedby (the tip's id) and `anchor-name`, and the
|
|
8
|
+
* tip with role="tooltip", popover="manual", `position-anchor` and `position-area` (styles; see
|
|
9
|
+
* the guide). No JavaScript positioning.
|
|
10
|
+
*
|
|
11
|
+
* Options: `trigger`, `tip` (required), `showDelay` (500 ms), `hideDelay` (100 ms).
|
|
12
|
+
* State: { open, pending: null | 'show' | 'hide', hover, focus }. Actions: ENTER / LEAVE (data
|
|
13
|
+
* 'hover' | 'focus': the pointer on the trigger or the tip (it stays open while hovered, WCAG
|
|
14
|
+
* 1.4.13), or the focus on the trigger), SHOW / HIDE (the timers), ESCAPE (hides it at once),
|
|
15
|
+
* TOGGLED. Timers (joined to the host's `timers`): '<key>.show' / '<key>.hide'; one whose state
|
|
16
|
+
* goes away is cancelled, so leaving before the delay never shows the tip. Needs
|
|
17
|
+
* makeTimerDriver() (tests: fake timers).
|
|
18
|
+
* G-403: the pointer and the focus are tracked apart (it hides once both have left: a mouse
|
|
19
|
+
* leaving doesn't hide a tip the keyboard focus still shows); touch pointer events are ignored
|
|
20
|
+
* (a tap focuses the trigger instead); the document keydown listener for Escape is there only
|
|
21
|
+
* while the tip is shown or about to be, so a page of tooltips has at most the open one's.
|
|
22
|
+
*/
|
|
23
|
+
import {ABORT, defineBehavior, dropRepeats, xs} from '../index'
|
|
24
|
+
|
|
25
|
+
const mouse = (e: any) => e?.pointerType != 'touch'
|
|
26
|
+
const which = (k: any) => (k == 'focus' ? 'focus' : 'hover')
|
|
27
|
+
|
|
28
|
+
const base = /*#__PURE__*/ defineBehavior({
|
|
29
|
+
initialState: {open: false, pending: null as null | string, hover: false, focus: false},
|
|
30
|
+
timers: (s: any, o: any) => ({
|
|
31
|
+
show: s?.pending == 'show' && {after: o.showDelay ?? 500, action: 'SHOW'},
|
|
32
|
+
hide: s?.pending == 'hide' && {after: o.hideDelay ?? 100, action: 'HIDE'},
|
|
33
|
+
}),
|
|
34
|
+
intent: ({DOM, STATE}: any, {trigger, tip}: any) => ({
|
|
35
|
+
ENTER: xs.merge(xs.merge(DOM.pointerenter(trigger), DOM.pointerenter(tip)).filter(mouse).mapTo('hover'), DOM.focus(trigger).mapTo('focus')),
|
|
36
|
+
LEAVE: xs.merge(xs.merge(DOM.pointerleave(trigger), DOM.pointerleave(tip)).filter(mouse).mapTo('hover'), DOM.blur(trigger).mapTo('focus')),
|
|
37
|
+
ESCAPE: (STATE ? STATE.stream.map((s: any) => !!(s?.open || s?.pending)).compose(dropRepeats()) : xs.of(true))
|
|
38
|
+
.map((on: boolean) => (on ? DOM.keydown('document').key().filter((k: string) => k == 'Escape') : xs.never()))
|
|
39
|
+
.flatten(),
|
|
40
|
+
TOGGLED: DOM.toggle(tip).map((e: any) => e.newState == 'open'),
|
|
41
|
+
}),
|
|
42
|
+
model: {
|
|
43
|
+
ENTER: (s: any, k: any) => {
|
|
44
|
+
const n = {...s, [which(k)]: true}, pending = s.open ? null : 'show'
|
|
45
|
+
return s[which(k)] && s.pending == pending ? ABORT : {...n, pending}
|
|
46
|
+
},
|
|
47
|
+
LEAVE: (s: any, k: any) => {
|
|
48
|
+
const n = {...s, [which(k)]: false}
|
|
49
|
+
// still hovered or focused: as it was
|
|
50
|
+
const pending = n.hover || n.focus ? s.pending : s.open ? 'hide' : null
|
|
51
|
+
return !s[which(k)] && s.pending == pending ? ABORT : {...n, pending}
|
|
52
|
+
},
|
|
53
|
+
SHOW: {
|
|
54
|
+
STATE: (s: any) => ({...s, pending: null}),
|
|
55
|
+
ELEMENT: (s: any, _d: any, _n: any, _p: any, o: any) => (s.open ? ABORT : {showPopover: o.tip}),
|
|
56
|
+
},
|
|
57
|
+
HIDE: {
|
|
58
|
+
STATE: (s: any) => ({...s, pending: null}),
|
|
59
|
+
ELEMENT: (s: any, _d: any, _n: any, _p: any, o: any) => (s.open ? {hidePopover: o.tip} : ABORT),
|
|
60
|
+
},
|
|
61
|
+
ESCAPE: {
|
|
62
|
+
STATE: (s: any) => (s.open || s.pending ? {...s, pending: null} : ABORT),
|
|
63
|
+
ELEMENT: (s: any, _d: any, _n: any, _p: any, o: any) => (s.open ? {hidePopover: o.tip} : ABORT),
|
|
64
|
+
},
|
|
65
|
+
TOGGLED: (s: any, open: boolean) => (s.open == open ? ABORT : {...s, open}),
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** A tooltip as a behavior: `uses = { tip: tooltip({ trigger: '.save', tip: '.save-tip', showDelay: 500 }) }`. */
|
|
70
|
+
export const tooltip = (options: any = {}): any => base({...options, open: false, pending: null, hover: false, focus: false})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-Z (U-1): Combobox, a Zag combobox machine (WAI-ARIA editable combobox with a listbox
|
|
3
|
+
* popup) as a widget tag.
|
|
4
|
+
*
|
|
5
|
+
* <Combobox className="city" label="City" items={CITIES} value={state.city} placeholder="Type a city" />
|
|
6
|
+
* intent: CITY: DOM.select('.city').events('value-change').detail() // 'Paris'
|
|
7
|
+
*
|
|
8
|
+
* Renders a label, the input (role="combobox", aria-autocomplete="list", aria-activedescendant
|
|
9
|
+
* on the highlighted option), a trigger button, and the listbox in a positioner. The items are
|
|
10
|
+
* filtered as the user types: by default the labels that contain the text (case-insensitive);
|
|
11
|
+
* `filter={(item, text) => boolean}` replaces the test, `filter={false}` leaves it to the app (it
|
|
12
|
+
* passes the `items` to show, from `input-change`). The filter text resets when the list closes.
|
|
13
|
+
* Keyboard is Zag's: ArrowDown / ArrowUp open and move, Enter selects, Escape closes, Home / End
|
|
14
|
+
* in the input move the caret. Styling hooks: `data-scope="combobox"` + `data-part` (root, label,
|
|
15
|
+
* control, input, trigger, positioner, content, item, item-text, item-indicator), `data-state`,
|
|
16
|
+
* `data-highlighted`.
|
|
17
|
+
*
|
|
18
|
+
* Forms (G-411): with `name`, hidden inputs submit the value (one per value when `multiple`);
|
|
19
|
+
* the visible input, which shows the label, has no name. `form` goes on the hidden inputs (G-434).
|
|
20
|
+
* With `allowCustomValue` (single), the selected value is submitted unless the user typed since
|
|
21
|
+
* the last selection; then the typed text (an item's exact label submits that item's value; G-435,
|
|
22
|
+
* G-468).
|
|
23
|
+
*
|
|
24
|
+
* Props: `label`, `items` (strings or { value, label?, disabled? }), `value` (controlled: a
|
|
25
|
+
* string, an array when `multiple`, null for none), `defaultValue`, `placeholder`, `filter`, and
|
|
26
|
+
* Zag's combobox props (`multiple`, `name`, `form`, `disabled`, `readOnly`, `required`,
|
|
27
|
+
* `invalid`, `allowCustomValue`, `openOnClick`, `inputBehavior`, `selectionBehavior`,
|
|
28
|
+
* `closeOnSelect`, `loopFocus`, `positioning`, `open`, `id`). Events: `value-change` (the value,
|
|
29
|
+
* or the values when multiple), `input-change` (the text the user typed), `open-change` (boolean).
|
|
30
|
+
* Commands: `open`, `close`, `clear`, `focus` (the input).
|
|
31
|
+
*/
|
|
32
|
+
import * as combobox from '@zag-js/combobox'
|
|
33
|
+
import {fromZag} from '../../zag'
|
|
34
|
+
import {createElement as h} from '../../index'
|
|
35
|
+
import {NAMING, arr, collectionOf, named, norm, options, valueOut} from './shared'
|
|
36
|
+
|
|
37
|
+
const shown = (all: any[], f: any, q: string) => {
|
|
38
|
+
if (f === false || !q) return all
|
|
39
|
+
const l = q.toLowerCase()
|
|
40
|
+
return all.filter((i) => (typeof f == 'function' ? f(i, q) : String(i.label).toLowerCase().includes(l)))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// the filter text: what the user typed; '' when the list closes
|
|
44
|
+
const setText = (x: any, q: string) => { if (x.q !== q) x.q = q, x.refresh() }
|
|
45
|
+
|
|
46
|
+
// G-411: forms get the value, not the label the input shows: the visible input has no name; one
|
|
47
|
+
// hidden input per value (a single combobox: one, '' when empty) carries `name`.
|
|
48
|
+
// G-435 / G-468: a single combobox with `allowCustomValue` submits the selected value unless the
|
|
49
|
+
// user typed since the last selection (`x.t`: set by input-change, cleared by a selection, a value
|
|
50
|
+
// change or a new controlled value that isn't empty: G-494); then the value of an item whose label the text is, else the
|
|
51
|
+
// text itself. So `selectionBehavior` 'preserve' / 'clear', items relabelled after a pick and a
|
|
52
|
+
// `defaultValue` before the items load all submit the value.
|
|
53
|
+
const submitted = (api: any, x: any): string => {
|
|
54
|
+
const v = api.value[0] ?? ''
|
|
55
|
+
if (!x.custom || !x.t) return v
|
|
56
|
+
const q = api.inputValue
|
|
57
|
+
return x.items.find((i: any) => i.label === q)?.value ?? q
|
|
58
|
+
}
|
|
59
|
+
const hidden = (api: any, p: any, x: any) => {
|
|
60
|
+
if (p.name == null) return null
|
|
61
|
+
const vs = x.multiple ? api.value : [submitted(api, x)]
|
|
62
|
+
// G-434: `form` as an attribute (HTMLInputElement.form is a read-only property)
|
|
63
|
+
const attrs = p.form == null ? undefined : {form: p.form}
|
|
64
|
+
return vs.map((value: string, i: number) => h('input', {key: 'h' + i, type: 'hidden', name: p.name, attrs, disabled: !!p.disabled, value}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const Combobox: any = /*#__PURE__*/ fromZag(combobox, (api: any, p: any, x: any) => {
|
|
68
|
+
const input = named(api.getInputProps(), p, 1)
|
|
69
|
+
delete input.attrs.name
|
|
70
|
+
delete input.attrs.form
|
|
71
|
+
return h('div', api.getRootProps(),
|
|
72
|
+
p.label != null && h('label', api.getLabelProps(), p.label),
|
|
73
|
+
h('div', api.getControlProps(),
|
|
74
|
+
h('input', {...input, placeholder: p.placeholder}),
|
|
75
|
+
h('button', api.getTriggerProps(), '▾')),
|
|
76
|
+
h('div', api.getPositionerProps(), h('div', named(api.getContentProps(), p), options(h, api))),
|
|
77
|
+
hidden(api, p, x))
|
|
78
|
+
}, {
|
|
79
|
+
name: 'Combobox',
|
|
80
|
+
$own: 1,
|
|
81
|
+
ownProps: [...NAMING, 'name'],
|
|
82
|
+
props: ({items, label, placeholder, value, defaultValue, filter, 'aria-label': _l, 'aria-labelledby': _b, 'aria-describedby': _d, ...p}: any, x: any) => {
|
|
83
|
+
x.multiple = !!p.multiple
|
|
84
|
+
x.custom = !!p.allowCustomValue
|
|
85
|
+
x.items = norm(items)
|
|
86
|
+
// G-468: a new controlled value ends the typing. G-494: not an empty one (an app that clears
|
|
87
|
+
// its value on input-change: the typed text is still what is submitted)
|
|
88
|
+
const v = arr(value), k = v && JSON.stringify(v)
|
|
89
|
+
if (k !== x.v) x.v = k, v?.length && (x.t = 0)
|
|
90
|
+
return {
|
|
91
|
+
...p,
|
|
92
|
+
collection: collectionOf(combobox, shown(x.items, filter, x.q || '')),
|
|
93
|
+
value: v,
|
|
94
|
+
defaultValue: arr(defaultValue),
|
|
95
|
+
// typing only (not the label a selection writes): the filter text, and input-change
|
|
96
|
+
// G-414: the app's own callbacks (p.onInputValueChange / p.onOpenChange) run first
|
|
97
|
+
onInputValueChange: (d: any) => { p.onInputValueChange?.(d); if (d.reason == 'input-change') x.t = 1, setText(x, d.inputValue), x.dispatch('input-change', d.inputValue) },
|
|
98
|
+
onSelect: (d: any) => { x.t = 0; p.onSelect?.(d) },
|
|
99
|
+
onValueChange: (d: any) => { x.t = 0; p.onValueChange?.(d) },
|
|
100
|
+
onOpenChange: (d: any) => { p.onOpenChange?.(d); d.open || setText(x, '') },
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
events: {
|
|
104
|
+
'value-change': ['onValueChange', valueOut],
|
|
105
|
+
'input-change': null,
|
|
106
|
+
'open-change': ['onOpenChange', (d: any) => d.open],
|
|
107
|
+
},
|
|
108
|
+
commands: {
|
|
109
|
+
open: (api: any) => api.setOpen(true),
|
|
110
|
+
close: (api: any) => api.setOpen(false),
|
|
111
|
+
clear: (api: any) => api.clearValue(),
|
|
112
|
+
focus: (api: any) => api.focus(),
|
|
113
|
+
},
|
|
114
|
+
fallback: (p: any, hh: any) => hh('div', null, p.label != null && hh('label', null, p.label), hh('input', {placeholder: p.placeholder, disabled: true})),
|
|
115
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-Z (U-1): Menu, a Zag menu machine (WAI-ARIA menu button) as a widget tag.
|
|
3
|
+
*
|
|
4
|
+
* <Menu className="actions" label="Actions" items={[{ value: 'edit', label: 'Edit' }, { value: 'delete', label: 'Delete', disabled: true }]} />
|
|
5
|
+
* intent: PICK: DOM.select('.actions').events('select').detail() // 'edit'
|
|
6
|
+
* model: OPEN_ACTIONS: { ELEMENT: { open: '.actions' } }
|
|
7
|
+
*
|
|
8
|
+
* Renders a trigger button (its text: `label`) and the menu (role="menu", items role="menuitem")
|
|
9
|
+
* in a positioner. Keyboard, focus and typeahead are Zag's: Enter / Space / ArrowDown open it on
|
|
10
|
+
* the first item (ArrowUp on the last), the arrows move, Home / End, typing jumps to an item,
|
|
11
|
+
* Enter / click selects and closes, Escape / Tab / a click outside closes; the focus returns to
|
|
12
|
+
* the trigger. Styling hooks: Zag's `data-scope="menu"` + `data-part` (trigger, positioner,
|
|
13
|
+
* content, item, separator), `data-state`, `data-highlighted`, `data-disabled`.
|
|
14
|
+
*
|
|
15
|
+
* Props: `label`, `items` (strings, or { value, label?, disabled? }, or { separator: true }), and
|
|
16
|
+
* Zag's menu props (`open` controlled, `defaultOpen`, `closeOnSelect`, `loopFocus`, `typeahead`,
|
|
17
|
+
* `positioning`, `dir`, `id`). Events: `select` (the item's value), `open-change` (boolean).
|
|
18
|
+
* Commands: `open`, `close`.
|
|
19
|
+
*/
|
|
20
|
+
import * as menu from '@zag-js/menu'
|
|
21
|
+
import {fromZag} from '../../zag'
|
|
22
|
+
import {createElement as h} from '../../index'
|
|
23
|
+
import {NAMING, named, norm} from './shared'
|
|
24
|
+
|
|
25
|
+
export const Menu: any = /*#__PURE__*/ fromZag(menu, (api: any, p: any) => [
|
|
26
|
+
h('button', named(api.getTriggerProps(), {...p, label: 1}, 1), p.label),
|
|
27
|
+
h('div', api.getPositionerProps(),
|
|
28
|
+
h('div', api.getContentProps(),
|
|
29
|
+
norm(p.items).map((i: any) => (i.separator
|
|
30
|
+
? h('hr', api.getSeparatorProps())
|
|
31
|
+
: h('div', {key: i.value, ...api.getItemProps({value: i.value, disabled: !!i.disabled})}, i.label))))),
|
|
32
|
+
], {
|
|
33
|
+
name: 'Menu',
|
|
34
|
+
$own: 1,
|
|
35
|
+
ownProps: NAMING,
|
|
36
|
+
props: ({items, label, 'aria-label': _l, 'aria-labelledby': _b, 'aria-describedby': _d, ...p}: any) => p,
|
|
37
|
+
events: {select: ['onSelect', (d: any) => d.value], 'open-change': ['onOpenChange', (d: any) => d.open]},
|
|
38
|
+
commands: {open: (api: any) => api.setOpen(true), close: (api: any) => api.setOpen(false)},
|
|
39
|
+
fallback: (p: any, hh: any) => hh('button', {type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': false}, p.label),
|
|
40
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-Z (U-1): Select, a Zag select machine (WAI-ARIA select-only combobox + listbox) as a
|
|
3
|
+
* widget tag.
|
|
4
|
+
*
|
|
5
|
+
* <Select className="size" label="Size" items={['S', 'M', 'L']} value={state.size} placeholder="Pick a size" name="size" />
|
|
6
|
+
* intent: SIZE: DOM.select('.size').events('value-change').detail() // 'M' (multiple: ['M', 'L'])
|
|
7
|
+
*
|
|
8
|
+
* Renders a label, a trigger button (role="combobox", named by the label) showing the selected
|
|
9
|
+
* label or the placeholder, the listbox (items role="option", aria-selected, a ✓ indicator Zag
|
|
10
|
+
* hides when unselected) in a positioner, and a hidden native <select> for forms (`name`).
|
|
11
|
+
* Keyboard and typeahead are Zag's. Styling hooks: `data-scope="select"` + `data-part` (root,
|
|
12
|
+
* label, control, trigger, value-text, indicator, positioner, content, item, item-text,
|
|
13
|
+
* item-indicator), `data-state`, `data-highlighted`, `data-placeholder-shown`.
|
|
14
|
+
*
|
|
15
|
+
* Props: `label`, `items` (strings or { value, label?, disabled? }), `value` (controlled: a
|
|
16
|
+
* string, or an array when `multiple`; null for none), `defaultValue`, `placeholder`, and Zag's
|
|
17
|
+
* select props (`multiple`, `name`, `form`, `disabled`, `required`, `readOnly`, `invalid`,
|
|
18
|
+
* `deselectable`, `closeOnSelect`, `loopFocus`, `positioning`, `open`, `id`). Events:
|
|
19
|
+
* `value-change` (the value, or the values when multiple), `open-change` (boolean).
|
|
20
|
+
* Commands: `open`, `close`, `clear`, `focus` (the trigger).
|
|
21
|
+
*/
|
|
22
|
+
import * as select from '@zag-js/select'
|
|
23
|
+
import {fromZag} from '../../zag'
|
|
24
|
+
import {createElement as h} from '../../index'
|
|
25
|
+
import {NAMING, arr, collectionOf, named, norm, options, valueOut} from './shared'
|
|
26
|
+
|
|
27
|
+
export const Select: any = /*#__PURE__*/ fromZag(select, (api: any, p: any, x: any) => {
|
|
28
|
+
const hidden = api.getHiddenSelectProps(), v = api.value
|
|
29
|
+
// multiple: the options' selected props carry the value (a select's value prop is one string)
|
|
30
|
+
if (x.multiple) delete hidden.props.value
|
|
31
|
+
if (p.label == null) delete hidden.attrs['aria-labelledby']
|
|
32
|
+
return h('div', api.getRootProps(),
|
|
33
|
+
p.label != null && h('label', api.getLabelProps(), p.label),
|
|
34
|
+
h('div', api.getControlProps(),
|
|
35
|
+
h('button', named(api.getTriggerProps(), p, 1),
|
|
36
|
+
h('span', api.getValueTextProps(), api.valueAsString || p.placeholder || ''),
|
|
37
|
+
h('span', api.getIndicatorProps(), '▾'))),
|
|
38
|
+
h('div', api.getPositionerProps(), h('div', named(api.getContentProps(), p), options(h, api))),
|
|
39
|
+
h('select', hidden,
|
|
40
|
+
h('option', {value: ''}),
|
|
41
|
+
x.items.map((i: any) => h('option', {key: i.value, value: i.value, selected: v.includes(i.value)}, i.label))))
|
|
42
|
+
}, {
|
|
43
|
+
name: 'Select',
|
|
44
|
+
$own: 1,
|
|
45
|
+
ownProps: [...NAMING, 'name'],
|
|
46
|
+
props: ({items, label, placeholder, value, defaultValue, 'aria-label': _l, 'aria-labelledby': _b, 'aria-describedby': _d, ...p}: any, x: any) => {
|
|
47
|
+
x.items = norm(items)
|
|
48
|
+
x.multiple = !!p.multiple
|
|
49
|
+
return {...p, collection: collectionOf(select, x.items), value: arr(value), defaultValue: arr(defaultValue)}
|
|
50
|
+
},
|
|
51
|
+
events: {'value-change': ['onValueChange', valueOut], 'open-change': ['onOpenChange', (d: any) => d.open]},
|
|
52
|
+
commands: {open: (api: any) => api.setOpen(true), close: (api: any) => api.setOpen(false), clear: (api: any) => api.clearValue(), focus: (api: any) => api.focus()},
|
|
53
|
+
fallback: (p: any, hh: any) => hh('div', null, p.label != null && hh('label', null, p.label), hh('button', {type: 'button', disabled: true}, p.placeholder || '')),
|
|
54
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-5 2-Z: helpers the Zag-based parts (Menu, Select, Combobox) share. '../../zag' becomes the
|
|
3
|
+
* external 'sygnal/zag' and '../../index' the external 'sygnal' in the 'sygnal/ui/menu', 'sygnal/ui/select' and 'sygnal/ui/combobox' builds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** G-413: the naming props a part applies to its control (not the host) */
|
|
7
|
+
export const NAMING = ['aria-label', 'aria-labelledby', 'aria-describedby']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A control's (or listbox's) Zag props named from the part's props: without a label element, no
|
|
11
|
+
* aria-labelledby to it; the props' aria-label / aria-labelledby (/ aria-describedby) win
|
|
12
|
+
*/
|
|
13
|
+
export const named = (bag: any, p: any, describe?: any) => {
|
|
14
|
+
const a = bag.attrs
|
|
15
|
+
if (p.label == null) delete a['aria-labelledby']
|
|
16
|
+
for (const k of NAMING) if (p[k] != null && (describe || k != 'aria-describedby')) a[k] = p[k]
|
|
17
|
+
if (p['aria-labelledby'] != null) delete a['aria-label']
|
|
18
|
+
return bag
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An item as the parts take it: a string, or { value, label?, disabled? } (Menu also { separator: true }) */
|
|
22
|
+
export const norm = (items: any): any[] =>
|
|
23
|
+
(items || []).map((i: any) => (i && typeof i == 'object'
|
|
24
|
+
? {...i, value: String(i.value ?? ''), label: i.label ?? String(i.value ?? '')}
|
|
25
|
+
: {value: String(i), label: String(i)}))
|
|
26
|
+
|
|
27
|
+
/** A Select / Combobox value prop as Zag's string[] (undefined: uncontrolled; null or '': none) */
|
|
28
|
+
export const arr = (v: any): any =>
|
|
29
|
+
v === undefined ? undefined : v == null || v === '' ? [] : Array.isArray(v) ? v.map(String) : [String(v)]
|
|
30
|
+
|
|
31
|
+
/** The listbox collection options for Zag's collection() */
|
|
32
|
+
export const collectionOf = (z: any, items: any[]) =>
|
|
33
|
+
z.collection({items, itemToValue: (i: any) => i.value, itemToString: (i: any) => i.label, isItemDisabled: (i: any) => !!i.disabled})
|
|
34
|
+
|
|
35
|
+
/** value-change's detail: the value (single) or the values (multiple) */
|
|
36
|
+
export const valueOut = (d: any, x: any) => (x.multiple ? d.value : d.value[0] ?? null)
|
|
37
|
+
|
|
38
|
+
/** Item rows of a listbox (Select, Combobox): text + a ✓ indicator Zag hides when unselected */
|
|
39
|
+
export const options = (h: any, api: any) =>
|
|
40
|
+
api.collection.items.map((item: any) =>
|
|
41
|
+
h('div', {key: item.value, ...api.getItemProps({item})},
|
|
42
|
+
h('span', api.getItemTextProps({item}), item.label),
|
|
43
|
+
h('span', api.getItemIndicatorProps({item}), '✓')))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// 'sygnal/ui/combobox' (PLAN-5 U-1, D202/D203/D211): Combobox on Zag.js's combobox machine.
|
|
2
|
+
// Needs @zag-js/vanilla and @zag-js/combobox (optional peer dependencies, ~1.45.0).
|
|
3
|
+
import type { Widget } from 'sygnal'
|
|
4
|
+
import type { ZagInstance } from 'sygnal/zag'
|
|
5
|
+
import type { UiItem, UiPositioning, ListboxProps } from './ui-zag-types'
|
|
6
|
+
export type { UiItem, UiPositioning, ListboxProps } from './ui-zag-types'
|
|
7
|
+
|
|
8
|
+
export interface ComboboxProps extends ListboxProps {
|
|
9
|
+
/** The items shown for the typed text: default, labels containing it (case-insensitive); false: none (the app filters `items` from `input-change`) */
|
|
10
|
+
filter?: false | ((item: { value: string; label: string; disabled?: boolean }, text: string) => boolean)
|
|
11
|
+
allowCustomValue?: boolean
|
|
12
|
+
openOnClick?: boolean
|
|
13
|
+
inputBehavior?: 'autohighlight' | 'autocomplete' | 'none'
|
|
14
|
+
selectionBehavior?: 'clear' | 'replace' | 'preserve'
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* An editable combobox (Zag combobox). Events: `value-change`, `input-change` (the typed text),
|
|
18
|
+
* `open-change`. Commands: `open`, `close`, `clear`, `focus`.
|
|
19
|
+
*/
|
|
20
|
+
export const Combobox: Widget<ComboboxProps, ZagInstance, 'value-change' | 'input-change' | 'open-change'>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// 'sygnal/ui/combobox' (PLAN-5 U-1, D202/D203/D211): Combobox, a widget tag on Zag.js's combobox machine
|
|
2
|
+
// (fromZag). A subpath per part (D211), so an app that uses Combobox installs only @zag-js/vanilla and
|
|
3
|
+
// @zag-js/combobox (optional peer dependencies, ~1.45.0), and 'sygnal/ui' (the native parts) never
|
|
4
|
+
// needs Zag. Rollup turns '../../zag' into the external 'sygnal/zag' (fromZag ships once when an
|
|
5
|
+
// app uses several parts) and '../../index' into 'sygnal'.
|
|
6
|
+
export {Combobox} from './ui/zag/combobox'
|