@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/package.json +21 -7
- package/src/alert.tsx +35 -5
- package/src/avatar.tsx +28 -3
- package/src/back_button.tsx +4 -2
- package/src/button.tsx +35 -5
- package/src/calendar/calendar_view.tsx +16 -7
- package/src/calendar/index.ts +2 -1
- package/src/calendar/month_view.tsx +43 -5
- package/src/calendar/time_grid_view.tsx +3 -1
- package/src/calendar/types.ts +29 -0
- package/src/checkbox_input.tsx +9 -3
- package/src/command_menu.tsx +50 -4
- package/src/css_modules.d.ts +2 -0
- package/src/dialog.tsx +1 -1
- package/src/form_field.tsx +77 -25
- package/src/form_switch.tsx +22 -3
- package/src/gantt/gantt_view.tsx +54 -14
- package/src/gantt/index.ts +2 -1
- package/src/gantt/types.ts +15 -0
- package/src/grid/select_header_cell.tsx +1 -0
- package/src/icon.tsx +14 -8
- package/src/icon_button.tsx +10 -4
- package/src/index.css +11 -0
- package/src/landmark.tsx +34 -0
- package/src/menu_button.tsx +21 -0
- package/src/menu_list_item.tsx +3 -0
- package/src/number_input.tsx +10 -1
- package/src/pill_button.tsx +1 -0
- package/src/popover.tsx +75 -4
- package/src/popover_header.tsx +4 -2
- package/src/pressable_highlight.tsx +24 -0
- package/src/radio_picker.tsx +63 -5
- package/src/section_heading.tsx +5 -3
- package/src/skip_link.tsx +46 -0
- package/src/switch.tsx +9 -1
- package/src/switch_button.tsx +3 -0
- package/src/tabs.tsx +81 -19
- package/src/text.tsx +33 -0
- package/src/text_input_field.tsx +31 -0
- package/src/tooltip.tsx +43 -6
- package/src/use_pointer_drag.ts +99 -0
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>
|
package/src/form_field.tsx
CHANGED
|
@@ -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
|
-
<
|
|
20
|
-
<View
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
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
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/form_switch.tsx
CHANGED
|
@@ -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
|
|
24
|
+
<Switch
|
|
25
|
+
value={value}
|
|
26
|
+
onChange={onChange}
|
|
27
|
+
accessibilityLabel={label}
|
|
28
|
+
/>
|
|
22
29
|
<View style={{ flex: 1 }}>
|
|
23
|
-
<Text
|
|
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
|
|
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} />
|
package/src/gantt/gantt_view.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
28
|
-
*
|
|
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">
|
|
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
|
-
{
|
|
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">
|
|
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
|
|
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:
|
|
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
|
})}
|
package/src/gantt/index.ts
CHANGED
|
@@ -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
|
|
4
|
+
export { DEFAULT_GANTT_LABELS } from "./types";
|
|
5
|
+
export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttLabels } from "./types";
|
package/src/gantt/types.ts
CHANGED
|
@@ -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;
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
}
|
package/src/icon_button.tsx
CHANGED
|
@@ -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
|
-
|
|
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/)
|
package/src/landmark.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/menu_button.tsx
CHANGED
|
@@ -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>
|
package/src/menu_list_item.tsx
CHANGED
|
@@ -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>
|
package/src/number_input.tsx
CHANGED
|
@@ -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)
|
package/src/pill_button.tsx
CHANGED