@_linked/shape-ui 2.0.0 → 2.3.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 (207) hide show
  1. package/lib/esm/columns.d.ts +58 -0
  2. package/lib/esm/columns.js +173 -0
  3. package/lib/esm/columns.js.map +1 -0
  4. package/lib/esm/components/AddInstanceForms.d.ts +38 -0
  5. package/lib/esm/components/AddInstanceForms.js +143 -0
  6. package/lib/esm/components/AddInstanceForms.js.map +1 -0
  7. package/lib/esm/components/AddInstanceForms.module.css +12 -0
  8. package/lib/esm/components/BatchEditDrawer.d.ts +13 -0
  9. package/lib/esm/components/BatchEditDrawer.js +122 -0
  10. package/lib/esm/components/BatchEditDrawer.js.map +1 -0
  11. package/lib/esm/components/BatchEditDrawer.module.css +193 -0
  12. package/lib/esm/components/CustomMultiSelect.d.ts +26 -0
  13. package/lib/esm/components/CustomMultiSelect.js +344 -0
  14. package/lib/esm/components/CustomMultiSelect.js.map +1 -0
  15. package/lib/esm/components/CustomMultiSelect.module.css +291 -0
  16. package/lib/esm/components/DynamicFormField.d.ts +21 -0
  17. package/lib/esm/components/DynamicFormField.js +149 -0
  18. package/lib/esm/components/DynamicFormField.js.map +1 -0
  19. package/lib/esm/components/DynamicFormField.module.css +169 -0
  20. package/lib/esm/components/EditInstanceForms.d.ts +15 -0
  21. package/lib/esm/components/EditInstanceForms.js +118 -0
  22. package/lib/esm/components/EditInstanceForms.js.map +1 -0
  23. package/lib/esm/components/FilterBadge.d.ts +20 -0
  24. package/lib/esm/components/FilterBadge.js +26 -0
  25. package/lib/esm/components/FilterBadge.js.map +1 -0
  26. package/lib/esm/components/FilterBadge.module.css +25 -0
  27. package/lib/esm/components/FormField.d.ts +18 -0
  28. package/lib/esm/components/FormField.js +9 -0
  29. package/lib/esm/components/FormField.js.map +1 -0
  30. package/lib/esm/components/FormField.module.css +57 -0
  31. package/lib/esm/components/InstanceDeletionDialog.d.ts +17 -0
  32. package/lib/esm/components/InstanceDeletionDialog.js +83 -0
  33. package/lib/esm/components/InstanceDeletionDialog.js.map +1 -0
  34. package/lib/esm/components/InstanceDeletionDialog.module.css +59 -0
  35. package/lib/esm/components/InstanceOverview.d.ts +43 -0
  36. package/lib/esm/components/InstanceOverview.js +236 -0
  37. package/lib/esm/components/InstanceOverview.js.map +1 -0
  38. package/lib/esm/components/InstanceOverview.module.css +35 -0
  39. package/lib/esm/components/InstanceView.d.ts +29 -0
  40. package/lib/esm/components/InstanceView.js +97 -0
  41. package/lib/esm/components/InstanceView.js.map +1 -0
  42. package/lib/esm/components/InstanceView.module.css +157 -0
  43. package/lib/esm/components/NodeBadge.d.ts +25 -0
  44. package/lib/esm/components/NodeBadge.js +29 -0
  45. package/lib/esm/components/NodeBadge.js.map +1 -0
  46. package/lib/esm/components/NodeValuesEditor.d.ts +15 -0
  47. package/lib/esm/components/NodeValuesEditor.js +29 -0
  48. package/lib/esm/components/NodeValuesEditor.js.map +1 -0
  49. package/lib/esm/components/NodeValuesEditor.module.css +4 -0
  50. package/lib/esm/components/ReactTable.d.ts +59 -0
  51. package/lib/esm/components/ReactTable.js +378 -0
  52. package/lib/esm/components/ReactTable.js.map +1 -0
  53. package/lib/esm/components/ReactTable.module.css +950 -0
  54. package/lib/esm/components/TextfieldEditor.d.ts +1 -2
  55. package/lib/esm/components/TextfieldEditor.js +2 -2
  56. package/lib/esm/components/TextfieldEditor.js.map +1 -1
  57. package/lib/esm/components/forms.module.css +96 -0
  58. package/lib/esm/host.d.ts +196 -0
  59. package/lib/esm/host.js +25 -0
  60. package/lib/esm/host.js.map +1 -0
  61. package/lib/esm/hostContext.d.ts +34 -0
  62. package/lib/esm/hostContext.js +95 -0
  63. package/lib/esm/hostContext.js.map +1 -0
  64. package/lib/esm/index.d.ts +60 -10
  65. package/lib/esm/index.js +60 -14
  66. package/lib/esm/index.js.map +1 -1
  67. package/lib/esm/read.d.ts +48 -0
  68. package/lib/esm/read.js +96 -0
  69. package/lib/esm/read.js.map +1 -0
  70. package/lib/esm/search.d.ts +32 -0
  71. package/lib/esm/search.js +87 -0
  72. package/lib/esm/search.js.map +1 -0
  73. package/lib/esm/shape/contracts.d.ts +58 -0
  74. package/lib/esm/shape/contracts.js +15 -0
  75. package/lib/esm/shape/contracts.js.map +1 -0
  76. package/lib/esm/shape/naming.d.ts +30 -0
  77. package/lib/esm/shape/naming.js +52 -0
  78. package/lib/esm/shape/naming.js.map +1 -0
  79. package/lib/esm/shape/nodeDisplay.d.ts +36 -0
  80. package/lib/esm/shape/nodeDisplay.js +117 -0
  81. package/lib/esm/shape/nodeDisplay.js.map +1 -0
  82. package/lib/esm/shape/propertyTypes.d.ts +12 -0
  83. package/lib/esm/shape/propertyTypes.js +60 -0
  84. package/lib/esm/shape/propertyTypes.js.map +1 -0
  85. package/lib/esm/shape/propertyVisibility.d.ts +56 -0
  86. package/lib/esm/shape/propertyVisibility.js +112 -0
  87. package/lib/esm/shape/propertyVisibility.js.map +1 -0
  88. package/lib/esm/shape/shapeDependencies.d.ts +41 -0
  89. package/lib/esm/shape/shapeDependencies.js +141 -0
  90. package/lib/esm/shape/shapeDependencies.js.map +1 -0
  91. package/lib/esm/shape/tableFilters.d.ts +35 -0
  92. package/lib/esm/shape/tableFilters.js +171 -0
  93. package/lib/esm/shape/tableFilters.js.map +1 -0
  94. package/lib/esm/shape/useFormDependencies.d.ts +38 -0
  95. package/lib/esm/shape/useFormDependencies.js +178 -0
  96. package/lib/esm/shape/useFormDependencies.js.map +1 -0
  97. package/lib/esm/shape/validation.d.ts +15 -0
  98. package/lib/esm/shape/validation.js +106 -0
  99. package/lib/esm/shape/validation.js.map +1 -0
  100. package/lib/esm/tokens.css +25 -0
  101. package/lib/esm/types.d.ts +22 -0
  102. package/lib/esm/types.js +1 -0
  103. package/lib/esm/types.js.map +1 -0
  104. package/lib/esm/write.d.ts +51 -0
  105. package/lib/esm/write.js +99 -0
  106. package/lib/esm/write.js.map +1 -0
  107. package/package.json +44 -18
  108. package/src/backend.ts +1 -0
  109. package/src/columns.regression.test.ts +143 -0
  110. package/src/columns.test.ts +200 -0
  111. package/src/columns.ts +228 -0
  112. package/src/components/AddInstanceForms.module.css +12 -0
  113. package/src/components/AddInstanceForms.tsx +321 -0
  114. package/src/components/AvatarEditor.tsx +34 -0
  115. package/src/components/BatchEditDrawer.module.css +193 -0
  116. package/src/components/BatchEditDrawer.tsx +295 -0
  117. package/src/components/CheckboxEditor.module.css +6 -0
  118. package/src/components/CheckboxEditor.tsx +86 -0
  119. package/src/components/CustomMultiSelect.module.css +291 -0
  120. package/src/components/CustomMultiSelect.tsx +527 -0
  121. package/src/components/DateEditor.tsx +50 -0
  122. package/src/components/DynamicFormField.module.css +169 -0
  123. package/src/components/DynamicFormField.tsx +357 -0
  124. package/src/components/EditInstanceForms.tsx +260 -0
  125. package/src/components/FilterBadge.module.css +25 -0
  126. package/src/components/FilterBadge.tsx +70 -0
  127. package/src/components/FormField.module.css +57 -0
  128. package/src/components/FormField.tsx +59 -0
  129. package/src/components/ImageValueEditor.module.css +3 -0
  130. package/src/components/ImageValueEditor.tsx +82 -0
  131. package/src/components/InstanceDeletionDialog.module.css +59 -0
  132. package/src/components/InstanceDeletionDialog.tsx +175 -0
  133. package/src/components/InstanceOverview.module.css +35 -0
  134. package/src/components/InstanceOverview.tsx +385 -0
  135. package/src/components/InstanceView.module.css +157 -0
  136. package/src/components/InstanceView.tsx +212 -0
  137. package/src/components/NodeBadge.tsx +60 -0
  138. package/src/components/NodeValuesEditor.module.css +4 -0
  139. package/src/components/NodeValuesEditor.tsx +64 -0
  140. package/src/components/RadioButtonEditor.module.css +22 -0
  141. package/src/components/RadioButtonEditor.tsx +96 -0
  142. package/src/components/ReactTable.module.css +950 -0
  143. package/src/components/ReactTable.tsx +1013 -0
  144. package/src/components/SelectEditor.tsx +85 -0
  145. package/src/components/ShapeTable.module.css +394 -0
  146. package/src/components/SwitchEditor.tsx +60 -0
  147. package/src/components/TextareaEditor.tsx +55 -0
  148. package/src/components/TextfieldEditor.tsx +67 -0
  149. package/src/components/ToggleEditor.tsx +63 -0
  150. package/src/components/forms.module.css +96 -0
  151. package/src/host.ts +226 -0
  152. package/src/hostContext.tsx +114 -0
  153. package/src/index.ts +93 -0
  154. package/src/package.ts +21 -0
  155. package/src/read.ts +148 -0
  156. package/src/search.test.ts +174 -0
  157. package/src/search.ts +108 -0
  158. package/src/shape/contracts.ts +66 -0
  159. package/src/shape/naming.ts +52 -0
  160. package/src/shape/nodeDisplay.ts +114 -0
  161. package/src/shape/propertyTypes.ts +86 -0
  162. package/src/shape/propertyVisibility.ts +180 -0
  163. package/src/shape/shapeDependencies.ts +183 -0
  164. package/src/shape/tableFilters.ts +223 -0
  165. package/src/shape/types.ts +1158 -0
  166. package/src/shape/useFormDependencies.ts +257 -0
  167. package/src/shape/validation.ts +121 -0
  168. package/src/tokens.css +25 -0
  169. package/src/types.ts +20 -0
  170. package/src/utils/helper.ts +35 -0
  171. package/src/write.test.ts +174 -0
  172. package/src/write.ts +114 -0
  173. package/.changeset/README.md +0 -20
  174. package/.changeset/config.json +0 -14
  175. package/.github/workflows/changeset-check.yml +0 -44
  176. package/.github/workflows/ci.yml +0 -43
  177. package/.github/workflows/publish.yml +0 -158
  178. package/CHANGELOG.md +0 -30
  179. package/lib/esm/components/AvatarEditor.d.ts +0 -20
  180. package/lib/esm/components/AvatarEditor.js +0 -7
  181. package/lib/esm/components/AvatarEditor.js.map +0 -1
  182. package/lib/esm/components/CheckboxEditor.d.ts +0 -21
  183. package/lib/esm/components/CheckboxEditor.js +0 -44
  184. package/lib/esm/components/CheckboxEditor.js.map +0 -1
  185. package/lib/esm/components/DateEditor.d.ts +0 -20
  186. package/lib/esm/components/DateEditor.js +0 -22
  187. package/lib/esm/components/DateEditor.js.map +0 -1
  188. package/lib/esm/components/RadioButtonEditor.d.ts +0 -23
  189. package/lib/esm/components/RadioButtonEditor.js +0 -41
  190. package/lib/esm/components/RadioButtonEditor.js.map +0 -1
  191. package/lib/esm/components/SelectEditor.d.ts +0 -21
  192. package/lib/esm/components/SelectEditor.js +0 -30
  193. package/lib/esm/components/SelectEditor.js.map +0 -1
  194. package/lib/esm/components/SwitchEditor.d.ts +0 -21
  195. package/lib/esm/components/SwitchEditor.js +0 -23
  196. package/lib/esm/components/SwitchEditor.js.map +0 -1
  197. package/lib/esm/components/TextareaEditor.d.ts +0 -23
  198. package/lib/esm/components/TextareaEditor.js +0 -15
  199. package/lib/esm/components/TextareaEditor.js.map +0 -1
  200. package/lib/esm/components/ToggleEditor.d.ts +0 -22
  201. package/lib/esm/components/ToggleEditor.js +0 -23
  202. package/lib/esm/components/ToggleEditor.js.map +0 -1
  203. package/lib/esm/ontologies/lincd-ui.d.ts +0 -6
  204. package/lib/esm/ontologies/lincd-ui.js +0 -18
  205. package/lib/esm/ontologies/lincd-ui.js.map +0 -1
  206. package/tsconfig-cjs.json +0 -8
  207. package/tsconfig-esm.json +0 -9
