@kerusiweb/react 2.3.0 → 3.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 +22 -22
- package/README.md +641 -609
- package/dist/kerusi-seatmap.d.ts +4 -2
- package/dist/kerusi-seatmap.d.ts.map +1 -1
- package/dist/kerusi-seatmap.js +30 -27
- package/dist/kerusi-seatmap.js.map +1 -1
- package/dist/kerusi-section.d.ts +16 -20
- package/dist/kerusi-section.d.ts.map +1 -1
- package/dist/kerusi-section.js +9 -15
- package/dist/kerusi-section.js.map +1 -1
- package/dist/public-api.d.ts +1 -1
- package/dist/public-api.js +1 -1
- package/dist/types.d.ts +19 -15
- package/dist/types.d.ts.map +1 -1
- package/dist/use-viewport.d.ts +3 -1
- package/dist/use-viewport.d.ts.map +1 -1
- package/dist/use-viewport.js +16 -1
- package/dist/use-viewport.js.map +1 -1
- package/package.json +52 -52
- package/styles.css +371 -365
package/README.md
CHANGED
|
@@ -1,609 +1,641 @@
|
|
|
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
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
|
140
|
-
|
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// map.current.
|
|
154
|
-
// map.current.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
|
215
|
-
|
|
|
216
|
-
| `
|
|
217
|
-
| `
|
|
218
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
222
|
-
| `
|
|
223
|
-
| `
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
|
247
|
-
|
|
|
248
|
-
| `data-seat-
|
|
249
|
-
| `data-seat-
|
|
250
|
-
| `data-seat-
|
|
251
|
-
| `data-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
|
258
|
-
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
- **
|
|
283
|
-
|
|
284
|
-
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
|
296
|
-
|
|
|
297
|
-
| `
|
|
298
|
-
| `
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
|
315
|
-
|
|
|
316
|
-
|
|
|
317
|
-
|
|
|
318
|
-
|
|
|
319
|
-
|
|
|
320
|
-
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
-
|
|
333
|
-
A
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
`
|
|
343
|
-
|
|
344
|
-
| `
|
|
345
|
-
|
|
|
346
|
-
| `'
|
|
347
|
-
| `'
|
|
348
|
-
| `
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
{
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
|
393
|
-
|
|
|
394
|
-
| `
|
|
395
|
-
| `
|
|
396
|
-
| `
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
`
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
**
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
|
469
|
-
|
|
|
470
|
-
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
`
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
// map.current.
|
|
492
|
-
// map.current.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
|
525
|
-
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
+
| `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
|
+
| `onValidationIssues` | `readonly KerusiViolation[]` | Called whenever the documents change. |
|
|
143
|
+
| `onViewportChange` | `KerusiViewport` | `{ zoom, x, y }`, whenever the zoom or pan changes. Only with `zoomable`. |
|
|
144
|
+
| `onMaximizedChange` | `boolean` | When the map is maximized or restored. Only with `maximizable`. |
|
|
145
|
+
|
|
146
|
+
### The `ref`
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
const map = useRef<KerusiSeatmapHandle>(null);
|
|
150
|
+
// map.current.summary -> { seats, total, unpriced }
|
|
151
|
+
// map.current.focusSeat('A12')
|
|
152
|
+
// map.current.viewport -> { zoom, x, y }
|
|
153
|
+
// map.current.zoomIn() / zoomOut() / resetZoom() / toggleMaximized()
|
|
154
|
+
// map.current.zoomToSection('balcony')
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Memoizing props
|
|
158
|
+
|
|
159
|
+
The render model is rebuilt whenever `map`, `state` or the interaction props
|
|
160
|
+
change identity. Object and function props declared inline get a new identity
|
|
161
|
+
every render, so hoist or memoize the ones that are not primitives:
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
const seatSelectable = useCallback((seat: RenderSeat) => seat.price != null, []);
|
|
165
|
+
const colors = useMemo(() => ({ selectedBg: '#8b6ad6' }), []);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`map` and `state` are usually stable already if they come from state or a query
|
|
169
|
+
cache. `selectableStatuses` and `sectionOverrides` have stable defaults, so you
|
|
170
|
+
only need to think about them if you pass your own.
|
|
171
|
+
|
|
172
|
+
### The seat glyph
|
|
173
|
+
|
|
174
|
+
**Colour means seat type; shape means status.** A seat's fill is always its
|
|
175
|
+
`SeatType.color`, or the theme's `availableBg` when the type has none. It does
|
|
176
|
+
not change when the seat is held or booked. `selected` is the one
|
|
177
|
+
exception: it owns the seat's colour outright, so your own picks are never
|
|
178
|
+
ambiguous, and a purple fill on its own would in any case fail WCAG 1.4.1 (Use
|
|
179
|
+
of Colour) for anyone who cannot distinguish it, hence the shape cues. Held
|
|
180
|
+
seats take a wash and a hollow occupant figure; booked ones a wash and a solid
|
|
181
|
+
figure; selected ones a bright core inside a frame.
|
|
182
|
+
|
|
183
|
+
### Theming
|
|
184
|
+
|
|
185
|
+
Colors resolve through three tiers, highest first:
|
|
186
|
+
|
|
187
|
+
1. A `--kerusi-*` custom property in **your** stylesheet.
|
|
188
|
+
2. The **`colors` prop**: a partial `KerusiSeatmapColors`, merged over the defaults.
|
|
189
|
+
3. The library default.
|
|
190
|
+
|
|
191
|
+
The library never writes a `--kerusi-*` property onto its own root, which is
|
|
192
|
+
what keeps that order true: every fill is emitted as
|
|
193
|
+
`var(--kerusi-selected-bg, <the resolved prop value>)`. Use whichever tier fits:
|
|
194
|
+
the prop for values known at build time, CSS for anything that has to respond
|
|
195
|
+
to a media query or a theme class.
|
|
196
|
+
|
|
197
|
+
```css
|
|
198
|
+
.kerusi-seatmap {
|
|
199
|
+
--kerusi-selected-bg: #8b6ad6;
|
|
200
|
+
--kerusi-available-bg: #2f6b45;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@media (prefers-color-scheme: dark) {
|
|
204
|
+
.kerusi-seatmap {
|
|
205
|
+
--kerusi-element-bg: #232a36;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Every key of `KerusiSeatmapColors` has a property, kebab-cased:
|
|
211
|
+
|
|
212
|
+
| Prop key | Custom property |
|
|
213
|
+
| ------------------------------------- | ----------------------------------------------------------- |
|
|
214
|
+
| `availableBg` / `availableFg` | `--kerusi-available-bg` / `--kerusi-available-fg` |
|
|
215
|
+
| `selectedBg` / `selectedFg` | `--kerusi-selected-bg` / `--kerusi-selected-fg` |
|
|
216
|
+
| `heldBg` / `heldFg` | `--kerusi-held-bg` / `--kerusi-held-fg` |
|
|
217
|
+
| `bookedBg` / `bookedFg` | `--kerusi-booked-bg` / `--kerusi-booked-fg` |
|
|
218
|
+
| `blockedBg` / `blockedFg` | `--kerusi-blocked-bg` / `--kerusi-blocked-fg` |
|
|
219
|
+
| `elementBg` / `elementFg` | `--kerusi-element-bg` / `--kerusi-element-fg` |
|
|
220
|
+
| `elementAccentBg` / `elementAccentFg` | `--kerusi-element-accent-bg` / `--kerusi-element-accent-fg` |
|
|
221
|
+
| `elementMutedBg` / `elementMutedFg` | `--kerusi-element-muted-bg` / `--kerusi-element-muted-fg` |
|
|
222
|
+
| `focusRing` | `--kerusi-focus-ring` |
|
|
223
|
+
| `backdrop` | `--kerusi-backdrop` |
|
|
224
|
+
|
|
225
|
+
A `SeatType.color` from the document has **no theme token**, because it is the
|
|
226
|
+
map's own value under §4.7. It can be overridden only seat by seat (or type by
|
|
227
|
+
type) with `--kerusi-seat-fill`; see
|
|
228
|
+
[Styling individual seats](#styling-individual-seats). Turn it off wholesale
|
|
229
|
+
with `typeColors={false}` if you want the theme to own every fill.
|
|
230
|
+
|
|
231
|
+
For anything the palette does not cover, each seat group carries the same class
|
|
232
|
+
hooks as the Angular binding (`.kerusi-seat`, `--available` / `--held` /
|
|
233
|
+
`--booked` / `--blocked`, `--selected`, `--unselectable`, `--wheelchair`,
|
|
234
|
+
`--companion`), and inside a seat: `.kerusi-seat__box`, `__wash`, `__core`,
|
|
235
|
+
`__occupant` (`__occupant--selected` / `--held` / `--booked`), `__label`,
|
|
236
|
+
`__wheelchair`, and, only while `renderSeat` is set, `__focus`.
|
|
237
|
+
|
|
238
|
+
### Styling individual seats
|
|
239
|
+
|
|
240
|
+
The palette themes every seat of a status at once. To pick out _some_ seats (a
|
|
241
|
+
VIP block, every aisle seat, one row), select them in CSS and set the per-seat
|
|
242
|
+
tokens. Each seat's `<g>` carries what the document says about it:
|
|
243
|
+
|
|
244
|
+
| Attribute | Value | Example selector |
|
|
245
|
+
| ---------------------- | ------------------------------------------------------ | --------------------------------- |
|
|
246
|
+
| `data-seat-id` | `Seat.id` | `[data-seat-id="12A"]` |
|
|
247
|
+
| `data-seat-type` | `Seat.type` | `[data-seat-type="recliner"]` |
|
|
248
|
+
| `data-seat-row` | `Seat.row`, when the seat has one | `[data-seat-row="A"]` |
|
|
249
|
+
| `data-seat-status` | `available`, `held`, `booked` or `blocked` | `[data-seat-status="held"]` |
|
|
250
|
+
| `data-seat-attributes` | `Seat.attributes`, space-separated, when there are any | `[data-seat-attributes~="aisle"]` |
|
|
251
|
+
| `data-section-id` | The seat's section | `[data-section-id="balcony"]` |
|
|
252
|
+
|
|
253
|
+
Two tokens then recolour the seats a selector picks out:
|
|
254
|
+
|
|
255
|
+
| Token | Replaces |
|
|
256
|
+
| --------------------- | -------------------------------------------------------------- |
|
|
257
|
+
| `--kerusi-seat-fill` | The seat's own colour: its `SeatType.color`, or `availableBg`. |
|
|
258
|
+
| `--kerusi-seat-label` | The colour of the number drawn on it. |
|
|
259
|
+
|
|
260
|
+
```css
|
|
261
|
+
.kerusi-seatmap :is(.kerusi-seat, .kerusi-legend__item)[data-seat-type='vip'] {
|
|
262
|
+
--kerusi-seat-fill: #d4a017;
|
|
263
|
+
--kerusi-seat-label: #000000;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.kerusi-seatmap .kerusi-seat[data-seat-attributes~='aisle'] {
|
|
267
|
+
--kerusi-seat-fill: #2f6b45;
|
|
268
|
+
--kerusi-seat-label: #ffffff;
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
- **Per seat, not global.** The library never sets these tokens and gives them
|
|
273
|
+
no map-wide default. They are meant for a selector that picks seats out;
|
|
274
|
+
setting one on the map itself would repaint every seat and hide the
|
|
275
|
+
document's type colours, which is what `typeColors={false}` and `availableBg` are for.
|
|
276
|
+
- **Status still shows.** They replace only the seat's own colour. A held or
|
|
277
|
+
booked seat keeps it under its wash and occupant figure, while a selected seat
|
|
278
|
+
keeps its selected frame and core, and a blocked seat its grey, whatever the
|
|
279
|
+
tokens say. Status is never carried by colour alone.
|
|
280
|
+
- **Set the label with the fill.** The library cannot read a colour set in CSS,
|
|
281
|
+
so it cannot choose a readable number colour for it.
|
|
282
|
+
- **Keep the legend in step.** Each type in the legend carries the same
|
|
283
|
+
`data-seat-type`, and its swatch reads `--kerusi-seat-fill`. Include
|
|
284
|
+
`.kerusi-legend__item` in a type selector, as above, and the key matches the
|
|
285
|
+
map.
|
|
286
|
+
|
|
287
|
+
#### From code
|
|
288
|
+
|
|
289
|
+
When the choice lives in TypeScript (a recommendation, a search result, the
|
|
290
|
+
document's own `Seat.metadata`), use `seatTypeStyles` for whole types and
|
|
291
|
+
`seatStyle` for single seats. Both take a `KerusiSeatStyle`:
|
|
292
|
+
|
|
293
|
+
| Field | Type | |
|
|
294
|
+
| ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
295
|
+
| `fill` | `string` | The seat's own colour, the same slot as `--kerusi-seat-fill`. Any CSS colour. |
|
|
296
|
+
| `labelColor` | `string` | The number's colour. Defaults to black or white for a hex `fill`; set it for any other kind of colour. |
|
|
297
|
+
| `className` | `string \| readonly string[]` | Extra classes on the seat's `<g>`, for CSS that needs more than colour. |
|
|
298
|
+
| `shape` | `KerusiSeatShape` | The seat's outline: `'seat'` (default), `'square'`, `'rounded'`, `'circle'`, or a function. See [Shapes](#shapes). |
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
import type { SeatStyleFn, SeatTypeStyles } from '@kerusiweb/core';
|
|
302
|
+
|
|
303
|
+
const typeStyles: SeatTypeStyles = { vip: { fill: '#d4a017', labelColor: '#000000' } };
|
|
304
|
+
const highlight: SeatStyleFn = (seat) =>
|
|
305
|
+
seat.source.metadata?.['bestView'] ? { fill: '#2f6b45', className: 'best-view' } : undefined;
|
|
306
|
+
|
|
307
|
+
<KerusiSeatmap map={map} seatTypeStyles={typeStyles} seatStyle={highlight} />;
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
A seat's colour resolves in this order, first match wins:
|
|
311
|
+
|
|
312
|
+
| # | Source | |
|
|
313
|
+
| --- | ----------------------------------------- | ------------------------------------------------------ |
|
|
314
|
+
| 1 | Selected | `selectedBg` frame and `selectedFg` core, always. |
|
|
315
|
+
| 2 | Blocked | `blockedBg`, always. |
|
|
316
|
+
| 3 | `--kerusi-seat-fill` from your stylesheet | See above. |
|
|
317
|
+
| 4 | `seatStyle(seat)` | Its `fill`. |
|
|
318
|
+
| 5 | `seatTypeStyles[seat.type]` | Its `fill`. Applies whether or not `typeColors` is on. |
|
|
319
|
+
| 6 | `SeatType.color` | When `typeColors` is on. |
|
|
320
|
+
| 7 | `availableBg` | The theme. |
|
|
321
|
+
|
|
322
|
+
- **Status always reads.** Neither option can change a selected or blocked
|
|
323
|
+
seat's colour, and a held or booked seat keeps its wash and occupant figure
|
|
324
|
+
over whatever colour it has. A style that could recolour your own picks
|
|
325
|
+
would make them ambiguous. For anything beyond this, use a
|
|
326
|
+
[custom seat template](#custom-seat-content).
|
|
327
|
+
- **Per-seat over per-type, field by field.** A `seatStyle` result replaces only
|
|
328
|
+
the fields it sets, so a type's `fill` survives a per-seat `className`.
|
|
329
|
+
Classes from both are kept.
|
|
330
|
+
- **The legend shows `seatTypeStyles`**, because a type has one value to show.
|
|
331
|
+
A per-seat `seatStyle` has no single value a legend could draw.
|
|
332
|
+
- The function runs for every seat on every render of the map, so keep it
|
|
333
|
+
cheap. A new function or object each render is fine, since neither rebuilds the
|
|
334
|
+
render model, but declaring them once outside the component is simplest.
|
|
335
|
+
|
|
336
|
+
#### Shapes
|
|
337
|
+
|
|
338
|
+
A style's `shape` changes the seat's outline. It works in `seatTypeStyles` (a
|
|
339
|
+
sofa type drawn rounded, say) and in `seatStyle`; for every seat at once, use
|
|
340
|
+
`seatStyle={() => ({ shape: 'circle' })}`.
|
|
341
|
+
|
|
342
|
+
| `shape` | |
|
|
343
|
+
| -------------------------- | --------------------------------------------------------------------------- |
|
|
344
|
+
| `'seat'` | The default: square-ish at the front, tapered at the back. |
|
|
345
|
+
| `'square'` | A square with the same slight corners as the seat's front. |
|
|
346
|
+
| `'rounded'` | A square with soft corners. |
|
|
347
|
+
| `'circle'` | A circle filling the seat's box. |
|
|
348
|
+
| `(box: SeatBox) => string` | Your own closed path `d`, drawn inside `box` (`x`, `y`, `width`, `height`). |
|
|
349
|
+
|
|
350
|
+
- **Status marks follow the shape.** The wash, the selected frame and core, and
|
|
351
|
+
a seat template's focus outline are all drawn in it, and the occupant figure
|
|
352
|
+
and wheelchair marker sit inside every preset.
|
|
353
|
+
- **Only `'seat'` shows which way a seat faces.** The other presets are
|
|
354
|
+
symmetric, so on rotated seats (a fanned theatre, a stadium bowl) that cue is
|
|
355
|
+
lost.
|
|
356
|
+
- **A function shape must draw relative to the box it is given.** It is called
|
|
357
|
+
once for the seat and again for the selected core's smaller box. Leave room
|
|
358
|
+
for the occupant figure (the middle 44% of the width, from 15% to 86% of the
|
|
359
|
+
height) and keep the top-right corner filled for the wheelchair marker.
|
|
360
|
+
- **The legend draws a shaped type in its shape.** Types without a `shape`, and
|
|
361
|
+
every status swatch, keep their usual swatches.
|
|
362
|
+
|
|
363
|
+
### Custom seat content
|
|
364
|
+
|
|
365
|
+
When the palette and the class hooks are not enough (an icon, a badge, a price
|
|
366
|
+
tag, a different shape), draw the inside of each seat yourself with
|
|
367
|
+
`renderSeat`. It returns SVG, in canvas units:
|
|
368
|
+
|
|
369
|
+
```tsx
|
|
370
|
+
import { KerusiSeatmap } from '@kerusiweb/react';
|
|
371
|
+
import type { SeatRenderContext } from '@kerusiweb/react';
|
|
372
|
+
|
|
373
|
+
function drawSeat({ seat, glyph, defaultContent }: SeatRenderContext) {
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
{/* The stock seat, then a badge on top of it. */}
|
|
377
|
+
{defaultContent}
|
|
378
|
+
{seat.source.metadata?.['bestView'] ? (
|
|
379
|
+
<circle cx={glyph.box.x + glyph.box.width} cy={glyph.box.y} r={4} fill="gold" />
|
|
380
|
+
) : null}
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
<KerusiSeatmap map={map} state={state} renderSeat={drawSeat} />;
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`renderSeat` receives a `SeatRenderContext`:
|
|
389
|
+
|
|
390
|
+
| Field | Type | |
|
|
391
|
+
| ---------------- | ------------ | -------------------------------------------------------------------------------------------------- |
|
|
392
|
+
| `seat` | `RenderSeat` | Status, type, price, accessibility, and the document's own `source.metadata`. |
|
|
393
|
+
| `selected` | `boolean` | Whether the seat is in the current selection. |
|
|
394
|
+
| `placed` | `PlacedSeat` | The seat's box, centre, font size and rotation, in canvas units. |
|
|
395
|
+
| `glyph` | `SeatGlyph` | The stock seat, resolved by `resolveSeatGlyph()` in core: every path, paint and position it draws. |
|
|
396
|
+
| `defaultContent` | `ReactNode` | The stock drawing, as a fragment. Return it to keep the stock seat, or place it beside your marks. |
|
|
397
|
+
|
|
398
|
+
It runs for every seat on every render of the map. A new function each render
|
|
399
|
+
is fine, since it does not rebuild the render model, but declaring it once
|
|
400
|
+
outside the component is simplest.
|
|
401
|
+
|
|
402
|
+
**What the library keeps.** Each seat's `<g>` stays the library's: its role,
|
|
403
|
+
`aria-label`, `aria-pressed`, `aria-disabled`, tab stop, `data-seat-id`, class
|
|
404
|
+
list, rotation and its click, focus and key handlers. Only what is drawn inside
|
|
405
|
+
it is yours. Keyboard navigation, selection, companions and announcements
|
|
406
|
+
therefore behave exactly as they do for the stock seat, and the accessible name
|
|
407
|
+
still comes from the library (and `ariaStrings`), whatever text you draw.
|
|
408
|
+
|
|
409
|
+
**Coordinates.** Draw in canvas units, using `glyph.box` (or
|
|
410
|
+
`placed`) for the seat's position and size. The `<g>` already carries the seat's
|
|
411
|
+
rotation, so draw it unrotated with the front at the top edge;
|
|
412
|
+
`glyph.label.transform` counter-rotates text so it stays upright.
|
|
413
|
+
|
|
414
|
+
**Status is yours to show.** A template replaces the stock marks, including the
|
|
415
|
+
occupant figure, the wash and the selected frame and core, which are what tell
|
|
416
|
+
held, booked and selected apart by shape rather than colour (WCAG 1.4.1). The
|
|
417
|
+
library's own styling options never alter those marks, so a template is the one
|
|
418
|
+
place that can, and it carries that responsibility. The simplest way to keep
|
|
419
|
+
them is to render `defaultContent` and add to it. Otherwise reuse `glyph.wash`,
|
|
420
|
+
`glyph.core` and `glyph.occupant`, and the `kerusi-seat__wash` and
|
|
421
|
+
`kerusi-seat__occupant--*` classes, which keep the stock opacity.
|
|
422
|
+
|
|
423
|
+
**Focus outline.** The stock focus ring strokes `.kerusi-seat__box`, which your
|
|
424
|
+
content may not have. So while a seat template is in use, the library draws one
|
|
425
|
+
more path in every seat, after your content: `.kerusi-seat__focus`, an outline
|
|
426
|
+
of the seat's body shown only on `:focus-visible`, so keyboard focus stays
|
|
427
|
+
visible whatever you draw (WCAG 2.4.7). It takes the same colour as the stock
|
|
428
|
+
ring (`--kerusi-focus-ring`, then `colors.focusRing`, then `currentColor`). It
|
|
429
|
+
follows the seat's outline (its [`shape`](#shapes), if a style gives one), not
|
|
430
|
+
your drawing; if you draw your own focus indicator, restyle or hide it with
|
|
431
|
+
`.kerusi-seat:focus-visible .kerusi-seat__focus`. Without a seat template it is
|
|
432
|
+
not rendered at all, and the markup is unchanged.
|
|
433
|
+
|
|
434
|
+
**Legend.** The legend keeps drawing the stock glyph. If your seats look very
|
|
435
|
+
different, turn it off and show your own key.
|
|
436
|
+
|
|
437
|
+
### Zoom, pan and maximize
|
|
438
|
+
|
|
439
|
+
Two features, both **off by default**: with them off the markup and behaviour are
|
|
440
|
+
exactly what they were. They are independent; turn on either or both.
|
|
441
|
+
|
|
442
|
+
On a phone a map of several hundred seats is drawn at fit-to-width, so a seat is
|
|
443
|
+
too small to tap. `zoomable` lets the reader zoom in and pan; `maximizable` adds a button
|
|
444
|
+
that fills the screen.
|
|
445
|
+
|
|
446
|
+
**Zoom and pan.**
|
|
447
|
+
|
|
448
|
+
- **Pinch** with two fingers on a touch screen. **Ctrl/Cmd + wheel**, or a
|
|
449
|
+
trackpad pinch (Safari's included), on desktop. A plain wheel is left alone, so
|
|
450
|
+
the page still scrolls.
|
|
451
|
+
- **Drag** with one finger or the primary mouse button to pan.
|
|
452
|
+
- **A tap still selects**, at any zoom level. A touch or click that moves less
|
|
453
|
+
than 8 CSS pixels is a tap, and reaches the seat exactly as it does without
|
|
454
|
+
`zoomable`. Anything that moves further is a drag, and a drag never selects the
|
|
455
|
+
seat under it.
|
|
456
|
+
- **Limits.** The floor is fit-to-container, the ceiling is `maxZoom` (default
|
|
457
|
+
`4`), and panning stays inside the map. A map smaller than its container, which
|
|
458
|
+
happens when it is maximized on a large screen, is centred.
|
|
459
|
+
- **Gestures and the page.** `touch-action` is `pan-y` at fit, so a one-finger
|
|
460
|
+
vertical swipe still scrolls the page while a pinch is the map's; once zoomed in
|
|
461
|
+
or maximized it is `none`. With `zoomable` off, no `touch-action` is set.
|
|
462
|
+
- **Reduced motion.** The short transition on the zoom buttons, the keys and
|
|
463
|
+
reset is dropped under `prefers-reduced-motion: reduce`. A finger or wheel moves
|
|
464
|
+
the map directly and never eases.
|
|
465
|
+
|
|
466
|
+
| Key | |
|
|
467
|
+
| --------------- | ------------------------------------------------------------ |
|
|
468
|
+
| `+` / `=`, `-` | Zoom in / out, from anywhere inside the map |
|
|
469
|
+
| `0` | Reset to fit |
|
|
470
|
+
| `←` `→` `↑` `↓` | Pan, when the zoomable viewport itself has focus (Tab to it) |
|
|
471
|
+
|
|
472
|
+
On a seat, the arrow keys keep their usual job of moving seat focus, and a seat
|
|
473
|
+
that takes keyboard focus is scrolled into view. Ctrl, Cmd and Alt combinations
|
|
474
|
+
are left to the browser, and keys typed into a field you put inside the map are
|
|
475
|
+
ignored.
|
|
476
|
+
|
|
477
|
+
**Controls.** The zoom buttons (zoom in, zoom out, reset to fit) are real
|
|
478
|
+
`<button>`s with accessible names. At a limit they set `aria-disabled` rather than
|
|
479
|
+
`disabled`, so keyboard focus is not thrown away mid-press. Their names come from
|
|
480
|
+
`controlStrings`, for translation. They use the theme's existing tokens
|
|
481
|
+
(`--kerusi-element-bg`, `--kerusi-element-fg`, `--kerusi-element-muted-fg`,
|
|
482
|
+
`--kerusi-focus-ring`), so there is nothing new to theme.
|
|
483
|
+
|
|
484
|
+
To put the controls somewhere else, turn the built-in ones off
|
|
485
|
+
(`showZoomControls={false}`) and drive the map yourself:
|
|
486
|
+
|
|
487
|
+
```tsx
|
|
488
|
+
const map = useRef<KerusiSeatmapHandle>(null);
|
|
489
|
+
// map.current.zoomIn(); map.current.zoomOut(); map.current.resetZoom(); map.current.toggleMaximized();
|
|
490
|
+
// map.current.zoomToSection('balcony');
|
|
491
|
+
// map.current.viewport -> { zoom, x, y }
|
|
492
|
+
// map.current.maximized -> boolean
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`onViewportChange` reports `{ zoom, x, y }` whenever the view changes, for a host that wants
|
|
496
|
+
to display or persist it. (Restoring a saved view is not supported.)
|
|
497
|
+
|
|
498
|
+
**Zoom to a section.** `zoomToSection(id)` zooms to a section's declared `bounds`, as
|
|
499
|
+
large as the viewport allows and centred, or to the box around its seats and
|
|
500
|
+
elements when it declares none. It does nothing unless the map is `zoomable`.
|
|
501
|
+
Reset with `resetZoom()`, which shows the whole canvas again.
|
|
502
|
+
|
|
503
|
+
**Maximize.** The button fills the screen with the map and its legend, freezes the
|
|
504
|
+
page behind it, and swaps to an "Exit full screen" button. The layout is a fixed
|
|
505
|
+
overlay, which is all iPhone Safari offers; where the browser has the Fullscreen
|
|
506
|
+
API it is requested on top, and leaving it with the browser's own Esc leaves the
|
|
507
|
+
map's maximized state too. `Escape` from the map also leaves, except that on a
|
|
508
|
+
seat with a non-empty selection the first `Escape` still clears the selection, as
|
|
509
|
+
it always has. The backdrop is `--kerusi-backdrop`, or the page's own canvas colour
|
|
510
|
+
while that is transparent; `--kerusi-maximized-z-index` (default `1000`) sets the
|
|
511
|
+
stacking. A maximized zoomable map fits the whole canvas to the screen at the
|
|
512
|
+
canvas's own proportions. It is not a modal dialog: focus is not trapped, and an ancestor with a
|
|
513
|
+
`transform` or `filter` would anchor the fixed layout to itself instead of the
|
|
514
|
+
screen.
|
|
515
|
+
|
|
516
|
+
### Keyboard
|
|
517
|
+
|
|
518
|
+
Tab moves between sections; each section keeps its own tab stop. Within a
|
|
519
|
+
section, arrow keys follow §4.3.5 adjacency: along a row in `col` order, and
|
|
520
|
+
across an aisle (a `col` no seat occupies) rather than into it. Up and down move
|
|
521
|
+
to the next row and pick the seat nearest on the canvas. The arrows follow the
|
|
522
|
+
screen, so they swap along with a mirrored right-to-left map.
|
|
523
|
+
|
|
524
|
+
| Key | |
|
|
525
|
+
| ------------------------ | -------------------------------- |
|
|
526
|
+
| `←` `→` | Previous / next seat in the row |
|
|
527
|
+
| `↑` `↓` | Nearest seat in the adjacent row |
|
|
528
|
+
| `Home` / `End` | First / last seat of the row |
|
|
529
|
+
| `Ctrl+Home` / `Ctrl+End` | First / last seat of the section |
|
|
530
|
+
| `PageUp` / `PageDown` | First / last seat of the section |
|
|
531
|
+
| `Enter` / `Space` | Toggle |
|
|
532
|
+
| `Escape` | Clear the selection |
|
|
533
|
+
|
|
534
|
+
Each seat is a `role="button"` announcing its position, whether it is next to
|
|
535
|
+
an aisle, its type, price and status, every `Seat.accessibility` property and its
|
|
536
|
+
attributes. Each section is a `role="group"` named by its label. A polite live region
|
|
537
|
+
reports the running selection and any disallowed reason.
|
|
538
|
+
|
|
539
|
+
With `zoomable` on there are a few more keys: see [Zoom, pan and maximize](#zoom-pan-and-maximize).
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## `<KerusiLegend>`
|
|
544
|
+
|
|
545
|
+
Rendered inline by `showLegend`, or placed anywhere yourself:
|
|
546
|
+
|
|
547
|
+
```tsx
|
|
548
|
+
<KerusiLegend legend={model.legend} locale="ms" showPrices />
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
It resolves swatches through the same path as the seat fills, so the two cannot
|
|
552
|
+
drift apart. Every availability swatch, not just **Selected**, draws the seat
|
|
553
|
+
glyph itself rather than a flat colour, because shape is the cue the map uses
|
|
554
|
+
for status.
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## `useKerusiState`
|
|
559
|
+
|
|
560
|
+
Live availability, fed by deltas from whatever transport you use. The ordering,
|
|
561
|
+
gap detection and hold expiry all come from `@kerusiweb/core`; the hook only
|
|
562
|
+
holds them in React state.
|
|
563
|
+
|
|
564
|
+
```tsx
|
|
565
|
+
const availability = useKerusiState(initialState, { expiryIntervalMs: 1000 });
|
|
566
|
+
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
const socket = new WebSocket(url);
|
|
569
|
+
socket.onmessage = (e) => {
|
|
570
|
+
if (availability.apply(JSON.parse(e.data)).outcome === 'gap') {
|
|
571
|
+
refetch().then(availability.reset);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
return () => socket.close();
|
|
575
|
+
}, [availability.apply, availability.reset]);
|
|
576
|
+
|
|
577
|
+
<KerusiSeatmap map={map} state={availability.state} />;
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
`apply`, `reset` and `tick` keep a stable identity across renders, so an effect
|
|
581
|
+
can subscribe once. It also exposes `needsRefetch` and `heldSeats`: every
|
|
582
|
+
currently-held seat with the time left on its hold, soonest first.
|
|
583
|
+
|
|
584
|
+
Deltas that are stale, duplicate or scoped to another session are discarded.
|
|
585
|
+
|
|
586
|
+
> **Gap detection needs a sequence.** §5.2 requires `updatedAt` to be strictly
|
|
587
|
+
> increasing but not contiguous, so it cannot by itself distinguish "a delta was
|
|
588
|
+
> lost" from "nothing happened for a while". The hook detects gaps when the
|
|
589
|
+
> transport supplies a monotonic sequence: `metadata.seq` by default, or your
|
|
590
|
+
> own `sequenceOf` reader. A gapped delta is still applied, so the map degrades
|
|
591
|
+
> rather than freezes while you re-fetch.
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## Working with the format directly
|
|
596
|
+
|
|
597
|
+
All of this lives in `@kerusiweb/core` and is pure (no React import), so it can
|
|
598
|
+
run in a test, a build step, a server component or a route loader.
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
import {
|
|
602
|
+
buildRenderModel, // KerusiMap + KerusiState -> the resolved render model
|
|
603
|
+
checkKerusiMap, // every violation, no throw
|
|
604
|
+
validateKerusiMap, // throws on the first error
|
|
605
|
+
validateDocumentSet, // map / session / state joins
|
|
606
|
+
resolveSeatPrice, // the §4.9 precedence order
|
|
607
|
+
resolveLocalizedText, // string | Record<string, string> -> string
|
|
608
|
+
formatMoney, // minor units -> "RM 45.00", "¥1,200", "KD 12.500"
|
|
609
|
+
summarizeSelection, // seats, total, unpriced count
|
|
610
|
+
computeMapLayout, // every seat and element placed on the canvas
|
|
611
|
+
buildNavigationGraph, // per-seat keyboard neighbours
|
|
612
|
+
} from '@kerusiweb/core';
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
## Migrating from 2.x
|
|
616
|
+
|
|
617
|
+
3.0.0 follows Kerusi rev 15, where a map has **one canvas** and every seat its
|
|
618
|
+
own position on it. The full list of changes is in the
|
|
619
|
+
[CHANGELOG](https://github.com/ShadAhm/kerusiweb/blob/master/CHANGELOG.md#migrating-from-2x);
|
|
620
|
+
for this package:
|
|
621
|
+
|
|
622
|
+
- **Documents.** Give the map a `canvas` and a `seatSize`, and every seat and
|
|
623
|
+
element an `x`/`y` in canvas units. Remove `Section.layout`,
|
|
624
|
+
`Section.aspectRatio`, and an element's `row`/`col`. `row`/`col` on a seat now
|
|
625
|
+
mean adjacency only.
|
|
626
|
+
- **Removed props.** `seatSize`, `seatGap` and `freeformBasis`, and
|
|
627
|
+
`aspectRatio` and `seatSize` in `sectionOverrides`. The document fixes seat
|
|
628
|
+
size and position now; `unitScale` is the one sizing control left.
|
|
629
|
+
- **Markup.** The map is one `<svg class="kerusi-seatmap__svg">`, and each section
|
|
630
|
+
a `<g class="kerusi-section" data-section-id>` inside it. The per-section
|
|
631
|
+
`<section class="kerusi-seatmap__section kerusi-seatmap__section--{mode}">`
|
|
632
|
+
wrapper, its `<h3>` heading and the per-section `svg.kerusi-section-svg` are
|
|
633
|
+
gone. A section label is drawn inside the SVG instead, only for a section
|
|
634
|
+
that declares `bounds`.
|
|
635
|
+
- **New.** `zoomToSection(id)`, and a "next to an aisle" phrase in each seat's
|
|
636
|
+
accessible name (`besideAisle` in `ariaStrings`, which you must add if you
|
|
637
|
+
supply your own strings).
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
MIT © Arsyad "Shad Ahm" Ahmad
|