@qoretechnologies/reqraft 0.10.11 → 0.10.13-pr.74.g6bfe1fd

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 (119) hide show
  1. package/.claude/CLAUDE.md +11 -8
  2. package/dist/components/composer/Composer.d.ts +108 -0
  3. package/dist/components/composer/Composer.d.ts.map +1 -0
  4. package/dist/components/composer/Composer.js +238 -0
  5. package/dist/components/composer/Composer.js.map +1 -0
  6. package/dist/components/composer/helpers.d.ts +21 -0
  7. package/dist/components/composer/helpers.d.ts.map +1 -0
  8. package/dist/components/composer/helpers.js +75 -0
  9. package/dist/components/composer/helpers.js.map +1 -0
  10. package/dist/components/composer/index.d.ts +3 -0
  11. package/dist/components/composer/index.d.ts.map +1 -0
  12. package/dist/components/composer/index.js +19 -0
  13. package/dist/components/composer/index.js.map +1 -0
  14. package/dist/components/form/engine/FormEngine.d.ts +1 -1
  15. package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
  16. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
  17. package/dist/components/imageLightbox/ImageLightbox.js +169 -0
  18. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
  19. package/dist/components/imageLightbox/index.d.ts +2 -0
  20. package/dist/components/imageLightbox/index.d.ts.map +1 -0
  21. package/dist/components/imageLightbox/index.js +18 -0
  22. package/dist/components/imageLightbox/index.js.map +1 -0
  23. package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
  24. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  25. package/dist/components/supportTicket/AttachmentChips.js +122 -0
  26. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  27. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +35 -0
  28. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  29. package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
  30. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  31. package/dist/components/supportTicket/ReferencePicker.d.ts +41 -0
  32. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
  33. package/dist/components/supportTicket/ReferencePicker.js +86 -0
  34. package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
  35. package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
  36. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
  37. package/dist/components/supportTicket/TicketHeader.js +149 -0
  38. package/dist/components/supportTicket/TicketHeader.js.map +1 -0
  39. package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
  40. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  41. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  42. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  43. package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
  44. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  45. package/dist/components/supportTicket/TicketReplyBox.js +115 -0
  46. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  47. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  48. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  49. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  50. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  51. package/dist/components/supportTicket/index.d.ts +9 -0
  52. package/dist/components/supportTicket/index.d.ts.map +1 -0
  53. package/dist/components/supportTicket/index.js +25 -0
  54. package/dist/components/supportTicket/index.js.map +1 -0
  55. package/dist/components/supportTicket/meta.d.ts +63 -0
  56. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  57. package/dist/components/supportTicket/meta.js +79 -0
  58. package/dist/components/supportTicket/meta.js.map +1 -0
  59. package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
  60. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
  61. package/dist/components/ticketReferences/TicketReferences.js +461 -0
  62. package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
  63. package/dist/components/ticketReferences/index.d.ts +3 -0
  64. package/dist/components/ticketReferences/index.d.ts.map +1 -0
  65. package/dist/components/ticketReferences/index.js +6 -0
  66. package/dist/components/ticketReferences/index.js.map +1 -0
  67. package/dist/components/ticketThread/TicketThread.d.ts +57 -0
  68. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  69. package/dist/components/ticketThread/TicketThread.js +282 -0
  70. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  71. package/dist/components/ticketThread/index.d.ts +3 -0
  72. package/dist/components/ticketThread/index.d.ts.map +1 -0
  73. package/dist/components/ticketThread/index.js +7 -0
  74. package/dist/components/ticketThread/index.js.map +1 -0
  75. package/dist/helpers/common.d.ts +5 -0
  76. package/dist/helpers/common.d.ts.map +1 -1
  77. package/dist/helpers/common.js +20 -1
  78. package/dist/helpers/common.js.map +1 -1
  79. package/dist/helpers/scrollbar.d.ts +2 -0
  80. package/dist/helpers/scrollbar.d.ts.map +1 -0
  81. package/dist/helpers/scrollbar.js +25 -0
  82. package/dist/helpers/scrollbar.js.map +1 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +7 -1
  86. package/dist/index.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/components/composer/Composer.stories.tsx +238 -0
  89. package/src/components/composer/Composer.tsx +545 -0
  90. package/src/components/composer/helpers.ts +74 -0
  91. package/src/components/composer/index.ts +2 -0
  92. package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
  93. package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
  94. package/src/components/imageLightbox/index.ts +1 -0
  95. package/src/components/supportTicket/AttachmentChips.stories.tsx +163 -0
  96. package/src/components/supportTicket/AttachmentChips.tsx +147 -0
  97. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +134 -0
  98. package/src/components/supportTicket/InterfaceReferenceTags.tsx +85 -0
  99. package/src/components/supportTicket/ReferencePicker.stories.tsx +296 -0
  100. package/src/components/supportTicket/ReferencePicker.tsx +237 -0
  101. package/src/components/supportTicket/TicketConversation.stories.tsx +122 -0
  102. package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
  103. package/src/components/supportTicket/TicketHeader.tsx +247 -0
  104. package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
  105. package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
  106. package/src/components/supportTicket/TicketReplyBox.stories.tsx +355 -0
  107. package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
  108. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  109. package/src/components/supportTicket/index.ts +8 -0
  110. package/src/components/supportTicket/meta.ts +145 -0
  111. package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
  112. package/src/components/ticketReferences/TicketReferences.tsx +907 -0
  113. package/src/components/ticketReferences/index.ts +2 -0
  114. package/src/components/ticketThread/TicketThread.stories.tsx +806 -0
  115. package/src/components/ticketThread/TicketThread.tsx +684 -0
  116. package/src/components/ticketThread/index.ts +6 -0
  117. package/src/helpers/common.ts +19 -0
  118. package/src/helpers/scrollbar.ts +32 -0
  119. package/src/index.tsx +5 -0
