@stll/ui 0.41.0 → 0.42.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.
@@ -13,8 +13,8 @@ import { VariantProps } from "class-variance-authority";
13
13
  */
14
14
  declare const buttonAccessibleDisabledClass = "cursor-not-allowed opacity-64";
15
15
  declare const buttonVariants: (props?: ({
16
- size?: "icon" | "default" | "row" | "chip" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "sm" | "xl" | "xs" | null | undefined;
17
- variant?: "link" | "destructive" | "default" | "destructive-outline" | "destructive-ghost" | "ghost" | "muted" | "outline" | "secondary" | null | undefined;
16
+ size?: "icon-sm" | "icon" | "default" | "sm" | "lg" | "row" | "chip" | "icon-lg" | "icon-xl" | "icon-xs" | "xl" | "xs" | null | undefined;
17
+ variant?: "link" | "default" | "destructive" | "destructive-outline" | "destructive-ghost" | "ghost" | "muted" | "outline" | "secondary" | null | undefined;
18
18
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
19
19
  type ButtonProps = {
20
20
  variant?: VariantProps<typeof buttonVariants>["variant"];
@@ -1,11 +1,37 @@
1
1
  import { OverlayLayer } from "../lib/overlay-layer.js";
2
+ import { DatePickerMode } from "./date-picker-popover.logic.js";
2
3
  //#region src/components/date-picker-popover.d.ts
4
+ /**
5
+ * `compact` keeps the dense 32px day cells everywhere. `touch` grows day,
6
+ * month, and year cells to 44px targets under a coarse pointer and leaves a
7
+ * fine pointer on the compact metric.
8
+ */
9
+ type DatePickerPopoverSize = "compact" | "touch";
10
+ /**
11
+ * `inline` is the borderless trigger for property rows and table cells;
12
+ * `field` is the bordered box form controls share with `SelectTrigger`.
13
+ */
14
+ type DatePickerPopoverVariant = "inline" | "field";
3
15
  type DatePickerPopoverProps = {
16
+ /**
17
+ * `date` (default) reads and writes `YYYY-MM-DD`. `date-time` reads and
18
+ * writes `YYYY-MM-DDTHH:mm`, a zone-less wall-clock time the host converts
19
+ * to and from an instant in the time zone it owns. A `Date` is read in UTC
20
+ * in both modes, so a string is the way to keep a local wall-clock time.
21
+ */
22
+ mode?: DatePickerMode;
23
+ value: string | Date | null;
24
+ onChange: (value: string | null) => void;
4
25
  id?: string;
5
26
  /** ID of the visible field label, when the picker is part of a form field. */
6
27
  labelledBy?: string;
7
- value: string | Date | null;
8
- onChange: (value: string | null) => void;
28
+ /** Merged into the trigger's classes. */
29
+ className?: string;
30
+ disabled?: boolean;
31
+ /** Hide the clear button, for fields that must keep a value. */
32
+ hideClear?: boolean;
33
+ size?: DatePickerPopoverSize;
34
+ variant?: DatePickerPopoverVariant;
9
35
  locale?: string;
10
36
  isOverdue?: boolean;
11
37
  showIcon?: boolean;
@@ -19,6 +45,18 @@ type DatePickerPopoverProps = {
19
45
  /** Label for the "go to today" button. Auto-localized from the locale when omitted. */
20
46
  todayLabel?: string;
21
47
  overdueLabel?: string;
48
+ /** Accessible name of the popup. The package carries no catalogs, so the
49
+ * English defaults below are meant to be replaced by the host. */
50
+ dialogLabel?: string;
51
+ previousMonthLabel?: string;
52
+ nextMonthLabel?: string;
53
+ previousYearLabel?: string;
54
+ nextYearLabel?: string;
55
+ previousDecadeLabel?: string;
56
+ nextDecadeLabel?: string;
57
+ /** Label of the time input in `date-time` mode. */
58
+ timeLabel?: string;
59
+ /** Inclusive `YYYY-MM-DD` bounds; in `date-time` mode they bound the day. */
22
60
  minDate?: string;
23
61
  maxDate?: string;
24
62
  isDateDisabled?: (date: string) => boolean;
@@ -3,11 +3,14 @@ import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from "../icons.js";
3
3
  import { cn } from "../lib/utils.js";
4
4
  import { Button } from "./button.js";
5
5
  import { DirectionalIcon } from "./directional-icon.js";
6
+ import { FIELD_TRIGGER_CLASS_NAME } from "../lib/field-trigger.js";
6
7
  import { getLocaleWeekInfo, getWeekendDays } from "../lib/week.js";
7
- import { localDateFromTimestamp, millisecondsUntilNextLocalDate, resolveCalendarViewMonth, shiftCalendarDate } from "./date-picker-popover.logic.js";
8
+ import { DATE_PICKER_MODE, formatDateTimeValue, getHourOptions, getMinuteOptions, getTimeFieldNames, joinPickerTime, localDateFromTimestamp, millisecondsUntilNextLocalDate, parseDateTimeValue, resolveCalendarViewMonth, shiftCalendarDate, splitPickerTime } from "./date-picker-popover.logic.js";
8
9
  import { Popover, PopoverContent as PopoverPopup, PopoverTrigger } from "./popover.js";
10
+ import { Select, SelectContent as SelectPopup, SelectItem, SelectTrigger, SelectValue } from "./select.js";
9
11
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
12
  import { useCallback, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
13
+ import { panic } from "better-result";
11
14
  import { Temporal } from "temporal-polyfill/full";
12
15
  //#region src/components/date-picker-popover.tsx
13
16
  const toISODate = (date) => date.toString();
@@ -154,6 +157,7 @@ const deriveTodayLabel = (locale) => {
154
157
  const raw = getRelativeTimeFormatter(locale).format(0, "day");
155
158
  return raw.charAt(0).toUpperCase() + raw.slice(1);
156
159
  };
160
+ const utcDateTimeFromDate = (date) => Temporal.Instant.fromEpochMilliseconds(date.getTime()).toZonedDateTimeISO("UTC").toPlainDateTime().toString({ smallestUnit: "minute" });
157
161
  const normalizeDate = (v) => {
158
162
  if (v === null || v === void 0) return "";
159
163
  if (v instanceof Date) return Temporal.Instant.fromEpochMilliseconds(v.getTime()).toZonedDateTimeISO("UTC").toPlainDate().toString();
@@ -165,8 +169,94 @@ const isAfter = (a, b) => a > b;
165
169
  /** Round down to the start of a decade (e.g. 2026 → 2020). */
166
170
  const decadeStart = (year) => Math.floor(year / 10) * 10;
167
171
  const DECADE_SIZE = 12;
168
- const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, locale, isOverdue = false, showIcon = true, placeholderLabel, clearLabel = "Clear date", defaultOpen = false, onOpenChange, todayLabel: todayLabelProp, overdueLabel, minDate, maxDate, isDateDisabled, layer = "default", today }) => {
169
- const value = normalizeDate(rawValue);
172
+ const DISPLAY_DATE_FORMAT = {
173
+ month: "short",
174
+ day: "numeric",
175
+ year: "numeric",
176
+ calendar: "gregory",
177
+ timeZone: "UTC"
178
+ };
179
+ const DISPLAY_DATE_TIME_FORMAT = {
180
+ ...DISPLAY_DATE_FORMAT,
181
+ hour: "numeric",
182
+ minute: "2-digit"
183
+ };
184
+ const resolveSelection = (mode, value) => {
185
+ switch (mode) {
186
+ case DATE_PICKER_MODE.date: return {
187
+ mode,
188
+ date: normalizeDate(value)
189
+ };
190
+ case DATE_PICKER_MODE.dateTime: {
191
+ const parsed = parseDateTimeValue(value instanceof Date ? utcDateTimeFromDate(value) : value);
192
+ return {
193
+ mode: DATE_PICKER_MODE.dateTime,
194
+ date: parsed?.date ?? "",
195
+ time: parsed?.time ?? null
196
+ };
197
+ }
198
+ default: return panic(`Unhandled date picker mode: ${String(mode)}`);
199
+ }
200
+ };
201
+ /** Locale-formatted trigger text, or null while nothing is set. */
202
+ const formatSelection = (locale, selection) => {
203
+ if (selection.date === "") return null;
204
+ const date = Temporal.PlainDate.from(selection.date);
205
+ switch (selection.mode) {
206
+ case DATE_PICKER_MODE.date: return getDateFormatter(locale, DISPLAY_DATE_FORMAT).format(toUTCDateTime(date));
207
+ case DATE_PICKER_MODE.dateTime: {
208
+ const instant = date.toZonedDateTime({
209
+ plainTime: Temporal.PlainTime.from(selection.time ?? "00:00"),
210
+ timeZone: "UTC"
211
+ }).epochMilliseconds;
212
+ return getDateFormatter(locale, DISPLAY_DATE_TIME_FORMAT).format(instant);
213
+ }
214
+ default: return panic(`Unhandled date picker mode: ${String(selection)}`);
215
+ }
216
+ };
217
+ const TRIGGER_VARIANT_CLASS_NAMES = {
218
+ inline: cn("flex h-auto min-h-7 w-full min-w-0 items-center gap-1.5", "rounded-md px-1.5 text-sm", "hover:bg-muted", "data-disabled:pointer-events-none data-disabled:opacity-64"),
219
+ field: cn(FIELD_TRIGGER_CLASS_NAME, "min-w-0 justify-start gap-1.5")
220
+ };
221
+ /** The package carries no catalogs: English defaults the host replaces. */
222
+ const resolvePopupLabels = (props) => ({
223
+ dialog: props.dialogLabel ?? "Date picker",
224
+ navigation: {
225
+ days: {
226
+ next: props.nextMonthLabel ?? "Next month",
227
+ previous: props.previousMonthLabel ?? "Previous month"
228
+ },
229
+ months: {
230
+ next: props.nextYearLabel ?? "Next year",
231
+ previous: props.previousYearLabel ?? "Previous year"
232
+ },
233
+ years: {
234
+ next: props.nextDecadeLabel ?? "Next decade",
235
+ previous: props.previousDecadeLabel ?? "Previous decade"
236
+ }
237
+ },
238
+ time: props.timeLabel ?? "Time"
239
+ });
240
+ const PICKER_SIZE_CLASS_NAMES = {
241
+ compact: {
242
+ popup: "w-60",
243
+ heading: "",
244
+ day: "",
245
+ cell: ""
246
+ },
247
+ touch: {
248
+ popup: "w-60 pointer-coarse:w-77",
249
+ heading: "pointer-coarse:min-h-11 pointer-coarse:text-sm",
250
+ day: "pointer-coarse:size-11 pointer-coarse:text-sm",
251
+ cell: "pointer-coarse:min-h-11 pointer-coarse:text-sm"
252
+ }
253
+ };
254
+ const DatePickerPopoverContent = (props) => {
255
+ const { id, labelledBy, className, disabled, hideClear, size = "compact", variant = "inline", mode = DATE_PICKER_MODE.date, value: rawValue, onChange, locale, isOverdue = false, showIcon = true, placeholderLabel, clearLabel = "Clear date", defaultOpen = false, onOpenChange, todayLabel: todayLabelProp, overdueLabel, minDate, maxDate, isDateDisabled, layer = "default", today } = props;
256
+ const selection = resolveSelection(mode, rawValue);
257
+ const value = selection.date;
258
+ const labels = resolvePopupLabels(props);
259
+ const sizeClassNames = PICKER_SIZE_CLASS_NAMES[size];
170
260
  const displayValueId = useId();
171
261
  const todayLabel = todayLabelProp ?? deriveTodayLabel(locale);
172
262
  const firstDow = useMemo(() => getFirstDayOfWeek(locale), [locale]);
@@ -204,13 +294,28 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
204
294
  maxDate,
205
295
  isDateDisabled
206
296
  ]);
207
- const displayLabel = value ? getDateFormatter(locale, {
208
- month: "short",
209
- day: "numeric",
210
- year: "numeric",
211
- calendar: "gregory",
212
- timeZone: "UTC"
213
- }).format(toUTCDateTime(Temporal.PlainDate.from(value))) : placeholderLabel ?? "—";
297
+ const displayLabel = formatSelection(locale, selection) ?? placeholderLabel ?? "—";
298
+ const selectDay = (date) => {
299
+ switch (selection.mode) {
300
+ case DATE_PICKER_MODE.date:
301
+ onChange(date);
302
+ return;
303
+ case DATE_PICKER_MODE.dateTime:
304
+ onChange(formatDateTimeValue({
305
+ date,
306
+ time: selection.time ?? "00:00"
307
+ }));
308
+ return;
309
+ default: panic(`Unhandled date picker mode: ${String(selection)}`);
310
+ }
311
+ };
312
+ const handleTimeChange = (clock) => {
313
+ if (value === "") return;
314
+ onChange(formatDateTimeValue({
315
+ date: value,
316
+ time: joinPickerTime(clock)
317
+ }));
318
+ };
214
319
  const formatDayLabel = useCallback((iso) => getDateFormatter(locale, {
215
320
  weekday: "long",
216
321
  month: "long",
@@ -240,7 +345,7 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
240
345
  else {
241
346
  if (e.key === "Enter" || e.key === " ") {
242
347
  e.preventDefault();
243
- if (!isDayDisabled(current)) onChange(current);
348
+ if (!isDayDisabled(current)) selectDay(current);
244
349
  }
245
350
  return;
246
351
  }
@@ -327,18 +432,20 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
327
432
  setDecadeBaseOverride(decadeStart(viewYear));
328
433
  }
329
434
  };
435
+ const triggerToneClassName = (() => {
436
+ if (isOverdue) return "text-destructive";
437
+ if (value) return "text-foreground";
438
+ return "text-muted-foreground";
439
+ })();
330
440
  return /* @__PURE__ */ jsxs(Popover, {
331
441
  defaultOpen,
332
442
  onOpenChange: handleOpenChange,
333
443
  children: [/* @__PURE__ */ jsxs(PopoverTrigger, {
444
+ disabled,
334
445
  render: /* @__PURE__ */ jsx("button", {
335
446
  "aria-label": !labelledBy && value ? displayLabel : void 0,
336
447
  "aria-labelledby": labelledBy ? `${labelledBy} ${displayValueId}` : void 0,
337
- className: cn("flex h-auto min-h-7 w-full min-w-0 items-center gap-1.5", "rounded-md px-1.5 text-sm", "hover:bg-muted", (() => {
338
- if (isOverdue) return "text-destructive";
339
- if (value) return "text-foreground";
340
- return "text-muted-foreground";
341
- })()),
448
+ className: cn(TRIGGER_VARIANT_CLASS_NAMES[variant], triggerToneClassName, className),
342
449
  id,
343
450
  type: "button"
344
451
  }),
@@ -355,48 +462,25 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
355
462
  })
356
463
  ]
357
464
  }), /* @__PURE__ */ jsx(PopoverPopup, {
465
+ className: "data-starting-style:scale-100",
358
466
  layer,
359
467
  padding: "sm",
360
468
  side: "bottom",
361
469
  sideOffset: 4,
362
470
  children: /* @__PURE__ */ jsxs("div", {
363
- className: "w-60",
471
+ "aria-label": labels.dialog,
472
+ className: sizeClassNames.popup,
473
+ "data-slot": "date-picker-popup",
364
474
  role: "dialog",
365
- "aria-label": "Date picker",
366
475
  children: [
367
- /* @__PURE__ */ jsxs("div", {
368
- className: "flex items-center justify-between gap-1 pb-1",
369
- children: [
370
- /* @__PURE__ */ jsx(Button, {
371
- "aria-label": (() => {
372
- if (view === "days") return "Previous month";
373
- if (view === "months") return "Previous year";
374
- return "Previous decade";
375
- })(),
376
- onClick: handlePrev,
377
- size: "icon-xs",
378
- variant: "ghost",
379
- children: /* @__PURE__ */ jsx(DirectionalIcon, { icon: ChevronLeftIcon })
380
- }),
381
- /* @__PURE__ */ jsx("button", {
382
- className: cn("text-xs font-medium", view !== "years" && "hover:bg-muted cursor-pointer rounded-md px-2 py-0.5", view === "years" && "cursor-default"),
383
- onClick: handleHeaderClick,
384
- tabIndex: view === "years" ? -1 : 0,
385
- type: "button",
386
- children: headerLabel
387
- }),
388
- /* @__PURE__ */ jsx(Button, {
389
- "aria-label": (() => {
390
- if (view === "days") return "Next month";
391
- if (view === "months") return "Next year";
392
- return "Next decade";
393
- })(),
394
- onClick: handleNext,
395
- size: "icon-xs",
396
- variant: "ghost",
397
- children: /* @__PURE__ */ jsx(DirectionalIcon, { icon: ChevronRightIcon })
398
- })
399
- ]
476
+ /* @__PURE__ */ jsx(PickerHeader, {
477
+ headingClassName: sizeClassNames.heading,
478
+ label: headerLabel,
479
+ labels: labels.navigation[view],
480
+ onHeadingClick: handleHeaderClick,
481
+ onNext: handleNext,
482
+ onPrevious: handlePrev,
483
+ view
400
484
  }),
401
485
  view === "days" && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
402
486
  className: "grid grid-cols-7 gap-0",
@@ -416,16 +500,16 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
416
500
  children: days.map((day) => {
417
501
  const isSelected = day.date === value;
418
502
  const isFocused = day.date === focusedDate || !focusedDate && isSelected || !focusedDate && !value && day.isToday;
419
- const disabled = isDayDisabled(day.date);
503
+ const isUnavailable = isDayDisabled(day.date);
420
504
  return /* @__PURE__ */ jsx("button", {
421
505
  "aria-current": day.isToday ? "date" : void 0,
422
- "aria-disabled": disabled || void 0,
506
+ "aria-disabled": isUnavailable || void 0,
423
507
  "aria-label": formatDayLabel(day.date),
424
508
  "aria-selected": isSelected || void 0,
425
- className: cn("flex size-8 items-center justify-center", "rounded-full text-xs", "focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none", disabled ? "text-foreground-disabled cursor-not-allowed" : "hover:bg-muted cursor-pointer", !day.isCurrentMonth && !disabled && "text-foreground-disabled", day.isWeekend && day.isCurrentMonth && !disabled && !isSelected && "text-foreground-label", day.isToday && !isSelected && !disabled && "ring-foreground font-medium ring-1", isSelected && "bg-primary text-primary-foreground hover:bg-primary/90"),
509
+ className: cn("flex size-8 items-center justify-center", "rounded-full text-xs", sizeClassNames.day, "focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none", isUnavailable ? "text-foreground-disabled cursor-not-allowed" : "hover:bg-muted cursor-pointer", !day.isCurrentMonth && !isUnavailable && "text-foreground-disabled", day.isWeekend && day.isCurrentMonth && !isUnavailable && !isSelected && "text-foreground-label", day.isToday && !isSelected && !isUnavailable && "ring-foreground font-medium ring-1", isSelected && "bg-primary text-primary-foreground hover:bg-primary/90"),
426
510
  "data-date": day.date,
427
511
  onClick: () => {
428
- if (!disabled) onChange(day.date);
512
+ if (!isUnavailable) selectDay(day.date);
429
513
  },
430
514
  role: "gridcell",
431
515
  tabIndex: isFocused ? 0 : -1,
@@ -437,6 +521,7 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
437
521
  view === "months" && /* @__PURE__ */ jsx(MonthGrid, {
438
522
  currentMonth: selectedMonth,
439
523
  currentYear: selectedYear,
524
+ cellClassName: sizeClassNames.cell,
440
525
  locale,
441
526
  onSelect: handleMonthSelect,
442
527
  today,
@@ -445,9 +530,17 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
445
530
  view === "years" && /* @__PURE__ */ jsx(YearGrid, {
446
531
  currentYear: selectedYear,
447
532
  decadeBase,
533
+ cellClassName: sizeClassNames.cell,
448
534
  onSelect: handleYearSelect,
449
535
  today
450
536
  }),
537
+ selection.mode === DATE_PICKER_MODE.dateTime && /* @__PURE__ */ jsx(TimeField, {
538
+ disabled: value === "",
539
+ label: labels.time,
540
+ locale,
541
+ onChange: handleTimeChange,
542
+ time: selection.time
543
+ }),
451
544
  /* @__PURE__ */ jsxs("div", {
452
545
  className: "mt-1 flex items-center gap-1 border-t pt-1",
453
546
  children: [/* @__PURE__ */ jsx(Button, {
@@ -463,7 +556,7 @@ const DatePickerPopoverContent = ({ id, labelledBy, value: rawValue, onChange, l
463
556
  size: "xs",
464
557
  variant: "ghost",
465
558
  children: todayLabel
466
- }), value && /* @__PURE__ */ jsx(Button, {
559
+ }), value && !hideClear && /* @__PURE__ */ jsx(Button, {
467
560
  className: "flex-1",
468
561
  onClick: () => onChange(null),
469
562
  size: "xs",
@@ -486,8 +579,102 @@ const DatePickerPopover = (props) => {
486
579
  today
487
580
  });
488
581
  };
582
+ const PickerHeader = ({ headingClassName, label, labels, onHeadingClick, onNext, onPrevious, view }) => {
583
+ const isTopLevel = view === "years";
584
+ return /* @__PURE__ */ jsxs("div", {
585
+ className: "flex items-center justify-between gap-1 pb-1",
586
+ children: [
587
+ /* @__PURE__ */ jsx(Button, {
588
+ "aria-label": labels.previous,
589
+ onClick: onPrevious,
590
+ size: "icon-xs",
591
+ variant: "ghost",
592
+ children: /* @__PURE__ */ jsx(DirectionalIcon, { icon: ChevronLeftIcon })
593
+ }),
594
+ /* @__PURE__ */ jsx("button", {
595
+ className: cn("text-xs font-medium", isTopLevel ? "cursor-default" : "hover:bg-muted cursor-pointer rounded-md px-2 py-0.5", headingClassName),
596
+ "data-slot": "date-picker-heading",
597
+ onClick: onHeadingClick,
598
+ tabIndex: isTopLevel ? -1 : 0,
599
+ type: "button",
600
+ children: label
601
+ }),
602
+ /* @__PURE__ */ jsx(Button, {
603
+ "aria-label": labels.next,
604
+ onClick: onNext,
605
+ size: "icon-xs",
606
+ variant: "ghost",
607
+ children: /* @__PURE__ */ jsx(DirectionalIcon, { icon: ChevronRightIcon })
608
+ })
609
+ ]
610
+ });
611
+ };
612
+ /**
613
+ * Hour and minute selects in the locale's own clock and numerals. Inert
614
+ * until a day is picked, since a time alone is not a value.
615
+ */
616
+ const TimeField = ({ disabled, label, locale, onChange, time }) => {
617
+ const labelId = useId();
618
+ const hourOptions = useMemo(() => getHourOptions(locale), [locale]);
619
+ const minuteOptions = useMemo(() => getMinuteOptions(locale), [locale]);
620
+ const fieldNames = useMemo(() => getTimeFieldNames(locale), [locale]);
621
+ const clock = time === null ? null : splitPickerTime(time);
622
+ return /* @__PURE__ */ jsxs("div", {
623
+ "aria-labelledby": labelId,
624
+ className: "mt-1 flex items-center gap-1.5 border-t pt-1",
625
+ role: "group",
626
+ children: [
627
+ /* @__PURE__ */ jsx("span", {
628
+ className: "text-foreground-label shrink-0 text-xs",
629
+ id: labelId,
630
+ children: label
631
+ }),
632
+ /* @__PURE__ */ jsx(TimePartSelect, {
633
+ disabled,
634
+ label: fieldNames.hour,
635
+ onChange: (hour) => onChange({
636
+ hour,
637
+ minute: clock?.minute ?? 0
638
+ }),
639
+ options: hourOptions,
640
+ value: clock?.hour ?? null
641
+ }),
642
+ /* @__PURE__ */ jsx("span", {
643
+ "aria-hidden": "true",
644
+ className: "text-muted-foreground text-xs",
645
+ children: ":"
646
+ }),
647
+ /* @__PURE__ */ jsx(TimePartSelect, {
648
+ disabled,
649
+ label: fieldNames.minute,
650
+ onChange: (minute) => onChange({
651
+ hour: clock?.hour ?? 0,
652
+ minute
653
+ }),
654
+ options: minuteOptions,
655
+ value: clock?.minute ?? null
656
+ })
657
+ ]
658
+ });
659
+ };
660
+ const TimePartSelect = ({ disabled, label, onChange, options, value }) => /* @__PURE__ */ jsxs(Select, {
661
+ disabled,
662
+ onValueChange: (next) => {
663
+ if (typeof next === "number") onChange(next);
664
+ },
665
+ value,
666
+ children: [/* @__PURE__ */ jsx(SelectTrigger, {
667
+ "aria-label": label,
668
+ className: "min-w-0 flex-1",
669
+ size: "sm",
670
+ children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "—" })
671
+ }), /* @__PURE__ */ jsx(SelectPopup, { children: options.map((option) => /* @__PURE__ */ jsx(SelectItem, {
672
+ value: option.value,
673
+ children: option.label
674
+ }, option.value)) })]
675
+ });
489
676
  const MONTHS_PER_ROW = 3;
490
- const MonthGrid = ({ locale, viewYear, currentMonth, currentYear, onSelect, today }) => {
677
+ const MonthGrid = ({ locale, viewYear, currentMonth, currentYear, cellClassName, onSelect, today }) => {
491
678
  const labels = useMemo(() => getMonthLabels(locale, "short"), [locale]);
492
679
  const now = Temporal.PlainDate.from(today);
493
680
  const todayMonth = now.month - 1;
@@ -504,7 +691,7 @@ const MonthGrid = ({ locale, viewYear, currentMonth, currentYear, onSelect, toda
504
691
  const isSelected = currentMonth !== null && currentYear !== null && month === currentMonth && viewYear === currentYear;
505
692
  return /* @__PURE__ */ jsxs("button", {
506
693
  "aria-selected": isSelected || void 0,
507
- className: cn("rounded-md px-2 py-1.5 text-xs", "hover:bg-muted cursor-pointer", "focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none", month === todayMonth && viewYear === todayYear && !isSelected && "ring-foreground font-medium ring-1", isSelected && "bg-primary text-primary-foreground hover:bg-primary/90"),
694
+ className: cn("rounded-md px-2 py-1.5 text-xs", cellClassName, "hover:bg-muted cursor-pointer", "focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none", month === todayMonth && viewYear === todayYear && !isSelected && "ring-foreground font-medium ring-1", isSelected && "bg-primary text-primary-foreground hover:bg-primary/90"),
508
695
  onClick: () => onSelect(month),
509
696
  role: "gridcell",
510
697
  type: "button",
@@ -519,7 +706,7 @@ const MonthGrid = ({ locale, viewYear, currentMonth, currentYear, onSelect, toda
519
706
  });
520
707
  };
521
708
  const YEARS_PER_ROW = 3;
522
- const YearGrid = ({ decadeBase, currentYear, onSelect, today }) => {
709
+ const YearGrid = ({ decadeBase, currentYear, cellClassName, onSelect, today }) => {
523
710
  const todayYear = Temporal.PlainDate.from(today).year;
524
711
  const startYear = decadeBase - 1;
525
712
  const rows = [];
@@ -536,7 +723,7 @@ const YearGrid = ({ decadeBase, currentYear, onSelect, today }) => {
536
723
  const isSelected = currentYear !== null && year === currentYear;
537
724
  return /* @__PURE__ */ jsx("button", {
538
725
  "aria-selected": isSelected || void 0,
539
- className: cn("rounded-md px-2 py-1.5 text-xs", "hover:bg-muted cursor-pointer", "focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none", isOutside && !isSelected && "text-foreground-subtle", year === todayYear && !isSelected && "ring-foreground font-medium ring-1", isSelected && "bg-primary text-primary-foreground hover:bg-primary/90"),
726
+ className: cn("rounded-md px-2 py-1.5 text-xs", cellClassName, "hover:bg-muted cursor-pointer", "focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none", isOutside && !isSelected && "text-foreground-subtle", year === todayYear && !isSelected && "ring-foreground font-medium ring-1", isSelected && "bg-primary text-primary-foreground hover:bg-primary/90"),
540
727
  onClick: () => onSelect(year),
541
728
  role: "gridcell",
542
729
  type: "button",
@@ -14,4 +14,43 @@ export declare const shiftCalendarDate: (date: string, options: {
14
14
  years?: number;
15
15
  }) => string;
16
16
  export declare const millisecondsUntilNextLocalDate: (timestamp: number) => number;
17
+ export declare const DATE_PICKER_MODE: Readonly<{
18
+ readonly date: "date";
19
+ readonly dateTime: "date-time";
20
+ }>;
21
+ export type DatePickerMode = (typeof DATE_PICKER_MODE)[keyof typeof DATE_PICKER_MODE];
22
+ /** The time a date-time value takes when a day is picked before any time. */
23
+ export declare const DEFAULT_PICKER_TIME = "00:00";
24
+ /** A wall-clock selection: an ISO date plus an `HH:mm` time. */
25
+ export type DateTimeSelection = {
26
+ date: string;
27
+ time: string;
28
+ };
29
+ /**
30
+ * Read a date-time value (`YYYY-MM-DDTHH:mm`, a zone-less
31
+ * `Temporal.PlainDateTime`). A bare date reads as midnight and seconds are
32
+ * dropped. An instant (`…Z`) is not a wall-clock time and throws, like any
33
+ * other malformed value.
34
+ */
35
+ export declare const parseDateTimeValue: (value: string | null) => DateTimeSelection | null;
36
+ export declare const formatDateTimeValue: ({ date, time }: DateTimeSelection) => string;
37
+ export type PickerClock = {
38
+ hour: number;
39
+ minute: number;
40
+ };
41
+ export declare const splitPickerTime: (time: string) => PickerClock;
42
+ export declare const joinPickerTime: (clock: PickerClock) => string;
43
+ export type PickerTimeOption = {
44
+ value: number;
45
+ label: string;
46
+ };
47
+ /**
48
+ * Hour choices in the locale's own clock: `2 PM` where the locale counts
49
+ * twelve hours, `14` where it counts twenty-four.
50
+ */
51
+ export declare const getHourOptions: (locale: string) => PickerTimeOption[];
52
+ /** Two-digit minute choices in the locale's numbering system. */
53
+ export declare const getMinuteOptions: (locale: string) => PickerTimeOption[];
54
+ /** Accessible names of the hour and minute fields, from the locale itself. */
55
+ export declare const getTimeFieldNames: (locale: string) => Record<keyof PickerClock, string>;
17
56
  //#endregion
@@ -27,5 +27,74 @@ const millisecondsUntilNextLocalDate = (timestamp) => {
27
27
  });
28
28
  return Math.max(0, nextLocalDate.epochMilliseconds - timestamp) + DATE_ROLLOVER_EPSILON_MS;
29
29
  };
30
+ const DATE_PICKER_MODE = Object.freeze({
31
+ date: "date",
32
+ dateTime: "date-time"
33
+ });
34
+ /** The time a date-time value takes when a day is picked before any time. */
35
+ const DEFAULT_PICKER_TIME = "00:00";
36
+ /**
37
+ * Read a date-time value (`YYYY-MM-DDTHH:mm`, a zone-less
38
+ * `Temporal.PlainDateTime`). A bare date reads as midnight and seconds are
39
+ * dropped. An instant (`…Z`) is not a wall-clock time and throws, like any
40
+ * other malformed value.
41
+ */
42
+ const parseDateTimeValue = (value) => {
43
+ if (value === null || value === "") return null;
44
+ const dateTime = Temporal.PlainDateTime.from(value);
45
+ return {
46
+ date: dateTime.toPlainDate().toString(),
47
+ time: dateTime.toPlainTime().toString({ smallestUnit: "minute" })
48
+ };
49
+ };
50
+ const formatDateTimeValue = ({ date, time }) => `${date}T${time}`;
51
+ const splitPickerTime = (time) => {
52
+ const { hour, minute } = Temporal.PlainTime.from(time);
53
+ return {
54
+ hour,
55
+ minute
56
+ };
57
+ };
58
+ const joinPickerTime = (clock) => Temporal.PlainTime.from(clock).toString({ smallestUnit: "minute" });
59
+ const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
60
+ const MINUTES = Array.from({ length: 60 }, (_, minute) => minute);
61
+ /**
62
+ * Hour choices in the locale's own clock: `2 PM` where the locale counts
63
+ * twelve hours, `14` where it counts twenty-four.
64
+ */
65
+ const getHourOptions = (locale) => {
66
+ const formatter = new Intl.DateTimeFormat(locale, {
67
+ hour: "numeric",
68
+ timeZone: "UTC"
69
+ });
70
+ const epoch = Temporal.PlainDate.from("1970-01-01");
71
+ return HOURS.map((hour) => ({
72
+ value: hour,
73
+ label: formatter.format(epoch.toZonedDateTime({
74
+ plainTime: Temporal.PlainTime.from({ hour }),
75
+ timeZone: "UTC"
76
+ }).epochMilliseconds)
77
+ }));
78
+ };
79
+ /** Two-digit minute choices in the locale's numbering system. */
80
+ const getMinuteOptions = (locale) => {
81
+ const formatter = new Intl.NumberFormat(locale, {
82
+ minimumIntegerDigits: 2,
83
+ useGrouping: false
84
+ });
85
+ return MINUTES.map((minute) => ({
86
+ value: minute,
87
+ label: formatter.format(minute)
88
+ }));
89
+ };
90
+ const capitalize = (locale, text) => text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
91
+ /** Accessible names of the hour and minute fields, from the locale itself. */
92
+ const getTimeFieldNames = (locale) => {
93
+ const names = new Intl.DisplayNames(locale, { type: "dateTimeField" });
94
+ return {
95
+ hour: capitalize(locale, names.of("hour") ?? "hour"),
96
+ minute: capitalize(locale, names.of("minute") ?? "minute")
97
+ };
98
+ };
30
99
  //#endregion
31
- export { localDateFromTimestamp, millisecondsUntilNextLocalDate, resolveCalendarViewMonth, shiftCalendarDate };
100
+ export { DATE_PICKER_MODE, DEFAULT_PICKER_TIME, formatDateTimeValue, getHourOptions, getMinuteOptions, getTimeFieldNames, joinPickerTime, localDateFromTimestamp, millisecondsUntilNextLocalDate, parseDateTimeValue, resolveCalendarViewMonth, shiftCalendarDate, splitPickerTime };
@@ -5,7 +5,7 @@ import { VariantProps } from "class-variance-authority";
5
5
  //#region src/components/input-group.d.ts
6
6
  declare const InputGroup: ({ className, ...props }: React$1.ComponentProps<"div">) => React$1.JSX.Element;
7
7
  declare const inputGroupAddonVariants: (props?: ({
8
- align?: "inline-start" | "block-end" | "block-start" | "inline-end" | null | undefined;
8
+ align?: "inline-end" | "inline-start" | "block-end" | "block-start" | null | undefined;
9
9
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
10
10
  declare const InputGroupAddon: ({ className, align, ...props }: React$1.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) => React$1.JSX.Element;
11
11
  declare const InputGroupText: ({ className, ...props }: React$1.ComponentProps<"span">) => React$1.JSX.Element;
@@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDownIcon } from "../icons.js"
3
3
  import { cn } from "../lib/utils.js";
4
4
  import { OVERLAY_LAYER_CLASS_NAMES } from "../lib/overlay-layer.js";
5
5
  import { CONTROL_SIZE } from "../lib/control-size.js";
6
+ import { FIELD_TRIGGER_CLASS_NAME } from "../lib/field-trigger.js";
6
7
  import { useLatest } from "../hooks/use-latest.js";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  import * as React$1 from "react";
@@ -31,7 +32,7 @@ const Select = ({ children, isItemEqualToValue, itemToStringLabel, itemToStringV
31
32
  });
32
33
  };
33
34
  const SelectTrigger = ({ className, size = CONTROL_SIZE.default, children, ...props }) => /* @__PURE__ */ jsxs(Select$1.Trigger, {
34
- className: cn("border-input bg-background text-foreground ring-ring/24 focus-visible:border-ring aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 relative inline-flex min-h-9 w-full min-w-36 items-center justify-center gap-2 rounded-lg border px-[calc(--spacing(3)-1px)] text-start text-base shadow-xs/5 outline-none select-none not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-[3px] data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none", SELECT_TRIGGER_SIZE_CLASS_NAMES[size], className),
35
+ className: cn(FIELD_TRIGGER_CLASS_NAME, SELECT_TRIGGER_SIZE_CLASS_NAMES[size], className),
35
36
  "data-slot": "select-trigger",
36
37
  ...props,
37
38
  children: [children, /* @__PURE__ */ jsx(Select$1.Icon, {
@@ -86,7 +86,7 @@ declare const SidebarMenu: ({ className, ...props }: React.ComponentProps<"ul">)
86
86
  declare const SidebarMenuItem: ({ className, ...props }: React.ComponentProps<"li">) => import("react").JSX.Element;
87
87
  declare const sidebarMenuButtonVariants: (props?: ({
88
88
  variant?: "default" | "outline" | null | undefined;
89
- size?: "default" | "lg" | "sm" | "rail" | null | undefined;
89
+ size?: "default" | "sm" | "lg" | "rail" | null | undefined;
90
90
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
91
91
  /**
92
92
  * The footer's avatar trigger, signed in (user menu) or not (sign-in): a
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ import { assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutRes
31
31
  import { ResourceCalendar } from "./calendar/resource-calendar.js";
32
32
  import { getFirstWeekday, getLocaleWeekInfo, getWeekendDays } from "./lib/week.js";
33
33
  import { Popover, PopoverClose, PopoverContent as PopoverPopup, PopoverCreateHandle, PopoverDescription, PopoverPanel, PopoverTitle, PopoverTrigger } from "./components/popover.js";
34
+ import { useLatest } from "./hooks/use-latest.js";
35
+ import { Select, SelectContent as SelectPopup, SelectGroup, SelectGroupLabel, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./components/select.js";
34
36
  import { DatePickerPopover } from "./components/date-picker-popover.js";
35
37
  import { Field, FieldControl, FieldDescription, FieldError, FieldItem, FieldLabel, FieldValidity } from "./components/field.js";
36
38
  import { DestructiveActionConfirmation, useDestructiveActionConfirmation } from "./components/destructive-action-confirmation.js";
@@ -38,7 +40,6 @@ import { DestructiveConfirmDialog } from "./components/destructive-confirm-dialo
38
40
  import { FileInput } from "./components/file-input.js";
39
41
  import { Form } from "./components/form.js";
40
42
  import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from "./components/frame.js";
41
- import { useLatest } from "./hooks/use-latest.js";
42
43
  import { HexColorPicker } from "./components/hex-color-picker.js";
43
44
  import { Textarea } from "./components/textarea.js";
44
45
  import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, InputGroupTextarea } from "./components/input-group.js";
@@ -55,7 +56,6 @@ import { MenuPreviewLayout, PreviewPane } from "./components/preview-pane.js";
55
56
  import { ScrollToTop } from "./components/scroll-to-top.js";
56
57
  import { SecretInput } from "./components/secret-input.js";
57
58
  import { SegmentedIconToggle } from "./components/segmented-icon-toggle.js";
58
- import { Select, SelectContent as SelectPopup, SelectGroup, SelectGroupLabel, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./components/select.js";
59
59
  import { Separator, TextSeparator } from "./components/separator.js";
60
60
  import { Skeleton } from "./components/skeleton.js";
61
61
  import { SIDEBAR_WIDTH_ICON_PX, SIDEBAR_WIDTH_PX, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, sidebarIdentityTriggerClassName, useSidebar, useSidebarInlineSize, useSidebarLayout } from "./components/sidebar.js";
@@ -5,6 +5,6 @@ declare const CONTROL_SIZE: Readonly<{
5
5
  readonly lg: "lg";
6
6
  }>;
7
7
  type ControlSize = (typeof CONTROL_SIZE)[keyof typeof CONTROL_SIZE];
8
- declare const CONTROL_SIZES: readonly ("default" | "lg" | "sm")[];
8
+ declare const CONTROL_SIZES: readonly ("default" | "sm" | "lg")[];
9
9
  //#endregion
10
10
  export { CONTROL_SIZE, CONTROL_SIZES, type ControlSize };
@@ -0,0 +1,9 @@
1
+ //#region src/lib/field-trigger.d.ts
2
+ /**
3
+ * The bordered box of a button that opens a picker inside a form (a select
4
+ * trigger, a date field). One string so every popup-backed field shares the
5
+ * input metric, focus ring, invalid and disabled treatment, and the 44px
6
+ * coarse-pointer hit area.
7
+ */
8
+ export declare const FIELD_TRIGGER_CLASS_NAME = "border-input bg-background text-foreground ring-ring/24 focus-visible:border-ring aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 relative inline-flex min-h-9 w-full min-w-36 items-center justify-center gap-2 rounded-lg border px-[calc(--spacing(3)-1px)] text-start text-base shadow-xs/5 outline-none select-none not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-[3px] data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none";
9
+ //#endregion
@@ -0,0 +1,10 @@
1
+ //#region src/lib/field-trigger.ts
2
+ /**
3
+ * The bordered box of a button that opens a picker inside a form (a select
4
+ * trigger, a date field). One string so every popup-backed field shares the
5
+ * input metric, focus ring, invalid and disabled treatment, and the 44px
6
+ * coarse-pointer hit area.
7
+ */
8
+ const FIELD_TRIGGER_CLASS_NAME = "border-input bg-background text-foreground ring-ring/24 focus-visible:border-ring aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 dark:aria-invalid:ring-destructive/24 relative inline-flex min-h-9 w-full min-w-36 items-center justify-center gap-2 rounded-lg border px-[calc(--spacing(3)-1px)] text-start text-base shadow-xs/5 outline-none select-none not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-[3px] data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none";
9
+ //#endregion
10
+ export { FIELD_TRIGGER_CLASS_NAME };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/ui",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Stella's design system: bidi-aware React primitives built on Base UI, the dockable inspector pane, and the Tailwind v4 theme they are styled with.",
5
5
  "keywords": [
6
6
  "base-ui",