@bsuite/page-builder 0.6.3 → 0.8.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.
- package/dist/PageGridLayout.d.ts +1 -1
- package/dist/PageGridLayout.d.ts.map +1 -1
- package/dist/PageGridLayout.js +190 -21
- package/dist/RelationshipField.d.ts +77 -0
- package/dist/RelationshipField.d.ts.map +1 -0
- package/dist/RelationshipField.js +158 -0
- package/dist/buildResponsiveLayouts.d.ts +61 -4
- package/dist/buildResponsiveLayouts.d.ts.map +1 -1
- package/dist/buildResponsiveLayouts.js +77 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/relationshipCatalog.d.ts +22 -0
- package/dist/relationshipCatalog.d.ts.map +1 -0
- package/dist/relationshipCatalog.js +26 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/usePageGridLayout.d.ts +22 -1
- package/dist/usePageGridLayout.d.ts.map +1 -1
- package/dist/usePageGridLayout.js +197 -66
- package/package.json +1 -1
package/dist/PageGridLayout.d.ts
CHANGED
|
@@ -2,5 +2,5 @@ import React from 'react';
|
|
|
2
2
|
import 'react-grid-layout/css/styles.css';
|
|
3
3
|
import 'react-resizable/css/styles.css';
|
|
4
4
|
import type { PageGridLayoutProps } from './types.js';
|
|
5
|
-
export declare function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVersion, canEditPage, editorEventNames, preferenceAdapter, widgets, widgetMeta, className, isResizable, resizeHandles, addEntityWidgetEventNames, createEntityWidget, onRegisterEntityWidget, }: PageGridLayoutProps): React.JSX.Element;
|
|
5
|
+
export declare function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVersion, canEditPage, editorEventNames, preferenceAdapter, widgets, widgetMeta, className, isResizable, resizeHandles, tenantId, defaultAutoHeight, addEntityWidgetEventNames, createEntityWidget, onRegisterEntityWidget, relationshipCatalog, addRelationshipWidgetEventNames, createRelationshipWidget, onRegisterRelationshipWidget, onRelationshipWidgetRejected, }: PageGridLayoutProps): React.JSX.Element;
|
|
6
6
|
//# sourceMappingURL=PageGridLayout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PageGridLayout.d.ts","sourceRoot":"","sources":["../src/PageGridLayout.tsx"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"PageGridLayout.d.ts","sourceRoot":"","sources":["../src/PageGridLayout.tsx"],"names":[],"mappings":"AACA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,kCAAkC,CAAC;AAC1C,OAAO,gCAAgC,CAAC;AAMxC,OAAO,KAAK,EAAe,mBAAmB,EAA4B,MAAM,YAAY,CAAC;AA8U7F,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,OAAO,EACP,UAAU,EACV,SAAS,EAMT,WAAkB,EAClB,aAAsC,EACtC,QAAQ,EACR,iBAAiB,EACjB,yBAAiE,EACjE,kBAAkB,EAClB,sBAAsB,EACtB,mBAAmB,EACnB,+BAA6E,EAC7E,wBAAwB,EACxB,4BAA4B,EAC5B,4BAA4B,GAC7B,EAAE,mBAAmB,qBA6xBrB"}
|
package/dist/PageGridLayout.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ArrowDown, ArrowUp, ChevronDown, ChevronUp, Eye, EyeOff, Layers, LayoutGrid, Lock, Plus, RotateCcw, Save, Settings2, Unlock } from 'lucide-react';
|
|
3
|
-
import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import React, { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
4
4
|
import { Responsive } from 'react-grid-layout';
|
|
5
5
|
import { gridBounds, minMaxSize, minSize } from 'react-grid-layout/core';
|
|
6
6
|
import 'react-grid-layout/css/styles.css';
|
|
7
7
|
import 'react-resizable/css/styles.css';
|
|
8
8
|
import { computeAutoHeightRows } from './autoHeight.js';
|
|
9
9
|
import { defaultPreferenceAdapter } from './preferences.js';
|
|
10
|
+
import { isRelationshipWritable } from './relationshipCatalog.js';
|
|
10
11
|
import { usePageGridLayout } from './usePageGridLayout.js';
|
|
11
12
|
import { cn } from './utils.js';
|
|
12
13
|
/**
|
|
@@ -48,6 +49,15 @@ const DEFAULT_CARD_CHROME_PX = 2;
|
|
|
48
49
|
const DEFAULT_RESIZE_BOUNDS = { minW: 2, minH: 1 };
|
|
49
50
|
const DEFAULT_RESIZE_HANDLES = ['se'];
|
|
50
51
|
const DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES = ['bsu-add-entity-widget', 'crm7-add-entity-widget'];
|
|
52
|
+
/**
|
|
53
|
+
* Second widget kind (W4-1) — a relationship field, distinct from the
|
|
54
|
+
* entity-list events above. Named analogously so a consumer wiring both
|
|
55
|
+
* kinds recognises the pattern immediately.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_ADD_RELATIONSHIP_WIDGET_EVENT_NAMES = [
|
|
58
|
+
'bsu-add-relationship-widget',
|
|
59
|
+
'crm7-add-relationship-widget',
|
|
60
|
+
];
|
|
51
61
|
/**
|
|
52
62
|
* Custom resize handle for react-grid-layout v2.
|
|
53
63
|
*
|
|
@@ -87,6 +97,71 @@ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, is
|
|
|
87
97
|
const measureRef = useRef(null);
|
|
88
98
|
const lastReportedRowsRef = useRef(null);
|
|
89
99
|
const measureRafRef = useRef(null);
|
|
100
|
+
/**
|
|
101
|
+
* Latest measured content height, updated on EVERY ResizeObserver entry.
|
|
102
|
+
*
|
|
103
|
+
* D-76 root cause ("cards render half cut off on page open"). The previous
|
|
104
|
+
* implementation captured `contentPx` in the rAF closure and then bailed
|
|
105
|
+
* out of any further entry that arrived before that frame ran:
|
|
106
|
+
*
|
|
107
|
+
* if (measureRafRef.current !== null) return; // <- measurement lost
|
|
108
|
+
*
|
|
109
|
+
* That is first-writer-wins inside a frame, and the first writer is
|
|
110
|
+
* routinely the WRONG one: a card mounts empty or skeletonised, the
|
|
111
|
+
* observer fires at (say) 90px and schedules the frame, the query resolves
|
|
112
|
+
* and the observer fires again at 760px — and that second entry returned
|
|
113
|
+
* early, discarding the real height. The frame then converged the card to
|
|
114
|
+
* ~3 rows. Because `computeAutoHeightRows` is a pure function of the height
|
|
115
|
+
* it is given, nothing re-triggered: the card stayed at the empty-state
|
|
116
|
+
* height with its content clipped by the `overflow-hidden` autoHeight
|
|
117
|
+
* wrapper until something else happened to resize it.
|
|
118
|
+
*
|
|
119
|
+
* Storing the height in a ref and READING IT INSIDE the frame makes it
|
|
120
|
+
* last-writer-wins, which is the only correct policy for "how tall is this
|
|
121
|
+
* content right now".
|
|
122
|
+
*/
|
|
123
|
+
const latestContentPxRef = useRef(null);
|
|
124
|
+
const reportRows = useCallback((contentPx) => {
|
|
125
|
+
if (!onAutoHeightChange)
|
|
126
|
+
return;
|
|
127
|
+
const rows = computeAutoHeightRows({
|
|
128
|
+
contentPx,
|
|
129
|
+
cardChromePx: DEFAULT_CARD_CHROME_PX,
|
|
130
|
+
rowHeightPx: DEFAULT_ROW_HEIGHT,
|
|
131
|
+
marginYPx: DEFAULT_MARGIN[1],
|
|
132
|
+
});
|
|
133
|
+
if (rows === lastReportedRowsRef.current)
|
|
134
|
+
return;
|
|
135
|
+
lastReportedRowsRef.current = rows;
|
|
136
|
+
onAutoHeightChange(id, rows);
|
|
137
|
+
}, [id, onAutoHeightChange]);
|
|
138
|
+
/**
|
|
139
|
+
* First measurement BEFORE paint (second half of D-76).
|
|
140
|
+
*
|
|
141
|
+
* A ResizeObserver's initial observation is delivered asynchronously, and
|
|
142
|
+
* the rAF hop below adds another frame. Until it lands the card renders at
|
|
143
|
+
* its seed `h` — 6 rows / 192px for a `DraggableCardPage` card — with
|
|
144
|
+
* `overflow-hidden`, so the first painted frame of a taller card is
|
|
145
|
+
* literally cut in half. That is what the operator sees "on page open",
|
|
146
|
+
* every open, on every surface with content taller than its seed.
|
|
147
|
+
*
|
|
148
|
+
* Measuring in a layout effect and reporting synchronously puts the correct
|
|
149
|
+
* row count into state within the same commit, so the browser never paints
|
|
150
|
+
* the clipped frame. React batches these updates across all GridItems
|
|
151
|
+
* mounting together, and `applyAutoHeightRows` is a functional update, so N
|
|
152
|
+
* cards settling at once cost one re-render and cannot clobber each other.
|
|
153
|
+
*/
|
|
154
|
+
useLayoutEffect(() => {
|
|
155
|
+
if (!autoHeight || !onAutoHeightChange)
|
|
156
|
+
return;
|
|
157
|
+
const el = measureRef.current;
|
|
158
|
+
if (!el)
|
|
159
|
+
return;
|
|
160
|
+
const contentPx = el.getBoundingClientRect().height;
|
|
161
|
+
latestContentPxRef.current = contentPx;
|
|
162
|
+
if (contentPx > 0)
|
|
163
|
+
reportRows(contentPx);
|
|
164
|
+
}, [autoHeight, onAutoHeightChange, reportRows]);
|
|
90
165
|
useEffect(() => {
|
|
91
166
|
if (!autoHeight || !onAutoHeightChange)
|
|
92
167
|
return;
|
|
@@ -97,7 +172,7 @@ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, is
|
|
|
97
172
|
const entry = entries[0];
|
|
98
173
|
if (!entry)
|
|
99
174
|
return;
|
|
100
|
-
|
|
175
|
+
latestContentPxRef.current = entry.contentRect.height;
|
|
101
176
|
if (measureRafRef.current !== null)
|
|
102
177
|
return;
|
|
103
178
|
const schedule = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
|
|
@@ -105,16 +180,10 @@ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, is
|
|
|
105
180
|
: (cb) => setTimeout(cb, 0);
|
|
106
181
|
measureRafRef.current = schedule(() => {
|
|
107
182
|
measureRafRef.current = null;
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
marginYPx: DEFAULT_MARGIN[1],
|
|
113
|
-
});
|
|
114
|
-
if (rows !== lastReportedRowsRef.current) {
|
|
115
|
-
lastReportedRowsRef.current = rows;
|
|
116
|
-
onAutoHeightChange(id, rows);
|
|
117
|
-
}
|
|
183
|
+
const contentPx = latestContentPxRef.current;
|
|
184
|
+
if (contentPx === null)
|
|
185
|
+
return;
|
|
186
|
+
reportRows(contentPx);
|
|
118
187
|
});
|
|
119
188
|
});
|
|
120
189
|
ro.observe(el);
|
|
@@ -127,7 +196,7 @@ const GridItem = React.memo(React.forwardRef(function GridItem({ id, content, is
|
|
|
127
196
|
}
|
|
128
197
|
measureRafRef.current = null;
|
|
129
198
|
};
|
|
130
|
-
}, [autoHeight,
|
|
199
|
+
}, [autoHeight, onAutoHeightChange, reportRows]);
|
|
131
200
|
// DnD root-cause fix (2026-05-07): react-draggable@4 (used internally by
|
|
132
201
|
// react-grid-layout@2) checks the drag handle via
|
|
133
202
|
// `matchesSelectorAndParentsTo(target, handle, baseNode)` — it walks UP
|
|
@@ -153,8 +222,8 @@ export function PageGridLayout({ pageKey, defaultLayouts, defaultCols, layoutVer
|
|
|
153
222
|
// adapters then defaulted it back to true. With v2's handle gap fixed,
|
|
154
223
|
// the safe default is true so any consumer who forgets to set it still
|
|
155
224
|
// gets a usable canvas. Opt out per-page with isResizable={false}.
|
|
156
|
-
isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEventNames = DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES, createEntityWidget, onRegisterEntityWidget, }) {
|
|
157
|
-
const { currentLayouts, layoutCols, isEditing, setIsEditing, activeCols, activeCompactor, onLayoutChange, handleColumnChange, handleCompact, handleReset, addWidget, moveWidget, setWidgetLocked, applyAutoHeightRows, autoHeightRows, resetConfirmOpen, setResetConfirmOpen, containerRef, containerWidth, } = usePageGridLayout({
|
|
225
|
+
isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, tenantId, defaultAutoHeight, addEntityWidgetEventNames = DEFAULT_ADD_ENTITY_WIDGET_EVENT_NAMES, createEntityWidget, onRegisterEntityWidget, relationshipCatalog, addRelationshipWidgetEventNames = DEFAULT_ADD_RELATIONSHIP_WIDGET_EVENT_NAMES, createRelationshipWidget, onRegisterRelationshipWidget, onRelationshipWidgetRejected, }) {
|
|
226
|
+
const { currentLayouts, layoutCols, isEditing, setIsEditing, activeCols, activeCompactor, onLayoutChange, handleColumnChange, handleBreakpointChange, handleCompact, handleReset, addWidget, moveWidget, setWidgetLocked, applyAutoHeightRows, autoHeightRows, resetConfirmOpen, setResetConfirmOpen, containerRef, containerWidth, } = usePageGridLayout({
|
|
158
227
|
pageKey,
|
|
159
228
|
defaultLayouts,
|
|
160
229
|
defaultCols,
|
|
@@ -162,6 +231,7 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
162
231
|
canEditPage,
|
|
163
232
|
editorEventNames,
|
|
164
233
|
preferenceAdapter,
|
|
234
|
+
defaultAutoHeight,
|
|
165
235
|
});
|
|
166
236
|
// Auto-height dispatcher (blueprint amendment A1, hardened per quality
|
|
167
237
|
// review 2026-07-14). `GridItem`'s ResizeObserver calls this per-widget
|
|
@@ -220,6 +290,7 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
220
290
|
minSize(DEFAULT_RESIZE_BOUNDS.minW, DEFAULT_RESIZE_BOUNDS.minH),
|
|
221
291
|
], []);
|
|
222
292
|
const [extraWidgetConfigs, setExtraWidgetConfigs] = useState({});
|
|
293
|
+
const [extraRelationshipWidgetConfigs, setExtraRelationshipWidgetConfigs] = useState({});
|
|
223
294
|
const { value: layerNames, setValue: setLayerNames } = (preferenceAdapter ?? defaultPreferenceAdapter)(`page:${pageKey}_grid_layer_names`, {});
|
|
224
295
|
const { value: hiddenLayerIds, setValue: setHiddenLayerIds } = (preferenceAdapter ?? defaultPreferenceAdapter)(`page:${pageKey}_grid_hidden_layers`, {});
|
|
225
296
|
const extraWidgets = useMemo(() => {
|
|
@@ -232,11 +303,35 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
232
303
|
entityType: config.entityType,
|
|
233
304
|
label: config.label,
|
|
234
305
|
isEditing,
|
|
306
|
+
tenantId,
|
|
235
307
|
});
|
|
236
308
|
}
|
|
237
309
|
return rendered;
|
|
238
|
-
}, [createEntityWidget, extraWidgetConfigs, isEditing]);
|
|
239
|
-
|
|
310
|
+
}, [createEntityWidget, extraWidgetConfigs, isEditing, tenantId]);
|
|
311
|
+
// Relationship-field widgets (W4-1) — same shape as the entity-list path
|
|
312
|
+
// above (config registry -> render via a consumer-supplied factory), kept
|
|
313
|
+
// as a parallel structure rather than folded in because the two widget
|
|
314
|
+
// kinds are gated differently: an entity-list widget can always be added,
|
|
315
|
+
// a relationship widget only when `relationshipCatalog` proves the FK is
|
|
316
|
+
// real (see the add-relationship-widget listener below).
|
|
317
|
+
const extraRelationshipWidgets = useMemo(() => {
|
|
318
|
+
const rendered = {};
|
|
319
|
+
if (!createRelationshipWidget)
|
|
320
|
+
return rendered;
|
|
321
|
+
for (const [widgetId, detail] of Object.entries(extraRelationshipWidgetConfigs)) {
|
|
322
|
+
rendered[widgetId] = createRelationshipWidget({
|
|
323
|
+
widgetId,
|
|
324
|
+
hostEntityType: detail.hostEntityType,
|
|
325
|
+
fkColumn: detail.fkColumn,
|
|
326
|
+
targetEntityType: detail.targetEntityType,
|
|
327
|
+
label: detail.label,
|
|
328
|
+
isEditing,
|
|
329
|
+
tenantId,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return rendered;
|
|
333
|
+
}, [createRelationshipWidget, extraRelationshipWidgetConfigs, isEditing, tenantId]);
|
|
334
|
+
const allWidgets = useMemo(() => ({ ...widgets, ...extraWidgets, ...extraRelationshipWidgets }), [widgets, extraWidgets, extraRelationshipWidgets]);
|
|
240
335
|
const renderableWidgetKeys = useMemo(() => new Set(Object.keys(allWidgets).filter((key) => allWidgets[key] !== null && allWidgets[key] !== undefined)), [allWidgets]);
|
|
241
336
|
const activeLayouts = useMemo(() => {
|
|
242
337
|
const filtered = { lg: [] };
|
|
@@ -274,9 +369,28 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
274
369
|
// Until the first measurement lands, `autoHeightRows` has no entry and
|
|
275
370
|
// the item's seed `h` renders as-is.
|
|
276
371
|
.map((item) => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
372
|
+
// HEAL a persisted `isResizable: false` on ANY item, not just an
|
|
373
|
+
// autoHeight one (D-75: "individual cards can no longer be resized").
|
|
374
|
+
//
|
|
375
|
+
// The 0.6.0 heal was scoped to `item.autoHeight`, which left the
|
|
376
|
+
// whole non-autoHeight population — every card that opted out, and
|
|
377
|
+
// every layout persisted before `autoHeight` existed as a concept —
|
|
378
|
+
// permanently unresizable with no way back: the flag is re-persisted
|
|
379
|
+
// on each load, so clearing it by hand does not survive a refresh.
|
|
380
|
+
//
|
|
381
|
+
// The one legitimate source of a per-item `isResizable: false` is the
|
|
382
|
+
// Layers "lock" control, which writes it together with `static: true`
|
|
383
|
+
// and `isDraggable: false`. Requiring that full signature separates a
|
|
384
|
+
// deliberate lock (kept) from the 0.5.2 leak (dropped), so the heal
|
|
385
|
+
// can be widened without silently unlocking anything a user locked.
|
|
386
|
+
const lockedDeliberately = item.static === true || item.isDraggable === false;
|
|
387
|
+
const needsResizeHeal = item.isResizable === false && !lockedDeliberately;
|
|
388
|
+
if (!item.autoHeight) {
|
|
389
|
+
if (!needsResizeHeal)
|
|
390
|
+
return item;
|
|
391
|
+
const { isResizable: _dropped, ...unlocked } = item;
|
|
392
|
+
return unlocked;
|
|
393
|
+
}
|
|
280
394
|
//
|
|
281
395
|
// 0.5.2 set this at the render layer, and it leaked out through
|
|
282
396
|
// `onLayoutChange` into the SAVED layout. Removing the override in
|
|
@@ -363,6 +477,57 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
363
477
|
onRegisterEntityWidget,
|
|
364
478
|
setIsEditing,
|
|
365
479
|
]);
|
|
480
|
+
// Relationship-field widget listener (W4-1/W4-2). Mirrors the
|
|
481
|
+
// add-entity-widget listener above, with one structural difference: every
|
|
482
|
+
// request is checked against `relationshipCatalog` via
|
|
483
|
+
// `isRelationshipWritable` BEFORE a widget is ever created. No catalogue
|
|
484
|
+
// match means the FK is not proven to exist, so the request is refused —
|
|
485
|
+
// `onRelationshipWidgetRejected` fires (for the consumer to surface why)
|
|
486
|
+
// and nothing is added to the layout. This is the only place that gate is
|
|
487
|
+
// enforced; there is no other path that creates a relationship widget.
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
if (!createRelationshipWidget || typeof window === 'undefined')
|
|
490
|
+
return;
|
|
491
|
+
const handleAddRelationshipWidget = (event) => {
|
|
492
|
+
const detail = event.detail;
|
|
493
|
+
if (!detail?.hostEntityType || !detail.fkColumn || !detail.targetEntityType)
|
|
494
|
+
return;
|
|
495
|
+
if (!isRelationshipWritable(relationshipCatalog, detail)) {
|
|
496
|
+
onRelationshipWidgetRejected?.(detail);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const widgetId = `relationship:${detail.hostEntityType}:${detail.fkColumn}`;
|
|
500
|
+
const alreadyInLayout = activeLayouts.lg?.some((item) => item.i === widgetId);
|
|
501
|
+
if (alreadyInLayout) {
|
|
502
|
+
startTransition(() => setIsEditing(true));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
onRegisterRelationshipWidget?.({ widgetId, ...detail });
|
|
506
|
+
setExtraRelationshipWidgetConfigs((previous) => ({
|
|
507
|
+
...previous,
|
|
508
|
+
[widgetId]: detail,
|
|
509
|
+
}));
|
|
510
|
+
addWidget(widgetId, { w: 4, h: 3, minW: 3, minH: 2 });
|
|
511
|
+
startTransition(() => setIsEditing(true));
|
|
512
|
+
};
|
|
513
|
+
for (const eventName of addRelationshipWidgetEventNames) {
|
|
514
|
+
window.addEventListener(eventName, handleAddRelationshipWidget);
|
|
515
|
+
}
|
|
516
|
+
return () => {
|
|
517
|
+
for (const eventName of addRelationshipWidgetEventNames) {
|
|
518
|
+
window.removeEventListener(eventName, handleAddRelationshipWidget);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}, [
|
|
522
|
+
activeLayouts.lg,
|
|
523
|
+
addRelationshipWidgetEventNames,
|
|
524
|
+
addWidget,
|
|
525
|
+
createRelationshipWidget,
|
|
526
|
+
onRegisterRelationshipWidget,
|
|
527
|
+
onRelationshipWidgetRejected,
|
|
528
|
+
relationshipCatalog,
|
|
529
|
+
setIsEditing,
|
|
530
|
+
]);
|
|
366
531
|
const visibleKeys = useMemo(() => new Set((activeLayouts.lg ?? []).map((item) => item.i)), [activeLayouts.lg]);
|
|
367
532
|
const hiddenWidgetKeys = useMemo(() => [...renderableWidgetKeys].filter((key) => !visibleKeys.has(key)), [renderableWidgetKeys, visibleKeys]);
|
|
368
533
|
const layerItems = useMemo(() => (activeLayouts.lg ?? []).map((item, index, layers) => {
|
|
@@ -479,7 +644,11 @@ isResizable = true, resizeHandles = DEFAULT_RESIZE_HANDLES, addEntityWidgetEvent
|
|
|
479
644
|
}) })] })), layerItems.length > 0 && (_jsxs("div", { className: "basis-full rounded-md border border-border bg-background/80 p-2", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Layers"] }), _jsx("div", { className: "flex flex-col gap-1", children: layerItems.map((layer) => (_jsxs("div", { className: "flex items-center gap-1 rounded border border-border bg-card px-2 py-1", children: [_jsx("input", { type: "text", value: layer.label, onChange: (event) => handleLayerRename(layer.id, event.target.value), className: "min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none", "aria-label": `Rename ${layer.label}` }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'up'), disabled: !layer.canMoveUp, "aria-label": `Move ${layer.label} up`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowUp, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => moveWidget(layer.id, 'down'), disabled: !layer.canMoveDown, "aria-label": `Move ${layer.label} down`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40", children: _jsx(ArrowDown, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => setWidgetLocked(layer.id, !layer.locked), "aria-label": `${layer.locked ? 'Unlock' : 'Lock'} ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: layer.locked ? _jsx(Lock, { className: "h-3.5 w-3.5" }) : _jsx(Unlock, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", onClick: () => hideLayer(layer.id), "aria-label": `Hide ${layer.label}`, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", children: _jsx(EyeOff, { className: "h-3.5 w-3.5" }) })] }, layer.id))) })] })), _jsxs("button", { type: "button", onClick: handleCompact, className: cn('ml-auto inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(Layers, { className: "h-4 w-4 mr-1" }), "Compact Layout"] }), _jsxs("button", { type: "button", onClick: () => setResetConfirmOpen(true), className: cn('inline-flex items-center rounded-md border border-border px-2 py-1 text-sm transition-colors', 'text-muted-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: [_jsx(RotateCcw, { className: "h-4 w-4 mr-1" }), "Reset to Default"] })] })] })), resetConfirmOpen && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-overlay/50 p-4", role: "dialog", "aria-modal": "true", "aria-labelledby": "page-grid-reset-title", "aria-describedby": "page-grid-reset-description", children: _jsxs("div", { className: "max-w-md rounded-lg bg-card p-5 shadow-lg border border-border", children: [_jsx("h2", { id: "page-grid-reset-title", className: "text-lg font-semibold", children: "Reset page layout?" }), _jsx("p", { id: "page-grid-reset-description", className: "mt-2 text-sm text-muted-foreground", children: "This will discard your current layout and restore all widgets to their default positions." }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { type: "button", ref: resetCancelButtonRef, onClick: () => setResetConfirmOpen(false), className: cn('inline-flex items-center rounded-md border border-border px-3 py-2 text-sm transition-colors', 'bg-card text-foreground hover:bg-muted hover:text-foreground', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'), children: "Cancel" }), _jsx("button", { type: "button", ref: resetConfirmButtonRef, onClick: () => {
|
|
480
645
|
handleReset();
|
|
481
646
|
setResetConfirmOpen(false);
|
|
482
|
-
}, className: cn('inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors', 'bg-destructive text-destructive-foreground shadow hover:bg-destructive/90', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-1'), children: "Reset to Default" })] })] }) })), _jsx("div", { ref: containerRef, className: cn('relative z-0 overflow-visible', isEditing && 'min-h-[200px] bg-muted/30'), "data-page-grid-editing": isEditing || undefined, children: _jsx("div", { className: "relative z-0", style: { opacity: containerWidth > 0 ? 1 : 0 }, "aria-busy": containerWidth <= 0, children: _jsx(Responsive, { width: Math.max(containerWidth, 1), className: "layout page-grid-canvas", layouts: activeLayouts, breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, rowHeight: DEFAULT_ROW_HEIGHT, onLayoutChange: onLayoutChange,
|
|
647
|
+
}, className: cn('inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors', 'bg-destructive text-destructive-foreground shadow hover:bg-destructive/90', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-1'), children: "Reset to Default" })] })] }) })), _jsx("div", { ref: containerRef, className: cn('relative z-0 overflow-visible', isEditing && 'min-h-[200px] bg-muted/30'), "data-page-grid-editing": isEditing || undefined, children: _jsx("div", { className: "relative z-0", style: { opacity: containerWidth > 0 ? 1 : 0 }, "aria-busy": containerWidth <= 0, children: _jsx(Responsive, { width: Math.max(containerWidth, 1), className: "layout page-grid-canvas", layouts: activeLayouts, breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }, rowHeight: DEFAULT_ROW_HEIGHT, onLayoutChange: onLayoutChange,
|
|
648
|
+
// Tells the hook which breakpoint a gesture belongs to, so the edit
|
|
649
|
+
// is folded back onto `lg` instead of into a derived breakpoint
|
|
650
|
+
// that is regenerated (and therefore discarded) on the next render.
|
|
651
|
+
onBreakpointChange: handleBreakpointChange, dragConfig: {
|
|
483
652
|
enabled: isEditing,
|
|
484
653
|
handle: '.drag-handle',
|
|
485
654
|
bounded: false,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { RelationshipFieldOption } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* RelationshipField — the app-agnostic relationship-field widget (W4-1).
|
|
5
|
+
*
|
|
6
|
+
* A typeahead over a single entity that writes ONE foreign key on the
|
|
7
|
+
* record being edited, distinct from the entity-list widget (a table of
|
|
8
|
+
* every row, writing nothing). `@bsuite/page-builder` cannot import from
|
|
9
|
+
* crm7 or talk to Supabase, so the data source is entirely caller-supplied
|
|
10
|
+
* — `search`/`resolveOption` are the ClientSelector-equivalent behaviour
|
|
11
|
+
* crm7 (or any consumer) wires in; this component owns only the picker
|
|
12
|
+
* interaction and its validation contract.
|
|
13
|
+
*
|
|
14
|
+
* The validation contract is deliberately the same shape as crm7's
|
|
15
|
+
* `EntitySelector`/`ClientSelector` (`crm7/src/components/entity/
|
|
16
|
+
* EntitySelector.tsx`, `.../selectors/ClientSelector.tsx`): the ONLY way to
|
|
17
|
+
* commit a value is to pick one of the options `search` returned — there is
|
|
18
|
+
* no code path that turns raw typed text into a committed value. Typed text
|
|
19
|
+
* that does not match the currently-committed option's label is refused on
|
|
20
|
+
* blur/Escape: the field reverts to the last committed value and
|
|
21
|
+
* `onInvalidEntry` fires instead of `onChange`. That refusal is exactly
|
|
22
|
+
* what stopped 7 contacts becoming free-text orphans in crm7 — a
|
|
23
|
+
* relationship field that skipped it would recreate the same problem this
|
|
24
|
+
* widget exists to fix.
|
|
25
|
+
*
|
|
26
|
+
* Controlled component: `value` is the FK currently persisted on the host
|
|
27
|
+
* record. `onChange` REQUESTS a write; the caller updates its own backing
|
|
28
|
+
* store and re-passes `value` once the write lands (optimistically or
|
|
29
|
+
* not) — this component never assumes its own write succeeded.
|
|
30
|
+
*/
|
|
31
|
+
export interface RelationshipFieldProps {
|
|
32
|
+
/** Currently selected FK value (the id written on the host record), or
|
|
33
|
+
* null/undefined when unset. */
|
|
34
|
+
value?: string | null;
|
|
35
|
+
/** The caller's tenant — forwarded to `search`/`resolveOption` on every
|
|
36
|
+
* call so the picker's data source is scoped to it by construction,
|
|
37
|
+
* never by convention (W4-1's tenant-scoping requirement). */
|
|
38
|
+
tenantId?: string | null;
|
|
39
|
+
label?: string;
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Debounce, in ms, between the last keystroke and firing `search`. */
|
|
43
|
+
debounceMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Search the target entity, scoped to `tenantId`. Consumers supply their
|
|
46
|
+
* own data source here (crm7's ClientSelector-equivalent query) — this
|
|
47
|
+
* package never talks to Supabase directly.
|
|
48
|
+
*/
|
|
49
|
+
search: (query: string, tenantId: string | null | undefined) => Promise<RelationshipFieldOption[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the option behind an already-set `value` (e.g. re-fetch by id)
|
|
52
|
+
* so the field can show its label without the caller pre-resolving it.
|
|
53
|
+
* Optional — omit if `initialOption` is supplied instead.
|
|
54
|
+
*/
|
|
55
|
+
resolveOption?: (id: string, tenantId: string | null | undefined) => Promise<RelationshipFieldOption | null>;
|
|
56
|
+
/** Pre-resolved option for `value`, when the caller already has it. */
|
|
57
|
+
initialOption?: RelationshipFieldOption | null;
|
|
58
|
+
/**
|
|
59
|
+
* Called with the picked option — this is what writes the foreign key.
|
|
60
|
+
* Called with `null` only from the explicit clear action, never from an
|
|
61
|
+
* unresolved typed value.
|
|
62
|
+
*/
|
|
63
|
+
onChange: (option: RelationshipFieldOption | null) => void | Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Called when the field is blurred/committed with typed text that does
|
|
66
|
+
* not match any resolved/committed option — the value is refused
|
|
67
|
+
* (reverted) and `onChange` is NOT called.
|
|
68
|
+
*/
|
|
69
|
+
onInvalidEntry?: (query: string) => void;
|
|
70
|
+
emptyLabel?: string;
|
|
71
|
+
className?: string;
|
|
72
|
+
id?: string;
|
|
73
|
+
'aria-label'?: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function RelationshipField({ value, tenantId, label, placeholder, disabled, debounceMs, search, resolveOption, initialOption, onChange, onInvalidEntry, emptyLabel, className, id, 'aria-label': ariaLabel, }: RelationshipFieldProps): React.JSX.Element;
|
|
76
|
+
export default RelationshipField;
|
|
77
|
+
//# sourceMappingURL=RelationshipField.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RelationshipField.d.ts","sourceRoot":"","sources":["../src/RelationshipField.tsx"],"names":[],"mappings":"AACA,OAAO,KAA0D,MAAM,OAAO,CAAC;AAE/E,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,WAAW,sBAAsB;IACrC;oCACgC;IAChC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;kEAE8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,KAAK,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAAC;IACnG;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,KAAK,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IAC7G,uEAAuE;IACvE,aAAa,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,EAAE,CAAC,MAAM,EAAE,uBAAuB,GAAG,IAAI,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,QAAQ,EACR,KAAK,EACL,WAAuB,EACvB,QAAgB,EAChB,UAAgB,EAChB,MAAM,EACN,aAAa,EACb,aAAoB,EACpB,QAAQ,EACR,cAAc,EACd,UAAyB,EACzB,SAAS,EACT,EAAE,EACF,YAAY,EAAE,SAAS,GACxB,EAAE,sBAAsB,qBAwOxB;AAED,eAAe,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { cn } from './utils.js';
|
|
5
|
+
export function RelationshipField({ value, tenantId, label, placeholder = 'Search…', disabled = false, debounceMs = 250, search, resolveOption, initialOption = null, onChange, onInvalidEntry, emptyLabel = 'No matches', className, id, 'aria-label': ariaLabel, }) {
|
|
6
|
+
const generatedId = useId();
|
|
7
|
+
const inputId = id ?? `relationship-field-${generatedId}`;
|
|
8
|
+
const listboxId = `${inputId}-listbox`;
|
|
9
|
+
const [selected, setSelected] = useState(initialOption);
|
|
10
|
+
const [query, setQuery] = useState(initialOption?.label ?? '');
|
|
11
|
+
const [options, setOptions] = useState([]);
|
|
12
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
13
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
14
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
15
|
+
const debounceRef = useRef(null);
|
|
16
|
+
const requestIdRef = useRef(0);
|
|
17
|
+
// Hydrate the label for an externally-set/changed `value` this instance
|
|
18
|
+
// has not resolved yet. Deliberately keyed on `value` alone — `selected`
|
|
19
|
+
// is set BY this same effect (and by `commitOption`/`clearSelection`), so
|
|
20
|
+
// including it would either no-op (already in sync) or fight the user's
|
|
21
|
+
// own selection; `resolveOption`/`tenantId` changing mid-session should
|
|
22
|
+
// not retrigger a resolve for a value that hasn't changed.
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if ((value ?? null) === (selected?.id ?? null))
|
|
25
|
+
return;
|
|
26
|
+
if (!value) {
|
|
27
|
+
setSelected(null);
|
|
28
|
+
setQuery('');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (!resolveOption)
|
|
32
|
+
return;
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
resolveOption(value, tenantId)
|
|
35
|
+
.then((option) => {
|
|
36
|
+
if (cancelled || !option)
|
|
37
|
+
return;
|
|
38
|
+
setSelected(option);
|
|
39
|
+
setQuery(option.label);
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {
|
|
42
|
+
/* leave the field as-is; caller's resolveOption owns error reporting */
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [value]);
|
|
49
|
+
useEffect(() => () => {
|
|
50
|
+
if (debounceRef.current)
|
|
51
|
+
clearTimeout(debounceRef.current);
|
|
52
|
+
}, []);
|
|
53
|
+
const runSearch = useCallback((text) => {
|
|
54
|
+
const requestId = ++requestIdRef.current;
|
|
55
|
+
setIsLoading(true);
|
|
56
|
+
search(text, tenantId)
|
|
57
|
+
.then((results) => {
|
|
58
|
+
if (requestIdRef.current !== requestId)
|
|
59
|
+
return;
|
|
60
|
+
setOptions(results);
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {
|
|
63
|
+
if (requestIdRef.current !== requestId)
|
|
64
|
+
return;
|
|
65
|
+
setOptions([]);
|
|
66
|
+
})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
if (requestIdRef.current !== requestId)
|
|
69
|
+
return;
|
|
70
|
+
setIsLoading(false);
|
|
71
|
+
});
|
|
72
|
+
}, [search, tenantId]);
|
|
73
|
+
const handleInputChange = (event) => {
|
|
74
|
+
const text = event.target.value;
|
|
75
|
+
setQuery(text);
|
|
76
|
+
setIsOpen(true);
|
|
77
|
+
setHighlightedIndex(-1);
|
|
78
|
+
if (debounceRef.current)
|
|
79
|
+
clearTimeout(debounceRef.current);
|
|
80
|
+
debounceRef.current = setTimeout(() => runSearch(text), debounceMs);
|
|
81
|
+
};
|
|
82
|
+
const commitOption = (option) => {
|
|
83
|
+
setSelected(option);
|
|
84
|
+
setQuery(option.label);
|
|
85
|
+
setIsOpen(false);
|
|
86
|
+
setOptions([]);
|
|
87
|
+
setHighlightedIndex(-1);
|
|
88
|
+
void onChange(option);
|
|
89
|
+
};
|
|
90
|
+
const clearSelection = () => {
|
|
91
|
+
setSelected(null);
|
|
92
|
+
setQuery('');
|
|
93
|
+
setOptions([]);
|
|
94
|
+
setIsOpen(false);
|
|
95
|
+
void onChange(null);
|
|
96
|
+
};
|
|
97
|
+
// The refusal behaviour this component exists to provide (see the module
|
|
98
|
+
// doc): committing is ONLY ever done via `commitOption`, called from a
|
|
99
|
+
// listbox option pick. Reaching blur/Escape with typed text that does not
|
|
100
|
+
// exactly match the currently-committed option's label means the user
|
|
101
|
+
// typed something that never resolved to a real row — revert, and tell
|
|
102
|
+
// the caller via `onInvalidEntry` instead of writing anything.
|
|
103
|
+
const refuseUnresolvedTypedText = () => {
|
|
104
|
+
const trimmed = query.trim();
|
|
105
|
+
const matchesCommitted = selected ? trimmed === selected.label : trimmed.length === 0;
|
|
106
|
+
if (matchesCommitted)
|
|
107
|
+
return;
|
|
108
|
+
onInvalidEntry?.(trimmed);
|
|
109
|
+
setQuery(selected?.label ?? '');
|
|
110
|
+
setOptions([]);
|
|
111
|
+
};
|
|
112
|
+
const handleBlur = () => {
|
|
113
|
+
// A mousedown on a listbox option (see onMouseDown below) prevents the
|
|
114
|
+
// input from ever losing focus for that interaction, so `handleBlur`
|
|
115
|
+
// only ever runs for a genuine focus-away — commitOption has already
|
|
116
|
+
// run synchronously before this fires when the user picks an option.
|
|
117
|
+
setIsOpen(false);
|
|
118
|
+
refuseUnresolvedTypedText();
|
|
119
|
+
};
|
|
120
|
+
const handleKeyDown = (event) => {
|
|
121
|
+
if (event.key === 'ArrowDown') {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
if (!isOpen) {
|
|
124
|
+
setIsOpen(true);
|
|
125
|
+
if (options.length === 0)
|
|
126
|
+
runSearch(query);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
setHighlightedIndex((index) => Math.min(index + 1, options.length - 1));
|
|
130
|
+
}
|
|
131
|
+
else if (event.key === 'ArrowUp') {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
|
134
|
+
}
|
|
135
|
+
else if (event.key === 'Enter') {
|
|
136
|
+
if (isOpen && highlightedIndex >= 0 && options[highlightedIndex]) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
commitOption(options[highlightedIndex]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (event.key === 'Escape') {
|
|
142
|
+
if (isOpen) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
setIsOpen(false);
|
|
145
|
+
setQuery(selected?.label ?? '');
|
|
146
|
+
setOptions([]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
return (_jsxs("div", { className: cn('relative w-full', className), children: [label && (_jsx("label", { htmlFor: inputId, className: "mb-1 block text-xs font-medium text-muted-foreground", children: label })), _jsxs("div", { className: "relative flex items-center gap-1", children: [_jsx("input", { id: inputId, role: "combobox", type: "text", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-activedescendant": isOpen && highlightedIndex >= 0 && options[highlightedIndex]
|
|
151
|
+
? `${listboxId}-option-${highlightedIndex}`
|
|
152
|
+
: undefined, "aria-label": ariaLabel ?? label, className: "w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground disabled:opacity-60", value: query, placeholder: placeholder, disabled: disabled, autoComplete: "off", onChange: handleInputChange, onFocus: () => {
|
|
153
|
+
setIsOpen(true);
|
|
154
|
+
if (options.length === 0)
|
|
155
|
+
runSearch(query);
|
|
156
|
+
}, onBlur: handleBlur, onKeyDown: handleKeyDown }), selected && !disabled && (_jsx("button", { type: "button", "aria-label": "Clear selection", className: "shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground", onMouseDown: (event) => event.preventDefault(), onClick: clearSelection, children: _jsx(X, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) }))] }), isOpen && (_jsx("ul", { id: listboxId, role: "listbox", className: "absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover shadow-md", children: isLoading ? (_jsx("li", { className: "px-3 py-2 text-sm text-muted-foreground", children: "Searching\u2026" })) : options.length === 0 ? (_jsx("li", { className: "px-3 py-2 text-sm text-muted-foreground", children: emptyLabel })) : (options.map((option, index) => (_jsxs("li", { id: `${listboxId}-option-${index}`, role: "option", "aria-selected": selected?.id === option.id, className: cn('cursor-pointer px-3 py-2 text-sm', index === highlightedIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'), onMouseDown: (event) => event.preventDefault(), onClick: () => commitOption(option), onMouseEnter: () => setHighlightedIndex(index), children: [_jsx("div", { children: option.label }), option.secondaryLabel && (_jsx("div", { className: "text-xs text-muted-foreground", children: option.secondaryLabel }))] }, option.id)))) }))] }));
|
|
157
|
+
}
|
|
158
|
+
export default RelationshipField;
|