@oslokommune/punkt-react 16.6.2 → 16.7.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "16.6.2",
3
+ "version": "16.7.1",
4
4
  "description": "React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo",
5
5
  "homepage": "https://punkt.oslo.kommune.no",
6
6
  "author": "Team Designsystem, Oslo Origo",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@lit-labs/ssr-dom-shim": "^1.2.1",
41
41
  "@lit/react": "^1.0.7",
42
- "@oslokommune/punkt-elements": "^16.6.2",
42
+ "@oslokommune/punkt-elements": "^16.7.1",
43
43
  "classnames": "^2.5.1",
44
44
  "prettier": "^3.3.3",
45
45
  "react-hook-form": "^7.53.0"
@@ -50,7 +50,7 @@
50
50
  "@eslint/eslintrc": "^3.3.3",
51
51
  "@eslint/js": "^9.37.0",
52
52
  "@oslokommune/punkt-assets": "^16.0.0",
53
- "@oslokommune/punkt-css": "^16.5.1",
53
+ "@oslokommune/punkt-css": "^16.7.1",
54
54
  "@testing-library/jest-dom": "^6.5.0",
55
55
  "@testing-library/react": "^16.0.1",
56
56
  "@testing-library/user-event": "^14.5.2",
@@ -109,5 +109,5 @@
109
109
  "url": "https://github.com/oslokommune/punkt/issues"
110
110
  },
111
111
  "license": "MIT",
112
- "gitHead": "420cd507db8426491e86687bf18f3483202f5c25"
112
+ "gitHead": "f03ecd48c2bc6097584f3a3f1d45848578bf524d"
113
113
  }
@@ -43,5 +43,6 @@ export { PktTabItem } from './tabs/TabItem'
43
43
  export { PktTag } from './tag/Tag'
44
44
  export { PktTextarea } from './textarea/Textarea'
45
45
  export { PktTextinput } from './textinput/Textinput'
46
+ export { PktTimepicker } from './timepicker/Timepicker'
46
47
  export * from './interfaces'
47
48
  export * from './types'
@@ -26,3 +26,4 @@ export type { IPktTab } from './tabs/Tabs'
26
26
  export type { IPktTag } from './tag/Tag'
27
27
  export type { IPktTextarea } from './textarea/Textarea'
28
28
  export type { IPktTextinput } from './textinput/Textinput'
