@oslokommune/punkt-react 16.14.0 → 16.15.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/{dialog-polyfill.esm-1hPr0gl9-EorOF9Wq.js → dialog-polyfill.esm-BmVHGRTX-CuhXqEqJ.js} +1 -1
  3. package/dist/index.d.ts +50 -47
  4. package/dist/punkt-react.es.js +4482 -3014
  5. package/dist/punkt-react.umd.js +471 -76
  6. package/dist/shared-types/fileupload.d.ts +73 -0
  7. package/dist/shared-types/index.d.ts +2 -0
  8. package/dist/shared-utils/fileupload/announcements.d.ts +11 -0
  9. package/dist/shared-utils/fileupload/filename.d.ts +13 -0
  10. package/dist/shared-utils/fileupload/focus-trap.d.ts +12 -0
  11. package/dist/shared-utils/fileupload/formats.d.ts +30 -0
  12. package/dist/shared-utils/fileupload/index.d.ts +15 -0
  13. package/dist/shared-utils/fileupload/navigation.d.ts +5 -0
  14. package/dist/shared-utils/fileupload/size.d.ts +12 -0
  15. package/dist/shared-utils/fileupload/transfers.d.ts +17 -0
  16. package/dist/shared-utils/fileupload/truncate.d.ts +13 -0
  17. package/dist/shared-utils/fileupload/validation.d.ts +24 -0
  18. package/package.json +4 -4
  19. package/src/components/fileupload/DropZone.tsx +35 -22
  20. package/src/components/fileupload/FileUpload.test.tsx +165 -10
  21. package/src/components/fileupload/FileUpload.tsx +116 -184
  22. package/src/components/fileupload/QueueDisplay.test.tsx +51 -8
  23. package/src/components/fileupload/QueueDisplay.tsx +71 -99
  24. package/src/components/fileupload/QueueItemContent.tsx +120 -135
  25. package/src/components/fileupload/Subcomponents.tsx +175 -144
  26. package/src/components/fileupload/Truncate.test.tsx +65 -207
  27. package/src/components/fileupload/Truncate.tsx +24 -120
  28. package/src/components/fileupload/extensions/Comments.tsx +94 -106
  29. package/src/components/fileupload/extensions/Remove.tsx +3 -5
  30. package/src/components/fileupload/extensions/Rename.tsx +45 -42
  31. package/src/components/fileupload/hooks/index.ts +1 -0
  32. package/src/components/fileupload/hooks/useFileAttributes.ts +37 -29
  33. package/src/components/fileupload/hooks/useImagePreview.ts +3 -7
  34. package/src/components/fileupload/hooks/useOperationState.ts +29 -8
  35. package/src/components/fileupload/hooks/useRequiredFormValidation.ts +43 -0
  36. package/src/components/fileupload/hooks.ts +1 -0
  37. package/src/components/fileupload/types.ts +49 -32
  38. package/src/components/fileupload/utils.ts +3 -54
  39. package/src/components/modal/Modal.tsx +27 -15
  40. package/src/components/types.ts +1 -1
  41. package/src/components/fileupload/texts.ts +0 -20
