@moda/om 21.6.10 → 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.
@@ -0,0 +1,6 @@
1
+ type FormControlContextValue = {
2
+ displaysError: boolean;
3
+ };
4
+ export declare const FormControlContext: import("react").Context<FormControlContextValue>;
5
+ export declare const useFormControlContext: () => FormControlContextValue;
6
+ export {};
@@ -1,3 +1,4 @@
1
1
  import { Field } from './Field';
2
2
  export { Field as Input };
3
3
  export * from './Field';
4
+ export * from './FormControlContext';
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda/om",
3
- "version": "21.6.10",
3
+ "version": "21.6.12",
4
4
  "description": "Moda Operandi design system",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.esm.js",
@@ -1,8 +1,9 @@
1
- import React, { JSX } from 'react';
1
+ import React, { JSX, useMemo } from 'react';
2
2
  import classNames from 'classnames';
3
3
  import WarningIcon from '@moda/icons/warning-16';
4
4
  import { TextInput, TextInputProps } from '../TextInput';
5
5
  import { Select } from '../Select';
6
+ import { FormControlContext } from './FormControlContext';
6
7
 
7
8
  import './Field.scss';
8
9
 
@@ -15,50 +16,54 @@ export const Field = React.forwardRef(
15
16
  { className, children, error, label, placeholder, ...rest }: FieldProps,
16
17
  ref: React.Ref<HTMLInputElement>
17
18
  ) => {
19
+ const fieldContextValue = useMemo(() => ({ displaysError: Boolean(error) }), [error]);
20
+
18
21
  return (
19
- <label className={classNames('Field', className)}>
20
- {label && <span className='Field__label'>{label}</span>}
22
+ <FormControlContext.Provider value={fieldContextValue}>
23
+ <label className={classNames('Field', className)}>
24
+ {label && <span className='Field__label'>{label}</span>}
21
25
 
22
- {/* TODO: Codify some kind of IconWrapper component */}
23
- <span className='Field__context'>
24
- {children ? (
25
- <>
26
- {React.cloneElement(children, {
27
- ref,
28
- error,
29
- placeholder,
30
- label,
31
- shiftIconLeftwards: error && children.type === Select,
32
- ...rest,
33
- ...children.props
34
- })}
26
+ {/* TODO: Codify some kind of IconWrapper component */}
27
+ <span className='Field__context'>
28
+ {children ? (
29
+ <>
30
+ {React.cloneElement(children, {
31
+ ref,
32
+ error,
33
+ placeholder,
34
+ label,
35
+ shiftIconLeftwards: error && children.type === Select,
36
+ ...rest,
37
+ ...children.props
38
+ })}
35
39
 
36
- {error && (
37
- <span className='Field__icon'>
38
- <WarningIcon />
39
- </span>
40
- )}
41
- </>
42
- ) : (
43
- <>
44
- <TextInput
45
- ref={ref}
46
- placeholder={placeholder}
47
- label={label}
48
- error={error}
49
- {...rest}
50
- />
51
- {error && (
52
- <span className='Field__icon'>
53
- <WarningIcon />
54
- </span>
55
- )}
56
- </>
57
- )}
58
- </span>
40
+ {error && (
41
+ <span className='Field__icon'>
42
+ <WarningIcon />
43
+ </span>
44
+ )}
45
+ </>
46
+ ) : (
47
+ <>
48
+ <TextInput
49
+ ref={ref}
50
+ placeholder={placeholder}
51
+ label={label}
52
+ error={error}
53
+ {...rest}
54
+ />
55
+ {error && (
56
+ <span className='Field__icon'>
57
+ <WarningIcon />
58
+ </span>
59
+ )}
60
+ </>
61
+ )}
62
+ </span>
59
63
 
60
- {error && <span className='Field__error'>{error}</span>}
61
- </label>
64
+ {error && <span className='Field__error'>{error}</span>}
65
+ </label>
66
+ </FormControlContext.Provider>
62
67
  );
63
68
  }
64
69
  );
@@ -0,0 +1,11 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ type FormControlContextValue = {
4
+ displaysError: boolean;
5
+ };
6
+
7
+ export const FormControlContext = createContext<FormControlContextValue>({
8
+ displaysError: false
9
+ });
10
+
11
+ export const useFormControlContext = () => useContext(FormControlContext);
@@ -2,3 +2,4 @@ import { Field } from './Field';
2
2
 
3
3
  export { Field as Input };
4
4
  export * from './Field';
5
+ export * from './FormControlContext';
@@ -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&apos;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 className='Popover__content' style={{ zIndex }}>
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>
@@ -5,6 +5,7 @@ import ChevronUpIcon from '@moda/icons/chevron-up-12';
5
5
  import { colors } from '@moda/tokens';
6
6
  import { Clickable } from '../Clickable';
7
7
  import { TextColor } from '../Text';
8
+ import { useFormControlContext } from '../Field/FormControlContext';
8
9
  import { SelectOptions } from './SelectOptions';
9
10
  import { SelectLabel } from './SelectLabel';
10
11
  import { useSelect } from './useSelect';
@@ -75,6 +76,7 @@ export const Select: React.FC<SelectProps> = ({
75
76
  smallMobileText = false,
76
77
  ...rest
77
78
  }) => {
79
+ const { displaysError: errorDisplayedByParent } = useFormControlContext();
78
80
  const { state, dispatch, Mode, selectRef, buttonRef } = useSelect({
79
81
  value,
80
82
  defaultValue
@@ -207,7 +209,7 @@ export const Select: React.FC<SelectProps> = ({
207
209
  )}
208
210
  </div>
209
211
 
210
- {typeof error === 'string' && (
212
+ {typeof error === 'string' && !errorDisplayedByParent && (
211
213
  <div role='status' aria-live='polite' aria-atomic='true' className='Select__live-region'>
212
214
  {error}
213
215
  </div>