@oslokommune/punkt-react 16.7.7 → 16.8.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 +43 -0
- package/dist/index.d.ts +2 -0
- package/dist/punkt-react.es.js +1022 -990
- package/dist/punkt-react.umd.js +433 -433
- package/package.json +2 -2
- package/src/components/combobox/Combobox.tsx +1 -1
- package/src/components/datepicker/Datepicker.tsx +1 -1
- package/src/components/fileupload/DropZone.tsx +4 -1
- package/src/components/fileupload/FileUpload.test.tsx +98 -0
- package/src/components/fileupload/FileUpload.tsx +61 -4
- package/src/components/timepicker/Timepicker.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oslokommune/punkt-react",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.8.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",
|
|
@@ -109,5 +109,5 @@
|
|
|
109
109
|
"url": "https://github.com/oslokommune/punkt/issues"
|
|
110
110
|
},
|
|
111
111
|
"license": "MIT",
|
|
112
|
-
"gitHead": "
|
|
112
|
+
"gitHead": "01d75359139a8932b991c37ecaea46458e2b2d66"
|
|
113
113
|
}
|
|
@@ -25,7 +25,7 @@ export const PktCombobox = forwardRef<HTMLDivElement, IPktCombobox>((props, ref)
|
|
|
25
25
|
const wrapperForId = hasTextInput ? state.inputId : `${state.id}-combobox`
|
|
26
26
|
|
|
27
27
|
return (
|
|
28
|
-
<div className={outerClasses} ref={state.wrapperRef}>
|
|
28
|
+
<div className={outerClasses} ref={state.wrapperRef} id={state.id || undefined}>
|
|
29
29
|
<PktInputWrapper
|
|
30
30
|
forId={wrapperForId}
|
|
31
31
|
hasFieldset={!hasTextInput}
|
|
@@ -15,7 +15,7 @@ export const PktDatepicker = forwardRef<HTMLDivElement, IPktDatepicker>((props,
|
|
|
15
15
|
const outerClasses = ['pkt-datepicker', state.className].filter(Boolean).join(' ')
|
|
16
16
|
|
|
17
17
|
return (
|
|
18
|
-
<div className={outerClasses}>
|
|
18
|
+
<div className={outerClasses} id={state.id || undefined}>
|
|
19
19
|
<PktInputWrapper
|
|
20
20
|
ref={state.wrapperRef}
|
|
21
21
|
forId={state.inputId}
|
|
@@ -53,6 +53,8 @@ interface IDropZoneProps
|
|
|
53
53
|
uploaded: string
|
|
54
54
|
errors: string
|
|
55
55
|
}
|
|
56
|
+
/** Whether the file input already has an external visible label associated with it. */
|
|
57
|
+
hasVisibleLabel?: boolean
|
|
56
58
|
}
|
|
57
59
|
export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
|
|
58
60
|
(
|
|
@@ -67,6 +69,7 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
|
|
|
67
69
|
isThumbnailView = false,
|
|
68
70
|
disabled = false,
|
|
69
71
|
srAnnouncementIds,
|
|
72
|
+
hasVisibleLabel = false,
|
|
70
73
|
...inputProps
|
|
71
74
|
}: IDropZoneProps,
|
|
72
75
|
forwardedRef,
|
|
@@ -195,7 +198,7 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
|
|
|
195
198
|
accept={resolvedAccept}
|
|
196
199
|
disabled={disabled}
|
|
197
200
|
name={(uploadStrategy === 'form' && name) || undefined} // Ikke sett name hvis uploadStrategy er 'custom' - ignorerer ved POST
|
|
198
|
-
aria-label={multiple ? 'Velg filer' : 'Velg fil'}
|
|
201
|
+
aria-label={hasVisibleLabel ? undefined : multiple ? 'Velg filer' : 'Velg fil'}
|
|
199
202
|
/>
|
|
200
203
|
{uploadStrategy === 'custom' && (
|
|
201
204
|
<>
|
|
@@ -416,6 +416,74 @@ describe('PktFileUpload', () => {
|
|
|
416
416
|
})
|
|
417
417
|
|
|
418
418
|
describe('Accessibility', () => {
|
|
419
|
+
it('keeps label and input associations isolated across multiple instances without explicit ids', () => {
|
|
420
|
+
render(
|
|
421
|
+
<>
|
|
422
|
+
<PktFileUpload name="pktFileUploadA" label="Første vedlegg" helptext="Hjelpetekst A" />
|
|
423
|
+
<PktFileUpload name="pktFileUploadB" label="Andre vedlegg" helptext="Hjelpetekst B" />
|
|
424
|
+
</>,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const firstInput = screen.getByLabelText('Første vedlegg') as HTMLInputElement
|
|
428
|
+
const secondInput = screen.getByLabelText('Andre vedlegg') as HTMLInputElement
|
|
429
|
+
|
|
430
|
+
expect(firstInput.id).toBeTruthy()
|
|
431
|
+
expect(secondInput.id).toBeTruthy()
|
|
432
|
+
expect(firstInput.id).not.toBe(secondInput.id)
|
|
433
|
+
expect(firstInput).toHaveAttribute('aria-describedby', `${firstInput.id}-helptext`)
|
|
434
|
+
expect(secondInput).toHaveAttribute('aria-describedby', `${secondInput.id}-helptext`)
|
|
435
|
+
expect(document.getElementById(`${firstInput.id}-helptext`)).toHaveTextContent('Hjelpetekst A')
|
|
436
|
+
expect(document.getElementById(`${secondInput.id}-helptext`)).toHaveTextContent('Hjelpetekst B')
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('associates the label with the native file input and wires help text + error message ids', () => {
|
|
440
|
+
render(
|
|
441
|
+
<PktFileUpload
|
|
442
|
+
id="test-upload"
|
|
443
|
+
name="pktFileUpload"
|
|
444
|
+
label="Last opp vedlegg"
|
|
445
|
+
helptext="Last opp minst én fil."
|
|
446
|
+
onFileValidation={() => 'Ugyldig fil.'}
|
|
447
|
+
/>,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
const fileInput = screen.getByLabelText('Last opp vedlegg') as HTMLInputElement
|
|
451
|
+
const file = createMockFile('test.pdf')
|
|
452
|
+
|
|
453
|
+
fireEvent.change(fileInput, { target: { files: [file] } })
|
|
454
|
+
|
|
455
|
+
expect(fileInput).toHaveAttribute('id', 'test-upload')
|
|
456
|
+
expect(fileInput).toHaveAttribute('aria-invalid', 'true')
|
|
457
|
+
expect(fileInput).toHaveAttribute('aria-describedby', 'test-upload-helptext test-upload-error')
|
|
458
|
+
expect(screen.getByText('Ugyldig fil.')).toBeInTheDocument()
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('applies native required only for form strategy', () => {
|
|
462
|
+
const { rerender } = render(
|
|
463
|
+
<PktFileUpload id="required-upload" name="pktFileUpload" label="Vedlegg" required uploadStrategy="form" />,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
let fileInput = screen.getByLabelText('Vedlegg') as HTMLInputElement
|
|
467
|
+
expect(fileInput).toHaveAttribute('required')
|
|
468
|
+
expect(fileInput).toHaveAttribute('aria-required', 'true')
|
|
469
|
+
|
|
470
|
+
rerender(
|
|
471
|
+
<PktFileUpload
|
|
472
|
+
id="required-upload"
|
|
473
|
+
name="pktFileUpload"
|
|
474
|
+
label="Vedlegg"
|
|
475
|
+
required
|
|
476
|
+
uploadStrategy="custom"
|
|
477
|
+
onFileUploadRequested={vi.fn()}
|
|
478
|
+
transfers={[]}
|
|
479
|
+
/>,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
fileInput = screen.getByLabelText('Vedlegg') as HTMLInputElement
|
|
483
|
+
expect(fileInput).not.toHaveAttribute('required')
|
|
484
|
+
expect(fileInput).toHaveAttribute('aria-required', 'true')
|
|
485
|
+
})
|
|
486
|
+
|
|
419
487
|
it('should have no accessibility violations', async () => {
|
|
420
488
|
const { container } = render(<PktFileUpload multiple name={'pktFileUpload'} />)
|
|
421
489
|
// Note: The hidden file input doesn't have a label, but it's intentionally hidden
|
|
@@ -448,6 +516,36 @@ describe('PktFileUpload', () => {
|
|
|
448
516
|
})
|
|
449
517
|
|
|
450
518
|
describe('Form submission values', () => {
|
|
519
|
+
it('blocks custom strategy submit when required and no files are selected', () => {
|
|
520
|
+
const { container } = render(
|
|
521
|
+
<form>
|
|
522
|
+
<PktFileUpload
|
|
523
|
+
id="custom-required-upload"
|
|
524
|
+
name="pktFileUpload"
|
|
525
|
+
label="Last opp vedlegg"
|
|
526
|
+
required
|
|
527
|
+
uploadStrategy="custom"
|
|
528
|
+
onFileUploadRequested={vi.fn()}
|
|
529
|
+
transfers={[]}
|
|
530
|
+
/>
|
|
531
|
+
</form>,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
535
|
+
let submitWasPrevented = false
|
|
536
|
+
form.addEventListener('submit', (event) => {
|
|
537
|
+
submitWasPrevented = event.defaultPrevented
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
fireEvent.submit(form)
|
|
541
|
+
|
|
542
|
+
const fileInput = screen.getByLabelText('Last opp vedlegg') as HTMLInputElement
|
|
543
|
+
expect(submitWasPrevented).toBe(true)
|
|
544
|
+
expect(fileInput).toHaveAttribute('aria-invalid', 'true')
|
|
545
|
+
expect(fileInput).toHaveAttribute('aria-describedby', 'custom-required-upload-error')
|
|
546
|
+
expect(screen.getByText('Velg minst én fil før du sender inn skjemaet.')).toBeInTheDocument()
|
|
547
|
+
})
|
|
548
|
+
|
|
451
549
|
it('should populate file input with selected files for form submission', () => {
|
|
452
550
|
const TestFormComponent = () => {
|
|
453
551
|
const [files, setFiles] = useState<TFileItemList>([])
|
|
@@ -12,7 +12,20 @@ Prinsipper:
|
|
|
12
12
|
- Støtte for tilpassede renderer-komponenter for visning av filer i køen.
|
|
13
13
|
*/
|
|
14
14
|
import classNames from 'classnames'
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
FC,
|
|
17
|
+
forwardRef,
|
|
18
|
+
HTMLAttributes,
|
|
19
|
+
InputHTMLAttributes,
|
|
20
|
+
ReactNode,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useId,
|
|
24
|
+
useImperativeHandle,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from 'react'
|
|
16
29
|
|
|
17
30
|
import { PktAlert } from '../alert/Alert'
|
|
18
31
|
import { PktInputWrapper } from '../inputwrapper/InputWrapper'
|
|
@@ -102,6 +115,8 @@ interface IBaseFileUploadProps
|
|
|
102
115
|
optionalTag?: boolean
|
|
103
116
|
/** Show "Må fylles ut" tag in wrapper. */
|
|
104
117
|
requiredTag?: boolean
|
|
118
|
+
/** Marks the upload as required. Native validation is used in `form`; submit validation in `custom`. */
|
|
119
|
+
required?: boolean
|
|
105
120
|
/** Enable image preview modal (only applies to thumbnail renderer). */
|
|
106
121
|
enableImagePreview?: boolean
|
|
107
122
|
}
|
|
@@ -136,7 +151,7 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
|
|
|
136
151
|
{
|
|
137
152
|
value: valueProp,
|
|
138
153
|
defaultValue,
|
|
139
|
-
id
|
|
154
|
+
id: idProp,
|
|
140
155
|
multiple = false,
|
|
141
156
|
uploadStrategy = 'form',
|
|
142
157
|
addCommentsEnabled = false,
|
|
@@ -159,17 +174,29 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
|
|
|
159
174
|
helptext,
|
|
160
175
|
optionalTag,
|
|
161
176
|
requiredTag,
|
|
177
|
+
required = false,
|
|
162
178
|
enableImagePreview = false,
|
|
179
|
+
['aria-describedby']: ariaDescribedByProp,
|
|
180
|
+
['aria-invalid']: ariaInvalidProp,
|
|
163
181
|
...props
|
|
164
182
|
}: IPktFileUpload,
|
|
165
183
|
forwardedRef,
|
|
166
184
|
) => {
|
|
185
|
+
const generatedId = useId()
|
|
186
|
+
const id = idProp ?? `pkt-fileupload-${generatedId}`
|
|
187
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
188
|
+
useImperativeHandle(forwardedRef, () => fileInputRef.current as HTMLInputElement, [])
|
|
189
|
+
|
|
167
190
|
const transfers = 'transfers' in props ? props.transfers : undefined
|
|
168
191
|
const [validationError, setValidationError] = useState<string | null>(null)
|
|
169
192
|
|
|
170
193
|
// Combine external error with internal validation error
|
|
171
194
|
const hasError = externalHasError || !!validationError
|
|
172
195
|
const errorMessage = externalErrorMessage || validationError
|
|
196
|
+
const errorMessageId = errorMessage ? `${id}-error` : undefined
|
|
197
|
+
const helptextId = helptext ? `${id}-helptext` : undefined
|
|
198
|
+
const describedBy = [ariaDescribedByProp, helptextId, errorMessageId].filter(Boolean).join(' ') || undefined
|
|
199
|
+
const isAriaInvalid = hasError || ariaInvalidProp === true || ariaInvalidProp === 'true'
|
|
173
200
|
|
|
174
201
|
// Parse maxFileSize once (supports "5MB" strings or raw bytes)
|
|
175
202
|
const maxFileSizeBytes = maxFileSize ? parseFileSize(maxFileSize) : undefined
|
|
@@ -238,6 +265,24 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
|
|
|
238
265
|
const isControlled = valueProp !== undefined
|
|
239
266
|
const value = isControlled ? valueProp : defaultValue || []
|
|
240
267
|
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (uploadStrategy !== 'custom' || !required) return
|
|
270
|
+
const input = fileInputRef.current
|
|
271
|
+
const form = input?.form
|
|
272
|
+
if (!form) return
|
|
273
|
+
|
|
274
|
+
const handleFormSubmit = (event: SubmitEvent) => {
|
|
275
|
+
if (value.length > 0) return
|
|
276
|
+
event.preventDefault()
|
|
277
|
+
setValidationError('Velg minst én fil før du sender inn skjemaet.')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
form.addEventListener('submit', handleFormSubmit)
|
|
281
|
+
return () => {
|
|
282
|
+
form.removeEventListener('submit', handleFormSubmit)
|
|
283
|
+
}
|
|
284
|
+
}, [required, uploadStrategy, value.length])
|
|
285
|
+
|
|
241
286
|
const itemRenderer = typeof itemRendererProp === 'string' ? ItemRenderers[itemRendererProp] : itemRendererProp
|
|
242
287
|
const isThumbnailView = itemRendererProp === 'thumbnail'
|
|
243
288
|
const explicitAccept = typeof props.accept === 'string' ? props.accept.trim() : undefined
|
|
@@ -389,14 +434,26 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
|
|
|
389
434
|
onFilesAdded={onFilesAdded}
|
|
390
435
|
multiple={multiple}
|
|
391
436
|
uploadStrategy={uploadStrategy}
|
|
392
|
-
ref={
|
|
437
|
+
ref={fileInputRef}
|
|
393
438
|
accept={isThumbnailView ? '.jpeg, .jpg, .png, .gif, .webp, .heic' : resolvedAcceptForDropZone}
|
|
394
439
|
isThumbnailView={isThumbnailView}
|
|
395
440
|
disabled={disabled}
|
|
396
441
|
srAnnouncementIds={srAnnouncementIds}
|
|
442
|
+
required={required && uploadStrategy === 'form'}
|
|
443
|
+
aria-required={required ? 'true' : undefined}
|
|
444
|
+
aria-invalid={isAriaInvalid ? 'true' : undefined}
|
|
445
|
+
aria-describedby={describedBy}
|
|
446
|
+
hasVisibleLabel={!!label}
|
|
397
447
|
/>
|
|
398
448
|
{hasError && errorMessage && (
|
|
399
|
-
<PktAlert
|
|
449
|
+
<PktAlert
|
|
450
|
+
skin="error"
|
|
451
|
+
aria-live="assertive"
|
|
452
|
+
role="alert"
|
|
453
|
+
compact
|
|
454
|
+
id={errorMessageId}
|
|
455
|
+
className="pkt-fileupload__error-alert"
|
|
456
|
+
>
|
|
400
457
|
{errorMessage}
|
|
401
458
|
</PktAlert>
|
|
402
459
|
)}
|
|
@@ -214,6 +214,7 @@ export const PktTimepicker = forwardRef<HTMLDivElement, IPktTimepicker>((props,
|
|
|
214
214
|
<div
|
|
215
215
|
ref={state.containerRef}
|
|
216
216
|
className={state.outerClasses}
|
|
217
|
+
id={state.id || undefined}
|
|
217
218
|
onFocus={state.handleFocusIn}
|
|
218
219
|
onBlur={state.handleFocusOut}
|
|
219
220
|
>
|