@phis/ui 0.3.0 → 0.4.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.
Files changed (164) hide show
  1. package/components/cms/phi-cms-layout-renderer.js +1 -1
  2. package/components/controls/phi-collection-header-control.js +1 -1
  3. package/components/controls/phi-collection-view-control.js +2 -1
  4. package/components/controls/phi-drawer-control-adapter.d.ts +1 -1
  5. package/components/controls/phi-drawer-control-adapter.js +2 -2
  6. package/components/controls/phi-image-control.d.ts +6 -1
  7. package/components/controls/phi-image-control.js +23 -4
  8. package/components/controls/phi-lightbox-image-control-adapter.d.ts +2 -0
  9. package/components/controls/phi-lightbox-image-control-adapter.js +11 -0
  10. package/components/controls/phi-lightbox-image-control.d.ts +3 -2
  11. package/components/controls/phi-lightbox-image-control.js +7 -3
  12. package/components/controls/phi-modal-control-adapter.d.ts +1 -1
  13. package/components/controls/phi-modal-control-adapter.js +5 -2
  14. package/components/controls/phi-overlay-control-contract.d.ts +5 -0
  15. package/components/controls/phi-pagination-control-adapter.d.ts +2 -0
  16. package/components/controls/phi-pagination-control-adapter.js +15 -0
  17. package/components/controls/phi-pagination-control.d.ts +2 -1
  18. package/components/controls/phi-pagination-control.js +11 -7
  19. package/components/controls/phi-table-control.d.ts +6 -1
  20. package/components/controls/phi-table-control.js +17 -5
  21. package/components/controls/phi-timeline-control-adapter.d.ts +2 -0
  22. package/components/controls/phi-timeline-control-adapter.js +28 -0
  23. package/components/controls/phi-timeline-control.d.ts +3 -1
  24. package/components/controls/phi-timeline-control.js +8 -22
  25. package/components/controls/phi-tour-control.js +25 -7
  26. package/components/controls/phi-tour-viewport.d.ts +36 -0
  27. package/components/controls/phi-tour-viewport.js +86 -0
  28. package/components/controls/phi-zoom-image-overlay.d.ts +17 -0
  29. package/components/controls/phi-zoom-image-overlay.js +58 -0
  30. package/components/layouts/layout-definitions.d.ts +2 -20
  31. package/components/layouts/layout-definitions.js +13 -2
  32. package/components/layouts/plugins/carousel-layout-plugin.js +2 -1
  33. package/components/media/aspect-ratios.d.ts +14 -0
  34. package/components/media/aspect-ratios.js +13 -0
  35. package/components/motion/phi-sequence-run.d.ts +27 -0
  36. package/components/motion/phi-sequence-run.js +87 -0
  37. package/components/motion/phi-sequence-viewport.d.ts +16 -0
  38. package/components/motion/phi-sequence-viewport.js +97 -34
  39. package/components/overlays/phi-overlay-container-client.js +5 -2
  40. package/components/runtime/runtime-visual-anchors.d.ts +13 -2
  41. package/components/runtime/runtime-visual-anchors.js +49 -3
  42. package/components/shell/use-phi-shell-sticky-top.d.ts +5 -0
  43. package/components/shell/use-phi-shell-sticky-top.js +78 -0
  44. package/components/widgets/config/image-variant-options.d.ts +3 -5
  45. package/components/widgets/config/image-variant-options.js +11 -11
  46. package/components/widgets/config/sequence-settings.d.ts +75 -0
  47. package/components/widgets/config/sequence-settings.js +173 -0
  48. package/components/widgets/helpers/semantic-html-translation.d.ts +3 -1
  49. package/components/widgets/helpers/semantic-html-translation.js +8 -3
  50. package/components/widgets/label-sets/content-entries.d.ts +9 -0
  51. package/components/widgets/label-sets/content-entries.js +17 -0
  52. package/components/widgets/label-types/gallery.d.ts +2 -10
  53. package/components/widgets/label-types/sequence.d.ts +18 -0
  54. package/components/widgets/label-types/sequence.js +1 -0
  55. package/components/widgets/navigation-descriptor-resolver.server.js +6 -1
  56. package/components/widgets/shared/markdown-render.js +15 -5
  57. package/helpers/cms-layout-defaults.js +9 -2
  58. package/helpers/cms-region-keys.d.ts +1 -1
  59. package/helpers/motion.d.ts +7 -0
  60. package/helpers/motion.js +7 -0
  61. package/helpers/text-source.d.ts +19 -0
  62. package/helpers/text-source.js +20 -0
  63. package/next/config.mjs +1 -1
  64. package/package.json +1 -1
  65. package/plugins/runtime-modules/asset/data-providers.d.ts +4 -0
  66. package/plugins/runtime-modules/asset/data-providers.js +1 -0
  67. package/plugins/runtime-modules/asset/definition.d.ts +4 -0
  68. package/plugins/runtime-modules/asset/presets.d.ts +53 -1
  69. package/plugins/runtime-modules/asset/presets.js +16 -12
  70. package/plugins/runtime-modules/asset/tour.d.ts +44 -0
  71. package/plugins/runtime-modules/asset/tour.js +53 -0
  72. package/plugins/runtime-modules/auth/trees/phi-default-pub-confirm-page-tree.js +3 -0
  73. package/plugins/runtime-modules/auth/trees/phi-default-pub-login-page-tree.js +2 -0
  74. package/plugins/runtime-modules/auth/trees/phi-default-pub-registration-page-tree.js +3 -0
  75. package/plugins/runtime-modules/auth/trees/phi-default-pub-reset-password-page-tree.js +3 -0
  76. package/plugins/runtime-modules/builder/clients/inspector-config-field.js +42 -18
  77. package/plugins/runtime-modules/builder/clients/inspector-image-field.js +2 -2
  78. package/plugins/runtime-modules/builder/clients/widget-inspector.js +9 -0
  79. package/plugins/runtime-modules/builder/clients/workspace-scope-boundary.js +10 -0
  80. package/plugins/runtime-modules/builder/controller/workspace-controller.js +18 -3
  81. package/plugins/runtime-modules/builder/plugin-meta-labels.js +8 -0
  82. package/plugins/runtime-modules/builder/presets.js +5 -3
  83. package/plugins/runtime-modules/builder/tour.d.ts +316 -0
  84. package/plugins/runtime-modules/builder/tour.js +337 -0
  85. package/plugins/runtime-modules/builder/trees/phi-builder-inspector-area-overlay-tree.server.js +8 -1
  86. package/plugins/runtime-modules/builder/trees/phi-default-builder-area-preset-tree.js +4 -0
  87. package/plugins/runtime-modules/builder/widgets/structure-region/built-in.js +3 -2
  88. package/plugins/runtime-modules/core/widgets/card/server.js +4 -2
  89. package/plugins/runtime-modules/core/widgets/collection-view/client.js +10 -0
  90. package/plugins/runtime-modules/core/widgets/description/config.d.ts +28 -1
  91. package/plugins/runtime-modules/core/widgets/description/config.js +2 -0
  92. package/plugins/runtime-modules/core/widgets/description/server.js +10 -12
  93. package/plugins/runtime-modules/core/widgets/entry-detail/config.d.ts +2 -0
  94. package/plugins/runtime-modules/core/widgets/entry-detail/config.js +8 -1
  95. package/plugins/runtime-modules/core/widgets/entry-header/config.d.ts +3 -0
  96. package/plugins/runtime-modules/core/widgets/entry-header/config.js +8 -1
  97. package/plugins/runtime-modules/core/widgets/entry-list/client.d.ts +4 -1
  98. package/plugins/runtime-modules/core/widgets/entry-list/client.js +3 -2
  99. package/plugins/runtime-modules/core/widgets/entry-list/config.d.ts +392 -93
  100. package/plugins/runtime-modules/core/widgets/entry-list/config.js +21 -3
  101. package/plugins/runtime-modules/core/widgets/entry-list/server.js +4 -2
  102. package/plugins/runtime-modules/core/widgets/gallery/authoring.js +62 -10
  103. package/plugins/runtime-modules/core/widgets/gallery/client.d.ts +6 -1
  104. package/plugins/runtime-modules/core/widgets/gallery/client.js +7 -79
  105. package/plugins/runtime-modules/core/widgets/gallery/config.d.ts +337 -144
  106. package/plugins/runtime-modules/core/widgets/gallery/config.js +52 -113
  107. package/plugins/runtime-modules/core/widgets/gallery/defaults.d.ts +15 -0
  108. package/plugins/runtime-modules/core/widgets/gallery/defaults.js +20 -0
  109. package/plugins/runtime-modules/core/widgets/html/server.js +1 -1
  110. package/plugins/runtime-modules/core/widgets/image/client.js +1 -1
  111. package/plugins/runtime-modules/core/widgets/image/config.d.ts +29 -4
  112. package/plugins/runtime-modules/core/widgets/image/config.js +19 -2
  113. package/plugins/runtime-modules/core/widgets/image/editor.js +1 -1
  114. package/plugins/runtime-modules/core/widgets/markdown-toc/server.js +7 -2
  115. package/plugins/runtime-modules/core/widgets/quick-links/config.d.ts +3 -0
  116. package/plugins/runtime-modules/core/widgets/quick-links/config.js +2 -0
  117. package/plugins/runtime-modules/core/widgets/quick-links/plugin.js +4 -2
  118. package/plugins/runtime-modules/core/widgets/quick-links/server.d.ts +5 -1
  119. package/plugins/runtime-modules/core/widgets/quick-links/server.js +4 -2
  120. package/plugins/runtime-modules/core/widgets/result/config.d.ts +25 -1
  121. package/plugins/runtime-modules/core/widgets/simple-text/config.d.ts +28 -1
  122. package/plugins/runtime-modules/core/widgets/simple-text/config.js +2 -0
  123. package/plugins/runtime-modules/core/widgets/simple-text/plugin.js +1 -1
  124. package/plugins/runtime-modules/core/widgets/simple-text/server.d.ts +4 -1
  125. package/plugins/runtime-modules/core/widgets/simple-text/server.js +3 -3
  126. package/plugins/runtime-modules/core/widgets/table/client.js +1 -1
  127. package/plugins/runtime-modules/core/widgets/table/config.d.ts +80 -3
  128. package/plugins/runtime-modules/core/widgets/table/config.js +3 -0
  129. package/plugins/runtime-modules/core/widgets/tour-button/client.js +1 -1
  130. package/plugins/runtime-modules/phi-default-site-area-preset-tree.js +12 -4
  131. package/plugins/runtime-modules/public/trees/phi-default-pub-contact-page-tree.js +2 -0
  132. package/plugins/runtime-modules/public/trees/phi-default-pub-unsubscribe-page-tree.js +3 -0
  133. package/plugins/runtime-modules/public/trees/phi-default-pub-welcome-page-tree.js +2 -1
  134. package/plugins/runtime-modules/revisions/presets.d.ts +53 -2
  135. package/plugins/runtime-modules/revisions/presets.js +16 -12
  136. package/plugins/runtime-modules/revisions/tour.d.ts +43 -0
  137. package/plugins/runtime-modules/revisions/tour.js +51 -0
  138. package/plugins/runtime-modules/revisions/trees/phi-default-builder-revisions-page-tree.js +4 -0
  139. package/plugins/runtime-modules/theme/presets.d.ts +36 -4
  140. package/plugins/runtime-modules/theme/tour.d.ts +45 -11
  141. package/plugins/runtime-modules/theme/tour.js +39 -8
  142. package/plugins/runtime-modules/tour/controller/address.js +2 -2
  143. package/plugins/runtime-modules/tour/controller/client.js +43 -2
  144. package/plugins/runtime-modules/tour/controller/definition.js +2 -1
  145. package/server-helpers/cms-request.js +1 -0
  146. package/server-helpers/page-tour.d.ts +10 -3
  147. package/server-helpers/page-tour.js +25 -12
  148. package/server-helpers/translate.d.ts +9 -0
  149. package/server-helpers/translate.js +14 -0
  150. package/styles/controls.css +15 -1
  151. package/styles/root.css +20 -19
  152. package/types/cms-config.d.ts +3 -1
  153. package/types/cms-config.js +3 -3
  154. package/types/cms-plugins.d.ts +31 -4
  155. package/types/core-widget-placements.d.ts +5 -0
  156. package/types/table-widget.d.ts +2 -0
  157. package/types/tour-controller.d.ts +10 -0
  158. package/types/tour-controller.js +13 -0
  159. package/types/tours.d.ts +27 -25
  160. package/types/tours.js +9 -23
  161. package/types/visual-anchors.d.ts +15 -0
  162. package/types/visual-anchors.js +12 -0
  163. package/types.d.ts +2 -0
  164. package/types.js +4 -1
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Where a Tour step's target is brought into view: below whatever stays stuck at the top of the window.
3
+ *
4
+ * Ant Design's Tour knows neither. It scrolls only when the target is not wholly inside the window, so a
5
+ * target taller than the window is scrolled on every step and every scroll event -- to the middle, its
6
+ * top under the Header -- and a target under a sticky Header counts as seen and is not scrolled at all.
7
+ * So the owning Controller sets the window's scroll padding to the cover before the step is presented
8
+ * (design/TOURS.md, Target readiness), `PhiTourControl` hands Ant Design the alignment that fits the
9
+ * target, and once the step stands it brings in a target Ant Design took for seen. Every scroll then
10
+ * aims at the same place, and the one Ant Design repeats on every scroll event moves nothing.
11
+ *
12
+ * Not while the previous step is still shown: Ant Design scrolls back to a shown target that leaves the
13
+ * window, so a scroll begun before the new step is presented is undone by the old one.
14
+ *
15
+ * The cover is found from the target outwards, not by searching the document: the Shell's Header bands
16
+ * and a Page's sticky band are siblings of something the target stands in, so the bands are found among
17
+ * the earlier siblings of the target's ancestors that are stuck, sit at the top, and stand over the target.
18
+ */
19
+ /** Whether the viewer asked for less motion: the Tour then jumps rather than glides. */
20
+ function prefersPhiTourReducedMotion() {
21
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
22
+ }
23
+ export function resolvePhiTourScrollBehavior() {
24
+ return prefersPhiTourReducedMotion() ? "auto" : "smooth";
25
+ }
26
+ /** Air between the cover and the target, so a highlighted edge is not drawn against the Header. */
27
+ const PHI_TOUR_VIEWPORT_GAP = 8;
28
+ function isStuck(element) {
29
+ const { position } = getComputedStyle(element);
30
+ return position === "sticky" || position === "fixed";
31
+ }
32
+ /** How far down from the window's top the bands that stay put over `target` reach, in pixels. */
33
+ function readPhiTourTopCover(target) {
34
+ const viewHeight = window.innerHeight;
35
+ const targetRect = target.getBoundingClientRect();
36
+ let cover = 0;
37
+ for (let node = target; node && node !== document.body; node = node.parentElement) {
38
+ for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
39
+ if (!isStuck(sibling))
40
+ continue;
41
+ const rect = sibling.getBoundingClientRect();
42
+ const atTop = rect.height > 0 && rect.top < viewHeight / 2 && rect.height < viewHeight / 2;
43
+ const overTarget = rect.left < targetRect.right && rect.right > targetRect.left;
44
+ if (atTop && overTarget)
45
+ cover = Math.max(cover, rect.bottom);
46
+ }
47
+ }
48
+ return Math.max(0, Math.round(cover));
49
+ }
50
+ /**
51
+ * Centered where the target fits below the cover, top-aligned under it where it does not: a target
52
+ * taller than what is left of the window is read from its top.
53
+ */
54
+ export function resolvePhiTourScrollBlock(target, cover = readPhiTourTopCover(target)) {
55
+ const free = window.innerHeight - cover - PHI_TOUR_VIEWPORT_GAP * 2;
56
+ return target.getBoundingClientRect().height <= free ? "center" : "start";
57
+ }
58
+ /**
59
+ * Keeps the window's scroll padding at the cover over `target`, so every `scrollIntoView` -- Ant
60
+ * Design's included -- aligns below it. Returns the cover.
61
+ */
62
+ export function holdPhiTourViewport(target) {
63
+ const cover = readPhiTourTopCover(target);
64
+ document.documentElement.style.scrollPaddingTop = `${cover + PHI_TOUR_VIEWPORT_GAP}px`;
65
+ return cover;
66
+ }
67
+ /**
68
+ * Brings `target` into the part of the window nothing covers. A target already wholly in that part --
69
+ * or, taller than it, already standing at its top -- is left where it is. Returns whether it scrolled.
70
+ */
71
+ export function bringPhiTourTargetIntoView(target, behavior) {
72
+ const cover = holdPhiTourViewport(target);
73
+ const rect = target.getBoundingClientRect();
74
+ const block = resolvePhiTourScrollBlock(target, cover);
75
+ const inView = block === "center"
76
+ ? rect.top >= cover && rect.bottom <= window.innerHeight
77
+ : Math.abs(rect.top - cover - PHI_TOUR_VIEWPORT_GAP) <= 1;
78
+ if (inView)
79
+ return false;
80
+ target.scrollIntoView({ block, inline: "nearest", behavior });
81
+ return true;
82
+ }
83
+ /** The window's scroll padding as it was before a Tour set it. */
84
+ export function releasePhiTourViewport() {
85
+ document.documentElement.style.scrollPaddingTop = "";
86
+ }
@@ -0,0 +1,17 @@
1
+ export type PhiZoomImageOverlayProps = {
2
+ open: boolean;
3
+ src: string;
4
+ alt: string;
5
+ title?: string;
6
+ onClose: () => void;
7
+ };
8
+ /**
9
+ * A picture scaled to the viewport over the page, and nothing else: no toolbar, no zoom steps, no
10
+ * rotation -- that is the lightbox's (`PhiLightboxImageControl`). A click anywhere closes it, as does
11
+ * Escape, and the focus goes back to where it was.
12
+ *
13
+ * On `document.body`, at the layer a Modal stands on, for the reason `PhiFloatButtonPortal` gives: a
14
+ * picture's slot and Region open stacking contexts of their own, and an overlay left inside them would be
15
+ * painted under the Header. The server draws nothing; it opens only on a click.
16
+ */
17
+ export declare function PhiZoomImageOverlay({ open, src, alt, title, onClose }: PhiZoomImageOverlayProps): import("react").ReactPortal | null;
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useSyncExternalStore } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { usePhiConfig } from "../root/phi-config-provider";
6
+ const subscribeNever = () => () => { };
7
+ /** Ant Design's `zIndexPopupBase`, the layer its Modal stands on; the Phi token set does not carry it. */
8
+ const PHI_ZOOM_OVERLAY_Z_INDEX = 1000;
9
+ /**
10
+ * A picture scaled to the viewport over the page, and nothing else: no toolbar, no zoom steps, no
11
+ * rotation -- that is the lightbox's (`PhiLightboxImageControl`). A click anywhere closes it, as does
12
+ * Escape, and the focus goes back to where it was.
13
+ *
14
+ * On `document.body`, at the layer a Modal stands on, for the reason `PhiFloatButtonPortal` gives: a
15
+ * picture's slot and Region open stacking contexts of their own, and an overlay left inside them would be
16
+ * painted under the Header. The server draws nothing; it opens only on a click.
17
+ */
18
+ export function PhiZoomImageOverlay({ open, src, alt, title, onClose }) {
19
+ const { token } = usePhiConfig();
20
+ const mounted = useSyncExternalStore(subscribeNever, () => true, () => false);
21
+ const overlayRef = useRef(null);
22
+ useEffect(() => {
23
+ if (!open)
24
+ return undefined;
25
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
26
+ overlayRef.current?.focus();
27
+ const closeOnEscape = (event) => {
28
+ if (event.key === "Escape")
29
+ onClose();
30
+ };
31
+ document.addEventListener("keydown", closeOnEscape);
32
+ return () => {
33
+ document.removeEventListener("keydown", closeOnEscape);
34
+ previousFocus?.focus();
35
+ };
36
+ }, [onClose, open]);
37
+ if (!mounted || !open)
38
+ return null;
39
+ return createPortal(_jsx("div", { ref: overlayRef, role: "dialog", "aria-modal": "true", "aria-label": alt || title, tabIndex: -1,
40
+ /*
41
+ * The portal leaves the DOM, not the React tree: a click here would bubble on to the picture's box,
42
+ * which opens it again. It stops at the overlay.
43
+ */
44
+ onClick: (event) => {
45
+ event.stopPropagation();
46
+ onClose();
47
+ }, onKeyDown: (event) => event.stopPropagation(), style: {
48
+ position: "fixed",
49
+ inset: 0,
50
+ zIndex: PHI_ZOOM_OVERLAY_Z_INDEX,
51
+ display: "flex",
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ background: token.colorBgMask,
55
+ cursor: "zoom-out",
56
+ outline: "none",
57
+ }, children: _jsx("img", { alt: alt, src: src, title: title, style: { display: "block", width: "100vw", height: "100dvh", objectFit: "contain" } }) }), document.body);
58
+ }
@@ -429,15 +429,6 @@ export declare const PHI_CAROUSEL_LAYOUT_DEFINITION: {
429
429
  step: number;
430
430
  prefix: string;
431
431
  options?: undefined;
432
- } | {
433
- key: string;
434
- type: "string";
435
- label: string;
436
- min?: undefined;
437
- max?: undefined;
438
- options?: undefined;
439
- step?: undefined;
440
- prefix?: undefined;
441
432
  } | {
442
433
  key: string;
443
434
  type: "boolean";
@@ -492,7 +483,7 @@ export declare const PHI_GRID_LAYOUT_DEFINITION: {
492
483
  kind: "layout";
493
484
  pluginKey: string;
494
485
  typeKey: string;
495
- slotSizePolicy: "fill";
486
+ slotSizePolicy: "fill-inline";
496
487
  title: string;
497
488
  description: string;
498
489
  category: "structure";
@@ -1132,15 +1123,6 @@ export declare const PHI_CORE_LAYOUT_DEFINITIONS: readonly [{
1132
1123
  step: number;
1133
1124
  prefix: string;
1134
1125
  options?: undefined;
1135
- } | {
1136
- key: string;
1137
- type: "string";
1138
- label: string;
1139
- min?: undefined;
1140
- max?: undefined;
1141
- options?: undefined;
1142
- step?: undefined;
1143
- prefix?: undefined;
1144
1126
  } | {
1145
1127
  key: string;
1146
1128
  type: "boolean";
@@ -1194,7 +1176,7 @@ export declare const PHI_CORE_LAYOUT_DEFINITIONS: readonly [{
1194
1176
  kind: "layout";
1195
1177
  pluginKey: string;
1196
1178
  typeKey: string;
1197
- slotSizePolicy: "fill";
1179
+ slotSizePolicy: "fill-inline";
1198
1180
  title: string;
1199
1181
  description: string;
1200
1182
  category: "structure";
@@ -7,6 +7,7 @@ import { PHI_RENDERABLE_BLOCK_DEFAULT_ANCHOR } from "../../helpers/renderable-bl
7
7
  import { PHI_LAYOUT_PADDING_FIELDS } from "../../helpers/layout-padding-field";
8
8
  import { PHI_SIGNAL_VALUE_SCHEMAS } from "../../types/signals";
9
9
  import { PHI_CORE_RUNTIME_DATA_PROVIDER_KEYS } from "../../constants/core-data-provider-keys";
10
+ import { PHI_SPACING_SCALE_KEYS } from "../widgets/config/spacing-options";
10
11
  const PHI_COLLAPSIBLE_SLOT_OPTIONS = PHI_CMS_COLLAPSIBLE_LAYOUT_SLOTS.map((slot) => ({
11
12
  value: slot.key,
12
13
  label: slot.label,
@@ -358,7 +359,12 @@ export const PHI_CAROUSEL_LAYOUT_DEFINITION = {
358
359
  label: "Easing",
359
360
  options: [...PHI_MOTION_EASING_FIELD_OPTIONS],
360
361
  },
361
- { key: "slotGap", type: "string", label: "Slot Gap" },
362
+ {
363
+ key: "slotGap",
364
+ type: "choice",
365
+ label: "Slot Gap",
366
+ options: PHI_SPACING_SCALE_KEYS.map((key) => ({ value: key, label: key })),
367
+ },
362
368
  {
363
369
  key: "controls",
364
370
  type: "choice",
@@ -409,7 +415,12 @@ export const PHI_GRID_LAYOUT_DEFINITION = {
409
415
  kind: "layout",
410
416
  pluginKey: resolvePhiCmsLayoutPluginKey("grid"),
411
417
  typeKey: "grid",
412
- slotSizePolicy: "fill",
418
+ /*
419
+ * As wide as its slot, as tall as its rows. A Grid is content in flow: filling the block axis as well
420
+ * let a vertical stack share its height out among the Grids in it, and each one, held to its share,
421
+ * spilled its rows over the next section.
422
+ */
423
+ slotSizePolicy: "fill-inline",
413
424
  title: "Grid",
414
425
  description: "Slots in rows, as many to a row as the Grid's own width allows.",
415
426
  category: "structure",
@@ -6,11 +6,12 @@ import { PhiCarouselLayout } from "../phi-carousel-layout";
6
6
  import { definePhiLayoutRenderers } from "../layout-plugin-renderers";
7
7
  import { resolvePhiLayoutSlotMeta } from "../layout-slot-meta";
8
8
  import { PHI_CAROUSEL_LAYOUT_DEFINITION } from "../layout-definitions";
9
+ import { resolvePhiSpacingScaleValue } from "../../widgets/config/spacing-options";
9
10
  function resolvePhiCarouselSlotKeys() {
10
11
  return PHI_CMS_CAROUSEL_LAYOUT_SLOTS.map((slot) => slot.key);
11
12
  }
12
13
  export const PHI_CAROUSEL_LAYOUT_PLUGIN = {
13
14
  ...PHI_CAROUSEL_LAYOUT_DEFINITION,
14
15
  parseConfig: parsePhiCmsCarouselLayoutConfig,
15
- ...definePhiLayoutRenderers(({ node, config, renderSequentialSlotChildren }, renderMode) => (_jsx(PhiCarouselLayout, { blockId: node.id, layoutKind: "carousel", slots: renderSequentialSlotChildren(node), slotKeys: resolvePhiCarouselSlotKeys(), slotMeta: resolvePhiLayoutSlotMeta(node, PHI_CMS_CAROUSEL_LAYOUT_SLOTS), renderMode: renderMode, defaultActiveSlotKey: config.defaultActiveSlotKey, visibleSlots: config.visibleSlots, windowAnchor: config.windowAnchor, transition: config.transition, transitionDurationMs: config.transitionDurationMs, transitionEasing: config.transitionEasing, slotGap: config.slotGap, controls: config.controls, loop: config.loop, autoplayMs: config.autoplayMs, lookahead: config.lookahead, slotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), editSlotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), zIndex: config.zIndex, surface: config.surface, padding: config.padding, paddingTop: config.paddingTop, paddingRight: config.paddingRight, paddingBottom: config.paddingBottom, paddingLeft: config.paddingLeft, labelEnd: config.labelEnd, size: config.size, minSize: config.minSize, maxSize: config.maxSize, collapsedSizeHint: config.collapsedSizeHint }, `layout-${node.id}`))),
16
+ ...definePhiLayoutRenderers(({ node, config, renderSequentialSlotChildren }, renderMode) => (_jsx(PhiCarouselLayout, { blockId: node.id, layoutKind: "carousel", slots: renderSequentialSlotChildren(node), slotKeys: resolvePhiCarouselSlotKeys(), slotMeta: resolvePhiLayoutSlotMeta(node, PHI_CMS_CAROUSEL_LAYOUT_SLOTS), renderMode: renderMode, defaultActiveSlotKey: config.defaultActiveSlotKey, visibleSlots: config.visibleSlots, windowAnchor: config.windowAnchor, transition: config.transition, transitionDurationMs: config.transitionDurationMs, transitionEasing: config.transitionEasing, slotGap: resolvePhiSpacingScaleValue(config.slotGap ?? "none"), controls: config.controls, loop: config.loop, autoplayMs: config.autoplayMs, lookahead: config.lookahead, slotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), editSlotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), zIndex: config.zIndex, surface: config.surface, padding: config.padding, paddingTop: config.paddingTop, paddingRight: config.paddingRight, paddingBottom: config.paddingBottom, paddingLeft: config.paddingLeft, labelEnd: config.labelEnd, size: config.size, minSize: config.minSize, maxSize: config.maxSize, collapsedSizeHint: config.collapsedSizeHint }, `layout-${node.id}`))),
16
17
  };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The proportions a frame for pictures is offered in, stored as `width:height`.
3
+ *
4
+ * A closed list rather than a text box: a ratio somebody types is a ratio nobody checks, and the few a
5
+ * page actually needs are the ones a camera, a screen and a print already use.
6
+ */
7
+ export declare const PHI_MEDIA_ASPECT_RATIOS: readonly ["16:9", "3:2", "4:3", "1:1", "3:4", "2:3", "9:16", "21:9"];
8
+ export type PhiMediaAspectRatio = (typeof PHI_MEDIA_ASPECT_RATIOS)[number];
9
+ export declare const PHI_MEDIA_ASPECT_RATIO_OPTIONS: {
10
+ value: "16:9" | "3:2" | "4:3" | "1:1" | "3:4" | "2:3" | "9:16" | "21:9";
11
+ label: "16:9" | "3:2" | "4:3" | "1:1" | "3:4" | "2:3" | "9:16" | "21:9";
12
+ }[];
13
+ /** The ratio as CSS `aspect-ratio` writes it. */
14
+ export declare function resolvePhiMediaAspectRatioCss(ratio: PhiMediaAspectRatio): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The proportions a frame for pictures is offered in, stored as `width:height`.
3
+ *
4
+ * A closed list rather than a text box: a ratio somebody types is a ratio nobody checks, and the few a
5
+ * page actually needs are the ones a camera, a screen and a print already use.
6
+ */
7
+ export const PHI_MEDIA_ASPECT_RATIOS = ["16:9", "3:2", "4:3", "1:1", "3:4", "2:3", "9:16", "21:9"];
8
+ export const PHI_MEDIA_ASPECT_RATIO_OPTIONS = PHI_MEDIA_ASPECT_RATIOS.map((ratio) => ({ value: ratio, label: ratio }));
9
+ /** The ratio as CSS `aspect-ratio` writes it. */
10
+ export function resolvePhiMediaAspectRatioCss(ratio) {
11
+ const [width, height] = ratio.split(":");
12
+ return `${width} / ${height}`;
13
+ }
@@ -0,0 +1,27 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type PhiSequenceSettings } from "../widgets/config/sequence-settings";
3
+ import type { PhiSequenceControlLabels } from "../widgets/label-types/sequence";
4
+ /** How many items stand in the window: never more than there are, never fewer than one. */
5
+ export declare function resolvePhiSequenceRunSpan(visibleCount: number | undefined, itemCount: number): number;
6
+ /**
7
+ * A run of items shown a few at a time, and the two things the browser will not do for us.
8
+ *
9
+ * The first is knowing which item is next. Native lazy loading decides from an element's distance to
10
+ * the viewport, so on a fade -- where every item sits in the same place -- it loads all of them at once,
11
+ * and on a slide it loads the next one as it arrives, during the transition. So the window says what
12
+ * loads: what is mounted is wanted, and an item that loads anything should load it eagerly.
13
+ *
14
+ * The second is stopping: `usePhiSequenceAutoplay` holds every way to decline movement nobody asked
15
+ * for, and the Pause button is the one a finger has, since a finger neither hovers nor tabs. Where
16
+ * the window stops -- the last window rather than the last multiple of the window -- is the Carousel
17
+ * Layout's list of stops (`phi-carousel-pages.ts`), read by the arrows, the dots and the timer alike.
18
+ *
19
+ * What the items are is the caller's: the Gallery's pictures, the Entries list's cards.
20
+ */
21
+ export declare function PhiSequenceRun({ items, settings, labels, itemStyle, }: {
22
+ /** Keyed by the caller; the run neither wraps nor rekeys them. */
23
+ items: readonly ReactNode[];
24
+ settings: PhiSequenceSettings | undefined;
25
+ labels: PhiSequenceControlLabels;
26
+ itemStyle?: CSSProperties;
27
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { usePhiConfig } from "../root/phi-config-provider";
5
+ import { PhiButtonControl } from "../controls/phi-button-control";
6
+ import { resolvePhiCarouselCurrentPage, resolvePhiCarouselPageStarts, resolvePhiCarouselStep, } from "../layouts/phi-carousel-pages";
7
+ import { PhiIcon } from "../shell/phi-icon";
8
+ import { resolvePhiSpacingScaleValue } from "../widgets/config/spacing-options";
9
+ import { PHI_SEQUENCE_SETTINGS_DEFAULTS, } from "../widgets/config/sequence-settings";
10
+ import { PhiSequenceViewport, resolvePhiSequenceWindowStart } from "./phi-sequence-viewport";
11
+ import { usePhiSequenceAutoplay } from "./use-phi-sequence-autoplay";
12
+ import { usePhiSequenceGestures } from "./use-phi-sequence-gestures";
13
+ /** The dots' label, `%1` the first and `%2` the last item a page shows. */
14
+ function formatPageLabel(template, from, to) {
15
+ return template.replace("%1", String(from)).replace("%2", String(to));
16
+ }
17
+ /** How many items stand in the window: never more than there are, never fewer than one. */
18
+ export function resolvePhiSequenceRunSpan(visibleCount, itemCount) {
19
+ return Math.min(visibleCount ?? PHI_SEQUENCE_SETTINGS_DEFAULTS.visibleCount, Math.max(itemCount, 1));
20
+ }
21
+ /**
22
+ * A run of items shown a few at a time, and the two things the browser will not do for us.
23
+ *
24
+ * The first is knowing which item is next. Native lazy loading decides from an element's distance to
25
+ * the viewport, so on a fade -- where every item sits in the same place -- it loads all of them at once,
26
+ * and on a slide it loads the next one as it arrives, during the transition. So the window says what
27
+ * loads: what is mounted is wanted, and an item that loads anything should load it eagerly.
28
+ *
29
+ * The second is stopping: `usePhiSequenceAutoplay` holds every way to decline movement nobody asked
30
+ * for, and the Pause button is the one a finger has, since a finger neither hovers nor tabs. Where
31
+ * the window stops -- the last window rather than the last multiple of the window -- is the Carousel
32
+ * Layout's list of stops (`phi-carousel-pages.ts`), read by the arrows, the dots and the timer alike.
33
+ *
34
+ * What the items are is the caller's: the Gallery's pictures, the Entries list's cards.
35
+ */
36
+ export function PhiSequenceRun({ items, settings, labels, itemStyle, }) {
37
+ const { token } = usePhiConfig();
38
+ const defaults = PHI_SEQUENCE_SETTINGS_DEFAULTS;
39
+ const loop = settings?.loop ?? defaults.loop;
40
+ const controls = settings?.controls ?? defaults.controls;
41
+ const autoplayMs = settings?.autoplayMs ?? defaults.autoplayMs;
42
+ const itemCount = items.length;
43
+ const [activeIndex, setActiveIndex] = useState(0);
44
+ const span = resolvePhiSequenceRunSpan(settings?.visibleCount, itemCount);
45
+ const pageStarts = useMemo(() => resolvePhiCarouselPageStarts(itemCount, span), [itemCount, span]);
46
+ const lastStart = pageStarts[pageStarts.length - 1] ?? 0;
47
+ const step = useCallback((direction) => {
48
+ setActiveIndex((current) => resolvePhiCarouselStep(pageStarts, current, direction, loop));
49
+ }, [loop, pageStarts]);
50
+ const advance = useCallback(() => step(1), [step]);
51
+ const autoplay = usePhiSequenceAutoplay({ intervalMs: autoplayMs, active: itemCount > span, advance });
52
+ // A step somebody took: the next automatic one is a whole interval away again.
53
+ const stepByHand = useCallback((direction) => {
54
+ step(direction);
55
+ autoplay.restart();
56
+ }, [autoplay, step]);
57
+ const gestures = usePhiSequenceGestures({ step: stepByHand });
58
+ if (itemCount === 0) {
59
+ return null;
60
+ }
61
+ const windowStart = resolvePhiSequenceWindowStart({
62
+ activeIndex,
63
+ visibleCount: span,
64
+ itemCount,
65
+ anchor: "start",
66
+ });
67
+ const pageCount = pageStarts.length;
68
+ const currentPage = resolvePhiCarouselCurrentPage(pageStarts, windowStart);
69
+ const atStart = windowStart <= 0;
70
+ const atEnd = windowStart >= lastStart;
71
+ // Two disabled arrows over a single item say nothing; the dots already know to stay away.
72
+ const showArrows = (controls === "arrows" || controls === "both") && itemCount > span;
73
+ const showDots = (controls === "dots" || controls === "both") && pageCount > 1;
74
+ return (_jsxs("div", { role: "region", "aria-roledescription": "carousel", "aria-label": labels.region, style: { display: "flex", flexDirection: "column", gap: token.marginXS, width: "100%", minWidth: 0, ...gestures.style }, ...autoplay.holdHandlers, ...gestures.handlers, children: [_jsxs("div", { style: { position: "relative", minWidth: 0 }, "aria-live": autoplay.running ? "off" : "polite", children: [_jsx(PhiSequenceViewport, { items: [...items], activeIndex: activeIndex, visibleCount: span, anchor: "start", transition: settings?.transition ?? defaults.transition, ...(settings?.durationMs === undefined ? {} : { durationMs: settings.durationMs }), ...(settings?.easing === undefined ? {} : { easing: settings.easing }), lookahead: settings?.lookahead ?? defaults.lookahead, mountPolicy: settings?.mountPolicy ?? defaults.mountPolicy, gap: resolvePhiSpacingScaleValue(settings?.gap ?? defaults.gap), itemStyle: itemStyle }), showArrows ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { position: "absolute", insetBlockStart: "50%", insetInlineStart: token.marginXS, transform: "translateY(-50%)" }, children: _jsx(PhiButtonControl, { icon: _jsx(PhiIcon, { name: "left", size: "inherit" }), ariaLabel: labels.previous, shape: "circle", disabled: atStart && !loop, onClick: () => stepByHand(-1) }) }), _jsx("div", { style: { position: "absolute", insetBlockStart: "50%", insetInlineEnd: token.marginXS, transform: "translateY(-50%)" }, children: _jsx(PhiButtonControl, { icon: _jsx(PhiIcon, { name: "right", size: "inherit" }), ariaLabel: labels.next, shape: "circle", disabled: atEnd && !loop, onClick: () => stepByHand(1) }) })] })) : null, autoplay.offered ? (_jsx("div", { style: { position: "absolute", insetBlockEnd: token.marginXS, insetInlineEnd: token.marginXS }, children: _jsx(PhiButtonControl, { icon: _jsx(PhiIcon, { name: autoplay.stopped ? "play-circle" : "pause-circle", size: "inherit" }), ariaLabel: autoplay.stopped ? labels.play : labels.pause, ariaPressed: autoplay.stopped, shape: "circle", onClick: autoplay.toggleStopped }) })) : null] }), showDots ? (_jsx("div", { role: "group", "aria-label": labels.pages, style: { display: "flex", justifyContent: "center", gap: token.marginXXS }, children: Array.from({ length: pageCount }, (_, page) => (_jsx("button", { type: "button", "aria-label": formatPageLabel(labels.page, (pageStarts[page] ?? 0) + 1, Math.min((pageStarts[page] ?? 0) + span, itemCount)), "aria-current": page === currentPage || undefined, onClick: () => {
75
+ setActiveIndex(pageStarts[page] ?? 0);
76
+ autoplay.restart();
77
+ }, style: {
78
+ width: page === currentPage ? token.controlHeightXS : token.marginXS,
79
+ height: token.marginXS,
80
+ padding: 0,
81
+ border: "none",
82
+ cursor: "pointer",
83
+ borderRadius: token.borderRadiusSM,
84
+ background: page === currentPage ? token.colorPrimary : token.colorFill,
85
+ transition: `width ${token.motionDurationMid}, background ${token.motionDurationMid}`,
86
+ } }, page))) })) : null] }));
87
+ }
@@ -1,6 +1,22 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
2
  import { type PhiMotionEasing, type PhiSequenceAnchor, type PhiSequenceTransition } from "../../helpers/motion";
3
3
  import { type PhiCmsMountPolicy } from "../../types/cms-mount-policy";
4
+ /**
5
+ * A window onto a sequence, and the movement from one window to the next.
6
+ *
7
+ * This is the piece a Stack, a Carousel and an image gallery have in common, and it is deliberately
8
+ * the thinnest of the three layers: it takes already-rendered children, an index and a shape, and it
9
+ * knows nothing about slots, signals, the Builder or where the pictures came from. Whoever owns the
10
+ * index -- a signal channel, a pair of arrows, a timer -- owns it outside.
11
+ *
12
+ * Two geometries, chosen by the transition, because the two ways of showing a sequence are genuinely
13
+ * different acts. `slide` lays the items out side by side on a track and moves the track: the
14
+ * neighbours exist next to the window and arrive from the direction they were in. `fade`, `diagonal`
15
+ * and `none` keep the window where it is and exchange what stands in its places -- the Stack's way.
16
+ * A fade crosses the leaving items over into the coming ones; a diagonal leaves the old ones standing
17
+ * and slides each new one over its place from the corner it comes from, below and after it going
18
+ * forward, above and before it going back. Nothing travels sideways.
19
+ */
4
20
  export type PhiSequenceViewportProps = {
5
21
  items: readonly ReactNode[];
6
22
  /**
@@ -1,26 +1,8 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useState } from "react";
4
- import { clampPhiSequenceTransitionMs, } from "../../helpers/motion";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { clampPhiSequenceTransitionMs, PHI_SEQUENCE_DEFAULT_DURATION_MS, PHI_SEQUENCE_DEFAULT_EASING, } from "../../helpers/motion";
5
5
  import { shouldPhiCmsContentStayMounted, } from "../../types/cms-mount-policy";
6
- /**
7
- * A window onto a sequence, and the movement from one window to the next.
8
- *
9
- * This is the piece a Stack, a Carousel and an image gallery have in common, and it is deliberately
10
- * the thinnest of the three layers: it takes already-rendered children, an index and a shape, and it
11
- * knows nothing about slots, signals, the Builder or where the pictures came from. Whoever owns the
12
- * index -- a signal channel, a pair of arrows, a timer -- owns it outside.
13
- *
14
- * Two geometries, chosen by the transition, because the two ways of showing a sequence are genuinely
15
- * different acts. `slide` and `diagonal` lay the items out side by side on a track and move the track:
16
- * the neighbours exist next to the window and arrive from the direction they were in. `fade` and
17
- * `none` put the window's items in flow and cross-fade the ones that are leaving over the top, which
18
- * is what the Stack has always done -- the items do not travel, they exchange places.
19
- */
20
- /** How far a diagonal leaves the horizontal, as a share of the travel it is already making. */
21
- const DIAGONAL_RISE = 0.35;
22
- /** What a caller who says nothing gets: long enough to read as movement, short enough to ignore. */
23
- const DEFAULT_SEQUENCE_DURATION_MS = 320;
24
6
  function toCssLength(value) {
25
7
  if (value === undefined)
26
8
  return "0px";
@@ -41,7 +23,7 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
41
23
  // whole number is a caller's mistake, and the type says so rather than this line rescuing it.
42
24
  const span = Math.min(visibleCount, Math.max(itemCount, 1));
43
25
  const start = resolvePhiSequenceWindowStart({ activeIndex, visibleCount: span, itemCount, anchor });
44
- const onTrack = transition === "slide" || transition === "diagonal";
26
+ const onTrack = transition === "slide";
45
27
  /*
46
28
  * Which items have ever been inside the window, which is what `lazy-keep` keeps.
47
29
  *
@@ -91,9 +73,9 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
91
73
  const resolvedDurationMs = reducedMotion || transition === "none"
92
74
  ? 0
93
75
  : durationMs === undefined
94
- ? DEFAULT_SEQUENCE_DURATION_MS
76
+ ? PHI_SEQUENCE_DEFAULT_DURATION_MS
95
77
  : clampPhiSequenceTransitionMs(durationMs);
96
- const resolvedEasing = easing ?? "ease-in-out";
78
+ const resolvedEasing = easing ?? PHI_SEQUENCE_DEFAULT_EASING;
97
79
  const gapLength = toCssLength(gap);
98
80
  const slice = span === 1
99
81
  ? "100%"
@@ -112,9 +94,70 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
112
94
  mountedIndices.add(index);
113
95
  }
114
96
  }
115
- const trackTransform = onTrack
116
- ? `translate3d(calc(-1 * ${start} * ${step}), ${transition === "diagonal" ? `calc(-1 * ${start} * ${step} * ${DIAGONAL_RISE})` : "0px"}, 0)`
117
- : undefined;
97
+ const trackTransform = onTrack ? `translate3d(calc(-1 * ${start} * ${step}), 0px, 0)` : undefined;
98
+ /*
99
+ * The exchange between two windows off the track, played after the new window is laid out and before
100
+ * it is painted, so the first frame already starts where the movement does.
101
+ *
102
+ * At the tail the last window overlaps the one before it -- five pictures three at a time go one to
103
+ * three, then three to five -- so an item can be in both windows at another place. It cannot leave
104
+ * where it was and arrive where it goes as one element, and moving it at once read as the one picture
105
+ * that changed without a transition. So it arrives at its new place like any other, and a copy of it
106
+ * stands in its old place until the leaving window is let go.
107
+ */
108
+ const leavingStart = onTrack ? null : fadeState.outgoingStart;
109
+ const movedIndices = leavingStart === null
110
+ ? []
111
+ : Array.from({ length: span }, (_, offset) => leavingStart + offset)
112
+ // In the window it left and in the one it enters: a new start puts it at another place.
113
+ .filter((index) => index >= start && index < start + span);
114
+ const itemElements = useRef([]);
115
+ const trackElement = useRef(null);
116
+ const previousStart = useRef(start);
117
+ const runningAnimations = useRef([]);
118
+ useLayoutEffect(() => {
119
+ const from = previousStart.current;
120
+ previousStart.current = start;
121
+ if (from === start || onTrack || resolvedDurationMs === 0)
122
+ return;
123
+ /*
124
+ * A step taken while the last one is still moving -- a click on top of autoplay, an interval as
125
+ * short as the movement -- starts from a window that has arrived. Left running, the half-arrived
126
+ * items became the leaving ones over nothing, because what stood under them was let go.
127
+ */
128
+ for (const animation of runningAnimations.current)
129
+ animation.finish();
130
+ runningAnimations.current = [];
131
+ const timing = { duration: resolvedDurationMs, easing: resolvedEasing };
132
+ const windowIndices = Array.from({ length: span }, (_, offset) => start + offset);
133
+ const moved = windowIndices.filter((index) => index >= from && index < from + span);
134
+ const copyOf = (index) => trackElement.current?.querySelector(`[data-phi-sequence-leaving="${index}"]`);
135
+ if (transition === "fade") {
136
+ for (const index of moved) {
137
+ // Not held at the end: the item's own opacity is 1 already, and a held animation would outlast
138
+ // the next fade that wants to take it away.
139
+ const arriving = itemElements.current[index]?.animate?.([{ opacity: 0 }, { opacity: 1 }], timing);
140
+ // The copy is held at nothing until it goes, which is when the leaving window is let go.
141
+ const leaving = copyOf(index)?.animate?.([{ opacity: 1 }, { opacity: 0 }], { ...timing, fill: "forwards" });
142
+ runningAnimations.current.push(...[arriving, leaving].filter((animation) => animation !== undefined));
143
+ }
144
+ return;
145
+ }
146
+ if (transition === "diagonal") {
147
+ /*
148
+ * A whole place's width and height, as a percentage of the item itself: every new item starts one
149
+ * box off its place, so each comes in from outside the window's corner, whatever its proportion.
150
+ */
151
+ const offset = start > from ? "100%" : "-100%";
152
+ const animations = windowIndices.flatMap((index) => {
153
+ const animation = itemElements.current[index]?.animate?.([{ translate: `${offset} ${offset}` }, { translate: "0 0" }], timing);
154
+ return animation ? [animation] : [];
155
+ });
156
+ runningAnimations.current = animations;
157
+ // Nothing fades here, so no transition ends to say so: the window is let go when the last item is in.
158
+ void Promise.all(animations.map((animation) => animation.finished)).then(() => setFadeState((current) => (current.start === start ? { ...current, outgoingStart: null } : current)), () => undefined);
159
+ }
160
+ }, [start, span, onTrack, transition, resolvedDurationMs, resolvedEasing]);
118
161
  const renderedItems = items.map((item, index) => {
119
162
  const visible = index >= start && index < start + span;
120
163
  const outgoing = !visible && fadeState.outgoingStart !== null &&
@@ -129,7 +172,8 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
129
172
  ? { flex: `0 0 ${slice}`, minWidth: 0 }
130
173
  : {
131
174
  ...(visible
132
- ? { flex: `0 0 ${slice}`, minWidth: 0 }
175
+ // Above what leaves, so a diagonal's new items cover the old ones they slide over.
176
+ ? { flex: `0 0 ${slice}`, minWidth: 0, position: "relative", zIndex: 1 }
133
177
  : {
134
178
  position: "absolute",
135
179
  insetBlockStart: 0,
@@ -139,17 +183,26 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
139
183
  : 0,
140
184
  width: slice,
141
185
  }),
142
- opacity: visible ? 1 : 0,
143
- transition: resolvedDurationMs > 0
186
+ // A diagonal's leaving items stand until the new ones cover them; a fade's go out.
187
+ opacity: visible || (outgoing && transition === "diagonal" && resolvedDurationMs > 0) ? 1 : 0,
188
+ transition: resolvedDurationMs > 0 && transition === "fade"
144
189
  ? `opacity ${resolvedDurationMs}ms ${resolvedEasing}`
145
190
  : undefined,
146
191
  };
192
+ const ref = (element) => {
193
+ itemElements.current[index] = element;
194
+ };
147
195
  if (!mounted && !visible && !outgoing) {
148
- return _jsx("div", { "aria-hidden": true, style: { ...box, pointerEvents: "none" } }, index);
196
+ return _jsx("div", { ref: ref, "aria-hidden": true, style: { ...box, pointerEvents: "none" } }, index);
149
197
  }
150
- return (_jsx("div", { "data-phi-sequence-item-state": visible ? "visible" : outgoing ? "outgoing" : "waiting", inert: !visible, "aria-hidden": !visible || undefined, style: { ...box, ...itemStyle }, children: mounted ? (renderItem ? renderItem(item, { index, visible }) : item) : null }, index));
198
+ return (_jsx("div", { ref: ref, "data-phi-sequence-item-state": visible ? "visible" : outgoing ? "outgoing" : "waiting", inert: !visible, "aria-hidden": !visible || undefined, style: { ...box, ...itemStyle }, children: mounted ? (renderItem ? renderItem(item, { index, visible }) : item) : null }, index));
151
199
  });
152
- return (_jsx("div", { className: className, "data-phi-sequence-transition": transition, style: { position: "relative", overflow: "hidden", minWidth: 0, ...style }, children: _jsx("div", { style: {
200
+ return (_jsx("div", { className: className, "data-phi-sequence-transition": transition,
201
+ /*
202
+ * Its own stacking context: the items it raises over the leaving ones are raised inside it, never
203
+ * over the arrows and buttons a caller lays over the viewport.
204
+ */
205
+ style: { position: "relative", overflow: "hidden", isolation: "isolate", minWidth: 0, ...style }, children: _jsxs("div", { ref: trackElement, style: {
153
206
  position: "relative",
154
207
  display: "flex",
155
208
  alignItems: "stretch",
@@ -177,5 +230,15 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
177
230
  : {}),
178
231
  }, onTransitionEnd: fadeState.outgoingStart === null
179
232
  ? undefined
180
- : () => setFadeState((current) => ({ ...current, outgoingStart: null })), children: renderedItems }) }));
233
+ : () => setFadeState((current) => ({ ...current, outgoingStart: null })), children: [renderedItems, leavingStart === null ? null : movedIndices.map((index) => (_jsx("div", { "data-phi-sequence-leaving": index, "aria-hidden": true, inert: true, style: {
234
+ position: "absolute",
235
+ insetBlockStart: 0,
236
+ insetBlockEnd: 0,
237
+ insetInlineStart: `calc(${index - leavingStart} * ${step})`,
238
+ width: slice,
239
+ // Under the window's own items, which arrive over it.
240
+ zIndex: 0,
241
+ pointerEvents: "none",
242
+ ...itemStyle,
243
+ }, children: items[index] }, `leaving-${index}`)))] }) }));
181
244
  }