@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.
- package/CHANGELOG.md +36 -0
- package/dist/index.d.ts +86 -0
- package/dist/punkt-react.es.js +6552 -5265
- package/dist/punkt-react.umd.js +566 -380
- package/package.json +4 -4
- package/src/components/header/HeaderService.tsx +157 -150
- package/src/components/index.ts +1 -0
- package/src/components/interfaces.ts +1 -0
- package/src/components/timepicker/Timepicker.test.tsx +336 -0
- package/src/components/timepicker/Timepicker.tsx +244 -0
- package/src/components/timepicker/types.ts +118 -0
- package/src/components/timepicker/useTimepickerState.ts +843 -0
|
@@ -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
|
+
}
|