@ably/ui 18.0.0-dev.dbc599e55b → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +46 -423
  2. package/README.md +1 -1
  3. package/core/Accordion.js +1 -1
  4. package/core/Accordion.js.map +1 -1
  5. package/core/Badge.js +1 -1
  6. package/core/Badge.js.map +1 -1
  7. package/core/CodeSnippet.js +1 -1
  8. package/core/CodeSnippet.js.map +1 -1
  9. package/core/FeaturedLink.js +1 -1
  10. package/core/FeaturedLink.js.map +1 -1
  11. package/core/Header/HeaderLinks.js +1 -1
  12. package/core/Header/HeaderLinks.js.map +1 -1
  13. package/core/Header.js +1 -1
  14. package/core/Header.js.map +1 -1
  15. package/core/Icon/components/icon-tech-perplexity-mono.js +2 -0
  16. package/core/Icon/components/icon-tech-perplexity-mono.js.map +1 -0
  17. package/core/Icon/components/index.js +1 -1
  18. package/core/Icon/components/index.js.map +1 -1
  19. package/core/Icon/computed-icons/tech-icons.js +1 -1
  20. package/core/Icon/computed-icons/tech-icons.js.map +1 -1
  21. package/core/Meganav/data.js +1 -1
  22. package/core/Meganav/data.js.map +1 -1
  23. package/core/Meganav/images/cust-logo-dialpad-dark.png +0 -0
  24. package/core/Meganav/images/cust-logo-dialpad-light.png +0 -0
  25. package/core/Meganav.js +1 -1
  26. package/core/Meganav.js.map +1 -1
  27. package/core/ProductTile/ProductLabel.js +1 -1
  28. package/core/ProductTile/ProductLabel.js.map +1 -1
  29. package/core/ProductTile/data.js +1 -1
  30. package/core/ProductTile/data.js.map +1 -1
  31. package/core/SegmentedControl.js +1 -1
  32. package/core/SegmentedControl.js.map +1 -1
  33. package/core/SessionData.js.map +1 -1
  34. package/core/Slider.js +1 -1
  35. package/core/Slider.js.map +1 -1
  36. package/core/TabMenu.js +1 -1
  37. package/core/TabMenu.js.map +1 -1
  38. package/core/Toggle.js +1 -1
  39. package/core/Toggle.js.map +1 -1
  40. package/core/icons/tech/icon-tech-perplexity-mono.svg +3 -0
  41. package/core/sprites-tech.svg +1 -1
  42. package/core/styles/buttons.css +6 -6
  43. package/core/styles/colors/types.js +1 -1
  44. package/core/styles/colors/types.js.map +1 -1
  45. package/core/styles/dropdowns.css +2 -2
  46. package/core/styles/forms.css +5 -5
  47. package/core/styles/legacy-buttons.css +8 -8
  48. package/core/styles/properties.css +2 -2
  49. package/core/styles/text.css +2 -2
  50. package/index.d.ts +18 -61
  51. package/package.json +27 -27
  52. package/tailwind.config.js +2 -2
  53. package/core/Icon/components/icon-display-asset-tracking-col.js +0 -2
  54. package/core/Icon/components/icon-display-asset-tracking-col.js.map +0 -1
  55. package/core/Icon/components/icon-gui-prod-asset-tracking-outline.js +0 -2
  56. package/core/Icon/components/icon-gui-prod-asset-tracking-outline.js.map +0 -1
  57. package/core/Icon/components/icon-gui-prod-asset-tracking-solid.js +0 -2
  58. package/core/Icon/components/icon-gui-prod-asset-tracking-solid.js.map +0 -1
  59. package/core/Icon/components/icon-product-asset-tracking-mono.js +0 -2
  60. package/core/Icon/components/icon-product-asset-tracking-mono.js.map +0 -1
  61. package/core/Icon/components/icon-product-asset-tracking.js +0 -2
  62. package/core/Icon/components/icon-product-asset-tracking.js.map +0 -1
  63. package/core/Meganav/images/cust-logo-doxy-dark.png +0 -0
  64. package/core/Meganav/images/cust-logo-doxy-light.png +0 -0
package/AGENTS.md CHANGED
@@ -1,19 +1,7 @@
1
1
  # Agent Development Guide
2
2
 
3
- This file is not necessarily for people, the intended audience is automated agents.
4
-
5
3
  > This file contains @ably/ui package-specific guidance. For universal code style and git workflow, see the [root AGENTS.md](../../AGENTS.md).
6
4
 
7
- ## Other references
8
-
9
- Consider the content of `README.md` as well, it contains technical information
10
- used by contributors to this project, as well as consumers of this project
11
-
12
- ## Consumers
13
-
14
- This project is intended to primarily be consumed by the Ably website, voltaire
15
- & docs projects. It is distributed via NPM as the `@ably/ui` package.
16
-
17
5
  ## Build & Test Commands
18
6
 
19
7
  - `pnpm build` - Build the library (prebuild, icons, swc, tsc, cleanup)
@@ -22,7 +10,7 @@ This project is intended to primarily be consumed by the Ably website, voltaire
22
10
  - `pnpm lint` - Run ESLint on all files
23
11
  - `pnpm format:check` - Check formatting with Prettier
24
12
  - `pnpm format:write` - Auto-format all files with Prettier
25
- - `pnpm storybook` - Start Storybook dev server on port 6006
13
+ - `pnpm storybook` - Start Storybook dev server on port 3200
26
14
  - `pnpm start` - Start Vite dev server on port 5000
27
15
 
28
16
  ## Code Style
@@ -42,31 +30,11 @@ This project is intended to primarily be consumed by the Ably website, voltaire
42
30
  ### Custom Hooks
43
31
 
44
32
  - **Naming**: `use` prefix with descriptive name (e.g., `useContentHeight`, `useThemedScrollpoints`)
45
- - **JSDoc**: Always include for custom hooks, especially performance-related ones
46
- - **Parameters**: Document with `@param` including types and defaults
47
- - **Returns**: Document with `@returns` including type and semantic meaning
33
+ - **JSDoc**: Always include for custom hooks, especially performance-related ones — document `@param` with types/defaults and `@returns` with type and semantic meaning
48
34
  - **Performance rationale**: Include "why" in JSDoc when optimizing (e.g., "eliminates forced reflows")
49
35
  - **Cleanup**: Always return cleanup function to prevent memory leaks
50
36
  - **Shared constants**: Import from `src/core/utils/heights.ts` instead of duplicating
51
37
 
52
- Example:
53
-
54
- ```typescript
55
- /**
56
- * Tracks element height using ResizeObserver to avoid forced reflows.
57
- *
58
- * @param ref - React ref to the element to observe
59
- * @param initialHeight - Initial height value (default: 0)
60
- * @returns Current height in pixels
61
- */
62
- export function useContentHeight(
63
- ref: RefObject<HTMLElement>,
64
- initialHeight = 0,
65
- ): number {
66
- // Implementation...
67
- }
68
- ```
69
-
70
38
  ## Development
71
39
 
72
40
  - Run `pnpm lint` & `pnpm format:write` on files after making changes, we lint
