@lotics/ui 1.11.0 → 1.12.0

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/src/popover.tsx CHANGED
@@ -3,6 +3,7 @@ import React, {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useRef,
7
8
  useState,
8
9
  } from "react";
@@ -150,10 +151,14 @@ export function PopoverTrigger({ children }: PopoverTriggerProps) {
150
151
  children as React.ReactElement<{
151
152
  ref?: React.Ref<HTMLDivElement>;
152
153
  onPress?: () => void;
154
+ "aria-haspopup"?: "dialog" | "menu" | "listbox" | "true";
155
+ "aria-expanded"?: boolean;
153
156
  }>,
154
157
  {
155
158
  ref: triggerRef as React.Ref<HTMLDivElement>,
156
159
  onPress: handlePress,
160
+ "aria-haspopup": "dialog",
161
+ "aria-expanded": open,
157
162
  },
158
163
  );
159
164
  }
@@ -177,10 +182,20 @@ export interface PopoverContentProps {
177
182
  disableBodyScroll?: boolean;
178
183
  /** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
179
184
  small?: boolean;
185
+ /** Accessible name for the bottom-sheet close button. Default: "Close". Pass a translated string from the consumer. */
186
+ closeLabel?: string;
180
187
  }
181
188
 
182
189
  export function PopoverContent(props: PopoverContentProps) {
183
- const { testID, children, style, contentContainerStyle, disableBodyScroll, small = false } = props;
190
+ const {
191
+ testID,
192
+ children,
193
+ style,
194
+ contentContainerStyle,
195
+ disableBodyScroll,
196
+ small = false,
197
+ closeLabel = "Close",
198
+ } = props;
184
199
  const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
185
200
  usePopoverContext();
186
201
 
@@ -188,12 +203,58 @@ export function PopoverContent(props: PopoverContentProps) {
188
203
  const [position, setPosition] = useState<Position | null>(null);
189
204
  const [triggerWidth, setTriggerWidth] = useState<number>(0);
190
205
  const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
206
+ const returnFocusRef = useRef<HTMLElement | null>(null);
207
+ // Last known trigger geometry. The trigger can unmount while the popover is
208
+ // open — a hover-revealed menu button stops being rendered the moment our
209
+ // overlay covers its row and the hover ends — so positioning cannot rely on
210
+ // `triggerRef.current` still being live when it runs.
211
+ const triggerRectRef = useRef<DOMRect | null>(null);
191
212
 
192
213
  const handleClose = useCallback(() => {
193
214
  if (!open) return;
194
215
  onOpenChange(false);
195
216
  }, [onOpenChange, open]);
196
217
 
218
+ // Snapshot the trigger geometry synchronously the moment we open, while it is
219
+ // guaranteed to still be mounted. `calculatePosition` runs later in a rAF, by
220
+ // which point a hover-revealed trigger may already be gone (our overlay covers
221
+ // its row, hover ends, the button unmounts). Capturing here keeps the popover
222
+ // anchored to where the trigger was instead of stranding it off-screen.
223
+ useLayoutEffect(() => {
224
+ if (!open || small) return;
225
+ if (triggerRef.current) {
226
+ triggerRectRef.current = triggerRef.current.getBoundingClientRect();
227
+ }
228
+ }, [open, small, triggerRef]);
229
+
230
+ // Focus management: when the popover opens, remember what had focus and move
231
+ // focus into the popover (the content div is tab-able via `tabIndex=-1`).
232
+ // When it closes, restore focus to the prior element so keyboard users don't
233
+ // land at the top of the page.
234
+ useEffect(() => {
235
+ if (!open) return;
236
+ const previouslyFocused = document.activeElement as HTMLElement | null;
237
+ returnFocusRef.current = previouslyFocused;
238
+
239
+ const focusFrame = requestAnimationFrame(() => {
240
+ const content = popoverRef.current;
241
+ if (!content) return;
242
+ const firstFocusable = content.querySelector<HTMLElement>(
243
+ 'input, select, textarea, button, [role="button"], [role="menuitem"], [role="option"], [tabindex]:not([tabindex="-1"])',
244
+ );
245
+ (firstFocusable ?? content).focus();
246
+ });
247
+
248
+ return () => {
249
+ cancelAnimationFrame(focusFrame);
250
+ const target = returnFocusRef.current;
251
+ returnFocusRef.current = null;
252
+ if (target && typeof target.focus === "function") {
253
+ target.focus();
254
+ }
255
+ };
256
+ }, [open]);
257
+
197
258
  // Separate header, footer, and body from children
198
259
  let header: React.ReactNode = null;
199
260
  let footer: React.ReactNode = null;
@@ -237,10 +298,17 @@ export function PopoverContent(props: PopoverContentProps) {
237
298
  }, [open, small, onOpenChange]);
238
299
 
239
300
  const calculatePosition = useCallback(() => {
240
- if (!triggerRef.current || !popoverRef.current) return;
301
+ if (!popoverRef.current) return;
241
302
  if (small) return;
242
303
 
243
- const triggerRect = triggerRef.current.getBoundingClientRect();
304
+ // Prefer the live trigger when it is still mounted (so the popover tracks
305
+ // scroll/layout); fall back to the rect captured at open time when the
306
+ // trigger has since unmounted, instead of stranding the popover off-screen.
307
+ const liveTriggerRect = triggerRef.current?.getBoundingClientRect();
308
+ if (liveTriggerRect) triggerRectRef.current = liveTriggerRect;
309
+ const triggerRect = triggerRectRef.current;
310
+ if (!triggerRect) return;
311
+
244
312
  const popoverRect = popoverRef.current.getBoundingClientRect();
245
313
  const viewportWidth = window.innerWidth;
246
314
  const viewportHeight = window.innerHeight;
@@ -360,6 +428,7 @@ export function PopoverContent(props: PopoverContentProps) {
360
428
  if (!open) {
361
429
  setPosition((previous) => (previous === null ? previous : null));
362
430
  setIsBottomSheetShown((previous) => (previous ? false : previous));
431
+ triggerRectRef.current = null;
363
432
  return;
364
433
  }
365
434
 
@@ -438,6 +507,8 @@ export function PopoverContent(props: PopoverContentProps) {
438
507
  ref={popoverRef}
439
508
  data-popover="true"
440
509
  data-testid={testID}
510
+ role="dialog"
511
+ aria-modal={small ? true : undefined}
441
512
  tabIndex={small ? undefined : -1}
442
513
  style={{
443
514
  position: "fixed",
@@ -497,7 +568,7 @@ export function PopoverContent(props: PopoverContentProps) {
497
568
  justifyContent: "flex-end",
498
569
  }}
499
570
  >
500
- <IconButton icon="x" onPress={handleClose} />
571
+ <IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
501
572
  </View>
502
573
  )}
503
574
  {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>
package/src/text.tsx CHANGED
@@ -22,8 +22,25 @@ export interface TextProps {
22
22
  decoration?: TextDecorationLine;
23
23
  style?: RNTextProps["style"];
24
24
  onPress?: () => void;
25
+ /** ID used to target this element from `accessibilityLabelledBy` / `aria-describedby`. */
26
+ nativeID?: string;
27
+ accessibilityRole?: RNTextProps["accessibilityRole"];
28
+ accessibilityLabel?: string;
29
+ accessibilityElementsHidden?: boolean;
30
+ importantForAccessibility?: RNTextProps["importantForAccessibility"];
31
+ /**
32
+ * Renders as a heading of this rank (1–6): exposes `accessibilityRole="header"`
33
+ * and `aria-level`, giving the page a real heading outline. Appearance still
34
+ * comes from `size`/`weight` — `level` is the semantic rank only. Use one
35
+ * level-1 per page.
36
+ */
37
+ level?: HeadingLevel;
38
+ "aria-live"?: "off" | "polite" | "assertive";
39
+ "aria-hidden"?: boolean;
25
40
  }
26
41
 
42
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
43
+
27
44
  type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
28
45
  type TextAlign = "left" | "right" | "center";
29
46
  type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
@@ -48,6 +65,14 @@ export function Text(props: TextProps) {
48
65
  transform,
49
66
  onPress,
50
67
  style,
68
+ nativeID,
69
+ accessibilityRole,
70
+ accessibilityLabel,
71
+ accessibilityElementsHidden,
72
+ importantForAccessibility,
73
+ level,
74
+ "aria-live": ariaLive,
75
+ "aria-hidden": ariaHidden,
51
76
  } = props;
52
77
 
53
78
  // On web, responsive font sizes are applied via CSS using data-text-size attribute.
@@ -57,6 +82,14 @@ export function Text(props: TextProps) {
57
82
  return (
58
83
  <RNText
59
84
  testID={testID}
85
+ nativeID={nativeID}
86
+ accessibilityRole={level != null ? "header" : accessibilityRole}
87
+ accessibilityLabel={accessibilityLabel}
88
+ accessibilityElementsHidden={accessibilityElementsHidden}
89
+ importantForAccessibility={importantForAccessibility}
90
+ aria-level={level}
91
+ aria-live={ariaLive}
92
+ aria-hidden={ariaHidden}
60
93
  {...webProps}
61
94
  style={[
62
95
  { color: getTextColor(color) },