diff --git a/skills/tedi-react/SKILL.md b/skills/tedi-react/SKILL.md index 2cc89bbb..8eac42d4 100644 --- a/skills/tedi-react/SKILL.md +++ b/skills/tedi-react/SKILL.md @@ -154,7 +154,7 @@ const [email, setEmail] = useState(''); setAgreed(checked)} /> ``` -Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `DateField`, `FileUpload`, `FileDropzone`. +Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `DateField`, `TimeField`, `FileUpload`, `FileDropzone`. ## Theming diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index ea9b3486..b54f27c1 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -338,6 +338,64 @@ The ref shape mirrors TextField (`{ input, wrapper }`). In `'multiple'` mode the ``` +### TimeField +**Props:** `TimeFieldProps` | bp, form +- `id: string` (required), `label: string` (required) +- `value?: string`, `defaultValue?: string` — `"HH:mm"` 24-hour format +- `onChange?: (time: string) => void` +- `placeholder?: string` +- `required?: boolean`, `readOnly?: boolean` +- `stepMinutes?: number = 1` — minute increment for the picker wheel / grid +- `availableTimes?: string[]` — limit selectable times to a fixed list (`["09:00", "09:30", …]`); switches the popover to grid mode +- `inputProps?: Omit` — pass-through to the underlying input +- `className?: string` +- **Breakpoint-aware:** `useNativePicker?: boolean = false` (swap to ``; ignores `availableTimes`), `showPicker?: boolean = true`, `timePickerTrigger?: 'input' | 'button' = 'button'`, `availableTimesVariant?: 'grid-buttons' | 'grid-radio' | 'dropdown'` — which variant the picker renders when `availableTimes` is set + +```tsx + + +// Constrain to specific slots, render as a radio-button grid + + +// Native picker on mobile, custom wheel on desktop + +``` + +### TimePicker +> **For plain time inputs use `TimeField`.** TimePicker is the lower-level picker primitive — reach for it only when you need a standalone, always-visible time selector (scheduling UI, custom popover, side-by-side with a calendar in a DateTime composite). + +**Props:** `TimePickerProps` | form +- `value?: string`, `defaultValue?: string` — `"HH:mm"` +- `onChange?: (time: string) => void` +- `stepMinutes?: number = 1` — minute increment for the wheel +- `availableTimes?: string[]` — switches from scroll-wheel mode to a predefined-slots grid +- `gridVariant?: 'button' | 'radio' = 'button'` — only used with `availableTimes` +- `bordered?: boolean = true` — set `false` when embedding inside a parent that already provides its own surface (e.g. alongside a Calendar) +- `className?: string` + +The wheel column supports full keyboard navigation: `ArrowUp` / `ArrowDown` and `PageUp` / `PageDown` cycle through the column (wrap at both ends), `Home` / `End` jump to the bounds, `Enter` / `Space` commit the highlighted value. + +```tsx +import { TimePicker } from '@tedi-design-system/react/tedi'; + + + +// Predefined slots + +``` + ### FileUpload **Props:** `FileUploadProps` | form - `id: string` (required), `name: string` (required) diff --git a/skills/tedi-react/references/forms.md b/skills/tedi-react/references/forms.md index 54cccc77..04faa18e 100644 --- a/skills/tedi-react/references/forms.md +++ b/skills/tedi-react/references/forms.md @@ -15,6 +15,7 @@ TEDI form controls support both **controlled** and **uncontrolled** modes, follo | ChoiceGroup | `ChoiceGroupValue` | Radio/checkbox groups, segmented variant | | Search | `string` | Search button, onSearch callback | | DateField | `Date \| Date[] \| DateRange` | Single/multiple/range, manual input, min/max, native picker, breakpoint-aware | +| TimeField | `string` (`"HH:mm"`) | Wheel / grid picker, native fallback, stepMinutes, availableTimes | | FileUpload | `FileUploadFile[]` | Multi-file, validation, loading states | | FileDropzone | `FileUploadFile[]` | Drag-and-drop | @@ -177,6 +178,50 @@ const [date, setDate] = useState(); /> ``` +## TimeField + +The value is always a `"HH:mm"` 24-hour string. The popover defaults to a wheel picker; set `availableTimes` to switch to a fixed-slot grid, or `useNativePicker` to drop the custom UI entirely. + +```tsx +import { TimeField } from '@tedi-design-system/react/tedi'; + +// Wheel picker, 15-minute step + + +// Constrain to predefined slots, render as a radio-button grid + + +// Native picker on mobile, custom wheel on desktop + +``` + +For an always-visible time selector (e.g. side-by-side with a calendar, or inside a custom popover) use the lower-level `TimePicker` directly: + +```tsx +import { TimePicker } from '@tedi-design-system/react/tedi'; + + +``` + ## Checkbox & Radio ```tsx @@ -283,6 +328,7 @@ import { FileUpload, FileDropzone } from '@tedi-design-system/react/tedi'; - **Select:** `onChange?: (value: ISelectOption | ISelectOption[] | null) => void` - **NumberField:** `onChange?: (value: number) => void` - **DateField:** `onSelect?: OnSelectHandler` — value shape depends on `mode` (`'single'` → `Date`, `'multiple'` → `Date[]`, `'range'` → `DateRange`) +- **TimeField / TimePicker:** `onChange?: (time: string) => void` — value is always `"HH:mm"` 24-hour format (empty string when cleared) ## Disabled State diff --git a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.module.scss b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.module.scss index ed139d26..b90c01c3 100644 --- a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.module.scss +++ b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.module.scss @@ -47,7 +47,11 @@ } } - &:focus-visible { + // The outline fires when the card itself is focused (checkbox variant) or + // when the inner radio input is focused (radio variant — the wrapper has + // tabIndex=-1, so only the input receives focus). + &:focus-visible, + &:has(input:focus-visible) { z-index: 5; outline: 2px solid var(--form-checkbox-radio-default-border-selected); outline-offset: 2px; diff --git a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.spec.tsx b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.spec.tsx index 1e2d8c94..38805ad2 100644 --- a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.spec.tsx +++ b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.spec.tsx @@ -125,4 +125,19 @@ describe('ChoiceGroupItem', () => { fireEvent.click(input); expect(mockInputClick).not.toHaveBeenCalled(); }); + + it('makes the outer wrapper non-tabbable for radio type (arrow-navigated group)', () => { + const { container } = renderWithContext({ type: 'radio', variant: 'card' }); + const card = container.querySelector('.tedi-choice-group-item') as HTMLElement; + expect(card).toHaveAttribute('tabIndex', '-1'); + expect(card).not.toHaveAttribute('role'); + expect(card).not.toHaveAttribute('aria-checked'); + }); + + it('keeps the outer wrapper tabbable with role=checkbox for checkbox type', () => { + const { container } = renderWithContext({ type: 'checkbox', variant: 'card' }); + const card = container.querySelector('.tedi-choice-group-item') as HTMLElement; + expect(card).toHaveAttribute('tabIndex', '0'); + expect(card).toHaveAttribute('role', 'checkbox'); + }); }); diff --git a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.tsx b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.tsx index 85de3d1b..b08c7dab 100644 --- a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.tsx +++ b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.tsx @@ -91,14 +91,16 @@ export const ChoiceGroupItem = (props: ExtendedChoiceGroupItemProps): React.Reac document.getElementById(id)?.click(); }; + + const isRadio = type === 'radio'; return (
{variant === 'default' || showIndicator ? ( span { + color: var(--button-main-neutral-text-active); + } } &--disabled { diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx index f054f853..5776bea2 100644 --- a/src/tedi/components/form/date-field/date-field.stories.tsx +++ b/src/tedi/components/form/date-field/date-field.stories.tsx @@ -85,6 +85,58 @@ export const Size: StoryObj = { }, }; +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', + }, + }, +}; + export const FieldOptions: StoryFn = () => { const [shortcutValue, setShortcutValue] = useState(undefined); diff --git a/src/tedi/components/form/textfield/textfield.module.scss b/src/tedi/components/form/textfield/textfield.module.scss index 1dfda1e9..b9a594e3 100644 --- a/src/tedi/components/form/textfield/textfield.module.scss +++ b/src/tedi/components/form/textfield/textfield.module.scss @@ -175,6 +175,10 @@ $input-padding-right-map: ( color: var(--form-input-text-filled); } + &:not(div, :disabled):active { + color: var(--button-main-neutral-text-active); + } + &:disabled { cursor: initial; } @@ -198,6 +202,7 @@ div.tedi-textfield__icon-wrapper { .tedi-textfield__feedback-wrapper { display: flex; + margin-top: var(--form-field-outer-spacing); } .tedi-textfield__separator { diff --git a/src/tedi/components/form/textfield/textfield.tsx b/src/tedi/components/form/textfield/textfield.tsx index 7f353748..9ea5c1b3 100644 --- a/src/tedi/components/form/textfield/textfield.tsx +++ b/src/tedi/components/form/textfield/textfield.tsx @@ -344,8 +344,10 @@ export const TextField = forwardRef((props, const renderIcon = useCallback(() => { if (!icon) return null; + const isInteractiveIcon = Boolean(onIconClick); + const smallIconSize = isInteractiveIcon ? 18 : 16; const defaultIconProps: Partial = { - size: size === 'large' ? 24 : size === 'small' ? 16 : 18, + size: size === 'large' ? 24 : size === 'small' ? smallIconSize : 18, className: styles['tedi-textfield__icon'], }; diff --git a/src/tedi/components/form/time-field/time-field-helpers.ts b/src/tedi/components/form/time-field/time-field-helpers.ts new file mode 100644 index 00000000..e30ddbc5 --- /dev/null +++ b/src/tedi/components/form/time-field/time-field-helpers.ts @@ -0,0 +1,141 @@ +/* istanbul ignore file */ +export const ITEM_HEIGHT = 40; +export const TIMEPICKER_OFFSET = 6; + +/** + * Generates an array of hours (00–23) + */ +export const generateHours = (): string[] => { + return Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0')); +}; + +/** + * Generates minute values based on a step (e.g. 5, 10, 15) + */ +export const generateMinutes = (stepMinutes: number): string[] => { + const step = Math.max(1, stepMinutes ?? 1); + const mins: string[] = []; + + for (let i = 0; i < 60; i += step) { + mins.push(i.toString().padStart(2, '0')); + } + + return mins; +}; + +/** + * Finds the closest available minute to a target value + */ +export const findClosestMinute = (target: string, mins: string[]): string => { + if (!mins.length) return '00'; + + const t = Number(target); + + if (isNaN(t)) return mins[0]; + + return mins.reduce((best, curr) => { + const diff = Math.abs(Number(curr) - t); + const bestDiff = Math.abs(Number(best) - t); + + return diff < bestDiff || (diff === bestDiff && Number(curr) > Number(best)) ? curr : best; + }, mins[0]); +}; + +/** + * Parses HH:mm time string + */ +export const parseTime = (time: string): { hour: string; minute: string } => { + if (!time || !time.includes(':')) { + return { hour: '00', minute: '00' }; + } + + const [hour, minute] = time.split(':'); + return { + hour: hour.padStart(2, '0'), + minute: minute.padStart(2, '0'), + }; +}; + +/** + * Returns nearest wheel index from scroll position + */ +export const snapToNearestItem = (scrollTop: number, length: number): number => { + const index = Math.round(scrollTop / ITEM_HEIGHT); + + return Math.max(0, Math.min(index, length - 1)); +}; + +/** + * Returns scrollTop position for index + */ +export const getScrollTopForIndex = (index: number) => index * ITEM_HEIGHT; + +/** + * Checks if scroll correction is needed + */ +export const needsScrollCorrection = (current: number, target: number, tolerance = 1) => + Math.abs(current - target) > tolerance; + +/** + * Scrolls element to index + */ +export const scrollToIndex = (element: HTMLDivElement, index: number, behavior: ScrollBehavior = 'auto') => { + element.scrollTo({ + top: getScrollTopForIndex(index), + behavior, + }); +}; + +/** + * Clears scroll timeout safely + */ +export const clearScrollTimeout = (timeout?: NodeJS.Timeout) => { + if (timeout) clearTimeout(timeout); +}; + +export const isValidTime = (time: string): boolean => { + if (!time) return false; + const regex = /^([01][0-9]|2[0-3]):[0-5][0-9]$/; + return regex.test(time.trim()); +}; + +/** + * Normalizes common typing patterns into HH:mm or returns null if impossible + * Examples: + * "9:5" → "09:05" + * "14:5" → "14:05" + * "2359" → "23:59" + * "4:89" → null + */ +export const normalizeTime = (input: string): string | null => { + const cleaned = input.trim(); + if (!cleaned) return ''; + + if (isValidTime(cleaned)) return cleaned; + + const digitsOnly = cleaned.replace(/[^0-9]/g, ''); + if (digitsOnly.length === 3) { + const h = digitsOnly.slice(0, 1).padStart(2, '0'); + const m = digitsOnly.slice(1); + const candidate = `${h}:${m}`; + return isValidTime(candidate) ? candidate : null; + } + if (digitsOnly.length === 4) { + const h = digitsOnly.slice(0, 2); + const m = digitsOnly.slice(2); + const candidate = `${h}:${m}`; + return isValidTime(candidate) ? candidate : null; + } + + if (cleaned.includes(':')) { + const [hPart, mPart] = cleaned.split(':'); + const hour = parseInt(hPart, 10); + const min = parseInt(mPart, 10); + + if (!isNaN(hour) && !isNaN(min) && hour >= 0 && hour <= 23 && min >= 0 && min <= 59) { + return `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`; + } + } + + return null; +}; diff --git a/src/tedi/components/form/time-field/time-field.module.scss b/src/tedi/components/form/time-field/time-field.module.scss new file mode 100644 index 00000000..a708ed03 --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.module.scss @@ -0,0 +1,94 @@ +.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); + + > span { + color: var(--button-main-neutral-text-active); + } + } + + &--disabled { + button:not([data-name='closing-button']):last-child { + > span { + color: var(--button-main-disabled-general-text); + } + + &:hover { + background: none; + } + } + } + + input[type='time'] { + appearance: none; + + &::-webkit-calendar-picker-indicator { + display: none !important; + width: 0; + height: 0; + padding: 0; + margin: 0; + appearance: none; + pointer-events: none; + opacity: 0; + } + + &::-webkit-inner-spin-button, + &::-webkit-clear-button { + display: none; + appearance: none; + } + } + + input[type='time'][value='']:not(:focus)::-webkit-datetime-edit { + /* stylelint-disable-next-line scale-unlimited/declaration-strict-value */ + color: transparent; + } + + input[type='time'][value='']:not(:focus)::before { + position: absolute; + color: var(--form-input-text-placeholder); + content: attr(placeholder); + } + + input[type='time'][value='']:focus::-webkit-datetime-edit { + color: inherit; + } + + input[type='time'][value='']:focus::before { + content: ''; + } +} + +.tedi-time-field__container { + width: 100%; +} + +.tedi-time-field__icon--disabled button:not([data-name='closing-button']):last-child { + &:hover { + cursor: auto; + background: none; + } +} + +.tedi-time-field__picker-wrapper { + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); +} diff --git a/src/tedi/components/form/time-field/time-field.spec.tsx b/src/tedi/components/form/time-field/time-field.spec.tsx new file mode 100644 index 00000000..e7366f9b --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.spec.tsx @@ -0,0 +1,262 @@ +/* eslint-disable react/display-name */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { TimeField } from './time-field'; + +jest.mock('../../../helpers', () => ({ + useBreakpointProps: () => ({ + getCurrentBreakpointProps: (props: any) => ({ + ...props, + }), + }), + useBreakpoint: () => 'xs', + isBreakpointBelow: (current: string, target: string) => { + const order = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + return order.indexOf(current) < order.indexOf(target); + }, +})); + +jest.mock('@floating-ui/react', () => ({ + autoUpdate: jest.fn(), + flip: jest.fn(), + offset: jest.fn(), + shift: jest.fn(), + useClick: () => ({}), + useDismiss: () => ({}), + useRole: () => ({}), + useInteractions: () => ({ + getReferenceProps: () => ({}), + getFloatingProps: (props: any) => props, + }), + useFloating: () => ({ + refs: { + setReference: jest.fn(), + setFloating: jest.fn(), + }, + context: {}, + x: 0, + y: 0, + strategy: 'absolute', + }), + FloatingPortal: ({ children }: any) => <>{children}, + FloatingFocusManager: ({ children }: any) => <>{children}, +})); + +jest.mock('../../overlays/dropdown', () => { + const Dropdown = ({ children }: any) =>
{children}
; + Dropdown.Trigger = ({ children }: any) =>
{children}
; + Dropdown.Content = ({ children }: any) =>
{children}
; + Dropdown.Item = ({ children, onClick }: any) => ; + + return { Dropdown }; +}); + +jest.mock('../textfield/textfield', () => { + const ReactModule = jest.requireActual('react'); + return ReactModule.forwardRef((props: any, ref: any) => { + const inputRef = ReactModule.useRef(null); + + ReactModule.useImperativeHandle(ref, () => ({ + input: inputRef.current, + inner: inputRef.current, + })); + + return ( +
+ props.onChange?.(e.target.value)} + onBlur={props.onBlur} + /> + +
+ ); + }); +}); + +jest.mock('../time-picker/time-picker', () => ({ + TimePicker: ({ value, onChange }: any) => ( +
+
{value}
+ +
+ ), +})); + +describe('TimeField', () => { + it('renders with default value', () => { + render(); + + expect(screen.getByTestId('textfield-input')).toHaveValue('10:00'); + }); + + it('works as controlled component', () => { + const { rerender } = render(); + + expect(screen.getByTestId('textfield-input')).toHaveValue('08:00'); + + rerender(); + + expect(screen.getByTestId('textfield-input')).toHaveValue('09:00'); + }); + + it('updates internal state when uncontrolled', async () => { + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId('textfield-input'); + + await user.clear(input); + await user.type(input, '11:15'); + + expect(input).toHaveValue('11:15'); + }); + + it('calls onChange when value changes', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render(); + + const input = screen.getByTestId('textfield-input'); + + await user.clear(input); + await user.type(input, '12:00'); + + expect(onChange).toHaveBeenCalled(); + }); + + it('opens custom picker when icon clicked (button trigger)', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId('icon')); + + expect(screen.getByTestId('timepicker')).toBeInTheDocument(); + }); + + it('closes custom picker when icon clicked again (button trigger)', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId('icon')); + expect(screen.getByTestId('timepicker')).toBeInTheDocument(); + + await user.click(screen.getByTestId('icon')); + expect(screen.queryByTestId('timepicker')).not.toBeInTheDocument(); + }); + + it('does NOT open picker when readOnly', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId('icon')); + + expect(screen.queryByTestId('timepicker')).not.toBeInTheDocument(); + }); + + it('updates value from TimePicker', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId('icon')); + await user.click(screen.getByText('pick')); + + expect(screen.getByTestId('textfield-input')).toHaveValue('12:30'); + }); + + it('renders dropdown variant when availableTimes + dropdown', () => { + render(); + + expect(screen.getByText('09:00')).toBeInTheDocument(); + expect(screen.getByText('10:00')).toBeInTheDocument(); + }); + + it('renders dropdown items when opened', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId('icon')); + + expect(screen.getByText('09:00')).toBeInTheDocument(); + expect(screen.getByText('10:00')).toBeInTheDocument(); + }); + + it('selects time from dropdown', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click(screen.getByText('10:00')); + + expect(onChange).toHaveBeenCalledWith('10:00'); + }); + + it('uses native picker path (focus/showPicker)', async () => { + const user = userEvent.setup(); + const showPicker = jest.fn(); + + render(); + + const input = screen.getByTestId('textfield-input'); + + Object.defineProperty(input, 'showPicker', { + value: showPicker, + }); + + await user.click(screen.getByTestId('icon')); + + expect(showPicker).toHaveBeenCalled(); + }); + + it('normalises a delimiter-less time on blur (e.g. "1155" -> "11:55")', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + const input = screen.getByTestId('textfield-input'); + await user.click(input); + await user.keyboard('1155'); + await user.tab(); + + await waitFor(() => expect(input).toHaveValue('11:55')); + expect(onChange).toHaveBeenLastCalledWith('11:55'); + }); + + it('leaves invalid input untouched on blur (no normalisation possible)', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + const input = screen.getByTestId('textfield-input'); + await user.click(input); + await user.keyboard('9999'); + onChange.mockClear(); + await user.tab(); + + expect(input).toHaveValue('9999'); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tedi/components/form/time-field/time-field.stories.tsx b/src/tedi/components/form/time-field/time-field.stories.tsx new file mode 100644 index 00000000..573f44b7 --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.stories.tsx @@ -0,0 +1,325 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { Text } from '../../base/typography/text/text'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { TextFieldProps } from '../textfield/textfield'; +import { TimeField, TimeFieldProps } from './time-field'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: 'Tedi-Ready/Components/Form/TimeField', + component: TimeField, + parameters: { + status: { + type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], + }, + controls: { + exclude: ['sm', 'md', 'lg', 'xl', 'xxl'], + }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.38.59?node-id=4662-91741&m=dev', + }, + }, +} as Meta; + +type Story = StoryObj; + +const Template: StoryFn = (args) => ( + + + + + +); +const sizeArray: TextFieldProps['size'][] = ['default', 'small']; + +interface TemplateMultipleProps extends TextFieldProps { + array: Type[]; + property: keyof TextFieldProps; +} + +const TemplateColumn: StoryFn = (args) => { + const { array, property } = args; + + return ( +
+ {array.map((value, key) => ( + + + {value ? value.charAt(0).toUpperCase() + value.slice(1) : ''} + + + + + + ))} +
+ ); +}; + +export const Default: Story = { + render: Template, + args: { + label: 'Time', + required: true, + stepMinutes: 1, + }, +}; + +export const Sizes: StoryObj = { + render: TemplateColumn, + + args: { + array: sizeArray, + property: 'size', + }, +}; + +const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const; + +export const States: StoryObj = { + render: () => ( +
+ {stateArray.map((state) => ( + + + {state} + + + + + + ))} + + + Success + + + + + + + + Error + + + + + +
+ ), + parameters: { + pseudo: { + hover: '#Hover', + focus: '#Focus', + active: '#Active', + }, + }, +}; + +export const FieldOptions: StoryFn = () => { + return ( + + +
+ + + +
+ +
+ ); +}; + +export const ValueType: StoryFn = () => { + return ( + + +
+ + + +
+ +
+ ); +}; + +export const OnClickType: Story = { + render: () => { + return ( + + + + Clock button is clickable + + + + + + Input is clickable + + + + + ); + }, + parameters: { + docs: { + description: { + story: 'timePickerTrigger prop allows you to open time picker either on input click or clock icon', + }, + }, + }, +}; + +export const PredefinedTimeSlots: Story = { + render: () => { + const [times, setTimes] = useState({ + dropdown: undefined as string | undefined, + grid: undefined as string | undefined, + }); + + const availableTimes = ['08:00', '08:30', '09:00', '09:15', '09:30', '10:00', '10:30', '11:00', '12:00']; + + const handleChange = (key: 'dropdown' | 'grid') => (newTime: string) => { + setTimes((prev) => ({ ...prev, [key]: newTime })); + }; + + return ( + + + + + + + + + + + + + + ); + }, +}; + +export const Dropdown: Story = { + render: () => { + const [time, setTime] = useState(); + const availableTimes = ['08:00', '08:30', '09:00', '09:15', '09:30', '10:00', '10:30', '11:00', '12:00']; + + return ( + + + + + + ); + }, +}; + +export const FieldWithoutPicker: Story = { + render: Template, + args: { + label: 'Time', + placeholder: 'hh:mm', + showPicker: false, + }, + parameters: { + docs: { + description: { + story: 'You can set showPicker=false if you only need to use the input and not the picker', + }, + }, + }, +}; + +export const CustomStep: Story = { + render: Template, + args: { + label: 'Time with 15-min steps', + stepMinutes: 15, + placeholder: 'hh:mm', + defaultValue: '12:30', + }, +}; + +export const ManualTyping: StoryFn = (args) => { + const [time, setTime] = useState('08:00'); + + return ( + + + setTime(val)} + label="Enter time manually" + placeholder="HH:mm" + /> + + + ); +}; + +export const NativePicker: Story = { + render: Template, + args: { + label: 'Time', + placeholder: 'hh:mm', + useNativePicker: true, + }, + parameters: { + docs: { + description: { + story: + 'Native time picker uses the browser’s built-in input[type="time"] UI instead of the custom TimePicker. Prefer this on mobile devices for better native UX, improved accessibility, and reduced UI complexity. It is also useful when you want to minimize bundle/UI overhead or align with platform conventions. Note: when enabled, availableTimes is ignored because native inputs do not support restricting selectable values.', + }, + }, + }, +}; diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx new file mode 100644 index 00000000..d6ac8b81 --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.tsx @@ -0,0 +1,331 @@ +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react'; +import cn from 'classnames'; +import React, { useEffect, useState } from 'react'; + +import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; +import { UnknownType } from '../../../types/commonTypes'; +import { Dropdown } from '../../overlays/dropdown'; +import TextField, { TextFieldForwardRef, TextFieldProps } from '../textfield/textfield'; +import { TimePicker } from '../time-picker/time-picker'; +import styles from './time-field.module.scss'; +import { normalizeTime, TIMEPICKER_OFFSET } from './time-field-helpers'; + +type TimeFieldBreakpointProps = { + /** + * If `true`, the field swaps the custom time-picker popover for the + * browser's native time picker (``). Works on both + * mobile and desktop — useful when the consumer wants to skip the custom + * UI entirely. + * Note: When using the native picker, the `availableTimes` prop is ignored. + * @default false + */ + useNativePicker?: boolean; + /** + * Determines how the time picker is triggered: + * - 'button' (default) – only clicking the icon opens the picker + * - 'input' – clicking anywhere in the input opens the picker + */ + timePickerTrigger?: 'input' | 'button'; + + /** + * Enables or disables the time picker popover. + * @default true + */ + showPicker?: boolean; + /** + * Variant of the available times: + * - 'grid-buttons' – buttons grid + * - 'grid-radio' – radio buttons grid + * - 'dropdown' – dropdown list + */ + availableTimesVariant?: 'grid-buttons' | 'grid-radio' | 'dropdown'; +}; + +export interface TimeFieldProps extends BreakpointSupport { + /** + * Unique identifier for the input field. + */ + id: string; + /** + * Label for the input field. Used for accessibility. + */ + label: string; + /** + * Current value of the time field (controlled). + */ + value?: string; + /** + * Initial value of the time field (uncontrolled). + */ + defaultValue?: string; + /** + * Callback fired when the time value changes. + */ + onChange?: (time: string) => void; + + /** + * Makes the input read-only. Picker can still be opened if showPicker is true. + * @default false + */ + readOnly?: boolean; + /** + * Marks the input as required. + */ + required?: boolean; + /** + * Placeholder text for the input field. + */ + placeholder?: string; + /** + * Additional props to pass to the underlying TextField component, + * excluding `id`, `label`, `value`, and `onChange`. + */ + inputProps?: Omit; + /** + * Step interval in minutes for the time picker. + * @default 1 + */ + stepMinutes?: number; + /** + * Additional CSS class for the container. + */ + className?: string; + /** + * Array of available times to show in the picker or dropdown. + */ + availableTimes?: string[]; +} + +export const TimeField: React.FC = (props) => { + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + + const { + id, + label, + value, + defaultValue, + onChange, + readOnly = false, + required, + placeholder, + inputProps, + stepMinutes = 1, + className, + availableTimes, + } = props; + + const { + useNativePicker = false, + timePickerTrigger = 'button', + showPicker = true, + availableTimesVariant = 'grid-buttons', + } = getCurrentBreakpointProps(props); + + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? ''); + + const currentValue = isControlled ? value : internalValue; + const [open, setOpen] = useState(false); + const isInputTrigger = timePickerTrigger === 'input'; + const shouldUseNativePicker = useNativePicker; + + const floating = useFloating({ + open, + onOpenChange: setOpen, + placement: isInputTrigger ? 'bottom-start' : 'bottom-end', + middleware: [offset(TIMEPICKER_OFFSET), flip(), shift()], + whileElementsMounted: autoUpdate, + }); + + const { refs, context, x, y, strategy } = floating; + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: 'listbox' }); + const shouldUseCustomInputTrigger = showPicker && isInputTrigger && !readOnly && !shouldUseNativePicker; + + const interactions = useInteractions([...(shouldUseCustomInputTrigger ? [click] : []), dismiss, role]); + + const updateTime = (time: string) => { + const cleaned = time.trim(); + + if (!isControlled) { + setInternalValue(cleaned); + } + + onChange?.(cleaned); + }; + + // Normalise common typed shorthands on blur (e.g. "1155" → "11:55", + // "9:5" → "09:05"). Doesn't run while the user is still typing — we keep + // the raw value visible until they tab/click away so the field doesn't + // fight mid-keystroke. Invalid input is left as-is for the consumer's + // validation to flag. + const handleInputBlur: React.FocusEventHandler = (event) => { + // Read off the target BEFORE running consumer's onBlur — React pools + // SyntheticEvents and `currentTarget` is nulled after the listener + // returns, so we must capture upfront. + const raw = (event.target as HTMLInputElement).value ?? ''; + (inputProps?.onBlur as React.FocusEventHandler | undefined)?.(event); + const normalised = normalizeTime(raw); + if (normalised !== null && normalised !== raw) { + updateTime(normalised); + } + }; + + useEffect(() => { + if (value !== undefined) { + setInternalValue(value); + } + }, [value]); + + const textFieldRef = React.useRef(null); + + useEffect(() => { + if (textFieldRef.current?.inner) { + refs.setReference(textFieldRef.current.inner); + } + }, [refs]); + + const openNativePicker = () => { + const input = textFieldRef.current?.input as HTMLInputElement | undefined; + + if (!input) return; + + if (typeof input.showPicker === 'function') { + input.showPicker(); + return; + } + + input.focus(); + }; + + const openCustomPicker = () => setOpen((prev) => !prev); + + const handleIconClick = () => { + if (readOnly || !showPicker) return; + + if (shouldUseNativePicker) { + openNativePicker(); + } else if (timePickerTrigger === 'button') { + openCustomPicker(); + } + }; + + const textFieldProps: TextFieldProps = { + ...(inputProps as TextFieldProps), + id, + label, + value: currentValue, + placeholder, + readOnly: readOnly || (!shouldUseNativePicker && isInputTrigger), + icon: 'schedule', + isClearable: true, + required, + onIconClick: handleIconClick, + onChange: updateTime, + onBlur: handleInputBlur, + className: cn( + styles['tedi-time-field__textfield'], + { [styles['tedi-time-field__icon--disabled']]: !showPicker || readOnly }, + { [styles['tedi-time-field__textfield--disabled']]: inputProps?.disabled }, + { [styles['tedi-time-field--native']]: shouldUseNativePicker } + ), + input: { + ...(inputProps?.input as UnknownType), + ...(shouldUseNativePicker && { type: 'time' }), + }, + }; + + const shouldUseDropdownPicker = + !shouldUseNativePicker && + showPicker && + !readOnly && + availableTimesVariant === 'dropdown' && + !!availableTimes?.length; + + if (shouldUseDropdownPicker) { + const selectedIndex = availableTimes.indexOf(currentValue); + const defaultActiveIndex = selectedIndex >= 0 ? selectedIndex : 0; + + return ( + + +
+ +
+
+ + + {availableTimes.map((time, index) => ( + updateTime(time)}> + {time} + + ))} + +
+ ); + } + + return ( + <> +
+ +
+ + {!shouldUseNativePicker && showPicker && ( + + {open && !readOnly && ( + +
+ { + updateTime(time); + if (availableTimes) setOpen(false); + }} + gridVariant={availableTimesVariant === 'grid-radio' ? 'radio' : 'button'} + className={styles['tedi-time-field__picker-wrapper']} + /> +
+
+ )} +
+ )} + + ); +}; + +TimeField.displayName = 'TimeField'; diff --git a/src/tedi/components/form/time-picker/components/time-grid/time-grid.spec.tsx b/src/tedi/components/form/time-picker/components/time-grid/time-grid.spec.tsx new file mode 100644 index 00000000..88a2da2b --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-grid/time-grid.spec.tsx @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { TimeGrid } from './time-grid'; + +jest.mock('../../../../buttons/button/button', () => ({ + __esModule: true, + default: ({ children, onClick, className, ...rest }: any) => ( + + ), +})); + +jest.mock('../../../../layout/grid', () => ({ + Row: ({ children }: any) =>
{children}
, + Col: ({ children }: any) =>
{children}
, +})); + +jest.mock('../../../choice-group/choice-group', () => ({ + __esModule: true, + default: ({ items, value, onChange }: any) => ( +
+ {items.map((item: any) => ( + + ))} +
+ ), +})); + +describe('TimeGrid', () => { + const times = ['09:00', '10:00', '11:00']; + + it('renders buttons variant by default', () => { + render(); + + expect(screen.getByText('09:00')).toBeInTheDocument(); + expect(screen.getByText('10:00')).toBeInTheDocument(); + expect(screen.getByText('11:00')).toBeInTheDocument(); + + expect(screen.getByTestId('row')).toBeInTheDocument(); + }); + + it('renders radio variant when specified', () => { + render(); + + expect(screen.getByTestId('choice-group')).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(times.length); + }); + + it('calls onSelect when a button is clicked', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + + render(); + + await user.click(screen.getByText('10:00')); + + expect(onSelect).toHaveBeenCalledWith('10:00'); + }); + + it('calls onSelect when a radio option is selected', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + + render(); + + const radios = screen.getAllByRole('radio'); + await user.click(radios[1]); + + expect(onSelect).toHaveBeenCalledWith('10:00'); + }); + + it('applies selected class to active button', () => { + render(); + + const selectedButton = screen.getByText('10:00'); + expect(selectedButton.className).toContain('tedi-time-picker__grid-item--selected'); + }); + + it('passes value to radio group as checked state', () => { + render(); + + const radios = screen.getAllByRole('radio'); + + expect(radios[2]).toBeChecked(); + }); + + it('applies custom className to root container', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('respects colWidth prop (radio items)', () => { + render(); + + expect(screen.getByTestId('choice-group')).toBeInTheDocument(); + }); + + it('auto-focuses the selected button on mount', () => { + render(); + + const selectedButton = screen.getByText('10:00'); + expect(selectedButton).toHaveFocus(); + }); + + it('auto-focuses the selected radio input on mount', () => { + render(); + + const radios = screen.getAllByRole('radio'); + expect(radios[2]).toHaveFocus(); + }); + + it('does not move focus when no value is selected', () => { + render( + <> + + + + ); + + const outside = screen.getByText('outside'); + outside.focus(); + expect(outside).toHaveFocus(); + }); + + it('arrow keys move focus between radio cards without calling onSelect', () => { + const onSelect = jest.fn(); + render(); + + const radios = screen.getAllByRole('radio'); + expect(radios[1]).toHaveFocus(); + + const grid = radios[0].closest('.tedi-time-picker__grid') as HTMLElement; + fireEvent.keyDown(grid, { key: 'ArrowDown' }); + + expect(radios[2]).toHaveFocus(); + expect(onSelect).not.toHaveBeenCalled(); + + fireEvent.keyDown(grid, { key: 'ArrowUp' }); + expect(radios[1]).toHaveFocus(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ArrowDown at the last radio wraps to the first', () => { + render(); + const radios = screen.getAllByRole('radio'); + const grid = radios[0].closest('.tedi-time-picker__grid') as HTMLElement; + + expect(radios[2]).toHaveFocus(); + fireEvent.keyDown(grid, { key: 'ArrowDown' }); + expect(radios[0]).toHaveFocus(); + }); + + it('Home and End jump to the first and last radio cards', () => { + render(); + const radios = screen.getAllByRole('radio'); + const grid = radios[0].closest('.tedi-time-picker__grid') as HTMLElement; + + fireEvent.keyDown(grid, { key: 'End' }); + expect(radios[radios.length - 1]).toHaveFocus(); + + fireEvent.keyDown(grid, { key: 'Home' }); + expect(radios[0]).toHaveFocus(); + }); +}); diff --git a/src/tedi/components/form/time-picker/components/time-grid/time-grid.tsx b/src/tedi/components/form/time-picker/components/time-grid/time-grid.tsx new file mode 100644 index 00000000..7f7eb936 --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-grid/time-grid.tsx @@ -0,0 +1,167 @@ +import cn from 'classnames'; +import { useEffect, useId, useRef } from 'react'; + +import { useLabels } from '../../../../../providers/label-provider'; +import Button from '../../../../buttons/button/button'; +import { Col, ColProps, ColSize, Row } from '../../../../layout/grid'; +import ChoiceGroup from '../../../choice-group/choice-group'; +import styles from '../../time-picker.module.scss'; + +export interface TimeGridProps { + /** + * Times in HH:mm format + */ + times: string[]; + /** + * Selected value + */ + value?: string; + /** + * Selection handler + */ + onSelect: (time: string) => void; + /** + * Grid column width per time slot. Accepts either: + * + * - a single `ColSize` (1–12 or `'auto'`) applied at every breakpoint, or + * - a breakpoint object (`{ xs?, sm?, md?, lg?, xl?, xxl? }`) for responsive + * layouts. + * + * Default is `{ xs: 6, md: 4 }` — 2 slots per row on phones (where 33% + * is too narrow for the radio card's intrinsic content width and would + * otherwise wrap one-per-row), 3 slots per row from `md` up. + */ + colWidth?: ColSize | Pick; + /** + * Display mode + */ + variant?: 'button' | 'radio'; + /* + * Additional CSS class name for custom styling + */ + className?: string; + /** + * Whether to render the surrounding card chrome (border, background, radius). + * @default true + */ + bordered?: boolean; +} + +export const TimeGrid: React.FC = ({ + times, + value, + onSelect, + className, + colWidth = { xs: 6, md: 4 }, + variant = 'button', + bordered = true, +}) => { + const timeGridId = useId(); + const { getLabel } = useLabels(); + const rootRef = useRef(null); + + useEffect(() => { + if (!value) return; + const root = rootRef.current; + if (!root) return; + const target = root.querySelector( + `input[type="radio"][value="${value}"], button[data-time="${value}"]` + ); + target?.focus({ preventScroll: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleRadioKeyDown = (event: React.KeyboardEvent) => { + if (variant !== 'radio') return; + const root = rootRef.current; + if (!root) return; + + const radios = Array.from(root.querySelectorAll('input[type="radio"]:not([disabled])')); + if (radios.length === 0) return; + + const currentIndex = radios.findIndex((r) => r === document.activeElement); + let nextIndex: number; + + switch (event.key) { + case 'ArrowDown': + case 'ArrowRight': + nextIndex = currentIndex < 0 || currentIndex === radios.length - 1 ? 0 : currentIndex + 1; + break; + case 'ArrowUp': + case 'ArrowLeft': + nextIndex = currentIndex <= 0 ? radios.length - 1 : currentIndex - 1; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = radios.length - 1; + break; + default: + return; + } + + event.preventDefault(); + radios[nextIndex]?.focus(); + }; + + const rootClassName = cn( + styles['tedi-time-picker__grid'], + { + [styles['tedi-time-picker__grid--borderless']]: !bordered, + }, + className + ); + + const resolvedColProps: Pick = + typeof colWidth === 'object' ? colWidth : { width: colWidth }; + + if (variant === 'radio') { + return ( +
+ onSelect(val as string)} + items={times.map((time) => ({ + id: `time-${timeGridId}-${time}`, + label: time, + value: time, + colProps: resolvedColProps, + }))} + direction="row" + variant="card" + showIndicator + color="secondary" + hideLabel + /> +
+ ); + } + + return ( +
+ + {times.map((time) => ( + + + + ))} + +
+ ); +}; + +TimeGrid.displayName = 'TimeGrid'; diff --git a/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.spec.tsx b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.spec.tsx new file mode 100644 index 00000000..49e5b214 --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.spec.tsx @@ -0,0 +1,751 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { clearScrollTimeout, needsScrollCorrection, scrollToIndex } from '../../../time-field/time-field-helpers'; +import { TimeWheel } from './time-wheel'; + +jest.useFakeTimers(); + +jest.mock('../../../time-field/time-field-helpers', () => ({ + clearScrollTimeout: jest.fn(), + scrollToIndex: jest.fn(), + + getScrollTopForIndex: (i: number) => i * 40, + + snapToNearestItem: (scrollTop: number, length: number) => + Math.max(0, Math.min(length - 1, Math.floor(scrollTop / 40))), + + needsScrollCorrection: jest.fn(() => false), +})); + +describe('TimeWheel', () => { + const hours = ['00', '01', '02']; + const minutes = ['00', '10', '20']; + + beforeEach(() => { + jest.clearAllMocks(); + Element.prototype.scrollTo = jest.fn(); + }); + + it('renders hours and minutes', () => { + render(); + + const columns = screen.getAllByRole('listbox'); + + expect(columns[0]).toHaveTextContent('00'); + expect(columns[0]).toHaveTextContent('01'); + expect(columns[0]).toHaveTextContent('02'); + expect(columns[1]).toHaveTextContent('00'); + expect(columns[1]).toHaveTextContent('10'); + expect(columns[1]).toHaveTextContent('20'); + }); + + it('marks selected values', () => { + render(); + + expect(screen.getByText('01').className).toContain('--selected'); + expect(screen.getByText('10').className).toContain('--selected'); + }); + + it('calls onChange on hour click', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + + render(); + + await user.click(screen.getByText('02')); + + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(onChange).toHaveBeenCalledWith('02', '00'); + }); + + it('calls onChange on minute click', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + + render(); + + await user.click(screen.getByText('20')); + + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(onChange).toHaveBeenCalledWith('00', '20'); + }); + + it('handles hour scroll', () => { + const onChange = jest.fn(); + + render(); + + // Flush the initial rAF so the useLayoutEffect's programmatic-scroll flag clears. + act(() => { + jest.advanceTimersByTime(20); + }); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + jest.advanceTimersByTime(200); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('handles minute scroll', () => { + const onChange = jest.fn(); + + render(); + + act(() => { + jest.advanceTimersByTime(20); + }); + + const col = screen.getAllByRole('listbox')[1]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + jest.advanceTimersByTime(200); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('triggers scroll correction timeout branch', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + + const onChange = jest.fn(); + + render( + + ); + + act(() => { + jest.advanceTimersByTime(20); + }); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + act(() => { + jest.advanceTimersByTime(150); + }); + + expect(scrollToIndex).toHaveBeenCalled(); + }); + + it('keyboard Enter selects value (hour column)', () => { + const onChange = jest.fn(); + + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith('01', '00'); + }); + + it('keyboard Space selects value (hour column)', () => { + const onChange = jest.fn(); + + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith('01', '00'); + }); + + it('cleans up timers on unmount', () => { + const { unmount } = render( + + ); + + unmount(); + + expect(clearScrollTimeout).toHaveBeenCalled(); + }); + + it('cleans up retry + scroll timers on unmount (full cleanup branch)', () => { + const { unmount } = render( + + ); + + act(() => { + jest.advanceTimersByTime(50); + }); + + unmount(); + + expect(clearScrollTimeout).toHaveBeenCalled(); + }); + + it('triggers minute scroll correction timeout branch', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + + const onChange = jest.fn(); + + render( + + ); + + act(() => { + jest.advanceTimersByTime(20); + }); + + const col = screen.getAllByRole('listbox')[1]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + act(() => { + jest.advanceTimersByTime(150); + }); + + expect(scrollToIndex).toHaveBeenCalled(); + }); + + it('does nothing on scroll when a programmatic scroll is in progress', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(false); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + + render(); + + const col = screen.getAllByRole('listbox')[0]; + + // A click sets isProgrammaticScrollHour = true and schedules a reset after 350ms. + // Firing a scroll event right after should be ignored, so onChange must not be + // called for the scroll. + return user.click(screen.getByText('01')).then(() => { + onChange.mockClear(); + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 80, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + it('ignores scroll when the snapped index has not changed', () => { + const onChange = jest.fn(); + + render(); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 0, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('ArrowDown focuses the next hour item', () => { + const onChange = jest.fn(); + + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + const focusSpy = jest.spyOn(items[1] as HTMLElement, 'focus'); + const scrollSpy = jest.spyOn(items[1] as HTMLElement, 'scrollIntoView'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + }); + + expect(focusSpy).toHaveBeenCalled(); + expect(scrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('ArrowUp from index 0 wraps to the last item', () => { + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + // Wrap target: from index 0 going up should jump to the last item + // (mirrors Angular's TimeWheel where 00 → 23 / 59 on ArrowUp). + const lastFocusSpy = jest.spyOn(items[items.length - 1] as HTMLElement, 'focus'); + const lastScrollSpy = jest.spyOn(items[items.length - 1] as HTMLElement, 'scrollIntoView'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + }); + + expect(lastFocusSpy).toHaveBeenCalled(); + // Wrap-around scrolls use 'auto' so the wheel jumps instantly instead of + // smooth-scrolling across every item. + expect(lastScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' }); + }); + + it('ArrowDown from the last index wraps to the first item', () => { + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + const firstFocusSpy = jest.spyOn(items[0] as HTMLElement, 'focus'); + const firstScrollSpy = jest.spyOn(items[0] as HTMLElement, 'scrollIntoView'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + }); + + expect(firstFocusSpy).toHaveBeenCalled(); + expect(firstScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' }); + }); + + it('Home focuses the first item and End focuses the last item', () => { + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + const firstFocusSpy = jest.spyOn(items[0] as HTMLElement, 'focus'); + const lastFocusSpy = jest.spyOn(items[3] as HTMLElement, 'focus'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + }); + expect(firstFocusSpy).toHaveBeenCalled(); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + }); + expect(lastFocusSpy).toHaveBeenCalled(); + }); + + it('PageDown jumps by 5 items within bounds', () => { + const list = ['00', '01', '02', '03', '04', '05', '06', '07']; + + render(); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + + // From index 1, +5 = 6 (within bounds) → focuses index 6 with a smooth scroll. + const pageDownSpy = jest.spyOn(items[6] as HTMLElement, 'focus'); + const pageDownScrollSpy = jest.spyOn(items[6] as HTMLElement, 'scrollIntoView'); + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true })); + }); + expect(pageDownSpy).toHaveBeenCalled(); + expect(pageDownScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + }); + + it('PageDown wraps around when the jump would exceed the end', () => { + const list = ['00', '01', '02', '03']; + + render(); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + + // From index 2, +5 = 7 → wraps to (2 + 5) % 4 = 3 with an instant 'auto' scroll. + const wrapSpy = jest.spyOn(items[3] as HTMLElement, 'focus'); + const wrapScrollSpy = jest.spyOn(items[3] as HTMLElement, 'scrollIntoView'); + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true })); + }); + expect(wrapSpy).toHaveBeenCalled(); + expect(wrapScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' }); + }); + + it('PageUp wraps around when the jump would go below 0', () => { + const list = ['00', '01', '02', '03', '04', '05', '06', '07']; + + render(); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + + // From index 1, -5 = -4 → wraps to (1 - 5 + 8) % 8 = 4 with an instant 'auto' scroll. + const wrapSpy = jest.spyOn(items[4] as HTMLElement, 'focus'); + const wrapScrollSpy = jest.spyOn(items[4] as HTMLElement, 'scrollIntoView'); + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true })); + }); + expect(wrapSpy).toHaveBeenCalled(); + expect(wrapScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' }); + }); + + it('ignores unhandled keys', () => { + const onChange = jest.fn(); + + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does nothing on keyboard input when the selected value is not in the list', () => { + const onChange = jest.fn(); + + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keyboard Enter on minute column selects the current minute', () => { + const onChange = jest.fn(); + + render( + + ); + + const col = screen.getAllByRole('listbox')[1]; + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith('00', '10'); + }); + + it('scrolls both columns to the selected index on mount (requestAnimationFrame)', () => { + const scrollToSpy = jest.fn(); + Element.prototype.scrollTo = scrollToSpy; + + render( + + ); + + act(() => { + jest.advanceTimersByTime(20); + }); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 80, behavior: 'instant' }); + }); + + it('skips initial scroll when the selected value is not in the list', () => { + const scrollToSpy = jest.fn(); + Element.prototype.scrollTo = scrollToSpy; + + render( + + ); + + act(() => { + jest.advanceTimersByTime(20); + }); + + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('stops retrying initial scroll once a re-render supersedes the pending attempt (stale gen)', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + Element.prototype.scrollTo = jest.fn(); + + const { rerender } = render( + + ); + + act(() => { + jest.advanceTimersByTime(20); + }); + + rerender( + + ); + + // Drain any retry timers — none should crash because the previous gen is now stale. + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getAllByRole('listbox')).toHaveLength(2); + }); + + it('advances past the 50ms inner timeout after a scroll correction fires', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + + const onChange = jest.fn(); + + render(); + + act(() => { + jest.advanceTimersByTime(20); + }); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + // Advance past the settle-timeout and the 50ms programmatic-flag reset. + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(scrollToIndex).toHaveBeenCalled(); + }); + + it('cleans up hour retry timers when the wheel unmounts mid-retry', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + Element.prototype.scrollTo = jest.fn(); + + const { unmount } = render( + + ); + + // Let the initial rAF enqueue a retry via setTimeout(16ms, schedule). + act(() => { + jest.advanceTimersByTime(16); + }); + + unmount(); + + // Draining timers after unmount must not crash (retry cleanup path ran). + expect(() => + act(() => { + jest.advanceTimersByTime(1000); + }) + ).not.toThrow(); + }); + + it('aborts pending retries when the wheel re-renders with different selected values', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + Element.prototype.scrollTo = jest.fn(); + + const { rerender } = render( + + ); + + // Enough for outer rAF (16ms) + inner rAF (~32ms) + first retry schedule fire (~48ms). + act(() => { + jest.advanceTimersByTime(80); + }); + + // Re-render clears the pending retry and bumps the generation counter. + rerender( + + ); + + // Any stale rAF callback that fires after this point must early-return via isStale(). + expect(() => + act(() => { + jest.advanceTimersByTime(2000); + }) + ).not.toThrow(); + }); + + it('skips scroll handling when the snapped minute index is unchanged', () => { + const onChange = jest.fn(); + + render(); + + const col = screen.getAllByRole('listbox')[1]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 0, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('skips minute scroll handling while a programmatic minute scroll is in progress', async () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(false); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onChange = jest.fn(); + + render(); + + const col = screen.getAllByRole('listbox')[1]; + + await user.click(screen.getByText('10')); + onChange.mockClear(); + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 80, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('skips the scroll-correction branch when no correction is needed (hour + minute)', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(false); + + const onChange = jest.fn(); + + render(); + + const [hourCol, minuteCol] = screen.getAllByRole('listbox'); + + act(() => { + Object.defineProperty(hourCol, 'scrollTop', { value: 40, writable: true }); + hourCol.dispatchEvent(new Event('scroll')); + Object.defineProperty(minuteCol, 'scrollTop', { value: 40, writable: true }); + minuteCol.dispatchEvent(new Event('scroll')); + }); + + // Fire the 100ms correction timeouts; scrollToIndex must not be called + // because needsScrollCorrection returns false. + act(() => { + jest.advanceTimersByTime(150); + }); + + expect(scrollToIndex).not.toHaveBeenCalled(); + }); + + it('ArrowDown on the minute column focuses the next minute item', () => { + render( + + ); + + const col = screen.getAllByRole('listbox')[1]; + const items = col.querySelectorAll('[role="option"]'); + const focusSpy = jest.spyOn(items[1] as HTMLElement, 'focus'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + }); + + expect(focusSpy).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 00000000..df7862a4 --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx @@ -0,0 +1,390 @@ +import cn from 'classnames'; +import React, { useEffect, useLayoutEffect, useRef } from 'react'; + +import { + clearScrollTimeout, + getScrollTopForIndex, + needsScrollCorrection, + scrollToIndex, + snapToNearestItem, +} from '../../../time-field/time-field-helpers'; +import styles from '../../time-picker.module.scss'; + +export interface TimeWheelProps { + /** + * Array of all available hours (normally ["00", "01", ..., "23"]). + */ + hours: string[]; + + /** + * Array of available minutes based on the `stepMinutes` value + * (e.g. ["00", "05", "10", ..., "55"] when step is 5). + */ + minutes: string[]; + /** + * The currently selected hour ("00" to "23"). + * Used to control the scroll position of the hour wheel. + */ + selectedHour: string; + /** + * The currently selected minute. + * Must exist in the `minutes` array. + * Used to control the scroll position of the minute wheel. + */ + selectedMinute: string; + /** + * Callback fired when the user changes either the hour or minute via scrolling or clicking. + * + * @param hour - Selected hour in "HH" format + * @param minute - Selected minute in "mm" format + */ + onChange: (hour: string, minute: string) => void; + /** + * Additional CSS class name to apply to the root wheel container. + */ + className?: string; + /** + * Whether to render the surrounding card chrome (border, background, radius). + * @default true + */ + bordered?: boolean; +} + +export const TimeWheel: React.FC = ({ + hours, + minutes, + selectedHour, + selectedMinute, + onChange, + className, + bordered = true, +}) => { + const uid = React.useId(); + const hourRef = useRef(null); + const minuteRef = useRef(null); + + const isProgrammaticScrollHour = useRef(false); + const isProgrammaticScrollMinute = useRef(false); + + const scrollTimeoutHour = useRef(); + const scrollTimeoutMinute = useRef(); + + const lastHourIndex = useRef(-1); + const lastMinuteIndex = useRef(-1); + + const [activeHourIndex, setActiveHourIndex] = React.useState(null); + const [activeMinuteIndex, setActiveMinuteIndex] = React.useState(null); + + const clampIndex = (index: number, length: number) => Math.max(0, Math.min(length - 1, index)); + + const forceScrollTo = (ref: React.RefObject, targetIndex: number, isHour: boolean) => { + const element = ref.current; + if (!element || targetIndex < 0) return; + + const target = getScrollTopForIndex(targetIndex); + + 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(() => { + if (isHour) isProgrammaticScrollHour.current = false; + else isProgrammaticScrollMinute.current = false; + }); + }; + + useLayoutEffect(() => { + const hourIndex = hours.indexOf(selectedHour); + const minuteIndex = minutes.indexOf(selectedMinute); + + if (hourIndex !== lastHourIndex.current) { + lastHourIndex.current = hourIndex; + forceScrollTo(hourRef, hourIndex, true); + } + + if (minuteIndex !== lastMinuteIndex.current) { + lastMinuteIndex.current = minuteIndex; + forceScrollTo(minuteRef, minuteIndex, false); + } + + setActiveHourIndex(hourIndex); + 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; + if (!el || isProgrammaticScrollHour.current) return; + + clearScrollTimeout(scrollTimeoutHour.current); + + const index = clampIndex(snapToNearestItem(el.scrollTop, hours.length), hours.length); + setActiveHourIndex(index); + + const target = getScrollTopForIndex(index); + + // Use 'instant' here — 'auto' would trigger the CSS scroll-behavior:smooth + // animation (~300ms), causing it to outlast the 50ms programmatic flag and + // treat the remaining animation frames as user scrolls, re-triggering snaps. + if (needsScrollCorrection(el.scrollTop, target, 8)) { + isProgrammaticScrollHour.current = true; + scrollToIndex(el, index, 'instant'); + requestAnimationFrame(() => { + isProgrammaticScrollHour.current = false; + }); + } + + if (index !== lastHourIndex.current) { + lastHourIndex.current = index; + onChange(hours[index]!, selectedMinute); + } + }; + + const processMinuteScrollEnd = useRef<() => void>(() => {}); + processMinuteScrollEnd.current = () => { + const el = minuteRef.current; + if (!el || isProgrammaticScrollMinute.current) return; + + clearScrollTimeout(scrollTimeoutMinute.current); + + const index = clampIndex(snapToNearestItem(el.scrollTop, minutes.length), minutes.length); + setActiveMinuteIndex(index); + + const target = getScrollTopForIndex(index); + + if (needsScrollCorrection(el.scrollTop, target, 8)) { + isProgrammaticScrollMinute.current = true; + scrollToIndex(el, index, 'instant'); + requestAnimationFrame(() => { + isProgrammaticScrollMinute.current = false; + }); + } + + if (index !== lastMinuteIndex.current) { + lastMinuteIndex.current = index; + onChange(selectedHour, minutes[index]!); + } + }; + + // 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; + if (!hourEl || !minuteEl) return; + + const onHourScrollEnd = () => processHourScrollEnd.current(); + const onMinuteScrollEnd = () => processMinuteScrollEnd.current(); + + hourEl.addEventListener('scrollend', onHourScrollEnd); + minuteEl.addEventListener('scrollend', onMinuteScrollEnd); + + return () => { + hourEl.removeEventListener('scrollend', onHourScrollEnd); + minuteEl.removeEventListener('scrollend', onMinuteScrollEnd); + }; + }, []); + + // 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; + + const index = clampIndex(snapToNearestItem(hourRef.current.scrollTop, hours.length), hours.length); + setActiveHourIndex(index); + + clearScrollTimeout(scrollTimeoutHour.current); + scrollTimeoutHour.current = setTimeout(() => processHourScrollEnd.current(), 150); + }; + + const handleMinuteScroll = () => { + if (!minuteRef.current || isProgrammaticScrollMinute.current) return; + + const index = clampIndex(snapToNearestItem(minuteRef.current.scrollTop, minutes.length), minutes.length); + setActiveMinuteIndex(index); + + clearScrollTimeout(scrollTimeoutMinute.current); + scrollTimeoutMinute.current = setTimeout(() => processMinuteScrollEnd.current(), 150); + }; + + const handleHourClick = (index: number) => { + const hour = hours[index]; + if (!hour || !hourRef.current) return; + + clearScrollTimeout(scrollTimeoutHour.current); + + lastHourIndex.current = index; + setActiveHourIndex(index); + onChange(hour, selectedMinute); + + isProgrammaticScrollHour.current = true; + scrollToIndex(hourRef.current, index, 'smooth'); + + setTimeout(() => { + isProgrammaticScrollHour.current = false; + }, 300); + }; + + const handleMinuteClick = (index: number) => { + const minute = minutes[index]; + if (!minute || !minuteRef.current) return; + + clearScrollTimeout(scrollTimeoutMinute.current); + + lastMinuteIndex.current = index; + setActiveMinuteIndex(index); + onChange(selectedHour, minute); + + isProgrammaticScrollMinute.current = true; + scrollToIndex(minuteRef.current, index, 'smooth'); + + setTimeout(() => { + isProgrammaticScrollMinute.current = false; + }, 300); + }; + + const handleColumnKeyDown = + (type: 'hour' | 'minute', list: string[], selected: string, onSelect: (v: string) => void) => + (event: React.KeyboardEvent) => { + const currentIndex = list.indexOf(selected); + 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) { + case 'ArrowDown': + nextIndex = (currentIndex + 1) % list.length; + wrapped = currentIndex === list.length - 1; + break; + case 'ArrowUp': + nextIndex = (currentIndex - 1 + list.length) % list.length; + wrapped = currentIndex === 0; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = list.length - 1; + break; + case 'PageDown': + nextIndex = (currentIndex + 5) % list.length; + wrapped = currentIndex + 5 >= list.length; + break; + case 'PageUp': + nextIndex = (currentIndex - 5 + list.length) % list.length; + wrapped = currentIndex - 5 < 0; + break; + case 'Enter': + case ' ': + event.preventDefault(); + onSelect(list[currentIndex]); + return; + default: + return; + } + + event.preventDefault(); + + const container = type === 'hour' ? hourRef.current : minuteRef.current; + const el = container?.querySelector(`#${CSS.escape(`${uid}-${type}-${nextIndex}`)}`); + + el?.focus(); + el?.scrollIntoView({ block: 'center', behavior: wrapped ? 'auto' : 'smooth' }); + }; + + useEffect(() => { + return () => { + clearScrollTimeout(scrollTimeoutHour.current); + clearScrollTimeout(scrollTimeoutMinute.current); + }; + }, []); + + return ( +
+
onChange(h, selectedMinute))} + > + {hours.map((h, idx) => ( +
handleHourClick(idx)} + id={`${uid}-hour-${idx}`} + role="option" + aria-selected={h === selectedHour} + > + {h} +
+ ))} +
+ +
onChange(selectedHour, m))} + > + {minutes.map((m, idx) => ( +
handleMinuteClick(idx)} + id={`${uid}-minute-${idx}`} + role="option" + aria-selected={m === selectedMinute} + > + {m} +
+ ))} +
+
+ ); +}; + +TimeWheel.displayName = 'TimeWheel'; diff --git a/src/tedi/components/form/time-picker/time-picker.module.scss b/src/tedi/components/form/time-picker/time-picker.module.scss new file mode 100644 index 00000000..cd887518 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.module.scss @@ -0,0 +1,153 @@ +.tedi-time-picker__wheel { + position: relative; + z-index: var(--z-index-dropdown); + display: flex; + width: 160px; + height: 200px; + overflow: hidden; + user-select: none; + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + + &--borderless { + background: transparent; + border: 0; + border-radius: 0; + } + + &::before { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + content: ''; + background: linear-gradient( + to bottom, + var(--card-background-primary) 0%, + transparent 30%, + transparent 70%, + var(--card-background-primary) 100% + ); + } + + &::after { + position: absolute; + inset: 0; + top: 50%; + right: 0; + left: 0; + z-index: 1; + height: 40px; + pointer-events: none; + content: ''; + background: var(--dropdown-item-active-background); + transform: translateY(-50%); + } +} + +.tedi-time-picker__wheel-column { + position: relative; + z-index: 2; + flex: 1; + padding: 80px 0; + overflow-y: scroll; + overscroll-behavior-y: contain; + will-change: scroll-position; + scroll-snap-type: y mandatory; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-padding: 80px; + + &:first-child { + border-right: 1px solid var(--general-border-primary); + } + + &::-webkit-scrollbar { + display: none; + } + + &:focus-visible { + z-index: 5; + outline: 2px solid var(--general-border-brand); + outline-offset: -2px; + } +} + +.tedi-time-picker__wheel-item { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + height: 40px; + color: var(--general-text-primary); + scroll-snap-align: center; + scroll-snap-stop: always; + + &:hover { + cursor: pointer; + opacity: 1; + } + + &--selected { + z-index: 2; + color: var(--dropdown-item-active-text); + } +} + +.tedi-time-picker__grid { + position: relative; + z-index: var(--z-index-dropdown); + display: flex; + flex-wrap: wrap; + gap: var(--layout-grid-gutters-08); + align-self: flex-start; + justify-content: space-between; + width: fit-content; + max-width: 310px; + height: fit-content; + padding: var(--card-padding-md-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + + &--borderless { + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + } + + .tedi-time-picker__grid-item { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 2.5rem; + padding: calc(var(--button-md-padding-y) - 1px) var(--button-md-padding-x); + font-size: var(--button-text-size-default); + color: var(--form-checkbox-radio-card-primary-default-text); + text-align: center; + border: 1px solid var(--form-checkbox-radio-card-secondary-default-border); + border-radius: var(--form-checkbox-radio-card-radius); + + &:hover { + color: var(--form-checkbox-radio-card-secondary-hover-text); + cursor: pointer; + border-color: var(--form-checkbox-radio-card-secondary-hover-border); + } + + &--selected { + color: var(--form-checkbox-radio-card-secondary-selected-text); + border-color: var(--form-checkbox-radio-card-secondary-selected-border); + box-shadow: 0 0 0 1px var(--form-checkbox-radio-card-secondary-selected-border); + + &:hover { + box-shadow: 0 0 0 1px var(--form-checkbox-radio-card-secondary-selected-border); + } + } + } +} diff --git a/src/tedi/components/form/time-picker/time-picker.spec.tsx b/src/tedi/components/form/time-picker/time-picker.spec.tsx new file mode 100644 index 00000000..e1a51347 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.spec.tsx @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { TimePicker } from './time-picker'; + +jest.mock('../time-field/time-field-helpers', () => ({ + generateHours: () => ['00', '01', '02'], + generateMinutes: (step: number) => { + if (step === 5) return ['00', '05', '10']; + return ['00', '01', '02', '03']; + }, + parseTime: (time: string) => { + const [hour = '00', minute = '00'] = (time || '12:00').split(':'); + return { hour, minute }; + }, + findClosestMinute: (target: string, minutes: string[]) => { + // simple deterministic mock + return minutes.includes(target) ? target : minutes[0]; + }, +})); + +jest.mock('./components/time-wheel/time-wheel', () => ({ + TimeWheel: ({ hours, minutes, selectedHour, selectedMinute, onChange }: any) => ( +
+
{hours.join(',')}
+
{minutes.join(',')}
+
+ {selectedHour}:{selectedMinute} +
+ + +
+ ), +})); + +jest.mock('./components/time-grid/time-grid', () => ({ + TimeGrid: ({ times, value, onSelect, variant, className }: any) => ( +
+
{value}
+ {times.map((t: string) => ( + + ))} +
+ ), +})); + +describe('TimePicker', () => { + it('renders TimeWheel when availableTimes is not provided', () => { + render(); + + expect(screen.getByTestId('time-wheel')).toBeInTheDocument(); + expect(screen.queryByTestId('time-grid')).not.toBeInTheDocument(); + }); + + it('renders TimeGrid when availableTimes is provided', () => { + render(); + + expect(screen.getByTestId('time-grid')).toBeInTheDocument(); + expect(screen.queryByTestId('time-wheel')).not.toBeInTheDocument(); + }); + + it('falls back to TimeWheel when availableTimes is an empty array', () => { + render(); + + expect(screen.getByTestId('time-wheel')).toBeInTheDocument(); + expect(screen.queryByTestId('time-grid')).not.toBeInTheDocument(); + }); + + it('falls back to TimeWheel when availableTimes is not an array (bad input)', () => { + // Simulates Storybook's "Set object" control producing `{}` when saved + // before the user replaces the placeholder with a real array. + render(); + + expect(screen.getByTestId('time-wheel')).toBeInTheDocument(); + expect(screen.queryByTestId('time-grid')).not.toBeInTheDocument(); + }); + + it('calls onChange when a grid time is selected', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render(); + + await user.click(screen.getByText('10:00')); + + expect(onChange).toHaveBeenCalledWith('10:00'); + }); + + it('passes computed hours and minutes to TimeWheel', () => { + render(); + + expect(screen.getByTestId('hours')).toHaveTextContent('00,01,02'); + expect(screen.getByTestId('minutes')).toHaveTextContent('00,05,10'); + }); + + it('derives selectedHour and selectedMinute correctly', () => { + render(); + + // minute should snap to closest from mocked helper + expect(screen.getByTestId('selected')).toHaveTextContent('01:00'); + }); + + it('falls back to the default "12:00" when the parsed hour is not in generated hours', () => { + render(); + + expect(screen.getByTestId('selected')).toHaveTextContent('12:00'); + }); + + it('calls onChange when TimeWheel triggers change', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render(); + + await user.click(screen.getByText('select-time')); + + expect(onChange).toHaveBeenCalledWith('01:10'); + }); + + it('applies className to TimeGrid', () => { + render(); + + expect(screen.getByTestId('time-grid')).toHaveAttribute('data-classname', 'custom-class'); + }); + + it('passes gridVariant to TimeGrid', () => { + render(); + + expect(screen.getByTestId('time-grid')).toHaveAttribute('data-variant', 'radio'); + }); + + it('updates the grid selection in uncontrolled mode and forwards onChange', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render(); + + expect(screen.getByTestId('value')).toHaveTextContent('09:00'); + + await user.click(screen.getByText('10:00')); + + expect(onChange).toHaveBeenCalledWith('10:00'); + expect(screen.getByTestId('value')).toHaveTextContent('10:00'); + }); + + it('updates the wheel selection in uncontrolled mode and forwards onChange', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render(); + + expect(screen.getByTestId('selected')).toHaveTextContent('00:00'); + + await user.click(screen.getByText('select-time')); + + expect(onChange).toHaveBeenCalledWith('01:10'); + expect(screen.getByTestId('selected')).toHaveTextContent('01:10'); + }); +}); diff --git a/src/tedi/components/form/time-picker/time-picker.stories.tsx b/src/tedi/components/form/time-picker/time-picker.stories.tsx new file mode 100644 index 00000000..de34c853 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.stories.tsx @@ -0,0 +1,83 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useEffect, useState } from 'react'; + +import { Text } from '../../base/typography/text/text'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import { TimePicker, TimePickerProps } from './time-picker'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: 'Tedi-Ready/Components/Form/TimePicker', + component: TimePicker, +} as Meta; + +type Story = StoryObj; + +const Template: StoryFn = (args) => { + const [time, setTime] = useState(args.value); + + useEffect(() => { + setTime(args.value); + }, [args.value]); + + return setTime(val)} />; +}; + +export const Default: Story = { + render: Template, + args: { + stepMinutes: 1, + }, +}; + +export const WithInitialValue: Story = { + render: Template, + args: { + value: '12:30', + }, +}; + +export const PredefinedSlots: Story = { + render: () => { + const [timeButton, setTimeButton] = useState(); + const [timeRadio, setTimeRadio] = useState(); + + const availableTimes = ['08:00', '08:30', '09:00', '09:15', '09:30', '10:00', '10:30', '11:00', '12:00']; + + return ( + + + + gridVariant = button + + + + gridVariant = radio + + + + + ); + }, +}; + +export const ControlledExample: StoryFn = () => { + const [time, setTime] = useState('09:00'); + + return ( + + + + + + + Selected time: {time} + + + ); +}; diff --git a/src/tedi/components/form/time-picker/time-picker.tsx b/src/tedi/components/form/time-picker/time-picker.tsx new file mode 100644 index 00000000..83249870 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.tsx @@ -0,0 +1,118 @@ +import React, { useMemo } from 'react'; + +import { findClosestMinute, generateHours, generateMinutes, parseTime } from '../time-field/time-field-helpers'; +import { TimeGrid } from './components/time-grid/time-grid'; +import { TimeWheel } from './components/time-wheel/time-wheel'; + +export interface TimePickerProps { + /** + * Currently selected time in "HH:mm" format (24-hour). + * + * @example "14:30" + * @default "" + */ + value?: string; + /** + * Initial time value for uncontrolled mode. Should be in "HH:mm" format. + * @example "09:00" + * @default "" + */ + defaultValue?: string; + /** + * Callback fired when the user selects a new time. + * Returns the selected time in "HH:mm" format. + * + * @param time - Selected time as "HH:mm" string + */ + onChange?: (time: string) => void; + /** + * Minute step interval for the minute wheel. + * Determines which minute values are shown (e.g. 00, 05, 10, ..., 55). + * + * @default 1 + */ + stepMinutes?: number; + /** + * When provided, the component switches from wheel mode to grid mode. + * Displays a list/grid of predefined time slots instead of scrollable wheels. + * + * Each string must be in "HH:mm" format. + * + * @example ["09:00", "09:30", "10:00", "14:00", "15:30"] + */ + availableTimes?: string[]; + /** + * Variant of the grid rendered when `availableTimes` is provided: + * - 'buttons' – buttons grid + * - 'radio' – radio buttons grid + * @default button + */ + gridVariant?: 'button' | 'radio'; + /** + * Additional CSS class name to apply to the root element. + * Useful for custom styling and layout overrides. + */ + className?: string; + /** + * Whether to render the surrounding card (border, background, radius). + * Set to `false` when embedding inside a parent that already provides + * its own surface — e.g. alongside a calendar inside `DateTimeField`. + * The inner gradient masks and column separators are preserved either way. + * @default true + */ + bordered?: boolean; +} + +export const TimePicker: React.FC = ({ + value, + defaultValue = '', + onChange, + stepMinutes = 1, + availableTimes, + gridVariant = 'button', + className, + bordered = true, +}) => { + const [internal, setInternal] = React.useState(defaultValue); + const isControlled = value !== undefined; + const current = isControlled ? value : internal; + const handleChange = (next: string) => { + if (!isControlled) setInternal(next); + onChange?.(next); + }; + + const hours = useMemo(generateHours, []); + const minutes = useMemo(() => generateMinutes(stepMinutes), [stepMinutes]); + + const { hour, minute } = parseTime(current || '12:00'); + + const selectedHour = hours.includes(hour) ? hour : '12'; + const selectedMinute = findClosestMinute(minute, minutes); + + if (Array.isArray(availableTimes) && availableTimes.length > 0) { + return ( + + ); + } + + return ( + handleChange(`${hour}:${minute}`)} + className={className} + bordered={bordered} + /> + ); +}; + +TimePicker.displayName = 'TimePicker'; diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx index 5f08e944..e25ab89b 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx @@ -180,6 +180,42 @@ describe('Dropdown component', () => { expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-0'); }); + it('focuses the item at defaultActiveIndex on open', () => { + renderDropdown( + { children: Trigger }, + <> + First + Second + Third + , + { defaultActiveIndex: 2 } + ); + + fireEvent.click(screen.getByText('Trigger')); + expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-2'); + }); + + it('resets the active index when the dropdown closes', () => { + renderDropdown( + { children: Trigger }, + <> + First + Second + , + { defaultActiveIndex: 1 } + ); + + // Open → close → reopen. Ensure the seeded index is reapplied cleanly. + fireEvent.click(screen.getByText('Trigger')); + expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-1'); + + fireEvent.click(screen.getByText('Trigger')); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Trigger')); + expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-1'); + }); + it('applies pixel width when width is a number', () => { renderDropdown({ children: Trigger }, Item, { width: 300, diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx index 9d71c814..962d3b9d 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.tsx +++ b/src/tedi/components/overlays/dropdown/dropdown.tsx @@ -90,6 +90,15 @@ export interface DropdownProps extends BreakpointSupport void; + /** + * Index of the item that should be focused when the dropdown opens. + * + * Pass the index of the "current selection" so the user can arrow-key or + * Enter/Space to reconfirm without first pressing an arrow. Omit (or pass + * `undefined`) to keep the default behaviour — no item is pre-focused and + * the user has to press an arrow key to start navigating. + */ + defaultActiveIndex?: number; /* * Additional class name(s) to apply to the dropdown container * @default undefined @@ -108,6 +117,7 @@ export const Dropdown = (props: DropdownProps) => { open: controlledOpen, defaultOpen = false, onOpenChange, + defaultActiveIndex, placement = 'bottom-start', className, } = getCurrentBreakpointProps(props); @@ -115,12 +125,19 @@ export const Dropdown = (props: DropdownProps) => { const nodeId = useFloatingNodeId(); const listItemsRef = React.useRef>([]); - const [activeIndex, setActiveIndex] = React.useState(null); + const [activeIndex, setActiveIndex] = React.useState(defaultActiveIndex ?? null); const [content, setContent] = React.useState(null); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); 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]); + const setOpen = React.useCallback( (next: boolean) => { if (controlledOpen === undefined) { @@ -151,6 +168,11 @@ 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 0fc6259e..60a9ea01 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -40,6 +40,8 @@ export * from './components/form/choice-group'; export * from './components/form/file-upload/file-upload'; export * from './components/form/file-dropzone/file-dropzone'; export * from './components/form/select/select'; +export * from './components/form/time-field/time-field'; +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'; diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index ef61448c..6fd6f501 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -875,6 +875,13 @@ export const labelsMap = validateDefaultLabels({ en: 'More information', ru: 'Больше информации', }, + 'timePicker.pickTime': { + description: 'Internal label for time picker, not visible for users but announced by screen readers', + components: ['TimePicker'], + et: 'Vali kellaaeg', + en: 'Pick time', + ru: 'Выберите время', + }, }); type DefaultLabels = typeof labelsMap;