@kerusiweb/react 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,306 @@
1
+ # @kerusiweb/react
2
+
3
+ A React 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/react
8
+ ```
9
+
10
+ Requires React 18.3+ or 19. Function components and hooks; no context, no
11
+ provider, no global state.
12
+
13
+ This package is the React binding only — the components and the `useKerusiState`
14
+ hook. The document types, the conformance validator, the render model and its
15
+ geometry live in its peer
16
+ [`@kerusiweb/core`](https://www.npmjs.com/package/@kerusiweb/core), which is
17
+ framework-free; import them from there.
18
+
19
+ ## Import the stylesheet
20
+
21
+ Once, anywhere in your app:
22
+
23
+ ```ts
24
+ import '@kerusiweb/react/styles.css';
25
+ ```
26
+
27
+ It carries layout, opacity, cursor, paint order and the focus ring — nothing
28
+ else. Every colour is bound inline by the components, so the theming tiers below
29
+ keep working.
30
+
31
+ ---
32
+
33
+ ## `<KerusiSeatmap>`
34
+
35
+ ```tsx
36
+ import { useState } from 'react';
37
+ import { KerusiSeatmap } from '@kerusiweb/react';
38
+ import type { KerusiMap, KerusiState } from '@kerusiweb/core';
39
+ import '@kerusiweb/react/styles.css';
40
+
41
+ export function Booking({ map, state }: { map: KerusiMap; state: KerusiState }) {
42
+ const [picked, setPicked] = useState<readonly string[]>([]);
43
+
44
+ return (
45
+ <KerusiSeatmap
46
+ map={map}
47
+ state={state}
48
+ selection={picked}
49
+ onSelectionChange={setPicked}
50
+ showLegend
51
+ onSeatDisallowed={(e) => explain(e.reason)}
52
+ />
53
+ );
54
+ }
55
+ ```
56
+
57
+ `selection` is a plain list of seat ids. Nothing is mutated, so it works with
58
+ `useState`, a reducer, or any store you already have.
59
+
60
+ ### Props
61
+
62
+ **Documents**
63
+
64
+ | Prop | Type | Default | |
65
+ | --------- | --------------- | ---------- | --------------------------------------------------------------------------- |
66
+ | `map` | `KerusiMap` | _required_ | The static venue layout. |
67
+ | `state` | `KerusiState` | — | Live availability. Merged by `Seat.id`; an absent seat is available (§5.1). |
68
+ | `session` | `KerusiSession` | — | The optional map↔event join (§5.3). Validated against the map and state. |
69
+
70
+ **Selection**
71
+
72
+ | Prop | Type | Default | |
73
+ | -------------------- | ------------------------------- | --------------- | ----------------------------------------------------------------------- |
74
+ | `selection` | `readonly string[]` | — | Selected seat ids. Supplying this makes the component controlled. |
75
+ | `defaultSelection` | `readonly string[]` | `[]` | The initial selection when uncontrolled. Ignored if `selection` is set. |
76
+ | `onSelectionChange` | `(selection) => void` | — | Fires on every change, controlled or not. |
77
+ | `selectableStatuses` | `SeatRenderStatus[]` | `['available']` | Which statuses a seat may be picked in. |
78
+ | `companionMode` | `'auto' \| 'independent'` | `'auto'` | `auto` selects a seat's whole companion closure together (§4.6). |
79
+ | `maxSelection` | `number` | — | Cap on selected seats. Counts a companion closure as its full size. |
80
+ | `seatSelectable` | `(seat: RenderSeat) => boolean` | — | A final say, applied after the status test. **Memoize it** — see below. |
81
+ | `interactive` | `boolean` | `true` | `false` renders read-only. |
82
+
83
+ Controlled and uncontrolled work the way they do for `<input>`: pass
84
+ `selection` + `onSelectionChange` to own it yourself, or pass neither and let
85
+ the component keep it. Passing `selection` without `onSelectionChange` renders a
86
+ selection the user cannot change — occasionally what you want, usually a
87
+ mistake.
88
+
89
+ **Appearance**
90
+
91
+ | Prop | Type | Default | |
92
+ | --------------------------------- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `colors` | `KerusiSeatmapColors` | theme defaults | Per-status fills, element tones, focus ring, backdrop. See [Theming](#theming). |
94
+ | `typeColors` | `boolean` | `true` | Let an available seat take its `SeatType.color` (§4.7). |
95
+ | `seatSize` | `number` | `28` | Grid cell edge, in viewBox units. |
96
+ | `seatGap` | `number` | `6` | Gap between grid cells. |
97
+ | `freeformBasis` | `number` | `1000` | Freeform viewBox width; height follows the aspect ratio. |
98
+ | `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. |
99
+ | `showSectionLabels` | `boolean` | `true` | |
100
+ | `showLegend` / `showLegendPrices` | `boolean` | `false` / `true` | |
101
+ | `className` / `style` | — | — | Applied to the root element, alongside `kerusi-seatmap`. |
102
+
103
+ **Sections**
104
+
105
+ | Prop | Type | |
106
+ | ------------------ | -------------------------------------- | ------------------------------------------------------------------- |
107
+ | `sectionIds` | `readonly string[]` | Render only these, in this order. Default: all, by `Section.index`. |
108
+ | `sectionOverrides` | `Record<string, SectionRenderOptions>` | Per-section `hidden`, `aspectRatio`, `seatSize`, `label`. |
109
+
110
+ **Localization, validation, lifecycle**
111
+
112
+ | Prop | Type | Default | |
113
+ | ------------------ | ------------------------------- | ---------------------------- | ---------------------------------------------------------------------- |
114
+ | `locale` | `string` | `KerusiMap.locale` ?? `'en'` | BCP-47. Resolves `Section.label` / `SeatType.label` locale maps. |
115
+ | `rtl` | `boolean \| 'auto'` | `'auto'` | Mirrors layout and arrow direction. `auto` derives it from the locale. |
116
+ | `ariaStrings` | `SeatAriaStrings` | English | Every announced phrase, for translation. |
117
+ | `validate` | `'collect' \| 'throw' \| 'off'` | `'collect'` | `collect` reports through `onValidationIssues` and renders anyway. |
118
+ | `expireHolds` | `boolean` | `false` | Revert a lapsed `holdExpires` to available on a ticker. |
119
+ | `expiryIntervalMs` | `number` | `1000` | |
120
+
121
+ `validate="throw"` throws a `KerusiValidationError` **during render**, so an
122
+ error boundary catches it. `collect` never throws.
123
+
124
+ ### Callbacks
125
+
126
+ | Prop | Payload | |
127
+ | --------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
128
+ | `onSelectionChange` | `readonly string[]` | The selection after the change. |
129
+ | `onSeatSelect` / `onSeatDeselect` | `SeatInteraction` | The `RenderSeat`, the resulting selection, and every seat that changed (companions included). |
130
+ | `onSeatDisallowed` | `SeatDisallowed` | `reason` is `booked`, `held`, `blocked`, `not-selectable`, `max-selection` or `companion-unavailable`. |
131
+ | `onSeatFocus` | `RenderSeat` | |
132
+ | `onValidationIssues` | `readonly KerusiViolation[]` | Called whenever the documents change. |
133
+
134
+ ### The `ref`
135
+
136
+ ```tsx
137
+ const map = useRef<KerusiSeatmapHandle>(null);
138
+ // map.current.summary -> { seats, total, unpriced }
139
+ // map.current.focusSeat('A12')
140
+ ```
141
+
142
+ ### Memoizing props
143
+
144
+ The render model is rebuilt whenever `map`, `state` or the interaction props
145
+ change identity. Object and function props declared inline get a new identity
146
+ every render, so hoist or memoize the ones that are not primitives:
147
+
148
+ ```tsx
149
+ const seatSelectable = useCallback((seat: RenderSeat) => seat.price != null, []);
150
+ const colors = useMemo(() => ({ selectedBg: '#8b6ad6' }), []);
151
+ ```
152
+
153
+ `map` and `state` are usually stable already if they come from state or a query
154
+ cache. `selectableStatuses` and `sectionOverrides` have stable defaults, so you
155
+ only need to think about them if you pass your own.
156
+
157
+ ### The seat glyph
158
+
159
+ **Colour means seat type; shape means status.** A seat's fill is always its
160
+ `SeatType.color`, or the theme's `availableBg` when the type has none — it does
161
+ not change when the seat is held or booked. `selected` is the one deliberate
162
+ exception: it owns the seat's colour outright, so your own picks are never
163
+ ambiguous, and a purple fill on its own would in any case fail WCAG 1.4.1 (Use
164
+ of Colour) for anyone who cannot distinguish it — hence the shape cues. Held
165
+ seats take a wash and a hollow occupant figure; booked ones a wash and a solid
166
+ figure; selected ones a bright core inside a frame.
167
+
168
+ ### Theming
169
+
170
+ Colors resolve through three tiers, highest first:
171
+
172
+ 1. A `--kerusi-*` custom property in **your** stylesheet.
173
+ 2. The **`colors` prop** — a partial `KerusiSeatmapColors`, merged over the defaults.
174
+ 3. The library default.
175
+
176
+ The library never writes a `--kerusi-*` property onto its own root, which is
177
+ what keeps that order true: every fill is emitted as
178
+ `var(--kerusi-selected-bg, <the resolved prop value>)`. Use whichever tier fits
179
+ — the prop for values known at build time, CSS for anything that has to respond
180
+ to a media query or a theme class.
181
+
182
+ ```css
183
+ .kerusi-seatmap {
184
+ --kerusi-selected-bg: #8b6ad6;
185
+ --kerusi-available-bg: #2f6b45;
186
+ }
187
+
188
+ @media (prefers-color-scheme: dark) {
189
+ .kerusi-seatmap {
190
+ --kerusi-element-bg: #232a36;
191
+ }
192
+ }
193
+ ```
194
+
195
+ Every key of `KerusiSeatmapColors` has a property, kebab-cased:
196
+
197
+ | Prop key | Custom property |
198
+ | ------------------------------------- | ----------------------------------------------------------- |
199
+ | `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
200
+ | `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
201
+ | `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
202
+ | `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
203
+ | `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
204
+ | `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
205
+ | `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
206
+ | `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
207
+ | `focusRing` | `--kerusi-focus-ring` |
208
+ | `backdrop` | `--kerusi-backdrop` |
209
+
210
+ ### Keyboard
211
+
212
+ Tab moves between sections; each section keeps its own tab stop. Within a
213
+ section, arrow keys follow the §4.3.1 `col` order — so an aisle, which is a
214
+ column no seat occupies, is stepped across rather than into.
215
+
216
+ | Key | |
217
+ | ------------------------ | ---------------------------------- |
218
+ | `←` `→` | Previous / next seat in the row |
219
+ | `↑` `↓` | Nearest column in the adjacent row |
220
+ | `Home` / `End` | First / last seat of the row |
221
+ | `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
222
+ | `PageUp` / `PageDown` | First / last seat of the section |
223
+ | `Enter` / `Space` | Toggle |
224
+ | `Escape` | Clear the selection |
225
+
226
+ Each seat is a `role="button"` announcing its position, type, price, status,
227
+ every `Seat.accessibility` property and its attributes. A polite live region
228
+ reports the running selection and any disallowed reason.
229
+
230
+ ---
231
+
232
+ ## `<KerusiLegend>`
233
+
234
+ Rendered inline by `showLegend`, or placed anywhere yourself:
235
+
236
+ ```tsx
237
+ <KerusiLegend legend={model.legend} locale="ms" showPrices />
238
+ ```
239
+
240
+ It resolves swatches through the same path as the seat fills, so the two cannot
241
+ drift apart. Every availability swatch — not just **Selected** — draws the seat
242
+ glyph itself rather than a flat colour, because shape is the cue the map uses
243
+ for status.
244
+
245
+ ---
246
+
247
+ ## `useKerusiState`
248
+
249
+ Live availability, fed by deltas from whatever transport you use. The ordering,
250
+ gap detection and hold expiry all come from `@kerusiweb/core`; the hook only
251
+ holds them in React state.
252
+
253
+ ```tsx
254
+ const availability = useKerusiState(initialState, { expiryIntervalMs: 1000 });
255
+
256
+ useEffect(() => {
257
+ const socket = new WebSocket(url);
258
+ socket.onmessage = (e) => {
259
+ if (availability.apply(JSON.parse(e.data)).outcome === 'gap') {
260
+ refetch().then(availability.reset);
261
+ }
262
+ };
263
+ return () => socket.close();
264
+ }, [availability.apply, availability.reset]);
265
+
266
+ <KerusiSeatmap map={map} state={availability.state} />;
267
+ ```
268
+
269
+ `apply`, `reset` and `tick` keep a stable identity across renders, so an effect
270
+ can subscribe once. It also exposes `needsRefetch` and `heldSeats` — every
271
+ currently-held seat with the time left on its hold, soonest first.
272
+
273
+ Deltas that are stale, duplicate or scoped to another session are discarded.
274
+
275
+ > **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
276
+ > increasing but not contiguous, so it cannot by itself distinguish "a delta was
277
+ > lost" from "nothing happened for a while". The hook detects gaps when the
278
+ > transport supplies a monotonic sequence — `metadata.seq` by default, or your
279
+ > own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
280
+ > rather than freezes while you re-fetch.
281
+
282
+ ---
283
+
284
+ ## Working with the format directly
285
+
286
+ All of this lives in `@kerusiweb/core` and is pure — no React import — so it can
287
+ run in a test, a build step, a server component or a route loader.
288
+
289
+ ```ts
290
+ import {
291
+ buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
292
+ checkKerusiMap, // every violation, no throw
293
+ validateKerusiMap, // throws on the first error
294
+ validateDocumentSet, // map / session / state joins
295
+ resolveSeatPrice, // the §4.9 precedence order
296
+ resolveLocalizedText, // string | Record<string, string> -> string
297
+ formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
298
+ summarizeSelection, // seats, total, unpriced count
299
+ computeSectionLayout, // placed geometry for one section
300
+ buildNavigationGraph, // per-seat keyboard neighbours
301
+ } from '@kerusiweb/core';
302
+ ```
303
+
304
+ ---
305
+
306
+ MIT © Arsyad "Shad Ahm" Ahmad
@@ -0,0 +1,29 @@
1
+ import type { KerusiSeatmapColors, RenderLegendEntry } from '@kerusiweb/core';
2
+ export interface KerusiLegendProps {
3
+ /** From `RenderMap.legend`. */
4
+ legend: readonly RenderLegendEntry[];
5
+ locale?: string;
6
+ colors?: Required<KerusiSeatmapColors>;
7
+ /** Whether an available seat takes its type color — mirrors the seatmap prop. */
8
+ typeColors?: boolean;
9
+ showPrices?: boolean;
10
+ /** Hide types no seat in the rendered map actually uses. */
11
+ hideUnusedTypes?: boolean;
12
+ headingSeatTypes?: string;
13
+ headingAvailability?: string;
14
+ }
15
+ /**
16
+ * A key for a seat map: its seat types on one side, its availability states on
17
+ * the other.
18
+ *
19
+ * This ships in the library rather than being left to each application because
20
+ * the swatch colors must come from the *same* resolution path as the seat
21
+ * fills — `SeatType.color` when the document supplies one, the theme's
22
+ * available color when it does not. Re-deriving that in application code is how
23
+ * a legend ends up disagreeing with the map it describes.
24
+ *
25
+ * It is opt-in: `<KerusiSeatmap showLegend />` renders it inline, or use
26
+ * `<KerusiLegend>` directly to place it anywhere.
27
+ */
28
+ export declare function KerusiLegend({ legend, locale, colors, typeColors, showPrices, hideUnusedTypes, headingSeatTypes, headingAvailability, }: KerusiLegendProps): import("react").JSX.Element;
29
+ //# sourceMappingURL=kerusi-legend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kerusi-legend.d.ts","sourceRoot":"","sources":["../src/kerusi-legend.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAuB,MAAM,iBAAiB,CAAC;AAcnG,MAAM,WAAW,iBAAiB;IAChC,+BAA+B;IAC/B,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAC;IACvC,iFAAiF;IACjF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,4DAA4D;IAC5D,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAuBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAC3B,MAAM,EACN,MAAa,EACb,MAA8B,EAC9B,UAAiB,EACjB,UAAiB,EACjB,eAAsB,EACtB,gBAA+B,EAC/B,mBAAoC,GACrC,EAAE,iBAAiB,+BAsFnB"}
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cssVar, DEFAULT_KERUSI_COLORS, formatMoney, occupantFillFor, occupantStrokeFor, readableOn, seatBodyPath, seatCoreFill, seatOccupantPath, seatOccupantStroke, seatSelectedFrame, seatWashFill, } from '@kerusiweb/core';
3
+ /** The swatch's viewBox edge; the seat glyph is drawn to fill it. */
4
+ const GLYPH_SIZE = 16;
5
+ const GLYPH_BODY = seatBodyPath(0, 0, GLYPH_SIZE, GLYPH_SIZE);
6
+ /** The selected swatch's frame width, and the core it leaves inside itself. */
7
+ const GLYPH_FRAME = seatSelectedFrame(GLYPH_SIZE, GLYPH_SIZE);
8
+ const GLYPH_CORE_SIZE = GLYPH_SIZE - GLYPH_FRAME * 2;
9
+ const GLYPH_CORE = seatBodyPath(0, 0, GLYPH_SIZE, GLYPH_SIZE, GLYPH_FRAME);
10
+ const GLYPH_OCCUPANT = seatOccupantPath(0, 0, GLYPH_SIZE, GLYPH_SIZE);
11
+ const GLYPH_OCCUPANT_STROKE = seatOccupantStroke(GLYPH_SIZE, GLYPH_SIZE);
12
+ /** The selected swatch's figure sits on the core, so it is drawn to the core. */
13
+ const GLYPH_CORE_OCCUPANT = seatOccupantPath(GLYPH_FRAME, GLYPH_FRAME, GLYPH_CORE_SIZE, GLYPH_CORE_SIZE);
14
+ const GLYPH_CORE_OCCUPANT_STROKE = seatOccupantStroke(GLYPH_CORE_SIZE, GLYPH_CORE_SIZE);
15
+ /**
16
+ * A key for a seat map: its seat types on one side, its availability states on
17
+ * the other.
18
+ *
19
+ * This ships in the library rather than being left to each application because
20
+ * the swatch colors must come from the *same* resolution path as the seat
21
+ * fills — `SeatType.color` when the document supplies one, the theme's
22
+ * available color when it does not. Re-deriving that in application code is how
23
+ * a legend ends up disagreeing with the map it describes.
24
+ *
25
+ * It is opt-in: `<KerusiSeatmap showLegend />` renders it inline, or use
26
+ * `<KerusiLegend>` directly to place it anywhere.
27
+ */
28
+ export function KerusiLegend({ legend, locale = 'en', colors = DEFAULT_KERUSI_COLORS, typeColors = true, showPrices = true, hideUnusedTypes = true, headingSeatTypes = 'Seat types', headingAvailability = 'Availability', }) {
29
+ const types = legend.filter((entry) => !hideUnusedTypes || entry.seatCount > 0);
30
+ const statuses = statusEntries(colors);
31
+ return (_jsxs("div", { className: "kerusi-legend", children: [types.length > 0 && (_jsxs("div", { className: "kerusi-legend__block", children: [_jsx("h4", { className: "kerusi-legend__heading", children: headingSeatTypes }), _jsx("ul", { className: "kerusi-legend__list", children: types.map((entry) => {
32
+ const typeColor = (typeColors && entry.color) || null;
33
+ const price = entry.price ? formatMoney(entry.price, locale) : '';
34
+ return (_jsxs("li", { className: "kerusi-legend__item", children: [_jsx("span", { className: "kerusi-legend__swatch", "aria-hidden": "true", style: {
35
+ background: typeColor ?? cssVar('availableBg', colors.availableBg),
36
+ // Mirrors `seatTextFill`: only a document-supplied color
37
+ // needs guessing at.
38
+ color: typeColor
39
+ ? readableOn(typeColor)
40
+ : cssVar('availableFg', colors.availableFg),
41
+ } }), _jsx("span", { className: "kerusi-legend__label", children: entry.label }), showPrices && price && _jsx("span", { className: "kerusi-legend__price", children: price })] }, entry.id));
42
+ }) })] })), _jsxs("div", { className: "kerusi-legend__block", children: [_jsx("h4", { className: "kerusi-legend__heading", children: headingAvailability }), _jsx("ul", { className: "kerusi-legend__list", children: statuses.map((status) => (_jsxs("li", { className: "kerusi-legend__item", children: [_jsxs("svg", { className: "kerusi-legend__swatch kerusi-legend__swatch--glyph", "aria-hidden": "true", focusable: "false", viewBox: `0 0 ${GLYPH_SIZE} ${GLYPH_SIZE}`, children: [_jsx("path", { d: GLYPH_BODY, style: { fill: status.fill } }), hasWash(status.kind) && (_jsx("path", { d: GLYPH_BODY, style: { fill: seatWashFill(status.kind, colors) }, opacity: "0.5" })), status.kind === 'selected' && (_jsx("path", { d: GLYPH_CORE, style: { fill: seatCoreFill(colors) } })), hasOccupant(status.kind) && (_jsx("path", { d: status.kind === 'selected' ? GLYPH_CORE_OCCUPANT : GLYPH_OCCUPANT, className: `kerusi-legend__occupant kerusi-legend__occupant--${status.kind}`, strokeWidth: status.kind === 'selected'
43
+ ? GLYPH_CORE_OCCUPANT_STROKE
44
+ : GLYPH_OCCUPANT_STROKE, style: {
45
+ fill: occupantFillFor(status.kind, colors),
46
+ stroke: occupantStrokeFor(status.kind, colors),
47
+ } }))] }), _jsx("span", { className: "kerusi-legend__label", children: status.label })] }, status.key))) })] })] }));
48
+ }
49
+ // Colour means seat type; shape means status (see `seatFill`'s doc comment).
50
+ // Held and booked swatches share the plain "available" body colour — the map's
51
+ // colour there is always whatever the seat's own type supplies, which the
52
+ // legend has no single value for — and are told apart by their wash and
53
+ // occupant figure instead, same as on the map.
54
+ function statusEntries(colors) {
55
+ const available = cssVar('availableBg', colors.availableBg);
56
+ return [
57
+ { key: 'available', label: 'Available', kind: 'plain', fill: available },
58
+ {
59
+ key: 'selected',
60
+ label: 'Selected',
61
+ kind: 'selected',
62
+ fill: cssVar('selectedBg', colors.selectedBg),
63
+ },
64
+ { key: 'held', label: 'On hold', kind: 'held', fill: available },
65
+ { key: 'booked', label: 'Booked', kind: 'booked', fill: available },
66
+ {
67
+ key: 'blocked',
68
+ label: 'Blocked',
69
+ kind: 'blocked',
70
+ fill: cssVar('blockedBg', colors.blockedBg),
71
+ },
72
+ ];
73
+ }
74
+ function hasWash(kind) {
75
+ return kind === 'held' || kind === 'booked';
76
+ }
77
+ function hasOccupant(kind) {
78
+ return kind === 'selected' || kind === 'held' || kind === 'booked';
79
+ }
80
+ //# sourceMappingURL=kerusi-legend.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kerusi-legend.js","sourceRoot":"","sources":["../src/kerusi-legend.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ import type { KerusiSeatmapHandle, KerusiSeatmapProps } from './types.js';
2
+ /**
3
+ * Renders a Kerusi seat map.
4
+ *
5
+ * Takes a `KerusiMap` and its `KerusiState` directly — no adapter, no
6
+ * intermediate row model. Each `Section` becomes its own `<svg>` with its own
7
+ * layout mode and proportions, seats take their color from the map's legend,
8
+ * every seat is keyboard-reachable and announced with its type, price, status
9
+ * and §4.3.4 accessibility properties, and selection is an immutable list of
10
+ * seat ids.
11
+ *
12
+ * ```tsx
13
+ * <KerusiSeatmap map={map} state={state} selection={picked} onSelectionChange={setPicked} />
14
+ * ```
15
+ *
16
+ * Import the stylesheet once, anywhere in the app:
17
+ * `import '@kerusiweb/react/styles.css'`.
18
+ */
19
+ export declare const KerusiSeatmap: import("react").ForwardRefExoticComponent<KerusiSeatmapProps & import("react").RefAttributes<KerusiSeatmapHandle>>;
20
+ //# sourceMappingURL=kerusi-seatmap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kerusi-seatmap.d.ts","sourceRoot":"","sources":["../src/kerusi-seatmap.tsx"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EACV,mBAAmB,EACnB,kBAAkB,EAGnB,MAAM,YAAY,CAAC;AAOpB;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,aAAa,oHAmazB,CAAC"}