@lotics/ui 41.4.1 → 42.0.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/MIGRATION.md CHANGED
@@ -4,6 +4,40 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 42.0.0 — `DateFilter`'s presets are a listbox, and "Custom" is not one of them
8
+
9
+ Three defects with one root: the preset row used EMPHASIS to mean "current state", inside a
10
+ panel where emphasis means "action".
11
+
12
+ **The active preset was painted like a primary button.** `zinc.800` with inverted text — the
13
+ loudest treatment the system has — spent on a row that reports state rather than offering one.
14
+ It outshouted the panel's own Done button. It now uses `MenuButton`'s `selected`, the kit's
15
+ listbox row: a `zinc.100` resting highlight. That prop also carries `aria-selected`, which the
16
+ hand-rolled fill never did — until now the active preset announced **nothing**, so which period
17
+ was selected was information available only to someone who could see the fill.
18
+
19
+ **The panel rendered its phone layout on desktop.** It sized itself with `useContainerSize()`,
20
+ which inside a `Popover` measures the popover BODY — and the body's width is decided by the
21
+ branch that reading picks. Two calendars come to ~617px, always under the 768 phone threshold,
22
+ so the panel resolved "small" on a 1440px viewport and its sidebar branch was unreachable: the
23
+ presets collapsed into a horizontal strip of bare text where the only item with any chrome was
24
+ the active one. It now reads `useScreenSize()` — the same instrument, and for the same reason,
25
+ that `Popover` already uses to choose sheet vs anchored.
26
+
27
+ **`"custom"` is gone from `PresetId`, `PRESET_IDS`, and `DateFilterLabels`.** It was the one
28
+ entry that set no range: it CLEARED the value. So it lit up whenever the range matched no
29
+ preset — the normal state for any hand-picked range, and the opening state of any app whose
30
+ default period is not a preset — and clicking the lit row, the obvious move for "let me pick my
31
+ own dates", threw the range away. The calendar is the custom picker and the footer's Clear is
32
+ the clear.
33
+
34
+ `getPresetValue` is now total: it returns `DateFilterValue`, never `| null`.
35
+
36
+ **To migrate:** delete `custom` from any `DateFilterLabels` / `DateRangeFilterFieldLabels`
37
+ object you pass — it is an excess property now and the compiler will point at it. If you called
38
+ `getPresetValue` and branched on `null`, drop the branch. Nothing else changes: a range that
39
+ matches no preset simply leaves every row unselected, which is what it always meant.
40
+
7
41
  ## 41.4.0 — embedded markdown is demoted in the OUTLINE, not only on the type ladder
8
42
 
9
43
  `<Markdown variant="embedded">` sized its headings down and left them as the tags the author
package/docs/catalog.md CHANGED
@@ -857,6 +857,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
857
857
  for it bare only when the calendar lives permanently on the surface, not behind a field.
858
858
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
859
859
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
860
+ **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
861
+ range selects nothing rather than lighting a "custom" row whose click cleared the value. The
862
+ presets are a `listbox` of `MenuButton` options: selection is the kit's `zinc.100` row
863
+ highlight plus `aria-selected`, never a filled primary treatment, because the row reports
864
+ state rather than offering an action. Sidebar at viewports of 768 and up, strip below —
865
+ measured on the VIEWPORT, since a popover's own width is decided by that very branch.
860
866
  **One selection rule, no modes**: a click OPENS a range, the next CLOSES it (in either
861
867
  order — bounds come back sorted), and a single day is the same day clicked twice
