@kerusiweb/angular 2.1.0 → 2.3.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.
@@ -1,6 +1,7 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
1
2
  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';
3
+ import { input, output, computed, ChangeDetectionStrategy, Component, inject, TemplateRef, Directive, ElementRef, model, contentChild, signal, viewChild, effect, untracked } from '@angular/core';
4
+ import { DEFAULT_KERUSI_COLORS, resolveSeatStyle, resolveSeatGlyph, cssVar, elementStyle, elementFill, elementTextFill, screenPath, legendSwatchFill, legendSwatchPath, legendSwatchText, seatBodyPath, seatSelectedFrame, seatOccupantPath, seatOccupantStroke, seatWashFill, occupantFillFor, occupantStrokeFor, seatCoreFill, formatMoney, DEFAULT_SEAT_ARIA_STRINGS, DEFAULT_MAX_ZOOM, FIT_VIEWPORT, IDLE_GESTURE, errorsOf, KerusiValidationError, viewportsEqual, validateDocumentSet, expireHolds, buildRenderModel, resolveMapLocale, isRtlLocale, resolveColors, zoomLimits, resolveControlStrings, controlStyle, viewportTransform, viewportTouchAction, computeSectionLayout, buildNavigationGraph, seatAriaLabel, summarizeSelection, toggleSeatSelection, disallowedAnnouncement, fitViewport, stepViewport, clampViewport, revealRect, reduceGesture, wheelZoomFactor, zoomViewportBy, zoomViewportTo, isTextEntryTarget, keyboardViewport, DEFAULT_SEQUENCE_READER, heldSeats, applyStateDeltaOrdered } from '@kerusiweb/core';
4
5
 
5
6
  /**
6
7
  * Draws one Kerusi `Section` as a single `<svg>`.
@@ -32,6 +33,15 @@ class KerusiSectionComponent {
32
33
  /** Whether an available seat may take its `SeatType.color` (§4.7). */
33
34
  typeColors = input(true, /* @ts-ignore */
34
35
  ...(ngDevMode ? [{ debugName: "typeColors" }] : /* istanbul ignore next */ []));
36
+ /** Styles for every seat of a type, keyed by `SeatType.id`. */
37
+ seatTypeStyles = input(undefined, /* @ts-ignore */
38
+ ...(ngDevMode ? [{ debugName: "seatTypeStyles" }] : /* istanbul ignore next */ []));
39
+ /** A style for one seat, laid over its type's. */
40
+ seatStyle = input(undefined, /* @ts-ignore */
41
+ ...(ngDevMode ? [{ debugName: "seatStyle" }] : /* istanbul ignore next */ []));
42
+ /** Draws the inside of each seat in place of the stock glyph. */
43
+ seatTemplate = input(undefined, /* @ts-ignore */
44
+ ...(ngDevMode ? [{ debugName: "seatTemplate" }] : /* istanbul ignore next */ []));
35
45
  /** When false, seats render but do not respond to pointer or keyboard. */
36
46
  interactive = input(true, /* @ts-ignore */
37
47
  ...(ngDevMode ? [{ debugName: "interactive" }] : /* istanbul ignore next */ []));
