@vectoriox/iox-ui 5.64.0 → 5.65.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": "@vectoriox/iox-ui",
3
- "version": "5.64.0",
3
+ "version": "5.65.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * How a dialog LEAVES (CC1 follow-up — the drawer that vanished). Opening is pure CSS (`@starting-style`
3
+ * in the shared `iox-design.css`); closing needs one beat of script, because `dialog.close()` drops the
4
+ * top layer at once and only some browsers can hold it for a transition (`overlay … allow-discrete`).
5
+ *
6
+ * The ONE exit every renderer runs: mark the element closing (`data-iox-closing` — the shared CSS gives it
7
+ * its closed look: the drawer back off its edge, the scrim clear), wait for the transitions that started,
8
+ * then finish. Reduced motion (or a stub with no animations) finishes now. Framework-free: the engine's
9
+ * every close path (actions, the X, the backdrop, Esc, the close-above watcher) and the builder canvas's
10
+ * stage share it.
11
+ */
12
+
13
+ export const DIALOG_CLOSING_ATTRIBUTE = 'data-iox-closing';
14
+
15
+ /** Longest an exit may hold the element before it finishes anyway — a transition that never ends (a
16
+ * value that did not change, a tab in the background) must not leave a dialog half-closed. */
17
+ export const DIALOG_EXIT_TIMEOUT_MS = 700;
18
+
19
+ export interface ExitAnimationLike {
20
+ finished: Promise<unknown>;
21
+ effect?: { getComputedTiming?: () => { endTime?: number | null } } | null;
22
+ }
23
+
24
+ export interface ExitMotionElement {
25
+ setAttribute(name: string, value: string): void;
26
+ removeAttribute(name: string): void;
27
+ hasAttribute(name: string): boolean;
28
+ getAnimations?: (options?: { subtree?: boolean }) => ExitAnimationLike[];
29
+ }
30
+
31
+ export interface DialogElementLike extends ExitMotionElement {
32
+ open: boolean;
33
+ close(): void;
34
+ }
35
+
36
+ export interface ExitMotionOptions {
37
+ /** False under reduced motion: finish at once (the caller asks `motionAllowed()`). */
38
+ animate: boolean;
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ /** True while an exit is running on the element (a second close waits on it; an open cancels it). */
43
+ export function isExiting(el: ExitMotionElement | null | undefined): boolean {
44
+ return !!el?.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
45
+ }
46
+
47
+ /** Stop an exit that is running (the dialog was opened again mid-close): it stays open. */
48
+ export function cancelExit(el: ExitMotionElement | null | undefined): void {
49
+ el?.removeAttribute(DIALOG_CLOSING_ATTRIBUTE);
50
+ }
51
+
52
+ const finite = (a: ExitAnimationLike) => {
53
+ const end = a.effect?.getComputedTiming?.().endTime;
54
+ return end === undefined || end === null || Number.isFinite(end);
55
+ };
56
+
57
+ /**
58
+ * Run the exit: mark the element, wait for its finite animations (its subtree's too — the drawer's links,
59
+ * the `::backdrop`) or the timeout. Resolves TRUE when the exit completed and the caller should finish
60
+ * (close / remove), FALSE when it was cancelled meanwhile. The mark stays on until the caller finishes.
61
+ */
62
+ export async function runExitMotion(el: ExitMotionElement, options: ExitMotionOptions): Promise<boolean> {
63
+ el.setAttribute(DIALOG_CLOSING_ATTRIBUTE, '');
64
+ if (!options.animate) return true;
65
+ // Reading the animations flushes style, so the transitions the mark just started are in the list.
66
+ const running = (el.getAnimations?.({ subtree: true }) ?? []).filter(finite);
67
+ if (running.length) {
68
+ const timeout = options.timeoutMs ?? DIALOG_EXIT_TIMEOUT_MS;
69
+ let timer: ReturnType<typeof setTimeout> | undefined;
70
+ await Promise.race([
71
+ Promise.all(running.map(a => a.finished.catch(() => undefined))),
72
+ new Promise<void>(resolve => { timer = setTimeout(resolve, timeout); }),
73
+ ]);
74
+ if (timer !== undefined) clearTimeout(timer);
75
+ }
76
+ return isExiting(el);
77
+ }
78
+
79
+ const pending = new WeakMap<object, Promise<void>>();
80
+
81
+ /**
82
+ * Close a live `<dialog>` with its exit motion. A second call while it is closing joins the first; an open
83
+ * in between (`cancelExit`) keeps it open. The dialog's own `close` event still fires once, after the
84
+ * motion — so whatever a runtime does on close (restart the page scroll, sync the openers, return focus)
85
+ * happens when the dialog is really gone.
86
+ */
87
+ export function closeDialogAnimated(el: DialogElementLike, options: ExitMotionOptions): Promise<void> {
88
+ if (!el.open) return Promise.resolve();
89
+ const running = pending.get(el);
90
+ if (running && isExiting(el)) return running;
91
+ const done = runExitMotion(el, options).then(finish => {
92
+ pending.delete(el);
93
+ if (!finish) return;
94
+ if (el.open) el.close();
95
+ cancelExit(el);
96
+ });
97
+ pending.set(el, done);
98
+ return done;
99
+ }
100
+
101
+ /** True when a toggle should CLOSE the dialog: it is open and not already on its way out. */
102
+ export function dialogTogglesClosed(el: Pick<DialogElementLike, 'open' | 'hasAttribute'>): boolean {
103
+ return el.open && !el.hasAttribute(DIALOG_CLOSING_ATTRIBUTE);
104
+ }
@@ -12,3 +12,4 @@ export * from './action.model';
12
12
  export * from './availability';
13
13
  export * from './element-actions';
14
14
  export * from './dialog-opener';
15
+ export * from './dialog-motion';
@@ -252,8 +252,18 @@ function makeKit(globals: ReadonlyArray<SerializedNode>, rootId: string, newId?:
252
252
 
253
253
  /** Point an opener's dialog actions at `dialogId` (a drawer rebuilt under a new id keeps its openers working). */
254
254
  function retarget(node: SerializedNode, dialogId: string): SerializedNode {
255
- for (const ix of node.interactions as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
256
- for (const a of ix.actions ?? []) if (a.type === 'openDialog' || a.type === 'toggleDialog') a.target = dialogId;
255
+ for (const ix of (node.interactions ?? []) as Array<{ actions?: Array<{ type?: string; target?: string }> }>) {
256
+ for (const a of ix.actions ?? []) if (a.type === NavDialogAction.Open || a.type === NavDialogAction.Toggle) a.target = dialogId;
257
+ }
258
+ return asToggle(node);
259
+ }
260
+
261
+ /** The menu button and More are TOGGLES (a second click closes what the first opened): a part saved before that
262
+ * rule opened only — kept through a change of shape, it toggles from then on. */
263
+ function asToggle(node: SerializedNode): SerializedNode {
264
+ for (const ix of (node.interactions ?? []) as Array<{ trigger?: string; actions?: Array<{ type?: string }> }>) {
265
+ if (ix.trigger !== CLICK) continue;
266
+ for (const a of ix.actions ?? []) if (a.type === NavDialogAction.Open) a.type = NavDialogAction.Toggle;
257
267
  }
258
268
  return node;
259
269
  }
@@ -267,7 +277,14 @@ function part(kit: Kit, suffix: string, type: string, props: Record<string, unkn
267
277
  return node;
268
278
  }
269
279
 
270
- function clickTo(kit: Kit, suffix: string, type: 'openDialog' | 'closeDialog', target: string) {
280
+ /** The dialog actions a nav part runs on click. */
281
+ enum NavDialogAction {
282
+ Open = 'openDialog',
283
+ Close = 'closeDialog',
284
+ Toggle = 'toggleDialog',
285
+ }
286
+
287
+ function clickTo(kit: Kit, suffix: string, type: NavDialogAction, target: string) {
271
288
  return { id: kit.id(`${suffix}-click`), trigger: CLICK, actions: [{ type, target, duration: 0, delay: 0, easing: 'ease' }] };
272
289
  }
273
290
 
@@ -302,11 +319,15 @@ function labelText(kit: Kit, suffix: string, words: string): SerializedNode {
302
319
  return part(kit, suffix, TEXT, { content: words, tag: 'span' }, 'button-label');
303
320
  }
304
321
 
322
+ /** The menu button and the drawer's Close are ONE size — and the shared CSS pads the drawer like the header
323
+ * (`.iox-role-nav-drawer` re-declares dialog-pad as nav-pad) — so the Close lands where the menu button is and
324
+ * reads as the same button turned into an X. */
325
+ const MENU_BUTTON_LAY = { width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' };
326
+
305
327
  function menuButton(kit: Kit, drawerId: string, at: string): SerializedNode {
306
- const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton,
307
- { width: '48px', height: '48px', alignItems: 'center', justifyContent: 'center' });
328
+ const btn = part(kit, 'menu', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.menu }, 'button-ghost', NavRole.MenuButton, { ...MENU_BUTTON_LAY });
308
329
  btn.children = [iconNode(kit, 'menu-icon', MENU_ICON)];
309
- btn.interactions = [clickTo(kit, 'menu', 'openDialog', drawerId)];
330
+ btn.interactions = [clickTo(kit, 'menu', NavDialogAction.Toggle, drawerId)];
310
331
  phoneOnly(btn, at, 'inline-flex');
311
332
  return btn;
312
333
  }
@@ -322,10 +343,9 @@ function drawer(kit: Kit, root: SerializedNode, pages: NavPage[], at: string): S
322
343
  const brandWords = textOf(find([root], NavRole.Brand) ?? root) || NAV_PART_LABELS[NavRole.Root];
323
344
  const head = part(kit, 'drawer-head', CONTAINER, {}, 'box', NavRole.DrawerHead,
324
345
  { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px' });
325
- const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose,
326
- { width: '44px', height: '44px', alignItems: 'center', justifyContent: 'center' });
346
+ const close = part(kit, 'drawer-close', BUTTON, { tag: 'button', ariaLabel: NAV_SHAPE_COPY.closeMenu }, 'button-ghost', NavRole.DrawerClose, { ...MENU_BUTTON_LAY });
327
347
  close.children = [iconNode(kit, 'drawer-close-icon', CLOSE_ICON)];
328
- close.interactions = [clickTo(kit, 'drawer-close', 'closeDialog', dr.id)];
348
+ close.interactions = [clickTo(kit, 'drawer-close', NavDialogAction.Close, dr.id)];
329
349
  head.children = [part(kit, 'drawer-brand', HEADING, { content: brandWords, level: 'h3' }, 'h3'), close];
330
350
  const list = part(kit, 'drawer-links', CONTAINER, {}, 'box', NavRole.DrawerLinks,
331
351
  { display: 'flex', flexDirection: 'column', gap: '4px' });
@@ -364,7 +384,7 @@ const BAR_ITEM_LAY = {
364
384
  function moreItem(kit: Kit, drawerId: string): SerializedNode {
365
385
  const more = part(kit, 'bar-more', BUTTON, { tag: 'button' }, 'bottom-bar-item', NavRole.More, BAR_ITEM_LAY);
366
386
  more.children = [iconNode(kit, 'bar-more-icon', MORE_ICON), labelText(kit, 'bar-more-label', NAV_SHAPE_COPY.more)];
367
- more.interactions = [clickTo(kit, 'bar-more', 'openDialog', drawerId)];
387
+ more.interactions = [clickTo(kit, 'bar-more', NavDialogAction.Toggle, drawerId)];
368
388
  return more;
369
389
  }
370
390
 
@@ -472,7 +492,7 @@ export function planNavShape(globals: ReadonlyArray<SerializedNode>, shape: NavS
472
492
 
473
493
  // The menu button: Drawer only (Q4: with a bar, the phone header keeps only the brand).
474
494
  const existingMenu = removeRole(root, NavRole.MenuButton);
475
- const menu = wantMenuButton ? (existingMenu ?? menuButton(kit, dr!.id, at)) : null;
495
+ const menu = wantMenuButton ? (existingMenu ? asToggle(existingMenu) : menuButton(kit, dr!.id, at)) : null;
476
496
 
477
497
  for (const p of phoneHiddenParts(root)) { moveDisplayOverride(p, at); hideAt(p, at); }
478
498
  centerBrandAt(root, at, wantBar);
@@ -118,15 +118,17 @@ iox-gallery.iox-es-media[data-iox-empty] > .iox-gallery {
118
118
 
119
119
  /* The DRAWER (board 1, board-8 Q9): a Dialog wearing an element style, placed at the start or end edge, is
120
120
  a full-height sheet on that edge — 100dvh (never 100vh: Safari's toolbar), no gap to the edge, the
121
- radius kept on its LEADING edge only (a placement rule, not a token). It slides in over dur-base, which
122
- the reduced-motion token block sets to 0ms: under reduced motion it simply appears. Beats the engine's
123
- placement margins (0,2,1) and the node rule; a Dialog saved before element styles keeps its look. */
121
+ radius kept on its LEADING edge only (a placement rule, not a token). Beats the engine's placement
122
+ margins (0,2,1) and the node rule; a Dialog saved before element styles keeps its look. */
124
123
  dialog[data-iox-dialog].iox-es:is([data-iox-dialog-placement='end'], [data-iox-dialog-placement='start']) {
125
124
  margin-block: 0;
126
125
  height: 100dvh;
127
126
  max-height: 100dvh;
128
- transition: translate var(--iox-dur-base) var(--iox-ease-standard), overlay var(--iox-dur-base) allow-discrete, display var(--iox-dur-base) allow-discrete;
129
127
  }
128
+ /* A NAVIGATION's drawer is padded like the header it opens from, so its Close (the menu button's size,
129
+ generation-core nav-shape) lands where the menu button is — the same button, turned into an X. The token is
130
+ re-declared, not the padding overridden: a padding the author sets on the drawer still wins. */
131
+ .iox-es.iox-role-nav-drawer { --iox-dialog-pad: var(--iox-nav-pad); }
130
132
  dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='end'] {
131
133
  margin-inline: auto 0;
132
134
  border-start-end-radius: 0;
@@ -137,11 +139,68 @@ dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='start'] {
137
139
  border-start-start-radius: 0;
138
140
  border-end-start-radius: 0;
139
141
  }
142
+ /* How a styled dialog COMES and GOES (CC1 follow-up). Three states, one look each:
143
+ · at rest, open — in place, the scrim on;
144
+ · entering (`@starting-style`) and leaving (`data-iox-closing`, set by actions-core `closeDialogAnimated`
145
+ for the whole exit, and `:not([open])` for a browser that closes it natively) — the drawer off its edge,
146
+ any other placement faded 12px toward where it sits, the scrim clear.
147
+ Entering decelerates (ease-enter), leaving accelerates (ease-exit), both over dur-base — the look's motion
148
+ tokens, which the reduced-motion token block collapses to 0ms: under reduced motion it simply appears and
149
+ goes. `overlay`/`display` allow-discrete keep a natively closed dialog in the top layer for its exit. */
150
+ dialog[data-iox-dialog].iox-es {
151
+ --dialog-exit-x: 0px;
152
+ --dialog-exit-y: 12px;
153
+ --dialog-exit-opacity: 0;
154
+ transition:
155
+ translate var(--iox-dur-base) var(--iox-ease-enter),
156
+ opacity var(--iox-dur-base) var(--iox-ease-enter),
157
+ overlay var(--iox-dur-base) allow-discrete,
158
+ display var(--iox-dur-base) allow-discrete;
159
+ }
160
+ dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='top'] { --dialog-exit-y: -12px; }
161
+ dialog[data-iox-dialog].iox-es:is([data-iox-dialog-placement='end'], [data-iox-dialog-placement='start']) {
162
+ --dialog-exit-x: 100%;
163
+ --dialog-exit-y: 0px;
164
+ --dialog-exit-opacity: 1;
165
+ }
166
+ dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='start'],
167
+ [dir='rtl'] dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='end'] { --dialog-exit-x: -100%; }
168
+ [dir='rtl'] dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='start'] { --dialog-exit-x: 100%; }
169
+ dialog[data-iox-dialog].iox-es:is([data-iox-closing], :not([open])) {
170
+ translate: var(--dialog-exit-x) var(--dialog-exit-y);
171
+ opacity: var(--dialog-exit-opacity);
172
+ transition-timing-function: var(--iox-ease-exit);
173
+ }
174
+ @starting-style {
175
+ dialog[data-iox-dialog].iox-es[open] {
176
+ translate: var(--dialog-exit-x) var(--dialog-exit-y);
177
+ opacity: var(--dialog-exit-opacity);
178
+ }
179
+ }
180
+ dialog[data-iox-dialog].iox-es::backdrop {
181
+ transition:
182
+ opacity var(--iox-dur-base) var(--iox-ease-enter),
183
+ overlay var(--iox-dur-base) allow-discrete,
184
+ display var(--iox-dur-base) allow-discrete;
185
+ }
186
+ dialog[data-iox-dialog].iox-es:is([data-iox-closing], :not([open]))::backdrop {
187
+ opacity: 0;
188
+ transition-timing-function: var(--iox-ease-exit);
189
+ }
140
190
  @starting-style {
141
- dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='end'][open] { translate: 100% 0; }
142
- dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='start'][open] { translate: -100% 0; }
143
- [dir='rtl'] dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='end'][open] { translate: -100% 0; }
144
- [dir='rtl'] dialog[data-iox-dialog].iox-es[data-iox-dialog-placement='start'][open] { translate: 100% 0; }
191
+ dialog[data-iox-dialog].iox-es[open]::backdrop { opacity: 0; }
192
+ }
193
+ /* The drawer's pages follow it in, a beat apart (board 1) — a quarter of dur-fast per link, so they collapse
194
+ with the tokens under reduced motion. They leave with the sheet, not one by one. */
195
+ dialog[data-iox-dialog].iox-es[open]:not([data-iox-closing]) .iox-role-nav-drawer-links > * {
196
+ animation: iox-drawer-link-in var(--iox-dur-base) var(--iox-ease-enter) both;
197
+ }
198
+ dialog[data-iox-dialog].iox-es[open] .iox-role-nav-drawer-links > :nth-child(2) { animation-delay: calc(var(--iox-dur-fast) * 0.25); }
199
+ dialog[data-iox-dialog].iox-es[open] .iox-role-nav-drawer-links > :nth-child(3) { animation-delay: calc(var(--iox-dur-fast) * 0.5); }
200
+ dialog[data-iox-dialog].iox-es[open] .iox-role-nav-drawer-links > :nth-child(4) { animation-delay: calc(var(--iox-dur-fast) * 0.75); }
201
+ dialog[data-iox-dialog].iox-es[open] .iox-role-nav-drawer-links > :nth-child(n + 5) { animation-delay: var(--iox-dur-fast); }
202
+ @keyframes iox-drawer-link-in {
203
+ from { opacity: 0; translate: 0 8px; }
145
204
  }
146
205
 
147
206
  /* A navigation's link lists (board 1): no underline at rest — the current page's link is the one