@oslokommune/punkt-react 15.0.4 → 15.2.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": "15.0.4",
3
+ "version": "15.2.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": "^15.0.4",
42
+ "@oslokommune/punkt-elements": "^15.2.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": "^15.0.0",
53
- "@oslokommune/punkt-css": "^15.0.4",
53
+ "@oslokommune/punkt-css": "^15.2.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": "cfa04006aa3ab25a27feff28f6b114443a41313a"
112
+ "gitHead": "67c9a3c4ede793c0e6bf1e4611ae29d00cf072ee"
113
113
  }
@@ -0,0 +1,236 @@
1
+ import classNames from 'classnames'
2
+ import {
3
+ ChangeEvent,
4
+ DragEvent,
5
+ forwardRef,
6
+ InputHTMLAttributes,
7
+ useCallback,
8
+ useEffect,
9
+ useImperativeHandle,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react'
14
+
15
+ import { PktIcon } from '..'
16
+ import { uiMultipleTexts, uiSingleTexts, uiThumbnailMultipleTexts, uiThumbnailSingleTexts } from './texts'
17
+ import { FileItem, TFileItemList, TUploadStrategy } from './types'
18
+
19
+ /**
20
+ * Props for the internal `DropZone` building block.
21
+ *
22
+ * This component is responsible for:
23
+ * - rendering the native `<input type="file">` (visually hidden, still accessible)
24
+ * - handling drag & drop + file dialog selection
25
+ * - optionally rendering hidden inputs with file IDs when `uploadStrategy="custom"`
26
+ */
27
+ interface IDropZoneProps
28
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, 'checked' | 'size' | 'type' | 'value' | 'onChange' | 'width'> {
29
+ /** Called with `FileItem`s created from dropped/selected files. */
30
+ onFilesAdded?: (files: TFileItemList) => void
31
+ /** Used for the native input `id` and related label/ARIA wiring. */
32
+ id: string
33
+ /** Allow selecting/dropping multiple files. */
34
+ multiple?: boolean
35
+ /** Current file list (used for custom hidden inputs + re-populating the native input). */
36
+ value: TFileItemList
37
+ /**
38
+ * Field name.
39
+ * - `uploadStrategy="form"`: used as `<input name="...">` so the files are posted on submit.
40
+ * - `uploadStrategy="custom"`: used for hidden `<input type="hidden" name="...">` file IDs.
41
+ */
42
+ name: string
43
+ /** Upload mode. */
44
+ uploadStrategy: TUploadStrategy
45
+ /** Enables alternate texts/UX for thumbnail view. */
46
+ isThumbnailView?: boolean
47
+ /** Disables all interaction. */
48
+ disabled?: boolean
49
+ /** IDs for screen reader announcements (uploaded/errors). */
50
+ srAnnouncementIds?: {
51
+ uploaded: string
52
+ errors: string
53
+ }
54
+ }
55
+ export const DropZone = forwardRef<HTMLInputElement, IDropZoneProps>(
56
+ (
57
+ {
58
+ id,
59
+ multiple,
60
+ value,
61
+ onFilesAdded = () => {},
62
+ name,
63
+ uploadStrategy,
64
+ accept = '.pdf, .jpeg, .jpg, .png, .heic, .doc, .docx, .odt',
65
+ isThumbnailView = false,
66
+ disabled = false,
67
+ srAnnouncementIds,
68
+ ...inputProps
69
+ }: IDropZoneProps,
70
+ forwardedRef,
71
+ ) => {
72
+ const fileInputRef = useRef<HTMLInputElement>(null)
73
+
74
+ const acceptedFormatsReadableString = useMemo(
75
+ () => (accept?.split(/\s*,\s*/).map((format) => format.trim()) || []).join(', ').toUpperCase(),
76
+ [accept],
77
+ )
78
+
79
+ useImperativeHandle(forwardedRef, () => fileInputRef.current! as HTMLInputElement)
80
+
81
+ const [isDragActive, setIsDragActive] = useState(false)
82
+
83
+ const uiTexts: typeof uiMultipleTexts = useMemo(() => {
84
+ if (isThumbnailView) {
85
+ return multiple ? uiThumbnailMultipleTexts : uiThumbnailSingleTexts
86
+ }
87
+ return multiple ? uiMultipleTexts : uiSingleTexts
88
+ }, [multiple, isThumbnailView])
89
+
90
+ const populateNativeFileInput = useCallback(
91
+ (fileItemList: TFileItemList) => {
92
+ if (!fileInputRef.current) return
93
+ try {
94
+ fileInputRef.current.files = createFileList(fileItemList)
95
+ } catch {
96
+ // Setting files property may fail in test environments like jsdom
97
+ // This is not critical for the component's functionality
98
+ }
99
+ },
100
+ [fileInputRef.current, value],
101
+ )
102
+
103
+ useEffect(() => {
104
+ populateNativeFileInput(value)
105
+ }, [fileInputRef, value])
106
+ const filesAdded = useCallback(
107
+ (files: Array<File>) => {
108
+ const addedFileItems = files.map((file) => new FileItem(file))
109
+ onFilesAdded(addedFileItems)
110
+ // Clear the input value to allow uploading the same file again if needed
111
+ if (fileInputRef.current) {
112
+ fileInputRef.current.value = ''
113
+ }
114
+ },
115
+ [onFilesAdded],
116
+ )
117
+
118
+ const filesSelectedInDialog = (event: ChangeEvent<HTMLInputElement>) => {
119
+ const selectedFiles = (event.target as HTMLInputElement).files!
120
+ const userCancelledFileSelectionDialog = selectedFiles.length === 0
121
+ if (userCancelledFileSelectionDialog) {
122
+ if (multiple) {
123
+ populateNativeFileInput(value)
124
+ } else {
125
+ // onFilesChanged([]) // TODO: Nullstill ved avbryt i enkel opplasting?
126
+ }
127
+ return
128
+ }
129
+ filesAdded(Array.from(selectedFiles))
130
+ }
131
+
132
+ const filesDroppedOnDropzone = (files: Array<File>) => {
133
+ filesAdded(files)
134
+ }
135
+
136
+ const handleDrop = (event: DragEvent<HTMLDivElement>) => {
137
+ event.preventDefault()
138
+ setIsDragActive(false)
139
+ if (disabled) return
140
+ const droppedFiles = event.dataTransfer.files
141
+ const arr: Array<File> = Array.from(droppedFiles)
142
+ filesDroppedOnDropzone(arr)
143
+ }
144
+
145
+ const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
146
+ event.preventDefault()
147
+ if (disabled) return
148
+ setIsDragActive(true)
149
+ }
150
+
151
+ const handleDragLeave = () => {
152
+ setIsDragActive(false)
153
+ }
154
+
155
+ const handleDropZoneClick = useCallback(
156
+ (event: React.MouseEvent<HTMLDivElement>) => {
157
+ if (disabled) return
158
+ const target = event.target as HTMLElement
159
+ // ignore clicks on the explicit open button (it opens the dialog itself)
160
+ if (target.closest('.pkt-fileupload__drop-zone__placeholder__title__open-file-dialog')) return
161
+ fileInputRef.current?.click()
162
+ },
163
+ [disabled],
164
+ )
165
+
166
+ return (
167
+ <div
168
+ onDrop={handleDrop}
169
+ onDragOver={handleDragOver}
170
+ onDragLeave={handleDragLeave}
171
+ onClick={handleDropZoneClick}
172
+ className={classNames('pkt-fileupload__drop-zone', {
173
+ 'pkt-fileupload__drop-zone--drag-active': isDragActive,
174
+ 'pkt-fileupload__drop-zone--disabled': disabled,
175
+ })}
176
+ >
177
+ <input
178
+ {...inputProps}
179
+ id={id}
180
+ type={'file'}
181
+ ref={fileInputRef}
182
+ multiple={multiple}
183
+ onChange={filesSelectedInDialog}
184
+ accept={accept}
185
+ disabled={disabled}
186
+ name={(uploadStrategy === 'form' && name) || undefined} // Ikke sett name hvis uploadStrategy er 'custom' - ignorerer ved POST
187
+ aria-label={multiple ? 'Velg filer' : 'Velg fil'}
188
+ />
189
+ {uploadStrategy === 'custom' && (
190
+ <>
191
+ {value?.map((fileItem) => (
192
+ <input key={fileItem.fileId} type="hidden" name={name} value={fileItem.fileId} />
193
+ ))}
194
+ </>
195
+ )}
196
+ <div className="pkt-fileupload__drop-zone__placeholder">
197
+ <PktIcon name={'attachment'} className="pkt-fileupload__drop-zone__placeholder__icon" aria-hidden="true" />
198
+ <p className={'pkt-fileupload__drop-zone__placeholder__title'}>
199
+ {isDragActive ? (
200
+ `${uiTexts.dropFilesHere} ...`
201
+ ) : (
202
+ <>
203
+ {uiTexts.selectOrDragFiles}{' '}
204
+ <button
205
+ className="pkt-fileupload__drop-zone__placeholder__title__open-file-dialog"
206
+ onClick={(e) => {
207
+ e.preventDefault()
208
+ e.stopPropagation()
209
+ fileInputRef.current?.click()
210
+ }}
211
+ type="button"
212
+ >
213
+ {uiTexts.chooseFiles}
214
+ </button>
215
+ </>
216
+ )}
217
+ </p>
218
+ <p className={'pkt-fileupload__drop-zone__placeholder__formats'}>Format: {acceptedFormatsReadableString}</p>
219
+ </div>
220
+ </div>
221
+ )
222
+ },
223
+ )
224
+
225
+ /**
226
+ * Create a `FileList` from a list of `FileItem`s.
227
+ *
228
+ * Note: relies on `DataTransfer`, which may be missing or restricted in test environments (jsdom).
229
+ */
230
+ export const createFileList = (fileItemList: TFileItemList) => {
231
+ const dataTransfer = new DataTransfer()
232
+ for (const fileItem of fileItemList) {
233
+ dataTransfer.items.add(fileItem.file)
234
+ }
235
+ return dataTransfer.files
236
+ }