@oslokommune/punkt-react 16.7.6 → 16.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "16.7.6",
3
+ "version": "16.8.0",
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.7.4",
42
+ "@oslokommune/punkt-elements": "^16.7.7",
43
43
  "classnames": "^2.5.1",
44
44
  "prettier": "^3.3.3",
45
45
  "react-hook-form": "^7.53.0"
@@ -109,5 +109,5 @@
109
109
  "url": "https://github.com/oslokommune/punkt/issues"
110
110
  },
111
111
  "license": "MIT",
112
- "gitHead": "208f11c3292166c3bbb6a40a1a1635a23e870b78"
112
+ "gitHead": "3e4333b4aec70ec35b49703dc70b5dd9f26ab384"
113
113
  }
@@ -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 { FC, forwardRef, HTMLAttributes, InputHTMLAttributes, ReactNode, useCallback, useMemo, useState } from 'react'
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 = 'fileupload-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={forwardedRef}
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 skin="error" aria-live="assertive" role="alert" compact>
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
  )}