@@ -76,50 +44,9 @@ export function useContentHeight(
76
44
  - When testing with Storybook, use Chrome DevTools Performance tab to verify no forced reflows
77
45
  - For performance-related changes, compare before/after metrics and include in commit/PR
78
46
 
79
- ### Chrome DevTools MCP Tools (Optional)
80
-
81
- If you have MCP support (Claude Code detects `.mcp.json` automatically), use these tools for automated performance profiling and visual verification during component development:
82
-
83
- **Primary use case:** Performance profiling during component development to detect forced reflows and layout thrashing.
84
-
85
- **Development server URLs:**
86
- - `http://localhost:6006` - Storybook (primary development environment for @ably/ui)
87
- - `http://localhost:4000` - Voltaire docs site (for testing components in production-like context)
88
-
89
- **Common workflows:**
47
+ ### Chrome DevTools MCP (Optional)
90
48
 
91
- 1. **Automated forced reflow detection:**
92
- - Start Storybook: `pnpm storybook`
93
- - Use `mcp__chrome-devtools__performance_start_trace` with `reload: true, autoStop: true`
94
- - Navigate to component story at `http://localhost:6006`
95
- - Tool automatically stops trace after page load
96
- - Review trace for forced reflow warnings (see Performance Optimization Guidelines above)
97
- - If forced reflows found, refactor to use ResizeObserver/IntersectionObserver patterns
98
-
99
- 2. **Visual verification across light/dark themes:**
100
- - Navigate to story: `mcp__chrome-devtools__navigate_page` with `url: "http://localhost:6006/..."`
101
- - Take snapshot: `mcp__chrome-devtools__take_snapshot`
102
- - Verify interactive states render correctly in both themes
103
- - Check hover/active/focus states match styling guide patterns
104
-
105
- 3. **React warnings and console errors:**
106
- - After making component changes, use `mcp__chrome-devtools__list_console_messages`
107
- - Filter for errors/warnings to catch React lifecycle issues early
108
- - Particularly useful for detecting missing cleanup in custom hooks
109
-
110
- 4. **Performance regression detection:**
111
- - Record baseline trace before refactoring
112
- - Make changes, record new trace
113
- - Compare metrics (layout time, scripting time) to ensure improvements
114
- - Include before/after metrics in commit/PR
115
-
116
- **When to use:**
117
- - After implementing custom hooks with ResizeObserver/IntersectionObserver
118
- - When optimizing scroll/resize event handlers
119
- - Before committing performance-related changes
120
- - When testing components that depend on layout measurements
121
-
122
- **Note:** Chrome DevTools MCP tools are optional. Manual Chrome DevTools Performance tab profiling works fine without them. These tools primarily automate workflows you'd otherwise do manually.
49
+ See the [root AGENTS.md](../../AGENTS.md) for the tool decision table. Use Chrome DevTools MCP for performance profiling and visual verification. Storybook URL: `http://localhost:3200` (port varies by worktree index).
123
50
 
124
51
  ## Styling Guide
125
52
 
@@ -149,73 +76,29 @@ For any given color, add a dark mode class that mirrors it across the palette.
149
76
  Lower values (lighter colors) in light mode should map to higher values (darker
150
77
  colors) in dark mode, and vice versa.
151
78
 
152
- **Examples:**
153
-
154
- - `bg-neutral-100` pairs with `dark:bg-neutral-1200`
155
- - `bg-neutral-200` pairs with `dark:bg-neutral-1100`
156
- - `bg-neutral-1200` pairs with `dark:bg-neutral-100`
157
- - `text-neutral-1300` pairs with `dark:text-neutral-000`
158
- - `bg-orange-200` pairs with `dark:bg-orange-900` (orange has 11 values: 200 + 900 = 1100)
159
- - `bg-blue-300` pairs with `dark:bg-blue-700` (blue has 9 values: 300 + 700 = 1000)
160
-
161
- The sum of mirrored color numbers should equal the total palette range. Different
162
- palettes have different ranges, so calculate mirrors accordingly:
163
-
164
- - Neutral (000-1300): `light + dark = 1300`
165
- - Orange (100-1100): `light + dark = 1200`
166
- - Secondary colors (100-900): `light + dark = 1000`
167
-
168
- #### Hover States
169
-
170
- Use the **next color value** along the palette for hover states:
171
-
172
- - `bg-neutral-100` → `hover:bg-neutral-200`
173
- - `bg-neutral-200` → `hover:bg-neutral-300`
174
- - `bg-orange-600` → `hover:bg-orange-700`
79
+ The sum of mirrored color numbers should equal the total palette range:
175
80
 
176
- Apply this pattern to both light and dark mode classes:
81
+ - Neutral (000-1300): `light + dark = 1300` (e.g., `bg-neutral-200` / `dark:bg-neutral-1100`, `text-neutral-1300` / `dark:text-neutral-000`)
82
+ - Orange (100-1100): `light + dark = 1200` (e.g., `bg-orange-200` pairs with `dark:bg-orange-1000`)
83
+ - Secondary colors (100-900): `light + dark = 1000` (e.g., `bg-blue-300` pairs with `dark:bg-blue-700`)
177
84
 
178
- ```
179
- bg-neutral-200 hover:bg-neutral-300
180
- dark:bg-neutral-1100 dark:hover:bg-neutral-1000
181
- ```
182
-
183
- #### Active States
85
+ The mirroring formula applies to all property prefixes (`bg-`, `text-`, `border-`, etc.), not just backgrounds.
184
86
 
185
- Use **two color values** along the palette for active/pressed states:
87
+ #### Hover and Active States
186
88
 
187
- - `bg-neutral-100` → `active:bg-neutral-300`
188
- - `bg-neutral-200` → `active:bg-neutral-400`
189
- - `bg-orange-600` `active:bg-orange-800`
190
-
191
- Apply to both modes:
192
-
193
- ```
194
- bg-neutral-200 hover:bg-neutral-300 active:bg-neutral-400
195
- dark:bg-neutral-1100 dark:hover:bg-neutral-1000 dark:active:bg-neutral-900
196
- ```
197
-
198
- #### Focus Styles
199
-
200
- Add the `focus-base` class to all interactive elements (buttons, links, inputs,
201
- selects, etc.). This class is defined in `src/core/styles/utils.css` and provides
202
- consistent focus styling with an accessible outline:
203
-
204
- ```css
205
- .focus-base {
206
- @apply focus:outline-none focus-visible:outline-4 focus-visible:outline-offset-0 focus-visible:outline-gui-focus;
207
- }
208
- ```
89
+ - **Hover**: Use the next color value along the palette (e.g., `bg-neutral-200` → `hover:bg-neutral-300`)
90
+ - **Active**: Use two color values along (e.g., `bg-neutral-200` → `active:bg-neutral-400`)
91
+ - Apply the same stepping in dark mode, moving in the opposite direction (towards lower values)
92
+ - For Radix stateful components, apply the same hover/active stepping to state variants (e.g., `data-[state=checked]:bg-orange-600 data-[state=checked]:hover:bg-orange-700`)
209
93
 
210
- #### Transitions
94
+ #### Focus and Transitions
211
95
 
212
- Add `transition-colors` to interactive elements unless a higher-specificity
213
- `transition` class is already present (e.g., `transition-all`, `transition-transform`).
214
- This ensures smooth visual feedback for state changes.
96
+ - Add the `focus-base` class (defined in `src/core/styles/utils.css`) to all interactive elements for consistent accessible focus styling
97
+ - Add `transition-colors` to interactive elements unless a higher-specificity `transition` class is already present
215
98
 
216
99
  ### Complete Example
217
100
 
218
- Here's a complete button component demonstrating all patterns:
101
+ A button demonstrating all patterns — dark mode mirroring, hover/active stepping, focus, and transitions:
219
102
 
220
103
  ```tsx
221
104
  <button
@@ -231,32 +114,6 @@ Here's a complete button component demonstrating all patterns:
231
114
  </button>
232
115
  ```
233
116
 
234
- ### Additional Examples
235
-
236
- **Select dropdown:**
237
-
238
- ```tsx
239
- <Select.Trigger
240
- className="bg-neutral-200 hover:bg-neutral-300 active:bg-neutral-400 dark:bg-neutral-1100 dark:hover:bg-neutral-1000 dark:active:bg-neutral-900 focus-base transition-colors border border-neutral-300 dark:border-neutral-1000"
241
- >
242
- ```
243
-
244
- **Badge with orange:**
245
-
246
- ```tsx
247
- <span
248
- className="bg-orange-200 hover:bg-orange-300 active:bg-orange-400 dark:bg-orange-900 dark:hover:bg-orange-800 dark:active:bg-orange-700 focus-base transition-colors"
249
- >
250
- ```
251
-
252
- **Toggle/Switch:**
253
-
254
- ```tsx
255
- <Switch
256
- className="bg-neutral-600 hover:bg-neutral-700 active:bg-neutral-800 data-[state=checked]:bg-orange-600 data-[state=checked]:hover:bg-orange-700 data-[state=checked]:active:bg-orange-800 focus-base transition-colors"
257
- >
258
- ```
259
-
260
117
  ## Performance Optimization Guidelines
261
118
 
262
119
  ### When to Optimize
@@ -276,246 +133,32 @@ Common anti-patterns causing forced reflows:
276
133
 
277
134
  ### Observer API Patterns
278
135
 
279
- #### IntersectionObserver (Scroll Position Detection)
280
-
281
- Use for detecting when elements enter/exit viewport or cross specific boundaries.
282
-
283
- **Example:** Header theme changes based on which section is visible
284
-
285
- **Key patterns:**
286
-
287
- ```typescript
288
- const observerRef = useRef<IntersectionObserver | null>(null);
289
- const intersectingElementsRef = useRef<Map<string, IntersectionObserverEntry>>(new Map());
290
-
291
- useEffect(() => {
292
- const intersectingElements = intersectingElementsRef.current;
293
-
294
- observerRef.current = new IntersectionObserver(
295
- (entries) => {
296
- requestAnimationFrame(() => {
297
- // Update tracking map
298
- for (const entry of entries) {
299
- if (entry.isIntersecting) {
300
- intersectingElements.set(entry.target.id, entry);
301
- } else {
302
- intersectingElements.delete(entry.target.id);
303
- }
304
- }
305
-
306
- // Find best match from ALL intersecting elements
307
- // (observer only reports changes, not all intersecting)
308
- let bestMatch = null;
309
- for (const [id, entry] of intersectingElements) {
310
- const rect = entry.boundingClientRect ?? entry.target.getBoundingClientRect();
311
- // Calculate match quality...
312
- if (isBetterMatch) bestMatch = {...};
313
- }
314
-
315
- // Only update state if changed
316
- if (bestMatch && bestMatch.value !== previousValueRef.current) {
317
- previousValueRef.current = bestMatch.value;
318
- setState(bestMatch.value);
319
- }
320
- });
321
- },
322
- {
323
- rootMargin: "-64px 0px 0px 0px", // Adjust for fixed header
324
- threshold: 0,
325
- }
326
- );
327
-
328
- // Observe elements
329
- elements.forEach(el => observerRef.current?.observe(el));
330
-
331
- // CRITICAL: Manual initial state check
332
- // IntersectionObserver callbacks only fire on CHANGES, not initial observation
333
- const timeoutId = setTimeout(() => {
334
- // Check which elements currently intersect
335
- // Set initial state
336
- }, 0);
337
-
338
- return () => {
339
- clearTimeout(timeoutId);
340
- observerRef.current?.disconnect();
341
- observerRef.current = null;
342
- intersectingElements.clear();
343
- };
344
- }, [deps]);
345
- ```
346
-
347
- **Critical points:**
348
-
349
- - Observer only reports state CHANGES, not all intersecting elements
350
- - Use Map to track currently intersecting elements
351
- - Manual initial check with `setTimeout(..., 0)` required
352
- - Batch updates with `requestAnimationFrame()`
353
- - Track previous value to skip redundant setState
354
- - **Tiebreaker logic**: When multiple elements have equal distances, use array order (earlier in array wins)
355
- - Clean up timeout, observer, and Map
356
-
357
- **Tiebreaker pattern:**
358
-
359
- ```typescript
360
- // When distances are equal, use scrollpoints array order
361
- if (
362
- !bestMatch ||
363
- distance < bestMatch.distance ||
364
- (distance === bestMatch.distance && scrollpointIndex < bestMatch.index)
365
- ) {
366
- bestMatch = { scrollpoint, distance, index: scrollpointIndex };
367
- }
368
- ```
369
-
370
- **Why this matters:** In Voltaire, both `meganav` (transparent) and `main-theme-dark` (with border) start at position 0, giving identical distances. Without a tiebreaker, the header unpredictably showed the border. Array order ensures `meganav` (listed first) always wins.
371
-
372
- #### ResizeObserver (Height/Size Tracking)
373
-
374
- Use for tracking element dimensions without synchronous layout reads.
375
-
376
- **Example:** Expander content height for expand/collapse animations
377
-
378
- **Key patterns:**
379
-
380
- ```typescript
381
- const rafIdRef = useRef<number | null>(null);
382
- const observerRef = useRef<ResizeObserver | null>(null);
383
-
384
- useEffect(() => {
385
- let isMounted = true;
386
-
387
- observerRef.current = new ResizeObserver((entries) => {
388
- // Cancel any pending RAF to avoid stale updates
389
- if (rafIdRef.current !== null) {
390
- cancelAnimationFrame(rafIdRef.current);
391
- }
392
-
393
- rafIdRef.current = requestAnimationFrame(() => {
394
- rafIdRef.current = null;
395
-
396
- // Guard against updates after unmount
397
- if (!isMounted) return;
398
-
399
- const entry = entries[0];
400
- if (entry && entry.contentRect) {
401
- const newHeight = Math.round(entry.contentRect.height);
402
- setState(newHeight);
403
- }
404
- });
405
- });
406
-
407
- observerRef.current.observe(element);
408
-
409
- return () => {
410
- isMounted = false;
411
- // Cancel pending RAF to prevent setState after unmount
412
- if (rafIdRef.current !== null) {
413
- cancelAnimationFrame(rafIdRef.current);
414
- rafIdRef.current = null;
415
- }
416
- observerRef.current?.disconnect();
417
- observerRef.current = null;
418
- };
419
- }, [ref]);
420
- ```
136
+ Prefer `IntersectionObserver` and `ResizeObserver` over synchronous layout reads.
421
137
 
422
- **Critical points:**
138
+ **IntersectionObserver** (scroll position detection, viewport triggers):
423
139
 
424
- - Always capture RAF ID and cancel on cleanup
425
- - Use `isMounted` flag to guard setState calls
426
- - Cancel pending RAF before scheduling new one
427
- - ResizeObserver doesn't need initial check (fires immediately on observe)
428
- - Round numeric values for consistency
140
+ - Observer only reports state _changes_, not all currently intersecting elements — maintain a `Map` to track the full set of intersecting elements
141
+ - Batch state updates inside `requestAnimationFrame()`
142
+ - Perform a manual initial state check with `setTimeout(..., 0)` since callbacks don't fire on first `observe()`
143
+ - Track the previous value and skip redundant `setState` calls
144
+ - When multiple elements have equal distances, use array order as a tiebreaker (earlier wins) — this prevents nondeterministic behaviour when elements share the same position (e.g., Voltaire's meganav and main theme at position 0)
145
+ - Clean up: disconnect observer, clear the Map, cancel any pending timeout
429
146
 
430
- ### Testing Async Hooks
147
+ **ResizeObserver** (element dimension tracking):
431
148
 
432
- #### Setup Pattern
149
+ - Store the `requestAnimationFrame` ID and cancel it on cleanup to prevent stale updates
150
+ - Use an `isMounted` flag to guard `setState` calls after unmount
151
+ - Cancel any pending RAF before scheduling a new one
152
+ - Unlike IntersectionObserver, ResizeObserver fires immediately on `observe()` — no manual initial check needed
433
153
 
434
- ```typescript
435
- describe("useMyHook", () => {
436
- let originalIntersectionObserver: typeof IntersectionObserver;
437
- let originalRequestAnimationFrame: typeof requestAnimationFrame;
154
+ ### Testing Observer Hooks
438
155
 
439
- beforeEach(() => {
440
- vi.useFakeTimers();
156
+ See `src/core/hooks/use-themed-scrollpoints.test.ts` for a reference implementation of these patterns:
441
157
 
442
- // CRITICAL: Save originals BEFORE mocking
443
- originalIntersectionObserver = global.IntersectionObserver;
444
- originalRequestAnimationFrame = global.requestAnimationFrame;
445
-
446
- // Mock global APIs
447
- global.IntersectionObserver = vi.fn((callback) => ({
448
- observe: vi.fn(),
449
- disconnect: vi.fn(),
450
- })) as unknown as typeof IntersectionObserver;
451
-
452
- global.requestAnimationFrame = vi.fn((cb) => {
453
- cb(0);
454
- return 0;
455
- });
456
- });
457
-
458
- afterEach(() => {
459
- vi.clearAllMocks();
460
- vi.useRealTimers();
461
- document.body.innerHTML = "";
462
-
463
- // CRITICAL: Restore originals to prevent test pollution
464
- global.IntersectionObserver = originalIntersectionObserver;
465
- global.requestAnimationFrame = originalRequestAnimationFrame;
466
- });
467
- });
468
- ```
469
-
470
- #### Testing Observer Callbacks
471
-
472
- ```typescript
473
- it("updates state when observer fires", () => {
474
- const elem = document.createElement("div");
475
- elem.id = "test";
476
- elem.getBoundingClientRect = vi.fn().mockReturnValue({ top: 0, bottom: 200 });
477
- document.body.appendChild(elem);
478
-
479
- const { result } = renderHook(() => useMyHook());
480
-
481
- // Advance timers for initial check
482
- act(() => {
483
- vi.runAllTimers();
484
- });
485
-
486
- // Simulate observer callback
487
- act(() => {
488
- observerCallback(
489
- [
490
- {
491
- target: elem,
492
- isIntersecting: true,
493
- boundingClientRect: {
494
- top: 0,
495
- bottom: 200,
496
- left: 0,
497
- right: 0,
498
- x: 0,
499
- y: 0,
500
- width: 0,
501
- height: 200,
502
- },
503
- } as unknown as IntersectionObserverEntry,
504
- ],
505
- {} as IntersectionObserver,
506
- );
507
- });
508
-
509
- expect(result.current).toBe("expected-value");
510
- });
511
- ```
512
-
513
- **Key points:**
514
-
515
- - Mock `getBoundingClientRect` on test elements
516
- - Provide `boundingClientRect` in IntersectionObserverEntry mocks
517
- - Wrap timer advances and callback calls in `act()`
518
- - Test both initial state and subsequent updates
158
+ - Save original globals (`IntersectionObserver`, `requestAnimationFrame`) _before_ mocking and restore them in `afterEach` to prevent test pollution
159
+ - Mock `getBoundingClientRect` on test elements and provide `boundingClientRect` in `IntersectionObserverEntry` mocks
160
+ - Wrap timer advances and observer callback invocations in `act()`
161
+ - Test both initial state and subsequent observer-driven updates
519
162
 
520
163
  ### Common Pitfalls Checklist
521
164
 
@@ -530,38 +173,18 @@ When writing performance-optimized hooks:
530
173
  - [ ] Console warnings: For missing DOM elements (not errors)
531
174
  - [ ] Tiebreaker logic: When multiple candidates have equal scores
532
175
 
533
- ## Storybook Development
176
+ ## Knowledge Base
534
177
 
535
- ### Testing Interactive Components
178
+ `knowledge/` contains patterns, conventions, and gotchas learned from past work (one file per pattern). Consult these when working in this package.
536
179
 
537
- - Use `http://localhost:6006` when developing/testing components
538
- - Create stories that simulate production patterns (e.g., overlapping scrollpoints like Voltaire)
539
- - Test edge cases in stories (empty arrays, missing DOM elements, rapid state changes)
540
-
541
- ### Performance Testing in Storybook
542
-
543
- 1. Open Chrome DevTools → Performance tab
544
- 2. Start recording while interacting with component
545
- 3. Search for forced reflow indicators:
546
- - `getBoundingClientRect`
547
- - `clientHeight`/`scrollHeight`/`offsetHeight`
548
- - "Forced reflow" warnings
549
- 4. Measure total time in layout/reflow (should be <5ms for interactions)
550
-
551
- ### Simulating Production Patterns
552
-
553
- When creating stories for layout-dependent components, replicate real-world scenarios:
554
-
555
- Example - Overlapping scrollpoints (like Voltaire):
556
-
557
- ```tsx
558
- <div className="relative">
559
- <div id="hero" className="absolute top-0 h-32" />
560
- <div id="main" className="relative pt-32 h-screen" />
561
- </div>
562
- ```
180
+ ## Storybook Development
563
181
 
564
- This allows testing tiebreaker logic and initial state detection. Storybook stories should replicate production layout patterns to catch bugs like the tiebreaker issue. The original simple sequential zones didn't expose the Voltaire bug where elements start at the same position.
182
+ - Use `http://localhost:3200` when developing/testing components
183
+ - Create stories that simulate production patterns (e.g., overlapping scrollpoints like Voltaire) — simple sequential layouts may not expose bugs like the tiebreaker issue where elements share the same position
184
+ - Test edge cases in stories (empty arrays, missing DOM elements, rapid state changes)
185
+ - **Performance testing:** Open Chrome DevTools Performance tab, record while interacting with the component, then check for:
186
+ - Forced reflow indicators: `getBoundingClientRect`, `clientHeight`/`scrollHeight`/`offsetHeight`, "Forced reflow" warnings
187
+ - Target: <5ms layout/reflow time during interactions
565
188
 
566
189
  ## Git workflow
567
190
 
package/README.md CHANGED
@@ -216,7 +216,7 @@ The easiest way to get started is to use the development script:
216
216
 
217
217
  This script will:
218
218
 
219
- - Check that all required tools (Node.js, pnpm) are installed at the correct versions via asdf
219
+ - Check that all required tools (Node.js, pnpm) are installed at the correct versions via mise or asdf
220
220
  - Install all project dependencies
221
221
  - Start Storybook automatically
222
222
 
package/core/Accordion.js CHANGED
@@ -1,2 +1,2 @@
1
- import React,{useMemo,useState,forwardRef,useEffect}from"react";import{AccordionContent,AccordionItem,AccordionTrigger,Accordion as RadixAccordion}from"@radix-ui/react-accordion";import Icon from"./Icon";import{themeClasses,isNonTransparentTheme,isStaticTheme}from"./Accordion/utils";import cn from"./utils/cn";const AccordionRow=({name,heading,children,rowIcon,options,toggleIcons,theme,index,onClick,openRowValues,rowInteractive=true})=>{const{selectable,sticky}=options||{};const rowKey=`accordion-item-${index}`;const isOpen=openRowValues.includes(rowKey);const{text,bg,hoverBg,selectableBg,selectableText,border,toggleIconColor}=themeClasses[theme];const textClass=selectable&&isOpen&&selectableText||text;const renderHeading=()=>{if(heading){if(typeof heading==="function"){return heading(index,isOpen)}return heading}return React.createElement("span",null,name)};return React.createElement(AccordionItem,{value:rowKey,className:cn({[`${border}`]:border&&!options?.hideBorders,[`${options?.selectedItemCSS}`]:options?.selectedItemCSS&&isOpen})},React.createElement(AccordionTrigger,{onClick:onClick,className:cn({"flex w-full group/accordion-trigger py-4 ui-text-p1 font-bold text-left items-center gap-3 transition-colors focus:outline-none":true,"px-4 mb-4 rounded-lg":isNonTransparentTheme(theme),"px-0 rounded-none":!isNonTransparentTheme(theme),"pointer-events-none focus-visible:outline-none":isStaticTheme(theme),"focus-base":!isStaticTheme(theme),"sticky top-0":sticky,[`${bg} ${hoverBg} ${text}`]:!(selectable&&isOpen),[`${selectableBg} ${selectableText}`]:selectable&&isOpen,[options?.headerCSS??""]:options?.headerCSS,[options?.selectedHeaderCSS??""]:options?.selectedHeaderCSS&&isOpen})},rowIcon?React.createElement(Icon,{name:typeof rowIcon==="object"?rowIcon.name:rowIcon,color:textClass,additionalCSS:typeof rowIcon==="object"&&rowIcon.css?rowIcon.css:"",size:options?.rowIconSize??"32px"}):null,renderHeading(),!selectable&&!isStaticTheme(theme)&&rowInteractive?React.createElement("span",{className:"flex-1 justify-end flex items-center"},React.createElement(Icon,{name:isOpen?toggleIcons.open.name:toggleIcons.closed.name,color:toggleIconColor,additionalCSS:isOpen?typeof toggleIcons.open==="object"&&toggleIcons.open.css||"":typeof toggleIcons.closed==="object"&&toggleIcons.closed.css||"",size:options?.iconSize??"16px"})):null),rowInteractive&&React.createElement(AccordionContent,{className:cn({"ui-text-p2 overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down":true,[options?.contentCSS??""]:options?.contentCSS})},React.createElement("div",{className:"pb-4"},children)))};const Accordion=forwardRef(({data,theme="transparent",icons={closed:{name:"icon-gui-plus-outline"},open:{name:"icon-gui-minus-outline"}},options,...props},ref)=>{const openIndexes=useMemo(()=>{const indexValues=data.map((_,i)=>`accordion-item-${i}`);return options?.fullyOpen?indexValues:indexValues.filter((_,index)=>options?.defaultOpenIndexes?.includes(index))},[data,options?.fullyOpen,options?.defaultOpenIndexes]);const[openRowValues,setOpenRowValues]=useState(openIndexes);useEffect(()=>{setOpenRowValues(openIndexes)},[openIndexes]);const innerAccordion=data.map((item,index)=>React.createElement(AccordionRow,{key:item.name,name:item.name,heading:item.heading,rowIcon:item.icon,toggleIcons:icons,theme:theme,options:options,index:index,onClick:()=>{item.onClick?.(index)},openRowValues:openRowValues,rowInteractive:item.interactive},item.content));return React.createElement("div",{ref:ref,...props},options?.autoClose?React.createElement(RadixAccordion,{type:"single",collapsible:true,value:openRowValues[0],onValueChange:values=>setOpenRowValues([values])},innerAccordion):React.createElement(RadixAccordion,{type:"multiple",value:openRowValues,onValueChange:values=>setOpenRowValues(values)},innerAccordion))});Accordion.displayName="Accordion";export default Accordion;
1
+ import React,{useMemo,useState,forwardRef}from"react";import{AccordionContent,AccordionItem,AccordionTrigger,Accordion as RadixAccordion}from"@radix-ui/react-accordion";import Icon from"./Icon";import{themeClasses,isNonTransparentTheme,isStaticTheme}from"./Accordion/utils";import cn from"./utils/cn";const AccordionRow=({name,heading,children,rowIcon,options,toggleIcons,theme,index,onClick,openRowValues,rowInteractive=true})=>{const{selectable,sticky}=options||{};const rowKey=`accordion-item-${index}`;const isOpen=openRowValues.includes(rowKey);const{text,bg,hoverBg,selectableBg,selectableText,border,toggleIconColor}=themeClasses[theme];const textClass=selectable&&isOpen&&selectableText||text;const renderHeading=()=>{if(heading){if(typeof heading==="function"){return heading(index,isOpen)}return heading}return React.createElement("span",null,name)};return React.createElement(AccordionItem,{value:rowKey,className:cn({[`${border}`]:border&&!options?.hideBorders,[`${options?.selectedItemCSS}`]:options?.selectedItemCSS&&isOpen})},React.createElement(AccordionTrigger,{onClick:onClick,className:cn({"flex w-full group/accordion-trigger py-4 ui-text-p1 font-bold text-left items-center gap-3 transition-colors focus:outline-none":true,"px-4 mb-4 rounded-lg":isNonTransparentTheme(theme),"px-0 rounded-none":!isNonTransparentTheme(theme),"pointer-events-none focus-visible:outline-none":isStaticTheme(theme),"focus-base":!isStaticTheme(theme),"sticky top-0":sticky,[`${bg} ${hoverBg} ${text}`]:!(selectable&&isOpen),[`${selectableBg} ${selectableText}`]:selectable&&isOpen,[options?.headerCSS??""]:options?.headerCSS,[options?.selectedHeaderCSS??""]:options?.selectedHeaderCSS&&isOpen})},rowIcon?React.createElement(Icon,{name:typeof rowIcon==="object"?rowIcon.name:rowIcon,color:textClass,additionalCSS:typeof rowIcon==="object"&&rowIcon.css?rowIcon.css:"",size:options?.rowIconSize??"32px"}):null,renderHeading(),!selectable&&!isStaticTheme(theme)&&rowInteractive?React.createElement("span",{className:"flex-1 justify-end flex items-center"},React.createElement(Icon,{name:isOpen?toggleIcons.open.name:toggleIcons.closed.name,color:toggleIconColor,additionalCSS:isOpen?typeof toggleIcons.open==="object"&&toggleIcons.open.css||"":typeof toggleIcons.closed==="object"&&toggleIcons.closed.css||"",size:options?.iconSize??"16px"})):null),rowInteractive&&React.createElement(AccordionContent,{className:cn({"ui-text-p2 overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down":true,[options?.contentCSS??""]:options?.contentCSS})},React.createElement("div",{className:"pb-4"},children)))};const Accordion=forwardRef(({data,theme="transparent",icons={closed:{name:"icon-gui-plus-outline"},open:{name:"icon-gui-minus-outline"}},options,...props},ref)=>{const openIndexes=useMemo(()=>{const indexValues=data.map((_,i)=>`accordion-item-${i}`);return options?.fullyOpen?indexValues:indexValues.filter((_,index)=>options?.defaultOpenIndexes?.includes(index))},[data,options?.fullyOpen,options?.defaultOpenIndexes]);const[openRowValues,setOpenRowValues]=useState(openIndexes);const[prevOpenIndexes,setPrevOpenIndexes]=useState(openIndexes);if(prevOpenIndexes!==openIndexes){setPrevOpenIndexes(openIndexes);setOpenRowValues(openIndexes)}const innerAccordion=data.map((item,index)=>React.createElement(AccordionRow,{key:item.name,name:item.name,heading:item.heading,rowIcon:item.icon,toggleIcons:icons,theme:theme,options:options,index:index,onClick:()=>{item.onClick?.(index)},openRowValues:openRowValues,rowInteractive:item.interactive},item.content));return React.createElement("div",{ref:ref,...props},options?.autoClose?React.createElement(RadixAccordion,{type:"single",collapsible:true,value:openRowValues[0],onValueChange:values=>setOpenRowValues([values])},innerAccordion):React.createElement(RadixAccordion,{type:"multiple",value:openRowValues,onValueChange:values=>setOpenRowValues(values)},innerAccordion))});Accordion.displayName="Accordion";export default Accordion;
2
2
  //# sourceMappingURL=Accordion.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/Accordion.tsx"],"sourcesContent":["import React, {\n ReactNode,\n useMemo,\n useState,\n forwardRef,\n useEffect,\n} from \"react\";\nimport {\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n Accordion as RadixAccordion,\n} from \"@radix-ui/react-accordion\";\n\nimport Icon from \"./Icon\";\nimport type { IconName } from \"./Icon/types\";\nimport type {\n AccordionData,\n AccordionIcon,\n AccordionIcons,\n AccordionOptions,\n AccordionTheme,\n} from \"./Accordion/types\";\nimport {\n themeClasses,\n isNonTransparentTheme,\n isStaticTheme,\n} from \"./Accordion/utils\";\nimport cn from \"./utils/cn\";\n\ntype AccordionRowProps = {\n children: ReactNode;\n name: string;\n heading?: ReactNode | ((index: number, isOpen: boolean) => ReactNode);\n rowIcon?: IconName | AccordionIcon;\n theme: AccordionTheme;\n toggleIcons: AccordionIcons;\n options?: AccordionOptions;\n index: number;\n onClick: () => void;\n openRowValues: string[];\n rowInteractive?: boolean;\n};\n\nexport type AccordionProps = {\n /**\n * The data for the accordion items.\n */\n data: AccordionData[];\n\n /**\n * Icons for the accordion toggle.\n */\n icons?: AccordionIcons;\n\n /**\n * Theme for the accordion.\n */\n theme?: AccordionTheme;\n\n /**\n * Options for the accordion behavior.\n */\n options?: AccordionOptions;\n} & React.HTMLAttributes<HTMLDivElement>;\n\nconst AccordionRow = ({\n name,\n heading,\n children,\n rowIcon,\n options,\n toggleIcons,\n theme,\n index,\n onClick,\n openRowValues,\n rowInteractive = true,\n}: AccordionRowProps) => {\n const { selectable, sticky } = options || {};\n const rowKey = `accordion-item-${index}`;\n const isOpen = openRowValues.includes(rowKey);\n\n const {\n text,\n bg,\n hoverBg,\n selectableBg,\n selectableText,\n border,\n toggleIconColor,\n } = themeClasses[theme];\n\n const textClass = (selectable && isOpen && selectableText) || text;\n\n // Render custom heading or fallback to name\n const renderHeading = () => {\n if (heading) {\n if (typeof heading === \"function\") {\n return heading(index, isOpen);\n }\n return heading;\n }\n return <span>{name}</span>;\n };\n\n return (\n <AccordionItem\n value={rowKey}\n className={cn({\n [`${border}`]: border && !options?.hideBorders,\n [`${options?.selectedItemCSS}`]: options?.selectedItemCSS && isOpen,\n })}\n >\n <AccordionTrigger\n onClick={onClick}\n className={cn({\n \"flex w-full group/accordion-trigger py-4 ui-text-p1 font-bold text-left items-center gap-3 transition-colors focus:outline-none\": true,\n \"px-4 mb-4 rounded-lg\": isNonTransparentTheme(theme),\n \"px-0 rounded-none\": !isNonTransparentTheme(theme),\n \"pointer-events-none focus-visible:outline-none\":\n isStaticTheme(theme),\n \"focus-base\": !isStaticTheme(theme),\n \"sticky top-0\": sticky,\n [`${bg} ${hoverBg} ${text}`]: !(selectable && isOpen),\n [`${selectableBg} ${selectableText}`]: selectable && isOpen,\n [options?.headerCSS ?? \"\"]: options?.headerCSS,\n [options?.selectedHeaderCSS ?? \"\"]:\n options?.selectedHeaderCSS && isOpen,\n })}\n >\n {rowIcon ? (\n <Icon\n name={typeof rowIcon === \"object\" ? rowIcon.name : rowIcon}\n color={textClass}\n additionalCSS={\n typeof rowIcon === \"object\" && rowIcon.css ? rowIcon.css : \"\"\n }\n size={options?.rowIconSize ?? \"32px\"}\n />\n ) : null}\n {renderHeading()}\n {!selectable && !isStaticTheme(theme) && rowInteractive ? (\n <span className=\"flex-1 justify-end flex items-center\">\n <Icon\n name={isOpen ? toggleIcons.open.name : toggleIcons.closed.name}\n color={toggleIconColor}\n additionalCSS={\n isOpen\n ? (typeof toggleIcons.open === \"object\" &&\n toggleIcons.open.css) ||\n \"\"\n : (typeof toggleIcons.closed === \"object\" &&\n toggleIcons.closed.css) ||\n \"\"\n }\n size={options?.iconSize ?? \"16px\"}\n />\n </span>\n ) : null}\n </AccordionTrigger>\n {rowInteractive && (\n <AccordionContent\n className={cn({\n \"ui-text-p2 overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\": true,\n [options?.contentCSS ?? \"\"]: options?.contentCSS,\n })}\n >\n <div className=\"pb-4\">{children}</div>\n </AccordionContent>\n )}\n </AccordionItem>\n );\n};\n\nconst Accordion = forwardRef<HTMLDivElement, AccordionProps>(\n (\n {\n data,\n theme = \"transparent\",\n icons = {\n closed: { name: \"icon-gui-plus-outline\" },\n open: { name: \"icon-gui-minus-outline\" },\n },\n options,\n ...props\n },\n ref,\n ) => {\n const openIndexes = useMemo(() => {\n const indexValues = data.map((_, i) => `accordion-item-${i}`);\n return options?.fullyOpen\n ? indexValues\n : indexValues.filter((_, index) =>\n options?.defaultOpenIndexes?.includes(index),\n );\n }, [data, options?.fullyOpen, options?.defaultOpenIndexes]);\n\n const [openRowValues, setOpenRowValues] = useState<string[]>(openIndexes);\n\n useEffect(() => {\n setOpenRowValues(openIndexes);\n }, [openIndexes]);\n\n const innerAccordion = data.map((item, index) => (\n <AccordionRow\n key={item.name}\n name={item.name}\n heading={item.heading}\n rowIcon={item.icon}\n toggleIcons={icons}\n theme={theme}\n options={options}\n index={index}\n onClick={() => {\n item.onClick?.(index);\n }}\n openRowValues={openRowValues}\n rowInteractive={item.interactive}\n >\n {item.content}\n </AccordionRow>\n ));\n\n return (\n <div ref={ref} {...props}>\n {options?.autoClose ? (\n <RadixAccordion\n type=\"single\"\n collapsible\n value={openRowValues[0]}\n onValueChange={(values) => setOpenRowValues([values])}\n >\n {innerAccordion}\n </RadixAccordion>\n ) : (\n <RadixAccordion\n type=\"multiple\"\n value={openRowValues}\n onValueChange={(values) => setOpenRowValues(values)}\n >\n {innerAccordion}\n </RadixAccordion>\n )}\n </div>\n );\n },\n);\n\nAccordion.displayName = \"Accordion\";\n\nexport default Accordion;\n"],"names":["React","useMemo","useState","forwardRef","useEffect","AccordionContent","AccordionItem","AccordionTrigger","Accordion","RadixAccordion","Icon","themeClasses","isNonTransparentTheme","isStaticTheme","cn","AccordionRow","name","heading","children","rowIcon","options","toggleIcons","theme","index","onClick","openRowValues","rowInteractive","selectable","sticky","rowKey","isOpen","includes","text","bg","hoverBg","selectableBg","selectableText","border","toggleIconColor","textClass","renderHeading","span","value","className","hideBorders","selectedItemCSS","headerCSS","selectedHeaderCSS","color","additionalCSS","css","size","rowIconSize","open","closed","iconSize","contentCSS","div","data","icons","props","ref","openIndexes","indexValues","map","_","i","fullyOpen","filter","defaultOpenIndexes","setOpenRowValues","innerAccordion","item","key","icon","interactive","content","autoClose","type","collapsible","onValueChange","values","displayName"],"mappings":"AAAA,OAAOA,OAELC,OAAO,CACPC,QAAQ,CACRC,UAAU,CACVC,SAAS,KACJ,OAAQ,AACf,QACEC,gBAAgB,CAChBC,aAAa,CACbC,gBAAgB,CAChBC,aAAaC,cAAc,KACtB,2BAA4B,AAEnC,QAAOC,SAAU,QAAS,AAS1B,QACEC,YAAY,CACZC,qBAAqB,CACrBC,aAAa,KACR,mBAAoB,AAC3B,QAAOC,OAAQ,YAAa,CAsC5B,MAAMC,aAAe,CAAC,CACpBC,IAAI,CACJC,OAAO,CACPC,QAAQ,CACRC,OAAO,CACPC,OAAO,CACPC,WAAW,CACXC,KAAK,CACLC,KAAK,CACLC,OAAO,CACPC,aAAa,CACbC,eAAiB,IAAI,CACH,IAClB,KAAM,CAAEC,UAAU,CAAEC,MAAM,CAAE,CAAGR,SAAW,CAAC,EAC3C,MAAMS,OAAS,CAAC,eAAe,EAAEN,MAAM,CAAC,CACxC,MAAMO,OAASL,cAAcM,QAAQ,CAACF,QAEtC,KAAM,CACJG,IAAI,CACJC,EAAE,CACFC,OAAO,CACPC,YAAY,CACZC,cAAc,CACdC,MAAM,CACNC,eAAe,CAChB,CAAG3B,YAAY,CAACW,MAAM,CAEvB,MAAMiB,UAAY,AAACZ,YAAcG,QAAUM,gBAAmBJ,KAG9D,MAAMQ,cAAgB,KACpB,GAAIvB,QAAS,CACX,GAAI,OAAOA,UAAY,WAAY,CACjC,OAAOA,QAAQM,MAAOO,OACxB,CACA,OAAOb,OACT,CACA,OAAO,oBAACwB,YAAMzB,KAChB,EAEA,OACE,oBAACV,eACCoC,MAAOb,OACPc,UAAW7B,GAAG,CACZ,CAAC,CAAC,EAAEuB,OAAO,CAAC,CAAC,CAAEA,QAAU,CAACjB,SAASwB,YACnC,CAAC,CAAC,EAAExB,SAASyB,gBAAgB,CAAC,CAAC,CAAEzB,SAASyB,iBAAmBf,MAC/D,IAEA,oBAACvB,kBACCiB,QAASA,QACTmB,UAAW7B,GAAG,CACZ,kIAAmI,KACnI,uBAAwBF,sBAAsBU,OAC9C,oBAAqB,CAACV,sBAAsBU,OAC5C,iDACET,cAAcS,OAChB,aAAc,CAACT,cAAcS,OAC7B,eAAgBM,OAChB,CAAC,CAAC,EAAEK,GAAG,CAAC,EAAEC,QAAQ,CAAC,EAAEF,KAAK,CAAC,CAAC,CAAE,CAAEL,CAAAA,YAAcG,MAAK,EACnD,CAAC,CAAC,EAAEK,aAAa,CAAC,EAAEC,eAAe,CAAC,CAAC,CAAET,YAAcG,OACrD,CAACV,SAAS0B,WAAa,GAAG,CAAE1B,SAAS0B,UACrC,CAAC1B,SAAS2B,mBAAqB,GAAG,CAChC3B,SAAS2B,mBAAqBjB,MAClC,IAECX,QACC,oBAACT,MACCM,KAAM,OAAOG,UAAY,SAAWA,QAAQH,IAAI,CAAGG,QACnD6B,MAAOT,UACPU,cACE,OAAO9B,UAAY,UAAYA,QAAQ+B,GAAG,CAAG/B,QAAQ+B,GAAG,CAAG,GAE7DC,KAAM/B,SAASgC,aAAe,SAE9B,KACHZ,gBACA,CAACb,YAAc,CAACd,cAAcS,QAAUI,eACvC,oBAACe,QAAKE,UAAU,wCACd,oBAACjC,MACCM,KAAMc,OAAST,YAAYgC,IAAI,CAACrC,IAAI,CAAGK,YAAYiC,MAAM,CAACtC,IAAI,CAC9DgC,MAAOV,gBACPW,cACEnB,OACI,AAAC,OAAOT,YAAYgC,IAAI,GAAK,UAC3BhC,YAAYgC,IAAI,CAACH,GAAG,EACtB,GACA,AAAC,OAAO7B,YAAYiC,MAAM,GAAK,UAC7BjC,YAAYiC,MAAM,CAACJ,GAAG,EACxB,GAENC,KAAM/B,SAASmC,UAAY,UAG7B,MAEL7B,gBACC,oBAACrB,kBACCsC,UAAW7B,GAAG,CACZ,8HAA+H,KAC/H,CAACM,SAASoC,YAAc,GAAG,CAAEpC,SAASoC,UACxC,IAEA,oBAACC,OAAId,UAAU,QAAQzB,WAKjC,EAEA,MAAMV,UAAYL,WAChB,CACE,CACEuD,IAAI,CACJpC,MAAQ,aAAa,CACrBqC,MAAQ,CACNL,OAAQ,CAAEtC,KAAM,uBAAwB,EACxCqC,KAAM,CAAErC,KAAM,wBAAyB,CACzC,CAAC,CACDI,OAAO,CACP,GAAGwC,MACJ,CACDC,OAEA,MAAMC,YAAc7D,QAAQ,KAC1B,MAAM8D,YAAcL,KAAKM,GAAG,CAAC,CAACC,EAAGC,IAAM,CAAC,eAAe,EAAEA,EAAE,CAAC,EAC5D,OAAO9C,SAAS+C,UACZJ,YACAA,YAAYK,MAAM,CAAC,CAACH,EAAG1C,QACrBH,SAASiD,oBAAoBtC,SAASR,OAE9C,EAAG,CAACmC,KAAMtC,SAAS+C,UAAW/C,SAASiD,mBAAmB,EAE1D,KAAM,CAAC5C,cAAe6C,iBAAiB,CAAGpE,SAAmB4D,aAE7D1D,UAAU,KACRkE,iBAAiBR,YACnB,EAAG,CAACA,YAAY,EAEhB,MAAMS,eAAiBb,KAAKM,GAAG,CAAC,CAACQ,KAAMjD,QACrC,oBAACR,cACC0D,IAAKD,KAAKxD,IAAI,CACdA,KAAMwD,KAAKxD,IAAI,CACfC,QAASuD,KAAKvD,OAAO,CACrBE,QAASqD,KAAKE,IAAI,CAClBrD,YAAasC,MACbrC,MAAOA,MACPF,QAASA,QACTG,MAAOA,MACPC,QAAS,KACPgD,KAAKhD,OAAO,GAAGD,MACjB,EACAE,cAAeA,cACfC,eAAgB8C,KAAKG,WAAW,EAE/BH,KAAKI,OAAO,GAIjB,OACE,oBAACnB,OAAII,IAAKA,IAAM,GAAGD,KAAK,EACrBxC,SAASyD,UACR,oBAACpE,gBACCqE,KAAK,SACLC,YAAAA,KACArC,MAAOjB,aAAa,CAAC,EAAE,CACvBuD,cAAe,AAACC,QAAWX,iBAAiB,CAACW,OAAO,GAEnDV,gBAGH,oBAAC9D,gBACCqE,KAAK,WACLpC,MAAOjB,cACPuD,cAAe,AAACC,QAAWX,iBAAiBW,SAE3CV,gBAKX,EAGF/D,CAAAA,UAAU0E,WAAW,CAAG,WAExB,gBAAe1E,SAAU"}
1
+ {"version":3,"sources":["../../src/core/Accordion.tsx"],"sourcesContent":["import React, { ReactNode, useMemo, useState, forwardRef } from \"react\";\nimport {\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n Accordion as RadixAccordion,\n} from \"@radix-ui/react-accordion\";\n\nimport Icon from \"./Icon\";\nimport type { IconName } from \"./Icon/types\";\nimport type {\n AccordionData,\n AccordionIcon,\n AccordionIcons,\n AccordionOptions,\n AccordionTheme,\n} from \"./Accordion/types\";\nimport {\n themeClasses,\n isNonTransparentTheme,\n isStaticTheme,\n} from \"./Accordion/utils\";\nimport cn from \"./utils/cn\";\n\ntype AccordionRowProps = {\n children: ReactNode;\n name: string;\n heading?: ReactNode | ((index: number, isOpen: boolean) => ReactNode);\n rowIcon?: IconName | AccordionIcon;\n theme: AccordionTheme;\n toggleIcons: AccordionIcons;\n options?: AccordionOptions;\n index: number;\n onClick: () => void;\n openRowValues: string[];\n rowInteractive?: boolean;\n};\n\nexport type AccordionProps = {\n /**\n * The data for the accordion items.\n */\n data: AccordionData[];\n\n /**\n * Icons for the accordion toggle.\n */\n icons?: AccordionIcons;\n\n /**\n * Theme for the accordion.\n */\n theme?: AccordionTheme;\n\n /**\n * Options for the accordion behavior.\n */\n options?: AccordionOptions;\n} & React.HTMLAttributes<HTMLDivElement>;\n\nconst AccordionRow = ({\n name,\n heading,\n children,\n rowIcon,\n options,\n toggleIcons,\n theme,\n index,\n onClick,\n openRowValues,\n rowInteractive = true,\n}: AccordionRowProps) => {\n const { selectable, sticky } = options || {};\n const rowKey = `accordion-item-${index}`;\n const isOpen = openRowValues.includes(rowKey);\n\n const {\n text,\n bg,\n hoverBg,\n selectableBg,\n selectableText,\n border,\n toggleIconColor,\n } = themeClasses[theme];\n\n const textClass = (selectable && isOpen && selectableText) || text;\n\n // Render custom heading or fallback to name\n const renderHeading = () => {\n if (heading) {\n if (typeof heading === \"function\") {\n return heading(index, isOpen);\n }\n return heading;\n }\n return <span>{name}</span>;\n };\n\n return (\n <AccordionItem\n value={rowKey}\n className={cn({\n [`${border}`]: border && !options?.hideBorders,\n [`${options?.selectedItemCSS}`]: options?.selectedItemCSS && isOpen,\n })}\n >\n <AccordionTrigger\n onClick={onClick}\n className={cn({\n \"flex w-full group/accordion-trigger py-4 ui-text-p1 font-bold text-left items-center gap-3 transition-colors focus:outline-none\": true,\n \"px-4 mb-4 rounded-lg\": isNonTransparentTheme(theme),\n \"px-0 rounded-none\": !isNonTransparentTheme(theme),\n \"pointer-events-none focus-visible:outline-none\":\n isStaticTheme(theme),\n \"focus-base\": !isStaticTheme(theme),\n \"sticky top-0\": sticky,\n [`${bg} ${hoverBg} ${text}`]: !(selectable && isOpen),\n [`${selectableBg} ${selectableText}`]: selectable && isOpen,\n [options?.headerCSS ?? \"\"]: options?.headerCSS,\n [options?.selectedHeaderCSS ?? \"\"]:\n options?.selectedHeaderCSS && isOpen,\n })}\n >\n {rowIcon ? (\n <Icon\n name={typeof rowIcon === \"object\" ? rowIcon.name : rowIcon}\n color={textClass}\n additionalCSS={\n typeof rowIcon === \"object\" && rowIcon.css ? rowIcon.css : \"\"\n }\n size={options?.rowIconSize ?? \"32px\"}\n />\n ) : null}\n {renderHeading()}\n {!selectable && !isStaticTheme(theme) && rowInteractive ? (\n <span className=\"flex-1 justify-end flex items-center\">\n <Icon\n name={isOpen ? toggleIcons.open.name : toggleIcons.closed.name}\n color={toggleIconColor}\n additionalCSS={\n isOpen\n ? (typeof toggleIcons.open === \"object\" &&\n toggleIcons.open.css) ||\n \"\"\n : (typeof toggleIcons.closed === \"object\" &&\n toggleIcons.closed.css) ||\n \"\"\n }\n size={options?.iconSize ?? \"16px\"}\n />\n </span>\n ) : null}\n </AccordionTrigger>\n {rowInteractive && (\n <AccordionContent\n className={cn({\n \"ui-text-p2 overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\": true,\n [options?.contentCSS ?? \"\"]: options?.contentCSS,\n })}\n >\n <div className=\"pb-4\">{children}</div>\n </AccordionContent>\n )}\n </AccordionItem>\n );\n};\n\nconst Accordion = forwardRef<HTMLDivElement, AccordionProps>(\n (\n {\n data,\n theme = \"transparent\",\n icons = {\n closed: { name: \"icon-gui-plus-outline\" },\n open: { name: \"icon-gui-minus-outline\" },\n },\n options,\n ...props\n },\n ref,\n ) => {\n const openIndexes = useMemo(() => {\n const indexValues = data.map((_, i) => `accordion-item-${i}`);\n return options?.fullyOpen\n ? indexValues\n : indexValues.filter((_, index) =>\n options?.defaultOpenIndexes?.includes(index),\n );\n }, [data, options?.fullyOpen, options?.defaultOpenIndexes]);\n\n const [openRowValues, setOpenRowValues] = useState<string[]>(openIndexes);\n const [prevOpenIndexes, setPrevOpenIndexes] = useState(openIndexes);\n if (prevOpenIndexes !== openIndexes) {\n setPrevOpenIndexes(openIndexes);\n setOpenRowValues(openIndexes);\n }\n\n const innerAccordion = data.map((item, index) => (\n <AccordionRow\n key={item.name}\n name={item.name}\n heading={item.heading}\n rowIcon={item.icon}\n toggleIcons={icons}\n theme={theme}\n options={options}\n index={index}\n onClick={() => {\n item.onClick?.(index);\n }}\n openRowValues={openRowValues}\n rowInteractive={item.interactive}\n >\n {item.content}\n </AccordionRow>\n ));\n\n return (\n <div ref={ref} {...props}>\n {options?.autoClose ? (\n <RadixAccordion\n type=\"single\"\n collapsible\n value={openRowValues[0]}\n onValueChange={(values) => setOpenRowValues([values])}\n >\n {innerAccordion}\n </RadixAccordion>\n ) : (\n <RadixAccordion\n type=\"multiple\"\n value={openRowValues}\n onValueChange={(values) => setOpenRowValues(values)}\n >\n {innerAccordion}\n </RadixAccordion>\n )}\n </div>\n );\n },\n);\n\nAccordion.displayName = \"Accordion\";\n\nexport default Accordion;\n"],"names":["React","useMemo","useState","forwardRef","AccordionContent","AccordionItem","AccordionTrigger","Accordion","RadixAccordion","Icon","themeClasses","isNonTransparentTheme","isStaticTheme","cn","AccordionRow","name","heading","children","rowIcon","options","toggleIcons","theme","index","onClick","openRowValues","rowInteractive","selectable","sticky","rowKey","isOpen","includes","text","bg","hoverBg","selectableBg","selectableText","border","toggleIconColor","textClass","renderHeading","span","value","className","hideBorders","selectedItemCSS","headerCSS","selectedHeaderCSS","color","additionalCSS","css","size","rowIconSize","open","closed","iconSize","contentCSS","div","data","icons","props","ref","openIndexes","indexValues","map","_","i","fullyOpen","filter","defaultOpenIndexes","setOpenRowValues","prevOpenIndexes","setPrevOpenIndexes","innerAccordion","item","key","icon","interactive","content","autoClose","type","collapsible","onValueChange","values","displayName"],"mappings":"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"}
package/core/Badge.js CHANGED
@@ -1,2 +1,2 @@
1
- import React,{useMemo}from"react";import Icon from"./Icon";import cn from"./utils/cn";const defaultIconSizeByBadgeSize={lg:"16px",md:"15px",sm:"14px",xs:"13px"};const Badge=({size="md",color="neutral",iconBefore,iconAfter,className,childClassName,children,disabled=false,focusable=false,hoverable=false,iconSize,ariaLabel})=>{const sizeClass=useMemo(()=>{switch(size){case"xs":return"px-2 py-0 text-[10px] leading-tight";case"sm":return"px-2 py-0.5 text-[10px] leading-tight";case"md":return"px-2.5 py-0.5 text-[11px] leading-normal";case"lg":return"px-3 py-[0.1875rem] text-[12px] leading-normal"}},[size]);const childClass=useMemo(()=>{switch(size){case"xs":case"sm":return"leading-[18px]";case"md":case"lg":return"leading-[20px]"}},[size]);const colorClass=useMemo(()=>{switch(color){case"neutral":return"text-neutral-900 dark:text-neutral-400";case"violet":return"text-violet-400";case"orange":return"text-orange-600";case"yellow":return"text-yellow-600";case"green":return"text-green-600";case"blue":return"text-blue-600";case"pink":return"text-pink-600";case"red":return"text-orange-700"}},[color]);const computedIconSize=iconSize??defaultIconSizeByBadgeSize[size];return React.createElement("div",{className:cn("inline-flex bg-neutral-100 dark:bg-neutral-1200 rounded-2xl gap-1 items-center focus-base transition-colors select-none font-semibold",sizeClass,colorClass,{"focus-base":focusable},{"hover:bg-neutral-300 hover:dark:bg-neutral-1000 active:bg-neutral-300 dark:active:bg-neutral-1000":hoverable},{"cursor-not-allowed disabled:text-gui-unavailable dark:disabled:text-gui-unavailable-dark":disabled},className),tabIndex:focusable?0:undefined,role:focusable?"button":undefined,"aria-label":focusable||hoverable?ariaLabel:undefined},iconBefore?React.createElement(Icon,{name:iconBefore,size:computedIconSize,color:colorClass}):null,React.createElement("span",{className:cn("whitespace-nowrap tracking-[0.04em]",childClass,childClassName)},children),iconAfter?React.createElement(Icon,{name:iconAfter,size:computedIconSize,color:colorClass}):null)};export default Badge;
1
+ import React,{useMemo}from"react";import Icon from"./Icon";import cn from"./utils/cn";const defaultIconSizeByBadgeSize={lg:"16px",md:"15px",sm:"14px",xs:"13px"};const Badge=({size="md",color="neutral",iconBefore,iconAfter,className,childClassName,children,disabled=false,focusable=false,hoverable=false,iconSize,ariaLabel})=>{const sizeClass=useMemo(()=>{switch(size){case"xs":return"px-2 py-0 text-[10px] leading-tight";case"sm":return"px-2 py-0.5 text-[10px] leading-tight";case"md":return"px-2.5 py-0.5 text-[11px] leading-normal";case"lg":return"px-3 py-[0.1875rem] text-[12px] leading-normal"}},[size]);const childClass=useMemo(()=>{switch(size){case"xs":case"sm":return"leading-[18px]";case"md":case"lg":return"leading-[20px]"}},[size]);const colorClass=useMemo(()=>{switch(color){case"neutral":return"text-neutral-900 dark:text-neutral-400";case"violet":return"text-violet-400";case"orange":return"text-orange-600";case"yellow":return"text-yellow-600";case"green":return"text-green-600";case"blue":return"text-blue-600";case"pink":return"text-pink-600";case"red":return"text-orange-700"}},[color]);const computedIconSize=iconSize??defaultIconSizeByBadgeSize[size];return React.createElement("div",{className:cn("inline-flex bg-neutral-100 dark:bg-neutral-1200 rounded-2xl gap-1 items-center focus-base transition-colors select-none font-semibold",sizeClass,colorClass,{"focus-base":focusable},{"hover:bg-neutral-300 hover:dark:bg-neutral-1000 active:bg-neutral-300 dark:active:bg-neutral-1000":hoverable},{"cursor-not-allowed disabled:text-gui-disabled-light dark:disabled:text-gui-disabled-dark":disabled},className),tabIndex:focusable?0:undefined,role:focusable?"button":undefined,"aria-label":focusable||hoverable?ariaLabel:undefined},iconBefore?React.createElement(Icon,{name:iconBefore,size:computedIconSize,color:colorClass}):null,React.createElement("span",{className:cn("whitespace-nowrap tracking-[0.04em]",childClass,childClassName)},children),iconAfter?React.createElement(Icon,{name:iconAfter,size:computedIconSize,color:colorClass}):null)};export default Badge;
2
2
  //# sourceMappingURL=Badge.js.map