@@ -0,0 +1,527 @@
1
+ import { Button } from '@_linked/primitives/components/Button';
2
+ import { Command } from '@_linked/primitives/components/Command';
3
+ import { Dialog } from '@_linked/primitives/components/Dialog';
4
+ import { Popover } from '@_linked/primitives/components/Popover';
5
+ import type { PropertyShapeWire, NodeShapeWire } from '@_linked/core/shapes/nodeShapeWire';
6
+ import { Shape } from '@_linked/core/shapes/Shape';
7
+ import { cl } from '@_linked/react/utils/ClassNames';
8
+ import { getNodeDisplay } from '../shape/nodeDisplay.js';
9
+ import { useCallback, useEffect, useRef, useState } from 'react';
10
+ import { useDataManagerHost } from '../hostContext.js';
11
+ import { formatShapeLabel } from '../shape/naming.js';
12
+ import { Icons } from '@_linked/icons';
13
+ import { InlineImageThumb } from '@_linked/primitives/components/ImageThumb';
14
+ import { Spinner } from '@_linked/primitives/components/Spinner';
15
+ import style from './CustomMultiSelect.module.css';
16
+
17
+ interface CustomMultiSelectProps {
18
+ values?;
19
+ property?: PropertyShapeWire;
20
+ /**
21
+ * The shape whose instance is being edited — the OWNER of `property`, not the shape being
22
+ * picked from. Needed for the pick-mode round-trip, which has to know where to return to.
23
+ *
24
+ * Supply these instead of letting the component read the route: it used to derive both
25
+ * from `useParams().shape_uri` plus CN's settings context, which assumed CN's URL layout
26
+ * for a value every caller already holds.
27
+ */
28
+ sourceShape?: NodeShapeWire;
29
+ sourceShapeUri?: string;
30
+ onShowAsTable?: () => void;
31
+ onChange?: (values: { id: string; label: string; image?: string }[]) => void;
32
+ onBeforeNavigate?: () => Promise<string | void> | string | void;
33
+ narrowedIds?: string[] | null;
34
+ isNarrowing?: boolean;
35
+ }
36
+
37
+ const CustomMultiSelect = ({
38
+ values,
39
+ property,
40
+ sourceShape,
41
+ sourceShapeUri,
42
+ onShowAsTable,
43
+ onChange,
44
+ onBeforeNavigate,
45
+ narrowedIds,
46
+ isNarrowing,
47
+ }: CustomMultiSelectProps) => {
48
+ const [comboboxValues, setComboboxValues] = useState<
49
+ { id: string; label: string; image?: string }[]
50
+ >([]);
51
+ const [isComboboxExpanded, setIsComboboxExpanded] = useState(false);
52
+ const [isComboboxOpen, setIsComboboxOpen] = useState(false);
53
+ const [comboboxInputValue, setComboboxInputValue] = useState('');
54
+ const [createNewInstanceFor, setCreateNewInstanceFor] = useState(null);
55
+ const [maxVisibleOptions, setMaxVisibleOptions] = useState(5);
56
+ const [searchResults, setSearchResults] = useState<{ id: string; label: string; image?: string }[]>([]);
57
+ const [isSearching, setIsSearching] = useState(false);
58
+ const [initialOptions, setInitialOptions] = useState<{ id: string; label: string; image?: string }[]>([]);
59
+ const [isLoadingInitial, setIsLoadingInitial] = useState(false);
60
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
61
+ const [hasMore, setHasMore] = useState(false);
62
+ const [browseOffset, setBrowseOffset] = useState(0);
63
+ const [searchOffset, setSearchOffset] = useState(0);
64
+ const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
65
+ const triggerRef = useRef<HTMLDivElement>(null);
66
+ const sentinelRef = useRef<HTMLDivElement>(null);
67
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
+ // The shape being edited arrives as a prop. It used to be read from the route
69
+ // (`useParams().shape_uri` + `getShapeFromParams(..., projectConfig)`), which assumed
70
+ // CN's URL layout AND CN's settings context — two couplings for one value every caller
71
+ // already has in hand.
72
+ const shape_uri = sourceShapeUri ?? sourceShape?.id;
73
+ // The host supplies searching, inline creation and navigation. The picker used to reach
74
+ // for `Project.searchInstances` + `useSettings` + the router directly, and to import
75
+ // `AddInstanceForms` — a molecule importing an organism, which is both a cycle and the
76
+ // one thing that made this component unextractable.
77
+ const host = useDataManagerHost();
78
+ const nodeShape = sourceShape as NodeShapeWire;
79
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
80
+ const valueArray = (Array.isArray(values) ? values : [values]).filter(Boolean);
81
+
82
+ const isInitialMount = useRef(true);
83
+
84
+ const renderText = (shape: Shape) => {
85
+ return getNodeDisplay(shape);
86
+ };
87
+
88
+ /** Extract an image URL from a shape value (for initial values from props) */
89
+ const getImageFromValue = (val: any): string | undefined => {
90
+ if (!val) return undefined;
91
+ // Direct contentUrl (ImageObject)
92
+ if (val.contentUrl) return String(val.contentUrl);
93
+ // schema:image → contentUrl
94
+ const img = val.image;
95
+ if (img) {
96
+ if (typeof img === 'string') return img;
97
+ if (Array.isArray(img)) {
98
+ const first = img[0];
99
+ return typeof first === 'string' ? first : first?.contentUrl ? String(first.contentUrl) : undefined;
100
+ }
101
+ if (img.contentUrl) return String(img.contentUrl);
102
+ }
103
+ return undefined;
104
+ };
105
+
106
+ // Init selected values from props
107
+ useEffect(() => {
108
+ const alreadySelectedValues =
109
+ valueArray.length > 0
110
+ ? valueArray.map((el) => ({
111
+ id: el.id,
112
+ label: renderText(el),
113
+ image: getImageFromValue(el),
114
+ }))
115
+ : [];
116
+ setComboboxValues(alreadySelectedValues);
117
+ }, []);
118
+
119
+ // The return leg of the pick round trip.
120
+ //
121
+ // This field sent the viewer away to choose and was unmounted while they did, so the
122
+ // answer cannot arrive through a callback — it has to be collected on the way back in.
123
+ // Where it was kept is the host's business; this used to read CN's sessionStorage
124
+ // directly, which is why the component only worked inside Create Now.
125
+ useEffect(() => {
126
+ if (!property?.label) return;
127
+ const pickedItems = host.picking?.takeResult({
128
+ propertyLabel: property.label,
129
+ sourceShapeIri: shape_uri,
130
+ });
131
+ if (!pickedItems || pickedItems.length === 0) return;
132
+ setComboboxValues((prev) => {
133
+ let next = [...prev];
134
+ for (const picked of pickedItems) {
135
+ if (next.some((v) => v.id === picked.id)) continue;
136
+ if (property?.maxCount === 1) return [picked];
137
+ if (property?.maxCount && next.length >= property.maxCount) break;
138
+ next.push(picked);
139
+ }
140
+ return next;
141
+ });
142
+ }, []);
143
+
144
+ // Propagate comboboxValues changes to parent via onChange
145
+ useEffect(() => {
146
+ if (isInitialMount.current) {
147
+ isInitialMount.current = false;
148
+ return;
149
+ }
150
+ onChange?.(comboboxValues);
151
+ }, [comboboxValues]);
152
+
153
+ // Debounced backend search — resets offset on new search text
154
+ useEffect(() => {
155
+ if (debounceRef.current) clearTimeout(debounceRef.current);
156
+ const valueShapeId = property?.valueShape?.id;
157
+ if (!comboboxInputValue || !host.searchInstances || !valueShapeId) {
158
+ setSearchResults([]);
159
+ setSearchOffset(0);
160
+ setHasMore(false);
161
+ setIsSearching(false);
162
+ return;
163
+ }
164
+ setIsSearching(true);
165
+ setSearchOffset(0);
166
+ debounceRef.current = setTimeout(async () => {
167
+ try {
168
+ const response = await host.searchInstances(valueShapeId, {
169
+ query: comboboxInputValue,
170
+ limit: 20,
171
+ offset: 0,
172
+ narrowedIds: narrowedIds || undefined,
173
+ });
174
+ setSearchResults(response?.results || []);
175
+ setHasMore(response?.hasMore ?? false);
176
+ } catch {
177
+ setSearchResults([]);
178
+ setHasMore(false);
179
+ } finally {
180
+ setIsSearching(false);
181
+ }
182
+ }, 300);
183
+ return () => {
184
+ if (debounceRef.current) clearTimeout(debounceRef.current);
185
+ };
186
+ }, [comboboxInputValue, host, property?.valueShape?.id, narrowedIds]);
187
+
188
+ // Load initial options when popover opens or narrowing changes (browse mode)
189
+ useEffect(() => {
190
+ if (!isComboboxOpen) return;
191
+ // Capture trigger width for popover alignment
192
+ if (triggerRef.current) {
193
+ setTriggerWidth(triggerRef.current.getBoundingClientRect().width);
194
+ }
195
+ const valueShapeId = property?.valueShape?.id;
196
+ if (!host.searchInstances || !valueShapeId) return;
197
+ setIsLoadingInitial(true);
198
+ setBrowseOffset(0);
199
+ host
200
+ .searchInstances(valueShapeId, {
201
+ query: '',
202
+ limit: 20,
203
+ offset: 0,
204
+ narrowedIds: narrowedIds || undefined,
205
+ })
206
+ .then((response) => {
207
+ setInitialOptions(response?.results || []);
208
+ setHasMore(response?.hasMore ?? false);
209
+ })
210
+ .catch(() => {
211
+ setInitialOptions([]);
212
+ setHasMore(false);
213
+ })
214
+ .finally(() => setIsLoadingInitial(false));
215
+ }, [isComboboxOpen, narrowedIds]);
216
+
217
+ // Load more results (infinite scroll) for both browse and search modes
218
+ const loadMore = useCallback(async () => {
219
+ const valueShapeId = property?.valueShape?.id;
220
+ if (!host.searchInstances || !valueShapeId || isLoadingMore || !hasMore) return;
221
+
222
+ setIsLoadingMore(true);
223
+ const isSearchMode = !!comboboxInputValue;
224
+ const currentOffset = isSearchMode ? searchOffset : browseOffset;
225
+ const nextOffset = currentOffset + 20;
226
+
227
+ try {
228
+ const response = await host.searchInstances(valueShapeId, {
229
+ query: isSearchMode ? comboboxInputValue : '',
230
+ limit: 20,
231
+ offset: nextOffset,
232
+ narrowedIds: narrowedIds || undefined,
233
+ });
234
+ const newResults = response?.results || [];
235
+ setHasMore(response?.hasMore ?? false);
236
+
237
+ if (isSearchMode) {
238
+ setSearchResults((prev) => [...prev, ...newResults]);
239
+ setSearchOffset(nextOffset);
240
+ } else {
241
+ setInitialOptions((prev) => [...prev, ...newResults]);
242
+ setBrowseOffset(nextOffset);
243
+ }
244
+ } catch {
245
+ setHasMore(false);
246
+ } finally {
247
+ setIsLoadingMore(false);
248
+ }
249
+ }, [host, property?.valueShape?.id, comboboxInputValue, isLoadingMore, hasMore, searchOffset, browseOffset]);
250
+
251
+ // IntersectionObserver for infinite scroll sentinel
252
+ useEffect(() => {
253
+ const sentinel = sentinelRef.current;
254
+ if (!sentinel) return;
255
+
256
+ const observer = new IntersectionObserver(
257
+ (entries) => {
258
+ if (entries[0]?.isIntersecting && hasMore && !isLoadingMore) {
259
+ loadMore();
260
+ }
261
+ },
262
+ { threshold: 0.1 }
263
+ );
264
+ observer.observe(sentinel);
265
+ return () => observer.disconnect();
266
+ }, [hasMore, isLoadingMore, loadMore]);
267
+
268
+ // Responsive max visible chips
269
+ useEffect(() => {
270
+ const update = () => {
271
+ if (window.innerWidth < 768) setMaxVisibleOptions(3);
272
+ else if (window.innerWidth < 1024) setMaxVisibleOptions(4);
273
+ else setMaxVisibleOptions(5);
274
+ };
275
+ update();
276
+ window.addEventListener('resize', update);
277
+ return () => window.removeEventListener('resize', update);
278
+ }, []);
279
+
280
+ const multiselect = property.maxCount > 1;
281
+ const onUpdateCombobox = (item: { id: string; label: string; image?: string }) => {
282
+ if (multiselect) {
283
+ if (comboboxValues?.some((v) => v.id === item.id)) {
284
+ setComboboxValues((prev) => prev.filter((e) => e.id !== item.id));
285
+ } else {
286
+ // Enforce maxCount limit
287
+ if (property?.maxCount && comboboxValues.length >= property.maxCount) return;
288
+ setComboboxValues((prev) => [...prev, item]);
289
+ }
290
+ } else {
291
+ // Single select: toggle — if already selected, deselect; otherwise select
292
+ if (comboboxValues?.some((v) => v.id === item.id)) {
293
+ setComboboxValues([]);
294
+ } else {
295
+ setComboboxValues([item]);
296
+ }
297
+ }
298
+ };
299
+
300
+ /** Dedicated removal handler for the X button on chips — always removes regardless of multiselect */
301
+ const onRemoveValue = (e: React.MouseEvent, item: { id: string; label: string; image?: string }) => {
302
+ e.stopPropagation();
303
+ e.preventDefault();
304
+ setComboboxValues((prev) => prev.filter((v) => v.id !== item.id));
305
+ };
306
+
307
+ const onClickTable = async () => {
308
+ if (onShowAsTable) {
309
+ onShowAsTable();
310
+ } else if (property?.valueShape?.id) {
311
+ // Flush draft to server before navigating away — returns the draftId
312
+ const flushedDraftId = await onBeforeNavigate?.();
313
+ // How "browse all of these" is presented is the host's decision — Create Now
314
+ // navigates to the overview in pick mode; another host might open a sheet. The
315
+ // flushed draft id rides along as the resume token so the half-filled form survives
316
+ // whatever round trip the host chooses.
317
+ host.picking?.open({
318
+ shapeIri: property.valueShape.id,
319
+ propertyLabel: property.label,
320
+ sourceShapeIri: shape_uri,
321
+ maxCount: property.maxCount,
322
+ minCount: property.minCount,
323
+ alreadySelected: comboboxValues,
324
+ resumeToken: flushedDraftId || undefined,
325
+ });
326
+ }
327
+ };
328
+
329
+ const dropdownOptions: { id: string; label: string; image?: string }[] = comboboxInputValue
330
+ ? searchResults
331
+ : initialOptions.length > 0
332
+ ? initialOptions
333
+ : valueArray.map((el) => ({ id: el.id, label: renderText(el), image: getImageFromValue(el) }));
334
+
335
+ const openCreateNew = () => {
336
+ setCreateNewInstanceFor(nodeShape);
337
+ setIsComboboxOpen(false); // close popover first — avoids z-index overlap
338
+ setIsDialogOpen(true);
339
+ };
340
+
341
+ return (
342
+ <div className={style.Root}>
343
+ <Popover.Root
344
+ open={isComboboxOpen}
345
+ onOpenChange={setIsComboboxOpen}
346
+ >
347
+ <Popover.Trigger asChild>
348
+ <div ref={triggerRef} className={style.selectedOptionsAndChevronIconContainer}>
349
+ <div
350
+ className={cl(
351
+ style.selectedOptionsContainer,
352
+ isComboboxExpanded ? style.expanded : ''
353
+ )}
354
+ >
355
+ <div
356
+ className={cl(
357
+ style.noBorderContainer,
358
+ isComboboxExpanded ? style.expanded : ''
359
+ )}
360
+ >
361
+ {comboboxValues.length > 0 ? (
362
+ comboboxValues.slice(0, maxVisibleOptions).map((val, i) => (
363
+ <Button key={i} size="small" className={style.optionButton}>
364
+ {val.image && <InlineImageThumb src={val.image} />}
365
+ {val.label}
366
+ <Icons.Cross2
367
+ onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
368
+ onClick={(e) => onRemoveValue(e, val)}
369
+ />
370
+ </Button>
371
+ ))
372
+ ) : (
373
+ <p className={style.selectorButton}>Select value</p>
374
+ )}
375
+ {comboboxValues.length > maxVisibleOptions &&
376
+ (isComboboxExpanded ? (
377
+ comboboxValues
378
+ .slice(maxVisibleOptions)
379
+ .map((val, i) => (
380
+ <Button key={i} size="small" className={style.optionButton}>
381
+ {val.image && <InlineImageThumb src={val.image} />}
382
+ {val.label}
383
+ <Icons.Cross2
384
+ onPointerDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
385
+ onClick={(e) => onRemoveValue(e, val)}
386
+ />
387
+ </Button>
388
+ ))
389
+ ) : (
390
+ <Button
391
+ size="small"
392
+ className={style.optionButton}
393
+ onClick={() => setIsComboboxExpanded(true)}
394
+ >
395
+ ... {comboboxValues.slice(maxVisibleOptions).length} more
396
+ </Button>
397
+ ))}
398
+ </div>
399
+ </div>
400
+ <Icons.ChevronDown
401
+ className={cl(
402
+ style.chevronDownIcon,
403
+ isComboboxOpen ? style.open : ''
404
+ )}
405
+ />
406
+ {isComboboxExpanded && (
407
+ <p
408
+ className={style.viewLessText}
409
+ onClick={() => setIsComboboxExpanded(false)}
410
+ >
411
+ View Less
412
+ </p>
413
+ )}
414
+ </div>
415
+ </Popover.Trigger>
416
+
417
+ <Popover.Content
418
+ className={style.options}
419
+ style={triggerWidth ? { width: triggerWidth } : undefined}
420
+ align="start"
421
+ >
422
+ <Command.Root shouldFilter={false}>
423
+ {/* Search input row */}
424
+ <div className={style.searchWrapper}>
425
+ <Command.Input
426
+ value={comboboxInputValue}
427
+ onValueChange={setComboboxInputValue}
428
+ placeholder="Search..."
429
+ />
430
+ {comboboxInputValue && (
431
+ <Icons.Cross2
432
+ className={style.resetSearchInputIcon}
433
+ onClick={() => setComboboxInputValue('')}
434
+ />
435
+ )}
436
+ </div>
437
+
438
+ {/* Options list */}
439
+ <Command.List className={style.commandRoot}>
440
+ <Command.Empty className={style.emptyResult}>
441
+ {isLoadingInitial || isSearching
442
+ ? <Spinner size="small" />
443
+ : comboboxInputValue
444
+ ? `No results for "${comboboxInputValue}"`
445
+ : 'No options available'}
446
+ </Command.Empty>
447
+ <Command.Group className={style.commandGroup}>
448
+ {dropdownOptions.map((option) => {
449
+ const selected = comboboxValues.some((v) => v.id === option.id);
450
+ return (
451
+ <Command.Item
452
+ key={option.id}
453
+ value={option.id}
454
+ onSelect={(currentValue) => {
455
+ const item = dropdownOptions.find(o => o.id === currentValue);
456
+ if (item) onUpdateCombobox(item);
457
+ }}
458
+ className={cl(style.option, selected ? style.optionSelected : '')}
459
+ >
460
+ {option.image && <InlineImageThumb src={option.image} />}
461
+ <span className={style.optionLabel}>{option.label}</span>
462
+ {selected && <Icons.Check width="16" height="16" className={style.optionCheck} />}
463
+ </Command.Item>
464
+ );
465
+ })}
466
+ </Command.Group>
467
+ {/* Infinite scroll sentinel + loading indicator */}
468
+ {hasMore && (
469
+ <div ref={sentinelRef} className={style.loadMoreSentinel}>
470
+ <Spinner active={isLoadingMore} size="small" />
471
+ </div>
472
+ )}
473
+ </Command.List>
474
+ </Command.Root>
475
+
476
+ {/* "Create New" sits at the bottom of the popover, outside Command.List */}
477
+ <div className={style.createNewContainer}>
478
+ <button
479
+ type="button"
480
+ className={style.modalButton}
481
+ onClick={openCreateNew}
482
+ >
483
+ <Icons.Plus width="14" height="14" />
484
+ <span>Create New</span>
485
+ </button>
486
+ </div>
487
+ </Popover.Content>
488
+ </Popover.Root>
489
+
490
+ {/* Dialog is a sibling of Popover — prevents portal/z-index layering conflict */}
491
+ <Dialog.Root open={isDialogOpen} onOpenChange={setIsDialogOpen}>
492
+ <Dialog.Content className={style.dialogRoot}>
493
+ <Dialog.Header className={style.dialogHeader}>
494
+ <Dialog.Title>
495
+ Create New {nodeShape?.label ? formatShapeLabel(nodeShape.label) : ''}
496
+ </Dialog.Title>
497
+ <Dialog.Description>
498
+ Fill the form to create a new {nodeShape?.label ? formatShapeLabel(nodeShape.label) : ''}
499
+ </Dialog.Description>
500
+ </Dialog.Header>
501
+ <div className={style.dialogContent}>
502
+ {/*
503
+ The host decides what "create one of these" looks like. This component used
504
+ to import `AddInstanceForms` and call `Project.createShapeInstance` itself —
505
+ a molecule reaching up into an organism and into the control plane. Now it
506
+ only decides WHEN to ask.
507
+ */}
508
+ {host.inlineCreate?.({
509
+ shapeIri: (createNewInstanceFor || nodeShape)?.id,
510
+ onCreated: (created) => {
511
+ setIsDialogOpen(false);
512
+ if (created?.id) onUpdateCombobox(created);
513
+ },
514
+ onCancel: () => setIsDialogOpen(false),
515
+ })}
516
+ </div>
517
+ </Dialog.Content>
518
+ </Dialog.Root>
519
+
520
+ <div onClick={onClickTable} className={style.tableIcon}>
521
+ <Icons.Table width="25" height="25" />
522
+ </div>
523
+ </div>
524
+ );
525
+ };
526
+
527
+ export default CustomMultiSelect;
@@ -0,0 +1,50 @@
1
+ import React, { useState } from 'react';
2
+
3
+ export interface DateEditorProps {
4
+ onChange?: (e) => void;
5
+ of?: Record<string, any>;
6
+ property?: string;
7
+ /**
8
+ * Converts the value returned by the shape, to the date string for the date field
9
+ * @param shapePropertyValue
10
+ * @returns
11
+ */
12
+ preConvertValueFn?: (shapePropertyValue: any) => string;
13
+ /**
14
+ * Converts the value of the date field to a value that can be provided to the set method of the shape
15
+ * @param inputValue
16
+ * @returns
17
+ */
18
+ postConvertValueFn?: (inputValue: string) => any;
19
+ }
20
+
21
+ export const DateEditor = ({
22
+ onChange,
23
+ of,
24
+ property,
25
+ preConvertValueFn,
26
+ postConvertValueFn,
27
+
28
+ ...restProps
29
+ }: DateEditorProps) => {
30
+ const onChangeHandler = (e) => {
31
+ if (onChange) {
32
+ onChange(e);
33
+ }
34
+ };
35
+
36
+ return (
37
+ <></>
38
+ // <Input
39
+ // onChange={onChangeHandler}
40
+ // defaultValue={
41
+ // of
42
+ // ? preConvertValueFn
43
+ // ? preConvertValueFn(of[property])
44
+ // : of[property]
45
+ // : ''
46
+ // }
47
+ // {...restProps}
48
+ // />
49
+ );
50
+ };