@object-ui/fields 3.3.2 → 4.0.1

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.
@@ -201,6 +201,8 @@ export * from './widgets/LookupField';
201
201
  export * from './widgets/RecordPickerDialog';
202
202
  export * from './widgets/FileField';
203
203
  export * from './widgets/ImageField';
204
+ export { ImageCropperDialog } from './widgets/ImageCropperDialog';
205
+ export type { ImageCropperDialogProps } from './widgets/ImageCropperDialog';
204
206
  export * from './widgets/LocationField';
205
207
  export * from './widgets/FormulaField';
206
208
  export * from './widgets/SummaryField';
@@ -0,0 +1,14 @@
1
+ import { default as React } from 'react';
2
+ export interface ImageCropperDialogProps {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ /** Source image URL or data URL */
6
+ src: string;
7
+ /** MIME type for the cropped output. Defaults to image/png. */
8
+ outputType?: string;
9
+ /** Suggested filename (passed back via onConfirm). Defaults to "cropped.png". */
10
+ outputName?: string;
11
+ /** Called when the user confirms the crop. */
12
+ onConfirm: (blob: Blob, name: string) => void;
13
+ }
14
+ export declare const ImageCropperDialog: React.FC<ImageCropperDialogProps>;
@@ -1,6 +1,11 @@
1
1
  import { FieldWidgetProps } from './types';
2
2
  /**
3
3
  * TextAreaField - Multi-line text input widget
4
- * Supports configurable row count and preserves whitespace in readonly mode
4
+ * Supports configurable row count and preserves whitespace in readonly mode.
5
+ *
6
+ * Mobile UX (round 3): when the host form passes `mobileFullscreen` (or the
7
+ * field schema sets `mobile_fullscreen: true`), an "expand" affordance opens
8
+ * a fullscreen edit dialog — much easier on phones than tapping a 4-row
9
+ * textarea trapped between other fields.
5
10
  */
6
11
  export declare function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>): import("react/jsx-runtime").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "3.3.2",
3
+ "version": "4.0.1",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,13 +25,14 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "clsx": "^2.1.1",
28
- "lucide-react": "^1.8.0",
28
+ "lucide-react": "^1.14.0",
29
29
  "tailwind-merge": "^3.5.0",
30
- "@object-ui/components": "3.3.2",
31
- "@object-ui/core": "3.3.2",
32
- "@object-ui/i18n": "3.3.2",
33
- "@object-ui/react": "3.3.2",
34
- "@object-ui/types": "3.3.2"
30
+ "@object-ui/components": "4.0.1",
31
+ "@object-ui/core": "4.0.1",
32
+ "@object-ui/i18n": "4.0.1",
33
+ "@object-ui/providers": "4.0.1",
34
+ "@object-ui/react": "4.0.1",
35
+ "@object-ui/types": "4.0.1"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "react": "^18.0.0 || ^19.0.0",
@@ -43,7 +44,7 @@
43
44
  "@vitejs/plugin-react": "^6.0.1",
44
45
  "typescript": "^6.0.3",
45
46
  "vite": "^8.0.10",
46
- "vite-plugin-dts": "^4.5.4"
47
+ "vite-plugin-dts": "^5.0.0"
47
48
  },
48
49
  "publishConfig": {
49
50
  "access": "public"
@@ -146,7 +146,7 @@ describe('SelectCellRenderer', () => {
146
146
  />
147
147
  );
148
148
  expect(screen.getByText('In Progress')).toBeInTheDocument();
149
- expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
149
+ expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
150
150
  });
151
151
 
152
152
  it('should fall back to a deterministic palette color when no color configured and no semantic match', () => {
@@ -166,11 +166,11 @@ describe('SelectCellRenderer', () => {
166
166
  );
167
167
  expect(screen.getByText('Unknown')).toBeInTheDocument();
168
168
  const palette = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
169
- const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-100"]`));
169
+ const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-50"]`));
170
170
  expect(matched).toBe(true);
171
171
 
172
172
  // Same value → same color across renders (deterministic).
173
- const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-100"]')?.className;
173
+ const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
174
174
  rerender(
175
175
  <SelectCellRenderer
176
176
  value="Unknown"
@@ -181,7 +181,7 @@ describe('SelectCellRenderer', () => {
181
181
  } as any}
182
182
  />
183
183
  );
184
- const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-100"]')?.className;
184
+ const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
185
185
  expect(secondClass).toBe(firstClass);
186
186
  });
187
187
 
@@ -193,7 +193,7 @@ describe('SelectCellRenderer', () => {
193
193
  />
194
194
  );
195
195
  expect(screen.getByText('Critical')).toBeInTheDocument();
196
- expect(container.querySelector('[class*="bg-red-100"]')).toBeInTheDocument();
196
+ expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
197
197
  });