@@ -54,114 +64,29 @@ class KerusiSectionComponent {
54
64
  /** The section's natural width in CSS pixels; it never draws wider. */
55
65
  naturalWidth = computed(() => this.layout().width * this.unitScale(), /* @ts-ignore */
56
66
  ...(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
67
  /**
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.
68
+ * Every seat with its glyph resolved, once per change of layout, selection or
69
+ * theme rather than once per binding per change detection.
152
70
  */
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());
71
+ seats = computed(() => {
72
+ const selection = this.selection();
73
+ const colors = this.colors();
74
+ const typeColors = this.typeColors();
75
+ const typeStyles = this.seatTypeStyles();
76
+ const seatStyle = this.seatStyle();
77
+ return this.layout().seats.map((placed) => {
78
+ const selected = selection.has(placed.seat.id);
79
+ const style = resolveSeatStyle(placed.seat, selected, typeStyles, seatStyle);
80
+ return {
81
+ placed,
82
+ selected,
83
+ glyph: resolveSeatGlyph(placed, selected, colors, typeColors, style),
84
+ };
85
+ });
86
+ }, /* @ts-ignore */
87
+ ...(ngDevMode ? [{ debugName: "seats" }] : /* istanbul ignore next */ []));
88
+ backdrop() {
89
+ return cssVar('backdrop', this.colors().backdrop);
165
90
  }
166
91
  /** The section's single tab stop; every other seat is reachable by arrow key. */
167
92
  tabIndex(placed) {
@@ -173,19 +98,6 @@ class KerusiSectionComponent {
173
98
  ariaLabel(placed) {
174
99
  return this.ariaLabels().get(placed.seat.id) ?? placed.seat.label;
175
100
  }
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
101
  /** SVG rotate() about the item's center; null when it does not rotate. */
190
102
  transform(item) {
191
103
  return item.rotation ? `rotate(${item.rotation} ${item.centerX} ${item.centerY})` : null;
@@ -237,12 +149,12 @@ class KerusiSectionComponent {
237
149
  }
238
150
  }
239
151
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSectionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
240
- 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 @if (drawsElement(placed)) {\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 ('text') {}\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]=\"labelTransform(placed)\"\r\n >\r\n {{ placed.element.label }}\r\n </text>\r\n }\r\n </g>\r\n }\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 });
152
+ 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 }, seatTypeStyles: { classPropertyName: "seatTypeStyles", publicName: "seatTypeStyles", isSignal: true, isRequired: false, transformFunction: null }, seatStyle: { classPropertyName: "seatStyle", publicName: "seatStyle", isSignal: true, isRequired: false, transformFunction: null }, seatTemplate: { classPropertyName: "seatTemplate", publicName: "seatTemplate", 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 @if (drawsElement(placed)) {\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 ('text') {}\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]=\"labelTransform(placed)\"\r\n >\r\n {{ placed.element.label }}\r\n </text>\r\n }\r\n </g>\r\n }\r\n }\r\n\r\n <!-- The stock seat drawing. A template rather than inline markup so that a\r\n custom seat template can render it too, as `defaultContent`. Declared\r\n inside the <svg>, so its elements are created in the SVG namespace. -->\r\n <ng-template #stockSeat let-seat>\r\n @let glyph = seat.glyph;\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]=\"glyph.body.d\" [style.fill]=\"glyph.body.fill\" />\r\n @if (glyph.wash; as wash) {\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 class=\"kerusi-seat__wash\" [attr.d]=\"wash.d\" [style.fill]=\"wash.fill\" />\r\n }\r\n @if (glyph.core; as core) {\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]=\"core.d\" [style.fill]=\"core.fill\" />\r\n }\r\n @if (glyph.occupant; as occupant) {\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--{{ occupant.variant }}\"\r\n [attr.d]=\"occupant.d\"\r\n [attr.stroke-width]=\"occupant.strokeWidth\"\r\n [style.fill]=\"occupant.fill\"\r\n [style.stroke]=\"occupant.stroke\"\r\n />\r\n }\r\n <!-- The halo colour and width are bound here rather than in the\r\n stylesheet: a CSS `var(--kerusi-selected-fg, \u2026)` rule would carry\r\n the library default as its fallback and skip the [colors] tier. -->\r\n <text\r\n class=\"kerusi-seat__label\"\r\n text-anchor=\"middle\"\r\n dominant-baseline=\"central\"\r\n [attr.x]=\"glyph.label.x\"\r\n [attr.y]=\"glyph.label.y\"\r\n [attr.font-size]=\"glyph.label.fontSize\"\r\n [style.fill]=\"glyph.label.fill\"\r\n [style.stroke]=\"glyph.label.halo\"\r\n [attr.stroke-width]=\"glyph.label.haloWidth\"\r\n [attr.transform]=\"glyph.label.transform\"\r\n >\r\n {{ glyph.label.text }}\r\n </text>\r\n @if (glyph.wheelchair; as wheelchair) {\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]=\"wheelchair.cx\"\r\n [attr.cy]=\"wheelchair.cy\"\r\n [attr.r]=\"wheelchair.r\"\r\n [style.fill]=\"wheelchair.fill\"\r\n />\r\n }\r\n </ng-template>\r\n\r\n @for (entry of seats(); track entry.placed.seat.id) {\r\n @let placed = entry.placed;\r\n @let glyph = entry.glyph;\r\n @let ctx =\r\n {\r\n placed: placed,\r\n seat: placed.seat,\r\n selected: entry.selected,\r\n glyph: glyph,\r\n defaultContent: stockSeat,\r\n };\r\n <g\r\n [class]=\"glyph.className\"\r\n role=\"button\"\r\n [attr.tabindex]=\"tabIndex(placed)\"\r\n [attr.aria-label]=\"ariaLabel(placed)\"\r\n [attr.aria-pressed]=\"entry.selected\"\r\n [attr.aria-disabled]=\"!placed.seat.selectable\"\r\n [attr.data-seat-id]=\"placed.seat.id\"\r\n [attr.data-seat-type]=\"glyph.data.type\"\r\n [attr.data-seat-status]=\"glyph.data.status\"\r\n [attr.data-section-id]=\"glyph.data.section\"\r\n [attr.data-seat-row]=\"glyph.data.row\"\r\n [attr.data-seat-attributes]=\"glyph.data.attributes\"\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 @if (seatTemplate(); as custom) {\r\n <ng-container *ngTemplateOutlet=\"custom; context: { $implicit: ctx }\" />\r\n <!-- Drawn over the custom content, because that content may have no\r\n .kerusi-seat__box for the stock focus ring to stroke. Only visible\r\n on :focus-visible. -->\r\n <path class=\"kerusi-seat__focus\" [attr.d]=\"glyph.body.d\" />\r\n } @else {\r\n <ng-container *ngTemplateOutlet=\"stockSeat; context: { $implicit: ctx }\" />\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-seat__focus{fill:none;stroke:none;pointer-events:none}.kerusi-seat:focus-visible .kerusi-seat__focus{stroke:var(--kerusi-focus-ring, var(--kerusi-focus-ring-input, currentColor));stroke-width:2.5}.kerusi-element__void{stroke-width:1;stroke-dasharray:4 3;opacity:.7}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
241
153
  }
242
154
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSectionComponent, decorators: [{
243
155
  type: Component,
244
- 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 @if (drawsElement(placed)) {\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 ('text') {}\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]=\"labelTransform(placed)\"\r\n >\r\n {{ placed.element.label }}\r\n </text>\r\n }\r\n </g>\r\n }\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"] }]
245
- }], 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"] }] } });
156
+ args: [{ selector: 'kerusi-section', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet], 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 @if (drawsElement(placed)) {\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 ('text') {}\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]=\"labelTransform(placed)\"\r\n >\r\n {{ placed.element.label }}\r\n </text>\r\n }\r\n </g>\r\n }\r\n }\r\n\r\n <!-- The stock seat drawing. A template rather than inline markup so that a\r\n custom seat template can render it too, as `defaultContent`. Declared\r\n inside the <svg>, so its elements are created in the SVG namespace. -->\r\n <ng-template #stockSeat let-seat>\r\n @let glyph = seat.glyph;\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]=\"glyph.body.d\" [style.fill]=\"glyph.body.fill\" />\r\n @if (glyph.wash; as wash) {\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 class=\"kerusi-seat__wash\" [attr.d]=\"wash.d\" [style.fill]=\"wash.fill\" />\r\n }\r\n @if (glyph.core; as core) {\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]=\"core.d\" [style.fill]=\"core.fill\" />\r\n }\r\n @if (glyph.occupant; as occupant) {\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--{{ occupant.variant }}\"\r\n [attr.d]=\"occupant.d\"\r\n [attr.stroke-width]=\"occupant.strokeWidth\"\r\n [style.fill]=\"occupant.fill\"\r\n [style.stroke]=\"occupant.stroke\"\r\n />\r\n }\r\n <!-- The halo colour and width are bound here rather than in the\r\n stylesheet: a CSS `var(--kerusi-selected-fg, \u2026)` rule would carry\r\n the library default as its fallback and skip the [colors] tier. -->\r\n <text\r\n class=\"kerusi-seat__label\"\r\n text-anchor=\"middle\"\r\n dominant-baseline=\"central\"\r\n [attr.x]=\"glyph.label.x\"\r\n [attr.y]=\"glyph.label.y\"\r\n [attr.font-size]=\"glyph.label.fontSize\"\r\n [style.fill]=\"glyph.label.fill\"\r\n [style.stroke]=\"glyph.label.halo\"\r\n [attr.stroke-width]=\"glyph.label.haloWidth\"\r\n [attr.transform]=\"glyph.label.transform\"\r\n >\r\n {{ glyph.label.text }}\r\n </text>\r\n @if (glyph.wheelchair; as wheelchair) {\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]=\"wheelchair.cx\"\r\n [attr.cy]=\"wheelchair.cy\"\r\n [attr.r]=\"wheelchair.r\"\r\n [style.fill]=\"wheelchair.fill\"\r\n />\r\n }\r\n </ng-template>\r\n\r\n @for (entry of seats(); track entry.placed.seat.id) {\r\n @let placed = entry.placed;\r\n @let glyph = entry.glyph;\r\n @let ctx =\r\n {\r\n placed: placed,\r\n seat: placed.seat,\r\n selected: entry.selected,\r\n glyph: glyph,\r\n defaultContent: stockSeat,\r\n };\r\n <g\r\n [class]=\"glyph.className\"\r\n role=\"button\"\r\n [attr.tabindex]=\"tabIndex(placed)\"\r\n [attr.aria-label]=\"ariaLabel(placed)\"\r\n [attr.aria-pressed]=\"entry.selected\"\r\n [attr.aria-disabled]=\"!placed.seat.selectable\"\r\n [attr.data-seat-id]=\"placed.seat.id\"\r\n [attr.data-seat-type]=\"glyph.data.type\"\r\n [attr.data-seat-status]=\"glyph.data.status\"\r\n [attr.data-section-id]=\"glyph.data.section\"\r\n [attr.data-seat-row]=\"glyph.data.row\"\r\n [attr.data-seat-attributes]=\"glyph.data.attributes\"\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 @if (seatTemplate(); as custom) {\r\n <ng-container *ngTemplateOutlet=\"custom; context: { $implicit: ctx }\" />\r\n <!-- Drawn over the custom content, because that content may have no\r\n .kerusi-seat__box for the stock focus ring to stroke. Only visible\r\n on :focus-visible. -->\r\n <path class=\"kerusi-seat__focus\" [attr.d]=\"glyph.body.d\" />\r\n } @else {\r\n <ng-container *ngTemplateOutlet=\"stockSeat; context: { $implicit: ctx }\" />\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-seat__focus{fill:none;stroke:none;pointer-events:none}.kerusi-seat:focus-visible .kerusi-seat__focus{stroke:var(--kerusi-focus-ring, var(--kerusi-focus-ring-input, currentColor));stroke-width:2.5}.kerusi-element__void{stroke-width:1;stroke-dasharray:4 3;opacity:.7}\n"] }]
157
+ }], 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 }] }], seatTypeStyles: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatTypeStyles", required: false }] }], seatStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatStyle", required: false }] }], seatTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatTemplate", 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"] }] } });
246
158
 
