@noya-app/noya-designsystem 0.0.2

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 (73) hide show
  1. package/.turbo/turbo-build.log +17 -0
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +1 -0
  4. package/dist/index.d.mts +1178 -0
  5. package/dist/index.d.ts +1178 -0
  6. package/dist/index.js +15651 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/index.mjs +15684 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/package.json +49 -0
  11. package/src/__tests__/__snapshots__/fuzzyScorer.test.ts.snap +41 -0
  12. package/src/__tests__/fuzzyScorer.test.ts +85 -0
  13. package/src/components/ActivityIndicator.tsx +44 -0
  14. package/src/components/Avatar.tsx +64 -0
  15. package/src/components/Button.tsx +209 -0
  16. package/src/components/Chip.tsx +214 -0
  17. package/src/components/ContextMenu.tsx +224 -0
  18. package/src/components/Dialog.tsx +143 -0
  19. package/src/components/Divider.tsx +40 -0
  20. package/src/components/DropdownMenu.tsx +208 -0
  21. package/src/components/FillInputField.tsx +43 -0
  22. package/src/components/FillPreviewBackground.tsx +132 -0
  23. package/src/components/GradientPicker.tsx +88 -0
  24. package/src/components/Grid.tsx +54 -0
  25. package/src/components/GridView.tsx +468 -0
  26. package/src/components/IconButton.tsx +46 -0
  27. package/src/components/InputField.tsx +568 -0
  28. package/src/components/InputFieldWithCompletions.tsx +477 -0
  29. package/src/components/Label.tsx +62 -0
  30. package/src/components/LabeledElementView.tsx +185 -0
  31. package/src/components/ListView.tsx +950 -0
  32. package/src/components/Popover.tsx +113 -0
  33. package/src/components/Progress.tsx +57 -0
  34. package/src/components/RadioGroup.tsx +163 -0
  35. package/src/components/ScrollArea.tsx +70 -0
  36. package/src/components/Select.tsx +152 -0
  37. package/src/components/Slider.tsx +82 -0
  38. package/src/components/Sortable.tsx +296 -0
  39. package/src/components/Spacer.tsx +29 -0
  40. package/src/components/Stack.tsx +142 -0
  41. package/src/components/Switch.tsx +67 -0
  42. package/src/components/Text.tsx +164 -0
  43. package/src/components/Toast.tsx +86 -0
  44. package/src/components/Tooltip.tsx +43 -0
  45. package/src/components/TreeView.tsx +85 -0
  46. package/src/components/internal/Menu.tsx +222 -0
  47. package/src/components/internal/TextInput.tsx +296 -0
  48. package/src/components/internal/__tests__/TextInput.test.tsx +144 -0
  49. package/src/contexts/DesignSystemConfiguration.tsx +57 -0
  50. package/src/contexts/DialogContext.tsx +190 -0
  51. package/src/contexts/GlobalInputBlurContext.tsx +61 -0
  52. package/src/contexts/ImageDataContext.tsx +44 -0
  53. package/src/hooks/__tests__/mergeEventHandlers.test.ts +47 -0
  54. package/src/hooks/mergeEventHandlers.ts +55 -0
  55. package/src/hooks/useHover.ts +173 -0
  56. package/src/hooks/useObjectURL.ts +22 -0
  57. package/src/hooks/usePlatform.ts +13 -0
  58. package/src/index.tsx +77 -0
  59. package/src/mediaQuery.ts +13 -0
  60. package/src/theme/dark.ts +37 -0
  61. package/src/theme/index.ts +17 -0
  62. package/src/theme/light.ts +178 -0
  63. package/src/utils/breakpoints.ts +45 -0
  64. package/src/utils/completions.ts +21 -0
  65. package/src/utils/createSectionedMenu.ts +38 -0
  66. package/src/utils/fuzzyScorer.ts +105 -0
  67. package/src/utils/getGradientBackground.tsx +33 -0
  68. package/src/utils/handleNudge.ts +30 -0
  69. package/src/utils/mouseEvent.ts +7 -0
  70. package/src/utils/sketchColor.ts +34 -0
  71. package/src/utils/sketchPattern.ts +29 -0
  72. package/src/utils/withSeparatorElements.ts +31 -0
  73. package/tsconfig.json +3 -0
