@idealyst/components 1.1.3 → 1.1.5
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/package.json +8 -3
- package/src/Accordion/Accordion.native.tsx +23 -2
- package/src/Accordion/Accordion.web.tsx +73 -2
- package/src/Accordion/types.ts +2 -1
- package/src/ActivityIndicator/ActivityIndicator.native.tsx +15 -1
- package/src/ActivityIndicator/ActivityIndicator.web.tsx +19 -2
- package/src/ActivityIndicator/types.ts +2 -1
- package/src/Avatar/Avatar.native.tsx +19 -2
- package/src/Avatar/Avatar.web.tsx +19 -2
- package/src/Avatar/types.ts +2 -1
- package/src/Breadcrumb/types.ts +3 -2
- package/src/Button/Button.native.tsx +48 -1
- package/src/Button/Button.styles.tsx +3 -5
- package/src/Button/Button.web.tsx +61 -2
- package/src/Button/types.ts +2 -1
- package/src/Card/Card.native.tsx +21 -5
- package/src/Card/Card.web.tsx +21 -4
- package/src/Card/types.ts +2 -6
- package/src/Checkbox/Checkbox.native.tsx +46 -5
- package/src/Checkbox/Checkbox.web.tsx +80 -4
- package/src/Checkbox/types.ts +2 -6
- package/src/Chip/Chip.native.tsx +5 -0
- package/src/Chip/Chip.web.tsx +5 -1
- package/src/Chip/types.ts +2 -1
- package/src/Dialog/Dialog.native.tsx +20 -3
- package/src/Dialog/Dialog.web.tsx +29 -4
- package/src/Dialog/types.ts +2 -1
- package/src/Image/Image.native.tsx +1 -1
- package/src/Image/Image.web.tsx +2 -0
- package/src/Input/Input.native.tsx +37 -1
- package/src/Input/Input.web.tsx +75 -8
- package/src/Input/types.ts +2 -1
- package/src/List/List.native.tsx +18 -2
- package/src/List/ListItem.native.tsx +44 -8
- package/src/List/ListItem.web.tsx +16 -0
- package/src/List/types.ts +6 -3
- package/src/Menu/Menu.native.tsx +21 -2
- package/src/Menu/Menu.web.tsx +110 -3
- package/src/Menu/MenuItem.web.tsx +12 -3
- package/src/Menu/types.ts +2 -1
- package/src/Popover/Popover.native.tsx +17 -1
- package/src/Popover/Popover.web.tsx +31 -2
- package/src/Popover/types.ts +2 -1
- package/src/RadioButton/RadioButton.native.tsx +41 -3
- package/src/RadioButton/RadioButton.web.tsx +45 -6
- package/src/RadioButton/RadioGroup.native.tsx +20 -2
- package/src/RadioButton/RadioGroup.web.tsx +24 -3
- package/src/RadioButton/types.ts +3 -2
- package/src/Select/types.ts +2 -6
- package/src/Skeleton/Skeleton.native.tsx +15 -1
- package/src/Skeleton/Skeleton.web.tsx +20 -1
- package/src/Skeleton/types.ts +2 -1
- package/src/Slider/Slider.native.tsx +42 -2
- package/src/Slider/Slider.web.tsx +81 -7
- package/src/Slider/types.ts +2 -1
- package/src/Switch/Switch.native.tsx +41 -3
- package/src/Switch/Switch.web.tsx +45 -5
- package/src/Switch/types.ts +2 -1
- package/src/TabBar/TabBar.native.tsx +23 -2
- package/src/TabBar/TabBar.web.tsx +71 -2
- package/src/TabBar/types.ts +2 -1
- package/src/Table/Table.native.tsx +17 -1
- package/src/Table/Table.web.tsx +20 -3
- package/src/Table/types.ts +3 -2
- package/src/TextArea/TextArea.native.tsx +50 -1
- package/src/TextArea/TextArea.web.tsx +82 -6
- package/src/TextArea/types.ts +2 -1
- package/src/Tooltip/Tooltip.native.tsx +19 -2
- package/src/Tooltip/Tooltip.web.tsx +54 -2
- package/src/Tooltip/types.ts +2 -1
- package/src/Video/Video.native.tsx +18 -3
- package/src/Video/Video.web.tsx +17 -1
- package/src/Video/types.ts +2 -1
- package/src/examples/InputExamples.tsx +53 -0
- package/src/examples/ListExamples.tsx +34 -0
- package/src/internal/index.ts +2 -0
- package/src/utils/accessibility/ariaHelpers.ts +393 -0
- package/src/utils/accessibility/index.ts +210 -0
- package/src/utils/accessibility/keyboardPatterns.ts +263 -0
- package/src/utils/accessibility/types.ts +223 -0
- package/src/utils/accessibility/useAnnounce.ts +210 -0
- package/src/utils/accessibility/useFocusTrap.ts +265 -0
- package/src/utils/accessibility/useKeyboardNavigation.ts +292 -0
- package/src/utils/index.ts +3 -0
|
@@ -57,7 +57,7 @@ const Image = forwardRef<View, ImageProps>(({
|
|
|
57
57
|
];
|
|
58
58
|
|
|
59
59
|
return (
|
|
60
|
-
<View ref={ref} nativeID={id} style={containerStyle as any} testID={testID}>
|
|
60
|
+
<View ref={ref} nativeID={id} style={containerStyle as any} testID={testID} accessibilityRole="image" accessibilityLabel={accessibilityLabel || alt}>
|
|
61
61
|
<RNImage
|
|
62
62
|
source={imageSource as any}
|
|
63
63
|
style={[imageStyles.image, { borderRadius }]}
|
package/src/Image/Image.web.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { useState, isValidElement } from 'react';
|
|
1
|
+
import React, { useState, isValidElement, useMemo } from 'react';
|
|
2
2
|
import { View, TextInput, TouchableOpacity } from 'react-native';
|
|
3
3
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
|
4
4
|
import { InputProps } from './types';
|
|
5
5
|
import { inputStyles } from './Input.styles';
|
|
6
|
+
import { getNativeFormAccessibilityProps } from '../utils/accessibility';
|
|
6
7
|
|
|
7
8
|
const Input = React.forwardRef<TextInput, InputProps>(({
|
|
8
9
|
value,
|
|
@@ -28,6 +29,14 @@ const Input = React.forwardRef<TextInput, InputProps>(({
|
|
|
28
29
|
style,
|
|
29
30
|
testID,
|
|
30
31
|
id,
|
|
32
|
+
// Accessibility props
|
|
33
|
+
accessibilityLabel,
|
|
34
|
+
accessibilityHint,
|
|
35
|
+
accessibilityDisabled,
|
|
36
|
+
accessibilityHidden,
|
|
37
|
+
accessibilityRole,
|
|
38
|
+
accessibilityRequired,
|
|
39
|
+
accessibilityInvalid,
|
|
31
40
|
}, ref) => {
|
|
32
41
|
const [isFocused, setIsFocused] = useState(false);
|
|
33
42
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
@@ -85,6 +94,32 @@ const Input = React.forwardRef<TextInput, InputProps>(({
|
|
|
85
94
|
marginHorizontal,
|
|
86
95
|
});
|
|
87
96
|
|
|
97
|
+
// Generate native accessibility props
|
|
98
|
+
const nativeA11yProps = useMemo(() => {
|
|
99
|
+
// Derive invalid state from hasError or explicit accessibilityInvalid
|
|
100
|
+
const isInvalid = accessibilityInvalid ?? hasError;
|
|
101
|
+
|
|
102
|
+
return getNativeFormAccessibilityProps({
|
|
103
|
+
accessibilityLabel,
|
|
104
|
+
accessibilityHint,
|
|
105
|
+
accessibilityDisabled: accessibilityDisabled ?? disabled,
|
|
106
|
+
accessibilityHidden,
|
|
107
|
+
accessibilityRole: accessibilityRole ?? 'textbox',
|
|
108
|
+
accessibilityRequired,
|
|
109
|
+
accessibilityInvalid: isInvalid,
|
|
110
|
+
});
|
|
111
|
+
}, [
|
|
112
|
+
accessibilityLabel,
|
|
113
|
+
accessibilityHint,
|
|
114
|
+
accessibilityDisabled,
|
|
115
|
+
disabled,
|
|
116
|
+
accessibilityHidden,
|
|
117
|
+
accessibilityRole,
|
|
118
|
+
accessibilityRequired,
|
|
119
|
+
accessibilityInvalid,
|
|
120
|
+
hasError,
|
|
121
|
+
]);
|
|
122
|
+
|
|
88
123
|
// Helper to render left icon
|
|
89
124
|
const renderLeftIcon = () => {
|
|
90
125
|
if (!leftIcon) return null;
|
|
@@ -149,6 +184,7 @@ const Input = React.forwardRef<TextInput, InputProps>(({
|
|
|
149
184
|
onBlur={handleBlur}
|
|
150
185
|
style={inputStyles.input}
|
|
151
186
|
placeholderTextColor="#999999"
|
|
187
|
+
{...nativeA11yProps}
|
|
152
188
|
/>
|
|
153
189
|
|
|
154
190
|
{/* Right Icon or Password Toggle */}
|
package/src/Input/Input.web.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React, { isValidElement, useState } from 'react';
|
|
1
|
+
import React, { isValidElement, useState, useMemo, useRef } from 'react';
|
|
2
2
|
import { getWebProps } from 'react-native-unistyles/web';
|
|
3
3
|
import { IconSvg } from '../Icon/IconSvg/IconSvg.web';
|
|
4
4
|
import { isIconName, resolveIconPath } from '../Icon/icon-resolver';
|
|
5
5
|
import useMergeRefs from '../hooks/useMergeRefs';
|
|
6
6
|
import { inputStyles } from './Input.styles';
|
|
7
7
|
import { InputProps } from './types';
|
|
8
|
+
import { getWebFormAriaProps, generateAccessibilityId, combineIds } from '../utils/accessibility';
|
|
8
9
|
|
|
9
10
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
10
11
|
value,
|
|
@@ -30,6 +31,23 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
|
30
31
|
style,
|
|
31
32
|
testID,
|
|
32
33
|
id,
|
|
34
|
+
// Accessibility props
|
|
35
|
+
accessibilityLabel,
|
|
36
|
+
accessibilityHint,
|
|
37
|
+
accessibilityDisabled,
|
|
38
|
+
accessibilityHidden,
|
|
39
|
+
accessibilityRole,
|
|
40
|
+
accessibilityLabelledBy,
|
|
41
|
+
accessibilityDescribedBy,
|
|
42
|
+
accessibilityControls,
|
|
43
|
+
accessibilityExpanded,
|
|
44
|
+
accessibilityPressed,
|
|
45
|
+
accessibilityOwns,
|
|
46
|
+
accessibilityHasPopup,
|
|
47
|
+
accessibilityRequired,
|
|
48
|
+
accessibilityInvalid,
|
|
49
|
+
accessibilityErrorMessage,
|
|
50
|
+
accessibilityAutoComplete,
|
|
33
51
|
}, ref) => {
|
|
34
52
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
35
53
|
|
|
@@ -106,7 +124,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
|
106
124
|
});
|
|
107
125
|
|
|
108
126
|
// Get web props for all styled elements
|
|
109
|
-
const containerProps = getWebProps([inputStyles.container, style]);
|
|
127
|
+
const {ref: containerStyleRef, ...containerProps} = getWebProps([inputStyles.container, style]);
|
|
110
128
|
const leftIconContainerProps = getWebProps([inputStyles.leftIconContainer]);
|
|
111
129
|
const rightIconContainerProps = getWebProps([inputStyles.rightIconContainer]);
|
|
112
130
|
const leftIconProps = getWebProps([inputStyles.leftIcon]);
|
|
@@ -117,11 +135,49 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
|
117
135
|
// Get input props
|
|
118
136
|
const inputWebProps = getWebProps([inputStyles.input]);
|
|
119
137
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
// Generate accessibility props
|
|
139
|
+
const ariaProps = useMemo(() => {
|
|
140
|
+
// Derive invalid state from hasError or explicit accessibilityInvalid
|
|
141
|
+
const isInvalid = accessibilityInvalid ?? hasError;
|
|
142
|
+
|
|
143
|
+
return getWebFormAriaProps({
|
|
144
|
+
accessibilityLabel,
|
|
145
|
+
accessibilityHint,
|
|
146
|
+
accessibilityDisabled: accessibilityDisabled ?? disabled,
|
|
147
|
+
accessibilityHidden,
|
|
148
|
+
accessibilityRole: accessibilityRole ?? 'textbox',
|
|
149
|
+
accessibilityLabelledBy,
|
|
150
|
+
accessibilityDescribedBy,
|
|
151
|
+
accessibilityControls,
|
|
152
|
+
accessibilityExpanded,
|
|
153
|
+
accessibilityPressed,
|
|
154
|
+
accessibilityOwns,
|
|
155
|
+
accessibilityHasPopup,
|
|
156
|
+
accessibilityRequired,
|
|
157
|
+
accessibilityInvalid: isInvalid,
|
|
158
|
+
accessibilityErrorMessage,
|
|
159
|
+
accessibilityAutoComplete,
|
|
160
|
+
});
|
|
161
|
+
}, [
|
|
162
|
+
accessibilityLabel,
|
|
163
|
+
accessibilityHint,
|
|
164
|
+
accessibilityDisabled,
|
|
165
|
+
disabled,
|
|
166
|
+
accessibilityHidden,
|
|
167
|
+
accessibilityRole,
|
|
168
|
+
accessibilityLabelledBy,
|
|
169
|
+
accessibilityDescribedBy,
|
|
170
|
+
accessibilityControls,
|
|
171
|
+
accessibilityExpanded,
|
|
172
|
+
accessibilityPressed,
|
|
173
|
+
accessibilityOwns,
|
|
174
|
+
accessibilityHasPopup,
|
|
175
|
+
accessibilityRequired,
|
|
176
|
+
accessibilityInvalid,
|
|
177
|
+
hasError,
|
|
178
|
+
accessibilityErrorMessage,
|
|
179
|
+
accessibilityAutoComplete,
|
|
180
|
+
]);
|
|
125
181
|
|
|
126
182
|
// Merge the forwarded ref with unistyles ref for the input
|
|
127
183
|
const mergedInputRef = useMergeRefs(ref, inputWebProps.ref);
|
|
@@ -179,8 +235,18 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
|
179
235
|
);
|
|
180
236
|
};
|
|
181
237
|
|
|
238
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
239
|
+
|
|
240
|
+
const handleContainerPress = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
containerRef.current?.focus();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const mergedContainerRef = useMergeRefs(containerRef, containerStyleRef);
|
|
247
|
+
|
|
182
248
|
return (
|
|
183
|
-
<div onClick={handleContainerPress} {...containerProps} id={id} data-testid={testID}>
|
|
249
|
+
<div onClick={handleContainerPress} ref={mergedContainerRef} {...containerProps} id={id} data-testid={testID}>
|
|
184
250
|
{/* Left Icon */}
|
|
185
251
|
{leftIcon && (
|
|
186
252
|
<span {...leftIconContainerProps}>
|
|
@@ -191,6 +257,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
|
191
257
|
{/* Input */}
|
|
192
258
|
<input
|
|
193
259
|
{...inputWebProps}
|
|
260
|
+
{...ariaProps}
|
|
194
261
|
ref={mergedInputRef}
|
|
195
262
|
type={getInputType()}
|
|
196
263
|
value={value}
|
package/src/Input/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { StyleProp, ViewStyle } from 'react-native';
|
|
|
2
2
|
import type { IconName } from '../Icon/icon-types';
|
|
3
3
|
import { Intent, Size } from '@idealyst/theme';
|
|
4
4
|
import { FormInputStyleProps } from '../utils/viewStyleProps';
|
|
5
|
+
import { FormAccessibilityProps } from '../utils/accessibility';
|
|
5
6
|
|
|
6
7
|
// Component-specific type aliases for future extensibility
|
|
7
8
|
export type InputIntent = Intent;
|
|
@@ -9,7 +10,7 @@ export type InputSize = Size;
|
|
|
9
10
|
export type InputType = 'outlined' | 'filled' | 'bare';
|
|
10
11
|
export type InputInputType = 'text' | 'email' | 'password' | 'number';
|
|
11
12
|
|
|
12
|
-
export interface InputProps extends FormInputStyleProps {
|
|
13
|
+
export interface InputProps extends FormInputStyleProps, FormAccessibilityProps {
|
|
13
14
|
/**
|
|
14
15
|
* The current value of the input
|
|
15
16
|
*/
|
package/src/List/List.native.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react';
|
|
1
|
+
import React, { forwardRef, useMemo } from 'react';
|
|
2
2
|
import { View, ScrollView } from 'react-native';
|
|
3
3
|
import { listStyles } from './List.styles';
|
|
4
4
|
import type { ListProps } from './types';
|
|
5
5
|
import { ListProvider } from './ListContext';
|
|
6
|
+
import { getNativeAccessibilityProps } from '../utils/accessibility';
|
|
6
7
|
|
|
7
8
|
const List = forwardRef<View, ListProps>(({
|
|
8
9
|
children,
|
|
@@ -21,7 +22,21 @@ const List = forwardRef<View, ListProps>(({
|
|
|
21
22
|
scrollable = false,
|
|
22
23
|
maxHeight,
|
|
23
24
|
id,
|
|
25
|
+
// Accessibility props
|
|
26
|
+
accessibilityLabel,
|
|
27
|
+
accessibilityHint,
|
|
28
|
+
accessibilityRole,
|
|
29
|
+
accessibilityHidden,
|
|
24
30
|
}, ref) => {
|
|
31
|
+
// Generate native accessibility props
|
|
32
|
+
const nativeA11yProps = useMemo(() => {
|
|
33
|
+
return getNativeAccessibilityProps({
|
|
34
|
+
accessibilityLabel,
|
|
35
|
+
accessibilityHint,
|
|
36
|
+
accessibilityRole: accessibilityRole ?? 'list',
|
|
37
|
+
accessibilityHidden,
|
|
38
|
+
});
|
|
39
|
+
}, [accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityHidden]);
|
|
25
40
|
// Apply types
|
|
26
41
|
listStyles.useVariants({
|
|
27
42
|
size,
|
|
@@ -55,6 +70,7 @@ const List = forwardRef<View, ListProps>(({
|
|
|
55
70
|
style={containerStyle as any}
|
|
56
71
|
testID={testID}
|
|
57
72
|
showsVerticalScrollIndicator={true}
|
|
73
|
+
{...nativeA11yProps}
|
|
58
74
|
>
|
|
59
75
|
{content}
|
|
60
76
|
</ScrollView>
|
|
@@ -62,7 +78,7 @@ const List = forwardRef<View, ListProps>(({
|
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
return (
|
|
65
|
-
<View ref={ref} nativeID={id} style={containerStyle as any} testID={testID}>
|
|
81
|
+
<View ref={ref} nativeID={id} style={containerStyle as any} testID={testID} {...nativeA11yProps}>
|
|
66
82
|
{content}
|
|
67
83
|
</View>
|
|
68
84
|
);
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import React, { isValidElement, forwardRef, ComponentRef } from 'react';
|
|
2
|
-
import { View,
|
|
1
|
+
import React, { isValidElement, forwardRef, ComponentRef, useMemo } from 'react';
|
|
2
|
+
import { View, Pressable, Text } from 'react-native';
|
|
3
|
+
import { useUnistyles } from 'react-native-unistyles';
|
|
3
4
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
|
5
|
+
import { getColorFromString, Intent, Theme, Color } from '@idealyst/theme';
|
|
4
6
|
import { listStyles } from './List.styles';
|
|
5
7
|
import type { ListItemProps } from './types';
|
|
6
8
|
import { useListContext } from './ListContext';
|
|
9
|
+
import { getNativeSelectableAccessibilityProps } from '../utils/accessibility';
|
|
7
10
|
|
|
8
|
-
const ListItem = forwardRef<ComponentRef<typeof View> | ComponentRef<typeof
|
|
11
|
+
const ListItem = forwardRef<ComponentRef<typeof View> | ComponentRef<typeof Pressable>, ListItemProps>(({
|
|
9
12
|
id,
|
|
10
13
|
label,
|
|
11
14
|
children,
|
|
12
15
|
leading,
|
|
13
16
|
trailing,
|
|
17
|
+
iconColor,
|
|
14
18
|
active = false,
|
|
15
19
|
selected = false,
|
|
16
20
|
disabled = false,
|
|
@@ -19,10 +23,30 @@ const ListItem = forwardRef<ComponentRef<typeof View> | ComponentRef<typeof Touc
|
|
|
19
23
|
onPress,
|
|
20
24
|
style,
|
|
21
25
|
testID,
|
|
26
|
+
// Accessibility props
|
|
27
|
+
accessibilityLabel,
|
|
28
|
+
accessibilityHint,
|
|
29
|
+
accessibilityRole,
|
|
30
|
+
accessibilityHidden,
|
|
31
|
+
accessibilitySelected,
|
|
32
|
+
accessibilityDisabled,
|
|
22
33
|
}, ref) => {
|
|
34
|
+
const { theme } = useUnistyles() as { theme: Theme };
|
|
23
35
|
const listContext = useListContext();
|
|
24
36
|
const isClickable = !disabled && !!onPress;
|
|
25
37
|
|
|
38
|
+
// Generate native accessibility props
|
|
39
|
+
const nativeA11yProps = useMemo(() => {
|
|
40
|
+
return getNativeSelectableAccessibilityProps({
|
|
41
|
+
accessibilityLabel: accessibilityLabel ?? label,
|
|
42
|
+
accessibilityHint,
|
|
43
|
+
accessibilityRole: accessibilityRole ?? (isClickable ? 'button' : 'none'),
|
|
44
|
+
accessibilityHidden,
|
|
45
|
+
accessibilitySelected: accessibilitySelected ?? selected,
|
|
46
|
+
accessibilityDisabled: accessibilityDisabled ?? disabled,
|
|
47
|
+
});
|
|
48
|
+
}, [accessibilityLabel, label, accessibilityHint, accessibilityRole, isClickable, accessibilityHidden, accessibilitySelected, selected, accessibilityDisabled, disabled]);
|
|
49
|
+
|
|
26
50
|
// Use explicit size prop, fallback to context size, then default
|
|
27
51
|
const effectiveSize = size ?? listContext.size ?? 'md';
|
|
28
52
|
const effectiveVariant = listContext.type ?? 'default';
|
|
@@ -36,6 +60,17 @@ const ListItem = forwardRef<ComponentRef<typeof View> | ComponentRef<typeof Touc
|
|
|
36
60
|
clickable: isClickable,
|
|
37
61
|
});
|
|
38
62
|
|
|
63
|
+
// Resolve icon color - check intents first, then color palette
|
|
64
|
+
const resolvedIconColor = (() => {
|
|
65
|
+
if (!iconColor) return undefined;
|
|
66
|
+
// Check if it's an intent name
|
|
67
|
+
if (iconColor in theme.intents) {
|
|
68
|
+
return theme.intents[iconColor as Intent]?.primary;
|
|
69
|
+
}
|
|
70
|
+
// Otherwise try color palette
|
|
71
|
+
return getColorFromString(theme, iconColor as Color);
|
|
72
|
+
})();
|
|
73
|
+
|
|
39
74
|
// Helper to render leading/trailing icons
|
|
40
75
|
const renderElement = (element: typeof leading | typeof trailing, styleKey: 'leading' | 'trailingIcon') => {
|
|
41
76
|
if (!element) return null;
|
|
@@ -47,7 +82,7 @@ const ListItem = forwardRef<ComponentRef<typeof View> | ComponentRef<typeof Touc
|
|
|
47
82
|
<MaterialCommunityIcons
|
|
48
83
|
name={element}
|
|
49
84
|
size={iconStyle.width}
|
|
50
|
-
color={iconStyle.color}
|
|
85
|
+
color={resolvedIconColor ?? iconStyle.color}
|
|
51
86
|
/>
|
|
52
87
|
);
|
|
53
88
|
} else if (isValidElement(element)) {
|
|
@@ -86,21 +121,22 @@ const ListItem = forwardRef<ComponentRef<typeof View> | ComponentRef<typeof Touc
|
|
|
86
121
|
|
|
87
122
|
if (isClickable) {
|
|
88
123
|
return (
|
|
89
|
-
<
|
|
124
|
+
<Pressable
|
|
90
125
|
ref={ref as any}
|
|
126
|
+
nativeID={id}
|
|
91
127
|
style={combinedStyle}
|
|
92
128
|
onPress={onPress}
|
|
93
129
|
disabled={disabled}
|
|
94
|
-
activeOpacity={0.7}
|
|
95
130
|
testID={testID}
|
|
131
|
+
{...nativeA11yProps}
|
|
96
132
|
>
|
|
97
133
|
{content}
|
|
98
|
-
</
|
|
134
|
+
</Pressable>
|
|
99
135
|
);
|
|
100
136
|
}
|
|
101
137
|
|
|
102
138
|
return (
|
|
103
|
-
<View ref={ref as any} style={combinedStyle} testID={testID}>
|
|
139
|
+
<View ref={ref as any} nativeID={id} style={combinedStyle} testID={testID} {...nativeA11yProps}>
|
|
104
140
|
{content}
|
|
105
141
|
</View>
|
|
106
142
|
);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React, { isValidElement } from 'react';
|
|
2
2
|
import { getWebProps } from 'react-native-unistyles/web';
|
|
3
|
+
import { useUnistyles } from 'react-native-unistyles';
|
|
4
|
+
import { getColorFromString, Intent, Theme, Color } from '@idealyst/theme';
|
|
3
5
|
import { listStyles } from './List.styles';
|
|
4
6
|
import type { ListItemProps } from './types';
|
|
5
7
|
import { IconSvg } from '../Icon/IconSvg/IconSvg.web';
|
|
@@ -12,6 +14,7 @@ const ListItem: React.FC<ListItemProps> = ({
|
|
|
12
14
|
children,
|
|
13
15
|
leading,
|
|
14
16
|
trailing,
|
|
17
|
+
iconColor,
|
|
15
18
|
active = false,
|
|
16
19
|
selected = false,
|
|
17
20
|
disabled = false,
|
|
@@ -21,6 +24,7 @@ const ListItem: React.FC<ListItemProps> = ({
|
|
|
21
24
|
style,
|
|
22
25
|
testID,
|
|
23
26
|
}) => {
|
|
27
|
+
const { theme } = useUnistyles() as { theme: Theme };
|
|
24
28
|
const listContext = useListContext();
|
|
25
29
|
const isClickable = !disabled && !!onPress;
|
|
26
30
|
|
|
@@ -49,6 +53,17 @@ const ListItem: React.FC<ListItemProps> = ({
|
|
|
49
53
|
}
|
|
50
54
|
};
|
|
51
55
|
|
|
56
|
+
// Resolve icon color - check intents first, then color palette
|
|
57
|
+
const resolvedIconColor = (() => {
|
|
58
|
+
if (!iconColor) return undefined;
|
|
59
|
+
// Check if it's an intent name
|
|
60
|
+
if (iconColor in theme.intents) {
|
|
61
|
+
return theme.intents[iconColor as Intent]?.primary;
|
|
62
|
+
}
|
|
63
|
+
// Otherwise try color palette
|
|
64
|
+
return getColorFromString(theme, iconColor as Color);
|
|
65
|
+
})();
|
|
66
|
+
|
|
52
67
|
// Helper to render leading/trailing icons
|
|
53
68
|
const renderElement = (element: typeof leading | typeof trailing, props: any, isTrailing = false) => {
|
|
54
69
|
if (!element) return null;
|
|
@@ -61,6 +76,7 @@ const ListItem: React.FC<ListItemProps> = ({
|
|
|
61
76
|
<IconSvg
|
|
62
77
|
path={iconPath}
|
|
63
78
|
{...iconPropsToUse}
|
|
79
|
+
color={resolvedIconColor}
|
|
64
80
|
aria-label={element}
|
|
65
81
|
/>
|
|
66
82
|
);
|
package/src/List/types.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import type { IconName } from '../Icon/icon-types';
|
|
4
|
-
import { Size } from '@idealyst/theme';
|
|
4
|
+
import { Size, Color, Intent } from '@idealyst/theme';
|
|
5
5
|
import { ContainerStyleProps } from '../utils/viewStyleProps';
|
|
6
|
+
import { AccessibilityProps, SelectableAccessibilityProps } from '../utils/accessibility';
|
|
6
7
|
|
|
7
8
|
// Component-specific type aliases for future extensibility
|
|
8
9
|
export type ListSizeVariant = Size;
|
|
9
10
|
export type ListType = 'default' | 'bordered' | 'divided';
|
|
10
11
|
|
|
11
|
-
export interface ListItemProps {
|
|
12
|
+
export interface ListItemProps extends SelectableAccessibilityProps {
|
|
12
13
|
id?: string;
|
|
13
14
|
label?: string;
|
|
14
15
|
children?: ReactNode;
|
|
15
16
|
leading?: IconName | ReactNode;
|
|
16
17
|
trailing?: IconName | ReactNode;
|
|
18
|
+
/** Color for leading and trailing icons. Accepts intent names (primary, success, error, warning) or palette colors (blue.500, red.300) */
|
|
19
|
+
iconColor?: Intent | Color;
|
|
17
20
|
active?: boolean;
|
|
18
21
|
selected?: boolean;
|
|
19
22
|
disabled?: boolean;
|
|
@@ -24,7 +27,7 @@ export interface ListItemProps {
|
|
|
24
27
|
testID?: string;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
export interface ListProps extends ContainerStyleProps {
|
|
30
|
+
export interface ListProps extends ContainerStyleProps, AccessibilityProps {
|
|
28
31
|
children: ReactNode;
|
|
29
32
|
type?: ListType;
|
|
30
33
|
size?: ListSizeVariant;
|
package/src/Menu/Menu.native.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useState, isValidElement, cloneElement, forwardRef, useEffect } from 'react';
|
|
1
|
+
import React, { useRef, useState, isValidElement, cloneElement, forwardRef, useEffect, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Modal,
|
|
@@ -11,6 +11,7 @@ import MenuItem from './MenuItem.native';
|
|
|
11
11
|
import useMergeRefs from '../hooks/useMergeRefs';
|
|
12
12
|
import { BoundedModalContent } from '../internal/BoundedModalContent.native';
|
|
13
13
|
import { useSmartPosition } from '../hooks/useSmartPosition.native';
|
|
14
|
+
import { getNativeInteractiveAccessibilityProps } from '../utils/accessibility';
|
|
14
15
|
|
|
15
16
|
const Menu = forwardRef<View, MenuProps>(({
|
|
16
17
|
children,
|
|
@@ -22,7 +23,25 @@ const Menu = forwardRef<View, MenuProps>(({
|
|
|
22
23
|
size,
|
|
23
24
|
testID,
|
|
24
25
|
id,
|
|
26
|
+
// Accessibility props
|
|
27
|
+
accessibilityLabel,
|
|
28
|
+
accessibilityHint,
|
|
29
|
+
accessibilityDisabled,
|
|
30
|
+
accessibilityHidden,
|
|
31
|
+
accessibilityRole,
|
|
32
|
+
accessibilityExpanded,
|
|
25
33
|
}, ref) => {
|
|
34
|
+
// Generate native accessibility props
|
|
35
|
+
const nativeA11yProps = useMemo(() => {
|
|
36
|
+
return getNativeInteractiveAccessibilityProps({
|
|
37
|
+
accessibilityLabel,
|
|
38
|
+
accessibilityHint,
|
|
39
|
+
accessibilityDisabled,
|
|
40
|
+
accessibilityHidden,
|
|
41
|
+
accessibilityRole: accessibilityRole ?? 'menu',
|
|
42
|
+
accessibilityExpanded: accessibilityExpanded ?? open,
|
|
43
|
+
});
|
|
44
|
+
}, [accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityExpanded, open]);
|
|
26
45
|
const {
|
|
27
46
|
position: menuPosition,
|
|
28
47
|
size: menuSize,
|
|
@@ -137,7 +156,7 @@ const Menu = forwardRef<View, MenuProps>(({
|
|
|
137
156
|
|
|
138
157
|
return (
|
|
139
158
|
<>
|
|
140
|
-
<View ref={mergedTriggerRef} nativeID={id} collapsable={false}>
|
|
159
|
+
<View ref={mergedTriggerRef} nativeID={id} collapsable={false} {...nativeA11yProps}>
|
|
141
160
|
{trigger}
|
|
142
161
|
</View>
|
|
143
162
|
|
package/src/Menu/Menu.web.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React, { useRef, forwardRef } from 'react';
|
|
1
|
+
import React, { useRef, forwardRef, useMemo, useCallback, useEffect } from 'react';
|
|
2
2
|
import { getWebProps } from 'react-native-unistyles/web';
|
|
3
3
|
import { menuStyles } from './Menu.styles';
|
|
4
4
|
import type { MenuProps } from './types';
|
|
5
5
|
import MenuItem from './MenuItem.web';
|
|
6
6
|
import useMergeRefs from '../hooks/useMergeRefs';
|
|
7
7
|
import { PositionedPortal } from '../internal/PositionedPortal';
|
|
8
|
+
import { getWebInteractiveAriaProps, generateAccessibilityId, MENU_KEYS } from '../utils/accessibility';
|
|
8
9
|
|
|
9
10
|
const Menu = forwardRef<HTMLDivElement, MenuProps>(({
|
|
10
11
|
children,
|
|
@@ -17,9 +18,96 @@ const Menu = forwardRef<HTMLDivElement, MenuProps>(({
|
|
|
17
18
|
style,
|
|
18
19
|
testID,
|
|
19
20
|
id,
|
|
21
|
+
// Accessibility props
|
|
22
|
+
accessibilityLabel,
|
|
23
|
+
accessibilityHint,
|
|
24
|
+
accessibilityDisabled,
|
|
25
|
+
accessibilityHidden,
|
|
26
|
+
accessibilityRole,
|
|
27
|
+
accessibilityExpanded,
|
|
28
|
+
accessibilityControls,
|
|
29
|
+
accessibilityHasPopup,
|
|
20
30
|
}, ref) => {
|
|
21
31
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
22
32
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const menuItemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
|
34
|
+
const focusedIndex = useRef<number>(-1);
|
|
35
|
+
|
|
36
|
+
// Generate unique ID for menu
|
|
37
|
+
const menuId = useMemo(() => id || generateAccessibilityId('menu'), [id]);
|
|
38
|
+
|
|
39
|
+
// Get enabled items for keyboard navigation
|
|
40
|
+
const enabledItems = useMemo(() =>
|
|
41
|
+
items.map((item, index) => ({ ...item, index })).filter(item => !item.disabled && !item.separator),
|
|
42
|
+
[items]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Focus first menu item when menu opens
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (open && enabledItems.length > 0) {
|
|
48
|
+
// Small delay to ensure menu is rendered
|
|
49
|
+
requestAnimationFrame(() => {
|
|
50
|
+
const firstItem = menuItemRefs.current.get(enabledItems[0].index);
|
|
51
|
+
if (firstItem) {
|
|
52
|
+
firstItem.focus();
|
|
53
|
+
focusedIndex.current = 0;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
focusedIndex.current = -1;
|
|
58
|
+
}
|
|
59
|
+
}, [open, enabledItems]);
|
|
60
|
+
|
|
61
|
+
// Keyboard navigation handler
|
|
62
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
63
|
+
const key = e.key;
|
|
64
|
+
|
|
65
|
+
if (MENU_KEYS.close.includes(key)) {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
onOpenChange?.(false);
|
|
68
|
+
// Return focus to trigger
|
|
69
|
+
triggerRef.current?.focus();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (enabledItems.length === 0) return;
|
|
74
|
+
|
|
75
|
+
let nextIndex = focusedIndex.current;
|
|
76
|
+
|
|
77
|
+
if (MENU_KEYS.next.includes(key)) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
nextIndex = focusedIndex.current < enabledItems.length - 1 ? focusedIndex.current + 1 : 0;
|
|
80
|
+
} else if (MENU_KEYS.prev.includes(key)) {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
nextIndex = focusedIndex.current > 0 ? focusedIndex.current - 1 : enabledItems.length - 1;
|
|
83
|
+
} else if (MENU_KEYS.first.includes(key)) {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
nextIndex = 0;
|
|
86
|
+
} else if (MENU_KEYS.last.includes(key)) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
nextIndex = enabledItems.length - 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (nextIndex !== focusedIndex.current && nextIndex >= 0) {
|
|
92
|
+
focusedIndex.current = nextIndex;
|
|
93
|
+
const item = enabledItems[nextIndex];
|
|
94
|
+
const button = menuItemRefs.current.get(item.index);
|
|
95
|
+
button?.focus();
|
|
96
|
+
}
|
|
97
|
+
}, [enabledItems, onOpenChange]);
|
|
98
|
+
|
|
99
|
+
// Generate ARIA props for menu
|
|
100
|
+
const ariaProps = useMemo(() => {
|
|
101
|
+
return getWebInteractiveAriaProps({
|
|
102
|
+
accessibilityLabel,
|
|
103
|
+
accessibilityHint,
|
|
104
|
+
accessibilityDisabled,
|
|
105
|
+
accessibilityHidden,
|
|
106
|
+
accessibilityRole: accessibilityRole ?? 'menu',
|
|
107
|
+
accessibilityExpanded: accessibilityExpanded ?? open,
|
|
108
|
+
accessibilityControls,
|
|
109
|
+
});
|
|
110
|
+
}, [accessibilityLabel, accessibilityHint, accessibilityDisabled, accessibilityHidden, accessibilityRole, accessibilityExpanded, open, accessibilityControls]);
|
|
23
111
|
|
|
24
112
|
menuStyles.useVariants({
|
|
25
113
|
size,
|
|
@@ -47,7 +135,21 @@ const Menu = forwardRef<HTMLDivElement, MenuProps>(({
|
|
|
47
135
|
|
|
48
136
|
return (
|
|
49
137
|
<>
|
|
50
|
-
<div
|
|
138
|
+
<div
|
|
139
|
+
ref={triggerRef}
|
|
140
|
+
onClick={handleTriggerClick}
|
|
141
|
+
style={{ display: 'inline-block' }}
|
|
142
|
+
aria-haspopup={accessibilityHasPopup ?? 'menu'}
|
|
143
|
+
aria-expanded={open}
|
|
144
|
+
aria-controls={menuId}
|
|
145
|
+
tabIndex={0}
|
|
146
|
+
onKeyDown={(e) => {
|
|
147
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
handleTriggerClick();
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
51
153
|
{children}
|
|
52
154
|
</div>
|
|
53
155
|
|
|
@@ -64,10 +166,12 @@ const Menu = forwardRef<HTMLDivElement, MenuProps>(({
|
|
|
64
166
|
>
|
|
65
167
|
<div
|
|
66
168
|
{...menuProps}
|
|
169
|
+
{...ariaProps}
|
|
67
170
|
ref={mergedMenuRef}
|
|
68
171
|
role="menu"
|
|
69
|
-
id={
|
|
172
|
+
id={menuId}
|
|
70
173
|
data-testid={testID}
|
|
174
|
+
onKeyDown={handleKeyDown}
|
|
71
175
|
>
|
|
72
176
|
{items.map((item, index) => {
|
|
73
177
|
if (item.separator) {
|
|
@@ -87,6 +191,9 @@ const Menu = forwardRef<HTMLDivElement, MenuProps>(({
|
|
|
87
191
|
onPress={handleItemClick}
|
|
88
192
|
size={size}
|
|
89
193
|
testID={testID ? `${testID}-item-${item.id || index}` : undefined}
|
|
194
|
+
ref={(el) => {
|
|
195
|
+
if (el) menuItemRefs.current.set(index, el);
|
|
196
|
+
}}
|
|
90
197
|
/>
|
|
91
198
|
);
|
|
92
199
|
})}
|