@liiift-studio/sanity-font-manager 2.7.0 → 2.7.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +1634 -17839
  4. package/dist/index.mjs +1551 -17745
  5. package/package.json +83 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  7. package/src/components/BulkActions.jsx +99 -99
  8. package/src/components/ExistingDocumentResolver.jsx +152 -152
  9. package/src/components/FontReviewCard.jsx +455 -455
  10. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  11. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  12. package/src/components/KeyValueInput.jsx +95 -95
  13. package/src/components/KeyValueReferenceInput.jsx +267 -267
  14. package/src/components/NestedObjectArraySelector.jsx +146 -146
  15. package/src/components/PriceInput.jsx +26 -26
  16. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  17. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  18. package/src/components/SetOTF.jsx +87 -87
  19. package/src/components/SingleUploaderTool.jsx +674 -674
  20. package/src/components/StatusDisplay.jsx +26 -26
  21. package/src/components/StyleCountInput.jsx +16 -16
  22. package/src/components/UpdateScriptsComponent.jsx +76 -76
  23. package/src/components/UploadButton.jsx +43 -43
  24. package/src/components/UploadModal.jsx +309 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  28. package/src/components/UploadStep3Execute.jsx +234 -234
  29. package/src/components/UploadStep3bInstances.jsx +396 -396
  30. package/src/components/UploadSummary.jsx +196 -196
  31. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  32. package/src/hooks/useNestedObjects.js +92 -92
  33. package/src/hooks/useSanityClient.js +9 -9
  34. package/src/index.js +120 -120
  35. package/src/schema/openTypeField.js +1995 -1995
  36. package/src/schema/styleCountField.js +12 -12
  37. package/src/schema/stylesField.js +302 -302
  38. package/src/schema/stylisticSetField.js +301 -301
  39. package/src/utils/buildUploadPlan.js +326 -326
  40. package/src/utils/executeUploadPlan.js +430 -430
  41. package/src/utils/executionReducer.js +56 -56
  42. package/src/utils/fontHelpers.js +281 -281
  43. package/src/utils/generateCssFile.js +207 -207
  44. package/src/utils/generateFontData.js +98 -98
  45. package/src/utils/generateFontFile.js +38 -38
  46. package/src/utils/generateKeywords.js +185 -185
  47. package/src/utils/generateSubset.js +45 -45
  48. package/src/utils/getEmptyFontKit.js +101 -101
  49. package/src/utils/parseFont.js +56 -56
  50. package/src/utils/parseVariableFontInstances.js +301 -301
  51. package/src/utils/planReducer.js +531 -531
  52. package/src/utils/planTypes.js +183 -183
  53. package/src/utils/processFontFiles.js +530 -530
  54. package/src/utils/regenerateFontData.js +146 -146
  55. package/src/utils/resolveExistingFont.js +87 -87
  56. package/src/utils/retitleFontEntries.js +154 -154
  57. package/src/utils/sanitizeForSanityId.js +65 -65
  58. package/src/utils/setupDecompressors.js +27 -27
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +162 -162
  61. package/src/utils/uploadFontFiles.js +405 -405
  62. package/src/utils/utils.js +24 -24
