@lotics/ui 1.12.0 → 1.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
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,15 +151,15 @@
148
151
  },
149
152
  "license": "MIT",
150
153
  "peerDependencies": {
151
- "react": "^19.0.0",
152
- "react-dom": "^19.0.0",
153
- "react-native": ">=0.76.0",
154
- "react-native-web": ">=0.20.0",
155
- "lucide-react-native": ">=0.460.0",
154
+ "@react-native-picker/picker": ">=2.0.0",
155
+ "expo-image": ">=3.0.0",
156
156
  "lucide-react": ">=0.460.0",
157
+ "lucide-react-native": ">=0.460.0",
158
+ "react": "^19.2.0",
159
+ "react-dom": "^19.2.0",
160
+ "react-native": ">=0.85.0",
157
161
  "react-native-svg": ">=15.0.0",
158
- "expo-image": ">=3.0.0",
159
- "@react-native-picker/picker": ">=2.0.0",
162
+ "react-native-web": ">=0.20.0",
160
163
  "recharts": ">=3.0.0"
161
164
  },
162
165
  "peerDependenciesMeta": {
@@ -180,5 +183,8 @@
180
183
  "typecheck": "tsgo --noEmit",
181
184
  "lint": "oxlint",
182
185
  "test": "vitest run"
186
+ },
187
+ "devDependencies": {
188
+ "recharts": "^3.8.1"
183
189
  }
184
190
  }
@@ -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
- accessibilityState={{ disabled: disabledOrLoading, busy: loading }}
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 }) => {
@@ -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
+ });
@@ -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 = new Date(value);
23
- if (isNaN(date.getTime())) return null;
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 includeTime ? date.toISOString() : value;
71
+ return value;
47
72
  }
48
73
  }
@@ -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
- accessibilityRole="checkbox"
33
- accessibilityLabel={accessibilityLabel}
34
- accessibilityState={{ checked: indeterminate ? "mixed" : checked, disabled: !!disabled }}
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>