@phis/ui 0.3.0 → 0.4.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/components/cms/phi-cms-layout-renderer.js +1 -1
- package/components/controls/phi-collection-header-control.js +1 -1
- package/components/controls/phi-collection-view-control.js +2 -1
- package/components/controls/phi-drawer-control-adapter.d.ts +1 -1
- package/components/controls/phi-drawer-control-adapter.js +2 -2
- package/components/controls/phi-image-control.d.ts +6 -1
- package/components/controls/phi-image-control.js +23 -4
- package/components/controls/phi-lightbox-image-control-adapter.d.ts +2 -0
- package/components/controls/phi-lightbox-image-control-adapter.js +11 -0
- package/components/controls/phi-lightbox-image-control.d.ts +3 -2
- package/components/controls/phi-lightbox-image-control.js +7 -3
- package/components/controls/phi-modal-control-adapter.d.ts +1 -1
- package/components/controls/phi-modal-control-adapter.js +5 -2
- package/components/controls/phi-overlay-control-contract.d.ts +5 -0
- package/components/controls/phi-pagination-control-adapter.d.ts +2 -0
- package/components/controls/phi-pagination-control-adapter.js +15 -0
- package/components/controls/phi-pagination-control.d.ts +2 -1
- package/components/controls/phi-pagination-control.js +11 -7
- package/components/controls/phi-table-control.js +1 -1
- package/components/controls/phi-timeline-control-adapter.d.ts +2 -0
- package/components/controls/phi-timeline-control-adapter.js +28 -0
- package/components/controls/phi-timeline-control.d.ts +3 -1
- package/components/controls/phi-timeline-control.js +8 -22
- package/components/controls/phi-tour-control.js +25 -7
- package/components/controls/phi-tour-viewport.d.ts +36 -0
- package/components/controls/phi-tour-viewport.js +86 -0
- package/components/controls/phi-zoom-image-overlay.d.ts +17 -0
- package/components/controls/phi-zoom-image-overlay.js +58 -0
- package/components/layouts/layout-definitions.d.ts +0 -18
- package/components/layouts/layout-definitions.js +7 -1
- package/components/layouts/plugins/carousel-layout-plugin.js +2 -1
- package/components/media/aspect-ratios.d.ts +14 -0
- package/components/media/aspect-ratios.js +13 -0
- package/components/motion/phi-sequence-run.d.ts +27 -0
- package/components/motion/phi-sequence-run.js +87 -0
- package/components/motion/phi-sequence-viewport.d.ts +16 -0
- package/components/motion/phi-sequence-viewport.js +97 -34
- package/components/overlays/phi-overlay-container-client.js +5 -2
- package/components/runtime/runtime-visual-anchors.d.ts +13 -2
- package/components/runtime/runtime-visual-anchors.js +49 -3
- package/components/widgets/config/image-variant-options.d.ts +3 -5
- package/components/widgets/config/image-variant-options.js +11 -11
- package/components/widgets/config/sequence-settings.d.ts +75 -0
- package/components/widgets/config/sequence-settings.js +173 -0
- package/components/widgets/label-sets/content-entries.d.ts +9 -0
- package/components/widgets/label-sets/content-entries.js +17 -0
- package/components/widgets/label-types/gallery.d.ts +2 -10
- package/components/widgets/label-types/sequence.d.ts +18 -0
- package/components/widgets/label-types/sequence.js +1 -0
- package/helpers/cms-layout-defaults.js +9 -2
- package/helpers/cms-region-keys.d.ts +1 -1
- package/helpers/motion.d.ts +7 -0
- package/helpers/motion.js +7 -0
- package/package.json +1 -1
- package/plugins/runtime-modules/asset/data-providers.d.ts +4 -0
- package/plugins/runtime-modules/asset/data-providers.js +1 -0
- package/plugins/runtime-modules/asset/definition.d.ts +4 -0
- package/plugins/runtime-modules/asset/presets.d.ts +53 -1
- package/plugins/runtime-modules/asset/presets.js +16 -12
- package/plugins/runtime-modules/asset/tour.d.ts +44 -0
- package/plugins/runtime-modules/asset/tour.js +53 -0
- package/plugins/runtime-modules/builder/clients/inspector-config-field.js +42 -18
- package/plugins/runtime-modules/builder/clients/inspector-image-field.js +2 -2
- package/plugins/runtime-modules/builder/clients/workspace-scope-boundary.js +10 -0
- package/plugins/runtime-modules/builder/controller/workspace-controller.js +18 -3
- package/plugins/runtime-modules/builder/plugin-meta-labels.js +8 -0
- package/plugins/runtime-modules/builder/presets.js +5 -3
- package/plugins/runtime-modules/builder/tour.d.ts +316 -0
- package/plugins/runtime-modules/builder/tour.js +337 -0
- package/plugins/runtime-modules/builder/trees/phi-builder-inspector-area-overlay-tree.server.js +8 -1
- package/plugins/runtime-modules/builder/widgets/structure-region/built-in.js +3 -2
- package/plugins/runtime-modules/core/widgets/collection-view/client.js +10 -0
- package/plugins/runtime-modules/core/widgets/description/config.d.ts +25 -1
- package/plugins/runtime-modules/core/widgets/entry-detail/config.d.ts +2 -0
- package/plugins/runtime-modules/core/widgets/entry-detail/config.js +8 -1
- package/plugins/runtime-modules/core/widgets/entry-header/config.d.ts +3 -0
- package/plugins/runtime-modules/core/widgets/entry-header/config.js +8 -1
- package/plugins/runtime-modules/core/widgets/entry-list/client.d.ts +4 -1
- package/plugins/runtime-modules/core/widgets/entry-list/client.js +3 -2
- package/plugins/runtime-modules/core/widgets/entry-list/config.d.ts +392 -93
- package/plugins/runtime-modules/core/widgets/entry-list/config.js +21 -3
- package/plugins/runtime-modules/core/widgets/entry-list/server.js +2 -0
- package/plugins/runtime-modules/core/widgets/gallery/authoring.js +62 -10
- package/plugins/runtime-modules/core/widgets/gallery/client.d.ts +6 -1
- package/plugins/runtime-modules/core/widgets/gallery/client.js +7 -79
- package/plugins/runtime-modules/core/widgets/gallery/config.d.ts +337 -144
- package/plugins/runtime-modules/core/widgets/gallery/config.js +52 -113
- package/plugins/runtime-modules/core/widgets/gallery/defaults.d.ts +15 -0
- package/plugins/runtime-modules/core/widgets/gallery/defaults.js +20 -0
- package/plugins/runtime-modules/core/widgets/image/client.js +1 -1
- package/plugins/runtime-modules/core/widgets/image/config.d.ts +29 -4
- package/plugins/runtime-modules/core/widgets/image/config.js +19 -2
- package/plugins/runtime-modules/core/widgets/image/editor.js +1 -1
- package/plugins/runtime-modules/core/widgets/result/config.d.ts +25 -1
- package/plugins/runtime-modules/core/widgets/simple-text/config.d.ts +25 -1
- package/plugins/runtime-modules/core/widgets/table/config.d.ts +79 -3
- package/plugins/runtime-modules/core/widgets/tour-button/client.js +1 -1
- package/plugins/runtime-modules/revisions/presets.d.ts +53 -2
- package/plugins/runtime-modules/revisions/presets.js +16 -12
- package/plugins/runtime-modules/revisions/tour.d.ts +43 -0
- package/plugins/runtime-modules/revisions/tour.js +51 -0
- package/plugins/runtime-modules/theme/presets.d.ts +36 -4
- package/plugins/runtime-modules/theme/tour.d.ts +45 -11
- package/plugins/runtime-modules/theme/tour.js +39 -8
- package/plugins/runtime-modules/tour/controller/address.js +2 -2
- package/plugins/runtime-modules/tour/controller/client.js +43 -2
- package/plugins/runtime-modules/tour/controller/definition.js +2 -1
- package/server-helpers/cms-request.js +1 -0
- package/server-helpers/page-tour.d.ts +10 -3
- package/server-helpers/page-tour.js +25 -12
- package/types/cms-config.d.ts +3 -1
- package/types/cms-config.js +3 -3
- package/types/cms-plugins.d.ts +31 -4
- package/types/tour-controller.d.ts +10 -0
- package/types/tour-controller.js +13 -0
- package/types/tours.d.ts +27 -25
- package/types/tours.js +9 -23
- package/types/visual-anchors.d.ts +15 -0
- package/types/visual-anchors.js +12 -0
- package/types.d.ts +2 -0
- package/types.js +4 -1
|
@@ -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";
|
|
@@ -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";
|
|
@@ -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
|
-
{
|
|
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",
|
|
@@ -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"
|
|
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
|
-
?
|
|
76
|
+
? PHI_SEQUENCE_DEFAULT_DURATION_MS
|
|
95
77
|
: clampPhiSequenceTransitionMs(durationMs);
|
|
96
|
-
const resolvedEasing = easing ??
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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,
|
|
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
|
}
|
|
@@ -12,6 +12,7 @@ import { emitPhiSignalCapability, usePhiSignalEmitter, PhiSignalIdentityProvider
|
|
|
12
12
|
import { usePhiSignalListener } from "../runtime/runtime-signal-bus";
|
|
13
13
|
import { registerPhiSignalInstance } from "../runtime/runtime-signal-registry";
|
|
14
14
|
import { usePhiSignalRuntimePartition } from "../runtime/runtime-signal-partition";
|
|
15
|
+
import { usePhiVisualAnchor } from "../runtime/runtime-visual-anchors";
|
|
15
16
|
import { PhiModalControl } from "../controls/phi-modal-control";
|
|
16
17
|
import { PhiDrawerControl } from "../controls/phi-drawer-control";
|
|
17
18
|
import { PhiAlertControl } from "../controls/phi-alert-control";
|
|
@@ -72,6 +73,8 @@ export function PhiOverlayContainerClient({ overlayId, overlayType, config: rawC
|
|
|
72
73
|
const openedAtPathname = useRef(null);
|
|
73
74
|
const pathname = usePhiPathname();
|
|
74
75
|
const emitSignal = usePhiSignalEmitter(receiver);
|
|
76
|
+
// The Overlay's panel, found by a Tour under the Overlay's own address while it is drawn.
|
|
77
|
+
const panelRef = usePhiVisualAnchor(receiver);
|
|
75
78
|
const loadZones = usePhiOverlayZonesLoaderIfAny();
|
|
76
79
|
const [loadedZones, setLoadedZones] = useState(null);
|
|
77
80
|
const listenRoutes = useMemo(() => config.signalRoutes?.listens ?? [], [config.signalRoutes?.listens]);
|
|
@@ -261,7 +264,7 @@ export function PhiOverlayContainerClient({ overlayId, overlayType, config: rawC
|
|
|
261
264
|
*/
|
|
262
265
|
const surfaceStyle = { ...resolvePhiCmsContainerSurface(config).style, padding: 0 };
|
|
263
266
|
if (overlayType === "drawer") {
|
|
264
|
-
return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiDrawerControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, placement: config.placement, size: config.size, maxSize: config.maxSize, resizable: behaviour.resizable, push: behaviour.push, containerStyle: surfaceStyle, onDismiss: requestClose })] }));
|
|
267
|
+
return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiDrawerControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, placement: config.placement, size: config.size, maxSize: config.maxSize, resizable: behaviour.resizable, push: behaviour.push, containerStyle: surfaceStyle, onDismiss: requestClose, panelRef: panelRef })] }));
|
|
265
268
|
}
|
|
266
|
-
return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiModalControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body == null ? null : _jsx(PhiDialogBodyProvider, { children: zones.body })), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, centered: behaviour.centered, controlSize: runtimeControlSize ?? config.controlSize, size: runtimeSize, width: config.width, containerStyle: surfaceStyle, onDismiss: requestClose })] }));
|
|
269
|
+
return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiModalControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body == null ? null : _jsx(PhiDialogBodyProvider, { children: zones.body })), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, centered: behaviour.centered, controlSize: runtimeControlSize ?? config.controlSize, size: runtimeSize, width: config.width, containerStyle: surfaceStyle, onDismiss: requestClose, panelRef: panelRef })] }));
|
|
267
270
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PhiVisualAnchorAddress } from "../../types/
|
|
1
|
+
import { type PhiVisualAnchorAddress } from "../../types/visual-anchors";
|
|
2
2
|
import { type PhiSignalRuntimePartition } from "./runtime-signal-partition";
|
|
3
3
|
/**
|
|
4
4
|
* One registration. The element is reached through a getter rather than held as a value, so whoever reads
|
|
@@ -16,7 +16,9 @@ export type PhiVisualAnchorEntry = {
|
|
|
16
16
|
export declare function registerPhiVisualAnchor(partition: PhiSignalRuntimePartition, entry: PhiVisualAnchorEntry): () => void;
|
|
17
17
|
/**
|
|
18
18
|
* The element an address is drawn as, in this partition and nowhere else, or `null` when it is not there
|
|
19
|
-
* to point at: not mounted, mounted twice, detached,
|
|
19
|
+
* to point at: not mounted, mounted twice, detached, rendered without a box (hidden, `display: none`), or
|
|
20
|
+
* with a box of no area -- a slot that hides itself because it has nothing to show still lays out at zero
|
|
21
|
+
* height, and a Tour pointing at it pointed at nothing.
|
|
20
22
|
*/
|
|
21
23
|
export declare function readPhiVisualAnchor(partition: PhiSignalRuntimePartition, address: PhiVisualAnchorAddress): HTMLElement | null;
|
|
22
24
|
/**
|
|
@@ -28,6 +30,15 @@ export declare function waitForPhiVisualAnchor(partition: PhiSignalRuntimePartit
|
|
|
28
30
|
timeoutMs: number;
|
|
29
31
|
signal?: AbortSignal;
|
|
30
32
|
}): Promise<HTMLElement | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Waits until a found target stops moving -- a Drawer sliding in, a slot opening -- and answers whether it
|
|
35
|
+
* did before `timeoutMs`. Whoever points at a target measures it once: measured mid-motion, the pointer
|
|
36
|
+
* stays where the target was on its way, off screen for a Drawer that is still coming in.
|
|
37
|
+
*/
|
|
38
|
+
export declare function waitForPhiVisualAnchorToSettle(element: HTMLElement, { timeoutMs, signal }: {
|
|
39
|
+
timeoutMs: number;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}): Promise<boolean>;
|
|
31
42
|
/**
|
|
32
43
|
* Registers the element a ref lands on as the visual anchor of `address` in the surrounding partition.
|
|
33
44
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useCallback, useEffect, useRef } from "react";
|
|
3
|
-
import { PHI_VISUAL_ANCHOR_PART_ATTRIBUTE } from "../../types/
|
|
3
|
+
import { PHI_VISUAL_ANCHOR_PART_ATTRIBUTE } from "../../types/visual-anchors";
|
|
4
4
|
import { usePhiSignalRuntimePartition } from "./runtime-signal-partition";
|
|
5
5
|
function notify(partition) {
|
|
6
6
|
for (const subscriber of partition.visualAnchorSubscribers) {
|
|
@@ -33,7 +33,9 @@ export function registerPhiVisualAnchor(partition, entry) {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* The element an address is drawn as, in this partition and nowhere else, or `null` when it is not there
|
|
36
|
-
* to point at: not mounted, mounted twice, detached,
|
|
36
|
+
* to point at: not mounted, mounted twice, detached, rendered without a box (hidden, `display: none`), or
|
|
37
|
+
* with a box of no area -- a slot that hides itself because it has nothing to show still lays out at zero
|
|
38
|
+
* height, and a Tour pointing at it pointed at nothing.
|
|
37
39
|
*/
|
|
38
40
|
export function readPhiVisualAnchor(partition, address) {
|
|
39
41
|
const entries = partition.visualAnchors.get(address);
|
|
@@ -41,7 +43,8 @@ export function readPhiVisualAnchor(partition, address) {
|
|
|
41
43
|
if (!element || !element.isConnected || element.getClientRects().length === 0) {
|
|
42
44
|
return null;
|
|
43
45
|
}
|
|
44
|
-
|
|
46
|
+
const box = element.getBoundingClientRect();
|
|
47
|
+
return box.width > 0 && box.height > 0 ? element : null;
|
|
45
48
|
}
|
|
46
49
|
/** How often a wait looks again for a target that is registered but not yet laid out. */
|
|
47
50
|
const WAIT_POLL_INTERVAL_MS = 100;
|
|
@@ -76,6 +79,49 @@ export function waitForPhiVisualAnchor(partition, address, { timeoutMs, signal }
|
|
|
76
79
|
signal?.addEventListener("abort", abort, { once: true });
|
|
77
80
|
});
|
|
78
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* How many frames in a row a target has to stand still before it counts as placed. More than two: an
|
|
84
|
+
* opening slot or Drawer often holds still for a frame or two before its motion starts, and measured
|
|
85
|
+
* then, the pointer is fixed to where it started.
|
|
86
|
+
*/
|
|
87
|
+
const SETTLE_STILL_FRAMES = 8;
|
|
88
|
+
/**
|
|
89
|
+
* Waits until a found target stops moving -- a Drawer sliding in, a slot opening -- and answers whether it
|
|
90
|
+
* did before `timeoutMs`. Whoever points at a target measures it once: measured mid-motion, the pointer
|
|
91
|
+
* stays where the target was on its way, off screen for a Drawer that is still coming in.
|
|
92
|
+
*/
|
|
93
|
+
export function waitForPhiVisualAnchorToSettle(element, { timeoutMs, signal }) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
let last = "";
|
|
96
|
+
let still = 0;
|
|
97
|
+
let frame = 0;
|
|
98
|
+
const finish = (settled) => {
|
|
99
|
+
window.cancelAnimationFrame(frame);
|
|
100
|
+
window.clearTimeout(timeout);
|
|
101
|
+
signal?.removeEventListener("abort", abort);
|
|
102
|
+
resolve(settled);
|
|
103
|
+
};
|
|
104
|
+
const abort = () => finish(false);
|
|
105
|
+
const look = () => {
|
|
106
|
+
const box = element.getBoundingClientRect();
|
|
107
|
+
const next = `${box.x},${box.y},${box.width},${box.height}`;
|
|
108
|
+
still = next === last ? still + 1 : 0;
|
|
109
|
+
last = next;
|
|
110
|
+
if (still >= SETTLE_STILL_FRAMES) {
|
|
111
|
+
finish(true);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
frame = window.requestAnimationFrame(look);
|
|
115
|
+
};
|
|
116
|
+
const timeout = window.setTimeout(() => finish(false), timeoutMs);
|
|
117
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
118
|
+
if (signal?.aborted) {
|
|
119
|
+
finish(false);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
frame = window.requestAnimationFrame(look);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
79
125
|
/**
|
|
80
126
|
* Registers the element a ref lands on as the visual anchor of `address` in the surrounding partition.
|
|
81
127
|
*
|