@lotics/ui 13.7.1 → 13.8.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/docs/catalog.md CHANGED
@@ -295,8 +295,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
295
295
  brand accent (OKLCH blue by default) that chart fills, hero CTAs, and focus rings read.
296
296
  - **`locale`** — `LoticsLocaleProvider` / `useLoticsLocale` + the shipped `en` (default) and
297
297
  `vi` packs. Set the language ONCE at the root; wired components resolve **prop → provider
298
- locale → English default**. **Limitation:** the calendar/gantt views and the comment
299
- labels are not yet provider-wired pass their `labels` props directly.
298
+ locale → English default**. Each pack also carries a **`bcp47`** tag (`en`→`en-US`,
299
+ `vi`→`vi-VN`); date/time controls read it via **`useLocaleTag`** (`resolveLocaleTag` is its
300
+ pure core) so the segmented field's part ORDER (dd/MM vs MM/dd), the picker calendar's
301
+ weekday/month names, and `formatDate` display all follow the provider with no per-instance
302
+ `locale` prop — an explicit `locale` still overrides. **Limitation:** the calendar/gantt
303
+ views and the comment labels are not yet provider-wired — pass their `labels` props directly.
300
304
  - **`colors`** — the palette + `withAlpha` · `solid` · `tint` · `ramp` · `ColorName` ·
301
305
  `isColorName` · `asColorName` (coerce a stored option/status token to a `ColorName`,
302
306
  neutral fallback).
@@ -65,8 +65,9 @@ restore is gated on modality via `shouldRestoreFocusOnClose`).
65
65
 
66
66
  **Typed dates.** `InlineDatePicker`'s keyboard mode is an internal segmented date field
67
67
  (`DateField` — not exported; reached only through `InlineDatePicker`): type
68
- the date in the locale's own field order (dd/MM/yyyy where the locale says so — pass
69
- `locale`), digits auto-advance, and a typed separator (`/` `.` `-`) advances a single-digit
68
+ the date in the locale's own field order dd/MM/yyyy under the `vi` provider, MM/dd/yyyy
69
+ under `en`, derived from the active `LoticsLocaleProvider` (an explicit `locale` prop still
70
+ overrides) — digits auto-advance, and a typed separator (`/` `.` `-`) advances a single-digit
70
71
  day/month; Enter or blur commits, Escape reverts, Alt+ArrowDown floats the calendar. The
71
72
  calendar popover stays the pointer path (click the resting value, as ever) — it is no longer
72
73
  the only path. A PARTIAL entry never commits and never clears the stored value: the field
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "13.7.1",
3
+ "version": "13.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -7,7 +7,7 @@ import { Picker, PickerOption } from "./picker";
7
7
  import { IconButton } from "./icon_button";
8
8
  import { FOCUS_RING } from "./control_surface";
9
9
  import { useFocusRing } from "./use_focus_ring";
10
- import { useLoticsLocale } from "./locale";
10
+ import { useLoticsLocale, useLocaleTag } from "./locale";
11
11
 
12
12
  /** Accessible names for the calendar's month-navigation arrows. Backs the
13
13
  * `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
@@ -76,7 +76,8 @@ export interface CalendarRef {
76
76
  interface CalendarBaseProps {
77
77
  /** First day of the week (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Default: 1 (Monday) */
78
78
  firstDayOfWeek?: DayOfWeek;
79
- /** BCP-47 locale for weekday/month names. Default: "en-US". */
79
+ /** BCP-47 locale for weekday/month names. Defaults to the active
80
+ * `LoticsLocaleProvider` locale. */
80
81
  locale?: string;
81
82
  /** Ref to access calendar methods like navigateToMonth */
82
83
  ref?: React.Ref<CalendarRef>;
