@inglorious/ui 7.1.6 → 7.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "7.1.6",
3
+ "version": "7.2.0",
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.2",
308
+ "@inglorious/charts": "10.0.3",
304
309
  "@inglorious/store": "11.0.2",
305
310
  "@inglorious/utils": "4.0.0",
306
- "@inglorious/web": "6.4.2"
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.4.2",
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",
@@ -22,71 +22,60 @@ import {
22
22
  const NO_FOCUSED_INDEX = -1
23
23
 
24
24
  /**
25
- * Window in which an emulated click is treated as the tail of a pointer
26
- * interaction that already handled the gesture.
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 GHOST_CLICK_MS = 700
31
+ const outsideListeners = new WeakMap()
29
32
 
30
33
  /**
31
- * Last pointer activation per control element, used to stop the click that
32
- * browsers synthesise after a tap from toggling the dropdown a second time.
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 leaves the listener armed, so it then closes the *next*
52
- * dropdown on the tap that opened it and the combobox looks stuck. `pointerdown`
53
- * is dispatched synchronously for mouse, touch and pen, so there is no race.
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
- * The registration is deferred by a macrotask because the pointerdown that opens
56
- * the dropdown is already travelling towards the document. The downside is that
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
- const key = props.id
65
- const previous = outsideClicks.get(key)
50
+ if (!el) return
66
51
 
67
- if (previous) {
68
- clearTimeout(previous.timer)
69
- document.removeEventListener("pointerdown", previous.listener)
70
- outsideClicks.delete(key)
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
- if (!el) return
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
- // Resolve the root so the control and the dropdown both count as inside.
76
- const root = el.closest(".iw-combobox") ?? el
77
- const listener = (event) => {
78
- outsideClicks.delete(key)
79
- // composedPath is captured at dispatch time, so it still reports the
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
- const timer = setTimeout(() => {
86
- document.addEventListener("pointerdown", listener, { once: true })
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 = {
@@ -140,22 +129,7 @@ export const Combobox = {
140
129
  Array.isArray(props.selectedValue) &&
141
130
  props.selectedValue.length,
142
131
  })}"
143
- @pointerdown=${(event) => {
144
- // Touch and pen only: the mouse keeps using click, so a mouse press
145
- // never runs the toggle twice.
146
- if (props.isDisabled) return
147
- if (event.pointerType === "mouse") return
148
- pointerActivations.set(event.currentTarget, Date.now())
149
- props.onToggle?.()
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
- }}
132
+ @click=${() => !props.isDisabled && props.onToggle?.()}
159
133
  >
160
134
  ${when(
161
135
  props.isMulti,
@@ -172,7 +146,6 @@ export const Combobox = {
172
146
  html`<button
173
147
  class="iw-combobox-clear"
174
148
  type="button"
175
- @pointerdown=${(event) => event.stopPropagation()}
176
149
  @click=${(event) => {
177
150
  event.stopPropagation()
178
151
  props.onClear?.()
@@ -248,7 +221,6 @@ export const Combobox = {
248
221
 
249
222
  return html`<span
250
223
  class="iw-combobox-multi-value-tag"
251
- @pointerdown=${(event) => event.stopPropagation()}
252
224
  @click=${(event) => event.stopPropagation()}
253
225
  >
254
226
  ${Chip.render({
@@ -304,9 +276,7 @@ export const Combobox = {
304
276
  @input=${(event) => props.onSearchChange?.(event.target.value)}
305
277
  @keydown=${(event) => handleKeyDown(props, event)}
306
278
  ${ref((el) => {
307
- if (el && props.isOpen && shouldFocusSearch()) {
308
- queueMicrotask(() => el.focus())
309
- }
279
+ if (el && props.isOpen) queueMicrotask(() => el.focus())
310
280
  })}
311
281
  />`
312
282
  },
@@ -394,25 +364,6 @@ export const Combobox = {
394
364
  },
395
365
  }
396
366
 
397
- /**
398
- * Whether opening the dropdown should move focus into the search field.
399
- *
400
- * Focusing it is what lets keyboard users start typing straight away, but on a
401
- * touch device it raises the on-screen keyboard, which covers the very option
402
- * list the dropdown was opened to read. Coarse pointers therefore open the
403
- * dropdown without focusing the search field.
404
- * @returns {boolean}
405
- */
406
- function shouldFocusSearch() {
407
- if (
408
- typeof window === "undefined" ||
409
- typeof window.matchMedia !== "function"
410
- ) {
411
- return true
412
- }
413
- return window.matchMedia("(hover: hover) and (pointer: fine)").matches
414
- }
415
-
416
367
  /**
417
368
  * Handle key navigation.
418
369
  * @param {ComboboxProps} props
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"
@@ -7,3 +7,4 @@
7
7
  @import "./speed-dial/style.css";
8
8
  @import "./stepper/style.css";
9
9
  @import "./tabs/style.css";
10
+ @import "./view-transitions/style.css";
@@ -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
+ }
@@ -126,9 +126,26 @@
126
126
  /* ========================================
127
127
  TRANSITIONS
128
128
  ======================================== */
129
- --iw-transition-fast: 150ms ease;
130
- --iw-transition-normal: 200ms ease;
131
- --iw-transition-slow: 300ms ease;
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
@@ -5,6 +5,7 @@ export interface MaterialIconProps {
5
5
  name?: string
6
6
  size?: "sm" | "md" | "lg"
7
7
  isFilled?: boolean
8
+ onClick?: () => void
8
9
  [key: string]: unknown
9
10
  }
10
11
 
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