From 3efd9788cc4b357704c40d013419634d7298291b Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:08:35 +0300 Subject: [PATCH 01/19] feat(time-field): new TEDI-ready component #25 --- .../form/time-field/time-field-helpers.ts | 141 ++++++++ .../form/time-field/time-field.module.scss | 73 ++++ .../form/time-field/time-field.spec.tsx | 215 ++++++++++++ .../form/time-field/time-field.stories.tsx | 275 +++++++++++++++ .../components/form/time-field/time-field.tsx | 300 ++++++++++++++++ .../components/time-grid/time-grid.spec.tsx | 110 ++++++ .../components/time-grid/time-grid.tsx | 88 +++++ .../components/time-wheel/time-wheel.spec.tsx | 195 +++++++++++ .../components/time-wheel/time-wheel.tsx | 328 ++++++++++++++++++ .../form/time-picker/time-picker.module.scss | 141 ++++++++ .../form/time-picker/time-picker.spec.tsx | 118 +++++++ .../form/time-picker/time-picker.stories.tsx | 68 ++++ .../form/time-picker/time-picker.tsx | 96 +++++ src/tedi/index.ts | 2 + 14 files changed, 2150 insertions(+) create mode 100644 src/tedi/components/form/time-field/time-field-helpers.ts create mode 100644 src/tedi/components/form/time-field/time-field.module.scss create mode 100644 src/tedi/components/form/time-field/time-field.spec.tsx create mode 100644 src/tedi/components/form/time-field/time-field.stories.tsx create mode 100644 src/tedi/components/form/time-field/time-field.tsx create mode 100644 src/tedi/components/form/time-picker/components/time-grid/time-grid.spec.tsx create mode 100644 src/tedi/components/form/time-picker/components/time-grid/time-grid.tsx create mode 100644 src/tedi/components/form/time-picker/components/time-wheel/time-wheel.spec.tsx create mode 100644 src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx create mode 100644 src/tedi/components/form/time-picker/time-picker.module.scss create mode 100644 src/tedi/components/form/time-picker/time-picker.spec.tsx create mode 100644 src/tedi/components/form/time-picker/time-picker.stories.tsx create mode 100644 src/tedi/components/form/time-picker/time-picker.tsx 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..54981a6b --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.module.scss @@ -0,0 +1,73 @@ +.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); + } + + &--disabled { + button:not([data-name='closing-button']):last-child { + > span { + color: var(--button-main-disabled-general-text); + } + + &:hover { + background: none; + } + } + } + + input[type='time']::-webkit-calendar-picker-indicator { + display: none !important; + } + + 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..ae808e3e --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.spec.tsx @@ -0,0 +1,215 @@ +/* eslint-disable react/display-name */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen } 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, + }), + }), +})); + +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)} + /> + +
+ ); + }); +}); + +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('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(); + }); +}); 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..c22de04e --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.stories.tsx @@ -0,0 +1,275 @@ +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', + }, +}; + +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..ac436d26 --- /dev/null +++ b/src/tedi/components/form/time-field/time-field.tsx @@ -0,0 +1,300 @@ +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 { TIMEPICKER_OFFSET } from './time-field-helpers'; + +type TimeFieldBreakpointProps = { + /** + * If `true`, uses the native time picker of the browser instead of a custom one. + * Note: When using the native picker, the `availableTimes` prop will not be applied. + * @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 ?? availableTimes?.[0] ?? ''); + + const currentValue = isControlled ? value : internalValue; + const [open, setOpen] = useState(false); + const isInputTrigger = timePickerTrigger === 'input'; + + 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 interactions = useInteractions([ + ...(showPicker && timePickerTrigger === 'input' && !readOnly ? [click] : []), + dismiss, + role, + ]); + + const updateTime = (time: string) => { + const cleaned = time.trim(); + + if (!isControlled) { + setInternalValue(cleaned); + } + + onChange?.(cleaned); + }; + + 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(true); + + const handleIconClick = () => { + if (readOnly || !showPicker) return; + + if (useNativePicker) { + openNativePicker(); + } else if (timePickerTrigger === 'button') { + openCustomPicker(); + } + }; + + const textFieldProps: TextFieldProps = { + ...(inputProps as TextFieldProps), + id, + label, + value: currentValue, + placeholder, + readOnly: readOnly || isInputTrigger, + icon: 'schedule', + isClearable: true, + required, + onIconClick: handleIconClick, + onChange: updateTime, + 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']]: useNativePicker } + ), + input: { + ...(inputProps?.input as UnknownType), + type: 'time', + }, + }; + + if (availableTimes && availableTimesVariant === 'dropdown') { + return ( + + +
+ +
+
+ + + {availableTimes.map((time, index) => ( + updateTime(time)}> + {time} + + ))} + +
+ ); + } + + return ( + <> +
+ +
+ + {!useNativePicker && showPicker && ( + + {open && !readOnly && ( + +
+ { + updateTime(time); + if (availableTimes) setOpen(false); + }} + gridVariant={availableTimesVariant === 'grid-radio' ? 'radio' : 'buttons'} + className={styles['tedi-time-field__picker-wrapper']} + /> +
+
+ )} +
+ )} + + ); +}; 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..985bd13c --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-grid/time-grid.spec.tsx @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { 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 }: 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(); + }); +}); 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..b83f2f2e --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-grid/time-grid.tsx @@ -0,0 +1,88 @@ +import cn from 'classnames'; + +import Button from '../../../../buttons/button/button'; +import { Col, 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 + */ + colWidth?: ColSize; + /** + * Display mode + */ + variant?: 'buttons' | 'radio'; + /* + * Additional CSS class name for custom styling + */ + className?: string; +} + +export const TimeGrid: React.FC = ({ + times, + value, + onSelect, + className, + colWidth = 4, + variant = 'buttons', +}) => { + if (variant === 'radio') { + return ( +
+ onSelect(val as string)} + items={times.map((time) => ({ + id: `time-${time}`, + label: time, + value: time, + colProps: { width: colWidth }, + }))} + direction="row" + variant="card" + showIndicator + color="secondary" + hideLabel + /> +
+ ); + } + + return ( +
+ + {times.map((time) => ( + + + + ))} + +
+ ); +}; 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..37f1b7d1 --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.spec.tsx @@ -0,0 +1,195 @@ +/* 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(); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('handles minute scroll', () => { + const onChange = jest.fn(); + + render(); + + const col = screen.getAllByRole('listbox')[1]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + expect(onChange).toHaveBeenCalled(); + }); + + it('triggers scroll correction timeout branch', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + + const onChange = jest.fn(); + + render( + + ); + + 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(); + }); +}); 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..016d12c3 --- /dev/null +++ b/src/tedi/components/form/time-picker/components/time-wheel/time-wheel.tsx @@ -0,0 +1,328 @@ +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; +} + +export const TimeWheel: React.FC = ({ + hours, + minutes, + selectedHour, + selectedMinute, + onChange, + className, +}) => { + const hourRef = useRef(null); + const minuteRef = useRef(null); + + const isProgrammaticScrollHour = useRef(false); + const isProgrammaticScrollMinute = useRef(false); + + const scrollTimeoutHour = useRef(); + const scrollTimeoutMinute = useRef(); + + const retryTimeoutHour = useRef(); + const retryTimeoutMinute = useRef(); + + const lastHourIndex = useRef(-1); + const lastMinuteIndex = useRef(-1); + + const clampIndex = (index: number, length: number) => Math.max(0, Math.min(length - 1, index)); + + const forceScrollTo = (ref: React.RefObject, targetIndex: number, isHour: boolean, attempt = 0) => { + const element = ref.current; + if (!element || targetIndex < 0) return; + + const target = getScrollTopForIndex(targetIndex); + const maxAttempts = 12; + + const tryScroll = () => { + if (!element) return; + + isHour ? (isProgrammaticScrollHour.current = true) : (isProgrammaticScrollMinute.current = true); + + element.scrollTo({ top: target, behavior: 'instant' }); + + requestAnimationFrame(() => { + if (!element) return; + + const stillWrong = needsScrollCorrection(element.scrollTop, target, 3); + + if (stillWrong && attempt < maxAttempts) { + const nextDelay = 16 + attempt * 20; + if (isHour) { + retryTimeoutHour.current = setTimeout(() => forceScrollTo(ref, targetIndex, true, attempt + 1), nextDelay); + } else { + retryTimeoutMinute.current = setTimeout( + () => forceScrollTo(ref, targetIndex, false, attempt + 1), + nextDelay + ); + } + } else { + if (isHour) isProgrammaticScrollHour.current = false; + else isProgrammaticScrollMinute.current = false; + } + }); + }; + + tryScroll(); + }; + + useLayoutEffect(() => { + const hourIndex = hours.indexOf(selectedHour); + const minuteIndex = minutes.indexOf(selectedMinute); + + lastHourIndex.current = hourIndex; + lastMinuteIndex.current = minuteIndex; + + if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); + if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); + + isProgrammaticScrollHour.current = false; + isProgrammaticScrollMinute.current = false; + + const initializeScrollPosition = () => { + forceScrollTo(hourRef, hourIndex, true); + forceScrollTo(minuteRef, minuteIndex, false); + }; + + requestAnimationFrame(() => { + initializeScrollPosition(); + }); + + return () => { + if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); + if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); + }; + }, []); + + const handleHourScroll = () => { + if (!hourRef.current || isProgrammaticScrollHour.current) return; + + const index = clampIndex(snapToNearestItem(hourRef.current.scrollTop, hours.length), hours.length); + + if (index !== lastHourIndex.current) { + lastHourIndex.current = index; + onChange(hours[index]!, selectedMinute); + } + + clearScrollTimeout(scrollTimeoutHour.current); + scrollTimeoutHour.current = setTimeout(() => { + if (!hourRef.current) return; + const target = getScrollTopForIndex(index); + if (needsScrollCorrection(hourRef.current.scrollTop, target, 4)) { + isProgrammaticScrollHour.current = true; + scrollToIndex(hourRef.current, index); + setTimeout(() => { + isProgrammaticScrollHour.current = false; + }, 50); + } + }, 100); + }; + + const handleMinuteScroll = () => { + if (!minuteRef.current || isProgrammaticScrollMinute.current) return; + + const index = clampIndex(snapToNearestItem(minuteRef.current.scrollTop, minutes.length), minutes.length); + + if (index !== lastMinuteIndex.current) { + lastMinuteIndex.current = index; + onChange(selectedHour, minutes[index]!); + } + + clearScrollTimeout(scrollTimeoutMinute.current); + scrollTimeoutMinute.current = setTimeout(() => { + if (!minuteRef.current) return; + const target = getScrollTopForIndex(index); + if (needsScrollCorrection(minuteRef.current.scrollTop, target, 4)) { + isProgrammaticScrollMinute.current = true; + scrollToIndex(minuteRef.current, index); + setTimeout(() => { + isProgrammaticScrollMinute.current = false; + }, 50); + } + }, 100); + }; + + const handleHourClick = (index: number) => { + const hour = hours[index]; + if (!hour || !hourRef.current) return; + + clearScrollTimeout(scrollTimeoutHour.current); + onChange(hour, selectedMinute); + lastHourIndex.current = index; + + isProgrammaticScrollHour.current = true; + scrollToIndex(hourRef.current, index, 'smooth'); + setTimeout(() => { + isProgrammaticScrollHour.current = false; + }, 350); + }; + + const handleMinuteClick = (index: number) => { + const minute = minutes[index]; + if (!minute || !minuteRef.current) return; + + clearScrollTimeout(scrollTimeoutMinute.current); + onChange(selectedHour, minute); + lastMinuteIndex.current = index; + + isProgrammaticScrollMinute.current = true; + scrollToIndex(minuteRef.current, index, 'smooth'); + setTimeout(() => { + isProgrammaticScrollMinute.current = false; + }, 350); + }; + + 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; + + switch (event.key) { + case 'ArrowDown': + nextIndex = Math.min(currentIndex + 1, list.length - 1); + break; + + case 'ArrowUp': + nextIndex = Math.max(currentIndex - 1, 0); + break; + + case 'Home': + nextIndex = 0; + break; + + case 'End': + nextIndex = list.length - 1; + break; + + case 'PageDown': + nextIndex = Math.min(currentIndex + 5, list.length - 1); + break; + + case 'PageUp': + nextIndex = Math.max(currentIndex - 5, 0); + break; + + case 'Enter': + case ' ': + event.preventDefault(); + onSelect(list[currentIndex]); + return; + + default: + return; + } + + event.preventDefault(); + + const el = document.getElementById(`${type}-${nextIndex}`); + el?.focus(); + el?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }; + + useEffect(() => { + return () => { + clearScrollTimeout(scrollTimeoutHour.current); + clearScrollTimeout(scrollTimeoutMinute.current); + if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); + if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); + }; + }, []); + + return ( +
+
onChange(h, selectedMinute))} + > + {hours.map((h, idx) => ( +
handleHourClick(idx)} + id={`hour-${idx}`} + > + {h} +
+ ))} +
+ +
onChange(selectedHour, m))} + > + {minutes.map((m, idx) => ( +
handleMinuteClick(idx)} + id={`minute-${idx}`} + > + {m} +
+ ))} +
+
+ ); +}; 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..698b07b2 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.module.scss @@ -0,0 +1,141 @@ +.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); + + &::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; + max-height: 200px; + padding: var(--card-padding-md-default); + background: var(--card-background-primary); + border: 1px solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + + .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..94c1f559 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.spec.tsx @@ -0,0 +1,118 @@ +/* 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 }: 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('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 "00" when hour is not in generated hours', () => { + render(); + + expect(screen.getByTestId('selected')).toHaveTextContent('00: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')).toBeInTheDocument(); + }); + + it('passes gridVariant to TimeGrid', () => { + render(); + + expect(screen.getByTestId('time-grid')).toBeInTheDocument(); + }); +}); 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..69436855 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.stories.tsx @@ -0,0 +1,68 @@ +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 { 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 [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 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..a15556d9 --- /dev/null +++ b/src/tedi/components/form/time-picker/time-picker.tsx @@ -0,0 +1,96 @@ +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; + /** + * 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[]; + /** + * Defines how available times are displayed + */ + availableTimesView?: 'grid' | 'dropdown'; + /* + * Variant of the grid when availableTimesView is set to 'grid': + * - 'buttons' – buttons grid + * - 'radio' – radio buttons grid + * @default 'buttons' + */ + gridVariant?: 'buttons' | 'radio'; + /** + * Additional CSS class name to apply to the root element. + * Useful for custom styling and layout overrides. + */ + className?: string; +} + +export const TimePicker: React.FC = ({ + value = '', + onChange, + stepMinutes = 1, + availableTimes, + gridVariant = 'buttons', + className, +}) => { + const hours = useMemo(generateHours, []); + const minutes = useMemo(() => generateMinutes(stepMinutes), [stepMinutes]); + + const { hour, minute } = parseTime(value || '12:00'); + + const selectedHour = hours.includes(hour) ? hour : '00'; + const selectedMinute = findClosestMinute(minute, minutes); + + if (availableTimes) { + return ( + onChange?.(time)} + className={className} + /> + ); + } + + return ( + { + onChange?.(`${hour}:${minute}`); + }} + className={className} + /> + ); +}; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 7493812a..61b6a579 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -37,6 +37,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/overlays/tooltip'; export * from './components/overlays/popover'; From 246af094459c15cde7f72d9ccf51b7a4f7b79b6a Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:29:44 +0300 Subject: [PATCH 02/19] fix(time-field, time-picker): code review fixes #25 --- .../components/form/time-field/time-field.tsx | 4 +++- .../components/time-grid/time-grid.tsx | 7 +++++-- .../components/time-wheel/time-wheel.tsx | 15 ++++++++++----- .../form/time-picker/time-picker.spec.tsx | 8 ++++---- .../components/form/time-picker/time-picker.tsx | 6 ++---- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx index ac436d26..1e0d6b8b 100644 --- a/src/tedi/components/form/time-field/time-field.tsx +++ b/src/tedi/components/form/time-field/time-field.tsx @@ -131,7 +131,7 @@ export const TimeField: React.FC = (props) => { } = getCurrentBreakpointProps(props); const isControlled = value !== undefined; - const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? availableTimes?.[0] ?? ''); + const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? ''); const currentValue = isControlled ? value : internalValue; const [open, setOpen] = useState(false); @@ -298,3 +298,5 @@ export const TimeField: React.FC = (props) => { ); }; + +TimeField.displayName = 'TimeField'; 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 index b83f2f2e..ef42102a 100644 --- 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 @@ -1,4 +1,5 @@ import cn from 'classnames'; +import { useId } from 'react'; import Button from '../../../../buttons/button/button'; import { Col, ColSize, Row } from '../../../../layout/grid'; @@ -40,14 +41,16 @@ export const TimeGrid: React.FC = ({ colWidth = 4, variant = 'buttons', }) => { + const reactId = useId(); + if (variant === 'radio') { return (
onSelect(val as string)} items={times.map((time) => ({ 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 016d12c3..9785819c 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 @@ -53,6 +53,7 @@ export const TimeWheel: React.FC = ({ onChange, className, }) => { + const uid = React.useId(); const hourRef = useRef(null); const minuteRef = useRef(null); @@ -135,7 +136,7 @@ export const TimeWheel: React.FC = ({ if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); }; - }, []); + }, [hours, minutes, selectedHour, selectedMinute]); const handleHourScroll = () => { if (!hourRef.current || isProgrammaticScrollHour.current) return; @@ -281,7 +282,7 @@ export const TimeWheel: React.FC = ({ role="listbox" aria-label="Hours" tabIndex={0} - aria-activedescendant={`hour-${selectedHour}`} + aria-activedescendant={`${uid}-hour-${hours.indexOf(selectedHour)}`} className={styles['tedi-time-picker__wheel-column']} onScroll={handleHourScroll} onKeyDown={handleColumnKeyDown('hour', hours, selectedHour, (h) => onChange(h, selectedMinute))} @@ -293,7 +294,9 @@ export const TimeWheel: React.FC = ({ [styles['tedi-time-picker__wheel-item--selected']]: h === selectedHour, })} onClick={() => handleHourClick(idx)} - id={`hour-${idx}`} + id={`${uid}-hour-${idx}`} + role="option" + aria-selected={h === selectedHour} > {h}
@@ -307,7 +310,7 @@ export const TimeWheel: React.FC = ({ onScroll={handleMinuteScroll} aria-label="Minutes" tabIndex={0} - aria-activedescendant={`minute-${selectedMinute}`} + aria-activedescendant={`${uid}-minute-${minutes.indexOf(selectedMinute)}`} onKeyDown={handleColumnKeyDown('minute', minutes, selectedMinute, (m) => onChange(selectedHour, m))} > {minutes.map((m, idx) => ( @@ -317,7 +320,9 @@ export const TimeWheel: React.FC = ({ [styles['tedi-time-picker__wheel-item--selected']]: m === selectedMinute, })} onClick={() => handleMinuteClick(idx)} - id={`minute-${idx}`} + id={`${uid}-minute-${idx}`} + role="option" + aria-selected={m === selectedMinute} > {m} diff --git a/src/tedi/components/form/time-picker/time-picker.spec.tsx b/src/tedi/components/form/time-picker/time-picker.spec.tsx index 94c1f559..41052887 100644 --- a/src/tedi/components/form/time-picker/time-picker.spec.tsx +++ b/src/tedi/components/form/time-picker/time-picker.spec.tsx @@ -35,8 +35,8 @@ jest.mock('./components/time-wheel/time-wheel', () => ({ })); jest.mock('./components/time-grid/time-grid', () => ({ - TimeGrid: ({ times, value, onSelect }: any) => ( -
+ TimeGrid: ({ times, value, onSelect, variant, className }: any) => ( +
{value}
{times.map((t: string) => (
); }; + +TimeWheel.displayName = 'TimeWheel'; diff --git a/src/tedi/components/form/time-picker/time-picker.spec.tsx b/src/tedi/components/form/time-picker/time-picker.spec.tsx index 41052887..c26d2eae 100644 --- a/src/tedi/components/form/time-picker/time-picker.spec.tsx +++ b/src/tedi/components/form/time-picker/time-picker.spec.tsx @@ -115,4 +115,32 @@ describe('TimePicker', () => { 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.tsx b/src/tedi/components/form/time-picker/time-picker.tsx index 854fcbbb..90661500 100644 --- a/src/tedi/components/form/time-picker/time-picker.tsx +++ b/src/tedi/components/form/time-picker/time-picker.tsx @@ -12,6 +12,12 @@ export interface TimePickerProps { * @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. @@ -36,7 +42,7 @@ export interface TimePickerProps { */ availableTimes?: string[]; /* - * Variant of the grid when availableTimesView is set to 'grid': + * Variant of the grid rendered when `availableTimes` is provided: * - 'buttons' – buttons grid * - 'radio' – radio buttons grid * @default 'buttons' @@ -50,17 +56,26 @@ export interface TimePickerProps { } export const TimePicker: React.FC = ({ - value = '', + value, + defaultValue = '', onChange, stepMinutes = 1, availableTimes, gridVariant = 'buttons', className, }) => { + 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(value || '12:00'); + const { hour, minute } = parseTime(current || '12:00'); const selectedHour = hours.includes(hour) ? hour : '00'; const selectedMinute = findClosestMinute(minute, minutes); @@ -69,9 +84,9 @@ export const TimePicker: React.FC = ({ return ( onChange?.(time)} + onSelect={handleChange} className={className} /> ); @@ -83,9 +98,7 @@ export const TimePicker: React.FC = ({ minutes={minutes} selectedHour={selectedHour} selectedMinute={selectedMinute} - onChange={(hour, minute) => { - onChange?.(`${hour}:${minute}`); - }} + onChange={(hour, minute) => handleChange(`${hour}:${minute}`)} className={className} /> ); diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index 8483ae1b..e15e48b5 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -848,6 +848,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; From cf7269551b0062073c4cd41a7d74abdda70ec163 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:46:17 +0300 Subject: [PATCH 04/19] fix(time-picker): code review fixes, improve test coverage #25 --- .../components/time-wheel/time-wheel.spec.tsx | 514 ++++++++++++++++++ .../components/time-wheel/time-wheel.tsx | 48 +- 2 files changed, 548 insertions(+), 14 deletions(-) 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 index 37f1b7d1..9d0c2784 100644 --- 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 @@ -192,4 +192,518 @@ describe('TimeWheel', () => { expect(clearScrollTimeout).toHaveBeenCalled(); }); + + it('triggers minute scroll correction timeout branch', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + + const onChange = jest.fn(); + + render( + + ); + + 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 focuses the previous hour item and clamps at 0', () => { + render( + + ); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + const focusSpy = jest.spyOn(items[0] as HTMLElement, 'focus'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + }); + + expect(focusSpy).toHaveBeenCalled(); + }); + + 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 and PageUp jump by 5 items and clamp at both ends', () => { + const list = ['00', '01', '02', '03', '04', '05', '06', '07']; + + render(); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + const pageDownSpy = jest.spyOn(items[6] as HTMLElement, 'focus'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true })); + }); + expect(pageDownSpy).toHaveBeenCalled(); + + const pageUpSpy = jest.spyOn(items[0] as HTMLElement, 'focus'); + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true })); + }); + expect(pageUpSpy).toHaveBeenCalled(); + }); + + it('PageDown clamps to the last item when near the end of the list', () => { + const list = ['00', '01', '02', '03']; + + render(); + + const col = screen.getAllByRole('listbox')[0]; + const items = col.querySelectorAll('[role="option"]'); + const lastFocusSpy = jest.spyOn(items[3] as HTMLElement, 'focus'); + + act(() => { + col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true })); + }); + + expect(lastFocusSpy).toHaveBeenCalled(); + }); + + 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('schedules a retry when the initial scroll does not land correctly', () => { + (needsScrollCorrection as jest.Mock).mockReturnValue(true); + const scrollToSpy = jest.fn(); + Element.prototype.scrollTo = scrollToSpy; + + render( + + ); + + act(() => { + jest.advanceTimersByTime(20); + }); + + const callsAfterFirstRaf = scrollToSpy.mock.calls.length; + + act(() => { + jest.advanceTimersByTime(60); + }); + + expect(scrollToSpy.mock.calls.length).toBeGreaterThan(callsAfterFirstRaf); + }); + + 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(); + + const col = screen.getAllByRole('listbox')[0]; + + act(() => { + Object.defineProperty(col, 'scrollTop', { value: 40, writable: true }); + col.dispatchEvent(new Event('scroll')); + }); + + // Advance past both the 100ms correction trigger and the 50ms 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 index 6a461128..35f0d0ff 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 @@ -56,6 +56,8 @@ export const TimeWheel: React.FC = ({ const uid = React.useId(); const hourRef = useRef(null); const minuteRef = useRef(null); + const hourGen = useRef(0); + const minuteGen = useRef(0); const isProgrammaticScrollHour = useRef(false); const isProgrammaticScrollMinute = useRef(false); @@ -71,34 +73,48 @@ export const TimeWheel: React.FC = ({ const clampIndex = (index: number, length: number) => Math.max(0, Math.min(length - 1, index)); - const forceScrollTo = (ref: React.RefObject, targetIndex: number, isHour: boolean, attempt = 0) => { + const forceScrollTo = ( + ref: React.RefObject, + targetIndex: number, + isHour: boolean, + gen: number, + attempt = 0 + ) => { const element = ref.current; if (!element || targetIndex < 0) return; + const isStale = () => (isHour ? gen !== hourGen.current : gen !== minuteGen.current); + + if (isStale()) return; + const target = getScrollTopForIndex(targetIndex); const maxAttempts = 12; const tryScroll = () => { - if (!element) return; + if (!element || isStale()) return; - isHour ? (isProgrammaticScrollHour.current = true) : (isProgrammaticScrollMinute.current = true); + if (isHour) isProgrammaticScrollHour.current = true; + else isProgrammaticScrollMinute.current = true; element.scrollTo({ top: target, behavior: 'instant' }); requestAnimationFrame(() => { - if (!element) return; + if (!element || isStale()) return; const stillWrong = needsScrollCorrection(element.scrollTop, target, 3); if (stillWrong && attempt < maxAttempts) { const nextDelay = 16 + attempt * 20; + + const schedule = () => { + if (isStale()) return; + forceScrollTo(ref, targetIndex, isHour, gen, attempt + 1); + }; + if (isHour) { - retryTimeoutHour.current = setTimeout(() => forceScrollTo(ref, targetIndex, true, attempt + 1), nextDelay); + retryTimeoutHour.current = setTimeout(schedule, nextDelay); } else { - retryTimeoutMinute.current = setTimeout( - () => forceScrollTo(ref, targetIndex, false, attempt + 1), - nextDelay - ); + retryTimeoutMinute.current = setTimeout(schedule, nextDelay); } } else { if (isHour) isProgrammaticScrollHour.current = false; @@ -111,6 +127,9 @@ export const TimeWheel: React.FC = ({ }; useLayoutEffect(() => { + hourGen.current++; + minuteGen.current++; + const hourIndex = hours.indexOf(selectedHour); const minuteIndex = minutes.indexOf(selectedMinute); @@ -123,14 +142,15 @@ export const TimeWheel: React.FC = ({ isProgrammaticScrollHour.current = false; isProgrammaticScrollMinute.current = false; + const currentHourGen = hourGen.current; + const currentMinuteGen = minuteGen.current; + const initializeScrollPosition = () => { - forceScrollTo(hourRef, hourIndex, true); - forceScrollTo(minuteRef, minuteIndex, false); + forceScrollTo(hourRef, hourIndex, true, currentHourGen); + forceScrollTo(minuteRef, minuteIndex, false, currentMinuteGen); }; - requestAnimationFrame(() => { - initializeScrollPosition(); - }); + requestAnimationFrame(initializeScrollPosition); return () => { if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); From 769e2a3e1957da2a1567ba4b9ceca29f6736dfdd Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:01:07 +0300 Subject: [PATCH 05/19] fix(time-field, time-picker): code review fixes #25 --- .../form/time-field/time-field.stories.tsx | 22 +-- .../components/form/time-field/time-field.tsx | 2 +- .../components/time-grid/time-grid.tsx | 12 +- .../components/time-wheel/time-wheel.spec.tsx | 55 +++--- .../components/time-wheel/time-wheel.tsx | 183 +++++++----------- .../form/time-picker/time-picker.spec.tsx | 16 ++ .../form/time-picker/time-picker.stories.tsx | 19 +- .../form/time-picker/time-picker.tsx | 12 +- 8 files changed, 153 insertions(+), 168 deletions(-) diff --git a/src/tedi/components/form/time-field/time-field.stories.tsx b/src/tedi/components/form/time-field/time-field.stories.tsx index c22de04e..735e0b01 100644 --- a/src/tedi/components/form/time-field/time-field.stories.tsx +++ b/src/tedi/components/form/time-field/time-field.stories.tsx @@ -33,7 +33,7 @@ type Story = StoryObj; const Template: StoryFn = (args) => ( - + @@ -52,10 +52,10 @@ const TemplateColumn: StoryFn = (args) => {
{array.map((value, key) => ( - + {value ? value.charAt(0).toUpperCase() + value.slice(1) : ''} - + @@ -85,7 +85,7 @@ export const Sizes: StoryObj = { export const FieldOptions: StoryFn = () => { return ( - +
@@ -103,7 +103,7 @@ export const FieldOptions: StoryFn = () => { export const ValueType: StoryFn = () => { return ( - +
@@ -119,13 +119,13 @@ export const OnClickType: Story = { return ( - + Clock button is clickable - + Input is clickable @@ -158,7 +158,7 @@ export const PredefinedTimeSlots: Story = { return ( - + - + - + = (args) => { return ( - + = (props) => { updateTime(time); if (availableTimes) setOpen(false); }} - gridVariant={availableTimesVariant === 'grid-radio' ? 'radio' : 'buttons'} + gridVariant={availableTimesVariant === 'grid-radio' ? 'radio' : 'button'} className={styles['tedi-time-field__picker-wrapper']} />
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 index 1ab7dc3c..78bf3ce3 100644 --- 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 @@ -27,7 +27,7 @@ export interface TimeGridProps { /** * Display mode */ - variant?: 'buttons' | 'radio'; + variant?: 'button' | 'radio'; /* * Additional CSS class name for custom styling */ @@ -40,23 +40,23 @@ export const TimeGrid: React.FC = ({ onSelect, className, colWidth = 4, - variant = 'buttons', + variant = 'button', }) => { - const reactId = useId(); + const timeGridId = useId(); const { getLabel } = useLabels(); if (variant === 'radio') { return (
onSelect(val as string)} items={times.map((time) => ({ - id: `time-${reactId}-${time}`, + id: `time-${timeGridId}-${time}`, label: time, value: time, colProps: { width: colWidth }, 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 index 9d0c2784..0b9648eb 100644 --- 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 @@ -83,11 +83,17 @@ describe('TimeWheel', () => { 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(50); }); expect(onChange).toHaveBeenCalled(); @@ -98,11 +104,16 @@ describe('TimeWheel', () => { 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(50); }); expect(onChange).toHaveBeenCalled(); @@ -123,6 +134,10 @@ describe('TimeWheel', () => { /> ); + act(() => { + jest.advanceTimersByTime(20); + }); + const col = screen.getAllByRole('listbox')[0]; act(() => { @@ -208,6 +223,10 @@ describe('TimeWheel', () => { /> ); + act(() => { + jest.advanceTimersByTime(20); + }); + const col = screen.getAllByRole('listbox')[1]; act(() => { @@ -447,7 +466,7 @@ describe('TimeWheel', () => { jest.advanceTimersByTime(20); }); - expect(scrollToSpy).toHaveBeenCalledWith({ top: 80, behavior: 'instant' }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 80, behavior: 'auto' }); }); it('skips initial scroll when the selected value is not in the list', () => { @@ -471,34 +490,6 @@ describe('TimeWheel', () => { expect(scrollToSpy).not.toHaveBeenCalled(); }); - it('schedules a retry when the initial scroll does not land correctly', () => { - (needsScrollCorrection as jest.Mock).mockReturnValue(true); - const scrollToSpy = jest.fn(); - Element.prototype.scrollTo = scrollToSpy; - - render( - - ); - - act(() => { - jest.advanceTimersByTime(20); - }); - - const callsAfterFirstRaf = scrollToSpy.mock.calls.length; - - act(() => { - jest.advanceTimersByTime(60); - }); - - expect(scrollToSpy.mock.calls.length).toBeGreaterThan(callsAfterFirstRaf); - }); - 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(); @@ -542,6 +533,10 @@ describe('TimeWheel', () => { render(); + act(() => { + jest.advanceTimersByTime(20); + }); + const col = screen.getAllByRole('listbox')[0]; act(() => { @@ -549,7 +544,7 @@ describe('TimeWheel', () => { col.dispatchEvent(new Event('scroll')); }); - // Advance past both the 100ms correction trigger and the 50ms flag reset. + // Advance past the settle-timeout and the 50ms programmatic-flag reset. act(() => { jest.advanceTimersByTime(200); }); 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 35f0d0ff..0a416351 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 @@ -56,8 +56,6 @@ export const TimeWheel: React.FC = ({ const uid = React.useId(); const hourRef = useRef(null); const minuteRef = useRef(null); - const hourGen = useRef(0); - const minuteGen = useRef(0); const isProgrammaticScrollHour = useRef(false); const isProgrammaticScrollMinute = useRef(false); @@ -65,145 +63,107 @@ export const TimeWheel: React.FC = ({ const scrollTimeoutHour = useRef(); const scrollTimeoutMinute = useRef(); - const retryTimeoutHour = useRef(); - const retryTimeoutMinute = 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, - gen: number, - attempt = 0 - ) => { + const forceScrollTo = (ref: React.RefObject, targetIndex: number, isHour: boolean) => { const element = ref.current; if (!element || targetIndex < 0) return; - const isStale = () => (isHour ? gen !== hourGen.current : gen !== minuteGen.current); - - if (isStale()) return; - const target = getScrollTopForIndex(targetIndex); - const maxAttempts = 12; - - const tryScroll = () => { - if (!element || isStale()) return; - - if (isHour) isProgrammaticScrollHour.current = true; - else isProgrammaticScrollMinute.current = true; - - element.scrollTo({ top: target, behavior: 'instant' }); - - requestAnimationFrame(() => { - if (!element || isStale()) return; - const stillWrong = needsScrollCorrection(element.scrollTop, target, 3); + if (isHour) isProgrammaticScrollHour.current = true; + else isProgrammaticScrollMinute.current = true; - if (stillWrong && attempt < maxAttempts) { - const nextDelay = 16 + attempt * 20; + element.scrollTo({ top: target, behavior: 'auto' }); - const schedule = () => { - if (isStale()) return; - forceScrollTo(ref, targetIndex, isHour, gen, attempt + 1); - }; - - if (isHour) { - retryTimeoutHour.current = setTimeout(schedule, nextDelay); - } else { - retryTimeoutMinute.current = setTimeout(schedule, nextDelay); - } - } else { - if (isHour) isProgrammaticScrollHour.current = false; - else isProgrammaticScrollMinute.current = false; - } - }); - }; - - tryScroll(); + requestAnimationFrame(() => { + if (isHour) isProgrammaticScrollHour.current = false; + else isProgrammaticScrollMinute.current = false; + }); }; useLayoutEffect(() => { - hourGen.current++; - minuteGen.current++; - const hourIndex = hours.indexOf(selectedHour); const minuteIndex = minutes.indexOf(selectedMinute); - lastHourIndex.current = hourIndex; - lastMinuteIndex.current = minuteIndex; - - if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); - if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); - - isProgrammaticScrollHour.current = false; - isProgrammaticScrollMinute.current = false; - - const currentHourGen = hourGen.current; - const currentMinuteGen = minuteGen.current; - - const initializeScrollPosition = () => { - forceScrollTo(hourRef, hourIndex, true, currentHourGen); - forceScrollTo(minuteRef, minuteIndex, false, currentMinuteGen); - }; + if (hourIndex !== lastHourIndex.current) { + lastHourIndex.current = hourIndex; + forceScrollTo(hourRef, hourIndex, true); + } - requestAnimationFrame(initializeScrollPosition); + if (minuteIndex !== lastMinuteIndex.current) { + lastMinuteIndex.current = minuteIndex; + forceScrollTo(minuteRef, minuteIndex, false); + } - return () => { - if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); - if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); - }; + setActiveHourIndex(hourIndex); + setActiveMinuteIndex(minuteIndex); }, [hours, minutes, selectedHour, selectedMinute]); const handleHourScroll = () => { if (!hourRef.current || isProgrammaticScrollHour.current) return; - const index = clampIndex(snapToNearestItem(hourRef.current.scrollTop, hours.length), hours.length); - - if (index !== lastHourIndex.current) { - lastHourIndex.current = index; - onChange(hours[index]!, selectedMinute); - } - clearScrollTimeout(scrollTimeoutHour.current); + scrollTimeoutHour.current = setTimeout(() => { if (!hourRef.current) return; + + const index = clampIndex(snapToNearestItem(hourRef.current.scrollTop, hours.length), hours.length); + + setActiveHourIndex(index); + const target = getScrollTopForIndex(index); - if (needsScrollCorrection(hourRef.current.scrollTop, target, 4)) { + + if (needsScrollCorrection(hourRef.current.scrollTop, target, 8)) { isProgrammaticScrollHour.current = true; scrollToIndex(hourRef.current, index); - setTimeout(() => { - isProgrammaticScrollHour.current = false; - }, 50); } - }, 100); + + if (index !== lastHourIndex.current) { + lastHourIndex.current = index; + onChange(hours[index]!, selectedMinute); + } + + setTimeout(() => { + isProgrammaticScrollHour.current = false; + }, 50); + }); }; const handleMinuteScroll = () => { if (!minuteRef.current || isProgrammaticScrollMinute.current) return; - const index = clampIndex(snapToNearestItem(minuteRef.current.scrollTop, minutes.length), minutes.length); - - if (index !== lastMinuteIndex.current) { - lastMinuteIndex.current = index; - onChange(selectedHour, minutes[index]!); - } - clearScrollTimeout(scrollTimeoutMinute.current); + scrollTimeoutMinute.current = setTimeout(() => { if (!minuteRef.current) return; + + const index = clampIndex(snapToNearestItem(minuteRef.current.scrollTop, minutes.length), minutes.length); + + setActiveMinuteIndex(index); + const target = getScrollTopForIndex(index); - if (needsScrollCorrection(minuteRef.current.scrollTop, target, 4)) { + + if (needsScrollCorrection(minuteRef.current.scrollTop, target, 8)) { isProgrammaticScrollMinute.current = true; scrollToIndex(minuteRef.current, index); - setTimeout(() => { - isProgrammaticScrollMinute.current = false; - }, 50); } - }, 100); + + if (index !== lastMinuteIndex.current) { + lastMinuteIndex.current = index; + onChange(selectedHour, minutes[index]!); + } + + setTimeout(() => { + isProgrammaticScrollMinute.current = false; + }, 50); + }); }; const handleHourClick = (index: number) => { @@ -211,14 +171,17 @@ export const TimeWheel: React.FC = ({ if (!hour || !hourRef.current) return; clearScrollTimeout(scrollTimeoutHour.current); - onChange(hour, selectedMinute); + lastHourIndex.current = index; + setActiveHourIndex(index); + onChange(hour, selectedMinute); isProgrammaticScrollHour.current = true; scrollToIndex(hourRef.current, index, 'smooth'); + setTimeout(() => { isProgrammaticScrollHour.current = false; - }, 350); + }, 300); }; const handleMinuteClick = (index: number) => { @@ -226,14 +189,17 @@ export const TimeWheel: React.FC = ({ if (!minute || !minuteRef.current) return; clearScrollTimeout(scrollTimeoutMinute.current); - onChange(selectedHour, minute); + lastMinuteIndex.current = index; + setActiveMinuteIndex(index); + onChange(selectedHour, minute); isProgrammaticScrollMinute.current = true; scrollToIndex(minuteRef.current, index, 'smooth'); + setTimeout(() => { isProgrammaticScrollMinute.current = false; - }, 350); + }, 300); }; const handleColumnKeyDown = @@ -248,33 +214,26 @@ export const TimeWheel: React.FC = ({ case 'ArrowDown': nextIndex = Math.min(currentIndex + 1, list.length - 1); break; - case 'ArrowUp': nextIndex = Math.max(currentIndex - 1, 0); break; - case 'Home': nextIndex = 0; break; - case 'End': nextIndex = list.length - 1; break; - case 'PageDown': nextIndex = Math.min(currentIndex + 5, list.length - 1); break; - case 'PageUp': nextIndex = Math.max(currentIndex - 5, 0); break; - case 'Enter': case ' ': event.preventDefault(); onSelect(list[currentIndex]); return; - default: return; } @@ -292,8 +251,6 @@ export const TimeWheel: React.FC = ({ return () => { clearScrollTimeout(scrollTimeoutHour.current); clearScrollTimeout(scrollTimeoutMinute.current); - if (retryTimeoutHour.current) clearTimeout(retryTimeoutHour.current); - if (retryTimeoutMinute.current) clearTimeout(retryTimeoutMinute.current); }; }, []); @@ -313,7 +270,8 @@ export const TimeWheel: React.FC = ({
handleHourClick(idx)} id={`${uid}-hour-${idx}`} @@ -339,7 +297,8 @@ export const TimeWheel: React.FC = ({
handleMinuteClick(idx)} id={`${uid}-minute-${idx}`} diff --git a/src/tedi/components/form/time-picker/time-picker.spec.tsx b/src/tedi/components/form/time-picker/time-picker.spec.tsx index c26d2eae..690729dd 100644 --- a/src/tedi/components/form/time-picker/time-picker.spec.tsx +++ b/src/tedi/components/form/time-picker/time-picker.spec.tsx @@ -62,6 +62,22 @@ describe('TimePicker', () => { 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(); diff --git a/src/tedi/components/form/time-picker/time-picker.stories.tsx b/src/tedi/components/form/time-picker/time-picker.stories.tsx index 69436855..de34c853 100644 --- a/src/tedi/components/form/time-picker/time-picker.stories.tsx +++ b/src/tedi/components/form/time-picker/time-picker.stories.tsx @@ -3,6 +3,7 @@ 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'; /** @@ -43,11 +44,25 @@ export const WithInitialValue: Story = { export const PredefinedSlots: Story = { render: () => { - const [time, setTime] = useState(); + 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 ; + return ( + + + + gridVariant = button + + + + gridVariant = radio + + + + + ); }, }; diff --git a/src/tedi/components/form/time-picker/time-picker.tsx b/src/tedi/components/form/time-picker/time-picker.tsx index 90661500..8c647558 100644 --- a/src/tedi/components/form/time-picker/time-picker.tsx +++ b/src/tedi/components/form/time-picker/time-picker.tsx @@ -12,7 +12,7 @@ export interface TimePickerProps { * @default "" */ value?: string; - /* + /** * Initial time value for uncontrolled mode. Should be in "HH:mm" format. * @example "09:00" * @default "" @@ -41,13 +41,13 @@ export interface TimePickerProps { * @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 'buttons' + * @default button */ - gridVariant?: 'buttons' | 'radio'; + gridVariant?: 'button' | 'radio'; /** * Additional CSS class name to apply to the root element. * Useful for custom styling and layout overrides. @@ -61,7 +61,7 @@ export const TimePicker: React.FC = ({ onChange, stepMinutes = 1, availableTimes, - gridVariant = 'buttons', + gridVariant = 'button', className, }) => { const [internal, setInternal] = React.useState(defaultValue); @@ -80,7 +80,7 @@ export const TimePicker: React.FC = ({ const selectedHour = hours.includes(hour) ? hour : '00'; const selectedMinute = findClosestMinute(minute, minutes); - if (availableTimes) { + if (Array.isArray(availableTimes) && availableTimes.length > 0) { return ( Date: Thu, 23 Apr 2026 12:25:52 +0300 Subject: [PATCH 06/19] fix(time-picker): fix initial value reset #25 --- .../components/time-wheel/time-wheel.spec.tsx | 2 +- .../time-picker/components/time-wheel/time-wheel.tsx | 9 ++++++++- src/tedi/components/form/time-picker/time-picker.tsx | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) 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 index 0b9648eb..40fa5091 100644 --- 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 @@ -466,7 +466,7 @@ describe('TimeWheel', () => { jest.advanceTimersByTime(20); }); - expect(scrollToSpy).toHaveBeenCalledWith({ top: 80, behavior: 'auto' }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 80, behavior: 'instant' }); }); it('skips initial scroll when the selected value is not in the list', () => { 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 0a416351..a27ecb2c 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 @@ -80,7 +80,14 @@ export const TimeWheel: React.FC = ({ if (isHour) isProgrammaticScrollHour.current = true; else isProgrammaticScrollMinute.current = true; - element.scrollTo({ top: target, behavior: 'auto' }); + // '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; diff --git a/src/tedi/components/form/time-picker/time-picker.tsx b/src/tedi/components/form/time-picker/time-picker.tsx index 8c647558..f356f390 100644 --- a/src/tedi/components/form/time-picker/time-picker.tsx +++ b/src/tedi/components/form/time-picker/time-picker.tsx @@ -77,7 +77,7 @@ export const TimePicker: React.FC = ({ const { hour, minute } = parseTime(current || '12:00'); - const selectedHour = hours.includes(hour) ? hour : '00'; + const selectedHour = hours.includes(hour) ? hour : '12'; const selectedMinute = findClosestMinute(minute, minutes); if (Array.isArray(availableTimes) && availableTimes.length > 0) { From 812811adca6fa194b2da99d33bf17a8936eba1e6 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:23:19 +0300 Subject: [PATCH 07/19] fix(time-field): radio grid tab selection fix #25 --- .../choice-group-item.module.scss | 6 +- .../choice-group-item.spec.tsx | 15 ++++ .../choice-group-item/choice-group-item.tsx | 16 +++- .../components/form/time-field/time-field.tsx | 8 +- .../components/time-grid/time-grid.spec.tsx | 73 ++++++++++++++++++- .../components/time-grid/time-grid.tsx | 63 +++++++++++++++- .../form/time-picker/time-picker.spec.tsx | 2 +- .../overlays/dropdown/dropdown.spec.tsx | 36 +++++++++ .../components/overlays/dropdown/dropdown.tsx | 24 +++++- 9 files changed, 230 insertions(+), 13 deletions(-) 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..d4ee6dae 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,24 @@ export const ChoiceGroupItem = (props: ExtendedChoiceGroupItemProps): React.Reac document.getElementById(id)?.click(); }; + + // Radio groups follow the WAI-ARIA radio-group pattern: Tab enters/exits + // the group, Arrow keys move between cards, Space selects the focused one. + // Native already implements all of that via + // the browser's roving-tabstop — but only if it's the tab-reachable element. + // So for radio, the outer wrapper steps out of the tab order and stops + // claiming `role="radio"` (which would duplicate the inner input's semantics). + // Checkboxes keep the existing independent-tabstop behaviour since they're + // not a roving-tabstop group natively. + const isRadio = type === 'radio'; return (
{variant === 'default' || showIndicator ? ( = (props) => { }; if (availableTimes && availableTimesVariant === 'dropdown') { + // Land focus on the previously selected item when the dropdown opens; if + // nothing is selected yet, focus the first item. Lets the user Enter/Space + // to reconfirm or Arrow to move without a priming keystroke. + const selectedIndex = availableTimes.indexOf(currentValue); + const defaultActiveIndex = selectedIndex >= 0 ? selectedIndex : 0; + return ( - +
({ __esModule: true, - default: ({ children, onClick, className }: any) => ( - ), @@ -107,4 +107,71 @@ describe('TimeGrid', () => { 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 index 78bf3ce3..c66644e0 100644 --- 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 @@ -1,5 +1,5 @@ import cn from 'classnames'; -import { useId } from 'react'; +import { useEffect, useId, useRef } from 'react'; import { useLabels } from '../../../../../providers/label-provider'; import Button from '../../../../buttons/button/button'; @@ -44,10 +44,66 @@ export const TimeGrid: React.FC = ({ }) => { const timeGridId = useId(); const { getLabel } = useLabels(); + const rootRef = useRef(null); + + // When the grid mounts (i.e. the picker opens), land focus on the card that + // matches the current value. Lets keyboard users arrow-navigate from where + // they left off and mirrors the way native `). 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; @@ -136,9 +139,7 @@ export const TimeField: React.FC = (props) => { const currentValue = isControlled ? value : internalValue; const [open, setOpen] = useState(false); const isInputTrigger = timePickerTrigger === 'input'; - const breakpoint = useBreakpoint(props.defaultServerBreakpoint); - const isMobile = isBreakpointBelow(breakpoint, 'md'); - const shouldUseNativePicker = useNativePicker && isMobile; + const shouldUseNativePicker = useNativePicker; const floating = useFloating({ open, From d2502ed9062490385b3ae95e5e59969e1c29988a Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 11 May 2026 16:02:27 +0300 Subject: [PATCH 13/19] fix(time-field): add formatting when valid time without delimiter is entered #25 --- .../form/time-field/time-field.spec.tsx | 32 ++++++++++++++++++- .../components/form/time-field/time-field.tsx | 20 +++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/tedi/components/form/time-field/time-field.spec.tsx b/src/tedi/components/form/time-field/time-field.spec.tsx index e8c0d776..e7366f9b 100644 --- a/src/tedi/components/form/time-field/time-field.spec.tsx +++ b/src/tedi/components/form/time-field/time-field.spec.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/display-name */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -71,6 +71,7 @@ jest.mock('../textfield/textfield', () => { data-testid="textfield-input" value={props.value || ''} onChange={(e: any) => props.onChange?.(e.target.value)} + onBlur={props.onBlur} />