@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
@@ -1,256 +1,114 @@
1
1
  import '@testing-library/jest-dom'
2
2
 
3
- import { act, render, screen, waitFor } from '@testing-library/react'
3
+ import { render, screen } from '@testing-library/react'
4
4
  import { axe, toHaveNoViolations } from 'jest-axe'
5
5
 
6
- import { Truncate } from './Truncate'
6
+ import { Truncate, TruncateContext } from './Truncate'
7
7
 
8
8
  expect.extend(toHaveNoViolations)
9
9
 
10
- describe('MiddleTruncate', () => {
11
- let resizeObserverCallback: ResizeObserverCallback
12
- let observedElements: Set<Element>
13
- let originalGetComputedStyle: typeof window.getComputedStyle
10
+ describe('Truncate', () => {
11
+ test('renders a short filename as a single span with no tail', () => {
12
+ const { container } = render(<Truncate tail={5}>file.txt</Truncate>)
14
13
 
15
- beforeEach(() => {
16
- observedElements = new Set()
14
+ const first = container.querySelector('[data-pkt-truncate-part="first"]')
15
+ const tail = container.querySelector('[data-pkt-truncate-part="tail"]')
17
16
 
18
- originalGetComputedStyle = window.getComputedStyle
19
- // Stable line-height/font-size for overflow calculations in tests
20
- window.getComputedStyle = ((...args: any[]) => {
21
- const style = originalGetComputedStyle(...(args as [Element]))
22
- // Keep CSSStyleDeclaration methods (like getPropertyValue) intact.
23
- return new Proxy(style, {
24
- get(target, prop) {
25
- if (prop === 'lineHeight') return '16px'
26
- return (target as any)[prop]
27
- },
28
- })
29
- }) as any
30
-
31
- // Mock ResizeObserver
32
- global.ResizeObserver = class ResizeObserver {
33
- constructor(callback: ResizeObserverCallback) {
34
- resizeObserverCallback = callback
35
- }
36
-
37
- observe(element: Element) {
38
- observedElements.add(element)
39
- }
40
-
41
- unobserve(element: Element) {
42
- observedElements.delete(element)
43
- }
44
-
45
- disconnect() {
46
- observedElements.clear()
47
- }
48
- } as any
49
- })
50
-
51
- afterEach(() => {
52
- observedElements.clear()
53
- window.getComputedStyle = originalGetComputedStyle
54
- })
55
-
56
- const triggerResize = (element: HTMLElement) => {
57
- resizeObserverCallback(
58
- [
59
- {
60
- target: element,
61
- contentRect: {} as DOMRectReadOnly,
62
- borderBoxSize: [] as ResizeObserverSize[],
63
- contentBoxSize: [] as ResizeObserverSize[],
64
- devicePixelContentBoxSize: [] as ResizeObserverSize[],
65
- },
66
- ],
67
- {} as ResizeObserver,
68
- )
69
- }
70
-
71
- test('renders text content correctly', () => {
72
- render(<Truncate>short-filename.txt</Truncate>)
73
-
74
- expect(
75
- screen.getByText(
76
- (content, element) =>
77
- content === 'short-filename.txt' && element?.getAttribute('data-pkt-truncate-part') === 'first',
78
- ),
79
- ).toBeInTheDocument()
80
- })
81
-
82
- test('renders with custom tail length', () => {
83
- const longText = 'very-long-filename-that-needs-truncation.txt'
84
- const { container } = render(<Truncate tail={3}>{longText}</Truncate>)
85
-
86
- // Check that the full text is in the first part
87
- const wrapper = container.querySelector('[data-pkt-truncate-part="wrapper"]')
88
- const firstPart = container.querySelector('[data-pkt-truncate-part="first"]')
89
- expect(wrapper).toBeTruthy()
90
- expect(firstPart).toBeTruthy()
91
- expect(firstPart).toHaveTextContent(longText)
92
- expect(wrapper as HTMLElement).toContainElement(firstPart as HTMLElement)
93
-
94
- // Check that the tail part contains the last 3 characters
95
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
96
- expect(tailSpan).toHaveTextContent('txt')
17
+ expect(first).toHaveTextContent('file.txt')
18
+ expect(tail).toBeNull() // no split when filename is not longer than tail + 3
97
19
  })
98
20
 
99
- test('uses default tail length of 5', () => {
100
- const longText = 'very-long-filename.txt'
101
- const { container } = render(<Truncate>{longText}</Truncate>)
102
-
103
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
104
- expect(tailSpan).toHaveTextContent('e.txt') // last 5 characters
105
- })
21
+ test('splits a long filename into head + tail spans that reconstruct the original', () => {
22
+ const name = 'very-long-filename-that-needs-truncation.txt'
23
+ const { container } = render(<Truncate tail={3}>{name}</Truncate>)
106
24
 
107
- test('does not show tail for very short strings', () => {
108
- const shortText = 'file.txt' // 8 characters, tail=5, so 5+3=8, no tail shown
109
- const { container } = render(<Truncate tail={5}>{shortText}</Truncate>)
25
+ const head = container.querySelector('[data-pkt-truncate-part="first"]')
26
+ const tail = container.querySelector('[data-pkt-truncate-part="tail"]')
110
27
 
111
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
112
- expect(tailSpan).toHaveTextContent('') // no tail content (secondPart is null)
28
+ expect(head).toHaveTextContent(name.slice(0, name.length - 3))
29
+ expect(tail).toHaveTextContent('txt')
30
+ expect((head?.textContent ?? '') + (tail?.textContent ?? '')).toBe(name)
113
31
  })
114
32
 
115
- test('tail is hidden when not overflowing', () => {
116
- const { container } = render(<Truncate>some-filename.txt</Truncate>)
117
-
118
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
119
-
120
- expect(tailSpan).toHaveStyle({ display: 'none' })
121
- })
122
-
123
- test('tail becomes visible when overflowing', async () => {
124
- const { container } = render(<Truncate>very-long-filename-that-overflows.txt</Truncate>)
125
-
126
- const wrapper = container.querySelector('[data-pkt-truncate-part="wrapper"]') as HTMLElement
127
- const measure = container.querySelector('[data-pkt-truncate-part="measure"]') as HTMLElement
128
- const tail = container.querySelector('[data-pkt-truncate-part="tail"]') as HTMLElement
129
- expect(wrapper).toBeTruthy()
130
- expect(measure).toBeTruthy()
131
- expect(tail).toBeTruthy()
132
-
133
- // Simulate > 2 lines (lineHeight=16px -> maxHeight=32px)
134
- measure.getBoundingClientRect = () => ({ height: 40 } as any)
135
-
136
- // Trigger overflow recalculation (ResizeObserver observes wrapper)
137
- act(() => triggerResize(wrapper))
138
-
139
- await waitFor(() => {
140
- expect(tail).toHaveStyle({ display: 'inline' })
141
- })
142
- })
143
-
144
- test('observes the wrapper element with ResizeObserver', () => {
145
- const { container } = render(<Truncate>filename.txt</Truncate>)
146
-
147
- const wrapper = container.querySelector('[data-pkt-truncate-part="wrapper"]')
148
- expect(observedElements.has(wrapper!)).toBe(true)
149
- })
150
-
151
- test('disconnects ResizeObserver on unmount', () => {
152
- const { unmount } = render(<Truncate>filename.txt</Truncate>)
153
-
154
- expect(observedElements.size).toBe(1)
155
-
156
- unmount()
33
+ test('defaults to a tail of 5 characters', () => {
34
+ const { container } = render(<Truncate>very-long-filename.txt</Truncate>)
157
35
 
158
- expect(observedElements.size).toBe(0)
36
+ const tail = container.querySelector('[data-pkt-truncate-part="tail"]')
37
+ expect(tail).toHaveTextContent('e.txt')
159
38
  })
160
39
 
161
- test('forwards additional props to wrapper span', () => {
162
- render(
163
- <Truncate className="custom-class" data-testid="test-truncate">
164
- filename.txt
165
- </Truncate>,
40
+ test('takes the tail prop over the context default', () => {
41
+ const { container } = render(
42
+ <TruncateContext.Provider value={{ tail: 10 }}>
43
+ <Truncate tail={3}>very-long-filename.txt</Truncate>
44
+ </TruncateContext.Provider>,
166
45
  )
167
46
 
168
- const wrapper = screen.getByTestId('test-truncate')
169
- expect(wrapper).toHaveClass('custom-class')
170
- expect(wrapper.tagName).toBe('SPAN')
47
+ const tail = container.querySelector('[data-pkt-truncate-part="tail"]')
48
+ expect(tail).toHaveTextContent('txt')
171
49
  })
172
50
 
173
- test('merges custom styles with default styles', () => {
51
+ test('uses the context tail when no prop is given', () => {
174
52
  const { container } = render(
175
- <Truncate style={{ color: 'rgb(255, 0, 0)', fontSize: '14px' }}>filename.txt</Truncate>,
53
+ <TruncateContext.Provider value={{ tail: 4 }}>
54
+ <Truncate>very-long-filename.txt</Truncate>
55
+ </TruncateContext.Provider>,
176
56
  )
177
57
 
178
- const wrapper = container.firstChild as HTMLElement
179
- expect(wrapper).toHaveStyle({
180
- display: 'grid',
181
- color: 'rgb(255, 0, 0)',
182
- fontSize: '14px',
183
- })
58
+ const tail = container.querySelector('[data-pkt-truncate-part="tail"]')
59
+ expect(tail).toHaveTextContent('.txt')
184
60
  })
185
61
 
186
- test('tail span has aria-hidden attribute', () => {
187
- const { container } = render(<Truncate>very-long-filename.txt</Truncate>)
188
-
189
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
190
- expect(tailSpan).toHaveAttribute('aria-hidden', 'true')
191
- })
62
+ test('does not split when the filename is exactly `tail + 3` characters', () => {
63
+ const { container } = render(<Truncate tail={5}>{'12345678'}</Truncate>)
192
64
 
193
- test('applies correct clamp styles to first part', () => {
194
- const { container } = render(<Truncate>filename.txt</Truncate>)
65
+ const head = container.querySelector('[data-pkt-truncate-part="first"]')
66
+ const tail = container.querySelector('[data-pkt-truncate-part="tail"]')
195
67
 
196
- const firstPart = container.querySelector('[data-pkt-truncate-part="first"]')
197
- expect(firstPart).toHaveStyle({
198
- display: '-webkit-box',
199
- overflow: 'hidden',
200
- textOverflow: 'ellipsis',
201
- whiteSpace: 'normal',
202
- overflowWrap: 'anywhere',
203
- })
204
- expect(firstPart).toHaveStyle('-webkit-line-clamp: 2')
68
+ expect(head).toHaveTextContent('12345678')
69
+ expect(tail).toBeNull()
205
70
  })
206
71
 
207
- test('applies correct styles to tail span', () => {
208
- const { container } = render(<Truncate>very-long-filename.txt</Truncate>)
72
+ test('does not split when tail is 0 or undefined', () => {
73
+ const { container: zero } = render(<Truncate tail={0}>very-long-filename.txt</Truncate>)
74
+ expect(zero.querySelector('[data-pkt-truncate-part="tail"]')).toBeNull()
209
75
 
210
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
211
- expect(tailSpan).toHaveStyle({
212
- whiteSpace: 'nowrap',
213
- paddingRight: '0.5rem',
214
- })
76
+ const { container: none } = render(
77
+ <TruncateContext.Provider value={{ tail: undefined }}>
78
+ <Truncate>very-long-filename.txt</Truncate>
79
+ </TruncateContext.Provider>,
80
+ )
81
+ expect(none.querySelector('[data-pkt-truncate-part="tail"]')).toBeNull()
215
82
  })
216
83
 
217
- test('handles empty string', () => {
84
+ test('handles an empty string', () => {
218
85
  const { container } = render(<Truncate>{''}</Truncate>)
219
86
 
220
- const firstPart = container.querySelector('[data-pkt-truncate-part="first"]')
221
- expect(firstPart).toHaveTextContent('')
222
- })
223
-
224
- test('handles string exactly equal to tail + 3', () => {
225
- const text = '12345678' // 8 characters, tail=5, so 5+3=8
226
- const { container } = render(<Truncate tail={5}>{text}</Truncate>)
227
-
228
- const tailSpan = container.querySelector('[data-pkt-truncate-part="tail"]')
229
- expect(tailSpan).toHaveTextContent('') // secondPart should be null
87
+ const first = container.querySelector('[data-pkt-truncate-part="first"]')
88
+ expect(first).toHaveTextContent('')
89
+ expect(container.querySelector('[data-pkt-truncate-part="tail"]')).toBeNull()
230
90
  })
231
91
 
232
92
  describe('accessibility', () => {
233
- test('renders with no wcag errors', async () => {
234
- const { container } = render(<Truncate>filename-with-accessibility.txt</Truncate>)
93
+ test('has no wcag violations for a short filename', async () => {
94
+ const { container } = render(<Truncate>filename.txt</Truncate>)
235
95
  const results = await axe(container)
236
-
237
96
  expect(results).toHaveNoViolations()
238
97
  })
239
98
 
240
- test('renders with no wcag errors when overflowing', async () => {
99
+ test('has no wcag violations for a split filename — both head and tail are readable text', async () => {
241
100
  const { container } = render(
242
- <Truncate>very-long-filename-that-definitely-overflows-the-container.txt</Truncate>,
101
+ <Truncate>very-long-filename-that-definitely-needs-truncation.txt</Truncate>,
243
102
  )
103
+ const results = await axe(container)
104
+ expect(results).toHaveNoViolations()
244
105
 
245
- const wrapper = container.querySelector('[data-pkt-truncate-part="wrapper"]') as HTMLElement
246
- const measure = container.querySelector('[data-pkt-truncate-part="measure"]') as HTMLElement
247
- measure.getBoundingClientRect = () => ({ height: 40 } as any)
248
- act(() => triggerResize(wrapper))
249
-
250
- await waitFor(async () => {
251
- const results = await axe(container)
252
- expect(results).toHaveNoViolations()
253
- })
106
+ // head + tail together equal the original, so screen readers read the full name.
107
+ const head = screen.getByText(/very-long-filename-that-definitely-needs-trunc/)
108
+ const tail = screen.getByText('n.txt')
109
+ expect((head.textContent ?? '') + (tail.textContent ?? '')).toBe(
110
+ 'very-long-filename-that-definitely-needs-truncation.txt',
111
+ )
254
112
  })
255
113
  })
256
114
  })
@@ -1,137 +1,41 @@
1
- import {
2
- createContext,
3
- HTMLAttributes,
4
- useCallback,
5
- useContext,
6
- useLayoutEffect,
7
- useMemo,
8
- useRef,
9
- useState,
10
- } from 'react'
1
+ import { createContext, useContext } from 'react'
11
2
 
12
- const MAX_LINES = 2
3
+ import { splitFilenameForTruncation } from 'shared-utils/fileupload'
13
4
 
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> {
5
+ export interface IMiddleTruncate {
31
6
  children: string
7
+ /** Trailing chars to preserve when the name is long enough to split. */
32
8
  tail?: number
33
9
  }
34
10
 
35
11
  type TTruncateContext = { tail: number | undefined }
36
12
  export const TruncateContext = createContext<TTruncateContext>({ tail: 5 })
37
13
 
38
- export const Truncate = ({ children, tail: tailProp, ...props }: IMiddleTruncate): JSX.Element => {
39
- const firstPart = children
14
+ /**
15
+ * Middle-truncate a filename. Renders as a React fragment — the parent element
16
+ * (typically a `<p className="…__title">`) is the grid container that drives
17
+ * the layout via CSS in `_fileupload.scss`. The split threshold is shared with
18
+ * the Lit implementation through `splitFilenameForTruncation`.
19
+ *
20
+ * For short filenames the tail span is omitted and the text renders unwrapped.
21
+ */
22
+ export const Truncate = ({ children, tail: tailProp }: IMiddleTruncate): JSX.Element => {
40
23
  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
24
+ const tail = tailProp !== undefined ? tailProp : context.tail
25
+ const split = splitFilenameForTruncation(children, tail)
52
26
 
53
- const lineHeightPx = getLineHeightPx(wrapper)
54
- setIsOverflowing(elementOverflowsLines(measureEl, lineHeightPx, MAX_LINES))
55
- }, [])
27
+ if (!split) {
28
+ return <span data-pkt-truncate-part="first">{children}</span>
29
+ }
56
30
 
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
31
  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}
32
+ <>
33
+ <span className="pkt-fileupload__queue-display__item__title__head" data-pkt-truncate-part="first">
34
+ {split.head}
102
35
  </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}
36
+ <span className="pkt-fileupload__queue-display__item__title__tail" data-pkt-truncate-part="tail">
37
+ {split.tail}
134
38
  </span>
135
- </span>
39
+ </>
136
40
  )
137
41
  }