@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/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>
@@ -1,3 +1,4 @@
1
+ import { createContext, useContext, useId } from "react";
1
2
  import { StyleProp, View, ViewStyle } from "react-native";
2
3
  import { Text } from "./text";
3
4
  import { Spacer } from "./spacer";
@@ -12,38 +13,89 @@ export interface FormFieldProps {
12
13
  optionalLabel?: string;
13
14
  }
14
15
 
16
+ /**
17
+ * Binding emitted by `FormField` to the single input it wraps. Descendants
18
+ * that call `useFormField()` apply these props to the native input so screen
19
+ * readers know the label, error, and description apply to it.
20
+ */
21
+ export interface FormFieldBinding {
22
+ inputId: string;
23
+ labelId: string;
24
+ descriptionId: string | undefined;
25
+ errorId: string | undefined;
26
+ invalid: boolean;
27
+ }
28
+
29
+ const FormFieldContext = createContext<FormFieldBinding | null>(null);
30
+
31
+ /**
32
+ * Returns the association IDs set by the nearest enclosing `FormField`.
33
+ * Inputs spread the returned props onto their underlying element so label,
34
+ * description, and error are announced together. Returns `null` when the
35
+ * input is used outside a `FormField` — callers should then provide their
36
+ * own `accessibilityLabel`.
37
+ */
38
+ export function useFormField(): FormFieldBinding | null {
39
+ return useContext(FormFieldContext);
40
+ }
41
+
15
42
  export function FormField(props: FormFieldProps & { children: React.ReactNode }) {
16
43
  const { label, description, error, optional, style, children, optionalLabel = "Optional" } = props;
17
44
 
45
+ const inputId = useId();
46
+ const labelId = `${inputId}-label`;
47
+ const descriptionId = description ? `${inputId}-description` : undefined;
48
+ const errorId = error ? `${inputId}-error` : undefined;
49
+
50
+ const binding: FormFieldBinding = {
51
+ inputId,
52
+ labelId,
53
+ descriptionId,
54
+ errorId,
55
+ invalid: !!error,
56
+ };
57
+
18
58
  return (
19
- <View style={[{ paddingBottom: 16 }, style]}>
20
- <View
21
- style={{
22
- flexDirection: "row",
23
- alignItems: "center",
24
- justifyContent: "space-between",
25
- }}
26
- >
27
- {!!label && (
28
- <Text numberOfLines={1} weight="medium">
29
- {label}
59
+ <FormFieldContext.Provider value={binding}>
60
+ <View style={[{ paddingBottom: 16 }, style]}>
61
+ <View
62
+ style={{
63
+ flexDirection: "row",
64
+ alignItems: "center",
65
+ justifyContent: "space-between",
66
+ }}
67
+ >
68
+ {!!label && (
69
+ <Text nativeID={labelId} numberOfLines={1} weight="medium">
70
+ {label}
71
+ </Text>
72
+ )}
73
+ {!!optional && (
74
+ <Text numberOfLines={1} size="sm" color="zinc-500">
75
+ {optionalLabel}
76
+ </Text>
77
+ )}
78
+ </View>
79
+ {!!description && (
80
+ <Text nativeID={descriptionId} color="muted">
81
+ {description}
30
82
  </Text>
31
83
  )}
32
- {!!optional && (
33
- <Text numberOfLines={1} size="sm" color="zinc-500">
34
- {optionalLabel}
35
- </Text>
84
+ <Spacer size={8} />
85
+ <View>{children}</View>
86
+ {error && (
87
+ <>
88
+ <Spacer size={8} />
89
+ {/*
90
+ `role="alert"` + `aria-live="polite"` makes the text announce
91
+ when the error first appears or changes, without stealing focus.
92
+ */}
93
+ <Text nativeID={errorId} color="danger" accessibilityRole="alert" aria-live="polite">
94
+ {error}
95
+ </Text>
96
+ </>
36
97
  )}
37
98
  </View>
38
- {!!description && <Text color="muted">{description}</Text>}
39
- <Spacer size={8} />
40
- <View>{children}</View>
41
- {error && (
42
- <>
43
- <Spacer size={8} />
44
- <Text color="danger">{error}</Text>
45
- </>
46
- )}
47
- </View>
99
+ </FormFieldContext.Provider>
48
100
  );
49
101
  }
@@ -1,3 +1,4 @@
1
+ import { useId } from "react";
1
2
  import { View } from "react-native";
2
3
  import { Text } from "./text";
3
4
  import { FormFieldProps } from "./form_field";
@@ -8,6 +9,8 @@ export interface FormSwitchProps extends Omit<FormFieldProps, "optional">, Switc
8
9
 
9
10
  export function FormSwitch(props: FormSwitchProps) {
10
11
  const { label, description, error, value, onChange } = props;
12
+ const labelId = useId();
13
+ const errorId = error ? `${labelId}-error` : undefined;
11
14
 
12
15
  return (
13
16
  <View>
@@ -18,9 +21,18 @@ export function FormSwitch(props: FormSwitchProps) {
18
21
  alignItems: description ? "flex-start" : "center",
19
22
  }}
20
23
  >
21
- <Switch value={value} onChange={onChange} />
24
+ <Switch
25
+ value={value}
26
+ onChange={onChange}
27
+ accessibilityLabel={label}
28
+ />
22
29
  <View style={{ flex: 1 }}>
23
- <Text onPress={() => onChange?.(!value)} weight="medium" userSelect="none">
30
+ <Text
31
+ nativeID={labelId}
32
+ onPress={() => onChange?.(!value)}
33
+ weight="medium"
34
+ userSelect="none"
35
+ >
24
36
  {label}
25
37
  </Text>
26
38
  {!!description && (
@@ -34,7 +46,14 @@ export function FormSwitch(props: FormSwitchProps) {
34
46
  {!!error && (
35
47
  <>
36
48
  <Spacer size={8} />
37
- <Text color="danger">{error}</Text>
49
+ <Text
50
+ nativeID={errorId}
51
+ color="danger"
52
+ accessibilityRole="alert"
53
+ aria-live="polite"
54
+ >
55
+ {error}
56
+ </Text>
38
57
  </>
39
58
  )}
40
59
  <Spacer size={8} />
@@ -1,16 +1,19 @@
1
- import { useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { View, ScrollView, Pressable, StyleSheet } from "react-native";
3
3
  import { Text } from "../text";
4
4
  import { colors } from "../colors";
5
- import { dayDiff } from "../calendar/dates";
5
+ import { addDays, dayDiff } from "../calendar/dates";
6
+ import { usePointerDrag } from "../use_pointer_drag";
6
7
  import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
7
- import type { GanttScale, GanttTask } from "./types";
8
+ import { DEFAULT_GANTT_LABELS } from "./types";
9
+ import type { GanttLabels, GanttScale, GanttTask } from "./types";
8
10
 
9
11
  const LABEL_W = 188;
10
12
  const HEADER_H = 38;
11
13
  const ROW_H = 40;
12
14
  const BAR_H = 22;
13
- const SCALE_LABELS: Record<GanttScale, string> = { day: "Ngày", week: "Tuần", month: "Tháng" };
15
+ const HANDLE_W = 8;
16
+ const MIN_BAR = 8;
14
17
  const SCALES: GanttScale[] = ["day", "week", "month"];
15
18
 
16
19
  export interface GanttViewProps<T = unknown> {
@@ -18,35 +21,62 @@ export interface GanttViewProps<T = unknown> {
18
21
  defaultScale?: GanttScale;
19
22
  today?: Date;
20
23
  locale?: string;
24
+ /** Optional toolbar caption shown left of the zoom switch. */
25
+ title?: string;
26
+ /** User-facing chrome strings; defaults to English. */
27
+ labels?: Partial<GanttLabels>;
21
28
  onTaskPress?: (task: GanttTask<T>) => void;
29
+ /** When set, each bar gets a right-edge resize handle; dragging it adjusts the
30
+ * task's end. Receives the task + the new inclusive end day (clamped ≥ start). */
31
+ onTaskResize?: (task: GanttTask<T>, newEnd: Date) => void;
22
32
  }
23
33
 
24
34
  /**
25
35
  * Timeline / gantt: a frozen task-label column beside a horizontally-scrollable
26
36
  * zoomable axis. Bars are positioned by {@link barGeometry}; month boundaries get
27
- * a heavier gridline; a red line marks today. Renders at its natural height — wrap
28
- * in a ScrollView for very long task lists.
37
+ * a heavier gridline; a red line marks today. The axis opens scrolled to today
38
+ * so a long history doesn't bury the active range. Renders at its natural height
39
+ * — wrap in a ScrollView for very long task lists.
29
40
  */
30
41
  export function GanttView<T = unknown>(props: GanttViewProps<T>) {
31
- const { tasks, defaultScale = "week", today = new Date(), locale, onTaskPress } = props;
42
+ const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress, onTaskResize } = props;
43
+ const L = { ...DEFAULT_GANTT_LABELS, ...props.labels };
32
44
  const [scale, setScale] = useState<GanttScale>(defaultScale);
33
45
 
46
+ const taskById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
47
+ const { live, bind } = usePointerDrag((id, _pointer, delta) => {
48
+ const t = taskById.get(id);
49
+ if (!t || !onTaskResize) return;
50
+ const days = Math.round(delta.dx / pxPerDay(scale));
51
+ if (days === 0) return;
52
+ const newEnd = addDays(t.end ?? t.start, days);
53
+ onTaskResize(t, newEnd < t.start ? t.start : newEnd);
54
+ });
55
+
34
56
  const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
35
57
  const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
36
58
  const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
37
59
  const todayLeft = dayDiff(axisStart, today) * pxPerDay(scale);
38
60
  const todayInRange = today >= axisStart && today <= axisEnd;
39
61
 
62
+ // Open scrolled to today (a margin of recent past visible), like the time-grid
63
+ // scrolls to "now" — keeps the active range in view when history is long.
64
+ const scrollRef = useRef<ScrollView>(null);
65
+ useEffect(() => {
66
+ const id = setTimeout(() => scrollRef.current?.scrollTo({ x: Math.max(0, todayLeft - 96), animated: false }), 0);
67
+ return () => clearTimeout(id);
68
+ }, [scale, todayLeft]);
69
+
40
70
  return (
41
71
  <View style={styles.root}>
42
72
  {/* Toolbar: zoom */}
43
73
  <View style={styles.toolbar}>
44
- <Text size="sm" color="muted">Tiến độ dự án</Text>
74
+ {title ? <Text size="sm" color="muted">{title}</Text> : <View />}
45
75
  <View style={styles.zoomSwitch}>
46
76
  {SCALES.map((s) => (
47
77
  <Pressable key={s} onPress={() => setScale(s)} accessibilityRole="button" style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
48
78
  <Text size="sm" weight={scale === s ? "medium" : "regular"} color={scale === s ? "default" : "muted"}>
49
- {SCALE_LABELS[s]}
79
+ {L[s]}
50
80
  </Text>
51
81
  </Pressable>
52
82
  ))}
@@ -57,7 +87,7 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
57
87
  {/* Frozen label column */}
58
88
  <View style={{ width: LABEL_W, borderRightWidth: 1, borderRightColor: colors.border }}>
59
89
  <View style={[styles.headerCell, { height: HEADER_H, paddingLeft: 12, justifyContent: "center" }]}>
60
- <Text size="xs" color="muted">CÔNG VIỆC</Text>
90
+ <Text size="xs" color="muted" style={{ textTransform: "uppercase" }}>{L.task}</Text>
61
91
  </View>
62
92
  {tasks.map((t) => (
63
93
  <View key={t.id} style={[styles.labelRow, { height: ROW_H }]}>
@@ -68,7 +98,7 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
68
98
  </View>
69
99
 
70
100
  {/* Scrollable timeline */}
71
- <ScrollView horizontal style={{ flex: 1 }} showsHorizontalScrollIndicator>
101
+ <ScrollView ref={scrollRef} horizontal style={{ flex: 1 }} showsHorizontalScrollIndicator>
72
102
  <View style={{ width: axisWidth }}>
73
103
  {/* Header ticks */}
74
104
  <View style={{ height: HEADER_H, flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border }}>
@@ -86,7 +116,9 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
86
116
 
87
117
  {/* Task rows */}
88
118
  {tasks.map((t) => {
89
- const bar = barGeometry(t, axisStart, scale);
119
+ const base = barGeometry(t, axisStart, scale);
120
+ const dragging = live && live.id === t.id ? live : null;
121
+ const width = dragging ? Math.max(MIN_BAR, base.width + dragging.dx) : base.width;
90
122
  const accent = t.color || colors.teal[600];
91
123
  return (
92
124
  <View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
@@ -99,12 +131,20 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
99
131
  onPress={onTaskPress ? () => onTaskPress(t) : undefined}
100
132
  accessibilityRole={onTaskPress ? "button" : undefined}
101
133
  accessibilityLabel={t.label}
102
- style={{ position: "absolute", left: bar.left, width: bar.width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
134
+ style={{ position: "absolute", left: base.left, width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
103
135
  >
104
- <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
136
+ <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8, opacity: dragging ? 0.85 : 1 }}>
105
137
  <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
106
138
  </View>
107
139
  </Pressable>
140
+ {onTaskResize ? (
141
+ <View
142
+ ref={bind(t.id, "ew-resize")}
143
+ style={{ position: "absolute", left: base.left + width - HANDLE_W, width: HANDLE_W + 6, top: (ROW_H - BAR_H) / 2, height: BAR_H, alignItems: "center", justifyContent: "center", zIndex: 2 }}
144
+ >
145
+ <View style={{ width: 3, height: 11, borderRadius: 2, backgroundColor: "rgba(255,255,255,0.75)" }} />
146
+ </View>
147
+ ) : null}
108
148
  </View>
109
149
  );
110
150
  })}
@@ -1,4 +1,5 @@
1
1
  export { GanttView } from "./gantt_view";
2
2
  export type { GanttViewProps } from "./gantt_view";
3
3
  export { barGeometry, axisRange, buildTicks, pxPerDay } from "./scale";
4
- export type { GanttTask, GanttScale, GanttTick, GanttBar } from "./types";
4
+ export { DEFAULT_GANTT_LABELS } from "./types";
5
+ export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttLabels } from "./types";
@@ -18,6 +18,21 @@ export interface GanttTask<T = unknown> {
18
18
  /** Zoom level of the horizontal time axis. */
19
19
  export type GanttScale = "day" | "week" | "month";
20
20
 
21
+ /**
22
+ * User-facing chrome strings. Defaults to English and taken as a prop — i18n is
23
+ * the consumer's responsibility (see the @lotics/ui convention in
24
+ * grid/data_grid.tsx). Axis tick labels come from `locale` via Intl.
25
+ */
26
+ export interface GanttLabels {
27
+ day: string;
28
+ week: string;
29
+ month: string;
30
+ /** Frozen task-column header. */
31
+ task: string;
32
+ }
33
+
34
+ export const DEFAULT_GANTT_LABELS: GanttLabels = { day: "Day", week: "Week", month: "Month", task: "Task" };
35
+
21
36
  /** A header tick on the time axis. */
22
37
  export interface GanttTick {
23
38
  date: Date;
@@ -17,6 +17,7 @@ export const SelectHeaderCell = memo(function SelectHeaderCell() {
17
17
  }}
18
18
  >
19
19
  <CheckboxInput
20
+ accessibilityLabel="Select all rows"
20
21
  testID="table-header-checkbox"
21
22
  indeterminate={isIndeterminate}
22
23
  checked={isRowSelected}
package/src/icon.tsx CHANGED
@@ -392,12 +392,18 @@ interface IconProps {
392
392
 
393
393
  export function Icon({ name, size = 24, color = colors.zinc["900"], testID }: IconProps) {
394
394
  const IconComponent = iconComponents[name] ?? iconComponents["box"];
395
- if (testID) {
396
- return (
397
- <View testID={testID}>
398
- <IconComponent size={size} color={color} />
399
- </View>
400
- );
401
- }
402
- return <IconComponent size={size} color={color} />;
395
+ // Icons are always decorative: the enclosing control (button, menu item,
396
+ // status badge) carries the accessible name. Exposing the SVG to assistive
397
+ // tech doubles announcements ("check check-mark"). Callers that need a
398
+ // standalone meaningful icon must wrap it in a View with their own label.
399
+ return (
400
+ <View
401
+ testID={testID}
402
+ accessibilityElementsHidden
403
+ importantForAccessibility="no-hide-descendants"
404
+ aria-hidden
405
+ >
406
+ <IconComponent size={size} color={color} />
407
+ </View>
408
+ );
403
409
  }
@@ -5,21 +5,27 @@ import { PressableHighlight } from "./pressable_highlight";
5
5
  import { Ref, useCallback } from "react";
6
6
  import { TooltipSide } from "./tooltip";
7
7
 
8
- export interface IconButtonProps {
8
+ interface IconButtonBase {
9
9
  ref?: Ref<View>;
10
10
  testID?: string;
11
11
  icon: IconName;
12
12
  color?: "none" | "secondary" | "white";
13
13
  iconColor?: string;
14
- tooltip?: string;
15
14
  tooltipSide?: TooltipSide;
16
- /** Accessible name for the button. Falls back to `tooltip` when omitted. */
17
- accessibilityLabel?: string;
18
15
  onPress?: (event: GestureResponderEvent) => void;
19
16
  style?: StyleProp<ViewStyle>;
20
17
  disabled?: boolean;
21
18
  }
22
19
 
20
+ /**
21
+ * An icon-only button has no visible text, so it must carry an accessible
22
+ * name. The compiler enforces at least one of `tooltip` (also shown visually
23
+ * on hover) or `accessibilityLabel` (a11y-only). When both are present,
24
+ * `accessibilityLabel` is the announced name.
25
+ */
26
+ export type IconButtonProps = IconButtonBase &
27
+ ({ tooltip: string; accessibilityLabel?: string } | { tooltip?: string; accessibilityLabel: string });
28
+
23
29
  export function IconButton(props: IconButtonProps) {
24
30
  const {
25
31
  ref,
package/src/index.css CHANGED
@@ -336,6 +336,17 @@ html {
336
336
  color: var(--color-zinc-900);
337
337
  }
338
338
 
339
+ /* Keyboard focus ring. `:focus-visible` only matches keyboard-driven focus,
340
+ so pointer/touch interactions stay visually unchanged. */
341
+ :focus-visible {
342
+ outline: 2px solid var(--color-zinc-900);
343
+ outline-offset: 2px;
344
+ border-radius: 4px;
345
+ }
346
+ :focus:not(:focus-visible) {
347
+ outline: none;
348
+ }
349
+
339
350
  /* @font-face declarations are NOT included here — each app provides its own
340
351
  font loading because paths differ per platform:
341
352
  - Frontend: /fonts/Inter_*.woff2 (served from public/)
@@ -0,0 +1,34 @@
1
+ import { View, ViewProps } from "react-native";
2
+
3
+ export type LandmarkKind =
4
+ | "banner"
5
+ | "navigation"
6
+ | "main"
7
+ | "complementary"
8
+ | "contentinfo"
9
+ | "region";
10
+
11
+ export interface LandmarkProps extends Omit<ViewProps, "role" | "accessibilityRole"> {
12
+ /**
13
+ * Semantic region kind. On React Native Web, each maps to the matching HTML
14
+ * element (`<header>`, `<nav>`, `<main>`, `<aside>`, `<footer>`), exposing
15
+ * the region to screen readers' landmark navigation. On native, the role is
16
+ * exposed via `accessibilityRole`.
17
+ */
18
+ kind: LandmarkKind;
19
+ /**
20
+ * Required for `region` (must have a name) and recommended for any
21
+ * landmark type when the page contains more than one.
22
+ */
23
+ accessibilityLabel?: string;
24
+ children?: React.ReactNode;
25
+ }
26
+
27
+ export function Landmark(props: LandmarkProps) {
28
+ const { kind, accessibilityLabel, children, ...rest } = props;
29
+ return (
30
+ <View role={kind} accessibilityLabel={accessibilityLabel} {...rest}>
31
+ {children}
32
+ </View>
33
+ );
34
+ }
@@ -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
  />