@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/CHANGELOG.md +36 -0
- package/dist/index.d.ts +86 -0
- package/dist/punkt-react.es.js +6542 -5239
- package/dist/punkt-react.umd.js +545 -355
- package/package.json +4 -4
- 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 +251 -0
- package/src/components/timepicker/types.ts +118 -0
- package/src/components/timepicker/useTimepickerState.ts +843 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KeyboardEvent,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import { defaultTimepickerStrings } from 'shared-types/timepicker'
|
|
12
|
+
import { getHourOptions, getMinuteOptions, getMinuteStep } from 'shared-utils/timepicker/options'
|
|
13
|
+
import { stepTime } from 'shared-utils/timepicker/stepper'
|
|
14
|
+
import { isValidTimeString, timeToMinutes } from 'shared-utils/timepicker/time-utils'
|
|
15
|
+
|
|
16
|
+
import type { IPktTimepicker } from './types'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ## Why this hook exists (React form model)
|
|
20
|
+
*
|
|
21
|
+
* Punkt’s time field is a **custom control** (two visible segments + optional popup), but forms and
|
|
22
|
+
* libraries (e.g. React Hook Form’s `register()`) expect a **single named value**. We therefore keep a
|
|
23
|
+
* real but **visually hidden** `<input type="time">` in the tree with `name` / `value` for serialization
|
|
24
|
+
* and refs — while users interact with the spinbuttons.
|
|
25
|
+
*
|
|
26
|
+
* ## Why we don’t put `required` / `min` / `max` / `step` on that hidden input
|
|
27
|
+
*
|
|
28
|
+
* In HTML, invalid fields trigger the browser’s built‑in validation. The browser then **focuses** the
|
|
29
|
+
* invalid control to show a message. A hidden, non-tabbable time input **cannot be focused**, so you get
|
|
30
|
+
* an error like “invalid form control is not focusable” and **`submit` never fires**. That is a React
|
|
31
|
+
* footgun, not a bug in your form handler.
|
|
32
|
+
*
|
|
33
|
+
* ## What we do instead (deliberate design)
|
|
34
|
+
*
|
|
35
|
+
* - **Value:** The hidden input is still the source of the submitted `HH:MM` string; we **sync** from the
|
|
36
|
+
* spinbuttons into it before submit (see `applyNativeConstraintAnchorMessage`, form `pointerdown`, Enter).
|
|
37
|
+
* - **Constraints:** We run the same rules as Elements (`validateTimeValue`) and attach messages with
|
|
38
|
+
* **`setCustomValidity` on the visible hours `<input>`** so the browser can always focus something real.
|
|
39
|
+
*
|
|
40
|
+
* Custom Elements use **`ElementInternals.setValidity(..., anchor)`** for the same “report on a visible
|
|
41
|
+
* node” idea. React components don’t get `attachInternals()` for that API on a `<div>`, so this pattern
|
|
42
|
+
* is the sustainable equivalent in plain React.
|
|
43
|
+
*/
|
|
44
|
+
const FORM_MESSAGES = {
|
|
45
|
+
required: 'Dette feltet er påkrevd',
|
|
46
|
+
invalid: 'Ugyldig verdi',
|
|
47
|
+
timeStepMismatch: 'Må treffe på et steg, for eksempel 0, {step} minutter',
|
|
48
|
+
timeStepMismatchHour: 'Må treffe på hver hele time',
|
|
49
|
+
timeStepMismatchHalfHour: 'Må treffe på hver hele eller halve time',
|
|
50
|
+
rangeUnderflowMin: 'Verdien må være større enn eller lik {min}.',
|
|
51
|
+
rangeOverflowMax: 'Verdien må være mindre enn eller lik {max}.',
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
function splitValid(value: string | undefined): [string, string] {
|
|
55
|
+
if (value && isValidTimeString(value)) {
|
|
56
|
+
const [h, m] = value.split(':')
|
|
57
|
+
return [h, m]
|
|
58
|
+
}
|
|
59
|
+
return ['', '']
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Spinbuttons hold 1–2 digit strings while editing; commits must be zero-padded `HH:MM`. */
|
|
63
|
+
function displaySegmentsToCommittedTime(h: string, m: string): string {
|
|
64
|
+
return `${String(parseInt(h, 10)).padStart(2, '0')}:${String(parseInt(m, 10)).padStart(2, '0')}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateTimeValue(
|
|
68
|
+
value: string,
|
|
69
|
+
opts: { required?: boolean; min?: string | number; max?: string | number; step?: number },
|
|
70
|
+
): { valid: boolean; message?: string } {
|
|
71
|
+
const { required, min, max, step } = opts
|
|
72
|
+
if (required && !value) {
|
|
73
|
+
return { valid: false, message: FORM_MESSAGES.required }
|
|
74
|
+
}
|
|
75
|
+
if (!value) {
|
|
76
|
+
return { valid: true }
|
|
77
|
+
}
|
|
78
|
+
if (!isValidTimeString(value)) {
|
|
79
|
+
return { valid: false, message: FORM_MESSAGES.invalid }
|
|
80
|
+
}
|
|
81
|
+
const totalMinutes = timeToMinutes(value)
|
|
82
|
+
const minuteStep = getMinuteStep(step)
|
|
83
|
+
if (min && totalMinutes < timeToMinutes(String(min))) {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
message: FORM_MESSAGES.rangeUnderflowMin.replace('{min}', String(min)),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (max && totalMinutes > timeToMinutes(String(max))) {
|
|
90
|
+
return {
|
|
91
|
+
valid: false,
|
|
92
|
+
message: FORM_MESSAGES.rangeOverflowMax.replace('{max}', String(max)),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (step && totalMinutes % minuteStep !== 0) {
|
|
96
|
+
const stepMessage =
|
|
97
|
+
minuteStep === 60
|
|
98
|
+
? FORM_MESSAGES.timeStepMismatchHour
|
|
99
|
+
: minuteStep === 30
|
|
100
|
+
? FORM_MESSAGES.timeStepMismatchHalfHour
|
|
101
|
+
: FORM_MESSAGES.timeStepMismatch.replace('{step}', `${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`)
|
|
102
|
+
return { valid: false, message: stepMessage }
|
|
103
|
+
}
|
|
104
|
+
return { valid: true }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isValidStep(s: number): boolean {
|
|
108
|
+
return s === 3600 || (s < 3600 && 3600 % s === 0 && s % 60 === 0)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRef<HTMLDivElement>) {
|
|
112
|
+
const {
|
|
113
|
+
id,
|
|
114
|
+
label,
|
|
115
|
+
value,
|
|
116
|
+
defaultValue,
|
|
117
|
+
min,
|
|
118
|
+
max,
|
|
119
|
+
step = 60,
|
|
120
|
+
hidePicker = false,
|
|
121
|
+
stepArrows = false,
|
|
122
|
+
fullwidth = false,
|
|
123
|
+
name,
|
|
124
|
+
disabled = false,
|
|
125
|
+
required = false,
|
|
126
|
+
helptext,
|
|
127
|
+
helptextDropdown,
|
|
128
|
+
helptextDropdownButton,
|
|
129
|
+
hasError = false,
|
|
130
|
+
errorMessage,
|
|
131
|
+
optionalTag = false,
|
|
132
|
+
optionalText,
|
|
133
|
+
requiredTag = false,
|
|
134
|
+
requiredText,
|
|
135
|
+
tagText,
|
|
136
|
+
inline = false,
|
|
137
|
+
useWrapper = true,
|
|
138
|
+
ariaDescribedby,
|
|
139
|
+
strings: stringsProp,
|
|
140
|
+
className,
|
|
141
|
+
onValueChange,
|
|
142
|
+
onFocus: onFocusProp,
|
|
143
|
+
onBlur: onBlurProp,
|
|
144
|
+
} = props
|
|
145
|
+
|
|
146
|
+
const strings = useMemo(() => stringsProp ?? defaultTimepickerStrings, [stringsProp])
|
|
147
|
+
|
|
148
|
+
const isControlled = value !== undefined
|
|
149
|
+
const initialStringValue = value !== undefined ? (value ?? '') : (defaultValue ?? '')
|
|
150
|
+
const initialValuesRef = useRef(initialStringValue)
|
|
151
|
+
|
|
152
|
+
const [internalValue, setInternalValue] = useState(() => initialStringValue)
|
|
153
|
+
|
|
154
|
+
const effectiveValue = isControlled ? (value ?? '') : internalValue
|
|
155
|
+
|
|
156
|
+
const [hours, setHoursState] = useState(() => splitValid(value !== undefined ? value : defaultValue)[0])
|
|
157
|
+
const [minutes, setMinutesState] = useState(() => splitValid(value !== undefined ? value : defaultValue)[1])
|
|
158
|
+
|
|
159
|
+
const hoursRef = useRef(hours)
|
|
160
|
+
const minutesRef = useRef(minutes)
|
|
161
|
+
|
|
162
|
+
const setHours = useCallback((h: string) => {
|
|
163
|
+
hoursRef.current = h
|
|
164
|
+
setHoursState(h)
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
const setMinutes = useCallback((m: string) => {
|
|
168
|
+
minutesRef.current = m
|
|
169
|
+
setMinutesState(m)
|
|
170
|
+
}, [])
|
|
171
|
+
|
|
172
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
173
|
+
const [isInvalid, setIsInvalid] = useState(false)
|
|
174
|
+
|
|
175
|
+
const prevControlledValueRef = useRef(value)
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!isControlled) return
|
|
178
|
+
if (value === prevControlledValueRef.current) return
|
|
179
|
+
prevControlledValueRef.current = value
|
|
180
|
+
const [h, m] = splitValid(value)
|
|
181
|
+
setHours(h)
|
|
182
|
+
setMinutes(m)
|
|
183
|
+
}, [value, isControlled, setHours, setMinutes])
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (step !== null && step !== undefined && !isValidStep(step)) {
|
|
187
|
+
// eslint-disable-next-line no-console
|
|
188
|
+
console.warn(
|
|
189
|
+
`PktTimepicker: step="${step}" er ikke en gyldig verdi. Step må være et multiplum av 60 (hele minutter) eller nøyaktig 3600 (hel time).`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}, [step])
|
|
193
|
+
|
|
194
|
+
const hoursInputRef = useRef<HTMLInputElement>(null)
|
|
195
|
+
const minutesInputRef = useRef<HTMLInputElement>(null)
|
|
196
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
197
|
+
const popupRef = useRef<HTMLDivElement>(null)
|
|
198
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
199
|
+
const changeInputRef = useRef<HTMLInputElement>(null)
|
|
200
|
+
|
|
201
|
+
const hoursDigitCountRef = useRef(0)
|
|
202
|
+
const hoursFirstDigitRef = useRef(-1)
|
|
203
|
+
const minutesDigitCountRef = useRef(0)
|
|
204
|
+
const minutesFirstDigitRef = useRef(-1)
|
|
205
|
+
const [touched, setTouched] = useState(false)
|
|
206
|
+
|
|
207
|
+
const nativeInputValueSetter = useMemo(
|
|
208
|
+
() => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set,
|
|
209
|
+
[],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const minuteStep = useMemo(() => getMinuteStep(step), [step])
|
|
213
|
+
const hourOptions = useMemo(() => getHourOptions(min, max), [min, max])
|
|
214
|
+
const minuteOptions = useMemo(() => getMinuteOptions(step), [step])
|
|
215
|
+
|
|
216
|
+
const hoursId = `${id}-hours`
|
|
217
|
+
const minutesId = `${id}-minutes`
|
|
218
|
+
const popupId = `${id}-popup`
|
|
219
|
+
const hiddenInputId = `${id}-input`
|
|
220
|
+
|
|
221
|
+
// Sett native constraint validation på det synlige timer-inputfeltet,
|
|
222
|
+
// slik at form.reportValidity() viser feilmelding på riktig element
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const anchor = hoursInputRef.current
|
|
225
|
+
if (!anchor) return
|
|
226
|
+
const validation = validateTimeValue(effectiveValue, { required, min, max, step })
|
|
227
|
+
anchor.setCustomValidity(validation.valid ? '' : (validation.message ?? ''))
|
|
228
|
+
setIsInvalid(touched && !validation.valid)
|
|
229
|
+
}, [effectiveValue, required, min, max, step, touched])
|
|
230
|
+
|
|
231
|
+
const commitValue = useCallback(
|
|
232
|
+
(newValue: string) => {
|
|
233
|
+
if (!isControlled) {
|
|
234
|
+
setInternalValue(newValue)
|
|
235
|
+
}
|
|
236
|
+
const input = changeInputRef.current
|
|
237
|
+
if (!input || !nativeInputValueSetter) return
|
|
238
|
+
nativeInputValueSetter.call(input, newValue)
|
|
239
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
240
|
+
onValueChange?.(newValue)
|
|
241
|
+
},
|
|
242
|
+
[isControlled, nativeInputValueSetter, onValueChange],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
const syncValueFromDisplay = useCallback(() => {
|
|
246
|
+
const h = hoursRef.current
|
|
247
|
+
const m = minutesRef.current
|
|
248
|
+
if (h !== '' && m !== '') {
|
|
249
|
+
const newValue = displaySegmentsToCommittedTime(h, m)
|
|
250
|
+
if (newValue !== effectiveValue) {
|
|
251
|
+
commitValue(newValue)
|
|
252
|
+
}
|
|
253
|
+
} else if (effectiveValue !== '') {
|
|
254
|
+
commitValue('')
|
|
255
|
+
}
|
|
256
|
+
}, [effectiveValue, commitValue])
|
|
257
|
+
|
|
258
|
+
const syncValueFromDisplayWithInput = useCallback(() => {
|
|
259
|
+
const h = hoursRef.current
|
|
260
|
+
const m = minutesRef.current
|
|
261
|
+
if (h !== '' && m !== '') {
|
|
262
|
+
const newValue = displaySegmentsToCommittedTime(h, m)
|
|
263
|
+
if (newValue !== effectiveValue) {
|
|
264
|
+
commitValue(newValue)
|
|
265
|
+
} else {
|
|
266
|
+
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
267
|
+
}
|
|
268
|
+
} else if (effectiveValue !== '') {
|
|
269
|
+
commitValue('')
|
|
270
|
+
} else {
|
|
271
|
+
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
272
|
+
}
|
|
273
|
+
}, [effectiveValue, commitValue])
|
|
274
|
+
|
|
275
|
+
/** Flush UI → hidden value, then report min/max/step/required on the focusable hours field (form anchor). */
|
|
276
|
+
const applyNativeConstraintAnchorMessage = useCallback(() => {
|
|
277
|
+
setTouched(true)
|
|
278
|
+
syncValueFromDisplay()
|
|
279
|
+
const hoursEl = hoursInputRef.current
|
|
280
|
+
const hidden = changeInputRef.current
|
|
281
|
+
if (!hoursEl) return
|
|
282
|
+
const raw = hidden?.value ?? ''
|
|
283
|
+
const result = validateTimeValue(raw, { required, min, max, step })
|
|
284
|
+
hoursEl.setCustomValidity(result.valid ? '' : (result.message ?? ''))
|
|
285
|
+
}, [syncValueFromDisplay, required, min, max, step])
|
|
286
|
+
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
const root = containerRef.current
|
|
289
|
+
const form = root?.closest('form')
|
|
290
|
+
if (!form) return
|
|
291
|
+
|
|
292
|
+
const isSubmitActivator = (target: EventTarget | null) => {
|
|
293
|
+
const el = target as HTMLElement | null
|
|
294
|
+
if (!el || !form.contains(el)) return false
|
|
295
|
+
const control = el.closest('button, input[type="submit"], input[type="image"]')
|
|
296
|
+
if (!control || !form.contains(control)) return false
|
|
297
|
+
if (control instanceof HTMLButtonElement) {
|
|
298
|
+
return control.type !== 'button' && control.type !== 'reset'
|
|
299
|
+
}
|
|
300
|
+
return control instanceof HTMLInputElement
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const onPointerDownCapture = (e: PointerEvent) => {
|
|
304
|
+
if (!isSubmitActivator(e.target)) return
|
|
305
|
+
applyNativeConstraintAnchorMessage()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
form.addEventListener('pointerdown', onPointerDownCapture, true)
|
|
309
|
+
return () => form.removeEventListener('pointerdown', onPointerDownCapture, true)
|
|
310
|
+
}, [applyNativeConstraintAnchorMessage])
|
|
311
|
+
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (changeInputRef.current) {
|
|
314
|
+
changeInputRef.current.value = effectiveValue
|
|
315
|
+
}
|
|
316
|
+
}, [effectiveValue])
|
|
317
|
+
|
|
318
|
+
const closePopup = useCallback(() => {
|
|
319
|
+
setIsOpen(false)
|
|
320
|
+
syncValueFromDisplay()
|
|
321
|
+
}, [syncValueFromDisplay])
|
|
322
|
+
|
|
323
|
+
useLayoutEffect(() => {
|
|
324
|
+
if (!isOpen) return
|
|
325
|
+
const popup = popupRef.current
|
|
326
|
+
if (!popup) return
|
|
327
|
+
popup.querySelectorAll('.pkt-timepicker-popup__col').forEach((col) => {
|
|
328
|
+
const selected = col.querySelector('.pkt-timepicker-popup__option--selected')
|
|
329
|
+
if (selected) {
|
|
330
|
+
selected.scrollIntoView({ block: 'center' })
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
|
|
334
|
+
const col = cols[0]
|
|
335
|
+
if (!col) return
|
|
336
|
+
const selected = col.querySelector('.pkt-timepicker-popup__option--selected') as HTMLElement | null
|
|
337
|
+
const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
|
|
338
|
+
;(selected || first)?.focus()
|
|
339
|
+
}, [isOpen])
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!isOpen) return
|
|
343
|
+
const handleDocClick = (e: MouseEvent) => {
|
|
344
|
+
const t = e.target as Node
|
|
345
|
+
if (containerRef.current && !containerRef.current.contains(t)) {
|
|
346
|
+
closePopup()
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
document.addEventListener('click', handleDocClick, true)
|
|
350
|
+
return () => document.removeEventListener('click', handleDocClick, true)
|
|
351
|
+
}, [isOpen, closePopup])
|
|
352
|
+
|
|
353
|
+
const focusSelectedOrFirst = useCallback((type: 'hour' | 'minute') => {
|
|
354
|
+
const popup = popupRef.current
|
|
355
|
+
if (!popup) return
|
|
356
|
+
const cols = popup.querySelectorAll('.pkt-timepicker-popup__col')
|
|
357
|
+
const col = type === 'hour' ? cols[0] : cols[1]
|
|
358
|
+
if (!col) return
|
|
359
|
+
const selected = col.querySelector('.pkt-timepicker-popup__option--selected') as HTMLElement | null
|
|
360
|
+
const first = col.querySelector('.pkt-timepicker-popup__option') as HTMLElement | null
|
|
361
|
+
;(selected || first)?.focus()
|
|
362
|
+
}, [])
|
|
363
|
+
|
|
364
|
+
const handleOptionClick = useCallback(
|
|
365
|
+
(optionValue: number, type: 'hour' | 'minute') => {
|
|
366
|
+
const padded = String(optionValue).padStart(2, '0')
|
|
367
|
+
if (type === 'hour') {
|
|
368
|
+
setHours(padded)
|
|
369
|
+
requestAnimationFrame(() => focusSelectedOrFirst('minute'))
|
|
370
|
+
} else {
|
|
371
|
+
setMinutes(padded)
|
|
372
|
+
setIsOpen(false)
|
|
373
|
+
const h = hoursRef.current
|
|
374
|
+
if (h !== '') {
|
|
375
|
+
commitValue(`${h}:${padded}`)
|
|
376
|
+
}
|
|
377
|
+
requestAnimationFrame(() => buttonRef.current?.focus())
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
[commitValue, focusSelectedOrFirst, setHours, setMinutes],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
const stepTimeDelta = useCallback(
|
|
384
|
+
(direction: 1 | -1) => {
|
|
385
|
+
const result = stepTime(hoursRef.current, minutesRef.current, direction, minuteStep)
|
|
386
|
+
setHours(result.hours)
|
|
387
|
+
setMinutes(result.minutes)
|
|
388
|
+
if (`${result.hours}:${result.minutes}` !== effectiveValue) {
|
|
389
|
+
commitValue(`${result.hours}:${result.minutes}`)
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
[minuteStep, effectiveValue, commitValue, setHours, setMinutes],
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
const handleHoursKeydown = useCallback(
|
|
396
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
397
|
+
hoursInputRef.current?.setCustomValidity('')
|
|
398
|
+
const input = e.currentTarget
|
|
399
|
+
const m = minutesRef.current
|
|
400
|
+
switch (e.key) {
|
|
401
|
+
case 'ArrowUp': {
|
|
402
|
+
e.preventDefault()
|
|
403
|
+
const h = hoursRef.current !== '' ? parseInt(hoursRef.current, 10) : 0
|
|
404
|
+
const newH = String((h + 1) % 24).padStart(2, '0')
|
|
405
|
+
setHours(newH)
|
|
406
|
+
if (m !== '') {
|
|
407
|
+
const v = `${newH}:${m}`
|
|
408
|
+
if (v !== effectiveValue) commitValue(v)
|
|
409
|
+
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
410
|
+
}
|
|
411
|
+
break
|
|
412
|
+
}
|
|
413
|
+
case 'ArrowDown': {
|
|
414
|
+
e.preventDefault()
|
|
415
|
+
const h = hoursRef.current !== '' ? parseInt(hoursRef.current, 10) : 0
|
|
416
|
+
const newH = String((h - 1 + 24) % 24).padStart(2, '0')
|
|
417
|
+
setHours(newH)
|
|
418
|
+
if (m !== '') {
|
|
419
|
+
const v = `${newH}:${m}`
|
|
420
|
+
if (v !== effectiveValue) commitValue(v)
|
|
421
|
+
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
422
|
+
}
|
|
423
|
+
break
|
|
424
|
+
}
|
|
425
|
+
case 'ArrowRight':
|
|
426
|
+
e.preventDefault()
|
|
427
|
+
minutesInputRef.current?.focus()
|
|
428
|
+
break
|
|
429
|
+
case 'Backspace':
|
|
430
|
+
case 'Delete': {
|
|
431
|
+
e.preventDefault()
|
|
432
|
+
hoursDigitCountRef.current = 0
|
|
433
|
+
hoursFirstDigitRef.current = -1
|
|
434
|
+
const cur = hoursRef.current
|
|
435
|
+
if (cur.length > 0) {
|
|
436
|
+
setHours(cur.slice(0, -1))
|
|
437
|
+
}
|
|
438
|
+
syncValueFromDisplayWithInput()
|
|
439
|
+
break
|
|
440
|
+
}
|
|
441
|
+
case 'Tab':
|
|
442
|
+
break
|
|
443
|
+
case 'Enter': {
|
|
444
|
+
e.preventDefault()
|
|
445
|
+
applyNativeConstraintAnchorMessage()
|
|
446
|
+
const form = input.form
|
|
447
|
+
if (form) form.requestSubmit()
|
|
448
|
+
else input.blur()
|
|
449
|
+
break
|
|
450
|
+
}
|
|
451
|
+
default:
|
|
452
|
+
if (/^\d$/.test(e.key)) {
|
|
453
|
+
e.preventDefault()
|
|
454
|
+
const digit = parseInt(e.key, 10)
|
|
455
|
+
if (hoursDigitCountRef.current === 0) {
|
|
456
|
+
hoursFirstDigitRef.current = digit
|
|
457
|
+
const newH = String(digit).padStart(2, '0')
|
|
458
|
+
setHours(newH)
|
|
459
|
+
hoursDigitCountRef.current = 1
|
|
460
|
+
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
461
|
+
if (digit >= 3) {
|
|
462
|
+
hoursFirstDigitRef.current = -1
|
|
463
|
+
hoursDigitCountRef.current = 0
|
|
464
|
+
syncValueFromDisplayWithInput()
|
|
465
|
+
minutesInputRef.current?.focus()
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
const combined = hoursFirstDigitRef.current * 10 + digit
|
|
469
|
+
const newH = combined <= 23 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
|
|
470
|
+
setHours(newH)
|
|
471
|
+
hoursFirstDigitRef.current = -1
|
|
472
|
+
hoursDigitCountRef.current = 0
|
|
473
|
+
syncValueFromDisplayWithInput()
|
|
474
|
+
minutesInputRef.current?.focus()
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
e.preventDefault()
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
[effectiveValue, commitValue, setHours, syncValueFromDisplayWithInput, applyNativeConstraintAnchorMessage],
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
const handleHoursBlur = useCallback(() => {
|
|
485
|
+
setTouched(true)
|
|
486
|
+
if (hoursRef.current !== '') {
|
|
487
|
+
const newH = String(parseInt(hoursRef.current, 10)).padStart(2, '0')
|
|
488
|
+
setHours(newH)
|
|
489
|
+
}
|
|
490
|
+
hoursDigitCountRef.current = 0
|
|
491
|
+
hoursFirstDigitRef.current = -1
|
|
492
|
+
syncValueFromDisplay()
|
|
493
|
+
}, [setHours, syncValueFromDisplay])
|
|
494
|
+
|
|
495
|
+
const handleMinutesKeydown = useCallback(
|
|
496
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
497
|
+
hoursInputRef.current?.setCustomValidity('')
|
|
498
|
+
const input = e.currentTarget
|
|
499
|
+
const h = hoursRef.current
|
|
500
|
+
switch (e.key) {
|
|
501
|
+
case 'ArrowUp': {
|
|
502
|
+
e.preventDefault()
|
|
503
|
+
const m = minutesRef.current !== '' ? parseInt(minutesRef.current, 10) : 0
|
|
504
|
+
const newM = String((m + minuteStep) % 60).padStart(2, '0')
|
|
505
|
+
setMinutes(newM)
|
|
506
|
+
if (h !== '') {
|
|
507
|
+
const v = `${h}:${newM}`
|
|
508
|
+
if (v !== effectiveValue) commitValue(v)
|
|
509
|
+
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
510
|
+
}
|
|
511
|
+
break
|
|
512
|
+
}
|
|
513
|
+
case 'ArrowDown': {
|
|
514
|
+
e.preventDefault()
|
|
515
|
+
const m = minutesRef.current !== '' ? parseInt(minutesRef.current, 10) : 0
|
|
516
|
+
const newM = String((m - minuteStep + 60) % 60).padStart(2, '0')
|
|
517
|
+
setMinutes(newM)
|
|
518
|
+
if (h !== '') {
|
|
519
|
+
const v = `${h}:${newM}`
|
|
520
|
+
if (v !== effectiveValue) commitValue(v)
|
|
521
|
+
else changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
522
|
+
}
|
|
523
|
+
break
|
|
524
|
+
}
|
|
525
|
+
case 'ArrowLeft':
|
|
526
|
+
e.preventDefault()
|
|
527
|
+
hoursInputRef.current?.focus()
|
|
528
|
+
break
|
|
529
|
+
case 'Backspace':
|
|
530
|
+
case 'Delete': {
|
|
531
|
+
e.preventDefault()
|
|
532
|
+
minutesDigitCountRef.current = 0
|
|
533
|
+
minutesFirstDigitRef.current = -1
|
|
534
|
+
const cur = minutesRef.current
|
|
535
|
+
if (cur.length > 0) {
|
|
536
|
+
setMinutes(cur.slice(0, -1))
|
|
537
|
+
}
|
|
538
|
+
syncValueFromDisplayWithInput()
|
|
539
|
+
break
|
|
540
|
+
}
|
|
541
|
+
case 'Tab':
|
|
542
|
+
break
|
|
543
|
+
case 'Enter': {
|
|
544
|
+
e.preventDefault()
|
|
545
|
+
applyNativeConstraintAnchorMessage()
|
|
546
|
+
const form = input.form
|
|
547
|
+
if (form) form.requestSubmit()
|
|
548
|
+
else input.blur()
|
|
549
|
+
break
|
|
550
|
+
}
|
|
551
|
+
default:
|
|
552
|
+
if (/^\d$/.test(e.key)) {
|
|
553
|
+
e.preventDefault()
|
|
554
|
+
const digit = parseInt(e.key, 10)
|
|
555
|
+
if (minutesDigitCountRef.current === 0) {
|
|
556
|
+
minutesFirstDigitRef.current = digit
|
|
557
|
+
const newM = String(digit).padStart(2, '0')
|
|
558
|
+
setMinutes(newM)
|
|
559
|
+
minutesDigitCountRef.current = 1
|
|
560
|
+
changeInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
|
|
561
|
+
if (digit >= 6) {
|
|
562
|
+
minutesFirstDigitRef.current = -1
|
|
563
|
+
minutesDigitCountRef.current = 0
|
|
564
|
+
syncValueFromDisplayWithInput()
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
const combined = minutesFirstDigitRef.current * 10 + digit
|
|
568
|
+
const newM = combined <= 59 ? String(combined).padStart(2, '0') : String(digit).padStart(2, '0')
|
|
569
|
+
setMinutes(newM)
|
|
570
|
+
minutesFirstDigitRef.current = -1
|
|
571
|
+
minutesDigitCountRef.current = 0
|
|
572
|
+
syncValueFromDisplayWithInput()
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
e.preventDefault()
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
[
|
|
580
|
+
minuteStep,
|
|
581
|
+
effectiveValue,
|
|
582
|
+
commitValue,
|
|
583
|
+
setMinutes,
|
|
584
|
+
syncValueFromDisplayWithInput,
|
|
585
|
+
applyNativeConstraintAnchorMessage,
|
|
586
|
+
],
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
const handleMinutesBlur = useCallback(() => {
|
|
590
|
+
setTouched(true)
|
|
591
|
+
if (minutesRef.current !== '') {
|
|
592
|
+
const newM = String(parseInt(minutesRef.current, 10)).padStart(2, '0')
|
|
593
|
+
setMinutes(newM)
|
|
594
|
+
}
|
|
595
|
+
minutesDigitCountRef.current = 0
|
|
596
|
+
minutesFirstDigitRef.current = -1
|
|
597
|
+
syncValueFromDisplay()
|
|
598
|
+
}, [setMinutes, syncValueFromDisplay])
|
|
599
|
+
|
|
600
|
+
const handlePopupKeydown = useCallback(
|
|
601
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
602
|
+
const focused = document.activeElement as HTMLElement
|
|
603
|
+
const col = focused.closest('.pkt-timepicker-popup__col')
|
|
604
|
+
if (!col) return
|
|
605
|
+
const options = Array.from(col.querySelectorAll<HTMLElement>('.pkt-timepicker-popup__option'))
|
|
606
|
+
const currentIdx = options.indexOf(focused)
|
|
607
|
+
|
|
608
|
+
const focusOptionAndSync = (option: HTMLElement | undefined) => {
|
|
609
|
+
if (!option) return
|
|
610
|
+
const val = parseInt(option.dataset.value ?? '0', 10)
|
|
611
|
+
const padded = String(val).padStart(2, '0')
|
|
612
|
+
if (focused.dataset.type === 'hour') setHours(padded)
|
|
613
|
+
else setMinutes(padded)
|
|
614
|
+
option.focus()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
switch (e.key) {
|
|
618
|
+
case 'ArrowDown':
|
|
619
|
+
e.preventDefault()
|
|
620
|
+
focusOptionAndSync(options[Math.min(currentIdx + 1, options.length - 1)])
|
|
621
|
+
break
|
|
622
|
+
case 'ArrowUp':
|
|
623
|
+
e.preventDefault()
|
|
624
|
+
focusOptionAndSync(options[Math.max(currentIdx - 1, 0)])
|
|
625
|
+
break
|
|
626
|
+
case 'Home':
|
|
627
|
+
e.preventDefault()
|
|
628
|
+
focusOptionAndSync(options[0])
|
|
629
|
+
break
|
|
630
|
+
case 'End':
|
|
631
|
+
e.preventDefault()
|
|
632
|
+
focusOptionAndSync(options[options.length - 1])
|
|
633
|
+
break
|
|
634
|
+
case 'ArrowRight':
|
|
635
|
+
e.preventDefault()
|
|
636
|
+
if (focused.dataset.type === 'hour') {
|
|
637
|
+
const val = parseInt(focused.dataset.value ?? '0', 10)
|
|
638
|
+
setHours(String(val).padStart(2, '0'))
|
|
639
|
+
requestAnimationFrame(() => {
|
|
640
|
+
popupRef.current?.querySelectorAll('.pkt-timepicker-popup__col').forEach((c) => {
|
|
641
|
+
const sel = c.querySelector('.pkt-timepicker-popup__option--selected')
|
|
642
|
+
sel?.scrollIntoView({ block: 'center' })
|
|
643
|
+
})
|
|
644
|
+
focusSelectedOrFirst('minute')
|
|
645
|
+
})
|
|
646
|
+
}
|
|
647
|
+
break
|
|
648
|
+
case 'ArrowLeft':
|
|
649
|
+
e.preventDefault()
|
|
650
|
+
if (focused.dataset.type === 'minute') {
|
|
651
|
+
const val = parseInt(focused.dataset.value ?? '0', 10)
|
|
652
|
+
setMinutes(String(val).padStart(2, '0'))
|
|
653
|
+
requestAnimationFrame(() => {
|
|
654
|
+
popupRef.current?.querySelectorAll('.pkt-timepicker-popup__col').forEach((c) => {
|
|
655
|
+
const sel = c.querySelector('.pkt-timepicker-popup__option--selected')
|
|
656
|
+
sel?.scrollIntoView({ block: 'center' })
|
|
657
|
+
})
|
|
658
|
+
focusSelectedOrFirst('hour')
|
|
659
|
+
})
|
|
660
|
+
}
|
|
661
|
+
break
|
|
662
|
+
case 'Enter':
|
|
663
|
+
case ' ':
|
|
664
|
+
e.preventDefault()
|
|
665
|
+
focused.click()
|
|
666
|
+
break
|
|
667
|
+
case 'Escape':
|
|
668
|
+
e.preventDefault()
|
|
669
|
+
closePopup()
|
|
670
|
+
buttonRef.current?.focus()
|
|
671
|
+
break
|
|
672
|
+
default:
|
|
673
|
+
break
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
[closePopup, focusSelectedOrFirst, setHours, setMinutes],
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
const handlePopupFocusOut = useCallback(
|
|
680
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
681
|
+
const popup = popupRef.current
|
|
682
|
+
if (!popup) return
|
|
683
|
+
const related = e.relatedTarget as Node | null
|
|
684
|
+
if (!related || !popup.contains(related)) {
|
|
685
|
+
closePopup()
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
[closePopup],
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
const handleFocusIn = useCallback(
|
|
692
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
693
|
+
const root = containerRef.current
|
|
694
|
+
if (!root) return
|
|
695
|
+
const related = e.relatedTarget as Node | null
|
|
696
|
+
if (related && root.contains(related)) return
|
|
697
|
+
onFocusProp?.(e)
|
|
698
|
+
},
|
|
699
|
+
[onFocusProp],
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
const handleFocusOut = useCallback(
|
|
703
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
704
|
+
const root = containerRef.current
|
|
705
|
+
if (!root) return
|
|
706
|
+
const related = e.relatedTarget as Node | null
|
|
707
|
+
if (related && root.contains(related)) return
|
|
708
|
+
onBlurProp?.(e)
|
|
709
|
+
},
|
|
710
|
+
[onBlurProp],
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
const handleClockButtonClick = useCallback(() => {
|
|
714
|
+
if (disabled) return
|
|
715
|
+
if (isOpen) {
|
|
716
|
+
closePopup()
|
|
717
|
+
} else {
|
|
718
|
+
setIsOpen(true)
|
|
719
|
+
}
|
|
720
|
+
}, [disabled, isOpen, closePopup])
|
|
721
|
+
|
|
722
|
+
// Expose value getter/setter on ref for React Hook Form register() compatibility (same pattern as PktDatepicker).
|
|
723
|
+
useImperativeHandle(
|
|
724
|
+
ref,
|
|
725
|
+
() =>
|
|
726
|
+
({
|
|
727
|
+
get value() {
|
|
728
|
+
return changeInputRef.current?.value ?? ''
|
|
729
|
+
},
|
|
730
|
+
set value(newVal: string) {
|
|
731
|
+
const v = newVal ?? ''
|
|
732
|
+
if (!isControlled) {
|
|
733
|
+
setInternalValue(v)
|
|
734
|
+
}
|
|
735
|
+
const [h, m] = splitValid(v)
|
|
736
|
+
setHours(h)
|
|
737
|
+
setMinutes(m)
|
|
738
|
+
if (changeInputRef.current) {
|
|
739
|
+
changeInputRef.current.value = v
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
focus() {
|
|
743
|
+
hoursInputRef.current?.focus()
|
|
744
|
+
},
|
|
745
|
+
blur() {
|
|
746
|
+
hoursInputRef.current?.blur()
|
|
747
|
+
},
|
|
748
|
+
}) as unknown as HTMLDivElement,
|
|
749
|
+
[isControlled, setHours, setMinutes],
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
const root = containerRef.current
|
|
754
|
+
const form = root?.closest('form')
|
|
755
|
+
if (!form) return
|
|
756
|
+
|
|
757
|
+
const handleReset = () => {
|
|
758
|
+
window.setTimeout(() => {
|
|
759
|
+
const init = initialValuesRef.current
|
|
760
|
+
const [h, m] = splitValid(init)
|
|
761
|
+
setHours(h)
|
|
762
|
+
setMinutes(m)
|
|
763
|
+
if (!isControlled) {
|
|
764
|
+
setInternalValue(init)
|
|
765
|
+
}
|
|
766
|
+
if (changeInputRef.current) {
|
|
767
|
+
changeInputRef.current.value = init
|
|
768
|
+
}
|
|
769
|
+
setIsOpen(false)
|
|
770
|
+
setTouched(false)
|
|
771
|
+
}, 0)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
form.addEventListener('reset', handleReset)
|
|
775
|
+
return () => form.removeEventListener('reset', handleReset)
|
|
776
|
+
}, [isControlled, setHours, setMinutes])
|
|
777
|
+
|
|
778
|
+
const outerClasses = [
|
|
779
|
+
'pkt-timepicker',
|
|
780
|
+
fullwidth ? 'pkt-timepicker--fullwidth' : '',
|
|
781
|
+
stepArrows ? 'pkt-timepicker--stepper' : '',
|
|
782
|
+
className ?? '',
|
|
783
|
+
]
|
|
784
|
+
.filter(Boolean)
|
|
785
|
+
.join(' ')
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
id,
|
|
789
|
+
containerRef,
|
|
790
|
+
outerClasses,
|
|
791
|
+
strings,
|
|
792
|
+
label,
|
|
793
|
+
disabled,
|
|
794
|
+
required,
|
|
795
|
+
helptext,
|
|
796
|
+
helptextDropdown,
|
|
797
|
+
helptextDropdownButton,
|
|
798
|
+
hasError,
|
|
799
|
+
isInvalid,
|
|
800
|
+
errorMessage,
|
|
801
|
+
optionalTag,
|
|
802
|
+
optionalText,
|
|
803
|
+
requiredTag,
|
|
804
|
+
requiredText,
|
|
805
|
+
tagText,
|
|
806
|
+
inline,
|
|
807
|
+
useWrapper,
|
|
808
|
+
ariaDescribedby,
|
|
809
|
+
hidePicker,
|
|
810
|
+
stepArrows,
|
|
811
|
+
min,
|
|
812
|
+
max,
|
|
813
|
+
step,
|
|
814
|
+
hours,
|
|
815
|
+
minutes,
|
|
816
|
+
hoursId,
|
|
817
|
+
minutesId,
|
|
818
|
+
popupId,
|
|
819
|
+
hiddenInputId,
|
|
820
|
+
name: name ?? id,
|
|
821
|
+
isOpen,
|
|
822
|
+
hourOptions,
|
|
823
|
+
minuteOptions,
|
|
824
|
+
hoursInputRef,
|
|
825
|
+
minutesInputRef,
|
|
826
|
+
buttonRef,
|
|
827
|
+
popupRef,
|
|
828
|
+
changeInputRef,
|
|
829
|
+
handleHoursKeydown,
|
|
830
|
+
handleHoursBlur,
|
|
831
|
+
handleMinutesKeydown,
|
|
832
|
+
handleMinutesBlur,
|
|
833
|
+
handlePopupKeydown,
|
|
834
|
+
handlePopupFocusOut,
|
|
835
|
+
handleFocusIn,
|
|
836
|
+
handleFocusOut,
|
|
837
|
+
handleOptionClick,
|
|
838
|
+
handleClockButtonClick,
|
|
839
|
+
closePopup,
|
|
840
|
+
stepTimeDelta,
|
|
841
|
+
effectiveValue,
|
|
842
|
+
}
|
|
843
|
+
}
|