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