Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3efd978
feat(time-field): new TEDI-ready component #25
airikej Apr 17, 2026
246af09
fix(time-field, time-picker): code review fixes #25
airikej Apr 21, 2026
35adf95
fix(time-picker): code review fixes #25
airikej Apr 21, 2026
cf72695
fix(time-picker): code review fixes, improve test coverage #25
airikej Apr 21, 2026
769e2a3
fix(time-field, time-picker): code review fixes #25
airikej Apr 23, 2026
9723972
fix(time-picker): fix initial value reset #25
airikej Apr 23, 2026
812811a
fix(time-field): radio grid tab selection fix #25
airikej Apr 23, 2026
3050c25
fix(time-field): code review fixes #25
airikej Apr 24, 2026
304bd61
fix(time-field): improve scrolling, toggle picker button #25
airikej May 4, 2026
bcd674b
fix(time-field): code review changes #25
airikej May 11, 2026
639f0ba
fix(time-field): remove duplicate row #25
airikej May 11, 2026
aa2e57d
fix(time-field): allow useNativePicker to be used regardless of break…
airikej May 11, 2026
d2502ed
fix(time-field): add formatting when valid time without delimiter is …
airikej May 11, 2026
1c1f698
fix(choice-group): remove comment #25
airikej May 12, 2026
487a4cc
feat(time-wheel): wrap keyboard navigation at column ends #25
airikej May 13, 2026
765140d
chore: update consumer skills for TimeField and TimePicker #25
airikej May 13, 2026
279dd36
Merge branch 'rc' into feat/25-timefield-new-tedi-ready-component-1
airikej May 14, 2026
264ff97
fix(time-field): cr fixes #25
airikej May 20, 2026
6947a4d
fix(time-field): cr fixes #25
airikej May 20, 2026
0baffbf
fix(time-picker): fix radio grid responsive #25
airikej May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/tedi-react/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ const [email, setEmail] = useState('');
<Checkbox id="agree" label="I agree" value="agree" onChange={(val, checked) => setAgreed(checked)} />
```

Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `DateField`, `FileUpload`, `FileDropzone`.
Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `DateField`, `TimeField`, `FileUpload`, `FileDropzone`.

## Theming

Expand Down
58 changes: 58 additions & 0 deletions skills/tedi-react/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,64 @@ The ref shape mirrors TextField (`{ input, wrapper }`). In `'multiple'` mode the
<DateField id="dob" label="Date of birth" useNativePicker md={{ useNativePicker: false }} />
```

### 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<TextFieldProps, 'id' | 'label' | 'value' | 'onChange'>` — pass-through to the underlying input
- `className?: string`
- **Breakpoint-aware:** `useNativePicker?: boolean = false` (swap to `<input type="time">`; 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
<TimeField id="meeting" label="Meeting time" value={time} onChange={setTime} stepMinutes={15} />

// Constrain to specific slots, render as a radio-button grid
<TimeField
id="slot"
label="Available slot"
availableTimes={['09:00', '09:30', '10:00', '14:00', '15:30']}
availableTimesVariant="grid-radio"
value={slot}
onChange={setSlot}
/>

// Native picker on mobile, custom wheel on desktop
<TimeField id="alarm" label="Alarm" useNativePicker md={{ useNativePicker: false }} />
```

### 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';

<TimePicker value={time} onChange={setTime} stepMinutes={5} />

// Predefined slots
<TimePicker
availableTimes={['09:00', '10:00', '11:00', '14:00']}
gridVariant="radio"
value={slot}
onChange={setSlot}
/>
```

### FileUpload
**Props:** `FileUploadProps` | form
- `id: string` (required), `name: string` (required)
Expand Down
46 changes: 46 additions & 0 deletions skills/tedi-react/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ TEDI form controls support both **controlled** and **uncontrolled** modes, follo
| ChoiceGroup | `ChoiceGroupValue` | Radio/checkbox groups, segmented variant |
| Search | `string` | Search button, onSearch callback |
| DateField | `Date \| Date[] \| DateRange` | Single/multiple/range, manual input, min/max, native picker, breakpoint-aware |
| TimeField | `string` (`"HH:mm"`) | Wheel / grid picker, native fallback, stepMinutes, availableTimes |
| FileUpload | `FileUploadFile[]` | Multi-file, validation, loading states |
| FileDropzone | `FileUploadFile[]` | Drag-and-drop |

Expand Down Expand Up @@ -177,6 +178,50 @@ const [date, setDate] = useState<Date>();
/>
```

## 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
<TimeField
id="meeting"
label="Meeting time"
value={time}
onChange={setTime}
stepMinutes={15}
required
/>

// Constrain to predefined slots, render as a radio-button grid
<TimeField
id="slot"
label="Available slot"
availableTimes={['09:00', '09:30', '10:00', '14:00', '15:30']}
availableTimesVariant="grid-radio"
value={slot}
onChange={setSlot}
/>

// Native picker on mobile, custom wheel on desktop
<TimeField
id="alarm"
label="Alarm"
useNativePicker
md={{ useNativePicker: false }}
/>
```

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';

<TimePicker value={time} onChange={setTime} stepMinutes={5} bordered={false} />
```

## Checkbox & Radio

```tsx
Expand Down Expand Up @@ -283,6 +328,7 @@ import { FileUpload, FileDropzone } from '@tedi-design-system/react/tedi';
- **Select:** `onChange?: (value: ISelectOption | ISelectOption[] | null) => void`
- **NumberField:** `onChange?: (value: number) => void`
- **DateField:** `onSelect?: OnSelectHandler<Date | Date[] | DateRange | undefined>` — value shape depends on `mode` (`'single'` → `Date`, `'multiple'` → `Date[]`, `'range'` → `DateRange`)
- **TimeField / TimePicker:** `onChange?: (time: string) => void` — value is always `"HH:mm"` 24-hour format (empty string when cleared)

## Disabled State

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,16 @@ export const ChoiceGroupItem = (props: ExtendedChoiceGroupItemProps): React.Reac

document.getElementById(id)?.click();
};

