diff --git a/package-lock.json b/package-lock.json index 1dde3b77a..1c8450500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2803,9 +2803,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -8401,9 +8401,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, diff --git a/src/community/components/form/pickers/calendar/calendar.stories.tsx b/src/community/components/form/pickers/calendar/calendar.stories.tsx index 81eca03fc..5999e59cc 100644 --- a/src/community/components/form/pickers/calendar/calendar.stories.tsx +++ b/src/community/components/form/pickers/calendar/calendar.stories.tsx @@ -6,6 +6,11 @@ import Calendar, { CalendarStatus } from './calendar'; const meta: Meta = { component: Calendar, title: 'Community/Form/Pickers/Calendar', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/community/components/form/pickers/calendar/calendar.tsx b/src/community/components/form/pickers/calendar/calendar.tsx index 50608b55a..6224151be 100644 --- a/src/community/components/form/pickers/calendar/calendar.tsx +++ b/src/community/components/form/pickers/calendar/calendar.tsx @@ -8,6 +8,9 @@ import styles from './calendar.module.scss'; export type CalendarValue = Dayjs | null; export type CalendarStatus = 'error' | 'success' | 'inactive'; +/** + * @deprecated Use `Calendar` from `@tedi-design-system/react/tedi` instead. + */ export interface CalendarProps { /** * Currently selected value. Accepts a dayjs date object. @@ -94,6 +97,9 @@ export interface CalendarProps { shouldShowStatusOnDate?: (day: CalendarValue) => CalendarStatus | undefined; } +/** + * @deprecated Use `Calendar` from `@tedi-design-system/react/tedi` instead. + */ export const Calendar = (props: CalendarProps): JSX.Element => { const { value, diff --git a/src/community/components/form/pickers/datepicker/datepicker.stories.tsx b/src/community/components/form/pickers/datepicker/datepicker.stories.tsx index b7ad4c973..a8d85a461 100644 --- a/src/community/components/form/pickers/datepicker/datepicker.stories.tsx +++ b/src/community/components/form/pickers/datepicker/datepicker.stories.tsx @@ -9,6 +9,11 @@ import DatePicker, { DatePickerProps } from './datepicker'; const meta: Meta = { component: DatePicker, title: 'Community/Form/Pickers/DatePicker', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/community/components/form/pickers/datepicker/datepicker.tsx b/src/community/components/form/pickers/datepicker/datepicker.tsx index 47dfd1e01..80b35445f 100644 --- a/src/community/components/form/pickers/datepicker/datepicker.tsx +++ b/src/community/components/form/pickers/datepicker/datepicker.tsx @@ -8,6 +8,9 @@ import MuiInputTransition from '../mui-input-transition/mui-input-transition'; export type DatepickerValue = Dayjs | null; +/** + * @deprecated Use `DateField` from `@tedi-design-system/react/tedi` instead. + */ export interface DatePickerProps extends Omit { /** * Datepicker initial value. Accepts a dayjs date object. @@ -92,6 +95,9 @@ export interface DatePickerProps extends Omit void; } +/** + * @deprecated Use `DateField` from `@tedi-design-system/react/tedi` instead. + */ export const DatePicker = (props: DatePickerProps): JSX.Element => { const { value, diff --git a/src/community/components/form/pickers/datetimepicker/datetimepicker.stories.tsx b/src/community/components/form/pickers/datetimepicker/datetimepicker.stories.tsx index cb7d85d78..0c950058e 100644 --- a/src/community/components/form/pickers/datetimepicker/datetimepicker.stories.tsx +++ b/src/community/components/form/pickers/datetimepicker/datetimepicker.stories.tsx @@ -9,6 +9,11 @@ import DateTimePicker, { DateTimePickerProps } from './datetimepicker'; const meta: Meta = { component: DateTimePicker, title: 'Community/Form/Pickers/DateTimePicker', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/community/components/form/pickers/datetimepicker/datetimepicker.tsx b/src/community/components/form/pickers/datetimepicker/datetimepicker.tsx index edbe99e69..52308ad52 100644 --- a/src/community/components/form/pickers/datetimepicker/datetimepicker.tsx +++ b/src/community/components/form/pickers/datetimepicker/datetimepicker.tsx @@ -8,6 +8,9 @@ import MuiInputTransition from '../mui-input-transition/mui-input-transition'; export type DateTimepickerValue = Dayjs | null; +/** + * @deprecated Use `DateTimeField` from `@tedi-design-system/react/tedi` instead. + */ export interface DateTimePickerProps extends Omit { /** * DateTimepicker initial value. Accepts a dayjs date object. @@ -131,6 +134,9 @@ export interface DateTimePickerProps extends Omit void; } +/** + * @deprecated Use `DateTimeField` from `@tedi-design-system/react/tedi` instead. + */ export const DateTimePicker = (props: DateTimePickerProps): JSX.Element => { const { value, diff --git a/src/community/components/form/pickers/timepicker/timepicker.stories.tsx b/src/community/components/form/pickers/timepicker/timepicker.stories.tsx index 65872bd71..afe8cbf25 100644 --- a/src/community/components/form/pickers/timepicker/timepicker.stories.tsx +++ b/src/community/components/form/pickers/timepicker/timepicker.stories.tsx @@ -9,6 +9,11 @@ import TimePicker, { TimePickerProps } from './timepicker'; const meta: Meta = { component: TimePicker, title: 'Community/Form/Pickers/TimePicker', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/community/components/form/pickers/timepicker/timepicker.tsx b/src/community/components/form/pickers/timepicker/timepicker.tsx index 33cd7afce..83716b721 100644 --- a/src/community/components/form/pickers/timepicker/timepicker.tsx +++ b/src/community/components/form/pickers/timepicker/timepicker.tsx @@ -8,6 +8,9 @@ import MuiInputTransition from '../mui-input-transition/mui-input-transition'; export type TimePickerValue = Dayjs | null; +/** + * @deprecated Use `TimeField` from `@tedi-design-system/react/tedi` instead. + */ export interface TimePickerProps extends Omit { /** * TimePicker initial value. Accepts a dayjs date object. @@ -75,6 +78,9 @@ export interface TimePickerProps extends Omit void; } +/** + * @deprecated Use `TimeField` from `@tedi-design-system/react/tedi` instead. + */ export const TimePicker = (props: TimePickerProps): JSX.Element => { const { defaultValue, diff --git a/src/tedi/components/content/calendar/calendar-grid.tsx b/src/tedi/components/content/calendar/calendar-grid.tsx index 68d944aef..62e40feec 100644 --- a/src/tedi/components/content/calendar/calendar-grid.tsx +++ b/src/tedi/components/content/calendar/calendar-grid.tsx @@ -70,6 +70,7 @@ export const CalendarGrid = ({ [styles['tedi-calendar__grid-button--selected']]: item.isSelected, })} aria-pressed={item.isSelected} + data-testid="tedi-calendar-grid-cell" noStyle > {item.label} diff --git a/src/tedi/components/content/calendar/calendar.module.scss b/src/tedi/components/content/calendar/calendar.module.scss index 01be53056..719936ee4 100644 --- a/src/tedi/components/content/calendar/calendar.module.scss +++ b/src/tedi/components/content/calendar/calendar.module.scss @@ -67,7 +67,8 @@ all: unset; display: block; width: 100%; - height: 100%; + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); overflow: hidden; font-size: var(--body-regular-size); font-weight: var(--tedi-weight-02); diff --git a/src/tedi/components/content/calendar/components/calendar-header/calendar-header.tsx b/src/tedi/components/content/calendar/components/calendar-header/calendar-header.tsx index 92e3710d6..b1815bf1d 100644 --- a/src/tedi/components/content/calendar/components/calendar-header/calendar-header.tsx +++ b/src/tedi/components/content/calendar/components/calendar-header/calendar-header.tsx @@ -119,11 +119,21 @@ export function CalendarHeader({ {isGridSelect ? ( <> - - diff --git a/src/tedi/components/form/_field-icon-button.scss b/src/tedi/components/form/_field-icon-button.scss new file mode 100644 index 000000000..f61f888e2 --- /dev/null +++ b/src/tedi/components/form/_field-icon-button.scss @@ -0,0 +1,93 @@ +// Shared icon-button states for picker-style form fields +// (`DateField`, `TimeField`, `DateTimeField`). +// +// All three render a TextField with a trailing icon button that opens a +// popover (calendar / time wheel / both). The icon button uses identical +// hover, open (`aria-expanded='true'`) and disabled treatments — this +// partial centralises the rules so cross-cutting tweaks (token swaps, +// accessibility fixes, design-review touch-ups) only need to land once. +// +// **The textfield's own `:active` pseudo (mousedown press) is handled by +// `textfield.module.scss` and inherits through to all three fields — it +// does not belong here.** +// +// Usage (inside each field's `*__textfield` block): +// +// ```scss +// @use '../field-icon-button' as field-icon-button; +// +// .tedi-foo-field__textfield { +// @include field-icon-button.states; +// +// &--disabled { +// @include field-icon-button.disabled; +// } +// +// &.tedi-foo-field__icon--disabled { +// @include field-icon-button.icon-only-disabled; +// } +// } +// ``` +// +// The selector `button:not([data-name='closing-button']):last-child` +// targets the picker-trigger button while explicitly skipping the clear +// ("closing") button — that one has its own hover treatment from +// `` and shouldn't pick up the picker icon's hover bg. + +@mixin states { + button:not([data-name='closing-button']):last-child { + display: flex; + align-items: center; + justify-content: center; + width: var(--button-xs-icon-size); + min-height: var(--form-field-button-height-sm); + max-height: var(--form-field-button-height-sm); + border-radius: var(--button-radius-sm); + + > span { + color: var(--button-main-neutral-text-default); + } + + &:hover { + background-color: var(--form-datepicker-date-hover); + } + } + + // Open-popover state: the textfield root carries `aria-expanded='true'` + // while the picker is mounted. Icon button takes the active background + // tint and active text colour to mirror the open-dropdown affordance. + &[aria-expanded='true'] button:not([data-name='closing-button']):last-child { + background-color: var(--form-datepicker-date-hover); + + > span { + color: var(--button-main-neutral-text-active); + } + } +} + +// Fully-disabled field — desaturates the icon and suppresses the hover bg. +// Applied alongside the field's own `*--disabled` modifier rule. +@mixin disabled { + button:not([data-name='closing-button']):last-child { + > span { + color: var(--button-main-disabled-general-text); + } + + &:hover { + background: none; + } + } +} + +// Icon-only disabled — the input itself stays editable but the picker is +// unavailable (e.g. `readOnly` or `showPicker={false}`). Suppresses the +// hover/focus background so the icon doesn't pretend to be a trigger. +@mixin icon-only-disabled { + button:not([data-name='closing-button']):last-child { + &:hover, + &:focus { + cursor: auto; + background: none; + } + } +} diff --git a/src/tedi/components/form/date-field/date-field-helpers.spec.ts b/src/tedi/components/form/date-field/date-field-helpers.spec.ts new file mode 100644 index 000000000..24a16110c --- /dev/null +++ b/src/tedi/components/form/date-field/date-field-helpers.spec.ts @@ -0,0 +1,232 @@ +import { + buildDateRegexSource, + buildDisabledMatchers, + CALENDAR_POPOVER_OFFSET, + CALENDAR_POPOVER_PADDING, + getInitialMonth, + getLocaleDateParts, + SelectedValueLike, +} from './date-field-helpers'; + +describe('date-field-helpers', () => { + describe('constants', () => { + it('CALENDAR_POPOVER_OFFSET is a fixed numeric offset', () => { + expect(typeof CALENDAR_POPOVER_OFFSET).toBe('number'); + expect(CALENDAR_POPOVER_OFFSET).toBeGreaterThan(0); + }); + + it('CALENDAR_POPOVER_PADDING is a fixed numeric padding', () => { + expect(typeof CALENDAR_POPOVER_PADDING).toBe('number'); + expect(CALENDAR_POPOVER_PADDING).toBeGreaterThan(0); + }); + }); + + describe('getInitialMonth', () => { + it('returns the date itself when value is a single Date', () => { + const date = new Date(2024, 5, 15); + expect(getInitialMonth(date)).toBe(date); + }); + + it('returns the earliest date when value is a non-empty array', () => { + const earliest = new Date(2024, 0, 1); + const middle = new Date(2024, 3, 1); + const latest = new Date(2024, 11, 31); + // Pass deliberately out of order — the helper must sort. + expect(getInitialMonth([latest, earliest, middle])).toBe(earliest); + }); + + it('returns the fallback when value is an empty array', () => { + const fallback = new Date(2030, 0, 1); + expect(getInitialMonth([], fallback)).toBe(fallback); + }); + + it('returns range.from when value is a {from, to} range', () => { + const from = new Date(2024, 2, 10); + const to = new Date(2024, 2, 20); + expect(getInitialMonth({ from, to })).toBe(from); + }); + + it('falls back to range.to when range.from is missing', () => { + const to = new Date(2024, 2, 20); + expect(getInitialMonth({ to } as unknown as SelectedValueLike)).toBe(to); + }); + + it('returns the fallback when range has neither from nor to', () => { + const fallback = new Date(2030, 5, 1); + expect(getInitialMonth({} as unknown as SelectedValueLike, fallback)).toBe(fallback); + }); + + it('returns the fallback when value is undefined', () => { + const fallback = new Date(2030, 6, 1); + expect(getInitialMonth(undefined, fallback)).toBe(fallback); + }); + + it('returns today when value is undefined and no fallback is given', () => { + const before = Date.now(); + const result = getInitialMonth(undefined); + const after = Date.now(); + expect(result.getTime()).toBeGreaterThanOrEqual(before); + expect(result.getTime()).toBeLessThanOrEqual(after); + }); + }); + + describe('buildDisabledMatchers', () => { + it('returns an empty array when nothing is configured', () => { + expect(buildDisabledMatchers({})).toEqual([]); + }); + + it('passes through a single matcher unchanged', () => { + const matcher = { dayOfWeek: [0, 6] }; + expect(buildDisabledMatchers({ disabled: matcher })).toEqual([matcher]); + }); + + it('spreads a matcher array so the caller gets a flat list', () => { + const a = { dayOfWeek: [0] }; + const b = { before: new Date(2024, 0, 1) }; + const result = buildDisabledMatchers({ disabled: [a, b] }); + expect(result).toEqual([a, b]); + }); + + it('appends `{ before: minDate }` when minDate is set', () => { + const minDate = new Date(2024, 5, 1); + expect(buildDisabledMatchers({ minDate })).toEqual([{ before: minDate }]); + }); + + it('appends `{ after: maxDate }` when maxDate is set', () => { + const maxDate = new Date(2024, 5, 30); + expect(buildDisabledMatchers({ maxDate })).toEqual([{ after: maxDate }]); + }); + + it('appends a `{ before: }` matcher when disablePast is true', () => { + const before = new Date(); + const result = buildDisabledMatchers({ disablePast: true }); + const after = new Date(); + + expect(result).toHaveLength(1); + const entry = result[0] as { before: Date }; + expect(entry.before.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(entry.before.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('appends a `{ after: }` matcher when disableFuture is true', () => { + const before = new Date(); + const result = buildDisabledMatchers({ disableFuture: true }); + const after = new Date(); + + expect(result).toHaveLength(1); + const entry = result[0] as { after: Date }; + expect(entry.after.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(entry.after.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('wraps a shouldDisableMonth predicate as a function matcher', () => { + const shouldDisableMonth = jest.fn(() => true); + const [matcher] = buildDisabledMatchers({ shouldDisableMonth }); + expect(typeof matcher).toBe('function'); + + const probe = new Date(2024, 0, 1); + const result = (matcher as (d: Date) => boolean)(probe); + expect(result).toBe(true); + expect(shouldDisableMonth).toHaveBeenCalledWith(probe); + }); + + it('returns false from the function matcher when shouldDisableMonth omits a return', () => { + const shouldDisableMonth = jest.fn(() => undefined as unknown as boolean); + const [matcher] = buildDisabledMatchers({ shouldDisableMonth }); + expect((matcher as (d: Date) => boolean)(new Date())).toBe(false); + }); + + it('wraps a shouldDisableYear predicate as a function matcher', () => { + const shouldDisableYear = jest.fn(() => true); + const [matcher] = buildDisabledMatchers({ shouldDisableYear }); + + const probe = new Date(2030, 0, 1); + expect((matcher as (d: Date) => boolean)(probe)).toBe(true); + expect(shouldDisableYear).toHaveBeenCalledWith(probe); + }); + + it('composes every input in input order', () => { + const disabledMatcher = { dayOfWeek: [0] }; + const minDate = new Date(2024, 0, 1); + const maxDate = new Date(2024, 11, 31); + const shouldDisableMonth = () => false; + const shouldDisableYear = () => false; + + const result = buildDisabledMatchers({ + disabled: disabledMatcher, + minDate, + maxDate, + disablePast: true, + disableFuture: true, + shouldDisableMonth, + shouldDisableYear, + }); + + expect(result).toHaveLength(7); + expect(result[0]).toBe(disabledMatcher); + expect(result[1]).toEqual({ before: minDate }); + expect(result[2]).toEqual({ after: maxDate }); + expect((result[3] as { before: Date }).before).toBeInstanceOf(Date); + expect((result[4] as { after: Date }).after).toBeInstanceOf(Date); + expect(typeof result[5]).toBe('function'); + expect(typeof result[6]).toBe('function'); + }); + }); + + describe('getLocaleDateParts', () => { + it('extracts day/month/year order for an `et-EE` formatter (dd.MM.yyyy)', () => { + const formatter = new Intl.DateTimeFormat('et-EE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const parts = getLocaleDateParts(formatter); + expect(parts.fieldOrder).toEqual(['day', 'month', 'year']); + expect(parts.separators).toHaveLength(2); + expect(parts.separators[0]).toMatch(/^[.\u00a0\s/-]+$/); + }); + + it('extracts year/month/day order for an `sv-SE` formatter (yyyy-MM-dd)', () => { + const formatter = new Intl.DateTimeFormat('sv-SE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const parts = getLocaleDateParts(formatter); + expect(parts.fieldOrder).toEqual(['year', 'month', 'day']); + expect(parts.separators).toHaveLength(2); + }); + + it('caps the separators array at two entries even with verbose formatters', () => { + const formatter = new Intl.DateTimeFormat('en-CA', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const parts = getLocaleDateParts(formatter); + expect(parts.separators.length).toBeLessThanOrEqual(2); + }); + }); + + describe('buildDateRegexSource', () => { + it('builds a dd.MM.yyyy regex source for day-first locales', () => { + const source = buildDateRegexSource({ fieldOrder: ['day', 'month', 'year'], separators: ['.', '.'] }); + const regex = new RegExp(`^${source}$`); + expect(regex.test('31.12.2099')).toBe(true); + expect(regex.test('31/12/2099')).toBe(false); + }); + + it('builds a yyyy-MM-dd regex source for ISO locales', () => { + const source = buildDateRegexSource({ fieldOrder: ['year', 'month', 'day'], separators: ['-', '-'] }); + const regex = new RegExp(`^${source}$`); + expect(regex.test('2099-12-31')).toBe(true); + expect(regex.test('2099/12/31')).toBe(false); + }); + + it('escapes regex meta-characters in the separator', () => { + const source = buildDateRegexSource({ fieldOrder: ['day', 'month', 'year'], separators: ['+', '+'] }); + const regex = new RegExp(`^${source}$`); + expect(regex.test('31+12+2099')).toBe(true); + expect(regex.test('311 22099')).toBe(false); + }); + + it('emits a 4-digit slot for `year` and 2-digit for day/month', () => { + const source = buildDateRegexSource({ fieldOrder: ['day', 'month', 'year'], separators: ['.', '.'] }); + const regex = new RegExp(`^${source}$`); + expect(regex.test('31.12.99')).toBe(false); + }); + + it('handles a missing separator entry without crashing', () => { + const source = buildDateRegexSource({ fieldOrder: ['year'], separators: [] }); + expect(new RegExp(`^${source}$`).test('2099')).toBe(true); + }); + }); +}); diff --git a/src/tedi/components/form/date-field/date-field-helpers.ts b/src/tedi/components/form/date-field/date-field-helpers.ts new file mode 100644 index 000000000..9ad8597f1 --- /dev/null +++ b/src/tedi/components/form/date-field/date-field-helpers.ts @@ -0,0 +1,121 @@ +import type { DateRange, Matcher } from 'react-day-picker'; + +/** + * Floating-ui offset (in px) between the trigger input and the popover. + * Shared by `DateField` and `DateTimeField` so the popovers sit at the + * same distance from their triggers. + */ +export const CALENDAR_POPOVER_OFFSET = 4; + +/** + * Floating-ui shift / size middleware padding (in px) — keeps the popover + * away from the viewport edges by this amount when the size middleware + * caps its `maxWidth`. + */ +export const CALENDAR_POPOVER_PADDING = 8; + +export type SelectedValueLike = Date | Date[] | DateRange | undefined; + +/** + * Resolves the month the calendar should start on for any selection + * shape. Used by both `DateField` (single / multiple / range Date(s)) and + * `DateTimeField` (single Date or `{from, to}` range). For arrays the + * earliest date wins; for ranges the `from` (or `to` if `from` is unset) + * wins. Falls back to the explicit `fallback` and finally `new Date()`. + */ +export const getInitialMonth = (val: SelectedValueLike, fallback?: Date): Date => { + if (val instanceof Date) return val; + if (Array.isArray(val) && val.length > 0) { + return [...val].sort((a, b) => a.getTime() - b.getTime())[0]; + } + if (val && typeof val === 'object' && 'from' in val && val.from instanceof Date) return val.from; + if (val && typeof val === 'object' && 'to' in val && val.to instanceof Date) return val.to; + return fallback ?? new Date(); +}; + +/** + * Configuration for the `buildDisabledMatchers` helper. All fields are + * forwarded as-is to react-day-picker. `shouldDisableMonth` / + * `shouldDisableYear` are predicates passed to `DayPicker.disabled` to + * disable a date when its month / year is "off limits". + */ +export interface DisabledMatcherInputs { + disabled?: Matcher | Matcher[]; + minDate?: Date; + maxDate?: Date; + disablePast?: boolean; + disableFuture?: boolean; + shouldDisableMonth?: (date: Date) => boolean; + shouldDisableYear?: (date: Date) => boolean; +} + +/** + * Composes a flat `Matcher[]` array from the date-field-style constraint + * props. Returns an empty array when nothing is set, which lets the + * caller pass `matchers.length ? matchers : undefined` straight through. + */ +export const buildDisabledMatchers = (inputs: DisabledMatcherInputs): Matcher[] => { + const matchers: Matcher[] = []; + + if (inputs.disabled) { + if (Array.isArray(inputs.disabled)) matchers.push(...inputs.disabled); + else matchers.push(inputs.disabled); + } + if (inputs.minDate) matchers.push({ before: inputs.minDate }); + if (inputs.maxDate) matchers.push({ after: inputs.maxDate }); + if (inputs.disablePast) matchers.push({ before: new Date() }); + if (inputs.disableFuture) matchers.push({ after: new Date() }); + if (inputs.shouldDisableMonth) matchers.push((d: Date) => inputs.shouldDisableMonth?.(d) ?? false); + if (inputs.shouldDisableYear) matchers.push((d: Date) => inputs.shouldDisableYear?.(d) ?? false); + + return matchers; +}; + +/** + * Field order + literal separators extracted from a locale's date format, + * derived via `Intl.DateTimeFormat.formatToParts`. Used by parsers in + * `DateField` (date-only) and `DateTimeField` (date + " HH:mm") to build + * a regex that round-trips the displayed value in any locale. + */ +export interface LocaleDateParts { + fieldOrder: ('day' | 'month' | 'year')[]; + separators: string[]; +} + +/** + * Extract the date-portion field order and literal separators from a + * `dateFormatter`. The reference date `Dec 31, 2099` is used because all + * three components are unambiguously two-digit (day, month) / four-digit + * (year) values, so each `formatToParts` entry is unambiguous. + */ +export const getLocaleDateParts = (dateFormatter: Intl.DateTimeFormat): LocaleDateParts => { + const ref = new Date(2099, 11, 31); + const parts = dateFormatter.formatToParts(ref); + + const fieldOrder: ('day' | 'month' | 'year')[] = []; + const separators: string[] = []; + for (const part of parts) { + if (part.type === 'day' || part.type === 'month' || part.type === 'year') { + fieldOrder.push(part.type); + } else if (part.type === 'literal' && fieldOrder.length > 0 && separators.length < 2) { + separators.push(part.value); + } + } + return { fieldOrder, separators }; +}; + +/** + * Build the date-portion of a parsing regex from the locale's field + * order and separators. Caller appends time / range separators as needed + * — DateField uses `^${datePart}$`, DateTimeField uses `^${datePart}\s+(\d{2}):(\d{2})$`. + */ +export const buildDateRegexSource = ({ fieldOrder, separators }: LocaleDateParts): string => { + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return fieldOrder + .map((field, i) => { + const digits = field === 'year' ? '\\d{4}' : '\\d{2}'; + const sep = i > 0 ? escapeRegex(separators[i - 1] ?? '') : ''; + return `${sep}(${digits})`; + }) + .join(''); +}; diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss index 5cb21f6bd..aef1b6d9c 100644 --- a/src/tedi/components/form/date-field/date-field.module.scss +++ b/src/tedi/components/form/date-field/date-field.module.scss @@ -1,3 +1,5 @@ +@use '../field-icon-button' as field-icon-button; + .tedi-date-field__calendar { position: relative; z-index: var(--z-index-dropdown); @@ -10,52 +12,14 @@ } .tedi-date-field__textfield { - button:not([data-name='closing-button']):last-child { - display: flex; - align-items: center; - justify-content: center; - width: var(--button-xs-icon-size); - min-height: var(--form-field-button-height-sm); - max-height: var(--form-field-button-height-sm); - border-radius: var(--button-radius-sm); - - > span { - color: var(--button-main-neutral-text-default); - } - - &:hover { - background-color: var(--form-datepicker-date-hover); - } - } - - &[aria-expanded='true'] button:not([data-name='closing-button']):last-child { - background-color: var(--form-datepicker-date-hover); - - > span { - color: var(--button-main-neutral-text-active); - } - } + @include field-icon-button.states; &--disabled { - button:not([data-name='closing-button']):last-child { - span { - color: var(--button-main-disabled-general-text); - } - - &:hover { - background: none; - } - } + @include field-icon-button.disabled; } &.tedi-date-field__icon--disabled { - button:not([data-name='closing-button']):last-child { - &:hover, - &:focus { - cursor: auto; - background: none; - } - } + @include field-icon-button.icon-only-disabled; } input[type='date'] { diff --git a/src/tedi/components/form/date-field/date-field.tsx b/src/tedi/components/form/date-field/date-field.tsx index b98a88247..55aca399f 100644 --- a/src/tedi/components/form/date-field/date-field.tsx +++ b/src/tedi/components/form/date-field/date-field.tsx @@ -23,9 +23,12 @@ import { Calendar } from '../../content/calendar/calendar'; import MultiValueField, { MultiValueFieldProps } from '../multi-value-field/multi-value-field'; import TextField, { TextFieldForwardRef, TextFieldProps } from '../textfield/textfield'; import styles from './date-field.module.scss'; - -const CALENDAR_OFFSET = 4; -const CALENDAR_PADDING = 8; +import { + buildDateRegexSource, + CALENDAR_POPOVER_OFFSET, + CALENDAR_POPOVER_PADDING, + getLocaleDateParts, +} from './date-field-helpers'; export type DateFieldMode = 'single' | 'multiple' | 'range'; export type CalendarView = 'days' | 'months' | 'years'; @@ -439,28 +442,9 @@ export const DateField = React.forwardRef(( }; const defaultParseDate = useMemo(() => { - const ref = new Date(2099, 11, 31); - const parts = dateFormatter.formatToParts(ref); - - const fieldOrder: ('day' | 'month' | 'year')[] = []; - const separators: string[] = []; - for (const part of parts) { - if (part.type === 'day' || part.type === 'month' || part.type === 'year') { - fieldOrder.push(part.type); - } else if (part.type === 'literal' && fieldOrder.length > 0 && separators.length < 2) { - separators.push(part.value); - } - } - - const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regexSource = fieldOrder - .map((field, i) => { - const digits = field === 'year' ? '\\d{4}' : '\\d{2}'; - const sep = i > 0 ? escapeRegex(separators[i - 1] ?? '') : ''; - return `${sep}(${digits})`; - }) - .join(''); - const regex = new RegExp(`^${regexSource}$`); + const localeParts = getLocaleDateParts(dateFormatter); + const regex = new RegExp(`^${buildDateRegexSource(localeParts)}$`); + const { fieldOrder } = localeParts; return (value: string): Date | undefined => { const match = value.match(regex); @@ -532,11 +516,11 @@ export const DateField = React.forwardRef(( onOpenChange: setOpen, placement: calendarTrigger === 'input' ? 'bottom-start' : 'bottom-end', middleware: [ - offset(CALENDAR_OFFSET), + offset(CALENDAR_POPOVER_OFFSET), flip(), - shift({ padding: CALENDAR_PADDING }), + shift({ padding: CALENDAR_POPOVER_PADDING }), size({ - padding: CALENDAR_PADDING, + padding: CALENDAR_POPOVER_PADDING, apply({ availableWidth, elements }) { const el = elements.floating; el.style.width = 'max-content'; @@ -635,6 +619,7 @@ export const DateField = React.forwardRef(( value={shouldUseNativePicker ? nativeValue : inputValue} placeholder={placeholder} icon="calendar_today" + aria-expanded={enableCalendar && !shouldUseNativePicker ? open : undefined} isClearable onIconClick={() => { if (!enableCalendar) return; diff --git a/src/tedi/components/form/date-time-field/date-time-field.module.scss b/src/tedi/components/form/date-time-field/date-time-field.module.scss new file mode 100644 index 000000000..2c388418f --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.module.scss @@ -0,0 +1,258 @@ +@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; +@use '../field-icon-button' as field-icon-button; + +.tedi-date-time-field__container { + width: 100%; +} + +.tedi-date-time-field__textfield { + @include field-icon-button.states; + + input[type='datetime-local']::-webkit-calendar-picker-indicator { + display: none !important; + } + + input[type='datetime-local'][value='']:not(:focus)::-webkit-datetime-edit { + /* stylelint-disable-next-line scale-unlimited/declaration-strict-value */ + color: transparent; + } + + input[type='datetime-local'][value='']:not(:focus)::before { + position: absolute; + color: var(--form-input-text-placeholder); + content: attr(placeholder); + } + + input[type='datetime-local'][value='']:focus::-webkit-datetime-edit { + color: inherit; + } + + input[type='datetime-local'][value='']:focus::before { + content: ''; + } +} + +.tedi-date-time-field__icon--disabled { + @include field-icon-button.icon-only-disabled; +} + +.tedi-date-time-field__popup { + z-index: var(--z-index-dropdown); +} + +.tedi-date-time-field__calendar { + position: relative; + min-width: 20rem; + font-family: var(--family-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); +} + +.tedi-date-time-field__split { + display: flex; + align-items: stretch; + overflow: hidden; + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + @include breakpoints.media-breakpoint-down(md) { + flex-direction: column; + } + + .tedi-date-time-field__calendar--split { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .tedi-date-time-field__time-picker { + max-width: 12rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + + @include breakpoints.media-breakpoint-down(md) { + width: 100%; + max-width: 20rem; + } + } + + .tedi-date-time-field__time-picker--wheel { + --tedi-time-wheel-height: 15.5rem; + --tedi-time-wheel-fade: 6.5rem; + } +} + +.tedi-date-time-field__split-separator { + display: flex; + flex-shrink: 0; + align-items: stretch; + padding: var(--card-padding-md-default) var(--separator-spacing-x-01); +} + +.tedi-date-time-field__split-separator-line { + flex: 1; + width: 1px; + background: var(--general-border-primary); + + @include breakpoints.media-breakpoint-down(md) { + width: auto; + height: 1px; + } +} + +.tedi-date-time-field__split-time { + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.tedi-date-time-field__split-time-header { + display: flex; + align-items: center; + justify-content: center; + padding: var(--card-padding-md-default) var(--card-padding-md-default) 0; +} + +.tedi-date-time-field__split-heading { + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--general-text-primary); +} + +.tedi-date-time-field__split-time-body { + display: flex; + flex: 1; + align-items: flex-start; + justify-content: center; + padding: var(--card-padding-md-default); +} + +.tedi-date-time-field__range { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + .tedi-date-time-field__calendar--split { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .tedi-date-time-field__time-picker { + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + + @include breakpoints.media-breakpoint-down(md) { + width: 100%; + max-width: 20rem; + } + } + + .tedi-date-time-field__time-picker--wheel { + --tedi-time-wheel-height: 7.25rem; + --tedi-time-wheel-fade: 2.25rem; + } +} + +.tedi-date-time-field__range-separator { + display: flex; + flex-shrink: 0; + align-items: stretch; +} + +.tedi-date-time-field__range-separator-line { + flex: 1; + height: 1px; + background: var(--general-border-primary); +} + +.tedi-date-time-field__range-times { + display: flex; + align-items: stretch; + + @include breakpoints.media-breakpoint-down(md) { + flex-direction: column; + } +} + +.tedi-date-time-field__range-time { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + + & + & { + @include breakpoints.media-breakpoint-down(md) { + border-top: 1px solid var(--card-border-primary); + border-left: 0; + } + } +} + +.tedi-date-time-field__select-time-wrapper { + display: flex; + justify-content: center; +} + +.tedi-date-time-field__time-step { + display: flex; + flex-direction: column; + min-width: 14rem; + overflow: hidden; + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + .tedi-date-time-field__time-picker { + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .tedi-date-time-field__time-picker--wheel { + width: 100%; + max-width: none; + } +} + +.tedi-date-time-field__time-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; + padding: var(--card-padding-md-default) var(--card-padding-md-default) 0; +} + +.tedi-date-time-field__time-date { + font-size: var(--body-regular-size); + font-weight: 500; + line-height: var(--body-regular-line-height); + color: var(--general-text-primary); +} + +.tedi-date-time-field__time-body { + padding: var(--card-padding-md-default); +} + +.tedi-date-time-field__time-picker { + flex-shrink: 0; +} diff --git a/src/tedi/components/form/date-time-field/date-time-field.spec.tsx b/src/tedi/components/form/date-time-field/date-time-field.spec.tsx new file mode 100644 index 000000000..6700c5dba --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.spec.tsx @@ -0,0 +1,548 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; + +import { TextFieldForwardRef } from '../textfield/textfield'; +import { DateTimeField, DateTimeFieldProps } from './date-time-field'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../providers/label-provider', () => ({ + useLabels: () => ({ + getLabel: (key: string) => key, + }), +})); + +describe('DateTimeField component', () => { + const defaultProps: DateTimeFieldProps = { + id: 'date-time-field', + label: 'When', + }; + + beforeEach(() => { + Element.prototype.scrollTo = jest.fn(); + }); + + it('renders the field with its label', () => { + render(); + expect(screen.getByLabelText('When')).toBeInTheDocument(); + }); + + it('forwards ref to the underlying TextField', () => { + const ref = createRef(); + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current?.input).toBeInstanceOf(HTMLInputElement); + expect(ref.current?.input).toBe(screen.getByLabelText('When')); + }); + + it('shows the placeholder when no value is provided', () => { + render(); + expect(screen.getByPlaceholderText('dd.MM.yyyy HH:mm')).toBeInTheDocument(); + }); + + it('formats the controlled value as "dd.MM.yyyy HH:mm" (et-EE default)', () => { + const value = new Date(2025, 8, 1, 11, 30); + render(); + + expect(screen.getByLabelText('When')).toHaveValue('01.09.2025 11:30'); + }); + + it('opens the calendar when the icon is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('toggles the popover closed when the icon is clicked again', async () => { + const user = userEvent.setup(); + render(); + + const icon = screen.getByRole('button'); + await user.click(icon); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + await user.click(icon); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders both calendar and time picker together in the side-by-side layout (default)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('11:30')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /select time/i })).not.toBeInTheDocument(); + }); + + it('navigates back from time step to date step via the "Back" button in multi-step layout', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('button')); + await user.click(await screen.findByRole('button', { name: /select time/i })); + + expect(screen.getByText('11:30')).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: /back/i })); + + expect(screen.queryByText('11:30')).not.toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /select time/i })).toBeInTheDocument(); + }); + + it('renders a custom `timeHeading` in the side-by-side layout', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + + expect(await screen.findByText('Pick a time')).toBeInTheDocument(); + }); + + it('advances from date step to time step via the "Select time" footer in multi-step layout', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('button')); + expect(screen.queryByText('11:30')).not.toBeInTheDocument(); + + const selectTime = await screen.findByRole('button', { name: /select time/i }); + await user.click(selectTime); + + expect(await screen.findByRole('button', { name: /back/i })).toBeInTheDocument(); + expect(screen.getByText('11:30')).toBeInTheDocument(); + }); + + it('selecting a predefined time fires onChange with the combined Date (multi-step)', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button')); + await user.click(await screen.findByRole('button', { name: /select time/i })); + await user.click(await screen.findByText('11:30')); + + expect(onChange).toHaveBeenCalled(); + const next = onChange.mock.calls[onChange.mock.calls.length - 1][0] as Date; + expect(next.getHours()).toBe(11); + expect(next.getMinutes()).toBe(30); + expect(next.getDate()).toBe(1); + expect(next.getMonth()).toBe(8); + expect(next.getFullYear()).toBe(2025); + }); + + it('selecting a predefined time in side-by-side layout updates the value but keeps the popover open', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button')); + await user.click(await screen.findByText('11:30')); + + expect(onChange).toHaveBeenCalled(); + const next = onChange.mock.calls[onChange.mock.calls.length - 1][0] as Date; + expect(next.getHours()).toBe(11); + expect(next.getMinutes()).toBe(30); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('parses a typed "dd.MM.yyyy HH:mm" value and fires onChange', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByLabelText('When'); + await user.type(input, '15.06.2024 09:45'); + + expect(onChange).toHaveBeenCalled(); + const next = onChange.mock.calls[onChange.mock.calls.length - 1][0] as Date; + expect(next.getDate()).toBe(15); + expect(next.getMonth()).toBe(5); + expect(next.getFullYear()).toBe(2024); + expect(next.getHours()).toBe(9); + expect(next.getMinutes()).toBe(45); + }); + + it('rejects malformed typed input (out-of-range hour) without firing onChange', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.type(screen.getByLabelText('When'), '15.06.2024 25:00'); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not open the popover when disabled', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('clears the value when the input is emptied', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.clear(screen.getByLabelText('When')); + + expect(onChange).toHaveBeenLastCalledWith(undefined); + }); + + describe('useNativePicker', () => { + it('renders the input with type="datetime-local" when native picker is enabled', () => { + render(); + + expect(screen.getByLabelText('When')).toHaveAttribute('type', 'datetime-local'); + }); + + it('formats the controlled value in datetime-local ISO format when native', () => { + const value = new Date(2025, 8, 1, 11, 30); + render(); + + // datetime-local expects "YYYY-MM-DDTHH:mm". + expect(screen.getByLabelText('When')).toHaveValue('2025-09-01T11:30'); + }); + + it('does not render the custom popover when native picker is enabled', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('falls back to the custom popover in range mode (native has no range counterpart)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('invokes `showPicker()` on the native input when the icon is clicked', async () => { + const showPicker = jest.fn(); + const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'showPicker'); + Object.defineProperty(HTMLInputElement.prototype, 'showPicker', { + value: showPicker, + configurable: true, + writable: true, + }); + + try { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + + expect(showPicker).toHaveBeenCalledTimes(1); + } finally { + if (originalDescriptor) { + Object.defineProperty(HTMLInputElement.prototype, 'showPicker', originalDescriptor); + } else { + delete (HTMLInputElement.prototype as unknown as { showPicker?: unknown }).showPicker; + } + } + }); + }); + + describe('range mode', () => { + it('formats the controlled range value as "from – to"', () => { + const value = { + from: new Date(2025, 8, 1, 11, 30), + to: new Date(2025, 8, 5, 14, 0), + }; + render(); + + expect(screen.getByLabelText('When')).toHaveValue('01.09.2025 11:30 – 05.09.2025 14:00'); + }); + + it('renders both from and to time pickers in the popover', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + + expect(await screen.findAllByText('11:30')).toHaveLength(2); + }); + + it('updates the from time without clearing the existing to time', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + const initial = { + from: new Date(2025, 8, 1, 9, 30), + to: new Date(2025, 8, 5, 14, 0), + }; + + render( + + ); + + await user.click(screen.getByRole('button')); + + const slots = await screen.findAllByText('11:30'); + await user.click(slots[0]); + + expect(onChange).toHaveBeenCalled(); + const next = onChange.mock.calls[onChange.mock.calls.length - 1][0] as { + from: Date; + to: Date; + }; + expect(next.from.getHours()).toBe(11); + expect(next.from.getMinutes()).toBe(30); + expect(next.to.getHours()).toBe(14); + expect(next.to.getMinutes()).toBe(0); + }); + }); + + describe('uncontrolled mode', () => { + it('seeds the input from `defaultValue` without `value`', () => { + render(); + expect(screen.getByLabelText('When')).toHaveValue('01.09.2025 09:30'); + }); + + it('mutates internal state on input typing without `value`', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('When'); + await user.type(input, '15.06.2025 10:00'); + // The last typed character commits a parsed Date. + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1][0] as Date; + expect(last).toBeInstanceOf(Date); + expect(last.getFullYear()).toBe(2025); + expect(last.getMonth()).toBe(5); + expect(last.getDate()).toBe(15); + expect(last.getHours()).toBe(10); + expect(last.getMinutes()).toBe(0); + }); + }); + + describe('parser', () => { + it('rejects an impossible calendar date (Feb 30) without firing onChange', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('When'), '30.02.2025 10:00'); + expect(onChange).not.toHaveBeenCalledWith(expect.any(Date)); + }); + + it('rejects an out-of-range minute (75) without firing onChange', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('When'), '15.06.2025 10:75'); + expect(onChange).not.toHaveBeenCalledWith(expect.any(Date)); + }); + + it('ignores extra surrounding whitespace and still parses', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('When'), ' 15.06.2025 10:00 '); + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1][0] as Date; + expect(last).toBeInstanceOf(Date); + expect(last.getFullYear()).toBe(2025); + }); + }); + + describe('inputProps passthrough', () => { + it('renders the inputProps.helper text below the field', () => { + render( + + ); + expect(screen.getByText('Format: pp.kk.aaaa hh:mm')).toBeInTheDocument(); + }); + + it('disables the underlying input when the top-level `disabled` prop is set', () => { + render(); + expect(screen.getByLabelText('When')).toBeDisabled(); + }); + }); + + describe('initialMonth fallback', () => { + it('opens on the explicit `initialMonth` when no value is present', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button')); + const headings = await screen.findAllByText(/2030/); + expect(headings.length).toBeGreaterThan(0); + }); + }); + + describe('clearing', () => { + it('emits undefined after the value is cleared from outside the input', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByLabelText('When'); + await user.clear(input); + expect(onChange).toHaveBeenLastCalledWith(undefined); + }); + }); + + describe('selectionLevel (handleApplyValue branch)', () => { + const getIconTrigger = (): HTMLElement => { + const trigger = screen.getAllByRole('button').find((b) => b.getAttribute('data-name') !== 'closing-button'); + if (!trigger) throw new Error('Icon trigger button not found'); + return trigger; + }; + + const findNonJuneMonth = async (): Promise => { + const cells = await screen.findAllByTestId('tedi-calendar-grid-cell'); + const target = cells.find((b) => b.textContent?.toLowerCase().startsWith('jaan')); + if (!target) throw new Error('Could not find the January month button to click'); + return target; + }; + + const openMonthGrid = async (user: ReturnType): Promise => { + await user.click(await screen.findByTestId('tedi-calendar-month-trigger')); + }; + + it('commits the value when a month is picked with `selectionLevel="months"` (single mode)', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click(getIconTrigger()); + await openMonthGrid(user); + await user.click(await findNonJuneMonth()); + + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + // Time component must be preserved from the previous value. + expect(last).toBeInstanceOf(Date); + expect((last as Date).getHours()).toBe(10); + expect((last as Date).getMinutes()).toBe(30); + }); + + it('commits a `from`-only range when a month is picked with `selectionLevel="months"` (range mode)', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click(getIconTrigger()); + await openMonthGrid(user); + await user.click(await findNonJuneMonth()); + + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1][0] as { from: Date; to?: Date }; + expect(last.from).toBeInstanceOf(Date); + expect(last.from.getHours()).toBe(9); + expect(last.from.getMinutes()).toBe(0); + expect(last.to).toBeUndefined(); + }); + }); + + describe('native input parsing', () => { + it('parses a typed `datetime-local` value when `useNativePicker` is true', async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByLabelText('When') as HTMLInputElement; + input.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, '2025-09-15T11:45'); + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls[onChange.mock.calls.length - 1][0] as Date; + expect(last).toBeInstanceOf(Date); + expect(last.getFullYear()).toBe(2025); + expect(last.getMonth()).toBe(8); + expect(last.getDate()).toBe(15); + expect(last.getHours()).toBe(11); + expect(last.getMinutes()).toBe(45); + }); + + it('ignores an unparseable datetime-local string without firing onChange', async () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByLabelText('When') as HTMLInputElement; + input.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, 'not-a-date'); + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/tedi/components/form/date-time-field/date-time-field.stories.tsx b/src/tedi/components/form/date-time-field/date-time-field.stories.tsx new file mode 100644 index 000000000..7c28cb02b --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.stories.tsx @@ -0,0 +1,355 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { UnknownType } from '../../../types/commonTypes'; +import { Text } from '../../base/typography/text/text'; +import Button from '../../buttons/button/button'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { TextFieldProps } from '../textfield/textfield'; +import { DateTimeField, DateTimeFieldProps } from './date-time-field'; + +/** + * Figma ↗
+ * ZeroHeight ↗ + */ +const meta: Meta = { + component: DateTimeField, + title: 'TEDI-Ready/Components/Form/DateTimeField', + argTypes: { + inputProps: { control: false }, + locale: { control: false }, + timeHeading: { control: false }, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=7895-221619&m=dev', + }, + status: { + type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], + }, + controls: { + exclude: ['sm', 'md', 'lg', 'xl', 'xxl'], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const Template: StoryFn = (args) => ( + + + + + +); + +/** + * Side-by-side layout with the scroll-wheel time picker — matches the + * Figma "One day → Default" frame. Calendar on the left, hour/minute wheel + * on the right under the "Kellaaeg" / "Time" heading. Both are interactive + * at the same time; the popover stays open until the user clicks outside. + */ +export const Default: Story = { + render: Template, + args: { + id: 'date-time-default', + label: 'Date', + placeholder: 'pp.kk.aaaa hh:mm', + layout: 'side-by-side', + stepMinutes: 1, + }, +}; + +const sizeArray: TextFieldProps['size'][] = ['default', 'small']; + +export const Size: StoryFn = () => ( +
+ {sizeArray.map((size, idx) => ( + + + {size ? size.charAt(0).toUpperCase() + size.slice(1) : ''} + + + + + + ))} +
+); + +const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const; + +export const States: Story = { + render: () => ( +
+ {stateArray.map((state) => ( + + + {state} + + + + + + ))} + + + Success + + + + + + + + Error + + + + + +
+ ), + parameters: { + pseudo: { + hover: '#Hover', + focus: '#Focus', + active: '#Active', + }, + }, +}; + +/** + * Demonstrates `inputProps` pass-through on the input control: helper hint, + * and a controlled-value pattern wired to quick-pick buttons (Today, Tomorrow + * at 09:00). Mirrors DateField's `FieldOptions` story. + */ +export const FieldOptions: StoryFn = () => { + const [shortcutValue, setShortcutValue] = useState(undefined); + + const today = new Date(); + today.setHours(9, 0, 0, 0); + + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + + return ( + + +
+ + + + +
+ setShortcutValue(v instanceof Date ? v : undefined)} + /> +
+ + +
+
+
+ +
+ ); +}; + +export const PredefinedTimeSlots: Story = { + render: Template, + args: { + id: 'date-time-predefined', + label: 'Date', + placeholder: 'pp.kk.aaaa hh:mm', + layout: 'side-by-side', + availableTimes: ['09:30', '10:00', '11:30', '15:30', '18:30', '20:30'], + timeGridVariant: 'button', + }, +}; + +export const MultiSteps: Story = { + render: Template, + args: { + id: 'date-time-multi-step', + label: 'Time', + placeholder: 'pp.kk.aaaa hh:mm', + layout: 'multi-step', + availableTimes: ['09:30', '10:00', '11:30', '15:30', '18:30', '20:30'], + timeGridVariant: 'radio', + }, +}; + +const ControlledTemplate: StoryFn = (args) => { + const [value, setValue] = useState(new Date(2025, 8, 1, 11, 30)); + + const handleChange = (newValue: UnknownType) => { + if (newValue instanceof Date) { + setValue(newValue); + } + }; + + return ( + + + + + + + + +

Current value: {value ? value.toISOString() : 'undefined'}

+ +
+
+ ); +}; + +export const Range: Story = { + render: Template, + args: { + id: 'date-time-range', + label: 'Date range', + placeholder: 'pp.kk.aaaa hh:mm – pp.kk.aaaa hh:mm', + mode: 'range', + stepMinutes: 1, + }, +}; + +export const RangePredefinedTimeSlots: Story = { + render: Template, + args: { + id: 'date-time-range-predefined', + label: 'Date range', + placeholder: 'pp.kk.aaaa hh:mm – pp.kk.aaaa hh:mm', + mode: 'range', + availableTimes: ['09:30', '10:00', '11:30', '15:30', '18:30', '20:30'], + timeGridVariant: 'button', + }, +}; + +/** + * Controlled-value pattern: parent owns selection in `useState`, passes + * `value` and `onChange`. Use this shape when the value needs to be lifted + * (form integration, programmatic updates, etc.). + */ +export const Controlled: Story = { + render: ControlledTemplate, + args: { + id: 'date-time-controlled', + label: 'Date', + placeholder: 'pp.kk.aaaa hh:mm', + layout: 'side-by-side', + availableTimes: ['09:30', '10:00', '11:30', '15:30', '18:30', '20:30'], + }, +}; + +/** + * Calendar constraints — `disablePast`, `disableFuture`, and explicit + * `minDate` / `maxDate`. The time picker doesn't enforce time-of-day bounds + * (every minute is selectable inside the allowed days), only the calendar + * grid is constrained. + */ +export const DateConstraints: StoryFn = () => { + const minDate = new Date(); + minDate.setDate(minDate.getDate() - 7); + const maxDate = new Date(); + maxDate.setDate(maxDate.getDate() + 7); + + return ( + + + disablePast + + + + disableFuture + + + + minDate / maxDate (±7 days) + + + + ); +}; + +/** + * Header month / year pickers render as a full grid instead of the default + * `` and skips the custom + * popover entirely — the browser's built-in date+time picker is shown + * when the calendar icon is clicked. Useful on mobile where the native + * picker is the platform-idiomatic UX, or as the only UI when the + * consumer wants to keep the bundle small. Range mode is not supported + * for native (silently falls back to custom). + */ +export const Native: Story = { + render: Template, + args: { + id: 'date-time-native', + label: 'Date', + placeholder: 'pp.kk.aaaa hh:mm', + useNativePicker: true, + }, +}; diff --git a/src/tedi/components/form/date-time-field/date-time-field.tsx b/src/tedi/components/form/date-time-field/date-time-field.tsx new file mode 100644 index 000000000..10fd5ee54 --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.tsx @@ -0,0 +1,705 @@ +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset, + shift, + size, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react'; +import cn from 'classnames'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DateRange, Locale, Matcher, OnSelectHandler } from 'react-day-picker'; +import { et } from 'react-day-picker/locale'; + +import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../helpers'; +import { useLabels } from '../../../providers/label-provider'; +import { UnknownType } from '../../../types/commonTypes'; +import { Button } from '../../buttons/button/button'; +import { Calendar } from '../../content/calendar/calendar'; +import { CalendarView } from '../date-field/date-field'; +import { + buildDateRegexSource, + buildDisabledMatchers, + CALENDAR_POPOVER_OFFSET, + CALENDAR_POPOVER_PADDING, + getLocaleDateParts, +} from '../date-field/date-field-helpers'; +import TextField, { TextFieldForwardRef, TextFieldProps } from '../textfield/textfield'; +import { TimePicker } from '../time-picker/time-picker'; +import styles from './date-time-field.module.scss'; + +export type DateTimeFieldStep = 'date' | 'time'; +export type DateTimeFieldLayout = 'side-by-side' | 'multi-step'; +export type DateTimeFieldMode = 'single' | 'range'; + +export interface DateTimeRange { + from?: Date; + to?: Date; +} + +export type DateTimeFieldValue = Date | DateTimeRange; + +type DateTimeFieldBreakpointProps = { + /** + * When `true`, renders an `` and skips the + * custom popover entirely — the browser's built-in date/time picker is + * shown when the calendar icon is clicked. Has no effect when + * `mode='range'` (native datetime-local has no range counterpart). + * @default false + */ + useNativePicker?: boolean; + /** + * Layout of the date-and-time popover. `mode='range'` always uses + * `'side-by-side'` regardless of this value — the range UI needs the + * calendar and both `from` / `to` time pickers visible at once. + * @default side-by-side + */ + layout?: DateTimeFieldLayout; + /** + * Predefined time slots, each in `"HH:mm"` format. When provided, the + * time step renders a grid of slots instead of the scroll-wheel picker. + */ + availableTimes?: string[]; + /** + * Layout variant for the time grid when `availableTimes` is set. + * Defaults differ per layout: `'button'` for `side-by-side`, `'radio'` + * for `multi-step`. + */ + timeGridVariant?: 'button' | 'radio'; + /** + * How the month/year selector in the calendar header is rendered. + * @default dropdown + */ + monthYearSelectType?: 'dropdown' | 'grid'; + /** + * Show days from adjacent months in the calendar grid. + * @default true + */ + showOutsideDays?: boolean; + /** + * Heading rendered above the time picker in the side-by-side layout. + * Falls back to the localised `dateTimeField.timeHeading` label. + */ + timeHeading?: React.ReactNode; +}; + +export interface DateTimeFieldProps extends BreakpointSupport { + /** + * Unique identifier for the input field. + */ + id: string; + /** + * Field label. Required for accessibility. + */ + label: string; + /** + * Placeholder shown in the input when no value is selected. + */ + placeholder?: string; + /** + * Additional class on the container. + */ + className?: string; + /** + * Selection mode. + * - `'single'` (default) — one combined `Date` value. + * - `'range'` — pair of `from` / `to` `Date` values, each carrying their + * own time. Renders a 2-month calendar and two time pickers (one for + * `from`, one for `to`) stacked underneath. + * @default single + */ + mode?: DateTimeFieldMode; + /** + * Controlled value. Type depends on `mode`: + * - `mode='single'` → `Date | undefined` + * - `mode='range'` → `DateTimeRange | undefined` (`{ from, to }`) + */ + value?: DateTimeFieldValue; + /** + * Initial value for uncontrolled usage. Ignored when `value` is provided. + */ + defaultValue?: DateTimeFieldValue; + /** + * Fires whenever the user picks a date or a time. The argument shape + * matches `mode`: `Date | undefined` for `'single'`, + * `DateTimeRange | undefined` for `'range'`. + */ + onChange?: (value: DateTimeFieldValue | undefined) => void; + /** + * Marks the field as required. + * @default false + */ + required?: boolean; + /** + * When `true`, the input is read-only — typing is disabled, but the + * picker can still be opened. + * @default false + */ + readOnly?: boolean; + /** + * Disables the input and the picker. + * @default false + */ + disabled?: boolean; + /** + * Minimum selectable date. Dates before this are disabled in the calendar. + */ + minDate?: Date; + /** + * Maximum selectable date. Dates after this are disabled in the calendar. + */ + maxDate?: Date; + /** + * Disables every date strictly before today. + */ + disablePast?: boolean; + /** + * Disables every date strictly after today. + */ + disableFuture?: boolean; + /** + * Step interval (minutes) for the time-wheel picker. Ignored when + * `availableTimes` is set. + * @default 15 + */ + stepMinutes?: number; + /** + * Initial month displayed when the calendar opens. Defaults to the + * month of the current value, or the current month if no value. + */ + initialMonth?: Date; + /** + * Locale object used by react-day-picker for the calendar grid. + * @default Estonian + */ + locale?: Locale; + /** + * Locale code used for the displayed date format. + * @default et-EE + */ + localeCode?: string; + /** + * Label of the "Select time" footer button under the calendar in the + * multi-step layout. Falls back to the localised + * `dateTimeField.selectTime` label. + */ + selectTimeLabel?: string; + /** + * Label of the "Back" link shown above the time picker in the + * multi-step layout. Falls back to the localised `dateTimeField.back` + * label. + */ + backLabel?: string; + /** + * Lowest calendar drill-down level the user can pick from before the value + * commits. `'days'` (default) requires drilling all the way down to a day; + * `'months'` commits as soon as a month is picked; `'years'` commits on + * year selection. Only meaningful when `monthYearSelectType='grid'`. + * @default days + */ + selectionLevel?: CalendarView; + /** + * Forwarded to the underlying `TextField`. `id`, `label`, `value`, and + * `onChange` are owned by `DateTimeField`. + */ + inputProps?: Omit; +} + +const pad = (n: number) => String(n).padStart(2, '0'); + +const getTimeOf = (d: Date | undefined): string => { + if (!d) return '00:00'; + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +}; + +const combineDateTime = (date: Date, time: string): Date => { + const [hStr, mStr] = time.split(':'); + const h = Number(hStr); + const m = Number(mStr); + const result = new Date(date); + result.setHours(Number.isFinite(h) ? h : 0, Number.isFinite(m) ? m : 0, 0, 0); + return result; +}; + +const isDate = (val: unknown): val is Date => val instanceof Date; + +const formatNativeValue = (d: Date | undefined): string => { + if (!d) return ''; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +}; + +const parseNativeValue = (input: string): Date | undefined => { + if (!input) return undefined; + const date = new Date(input); + return isNaN(date.getTime()) ? undefined : date; +}; + +const asRange = (val: DateTimeFieldValue | undefined): DateTimeRange => { + if (!val || isDate(val)) return {}; + return val; +}; + +const asSingle = (val: DateTimeFieldValue | undefined): Date | undefined => (isDate(val) ? val : undefined); + +export const DateTimeField = React.forwardRef((props, ref) => { + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { getLabel } = useLabels(); + const breakpoint = useBreakpoint(props.defaultServerBreakpoint); + const isMobile = isBreakpointBelow(breakpoint, 'md'); + const { + layout = 'side-by-side', + useNativePicker = false, + monthYearSelectType = 'dropdown', + timeGridVariant, + showOutsideDays = true, + availableTimes, + timeHeading = getLabel('dateTimeField.timeHeading'), + } = getCurrentBreakpointProps(props); + + const { + id, + label, + placeholder, + className, + value, + defaultValue, + onChange, + required, + readOnly, + disabled, + minDate, + maxDate, + disablePast, + disableFuture, + stepMinutes = 15, + initialMonth, + locale = et, + localeCode = 'et-EE', + selectTimeLabel = getLabel('dateTimeField.selectTime'), + backLabel = getLabel('dateTimeField.back'), + mode = 'single', + selectionLevel = 'days', + inputProps, + } = props; + + const isRange = mode === 'range'; + const useNative = useNativePicker && !isRange; + const effectiveLayout: DateTimeFieldLayout = isRange ? 'side-by-side' : layout; + const resolvedGridVariant: 'button' | 'radio' = + timeGridVariant ?? (effectiveLayout === 'multi-step' ? 'radio' : 'button'); + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(defaultValue); + const currentValue = isControlled ? value : internalValue; + const rangeValue = isRange ? asRange(currentValue) : ({} as DateTimeRange); + const singleValue = isRange ? undefined : asSingle(currentValue); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState('date'); + const [view, setView] = useState('days'); + const monthAnchor = isRange ? rangeValue.from : singleValue; + const [currentMonth, setCurrentMonth] = useState(() => monthAnchor ?? initialMonth ?? new Date()); + + const monthAnchorRef = useRef(monthAnchor); + monthAnchorRef.current = monthAnchor; + + useEffect(() => { + if (!open) { + setStep('date'); + setView('days'); + setCurrentMonth(monthAnchorRef.current ?? initialMonth ?? new Date()); + } + }, [open, initialMonth]); + + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(localeCode, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }), + [localeCode] + ); + + const shortDateFormatter = useMemo( + () => + new Intl.DateTimeFormat(localeCode, { + day: '2-digit', + month: '2-digit', + year: '2-digit', + }), + [localeCode] + ); + + const formatSingle = useCallback( + (d: Date | undefined): string => { + if (!d) return ''; + return `${dateFormatter.format(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + }, + [dateFormatter] + ); + + const formatValue = useCallback( + (val: DateTimeFieldValue | undefined): string => { + if (!val) return ''; + if (isDate(val)) return formatSingle(val); + const from = formatSingle(val.from); + const to = formatSingle(val.to); + if (!from && !to) return ''; + if (!to) return from; + if (!from) return to; + return `${from} – ${to}`; + }, + [formatSingle] + ); + + const parseDateTimeText = useMemo(() => { + const localeParts = getLocaleDateParts(dateFormatter); + const { fieldOrder } = localeParts; + const regex = new RegExp(`^${buildDateRegexSource(localeParts)}\\s+(\\d{2}):(\\d{2})$`); + + return (input: string): Date | undefined => { + const match = input.trim().match(regex); + if (!match) return undefined; + + const values: Partial> = {}; + fieldOrder.forEach((field, i) => { + values[field] = Number(match[i + 1]); + }); + const hour = Number(match[fieldOrder.length + 1]); + const minute = Number(match[fieldOrder.length + 2]); + + const { day, month, year } = values; + if (day === undefined || month === undefined || year === undefined) return undefined; + if (hour > 23 || minute > 59) return undefined; + + const date = new Date(year, month - 1, day, hour, minute, 0, 0); + if ( + isNaN(date.getTime()) || + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; + } + return date; + }; + }, [dateFormatter]); + + const [inputText, setInputText] = useState(() => formatValue(currentValue)); + + useEffect(() => { + setInputText(formatValue(currentValue)); + }, [currentValue, formatValue]); + + const floating = useFloating({ + open, + onOpenChange: setOpen, + placement: 'bottom-end', + middleware: [ + offset(CALENDAR_POPOVER_OFFSET), + flip(), + shift({ padding: CALENDAR_POPOVER_PADDING }), + size({ + padding: CALENDAR_POPOVER_PADDING, + apply({ availableWidth, elements }) { + elements.floating.style.maxWidth = `${availableWidth}px`; + }, + }), + ], + whileElementsMounted: autoUpdate, + }); + const { refs, context, x, y, strategy } = floating; + const interactions = useInteractions([useDismiss(context), useRole(context, { role: 'dialog' })]); + + const textFieldRef = React.useRef(null); + + const setTextFieldRef = useCallback( + (node: TextFieldForwardRef | null) => { + textFieldRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref] + ); + + useEffect(() => { + if (textFieldRef.current?.inner) { + refs.setReference(textFieldRef.current.inner); + } + }, [refs]); + + const updateValue = (next: DateTimeFieldValue | undefined) => { + if (!isControlled) setInternalValue(next); + onChange?.(next); + setInputText(formatValue(next)); + }; + + const handleCalendarSelect: OnSelectHandler = (selected) => { + if (isRange) { + const range = selected as DateRange | undefined; + if (!range || (!range.from && !range.to)) { + updateValue(undefined); + return; + } + const fromTime = getTimeOf(rangeValue.from); + const toTime = getTimeOf(rangeValue.to); + const next: DateTimeRange = {}; + if (range.from) next.from = combineDateTime(range.from, fromTime); + if (range.to) next.to = combineDateTime(range.to, toTime); + updateValue(next); + return; + } + if (selected instanceof Date) { + updateValue(combineDateTime(selected, getTimeOf(singleValue))); + } else if (selected === undefined) { + updateValue(undefined); + } + }; + + const handleApplyValue = (date: Date) => { + if (isRange) { + updateValue({ from: combineDateTime(date, getTimeOf(rangeValue.from)) }); + return; + } + updateValue(combineDateTime(date, getTimeOf(singleValue))); + }; + + const handleTimeSelect = (time: string) => { + const baseDate = singleValue ?? new Date(); + updateValue(combineDateTime(baseDate, time)); + if (effectiveLayout === 'multi-step' && availableTimes) setOpen(false); + }; + + const handleRangeTimeSelect = (kind: 'from' | 'to') => (time: string) => { + const baseDate = rangeValue[kind] ?? rangeValue.from ?? new Date(); + updateValue({ ...rangeValue, [kind]: combineDateTime(baseDate, time) }); + }; + + const handleInputChange = (newText: string) => { + setInputText(newText); + + if (newText === '') { + updateValue(undefined); + return; + } + + if (useNative) { + const parsed = parseNativeValue(newText); + if (!parsed) return; + updateValue(parsed); + setCurrentMonth(parsed); + return; + } + + if (isRange) return; + + const parsed = parseDateTimeText(newText); + if (!parsed) return; + + updateValue(parsed); + setCurrentMonth(parsed); + }; + + const openNativePicker = () => { + const input = textFieldRef.current?.input as HTMLInputElement | undefined; + if (!input) return; + if (typeof input.showPicker === 'function') { + input.showPicker(); + return; + } + input.focus(); + }; + + const handleIconClick = () => { + if (readOnly || disabled) return; + if (useNative) { + openNativePicker(); + return; + } + setOpen((prev) => !prev); + }; + + const disabledMatchers: Matcher[] = buildDisabledMatchers({ + minDate, + maxDate, + disablePast, + disableFuture, + }); + + const calendarFooter = + effectiveLayout === 'multi-step' ? ( +
+ +
+ ) : undefined; + + const calendarElement = ( + + ); + + const isWheelMode = !availableTimes || availableTimes.length === 0; + + const timePickerElement = ( + + ); + + const renderRangeTimePicker = (kind: 'from' | 'to') => ( + + ); + + const textFieldProps: TextFieldProps = { + ...(inputProps as TextFieldProps), + id, + label, + value: useNative ? formatNativeValue(singleValue) : inputText, + placeholder, + readOnly: readOnly || (!useNative && !!availableTimes && !!currentValue), + icon: 'calendar_today', + isClearable: true, + required, + disabled, + onIconClick: handleIconClick, + onChange: handleInputChange, + className: cn(styles['tedi-date-time-field__textfield'], inputProps?.className, { + [styles['tedi-date-time-field__icon--disabled']]: disabled || readOnly, + }), + input: { + ...(inputProps?.input as UnknownType), + type: useNative ? 'datetime-local' : 'text', + 'aria-expanded': useNative ? undefined : open, + }, + }; + + return ( + <> +
+ +
+ + {!useNative && ( + + {open && !disabled && ( + +
+ {isRange ? ( +
+ {calendarElement} + + ) : effectiveLayout === 'side-by-side' ? ( +
+ {calendarElement} + + ) : step === 'date' ? ( + calendarElement + ) : ( +
+
+ + + {shortDateFormatter.format(singleValue ?? new Date())} + +
+
{timePickerElement}
+
+ )} +
+ + )} + + )} + + ); +}); + +DateTimeField.displayName = 'DateTimeField'; + +export default DateTimeField; diff --git a/src/tedi/components/form/time-field/time-field.module.scss b/src/tedi/components/form/time-field/time-field.module.scss index a708ed034..eb6d4a8dd 100644 --- a/src/tedi/components/form/time-field/time-field.module.scss +++ b/src/tedi/components/form/time-field/time-field.module.scss @@ -1,40 +1,10 @@ -.tedi-time-field__textfield { - button:not([data-name='closing-button']):last-child { - display: flex; - align-items: center; - justify-content: center; - width: var(--button-xs-icon-size); - min-height: var(--form-field-button-height-sm); - max-height: var(--form-field-button-height-sm); - border-radius: var(--button-radius-sm); - - > span { - color: var(--button-main-neutral-text-default); - } - - &:hover { - background-color: var(--form-datepicker-date-hover); - } - } - - &[aria-expanded='true'] button:not([data-name='closing-button']):last-child { - background-color: var(--form-datepicker-date-hover); +@use '../field-icon-button' as field-icon-button; - > span { - color: var(--button-main-neutral-text-active); - } - } +.tedi-time-field__textfield { + @include field-icon-button.states; &--disabled { - button:not([data-name='closing-button']):last-child { - > span { - color: var(--button-main-disabled-general-text); - } - - &:hover { - background: none; - } - } + @include field-icon-button.disabled; } input[type='time'] { @@ -82,11 +52,8 @@ width: 100%; } -.tedi-time-field__icon--disabled button:not([data-name='closing-button']):last-child { - &:hover { - cursor: auto; - background: none; - } +.tedi-time-field__icon--disabled { + @include field-icon-button.icon-only-disabled; } .tedi-time-field__picker-wrapper { diff --git a/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx index df7862a49..8d5532733 100644 --- a/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx +++ b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx @@ -86,13 +86,6 @@ export const TimeWheel: React.FC = ({ if (isHour) isProgrammaticScrollHour.current = true; else isProgrammaticScrollMinute.current = true; - // 'instant' (not 'auto') so this jump is synchronous — the column's - // CSS `scroll-behavior: smooth` would otherwise turn this into a ~300ms - // animation, and the in-flight scroll events during that animation would - // get mis-classified as user scrolls (the rAF clears the programmatic - // flag after one frame, while the animation keeps firing for ~280ms more) - // and fire onChange with every intermediate index — which is how the - // wheel ended up snapping back to 00:00 on open. element.scrollTo({ top: target, behavior: 'instant' }); requestAnimationFrame(() => { @@ -119,9 +112,6 @@ export const TimeWheel: React.FC = ({ setActiveMinuteIndex(minuteIndex); }, [hours, minutes, selectedHour, selectedMinute]); - // Callback refs — updated every render so scrollend listeners always use fresh - // closure values without needing to re-register. This avoids the stale-closure - // problem that arises when event listeners are registered once in useEffect. const processHourScrollEnd = useRef<() => void>(() => {}); processHourScrollEnd.current = () => { const el = hourRef.current; @@ -177,10 +167,6 @@ export const TimeWheel: React.FC = ({ } }; - // Primary path: scrollend fires once after the CSS snap animation completes, - // so scrollTop is always at a valid snap point when we read it. This prevents - // the flicker caused by the debounced scroll handler reading an intermediate - // scrollTop mid-snap-animation (most visible on high-refresh-rate trackpads). useEffect(() => { const hourEl = hourRef.current; const minuteEl = minuteRef.current; @@ -198,14 +184,6 @@ export const TimeWheel: React.FC = ({ }; }, []); - // Fallback for browsers without scrollend support: debounce at 150ms so the - // handler fires well after the CSS snap animation has finished emitting scroll - // events. processScrollEnd clears this timer when scrollend fires first. - // - // The active-index state is updated on every scroll event so the highlight - // follows the wheel in real time — this gives instant visual feedback without - // waiting for scrollend. onChange is intentionally NOT called here; it fires - // only in processHourScrollEnd once the scroll has fully settled. const handleHourScroll = () => { if (!hourRef.current || isProgrammaticScrollHour.current) return; @@ -269,9 +247,6 @@ export const TimeWheel: React.FC = ({ if (currentIndex === -1) return; let nextIndex = -1; - // Track whether the new index wrapped around (e.g. 59 → 00 going down, - // 00 → 59 going up). When it does, animate the scroll instantly so we - // don't smooth-scroll across the entire wheel. let wrapped = false; switch (event.key) { diff --git a/src/tedi/components/form/time-picker/time-picker.module.scss b/src/tedi/components/form/time-picker/time-picker.module.scss index cd887518d..40174a518 100644 --- a/src/tedi/components/form/time-picker/time-picker.module.scss +++ b/src/tedi/components/form/time-picker/time-picker.module.scss @@ -1,9 +1,12 @@ .tedi-time-picker__wheel { + --tedi-time-wheel-height: 12.5rem; + --tedi-time-wheel-fade: 5rem; + position: relative; z-index: var(--z-index-dropdown); display: flex; - width: 160px; - height: 200px; + width: 10rem; + height: var(--tedi-time-wheel-height); overflow: hidden; user-select: none; background: var(--card-background-primary); @@ -50,7 +53,7 @@ position: relative; z-index: 2; flex: 1; - padding: 80px 0; + padding: var(--tedi-time-wheel-fade) 0; overflow-y: scroll; overscroll-behavior-y: contain; will-change: scroll-position; @@ -59,7 +62,7 @@ -webkit-overflow-scrolling: touch; scrollbar-width: none; -ms-overflow-style: none; - scroll-padding: 80px; + scroll-padding: var(--tedi-time-wheel-fade); &:first-child { border-right: 1px solid var(--general-border-primary); diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 962d3b9da..3da9fdf13 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -131,9 +131,6 @@ export const Dropdown = (props: DropdownProps) => { const open = controlledOpen ?? uncontrolledOpen; - // Re-seed the active index every time the dropdown closes, so the next open - // starts with the caller-provided "current selection" pre-focused — not - // whatever activeIndex useListNavigation last left behind. React.useEffect(() => { if (!open) setActiveIndex(defaultActiveIndex ?? null); }, [open, defaultActiveIndex]); @@ -168,9 +165,6 @@ export const Dropdown = (props: DropdownProps) => { activeIndex, onNavigate: setActiveIndex, loop: true, - // When the caller passes `defaultActiveIndex`, treat that item as the - // current selection and force focus to land on it when the dropdown - // opens — regardless of whether it was opened via click or keyboard. selectedIndex: defaultActiveIndex ?? null, focusItemOnOpen: defaultActiveIndex !== null ? true : 'auto', }), diff --git a/src/tedi/index.ts b/src/tedi/index.ts index ae73d1fb0..2f465a2aa 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -45,6 +45,7 @@ export * from './components/form/time-picker/time-picker'; export * from './components/form/checkbox/checkbox'; export * from './components/form/slider/slider'; export * from './components/form/date-field/date-field'; +export * from './components/form/date-time-field/date-time-field'; export * from './components/form/input-group'; export * from './components/form/field/field'; export * from './components/overlays/tooltip'; diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 6fd6f501a..5e05a0a47 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -299,6 +299,27 @@ export const labelsMap = validateDefaultLabels({ en: 'Close modal', ru: 'Закрыть модальное окно', }, + 'dateTimeField.timeHeading': { + description: 'Heading rendered above the time picker in DateTimeField', + components: ['DateTimeField'], + et: 'Kellaaeg', + en: 'Time', + ru: 'Время', + }, + 'dateTimeField.selectTime': { + description: 'Footer link in the multi-step DateTimeField calendar that advances to the time picker', + components: ['DateTimeField'], + et: 'Vali kellaaeg', + en: 'Select time', + ru: 'Выбрать время', + }, + 'dateTimeField.back': { + description: 'Back link in the multi-step DateTimeField time picker that returns to the calendar', + components: ['DateTimeField'], + et: 'Tagasi', + en: 'Back', + ru: 'Назад', + }, 'select.loading': { description: 'Text when select options are loading', components: ['select'],