@oslokommune/punkt-react 15.0.3 → 15.1.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 +31 -0
- package/dist/index.d.ts +173 -0
- package/dist/punkt-react.es.js +8216 -7134
- package/dist/punkt-react.umd.js +435 -435
- package/package.json +4 -4
- package/src/components/fileupload/DropZone.tsx +236 -0
- package/src/components/fileupload/FileUpload.test.tsx +584 -0
- package/src/components/fileupload/FileUpload.tsx +425 -0
- package/src/components/fileupload/QueueDisplay.test.tsx +222 -0
- package/src/components/fileupload/QueueDisplay.tsx +155 -0
- package/src/components/fileupload/QueueItemContent.tsx +200 -0
- package/src/components/fileupload/Subcomponents.tsx +363 -0
- package/src/components/fileupload/Truncate.test.tsx +256 -0
- package/src/components/fileupload/Truncate.tsx +137 -0
- package/src/components/fileupload/extensions/Comments.tsx +188 -0
- package/src/components/fileupload/extensions/Remove.tsx +10 -0
- package/src/components/fileupload/extensions/Rename.tsx +79 -0
- package/src/components/fileupload/hooks/index.ts +3 -0
- package/src/components/fileupload/hooks/useFileAttributes.ts +46 -0
- package/src/components/fileupload/hooks/useImagePreview.ts +42 -0
- package/src/components/fileupload/hooks/useOperationState.ts +30 -0
- package/src/components/fileupload/hooks.ts +4 -0
- package/src/components/fileupload/texts.ts +20 -0
- package/src/components/fileupload/types.ts +94 -0
- package/src/components/fileupload/utils.ts +56 -0
- package/src/components/index.ts +1 -0
- package/src/components/interfaces.ts +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
HTMLAttributes,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
|
|
12
|
+
const MAX_LINES = 2
|
|
13
|
+
|
|
14
|
+
const getLineHeightPx = (element: HTMLElement): number => {
|
|
15
|
+
const style = window.getComputedStyle(element)
|
|
16
|
+
const lineHeight = parseFloat(style.lineHeight)
|
|
17
|
+
if (!Number.isNaN(lineHeight) && lineHeight > 0) return lineHeight
|
|
18
|
+
|
|
19
|
+
// `line-height: normal` fallback (approx. 1.2 * font-size)
|
|
20
|
+
const fontSize = parseFloat(style.fontSize)
|
|
21
|
+
return (!Number.isNaN(fontSize) && fontSize > 0 ? fontSize : 16) * 1.2
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const elementOverflowsLines = (measureEl: HTMLElement, lineHeightPx: number, maxLines: number): boolean => {
|
|
25
|
+
const maxHeight = lineHeightPx * maxLines
|
|
26
|
+
const height = measureEl.getBoundingClientRect().height
|
|
27
|
+
return height > maxHeight + 0.5
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IMiddleTruncate extends HTMLAttributes<HTMLSpanElement> {
|
|
31
|
+
children: string
|
|
32
|
+
tail?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type TTruncateContext = { tail: number | undefined }
|
|
36
|
+
export const TruncateContext = createContext<TTruncateContext>({ tail: 5 })
|
|
37
|
+
|
|
38
|
+
export const Truncate = ({ children, tail: tailProp, ...props }: IMiddleTruncate): JSX.Element => {
|
|
39
|
+
const firstPart = children
|
|
40
|
+
const context = useContext(TruncateContext)
|
|
41
|
+
const tail = tailProp !== undefined ? tailProp : context.tail || 0
|
|
42
|
+
const secondPart = children.length <= tail + 3 ? null : children.slice(-tail)
|
|
43
|
+
const showingSecondPart = secondPart !== null && tail > 0
|
|
44
|
+
const wrapperRef = useRef<HTMLSpanElement>(null)
|
|
45
|
+
const measureRef = useRef<HTMLSpanElement>(null)
|
|
46
|
+
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
47
|
+
|
|
48
|
+
const computeOverflow = useCallback(() => {
|
|
49
|
+
const wrapper = wrapperRef.current
|
|
50
|
+
const measureEl = measureRef.current
|
|
51
|
+
if (!wrapper || !measureEl) return
|
|
52
|
+
|
|
53
|
+
const lineHeightPx = getLineHeightPx(wrapper)
|
|
54
|
+
setIsOverflowing(elementOverflowsLines(measureEl, lineHeightPx, MAX_LINES))
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const resizeObserver = useMemo(() => {
|
|
58
|
+
return new ResizeObserver((entries) => {
|
|
59
|
+
if (entries.some((entry) => entry.target === wrapperRef.current)) {
|
|
60
|
+
computeOverflow()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}, [computeOverflow])
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
if (wrapperRef.current) {
|
|
66
|
+
resizeObserver.observe(wrapperRef.current)
|
|
67
|
+
}
|
|
68
|
+
computeOverflow()
|
|
69
|
+
return () => resizeObserver.disconnect()
|
|
70
|
+
}, [children, resizeObserver, computeOverflow])
|
|
71
|
+
return (
|
|
72
|
+
<span
|
|
73
|
+
ref={wrapperRef}
|
|
74
|
+
{...props}
|
|
75
|
+
data-pkt-truncate-part="wrapper"
|
|
76
|
+
style={{
|
|
77
|
+
maxWidth: '100%',
|
|
78
|
+
width: '100%',
|
|
79
|
+
display: 'grid',
|
|
80
|
+
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
|
81
|
+
alignItems: 'end',
|
|
82
|
+
columnGap: '0.25rem',
|
|
83
|
+
overflow: 'hidden',
|
|
84
|
+
position: 'relative',
|
|
85
|
+
...props.style,
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<span
|
|
89
|
+
data-pkt-truncate-part="first"
|
|
90
|
+
style={{
|
|
91
|
+
minWidth: 0,
|
|
92
|
+
display: '-webkit-box',
|
|
93
|
+
WebkitBoxOrient: 'vertical',
|
|
94
|
+
WebkitLineClamp: MAX_LINES,
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
textOverflow: 'ellipsis',
|
|
97
|
+
whiteSpace: 'normal',
|
|
98
|
+
overflowWrap: 'anywhere',
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
{firstPart}
|
|
102
|
+
</span>
|
|
103
|
+
{tail !== 0 && (
|
|
104
|
+
<span
|
|
105
|
+
data-pkt-truncate-part="tail"
|
|
106
|
+
style={{
|
|
107
|
+
whiteSpace: 'nowrap',
|
|
108
|
+
display: isOverflowing ? 'inline' : 'none',
|
|
109
|
+
paddingRight: '0.5rem',
|
|
110
|
+
}}
|
|
111
|
+
aria-hidden={true}
|
|
112
|
+
>
|
|
113
|
+
{secondPart}
|
|
114
|
+
</span>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{/* Hidden measuring element (full text, wraps normally) */}
|
|
118
|
+
<span
|
|
119
|
+
ref={measureRef}
|
|
120
|
+
aria-hidden={true}
|
|
121
|
+
data-pkt-truncate-part="measure"
|
|
122
|
+
style={{
|
|
123
|
+
position: 'absolute',
|
|
124
|
+
top: 0,
|
|
125
|
+
left: 0,
|
|
126
|
+
width: '100%',
|
|
127
|
+
visibility: 'hidden',
|
|
128
|
+
pointerEvents: 'none',
|
|
129
|
+
whiteSpace: 'normal',
|
|
130
|
+
overflowWrap: 'anywhere',
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useContext, useLayoutEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { PktButton } from '../../button/Button'
|
|
4
|
+
import { PktIcon } from '../../icon/Icon'
|
|
5
|
+
import { TFileAttributes, FileItem, PktFileUploadContext, TQueueItemOperation } from '../types'
|
|
6
|
+
import { PktTextarea } from '../../textarea/Textarea'
|
|
7
|
+
|
|
8
|
+
const COMMENT_SYMBOL = Symbol('comment')
|
|
9
|
+
|
|
10
|
+
export interface IComment {
|
|
11
|
+
text: string
|
|
12
|
+
timestamp: Date
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const formatTimestamp = (date: Date): string => {
|
|
16
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
17
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
18
|
+
const year = date.getFullYear()
|
|
19
|
+
const hours = String(date.getHours()).padStart(2, '0')
|
|
20
|
+
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
21
|
+
return `${day}.${month}.${year} kl. ${hours}:${minutes}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AddComment = ({
|
|
25
|
+
fileItem,
|
|
26
|
+
closeOperationUi,
|
|
27
|
+
onAddComment,
|
|
28
|
+
existingComment,
|
|
29
|
+
}: {
|
|
30
|
+
fileItem: FileItem
|
|
31
|
+
closeOperationUi: () => void
|
|
32
|
+
onAddComment: (comment: IComment) => void
|
|
33
|
+
existingComment?: IComment
|
|
34
|
+
}) => {
|
|
35
|
+
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
36
|
+
const isEditing = !!existingComment
|
|
37
|
+
|
|
38
|
+
const handleAddComment = () => {
|
|
39
|
+
const text = inputRef.current?.value?.trim()
|
|
40
|
+
if (text) {
|
|
41
|
+
onAddComment({
|
|
42
|
+
text,
|
|
43
|
+
timestamp: new Date(),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
closeOperationUi()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useLayoutEffect(() => {
|
|
50
|
+
const t = setTimeout(() => inputRef.current?.focus({ preventScroll: true }), 0)
|
|
51
|
+
return () => clearTimeout(t)
|
|
52
|
+
}, [fileItem.fileId])
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<PktTextarea
|
|
57
|
+
autoFocus
|
|
58
|
+
label={isEditing ? 'Rediger kommentar' : 'Legg til kommentar'}
|
|
59
|
+
name={`comment-${fileItem.fileId}`}
|
|
60
|
+
className="pkt-fileupload__queue-display__item__comment-input"
|
|
61
|
+
placeholder="Skriv inn kommentar"
|
|
62
|
+
rows={2}
|
|
63
|
+
id={`comment-${fileItem.fileId}`}
|
|
64
|
+
ref={inputRef}
|
|
65
|
+
defaultValue={existingComment?.text}
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
<PktButton skin="secondary" size="small" onClick={handleAddComment}>
|
|
69
|
+
{isEditing ? 'Lagre kommentar' : 'Legg til kommentar'}
|
|
70
|
+
</PktButton>
|
|
71
|
+
<PktButton skin="tertiary" size="small" onClick={closeOperationUi}>
|
|
72
|
+
Avbryt
|
|
73
|
+
</PktButton>
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ShowComments = ({
|
|
79
|
+
comments,
|
|
80
|
+
onDeleteComment,
|
|
81
|
+
onEditComment,
|
|
82
|
+
}: {
|
|
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
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
) : null
|
|
125
|
+
|
|
126
|
+
const CommentHiddenInput = (props: { comments: IComment[] | undefined }) => {
|
|
127
|
+
const context = useContext(PktFileUploadContext)
|
|
128
|
+
return (
|
|
129
|
+
<input
|
|
130
|
+
type="hidden"
|
|
131
|
+
name={`${context.name}-comments`}
|
|
132
|
+
value={props.comments ? JSON.stringify(props.comments) : ''}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
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
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TFileId, FileItem, TQueueItemOperation } from '@/components/fileupload/types'
|
|
2
|
+
|
|
3
|
+
const DELETE_SYMBOL = Symbol('deleteFile')
|
|
4
|
+
|
|
5
|
+
export const removeFileOperation = (onFileRemoved: (fileId: TFileId) => void): TQueueItemOperation => ({
|
|
6
|
+
title: 'Slett',
|
|
7
|
+
ariaLabel: 'Slett fil',
|
|
8
|
+
onClick: (fileItem: FileItem) => onFileRemoved(fileItem.fileId),
|
|
9
|
+
symbol: DELETE_SYMBOL,
|
|
10
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useContext, useId, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { PktButton } from '../../button/Button'
|
|
4
|
+
import { TFileAttributes, FileItem, PktFileUploadContext, TQueueItemOperation } from '../types'
|
|
5
|
+
|
|
6
|
+
const RENAME_SYMBOL = Symbol('renameFile')
|
|
7
|
+
|
|
8
|
+
const RenameFile = (props: { value: string | undefined; onSave: (newName: string) => void; onCancel: () => void }) => {
|
|
9
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
10
|
+
const inputId = useId()
|
|
11
|
+
|
|
12
|
+
const save = () => {
|
|
13
|
+
props.onSave(inputRef.current?.value ?? '')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<label htmlFor={inputId} className="pkt-sr-only">
|
|
19
|
+
Endre filnavn
|
|
20
|
+
</label>
|
|
21
|
+
<input
|
|
22
|
+
id={inputId}
|
|
23
|
+
ref={inputRef}
|
|
24
|
+
type="text"
|
|
25
|
+
autoFocus
|
|
26
|
+
defaultValue={props.value}
|
|
27
|
+
className="pkt-fileupload__queue-display__item__rename-input"
|
|
28
|
+
onKeyDown={(e) => {
|
|
29
|
+
if (e.key === 'Enter') {
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
e.stopPropagation()
|
|
32
|
+
save()
|
|
33
|
+
}
|
|
34
|
+
if (e.key === 'Escape') {
|
|
35
|
+
e.preventDefault()
|
|
36
|
+
e.stopPropagation()
|
|
37
|
+
props.onCancel()
|
|
38
|
+
}
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
<PktButton skin="secondary" size="small" onClick={() => props.onSave(inputRef.current!.value)}>
|
|
42
|
+
Lagre
|
|
43
|
+
</PktButton>
|
|
44
|
+
<PktButton skin="tertiary" size="small" onClick={props.onCancel}>
|
|
45
|
+
Avbryt
|
|
46
|
+
</PktButton>
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const RenameHiddenInput = (props: { targetFilename: string }) => {
|
|
52
|
+
const context = useContext(PktFileUploadContext)
|
|
53
|
+
return <input type="hidden" name={`${context.name}-targetFilename`} value={props.targetFilename} />
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
import { TFileAttributes, TFileId, FileItem, TFileItemList } from '../types'
|
|
4
|
+
|
|
5
|
+
export const useFileAttributes = (
|
|
6
|
+
value: TFileItemList,
|
|
7
|
+
onFileUpdated: (TFileId: TFileId, updates: Partial<FileItem>) => void,
|
|
8
|
+
) => {
|
|
9
|
+
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,
|
|
16
|
+
}
|
|
17
|
+
onFileUpdated(TFileId, { attributes: fileItem.attributes })
|
|
18
|
+
},
|
|
19
|
+
[onFileUpdated, value],
|
|
20
|
+
)
|
|
21
|
+
|
|
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],
|
|
29
|
+
)
|
|
30
|
+
|
|
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
|
+
},
|
|
42
|
+
[getAttributeForFile, setAttributeForFile],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return { fileAttributes } as const
|
|
46
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { TFileAndTransfer, TFileId } from '../types'
|
|
4
|
+
|
|
5
|
+
export const useImagePreview = (previewableImages: TFileAndTransfer[]) => {
|
|
6
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
7
|
+
const [currentIndex, setCurrentIndex] = useState(0)
|
|
8
|
+
const modalRef = useRef<HTMLDialogElement>(null)
|
|
9
|
+
|
|
10
|
+
const open = useCallback(
|
|
11
|
+
(fileId: TFileId) => {
|
|
12
|
+
const index = previewableImages.findIndex((item) => item.fileId === fileId)
|
|
13
|
+
if (index >= 0) {
|
|
14
|
+
setCurrentIndex(index)
|
|
15
|
+
setIsOpen(true)
|
|
16
|
+
modalRef.current?.showModal()
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
[previewableImages],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const close = useCallback(() => {
|
|
23
|
+
setIsOpen(false)
|
|
24
|
+
modalRef.current?.close()
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
const navigate = useCallback(
|
|
28
|
+
(direction: 'prev' | 'next') => {
|
|
29
|
+
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
|
+
})
|
|
37
|
+
},
|
|
38
|
+
[previewableImages.length],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return { isOpen, currentIndex, modalRef, open, close, navigate }
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { TFileId, TQueueItemOperation } from '../types'
|
|
4
|
+
|
|
5
|
+
export const useOperationState = (queueItemOperations: TQueueItemOperation[]) => {
|
|
6
|
+
const [activatedSymbols, setActivatedSymbols] = useState<Record<TFileId, symbol>>({})
|
|
7
|
+
|
|
8
|
+
const getActivated = useCallback(
|
|
9
|
+
(fileId: TFileId): TQueueItemOperation | undefined => {
|
|
10
|
+
const symbol = activatedSymbols[fileId]
|
|
11
|
+
if (!symbol) return undefined
|
|
12
|
+
return queueItemOperations.find((op) => op.symbol === symbol)
|
|
13
|
+
},
|
|
14
|
+
[activatedSymbols, queueItemOperations],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const activate = useCallback((fileId: TFileId, operation: TQueueItemOperation) => {
|
|
18
|
+
setActivatedSymbols((prev) => ({ ...prev, [fileId]: operation.symbol }))
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
const close = useCallback((fileId: TFileId) => {
|
|
22
|
+
setActivatedSymbols((prev) => {
|
|
23
|
+
const updated = { ...prev }
|
|
24
|
+
delete updated[fileId]
|
|
25
|
+
return updated
|
|
26
|
+
})
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
return { getActivated, activate, close }
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const uiMultipleTexts = {
|
|
2
|
+
dropFilesHere: 'Slipp filene her ...',
|
|
3
|
+
selectOrDragFiles: 'Dra filer hit for å laste dem opp eller ',
|
|
4
|
+
chooseFiles: 'velg filer',
|
|
5
|
+
}
|
|
6
|
+
export const uiSingleTexts: typeof uiMultipleTexts = {
|
|
7
|
+
dropFilesHere: 'Slipp filen her ...',
|
|
8
|
+
selectOrDragFiles: 'Dra en fil for å laste den opp eller ',
|
|
9
|
+
chooseFiles: 'velg en fil',
|
|
10
|
+
}
|
|
11
|
+
export const uiThumbnailMultipleTexts: typeof uiMultipleTexts = {
|
|
12
|
+
dropFilesHere: 'Slipp bildene her ...',
|
|
13
|
+
selectOrDragFiles: 'Dra bilder hit for å laste dem opp eller ',
|
|
14
|
+
chooseFiles: 'velg fra kamerarull',
|
|
15
|
+
}
|
|
16
|
+
export const uiThumbnailSingleTexts: typeof uiMultipleTexts = {
|
|
17
|
+
dropFilesHere: 'Slipp bildet her ...',
|
|
18
|
+
selectOrDragFiles: 'Dra et bilde for å laste det opp eller ',
|
|
19
|
+
chooseFiles: 'velg fra kamerarull',
|
|
20
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createContext, ReactElement, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { uuidish } from '../../utils/stringutils'
|
|
4
|
+
|
|
5
|
+
/** Unique identifier assigned to each selected file. */
|
|
6
|
+
export type TFileId = string
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A selected file plus metadata used by the FileUpload UI.
|
|
10
|
+
*
|
|
11
|
+
* Notes:
|
|
12
|
+
* - A `fileId` is generated automatically if omitted.
|
|
13
|
+
* - `attributes.targetFilename` is the filename shown in the queue and may be updated by extensions (e.g. rename).
|
|
14
|
+
*/
|
|
15
|
+
export class FileItem implements FileItem {
|
|
16
|
+
fileId: TFileId
|
|
17
|
+
file: File
|
|
18
|
+
attributes: Record<string, any> = {}
|
|
19
|
+
|
|
20
|
+
constructor(file: File, fileId?: TFileId) {
|
|
21
|
+
this.fileId = fileId || uuidish()
|
|
22
|
+
this.file = file
|
|
23
|
+
this.attributes = {
|
|
24
|
+
targetFilename: file.name,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Internal context used by queue item operations and hidden inputs. */
|
|
30
|
+
export type TPktFileUploadContext = { name?: string; multiple: boolean; id?: string }
|
|
31
|
+
export const PktFileUploadContext = createContext<TPktFileUploadContext>({} as TPktFileUploadContext)
|
|
32
|
+
|
|
33
|
+
/** The value type for `PktFileUpload` (`value` / `defaultValue`). */
|
|
34
|
+
export type TFileItemList = Array<FileItem>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Transfer status for a file when using `uploadStrategy="custom"`.
|
|
38
|
+
*
|
|
39
|
+
* - `progress`: `0..1` during upload, or a state (`queued`, `done`, `error`, `canceled`)
|
|
40
|
+
* - `showProgress`: if false, UI uses indeterminate "Laster opp..." style instead of progress bar
|
|
41
|
+
* - `lastProgress`: used to render a "failed at X%" bar when an upload errors
|
|
42
|
+
*/
|
|
43
|
+
export type TFileTransfer = {
|
|
44
|
+
fileId: TFileId
|
|
45
|
+
progress: number | 'done' | 'error' | 'canceled' | 'queued' // i tilfelle number: 0-1
|
|
46
|
+
errorMessage?: string
|
|
47
|
+
showProgress?: boolean // true = show progress bar/file size, false = show "Laster opp..."/just error message
|
|
48
|
+
lastProgress?: number // Store progress value when error occurs (0-1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Upload mode: `form` posts the files on submit, `custom` posts file IDs and uploads separately. */
|
|
52
|
+
export type TUploadStrategy = 'custom' | 'form'
|
|
53
|
+
export type TFileAttribute<T> = {
|
|
54
|
+
get: (fileId: TFileId) => T | undefined
|
|
55
|
+
set: (fileId: TFileId, attributeValue: T | undefined) => void
|
|
56
|
+
}
|
|
57
|
+
export type TFileAttributes = <T>(attributeName: string) => TFileAttribute<T>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An operation that can be attached to a queue item (rename, comment, remove, etc).
|
|
61
|
+
*
|
|
62
|
+
* An operation may:
|
|
63
|
+
* - be a simple action (`onClick`)
|
|
64
|
+
* - render inline UI (e.g. rename)
|
|
65
|
+
* - render extended UI (e.g. comments)
|
|
66
|
+
* - render hidden inputs for form submission (`renderHidden`)
|
|
67
|
+
*/
|
|
68
|
+
export type TQueueItemOperation = {
|
|
69
|
+
title: string | ((fileItem: FileItem) => string)
|
|
70
|
+
ariaLabel?: string | ((fileItem: FileItem) => string)
|
|
71
|
+
onClick?: (transferItem: FileItem) => void
|
|
72
|
+
renderInlineUI?: (fileItem: FileItem, closeOperationUi: () => void) => ReactNode
|
|
73
|
+
renderExtendedUI?: (fileItem: FileItem, closeOperationUi: () => void) => ReactNode
|
|
74
|
+
renderContent?: (fileItem: FileItem, activateOperation?: () => void, isOperationActive?: boolean) => ReactNode
|
|
75
|
+
renderHidden?: (fileItem: FileItem) => ReactNode
|
|
76
|
+
symbol: symbol
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Factory that produces a queue item operation given access to file attributes. */
|
|
80
|
+
export type TQueueItemExtension = (attributes: TFileAttributes) => TQueueItemOperation
|
|
81
|
+
export type TFileAndTransfer = FileItem &
|
|
82
|
+
Pick<TFileTransfer, 'progress' | 'errorMessage' | 'showProgress' | 'lastProgress'>
|
|
83
|
+
export type TTransferItemInProgress = TFileAndTransfer & { progress: number }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Custom renderer for how a queue item is displayed (e.g. filename vs thumbnail grid).
|
|
87
|
+
*
|
|
88
|
+
* Must return a renderable React element (or `null`).
|
|
89
|
+
*/
|
|
90
|
+
export type TItemRenderer = (props: {
|
|
91
|
+
transferItem: TFileAndTransfer
|
|
92
|
+
queueItemOperations: Array<TQueueItemOperation>
|
|
93
|
+
onPreviewClick?: () => void
|
|
94
|
+
}) => ReactElement | null
|