@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/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 {
|
|
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 (!
|
|
301
|
+
if (!popoverRef.current) return;
|
|
241
302
|
if (small) return;
|
|
242
303
|
|
|
243
|
-
|
|
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}
|
package/src/popover_header.tsx
CHANGED
|
@@ -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;
|
package/src/radio_picker.tsx
CHANGED
|
@@ -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={{
|
package/src/section_heading.tsx
CHANGED
|
@@ -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>
|
package/src/switch_button.tsx
CHANGED
|
@@ -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
|
|
23
|
-
{
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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) },
|