@lotics/ui 1.10.0 → 1.11.1

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 (55) hide show
  1. package/package.json +24 -7
  2. package/src/alert.tsx +35 -5
  3. package/src/avatar.tsx +28 -3
  4. package/src/back_button.tsx +4 -2
  5. package/src/button.tsx +35 -5
  6. package/src/calendar/calendar_view.tsx +127 -0
  7. package/src/calendar/dates.ts +102 -0
  8. package/src/calendar/index.ts +20 -0
  9. package/src/calendar/layout.test.ts +103 -0
  10. package/src/calendar/layout.ts +142 -0
  11. package/src/calendar/month_view.tsx +159 -0
  12. package/src/calendar/time_grid_view.tsx +263 -0
  13. package/src/calendar/types.ts +67 -0
  14. package/src/checkbox_input.tsx +9 -3
  15. package/src/command_menu.tsx +50 -4
  16. package/src/dialog.tsx +1 -1
  17. package/src/download.ts +14 -2
  18. package/src/form_field.tsx +77 -25
  19. package/src/form_switch.tsx +22 -3
  20. package/src/gantt/gantt_view.tsx +145 -0
  21. package/src/gantt/index.ts +5 -0
  22. package/src/gantt/scale.test.ts +47 -0
  23. package/src/gantt/scale.ts +92 -0
  24. package/src/gantt/types.ts +51 -0
  25. package/src/grid/select_header_cell.tsx +1 -0
  26. package/src/icon.tsx +14 -8
  27. package/src/icon_button.tsx +10 -4
  28. package/src/index.css +11 -0
  29. package/src/kanban/constants.ts +18 -0
  30. package/src/kanban/default_renderers.tsx +160 -0
  31. package/src/kanban/drag_preview.tsx +157 -0
  32. package/src/kanban/index.ts +13 -0
  33. package/src/kanban/insert_card_zone.tsx +135 -0
  34. package/src/kanban/kanban_board.tsx +616 -0
  35. package/src/kanban/kanban_card.tsx +312 -0
  36. package/src/kanban/kanban_column.tsx +487 -0
  37. package/src/kanban/placeholders.tsx +54 -0
  38. package/src/kanban/types.ts +116 -0
  39. package/src/landmark.tsx +34 -0
  40. package/src/menu_button.tsx +21 -0
  41. package/src/menu_list_item.tsx +3 -0
  42. package/src/number_input.tsx +10 -1
  43. package/src/pill_button.tsx +1 -0
  44. package/src/popover.tsx +47 -2
  45. package/src/popover_header.tsx +4 -2
  46. package/src/pressable_highlight.tsx +24 -0
  47. package/src/radio_picker.tsx +63 -5
  48. package/src/section_heading.tsx +5 -3
  49. package/src/skip_link.tsx +46 -0
  50. package/src/switch.tsx +9 -1
  51. package/src/switch_button.tsx +3 -0
  52. package/src/tabs.tsx +81 -19
  53. package/src/text.tsx +33 -0
  54. package/src/text_input_field.tsx +31 -0
  55. package/src/tooltip.tsx +43 -6
@@ -18,6 +18,18 @@ export interface MenuButtonProps {
18
18
  tooltip?: string;
19
19
  style?: StyleProp<ViewStyle>;
20
20
  testID?: string;
21
+ /** DOM ID, used by combobox patterns via `aria-activedescendant`. */
22
+ nativeID?: string;
23
+ /**
24
+ * Accessible name when `title` is a React node (so no string is available)
25
+ * or when you need a name that differs from the visible title.
26
+ */
27
+ accessibilityLabel?: string;
28
+ /**
29
+ * Override the ARIA role. Defaults to `menuitem` when pressable, matching
30
+ * the common popover-menu usage.
31
+ */
32
+ role?: "menuitem" | "button" | "option";
21
33
  }
22
34
 
