@lotics/ui 41.4.1 → 42.3.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/src/colors.ts CHANGED
@@ -1,386 +1,17 @@
1
- const palette = {
2
- black: "rgba(0,0,0,1)",
3
- white: "rgba(255,255,255,1)",
4
-
5
- // We use RGBA so that we can use React Native Animations with colors
6
- red: {
7
- 50: "rgba(254, 242, 242, 1)",
8
- 100: "rgba(254, 226, 226, 1)",
9
- 200: "rgba(254, 202, 202, 1)",
10
- 300: "rgba(252, 165, 165, 1)",
11
- 400: "rgba(248, 113, 113, 1)",
12
- 500: "rgba(239, 68, 68, 1)",
13
- 600: "rgba(220, 38, 38, 1)",
14
- 700: "rgba(185, 28, 28, 1)",
15
- 800: "rgba(153, 27, 27, 1)",
16
- 900: "rgba(127, 29, 29, 1)",
17
- 950: "rgba(69, 10, 10, 1)",
18
- },
19
- orange: {
20
- 50: "rgba(255, 247, 237, 1)",
21
- 100: "rgba(255, 237, 213, 1)",
22
- 200: "rgba(254, 215, 170, 1)",
23
- 300: "rgba(253, 186, 116, 1)",
24
- 400: "rgba(251, 146, 60, 1)",
25
- 500: "rgba(249, 115, 22, 1)",
26
- 600: "rgba(234, 88, 12, 1)",
27
- 700: "rgba(194, 65, 12, 1)",
28
- 800: "rgba(154, 52, 18, 1)",
29
- 900: "rgba(124, 45, 18, 1)",
30
- 950: "rgba(67, 20, 7, 1)",
31
- },
32
- amber: {
33
- 50: "rgba(255, 251, 235, 1)",
34
- 100: "rgba(254, 243, 199, 1)",
35
- 200: "rgba(253, 230, 138, 1)",
36
- 300: "rgba(252, 211, 77, 1)",
37
- 400: "rgba(251, 191, 36, 1)",
38
- 500: "rgba(245, 158, 11, 1)",
39
- 600: "rgba(217, 119, 6, 1)",
40
- 700: "rgba(180, 83, 9, 1)",
41
- 800: "rgba(146, 64, 14, 1)",
42
- 900: "rgba(120, 53, 15, 1)",
43
- 950: "rgba(69, 26, 3, 1)",
44
- },
45
- yellow: {
46
- 50: "rgba(254, 252, 232, 1)",
47
- 100: "rgba(254, 249, 195, 1)",
48
- 200: "rgba(254, 240, 138, 1)",
49
- 300: "rgba(253, 224, 71, 1)",
50
- 400: "rgba(250, 204, 21, 1)",
51
- 500: "rgba(234, 179, 8, 1)",
52
- 600: "rgba(202, 138, 4, 1)",
53
- 700: "rgba(161, 98, 7, 1)",
54
- 800: "rgba(133, 77, 14, 1)",
55
- 900: "rgba(113, 63, 18, 1)",
56
- 950: "rgba(66, 32, 6, 1)",
57
- },
58
- lime: {
59
- 50: "rgba(247, 254, 231, 1)",
60
- 100: "rgba(236, 252, 203, 1)",
61
- 200: "rgba(217, 249, 157, 1)",
62
- 300: "rgba(190, 242, 100, 1)",
63
- 400: "rgba(163, 230, 53, 1)",
64
- 500: "rgba(132, 204, 22, 1)",
65
- 600: "rgba(101, 163, 13, 1)",
66
- 700: "rgba(77, 124, 15, 1)",
67
- 800: "rgba(63, 98, 18, 1)",
68
- 900: "rgba(54, 83, 20, 1)",
69
- 950: "rgba(26, 46, 5, 1)",
70
- },
71
- green: {
72
- 50: "rgba(240, 253, 244, 1)",
73
- 100: "rgba(220, 252, 231, 1)",
74
- 200: "rgba(187, 247, 208, 1)",
75
- 300: "rgba(134, 239, 172, 1)",
76
- 400: "rgba(74, 222, 128, 1)",
77
- 500: "rgba(34, 197, 94, 1)",
78
- 600: "rgba(22, 163, 74, 1)",
79
- 700: "rgba(21, 128, 61, 1)",
80
- 800: "rgba(22, 101, 52, 1)",
81
- 900: "rgba(20, 83, 45, 1)",
82
- 950: "rgba(5, 46, 22, 1)",
83
- },
84
- emerald: {
85
- 50: "rgba(236, 253, 245, 1)",
86
- 100: "rgba(209, 250, 229, 1)",
87
- 200: "rgba(167, 243, 208, 1)",
88
- 300: "rgba(110, 231, 183, 1)",
89
- 400: "rgba(52, 211, 153, 1)",
90
- 500: "rgba(16, 185, 129, 1)",
91
- 600: "rgba(5, 150, 105, 1)",
92
- 700: "rgba(4, 120, 87, 1)",
93
- 800: "rgba(6, 95, 70, 1)",
94
- 900: "rgba(6, 78, 59, 1)",
95
- 950: "rgba(2, 44, 34, 1)",
96
- },
97
- teal: {
98
- 50: "rgba(240, 253, 250, 1)",
99
- 100: "rgba(204, 251, 241, 1)",
100
- 200: "rgba(153, 246, 228, 1)",
101
- 300: "rgba(94, 234, 212, 1)",
102
- 400: "rgba(45, 212, 191, 1)",
103
- 500: "rgba(20, 184, 166, 1)",
104
- 600: "rgba(13, 148, 136, 1)",
105
- 700: "rgba(15, 118, 110, 1)",
106
- 800: "rgba(17, 94, 89, 1)",
107
- 900: "rgba(19, 78, 74, 1)",
108
- 950: "rgba(4, 47, 46, 1)",
109
- },
110
- cyan: {
111
- 50: "rgba(236, 254, 255, 1)",
112
- 100: "rgba(207, 250, 254, 1)",
113
- 200: "rgba(165, 243, 252, 1)",
114
- 300: "rgba(103, 232, 249, 1)",
115
- 400: "rgba(34, 211, 238, 1)",
116
- 500: "rgba(6, 182, 212, 1)",
117
- 600: "rgba(8, 145, 178, 1)",
118
- 700: "rgba(14, 116, 144, 1)",
119
- 800: "rgba(21, 94, 117, 1)",
120
- 900: "rgba(22, 78, 99, 1)",
121
- 950: "rgba(8, 51, 68, 1)",
122
- },
123
- sky: {
124
- 50: "rgba(240, 249, 255, 1)",
125
- 100: "rgba(224, 242, 254, 1)",
126
- 200: "rgba(186, 230, 253, 1)",
127
- 300: "rgba(125, 211, 252, 1)",
128
- 400: "rgba(56, 189, 248, 1)",
129
- 500: "rgba(14, 165, 233, 1)",
130
- 600: "rgba(2, 132, 199, 1)",
131
- 700: "rgba(3, 105, 161, 1)",
132
- 800: "rgba(7, 89, 133, 1)",
133
- 900: "rgba(12, 74, 110, 1)",
134
- 950: "rgba(8, 47, 73, 1)",
135
- },
136
- blue: {
137
- 50: "rgba(239, 246, 255, 1)",
138
- 100: "rgba(219, 234, 254, 1)",
139
- 200: "rgba(191, 219, 254, 1)",
140
- 300: "rgba(147, 197, 253, 1)",
141
- 400: "rgba(96, 165, 250, 1)",
142
- 500: "rgba(59, 130, 246, 1)",
143
- 600: "rgba(37, 99, 235, 1)",
144
- 700: "rgba(29, 78, 216, 1)",
145
- 800: "rgba(30, 64, 175, 1)",
146
- 900: "rgba(30, 58, 138, 1)",
147
- 950: "rgba(23, 37, 84, 1)",
148
- },
149
- indigo: {
150
- 50: "rgba(238, 242, 255, 1)",
151
- 100: "rgba(224, 231, 255, 1)",
152
- 200: "rgba(199, 210, 254, 1)",
153
- 300: "rgba(165, 180, 252, 1)",
154
- 400: "rgba(129, 140, 248, 1)",
155
- 500: "rgba(99, 102, 241, 1)",
156
- 600: "rgba(79, 70, 229, 1)",
157
- 700: "rgba(67, 56, 202, 1)",
158
- 800: "rgba(55, 48, 163, 1)",
159
- 900: "rgba(49, 46, 129, 1)",
160
- 950: "rgba(30, 27, 75, 1)",
161
- },
162
- violet: {
163
- 50: "rgba(245, 243, 255, 1)",
164
- 100: "rgba(237, 233, 254, 1)",
165
- 200: "rgba(221, 214, 254, 1)",
166
- 300: "rgba(196, 181, 253, 1)",
167
- 400: "rgba(167, 139, 250, 1)",
168
- 500: "rgba(139, 92, 246, 1)",
169
- 600: "rgba(124, 58, 237, 1)",
170
- 700: "rgba(109, 40, 217, 1)",
171
- 800: "rgba(91, 33, 182, 1)",
172
- 900: "rgba(76, 29, 149, 1)",
173
- 950: "rgba(46, 16, 101, 1)",
174
- },
175
- purple: {
176
- 50: "rgba(250, 245, 255, 1)",
177
- 100: "rgba(243, 232, 255, 1)",
178
- 200: "rgba(233, 213, 255, 1)",
179
- 300: "rgba(216, 180, 254, 1)",
180
- 400: "rgba(192, 132, 252, 1)",
181
- 500: "rgba(168, 85, 247, 1)",
182
- 600: "rgba(147, 51, 234, 1)",
183
- 700: "rgba(126, 34, 206, 1)",
184
- 800: "rgba(107, 33, 168, 1)",
185
- 900: "rgba(88, 28, 135, 1)",
186
- 950: "rgba(59, 7, 100, 1)",
187
- },
188
- fuchsia: {
189
- 50: "rgba(253, 244, 255, 1)",
190
- 100: "rgba(250, 232, 255, 1)",
191
- 200: "rgba(245, 208, 254, 1)",
192
- 300: "rgba(240, 171, 252, 1)",
193
- 400: "rgba(232, 121, 249, 1)",
194
- 500: "rgba(217, 70, 239, 1)",
195
- 600: "rgba(192, 38, 211, 1)",
196
- 700: "rgba(162, 28, 175, 1)",
197
- 800: "rgba(134, 25, 143, 1)",
198
- 900: "rgba(112, 26, 117, 1)",
199
- 950: "rgba(74, 4, 78, 1)",
200
- },
201
- pink: {
202
- 50: "rgba(253, 242, 248, 1)",
203
- 100: "rgba(252, 231, 243, 1)",
204
- 200: "rgba(251, 207, 232, 1)",
205
- 300: "rgba(249, 168, 212, 1)",
206
- 400: "rgba(244, 114, 182, 1)",
207
- 500: "rgba(236, 72, 153, 1)",
208
- 600: "rgba(219, 39, 119, 1)",
209
- 700: "rgba(190, 24, 93, 1)",
210
- 800: "rgba(157, 23, 77, 1)",
211
- 900: "rgba(131, 24, 67, 1)",
212
- 950: "rgba(80, 7, 36, 1)",
213
- },
214
- rose: {
215
- 50: "rgba(255, 241, 242, 1)",
216
- 100: "rgba(255, 228, 230, 1)",
217
- 200: "rgba(254, 205, 211, 1)",
218
- 300: "rgba(253, 164, 175, 1)",
219
- 400: "rgba(251, 113, 133, 1)",
220
- 500: "rgba(244, 63, 94, 1)",
221
- 600: "rgba(225, 29, 72, 1)",
222
- 700: "rgba(190, 18, 60, 1)",
223
- 800: "rgba(159, 18, 57, 1)",
224
- 900: "rgba(136, 19, 55, 1)",
225
- 950: "rgba(76, 5, 25, 1)",
226
- },
227
- slate: {
228
- 50: "rgba(248, 250, 252, 1)",
229
- 100: "rgba(241, 245, 249, 1)",
230
- 200: "rgba(226, 232, 240, 1)",
231
- 300: "rgba(203, 213, 225, 1)",
232
- 400: "rgba(148, 163, 184, 1)",
233
- 500: "rgba(100, 116, 139, 1)",
234
- 600: "rgba(71, 85, 105, 1)",
235
- 700: "rgba(51, 65, 85, 1)",
236
- 800: "rgba(30, 41, 59, 1)",
237
- 900: "rgba(15, 23, 42, 1)",
238
- 950: "rgba(2, 6, 23, 1)",
239
- },
240
- gray: {
241
- 50: "rgba(249, 250, 251, 1)",
242
- 100: "rgba(243, 244, 246, 1)",
243
- 200: "rgba(229, 231, 235, 1)",
244
- 300: "rgba(209, 213, 219, 1)",
245
- 400: "rgba(156, 163, 175, 1)",
246
- 500: "rgba(107, 114, 128, 1)",
247
- 600: "rgba(75, 85, 99, 1)",
248
- 700: "rgba(55, 65, 81, 1)",
249
- 800: "rgba(31, 41, 55, 1)",
250
- 900: "rgba(17, 24, 39, 1)",
251
- 950: "rgba(3, 7, 18, 1)",
252
- },
253
- zinc: {
254
- 50: "rgba(250, 250, 250, 1)",
255
- 100: "rgba(244, 244, 245, 1)",
256
- 200: "rgba(228, 228, 231, 1)",
257
- 300: "rgba(212, 212, 216, 1)",
258
- 400: "rgba(161, 161, 170, 1)",
259
- 500: "rgba(113, 113, 122, 1)",
260
- 600: "rgba(82, 82, 91, 1)",
261
- 700: "rgba(63, 63, 70, 1)",
262
- 800: "rgba(39, 39, 42, 1)",
263
- 900: "rgba(24, 24, 27, 1)",
264
- 950: "rgba(9, 9, 11, 1)",
265
- },
266
- neutral: {
267
- 50: "rgba(250, 250, 250, 1)",
268
- 100: "rgba(245, 245, 245, 1)",
269
- 200: "rgba(229, 229, 229, 1)",
270
- 300: "rgba(212, 212, 212, 1)",
271
- 400: "rgba(163, 163, 163, 1)",
272
- 500: "rgba(115, 115, 115, 1)",
273
- 600: "rgba(82, 82, 82, 1)",
274
- 700: "rgba(64, 64, 64, 1)",
275
- 800: "rgba(38, 38, 38, 1)",
276
- 900: "rgba(23, 23, 23, 1)",
277
- 950: "rgba(10, 10, 10, 1)",
278
- },
279
- stone: {
280
- 50: "rgba(250, 250, 249, 1)",
281
- 100: "rgba(245, 245, 244, 1)",
282
- 200: "rgba(231, 229, 228, 1)",
283
- 300: "rgba(214, 211, 209, 1)",
284
- 400: "rgba(168, 162, 158, 1)",
285
- 500: "rgba(120, 113, 108, 1)",
286
- 600: "rgba(87, 83, 78, 1)",
287
- 700: "rgba(68, 64, 60, 1)",
288
- 800: "rgba(41, 37, 36, 1)",
289
- 900: "rgba(28, 25, 23, 1)",
290
- 950: "rgba(12, 10, 9, 1)",
291
- },
292
- };
293
-
294
- export const colors = {
295
- ...palette,
296
- border: palette.zinc["200"],
297
- border_shadow: `0px 1px 2px 0px ${palette.zinc["200"]}`,
298
- background: palette.white,
299
- shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
300
- };
301
-
302
- /**
303
- * A low-alpha wash of a palette color (hover tints, dimmed chart segments,
304
- * icon discs). Handles the palette's rgba() strings plus rgb()/#RRGGBB.
305
- */
306
- export function withAlpha(color: string, alpha: number): string {
307
- if (color.startsWith("rgba(")) return color.replace(/,\s*[\d.]+\s*\)\s*$/, `, ${alpha})`);
308
- if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(/\)\s*$/, `, ${alpha})`);
309
- if (color.startsWith("#") && color.length === 7) {
310
- return `${color}${Math.round(alpha * 255)
311
- .toString(16)
312
- .padStart(2, "0")}`;
313
- }
314
- return color;
315
- }
316
-
317
- /**
318
- * A palette FAMILY name — the single, semantic way to reference a color across
319
- * the system (Badge, status indicators, chart series, breakdown segments).
320
- * Reference colors by NAME and let the component resolve the shade it needs;
321
- * never thread a raw hex through props — it drifts (one call site picks 500,
322
- * another 600, and the same "status" renders two greens). Excludes the
323
- * non-scale role keys (border/background/shadow/black/white).
324
- */
325
- export type ColorName = Exclude<keyof typeof colors, "border" | "border_shadow" | "background" | "shadow" | "black" | "white">;
326
-
327
- /**
328
- * The canonical SOLID shade of a family (500) — status dots, status-grid
329
- * cells, breakdown segments, chart series. Defined ONCE so every indicator of
330
- * the same color agrees on the shade.
331
- */
332
- export function solid(name: ColorName): string {
333
- return colors[name][500];
334
- }
335
-
336
- /** A low-alpha wash of a family's solid shade — cell/segment tints, dimmed
337
- * states. `tint("emerald", 0.2)` === `withAlpha(solid("emerald"), 0.2)`. */
338
- export function tint(name: ColorName, alpha: number): string {
339
- return withAlpha(solid(name), alpha);
340
- }
341
-
342
- /** Dark → light stops a `ramp` spans (the usable mid range of a scale). */
343
- const RAMP_STOPS = [700, 600, 500, 400, 300, 200] as const;
344
-
345
- /**
346
- * `count` distinct shades of ONE family, strong → light — the monochrome
347
- * "one hue family per dimension" data ramp (a Breakdown's segments, an ordered
348
- * funnel, any multi-category breakdown of a single dimension). Use this for a
349
- * COHERENT dimension instead of hand-picking shades or scattering hues; the
350
- * segment LABEL carries identity, the shade only orders. (For semantic
351
- * categories whose color carries MEANING — status — give each its own
352
- * `ColorName` and `solid()` it, don't ramp.)
353
- */
354
- export function ramp(name: ColorName, count: number): string[] {
355
- const scale = colors[name];
356
- if (count <= 1) return [scale[600]];
357
- return Array.from({ length: count }, (_, i) => {
358
- const idx = Math.round((i / (count - 1)) * (RAMP_STOPS.length - 1));
359
- return scale[RAMP_STOPS[idx]];
360
- });
361
- }
362
-
363
- /**
364
- * Is `value` a usable {@link ColorName} — a palette FAMILY, not a role key
365
- * (`border`/`background`/…) and not `black`/`white`? A family resolves to a
366
- * shade object; the role/scalar keys resolve to a string, so "value is an
367
- * object" is the test (no name list to keep in sync).
368
- */
369
- export function isColorName(value: unknown): value is ColorName {
370
- return (
371
- typeof value === "string" &&
372
- value in colors &&
373
- typeof (colors as Record<string, unknown>)[value] === "object"
374
- );
375
- }
1
+ export * from "./color_tokens";
376
2
 