29
+ export type { IPktTimepicker } from './timepicker/types'
@@ -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,251 @@
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
83
+ className="pkt-input__container"
84
+ onClick={(e) => {
85
+ const target = e.target as HTMLElement
86
+ if (target.closest('button, input')) return
87
+ state.hoursInputRef.current?.focus()
88
+ }}
89
+ >
90
+ {state.stepArrows && (
91
+ <button
92
+ type="button"
93
+ className="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--prev"
94
+ aria-label={state.strings.prevTime}
95
+ disabled={state.disabled}
96
+ onClick={() => state.stepTimeDelta(-1)}
97
+ >
98
+ <PktIcon name="chevron-thin-left" />
99
+ <span className="pkt-btn__text">{state.strings.prevTime}</span>
100
+ </button>
101
+ )}
102
+ <input
103
+ ref={state.hoursInputRef}
104
+ type="text"
105
+ inputMode="numeric"
106
+ maxLength={2}
107
+ size={2}
108
+ className="pkt-input pkt-timepicker__input"
109
+ id={state.hoursId}
110
+ data-min="0"
111
+ data-max="23"
112
+ value={state.hours}
113
+ placeholder="––"
114
+ aria-label={hoursAriaLabel}
115
+ role="spinbutton"
116
+ aria-invalid={state.hasError || state.isInvalid || undefined}
117
+ aria-valuemin={0}
118
+ aria-valuemax={23}
119
+ aria-valuenow={state.hours !== '' ? parseInt(state.hours, 10) : undefined}
120
+ aria-valuetext={state.hours !== '' ? `${state.hours} ${hoursLabel.toLowerCase()}` : undefined}
121
+ autoComplete="off"
122
+ disabled={state.disabled}
123
+ onKeyDown={state.handleHoursKeydown}
124
+ onBlur={state.handleHoursBlur}
125
+ onFocus={(e) => {
126
+ e.currentTarget.select()
127
+ }}
128
+ onChange={() => {
129
+ /* value is driven by keydown handlers */
130
+ }}
131
+ onPaste={(e) => e.preventDefault()}
132
+ />
133
+ <span className="pkt-timepicker__separator">:</span>
134
+ <input
135
+ ref={state.minutesInputRef}
136
+ type="text"
137
+ inputMode="numeric"
138
+ maxLength={2}
139
+ size={2}
140
+ className="pkt-input pkt-timepicker__input"
141
+ id={state.minutesId}
142
+ data-min="0"
143
+ data-max="59"
144
+ value={state.minutes}
145
+ placeholder="––"
146
+ aria-label={minutesAriaLabel}
147
+ role="spinbutton"
148
+ aria-invalid={state.hasError || state.isInvalid || undefined}
149
+ aria-valuemin={0}
150
+ aria-valuemax={59}
151
+ aria-valuenow={state.minutes !== '' ? parseInt(state.minutes, 10) : undefined}
152
+ aria-valuetext={state.minutes !== '' ? `${state.minutes} ${minutesLabel.toLowerCase()}` : undefined}
153
+ autoComplete="off"
154
+ disabled={state.disabled}
155
+ onKeyDown={state.handleMinutesKeydown}
156
+ onBlur={state.handleMinutesBlur}
157
+ onFocus={(e) => {
158
+ e.currentTarget.select()
159
+ }}
160
+ onChange={() => {
161
+ /* value is driven by keydown handlers */
162
+ }}
163
+ onPaste={(e) => e.preventDefault()}
164
+ />
165
+ {state.hidePicker && !state.stepArrows && (
166
+ <PktIcon className="pkt-input-icon pkt-timepicker__icon" name="clock" aria-hidden={true} />
167
+ )}
168
+ {!state.hidePicker && !state.stepArrows && (
169
+ <button
170
+ ref={state.buttonRef}
171
+ type="button"
172
+ className="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button"
173
+ aria-label={state.strings.openPicker}
174
+ aria-haspopup="listbox"
175
+ aria-expanded={state.isOpen}
176
+ aria-controls={state.popupId}
177
+ disabled={state.disabled}
178
+ onClick={state.handleClockButtonClick}
179
+ >
180
+ <PktIcon name="clock" />
181
+ <span className="pkt-btn__text">{state.strings.openPicker}</span>
182
+ </button>
183
+ )}
184
+ {state.stepArrows && (
185
+ <button
186
+ type="button"
187
+ className="pkt-input-icon pkt-btn pkt-btn--icon-only pkt-btn--tertiary pkt-timepicker__button pkt-timepicker__button--next"
188
+ aria-label={state.strings.nextTime}
189
+ disabled={state.disabled}
190
+ onClick={() => state.stepTimeDelta(1)}
191
+ >
192
+ <PktIcon name="chevron-thin-right" />
193
+ <span className="pkt-btn__text">{state.strings.nextTime}</span>
194
+ </button>
195
+ )}
196
+ {/* Native constraints are reported on the hours spinbutton (setCustomValidity), not here — avoids
197
+ "invalid form control is not focusable" for this hidden type="time" input. */}
198
+ <input
199
+ ref={state.changeInputRef}
200
+ type="time"
201
+ hidden
202
+ name={state.name}
203
+ id={state.hiddenInputId}
204
+ disabled={state.disabled}
205
+ tabIndex={-1}
206
+ aria-hidden={true}
207
+ onChange={onChange}
208
+ onInput={onInput}
209
+ />
210
+ </div>
211
+ )
212
+
213
+ return (
214
+ <div
215
+ ref={state.containerRef}
216
+ className={state.outerClasses}
217
+ onFocus={state.handleFocusIn}
218
+ onBlur={state.handleFocusOut}
219
+ >
220
+ <PktInputWrapper
221
+ forId={state.hoursId}
222
+ label={state.label}
223
+ disabled={state.disabled}
224
+ hasError={state.hasError}
225
+ inline={state.inline}
226
+ optionalTag={state.optionalTag}
227
+ optionalText={state.optionalText}
228
+ requiredTag={state.requiredTag}
229
+ requiredText={state.requiredText}
230
+ useWrapper={state.useWrapper}
231
+ ariaDescribedby={state.ariaDescribedby}
232
+ errorMessage={state.errorMessage}
233
+ helptext={state.helptext}
234
+ helptextDropdown={state.helptextDropdown}
235
+ helptextDropdownButton={state.helptextDropdownButton}
236
+ tagText={state.tagText}
237
+ >
238
+ {!state.hidePicker && !state.stepArrows ? (
239
+ <div className="pkt-timepicker__anchor">
240
+ {renderContainer()}
241
+ {renderPopup()}
242
+ </div>
243
+ ) : (
244
+ renderContainer()
245
+ )}
246
+ </PktInputWrapper>
247
+ </div>
248
+ )
249
+ })
250
+
251
+ PktTimepicker.displayName = 'PktTimepicker'