@arbor-education/design-system.components 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/components/combobox/Combobox.d.ts.map +1 -1
  3. package/dist/components/combobox/Combobox.js +8 -7
  4. package/dist/components/combobox/Combobox.js.map +1 -1
  5. package/dist/components/combobox/Combobox.stories.d.ts +1 -0
  6. package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
  7. package/dist/components/combobox/Combobox.stories.js +16 -0
  8. package/dist/components/combobox/Combobox.stories.js.map +1 -1
  9. package/dist/components/combobox/Combobox.test.js +9 -0
  10. package/dist/components/combobox/Combobox.test.js.map +1 -1
  11. package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -2
  12. package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
  13. package/dist/components/combobox/ComboboxButtonTrigger.js +11 -4
  14. package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
  15. package/dist/components/combobox/ComboboxTrigger.d.ts +3 -1
  16. package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
  17. package/dist/components/combobox/ComboboxTrigger.js +10 -2
  18. package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
  19. package/dist/components/combobox/types.d.ts +3 -0
  20. package/dist/components/combobox/types.d.ts.map +1 -1
  21. package/dist/components/formField/FormField.d.ts +4 -0
  22. package/dist/components/formField/FormField.d.ts.map +1 -1
  23. package/dist/components/formField/FormField.js +2 -1
  24. package/dist/components/formField/FormField.js.map +1 -1
  25. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  26. package/dist/components/formField/FormField.stories.js +4 -1
  27. package/dist/components/formField/FormField.stories.js.map +1 -1
  28. package/dist/components/formField/FormField.test.js +5 -0
  29. package/dist/components/formField/FormField.test.js.map +1 -1
  30. package/dist/components/formField/inputs/time/TimeInput.d.ts +29 -0
  31. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -0
  32. package/dist/components/formField/inputs/time/TimeInput.js +67 -0
  33. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -0
  34. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +60 -0
  35. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -0
  36. package/dist/components/formField/inputs/time/TimeInput.stories.js +132 -0
  37. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -0
  38. package/dist/components/formField/inputs/time/TimeInput.test.d.ts +2 -0
  39. package/dist/components/formField/inputs/time/TimeInput.test.d.ts.map +1 -0
  40. package/dist/components/formField/inputs/time/TimeInput.test.js +58 -0
  41. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -0
  42. package/dist/index.css +51 -1
  43. package/dist/index.css.map +1 -1
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/components/combobox/Combobox.stories.tsx +18 -0
  50. package/src/components/combobox/Combobox.test.tsx +27 -0
  51. package/src/components/combobox/Combobox.tsx +12 -5
  52. package/src/components/combobox/ComboboxButtonTrigger.tsx +54 -25
  53. package/src/components/combobox/ComboboxTrigger.tsx +39 -15
  54. package/src/components/combobox/combobox.scss +18 -0
  55. package/src/components/combobox/types.ts +3 -0
  56. package/src/components/formField/FormField.stories.tsx +10 -1
  57. package/src/components/formField/FormField.test.tsx +6 -0
  58. package/src/components/formField/FormField.tsx +5 -0
  59. package/src/components/formField/inputs/time/TimeInput.stories.tsx +170 -0
  60. package/src/components/formField/inputs/time/TimeInput.test.tsx +86 -0
  61. package/src/components/formField/inputs/time/TimeInput.tsx +168 -0
  62. package/src/components/formField/inputs/time/timeInput.scss +33 -0
  63. package/src/components/row/row.scss +2 -2
  64. package/src/index.scss +1 -0
  65. package/src/index.ts +3 -0
@@ -2,7 +2,7 @@ import classNames from 'classnames';
2
2
  import { Icon } from 'Components/icon/Icon';
3
3
  import { Tag } from 'Components/tag/Tag';
4
4
  import { Popover } from 'radix-ui';
5
- import type { ComboboxAriaInvalid, ComboboxOption } from './types';
5
+ import type { ComboboxAriaInvalid, ComboboxOption, ComboboxSelectedValueDisplay } from './types';
6
6
 
