@inglorious/ui 7.1.6 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -5
- package/src/controls/combobox/style.css +7 -0
- package/src/controls/combobox/template.js +75 -81
- package/src/index.js +1 -0
- package/src/navigation/navigation.css +1 -0
- package/src/navigation/view-transitions/index.js +141 -0
- package/src/navigation/view-transitions/style.css +124 -0
- package/src/styles/tokens.css +20 -3
- package/types/data-display/material-icon.d.ts +1 -0
- package/types/index.d.ts +5 -0
- package/types/navigation/view-transitions.d.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ui",
|
|
3
|
-
"version": "7.1
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "A design system for Inglorious Web with render-function components and customizable themes",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -286,7 +286,12 @@
|
|
|
286
286
|
"types": "./types/navigation/tabs.d.ts",
|
|
287
287
|
"import": "./src/navigation/tabs/index.js"
|
|
288
288
|
},
|
|
289
|
-
"./tabs.css": "./src/navigation/tabs/style.css"
|
|
289
|
+
"./tabs.css": "./src/navigation/tabs/style.css",
|
|
290
|
+
"./view-transitions": {
|
|
291
|
+
"types": "./types/navigation/view-transitions.d.ts",
|
|
292
|
+
"import": "./src/navigation/view-transitions/index.js"
|
|
293
|
+
},
|
|
294
|
+
"./view-transitions.css": "./src/navigation/view-transitions/style.css"
|
|
290
295
|
},
|
|
291
296
|
"files": [
|
|
292
297
|
"src",
|
|
@@ -300,10 +305,10 @@
|
|
|
300
305
|
"**/*.css"
|
|
301
306
|
],
|
|
302
307
|
"peerDependencies": {
|
|
303
|
-
"@inglorious/charts": "10.0.
|
|
308
|
+
"@inglorious/charts": "10.0.3",
|
|
304
309
|
"@inglorious/store": "11.0.2",
|
|
305
310
|
"@inglorious/utils": "4.0.0",
|
|
306
|
-
"@inglorious/web": "6.
|
|
311
|
+
"@inglorious/web": "6.5.0"
|
|
307
312
|
},
|
|
308
313
|
"devDependencies": {
|
|
309
314
|
"@fontsource/material-symbols-outlined": "^5.3.5",
|
|
@@ -312,7 +317,7 @@
|
|
|
312
317
|
"@inglorious/store": "11.0.2",
|
|
313
318
|
"@inglorious/vite-plugin-jsx": "2.0.2",
|
|
314
319
|
"@inglorious/vite-plugin-vue": "1.0.3",
|
|
315
|
-
"@inglorious/web": "6.
|
|
320
|
+
"@inglorious/web": "6.5.0",
|
|
316
321
|
"@storybook/addon-docs": "^10.6.0",
|
|
317
322
|
"@storybook/web-components-vite": "^10.6.0",
|
|
318
323
|
"happy-dom": "^20.14.0",
|
|
@@ -39,6 +39,13 @@
|
|
|
39
39
|
background: var(--iw-input-bg-hover);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/* The control is focusable so it can be opened from the keyboard. */
|
|
43
|
+
&:focus-visible {
|
|
44
|
+
outline: none;
|
|
45
|
+
border-color: var(--iw-input-border-focus);
|
|
46
|
+
box-shadow: var(--iw-input-shadow-focus);
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
&.iw-combobox-control-open {
|
|
43
50
|
border-color: var(--iw-input-border-focus);
|
|
44
51
|
box-shadow: var(--iw-input-shadow-focus);
|
|
@@ -22,71 +22,60 @@ import {
|
|
|
22
22
|
const NO_FOCUSED_INDEX = -1
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Registered outside-pointer listeners, keyed by the dropdown element they
|
|
26
|
+
* belong to. Used only to deduplicate: the ref callback is handed a fresh arrow
|
|
27
|
+
* on every re-render, so without this each pass would stack another listener on
|
|
28
|
+
* the document.
|
|
29
|
+
* @type {WeakMap<Element, { props: ComboboxProps, listener: (event: Event) => void }>}
|
|
27
30
|
*/
|
|
28
|
-
const
|
|
31
|
+
const outsideListeners = new WeakMap()
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @type {WeakMap<Element, number>}
|
|
34
|
-
*/
|
|
35
|
-
const pointerActivations = new WeakMap()
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Pending outside-pointer listeners, keyed by combobox id. Each entry owns both
|
|
39
|
-
* the deferred timer and the listener so both can be torn down on unmount.
|
|
40
|
-
* @type {Map<string|number, { timer: any, listener: (event: Event) => void }>}
|
|
41
|
-
*/
|
|
42
|
-
const outsideClicks = new Map()
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Attach (or tear down) the document listener that closes the dropdown when the
|
|
46
|
-
* user interacts outside of it.
|
|
34
|
+
* Listen on the document for presses outside the dropdown, closing it when one
|
|
35
|
+
* happens.
|
|
47
36
|
*
|
|
48
37
|
* This listens for `pointerdown` rather than `click` on purpose. Mobile browsers
|
|
49
38
|
* synthesise `click` asynchronously after `touchend`, and drop it entirely when
|
|
50
39
|
* the touch drifts into a scroll gesture or the target is replaced meanwhile.
|
|
51
|
-
* A suppressed click
|
|
52
|
-
* dropdown on the tap that opened it
|
|
53
|
-
* is dispatched synchronously for mouse, touch and pen
|
|
40
|
+
* A suppressed click would leave the listener armed, and it would then close the
|
|
41
|
+
* *next* dropdown on the tap that opened it, leaving the combobox looking stuck.
|
|
42
|
+
* `pointerdown` is dispatched synchronously for mouse, touch and pen.
|
|
54
43
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* the dropdown can unmount before the timer runs, which would leave a listener
|
|
58
|
-
* bound to a detached node behind; tearing the pending timer down on unmount
|
|
59
|
-
* avoids that.
|
|
44
|
+
* No id is needed: the element keys the listener, and it retires itself once its
|
|
45
|
+
* dropdown has left the DOM.
|
|
60
46
|
* @param {ComboboxProps} props
|
|
61
47
|
* @param {Element|null|undefined} el dropdown element, or undefined on unmount
|
|
62
48
|
*/
|
|
63
49
|
function trackOutsideClick(props, el) {
|
|
64
|
-
|
|
65
|
-
const previous = outsideClicks.get(key)
|
|
50
|
+
if (!el) return
|
|
66
51
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
52
|
+
const existing = outsideListeners.get(el)
|
|
53
|
+
if (existing) {
|
|
54
|
+
// A re-render of the same dropdown: keep the one listener, but point it at
|
|
55
|
+
// the current props so onClose is not captured from the first render.
|
|
56
|
+
existing.props = props
|
|
57
|
+
return
|
|
71
58
|
}
|
|
72
59
|
|
|
73
|
-
|
|
60
|
+
const entry = { props }
|
|
61
|
+
entry.listener = (event) => {
|
|
62
|
+
// The dropdown left the DOM: this press is the last one to hear from it.
|
|
63
|
+
// Retiring here is what keeps a torn-down dropdown from closing whatever
|
|
64
|
+
// opens next.
|
|
65
|
+
if (!el.isConnected) {
|
|
66
|
+
document.removeEventListener("pointerdown", entry.listener)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
74
69
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// combobox as an ancestor even when the pressed node was removed from the
|
|
81
|
-
// DOM while the event was bubbling.
|
|
82
|
-
if (!event.composedPath().includes(root)) props.onClose?.()
|
|
70
|
+
// The root, not the dropdown: the control is a sibling of the dropdown, and a
|
|
71
|
+
// press on it has to count as inside. Resolved per event rather than at mount
|
|
72
|
+
// because the dropdown is not connected yet when the ref first runs.
|
|
73
|
+
const root = el.closest(".iw-combobox") ?? el
|
|
74
|
+
if (!event.composedPath().includes(root)) entry.props.onClose?.()
|
|
83
75
|
}
|
|
84
76
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
outsideClicks.set(key, { timer, listener })
|
|
77
|
+
outsideListeners.set(el, entry)
|
|
78
|
+
document.addEventListener("pointerdown", entry.listener)
|
|
90
79
|
}
|
|
91
80
|
|
|
92
81
|
export const Combobox = {
|
|
@@ -127,11 +116,18 @@ export const Combobox = {
|
|
|
127
116
|
/**
|
|
128
117
|
* Renders the clickable control element that shows the current selection and toggles the dropdown.
|
|
129
118
|
* Handles placeholder, clear button, and the open/disabled state.
|
|
119
|
+
*
|
|
120
|
+
* The control is the only way into a closed combobox, so it has to be
|
|
121
|
+
* focusable: it was a plain div with a click handler, which meant tabbing
|
|
122
|
+
* skipped the field entirely and none of the keyboard handling was reachable.
|
|
123
|
+
* It keeps focus while the dropdown is open unless a search input takes it.
|
|
130
124
|
* @param {ComboboxProps} props
|
|
131
125
|
* @returns {TemplateResult}
|
|
132
126
|
*/
|
|
133
127
|
renderControl(props) {
|
|
128
|
+
const controlId = props.id ? `${props.id}-control` : undefined
|
|
134
129
|
return html`<div
|
|
130
|
+
id=${controlId ?? ""}
|
|
135
131
|
class="iw-combobox-control ${classMap({
|
|
136
132
|
"iw-combobox-control-open": props.isOpen,
|
|
137
133
|
"iw-combobox-control-disabled": props.isDisabled,
|
|
@@ -140,22 +136,13 @@ export const Combobox = {
|
|
|
140
136
|
Array.isArray(props.selectedValue) &&
|
|
141
137
|
props.selectedValue.length,
|
|
142
138
|
})}"
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}}
|
|
151
|
-
@click=${(event) => {
|
|
152
|
-
if (props.isDisabled) return
|
|
153
|
-
// Swallow the click a browser synthesises after a tap that the
|
|
154
|
-
// pointerdown above already handled.
|
|
155
|
-
const lastActivation = pointerActivations.get(event.currentTarget) ?? 0
|
|
156
|
-
if (Date.now() - lastActivation < GHOST_CLICK_MS) return
|
|
157
|
-
props.onToggle?.()
|
|
158
|
-
}}
|
|
139
|
+
role="combobox"
|
|
140
|
+
aria-haspopup="listbox"
|
|
141
|
+
aria-expanded=${props.isOpen ? "true" : "false"}
|
|
142
|
+
aria-controls=${controlId ? `${controlId}-listbox` : ""}
|
|
143
|
+
tabindex=${props.isDisabled ? -1 : 0}
|
|
144
|
+
@keydown=${(event) => handleControlKeyDown(props, event)}
|
|
145
|
+
@click=${() => !props.isDisabled && props.onToggle?.()}
|
|
159
146
|
>
|
|
160
147
|
${when(
|
|
161
148
|
props.isMulti,
|
|
@@ -172,7 +159,6 @@ export const Combobox = {
|
|
|
172
159
|
html`<button
|
|
173
160
|
class="iw-combobox-clear"
|
|
174
161
|
type="button"
|
|
175
|
-
@pointerdown=${(event) => event.stopPropagation()}
|
|
176
162
|
@click=${(event) => {
|
|
177
163
|
event.stopPropagation()
|
|
178
164
|
props.onClear?.()
|
|
@@ -248,7 +234,6 @@ export const Combobox = {
|
|
|
248
234
|
|
|
249
235
|
return html`<span
|
|
250
236
|
class="iw-combobox-multi-value-tag"
|
|
251
|
-
@pointerdown=${(event) => event.stopPropagation()}
|
|
252
237
|
@click=${(event) => event.stopPropagation()}
|
|
253
238
|
>
|
|
254
239
|
${Chip.render({
|
|
@@ -275,7 +260,9 @@ export const Combobox = {
|
|
|
275
260
|
const filteredOptions = filterOptions(props.options, props.searchTerm)
|
|
276
261
|
|
|
277
262
|
return html`<div
|
|
263
|
+
id=${props.id ? `${props.id}-control-listbox` : ""}
|
|
278
264
|
class="iw-combobox-dropdown"
|
|
265
|
+
role="listbox"
|
|
279
266
|
${ref((el) => trackOutsideClick(props, el))}
|
|
280
267
|
>
|
|
281
268
|
${when(props.isSearchable, () => this.renderSearchInput?.(props))}
|
|
@@ -304,9 +291,7 @@ export const Combobox = {
|
|
|
304
291
|
@input=${(event) => props.onSearchChange?.(event.target.value)}
|
|
305
292
|
@keydown=${(event) => handleKeyDown(props, event)}
|
|
306
293
|
${ref((el) => {
|
|
307
|
-
if (el && props.isOpen
|
|
308
|
-
queueMicrotask(() => el.focus())
|
|
309
|
-
}
|
|
294
|
+
if (el && props.isOpen) queueMicrotask(() => el.focus())
|
|
310
295
|
})}
|
|
311
296
|
/>`
|
|
312
297
|
},
|
|
@@ -346,6 +331,9 @@ export const Combobox = {
|
|
|
346
331
|
const isFocused = index === props.focusedIndex
|
|
347
332
|
|
|
348
333
|
return html`<div
|
|
334
|
+
id=${props.id ? `${props.id}-option-${index}` : ""}
|
|
335
|
+
role="option"
|
|
336
|
+
aria-selected=${isSelected ? "true" : "false"}
|
|
349
337
|
class="iw-combobox-dropdown-options-option ${classMap({
|
|
350
338
|
"iw-combobox-dropdown-options-option-selected": isSelected,
|
|
351
339
|
"iw-combobox-dropdown-options-option-focused": isFocused,
|
|
@@ -395,22 +383,28 @@ export const Combobox = {
|
|
|
395
383
|
}
|
|
396
384
|
|
|
397
385
|
/**
|
|
398
|
-
*
|
|
386
|
+
* Opens a closed combobox from the keyboard, and navigates an open one.
|
|
399
387
|
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* @
|
|
388
|
+
* The control keeps DOM focus whenever the combobox is not searchable — there
|
|
389
|
+
* is no search input to hand focus to — so while open it drives the same
|
|
390
|
+
* navigation handleKeyDown() gives the search input, rather than toggling.
|
|
391
|
+
* @param {ComboboxProps} props
|
|
392
|
+
* @param {KeyboardEvent} event
|
|
405
393
|
*/
|
|
406
|
-
function
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return
|
|
394
|
+
function handleControlKeyDown(props, event) {
|
|
395
|
+
if (props.isDisabled) return
|
|
396
|
+
|
|
397
|
+
if (props.isOpen) {
|
|
398
|
+
handleKeyDown(props, event)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (event.key !== "Enter" && event.key !== " " && event.key !== "ArrowDown") {
|
|
403
|
+
return
|
|
412
404
|
}
|
|
413
|
-
|
|
405
|
+
// Space would otherwise scroll the page out from under the dropdown.
|
|
406
|
+
event.preventDefault()
|
|
407
|
+
props.onToggle?.()
|
|
414
408
|
}
|
|
415
409
|
|
|
416
410
|
/**
|
package/src/index.js
CHANGED
|
@@ -43,6 +43,7 @@ export { Pagination } from "./navigation/pagination/index.js"
|
|
|
43
43
|
export { SpeedDial } from "./navigation/speed-dial/index.js"
|
|
44
44
|
export { Stepper } from "./navigation/stepper/index.js"
|
|
45
45
|
export { Tabs } from "./navigation/tabs/index.js"
|
|
46
|
+
export { withTransitions } from "./navigation/view-transitions/index.js"
|
|
46
47
|
export { Accordion } from "./surfaces/accordion/index.js"
|
|
47
48
|
export { AppBar } from "./surfaces/app-bar/index.js"
|
|
48
49
|
export { Card } from "./surfaces/card/index.js"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import("../../../types/navigation/view-transitions").TransitionsStore} Store
|
|
3
|
+
* @typedef {import("../../../types/navigation/view-transitions").ViewTransitionType} ViewTransitionType
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The path the router matches against. Hashes are deliberately excluded: they
|
|
7
|
+
* change the fragment, not the page, so they must not animate one. */
|
|
8
|
+
function getPath() {
|
|
9
|
+
return window.location.pathname + window.location.search
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The position of the current entry in the session history, stamped by the
|
|
13
|
+
* Router as it navigates. Comparing it to the previous one is what tells a push
|
|
14
|
+
* from a traversal, and in which direction. */
|
|
15
|
+
function getIndex() {
|
|
16
|
+
return window.history.state?.idx ?? 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function supportsViewTransitions() {
|
|
20
|
+
return typeof document.startViewTransition === "function"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `types` landed after `startViewTransition` itself, so an engine can have the
|
|
24
|
+
* method and still throw when handed the object form of the argument. */
|
|
25
|
+
function supportsTransitionTypes() {
|
|
26
|
+
return (
|
|
27
|
+
typeof ViewTransition !== "undefined" && "types" in ViewTransition.prototype
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {number} index - The index of the entry now current.
|
|
33
|
+
* @param {number} previousIndex - The index of the entry before this render.
|
|
34
|
+
* @returns {ViewTransitionType}
|
|
35
|
+
*/
|
|
36
|
+
function getTransitionType(index, previousIndex) {
|
|
37
|
+
if (index < previousIndex) return "back"
|
|
38
|
+
if (index > previousIndex) return "forward"
|
|
39
|
+
return "replace"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Wraps a store so that the renders which follow a navigation are run inside a
|
|
44
|
+
* view transition, tagged with the direction they navigated in. Pair it with
|
|
45
|
+
* `style.css` from this package, which turns those tags into a slide.
|
|
46
|
+
*
|
|
47
|
+
* The store is returned unchanged apart from its `subscribe`, which fans out to
|
|
48
|
+
* every listener instead of subscribing to the store once per listener.
|
|
49
|
+
*
|
|
50
|
+
* Requires the `Router` type from `@inglorious/web`: direction is read from the
|
|
51
|
+
* index the Router stamps into each history entry, and without it every
|
|
52
|
+
* navigation looks like a replacement and slides the wrong way.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* import { withTransitions } from "@inglorious/ui/view-transitions"
|
|
56
|
+
* import "@inglorious/ui/view-transitions.css"
|
|
57
|
+
*
|
|
58
|
+
* mount(withTransitions(store), renderApp, document.getElementById("root"))
|
|
59
|
+
*
|
|
60
|
+
* @param {Store} store - The store to wrap.
|
|
61
|
+
* @returns {Store} The same store, with a wrapped `subscribe`.
|
|
62
|
+
* @throws {Error} If the store has no `Router` type.
|
|
63
|
+
*/
|
|
64
|
+
export function withTransitions(store) {
|
|
65
|
+
if (!store.getType("Router")) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'[withTransitions] Requires the "Router" type from @inglorious/web. The ' +
|
|
68
|
+
"direction of a transition is read from the index the Router stamps " +
|
|
69
|
+
"into each history entry.",
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const subscribe = store.subscribe
|
|
74
|
+
const listeners = new Set()
|
|
75
|
+
|
|
76
|
+
let currentPath = ""
|
|
77
|
+
let currentIndex = 0
|
|
78
|
+
let unsubscribeStore = null
|
|
79
|
+
|
|
80
|
+
/* The update callback of a view transition does not run synchronously: the
|
|
81
|
+
* browser captures the old state first and calls it on a later task. Reading
|
|
82
|
+
* the state here rather than in the subscription keeps a listener from
|
|
83
|
+
* replaying whatever the store held when the transition started. */
|
|
84
|
+
function notify() {
|
|
85
|
+
const state = store.getState()
|
|
86
|
+
// Copied, so that a listener subscribing during this pass waits for the
|
|
87
|
+
// next one instead of being notified by the pass that added it.
|
|
88
|
+
;[...listeners].forEach((listener) => listener(state))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onStateChange() {
|
|
92
|
+
const path = getPath()
|
|
93
|
+
const index = getIndex()
|
|
94
|
+
/* Both are compared against the baseline before it moves forward,
|
|
95
|
+
* otherwise the comparison would always be against itself and every
|
|
96
|
+
* navigation would read as a replacement. */
|
|
97
|
+
const hasNavigated = path !== currentPath
|
|
98
|
+
const previousIndex = currentIndex
|
|
99
|
+
|
|
100
|
+
currentPath = path
|
|
101
|
+
currentIndex = index
|
|
102
|
+
|
|
103
|
+
if (!hasNavigated || !supportsViewTransitions()) return notify()
|
|
104
|
+
|
|
105
|
+
const types = [getTransitionType(index, previousIndex)]
|
|
106
|
+
|
|
107
|
+
if (supportsTransitionTypes()) {
|
|
108
|
+
document.startViewTransition({ update: notify, types })
|
|
109
|
+
} else {
|
|
110
|
+
// Without `types` the browser falls back to its own cross-fade, which is
|
|
111
|
+
// a reasonable way to change page when the slide is unavailable.
|
|
112
|
+
document.startViewTransition(notify)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
store.subscribe = (listener) => {
|
|
117
|
+
/* Taken here rather than when the store is wrapped, so that anything that
|
|
118
|
+
navigated in between — an initial redirect, a renderer mounting late — is
|
|
119
|
+
part of the baseline instead of a navigation in its own right. It also
|
|
120
|
+
keeps `window` untouched until something subscribes, so wrapping a store
|
|
121
|
+
during a server render is harmless. */
|
|
122
|
+
if (!unsubscribeStore) {
|
|
123
|
+
currentPath = getPath()
|
|
124
|
+
currentIndex = getIndex()
|
|
125
|
+
unsubscribeStore = subscribe.call(store, onStateChange)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
listeners.add(listener)
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
listeners.delete(listener)
|
|
132
|
+
|
|
133
|
+
if (!listeners.size) {
|
|
134
|
+
unsubscribeStore?.()
|
|
135
|
+
unsubscribeStore = null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return store
|
|
141
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Directional page transitions for the whole document, driven by the types that
|
|
2
|
+
`withTransitions` tags each transition with. Pair the two: without the
|
|
3
|
+
JavaScript nothing sets a type and the browser runs its own cross-fade,
|
|
4
|
+
without this file the tags are inert and the browser does the same.
|
|
5
|
+
|
|
6
|
+
The transition pseudo-elements have to be written as one compound selector
|
|
7
|
+
glued straight onto the document element. The nested form that reads so much
|
|
8
|
+
better — `:root:active-view-transition-type(forward) { ::view-transition-old(root)
|
|
9
|
+
{ … } }` — compiles to a descendant combinator, which does not reach a
|
|
10
|
+
pseudo-element that hangs off the root, and silently leaves the browser's own
|
|
11
|
+
cross-fade in place. */
|
|
12
|
+
|
|
13
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
14
|
+
:is(
|
|
15
|
+
:root:active-view-transition-type(forward),
|
|
16
|
+
:root:active-view-transition-type(back),
|
|
17
|
+
:root:active-view-transition-type(replace)
|
|
18
|
+
)
|
|
19
|
+
::view-transition-group(root) {
|
|
20
|
+
animation-duration: var(--iw-duration-page);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The user agent isolates the image pair and blends the old snapshot onto the
|
|
24
|
+
new one with `plus-lighter`, so that two opaque snapshots cross-fading never
|
|
25
|
+
let the page show through. That is right for a fade and wrong for a slide:
|
|
26
|
+
the two snapshots do not overlap while they travel, so adding their pixels
|
|
27
|
+
only shifts their brightness. Undo both. */
|
|
28
|
+
:is(
|
|
29
|
+
:root:active-view-transition-type(forward),
|
|
30
|
+
:root:active-view-transition-type(back),
|
|
31
|
+
:root:active-view-transition-type(replace)
|
|
32
|
+
)
|
|
33
|
+
::view-transition-image-pair(root) {
|
|
34
|
+
isolation: auto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
:is(
|
|
38
|
+
:root:active-view-transition-type(forward),
|
|
39
|
+
:root:active-view-transition-type(back),
|
|
40
|
+
:root:active-view-transition-type(replace)
|
|
41
|
+
)
|
|
42
|
+
::view-transition-old(root) {
|
|
43
|
+
animation: none;
|
|
44
|
+
mix-blend-mode: normal;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
:is(
|
|
48
|
+
:root:active-view-transition-type(forward),
|
|
49
|
+
:root:active-view-transition-type(back),
|
|
50
|
+
:root:active-view-transition-type(replace)
|
|
51
|
+
)
|
|
52
|
+
::view-transition-new(root) {
|
|
53
|
+
animation: none;
|
|
54
|
+
mix-blend-mode: normal;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
:root:active-view-transition-type(forward)::view-transition-old(root) {
|
|
58
|
+
animation: iw-view-transition-out-left var(--iw-duration-page)
|
|
59
|
+
var(--iw-easing-accelerate) both;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
:root:active-view-transition-type(forward)::view-transition-new(root) {
|
|
63
|
+
animation: iw-view-transition-in-right var(--iw-duration-page)
|
|
64
|
+
var(--iw-easing-decelerate) both;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
:root:active-view-transition-type(back)::view-transition-old(root) {
|
|
68
|
+
animation: iw-view-transition-out-right var(--iw-duration-page)
|
|
69
|
+
var(--iw-easing-accelerate) both;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
:root:active-view-transition-type(back)::view-transition-new(root) {
|
|
73
|
+
animation: iw-view-transition-in-left var(--iw-duration-page)
|
|
74
|
+
var(--iw-easing-decelerate) both;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* A replacement navigates in place rather than arriving from a side, so it
|
|
78
|
+
cross-fades instead of sliding. */
|
|
79
|
+
:root:active-view-transition-type(replace)::view-transition-old(root) {
|
|
80
|
+
animation: iw-view-transition-fade-out var(--iw-duration-page)
|
|
81
|
+
var(--iw-easing-standard) both;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
:root:active-view-transition-type(replace)::view-transition-new(root) {
|
|
85
|
+
animation: iw-view-transition-fade-in var(--iw-duration-page)
|
|
86
|
+
var(--iw-easing-standard) both;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@keyframes iw-view-transition-out-left {
|
|
91
|
+
to {
|
|
92
|
+
transform: translateX(-100%);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@keyframes iw-view-transition-in-right {
|
|
97
|
+
from {
|
|
98
|
+
transform: translateX(100%);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@keyframes iw-view-transition-out-right {
|
|
103
|
+
to {
|
|
104
|
+
transform: translateX(100%);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@keyframes iw-view-transition-in-left {
|
|
109
|
+
from {
|
|
110
|
+
transform: translateX(-100%);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@keyframes iw-view-transition-fade-out {
|
|
115
|
+
to {
|
|
116
|
+
opacity: 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@keyframes iw-view-transition-fade-in {
|
|
121
|
+
from {
|
|
122
|
+
opacity: 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/styles/tokens.css
CHANGED
|
@@ -126,9 +126,26 @@
|
|
|
126
126
|
/* ========================================
|
|
127
127
|
TRANSITIONS
|
|
128
128
|
======================================== */
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
/* A duration and an easing, so that motion can pick one of each. Anything
|
|
130
|
+
that has to vary them separately — a page arriving is not eased the way a
|
|
131
|
+
page leaving is — composes them rather than reaching for the shorthands. */
|
|
132
|
+
--iw-duration-fast: 150ms;
|
|
133
|
+
--iw-duration-normal: 200ms;
|
|
134
|
+
--iw-duration-page: 250ms;
|
|
135
|
+
--iw-duration-slow: 300ms;
|
|
136
|
+
--iw-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
137
|
+
--iw-easing-accelerate: cubic-bezier(0.4, 0, 1, 1);
|
|
138
|
+
--iw-easing-decelerate: cubic-bezier(0, 0, 0.2, 1);
|
|
139
|
+
|
|
140
|
+
/* Shorthands for a plain `ease` at one of the durations above. These keep
|
|
141
|
+
working as they always have; reach for the tokens above instead when the
|
|
142
|
+
easing has to be anything other than `ease`. The page transitions in
|
|
143
|
+
`view-transitions` compose the two on purpose — a page arriving decelerates
|
|
144
|
+
and a page leaving accelerates — so they are not a shorthand case. */
|
|
145
|
+
--iw-transition-fast: var(--iw-duration-fast) ease;
|
|
146
|
+
--iw-transition-normal: var(--iw-duration-normal) ease;
|
|
147
|
+
--iw-transition-page: var(--iw-duration-page) ease;
|
|
148
|
+
--iw-transition-slow: var(--iw-duration-slow) ease;
|
|
132
149
|
|
|
133
150
|
/* ========================================
|
|
134
151
|
Z-INDEX SCALE
|
package/types/index.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export type {
|
|
|
44
44
|
} from "./navigation/speed-dial"
|
|
45
45
|
export type { StepperProps, StepperType, Step } from "./navigation/stepper"
|
|
46
46
|
export type { TabsProps, TabsType, TabItem } from "./navigation/tabs"
|
|
47
|
+
export type {
|
|
48
|
+
ViewTransitionType,
|
|
49
|
+
TransitionsStore,
|
|
50
|
+
WithTransitions,
|
|
51
|
+
} from "./navigation/view-transitions"
|
|
47
52
|
export type {
|
|
48
53
|
AccordionProps,
|
|
49
54
|
AccordionType,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The direction a view transition is tagged with, and how the stylesheet in
|
|
2
|
+
* this package reacts to it. `forward` and `back` slide, `replace` cross-fades. */
|
|
3
|
+
export type ViewTransitionType = "forward" | "back" | "replace"
|
|
4
|
+
|
|
5
|
+
/** The part of a store this module needs. Declared structurally rather than
|
|
6
|
+
* imported, so any store-shaped object works. */
|
|
7
|
+
export interface TransitionsStore<S = unknown> {
|
|
8
|
+
subscribe(listener: (state: S) => void): () => void
|
|
9
|
+
getState(): S
|
|
10
|
+
getType(name: string): unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WithTransitions {
|
|
14
|
+
<S>(store: TransitionsStore<S>): TransitionsStore<S>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export declare const withTransitions: WithTransitions
|