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) => {children} ;
+
+ 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)}
+ />
+
+ icon
+
+
+ );
+ });
+});
+
+jest.mock('../time-picker/time-picker', () => ({
+ TimePicker: ({ value, onChange }: any) => (
+
+
{value}
+
onChange('12:30')}>pick
+
+ ),
+}));
+
+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) => (
+
+ {children}
+
+ ),
+}));
+
+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) => (
+
+ onChange(item.value)}
+ />
+ {item.label}
+
+ ))}
+
+ ),
+}));
+
+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) => (
+
+ onSelect(time)}
+ >
+ {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}
+
+
+
onChange('01', '10')}>select-time
+
+ ),
+}));
+
+jest.mock('./components/time-grid/time-grid', () => ({
+ TimeGrid: ({ times, value, onSelect }: any) => (
+
+
{value}
+ {times.map((t: string) => (
+
onSelect(t)}>
+ {t}
+
+ ))}
+
+ ),
+}));
+
+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) => (
onSelect(t)}>
@@ -107,12 +107,12 @@ describe('TimePicker', () => {
it('applies className to TimeGrid', () => {
render( );
- expect(screen.getByTestId('time-grid')).toBeInTheDocument();
+ expect(screen.getByTestId('time-grid')).toHaveAttribute('data-classname', 'custom-class');
});
it('passes gridVariant to TimeGrid', () => {
render( );
- expect(screen.getByTestId('time-grid')).toBeInTheDocument();
+ expect(screen.getByTestId('time-grid')).toHaveAttribute('data-variant', 'radio');
});
});
diff --git a/src/tedi/components/form/time-picker/time-picker.tsx b/src/tedi/components/form/time-picker/time-picker.tsx
index a15556d9..854fcbbb 100644
--- a/src/tedi/components/form/time-picker/time-picker.tsx
+++ b/src/tedi/components/form/time-picker/time-picker.tsx
@@ -35,10 +35,6 @@ export interface TimePickerProps {
* @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
@@ -94,3 +90,5 @@ export const TimePicker: React.FC = ({
/>
);
};
+
+TimePicker.displayName = 'TimePicker';
From 35adf95bfa19a61dd14565986d3a4df6ebbf0705 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Tue, 21 Apr 2026 10:58:25 +0300
Subject: [PATCH 03/19] fix(time-picker): code review fixes #25
---
.../components/time-grid/time-grid.tsx | 8 +++--
.../components/time-wheel/time-wheel.tsx | 6 +++-
.../form/time-picker/time-picker.spec.tsx | 28 ++++++++++++++++++
.../form/time-picker/time-picker.tsx | 29 ++++++++++++++-----
.../providers/label-provider/labels-map.ts | 7 +++++
5 files changed, 67 insertions(+), 11 deletions(-)
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 ef42102a..1ab7dc3c 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,6 +1,7 @@
import cn from 'classnames';
import { useId } from 'react';
+import { useLabels } from '../../../../../providers/label-provider';
import Button from '../../../../buttons/button/button';
import { Col, ColSize, Row } from '../../../../layout/grid';
import ChoiceGroup from '../../../choice-group/choice-group';
@@ -42,19 +43,20 @@ export const TimeGrid: React.FC = ({
variant = 'buttons',
}) => {
const reactId = useId();
+ const { getLabel } = useLabels();
if (variant === 'radio') {
return (
onSelect(val as string)}
items={times.map((time) => ({
- id: `time-${time}`,
+ id: `time-${reactId}-${time}`,
label: time,
value: time,
colProps: { width: colWidth },
@@ -89,3 +91,5 @@ export const TimeGrid: React.FC = ({
);
};
+
+TimeGrid.displayName = 'TimeGrid';
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 9785819c..6a461128 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
@@ -261,7 +261,9 @@ export const TimeWheel: React.FC = ({
event.preventDefault();
- const el = document.getElementById(`${type}-${nextIndex}`);
+ const container = type === 'hour' ? hourRef.current : minuteRef.current;
+ const el = container?.querySelector(`#${CSS.escape(`${uid}-${type}-${nextIndex}`)}`);
+
el?.focus();
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
@@ -331,3 +333,5 @@ export const TimeWheel: React.FC = ({
);
};
+
+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) => (
-
+ default: ({ children, onClick, className, ...rest }: any) => (
+
{children}
),
@@ -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(
+ <>
+ outside
+
+ >
+ );
+
+ 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 opens pre-highlighted.
+ // Runs once per mount — TimeGrid remounts every time the picker reopens.
+ useEffect(() => {
+ if (!value) return;
+ const root = rootRef.current;
+ if (!root) return;
+ const target = root.querySelector(
+ `input[type="radio"][value="${value}"], button[data-time="${value}"]`
+ );
+ target?.focus({ preventScroll: true });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Listbox-style keyboard handling for the radio variant: arrow keys move
+ // focus between slots *without* triggering native same-name-radio
+ // auto-selection. Without this, Arrow-Down would also mark the next radio
+ // checked, fire onSelect/onChange, and (inside TimeField) close the picker.
+ // Space/Enter fall through to native radio activation — that still fires
+ // onChange and lets the picker close on explicit confirmation.
+ const handleRadioKeyDown = (event: React.KeyboardEvent) => {
+ if (variant !== 'radio') return;
+ const root = rootRef.current;
+ if (!root) return;
+
+ const radios = Array.from(root.querySelectorAll('input[type="radio"]:not([disabled])'));
+ if (radios.length === 0) return;
+
+ const currentIndex = radios.findIndex((r) => r === document.activeElement);
+ let nextIndex: number;
+
+ switch (event.key) {
+ case 'ArrowDown':
+ case 'ArrowRight':
+ nextIndex = currentIndex < 0 || currentIndex === radios.length - 1 ? 0 : currentIndex + 1;
+ break;
+ case 'ArrowUp':
+ case 'ArrowLeft':
+ nextIndex = currentIndex <= 0 ? radios.length - 1 : currentIndex - 1;
+ break;
+ case 'Home':
+ nextIndex = 0;
+ break;
+ case 'End':
+ nextIndex = radios.length - 1;
+ break;
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ radios[nextIndex]?.focus();
+ };
if (variant === 'radio') {
return (
-
+
= ({
}
return (
-
+
{times.map((time) => (
{
it('falls back to "00" when hour is not in generated hours', () => {
render( );
- expect(screen.getByTestId('selected')).toHaveTextContent('00:00');
+ expect(screen.getByTestId('selected')).toHaveTextContent('12:00');
});
it('calls onChange when TimeWheel triggers change', async () => {
diff --git a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx
index 5f08e944..e25ab89b 100644
--- a/src/tedi/components/overlays/dropdown/dropdown.spec.tsx
+++ b/src/tedi/components/overlays/dropdown/dropdown.spec.tsx
@@ -180,6 +180,42 @@ describe('Dropdown component', () => {
expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-0');
});
+ it('focuses the item at defaultActiveIndex on open', () => {
+ renderDropdown(
+ { children: Trigger },
+ <>
+ First
+ Second
+ Third
+ >,
+ { defaultActiveIndex: 2 }
+ );
+
+ fireEvent.click(screen.getByText('Trigger'));
+ expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-2');
+ });
+
+ it('resets the active index when the dropdown closes', () => {
+ renderDropdown(
+ { children: Trigger },
+ <>
+ First
+ Second
+ >,
+ { defaultActiveIndex: 1 }
+ );
+
+ // Open → close → reopen. Ensure the seeded index is reapplied cleanly.
+ fireEvent.click(screen.getByText('Trigger'));
+ expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-1');
+
+ fireEvent.click(screen.getByText('Trigger'));
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Trigger'));
+ expect(screen.getByRole('menu')).toHaveAttribute('aria-activedescendant', 'dropdown-item-1');
+ });
+
it('applies pixel width when width is a number', () => {
renderDropdown({ children: Trigger }, Item , {
width: 300,
diff --git a/src/tedi/components/overlays/dropdown/dropdown.tsx b/src/tedi/components/overlays/dropdown/dropdown.tsx
index 9d71c814..962d3b9d 100644
--- a/src/tedi/components/overlays/dropdown/dropdown.tsx
+++ b/src/tedi/components/overlays/dropdown/dropdown.tsx
@@ -90,6 +90,15 @@ export interface DropdownProps extends BreakpointSupport void;
+ /**
+ * Index of the item that should be focused when the dropdown opens.
+ *
+ * Pass the index of the "current selection" so the user can arrow-key or
+ * Enter/Space to reconfirm without first pressing an arrow. Omit (or pass
+ * `undefined`) to keep the default behaviour — no item is pre-focused and
+ * the user has to press an arrow key to start navigating.
+ */
+ defaultActiveIndex?: number;
/*
* Additional class name(s) to apply to the dropdown container
* @default undefined
@@ -108,6 +117,7 @@ export const Dropdown = (props: DropdownProps) => {
open: controlledOpen,
defaultOpen = false,
onOpenChange,
+ defaultActiveIndex,
placement = 'bottom-start',
className,
} = getCurrentBreakpointProps(props);
@@ -115,12 +125,19 @@ export const Dropdown = (props: DropdownProps) => {
const nodeId = useFloatingNodeId();
const listItemsRef = React.useRef>([]);
- const [activeIndex, setActiveIndex] = React.useState(null);
+ const [activeIndex, setActiveIndex] = React.useState(defaultActiveIndex ?? null);
const [content, setContent] = React.useState(null);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const open = controlledOpen ?? uncontrolledOpen;
+ // Re-seed the active index every time the dropdown closes, so the next open
+ // starts with the caller-provided "current selection" pre-focused — not
+ // whatever activeIndex useListNavigation last left behind.
+ React.useEffect(() => {
+ if (!open) setActiveIndex(defaultActiveIndex ?? null);
+ }, [open, defaultActiveIndex]);
+
const setOpen = React.useCallback(
(next: boolean) => {
if (controlledOpen === undefined) {
@@ -151,6 +168,11 @@ export const Dropdown = (props: DropdownProps) => {
activeIndex,
onNavigate: setActiveIndex,
loop: true,
+ // When the caller passes `defaultActiveIndex`, treat that item as the
+ // current selection and force focus to land on it when the dropdown
+ // opens — regardless of whether it was opened via click or keyboard.
+ selectedIndex: defaultActiveIndex ?? null,
+ focusItemOnOpen: defaultActiveIndex !== null ? true : 'auto',
}),
]);
From 3050c25d08b8d7852e6840dab02a269245e32dce Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Fri, 24 Apr 2026 07:06:05 +0300
Subject: [PATCH 08/19] fix(time-field): code review fixes #25
---
.../components/form/time-field/time-field.tsx | 16 ++++++++--------
.../form/time-picker/time-picker.spec.tsx | 2 +-
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx
index f48f55c6..2ec38396 100644
--- a/src/tedi/components/form/time-field/time-field.tsx
+++ b/src/tedi/components/form/time-field/time-field.tsx
@@ -150,12 +150,9 @@ export const TimeField: React.FC = (props) => {
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'listbox' });
+ const shouldUseCustomInputTrigger = showPicker && isInputTrigger && !readOnly && !useNativePicker;
- const interactions = useInteractions([
- ...(showPicker && timePickerTrigger === 'input' && !readOnly ? [click] : []),
- dismiss,
- role,
- ]);
+ const interactions = useInteractions([...(shouldUseCustomInputTrigger ? [click] : []), dismiss, role]);
const updateTime = (time: string) => {
const cleaned = time.trim();
@@ -212,7 +209,7 @@ export const TimeField: React.FC = (props) => {
label,
value: currentValue,
placeholder,
- readOnly: readOnly || isInputTrigger,
+ readOnly: readOnly || (!useNativePicker && isInputTrigger),
icon: 'schedule',
isClearable: true,
required,
@@ -230,7 +227,10 @@ export const TimeField: React.FC = (props) => {
},
};
- if (availableTimes && availableTimesVariant === 'dropdown') {
+ const shouldUseDropdownPicker =
+ !useNativePicker && showPicker && !readOnly && availableTimesVariant === 'dropdown' && !!availableTimes?.length;
+
+ if (shouldUseDropdownPicker) {
// 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.
@@ -264,7 +264,7 @@ export const TimeField: React.FC = (props) => {
<>
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 e8cf7309..e1a51347 100644
--- a/src/tedi/components/form/time-picker/time-picker.spec.tsx
+++ b/src/tedi/components/form/time-picker/time-picker.spec.tsx
@@ -103,7 +103,7 @@ describe('TimePicker', () => {
expect(screen.getByTestId('selected')).toHaveTextContent('01:00');
});
- it('falls back to "00" when hour is not in generated hours', () => {
+ it('falls back to the default "12:00" when the parsed hour is not in generated hours', () => {
render(
);
expect(screen.getByTestId('selected')).toHaveTextContent('12:00');
From 304bd618adb75932fc4b66afd147b22c0727e752 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Mon, 4 May 2026 14:47:54 +0300
Subject: [PATCH 09/19] fix(time-field): improve scrolling, toggle picker
button #25
---
.../form/time-field/time-field.spec.tsx | 12 ++
.../components/form/time-field/time-field.tsx | 2 +-
.../components/time-wheel/time-wheel.spec.tsx | 4 +-
.../components/time-wheel/time-wheel.tsx | 125 ++++++++++++------
4 files changed, 101 insertions(+), 42 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 ae808e3e..0c378494 100644
--- a/src/tedi/components/form/time-field/time-field.spec.tsx
+++ b/src/tedi/components/form/time-field/time-field.spec.tsx
@@ -138,6 +138,18 @@ describe('TimeField', () => {
expect(screen.getByTestId('timepicker')).toBeInTheDocument();
});
+ it('closes custom picker when icon clicked again (button trigger)', async () => {
+ const user = userEvent.setup();
+
+ render(
);
+
+ await user.click(screen.getByTestId('icon'));
+ expect(screen.getByTestId('timepicker')).toBeInTheDocument();
+
+ await user.click(screen.getByTestId('icon'));
+ expect(screen.queryByTestId('timepicker')).not.toBeInTheDocument();
+ });
+
it('does NOT open picker when readOnly', async () => {
const user = userEvent.setup();
diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx
index 2ec38396..2a67b306 100644
--- a/src/tedi/components/form/time-field/time-field.tsx
+++ b/src/tedi/components/form/time-field/time-field.tsx
@@ -191,7 +191,7 @@ export const TimeField: React.FC
= (props) => {
input.focus();
};
- const openCustomPicker = () => setOpen(true);
+ const openCustomPicker = () => setOpen((prev) => !prev);
const handleIconClick = () => {
if (readOnly || !showPicker) return;
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 40fa5091..cd8ead46 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
@@ -93,7 +93,7 @@ describe('TimeWheel', () => {
act(() => {
Object.defineProperty(col, 'scrollTop', { value: 40, writable: true });
col.dispatchEvent(new Event('scroll'));
- jest.advanceTimersByTime(50);
+ jest.advanceTimersByTime(200);
});
expect(onChange).toHaveBeenCalled();
@@ -113,7 +113,7 @@ describe('TimeWheel', () => {
act(() => {
Object.defineProperty(col, 'scrollTop', { value: 40, writable: true });
col.dispatchEvent(new Event('scroll'));
- jest.advanceTimersByTime(50);
+ jest.advanceTimersByTime(200);
});
expect(onChange).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 a27ecb2c..b8e9f676 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
@@ -113,64 +113,111 @@ export const TimeWheel: React.FC = ({
setActiveMinuteIndex(minuteIndex);
}, [hours, minutes, selectedHour, selectedMinute]);
- const handleHourScroll = () => {
- if (!hourRef.current || isProgrammaticScrollHour.current) return;
+ // Callback refs — updated every render so scrollend listeners always use fresh
+ // closure values without needing to re-register. This avoids the stale-closure
+ // problem that arises when event listeners are registered once in useEffect.
+ const processHourScrollEnd = useRef<() => void>(() => {});
+ processHourScrollEnd.current = () => {
+ const el = hourRef.current;
+ if (!el || isProgrammaticScrollHour.current) return;
clearScrollTimeout(scrollTimeoutHour.current);
- scrollTimeoutHour.current = setTimeout(() => {
- if (!hourRef.current) return;
+ const index = clampIndex(snapToNearestItem(el.scrollTop, hours.length), hours.length);
+ setActiveHourIndex(index);
- const index = clampIndex(snapToNearestItem(hourRef.current.scrollTop, hours.length), hours.length);
+ const target = getScrollTopForIndex(index);
- setActiveHourIndex(index);
+ // Use 'instant' here — 'auto' would trigger the CSS scroll-behavior:smooth
+ // animation (~300ms), causing it to outlast the 50ms programmatic flag and
+ // treat the remaining animation frames as user scrolls, re-triggering snaps.
+ if (needsScrollCorrection(el.scrollTop, target, 8)) {
+ isProgrammaticScrollHour.current = true;
+ scrollToIndex(el, index, 'instant');
+ requestAnimationFrame(() => {
+ isProgrammaticScrollHour.current = false;
+ });
+ }
- const target = getScrollTopForIndex(index);
+ if (index !== lastHourIndex.current) {
+ lastHourIndex.current = index;
+ onChange(hours[index]!, selectedMinute);
+ }
+ };
- if (needsScrollCorrection(hourRef.current.scrollTop, target, 8)) {
- isProgrammaticScrollHour.current = true;
- scrollToIndex(hourRef.current, index);
- }
+ const processMinuteScrollEnd = useRef<() => void>(() => {});
+ processMinuteScrollEnd.current = () => {
+ const el = minuteRef.current;
+ if (!el || isProgrammaticScrollMinute.current) return;
- if (index !== lastHourIndex.current) {
- lastHourIndex.current = index;
- onChange(hours[index]!, selectedMinute);
- }
+ clearScrollTimeout(scrollTimeoutMinute.current);
- setTimeout(() => {
- isProgrammaticScrollHour.current = false;
- }, 50);
- });
+ const index = clampIndex(snapToNearestItem(el.scrollTop, minutes.length), minutes.length);
+ setActiveMinuteIndex(index);
+
+ const target = getScrollTopForIndex(index);
+
+ if (needsScrollCorrection(el.scrollTop, target, 8)) {
+ isProgrammaticScrollMinute.current = true;
+ scrollToIndex(el, index, 'instant');
+ requestAnimationFrame(() => {
+ isProgrammaticScrollMinute.current = false;
+ });
+ }
+
+ if (index !== lastMinuteIndex.current) {
+ lastMinuteIndex.current = index;
+ onChange(selectedHour, minutes[index]!);
+ }
};
- const handleMinuteScroll = () => {
- if (!minuteRef.current || isProgrammaticScrollMinute.current) return;
+ // Primary path: scrollend fires once after the CSS snap animation completes,
+ // so scrollTop is always at a valid snap point when we read it. This prevents
+ // the flicker caused by the debounced scroll handler reading an intermediate
+ // scrollTop mid-snap-animation (most visible on high-refresh-rate trackpads).
+ useEffect(() => {
+ const hourEl = hourRef.current;
+ const minuteEl = minuteRef.current;
+ if (!hourEl || !minuteEl) return;
- clearScrollTimeout(scrollTimeoutMinute.current);
+ const onHourScrollEnd = () => processHourScrollEnd.current();
+ const onMinuteScrollEnd = () => processMinuteScrollEnd.current();
- scrollTimeoutMinute.current = setTimeout(() => {
- if (!minuteRef.current) return;
+ hourEl.addEventListener('scrollend', onHourScrollEnd);
+ minuteEl.addEventListener('scrollend', onMinuteScrollEnd);
- const index = clampIndex(snapToNearestItem(minuteRef.current.scrollTop, minutes.length), minutes.length);
+ return () => {
+ hourEl.removeEventListener('scrollend', onHourScrollEnd);
+ minuteEl.removeEventListener('scrollend', onMinuteScrollEnd);
+ };
+ }, []);
- setActiveMinuteIndex(index);
+ // Fallback for browsers without scrollend support: debounce at 150ms so the
+ // handler fires well after the CSS snap animation has finished emitting scroll
+ // events. processScrollEnd clears this timer when scrollend fires first.
+ //
+ // The active-index state is updated on every scroll event so the highlight
+ // follows the wheel in real time — this gives instant visual feedback without
+ // waiting for scrollend. onChange is intentionally NOT called here; it fires
+ // only in processHourScrollEnd once the scroll has fully settled.
+ const handleHourScroll = () => {
+ if (!hourRef.current || isProgrammaticScrollHour.current) return;
- const target = getScrollTopForIndex(index);
+ const index = clampIndex(snapToNearestItem(hourRef.current.scrollTop, hours.length), hours.length);
+ setActiveHourIndex(index);
- if (needsScrollCorrection(minuteRef.current.scrollTop, target, 8)) {
- isProgrammaticScrollMinute.current = true;
- scrollToIndex(minuteRef.current, index);
- }
+ clearScrollTimeout(scrollTimeoutHour.current);
+ scrollTimeoutHour.current = setTimeout(() => processHourScrollEnd.current(), 150);
+ };
- if (index !== lastMinuteIndex.current) {
- lastMinuteIndex.current = index;
- onChange(selectedHour, minutes[index]!);
- }
+ const handleMinuteScroll = () => {
+ if (!minuteRef.current || isProgrammaticScrollMinute.current) return;
- setTimeout(() => {
- isProgrammaticScrollMinute.current = false;
- }, 50);
- });
+ const index = clampIndex(snapToNearestItem(minuteRef.current.scrollTop, minutes.length), minutes.length);
+ setActiveMinuteIndex(index);
+
+ clearScrollTimeout(scrollTimeoutMinute.current);
+ scrollTimeoutMinute.current = setTimeout(() => processMinuteScrollEnd.current(), 150);
};
const handleHourClick = (index: number) => {
From bcd674be2e3917d7a2bd74920e6f9b98ccb66eb9 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Mon, 11 May 2026 08:23:22 +0300
Subject: [PATCH 10/19] fix(time-field): code review changes #25
---
.../form/time-field/time-field.module.scss | 24 ++++++++++++++--
.../form/time-field/time-field.spec.tsx | 5 ++++
.../components/form/time-field/time-field.tsx | 28 +++++++++++--------
.../components/time-grid/time-grid.tsx | 16 +++++++++--
.../components/time-wheel/time-wheel.tsx | 14 +++++++++-
.../form/time-picker/time-picker.module.scss | 13 +++++++++
.../form/time-picker/time-picker.tsx | 11 ++++++++
7 files changed, 94 insertions(+), 17 deletions(-)
diff --git a/src/tedi/components/form/time-field/time-field.module.scss b/src/tedi/components/form/time-field/time-field.module.scss
index 54981a6b..5b2bc79e 100644
--- a/src/tedi/components/form/time-field/time-field.module.scss
+++ b/src/tedi/components/form/time-field/time-field.module.scss
@@ -33,8 +33,28 @@
}
}
- input[type='time']::-webkit-calendar-picker-indicator {
- display: none !important;
+ input[type='time'] {
+ appearance: none;
+ appearance: none;
+
+ &::-webkit-calendar-picker-indicator {
+ display: none !important;
+ width: 0;
+ height: 0;
+ padding: 0;
+ margin: 0;
+ appearance: none;
+ appearance: none;
+ pointer-events: none;
+ opacity: 0;
+ }
+
+ &::-webkit-inner-spin-button,
+ &::-webkit-clear-button {
+ display: none;
+ appearance: none;
+ appearance: none;
+ }
}
input[type='time'][value='']:not(:focus)::-webkit-datetime-edit {
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 0c378494..e8c0d776 100644
--- a/src/tedi/components/form/time-field/time-field.spec.tsx
+++ b/src/tedi/components/form/time-field/time-field.spec.tsx
@@ -12,6 +12,11 @@ jest.mock('../../../helpers', () => ({
...props,
}),
}),
+ useBreakpoint: () => 'xs',
+ isBreakpointBelow: (current: string, target: string) => {
+ const order = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
+ return order.indexOf(current) < order.indexOf(target);
+ },
}));
jest.mock('@floating-ui/react', () => ({
diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx
index 2a67b306..787e13b9 100644
--- a/src/tedi/components/form/time-field/time-field.tsx
+++ b/src/tedi/components/form/time-field/time-field.tsx
@@ -14,7 +14,7 @@ import {
import cn from 'classnames';
import React, { useEffect, useState } from 'react';
-import { BreakpointSupport, useBreakpointProps } from '../../../helpers';
+import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../helpers';
import { UnknownType } from '../../../types/commonTypes';
import { Dropdown } from '../../overlays/dropdown';
import TextField, { TextFieldForwardRef, TextFieldProps } from '../textfield/textfield';
@@ -136,6 +136,9 @@ 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 floating = useFloating({
open,
@@ -150,7 +153,7 @@ export const TimeField: React.FC = (props) => {
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'listbox' });
- const shouldUseCustomInputTrigger = showPicker && isInputTrigger && !readOnly && !useNativePicker;
+ const shouldUseCustomInputTrigger = showPicker && isInputTrigger && !readOnly && !shouldUseNativePicker;
const interactions = useInteractions([...(shouldUseCustomInputTrigger ? [click] : []), dismiss, role]);
@@ -196,7 +199,7 @@ export const TimeField: React.FC = (props) => {
const handleIconClick = () => {
if (readOnly || !showPicker) return;
- if (useNativePicker) {
+ if (shouldUseNativePicker) {
openNativePicker();
} else if (timePickerTrigger === 'button') {
openCustomPicker();
@@ -209,7 +212,7 @@ export const TimeField: React.FC = (props) => {
label,
value: currentValue,
placeholder,
- readOnly: readOnly || (!useNativePicker && isInputTrigger),
+ readOnly: readOnly || (!shouldUseNativePicker && isInputTrigger),
icon: 'schedule',
isClearable: true,
required,
@@ -219,21 +222,22 @@ export const TimeField: React.FC = (props) => {
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 }
+ { [styles['tedi-time-field--native']]: shouldUseNativePicker }
),
input: {
...(inputProps?.input as UnknownType),
- type: 'time',
+ ...(shouldUseNativePicker && { type: 'time' }),
},
};
const shouldUseDropdownPicker =
- !useNativePicker && showPicker && !readOnly && availableTimesVariant === 'dropdown' && !!availableTimes?.length;
+ !shouldUseNativePicker &&
+ showPicker &&
+ !readOnly &&
+ availableTimesVariant === 'dropdown' &&
+ !!availableTimes?.length;
if (shouldUseDropdownPicker) {
- // 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;
@@ -242,7 +246,7 @@ export const TimeField: React.FC = (props) => {
@@ -271,7 +275,7 @@ export const TimeField: React.FC = (props) => {
- {!useNativePicker && showPicker && (
+ {!shouldUseNativePicker && showPicker && (
{open && !readOnly && (
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 c66644e0..85af3529 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
@@ -32,6 +32,11 @@ export interface TimeGridProps {
* Additional CSS class name for custom styling
*/
className?: string;
+ /**
+ * Whether to render the surrounding card chrome (border, background, radius).
+ * @default true
+ */
+ bordered?: boolean;
}
export const TimeGrid: React.FC = ({
@@ -41,6 +46,7 @@ export const TimeGrid: React.FC = ({
className,
colWidth = 4,
variant = 'button',
+ bordered = true,
}) => {
const timeGridId = useId();
const { getLabel } = useLabels();
@@ -101,9 +107,15 @@ export const TimeGrid: React.FC = ({
radios[nextIndex]?.focus();
};
+ const rootClassName = cn(
+ styles['tedi-time-picker__grid'],
+ { [styles['tedi-time-picker__grid--borderless']]: !bordered },
+ className
+ );
+
if (variant === 'radio') {
return (
-
+
= ({
}
return (
-
+
{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 b8e9f676..612828eb 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
@@ -43,6 +43,11 @@ export interface TimeWheelProps {
* Additional CSS class name to apply to the root wheel container.
*/
className?: string;
+ /**
+ * Whether to render the surrounding card chrome (border, background, radius).
+ * @default true
+ */
+ bordered?: boolean;
}
export const TimeWheel: React.FC = ({
@@ -52,6 +57,7 @@ export const TimeWheel: React.FC = ({
selectedMinute,
onChange,
className,
+ bordered = true,
}) => {
const uid = React.useId();
const hourRef = useRef(null);
@@ -309,7 +315,13 @@ export const TimeWheel: React.FC = ({
}, []);
return (
-
+
= ({
@@ -63,6 +71,7 @@ export const TimePicker: React.FC
= ({
availableTimes,
gridVariant = 'button',
className,
+ bordered = true,
}) => {
const [internal, setInternal] = React.useState(defaultValue);
const isControlled = value !== undefined;
@@ -88,6 +97,7 @@ export const TimePicker: React.FC = ({
variant={gridVariant}
onSelect={handleChange}
className={className}
+ bordered={bordered}
/>
);
}
@@ -100,6 +110,7 @@ export const TimePicker: React.FC = ({
selectedMinute={selectedMinute}
onChange={(hour, minute) => handleChange(`${hour}:${minute}`)}
className={className}
+ bordered={bordered}
/>
);
};
From 639f0bae6de0581a8c50335be69c9d61b9eede72 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Mon, 11 May 2026 08:39:05 +0300
Subject: [PATCH 11/19] fix(time-field): remove duplicate row #25
---
src/tedi/components/form/time-field/time-field.module.scss | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/tedi/components/form/time-field/time-field.module.scss b/src/tedi/components/form/time-field/time-field.module.scss
index 5b2bc79e..1eb91fed 100644
--- a/src/tedi/components/form/time-field/time-field.module.scss
+++ b/src/tedi/components/form/time-field/time-field.module.scss
@@ -35,7 +35,6 @@
input[type='time'] {
appearance: none;
- appearance: none;
&::-webkit-calendar-picker-indicator {
display: none !important;
@@ -44,7 +43,6 @@
padding: 0;
margin: 0;
appearance: none;
- appearance: none;
pointer-events: none;
opacity: 0;
}
@@ -53,7 +51,6 @@
&::-webkit-clear-button {
display: none;
appearance: none;
- appearance: none;
}
}
From aa2e57daa145a3098b2d4eef7a4d04ac0838cd9d Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Mon, 11 May 2026 09:17:14 +0300
Subject: [PATCH 12/19] fix(time-field): allow useNativePicker to be used
regardless of breakpoint #25
---
src/tedi/components/form/time-field/time-field.tsx | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx
index 787e13b9..29a3693c 100644
--- a/src/tedi/components/form/time-field/time-field.tsx
+++ b/src/tedi/components/form/time-field/time-field.tsx
@@ -14,7 +14,7 @@ import {
import cn from 'classnames';
import React, { useEffect, useState } from 'react';
-import { BreakpointSupport, isBreakpointBelow, useBreakpoint, useBreakpointProps } from '../../../helpers';
+import { BreakpointSupport, useBreakpointProps } from '../../../helpers';
import { UnknownType } from '../../../types/commonTypes';
import { Dropdown } from '../../overlays/dropdown';
import TextField, { TextFieldForwardRef, TextFieldProps } from '../textfield/textfield';
@@ -24,8 +24,11 @@ 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.
+ * If `true`, the field swaps the custom time-picker popover for the
+ * browser's native time picker (` `). Works on both
+ * mobile and desktop — useful when the consumer wants to skip the custom
+ * UI entirely.
+ * Note: When using the native picker, the `availableTimes` prop is ignored.
* @default false
*/
useNativePicker?: boolean;
@@ -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}
/>
icon
@@ -229,4 +230,33 @@ describe('TimeField', () => {
expect(showPicker).toHaveBeenCalled();
});
+
+ it('normalises a delimiter-less time on blur (e.g. "1155" -> "11:55")', async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render( );
+
+ const input = screen.getByTestId('textfield-input');
+ await user.click(input);
+ await user.keyboard('1155');
+ await user.tab();
+
+ await waitFor(() => expect(input).toHaveValue('11:55'));
+ expect(onChange).toHaveBeenLastCalledWith('11:55');
+ });
+
+ it('leaves invalid input untouched on blur (no normalisation possible)', async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render( );
+
+ const input = screen.getByTestId('textfield-input');
+ await user.click(input);
+ await user.keyboard('9999');
+ onChange.mockClear();
+ await user.tab();
+
+ expect(input).toHaveValue('9999');
+ expect(onChange).not.toHaveBeenCalled();
+ });
});
diff --git a/src/tedi/components/form/time-field/time-field.tsx b/src/tedi/components/form/time-field/time-field.tsx
index 29a3693c..d6ac8b81 100644
--- a/src/tedi/components/form/time-field/time-field.tsx
+++ b/src/tedi/components/form/time-field/time-field.tsx
@@ -20,7 +20,7 @@ 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';
+import { normalizeTime, TIMEPICKER_OFFSET } from './time-field-helpers';
type TimeFieldBreakpointProps = {
/**
@@ -168,6 +168,23 @@ export const TimeField: React.FC = (props) => {
onChange?.(cleaned);
};
+ // Normalise common typed shorthands on blur (e.g. "1155" → "11:55",
+ // "9:5" → "09:05"). Doesn't run while the user is still typing — we keep
+ // the raw value visible until they tab/click away so the field doesn't
+ // fight mid-keystroke. Invalid input is left as-is for the consumer's
+ // validation to flag.
+ const handleInputBlur: React.FocusEventHandler = (event) => {
+ // Read off the target BEFORE running consumer's onBlur — React pools
+ // SyntheticEvents and `currentTarget` is nulled after the listener
+ // returns, so we must capture upfront.
+ const raw = (event.target as HTMLInputElement).value ?? '';
+ (inputProps?.onBlur as React.FocusEventHandler | undefined)?.(event);
+ const normalised = normalizeTime(raw);
+ if (normalised !== null && normalised !== raw) {
+ updateTime(normalised);
+ }
+ };
+
useEffect(() => {
if (value !== undefined) {
setInternalValue(value);
@@ -219,6 +236,7 @@ export const TimeField: React.FC = (props) => {
required,
onIconClick: handleIconClick,
onChange: updateTime,
+ onBlur: handleInputBlur,
className: cn(
styles['tedi-time-field__textfield'],
{ [styles['tedi-time-field__icon--disabled']]: !showPicker || readOnly },
From 1c1f6980f69c6005bcf81cbf34a481270f19c458 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Tue, 12 May 2026 13:36:22 +0300
Subject: [PATCH 14/19] fix(choice-group): remove comment #25
---
.../components/choice-group-item/choice-group-item.tsx | 8 --------
1 file changed, 8 deletions(-)
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 d4ee6dae..b08c7dab 100644
--- a/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.tsx
+++ b/src/tedi/components/form/choice-group/components/choice-group-item/choice-group-item.tsx
@@ -92,14 +92,6 @@ 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 (
From 487a4cc5cfe32f39ff4c1a13afa3ee2ac22aa0e2 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Wed, 13 May 2026 14:58:06 +0300
Subject: [PATCH 15/19] feat(time-wheel): wrap keyboard navigation at column
ends #25
---
.../components/time-wheel/time-wheel.spec.tsx | 75 +++++++++++++++----
.../components/time-wheel/time-wheel.tsx | 18 +++--
2 files changed, 74 insertions(+), 19 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 cd8ead46..49e5b214 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
@@ -308,7 +308,7 @@ describe('TimeWheel', () => {
expect(onChange).not.toHaveBeenCalled();
});
- it('ArrowUp focuses the previous hour item and clamps at 0', () => {
+ it('ArrowUp from index 0 wraps to the last item', () => {
render(
{
const col = screen.getAllByRole('listbox')[0];
const items = col.querySelectorAll('[role="option"]');
- const focusSpy = jest.spyOn(items[0] as HTMLElement, 'focus');
+ // Wrap target: from index 0 going up should jump to the last item
+ // (mirrors Angular's TimeWheel where 00 → 23 / 59 on ArrowUp).
+ const lastFocusSpy = jest.spyOn(items[items.length - 1] as HTMLElement, 'focus');
+ const lastScrollSpy = jest.spyOn(items[items.length - 1] as HTMLElement, 'scrollIntoView');
act(() => {
col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
});
- expect(focusSpy).toHaveBeenCalled();
+ expect(lastFocusSpy).toHaveBeenCalled();
+ // Wrap-around scrolls use 'auto' so the wheel jumps instantly instead of
+ // smooth-scrolling across every item.
+ expect(lastScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' });
+ });
+
+ it('ArrowDown from the last index wraps to the first item', () => {
+ render(
+
+ );
+
+ const col = screen.getAllByRole('listbox')[0];
+ const items = col.querySelectorAll('[role="option"]');
+ const firstFocusSpy = jest.spyOn(items[0] as HTMLElement, 'focus');
+ const firstScrollSpy = jest.spyOn(items[0] as HTMLElement, 'scrollIntoView');
+
+ act(() => {
+ col.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ });
+
+ expect(firstFocusSpy).toHaveBeenCalled();
+ expect(firstScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' });
});
it('Home focuses the first item and End focuses the last item', () => {
@@ -357,41 +387,58 @@ describe('TimeWheel', () => {
expect(lastFocusSpy).toHaveBeenCalled();
});
- it('PageDown and PageUp jump by 5 items and clamp at both ends', () => {
+ it('PageDown jumps by 5 items within bounds', () => {
const list = ['00', '01', '02', '03', '04', '05', '06', '07'];
render( );
const col = screen.getAllByRole('listbox')[0];
const items = col.querySelectorAll('[role="option"]');
- const pageDownSpy = jest.spyOn(items[6] as HTMLElement, 'focus');
+ // From index 1, +5 = 6 (within bounds) → focuses index 6 with a smooth scroll.
+ const pageDownSpy = jest.spyOn(items[6] as HTMLElement, 'focus');
+ const pageDownScrollSpy = jest.spyOn(items[6] as HTMLElement, 'scrollIntoView');
act(() => {
col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true }));
});
expect(pageDownSpy).toHaveBeenCalled();
-
- const pageUpSpy = jest.spyOn(items[0] as HTMLElement, 'focus');
- act(() => {
- col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true }));
- });
- expect(pageUpSpy).toHaveBeenCalled();
+ expect(pageDownScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' });
});
- it('PageDown clamps to the last item when near the end of the list', () => {
+ it('PageDown wraps around when the jump would exceed the end', () => {
const list = ['00', '01', '02', '03'];
render( );
const col = screen.getAllByRole('listbox')[0];
const items = col.querySelectorAll('[role="option"]');
- const lastFocusSpy = jest.spyOn(items[3] as HTMLElement, 'focus');
+ // From index 2, +5 = 7 → wraps to (2 + 5) % 4 = 3 with an instant 'auto' scroll.
+ const wrapSpy = jest.spyOn(items[3] as HTMLElement, 'focus');
+ const wrapScrollSpy = jest.spyOn(items[3] as HTMLElement, 'scrollIntoView');
act(() => {
col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true }));
});
+ expect(wrapSpy).toHaveBeenCalled();
+ expect(wrapScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' });
+ });
- expect(lastFocusSpy).toHaveBeenCalled();
+ it('PageUp wraps around when the jump would go below 0', () => {
+ const list = ['00', '01', '02', '03', '04', '05', '06', '07'];
+
+ render( );
+
+ const col = screen.getAllByRole('listbox')[0];
+ const items = col.querySelectorAll('[role="option"]');
+
+ // From index 1, -5 = -4 → wraps to (1 - 5 + 8) % 8 = 4 with an instant 'auto' scroll.
+ const wrapSpy = jest.spyOn(items[4] as HTMLElement, 'focus');
+ const wrapScrollSpy = jest.spyOn(items[4] as HTMLElement, 'scrollIntoView');
+ act(() => {
+ col.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true }));
+ });
+ expect(wrapSpy).toHaveBeenCalled();
+ expect(wrapScrollSpy).toHaveBeenCalledWith({ block: 'center', behavior: 'auto' });
});
it('ignores unhandled keys', () => {
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 612828eb..df7862a4 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
@@ -269,13 +269,19 @@ export const TimeWheel: React.FC = ({
if (currentIndex === -1) return;
let nextIndex = -1;
+ // Track whether the new index wrapped around (e.g. 59 → 00 going down,
+ // 00 → 59 going up). When it does, animate the scroll instantly so we
+ // don't smooth-scroll across the entire wheel.
+ let wrapped = false;
switch (event.key) {
case 'ArrowDown':
- nextIndex = Math.min(currentIndex + 1, list.length - 1);
+ nextIndex = (currentIndex + 1) % list.length;
+ wrapped = currentIndex === list.length - 1;
break;
case 'ArrowUp':
- nextIndex = Math.max(currentIndex - 1, 0);
+ nextIndex = (currentIndex - 1 + list.length) % list.length;
+ wrapped = currentIndex === 0;
break;
case 'Home':
nextIndex = 0;
@@ -284,10 +290,12 @@ export const TimeWheel: React.FC = ({
nextIndex = list.length - 1;
break;
case 'PageDown':
- nextIndex = Math.min(currentIndex + 5, list.length - 1);
+ nextIndex = (currentIndex + 5) % list.length;
+ wrapped = currentIndex + 5 >= list.length;
break;
case 'PageUp':
- nextIndex = Math.max(currentIndex - 5, 0);
+ nextIndex = (currentIndex - 5 + list.length) % list.length;
+ wrapped = currentIndex - 5 < 0;
break;
case 'Enter':
case ' ':
@@ -304,7 +312,7 @@ export const TimeWheel: React.FC = ({
const el = container?.querySelector(`#${CSS.escape(`${uid}-${type}-${nextIndex}`)}`);
el?.focus();
- el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ el?.scrollIntoView({ block: 'center', behavior: wrapped ? 'auto' : 'smooth' });
};
useEffect(() => {
From 765140d744e0bb90f0d260ef56d50b8b6312cf41 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Wed, 13 May 2026 15:07:09 +0300
Subject: [PATCH 16/19] chore: update consumer skills for TimeField and
TimePicker #25
---
skills/tedi-react/SKILL.md | 2 +-
skills/tedi-react/references/components.md | 58 ++++++++++++++++++++++
skills/tedi-react/references/forms.md | 46 +++++++++++++++++
3 files changed, 105 insertions(+), 1 deletion(-)
diff --git a/skills/tedi-react/SKILL.md b/skills/tedi-react/SKILL.md
index e6ca75ed..e65929fd 100644
--- a/skills/tedi-react/SKILL.md
+++ b/skills/tedi-react/SKILL.md
@@ -154,7 +154,7 @@ const [email, setEmail] = useState('');
setAgreed(checked)} />
```
-Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `FileUpload`, `FileDropzone`.
+Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `TimeField`, `FileUpload`, `FileDropzone`.
## Theming
diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md
index 5fa6848d..4aadd44f 100644
--- a/skills/tedi-react/references/components.md
+++ b/skills/tedi-react/references/components.md
@@ -232,6 +232,64 @@ Same as Checkbox (without indeterminate)
- `onSearch?: (value: string) => void`
- `button?: Partial`
+### TimeField
+**Props:** `TimeFieldProps` | bp, form
+- `id: string` (required), `label: string` (required)
+- `value?: string`, `defaultValue?: string` — `"HH:mm"` 24-hour format
+- `onChange?: (time: string) => void`
+- `placeholder?: string`
+- `required?: boolean`, `readOnly?: boolean`
+- `stepMinutes?: number = 1` — minute increment for the picker wheel / grid
+- `availableTimes?: string[]` — limit selectable times to a fixed list (`["09:00", "09:30", …]`); switches the popover to grid mode
+- `inputProps?: Omit` — pass-through to the underlying input
+- `className?: string`
+- **Breakpoint-aware:** `useNativePicker?: boolean = false` (swap to ` `; ignores `availableTimes`), `showPicker?: boolean = true`, `timePickerTrigger?: 'input' | 'button' = 'button'`, `availableTimesVariant?: 'grid-buttons' | 'grid-radio' | 'dropdown'` — which variant the picker renders when `availableTimes` is set
+
+```tsx
+
+
+// Constrain to specific slots, render as a radio-button grid
+
+
+// Native picker on mobile, custom wheel on desktop
+
+```
+
+### TimePicker
+> **For plain time inputs use `TimeField`.** TimePicker is the lower-level picker primitive — reach for it only when you need a standalone, always-visible time selector (scheduling UI, custom popover, side-by-side with a calendar in a DateTime composite).
+
+**Props:** `TimePickerProps` | form
+- `value?: string`, `defaultValue?: string` — `"HH:mm"`
+- `onChange?: (time: string) => void`
+- `stepMinutes?: number = 1` — minute increment for the wheel
+- `availableTimes?: string[]` — switches from scroll-wheel mode to a predefined-slots grid
+- `gridVariant?: 'button' | 'radio' = 'button'` — only used with `availableTimes`
+- `bordered?: boolean = true` — set `false` when embedding inside a parent that already provides its own surface (e.g. alongside a Calendar)
+- `className?: string`
+
+The wheel column supports full keyboard navigation: `ArrowUp` / `ArrowDown` and `PageUp` / `PageDown` cycle through the column (wrap at both ends), `Home` / `End` jump to the bounds, `Enter` / `Space` commit the highlighted value.
+
+```tsx
+import { TimePicker } from '@tedi-design-system/react/tedi';
+
+
+
+// Predefined slots
+
+```
+
### FileUpload
**Props:** `FileUploadProps` | form
- `id: string` (required), `name: string` (required)
diff --git a/skills/tedi-react/references/forms.md b/skills/tedi-react/references/forms.md
index cedd7c86..6d3fbed1 100644
--- a/skills/tedi-react/references/forms.md
+++ b/skills/tedi-react/references/forms.md
@@ -14,6 +14,7 @@ TEDI form controls support both **controlled** and **uncontrolled** modes, follo
| Radio | `boolean` (via onChange) | Used in ChoiceGroup |
| ChoiceGroup | `ChoiceGroupValue` | Radio/checkbox groups, segmented variant |
| Search | `string` | Search button, onSearch callback |
+| TimeField | `string` (`"HH:mm"`) | Wheel / grid picker, native fallback, stepMinutes, availableTimes |
| FileUpload | `FileUploadFile[]` | Multi-file, validation, loading states |
| FileDropzone | `FileUploadFile[]` | Drag-and-drop |
@@ -108,6 +109,50 @@ import { NumberField } from '@tedi-design-system/react/tedi';
/>
```
+## TimeField
+
+The value is always a `"HH:mm"` 24-hour string. The popover defaults to a wheel picker; set `availableTimes` to switch to a fixed-slot grid, or `useNativePicker` to drop the custom UI entirely.
+
+```tsx
+import { TimeField } from '@tedi-design-system/react/tedi';
+
+// Wheel picker, 15-minute step
+
+
+// Constrain to predefined slots, render as a radio-button grid
+
+
+// Native picker on mobile, custom wheel on desktop
+
+```
+
+For an always-visible time selector (e.g. side-by-side with a calendar, or inside a custom popover) use the lower-level `TimePicker` directly:
+
+```tsx
+import { TimePicker } from '@tedi-design-system/react/tedi';
+
+
+```
+
## Checkbox & Radio
```tsx
@@ -213,6 +258,7 @@ import { FileUpload, FileDropzone } from '@tedi-design-system/react/tedi';
- **Choice inputs:** `onChange?: (value: string, checked: boolean) => void`
- **Select:** `onChange?: (value: ISelectOption | ISelectOption[] | null) => void`
- **NumberField:** `onChange?: (value: number) => void`
+- **TimeField / TimePicker:** `onChange?: (time: string) => void` — value is always `"HH:mm"` 24-hour format (empty string when cleared)
## Disabled State
From 264ff9796c447a8f1e5255bd4d3e14f29c50d846 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Wed, 20 May 2026 08:23:55 +0300
Subject: [PATCH 17/19] fix(time-field): cr fixes #25
---
.../form/date-field/date-field.stories.tsx | 52 +++++++++++++++++++
.../form/textfield/textfield.module.scss | 5 ++
.../components/form/textfield/textfield.tsx | 4 +-
.../form/time-field/time-field.stories.tsx | 50 ++++++++++++++++++
4 files changed, 110 insertions(+), 1 deletion(-)
diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx
index f054f853..b7ee7f59 100644
--- a/src/tedi/components/form/date-field/date-field.stories.tsx
+++ b/src/tedi/components/form/date-field/date-field.stories.tsx
@@ -442,3 +442,55 @@ export const NativePicker: Story = {
);
},
};
+
+const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;
+
+export const States: Story = {
+ render: () => (
+
+ {stateArray.map((state) => (
+
+
+ {state}
+
+
+
+
+
+ ))}
+
+
+ Success
+
+
+
+
+
+
+
+ Error
+
+
+
+
+
+
+ ),
+ parameters: {
+ pseudo: {
+ hover: '#Hover',
+ focus: '#Focus',
+ active: '#Active',
+ },
+ },
+};
diff --git a/src/tedi/components/form/textfield/textfield.module.scss b/src/tedi/components/form/textfield/textfield.module.scss
index 1dfda1e9..b9a594e3 100644
--- a/src/tedi/components/form/textfield/textfield.module.scss
+++ b/src/tedi/components/form/textfield/textfield.module.scss
@@ -175,6 +175,10 @@ $input-padding-right-map: (
color: var(--form-input-text-filled);
}
+ &:not(div, :disabled):active {
+ color: var(--button-main-neutral-text-active);
+ }
+
&:disabled {
cursor: initial;
}
@@ -198,6 +202,7 @@ div.tedi-textfield__icon-wrapper {
.tedi-textfield__feedback-wrapper {
display: flex;
+ margin-top: var(--form-field-outer-spacing);
}
.tedi-textfield__separator {
diff --git a/src/tedi/components/form/textfield/textfield.tsx b/src/tedi/components/form/textfield/textfield.tsx
index 7f353748..9ea5c1b3 100644
--- a/src/tedi/components/form/textfield/textfield.tsx
+++ b/src/tedi/components/form/textfield/textfield.tsx
@@ -344,8 +344,10 @@ export const TextField = forwardRef((props,
const renderIcon = useCallback(() => {
if (!icon) return null;
+ const isInteractiveIcon = Boolean(onIconClick);
+ const smallIconSize = isInteractiveIcon ? 18 : 16;
const defaultIconProps: Partial = {
- size: size === 'large' ? 24 : size === 'small' ? 16 : 18,
+ size: size === 'large' ? 24 : size === 'small' ? smallIconSize : 18,
className: styles['tedi-textfield__icon'],
};
diff --git a/src/tedi/components/form/time-field/time-field.stories.tsx b/src/tedi/components/form/time-field/time-field.stories.tsx
index 735e0b01..9590dde6 100644
--- a/src/tedi/components/form/time-field/time-field.stories.tsx
+++ b/src/tedi/components/form/time-field/time-field.stories.tsx
@@ -273,3 +273,53 @@ export const NativePicker: Story = {
},
},
};
+
+const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;
+
+export const States: StoryObj = {
+ render: () => (
+
+ {stateArray.map((state) => (
+
+
+ {state}
+
+
+
+
+
+ ))}
+
+
+ Success
+
+
+
+
+
+
+
+ Error
+
+
+
+
+
+
+ ),
+ parameters: {
+ pseudo: {
+ hover: '#Hover',
+ focus: '#Focus',
+ active: '#Active',
+ },
+ },
+};
From 6947a4dcb764d27170c9e8178538ba8b5060c969 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Wed, 20 May 2026 12:32:16 +0300
Subject: [PATCH 18/19] fix(time-field): cr fixes #25
---
.../form/date-field/date-field.module.scss | 4 +
.../form/date-field/date-field.stories.tsx | 104 +++++++++---------
.../form/time-field/time-field.module.scss | 4 +
.../form/time-field/time-field.stories.tsx | 100 ++++++++---------
4 files changed, 110 insertions(+), 102 deletions(-)
diff --git a/src/tedi/components/form/date-field/date-field.module.scss b/src/tedi/components/form/date-field/date-field.module.scss
index 5cede655..5cb21f6b 100644
--- a/src/tedi/components/form/date-field/date-field.module.scss
+++ b/src/tedi/components/form/date-field/date-field.module.scss
@@ -30,6 +30,10 @@
&[aria-expanded='true'] button:not([data-name='closing-button']):last-child {
background-color: var(--form-datepicker-date-hover);
+
+ > span {
+ color: var(--button-main-neutral-text-active);
+ }
}
&--disabled {
diff --git a/src/tedi/components/form/date-field/date-field.stories.tsx b/src/tedi/components/form/date-field/date-field.stories.tsx
index b7ee7f59..5776bea2 100644
--- a/src/tedi/components/form/date-field/date-field.stories.tsx
+++ b/src/tedi/components/form/date-field/date-field.stories.tsx
@@ -85,6 +85,58 @@ export const Size: StoryObj = {
},
};
+const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;
+
+export const States: Story = {
+ render: () => (
+
+ {stateArray.map((state) => (
+
+
+ {state}
+
+
+
+
+
+ ))}
+
+
+ Success
+
+
+
+
+
+
+
+ Error
+
+
+
+
+
+
+ ),
+ parameters: {
+ pseudo: {
+ hover: '#Hover',
+ focus: '#Focus',
+ active: '#Active',
+ },
+ },
+};
+
export const FieldOptions: StoryFn = () => {
const [shortcutValue, setShortcutValue] = useState(undefined);
@@ -442,55 +494,3 @@ export const NativePicker: Story = {
);
},
};
-
-const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;
-
-export const States: Story = {
- render: () => (
-
- {stateArray.map((state) => (
-
-
- {state}
-
-
-
-
-
- ))}
-
-
- Success
-
-
-
-
-
-
-
- Error
-
-
-
-
-
-
- ),
- parameters: {
- pseudo: {
- hover: '#Hover',
- focus: '#Focus',
- active: '#Active',
- },
- },
-};
diff --git a/src/tedi/components/form/time-field/time-field.module.scss b/src/tedi/components/form/time-field/time-field.module.scss
index 1eb91fed..a708ed03 100644
--- a/src/tedi/components/form/time-field/time-field.module.scss
+++ b/src/tedi/components/form/time-field/time-field.module.scss
@@ -19,6 +19,10 @@
&[aria-expanded='true'] button:not([data-name='closing-button']):last-child {
background-color: var(--form-datepicker-date-hover);
+
+ > span {
+ color: var(--button-main-neutral-text-active);
+ }
}
&--disabled {
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 9590dde6..573f44b7 100644
--- a/src/tedi/components/form/time-field/time-field.stories.tsx
+++ b/src/tedi/components/form/time-field/time-field.stories.tsx
@@ -82,6 +82,56 @@ export const Sizes: StoryObj = {
},
};
+const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;
+
+export const States: StoryObj = {
+ render: () => (
+
+ {stateArray.map((state) => (
+
+
+ {state}
+
+
+
+
+
+ ))}
+
+
+ Success
+
+
+
+
+
+
+
+ Error
+
+
+
+
+
+
+ ),
+ parameters: {
+ pseudo: {
+ hover: '#Hover',
+ focus: '#Focus',
+ active: '#Active',
+ },
+ },
+};
+
export const FieldOptions: StoryFn = () => {
return (
@@ -273,53 +323,3 @@ export const NativePicker: Story = {
},
},
};
-
-const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;
-
-export const States: StoryObj = {
- render: () => (
-
- {stateArray.map((state) => (
-
-
- {state}
-
-
-
-
-
- ))}
-
-
- Success
-
-
-
-
-
-
-
- Error
-
-
-
-
-
-
- ),
- parameters: {
- pseudo: {
- hover: '#Hover',
- focus: '#Focus',
- active: '#Active',
- },
- },
-};
From 0baffbfa3de8b9c66b0727c4bee12e6adad23664 Mon Sep 17 00:00:00 2001
From: Airike Jaska <95303654+airikej@users.noreply.github.com>
Date: Wed, 20 May 2026 13:43:37 +0300
Subject: [PATCH 19/19] fix(time-picker): fix radio grid responsive #25
---
.../components/time-grid/time-grid.tsx | 37 ++++++++++---------
.../form/time-picker/time-picker.module.scss | 1 -
2 files changed, 20 insertions(+), 18 deletions(-)
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 85af3529..7f7eb936 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
@@ -3,7 +3,7 @@ import { useEffect, useId, useRef } from 'react';
import { useLabels } from '../../../../../providers/label-provider';
import Button from '../../../../buttons/button/button';
-import { Col, ColSize, Row } from '../../../../layout/grid';
+import { Col, ColProps, ColSize, Row } from '../../../../layout/grid';
import ChoiceGroup from '../../../choice-group/choice-group';
import styles from '../../time-picker.module.scss';
@@ -21,9 +21,17 @@ export interface TimeGridProps {
*/
onSelect: (time: string) => void;
/**
- * Grid column width
+ * Grid column width per time slot. Accepts either:
+ *
+ * - a single `ColSize` (1–12 or `'auto'`) applied at every breakpoint, or
+ * - a breakpoint object (`{ xs?, sm?, md?, lg?, xl?, xxl? }`) for responsive
+ * layouts.
+ *
+ * Default is `{ xs: 6, md: 4 }` — 2 slots per row on phones (where 33%
+ * is too narrow for the radio card's intrinsic content width and would
+ * otherwise wrap one-per-row), 3 slots per row from `md` up.
*/
- colWidth?: ColSize;
+ colWidth?: ColSize | Pick;
/**
* Display mode
*/
@@ -44,7 +52,7 @@ export const TimeGrid: React.FC = ({
value,
onSelect,
className,
- colWidth = 4,
+ colWidth = { xs: 6, md: 4 },
variant = 'button',
bordered = true,
}) => {
@@ -52,10 +60,6 @@ export const TimeGrid: React.FC = ({
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 opens pre-highlighted.
- // Runs once per mount — TimeGrid remounts every time the picker reopens.
useEffect(() => {
if (!value) return;
const root = rootRef.current;
@@ -67,12 +71,6 @@ export const TimeGrid: React.FC = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // Listbox-style keyboard handling for the radio variant: arrow keys move
- // focus between slots *without* triggering native same-name-radio
- // auto-selection. Without this, Arrow-Down would also mark the next radio
- // checked, fire onSelect/onChange, and (inside TimeField) close the picker.
- // Space/Enter fall through to native radio activation — that still fires
- // onChange and lets the picker close on explicit confirmation.
const handleRadioKeyDown = (event: React.KeyboardEvent) => {
if (variant !== 'radio') return;
const root = rootRef.current;
@@ -109,10 +107,15 @@ export const TimeGrid: React.FC = ({
const rootClassName = cn(
styles['tedi-time-picker__grid'],
- { [styles['tedi-time-picker__grid--borderless']]: !bordered },
+ {
+ [styles['tedi-time-picker__grid--borderless']]: !bordered,
+ },
className
);
+ const resolvedColProps: Pick =
+ typeof colWidth === 'object' ? colWidth : { width: colWidth };
+
if (variant === 'radio') {
return (
@@ -127,7 +130,7 @@ export const TimeGrid: React.FC
= ({
id: `time-${timeGridId}-${time}`,
label: time,
value: time,
- colProps: { width: colWidth },
+ colProps: resolvedColProps,
}))}
direction="row"
variant="card"
@@ -143,7 +146,7 @@ export const TimeGrid: React.FC = ({
{times.map((time) => (
-
+