862
868
  (`start`/`end` equal — which is how a consumer detects "one day" and how the trigger
@@ -98,7 +98,7 @@ const KY_CUOI = "2026-06";
98
98
  const NHAN_BO_LOC: Partial<DateRangeFilterFieldLabels> = {
99
99
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
100
100
  today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow", thisWeek: "This week",
101
- thisMonth: "This month", lastMonth: "Last month", custom: "Custom", from: "From", to: "To",
101
+ thisMonth: "This month", lastMonth: "Last month", from: "From", to: "To",
102
102
  selectDateRange: "Select date range", selectDate: "Select date",
103
103
  clear: "Clear", done: "Done", placeholder: "All time",
104
104
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "41.4.1",
3
+ "version": "42.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -6,7 +6,7 @@ import { CONTROL_RADIUS } from "./control_surface";
6
6
  import { MenuButton } from "./menu_button";
7
7
  import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
8
8
  import { TimePicker } from "./time_picker";
9
- import { useContainerSize } from "./size_boundary";
9
+ import { useScreenSize } from "./use_screen_size";
10
10
  import { SegmentLabels } from "./date_segments";
11
11
  import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
12
12
  import { formatDate } from "./format_date";
@@ -33,7 +33,6 @@ export interface DateFilterLabels extends SegmentLabels {
33
33
  thisWeek: string;
34
34
  thisMonth: string;
35
35
  lastMonth: string;
36
- custom: string;
37
36
  from: string;
38
37
  to: string;
39
38
  /** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
@@ -72,8 +71,6 @@ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
72
71
  return labels.thisMonth;
73
72
  case "last_month":
74
73
  return labels.lastMonth;
75
- case "custom":
76
- return labels.custom;
77
74
  }
78
75
  }
79
76
 
@@ -134,7 +131,21 @@ export function DateFilter(props: DateFilterProps) {
134
131
  () => ({ ...loc, ...props.labels }),
135
132
  [loc, props.labels],
136
133
  );
137
- const screenSize = useContainerSize();
134
+ // The VIEWPORT, never this panel's own box.
135
+ //
136
+ // This read `useContainerSize()`, which inside a `Popover` measures the popover
137
+ // BODY — and the body's width is decided by which branch this very line picks.
138
+ // Two calendars come to ~617px, always under the 768 phone threshold, so the
139
+ // panel resolved "small" on a 1440px desktop and the sidebar branch below was
140
+ // unreachable: the presets rendered as the phone strip on every screen, which
141
+ // is why the only item in them with any chrome read as a button.
142
+ //
143
+ // `Popover` already carries this rule for its own sheet-vs-anchored decision,
144
+ // and its comment names the same bug being introduced by the same hook. Same
145
+ // instrument here, so the two agree: under 768 the popover is a bottom sheet
146
+ // and the presets are a strip; at or above it, an anchored panel with the
147
+ // sidebar.
148
+ const screenSize = useScreenSize();
138
149
  const calendarRef = useRef<CalendarRef>(null);
139
150
 
140
151
  // A half-picked range is held HERE and never emitted — the same rule
@@ -192,13 +203,15 @@ export function DateFilter(props: DateFilterProps) {
192
203
  [onValueChange, value],
193
204
  );
194
205
 
195
- // Which preset (if any) the current value matches.
206
+ // Which preset (if any) the current value matches. A hand-picked range matches
207
+ // none, and that is `null` — no row is selected. It used to fall through to
208
+ // "custom", so the panel opened with a row lit for a range the member had
209
+ // never chosen from this list.
196
210
  const activePresetId = useMemo((): PresetId | null => {
197
211
  if (!value.start.date && !value.end.date) return null;
198
212
  const now = new Date();
199
213
  for (const id of PRESET_IDS) {
200
214
  const presetValue = getPresetValue(id, now);
201
- if (!presetValue) continue; // "custom"
202
215
  if (
203
216
  value.start.date?.toDateString() === presetValue.start.date?.toDateString() &&
204
217
  value.end.date?.toDateString() === presetValue.end.date?.toDateString()
@@ -206,7 +219,7 @@ export function DateFilter(props: DateFilterProps) {
206
219
  return id;
207
220
  }
208
221
  }
209
- return "custom";
222
+ return null;
210
223
  }, [value]);
211
224
 
212
225
  const handlePresetSelect = useCallback(
@@ -220,12 +233,6 @@ export function DateFilter(props: DateFilterProps) {
220
233
  }
221
234
 
222
235
  const presetValue = getPresetValue(id, new Date());
223
- if (!presetValue) {
224
- // "Custom" clears the value so the user can select manually
225
- onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
226
- return;
227
- }
228
-
229
236
  onValueChange(presetValue);
230
237
 
231
238
  if (presetValue.start.date) {
@@ -238,27 +245,39 @@ export function DateFilter(props: DateFilterProps) {
238
245
  [onValueChange, activePresetId],
239
246
  );
240
247
 
241
- const renderPreset = (id: PresetId) => {
242
- const isActive = activePresetId === id;
243
- return (
244
- <MenuButton
245
- key={id}
246
- title={
247
- <Text size="sm" color={isActive ? "inverted" : "default"}>
248
- {presetLabel(id, labels)}
249
- </Text>
250
- }
251
- onPress={() => handlePresetSelect(id)}
252
- style={[styles.presetItem, isActive && styles.presetItemActive]}
253
- />
254
- );
255
- };
248
+ // `selected` + `role="option"` is the kit's listbox row, and `MenuButton` owns
249
+ // both halves of it: the resting `zinc.100` highlight and the `aria-selected`
250
+ // that carries the state to a screen reader.
251
+ //
252
+ // This used to paint `zinc.800` with inverted text and pass neither. That is
253
+ // the PRIMARY BUTTON treatment — the loudest thing the system has, spent on a
254
+ // row that reports state rather than offering an action, so the selected
255
+ // preset outshouted the panel's own Done button. And because no `selected` or
256
+ // `current` ever reached `MenuButton`, the active preset announced nothing at
257
+ // all: the state was visible only to someone who could see the fill.
258
+ const renderPreset = (id: PresetId) => (
259
+ <MenuButton
260
+ key={id}
261
+ role="option"
262
+ selected={activePresetId === id}
263
+ title={<Text size="sm">{presetLabel(id, labels)}</Text>}
264
+ onPress={() => handlePresetSelect(id)}
265
+ style={styles.presetItem}
266
+ />
267
+ );
256
268
 
257
269
  return (
258
270
  <View style={[styles.container, { flexDirection: screenSize.small ? "column" : "row" }]}>
259
271
  {/* Presets sidebar (desktop only) */}
260
272
  {!screenSize.small && (
261
- <View style={styles.presetsSidebar}>{PRESET_IDS.map(renderPreset)}</View>
273
+ <View
274
+ style={styles.presetsSidebar}
275
+ accessibilityLabel={labels.selectDateRange}
276
+ // `listbox` is valid ARIA but absent from RN's Role enum; rn-web forwards it.
277
+ role={"listbox" as "list"}
278
+ >
279
+ {PRESET_IDS.map(renderPreset)}
280
+ </View>
262
281
  )}
263
282
 
264
283
  {/* Calendar and time pickers */}
@@ -322,7 +341,11 @@ const styles = StyleSheet.create({
322
341
  gap: 16,
323
342
  },
324
343
  presetsSidebar: {
325
- minWidth: 140,
344
+ // 128, not 140: the calendars come to ~617px and a popover is only anchored
345
+ // (rather than a bottom sheet) at viewports of 768 and up, so the sidebar
346
+ // has ~135px to spend before the panel is wider than the narrowest screen
347
+ // that shows it. The longest preset label sits well inside 128.
348
+ minWidth: 128,
326
349
  },
327
350
  presetsScrollView: {
328
351
  borderTopWidth: 1,
@@ -337,9 +360,6 @@ const styles = StyleSheet.create({
337
360
  marginHorizontal: 0,
338
361
  marginBottom: 2,
339
362
  },
340
- presetItemActive: {
341
- backgroundColor: colors.zinc["800"],
342
- },
343
363
  mainContent: {
344
364
  flex: 1,
345
365
  },
@@ -5,7 +5,6 @@ import type { DateFilterValue } from "./date_filter";
5
5
  // `now`, never an ambient clock.
6
6
 
7
7
  export type PresetId =
8
- | "custom"
9
8
  | "today"
10
9
  | "yesterday"
11
10
  | "tomorrow"
@@ -13,7 +12,17 @@ export type PresetId =
13
12
  | "this_month"
14
13
  | "last_month";
15
14
 
16
- /** Display order. "custom" carries no range — it clears the value. */
15
+ /**
16
+ * Display order. Every id here SETS a range — that is what makes the list a set
17
+ * of presets rather than a set of modes.
18
+ *
19
+ * "custom" used to sit at the end and it was the one entry that set nothing: it
20
+ * CLEARED the value. So it lit up whenever the range matched no preset — which
21
+ * is the normal state for any hand-picked range — and clicking the lit item,
22
+ * the obvious move for "let me pick my own dates", threw the range away. The
23
+ * calendar above it is the custom picker, and the panel's Clear button is the
24
+ * clear; the item was a third name for two controls that were already there.
25
+ */
17
26
  export const PRESET_IDS: PresetId[] = [
18
27
  "today",
19
28
  "yesterday",
@@ -21,7 +30,6 @@ export const PRESET_IDS: PresetId[] = [
21
30
  "this_week",
22
31
  "this_month",
23
32
  "last_month",
24
- "custom",
25
33
  ];
26
34
 
27
35
  function startOfDay(date: Date): Date {
@@ -66,12 +74,11 @@ function range(start: Date, end: Date): DateFilterValue {
66
74
  }
67
75
 
68
76
  /**
69
- * Resolve a preset to a concrete date range relative to `now`. Returns `null`
70
- * for "custom" (no range the caller clears the value so the user picks
71
- * manually). The boundary math is identical to the view-page filter so a
72
- * resolved range round-trips back to the same preset.
77
+ * Resolve a preset to a concrete date range relative to `now`. Total — every
78
+ * `PresetId` names a range. The boundary math is identical to the view-page
79
+ * filter so a resolved range round-trips back to the same preset.
73
80
  */
74
- export function getPresetValue(id: PresetId, now: Date): DateFilterValue | null {
81
+ export function getPresetValue(id: PresetId, now: Date): DateFilterValue {
75
82
  switch (id) {
76
83
  case "today":
77
84
  return range(startOfDay(now), endOfDay(now));
@@ -94,7 +101,5 @@ export function getPresetValue(id: PresetId, now: Date): DateFilterValue | null
94
101
  d.setMonth(d.getMonth() - 1);
95
102
  return range(startOfMonth(d), endOfMonth(d));
96
103
  }
97
- case "custom":
98
- return null;
99
104
  }
100
105
  }
package/src/locale.tsx CHANGED
@@ -308,7 +308,7 @@ export const en: LoticsLocale = {
308
308
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
309
309
  today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow",
310
310
  thisWeek: "This week", thisMonth: "This month", lastMonth: "Last month",
311
- custom: "Custom", from: "From", to: "To",
311
+ from: "From", to: "To",
312
312
  selectDateRange: "Select date range", selectDate: "Select date",
313
313
  clear: "Clear", done: "Done", placeholder: "All time",
314
314
  },
@@ -479,7 +479,7 @@ export const vi: LoticsLocale = {
479
479
  year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
480
480
  today: "Hôm nay", yesterday: "Hôm qua", tomorrow: "Ngày mai",
481
481
  thisWeek: "Tuần này", thisMonth: "Tháng này", lastMonth: "Tháng trước",
482
- custom: "Tùy chọn", from: "Từ", to: "Đến",
482
+ from: "Từ", to: "Đến",
483
483
  selectDateRange: "Chọn khoảng ngày", selectDate: "Chọn ngày",
484
484
  clear: "Xóa", done: "Xong", placeholder: "Tất cả thời gian",
485
485
  },