@object-ui/fields 16.1.0 → 17.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/dist/FieldEditWidget.d.ts +7 -0
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +5 -5
  5. package/dist/index.d.ts +14 -11
  6. package/dist/index.js +2428 -2100
  7. package/dist/widgets/FilterConditionField.d.ts +32 -0
  8. package/dist/widgets/ImageLightbox.d.ts +30 -0
  9. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  10. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  11. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  12. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  13. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  14. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  15. package/dist/widgets/file-value.d.ts +112 -0
  16. package/dist/widgets/file-value.test.d.ts +1 -0
  17. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  18. package/package.json +9 -8
  19. package/src/FieldEditWidget.test.ts +74 -2
  20. package/src/FieldEditWidget.tsx +44 -4
  21. package/src/field-type-alias.ts +100 -0
  22. package/src/index.tsx +94 -133
  23. package/src/widgets/CheckboxesField.tsx +8 -5
  24. package/src/widgets/FileField.tsx +88 -57
  25. package/src/widgets/FilterConditionField.tsx +109 -9
  26. package/src/widgets/ImageField.tsx +71 -33
  27. package/src/widgets/ImageLightbox.test.tsx +52 -0
  28. package/src/widgets/ImageLightbox.tsx +108 -0
  29. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  30. package/src/widgets/LookupField.idField.test.tsx +61 -0
  31. package/src/widgets/LookupField.tsx +106 -9
  32. package/src/widgets/MultiSelectField.tsx +6 -3
  33. package/src/widgets/ObjectRefField.tsx +9 -4
  34. package/src/widgets/PeoplePicker.test.tsx +20 -0
  35. package/src/widgets/PeoplePicker.tsx +15 -3
  36. package/src/widgets/RadioField.tsx +6 -3
  37. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  38. package/src/widgets/RecipientPickerField.tsx +36 -16
  39. package/src/widgets/TextAreaField.tsx +4 -1
  40. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  41. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  42. package/src/widgets/file-value.test.ts +230 -0
  43. package/src/widgets/file-value.ts +230 -0
  44. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { FilterBuilder, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
4
  import type { FieldWidgetProps } from './types';
5
+ import { useFieldTranslation } from './useFieldTranslation';
5
6
 
6
7
  /**
7
8
  * FilterConditionField — visual criteria builder for a stored FilterCondition
@@ -101,7 +102,13 @@ function toArray(value: any): any[] {
101
102
  return value == null ? [] : [value];
102
103
  }
103
104
 
104
- function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
105
+ /**
106
+ * Builder condition → `$`-operator criteria. Exported for tests: this is the
107
+ * chokepoint where a builder token becomes a spec `FieldOperatorsSchema` key,
108
+ * and a wrong spelling here is rejected downstream by `convertFiltersToAST`
109
+ * rather than at authoring time. @internal
110
+ */
111
+ export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
105
112
  const { field, operator, value } = c || ({} as BuilderCondition);
106
113
  if (!field) return null;
107
114
  const t = typeOf(field);
@@ -110,9 +117,23 @@ function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefi
110
117
  case 'equals': return { [field]: cv };
111
118
  case 'notEquals': return { [field]: { $ne: cv } };
112
119
  case 'contains': return { [field]: { $contains: value } };
113
- case 'notContains': return { [field]: { $ncontains: value } };
120
+ // `$notContains` is the spec spelling (FieldOperatorsSchema, data/filter.zod.ts).
121
+ // This emitted `$ncontains` — a token that appears nowhere in @objectstack/spec and
122
+ // that convertFiltersToAST throws on, so every "does not contain" rule authored here
123
+ // was rejected downstream. See kvToCondition for reading the old spelling back.
124
+ case 'notContains': return { [field]: { $notContains: value } };
125
+ // String-specific spec operators — previously unreachable from the
126
+ // builder UI even though FieldOperatorsSchema accepts them (#2942).
127
+ case 'startsWith': return { [field]: { $startsWith: value } };
128
+ case 'endsWith': return { [field]: { $endsWith: value } };
114
129
  case 'isEmpty': return { [field]: { $in: [null, ''] } };
115
130
  case 'isNotEmpty': return { [field]: { $nin: [null, ''] } };
131
+ // Null / existence spec operators. Distinct from isEmpty/isNotEmpty,
132
+ // which also treat '' as empty.
133
+ case 'isNull': return { [field]: { $null: true } };
134
+ case 'isNotNull': return { [field]: { $null: false } };
135
+ case 'exists': return { [field]: { $exists: true } };
136
+ case 'notExists': return { [field]: { $exists: false } };
116
137
  case 'greaterThan':
117
138
  case 'after': return { [field]: { $gt: cv } };
118
139
  case 'lessThan':
@@ -133,7 +154,11 @@ function filterGroupToMongo(group: BuilderGroup, typeOf: (f: string) => string |
133
154
  const frags = (group?.conditions ?? [])
134
155
  .map((c) => condToMongo(c, typeOf))
135
156
  .filter((x): x is Record<string, any> => !!x);
136
- if (frags.length === 0) return null; // empty = match all
157
+ // No conditions → no predicate. NOT "match all": a sharing rule with an
158
+ // empty criteria is refused on save, and one already stored shares nothing
159
+ // (objectstack#3896 / ADR-0049). `null` becomes an empty stored value, which
160
+ // is what the required-criteria messaging below keys off.
161
+ if (frags.length === 0) return null;
137
162
  if (group.logic === 'or') return { $or: frags };
138
163
  if (frags.length === 1) return frags[0];
139
164
  const keys = frags.flatMap((f) => Object.keys(f));
@@ -145,7 +170,13 @@ function arraysEqual(a: any, b: any[]): boolean {
145
170
  return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
146
171
  }
147
172
 
148
- function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
173
+ /**
174
+ * Criteria → builder condition (the reverse of {@link condToMongo}). Returning
175
+ * `null` makes the builder refuse to load the rule ("criteria can't be
176
+ * represented"), so this must keep accepting spellings previously written.
177
+ * @internal
178
+ */
179
+ export function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
149
180
  const id = `c_${idx}_${field}`;
150
181
  if (v === null || typeof v !== 'object' || Array.isArray(v)) {
151
182
  return { id, field, operator: 'equals', value: v };
@@ -157,11 +188,19 @@ function kvToCondition(field: string, v: any, idx: number): BuilderCondition | n
157
188
  switch (op) {
158
189
  case '$ne': return { id, field, operator: 'notEquals', value: val };
159
190
  case '$contains': return { id, field, operator: 'contains', value: val };
191
+ // `$ncontains` is the pre-fix spelling this widget used to emit. Criteria saved
192
+ // before the fix still carry it, so keep reading it — dropping it here would make
193
+ // those rules fail to load ("criteria can't be represented") instead of migrating.
194
+ case '$notContains':
160
195
  case '$ncontains': return { id, field, operator: 'notContains', value: val };
161
196
  case '$gt': return { id, field, operator: 'greaterThan', value: val };
162
197
  case '$lt': return { id, field, operator: 'lessThan', value: val };
163
198
  case '$gte': return { id, field, operator: 'greaterOrEqual', value: val };
164
199
  case '$lte': return { id, field, operator: 'lessOrEqual', value: val };
200
+ case '$startsWith': return { id, field, operator: 'startsWith', value: val };
201
+ case '$endsWith': return { id, field, operator: 'endsWith', value: val };
202
+ case '$null': return { id, field, operator: val === false ? 'isNotNull' : 'isNull', value: '' };
203
+ case '$exists': return { id, field, operator: val === false ? 'notExists' : 'exists', value: '' };
165
204
  case '$in':
166
205
  return arraysEqual(val, [null, ''])
167
206
  ? { id, field, operator: 'isEmpty', value: '' }
@@ -212,6 +251,37 @@ function mongoToFilterGroup(mongo: any): BuilderGroup | null {
212
251
  return { id: 'root', logic: 'and', conditions };
213
252
  }
214
253
 
254
+ /**
255
+ * Would this criteria select EVERY record of the object?
256
+ *
257
+ * Mirrors the server's `isMatchAllCriteria` (objectstack `plugin-sharing`,
258
+ * #3896) closely enough to warn before the round-trip: blank, `{}`, `[]`, and
259
+ * the vacuous combinators. Deliberately conservative in the same direction —
260
+ * the cost of a false positive is one extra hint, the cost of a false negative
261
+ * is a save that fails with a toast. The server stays authoritative.
262
+ *
263
+ * @internal exported for tests
264
+ */
265
+ export function isMatchAllCriteria(parsed: any): boolean {
266
+ if (parsed == null) return true;
267
+ if (Array.isArray(parsed)) return parsed.every(isMatchAllCriteria);
268
+ if (typeof parsed !== 'object') return true;
269
+ const entries = Object.entries(parsed);
270
+ if (entries.length === 0) return true;
271
+ for (const [key, value] of entries) {
272
+ if (key === '$and') {
273
+ if (!Array.isArray(value) || value.every(isMatchAllCriteria)) continue;
274
+ return false;
275
+ }
276
+ if (key === '$or') {
277
+ if (!Array.isArray(value) || value.length === 0 || value.some(isMatchAllCriteria)) continue;
278
+ return false;
279
+ }
280
+ return false;
281
+ }
282
+ return true;
283
+ }
284
+
215
285
  function stringifyValue(value: string | object | undefined | null): string {
216
286
  if (value == null || value === '') return '';
217
287
  if (typeof value === 'string') return value;
@@ -230,6 +300,7 @@ export function FilterConditionField({
230
300
  ...props
231
301
  }: FieldWidgetProps<string | object>) {
232
302
  const ctx = React.useContext(SchemaRendererContext);
303
+ const { t } = useFieldTranslation();
233
304
  const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
234
305
  const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
235
306
  const objectName = String(dependentValues.object_name ?? '');
@@ -269,6 +340,13 @@ export function FilterConditionField({
269
340
  [parsed],
270
341
  );
271
342
 
343
+ // Only flag a criteria that PARSES to match-all. Unparsable JSON has its own
344
+ // message (`invalidJson`) and must not collect a second, contradictory one.
345
+ const isEmptyCriteria = React.useMemo(
346
+ () => parsed.ok && isMatchAllCriteria(parsed.mongo),
347
+ [parsed],
348
+ );
349
+
272
350
  // Raw JSON mode: forced when the stored value can't be represented in the
273
351
  // builder; otherwise opt-in via the toggle.
274
352
  const representable = parsed.ok && group !== null;
@@ -290,14 +368,22 @@ export function FilterConditionField({
290
368
  if (!objectName) {
291
369
  return (
292
370
  <p className={cn('text-sm text-muted-foreground', className)}>
293
- Select an object first.
371
+ {t('fields.filterCondition.selectObjectFirst')}
294
372
  </p>
295
373
  );
296
374
  }
297
375
 
298
376
  if (readonly) {
299
377
  if (!rawValue.trim()) {
300
- return <span className={cn('text-sm text-muted-foreground', className)}>All records</span>;
378
+ // Used to read "All records" — which was both wrong and the most
379
+ // dangerous thing this widget could say (objectstack#3896). A rule with
380
+ // no criteria has never usefully shared everything; it now shares
381
+ // nothing and is refused on save, so name that instead.
382
+ return (
383
+ <span className={cn('text-sm text-destructive', className)}>
384
+ {t('fields.filterCondition.noCriteria')}
385
+ </span>
386
+ );
301
387
  }
302
388
  return (
303
389
  <pre className={cn('overflow-x-auto rounded bg-muted/40 p-2 text-xs', className)}>
@@ -317,7 +403,7 @@ export function FilterConditionField({
317
403
  onChange={(e) => onChange(e.target.value as any)}
318
404
  />
319
405
  {!parsed.ok && (
320
- <span className="text-xs text-destructive">Invalid JSON — the rule will match no records until fixed.</span>
406
+ <span className="text-xs text-destructive">{t('fields.filterCondition.invalidJson')}</span>
321
407
  )}
322
408
  </>
323
409
  ) : (
@@ -327,14 +413,28 @@ export function FilterConditionField({
327
413
  onChange={handleBuilderChange as any}
328
414
  />
329
415
  )}
416
+ {/*
417
+ The server refuses to save a criteria that would select every record
418
+ (objectstack#3896), but that rejection only arrives as a toast after
419
+ the admin hits Save. Say it here, while they are still looking at the
420
+ empty builder — and never imply that leaving it empty means "share
421
+ everything", which is what this widget used to do.
422
+ */}
423
+ {isEmptyCriteria && (
424
+ <span className="text-xs text-destructive">
425
+ {t('fields.filterCondition.criteriaRequired')}
426
+ </span>
427
+ )}
330
428
  <button
331
429
  type="button"
332
430
  className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
333
431
  onClick={() => setRawMode((m) => !m)}
334
432
  disabled={!representable && !rawMode}
335
- title={!representable ? 'This criteria can only be edited as JSON' : undefined}
433
+ title={!representable ? t('fields.filterCondition.jsonOnly') : undefined}
336
434
  >
337
- {rawMode ? 'Use visual builder' : 'Edit as JSON'}
435
+ {rawMode
436
+ ? t('fields.filterCondition.useVisualBuilder')
437
+ : t('fields.filterCondition.editAsJson')}
338
438
  </button>
339
439
  </div>
340
440
  );
@@ -1,9 +1,18 @@
1
1
  import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
2
2
  import { Button, EmptyValue } from '@object-ui/components';
3
3
  import { useUpload } from '@object-ui/providers';
4
- import { Upload, X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
4
+ import { useObjectTranslation } from '@object-ui/i18n';
5
+ import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
5
6
  import { FieldWidgetProps } from './types';
7
+ import { ImageLightbox } from './ImageLightbox';
6
8
  import { useUploadingSignal } from './useUploadingSignal';
9
+ import {
10
+ fileValueForSubmit,
11
+ readFileValues,
12
+ uploadResultView,
13
+ withRecentUploads,
14
+ type FileValueView,
15
+ } from './file-value';
7
16
 
8
17
  // Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
9
18
  // ImageField bundle. Consumers that never crop pay zero cost.
@@ -25,13 +34,26 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
25
34
  */
26
35
  const cropEnabled = imageField?.crop !== false;
27
36
  const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
37
+ const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
28
38
  const { upload } = useUpload();
39
+ const { t } = useObjectTranslation();
29
40
  const [uploading, setUploading] = useState(false);
41
+ // Display details of just-uploaded images, keyed by their new `sys_file` id.
42
+ // Submitting the reference form means the field value no longer carries the
43
+ // URL a thumbnail needs; these keep the preview visible until the next read
44
+ // returns the expanded form. See `file-value`.
45
+ const [recent, setRecent] = useState<Record<string, FileValueView>>({});
30
46
  useUploadingSignal(uploading, onUploadingChange);
31
47
 
32
48
  // Derived value + memoized handlers must run before the readonly early return
33
49
  // so hook order stays stable across renders.
34
50
  const images = value ? (Array.isArray(value) ? value : [value]) : [];
51
+ const views = withRecentUploads(readFileValues(value, 'Image'), recent);
52
+
53
+ const remember = useCallback((result: any, originalName: string) => {
54
+ const view = uploadResultView(result, originalName);
55
+ if (view.id) setRecent((prev) => ({ ...prev, [view.id as string]: view }));
56
+ }, []);
35
57
 
36
58
  const handleCropConfirm = useCallback(
37
59
  async (blob: Blob, name: string) => {
@@ -39,13 +61,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
39
61
  setUploading(true);
40
62
  try {
41
63
  const result = await upload(blob);
42
- const next = {
43
- name: result.name || name,
44
- original_name: name,
45
- size: result.size,
46
- mime_type: result.mimeType,
47
- url: result.url,
48
- };
64
+ remember(result, name);
65
+ const next = fileValueForSubmit(result, name);
49
66
  if (multiple) {
50
67
  const updated = [...images];
51
68
  updated[cropTarget.index] = next;
@@ -58,32 +75,53 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
58
75
  setCropTarget(null);
59
76
  }
60
77
  },
61
- [cropTarget, images, multiple, onChange, upload],
78
+ [cropTarget, images, multiple, onChange, upload, remember],
62
79
  );
63
80
 
64
81
  const openCropper = useCallback(
65
82
  (index: number) => {
66
- const img = images[index];
83
+ const img = views[index];
67
84
  if (!img?.url) return;
68
85
  setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
69
86
  },
70
- [images],
87
+ [views],
71
88
  );
72
89
 
90
+ const lightboxImages = views.filter((v) => v.url).map((v) => ({ url: v.url as string, name: v.name }));
91
+
73
92
  if (readonly) {
74
- if (!value) return <EmptyValue />;
93
+ if (!value || lightboxImages.length === 0) return <EmptyValue />;
75
94
 
76
95
  return (
77
- <div className="flex flex-wrap gap-2">
78
- {images.map((img: any, idx: number) => (
79
- <img
80
- key={idx}
81
- src={img.url || ''}
82
- alt={img.name || `Image ${idx + 1}`}
83
- className="size-20 rounded-md object-cover border border-gray-200"
96
+ <>
97
+ <div className="flex flex-wrap gap-2">
98
+ {lightboxImages.map((img, idx) => (
99
+ <button
100
+ key={idx}
101
+ type="button"
102
+ onClick={() => setLightboxIndex(idx)}
103
+ className="group relative overflow-hidden rounded-md border border-border focus:outline-none focus:ring-2 focus:ring-ring"
104
+ aria-label={t('fields.image.enlarge', { name: img.name || t('fields.image.imageAlt', { index: idx + 1 }) })}
105
+ >
106
+ <img
107
+ src={img.url}
108
+ alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
109
+ className="size-20 object-cover transition-transform duration-150 group-hover:scale-105"
110
+ />
111
+ <span className="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
112
+ </button>
113
+ ))}
114
+ </div>
115
+ {lightboxIndex !== null && (
116
+ <ImageLightbox
117
+ images={lightboxImages}
118
+ index={lightboxIndex}
119
+ open
120
+ onOpenChange={(o) => !o && setLightboxIndex(null)}
121
+ onIndexChange={setLightboxIndex}
84
122
  />
85
- ))}
86
- </div>
123
+ )}
124
+ </>
87
125
  );
88
126
  }
89
127
 
@@ -96,13 +134,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
96
134
  const imageObjects = await Promise.all(
97
135
  selectedFiles.map(async (file) => {
98
136
  const result = await upload(file);
99
- return {
100
- name: result.name,
101
- original_name: file.name,
102
- size: result.size,
103
- mime_type: result.mimeType,
104
- url: result.url,
105
- };
137
+ remember(result, file.name);
138
+ return fileValueForSubmit(result, file.name);
106
139
  }),
107
140
  );
108
141
 
@@ -139,13 +172,13 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
139
172
  />
140
173
 
141
174
  <div className="space-y-2">
142
- {images.length > 0 && (
175
+ {views.length > 0 && (
143
176
  <div className="grid grid-cols-4 gap-2">
144
- {images.map((img: any, idx: number) => (
177
+ {views.map((img, idx) => (
145
178
  <div key={idx} className="relative group">
146
179
  <img
147
180
  src={img.url || ''}
148
- alt={img.name || `Image ${idx + 1}`}
181
+ alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
149
182
  className="size-20 rounded-md object-cover border border-gray-200"
150
183
  />
151
184
  <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -156,7 +189,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
156
189
  size="sm"
157
190
  onClick={() => openCropper(idx)}
158
191
  className="h-6 w-6 p-0"
159
- aria-label={`Crop image ${idx + 1}`}
192
+ aria-label={t('fields.image.crop', { index: idx + 1 })}
160
193
  data-testid={`image-field-crop-${idx}`}
161
194
  >
162
195
  <CropIcon className="size-3" />
@@ -168,6 +201,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
168
201
  size="sm"
169
202
  onClick={() => handleRemove(idx)}
170
203
  className="h-6 w-6 p-0"
204
+ aria-label={t('fields.image.remove', { index: idx + 1 })}
171
205
  >
172
206
  <X className="size-3" />
173
207
  </Button>
@@ -190,7 +224,11 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
190
224
  ) : (
191
225
  <ImageIcon className="size-4 mr-2" />
192
226
  )}
193
- {uploading ? 'Uploading…' : images.length > 0 ? 'Add More Images' : 'Upload Image'}
227
+ {uploading
228
+ ? t('fields.image.uploading')
229
+ : images.length > 0
230
+ ? t('fields.image.addMore')
231
+ : t('fields.image.upload')}
194
232
  </Button>
195
233
  </div>
196
234
 
@@ -0,0 +1,52 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { I18nProvider } from '@object-ui/i18n';
4
+ import { ImageLightbox, type ImageLightboxProps } from './ImageLightbox';
5
+
6
+ const imgs = [
7
+ { url: '/api/v1/storage/files/a', name: 'a.png' },
8
+ { url: '/api/v1/storage/files/b', name: 'b.png' },
9
+ { url: '/api/v1/storage/files/c', name: 'c.png' },
10
+ ];
11
+
12
+ const renderLightbox = (props: ImageLightboxProps) =>
13
+ render(
14
+ <I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
15
+ <ImageLightbox {...props} />
16
+ </I18nProvider>,
17
+ );
18
+
19
+ describe('ImageLightbox', () => {
20
+ it('renders the image at the requested index', () => {
21
+ renderLightbox({ images: imgs, index: 1, open: true, onOpenChange: () => {} });
22
+ const img = screen.getByRole('img') as HTMLImageElement;
23
+ expect(img.src).toContain('/api/v1/storage/files/b');
24
+ });
25
+
26
+ it('shows navigation and a position counter for multiple images', () => {
27
+ renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {} });
28
+ expect(screen.getByText('1 / 3')).toBeInTheDocument();
29
+ expect(screen.getByLabelText('Next image')).toBeInTheDocument();
30
+ expect(screen.getByLabelText('Previous image')).toBeInTheDocument();
31
+ });
32
+
33
+ it('omits navigation for a single image', () => {
34
+ renderLightbox({ images: [imgs[0]], index: 0, open: true, onOpenChange: () => {} });
35
+ expect(screen.queryByLabelText('Next image')).not.toBeInTheDocument();
36
+ expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
37
+ });
38
+
39
+ it('advances to the next image and wraps around', () => {
40
+ const seen: number[] = [];
41
+ renderLightbox({ images: imgs, index: 2, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
42
+ fireEvent.click(screen.getByLabelText('Next image'));
43
+ expect(seen).toEqual([0]); // wrapped 2 -> 0
44
+ });
45
+
46
+ it('steps to the previous image and wraps around', () => {
47
+ const seen: number[] = [];
48
+ renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
49
+ fireEvent.click(screen.getByLabelText('Previous image'));
50
+ expect(seen).toEqual([2]); // wrapped 0 -> 2
51
+ });
52
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import * as React from 'react';
10
+ import { Dialog, DialogContent, DialogTitle } from '@object-ui/components';
11
+ import { useObjectTranslation } from '@object-ui/i18n';
12
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
13
+
14
+ export interface LightboxImage {
15
+ url: string;
16
+ name?: string;
17
+ }
18
+
19
+ export interface ImageLightboxProps {
20
+ images: LightboxImage[];
21
+ /** Index of the image shown when the lightbox opens. */
22
+ index: number;
23
+ open: boolean;
24
+ onOpenChange: (open: boolean) => void;
25
+ /** Called when the user navigates to another image (prev/next, arrow keys). */
26
+ onIndexChange?: (index: number) => void;
27
+ }
28
+
29
+ /**
30
+ * Full-screen image preview with prev/next navigation for a set of images.
31
+ *
32
+ * Controlled: the caller owns `open` and the current `index`. Built on the
33
+ * shared `Dialog`, so backdrop-click and Escape close it for free; this adds
34
+ * left/right arrow-key navigation and a position counter for the multi-image
35
+ * case. A single image simply omits the arrows and counter.
36
+ */
37
+ export function ImageLightbox({ images, index, open, onOpenChange, onIndexChange }: ImageLightboxProps) {
38
+ const { t } = useObjectTranslation();
39
+ const count = images.length;
40
+ const current = images[index];
41
+
42
+ const go = React.useCallback(
43
+ (delta: number) => {
44
+ if (count <= 1) return;
45
+ const next = (index + delta + count) % count;
46
+ onIndexChange?.(next);
47
+ },
48
+ [count, index, onIndexChange],
49
+ );
50
+
51
+ const handleKeyDown = React.useCallback(
52
+ (e: React.KeyboardEvent) => {
53
+ if (e.key === 'ArrowRight') {
54
+ e.preventDefault();
55
+ go(1);
56
+ } else if (e.key === 'ArrowLeft') {
57
+ e.preventDefault();
58
+ go(-1);
59
+ }
60
+ },
61
+ [go],
62
+ );
63
+
64
+ if (!current) return null;
65
+
66
+ return (
67
+ <Dialog open={open} onOpenChange={onOpenChange}>
68
+ <DialogContent
69
+ className="max-w-[92vw] sm:max-w-4xl p-0 bg-transparent border-0 shadow-none"
70
+ onKeyDown={handleKeyDown}
71
+ >
72
+ {/* Title is required for a labelled dialog but visually redundant here. */}
73
+ <DialogTitle className="sr-only">{current.name || t('fields.image.preview')}</DialogTitle>
74
+ <div className="relative flex items-center justify-center">
75
+ <img
76
+ src={current.url}
77
+ alt={current.name || t('fields.image.imageAlt', { index: index + 1 })}
78
+ className="max-h-[85vh] max-w-full rounded-md object-contain"
79
+ />
80
+
81
+ {count > 1 && (
82
+ <>
83
+ <button
84
+ type="button"
85
+ onClick={() => go(-1)}
86
+ aria-label={t('fields.image.previous')}
87
+ className="absolute left-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
88
+ >
89
+ <ChevronLeft className="size-5" />
90
+ </button>
91
+ <button
92
+ type="button"
93
+ onClick={() => go(1)}
94
+ aria-label={t('fields.image.next')}
95
+ className="absolute right-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
96
+ >
97
+ <ChevronRight className="size-5" />
98
+ </button>
99
+ <div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white">
100
+ {t('fields.image.counter', { current: index + 1, total: count })}
101
+ </div>
102
+ </>
103
+ )}
104
+ </div>
105
+ </DialogContent>
106
+ </Dialog>
107
+ );
108
+ }