const isRadio = type === 'radio';
return (
<Col {...colProps} className={ColumnBEM}>
<div
className={ChoiceGroupItemBEM}
tabIndex={disabled ? -1 : 0}
tabIndex={isRadio || disabled ? -1 : 0}
onClick={handleClick}
role={type}
aria-checked={isChecked}
role={isRadio ? undefined : type}
aria-checked={isRadio ? undefined : isChecked}
>
{variant === 'default' || showIndicator ? (
<InputComponent
Expand Down
4 changes: 4 additions & 0 deletions src/tedi/components/form/date-field/date-field.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions src/tedi/components/form/date-field/date-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,58 @@ export const Size: StoryObj<TemplateMultipleProps> = {
},
};

const stateArray = ['Default', 'Hover', 'Focus', 'Active', 'Disabled'] as const;

export const States: Story = {
render: () => (
<div className="state-example">
{stateArray.map((state) => (
<Row key={state} className="padding-14-16">
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">{state}</Text>
</Col>
<Col md={4} xs={12} className="display-flex align-items-center">
<DateField id={state} mode="single" label="Date" inputProps={{ disabled: state === 'Disabled' }} />
</Col>
</Row>
))}
<Row className="padding-14-16">
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">Success</Text>
</Col>
<Col md={4} xs={12} className="display-flex align-items-center">
<DateField
id="success-datefield"
mode="single"
label="Date"
inputProps={{ helper: { text: 'Feedback text', type: 'valid' } }}
/>
</Col>
</Row>
<Row className="padding-14-16">
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">Error</Text>
</Col>
<Col md={4} xs={12} className="display-flex align-items-center">
<DateField
id="error-datefield"
mode="single"
label="Date"
inputProps={{ helper: { text: 'Feedback text', type: 'error' } }}
/>
</Col>
</Row>
</div>
),
parameters: {
pseudo: {
hover: '#Hover',
focus: '#Focus',
active: '#Active',
},
},
};

export const FieldOptions: StoryFn = () => {
const [shortcutValue, setShortcutValue] = useState<Date | undefined>(undefined);

Expand Down
5 changes: 5 additions & 0 deletions src/tedi/components/form/textfield/textfield.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
ly-tempel-bitweb marked this conversation as resolved.
}

&:disabled {
cursor: initial;
}
Expand All @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion src/tedi/components/form/textfield/textfield.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,10 @@ export const TextField = forwardRef<TextFieldForwardRef, TextFieldProps>((props,
const renderIcon = useCallback(() => {
if (!icon) return null;

const isInteractiveIcon = Boolean(onIconClick);
const smallIconSize = isInteractiveIcon ? 18 : 16;
const defaultIconProps: Partial<IconWithoutBackgroundProps> = {
size: size === 'large' ? 24 : size === 'small' ? 16 : 18,
size: size === 'large' ? 24 : size === 'small' ? smallIconSize : 18,
className: styles['tedi-textfield__icon'],
};

Expand Down
Loading
Loading