Skip to content

Commit 4f4e610

Browse files
Add roving tabindex keyboard navigation to SeatmapLayout
Convert Area, Seat, and Volume to forwardRef components and add tabIndex/onFocus props to support the roving tabindex pattern implemented in SeatmapLayout. Also upgrades Storybook from v8 to v10 and removes the now-bundled addon packages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 931582d commit 4f4e610

9 files changed

Lines changed: 1407 additions & 1173 deletions

File tree

packages/seatmaps/.storybook/main.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { StorybookConfig } from '@storybook/react-vite';
22

33
const config: StorybookConfig = {
44
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
5-
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
65
framework: { name: '@storybook/react-vite', options: {} },
76
};
87

packages/seatmaps/api/react-seatmaps.api.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@
55
```ts
66

77
import { CSSProperties } from 'react';
8+
import { default as React_2 } from 'react';
89
import * as react_jsx_runtime from 'react/jsx-runtime';
910
import { ReactNode } from 'react';
1011

1112
// @public
12-
export const Area: (input: AreaProps) => react_jsx_runtime.JSX.Element;
13+
export const Area: React_2.ForwardRefExoticComponent<AreaProps & React_2.RefAttributes<SVGGElement>>;
1314

1415
// @public
1516
export interface AreaProps {
1617
angle?: number;
1718
children?: ReactNode;
1819
height?: number;
1920
name?: string;
21+
onFocus?: React_2.FocusEventHandler<SVGGElement>;
22+
tabIndex?: number;
2023
width?: number;
2124
x?: number;
2225
y?: number;
@@ -124,7 +127,7 @@ export interface RowProps {
124127
}
125128

126129
// @public
127-
export const Seat: (input: SeatProps) => react_jsx_runtime.JSX.Element;
130+
export const Seat: React_2.ForwardRefExoticComponent<SeatProps & React_2.RefAttributes<SVGGElement>>;
128131

129132
// @public
130133
export const SeatCountBadge: (input: SeatCountBadgeProps) => react_jsx_runtime.JSX.Element;
@@ -299,7 +302,9 @@ export interface SeatProps {
299302
name?: string;
300303
onClick?: () => void;
301304
onDisabledClick?: () => void;
305+
onFocus?: React_2.FocusEventHandler<SVGGElement>;
302306
shape?: SeatShape;
307+
tabIndex?: number;
303308
x?: number;
304309
y?: number;
305310
}
@@ -323,7 +328,7 @@ export interface TextProps {
323328
}
324329

325330
// @public
326-
export const Volume: (props: VolumeProps) => react_jsx_runtime.JSX.Element;
331+
export const Volume: React_2.ForwardRefExoticComponent<VolumeProps & React_2.RefAttributes<SVGGElement>>;
327332

328333
// @public
329334
export interface VolumeProps {
@@ -339,7 +344,9 @@ export interface VolumeProps {
339344
'label'?: string;
340345
'onClick'?: () => void;
341346
'onDisabledClick'?: () => void;
347+
'onFocus'?: React_2.FocusEventHandler<SVGGElement>;
342348
'shape'?: 'rectangle' | 'ellipse';
349+
'tabIndex'?: number;
343350
'width': number;
344351
'x'?: number;
345352
'y'?: number;

packages/seatmaps/package.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,8 @@
5050
"@emotion/styled": "^11.10.0",
5151
"@eslint/js": "^9.0.0",
5252
"@microsoft/api-extractor": "^7.56.2",
53-
"@storybook/addon-essentials": "^8.4.0",
54-
"@storybook/addon-interactions": "^8.4.0",
55-
"@storybook/react": "^8.4.0",
56-
"@storybook/react-vite": "^8.4.0",
53+
"@storybook/react": "^10.0.0",
54+
"@storybook/react-vite": "^10.0.0",
5755
"@testing-library/react": "^16.0.0",
5856
"@types/react": "^19.2.13",
5957
"@types/react-dom": "^19.2.3",
@@ -67,7 +65,7 @@
6765
"prettier": "^3.4.0",
6866
"react": "^19.2.4",
6967
"react-dom": "^19.2.4",
70-
"storybook": "^8.4.0",
68+
"storybook": "^10.0.0",
7169
"tsup": "^8.0.0",
7270
"typescript": "^5.7.0",
7371
"typescript-eslint": "^8.0.0",

packages/seatmaps/src/Area.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ReactNode } from 'react';
1+
import styled from '@emotion/styled';
2+
import React, { ReactNode } from 'react';
23
import { getTransform } from './transform';
34

45
/**
@@ -20,8 +21,25 @@ export interface AreaProps {
2021
children?: ReactNode;
2122
/** Area name for accessibility. When provided, the area is marked as a group with this label. */
2223
name?: string;
24+
/**
25+
* Tab index for keyboard navigation. Used by {@link SeatmapLayout} to implement the roving
26+
* tabindex pattern where only the current element holds `0` and all others hold `-1`.
27+
*/
28+
tabIndex?: number;
29+
/**
30+
* Focus event handler. Used by {@link SeatmapLayout} to sync the roving focus position
31+
* when a user clicks directly on the area.
32+
*/
33+
onFocus?: React.FocusEventHandler<SVGGElement>;
2334
}
2435

36+
const StyledArea = styled.g`
37+
&:focus-visible {
38+
outline: 2px dashed #005fcc;
39+
outline-offset: 2px;
40+
}
41+
`;
42+
2543
/**
2644
* A section of the seatmap containing rows, seats, and volumes.
2745
*
@@ -49,12 +67,19 @@ export interface AreaProps {
4967
*
5068
* @public
5169
*/
52-
export const Area = ({ children, x = 0, y = 0, angle = 0, width = 0, height = 0, name }: AreaProps) => (
53-
<g
54-
transform={getTransform(x, y, angle, width, height)}
55-
role={name ? 'group' : undefined}
56-
aria-label={name}
57-
>
58-
{children}
59-
</g>
70+
export const Area = React.forwardRef<SVGGElement, AreaProps>(
71+
({ children, x = 0, y = 0, angle = 0, width = 0, height = 0, name, tabIndex, onFocus }, ref) => (
72+
<StyledArea
73+
ref={ref}
74+
transform={getTransform(x, y, angle, width, height)}
75+
role={name ? 'group' : undefined}
76+
aria-label={name}
77+
tabIndex={tabIndex}
78+
onFocus={onFocus}
79+
>
80+
{children}
81+
</StyledArea>
82+
),
6083
);
84+
85+
Area.displayName = 'Area';

packages/seatmaps/src/Seat.tsx

Lines changed: 83 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import styled from '@emotion/styled';
2+
import React, { useCallback } from 'react';
23
import { textCss } from './textCss';
34
import { TextSize, useTextSize } from './textSize';
45
import { getTransform } from './transform';
56
import { noop } from './util/noop';
6-
import { useCallback } from 'react';
77
import { clsx } from 'clsx';
88

99
/**
@@ -133,6 +133,17 @@ export interface SeatProps {
133133
x?: number;
134134
/** Y position of the seat in seatmap units. Defaults to `0`. */
135135
y?: number;
136+
/**
137+
* Tab index for keyboard navigation. When provided, overrides the default behaviour of
138+
* `0` for enabled seats and `-1` for disabled seats. Used by {@link SeatmapLayout} to
139+
* implement the roving tabindex pattern.
140+
*/
141+
tabIndex?: number;
142+
/**
143+
* Focus event handler. Used by {@link SeatmapLayout} to sync the roving focus position
144+
* when a user clicks directly on the seat.
145+
*/
146+
onFocus?: React.FocusEventHandler<SVGGElement>;
136147
}
137148

138149
/**
@@ -157,63 +168,75 @@ export interface SeatProps {
157168
*
158169
* @public
159170
*/
160-
export const Seat = ({
161-
x = 0,
162-
y = 0,
163-
name,
164-
ariaLabel,
165-
hideName = false,
166-
color,
167-
disabled = false,
168-
onClick = noop,
169-
onDisabledClick = noop,
170-
active = false,
171-
shape = SeatShape.SQUARE,
172-
}: SeatProps) => {
173-
const textSize = useTextSize((name?.length ?? 0) > 2 ? TextSize.SMALL : TextSize.NORMAL);
174-
const textTransform = getTransform(x, y);
175-
const handleClick = useCallback(
176-
() => (disabled ? onDisabledClick : onClick)(),
177-
[disabled, onClick, onDisabledClick],
178-
);
179-
const handleKeyDown = useCallback(
180-
(event: React.KeyboardEvent) => {
181-
if (event.key === 'Enter' || event.key === ' ') {
182-
event.preventDefault();
183-
handleClick();
184-
}
171+
export const Seat = React.forwardRef<SVGGElement, SeatProps>(
172+
(
173+
{
174+
x = 0,
175+
y = 0,
176+
name,
177+
ariaLabel,
178+
hideName = false,
179+
color,
180+
disabled = false,
181+
onClick = noop,
182+
onDisabledClick = noop,
183+
active = false,
184+
shape = SeatShape.SQUARE,
185+
tabIndex: tabIndexProp,
186+
onFocus,
185187
},
186-
[handleClick],
187-
);
188-
const ShapeComponent = shape === SeatShape.CIRCLE ? CircularSeat : SquareSeat;
189-
const transform = getTransform(x + 2.5, y + 2.5);
190-
return (
191-
<StyledSeat
192-
className={clsx({ nameHidden: hideName, clickable: onClick !== noop && !disabled, active: active })}
193-
onClick={handleClick}
194-
onKeyDown={handleKeyDown}
195-
tabIndex={disabled ? -1 : 0}
196-
role="button"
197-
aria-label={ariaLabel ?? name ?? 'Unnamed seat'}
198-
aria-pressed={active}
199-
aria-disabled={disabled}
200-
>
201-
<ShapeComponent
202-
transform={transform}
203-
fill={disabled ? '#cccccc' : color}
204-
/>
205-
{name !== undefined ? (
206-
<Name
207-
transform={textTransform}
208-
x="5"
209-
y="5"
210-
className="name"
211-
style={textSize === TextSize.SMALL ? { fontSize: 4 } : undefined}
212-
aria-hidden={true}
213-
>
214-
{name}
215-
</Name>
216-
) : undefined}
217-
</StyledSeat>
218-
);
219-
};
188+
ref,
189+
) => {
190+
const textSize = useTextSize((name?.length ?? 0) > 2 ? TextSize.SMALL : TextSize.NORMAL);
191+
const textTransform = getTransform(x, y);
192+
const handleClick = useCallback(
193+
() => (disabled ? onDisabledClick : onClick)(),
194+
[disabled, onClick, onDisabledClick],
195+
);
196+
const handleKeyDown = useCallback(
197+
(event: React.KeyboardEvent) => {
198+
if (event.key === 'Enter' || event.key === ' ') {
199+
event.preventDefault();
200+
handleClick();
201+
}
202+
},
203+
[handleClick],
204+
);
205+
const ShapeComponent = shape === SeatShape.CIRCLE ? CircularSeat : SquareSeat;
206+
const transform = getTransform(x + 2.5, y + 2.5);
207+
const resolvedTabIndex = tabIndexProp !== undefined ? tabIndexProp : disabled ? -1 : 0;
208+
return (
209+
<StyledSeat
210+
ref={ref}
211+
className={clsx({ nameHidden: hideName, clickable: onClick !== noop && !disabled, active: active })}
212+
onClick={handleClick}
213+
onKeyDown={handleKeyDown}
214+
onFocus={onFocus}
215+
tabIndex={resolvedTabIndex}
216+
role="button"
217+
aria-label={ariaLabel ?? name ?? 'Unnamed seat'}
218+
aria-pressed={active}
219+
aria-disabled={disabled}
220+
>
221+
<ShapeComponent
222+
transform={transform}
223+
fill={disabled ? '#cccccc' : color}
224+
/>
225+
{name !== undefined ? (
226+
<Name
227+
transform={textTransform}
228+
x="5"
229+
y="5"
230+
className="name"
231+
style={textSize === TextSize.SMALL ? { fontSize: 4 } : undefined}
232+
aria-hidden={true}
233+
>
234+
{name}
235+
</Name>
236+
) : undefined}
237+
</StyledSeat>
238+
);
239+
},
240+
);
241+
242+
Seat.displayName = 'Seat';

0 commit comments

Comments
 (0)