@vectoriox/iox-ui 5.36.0 → 5.38.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/actions-core/index.d.ts +17 -3
- package/composite-core/index.d.ts +463 -31
- package/design-core/index.d.ts +130 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +33 -3
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1062 -121
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +172 -3
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +410 -10
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-media-core.mjs +571 -0
- package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -0
- package/fesm2022/vectoriox-iox-ui.mjs +482 -120
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +167 -4
- package/index.d.ts +209 -25
- package/media-core/index.d.ts +277 -0
- package/media-core/package.json +3 -0
- package/package.json +5 -1
- package/src/actions-core/action.model.ts +4 -2
- package/src/actions-core/element-actions.ts +37 -2
- package/src/composite-core/accordion-runtime.ts +117 -28
- package/src/composite-core/accordion.ts +89 -45
- package/src/composite-core/composite-kinds.ts +110 -0
- package/src/composite-core/composite-node.ts +15 -0
- package/src/composite-core/disclosure.ts +125 -0
- package/src/composite-core/lightbox.ts +192 -0
- package/src/composite-core/public-api.ts +7 -1
- package/src/composite-core/slider-runtime.ts +180 -0
- package/src/composite-core/slider.ts +378 -0
- package/src/composite-core/structure.ts +25 -5
- package/src/design-core/element-styles.ts +27 -0
- package/src/design-core/flavor-spec.generated.ts +146 -2
- package/src/generation-core/baseline-roles.ts +2 -0
- package/src/generation-core/compose-site.ts +80 -4
- package/src/generation-core/node-baselines.generated.ts +148 -4
- package/src/generation-core/patterns.ts +17 -0
- package/src/generation-core/site-spec.generated.ts +164 -0
- package/src/media-core/public-api.ts +9 -0
- package/src/media-core/video-runtime.ts +239 -0
- package/src/media-core/video.ts +443 -0
- package/styles/iox-design.css +96 -0
|
@@ -37,6 +37,10 @@ export interface CompositeNode extends ElementStyleTarget {
|
|
|
37
37
|
* (the builder's `createConfig`, generation-core's `createSerializedNode`), with these props and this
|
|
38
38
|
* element style. `key` is stable per recipe node — a consumer that needs deterministic ids builds them
|
|
39
39
|
* from it (generation-core: `<root id>-<key>`). */
|
|
40
|
+
/** `elementStyle: NO_ELEMENT_STYLE` asks for a node that wears NO element style (undefined / null = the type's default,
|
|
41
|
+
* as both node factories have always read it): a migrated slider's bare chevrons, a lightbox's slide (CC3). */
|
|
42
|
+
export const NO_ELEMENT_STYLE = '';
|
|
43
|
+
|
|
40
44
|
export interface CompositeNodeSpec {
|
|
41
45
|
key: string;
|
|
42
46
|
type: string;
|
|
@@ -46,6 +50,17 @@ export interface CompositeNodeSpec {
|
|
|
46
50
|
|
|
47
51
|
export type CompositeNodeFactory = (spec: CompositeNodeSpec) => CompositeNode;
|
|
48
52
|
|
|
53
|
+
/** A recipe part: a NEW node (`create`) playing `role` under `label`, with the layout `base` styles its recipe
|
|
54
|
+
* gives it — its LOOK is its element style. Shared by every recipe (Accordion, Disclosure). */
|
|
55
|
+
export function makeCompositePart(create: CompositeNodeFactory, spec: CompositeNodeSpec, role: string | null, label: string | undefined,
|
|
56
|
+
base?: Readonly<Record<string, string>>): CompositeNode {
|
|
57
|
+
const node = create(spec);
|
|
58
|
+
if (role) node.role = role;
|
|
59
|
+
if (label) node.label = label;
|
|
60
|
+
if (base) writeStyles(node, base);
|
|
61
|
+
return node;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
const usesTraits = (node: CompositeNode): boolean => Array.isArray(node.traits);
|
|
50
65
|
|
|
51
66
|
/** A content trait's value — `traits[].default` on a builder node, `props[name]` on a saved one. */
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { accordionBehaviour, AccordionDefaultOn, AccordionIcon, buildCompositeIcon, CompositeBehaviourTrait, ITEM_OPEN_STATE } from './accordion';
|
|
2
|
+
import { DisclosureRole } from './composite-kinds';
|
|
3
|
+
import { CompositeNode, CompositeNodeFactory, makeCompositePart, writeProp, writeStyles } from './composite-node';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DISCLOSURE / "Read more" (CC2.1 — composite T21). Not a component: a recipe of ordinary nodes on the
|
|
7
|
+
* ACCORDION's machinery — a disclosure is a one-item accordion whose root is its item, so it runs the same
|
|
8
|
+
* state machine, attribute planners and runtime (`accordion-runtime.ts`), on the canvas and on the live site.
|
|
9
|
+
*
|
|
10
|
+
* Container `box` role disclosure itemKey k1 · trait defaultOn (none | first)
|
|
11
|
+
* ├─ LinkedContainer `accordion-trigger` role disclosure.trigger → <button aria-expanded aria-controls>
|
|
12
|
+
* │ ├─ Text `body` (span) role disclosure.more "Read more" — hidden while open
|
|
13
|
+
* │ ├─ Text `body` (span) role disclosure.less "Read less" — shown only while open
|
|
14
|
+
* │ └─ Icon `icon` role disclosure.icon Item open → turns
|
|
15
|
+
* └─ Container `accordion-panel` role disclosure.panel inside a hidden="until-found" track
|
|
16
|
+
* └─ Text `body` the rest of the text
|
|
17
|
+
*
|
|
18
|
+
* The words swap through the ordinary Item open state (`trigger-on` → the root), so the label an author sees on
|
|
19
|
+
* the canvas is the one the visitor gets, with no script and nothing to break. The look is the accordion's own
|
|
20
|
+
* element styles (no `disclosure-*` contract: the boards draw none) — the header's ink, hover ink and focus ring;
|
|
21
|
+
* the panel's padding and muted ink — and the words are `body` text: the site's body type, with `color: inherit`, so
|
|
22
|
+
* the toggle's ink and its hover ink reach them.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface DisclosureWords {
|
|
26
|
+
more: string;
|
|
27
|
+
less: string;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The words a fresh disclosure starts with — generic, the author's to replace. */
|
|
32
|
+
export const DISCLOSURE_WORDS: Readonly<DisclosureWords> = {
|
|
33
|
+
more: 'Read more',
|
|
34
|
+
less: 'Read less',
|
|
35
|
+
body: 'Write the rest of the story here. It stays out of the way until someone asks for it.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface DisclosureOptions {
|
|
39
|
+
words?: Partial<DisclosureWords>;
|
|
40
|
+
/** Open on first render (default: closed). */
|
|
41
|
+
open?: boolean;
|
|
42
|
+
icon?: AccordionIcon;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A disclosure's one item key — its root IS its item. */
|
|
46
|
+
export const DISCLOSURE_ITEM_KEY = 'k1';
|
|
47
|
+
|
|
48
|
+
enum Type {
|
|
49
|
+
Container = 'Container',
|
|
50
|
+
LinkedContainer = 'LinkedContainer',
|
|
51
|
+
Text = 'Text',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
enum Style {
|
|
55
|
+
Root = 'box',
|
|
56
|
+
Trigger = 'accordion-trigger',
|
|
57
|
+
Word = 'body',
|
|
58
|
+
Panel = 'accordion-panel',
|
|
59
|
+
Body = 'body',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ZERO_BOX = '0px 0px 0px 0px';
|
|
63
|
+
const HIDDEN = 'none';
|
|
64
|
+
const INHERIT = 'inherit';
|
|
65
|
+
/** A word is a flex item of the toggle — `block` (never `inline`: the compiler drops a value equal to the CSS initial,
|
|
66
|
+
* and the Text's own default would come back in its place). */
|
|
67
|
+
const SHOWN = 'block';
|
|
68
|
+
|
|
69
|
+
/** What each part holds — layout only; its LOOK is its element style. The trigger sizes to its words (a button in
|
|
70
|
+
* the flow, not a full-width header row — `fit-content` keeps it from stretching) and the panel spans the root. The
|
|
71
|
+
* trigger is `flex`, not `inline-flex`: it is a flex item of the root, which blockifies it on the site anyway. */
|
|
72
|
+
const PART_BASE: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
73
|
+
[DisclosureRole.Disclosure]: { display: 'flex', flexDirection: 'column', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
74
|
+
// The toggle reads the text around it (the page's body type, through the root) — a LinkedContainer is born with
|
|
75
|
+
// its own literal font, which an accordion's heading masks and a disclosure's words would show.
|
|
76
|
+
[DisclosureRole.Trigger]: {
|
|
77
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: 'fit-content', minHeight: 'auto', height: 'auto', textAlign: 'start', cursor: 'pointer',
|
|
78
|
+
fontFamily: INHERIT, fontSize: INHERIT, fontWeight: INHERIT, lineHeight: INHERIT,
|
|
79
|
+
},
|
|
80
|
+
// A word is just its words: none of a Text's own box (its type's 10px padding, its full width) — the toggle's
|
|
81
|
+
// padding is the click target.
|
|
82
|
+
[DisclosureRole.More]: { display: SHOWN, margin: ZERO_BOX, padding: ZERO_BOX, width: 'auto' },
|
|
83
|
+
[DisclosureRole.Less]: { display: HIDDEN, margin: ZERO_BOX, padding: ZERO_BOX, width: 'auto' },
|
|
84
|
+
[DisclosureRole.Panel]: { display: 'block', width: '100%', minHeight: 'auto', height: 'auto' },
|
|
85
|
+
};
|
|
86
|
+
const BODY_BASE: Readonly<Record<string, string>> = { margin: ZERO_BOX, padding: ZERO_BOX, minHeight: 'auto' };
|
|
87
|
+
|
|
88
|
+
function make(create: CompositeNodeFactory, key: string, type: string, role: DisclosureRole | null, style: string,
|
|
89
|
+
props: Record<string, unknown>, label: string): CompositeNode {
|
|
90
|
+
return makeCompositePart(create, { key, type, props, elementStyle: style }, role, label, role ? PART_BASE[role] : undefined);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One of the trigger's two words: shown while the disclosure is closed (`more`) or open (`less`). */
|
|
94
|
+
function buildWord(create: CompositeNodeFactory, role: DisclosureRole.More | DisclosureRole.Less, words: string, rootId?: string): CompositeNode {
|
|
95
|
+
const node = make(create, role === DisclosureRole.More ? 'more' : 'less', Type.Text, role, Style.Word, { content: words, tag: 'span' }, 'Text');
|
|
96
|
+
node.stateStyles = { ...(node.stateStyles ?? {}), [ITEM_OPEN_STATE]: { display: role === DisclosureRole.More ? HIDDEN : SHOWN } };
|
|
97
|
+
if (rootId) node.stateTriggers = { ...(node.stateTriggers ?? {}), [ITEM_OPEN_STATE]: rootId };
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A fresh Disclosure: "Read more" opens the text below and reads "Read less" while it is open; closed by default. */
|
|
102
|
+
export function buildDisclosureRecipe(create: CompositeNodeFactory, options: DisclosureOptions = {}): CompositeNode {
|
|
103
|
+
const words = { ...DISCLOSURE_WORDS, ...(options.words ?? {}) };
|
|
104
|
+
const root = make(create, 'disclosure', Type.Container, DisclosureRole.Disclosure, Style.Root, {}, 'Disclosure');
|
|
105
|
+
root.itemKey = DISCLOSURE_ITEM_KEY;
|
|
106
|
+
writeProp(root, CompositeBehaviourTrait.DefaultOn, options.open ? AccordionDefaultOn.First : AccordionDefaultOn.None);
|
|
107
|
+
const icon = options.icon ?? AccordionIcon.Chevron;
|
|
108
|
+
const trigger = make(create, 'trigger', Type.LinkedContainer, DisclosureRole.Trigger, Style.Trigger, {}, 'Toggle');
|
|
109
|
+
trigger.children = [
|
|
110
|
+
buildWord(create, DisclosureRole.More, words.more, root.id),
|
|
111
|
+
buildWord(create, DisclosureRole.Less, words.less, root.id),
|
|
112
|
+
...(icon === AccordionIcon.None ? [] : [buildCompositeIcon(create, 'icon', icon, root.id, DisclosureRole.Icon)]),
|
|
113
|
+
];
|
|
114
|
+
const panel = make(create, 'panel', Type.Container, DisclosureRole.Panel, Style.Panel, {}, 'Panel');
|
|
115
|
+
const body = make(create, 'body', Type.Text, null, Style.Body, { content: words.body, tag: 'p' }, 'Text');
|
|
116
|
+
writeStyles(body, BODY_BASE);
|
|
117
|
+
panel.children = [body];
|
|
118
|
+
root.children = [trigger, panel];
|
|
119
|
+
return root;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Behaviour ▸ Open by default (a disclosure: on or off). */
|
|
123
|
+
export function disclosureOpenByDefault(root: CompositeNode): boolean {
|
|
124
|
+
return accordionBehaviour(root).defaultOn !== AccordionDefaultOn.None;
|
|
125
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { setColorScheme } from '@vectoriox/iox-ui/design-core';
|
|
2
|
+
|
|
3
|
+
import { CompositeNode, CompositeNodeFactory, makeCompositePart, NO_ELEMENT_STYLE, readProp, writeProp, writeStyles } from './composite-node';
|
|
4
|
+
import { buildSliderPart, SLIDE_TYPE, SLIDER_TYPE, SliderRole } from './slider';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE LIGHTBOX (CC3 — complex-components plan §7d, board 6). Not a component: a Dialog holding a Slider bound to the
|
|
8
|
+
* SAME source as the grid of thumbnails. The one new thing is `openDialog`'s "Open at the clicked item"
|
|
9
|
+
* (`slideTo: 'row'`): a thumbnail in a repeater row opens the lightbox at ITS slide.
|
|
10
|
+
*
|
|
11
|
+
* Dialog `dialog` · centre · full viewport · Inverse scheme "Lightbox" (a free-standing Dialog: board A4)
|
|
12
|
+
* ├─ ButtonBlock `button-ghost` "Close" › Icon × click → closeDialog
|
|
13
|
+
* └─ SliderContainer one slide in view · keyboard · the source's rows
|
|
14
|
+
* ├─ Previous · Next (slider.prev / slider.next, button-secondary) hidden on a phone (swipe)
|
|
15
|
+
* ├─ SliderSlide › Image `media` (contain, keeps its alt) + Text `small` caption
|
|
16
|
+
* └─ Pagination (slider.pagination) — the counter "3 / 6"
|
|
17
|
+
*
|
|
18
|
+
* Keyboard ← → are the Slider's Keyboard module; Esc and the focus trap are the `<dialog>`'s; focus returns to the
|
|
19
|
+
* thumbnail that opened it (the dialog opener's own rule).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SLIDE_TO_ROW = 'row';
|
|
23
|
+
/** The attribute a renderer writes on every repeated row (the context-state provenance, both engine paths, the canvas). */
|
|
24
|
+
export const ROW_INDEX_ATTRIBUTE = 'data-iox-row-idx';
|
|
25
|
+
export const LIGHTBOX_LABEL = 'Lightbox';
|
|
26
|
+
const DIALOG = 'Dialog';
|
|
27
|
+
const REPEATER = 'Repeater';
|
|
28
|
+
const SOURCE = 'source';
|
|
29
|
+
const SOURCE_PATH = 'sourcePath';
|
|
30
|
+
|
|
31
|
+
export interface LightboxOptions {
|
|
32
|
+
/** The list the slides come from (the grid's own source). Empty = three manual slides. */
|
|
33
|
+
source?: string;
|
|
34
|
+
sourcePath?: string;
|
|
35
|
+
/** The row field each slide's image and caption read (`item` = the row itself, a bare URL). */
|
|
36
|
+
imageField?: string;
|
|
37
|
+
captionField?: string;
|
|
38
|
+
/** The image's alt binding; '' = decorative (a bare URL row has no words of its own). */
|
|
39
|
+
altField?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ZERO = '0px 0px 0px 0px';
|
|
43
|
+
const DIALOG_BASE: Readonly<Record<string, string>> = {
|
|
44
|
+
width: '100vw', maxWidth: '100vw', height: '100dvh', maxHeight: '100dvh', borderRadius: '0px', padding: '18px 18px 18px 18px',
|
|
45
|
+
display: 'flex', flexDirection: 'column', gap: '0px',
|
|
46
|
+
};
|
|
47
|
+
/** The top row (board 6): the counter at its start, Close at its end — Close sits above the slider, which rises into
|
|
48
|
+
* the row so its counter shares it. */
|
|
49
|
+
const CLOSE_BASE: Readonly<Record<string, string>> = {
|
|
50
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', width: '44px', height: '44px', minWidth: '44px',
|
|
51
|
+
padding: ZERO, margin: ZERO, borderRadius: '999px', alignSelf: 'flex-end', flexShrink: '0', position: 'relative', zIndex: '20',
|
|
52
|
+
};
|
|
53
|
+
const SLIDER_BASE: Readonly<Record<string, string>> = { width: '100%', height: '100%', minHeight: '0px', flexGrow: '1', margin: '-44px 0px 0px 0px' };
|
|
54
|
+
const SLIDE_BASE: Readonly<Record<string, string>> = {
|
|
55
|
+
width: '100%', height: '100%', aspectRatio: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
56
|
+
justifyContent: 'center', gap: '12px', padding: '56px 64px 16px 64px',
|
|
57
|
+
};
|
|
58
|
+
const IMAGE_BASE: Readonly<Record<string, string>> = { width: '100%', height: 'auto', maxHeight: '72dvh', minHeight: '0px', objectFit: 'contain', aspectRatio: '4 / 3', flexShrink: '1' };
|
|
59
|
+
const CAPTION_BASE: Readonly<Record<string, string>> = { textAlign: 'center', margin: ZERO, padding: ZERO, minHeight: 'auto', width: 'auto' };
|
|
60
|
+
/** The counter "3 / 6" sits at the START of the top row, Close at its end (board 6). */
|
|
61
|
+
const COUNTER_BASE: Readonly<Record<string, string>> = { color: 'var(--iox-text-muted)', top: '12px', alignItems: 'flex-start', justifyContent: 'flex-start' };
|
|
62
|
+
/** On a phone the arrows give way to a swipe (board 6, "the arrows hide on touch-only widths"). */
|
|
63
|
+
export const LIGHTBOX_ARROWS_HIDE_AT = 'mobile';
|
|
64
|
+
|
|
65
|
+
interface WithInteractions { interactions?: unknown[] }
|
|
66
|
+
|
|
67
|
+
/** A click on `node` runs one instant action on `target`. */
|
|
68
|
+
function onClick(node: CompositeNode, type: string, target: string, params?: Record<string, unknown>): void {
|
|
69
|
+
(node as WithInteractions).interactions = [{
|
|
70
|
+
id: `${node.id ?? 'node'}-click`, trigger: 'click',
|
|
71
|
+
actions: [{ type, target, duration: 0, delay: 0, easing: 'ease', ...(params ? { params } : {}) }],
|
|
72
|
+
}];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A binding of `prop` to the row field `field` of `source` (`item` = the row itself). */
|
|
76
|
+
function bindRow(node: CompositeNode, prop: string, source: string, field: string): void {
|
|
77
|
+
const bindings = ((node as { bindings?: unknown[] }).bindings ?? []).filter((b) => (b as { prop?: string }).prop !== prop);
|
|
78
|
+
bindings.push({ prop, source, path: field === 'item' ? '' : field });
|
|
79
|
+
(node as { bindings?: unknown[] }).bindings = bindings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildSlide(create: CompositeNodeFactory, key: string, options: LightboxOptions, rowAlias: string | null): CompositeNode {
|
|
83
|
+
const slide = makeCompositePart(create, { key, type: SLIDE_TYPE, props: {}, elementStyle: NO_ELEMENT_STYLE }, null, 'Slide', SLIDE_BASE);
|
|
84
|
+
const image = makeCompositePart(create, { key: `${key}-image`, type: 'Image', props: { src: '', alt: '' }, elementStyle: 'media' }, null, 'Image', IMAGE_BASE);
|
|
85
|
+
const caption = makeCompositePart(create, { key: `${key}-caption`, type: 'Text', props: { content: 'Caption', tag: 'p' }, elementStyle: 'small' }, null, 'Caption', CAPTION_BASE);
|
|
86
|
+
if (rowAlias) {
|
|
87
|
+
bindRow(image, 'src', rowAlias, options.imageField ?? 'item');
|
|
88
|
+
if (options.altField) bindRow(image, 'alt', rowAlias, options.altField);
|
|
89
|
+
if (options.captionField) bindRow(caption, 'content', rowAlias, options.captionField);
|
|
90
|
+
}
|
|
91
|
+
slide.children = options.captionField || !rowAlias ? [image, caption] : [image];
|
|
92
|
+
return slide;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A fresh lightbox: the Dialog, its Close part and the Slider (bound to `source`, or three manual slides). */
|
|
96
|
+
export function buildLightboxRecipe(create: CompositeNodeFactory, options: LightboxOptions = {}): CompositeNode {
|
|
97
|
+
const dialog = makeCompositePart(create, { key: 'lightbox', type: DIALOG, props: {}, elementStyle: 'dialog' }, null, LIGHTBOX_LABEL, DIALOG_BASE);
|
|
98
|
+
writeProp(dialog, 'placement', 'center');
|
|
99
|
+
writeProp(dialog, 'closeOnBackdrop', true);
|
|
100
|
+
writeProp(dialog, 'showCloseButton', false);
|
|
101
|
+
writeProp(dialog, 'ariaLabel', 'Gallery');
|
|
102
|
+
setColorScheme(dialog, 'inverse');
|
|
103
|
+
|
|
104
|
+
const close = makeCompositePart(create, { key: 'close', type: 'ButtonBlock', props: { tag: 'button', type: 'button', ariaLabel: 'Close' }, elementStyle: 'button-ghost' }, null, 'Close', CLOSE_BASE);
|
|
105
|
+
close.children = [makeCompositePart(create, { key: 'close-icon', type: 'Icon', props: { iconClass: 'ph ph-x' }, elementStyle: 'icon' }, null, 'Icon', { fontSize: '22px', color: 'inherit' })];
|
|
106
|
+
onClick(close, 'closeDialog', dialog.id ?? '');
|
|
107
|
+
|
|
108
|
+
const slider = makeCompositePart(create, { key: 'slider', type: SLIDER_TYPE, props: {}, elementStyle: NO_ELEMENT_STYLE }, null, 'Slider', SLIDER_BASE);
|
|
109
|
+
for (const [name, value] of Object.entries({
|
|
110
|
+
slidesPerView: 1, spaceBetween: 0, loop: 'off', enableNavigation: true, enablePagination: true, paginationType: 'fraction',
|
|
111
|
+
enableKeyboard: true, ariaLabel: 'Gallery', limit: 50,
|
|
112
|
+
})) writeProp(slider, name, value);
|
|
113
|
+
const rowAlias = options.source ? lastSegment(options.sourcePath) || options.source : null;
|
|
114
|
+
if (options.source) {
|
|
115
|
+
writeProp(slider, SOURCE, options.source);
|
|
116
|
+
if (options.sourcePath) writeProp(slider, SOURCE_PATH, options.sourcePath);
|
|
117
|
+
}
|
|
118
|
+
const prev = buildSliderPart(create, SliderRole.Prev);
|
|
119
|
+
const next = buildSliderPart(create, SliderRole.Next);
|
|
120
|
+
for (const arrow of [prev, next]) {
|
|
121
|
+
const layers = { ...(arrow.responsive ?? {}) };
|
|
122
|
+
layers[LIGHTBOX_ARROWS_HIDE_AT] = { styleProps: { display: 'none' } };
|
|
123
|
+
arrow.responsive = layers;
|
|
124
|
+
}
|
|
125
|
+
const counter = buildSliderPart(create, SliderRole.Pagination);
|
|
126
|
+
counter.label = 'Counter';
|
|
127
|
+
writeStyles(counter, COUNTER_BASE);
|
|
128
|
+
const slides = rowAlias ? [buildSlide(create, 'slide', options, rowAlias)] : [1, 2, 3].map(n => buildSlide(create, `slide-${n}`, options, null));
|
|
129
|
+
slider.children = [prev, next, ...slides, counter];
|
|
130
|
+
dialog.children = [close, slider];
|
|
131
|
+
return dialog;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lastSegment = (path: string | undefined): string => (path ? String(path).split('.').filter(Boolean).pop() ?? '' : '');
|
|
135
|
+
|
|
136
|
+
// ── "Open at the clicked item" ─────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export interface RepeatSource {
|
|
139
|
+
source: string;
|
|
140
|
+
path: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What a Repeater (its `source` binding) or a bound Slider (its `source` / `sourcePath` traits) repeats over. */
|
|
144
|
+
export function repeatSourceOf(node: CompositeNode | null | undefined): RepeatSource | null {
|
|
145
|
+
if (!node || (node.type !== REPEATER && node.type !== SLIDER_TYPE)) return null;
|
|
146
|
+
const binding = ((node as { bindings?: Array<{ prop?: string; source?: string; path?: string }> }).bindings ?? []).find(b => b?.prop === SOURCE);
|
|
147
|
+
if (binding?.source) return { source: binding.source, path: binding.path ?? '' };
|
|
148
|
+
const source = readProp(node, SOURCE);
|
|
149
|
+
if (typeof source !== 'string' || !source) return null;
|
|
150
|
+
const path = readProp(node, SOURCE_PATH);
|
|
151
|
+
return { source, path: typeof path === 'string' ? path : '' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function sameRepeatSource(a: RepeatSource | null, b: RepeatSource | null): boolean {
|
|
155
|
+
return !!a && !!b && a.source === b.source && (a.path ?? '') === (b.path ?? '');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The Slider a lightbox Dialog holds (the first one, depth first), or null. */
|
|
159
|
+
export function lightboxSliderOf(dialog: CompositeNode | null | undefined): CompositeNode | null {
|
|
160
|
+
for (const child of dialog?.children ?? []) {
|
|
161
|
+
if (child.type === SLIDER_TYPE) return child;
|
|
162
|
+
const below = lightboxSliderOf(child);
|
|
163
|
+
if (below) return below;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The repeated rows the element is in: the nearest Repeater or bound Slider among its ancestors (`path` = root → the
|
|
169
|
+
* element, inclusive). */
|
|
170
|
+
export function rowSourceOf(path: ReadonlyArray<CompositeNode>): RepeatSource | null {
|
|
171
|
+
for (let i = path.length - 2; i >= 0; i--) {
|
|
172
|
+
const source = repeatSourceOf(path[i]);
|
|
173
|
+
if (source) return source;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Interactions ▸ openDialog ▸ "Open at the clicked item" is offered only when it can work (board 9): the clicked
|
|
180
|
+
* element sits in a repeater row AND the target Dialog holds a Slider bound to the same source.
|
|
181
|
+
*/
|
|
182
|
+
export function canOpenAtClickedItem(ownerPath: ReadonlyArray<CompositeNode>, dialog: CompositeNode | null | undefined): boolean {
|
|
183
|
+
if (!dialog || dialog.type !== DIALOG) return false;
|
|
184
|
+
return sameRepeatSource(rowSourceOf(ownerPath), repeatSourceOf(lightboxSliderOf(dialog)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The repeated row an element sits in (its row index), 0 when it is in none (the canvas' template row). */
|
|
188
|
+
export function rowIndexOf(el: Element | null | undefined): number {
|
|
189
|
+
const row = el?.closest?.(`[${ROW_INDEX_ATTRIBUTE}]`);
|
|
190
|
+
const index = Number(row?.getAttribute(ROW_INDEX_ATTRIBUTE));
|
|
191
|
+
return Number.isInteger(index) && index >= 0 ? index : 0;
|
|
192
|
+
}
|
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
* plan §3a). FRAMEWORK-FREE: the builder, the SSR engine and iox-cms (generation-core's composer, the site
|
|
4
4
|
* build) read the same recipes, roles and structure rules. Card v2 + Badge / Chip (T23 / T25) are the
|
|
5
5
|
* first; the Accordion (CC2) adds the first composite RUNTIME (accordion-runtime.ts — DOM only, no
|
|
6
|
-
* framework); Tabs join with
|
|
6
|
+
* framework), which the Disclosure (CC2.1) runs too — a one-item accordion whose root is its item; Tabs join with
|
|
7
|
+
* their own phase.
|
|
7
8
|
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
|
|
8
9
|
*/
|
|
9
10
|
export * from './composite-node';
|
|
11
|
+
export * from './composite-kinds';
|
|
10
12
|
export * from './accordion';
|
|
11
13
|
export * from './accordion-runtime';
|
|
14
|
+
export * from './disclosure';
|
|
12
15
|
export * from './badge';
|
|
13
16
|
export * from './card';
|
|
14
17
|
export * from './structure';
|
|
18
|
+
export * from './slider';
|
|
19
|
+
export * from './slider-runtime';
|
|
20
|
+
export * from './lightbox';
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { sliderLiveRegion, sliderPauseAttributes, sliderPauseGlyph, SliderRole, SliderWords, sliderWords } from './slider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The accessible carousel's RUNTIME (CC3, board 6; closes vectoriox/iox-ui#13) — DOM only, over a running Swiper,
|
|
5
|
+
* ONE copy for the engine (both paths) and the admin's Preview. Swiper's A11y module gives the container
|
|
6
|
+
* `aria-roledescription="carousel"`, every slide `role=group` + "slide" + "n of m"; this adds what the APG
|
|
7
|
+
* carousel needs on top:
|
|
8
|
+
* - the PAUSE control (role `slider.pause`, rendered first): it stops rotation for good and starts it again; its
|
|
9
|
+
* name says what it will do, its glyph swaps;
|
|
10
|
+
* - rotation PAUSES while focus is anywhere inside (hover is Swiper's `pauseOnMouseEnter`) and resumes when it
|
|
11
|
+
* leaves — unless the visitor pressed pause;
|
|
12
|
+
* - the wrapper's live region is `off` while the slides move on their own and `polite` otherwise;
|
|
13
|
+
* - slides that are not in view are `inert` (and hidden from assistive technology), so Tab never lands in one.
|
|
14
|
+
* The builder's EDIT canvas never attaches it (no autoplay there, and a slide must stay clickable).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The parts of the Swiper instance this runtime reads — a real Swiper, or a stub in a spec. */
|
|
18
|
+
export interface CarouselSwiper {
|
|
19
|
+
el: HTMLElement;
|
|
20
|
+
wrapperEl: HTMLElement;
|
|
21
|
+
slides: HTMLElement[];
|
|
22
|
+
activeIndex: number;
|
|
23
|
+
destroyed?: boolean;
|
|
24
|
+
params?: { slidesPerView?: number | 'auto'; autoplay?: { pauseOnMouseEnter?: boolean } | boolean };
|
|
25
|
+
autoplay?: { running: boolean; paused?: boolean; start(): void; stop(): void; pause(): void; resume(): void };
|
|
26
|
+
on(event: string, handler: (...args: unknown[]) => void): void;
|
|
27
|
+
off(event: string, handler: (...args: unknown[]) => void): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CarouselA11yOptions {
|
|
31
|
+
words?: SliderWords;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SLIDE_VISIBLE = 'swiper-slide-visible';
|
|
35
|
+
const RUNTIME_KEY = '__ioxCarousel';
|
|
36
|
+
const ROTATION_EVENTS = 'autoplayStart autoplayStop autoplayPause autoplayResume';
|
|
37
|
+
const SLIDE_EVENTS = 'slideChange transitionEnd resize slidesLengthChange';
|
|
38
|
+
|
|
39
|
+
const AUTHOR_NAME_KEY = '__ioxAuthorName';
|
|
40
|
+
|
|
41
|
+
/** The name the AUTHOR gave the pause control (its rendered `aria-label` before this runtime first relabelled it). */
|
|
42
|
+
function authorNameOf(el: HTMLElement | null): string | null {
|
|
43
|
+
if (!el) return null;
|
|
44
|
+
const host = el as unknown as Record<string, unknown>;
|
|
45
|
+
if (host[AUTHOR_NAME_KEY] === undefined) host[AUTHOR_NAME_KEY] = el.getAttribute('aria-label') ?? '';
|
|
46
|
+
return (host[AUTHOR_NAME_KEY] as string) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The part of `role` that belongs to THIS slider (never one of a slider nested in a slide). */
|
|
50
|
+
export function sliderPartElement(sliderEl: Element, role: SliderRole): HTMLElement | null {
|
|
51
|
+
const cls = `iox-role-${role.replace('.', '-')}`;
|
|
52
|
+
for (const el of Array.from(sliderEl.querySelectorAll<HTMLElement>(`.${cls}`))) {
|
|
53
|
+
if (el.closest('.swiper') === sliderEl && !el.closest('.swiper-wrapper')) return el;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class CarouselA11y {
|
|
59
|
+
/** The visitor pressed pause: rotation stays stopped until they press it again. */
|
|
60
|
+
private stoppedByVisitor = false;
|
|
61
|
+
private focusInside = false;
|
|
62
|
+
/** The pointer is over the carousel and Swiper holds rotation for it (`pauseOnMouseEnter`). */
|
|
63
|
+
private hovering = false;
|
|
64
|
+
private readonly cleanups: Array<() => void> = [];
|
|
65
|
+
private readonly words: SliderWords;
|
|
66
|
+
private readonly pause: HTMLElement | null;
|
|
67
|
+
private readonly authorName: string | null;
|
|
68
|
+
|
|
69
|
+
constructor(private readonly swiper: CarouselSwiper, options: CarouselA11yOptions = {}) {
|
|
70
|
+
const el = swiper.el;
|
|
71
|
+
this.words = options.words ?? sliderWords(el.ownerDocument?.documentElement?.getAttribute('lang'));
|
|
72
|
+
this.pause = sliderPartElement(el, SliderRole.Pause);
|
|
73
|
+
this.authorName = authorNameOf(this.pause);
|
|
74
|
+
this.listen(el, 'focusin', () => this.onFocusIn());
|
|
75
|
+
this.listen(el, 'pointerenter', () => { this.hovering = this.hoverPauses; this.sync(); });
|
|
76
|
+
this.listen(el, 'pointerleave', () => { this.hovering = false; this.sync(); });
|
|
77
|
+
this.listen(el, 'focusout', (e) => this.onFocusOut(e as FocusEvent));
|
|
78
|
+
if (this.pause) this.listen(this.pause, 'click', (e) => { e.preventDefault(); this.togglePause(); });
|
|
79
|
+
const sync = () => this.sync();
|
|
80
|
+
swiper.on(ROTATION_EVENTS, sync);
|
|
81
|
+
swiper.on(SLIDE_EVENTS, sync);
|
|
82
|
+
this.cleanups.push(() => { swiper.off(ROTATION_EVENTS, sync); swiper.off(SLIDE_EVENTS, sync); });
|
|
83
|
+
(el as unknown as Record<string, unknown>)[RUNTIME_KEY] = this;
|
|
84
|
+
this.sync();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True while the slides move on their own: autoplay runs and nothing holds it — not focus inside, not the
|
|
88
|
+
* pointer over it. (Swiper's own `paused` also flips during every transition, so it is not read here.) */
|
|
89
|
+
get rotating(): boolean {
|
|
90
|
+
return !!this.swiper.autoplay?.running && !this.focusInside && !this.hovering;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private get hoverPauses(): boolean {
|
|
94
|
+
const autoplay = this.swiper.params?.autoplay;
|
|
95
|
+
return typeof autoplay === 'object' && !!autoplay?.pauseOnMouseEnter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The pause control: stop rotation for good, or start it again. */
|
|
99
|
+
togglePause(): void {
|
|
100
|
+
const a = this.swiper.autoplay;
|
|
101
|
+
if (!a) return;
|
|
102
|
+
if (a.running) {
|
|
103
|
+
this.stoppedByVisitor = true;
|
|
104
|
+
a.stop();
|
|
105
|
+
} else {
|
|
106
|
+
this.stoppedByVisitor = false;
|
|
107
|
+
a.start();
|
|
108
|
+
// Focus is on the pause control itself — inside the carousel: the rotation it just started waits until
|
|
109
|
+
// focus leaves (APG), but the control already reads "stop".
|
|
110
|
+
if (this.focusInside) a.pause();
|
|
111
|
+
}
|
|
112
|
+
this.sync();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
destroy(): void {
|
|
116
|
+
this.cleanups.splice(0).forEach(fn => fn());
|
|
117
|
+
delete (this.swiper.el as unknown as Record<string, unknown>)[RUNTIME_KEY];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private listen(target: EventTarget, type: string, fn: (e: Event) => void): void {
|
|
121
|
+
target.addEventListener(type, fn);
|
|
122
|
+
this.cleanups.push(() => target.removeEventListener(type, fn));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private onFocusIn(): void {
|
|
126
|
+
this.focusInside = true;
|
|
127
|
+
if (this.swiper.autoplay?.running && !this.swiper.autoplay.paused) this.swiper.autoplay.pause();
|
|
128
|
+
this.sync();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private onFocusOut(e: FocusEvent): void {
|
|
132
|
+
const next = e.relatedTarget as Node | null;
|
|
133
|
+
if (next && this.swiper.el.contains(next)) return;
|
|
134
|
+
this.focusInside = false;
|
|
135
|
+
if (!this.stoppedByVisitor && this.swiper.autoplay?.running && this.swiper.autoplay.paused) this.swiper.autoplay.resume();
|
|
136
|
+
this.sync();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Re-state the carousel: the live region, the pause control, the slides out of view. */
|
|
140
|
+
sync(): void {
|
|
141
|
+
if (this.swiper.destroyed) return;
|
|
142
|
+
const rotating = this.rotating;
|
|
143
|
+
this.swiper.wrapperEl?.setAttribute('aria-live', sliderLiveRegion(rotating));
|
|
144
|
+
if (this.pause) {
|
|
145
|
+
const running = !!this.swiper.autoplay?.running;
|
|
146
|
+
const attrs = sliderPauseAttributes(running, this.words, this.authorName);
|
|
147
|
+
for (const [name, value] of Object.entries(attrs)) this.pause.setAttribute(name, value);
|
|
148
|
+
if (!('aria-pressed' in attrs)) this.pause.removeAttribute('aria-pressed');
|
|
149
|
+
const icon = this.pause.querySelector('i');
|
|
150
|
+
if (icon) icon.className = `${sliderPauseGlyph(running)} ${Array.from(icon.classList).filter(c => !c.startsWith('ph')).join(' ')}`.trim();
|
|
151
|
+
}
|
|
152
|
+
this.syncHiddenSlides();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private syncHiddenSlides(): void {
|
|
156
|
+
const slides = this.swiper.slides ?? [];
|
|
157
|
+
const anyVisibleMarked = slides.some(s => s.classList?.contains(SLIDE_VISIBLE));
|
|
158
|
+
slides.forEach((slide, i) => {
|
|
159
|
+
const visible = anyVisibleMarked ? slide.classList.contains(SLIDE_VISIBLE) : i === this.swiper.activeIndex;
|
|
160
|
+
if (visible) {
|
|
161
|
+
slide.removeAttribute('inert');
|
|
162
|
+
slide.removeAttribute('aria-hidden');
|
|
163
|
+
} else {
|
|
164
|
+
slide.setAttribute('inert', '');
|
|
165
|
+
slide.setAttribute('aria-hidden', 'true');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The carousel runtime attached to a slider element, if any. */
|
|
172
|
+
export function carouselOf(el: Element | null | undefined): CarouselA11y | null {
|
|
173
|
+
return ((el as unknown as Record<string, unknown> | null)?.[RUNTIME_KEY] as CarouselA11y | undefined) ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Attach once per Swiper (a re-init gets a fresh runtime). */
|
|
177
|
+
export function attachCarouselA11y(swiper: CarouselSwiper, options: CarouselA11yOptions = {}): CarouselA11y {
|
|
178
|
+
carouselOf(swiper.el)?.destroy();
|
|
179
|
+
return new CarouselA11y(swiper, options);
|
|
180
|
+
}
|