@kerusiweb/angular 2.0.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.
@@ -0,0 +1,871 @@
1
+ import * as i0 from '@angular/core';
2
+ import { input, output, computed, ChangeDetectionStrategy, Component, inject, ElementRef, model, signal, effect } from '@angular/core';
3
+ import { DEFAULT_KERUSI_COLORS, seatFill, seatTextFill, cssVar, seatBodyPath, seatOccupantVariant, seatSelectedFrame, seatCoreFill, seatWashFill, seatOccupantPath, seatOccupantStroke, occupantFillFor, occupantStrokeFor, elementStyle, elementFill, elementTextFill, screenPath, readableOn, formatMoney, DEFAULT_SEAT_ARIA_STRINGS, errorsOf, KerusiValidationError, validateDocumentSet, expireHolds, buildRenderModel, resolveMapLocale, isRtlLocale, resolveColors, computeSectionLayout, buildNavigationGraph, seatAriaLabel, summarizeSelection, toggleSeatSelection, disallowedAnnouncement, DEFAULT_SEQUENCE_READER, heldSeats, applyStateDeltaOrdered } from '@kerusiweb/core';
4
+
5
+ /**
6
+ * Draws one Kerusi `Section` as a single `<svg>`.
7
+ *
8
+ * One SVG per section — rather than section groups inside a shared canvas — is
9
+ * what makes per-section `aspectRatio` and mixed grid/freeform modes work: each
10
+ * gets its own `viewBox` and its own intrinsic proportions, and CSS is free to
11
+ * stack, wrap or column them. It also puts the section heading in real DOM,
12
+ * where a screen reader can use it as a landmark.
13
+ *
14
+ * This component holds no state. It takes a placed layout and emits intent.
15
+ */
16
+ class KerusiSectionComponent {
17
+ section = input.required(/* @ts-ignore */
18
+ ...(ngDevMode ? [{ debugName: "section" }] : /* istanbul ignore next */ []));
19
+ layout = input.required(/* @ts-ignore */
20
+ ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
21
+ /** Currently selected seat ids. */
22
+ selection = input(new Set(), /* @ts-ignore */
23
+ ...(ngDevMode ? [{ debugName: "selection" }] : /* istanbul ignore next */ []));
24
+ /** The seat holding the section's single tab stop, if any. */
25
+ focusedSeatId = input(null, /* @ts-ignore */
26
+ ...(ngDevMode ? [{ debugName: "focusedSeatId" }] : /* istanbul ignore next */ []));
27
+ /** Per-seat accessible names, keyed by seat id. */
28
+ ariaLabels = input(new Map(), /* @ts-ignore */
29
+ ...(ngDevMode ? [{ debugName: "ariaLabels" }] : /* istanbul ignore next */ []));
30
+ colors = input(DEFAULT_KERUSI_COLORS, /* @ts-ignore */
31
+ ...(ngDevMode ? [{ debugName: "colors" }] : /* istanbul ignore next */ []));
32
+ /** Whether an available seat may take its `SeatType.color` (§4.7). */
33
+ typeColors = input(true, /* @ts-ignore */
34
+ ...(ngDevMode ? [{ debugName: "typeColors" }] : /* istanbul ignore next */ []));
35
+ /** When false, seats render but do not respond to pointer or keyboard. */
36
+ interactive = input(true, /* @ts-ignore */
37
+ ...(ngDevMode ? [{ debugName: "interactive" }] : /* istanbul ignore next */ []));
38
+ /**
39
+ * CSS pixels per viewBox unit, at the section's natural size.
40
+ *
41
+ * Without a cap every section stretches to the full container width, so in a
42
+ * four-tier theatre the narrow box row would draw seats several times the
43
+ * size of the orchestra's. Capping each section at its own intrinsic width
44
+ * keeps a seat the same size everywhere; a section too wide to fit still
45
+ * scales down to the container.
46
+ */
47
+ unitScale = input(1, /* @ts-ignore */
48
+ ...(ngDevMode ? [{ debugName: "unitScale" }] : /* istanbul ignore next */ []));
49
+ seatActivate = output();
50
+ seatFocused = output();
51
+ seatKeydown = output();
52
+ viewBox = computed(() => `0 0 ${this.layout().width} ${this.layout().height}`, /* @ts-ignore */
53
+ ...(ngDevMode ? [{ debugName: "viewBox" }] : /* istanbul ignore next */ []));
54
+ /** The section's natural width in CSS pixels; it never draws wider. */
55
+ naturalWidth = computed(() => this.layout().width * this.unitScale(), /* @ts-ignore */
56
+ ...(ngDevMode ? [{ debugName: "naturalWidth" }] : /* istanbul ignore next */ []));
57
+ isSelected(placed) {
58
+ return this.selection().has(placed.seat.id);
59
+ }
60
+ fill(placed) {
61
+ return seatFill(placed.seat, this.isSelected(placed), this.colors(), this.typeColors());
62
+ }
63
+ textFill(placed) {
64
+ return seatTextFill(placed.seat, this.isSelected(placed), this.colors(), this.typeColors());
65
+ }
66
+ backdrop() {
67
+ return cssVar('backdrop', this.colors().backdrop);
68
+ }
69
+ // --- the seat glyph -------------------------------------------------------
70
+ /** The seat's outline: square-ish front, tapered back — shape carries facing. */
71
+ bodyPath(placed) {
72
+ return seatBodyPath(placed.x, placed.y, placed.width, placed.height);
73
+ }
74
+ /** `null` for an available or blocked seat — nothing to mark. */
75
+ occupantVariant(placed) {
76
+ return seatOccupantVariant(placed.seat, this.isSelected(placed));
77
+ }
78
+ /**
79
+ * The box a seat's decorative marks are drawn to: the seat's own, or — when
80
+ * it is selected — the core plate's, which is inset by the frame width.
81
+ *
82
+ * Everything over a selected seat sits on the core, so it all has to be
83
+ * measured from the core. Drawing to the seat's box instead leaves the
84
+ * occupant's hips crossing the frame, where a figure tinted `selectedBg`
85
+ * lands on a frame filled `selectedBg` and reads as a hard nub. Since the
86
+ * core is an exact scaled copy of the body (see {@link seatSelectedFrame}),
87
+ * routing through here reproduces every clearance the mark had against the
88
+ * body, scaled — so the frame width stays free to change.
89
+ */
90
+ markBox(placed) {
91
+ if (!this.isSelected(placed)) {
92
+ return placed;
93
+ }
94
+ const frame = seatSelectedFrame(placed.width, placed.height);
95
+ return {
96
+ x: placed.x + frame,
97
+ y: placed.y + frame,
98
+ width: placed.width - frame * 2,
99
+ height: placed.height - frame * 2,
100
+ };
101
+ }
102
+ /** The bright plate inside a selected seat's frame. */
103
+ corePath(placed) {
104
+ const box = this.markBox(placed);
105
+ return seatBodyPath(box.x, box.y, box.width, box.height);
106
+ }
107
+ coreFill() {
108
+ return seatCoreFill(this.colors());
109
+ }
110
+ /** The dimming wash over a held or booked seat's type colour. */
111
+ washFill(placed) {
112
+ // Only called for held/booked seats — see the template's @if.
113
+ return seatWashFill(placed.seat.status, this.colors());
114
+ }
115
+ /** The occupant figure for a marked seat, sized to whatever it sits on. */
116
+ occupantPath(placed) {
117
+ const box = this.markBox(placed);
118
+ return seatOccupantPath(box.x, box.y, box.width, box.height);
119
+ }
120
+ occupantStroke(placed) {
121
+ const box = this.markBox(placed);
122
+ return seatOccupantStroke(box.width, box.height);
123
+ }
124
+ // The wheelchair marker keeps the same relative corner it always had; only
125
+ // the box it is relative to changes, so a selected seat's dot stays whole
126
+ // instead of being bitten off by the frame.
127
+ wheelchairCx(placed) {
128
+ const box = this.markBox(placed);
129
+ return box.x + box.width * 0.82;
130
+ }
131
+ wheelchairCy(placed) {
132
+ const box = this.markBox(placed);
133
+ return box.y + box.height * 0.18;
134
+ }
135
+ wheelchairRadius(placed) {
136
+ return this.markBox(placed).width * 0.13;
137
+ }
138
+ /**
139
+ * A core-coloured outline behind a selected seat's number, painted under the
140
+ * glyphs via `paint-order`.
141
+ *
142
+ * The label is centred on the occupant's shoulder line, so without this it is
143
+ * `selectedBg` over a `selectedBg` silhouette — the figure, not the plate, is
144
+ * what it actually has to contrast against. The halo also rescues a label too
145
+ * long for the core, which would otherwise run onto the frame and vanish into
146
+ * it.
147
+ *
148
+ * Bound here rather than in the stylesheet on purpose: a CSS
149
+ * `var(--kerusi-selected-fg, …)` rule would carry the library default as its
150
+ * fallback and silently skip the `[colors]` input tier, which only the
151
+ * TypeScript side can resolve.
152
+ */
153
+ labelHalo(placed) {
154
+ return this.isSelected(placed) ? seatCoreFill(this.colors()) : 'none';
155
+ }
156
+ labelHaloWidth(placed) {
157
+ return this.isSelected(placed) ? placed.fontSize * 0.22 : 0;
158
+ }
159
+ /** Solid for selected/booked; `none` for held, which draws hollow instead. */
160
+ occupantFill(variant) {
161
+ return occupantFillFor(variant, this.colors());
162
+ }
163
+ occupantStrokeColor(variant) {
164
+ return occupantStrokeFor(variant, this.colors());
165
+ }
166
+ /** The section's single tab stop; every other seat is reachable by arrow key. */
167
+ tabIndex(placed) {
168
+ if (!this.interactive()) {
169
+ return -1;
170
+ }
171
+ return placed.seat.id === this.focusedSeatId() ? 0 : -1;
172
+ }
173
+ ariaLabel(placed) {
174
+ return this.ariaLabels().get(placed.seat.id) ?? placed.seat.label;
175
+ }
176
+ seatClasses(placed) {
177
+ const seat = placed.seat;
178
+ return [
179
+ 'kerusi-seat',
180
+ `kerusi-seat--${seat.status}`,
181
+ this.isSelected(placed) ? 'kerusi-seat--selected' : '',
182
+ seat.selectable ? '' : 'kerusi-seat--unselectable',
183
+ seat.accessibility?.wheelchairAccessible ? 'kerusi-seat--wheelchair' : '',
184
+ seat.companions.length > 0 ? 'kerusi-seat--companion' : '',
185
+ ]
186
+ .filter(Boolean)
187
+ .join(' ');
188
+ }
189
+ /** SVG rotate() about the item's center; null when it does not rotate. */
190
+ transform(item) {
191
+ return item.rotation ? `rotate(${item.rotation} ${item.centerX} ${item.centerY})` : null;
192
+ }
193
+ /** Cancels the parent <g>'s rotation so the label stays upright and stays put. */
194
+ counterRotate(item) {
195
+ return item.rotation ? `rotate(${-item.rotation} ${item.centerX} ${item.centerY})` : null;
196
+ }
197
+ // --- elements -------------------------------------------------------------
198
+ style(placed) {
199
+ return elementStyle(placed.element.kind);
200
+ }
201
+ elementFillOf(placed) {
202
+ return elementFill(this.style(placed).tone, this.colors());
203
+ }
204
+ elementTextFillOf(placed) {
205
+ return elementTextFill(this.style(placed).tone, this.colors());
206
+ }
207
+ screenPathOf(placed) {
208
+ return screenPath(placed.x, placed.y, placed.width, placed.height);
209
+ }
210
+ /** The corner radius for a rectangular element; a stage reads as squarer. */
211
+ radiusOf(placed) {
212
+ return this.style(placed).shape === 'stage' ? 2 : Math.min(placed.height * 0.25, 6);
213
+ }
214
+ // --- interaction ----------------------------------------------------------
215
+ onSeatClick(placed) {
216
+ if (this.interactive()) {
217
+ this.seatActivate.emit(placed.seat.id);
218
+ }
219
+ }
220
+ onSeatFocus(placed) {
221
+ if (this.interactive()) {
222
+ this.seatFocused.emit(placed.seat.id);
223
+ }
224
+ }
225
+ onKeydown(event) {
226
+ if (this.interactive()) {
227
+ this.seatKeydown.emit(event);
228
+ }
229
+ }
230
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSectionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
231
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: KerusiSectionComponent, isStandalone: true, selector: "kerusi-section", inputs: { section: { classPropertyName: "section", publicName: "section", isSignal: true, isRequired: true, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: true, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, focusedSeatId: { classPropertyName: "focusedSeatId", publicName: "focusedSeatId", isSignal: true, isRequired: false, transformFunction: null }, ariaLabels: { classPropertyName: "ariaLabels", publicName: "ariaLabels", isSignal: true, isRequired: false, transformFunction: null }, colors: { classPropertyName: "colors", publicName: "colors", isSignal: true, isRequired: false, transformFunction: null }, typeColors: { classPropertyName: "typeColors", publicName: "typeColors", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, unitScale: { classPropertyName: "unitScale", publicName: "unitScale", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { seatActivate: "seatActivate", seatFocused: "seatFocused", seatKeydown: "seatKeydown" }, ngImport: i0, template: "<svg\r\n xmlns=\"http://www.w3.org/2000/svg\"\r\n class=\"kerusi-section-svg\"\r\n role=\"presentation\"\r\n focusable=\"false\"\r\n [style.--kerusi-focus-ring-input]=\"colors().focusRing\"\r\n [attr.viewBox]=\"viewBox()\"\r\n [style.max-width.px]=\"naturalWidth()\"\r\n preserveAspectRatio=\"xMidYMid meet\"\r\n>\r\n <rect\r\n x=\"0\"\r\n y=\"0\"\r\n [attr.width]=\"layout().width\"\r\n [attr.height]=\"layout().height\"\r\n [style.fill]=\"backdrop()\"\r\n />\r\n\r\n <!-- Elements first, so a screen or stage sits behind the seats it faces. -->\r\n @for (placed of layout().elements; track placed.element.id) {\r\n <g\r\n class=\"kerusi-element kerusi-element--{{ placed.element.kind }}\"\r\n [attr.transform]=\"transform(placed)\"\r\n >\r\n @switch (style(placed).shape) {\r\n @case ('screen') {\r\n <path [attr.d]=\"screenPathOf(placed)\" [style.fill]=\"elementFillOf(placed)\" />\r\n }\r\n @case ('void') {\r\n <rect\r\n class=\"kerusi-element__void\"\r\n [attr.x]=\"placed.x\"\r\n [attr.y]=\"placed.y\"\r\n [attr.width]=\"placed.width\"\r\n [attr.height]=\"placed.height\"\r\n [style.stroke]=\"elementTextFillOf(placed)\"\r\n fill=\"none\"\r\n />\r\n }\r\n @default {\r\n <rect\r\n [attr.x]=\"placed.x\"\r\n [attr.y]=\"placed.y\"\r\n [attr.width]=\"placed.width\"\r\n [attr.height]=\"placed.height\"\r\n [attr.rx]=\"radiusOf(placed)\"\r\n [style.fill]=\"elementFillOf(placed)\"\r\n />\r\n }\r\n }\r\n @if (placed.element.label && style(placed).showLabel) {\r\n <text\r\n class=\"kerusi-element__label\"\r\n text-anchor=\"middle\"\r\n dominant-baseline=\"central\"\r\n [attr.x]=\"placed.centerX\"\r\n [attr.y]=\"placed.centerY\"\r\n [attr.font-size]=\"placed.fontSize\"\r\n [style.fill]=\"elementTextFillOf(placed)\"\r\n [attr.transform]=\"counterRotate(placed)\"\r\n >\r\n {{ placed.element.label }}\r\n </text>\r\n }\r\n </g>\r\n }\r\n\r\n @for (placed of layout().seats; track placed.seat.id) {\r\n <g\r\n [class]=\"seatClasses(placed)\"\r\n role=\"button\"\r\n [attr.tabindex]=\"tabIndex(placed)\"\r\n [attr.aria-label]=\"ariaLabel(placed)\"\r\n [attr.aria-pressed]=\"isSelected(placed)\"\r\n [attr.aria-disabled]=\"!placed.seat.selectable\"\r\n [attr.data-seat-id]=\"placed.seat.id\"\r\n [attr.transform]=\"transform(placed)\"\r\n (click)=\"onSeatClick(placed)\"\r\n (focus)=\"onSeatFocus(placed)\"\r\n (keydown)=\"onKeydown($event)\"\r\n >\r\n @let variant = occupantVariant(placed);\r\n <!-- Tapered toward the back, square at the front, so which way the seat\r\n faces is legible from the outline alone. -->\r\n <path class=\"kerusi-seat__box\" [attr.d]=\"bodyPath(placed)\" [style.fill]=\"fill(placed)\" />\r\n @if (variant === 'held' || variant === 'booked') {\r\n <!-- Colour stays the seat type's; a taken seat recedes under a wash\r\n instead, so the type colour still reads on a busy map. -->\r\n <path\r\n class=\"kerusi-seat__wash\"\r\n [attr.d]=\"bodyPath(placed)\"\r\n [style.fill]=\"washFill(placed)\"\r\n />\r\n }\r\n @if (variant === 'selected') {\r\n <!-- Selection is the one state drawn from two tones: the body above is\r\n the frame, this is the bright core inside it. Whichever way the\r\n host page is themed, one of the two separates from it \u2014 which is\r\n why nothing here has to know about the page. -->\r\n <path class=\"kerusi-seat__core\" [attr.d]=\"corePath(placed)\" [style.fill]=\"coreFill()\" />\r\n }\r\n @if (variant) {\r\n <!-- Deliberately not counter-rotated: the occupant leans with the\r\n seat. Solid = settled (yours, or sold); hollow = a hold still in\r\n progress. -->\r\n <path\r\n class=\"kerusi-seat__occupant kerusi-seat__occupant--{{ variant }}\"\r\n [attr.d]=\"occupantPath(placed)\"\r\n [attr.stroke-width]=\"occupantStroke(placed)\"\r\n [style.fill]=\"occupantFill(variant)\"\r\n [style.stroke]=\"occupantStrokeColor(variant)\"\r\n />\r\n }\r\n <text\r\n class=\"kerusi-seat__label\"\r\n text-anchor=\"middle\"\r\n dominant-baseline=\"central\"\r\n [attr.x]=\"placed.centerX\"\r\n [attr.y]=\"placed.centerY\"\r\n [attr.font-size]=\"placed.fontSize\"\r\n [style.fill]=\"textFill(placed)\"\r\n [style.stroke]=\"labelHalo(placed)\"\r\n [attr.stroke-width]=\"labelHaloWidth(placed)\"\r\n [attr.transform]=\"counterRotate(placed)\"\r\n >\r\n {{ placed.seat.label }}\r\n </text>\r\n @if (placed.seat.accessibility?.wheelchairAccessible) {\r\n <!-- A corner marker, not a different fill: availability owns the fill. -->\r\n <circle\r\n class=\"kerusi-seat__wheelchair\"\r\n [attr.cx]=\"wheelchairCx(placed)\"\r\n [attr.cy]=\"wheelchairCy(placed)\"\r\n [attr.r]=\"wheelchairRadius(placed)\"\r\n [style.fill]=\"textFill(placed)\"\r\n />\r\n }\r\n </g>\r\n }\r\n</svg>\r\n", styles: [":host{display:block}.kerusi-section-svg{display:block;width:100%;height:auto;max-width:100%}.kerusi-seat{cursor:pointer;outline:none}.kerusi-seat--unselectable{cursor:not-allowed}.kerusi-seat__label,.kerusi-element__label{pointer-events:none;-webkit-user-select:none;user-select:none;font-family:inherit}.kerusi-seat__label{paint-order:stroke;stroke-linejoin:round}.kerusi-seat__wash{pointer-events:none;opacity:.5}.kerusi-seat__core,.kerusi-seat__occupant{pointer-events:none}.kerusi-seat__occupant--selected{opacity:.2}.kerusi-seat__occupant--booked{opacity:.55}.kerusi-seat__occupant--held{opacity:.75;stroke-linecap:round;stroke-linejoin:round}.kerusi-seat--selected .kerusi-seat__label{font-weight:600}.kerusi-seat:focus-visible .kerusi-seat__box{stroke:var(--kerusi-focus-ring, var(--kerusi-focus-ring-input, currentColor));stroke-width:2.5;paint-order:stroke}.kerusi-element__void{stroke-width:1;stroke-dasharray:4 3;opacity:.7}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
232
+ }
233
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSectionComponent, decorators: [{
234
+ type: Component,
235
+ args: [{ selector: 'kerusi-section', changeDetection: ChangeDetectionStrategy.OnPush, template: "<svg\r\n xmlns=\"http://www.w3.org/2000/svg\"\r\n class=\"kerusi-section-svg\"\r\n role=\"presentation\"\r\n focusable=\"false\"\r\n [style.--kerusi-focus-ring-input]=\"colors().focusRing\"\r\n [attr.viewBox]=\"viewBox()\"\r\n [style.max-width.px]=\"naturalWidth()\"\r\n preserveAspectRatio=\"xMidYMid meet\"\r\n>\r\n <rect\r\n x=\"0\"\r\n y=\"0\"\r\n [attr.width]=\"layout().width\"\r\n [attr.height]=\"layout().height\"\r\n [style.fill]=\"backdrop()\"\r\n />\r\n\r\n <!-- Elements first, so a screen or stage sits behind the seats it faces. -->\r\n @for (placed of layout().elements; track placed.element.id) {\r\n <g\r\n class=\"kerusi-element kerusi-element--{{ placed.element.kind }}\"\r\n [attr.transform]=\"transform(placed)\"\r\n >\r\n @switch (style(placed).shape) {\r\n @case ('screen') {\r\n <path [attr.d]=\"screenPathOf(placed)\" [style.fill]=\"elementFillOf(placed)\" />\r\n }\r\n @case ('void') {\r\n <rect\r\n class=\"kerusi-element__void\"\r\n [attr.x]=\"placed.x\"\r\n [attr.y]=\"placed.y\"\r\n [attr.width]=\"placed.width\"\r\n [attr.height]=\"placed.height\"\r\n [style.stroke]=\"elementTextFillOf(placed)\"\r\n fill=\"none\"\r\n />\r\n }\r\n @default {\r\n <rect\r\n [attr.x]=\"placed.x\"\r\n [attr.y]=\"placed.y\"\r\n [attr.width]=\"placed.width\"\r\n [attr.height]=\"placed.height\"\r\n [attr.rx]=\"radiusOf(placed)\"\r\n [style.fill]=\"elementFillOf(placed)\"\r\n />\r\n }\r\n }\r\n @if (placed.element.label && style(placed).showLabel) {\r\n <text\r\n class=\"kerusi-element__label\"\r\n text-anchor=\"middle\"\r\n dominant-baseline=\"central\"\r\n [attr.x]=\"placed.centerX\"\r\n [attr.y]=\"placed.centerY\"\r\n [attr.font-size]=\"placed.fontSize\"\r\n [style.fill]=\"elementTextFillOf(placed)\"\r\n [attr.transform]=\"counterRotate(placed)\"\r\n >\r\n {{ placed.element.label }}\r\n </text>\r\n }\r\n </g>\r\n }\r\n\r\n @for (placed of layout().seats; track placed.seat.id) {\r\n <g\r\n [class]=\"seatClasses(placed)\"\r\n role=\"button\"\r\n [attr.tabindex]=\"tabIndex(placed)\"\r\n [attr.aria-label]=\"ariaLabel(placed)\"\r\n [attr.aria-pressed]=\"isSelected(placed)\"\r\n [attr.aria-disabled]=\"!placed.seat.selectable\"\r\n [attr.data-seat-id]=\"placed.seat.id\"\r\n [attr.transform]=\"transform(placed)\"\r\n (click)=\"onSeatClick(placed)\"\r\n (focus)=\"onSeatFocus(placed)\"\r\n (keydown)=\"onKeydown($event)\"\r\n >\r\n @let variant = occupantVariant(placed);\r\n <!-- Tapered toward the back, square at the front, so which way the seat\r\n faces is legible from the outline alone. -->\r\n <path class=\"kerusi-seat__box\" [attr.d]=\"bodyPath(placed)\" [style.fill]=\"fill(placed)\" />\r\n @if (variant === 'held' || variant === 'booked') {\r\n <!-- Colour stays the seat type's; a taken seat recedes under a wash\r\n instead, so the type colour still reads on a busy map. -->\r\n <path\r\n class=\"kerusi-seat__wash\"\r\n [attr.d]=\"bodyPath(placed)\"\r\n [style.fill]=\"washFill(placed)\"\r\n />\r\n }\r\n @if (variant === 'selected') {\r\n <!-- Selection is the one state drawn from two tones: the body above is\r\n the frame, this is the bright core inside it. Whichever way the\r\n host page is themed, one of the two separates from it \u2014 which is\r\n why nothing here has to know about the page. -->\r\n <path class=\"kerusi-seat__core\" [attr.d]=\"corePath(placed)\" [style.fill]=\"coreFill()\" />\r\n }\r\n @if (variant) {\r\n <!-- Deliberately not counter-rotated: the occupant leans with the\r\n seat. Solid = settled (yours, or sold); hollow = a hold still in\r\n progress. -->\r\n <path\r\n class=\"kerusi-seat__occupant kerusi-seat__occupant--{{ variant }}\"\r\n [attr.d]=\"occupantPath(placed)\"\r\n [attr.stroke-width]=\"occupantStroke(placed)\"\r\n [style.fill]=\"occupantFill(variant)\"\r\n [style.stroke]=\"occupantStrokeColor(variant)\"\r\n />\r\n }\r\n <text\r\n class=\"kerusi-seat__label\"\r\n text-anchor=\"middle\"\r\n dominant-baseline=\"central\"\r\n [attr.x]=\"placed.centerX\"\r\n [attr.y]=\"placed.centerY\"\r\n [attr.font-size]=\"placed.fontSize\"\r\n [style.fill]=\"textFill(placed)\"\r\n [style.stroke]=\"labelHalo(placed)\"\r\n [attr.stroke-width]=\"labelHaloWidth(placed)\"\r\n [attr.transform]=\"counterRotate(placed)\"\r\n >\r\n {{ placed.seat.label }}\r\n </text>\r\n @if (placed.seat.accessibility?.wheelchairAccessible) {\r\n <!-- A corner marker, not a different fill: availability owns the fill. -->\r\n <circle\r\n class=\"kerusi-seat__wheelchair\"\r\n [attr.cx]=\"wheelchairCx(placed)\"\r\n [attr.cy]=\"wheelchairCy(placed)\"\r\n [attr.r]=\"wheelchairRadius(placed)\"\r\n [style.fill]=\"textFill(placed)\"\r\n />\r\n }\r\n </g>\r\n }\r\n</svg>\r\n", styles: [":host{display:block}.kerusi-section-svg{display:block;width:100%;height:auto;max-width:100%}.kerusi-seat{cursor:pointer;outline:none}.kerusi-seat--unselectable{cursor:not-allowed}.kerusi-seat__label,.kerusi-element__label{pointer-events:none;-webkit-user-select:none;user-select:none;font-family:inherit}.kerusi-seat__label{paint-order:stroke;stroke-linejoin:round}.kerusi-seat__wash{pointer-events:none;opacity:.5}.kerusi-seat__core,.kerusi-seat__occupant{pointer-events:none}.kerusi-seat__occupant--selected{opacity:.2}.kerusi-seat__occupant--booked{opacity:.55}.kerusi-seat__occupant--held{opacity:.75;stroke-linecap:round;stroke-linejoin:round}.kerusi-seat--selected .kerusi-seat__label{font-weight:600}.kerusi-seat:focus-visible .kerusi-seat__box{stroke:var(--kerusi-focus-ring, var(--kerusi-focus-ring-input, currentColor));stroke-width:2.5;paint-order:stroke}.kerusi-element__void{stroke-width:1;stroke-dasharray:4 3;opacity:.7}\n"] }]
236
+ }], propDecorators: { section: [{ type: i0.Input, args: [{ isSignal: true, alias: "section", required: true }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: true }] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }], focusedSeatId: [{ type: i0.Input, args: [{ isSignal: true, alias: "focusedSeatId", required: false }] }], ariaLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabels", required: false }] }], colors: [{ type: i0.Input, args: [{ isSignal: true, alias: "colors", required: false }] }], typeColors: [{ type: i0.Input, args: [{ isSignal: true, alias: "typeColors", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], unitScale: [{ type: i0.Input, args: [{ isSignal: true, alias: "unitScale", required: false }] }], seatActivate: [{ type: i0.Output, args: ["seatActivate"] }], seatFocused: [{ type: i0.Output, args: ["seatFocused"] }], seatKeydown: [{ type: i0.Output, args: ["seatKeydown"] }] } });
237
+
238
+ /**
239
+ * A key for a seat map: its seat types on one side, its availability states on
240
+ * the other.
241
+ *
242
+ * This ships in the library rather than being left to each application because
243
+ * the swatch colors must come from the *same* resolution path as the seat
244
+ * fills — `SeatType.color` when the document supplies one, the theme's
245
+ * available color when it does not. Re-deriving that in application code is how
246
+ * a legend ends up disagreeing with the map it describes.
247
+ *
248
+ * It is opt-in: `<kerusi-seatmap [showLegend]="true">` renders it inline, or
249
+ * use `<kerusi-legend>` directly to place it anywhere.
250
+ */
251
+ class KerusiLegendComponent {
252
+ /** From `RenderMap.legend`. */
253
+ legend = input.required(/* @ts-ignore */
254
+ ...(ngDevMode ? [{ debugName: "legend" }] : /* istanbul ignore next */ []));
255
+ locale = input('en', /* @ts-ignore */
256
+ ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
257
+ colors = input(DEFAULT_KERUSI_COLORS, /* @ts-ignore */
258
+ ...(ngDevMode ? [{ debugName: "colors" }] : /* istanbul ignore next */ []));
259
+ /** Whether an available seat takes its type color — mirrors the seatmap input. */
260
+ typeColors = input(true, /* @ts-ignore */
261
+ ...(ngDevMode ? [{ debugName: "typeColors" }] : /* istanbul ignore next */ []));
262
+ showPrices = input(true, /* @ts-ignore */
263
+ ...(ngDevMode ? [{ debugName: "showPrices" }] : /* istanbul ignore next */ []));
264
+ /** Hide types no seat in the rendered map actually uses. */
265
+ hideUnusedTypes = input(true, /* @ts-ignore */
266
+ ...(ngDevMode ? [{ debugName: "hideUnusedTypes" }] : /* istanbul ignore next */ []));
267
+ headingSeatTypes = input('Seat types', /* @ts-ignore */
268
+ ...(ngDevMode ? [{ debugName: "headingSeatTypes" }] : /* istanbul ignore next */ []));
269
+ headingAvailability = input('Availability', /* @ts-ignore */
270
+ ...(ngDevMode ? [{ debugName: "headingAvailability" }] : /* istanbul ignore next */ []));
271
+ types = computed(() => this.legend().filter((entry) => !this.hideUnusedTypes() || entry.seatCount > 0), /* @ts-ignore */
272
+ ...(ngDevMode ? [{ debugName: "types" }] : /* istanbul ignore next */ []));
273
+ // Colour means seat type; shape means status (see `seatFill`'s doc comment).
274
+ // Held and booked swatches share the plain "available" body colour — the
275
+ // map's colour there is always whatever the seat's own type supplies, which
276
+ // the legend has no single value for — and are told apart by their wash and
277
+ // occupant figure instead, same as on the map.
278
+ statuses = computed(() => {
279
+ const c = this.colors();
280
+ const available = cssVar('availableBg', c.availableBg);
281
+ return [
282
+ { key: 'available', label: 'Available', kind: 'plain', fill: available },
283
+ {
284
+ key: 'selected',
285
+ label: 'Selected',
286
+ kind: 'selected',
287
+ fill: cssVar('selectedBg', c.selectedBg),
288
+ },
289
+ { key: 'held', label: 'On hold', kind: 'held', fill: available },
290
+ { key: 'booked', label: 'Booked', kind: 'booked', fill: available },
291
+ { key: 'blocked', label: 'Blocked', kind: 'blocked', fill: cssVar('blockedBg', c.blockedBg) },
292
+ ];
293
+ }, /* @ts-ignore */
294
+ ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
295
+ swatch(entry) {
296
+ return (this.typeColors() && entry.color) || cssVar('availableBg', this.colors().availableBg);
297
+ }
298
+ swatchText(entry) {
299
+ const typeColor = this.typeColors() && entry.color;
300
+ // Mirrors `seatTextFill`: only a document-supplied color needs guessing at.
301
+ return typeColor ? readableOn(typeColor) : cssVar('availableFg', this.colors().availableFg);
302
+ }
303
+ // --- status swatches -------------------------------------------------------
304
+ //
305
+ // Availability is now signalled by shape, not colour, so every status swatch
306
+ // (bar "available" and "blocked") draws the same glyph the map does rather
307
+ // than a flat square. A legend that showed only flat colour would teach a
308
+ // cue the map no longer uses.
309
+ /** The swatch's viewBox edge; the seat glyph is drawn to fill it. */
310
+ glyphSize = 16;
311
+ glyphBody = seatBodyPath(0, 0, this.glyphSize, this.glyphSize);
312
+ /** The selected swatch's frame width, and the core it leaves inside itself. */
313
+ glyphFrame = seatSelectedFrame(this.glyphSize, this.glyphSize);
314
+ glyphCoreSize = this.glyphSize - this.glyphFrame * 2;
315
+ glyphCore = seatBodyPath(0, 0, this.glyphSize, this.glyphSize, this.glyphFrame);
316
+ glyphOccupant = seatOccupantPath(0, 0, this.glyphSize, this.glyphSize);
317
+ glyphOccupantStroke = seatOccupantStroke(this.glyphSize, this.glyphSize);
318
+ /** The selected swatch's figure sits on the core, so it is drawn to the core. */
319
+ glyphCoreOccupant = seatOccupantPath(this.glyphFrame, this.glyphFrame, this.glyphCoreSize, this.glyphCoreSize);
320
+ glyphCoreOccupantStroke = seatOccupantStroke(this.glyphCoreSize, this.glyphCoreSize);
321
+ occupantPathOf(variant) {
322
+ return variant === 'selected' ? this.glyphCoreOccupant : this.glyphOccupant;
323
+ }
324
+ occupantStrokeWidthOf(variant) {
325
+ return variant === 'selected' ? this.glyphCoreOccupantStroke : this.glyphOccupantStroke;
326
+ }
327
+ hasWash(kind) {
328
+ return kind === 'held' || kind === 'booked';
329
+ }
330
+ hasOccupant(kind) {
331
+ return kind === 'selected' || kind === 'held' || kind === 'booked';
332
+ }
333
+ washFillOf(kind) {
334
+ return seatWashFill(kind, this.colors());
335
+ }
336
+ occupantFillOf(variant) {
337
+ return occupantFillFor(variant, this.colors());
338
+ }
339
+ occupantStrokeOf(variant) {
340
+ return occupantStrokeFor(variant, this.colors());
341
+ }
342
+ coreFill() {
343
+ return seatCoreFill(this.colors());
344
+ }
345
+ price(entry) {
346
+ return entry.price ? formatMoney(entry.price, this.locale()) : '';
347
+ }
348
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiLegendComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
349
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: KerusiLegendComponent, isStandalone: true, selector: "kerusi-legend", inputs: { legend: { classPropertyName: "legend", publicName: "legend", isSignal: true, isRequired: true, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, colors: { classPropertyName: "colors", publicName: "colors", isSignal: true, isRequired: false, transformFunction: null }, typeColors: { classPropertyName: "typeColors", publicName: "typeColors", isSignal: true, isRequired: false, transformFunction: null }, showPrices: { classPropertyName: "showPrices", publicName: "showPrices", isSignal: true, isRequired: false, transformFunction: null }, hideUnusedTypes: { classPropertyName: "hideUnusedTypes", publicName: "hideUnusedTypes", isSignal: true, isRequired: false, transformFunction: null }, headingSeatTypes: { classPropertyName: "headingSeatTypes", publicName: "headingSeatTypes", isSignal: true, isRequired: false, transformFunction: null }, headingAvailability: { classPropertyName: "headingAvailability", publicName: "headingAvailability", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"kerusi-legend\">\r\n @if (types().length) {\r\n <div class=\"kerusi-legend__block\">\r\n <h4 class=\"kerusi-legend__heading\">{{ headingSeatTypes() }}</h4>\r\n <ul class=\"kerusi-legend__list\">\r\n @for (entry of types(); track entry.id) {\r\n <li class=\"kerusi-legend__item\">\r\n <span\r\n class=\"kerusi-legend__swatch\"\r\n aria-hidden=\"true\"\r\n [style.background]=\"swatch(entry)\"\r\n [style.color]=\"swatchText(entry)\"\r\n ></span>\r\n <span class=\"kerusi-legend__label\">{{ entry.label }}</span>\r\n @if (showPrices() && price(entry)) {\r\n <span class=\"kerusi-legend__price\">{{ price(entry) }}</span>\r\n }\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n }\r\n\r\n <div class=\"kerusi-legend__block\">\r\n <h4 class=\"kerusi-legend__heading\">{{ headingAvailability() }}</h4>\r\n <ul class=\"kerusi-legend__list\">\r\n @for (status of statuses(); track status.key) {\r\n <li class=\"kerusi-legend__item\">\r\n <!-- Shape carries status now, so every swatch draws the seat glyph\r\n rather than a flat colour \u2014 a flat swatch would teach a cue the\r\n map itself no longer uses. -->\r\n <svg\r\n class=\"kerusi-legend__swatch kerusi-legend__swatch--glyph\"\r\n aria-hidden=\"true\"\r\n focusable=\"false\"\r\n [attr.viewBox]=\"'0 0 ' + glyphSize + ' ' + glyphSize\"\r\n >\r\n <path [attr.d]=\"glyphBody\" [style.fill]=\"status.fill\" />\r\n @if (hasWash(status.kind)) {\r\n <path [attr.d]=\"glyphBody\" [style.fill]=\"washFillOf(status.kind)\" opacity=\"0.5\" />\r\n }\r\n @if (status.kind === 'selected') {\r\n <!-- Two tones, same as the map: the body above is the frame, this\r\n is the core inside it. -->\r\n <path [attr.d]=\"glyphCore\" [style.fill]=\"coreFill()\" />\r\n }\r\n @if (hasOccupant(status.kind)) {\r\n <path\r\n [attr.d]=\"occupantPathOf(status.kind)\"\r\n class=\"kerusi-legend__occupant kerusi-legend__occupant--{{ status.kind }}\"\r\n [attr.stroke-width]=\"occupantStrokeWidthOf(status.kind)\"\r\n [style.fill]=\"occupantFillOf(status.kind)\"\r\n [style.stroke]=\"occupantStrokeOf(status.kind)\"\r\n />\r\n }\r\n </svg>\r\n <span class=\"kerusi-legend__label\">{{ status.label }}</span>\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n</div>\r\n", styles: [":host{display:block}.kerusi-legend{display:flex;flex-wrap:wrap;gap:1.5rem;font-size:.85rem}.kerusi-legend__block{display:flex;flex-direction:column;gap:.4rem;min-width:0}.kerusi-legend__heading{margin:0;font-size:.72rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;opacity:.65}.kerusi-legend__list{display:flex;flex-wrap:wrap;gap:.35rem .9rem;margin:0;padding:0;list-style:none}.kerusi-legend__item{display:inline-flex;align-items:center;gap:.4rem}.kerusi-legend__swatch{display:inline-block;flex:none;width:.85rem;height:.85rem;border-radius:3px}.kerusi-legend__swatch--glyph{border-radius:0;overflow:visible}.kerusi-legend__occupant--selected{opacity:.2}.kerusi-legend__occupant--booked{opacity:.55}.kerusi-legend__occupant--held{opacity:.75;stroke-linecap:round;stroke-linejoin:round}.kerusi-legend__price{opacity:.7;font-variant-numeric:tabular-nums}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
350
+ }
351
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiLegendComponent, decorators: [{
352
+ type: Component,
353
+ args: [{ selector: 'kerusi-legend', changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"kerusi-legend\">\r\n @if (types().length) {\r\n <div class=\"kerusi-legend__block\">\r\n <h4 class=\"kerusi-legend__heading\">{{ headingSeatTypes() }}</h4>\r\n <ul class=\"kerusi-legend__list\">\r\n @for (entry of types(); track entry.id) {\r\n <li class=\"kerusi-legend__item\">\r\n <span\r\n class=\"kerusi-legend__swatch\"\r\n aria-hidden=\"true\"\r\n [style.background]=\"swatch(entry)\"\r\n [style.color]=\"swatchText(entry)\"\r\n ></span>\r\n <span class=\"kerusi-legend__label\">{{ entry.label }}</span>\r\n @if (showPrices() && price(entry)) {\r\n <span class=\"kerusi-legend__price\">{{ price(entry) }}</span>\r\n }\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n }\r\n\r\n <div class=\"kerusi-legend__block\">\r\n <h4 class=\"kerusi-legend__heading\">{{ headingAvailability() }}</h4>\r\n <ul class=\"kerusi-legend__list\">\r\n @for (status of statuses(); track status.key) {\r\n <li class=\"kerusi-legend__item\">\r\n <!-- Shape carries status now, so every swatch draws the seat glyph\r\n rather than a flat colour \u2014 a flat swatch would teach a cue the\r\n map itself no longer uses. -->\r\n <svg\r\n class=\"kerusi-legend__swatch kerusi-legend__swatch--glyph\"\r\n aria-hidden=\"true\"\r\n focusable=\"false\"\r\n [attr.viewBox]=\"'0 0 ' + glyphSize + ' ' + glyphSize\"\r\n >\r\n <path [attr.d]=\"glyphBody\" [style.fill]=\"status.fill\" />\r\n @if (hasWash(status.kind)) {\r\n <path [attr.d]=\"glyphBody\" [style.fill]=\"washFillOf(status.kind)\" opacity=\"0.5\" />\r\n }\r\n @if (status.kind === 'selected') {\r\n <!-- Two tones, same as the map: the body above is the frame, this\r\n is the core inside it. -->\r\n <path [attr.d]=\"glyphCore\" [style.fill]=\"coreFill()\" />\r\n }\r\n @if (hasOccupant(status.kind)) {\r\n <path\r\n [attr.d]=\"occupantPathOf(status.kind)\"\r\n class=\"kerusi-legend__occupant kerusi-legend__occupant--{{ status.kind }}\"\r\n [attr.stroke-width]=\"occupantStrokeWidthOf(status.kind)\"\r\n [style.fill]=\"occupantFillOf(status.kind)\"\r\n [style.stroke]=\"occupantStrokeOf(status.kind)\"\r\n />\r\n }\r\n </svg>\r\n <span class=\"kerusi-legend__label\">{{ status.label }}</span>\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n</div>\r\n", styles: [":host{display:block}.kerusi-legend{display:flex;flex-wrap:wrap;gap:1.5rem;font-size:.85rem}.kerusi-legend__block{display:flex;flex-direction:column;gap:.4rem;min-width:0}.kerusi-legend__heading{margin:0;font-size:.72rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;opacity:.65}.kerusi-legend__list{display:flex;flex-wrap:wrap;gap:.35rem .9rem;margin:0;padding:0;list-style:none}.kerusi-legend__item{display:inline-flex;align-items:center;gap:.4rem}.kerusi-legend__swatch{display:inline-block;flex:none;width:.85rem;height:.85rem;border-radius:3px}.kerusi-legend__swatch--glyph{border-radius:0;overflow:visible}.kerusi-legend__occupant--selected{opacity:.2}.kerusi-legend__occupant--booked{opacity:.55}.kerusi-legend__occupant--held{opacity:.75;stroke-linecap:round;stroke-linejoin:round}.kerusi-legend__price{opacity:.7;font-variant-numeric:tabular-nums}\n"] }]
354
+ }], propDecorators: { legend: [{ type: i0.Input, args: [{ isSignal: true, alias: "legend", required: true }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], colors: [{ type: i0.Input, args: [{ isSignal: true, alias: "colors", required: false }] }], typeColors: [{ type: i0.Input, args: [{ isSignal: true, alias: "typeColors", required: false }] }], showPrices: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPrices", required: false }] }], hideUnusedTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideUnusedTypes", required: false }] }], headingSeatTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "headingSeatTypes", required: false }] }], headingAvailability: [{ type: i0.Input, args: [{ isSignal: true, alias: "headingAvailability", required: false }] }] } });
355
+
356
+ /**
357
+ * Renders a Kerusi seat map.
358
+ *
359
+ * Takes a `KerusiMap` and its `KerusiState` directly — no adapter, no
360
+ * intermediate row model. Each `Section` becomes its own `<svg>` with its own
361
+ * layout mode and proportions, seats take their color from the map's legend,
362
+ * every seat is keyboard-reachable and announced with its type, price, status
363
+ * and §4.3.4 accessibility properties, and selection is an immutable set of
364
+ * seat ids the caller can two-way bind.
365
+ *
366
+ * ```html
367
+ * <kerusi-seatmap [map]="map" [state]="state" [(selection)]="picked" />
368
+ * ```
369
+ */
370
+ class KerusiSeatmapComponent {
371
+ host = inject((ElementRef));
372
+ // --- documents ------------------------------------------------------------
373
+ map = input.required(/* @ts-ignore */
374
+ ...(ngDevMode ? [{ debugName: "map" }] : /* istanbul ignore next */ []));
375
+ state = input(undefined, /* @ts-ignore */
376
+ ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
377
+ /** The optional map↔event join (§5.3). Validated against the map and state. */
378
+ session = input(undefined, /* @ts-ignore */
379
+ ...(ngDevMode ? [{ debugName: "session" }] : /* istanbul ignore next */ []));
380
+ // --- selection ------------------------------------------------------------
381
+ /** Selected seat ids. Two-way: `[(selection)]`, or read-only with `[selection]`. */
382
+ selection = model([], /* @ts-ignore */
383
+ ...(ngDevMode ? [{ debugName: "selection" }] : /* istanbul ignore next */ []));
384
+ // --- localization ---------------------------------------------------------
385
+ /** Overrides `KerusiMap.locale` (BCP-47). */
386
+ locale = input(undefined, /* @ts-ignore */
387
+ ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
388
+ /** Right-to-left rendering. `auto` derives it from the resolved locale. */
389
+ rtl = input('auto', /* @ts-ignore */
390
+ ...(ngDevMode ? [{ debugName: "rtl" }] : /* istanbul ignore next */ []));
391
+ /** Overrides the built-in English announcement strings. */
392
+ ariaStrings = input(DEFAULT_SEAT_ARIA_STRINGS, /* @ts-ignore */
393
+ ...(ngDevMode ? [{ debugName: "ariaStrings" }] : /* istanbul ignore next */ []));
394
+ // --- appearance -----------------------------------------------------------
395
+ colors = input({}, /* @ts-ignore */
396
+ ...(ngDevMode ? [{ debugName: "colors" }] : /* istanbul ignore next */ []));
397
+ /** Let an available seat take its `SeatType.color` (§4.7). */
398
+ typeColors = input(true, /* @ts-ignore */
399
+ ...(ngDevMode ? [{ debugName: "typeColors" }] : /* istanbul ignore next */ []));
400
+ /** Grid cell edge, in viewBox units. */
401
+ seatSize = input(28, /* @ts-ignore */
402
+ ...(ngDevMode ? [{ debugName: "seatSize" }] : /* istanbul ignore next */ []));
403
+ /** Gap between grid cells, in viewBox units. */
404
+ seatGap = input(6, /* @ts-ignore */
405
+ ...(ngDevMode ? [{ debugName: "seatGap" }] : /* istanbul ignore next */ []));
406
+ /** Freeform viewBox width; height follows the section's aspect ratio. */
407
+ freeformBasis = input(1000, /* @ts-ignore */
408
+ ...(ngDevMode ? [{ debugName: "freeformBasis" }] : /* istanbul ignore next */ []));
409
+ /**
410
+ * CSS pixels per viewBox unit. Caps each section at its natural size so a
411
+ * narrow section and a wide one in the same map draw seats the same size.
412
+ */
413
+ unitScale = input(1, /* @ts-ignore */
414
+ ...(ngDevMode ? [{ debugName: "unitScale" }] : /* istanbul ignore next */ []));
415
+ showSectionLabels = input(true, /* @ts-ignore */
416
+ ...(ngDevMode ? [{ debugName: "showSectionLabels" }] : /* istanbul ignore next */ []));
417
+ showLegend = input(false, /* @ts-ignore */
418
+ ...(ngDevMode ? [{ debugName: "showLegend" }] : /* istanbul ignore next */ []));
419
+ showLegendPrices = input(true, /* @ts-ignore */
420
+ ...(ngDevMode ? [{ debugName: "showLegendPrices" }] : /* istanbul ignore next */ []));
421
+ // --- section control ------------------------------------------------------
422
+ /** Render only these sections, in this order. Default: all, by `Section.index`. */
423
+ sectionIds = input(undefined, /* @ts-ignore */
424
+ ...(ngDevMode ? [{ debugName: "sectionIds" }] : /* istanbul ignore next */ []));
425
+ sectionOverrides = input({}, /* @ts-ignore */
426
+ ...(ngDevMode ? [{ debugName: "sectionOverrides" }] : /* istanbul ignore next */ []));
427
+ // --- interaction ----------------------------------------------------------
428
+ /** Statuses in which a seat may be picked. Default: `['available']`. */
429
+ selectableStatuses = input(['available'], /* @ts-ignore */
430
+ ...(ngDevMode ? [{ debugName: "selectableStatuses" }] : /* istanbul ignore next */ []));
431
+ /** `auto` selects a seat's companion closure together (§4.6). */
432
+ companionMode = input('auto', /* @ts-ignore */
433
+ ...(ngDevMode ? [{ debugName: "companionMode" }] : /* istanbul ignore next */ []));
434
+ maxSelection = input(undefined, /* @ts-ignore */
435
+ ...(ngDevMode ? [{ debugName: "maxSelection" }] : /* istanbul ignore next */ []));
436
+ /** A final say on selectability, applied after the status test. */
437
+ seatSelectable = input(undefined, /* @ts-ignore */
438
+ ...(ngDevMode ? [{ debugName: "seatSelectable" }] : /* istanbul ignore next */ []));
439
+ /** When false the map renders read-only: no pointer or keyboard interaction. */
440
+ interactive = input(true, /* @ts-ignore */
441
+ ...(ngDevMode ? [{ debugName: "interactive" }] : /* istanbul ignore next */ []));
442
+ /**
443
+ * Revert a lapsed `holdExpires` to available on a ticker (§5.1). Off by
444
+ * default — a renderer should stay passive unless asked.
445
+ */
446
+ expireHolds = input(false, /* @ts-ignore */
447
+ ...(ngDevMode ? [{ debugName: "expireHolds" }] : /* istanbul ignore next */ []));
448
+ expiryIntervalMs = input(1000, /* @ts-ignore */
449
+ ...(ngDevMode ? [{ debugName: "expiryIntervalMs" }] : /* istanbul ignore next */ []));
450
+ // --- validation -----------------------------------------------------------
451
+ validate = input('collect', /* @ts-ignore */
452
+ ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
453
+ // --- outputs --------------------------------------------------------------
454
+ seatSelect = output();
455
+ seatDeselect = output();
456
+ seatDisallowed = output();
457
+ seatFocus = output();
458
+ /** Emitted whenever the documents change, in `collect` mode. */
459
+ validationIssues = output();
460
+ // --- internal state -------------------------------------------------------
461
+ /**
462
+ * The seat holding each section's tab stop, keyed by section id.
463
+ *
464
+ * Per section, not per map: arrow keys never cross a section boundary, so a
465
+ * single map-wide tab stop would leave every section after the first
466
+ * unreachable by keyboard. Tab moves between sections, arrows move within.
467
+ */
468
+ focusedSeatIds = signal({}, /* @ts-ignore */
469
+ ...(ngDevMode ? [{ debugName: "focusedSeatIds" }] : /* istanbul ignore next */ []));
470
+ announcement = signal('', /* @ts-ignore */
471
+ ...(ngDevMode ? [{ debugName: "announcement" }] : /* istanbul ignore next */ []));
472
+ /** Bumped by the hold ticker to force the state to be re-derived. */
473
+ expiryTick = signal(0, /* @ts-ignore */
474
+ ...(ngDevMode ? [{ debugName: "expiryTick" }] : /* istanbul ignore next */ []));
475
+ constructor() {
476
+ effect(() => {
477
+ const issues = this.issues();
478
+ if (this.validate() !== 'off') {
479
+ this.validationIssues.emit(issues);
480
+ }
481
+ if (this.validate() === 'throw') {
482
+ const [first] = errorsOf(issues);
483
+ if (first) {
484
+ throw new KerusiValidationError(first.message, first.rule, first.id, issues);
485
+ }
486
+ }
487
+ });
488
+ // Keep every section's tab stop pointing at a seat that still exists.
489
+ effect(() => {
490
+ const model = this.renderMap();
491
+ const current = this.focusedSeatIds();
492
+ const next = {};
493
+ let changed = Object.keys(current).length !== model.sections.length;
494
+ for (const section of model.sections) {
495
+ const held = current[section.id];
496
+ const valid = held && section.seats.some((seat) => seat.id === held);
497
+ const resolved = valid ? held : section.seats[0]?.id;
498
+ if (resolved) {
499
+ next[section.id] = resolved;
500
+ changed ||= resolved !== held;
501
+ }
502
+ }
503
+ if (changed) {
504
+ this.focusedSeatIds.set(next);
505
+ }
506
+ });
507
+ effect((onCleanup) => {
508
+ if (!this.expireHolds()) {
509
+ return;
510
+ }
511
+ const handle = setInterval(() => this.expiryTick.update((n) => n + 1), this.expiryIntervalMs());
512
+ onCleanup(() => clearInterval(handle));
513
+ });
514
+ }
515
+ // --- derived model --------------------------------------------------------
516
+ issues = computed(() => this.validate() === 'off'
517
+ ? []
518
+ : validateDocumentSet({
519
+ map: this.map(),
520
+ state: this.state(),
521
+ session: this.session(),
522
+ }), /* @ts-ignore */
523
+ ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
524
+ /** The state actually rendered — with lapsed holds reverted when asked. */
525
+ effectiveState = computed(() => {
526
+ const state = this.state();
527
+ if (!state || !this.expireHolds()) {
528
+ return state;
529
+ }
530
+ this.expiryTick();
531
+ return expireHolds(state);
532
+ }, /* @ts-ignore */
533
+ ...(ngDevMode ? [{ debugName: "effectiveState" }] : /* istanbul ignore next */ []));
534
+ renderMap = computed(() => buildRenderModel(this.map(), this.effectiveState(), {
535
+ locale: this.locale(),
536
+ sectionIds: this.visibleSectionIds(),
537
+ selectableStatuses: this.selectableStatuses(),
538
+ seatSelectable: this.seatSelectable(),
539
+ }), /* @ts-ignore */
540
+ ...(ngDevMode ? [{ debugName: "renderMap" }] : /* istanbul ignore next */ []));
541
+ resolvedLocale = computed(() => resolveMapLocale(this.map(), this.locale()), /* @ts-ignore */
542
+ ...(ngDevMode ? [{ debugName: "resolvedLocale" }] : /* istanbul ignore next */ []));
543
+ isRtl = computed(() => this.rtl() === 'auto' ? isRtlLocale(this.resolvedLocale()) : this.rtl() === true, /* @ts-ignore */
544
+ ...(ngDevMode ? [{ debugName: "isRtl" }] : /* istanbul ignore next */ []));
545
+ resolvedColors = computed(() => resolveColors(this.colors()), /* @ts-ignore */
546
+ ...(ngDevMode ? [{ debugName: "resolvedColors" }] : /* istanbul ignore next */ []));
547
+ selectionSet = computed(() => new Set(this.selection()), /* @ts-ignore */
548
+ ...(ngDevMode ? [{ debugName: "selectionSet" }] : /* istanbul ignore next */ []));
549
+ /** Placed geometry per section, keyed by section id. */
550
+ layouts = computed(() => {
551
+ const overrides = this.sectionOverrides();
552
+ return new Map(this.renderMap().sections.map((section) => {
553
+ const override = overrides[section.id] ?? {};
554
+ return [
555
+ section.id,
556
+ computeSectionLayout(section, {
557
+ seatSize: override.seatSize ?? this.seatSize(),
558
+ seatGap: this.seatGap(),
559
+ freeformBasis: this.freeformBasis(),
560
+ aspectRatio: override.aspectRatio,
561
+ rtl: this.isRtl(),
562
+ }),
563
+ ];
564
+ }));
565
+ }, /* @ts-ignore */
566
+ ...(ngDevMode ? [{ debugName: "layouts" }] : /* istanbul ignore next */ []));
567
+ /** Navigation graphs per section — arrows move within, Tab moves between. */
568
+ graphs = computed(() => new Map(this.renderMap().sections.map((section) => [
569
+ section.id,
570
+ buildNavigationGraph(section, { rtl: this.isRtl() }),
571
+ ])), /* @ts-ignore */
572
+ ...(ngDevMode ? [{ debugName: "graphs" }] : /* istanbul ignore next */ []));
573
+ ariaLabels = computed(() => {
574
+ const locale = this.resolvedLocale();
575
+ const strings = this.ariaStrings();
576
+ return new Map([...this.renderMap().seatsById.values()].map((seat) => [
577
+ seat.id,
578
+ seatAriaLabel(seat, locale, strings),
579
+ ]));
580
+ }, /* @ts-ignore */
581
+ ...(ngDevMode ? [{ debugName: "ariaLabels" }] : /* istanbul ignore next */ []));
582
+ mapLabel = computed(() => this.renderMap().name ?? `Seat map ${this.renderMap().id}`, /* @ts-ignore */
583
+ ...(ngDevMode ? [{ debugName: "mapLabel" }] : /* istanbul ignore next */ []));
584
+ /** Live-region text: the running selection, or the last disallowed reason. */
585
+ liveMessage = computed(() => this.announcement(), /* @ts-ignore */
586
+ ...(ngDevMode ? [{ debugName: "liveMessage" }] : /* istanbul ignore next */ []));
587
+ /** The selection's seats and total, for callers that want it off the template. */
588
+ summary = computed(() => summarizeSelection(this.renderMap(), this.selection()), /* @ts-ignore */
589
+ ...(ngDevMode ? [{ debugName: "summary" }] : /* istanbul ignore next */ []));
590
+ // --- template helpers -----------------------------------------------------
591
+ layoutFor(section) {
592
+ return this.layouts().get(section.id);
593
+ }
594
+ labelFor(section) {
595
+ return this.sectionOverrides()[section.id]?.label ?? section.label;
596
+ }
597
+ labelId(section) {
598
+ return `kerusi-section-${this.renderMap().id}-${section.id}`;
599
+ }
600
+ /** The seat holding this section's tab stop. */
601
+ focusedSeatIn(section) {
602
+ return this.focusedSeatIds()[section.id] ?? null;
603
+ }
604
+ // --- interaction ----------------------------------------------------------
605
+ onSeatActivate(seatId) {
606
+ const model = this.renderMap();
607
+ const seat = model.seatsById.get(seatId);
608
+ if (!seat) {
609
+ return;
610
+ }
611
+ const outcome = toggleSeatSelection(this.selection(), seatId, {
612
+ seatsById: model.seatsById,
613
+ companionMode: this.companionMode(),
614
+ maxSelection: this.maxSelection(),
615
+ });
616
+ if (outcome.kind === 'disallowed') {
617
+ this.announce(disallowedAnnouncement(seat, outcome.reason, this.ariaStrings()));
618
+ this.seatDisallowed.emit({ seat, reason: outcome.reason });
619
+ return;
620
+ }
621
+ this.selection.set(outcome.selection);
622
+ const event = {
623
+ seat,
624
+ selection: outcome.selection,
625
+ changed: outcome.changed,
626
+ };
627
+ (outcome.kind === 'select' ? this.seatSelect : this.seatDeselect).emit(event);
628
+ this.announceSelection(outcome.selection);
629
+ }
630
+ onSeatFocused(seatId) {
631
+ const seat = this.renderMap().seatsById.get(seatId);
632
+ if (!seat) {
633
+ return;
634
+ }
635
+ this.setFocusedSeat(seat.sectionId, seatId);
636
+ this.seatFocus.emit(seat);
637
+ }
638
+ /**
639
+ * Arrow keys walk the navigation graph, which orders seats by `col` in grid
640
+ * and mixed sections — §4.3.1's logical adjacency — so the aisle that is a
641
+ * skipped column is stepped across rather than into.
642
+ */
643
+ onKeydown(section, event) {
644
+ const seatId = event.target?.dataset?.['seatId'];
645
+ if (!seatId) {
646
+ return;
647
+ }
648
+ const graph = this.graphs().get(section.id);
649
+ const neighbours = graph?.neighbours.get(seatId);
650
+ if (!graph) {
651
+ return;
652
+ }
653
+ let target;
654
+ switch (event.key) {
655
+ case 'ArrowLeft':
656
+ target = neighbours?.left;
657
+ break;
658
+ case 'ArrowRight':
659
+ target = neighbours?.right;
660
+ break;
661
+ case 'ArrowUp':
662
+ target = neighbours?.up;
663
+ break;
664
+ case 'ArrowDown':
665
+ target = neighbours?.down;
666
+ break;
667
+ case 'Home':
668
+ target = event.ctrlKey ? graph.first : neighbours?.rowStart;
669
+ break;
670
+ case 'End':
671
+ target = event.ctrlKey ? graph.last : neighbours?.rowEnd;
672
+ break;
673
+ case 'PageUp':
674
+ target = graph.first;
675
+ break;
676
+ case 'PageDown':
677
+ target = graph.last;
678
+ break;
679
+ case 'Enter':
680
+ case ' ':
681
+ event.preventDefault();
682
+ this.onSeatActivate(seatId);
683
+ return;
684
+ case 'Escape':
685
+ if (this.selection().length > 0) {
686
+ event.preventDefault();
687
+ this.selection.set([]);
688
+ this.announceSelection([]);
689
+ }
690
+ return;
691
+ default:
692
+ return;
693
+ }
694
+ if (target) {
695
+ event.preventDefault();
696
+ this.moveFocusTo(section.id, target);
697
+ }
698
+ }
699
+ setFocusedSeat(sectionId, seatId) {
700
+ if (this.focusedSeatIds()[sectionId] === seatId) {
701
+ return;
702
+ }
703
+ this.focusedSeatIds.update((all) => ({ ...all, [sectionId]: seatId }));
704
+ }
705
+ /**
706
+ * Moves the roving tab stop, then the DOM focus that follows it.
707
+ *
708
+ * Seat ids come from the document, so they cannot be interpolated into a
709
+ * selector unescaped — and `CSS.escape` is absent in jsdom and under SSR.
710
+ * Scanning the rendered nodes avoids both problems.
711
+ */
712
+ moveFocusTo(sectionId, seatId) {
713
+ this.setFocusedSeat(sectionId, seatId);
714
+ const nodes = this.host.nativeElement.querySelectorAll('[data-seat-id]');
715
+ for (const node of Array.from(nodes)) {
716
+ if (node.dataset['seatId'] === seatId) {
717
+ node.focus?.();
718
+ return;
719
+ }
720
+ }
721
+ }
722
+ announceSelection(selection) {
723
+ const strings = this.ariaStrings();
724
+ if (selection.length === 0) {
725
+ this.announce(strings.selectionEmpty);
726
+ return;
727
+ }
728
+ const { total } = summarizeSelection(this.renderMap(), selection);
729
+ this.announce(strings.selectionSummary(selection.length, formatTotal(total, this.resolvedLocale())));
730
+ }
731
+ announce(message) {
732
+ // Re-announce an identical message by clearing first; a live region that
733
+ // sees no text change stays silent.
734
+ this.announcement.set('');
735
+ queueMicrotask(() => this.announcement.set(message));
736
+ }
737
+ visibleSectionIds() {
738
+ const explicit = this.sectionIds();
739
+ const overrides = this.sectionOverrides();
740
+ const hidden = Object.entries(overrides)
741
+ .filter(([, o]) => o.hidden)
742
+ .map(([id]) => id);
743
+ if (explicit) {
744
+ return explicit.filter((id) => !hidden.includes(id));
745
+ }
746
+ if (hidden.length === 0) {
747
+ return undefined;
748
+ }
749
+ return (this.map().sections ?? []).map((s) => s.id).filter((id) => !hidden.includes(id));
750
+ }
751
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSeatmapComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
752
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: KerusiSeatmapComponent, isStandalone: true, selector: "kerusi-seatmap", inputs: { map: { classPropertyName: "map", publicName: "map", isSignal: true, isRequired: true, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, session: { classPropertyName: "session", publicName: "session", isSignal: true, isRequired: false, transformFunction: null }, selection: { classPropertyName: "selection", publicName: "selection", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, rtl: { classPropertyName: "rtl", publicName: "rtl", isSignal: true, isRequired: false, transformFunction: null }, ariaStrings: { classPropertyName: "ariaStrings", publicName: "ariaStrings", isSignal: true, isRequired: false, transformFunction: null }, colors: { classPropertyName: "colors", publicName: "colors", isSignal: true, isRequired: false, transformFunction: null }, typeColors: { classPropertyName: "typeColors", publicName: "typeColors", isSignal: true, isRequired: false, transformFunction: null }, seatSize: { classPropertyName: "seatSize", publicName: "seatSize", isSignal: true, isRequired: false, transformFunction: null }, seatGap: { classPropertyName: "seatGap", publicName: "seatGap", isSignal: true, isRequired: false, transformFunction: null }, freeformBasis: { classPropertyName: "freeformBasis", publicName: "freeformBasis", isSignal: true, isRequired: false, transformFunction: null }, unitScale: { classPropertyName: "unitScale", publicName: "unitScale", isSignal: true, isRequired: false, transformFunction: null }, showSectionLabels: { classPropertyName: "showSectionLabels", publicName: "showSectionLabels", isSignal: true, isRequired: false, transformFunction: null }, showLegend: { classPropertyName: "showLegend", publicName: "showLegend", isSignal: true, isRequired: false, transformFunction: null }, showLegendPrices: { classPropertyName: "showLegendPrices", publicName: "showLegendPrices", isSignal: true, isRequired: false, transformFunction: null }, sectionIds: { classPropertyName: "sectionIds", publicName: "sectionIds", isSignal: true, isRequired: false, transformFunction: null }, sectionOverrides: { classPropertyName: "sectionOverrides", publicName: "sectionOverrides", isSignal: true, isRequired: false, transformFunction: null }, selectableStatuses: { classPropertyName: "selectableStatuses", publicName: "selectableStatuses", isSignal: true, isRequired: false, transformFunction: null }, companionMode: { classPropertyName: "companionMode", publicName: "companionMode", isSignal: true, isRequired: false, transformFunction: null }, maxSelection: { classPropertyName: "maxSelection", publicName: "maxSelection", isSignal: true, isRequired: false, transformFunction: null }, seatSelectable: { classPropertyName: "seatSelectable", publicName: "seatSelectable", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, expireHolds: { classPropertyName: "expireHolds", publicName: "expireHolds", isSignal: true, isRequired: false, transformFunction: null }, expiryIntervalMs: { classPropertyName: "expiryIntervalMs", publicName: "expiryIntervalMs", isSignal: true, isRequired: false, transformFunction: null }, validate: { classPropertyName: "validate", publicName: "validate", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selection: "selectionChange", seatSelect: "seatSelect", seatDeselect: "seatDeselect", seatDisallowed: "seatDisallowed", seatFocus: "seatFocus", validationIssues: "validationIssues" }, ngImport: i0, template: "<div\r\n class=\"kerusi-seatmap\"\r\n role=\"group\"\r\n [attr.aria-label]=\"mapLabel()\"\r\n [attr.dir]=\"isRtl() ? 'rtl' : null\"\r\n [attr.lang]=\"resolvedLocale()\"\r\n>\r\n @for (section of renderMap().sections; track section.id) {\r\n <section\r\n class=\"kerusi-seatmap__section kerusi-seatmap__section--{{ section.layoutMode }}\"\r\n role=\"group\"\r\n [attr.aria-labelledby]=\"labelFor(section) ? labelId(section) : null\"\r\n [attr.aria-label]=\"labelFor(section) ? null : section.id\"\r\n >\r\n @if (showSectionLabels() && labelFor(section)) {\r\n <h3 class=\"kerusi-section-label\" [id]=\"labelId(section)\">{{ labelFor(section) }}</h3>\r\n }\r\n\r\n <kerusi-section\r\n [section]=\"section\"\r\n [layout]=\"layoutFor(section)\"\r\n [selection]=\"selectionSet()\"\r\n [focusedSeatId]=\"focusedSeatIn(section)\"\r\n [ariaLabels]=\"ariaLabels()\"\r\n [colors]=\"resolvedColors()\"\r\n [typeColors]=\"typeColors()\"\r\n [unitScale]=\"unitScale()\"\r\n [interactive]=\"interactive()\"\r\n (seatActivate)=\"onSeatActivate($event)\"\r\n (seatFocused)=\"onSeatFocused($event)\"\r\n (seatKeydown)=\"onKeydown(section, $event)\"\r\n />\r\n </section>\r\n }\r\n\r\n @if (showLegend()) {\r\n <kerusi-legend\r\n [legend]=\"renderMap().legend\"\r\n [locale]=\"resolvedLocale()\"\r\n [colors]=\"resolvedColors()\"\r\n [typeColors]=\"typeColors()\"\r\n [showPrices]=\"showLegendPrices()\"\r\n />\r\n }\r\n\r\n <p class=\"kerusi-sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{{ liveMessage() }}</p>\r\n</div>\r\n", styles: [":host{display:block}.kerusi-seatmap{display:flex;flex-direction:column;gap:1.25rem}.kerusi-seatmap__section{display:flex;flex-direction:column;gap:.4rem}.kerusi-section-label{margin:0;font-size:.85rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;opacity:.75}.kerusi-sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}\n"], dependencies: [{ kind: "component", type: KerusiSectionComponent, selector: "kerusi-section", inputs: ["section", "layout", "selection", "focusedSeatId", "ariaLabels", "colors", "typeColors", "interactive", "unitScale"], outputs: ["seatActivate", "seatFocused", "seatKeydown"] }, { kind: "component", type: KerusiLegendComponent, selector: "kerusi-legend", inputs: ["legend", "locale", "colors", "typeColors", "showPrices", "hideUnusedTypes", "headingSeatTypes", "headingAvailability"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
753
+ }
754
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSeatmapComponent, decorators: [{
755
+ type: Component,
756
+ args: [{ selector: 'kerusi-seatmap', changeDetection: ChangeDetectionStrategy.OnPush, imports: [KerusiSectionComponent, KerusiLegendComponent], template: "<div\r\n class=\"kerusi-seatmap\"\r\n role=\"group\"\r\n [attr.aria-label]=\"mapLabel()\"\r\n [attr.dir]=\"isRtl() ? 'rtl' : null\"\r\n [attr.lang]=\"resolvedLocale()\"\r\n>\r\n @for (section of renderMap().sections; track section.id) {\r\n <section\r\n class=\"kerusi-seatmap__section kerusi-seatmap__section--{{ section.layoutMode }}\"\r\n role=\"group\"\r\n [attr.aria-labelledby]=\"labelFor(section) ? labelId(section) : null\"\r\n [attr.aria-label]=\"labelFor(section) ? null : section.id\"\r\n >\r\n @if (showSectionLabels() && labelFor(section)) {\r\n <h3 class=\"kerusi-section-label\" [id]=\"labelId(section)\">{{ labelFor(section) }}</h3>\r\n }\r\n\r\n <kerusi-section\r\n [section]=\"section\"\r\n [layout]=\"layoutFor(section)\"\r\n [selection]=\"selectionSet()\"\r\n [focusedSeatId]=\"focusedSeatIn(section)\"\r\n [ariaLabels]=\"ariaLabels()\"\r\n [colors]=\"resolvedColors()\"\r\n [typeColors]=\"typeColors()\"\r\n [unitScale]=\"unitScale()\"\r\n [interactive]=\"interactive()\"\r\n (seatActivate)=\"onSeatActivate($event)\"\r\n (seatFocused)=\"onSeatFocused($event)\"\r\n (seatKeydown)=\"onKeydown(section, $event)\"\r\n />\r\n </section>\r\n }\r\n\r\n @if (showLegend()) {\r\n <kerusi-legend\r\n [legend]=\"renderMap().legend\"\r\n [locale]=\"resolvedLocale()\"\r\n [colors]=\"resolvedColors()\"\r\n [typeColors]=\"typeColors()\"\r\n [showPrices]=\"showLegendPrices()\"\r\n />\r\n }\r\n\r\n <p class=\"kerusi-sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{{ liveMessage() }}</p>\r\n</div>\r\n", styles: [":host{display:block}.kerusi-seatmap{display:flex;flex-direction:column;gap:1.25rem}.kerusi-seatmap__section{display:flex;flex-direction:column;gap:.4rem}.kerusi-section-label{margin:0;font-size:.85rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;opacity:.75}.kerusi-sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}\n"] }]
757
+ }], ctorParameters: () => [], propDecorators: { map: [{ type: i0.Input, args: [{ isSignal: true, alias: "map", required: true }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], session: [{ type: i0.Input, args: [{ isSignal: true, alias: "session", required: false }] }], selection: [{ type: i0.Input, args: [{ isSignal: true, alias: "selection", required: false }] }, { type: i0.Output, args: ["selectionChange"] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], rtl: [{ type: i0.Input, args: [{ isSignal: true, alias: "rtl", required: false }] }], ariaStrings: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaStrings", required: false }] }], colors: [{ type: i0.Input, args: [{ isSignal: true, alias: "colors", required: false }] }], typeColors: [{ type: i0.Input, args: [{ isSignal: true, alias: "typeColors", required: false }] }], seatSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatSize", required: false }] }], seatGap: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatGap", required: false }] }], freeformBasis: [{ type: i0.Input, args: [{ isSignal: true, alias: "freeformBasis", required: false }] }], unitScale: [{ type: i0.Input, args: [{ isSignal: true, alias: "unitScale", required: false }] }], showSectionLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSectionLabels", required: false }] }], showLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegend", required: false }] }], showLegendPrices: [{ type: i0.Input, args: [{ isSignal: true, alias: "showLegendPrices", required: false }] }], sectionIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "sectionIds", required: false }] }], sectionOverrides: [{ type: i0.Input, args: [{ isSignal: true, alias: "sectionOverrides", required: false }] }], selectableStatuses: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectableStatuses", required: false }] }], companionMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "companionMode", required: false }] }], maxSelection: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSelection", required: false }] }], seatSelectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatSelectable", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], expireHolds: [{ type: i0.Input, args: [{ isSignal: true, alias: "expireHolds", required: false }] }], expiryIntervalMs: [{ type: i0.Input, args: [{ isSignal: true, alias: "expiryIntervalMs", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], seatSelect: [{ type: i0.Output, args: ["seatSelect"] }], seatDeselect: [{ type: i0.Output, args: ["seatDeselect"] }], seatDisallowed: [{ type: i0.Output, args: ["seatDisallowed"] }], seatFocus: [{ type: i0.Output, args: ["seatFocus"] }], validationIssues: [{ type: i0.Output, args: ["validationIssues"] }] } });
758
+ function formatTotal(total, locale) {
759
+ return total ? formatMoney(total, locale) : '';
760
+ }
761
+
762
+ /**
763
+ * The Kerusi-native seat map: `<kerusi-seatmap>`, its section renderer, and an
764
+ * optional legend.
765
+ *
766
+ * The selection, theming and announcement helpers they are built from are
767
+ * framework-free and live in `@kerusiweb/core`.
768
+ */
769
+
770
+ /**
771
+ * A signal-backed live view of a {@link KerusiState}.
772
+ *
773
+ * Construct it directly — `new KerusiStateStore(initialState)` — and feed it
774
+ * deltas from whatever transport you use. It is deliberately not an
775
+ * `@Injectable`: this is a rendering library, and an application that already
776
+ * owns its availability state should keep owning it.
777
+ */
778
+ class KerusiStateStore {
779
+ _state;
780
+ _needsRefetch = signal(false, /* @ts-ignore */
781
+ ...(ngDevMode ? [{ debugName: "_needsRefetch" }] : /* istanbul ignore next */ []));
782
+ _now;
783
+ clock;
784
+ sequenceOf;
785
+ lastSequence;
786
+ constructor(initial, opts = {}) {
787
+ this._state = signal(initial, /* @ts-ignore */
788
+ ...(ngDevMode ? [{ debugName: "_state" }] : /* istanbul ignore next */ []));
789
+ this.clock = opts.now ?? (() => new Date());
790
+ this.sequenceOf = opts.sequenceOf ?? DEFAULT_SEQUENCE_READER;
791
+ this._now = signal(this.clock(), /* @ts-ignore */
792
+ ...(ngDevMode ? [{ debugName: "_now" }] : /* istanbul ignore next */ []));
793
+ }
794
+ /** The current availability snapshot. */
795
+ get state() {
796
+ return this._state.asReadonly();
797
+ }
798
+ /** The timestamp of the most recently applied document. */
799
+ updatedAt = computed(() => this._state().updatedAt, /* @ts-ignore */
800
+ ...(ngDevMode ? [{ debugName: "updatedAt" }] : /* istanbul ignore next */ []));
801
+ /**
802
+ * True once a gap was detected in the delta stream. The consumer SHOULD
803
+ * re-fetch a full `KerusiState` and hand it to {@link reset} (§5.2).
804
+ */
805
+ get needsRefetch() {
806
+ return this._needsRefetch.asReadonly();
807
+ }
808
+ /** Every currently-held seat, with the time left on its hold. */
809
+ heldSeats = computed(() => heldSeats(this._state(), this._now()), /* @ts-ignore */
810
+ ...(ngDevMode ? [{ debugName: "heldSeats" }] : /* istanbul ignore next */ []));
811
+ /** Offers a delta to the store. Returns what happened to it. */
812
+ apply(delta) {
813
+ const result = applyStateDeltaOrdered(this._state(), delta, {
814
+ sequenceOf: this.sequenceOf,
815
+ lastSequence: this.lastSequence,
816
+ });
817
+ if (result.outcome === 'applied' || result.outcome === 'gap') {
818
+ this._state.set(result.state);
819
+ const seq = this.sequenceOf(delta);
820
+ if (seq !== undefined) {
821
+ this.lastSequence = seq;
822
+ }
823
+ }
824
+ if (result.outcome === 'gap') {
825
+ this._needsRefetch.set(true);
826
+ }
827
+ return result;
828
+ }
829
+ /**
830
+ * Replaces the state wholesale — the post-refetch path. Clears the
831
+ * refetch flag and forgets the delta sequence.
832
+ */
833
+ reset(state) {
834
+ this._state.set(state);
835
+ this._needsRefetch.set(false);
836
+ this.lastSequence = undefined;
837
+ this._now.set(this.clock());
838
+ }
839
+ /** Reverts lapsed holds and refreshes `heldSeats` countdowns. */
840
+ tick() {
841
+ const now = this.clock();
842
+ this._now.set(now);
843
+ const next = expireHolds(this._state(), now);
844
+ if (next !== this._state()) {
845
+ this._state.set(next);
846
+ }
847
+ }
848
+ /**
849
+ * Runs {@link tick} on an interval. Returns a function that stops it — call
850
+ * that from `DestroyRef.onDestroy` or `ngOnDestroy`.
851
+ */
852
+ startExpiryTicker(intervalMs = 1000) {
853
+ const handle = setInterval(() => this.tick(), intervalMs);
854
+ return () => clearInterval(handle);
855
+ }
856
+ }
857
+
858
+ /*
859
+ * Public API Surface of @kerusiweb/angular
860
+ *
861
+ * The Angular binding only. Document types, the render model and the view
862
+ * helpers live in @kerusiweb/core — import them from there.
863
+ */
864
+ // --- Components -------------------------------------------------------------
865
+
866
+ /**
867
+ * Generated bundle index. Do not edit.
868
+ */
869
+
870
+ export { KerusiLegendComponent, KerusiSeatmapComponent, KerusiSectionComponent, KerusiStateStore };
871
+ //# sourceMappingURL=kerusiweb-angular.mjs.map