7
7
  export type ComboboxTriggerProps = {
8
8
  'inputRef': React.RefObject<HTMLInputElement | null>;
@@ -21,6 +21,8 @@ export type ComboboxTriggerProps = {
21
21
  'aria-invalid'?: ComboboxAriaInvalid;
22
22
  'aria-label'?: string;
23
23
  'showDropdownTrigger': boolean;
24
+ 'selectedValueDisplay': ComboboxSelectedValueDisplay;
25
+ 'triggerEndContent'?: React.ReactNode;
24
26
  'selectedChips': ComboboxOption[];
25
27
  'selectedChipValuesSet': Set<string>;
26
28
  'focusedChipIndex': number | null;
@@ -52,6 +54,8 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
52
54
  'aria-invalid': ariaInvalid,
53
55
  'aria-label': ariaLabel,
54
56
  showDropdownTrigger,
57
+ selectedValueDisplay,
58
+ triggerEndContent,
55
59
  selectedChips,
56
60
  selectedChipValuesSet,
57
61
  focusedChipIndex,
@@ -65,6 +69,12 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
65
69
  handleChevronClick,
66
70
  } = props;
67
71
 
72
+ const selectedValueText = selectedChips.map(resolveTagLabel).join(', ');
73
+ const showSelectedValueText
74
+ = selectedValueDisplay === 'text'
75
+ && query.length === 0
76
+ && selectedValueText.length > 0;
77
+
68
78
  return (
69
79
  <Popover.Anchor asChild>
70
80
  <div
@@ -77,23 +87,32 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
77
87
  onClick={handleTriggerClick}
78
88
  >
79
89
  <div className="ds-combobox__chips-and-input">
80
- {selectedChips.map((opt, chipIdx) => (
81
- <Tag
82
- key={opt.value}
83
- color="neutral"
84
- selected={selectedChipValuesSet.has(opt.value) || focusedChipIndex === chipIdx}
85
- slotStart={opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined}
86
- onRemove={disabled ? undefined : () => removeValue(opt.value)}
87
- removeLabel={`Remove ${resolveTagLabel(opt)}`}
88
- removeButtonTabIndex={-1}
89
- >
90
- {resolveTagLabel(opt)}
91
- </Tag>
92
- ))}
90
+ {selectedValueDisplay === 'tags'
91
+ ? selectedChips.map((opt, chipIdx) => (
92
+ <Tag
93
+ key={opt.value}
94
+ color="neutral"
95
+ selected={selectedChipValuesSet.has(opt.value) || focusedChipIndex === chipIdx}
96
+ slotStart={opt.iconName ? <Icon name={opt.iconName} size={12} /> : undefined}
97
+ onRemove={disabled ? undefined : () => removeValue(opt.value)}
98
+ removeLabel={`Remove ${resolveTagLabel(opt)}`}
99
+ removeButtonTabIndex={-1}
100
+ >
101
+ {resolveTagLabel(opt)}
102
+ </Tag>
103
+ ))
104
+ : null}
105
+ {showSelectedValueText && (
106
+ <span className="ds-combobox__selected-value">
107
+ {selectedValueText}
108
+ </span>
109
+ )}
93
110
  <input
94
111
  ref={inputRef}
95
112
  id={comboboxId}
96
- className="ds-combobox__input"
113
+ className={classNames('ds-combobox__input', {
114
+ 'ds-combobox__input--with-selected-value': showSelectedValueText,
115
+ })}
97
116
  type="text"
98
117
  role="combobox"
99
118
  aria-autocomplete="list"
@@ -114,6 +133,11 @@ export const ComboboxTrigger = (props: ComboboxTriggerProps): React.JSX.Element
114
133
  autoComplete="off"
115
134
  />
116
135
  </div>
136
+ {triggerEndContent && (
137
+ <span className="ds-combobox__end-content" aria-hidden="true">
138
+ {triggerEndContent}
139
+ </span>
140
+ )}
117
141
  {showDropdownTrigger && (
118
142
  <button
119
143
  type="button"
@@ -88,6 +88,13 @@
88
88
  white-space: nowrap;
89
89
  }
90
90
 
91
+ .ds-combobox__button-value {
92
+ min-width: 0;
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ white-space: nowrap;
96
+ }
97
+
91
98
  .ds-combobox__button-ellipsis {
92
99
  flex-shrink: 0;
93
100
  font-size: var(--font-size-medium);
@@ -127,6 +134,10 @@
127
134
  }
128
135
  }
129
136
 
137
+ .ds-combobox__input--with-selected-value {
138
+ min-width: 1ch;
139
+ }
140
+
130
141
  // Chevron
131
142
  .ds-combobox__chevron {
132
143
  flex-shrink: 0;
@@ -144,6 +155,13 @@
144
155
  }
145
156
  }
146
157
 
158
+ .ds-combobox__end-content {
159
+ flex-shrink: 0;
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ }
164
+
147
165
  // Popover
148
166
  .ds-combobox__popover {
149
167
  min-width: var(--radix-popper-anchor-width);
@@ -16,6 +16,7 @@ export type ComboboxOption = {
16
16
  export type ComboboxCreateResult = string | ComboboxOption | void;
17
17
  export type ComboboxSearchFn = (option: ComboboxOption, query: string) => boolean;
18
18
  export type ComboboxSearchType = 'prefix' | 'substring' | ComboboxSearchFn;
19
+ export type ComboboxSelectedValueDisplay = 'tags' | 'text';
19
20
 
20
21
  export type ComboboxProps = {
21
22
  'options': ComboboxOption[];
@@ -36,7 +37,9 @@ export type ComboboxProps = {
36
37
  'disabled'?: boolean;
37
38
  'dropdownOnFocus'?: boolean;
38
39
  'triggerVariant'?: 'input' | 'button';
40
+ 'selectedValueDisplay'?: ComboboxSelectedValueDisplay;
39
41
  'showDropdownTrigger'?: boolean;
42
+ 'triggerEndContent'?: React.ReactNode;
40
43
  'showSelectionCountBadge'?: boolean;
41
44
  'selectionCountA11yLabel'?: string | ((count: number) => string);
42
45
  'loading'?: boolean;
@@ -42,7 +42,7 @@ export const Default = {
42
42
  },
43
43
  'inputType': {
44
44
  control: 'select',
45
- options: ['text', 'textarea', 'number', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox'],
45
+ options: ['text', 'textarea', 'number', 'time', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox'],
46
46
  description: 'Input type',
47
47
  },
48
48
  'inputProps.size': {
@@ -114,6 +114,15 @@ export const FormExample: Story = {
114
114
  placeholder: 'Enter your age',
115
115
  }}
116
116
  />
117
+ <FormField
118
+ id="start-time"
119
+ label="Start time"
120
+ inputType="time"
121
+ inputProps={{
122
+ 'aria-label': 'Start time',
123
+ 'defaultValue': '14:30',
124
+ }}
125
+ />
117
126
  <FormField
118
127
  id="colour-dropdown"
119
128
  label="Colour"
@@ -45,6 +45,12 @@ describe('FormField component', () => {
45
45
  expect(input).toHaveClass('ds-number-input');
46
46
  });
47
47
 
48
+ test('renders a time input when inputType is time', () => {
49
+ render(<FormField id="niceid" inputType="time" inputProps={{ 'aria-label': 'Start time' }} />);
50
+ const input = screen.getByLabelText('Start time');
51
+ expect(input).toHaveAttribute('type', 'time');
52
+ });
53
+
48
54
  test('renders a colour picker dropdown when inputType is colourPicker', () => {
49
55
  render(<FormField id="niceid" inputType="colourPicker" />);
50
56
  const input = screen.getByRole('button');
@@ -7,6 +7,7 @@ import { ColourPickerDropdown, type ColourPickerDropdownProps } from './inputs/c
7
7
  import { NumberInput, type NumberInputProps } from './inputs/number/NumberInput';
8
8
  import { SelectDropdown, type SelectDropdownInputProps } from './inputs/selectDropdown/SelectDropdown';
9
9
  import { TextInput, type TextInputProps } from './inputs/text/TextInput';
10
+ import { TimeInput, type TimeInputProps } from './inputs/time/TimeInput';
10
11
  import { TextArea, type TextAreaProps } from './inputs/textArea/TextArea';
11
12
  import { Label } from './label/Label';
12
13
 
@@ -22,6 +23,7 @@ type FormFieldProps = {
22
23
  | { inputType?: 'text'; inputProps?: TextInputProps }
23
24
  | { inputType?: 'textarea'; inputProps?: TextAreaProps }
24
25
  | { inputType?: 'number'; inputProps?: NumberInputProps }
26
+ | { inputType?: 'time'; inputProps?: TimeInputProps }
25
27
  | { inputType?: 'colourPicker'; inputProps?: ColourPickerDropdownProps }
26
28
  | { inputType?: 'selectDropdown'; inputProps?: SelectDropdownInputProps }
27
29
  | { inputType?: 'datePicker'; inputProps?: DatePickerProps }
@@ -65,6 +67,9 @@ export const FormField = (props: FormFieldProps) => {
65
67
  {inputType === 'number' && (
66
68
  <NumberInput {...sharedProps} {...(inputProps as NumberInputProps)} />
67
69
  )}
70
+ {inputType === 'time' && (
71
+ <TimeInput {...sharedProps} {...(inputProps as TimeInputProps)} />
72
+ )}
68
73
  {inputType === 'colourPicker' && (
69
74
  <ColourPickerDropdown {...sharedProps} {...(inputProps as ColourPickerDropdownProps)} />
70
75
  )}
@@ -0,0 +1,170 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Button } from 'Components/button/Button';
3
+ import { useEffect, useState } from 'react';
4
+ import { fn } from 'storybook/test';
5
+ import { TimeInput, type TimeInputProps, type TimeValue } from './TimeInput';
6
+
7
+ const timeOptions = ['09:00', '09:30', '10:00', '10:30', '11:00'] as const;
8
+
9
+ const meta = {
10
+ title: 'Components/FormField/Inputs/TimeInput',
11
+ component: TimeInput,
12
+ parameters: {
13
+ layout: 'centered',
14
+ docs: {
15
+ description: {
16
+ component:
17
+ '`TimeInput` supports both native time entry and a combobox-backed time list. When you pass `value`, treat it as a controlled component and update it in `onValueChange`; use `defaultValue` for uncontrolled usage.',
18
+ },
19
+ },
20
+ },
21
+ tags: ['autodocs'],
22
+ args: {
23
+ onValueChange: fn(),
24
+ },
25
+ argTypes: {
26
+ granularity: {
27
+ control: 'inline-radio',
28
+ options: ['minute', 'second'],
29
+ description: 'Controls whether the native time input works in minute or second increments.',
30
+ },
31
+ options: {
32
+ control: 'object',
33
+ description: 'When provided, `TimeInput` switches from native `input[type="time"]` to the combobox-backed time list mode.',
34
+ },
35
+ value: {
36
+ control: 'text',
37
+ description: 'Controlled value. If you provide this, update it from `onValueChange` in your app or story state.',
38
+ },
39
+ defaultValue: {
40
+ control: 'text',
41
+ description: 'Uncontrolled initial value. Use this when you want the component to manage its own state.',
42
+ },
43
+ onValueChange: {
44
+ action: 'value changed',
45
+ description: 'Called with the next string value (`HH:MM` or `HH:MM:SS`).',
46
+ },
47
+ },
48
+ } satisfies Meta<typeof TimeInput>;
49
+
50
+ export default meta;
51
+
52
+ type Story = StoryObj<typeof meta>;
53
+
54
+ const withDescription = (story: Story, description: string): Story => ({
55
+ ...story,
56
+ parameters: {
57
+ ...story.parameters,
58
+ docs: {
59
+ ...story.parameters?.docs,
60
+ description: {
61
+ story: description,
62
+ },
63
+ },
64
+ },
65
+ });
66
+
67
+ const ControlledTimeInput = (args: TimeInputProps) => {
68
+ const [value, setValue] = useState<TimeValue | ''>(args.value ?? args.defaultValue ?? '');
69
+
70
+ useEffect(() => {
71
+ setValue(args.value ?? args.defaultValue ?? '');
72
+ }, [args.defaultValue, args.value]);
73
+
74
+ return (
75
+ <TimeInput
76
+ {...args}
77
+ value={value}
78
+ onValueChange={(nextValue) => {
79
+ setValue(nextValue as TimeValue | '');
80
+ args.onValueChange?.(nextValue);
81
+ }}
82
+ />
83
+ );
84
+ };
85
+
86
+ const NativeValidationDemo = (args: TimeInputProps) => {
87
+ const [value, setValue] = useState<TimeValue | ''>(args.value ?? '');
88
+
89
+ useEffect(() => {
90
+ setValue(args.value ?? '');
91
+ }, [args.value]);
92
+
93
+ return (
94
+ <form
95
+ style={{ display: 'grid', gap: 12, width: 280 }}
96
+ onSubmit={(event) => {
97
+ event.preventDefault();
98
+ const form = event.currentTarget;
99
+ if (!form.reportValidity()) {
100
+ return;
101
+ }
102
+ }}
103
+ >
104
+ <TimeInput
105
+ {...args}
106
+ value={value}
107
+ onValueChange={(nextValue) => {
108
+ setValue(nextValue as TimeValue | '');
109
+ args.onValueChange?.(nextValue);
110
+ }}
111
+ />
112
+ <Button variant="primary" size="M" type="submit">
113
+ Submit
114
+ </Button>
115
+ </form>
116
+ );
117
+ };
118
+
119
+ export const NativeMinute: Story = withDescription({
120
+ args: {
121
+ value: '14:30',
122
+ granularity: 'minute',
123
+ },
124
+ render: args => <ControlledTimeInput {...args} />,
125
+ }, 'Controlled native time input in minute mode. This story keeps local state on purpose to demonstrate the correct `value` + `onValueChange` integration pattern.');
126
+
127
+ export const NativeSecond: Story = withDescription({
128
+ args: {
129
+ value: '14:30:15',
130
+ granularity: 'second',
131
+ },
132
+ render: args => <ControlledTimeInput {...args} />,
133
+ }, 'Controlled native time input in second mode, showing the same parent-managed state pattern with `HH:MM:SS` values.');
134
+
135
+ export const NativeWithBounds: Story = withDescription({
136
+ args: {
137
+ value: '08:30',
138
+ granularity: 'minute',
139
+ min: '09:00',
140
+ max: '17:30',
141
+ },
142
+ render: args => <NativeValidationDemo {...args} />,
143
+ }, 'Demonstrates the browser-native min/max validation flow. Enter a time outside the allowed range and submit the form to let the browser show its own native validation message.');
144
+
145
+ export const TimeList: Story = withDescription({
146
+ args: {
147
+ options: [...timeOptions],
148
+ value: '10:00',
149
+ placeholder: 'Select time',
150
+ },
151
+ render: args => <ControlledTimeInput {...args} />,
152
+ }, 'Controlled time-list mode using the combobox-backed variant. Selecting a time updates the local story state the same way an app component would.');
153
+
154
+ export const TimeListHighlightedMatches: Story = withDescription({
155
+ args: {
156
+ options: [...timeOptions],
157
+ value: '',
158
+ placeholder: 'Search times',
159
+ searchType: 'substring',
160
+ highlightStringMatches: true,
161
+ },
162
+ render: args => <ControlledTimeInput {...args} />,
163
+ }, 'Combobox-backed time-list mode with substring matching and highlighted text so consumers can review how search behaves when users type partial time fragments such as `30`.');
164
+
165
+ export const UncontrolledNative: Story = withDescription({
166
+ args: {
167
+ defaultValue: '09:30',
168
+ granularity: 'minute',
169
+ },
170
+ }, 'Uncontrolled native usage using `defaultValue`. This is useful when the parent does not need to drive the current value after initial render.');
@@ -0,0 +1,86 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
5
+ import { TimeInput } from './TimeInput';
6
+
7
+ describe('TimeInput', () => {
8
+ beforeEach(() => {
9
+ globalThis.ResizeObserver = class {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ } as unknown as typeof ResizeObserver;
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ test('renders a native time input by default', () => {
21
+ render(<TimeInput aria-label="Start time" />);
22
+ const input = screen.getByLabelText('Start time');
23
+
24
+ expect(input).toHaveAttribute('type', 'time');
25
+ expect(input).toHaveAttribute('step', '60');
26
+ });
27
+
28
+ test('uses one-second stepping when granularity is second', () => {
29
+ render(<TimeInput aria-label="Start time" granularity="second" />);
30
+ expect(screen.getByLabelText('Start time')).toHaveAttribute('step', '1');
31
+ });
32
+
33
+ test('native mode emits the string value when changed', () => {
34
+ const onValueChange = vi.fn();
35
+ render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
36
+
37
+ fireEvent.change(screen.getByLabelText('Start time'), {
38
+ target: { value: '14:30' },
39
+ });
40
+
41
+ expect(onValueChange).toHaveBeenCalledWith('14:30');
42
+ });
43
+
44
+ test('clicking the clock icon focuses the input and selects the hour segment when supported', async () => {
45
+ const user = userEvent.setup();
46
+ const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange').mockImplementation(() => {});
47
+
48
+ render(<TimeInput aria-label="Start time" />);
49
+
50
+ await user.click(screen.getByRole('button', { name: 'Select time' }));
51
+
52
+ expect(screen.getByLabelText('Start time')).toHaveFocus();
53
+ expect(setSelectionRange).toHaveBeenCalledWith(0, 2);
54
+ });
55
+
56
+ test('options mode renders the combobox-backed time list with plain text selection', () => {
57
+ render(
58
+ <TimeInput
59
+ aria-label="Start time"
60
+ options={['13:00', '13:30', '14:00']}
61
+ value="13:30"
62
+ />,
63
+ );
64
+
65
+ expect(screen.getByRole('button', { name: 'Start time' })).toHaveTextContent('13:30');
66
+ expect(document.querySelector('.ds-tag')).not.toBeInTheDocument();
67
+ });
68
+
69
+ test('options mode emits the selected allowed time', async () => {
70
+ const user = userEvent.setup();
71
+ const onValueChange = vi.fn();
72
+
73
+ render(
74
+ <TimeInput
75
+ aria-label="Start time"
76
+ options={['13:00', '13:30', '14:00']}
77
+ onValueChange={onValueChange}
78
+ />,
79
+ );
80
+
81
+ await user.click(screen.getByRole('button', { name: 'Start time' }));
82
+ await user.click(screen.getByText('13:30'));
83
+
84
+ expect(onValueChange).toHaveBeenCalledWith('13:30');
85
+ });
86
+ });
@@ -0,0 +1,168 @@
1
+ import classNames from 'classnames';
2
+ import { Combobox } from 'Components/combobox/Combobox';
3
+ import type { ComboboxOption, ComboboxSearchType } from 'Components/combobox/types';
4
+ import { Icon } from 'Components/icon/Icon';
5
+ import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
6
+
7
+ /** Expected string `HH:MM` or `HH:MM:SS`; format-shaped versus strict time validation. */
8
+ export type TimeValue = `${string}:${string}` | `${string}:${string}:${string}`;
9
+ export type TimeGranularity = 'minute' | 'second';
10
+
11
+ export type TimeInputProps = {
12
+ options?: TimeValue[];
13
+ granularity?: TimeGranularity;
14
+ hasError?: boolean;
15
+ onValueChange?: (value: string) => void;
16
+ searchType?: ComboboxSearchType;
17
+ highlightStringMatches?: boolean;
18
+ } & Omit<InputHTMLAttributes<HTMLInputElement>, 'defaultValue' | 'size' | 'type' | 'value'> & {
19
+ value?: TimeValue | '';
20
+ defaultValue?: TimeValue | '';
21
+ };
22
+
23
+ const clockIcon = <Icon name="clock-3" size={16} />;
24
+
25
+ /** The forwarded ref is attached in native mode but when `options` is provided, no input ref is exposed. */
26
+ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, ref) => {
27
+ const {
28
+ options,
29
+ granularity = 'minute',
30
+ hasError = false,
31
+ onValueChange,
32
+ searchType = 'prefix',
33
+ highlightStringMatches = false,
34
+ onChange,
35
+ className,
36
+ disabled = false,
37
+ id,
38
+ name,
39
+ placeholder,
40
+ value: controlledValue,
41
+ defaultValue = '',
42
+ 'aria-describedby': ariaDescribedBy,
43
+ 'aria-invalid': ariaInvalid,
44
+ 'aria-label': ariaLabel,
45
+ ...rest
46
+ } = props;
47
+
48
+ const isControlled = controlledValue !== undefined;
49
+ const [internalValue, setInternalValue] = useState<string>(defaultValue);
50
+ const currentValue = isControlled ? controlledValue ?? '' : internalValue;
51
+ const inputRef = useRef<HTMLInputElement>(null);
52
+
53
+ const setRefs = useCallback((node: HTMLInputElement | null) => {
54
+ inputRef.current = node;
55
+ if (typeof ref === 'function') {
56
+ ref(node);
57
+ }
58
+ else if (ref) {
59
+ ref.current = node;
60
+ }
61
+ }, [ref]);
62
+
63
+ const updateValue = useCallback((nextValue: string, nativeEvent?: ChangeEvent<HTMLInputElement>) => {
64
+ if (!isControlled) {
65
+ setInternalValue(nextValue);
66
+ }
67
+ onValueChange?.(nextValue);
68
+ if (nativeEvent) {
69
+ onChange?.(nativeEvent);
70
+ return;
71
+ }
72
+ // Combobox-driven updates only provide compatibility with common form handlers.
73
+ onChange?.({
74
+ currentTarget: { value: nextValue } as EventTarget & HTMLInputElement,
75
+ target: { value: nextValue } as EventTarget & HTMLInputElement,
76
+ } as ChangeEvent<HTMLInputElement>);
77
+ }, [isControlled, onChange, onValueChange]);
78
+
79
+ const handleNativeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
80
+ updateValue(event.currentTarget.value, event);
81
+ }, [updateValue]);
82
+
83
+ const focusAndSelectHours = useCallback(() => {
84
+ const input = inputRef.current;
85
+ if (!input) return;
86
+
87
+ input.focus();
88
+
89
+ try {
90
+ input.setSelectionRange(0, 2);
91
+ }
92
+ catch {
93
+ // Some browsers do not expose selection APIs on time inputs.
94
+ }
95
+ }, []);
96
+
97
+ const timeOptions = useMemo<ComboboxOption[]>(
98
+ () => (options ?? []).map(time => ({ value: time, label: time })),
99
+ [options],
100
+ );
101
+
102
+ const selectedTimeValue = options?.includes(currentValue as TimeValue) ? [currentValue] : [];
103
+
104
+ const handleTimeListChange = useCallback((values: string[]) => {
105
+ const nextValue = values[0] ?? '';
106
+ updateValue(nextValue);
107
+ }, [updateValue]);
108
+
109
+ if (options) {
110
+ return (
111
+ <div className={classNames('ds-time-input', className)}>
112
+ {name && <input type="hidden" name={name} value={selectedTimeValue[0] ?? ''} />}
113
+ <Combobox
114
+ id={id}
115
+ options={timeOptions}
116
+ value={selectedTimeValue}
117
+ onValueChange={handleTimeListChange}
118
+ searchType={searchType}
119
+ highlightStringMatches={highlightStringMatches}
120
+ placeholder={placeholder ?? 'Select time'}
121
+ disabled={disabled}
122
+ hasError={hasError}
123
+ aria-describedby={ariaDescribedBy}
124
+ aria-invalid={ariaInvalid}
125
+ aria-label={ariaLabel}
126
+ triggerVariant="button"
127
+ selectedValueDisplay="text"
128
+ showDropdownTrigger={false}
129
+ triggerEndContent={clockIcon}
130
+ />
131
+ </div>
132
+ );
133
+ }
134
+
135
+ return (
136
+ <div className={classNames('ds-time-input', className)}>
137
+ <input
138
+ {...rest}
139
+ ref={setRefs}
140
+ id={id}
141
+ name={name}
142
+ type="time"
143
+ value={currentValue}
144
+ step={granularity === 'second' ? 1 : 60}
145
+ disabled={disabled}
146
+ aria-describedby={ariaDescribedBy}
147
+ aria-invalid={ariaInvalid}
148
+ aria-label={ariaLabel}
149
+ placeholder={placeholder}
150
+ className={classNames('ds-input', 'ds-input--M', 'ds-time-input__input', {
151
+ 'ds-input--error': hasError,
152
+ })}
153
+ onChange={handleNativeChange}
154
+ />
155
+ <button
156
+ type="button"
157
+ className="ds-time-input__icon-button"
158
+ onClick={focusAndSelectHours}
159
+ aria-label="Select time"
160
+ disabled={disabled}
161
+ >
162
+ {clockIcon}
163
+ </button>
164
+ </div>
165
+ );
166
+ });
167
+
168
+ TimeInput.displayName = 'TimeInput';
@@ -0,0 +1,33 @@
1
+ .ds-time-input {
2
+ position: relative;
3
+ width: 100%;
4
+ }
5
+
6
+ .ds-time-input__input {
7
+ appearance: none;
8
+ padding-right: calc(var(--form-field-text-medium-height) + var(--spacing-small));
9
+
10
+ &::-webkit-calendar-picker-indicator {
11
+ display: none;
12
+ appearance: none;
13
+ }
14
+ }
15
+
16
+ .ds-time-input__icon-button {
17
+ position: absolute;
18
+ inset-block: 0;
19
+ inset-inline-end: 0;
20
+ width: var(--form-field-text-medium-height);
21
+ display: inline-flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ border: none;
25
+ background: transparent;
26
+ color: var(--form-field-icon-default-color-icon);
27
+ cursor: pointer;
28
+
29
+ &:disabled {
30
+ color: var(--form-field-icon-disabled-color-icon);
31
+ cursor: not-allowed;
32
+ }
33
+ }