@@ -0,0 +1,73 @@
1
+ /**
2
+ * fileupload.ts
3
+ *
4
+ * Type definitions for the fileupload component.
5
+ * Used by both Elements and React implementations.
6
+ */
7
+ export type TUploadStrategy = 'form' | 'custom';
8
+ export type TFileUploadItemRenderer = 'filename' | 'thumbnail';
9
+ export type TFilesChangedReason = 'add' | 'remove' | 'update';
10
+ export type TFileValidator = (file: File) => string | null;
11
+ export type TTransferProgress = number | 'done' | 'error' | 'canceled' | 'queued';
12
+ /** A selected file plus metadata used by the FileUpload UI. */
13
+ export interface IFileItem {
14
+ fileId: string;
15
+ file?: File;
16
+ attributes: {
17
+ targetFilename: string;
18
+ } & Record<string, unknown>;
19
+ }
20
+ /** Transfer status for a file when using `uploadStrategy="custom"`. */
21
+ export interface IFileTransfer {
22
+ fileId: string;
23
+ progress: TTransferProgress;
24
+ errorMessage?: string;
25
+ showProgress?: boolean;
26
+ lastProgress?: number;
27
+ }
28
+ /** Detail payload for the `file-validate` event (Lit) / callback equivalent (React). */
29
+ export interface IFileValidateDetail {
30
+ file: File;
31
+ errorMessage: string | null;
32
+ }
33
+ /** Detail payload for `transfer-cancelled` event / `onTransferCancelled` callback. */
34
+ export interface ITransferCancelledDetail {
35
+ fileId: string;
36
+ file?: File;
37
+ attributes: {
38
+ targetFilename: string;
39
+ } & Record<string, unknown>;
40
+ }
41
+ /** Detail payload for the `files-changed` event. */
42
+ export interface IFilesChangedDetail {
43
+ files: IFileItem[];
44
+ reason: TFilesChangedReason;
45
+ changedFileIds?: string[];
46
+ }
47
+ /** Default Norwegian Bokmål strings used by both implementations. */
48
+ export interface IFileUploadStrings {
49
+ dropZoneDragMultiple: string;
50
+ dropZoneDragSingle: string;
51
+ dropZoneDragMultipleThumbnail: string;
52
+ dropZoneDragSingleThumbnail: string;
53
+ dropZoneDragActiveMultiple: string;
54
+ dropZoneDragActiveSingle: string;
55
+ dropZoneDragActiveMultipleThumbnail: string;
56
+ dropZoneDragActiveSingleThumbnail: string;
57
+ dropZoneOpenFileDialogMultiple: string;
58
+ dropZoneOpenFileDialogSingle: string;
59
+ dropZoneOpenFileDialogMultipleThumbnail: string;
60
+ dropZoneOpenFileDialogSingleThumbnail: string;
61
+ supportedFormatsPrefix: string;
62
+ invalidFormatDefault: (formats: string) => string;
63
+ sizeTooLargeDefault: (maxSize: string) => string;
64
+ requiredMissing: string;
65
+ genericValidationRejection: string;
66
+ unknownFilename: string;
67
+ fileLabel: (count: number) => string;
68
+ srFileAdded: (filename: string) => string;
69
+ srFilesAdded: (count: number) => string;
70
+ srFilesUploadedOfTotal: (uploaded: number, total: number, label: string) => string;
71
+ srFilesFailedOfTotal: (failed: number, total: number, label: string) => string;
72
+ }
73
+ export declare const defaultFileUploadStrings: IFileUploadStrings;
@@ -15,3 +15,5 @@ export type { ITimepickerStrings } from './timepicker';
15
15
  export { defaultTimepickerStrings } from './timepicker';
16
16
  export type { TProgressbarRole, TProgressbarSkin, TProgressbarStatusPlacement, TProgressbarStatusType, TProgressbarTitlePosition, } from './progressbar';
17
17
  export type { IPktComboboxOption, TPktComboboxTagSkin, TPktComboboxDisplayValue, TPktComboboxTagPlacement, } from './combobox';
