@kerusiweb/angular 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-2026 Arsyad "Shad Ahm" Ahmad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # @kerusiweb/angular
2
+
3
+ An Angular seat map that renders [Kerusi](https://github.com/ShadAhm/kerusi)
4
+ documents as interactive, accessible inline SVG.
5
+
6
+ ```bash
7
+ npm install @kerusiweb/core @kerusiweb/angular
8
+ ```
9
+
10
+ Requires Angular 22+. Standalone, signal-based, zoneless-friendly.
11
+
12
+ This package is the Angular binding only — the components and the signal-backed
13
+ `KerusiStateStore`. The document types, the conformance validator, the render
14
+ model and its geometry live in its peer
15
+ [`@kerusiweb/core`](https://www.npmjs.com/package/@kerusiweb/core), which is
16
+ framework-free; import them from there.
17
+
18
+ ---
19
+
20
+ ## `<kerusi-seatmap>`
21
+
22
+ ```ts
23
+ import { KerusiSeatmapComponent } from '@kerusiweb/angular';
24
+
25
+ @Component({ imports: [KerusiSeatmapComponent], /* ... */ })
26
+ ```
27
+
28
+ ```html
29
+ <kerusi-seatmap
30
+ [map]="map"
31
+ [state]="state"
32
+ [(selection)]="picked"
33
+ [showLegend]="true"
34
+ (seatDisallowed)="explain($event)"
35
+ />
36
+ ```
37
+
38
+ ### Inputs
39
+
40
+ **Documents**
41
+
42
+ | Input | Type | Default | |
43
+ | --------- | --------------- | ---------- | --------------------------------------------------------------------------- |
44
+ | `map` | `KerusiMap` | _required_ | The static venue layout. |
45
+ | `state` | `KerusiState` | — | Live availability. Merged by `Seat.id`; an absent seat is available (§5.1). |
46
+ | `session` | `KerusiSession` | — | The optional map↔event join (§5.3). Validated against the map and state. |
47
+
48
+ **Selection**
49
+
50
+ | Input | Type | Default | |
51
+ | -------------------- | ------------------------------- | --------------- | ------------------------------------------------------------------------------- |
52
+ | `selection` | `readonly string[]` | `[]` | Selected seat ids. Two-way — `[(selection)]` — or read-only with `[selection]`. |
53
+ | `selectableStatuses` | `SeatRenderStatus[]` | `['available']` | Which statuses a seat may be picked in. |
54
+ | `companionMode` | `'auto' \| 'independent'` | `'auto'` | `auto` selects a seat's whole companion closure together (§4.6). |
55
+ | `maxSelection` | `number` | — | Cap on selected seats. Counts a companion closure as its full size. |
56
+ | `seatSelectable` | `(seat: RenderSeat) => boolean` | — | A final say, applied after the status test. |
57
+ | `interactive` | `boolean` | `true` | `false` renders read-only. |
58
+
59
+ **Appearance**
60
+
61
+ | Input | Type | Default | |
62
+ | --------------------------------- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
63
+ | `colors` | `KerusiSeatmapColors` | theme defaults | Per-status fills, element tones, focus ring, backdrop. See [Theming](#theming) for the CSS custom properties. |
64
+ | `typeColors` | `boolean` | `true` | Let an available seat take its `SeatType.color` (§4.7). |
65
+ | `seatSize` | `number` | `28` | Grid cell edge, in viewBox units. |
66
+ | `seatGap` | `number` | `6` | Gap between grid cells. |
67
+ | `freeformBasis` | `number` | `1000` | Freeform viewBox width; height follows the aspect ratio. |
68
+ | `unitScale` | `number` | `1` | CSS pixels per viewBox unit. Caps each section at its natural size so a narrow section and a wide one draw seats the same size. |
69
+ | `showSectionLabels` | `boolean` | `true` | |
70
+ | `showLegend` / `showLegendPrices` | `boolean` | `false` / `true` | |
71
+
72
+ **Sections**
73
+
74
+ | Input | Type | |
75
+ | ------------------ | -------------------------------------- | ------------------------------------------------------------------- |
76
+ | `sectionIds` | `readonly string[]` | Render only these, in this order. Default: all, by `Section.index`. |
77
+ | `sectionOverrides` | `Record<string, SectionRenderOptions>` | Per-section `hidden`, `aspectRatio`, `seatSize`, `label`. |
78
+
79
+ **Localization, validation, lifecycle**
80
+
81
+ | Input | Type | Default | |
82
+ | ------------------ | ------------------------------- | ---------------------------- | ---------------------------------------------------------------------- |
83
+ | `locale` | `string` | `KerusiMap.locale` ?? `'en'` | BCP-47. Resolves `Section.label` / `SeatType.label` locale maps. |
84
+ | `rtl` | `boolean \| 'auto'` | `'auto'` | Mirrors layout and arrow direction. `auto` derives it from the locale. |
85
+ | `ariaStrings` | `SeatAriaStrings` | English | Every announced phrase, for translation. |
86
+ | `validate` | `'collect' \| 'throw' \| 'off'` | `'collect'` | `collect` reports through `validationIssues` and renders anyway. |
87
+ | `expireHolds` | `boolean` | `false` | Revert a lapsed `holdExpires` to available on a ticker. |
88
+ | `expiryIntervalMs` | `number` | `1000` | |
89
+
90
+ ### Outputs
91
+
92
+ | Output | Payload | |
93
+ | ----------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
94
+ | `selectionChange` | `readonly string[]` | From the `selection` model. |
95
+ | `seatSelect` / `seatDeselect` | `SeatInteraction` | The `RenderSeat`, the resulting selection, and every seat that changed (companions included). |
96
+ | `seatDisallowed` | `SeatDisallowed` | `reason` is `booked`, `held`, `blocked`, `not-selectable`, `max-selection` or `companion-unavailable`. |
97
+ | `seatFocus` | `RenderSeat` | |
98
+ | `validationIssues` | `readonly KerusiViolation[]` | Emitted whenever the documents change. |
99
+
100
+ ### The seat glyph
101
+
102
+ **Colour means seat type; shape means status.** A seat's fill is always its
103
+ `SeatType.color`, or the theme's `availableBg` when the type has none — it
104
+ does not change when the seat is held or booked. Overloading fill with both
105
+ type and status hid the type colour on exactly the seats a busy map has most
106
+ of; see the doc comment on `seatFill` for the full argument. `selected` is the
107
+ one deliberate exception: it still owns the seat's colour outright, so your own picks
108
+ are never ambiguous, and a purple fill on its own would in any case fail WCAG
109
+ 1.4.1 (Use of Colour) for anyone who cannot distinguish it — hence the shape
110
+ cues below.
111
+
112
+ The seat body itself carries orientation: square-ish at the front, tapered at
113
+ the back, so which way a seat faces is legible from its outline alone.
114
+ `Seat.rotation` turns the whole group, and the taper turns with it — no
115
+ separate frame is drawn on top. This is what makes a fanned lecture theatre or
116
+ a stadium stand read at a glance. The seat number counter-rotates and stays
117
+ upright regardless.
118
+
119
+ Status is read from two marks layered over the body, never from its colour:
120
+
121
+ | State | Fill | Wash | Occupant figure |
122
+ | --------- | ---------------------------------------------- | ---------- | ---------------------------------------------------- |
123
+ | Available | type colour | — | — |
124
+ | Selected | `selectedBg` **frame** + `selectedFg` **core** | — | solid, tinted `selectedBg` |
125
+ | Held | type colour | `heldBg` | **hollow**, stroked `heldFg` — a hold is provisional |
126
+ | Booked | type colour | `bookedBg` | solid, tinted `bookedFg` — settled, someone else's |
127
+ | Blocked | `blockedBg` | — | — (withheld by the venue; no one is there) |
128
+
129
+ Solid vs. hollow is the real distinction — settled vs. still in progress — and
130
+ every figure leans with the seat rather than staying upright, so it doubles as
131
+ an orientation cue too.
132
+
133
+ Selection is the only state drawn from **two** tones rather than one, and that
134
+ is deliberate. `selectedBg` frames the seat; `selectedFg` fills a core inside
135
+ that frame and is what the number and the figure are read against. Because the
136
+ treatment always holds a light tone and a dark one at once, one of the two
137
+ separates from the page whichever way you have themed it — so the library never
138
+ has to detect a colour scheme, and there is no `prefers-color-scheme` default to
139
+ fight with your own. An earlier revision had this inverted, a near-white rim
140
+ around a mid-purple middle, which put the low-contrast tone on the outside
141
+ boundary: a selected seat dissolved into a light page and sank into a dark one.
142
+
143
+ Both tokens therefore carry real visual weight — override them as a pair. Swapping
144
+ the two gives the inverse treatment, a light frame around a dark core, which works
145
+ just as well.
146
+
147
+ A theme that overrides `heldFg`/`bookedFg` owns the contrast of that mark
148
+ against whatever `SeatType.color` the document supplies — the library cannot
149
+ know the pairing in advance, since the figure tint is fixed while the type
150
+ colour underneath it is not. The shipped defaults are light marks over a
151
+ darkened wash, which reads across the tier colours in the demo fixtures.
152
+
153
+ The geometry is exported, if you want to draw a matching seat elsewhere:
154
+ `seatBodyPath`, `seatSelectedFrame`, `seatOccupantPath`, `seatOccupantStroke`.
155
+ (`seatRingStroke` is the old name for `seatSelectedFrame` and still resolves to
156
+ it.) A seat is always square, which makes `seatBodyPath`'s `inset` an exact
157
+ scaled copy about the centre — that is how the selected core is derived, and why
158
+ marks drawn to the core's box keep every clearance they had against the body.
159
+
160
+ ### Theming
161
+
162
+ Colors resolve through three tiers, highest first:
163
+
164
+ 1. A `--kerusi-*` custom property in **your** stylesheet.
165
+ 2. The **`[colors]` input** — a partial `KerusiSeatmapColors`, merged over the defaults.
166
+ 3. The library default.
167
+
168
+ The library never writes a `--kerusi-*` property onto its own host, which is
169
+ what keeps that order true: every fill is emitted as
170
+ `var(--kerusi-selected-bg, <the resolved input value>)`. Use whichever tier
171
+ fits — the input for values known at build time, CSS for anything that has to
172
+ respond to a media query or a theme class.
173
+
174
+ ```css
175
+ kerusi-seatmap {
176
+ --kerusi-selected-bg: #8b6ad6;
177
+ --kerusi-available-bg: #2f6b45;
178
+ }
179
+
180
+ @media (prefers-color-scheme: dark) {
181
+ kerusi-seatmap {
182
+ --kerusi-element-bg: #232a36;
183
+ }
184
+ }
185
+ ```
186
+
187
+ Every key of `KerusiSeatmapColors` has a property, kebab-cased:
188
+
189
+ | Input key | Custom property |
190
+ | ------------------------------------- | ----------------------------------------------------------- |
191
+ | `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
192
+ | `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
193
+ | `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
194
+ | `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
195
+ | `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
196
+ | `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
197
+ | `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
198
+ | `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
199
+ | `focusRing` | `--kerusi-focus-ring` |
200
+ | `backdrop` | `--kerusi-backdrop` |
201
+
202
+ A `SeatType.color` from the document is deliberately **not** themable — it is
203
+ the map's own value under §4.7. Turn it off wholesale with
204
+ `[typeColors]="false"` if you want the theme to own every fill.
205
+
206
+ Overriding `selectedBg` does not cost you the selected cue: the core and the
207
+ figure are shape, not color. Do set `selectedFg` alongside it, though — it is the
208
+ core's fill, so it is most of a selected seat's area rather than just a label
209
+ tint, and the two are read against each other. `heldBg`/`bookedBg` are the wash
210
+ drawn over a taken seat's type colour, not the seat's own fill — see
211
+ [The seat glyph](#the-seat-glyph) for the full state table.
212
+
213
+ If you need the pre-1.1 flat selected seat back, the core is its own node:
214
+
215
+ ```css
216
+ kerusi-seatmap .kerusi-seat__core {
217
+ display: none;
218
+ }
219
+ /* Without the core, these would be selectedBg on selectedBg. */
220
+ kerusi-seatmap .kerusi-seat--selected .kerusi-seat__label,
221
+ kerusi-seatmap .kerusi-seat--selected .kerusi-seat__occupant,
222
+ kerusi-seatmap .kerusi-seat--selected .kerusi-seat__wheelchair {
223
+ fill: var(--kerusi-selected-fg, #f3ecff);
224
+ stroke: none;
225
+ }
226
+ ```
227
+
228
+ For anything the palette does not cover, each seat group carries class hooks:
229
+
230
+ | Class | |
231
+ | --------------------------------------------------------------- | ------------------------------------------ |
232
+ | `.kerusi-seat` | Every seat group. |
233
+ | `.kerusi-seat--available` / `--held` / `--booked` / `--blocked` | Its `SeatStatus`. |
234
+ | `.kerusi-seat--selected` | Currently picked. |
235
+ | `.kerusi-seat--unselectable` | Fails the status test or `seatSelectable`. |
236
+ | `.kerusi-seat--wheelchair` | `accessibility.wheelchairAccessible`. |
237
+ | `.kerusi-seat--companion` | Has companions (§4.6). |
238
+
239
+ Inside a seat: `.kerusi-seat__box`, `__wash`, `__core`, `__occupant`
240
+ (`__occupant--selected` / `--held` / `--booked`), `__label`, `__wheelchair`.
241
+
242
+ ### Keyboard
243
+
244
+ Tab moves between sections; each section keeps its own tab stop. Within a
245
+ section, arrow keys follow the §4.3.1 `col` order — so an aisle, which is a
246
+ column no seat occupies, is stepped across rather than into.
247
+
248
+ | Key | |
249
+ | ------------------------ | ---------------------------------- |
250
+ | `←` `→` | Previous / next seat in the row |
251
+ | `↑` `↓` | Nearest column in the adjacent row |
252
+ | `Home` / `End` | First / last seat of the row |
253
+ | `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
254
+ | `PageUp` / `PageDown` | First / last seat of the section |
255
+ | `Enter` / `Space` | Toggle |
256
+ | `Escape` | Clear the selection |
257
+
258
+ Each seat is a `role="button"` announcing its position, type, price, status,
259
+ every `Seat.accessibility` property and its attributes. A polite live region
260
+ reports the running selection and any disallowed reason.
261
+
262
+ ---
263
+
264
+ ## `<kerusi-legend>`
265
+
266
+ Rendered inline by `[showLegend]="true"`, or placed anywhere yourself:
267
+
268
+ ```html
269
+ <kerusi-legend [legend]="model.legend" [locale]="'ms'" [showPrices]="true" />
270
+ ```
271
+
272
+ It resolves swatches through the same path as the seat fills, so the two cannot
273
+ drift apart. Every availability swatch — not just **Selected** — draws the
274
+ seat glyph itself (wash, ring, occupant, as it applies) rather than a flat
275
+ colour, because shape is the cue the map uses for status.
276
+
277
+ ---
278
+
279
+ ## Working with the format directly
280
+
281
+ All of this lives in `@kerusiweb/core` and is pure — no Angular import — so it
282
+ can run in a test, a build step or on a server.
283
+
284
+ ```ts
285
+ import {
286
+ buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
287
+ checkKerusiMap, // every violation, no throw
288
+ validateKerusiMap, // throws on the first error
289
+ validateDocumentSet, // map / session / state joins
290
+ resolveSeatPrice, // the §4.9 precedence order
291
+ resolveLocalizedText, // string | Record<string, string> -> string
292
+ formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
293
+ summarizeSelection, // seats, total, unpriced count
294
+ computeSectionLayout, // placed geometry for one section
295
+ buildNavigationGraph, // per-seat keyboard neighbours
296
+ } from '@kerusiweb/core';
297
+ ```
298
+
299
+ ### Validation
300
+
301
+ `checkKerusiMap` returns every violation in document order without throwing;
302
+ `validateKerusiMap` throws a `KerusiValidationError` carrying the first error
303
+ and the full list. Each violation has a stable `rule` slug, a `severity`
304
+ (`error` blocks conformance, `warning` is advisory) and a document `path`.
305
+
306
+ ```ts
307
+ for (const v of checkKerusiMap(map)) {
308
+ console.warn(`${v.severity} ${v.rule} at ${v.path}: ${v.message}`);
309
+ }
310
+ ```
311
+
312
+ ### Live availability
313
+
314
+ ```ts
315
+ import { KerusiStateStore } from '@kerusiweb/angular';
316
+
317
+ const store = new KerusiStateStore(initialState);
318
+ socket.onmessage = (e) => {
319
+ const result = store.apply(JSON.parse(e.data));
320
+ if (result.outcome === 'gap') refetch(); // store.needsRefetch() is now true
321
+ };
322
+ const stop = store.startExpiryTicker();
323
+ ```
324
+
325
+ Deltas that are stale, duplicate or scoped to another session are discarded.
326
+
327
+ > **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
328
+ > increasing but not contiguous, so it cannot by itself distinguish "a delta was
329
+ > lost" from "nothing happened for a while". The store detects gaps when the
330
+ > transport supplies a monotonic sequence — `metadata.seq` by default, or your
331
+ > own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
332
+ > rather than freezes while you re-fetch.
333
+
334
+ ---
335
+
336
+ MIT © Arsyad "Shad Ahm" Ahmad