@oslokommune/punkt-react 16.6.1 → 16.7.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.
@@ -0,0 +1,336 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import userEvent from '@testing-library/user-event'
5
+ import { useForm } from 'react-hook-form'
6
+ import { axe, toHaveNoViolations } from 'jest-axe'
7
+ import { vi } from 'vitest'
8
+
9
+ import { PktButton } from '../button/Button'
10
+ import { PktTimepicker } from './Timepicker'
11
+
12
+ expect.extend(toHaveNoViolations)
13
+
14
+ const id = 'test-timepicker'
15
+ const label = 'Test Timepicker'
16
+
17
+ describe('PktTimepicker', () => {
18
+ describe('Rendering', () => {
19
+ test('renders without errors', () => {
20
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} />)
21
+ expect(container.querySelector('.pkt-timepicker')).toBeInTheDocument()
22
+ })
23
+
24
+ test('renders spinbutton inputs and separator', () => {
25
+ render(<PktTimepicker id={id} name="t" label={label} />)
26
+ const spinbuttons = screen.getAllByRole('spinbutton')
27
+ expect(spinbuttons).toHaveLength(2)
28
+ expect(spinbuttons[0]).toHaveAttribute('id', `${id}-hours`)
29
+ expect(spinbuttons[1]).toHaveAttribute('id', `${id}-minutes`)
30
+ })
31
+
32
+ test('adds stepper modifier when stepArrows is set', () => {
33
+ const { container } = render(
34
+ <PktTimepicker id={id} name="t" label={label} stepArrows />,
35
+ )
36
+ expect(container.querySelector('.pkt-timepicker--stepper')).toBeInTheDocument()
37
+ })
38
+
39
+ test('adds fullwidth modifier when fullwidth is set', () => {
40
+ const { container } = render(
41
+ <PktTimepicker id={id} name="t" label={label} fullwidth />,
42
+ )
43
+ expect(container.querySelector('.pkt-timepicker--fullwidth')).toBeInTheDocument()
44
+ })
45
+
46
+ test('renders clock button when picker is visible', () => {
47
+ render(<PktTimepicker id={id} name="t" label={label} />)
48
+ expect(
49
+ screen.getByRole('button', { name: /åpne tidspunkt-velger/i }),
50
+ ).toBeInTheDocument()
51
+ })
52
+
53
+ test('hides clock button when hidePicker is set', () => {
54
+ render(<PktTimepicker id={id} name="t" label={label} hidePicker />)
55
+ expect(
56
+ screen.queryByRole('button', { name: /åpne tidspunkt-velger/i }),
57
+ ).not.toBeInTheDocument()
58
+ })
59
+
60
+ test('renders decorative clock icon when hidePicker is set', () => {
61
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} hidePicker />)
62
+ expect(container.querySelector('.pkt-timepicker__icon')).toBeInTheDocument()
63
+ })
64
+
65
+ test('does not render popup when hidePicker is set', () => {
66
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} hidePicker />)
67
+ expect(container.querySelector('.pkt-timepicker-popup')).not.toBeInTheDocument()
68
+ })
69
+
70
+ test('does not render popup when stepArrows is set', () => {
71
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} stepArrows />)
72
+ expect(container.querySelector('.pkt-timepicker-popup')).not.toBeInTheDocument()
73
+ })
74
+
75
+ test('renders prev/next buttons when stepArrows is set', () => {
76
+ render(<PktTimepicker id={id} name="t" label={label} stepArrows />)
77
+ expect(screen.getByRole('button', { name: /forrige tidspunkt/i })).toBeInTheDocument()
78
+ expect(screen.getByRole('button', { name: /neste tidspunkt/i })).toBeInTheDocument()
79
+ })
80
+
81
+ test('popup is hidden by default', () => {
82
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} />)
83
+ const popup = container.querySelector('.pkt-timepicker-popup')
84
+ expect(popup).toBeInTheDocument()
85
+ expect(popup).toHaveAttribute('hidden')
86
+ })
87
+ })
88
+
89
+ describe('Properties', () => {
90
+ test('value sets display inputs', () => {
91
+ render(<PktTimepicker id={id} name="t" label={label} value="09:30" />)
92
+ const spinbuttons = screen.getAllByRole('spinbutton')
93
+ expect(spinbuttons[0]).toHaveValue('09')
94
+ expect(spinbuttons[1]).toHaveValue('30')
95
+ })
96
+
97
+ test('empty value renders empty display inputs', () => {
98
+ render(<PktTimepicker id={id} name="t" label={label} />)
99
+ const spinbuttons = screen.getAllByRole('spinbutton')
100
+ expect(spinbuttons[0]).toHaveValue('')
101
+ expect(spinbuttons[1]).toHaveValue('')
102
+ })
103
+
104
+ test('disabled disables inputs and clock button', () => {
105
+ render(<PktTimepicker id={id} name="t" label={label} disabled />)
106
+ const spinbuttons = screen.getAllByRole('spinbutton')
107
+ expect(spinbuttons[0]).toBeDisabled()
108
+ expect(spinbuttons[1]).toBeDisabled()
109
+ expect(screen.getByRole('button', { name: /åpne tidspunkt-velger/i })).toBeDisabled()
110
+ })
111
+
112
+ test('min/max/step are not on hidden input (constraints use setCustomValidity on hours for native form validation)', () => {
113
+ const { container } = render(
114
+ <PktTimepicker id={id} name="t" label={label} min="08:00" max="17:00" step={300} />,
115
+ )
116
+ const hidden = container.querySelector<HTMLInputElement>(`#${id}-input`)
117
+ expect(hidden?.hasAttribute('min')).toBe(false)
118
+ expect(hidden?.hasAttribute('max')).toBe(false)
119
+ expect(hidden?.hasAttribute('step')).toBe(false)
120
+ expect(hidden?.hasAttribute('required')).toBe(false)
121
+ })
122
+
123
+ test('hidden input reflects value', () => {
124
+ const { container } = render(
125
+ <PktTimepicker id={id} name="t" label={label} value="09:30" />,
126
+ )
127
+ const hidden = container.querySelector<HTMLInputElement>(`#${id}-input`)
128
+ expect(hidden?.value).toBe('09:30')
129
+ })
130
+
131
+ test('native form submit flushes spinbutton digits into the named hidden input before validation', async () => {
132
+ const user = userEvent.setup()
133
+ const onSubmit = vi.fn()
134
+ render(
135
+ <form
136
+ onSubmit={(e) => {
137
+ e.preventDefault()
138
+ onSubmit(Object.fromEntries(new FormData(e.currentTarget).entries()))
139
+ }}
140
+ >
141
+ <PktTimepicker id={id} name="tid" label={label} required requiredTag />
142
+ <button type="submit">Send</button>
143
+ </form>,
144
+ )
145
+ const [hoursEl, minutesEl] = screen.getAllByRole('spinbutton')
146
+ await user.click(hoursEl)
147
+ await user.keyboard('01')
148
+ await user.click(minutesEl)
149
+ await user.keyboard('02')
150
+ await user.click(screen.getByRole('button', { name: /^send$/i }))
151
+ expect(onSubmit).toHaveBeenCalledWith({ tid: '01:02' })
152
+ })
153
+ })
154
+
155
+ describe('Popup', () => {
156
+ test('clock button opens popup', async () => {
157
+ const user = userEvent.setup()
158
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} />)
159
+ await user.click(screen.getByRole('button', { name: /åpne tidspunkt-velger/i }))
160
+ const popup = container.querySelector('.pkt-timepicker-popup')
161
+ expect(popup).not.toHaveAttribute('hidden')
162
+ })
163
+
164
+ test('Escape closes popup', async () => {
165
+ const user = userEvent.setup()
166
+ render(<PktTimepicker id={id} name="t" label={label} />)
167
+ const btn = screen.getByRole('button', { name: /åpne tidspunkt-velger/i })
168
+ await user.click(btn)
169
+ await waitFor(() => expect(btn).toHaveAttribute('aria-expanded', 'true'))
170
+ await user.keyboard('{Escape}')
171
+ await waitFor(() => expect(btn).toHaveAttribute('aria-expanded', 'false'))
172
+ })
173
+
174
+ test('popup renders hour and minute columns', async () => {
175
+ const user = userEvent.setup()
176
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} />)
177
+ await user.click(screen.getByRole('button', { name: /åpne tidspunkt-velger/i }))
178
+ const cols = container.querySelectorAll('.pkt-timepicker-popup__col')
179
+ expect(cols).toHaveLength(2)
180
+ })
181
+ })
182
+
183
+ describe('Stepper', () => {
184
+ test('next button increments by one minute step', async () => {
185
+ const user = userEvent.setup()
186
+ const { container } = render(
187
+ <PktTimepicker id={id} name="t" label={label} stepArrows defaultValue="09:00" />,
188
+ )
189
+ await user.click(screen.getByRole('button', { name: /neste tidspunkt/i }))
190
+ const hidden = container.querySelector<HTMLInputElement>(`#${id}-input`)
191
+ expect(hidden?.value).toBe('09:01')
192
+ })
193
+
194
+ test('prev button decrements by step (5 min)', async () => {
195
+ const user = userEvent.setup()
196
+ const { container } = render(
197
+ <PktTimepicker
198
+ id={id}
199
+ name="t"
200
+ label={label}
201
+ stepArrows
202
+ step={300}
203
+ defaultValue="09:05"
204
+ />,
205
+ )
206
+ await user.click(screen.getByRole('button', { name: /forrige tidspunkt/i }))
207
+ const hidden = container.querySelector<HTMLInputElement>(`#${id}-input`)
208
+ expect(hidden?.value).toBe('09:00')
209
+ })
210
+
211
+ test('next button at 59 minutes rolls over to next hour', async () => {
212
+ const user = userEvent.setup()
213
+ const { container } = render(
214
+ <PktTimepicker id={id} name="t" label={label} stepArrows defaultValue="09:59" />,
215
+ )
216
+ await user.click(screen.getByRole('button', { name: /neste tidspunkt/i }))
217
+ const hidden = container.querySelector<HTMLInputElement>(`#${id}-input`)
218
+ expect(hidden?.value).toBe('10:00')
219
+ })
220
+ })
221
+
222
+ describe('Events', () => {
223
+ test('onFocus fires when a spinbutton is focused (focus must bubble to root handler)', async () => {
224
+ const user = userEvent.setup()
225
+ const onFocus = vi.fn()
226
+ const { container } = render(
227
+ <PktTimepicker id={id} name="t" label={label} onFocus={onFocus} />,
228
+ )
229
+ const hoursInput = container.querySelector<HTMLInputElement>(`#${id}-hours`)!
230
+ await user.click(hoursInput)
231
+ expect(onFocus).toHaveBeenCalledTimes(1)
232
+ })
233
+
234
+ test('Backspace removes digits in hours and updates hidden value (controlled inputs)', async () => {
235
+ const user = userEvent.setup()
236
+ const { container } = render(
237
+ <PktTimepicker id={id} name="t" label={label} defaultValue="09:30" />,
238
+ )
239
+ const hoursInput = container.querySelector<HTMLInputElement>(`#${id}-hours`)!
240
+ const hidden = container.querySelector<HTMLInputElement>(`#${id}-input`)
241
+ hoursInput.focus()
242
+ await user.keyboard('{Backspace}')
243
+ expect(hoursInput).toHaveValue('0')
244
+ expect(hidden?.value).toBe('00:30')
245
+ await user.keyboard('{Backspace}')
246
+ expect(hoursInput).toHaveValue('')
247
+ expect(hidden?.value).toBe('')
248
+ })
249
+
250
+ test('onValueChange fires when hours change via ArrowUp', async () => {
251
+ const handleValueChange = vi.fn()
252
+ const { container } = render(
253
+ <PktTimepicker
254
+ id={id}
255
+ name="t"
256
+ label={label}
257
+ defaultValue="09:30"
258
+ onValueChange={handleValueChange}
259
+ />,
260
+ )
261
+ const hoursInput = container.querySelector<HTMLInputElement>(`#${id}-hours`)!
262
+ fireEvent.keyDown(hoursInput, { key: 'ArrowUp' })
263
+ await waitFor(() => {
264
+ expect(handleValueChange).toHaveBeenCalledWith('10:30')
265
+ })
266
+ })
267
+ })
268
+
269
+ describe('Accessibility', () => {
270
+ test('passes axe in default state', async () => {
271
+ const { container } = render(<PktTimepicker id={id} name="t" label={label} />)
272
+ await window.customElements.whenDefined('pkt-icon')
273
+ const results = await axe(container)
274
+ expect(results).toHaveNoViolations()
275
+ })
276
+
277
+ test('hours input has spinbutton role and bounds', () => {
278
+ render(<PktTimepicker id={id} name="t" label={label} value="09:30" />)
279
+ const hours = screen.getAllByRole('spinbutton')[0]
280
+ expect(hours).toHaveAttribute('role', 'spinbutton')
281
+ expect(hours).toHaveAttribute('aria-valuemin', '0')
282
+ expect(hours).toHaveAttribute('aria-valuemax', '23')
283
+ expect(hours).toHaveAttribute('aria-valuenow', '9')
284
+ })
285
+ })
286
+ })
287
+
288
+ describe('PktTimepicker + React Hook Form', () => {
289
+ function WatchForm() {
290
+ const { register, watch } = useForm<{ tid: string }>({
291
+ defaultValues: { tid: '09:30' },
292
+ })
293
+ const values = watch()
294
+ return (
295
+ <form>
296
+ <PktTimepicker id="tp-rhf-watch" label="Tid" {...register('tid')} />
297
+ <span data-testid="watched">{values.tid ?? ''}</span>
298
+ </form>
299
+ )
300
+ }
301
+
302
+ test('register() defaultValues are reflected in watch()', async () => {
303
+ render(<WatchForm />)
304
+ await waitFor(() => {
305
+ expect(screen.getByTestId('watched')).toHaveTextContent('09:30')
306
+ })
307
+ })
308
+
309
+ function SubmitForm({
310
+ onSubmit,
311
+ }: {
312
+ onSubmit: (data: { tid: string }) => void
313
+ }) {
314
+ const { register, handleSubmit } = useForm<{ tid: string }>({
315
+ defaultValues: { tid: '14:45' },
316
+ })
317
+ return (
318
+ <form onSubmit={handleSubmit(onSubmit)}>
319
+ <PktTimepicker id="tp-rhf-submit" label="Tid" {...register('tid')} />
320
+ <PktButton type="submit">Send</PktButton>
321
+ </form>
322
+ )
323
+ }
324
+
325
+ test('submit sends current time value from register()', async () => {
326
+ const user = userEvent.setup()
327
+ const onSubmit = vi.fn()
328
+ render(<SubmitForm onSubmit={onSubmit} />)
329
+
330
+ await user.click(screen.getByRole('button', { name: /send/i }))
331
+
332
+ await waitFor(() => {
333
+ expect(onSubmit).toHaveBeenCalledWith({ tid: '14:45' }, expect.anything())
334
+ })
335
+ })
336
+ })
@@ -0,0 +1,244 @@
1
+ 'use client'
2
+
3
+ import { forwardRef } from 'react'
4
+
5
+ import { PktIcon } from '../icon/Icon'
6
+ import { PktInputWrapper } from '../inputwrapper/InputWrapper'
7
+ import { useTimepickerState } from './useTimepickerState'
8
+ import type { IPktTimepicker } from './types'
9
+
10
+ export type { IPktTimepicker } from './types'
11
+
12
+ export const PktTimepicker = forwardRef<HTMLDivElement, IPktTimepicker>((props, ref) => {
13
+ const { onChange, onInput } = props
14
+ const state = useTimepickerState(props, ref)
15
+
16
+ const hoursLabel = state.strings.hours
17
+ const minutesLabel = state.strings.minutes
18
+ const hoursAriaLabel = state.label ? `${hoursLabel}, ${state.label}` : hoursLabel
19
+ const minutesAriaLabel = state.label ? `${minutesLabel}, ${state.label}` : minutesLabel
20
+
21
+ const renderOption = (optionValue: number, type: 'hour' | 'minute') => {
22
+ const strVal = String(optionValue).padStart(2, '0')
23
+ const currentVal =
24
+ type === 'hour'
25
+ ? state.hours !== ''
26
+ ? parseInt(state.hours, 10)
27
+ : NaN
28
+ : state.minutes !== ''
29
+ ? parseInt(state.minutes, 10)
30
+ : NaN
31
+ const isSelected = optionValue === currentVal
32
+ return (
33
+ <button
34
+ key={`${type}-${optionValue}`}
35
+ type="button"
36
+ className={[
37
+ 'pkt-btn',
38
+ 'pkt-btn--tertiary',
39
+ 'pkt-btn--small',
40
+ 'pkt-btn--label-only',
41
+ 'pkt-timepicker-popup__option',
42
+ isSelected ? 'pkt-timepicker-popup__option--selected' : '',
43
+ ]
44
+ .filter(Boolean)
45
+ .join(' ')}
46
+ role="option"
47
+ aria-selected={isSelected ? 'true' : 'false'}
48
+ tabIndex={isSelected ? 0 : -1}
49
+ data-type={type}
50
+ data-value={optionValue}
51
+ onClick={(e) => {
52
+ e.stopPropagation()
53
+ state.handleOptionClick(optionValue, type)
54
+ }}
55
+ >
56
+ <span className="pkt-btn__text pkt-txt-14-light">{strVal}</span>
57
+ </button>
58
+ )
59
+ }
60
+
61
+ const renderPopup = () => (
62
+ <div
63
+ ref={state.popupRef}
64
+ className="pkt-timepicker-popup"
65
+ id={state.popupId}
66
+ hidden={!state.isOpen}
67
+ role="group"
68
+ aria-label={state.strings.selectTime}
69
+ onKeyDown={state.handlePopupKeydown}
70
+ onBlur={state.handlePopupFocusOut}
71
+ >
72
+ <div className="pkt-timepicker-popup__col" role="listbox" aria-label={hoursLabel} aria-orientation="vertical">
73
+ {state.hourOptions.map((h) => renderOption(h, 'hour'))}
74
+ </div>
75
+ <div className="pkt-timepicker-popup__col" role="listbox" aria-label={minutesLabel} aria-orientation="vertical">
76
+ {state.minuteOptions.map((m) => renderOption(m, 'minute'))}
77
+ </div>
78
+ </div>
79
+ )
80
+
81
+ const renderContainer = () => (
82
+ <div className="pkt-input__container">
83
+ {state.stepArrows && (
84
+ <button
85
+ type="button"
86
+ className="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--prev"
87
+ aria-label={state.strings.prevTime}
88
+ disabled={state.disabled}
89
+ onClick={() => state.stepTimeDelta(-1)}
90
+ >
91
+ <PktIcon name="chevron-thin-left" />
92
+ <span className="pkt-btn__text">{state.strings.prevTime}</span>
93
+ </button>
94
+ )}
95
+ <input
96
+ ref={state.hoursInputRef}
97
+ type="text"
98
+ inputMode="numeric"
99
+ maxLength={2}
100
+ size={2}
101
+ className="pkt-input pkt-timepicker__input"
102
+ id={state.hoursId}
103
+ data-min="0"
104
+ data-max="23"
105
+ value={state.hours}
106
+ placeholder="––"
107
+ aria-label={hoursAriaLabel}
108
+ role="spinbutton"
109
+ aria-invalid={state.hasError || state.isInvalid || undefined}
110
+ aria-valuemin={0}
111
+ aria-valuemax={23}
112
+ aria-valuenow={state.hours !== '' ? parseInt(state.hours, 10) : undefined}
113
+ aria-valuetext={state.hours !== '' ? `${state.hours} ${hoursLabel.toLowerCase()}` : undefined}
114
+ autoComplete="off"
115
+ disabled={state.disabled}
116
+ onKeyDown={state.handleHoursKeydown}
117
+ onBlur={state.handleHoursBlur}
118
+ onFocus={(e) => {
119
+ e.currentTarget.select()
120
+ }}
121
+ onChange={() => {
122
+ /* value is driven by keydown handlers */
123
+ }}
124
+ onPaste={(e) => e.preventDefault()}
125
+ />
126
+ <span className="pkt-timepicker__separator">:</span>
127
+ <input
128
+ ref={state.minutesInputRef}
129
+ type="text"
130
+ inputMode="numeric"
131
+ maxLength={2}
132
+ size={2}
133
+ className="pkt-input pkt-timepicker__input"
134
+ id={state.minutesId}
135
+ data-min="0"
136
+ data-max="59"
137
+ value={state.minutes}
138
+ placeholder="––"
139
+ aria-label={minutesAriaLabel}
140
+ role="spinbutton"
141
+ aria-invalid={state.hasError || state.isInvalid || undefined}
142
+ aria-valuemin={0}
143
+ aria-valuemax={59}
144
+ aria-valuenow={state.minutes !== '' ? parseInt(state.minutes, 10) : undefined}
145
+ aria-valuetext={state.minutes !== '' ? `${state.minutes} ${minutesLabel.toLowerCase()}` : undefined}
146
+ autoComplete="off"
147
+ disabled={state.disabled}
148
+ onKeyDown={state.handleMinutesKeydown}
149
+ onBlur={state.handleMinutesBlur}
150
+ onFocus={(e) => {
151
+ e.currentTarget.select()
152
+ }}
153
+ onChange={() => {
154
+ /* value is driven by keydown handlers */
155
+ }}
156
+ onPaste={(e) => e.preventDefault()}
157
+ />
158
+ {state.hidePicker && !state.stepArrows && (
159
+ <PktIcon className="pkt-input-icon pkt-timepicker__icon" name="clock" aria-hidden={true} />
160
+ )}
161
+ {!state.hidePicker && !state.stepArrows && (
162
+ <button
163
+ ref={state.buttonRef}
164
+ type="button"
165
+ className="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button"
166
+ aria-label={state.strings.openPicker}
167
+ aria-haspopup="listbox"
168
+ aria-expanded={state.isOpen}
169
+ aria-controls={state.popupId}
170
+ disabled={state.disabled}
171
+ onClick={state.handleClockButtonClick}
172
+ >
173
+ <PktIcon name="clock" />
174
+ <span className="pkt-btn__text">{state.strings.openPicker}</span>
175
+ </button>
176
+ )}
177
+ {state.stepArrows && (
178
+ <button
179
+ type="button"
180
+ className="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--next"
181
+ aria-label={state.strings.nextTime}
182
+ disabled={state.disabled}
183
+ onClick={() => state.stepTimeDelta(1)}
184
+ >
185
+ <PktIcon name="chevron-thin-right" />
186
+ <span className="pkt-btn__text">{state.strings.nextTime}</span>
187
+ </button>
188
+ )}
189
+ {/* Native constraints are reported on the hours spinbutton (setCustomValidity), not here — avoids
190
+ "invalid form control is not focusable" for this hidden type="time" input. */}
191
+ <input
192
+ ref={state.changeInputRef}
193
+ type="time"
194
+ hidden
195
+ name={state.name}
196
+ id={state.hiddenInputId}
197
+ disabled={state.disabled}
198
+ tabIndex={-1}
199
+ aria-hidden={true}
200
+ onChange={onChange}
201
+ onInput={onInput}
202
+ />
203
+ </div>
204
+ )
205
+
206
+ return (
207
+ <div
208
+ ref={state.containerRef}
209
+ className={state.outerClasses}
210
+ onFocus={state.handleFocusIn}
211
+ onBlur={state.handleFocusOut}
212
+ >
213
+ <PktInputWrapper
214
+ forId={state.hoursId}
215
+ label={state.label}
216
+ disabled={state.disabled}
217
+ hasError={state.hasError}
218
+ inline={state.inline}
219
+ optionalTag={state.optionalTag}
220
+ optionalText={state.optionalText}
221
+ requiredTag={state.requiredTag}
222
+ requiredText={state.requiredText}
223
+ useWrapper={state.useWrapper}
224
+ ariaDescribedby={state.ariaDescribedby}
225
+ errorMessage={state.errorMessage}
226
+ helptext={state.helptext}
227
+ helptextDropdown={state.helptextDropdown}
228
+ helptextDropdownButton={state.helptextDropdownButton}
229
+ tagText={state.tagText}
230
+ >
231
+ {!state.hidePicker && !state.stepArrows ? (
232
+ <div className="pkt-timepicker__anchor">
233
+ {renderContainer()}
234
+ {renderPopup()}
235
+ </div>
236
+ ) : (
237
+ renderContainer()
238
+ )}
239
+ </PktInputWrapper>
240
+ </div>
241
+ )
242
+ })
243
+
244
+ PktTimepicker.displayName = 'PktTimepicker'
@@ -0,0 +1,118 @@
1
+ import type { ChangeEvent, FocusEvent, InputHTMLAttributes, ReactNode } from 'react'
2
+ import type { ITimepickerStrings } from 'shared-types/timepicker'
3
+
4
+ /**
5
+ * Props for {@link PktTimepicker}: a React time picker with `HH:MM`, aligned with Elements `pkt-timepicker`.
6
+ *
7
+ * The value is submitted via a hidden `input type="time"`; hour/minute fields are the editing UI. Use `value`
8
+ * or `defaultValue` for controlled vs. uncontrolled mode.
9
+ */
10
+ export interface IPktTimepicker
11
+ extends Omit<
12
+ InputHTMLAttributes<HTMLInputElement>,
13
+ 'value' | 'onChange' | 'min' | 'max' | 'step' | 'onFocus' | 'onBlur'
14
+ > {
15
+ /** Unique id; used for hour, minute, popup, and hidden input ids. */
16
+ id: string
17
+
18
+ /** Visible label (InputWrapper). */
19
+ label: string
20
+
21
+ /** Controlled value (`HH:MM`). */
22
+ value?: string
23
+
24
+ /** Initial value when the component is uncontrolled. */
25
+ defaultValue?: string
26
+
27
+ /**
28
+ * Earliest valid time (`HH:MM`). May be a number or string (HTML attribute / RHF).
29
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/time}
30
+ */
31
+ min?: string | number
32
+
33
+ /**
34
+ * Latest valid time (`HH:MM`). May be a number or string (HTML attribute / RHF).
35
+ */
36
+ max?: string | number
37
+
38
+ /**
39
+ * Step in **seconds** (e.g. `60` = 1 min, `300` = 5 min). Must be a valid multiple (same rules as Elements).
40
+ * @default 60
41
+ */
42
+ step?: number
43
+
44
+ /** Hides the clock button and popup; shows a static clock icon. */
45
+ hidePicker?: boolean
46
+
47
+ /** Shows previous/next step buttons instead of the popup. */
48
+ stepArrows?: boolean
49
+
50
+ /** Full width (`pkt-timepicker--fullwidth`). */
51
+ fullwidth?: boolean
52
+
53
+ /** Form field name on the hidden `input`; falls back to `id` if omitted. */
54
+ name?: string
55
+
56
+ /** Help text above the field. */
57
+ helptext?: string | ReactNode
58
+
59
+ /** Expandable help text (dropdown). */
60
+ helptextDropdown?: string | ReactNode
61
+
62
+ /** Button label for expandable help text. */
63
+ helptextDropdownButton?: string
64
+
65
+ /** Forces error state on the wrapper. */
66
+ hasError?: boolean
67
+
68
+ /** Error message below the field. */
69
+ errorMessage?: string | ReactNode
70
+
71
+ /** Shows an "optional" tag. */
72
+ optionalTag?: boolean
73
+
74
+ /** Label text for the optional tag. */
75
+ optionalText?: string
76
+
77
+ /** Shows a "required" tag. */
78
+ requiredTag?: boolean
79
+
80
+ /** Label text for the required tag. */
81
+ requiredText?: string
82
+
83
+ /** Extra tag next to the label. */
84
+ tagText?: string | null
85
+
86
+ /** Inline layout in InputWrapper. */
87
+ inline?: boolean
88
+
89
+ /** Show visible label and help text (`false` = screen-reader-only label). */
90
+ useWrapper?: boolean
91
+
92
+ /** Passed through to InputWrapper as `aria-describedby`. */
93
+ ariaDescribedby?: string
94
+
95
+ /** Copy for hours, minutes, buttons, and popup. */
96
+ strings?: ITimepickerStrings
97
+
98
+ /** Change event from the hidden input (`e.target.value` is `HH:MM`). */
99
+ onChange?: (e: ChangeEvent<HTMLInputElement>) => void
100
+
101
+ /** Input events while typing or using spinners. */
102
+ onInput?: (e: ChangeEvent<HTMLInputElement>) => void
103
+
104
+ /** Callback with the committed `HH:MM` string. */
105
+ onValueChange?: (value: string) => void
106
+
107
+ /**
108
+ * Fires when focus enters this control from outside (not when moving only between hour and minute).
109
+ * Implemented on the root wrapper; spinbuttons must allow focus to bubble so this runs.
110
+ */
111
+ onFocus?: (e: FocusEvent<Element>) => void
112
+
113
+ /** Focus leaves the whole component. */
114
+ onBlur?: (e: FocusEvent<Element>) => void
115
+
116
+ /** Extra class names on the root (`pkt-timepicker`). */
117
+ className?: string
118
+ }