18
+ export type { TUploadStrategy as TFileUploadStrategy, TFileUploadItemRenderer, TFilesChangedReason, TFileValidator, TTransferProgress, IFileItem, IFileTransfer, IFileValidateDetail, ITransferCancelledDetail, IFilesChangedDetail, IFileUploadStrings, } from './fileupload';
19
+ export { defaultFileUploadStrings } from './fileupload';
@@ -0,0 +1,11 @@
1
+ import type { IFileItem, IFileTransfer } from '../../shared-types';
2
+ export interface IFileUploadCounts {
3
+ totalCount: number;
4
+ uploadedCount: number;
5
+ failedCount: number;
6
+ }
7
+ /**
8
+ * Count how many files have reached `done` vs `error` based on the supplied
9
+ * transfer list. Pure — both runtimes share this for their aria-live summaries.
10
+ */
11
+ export declare function countFileTransferStates(files: IFileItem[], transfers: IFileTransfer[] | undefined): IFileUploadCounts;
@@ -0,0 +1,13 @@
1
+ /** MIME / accept fallback for the thumbnail view (image-only upload mode). */
2
+ export declare const THUMBNAIL_ACCEPT = ".jpeg, .jpg, .png, .gif, .webp, .heic";
3
+ /**
4
+ * Resolve the display name for a file item, preferring the (possibly renamed)
5
+ * `attributes.targetFilename`, falling back to the underlying File, and finally
6
+ * a localized "unknown file" label.
7
+ */
8
+ export declare function getDisplayFilename(item: {
9
+ file?: File | null;
10
+ attributes?: {
11
+ targetFilename?: string;
12
+ };
13
+ }, fallback?: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Build a list of focusable elements inside a container (same selector used by
3
+ * Lit and React fileupload preview modals). Hidden elements are filtered out.
4
+ */
5
+ export declare function getFocusableElements(container: HTMLElement | null | undefined): HTMLElement[];
6
+ /**
7
+ * Keyboard handler that wraps Tab / Shift-Tab focus around the first and last
8
+ * focusable elements inside a container. Returns `true` if the event was handled.
9
+ *
10
+ * Call from a `keydown` listener on the modal root.
11
+ */
12
+ export declare function trapTabInside(event: KeyboardEvent, container: HTMLElement | null | undefined): boolean;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Normalize an `allowedFormats` value (array or undefined) to lowercase, trimmed, non-empty tokens.
3
+ */
4
+ export declare function normalizeAllowedFormats(formats: string[] | undefined | null): string[];
5
+ /**
6
+ * Parse a native `accept` attribute value (comma-separated) into normalized tokens.
7
+ */
8
+ export declare function parseAcceptTokens(accept: string | undefined | null): string[];
9
+ /**
10
+ * Return `true` when a file matches one of the normalized allowed-format tokens.
11
+ * Supports plain extensions (`'pdf'`, `'.pdf'`), exact MIME types (`'application/pdf'`),
12
+ * and wildcards (`'image/*'`).
13
+ */
14
+ export declare function matchesAllowedFormat(token: string, fileExtension: string, fileMimeType: string): boolean;
15
+ /**
16
+ * Check whether a `File` matches any of the allowed formats.
17
+ * An empty list is treated as "accept everything".
18
+ */
19
+ export declare function isFileAllowed(file: File, allowedFormats: string[]): boolean;
20
+ /**
21
+ * Build a `accept=""` string from allowed formats, falling back to an explicit accept when provided.
22
+ */
23
+ export declare function resolveAcceptAttribute(explicitAccept: string | undefined, allowedFormats: string[] | undefined): string;
24
+ /** Human-readable token for the "Format: …" help line. */
25
+ export declare function formatTokenForDisplay(token: string): string;
26
+ /**
27
+ * Produce the "Format: PDF, JPG, …" list used under the drop zone.
28
+ * Falls back to `accept` tokens when `allowedFormats` is empty.
29
+ */
30
+ export declare function getSupportedFormatsText(allowedFormats: string[] | undefined, accept: string | undefined): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared fileupload utilities for use by both Elements and React packages.
3
+ */
4
+ export { parseFileSize, formatFileSize } from './size';
5
+ export { normalizeAllowedFormats, parseAcceptTokens, matchesAllowedFormat, isFileAllowed, resolveAcceptAttribute, formatTokenForDisplay, getSupportedFormatsText, } from './formats';
6
+ export { validateFile, applyErrorTemplate, isImageFileLike } from './validation';
7
+ export type { IValidateFileOptions } from './validation';
8
+ export { getProgressState, mergeFilesAndTransfers } from './transfers';
9
+ export type { IFileAndTransfer, TProgressState } from './transfers';
10
+ export { getFocusableElements, trapTabInside } from './focus-trap';
11
+ export { splitFilenameForTruncation } from './truncate';
12
+ export { navigateCyclic } from './navigation';
13
+ export { countFileTransferStates } from './announcements';
14
+ export type { IFileUploadCounts } from './announcements';
15
+ export { getDisplayFilename, THUMBNAIL_ACCEPT } from './filename';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Step an index forward or backward with wrap-around. Used by the image
3
+ * preview modal in both runtimes. Returns `0` for an empty list.
4
+ */
5
+ export declare function navigateCyclic(currentIndex: number, direction: 'prev' | 'next', length: number): number;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Parse a file-size string like `"5MB"`, `"500KB"`, `"1GB"`, or a raw byte number.
3
+ * Returns `undefined` for invalid input and logs a warning.
4
+ *
5
+ * @example
6
+ * parseFileSize('5MB') // 5_242_880
7
+ * parseFileSize('500KB') // 512_000
8
+ * parseFileSize(1024) // 1024
9
+ */
10
+ export declare function parseFileSize(size: string | number | undefined | null): number | undefined;
11
+ /** Format bytes to a compact human-readable string (e.g. `"500 KB"`, `"5 MB"`). */
12
+ export declare function formatFileSize(bytes: number): string;
@@ -0,0 +1,17 @@
1
+ import type { IFileItem, IFileTransfer, TFileUploadStrategy, TTransferProgress } from '../../shared-types';
2
+ export type TProgressState = 'in-progress' | 'error' | 'idle';
3
+ /** Bucket a raw progress value into a sort-priority class. */
4
+ export declare function getProgressState(progress: TTransferProgress): TProgressState;
5
+ export interface IFileAndTransfer extends IFileItem {
6
+ progress: TTransferProgress;
7
+ errorMessage?: string;
8
+ showProgress?: boolean;
9
+ lastProgress?: number;
10
+ }
11
+ /**
12
+ * Merge a `FileItem[]` list with a `TFileTransfer[]` list and sort by priority
13
+ * (in-progress first, then errors, then done/queued).
14
+ *
15
+ * Used by both Lit (`getFilesAndTransfers`) and React (`useFilesAndTransfers`).
16
+ */
17
+ export declare function mergeFilesAndTransfers(files: IFileItem[], transfers: IFileTransfer[] | undefined, uploadStrategy: TFileUploadStrategy): IFileAndTransfer[];
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Decide how to split a filename for middle-truncation (head + fixed-length tail,
3
+ * so the extension stays visible).
4
+ *
5
+ * Returns `null` when no split should happen — either the caller didn't ask for
6
+ * a tail, or the filename is so short that splitting would produce visual noise
7
+ * (we use a threshold of `tail + 3` characters, same as the React Truncate
8
+ * component has used since it was introduced).
9
+ */
10
+ export declare function splitFilenameForTruncation(filename: string, tail: number | undefined): {
11
+ head: string;
12
+ tail: string;
13
+ } | null;
@@ -0,0 +1,24 @@
1
+ import type { TFileValidator } from '../../shared-types';
2
+ export interface IValidateFileOptions {
3
+ allowedFormats?: string[];
4
+ maxFileSize?: string | number;
5
+ formatErrorMessage?: string;
6
+ sizeErrorMessage?: string;
7
+ onFileValidation?: TFileValidator;
8
+ }
9
+ /** Apply `{formats}` / `{maxSize}` placeholders to a user-supplied message, falling back to the default. */
10
+ export declare function applyErrorTemplate(template: string | undefined | null, fallback: string, placeholders: Record<string, string>): string;
11
+ /**
12
+ * Run the shared validation pipeline (format → size → user callback).
13
+ * Returns the first error string, or `null` if the file is accepted.
14
+ *
15
+ * The `onFileValidation` callback is called last and can veto an otherwise-valid file.
16
+ */
17
+ export declare function validateFile(file: File, options: IValidateFileOptions): string | null;
18
+ /** Detect whether a file (or an item with `.file` and `.attributes.targetFilename`) is an image. */
19
+ export declare function isImageFileLike(input: {
20
+ file?: File | null;
21
+ attributes?: {
22
+ targetFilename?: string;
23
+ };
24
+ }): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oslokommune/punkt-react",
3
- "version": "16.14.0",
3
+ "version": "16.15.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.14.0",
42
+ "@oslokommune/punkt-elements": "^16.15.0",
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.13.2",
53
- "@oslokommune/punkt-css": "^16.14.0",
53
+ "@oslokommune/punkt-css": "^16.15.0",
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": "af9088d3c57930488829eb4e6384dbfb9aceac32"
112
+ "gitHead": "5147b31531bab9ff64963a1f091a717bb93d35cf"
113
113
  }
