@lotics/ui 1.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -118,6 +118,8 @@
118
118
  "./command_menu": "./src/command_menu.tsx",
119
119
  "./switch_button": "./src/switch_button.tsx",
120
120
  "./alert": "./src/alert.tsx",
121
+ "./landmark": "./src/landmark.tsx",
122
+ "./skip_link": "./src/skip_link.tsx",
121
123
  "./text_utils": "./src/text_utils.ts",
122
124
  "./cell_text": "./src/cell_text.tsx",
123
125
  "./cell_number": "./src/cell_number.tsx",
@@ -133,7 +135,9 @@
133
135
  "./grid/data_grid_context": "./src/grid/data_grid_context.ts",
134
136
  "./grid/search_highlight": "./src/grid/search_highlight.ts"
135
137
  },
136
- "files": ["src"],
138
+ "files": [
139
+ "src"
140
+ ],
137
141
  "publishConfig": {
138
142
  "access": "public"
139
143
  },
@@ -156,11 +160,21 @@
156
160
  "recharts": ">=3.0.0"
157
161
  },
158
162
  "peerDependenciesMeta": {
159
- "expo-image": { "optional": true },
160
- "@react-native-picker/picker": { "optional": true },
161
- "lucide-react-native": { "optional": true },
162
- "react-native-svg": { "optional": true },
163
- "recharts": { "optional": true }
163
+ "expo-image": {
164
+ "optional": true
165
+ },
166
+ "@react-native-picker/picker": {
167
+ "optional": true
168
+ },
169
+ "lucide-react-native": {
170
+ "optional": true
171
+ },
172
+ "react-native-svg": {
173
+ "optional": true
174
+ },
175
+ "recharts": {
176
+ "optional": true
177
+ }
164
178
  },
165
179
  "scripts": {
166
180
  "typecheck": "tsgo --noEmit",
package/src/alert.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState, useCallback } from "react";
1
+ import React, { useEffect, useId, useRef, useState, useCallback } from "react";
2
2
  import { createRoot, Root } from "react-dom/client";
3
3
  import "./alert.css";
4
4
  import { Text } from "./text";
@@ -81,6 +81,11 @@ class Alert {
81
81
 
82
82
  const AlertComponent = () => {
83
83
  const [visible, setVisible] = useState(true);
84
+ const dialogId = useId();
85
+ const titleId = `${dialogId}-title`;
86
+ const messageId = `${dialogId}-message`;
87
+ const dialogRef = useRef<HTMLDivElement>(null);
88
+ const returnFocusRef = useRef<HTMLElement | null>(null);
84
89
 
85
90
  useOverlayScope(visible);
86
91
 
@@ -106,6 +111,29 @@ class Alert {
106
111
  }
107
112
  }, [visible]);
108
113
 
