@lotics/ui 1.11.1 → 1.13.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 +7 -4
- package/src/accordion.tsx +97 -0
- package/src/button.tsx +1 -1
- package/src/calendar/calendar_view.tsx +5 -1
- package/src/calendar/month_view.tsx +40 -4
- package/src/cell_date_format.test.ts +32 -0
- package/src/cell_date_format.ts +28 -3
- package/src/checkbox_input.tsx +8 -3
- package/src/css_modules.d.ts +2 -0
- package/src/date_calendar.tsx +679 -0
- package/src/date_field.tsx +172 -0
- package/src/date_picker.tsx +403 -28
- package/src/date_picker_value.test.ts +167 -0
- package/src/date_picker_value.ts +128 -0
- package/src/date_segments.test.ts +206 -0
- package/src/date_segments.ts +347 -0
- package/src/date_segments_field.tsx +418 -0
- package/src/gantt/gantt_view.tsx +31 -5
- package/src/icon.tsx +2 -0
- package/src/menu_button.tsx +1 -1
- package/src/menu_list_item.tsx +1 -1
- package/src/popover.tsx +28 -2
- package/src/radio_picker.tsx +1 -1
- package/src/stepper.tsx +83 -0
- package/src/switch.tsx +1 -1
- package/src/switch_button.tsx +1 -1
- package/src/tabs.tsx +1 -1
- package/src/time_field.tsx +300 -0
- package/src/use_pointer_drag.ts +99 -0
- package/src/datetime_picker.tsx +0 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"./stack": "./src/stack.tsx",
|
|
87
87
|
"./status_badge": "./src/status_badge.tsx",
|
|
88
88
|
"./card": "./src/card.tsx",
|
|
89
|
+
"./accordion": "./src/accordion.tsx",
|
|
90
|
+
"./stepper": "./src/stepper.tsx",
|
|
89
91
|
"./tabs": "./src/tabs.tsx",
|
|
90
92
|
"./table": "./src/table.tsx",
|
|
91
93
|
"./auto_sizer": "./src/auto_sizer.tsx",
|
|
@@ -95,8 +97,9 @@
|
|
|
95
97
|
"./page_header": "./src/page_header.tsx",
|
|
96
98
|
"./pager_view": "./src/pager_view.tsx",
|
|
97
99
|
"./date_picker": "./src/date_picker.tsx",
|
|
98
|
-
"./datetime_picker": "./src/datetime_picker.tsx",
|
|
99
100
|
"./time_picker": "./src/time_picker.tsx",
|
|
101
|
+
"./time_field": "./src/time_field.tsx",
|
|
102
|
+
"./date_calendar": "./src/date_calendar.tsx",
|
|
100
103
|
"./dialog": "./src/dialog.tsx",
|
|
101
104
|
"./screen_router": "./src/screen_router.tsx",
|
|
102
105
|
"./route_matching": "./src/route_matching.ts",
|
|
@@ -148,8 +151,8 @@
|
|
|
148
151
|
},
|
|
149
152
|
"license": "MIT",
|
|
150
153
|
"peerDependencies": {
|
|
151
|
-
"react": "^19.
|
|
152
|
-
"react-dom": "^19.
|
|
154
|
+
"react": "^19.2.0",
|
|
155
|
+
"react-dom": "^19.2.0",
|
|
153
156
|
"react-native": ">=0.76.0",
|
|
154
157
|
"react-native-web": ">=0.20.0",
|
|
155
158
|
"lucide-react-native": ">=0.460.0",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ReactNode, useState } from "react";
|
|
2
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
+
import { AnimationFadeIn } from "./animation_fade_in";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
+
|
|
8
|
+
export interface AccordionProps {
|
|
9
|
+
/** Content of the clickable header (the main area); a chevron is appended. */
|
|
10
|
+
header: ReactNode;
|
|
11
|
+
/** Collapsible body, revealed when expanded. */
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
/** Rendered after the chevron, OUTSIDE the toggle — e.g. an action button
|
|
14
|
+
* that must stay clickable without expanding the panel. */
|
|
15
|
+
headerRight?: ReactNode;
|
|
16
|
+
/** Uncontrolled initial state. Ignored when `expanded` is provided. */
|
|
17
|
+
defaultExpanded?: boolean;
|
|
18
|
+
/** Controlled open state — provide together with `onToggle`. */
|
|
19
|
+
expanded?: boolean;
|
|
20
|
+
onToggle?: (expanded: boolean) => void;
|
|
21
|
+
/** Container style, always applied. */
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
/** Merged onto the container while open — e.g. an accent border. */
|
|
24
|
+
expandedStyle?: StyleProp<ViewStyle>;
|
|
25
|
+
accessibilityLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A single expand/collapse disclosure. The header is a caller-supplied node
|
|
30
|
+
* (so it can be as rich as a row of metrics), the chevron is added
|
|
31
|
+
* automatically, and `headerRight` holds an action that stays clickable
|
|
32
|
+
* without toggling. Controlled via `expanded`/`onToggle`, or uncontrolled via
|
|
33
|
+
* `defaultExpanded`. Compose several to build an accordion list.
|
|
34
|
+
*/
|
|
35
|
+
export function Accordion(props: AccordionProps) {
|
|
36
|
+
const {
|
|
37
|
+
header,
|
|
38
|
+
children,
|
|
39
|
+
headerRight,
|
|
40
|
+
defaultExpanded = false,
|
|
41
|
+
expanded: controlled,
|
|
42
|
+
onToggle,
|
|
43
|
+
style,
|
|
44
|
+
expandedStyle,
|
|
45
|
+
accessibilityLabel,
|
|
46
|
+
} = props;
|
|
47
|
+
const [internal, setInternal] = useState(defaultExpanded);
|
|
48
|
+
const expanded = controlled ?? internal;
|
|
49
|
+
|
|
50
|
+
const toggle = () => {
|
|
51
|
+
const next = !expanded;
|
|
52
|
+
if (controlled === undefined) setInternal(next);
|
|
53
|
+
onToggle?.(next);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View style={[styles.container, style, expanded ? expandedStyle : null]}>
|
|
58
|
+
<View style={styles.headerRow}>
|
|
59
|
+
<PressableHighlight
|
|
60
|
+
onPress={toggle}
|
|
61
|
+
accessibilityRole="button"
|
|
62
|
+
accessibilityState={{ expanded }}
|
|
63
|
+
accessibilityLabel={accessibilityLabel}
|
|
64
|
+
style={styles.trigger}
|
|
65
|
+
>
|
|
66
|
+
<View style={styles.headerContent}>{header}</View>
|
|
67
|
+
<Icon name={expanded ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
|
|
68
|
+
</PressableHighlight>
|
|
69
|
+
{headerRight}
|
|
70
|
+
</View>
|
|
71
|
+
{expanded ? <AnimationFadeIn style={styles.body}>{children}</AnimationFadeIn> : null}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
container: {
|
|
78
|
+
borderRadius: 12,
|
|
79
|
+
},
|
|
80
|
+
headerRow: {
|
|
81
|
+
flexDirection: "row",
|
|
82
|
+
alignItems: "center",
|
|
83
|
+
gap: 12,
|
|
84
|
+
},
|
|
85
|
+
trigger: {
|
|
86
|
+
flex: 1,
|
|
87
|
+
flexDirection: "row",
|
|
88
|
+
alignItems: "center",
|
|
89
|
+
gap: 12,
|
|
90
|
+
},
|
|
91
|
+
headerContent: {
|
|
92
|
+
flex: 1,
|
|
93
|
+
},
|
|
94
|
+
body: {
|
|
95
|
+
marginTop: 12,
|
|
96
|
+
},
|
|
97
|
+
});
|
package/src/button.tsx
CHANGED
|
@@ -104,7 +104,7 @@ export function Button(props: ButtonProps) {
|
|
|
104
104
|
(typeof tooltip === "string" ? tooltip : tooltip?.text) ||
|
|
105
105
|
undefined
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
aria-disabled={disabledOrLoading || undefined} aria-busy={loading || undefined}
|
|
108
108
|
disabled={disabledOrLoading}
|
|
109
109
|
// @ts-ignore hovered is a react-native-web extension not in base RN types
|
|
110
110
|
style={({ pressed, hovered }) => {
|
|
@@ -20,6 +20,9 @@ export interface CalendarViewProps<T = unknown> {
|
|
|
20
20
|
labels?: Partial<CalendarLabels>;
|
|
21
21
|
onEventPress?: (event: CalendarEvent<T>) => void;
|
|
22
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;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -28,7 +31,7 @@ export interface CalendarViewProps<T = unknown> {
|
|
|
28
31
|
* primitive is drop-in; the consumer only supplies events + callbacks.
|
|
29
32
|
*/
|
|
30
33
|
export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
|
|
31
|
-
const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
|
|
34
|
+
const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress, onEventDrop } = props;
|
|
32
35
|
const L = { ...DEFAULT_CALENDAR_LABELS, ...props.labels };
|
|
33
36
|
const [view, setView] = useState<CalendarViewMode>(defaultView);
|
|
34
37
|
const [date, setDate] = useState<Date>(defaultDate ?? new Date());
|
|
@@ -88,6 +91,7 @@ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
|
|
|
88
91
|
moreLabel={L.more}
|
|
89
92
|
onEventPress={onEventPress}
|
|
90
93
|
onDayPress={drillToDay}
|
|
94
|
+
onEventDrop={onEventDrop}
|
|
91
95
|
/>
|
|
92
96
|
) : (
|
|
93
97
|
<TimeGridView
|
|
@@ -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;
|
|
@@ -19,15 +20,39 @@ export interface MonthViewProps<T = unknown> {
|
|
|
19
20
|
moreLabel?: (count: number) => string;
|
|
20
21
|
onEventPress?: (event: CalendarEvent<T>) => void;
|
|
21
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;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
25
|
-
const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress } = props;
|
|
29
|
+
const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress, onEventDrop } = props;
|
|
26
30
|
const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
|
|
27
31
|
const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
|
|
28
32
|
const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
|
|
29
33
|
const now = new Date();
|
|
30
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
|
+
|
|
31
56
|
return (
|
|
32
57
|
<View style={styles.root}>
|
|
33
58
|
<View style={styles.weekdayHeader}>
|
|
@@ -49,7 +74,15 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
return (
|
|
52
|
-
<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
|
+
>
|
|
53
86
|
{/* Date numbers */}
|
|
54
87
|
<View style={styles.dateRow}>
|
|
55
88
|
{weekDays.map((day) => {
|
|
@@ -89,9 +122,11 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
|
89
122
|
{visible.map((bar) => {
|
|
90
123
|
const accent = bar.event.color || colors.teal[600];
|
|
91
124
|
const banner = bar.span > 1 || !!bar.event.allDay;
|
|
125
|
+
const drag = live && live.id === bar.event.id ? live : null;
|
|
92
126
|
return (
|
|
93
127
|
<Pressable
|
|
94
128
|
key={bar.event.id}
|
|
129
|
+
ref={onEventDrop ? bind(bar.event.id, "grab") : undefined}
|
|
95
130
|
onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
|
|
96
131
|
accessibilityRole={onEventPress ? "button" : undefined}
|
|
97
132
|
accessibilityLabel={bar.event.title}
|
|
@@ -102,6 +137,7 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
|
102
137
|
left: `${(bar.startCol / 7) * 100}%`,
|
|
103
138
|
width: `${(bar.span / 7) * 100}%`,
|
|
104
139
|
paddingHorizontal: 2,
|
|
140
|
+
...(drag ? { transform: [{ translateX: drag.dx }, { translateY: drag.dy }], zIndex: 20, opacity: 0.9 } : null),
|
|
105
141
|
}}
|
|
106
142
|
>
|
|
107
143
|
<View
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Force a positive-offset zone (UTC+7) before importing the formatter: the old
|
|
2
|
+
// `new Date("2024-03-15")` UTC-parse would surface a phantom "07:00" here, and a
|
|
3
|
+
// negative-offset zone would shift the date a day. The wall-clock parse must keep
|
|
4
|
+
// the value naive so output is timezone-independent.
|
|
5
|
+
process.env.TZ = "Asia/Ho_Chi_Minh";
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
import { formatDateValue } from "./cell_date_format";
|
|
9
|
+
|
|
10
|
+
describe("formatDateValue — wall-clock (no timezone shift)", () => {
|
|
11
|
+
it("renders a date-only value as midnight, not the zone offset", () => {
|
|
12
|
+
const out = formatDateValue("2024-03-15", { format: "datetime", locale: "en-US" });
|
|
13
|
+
expect(out).toMatch(/3\/15\/2024/);
|
|
14
|
+
expect(out).toMatch(/12:00\s?AM/i); // midnight — not 7:00 AM
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps the same calendar day for a date field in any zone", () => {
|
|
18
|
+
expect(formatDateValue("2024-03-15", { format: "date", locale: "en-US" })).toBe("3/15/2024");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders a datetime value at its literal wall-clock time", () => {
|
|
22
|
+
expect(formatDateValue("2024-03-15T09:30", { format: "datetime", locale: "en-US" })).toMatch(
|
|
23
|
+
/9:30\s?AM/i,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns '' for empty and null (sentinel) for unparseable input", () => {
|
|
28
|
+
expect(formatDateValue("")).toBe("");
|
|
29
|
+
expect(formatDateValue(null)).toBe("");
|
|
30
|
+
expect(formatDateValue("not-a-date")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/cell_date_format.ts
CHANGED
|
@@ -8,6 +8,31 @@ export interface DateFormatOptions {
|
|
|
8
8
|
timeStyle?: "full" | "long" | "medium" | "short";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a canonical, timezone-naive ISO value as wall-clock time in the local zone.
|
|
15
|
+
* `new Date("2024-03-15")` parses date-only strings as **UTC** midnight, which then
|
|
16
|
+
* shifts under Intl's local formatting — a phantom time on date-only values and an
|
|
17
|
+
* off-by-one date in negative-offset zones. Building the Date from its parts keeps
|
|
18
|
+
* it naive, matching how the picker reads the same value. Non-ISO input falls back
|
|
19
|
+
* to the native parser.
|
|
20
|
+
*/
|
|
21
|
+
function parseWallClock(value: string): Date | null {
|
|
22
|
+
const m = ISO_DATE_TIME.exec(value.trim());
|
|
23
|
+
const date = m
|
|
24
|
+
? new Date(
|
|
25
|
+
Number(m[1]),
|
|
26
|
+
Number(m[2]) - 1,
|
|
27
|
+
Number(m[3]),
|
|
28
|
+
Number(m[4] ?? 0),
|
|
29
|
+
Number(m[5] ?? 0),
|
|
30
|
+
Number(m[6] ?? 0),
|
|
31
|
+
)
|
|
32
|
+
: new Date(value);
|
|
33
|
+
return isNaN(date.getTime()) ? null : date;
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
/**
|
|
12
37
|
* Format an ISO date string to a localized display string.
|
|
13
38
|
* Returns "" for null/empty input. Returns null (sentinel) for unparseable input
|
|
@@ -19,8 +44,8 @@ export function formatDateValue(
|
|
|
19
44
|
): string | null {
|
|
20
45
|
if (!value) return "";
|
|
21
46
|
|
|
22
|
-
const date =
|
|
23
|
-
if (
|
|
47
|
+
const date = parseWallClock(value);
|
|
48
|
+
if (!date) return null;
|
|
24
49
|
|
|
25
50
|
const locale = options.locale ?? "en-US";
|
|
26
51
|
const { format = "date", dateStyle = "short", timeStyle = "short" } = options;
|
|
@@ -43,6 +68,6 @@ export function formatDateValue(
|
|
|
43
68
|
try {
|
|
44
69
|
return new Intl.DateTimeFormat(locale, intlOptions).format(date);
|
|
45
70
|
} catch {
|
|
46
|
-
return
|
|
71
|
+
return value;
|
|
47
72
|
}
|
|
48
73
|
}
|
package/src/checkbox_input.tsx
CHANGED
|
@@ -29,9 +29,14 @@ export function CheckboxInput(props: CheckboxInputProps) {
|
|
|
29
29
|
onPress={handlePress}
|
|
30
30
|
disabled={disabled}
|
|
31
31
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Use the W3C ARIA props directly: RN-web 0.21 no longer maps
|
|
33
|
+
// `accessibilityState={{ checked }}` to `aria-checked`, which dropped the
|
|
34
|
+
// checked state from the accessibility tree (a role="checkbox" with no
|
|
35
|
+
// aria-checked). `role`/`aria-*` map correctly on both web and native.
|
|
36
|
+
role="checkbox"
|
|
37
|
+
aria-label={accessibilityLabel}
|
|
38
|
+
aria-checked={indeterminate ? "mixed" : checked}
|
|
39
|
+
aria-disabled={disabled || undefined}
|
|
35
40
|
>
|
|
36
41
|
<Checkbox checked={checked} indeterminate={indeterminate} />
|
|
37
42
|
</Pressable>
|