@@ -1,267 +1,267 @@
1
- // Generic key-value pair editor where values are weak Sanity document references — add, remove, reorder, and searchable picker
2
-
3
- import React, { useState, useCallback, useEffect } from 'react';
4
- import { Button, Stack, TextInput, Box, Card, Flex, Text, Dialog, Menu, MenuButton, MenuItem, Autocomplete } from '@sanity/ui';
5
- import { AddIcon, ArrowDownIcon, ArrowUpIcon, TrashIcon, SyncIcon, EllipsisHorizontalIcon } from '@sanity/icons';
6
- import { set, useFormValue } from 'sanity';
7
- import { useSanityClient } from '../hooks/useSanityClient.js';
8
- import { nanoid } from 'nanoid';
9
-
10
- /**
11
- * Generic key-value pair editor where values are weak references to Sanity documents.
12
- * Handles add/remove/reorder, a searchable reference picker dialog, and cached title display.
13
- * @param {Array} value - Current array of { _key, key, value } pairs
14
- * @param {Function} onChange - Sanity onChange callback
15
- * @param {string} referenceType - Display label for the referenced document type (e.g. 'font')
16
- * @param {Function} fetchReferences - async (client, doc) => [{ _id, title }] — populates the picker
17
- * @param {ReactNode} topActions - Optional slot rendered above the pairs list (e.g. autofill buttons)
18
- * @param {Object} schemaType - Sanity schemaType passed automatically by the Studio
19
- */
20
- export function KeyValueReferenceInput(props) {
21
- const { value = [], onChange, schemaType, referenceType, fetchReferences, topActions } = props;
22
-
23
- const [pairs, setPairs] = useState(value);
24
- const [referenceData, setReferenceData] = useState({});
25
- const [isDialogOpen, setIsDialogOpen] = useState(false);
26
- const [editingIndex, setEditingIndex] = useState(null);
27
- const [availableReferences, setAvailableReferences] = useState([]);
28
-
29
- const sanityClient = useSanityClient();
30
- const formDocument = useFormValue([]);
31
-
32
- /** Syncs local pairs state when the value prop changes externally (e.g. autofill from parent) */
33
- useEffect(() => {
34
- setPairs(value);
35
- }, [value]);
36
-
37
- /** Fetches and caches display titles for all referenced documents whenever pairs change */
38
- useEffect(() => {
39
- const refIds = pairs.filter(p => p.value?._ref).map(p => p.value._ref);
40
- if (refIds.length === 0) return;
41
-
42
- if (!sanityClient) {
43
- const fallback = {};
44
- refIds.forEach(id => { fallback[id] = `Reference (${id.substring(0, 6)}...)`; });
45
- setReferenceData(fallback);
46
- return;
47
- }
48
-
49
- sanityClient.fetch(`*[_id in $ids]{_id, title}`, { ids: refIds })
50
- .then(result => {
51
- const map = {};
52
- result.forEach(item => { map[item._id] = item.title; });
53
- setReferenceData(map);
54
- })
55
- .catch(err => {
56
- console.error('Error fetching reference data:', err);
57
- const fallback = {};
58
- refIds.forEach(id => { fallback[id] = `Reference (${id.substring(0, 6)}...)`; });
59
- setReferenceData(fallback);
60
- });
61
- }, [pairs, sanityClient]);
62
-
63
- /** Updates a field on a pair at the given index and syncs to Sanity */
64
- const handlePairChange = useCallback((index, field, fieldValue) => {
65
- const updatedPairs = pairs.map((pair, idx) => idx === index ? { ...pair, [field]: fieldValue } : pair);
66
- setPairs(updatedPairs);
67
- onChange(set(updatedPairs));
68
- }, [pairs, onChange]);
69
-
70
- /** Appends a new empty pair */
71
- const handleAddPair = useCallback(() => {
72
- const updatedPairs = [...pairs, { key: '', value: null, _key: nanoid() }];
73
- setPairs(updatedPairs);
74
- onChange(set(updatedPairs));
75
- }, [pairs, onChange]);
76
-
77
- /** Removes the pair at the given index */
78
- const handleRemovePair = useCallback((index) => {
79
- const updatedPairs = pairs.filter((_, idx) => idx !== index);
80
- setPairs(updatedPairs);
81
- onChange(set(updatedPairs));
82
- }, [pairs, onChange]);
83
-
84
- /** Swaps a pair with the one above it */
85
- const handleMoveUp = useCallback((index) => {
86
- if (index === 0) return;
87
- const updatedPairs = [...pairs];
88
- [updatedPairs[index], updatedPairs[index - 1]] = [updatedPairs[index - 1], updatedPairs[index]];
89
- setPairs(updatedPairs);
90
- onChange(set(updatedPairs));
91
- }, [pairs, onChange]);
92
-
93
- /** Swaps a pair with the one below it */
94
- const handleMoveDown = useCallback((index) => {
95
- if (index === pairs.length - 1) return;
96
- const updatedPairs = [...pairs];
97
- [updatedPairs[index], updatedPairs[index + 1]] = [updatedPairs[index + 1], updatedPairs[index]];
98
- setPairs(updatedPairs);
99
- onChange(set(updatedPairs));
100
- }, [pairs, onChange]);
101
-
102
- /** Opens the reference picker, calling fetchReferences to populate the list */
103
- const openReferenceSelector = useCallback(async (index) => {
104
- setEditingIndex(index);
105
- if (!sanityClient) {
106
- console.error('KeyValueReferenceInput: Sanity client not available');
107
- return;
108
- }
109
- try {
110
- let refs;
111
- if (fetchReferences) {
112
- refs = await fetchReferences(sanityClient, formDocument);
113
- } else if (referenceType) {
114
- refs = await sanityClient.fetch(`*[_type == $type]{_id, title}`, { type: referenceType });
115
- } else {
116
- console.warn('KeyValueReferenceInput: provide a fetchReferences prop or referenceType');
117
- refs = [];
118
- }
119
- setAvailableReferences(refs);
120
- setIsDialogOpen(true);
121
- } catch (err) {
122
- console.error('Error fetching available references:', err);
123
- }
124
- }, [sanityClient, fetchReferences, referenceType, formDocument]);
125
-
126
- /** Closes the picker dialog and clears editing state */
127
- const closeDialog = useCallback(() => {
128
- setIsDialogOpen(false);
129
- setEditingIndex(null);
130
- }, []);
131
-
132
- /** Writes the selected item as a weak reference and closes the dialog */
133
- const handleReferenceSelect = useCallback((reference) => {
134
- if (editingIndex === null) return;
135
- handlePairChange(editingIndex, 'value', { _type: 'reference', _ref: reference._id, _weak: true });
136
- closeDialog();
137
- }, [editingIndex, handlePairChange, closeDialog]);
138
-
139
- const referenceOptions = availableReferences.map(ref => ({ value: ref._id, title: ref.title }));
140
-
141
- // Infer labels from schemaType if available
142
- const keyField = schemaType?.options?.of?.[0]?.fields?.find(f => f.name === 'key');
143
- const valueField = schemaType?.options?.of?.[0]?.fields?.find(f => f.name === 'value');
144
- const keyTitle = keyField?.title || 'Key';
145
- const valueTitle = valueField?.title || 'Value';
146
- const keyPlaceholder = keyField?.placeholder || 'Enter key';
147
- const pickerLabel = referenceType || valueTitle.toLowerCase();
148
-
149
- return (
150
- <Stack space={3}>
151
- {topActions && <Box paddingBottom={2}>{topActions}</Box>}
152
-
153
- <Box>
154
- <Stack space={2}>
155
- {pairs.map((pair, index) => (
156
- <Flex key={index} gap={1} align="center">
157
- {/* Reorder buttons */}
158
- <Flex direction="column" style={{ flexShrink: 0 }}>
159
- <Button
160
- mode="bleed"
161
- icon={ArrowUpIcon}
162
- padding={1}
163
- fontSize={0}
164
- onClick={() => handleMoveUp(index)}
165
- disabled={index === 0}
166
- style={{ cursor: index === 0 ? 'default' : 'pointer' }}
167
- />
168
- <Button
169
- mode="bleed"
170
- icon={ArrowDownIcon}
171
- padding={1}
172
- fontSize={0}
173
- onClick={() => handleMoveDown(index)}
174
- disabled={index === pairs.length - 1}
175
- style={{ cursor: index === pairs.length - 1 ? 'default' : 'pointer' }}
176
- />
177
- </Flex>
178
-
179
- {/* Key input */}
180
- <Box flex={1}>
181
- <TextInput
182
- value={pair.key}
183
- onChange={(e) => handlePairChange(index, 'key', e.target.value)}
184
- placeholder={keyPlaceholder}
185
- />
186
- </Box>
187
-
188
- {/* Reference display or empty-state picker trigger */}
189
- <Box flex={1}>
190
- {pair.value?._ref ? (
191
- <Card radius={2} tone="primary" style={{ paddingLeft: '0.75rem', height: 'fit-content' }}>
192
- <Flex align="center" justify="space-between">
193
- <Text size={2} style={{ whiteSpace: 'nowrap' }}>
194
- {referenceData[pair.value._ref] || 'Loading...'}
195
- </Text>
196
- <MenuButton
197
- button={<Button icon={EllipsisHorizontalIcon} mode="bleed" title="Options" />}
198
- id={`ref-options-${index}`}
199
- menu={
200
- <Menu>
201
- <MenuItem tone="critical" icon={TrashIcon} text="Remove" onClick={() => handlePairChange(index, 'value', null)} />
202
- <MenuItem icon={SyncIcon} text="Replace" onClick={() => openReferenceSelector(index)} />
203
- </Menu>
204
- }
205
- popover={{ portal: true, tone: 'default', placement: 'left' }}
206
- />
207
- </Flex>
208
- </Card>
209
- ) : (
210
- <Box
211
- padding={2}
212
- style={{ border: '1px dashed #ccc', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
213
- onClick={() => openReferenceSelector(index)}
214
- >
215
- <Text muted size={2}>Click to select a {pickerLabel}</Text>
216
- </Box>
217
- )}
218
- </Box>
219
-
220
- {/* Remove button */}
221
- <Button
222
- mode="bleed"
223
- tone="critical"
224
- icon={TrashIcon}
225
- padding={2}
226
- onClick={() => handleRemovePair(index)}
227
- style={{ flexShrink: 0, cursor: 'pointer' }}
228
- />
229
- </Flex>
230
- ))}
231
- </Stack>
232
- </Box>
233
-
234
- <Button tone="primary" mode="ghost" onClick={handleAddPair} icon={AddIcon} text={`Add ${keyTitle}`} />
235
-
236
- {/* Reference picker dialog */}
237
- {isDialogOpen && (
238
- <Dialog
239
- header={`Select a ${pickerLabel}`}
240
- id="reference-selector-dialog"
241
- onClose={closeDialog}
242
- width={1}
243
- >
244
- <Box padding={4}>
245
- <Autocomplete
246
- id="reference-autocomplete"
247
- options={referenceOptions}
248
- placeholder={`Search ${pickerLabel}s...`}
249
- renderOption={(option) => (
250
- <Card key={option.value} padding={3} radius={2} tone="default" style={{ cursor: 'pointer' }}>
251
- <Text size={2}>{option.title}</Text>
252
- </Card>
253
- )}
254
- renderValue={(val) => referenceOptions.find(o => o.value === val)?.title || ''}
255
- onChange={(newValue) => {
256
- const selected = availableReferences.find(r => r._id === newValue);
257
- if (selected) handleReferenceSelect(selected);
258
- }}
259
- openButton
260
- fontSize={2}
261
- />
262
- </Box>
263
- </Dialog>
264
- )}
265
- </Stack>
266
- );
267
- }
1
+ // Generic key-value pair editor where values are weak Sanity document references — add, remove, reorder, and searchable picker
2
+
3
+ import React, { useState, useCallback, useEffect } from 'react';
4
+ import { Button, Stack, TextInput, Box, Card, Flex, Text, Dialog, Menu, MenuButton, MenuItem, Autocomplete } from '@sanity/ui';
5
+ import { AddIcon, ArrowDownIcon, ArrowUpIcon, TrashIcon, SyncIcon, EllipsisHorizontalIcon } from '@sanity/icons';
6
+ import { set, useFormValue } from 'sanity';
7
+ import { useSanityClient } from '../hooks/useSanityClient.js';
8
+ import { nanoid } from 'nanoid';
9
+
10
+ /**
11
+ * Generic key-value pair editor where values are weak references to Sanity documents.
12
+ * Handles add/remove/reorder, a searchable reference picker dialog, and cached title display.
13
+ * @param {Array} value - Current array of { _key, key, value } pairs
14
+ * @param {Function} onChange - Sanity onChange callback
15
+ * @param {string} referenceType - Display label for the referenced document type (e.g. 'font')
16
+ * @param {Function} fetchReferences - async (client, doc) => [{ _id, title }] — populates the picker
17
+ * @param {ReactNode} topActions - Optional slot rendered above the pairs list (e.g. autofill buttons)
18
+ * @param {Object} schemaType - Sanity schemaType passed automatically by the Studio
19
+ */
20
+ export function KeyValueReferenceInput(props) {
21
+ const { value = [], onChange, schemaType, referenceType, fetchReferences, topActions } = props;
22
+
23
+ const [pairs, setPairs] = useState(value);
24
+ const [referenceData, setReferenceData] = useState({});
25
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
26
+ const [editingIndex, setEditingIndex] = useState(null);
27
+ const [availableReferences, setAvailableReferences] = useState([]);
28
+
29
+ const sanityClient = useSanityClient();
30
+ const formDocument = useFormValue([]);
31
+
32
+ /** Syncs local pairs state when the value prop changes externally (e.g. autofill from parent) */
33
+ useEffect(() => {
34
+ setPairs(value);
35
+ }, [value]);
36
+
37
+ /** Fetches and caches display titles for all referenced documents whenever pairs change */
38
+ useEffect(() => {
39
+ const refIds = pairs.filter(p => p.value?._ref).map(p => p.value._ref);
40
+ if (refIds.length === 0) return;
41
+
42
+ if (!sanityClient) {
43
+ const fallback = {};
44
+ refIds.forEach(id => { fallback[id] = `Reference (${id.substring(0, 6)}...)`; });
45
+ setReferenceData(fallback);
46
+ return;
47
+ }
48
+
49
+ sanityClient.fetch(`*[_id in $ids]{_id, title}`, { ids: refIds })
50
+ .then(result => {
51
+ const map = {};
52
+ result.forEach(item => { map[item._id] = item.title; });
53
+ setReferenceData(map);
54
+ })
55
+ .catch(err => {
56
+ console.error('Error fetching reference data:', err);
57
+ const fallback = {};
58
+ refIds.forEach(id => { fallback[id] = `Reference (${id.substring(0, 6)}...)`; });
59
+ setReferenceData(fallback);
60
+ });
61
+ }, [pairs, sanityClient]);
62
+
63
+ /** Updates a field on a pair at the given index and syncs to Sanity */
64
+ const handlePairChange = useCallback((index, field, fieldValue) => {
65
+ const updatedPairs = pairs.map((pair, idx) => idx === index ? { ...pair, [field]: fieldValue } : pair);
66
+ setPairs(updatedPairs);
67
+ onChange(set(updatedPairs));
68
+ }, [pairs, onChange]);
69
+
70
+ /** Appends a new empty pair */
71
+ const handleAddPair = useCallback(() => {
72
+ const updatedPairs = [...pairs, { key: '', value: null, _key: nanoid() }];
73
+ setPairs(updatedPairs);
74
+ onChange(set(updatedPairs));
75
+ }, [pairs, onChange]);
76
+
77
+ /** Removes the pair at the given index */
78
+ const handleRemovePair = useCallback((index) => {
79
+ const updatedPairs = pairs.filter((_, idx) => idx !== index);
80
+ setPairs(updatedPairs);
81
+ onChange(set(updatedPairs));
82
+ }, [pairs, onChange]);
83
+
84
+ /** Swaps a pair with the one above it */
85
+ const handleMoveUp = useCallback((index) => {
86
+ if (index === 0) return;
87
+ const updatedPairs = [...pairs];
88
+ [updatedPairs[index], updatedPairs[index - 1]] = [updatedPairs[index - 1], updatedPairs[index]];
89
+ setPairs(updatedPairs);
90
+ onChange(set(updatedPairs));
91
+ }, [pairs, onChange]);
92
+
93
+ /** Swaps a pair with the one below it */
94
+ const handleMoveDown = useCallback((index) => {
95
+ if (index === pairs.length - 1) return;
96
+ const updatedPairs = [...pairs];
97
+ [updatedPairs[index], updatedPairs[index + 1]] = [updatedPairs[index + 1], updatedPairs[index]];
98
+ setPairs(updatedPairs);
99
+ onChange(set(updatedPairs));
100
+ }, [pairs, onChange]);
101
+
102
+ /** Opens the reference picker, calling fetchReferences to populate the list */
103
+ const openReferenceSelector = useCallback(async (index) => {
104
+ setEditingIndex(index);
105
+ if (!sanityClient) {
106
+ console.error('KeyValueReferenceInput: Sanity client not available');
107
+ return;
108
+ }
109
+ try {
110
+ let refs;
111
+ if (fetchReferences) {
112
+ refs = await fetchReferences(sanityClient, formDocument);
113
+ } else if (referenceType) {
114
+ refs = await sanityClient.fetch(`*[_type == $type]{_id, title}`, { type: referenceType });
115
+ } else {
116
+ console.warn('KeyValueReferenceInput: provide a fetchReferences prop or referenceType');
117
+ refs = [];
118
+ }
119
+ setAvailableReferences(refs);
120
+ setIsDialogOpen(true);
121
+ } catch (err) {
122
+ console.error('Error fetching available references:', err);
123
+ }
124
+ }, [sanityClient, fetchReferences, referenceType, formDocument]);
125
+
126
+ /** Closes the picker dialog and clears editing state */
127
+ const closeDialog = useCallback(() => {
128
+ setIsDialogOpen(false);
129
+ setEditingIndex(null);
130
+ }, []);
131
+
132
+ /** Writes the selected item as a weak reference and closes the dialog */
133
+ const handleReferenceSelect = useCallback((reference) => {
134
+ if (editingIndex === null) return;
135
+ handlePairChange(editingIndex, 'value', { _type: 'reference', _ref: reference._id, _weak: true });
136
+ closeDialog();
137
+ }, [editingIndex, handlePairChange, closeDialog]);
138
+
139
+ const referenceOptions = availableReferences.map(ref => ({ value: ref._id, title: ref.title }));
140
+
141
+ // Infer labels from schemaType if available
142
+ const keyField = schemaType?.options?.of?.[0]?.fields?.find(f => f.name === 'key');
143
+ const valueField = schemaType?.options?.of?.[0]?.fields?.find(f => f.name === 'value');
144
+ const keyTitle = keyField?.title || 'Key';
145
+ const valueTitle = valueField?.title || 'Value';
146
+ const keyPlaceholder = keyField?.placeholder || 'Enter key';
147
+ const pickerLabel = referenceType || valueTitle.toLowerCase();
148
+
149
+ return (
150
+ <Stack space={3}>
151
+ {topActions && <Box paddingBottom={2}>{topActions}</Box>}
152
+
153
+ <Box>
154
+ <Stack space={2}>
155
+ {pairs.map((pair, index) => (
156
+ <Flex key={index} gap={1} align="center">
157
+ {/* Reorder buttons */}
158
+ <Flex direction="column" style={{ flexShrink: 0 }}>
159
+ <Button
160
+ mode="bleed"
161
+ icon={ArrowUpIcon}
162
+ padding={1}
163
+ fontSize={0}
164
+ onClick={() => handleMoveUp(index)}
165
+ disabled={index === 0}
166
+ style={{ cursor: index === 0 ? 'default' : 'pointer' }}
167
+ />
168
+ <Button
169
+ mode="bleed"
170
+ icon={ArrowDownIcon}
171
+ padding={1}
172
+ fontSize={0}
173
+ onClick={() => handleMoveDown(index)}
174
+ disabled={index === pairs.length - 1}
175
+ style={{ cursor: index === pairs.length - 1 ? 'default' : 'pointer' }}
176
+ />
177
+ </Flex>
178
+
179
+ {/* Key input */}
180
+ <Box flex={1}>
181
+ <TextInput
182
+ value={pair.key}
183
+ onChange={(e) => handlePairChange(index, 'key', e.target.value)}
184
+ placeholder={keyPlaceholder}
185
+ />
186
+ </Box>
187
+
188
+ {/* Reference display or empty-state picker trigger */}
189
+ <Box flex={1}>
190
+ {pair.value?._ref ? (
191
+ <Card radius={2} tone="primary" style={{ paddingLeft: '0.75rem', height: 'fit-content' }}>
192
+ <Flex align="center" justify="space-between">
193
+ <Text size={2} style={{ whiteSpace: 'nowrap' }}>
194
+ {referenceData[pair.value._ref] || 'Loading...'}
195
+ </Text>
196
+ <MenuButton
197
+ button={<Button icon={EllipsisHorizontalIcon} mode="bleed" title="Options" />}
198
+ id={`ref-options-${index}`}
199
+ menu={
200
+ <Menu>
201
+ <MenuItem tone="critical" icon={TrashIcon} text="Remove" onClick={() => handlePairChange(index, 'value', null)} />
202
+ <MenuItem icon={SyncIcon} text="Replace" onClick={() => openReferenceSelector(index)} />
203
+ </Menu>
204
+ }
205
+ popover={{ portal: true, tone: 'default', placement: 'left' }}
206
+ />
207
+ </Flex>
208
+ </Card>
209
+ ) : (
210
+ <Box
211
+ padding={2}
212
+ style={{ border: '1px dashed #ccc', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
213
+ onClick={() => openReferenceSelector(index)}
214
+ >
215
+ <Text muted size={2}>Click to select a {pickerLabel}</Text>
216
+ </Box>
217
+ )}
218
+ </Box>
219
+
220
+ {/* Remove button */}
221
+ <Button
222
+ mode="bleed"
223
+ tone="critical"
224
+ icon={TrashIcon}
225
+ padding={2}
226
+ onClick={() => handleRemovePair(index)}
227
+ style={{ flexShrink: 0, cursor: 'pointer' }}
228
+ />
229
+ </Flex>
230
+ ))}
231
+ </Stack>
232
+ </Box>
233
+
234
+ <Button tone="primary" mode="ghost" onClick={handleAddPair} icon={AddIcon} text={`Add ${keyTitle}`} />
235
+
236
+ {/* Reference picker dialog */}
237
+ {isDialogOpen && (
238
+ <Dialog
239
+ header={`Select a ${pickerLabel}`}
240
+ id="reference-selector-dialog"
241
+ onClose={closeDialog}
242
+ width={1}
243
+ >
244
+ <Box padding={4}>
245
+ <Autocomplete
246
+ id="reference-autocomplete"
247
+ options={referenceOptions}
248
+ placeholder={`Search ${pickerLabel}s...`}
249
+ renderOption={(option) => (
250
+ <Card key={option.value} padding={3} radius={2} tone="default" style={{ cursor: 'pointer' }}>
251
+ <Text size={2}>{option.title}</Text>
252
+ </Card>
253
+ )}
254
+ renderValue={(val) => referenceOptions.find(o => o.value === val)?.title || ''}
255
+ onChange={(newValue) => {
256
+ const selected = availableReferences.find(r => r._id === newValue);
257
+ if (selected) handleReferenceSelect(selected);
258
+ }}
259
+ openButton
260
+ fontSize={2}
261
+ />
262
+ </Box>
263
+ </Dialog>
264
+ )}
265
+ </Stack>
266
+ );
267
+ }