@idealyst/components 1.1.4 → 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.
Files changed (84) hide show
  1. package/package.json +8 -3
  2. package/src/Accordion/Accordion.native.tsx +23 -2
  3. package/src/Accordion/Accordion.web.tsx +73 -2
  4. package/src/Accordion/types.ts +2 -1
  5. package/src/ActivityIndicator/ActivityIndicator.native.tsx +15 -1
  6. package/src/ActivityIndicator/ActivityIndicator.web.tsx +19 -2
  7. package/src/ActivityIndicator/types.ts +2 -1
  8. package/src/Avatar/Avatar.native.tsx +19 -2
  9. package/src/Avatar/Avatar.web.tsx +19 -2
  10. package/src/Avatar/types.ts +2 -1
  11. package/src/Breadcrumb/types.ts +3 -2
  12. package/src/Button/Button.native.tsx +48 -1
  13. package/src/Button/Button.styles.tsx +3 -5
  14. package/src/Button/Button.web.tsx +61 -2
  15. package/src/Button/types.ts +2 -1
  16. package/src/Card/Card.native.tsx +21 -5
  17. package/src/Card/Card.web.tsx +21 -4
  18. package/src/Card/types.ts +2 -6
  19. package/src/Checkbox/Checkbox.native.tsx +46 -5
  20. package/src/Checkbox/Checkbox.web.tsx +80 -4
  21. package/src/Checkbox/types.ts +2 -6
  22. package/src/Chip/Chip.native.tsx +5 -0
  23. package/src/Chip/Chip.web.tsx +5 -1
  24. package/src/Chip/types.ts +2 -1
  25. package/src/Dialog/Dialog.native.tsx +20 -3
  26. package/src/Dialog/Dialog.web.tsx +29 -4
  27. package/src/Dialog/types.ts +2 -1
  28. package/src/Image/Image.native.tsx +1 -1
  29. package/src/Image/Image.web.tsx +2 -0
  30. package/src/Input/Input.native.tsx +37 -1
  31. package/src/Input/Input.web.tsx +75 -8
  32. package/src/Input/types.ts +2 -1
  33. package/src/List/List.native.tsx +18 -2
  34. package/src/List/ListItem.native.tsx +44 -8
  35. package/src/List/ListItem.web.tsx +16 -0
  36. package/src/List/types.ts +6 -3
  37. package/src/Menu/Menu.native.tsx +21 -2
  38. package/src/Menu/Menu.web.tsx +110 -3
  39. package/src/Menu/MenuItem.web.tsx +12 -3
  40. package/src/Menu/types.ts +2 -1
  41. package/src/Popover/Popover.native.tsx +17 -1
  42. package/src/Popover/Popover.web.tsx +31 -2
  43. package/src/Popover/types.ts +2 -1
  44. package/src/RadioButton/RadioButton.native.tsx +41 -3
  45. package/src/RadioButton/RadioButton.web.tsx +45 -6
  46. package/src/RadioButton/RadioGroup.native.tsx +20 -2
  47. package/src/RadioButton/RadioGroup.web.tsx +24 -3
  48. package/src/RadioButton/types.ts +3 -2
  49. package/src/Select/types.ts +2 -6
  50. package/src/Skeleton/Skeleton.native.tsx +15 -1
  51. package/src/Skeleton/Skeleton.web.tsx +20 -1
  52. package/src/Skeleton/types.ts +2 -1
  53. package/src/Slider/Slider.native.tsx +42 -2
  54. package/src/Slider/Slider.web.tsx +81 -7
  55. package/src/Slider/types.ts +2 -1
  56. package/src/Switch/Switch.native.tsx +41 -3
  57. package/src/Switch/Switch.web.tsx +45 -5
  58. package/src/Switch/types.ts +2 -1
  59. package/src/TabBar/TabBar.native.tsx +23 -2
  60. package/src/TabBar/TabBar.web.tsx +71 -2
  61. package/src/TabBar/types.ts +2 -1
  62. package/src/Table/Table.native.tsx +17 -1
  63. package/src/Table/Table.web.tsx +20 -3
  64. package/src/Table/types.ts +3 -2
  65. package/src/TextArea/TextArea.native.tsx +50 -1
  66. package/src/TextArea/TextArea.web.tsx +82 -6
  67. package/src/TextArea/types.ts +2 -1
  68. package/src/Tooltip/Tooltip.native.tsx +19 -2
  69. package/src/Tooltip/Tooltip.web.tsx +54 -2
  70. package/src/Tooltip/types.ts +2 -1
  71. package/src/Video/Video.native.tsx +18 -3
  72. package/src/Video/Video.web.tsx +17 -1
  73. package/src/Video/types.ts +2 -1
  74. package/src/examples/InputExamples.tsx +53 -0
  75. package/src/examples/ListExamples.tsx +34 -0
  76. package/src/internal/index.ts +2 -0
  77. package/src/utils/accessibility/ariaHelpers.ts +393 -0
  78. package/src/utils/accessibility/index.ts +210 -0
  79. package/src/utils/accessibility/keyboardPatterns.ts +263 -0
  80. package/src/utils/accessibility/types.ts +223 -0
  81. package/src/utils/accessibility/useAnnounce.ts +210 -0
  82. package/src/utils/accessibility/useFocusTrap.ts +265 -0
  83. package/src/utils/accessibility/useKeyboardNavigation.ts +292 -0
  84. 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 }]}
@@ -63,6 +63,8 @@ const Image: React.FC<ImageProps> = ({
63
63
  return (
64
64
  <div
65
65
  {...containerProps}
66
+ role="img"
67
+ aria-label={accessibilityLabel || alt}
66
68
  id={id}
67
69
  data-testid={testID}
68
70
  >
@@ -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 */}
@@ -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
- const handleContainerPress = (e: React.MouseEvent<HTMLDivElement>) => {
121
- e.preventDefault();
122
- e.stopPropagation();
123
- inputWebProps.ref?.current?.focus();
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}
@@ -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
  */
@@ -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, TouchableOpacity, Text } from 'react-native';
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 TouchableOpacity>, ListItemProps>(({
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
- <TouchableOpacity
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
- </TouchableOpacity>
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;
@@ -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
 
@@ -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 ref={triggerRef} onClick={handleTriggerClick} style={{ display: 'inline-block' }}>
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={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
  })}