@@ -0,0 +1,238 @@
1
+ import { StoryObj } from '@storybook/react-vite';
2
+ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
+ import { useEffect, useState } from 'react';
4
+ import { StoryMeta } from '../../types';
5
+ import { IImageLightboxImage, ImageLightbox } from './ImageLightbox';
6
+
7
+ /**
8
+ * The shared full-image viewer, on `ReqoreModal`. Opened by the ticket thread (an image
9
+ * on a message) and the References tab (the screenshot grid) alike. Pages across a set,
10
+ * click-to-zoom, download; the shell/backdrop/Esc are the modal's.
11
+ */
12
+ const meta = {
13
+ component: ImageLightbox,
14
+ title: 'Components/ImageLightbox',
15
+ } as StoryMeta<typeof ImageLightbox>;
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+
19
+ const shot = (label: string, hue: number): string =>
20
+ `data:image/svg+xml,${encodeURIComponent(
21
+ `<svg xmlns='http://www.w3.org/2000/svg' width='640' height='400'>` +
22
+ `<rect width='640' height='400' fill='hsl(${hue},38%,16%)'/>` +
23
+ `<text x='24' y='210' fill='white' font-family='sans-serif' font-size='28' opacity='0.7'>${label}</text>` +
24
+ `</svg>`
25
+ )}`;
26
+
27
+ const IMAGES: IImageLightboxImage[] = [
28
+ { attachment_id: 'i1', filename: 'canvas-error.png' },
29
+ { attachment_id: 'i2', filename: 'console.png' },
30
+ { attachment_id: 'i3', filename: 'network.png' },
31
+ ];
32
+
33
+ /** Open on a set of three, so the paging chrome (`1 / 3`, prev/next) shows. */
34
+ export const Paging: Story = {
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ story:
39
+ "Renders the lightbox open on a set of three images, so the paging chrome ('1 / 3', prev/next) shows, and arrow keys page through the set.",
40
+ },
41
+ },
42
+ },
43
+ render: function Render() {
44
+ const [index, setIndex] = useState<number | null>(0);
45
+ if (index === null) {
46
+ return <div>closed</div>;
47
+ }
48
+ return (
49
+ <ImageLightbox
50
+ images={IMAGES}
51
+ index={index}
52
+ onIndex={setIndex}
53
+ onClose={() => setIndex(null)}
54
+ fetchAttachmentUrl={async (id) => shot(id, id === 'i1' ? 265 : id === 'i2' ? 205 : 150)}
55
+ onDownload={fn()}
56
+ />
57
+ );
58
+ },
59
+ async play() {
60
+ // the modal portals to the body, so query document-wide
61
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
62
+ const dialog = within(document.body);
63
+ await expect(await dialog.findByText('canvas-error.png')).toBeInTheDocument();
64
+ await expect(dialog.getByText('1 / 3')).toBeInTheDocument();
65
+ // page forward via the keyboard (the action buttons are icon-only)
66
+ await userEvent.keyboard('{ArrowRight}');
67
+ await waitFor(() => expect(dialog.getByText('2 / 3')).toBeInTheDocument());
68
+ },
69
+ };
70
+
71
+ /**
72
+ * Regression guard for the "screenshot preview stuck loading" bug.
73
+ *
74
+ * Consumers pass `fetchAttachmentUrl` as an inline arrow — a new identity every render —
75
+ * and re-render for reasons unrelated to the lightbox. Here the parent ticks on a timer
76
+ * (handing a fresh fetcher each render) while the fetch is deliberately slow, so those
77
+ * re-renders land WHILE the image is still loading. When the fetch effect depended on
78
+ * `fetchAttachmentUrl`, each re-render tore down the in-flight fetch and the `requested`
79
+ * guard blocked a retry — the spinner hung forever. The image resolving proves the effect
80
+ * now depends on `id` alone. Removing that fix makes this story time out.
81
+ */
82
+ export const ResolvesDespiteParentReRenders: Story = {
83
+ parameters: {
84
+ docs: {
85
+ description: {
86
+ story:
87
+ 'Renders the lightbox while the parent re-renders on a timer with a deliberately slow fetch — a regression guard proving the image still resolves instead of the spinner hanging forever.',
88
+ },
89
+ },
90
+ },
91
+ render: function Render() {
92
+ const [index, setIndex] = useState<number | null>(0);
93
+ const [, setTick] = useState(0);
94
+ useEffect(() => {
95
+ const timer = setInterval(() => setTick((n) => n + 1), 30);
96
+ return () => clearInterval(timer);
97
+ }, []);
98
+ if (index === null) {
99
+ return <div>closed</div>;
100
+ }
101
+ return (
102
+ <ImageLightbox
103
+ images={[IMAGES[0]]}
104
+ index={index}
105
+ onIndex={setIndex}
106
+ onClose={() => setIndex(null)}
107
+ // a brand-new arrow every render, and a slow fetch so the re-renders overlap it
108
+ fetchAttachmentUrl={async (id) => {
109
+ await new Promise((resolve) => setTimeout(resolve, 120));
110
+ return shot(id, 265);
111
+ }}
112
+ onDownload={fn()}
113
+ />
114
+ );
115
+ },
116
+ async play() {
117
+ await expect(await within(document.body).findByText('canvas-error.png')).toBeInTheDocument();
118
+ // the image resolves and paints — it never would while the spinner was hung
119
+ await waitFor(
120
+ () => expect(document.body.querySelector('img[alt="canvas-error.png"]')).not.toBeNull(),
121
+ { timeout: 3000 }
122
+ );
123
+ },
124
+ };
125
+
126
+ /**
127
+ * Renaming from inside the preview. The pencil (only offered when `onRename` is wired)
128
+ * swaps in an input row; Enter commits through the host, which owns the actual rename —
129
+ * here the harness updates its image list, so the modal header shows the new name.
130
+ * Esc cancels the edit WITHOUT closing the modal. Discriminating: remove the
131
+ * `onRename` plumbing and the pencil never renders / the title never changes.
132
+ */
133
+ export const Renamable: Story = {
134
+ parameters: {
135
+ docs: {
136
+ description: {
137
+ story:
138
+ 'Renders the lightbox with onRename wired — the header pencil opens an inline input; Enter commits the new name through the host and Esc cancels without closing the modal.',
139
+ },
140
+ },
141
+ },
142
+ render: function Render() {
143
+ const [index, setIndex] = useState<number | null>(0);
144
+ const [images, setImages] = useState<IImageLightboxImage[]>([IMAGES[0]]);
145
+ if (index === null) {
146
+ return <div>closed</div>;
147
+ }
148
+ return (
149
+ <ImageLightbox
150
+ images={images}
151
+ index={index}
152
+ onIndex={setIndex}
153
+ onClose={() => setIndex(null)}
154
+ fetchAttachmentUrl={async (id) => shot(id, 265)}
155
+ onRename={(id, name) =>
156
+ setImages((current) =>
157
+ current.map((img) => (img.attachment_id === id ? { ...img, filename: name } : img))
158
+ )
159
+ }
160
+ />
161
+ );
162
+ },
163
+ async play() {
164
+ const dialog = within(document.body);
165
+ await expect(await dialog.findByText('canvas-error.png')).toBeInTheDocument();
166
+
167
+ const pencil = await waitFor(() => {
168
+ const el = document.body.querySelector<HTMLButtonElement>('.imagelightbox-rename');
169
+ if (!el) {
170
+ throw new Error('rename action not rendered');
171
+ }
172
+ return el;
173
+ });
174
+ await userEvent.click(pencil);
175
+
176
+ // Esc cancels the edit but keeps the modal open
177
+ const input = await waitFor(() => {
178
+ const el = document.body.querySelector<HTMLInputElement>('.reqore-modal input');
179
+ if (!el) {
180
+ throw new Error('rename input not shown');
181
+ }
182
+ return el;
183
+ });
184
+ await expect(input).toHaveValue('canvas-error.png'); // pre-filled with the current name
185
+ await userEvent.keyboard('{Escape}');
186
+ await waitFor(() =>
187
+ expect(document.body.querySelector('.reqore-modal input')).toBeNull()
188
+ );
189
+ await expect(document.querySelector('[role="dialog"]')).not.toBeNull();
190
+
191
+ // now rename for real: pencil → clear → type → Enter → header carries the new name
192
+ await userEvent.click(document.body.querySelector<HTMLButtonElement>('.imagelightbox-rename')!);
193
+ const input2 = await waitFor(() => {
194
+ const el = document.body.querySelector<HTMLInputElement>('.reqore-modal input');
195
+ if (!el) {
196
+ throw new Error('rename input not shown (second open)');
197
+ }
198
+ return el;
199
+ });
200
+ await userEvent.clear(input2);
201
+ await userEvent.type(input2, 'mapper-crash.png');
202
+ await userEvent.keyboard('{Enter}');
203
+ await waitFor(() => expect(dialog.getByText('mapper-crash.png')).toBeInTheDocument());
204
+ await expect(dialog.queryByText('canvas-error.png')).not.toBeInTheDocument();
205
+ },
206
+ };
207
+
208
+ /** A single image: no paging chrome. */
209
+ export const Single: Story = {
210
+ parameters: {
211
+ docs: {
212
+ description: {
213
+ story: 'Renders the lightbox on a single image, so no paging chrome appears.',
214
+ },
215
+ },
216
+ },
217
+ render: function Render() {
218
+ const [index, setIndex] = useState<number | null>(0);
219
+ if (index === null) {
220
+ return <div>closed</div>;
221
+ }
222
+ return (
223
+ <ImageLightbox
224
+ images={[IMAGES[0]]}
225
+ index={index}
226
+ onIndex={setIndex}
227
+ onClose={() => setIndex(null)}
228
+ fetchAttachmentUrl={async (id) => shot(id, 265)}
229
+ onDownload={fn()}
230
+ />
231
+ );
232
+ },
233
+ async play() {
234
+ const dialog = within(document.body);
235
+ await expect(await dialog.findByText('canvas-error.png')).toBeInTheDocument();
236
+ await expect(dialog.queryByText('1 / 1')).not.toBeInTheDocument();
237
+ },
238
+ };
@@ -0,0 +1,236 @@
1
+ import {
2
+ ReqoreInput,
3
+ ReqoreModal,
4
+ ReqoreSpan,
5
+ ReqoreSpinner,
6
+ useLatestZIndex,
7
+ } from '@qoretechnologies/reqore';
8
+ import { useCallback, useEffect, useRef, useState } from 'react';
9
+ import styled from 'styled-components';
10
+
11
+ /*
12
+ * The shared full-image viewer: a `ReqoreModal` holding one image at a time, with
13
+ * click-to-zoom, keyboard/arrow paging across a set, and a download action. Both the
14
+ * ticket thread (an image attachment on a message) and the References tab (the ticket's
15
+ * screenshot grid) open this, so there is one lightbox rather than a Modal-backed one
16
+ * here and a hand-rolled overlay there.
17
+ *
18
+ * reqore has no image-viewer primitive, so the zoom stage below is bespoke — but the
19
+ * shell, backdrop, focus trap, Esc-to-close and header/actions are all `ReqoreModal`.
20
+ * When reqore gains a real viewer this collapses onto it. It fetches the shown image's
21
+ * bytes itself (caching per id), so a consumer only has to hand it `fetchAttachmentUrl`.
22
+ */
23
+
24
+ // Compact rename row above the stage while editing — the modal's `label` slot is
25
+ // string-only, so the input can't live in the header itself.
26
+ const RenameRow = styled.div`
27
+ margin-bottom: 8px;
28
+ `;
29
+
30
+ const Stage = styled.div<{ $zoom: boolean }>`
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ min-height: 40vh;
35
+ max-height: 74vh;
36
+ overflow: auto;
37
+ background: rgba(0, 0, 0, 0.25);
38
+ border-radius: 8px;
39
+
40
+ img {
41
+ display: block;
42
+ cursor: ${({ $zoom }) => ($zoom ? 'zoom-out' : 'zoom-in')};
43
+ ${({ $zoom }) =>
44
+ $zoom
45
+ ? 'max-width: none; max-height: none;'
46
+ : 'max-width: 100%; max-height: 74vh; object-fit: contain;'}
47
+ }
48
+ `;
49
+
50
+ export interface IImageLightboxImage {
51
+ attachment_id: string;
52
+ filename: string;
53
+ }
54
+
55
+ export interface IImageLightboxProps {
56
+ /** the set the viewer pages across — one image is a set of one (no paging chrome) */
57
+ images: IImageLightboxImage[];
58
+ /** which image is shown */
59
+ index: number;
60
+ onIndex: (index: number) => void;
61
+ onClose: () => void;
62
+ /** resolve an image's bytes as an object URL (the consumer's authed endpoint) */
63
+ fetchAttachmentUrl: (attachmentId: string) => Promise<string>;
64
+ onDownload?: (attachmentId: string, filename: string) => void;
65
+ /** Rename the shown image. Presence of the callback IS the permission — a staged
66
+ * (composer) set passes it, server-side attachment sets simply don't, so the
67
+ * thread / References viewers stay rename-free without any extra flag. */
68
+ onRename?: (attachmentId: string, newName: string) => void;
69
+ }
70
+
71
+ export const ImageLightbox = ({
72
+ images,
73
+ index,
74
+ onIndex,
75
+ onClose,
76
+ fetchAttachmentUrl,
77
+ onDownload,
78
+ onRename,
79
+ }: IImageLightboxProps) => {
80
+ const [zoom, setZoom] = useState<boolean>(false);
81
+ // inline rename state: editing toggles the input row; draftName is the working value
82
+ const [editing, setEditing] = useState<boolean>(false);
83
+ const [draftName, setDraftName] = useState<string>('');
84
+ // url per attachment: undefined = not fetched, null = fetch failed, string = ready
85
+ const [urls, setUrls] = useState<Record<string, string | null>>({});
86
+ const requested = useRef<Set<string>>(new Set());
87
+ // Render above everything currently on screen: the ticket composer that can open this
88
+ // lightbox floats at its own climbed z-index (also useLatestZIndex), so a modal on a fixed
89
+ // z-index ends up BEHIND it. Climbing past the current top keeps the viewer on top wherever
90
+ // it's opened from.
91
+ const zIndex = useLatestZIndex();
92
+
93
+ const image = images[index];
94
+ const id = image?.attachment_id;
95
+ const url = id ? urls[id] : undefined;
96
+ const multiple = images.length > 1;
97
+
98
+ // Keep the latest fetcher in a ref so the fetch effect can depend on `id` ALONE.
99
+ // Consumers routinely pass `fetchAttachmentUrl` as an inline arrow — a new identity
100
+ // every render. If the effect depended on it, each parent re-render would re-run the
101
+ // effect: the previous run's cleanup fired first, so its in-flight fetch resolved with
102
+ // nothing to write to, and because `requested` already held the id nothing re-fetched.
103
+ // Net result: the spinner hung forever. Depending on `id` only breaks that cycle.
104
+ const fetchRef = useRef(fetchAttachmentUrl);
105
+ fetchRef.current = fetchAttachmentUrl;
106
+ // True only while mounted: a fetch that resolves after unmount is dropped, but one that
107
+ // resolves after a benign re-render still caches — the distinction a per-run flag missed.
108
+ const mounted = useRef(true);
109
+ useEffect(
110
+ () => () => {
111
+ mounted.current = false;
112
+ },
113
+ []
114
+ );
115
+
116
+ // fetch the shown image's bytes once per id; `requested` guards against re-fetching a
117
+ // page you've already visited
118
+ useEffect(() => {
119
+ if (!id || requested.current.has(id)) {
120
+ return undefined;
121
+ }
122
+ requested.current.add(id);
123
+ fetchRef.current(id)
124
+ .then((resolved) => mounted.current && setUrls((current) => ({ ...current, [id]: resolved })))
125
+ .catch(() => mounted.current && setUrls((current) => ({ ...current, [id]: null })));
126
+ return undefined;
127
+ }, [id]);
128
+
129
+ const step = useCallback(
130
+ (delta: number) => {
131
+ setZoom(false);
132
+ // paging abandons an in-flight rename — the draft belonged to the image left behind
133
+ setEditing(false);
134
+ onIndex((index + delta + images.length) % images.length);
135
+ },
136
+ [index, images.length, onIndex]
137
+ );
138
+
139
+ useEffect(() => {
140
+ if (!multiple || editing) {
141
+ // while renaming, arrow keys belong to the text input, not the pager
142
+ return undefined;
143
+ }
144
+ const handler = (event: KeyboardEvent) => {
145
+ if (event.key === 'ArrowRight') {
146
+ step(1);
147
+ } else if (event.key === 'ArrowLeft') {
148
+ step(-1);
149
+ }
150
+ };
151
+ window.addEventListener('keydown', handler);
152
+ return () => window.removeEventListener('keydown', handler);
153
+ }, [step, multiple, editing]);
154
+
155
+ const startRename = useCallback(() => {
156
+ setDraftName(images[index]?.filename ?? '');
157
+ setEditing(true);
158
+ }, [images, index]);
159
+
160
+ const commitRename = useCallback(() => {
161
+ const current = images[index];
162
+ const name = draftName.trim();
163
+ if (onRename && current && name && name !== current.filename) {
164
+ onRename(current.attachment_id, name);
165
+ }
166
+ setEditing(false);
167
+ }, [draftName, images, index, onRename]);
168
+
169
+ if (!image) {
170
+ return null;
171
+ }
172
+
173
+ return (
174
+ <ReqoreModal
175
+ isOpen
176
+ onClose={onClose}
177
+ customZIndex={zIndex}
178
+ blur={3}
179
+ icon='ImageLine'
180
+ label={image.filename}
181
+ badge={multiple ? `${index + 1} / ${images.length}` : undefined}
182
+ actions={[
183
+ { icon: 'ArrowLeftSLine', tooltip: 'Previous', onClick: () => step(-1), show: multiple },
184
+ { icon: 'ArrowRightSLine', tooltip: 'Next', onClick: () => step(1), show: multiple },
185
+ {
186
+ icon: 'Edit2Line',
187
+ tooltip: 'Rename',
188
+ className: 'imagelightbox-rename',
189
+ onClick: startRename,
190
+ show: !!onRename && !editing,
191
+ },
192
+ {
193
+ icon: 'DownloadLine',
194
+ tooltip: 'Download',
195
+ onClick: () => onDownload?.(image.attachment_id, image.filename),
196
+ show: !!onDownload,
197
+ },
198
+ ]}
199
+ >
200
+ {editing ? (
201
+ <RenameRow>
202
+ <ReqoreInput
203
+ value={draftName}
204
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
205
+ setDraftName(event.target.value)
206
+ }
207
+ onBlur={commitRename}
208
+ onKeyDown={(event: React.KeyboardEvent) => {
209
+ if (event.key === 'Enter') {
210
+ commitRename();
211
+ } else if (event.key === 'Escape') {
212
+ // cancel the edit only — don't let the modal's own Esc-to-close see it
213
+ event.stopPropagation();
214
+ setEditing(false);
215
+ }
216
+ }}
217
+ size='small'
218
+ fluid
219
+ autoFocus
220
+ />
221
+ </RenameRow>
222
+ ) : null}
223
+ <Stage $zoom={zoom}>
224
+ {url ? (
225
+ <img src={url} alt={image.filename} onClick={() => setZoom((z) => !z)} />
226
+ ) : url === null ? (
227
+ <ReqoreSpan effect={{ opacity: 0.6 }}>Preview unavailable — use Download.</ReqoreSpan>
228
+ ) : (
229
+ <ReqoreSpinner iconColor='info' size='big'>
230
+ Loading…
231
+ </ReqoreSpinner>
232
+ )}
233
+ </Stage>
234
+ </ReqoreModal>
235
+ );
236
+ };
@@ -0,0 +1 @@
1
+ export * from './ImageLightbox';
@@ -0,0 +1,163 @@
1
+ import { StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
4
+ import { StoryMeta } from '../../types';
5
+ import { AttachmentChips } from './AttachmentChips';
6
+
7
+ /**
8
+ * Staged-file chips for a ticket composer. Image files show a thumbnail and open the shared
9
+ * `ImageLightbox` on click; other files show a paperclip. The pencil renames a file inline
10
+ * (a File name is read-only, so the host swaps in a renamed copy); the ✕ removes it.
11
+ */
12
+ const meta = {
13
+ component: AttachmentChips,
14
+ title: 'Components/AttachmentChips',
15
+ } as StoryMeta<typeof AttachmentChips>;
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+
19
+ const imageFile = (label: string, hue: number, name: string): File => {
20
+ const svg =
21
+ `<svg xmlns='http://www.w3.org/2000/svg' width='640' height='400'>` +
22
+ `<rect width='640' height='400' fill='hsl(${hue},40%,16%)'/>` +
23
+ `<text x='24' y='210' fill='white' font-family='sans-serif' font-size='30'>${label}</text></svg>`;
24
+ return new File([svg], name, { type: 'image/svg+xml' });
25
+ };
26
+ const textFile = (name: string): File => new File(['log contents'], name, { type: 'text/plain' });
27
+
28
+ const FILES = [
29
+ imageFile('before', 265, 'screenshot-2026-07-26-17-41-44.png'),
30
+ imageFile('after', 205, 'canvas-after.png'),
31
+ textFile('server.log'),
32
+ ];
33
+
34
+ // Plays the composer's role: owns the file set, swaps in a renamed copy on rename.
35
+ const Harness = () => {
36
+ const [files, setFiles] = useState<File[]>(FILES);
37
+ return (
38
+ <AttachmentChips
39
+ files={files}
40
+ onRemove={(index) => setFiles((current) => current.filter((_, j) => j !== index))}
41
+ onRename={(index, name) =>
42
+ setFiles((current) =>
43
+ current.map((file, i) => (i === index ? new File([file], name, { type: file.type }) : file))
44
+ )
45
+ }
46
+ />
47
+ );
48
+ };
49
+
50
+ /**
51
+ * Two screenshots + a log. Clicking a screenshot chip opens the lightbox (paging across the two
52
+ * images); the log stays a plain chip. Proves the preview opens and paints the image.
53
+ */
54
+ export const WithImages: Story = {
55
+ parameters: {
56
+ docs: {
57
+ description: {
58
+ story:
59
+ 'Renders staged chips for two screenshots and a log, where clicking a screenshot chip opens the shared lightbox while the log stays a plain chip.',
60
+ },
61
+ },
62
+ },
63
+ render: () => <Harness />,
64
+ play: async ({ canvasElement }) => {
65
+ const canvas = within(canvasElement);
66
+ await expect(canvas.getByText('screenshot-2026-07-26-17-41-44.png')).toBeInTheDocument();
67
+ await expect(canvas.getByText('server.log')).toBeInTheDocument();
68
+
69
+ // clicking an image chip opens the shared lightbox (portals to the body)
70
+ await userEvent.click(canvas.getByText('canvas-after.png'));
71
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
72
+ await waitFor(() =>
73
+ expect(document.body.querySelector('img[alt="canvas-after.png"]')).not.toBeNull()
74
+ );
75
+ },
76
+ };
77
+
78
+ /**
79
+ * Renaming from INSIDE the preview: open a chip's lightbox, use its pencil, and the new
80
+ * name lands on the chip too — the lightbox funnels into the same host `onRename` as the
81
+ * chip pencil, so both affordances stay one mechanism.
82
+ */
83
+ export const RenameFromPreview: Story = {
84
+ render: () => <Harness />,
85
+ play: async ({ canvasElement }) => {
86
+ const canvas = within(canvasElement);
87
+ await userEvent.click(canvas.getByText('canvas-after.png'));
88
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull());
89
+
90
+ const pencil = await waitFor(() => {
91
+ const el = document.body.querySelector<HTMLButtonElement>('.imagelightbox-rename');
92
+ if (!el) {
93
+ throw new Error('lightbox rename action not rendered');
94
+ }
95
+ return el;
96
+ });
97
+ await userEvent.click(pencil);
98
+ const input = await waitFor(() => {
99
+ const el = document.body.querySelector<HTMLInputElement>('.reqore-modal input');
100
+ if (!el) {
101
+ throw new Error('lightbox rename input not shown');
102
+ }
103
+ return el;
104
+ });
105
+ await userEvent.clear(input);
106
+ await userEvent.type(input, 'after-fix.png');
107
+ await userEvent.keyboard('{Enter}');
108
+
109
+ // commit ends the edit; a second Esc now closes the modal itself
110
+ await userEvent.keyboard('{Escape}');
111
+ await waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
112
+
113
+ // the rename reached the host: the CHIP carries the new name
114
+ await waitFor(() => expect(canvas.getByText('after-fix.png')).toBeInTheDocument());
115
+ await expect(canvas.queryByText('canvas-after.png')).not.toBeInTheDocument();
116
+ },
117
+ };
118
+
119
+ /**
120
+ * Renaming an auto-named screenshot: the pencil turns the chip into an inline input; Enter
121
+ * commits and the chip shows the new name. Discriminating — remove the rename wiring and the
122
+ * chip keeps its `screenshot-<timestamp>.png` name.
123
+ */
124
+ export const Rename: Story = {
125
+ parameters: {
126
+ docs: {
127
+ description: {
128
+ story:
129
+ 'Renders the staged chips and renames an auto-named screenshot inline — the pencil opens an input, Enter commits, and the chip shows the new name.',
130
+ },
131
+ },
132
+ },
133
+ render: () => <Harness />,
134
+ play: async ({ canvasElement }) => {
135
+ const canvas = within(canvasElement);
136
+ const chip = canvas.getByText('screenshot-2026-07-26-17-41-44.png').closest('.reqore-tag');
137
+ if (!chip) {
138
+ throw new Error('chip not found');
139
+ }
140
+ // click the rename pencil (its own action; the ✕ is a separate reqore-tag-remove)
141
+ const renameAction = chip.querySelector<HTMLElement>('.reqore-tag-action:not(.reqore-tag-remove)');
142
+ if (!renameAction) {
143
+ throw new Error('rename action not rendered');
144
+ }
145
+ await userEvent.click(renameAction);
146
+
147
+ // the inline input appears, pre-filled with the current name
148
+ const input = await waitFor(() => {
149
+ const el = canvasElement.querySelector<HTMLInputElement>('input');
150
+ if (!el) {
151
+ throw new Error('rename input not shown');
152
+ }
153
+ return el;
154
+ });
155
+ await userEvent.clear(input);
156
+ await userEvent.type(input, 'mapper-error.png');
157
+ await userEvent.keyboard('{Enter}');
158
+
159
+ // the chip now carries the new name; the auto-name is gone
160
+ await waitFor(() => expect(canvas.getByText('mapper-error.png')).toBeInTheDocument());
161
+ await expect(canvas.queryByText('screenshot-2026-07-26-17-41-44.png')).not.toBeInTheDocument();
162
+ },
163
+ };