114
+ // Focus management: remember what had focus so it can be restored when
115
+ // the dialog dismisses. Move focus to the preferred button on mount so
116
+ // keyboard users do not land outside the dialog.
117
+ useEffect(() => {
118
+ returnFocusRef.current = document.activeElement as HTMLElement | null;
119
+ const frame = requestAnimationFrame(() => {
120
+ // The primary/positive button is conventionally rightmost in the
121
+ // row, so default focus lands there. Users can Shift-Tab to the
122
+ // cancel/neutral buttons if they need them.
123
+ const buttons = dialogRef.current?.querySelectorAll<HTMLElement>(
124
+ ".lotics-alert-buttons [role='button']",
125
+ );
126
+ const preferred = buttons ? buttons[buttons.length - 1] : undefined;
127
+ preferred?.focus();
128
+ });
129
+ return () => {
130
+ cancelAnimationFrame(frame);
131
+ const target = returnFocusRef.current;
132
+ returnFocusRef.current = null;
133
+ if (target && typeof target.focus === "function") target.focus();
134
+ };
135
+ }, []);
136
+
109
137
  useEffect(() => {
110
138
  const handleEscape = (e: KeyboardEvent) => {
111
139
  if (e.key === "Escape" && alertOptions.cancelable !== false) {
@@ -124,19 +152,21 @@ class Alert {
124
152
  return (
125
153
  <div className="lotics-alert-overlay" onClick={handleDismiss}>
126
154
  <div
155
+ ref={dialogRef}
127
156
  className="lotics-alert-dialog"
128
157
  onClick={(e) => e.stopPropagation()}
129
158
  role="alertdialog"
130
- aria-labelledby={title && title.trim() ? "alert-title" : undefined}
131
- aria-describedby={message ? "alert-message" : undefined}
159
+ aria-modal="true"
160
+ aria-labelledby={title && title.trim() ? titleId : undefined}
161
+ aria-describedby={message ? messageId : undefined}
132
162
  >
133
163
  {title && title.trim() && (
134
- <Text size="md" weight="medium">
164
+ <Text nativeID={titleId} size="md" weight="medium">
135
165
  {title}
136
166
  </Text>
137
167
  )}
138
168
  {message && (
139
- <Text size="sm" color="zinc-500">
169
+ <Text nativeID={messageId} size="sm" color="zinc-500">
140
170
  {message}
141
171
  </Text>
142
172
  )}
package/src/avatar.tsx CHANGED
@@ -10,21 +10,44 @@ interface AvatarProps {
10
10
  name?: string;
11
11
  style?: StyleProp<ViewStyle | ImageStyle>;
12
12
  contentFit?: ImageContentFit;
13
+ /**
14
+ * When true, the avatar announces its `name` to assistive tech. Default
15
+ * false because avatars almost always appear adjacent to the name text —
16
+ * announcing the image as well would double-read. Pass `announce` when the
17
+ * avatar is standalone (with no visible name nearby).
18
+ */
19
+ announce?: boolean;
13
20
  }
14
21
 
15
22
  export function Avatar(props: AvatarProps) {
16
- const { source, size = 32, name = "Unknown", style, contentFit } = props;
23
+ const { source, size = 32, name = "Unknown", style, contentFit, announce } = props;
24
+ const decorative = !announce;
17
25
 
18
26
  if (!source || !source.uri) {
19
27
  return (
20
28
  <View
29
+ accessible={!decorative}
30
+ accessibilityLabel={decorative ? undefined : name}
31
+ accessibilityElementsHidden={decorative}
32
+ importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
33
+ aria-hidden={decorative || undefined}
21
34
  style={[
22
35
  styles.base,
23
36
  { backgroundColor: colors.blue["600"], width: size, height: size },
24
37
  style,
25
38
  ]}
26
39
  >
27
- <Text userSelect="none" size="xs" weight="medium" color="inverted">
40
+ {/* Initials are a visual shorthand for the name; the accessible name is
41
+ on the container so the SR does not read "HM" in addition. */}
42
+ <Text
43
+ userSelect="none"
44
+ size="xs"
45
+ weight="medium"
46
+ color="inverted"
47
+ accessibilityElementsHidden
48
+ importantForAccessibility="no-hide-descendants"
49
+ aria-hidden
50
+ >
28
51
  {getInitials(name, size)}
29
52
  </Text>
30
53
  </View>
@@ -33,7 +56,9 @@ export function Avatar(props: AvatarProps) {
33
56
 
34
57
  return (
35
58
  <Image
36
- alt={name}
59
+ alt={decorative ? "" : name}
60
+ accessibilityElementsHidden={decorative}
61
+ importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
37
62
  style={[styles.base, { width: size, height: size }, style as ImageStyle]}
38
63
  source={source}
39
64
  contentFit={contentFit}
@@ -1,18 +1,20 @@
1
1
  import { Button } from "./button";
2
- import { Text } from "./text";
3
2
  import { View } from "react-native";
4
3
 
5
4
  interface BackButtonProps {
6
5
  onPress: () => void;
6
+ /** Accessible name. Default: "Back". Pass a translated string from the consumer. */
7
+ accessibilityLabel?: string;
7
8
  }
8
9
 
9
10
  export function BackButton(props: BackButtonProps) {
10
- const { onPress } = props;
11
+ const { onPress, accessibilityLabel = "Back" } = props;
11
12
  return (
12
13
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
13
14
  <Button
14
15
  icon="chevron-left"
15
16
  color="secondary"
17
+ accessibilityLabel={accessibilityLabel}
16
18
  onPress={onPress}
17
19
  style={{ alignSelf: "flex-start" }}
18
20
  />
package/src/button.tsx CHANGED
@@ -16,10 +16,9 @@ import { useTooltip, UseTooltipOptions } from "./tooltip";
16
16
  export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
17
17
  export type ButtonIconPosition = "left" | "right";
18
18
 
19
- export interface ButtonProps {
19
+ interface ButtonPropsBase {
20
20
  ref?: Ref<View>;
21
21
  testID?: string;
22
- title?: string | boolean;
23
22
  icon?: IconName;
24
23
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
25
24
  color?: ButtonColor;
@@ -30,9 +29,34 @@ export interface ButtonProps {
30
29
  style?: StyleProp<ViewStyle>;
31
30
  }
32
31
 
32
+ /**
33
+ * A button must have an accessible name. The compiler enforces it by
34
+ * requiring at least one of: a string `title`, an explicit
35
+ * `accessibilityLabel`, or a `tooltip` that carries a visible name. The
36
+ * runtime derives `accessibilityLabel` in that precedence order.
37
+ */
38
+ export type ButtonProps = ButtonPropsBase &
39
+ (
40
+ | { title: string; accessibilityLabel?: string; tooltip?: string | UseTooltipOptions }
41
+ | { title?: boolean | undefined; accessibilityLabel: string; tooltip?: string | UseTooltipOptions }
42
+ | { title?: boolean | undefined; accessibilityLabel?: string; tooltip: string | UseTooltipOptions }
43
+ );
44
+
33
45
  export function Button(props: ButtonProps) {
34
- const { ref, icon, alignSelf, title, color, style, disabled, tooltip, loading, onPress, testID } =
35
- props;
46
+ const {
47
+ ref,
48
+ icon,
49
+ alignSelf,
50
+ title,
51
+ color,
52
+ style,
53
+ disabled,
54
+ tooltip,
55
+ loading,
56
+ onPress,
57
+ testID,
58
+ accessibilityLabel,
59
+ } = props;
36
60
 
37
61
  const disabledOrLoading = disabled || loading;
38
62
  const tooltipProps = useTooltip(tooltip);
@@ -74,7 +98,13 @@ export function Button(props: ButtonProps) {
74
98
  ref={ref}
75
99
  testID={testID}
76
100
  accessibilityRole="button"
77
- accessibilityState={{ disabled: disabledOrLoading }}
101
+ accessibilityLabel={
102
+ accessibilityLabel ||
103
+ (typeof title === "string" ? title : "") ||
104
+ (typeof tooltip === "string" ? tooltip : tooltip?.text) ||
105
+ undefined
106
+ }
107
+ accessibilityState={{ disabled: disabledOrLoading, busy: loading }}
78
108
  disabled={disabledOrLoading}
79
109
  // @ts-ignore hovered is a react-native-web extension not in base RN types
80
110
  style={({ pressed, hovered }) => {
@@ -5,9 +5,9 @@ import { colors } from "../colors";
5
5
  import { MonthView } from "./month_view";
6
6
  import { TimeGridView } from "./time_grid_view";
7
7
  import { addDays, addMonths, viewTitle } from "./dates";
8
- import type { CalendarEvent, CalendarViewMode, Weekday } from "./types";
8
+ import { DEFAULT_CALENDAR_LABELS } from "./types";
9
+ import type { CalendarEvent, CalendarLabels, CalendarViewMode, Weekday } from "./types";
9
10
 
10
- const VIEW_LABELS: Record<CalendarViewMode, string> = { month: "Tháng", week: "Tuần", day: "Ngày" };
11
11
  const VIEW_ORDER: CalendarViewMode[] = ["month", "week", "day"];
12
12
 
13
13
  export interface CalendarViewProps<T = unknown> {
@@ -16,6 +16,8 @@ export interface CalendarViewProps<T = unknown> {
16
16
  defaultDate?: Date;
17
17
  weekStartsOn?: Weekday;
18
18
  locale?: string;
19
+ /** User-facing chrome strings; defaults to English. */
20
+ labels?: Partial<CalendarLabels>;
19
21
  onEventPress?: (event: CalendarEvent<T>) => void;
20
22
  onDayPress?: (day: Date) => void;
21
23
  }
@@ -27,6 +29,7 @@ export interface CalendarViewProps<T = unknown> {
27
29
  */
28
30
  export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
29
31
  const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
32
+ const L = { ...DEFAULT_CALENDAR_LABELS, ...props.labels };
30
33
  const [view, setView] = useState<CalendarViewMode>(defaultView);
31
34
  const [date, setDate] = useState<Date>(defaultDate ?? new Date());
32
35
 
@@ -44,13 +47,13 @@ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
44
47
  <View style={styles.root}>
45
48
  <View style={styles.toolbar}>
46
49
  <View style={styles.navGroup}>
47
- <Pressable onPress={() => step(-1)} accessibilityRole="button" accessibilityLabel="Trước" style={styles.iconBtn}>
50
+ <Pressable onPress={() => step(-1)} accessibilityRole="button" accessibilityLabel={L.previous} style={styles.iconBtn}>
48
51
  <Text size="lg" color="muted">‹</Text>
49
52
  </Pressable>
50
53
  <Pressable onPress={() => setDate(new Date())} accessibilityRole="button" style={styles.todayBtn}>
51
- <Text size="sm" weight="medium">Hôm nay</Text>
54
+ <Text size="sm" weight="medium">{L.today}</Text>
52
55
  </Pressable>
53
- <Pressable onPress={() => step(1)} accessibilityRole="button" accessibilityLabel="Sau" style={styles.iconBtn}>
56
+ <Pressable onPress={() => step(1)} accessibilityRole="button" accessibilityLabel={L.next} style={styles.iconBtn}>
54
57
  <Text size="lg" color="muted">›</Text>
55
58
  </Pressable>
56
59
  </View>
@@ -68,7 +71,7 @@ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
68
71
  style={[styles.viewBtn, view === v && styles.viewBtnActive]}
69
72
  >
70
73
  <Text size="sm" weight={view === v ? "medium" : "regular"} color={view === v ? "default" : "muted"}>
71
- {VIEW_LABELS[v]}
74
+ {L[v]}
72
75
  </Text>
73
76
  </Pressable>
74
77
  ))}
@@ -82,6 +85,7 @@ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
82
85
  events={events}
83
86
  weekStartsOn={weekStartsOn}
84
87
  locale={locale}
88
+ moreLabel={L.more}
85
89
  onEventPress={onEventPress}
86
90
  onDayPress={drillToDay}
87
91
  />
@@ -92,6 +96,7 @@ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
92
96
  events={events}
93
97
  weekStartsOn={weekStartsOn}
94
98
  locale={locale}
99
+ allDayLabel={L.allDay}
95
100
  onEventPress={onEventPress}
96
101
  />
97
102
  )}
@@ -6,7 +6,8 @@ export { MonthView } from "./month_view";
6
6
  export type { MonthViewProps } from "./month_view";
7
7
  export { layoutDayColumns, packEventLanes } from "./layout";
8
8
  export type { LaneBar } from "./layout";
9
- export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn } from "./types";
9
+ export { DEFAULT_CALENDAR_LABELS } from "./types";
10
+ export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn, CalendarLabels } from "./types";
10
11
  export {
11
12
  addDays,
12
13
  addMonths,
@@ -15,12 +15,14 @@ export interface MonthViewProps<T = unknown> {
15
15
  events: CalendarEvent<T>[];
16
16
  weekStartsOn?: Weekday;
17
17
  locale?: string;
18
+ /** Overflow chip label, e.g. (3) => "+3 more". Defaults to English. */
19
+ moreLabel?: (count: number) => string;
18
20
  onEventPress?: (event: CalendarEvent<T>) => void;
19
21
  onDayPress?: (day: Date) => void;
20
22
  }
21
23
 
22
24
  export function MonthView<T = unknown>(props: MonthViewProps<T>) {
23
- const { date, events, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
25
+ const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress } = props;
24
26
  const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
25
27
  const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
26
28
  const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
@@ -133,7 +135,7 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
133
135
  onPress={onDayPress ? () => onDayPress(day) : undefined}
134
136
  style={{ position: "absolute", top: MAX_LANES * (LANE_H + LANE_GAP), left: `${(col / 7) * 100}%`, width: `${100 / 7}%`, paddingHorizontal: 6 }}
135
137
  >
136
- <Text size="xs" color="muted">+{overflow[col]} nữa</Text>
138
+ <Text size="xs" color="muted">{moreLabel(overflow[col])}</Text>
137
139
  </Pressable>
138
140
  ) : null,
139
141
  )}
@@ -31,6 +31,8 @@ export interface TimeGridViewProps<T = unknown> {
31
31
  locale?: string;
32
32
  /** Hour to scroll to on mount. Default: an hour before now (clamped). */
33
33
  scrollToHour?: number;
34
+ /** All-day lane label; defaults to English ("all-day"). */
35
+ allDayLabel?: string;
34
36
  onEventPress?: (event: CalendarEvent<T>) => void;
35
37
  }
36
38
 
@@ -161,7 +163,7 @@ export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
161
163
  <View style={[styles.allDayRow, { height: allDayPacked.lanes * (ALLDAY_LANE_H + 2) + 6 }]}>
162
164
  <View style={{ width: GUTTER, justifyContent: "center" }}>
163
165
  <Text size="xs" style={{ color: colors.zinc[400], textAlign: "right", paddingRight: 6 }}>
164
- cả ngày
166
+ {props.allDayLabel ?? "all-day"}
165
167
  </Text>
166
168
  </View>
167
169
  <View style={{ flex: 1 }}>
@@ -19,6 +19,35 @@ export interface CalendarEvent<T = unknown> {
19
19
 
20
20
  export type CalendarViewMode = "month" | "week" | "day";
21
21
 
22
+ /**
23
+ * User-facing chrome strings. Primitives default to English and take these as a
24
+ * prop — i18n is the consumer's responsibility (see the @lotics/ui convention in
25
+ * grid/data_grid.tsx). Dates/weekday/month names come from `locale` via Intl and
26
+ * are not part of this set.
27
+ */
28
+ export interface CalendarLabels {
29
+ today: string;
30
+ month: string;
31
+ week: string;
32
+ day: string;
33
+ previous: string;
34
+ next: string;
35
+ allDay: string;
36
+ /** Month-cell overflow chip, e.g. (3) => "+3 more". */
37
+ more: (count: number) => string;
38
+ }
39
+
40
+ export const DEFAULT_CALENDAR_LABELS: CalendarLabels = {
41
+ today: "Today",
42
+ month: "Month",
43
+ week: "Week",
44
+ day: "Day",
45
+ previous: "Previous",
46
+ next: "Next",
47
+ allDay: "all-day",
48
+ more: (n) => `+${n} more`,
49
+ };
50
+
22
51
  /** 0 = Sunday … 6 = Saturday. */
23
52
  export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
24
53
 
@@ -2,6 +2,11 @@ import { useCallback } from "react";
2
2
  import { Pressable } from "react-native";
3
3
  import { Checkbox } from "./checkbox";
4
4
  interface CheckboxInputProps {
5
+ /**
6
+ * Accessible name. Required because an unlabelled checkbox only announces
7
+ * its state, leaving the user with no idea what is being checked.
8
+ */
9
+ accessibilityLabel: string;
5
10
  checked: boolean;
6
11
  onChange?: (checked: boolean) => void;
7
12
  disabled?: boolean;
@@ -10,7 +15,7 @@ interface CheckboxInputProps {
10
15
  }
11
16
 
12
17
  export function CheckboxInput(props: CheckboxInputProps) {
13
- const { indeterminate, checked, onChange, disabled, testID } = props;
18
+ const { accessibilityLabel, indeterminate, checked, onChange, disabled, testID } = props;
14
19
 
15
20
  const handlePress = useCallback(() => {
16
21
  if (!disabled) {
@@ -24,8 +29,9 @@ export function CheckboxInput(props: CheckboxInputProps) {
24
29
  onPress={handlePress}
25
30
  disabled={disabled}
26
31
  style={{ opacity: disabled ? 0.5 : 1 }}
27
- role="checkbox"
28
- aria-checked={checked}
32
+ accessibilityRole="checkbox"
33
+ accessibilityLabel={accessibilityLabel}
34
+ accessibilityState={{ checked: indeterminate ? "mixed" : checked, disabled: !!disabled }}
29
35
  >
30
36
  <Checkbox checked={checked} indeterminate={indeterminate} />
31
37
  </Pressable>
@@ -1,7 +1,6 @@
1
1
  import { StyleSheet, View, ScrollView } from "react-native";
2
- import { useState, useCallback, useMemo, useRef } from "react";
2
+ import { useState, useCallback, useMemo, useRef, useId } from "react";
3
3
  import { colors } from "./colors";
4
- import { Icon } from "./icon";
5
4
  import { TextInputField } from "./text_input_field";
6
5
  import { MenuButton } from "./menu_button";
7
6
  import { Text } from "./text";
@@ -20,14 +19,27 @@ interface CommandMenuProps {
20
19
  enableSearch?: boolean;
21
20
  /** Search input placeholder. Default: "Search...". Pass a translated string from the consumer. */
22
21
  searchPlaceholder?: string;
22
+ /** Accessible name for the list. Default: "Options". Pass a translated string from the consumer. */
23
+ accessibilityLabel?: string;
23
24
  }
24
25
 
25
26
  export function CommandMenu(props: CommandMenuProps) {
26
- const { options, onSelect, onRequestClose, enableSearch, searchPlaceholder = "Search..." } = props;
27
+ const {
28
+ options,
29
+ onSelect,
30
+ onRequestClose,
31
+ enableSearch,
32
+ searchPlaceholder = "Search...",
33
+ accessibilityLabel,
34
+ } = props;
27
35
  const scrollViewRef = useRef<ScrollView>(null);
28
36
  const screenSize = useScreenSize();
29
37
  const OPTION_HEIGHT = 40;
30
38
 
39
+ const baseId = useId();
40
+ const listboxId = `${baseId}-listbox`;
41
+ const optionId = (index: number) => `${baseId}-option-${index}`;
42
+
31
43
  const [searchQuery, setSearchQuery] = useState("");
32
44
 
33
45
  const filteredOptions = useMemo(() => {
@@ -83,6 +95,16 @@ export function CommandMenu(props: CommandMenuProps) {
83
95
  }
84
96
  return true;
85
97
  }
98
+ case "Home":
99
+ setFocusedIndex(0);
100
+ scrollToIndex(0);
101
+ return true;
102
+ case "End": {
103
+ const last = filteredOptions.length - 1;
104
+ setFocusedIndex(last);
105
+ scrollToIndex(last);
106
+ return true;
107
+ }
86
108
  case "Enter":
87
109
  if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
88
110
  const opt = filteredOptions[focusedIndex];
@@ -111,6 +133,10 @@ export function CommandMenu(props: CommandMenuProps) {
111
133
  [handleKeyNavigation],
112
134
  );
113
135
 
136
+ const listLabel = accessibilityLabel ?? "Options";
137
+ const activeOptionId =
138
+ focusedIndex >= 0 && focusedIndex < filteredOptions.length ? optionId(focusedIndex) : undefined;
139
+
114
140
  return (
115
141
  <View style={[styles.container, screenSize.small ? { flex: 1 } : { maxHeight: 480 }]}>
116
142
  {enableSearch && (
@@ -124,20 +150,40 @@ export function CommandMenu(props: CommandMenuProps) {
124
150
  autoCapitalize="none"
125
151
  autoCorrect={false}
126
152
  onKeyPress={handleSearchKeyPress}
153
+ accessibilityLabel={listLabel}
154
+ role="combobox"
155
+ aria-expanded
156
+ aria-controls={listboxId}
157
+ aria-activedescendant={activeOptionId}
158
+ aria-autocomplete="list"
127
159
  />
128
160
  )}
129
- <ScrollView ref={scrollViewRef} style={styles.optionsList}>
161
+ <ScrollView
162
+ ref={scrollViewRef}
163
+ style={styles.optionsList}
164
+ nativeID={listboxId}
165
+ accessibilityLabel={listLabel}
166
+ // Boundary adapter: `listbox` is a valid ARIA role but missing from
167
+ // React Native's `Role` enum, so TypeScript cannot accept the literal
168
+ // here. `react-native-web` forwards the attribute verbatim, which is
169
+ // what assistive technology reads on web.
170
+ role={"listbox" as "list"}
171
+ >
130
172
  {filteredOptions.map((item, index) => (
131
173
  <MenuButton
132
174
  key={item.value}
175
+ nativeID={optionId(index)}
133
176
  testID={`command-option-${item.value}`}
134
177
  title={
135
178
  <Text userSelect="none" numberOfLines={1}>
136
179
  {item.label}
137
180
  </Text>
138
181
  }
182
+ accessibilityLabel={item.label}
183
+ role="option"
139
184
  right={undefined}
140
185
  focused={index === focusedIndex}
186
+ selected={index === focusedIndex}
141
187
  disabled={item.disabled}
142
188
  onPress={() => handleSelect(item.value)}
143
189
  onHoverIn={() => setFocusedIndex(index)}
package/src/dialog.tsx CHANGED
@@ -163,7 +163,7 @@ export function Dialog(props: DialogProps) {
163
163
  <PortalHost>
164
164
  <View testID={testID} style={[styles.dialogContainer, { borderRadius }]}>
165
165
  <View style={[styles.closeButtonContainer, { paddingHorizontal: screenSize.small ? 16 : 24 }]}>
166
- <Button icon="x" onPress={handleClose} />
166
+ <Button icon="x" accessibilityLabel="Close" onPress={handleClose} />
167
167
  </View>
168
168
  <View style={styles.container}>{children}</View>
169
169
  </View>