198
198
 
199
199
  it('should auto-detect priority semantic colors (High → orange)', () => {
@@ -203,7 +203,7 @@ describe('SelectCellRenderer', () => {
203
203
  field={{ name: 'priority', type: 'select', options: [] } as any}
204
204
  />
205
205
  );
206
- expect(container.querySelector('[class*="bg-orange-100"]')).toBeInTheDocument();
206
+ expect(container.querySelector('[class*="bg-orange-50"]')).toBeInTheDocument();
207
207
  });
208
208
 
209
209
  it('should auto-detect priority semantic colors (Medium → yellow)', () => {
@@ -213,7 +213,7 @@ describe('SelectCellRenderer', () => {
213
213
  field={{ name: 'priority', type: 'select', options: [] } as any}
214
214
  />
215
215
  );
216
- expect(container.querySelector('[class*="bg-yellow-100"]')).toBeInTheDocument();
216
+ expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
217
217
  });
218
218
 
219
219
  it('should auto-detect priority semantic colors (Low → gray)', () => {
@@ -223,7 +223,7 @@ describe('SelectCellRenderer', () => {
223
223
  field={{ name: 'priority', type: 'select', options: [] } as any}
224
224
  />
225
225
  );
226
- expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument();
226
+ expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
227
227
  });
228
228
 
229
229
  it('should auto-detect status semantic colors (Paid → green)', () => {
@@ -233,7 +233,7 @@ describe('SelectCellRenderer', () => {
233
233
  field={{ name: 'status', type: 'select', options: [] } as any}
234
234
  />
235
235
  );
236
- expect(container.querySelector('[class*="bg-green-100"]')).toBeInTheDocument();
236
+ expect(container.querySelector('[class*="bg-green-50"]')).toBeInTheDocument();
237
237
  });
238
238
 
239
239
  it('should auto-detect status semantic colors (Pending → yellow)', () => {
@@ -243,7 +243,7 @@ describe('SelectCellRenderer', () => {
243
243
  field={{ name: 'status', type: 'select', options: [] } as any}
244
244
  />
245
245
  );
246
- expect(container.querySelector('[class*="bg-yellow-100"]')).toBeInTheDocument();
246
+ expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
247
247
  });
248
248
 
249
249
  it('should auto-detect status semantic colors (Shipped → blue)', () => {
@@ -253,7 +253,7 @@ describe('SelectCellRenderer', () => {
253
253
  field={{ name: 'status', type: 'select', options: [] } as any}
254
254
  />
255
255
  );
256
- expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
256
+ expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
257
257
  });
258
258
 
259
259
  it('should auto-detect status semantic colors (Draft → gray)', () => {
@@ -263,7 +263,7 @@ describe('SelectCellRenderer', () => {
263
263
  field={{ name: 'status', type: 'select', options: [] } as any}
264
264
  />
265
265
  );
266
- expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument();
266
+ expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
267
267
  });
268
268
 
269
269
  it('should auto-detect status semantic colors (Cancelled → red)', () => {
@@ -273,7 +273,7 @@ describe('SelectCellRenderer', () => {
273
273
  field={{ name: 'status', type: 'select', options: [] } as any}
274
274
  />
275
275
  );
276
- expect(container.querySelector('[class*="bg-red-100"]')).toBeInTheDocument();
276
+ expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
277
277
  });
278
278
 
279
279
  it('should auto-detect status semantic colors (Delivered → purple)', () => {
@@ -283,7 +283,7 @@ describe('SelectCellRenderer', () => {
283
283
  field={{ name: 'status', type: 'select', options: [] } as any}
284
284
  />
285
285
  );
286
- expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument();
286
+ expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
287
287
  });
288
288
 
289
289
  it('should auto-detect status semantic colors with snake_case (in_progress → blue)', () => {
@@ -293,7 +293,7 @@ describe('SelectCellRenderer', () => {
293
293
  field={{ name: 'status', type: 'select', options: [] } as any}
294
294
  />
295
295
  );
296
- expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
296
+ expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
297
297
  });
298
298
 
299
299
  it('should auto-detect status semantic colors with hyphen (in-progress → blue)', () => {
@@ -303,7 +303,7 @@ describe('SelectCellRenderer', () => {
303
303
  field={{ name: 'status', type: 'select', options: [] } as any}
304
304
  />
305
305
  );
306
- expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
306
+ expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
307
307
  });
308
308
 
309
309
  it('should auto-detect status semantic colors with spaces (in progress → blue)', () => {
@@ -313,7 +313,7 @@ describe('SelectCellRenderer', () => {
313
313
  field={{ name: 'status', type: 'select', options: [] } as any}
314
314
  />
315
315
  );
316
- expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
316
+ expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
317
317
  });
318
318
 
319
319
  it('should render dash for null/empty value', () => {
@@ -356,7 +356,7 @@ describe('SelectCellRenderer', () => {
356
356
  />
357
357
  );
358
358
  // Explicit purple should override auto-detected orange
359
- expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument();
359
+ expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
360
360
  });
361
361
  });
362
362
 
@@ -566,5 +566,51 @@ describe('Complex & Relationship Widgets', () => {
566
566
  // Check for remove button existence implies it rendered correctly
567
567
  // Typically icon X or Trash.
568
568
  });
569
+
570
+ it('renders the camera button when capture is explicitly enabled', () => {
571
+ render(
572
+ <FileField
573
+ {...baseProps}
574
+ field={{ ...baseProps.field, capture: 'environment' } as any}
575
+ value={null}
576
+ />,
577
+ );
578
+ expect(screen.getByTestId('file-field-camera-button')).toBeInTheDocument();
579
+ expect(screen.getByText(/Take photo/i)).toBeInTheDocument();
580
+ const cameraInput = screen.getByTestId('file-field-camera-input') as HTMLInputElement;
581
+ expect(cameraInput).toHaveAttribute('capture', 'environment');
582
+ expect(cameraInput).toHaveAttribute('accept', 'image/*');
583
+ });
584
+
585
+ it('uses "Take selfie" label when capture is "user"', () => {
586
+ render(
587
+ <FileField
588
+ {...baseProps}
589
+ field={{ ...baseProps.field, capture: 'user' } as any}
590
+ value={null}
591
+ />,
592
+ );
593
+ expect(screen.getByText(/Take selfie/i)).toBeInTheDocument();
594
+ const cameraInput = screen.getByTestId('file-field-camera-input') as HTMLInputElement;
595
+ expect(cameraInput).toHaveAttribute('capture', 'user');
596
+ });
597
+
598
+ it('does not render the camera button when capture is explicitly false', () => {
599
+ render(
600
+ <FileField
601
+ {...baseProps}
602
+ field={{ ...baseProps.field, capture: false } as any}
603
+ value={null}
604
+ />,
605
+ );
606
+ expect(screen.queryByTestId('file-field-camera-button')).not.toBeInTheDocument();
607
+ expect(screen.queryByTestId('file-field-camera-input')).not.toBeInTheDocument();
608
+ });
609
+
610
+ it('does not render the camera button on a non-touch device by default', () => {
611
+ // jsdom defaults to maxTouchPoints=0 and a non-mobile UA, so capture is auto-disabled.
612
+ render(<FileField {...baseProps} value={null} />);
613
+ expect(screen.queryByTestId('file-field-camera-button')).not.toBeInTheDocument();
614
+ });
569
615
  });
570
616
  });
package/src/index.tsx CHANGED
@@ -366,15 +366,21 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
366
366
  const dateField = field as any;
367
367
  const style = dateField.format || 'relative';
368
368
  const formatted = formatDate(safe as string | Date, style);
369
-
370
- // Determine if date is overdue (in the past)
369
+
370
+ // Determine if date is overdue (in the past) — but only color it red when the
371
+ // field is *semantically* a due/deadline. A plain "start_date" or "created_at"
372
+ // in the past should not render in red.
371
373
  const date = typeof safe === 'string' ? new Date(safe) : safe;
372
374
  const isValidDate = date instanceof Date && !isNaN(date.getTime());
373
375
  const startOfToday = new Date();
374
376
  startOfToday.setHours(0, 0, 0, 0);
375
- const isOverdue = isValidDate && date < startOfToday;
377
+ const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
378
+ const dueLike =
379
+ dateField?.dueLike === true ||
380
+ /(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
381
+ const isOverdue = dueLike && isValidDate && date < startOfToday;
376
382
  const isoString = isValidDate ? date.toISOString() : String(safe);
377
-
383
+
378
384
  return (
379
385
  <span
380
386
  className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
@@ -452,16 +458,21 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
452
458
  };
453
459
 
454
460
  // Color to Tailwind class mapping for custom Badge styling
461
+ // Color → Tailwind class mapping for status-style badges.
462
+ // Uses the modern "soft pill" pattern (Tailwind UI style): -50 background,
463
+ // -700 text, hairline -200 border. Dark mode mirrors with -950/40 surface
464
+ // and -300 text. This keeps status fields readable without the heavy,
465
+ // candy-colored look of the older -100/-300/-800 combination.
455
466
  const BADGE_COLOR_MAP: Record<string, string> = {
456
- gray: 'bg-gray-100 text-gray-800 border-gray-300',
457
- red: 'bg-red-100 text-red-800 border-red-300',
458
- orange: 'bg-orange-100 text-orange-800 border-orange-300',
459
- yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300',
460
- green: 'bg-green-100 text-green-800 border-green-300',
461
- blue: 'bg-blue-100 text-blue-800 border-blue-300',
462
- indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300',
463
- purple: 'bg-purple-100 text-purple-800 border-purple-300',
464
- pink: 'bg-pink-100 text-pink-800 border-pink-300',
467
+ gray: 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800/50 dark:text-gray-200 dark:border-gray-700/60',
468
+ red: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900/60',
469
+ orange: 'bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-900/60',
470
+ yellow: 'bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-300 dark:border-yellow-900/60',
471
+ green: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900/60',
472
+ blue: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900/60',
473
+ indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:border-indigo-900/60',
474
+ purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900/60',
475
+ pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-950/40 dark:text-pink-300 dark:border-pink-900/60',
465
476
  };
466
477
 
467
478
  // Color palette used by the deterministic fallback when no schema/semantic
@@ -746,7 +757,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
746
757
  return (
747
758
  <span
748
759
  key={idx}
749
- className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"
760
+ className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200"
750
761
  >
751
762
  {label}
752
763
  </span>
@@ -1361,6 +1372,8 @@ export * from './widgets/LookupField';
1361
1372
  export * from './widgets/RecordPickerDialog';
1362
1373
  export * from './widgets/FileField';
1363
1374
  export * from './widgets/ImageField';
1375
+ export { ImageCropperDialog } from './widgets/ImageCropperDialog';
1376
+ export type { ImageCropperDialogProps } from './widgets/ImageCropperDialog';
1364
1377
  export * from './widgets/LocationField';
1365
1378
  export * from './widgets/FormulaField';
1366
1379
  export * from './widgets/SummaryField';
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for the dependent-lookups feature: `field.depends_on` should add
3
+ * an `$filter` chain when querying, gate the trigger when dependencies are
4
+ * empty, and re-fetch when the dependency value changes.
5
+ */
6
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import React, { useState } from 'react';
9
+ import { LookupField } from './widgets/LookupField';
10
+
11
+ const mockDataSource = {
12
+ find: vi.fn(),
13
+ findOne: vi.fn(),
14
+ create: vi.fn(),
15
+ update: vi.fn(),
16
+ delete: vi.fn(),
17
+ };
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
22
+ });
23
+
24
+ describe('LookupField — depends_on', () => {
25
+ const baseField: any = {
26
+ name: 'city',
27
+ type: 'lookup',
28
+ reference: 'cities',
29
+ display_field: 'name',
30
+ id_field: 'id',
31
+ depends_on: ['country'],
32
+ };
33
+
34
+ it('disables the trigger when a dependency value is missing', () => {
35
+ render(
36
+ <LookupField
37
+ field={baseField}
38
+ value={null}
39
+ onChange={vi.fn()}
40
+ readonly={false}
41
+ dataSource={mockDataSource as any}
42
+ dependentValues={{}}
43
+ />,
44
+ );
45
+ const trigger = screen.getByTestId('lookup-trigger-gated');
46
+ expect(trigger).toBeDisabled();
47
+ expect(trigger).toHaveTextContent(/Select country first/i);
48
+ });
49
+
50
+ it('enables the trigger and includes a filter chain once the dependency is set', async () => {
51
+ render(
52
+ <LookupField
53
+ field={baseField}
54
+ value={null}
55
+ onChange={vi.fn()}
56
+ readonly={false}
57
+ dataSource={mockDataSource as any}
58
+ dependentValues={{ country: 'US' }}
59
+ />,
60
+ );
61
+ // Trigger is enabled (no gated test id).
62
+ expect(screen.queryByTestId('lookup-trigger-gated')).not.toBeInTheDocument();
63
+
64
+ // Open the popover to fire fetch.
65
+ await act(async () => {
66
+ fireEvent.click(screen.getByRole('button', { name: /select|search/i }));
67
+ });
68
+
69
+ await waitFor(() => expect(mockDataSource.find).toHaveBeenCalled());
70
+ const [, params] = mockDataSource.find.mock.calls[0];
71
+ expect(params.$filter).toEqual({ country: 'US' });
72
+ });
73
+
74
+ it('supports the explicit { field, param } shape', async () => {
75
+ const explicitField: any = {
76
+ ...baseField,
77
+ depends_on: [{ field: 'country', param: 'country_id' }],
78
+ };
79
+ render(
80
+ <LookupField
81
+ field={explicitField}
82
+ value={null}
83
+ onChange={vi.fn()}
84
+ readonly={false}
85
+ dataSource={mockDataSource as any}
86
+ dependentValues={{ country: 42 }}
87
+ />,
88
+ );
89
+ await act(async () => {
90
+ fireEvent.click(screen.getByRole('button', { name: /select|search/i }));
91
+ });
92
+ await waitFor(() => expect(mockDataSource.find).toHaveBeenCalled());
93
+ const [, params] = mockDataSource.find.mock.calls[0];
94
+ // numeric value, no quoting
95
+ expect(params.$filter).toEqual({ country_id: 42 });
96
+ });
97
+
98
+ it('re-fetches when the dependency value changes while the picker is open', async () => {
99
+ function Harness() {
100
+ const [country, setCountry] = useState('US');
101
+ return (
102
+ <div>
103
+ <button data-testid="set-uk" onClick={() => setCountry('UK')}>UK</button>
104
+ <LookupField
105
+ field={baseField}
106
+ value={null}
107
+ onChange={vi.fn()}
108
+ readonly={false}
109
+ dataSource={mockDataSource as any}
110
+ dependentValues={{ country }}
111
+ />
112
+ </div>
113
+ );
114
+ }
115
+ render(<Harness />);
116
+ // Open the popover.
117
+ await act(async () => {
118
+ fireEvent.click(screen.getByRole('button', { name: /select|search/i }));
119
+ });
120
+ await waitFor(() => expect(mockDataSource.find).toHaveBeenCalledTimes(1));
121
+
122
+ // Change the dependency — should re-fetch.
123
+ await act(async () => {
124
+ fireEvent.click(screen.getByTestId('set-uk'));
125
+ });
126
+ await waitFor(() => expect(mockDataSource.find).toHaveBeenCalledTimes(2));
127
+ const [, paramsAfter] = mockDataSource.find.mock.calls[1];
128
+ expect(paramsAfter.$filter).toEqual({ country: 'UK' });
129
+ });
130
+ });
@@ -133,7 +133,20 @@ describe('Remaining Field Widgets', () => {
133
133
  expect(imgs).toHaveLength(2);
134
134
  expect(imgs[0]).toHaveAttribute('src', 'http://example.com/1.jpg');
135
135
  });
136
-
136
+
137
+ it('renders a Crop button per image when crop is enabled (default)', () => {
138
+ render(<ImageField {...baseProps} value={images} field={{ ...baseProps.field, multiple: true } as any} />);
139
+ // Two crop buttons (one per image) with stable test ids — opacity is hover-controlled
140
+ // but the buttons are still present in the DOM for a11y/keyboard users.
141
+ expect(screen.getByTestId('image-field-crop-0')).toBeInTheDocument();
142
+ expect(screen.getByTestId('image-field-crop-1')).toBeInTheDocument();
143
+ });
144
+
145
+ it('hides the Crop button when field.crop = false', () => {
146
+ render(<ImageField {...baseProps} value={images} field={{ ...baseProps.field, multiple: true, crop: false } as any} />);
147
+ expect(screen.queryByTestId('image-field-crop-0')).not.toBeInTheDocument();
148
+ });
149
+
137
150
  // Complex interaction omitted for brevity, focusing on render contracts
138
151
  });
139
152