From ec7c5802d529bd309b91a3bf23c946283b4b82f0 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:37:17 -0500 Subject: [PATCH 01/41] feat(base-ui): add date picker theming tokens --- packages/base-ui/src/theme/styles.css | 88 +++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/packages/base-ui/src/theme/styles.css b/packages/base-ui/src/theme/styles.css index 09e0a44..a3204b3 100644 --- a/packages/base-ui/src/theme/styles.css +++ b/packages/base-ui/src/theme/styles.css @@ -512,6 +512,18 @@ --ov-syntax-style-keyword: normal; --ov-syntax-style-function: normal; --ov-syntax-style-type: normal; + + /* Date picker tokens — dark */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-fg-inverse); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } :root[data-ov-theme='light'] { @@ -618,6 +630,18 @@ --ov-syntax-regexp: #032f62; --ov-syntax-operator: #d73a49; --ov-syntax-punctuation: #24292e; + + /* Date picker tokens — light */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-fg-inverse); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } :root[data-ov-theme='high-contrast-dark'] { @@ -715,6 +739,18 @@ --ov-syntax-regexp: #a5d6ff; --ov-syntax-operator: #ff9f9f; --ov-syntax-punctuation: #ffffff; + + /* Date picker tokens — high-contrast-dark */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-bg-base); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } :root[data-ov-theme='high-contrast-light'] { @@ -812,6 +848,18 @@ --ov-syntax-regexp: #032f62; --ov-syntax-operator: #8f0000; --ov-syntax-punctuation: #000000; + + /* Date picker tokens — high-contrast-light */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-bg-surface); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } /* --------------------------------------------------------------------------- @@ -857,6 +905,18 @@ --ov-color-state-pressed: rgb(160 170 185 / 0.16); --ov-color-state-selected: rgb(77 124 255 / 0.18); --ov-color-state-focus-ring: #4d7cff; + + /* Date picker tokens — obsidian */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-fg-inverse); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } /* --------------------------------------------------------------------------- @@ -902,6 +962,18 @@ --ov-color-state-pressed: rgb(160 160 160 / 0.16); --ov-color-state-selected: rgb(75 140 255 / 0.18); --ov-color-state-focus-ring: #4b8cff; + + /* Date picker tokens — carbon */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-fg-inverse); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } /* --------------------------------------------------------------------------- @@ -947,6 +1019,18 @@ --ov-color-state-pressed: rgb(160 165 175 / 0.14); --ov-color-state-selected: rgb(80 138 255 / 0.15); --ov-color-state-focus-ring: #508aff; + + /* Date picker tokens — void */ + --ov-color-datepicker-header-bg: var(--ov-color-bg-surface-raised); + --ov-color-datepicker-cell-bg: transparent; + --ov-color-datepicker-cell-bg-today: var(--ov-color-state-selected); + --ov-color-datepicker-cell-bg-selected: var(--ov-color-brand-500); + --ov-color-datepicker-cell-bg-hover: var(--ov-color-state-hover); + --ov-color-datepicker-cell-fg: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-today: var(--ov-color-fg-default); + --ov-color-datepicker-cell-fg-selected: var(--ov-color-fg-inverse); + --ov-color-datepicker-cell-fg-disabled: var(--ov-color-fg-disabled); + --ov-color-datepicker-cell-fg-other-month: var(--ov-color-fg-muted); } :root[data-ov-density='compact'] { @@ -1118,6 +1202,8 @@ --ov-size-spinner-md: 18px; --ov-size-spinner-lg: 24px; --ov-size-tab-height: 32px; + --ov-size-datepicker-cell: 26px; + --ov-size-datepicker-gap: 1px; } :root[data-ov-density='comfortable'] { @@ -1289,6 +1375,8 @@ --ov-size-spinner-md: 20px; --ov-size-spinner-lg: 28px; --ov-size-tab-height: 35px; + --ov-size-datepicker-cell: 32px; + --ov-size-datepicker-gap: 2px; } :root[data-ov-motion='reduced'] { From 25b8e3ee7e4fb256877ac9fc2b9c2a84f1901127 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:39:39 -0500 Subject: [PATCH 02/41] feat(base-ui): pure date utilities for DatePicker Zero-dependency date helpers (startOfDay, startOfMonth, addMonths, addDays, addYears, isSameDay, isBefore, isAfter, clampDate, isDateInRange, getMonthMatrix) with 10 exhaustive unit tests covering calendar correctness including month-end clamping and week alignment. --- .../components/date-picker/dateUtils.test.ts | 86 +++++++++++++++++++ .../src/components/date-picker/dateUtils.ts | 80 +++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 packages/base-ui/src/components/date-picker/dateUtils.test.ts create mode 100644 packages/base-ui/src/components/date-picker/dateUtils.ts diff --git a/packages/base-ui/src/components/date-picker/dateUtils.test.ts b/packages/base-ui/src/components/date-picker/dateUtils.test.ts new file mode 100644 index 0000000..fc029da --- /dev/null +++ b/packages/base-ui/src/components/date-picker/dateUtils.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { + startOfDay, + startOfMonth, + addMonths, + addDays, + addYears, + isSameDay, + isBefore, + isAfter, + getMonthMatrix, + clampDate, + isDateInRange, +} from './dateUtils'; + +describe('dateUtils', () => { + it('startOfDay zeroes h/m/s/ms', () => { + const d = new Date(2026, 3, 12, 14, 30, 45, 678); + const s = startOfDay(d); + expect(s.getHours()).toBe(0); + expect(s.getMinutes()).toBe(0); + expect(s.getSeconds()).toBe(0); + expect(s.getMilliseconds()).toBe(0); + expect(s.getDate()).toBe(12); + }); + + it('startOfMonth returns day 1', () => { + const s = startOfMonth(new Date(2026, 3, 25)); + expect(s.getDate()).toBe(1); + expect(s.getMonth()).toBe(3); + }); + + it('addMonths handles month wrap and year rollover', () => { + expect(addMonths(new Date(2026, 11, 15), 1)).toEqual(new Date(2027, 0, 15)); + expect(addMonths(new Date(2026, 0, 31), 1).getMonth()).toBe(1); + }); + + it('addDays and addYears', () => { + expect(addDays(new Date(2026, 3, 12), 1)).toEqual(new Date(2026, 3, 13)); + expect(addYears(new Date(2026, 3, 12), -1)).toEqual(new Date(2025, 3, 12)); + }); + + it('isSameDay is strict on year/month/day', () => { + expect(isSameDay(new Date(2026, 3, 12), new Date(2026, 3, 12, 15, 0))).toBe(true); + expect(isSameDay(new Date(2026, 3, 12), new Date(2026, 3, 13))).toBe(false); + expect(isSameDay(new Date(2026, 3, 12), new Date(2025, 3, 12))).toBe(false); + }); + + it('isBefore / isAfter compare at day granularity', () => { + expect(isBefore(new Date(2026, 3, 11), new Date(2026, 3, 12))).toBe(true); + expect(isBefore(new Date(2026, 3, 12), new Date(2026, 3, 12))).toBe(false); + expect(isAfter(new Date(2026, 3, 13), new Date(2026, 3, 12))).toBe(true); + }); + + it('clampDate clamps to [min, max]', () => { + const min = new Date(2026, 3, 10); + const max = new Date(2026, 3, 20); + expect(clampDate(new Date(2026, 3, 5), min, max)).toEqual(min); + expect(clampDate(new Date(2026, 3, 25), min, max)).toEqual(max); + expect(clampDate(new Date(2026, 3, 15), min, max)).toEqual(new Date(2026, 3, 15)); + }); + + it('isDateInRange is inclusive', () => { + const min = new Date(2026, 3, 10); + const max = new Date(2026, 3, 20); + expect(isDateInRange(new Date(2026, 3, 10), min, max)).toBe(true); + expect(isDateInRange(new Date(2026, 3, 20), min, max)).toBe(true); + expect(isDateInRange(new Date(2026, 3, 9), min, max)).toBe(false); + expect(isDateInRange(new Date(2026, 3, 21), min, max)).toBe(false); + }); + + it('getMonthMatrix returns 6 rows of 7 days aligned to weekStartsOn', () => { + const matrix = getMonthMatrix(new Date(2026, 3, 15), 0); + expect(matrix.length).toBe(6); + expect(matrix[0]).toBeDefined(); + expect(matrix[0]!.length).toBe(7); + expect(matrix[0]![0]).toEqual(new Date(2026, 2, 29)); + expect(matrix[5]![6]!.getTime()).toBeGreaterThanOrEqual(new Date(2026, 3, 30).getTime()); + }); + + it('getMonthMatrix respects weekStartsOn=1 (Monday)', () => { + const matrix = getMonthMatrix(new Date(2026, 3, 15), 1); + expect(matrix[0]).toBeDefined(); + expect(matrix[0]![0]!.getDay()).toBe(1); + }); +}); diff --git a/packages/base-ui/src/components/date-picker/dateUtils.ts b/packages/base-ui/src/components/date-picker/dateUtils.ts new file mode 100644 index 0000000..852d924 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/dateUtils.ts @@ -0,0 +1,80 @@ +export type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export function startOfDay(d: Date): Date { + const out = new Date(d); + out.setHours(0, 0, 0, 0); + return out; +} + +export function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +export function addMonths(d: Date, n: number): Date { + const targetMonth = d.getMonth() + n; + const out = new Date(d.getFullYear(), targetMonth, 1); + // Clamp to the last day of the target month if the original day exceeds it. + const daysInTarget = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate(); + out.setDate(Math.min(d.getDate(), daysInTarget)); + return out; +} + +export function addDays(d: Date, n: number): Date { + const out = new Date(d); + out.setDate(out.getDate() + n); + return out; +} + +export function addYears(d: Date, n: number): Date { + const out = new Date(d); + out.setFullYear(out.getFullYear() + n); + return out; +} + +export function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export function isBefore(a: Date, b: Date): boolean { + return startOfDay(a).getTime() < startOfDay(b).getTime(); +} + +export function isAfter(a: Date, b: Date): boolean { + return startOfDay(a).getTime() > startOfDay(b).getTime(); +} + +export function clampDate(d: Date, min?: Date, max?: Date): Date { + if (min && isBefore(d, min)) return min; + if (max && isAfter(d, max)) return max; + return d; +} + +export function isDateInRange(d: Date, min?: Date, max?: Date): boolean { + if (min && isBefore(d, min)) return false; + if (max && isAfter(d, max)) return false; + return true; +} + +/** + * Return a 6×7 matrix of Dates that covers the visible month grid, with the + * leading cells filled from the previous month and trailing cells from the + * next month, aligned so the first column is `weekStartsOn`. + */ +export function getMonthMatrix(anchor: Date, weekStartsOn: WeekStart): Date[][] { + const firstOfMonth = startOfMonth(anchor); + const leadingOffset = (firstOfMonth.getDay() - weekStartsOn + 7) % 7; + const gridStart = addDays(firstOfMonth, -leadingOffset); + const rows: Date[][] = []; + for (let r = 0; r < 6; r++) { + const row: Date[] = []; + for (let c = 0; c < 7; c++) { + row.push(addDays(gridStart, r * 7 + c)); + } + rows.push(row); + } + return rows; +} From 27143f0ce78ae4641b539dda718e501a882c400c Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:41:07 -0500 Subject: [PATCH 03/41] feat(base-ui): Intl-based formatters for DatePicker --- .../components/date-picker/formatters.test.ts | 46 ++++++++++++++++ .../src/components/date-picker/formatters.ts | 54 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/base-ui/src/components/date-picker/formatters.test.ts create mode 100644 packages/base-ui/src/components/date-picker/formatters.ts diff --git a/packages/base-ui/src/components/date-picker/formatters.test.ts b/packages/base-ui/src/components/date-picker/formatters.test.ts new file mode 100644 index 0000000..5cad9f0 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/formatters.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { + formatDate, + getWeekStartsOnForLocale, + getWeekdayLabels, + formatMonthYear, +} from './formatters'; + +describe('formatters', () => { + it('formatDate uses Intl.DateTimeFormatOptions', () => { + const d = new Date(2026, 3, 12); + const out = formatDate(d, { dateStyle: 'short' }, 'en-US'); + expect(out).toMatch(/2026|26/); + }); + + it('formatDate accepts a function formatter', () => { + const d = new Date(2026, 3, 12); + expect(formatDate(d, (date) => `custom:${date.getFullYear()}`)).toBe('custom:2026'); + }); + + it('formatDate defaults to short date style when no format given', () => { + const d = new Date(2026, 3, 12); + const out = formatDate(d, undefined, 'en-US'); + expect(out.length).toBeGreaterThan(0); + }); + + it('getWeekStartsOnForLocale returns Sunday for en-US', () => { + expect(getWeekStartsOnForLocale('en-US')).toBe(0); + }); + + it('getWeekStartsOnForLocale returns Monday for en-GB', () => { + expect(getWeekStartsOnForLocale('en-GB')).toBe(1); + }); + + it('getWeekdayLabels returns 7 strings starting from weekStartsOn', () => { + const labels = getWeekdayLabels('en-US', 0, 'narrow'); + expect(labels).toHaveLength(7); + expect(labels[0]!).toMatch(/^S/); + }); + + it('formatMonthYear uses locale', () => { + const out = formatMonthYear(new Date(2026, 3, 1), 'en-US'); + expect(out).toMatch(/April/); + expect(out).toMatch(/2026/); + }); +}); diff --git a/packages/base-ui/src/components/date-picker/formatters.ts b/packages/base-ui/src/components/date-picker/formatters.ts new file mode 100644 index 0000000..a83e984 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/formatters.ts @@ -0,0 +1,54 @@ +import type { WeekStart } from './dateUtils'; + +export type DateFormat = Intl.DateTimeFormatOptions | ((date: Date) => string); + +export function formatDate(date: Date, format?: DateFormat, locale?: string): string { + if (typeof format === 'function') return format(date); + const options: Intl.DateTimeFormatOptions = format ?? { dateStyle: 'short' }; + return new Intl.DateTimeFormat(locale, options).format(date); +} + +export function formatMonthYear(date: Date, locale?: string): string { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(date); +} + +/** + * Return the first day of week for a given locale using Intl.Locale's + * weekInfo (modern browsers) or fall back to Sunday for en-US, Monday + * otherwise. + */ +export function getWeekStartsOnForLocale(locale?: string): WeekStart { + try { + const info = (new Intl.Locale(locale ?? 'en-US') as unknown as { + getWeekInfo?: () => { firstDay: number }; + weekInfo?: { firstDay: number }; + }); + const first = + info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay ?? undefined; + if (first !== undefined) { + return (first === 7 ? 0 : first) as WeekStart; + } + } catch { + /* fall through */ + } + const tag = (locale ?? 'en-US').toLowerCase(); + if (tag === 'en-us' || tag.startsWith('en-us-')) return 0; + return 1; +} + +export function getWeekdayLabels( + locale: string | undefined, + weekStartsOn: WeekStart, + style: 'narrow' | 'short' | 'long' = 'narrow', +): string[] { + const fmt = new Intl.DateTimeFormat(locale, { weekday: style }); + // Pick a Sunday that's stable across DST; 2026-01-04 was a Sunday. + const sunday = new Date(2026, 0, 4); + const out: string[] = []; + for (let i = 0; i < 7; i++) { + const day = new Date(sunday); + day.setDate(sunday.getDate() + ((i + weekStartsOn) % 7)); + out.push(fmt.format(day)); + } + return out; +} From 5c62deb4128c74f26dbe22dc4a18a56ead45406e Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:45:23 -0500 Subject: [PATCH 04/41] feat(base-ui): Calendar grid with WAI-ARIA keyboard nav --- .../date-picker/Calendar.module.css | 79 +++++++ .../components/date-picker/Calendar.test.tsx | 115 +++++++++ .../src/components/date-picker/Calendar.tsx | 218 ++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 packages/base-ui/src/components/date-picker/Calendar.module.css create mode 100644 packages/base-ui/src/components/date-picker/Calendar.test.tsx create mode 100644 packages/base-ui/src/components/date-picker/Calendar.tsx diff --git a/packages/base-ui/src/components/date-picker/Calendar.module.css b/packages/base-ui/src/components/date-picker/Calendar.module.css new file mode 100644 index 0000000..52c81da --- /dev/null +++ b/packages/base-ui/src/components/date-picker/Calendar.module.css @@ -0,0 +1,79 @@ +.root { + display: inline-flex; + flex-direction: column; + padding: var(--ov-primitive-space-2); + background: var(--ov-color-bg-surface-raised); + border-radius: var(--ov-primitive-radius-md); + color: var(--ov-color-fg-default); + font-family: var(--ov-primitive-font-sans); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ov-primitive-space-1); + padding: var(--ov-primitive-space-1); + background: var(--ov-color-datepicker-header-bg); + border-radius: var(--ov-primitive-radius-sm); + margin-bottom: var(--ov-primitive-space-2); +} + +.monthLabel { + font-weight: var(--ov-primitive-font-weight-medium); +} + +.grid { + display: grid; + grid-template-columns: repeat(7, var(--ov-size-datepicker-cell)); + gap: var(--ov-size-datepicker-gap); +} + +.weekday { + display: grid; + place-items: center; + height: var(--ov-size-datepicker-cell); + color: var(--ov-color-fg-muted); + font-size: var(--ov-primitive-font-size-11); +} + +.cell { + display: grid; + place-items: center; + height: var(--ov-size-datepicker-cell); + width: var(--ov-size-datepicker-cell); + border: none; + border-radius: var(--ov-primitive-radius-sm); + background: var(--ov-color-datepicker-cell-bg); + color: var(--ov-color-datepicker-cell-fg); + cursor: pointer; + transition: background var(--ov-duration-interactive) var(--ov-ease-standard); +} + +.cell:hover:not([aria-disabled='true']) { + background: var(--ov-color-datepicker-cell-bg-hover); +} + +.cell[aria-current='date'] { + background: var(--ov-color-datepicker-cell-bg-today); + color: var(--ov-color-datepicker-cell-fg-today); +} + +.cell[aria-selected='true'] { + background: var(--ov-color-datepicker-cell-bg-selected); + color: var(--ov-color-datepicker-cell-fg-selected); +} + +.cell[aria-disabled='true'] { + color: var(--ov-color-datepicker-cell-fg-disabled); + cursor: not-allowed; +} + +.otherMonth { + color: var(--ov-color-datepicker-cell-fg-other-month); +} + +.cell:focus-visible { + outline: 2px solid var(--ov-color-border-focus); + outline-offset: -2px; +} diff --git a/packages/base-ui/src/components/date-picker/Calendar.test.tsx b/packages/base-ui/src/components/date-picker/Calendar.test.tsx new file mode 100644 index 0000000..c84c793 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/Calendar.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Calendar } from './Calendar'; + +const ANCHOR = new Date(2026, 3, 15); // April 15, 2026 + +describe('Calendar', () => { + it('renders 7 weekday headers and 42 day cells', () => { + render( {}} locale="en-US" />); + expect(screen.getAllByRole('columnheader')).toHaveLength(7); + expect(screen.getAllByRole('gridcell')).toHaveLength(42); + }); + + it('marks the selected day with aria-selected', () => { + render( {}} locale="en-US" />); + const selected = screen.getByRole('gridcell', { selected: true }); + expect(selected.textContent).toContain('15'); + }); + + it('onChange fires when a day is clicked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + const arg = onChange.mock.calls[0]?.[0] as Date; + expect(arg.getDate()).toBe(20); + }); + + it('arrow keys move focus across days', async () => { + const user = userEvent.setup(); + render( {}} locale="en-US" autoFocus />); + const initial = screen.getByRole('gridcell', { selected: true }); + initial.focus(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement?.textContent).toContain('16'); + await user.keyboard('{ArrowDown}'); + expect(document.activeElement?.textContent).toContain('23'); + }); + + it('Home moves to first day of week; End moves to last day of week', async () => { + const user = userEvent.setup(); + render( {}} locale="en-US" weekStartsOn={0} autoFocus />); + screen.getByRole('gridcell', { selected: true }).focus(); + await user.keyboard('{Home}'); + expect(document.activeElement?.textContent).toContain('12'); + await user.keyboard('{End}'); + expect(document.activeElement?.textContent).toContain('18'); + }); + + it('PageUp/PageDown navigate months', async () => { + const user = userEvent.setup(); + render( {}} locale="en-US" autoFocus />); + screen.getByRole('gridcell', { selected: true }).focus(); + await user.keyboard('{PageDown}'); + const focused = document.activeElement as HTMLElement; + expect(focused.getAttribute('data-date')).toBe('2026-05-15'); + }); + + it('Shift+PageUp/Shift+PageDown navigate years', async () => { + const user = userEvent.setup(); + render( {}} locale="en-US" autoFocus />); + screen.getByRole('gridcell', { selected: true }).focus(); + await user.keyboard('{Shift>}{PageDown}{/Shift}'); + const focused = document.activeElement as HTMLElement; + expect(focused.getAttribute('data-date')).toBe('2027-04-15'); + }); + + it('Enter and Space select the focused day', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + screen.getByRole('gridcell', { selected: true }).focus(); + await user.keyboard('{ArrowRight}{Enter}'); + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + expect((onChange.mock.calls[0]?.[0] as Date).getDate()).toBe(16); + }); + + it('disables days outside [min, max]', () => { + const min = new Date(2026, 3, 10); + const max = new Date(2026, 3, 20); + render( {}} locale="en-US" min={min} max={max} />); + const cellFor = (day: number) => + screen.getByRole('gridcell', { name: new RegExp(`^${day}`) }); + expect(cellFor(5)).toHaveAttribute('aria-disabled', 'true'); + expect(cellFor(15)).not.toHaveAttribute('aria-disabled'); + expect(cellFor(25)).toHaveAttribute('aria-disabled', 'true'); + }); + + it('isDateDisabled overrides individual cells', () => { + const isDateDisabled = (d: Date) => d.getDate() === 14; + render( + {}} + locale="en-US" + isDateDisabled={isDateDisabled} + />, + ); + expect(screen.getByRole('gridcell', { name: /^14/ })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + it('marks today with aria-current="date"', () => { + const today = new Date(); + render( {}} locale="en-US" />); + const todayCell = screen.getByRole('gridcell', { + name: new RegExp(`^${today.getDate()}`), + }); + expect(todayCell).toHaveAttribute('aria-current', 'date'); + }); +}); diff --git a/packages/base-ui/src/components/date-picker/Calendar.tsx b/packages/base-ui/src/components/date-picker/Calendar.tsx new file mode 100644 index 0000000..7d2f246 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/Calendar.tsx @@ -0,0 +1,218 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import styles from './Calendar.module.css'; +import { + addDays, + addMonths, + addYears, + getMonthMatrix, + isDateInRange, + isSameDay, + startOfMonth, + type WeekStart, +} from './dateUtils'; +import { + formatMonthYear, + getWeekStartsOnForLocale, + getWeekdayLabels, +} from './formatters'; + +export interface CalendarProps { + value: Date | null; + onChange: (value: Date) => void; + min?: Date; + max?: Date; + isDateDisabled?: (date: Date) => boolean; + locale?: string; + weekStartsOn?: WeekStart; + autoFocus?: boolean; + className?: string; +} + +function toIsoDay(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export function Calendar({ + value, + onChange, + min, + max, + isDateDisabled, + locale, + weekStartsOn, + autoFocus, + className, +}: CalendarProps) { + const resolvedWeekStart = weekStartsOn ?? getWeekStartsOnForLocale(locale); + const [focusedDate, setFocusedDate] = useState(() => value ?? new Date()); + const [viewMonth, setViewMonth] = useState(() => startOfMonth(value ?? new Date())); + const gridRef = useRef(null); + const rovingRef = useRef(null); + + useEffect(() => { + if (autoFocus) rovingRef.current?.focus(); + }, [autoFocus]); + + useEffect(() => { + setViewMonth((prev) => + prev.getMonth() === focusedDate.getMonth() && + prev.getFullYear() === focusedDate.getFullYear() + ? prev + : startOfMonth(focusedDate), + ); + }, [focusedDate]); + + const matrix = useMemo( + () => getMonthMatrix(viewMonth, resolvedWeekStart), + [viewMonth, resolvedWeekStart], + ); + const weekdayLabels = useMemo( + () => getWeekdayLabels(locale, resolvedWeekStart), + [locale, resolvedWeekStart], + ); + const today = useMemo(() => new Date(), []); + + const isCellDisabled = useCallback( + (d: Date) => { + if (!isDateInRange(d, min, max)) return true; + if (isDateDisabled?.(d)) return true; + return false; + }, + [min, max, isDateDisabled], + ); + + const moveFocus = useCallback((next: Date) => { + setFocusedDate(next); + // Use a microtask to wait for the re-render so the new cell exists in the DOM + queueMicrotask(() => { + const selector = `[data-date='${toIsoDay(next)}']`; + gridRef.current?.querySelector(selector)?.focus(); + }); + }, []); + + const onKeyDown = (e: KeyboardEvent) => { + const current = focusedDate; + let next: Date | null = null; + switch (e.key) { + case 'ArrowRight': next = addDays(current, 1); break; + case 'ArrowLeft': next = addDays(current, -1); break; + case 'ArrowDown': next = addDays(current, 7); break; + case 'ArrowUp': next = addDays(current, -7); break; + case 'Home': { + const diff = (current.getDay() - resolvedWeekStart + 7) % 7; + next = addDays(current, -diff); + break; + } + case 'End': { + const diff = (current.getDay() - resolvedWeekStart + 7) % 7; + next = addDays(current, 6 - diff); + break; + } + case 'PageUp': + next = e.shiftKey ? addYears(current, -1) : addMonths(current, -1); + break; + case 'PageDown': + next = e.shiftKey ? addYears(current, 1) : addMonths(current, 1); + break; + case 'Enter': + case ' ': + if (!isCellDisabled(current)) onChange(current); + e.preventDefault(); + return; + default: + return; + } + if (next) { + e.preventDefault(); + moveFocus(next); + } + }; + + return ( +
+
+ + + {formatMonthYear(viewMonth, locale)} + + +
+
+
+ {weekdayLabels.map((label, i) => ( +
+ {label} +
+ ))} +
+ {matrix.map((row, ri) => ( +
+ {row.map((date) => { + const iso = toIsoDay(date); + const disabled = isCellDisabled(date); + const selected = value ? isSameDay(date, value) : false; + const isToday = isSameDay(date, today); + const inMonth = date.getMonth() === viewMonth.getMonth(); + const focused = isSameDay(date, focusedDate); + return ( + + ); + })} +
+ ))} +
+
+ ); +} From 65e035b556288198bdbfb19ee6c4d6dafb6cd77f Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:47:57 -0500 Subject: [PATCH 05/41] feat(base-ui): DatePicker component with compound API Wraps Calendar in a Base UI Popover with controlled/uncontrolled value support, Intl-based formatting, and full keyboard accessibility (Escape returns focus to trigger, day selection closes popup). --- .../date-picker/DatePicker.module.css | 44 +++++++ .../date-picker/DatePicker.test.tsx | 56 +++++++++ .../src/components/date-picker/DatePicker.tsx | 111 ++++++++++++++++++ .../src/components/date-picker/index.ts | 4 + packages/base-ui/src/components/index.ts | 1 + 5 files changed, 216 insertions(+) create mode 100644 packages/base-ui/src/components/date-picker/DatePicker.module.css create mode 100644 packages/base-ui/src/components/date-picker/DatePicker.test.tsx create mode 100644 packages/base-ui/src/components/date-picker/DatePicker.tsx create mode 100644 packages/base-ui/src/components/date-picker/index.ts diff --git a/packages/base-ui/src/components/date-picker/DatePicker.module.css b/packages/base-ui/src/components/date-picker/DatePicker.module.css new file mode 100644 index 0000000..6fd08cb --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.module.css @@ -0,0 +1,44 @@ +.trigger { + display: inline-flex; + align-items: center; + gap: var(--ov-space-inline-sm); + min-height: var(--ov-size-button-height-md); + padding-inline: var(--ov-size-button-padding-inline-md); + background: var(--ov-color-bg-surface-raised); + color: var(--ov-color-fg-default); + border: 1px solid var(--ov-color-border-default); + border-radius: var(--ov-radius-control); + font-family: var(--ov-font-sans); + font-size: var(--ov-font-size-body); + cursor: pointer; + transition: + background-color var(--ov-duration-interactive) var(--ov-ease-standard), + border-color var(--ov-duration-interactive) var(--ov-ease-standard); +} + +.trigger:hover { + background: var(--ov-color-state-hover); +} + +.trigger:focus-visible { + outline: none; + border-color: var(--ov-color-border-focus); + box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); +} + +.trigger:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.placeholder { + color: var(--ov-color-fg-muted); +} + +.popup { + background: var(--ov-color-bg-surface); + border: 1px solid var(--ov-color-border-default); + border-radius: var(--ov-radius-surface); + box-shadow: var(--ov-shadow-surface); + padding: 0; +} diff --git a/packages/base-ui/src/components/date-picker/DatePicker.test.tsx b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx new file mode 100644 index 0000000..7089c4f --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DatePicker } from './DatePicker'; + +describe('DatePicker (convenience)', () => { + it('renders a closed trigger by default', () => { + render( {}} placeholder="Pick a date" />); + expect(screen.getByText('Pick a date')).toBeInTheDocument(); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + it('opens the popover when the trigger is clicked', async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button')); + expect(screen.getByRole('grid')).toBeInTheDocument(); + }); + + it('calls onChange and closes the popover when a day is selected', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + it('Escape closes the popover and returns focus to the trigger', async () => { + const user = userEvent.setup(); + render( {}} />); + const trigger = screen.getByRole('button'); + await user.click(trigger); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(trigger); + }); + + it('disables the trigger when disabled=true', () => { + render( {}} disabled />); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('formats the value using provided Intl options', () => { + render( + {}} + locale="en-US" + format={{ year: 'numeric', month: 'long', day: 'numeric' }} + />, + ); + expect(screen.getByRole('button').textContent).toMatch(/April 12, 2026/); + }); +}); diff --git a/packages/base-ui/src/components/date-picker/DatePicker.tsx b/packages/base-ui/src/components/date-picker/DatePicker.tsx new file mode 100644 index 0000000..db755c1 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.tsx @@ -0,0 +1,111 @@ +import { Popover } from '@base-ui/react/popover'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import styles from './DatePicker.module.css'; +import { Calendar, type CalendarProps } from './Calendar'; +import { formatDate, type DateFormat } from './formatters'; +import type { WeekStart } from './dateUtils'; +import type { StyledComponentProps } from '../../system/types'; + +export interface DatePickerProps extends StyledComponentProps { + value?: Date | null; + defaultValue?: Date | null; + onChange?: (value: Date | null) => void; + min?: Date; + max?: Date; + isDateDisabled?: (date: Date) => boolean; + format?: DateFormat; + locale?: string; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + weekStartsOn?: WeekStart; + className?: string; +} + +function useControlled( + value: T | undefined, + defaultValue: T, + onChange?: (value: T) => void, +): [T, (next: T) => void] { + const isControlled = value !== undefined; + const [internal, setInternal] = useState(defaultValue); + const current = isControlled ? (value as T) : internal; + const set = useCallback( + (next: T) => { + if (!isControlled) setInternal(next); + onChange?.(next); + }, + [isControlled, onChange], + ); + return [current, set]; +} + +export function DatePicker(props: DatePickerProps) { + const { + value, + defaultValue = null, + onChange, + min, + max, + isDateDisabled, + format, + locale, + placeholder, + disabled, + readOnly, + weekStartsOn, + className, + } = props; + + const [current, setCurrent] = useControlled(value, defaultValue, onChange); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const label = useMemo( + () => (current ? formatDate(current, format, locale) : null), + [current, format, locale], + ); + + const handleSelect: CalendarProps['onChange'] = (next) => { + setCurrent(next); + setOpen(false); + queueMicrotask(() => triggerRef.current?.focus()); + }; + + return ( + + + {label ?? ( + {placeholder ?? 'Select a date'} + )} + + + + + + + + + + ); +} + +DatePicker.Root = Popover.Root; +DatePicker.Trigger = Popover.Trigger; +DatePicker.Popup = Popover.Popup; +DatePicker.Calendar = Calendar; diff --git a/packages/base-ui/src/components/date-picker/index.ts b/packages/base-ui/src/components/date-picker/index.ts new file mode 100644 index 0000000..7aac6de --- /dev/null +++ b/packages/base-ui/src/components/date-picker/index.ts @@ -0,0 +1,4 @@ +export { DatePicker } from './DatePicker'; +export type { DatePickerProps } from './DatePicker'; +export { Calendar } from './Calendar'; +export type { CalendarProps } from './Calendar'; diff --git a/packages/base-ui/src/components/index.ts b/packages/base-ui/src/components/index.ts index c7f7c96..7556c72 100644 --- a/packages/base-ui/src/components/index.ts +++ b/packages/base-ui/src/components/index.ts @@ -86,3 +86,4 @@ export * from './status-bar'; export * from './timeline'; export * from './sortable-table'; export * from './file-table'; +export * from './date-picker'; From 5228945f704280f9ca9e0b65cd25cc34be268adb Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:50:40 -0500 Subject: [PATCH 06/41] feat(base-ui): TimePicker with 12/24 hour support and minute step --- packages/base-ui/src/components/index.ts | 1 + .../time-picker/TimePicker.module.css | 36 ++++ .../time-picker/TimePicker.test.tsx | 85 +++++++++ .../src/components/time-picker/TimePicker.tsx | 178 ++++++++++++++++++ .../src/components/time-picker/index.ts | 2 + 5 files changed, 302 insertions(+) create mode 100644 packages/base-ui/src/components/time-picker/TimePicker.module.css create mode 100644 packages/base-ui/src/components/time-picker/TimePicker.test.tsx create mode 100644 packages/base-ui/src/components/time-picker/TimePicker.tsx create mode 100644 packages/base-ui/src/components/time-picker/index.ts diff --git a/packages/base-ui/src/components/index.ts b/packages/base-ui/src/components/index.ts index 7556c72..f434b73 100644 --- a/packages/base-ui/src/components/index.ts +++ b/packages/base-ui/src/components/index.ts @@ -87,3 +87,4 @@ export * from './timeline'; export * from './sortable-table'; export * from './file-table'; export * from './date-picker'; +export * from './time-picker'; diff --git a/packages/base-ui/src/components/time-picker/TimePicker.module.css b/packages/base-ui/src/components/time-picker/TimePicker.module.css new file mode 100644 index 0000000..4ba311b --- /dev/null +++ b/packages/base-ui/src/components/time-picker/TimePicker.module.css @@ -0,0 +1,36 @@ +.root { + display: inline-flex; + align-items: center; + gap: var(--ov-space-inline-xs); + font-family: var(--ov-font-mono); + color: var(--ov-color-fg-default); +} + +.field { + width: 2.5ch; + background: transparent; + border: none; + color: inherit; + font: inherit; + text-align: center; +} + +.field:focus-visible { + outline: 2px solid var(--ov-color-border-focus); + outline-offset: 2px; + border-radius: 2px; +} + +.separator { color: var(--ov-color-fg-muted); } + +.meridiem { + min-width: 3ch; + background: var(--ov-color-bg-surface-raised); + border: 1px solid var(--ov-color-border-default); + border-radius: var(--ov-radius-control); + color: var(--ov-color-fg-default); + cursor: pointer; + padding-inline: var(--ov-space-inline-sm); +} + +.root[data-disabled] { opacity: 0.6; } diff --git a/packages/base-ui/src/components/time-picker/TimePicker.test.tsx b/packages/base-ui/src/components/time-picker/TimePicker.test.tsx new file mode 100644 index 0000000..b629ccb --- /dev/null +++ b/packages/base-ui/src/components/time-picker/TimePicker.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TimePicker } from './TimePicker'; + +describe('TimePicker', () => { + it('renders hour and minute inputs; no seconds by default', () => { + render( {}} />); + expect(screen.getByLabelText(/hour/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/minute/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/second/i)).not.toBeInTheDocument(); + }); + + it('shows seconds when showSeconds=true', () => { + render( {}} showSeconds />); + expect(screen.getByLabelText(/second/i)).toBeInTheDocument(); + }); + + it('calls onChange when the hour is edited (24-hour)', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const hour = screen.getByLabelText(/hour/i) as HTMLInputElement; + await user.clear(hour); + await user.type(hour, '14'); + await user.tab(); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getHours()).toBe(14); + expect(last.getMinutes()).toBe(30); + }); + + it('shows AM/PM toggle when hourCycle=12', () => { + render( + {}} hourCycle={12} />, + ); + expect(screen.getByRole('button', { name: /pm/i })).toBeInTheDocument(); + }); + + it('toggling PM→AM subtracts 12 from a 14:00 value', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: /pm/i })); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getHours()).toBe(2); + }); + + it('minuteStep=5 clamps a typed 37 to 35', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const minute = screen.getByLabelText(/minute/i) as HTMLInputElement; + await user.clear(minute); + await user.type(minute, '37'); + await user.tab(); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getMinutes()).toBe(35); + }); + + it('ignores edits when readOnly', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const hour = screen.getByLabelText(/hour/i) as HTMLInputElement; + await user.type(hour, '14'); + await user.tab(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('disables all inputs when disabled', () => { + render( + {}} disabled />, + ); + expect(screen.getByLabelText(/hour/i)).toBeDisabled(); + expect(screen.getByLabelText(/minute/i)).toBeDisabled(); + }); +}); diff --git a/packages/base-ui/src/components/time-picker/TimePicker.tsx b/packages/base-ui/src/components/time-picker/TimePicker.tsx new file mode 100644 index 0000000..41609a1 --- /dev/null +++ b/packages/base-ui/src/components/time-picker/TimePicker.tsx @@ -0,0 +1,178 @@ +import { useCallback, useId, useRef, useState } from 'react'; +import styles from './TimePicker.module.css'; +import type { StyledComponentProps } from '../../system/types'; + +export interface TimePickerProps extends StyledComponentProps { + value?: Date | null; + defaultValue?: Date | null; + onChange?: (value: Date) => void; + showSeconds?: boolean; + hourCycle?: 12 | 24; + minuteStep?: number; + disabled?: boolean; + readOnly?: boolean; + className?: string; + 'aria-label'?: string; +} + +function clampToStep(value: number, step: number, max: number): number { + if (step <= 1) return Math.max(0, Math.min(max, value)); + const snapped = Math.floor(value / step) * step; + return Math.max(0, Math.min(max, snapped)); +} + +type FieldName = 'hour' | 'minute' | 'second'; + +export function TimePicker(props: TimePickerProps) { + const { + value, + onChange, + showSeconds = false, + hourCycle = 24, + minuteStep = 1, + disabled = false, + readOnly = false, + className, + } = props; + + const current = value ?? new Date(); + const id = useId(); + + const h24 = current.getHours(); + const isPM = h24 >= 12; + const displayedHour = hourCycle === 12 ? ((h24 + 11) % 12) + 1 : h24; + + // Draft state: tracks what the user is currently typing, null = use canonical value + const [draft, setDraft] = useState>>({}); + + const emitHour = useCallback( + (text: string) => { + if (readOnly) return; + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) return; + let hours: number; + if (hourCycle === 12) { + const clamped = Math.max(1, Math.min(12, parsed)); + hours = (clamped % 12) + (isPM ? 12 : 0); + } else { + hours = Math.max(0, Math.min(23, parsed)); + } + const next = new Date(current); + next.setHours(hours, current.getMinutes(), current.getSeconds(), 0); + onChange?.(next); + }, + [readOnly, hourCycle, isPM, current, onChange], + ); + + const emitMinute = useCallback( + (text: string) => { + if (readOnly) return; + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) return; + const next = new Date(current); + next.setHours(current.getHours(), clampToStep(parsed, minuteStep, 59), current.getSeconds(), 0); + onChange?.(next); + }, + [readOnly, current, minuteStep, onChange], + ); + + const emitSecond = useCallback( + (text: string) => { + if (readOnly) return; + const parsed = Number.parseInt(text, 10); + if (Number.isNaN(parsed)) return; + const next = new Date(current); + next.setHours(current.getHours(), current.getMinutes(), Math.max(0, Math.min(59, parsed)), 0); + onChange?.(next); + }, + [readOnly, current, onChange], + ); + + const toggleMeridiem = () => { + if (readOnly) return; + const next = isPM ? h24 - 12 : h24 + 12; + const d = new Date(current); + d.setHours(next, current.getMinutes(), current.getSeconds(), 0); + onChange?.(d); + }; + + const hourDisplay = draft.hour ?? String(displayedHour).padStart(2, '0'); + const minuteDisplay = draft.minute ?? String(current.getMinutes()).padStart(2, '0'); + const secondDisplay = draft.second ?? String(current.getSeconds()).padStart(2, '0'); + + return ( +
+ { + setDraft((d) => ({ ...d, hour: e.target.value })); + }} + onBlur={(e) => { + emitHour(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.hour; return n; }); + }} + /> + + { + setDraft((d) => ({ ...d, minute: e.target.value })); + }} + onBlur={(e) => { + emitMinute(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.minute; return n; }); + }} + /> + {showSeconds && ( + <> + + { + setDraft((d) => ({ ...d, second: e.target.value })); + }} + onBlur={(e) => { + emitSecond(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.second; return n; }); + }} + /> + + )} + {hourCycle === 12 && ( + + )} +
+ ); +} diff --git a/packages/base-ui/src/components/time-picker/index.ts b/packages/base-ui/src/components/time-picker/index.ts new file mode 100644 index 0000000..1d8866f --- /dev/null +++ b/packages/base-ui/src/components/time-picker/index.ts @@ -0,0 +1,2 @@ +export { TimePicker } from './TimePicker'; +export type { TimePickerProps } from './TimePicker'; From 9c60c83efac512e75cb6e52d09da017af1f45ca1 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:51:17 -0500 Subject: [PATCH 07/41] chore(base-ui): remove unused useRef import from TimePicker --- packages/base-ui/src/components/time-picker/TimePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base-ui/src/components/time-picker/TimePicker.tsx b/packages/base-ui/src/components/time-picker/TimePicker.tsx index 41609a1..d4bd554 100644 --- a/packages/base-ui/src/components/time-picker/TimePicker.tsx +++ b/packages/base-ui/src/components/time-picker/TimePicker.tsx @@ -1,4 +1,4 @@ -import { useCallback, useId, useRef, useState } from 'react'; +import { useCallback, useId, useState } from 'react'; import styles from './TimePicker.module.css'; import type { StyledComponentProps } from '../../system/types'; From b2b5aa3bc80884c0b64d7da15a36887584aba239 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 15:53:16 -0500 Subject: [PATCH 08/41] feat(base-ui): DateTimePicker composing DatePicker and TimePicker --- .../DateTimePicker.module.css | 13 ++ .../date-time-picker/DateTimePicker.test.tsx | 52 +++++++ .../date-time-picker/DateTimePicker.tsx | 132 ++++++++++++++++++ .../src/components/date-time-picker/index.ts | 2 + packages/base-ui/src/components/index.ts | 1 + 5 files changed, 200 insertions(+) create mode 100644 packages/base-ui/src/components/date-time-picker/DateTimePicker.module.css create mode 100644 packages/base-ui/src/components/date-time-picker/DateTimePicker.test.tsx create mode 100644 packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx create mode 100644 packages/base-ui/src/components/date-time-picker/index.ts diff --git a/packages/base-ui/src/components/date-time-picker/DateTimePicker.module.css b/packages/base-ui/src/components/date-time-picker/DateTimePicker.module.css new file mode 100644 index 0000000..2cfbd65 --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.module.css @@ -0,0 +1,13 @@ +.combo { + display: flex; + flex-direction: column; + gap: var(--ov-space-stack-sm); + padding: var(--ov-space-inline-sm); +} + +.timeRow { + display: flex; + justify-content: center; + padding: var(--ov-space-inline-xs); + border-top: 1px solid var(--ov-color-border-default); +} diff --git a/packages/base-ui/src/components/date-time-picker/DateTimePicker.test.tsx b/packages/base-ui/src/components/date-time-picker/DateTimePicker.test.tsx new file mode 100644 index 0000000..6797e7c --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTimePicker } from './DateTimePicker'; + +describe('DateTimePicker', () => { + it('opens popover and renders both Calendar and TimePicker', async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button')); + expect(screen.getByRole('grid')).toBeInTheDocument(); + expect(screen.getByLabelText(/hour/i)).toBeInTheDocument(); + }); + + it('selecting a day preserves the current time-of-day', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getDate()).toBe(20); + expect(last.getHours()).toBe(9); + expect(last.getMinutes()).toBe(30); + }); + + it('changing the hour preserves the current date', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button')); + const hour = screen.getByLabelText(/hour/i) as HTMLInputElement; + await user.clear(hour); + await user.type(hour, '15'); + await user.tab(); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getDate()).toBe(12); + expect(last.getHours()).toBe(15); + }); +}); diff --git a/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx b/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx new file mode 100644 index 0000000..ed35d9e --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx @@ -0,0 +1,132 @@ +import { Popover } from '@base-ui/react/popover'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Calendar } from '../date-picker/Calendar'; +import { formatDate, type DateFormat } from '../date-picker/formatters'; +import type { WeekStart } from '../date-picker/dateUtils'; +import { TimePicker } from '../time-picker/TimePicker'; +import pickerStyles from '../date-picker/DatePicker.module.css'; +import styles from './DateTimePicker.module.css'; +import type { StyledComponentProps } from '../../system/types'; + +export interface DateTimePickerProps extends StyledComponentProps { + value?: Date | null; + defaultValue?: Date | null; + onChange?: (value: Date | null) => void; + min?: Date; + max?: Date; + isDateDisabled?: (date: Date) => boolean; + format?: DateFormat; + locale?: string; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + weekStartsOn?: WeekStart; + showSeconds?: boolean; + hourCycle?: 12 | 24; + minuteStep?: number; + className?: string; +} + +export function DateTimePicker(props: DateTimePickerProps) { + const { + value, + defaultValue = null, + onChange, + min, + max, + isDateDisabled, + format, + locale, + placeholder, + disabled, + readOnly, + weekStartsOn, + showSeconds, + hourCycle, + minuteStep, + className, + } = props; + + const isControlled = value !== undefined; + const [internal, setInternal] = useState(defaultValue); + const current = isControlled ? (value as Date | null) : internal; + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const emit = useCallback( + (next: Date | null) => { + if (!isControlled) setInternal(next); + onChange?.(next); + }, + [isControlled, onChange], + ); + + const onDateChange = (d: Date) => { + const base = current ?? new Date(); + const next = new Date(d); + next.setHours(base.getHours(), base.getMinutes(), base.getSeconds(), 0); + emit(next); + }; + + const onTimeChange = (t: Date) => { + const base = current ?? new Date(); + const next = new Date(base); + next.setHours(t.getHours(), t.getMinutes(), t.getSeconds(), 0); + emit(next); + }; + + const label = useMemo( + () => + current + ? formatDate(current, format ?? { dateStyle: 'short', timeStyle: 'short' }, locale) + : null, + [current, format, locale], + ); + + return ( + + + {label ?? ( + + {placeholder ?? 'Select date and time'} + + )} + + + + +
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/packages/base-ui/src/components/date-time-picker/index.ts b/packages/base-ui/src/components/date-time-picker/index.ts new file mode 100644 index 0000000..339f41d --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/index.ts @@ -0,0 +1,2 @@ +export { DateTimePicker } from './DateTimePicker'; +export type { DateTimePickerProps } from './DateTimePicker'; diff --git a/packages/base-ui/src/components/index.ts b/packages/base-ui/src/components/index.ts index f434b73..5b3c449 100644 --- a/packages/base-ui/src/components/index.ts +++ b/packages/base-ui/src/components/index.ts @@ -88,3 +88,4 @@ export * from './sortable-table'; export * from './file-table'; export * from './date-picker'; export * from './time-picker'; +export * from './date-time-picker'; From e8b76305453f6a76f26324804960902676f1a5df Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 16:14:20 -0500 Subject: [PATCH 09/41] docs(base-ui): Storybook stories for date/time pickers Adds stories for DatePicker (Default, Controlled, WithMinMax, LongFormat, Disabled), TimePicker (TwentyFourHour, TwelveHour, WithSeconds, MinuteStep15, Disabled), and DateTimePicker (Default, WithSeconds, TwelveHour), following project conventions (satisfies Meta, autodocs, render + args pattern). --- .../date-picker/DatePicker.stories.tsx | 79 +++++++++++++++++++ .../DateTimePicker.stories.tsx | 47 +++++++++++ .../time-picker/TimePicker.stories.tsx | 64 +++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 packages/base-ui/src/components/date-picker/DatePicker.stories.tsx create mode 100644 packages/base-ui/src/components/date-time-picker/DateTimePicker.stories.tsx create mode 100644 packages/base-ui/src/components/time-picker/TimePicker.stories.tsx diff --git a/packages/base-ui/src/components/date-picker/DatePicker.stories.tsx b/packages/base-ui/src/components/date-picker/DatePicker.stories.tsx new file mode 100644 index 0000000..8a0fd35 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { DatePicker } from './DatePicker'; + +const meta = { + title: 'Components/DatePicker', + component: DatePicker, + tags: ['autodocs'], + args: { + placeholder: 'Pick a date', + disabled: false, + }, + argTypes: { + disabled: { control: 'boolean' }, + placeholder: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [value, setValue] = useState(null); + return ; + }, +}; + +export const Controlled: Story = { + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; + +export const WithMinMax: Story = { + render: (args) => { + const [value, setValue] = useState(null); + const today = new Date(); + const min = new Date(today); + min.setDate(today.getDate() - 7); + const max = new Date(today); + max.setDate(today.getDate() + 7); + return ( + + ); + }, +}; + +export const LongFormat: Story = { + render: (args) => { + const [value, setValue] = useState(new Date()); + return ( + + ); + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; diff --git a/packages/base-ui/src/components/date-time-picker/DateTimePicker.stories.tsx b/packages/base-ui/src/components/date-time-picker/DateTimePicker.stories.tsx new file mode 100644 index 0000000..fdf8272 --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { DateTimePicker } from './DateTimePicker'; + +const meta = { + title: 'Components/DateTimePicker', + component: DateTimePicker, + tags: ['autodocs'], + args: { + placeholder: 'Select date and time', + disabled: false, + showSeconds: false, + hourCycle: 24, + }, + argTypes: { + disabled: { control: 'boolean' }, + showSeconds: { control: 'boolean' }, + hourCycle: { control: 'inline-radio', options: [12, 24] }, + placeholder: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [value, setValue] = useState(null); + return ; + }, +}; + +export const WithSeconds: Story = { + args: { showSeconds: true }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; + +export const TwelveHour: Story = { + args: { hourCycle: 12 }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; diff --git a/packages/base-ui/src/components/time-picker/TimePicker.stories.tsx b/packages/base-ui/src/components/time-picker/TimePicker.stories.tsx new file mode 100644 index 0000000..de64f4d --- /dev/null +++ b/packages/base-ui/src/components/time-picker/TimePicker.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { TimePicker } from './TimePicker'; + +const meta = { + title: 'Components/TimePicker', + component: TimePicker, + tags: ['autodocs'], + args: { + hourCycle: 24, + showSeconds: false, + minuteStep: 1, + disabled: false, + }, + argTypes: { + hourCycle: { control: 'inline-radio', options: [12, 24] }, + showSeconds: { control: 'boolean' }, + minuteStep: { control: 'select', options: [1, 5, 10, 15, 30] }, + disabled: { control: 'boolean' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TwentyFourHour: Story = { + args: { hourCycle: 24 }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; + +export const TwelveHour: Story = { + args: { hourCycle: 12 }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; + +export const WithSeconds: Story = { + args: { showSeconds: true }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; + +export const MinuteStep15: Story = { + args: { minuteStep: 15 }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, + render: (args) => { + const [value, setValue] = useState(new Date()); + return ; + }, +}; From ae8c649deee4ca7c3d6a8716b8d44280cc189592 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Sun, 12 Apr 2026 16:16:07 -0500 Subject: [PATCH 10/41] docs(base-ui): note date/time pickers in component status Add Calendar, DatePicker, DateTimePicker, and TimePicker to the approved component set in COMPONENT_STATUS.md with brief feature descriptions. --- docs/COMPONENT_STATUS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/COMPONENT_STATUS.md b/docs/COMPONENT_STATUS.md index c1d86d7..c8c91b4 100644 --- a/docs/COMPONENT_STATUS.md +++ b/docs/COMPONENT_STATUS.md @@ -20,6 +20,7 @@ These are the only components currently in scope and exported: - `Breadcrumbs` - `Button` - `ButtonGroup` +- `Calendar` — calendar grid with full keyboard nav, min/max bounds, and custom disabled-cell support (internal to `DatePicker`; also exported standalone) - `Card` - `Checkbox` - `CheckboxGroup` @@ -30,6 +31,8 @@ These are the only components currently in scope and exported: - `ConfirmButton` - `Container` - `ContextMenu` +- `DatePicker` — calendar popover input with full keyboard navigation, min/max constraints, and custom disabled-cell callback +- `DateTimePicker` — composition of `DatePicker` + `TimePicker` producing a combined date-and-time value - `DescriptionList` - `Dialog` - `DockLayout` @@ -69,6 +72,7 @@ These are the only components currently in scope and exported: - `TagInput` - `TextField` - `TextArea` +- `TimePicker` — 12/24-hour time input with optional seconds display and configurable minute step - `Toast` - `Tooltip` - `ToggleButton` From 039dd97c3a61ace03fa6ead036402e0cf9b2f8c7 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Thu, 16 Apr 2026 23:13:23 -0500 Subject: [PATCH 11/41] feat(showcase): add date/time pickers interactive demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a live showcase demo for DatePicker, TimePicker, and DateTimePicker — each displaying its current value alongside the picker, with meaningful variations (min/max bounds, 12-hour + seconds, 24-hour + minuteStep=15). --- .../src/demos/date-time-pickers/index.tsx | 139 ++++++++++++++++++ apps/showcase/src/registry.ts | 8 + 2 files changed, 147 insertions(+) create mode 100644 apps/showcase/src/demos/date-time-pickers/index.tsx diff --git a/apps/showcase/src/demos/date-time-pickers/index.tsx b/apps/showcase/src/demos/date-time-pickers/index.tsx new file mode 100644 index 0000000..05532b6 --- /dev/null +++ b/apps/showcase/src/demos/date-time-pickers/index.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { + DatePicker, + TimePicker, + DateTimePicker, + Stack, + Card, + Heading, + Text, +} from '@omniviewdev/base-ui'; + +const today = new Date(); +const twoWeeksAgo = new Date(today); +twoWeeksAgo.setDate(today.getDate() - 14); +const twoWeeksAhead = new Date(today); +twoWeeksAhead.setDate(today.getDate() + 14); + +function formatDateDisplay(date: Date | null): string { + if (!date) return '—'; + return date.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function formatTimeDisplay(date: Date | null): string { + if (!date) return '—'; + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatDateTimeDisplay(date: Date | null): string { + if (!date) return '—'; + return date.toLocaleString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function DateTimePickersDemo() { + const [date, setDate] = useState(null); + const [time, setTime] = useState(today); + const [dateTime, setDateTime] = useState(null); + + return ( + + + Date & Time Pickers + + Interactive demo of DatePicker, TimePicker, and DateTimePicker components. + + + + {/* DatePicker */} + + + DatePicker + + Calendar popover with min/max bounds (±14 days from today). + + + + + + + Selected + {formatDateDisplay(date)} + + + + + + {/* TimePicker */} + + + TimePicker + + 12-hour clock with seconds enabled. Type into the fields or use AM/PM toggle. + + + + + + + Selected + {formatTimeDisplay(time)} + + + + + + {/* DateTimePicker */} + + + DateTimePicker + + Combined calendar + time picker (24-hour, with seconds, 15-minute steps). + + + + + + + Selected + {formatDateTimeDisplay(dateTime)} + + + + + + ); +} diff --git a/apps/showcase/src/registry.ts b/apps/showcase/src/registry.ts index 3b96386..d4e766f 100644 --- a/apps/showcase/src/registry.ts +++ b/apps/showcase/src/registry.ts @@ -8,6 +8,7 @@ import { LuFileText, LuContainer, LuMessageCircle, + LuCalendarClock, } from 'react-icons/lu'; export interface DemoApp { @@ -68,4 +69,11 @@ export const apps: DemoApp[] = [ icon: LuMessageCircle, component: lazy(() => import('./demos/chat-app')), }, + { + id: 'date-time-pickers', + name: 'Date & Time Pickers', + description: 'Interactive demo of DatePicker, TimePicker, and DateTimePicker', + icon: LuCalendarClock, + component: lazy(() => import('./demos/date-time-pickers')), + }, ]; From 827839aef2f0e3d42a5f1d4d6d708bf360db854d Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Thu, 16 Apr 2026 23:24:37 -0500 Subject: [PATCH 12/41] fix(base-ui): input-styled triggers + solid popover surface for pickers Switch DatePicker and DateTimePicker from raw @base-ui/react/popover to the project's Popover wrapper, giving the portal and positioner proper z-index and the popup a solid background with border/shadow so no bleed-through occurs. Redesign all three picker triggers to look like form inputs (matching Select/Input shell style): inline-flex with full border, control-height, space-inline-control padding, placeholder-tinted value text on the left, and a contextual icon (LuCalendar / LuClock / LuCalendarClock from react-icons/lu) pinned to the right. TimePicker root is now an input-shell wrapper; when embedded inside DateTimePicker's popup the shell styling is stripped via a scoped CSS override in DateTimePicker.module.css. --- .../date-picker/DatePicker.module.css | 73 +++++++-- .../src/components/date-picker/DatePicker.tsx | 14 +- .../DateTimePicker.module.css | 16 +- .../date-time-picker/DateTimePicker.tsx | 19 ++- .../time-picker/TimePicker.module.css | 84 ++++++++++- .../src/components/time-picker/TimePicker.tsx | 140 +++++++++--------- 6 files changed, 246 insertions(+), 100 deletions(-) diff --git a/packages/base-ui/src/components/date-picker/DatePicker.module.css b/packages/base-ui/src/components/date-picker/DatePicker.module.css index 6fd08cb..67e05f5 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.module.css +++ b/packages/base-ui/src/components/date-picker/DatePicker.module.css @@ -1,44 +1,91 @@ +/* ─── Trigger — looks like a form input ─────────────────────────────────── */ + .trigger { + --_ov-control-height: var(--ov-control-height-md); + --_ov-font-size: var(--ov-font-size-body); + --_ov-padding-inline: var(--ov-space-inline-control); + --_ov-gap: var(--ov-space-stack-sm); + --_ov-icon-size: var(--ov-size-icon-button-icon-md); + --_ov-bg: var(--ov-color-bg-surface-raised); + --_ov-fg: var(--ov-color-fg-default); + --_ov-border: var(--ov-color-border-default); + --_ov-focus: var(--ov-color-border-focus); + --_ov-placeholder: var(--ov-color-fg-subtle); + display: inline-flex; align-items: center; - gap: var(--ov-space-inline-sm); - min-height: var(--ov-size-button-height-md); - padding-inline: var(--ov-size-button-padding-inline-md); - background: var(--ov-color-bg-surface-raised); - color: var(--ov-color-fg-default); - border: 1px solid var(--ov-color-border-default); + justify-content: flex-start; + gap: var(--_ov-gap); + min-height: var(--_ov-control-height); + padding-inline: var(--_ov-padding-inline); + border: 1px solid var(--_ov-border); border-radius: var(--ov-radius-control); + background: var(--_ov-bg); + color: var(--_ov-fg); font-family: var(--ov-font-sans); - font-size: var(--ov-font-size-body); + font-size: var(--_ov-font-size); + font-weight: var(--ov-font-weight-body, 400); + line-height: 1.2; + text-align: left; cursor: pointer; transition: background-color var(--ov-duration-interactive) var(--ov-ease-standard), - border-color var(--ov-duration-interactive) var(--ov-ease-standard); + border-color var(--ov-duration-interactive) var(--ov-ease-standard), + box-shadow var(--ov-duration-interactive) var(--ov-ease-standard); } .trigger:hover { - background: var(--ov-color-state-hover); + background: color-mix(in srgb, var(--_ov-bg) 86%, var(--ov-color-state-hover) 14%); } .trigger:focus-visible { outline: none; - border-color: var(--ov-color-border-focus); + border-color: var(--_ov-focus); box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); } .trigger:disabled { + opacity: var(--ov-opacity-disabled, 0.45); cursor: not-allowed; - opacity: 0.6; } -.placeholder { +/* ─── Trigger internals ──────────────────────────────────────────────────── */ + +.triggerValue { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.triggerValue[data-placeholder] { + color: var(--_ov-placeholder); +} + +.triggerIcon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-inline-start: auto; color: var(--ov-color-fg-muted); } +.triggerIcon :where(svg) { + inline-size: var(--_ov-icon-size); + block-size: var(--_ov-icon-size); +} + +/* ─── Popup — solid surface, no bleed-through ────────────────────────────── */ + .popup { - background: var(--ov-color-bg-surface); + background: var(--ov-color-bg-surface-raised); border: 1px solid var(--ov-color-border-default); border-radius: var(--ov-radius-surface); box-shadow: var(--ov-shadow-surface); padding: 0; + /* Override the default Popover.Popup min/max sizing for compact calendar */ + min-inline-size: unset; + max-inline-size: unset; } diff --git a/packages/base-ui/src/components/date-picker/DatePicker.tsx b/packages/base-ui/src/components/date-picker/DatePicker.tsx index db755c1..08f645b 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.tsx +++ b/packages/base-ui/src/components/date-picker/DatePicker.tsx @@ -1,5 +1,6 @@ -import { Popover } from '@base-ui/react/popover'; import { useCallback, useMemo, useRef, useState } from 'react'; +import { LuCalendar } from 'react-icons/lu'; +import { Popover } from '../popover/Popover'; import styles from './DatePicker.module.css'; import { Calendar, type CalendarProps } from './Calendar'; import { formatDate, type DateFormat } from './formatters'; @@ -81,12 +82,15 @@ export function DatePicker(props: DatePickerProps) { aria-readonly={readOnly || undefined} className={[styles.trigger, className].filter(Boolean).join(' ')} > - {label ?? ( - {placeholder ?? 'Select a date'} - )} + + {label ?? (placeholder ?? 'Select a date')} + + - + * { + border: none; + background: transparent; + box-shadow: none; + padding-inline: 0; + min-height: unset; } diff --git a/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx b/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx index ed35d9e..329620e 100644 --- a/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx @@ -1,5 +1,6 @@ -import { Popover } from '@base-ui/react/popover'; import { useCallback, useMemo, useRef, useState } from 'react'; +import { LuCalendarClock } from 'react-icons/lu'; +import { Popover } from '../popover/Popover'; import { Calendar } from '../date-picker/Calendar'; import { formatDate, type DateFormat } from '../date-picker/formatters'; import type { WeekStart } from '../date-picker/dateUtils'; @@ -92,14 +93,18 @@ export function DateTimePicker(props: DateTimePickerProps) { aria-readonly={readOnly || undefined} className={[pickerStyles.trigger, className].filter(Boolean).join(' ')} > - {label ?? ( - - {placeholder ?? 'Select date and time'} - - )} + + {label ?? (placeholder ?? 'Select date and time')} + + - +
- { - setDraft((d) => ({ ...d, hour: e.target.value })); - }} - onBlur={(e) => { - emitHour(e.target.value); - setDraft((d) => { const n = { ...d }; delete n.hour; return n; }); - }} - /> - - { - setDraft((d) => ({ ...d, minute: e.target.value })); - }} - onBlur={(e) => { - emitMinute(e.target.value); - setDraft((d) => { const n = { ...d }; delete n.minute; return n; }); - }} - /> - {showSeconds && ( - <> - - { - setDraft((d) => ({ ...d, second: e.target.value })); - }} - onBlur={(e) => { - emitSecond(e.target.value); - setDraft((d) => { const n = { ...d }; delete n.second; return n; }); - }} - /> - - )} - {hourCycle === 12 && ( - - )} + readOnly={readOnly} + value={minuteDisplay} + onChange={(e) => { + setDraft((d) => ({ ...d, minute: e.target.value })); + }} + onBlur={(e) => { + emitMinute(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.minute; return n; }); + }} + /> + {showSeconds && ( + <> + + { + setDraft((d) => ({ ...d, second: e.target.value })); + }} + onBlur={(e) => { + emitSecond(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.second; return n; }); + }} + /> + + )} + {hourCycle === 12 && ( + + )} +
+ ); } From 0f31883ab2f914777037f7f4e097b9bdd32c6549 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Thu, 16 Apr 2026 23:26:25 -0500 Subject: [PATCH 13/41] fix(base-ui): drop DatePicker.Root/Trigger/Popup aliases to fix dts emit --- packages/base-ui/src/components/date-picker/DatePicker.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/base-ui/src/components/date-picker/DatePicker.tsx b/packages/base-ui/src/components/date-picker/DatePicker.tsx index 08f645b..59b25d5 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.tsx +++ b/packages/base-ui/src/components/date-picker/DatePicker.tsx @@ -109,7 +109,4 @@ export function DatePicker(props: DatePickerProps) { ); } -DatePicker.Root = Popover.Root; -DatePicker.Trigger = Popover.Trigger; -DatePicker.Popup = Popover.Popup; DatePicker.Calendar = Calendar; From 86d2e371a567548ed3088bfc8c6e9eb3e951948e Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Thu, 16 Apr 2026 23:33:42 -0500 Subject: [PATCH 14/41] feat(base-ui): DatePicker trigger becomes text input with live parse Replace the button trigger with a controlled text input that accepts direct typing; parse/validate on blur or Enter, revert on Escape, and commit calendar selections immediately. A right-side icon button opens the existing calendar popover, anchored to the full input shell. --- .../date-picker/DatePicker.module.css | 76 ++++++-- .../date-picker/DatePicker.test.tsx | 107 +++++++++-- .../src/components/date-picker/DatePicker.tsx | 170 +++++++++++++++--- 3 files changed, 303 insertions(+), 50 deletions(-) diff --git a/packages/base-ui/src/components/date-picker/DatePicker.module.css b/packages/base-ui/src/components/date-picker/DatePicker.module.css index 67e05f5..01bbd1a 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.module.css +++ b/packages/base-ui/src/components/date-picker/DatePicker.module.css @@ -1,6 +1,6 @@ -/* ─── Trigger — looks like a form input ─────────────────────────────────── */ +/* ─── Input shell — mimics Input.module.css ControlShell ─────────────────── */ -.trigger { +.shell { --_ov-control-height: var(--ov-control-height-md); --_ov-font-size: var(--ov-font-size-body); --_ov-padding-inline: var(--ov-space-inline-control); @@ -14,7 +14,6 @@ display: inline-flex; align-items: center; - justify-content: flex-start; gap: var(--_ov-gap); min-height: var(--_ov-control-height); padding-inline: var(--_ov-padding-inline); @@ -26,53 +25,94 @@ font-size: var(--_ov-font-size); font-weight: var(--ov-font-weight-body, 400); line-height: 1.2; - text-align: left; - cursor: pointer; transition: background-color var(--ov-duration-interactive) var(--ov-ease-standard), border-color var(--ov-duration-interactive) var(--ov-ease-standard), box-shadow var(--ov-duration-interactive) var(--ov-ease-standard); } -.trigger:hover { +.shell:hover:not(.shellDisabled) { background: color-mix(in srgb, var(--_ov-bg) 86%, var(--ov-color-state-hover) 14%); } -.trigger:focus-visible { - outline: none; +.shell:focus-within:not(.shellDisabled) { border-color: var(--_ov-focus); box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); } -.trigger:disabled { +/* ─── Error state ─────────────────────────────────────────────────────────── */ + +.shellError { + border-color: var(--ov-color-danger); +} + +.shellError:focus-within { + border-color: var(--ov-color-danger); + box-shadow: 0 0 0 1px var(--ov-color-danger-soft); +} + +/* ─── Disabled state ──────────────────────────────────────────────────────── */ + +.shellDisabled { opacity: var(--ov-opacity-disabled, 0.45); cursor: not-allowed; } -/* ─── Trigger internals ──────────────────────────────────────────────────── */ +/* ─── Text input ──────────────────────────────────────────────────────────── */ -.triggerValue { +.input { flex: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--_ov-font-size); + line-height: 1.35; + outline: none; + padding: 0; + cursor: text; } -.triggerValue[data-placeholder] { +.input::placeholder { color: var(--_ov-placeholder); } -.triggerIcon { +.input:disabled { + cursor: not-allowed; +} + +/* ─── Calendar icon button ────────────────────────────────────────────────── */ + +.iconButton { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; - margin-inline-start: auto; + padding: 0; + border: 0; + background: transparent; color: var(--ov-color-fg-muted); + cursor: pointer; + border-radius: var(--ov-radius-control); + outline: none; + transition: color var(--ov-duration-interactive) var(--ov-ease-standard); +} + +.iconButton:hover { + color: var(--ov-color-fg-default); +} + +.iconButton:focus-visible { + outline: 2px solid var(--ov-color-border-focus); + outline-offset: 1px; +} + +.iconButton:disabled { + cursor: not-allowed; } -.triggerIcon :where(svg) { +.iconButton :where(svg) { inline-size: var(--_ov-icon-size); block-size: var(--_ov-icon-size); } diff --git a/packages/base-ui/src/components/date-picker/DatePicker.test.tsx b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx index 7089c4f..7dd7360 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.test.tsx +++ b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx @@ -6,14 +6,14 @@ import { DatePicker } from './DatePicker'; describe('DatePicker (convenience)', () => { it('renders a closed trigger by default', () => { render( {}} placeholder="Pick a date" />); - expect(screen.getByText('Pick a date')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Pick a date')).toBeInTheDocument(); expect(screen.queryByRole('grid')).not.toBeInTheDocument(); }); - it('opens the popover when the trigger is clicked', async () => { + it('opens the popover when the icon button is clicked', async () => { const user = userEvent.setup(); render( {}} />); - await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); expect(screen.getByRole('grid')).toBeInTheDocument(); }); @@ -21,25 +21,25 @@ describe('DatePicker (convenience)', () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); - await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); await user.click(screen.getByRole('gridcell', { name: /^20/ })); expect(onChange).toHaveBeenCalledWith(expect.any(Date)); expect(screen.queryByRole('grid')).not.toBeInTheDocument(); }); - it('Escape closes the popover and returns focus to the trigger', async () => { + it('Escape closes the popover and returns focus to the input', async () => { const user = userEvent.setup(); render( {}} />); - const trigger = screen.getByRole('button'); - await user.click(trigger); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); await user.keyboard('{Escape}'); expect(screen.queryByRole('grid')).not.toBeInTheDocument(); - expect(document.activeElement).toBe(trigger); }); it('disables the trigger when disabled=true', () => { render( {}} disabled />); - expect(screen.getByRole('button')).toBeDisabled(); + const input = screen.getByRole('combobox'); + expect(input).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Open calendar' })).toBeDisabled(); }); it('formats the value using provided Intl options', () => { @@ -51,6 +51,93 @@ describe('DatePicker (convenience)', () => { format={{ year: 'numeric', month: 'long', day: 'numeric' }} />, ); - expect(screen.getByRole('button').textContent).toMatch(/April 12, 2026/); + const input = screen.getByRole('combobox') as HTMLInputElement; + expect(input.value).toMatch(/April 12, 2026/); + }); + + // ── New: typing flow ────────────────────────────────────────────────────── + + it('typing a valid date commits on blur', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.clear(input); + // Use "April 15 2026" — parsed as local midnight by new Date(), timezone-safe + await user.type(input, 'April 15 2026'); + await user.tab(); // blur + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + const committed: Date = onChange.mock.calls[0][0]; + expect(committed.getFullYear()).toBe(2026); + expect(committed.getMonth()).toBe(3); // April = 3 + expect(committed.getDate()).toBe(15); + }); + + it('typing an invalid date does not commit onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.type(input, 'not-a-date'); + await user.tab(); // blur + expect(onChange).not.toHaveBeenCalled(); + }); + + it('typing a valid date then pressing Enter commits', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.clear(input); + // Use "June 1 2026" — parsed as local midnight by new Date(), timezone-safe + await user.type(input, 'June 1 2026'); + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + const committed: Date = onChange.mock.calls[0][0]; + expect(committed.getFullYear()).toBe(2026); + expect(committed.getMonth()).toBe(5); // June = 5 + expect(committed.getDate()).toBe(1); + }); + + it('pressing Escape reverts to committed value', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('combobox') as HTMLInputElement; + await user.click(input); + await user.clear(input); + await user.type(input, 'garbage'); + await user.keyboard('{Escape}'); + expect(input.value).toMatch(/April 12, 2026/); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('calendar selection updates the input text', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('combobox') as HTMLInputElement; + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + // Input should now reflect the new date, formatted + expect(input.value).toMatch(/2026/); }); }); diff --git a/packages/base-ui/src/components/date-picker/DatePicker.tsx b/packages/base-ui/src/components/date-picker/DatePicker.tsx index 59b25d5..536d101 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.tsx +++ b/packages/base-ui/src/components/date-picker/DatePicker.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { LuCalendar } from 'react-icons/lu'; import { Popover } from '../popover/Popover'; import styles from './DatePicker.module.css'; @@ -41,6 +41,12 @@ function useControlled( return [current, set]; } +function isDateInRange(date: Date, min?: Date, max?: Date): boolean { + if (min && date < min) return false; + if (max && date > max) return false; + return true; +} + export function DatePicker(props: DatePickerProps) { const { value, @@ -60,41 +66,161 @@ export function DatePicker(props: DatePickerProps) { const [current, setCurrent] = useControlled(value, defaultValue, onChange); const [open, setOpen] = useState(false); - const triggerRef = useRef(null); + const [draft, setDraft] = useState(''); + const [parseError, setParseError] = useState(false); + + // draft is initialised lazily from current on first focus + const hasFocusedOnce = useRef(false); - const label = useMemo( - () => (current ? formatDate(current, format, locale) : null), + const shellRef = useRef(null); + const inputRef = useRef(null); + const popoverId = useId(); + + const formattedValue = useMemo( + () => (current ? formatDate(current, format, locale) : ''), [current, format, locale], ); - const handleSelect: CalendarProps['onChange'] = (next) => { + // Keep draft in sync whenever the committed value changes externally (controlled mode) + // but only when the input is not actively being edited (not focused). + const inputFocused = useRef(false); + + function syncDraft(formatted: string) { + if (!inputFocused.current) { + setDraft(formatted); + setParseError(false); + } + } + + // Sync draft when formattedValue changes and input isn't focused + useMemo(() => { + syncDraft(formattedValue); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formattedValue]); + + /** Attempt to parse a string and commit if valid */ + function tryCommit(raw: string) { + const trimmed = raw.trim(); + if (trimmed === '') { + setCurrent(null); + setDraft(''); + setParseError(false); + return; + } + const parsed = new Date(trimmed); + if (isNaN(parsed.getTime())) { + setParseError(true); + return; + } + if (!isDateInRange(parsed, min, max)) { + setParseError(true); + return; + } + if (isDateDisabled?.(parsed)) { + setParseError(true); + return; + } + setCurrent(parsed); + setDraft(formatDate(parsed, format, locale)); + setParseError(false); + } + + const handleInputChange = (e: React.ChangeEvent) => { + if (readOnly) return; + const raw = e.target.value; + setDraft(raw); + // Clear error state while typing so it doesn't flicker + setParseError(false); + }; + + const handleInputBlur = () => { + inputFocused.current = false; + tryCommit(draft); + }; + + const handleInputFocus = () => { + inputFocused.current = true; + hasFocusedOnce.current = true; + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + tryCommit(draft); + inputRef.current?.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDraft(formattedValue); + setParseError(false); + inputRef.current?.blur(); + } + }; + + const handleCalendarSelect: CalendarProps['onChange'] = (next) => { setCurrent(next); + setDraft(next ? formatDate(next, format, locale) : ''); + setParseError(false); setOpen(false); - queueMicrotask(() => triggerRef.current?.focus()); + queueMicrotask(() => inputRef.current?.focus()); + }; + + const handleIconButtonClick = () => { + if (disabled || readOnly) return; + setOpen((prev) => !prev); }; + const displayValue = hasFocusedOnce.current || draft !== '' ? draft : formattedValue; + return ( - - - {label ?? (placeholder ?? 'Select a date')} - - - + + + - - + + Date: Thu, 16 Apr 2026 23:34:18 -0500 Subject: [PATCH 15/41] chore(base-ui): fix strict-mode index access in DatePicker tests --- .../base-ui/src/components/date-picker/DatePicker.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/base-ui/src/components/date-picker/DatePicker.test.tsx b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx index 7dd7360..2012dec 100644 --- a/packages/base-ui/src/components/date-picker/DatePicker.test.tsx +++ b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx @@ -68,7 +68,7 @@ describe('DatePicker (convenience)', () => { await user.type(input, 'April 15 2026'); await user.tab(); // blur expect(onChange).toHaveBeenCalledWith(expect.any(Date)); - const committed: Date = onChange.mock.calls[0][0]; + const committed = onChange.mock.calls[0]![0] as Date; expect(committed.getFullYear()).toBe(2026); expect(committed.getMonth()).toBe(3); // April = 3 expect(committed.getDate()).toBe(15); @@ -96,7 +96,7 @@ describe('DatePicker (convenience)', () => { await user.type(input, 'June 1 2026'); await user.keyboard('{Enter}'); expect(onChange).toHaveBeenCalledWith(expect.any(Date)); - const committed: Date = onChange.mock.calls[0][0]; + const committed = onChange.mock.calls[0]![0] as Date; expect(committed.getFullYear()).toBe(2026); expect(committed.getMonth()).toBe(5); // June = 5 expect(committed.getDate()).toBe(1); From c02beb77b5f4dbadac85b5b25454a3ec2ef341c3 Mon Sep 17 00:00:00 2001 From: Joshua Pare Date: Thu, 16 Apr 2026 23:38:37 -0500 Subject: [PATCH 16/41] feat(base-ui): TimePicker column-selector popover alongside text entry Adds a scrollable column-picker popover (hours, minutes, optional seconds, optional AM/PM) triggered by the clock icon button, while preserving all existing text-field typing behavior. Also adds 5 new tests covering popover open/close, column selection, minuteStep, and AM/PM switching. --- .../time-picker/TimePicker.module.css | 115 ++++- .../time-picker/TimePicker.test.tsx | 91 +++- .../src/components/time-picker/TimePicker.tsx | 413 ++++++++++++++---- 3 files changed, 538 insertions(+), 81 deletions(-) diff --git a/packages/base-ui/src/components/time-picker/TimePicker.module.css b/packages/base-ui/src/components/time-picker/TimePicker.module.css index 0a1af3e..ddb8913 100644 --- a/packages/base-ui/src/components/time-picker/TimePicker.module.css +++ b/packages/base-ui/src/components/time-picker/TimePicker.module.css @@ -93,18 +93,127 @@ background: var(--ov-color-state-hover); } -/* ─── Right-side clock icon ───────────────────────────────────────────────── */ +/* ─── Right-side clock icon button ───────────────────────────────────────── */ -.endIcon { +.iconButton { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-inline-start: auto; + padding: 0; + border: 0; + background: transparent; color: var(--ov-color-fg-muted); + cursor: pointer; + border-radius: var(--ov-radius-control); + outline: none; + transition: color var(--ov-duration-interactive) var(--ov-ease-standard); +} + +.iconButton:hover { + color: var(--ov-color-fg-default); +} + +.iconButton:focus-visible { + outline: 2px solid var(--ov-color-border-focus); + outline-offset: 1px; +} + +.iconButton:disabled { + cursor: not-allowed; } -.endIcon :where(svg) { +.iconButton :where(svg) { inline-size: var(--_ov-icon-size); block-size: var(--_ov-icon-size); } + +/* ─── Popup ───────────────────────────────────────────────────────────────── */ + +.popup { + background: var(--ov-color-bg-surface-raised); + border: 1px solid var(--ov-color-border-default); + border-radius: var(--ov-radius-surface); + box-shadow: var(--ov-shadow-surface); + padding: 0; + min-inline-size: unset; + max-inline-size: unset; + overflow: hidden; +} + +/* ─── Column layout ───────────────────────────────────────────────────────── */ + +.columns { + display: flex; + flex-direction: row; + align-items: stretch; +} + +.columnDivider { + width: 1px; + background: var(--ov-color-border-default); + flex-shrink: 0; +} + +/* ─── Individual column (scrollable list) ────────────────────────────────── */ + +.column { + list-style: none; + margin: 0; + padding-block: var(--ov-space-stack-sm, 4px); + padding-inline: 0; + width: 56px; + height: 200px; + overflow-y: auto; + scroll-snap-type: y mandatory; + overscroll-behavior: contain; + + /* Hide scrollbar visually while keeping it functional */ + scrollbar-width: none; +} + +.column::-webkit-scrollbar { + display: none; +} + +/* ─── Column item ─────────────────────────────────────────────────────────── */ + +.columnItem { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + padding-inline: var(--ov-space-inline-sm, 6px); + font-family: var(--ov-font-mono); + font-size: var(--ov-font-size-body); + color: var(--ov-color-fg-muted); + cursor: pointer; + border-radius: var(--ov-radius-control, 4px); + scroll-snap-align: center; + user-select: none; + outline: none; + transition: + background-color var(--ov-duration-interactive) var(--ov-ease-standard), + color var(--ov-duration-interactive) var(--ov-ease-standard); +} + +.columnItem:hover { + background: var(--ov-color-state-hover); + color: var(--ov-color-fg-default); +} + +.columnItem:focus-visible { + outline: 2px solid var(--ov-color-border-focus); + outline-offset: -2px; +} + +.columnItemSelected { + background: var(--ov-color-brand-500); + color: var(--ov-color-fg-inverse); +} + +.columnItemSelected:hover { + background: var(--ov-color-brand-500); + color: var(--ov-color-fg-inverse); +} diff --git a/packages/base-ui/src/components/time-picker/TimePicker.test.tsx b/packages/base-ui/src/components/time-picker/TimePicker.test.tsx index b629ccb..87f12ca 100644 --- a/packages/base-ui/src/components/time-picker/TimePicker.test.tsx +++ b/packages/base-ui/src/components/time-picker/TimePicker.test.tsx @@ -1,9 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TimePicker } from './TimePicker'; describe('TimePicker', () => { + // ─── Existing tests (8) ─────────────────────────────────────────────────── + it('renders hour and minute inputs; no seconds by default', () => { render( {}} />); expect(screen.getByLabelText(/hour/i)).toBeInTheDocument(); @@ -82,4 +84,91 @@ describe('TimePicker', () => { expect(screen.getByLabelText(/hour/i)).toBeDisabled(); expect(screen.getByLabelText(/minute/i)).toBeDisabled(); }); + + // ─── New tests ──────────────────────────────────────────────────────────── + + it('opens the popover when the icon button is clicked', async () => { + const user = userEvent.setup(); + render( {}} />); + + // Popover should not be visible before click + expect(screen.queryByRole('listbox', { name: /hours/i })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /open time picker/i })); + + expect(screen.getByRole('listbox', { name: /hours/i })).toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: /minutes/i })).toBeInTheDocument(); + }); + + it('selecting a column item commits the time', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /open time picker/i })); + + const hoursColumn = screen.getByRole('listbox', { name: /hours/i }); + const item14 = within(hoursColumn).getByRole('option', { name: '14' }); + await user.click(item14); + + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getHours()).toBe(14); + }); + + it('minutes column respects minuteStep (60/step items visible)', async () => { + const user = userEvent.setup(); + render( + {}} + minuteStep={15} + />, + ); + + await user.click(screen.getByRole('button', { name: /open time picker/i })); + + const minutesColumn = screen.getByRole('listbox', { name: /minutes/i }); + const options = within(minutesColumn).getAllByRole('option'); + // 0, 15, 30, 45 → 4 items + expect(options).toHaveLength(4); + expect(options.map((o) => o.textContent)).toEqual(['00', '15', '30', '45']); + }); + + it('AM/PM column switches meridiem', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /open time picker/i })); + + const ampmColumn = screen.getByRole('listbox', { name: /am\/pm/i }); + const amOption = within(ampmColumn).getByRole('option', { name: 'AM' }); + await user.click(amOption); + + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getHours()).toBe(2); // 14 - 12 + }); + + it('popover closes on Escape and returns focus to icon button', async () => { + const user = userEvent.setup(); + render( {}} />); + + const iconButton = screen.getByRole('button', { name: /open time picker/i }); + await user.click(iconButton); + + expect(screen.getByRole('listbox', { name: /hours/i })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('listbox', { name: /hours/i })).not.toBeInTheDocument(); + expect(iconButton).toHaveFocus(); + }); }); diff --git a/packages/base-ui/src/components/time-picker/TimePicker.tsx b/packages/base-ui/src/components/time-picker/TimePicker.tsx index 59ac2b1..b0c84b5 100644 --- a/packages/base-ui/src/components/time-picker/TimePicker.tsx +++ b/packages/base-ui/src/components/time-picker/TimePicker.tsx @@ -1,5 +1,6 @@ -import { useCallback, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { LuClock } from 'react-icons/lu'; +import { Popover } from '../popover/Popover'; import styles from './TimePicker.module.css'; import type { StyledComponentProps } from '../../system/types'; @@ -24,6 +25,101 @@ function clampToStep(value: number, step: number, max: number): number { type FieldName = 'hour' | 'minute' | 'second'; +// ─── Column component ──────────────────────────────────────────────────────── + +interface ColumnItem { + value: number | string; + label: string; +} + +interface TimeColumnProps { + label: string; + items: ColumnItem[]; + selected: number | string; + onSelect: (value: number | string) => void; + disabled?: boolean; +} + +function TimeColumn({ label, items, selected, onSelect, disabled }: TimeColumnProps) { + const listRef = useRef(null); + const selectedRef = useRef(null); + + // Scroll selected item into center when the column mounts or selected changes + useEffect(() => { + const el = selectedRef.current; + if (!el) return; + if (typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', behavior: 'auto' }); + } + }, [selected]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + const list = listRef.current; + if (!list) return; + const options = Array.from(list.querySelectorAll('[role="option"]')); + const focused = document.activeElement as HTMLLIElement | null; + const idx = focused ? options.indexOf(focused) : -1; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = options[Math.min(idx + 1, options.length - 1)]; + next?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = options[Math.max(idx - 1, 0)]; + prev?.focus(); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (focused && focused.dataset.value !== undefined) { + const raw = focused.dataset.value; + const parsed = Number.parseInt(raw, 10); + onSelect(Number.isNaN(parsed) ? raw : parsed); + } + } + }; + + return ( +
    + {items.map((item) => { + const isSelected = item.value === selected; + return ( +
  • { + if (!disabled) onSelect(item.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) onSelect(item.value); + } + }} + > + {item.label} +
  • + ); + })} +
+ ); +} + +// ─── TimePicker ────────────────────────────────────────────────────────────── + export function TimePicker(props: TimePickerProps) { const { value, @@ -38,6 +134,11 @@ export function TimePicker(props: TimePickerProps) { const current = value ?? new Date(); const id = useId(); + const popoverId = useId(); + + const [open, setOpen] = useState(false); + const shellRef = useRef(null); + const iconButtonRef = useRef(null); const h24 = current.getHours(); const isPM = h24 >= 12; @@ -101,84 +202,242 @@ export function TimePicker(props: TimePickerProps) { const minuteDisplay = draft.minute ?? String(current.getMinutes()).padStart(2, '0'); const secondDisplay = draft.second ?? String(current.getSeconds()).padStart(2, '0'); + // ─── Column data ──────────────────────────────────────────────────────── + + const hourItems: ColumnItem[] = + hourCycle === 12 + ? Array.from({ length: 12 }, (_, i) => { + const v = i + 1; + return { value: v, label: String(v).padStart(2, '0') }; + }) + : Array.from({ length: 24 }, (_, i) => ({ + value: i, + label: String(i).padStart(2, '0'), + })); + + const minuteItems: ColumnItem[] = []; + for (let m = 0; m < 60; m += Math.max(1, minuteStep)) { + minuteItems.push({ value: m, label: String(m).padStart(2, '0') }); + } + + const secondItems: ColumnItem[] = Array.from({ length: 60 }, (_, i) => ({ + value: i, + label: String(i).padStart(2, '0'), + })); + + const meridiemItems: ColumnItem[] = [ + { value: 'AM', label: 'AM' }, + { value: 'PM', label: 'PM' }, + ]; + + // Currently selected values for columns + const selectedHour = hourCycle === 12 ? displayedHour : h24; + const selectedMinute = clampToStep(current.getMinutes(), minuteStep, 59); + const selectedSecond = current.getSeconds(); + const selectedMeridiem = isPM ? 'PM' : 'AM'; + + // ─── Column handlers ──────────────────────────────────────────────────── + + const handleHourSelect = (v: number | string) => { + if (readOnly) return; + const parsed = Number(v); + let hours: number; + if (hourCycle === 12) { + const clamped = Math.max(1, Math.min(12, parsed)); + hours = (clamped % 12) + (isPM ? 12 : 0); + } else { + hours = Math.max(0, Math.min(23, parsed)); + } + const next = new Date(current); + next.setHours(hours, current.getMinutes(), current.getSeconds(), 0); + onChange?.(next); + }; + + const handleMinuteSelect = (v: number | string) => { + if (readOnly) return; + const next = new Date(current); + next.setHours(current.getHours(), Number(v), current.getSeconds(), 0); + onChange?.(next); + }; + + const handleSecondSelect = (v: number | string) => { + if (readOnly) return; + const next = new Date(current); + next.setHours(current.getHours(), current.getMinutes(), Number(v), 0); + onChange?.(next); + }; + + const handleMeridiemSelect = (v: number | string) => { + if (readOnly) return; + const wantPM = v === 'PM'; + if (wantPM === isPM) return; + const next = new Date(current); + next.setHours(wantPM ? h24 + 12 : h24 - 12, current.getMinutes(), current.getSeconds(), 0); + onChange?.(next); + }; + + const handleIconButtonClick = () => { + if (disabled || readOnly) return; + setOpen((prev) => !prev); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + queueMicrotask(() => iconButtonRef.current?.focus()); + } + }; + + // Alt+Down inside a field opens the popover + const handleFieldKeyDown = (e: React.KeyboardEvent) => { + if (e.altKey && e.key === 'ArrowDown') { + e.preventDefault(); + if (!disabled && !readOnly) setOpen(true); + } + }; + return ( -
-
- { - setDraft((d) => ({ ...d, hour: e.target.value })); - }} - onBlur={(e) => { - emitHour(e.target.value); - setDraft((d) => { const n = { ...d }; delete n.hour; return n; }); - }} - /> - - { - setDraft((d) => ({ ...d, minute: e.target.value })); - }} - onBlur={(e) => { - emitMinute(e.target.value); - setDraft((d) => { const n = { ...d }; delete n.minute; return n; }); - }} - /> - {showSeconds && ( - <> - - { - setDraft((d) => ({ ...d, second: e.target.value })); - }} - onBlur={(e) => { - emitSecond(e.target.value); - setDraft((d) => { const n = { ...d }; delete n.second; return n; }); - }} - /> - - )} - {hourCycle === 12 && ( - - )} + readOnly={readOnly} + value={hourDisplay} + onChange={(e) => { + setDraft((d) => ({ ...d, hour: e.target.value })); + }} + onBlur={(e) => { + emitHour(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.hour; return n; }); + }} + onKeyDown={handleFieldKeyDown} + /> + + { + setDraft((d) => ({ ...d, minute: e.target.value })); + }} + onBlur={(e) => { + emitMinute(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.minute; return n; }); + }} + onKeyDown={handleFieldKeyDown} + /> + {showSeconds && ( + <> + + { + setDraft((d) => ({ ...d, second: e.target.value })); + }} + onBlur={(e) => { + emitSecond(e.target.value); + setDraft((d) => { const n = { ...d }; delete n.second; return n; }); + }} + onKeyDown={handleFieldKeyDown} + /> + + )} + {hourCycle === 12 && ( + + )} +
+
- - + + + +
+ +