@vectoriox/iox-ui 5.37.0 → 5.39.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 +6 -1
- package/composite-core/index.d.ts +684 -28
- package/design-core/index.d.ts +452 -6
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +12 -4
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +1761 -143
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +555 -6
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +534 -12
- 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 +494 -127
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +210 -4
- package/index.d.ts +217 -30
- package/media-core/index.d.ts +277 -0
- package/media-core/package.json +3 -0
- package/package.json +5 -1
- package/src/actions-core/element-actions.ts +12 -3
- package/src/composite-core/accordion-runtime.ts +3 -15
- package/src/composite-core/accordion.ts +4 -2
- package/src/composite-core/composite-kinds.ts +121 -7
- package/src/composite-core/composite-node.ts +4 -0
- package/src/composite-core/composite-runtime.ts +68 -0
- package/src/composite-core/exclusive-runtime.ts +358 -0
- package/src/composite-core/lightbox.ts +192 -0
- package/src/composite-core/public-api.ts +8 -2
- package/src/composite-core/slider-runtime.ts +180 -0
- package/src/composite-core/slider.ts +378 -0
- package/src/composite-core/structure.ts +38 -5
- package/src/composite-core/tabs.ts +628 -0
- package/src/design-core/contrast-audit.ts +5 -0
- package/src/design-core/element-styles.ts +34 -3
- package/src/design-core/flavor-spec.generated.ts +517 -2
- package/src/generation-core/baseline-roles.ts +2 -0
- package/src/generation-core/compose-site.ts +147 -4
- package/src/generation-core/node-baselines.generated.ts +156 -6
- package/src/generation-core/patterns.ts +20 -0
- package/src/generation-core/site-spec.generated.ts +210 -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 +112 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { CompositeNode, CompositeNodeFactory, makeCompositePart, NO_ELEMENT_STYLE, readProp, writeProp, writeStyles } from './composite-node';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE ACCESSIBLE CAROUSEL (CC3 — complex-components plan §7c, board 6; board 9 Q13). The Slider's controls are no
|
|
5
|
+
* longer drawn by Swiper: Previous, Next, Pause and Pagination are REAL nodes with roles — selectable on the canvas,
|
|
6
|
+
* in the layer tree and the breadcrumb, and styled on their own (R2) — handed to Swiper as its navigation and
|
|
7
|
+
* pagination elements.
|
|
8
|
+
*
|
|
9
|
+
* SliderContainer (.swiper)
|
|
10
|
+
* ├─ ButtonBlock `button-secondary` role slider.pause FIRST focus stop; present while Autoplay is on, locked
|
|
11
|
+
* ├─ ButtonBlock `button-secondary` role slider.prev › Icon caret (points the reading way in RTL)
|
|
12
|
+
* ├─ ButtonBlock `button-secondary` role slider.next › Icon caret
|
|
13
|
+
* ├─ (the slides — `.swiper-wrapper` › SliderSlide …)
|
|
14
|
+
* └─ Container role slider.pagination Swiper draws the dots (or "3 / 6") inside it
|
|
15
|
+
*
|
|
16
|
+
* The parts render INSIDE the `.swiper` element, never inside `.swiper-wrapper` (slider.md: a `.swiper-slide` must be
|
|
17
|
+
* the wrapper's direct child): the parts that come before the first slide in the tree render before the wrapper, the
|
|
18
|
+
* rest after it. By default they sit over the slides like Swiper's own controls (the shared CSS, iox-design.css, at
|
|
19
|
+
* zero specificity — a part's own styles win), so a saved slider migrated from Swiper-drawn controls keeps its look.
|
|
20
|
+
*
|
|
21
|
+
* The Bindings switches stay the source of truth: Navigation arrows ⇔ Previous + Next, Pagination ⇔ Pagination,
|
|
22
|
+
* Autoplay ⇔ Pause (WCAG 2.2.2 — "added while Autoplay is on, cannot be removed"). `syncSliderParts` is the one edit.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export enum SliderRole {
|
|
26
|
+
Pause = 'slider.pause',
|
|
27
|
+
Prev = 'slider.prev',
|
|
28
|
+
Next = 'slider.next',
|
|
29
|
+
Pagination = 'slider.pagination',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parts in the order they are stamped: the pause control first (APG: the first focus stop), the dots last. */
|
|
33
|
+
export const SLIDER_PART_ROLES: ReadonlyArray<SliderRole> = [SliderRole.Pause, SliderRole.Prev, SliderRole.Next, SliderRole.Pagination];
|
|
34
|
+
|
|
35
|
+
export const SLIDER_TYPE = 'SliderContainer';
|
|
36
|
+
export const SLIDE_TYPE = 'SliderSlide';
|
|
37
|
+
|
|
38
|
+
/** The Bindings switches that own the parts. */
|
|
39
|
+
export enum SliderFlag {
|
|
40
|
+
Navigation = 'enableNavigation',
|
|
41
|
+
Pagination = 'enablePagination',
|
|
42
|
+
Autoplay = 'enableAutoplay',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FLAG_PARTS: ReadonlyArray<readonly [SliderFlag, ReadonlyArray<SliderRole>]> = [
|
|
46
|
+
[SliderFlag.Autoplay, [SliderRole.Pause]],
|
|
47
|
+
[SliderFlag.Navigation, [SliderRole.Prev, SliderRole.Next]],
|
|
48
|
+
[SliderFlag.Pagination, [SliderRole.Pagination]],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** The trait that names the carousel (Slider ▸ Accessibility ▸ Carousel name) — the A11y trait every named part uses. */
|
|
52
|
+
export const SLIDER_NAME_TRAIT = 'ariaLabel';
|
|
53
|
+
|
|
54
|
+
interface RoleLike { role?: string | null; type?: string }
|
|
55
|
+
|
|
56
|
+
export function isSliderPart(node: RoleLike | null | undefined): boolean {
|
|
57
|
+
return !!node?.role && (SLIDER_PART_ROLES as ReadonlyArray<string>).includes(node.role);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isSlider(node: RoleLike | null | undefined): boolean {
|
|
61
|
+
return node?.type === SLIDER_TYPE;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The slider's own part playing `role` (a direct child), or undefined. */
|
|
65
|
+
export function sliderPart<T extends RoleLike>(slider: { children?: T[] | null }, role: SliderRole): T | undefined {
|
|
66
|
+
return (slider.children ?? []).find(c => c?.role === role);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when the slider's controls are parts (it was created or migrated in CC3) — Swiper then draws none of its own. */
|
|
70
|
+
export function hasSliderParts(slider: { children?: ReadonlyArray<RoleLike> | null }): boolean {
|
|
71
|
+
return (slider.children ?? []).some(isSliderPart);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A slider's children split as they render: parts before the first slide, the slides (into `.swiper-wrapper`), and
|
|
75
|
+
* the parts after it. */
|
|
76
|
+
export function splitSliderChildren<T extends RoleLike>(children: ReadonlyArray<T> | null | undefined): { lead: T[]; slides: T[]; trail: T[] } {
|
|
77
|
+
const lead: T[] = [];
|
|
78
|
+
const slides: T[] = [];
|
|
79
|
+
const trail: T[] = [];
|
|
80
|
+
for (const child of children ?? []) {
|
|
81
|
+
if (!isSliderPart(child)) slides.push(child);
|
|
82
|
+
else (slides.length ? trail : lead).push(child);
|
|
83
|
+
}
|
|
84
|
+
return { lead, slides, trail };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The slides only (the template slide of a bound slider is `sliderSlides(children)[0]`). */
|
|
88
|
+
export function sliderSlides<T extends RoleLike>(children: ReadonlyArray<T> | null | undefined): T[] {
|
|
89
|
+
return (children ?? []).filter(c => !isSliderPart(c));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── the parts' recipe ───────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
enum Type {
|
|
95
|
+
ButtonBlock = 'ButtonBlock',
|
|
96
|
+
Icon = 'Icon',
|
|
97
|
+
Container = 'Container',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
enum Style {
|
|
101
|
+
Control = 'button-secondary',
|
|
102
|
+
Icon = 'icon',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Each part's glyph (Phosphor) and its default accessible name. */
|
|
106
|
+
export const SLIDER_PART_GLYPH: Readonly<Record<Exclude<SliderRole, SliderRole.Pagination>, string>> = {
|
|
107
|
+
[SliderRole.Pause]: 'ph ph-pause',
|
|
108
|
+
[SliderRole.Prev]: 'ph ph-caret-left',
|
|
109
|
+
[SliderRole.Next]: 'ph ph-caret-right',
|
|
110
|
+
};
|
|
111
|
+
export const SLIDER_PLAY_GLYPH = 'ph-fill ph-play';
|
|
112
|
+
|
|
113
|
+
const PART_LABEL: Readonly<Record<SliderRole, string>> = {
|
|
114
|
+
[SliderRole.Pause]: 'Pause',
|
|
115
|
+
[SliderRole.Prev]: 'Previous',
|
|
116
|
+
[SliderRole.Next]: 'Next',
|
|
117
|
+
[SliderRole.Pagination]: 'Pagination',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const ZERO = '0px 0px 0px 0px';
|
|
121
|
+
/** A round 44px control (board 6 `.st-ctl`), over the slides (z above `.swiper-wrapper`). The PLACE (which edge) is the
|
|
122
|
+
* shared CSS's, in logical insets, so a carousel reads the same way in RTL. */
|
|
123
|
+
const CONTROL_BASE: Readonly<Record<string, string>> = {
|
|
124
|
+
position: 'absolute', zIndex: '10', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
125
|
+
width: '44px', height: '44px', minWidth: '44px', padding: ZERO, margin: ZERO, borderRadius: '999px',
|
|
126
|
+
};
|
|
127
|
+
const ICON_BASE: Readonly<Record<string, string>> = { fontSize: '20px', color: 'inherit' };
|
|
128
|
+
/** The arrows' vertical middle over the slides: half the 44px control above the centre line. */
|
|
129
|
+
const ARROW_TOP = 'calc(50% - 22px)';
|
|
130
|
+
const PAGINATION_BASE: Readonly<Record<string, string>> = {
|
|
131
|
+
position: 'absolute', zIndex: '10', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px',
|
|
132
|
+
width: 'auto', minHeight: 'auto', height: 'auto', padding: ZERO, margin: ZERO, color: 'var(--iox-accent)',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What a saved slider drew through Swiper before CC3, carried into its parts by the migration so it looks the same:
|
|
137
|
+
* bare chevrons in the author's arrow colour and size, the dots in the author's colour. Swiper's own default colour
|
|
138
|
+
* is its theme blue — what the live site drew, because the engine never applied the colour traits.
|
|
139
|
+
*/
|
|
140
|
+
export interface LegacySliderLook {
|
|
141
|
+
navigationColor?: string;
|
|
142
|
+
navigationSize?: number;
|
|
143
|
+
paginationColor?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const SWIPER_THEME_COLOR = '#007aff';
|
|
147
|
+
|
|
148
|
+
function legacyLookOf(slider: CompositeNode): LegacySliderLook {
|
|
149
|
+
const color = readProp(slider, 'navigationColor');
|
|
150
|
+
const size = Number(readProp(slider, 'navigationSize'));
|
|
151
|
+
const dots = readProp(slider, 'paginationColor');
|
|
152
|
+
return {
|
|
153
|
+
navigationColor: typeof color === 'string' && color ? color : SWIPER_THEME_COLOR,
|
|
154
|
+
navigationSize: Number.isFinite(size) && size > 0 ? size : 44,
|
|
155
|
+
paginationColor: typeof dots === 'string' && dots ? dots : SWIPER_THEME_COLOR,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One part, from the recipe (board 6: `button-secondary` controls, token dots) or in a migrated slider's legacy look. */
|
|
160
|
+
export function buildSliderPart(create: CompositeNodeFactory, role: SliderRole, legacy?: LegacySliderLook): CompositeNode {
|
|
161
|
+
if (role === SliderRole.Pagination) {
|
|
162
|
+
const dots = makeCompositePart(create, { key: 'pagination', type: Type.Container, props: {}, elementStyle: NO_ELEMENT_STYLE }, role, PART_LABEL[role],
|
|
163
|
+
legacy?.paginationColor ? { ...PAGINATION_BASE, color: legacy.paginationColor } : PAGINATION_BASE);
|
|
164
|
+
dots.children = [];
|
|
165
|
+
return dots;
|
|
166
|
+
}
|
|
167
|
+
const key = role.split('.')[1];
|
|
168
|
+
// Previous / Next sit at the vertical middle (their own `top`, so a part set back in flow drops it); the inline edge
|
|
169
|
+
// is the shared CSS's, logical, so the arrows swap sides in RTL.
|
|
170
|
+
const placed = role === SliderRole.Pause ? CONTROL_BASE : { ...CONTROL_BASE, top: ARROW_TOP };
|
|
171
|
+
const base = legacy
|
|
172
|
+
? { ...placed, backgroundColor: 'transparent', borderWidth: ZERO, color: legacy.navigationColor ?? SWIPER_THEME_COLOR, boxShadow: 'none' }
|
|
173
|
+
: placed;
|
|
174
|
+
// Previous / Next carry their accessible name (Swiper's A11y module reads it); the pause control's name is its
|
|
175
|
+
// runtime's — it says what pressing it will do.
|
|
176
|
+
const props: Record<string, unknown> = { tag: 'button', type: 'button' };
|
|
177
|
+
if (role !== SliderRole.Pause) props[SLIDER_NAME_TRAIT] = role === SliderRole.Prev ? SLIDER_WORDS.en.prev : SLIDER_WORDS.en.next;
|
|
178
|
+
const button = makeCompositePart(create, { key, type: Type.ButtonBlock, props, elementStyle: legacy ? NO_ELEMENT_STYLE : Style.Control },
|
|
179
|
+
role, PART_LABEL[role], base);
|
|
180
|
+
const iconBase = legacy ? { ...ICON_BASE, fontSize: `${Math.round((legacy.navigationSize ?? 44) * 0.6)}px` } : ICON_BASE;
|
|
181
|
+
const icon = makeCompositePart(create, { key: `${key}-icon`, type: Type.Icon, props: { iconClass: SLIDER_PART_GLYPH[role] }, elementStyle: legacy ? NO_ELEMENT_STYLE : Style.Icon },
|
|
182
|
+
null, 'Icon', iconBase);
|
|
183
|
+
button.children = [icon];
|
|
184
|
+
return button;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
|
188
|
+
|
|
189
|
+
/** The parts the slider's switches call for, in stamping order. */
|
|
190
|
+
export function wantedSliderParts(slider: CompositeNode): SliderRole[] {
|
|
191
|
+
return FLAG_PARTS.filter(([flag]) => truthy(readProp(slider, flag))).flatMap(([, roles]) => roles);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface SliderPartsChange {
|
|
195
|
+
added: CompositeNode[];
|
|
196
|
+
removed: CompositeNode[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Make the slider's parts match its switches — ONE edit (a palette switch, the migration, the composer). Missing
|
|
201
|
+
* parts are stamped from the recipe (or, `legacy`, in the migrated look) at their place: the pause control, Previous
|
|
202
|
+
* and Next before the first slide, the pagination after the last; parts whose switch is off are removed. Everything
|
|
203
|
+
* else (the slides, the author's edits to an existing part) is kept.
|
|
204
|
+
*/
|
|
205
|
+
export function syncSliderParts(slider: CompositeNode, create: CompositeNodeFactory, legacy?: LegacySliderLook): SliderPartsChange {
|
|
206
|
+
const wanted = new Set(wantedSliderParts(slider));
|
|
207
|
+
const children = [...(slider.children ?? [])];
|
|
208
|
+
const removed = children.filter(c => isSliderPart(c) && !wanted.has(c.role as SliderRole));
|
|
209
|
+
const kept = children.filter(c => !removed.includes(c));
|
|
210
|
+
const added: CompositeNode[] = [];
|
|
211
|
+
for (const role of SLIDER_PART_ROLES) {
|
|
212
|
+
if (!wanted.has(role) || kept.some(c => c.role === role)) continue;
|
|
213
|
+
const part = buildSliderPart(create, role, legacy);
|
|
214
|
+
added.push(part);
|
|
215
|
+
if (role === SliderRole.Pagination) {
|
|
216
|
+
kept.push(part);
|
|
217
|
+
} else {
|
|
218
|
+
// Before the first slide, after the parts that come before it in stamping order.
|
|
219
|
+
const order = SLIDER_PART_ROLES.indexOf(role);
|
|
220
|
+
const firstSlide = kept.findIndex(c => !isSliderPart(c));
|
|
221
|
+
let at = firstSlide < 0 ? kept.length : firstSlide;
|
|
222
|
+
for (let i = 0; i < kept.length; i++) {
|
|
223
|
+
const r = kept[i].role as SliderRole;
|
|
224
|
+
if (isSliderPart(kept[i]) && SLIDER_PART_ROLES.indexOf(r) > order && i < at) { at = i; break; }
|
|
225
|
+
}
|
|
226
|
+
kept.splice(at, 0, part);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (added.length || removed.length) slider.children = kept;
|
|
230
|
+
return { added, removed };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** True when a saved slider still relies on Swiper-drawn controls: a switch is on and no part exists yet. */
|
|
234
|
+
export function needsSliderMigration(slider: CompositeNode | null | undefined): boolean {
|
|
235
|
+
return !!slider && isSlider(slider) && !hasSliderParts(slider) && wantedSliderParts(slider).length > 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The migration of a saved slider (board 9 Q13): its Swiper-drawn arrows, dots — and, when Autoplay is on, the pause
|
|
240
|
+
* control WCAG 2.2.2 requires — become parts, in the place and the colours Swiper drew them. Idempotent: a slider
|
|
241
|
+
* that already has parts is left alone. True when it changed.
|
|
242
|
+
*/
|
|
243
|
+
export function migrateSliderControls(slider: CompositeNode, create: CompositeNodeFactory): boolean {
|
|
244
|
+
if (!needsSliderMigration(slider)) return false;
|
|
245
|
+
const change = syncSliderParts(slider, create, legacyLookOf(slider));
|
|
246
|
+
return change.added.length > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A switch flipped in the panel: on a slider still on Swiper-drawn controls, migrate it first (so its other controls
|
|
251
|
+
* keep their look), then stamp / remove the parts the new switch calls for. ONE edit.
|
|
252
|
+
*/
|
|
253
|
+
export function setSliderFlag(slider: CompositeNode, flag: SliderFlag, on: boolean, create: CompositeNodeFactory): SliderPartsChange {
|
|
254
|
+
const wasLegacy = !hasSliderParts(slider);
|
|
255
|
+
const before = wantedSliderParts(slider);
|
|
256
|
+
const migrated: CompositeNode[] = [];
|
|
257
|
+
if (wasLegacy && before.length) {
|
|
258
|
+
syncSliderParts(slider, create, legacyLookOf(slider)).added.forEach(p => migrated.push(p));
|
|
259
|
+
}
|
|
260
|
+
writeProp(slider, flag, on);
|
|
261
|
+
const change = syncSliderParts(slider, create);
|
|
262
|
+
return { added: [...migrated, ...change.added], removed: change.removed };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── accessibility vocabulary (both runtimes) ───────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
export interface SliderWords {
|
|
268
|
+
stop: string;
|
|
269
|
+
start: string;
|
|
270
|
+
prev: string;
|
|
271
|
+
next: string;
|
|
272
|
+
carousel: string;
|
|
273
|
+
slide: string;
|
|
274
|
+
/** `{{index}}` and `{{slidesLength}}`, Swiper's placeholders. */
|
|
275
|
+
slideLabel: string;
|
|
276
|
+
goTo: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const SLIDER_WORDS: Readonly<Record<'en' | 'he', SliderWords>> = {
|
|
280
|
+
en: {
|
|
281
|
+
stop: 'Stop automatic slide show', start: 'Start automatic slide show', prev: 'Previous slide', next: 'Next slide',
|
|
282
|
+
carousel: 'carousel', slide: 'slide', slideLabel: '{{index}} of {{slidesLength}}', goTo: 'Go to slide {{index}}',
|
|
283
|
+
},
|
|
284
|
+
he: {
|
|
285
|
+
stop: 'עצירת המצגת האוטומטית', start: 'הפעלת המצגת האוטומטית', prev: 'השקופית הקודמת', next: 'השקופית הבאה',
|
|
286
|
+
carousel: 'קרוסלה', slide: 'שקופית', slideLabel: '{{index}} מתוך {{slidesLength}}', goTo: 'מעבר לשקופית {{index}}',
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export function sliderWords(lang?: string | null): SliderWords {
|
|
291
|
+
return (lang ?? '').toLowerCase().startsWith('he') ? SLIDER_WORDS.he : SLIDER_WORDS.en;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The wrapper's live region (APG): silent while the slides move on their own, polite otherwise. */
|
|
295
|
+
export function sliderLiveRegion(rotating: boolean): 'off' | 'polite' {
|
|
296
|
+
return rotating ? 'off' : 'polite';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The pause control's state (APG carousel): its NAME says what it will do ("Stop automatic slide show" while it
|
|
301
|
+
* rotates, "Start automatic slide show" when stopped). An author who named the control keeps the name, and the
|
|
302
|
+
* state is `aria-pressed` instead (pressed = stopped).
|
|
303
|
+
*/
|
|
304
|
+
export function sliderPauseAttributes(rotating: boolean, words: SliderWords = SLIDER_WORDS.en, authorName?: string | null): Record<string, string> {
|
|
305
|
+
const name = (authorName ?? '').trim();
|
|
306
|
+
return name
|
|
307
|
+
? { 'aria-label': name, 'aria-pressed': rotating ? 'false' : 'true' }
|
|
308
|
+
: { 'aria-label': rotating ? words.stop : words.start };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The pause control's glyph: pause while it rotates, play when stopped. */
|
|
312
|
+
export function sliderPauseGlyph(rotating: boolean): string {
|
|
313
|
+
return rotating ? SLIDER_PART_GLYPH[SliderRole.Pause] : SLIDER_PLAY_GLYPH;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── the testimonial carousel (board 6; generation-core `proof.testimonial-carousel`) ─────────────────
|
|
317
|
+
|
|
318
|
+
export interface CarouselQuote {
|
|
319
|
+
quote: string;
|
|
320
|
+
who: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Board 6's sample quotes — PLACEHOLDERS: testimonials are facts the studio gives, never invented. */
|
|
324
|
+
export const CAROUSEL_QUOTES: ReadonlyArray<CarouselQuote> = [
|
|
325
|
+
{ quote: 'A sample quote, two lines long, in the client’s own words.', who: '[Client name] · [Project, city]' },
|
|
326
|
+
{ quote: 'Testimonials are facts: replace these with words a client actually said.', who: '[Client name] · [Project, city]' },
|
|
327
|
+
{ quote: 'A third sample quote so the rotation has somewhere to go.', who: '[Client name] · [Project, city]' },
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
export interface CarouselOptions {
|
|
331
|
+
/** The carousel's accessible name (a `region` landmark). */
|
|
332
|
+
name?: string;
|
|
333
|
+
quotes?: ReadonlyArray<CarouselQuote>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A quote card. The carousel's controls sit in a ROW above the slides and the dots below them (board 6), so the
|
|
337
|
+
* card needs no room kept for an overlay. */
|
|
338
|
+
const QUOTE_SLIDE_BASE: Readonly<Record<string, string>> = {
|
|
339
|
+
width: '100%', aspectRatio: 'auto', minHeight: 'auto', height: 'auto', display: 'flex', flexDirection: 'column', gap: '12px', padding: '32px 32px 32px 32px',
|
|
340
|
+
};
|
|
341
|
+
/** Board 6's carousel: the pause control, Previous and Next in one row above the slides (in flow — they read in the
|
|
342
|
+
* page's direction), the dots under them. */
|
|
343
|
+
const ROW_CONTROL_BASE: Readonly<Record<string, string>> = { position: 'static', display: 'inline-flex', margin: '0px 4px 12px 4px' };
|
|
344
|
+
const ROW_DOTS_BASE: Readonly<Record<string, string>> = { position: 'static', margin: '12px 0px 0px 0px' };
|
|
345
|
+
const QUOTE_TEXT_BASE: Readonly<Record<string, string>> = { margin: ZERO, padding: ZERO, minHeight: 'auto', width: 'auto' };
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The testimonial carousel (board 6): a named Slider, one quote per slide (each a `card`), rotating every 5s with its
|
|
349
|
+
* control parts — the pause control first, Previous · Next, the dots. ONE recipe for the composer and the builder.
|
|
350
|
+
*/
|
|
351
|
+
export function buildCarouselRecipe(create: CompositeNodeFactory, options: CarouselOptions = {}): CompositeNode {
|
|
352
|
+
const slider = makeCompositePart(create, { key: 'carousel', type: SLIDER_TYPE, props: {}, elementStyle: NO_ELEMENT_STYLE }, null, 'Carousel', { width: '100%' });
|
|
353
|
+
for (const [name, value] of Object.entries({
|
|
354
|
+
// Rewind: Previous and Next are always there to press (board 6) — neither greys out at an end.
|
|
355
|
+
slidesPerView: 1, spaceBetween: 0, loop: 'rewind', enableNavigation: true, enablePagination: true, enableAutoplay: true, autoplayDelay: 5000,
|
|
356
|
+
autoplayPauseOnMouseEnter: true, autoHeight: true, [SLIDER_NAME_TRAIT]: options.name ?? 'What clients say',
|
|
357
|
+
})) writeProp(slider, name, value);
|
|
358
|
+
slider.children = (options.quotes ?? CAROUSEL_QUOTES).map((q, i) => {
|
|
359
|
+
const slide = makeCompositePart(create, { key: `slide-${i + 1}`, type: SLIDE_TYPE, props: {}, elementStyle: 'card' }, null, 'Slide', QUOTE_SLIDE_BASE);
|
|
360
|
+
slide.children = [
|
|
361
|
+
makeCompositePart(create, { key: `quote-${i + 1}`, type: 'Text', props: { content: q.quote, tag: 'p' }, elementStyle: 'lead' }, null, 'Quote', QUOTE_TEXT_BASE),
|
|
362
|
+
makeCompositePart(create, { key: `who-${i + 1}`, type: 'Text', props: { content: q.who, tag: 'p' }, elementStyle: 'small' }, null, 'Client', QUOTE_TEXT_BASE),
|
|
363
|
+
];
|
|
364
|
+
return slide;
|
|
365
|
+
});
|
|
366
|
+
syncSliderParts(slider, create);
|
|
367
|
+
for (const part of slider.children ?? []) {
|
|
368
|
+
if (part.role === SliderRole.Pagination) writeStyles(part, ROW_DOTS_BASE);
|
|
369
|
+
else if (isSliderPart(part)) writeStyles(part, ROW_CONTROL_BASE);
|
|
370
|
+
}
|
|
371
|
+
return slider;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The slide a lightbox opens at for the clicked row (clamped to the slides there are). */
|
|
375
|
+
export function slideIndexForRow(rowIndex: number, slideCount: number): number {
|
|
376
|
+
if (!Number.isFinite(rowIndex) || rowIndex < 0 || slideCount <= 0) return 0;
|
|
377
|
+
return Math.min(Math.floor(rowIndex), slideCount - 1);
|
|
378
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { AccordionRole, DisclosureRole } from './composite-kinds';
|
|
1
|
+
import { AccordionRole, DisclosureRole, TabsRole } from './composite-kinds';
|
|
2
2
|
import { CardPart, CardRole } from './card';
|
|
3
|
+
import { SLIDER_TYPE, SliderRole } from './slider';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The structure every composite declares (composite-components plan §3a `RoleSpec`): where a role may
|
|
@@ -10,8 +11,14 @@ import { CardPart, CardRole } from './card';
|
|
|
10
11
|
export interface CompositeRoleSpec {
|
|
11
12
|
/** The role of the part's parent; null for a composite root. */
|
|
12
13
|
readonly parent: string | null;
|
|
14
|
+
/** The TYPE of the part's parent, for a part whose parent carries no role (a Slider's controls). */
|
|
15
|
+
readonly parentType?: string;
|
|
13
16
|
readonly locked?: boolean;
|
|
17
|
+
/** May be deleted, but never moved out of its parent (a Slider's controls work only as its direct children). */
|
|
18
|
+
readonly pinned?: boolean;
|
|
14
19
|
readonly part?: CardPart;
|
|
20
|
+
/** The only roles that may be dropped INTO this part (a tab list takes tabs, a panels holder takes panels). */
|
|
21
|
+
readonly accepts?: ReadonlyArray<string>;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> = {
|
|
@@ -36,6 +43,21 @@ export const COMPOSITE_ROLE_SPECS: Readonly<Record<string, CompositeRoleSpec>> =
|
|
|
36
43
|
[DisclosureRole.More]: { parent: DisclosureRole.Trigger },
|
|
37
44
|
[DisclosureRole.Less]: { parent: DisclosureRole.Trigger },
|
|
38
45
|
[DisclosureRole.Icon]: { parent: DisclosureRole.Trigger },
|
|
46
|
+
// CC4a — a Tabs always keeps its tab list and its panels holder; a tab lives in the list and a panel in the
|
|
47
|
+
// holder (each may be deleted — with its pair — but never moved out); the list takes only tabs, the holder only
|
|
48
|
+
// panels, and a panel takes anything.
|
|
49
|
+
[TabsRole.Tabs]: { parent: null },
|
|
50
|
+
[TabsRole.List]: { parent: TabsRole.Tabs, locked: true, accepts: [TabsRole.Trigger] },
|
|
51
|
+
[TabsRole.Panels]: { parent: TabsRole.Tabs, locked: true, accepts: [TabsRole.Panel] },
|
|
52
|
+
[TabsRole.Trigger]: { parent: TabsRole.List, pinned: true },
|
|
53
|
+
[TabsRole.Panel]: { parent: TabsRole.Panels, pinned: true },
|
|
54
|
+
// CC3 — a Slider's controls are its own direct children (Swiper is handed them). The pause control stays while
|
|
55
|
+
// Autoplay is on (WCAG 2.2.2): the Autoplay switch removes it, never Delete. The arrows and the dots follow
|
|
56
|
+
// their switches and may be deleted.
|
|
57
|
+
[SliderRole.Pause]: { parent: null, parentType: SLIDER_TYPE, locked: true },
|
|
58
|
+
[SliderRole.Prev]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
|
|
59
|
+
[SliderRole.Next]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
|
|
60
|
+
[SliderRole.Pagination]: { parent: null, parentType: SLIDER_TYPE, pinned: true },
|
|
39
61
|
};
|
|
40
62
|
|
|
41
63
|
export enum StructureEdit {
|
|
@@ -49,9 +71,11 @@ export enum StructureRefusal {
|
|
|
49
71
|
LockedPart = 'locked-part',
|
|
50
72
|
/** A locked part can only live directly inside its own parent role. */
|
|
51
73
|
LockedPlacement = 'locked-placement',
|
|
74
|
+
/** The new parent takes only certain roles (a tab list takes only tabs, CC4a). */
|
|
75
|
+
NotAccepted = 'not-accepted',
|
|
52
76
|
}
|
|
53
77
|
|
|
54
|
-
interface RoleLike { role?: string | null }
|
|
78
|
+
interface RoleLike { role?: string | null; type?: string }
|
|
55
79
|
|
|
56
80
|
/**
|
|
57
81
|
* Why the structure guard refuses `edit` on `node`, or null when it is allowed. `ancestors` (Move only) =
|
|
@@ -59,10 +83,12 @@ interface RoleLike { role?: string | null }
|
|
|
59
83
|
*/
|
|
60
84
|
export function structureRefusal(node: RoleLike | null | undefined, edit: StructureEdit, ancestors: ReadonlyArray<RoleLike> = []): StructureRefusal | null {
|
|
61
85
|
const spec = node?.role ? COMPOSITE_ROLE_SPECS[node.role] : undefined;
|
|
62
|
-
if (!spec?.locked) return null;
|
|
63
|
-
if (edit === StructureEdit.Delete) return StructureRefusal.LockedPart;
|
|
64
86
|
const parent = ancestors[ancestors.length - 1];
|
|
65
|
-
|
|
87
|
+
if (edit === StructureEdit.Move && acceptsRefusal(node, parent)) return StructureRefusal.NotAccepted;
|
|
88
|
+
if (!spec?.locked && !spec?.pinned) return null;
|
|
89
|
+
if (edit === StructureEdit.Delete) return spec.locked ? StructureRefusal.LockedPart : null;
|
|
90
|
+
const placed = spec.parentType ? parent?.type === spec.parentType : parent?.role === spec.parent;
|
|
91
|
+
return placed ? null : StructureRefusal.LockedPlacement;
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
/** True when `node` is a locked part (shown with a lock and no delete in the layer tree). Deleting a whole
|
|
@@ -70,3 +96,10 @@ export function structureRefusal(node: RoleLike | null | undefined, edit: Struct
|
|
|
70
96
|
export function isLockedPart(node: RoleLike | null | undefined): boolean {
|
|
71
97
|
return !!(node?.role && COMPOSITE_ROLE_SPECS[node.role]?.locked);
|
|
72
98
|
}
|
|
99
|
+
|
|
100
|
+
/** Why `parent` refuses `node` as a child (it takes only certain roles), or null. */
|
|
101
|
+
export function acceptsRefusal(node: RoleLike | null | undefined, parent: RoleLike | null | undefined): StructureRefusal | null {
|
|
102
|
+
const accepts = parent?.role ? COMPOSITE_ROLE_SPECS[parent.role]?.accepts : undefined;
|
|
103
|
+
if (!accepts) return null;
|
|
104
|
+
return node?.role && accepts.includes(node.role) ? null : StructureRefusal.NotAccepted;
|
|
105
|
+
}
|