23
35
  export function MenuButton(props: MenuButtonProps) {
@@ -35,8 +47,13 @@ export function MenuButton(props: MenuButtonProps) {
35
47
  tooltip,
36
48
  style,
37
49
  testID,
50
+ accessibilityLabel,
51
+ role = "menuitem",
52
+ nativeID,
38
53
  } = props;
39
54
 
55
+ const resolvedLabel = accessibilityLabel ?? (typeof title === "string" ? title : undefined) ?? tooltip;
56
+
40
57
  const resolvedIcon =
41
58
  typeof icon === "string" ? (
42
59
  <Icon size={20} name={icon as IconName} color={danger ? colors.red["900"] : undefined} />
@@ -78,6 +95,7 @@ export function MenuButton(props: MenuButtonProps) {
78
95
  <PressableHighlight
79
96
  ref={ref}
80
97
  testID={testID}
98
+ nativeID={nativeID}
81
99
  onPress={() => {
82
100
  onPress?.();
83
101
  }}
@@ -85,6 +103,9 @@ export function MenuButton(props: MenuButtonProps) {
85
103
  disabled={disabled}
86
104
  tooltip={tooltip}
87
105
  style={containerStyle}
106
+ role={role}
107
+ accessibilityLabel={resolvedLabel}
108
+ accessibilityState={{ selected: !!selected, disabled: !!disabled }}
88
109
  >
89
110
  {inner}
90
111
  </PressableHighlight>
@@ -49,6 +49,9 @@ export function MenuListItem(props: MenuListItemProps) {
49
49
  onPress={onPress}
50
50
  disabled={disabled}
51
51
  style={containerStyle}
52
+ accessibilityRole="button"
53
+ accessibilityLabel={description ? `${title}, ${description}` : title}
54
+ accessibilityState={{ disabled: !!disabled }}
52
55
  >
53
56
  {inner}
54
57
  </PressableHighlight>
@@ -1,5 +1,6 @@
1
1
  import { colors } from "./colors";
2
2
  import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
3
+ import { useFormField } from "./form_field";
3
4
 
4
5
  export interface NumberInputProps {
5
6
  value: number | null;
@@ -9,14 +10,22 @@ export interface NumberInputProps {
9
10
  onBlur?: () => void;
10
11
  disabled?: boolean;
11
12
  testID?: string;
13
+ accessibilityLabel?: string;
12
14
  }
13
15
 
14
16
  export function NumberInput(props: NumberInputProps) {
15
- const { value, onValueChange, min, max, disabled, onBlur, testID } = props;
17
+ const { value, onValueChange, min, max, disabled, onBlur, testID, accessibilityLabel } = props;
18
+ const binding = useFormField();
19
+ const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
16
20
 
17
21
  return (
18
22
  <input
19
23
  data-testid={testID}
24
+ id={binding?.inputId}
25
+ aria-labelledby={binding?.labelId}
26
+ aria-label={!binding ? accessibilityLabel : undefined}
27
+ aria-describedby={describedBy}
28
+ aria-invalid={binding?.invalid || undefined}
20
29
  value={value ?? ""}
21
30
  onChange={(e) =>
22
31
  e.target.value !== "" ? onValueChange(Number(e.target.value)) : onValueChange(null)
@@ -30,6 +30,7 @@ export function PillButton(props: PillButtonProps) {
30
30
  <IconButton
31
31
  icon="x"
32
32
  tooltip={dismissTooltip}
33
+ accessibilityLabel={dismissTooltip ?? "Remove"}
33
34
  onPress={onDismiss}
34
35
  testID={testID ? `${testID}-remove` : undefined}
35
36
  />
package/src/popover.tsx CHANGED
@@ -150,10 +150,14 @@ export function PopoverTrigger({ children }: PopoverTriggerProps) {
150
150
  children as React.ReactElement<{
151
151
  ref?: React.Ref<HTMLDivElement>;
152
152
  onPress?: () => void;
153
+ "aria-haspopup"?: "dialog" | "menu" | "listbox" | "true";
154
+ "aria-expanded"?: boolean;
153
155
  }>,
154
156
  {
155
157
  ref: triggerRef as React.Ref<HTMLDivElement>,
156
158
  onPress: handlePress,
159
+ "aria-haspopup": "dialog",
160
+ "aria-expanded": open,
157
161
  },
158
162
  );
159
163
  }
@@ -177,10 +181,20 @@ export interface PopoverContentProps {
177
181
  disableBodyScroll?: boolean;
178
182
  /** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
179
183
  small?: boolean;
184
+ /** Accessible name for the bottom-sheet close button. Default: "Close". Pass a translated string from the consumer. */
185
+ closeLabel?: string;
180
186
  }
181
187
 
182
188
  export function PopoverContent(props: PopoverContentProps) {
183
- const { testID, children, style, contentContainerStyle, disableBodyScroll, small = false } = props;
189
+ const {
190
+ testID,
191
+ children,
192
+ style,
193
+ contentContainerStyle,
194
+ disableBodyScroll,
195
+ small = false,
196
+ closeLabel = "Close",
197
+ } = props;
184
198
  const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
185
199
  usePopoverContext();
186
200
 
@@ -188,12 +202,41 @@ export function PopoverContent(props: PopoverContentProps) {
188
202
  const [position, setPosition] = useState<Position | null>(null);
189
203
  const [triggerWidth, setTriggerWidth] = useState<number>(0);
190
204
  const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
205
+ const returnFocusRef = useRef<HTMLElement | null>(null);
191
206
 
192
207
  const handleClose = useCallback(() => {
193
208
  if (!open) return;
194
209
  onOpenChange(false);
195
210
  }, [onOpenChange, open]);
196
211
 
212
+ // Focus management: when the popover opens, remember what had focus and move
213
+ // focus into the popover (the content div is tab-able via `tabIndex=-1`).
214
+ // When it closes, restore focus to the prior element so keyboard users don't
215
+ // land at the top of the page.
216
+ useEffect(() => {
217
+ if (!open) return;
218
+ const previouslyFocused = document.activeElement as HTMLElement | null;
219
+ returnFocusRef.current = previouslyFocused;
220
+
221
+ const focusFrame = requestAnimationFrame(() => {
222
+ const content = popoverRef.current;
223
+ if (!content) return;
224
+ const firstFocusable = content.querySelector<HTMLElement>(
225
+ 'input, select, textarea, button, [role="button"], [role="menuitem"], [role="option"], [tabindex]:not([tabindex="-1"])',
226
+ );
227
+ (firstFocusable ?? content).focus();
228
+ });
229
+
230
+ return () => {
231
+ cancelAnimationFrame(focusFrame);
232
+ const target = returnFocusRef.current;
233
+ returnFocusRef.current = null;
234
+ if (target && typeof target.focus === "function") {
235
+ target.focus();
236
+ }
237
+ };
238
+ }, [open]);
239
+
197
240
  // Separate header, footer, and body from children
198
241
  let header: React.ReactNode = null;
199
242
  let footer: React.ReactNode = null;
@@ -438,6 +481,8 @@ export function PopoverContent(props: PopoverContentProps) {
438
481
  ref={popoverRef}
439
482
  data-popover="true"
440
483
  data-testid={testID}
484
+ role="dialog"
485
+ aria-modal={small ? true : undefined}
441
486
  tabIndex={small ? undefined : -1}
442
487
  style={{
443
488
  position: "fixed",
@@ -497,7 +542,7 @@ export function PopoverContent(props: PopoverContentProps) {
497
542
  justifyContent: "flex-end",
498
543
  }}
499
544
  >
500
- <IconButton icon="x" onPress={handleClose} />
545
+ <IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
501
546
  </View>
502
547
  )}
503
548
  {header}
@@ -7,16 +7,18 @@ import { usePopoverNav } from "./popover_nav";
7
7
  interface PopoverHeaderProps {
8
8
  title: string;
9
9
  right?: ReactNode;
10
+ /** Accessible name for the back button. Default: "Back". Pass a translated string from the consumer. */
11
+ backLabel?: string;
10
12
  }
11
13
 
12
14
  export function PopoverHeader(props: PopoverHeaderProps) {
13
- const { title, right } = props;
15
+ const { title, right, backLabel = "Back" } = props;
14
16
  const { goBack, canGoBack } = usePopoverNav();
15
17
 
16
18
  return (
17
19
  <View style={styles.container}>
18
20
  {canGoBack && (
19
- <Button icon="chevron-left" color="secondary" onPress={goBack} />
21
+ <Button icon="chevron-left" color="secondary" accessibilityLabel={backLabel} onPress={goBack} />
20
22
  )}
21
23
  <Text size="sm" weight="medium" style={{ flex: 1 }}>
22
24
  {title}
@@ -10,10 +10,28 @@ import { Ref, useCallback } from "react";
10
10
  import { TooltipSide, useTooltip } from "./tooltip";
11
11
  import { colors } from "./colors";
12
12
 
13
+ function composeHandler<E>(
14
+ a: ((event: E) => void) | undefined,
15
+ b: ((event: E) => void) | undefined,
16
+ ): ((event: E) => void) | undefined {
17
+ if (!a) return b;
18
+ if (!b) return a;
19
+ return (event: E) => {
20
+ a(event);
21
+ b(event);
22
+ };
23
+ }
24
+
13
25
  export interface PressableHighlightProps extends PressableProps {
14
26
  ref?: Ref<View>;
15
27
  tooltip?: string;
16
28
  tooltipSide?: TooltipSide;
29
+ /**
30
+ * Web-only keyboard handler. `react-native-web`'s `Pressable` forwards this
31
+ * to the rendered DOM element; native platforms ignore it. Exposed
32
+ * explicitly because the base React Native `PressableProps` type omits it.
33
+ */
34
+ onKeyDown?: (event: { key: string; preventDefault?: () => void }) => void;
17
35
  }
18
36
 
19
37
  /**
@@ -63,7 +81,13 @@ export function PressableHighlight(props: PressableHighlightProps) {
63
81
  }}
64
82
  onPress={handlePress}
65
83
  {...restPressableProps}
84
+ // `onFocus` / `onBlur` are part of `PressableProps`, so a trailing
85
+ // `{...tooltipProps}` spread would silently overwrite caller-provided
86
+ // handlers. Compose instead. Mouse handlers (not in the RN types but
87
+ // passed through on web) come along with the tooltipProps spread.
66
88
  {...tooltipProps}
89
+ onFocus={composeHandler(restPressableProps.onFocus ?? undefined, tooltipProps.onFocus)}
90
+ onBlur={composeHandler(restPressableProps.onBlur ?? undefined, tooltipProps.onBlur)}
67
91
  >
68
92
  {(state) => {
69
93
  const hovered = (state as { hovered?: boolean }).hovered;
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
@@ -14,25 +14,72 @@ export interface RadioPickerOption<T extends string | number | symbol = string>
14
14
  }
15
15
 
16
16
  export interface RadioPickerProps<T extends string | number | symbol> {
17
+ /**
18
+ * Accessible name for the group. Announced by screen readers before
19
+ * stepping into the individual options.
20
+ */
21
+ accessibilityLabel: string;
17
22
  options: RadioPickerOption<T>[];
18
23
  value: T;
19
24
  onValueChange: (value: T) => void;
20
25
  }
21
26
 
22
27
  export function RadioPicker<T extends string | number | symbol>(props: RadioPickerProps<T>) {
23
- const { options, value, onValueChange } = props;
28
+ const { accessibilityLabel, options, value, onValueChange } = props;
29
+ const itemRefs = useRef<Array<View | null>>([]);
30
+
31
+ // Roving tabindex: arrow keys move focus between options and select, matching
32
+ // the WAI-ARIA Radio Group pattern. Home/End jump to the ends.
33
+ const handleKeyDown = useCallback(
34
+ (event: { key: string; preventDefault?: () => void }, index: number) => {
35
+ const last = options.length - 1;
36
+ let next = index;
37
+ switch (event.key) {
38
+ case "ArrowDown":
39
+ case "ArrowRight":
40
+ next = index === last ? 0 : index + 1;
41
+ break;
42
+ case "ArrowUp":
43
+ case "ArrowLeft":
44
+ next = index === 0 ? last : index - 1;
45
+ break;
46
+ case "Home":
47
+ next = 0;
48
+ break;
49
+ case "End":
50
+ next = last;
51
+ break;
52
+ default:
53
+ return;
54
+ }
55
+ event.preventDefault?.();
56
+ onValueChange(options[next].value);
57
+ itemRefs.current[next]?.focus();
58
+ },
59
+ [onValueChange, options],
60
+ );
61
+
62
+ // When no option matches the current value, the first option becomes the
63
+ // tab-stop so the group stays keyboard-reachable. ARIA Radio Group spec.
64
+ const selectedIndex = options.findIndex((option) => option.value === value);
65
+ const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
24
66
 
25
67
  return (
26
- <View>
27
- {options.map((option) => (
68
+ <View accessibilityRole="radiogroup" accessibilityLabel={accessibilityLabel}>
69
+ {options.map((option, index) => (
28
70
  <RadioOption
71
+ ref={(node: View | null) => {
72
+ itemRefs.current[index] = node;
73
+ }}
29
74
  key={option.value.toString()}
30
75
  label={option.label}
31
76
  value={option.value}
32
77
  description={option.description}
33
78
  testID={option.testID}
34
79
  selected={value === option.value}
80
+ isTabStop={index === tabStopIndex}
35
81
  onSelect={() => onValueChange(option.value)}
82
+ onKeyDown={(event) => handleKeyDown(event, index)}
36
83
  />
37
84
  ))}
38
85
  </View>
@@ -41,11 +88,14 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
41
88
 
42
89
  function RadioOption<T extends string | number | symbol>(
43
90
  props: RadioPickerOption<T> & {
91
+ ref: (node: View | null) => void;
44
92
  selected: boolean;
93
+ isTabStop: boolean;
45
94
  onSelect: () => void;
95
+ onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
46
96
  },
47
97
  ) {
48
- const { label, description, selected, onSelect, value, testID } = props;
98
+ const { ref, label, description, selected, isTabStop, onSelect, value, testID, onKeyDown } = props;
49
99
 
50
100
  const handlePress = useCallback(() => {
51
101
  onSelect();
@@ -53,6 +103,8 @@ function RadioOption<T extends string | number | symbol>(
53
103
 
54
104
  return (
55
105
  <PressableHighlight
106
+ ref={ref}
107
+ testID={testID}
56
108
  style={{
57
109
  flexDirection: "row",
58
110
  alignItems: "center",
@@ -61,6 +113,12 @@ function RadioOption<T extends string | number | symbol>(
61
113
  gap: 16,
62
114
  }}
63
115
  onPress={handlePress}
116
+ accessibilityRole="radio"
117
+ accessibilityLabel={description ? `${label}, ${description}` : label}
118
+ accessibilityState={{ checked: selected }}
119
+ // Roving tabindex: exactly one radio is the tab-stop. Arrow keys cycle.
120
+ focusable={isTabStop}
121
+ onKeyDown={onKeyDown}
64
122
  >
65
123
  <View
66
124
  style={{
@@ -1,5 +1,5 @@
1
1
  import { View } from "react-native";
2
- import { Text } from "./text";
2
+ import { Text, HeadingLevel } from "./text";
3
3
  import { Icon, IconName } from "./icon";
4
4
 
5
5
  export interface SectionHeadingProps {
@@ -7,10 +7,12 @@ export interface SectionHeadingProps {
7
7
  title: string;
8
8
  description?: string;
9
9
  right?: React.ReactNode;
10
+ /** Heading rank. Defaults to 2 — typical for page-level section titles. */
11
+ level?: HeadingLevel;
10
12
  }
11
13
 
12
14
  export function SectionHeading(props: SectionHeadingProps) {
13
- const { icon, title, description, right } = props;
15
+ const { icon, title, description, right, level = 2 } = props;
14
16
 
15
17
  return (
16
18
  <View
@@ -23,7 +25,7 @@ export function SectionHeading(props: SectionHeadingProps) {
23
25
  <View style={{ flex: 1 }}>
24
26
  <View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
25
27
  {icon && <Icon name={icon} size={18} />}
26
- <Text weight="medium" size="md">
28
+ <Text level={level} weight="medium" size="md">
27
29
  {title}
28
30
  </Text>
29
31
  </View>
@@ -0,0 +1,46 @@
1
+ import { Platform } from "react-native";
2
+ import { colors } from "./colors";
3
+
4
+ export interface SkipLinkProps {
5
+ /** DOM id of the main region to jump to, e.g. `"main-content"`. */
6
+ targetId: string;
7
+ /** Visible label. Localize. */
8
+ label: string;
9
+ }
10
+
11
+ /**
12
+ * Keyboard-first link that lets assistive tech and keyboard users bypass
13
+ * repeated navigation and land directly in the main region. Hidden visually
14
+ * until focused. Web-only; returns null on native where there is no tab
15
+ * traversal through a persistent nav.
16
+ */
17
+ export function SkipLink(props: SkipLinkProps) {
18
+ const { targetId, label } = props;
19
+ if (Platform.OS !== "web") return null;
20
+ return (
21
+ <a
22
+ href={`#${targetId}`}
23
+ style={{
24
+ position: "absolute",
25
+ top: 0,
26
+ left: 0,
27
+ padding: "8px 16px",
28
+ backgroundColor: colors.zinc["900"],
29
+ color: colors.white,
30
+ textDecoration: "none",
31
+ borderRadius: 4,
32
+ zIndex: 10001,
33
+ transform: "translateY(-200%)",
34
+ transition: "transform 0.15s ease",
35
+ }}
36
+ onFocus={(e) => {
37
+ e.currentTarget.style.transform = "translateY(8px)";
38
+ }}
39
+ onBlur={(e) => {
40
+ e.currentTarget.style.transform = "translateY(-200%)";
41
+ }}
42
+ >
43
+ {label}
44
+ </a>
45
+ );
46
+ }
package/src/switch.tsx CHANGED
@@ -4,13 +4,18 @@ import { colors } from "./colors";
4
4
  import { Icon } from "./icon";
5
5
  export interface SwitchProps {
6
6
  testID?: string;
7
+ /**
8
+ * Accessible name. Required on interactive switches (those with `onChange`);
9
+ * optional when the switch is rendered as a read-only indicator.
10
+ */
11
+ accessibilityLabel?: string;
7
12
  value?: boolean | null;
8
13
  disabled?: boolean;
9
14
  onChange?: (value: boolean) => void;
10
15
  }
11
16
 
12
17
  export function Switch(props: SwitchProps) {
13
- const { testID, value, disabled, onChange } = props;
18
+ const { testID, accessibilityLabel, value, disabled, onChange } = props;
14
19
  const checked = useRef(new Animated.Value(value ? 1 : 0)).current;
15
20
 
16
21
  const handlePress = useCallback(() => {
@@ -69,6 +74,9 @@ export function Switch(props: SwitchProps) {
69
74
  onPress={handlePress}
70
75
  style={styles.root}
71
76
  disabled={disabled}
77
+ accessibilityRole="switch"
78
+ accessibilityLabel={accessibilityLabel}
79
+ accessibilityState={{ checked: !!value, disabled: !!disabled }}
72
80
  >
73
81
  {content}
74
82
  </Pressable>
@@ -26,6 +26,9 @@ export function SwitchButton(props: SwitchButtonProps) {
26
26
  }}
27
27
  onPress={() => onChange?.(!value)}
28
28
  tooltip={tooltip}
29
+ accessibilityRole="switch"
30
+ accessibilityLabel={title}
31
+ accessibilityState={{ checked: !!value }}
29
32
  >
30
33
  <View style={{ flexDirection: "row", gap: 8, alignItems: "center", flex: 1 }}>
31
34
  {!!icon && <Icon name={icon} size={20} />}
package/src/tabs.tsx CHANGED
@@ -1,8 +1,9 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { View } from "react-native";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
4
  import { Text } from "./text";
5
5
  import { colors } from "./colors";
6
+
6
7
  export interface TabOption<T extends string> {
7
8
  label: string;
8
9
  value: T;
@@ -10,22 +11,70 @@ export interface TabOption<T extends string> {
10
11
  }
11
12
 
12
13
  interface TabsProps<T extends string> {
14
+ /** Accessible name of the tablist. */
15
+ accessibilityLabel: string;
13
16
  options: TabOption<T>[];
14
17
  selectedTab: T;
15
18
  onSelectTab?: (value: T) => void;
16
19
  }
17
20
 
18
21
  export function Tabs<T extends string>(props: TabsProps<T>) {
19
- const { options, selectedTab, onSelectTab } = props;
22
+ const { accessibilityLabel, options, selectedTab, onSelectTab } = props;
23
+ const tabRefs = useRef<Array<View | null>>([]);
24
+
25
+ // Roving tabindex + arrow-key navigation per the WAI-ARIA Tabs pattern.
26
+ // Home/End jump to the ends; Left/Right cycle.
27
+ const handleKeyDown = useCallback(
28
+ (event: { key: string; preventDefault?: () => void }, index: number) => {
29
+ const last = options.length - 1;
30
+ let next = index;
31
+ switch (event.key) {
32
+ case "ArrowRight":
33
+ case "ArrowDown":
34
+ next = index === last ? 0 : index + 1;
35
+ break;
36
+ case "ArrowLeft":
37
+ case "ArrowUp":
38
+ next = index === 0 ? last : index - 1;
39
+ break;
40
+ case "Home":
41
+ next = 0;
42
+ break;
43
+ case "End":
44
+ next = last;
45
+ break;
46
+ default:
47
+ return;
48
+ }
49
+ event.preventDefault?.();
50
+ onSelectTab?.(options[next].value);
51
+ tabRefs.current[next]?.focus();
52
+ },
53
+ [onSelectTab, options],
54
+ );
55
+
56
+ // When no tab matches the current selection, the first tab becomes the
57
+ // tab-stop so keyboard users can still enter the group.
58
+ const selectedIndex = options.findIndex((option) => option.value === selectedTab);
59
+ const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
20
60
 
21
61
  return (
22
- <View style={{ flexDirection: "row", gap: 4 }}>
23
- {options.map((option) => (
62
+ <View
63
+ style={{ flexDirection: "row", gap: 4 }}
64
+ accessibilityRole="tablist"
65
+ accessibilityLabel={accessibilityLabel}
66
+ >
67
+ {options.map((option, index) => (
24
68
  <TabButton
69
+ ref={(node: View | null) => {
70
+ tabRefs.current[index] = node;
71
+ }}
25
72
  key={option.value}
26
73
  option={option}
27
74
  selected={selectedTab === option.value}
75
+ isTabStop={index === tabStopIndex}
28
76
  onSelectTab={onSelectTab}
77
+ onKeyDown={(event) => handleKeyDown(event, index)}
29
78
  />
30
79
  ))}
31
80
  </View>
@@ -33,13 +82,16 @@ export function Tabs<T extends string>(props: TabsProps<T>) {
33
82
  }
34
83
 
35
84
  interface TabButtonProps<T extends string> {
85
+ ref: (node: View | null) => void;
36
86
  option: TabOption<T>;
37
87
  selected: boolean;
88
+ isTabStop: boolean;
38
89
  onSelectTab?: (value: T) => void;
90
+ onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
39
91
  }
40
92
 
41
93
  function TabButton<T extends string>(props: TabButtonProps<T>) {
42
- const { option, selected, onSelectTab } = props;
94
+ const { ref, option, selected, isTabStop, onSelectTab, onKeyDown } = props;
43
95
 
44
96
  const handlePress = useCallback(() => {
45
97
  onSelectTab?.(option.value);
@@ -57,6 +109,7 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
57
109
 
58
110
  return (
59
111
  <PressableHighlight
112
+ ref={ref}
60
113
  style={{
61
114
  borderBottomWidth: 3,
62
115
  borderBottomColor: selected ? colors.zinc["700"] : "transparent",
@@ -64,26 +117,35 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
64
117
  paddingBottom: 4,
65
118
  }}
66
119
  onPress={handlePress}
120
+ onKeyDown={onKeyDown}
67
121
  testID={option.testID}
122
+ accessibilityRole="tab"
123
+ accessibilityLabel={option.label}
124
+ accessibilityState={{ selected }}
125
+ // Roving tabindex: the selected tab is the tab-stop, others are reachable
126
+ // via arrow keys. When no tab matches the current selection (props.value
127
+ // is stale), the first tab is the fallback so the group stays keyboard-
128
+ // reachable.
129
+ focusable={isTabStop}
68
130
  >
69
131
  {(state) => {
70
132
  const { pressed } = state;
71
133
  const hovered = (state as { hovered?: boolean }).hovered;
72
134
  return (
73
- <View
74
- style={{
75
- paddingVertical: 8,
76
- paddingHorizontal: 16,
77
- borderRadius: 8,
78
- backgroundColor: pressed
79
- ? colors.zinc["100"]
80
- : hovered
81
- ? colors.zinc["50"]
82
- : "transparent",
83
- }}
84
- >
85
- {inner}
86
- </View>
135
+ <View
136
+ style={{
137
+ paddingVertical: 8,
138
+ paddingHorizontal: 16,
139
+ borderRadius: 8,
140
+ backgroundColor: pressed
141
+ ? colors.zinc["100"]
142
+ : hovered
143
+ ? colors.zinc["50"]
144
+ : "transparent",
145
+ }}
146
+ >
147
+ {inner}
148
+ </View>
87
149
  );
88
150
  }}
89
151
  </PressableHighlight>