@moda/om 21.6.11 → 21.6.12
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/dist/index.cjs.js +50 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +50 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/src/components/Popover/Popover.d.ts +16 -0
- package/dist/src/components/Popover/Popover.stories.d.ts +53 -0
- package/package.json +1 -1
- package/src/components/Popover/Popover.stories.tsx +189 -1
- package/src/components/Popover/Popover.tsx +75 -1
|
@@ -8,6 +8,22 @@ export type PopoverProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'content'>
|
|
|
8
8
|
zIndex?: number;
|
|
9
9
|
autoPreview?: boolean;
|
|
10
10
|
smoothTransitioning?: boolean;
|
|
11
|
+
/** Semantic role for the popover content (e.g., 'tooltip', 'dialog', 'menu', 'listbox') */
|
|
12
|
+
role?: 'tooltip' | 'dialog' | 'menu' | 'listbox';
|
|
13
|
+
/** Accessible label for the popover content */
|
|
14
|
+
'aria-label'?: string;
|
|
15
|
+
/** ID of element that labels the popover */
|
|
16
|
+
'aria-labelledby'?: string;
|
|
17
|
+
/** ID for the popover content */
|
|
18
|
+
popoverId?: string;
|
|
19
|
+
/** Open popover when trigger receives focus */
|
|
20
|
+
openOnFocus?: boolean;
|
|
21
|
+
/** Close popover when Escape is pressed */
|
|
22
|
+
closeOnEscape?: boolean;
|
|
23
|
+
/** Callback fired when user attempts to close (e.g., Escape key). */
|
|
24
|
+
onClose?: () => void;
|
|
25
|
+
/** Automatically focus the popover content when opened */
|
|
26
|
+
autoFocus?: boolean;
|
|
11
27
|
};
|
|
12
28
|
export declare const POPOVER_MOUSEOUT_DELAY_MS = 200;
|
|
13
29
|
export declare const Popover: React.FC<PopoverProps>;
|
|
@@ -4,3 +4,56 @@ declare const _default: {
|
|
|
4
4
|
};
|
|
5
5
|
export default _default;
|
|
6
6
|
export declare const Default: () => React.JSX.Element;
|
|
7
|
+
/**
|
|
8
|
+
* ## Accessibility
|
|
9
|
+
*
|
|
10
|
+
* The Popover component supports several accessibility props to make it usable
|
|
11
|
+
* with screen readers and keyboard navigation. **Consumers are responsible for
|
|
12
|
+
* adding ARIA attributes to their trigger elements.**
|
|
13
|
+
*
|
|
14
|
+
* ### Available Props
|
|
15
|
+
*
|
|
16
|
+
* | Prop | Type | Description |
|
|
17
|
+
* |------|------|-------------|
|
|
18
|
+
* | `role` | `'tooltip' \| 'dialog' \| 'menu' \| 'listbox'` | Semantic role for the popover content |
|
|
19
|
+
* | `aria-label` | `string` | Accessible label for the popover |
|
|
20
|
+
* | `aria-labelledby` | `string` | ID of element that labels the popover |
|
|
21
|
+
* | `popoverId` | `string` | ID for the content - use in trigger's `aria-controls` or `aria-describedby` |
|
|
22
|
+
* | `openOnFocus` | `boolean` | Open popover when trigger receives focus |
|
|
23
|
+
* | `closeOnEscape` | `boolean` | Close on Escape key (default: true) |
|
|
24
|
+
* | `onClose` | `() => void` | Callback when user attempts to close (Escape key). Required for controlled popovers. |
|
|
25
|
+
* | `autoFocus` | `boolean` | Automatically focus the popover content when opened |
|
|
26
|
+
*
|
|
27
|
+
* ### Trigger ARIA Attributes (Consumer Responsibility)
|
|
28
|
+
*
|
|
29
|
+
* For **dialogs/menus**, add to your trigger:
|
|
30
|
+
* - `aria-expanded={isOpen}`
|
|
31
|
+
* - `aria-haspopup="dialog"` (or "menu", "listbox")
|
|
32
|
+
* - `aria-controls={popoverId}` (when open)
|
|
33
|
+
*
|
|
34
|
+
* For **tooltips**, add to your trigger:
|
|
35
|
+
* - `aria-describedby={popoverId}` (when open)
|
|
36
|
+
*/
|
|
37
|
+
export declare const AccessibilityTooltip: {
|
|
38
|
+
(): React.JSX.Element;
|
|
39
|
+
storyName: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* For controlled popovers like dialogs or menus, you manage the open state
|
|
43
|
+
* and add the appropriate ARIA attributes to your trigger.
|
|
44
|
+
*
|
|
45
|
+
* Use `onClose` to handle dismiss actions (like Escape key) for controlled popovers.
|
|
46
|
+
* Use `autoFocus` to move focus into the dialog when it opens.
|
|
47
|
+
*/
|
|
48
|
+
export declare const AccessibilityDialog: {
|
|
49
|
+
(): React.JSX.Element;
|
|
50
|
+
storyName: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Use `openOnFocus` to make hover-triggered popovers accessible to keyboard users.
|
|
54
|
+
* The popover opens when the trigger receives focus and closes when focus leaves.
|
|
55
|
+
*/
|
|
56
|
+
export declare const AccessibilityKeyboardNavigation: {
|
|
57
|
+
(): React.JSX.Element;
|
|
58
|
+
storyName: string;
|
|
59
|
+
};
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { States } from '../../utilities';
|
|
3
3
|
import { ControlLink } from '..';
|
|
4
4
|
import { Text } from '../Text';
|
|
@@ -39,3 +39,191 @@ export const Default = () => (
|
|
|
39
39
|
</States>
|
|
40
40
|
</div>
|
|
41
41
|
);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* ## Accessibility
|
|
45
|
+
*
|
|
46
|
+
* The Popover component supports several accessibility props to make it usable
|
|
47
|
+
* with screen readers and keyboard navigation. **Consumers are responsible for
|
|
48
|
+
* adding ARIA attributes to their trigger elements.**
|
|
49
|
+
*
|
|
50
|
+
* ### Available Props
|
|
51
|
+
*
|
|
52
|
+
* | Prop | Type | Description |
|
|
53
|
+
* |------|------|-------------|
|
|
54
|
+
* | `role` | `'tooltip' \| 'dialog' \| 'menu' \| 'listbox'` | Semantic role for the popover content |
|
|
55
|
+
* | `aria-label` | `string` | Accessible label for the popover |
|
|
56
|
+
* | `aria-labelledby` | `string` | ID of element that labels the popover |
|
|
57
|
+
* | `popoverId` | `string` | ID for the content - use in trigger's `aria-controls` or `aria-describedby` |
|
|
58
|
+
* | `openOnFocus` | `boolean` | Open popover when trigger receives focus |
|
|
59
|
+
* | `closeOnEscape` | `boolean` | Close on Escape key (default: true) |
|
|
60
|
+
* | `onClose` | `() => void` | Callback when user attempts to close (Escape key). Required for controlled popovers. |
|
|
61
|
+
* | `autoFocus` | `boolean` | Automatically focus the popover content when opened |
|
|
62
|
+
*
|
|
63
|
+
* ### Trigger ARIA Attributes (Consumer Responsibility)
|
|
64
|
+
*
|
|
65
|
+
* For **dialogs/menus**, add to your trigger:
|
|
66
|
+
* - `aria-expanded={isOpen}`
|
|
67
|
+
* - `aria-haspopup="dialog"` (or "menu", "listbox")
|
|
68
|
+
* - `aria-controls={popoverId}` (when open)
|
|
69
|
+
*
|
|
70
|
+
* For **tooltips**, add to your trigger:
|
|
71
|
+
* - `aria-describedby={popoverId}` (when open)
|
|
72
|
+
*/
|
|
73
|
+
export const AccessibilityTooltip = () => (
|
|
74
|
+
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
75
|
+
<Text>Hover or focus the button to see a tooltip. Press Escape to dismiss.</Text>
|
|
76
|
+
<br />
|
|
77
|
+
<br />
|
|
78
|
+
<Popover
|
|
79
|
+
role='tooltip'
|
|
80
|
+
popoverId='price-tooltip'
|
|
81
|
+
aria-label='Price explanation'
|
|
82
|
+
openOnFocus
|
|
83
|
+
content={
|
|
84
|
+
<div style={{ padding: '0.5rem', maxWidth: '200px' }}>
|
|
85
|
+
<Text>
|
|
86
|
+
This is the estimated total you'll pay at checkout, including taxes and shipping.
|
|
87
|
+
</Text>
|
|
88
|
+
</div>
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
<button
|
|
92
|
+
type='button'
|
|
93
|
+
aria-describedby='price-tooltip'
|
|
94
|
+
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
|
|
95
|
+
>
|
|
96
|
+
Est. Total (?)
|
|
97
|
+
</button>
|
|
98
|
+
</Popover>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
AccessibilityTooltip.storyName = 'Accessibility: Tooltip';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* For controlled popovers like dialogs or menus, you manage the open state
|
|
106
|
+
* and add the appropriate ARIA attributes to your trigger.
|
|
107
|
+
*
|
|
108
|
+
* Use `onClose` to handle dismiss actions (like Escape key) for controlled popovers.
|
|
109
|
+
* Use `autoFocus` to move focus into the dialog when it opens.
|
|
110
|
+
*/
|
|
111
|
+
export const AccessibilityDialog = () => {
|
|
112
|
+
const [open, setOpen] = useState(false);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
116
|
+
<Text>Click the button to open a dialog. Press Escape or click Close to dismiss.</Text>
|
|
117
|
+
<br />
|
|
118
|
+
<br />
|
|
119
|
+
<Popover
|
|
120
|
+
role='dialog'
|
|
121
|
+
popoverId='cart-dialog'
|
|
122
|
+
aria-label='Shopping bag contents'
|
|
123
|
+
open={open}
|
|
124
|
+
onClose={() => setOpen(false)}
|
|
125
|
+
autoFocus
|
|
126
|
+
content={
|
|
127
|
+
<div style={{ padding: '1rem', width: '250px' }}>
|
|
128
|
+
<Text treatment='h4'>Your Bag</Text>
|
|
129
|
+
<hr />
|
|
130
|
+
<Text>2 items in your bag</Text>
|
|
131
|
+
<br />
|
|
132
|
+
<button
|
|
133
|
+
type='button'
|
|
134
|
+
onClick={() => setOpen(false)}
|
|
135
|
+
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
|
|
136
|
+
>
|
|
137
|
+
Close
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
<button
|
|
143
|
+
type='button'
|
|
144
|
+
onClick={() => setOpen(!open)}
|
|
145
|
+
aria-expanded={open}
|
|
146
|
+
aria-haspopup='dialog'
|
|
147
|
+
aria-controls={open ? 'cart-dialog' : undefined}
|
|
148
|
+
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
|
|
149
|
+
>
|
|
150
|
+
Shopping Bag (2)
|
|
151
|
+
</button>
|
|
152
|
+
</Popover>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
AccessibilityDialog.storyName = 'Accessibility: Dialog';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Use `openOnFocus` to make hover-triggered popovers accessible to keyboard users.
|
|
161
|
+
* The popover opens when the trigger receives focus and closes when focus leaves.
|
|
162
|
+
*/
|
|
163
|
+
export const AccessibilityKeyboardNavigation = () => (
|
|
164
|
+
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
165
|
+
<Text>Tab to each button to see the popover open on focus.</Text>
|
|
166
|
+
<br />
|
|
167
|
+
<br />
|
|
168
|
+
<div style={{ display: 'flex', gap: '2rem', justifyContent: 'center' }}>
|
|
169
|
+
<Popover
|
|
170
|
+
role='tooltip'
|
|
171
|
+
popoverId='info-1'
|
|
172
|
+
openOnFocus
|
|
173
|
+
content={
|
|
174
|
+
<div style={{ padding: '0.5rem' }}>
|
|
175
|
+
<Text>First item info</Text>
|
|
176
|
+
</div>
|
|
177
|
+
}
|
|
178
|
+
>
|
|
179
|
+
<button
|
|
180
|
+
type='button'
|
|
181
|
+
aria-describedby='info-1'
|
|
182
|
+
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
|
|
183
|
+
>
|
|
184
|
+
Item 1
|
|
185
|
+
</button>
|
|
186
|
+
</Popover>
|
|
187
|
+
|
|
188
|
+
<Popover
|
|
189
|
+
role='tooltip'
|
|
190
|
+
popoverId='info-2'
|
|
191
|
+
openOnFocus
|
|
192
|
+
content={
|
|
193
|
+
<div style={{ padding: '0.5rem' }}>
|
|
194
|
+
<Text>Second item info</Text>
|
|
195
|
+
</div>
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
<button
|
|
199
|
+
type='button'
|
|
200
|
+
aria-describedby='info-2'
|
|
201
|
+
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
|
|
202
|
+
>
|
|
203
|
+
Item 2
|
|
204
|
+
</button>
|
|
205
|
+
</Popover>
|
|
206
|
+
|
|
207
|
+
<Popover
|
|
208
|
+
role='tooltip'
|
|
209
|
+
popoverId='info-3'
|
|
210
|
+
openOnFocus
|
|
211
|
+
content={
|
|
212
|
+
<div style={{ padding: '0.5rem' }}>
|
|
213
|
+
<Text>Third item info</Text>
|
|
214
|
+
</div>
|
|
215
|
+
}
|
|
216
|
+
>
|
|
217
|
+
<button
|
|
218
|
+
type='button'
|
|
219
|
+
aria-describedby='info-3'
|
|
220
|
+
style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
|
|
221
|
+
>
|
|
222
|
+
Item 3
|
|
223
|
+
</button>
|
|
224
|
+
</Popover>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
AccessibilityKeyboardNavigation.storyName = 'Accessibility: Keyboard Navigation';
|
|
@@ -10,6 +10,22 @@ export type PopoverProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'content'>
|
|
|
10
10
|
zIndex?: number;
|
|
11
11
|
autoPreview?: boolean;
|
|
12
12
|
smoothTransitioning?: boolean;
|
|
13
|
+
/** Semantic role for the popover content (e.g., 'tooltip', 'dialog', 'menu', 'listbox') */
|
|
14
|
+
role?: 'tooltip' | 'dialog' | 'menu' | 'listbox';
|
|
15
|
+
/** Accessible label for the popover content */
|
|
16
|
+
'aria-label'?: string;
|
|
17
|
+
/** ID of element that labels the popover */
|
|
18
|
+
'aria-labelledby'?: string;
|
|
19
|
+
/** ID for the popover content */
|
|
20
|
+
popoverId?: string;
|
|
21
|
+
/** Open popover when trigger receives focus */
|
|
22
|
+
openOnFocus?: boolean;
|
|
23
|
+
/** Close popover when Escape is pressed */
|
|
24
|
+
closeOnEscape?: boolean;
|
|
25
|
+
/** Callback fired when user attempts to close (e.g., Escape key). */
|
|
26
|
+
onClose?: () => void;
|
|
27
|
+
/** Automatically focus the popover content when opened */
|
|
28
|
+
autoFocus?: boolean;
|
|
13
29
|
};
|
|
14
30
|
|
|
15
31
|
export const POPOVER_MOUSEOUT_DELAY_MS = 200;
|
|
@@ -35,6 +51,14 @@ export const Popover: React.FC<PopoverProps> = ({
|
|
|
35
51
|
zIndex,
|
|
36
52
|
autoPreview = false,
|
|
37
53
|
smoothTransitioning = false,
|
|
54
|
+
role,
|
|
55
|
+
'aria-label': ariaLabel,
|
|
56
|
+
'aria-labelledby': ariaLabelledby,
|
|
57
|
+
popoverId,
|
|
58
|
+
openOnFocus = false,
|
|
59
|
+
closeOnEscape = true,
|
|
60
|
+
onClose,
|
|
61
|
+
autoFocus = false,
|
|
38
62
|
...rest
|
|
39
63
|
}) => {
|
|
40
64
|
const [mode, setMode] = useState(() => {
|
|
@@ -54,6 +78,7 @@ export const Popover: React.FC<PopoverProps> = ({
|
|
|
54
78
|
});
|
|
55
79
|
|
|
56
80
|
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
81
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
57
82
|
|
|
58
83
|
const handleOpen = useCallback(() => {
|
|
59
84
|
if (smoothTransitioning) setMode(Mode.Opening);
|
|
@@ -80,6 +105,36 @@ export const Popover: React.FC<PopoverProps> = ({
|
|
|
80
105
|
timeout.current = setTimeout(handleClose, POPOVER_MOUSEOUT_DELAY_MS);
|
|
81
106
|
}, [handleClose, open]);
|
|
82
107
|
|
|
108
|
+
const handleKeyDown = useCallback(
|
|
109
|
+
(event: React.KeyboardEvent) => {
|
|
110
|
+
if (closeOnEscape && event.key === 'Escape') {
|
|
111
|
+
onClose?.();
|
|
112
|
+
handleClose();
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[closeOnEscape, onClose, handleClose]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const handleFocus = useCallback(() => {
|
|
120
|
+
if (openOnFocus && open === undefined) {
|
|
121
|
+
if (timeout.current) clearTimeout(timeout.current);
|
|
122
|
+
handleOpen();
|
|
123
|
+
}
|
|
124
|
+
}, [openOnFocus, open, handleOpen]);
|
|
125
|
+
|
|
126
|
+
const handleBlur = useCallback(
|
|
127
|
+
(event: React.FocusEvent) => {
|
|
128
|
+
if (openOnFocus && open === undefined) {
|
|
129
|
+
// Only close if focus moved outside the popover entirely
|
|
130
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
131
|
+
timeout.current = setTimeout(handleClose, POPOVER_MOUSEOUT_DELAY_MS);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
[openOnFocus, open, handleClose]
|
|
136
|
+
);
|
|
137
|
+
|
|
83
138
|
useEffect(() => {
|
|
84
139
|
if (mode !== Mode.Closing) return;
|
|
85
140
|
|
|
@@ -125,11 +180,20 @@ export const Popover: React.FC<PopoverProps> = ({
|
|
|
125
180
|
const isOpen =
|
|
126
181
|
mode === Mode.AutoOpen || mode === Mode.Opening || mode === Mode.Open || mode === Mode.Closing;
|
|
127
182
|
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (autoFocus && isOpen && contentRef.current) {
|
|
185
|
+
contentRef.current.focus();
|
|
186
|
+
}
|
|
187
|
+
}, [autoFocus, isOpen]);
|
|
188
|
+
|
|
128
189
|
return (
|
|
129
190
|
<div
|
|
130
191
|
className={classNames(`Popover Popover--anchor-${anchor}`, className)}
|
|
131
192
|
onMouseEnter={handleMouseEnter}
|
|
132
193
|
onMouseLeave={handleMouseLeave}
|
|
194
|
+
onKeyDown={handleKeyDown}
|
|
195
|
+
onFocus={handleFocus}
|
|
196
|
+
onBlur={handleBlur}
|
|
133
197
|
{...rest}
|
|
134
198
|
>
|
|
135
199
|
<span className='Popover__trigger'>
|
|
@@ -142,10 +206,20 @@ export const Popover: React.FC<PopoverProps> = ({
|
|
|
142
206
|
>
|
|
143
207
|
<div
|
|
144
208
|
className='Popover__caret'
|
|
209
|
+
aria-hidden='true'
|
|
145
210
|
style={{ zIndex: zIndex != null ? zIndex + 1 : undefined }}
|
|
146
211
|
/>
|
|
147
212
|
|
|
148
|
-
<div
|
|
213
|
+
<div
|
|
214
|
+
ref={contentRef}
|
|
215
|
+
id={popoverId}
|
|
216
|
+
role={role}
|
|
217
|
+
aria-label={ariaLabel}
|
|
218
|
+
aria-labelledby={ariaLabelledby}
|
|
219
|
+
tabIndex={autoFocus ? -1 : undefined}
|
|
220
|
+
className='Popover__content'
|
|
221
|
+
style={{ zIndex }}
|
|
222
|
+
>
|
|
149
223
|
{content}
|
|
150
224
|
</div>
|
|
151
225
|
</div>
|