@@ -12,12 +12,11 @@ import {
12
12
  useState,
13
13
  } from 'react'
14
14
 
15
+ import { defaultFileUploadStrings } from 'shared-types'
16
+
15
17
  import { PktIcon } from '..'
16
- import { uiMultipleTexts, uiSingleTexts, uiThumbnailMultipleTexts, uiThumbnailSingleTexts } from './texts'
17
18
  import { FileItem, TFileItemList, TUploadStrategy } from './types'
18
19
 
19
- const DEFAULT_FORMATS_HELP_TEXT = '.PDF, .JPEG, .JPG, .PNG, .HEIC, .DOC, .DOCX, .ODT'
20
-
21
20
  /**
22
21
  * Props for the internal `DropZone` building block.
23
22
  *
@@ -48,11 +47,6 @@ interface IDropZoneProps
48
47
  isThumbnailView?: boolean
49
48
  /** Disables all interaction. */
50
49
  disabled?: boolean
51
- /** IDs for screen reader announcements (uploaded/errors). */
52
- srAnnouncementIds?: {
53
- uploaded: string
54
- errors: string
55
- }
56
50
  /** Whether the file input already has an external visible label associated with it. */
57
51
  hasVisibleLabel?: boolean
58
52
  }
@@ -68,7 +62,6 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
68
62
  accept,