247
159
  /**
248
160
  * A key for a seat map: its seat types on one side, its availability states on
@@ -268,6 +180,9 @@ class KerusiLegendComponent {
268
180
  /** Whether an available seat takes its type color — mirrors the seatmap input. */
269
181
  typeColors = input(true, /* @ts-ignore */
270
182
  ...(ngDevMode ? [{ debugName: "typeColors" }] : /* istanbul ignore next */ []));
183
+ /** Mirrors the seatmap input: a type's style is what its swatch shows. */
184
+ seatTypeStyles = input(undefined, /* @ts-ignore */
185
+ ...(ngDevMode ? [{ debugName: "seatTypeStyles" }] : /* istanbul ignore next */ []));
271
186
  showPrices = input(true, /* @ts-ignore */
272
187
  ...(ngDevMode ? [{ debugName: "showPrices" }] : /* istanbul ignore next */ []));
273
188
  /** Hide types no seat in the rendered map actually uses. */
@@ -301,13 +216,17 @@ class KerusiLegendComponent {
301
216
  ];
302
217
  }, /* @ts-ignore */
303
218
  ...(ngDevMode ? [{ debugName: "statuses" }] : /* istanbul ignore next */ []));
219
+ // Same token and fallback as an available seat of the type, so a rule that
220
+ // sets `--kerusi-seat-fill` on the seats and the legend items keeps them in step.
304
221
  swatch(entry) {
305
- return (this.typeColors() && entry.color) || cssVar('availableBg', this.colors().availableBg);
222
+ return legendSwatchFill(entry, this.colors(), this.typeColors(), this.seatTypeStyles()?.[entry.id]);
223
+ }
224
+ /** The swatch outline for a type styled with its own shape; `undefined` keeps the square. */
225
+ typeShape(entry) {
226
+ return legendSwatchPath(this.seatTypeStyles()?.[entry.id], this.glyphSize);
306
227
  }
307
228
  swatchText(entry) {
308
- const typeColor = this.typeColors() && entry.color;
309
- // Mirrors `seatTextFill`: only a document-supplied color needs guessing at.
310
- return typeColor ? readableOn(typeColor) : cssVar('availableFg', this.colors().availableFg);
229
+ return legendSwatchText(entry, this.colors(), this.typeColors(), this.seatTypeStyles()?.[entry.id]);
311
230
  }
312
231
  // --- status swatches -------------------------------------------------------
313
232
  //
@@ -355,12 +274,44 @@ class KerusiLegendComponent {
355
274
  return entry.price ? formatMoney(entry.price, this.locale()) : '';
356
275
  }
357
276
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiLegendComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
358
- 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 });
277
+ 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 }, seatTypeStyles: { classPropertyName: "seatTypeStyles", publicName: "seatTypeStyles", 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\" [attr.data-seat-type]=\"entry.id\">\r\n @if (typeShape(entry); as d) {\r\n <!-- A type styled with its own shape keys that shape, not a square. -->\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]=\"d\" [style.fill]=\"swatch(entry)\" />\r\n </svg>\r\n } @else {\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 }\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 });
359
278
  }
360
279
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiLegendComponent, decorators: [{
361
280
  type: Component,
362
- 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"] }]
363
- }], 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 }] }] } });
281
+ 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\" [attr.data-seat-type]=\"entry.id\">\r\n @if (typeShape(entry); as d) {\r\n <!-- A type styled with its own shape keys that shape, not a square. -->\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]=\"d\" [style.fill]=\"swatch(entry)\" />\r\n </svg>\r\n } @else {\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 }\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"] }]
282
+ }], 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 }] }], seatTypeStyles: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatTypeStyles", 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 }] }] } });
283
+
284
+ /**
285
+ * Marks an `<ng-template>` inside `<kerusi-seatmap>` as the drawing for every
286
+ * seat. The library keeps each seat's `<g>`, with its role, ARIA state, tab
287
+ * stop, rotation and event handlers, and stamps this template inside it.
288
+ *
289
+ * The template is declared outside any `<svg>`, so Angular cannot tell its
290
+ * elements are SVG: prefix each one with `svg:` (`<svg:path>`, `<svg:image>`),
291
+ * or it is created as an unknown HTML element and draws nothing.
292
+ *
293
+ * ```html
294
+ * <kerusi-seatmap [map]="map">
295
+ * <ng-template kerusiSeat let-seat>
296
+ * <ng-container *ngTemplateOutlet="seat.defaultContent; context: { $implicit: seat }" />
297
+ * <svg:circle [attr.cx]="seat.glyph.box.x" [attr.cy]="seat.glyph.box.y" r="3" />
298
+ * </ng-template>
299
+ * </kerusi-seatmap>
300
+ * ```
301
+ */
302
+ class KerusiSeatTemplateDirective {
303
+ template = inject(TemplateRef);
304
+ /** Types `let-seat` as a {@link SeatTemplateContext}. */
305
+ static ngTemplateContextGuard(_dir, ctx) {
306
+ return true;
307
+ }
308
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSeatTemplateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
309
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.2", type: KerusiSeatTemplateDirective, isStandalone: true, selector: "ng-template[kerusiSeat]", ngImport: i0 });
310
+ }
311
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSeatTemplateDirective, decorators: [{
312
+ type: Directive,
313
+ args: [{ selector: 'ng-template[kerusiSeat]' }]
314
+ }] });
364
315
 
365
316
  /**
366
317
  * Renders a Kerusi seat map.
@@ -375,6 +326,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
375
326
  * ```html
376
327
  * <kerusi-seatmap [map]="map" [state]="state" [(selection)]="picked" />
377
328
  * ```
329
+ *
330
+ * Two opt-in features sit on top, both off by default so an existing map is
331
+ * unchanged: `[zoomable]` (pinch, wheel and drag, keys `+` `-` `0`, buttons)
332
+ * and `[maximizable]` (a full-screen button). Their arithmetic is in
333
+ * `@kerusiweb/core`; this class supplies the listeners and the signals.
378
334
  */
