@object-ui/components 3.0.3 → 3.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 (54) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/dist/index.css +1 -1
  3. package/dist/index.js +24701 -22929
  4. package/dist/index.umd.cjs +37 -37
  5. package/dist/src/custom/config-field-renderer.d.ts +21 -0
  6. package/dist/src/custom/config-panel-renderer.d.ts +81 -0
  7. package/dist/src/custom/config-row.d.ts +27 -0
  8. package/dist/src/custom/index.d.ts +5 -0
  9. package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
  10. package/dist/src/custom/navigation-overlay.d.ts +8 -0
  11. package/dist/src/custom/section-header.d.ts +31 -0
  12. package/dist/src/debug/DebugPanel.d.ts +39 -0
  13. package/dist/src/debug/index.d.ts +9 -0
  14. package/dist/src/hooks/use-config-draft.d.ts +46 -0
  15. package/dist/src/index.d.ts +4 -0
  16. package/dist/src/renderers/action/action-bar.d.ts +23 -0
  17. package/dist/src/types/config-panel.d.ts +92 -0
  18. package/dist/src/ui/sheet.d.ts +2 -0
  19. package/dist/src/ui/sidebar.d.ts +4 -0
  20. package/package.json +17 -17
  21. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
  22. package/src/__tests__/action-bar.test.tsx +172 -0
  23. package/src/__tests__/config-field-renderer.test.tsx +307 -0
  24. package/src/__tests__/config-panel-renderer.test.tsx +580 -0
  25. package/src/__tests__/config-primitives.test.tsx +106 -0
  26. package/src/__tests__/mobile-accessibility.test.tsx +120 -0
  27. package/src/__tests__/navigation-overlay.test.tsx +97 -0
  28. package/src/__tests__/use-config-draft.test.tsx +295 -0
  29. package/src/custom/config-field-renderer.tsx +276 -0
  30. package/src/custom/config-panel-renderer.tsx +306 -0
  31. package/src/custom/config-row.tsx +50 -0
  32. package/src/custom/index.ts +5 -0
  33. package/src/custom/mobile-dialog-content.tsx +67 -0
  34. package/src/custom/navigation-overlay.tsx +42 -4
  35. package/src/custom/section-header.tsx +68 -0
  36. package/src/debug/DebugPanel.tsx +313 -0
  37. package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
  38. package/src/debug/index.ts +10 -0
  39. package/src/hooks/use-config-draft.ts +127 -0
  40. package/src/index.css +4 -0
  41. package/src/index.ts +15 -0
  42. package/src/renderers/action/action-bar.tsx +202 -0
  43. package/src/renderers/action/index.ts +1 -0
  44. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
  45. package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
  46. package/src/renderers/complex/data-table.tsx +346 -43
  47. package/src/renderers/data-display/breadcrumb.tsx +3 -2
  48. package/src/renderers/form/form.tsx +4 -4
  49. package/src/renderers/navigation/header-bar.tsx +69 -10
  50. package/src/stories/ConfigPanel.stories.tsx +232 -0
  51. package/src/types/config-panel.ts +101 -0
  52. package/src/ui/dialog.tsx +20 -3
  53. package/src/ui/sheet.tsx +6 -3
  54. package/src/ui/sidebar.tsx +93 -9