69
63
  isThumbnailView = false,
70
64
  disabled = false,
71
- srAnnouncementIds,
72
65
  hasVisibleLabel = false,
73
66
  ...inputProps
74
67
  }: IDropZoneProps,
@@ -86,7 +79,7 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
86
79
  .filter(Boolean)
87
80
  .join(', ')
88
81
  .toUpperCase()
89
- : DEFAULT_FORMATS_HELP_TEXT,
82
+ : '',
90
83
  [resolvedAccept],
91
84
  )
92
85
 
@@ -94,11 +87,31 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
94
87
 
95
88
  const [isDragActive, setIsDragActive] = useState(false)
96
89
 
97
- const uiTexts: typeof uiMultipleTexts = useMemo(() => {
90
+ const uiTexts = useMemo(() => {
98
91
  if (isThumbnailView) {
99
- return multiple ? uiThumbnailMultipleTexts : uiThumbnailSingleTexts
92
+ return multiple
93
+ ? {
94
+ dragInactive: defaultFileUploadStrings.dropZoneDragMultipleThumbnail,
95
+ dragActive: defaultFileUploadStrings.dropZoneDragActiveMultipleThumbnail,
96
+ openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogMultipleThumbnail,
97
+ }
98
+ : {
99
+ dragInactive: defaultFileUploadStrings.dropZoneDragSingleThumbnail,
100
+ dragActive: defaultFileUploadStrings.dropZoneDragActiveSingleThumbnail,
101
+ openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogSingleThumbnail,
102
+ }
100
103
  }
101
- return multiple ? uiMultipleTexts : uiSingleTexts
104
+ return multiple
105
+ ? {
106
+ dragInactive: defaultFileUploadStrings.dropZoneDragMultiple,
107
+ dragActive: defaultFileUploadStrings.dropZoneDragActiveMultiple,
108
+ openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogMultiple,
109
+ }
110
+ : {
111
+ dragInactive: defaultFileUploadStrings.dropZoneDragSingle,
112
+ dragActive: defaultFileUploadStrings.dropZoneDragActiveSingle,
113
+ openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogSingle,
114
+ }
102
115
  }, [multiple, isThumbnailView])
103
116
 
104
117
  const populateNativeFileInput = useCallback(
@@ -133,11 +146,7 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
133
146
  const selectedFiles = (event.target as HTMLInputElement).files!
134
147
  const userCancelledFileSelectionDialog = selectedFiles.length === 0
135
148
  if (userCancelledFileSelectionDialog) {
136
- if (multiple) {
137
- populateNativeFileInput(value)
138
- } else {
139
- // onFilesChanged([]) // TODO: Nullstill ved avbryt i enkel opplasting?
140
- }
149
+ populateNativeFileInput(value)
141
150
  return
142
151
  }
143
152
  filesAdded(Array.from(selectedFiles))
@@ -211,10 +220,10 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
211
220
  <PktIcon name={'attachment'} className="pkt-fileupload__drop-zone__placeholder__icon" aria-hidden="true" />
212
221
  <p className={'pkt-fileupload__drop-zone__placeholder__title'}>
213
222
  {isDragActive ? (
214
- `${uiTexts.dropFilesHere} ...`
223
+ `${uiTexts.dragActive} ...`
215
224
  ) : (
216
225
  <>
217
- {uiTexts.selectOrDragFiles}{' '}
226
+ {uiTexts.dragInactive}{' '}
218
227
  <button
219
228
  className="pkt-fileupload__drop-zone__placeholder__title__open-file-dialog"
220
229
  onClick={(e) => {
@@ -224,12 +233,16 @@ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
224
233
  }}
225
234
  type="button"
226
235
  >
227
- {uiTexts.chooseFiles}
236
+ {uiTexts.openFileDialog}
228
237
  </button>
229
238
  </>
230
239
  )}
231
240
  </p>
232
- <p className={'pkt-fileupload__drop-zone__placeholder__formats'}>Format: {acceptedFormatsReadableString}</p>
241
+ {acceptedFormatsReadableString ? (
242
+ <p className={'pkt-fileupload__drop-zone__placeholder__formats'}>
243
+ {defaultFileUploadStrings.supportedFormatsPrefix} {acceptedFormatsReadableString}
244
+ </p>
245
+ ) : null}
233
246
  </div>
234
247
  </div>
235
248
  )
@@ -69,13 +69,21 @@ if (!global.ResizeObserver) {
69
69
 
70
70
  const NOOP = () => {}
71
71
 
72
- const getVisibleFilenameNode = (filename: string) =>
73
- screen.queryByText(
74
- (content, element) => content === filename && element?.getAttribute('data-pkt-truncate-part') === 'first',
75
- )
72
+ const reconstructFilenamesFromTitles = (): string[] => {
73
+ const titles = document.querySelectorAll('.pkt-fileupload__queue-display__item__title')
74
+ return Array.from(titles).map((title) => {
75
+ const head = title.querySelector('[data-pkt-truncate-part="first"]')?.textContent ?? ''
76
+ const tail = title.querySelector('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
77
+ return head + tail
78
+ })
79
+ }
76
80
 
77
81
  const expectVisibleFilename = (filename: string) => {
78
- expect(getVisibleFilenameNode(filename)).toBeInTheDocument()
82
+ expect(reconstructFilenamesFromTitles()).toContain(filename)
83
+ }
84
+
85
+ const expectNoVisibleFilename = (filename: string) => {
86
+ expect(reconstructFilenamesFromTitles()).not.toContain(filename)
79
87
  }
80
88
 
81
89
  const makeFilesPropWritable = (fileInput: HTMLInputElement) => {
@@ -102,18 +110,18 @@ function createFileItem(name: string, fileId?: string) {
102
110
 
103
111
  describe('PktFileUpload', () => {
104
112
  describe('Rendering', () => {
105
- it('should render the drop zone with default placeholder text for multiple files', () => {
113
+ it('should render the drop zone without format hint by default for multiple files', () => {
106
114
  render(<PktFileUpload multiple name={'pktFileUpload'} />)
107
115
 
108
116
  expect(screen.getByText(/Dra filer hit for å laste dem opp eller/)).toBeInTheDocument()
109
117
  expect(screen.getByText('velg filer')).toBeInTheDocument()
110
- expect(screen.getByText(/Format: .PDF, .JPEG, .JPG, .PNG, .HEIC, .DOC, .DOCX, .ODT/)).toBeInTheDocument()
118
+ expect(screen.queryByText(/^Format:/)).not.toBeInTheDocument()
111
119
  })
112
120
 
113
121
  it('should render the drop zone with single file text when multiple is false', () => {
114
122
  render(<PktFileUpload name={'pktFileUpload'} />)
115
123
 
116
- expect(screen.getByText(/Dra en fil for å laste den opp eller/)).toBeInTheDocument()
124
+ expect(screen.getByText(/Dra en fil hit for å laste den opp eller/)).toBeInTheDocument()
117
125
  expect(screen.getByText('velg en fil')).toBeInTheDocument()
118
126
  })
119
127
 
@@ -372,7 +380,7 @@ describe('PktFileUpload', () => {
372
380
 
373
381
  rerender(<PktFileUpload value={updatedValue} name={'pktFileUpload'} onFilesChanged={NOOP} />)
374
382
 
375
- expect(getVisibleFilenameNode('file1.pdf')).not.toBeInTheDocument()
383
+ expectNoVisibleFilename('file1.pdf')
376
384
  expectVisibleFilename('file2.pdf')
377
385
  })
378
386
 
@@ -413,6 +421,102 @@ describe('PktFileUpload', () => {
413
421
  const closeIcon = container.querySelector('pkt-icon[name="close-circle"]')
414
422
  expect(closeIcon).toBeInTheDocument()
415
423
  })
424
+
425
+ it('disabled mode prevents remove buttons from firing', () => {
426
+ const onFilesChanged = vi.fn()
427
+ const initialValue: TFileItemList = [createFileItem('locked.pdf', 'lock-1')]
428
+ render(
429
+ <PktFileUpload
430
+ value={initialValue}
431
+ name="pktFileUpload"
432
+ disabled
433
+ onFilesChanged={onFilesChanged}
434
+ />,
435
+ )
436
+
437
+ const removeButton = screen.getByRole('button', { name: /Slett fil/ })
438
+ removeButton.click()
439
+
440
+ expect(onFilesChanged).not.toHaveBeenCalled()
441
+ })
442
+
443
+ it('calls onTransferCancelled with the file id when removing in custom strategy', () => {
444
+ const onTransferCancelled = vi.fn()
445
+ const onFilesChanged = vi.fn()
446
+ const initialValue: TFileItemList = [createFileItem('uploading.pdf', 'cancel-1')]
447
+
448
+ render(
449
+ <PktFileUpload
450
+ id="cancel-upload"
451
+ name="pktFileUpload"
452
+ uploadStrategy="custom"
453
+ value={initialValue}
454
+ onFilesChanged={onFilesChanged}
455
+ onFileUploadRequested={vi.fn()}
456
+ onTransferCancelled={onTransferCancelled}
457
+ transfers={[{ fileId: 'cancel-1', progress: 0.4, showProgress: true }]}
458
+ />,
459
+ )
460
+
461
+ const cancelButton = screen.getByRole('button', { name: /Avbryt opplasting/ })
462
+ cancelButton.click()
463
+
464
+ expect(onTransferCancelled).toHaveBeenCalledTimes(1)
465
+ expect(onTransferCancelled).toHaveBeenCalledWith('cancel-1')
466
+ expect(onFilesChanged).toHaveBeenCalledTimes(1)
467
+ expect(onFilesChanged.mock.calls[0][0]).toEqual([])
468
+ })
469
+ })
470
+
471
+ describe('extraOperations', () => {
472
+ it('renders custom operation buttons and forwards a context to onClick', () => {
473
+ const seen: Array<{ fileId: string; isActive: boolean }> = []
474
+ const initialValue: TFileItemList = [createFileItem('star-me.pdf', 'star-1')]
475
+ render(
476
+ <PktFileUpload
477
+ name="pktFileUpload"
478
+ value={initialValue}
479
+ onFilesChanged={NOOP}
480
+ extraOperations={[
481
+ {
482
+ id: 'star',
483
+ title: 'Stjernemerk',
484
+ ariaLabel: 'Stjernemerk fil',
485
+ onClick: (context) =>
486
+ seen.push({ fileId: context.file.fileId, isActive: context.isActive }),
487
+ },
488
+ ]}
489
+ />,
490
+ )
491
+
492
+ const button = screen.getByRole('button', { name: /Stjernemerk fil/ })
493
+ button.click()
494
+
495
+ expect(seen).toEqual([{ fileId: 'star-1', isActive: false }])
496
+ })
497
+
498
+ it('renderInlineUI swaps in custom UI when activated', () => {
499
+ const initialValue: TFileItemList = [createFileItem('inline.pdf', 'inline-1')]
500
+ render(
501
+ <PktFileUpload
502
+ name="pktFileUpload"
503
+ value={initialValue}
504
+ onFilesChanged={NOOP}
505
+ extraOperations={[
506
+ {
507
+ id: 'edit-inline',
508
+ title: 'Inline rediger',
509
+ renderInlineUI: () => <span data-testid="custom-inline">Custom inline UI</span>,
510
+ },
511
+ ]}
512
+ />,
513
+ )
514
+
515
+ const button = screen.getByRole('button', { name: /Inline rediger/ })
516
+ fireEvent.click(button)
517
+
518
+ expect(screen.getByTestId('custom-inline')).toBeInTheDocument()
519
+ })
416
520
  })
417
521
 
418
522
  describe('Accessibility', () => {
@@ -515,6 +619,57 @@ describe('PktFileUpload', () => {
515
619
  })
516
620
  })
517
621
 
622
+ describe('onFileValidate escape hatch', () => {
623
+ it('runs after built-in validation, receives the file, and can reject by setting errorMessage', () => {
624
+ const onFileValidate = vi.fn((detail: { file: File; errorMessage: string | null }) => {
625
+ if (detail.file.name.endsWith('.exe')) detail.errorMessage = 'Blokkert av onFileValidate'
626
+ })
627
+ render(<PktFileUpload multiple name="pktFileUpload" onFileValidate={onFileValidate} />)
628
+
629
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
630
+ fireEvent.change(fileInput, { target: { files: [createMockFile('malware.exe')] } })
631
+
632
+ expect(onFileValidate).toHaveBeenCalledTimes(1)
633
+ expect(onFileValidate.mock.calls[0][0].file.name).toBe('malware.exe')
634
+ expect(screen.getByText('Blokkert av onFileValidate')).toBeInTheDocument()
635
+ })
636
+
637
+ it('allows the file through when errorMessage stays null', () => {
638
+ const onFilesChanged = vi.fn()
639
+ const onFileValidate = vi.fn() // touches nothing, so errorMessage stays null
640
+ render(
641
+ <PktFileUpload
642
+ name="pktFileUpload"
643
+ onFilesChanged={onFilesChanged}
644
+ onFileValidate={onFileValidate}
645
+ />,
646
+ )
647
+
648
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
649
+ fireEvent.change(fileInput, { target: { files: [createMockFile('ok.pdf')] } })
650
+
651
+ expect(onFileValidate).toHaveBeenCalledTimes(1)
652
+ expect(onFilesChanged).toHaveBeenCalledTimes(1)
653
+ })
654
+
655
+ it('does not run when a built-in validator (allowedFormats) already rejects the file', () => {
656
+ const onFileValidate = vi.fn()
657
+ render(
658
+ <PktFileUpload
659
+ name="pktFileUpload"
660
+ allowedFormats={['pdf']}
661
+ onFileValidate={onFileValidate}
662
+ />,
663
+ )
664
+
665
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
666
+ fireEvent.change(fileInput, { target: { files: [createMockFile('nope.png')] } })
667
+
668
+ expect(onFileValidate).not.toHaveBeenCalled()
669
+ expect(screen.getByText(/Ugyldig filtype/)).toBeInTheDocument()
670
+ })
671
+ })
672
+
518
673
  describe('Form submission values', () => {
519
674
  it('blocks custom strategy submit when required and no files are selected', () => {
520
675
  const { container } = render(
@@ -543,7 +698,7 @@ describe('PktFileUpload', () => {
543
698
  expect(submitWasPrevented).toBe(true)
544
699
  expect(fileInput).toHaveAttribute('aria-invalid', 'true')
545
700
  expect(fileInput).toHaveAttribute('aria-describedby', 'custom-required-upload-error')
546
- expect(screen.getByText('Velg minst én fil før du sender inn skjemaet.')).toBeInTheDocument()
701
+ expect(screen.getByText('Du må laste opp minst én fil.')).toBeInTheDocument()
547
702
  })
548
703
 
549
704
  it('should populate file input with selected files for form submission', () => {