@oslokommune/punkt-react 16.13.3 → 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.
- package/CHANGELOG.md +35 -0
- package/dist/{dialog-polyfill.esm-1hPr0gl9-EorOF9Wq.js → dialog-polyfill.esm-BmVHGRTX-CuhXqEqJ.js} +1 -1
- package/dist/index.d.ts +50 -47
- package/dist/punkt-react.es.js +4560 -3038
- package/dist/punkt-react.umd.js +670 -274
- package/dist/shared-types/fileupload.d.ts +73 -0
- package/dist/shared-types/index.d.ts +2 -0
- package/dist/shared-utils/fileupload/announcements.d.ts +11 -0
- package/dist/shared-utils/fileupload/filename.d.ts +13 -0
- package/dist/shared-utils/fileupload/focus-trap.d.ts +12 -0
- package/dist/shared-utils/fileupload/formats.d.ts +30 -0
- package/dist/shared-utils/fileupload/index.d.ts +15 -0
- package/dist/shared-utils/fileupload/navigation.d.ts +5 -0
- package/dist/shared-utils/fileupload/size.d.ts +12 -0
- package/dist/shared-utils/fileupload/transfers.d.ts +17 -0
- package/dist/shared-utils/fileupload/truncate.d.ts +13 -0
- package/dist/shared-utils/fileupload/validation.d.ts +24 -0
- package/package.json +4 -4
- package/src/components/fileupload/DropZone.tsx +35 -22
- package/src/components/fileupload/FileUpload.test.tsx +165 -10
- package/src/components/fileupload/FileUpload.tsx +116 -184
- package/src/components/fileupload/QueueDisplay.test.tsx +51 -8
- package/src/components/fileupload/QueueDisplay.tsx +71 -99
- package/src/components/fileupload/QueueItemContent.tsx +120 -135
- package/src/components/fileupload/Subcomponents.tsx +175 -144
- package/src/components/fileupload/Truncate.test.tsx +65 -207
- package/src/components/fileupload/Truncate.tsx +24 -120
- package/src/components/fileupload/extensions/Comments.tsx +94 -106
- package/src/components/fileupload/extensions/Remove.tsx +3 -5
- package/src/components/fileupload/extensions/Rename.tsx +45 -42
- package/src/components/fileupload/hooks/index.ts +1 -0
- package/src/components/fileupload/hooks/useFileAttributes.ts +37 -29
- package/src/components/fileupload/hooks/useImagePreview.ts +3 -7
- package/src/components/fileupload/hooks/useOperationState.ts +29 -8
- package/src/components/fileupload/hooks/useRequiredFormValidation.ts +43 -0
- package/src/components/fileupload/hooks.ts +1 -0
- package/src/components/fileupload/types.ts +49 -32
- package/src/components/fileupload/utils.ts +3 -54
- package/src/components/inputwrapper/InputWrapper.tsx +4 -1
- package/src/components/modal/Modal.tsx +27 -15
- package/src/components/searchinput/SearchInput.tsx +14 -13
- package/src/components/types.ts +1 -1
- 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,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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
-
:
|
|
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
|
|
90
|
+
const uiTexts = useMemo(() => {
|
|
98
91
|
if (isThumbnailView) {
|
|
99
|
-
return multiple
|
|
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
|
|
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
|
-
|
|
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.
|
|
223
|
+
`${uiTexts.dragActive} ...`
|
|
215
224
|
) : (
|
|
216
225
|
<>
|
|
217
|
-
{uiTexts.
|
|
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.
|
|
236
|
+
{uiTexts.openFileDialog}
|
|
228
237
|
</button>
|
|
229
238
|
</>
|
|
230
239
|
)}
|
|
231
240
|
</p>
|
|
232
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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('
|
|
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', () => {
|