@@ -130,9 +131,6 @@ interface CalendarMonthProps {
130
131
  // Helper Functions
131
132
  // =============================================================================
132
133
 
133
- /** BCP-47 locale used when a caller does not supply one. */
134
- const DEFAULT_LOCALE = "en-US";
135
-
136
134
  interface LocalizedNames {
137
135
  /** Weekday short names, index 0 = Sunday. */
138
136
  weekdays: string[];
@@ -425,7 +423,7 @@ export function Calendar<TMode extends "single" | "range">(
425
423
  ): React.ReactElement {
426
424
  const screenSize = useScreenSize();
427
425
  const firstDayOfWeek = props.firstDayOfWeek ?? 1; // Default to Monday
428
- const locale = props.locale ?? DEFAULT_LOCALE;
426
+ const locale = useLocaleTag(props.locale);
429
427
 
430
428
  if (props.mode === "single") {
431
429
  return (
@@ -6,6 +6,7 @@ import { useHover } from "./use_hover";
6
6
  import { Text } from "./text";
7
7
  import { DateSegments } from "./date_segments_field";
8
8
  import { SegmentLabels, dateSegmentsConfig } from "./date_segments";
9
+ import { useLocaleTag } from "./locale";
9
10
 
10
11
  export interface DateFieldProps {
11
12
  /** Canonical ISO value parts: one (single) or two (range, "start"/"end"). */
@@ -13,7 +14,8 @@ export interface DateFieldProps {
13
14
  onPartChange: (index: number, value: string) => void;
14
15
  hasTime: boolean;
15
16
  segmentLabels: SegmentLabels;
16
- /** BCP-47 locale driving segment order + 12/24h. Defaults to "en-US". */
17
+ /** BCP-47 locale driving segment order + 12/24h. Defaults to the active
18
+ * `LoticsLocaleProvider` locale (`vi` → dd/MM/yyyy, `en` → MM/dd/yyyy). */
17
19
  locale?: string;
18
20
  disabled?: boolean;
19
21
  /** Shown when every part is empty and the field is unfocused. */
@@ -111,7 +113,8 @@ export function DateField(props: DateFieldProps) {
111
113
  [onIncompleteChange],
112
114
  );
113
115
 
114
- const config = useMemo(() => dateSegmentsConfig(locale ?? "en-US", hasTime), [locale, hasTime]);
116
+ const localeTag = useLocaleTag(locale);
117
+ const config = useMemo(() => dateSegmentsConfig(localeTag, hasTime), [localeTag, hasTime]);
115
118
 
116
119
  const isEmpty = parts.every((part) => !part);
117
120
  const showPlaceholder = !!placeholder && isEmpty && !focused;
@@ -11,7 +11,7 @@ import { useScreenSize } from "./use_screen_size";
11
11
  import { SegmentLabels } from "./date_segments";
12
12
  import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
13
13
  import { formatDate } from "./format_date";
14
- import { useLoticsLocale } from "./locale";
14
+ import { useLoticsLocale, useLocaleTag } from "./locale";
15
15
 
16
16
  type SelectionMode = "single" | "range";
17
17
 
@@ -50,7 +50,8 @@ export interface DateFilterProps {
50
50
  includeTime?: boolean;
51
51
  /** Translated labels. Defaults to English. */
52
52
  labels?: Partial<DateFilterLabels>;
53
- /** BCP-47 locale for the calendar and date display. Defaults to "en-US". */
53
+ /** BCP-47 locale for the calendar and date display. Defaults to the active
54
+ * `LoticsLocaleProvider` locale. */
54
55
  locale?: string;
55
56
  }
56
57
 
@@ -88,6 +89,7 @@ function formatDateDisplay(date: Date | null, locale: string | undefined): strin
88
89
 
89
90
  export function DateFilter(props: DateFilterProps) {
90
91
  const { value, onValueChange, includeTime = false, locale } = props;
92
+ const localeTag = useLocaleTag(locale);
91
93
  const loc = useLoticsLocale().dateRange;
92
94
  const labels = useMemo<DateFilterLabels>(
93
95
  () => ({ ...loc, ...props.labels }),
@@ -238,7 +240,7 @@ export function DateFilter(props: DateFilterProps) {
238
240
  mode="range"
239
241
  value={calendarValue}
240
242
  onValueChange={handleCalendarChange}
241
- locale={locale}
243
+ locale={localeTag}
242
244
  />
243
245
 
244
246
  {selectionMode === "range" ? (
@@ -249,7 +251,7 @@ export function DateFilter(props: DateFilterProps) {
249
251
  {labels.from}
250
252
  </Text>
251
253
  <Text size="sm" weight="medium">
252
- {formatDateDisplay(value.start.date, locale) || labels.selectDate}
254
+ {formatDateDisplay(value.start.date, localeTag) || labels.selectDate}
253
255
  </Text>
254
256
  {includeTime && (
255
257
  <View style={{ marginTop: 4 }}>
@@ -269,7 +271,7 @@ export function DateFilter(props: DateFilterProps) {
269
271
  {labels.to}
270
272
  </Text>
271
273
  <Text size="sm" weight="medium">
272
- {formatDateDisplay(value.end.date, locale) || labels.selectDate}
274
+ {formatDateDisplay(value.end.date, localeTag) || labels.selectDate}
273
275
  </Text>
274
276
  {includeTime && value.end.date && (
275
277
  <View style={{ marginTop: 4 }}>
@@ -74,7 +74,8 @@ export interface DatePickerProps {
74
74
  * omitted resolves from the `datePicker` locale slice — English by default, or
75
75
  * the active `LoticsLocaleProvider` pack. */
76
76
  labels?: Partial<DatePickerLabels>;
77
- /** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
77
+ /** BCP-47 locale for the segment order + the calendar's weekday/month names.
78
+ * Defaults to the active `LoticsLocaleProvider` locale. */
78
79
  locale?: string;
79
80
  /** Shown in the field when nothing is selected yet. */
80
81
  placeholder?: string;
@@ -94,7 +95,8 @@ export interface DatePickerPanelProps {
94
95
  /** Single `date` formats only: time is optional and driven by the value's shape. */
95
96
  optionalTime?: boolean;
96
97
  labels?: Partial<DatePickerLabels>;
97
- /** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
98
+ /** BCP-47 locale for the segment order + the calendar's weekday/month names.
99
+ * Defaults to the active `LoticsLocaleProvider` locale. */
98
100
  locale?: string;
99
101
  /** Called when a selection completes and the surrounding surface should dismiss. */
100
102
  onRequestClose?: () => void;
@@ -9,7 +9,7 @@ import { FocusRingPressable } from "./focus_ring_pressable";
9
9
  import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
10
10
  import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
11
11
  import { formatDate } from "./format_date";
12
- import { useLoticsLocale } from "./locale";
12
+ import { useLoticsLocale, useLocaleTag } from "./locale";
13
13
 
14
14
  // =============================================================================
15
15
  // DateRangeFilterField — the common filter composition over DateFilter:
@@ -35,8 +35,8 @@ export interface DateRangeFilterFieldProps {
35
35
  includeTime?: boolean;
36
36
  /** Translated labels (presets + footer + placeholder). Defaults to English. */
37
37
  labels?: Partial<DateRangeFilterFieldLabels>;
38
- /** BCP-47 locale for the calendar + trigger date display. Date display defaults to
39
- * the kit's home market (vi-VN, via `formatDate`) when unset. */
38
+ /** BCP-47 locale for the calendar + trigger date display. Defaults to the active
39
+ * `LoticsLocaleProvider` locale. */
40
40
  locale?: string;
41
41
  testID?: string;
42
42
  }
@@ -110,12 +110,13 @@ function formatTrigger(value: DateFilterValue, includeTime: boolean, locale: str
110
110
 
111
111
  export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
112
112
  const { value, onValueChange, includeTime, locale, testID } = props;
113
+ const localeTag = useLocaleTag(locale);
113
114
  const loc = useLoticsLocale().dateRange;
114
115
  const labels = useMemo(() => ({ ...loc, ...props.labels }), [loc, props.labels]);
115
116
  const [open, setOpen] = useState(false);
116
117
 
117
118
  const hasValue = Boolean(value.start.date || value.end.date);
118
- const display = formatTrigger(value, Boolean(includeTime), locale) || labels.placeholder;
119
+ const display = formatTrigger(value, Boolean(includeTime), localeTag) || labels.placeholder;
119
120
 
120
121
  return (
121
122
  <Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
@@ -150,7 +151,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
150
151
  onValueChange={onValueChange}
151
152
  includeTime={includeTime}
152
153
  labels={props.labels}
153
- locale={locale}
154
+ locale={localeTag}
154
155
  />
155
156
  <PopoverFooter align="space-between">
156
157
  {hasValue ? <Button title={labels.clear} onPress={() => onValueChange(EMPTY_VALUE)} /> : <View />}
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { resolveLocaleTag, en as enLocale, vi as viLocale } from "./locale";
2
3
  import {
3
4
  getDateLayout,
4
5
  getTimeLayout,
@@ -241,6 +242,41 @@ describe("placeholderFor", () => {
241
242
  });
242
243
  });
243
244
 
245
+ describe("locale-driven default segment order (LoticsLocaleProvider)", () => {
246
+ // The provider pack carries a BCP-47 tag; a wired date field defaults its
247
+ // segment order to that tag when no explicit `locale` prop is given. These
248
+ // assert the end-to-end contract — provider pack → rendered part ORDER —
249
+ // through the same resolveLocaleTag + getDateLayout the components compose.
250
+ // Before this, the field hardcoded "en-US", so a vi-wrapped app showed
251
+ // MM/DD/YYYY unless every call site threaded a `locale` prop.
252
+ it("a vi provider yields dd/MM/yyyy by default (no explicit locale)", () => {
253
+ const tag = resolveLocaleTag(viLocale);
254
+ expect(tag).toBe("vi-VN");
255
+ expect(fieldOrder(getDateLayout(tag, false))).toEqual(["day", "month", "year"]);
256
+ });
257
+
258
+ it("an en provider yields MM/dd/yyyy by default", () => {
259
+ const tag = resolveLocaleTag(enLocale);
260
+ expect(tag).toBe("en-US");
261
+ expect(fieldOrder(getDateLayout(tag, false))).toEqual(["month", "day", "year"]);
262
+ });
263
+
264
+ it("an explicit locale prop overrides the provider pack, both directions", () => {
265
+ // Under a vi provider, an author passing en-US still gets MM/dd/yyyy…
266
+ expect(fieldOrder(getDateLayout(resolveLocaleTag(viLocale, "en-US"), false))).toEqual([
267
+ "month",
268
+ "day",
269
+ "year",
270
+ ]);
271
+ // …and an en provider with an explicit vi-VN gets dd/MM/yyyy.
272
+ expect(fieldOrder(getDateLayout(resolveLocaleTag(enLocale, "vi-VN"), false))).toEqual([
273
+ "day",
274
+ "month",
275
+ "year",
276
+ ]);
277
+ });
278
+ });
279
+
244
280
  describe("getTimeLayout", () => {
245
281
  it("is hour/minute(+AM/PM) only, locale-driven", () => {
246
282
  expect(fieldOrder(getTimeLayout("en-US"))).toEqual(["hour", "minute", "dayPeriod"]);
@@ -11,7 +11,7 @@ import { formatDate } from "./format_date";
11
11
  import { ActivityIndicator } from "./activity_indicator";
12
12
  import { type InlineEditVariant, InlineEditView } from "./inline_edit";
13
13
  import { type TextColor } from "./text_utils";
14
- import { useLoticsLocale } from "./locale";
14
+ import { useLoticsLocale, useLocaleTag } from "./locale";
15
15
  import { getInteractionModality } from "./interaction_modality";
16
16
  import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
17
17
 
@@ -31,7 +31,8 @@ export interface InlineDatePickerProps {
31
31
  * until a time is added). Ignored when `format` is "datetime" (time always on). */
32
32
  optionalTime?: boolean;
33
33
  placeholder?: string;
34
- /** BCP-47 locale for the calendar, the displayed date, and the typed segment order. */
34
+ /** BCP-47 locale for the calendar, the displayed date, and the typed segment
35
+ * order. Defaults to the active `LoticsLocaleProvider` locale. */
35
36
  locale?: string;
36
37
  disabled?: boolean;
37
38
  accessibilityLabel?: string;
@@ -62,6 +63,9 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
62
63
  const locales = useLoticsLocale();
63
64
  const inlineLabels = locales.inline;
64
65
  const dateLabels = locales.datePicker;
66
+ // The calendar, the resting display, and the typed segments must all agree —
67
+ // resolve one tag (explicit prop, else the provider) and thread it to all three.
68
+ const localeTag = useLocaleTag(locale);
65
69
 
66
70
  const [editing, setEditing] = useState(false);
67
71
  const [open, setOpen] = useState(false);
@@ -224,7 +228,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
224
228
  // resting view follows the stored value, the segments follow the session draft
225
229
  // (the panel's "Add time" grows the segments live).
226
230
  const showTime = optionalTime ? isoHasTime(value) : format === "datetime";
227
- const display = formatDate(value, { time: showTime, locale, emptyLabel: "" });
231
+ const display = formatDate(value, { time: showTime, locale: localeTag, emptyLabel: "" });
228
232
  const fieldHasTime = optionalTime ? isoHasTime(draft) : format === "datetime";
229
233
 
230
234
  const trailing = saving ? (
@@ -248,7 +252,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
248
252
  onPartChange={(_, next) => updateDraft(next)}
249
253
  hasTime={fieldHasTime}
250
254
  segmentLabels={dateLabels}
251
- locale={locale}
255
+ locale={localeTag}
252
256
  disabled={disabled}
253
257
  placeholder={placeholder}
254
258
  partLabels={accessibilityLabel ? [accessibilityLabel] : undefined}
@@ -284,7 +288,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
284
288
  onValueChange={updateDraft}
285
289
  format={format}
286
290
  optionalTime={optionalTime}
287
- locale={locale}
291
+ locale={localeTag}
288
292
  // Commit on panel close. A single-date pick and the "Today" button
289
293
  // auto-close through here — a bare setOpen(false) would be a
290
294
  // controlled close the Popover never reports, silently dropping it.
package/src/locale.tsx CHANGED
@@ -20,9 +20,18 @@ import { type FindingLabels } from "./finding";
20
20
  *
21
21
  * Resolution order in every wired component: an explicit per-instance prop wins,
22
22
  * else this locale (from `LoticsLocaleProvider`, default `en`), else nothing —
23
- * the locale is always complete, so there is no third fallback to forget.
23
+ * the locale is always complete, so there is no third fallback to forget. The
24
+ * same order governs the {@link LoticsLocale.bcp47} tag via {@link useLocaleTag}.
24
25
  */
25
26
  export interface LoticsLocale {
27
+ /** The BCP-47 language tag this pack renders in (`en` → `"en-US"`, `vi` →
28
+ * `"vi-VN"`). This is what makes a date control follow the active locale
29
+ * WITHOUT the author threading a `locale` prop: it drives every `Intl`-derived
30
+ * rendering the kit does — the segmented date field's part ORDER (dd/MM/yyyy
31
+ * vs MM/dd/yyyy) and 12/24h, the calendar's weekday/month names, and
32
+ * `formatDate` display. A per-instance `locale` prop still overrides it
33
+ * (resolved through {@link useLocaleTag} / {@link resolveLocaleTag}). */
34
+ bcp47: string;
26
35
  /** `Pagination` range + page summaries and the prev/next tooltips. */
27
36
  pagination: Required<PaginationLabels>;
28
37
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
@@ -127,6 +136,7 @@ export interface LoticsLocale {
127
136
  /** The platform default — English. Every component's hardcoded default lives
128
137
  * HERE now, so the strings are documented in one place and overridable. */
129
138
  export const en: LoticsLocale = {
139
+ bcp47: "en-US",
130
140
  pagination: {
131
141
  rangeWithTotal: (start, end, total) => `${start}–${end} of ${total.toLocaleString()}`,
132
142
  range: (start, end) => `${start}–${end}`,
@@ -221,6 +231,7 @@ export const en: LoticsLocale = {
221
231
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
222
232
  * canonical translation — no per-app drift ("Chọn tất cả" vs "Chọn hết"). */
223
233
  export const vi: LoticsLocale = {
234
+ bcp47: "vi-VN",
224
235
  pagination: {
225
236
  rangeWithTotal: (start, end, total) => `${start}–${end} trên ${total.toLocaleString("vi-VN")}`,
226
237
  range: (start, end) => `${start}–${end}`,
@@ -344,3 +355,24 @@ export function LoticsLocaleProvider(props: LoticsLocaleProviderProps) {
344
355
  export function useLoticsLocale(): LoticsLocale {
345
356
  return useContext(LoticsLocaleContext);
346
357
  }
358
+
359
+ /**
360
+ * Resolve the effective BCP-47 tag for a locale-aware control: an explicit
361
+ * per-instance `locale` wins, else the pack's own {@link LoticsLocale.bcp47}.
362
+ * The pure core of {@link useLocaleTag}, kept RN-free so the resolution — and
363
+ * the segment ORDER it drives — is unit-testable without rendering a component.
364
+ */
365
+ export function resolveLocaleTag(locale: LoticsLocale, explicit?: string): string {
366
+ return explicit ?? locale.bcp47;
367
+ }
368
+
369
+ /**
370
+ * The BCP-47 tag a date / time control should render in: the explicit `locale`
371
+ * prop when given, else the active `LoticsLocaleProvider` pack's tag. Wired date
372
+ * controls default their `locale` through this, so wrapping an app in the `vi`
373
+ * pack yields dd/MM/yyyy segment order, Vietnamese calendar names, and Vietnamese
374
+ * display with no per-instance prop — while an explicit `locale` still wins.
375
+ */
376
+ export function useLocaleTag(explicit?: string): string {
377
+ return resolveLocaleTag(useContext(LoticsLocaleContext), explicit);
378
+ }
package/src/vite.mjs CHANGED
@@ -64,4 +64,10 @@ export const loticsOptimizeDeps = [
64
64
  "react-dom",
65
65
  "react-dom/client",
66
66
  "nullthrows",
67
+ // FilePreview's Excel/CSV engine (@lotics/xlsx) lazy-imports these CJS formula
68
+ // libraries; without pre-bundling, the dev optimizer can't resolve their CJS→ESM
69
+ // default export and the in-app spreadsheet preview fails (dev-server only — the
70
+ // rollup prod build handles the CJS interop).
71
+ "fast-formula-parser",
72
+ "@formulajs/formulajs",
67
73
  ];