377
3
  /**
378
- * Coerce an arbitrary color token to a {@link ColorName}, falling back to a
379
- * neutral. The single graceful-degradation point for stored option/status
380
- * colors: a select option's `color` may be a token a newer table config
381
- * introduced that this UI build predates, or absent entirely — either way a
382
- * component renders a neutral badge instead of breaking. Used by `OptionBadge`.
4
+ * The NATIVE token surface the literal table, unchanged.
5
+ *
6
+ * The values themselves live in `./color_tokens`, which both this file and
7
+ * `./colors.web.ts` re-export. That indirection exists for one concrete reason:
8
+ * a relative `./colors` resolves `.web.ts` FIRST on web, so a web variant that
9
+ * imported `./colors` would import itself, and esbuild reports it as
10
+ * "Detected cycle while resolving import". Neither variant may name the other;
11
+ * both name a third module instead.
12
+ *
13
+ * Importers never see this split — `@lotics/ui/colors` and the kit's own
14
+ * `./colors` both keep working, and which of the two files answers is decided by
15
+ * platform (the `react-native` export condition, and `.web.ts` extension order
16
+ * inside the kit).
383
17
  */
384
- export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
385
- return isColorName(value) ? value : fallback;
386
- }
@@ -0,0 +1,43 @@
1
+ import { colors as contract } from "./color_tokens";
2
+
3
+ export { withAlpha, solid, tint, ramp, isColorName, asColorName } from "./color_tokens";
4
+ export type { ColorName } from "./color_tokens";
5
+
6
+ /**
7
+ * The token contract, with its FOUR themeable roles indirected through CSS
8
+ * variables so an app can carry its own visual identity.
9
+ *
10
+ * Why this file exists at all, rather than a React context: 136 of the kit's 320
11
+ * modules import `colors` directly, at module scope, into `StyleSheet.create`.
12
+ * A context would have to reach every one of them, turning static stylesheets
13
+ * into per-render inline styles — the kit's whole performance model — for a
14
+ * feature that changes four values. A variable costs nothing at runtime and
15
+ * touches no component: `colors.background` still reads `colors.background`.
16
+ *
17
+ * It is safe because react-native-web's `isWebColor` allowlists `var(` ahead of
18
+ * its own colour normalization, so a variable reaches CSS untouched on every
19
+ * colour property. That is a web-only guarantee, which is exactly why this is a
20
+ * `.web.ts` sibling: the `react-native` export condition and `.web.ts` extension
21
+ * order pick it for the browser; native keeps the literals it can parse.
22
+ *
23
+ * It reads its literals from `./color_tokens`, NEVER from `./colors` — on web a
24
+ * relative `./colors` resolves back to this very file, which esbuild reports as
25
+ * "Detected cycle while resolving import".
26
+ *
27
+ * Each variable carries its literal DEFAULT inline, so the theme is opt-in in
28
+ * the strictest sense — an app that sets nothing has no `--lotics-*` defined,
29
+ * every `var()` falls back, and the pixels are identical to before this file.
30
+ *
31
+ * ONLY these three. The palette families stay literal, and must: `withAlpha`,
32
+ * `tint` and `ramp` do string surgery on an `rgba()` to derive a wash, and a
33
+ * `var()` handed to them would produce garbage no type would catch. Keeping the
34
+ * themeable set to chrome keeps those functions total — the maintainability
35
+ * argument and the "colour must keep meaning status" argument turn out to be the
36
+ * same argument.
37
+ */
38
+ export const colors = {
39
+ ...contract,
40
+ background: `var(--lotics-background, ${contract.background})`,
41
+ border: `var(--lotics-border, ${contract.border})`,
42
+ accent: `var(--lotics-accent, ${contract.accent})`,
43
+ };
@@ -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
  }