@lotics/ui 1.10.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 +24 -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 +127 -0
- package/src/calendar/dates.ts +102 -0
- package/src/calendar/index.ts +20 -0
- package/src/calendar/layout.test.ts +103 -0
- package/src/calendar/layout.ts +142 -0
- package/src/calendar/month_view.tsx +159 -0
- package/src/calendar/time_grid_view.tsx +263 -0
- package/src/calendar/types.ts +67 -0
- package/src/checkbox_input.tsx +9 -3
- package/src/command_menu.tsx +50 -4
- package/src/dialog.tsx +1 -1
- package/src/download.ts +14 -2
- package/src/form_field.tsx +77 -25
- package/src/form_switch.tsx +22 -3
- package/src/gantt/gantt_view.tsx +145 -0
- package/src/gantt/index.ts +5 -0
- package/src/gantt/scale.test.ts +47 -0
- package/src/gantt/scale.ts +92 -0
- package/src/gantt/types.ts +51 -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/kanban/constants.ts +18 -0
- package/src/kanban/default_renderers.tsx +160 -0
- package/src/kanban/drag_preview.tsx +157 -0
- package/src/kanban/index.ts +13 -0
- package/src/kanban/insert_card_zone.tsx +135 -0
- package/src/kanban/kanban_board.tsx +616 -0
- package/src/kanban/kanban_card.tsx +312 -0
- package/src/kanban/kanban_column.tsx +487 -0
- package/src/kanban/placeholders.tsx +54 -0
- package/src/kanban/types.ts +116 -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 +47 -2
- 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/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
package/src/popover.tsx
CHANGED
|
@@ -150,10 +150,14 @@ export function PopoverTrigger({ children }: PopoverTriggerProps) {
|
|
|
150
150
|
children as React.ReactElement<{
|
|
151
151
|
ref?: React.Ref<HTMLDivElement>;
|
|
152
152
|
onPress?: () => void;
|
|
153
|
+
"aria-haspopup"?: "dialog" | "menu" | "listbox" | "true";
|
|
154
|
+
"aria-expanded"?: boolean;
|
|
153
155
|
}>,
|
|
154
156
|
{
|
|
155
157
|
ref: triggerRef as React.Ref<HTMLDivElement>,
|
|
156
158
|
onPress: handlePress,
|
|
159
|
+
"aria-haspopup": "dialog",
|
|
160
|
+
"aria-expanded": open,
|
|
157
161
|
},
|
|
158
162
|
);
|
|
159
163
|
}
|
|
@@ -177,10 +181,20 @@ export interface PopoverContentProps {
|
|
|
177
181
|
disableBodyScroll?: boolean;
|
|
178
182
|
/** When true, renders as bottom sheet on small screens (close button, slide-up animation) */
|
|
179
183
|
small?: boolean;
|
|
184
|
+
/** Accessible name for the bottom-sheet close button. Default: "Close". Pass a translated string from the consumer. */
|
|
185
|
+
closeLabel?: string;
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
export function PopoverContent(props: PopoverContentProps) {
|
|
183
|
-
const {
|
|
189
|
+
const {
|
|
190
|
+
testID,
|
|
191
|
+
children,
|
|
192
|
+
style,
|
|
193
|
+
contentContainerStyle,
|
|
194
|
+
disableBodyScroll,
|
|
195
|
+
small = false,
|
|
196
|
+
closeLabel = "Close",
|
|
197
|
+
} = props;
|
|
184
198
|
const { open, onOpenChange, triggerRef, side, align, offset, inheritTriggerWidth } =
|
|
185
199
|
usePopoverContext();
|
|
186
200
|
|
|
@@ -188,12 +202,41 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
188
202
|
const [position, setPosition] = useState<Position | null>(null);
|
|
189
203
|
const [triggerWidth, setTriggerWidth] = useState<number>(0);
|
|
190
204
|
const [isBottomSheetShown, setIsBottomSheetShown] = useState(false);
|
|
205
|
+
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
191
206
|
|
|
192
207
|
const handleClose = useCallback(() => {
|
|
193
208
|
if (!open) return;
|
|
194
209
|
onOpenChange(false);
|
|
195
210
|
}, [onOpenChange, open]);
|
|
196
211
|
|
|
212
|
+
// Focus management: when the popover opens, remember what had focus and move
|
|
213
|
+
// focus into the popover (the content div is tab-able via `tabIndex=-1`).
|
|
214
|
+
// When it closes, restore focus to the prior element so keyboard users don't
|
|
215
|
+
// land at the top of the page.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!open) return;
|
|
218
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
219
|
+
returnFocusRef.current = previouslyFocused;
|
|
220
|
+
|
|
221
|
+
const focusFrame = requestAnimationFrame(() => {
|
|
222
|
+
const content = popoverRef.current;
|
|
223
|
+
if (!content) return;
|
|
224
|
+
const firstFocusable = content.querySelector<HTMLElement>(
|
|
225
|
+
'input, select, textarea, button, [role="button"], [role="menuitem"], [role="option"], [tabindex]:not([tabindex="-1"])',
|
|
226
|
+
);
|
|
227
|
+
(firstFocusable ?? content).focus();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return () => {
|
|
231
|
+
cancelAnimationFrame(focusFrame);
|
|
232
|
+
const target = returnFocusRef.current;
|
|
233
|
+
returnFocusRef.current = null;
|
|
234
|
+
if (target && typeof target.focus === "function") {
|
|
235
|
+
target.focus();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}, [open]);
|
|
239
|
+
|
|
197
240
|
// Separate header, footer, and body from children
|
|
198
241
|
let header: React.ReactNode = null;
|
|
199
242
|
let footer: React.ReactNode = null;
|
|
@@ -438,6 +481,8 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
438
481
|
ref={popoverRef}
|
|
439
482
|
data-popover="true"
|
|
440
483
|
data-testid={testID}
|
|
484
|
+
role="dialog"
|
|
485
|
+
aria-modal={small ? true : undefined}
|
|
441
486
|
tabIndex={small ? undefined : -1}
|
|
442
487
|
style={{
|
|
443
488
|
position: "fixed",
|
|
@@ -497,7 +542,7 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
497
542
|
justifyContent: "flex-end",
|
|
498
543
|
}}
|
|
499
544
|
>
|
|
500
|
-
<IconButton icon="x" onPress={handleClose} />
|
|
545
|
+
<IconButton icon="x" tooltip={closeLabel} onPress={handleClose} />
|
|
501
546
|
</View>
|
|
502
547
|
)}
|
|
503
548
|
{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>
|