@@ -0,0 +1,120 @@
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
+ /**
10
+ * Mobile Accessibility Audit — axe-core checks at mobile viewport widths.
11
+ *
12
+ * Verifies that common UI patterns (buttons, forms, navigation, dialogs,
13
+ * tables) remain WCAG 2.1 AA compliant when rendered in a narrow container
14
+ * that simulates a mobile viewport. Part of P3 Mobile Testing & QA.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { render } from '@testing-library/react';
19
+ import { axe } from 'vitest-axe';
20
+ import React from 'react';
21
+
22
+ async function expectNoViolations(container: HTMLElement) {
23
+ const results = await axe(container);
24
+ const violations = (results as any).violations || [];
25
+ if (violations.length > 0) {
26
+ const messages = violations.map(
27
+ (v: any) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} instance(s))`,
28
+ );
29
+ throw new Error(
30
+ `Expected no accessibility violations, but found ${violations.length}:\n${messages.join('\n')}`,
31
+ );
32
+ }
33
+ }
34
+
35
+ const MOBILE_WIDTH = 375;
36
+
37
+ describe('Mobile Accessibility Audit', () => {
38
+ it('should have no axe violations for Button at mobile viewport', async () => {
39
+ const { container } = render(
40
+ <div style={{ width: `${MOBILE_WIDTH}px` }}>
41
+ <button type="button">Click me</button>
42
+ </div>,
43
+ );
44
+ await expectNoViolations(container);
45
+ });
46
+
47
+ it('should have no axe violations for form inputs at mobile viewport', async () => {
48
+ const { container } = render(
49
+ <div style={{ width: `${MOBILE_WIDTH}px` }}>
50
+ <form>
51
+ <label htmlFor="name">Name</label>
52
+ <input id="name" type="text" />
53
+ <label htmlFor="email">Email</label>
54
+ <input id="email" type="email" />
55
+ <button type="submit">Submit</button>
56
+ </form>
57
+ </div>,
58
+ );
59
+ await expectNoViolations(container);
60
+ });
61
+
62
+ it('should have no axe violations for navigation at mobile viewport', async () => {
63
+ const { container } = render(
64
+ <div style={{ width: `${MOBILE_WIDTH}px` }}>
65
+ <nav aria-label="Main navigation">
66
+ <ul>
67
+ <li>
68
+ <a href="/">Home</a>
69
+ </li>
70
+ <li>
71
+ <a href="/about">About</a>
72
+ </li>
73
+ </ul>
74
+ </nav>
75
+ </div>,
76
+ );
77
+ await expectNoViolations(container);
78
+ });
79
+
80
+ it('should have no axe violations for dialog at mobile viewport', async () => {
81
+ const { container } = render(
82
+ <div style={{ width: `${MOBILE_WIDTH}px` }}>
83
+ <div role="dialog" aria-label="Confirmation" aria-modal="true">
84
+ <h2>Confirm Action</h2>
85
+ <p>Are you sure?</p>
86
+ <button type="button">Yes</button>
87
+ <button type="button">No</button>
88
+ </div>
89
+ </div>,
90
+ );
91
+ await expectNoViolations(container);
92
+ });
93
+
94
+ it('should have no axe violations for data table at mobile viewport', async () => {
95
+ const { container } = render(
96
+ <div style={{ width: `${MOBILE_WIDTH}px`, overflow: 'auto' }}>
97
+ <table>
98
+ <caption>Sample data</caption>
99
+ <thead>
100
+ <tr>
101
+ <th>Name</th>
102
+ <th>Value</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ <tr>
107
+ <td>Item 1</td>
108
+ <td>100</td>
109
+ </tr>
110
+ <tr>
111
+ <td>Item 2</td>
112
+ <td>200</td>
113
+ </tr>
114
+ </tbody>
115
+ </table>
116
+ </div>,
117
+ );
118
+ await expectNoViolations(container);
119
+ });
120
+ });
@@ -219,6 +219,31 @@ describe('NavigationOverlay', () => {
219
219
  );
220
220
  expect(screen.getByText('Quick View')).toBeInTheDocument();
221
221
  });
222
+
223
+ it('should render fallback dialog when no popoverTrigger is provided', () => {
224
+ render(
225
+ <NavigationOverlay
226
+ {...createProps({
227
+ mode: 'popover',
228
+ title: 'Preview',
229
+ })}
230
+ />
231
+ );
232
+ expect(screen.getByText('Preview')).toBeInTheDocument();
233
+ expect(screen.getByText('Test Record')).toBeInTheDocument();
234
+ });
235
+
236
+ it('should not render fallback dialog when closed and no popoverTrigger', () => {
237
+ const { container } = render(
238
+ <NavigationOverlay
239
+ {...createProps({
240
+ mode: 'popover',
241
+ isOpen: false,
242
+ })}
243
+ />
244
+ );
245
+ expect(container.innerHTML).toBe('');
246
+ });
222
247
  });
223
248
 
224
249
  // ============================================================
@@ -270,4 +295,76 @@ describe('NavigationOverlay', () => {
270
295
  expect(screen.getByTestId('status')).toHaveTextContent('active');
271
296
  });
272
297
  });
298
+
299
+ // ============================================================
300
+ // renderView support
301
+ // ============================================================
302
+
303
+ describe('renderView support', () => {
304
+ it('should use renderView when both renderView and view are provided', () => {
305
+ render(
306
+ <NavigationOverlay
307
+ {...createProps({
308
+ mode: 'drawer',
309
+ view: 'contact-detail',
310
+ renderView: (record, viewName) => (
311
+ <div data-testid="custom-view">
312
+ <span data-testid="view-name">{viewName}</span>
313
+ <span data-testid="record-name">{String(record.name)}</span>
314
+ </div>
315
+ ),
316
+ })}
317
+ />
318
+ );
319
+ expect(screen.getByTestId('custom-view')).toBeInTheDocument();
320
+ expect(screen.getByTestId('view-name')).toHaveTextContent('contact-detail');
321
+ expect(screen.getByTestId('record-name')).toHaveTextContent('Test Record');
322
+ // Children should NOT be rendered
323
+ expect(screen.queryByTestId('record-content')).not.toBeInTheDocument();
324
+ });
325
+
326
+ it('should fallback to children when renderView is provided but view is not', () => {
327
+ render(
328
+ <NavigationOverlay
329
+ {...createProps({
330
+ mode: 'drawer',
331
+ renderView: (_record, _viewName) => (
332
+ <div data-testid="custom-view">Should not appear</div>
333
+ ),
334
+ })}
335
+ />
336
+ );
337
+ // Children should be rendered since view is undefined
338
+ expect(screen.getByTestId('record-content')).toBeInTheDocument();
339
+ expect(screen.queryByTestId('custom-view')).not.toBeInTheDocument();
340
+ });
341
+
342
+ it('should fallback to children when view is provided but renderView is not', () => {
343
+ render(
344
+ <NavigationOverlay
345
+ {...createProps({
346
+ mode: 'drawer',
347
+ view: 'contact-detail',
348
+ })}
349
+ />
350
+ );
351
+ // Children should be rendered since renderView is undefined
352
+ expect(screen.getByTestId('record-content')).toBeInTheDocument();
353
+ });
354
+
355
+ it('should use renderView in modal mode', () => {
356
+ render(
357
+ <NavigationOverlay
358
+ {...createProps({
359
+ mode: 'modal',
360
+ view: 'edit-form',
361
+ renderView: (record, viewName) => (
362
+ <div data-testid="modal-custom-view">{viewName}: {String(record.name)}</div>
363
+ ),
364
+ })}
365
+ />
366
+ );
367
+ expect(screen.getByTestId('modal-custom-view')).toHaveTextContent('edit-form: Test Record');
368
+ });
369
+ });
273
370
  });
@@ -0,0 +1,295 @@
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 { describe, it, expect, vi } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import { useConfigDraft } from '../hooks/use-config-draft';
12
+
13
+ describe('useConfigDraft', () => {
14
+ const source = { name: 'Test', columns: 3, gap: 4, enabled: true };
15
+
16
+ it('should initialize draft from source', () => {
17
+ const { result } = renderHook(() => useConfigDraft(source));
18
+ expect(result.current.draft).toEqual(source);
19
+ expect(result.current.isDirty).toBe(false);
20
+ });
21
+
22
+ it('should start dirty in create mode', () => {
23
+ const { result } = renderHook(() =>
24
+ useConfigDraft(source, { mode: 'create' }),
25
+ );
26
+ expect(result.current.isDirty).toBe(true);
27
+ });
28
+
29
+ it('should start clean in edit mode', () => {
30
+ const { result } = renderHook(() =>
31
+ useConfigDraft(source, { mode: 'edit' }),
32
+ );
33
+ expect(result.current.isDirty).toBe(false);
34
+ });
35
+
36
+ it('should update a single field and mark dirty', () => {
37
+ const { result } = renderHook(() => useConfigDraft(source));
38
+ act(() => {
39
+ result.current.updateField('columns', 6);
40
+ });
41
+ expect(result.current.draft.columns).toBe(6);
42
+ expect(result.current.isDirty).toBe(true);
43
+ });
44
+
45
+ it('should preserve other fields when updating one', () => {
46
+ const { result } = renderHook(() => useConfigDraft(source));
47
+ act(() => {
48
+ result.current.updateField('gap', 8);
49
+ });
50
+ expect(result.current.draft.name).toBe('Test');
51
+ expect(result.current.draft.columns).toBe(3);
52
+ expect(result.current.draft.gap).toBe(8);
53
+ });
54
+
55
+ it('should discard changes and revert to source', () => {
56
+ const { result } = renderHook(() => useConfigDraft(source));
57
+ act(() => {
58
+ result.current.updateField('name', 'Modified');
59
+ result.current.updateField('columns', 12);
60
+ });
61
+ expect(result.current.isDirty).toBe(true);
62
+
63
+ act(() => {
64
+ result.current.discard();
65
+ });
66
+ expect(result.current.draft).toEqual(source);
67
+ expect(result.current.isDirty).toBe(false);
68
+ });
69
+
70
+ it('should call onUpdate callback when field changes', () => {
71
+ const onUpdate = vi.fn();
72
+ const { result } = renderHook(() =>
73
+ useConfigDraft(source, { onUpdate }),
74
+ );
75
+ act(() => {
76
+ result.current.updateField('gap', 10);
77
+ });
78
+ expect(onUpdate).toHaveBeenCalledWith('gap', 10);
79
+ expect(onUpdate).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('should reset draft when source changes', () => {
83
+ const source1 = { name: 'A', value: 1 };
84
+ const source2 = { name: 'B', value: 2 };
85
+
86
+ const { result, rerender } = renderHook(
87
+ ({ src }) => useConfigDraft(src),
88
+ { initialProps: { src: source1 } },
89
+ );
90
+
91
+ act(() => {
92
+ result.current.updateField('name', 'Modified');
93
+ });
94
+ expect(result.current.isDirty).toBe(true);
95
+
96
+ rerender({ src: source2 });
97
+ expect(result.current.draft).toEqual(source2);
98
+ expect(result.current.isDirty).toBe(false);
99
+ });
100
+
101
+ it('should handle setDraft for advanced use cases', () => {
102
+ const { result } = renderHook(() => useConfigDraft(source));
103
+ act(() => {
104
+ result.current.setDraft({ ...source, name: 'Advanced', extra: 42 });
105
+ });
106
+ expect(result.current.draft.name).toBe('Advanced');
107
+ expect(result.current.draft.extra).toBe(42);
108
+ });
109
+
110
+ it('should not share reference with source', () => {
111
+ const mutableSource = { name: 'Original' };
112
+ const { result } = renderHook(() => useConfigDraft(mutableSource));
113
+ expect(result.current.draft).not.toBe(mutableSource);
114
+ expect(result.current.draft).toEqual(mutableSource);
115
+ });
116
+
117
+ // ── Undo / Redo ──────────────────────────────────────────────
118
+
119
+ describe('undo/redo', () => {
120
+ it('should start with canUndo=false and canRedo=false', () => {
121
+ const { result } = renderHook(() => useConfigDraft(source));
122
+ expect(result.current.canUndo).toBe(false);
123
+ expect(result.current.canRedo).toBe(false);
124
+ });
125
+
126
+ it('should enable undo after a field update', () => {
127
+ const { result } = renderHook(() => useConfigDraft(source));
128
+ act(() => {
129
+ result.current.updateField('name', 'Changed');
130
+ });
131
+ expect(result.current.canUndo).toBe(true);
132
+ expect(result.current.canRedo).toBe(false);
133
+ });
134
+
135
+ it('should undo to previous state', () => {
136
+ const { result } = renderHook(() => useConfigDraft(source));
137
+ act(() => {
138
+ result.current.updateField('name', 'Changed');
139
+ });
140
+ expect(result.current.draft.name).toBe('Changed');
141
+
142
+ act(() => {
143
+ result.current.undo();
144
+ });
145
+ expect(result.current.draft.name).toBe('Test');
146
+ expect(result.current.canUndo).toBe(false);
147
+ expect(result.current.canRedo).toBe(true);
148
+ });
149
+
150
+ it('should redo after undo', () => {
151
+ const { result } = renderHook(() => useConfigDraft(source));
152
+ act(() => {
153
+ result.current.updateField('name', 'Changed');
154
+ });
155
+ act(() => {
156
+ result.current.undo();
157
+ });
158
+ act(() => {
159
+ result.current.redo();
160
+ });
161
+ expect(result.current.draft.name).toBe('Changed');
162
+ expect(result.current.canUndo).toBe(true);
163
+ expect(result.current.canRedo).toBe(false);
164
+ });
165
+
166
+ it('should clear redo stack when a new change is made after undo', () => {
167
+ const { result } = renderHook(() => useConfigDraft(source));
168
+ act(() => {
169
+ result.current.updateField('name', 'First');
170
+ });
171
+ act(() => {
172
+ result.current.updateField('name', 'Second');
173
+ });
174
+ act(() => {
175
+ result.current.undo();
176
+ });
177
+ expect(result.current.canRedo).toBe(true);
178
+
179
+ act(() => {
180
+ result.current.updateField('name', 'Third');
181
+ });
182
+ expect(result.current.canRedo).toBe(false);
183
+ expect(result.current.draft.name).toBe('Third');
184
+ });
185
+
186
+ it('should support multiple undo steps', () => {
187
+ const { result } = renderHook(() => useConfigDraft(source));
188
+ act(() => {
189
+ result.current.updateField('name', 'A');
190
+ });
191
+ act(() => {
192
+ result.current.updateField('name', 'B');
193
+ });
194
+ act(() => {
195
+ result.current.updateField('name', 'C');
196
+ });
197
+ expect(result.current.draft.name).toBe('C');
198
+
199
+ act(() => {
200
+ result.current.undo();
201
+ });
202
+ expect(result.current.draft.name).toBe('B');
203
+
204
+ act(() => {
205
+ result.current.undo();
206
+ });
207
+ expect(result.current.draft.name).toBe('A');
208
+
209
+ act(() => {
210
+ result.current.undo();
211
+ });
212
+ expect(result.current.draft.name).toBe('Test');
213
+ expect(result.current.canUndo).toBe(false);
214
+ });
215
+
216
+ it('should do nothing when undoing with empty history', () => {
217
+ const { result } = renderHook(() => useConfigDraft(source));
218
+ act(() => {
219
+ result.current.undo();
220
+ });
221
+ expect(result.current.draft).toEqual(source);
222
+ });
223
+
224
+ it('should do nothing when redoing with empty future', () => {
225
+ const { result } = renderHook(() => useConfigDraft(source));
226
+ act(() => {
227
+ result.current.redo();
228
+ });
229
+ expect(result.current.draft).toEqual(source);
230
+ });
231
+
232
+ it('should clear history on discard', () => {
233
+ const { result } = renderHook(() => useConfigDraft(source));
234
+ act(() => {
235
+ result.current.updateField('name', 'Changed');
236
+ });
237
+ expect(result.current.canUndo).toBe(true);
238
+
239
+ act(() => {
240
+ result.current.discard();
241
+ });
242
+ expect(result.current.canUndo).toBe(false);
243
+ expect(result.current.canRedo).toBe(false);
244
+ });
245
+
246
+ it('should clear history when source changes', () => {
247
+ const source1 = { name: 'A', value: 1 };
248
+ const source2 = { name: 'B', value: 2 };
249
+ const { result, rerender } = renderHook(
250
+ ({ src }) => useConfigDraft(src),
251
+ { initialProps: { src: source1 } },
252
+ );
253
+ act(() => {
254
+ result.current.updateField('name', 'Modified');
255
+ });
256
+ expect(result.current.canUndo).toBe(true);
257
+
258
+ rerender({ src: source2 });
259
+ expect(result.current.canUndo).toBe(false);
260
+ expect(result.current.canRedo).toBe(false);
261
+ });
262
+
263
+ it('should respect maxHistory limit', () => {
264
+ const { result } = renderHook(() =>
265
+ useConfigDraft(source, { maxHistory: 3 }),
266
+ );
267
+ act(() => {
268
+ result.current.updateField('name', 'A');
269
+ });
270
+ act(() => {
271
+ result.current.updateField('name', 'B');
272
+ });
273
+ act(() => {
274
+ result.current.updateField('name', 'C');
275
+ });
276
+ act(() => {
277
+ result.current.updateField('name', 'D');
278
+ });
279
+ // maxHistory=3, so only 3 undo steps (not 4)
280
+ act(() => {
281
+ result.current.undo();
282
+ });
283
+ expect(result.current.draft.name).toBe('C');
284
+ act(() => {
285
+ result.current.undo();
286
+ });
287
+ expect(result.current.draft.name).toBe('B');
288
+ act(() => {
289
+ result.current.undo();
290
+ });
291
+ // Should stop here — oldest entry was trimmed
292
+ expect(result.current.canUndo).toBe(false);
293
+ });
294
+ });
295
+ });