diff --git a/apps/showcase/package.json b/apps/showcase/package.json index c7e6f9a..541f36f 100644 --- a/apps/showcase/package.json +++ b/apps/showcase/package.json @@ -5,8 +5,10 @@ "description": "Demo showcase app built entirely from Omniview UI packages.", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -p tsconfig.json --noEmit && vite build", + "build:deps": "pnpm --filter \"@omniviewdev/showcase^...\" build", + "dev": "pnpm build:deps && vite", + "dev:fast": "vite", + "build": "pnpm build:deps && tsc -p tsconfig.json --noEmit && vite build", "preview": "vite preview" }, "dependencies": { diff --git a/docs/COMPONENT_STATUS.md b/docs/COMPONENT_STATUS.md index c1d86d7..e9a9b3e 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` (Beta) — 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,10 @@ These are the only components currently in scope and exported: - `ConfirmButton` - `Container` - `ContextMenu` +- `DateField` (Beta) — sectioned guided date/time input primitive with per-section validation, paste-to-fill, and locale-aware section ordering +- `DatePicker` (Beta) — calendar popover input with full keyboard navigation, min/max constraints, and custom disabled-cell callback +- `DateRangePicker` (Beta) — dual `DateField` trigger with range-selecting calendar popover; min/max and custom separators +- `DateTimePicker` (Beta) — composition of `DatePicker` + `TimePicker` producing a combined date-and-time value - `DescriptionList` - `Dialog` - `DockLayout` @@ -69,6 +74,7 @@ These are the only components currently in scope and exported: - `TagInput` - `TextField` - `TextArea` +- `TimePicker` (Beta) — 12/24-hour time input with optional seconds display and configurable minute step - `Toast` - `Tooltip` - `ToggleButton` diff --git a/packages/base-ui/docs/COMPONENT_STATUS.md b/packages/base-ui/docs/COMPONENT_STATUS.md new file mode 100644 index 0000000..daaa43d --- /dev/null +++ b/packages/base-ui/docs/COMPONENT_STATUS.md @@ -0,0 +1,14 @@ +# Component Status — @omniviewdev/base-ui + +Tracks the implementation status of components in the package. + +| Component | Status | Description | +|---|---|---| +| Calendar | Beta[^1] | Single-date and range-mode calendar grid; supports `min`/`max`, custom `isDateDisabled`, `weekStartsOn`, `locale`, and `autoFocus` | +| DateField | Beta[^1] | Sectioned guided input primitive; tabbable sections, per-section validation, arrow-key increment, paste-to-fill; supports `mode` (`date`/`time`/`datetime`), `locale`, `hourCycle`, `showSeconds`, `min`/`max`, `disabled`, `readOnly` | +| DatePicker | Beta[^1] | DateField-powered date input + calendar popover; supports `min`/`max`, `locale`, `hourCycle`, `disabled`, `readOnly` | +| DateRangePicker | Beta[^1] | Dual DateField trigger + range-selecting calendar popover; supports `min`/`max`, `locale`, `rangeSeparator`, `disabled`, `readOnly` | +| DateTimePicker | Beta[^1] | DateField-powered combined date+time input with calendar+time popover; supports `min`/`max`, `showSeconds`, `hourCycle`, `minuteStep`, `disabled`, `readOnly` | +| TimePicker | Beta[^1] | DateField-powered time input + column-selector popover; supports `showSeconds`, `hourCycle`, `minuteStep`, `disabled`, `readOnly` | + +[^1]: Date and time components are feature-complete and unit-tested, but final UX verification (cross-browser behavior, locale exotica, keyboard-only flows) is still pending — treat as Beta until that sign-off lands. diff --git a/packages/base-ui/src/components/date-field/DateField.module.css b/packages/base-ui/src/components/date-field/DateField.module.css new file mode 100644 index 0000000..b0a8aab --- /dev/null +++ b/packages/base-ui/src/components/date-field/DateField.module.css @@ -0,0 +1,107 @@ +/* ─── DateField: sectioned input primitive ──────────────────────────────── */ + +.root { + --_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-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; + 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); + line-height: 1.2; + /* Tabular numerals so sections align cleanly as values change */ + font-variant-numeric: tabular-nums; + white-space: nowrap; + cursor: text; + outline: none; + 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); +} + +.root:hover:not([data-disabled]) { + background: color-mix(in srgb, var(--_ov-bg) 86%, var(--ov-color-state-hover) 14%); +} + +.root:focus-within:not([data-disabled]) { + border-color: var(--_ov-focus); + box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); +} + +.root[data-disabled] { + opacity: var(--ov-opacity-disabled, 0.45); + cursor: not-allowed; +} + +/* ─── Bare mode — strip shell styling when embedded inside a picker shell ─── */ + +.root[data-bare] { + border: none; + background: transparent; + padding-inline: 0; + min-height: unset; +} + +.root[data-bare]:hover { + background: transparent; +} + +.root[data-bare]:focus-within { + border: none; + box-shadow: none; +} + +/* ─── Section span (editable) ────────────────────────────────────────────── */ + +.section { + display: inline-block; + padding: 0 1px; + border-radius: 2px; + min-width: 1ch; + text-align: center; + white-space: nowrap; + outline: none; + caret-color: transparent; + user-select: none; + -webkit-user-select: none; + cursor: text; +} + +.section[data-focused] { + background: var(--ov-color-brand-500); + color: var(--ov-color-fg-inverse, var(--ov-color-fg-default)); +} + +.section[data-placeholder] { + color: var(--ov-color-fg-muted); +} + +/* When focused and also placeholder, preserve readable contrast on brand bg */ +.section[data-focused][data-placeholder] { + color: var(--ov-color-fg-inverse, var(--ov-color-fg-default)); + opacity: 0.85; +} + +/* ─── Literal separators ─────────────────────────────────────────────────── */ + +.literal { + display: inline-block; + color: var(--ov-color-fg-muted); + user-select: none; + -webkit-user-select: none; + pointer-events: none; + white-space: pre; +} diff --git a/packages/base-ui/src/components/date-field/DateField.stories.tsx b/packages/base-ui/src/components/date-field/DateField.stories.tsx new file mode 100644 index 0000000..a4790c4 --- /dev/null +++ b/packages/base-ui/src/components/date-field/DateField.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { DateField, type DateFieldProps } from './DateField'; + +const meta = { + title: 'Components/DateField', + component: DateField, + tags: ['autodocs'], + args: { + mode: 'date', + disabled: false, + readOnly: false, + }, + argTypes: { + disabled: { control: 'boolean' }, + readOnly: { control: 'boolean' }, + mode: { control: 'select', options: ['date', 'time', 'datetime'] }, + hourCycle: { control: 'inline-radio', options: [12, 24] }, + showSeconds: { control: 'boolean' }, + locale: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Story components (keep hooks legal) ────────────────────────────────── + +function DateModeStory(args: DateFieldProps) { + const [value, setValue] = useState(null); + return ; +} + +function TimeMode24hStory(args: DateFieldProps) { + const [value, setValue] = useState(null); + return ; +} + +function TimeMode12hStory(args: DateFieldProps) { + const [value, setValue] = useState(null); + return ( + + ); +} + +function DateTimeModeStory(args: DateFieldProps) { + const [value, setValue] = useState(null); + return ; +} + +function LocaleGBStory(args: DateFieldProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +function DisabledStory(args: DateFieldProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +function ReadOnlyStory(args: DateFieldProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +// ─── Story exports ──────────────────────────────────────────────────────── + +export const DateMode: Story = { + render: (args) => , +}; + +export const TimeMode24h: Story = { + render: (args) => , +}; + +export const TimeMode12h: Story = { + render: (args) => , +}; + +export const DateTimeMode: Story = { + render: (args) => , +}; + +export const LocaleGB: Story = { + render: (args) => , +}; + +export const Disabled: Story = { + render: (args) => , +}; + +export const ReadOnly: Story = { + render: (args) => , +}; diff --git a/packages/base-ui/src/components/date-field/DateField.test.tsx b/packages/base-ui/src/components/date-field/DateField.test.tsx new file mode 100644 index 0000000..dd51521 --- /dev/null +++ b/packages/base-ui/src/components/date-field/DateField.test.tsx @@ -0,0 +1,470 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateField } from './DateField'; + +/** + * Helpers to locate section spans by their `data-section-type` attribute. + * The root has role="group"; editable spans are role="spinbutton". + */ + +function getSection(container: HTMLElement, type: string): HTMLElement { + const el = container.querySelector(`[data-section-type="${type}"]:not([data-literal])`); + if (!el) throw new Error(`No section with type="${type}"`); + return el as HTMLElement; +} + +describe('DateField — rendering', () => { + it('renders sections in locale order for en-US (month-first)', () => { + const { container } = render(); + const sections = Array.from( + container.querySelectorAll('[data-section-type]:not([data-literal])'), + ).map((el) => el.getAttribute('data-section-type')); + expect(sections).toEqual(['month', 'day', 'year']); + }); + + it('renders sections in locale order for en-GB (day-first)', () => { + const { container } = render(); + const sections = Array.from( + container.querySelectorAll('[data-section-type]:not([data-literal])'), + ).map((el) => el.getAttribute('data-section-type')); + expect(sections).toEqual(['day', 'month', 'year']); + }); + + it('shows placeholders when value is null', () => { + const { container } = render(); + const month = getSection(container, 'month'); + expect(month.textContent).toBe('MM'); + expect(month.getAttribute('data-placeholder')).toBe(''); + }); + + it('shows formatted value when value is set', () => { + const { container } = render( + , + ); + expect(getSection(container, 'month').textContent).toBe('04'); + expect(getSection(container, 'day').textContent).toBe('12'); + expect(getSection(container, 'year').textContent).toBe('2026'); + }); + + it('time mode renders hour/minute sections', () => { + const { container } = render(); + expect(container.querySelector('[data-section-type="hour"]')).toBeTruthy(); + expect(container.querySelector('[data-section-type="minute"]')).toBeTruthy(); + }); + + it('datetime mode renders date + time sections', () => { + const { container } = render(); + expect(container.querySelector('[data-section-type="year"]')).toBeTruthy(); + expect(container.querySelector('[data-section-type="month"]')).toBeTruthy(); + expect(container.querySelector('[data-section-type="day"]')).toBeTruthy(); + expect(container.querySelector('[data-section-type="hour"]')).toBeTruthy(); + expect(container.querySelector('[data-section-type="minute"]')).toBeTruthy(); + }); + + it('time mode with hourCycle 12 renders meridiem section', () => { + const { container } = render(); + expect(container.querySelector('[data-section-type="meridiem"]')).toBeTruthy(); + }); + + it('applies aria-label to the root', () => { + render(); + expect(screen.getByRole('group')).toHaveAttribute('aria-label', 'Start date'); + }); + + it('marks literal separators as aria-hidden', () => { + const { container } = render(); + const literals = container.querySelectorAll('[data-literal]'); + expect(literals.length).toBeGreaterThan(0); + literals.forEach((el) => expect(el.getAttribute('aria-hidden')).toBe('true')); + }); +}); + +describe('DateField — keyboard navigation', () => { + it('clicking a section focuses it', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + await user.click(month); + expect(month).toHaveAttribute('data-focused', ''); + }); + + it('Tab moves to next section', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(month); + await user.keyboard('{Tab}'); + expect(day).toHaveAttribute('data-focused', ''); + }); + + it('Shift+Tab moves to previous section', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(day); + await user.keyboard('{Shift>}{Tab}{/Shift}'); + expect(month).toHaveAttribute('data-focused', ''); + }); + + it('ArrowLeft/ArrowRight move between sections', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(month); + await user.keyboard('{ArrowRight}'); + expect(day).toHaveAttribute('data-focused', ''); + await user.keyboard('{ArrowLeft}'); + expect(month).toHaveAttribute('data-focused', ''); + }); + + it('Arrow Up increments the focused month section', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('{ArrowUp}'); + expect(month.textContent).toBe('05'); + }); + + it('Arrow Down decrements the focused day section', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const day = getSection(container, 'day'); + await user.click(day); + await user.keyboard('{ArrowDown}'); + expect(day.textContent).toBe('11'); + }); + + it('Arrow Up on empty month section starts at 01', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('{ArrowUp}'); + expect(month.textContent).toBe('01'); + }); + + it('Arrow Down on empty month starts at 12', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('{ArrowDown}'); + expect(month.textContent).toBe('12'); + }); + + it('Arrow Up toggles meridiem', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const meridiem = getSection(container, 'meridiem'); + await user.click(meridiem); + expect(meridiem.textContent).toBe('AM'); + await user.keyboard('{ArrowUp}'); + expect(meridiem.textContent).toBe('PM'); + }); +}); + +describe('DateField — digit input', () => { + it('typing "1" in month section stages it without advancing', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('1'); + expect(month.textContent).toBe('1'); + expect(month).toHaveAttribute('data-focused', ''); + }); + + it('typing "12" in month auto-advances to day', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(month); + await user.keyboard('12'); + expect(month.textContent).toBe('12'); + expect(day).toHaveAttribute('data-focused', ''); + }); + + it('typing "1" then "3" in month snaps to 03 and advances', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(month); + await user.keyboard('13'); + // "1" then "3": "13" > 12, so digit 3 replaces to "03" and advances. + expect(month.textContent).toBe('03'); + expect(day).toHaveAttribute('data-focused', ''); + }); + + it('typing "3" in month section auto-advances as "03"', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(month); + await user.keyboard('3'); + expect(month.textContent).toBe('03'); + expect(day).toHaveAttribute('data-focused', ''); + }); + + it('typing digit into meridiem is ignored', async () => { + const user = userEvent.setup(); + const { container } = render(); + const meridiem = getSection(container, 'meridiem'); + await user.click(meridiem); + await user.keyboard('3'); + expect(meridiem.textContent).toBe('AM'); // placeholder unchanged + }); + + it('typing "A" in meridiem sets AM and advances', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const meridiem = getSection(container, 'meridiem'); + await user.click(meridiem); + await user.keyboard('a'); + expect(meridiem.textContent).toBe('AM'); + }); + + it('typing "P" in meridiem sets PM', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const meridiem = getSection(container, 'meridiem'); + await user.click(meridiem); + await user.keyboard('p'); + expect(meridiem.textContent).toBe('PM'); + }); + + it('Backspace clears focused section', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('{Backspace}'); + expect(month.textContent).toBe('MM'); + expect(month.getAttribute('data-placeholder')).toBe(''); + }); + + it('Backspace on empty section moves focus to previous', async () => { + const user = userEvent.setup(); + const { container } = render(); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(day); + await user.keyboard('{Backspace}'); + expect(month).toHaveAttribute('data-focused', ''); + }); +}); + +describe('DateField — validation + commit', () => { + it('complete valid entry triggers onChange with parsed Date', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('04121999'); + + expect(onChange).toHaveBeenCalled(); + const call = onChange.mock.calls[onChange.mock.calls.length - 1]; + if (!call) throw new Error('onChange not called'); + const d = call[0] as Date; + expect(d.getFullYear()).toBe(1999); + expect(d.getMonth()).toBe(3); + expect(d.getDate()).toBe(12); + }); + + it('incomplete sections do not trigger onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('04'); + await user.keyboard('12'); + // year not entered + expect(onChange).not.toHaveBeenCalled(); + }); + + it('Feb 30 does not trigger onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + // type 02 30 2026 + await user.keyboard('02302026'); + // "0230" would go: month=02 (advance), day starts "3"(advance as 03), then "0" in year. + // Actually digit flow: month=02 advances, day sees "3" → 03 advance, year sees "02026" + // Since exact behaviour is flow-specific, just assert: no Feb 30 date produced. + for (const c of onChange.mock.calls) { + const d = c[0] as Date; + if (d) { + const isFeb30 = + d.getMonth() === 1 && d.getDate() === 30; + expect(isFeb30).toBe(false); + } + } + }); + + it('Feb 29 on a leap year triggers onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('02292024'); + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1]!; + const d = last[0] as Date; + expect(d.getFullYear()).toBe(2024); + expect(d.getMonth()).toBe(1); + expect(d.getDate()).toBe(29); + }); + + it('setting month to Apr clamps day from 31 to 30', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + // Start with March 31, 2026 — then change month to April via ArrowUp. + const { container } = render( + , + ); + const month = getSection(container, 'month'); + const day = getSection(container, 'day'); + await user.click(month); + await user.keyboard('{ArrowUp}'); + expect(month.textContent).toBe('04'); + expect(day.textContent).toBe('30'); + }); +}); + +describe('DateField — Escape reverts', () => { + it('Escape reverts sections to the current value', async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('{Backspace}'); + expect(month.textContent).toBe('MM'); + await user.keyboard('{Escape}'); + expect(month.textContent).toBe('04'); + }); +}); + +describe('DateField — disabled / readOnly', () => { + it('disabled field does not respond to keyboard', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.keyboard('12'); + expect(month.textContent).toBe('MM'); + }); + + it('readOnly field does not accept digits but allows focus', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + expect(month).toHaveAttribute('data-focused', ''); + await user.keyboard('01'); + expect(month.textContent).toBe('04'); // unchanged + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('DateField — paste', () => { + it('pasting 04/12/2026 fills all date sections', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const month = getSection(container, 'month'); + await user.click(month); + await user.paste('04/12/2026'); + + expect(getSection(container, 'month').textContent).toBe('04'); + expect(getSection(container, 'day').textContent).toBe('12'); + expect(getSection(container, 'year').textContent).toBe('2026'); + + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1]!; + const d = last[0] as Date; + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(3); + expect(d.getDate()).toBe(12); + }); +}); + +describe('DateField — external value sync', () => { + it('updates sections when value prop changes externally', () => { + const { container, rerender } = render( + , + ); + expect(getSection(container, 'month').textContent).toBe('04'); + + rerender(); + expect(getSection(container, 'month').textContent).toBe('06'); + expect(getSection(container, 'day').textContent).toBe('01'); + expect(getSection(container, 'year').textContent).toBe('2027'); + }); + + it('clears sections when value becomes null', () => { + const { container, rerender } = render( + , + ); + rerender(); + expect(getSection(container, 'month').textContent).toBe('MM'); + expect(getSection(container, 'year').textContent).toBe('YYYY'); + }); +}); diff --git a/packages/base-ui/src/components/date-field/DateField.tsx b/packages/base-ui/src/components/date-field/DateField.tsx new file mode 100644 index 0000000..6834c08 --- /dev/null +++ b/packages/base-ui/src/components/date-field/DateField.tsx @@ -0,0 +1,117 @@ +import { forwardRef } from 'react'; +import styles from './DateField.module.css'; +import { useDateField, type UseDateFieldOptions } from './useDateField'; +import type { Section } from './sections'; + +export interface DateFieldProps { + value?: Date | null; + onChange?: (value: Date | null) => void; + mode?: 'date' | 'time' | 'datetime'; + locale?: string; + hourCycle?: 12 | 24; + showSeconds?: boolean; + min?: Date; + max?: Date; + disabled?: boolean; + readOnly?: boolean; + className?: string; + 'aria-label'?: string; + /** + * When true, removes the input-shell styling (border, background, padding, + * min-height, hover, focus-within ring) so the field can be embedded inside + * an outer shell (e.g. a picker trigger) without doubling the border. + */ + bare?: boolean; +} + +/** + * `DateField` — a sectioned, guided input for date/time entry. Each section + * (MM, DD, YYYY, HH, mm, ss, AM/PM) is individually editable and validated. + * + * The component is a thin wrapper over `useDateField`; consumers who need + * custom layouts can reach for the hook directly. + */ +export const DateField = forwardRef(function DateField( + props, + ref, +) { + const { + value, + onChange, + mode = 'date', + locale, + hourCycle, + showSeconds, + min, + max, + disabled, + readOnly, + className, + 'aria-label': ariaLabel, + bare, + } = props; + + const options: UseDateFieldOptions = { + value: value ?? null, + onChange, + mode, + locale, + hourCycle, + showSeconds, + min, + max, + disabled, + readOnly, + }; + + const { sections, rootProps, getSectionProps, registerSectionRef } = useDateField(options); + + return ( +
+ {sections.map((section, idx) => + section.type === 'literal' ? ( + + {section.value} + + ) : ( + + {renderSectionContent(section)} + + ), + )} +
+ ); +}); + +function renderSectionContent(section: Section): string { + if (section.value !== '') return section.value; + return section.placeholder; +} + +function defaultAriaLabel(mode: 'date' | 'time' | 'datetime'): string { + switch (mode) { + case 'time': + return 'Time'; + case 'datetime': + return 'Date and time'; + default: + return 'Date'; + } +} diff --git a/packages/base-ui/src/components/date-field/index.ts b/packages/base-ui/src/components/date-field/index.ts new file mode 100644 index 0000000..93ad4b9 --- /dev/null +++ b/packages/base-ui/src/components/date-field/index.ts @@ -0,0 +1,20 @@ +export { DateField } from './DateField'; +export type { DateFieldProps } from './DateField'; +export { useDateField } from './useDateField'; +export type { + UseDateFieldOptions, + UseDateFieldReturn, + SectionDomProps, +} from './useDateField'; +export type { + Section, + SectionType, + BuildSectionsOptions, + ValidateSectionsResult, +} from './sections'; +export { + buildSections, + validateSections, + getDaysInMonth, + isLeapYear, +} from './sections'; diff --git a/packages/base-ui/src/components/date-field/sections.test.ts b/packages/base-ui/src/components/date-field/sections.test.ts new file mode 100644 index 0000000..9fb48ee --- /dev/null +++ b/packages/base-ui/src/components/date-field/sections.test.ts @@ -0,0 +1,562 @@ +import { describe, it, expect } from 'vitest'; +import { + buildSections, + getDaysInMonth, + isLeapYear, + validateSections, + adjustSectionValue, + applyDigitToSection, + applyPaste, + clampDayToMonth, + padZero, + getNextEditableIndex, + getPreviousEditableIndex, + setSectionValue, +} from './sections'; + +describe('padZero', () => { + it('pads numbers smaller than len', () => { + expect(padZero(4, 2)).toBe('04'); + expect(padZero(4, 4)).toBe('0004'); + }); + + it('does not pad numbers at or above len', () => { + expect(padZero(12, 2)).toBe('12'); + expect(padZero(1234, 2)).toBe('1234'); + }); +}); + +describe('isLeapYear / getDaysInMonth', () => { + it('detects leap years', () => { + expect(isLeapYear(2024)).toBe(true); + expect(isLeapYear(2025)).toBe(false); + expect(isLeapYear(2000)).toBe(true); + expect(isLeapYear(1900)).toBe(false); + }); + + it('returns 29 for Feb on leap years and 28 on non-leap', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + expect(getDaysInMonth(2025, 2)).toBe(28); + }); + + it('returns 30 for Apr/Jun/Sep/Nov', () => { + expect(getDaysInMonth(2026, 4)).toBe(30); + expect(getDaysInMonth(2026, 6)).toBe(30); + expect(getDaysInMonth(2026, 9)).toBe(30); + expect(getDaysInMonth(2026, 11)).toBe(30); + }); + + it('returns 31 for Jan/Mar/May/Jul/Aug/Oct/Dec', () => { + for (const m of [1, 3, 5, 7, 8, 10, 12]) { + expect(getDaysInMonth(2026, m)).toBe(31); + } + }); +}); + +describe('buildSections', () => { + it('renders month-first sections for en-US date mode', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const order = sections.map((s) => s.type); + // en-US: MM / DD / YYYY + expect(order[0]).toBe('month'); + const firstNonLiteral = order.filter((t) => t !== 'literal'); + expect(firstNonLiteral).toEqual(['month', 'day', 'year']); + }); + + it('renders day-first sections for en-GB date mode', () => { + const sections = buildSections({ mode: 'date', locale: 'en-GB', value: null }); + const nonLiteral = sections.filter((s) => s.type !== 'literal').map((s) => s.type); + expect(nonLiteral).toEqual(['day', 'month', 'year']); + }); + + it('renders year-first sections for en-CA or ja-JP', () => { + const sections = buildSections({ mode: 'date', locale: 'ja-JP', value: null }); + const nonLiteral = sections.filter((s) => s.type !== 'literal').map((s) => s.type); + expect(nonLiteral).toEqual(['year', 'month', 'day']); + }); + + it('time mode includes hour/minute', () => { + const sections = buildSections({ mode: 'time', locale: 'en-US', hourCycle: 24 }); + const types = sections.filter((s) => s.type !== 'literal').map((s) => s.type); + expect(types).toContain('hour'); + expect(types).toContain('minute'); + expect(types).not.toContain('second'); + }); + + it('time mode with showSeconds includes seconds', () => { + const sections = buildSections({ + mode: 'time', + locale: 'en-US', + hourCycle: 24, + showSeconds: true, + }); + const types = sections.filter((s) => s.type !== 'literal').map((s) => s.type); + expect(types).toContain('second'); + }); + + it('time mode with hourCycle 12 includes meridiem', () => { + const sections = buildSections({ mode: 'time', locale: 'en-US', hourCycle: 12 }); + const types = sections.filter((s) => s.type !== 'literal').map((s) => s.type); + expect(types).toContain('meridiem'); + }); + + it('datetime mode combines date + time sections', () => { + const sections = buildSections({ + mode: 'datetime', + locale: 'en-US', + hourCycle: 24, + }); + const types = sections.filter((s) => s.type !== 'literal').map((s) => s.type); + expect(types).toContain('year'); + expect(types).toContain('month'); + expect(types).toContain('day'); + expect(types).toContain('hour'); + expect(types).toContain('minute'); + }); + + it('populates section values from a provided Date', () => { + const d = new Date(2026, 3, 12, 9, 5); // Apr 12 2026 09:05 + const sections = buildSections({ + mode: 'datetime', + locale: 'en-US', + hourCycle: 24, + value: d, + }); + const month = sections.find((s) => s.type === 'month'); + const day = sections.find((s) => s.type === 'day'); + const year = sections.find((s) => s.type === 'year'); + const hour = sections.find((s) => s.type === 'hour'); + const minute = sections.find((s) => s.type === 'minute'); + expect(month?.value).toBe('04'); + expect(day?.value).toBe('12'); + expect(year?.value).toBe('2026'); + expect(hour?.value).toBe('09'); + expect(minute?.value).toBe('05'); + }); + + it('sets proper min/max for month/day/hour sections', () => { + const date24 = buildSections({ mode: 'datetime', locale: 'en-US', hourCycle: 24 }); + const month = date24.find((s) => s.type === 'month'); + const day = date24.find((s) => s.type === 'day'); + const hour = date24.find((s) => s.type === 'hour'); + expect(month?.min).toBe(1); + expect(month?.max).toBe(12); + expect(day?.min).toBe(1); + expect(day?.max).toBe(31); + expect(hour?.min).toBe(0); + expect(hour?.max).toBe(23); + }); + + it('12-hour hour section has min=1 max=12', () => { + const t = buildSections({ mode: 'time', locale: 'en-US', hourCycle: 12 }); + const hour = t.find((s) => s.type === 'hour'); + expect(hour?.min).toBe(1); + expect(hour?.max).toBe(12); + }); + + it('populates meridiem from value', () => { + const d = new Date(2026, 3, 12, 15, 0); // 3 PM + const s = buildSections({ mode: 'time', locale: 'en-US', hourCycle: 12, value: d }); + const m = s.find((x) => x.type === 'meridiem'); + expect(m?.value).toBe('PM'); + }); +}); + +describe('validateSections', () => { + it('returns incomplete when any numeric section is empty', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const result = validateSections(sections); + expect(result.valid).toBe(false); + expect(result.incomplete).toBe(true); + expect(result.date).toBeNull(); + }); + + it('returns a valid Date for a complete valid input', () => { + const sections = buildSections({ + mode: 'date', + locale: 'en-US', + value: new Date(2026, 3, 12), + }); + const result = validateSections(sections); + expect(result.valid).toBe(true); + expect(result.date?.getFullYear()).toBe(2026); + expect(result.date?.getMonth()).toBe(3); + expect(result.date?.getDate()).toBe(12); + }); + + it('Feb 30 is invalid', () => { + let sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'month'), + '02', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'day'), + '30', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'year'), + '2026', + ); + const result = validateSections(sections); + expect(result.valid).toBe(false); + expect(result.incomplete).toBe(false); + }); + + it('Feb 29 is valid on a leap year', () => { + let sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'month'), + '02', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'day'), + '29', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'year'), + '2024', + ); + const result = validateSections(sections); + expect(result.valid).toBe(true); + expect(result.date?.getFullYear()).toBe(2024); + }); + + it('Feb 29 is invalid on a non-leap year', () => { + let sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'month'), + '02', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'day'), + '29', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'year'), + '2025', + ); + const result = validateSections(sections); + expect(result.valid).toBe(false); + }); + + it('month=13 is invalid', () => { + let sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'month'), + '13', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'day'), + '01', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'year'), + '2026', + ); + const result = validateSections(sections); + expect(result.valid).toBe(false); + }); + + it('12-hour mode converts PM correctly', () => { + let sections = buildSections({ mode: 'time', locale: 'en-US', hourCycle: 12 }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'hour'), + '03', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'minute'), + '15', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'meridiem'), + 'PM', + ); + const result = validateSections(sections); + expect(result.valid).toBe(true); + expect(result.date?.getHours()).toBe(15); + expect(result.date?.getMinutes()).toBe(15); + }); + + it('12-hour mode maps 12 AM to midnight and 12 PM to noon', () => { + let sections = buildSections({ mode: 'time', locale: 'en-US', hourCycle: 12 }); + const hourIdx = sections.findIndex((s) => s.type === 'hour'); + const minuteIdx = sections.findIndex((s) => s.type === 'minute'); + const meridiemIdx = sections.findIndex((s) => s.type === 'meridiem'); + + sections = setSectionValue(sections, hourIdx, '12'); + sections = setSectionValue(sections, minuteIdx, '00'); + sections = setSectionValue(sections, meridiemIdx, 'AM'); + let result = validateSections(sections); + expect(result.date?.getHours()).toBe(0); + + sections = setSectionValue(sections, meridiemIdx, 'PM'); + result = validateSections(sections); + expect(result.date?.getHours()).toBe(12); + }); +}); + +describe('adjustSectionValue', () => { + it('increments month within bounds', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const m = sections.find((s) => s.type === 'month')!; + const withVal = { ...m, value: '05' }; + expect(adjustSectionValue(withVal, 1)).toBe('06'); + expect(adjustSectionValue(withVal, -1)).toBe('04'); + }); + + it('wraps month at boundaries', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const m = sections.find((s) => s.type === 'month')!; + expect(adjustSectionValue({ ...m, value: '12' }, 1)).toBe('01'); + expect(adjustSectionValue({ ...m, value: '01' }, -1)).toBe('12'); + }); + + it('starts empty sections at min on Arrow Up and max on Arrow Down', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const m = sections.find((s) => s.type === 'month')!; + expect(adjustSectionValue({ ...m, value: '' }, 1)).toBe('01'); + expect(adjustSectionValue({ ...m, value: '' }, -1)).toBe('12'); + }); + + it('toggles meridiem', () => { + const meridiem = { + type: 'meridiem' as const, + value: 'AM', + placeholder: 'AM', + maxLength: null, + min: null, + max: null, + }; + expect(adjustSectionValue(meridiem, 1)).toBe('PM'); + expect(adjustSectionValue({ ...meridiem, value: 'PM' }, 1)).toBe('AM'); + expect(adjustSectionValue({ ...meridiem, value: '' }, 1)).toBe('AM'); + expect(adjustSectionValue({ ...meridiem, value: '' }, -1)).toBe('PM'); + }); +}); + +describe('applyDigitToSection', () => { + const buildMonth = () => + buildSections({ mode: 'date', locale: 'en-US', value: null }).find((s) => s.type === 'month')!; + const buildYear = () => + buildSections({ mode: 'date', locale: 'en-US', value: null }).find((s) => s.type === 'year')!; + const buildHour24 = () => + buildSections({ mode: 'time', locale: 'en-US', hourCycle: 24 }).find((s) => s.type === 'hour')!; + + it('accepts a first digit in month without advancing', () => { + const m = buildMonth(); + const r = applyDigitToSection(m, '1'); + expect(r.value).toBe('1'); + expect(r.shouldAdvance).toBe(false); + }); + + it('appends a second digit in month and advances', () => { + const m = { ...buildMonth(), value: '1' }; + const r = applyDigitToSection(m, '2'); + expect(r.value).toBe('12'); + expect(r.shouldAdvance).toBe(true); + }); + + it('digit that would exceed month max replaces + advances (e.g. 1 then 3 -> snap)', () => { + // Typing "1" then "3" in month: "13" > 12, so fresh "3" replaces and advances (3*10>12). + const m = { ...buildMonth(), value: '1' }; + const r = applyDigitToSection(m, '3'); + expect(r.shouldAdvance).toBe(true); + expect(Number(r.value)).toBe(3); + expect(r.value).toBe('03'); // padded on advance + }); + + it('typing a leading digit that cannot be extended advances (e.g. "3" in month)', () => { + const m = buildMonth(); + const r = applyDigitToSection(m, '3'); + // 3*10=30 > 12, so shouldAdvance with value "03" + expect(r.shouldAdvance).toBe(true); + expect(r.value).toBe('03'); + }); + + it('year accepts 4 digits before advancing', () => { + let y = buildYear(); + let r = applyDigitToSection(y, '2'); + expect(r.shouldAdvance).toBe(false); + y = { ...y, value: r.value }; + r = applyDigitToSection(y, '0'); + expect(r.shouldAdvance).toBe(false); + y = { ...y, value: r.value }; + r = applyDigitToSection(y, '2'); + expect(r.shouldAdvance).toBe(false); + y = { ...y, value: r.value }; + r = applyDigitToSection(y, '6'); + expect(r.shouldAdvance).toBe(true); + expect(r.value).toBe('2026'); + }); + + it('hour 24: typing 2 then 5 results in fresh "5" with advance', () => { + const h = { ...buildHour24(), value: '2' }; + const r = applyDigitToSection(h, '5'); + // 25 > 23 so replace with "5", advance (5*10=50>23) + expect(r.shouldAdvance).toBe(true); + expect(r.value).toBe('05'); + }); + + it('hour 24: typing 0 stays as "0" (no advance), then "9" -> "09" advance', () => { + const h0 = buildHour24(); + const r1 = applyDigitToSection(h0, '0'); + expect(r1.value).toBe('0'); + expect(r1.shouldAdvance).toBe(false); + const r2 = applyDigitToSection({ ...h0, value: '0' }, '9'); + expect(r2.value).toBe('09'); + expect(r2.shouldAdvance).toBe(true); + }); + + it('non-digit input returns unchanged', () => { + const m = buildMonth(); + const r = applyDigitToSection(m, 'a'); + expect(r.value).toBe(m.value); + expect(r.shouldAdvance).toBe(false); + }); +}); + +describe('clampDayToMonth', () => { + it('clamps day=31 to 30 when month is April', () => { + let sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'month'), + '04', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'day'), + '31', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'year'), + '2026', + ); + const clamped = clampDayToMonth(sections); + const day = clamped.find((s) => s.type === 'day'); + expect(day?.value).toBe('30'); + }); + + it('leaves day alone if it fits', () => { + let sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'month'), + '03', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'day'), + '31', + ); + sections = setSectionValue( + sections, + sections.findIndex((s) => s.type === 'year'), + '2026', + ); + const clamped = clampDayToMonth(sections); + const day = clamped.find((s) => s.type === 'day'); + expect(day?.value).toBe('31'); + }); +}); + +describe('applyPaste', () => { + it('fills all date sections from "04/12/2026"', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const result = applyPaste(sections, '04/12/2026'); + const month = result.find((s) => s.type === 'month'); + const day = result.find((s) => s.type === 'day'); + const year = result.find((s) => s.type === 'year'); + expect(month?.value).toBe('04'); + expect(day?.value).toBe('12'); + expect(year?.value).toBe('2026'); + }); + + it('fills en-GB order from "12/04/2026"', () => { + const sections = buildSections({ mode: 'date', locale: 'en-GB', value: null }); + const result = applyPaste(sections, '12/04/2026'); + const month = result.find((s) => s.type === 'month'); + const day = result.find((s) => s.type === 'day'); + const year = result.find((s) => s.type === 'year'); + expect(day?.value).toBe('12'); + expect(month?.value).toBe('04'); + expect(year?.value).toBe('2026'); + }); + + it('handles datetime paste', () => { + const sections = buildSections({ + mode: 'datetime', + locale: 'en-US', + hourCycle: 24, + }); + const result = applyPaste(sections, '04/12/2026 14:30'); + const hour = result.find((s) => s.type === 'hour'); + const minute = result.find((s) => s.type === 'minute'); + expect(hour?.value).toBe('14'); + expect(minute?.value).toBe('30'); + }); + + it('ignores out-of-range tokens', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + const result = applyPaste(sections, '99/99/2026'); + const month = result.find((s) => s.type === 'month'); + const day = result.find((s) => s.type === 'day'); + const year = result.find((s) => s.type === 'year'); + // Out-of-range tokens skip; year should still be set. + expect(month?.value).toBe(''); + expect(day?.value).toBe(''); + expect(year?.value).toBe('2026'); + }); +}); + +describe('getNextEditableIndex / getPreviousEditableIndex', () => { + const sections = buildSections({ mode: 'date', locale: 'en-US', value: null }); + + it('returns first editable when from is null', () => { + const idx = getNextEditableIndex(sections, null); + expect(idx).not.toBeNull(); + expect(sections[idx as number]?.type).not.toBe('literal'); + }); + + it('skips literal sections', () => { + const first = getNextEditableIndex(sections, null) as number; + const second = getNextEditableIndex(sections, first) as number; + expect(sections[second]?.type).not.toBe('literal'); + expect(second).toBeGreaterThan(first + 1); + }); + + it('returns null at end', () => { + // Find last editable — seed from a known-non-null first index. + const first = getNextEditableIndex(sections, null); + expect(first).not.toBeNull(); + let last: number = first as number; + while (true) { + const next = getNextEditableIndex(sections, last); + if (next === null) break; + last = next; + } + expect(getNextEditableIndex(sections, last)).toBeNull(); + }); + + it('previous from first is null', () => { + const first = getNextEditableIndex(sections, null); + expect(getPreviousEditableIndex(sections, first)).toBeNull(); + }); +}); diff --git a/packages/base-ui/src/components/date-field/sections.ts b/packages/base-ui/src/components/date-field/sections.ts new file mode 100644 index 0000000..8e0685b --- /dev/null +++ b/packages/base-ui/src/components/date-field/sections.ts @@ -0,0 +1,698 @@ +/** + * Section model + pure helpers for the DateField primitive. + * + * Inspired by MUI X's `useField`, adapted for our constraints: + * - Native `Date` (no adapter) + * - `Intl.DateTimeFormatToParts` for format tokenization + * - Simpler scope: skip localized digits, RTL reordering, adapters + */ + +export type SectionType = + | 'year' + | 'month' + | 'day' + | 'hour' + | 'minute' + | 'second' + | 'meridiem' // AM/PM + | 'literal'; // separator like "/", ":", " " + +export interface Section { + type: SectionType; + /** Raw typed value or '' if empty */ + value: string; + /** Placeholder shown when value is empty */ + placeholder: string; + /** For digit sections: expected length (e.g. 2 for MM, 4 for YYYY). null for literal/meridiem. */ + maxLength: number | null; + /** Inclusive min/max for validation (e.g. month = 1..12). null for literal/meridiem. */ + min: number | null; + max: number | null; + /** + * Hour cycle (12 or 24). Set on hour sections only; undefined otherwise. + * Validation reads this flag to decide whether meridiem is required, rather + * than re-inferring from `max === 12`. + */ + hourCycle?: 12 | 24; +} + +export interface BuildSectionsOptions { + mode: 'date' | 'time' | 'datetime'; + locale?: string; + hourCycle?: 12 | 24; + showSeconds?: boolean; + /** Optional current value used to seed section content. */ + value?: Date | null; +} + +const DIGIT_PLACEHOLDERS: Record, string> = { + year: 'YYYY', + month: 'MM', + day: 'DD', + hour: 'HH', + minute: 'mm', + second: 'ss', +}; + +const NUMERIC_TYPES: ReadonlySet = new Set([ + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', +]); + +/** Representative date: 2026-01-07 (a Wednesday) at 14:30:45 local time. */ +function getRepresentativeDate(): Date { + return new Date(2026, 0, 7, 14, 30, 45); +} + +function getDateFormatOptions(): Intl.DateTimeFormatOptions { + // Callers only invoke this for `mode === 'date'` or `'datetime'`; the shape + // is identical in both cases. + return { year: 'numeric', month: '2-digit', day: '2-digit' }; +} + +function getTimeFormatOptions( + hourCycle: 12 | 24, + showSeconds: boolean, +): Intl.DateTimeFormatOptions { + const opts: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + hour12: hourCycle === 12, + }; + if (showSeconds) opts.second = '2-digit'; + return opts; +} + +function buildFormatOptions(opts: BuildSectionsOptions): Intl.DateTimeFormatOptions { + const hourCycle = opts.hourCycle ?? 24; + const showSeconds = opts.showSeconds ?? false; + + if (opts.mode === 'date') { + return getDateFormatOptions(); + } + if (opts.mode === 'time') { + return getTimeFormatOptions(hourCycle, showSeconds); + } + // datetime + return { + ...getDateFormatOptions(), + ...getTimeFormatOptions(hourCycle, showSeconds), + }; +} + +/** + * Map an `Intl.DateTimeFormatPartTypes` value to our internal SectionType. + * Returns null for unsupported parts (which we'll skip). + */ +function partTypeToSectionType(part: Intl.DateTimeFormatPart['type']): SectionType | null { + switch (part) { + case 'year': + return 'year'; + case 'month': + return 'month'; + case 'day': + return 'day'; + case 'hour': + return 'hour'; + case 'minute': + return 'minute'; + case 'second': + return 'second'; + case 'dayPeriod': + return 'meridiem'; + case 'literal': + return 'literal'; + default: + return null; + } +} + +function getMinMax(type: SectionType, hourCycle: 12 | 24): { min: number | null; max: number | null } { + switch (type) { + case 'year': + return { min: 1, max: 9999 }; + case 'month': + return { min: 1, max: 12 }; + case 'day': + return { min: 1, max: 31 }; + case 'hour': + return hourCycle === 12 ? { min: 1, max: 12 } : { min: 0, max: 23 }; + case 'minute': + case 'second': + return { min: 0, max: 59 }; + case 'meridiem': + case 'literal': + default: + return { min: null, max: null }; + } +} + +/** + * Pad a number to `len` digits with leading zeros. Negative numbers are not expected here. + */ +export function padZero(n: number, len: number): string { + const s = String(n); + if (s.length >= len) return s; + return '0'.repeat(len - s.length) + s; +} + +/** + * Extract a numeric section value from a Date based on the section's type and hour cycle. + * Returns the formatted string representation using `maxLength` leading zeros. + */ +export function getSectionValueFromDate( + type: SectionType, + date: Date, + maxLength: number, + hourCycle: 12 | 24, +): string { + switch (type) { + case 'year': + return padZero(date.getFullYear(), maxLength); + case 'month': + return padZero(date.getMonth() + 1, maxLength); + case 'day': + return padZero(date.getDate(), maxLength); + case 'hour': { + const h = date.getHours(); + if (hourCycle === 24) return padZero(h, maxLength); + // 12-hour display: 0 -> 12, 13 -> 1, etc. + const display = h % 12 === 0 ? 12 : h % 12; + return padZero(display, maxLength); + } + case 'minute': + return padZero(date.getMinutes(), maxLength); + case 'second': + return padZero(date.getSeconds(), maxLength); + default: + return ''; + } +} + +/** + * Build an ordered array of `Section`s for the given mode/locale/options. + * + * Uses `Intl.DateTimeFormat(locale).formatToParts()` to establish + * the display order (e.g. `en-US` -> month/day/year, `en-GB` -> day/month/year). + */ +export function buildSections(options: BuildSectionsOptions): Section[] { + const hourCycle = options.hourCycle ?? 24; + const formatOptions = buildFormatOptions(options); + + // Nothing to render for this mode. + if (Object.keys(formatOptions).length === 0) return []; + + const representative = getRepresentativeDate(); + const fmt = new Intl.DateTimeFormat(options.locale, formatOptions); + const parts = fmt.formatToParts(representative); + + const sections: Section[] = []; + + for (const part of parts) { + const type = partTypeToSectionType(part.type); + if (type === null) continue; + + if (type === 'literal') { + sections.push({ + type, + value: part.value, + placeholder: part.value, + maxLength: null, + min: null, + max: null, + }); + continue; + } + + if (type === 'meridiem') { + let value = ''; + if (options.value) { + value = options.value.getHours() >= 12 ? 'PM' : 'AM'; + } + sections.push({ + type, + value, + placeholder: 'AM', + maxLength: null, + min: null, + max: null, + }); + continue; + } + + // Numeric section: derive maxLength from the formatted token length. + // e.g. "2026" -> 4, "04" -> 2. + const maxLength = part.value.length; + const { min, max } = getMinMax(type, hourCycle); + + let value = ''; + if (options.value) { + value = getSectionValueFromDate(type, options.value, maxLength, hourCycle); + } + + sections.push({ + type, + value, + placeholder: DIGIT_PLACEHOLDERS[type].slice(0, maxLength), + maxLength, + min, + max, + // Only hour sections carry an explicit hour cycle; validateSections + // relies on this flag to drive 12h → 24h conversion. + ...(type === 'hour' ? { hourCycle } : {}), + }); + } + + return sections; +} + +/** True if the `year` is a leap year (Gregorian calendar). */ +export function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +/** + * Number of days in `month` (1-12) for `year`. + * If `year` is not supplied (NaN), assume non-leap behaviour for February (28). + */ +export function getDaysInMonth(year: number, month: number): number { + if (month < 1 || month > 12) return 31; // fall back to max + if (month === 2) { + if (Number.isFinite(year) && isLeapYear(year)) return 29; + return 28; + } + // 30 days: April, June, September, November + if (month === 4 || month === 6 || month === 9 || month === 11) return 30; + return 31; +} + +export interface ValidateSectionsResult { + /** True when all non-literal sections are populated AND valid. */ + valid: boolean; + /** Parsed Date on success, null on failure or incompleteness. */ + date: Date | null; + /** True if ANY section is empty (different from "invalid"). */ + incomplete: boolean; +} + +/** + * Validate + parse sections into a Date. + * Returns `{ valid: false, date: null, incomplete: true }` when any required section is empty. + */ +export function validateSections(sections: Section[]): ValidateSectionsResult { + const nonLiterals = sections.filter((s) => s.type !== 'literal'); + + // Check for emptiness first. + for (const s of nonLiterals) { + if (s.value === '') { + return { valid: false, date: null, incomplete: true }; + } + } + + let year = NaN; + let month = NaN; + let day = NaN; + let hour = NaN; + let minute = 0; + let second = 0; + let meridiem: 'AM' | 'PM' | null = null; + let has12HourHour = false; + + let hasYear = false; + let hasMonth = false; + let hasDay = false; + let hasHour = false; + + for (const s of sections) { + if (s.type === 'literal') continue; + + if (s.type === 'meridiem') { + const v = s.value.toUpperCase(); + if (v !== 'AM' && v !== 'PM') { + return { valid: false, date: null, incomplete: false }; + } + meridiem = v; + continue; + } + + if (!NUMERIC_TYPES.has(s.type)) continue; + + const n = Number(s.value); + if (!Number.isFinite(n) || !/^\d+$/.test(s.value)) { + return { valid: false, date: null, incomplete: false }; + } + + // min/max check + if (s.min !== null && n < s.min) { + return { valid: false, date: null, incomplete: false }; + } + if (s.max !== null && n > s.max) { + return { valid: false, date: null, incomplete: false }; + } + + switch (s.type) { + case 'year': + year = n; + hasYear = true; + break; + case 'month': + month = n; + hasMonth = true; + break; + case 'day': + day = n; + hasDay = true; + break; + case 'hour': + hour = n; + hasHour = true; + // Prefer the explicit `hourCycle` stamped on the hour Section; fall + // back to max===12 inference only for Section objects constructed + // outside of buildSections (e.g. in tests). + if (s.hourCycle === 12 || (s.hourCycle === undefined && s.max === 12)) { + has12HourHour = true; + } + break; + case 'minute': + minute = n; + break; + case 'second': + second = n; + break; + } + } + + // Day-in-month check (when month/year are present). Defaults: + // - If year is missing, use a leap year to be lenient (e.g. time-only mode). + if (hasDay && hasMonth) { + const maxDay = getDaysInMonth(hasYear ? year : 2024, month); + if (day > maxDay) { + return { valid: false, date: null, incomplete: false }; + } + } + + // Convert 12-hour to 24-hour + if (hasHour && has12HourHour) { + if (meridiem === null) { + // 12-hour mode but no AM/PM provided (should have been caught by emptiness check). + return { valid: false, date: null, incomplete: false }; + } + const base = hour % 12; // 12 -> 0, 1..11 -> 1..11 + hour = meridiem === 'PM' ? base + 12 : base; + } + + // Construct. Time-only mode uses epoch date; datetime uses provided y/m/d. + const y = hasYear ? year : 1970; + const m = hasMonth ? month - 1 : 0; + const d = hasDay ? day : 1; + const h = hasHour ? hour : 0; + + const date = new Date(y, m, d, h, minute, second); + // `new Date(y, ...)` remaps years 0–99 to 1900–1999; restore the intended year. + date.setFullYear(y); + + if (Number.isNaN(date.getTime())) { + return { valid: false, date: null, incomplete: false }; + } + + return { valid: true, date, incomplete: false }; +} + +/** + * Update one section's `value` immutably, returning a new sections array. + */ +export function setSectionValue(sections: Section[], index: number, value: string): Section[] { + if (index < 0 || index >= sections.length) return sections; + const existing = sections[index]; + if (!existing) return sections; + const next = sections.slice(); + next[index] = { ...existing, value }; + return next; +} + +/** Return the indices of non-literal sections in order. */ +export function getEditableIndices(sections: Section[]): number[] { + const result: number[] = []; + for (let i = 0; i < sections.length; i++) { + const s = sections[i]; + if (s && s.type !== 'literal') result.push(i); + } + return result; +} + +/** + * Find the next editable section index after `from`, or null if none. + * If `from` is null, returns the first editable index. + */ +export function getNextEditableIndex(sections: Section[], from: number | null): number | null { + const editable = getEditableIndices(sections); + if (editable.length === 0) return null; + if (from === null) return editable[0] ?? null; + for (const idx of editable) { + if (idx > from) return idx; + } + return null; +} + +/** + * Find the previous editable section index before `from`, or null if none. + */ +export function getPreviousEditableIndex( + sections: Section[], + from: number | null, +): number | null { + const editable = getEditableIndices(sections); + if (editable.length === 0) return null; + if (from === null) return editable[editable.length - 1] ?? null; + for (let i = editable.length - 1; i >= 0; i--) { + const idx = editable[i]; + if (idx !== undefined && idx < from) return idx; + } + return null; +} + +/** + * Increment/decrement a section's value by `delta`, wrapping within [min, max]. + * If the section is empty, starts from `min`. + * For meridiem, toggles between AM and PM. + * Returns the new string value. + */ +export function adjustSectionValue(section: Section, delta: number): string { + if (section.type === 'meridiem') { + const current = section.value.toUpperCase(); + if (current === 'AM') return 'PM'; + if (current === 'PM') return 'AM'; + // empty -> default start + return delta >= 0 ? 'AM' : 'PM'; + } + + if (section.type === 'literal') return section.value; + + const min = section.min ?? 0; + const max = section.max ?? 0; + const maxLength = section.maxLength ?? 2; + + if (min === max) return padZero(min, maxLength); + + const range = max - min + 1; + + let current: number; + if (section.value === '') { + // Empty: Arrow Up -> start at `min`, Arrow Down -> start at `max`. + current = delta >= 0 ? min - delta : max - delta; + } else { + current = Number(section.value); + if (!Number.isFinite(current)) current = min; + } + + // Compute raw next then wrap. + let next = current + delta; + // Wrap into [min, max] + next = ((((next - min) % range) + range) % range) + min; + + return padZero(next, maxLength); +} + +/** + * Clamp a day section against the current month/year if possible. Returns an + * updated sections array where the day is reduced if it exceeds the days-in-month. + */ +export function clampDayToMonth(sections: Section[]): Section[] { + const dayIdx = sections.findIndex((s) => s.type === 'day'); + if (dayIdx === -1) return sections; + + const day = sections[dayIdx]; + if (!day || day.value === '') return sections; + + const monthSection = sections.find((s) => s.type === 'month'); + if (!monthSection || monthSection.value === '') return sections; + + const yearSection = sections.find((s) => s.type === 'year'); + // Only trust the year when it is fully populated (value length matches maxLength). + // Otherwise a partial year (e.g. "2" while typing) would mis-clamp against year 2. + const yearComplete = + yearSection && + yearSection.value !== '' && + yearSection.maxLength !== null && + yearSection.value.length === yearSection.maxLength; + const year = yearComplete ? Number(yearSection.value) : 2024; /* leap */ + + const month = Number(monthSection.value); + if (!Number.isFinite(month) || month < 1 || month > 12) return sections; + + const maxDays = getDaysInMonth(year, month); + const dayNum = Number(day.value); + if (!Number.isFinite(dayNum)) return sections; + if (dayNum <= maxDays) return sections; + + return setSectionValue(sections, dayIdx, padZero(maxDays, day.maxLength ?? 2)); +} + +/** + * Apply a typed digit to a section. Returns the new section value (possibly truncated + * or replaced), and a `shouldAdvance` flag indicating whether focus should move to the + * next section (either because maxLength is reached or the next digit would overflow). + * + * Rules: + * - If the section is not a numeric type, returns unchanged. + * - If the section is empty: the digit becomes the new value. + * - If appending the digit stays within [min, max]: append and check if length reached maxLength. + * - If appending overflows (> max): the digit REPLACES the existing value (fresh start). + */ +export function applyDigitToSection( + section: Section, + digit: string, +): { value: string; shouldAdvance: boolean } { + if (!/^\d$/.test(digit)) return { value: section.value, shouldAdvance: false }; + if (!NUMERIC_TYPES.has(section.type)) return { value: section.value, shouldAdvance: false }; + + const max = section.max ?? 99; + const min = section.min ?? 0; + const maxLength = section.maxLength ?? 2; + + const d = Number(digit); + + // Empty: start fresh. For sections with min > 0 (like month=1..12), typing "0" + // is kept as "0" (staged) but does NOT advance — the user may want to type "01". + // However, if the digit alone already equals/exceeds half the max range, we can + // pre-advance. Keep it simple: always stage and only advance when maxLength reached + // or when the next digit could not grow. + if (section.value === '') { + // If typing this single digit would be > max, it's not a valid start either — but + // for max=12 typing "3" is fine (stays as "3"). For max=5 typing "7"... clamp to max. + const num = d; + if (num > max) { + // Can't fit. Snap to max. + const clamped = Math.min(num, max); + return { value: padZero(clamped, maxLength), shouldAdvance: true }; + } + + // If num*10 > max, no more digits can be appended → advance. + // Also advance if num already has length === maxLength (only possible if maxLength=1). + const shouldAdvance = num * 10 > max || maxLength === 1; + return { + value: shouldAdvance ? padZero(num, maxLength) : String(num), + shouldAdvance, + }; + } + + // Non-empty: try to append. + const candidate = section.value + digit; + const candidateNum = Number(candidate); + + if (candidateNum <= max && candidate.length <= maxLength) { + // Accept the append. Advance when we reach maxLength OR no more digits can be added. + const shouldAdvance = + candidate.length === maxLength || candidateNum * 10 > max; + + if (shouldAdvance) { + // On advance, make sure the stored value has leading zeros up to maxLength. + // But only if we still meet min. If below min, clamp up to min. + let finalNum = candidateNum; + if (finalNum < min) finalNum = min; + return { value: padZero(finalNum, maxLength), shouldAdvance: true }; + } + + return { value: candidate, shouldAdvance: false }; + } + + // Overflow — the new digit replaces the old value. + const fresh = d; + if (fresh > max) { + return { value: padZero(Math.min(fresh, max), maxLength), shouldAdvance: true }; + } + + const shouldAdvance = fresh * 10 > max || maxLength === 1; + return { + value: shouldAdvance ? padZero(fresh, maxLength) : String(fresh), + shouldAdvance, + }; +} + +/** + * Parse a free-form pasted string and attempt to distribute digits into matching sections. + * Returns an updated `sections` array. Strategy: + * - Split the pasted text by non-digit+non-letter characters. + * - Match tokens to non-literal section slots in order (preferring digits for digit + * sections, letters for meridiem). + * - Only apply tokens that fit the section's bounds. + */ +export function applyPaste(sections: Section[], text: string): Section[] { + const tokens = text.split(/[^0-9A-Za-z]+/).filter((t) => t.length > 0); + if (tokens.length === 0) return sections; + + const result = sections.slice(); + const editableIdxs = getEditableIndices(result); + + let tokenIdx = 0; + for (const sectionIdx of editableIdxs) { + if (tokenIdx >= tokens.length) break; + const section = result[sectionIdx]; + const token = tokens[tokenIdx]; + if (!section || token === undefined) continue; + + if (section.type === 'meridiem') { + const letters = token.replace(/[^A-Za-z]/g, '').toUpperCase(); + if (letters === 'AM' || letters === 'PM') { + result[sectionIdx] = { ...section, value: letters }; + tokenIdx++; + } + // Else: the current token is not a meridiem letter pair (likely a stray + // digit token). Leave `tokenIdx` where it is so the token remains + // available to the next digit section, and skip this meridiem slot. + continue; + } + + // Numeric + const digits = token.replace(/[^0-9]/g, ''); + if (digits === '') { + tokenIdx++; + continue; + } + + const maxLength = section.maxLength ?? 2; + // Take up to maxLength digits from start; if too many, truncate. + let candidate = digits.slice(0, maxLength); + // Pad with leading zeros if token is shorter than maxLength (interpretation: numeric). + const asNum = Number(candidate); + const max = section.max ?? Number.POSITIVE_INFINITY; + const min = section.min ?? 0; + + if (!Number.isFinite(asNum) || asNum < min || asNum > max) { + tokenIdx++; + continue; + } + + // Store with leading zeros for digit sections. + candidate = padZero(asNum, maxLength); + result[sectionIdx] = { ...section, value: candidate }; + tokenIdx++; + } + + return clampDayToMonth(result); +} + diff --git a/packages/base-ui/src/components/date-field/useDateField.ts b/packages/base-ui/src/components/date-field/useDateField.ts new file mode 100644 index 0000000..e946c72 --- /dev/null +++ b/packages/base-ui/src/components/date-field/useDateField.ts @@ -0,0 +1,517 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ClipboardEvent, + type KeyboardEvent, + type MouseEvent, + type FocusEvent, +} from 'react'; +import { flushSync } from 'react-dom'; +import { + adjustSectionValue, + applyDigitToSection, + applyPaste, + buildSections, + clampDayToMonth, + getEditableIndices, + getNextEditableIndex, + getPreviousEditableIndex, + setSectionValue, + validateSections, + type Section, +} from './sections'; +import { isDateInRange } from '../date-picker/dateUtils'; + +export interface UseDateFieldOptions { + value: Date | null; + onChange?: (value: Date | null) => void; + mode: 'date' | 'time' | 'datetime'; + locale?: string; + hourCycle?: 12 | 24; + showSeconds?: boolean; + min?: Date; + max?: Date; + disabled?: boolean; + readOnly?: boolean; +} + +export interface SectionDomProps { + /** Tag whether this section is currently focused. */ + 'data-focused': '' | undefined; + /** True when the value is empty (placeholder shown). */ + 'data-placeholder': '' | undefined; + /** True when this is a literal (separator) span — not editable. */ + 'data-literal': '' | undefined; + /** 0-based section index, for testing + delegation. */ + 'data-section-index': number; + /** Section type, for testing + delegation. */ + 'data-section-type': Section['type']; + role?: string; + 'aria-label'?: string; + 'aria-readonly'?: boolean; + 'aria-hidden'?: boolean; + contentEditable?: 'true' | 'false' | 'plaintext-only'; + suppressContentEditableWarning?: boolean; + tabIndex?: number; + spellCheck?: boolean; + autoCorrect?: string; + inputMode?: 'numeric' | 'text' | 'none'; + onClick?: (e: MouseEvent) => void; + onFocus?: (e: FocusEvent) => void; + onBlur?: (e: FocusEvent) => void; + onKeyDown?: (e: KeyboardEvent) => void; + onBeforeInput?: (e: React.SyntheticEvent) => void; +} + +export interface UseDateFieldReturn { + /** Current sections (derived from options + user edits). */ + sections: Section[]; + /** 0-based index of the currently focused section, or null. */ + focusedIndex: number | null; + /** Whether the current section state represents a complete valid date. */ + isValid: boolean; + /** Spread these onto the root element. */ + rootProps: { + role: 'group'; + 'aria-disabled': boolean | undefined; + onKeyDown: (e: KeyboardEvent) => void; + onPaste: (e: ClipboardEvent) => void; + onBlur: (e: FocusEvent) => void; + }; + /** Returns per-section DOM props for the editable section span. */ + getSectionProps: (index: number) => SectionDomProps; + /** Register a ref for a section's DOM element (callback ref). */ + registerSectionRef: (index: number) => (el: HTMLElement | null) => void; + /** Programmatically focus a section by index. */ + focusSection: (index: number) => void; +} + +/** + * `useDateField` is the headless hook driving the sectioned input primitive. + * It maintains section state derived from (locale, mode, value), handles + * keyboard + paste, and calls `onChange` with a parsed Date when the sections + * represent a complete valid datetime. + */ +export function useDateField(options: UseDateFieldOptions): UseDateFieldReturn { + const { + value, + onChange, + mode, + locale, + hourCycle, + showSeconds, + min, + max, + disabled, + readOnly, + } = options; + + // Build the initial section template (types + order + min/max) without value. + const template = useMemo( + () => buildSections({ mode, locale, hourCycle, showSeconds }), + [mode, locale, hourCycle, showSeconds], + ); + + // State: current sections. Initialized from template + value. + const [sections, setSections] = useState(() => + buildSections({ mode, locale, hourCycle, showSeconds, value }), + ); + + // State: focused section index. null when nothing focused. + const [focusedIndex, setFocusedIndex] = useState(null); + + // Track the last "external" value we synced from, to avoid clobbering local edits. + const lastSyncedValue = useRef(value); + const lastTemplateKey = useRef(JSON.stringify(template.map((s) => s.type))); + + const currentTemplateKey = useMemo( + () => JSON.stringify(template.map((s) => s.type)), + [template], + ); + + // When the template changes (mode/locale/hourCycle/showSeconds), rebuild from value. + useEffect(() => { + if (lastTemplateKey.current !== currentTemplateKey) { + setSections(buildSections({ mode, locale, hourCycle, showSeconds, value })); + lastTemplateKey.current = currentTemplateKey; + lastSyncedValue.current = value; + } + }, [currentTemplateKey, mode, locale, hourCycle, showSeconds, value]); + + // When the external value changes (and isn't the same as last sync), update sections. + useEffect(() => { + const prev = lastSyncedValue.current; + const changed = + (prev === null) !== (value === null) || + (prev !== null && value !== null && prev.getTime() !== value.getTime()); + if (changed) { + setSections(buildSections({ mode, locale, hourCycle, showSeconds, value })); + lastSyncedValue.current = value; + } + }, [value, mode, locale, hourCycle, showSeconds]); + + // Validation snapshot for current sections. + const validation = useMemo(() => validateSections(sections), [sections]); + + // Fire onChange when the parsed Date changes and differs from the last synced. + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }); + + useEffect(() => { + if (!validation.valid || validation.date === null) return; + const next = validation.date; + // Suppress onChange if the parsed date falls outside [min, max]. + if (!isDateInRange(next, min, max)) return; + const prev = lastSyncedValue.current; + const changed = + prev === null || prev.getTime() !== next.getTime(); + if (changed) { + lastSyncedValue.current = next; + onChangeRef.current?.(next); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [validation.valid, validation.date?.getTime(), min, max]); + + // Refs map for section DOM elements. + const sectionRefs = useRef>(new Map()); + + const registerSectionRef = useCallback( + (index: number) => (el: HTMLElement | null) => { + if (el === null) { + sectionRefs.current.delete(index); + } else { + sectionRefs.current.set(index, el); + } + }, + [], + ); + + const focusSection = useCallback((index: number) => { + const el = sectionRefs.current.get(index); + if (!el) { + // Still update state so render can focus next tick. + setFocusedIndex(index); + return; + } + el.focus(); + setFocusedIndex(index); + // Select all content for fast replacement + try { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + sel.addRange(range); + } + } catch { + /* ignore selection errors in jsdom */ + } + }, []); + + const focusNext = useCallback( + (from: number): boolean => { + const nextIdx = getNextEditableIndex(sections, from); + if (nextIdx === null) return false; + focusSection(nextIdx); + return true; + }, + [sections, focusSection], + ); + + const focusPrevious = useCallback( + (from: number): boolean => { + const prevIdx = getPreviousEditableIndex(sections, from); + if (prevIdx === null) return false; + focusSection(prevIdx); + return true; + }, + [sections, focusSection], + ); + + const handleRootKeyDown = useCallback( + (e: KeyboardEvent) => { + if (disabled) return; + if (focusedIndex === null) return; + + const section = sections[focusedIndex]; + if (!section || section.type === 'literal') return; + + // Tab navigation — allow native Tab to leave the component when at boundaries. + if (e.key === 'Tab') { + const moved = e.shiftKey + ? focusPrevious(focusedIndex) + : focusNext(focusedIndex); + if (moved) { + e.preventDefault(); + } + return; + } + + // Arrow keys: increment/decrement focused section + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + if (readOnly) return; + const delta = e.key === 'ArrowUp' ? 1 : -1; + const newValue = adjustSectionValue(section, delta); + setSections((prev) => { + const updated = setSectionValue(prev, focusedIndex, newValue); + // Clamp day after month/year changes + if (section.type === 'month' || section.type === 'year') { + return clampDayToMonth(updated); + } + return updated; + }); + return; + } + + // ArrowLeft/ArrowRight move between sections (like MUI) + if (e.key === 'ArrowLeft') { + e.preventDefault(); + focusPrevious(focusedIndex); + return; + } + if (e.key === 'ArrowRight') { + e.preventDefault(); + focusNext(focusedIndex); + return; + } + + // Digit input + if (/^[0-9]$/.test(e.key)) { + e.preventDefault(); + if (readOnly) return; + if (section.type === 'meridiem') return; + + const { value: newVal, shouldAdvance } = applyDigitToSection(section, e.key); + // Flush synchronously so the subsequent focusNext call reads the + // up-to-date `sections` (captured by the useCallback closure) — the + // previous queueMicrotask path could capture a stale focusedIndex. + flushSync(() => { + setSections((prev) => { + const updated = setSectionValue(prev, focusedIndex, newVal); + if (section.type === 'month' || section.type === 'year') { + return clampDayToMonth(updated); + } + return updated; + }); + }); + if (shouldAdvance) { + focusNext(focusedIndex); + } + return; + } + + // Letters for meridiem (A/P) + if (section.type === 'meridiem' && /^[apAP]$/.test(e.key)) { + e.preventDefault(); + if (readOnly) return; + const letter = e.key.toUpperCase() === 'A' ? 'AM' : 'PM'; + flushSync(() => { + setSections((prev) => setSectionValue(prev, focusedIndex, letter)); + }); + focusNext(focusedIndex); + return; + } + + // Backspace — clear section, then move to previous if already empty. + if (e.key === 'Backspace') { + e.preventDefault(); + if (readOnly) return; + if (section.value === '') { + focusPrevious(focusedIndex); + } else { + setSections((prev) => setSectionValue(prev, focusedIndex, '')); + // When we clear the current section, `onChange` won't be called because + // validation returns incomplete. But we should still reset the + // "last synced value" so a future re-fill triggers onChange. + // We do NOT call onChange(null) automatically — that could be surprising. + } + return; + } + + // Delete — behave like Backspace without backwards motion + if (e.key === 'Delete') { + e.preventDefault(); + if (readOnly) return; + if (section.value !== '') { + setSections((prev) => setSectionValue(prev, focusedIndex, '')); + } + return; + } + + // Escape — revert to external value + if (e.key === 'Escape') { + e.preventDefault(); + setSections(buildSections({ mode, locale, hourCycle, showSeconds, value })); + return; + } + + // Enter — commit (no-op; validation already fires onChange on valid) + if (e.key === 'Enter') { + e.preventDefault(); + return; + } + }, + [ + disabled, + readOnly, + focusedIndex, + sections, + focusNext, + focusPrevious, + mode, + locale, + hourCycle, + showSeconds, + value, + ], + ); + + const handleRootPaste = useCallback( + (e: ClipboardEvent) => { + if (disabled || readOnly) return; + e.preventDefault(); + const text = e.clipboardData.getData('text'); + if (!text) return; + let postPaste: Section[] | null = null; + flushSync(() => { + setSections((prev) => { + const next = applyPaste(prev, text); + postPaste = next; + return next; + }); + }); + // Advance focus to the first still-empty editable section, so the user + // can continue typing where the paste left off. If every editable + // section is now filled, focus the last editable one. + if (!postPaste) return; + const editable = getEditableIndices(postPaste); + if (editable.length === 0) return; + const pasted: Section[] = postPaste; + const firstEmpty = editable.find((idx) => pasted[idx]?.value === ''); + const target = firstEmpty ?? editable[editable.length - 1]; + if (target !== undefined) focusSection(target); + }, + [disabled, readOnly, focusSection], + ); + + const handleRootBlur = useCallback((e: FocusEvent) => { + const nextTarget = e.relatedTarget as HTMLElement | null; + const rootEl = e.currentTarget; + if (nextTarget && rootEl.contains(nextTarget)) { + // Still within the component; don't reset. + return; + } + setFocusedIndex(null); + }, []); + + const getSectionProps = useCallback( + (index: number): SectionDomProps => { + const section = sections[index]; + if (!section) { + return { + 'data-focused': undefined, + 'data-placeholder': undefined, + 'data-literal': undefined, + 'data-section-index': index, + 'data-section-type': 'literal', + }; + } + + const isFocused = focusedIndex === index; + const isLiteral = section.type === 'literal'; + const isPlaceholder = !isLiteral && section.value === ''; + + const base: SectionDomProps = { + 'data-focused': isFocused ? '' : undefined, + 'data-placeholder': isPlaceholder ? '' : undefined, + 'data-literal': isLiteral ? '' : undefined, + 'data-section-index': index, + 'data-section-type': section.type, + }; + + if (isLiteral) { + // Literals are presentational and non-editable. + return { + ...base, + 'aria-hidden': true, + contentEditable: 'false', + }; + } + + return { + ...base, + role: 'spinbutton', + 'aria-label': ariaLabelForSection(section.type), + 'aria-readonly': !!readOnly, + contentEditable: disabled || readOnly ? 'false' : 'true', + suppressContentEditableWarning: true, + tabIndex: isFocused ? 0 : -1, + spellCheck: false, + autoCorrect: 'off', + inputMode: section.type === 'meridiem' ? 'text' : 'numeric', + onClick: (e) => { + e.stopPropagation(); + focusSection(index); + }, + onFocus: () => { + setFocusedIndex(index); + }, + onBeforeInput: (e) => { + // Block native editing — we drive everything through keydown. + e.preventDefault(); + }, + }; + }, + [sections, focusedIndex, readOnly, disabled, focusSection], + ); + + const rootProps = useMemo( + () => + ({ + role: 'group', + 'aria-disabled': disabled || undefined, + onKeyDown: handleRootKeyDown, + onPaste: handleRootPaste, + onBlur: handleRootBlur, + }) as UseDateFieldReturn['rootProps'], + [disabled, handleRootKeyDown, handleRootPaste, handleRootBlur], + ); + + return { + sections, + focusedIndex, + isValid: validation.valid, + rootProps, + getSectionProps, + registerSectionRef, + focusSection, + }; +} + +function ariaLabelForSection(type: Section['type']): string { + switch (type) { + case 'year': + return 'Year'; + case 'month': + return 'Month'; + case 'day': + return 'Day'; + case 'hour': + return 'Hour'; + case 'minute': + return 'Minute'; + case 'second': + return 'Second'; + case 'meridiem': + return 'AM or PM'; + default: + return ''; + } +} 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..2566ee6 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/Calendar.module.css @@ -0,0 +1,101 @@ +.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'] { + background: var(--ov-color-datepicker-cell-bg); + 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; +} + +/* Range-selection styles */ +.cellInRange { + background: var(--ov-color-accent-soft); + color: var(--ov-color-fg-default); + border-radius: 0; +} + +.cellInRange:hover:not([aria-disabled='true']) { + background: var(--ov-color-accent-soft); +} + +.cellRangeStart { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.cellRangeEnd { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} 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..d1ba37b --- /dev/null +++ b/packages/base-ui/src/components/date-picker/Calendar.test.tsx @@ -0,0 +1,235 @@ +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'); + }); +}); + +describe('Calendar range mode', () => { + const START = new Date(2026, 3, 10); // April 10 + const END = new Date(2026, 3, 15); // April 15 + + it('renders both start and end with aria-selected=true', () => { + render( + {}} + locale="en-US" + />, + ); + const selected = screen.getAllByRole('gridcell', { selected: true }); + const texts = selected.map((el) => el.textContent?.trim()); + expect(texts).toContain('10'); + expect(texts).toContain('15'); + }); + + it('clicking a date when no start is set emits { start, end: null }', async () => { + const user = userEvent.setup(); + const onRangeChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('gridcell', { name: /^10/ })); + expect(onRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ start: expect.any(Date), end: null }), + ); + expect((onRangeChange.mock.calls[0]?.[0] as { start: Date }).start.getDate()).toBe(10); + }); + + it('clicking a date after start is set emits { start, end }', async () => { + const user = userEvent.setup(); + const onRangeChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + expect(onRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ start: START, end: expect.any(Date) }), + ); + expect((onRangeChange.mock.calls[0]?.[0] as { end: Date }).end.getDate()).toBe(20); + }); + + it('clicking a date before start resets the range', async () => { + const user = userEvent.setup(); + const onRangeChange = vi.fn(); + render( + , + ); + // Click April 5, which is before START (April 10) + await user.click(screen.getByRole('gridcell', { name: /^5/ })); + const call = onRangeChange.mock.calls[0]?.[0] as { start: Date; end: Date | null }; + expect(call.start.getDate()).toBe(5); + expect(call.end).toBeNull(); + }); + + it('cells between start and end get the in-range class', () => { + render( + {}} + locale="en-US" + />, + ); + // April 12 and 13 are strictly between April 10 and April 15 + const cell12 = screen.getByRole('gridcell', { name: /^12/ }); + const cell13 = screen.getByRole('gridcell', { name: /^13/ }); + expect(cell12.className).toMatch(/cellInRange/); + expect(cell13.className).toMatch(/cellInRange/); + // Start and end cells should NOT have the in-range class + const cell10 = screen.getByRole('gridcell', { name: /^10/ }); + const cell15 = screen.getByRole('gridcell', { name: /^15/ }); + expect(cell10.className).not.toMatch(/cellInRange/); + expect(cell15.className).not.toMatch(/cellInRange/); + }); + + it('disabled dates are not selectable in range mode', async () => { + const user = userEvent.setup(); + const onRangeChange = vi.fn(); + const isDateDisabled = (d: Date) => d.getDate() === 14; + render( + , + ); + const disabledCell = screen.getByRole('gridcell', { name: /^14/ }); + expect(disabledCell).toHaveAttribute('aria-disabled', 'true'); + await user.click(disabledCell); + expect(onRangeChange).not.toHaveBeenCalled(); + }); +}); 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..d0e1160 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/Calendar.tsx @@ -0,0 +1,391 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import styles from './Calendar.module.css'; +import { + addDays, + addMonths, + addYears, + getMonthMatrix, + isDateInRange, + isSameDay, + startOfDay, + startOfMonth, + type WeekStart, +} from './dateUtils'; +import { + formatMonthYear, + getWeekStartsOnForLocale, + getWeekdayLabels, +} from './formatters'; + +type CalendarBaseProps = { + min?: Date; + max?: Date; + isDateDisabled?: (date: Date) => boolean; + locale?: string; + weekStartsOn?: WeekStart; + autoFocus?: boolean; + className?: string; +}; + +type CalendarSingleProps = CalendarBaseProps & { + mode?: 'single'; + value: Date | null; + onChange: (value: Date) => void; +}; + +type CalendarRangeProps = CalendarBaseProps & { + mode: 'range'; + startDate: Date | null; + endDate: Date | null; + onRangeChange: (range: { start: Date | null; end: Date | null }) => void; +}; + +export type CalendarProps = CalendarSingleProps | CalendarRangeProps; + +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}`; +} + +/** + * Returns true if `d` is strictly between `a` and `b` (order-independent), + * compared at day granularity. Dates on the same calendar day — even with + * different times — are treated as equal. + */ +function isStrictlyBetween(d: Date, a: Date, b: Date): boolean { + const t = startOfDay(d).getTime(); + const ta = startOfDay(a).getTime(); + const tb = startOfDay(b).getTime(); + const lo = Math.min(ta, tb); + const hi = Math.max(ta, tb); + return t > lo && t < hi; +} + +export function Calendar(props: CalendarProps) { + const { + min, + max, + isDateDisabled, + locale, + weekStartsOn, + autoFocus, + className, + } = props; + + const isRangeMode = props.mode === 'range'; + + // Derive the "anchor" date for initial focus/view. + const anchorDate = isRangeMode + ? (props.startDate ?? new Date()) + : (props.value ?? new Date()); + + const resolvedWeekStart = weekStartsOn ?? getWeekStartsOnForLocale(locale); + const [focusedDate, setFocusedDate] = useState(() => anchorDate); + const [viewMonth, setViewMonth] = useState(() => startOfMonth(anchorDate)); + // Hover state for range preview (range mode only) + const [hoveredDate, setHoveredDate] = useState(null); + 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]); + + // When viewMonth changes independently of focusedDate (e.g. the header + // prev/next buttons), clamp focusedDate into the new month so the roving + // tabindex always points to a visible cell. Without this, clicking the + // header arrows would leave no cell with tabIndex=0. + useEffect(() => { + if ( + viewMonth.getMonth() === focusedDate.getMonth() && + viewMonth.getFullYear() === focusedDate.getFullYear() + ) { + return; + } + // Preserve the day-of-month when possible; clamp to the last day of the + // new month if the original day exceeds it. + const daysInTarget = new Date( + viewMonth.getFullYear(), + viewMonth.getMonth() + 1, + 0, + ).getDate(); + const clampedDay = Math.min(focusedDate.getDate(), daysInTarget); + setFocusedDate( + new Date(viewMonth.getFullYear(), viewMonth.getMonth(), clampedDay), + ); + }, [viewMonth, 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); + }, []); + + // Focus the cell after the DOM has committed (handles cross-month navigation + // where the new cell doesn't exist until the re-render completes). + useLayoutEffect(() => { + const selector = `[data-date='${toIsoDay(focusedDate)}']`; + gridRef.current?.querySelector(selector)?.focus(); + }, [focusedDate]); + + /** Handle a cell selection in single mode. */ + const handleSingleSelect = useCallback( + (date: Date) => { + if (!isRangeMode) { + (props as CalendarSingleProps).onChange(date); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isRangeMode, props], + ); + + /** Handle a cell selection in range mode. */ + const handleRangeSelect = useCallback( + (date: Date) => { + if (!isRangeMode) return; + const rangeProps = props as CalendarRangeProps; + const { startDate, endDate, onRangeChange } = rangeProps; + + if (!startDate || (startDate && endDate)) { + // No start yet, or both already set → start a fresh range + onRangeChange({ start: date, end: null }); + } else { + // Start is set, end is null — compare at day granularity + const dStart = startOfDay(date).getTime(); + const sStart = startOfDay(startDate).getTime(); + if (dStart >= sStart) { + onRangeChange({ start: startDate, end: date }); + } else { + // Clicked before start → reset with new start + onRangeChange({ start: date, end: null }); + } + } + setHoveredDate(null); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isRangeMode, props], + ); + + const handleCellClick = useCallback( + (date: Date) => { + if (isCellDisabled(date)) return; + if (isRangeMode) { + handleRangeSelect(date); + } else { + handleSingleSelect(date); + } + }, + [isCellDisabled, isRangeMode, handleRangeSelect, handleSingleSelect], + ); + + 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 ' ': + handleCellClick(current); + e.preventDefault(); + return; + default: + return; + } + if (next) { + e.preventDefault(); + moveFocus(next); + } + }; + + // Derived range endpoints for rendering + const selectedStart = isRangeMode ? (props as CalendarRangeProps).startDate : null; + const selectedEnd = isRangeMode ? (props as CalendarRangeProps).endDate : null; + + // Effective preview end: actual end if set, otherwise hovered date (range mode only) + const previewEnd = isRangeMode + ? (selectedEnd ?? (selectedStart ? hoveredDate : null)) + : null; + + 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 isToday = isSameDay(date, today); + const inMonth = date.getMonth() === viewMonth.getMonth(); + const focused = isSameDay(date, focusedDate); + + // Selection & range state + let selected: boolean; + let isInRange = false; + let isRangeStart = false; + let isRangeEnd = false; + + if (isRangeMode) { + const isStart = selectedStart ? isSameDay(date, selectedStart) : false; + const isEnd = selectedEnd ? isSameDay(date, selectedEnd) : false; + selected = isStart || isEnd; + isRangeStart = isStart; + isRangeEnd = isEnd; + + // In-range: strictly between effective start and preview/actual end + if (selectedStart && previewEnd && !isSameDay(selectedStart, previewEnd)) { + isInRange = isStrictlyBetween(date, selectedStart, previewEnd); + // Also update range start/end markers for preview direction + if (!selectedEnd) { + // Preview mode: determine visual start/end based on day order + const tStart = startOfDay(selectedStart).getTime(); + const tPreview = startOfDay(previewEnd).getTime(); + const lo = tStart < tPreview ? selectedStart : previewEnd; + const hi = tStart < tPreview ? previewEnd : selectedStart; + isRangeStart = isSameDay(date, lo); + isRangeEnd = isSameDay(date, hi); + selected = isRangeStart || isRangeEnd; + } + } + } else { + selected = (props as CalendarSingleProps).value + ? isSameDay(date, (props as CalendarSingleProps).value!) + : false; + } + + const classNames = [ + styles.cell, + !inMonth && styles.otherMonth, + isInRange && styles.cellInRange, + isRangeStart && !isRangeEnd && styles.cellRangeStart, + isRangeEnd && !isRangeStart && styles.cellRangeEnd, + ] + .filter(Boolean) + .join(' '); + + return ( + + ); + })} +
+ ))} +
+
+ ); +} 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..9ba3a97 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.module.css @@ -0,0 +1,154 @@ +/* ─── Input shell — mimics Input.module.css ControlShell ─────────────────── */ + +.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); + --_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-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); + font-weight: var(--ov-font-weight-body, 400); + line-height: 1.2; + 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); +} + +.shell:hover:not(.shellDisabled) { + background: color-mix(in srgb, var(--_ov-bg) 86%, var(--ov-color-state-hover) 14%); +} + +.shell:focus-within:not(.shellDisabled) { + border-color: var(--_ov-focus); + box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); +} + +/* ─── 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; +} + +/* ─── DateField inside shell — grows to fill available space ─────────────── */ + +.shell > [role="group"] { + flex: 1; + min-width: 0; +} + +/* ─── Text input ──────────────────────────────────────────────────────────── */ + +.input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--_ov-font-size); + line-height: 1.35; + outline: none; + padding: 0; + cursor: text; +} + +.input::placeholder { + color: var(--_ov-placeholder); +} + +.input:disabled { + cursor: not-allowed; +} + +/* ─── Calendar icon button ────────────────────────────────────────────────── */ + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + 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; +} + +.iconButton :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-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; +} + +/* ─── Footer ──────────────────────────────────────────────────────────────── */ + +.footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--ov-space-stack-sm, 8px); + padding: var(--ov-space-stack-sm, 8px) var(--ov-space-stack-md, 12px); + border-top: 1px solid var(--ov-color-border-default); + background: var(--ov-color-bg-surface-raised); +} + +.footer > :first-child { + margin-inline-end: auto; +} 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..2a4d688 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { DatePicker, type DatePickerProps } from './DatePicker'; + +const meta = { + title: 'Components/DatePicker', + component: DatePicker, + tags: ['autodocs'], + args: { + disabled: false, + }, + argTypes: { + disabled: { control: 'boolean' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Story components (keep hooks legal) ────────────────────────────────── + +function DefaultStory(args: DatePickerProps) { + const [value, setValue] = useState(null); + return ; +} + +function ControlledStory(args: DatePickerProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +function WithMinMaxStory(args: DatePickerProps) { + 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 ; +} + +function LocaleGBStory(args: DatePickerProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +function DisabledStory(args: DatePickerProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +// ─── Story exports ──────────────────────────────────────────────────────── + +export const Default: Story = { + render: (args) => , +}; + +export const Controlled: Story = { + render: (args) => , +}; + +export const WithMinMax: Story = { + render: (args) => , +}; + +export const LocaleGB: Story = { + render: (args) => , +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + render: (args) => , +}; 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..4ded9cf --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.test.tsx @@ -0,0 +1,116 @@ +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 the DateField trigger with per-section placeholders', () => { + render( {}} />); + // DateField root has role="group" + expect(screen.getByRole('group', { name: 'Date' })).toBeInTheDocument(); + // Popover calendar should not be visible + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + it('opens the popover when the icon button is clicked', async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + expect(screen.getByRole('grid')).toBeInTheDocument(); + }); + + it('calls onChange and closes the popover when a day is selected from the calendar', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + 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', async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + it('disables the DateField and icon button when disabled=true', () => { + render( {}} disabled />); + const field = screen.getByRole('group', { name: 'Date' }); + expect(field).toHaveAttribute('data-disabled', ''); + expect(screen.getByRole('button', { name: 'Open calendar' })).toBeDisabled(); + }); + + it('applies readOnly to the DateField', () => { + render( {}} readOnly />); + const field = screen.getByRole('group', { name: 'Date' }); + expect(field).toHaveAttribute('data-readonly', ''); + }); + + it('calendar selection updates the DateField value', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + expect(onChange).toHaveBeenCalledWith(expect.any(Date)); + }); + + it('shows error state when DateField fires a date outside the min/max range', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + // Start with an in-range value so the shell renders without error, then + // paste a date outside the range into the DateField. The DatePicker's + // onChange handler should mark the shell with shellError. + render( + , + ); + const shell = screen.getByTestId('date-picker-shell'); + expect(shell.className).not.toMatch(/shellError/); + + // Focus the DateField (en-US → month/day/year), then paste an out-of- + // range date. The DateField parses the paste + emits a complete Date, + // which the DatePicker wrapper then rejects via setRangeError(true). + const dateField = screen.getByRole('group', { name: 'Date' }); + const firstSection = dateField.querySelector( + '[role="spinbutton"]', + ) as HTMLElement; + firstSection.focus(); + await user.paste('01/05/2026'); // Jan 5 2026 — before min + + expect(shell.className).toMatch(/shellError/); + }); + + it('does not open the popover when disabled and icon button is clicked', async () => { + const user = userEvent.setup(); + render( {}} disabled />); + // button is disabled so click has no effect + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + it('Clear button resets the date', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('Done button closes the popover', async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); +}); 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..7e43a2b --- /dev/null +++ b/packages/base-ui/src/components/date-picker/DatePicker.tsx @@ -0,0 +1,173 @@ +import { useCallback, useId, useRef, useState } from 'react'; +import { LuCalendar } from 'react-icons/lu'; +import { Popover } from '../popover/Popover'; +import { DateField } from '../date-field/DateField'; +import { Button } from '../button/Button'; +import styles from './DatePicker.module.css'; +import { Calendar } from './Calendar'; +import type { DateFormat } from './formatters'; +import { isDateInRange, 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; + /** @deprecated Ignored for the trigger display; DateField uses Intl locale formatting. Kept for API stability. */ + format?: DateFormat; + locale?: string; + /** @deprecated DateField renders per-section placeholders. Kept for API stability. */ + 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 is kept in props for API stability but not used for trigger display + locale, + // placeholder is kept in props for API stability but not used + disabled, + readOnly, + weekStartsOn, + className, + } = props; + + const [current, setCurrent] = useControlled(value, defaultValue, onChange); + const [open, setOpen] = useState(false); + const [rangeError, setRangeError] = useState(false); + + const shellRef = useRef(null); + const popoverId = useId(); + + const handleDateFieldChange = (next: Date | null) => { + if (next === null) { + setCurrent(null); + setRangeError(false); + return; + } + if (!isDateInRange(next, min, max) || isDateDisabled?.(next)) { + setRangeError(true); + return; + } + setRangeError(false); + setCurrent(next); + }; + + const handleCalendarSelect = (next: Date) => { + setCurrent(next); + setRangeError(false); + setOpen(false); + }; + + const handleClear = () => { + setCurrent(null); + setRangeError(false); + }; + + const handleDone = () => { + setOpen(false); + }; + + const handleIconButtonClick = () => { + if (disabled || readOnly) return; + setOpen((prev) => !prev); + }; + + return ( + + {/* Shell acts as the visual input container and popover anchor */} +
+ + +
+ + + + +
+ + +
+
+
+
+
+ ); +} + +DatePicker.Calendar = Calendar; 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..d856784 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/dateUtils.test.ts @@ -0,0 +1,102 @@ +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('addYears clamps Feb 29 to Feb 28 on non-leap target years', () => { + // Feb 29 2024 (leap) + 1 year → Feb 28 2025 (not March 1) + const out = addYears(new Date(2024, 1, 29), 1); + expect(out.getFullYear()).toBe(2025); + expect(out.getMonth()).toBe(1); + expect(out.getDate()).toBe(28); + }); + + it('addYears preserves Feb 29 when the target year is also a leap year', () => { + // Feb 29 2024 + 4 years → Feb 29 2028 + const out = addYears(new Date(2024, 1, 29), 4); + expect(out.getFullYear()).toBe(2028); + expect(out.getMonth()).toBe(1); + expect(out.getDate()).toBe(29); + }); + + 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]).toEqual(new Date(2026, 4, 9)); + }); + + 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..0041e4d --- /dev/null +++ b/packages/base-ui/src/components/date-picker/dateUtils.ts @@ -0,0 +1,84 @@ +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 targetYear = d.getFullYear() + n; + // Clamp the day to the last day of the target month in the target year — + // handles e.g. Feb 29 + 1 year → Feb 28 (not March 1) on non-leap years. + const daysInTarget = new Date(targetYear, d.getMonth() + 1, 0).getDate(); + const out = new Date(targetYear, d.getMonth(), Math.min(d.getDate(), daysInTarget)); + out.setHours(d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()); + 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 new Date(min); + if (max && isAfter(d, max)) return new Date(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; +} 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..e64dd9f --- /dev/null +++ b/packages/base-ui/src/components/date-picker/formatters.test.ts @@ -0,0 +1,48 @@ +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 (Sunday-first)', () => { + // Use 'short' form so the first-three assertion is unambiguous + // ('narrow' renders both Sat and Sun as "S" in en-US). + const labels = getWeekdayLabels('en-US', 0, 'short'); + expect(labels).toHaveLength(7); + expect(labels.slice(0, 3)).toEqual(['Sun', 'Mon', 'Tue']); + }); + + 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..da73850 --- /dev/null +++ b/packages/base-ui/src/components/date-picker/formatters.ts @@ -0,0 +1,61 @@ +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); +} + +// Locales where the week starts on Saturday (6). +const SATURDAY_START_LOCALES = new Set(['ar-sa']); +// Locales where the week starts on Sunday (0), beyond the en-US family. +const SUNDAY_START_PREFIXES = ['he', 'pt-br', 'zh-cn', 'ja', 'ko', 'th']; + +/** + * Return the first day of week for a given locale using Intl.Locale's + * weekInfo (modern browsers) or fall back to a well-known locale map, + * with a final Monday default. + */ +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; + if (SATURDAY_START_LOCALES.has(tag)) return 6; + if (SUNDAY_START_PREFIXES.some((p) => tag === p || tag.startsWith(`${p}-`))) 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; +} 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/date-range-picker/DateRangePicker.module.css b/packages/base-ui/src/components/date-range-picker/DateRangePicker.module.css new file mode 100644 index 0000000..b9942eb --- /dev/null +++ b/packages/base-ui/src/components/date-range-picker/DateRangePicker.module.css @@ -0,0 +1,140 @@ +/* ─── Input shell — mimics DatePicker.module.css ─────────────────────────── */ + +.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); + --_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-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); + font-weight: var(--ov-font-weight-body, 400); + line-height: 1.2; + 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); +} + +.shell:hover:not(.shellDisabled) { + background: color-mix(in srgb, var(--_ov-bg) 86%, var(--ov-color-state-hover) 14%); +} + +.shell:focus-within:not(.shellDisabled) { + border-color: var(--_ov-focus); + box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); +} + +/* ─── 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; +} + +/* ─── DateField inside shell — each field grows to fill available space ──── */ + +.shell > [role="group"] { + flex: 1; + min-width: 0; +} + +/* ─── Range separator ─────────────────────────────────────────────────────── */ + +.separator { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--ov-color-fg-muted); + user-select: none; +} + +/* ─── Calendar icon button ────────────────────────────────────────────────── */ + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + 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; +} + +.iconButton :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-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; +} + +/* ─── Footer ──────────────────────────────────────────────────────────────── */ + +.footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--ov-space-stack-sm, 8px); + padding: var(--ov-space-stack-sm, 8px) var(--ov-space-stack-md, 12px); + border-top: 1px solid var(--ov-color-border-default); + background: var(--ov-color-bg-surface-raised); +} + +.footer > :first-child { + margin-inline-end: auto; +} diff --git a/packages/base-ui/src/components/date-range-picker/DateRangePicker.stories.tsx b/packages/base-ui/src/components/date-range-picker/DateRangePicker.stories.tsx new file mode 100644 index 0000000..8c6b4fb --- /dev/null +++ b/packages/base-ui/src/components/date-range-picker/DateRangePicker.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { DateRangePicker, type DateRangePickerProps } from './DateRangePicker'; +import type { DateRange } from './DateRangePicker'; + +const meta = { + title: 'Components/DateRangePicker', + component: DateRangePicker, + tags: ['autodocs'], + args: { + disabled: false, + }, + argTypes: { + disabled: { control: 'boolean' }, + rangeSeparator: { control: 'text' }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Story components (keep hooks legal) ────────────────────────────────── + +function DefaultStory(args: DateRangePickerProps) { + const [value, setValue] = useState({ start: null, end: null }); + return ; +} + +function ControlledStory(args: DateRangePickerProps) { + const today = new Date(); + const end = new Date(today); + end.setDate(today.getDate() + 7); + const [value, setValue] = useState({ start: today, end }); + return ; +} + +function WithMinMaxStory(args: DateRangePickerProps) { + const [value, setValue] = useState({ start: null, end: null }); + const today = new Date(); + const min = new Date(today); + min.setDate(today.getDate() - 30); + const max = new Date(today); + max.setDate(today.getDate() + 30); + return ( + + ); +} + +function DisabledStory(args: DateRangePickerProps) { + const today = new Date(); + const end = new Date(today); + end.setDate(today.getDate() + 7); + const [value, setValue] = useState({ start: today, end }); + return ; +} + +function CustomSeparatorStory(args: DateRangePickerProps) { + const today = new Date(); + const end = new Date(today); + end.setDate(today.getDate() + 7); + const [value, setValue] = useState({ start: today, end }); + return ( + + ); +} + +function LocaleGBStory(args: DateRangePickerProps) { + const today = new Date(); + const end = new Date(today); + end.setDate(today.getDate() + 7); + const [value, setValue] = useState({ start: today, end }); + return ; +} + +// ─── Story exports ──────────────────────────────────────────────────────── + +export const Default: Story = { + render: (args) => , +}; + +export const Controlled: Story = { + render: (args) => , +}; + +export const WithMinMax: Story = { + render: (args) => , +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + render: (args) => , +}; + +export const CustomSeparator: Story = { + render: (args) => , +}; + +export const LocaleGB: Story = { + render: (args) => , +}; diff --git a/packages/base-ui/src/components/date-range-picker/DateRangePicker.test.tsx b/packages/base-ui/src/components/date-range-picker/DateRangePicker.test.tsx new file mode 100644 index 0000000..c854762 --- /dev/null +++ b/packages/base-ui/src/components/date-range-picker/DateRangePicker.test.tsx @@ -0,0 +1,214 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateRangePicker } from './DateRangePicker'; +import type { DateRange } from './DateRangePicker'; + +/** Locate a section span by type within a labelled DateField group. */ +function getSectionInGroup(group: HTMLElement, type: string): HTMLElement { + const el = group.querySelector(`[data-section-type="${type}"]:not([data-literal])`); + if (!el) throw new Error(`No section with type="${type}" in group`); + return el as HTMLElement; +} + +describe('DateRangePicker', () => { + // 1. DateFields show per-section placeholders when start/end are null + it('renders placeholder sections when value is empty', () => { + const { container } = render( + {}} />, + ); + const [startGroup, endGroup] = container.querySelectorAll('[role="group"]'); + if (!startGroup || !endGroup) throw new Error('Expected two DateField groups'); + // Both fields should show placeholder text in their sections + expect( + getSectionInGroup(startGroup as HTMLElement, 'month').getAttribute('data-placeholder'), + ).toBe(''); + expect( + getSectionInGroup(endGroup as HTMLElement, 'month').getAttribute('data-placeholder'), + ).toBe(''); + }); + + // 2. Renders formatted range when both dates are set + it('renders formatted range when both dates are set', () => { + const { container } = render( + {}} + locale="en-US" + />, + ); + const [startGroup, endGroup] = container.querySelectorAll('[role="group"]'); + if (!startGroup || !endGroup) throw new Error('Expected two DateField groups'); + // Start field shows April 12 + expect(getSectionInGroup(startGroup as HTMLElement, 'month').textContent).toBe('04'); + expect(getSectionInGroup(startGroup as HTMLElement, 'day').textContent).toBe('12'); + // End field shows April 19 + expect(getSectionInGroup(endGroup as HTMLElement, 'month').textContent).toBe('04'); + expect(getSectionInGroup(endGroup as HTMLElement, 'day').textContent).toBe('19'); + }); + + // 3. Clicking icon button opens the calendar + it('clicking icon button opens the calendar', async () => { + const user = userEvent.setup(); + render( + {}} />, + ); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + expect(screen.getByRole('grid')).toBeInTheDocument(); + }); + + // 4. Clicking a date sets the start and keeps the popover open + it('clicking a date sets the start and keeps the popover open', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('gridcell', { name: /^10/ })); + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]![0] as DateRange; + expect(lastCall.start).toBeInstanceOf(Date); + expect(lastCall.end).toBeNull(); + // Calendar should still be visible (waiting for end) + expect(screen.getByRole('grid')).toBeInTheDocument(); + }); + + // 5. Clicking a second date after start sets the end and closes the popover + it('clicking a second date after start sets the end and closes the popover', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + + // Click "10" → sets start + await user.click(screen.getByRole('gridcell', { name: /^10/ })); + expect(onChange).toHaveBeenCalled(); + const firstCall = onChange.mock.calls[0]![0] as DateRange; + expect(firstCall.start).toBeInstanceOf(Date); + expect(firstCall.end).toBeNull(); + expect(screen.getByRole('grid')).toBeInTheDocument(); + + // Click "20" → sets end and closes + await user.click(screen.getByRole('gridcell', { name: /^20/ })); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]![0] as DateRange; + expect(lastCall.start).toBeInstanceOf(Date); + expect(lastCall.end).toBeInstanceOf(Date); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + // 6. Typing into the start field commits the start date + it('typing into start field commits the start date', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const [startGroup] = container.querySelectorAll('[role="group"]'); + if (!startGroup) throw new Error('No start DateField group found'); + const monthSection = getSectionInGroup(startGroup as HTMLElement, 'month'); + await user.click(monthSection); + // type 04 12 2026 + await user.keyboard('04122026'); + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]![0] as DateRange; + expect(lastCall.start).toBeInstanceOf(Date); + expect(lastCall.start!.getMonth()).toBe(3); // April + expect(lastCall.start!.getDate()).toBe(12); + expect(lastCall.start!.getFullYear()).toBe(2026); + }); + + // 7. When start date is entered after end, end is reset + it('typing a start date after the current end resets end to null', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const [startGroup] = container.querySelectorAll('[role="group"]'); + if (!startGroup) throw new Error('No start DateField group found'); + // Set start to April 20 (after current end April 10) + const monthSection = getSectionInGroup(startGroup as HTMLElement, 'month'); + await user.click(monthSection); + await user.keyboard('04202026'); + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]![0] as DateRange; + // start should be the new date + expect(lastCall.start).toBeInstanceOf(Date); + expect(lastCall.start!.getDate()).toBe(20); + // end should have been reset to null since it was before new start + expect(lastCall.end).toBeNull(); + }); + + // 8. Escape closes the popover + it('Escape closes the popover', async () => { + const user = userEvent.setup(); + render( + {}} />, + ); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + expect(screen.getByRole('grid')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); + + // 9. Disabled state applies to both fields and icon button + it('disabled state applies to both fields and icon button', () => { + const { container } = render( + {}} disabled />, + ); + const [startGroup, endGroup] = container.querySelectorAll('[role="group"]'); + if (!startGroup || !endGroup) throw new Error('Expected two DateField groups'); + expect((startGroup as HTMLElement).getAttribute('data-disabled')).toBe(''); + expect((endGroup as HTMLElement).getAttribute('data-disabled')).toBe(''); + expect(screen.getByRole('button', { name: 'Open calendar' })).toBeDisabled(); + }); + + // 10. Clear button resets both start and end to null + it('Clear button resets the range', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(onChange).toHaveBeenCalledWith({ start: null, end: null }); + }); + + // 11. Done button closes the popover + it('Done button closes the popover', async () => { + const user = userEvent.setup(); + render( + {}} + />, + ); + await user.click(screen.getByRole('button', { name: 'Open calendar' })); + await user.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/base-ui/src/components/date-range-picker/DateRangePicker.tsx b/packages/base-ui/src/components/date-range-picker/DateRangePicker.tsx new file mode 100644 index 0000000..d54f044 --- /dev/null +++ b/packages/base-ui/src/components/date-range-picker/DateRangePicker.tsx @@ -0,0 +1,227 @@ +import { useCallback, useId, useRef, useState } from 'react'; +import { LuCalendarRange } from 'react-icons/lu'; +import { Popover } from '../popover/Popover'; +import { DateField } from '../date-field/DateField'; +import { Button } from '../button/Button'; +import styles from './DateRangePicker.module.css'; +import { Calendar } from '../date-picker/Calendar'; +import type { DateFormat } from '../date-picker/formatters'; +import { isAfter, isBefore, isDateInRange } from '../date-picker/dateUtils'; +import type { WeekStart } from '../date-picker/dateUtils'; +import type { StyledComponentProps } from '../../system/types'; + +export interface DateRange { + start: Date | null; + end: Date | null; +} + +export interface DateRangePickerProps extends StyledComponentProps { + value?: DateRange; + defaultValue?: DateRange; + onChange?: (value: DateRange) => void; + min?: Date; + max?: Date; + isDateDisabled?: (date: Date) => boolean; + /** @deprecated Ignored for the trigger display; DateField uses Intl locale formatting. Kept for API stability. */ + format?: DateFormat; + locale?: string; + /** @deprecated DateField renders per-section placeholders. Kept for API stability. */ + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + weekStartsOn?: WeekStart; + className?: string; + /** Separator rendered between the start and end DateFields. Default: " – " (en dash with spaces). */ + rangeSeparator?: string; +} + +const DEFAULT_SEPARATOR = ' \u2013 '; + +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 DateRangePicker(props: DateRangePickerProps) { + const { + value, + defaultValue = { start: null, end: null }, + onChange, + min, + max, + isDateDisabled, + // format kept for API stability — not used for trigger display + locale, + // placeholder kept for API stability — not used + disabled, + readOnly, + weekStartsOn, + className, + rangeSeparator = DEFAULT_SEPARATOR, + } = props; + + const [current, setCurrent] = useControlled(value, defaultValue, onChange); + const [open, setOpen] = useState(false); + const [rangeError, setRangeError] = useState(false); + + const shellRef = useRef(null); + const popoverId = useId(); + + const handleStartChange = (next: Date | null) => { + if (next === null) { + setCurrent({ start: null, end: current.end }); + setRangeError(false); + return; + } + // Validate against min/max and isDateDisabled + if (!isDateInRange(next, min, max) || isDateDisabled?.(next)) { + setRangeError(true); + return; + } + setRangeError(false); + if (current.end && isAfter(next, current.end)) { + // start is after end — reset end so the user can pick a new one + setCurrent({ start: next, end: null }); + } else { + setCurrent({ start: next, end: current.end }); + } + }; + + const handleEndChange = (next: Date | null) => { + if (next === null) { + setCurrent({ start: current.start, end: null }); + setRangeError(false); + return; + } + // Validate against min/max and isDateDisabled + if (!isDateInRange(next, min, max) || isDateDisabled?.(next)) { + setRangeError(true); + return; + } + setRangeError(false); + if (current.start && isBefore(next, current.start)) { + // end is before start — swap them for a better UX + setCurrent({ start: next, end: current.start }); + } else { + setCurrent({ start: current.start, end: next }); + } + }; + + const handleRangeChange = (range: { start: Date | null; end: Date | null }) => { + setCurrent({ start: range.start, end: range.end }); + setRangeError(false); + // Close only when a complete range is selected + if (range.start && range.end) { + setOpen(false); + } + }; + + const handleClear = () => { + setCurrent({ start: null, end: null }); + setRangeError(false); + }; + + const handleDone = () => { + setOpen(false); + }; + + return ( + +
+ end. + max={max} + disabled={disabled} + readOnly={readOnly} + aria-label="Start date" + bare + /> + + + +
+ + + + +
+ + +
+
+
+
+
+ ); +} diff --git a/packages/base-ui/src/components/date-range-picker/index.ts b/packages/base-ui/src/components/date-range-picker/index.ts new file mode 100644 index 0000000..f4fd25f --- /dev/null +++ b/packages/base-ui/src/components/date-range-picker/index.ts @@ -0,0 +1,2 @@ +export { DateRangePicker } from './DateRangePicker'; +export type { DateRangePickerProps, DateRange } from './DateRangePicker'; 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..37fe85f --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.module.css @@ -0,0 +1,144 @@ +/* ─── Input shell — mimics DatePicker.module.css shell ───────────────────── */ + +.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); + --_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-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); + font-weight: var(--ov-font-weight-body, 400); + line-height: 1.2; + 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); +} + +.shell:hover:not(.shellDisabled) { + background: color-mix(in srgb, var(--_ov-bg) 86%, var(--ov-color-state-hover) 14%); +} + +.shell:focus-within:not(.shellDisabled) { + border-color: var(--_ov-focus); + box-shadow: 0 0 0 1px var(--ov-color-state-focus-ring); +} + +/* ─── 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; +} + +/* ─── DateField inside shell — grows to fill available space ─────────────── */ + +.shell > [role="group"] { + flex: 1; + min-width: 0; +} + +/* ─── Calendar-clock icon button ──────────────────────────────────────────── */ + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + 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:not(:disabled):not([aria-disabled="true"]):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; +} + +.iconButton :where(svg) { + inline-size: var(--_ov-icon-size); + block-size: var(--_ov-icon-size); +} + +/* ─── Popup body wrapper — stacks combo + footer vertically ──────────────── */ + +.body { + display: flex; + flex-direction: column; +} + +/* ─── Popup combo layout ──────────────────────────────────────────────────── */ + +.combo { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0; +} + +.timeColumns { + display: flex; + flex-direction: column; + border-left: 1px solid var(--ov-color-border-default); + /* Critical: clip inner columns' content so they can't push our height past + * the Calendar's. Combined with `.combo { align-items: stretch }`, this + * sizes us to match the Calendar and lets inner columns scroll. */ + overflow: hidden; + min-height: 0; +} + +/* ─── Footer ──────────────────────────────────────────────────────────────── */ + +.footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--ov-space-stack-sm, 8px); + padding: var(--ov-space-stack-sm, 8px) var(--ov-space-stack-md, 12px); + border-top: 1px solid var(--ov-color-border-default); + background: var(--ov-color-bg-surface-raised); +} + +.footer > :first-child { + margin-inline-end: auto; +} 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..5dfcac8 --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { DateTimePicker, type DateTimePickerProps } from './DateTimePicker'; + +const meta = { + title: 'Components/DateTimePicker', + component: DateTimePicker, + tags: ['autodocs'], + args: { + disabled: false, + showSeconds: false, + hourCycle: 24, + }, + argTypes: { + disabled: { control: 'boolean' }, + showSeconds: { control: 'boolean' }, + hourCycle: { control: 'inline-radio', options: [12, 24] }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Story components (keep hooks legal) ────────────────────────────────── + +function DefaultStory(args: DateTimePickerProps) { + const [value, setValue] = useState(null); + return ; +} + +function WithSecondsStory(args: DateTimePickerProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +function TwelveHourStory(args: DateTimePickerProps) { + const [value, setValue] = useState(new Date()); + return ; +} + +// ─── Story exports ──────────────────────────────────────────────────────── + +export const Default: Story = { + render: (args) => , +}; + +export const WithSeconds: Story = { + args: { showSeconds: true }, + render: (args) => , +}; + +export const TwelveHour: Story = { + args: { hourCycle: 12 }, + render: (args) => , +}; 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..8c9c63d --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, within } 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 time columns', async () => { + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button', { name: /open calendar/i })); + expect(screen.getByRole('grid')).toBeInTheDocument(); + // Time columns are rendered as listboxes in the popup + expect(screen.getByRole('listbox', { name: /hours/i })).toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: /minutes/i })).toBeInTheDocument(); + }); + + it('selecting a day preserves the current time-of-day and keeps the popover open', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: /open calendar/i })); + 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); + // Popover should remain open so the user can still pick a time. + expect(screen.getByRole('grid')).toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: /hours/i })).toBeInTheDocument(); + }); + + it('selecting an hour option in the popup updates value and preserves the current date', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: /open calendar/i })); + // TimeColumns renders a listbox for hours in the popup + const hoursColumn = screen.getByRole('listbox', { name: /hours/i }); + const option14 = within(hoursColumn).getByRole('option', { name: '14' }); + await user.click(option14); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last.getDate()).toBe(12); + expect(last.getHours()).toBe(14); + }); + + it('typing in the DateField hour section updates value with new hour', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + // The DateField trigger is always visible (no need to open popover) + const hourSections = screen.getAllByRole('spinbutton', { name: /hour/i }); + const triggerHour = hourSections[0] as HTMLElement; + await user.click(triggerHour); + await user.keyboard('15'); + const last = onChange.mock.calls.at(-1)?.[0] as Date; + expect(last?.getHours()).toBe(15); + expect(last?.getDate()).toBe(12); + }); + + it('disabled state applies to the DateField and icon button', () => { + render( + {}} disabled />, + ); + const iconButton = screen.getByRole('button', { name: /open calendar/i }); + expect(iconButton).toBeDisabled(); + const shell = screen.getByTestId('date-time-picker-shell'); + expect(shell).toHaveAttribute('data-disabled'); + }); + + it('Clear button resets the date and time', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: /open calendar/i })); + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('Done button closes the popover', async () => { + const user = userEvent.setup(); + render( + {}} />, + ); + await user.click(screen.getByRole('button', { name: /open calendar/i })); + await user.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + }); +}); 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..30caa96 --- /dev/null +++ b/packages/base-ui/src/components/date-time-picker/DateTimePicker.tsx @@ -0,0 +1,219 @@ +import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import { LuCalendarClock } from 'react-icons/lu'; +import { Popover } from '../popover/Popover'; +import { DateField } from '../date-field/DateField'; +import { Calendar } from '../date-picker/Calendar'; +import { TimeColumns } from '../time-picker/TimeColumns'; +import { Button } from '../button/Button'; +import type { DateFormat } from '../date-picker/formatters'; +import type { WeekStart } from '../date-picker/dateUtils'; +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; + /** @deprecated Ignored for the trigger display; DateField uses Intl locale formatting. Kept for API stability. */ + format?: DateFormat; + locale?: string; + /** @deprecated DateField renders per-section placeholders. Kept for API stability. */ + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + weekStartsOn?: WeekStart; + showSeconds?: boolean; + hourCycle?: 12 | 24; + minuteStep?: number; + 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]; +} + +// Timestamp-aware range check — intentionally differs from dateUtils.isDateInRange, +// which operates at day granularity. DateTimePicker needs exact timestamp comparison +// so that min/max can constrain times within the same calendar day. +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 DateTimePicker(props: DateTimePickerProps) { + const { + value, + defaultValue = null, + onChange, + min, + max, + isDateDisabled, + // format is kept in props for API stability but not used for trigger display + locale, + // placeholder is kept in props for API stability but not used + disabled, + readOnly, + weekStartsOn, + showSeconds, + hourCycle, + minuteStep, + className, + } = props; + + const [current, setCurrent] = useControlled(value, defaultValue, onChange); + const [open, setOpen] = useState(false); + const [rangeError, setRangeError] = useState(false); + // Stable fallback used when `current` is null — avoids creating a fresh + // Date every render, which would break downstream memoization. + const fallbackNow = useMemo(() => new Date(), []); + + const shellRef = useRef(null); + const popoverId = useId(); + + const handleFieldChange = (next: Date | null) => { + if (next === null) { + setCurrent(null); + setRangeError(false); + return; + } + if (!isDateInRange(next, min, max) || isDateDisabled?.(next)) { + setRangeError(true); + return; + } + setRangeError(false); + setCurrent(next); + }; + + const onDateChange = (d: Date) => { + const base = current ?? new Date(); + const next = new Date(d); + next.setHours(base.getHours(), base.getMinutes(), base.getSeconds(), 0); + setCurrent(next); + setRangeError(false); + // Keep the popover open so the user can still pick a time after selecting + // a day; closing is triggered by the Done button or explicit dismissal. + }; + + const onTimeChange = (t: Date) => { + const base = current ?? new Date(); + const next = new Date(base); + next.setHours(t.getHours(), t.getMinutes(), t.getSeconds(), 0); + setCurrent(next); + setRangeError(false); + }; + + const handleClear = () => { + setCurrent(null); + setRangeError(false); + }; + + const handleDone = () => { + setOpen(false); + }; + + return ( + + {/* Shell acts as the visual input container and popover anchor */} +
+ + +
+ + + +
+
+ +
+ +
+
+
+ + +
+
+
+
+
+
+ ); +} 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/editor-tabs/hooks/useTabDetach.test.ts b/packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.test.ts index 184fdc5..009edbb 100644 --- a/packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.test.ts +++ b/packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.test.ts @@ -113,7 +113,7 @@ describe('useTabDetach', () => { // Simulate pointer move above threshold (100 - 18 - 1 = 81) const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; expect(pointerMoveHandler).toBeDefined(); @@ -138,7 +138,7 @@ describe('useTabDetach', () => { }); const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; act(() => { @@ -163,7 +163,7 @@ describe('useTabDetach', () => { }); const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; act(() => { @@ -214,7 +214,7 @@ describe('useTabDetach', () => { result.current.handleDetachDragStart(makeDragStartEvent('tab1')); }); - const pointerMoveHandler = addSpy.mock.calls.find((call) => call[0] === 'pointermove')?.[1]; + const pointerMoveHandler = addSpy.mock.calls.find((call: unknown[]) => call[0] === 'pointermove')?.[1]; act(() => { result.current.handleDetachDragCancel(); @@ -235,7 +235,7 @@ describe('useTabDetach', () => { }); const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; act(() => { @@ -259,7 +259,7 @@ describe('useTabDetach', () => { }); const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; act(() => { @@ -283,7 +283,7 @@ describe('useTabDetach', () => { }); const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; act(() => { @@ -307,7 +307,7 @@ describe('useTabDetach', () => { }); const pointerMoveHandler = addSpy.mock.calls.find( - (call) => call[0] === 'pointermove', + (call: unknown[]) => call[0] === 'pointermove', )?.[1] as EventListener; // Move above threshold (clientX within viewport bounds so only Y triggers) diff --git a/packages/base-ui/src/components/index.ts b/packages/base-ui/src/components/index.ts index c7f7c96..5505c70 100644 --- a/packages/base-ui/src/components/index.ts +++ b/packages/base-ui/src/components/index.ts @@ -86,3 +86,8 @@ export * from './status-bar'; export * from './timeline'; export * from './sortable-table'; export * from './file-table'; +export * from './date-field'; +export * from './date-picker'; +export * from './time-picker'; +export * from './date-time-picker'; +export * from './date-range-picker'; diff --git a/packages/base-ui/src/components/sortable-table/useSortableTable.test.ts b/packages/base-ui/src/components/sortable-table/useSortableTable.test.ts index 0c4b41d..ebc3314 100644 --- a/packages/base-ui/src/components/sortable-table/useSortableTable.test.ts +++ b/packages/base-ui/src/components/sortable-table/useSortableTable.test.ts @@ -101,7 +101,7 @@ describe('useSortableTable', () => { ); // 'cherry' (6) > 'banana' (6) > 'apple' (5) — custom sort by length - expect(result.current.sortedData[2].name).toBe('apple'); + expect(result.current.sortedData[2]!.name).toBe('apple'); }); it('sorts undefined values last', () => { @@ -121,7 +121,7 @@ describe('useSortableTable', () => { }), ); - expect(result.current.sortedData[result.current.sortedData.length - 1].id).toBe('4'); + expect(result.current.sortedData[result.current.sortedData.length - 1]!.id).toBe('4'); }); it('skips sorting for non-sortable columns', () => { diff --git a/packages/base-ui/src/components/time-picker/TimeColumns.tsx b/packages/base-ui/src/components/time-picker/TimeColumns.tsx new file mode 100644 index 0000000..d76490a --- /dev/null +++ b/packages/base-ui/src/components/time-picker/TimeColumns.tsx @@ -0,0 +1,275 @@ +import { useEffect, useRef } from 'react'; +import styles from './TimePicker.module.css'; + +// ─── Shared types ───────────────────────────────────────────────────────────── + +export interface ColumnItem { + value: number | string; + label: string; +} + +// ─── TimeColumn sub-component ───────────────────────────────────────────────── + +interface TimeColumnProps { + label: string; + items: ColumnItem[]; + selected: number | string; + onSelect: (value: number | string) => void; + disabled?: boolean; +} + +export function TimeColumn({ label, items, selected, onSelect, disabled }: TimeColumnProps) { + const listRef = useRef(null); + const selectedRef = useRef(null); + const suppressScrollRef = useRef(false); + + // Scroll selected item into center when the column mounts or selected changes + // from an external source. Skip when the change came from a user click in + // this column (the clicked item is already visible; extra scroll causes + // a jittery double-jump). + useEffect(() => { + if (suppressScrollRef.current) { + suppressScrollRef.current = false; + return; + } + const el = selectedRef.current; + const list = listRef.current; + if (!el || !list) return; + + // If the selected element is already fully within the list's viewport, + // skip the scroll. This avoids re-centering on every click even when the + // click handler didn't set the suppress flag (e.g. external mutations). + const listRect = list.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const fullyVisible = elRect.top >= listRect.top && elRect.bottom <= listRect.bottom; + if (fullyVisible) 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); + // Keyboard select: the focused item is already visible; don't re-center. + suppressScrollRef.current = true; + onSelect(Number.isNaN(parsed) ? raw : parsed); + } + } + }; + + return ( +
    + {items.map((item) => { + const isSelected = item.value === selected; + return ( +
  • { + if (disabled) return; + // The clicked item is already visible; don't re-center on the + // resulting `selected` change. + suppressScrollRef.current = true; + onSelect(item.value); + }} + // Enter/Space are handled at the
      level (see `handleKeyDown` + // above), which also sets `suppressScrollRef` to avoid a jittery + // re-center on keyboard selection. Keeping a duplicate handler + // here would either miss the suppress flag or diverge in behavior. + > + {item.label} + + ); + })} +
    + ); +} + +// ─── TimeColumns public API ─────────────────────────────────────────────────── + +export interface TimeColumnsProps { + value: Date; + onChange: (next: Date) => void; + hourCycle?: 12 | 24; + showSeconds?: boolean; + minuteStep?: number; + disabled?: boolean; + readOnly?: boolean; + className?: string; + /** Auto-scroll columns so selected item is visible on mount. Default true. */ + autoScroll?: boolean; +} + +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)); +} + +export function TimeColumns({ + value, + onChange, + hourCycle = 24, + showSeconds = false, + minuteStep = 1, + disabled = false, + readOnly = false, + className, +}: TimeColumnsProps) { + const h24 = value.getHours(); + const isPM = h24 >= 12; + + // ─── 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' }, + ]; + + const displayedHour = hourCycle === 12 ? ((h24 + 11) % 12) + 1 : h24; + const selectedHour = hourCycle === 12 ? displayedHour : h24; + const selectedMinute = clampToStep(value.getMinutes(), minuteStep, 59); + const selectedSecond = value.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(value); + next.setHours(hours, value.getMinutes(), value.getSeconds(), 0); + onChange(next); + }; + + const handleMinuteSelect = (v: number | string) => { + if (readOnly) return; + const next = new Date(value); + next.setHours(value.getHours(), Number(v), value.getSeconds(), 0); + onChange(next); + }; + + const handleSecondSelect = (v: number | string) => { + if (readOnly) return; + const next = new Date(value); + next.setHours(value.getHours(), value.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(value); + next.setHours(wantPM ? h24 + 12 : h24 - 12, value.getMinutes(), value.getSeconds(), 0); + onChange(next); + }; + + // readOnly prevents commits (handlers return early) but should not prevent + // keyboard focus — so we only disable tabIndex when genuinely disabled. + const isInteractionDisabled = disabled; + + return ( +
    + +