@inglorious/ui 7.1.5 → 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.5",
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",
@@ -21,24 +21,61 @@ import {
21
21
 
22
22
  const NO_FOCUSED_INDEX = -1
23
23
 
24
- const outsideClickListeners = new Map()
24
+ /**
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 }>}
30
+ */
31
+ const outsideListeners = new WeakMap()
25
32
 
33
+ /**
34
+ * Listen on the document for presses outside the dropdown, closing it when one
35
+ * happens.
36
+ *
37
+ * This listens for `pointerdown` rather than `click` on purpose. Mobile browsers
38
+ * synthesise `click` asynchronously after `touchend`, and drop it entirely when
39
+ * the touch drifts into a scroll gesture or the target is replaced meanwhile.
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.
43
+ *
44
+ * No id is needed: the element keys the listener, and it retires itself once its
45
+ * dropdown has left the DOM.
46
+ * @param {ComboboxProps} props
47
+ * @param {Element|null|undefined} el dropdown element, or undefined on unmount
48
+ */
26
49
  function trackOutsideClick(props, el) {
27
- const previous = outsideClickListeners.get(props.id)
28
- if (previous) {
29
- document.removeEventListener("click", previous)
30
- outsideClickListeners.delete(props.id)
50
+ if (!el) return
51
+
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
31
58
  }
32
59
 
33
- 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
+ }
34
69
 
35
- const root = el.closest(".iw-combobox")
36
- const listener = (event) => {
37
- 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?.()
38
75
  }
39
76
 
40
- outsideClickListeners.set(props.id, listener)
41
- document.addEventListener("click", listener)
77
+ outsideListeners.set(el, entry)
78
+ document.addEventListener("pointerdown", entry.listener)
42
79
  }
43
80
 
44
81
  export const Combobox = {
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