@@ -0,0 +1,477 @@
1
+ import { Size } from "@noya-app/noya-geometry";
2
+ import { chunkBy, partition } from "@noya-app/noya-utils";
3
+ import React, {
4
+ FocusEventHandler,
5
+ ForwardedRef,
6
+ forwardRef,
7
+ memo,
8
+ useCallback,
9
+ useEffect,
10
+ useLayoutEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from "react";
15
+ import styled from "styled-components";
16
+ import {
17
+ CompletionItem,
18
+ CompletionListItem,
19
+ CompletionSectionHeader,
20
+ } from "../utils/completions";
21
+ import { IToken, fuzzyFilter, fuzzyTokenize } from "../utils/fuzzyScorer";
22
+ import { ActivityIndicator } from "./ActivityIndicator";
23
+ import { InputField, InputFieldSize } from "./InputField";
24
+ import { IVirtualizedList, ListView } from "./ListView";
25
+ import { Spacer } from "./Spacer";
26
+ import { Stack } from "./Stack";
27
+ import { Small } from "./Text";
28
+
29
+ function filterWithGroupedSections(
30
+ items: (CompletionItem | CompletionSectionHeader)[],
31
+ query: string
32
+ ): CompletionListItem[] {
33
+ const sections = chunkBy(items, (a, b) => b.type !== "sectionHeader");
34
+
35
+ return sections.flatMap((section) => {
36
+ const [headers, regular] = partition(
37
+ section,
38
+ (item): item is CompletionSectionHeader => item.type === "sectionHeader"
39
+ );
40
+ const maxVisibleItems =
41
+ headers.length > 0 ? headers[0].maxVisibleItems : undefined;
42
+
43
+ const scoredItems = fuzzyFilter({
44
+ items: regular.map((item) => item.name),
45
+ query,
46
+ });
47
+
48
+ const usedIndexes = new Set(scoredItems.map((item) => item.index));
49
+ const extraItems = regular.flatMap((item, index): CompletionListItem[] =>
50
+ item.alwaysInclude && !usedIndexes.has(index)
51
+ ? [{ ...item, index, score: 0 }]
52
+ : []
53
+ );
54
+ let newItems = scoredItems
55
+ .map((item): CompletionListItem => ({ ...item, ...regular[item.index] }))
56
+ .concat(extraItems);
57
+
58
+ if (maxVisibleItems !== undefined) {
59
+ newItems = newItems.slice(0, maxVisibleItems);
60
+ }
61
+
62
+ if (newItems.length === 0) return [];
63
+
64
+ return [...headers, ...newItems];
65
+ });
66
+ }
67
+
68
+ export const CompletionToken = styled.span<{ type: IToken["type"] }>(
69
+ ({ type }) => ({
70
+ fontWeight: type === "match" ? "bold" : "normal",
71
+ whiteSpace: "pre",
72
+ })
73
+ );
74
+
75
+ interface CompletionMenuProps {
76
+ items: CompletionListItem[];
77
+ selectedIndex: number;
78
+ onSelectItem: (item: CompletionListItem) => void;
79
+ onHoverIndex: (index: number) => void;
80
+ listSize: Size;
81
+ }
82
+
83
+ export const CompletionMenu = memo(
84
+ forwardRef(function CompletionMenu(
85
+ {
86
+ items,
87
+ selectedIndex,
88
+ onSelectItem,
89
+ onHoverIndex,
90
+ listSize,
91
+ }: CompletionMenuProps,
92
+ forwardedRef: ForwardedRef<IVirtualizedList>
93
+ ) {
94
+ return (
95
+ <ListView.Root
96
+ ref={forwardedRef}
97
+ scrollable
98
+ keyExtractor={(item) => item.id}
99
+ data={items}
100
+ virtualized={listSize}
101
+ pressEventName="onPointerDown"
102
+ sectionHeaderVariant="label"
103
+ renderItem={(item, i) => {
104
+ if (item.type === "sectionHeader") {
105
+ return (
106
+ <ListView.Row key={item.id} isSectionHeader>
107
+ {item.name}
108
+ </ListView.Row>
109
+ );
110
+ }
111
+
112
+ const tokens = fuzzyTokenize({
113
+ item: item.name,
114
+ itemScore: item,
115
+ });
116
+
117
+ return (
118
+ <ListView.Row
119
+ key={item.id}
120
+ selected={i === selectedIndex}
121
+ onPress={() => onSelectItem(item)}
122
+ onHoverChange={(hovered) => {
123
+ if (hovered) {
124
+ onHoverIndex(i);
125
+ }
126
+ }}
127
+ >
128
+ {tokens.map((token, j) => (
129
+ <CompletionToken key={j} type={token.type}>
130
+ {token.text}
131
+ </CompletionToken>
132
+ ))}
133
+ {item.icon && (
134
+ <>
135
+ <Spacer.Horizontal />
136
+ {item.icon}
137
+ </>
138
+ )}
139
+ </ListView.Row>
140
+ );
141
+ }}
142
+ />
143
+ );
144
+ })
145
+ );
146
+
147
+ interface Props {
148
+ loading?: boolean;
149
+ initialValue?: string;
150
+ placeholder?: string;
151
+ items: (CompletionItem | CompletionSectionHeader)[];
152
+ onChange?: (value: string) => void;
153
+ onHoverItem?: (item: CompletionItem | undefined) => void;
154
+ onSelectItem?: (item: CompletionItem) => void;
155
+ onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
156
+ onBlur?: (didSubmit: boolean, value: string) => void;
157
+ onDeleteWhenEmpty?: () => void;
158
+ size?: InputFieldSize;
159
+ style?: React.CSSProperties;
160
+ children?: React.ReactNode;
161
+ hideChildrenWhenFocused?: boolean;
162
+ hideMenuWhenEmptyValue?: boolean;
163
+ }
164
+
165
+ export const InputFieldWithCompletions = memo(
166
+ forwardRef(function InputFieldWithCompletions(
167
+ {
168
+ loading,
169
+ initialValue = "",
170
+ placeholder,
171
+ items,
172
+ size,
173
+ onChange,
174
+ onSelectItem,
175
+ onHoverItem,
176
+ onFocus,
177
+ onBlur,
178
+ onDeleteWhenEmpty,
179
+ style,
180
+ children,
181
+ hideChildrenWhenFocused = false,
182
+ hideMenuWhenEmptyValue = false,
183
+ }: Props,
184
+ forwardedRef: ForwardedRef<HTMLInputElement>
185
+ ) {
186
+ const defaultRef = useRef<HTMLInputElement>(null);
187
+ const ref = forwardedRef || defaultRef;
188
+
189
+ const [isFocused, setIsFocused] = useState(false);
190
+ const [state, _setState] = useState({
191
+ filter: initialValue,
192
+ selectedIndex: 0,
193
+ });
194
+
195
+ const updateState = useCallback(
196
+ (
197
+ newState: { filter?: string; selectedIndex?: number },
198
+ hoverItem?: "resetHover"
199
+ ) => {
200
+ const nextState = { ...state, ...newState };
201
+ const nextItems = filterWithGroupedSections(items, nextState.filter);
202
+
203
+ // If we would be selecting a header, find the next valid index
204
+ if (nextItems[nextState.selectedIndex]?.type === "sectionHeader") {
205
+ nextState.selectedIndex = getNextIndex(
206
+ nextItems,
207
+ nextState.selectedIndex,
208
+ "next",
209
+ (item) => item.type === "sectionHeader"
210
+ );
211
+ }
212
+
213
+ if (hoverItem === "resetHover") {
214
+ onHoverItem?.(undefined);
215
+ } else {
216
+ const nextItem = nextItems[nextState.selectedIndex];
217
+
218
+ if (nextItem && nextItem.type !== "sectionHeader") {
219
+ onHoverItem?.(nextItem);
220
+ } else {
221
+ onHoverItem?.(undefined);
222
+ }
223
+ }
224
+
225
+ _setState(nextState);
226
+ if (newState.filter !== undefined) {
227
+ onChange?.(newState.filter);
228
+ }
229
+ },
230
+ [items, onChange, onHoverItem, state]
231
+ );
232
+
233
+ const initialValueRef = useRef(initialValue);
234
+
235
+ useLayoutEffect(() => {
236
+ if (initialValueRef.current === initialValue) return;
237
+ initialValueRef.current = initialValue;
238
+ updateState({ filter: initialValue });
239
+ }, [initialValue, updateState]);
240
+
241
+ const { filter, selectedIndex } = state;
242
+
243
+ const listRef = React.useRef<ListView.VirtualizedList>(null);
244
+
245
+ const filteredItems = useMemo(
246
+ () => filterWithGroupedSections(items, filter),
247
+ [items, filter]
248
+ );
249
+
250
+ const [normalItems, sectionHeaderItems] = partition(
251
+ filteredItems,
252
+ (item) => item.type !== "sectionHeader"
253
+ );
254
+
255
+ const height = Math.min(
256
+ ListView.calculateHeight(
257
+ normalItems.length,
258
+ sectionHeaderItems.length,
259
+ "label"
260
+ ),
261
+ ListView.rowHeight * 10.5
262
+ );
263
+
264
+ useEffect(() => {
265
+ if (listRef.current) {
266
+ listRef.current.scrollToIndex(selectedIndex);
267
+ }
268
+ }, [selectedIndex]);
269
+
270
+ // Keep track of the last submitted value so we can detect whether a blur
271
+ // event was caused by selecting an item or not
272
+ const lastSubmittedValueRef = useRef<
273
+ { filter: string; itemName: string } | undefined
274
+ >(undefined);
275
+
276
+ const selectItem = useCallback(
277
+ (item: CompletionListItem) => {
278
+ if (item.type === "sectionHeader") return;
279
+
280
+ lastSubmittedValueRef.current = {
281
+ filter,
282
+ itemName: item.name,
283
+ };
284
+
285
+ onSelectItem?.(item);
286
+ onHoverItem?.(undefined);
287
+
288
+ if (ref && typeof ref === "object") {
289
+ ref.current?.blur();
290
+ }
291
+ },
292
+ [filter, onSelectItem, onHoverItem, ref]
293
+ );
294
+
295
+ const handleChange = useCallback(
296
+ (value: string) => updateState({ selectedIndex: 0, filter: value }),
297
+ [updateState]
298
+ );
299
+
300
+ const handleBlur = useCallback(() => {
301
+ setIsFocused(false);
302
+ const didSubmit = lastSubmittedValueRef.current?.filter === filter;
303
+ updateState({ selectedIndex: 0, filter: initialValue }, "resetHover");
304
+ onBlur?.(didSubmit, filter);
305
+ }, [filter, initialValue, onBlur, updateState]);
306
+
307
+ const handleFocus: FocusEventHandler<HTMLInputElement> = useCallback(
308
+ (event) => {
309
+ setIsFocused(true);
310
+ updateState({ selectedIndex: 0, filter: initialValue });
311
+ onFocus?.(event);
312
+ },
313
+ [initialValue, onFocus, updateState]
314
+ );
315
+
316
+ const handleIndexChange = useCallback(
317
+ (index: number) => updateState({ selectedIndex: index }),
318
+ [updateState]
319
+ );
320
+
321
+ const handleKeyDown = useCallback(
322
+ (event: React.KeyboardEvent<HTMLInputElement>) => {
323
+ let handled = false;
324
+
325
+ switch (event.key) {
326
+ case "ArrowDown":
327
+ case "ArrowUp":
328
+ handleIndexChange(
329
+ getNextIndex(
330
+ filteredItems,
331
+ selectedIndex,
332
+ event.key === "ArrowDown" ? "next" : "previous",
333
+ (item) => item.type === "sectionHeader"
334
+ )
335
+ );
336
+
337
+ handled = true;
338
+ break;
339
+ case "Enter":
340
+ const item = filteredItems[selectedIndex];
341
+ selectItem(item);
342
+
343
+ handled = true;
344
+ break;
345
+ case "Escape":
346
+ if (ref && typeof ref === "object") {
347
+ ref.current?.blur();
348
+ }
349
+
350
+ handled = true;
351
+ break;
352
+ case "Backspace":
353
+ case "Delete": {
354
+ if (filter === "") {
355
+ onDeleteWhenEmpty?.();
356
+ }
357
+
358
+ break;
359
+ }
360
+ }
361
+
362
+ if (handled) {
363
+ event.preventDefault();
364
+ event.stopPropagation();
365
+ }
366
+ },
367
+ [
368
+ handleIndexChange,
369
+ filteredItems,
370
+ selectedIndex,
371
+ selectItem,
372
+ ref,
373
+ filter,
374
+ onDeleteWhenEmpty,
375
+ ]
376
+ );
377
+
378
+ const display = !filter && hideMenuWhenEmptyValue ? "none" : "flex";
379
+
380
+ return (
381
+ <InputField.Root
382
+ size={size}
383
+ labelSize={16}
384
+ renderPopoverContent={({ width }) => {
385
+ const listSize = { width, height };
386
+
387
+ return (
388
+ <Stack.V flex={`0 0 ${height}px`} display={display}>
389
+ {filteredItems.length > 0 ? (
390
+ <CompletionMenu
391
+ ref={listRef}
392
+ items={filteredItems}
393
+ selectedIndex={selectedIndex}
394
+ onSelectItem={selectItem}
395
+ onHoverIndex={handleIndexChange}
396
+ listSize={listSize}
397
+ />
398
+ ) : (
399
+ <Stack.V
400
+ height="100px"
401
+ padding="20px"
402
+ alignItems="center"
403
+ justifyContent="center"
404
+ >
405
+ <Small color="textDisabled">
406
+ {loading ? "Loading" : "No results"}
407
+ </Small>
408
+ </Stack.V>
409
+ )}
410
+ </Stack.V>
411
+ );
412
+ }}
413
+ >
414
+ <InputField.Input
415
+ ref={ref}
416
+ value={filter}
417
+ name="component"
418
+ placeholder={placeholder}
419
+ onChange={handleChange}
420
+ onBlur={handleBlur}
421
+ onFocusCapture={handleFocus}
422
+ onKeyDown={handleKeyDown}
423
+ style={style}
424
+ autoCapitalize="off"
425
+ autoComplete="off"
426
+ autoCorrect="off"
427
+ role="combobox"
428
+ aria-autocomplete="list"
429
+ aria-haspopup="listbox"
430
+ aria-owns="component-listbox"
431
+ aria-expanded={isFocused && display !== "none"}
432
+ aria-controls="component-listbox"
433
+ />
434
+ {(!isFocused || !hideChildrenWhenFocused) && children}
435
+ {loading && isFocused && (
436
+ <InputField.Label>
437
+ <ActivityIndicator />
438
+ </InputField.Label>
439
+ )}
440
+ </InputField.Root>
441
+ );
442
+ })
443
+ );
444
+
445
+ function getNextIndex<T>(
446
+ items: T[],
447
+ currentIndex: number,
448
+ direction: "next" | "previous",
449
+ isDisabled: (item: T) => boolean
450
+ ): number {
451
+ // Make sure the current index is within bounds
452
+ currentIndex =
453
+ currentIndex < 0
454
+ ? 0
455
+ : currentIndex >= items.length
456
+ ? items.length - 1
457
+ : currentIndex;
458
+
459
+ // If there are no valid items in the array, return -1
460
+ if (items.every(isDisabled)) return -1;
461
+
462
+ let nextIndex = currentIndex;
463
+
464
+ do {
465
+ // Move to the next or previous index, wrapping around if necessary
466
+ if (direction === "next") {
467
+ nextIndex = (nextIndex + 1) % items.length;
468
+ } else {
469
+ nextIndex = (nextIndex - 1 + items.length) % items.length;
470
+ }
471
+
472
+ // If we've looped all the way around without finding a valid item, return the current index
473
+ if (nextIndex === currentIndex) return currentIndex;
474
+ } while (isDisabled(items[nextIndex]));
475
+
476
+ return nextIndex;
477
+ }
@@ -0,0 +1,62 @@
1
+ import React, { memo, ReactNode } from 'react';
2
+ import styled from 'styled-components';
3
+ import { Spacer } from './Spacer';
4
+
5
+ /* ----------------------------------------------------------------------------
6
+ * Label
7
+ * ------------------------------------------------------------------------- */
8
+
9
+ const LabelLabel = styled.label<{ selected?: boolean }>(
10
+ ({ theme, selected }) => ({
11
+ ...theme.textStyles.small,
12
+ color: theme.colors.textMuted,
13
+ ...(selected && {
14
+ color: theme.colors.text,
15
+ }),
16
+ fontSize: '11px',
17
+ flex: '0 0 auto',
18
+ minWidth: '0',
19
+ letterSpacing: '0.4px',
20
+ whiteSpace: 'pre', // prevent breaking - may need to make this an option
21
+ }),
22
+ );
23
+
24
+ /* ----------------------------------------------------------------------------
25
+ * Root
26
+ * ------------------------------------------------------------------------- */
27
+
28
+ const LabelContainer = styled.span(({ theme }) => ({
29
+ flex: '0 0 auto',
30
+ position: 'relative',
31
+ border: '0',
32
+ outline: 'none',
33
+ minWidth: '0',
34
+ textAlign: 'left',
35
+ display: 'flex',
36
+ flexDirection: 'column',
37
+ alignItems: 'center',
38
+ }));
39
+
40
+ interface LabelRootProps {
41
+ label: ReactNode;
42
+ children: ReactNode;
43
+ }
44
+
45
+ function LabelRoot({ label, children }: LabelRootProps) {
46
+ return (
47
+ <LabelContainer>
48
+ {children}
49
+ {label && (
50
+ <>
51
+ <Spacer.Vertical size={2} />
52
+ <LabelLabel>{label}</LabelLabel>
53
+ </>
54
+ )}
55
+ </LabelContainer>
56
+ );
57
+ }
58
+
59
+ export namespace Label {
60
+ export const Label = memo(LabelLabel);
61
+ export const Root = memo(LabelRoot);
62
+ }
@@ -0,0 +1,185 @@
1
+ import * as kiwi from 'kiwi.js';
2
+ import React, {
3
+ Children,
4
+ createRef,
5
+ Fragment,
6
+ isValidElement,
7
+ memo,
8
+ ReactNode,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ useRef,
12
+ } from 'react';
13
+ import styled from 'styled-components';
14
+
15
+ const Container = styled.div(({ theme }) => ({
16
+ display: 'flex',
17
+ flex: '1',
18
+ flexDirection: 'column',
19
+ position: 'relative',
20
+ }));
21
+
22
+ const Tools = styled.div(({ theme }) => ({
23
+ display: 'flex',
24
+ flex: '1',
25
+ alignItems: 'center',
26
+ }));
27
+
28
+ const Labels = styled.div(({ theme }) => ({
29
+ height: 'var(--height)',
30
+ position: 'relative',
31
+ overflow: 'hidden',
32
+ userSelect: 'none',
33
+ }));
34
+
35
+ interface ContainerProps {
36
+ children: ReactNode;
37
+ renderLabel: (provided: { id: string; index: number }) => ReactNode;
38
+ }
39
+
40
+ export const LabeledElementView = memo(function LabeledElementView({
41
+ children,
42
+ renderLabel,
43
+ }: ContainerProps) {
44
+ const elementIds: string[] = Children.toArray(children)
45
+ .flatMap((child) =>
46
+ isValidElement(child) && child.type === Fragment
47
+ ? (child.props.children as ReactNode[])
48
+ : [child],
49
+ )
50
+ .map((child) =>
51
+ isValidElement(child) && 'id' in child.props ? child.props.id : null,
52
+ )
53
+ .filter((id) => !!id);
54
+ const serializedIds = elementIds.join(',');
55
+ const containerRef = useRef<HTMLDivElement | null>(null);
56
+
57
+ const refs = useMemo(() => {
58
+ return Object.fromEntries(
59
+ serializedIds.split(',').map((id) => [id, createRef<HTMLSpanElement>()]),
60
+ );
61
+ }, [serializedIds]);
62
+
63
+ const labelElements = useMemo(() => {
64
+ return serializedIds.split(',').map((id, index) => (
65
+ <span
66
+ key={id}
67
+ ref={refs[id]}
68
+ style={{ position: 'absolute', left: `var(--x-offset)` }}
69
+ >
70
+ {renderLabel({
71
+ id,
72
+ index,
73
+ })}
74
+ </span>
75
+ ));
76
+ }, [refs, serializedIds, renderLabel]);
77
+
78
+ useLayoutEffect(() => {
79
+ if (!containerRef.current) return;
80
+
81
+ const containerRect = containerRef.current.getBoundingClientRect();
82
+
83
+ const solver = new kiwi.Solver();
84
+ const variables: kiwi.Variable[] = [];
85
+ const heights: number[] = [];
86
+
87
+ Object.entries(refs).forEach(([id, ref], index, list) => {
88
+ if (!ref.current) return;
89
+
90
+ // console.log('id', id, ref, index);
91
+
92
+ const targetElement = document.getElementById(id)!;
93
+ const targetRect = targetElement.getBoundingClientRect();
94
+ targetRect.x = targetElement.offsetLeft;
95
+ const labelRect = ref.current.getBoundingClientRect();
96
+ labelRect.x = ref.current.offsetLeft;
97
+
98
+ // console.log(id, labelRect, ref.current.offsetLeft);
99
+
100
+ heights.push(labelRect.height);
101
+
102
+ const targetMidXValue = targetRect.x + targetRect.width / 2;
103
+
104
+ const labelMidX = new kiwi.Variable();
105
+ solver.addEditVariable(labelMidX, kiwi.Strength.weak);
106
+ solver.addConstraint(
107
+ new kiwi.Constraint(
108
+ new kiwi.Expression(labelMidX),
109
+ kiwi.Operator.Eq,
110
+ new kiwi.Expression(targetMidXValue),
111
+ kiwi.Strength.strong,
112
+ ),
113
+ );
114
+
115
+ if (index === 0) {
116
+ solver.addConstraint(
117
+ new kiwi.Constraint(
118
+ new kiwi.Expression(labelMidX),
119
+ kiwi.Operator.Ge,
120
+ new kiwi.Expression(labelRect.width / 2),
121
+ kiwi.Strength.required,
122
+ ),
123
+ );
124
+ }
125
+
126
+ // if (index > 0) {
127
+ // solver.addConstraint(
128
+ // new kiwi.Constraint(
129
+ // new kiwi.Expression(variables[index - 1]),
130
+ // kiwi.Operator.Le,
131
+ // new kiwi.Expression(labelRect.width / 2),
132
+ // kiwi.Strength.required
133
+ // )
134
+ // );
135
+ // }
136
+
137
+ if (index === list.length - 1) {
138
+ // console.log(
139
+ // labelMidX.value(),
140
+ // containerRect.width,
141
+ // containerRect.width - labelRect.width / 2,
142
+ // );
143
+ solver.addConstraint(
144
+ new kiwi.Constraint(
145
+ new kiwi.Expression(labelMidX),
146
+ kiwi.Operator.Le,
147
+ new kiwi.Expression(containerRect.width - labelRect.width / 2),
148
+ kiwi.Strength.required,
149
+ ),
150
+ );
151
+ }
152
+
153
+ solver.suggestValue(labelMidX, labelRect.x + labelRect.width / 2);
154
+
155
+ variables.push(labelMidX);
156
+ });
157
+
158
+ solver.updateVariables();
159
+
160
+ Object.entries(refs).forEach(([id, ref], index) => {
161
+ if (!ref.current) return;
162
+
163
+ // console.log(id, variables[index].value());
164
+
165
+ const labelRect = ref.current.getBoundingClientRect();
166
+
167
+ ref.current.style.setProperty(
168
+ '--x-offset',
169
+ `${variables[index].value() - labelRect.width / 2}px`,
170
+ );
171
+ });
172
+
173
+ containerRef.current.style.setProperty(
174
+ '--height',
175
+ `${Math.max(...heights)}px`,
176
+ );
177
+ }, [refs, labelElements]);
178
+
179
+ return (
180
+ <Container ref={containerRef}>
181
+ <Tools>{children}</Tools>
182
+ <Labels>{labelElements}</Labels>
183
+ </Container>
184
+ );
185
+ });