@kerusiweb/react 2.3.0 → 3.1.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/README.md CHANGED
@@ -1,609 +1,818 @@
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, and 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. That is occasionally what you want, but
87
- usually a 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
- | `seatTypeStyles` | `SeatTypeStyles` | — | Styles for every seat of a type, keyed by `SeatType.id`. Also shown in the legend. See [From code](#from-code). |
96
- | `seatStyle` | `SeatStyleFn` | — | A style for one seat, over its type's. Never recolours a selected or blocked seat. See [From code](#from-code). |
97
- | `renderSeat` | `(ctx: SeatRenderContext) => ReactNode` | — | Draws the inside of every seat yourself. See [Custom seat content](#custom-seat-content). |
98
- | `seatSize` | `number` | `28` | Grid cell edge, in viewBox units. |
99
- | `seatGap` | `number` | `6` | Gap between grid cells. |
100
- | `freeformBasis` | `number` | `1000` | Freeform viewBox width; height follows the aspect ratio. |
101
- | `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. |
102
- | `showSectionLabels` | `boolean` | `true` | |
103
- | `showLegend` / `showLegendPrices` | `boolean` | `false` / `true` | |
104
- | `className` / `style` | — | — | Applied to the root element, alongside `kerusi-seatmap`. |
105
-
106
- **Sections**
107
-
108
- | Prop | Type | |
109
- | ------------------ | -------------------------------------- | ------------------------------------------------------------------- |
110
- | `sectionIds` | `readonly string[]` | Render only these, in this order. Default: all, by `Section.index`. |
111
- | `sectionOverrides` | `Record<string, SectionRenderOptions>` | Per-section `hidden`, `aspectRatio`, `seatSize`, `label`. |
112
-
113
- **Localization, validation, lifecycle**
114
-
115
- | Prop | Type | Default | |
116
- | ------------------ | ------------------------------- | ---------------------------- | ---------------------------------------------------------------------- |
117
- | `locale` | `string` | `KerusiMap.locale` ?? `'en'` | BCP-47. Resolves `Section.label` / `SeatType.label` locale maps. |
118
- | `rtl` | `boolean \| 'auto'` | `'auto'` | Mirrors layout and arrow direction. `auto` derives it from the locale. |
119
- | `ariaStrings` | `SeatAriaStrings` | English | Every announced phrase, for translation. |
120
- | `validate` | `'collect' \| 'throw' \| 'off'` | `'collect'` | `collect` reports through `onValidationIssues` and renders anyway. |
121
- | `expireHolds` | `boolean` | `false` | Revert a lapsed `holdExpires` to available on a ticker. |
122
- | `expiryIntervalMs` | `number` | `1000` | |
123
-
124
- `validate="throw"` throws a `KerusiValidationError` **during render**, so an
125
- error boundary catches it. `collect` never throws.
126
-
127
- **Zoom, pan and maximize** (both off by default; see [Zoom, pan and maximize](#zoom-pan-and-maximize))
128
-
129
- | Prop | Type | Default | |
130
- | ------------------ | ------------------------------- | ------- | -------------------------------------------------------------------------------------- |
131
- | `zoomable` | `boolean` | `false` | Pinch, Ctrl/Cmd + wheel and drag to zoom and pan; `+` `-` `0` keys; on-screen buttons. |
132
- | `maxZoom` | `number` | `4` | The zoom ceiling, relative to fit. The floor is always fit. |
133
- | `showZoomControls` | `boolean` | `true` | The built-in zoom buttons. Turn off to supply your own. |
134
- | `maximizable` | `boolean` | `false` | A button that fills the screen with the map. |
135
- | `controlStrings` | `Partial<KerusiControlStrings>` | English | The names of the zoom and maximize buttons, for translation. |
136
-
137
- ### Callbacks
138
-
139
- | Prop | Payload | |
140
- | --------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
141
- | `onSelectionChange` | `readonly string[]` | The selection after the change. |
142
- | `onSeatSelect` / `onSeatDeselect` | `SeatInteraction` | The `RenderSeat`, the resulting selection, and every seat that changed (companions included). |
143
- | `onSeatDisallowed` | `SeatDisallowed` | `reason` is `booked`, `held`, `blocked`, `not-selectable`, `max-selection` or `companion-unavailable`. |
144
- | `onSeatFocus` | `RenderSeat` | |
145
- | `onValidationIssues` | `readonly KerusiViolation[]` | Called whenever the documents change. |
146
- | `onViewportChange` | `KerusiViewport` | `{ zoom, x, y }`, whenever the zoom or pan changes. Only with `zoomable`. |
147
- | `onMaximizedChange` | `boolean` | When the map is maximized or restored. Only with `maximizable`. |
148
-
149
- ### The `ref`
150
-
151
- ```tsx
152
- const map = useRef<KerusiSeatmapHandle>(null);
153
- // map.current.summary -> { seats, total, unpriced }
154
- // map.current.focusSeat('A12')
155
- // map.current.viewport -> { zoom, x, y }
156
- // map.current.zoomIn() / zoomOut() / resetZoom() / toggleMaximized()
157
- ```
158
-
159
- ### Memoizing props
160
-
161
- The render model is rebuilt whenever `map`, `state` or the interaction props
162
- change identity. Object and function props declared inline get a new identity
163
- every render, so hoist or memoize the ones that are not primitives:
164
-
165
- ```tsx
166
- const seatSelectable = useCallback((seat: RenderSeat) => seat.price != null, []);
167
- const colors = useMemo(() => ({ selectedBg: '#8b6ad6' }), []);
168
- ```
169
-
170
- `map` and `state` are usually stable already if they come from state or a query
171
- cache. `selectableStatuses` and `sectionOverrides` have stable defaults, so you
172
- only need to think about them if you pass your own.
173
-
174
- ### The seat glyph
175
-
176
- **Colour means seat type; shape means status.** A seat's fill is always its
177
- `SeatType.color`, or the theme's `availableBg` when the type has none. It does
178
- not change when the seat is held or booked. `selected` is the one
179
- exception: it owns the seat's colour outright, so your own picks are never
180
- ambiguous, and a purple fill on its own would in any case fail WCAG 1.4.1 (Use
181
- of Colour) for anyone who cannot distinguish it, hence the shape cues. Held
182
- seats take a wash and a hollow occupant figure; booked ones a wash and a solid
183
- figure; selected ones a bright core inside a frame.
184
-
185
- ### Theming
186
-
187
- Colors resolve through three tiers, highest first:
188
-
189
- 1. A `--kerusi-*` custom property in **your** stylesheet.
190
- 2. The **`colors` prop**: a partial `KerusiSeatmapColors`, merged over the defaults.
191
- 3. The library default.
192
-
193
- The library never writes a `--kerusi-*` property onto its own root, which is
194
- what keeps that order true: every fill is emitted as
195
- `var(--kerusi-selected-bg, <the resolved prop value>)`. Use whichever tier fits:
196
- the prop for values known at build time, CSS for anything that has to respond
197
- to a media query or a theme class.
198
-
199
- ```css
200
- .kerusi-seatmap {
201
- --kerusi-selected-bg: #8b6ad6;
202
- --kerusi-available-bg: #2f6b45;
203
- }
204
-
205
- @media (prefers-color-scheme: dark) {
206
- .kerusi-seatmap {
207
- --kerusi-element-bg: #232a36;
208
- }
209
- }
210
- ```
211
-
212
- Every key of `KerusiSeatmapColors` has a property, kebab-cased:
213
-
214
- | Prop key | Custom property |
215
- | ------------------------------------- | ----------------------------------------------------------- |
216
- | `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
217
- | `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
218
- | `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
219
- | `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
220
- | `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
221
- | `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
222
- | `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
223
- | `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
224
- | `focusRing` | `--kerusi-focus-ring` |
225
- | `backdrop` | `--kerusi-backdrop` |
226
-
227
- A `SeatType.color` from the document has **no theme token**, because it is the
228
- map's own value under §4.7. It can be overridden only seat by seat (or type by
229
- type) with `--kerusi-seat-fill`; see
230
- [Styling individual seats](#styling-individual-seats). Turn it off wholesale
231
- with `typeColors={false}` if you want the theme to own every fill.
232
-
233
- For anything the palette does not cover, each seat group carries the same class
234
- hooks as the Angular binding (`.kerusi-seat`, `--available` / `--held` /
235
- `--booked` / `--blocked`, `--selected`, `--unselectable`, `--wheelchair`,
236
- `--companion`), and inside a seat: `.kerusi-seat__box`, `__wash`, `__core`,
237
- `__occupant` (`__occupant--selected` / `--held` / `--booked`), `__label`,
238
- `__wheelchair`, and, only while `renderSeat` is set, `__focus`.
239
-
240
- ### Styling individual seats
241
-
242
- The palette themes every seat of a status at once. To pick out _some_ seats (a
243
- VIP block, every aisle seat, one row), select them in CSS and set the per-seat
244
- tokens. Each seat's `<g>` carries what the document says about it:
245
-
246
- | Attribute | Value | Example selector |
247
- | ---------------------- | ------------------------------------------------------ | --------------------------------- |
248
- | `data-seat-id` | `Seat.id` | `[data-seat-id="12A"]` |
249
- | `data-seat-type` | `Seat.type` | `[data-seat-type="recliner"]` |
250
- | `data-seat-row` | `Seat.row`, when the seat has one | `[data-seat-row="A"]` |
251
- | `data-seat-status` | `available`, `held`, `booked` or `blocked` | `[data-seat-status="held"]` |
252
- | `data-seat-attributes` | `Seat.attributes`, space-separated, when there are any | `[data-seat-attributes~="aisle"]` |
253
- | `data-section-id` | The seat's section | `[data-section-id="balcony"]` |
254
-
255
- Two tokens then recolour the seats a selector picks out:
256
-
257
- | Token | Replaces |
258
- | --------------------- | -------------------------------------------------------------- |
259
- | `--kerusi-seat-fill` | The seat's own colour: its `SeatType.color`, or `availableBg`. |
260
- | `--kerusi-seat-label` | The colour of the number drawn on it. |
261
-
262
- ```css
263
- .kerusi-seatmap :is(.kerusi-seat, .kerusi-legend__item)[data-seat-type='vip'] {
264
- --kerusi-seat-fill: #d4a017;
265
- --kerusi-seat-label: #000000;
266
- }
267
-
268
- .kerusi-seatmap .kerusi-seat[data-seat-attributes~='aisle'] {
269
- --kerusi-seat-fill: #2f6b45;
270
- --kerusi-seat-label: #ffffff;
271
- }
272
- ```
273
-
274
- - **Per seat, not global.** The library never sets these tokens and gives them
275
- no map-wide default. They are meant for a selector that picks seats out;
276
- setting one on the map itself would repaint every seat and hide the
277
- document's type colours, which is what `typeColors={false}` and `availableBg` are for.
278
- - **Status still shows.** They replace only the seat's own colour. A held or
279
- booked seat keeps it under its wash and occupant figure, while a selected seat
280
- keeps its selected frame and core, and a blocked seat its grey, whatever the
281
- tokens say. Status is never carried by colour alone.
282
- - **Set the label with the fill.** The library cannot read a colour set in CSS,
283
- so it cannot choose a readable number colour for it.
284
- - **Keep the legend in step.** Each type in the legend carries the same
285
- `data-seat-type`, and its swatch reads `--kerusi-seat-fill`. Include
286
- `.kerusi-legend__item` in a type selector, as above, and the key matches the
287
- map.
288
-
289
- #### From code
290
-
291
- When the choice lives in TypeScript (a recommendation, a search result, the
292
- document's own `Seat.metadata`), use `seatTypeStyles` for whole types and
293
- `seatStyle` for single seats. Both take a `KerusiSeatStyle`:
294
-
295
- | Field | Type | |
296
- | ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
297
- | `fill` | `string` | The seat's own colour, the same slot as `--kerusi-seat-fill`. Any CSS colour. |
298
- | `labelColor` | `string` | The number's colour. Defaults to black or white for a hex `fill`; set it for any other kind of colour. |
299
- | `className` | `string \| readonly string[]` | Extra classes on the seat's `<g>`, for CSS that needs more than colour. |
300
- | `shape` | `KerusiSeatShape` | The seat's outline: `'seat'` (default), `'square'`, `'rounded'`, `'circle'`, or a function. See [Shapes](#shapes). |
301
-
302
- ```tsx
303
- import type { SeatStyleFn, SeatTypeStyles } from '@kerusiweb/core';
304
-
305
- const typeStyles: SeatTypeStyles = { vip: { fill: '#d4a017', labelColor: '#000000' } };
306
- const highlight: SeatStyleFn = (seat) =>
307
- seat.source.metadata?.['bestView'] ? { fill: '#2f6b45', className: 'best-view' } : undefined;
308
-
309
- <KerusiSeatmap map={map} seatTypeStyles={typeStyles} seatStyle={highlight} />;
310
- ```
311
-
312
- A seat's colour resolves in this order, first match wins:
313
-
314
- | # | Source | |
315
- | --- | ----------------------------------------- | ------------------------------------------------------ |
316
- | 1 | Selected | `selectedBg` frame and `selectedFg` core, always. |
317
- | 2 | Blocked | `blockedBg`, always. |
318
- | 3 | `--kerusi-seat-fill` from your stylesheet | See above. |
319
- | 4 | `seatStyle(seat)` | Its `fill`. |
320
- | 5 | `seatTypeStyles[seat.type]` | Its `fill`. Applies whether or not `typeColors` is on. |
321
- | 6 | `SeatType.color` | When `typeColors` is on. |
322
- | 7 | `availableBg` | The theme. |
323
-
324
- - **Status always reads.** Neither option can change a selected or blocked
325
- seat's colour, and a held or booked seat keeps its wash and occupant figure
326
- over whatever colour it has. A style that could recolour your own picks
327
- would make them ambiguous. For anything beyond this, use a
328
- [custom seat template](#custom-seat-content).
329
- - **Per-seat over per-type, field by field.** A `seatStyle` result replaces only
330
- the fields it sets, so a type's `fill` survives a per-seat `className`.
331
- Classes from both are kept.
332
- - **The legend shows `seatTypeStyles`**, because a type has one value to show.
333
- A per-seat `seatStyle` has no single value a legend could draw.
334
- - The function runs for every seat on every render of the map, so keep it
335
- cheap. A new function or object each render is fine, since neither rebuilds the
336
- render model, but declaring them once outside the component is simplest.
337
-
338
- #### Shapes
339
-
340
- A style's `shape` changes the seat's outline. It works in `seatTypeStyles` (a
341
- sofa type drawn rounded, say) and in `seatStyle`; for every seat at once, use
342
- `seatStyle={() => ({ shape: 'circle' })}`.
343
-
344
- | `shape` | |
345
- | -------------------------- | --------------------------------------------------------------------------- |
346
- | `'seat'` | The default: square-ish at the front, tapered at the back. |
347
- | `'square'` | A square with the same slight corners as the seat's front. |
348
- | `'rounded'` | A square with soft corners. |
349
- | `'circle'` | A circle filling the seat's box. |
350
- | `(box: SeatBox) => string` | Your own closed path `d`, drawn inside `box` (`x`, `y`, `width`, `height`). |
351
-
352
- - **Status marks follow the shape.** The wash, the selected frame and core, and
353
- a seat template's focus outline are all drawn in it, and the occupant figure
354
- and wheelchair marker sit inside every preset.
355
- - **Only `'seat'` shows which way a seat faces.** The other presets are
356
- symmetric, so on rotated seats (a fanned theatre, a stadium bowl) that cue is
357
- lost.
358
- - **A function shape must draw relative to the box it is given.** It is called
359
- once for the seat and again for the selected core's smaller box. Leave room
360
- for the occupant figure (the middle 44% of the width, from 15% to 86% of the
361
- height) and keep the top-right corner filled for the wheelchair marker.
362
- - **The legend draws a shaped type in its shape.** Types without a `shape`, and
363
- every status swatch, keep their usual swatches.
364
-
365
- ### Custom seat content
366
-
367
- When the palette and the class hooks are not enough (an icon, a badge, a price
368
- tag, a different shape), draw the inside of each seat yourself with
369
- `renderSeat`. It returns SVG, in the section's viewBox units:
370
-
371
- ```tsx
372
- import { KerusiSeatmap } from '@kerusiweb/react';
373
- import type { SeatRenderContext } from '@kerusiweb/react';
374
-
375
- function drawSeat({ seat, glyph, defaultContent }: SeatRenderContext) {
376
- return (
377
- <>
378
- {/* The stock seat, then a badge on top of it. */}
379
- {defaultContent}
380
- {seat.source.metadata?.['bestView'] ? (
381
- <circle cx={glyph.box.x + glyph.box.width} cy={glyph.box.y} r={4} fill="gold" />
382
- ) : null}
383
- </>
384
- );
385
- }
386
-
387
- <KerusiSeatmap map={map} state={state} renderSeat={drawSeat} />;
388
- ```
389
-
390
- `renderSeat` receives a `SeatRenderContext`:
391
-
392
- | Field | Type | |
393
- | ---------------- | ------------ | -------------------------------------------------------------------------------------------------- |
394
- | `seat` | `RenderSeat` | Status, type, price, accessibility, and the document's own `source.metadata`. |
395
- | `selected` | `boolean` | Whether the seat is in the current selection. |
396
- | `placed` | `PlacedSeat` | The seat's box, centre, font size and rotation, in viewBox units. |
397
- | `glyph` | `SeatGlyph` | The stock seat, resolved by `resolveSeatGlyph()` in core: every path, paint and position it draws. |
398
- | `defaultContent` | `ReactNode` | The stock drawing, as a fragment. Return it to keep the stock seat, or place it beside your marks. |
399
-
400
- It runs for every seat on every render of the map. A new function each render
401
- is fine, since it does not rebuild the render model, but declaring it once
402
- outside the component is simplest.
403
-
404
- **What the library keeps.** Each seat's `<g>` stays the library's: its role,
405
- `aria-label`, `aria-pressed`, `aria-disabled`, tab stop, `data-seat-id`, class
406
- list, rotation and its click, focus and key handlers. Only what is drawn inside
407
- it is yours. Keyboard navigation, selection, companions and announcements
408
- therefore behave exactly as they do for the stock seat, and the accessible name
409
- still comes from the library (and `ariaStrings`), whatever text you draw.
410
-
411
- **Coordinates.** Draw in the section's viewBox units, using `glyph.box` (or
412
- `placed`) for the seat's position and size. The `<g>` already carries the seat's
413
- rotation, so draw it unrotated with the front at the top edge;
414
- `glyph.label.transform` counter-rotates text so it stays upright.
415
-
416
- **Status is yours to show.** A template replaces the stock marks, including the
417
- occupant figure, the wash and the selected frame and core, which are what tell
418
- held, booked and selected apart by shape rather than colour (WCAG 1.4.1). The
419
- library's own styling options never alter those marks, so a template is the one
420
- place that can, and it carries that responsibility. The simplest way to keep
421
- them is to render `defaultContent` and add to it. Otherwise reuse `glyph.wash`,
422
- `glyph.core` and `glyph.occupant`, and the `kerusi-seat__wash` and
423
- `kerusi-seat__occupant--*` classes, which keep the stock opacity.
424
-
425
- **Focus outline.** The stock focus ring strokes `.kerusi-seat__box`, which your
426
- content may not have. So while a seat template is in use, the library draws one
427
- more path in every seat, after your content: `.kerusi-seat__focus`, an outline
428
- of the seat's body shown only on `:focus-visible`, so keyboard focus stays
429
- visible whatever you draw (WCAG 2.4.7). It takes the same colour as the stock
430
- ring (`--kerusi-focus-ring`, then `colors.focusRing`, then `currentColor`). It
431
- follows the seat's outline (its [`shape`](#shapes), if a style gives one), not
432
- your drawing; if you draw your own focus indicator, restyle or hide it with
433
- `.kerusi-seat:focus-visible .kerusi-seat__focus`. Without a seat template it is
434
- not rendered at all, and the markup is unchanged.
435
-
436
- **Legend.** The legend keeps drawing the stock glyph. If your seats look very
437
- different, turn it off and show your own key.
438
-
439
- ### Zoom, pan and maximize
440
-
441
- Two features, both **off by default**: with them off the markup and behaviour are
442
- exactly what they were. They are independent; turn on either or both.
443
-
444
- On a phone a map of several hundred seats is drawn at fit-to-width, so a seat is
445
- too small to tap. `zoomable` lets the reader zoom in and pan; `maximizable` adds a button
446
- that fills the screen.
447
-
448
- **Zoom and pan.**
449
-
450
- - **Pinch** with two fingers on a touch screen. **Ctrl/Cmd + wheel**, or a
451
- trackpad pinch (Safari's included), on desktop. A plain wheel is left alone, so
452
- the page still scrolls.
453
- - **Drag** with one finger or the primary mouse button to pan.
454
- - **A tap still selects**, at any zoom level. A touch or click that moves less
455
- than 8 CSS pixels is a tap, and reaches the seat exactly as it does without
456
- `zoomable`. Anything that moves further is a drag, and a drag never selects the
457
- seat under it.
458
- - **Limits.** The floor is fit-to-container, the ceiling is `maxZoom` (default
459
- `4`), and panning stays inside the map. A map smaller than its container, which
460
- happens when it is maximized on a large screen, is centred.
461
- - **Gestures and the page.** `touch-action` is `pan-y` at fit, so a one-finger
462
- vertical swipe still scrolls the page while a pinch is the map's; once zoomed in
463
- or maximized it is `none`. With `zoomable` off, no `touch-action` is set.
464
- - **Reduced motion.** The short transition on the zoom buttons, the keys and
465
- reset is dropped under `prefers-reduced-motion: reduce`. A finger or wheel moves
466
- the map directly and never eases.
467
-
468
- | Key | |
469
- | --------------- | ------------------------------------------------------------ |
470
- | `+` / `=`, `-` | Zoom in / out, from anywhere inside the map |
471
- | `0` | Reset to fit |
472
- | `←` `→` `↑` `↓` | Pan, when the zoomable viewport itself has focus (Tab to it) |
473
-
474
- On a seat, the arrow keys keep their usual job of moving seat focus, and a seat
475
- that takes keyboard focus is scrolled into view. Ctrl, Cmd and Alt combinations
476
- are left to the browser, and keys typed into a field you put inside the map are
477
- ignored.
478
-
479
- **Controls.** The zoom buttons (zoom in, zoom out, reset to fit) are real
480
- `<button>`s with accessible names. At a limit they set `aria-disabled` rather than
481
- `disabled`, so keyboard focus is not thrown away mid-press. Their names come from
482
- `controlStrings`, for translation. They use the theme's existing tokens
483
- (`--kerusi-element-bg`, `--kerusi-element-fg`, `--kerusi-element-muted-fg`,
484
- `--kerusi-focus-ring`), so there is nothing new to theme.
485
-
486
- To put the controls somewhere else, turn the built-in ones off
487
- (`showZoomControls={false}`) and drive the map yourself:
488
-
489
- ```tsx
490
- const map = useRef<KerusiSeatmapHandle>(null);
491
- // map.current.zoomIn(); map.current.zoomOut(); map.current.resetZoom(); map.current.toggleMaximized();
492
- // map.current.viewport -> { zoom, x, y }
493
- // map.current.maximized -> boolean
494
- ```
495
-
496
- `onViewportChange` reports `{ zoom, x, y }` whenever the view changes, for a host that wants
497
- to display or persist it. (Restoring a saved view is not supported.)
498
-
499
- **Maximize.** The button fills the screen with the map and its legend, freezes the
500
- page behind it, and swaps to an "Exit full screen" button. The layout is a fixed
501
- overlay, which is all iPhone Safari offers; where the browser has the Fullscreen
502
- API it is requested on top, and leaving it with the browser's own Esc leaves the
503
- map's maximized state too. `Escape` from the map also leaves, except that on a
504
- seat with a non-empty selection the first `Escape` still clears the selection, as
505
- it always has. The backdrop is `--kerusi-backdrop`, or the page's own canvas colour
506
- while that is transparent; `--kerusi-maximized-z-index` (default `1000`) sets the
507
- stacking. It is not a modal dialog: focus is not trapped, and an ancestor with a
508
- `transform` or `filter` would anchor the fixed layout to itself instead of the
509
- screen.
510
-
511
- ### Keyboard
512
-
513
- Tab moves between sections; each section keeps its own tab stop. Within a
514
- section, arrow keys follow the §4.3.1 `col` order, so an aisle, which is a
515
- column no seat occupies, is stepped across rather than into.
516
-
517
- | Key | |
518
- | ------------------------ | ---------------------------------- |
519
- | `←` `→` | Previous / next seat in the row |
520
- | `↑` `↓` | Nearest column in the adjacent row |
521
- | `Home` / `End` | First / last seat of the row |
522
- | `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
523
- | `PageUp` / `PageDown` | First / last seat of the section |
524
- | `Enter` / `Space` | Toggle |
525
- | `Escape` | Clear the selection |
526
-
527
- Each seat is a `role="button"` announcing its position, type, price, status,
528
- every `Seat.accessibility` property and its attributes. A polite live region
529
- reports the running selection and any disallowed reason.
530
-
531
- With `zoomable` on there are a few more keys: see [Zoom, pan and maximize](#zoom-pan-and-maximize).
532
-
533
- ---
534
-
535
- ## `<KerusiLegend>`
536
-
537
- Rendered inline by `showLegend`, or placed anywhere yourself:
538
-
539
- ```tsx
540
- <KerusiLegend legend={model.legend} locale="ms" showPrices />
541
- ```
542
-
543
- It resolves swatches through the same path as the seat fills, so the two cannot
544
- drift apart. Every availability swatch, not just **Selected**, draws the seat
545
- glyph itself rather than a flat colour, because shape is the cue the map uses
546
- for status.
547
-
548
- ---
549
-
550
- ## `useKerusiState`
551
-
552
- Live availability, fed by deltas from whatever transport you use. The ordering,
553
- gap detection and hold expiry all come from `@kerusiweb/core`; the hook only
554
- holds them in React state.
555
-
556
- ```tsx
557
- const availability = useKerusiState(initialState, { expiryIntervalMs: 1000 });
558
-
559
- useEffect(() => {
560
- const socket = new WebSocket(url);
561
- socket.onmessage = (e) => {
562
- if (availability.apply(JSON.parse(e.data)).outcome === 'gap') {
563
- refetch().then(availability.reset);
564
- }
565
- };
566
- return () => socket.close();
567
- }, [availability.apply, availability.reset]);
568
-
569
- <KerusiSeatmap map={map} state={availability.state} />;
570
- ```
571
-
572
- `apply`, `reset` and `tick` keep a stable identity across renders, so an effect
573
- can subscribe once. It also exposes `needsRefetch` and `heldSeats`: every
574
- currently-held seat with the time left on its hold, soonest first.
575
-
576
- Deltas that are stale, duplicate or scoped to another session are discarded.
577
-
578
- > **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
579
- > increasing but not contiguous, so it cannot by itself distinguish "a delta was
580
- > lost" from "nothing happened for a while". The hook detects gaps when the
581
- > transport supplies a monotonic sequence: `metadata.seq` by default, or your
582
- > own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
583
- > rather than freezes while you re-fetch.
584
-
585
- ---
586
-
587
- ## Working with the format directly
588
-
589
- All of this lives in `@kerusiweb/core` and is pure (no React import), so it can
590
- run in a test, a build step, a server component or a route loader.
591
-
592
- ```ts
593
- import {
594
- buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
595
- checkKerusiMap, // every violation, no throw
596
- validateKerusiMap, // throws on the first error
597
- validateDocumentSet, // map / session / state joins
598
- resolveSeatPrice, // the §4.9 precedence order
599
- resolveLocalizedText, // string | Record<string, string> -> string
600
- formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
601
- summarizeSelection, // seats, total, unpriced count
602
- computeSectionLayout, // placed geometry for one section
603
- buildNavigationGraph, // per-seat keyboard neighbours
604
- } from '@kerusiweb/core';
605
- ```
606
-
607
- ---
608
-
609
- MIT © Arsyad "Shad Ahm" Ahmad
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, and 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` turns selection off. Seats still take clicks, and stay keyboard-reachable, while `onSeatClick` or `renderClickCard` is set. |
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. That is occasionally what you want, but
87
+ usually a 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
+ | `seatTypeStyles` | `SeatTypeStyles` | — | Styles for every seat of a type, keyed by `SeatType.id`. Also shown in the legend. See [From code](#from-code). |
96
+ | `seatStyle` | `SeatStyleFn` | — | A style for one seat, over its type's. Never recolours a selected or blocked seat. See [From code](#from-code). |
97
+ | `renderSeat` | `(ctx: SeatRenderContext) => ReactNode` | — | Draws the inside of every seat yourself. See [Custom seat content](#custom-seat-content). |
98
+ | `unitScale` | `number` | `1` | CSS pixels per canvas unit, at most: the map never draws wider than `canvas.width × unitScale`. The only sizing control; the document fixes every seat's size and position. |
99
+ | `showSectionLabels` | `boolean` | `true` | Draw each section's label just above its `bounds`. Only sections that declare `bounds` get one; every section's label is its group's accessible name either way. |
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` and `label`. A hidden section leaves its space on the canvas empty. |
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 the whole canvas, and the arrow keys with it. `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
+ **Zoom, pan and maximize** (both off by default; see [Zoom, pan and maximize](#zoom-pan-and-maximize))
125
+
126
+ | Prop | Type | Default | |
127
+ | ------------------ | ------------------------------- | ------- | -------------------------------------------------------------------------------------- |
128
+ | `zoomable` | `boolean` | `false` | Pinch, Ctrl/Cmd + wheel and drag to zoom and pan; `+` `-` `0` keys; on-screen buttons. |
129
+ | `maxZoom` | `number` | `4` | The zoom ceiling, relative to fit. The floor is always fit. |
130
+ | `showZoomControls` | `boolean` | `true` | The built-in zoom buttons. Turn off to supply your own. |
131
+ | `maximizable` | `boolean` | `false` | A button that fills the screen with the map. |
132
+ | `controlStrings` | `Partial<KerusiControlStrings>` | English | The names of the zoom and maximize buttons, for translation. |
133
+
134
+ ### Callbacks
135
+
136
+ | Prop | Payload | |
137
+ | --------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ |
138
+ | `onSelectionChange` | `readonly string[]` | The selection after the change. |
139
+ | `onSeatSelect` / `onSeatDeselect` | `SeatInteraction` | The `RenderSeat`, the resulting selection, and every seat that changed (companions included). |
140
+ | `onSeatDisallowed` | `SeatDisallowed` | `reason` is `booked`, `held`, `blocked`, `not-selectable`, `max-selection` or `companion-unavailable`. |
141
+ | `onSeatFocus` | `RenderSeat` | |
142
+ | `onSeatHover` | `RenderSeat \| null` | The seat under the pointer, then `null` when it leaves. See [Hover and click](#hover-and-click). |
143
+ | `onSeatClick` | `SeatClick` | Every seat activation: `{ seat, selected, source }`. Read-only; fires before the selection callbacks. |
144
+ | `onValidationIssues` | `readonly KerusiViolation[]` | Called whenever the documents change. |
145
+ | `onViewportChange` | `KerusiViewport` | `{ zoom, x, y }`, whenever the zoom or pan changes. Only with `zoomable`. |
146
+ | `onMaximizedChange` | `boolean` | When the map is maximized or restored. Only with `maximizable`. |
147
+
148
+ ### The `ref`
149
+
150
+ ```tsx
151
+ const map = useRef<KerusiSeatmapHandle>(null);
152
+ // map.current.summary -> { seats, total, unpriced }
153
+ // map.current.focusSeat('A12')
154
+ // map.current.openSeatCard('A12') / closeSeatCard() / openSeatCardId
155
+ // map.current.viewport -> { zoom, x, y }
156
+ // map.current.zoomIn() / zoomOut() / resetZoom() / toggleMaximized()
157
+ // map.current.zoomToSection('balcony')
158
+ ```
159
+
160
+ ### Memoizing props
161
+
162
+ The render model is rebuilt whenever `map`, `state` or the interaction props
163
+ change identity. Object and function props declared inline get a new identity
164
+ every render, so hoist or memoize the ones that are not primitives:
165
+
166
+ ```tsx
167
+ const seatSelectable = useCallback((seat: RenderSeat) => seat.price != null, []);
168
+ const colors = useMemo(() => ({ selectedBg: '#8b6ad6' }), []);
169
+ ```
170
+
171
+ `map` and `state` are usually stable already if they come from state or a query
172
+ cache. `selectableStatuses` and `sectionOverrides` have stable defaults, so you
173
+ only need to think about them if you pass your own.
174
+
175
+ ### The seat glyph
176
+
177
+ **Colour means seat type; shape means status.** A seat's fill is always its
178
+ `SeatType.color`, or the theme's `availableBg` when the type has none. It does
179
+ not change when the seat is held or booked. `selected` is the one
180
+ exception: it owns the seat's colour outright, so your own picks are never
181
+ ambiguous, and a purple fill on its own would in any case fail WCAG 1.4.1 (Use
182
+ of Colour) for anyone who cannot distinguish it, hence the shape cues. Held
183
+ seats take a wash and a hollow occupant figure; booked ones a wash and a solid
184
+ figure; selected ones a bright core inside a frame.
185
+
186
+ ### Theming
187
+
188
+ Colors resolve through three tiers, highest first:
189
+
190
+ 1. A `--kerusi-*` custom property in **your** stylesheet.
191
+ 2. The **`colors` prop**: a partial `KerusiSeatmapColors`, merged over the defaults.
192
+ 3. The library default.
193
+
194
+ The library never writes a `--kerusi-*` property onto its own root, which is
195
+ what keeps that order true: every fill is emitted as
196
+ `var(--kerusi-selected-bg, <the resolved prop value>)`. Use whichever tier fits:
197
+ the prop for values known at build time, CSS for anything that has to respond
198
+ to a media query or a theme class.
199
+
200
+ ```css
201
+ .kerusi-seatmap {
202
+ --kerusi-selected-bg: #8b6ad6;
203
+ --kerusi-available-bg: #2f6b45;
204
+ }
205
+
206
+ @media (prefers-color-scheme: dark) {
207
+ .kerusi-seatmap {
208
+ --kerusi-element-bg: #232a36;
209
+ }
210
+ }
211
+ ```
212
+
213
+ Every key of `KerusiSeatmapColors` has a property, kebab-cased:
214
+
215
+ | Prop key | Custom property |
216
+ | ------------------------------------- | ----------------------------------------------------------- |
217
+ | `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
218
+ | `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
219
+ | `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
220
+ | `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
221
+ | `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
222
+ | `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
223
+ | `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
224
+ | `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
225
+ | `focusRing` | `--kerusi-focus-ring` |
226
+ | `backdrop` | `--kerusi-backdrop` |
227
+
228
+ A `SeatType.color` from the document has **no theme token**, because it is the
229
+ map's own value under §4.7. It can be overridden only seat by seat (or type by
230
+ type) with `--kerusi-seat-fill`; see
231
+ [Styling individual seats](#styling-individual-seats). Turn it off wholesale
232
+ with `typeColors={false}` if you want the theme to own every fill.
233
+
234
+ For anything the palette does not cover, each seat group carries the same class
235
+ hooks as the Angular binding (`.kerusi-seat`, `--available` / `--held` /
236
+ `--booked` / `--blocked`, `--selected`, `--unselectable`, `--wheelchair`,
237
+ `--companion`), and inside a seat: `.kerusi-seat__box`, `__wash`, `__core`,
238
+ `__occupant` (`__occupant--selected` / `--held` / `--booked`), `__label`,
239
+ `__wheelchair`, and, only while `renderSeat` is set, `__focus`.
240
+
241
+ ### Styling individual seats
242
+
243
+ The palette themes every seat of a status at once. To pick out _some_ seats (a
244
+ VIP block, every aisle seat, one row), select them in CSS and set the per-seat
245
+ tokens. Each seat's `<g>` carries what the document says about it:
246
+
247
+ | Attribute | Value | Example selector |
248
+ | ---------------------- | ------------------------------------------------------ | --------------------------------- |
249
+ | `data-seat-id` | `Seat.id` | `[data-seat-id="12A"]` |
250
+ | `data-seat-type` | `Seat.type` | `[data-seat-type="recliner"]` |
251
+ | `data-seat-row` | `Seat.row`, when the seat has one | `[data-seat-row="A"]` |
252
+ | `data-seat-status` | `available`, `held`, `booked` or `blocked` | `[data-seat-status="held"]` |
253
+ | `data-seat-attributes` | `Seat.attributes`, space-separated, when there are any | `[data-seat-attributes~="aisle"]` |
254
+ | `data-section-id` | The seat's section | `[data-section-id="balcony"]` |
255
+
256
+ Two tokens then recolour the seats a selector picks out:
257
+
258
+ | Token | Replaces |
259
+ | --------------------- | -------------------------------------------------------------- |
260
+ | `--kerusi-seat-fill` | The seat's own colour: its `SeatType.color`, or `availableBg`. |
261
+ | `--kerusi-seat-label` | The colour of the number drawn on it. |
262
+
263
+ ```css
264
+ .kerusi-seatmap :is(.kerusi-seat, .kerusi-legend__item)[data-seat-type='vip'] {
265
+ --kerusi-seat-fill: #d4a017;
266
+ --kerusi-seat-label: #000000;
267
+ }
268
+
269
+ .kerusi-seatmap .kerusi-seat[data-seat-attributes~='aisle'] {
270
+ --kerusi-seat-fill: #2f6b45;
271
+ --kerusi-seat-label: #ffffff;
272
+ }
273
+ ```
274
+
275
+ - **Per seat, not global.** The library never sets these tokens and gives them
276
+ no map-wide default. They are meant for a selector that picks seats out;
277
+ setting one on the map itself would repaint every seat and hide the
278
+ document's type colours, which is what `typeColors={false}` and `availableBg` are for.
279
+ - **Status still shows.** They replace only the seat's own colour. A held or
280
+ booked seat keeps it under its wash and occupant figure, while a selected seat
281
+ keeps its selected frame and core, and a blocked seat its grey, whatever the
282
+ tokens say. Status is never carried by colour alone.
283
+ - **Set the label with the fill.** The library cannot read a colour set in CSS,
284
+ so it cannot choose a readable number colour for it.
285
+ - **Keep the legend in step.** Each type in the legend carries the same
286
+ `data-seat-type`, and its swatch reads `--kerusi-seat-fill`. Include
287
+ `.kerusi-legend__item` in a type selector, as above, and the key matches the
288
+ map.
289
+
290
+ #### From code
291
+
292
+ When the choice lives in TypeScript (a recommendation, a search result, the
293
+ document's own `Seat.metadata`), use `seatTypeStyles` for whole types and
294
+ `seatStyle` for single seats. Both take a `KerusiSeatStyle`:
295
+
296
+ | Field | Type | |
297
+ | ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
298
+ | `fill` | `string` | The seat's own colour, the same slot as `--kerusi-seat-fill`. Any CSS colour. |
299
+ | `labelColor` | `string` | The number's colour. Defaults to black or white for a hex `fill`; set it for any other kind of colour. |
300
+ | `className` | `string \| readonly string[]` | Extra classes on the seat's `<g>`, for CSS that needs more than colour. |
301
+ | `shape` | `KerusiSeatShape` | The seat's outline: `'seat'` (default), `'square'`, `'rounded'`, `'circle'`, or a function. See [Shapes](#shapes). |
302
+
303
+ ```tsx
304
+ import type { SeatStyleFn, SeatTypeStyles } from '@kerusiweb/core';
305
+
306
+ const typeStyles: SeatTypeStyles = { vip: { fill: '#d4a017', labelColor: '#000000' } };
307
+ const highlight: SeatStyleFn = (seat) =>
308
+ seat.source.metadata?.['bestView'] ? { fill: '#2f6b45', className: 'best-view' } : undefined;
309
+
310
+ <KerusiSeatmap map={map} seatTypeStyles={typeStyles} seatStyle={highlight} />;
311
+ ```
312
+
313
+ A seat's colour resolves in this order, first match wins:
314
+
315
+ | # | Source | |
316
+ | --- | ----------------------------------------- | ------------------------------------------------------ |
317
+ | 1 | Selected | `selectedBg` frame and `selectedFg` core, always. |
318
+ | 2 | Blocked | `blockedBg`, always. |
319
+ | 3 | `--kerusi-seat-fill` from your stylesheet | See above. |
320
+ | 4 | `seatStyle(seat)` | Its `fill`. |
321
+ | 5 | `seatTypeStyles[seat.type]` | Its `fill`. Applies whether or not `typeColors` is on. |
322
+ | 6 | `SeatType.color` | When `typeColors` is on. |
323
+ | 7 | `availableBg` | The theme. |
324
+
325
+ - **Status always reads.** Neither option can change a selected or blocked
326
+ seat's colour, and a held or booked seat keeps its wash and occupant figure
327
+ over whatever colour it has. A style that could recolour your own picks
328
+ would make them ambiguous. For anything beyond this, use a
329
+ [custom seat template](#custom-seat-content).
330
+ - **Per-seat over per-type, field by field.** A `seatStyle` result replaces only
331
+ the fields it sets, so a type's `fill` survives a per-seat `className`.
332
+ Classes from both are kept.
333
+ - **The legend shows `seatTypeStyles`**, because a type has one value to show.
334
+ A per-seat `seatStyle` has no single value a legend could draw.
335
+ - The function runs for every seat on every render of the map, so keep it
336
+ cheap. A new function or object each render is fine, since neither rebuilds the
337
+ render model, but declaring them once outside the component is simplest.
338
+
339
+ #### Shapes
340
+
341
+ A style's `shape` changes the seat's outline. It works in `seatTypeStyles` (a
342
+ sofa type drawn rounded, say) and in `seatStyle`; for every seat at once, use
343
+ `seatStyle={() => ({ shape: 'circle' })}`.
344
+
345
+ | `shape` | |
346
+ | -------------------------- | --------------------------------------------------------------------------- |
347
+ | `'seat'` | The default: square-ish at the front, tapered at the back. |
348
+ | `'square'` | A square with the same slight corners as the seat's front. |
349
+ | `'rounded'` | A square with soft corners. |
350
+ | `'circle'` | A circle filling the seat's box. |
351
+ | `(box: SeatBox) => string` | Your own closed path `d`, drawn inside `box` (`x`, `y`, `width`, `height`). |
352
+
353
+ - **Status marks follow the shape.** The wash, the selected frame and core, and
354
+ a seat template's focus outline are all drawn in it, and the occupant figure
355
+ and wheelchair marker sit inside every preset.
356
+ - **Only `'seat'` shows which way a seat faces.** The other presets are
357
+ symmetric, so on rotated seats (a fanned theatre, a stadium bowl) that cue is
358
+ lost.
359
+ - **A function shape must draw relative to the box it is given.** It is called
360
+ once for the seat and again for the selected core's smaller box. Leave room
361
+ for the occupant figure (the middle 44% of the width, from 15% to 86% of the
362
+ height) and keep the top-right corner filled for the wheelchair marker.
363
+ - **The legend draws a shaped type in its shape.** Types without a `shape`, and
364
+ every status swatch, keep their usual swatches.
365
+
366
+ ### Custom seat content
367
+
368
+ When the palette and the class hooks are not enough (an icon, a badge, a price
369
+ tag, a different shape), draw the inside of each seat yourself with
370
+ `renderSeat`. It returns SVG, in canvas units:
371
+
372
+ ```tsx
373
+ import { KerusiSeatmap } from '@kerusiweb/react';
374
+ import type { SeatRenderContext } from '@kerusiweb/react';
375
+
376
+ function drawSeat({ seat, glyph, defaultContent }: SeatRenderContext) {
377
+ return (
378
+ <>
379
+ {/* The stock seat, then a badge on top of it. */}
380
+ {defaultContent}
381
+ {seat.source.metadata?.['bestView'] ? (
382
+ <circle cx={glyph.box.x + glyph.box.width} cy={glyph.box.y} r={4} fill="gold" />
383
+ ) : null}
384
+ </>
385
+ );
386
+ }
387
+
388
+ <KerusiSeatmap map={map} state={state} renderSeat={drawSeat} />;
389
+ ```
390
+
391
+ `renderSeat` receives a `SeatRenderContext`:
392
+
393
+ | Field | Type | |
394
+ | ---------------- | ------------ | -------------------------------------------------------------------------------------------------- |
395
+ | `seat` | `RenderSeat` | Status, type, price, accessibility, and the document's own `source.metadata`. |
396
+ | `selected` | `boolean` | Whether the seat is in the current selection. |
397
+ | `placed` | `PlacedSeat` | The seat's box, centre, font size and rotation, in canvas units. |
398
+ | `glyph` | `SeatGlyph` | The stock seat, resolved by `resolveSeatGlyph()` in core: every path, paint and position it draws. |
399
+ | `defaultContent` | `ReactNode` | The stock drawing, as a fragment. Return it to keep the stock seat, or place it beside your marks. |
400
+
401
+ It runs for every seat on every render of the map. A new function each render
402
+ is fine, since it does not rebuild the render model, but declaring it once
403
+ outside the component is simplest.
404
+
405
+ **What the library keeps.** Each seat's `<g>` stays the library's: its role,
406
+ `aria-label`, `aria-pressed`, `aria-disabled`, tab stop, `data-seat-id`, class
407
+ list, rotation and its click, focus and key handlers. Only what is drawn inside
408
+ it is yours. Keyboard navigation, selection, companions and announcements
409
+ therefore behave exactly as they do for the stock seat, and the accessible name
410
+ still comes from the library (and `ariaStrings`), whatever text you draw.
411
+
412
+ **Coordinates.** Draw in canvas units, using `glyph.box` (or
413
+ `placed`) for the seat's position and size. The `<g>` already carries the seat's
414
+ rotation, so draw it unrotated with the front at the top edge;
415
+ `glyph.label.transform` counter-rotates text so it stays upright.
416
+
417
+ **Status is yours to show.** A template replaces the stock marks, including the
418
+ occupant figure, the wash and the selected frame and core, which are what tell
419
+ held, booked and selected apart by shape rather than colour (WCAG 1.4.1). The
420
+ library's own styling options never alter those marks, so a template is the one
421
+ place that can, and it carries that responsibility. The simplest way to keep
422
+ them is to render `defaultContent` and add to it. Otherwise reuse `glyph.wash`,
423
+ `glyph.core` and `glyph.occupant`, and the `kerusi-seat__wash` and
424
+ `kerusi-seat__occupant--*` classes, which keep the stock opacity.
425
+
426
+ **Focus outline.** The stock focus ring strokes `.kerusi-seat__box`, which your
427
+ content may not have. So while a seat template is in use, the library draws one
428
+ more path in every seat, after your content: `.kerusi-seat__focus`, an outline
429
+ of the seat's body shown only on `:focus-visible`, so keyboard focus stays
430
+ visible whatever you draw (WCAG 2.4.7). It takes the same colour as the stock
431
+ ring (`--kerusi-focus-ring`, then `colors.focusRing`, then `currentColor`). It
432
+ follows the seat's outline (its [`shape`](#shapes), if a style gives one), not
433
+ your drawing; if you draw your own focus indicator, restyle or hide it with
434
+ `.kerusi-seat:focus-visible .kerusi-seat__focus`. Without a seat template it is
435
+ not rendered at all, and the markup is unchanged.
436
+
437
+ **Legend.** The legend keeps drawing the stock glyph. If your seats look very
438
+ different, turn it off and show your own key.
439
+
440
+ ### Zoom, pan and maximize
441
+
442
+ Two features, both **off by default**: with them off the markup and behaviour are
443
+ exactly what they were. They are independent; turn on either or both.
444
+
445
+ On a phone a map of several hundred seats is drawn at fit-to-width, so a seat is
446
+ too small to tap. `zoomable` lets the reader zoom in and pan; `maximizable` adds a button
447
+ that fills the screen.
448
+
449
+ **Zoom and pan.**
450
+
451
+ - **Pinch** with two fingers on a touch screen. **Ctrl/Cmd + wheel**, or a
452
+ trackpad pinch (Safari's included), on desktop. A plain wheel is left alone, so
453
+ the page still scrolls.
454
+ - **Drag** with one finger or the primary mouse button to pan.
455
+ - **A tap still selects**, at any zoom level. A touch or click that moves less
456
+ than 8 CSS pixels is a tap, and reaches the seat exactly as it does without
457
+ `zoomable`. Anything that moves further is a drag, and a drag never selects the
458
+ seat under it.
459
+ - **Limits.** The floor is fit-to-container, the ceiling is `maxZoom` (default
460
+ `4`), and panning stays inside the map. A map smaller than its container, which
461
+ happens when it is maximized on a large screen, is centred.
462
+ - **Gestures and the page.** `touch-action` is `pan-y` at fit, so a one-finger
463
+ vertical swipe still scrolls the page while a pinch is the map's; once zoomed in
464
+ or maximized it is `none`. With `zoomable` off, no `touch-action` is set.
465
+ - **Reduced motion.** The short transition on the zoom buttons, the keys and
466
+ reset is dropped under `prefers-reduced-motion: reduce`. A finger or wheel moves
467
+ the map directly and never eases.
468
+
469
+ | Key | |
470
+ | --------------- | ------------------------------------------------------------ |
471
+ | `+` / `=`, `-` | Zoom in / out, from anywhere inside the map |
472
+ | `0` | Reset to fit |
473
+ | `←` `→` `↑` `↓` | Pan, when the zoomable viewport itself has focus (Tab to it) |
474
+
475
+ On a seat, the arrow keys keep their usual job of moving seat focus, and a seat
476
+ that takes keyboard focus is scrolled into view. Ctrl, Cmd and Alt combinations
477
+ are left to the browser, and keys typed into a field you put inside the map are
478
+ ignored.
479
+
480
+ **Controls.** The zoom buttons (zoom in, zoom out, reset to fit) are real
481
+ `<button>`s with accessible names. At a limit they set `aria-disabled` rather than
482
+ `disabled`, so keyboard focus is not thrown away mid-press. Their names come from
483
+ `controlStrings`, for translation. They are one compact bar (32px buttons, 44px
484
+ on touch screens) coloured from the theme's existing tokens
485
+ (`--kerusi-element-bg`, `--kerusi-element-fg`, `--kerusi-element-muted-fg`,
486
+ `--kerusi-focus-ring`).
487
+
488
+ To restyle them, set any of these in your own stylesheet; each beats the theme
489
+ colours without `!important`:
490
+
491
+ | Token | Default |
492
+ | ------------------------------ | -------------------------------------------------- |
493
+ | `--kerusi-control-size` | `2rem` (`2.75rem` on touch screens) |
494
+ | `--kerusi-control-icon-size` | `1rem` |
495
+ | `--kerusi-control-icon-stroke` | `1.75` |
496
+ | `--kerusi-control-radius` | `0.5rem` |
497
+ | `--kerusi-control-shadow` | `0 1px 3px rgb(0 0 0 / 0.14)` |
498
+ | `--kerusi-control-bg` | `--kerusi-element-bg` |
499
+ | `--kerusi-control-fg` | `--kerusi-element-fg` |
500
+ | `--kerusi-control-border` | `--kerusi-element-muted-fg` at 40% |
501
+ | `--kerusi-control-hover-bg` | the foreground mixed 10% (18% pressed) into the bg |
502
+
503
+ ```css
504
+ .kerusi-seatmap {
505
+ --kerusi-control-size: 2.5rem;
506
+ --kerusi-control-radius: 999px;
507
+ --kerusi-control-bg: #1b1f27;
508
+ --kerusi-control-fg: #fff;
509
+ }
510
+ ```
511
+
512
+ The classes `.kerusi-toolbar`, `.kerusi-control` and `.kerusi-control--zoom-in`,
513
+ `--zoom-out`, `--zoom-reset` and `--maximize` are there for anything the tokens
514
+ do not cover.
515
+
516
+ To put the controls somewhere else, turn the built-in ones off
517
+ (`showZoomControls={false}`) and drive the map yourself:
518
+
519
+ ```tsx
520
+ const map = useRef<KerusiSeatmapHandle>(null);
521
+ // map.current.zoomIn(); map.current.zoomOut(); map.current.resetZoom(); map.current.toggleMaximized();
522
+ // map.current.zoomToSection('balcony');
523
+ // map.current.viewport -> { zoom, x, y }
524
+ // map.current.maximized -> boolean
525
+ ```
526
+
527
+ `onViewportChange` reports `{ zoom, x, y }` whenever the view changes, for a host that wants
528
+ to display or persist it. (Restoring a saved view is not supported.)
529
+
530
+ **Zoom to a section.** `zoomToSection(id)` zooms to a section's declared `bounds`, as
531
+ large as the viewport allows and centred, or to the box around its seats and
532
+ elements when it declares none. It does nothing unless the map is `zoomable`.
533
+ Reset with `resetZoom()`, which shows the whole canvas again.
534
+
535
+ **Maximize.** The button fills the screen with the map and its legend, freezes the
536
+ page behind it, and swaps to an "Exit full screen" button. The layout is a fixed
537
+ overlay, which is all iPhone Safari offers; where the browser has the Fullscreen
538
+ API it is requested on top, and leaving it with the browser's own Esc leaves the
539
+ map's maximized state too. `Escape` from the map also leaves, except that on a
540
+ seat with a non-empty selection the first `Escape` still clears the selection, as
541
+ it always has. The backdrop is `--kerusi-backdrop`, or the page's own canvas colour
542
+ while that is transparent; `--kerusi-maximized-z-index` (default `1000`) sets the
543
+ stacking. A maximized zoomable map fits the whole canvas to the screen at the
544
+ canvas's own proportions. It is not a modal dialog: focus is not trapped, and an ancestor with a
545
+ `transform` or `filter` would anchor the fixed layout to itself instead of the
546
+ screen.
547
+
548
+ ### Hover and click
549
+
550
+ What happens when the pointer **hovers** a seat, and what happens when a seat is **clicked**, are decided separately, and each is optional (D10). Nothing is on by default: a map with none of the props below renders and behaves exactly as it always did.
551
+
552
+ | Hover | Click | How |
553
+ | -------- | -------- | ----------------------------------------------------------------------- |
554
+ | nothing | nothing | The default: no new markup, no new behaviour. |
555
+ | card | nothing | A read-only "who sits where" map: point at a seat to see who booked it. |
556
+ | nothing | card | A booking map: tap a seat to see its price and perks in a card. |
557
+ | card | card | A short tooltip on hover, plus a fuller card with actions on click. |
558
+ | callback | callback | Your own UI: the library only reports the events. |
559
+
560
+ Two layers ship, and each is useful without the other: **events**, which report the seat and nothing more (below), and optional [**seat cards**](#seat-cards), which show a card anchored to the seat.
561
+
562
+ ```tsx
563
+ <KerusiSeatmap
564
+ map={map}
565
+ state={state}
566
+ interactive={false}
567
+ onSeatHover={(seat) => setHovered(seat?.id ?? null)} // RenderSeat | null
568
+ onSeatClick={({ seat, selected, source }) => openDetail(seat.id)}
569
+ onSeatFocus={(seat) => setHovered(seat.id)} // the same detail for a seat focused by keyboard
570
+ />
571
+ ```
572
+
573
+ **`onSeatHover(seat: RenderSeat | null)`**
574
+
575
+ - Called with the seat under the pointer when it enters a seat, and with `null` when it leaves the seat or the map. It is not called again while the pointer moves within the same seat.
576
+ - The payload is the `RenderSeat` that `onSeatDisallowed` gets: id, label, row label, section, status, type, price, accessibility and `source.metadata`.
577
+ - **Mouse and pen only.** Touch has no hover, and a tap behaves as it always did.
578
+ - **Not fired while the map is panned or zoomed.** The hovered seat is cleared (`null`) once when a drag, pinch or wheel zoom starts, and hover resumes on the next pointer move after it ends.
579
+ - Fires on a map with `interactive={false}` too, and with a custom `renderSeat`. It works at any zoom and in full screen.
580
+
581
+ **`onSeatClick({ seat, selected, source })`**
582
+
583
+ - Called on **every** activation of **any** seat, whether or not it can be selected: booked, held and blocked seats included. This is the supported way to react to a click on a booked seat, in place of catching `onSeatDisallowed`.
584
+ - `selected` is whether the seat was selected **before** the click. `source` is `'pointer'` (a click or tap) or `'keyboard'` (Enter or Space on a focused seat).
585
+ - It fires before the selection callbacks for the same click, and it is **read-only** (D13): it cannot cancel or change what the click does. `onSeatSelect`, `onSeatDeselect` and `onSeatDisallowed` fire exactly as they do without it.
586
+ - To show details _without_ selecting, turn selection off with props that already exist: `interactive={false}`, `selectableStatuses={[]}` or `seatSelectable`.
587
+ - A tap that turns into a pan is never a click.
588
+
589
+ **`interactive={false}` and clicks (D14).** `false` turns selection off. Seats still take clicks, and stay reachable by Tab and the arrow keys, while `onSeatClick` or `renderClickCard` is set: Enter and Space then activate the click behaviour, with no toggle and no `aria-pressed`. There is no extra prop: setting one of them turns this on, and removing both turns it off again, back to a read-only map's usual markup. A hover card that follows keyboard focus (`hoverCardOnFocus`) makes the seats reachable too.
590
+
591
+ ### Seat cards
592
+
593
+ A floating card anchored to a seat: **one card element, two triggers**, and you choose which to use. The library handles positioning, dismissal, zoom, full screen, touch and accessibility. **What a card says is yours** (D10): with neither function set, nothing is rendered and the markup is unchanged.
594
+
595
+ ```tsx
596
+ <KerusiSeatmap
597
+ map={map}
598
+ state={state}
599
+ interactive={false}
600
+ // Shown while a seat is hovered. Return null for no card on this seat.
601
+ renderHoverCard={({ seat }) =>
602
+ seat.status === 'booked' ? <span>{bookings[seat.id]?.name}</span> : null
603
+ }
604
+ // Opened on click, tap or Enter. Stays open until dismissed.
605
+ renderClickCard={({ seat, close }) => <BookingDetail id={seat.id} onDone={close} />}
606
+ seatCardPlacement="top" // 'top' | 'bottom' | 'left' | 'right' | 'auto'
607
+ hoverCardDelay={{ open: 150, close: 100 }}
608
+ hoverCardOnFocus // opt in: also show the hover card on keyboard focus
609
+ />
610
+ ```
611
+
612
+ Both functions are handed a `KerusiSeatCardContext`:
613
+
614
+ | Field | Type | |
615
+ | ---------- | ------------------------------- | -------------------------------------------------------------------- |
616
+ | `seat` | `RenderSeat` | Everything the document knows about the seat, already resolved. |
617
+ | `selected` | `boolean` | Whether the seat is in the current selection. |
618
+ | `trigger` | `'hover' \| 'focus' \| 'click'` | What opened the card. `'focus'` only occurs with `hoverCardOnFocus`. |
619
+ | `close` | `() => void` | Closes the card. Most useful for a click card's own buttons. |
620
+
621
+ | Prop | Type | Default | |
622
+ | -------------------- | -------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------- |
623
+ | `renderHoverCard` | `(ctx: KerusiSeatCardContext) => ReactNode` | — | The card shown on hover. Never shown for touch. |
624
+ | `renderClickCard` | `(ctx: KerusiSeatCardContext) => ReactNode` | — | The card opened by a click, a tap or Enter/Space. |
625
+ | `seatCardPlacement` | `'top' \| 'bottom' \| 'left' \| 'right' \| 'auto'` | `'top'` | The side it prefers. It flips when it would overflow the map. |
626
+ | `seatCardClassName` | `string` | — | Extra classes on the card element. |
627
+ | `seatCardAppearance` | `'default' \| 'none'` | `'default'` | `none` drops the library's chrome. |
628
+ | `hoverCardDelay` | `{ open?: number; close?: number }` | `{ open: 150, close: 100 }` | Milliseconds before a hover card opens, and after the pointer leaves it closes. |
629
+ | `hoverCardOnFocus` | `boolean` | `false` | Also show the hover card on keyboard focus (D12). |
630
+
631
+ | | Hover card | Click card |
632
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
633
+ | **Opens on** | The pointer entering a seat, after `hoverCardDelay.open`. Keyboard focus too, **only** with `hoverCardOnFocus`. | A click, a tap, or Enter/Space on a seat. |
634
+ | **Closes on** | The pointer leaving both the seat and the card (after `hoverCardDelay.close`), focus leaving, `Escape`, or a pan or zoom starting. | `Escape`, a press outside the card and the seats, a second click on the same seat, `close()`, or another seat's click card opening. |
635
+ | **Touch** | Never shown. | Shown: a tap is the trigger. |
636
+ | **The pointer can enter it** | Yes. The close delay lets it travel in to select text or follow a link (WCAG 1.4.13). | Yes. |
637
+ | **ARIA** | `role="tooltip"`, wired to the seat with `aria-describedby`. | A non-modal `role="dialog"` labelled by the seat. Focus moves into it when opened from the keyboard, and returns to the seat on close. |
638
+ | **With both set** | While a seat's click card is open, its hover card is hidden. | |
639
+
640
+ - **A click on a selectable seat toggles it and opens the click card.** Neither the card nor `onSeatClick` can stop the toggle (D13).
641
+ - **Keyboard users and hover cards.** With `hoverCardOnFocus` off (the default), a hover card is pointer-only, so it must not be the only place important information lives. For keyboard parity use `hoverCardOnFocus`, a click card, or `onSeatFocus` beside `onSeatHover`.
642
+ - **Return `null` (or nothing) from a render function** for no card on that seat: show cards for booked seats only, for example.
643
+ - **Positioning.** The card is anchored to the seat's on-screen box and **follows zoom and pan**. It flips to the opposite side when it would overflow the map, and is clamped inside it. It is rendered inside `.kerusi-seatmap`, outside the clipped viewport, so it shows in full screen and inherits the `--kerusi-*` properties set on the map.
644
+ - **Live state.** If the seat's status changes while a card is open, the card re-renders with the new `RenderSeat`. If the seat disappears (a hidden section, say), the card closes.
645
+ - **`Escape`** closes a card before it does anything else: a second `Escape` then clears the selection, or leaves a maximized map.
646
+
647
+ `openSeatCard(seatId)`, `closeSeatCard()` and `openSeatCardId` are on the [`ref`](#the-ref), to open a click card from a search result, say.
648
+
649
+ ### Styling seat cards
650
+
651
+ The library's stylesheet sets **structure only** (position, stacking, pointer events, the arrow's geometry), plus a plain default look built entirely from custom properties. The app owns the rest. From the smallest change to the largest:
652
+
653
+ 1. **Tokens.** Set any of these on `.kerusi-seatmap` or any ancestor. The library never sets one on its own root, so a value you set always wins (the same precedence as [Theming](#theming)).
654
+
655
+ | Custom property | Default |
656
+ | ------------------------------- | -------------------------------------- |
657
+ | `--kerusi-seat-card-bg` | `--kerusi-element-bg`, else white |
658
+ | `--kerusi-seat-card-fg` | `--kerusi-element-fg`, else near-black |
659
+ | `--kerusi-seat-card-border` | `1px solid rgb(0 0 0 / 0.12)` |
660
+ | `--kerusi-seat-card-radius` | `8px` |
661
+ | `--kerusi-seat-card-shadow` | a soft drop shadow |
662
+ | `--kerusi-seat-card-padding` | `8px 12px` |
663
+ | `--kerusi-seat-card-max-width` | `280px` |
664
+ | `--kerusi-seat-card-font` | `inherit` |
665
+ | `--kerusi-seat-card-offset` | `8px` (the gap between seat and card) |
666
+ | `--kerusi-seat-card-arrow-size` | `6px` (`0` hides the arrow) |
667
+ | `--kerusi-seat-card-z-index` | `10` |
668
+
669
+ `--kerusi-seat-card-offset` and `--kerusi-seat-card-arrow-size` are read as numbers by the placement code, so give them `px`, `rem` or `em`. The card sits inside the map's root, which is itself the stacking context while maximized, so it is always above the map without touching `--kerusi-maximized-z-index`.
670
+
671
+ 2. **Class and attribute hooks**, for anything the tokens don't cover: `.kerusi-seat-card`, `.kerusi-seat-card--hover` / `--click`, `.kerusi-seat-card__body`, `.kerusi-seat-card__arrow`; `data-placement="top|bottom|left|right"` (the side actually used, after any flip); and `data-seat-id`, `data-seat-status`, `data-seat-type` and `data-section-id`, mirroring the seat's own attributes. Every library rule has no specificity (it is wrapped in `:where()`), so a single class of yours wins without `!important`.
672
+
673
+ ```css
674
+ .kerusi-seat-card[data-seat-status='booked'] {
675
+ --kerusi-seat-card-border: 2px solid #e5707e;
676
+ }
677
+ ```
678
+
679
+ 3. **Your own classes**, for Tailwind or a design-system class: `seatCardClassName="my-seat-card"`.
680
+ 4. **No chrome at all.** `seatCardAppearance="none"` drops the background, border, padding, shadow and arrow, leaving only positioning, so your content _is_ the card.
681
+ 5. **The content is your own markup**, styled however your app styles markup. In React it is plain JSX.
682
+ 6. **Motion.** A short fade and scale on open, removed under `prefers-reduced-motion: reduce`. Override it with `.kerusi-seat-card { transition: none; animation: none }`.
683
+
684
+ ```tsx
685
+ <KerusiSeatmap
686
+ map={map}
687
+ seatCardClassName="rounded-xl shadow-lg"
688
+ seatCardAppearance="none" // your content is the card
689
+ renderClickCard={({ seat }) => <MyCard seat={seat} />}
690
+ />
691
+ ```
692
+
693
+ ### Keyboard
694
+
695
+ Tab moves between sections; each section keeps its own tab stop. Within a
696
+ section, arrow keys follow §4.3.5 adjacency: along a row in `col` order, and
697
+ across an aisle (a `col` no seat occupies) rather than into it. Up and down move
698
+ to the next row and pick the seat nearest on the canvas. The arrows follow the
699
+ screen, so they swap along with a mirrored right-to-left map.
700
+
701
+ | Key | |
702
+ | ------------------------ | -------------------------------------- |
703
+ | `←` `→` | Previous / next seat in the row |
704
+ | `↑` `↓` | Nearest seat in the adjacent row |
705
+ | `Home` / `End` | First / last seat of the row |
706
+ | `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
707
+ | `PageUp` / `PageDown` | First / last seat of the section |
708
+ | `Enter` / `Space` | Toggle, and open a click card |
709
+ | `Escape` | Close a card; else clear the selection |
710
+
711
+ Each seat is a `role="button"` announcing its position, whether it is next to
712
+ an aisle, its type, price and status, every `Seat.accessibility` property and its
713
+ attributes. Each section is a `role="group"` named by its label. A polite live region
714
+ reports the running selection and any disallowed reason.
715
+
716
+ With `zoomable` on there are a few more keys: see [Zoom, pan and maximize](#zoom-pan-and-maximize).
717
+
718
+ ---
719
+
720
+ ## `<KerusiLegend>`
721
+
722
+ Rendered inline by `showLegend`, or placed anywhere yourself:
723
+
724
+ ```tsx
725
+ <KerusiLegend legend={model.legend} locale="ms" showPrices />
726
+ ```
727
+
728
+ It resolves swatches through the same path as the seat fills, so the two cannot
729
+ drift apart. Every availability swatch, not just **Selected**, draws the seat
730
+ glyph itself rather than a flat colour, because shape is the cue the map uses
731
+ for status.
732
+
733
+ ---
734
+
735
+ ## `useKerusiState`
736
+
737
+ Live availability, fed by deltas from whatever transport you use. The ordering,
738
+ gap detection and hold expiry all come from `@kerusiweb/core`; the hook only
739
+ holds them in React state.
740
+
741
+ ```tsx
742
+ const availability = useKerusiState(initialState, { expiryIntervalMs: 1000 });
743
+
744
+ useEffect(() => {
745
+ const socket = new WebSocket(url);
746
+ socket.onmessage = (e) => {
747
+ if (availability.apply(JSON.parse(e.data)).outcome === 'gap') {
748
+ refetch().then(availability.reset);
749
+ }
750
+ };
751
+ return () => socket.close();
752
+ }, [availability.apply, availability.reset]);
753
+
754
+ <KerusiSeatmap map={map} state={availability.state} />;
755
+ ```
756
+
757
+ `apply`, `reset` and `tick` keep a stable identity across renders, so an effect
758
+ can subscribe once. It also exposes `needsRefetch` and `heldSeats`: every
759
+ currently-held seat with the time left on its hold, soonest first.
760
+
761
+ Deltas that are stale, duplicate or scoped to another session are discarded.
762
+
763
+ > **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
764
+ > increasing but not contiguous, so it cannot by itself distinguish "a delta was
765
+ > lost" from "nothing happened for a while". The hook detects gaps when the
766
+ > transport supplies a monotonic sequence: `metadata.seq` by default, or your
767
+ > own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
768
+ > rather than freezes while you re-fetch.
769
+
770
+ ---
771
+
772
+ ## Working with the format directly
773
+
774
+ All of this lives in `@kerusiweb/core` and is pure (no React import), so it can
775
+ run in a test, a build step, a server component or a route loader.
776
+
777
+ ```ts
778
+ import {
779
+ buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
780
+ checkKerusiMap, // every violation, no throw
781
+ validateKerusiMap, // throws on the first error
782
+ validateDocumentSet, // map / session / state joins
783
+ resolveSeatPrice, // the §4.9 precedence order
784
+ resolveLocalizedText, // string | Record<string, string> -> string
785
+ formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
786
+ summarizeSelection, // seats, total, unpriced count
787
+ computeMapLayout, // every seat and element placed on the canvas
788
+ buildNavigationGraph, // per-seat keyboard neighbours
789
+ } from '@kerusiweb/core';
790
+ ```
791
+
792
+ ## Migrating from 2.x
793
+
794
+ 3.0.0 follows Kerusi rev 15, where a map has **one canvas** and every seat its
795
+ own position on it. The full list of changes is in the
796
+ [CHANGELOG](https://github.com/ShadAhm/kerusiweb/blob/master/CHANGELOG.md#migrating-from-2x);
797
+ for this package:
798
+
799
+ - **Documents.** Give the map a `canvas` and a `seatSize`, and every seat and
800
+ element an `x`/`y` in canvas units. Remove `Section.layout`,
801
+ `Section.aspectRatio`, and an element's `row`/`col`. `row`/`col` on a seat now
802
+ mean adjacency only.
803
+ - **Removed props.** `seatSize`, `seatGap` and `freeformBasis`, and
804
+ `aspectRatio` and `seatSize` in `sectionOverrides`. The document fixes seat
805
+ size and position now; `unitScale` is the one sizing control left.
806
+ - **Markup.** The map is one `<svg class="kerusi-seatmap__svg">`, and each section
807
+ a `<g class="kerusi-section" data-section-id>` inside it. The per-section
808
+ `<section class="kerusi-seatmap__section kerusi-seatmap__section--{mode}">`
809
+ wrapper, its `<h3>` heading and the per-section `svg.kerusi-section-svg` are
810
+ gone. A section label is drawn inside the SVG instead, only for a section
811
+ that declares `bounds`.
812
+ - **New.** `zoomToSection(id)`, and a "next to an aisle" phrase in each seat's
813
+ accessible name (`besideAisle` in `ariaStrings`, which you must add if you
814
+ supply your own strings).
815
+
816
+ ---
817
+
818
+ MIT © Arsyad "Shad Ahm" Ahmad