379
335
  class KerusiSeatmapComponent {
380
336
  host = inject((ElementRef));
@@ -406,6 +362,25 @@ class KerusiSeatmapComponent {
406
362
  /** Let an available seat take its `SeatType.color` (§4.7). */
407
363
  typeColors = input(true, /* @ts-ignore */
408
364
  ...(ngDevMode ? [{ debugName: "typeColors" }] : /* istanbul ignore next */ []));
365
+ /**
366
+ * Styles for every seat of a type, keyed by `SeatType.id`: fill, label colour
367
+ * and classes. Never changes a selected or blocked seat's fill. Also shown in
368
+ * the legend.
369
+ */
370
+ seatTypeStyles = input(undefined, /* @ts-ignore */
371
+ ...(ngDevMode ? [{ debugName: "seatTypeStyles" }] : /* istanbul ignore next */ []));
372
+ /**
373
+ * A style for one seat, laid over its `seatTypeStyles` entry; return nothing
374
+ * to leave a seat be. Never changes a selected or blocked seat's fill.
375
+ */
376
+ seatStyle = input(undefined, /* @ts-ignore */
377
+ ...(ngDevMode ? [{ debugName: "seatStyle" }] : /* istanbul ignore next */ []));
378
+ /**
379
+ * A `<ng-template kerusiSeat let-seat>` placed inside the map draws the inside
380
+ * of every seat in place of the stock glyph. See {@link KerusiSeatTemplateDirective}.
381
+ */
382
+ seatTemplate = contentChild(KerusiSeatTemplateDirective, /* @ts-ignore */
383
+ ...(ngDevMode ? [{ debugName: "seatTemplate" }] : /* istanbul ignore next */ []));
409
384
  /** Grid cell edge, in viewBox units. */
410
385
  seatSize = input(28, /* @ts-ignore */
411
386
  ...(ngDevMode ? [{ debugName: "seatSize" }] : /* istanbul ignore next */ []));
@@ -456,6 +431,28 @@ class KerusiSeatmapComponent {
456
431
  ...(ngDevMode ? [{ debugName: "expireHolds" }] : /* istanbul ignore next */ []));
457
432
  expiryIntervalMs = input(1000, /* @ts-ignore */
458
433
  ...(ngDevMode ? [{ debugName: "expiryIntervalMs" }] : /* istanbul ignore next */ []));
434
+ // --- zoom, pan and maximize (both opt in) ---------------------------------
435
+ /**
436
+ * Pinch, Ctrl/Cmd + wheel and drag to zoom and pan, `+` `-` `0` on the keyboard,
437
+ * and on-screen buttons. A tap still selects at every zoom level.
438
+ */
439
+ zoomable = input(false, /* @ts-ignore */
440
+ ...(ngDevMode ? [{ debugName: "zoomable" }] : /* istanbul ignore next */ []));
441
+ /** The zoom ceiling, relative to fit-to-container. The floor is always fit. */
442
+ maxZoom = input(DEFAULT_MAX_ZOOM, /* @ts-ignore */
443
+ ...(ngDevMode ? [{ debugName: "maxZoom" }] : /* istanbul ignore next */ []));
444
+ /**
445
+ * The built-in zoom buttons. Turn this off to supply your own, anywhere on the
446
+ * page, and call `zoomIn()`, `zoomOut()` and `resetZoom()` on the component.
447
+ */
448
+ showZoomControls = input(true, /* @ts-ignore */
449
+ ...(ngDevMode ? [{ debugName: "showZoomControls" }] : /* istanbul ignore next */ []));
450
+ /** A button that maximizes the map to fill the screen. */
451
+ maximizable = input(false, /* @ts-ignore */
452
+ ...(ngDevMode ? [{ debugName: "maximizable" }] : /* istanbul ignore next */ []));
453
+ /** Overrides the built-in English names of the zoom and maximize controls. */
454
+ controlStrings = input({}, /* @ts-ignore */
455
+ ...(ngDevMode ? [{ debugName: "controlStrings" }] : /* istanbul ignore next */ []));
459
456
  // --- validation -----------------------------------------------------------
460
457
  validate = input('collect', /* @ts-ignore */
461
458
  ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
@@ -466,6 +463,9 @@ class KerusiSeatmapComponent {
466
463
  seatFocus = output();
467
464
  /** Emitted whenever the documents change, in `collect` mode. */
468
465
  validationIssues = output();
466
+ /** The zoom and pan, whenever they change. For a host that persists or shows them. */
467
+ viewportChange = output();
468
+ maximizedChange = output();
469
469
  // --- internal state -------------------------------------------------------
470
470
  /**
471
471
  * The seat holding each section's tab stop, keyed by section id.
@@ -481,6 +481,25 @@ class KerusiSeatmapComponent {
481
481
  /** Bumped by the hold ticker to force the state to be re-derived. */
482
482
  expiryTick = signal(0, /* @ts-ignore */
483
483
  ...(ngDevMode ? [{ debugName: "expiryTick" }] : /* istanbul ignore next */ []));
484
+ viewportState = signal(FIT_VIEWPORT, /* @ts-ignore */
485
+ ...(ngDevMode ? [{ debugName: "viewportState" }] : /* istanbul ignore next */ []));
486
+ maximizedState = signal(false, /* @ts-ignore */
487
+ ...(ngDevMode ? [{ debugName: "maximizedState" }] : /* istanbul ignore next */ []));
488
+ /** True for a discrete change (button, key, reveal), false while a finger or wheel drives. */
489
+ animated = signal(false, /* @ts-ignore */
490
+ ...(ngDevMode ? [{ debugName: "animated" }] : /* istanbul ignore next */ []));
491
+ viewportEl = viewChild('viewport', /* @ts-ignore */
492
+ ...(ngDevMode ? [{ debugName: "viewportEl" }] : /* istanbul ignore next */ []));
493
+ contentEl = viewChild('content', /* @ts-ignore */
494
+ ...(ngDevMode ? [{ debugName: "contentEl" }] : /* istanbul ignore next */ []));
495
+ gesture = IDLE_GESTURE;
496
+ suppressClick = false;
497
+ gestureBase = null;
498
+ nativeFullscreen = false;
499
+ /** The current zoom and pan. Read-only: drive it with the methods below. */
500
+ viewport = this.viewportState.asReadonly();
501
+ /** Whether the map is currently maximized. */
502
+ maximized = this.maximizedState.asReadonly();
484
503
  constructor() {
485
504
  effect(() => {
486
505
  const issues = this.issues();
@@ -520,6 +539,93 @@ class KerusiSeatmapComponent {
520
539
  const handle = setInterval(() => this.expiryTick.update((n) => n + 1), this.expiryIntervalMs());
521
540
  onCleanup(() => clearInterval(handle));
522
541
  });
542
+ // Gesture listeners go on natively, not through the template: the wheel
543
+ // listener must be non-passive to `preventDefault` a page zoom, and the
544
+ // click listener must be in the capture phase to swallow the click that
545
+ // follows a drag before the seat sees it.
546
+ effect((onCleanup) => {
547
+ const el = this.viewportEl()?.nativeElement;
548
+ if (!el) {
549
+ return;
550
+ }
551
+ const listeners = [
552
+ ['pointerdown', (e) => this.onPointerDown(e), false],
553
+ ['pointermove', (e) => this.onPointerMove(e), false],
554
+ ['pointerup', (e) => this.onPointerEnd(e, 'up'), false],
555
+ ['pointercancel', (e) => this.onPointerEnd(e, 'cancel'), false],
556
+ ['wheel', (e) => this.onWheel(e), { passive: false }],
557
+ ['click', (e) => this.onClickCapture(e), true],
558
+ // Safari reports a trackpad pinch as gesture events, not Ctrl + wheel.
559
+ ['gesturestart', (e) => this.onGestureStart(e), { passive: false }],
560
+ ['gesturechange', (e) => this.onGestureChange(e), { passive: false }],
561
+ ['gestureend', (e) => this.onGestureEnd(e), { passive: false }],
562
+ ];
563
+ for (const [type, listener, options] of listeners) {
564
+ el.addEventListener(type, listener, options);
565
+ }
566
+ onCleanup(() => {
567
+ for (const [type, listener, options] of listeners) {
568
+ el.removeEventListener(type, listener, options);
569
+ }
570
+ });
571
+ });
572
+ // Re-clamp when the box or the ceiling changes: a rotated phone, a
573
+ // maximized map, a lowered `maxZoom`.
574
+ effect((onCleanup) => {
575
+ this.limits();
576
+ const view = this.viewportEl()?.nativeElement;
577
+ untracked(() => this.reclamp());
578
+ if (!view || typeof ResizeObserver === 'undefined') {
579
+ return;
580
+ }
581
+ const observer = new ResizeObserver(() => this.reclamp());
582
+ observer.observe(view);
583
+ const content = this.contentEl()?.nativeElement;
584
+ if (content) {
585
+ observer.observe(content);
586
+ }
587
+ onCleanup(() => observer.disconnect());
588
+ });
589
+ // Switching `zoomable` or `maximizable` off puts the map back as it was.
590
+ effect(() => {
591
+ if (!this.zoomable() && !viewportsEqual(untracked(this.viewportState), FIT_VIEWPORT)) {
592
+ this.viewportState.set(FIT_VIEWPORT);
593
+ }
594
+ });
595
+ effect(() => {
596
+ if (!this.maximizable() && untracked(this.maximizedState)) {
597
+ untracked(() => this.setMaximized(false));
598
+ }
599
+ });
600
+ // While maximized: freeze the page behind, and follow the browser leaving
601
+ // native full screen (Esc) so the two never disagree.
602
+ effect((onCleanup) => {
603
+ if (!this.maximizedState()) {
604
+ return;
605
+ }
606
+ const host = this.host.nativeElement;
607
+ const doc = host.ownerDocument;
608
+ const previousOverflow = doc.body.style.overflow;
609
+ doc.body.style.overflow = 'hidden';
610
+ const onFullscreenChange = () => {
611
+ if (doc.fullscreenElement === host) {
612
+ this.nativeFullscreen = true;
613
+ }
614
+ else if (this.nativeFullscreen) {
615
+ this.nativeFullscreen = false;
616
+ this.setMaximized(false);
617
+ }
618
+ };
619
+ doc.addEventListener('fullscreenchange', onFullscreenChange);
620
+ onCleanup(() => {
621
+ doc.body.style.overflow = previousOverflow;
622
+ doc.removeEventListener('fullscreenchange', onFullscreenChange);
623
+ this.nativeFullscreen = false;
624
+ if (doc.fullscreenElement === host) {
625
+ doc.exitFullscreen?.().catch(() => undefined);
626
+ }
627
+ });
628
+ });
523
629
  }
524
630
  // --- derived model --------------------------------------------------------
525
631
  issues = computed(() => this.validate() === 'off'
@@ -555,6 +661,22 @@ class KerusiSeatmapComponent {
555
661
  ...(ngDevMode ? [{ debugName: "resolvedColors" }] : /* istanbul ignore next */ []));
556
662
  selectionSet = computed(() => new Set(this.selection()), /* @ts-ignore */
557
663
  ...(ngDevMode ? [{ debugName: "selectionSet" }] : /* istanbul ignore next */ []));
664
+ limits = computed(() => zoomLimits(this.maxZoom()), /* @ts-ignore */
665
+ ...(ngDevMode ? [{ debugName: "limits" }] : /* istanbul ignore next */ []));
666
+ strings = computed(() => resolveControlStrings(this.controlStrings()), /* @ts-ignore */
667
+ ...(ngDevMode ? [{ debugName: "strings" }] : /* istanbul ignore next */ []));
668
+ controlColors = computed(() => controlStyle(this.colors()), /* @ts-ignore */
669
+ ...(ngDevMode ? [{ debugName: "controlColors" }] : /* istanbul ignore next */ []));
670
+ transform = computed(() => viewportTransform(this.viewportState()), /* @ts-ignore */
671
+ ...(ngDevMode ? [{ debugName: "transform" }] : /* istanbul ignore next */ []));
672
+ touchAction = computed(() => viewportTouchAction(this.viewportState(), this.maximizedState()), /* @ts-ignore */
673
+ ...(ngDevMode ? [{ debugName: "touchAction" }] : /* istanbul ignore next */ []));
674
+ showToolbar = computed(() => (this.zoomable() && this.showZoomControls()) || this.maximizable(), /* @ts-ignore */
675
+ ...(ngDevMode ? [{ debugName: "showToolbar" }] : /* istanbul ignore next */ []));
676
+ atMaxZoom = computed(() => this.viewportState().zoom >= this.limits().maxZoom - 1e-4, /* @ts-ignore */
677
+ ...(ngDevMode ? [{ debugName: "atMaxZoom" }] : /* istanbul ignore next */ []));
678
+ atFit = computed(() => this.viewportState().zoom <= 1 + 1e-4, /* @ts-ignore */
679
+ ...(ngDevMode ? [{ debugName: "atFit" }] : /* istanbul ignore next */ []));
558
680
  /** Placed geometry per section, keyed by section id. */
559
681
  layouts = computed(() => {
560
682
  const overrides = this.sectionOverrides();
@@ -643,6 +765,9 @@ class KerusiSeatmapComponent {
643
765
  }
644
766
  this.setFocusedSeat(seat.sectionId, seatId);
645
767
  this.seatFocus.emit(seat);
768
+ if (this.zoomable()) {
769
+ this.revealSeat(seatId);
770
+ }
646
771
  }
647
772
  /**
648
773
  * Arrow keys walk the navigation graph, which orders seats by `col` in grid
@@ -714,19 +839,247 @@ class KerusiSeatmapComponent {
714
839
  /**
715
840
  * Moves the roving tab stop, then the DOM focus that follows it.
716
841
  *
842
+ * When zoomable, the browser's own scroll-into-view is suppressed: the map
843
+ * moves by transform, so `onSeatFocused` pans it into view instead.
844
+ */
845
+ moveFocusTo(sectionId, seatId) {
846
+ this.setFocusedSeat(sectionId, seatId);
847
+ const node = this.seatNode(seatId);
848
+ if (this.zoomable()) {
849
+ node?.focus?.({ preventScroll: true });
850
+ }
851
+ else {
852
+ node?.focus?.();
853
+ }
854
+ }
855
+ /**
717
856
  * Seat ids come from the document, so they cannot be interpolated into a
718
857
  * selector unescaped — and `CSS.escape` is absent in jsdom and under SSR.
719
858
  * Scanning the rendered nodes avoids both problems.
720
859
  */
721
- moveFocusTo(sectionId, seatId) {
722
- this.setFocusedSeat(sectionId, seatId);
860
+ seatNode(seatId) {
723
861
  const nodes = this.host.nativeElement.querySelectorAll('[data-seat-id]');
724
862
  for (const node of Array.from(nodes)) {
725
863
  if (node.dataset['seatId'] === seatId) {
726
- node.focus?.();
864
+ return node;
865
+ }
866
+ }
867
+ return null;
868
+ }
869
+ // --- zoom and pan ---------------------------------------------------------
870
+ /** Zooms in one step about the centre. No-op unless `zoomable`. */
871
+ zoomIn() {
872
+ this.stepZoom(1);
873
+ }
874
+ /** Zooms out one step about the centre. No-op unless `zoomable`. */
875
+ zoomOut() {
876
+ this.stepZoom(-1);
877
+ }
878
+ /** Back to fit-to-container: the map's original view. No-op unless `zoomable`. */
879
+ resetZoom() {
880
+ const measured = this.measure();
881
+ if (measured && this.zoomable()) {
882
+ this.commit(fitViewport(measured.content, measured.container, this.limits()), true);
883
+ }
884
+ }
885
+ stepZoom(direction) {
886
+ const measured = this.measure();
887
+ if (measured && this.zoomable()) {
888
+ const next = stepViewport(this.viewportState(), direction, measured.content, measured.container, this.limits());
889
+ this.commit(next, true);
890
+ }
891
+ }
892
+ /** Sizes in CSS pixels, read when needed: `offset*` ignores the zoom transform. */
893
+ measure() {
894
+ const view = this.viewportEl()?.nativeElement;
895
+ const content = this.contentEl()?.nativeElement;
896
+ if (!view || !content) {
897
+ return null;
898
+ }
899
+ return {
900
+ container: { width: view.clientWidth, height: view.clientHeight },
901
+ content: { width: content.offsetWidth, height: content.offsetHeight },
902
+ };
903
+ }
904
+ commit(next, animate) {
905
+ if (viewportsEqual(next, this.viewportState())) {
906
+ return;
907
+ }
908
+ this.animated.set(animate);
909
+ this.viewportState.set(next);
910
+ this.viewportChange.emit(next);
911
+ }
912
+ reclamp() {
913
+ const measured = this.measure();
914
+ if (measured && this.zoomable()) {
915
+ const next = clampViewport(this.viewportState(), measured.content, measured.container, this.limits());
916
+ this.commit(next, false);
917
+ }
918
+ }
919
+ /** A point in the viewport's own coordinates, which is what the core functions take. */
920
+ containerPoint(event) {
921
+ const box = this.viewportEl().nativeElement.getBoundingClientRect();
922
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
923
+ }
924
+ /**
925
+ * Pans a seat that took keyboard focus into view. A click or tap also
926
+ * focuses the seat, but should not move the map under the finger, so only a
927
+ * `:focus-visible` focus counts.
928
+ */
929
+ revealSeat(seatId) {
930
+ const view = this.viewportEl()?.nativeElement;
931
+ const node = this.seatNode(seatId);
932
+ const measured = this.measure();
933
+ if (!view || !node || !measured || !focusIsVisible(node)) {
934
+ return;
935
+ }
936
+ const seat = node.getBoundingClientRect();
937
+ const frame = view.getBoundingClientRect();
938
+ const next = revealRect(this.viewportState(), {
939
+ x: seat.left - frame.left,
940
+ y: seat.top - frame.top,
941
+ width: seat.width,
942
+ height: seat.height,
943
+ }, measured.content, measured.container, undefined, this.limits());
944
+ this.commit(next, true);
945
+ }
946
+ // --- pointer gestures -----------------------------------------------------
947
+ onPointerDown(event) {
948
+ if (event.pointerType === 'mouse' && event.button !== 0) {
949
+ return;
950
+ }
951
+ this.suppressClick = false;
952
+ this.feedGesture({ type: 'down', id: event.pointerId, point: this.containerPoint(event) });
953
+ }
954
+ onPointerMove(event) {
955
+ if (this.gesture.ids.length > 0) {
956
+ this.feedGesture({ type: 'move', id: event.pointerId, point: this.containerPoint(event) });
957
+ }
958
+ }
959
+ onPointerEnd(event, type) {
960
+ this.feedGesture({ type, id: event.pointerId });
961
+ }
962
+ feedGesture(event) {
963
+ const measured = this.measure();
964
+ if (!measured) {
965
+ return;
966
+ }
967
+ const result = reduceGesture(this.gesture, event, {
968
+ viewport: this.viewportState(),
969
+ content: measured.content,
970
+ container: measured.container,
971
+ limits: this.limits(),
972
+ });
973
+ this.gesture = result.state;
974
+ // Capture only once a tap has become a gesture: capturing on `down` would
975
+ // retarget the tap's click away from the seat.
976
+ if (result.capture) {
977
+ const view = this.viewportEl()?.nativeElement;
978
+ for (const id of result.state.ids) {
979
+ try {
980
+ view?.setPointerCapture(id);
981
+ }
982
+ catch {
983
+ // The pointer already ended; nothing to capture.
984
+ }
985
+ }
986
+ }
987
+ if (result.viewport !== this.viewportState()) {
988
+ this.commit(result.viewport, false);
989
+ }
990
+ if (result.suppressClick) {
991
+ // The click a drag ends with arrives right after `pointerup`, before this timer.
992
+ this.suppressClick = true;
993
+ setTimeout(() => (this.suppressClick = false));
994
+ }
995
+ }
996
+ onClickCapture(event) {
997
+ if (this.suppressClick) {
998
+ this.suppressClick = false;
999
+ event.stopPropagation();
1000
+ event.preventDefault();
1001
+ }
1002
+ }
1003
+ onWheel(event) {
1004
+ const factor = wheelZoomFactor(event);
1005
+ const measured = this.measure();
1006
+ if (factor === null || !measured) {
1007
+ return; // A plain wheel is the page's to scroll.
1008
+ }
1009
+ event.preventDefault();
1010
+ const next = zoomViewportBy(this.viewportState(), factor, this.containerPoint(event), measured.content, measured.container, this.limits());
1011
+ this.commit(next, false);
1012
+ }
1013
+ onGestureStart(event) {
1014
+ event.preventDefault();
1015
+ this.gestureBase = this.viewportState();
1016
+ }
1017
+ onGestureChange(event) {
1018
+ event.preventDefault();
1019
+ const measured = this.measure();
1020
+ if (!this.gestureBase || !measured) {
1021
+ return;
1022
+ }
1023
+ const next = zoomViewportTo(this.gestureBase, this.gestureBase.zoom * event.scale, this.containerPoint(event), measured.content, measured.container, this.limits());
1024
+ this.commit(next, false);
1025
+ }
1026
+ onGestureEnd(event) {
1027
+ event.preventDefault();
1028
+ this.gestureBase = null;
1029
+ }
1030
+ // --- keyboard on the map as a whole ----------------------------------------
1031
+ /**
1032
+ * `+` `-` `0` zoom, and arrows pan when the viewport itself has focus; Esc
1033
+ * leaves a maximized map. A seat's own handler runs first, so an Esc that
1034
+ * cleared the selection is already `defaultPrevented` and stays at that.
1035
+ */
1036
+ onRootKeydown(event) {
1037
+ if (event.defaultPrevented || isTextEntryTarget(event.target)) {
1038
+ return;
1039
+ }
1040
+ if (this.zoomable()) {
1041
+ const measured = this.measure();
1042
+ const next = measured &&
1043
+ keyboardViewport(this.viewportState(), {
1044
+ key: event.key,
1045
+ ctrlKey: event.ctrlKey,
1046
+ metaKey: event.metaKey,
1047
+ altKey: event.altKey,
1048
+ viewportFocused: event.target === this.viewportEl()?.nativeElement,
1049
+ }, measured.content, measured.container, this.limits());
1050
+ if (next) {
1051
+ event.preventDefault();
1052
+ this.commit(next, true);
727
1053
  return;
728
1054
  }
729
1055
  }
1056
+ if (event.key === 'Escape' && this.maximizedState()) {
1057
+ event.preventDefault();
1058
+ this.setMaximized(false);
1059
+ }
1060
+ }
1061
+ // --- maximize -------------------------------------------------------------
1062
+ /** Maximizes or restores the map. Maximizing needs `maximizable`. */
1063
+ toggleMaximized() {
1064
+ this.setMaximized(!this.maximizedState());
1065
+ }
1066
+ setMaximized(next) {
1067
+ if (next === this.maximizedState() || (next && !this.maximizable())) {
1068
+ return;
1069
+ }
1070
+ this.maximizedState.set(next);
1071
+ this.maximizedChange.emit(next);
1072
+ // Native full screen is an enhancement over the fixed-position layout, which
1073
+ // is all iPhone Safari offers. It is requested here, inside the click that
1074
+ // got us here, because browsers only grant it to a user gesture.
1075
+ const host = this.host.nativeElement;
1076
+ const doc = host.ownerDocument;
1077
+ if (next && !doc.fullscreenElement) {
1078
+ host.requestFullscreen?.()?.catch(() => undefined);
1079
+ }
1080
+ else if (!next && doc.fullscreenElement === host) {
1081
+ doc.exitFullscreen?.()?.catch(() => undefined);
1082
+ }
730
1083
  }
731
1084
  announceSelection(selection) {
732
1085
  const strings = this.ariaStrings();
@@ -758,15 +1111,24 @@ class KerusiSeatmapComponent {
758
1111
  return (this.map().sections ?? []).map((s) => s.id).filter((id) => !hidden.includes(id));
759
1112
  }
760
1113
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSeatmapComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
761
- 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 });
1114
+ 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 }, seatTypeStyles: { classPropertyName: "seatTypeStyles", publicName: "seatTypeStyles", isSignal: true, isRequired: false, transformFunction: null }, seatStyle: { classPropertyName: "seatStyle", publicName: "seatStyle", 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 }, zoomable: { classPropertyName: "zoomable", publicName: "zoomable", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, showZoomControls: { classPropertyName: "showZoomControls", publicName: "showZoomControls", isSignal: true, isRequired: false, transformFunction: null }, maximizable: { classPropertyName: "maximizable", publicName: "maximizable", isSignal: true, isRequired: false, transformFunction: null }, controlStrings: { classPropertyName: "controlStrings", publicName: "controlStrings", 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", viewportChange: "viewportChange", maximizedChange: "maximizedChange" }, queries: [{ propertyName: "seatTemplate", first: true, predicate: KerusiSeatTemplateDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "viewportEl", first: true, predicate: ["viewport"], descendants: true, isSignal: true }, { propertyName: "contentEl", first: true, predicate: ["content"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\r\n class=\"kerusi-seatmap\"\r\n [class.kerusi-seatmap--maximized]=\"maximized()\"\r\n role=\"group\"\r\n [attr.aria-label]=\"mapLabel()\"\r\n [attr.dir]=\"isRtl() ? 'rtl' : null\"\r\n [attr.lang]=\"resolvedLocale()\"\r\n [style.background]=\"maximized() ? controlColors().maximizedBackground : null\"\r\n [style.color]=\"maximized() ? controlColors().maximizedColor : null\"\r\n (keydown)=\"onRootKeydown($event)\"\r\n>\r\n @if (zoomable() || maximizable()) {\r\n <!-- Only when a zoom or maximize feature is on, so a plain map's markup is\r\n exactly what it was before they existed. -->\r\n <div class=\"kerusi-seatmap__stage\">\r\n @if (showToolbar()) {\r\n <div\r\n class=\"kerusi-toolbar\"\r\n role=\"toolbar\"\r\n [attr.aria-label]=\"strings().toolbar\"\r\n [style.--kerusi-focus-ring-input]=\"resolvedColors().focusRing\"\r\n >\r\n @if (zoomable() && showZoomControls()) {\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--zoom-in\"\r\n [attr.aria-label]=\"strings().zoomIn\"\r\n [attr.aria-disabled]=\"atMaxZoom()\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"zoomIn()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n <path d=\"M12 5v14M5 12h14\" />\r\n </svg>\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--zoom-out\"\r\n [attr.aria-label]=\"strings().zoomOut\"\r\n [attr.aria-disabled]=\"atFit()\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"zoomOut()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n <path d=\"M5 12h14\" />\r\n </svg>\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--zoom-reset\"\r\n [attr.aria-label]=\"strings().zoomReset\"\r\n [attr.aria-disabled]=\"atFit()\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"resetZoom()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n <path d=\"M3 12a9 9 0 1 0 3-6.7M3 4v5h5\" />\r\n </svg>\r\n </button>\r\n }\r\n @if (maximizable()) {\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--maximize\"\r\n [attr.aria-label]=\"maximized() ? strings().restore : strings().maximize\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"toggleMaximized()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n @if (maximized()) {\r\n <path d=\"M9 4v5H4M15 4v5h5M15 20v-5h5M9 20v-5H4\" />\r\n } @else {\r\n <path d=\"M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5\" />\r\n }\r\n </svg>\r\n </button>\r\n }\r\n </div>\r\n }\r\n\r\n @if (zoomable()) {\r\n <div\r\n #viewport\r\n class=\"kerusi-viewport\"\r\n role=\"group\"\r\n tabindex=\"0\"\r\n [attr.aria-label]=\"strings().viewport\"\r\n [style.touch-action]=\"touchAction()\"\r\n [style.--kerusi-focus-ring-input]=\"resolvedColors().focusRing\"\r\n >\r\n <div\r\n #content\r\n class=\"kerusi-viewport__content\"\r\n [class.kerusi-viewport__content--animated]=\"animated()\"\r\n [style.transform]=\"transform()\"\r\n >\r\n <ng-container [ngTemplateOutlet]=\"sections\" />\r\n </div>\r\n </div>\r\n } @else {\r\n <ng-container [ngTemplateOutlet]=\"sections\" />\r\n }\r\n </div>\r\n } @else {\r\n <ng-container [ngTemplateOutlet]=\"sections\" />\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 [seatTypeStyles]=\"seatTypeStyles()\"\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\r\n<ng-template #sections>\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 [seatTemplate]=\"seatTemplate()?.template\"\r\n [seatStyle]=\"seatStyle()\"\r\n [seatTypeStyles]=\"seatTypeStyles()\"\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</ng-template>\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}.kerusi-seatmap__stage{display:flex;flex-direction:column;gap:1.25rem}.kerusi-toolbar{display:flex;gap:.35rem;align-self:flex-end;flex:none}.kerusi-control{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.75rem;height:2.75rem;padding:0;border:1px solid;border-radius:.4rem;font:inherit;cursor:pointer}.kerusi-control[aria-disabled=true]{opacity:.45;cursor:default}.kerusi-control svg{width:1.4rem;height:1.4rem;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}.kerusi-control:focus-visible,.kerusi-viewport:focus-visible{outline:2px solid var(--kerusi-focus-ring, var(--kerusi-focus-ring-input, currentColor));outline-offset:2px}.kerusi-viewport{position:relative;overflow:hidden;overflow:clip;user-select:none;-webkit-user-select:none}.kerusi-viewport__content{display:flex;flex-direction:column;gap:1.25rem;transform-origin:0 0}.kerusi-viewport__content--animated{transition:transform .16s ease-out}@media(prefers-reduced-motion:reduce){.kerusi-viewport__content--animated{transition:none}}.kerusi-seatmap--maximized{position:fixed;inset:0;z-index:var(--kerusi-maximized-z-index, 1000);box-sizing:border-box;padding:.75rem;gap:.5rem;overflow:hidden;overscroll-behavior:contain}.kerusi-seatmap--maximized .kerusi-seatmap__stage{flex:1 1 auto;min-height:0;gap:.5rem;overflow:auto}.kerusi-seatmap--maximized .kerusi-viewport{flex:1 1 auto;min-height:0}\n"], dependencies: [{ kind: "component", type: KerusiSectionComponent, selector: "kerusi-section", inputs: ["section", "layout", "selection", "focusedSeatId", "ariaLabels", "colors", "typeColors", "seatTypeStyles", "seatStyle", "seatTemplate", "interactive", "unitScale"], outputs: ["seatActivate", "seatFocused", "seatKeydown"] }, { kind: "component", type: KerusiLegendComponent, selector: "kerusi-legend", inputs: ["legend", "locale", "colors", "typeColors", "seatTypeStyles", "showPrices", "hideUnusedTypes", "headingSeatTypes", "headingAvailability"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
762
1115
  }
763
1116
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: KerusiSeatmapComponent, decorators: [{
764
1117
  type: Component,
765
- 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"] }]
766
- }], 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"] }] } });
1118
+ args: [{ selector: 'kerusi-seatmap', changeDetection: ChangeDetectionStrategy.OnPush, imports: [KerusiSectionComponent, KerusiLegendComponent, NgTemplateOutlet], template: "<div\r\n class=\"kerusi-seatmap\"\r\n [class.kerusi-seatmap--maximized]=\"maximized()\"\r\n role=\"group\"\r\n [attr.aria-label]=\"mapLabel()\"\r\n [attr.dir]=\"isRtl() ? 'rtl' : null\"\r\n [attr.lang]=\"resolvedLocale()\"\r\n [style.background]=\"maximized() ? controlColors().maximizedBackground : null\"\r\n [style.color]=\"maximized() ? controlColors().maximizedColor : null\"\r\n (keydown)=\"onRootKeydown($event)\"\r\n>\r\n @if (zoomable() || maximizable()) {\r\n <!-- Only when a zoom or maximize feature is on, so a plain map's markup is\r\n exactly what it was before they existed. -->\r\n <div class=\"kerusi-seatmap__stage\">\r\n @if (showToolbar()) {\r\n <div\r\n class=\"kerusi-toolbar\"\r\n role=\"toolbar\"\r\n [attr.aria-label]=\"strings().toolbar\"\r\n [style.--kerusi-focus-ring-input]=\"resolvedColors().focusRing\"\r\n >\r\n @if (zoomable() && showZoomControls()) {\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--zoom-in\"\r\n [attr.aria-label]=\"strings().zoomIn\"\r\n [attr.aria-disabled]=\"atMaxZoom()\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"zoomIn()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n <path d=\"M12 5v14M5 12h14\" />\r\n </svg>\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--zoom-out\"\r\n [attr.aria-label]=\"strings().zoomOut\"\r\n [attr.aria-disabled]=\"atFit()\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"zoomOut()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n <path d=\"M5 12h14\" />\r\n </svg>\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--zoom-reset\"\r\n [attr.aria-label]=\"strings().zoomReset\"\r\n [attr.aria-disabled]=\"atFit()\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"resetZoom()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n <path d=\"M3 12a9 9 0 1 0 3-6.7M3 4v5h5\" />\r\n </svg>\r\n </button>\r\n }\r\n @if (maximizable()) {\r\n <button\r\n type=\"button\"\r\n class=\"kerusi-control kerusi-control--maximize\"\r\n [attr.aria-label]=\"maximized() ? strings().restore : strings().maximize\"\r\n [style.background]=\"controlColors().buttonBackground\"\r\n [style.color]=\"controlColors().buttonColor\"\r\n [style.border-color]=\"controlColors().buttonBorder\"\r\n (click)=\"toggleMaximized()\"\r\n >\r\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\r\n @if (maximized()) {\r\n <path d=\"M9 4v5H4M15 4v5h5M15 20v-5h5M9 20v-5H4\" />\r\n } @else {\r\n <path d=\"M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5\" />\r\n }\r\n </svg>\r\n </button>\r\n }\r\n </div>\r\n }\r\n\r\n @if (zoomable()) {\r\n <div\r\n #viewport\r\n class=\"kerusi-viewport\"\r\n role=\"group\"\r\n tabindex=\"0\"\r\n [attr.aria-label]=\"strings().viewport\"\r\n [style.touch-action]=\"touchAction()\"\r\n [style.--kerusi-focus-ring-input]=\"resolvedColors().focusRing\"\r\n >\r\n <div\r\n #content\r\n class=\"kerusi-viewport__content\"\r\n [class.kerusi-viewport__content--animated]=\"animated()\"\r\n [style.transform]=\"transform()\"\r\n >\r\n <ng-container [ngTemplateOutlet]=\"sections\" />\r\n </div>\r\n </div>\r\n } @else {\r\n <ng-container [ngTemplateOutlet]=\"sections\" />\r\n }\r\n </div>\r\n } @else {\r\n <ng-container [ngTemplateOutlet]=\"sections\" />\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 [seatTypeStyles]=\"seatTypeStyles()\"\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\r\n<ng-template #sections>\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 [seatTemplate]=\"seatTemplate()?.template\"\r\n [seatStyle]=\"seatStyle()\"\r\n [seatTypeStyles]=\"seatTypeStyles()\"\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</ng-template>\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}.kerusi-seatmap__stage{display:flex;flex-direction:column;gap:1.25rem}.kerusi-toolbar{display:flex;gap:.35rem;align-self:flex-end;flex:none}.kerusi-control{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.75rem;height:2.75rem;padding:0;border:1px solid;border-radius:.4rem;font:inherit;cursor:pointer}.kerusi-control[aria-disabled=true]{opacity:.45;cursor:default}.kerusi-control svg{width:1.4rem;height:1.4rem;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}.kerusi-control:focus-visible,.kerusi-viewport:focus-visible{outline:2px solid var(--kerusi-focus-ring, var(--kerusi-focus-ring-input, currentColor));outline-offset:2px}.kerusi-viewport{position:relative;overflow:hidden;overflow:clip;user-select:none;-webkit-user-select:none}.kerusi-viewport__content{display:flex;flex-direction:column;gap:1.25rem;transform-origin:0 0}.kerusi-viewport__content--animated{transition:transform .16s ease-out}@media(prefers-reduced-motion:reduce){.kerusi-viewport__content--animated{transition:none}}.kerusi-seatmap--maximized{position:fixed;inset:0;z-index:var(--kerusi-maximized-z-index, 1000);box-sizing:border-box;padding:.75rem;gap:.5rem;overflow:hidden;overscroll-behavior:contain}.kerusi-seatmap--maximized .kerusi-seatmap__stage{flex:1 1 auto;min-height:0;gap:.5rem;overflow:auto}.kerusi-seatmap--maximized .kerusi-viewport{flex:1 1 auto;min-height:0}\n"] }]
1119
+ }], 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 }] }], seatTypeStyles: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatTypeStyles", required: false }] }], seatStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "seatStyle", required: false }] }], seatTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => KerusiSeatTemplateDirective), { isSignal: true }] }], 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 }] }], zoomable: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomable", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], showZoomControls: [{ type: i0.Input, args: [{ isSignal: true, alias: "showZoomControls", required: false }] }], maximizable: [{ type: i0.Input, args: [{ isSignal: true, alias: "maximizable", required: false }] }], controlStrings: [{ type: i0.Input, args: [{ isSignal: true, alias: "controlStrings", 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"] }], viewportChange: [{ type: i0.Output, args: ["viewportChange"] }], maximizedChange: [{ type: i0.Output, args: ["maximizedChange"] }], viewportEl: [{ type: i0.ViewChild, args: ['viewport', { isSignal: true }] }], contentEl: [{ type: i0.ViewChild, args: ['content', { isSignal: true }] }] } });
767
1120
  function formatTotal(total, locale) {
768
1121
  return total ? formatMoney(total, locale) : '';
769
1122
  }
1123
+ /** `:focus-visible` where the engine knows it; otherwise assume the focus is the keyboard's. */
1124
+ function focusIsVisible(node) {
1125
+ try {
1126
+ return node.matches(':focus-visible');
1127
+ }
1128
+ catch {
1129
+ return true;
1130
+ }
1131
+ }
770
1132
 
771
1133
  /**
772
1134
  * The Kerusi-native seat map: `<kerusi-seatmap>`, its section renderer, and an
@@ -876,5 +1238,5 @@ class KerusiStateStore {
876
1238
  * Generated bundle index. Do not edit.
877
1239
  */
878
1240
 
879
- export { KerusiLegendComponent, KerusiSeatmapComponent, KerusiSectionComponent, KerusiStateStore };
1241
+ export { KerusiLegendComponent, KerusiSeatTemplateDirective, KerusiSeatmapComponent, KerusiSectionComponent, KerusiStateStore };
880
1242
  //# sourceMappingURL=kerusiweb-angular.mjs.map