@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +35 -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 +4560 -3038
  5. package/dist/punkt-react.umd.js +670 -274
  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/inputwrapper/InputWrapper.tsx +4 -1
  40. package/src/components/modal/Modal.tsx +27 -15
  41. package/src/components/searchinput/SearchInput.tsx +14 -13
  42. package/src/components/types.ts +1 -1
  43. package/src/components/fileupload/texts.ts +0 -20
@@ -2,17 +2,19 @@ import { useContext, useLayoutEffect, useRef } from 'react'
2
2
 
3
3
  import { PktButton } from '../../button/Button'
4
4
  import { PktIcon } from '../../icon/Icon'
5
- import { TFileAttributes, FileItem, PktFileUploadContext, TQueueItemOperation } from '../types'
6
5
  import { PktTextarea } from '../../textarea/Textarea'
6
+ import { FileItem, PktFileUploadContext, TQueueItemOperation, TQueueOperationContext } from '../types'
7
7
 
8
- const COMMENT_SYMBOL = Symbol('comment')
8
+ const COMMENTS_ATTRIBUTE = 'comments'
9
9
 
10
10
  export interface IComment {
11
11
  text: string
12
- timestamp: Date
12
+ timestamp: string
13
13
  }
14
14
 
15
- const formatTimestamp = (date: Date): string => {
15
+ const formatTimestamp = (iso: string): string => {
16
+ const date = new Date(iso)
17
+ if (Number.isNaN(date.getTime())) return iso
16
18
  const day = String(date.getDate()).padStart(2, '0')
17
19
  const month = String(date.getMonth() + 1).padStart(2, '0')
18
20
  const year = date.getFullYear()
@@ -23,11 +25,13 @@ const formatTimestamp = (date: Date): string => {
23
25
 
24
26
  const AddComment = ({
25
27
  fileItem,
28
+ disabled,
26
29
  closeOperationUi,
27
30
  onAddComment,
28
31
  existingComment,
29
32
  }: {
30
33
  fileItem: FileItem
34
+ disabled: boolean
31
35
  closeOperationUi: () => void
32
36
  onAddComment: (comment: IComment) => void
33
37
  existingComment?: IComment
@@ -38,10 +42,7 @@ const AddComment = ({
38
42
  const handleAddComment = () => {
39
43
  const text = inputRef.current?.value?.trim()
40
44
  if (text) {
41
- onAddComment({
42
- text,
43
- timestamp: new Date(),
44
- })
45
+ onAddComment({ text, timestamp: new Date().toISOString() })
45
46
  }
46
47
  closeOperationUi()
47
48
  }
@@ -62,127 +63,114 @@ const AddComment = ({
62
63
  rows={2}
63
64
  id={`comment-${fileItem.fileId}`}
64
65
  ref={inputRef}
66
+ disabled={disabled}
65
67
  defaultValue={existingComment?.text}
66
68
  />
67
69
 
68
- <PktButton skin="secondary" size="small" onClick={handleAddComment}>
70
+ <PktButton skin="secondary" size="small" disabled={disabled} onClick={handleAddComment}>
69
71
  {isEditing ? 'Lagre kommentar' : 'Legg til kommentar'}
70
72
  </PktButton>
71
- <PktButton skin="tertiary" size="small" onClick={closeOperationUi}>
73
+ <PktButton skin="tertiary" size="small" disabled={disabled} onClick={closeOperationUi}>
72
74
  Avbryt
73
75
  </PktButton>
74
76
  </>
75
77
  )
76
78
  }
77
79
 
78
- const ShowComments = ({
79
- comments,
80
+ const ShowComment = ({
81
+ comment,
82
+ disabled,
80
83
  onDeleteComment,
81
84
  onEditComment,
82
85
  }: {
83
- comments?: IComment[]
84
- onDeleteComment?: (index: number) => void
85
- onEditComment?: (index: number) => void
86
- }) =>
87
- comments && comments.length > 0 ? (
88
- <div className="pkt-fileupload__queue-display__item__comments">
89
- {comments.map((comment, index) => (
90
- <div key={index} className="pkt-fileupload__queue-display__item__comment">
91
- <div className="pkt-fileupload__queue-display__item__comment__content">
92
- <span className="pkt-fileupload__queue-display__item__comment__text" aria-label="Kommentar tekst">
93
- {comment.text}
94
- </span>
95
- <time className="pkt-fileupload__queue-display__item__comment__time">
96
- {formatTimestamp(comment.timestamp)}
97
- </time>
98
- </div>
99
- <div className="pkt-fileupload__queue-display__item__comment__actions">
100
- {onDeleteComment && (
101
- <button
102
- type="button"
103
- className="pkt-fileupload__queue-display__item__comment__action"
104
- onClick={() => onDeleteComment(index)}
105
- aria-label="Slett kommentar"
106
- >
107
- <PktIcon name="trash-can" />
108
- </button>
109
- )}
110
- {onEditComment && (
111
- <button
112
- type="button"
113
- className="pkt-fileupload__queue-display__item__comment__action"
114
- onClick={() => onEditComment(index)}
115
- aria-label="Rediger kommentar"
116
- >
117
- <PktIcon name="edit" />
118
- </button>
119
- )}
120
- </div>
121
- </div>
122
- ))}
86
+ comment: IComment
87
+ disabled: boolean
88
+ onDeleteComment: () => void
89
+ onEditComment: () => void
90
+ }) => (
91
+ <div className="pkt-fileupload__queue-display__item__comments">
92
+ <div className="pkt-fileupload__queue-display__item__comment">
93
+ <div className="pkt-fileupload__queue-display__item__comment__content">
94
+ <span className="pkt-fileupload__queue-display__item__comment__text" aria-label="Kommentar tekst">
95
+ {comment.text}
96
+ </span>
97
+ <time className="pkt-fileupload__queue-display__item__comment__time">
98
+ {formatTimestamp(comment.timestamp)}
99
+ </time>
100
+ </div>
101
+ <div className="pkt-fileupload__queue-display__item__comment__actions">
102
+ <button
103
+ type="button"
104
+ className="pkt-fileupload__queue-display__item__comment__action"
105
+ onClick={onEditComment}
106
+ disabled={disabled}
107
+ aria-label="Rediger kommentar"
108
+ >
109
+ <PktIcon name="edit" />
110
+ </button>
111
+ <button
112
+ type="button"
113
+ className="pkt-fileupload__queue-display__item__comment__action"
114
+ onClick={onDeleteComment}
115
+ disabled={disabled}
116
+ aria-label="Slett kommentar"
117
+ >
118
+ <PktIcon name="trash-can" />
119
+ </button>
120
+ </div>
123
121
  </div>
124
- ) : null
122
+ </div>
123
+ )
125
124
 
126
- const CommentHiddenInput = (props: { comments: IComment[] | undefined }) => {
125
+ const CommentHiddenInput = ({ comments }: { comments: IComment[] | undefined }) => {
127
126
  const context = useContext(PktFileUploadContext)
128
127
  return (
129
128
  <input
130
129
  type="hidden"
131
130
  name={`${context.name}-comments`}
132
- value={props.comments ? JSON.stringify(props.comments) : ''}
131
+ value={comments ? JSON.stringify(comments) : ''}
133
132
  />
134
133
  )
135
134
  }
136
135
 
137
- export const addCommentOperation = (attributes: TFileAttributes): TQueueItemOperation => {
138
- const commentsAttribute = attributes<IComment[]>('comments')
139
-
140
- const addComment = (fileId: string, newComment: IComment) => {
141
- commentsAttribute.set(fileId, [newComment])
142
- }
143
-
144
- const deleteComment = (fileId: string, commentIndex: number) => {
145
- const existingComments = commentsAttribute.get(fileId) || []
146
- const newComments = existingComments.filter((_, i) => i !== commentIndex)
147
- commentsAttribute.set(fileId, newComments.length > 0 ? newComments : undefined)
148
- }
149
-
150
- return {
151
- // Only show button text when no comment exists yet
152
- title: (fileItem: FileItem) => {
153
- const comments = commentsAttribute.get(fileItem.fileId)
154
- // If comment exists, return empty string to hide the button (icons are shown in renderContent)
155
- if (comments && comments.length > 0) return ''
156
- return 'Legg til kommentar'
157
- },
158
- renderExtendedUI: (fileItem: FileItem, closeOperationUi: () => void) => {
159
- const existingComments = commentsAttribute.get(fileItem.fileId)
160
- const existingComment = existingComments?.[0]
161
- return (
162
- <AddComment
163
- fileItem={fileItem}
164
- closeOperationUi={closeOperationUi}
165
- onAddComment={(comment) => addComment(fileItem.fileId, comment)}
166
- existingComment={existingComment}
167
- />
168
- )
169
- },
170
- renderContent: (fileItem: FileItem, activateOperation?: () => void, isOperationActive?: boolean) => {
171
- // Hide comment preview when editing
172
- if (isOperationActive) return null
173
-
174
- const comments = commentsAttribute.get(fileItem.fileId)
175
- return (
176
- <ShowComments
177
- comments={comments}
178
- onDeleteComment={(index) => deleteComment(fileItem.fileId, index)}
179
- onEditComment={activateOperation ? () => activateOperation() : undefined}
180
- />
181
- )
182
- },
183
- renderHidden: (fileItem: FileItem) => (
184
- <CommentHiddenInput key={`comments${fileItem.fileId}`} comments={commentsAttribute.get(fileItem.fileId)} />
185
- ),
186
- symbol: COMMENT_SYMBOL,
187
- }
136
+ const getComments = (context: TQueueOperationContext): IComment[] =>
137
+ context.getAttribute<IComment[]>(COMMENTS_ATTRIBUTE) ?? []
138
+
139
+ export const addCommentOperation: TQueueItemOperation = {
140
+ id: 'comment',
141
+ title: (fileItem) => {
142
+ const comments = (fileItem.attributes?.[COMMENTS_ATTRIBUTE] as IComment[] | undefined) ?? []
143
+ return comments.length > 0 ? '' : 'Legg til kommentar'
144
+ },
145
+ ariaLabel: 'Legg til kommentar',
146
+ renderExtendedUI: (context) => {
147
+ const existingComment = getComments(context)[0]
148
+ return (
149
+ <AddComment
150
+ fileItem={context.file}
151
+ disabled={context.disabled}
152
+ closeOperationUi={context.close}
153
+ onAddComment={(comment) => {
154
+ context.setAttribute(COMMENTS_ATTRIBUTE, [comment])
155
+ }}
156
+ existingComment={existingComment}
157
+ />
158
+ )
159
+ },
160
+ renderContent: (context) => {
161
+ if (context.isActive) return null
162
+ const comment = getComments(context)[0]
163
+ if (!comment) return null
164
+ return (
165
+ <ShowComment
166
+ comment={comment}
167
+ disabled={context.disabled}
168
+ onEditComment={context.activate}
169
+ onDeleteComment={() => context.setAttribute(COMMENTS_ATTRIBUTE, undefined)}
170
+ />
171
+ )
172
+ },
173
+ renderHidden: (context) => (
174
+ <CommentHiddenInput key={`comments${context.file.fileId}`} comments={context.getAttribute<IComment[]>(COMMENTS_ATTRIBUTE)} />
175
+ ),
188
176
  }
@@ -1,10 +1,8 @@
1
- import { TFileId, FileItem, TQueueItemOperation } from '@/components/fileupload/types'
2
-
3
- const DELETE_SYMBOL = Symbol('deleteFile')
1
+ import type { TFileId, TQueueItemOperation } from '@/components/fileupload/types'
4
2
 
5
3
  export const removeFileOperation = (onFileRemoved: (fileId: TFileId) => void): TQueueItemOperation => ({
4
+ id: 'remove',
6
5
  title: 'Slett',
7
6
  ariaLabel: 'Slett fil',
8
- onClick: (fileItem: FileItem) => onFileRemoved(fileItem.fileId),
9
- symbol: DELETE_SYMBOL,
7
+ onClick: ({ file }) => onFileRemoved(file.fileId),
10
8
  })
@@ -1,17 +1,22 @@
1
1
  import { useContext, useId, useRef } from 'react'
2
2
 
3
+ import { getDisplayFilename } from 'shared-utils/fileupload'
4
+
3
5
  import { PktButton } from '../../button/Button'
4
- import { TFileAttributes, FileItem, PktFileUploadContext, TQueueItemOperation } from '../types'
6
+ import { PktFileUploadContext, TQueueItemOperation, TQueueOperationContext } from '../types'
5
7
 
6
- const RENAME_SYMBOL = Symbol('renameFile')
8
+ interface IRenameFileProps {
9
+ initialValue: string
10
+ disabled: boolean
11
+ onSave: (newName: string) => void
12
+ onCancel: () => void
13
+ }
7
14
 
8
- const RenameFile = (props: { value: string | undefined; onSave: (newName: string) => void; onCancel: () => void }) => {
15
+ const RenameFile = ({ initialValue, disabled, onSave, onCancel }: IRenameFileProps) => {
9
16
  const inputRef = useRef<HTMLInputElement>(null)
10
17
  const inputId = useId()
11
18
 
12
- const save = () => {
13
- props.onSave(inputRef.current?.value ?? '')
14
- }
19
+ const save = () => onSave(inputRef.current?.value ?? '')
15
20
 
16
21
  return (
17
22
  <>
@@ -23,57 +28,55 @@ const RenameFile = (props: { value: string | undefined; onSave: (newName: string
23
28
  ref={inputRef}
24
29
  type="text"
25
30
  autoFocus
26
- defaultValue={props.value}
31
+ disabled={disabled}
32
+ defaultValue={initialValue}
27
33
  className="pkt-fileupload__queue-display__item__rename-input"
28
- onKeyDown={(e) => {
29
- if (e.key === 'Enter') {
30
- e.preventDefault()
31
- e.stopPropagation()
34
+ onKeyDown={(event) => {
35
+ if (event.key === 'Enter') {
36
+ event.preventDefault()
37
+ event.stopPropagation()
32
38
  save()
33
39
  }
34
- if (e.key === 'Escape') {
35
- e.preventDefault()
36
- e.stopPropagation()
37
- props.onCancel()
40
+ if (event.key === 'Escape') {
41
+ event.preventDefault()
42
+ event.stopPropagation()
43
+ onCancel()
38
44
  }
39
45
  }}
40
46
  />
41
- <PktButton skin="secondary" size="small" onClick={() => props.onSave(inputRef.current!.value)}>
47
+ <PktButton skin="secondary" size="small" disabled={disabled} onClick={save}>
42
48
  Lagre
43
49
  </PktButton>
44
- <PktButton skin="tertiary" size="small" onClick={props.onCancel}>
50
+ <PktButton skin="tertiary" size="small" disabled={disabled} onClick={onCancel}>
45
51
  Avbryt
46
52
  </PktButton>
47
53
  </>
48
54
  )
49
55
  }
50
56
 
51
- const RenameHiddenInput = (props: { targetFilename: string }) => {
57
+ const RenameHiddenInput = ({ targetFilename }: { targetFilename: string }) => {
52
58
  const context = useContext(PktFileUploadContext)
53
- return <input type="hidden" name={`${context.name}-targetFilename`} value={props.targetFilename} />
59
+ return <input type="hidden" name={`${context.name}-targetFilename`} value={targetFilename} />
54
60
  }
55
61
 
56
- export const renameFileOperation = (attributes: TFileAttributes): TQueueItemOperation => {
57
- const targetFilenameAttribute = attributes<string>('targetFilename')
58
- return {
59
- title: 'Rediger',
60
- ariaLabel: 'Rediger filnavn',
61
- renderInlineUI: (fileItem: FileItem, closeOperationUi: () => void) => (
62
- <RenameFile
63
- value={targetFilenameAttribute.get(fileItem.fileId) || undefined}
64
- onSave={(newName: string) => {
65
- targetFilenameAttribute.set(fileItem.fileId, newName)
66
- closeOperationUi()
67
- }}
68
- onCancel={closeOperationUi}
69
- />
70
- ),
71
- renderHidden: (fileItem: FileItem) => (
72
- <RenameHiddenInput
73
- key={`rename${fileItem.fileId}`}
74
- targetFilename={targetFilenameAttribute.get(fileItem.fileId) || fileItem.file.name}
75
- />
76
- ),
77
- symbol: RENAME_SYMBOL,
78
- }
62
+ const resolveCurrentName = (context: TQueueOperationContext) =>
63
+ context.getAttribute<string>('targetFilename') || getDisplayFilename(context.file, '')
64
+
65
+ export const renameFileOperation: TQueueItemOperation = {
66
+ id: 'rename',
67
+ title: 'Rediger',
68
+ ariaLabel: 'Rediger filnavn',
69
+ renderInlineUI: (context) => (
70
+ <RenameFile
71
+ initialValue={resolveCurrentName(context)}
72
+ disabled={context.disabled}
73
+ onSave={(newName: string) => {
74
+ const trimmed = newName.trim()
75
+ if (trimmed) context.setAttribute('targetFilename', trimmed)
76
+ context.close()
77
+ }}
78
+ onCancel={context.close}
79
+ />
80
+ ),
81
+ renderHidden: (context) => <RenameHiddenInput key={`rename${context.file.fileId}`} targetFilename={resolveCurrentName(context)} />,
79
82
  }
@@ -1,3 +1,4 @@
1
1
  export { useFileAttributes } from './useFileAttributes'
2
2
  export { useImagePreview } from './useImagePreview'
3
3
  export { useOperationState } from './useOperationState'
4
+ export { useRequiredFormValidation } from './useRequiredFormValidation'
@@ -1,46 +1,54 @@
1
- import { useCallback } from 'react'
1
+ import { useCallback, useMemo } from 'react'
2
2
 
3
- import { TFileAttributes, TFileId, FileItem, TFileItemList } from '../types'
3
+ import { FileItem, TFileAttributes, TFileId, TFileItemList, TQueueOperationContext } from '../types'
4
4
 
5
+ /**
6
+ * Build accessors that let queue-item operations read and write per-file
7
+ * attributes without ever mutating the current `value` list. All changes are
8
+ * propagated via `onFileUpdated`, so controlled parents stay authoritative.
9
+ */
5
10
  export const useFileAttributes = (
6
11
  value: TFileItemList,
7
- onFileUpdated: (TFileId: TFileId, updates: Partial<FileItem>) => void,
12
+ onFileUpdated: (fileId: TFileId, updates: Partial<FileItem>) => void,
8
13
  ) => {
14
+ const getAttributeForFile = useCallback(
15
+ <T>(fileId: TFileId, attributeName: string): T | undefined => {
16
+ const fileItem = value.find((file) => file.fileId === fileId)
17
+ return fileItem?.attributes?.[attributeName] as T | undefined
18
+ },
19
+ [value],
20
+ )
21
+
9
22
  const setAttributeForFile = useCallback(
10
- (TFileId: TFileId, attributeName: string, attributeValue: any) => {
11
- const fileItem = value.find((file) => file.fileId === TFileId)!
12
- const attributes = fileItem.attributes || {}
13
- fileItem.attributes = {
14
- ...attributes,
15
- [attributeName]: attributeValue,
23
+ (fileId: TFileId, attributeName: string, attributeValue: unknown) => {
24
+ const fileItem = value.find((file) => file.fileId === fileId)
25
+ if (!fileItem) return
26
+ const nextAttributes = { ...fileItem.attributes, [attributeName]: attributeValue }
27
+ if (attributeValue === undefined) {
28
+ delete nextAttributes[attributeName]
16
29
  }
17
- onFileUpdated(TFileId, { attributes: fileItem.attributes })
30
+ onFileUpdated(fileId, { attributes: nextAttributes as FileItem['attributes'] })
18
31
  },
19
32
  [onFileUpdated, value],
20
33
  )
21
34
 
22
- const getAttributeForFile = useCallback(
23
- (fileId: TFileId, attributeName: string): any | undefined => {
24
- const fileItem = value.find((file) => file.fileId === fileId)!
25
- const attributes = fileItem.attributes || {}
26
- return attributes[attributeName]
27
- },
28
- [value],
35
+ const fileAttributes: TFileAttributes = useMemo(
36
+ () =>
37
+ <T>(attributeName: string) => ({
38
+ get: (fileId: TFileId) => getAttributeForFile<T>(fileId, attributeName),
39
+ set: (fileId: TFileId, attributeValue: T | undefined) =>
40
+ setAttributeForFile(fileId, attributeName, attributeValue),
41
+ }),
42
+ [getAttributeForFile, setAttributeForFile],
29
43
  )
30
44
 
31
- const fileAttributes: TFileAttributes = useCallback(
32
- <T>(attributeName: string) => {
33
- return {
34
- get: (fileId: TFileId): T | undefined => {
35
- return getAttributeForFile(fileId, attributeName)
36
- },
37
- set: (fileId: TFileId, attributeValue: T | undefined) => {
38
- setAttributeForFile(fileId, attributeName, attributeValue)
39
- },
40
- }
41
- },
45
+ const buildAttributeAccessors = useCallback(
46
+ (fileId: TFileId): Pick<TQueueOperationContext, 'getAttribute' | 'setAttribute'> => ({
47
+ getAttribute: <T>(name: string) => getAttributeForFile<T>(fileId, name),
48
+ setAttribute: (name, attributeValue) => setAttributeForFile(fileId, name, attributeValue),
49
+ }),
42
50
  [getAttributeForFile, setAttributeForFile],
43
51
  )
44
52
 
45
- return { fileAttributes } as const
53
+ return { fileAttributes, buildAttributeAccessors } as const
46
54
  }
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useRef, useState } from 'react'
2
2
 
3
+ import { navigateCyclic } from 'shared-utils/fileupload'
4
+
3
5
  import { TFileAndTransfer, TFileId } from '../types'
4
6
 
5
7
  export const useImagePreview = (previewableImages: TFileAndTransfer[]) => {
@@ -27,13 +29,7 @@ export const useImagePreview = (previewableImages: TFileAndTransfer[]) => {
27
29
  const navigate = useCallback(
28
30
  (direction: 'prev' | 'next') => {
29
31
  if (previewableImages.length === 0) return
30
-
31
- setCurrentIndex((prev) => {
32
- if (direction === 'prev') {
33
- return prev <= 0 ? previewableImages.length - 1 : prev - 1
34
- }
35
- return prev >= previewableImages.length - 1 ? 0 : prev + 1
36
- })
32
+ setCurrentIndex((prev) => navigateCyclic(prev, direction, previewableImages.length))
37
33
  },
38
34
  [previewableImages.length],
39
35
  )
@@ -1,25 +1,46 @@
1
- import { useCallback, useState } from 'react'
1
+ import { useCallback, useEffect, useState } from 'react'
2
2
 
3
3
  import { TFileId, TQueueItemOperation } from '../types'
4
4
 
5
5
  export const useOperationState = (queueItemOperations: TQueueItemOperation[]) => {
6
- const [activatedSymbols, setActivatedSymbols] = useState<Record<TFileId, symbol>>({})
6
+ const [activatedIds, setActivatedIds] = useState<Record<TFileId, string>>({})
7
+
8
+ // If an operation is removed from the list while still referenced as "activated",
9
+ // the stored id would re-activate a freshly-added operation with the same id
10
+ // (e.g. an extension re-mounted). Drop any stale entries on list changes so the
11
+ // activation state can never refer to an operation that no longer exists.
12
+ useEffect(() => {
13
+ setActivatedIds((prev) => {
14
+ const validIds = new Set(queueItemOperations.map((op) => op.id))
15
+ let changed = false
16
+ const next: Record<TFileId, string> = {}
17
+ for (const [fileId, opId] of Object.entries(prev)) {
18
+ if (validIds.has(opId)) {
19
+ next[fileId] = opId
20
+ } else {
21
+ changed = true
22
+ }
23
+ }
24
+ return changed ? next : prev
25
+ })
26
+ }, [queueItemOperations])
7
27
 
8
28
  const getActivated = useCallback(
9
29
  (fileId: TFileId): TQueueItemOperation | undefined => {
10
- const symbol = activatedSymbols[fileId]
11
- if (!symbol) return undefined
12
- return queueItemOperations.find((op) => op.symbol === symbol)
30
+ const id = activatedIds[fileId]
31
+ if (!id) return undefined
32
+ return queueItemOperations.find((op) => op.id === id)
13
33
  },
14
- [activatedSymbols, queueItemOperations],
34
+ [activatedIds, queueItemOperations],
15
35
  )
16
36
 
17
37
  const activate = useCallback((fileId: TFileId, operation: TQueueItemOperation) => {
18
- setActivatedSymbols((prev) => ({ ...prev, [fileId]: operation.symbol }))
38
+ setActivatedIds((prev) => ({ ...prev, [fileId]: operation.id }))
19
39
  }, [])
20
40
 
21
41
  const close = useCallback((fileId: TFileId) => {
22
- setActivatedSymbols((prev) => {
42
+ setActivatedIds((prev) => {
43
+ if (!(fileId in prev)) return prev
23
44
  const updated = { ...prev }
24
45
  delete updated[fileId]
25
46
  return updated
@@ -0,0 +1,43 @@
1
+ import { RefObject, useEffect } from 'react'
2
+
3
+ /**
4
+ * When `uploadStrategy="custom"` and the upload is marked `required`, the
5
+ * native `required` attribute can't do the work (the input isn't posted).
6
+ * This hook attaches a submit listener to the containing `<form>` that
7
+ * prevents submission if the queue is empty, and surfaces a validation
8
+ * error via `onMissingFiles`.
9
+ *
10
+ * Passive in `form` strategy — native validation handles that case.
11
+ */
12
+ export const useRequiredFormValidation = (
13
+ fileInputRef: RefObject<HTMLInputElement>,
14
+ {
15
+ uploadStrategy,
16
+ required,
17
+ currentFileCount,
18
+ onMissingFiles,
19
+ }: {
20
+ uploadStrategy: 'form' | 'custom'
21
+ required: boolean
22
+ currentFileCount: number
23
+ onMissingFiles: () => void
24
+ },
25
+ ) => {
26
+ useEffect(() => {
27
+ if (uploadStrategy !== 'custom' || !required) return
28
+ const input = fileInputRef.current
29
+ const form = input?.form
30
+ if (!form) return
31
+
32
+ const handleFormSubmit = (event: SubmitEvent) => {
33
+ if (currentFileCount > 0) return
34
+ event.preventDefault()
35
+ onMissingFiles()
36
+ }
37
+
38
+ form.addEventListener('submit', handleFormSubmit)
39
+ return () => {
40
+ form.removeEventListener('submit', handleFormSubmit)
41
+ }
42
+ }, [fileInputRef, uploadStrategy, required, currentFileCount, onMissingFiles])
43
+ }
@@ -2,3 +2,4 @@
2
2
  export { useFileAttributes } from './hooks/useFileAttributes'
3
3
  export { useImagePreview } from './hooks/useImagePreview'
4
4
  export { useOperationState } from './hooks/useOperationState'
5
+ export { useRequiredFormValidation } from './hooks/useRequiredFormValidation'