diff --git a/.storybook/main.js b/.storybook/main.js index c83f517fa..fcd5ef70f 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -5,6 +5,7 @@ const config = { '@storybook/addon-links', '@storybook/addon-docs', '@storybook/addon-onboarding', + '@storybook/addon-a11y', ], framework: { name: '@storybook/nextjs', diff --git a/lib/dnd/stories/Accessibility.stories.tsx b/lib/dnd/stories/Accessibility.stories.tsx index a55453442..d393479f2 100644 --- a/lib/dnd/stories/Accessibility.stories.tsx +++ b/lib/dnd/stories/Accessibility.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; import { useCallback, useRef, useState } from 'react'; import { DndStoreProvider, useDragSource, useDropTarget } from '~/lib/dnd'; import { useAccessibilityAnnouncements } from '~/lib/dnd/useAccessibilityAnnouncements'; diff --git a/lib/dnd/stories/DragAndDrop.stories.tsx b/lib/dnd/stories/DragAndDrop.stories.tsx index 2f0b0931d..5b7e4025f 100644 --- a/lib/dnd/stories/DragAndDrop.stories.tsx +++ b/lib/dnd/stories/DragAndDrop.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; import { useState } from 'react'; import { DndStoreProvider, diff --git a/lib/dnd/stories/DragSource.stories.tsx b/lib/dnd/stories/DragSource.stories.tsx index 7b0468aff..d44cb6252 100644 --- a/lib/dnd/stories/DragSource.stories.tsx +++ b/lib/dnd/stories/DragSource.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; import { useState } from 'react'; import { DndStoreProvider, useDragSource, useDropTarget } from '..'; diff --git a/lib/dnd/stories/DropTarget.stories.tsx b/lib/dnd/stories/DropTarget.stories.tsx index 0f29900c4..77ab68f8e 100644 --- a/lib/dnd/stories/DropTarget.stories.tsx +++ b/lib/dnd/stories/DropTarget.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/nextjs'; import { useState } from 'react'; import { DndStoreProvider, useDragSource, useDropTarget } from '..'; diff --git a/lib/ui/components/VirtualList/MVPList.stories.tsx b/lib/ui/components/VirtualList/MVPList.stories.tsx new file mode 100644 index 000000000..0b522d532 --- /dev/null +++ b/lib/ui/components/VirtualList/MVPList.stories.tsx @@ -0,0 +1,656 @@ +import { faker } from '@faker-js/faker'; +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { useRef, useState } from 'react'; +import MVPList from './MVPList'; + +const meta: Meta = { + title: 'Components/MVPList', + component: MVPList, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: ` +# MVPList - Virtual List with Staggered Animations + +A virtualized grid component that supports staggered animations when items change. Uses the \`useVirtualListAnimation\` hook internally. + +## Features + +- **Virtual scrolling** for performance with large datasets +- **Responsive grid** that adapts to container width +- **Staggered animations** when items change (controlled via \`listId\`) +- **Smooth transitions** between different item sets + +## Usage + +The component requires three props: +- \`items\`: Array of items with \`id\` and \`name\` properties +- \`listId\`: String that controls when animations trigger (change this to animate) +- \`ListItem\` (optional): Custom component for rendering individual items + +### Basic Example + +\`\`\`tsx +import MVPList from './MVPList'; + +const items = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' } +]; + +function MyComponent() { + return ; +} +\`\`\` + +### With Animation Triggers + +\`\`\`tsx +function MyComponent() { + const [items, setItems] = useState(generateItems(100)); + const [listId, setListId] = useState('0'); + + const handleNewItems = () => { + setItems(generateNewItems(100)); + setListId(prev => (parseInt(prev) + 1).toString()); // Trigger animation + }; + + return ( + <> + + + + ); +} +\`\`\` + +## useVirtualListAnimation Hook + +The component uses the \`useVirtualListAnimation\` hook internally. You can use this hook directly for custom implementations: + +\`\`\`tsx +import { useVirtualListAnimation } from './useVirtualListAnimation'; + +function CustomVirtualList({ items, listId }) { + const containerRef = useRef(null); + const columns = 4; // or calculate dynamically + + const { + displayItems, + isTransitioning, + scope, + shouldAnimateItem, + getItemDelay, + captureVisibleItems + } = useVirtualListAnimation({ + items, + listId, + containerRef, + columns + }); + + // Use displayItems instead of items + // Check shouldAnimateItem(id) to determine if item should animate + // Use getItemDelay(id) for stagger timing + + return ( +
+
+ {displayItems.map(item => ( + + {item.name} + + ))} +
+
+ ); +} +\`\`\` + +## Animation Behavior + +- **listId unchanged**: Items added/removed without animation (useful for drag & drop) +- **listId changed**: Full exit → enter animation sequence with staggered timing +- **Stagger timing**: Calculates delays to fit animation within fixed duration + +## listId Management Patterns + +There are two main patterns for managing \`listId\`: + +### 1. Stable listId (no animations) + +Use when you want seamless updates without exit/enter animations: + +\`\`\`tsx +// Keep listId constant +const listId = useRef('stable-list').current; +// or +const [listId] = useState('stable-list'); + +// Items can change without triggering animations +const addItem = () => setItems([...items, newItem]); +const removeItem = (id) => setItems(items.filter(item => item.id !== id)); +const reorderItems = (newOrder) => setItems(newOrder); +// listId stays the same - no animation +\`\`\` + +**Use cases:** +- Drag & drop operations +- Adding/removing individual items +- Filtering or sorting existing data +- Real-time updates + +### 2. Context-based listId (trigger animations) + +Use when external context changes and you want fresh animations: + +\`\`\`tsx +// Prompt/Step-based (common pattern) +const [currentPrompt, setCurrentPrompt] = useState(1); +const listId = \`prompt-\${currentPrompt}\`; + +const goToPrompt = (promptNumber) => { + setCurrentPrompt(promptNumber); + setItems(getItemsForPrompt(promptNumber)); + // listId automatically changes to trigger animation +}; + +// Other context-based examples: +const listId = \`view-\${viewType}\`; // Different views +const listId = \`step-\${stepNumber}\`; // Wizard steps +const listId = \`page-\${pageNumber}\`; // Pagination +const listId = \`filter-\${filterType}\`; // Different filters +\`\`\` + +**Use cases:** +- Interview/survey prompts +- Multi-step workflows +- Switching between different views +- Loading new datasets +- Navigation between pages/sections + `, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Function to generate items with unique IDs +const generateItems = (count: number, offset = 0) => + Array.from({ length: count }, (_, i) => ({ + id: offset + i + 1, + name: faker.person.firstName(), + })); + +export const Default: Story = { + args: { + items: generateItems(1000), + listId: '0', // provide a stable listId for default story + }, +}; + +export const WithRegenerateButton: Story = { + render: () => { + const [items, setItems] = useState(generateItems(1000)); + const listIdRef = useRef(0); + + const handleRegenerate = () => { + // Generate new items with different IDs to ensure they're treated as new + setItems(generateItems(Math.floor(Math.random() * 1000), Date.now())); + listIdRef.current += 1; // Increment listId to trigger animation + }; + + return ( +
+ + +
+ ); + }, +}; + +export const StableListId: Story = { + render: () => { + const [items, setItems] = useState(generateItems(10)); + const listIdRef = useRef(0); + + const addItem = () => { + // Add a new item without changing listId (no full animation) + const maxId = items.length > 0 ? Math.max(...items.map((i) => i.id)) : 0; + setItems([...items, { id: maxId + 1, name: faker.person.firstName() }]); + // Note: listId not changed, so no full list animation triggered + }; + + const removeItem = () => { + // Remove last item without changing listId + if (items.length === 0) return; + setItems(items.slice(0, items.length - 1)); + // listId stays the same + }; + + return ( +
+
+ + +

+ Note: listId is stable, so adding/removing items does{' '} + not re-trigger full animation. This is useful in + cases like drag and drop. +

+
+ +
+ ); + }, +}; + +export const PromptBasedListId: Story = { + render: () => { + const [currentPrompt, setCurrentPrompt] = useState(1); + + // Mock data for different prompts + const promptData = { + 1: { + title: 'Who are your close friends?', + items: generateItems(8).map((item) => ({ + ...item, + name: `Friend ${item.id}`, + })), + }, + 2: { + title: 'Who are your work colleagues?', + items: generateItems(12).map((item) => ({ + ...item, + name: `Colleague ${item.id}`, + })), + }, + 3: { + title: 'Who are your family members?', + items: generateItems(6).map((item) => ({ + ...item, + name: `Family ${item.id}`, + })), + }, + }; + + const currentData = promptData[currentPrompt]; + const listId = `prompt-${currentPrompt}`; // Key insight: listId changes with prompt + + return ( +
+
+

+ Interview Prompt Pattern +

+

+ Each prompt shows different items and triggers fresh animations. + Common in side panels. +

+ +
+ {[1, 2, 3].map((promptNum) => ( + + ))} +
+ +
+ Current listId:{' '} + {listId} +
+
+ +
+

{currentData.title}

+ +
+ +
+

Code Pattern:

+
+            {`const [currentPrompt, setCurrentPrompt] = useState(1);
+
+const goToPrompt = (promptNumber) => {
+  setCurrentPrompt(promptNumber);
+  setItems(getItemsForPrompt(promptNumber));
+  // Key: listId includes prompt number
+  setListId(\`prompt-\${promptNumber}\`);
+};
+
+return (
+  
+);`}
+          
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: ` +This pattern is ideal for **multi-step workflows** where each step shows different content: + +**Use Cases:** +- Network Canvas interview prompts +- Multi-step survey forms +- Wizard/stepper interfaces +- Different views of the same dataset + +**Why it works:** +- Each prompt gets a unique \`listId\` (\`prompt-1\`, \`prompt-2\`, etc.) +- Changing prompts triggers fresh animations +- Users get visual feedback that content has changed +- Maintains context that this is a "new screen" + +**Alternative approaches:** +- \`listId="step-\${stepNumber}"\` for generic steps +- \`listId="view-\${viewType}"\` for different views +- \`listId="page-\${pageNumber}"\` for pagination + `, + }, + }, + }, +}; + +export const StableVsContextBased: Story = { + render: () => { + // Stable listId pattern + const [stableItems, setStableItems] = useState(generateItems(12)); + const stableListId = useRef('stable-dnd').current; + + // Context-based listId pattern + const [contextItems, setContextItems] = useState(generateItems(12)); + const [context, setContext] = useState('friends'); + const contextListId = `context-${context}`; + + const addToStable = () => { + const newItem = { id: Date.now(), name: faker.person.firstName() }; + setStableItems([...stableItems, newItem]); + // No animation - listId stays the same + }; + + const removeFromStable = () => { + if (stableItems.length > 0) { + setStableItems(stableItems.slice(0, -1)); + // No animation - listId stays the same + } + }; + + const addToContext = () => { + const newItem = { id: Date.now(), name: faker.person.firstName() }; + setContextItems([...contextItems, newItem]); + // No animation - listId stays the same within this context + }; + + const removeFromContext = () => { + if (contextItems.length > 0) { + setContextItems(contextItems.slice(0, -1)); + // No animation - listId stays the same within this context + } + }; + + const switchContext = (newContext: string) => { + setContext(newContext); + setContextItems(generateItems(12, Date.now())); + // Animation triggered - listId changes + }; + + return ( +
+
+ {/* Stable Pattern */} +
+
+

Stable listId Pattern

+

+ Perfect for drag & drop, add/remove operations +

+

+ listId:{' '} + + {stableListId} + +

+
+ + +
+
+
+ +
+
+ + {/* Context-based Pattern */} +
+
+

Context-based listId

+

+ Each prompt shows different items and triggers fresh animations. +

+

+ listId:{' '} + + {contextListId} + +

+
+ {['friends', 'work', 'family'].map((ctx) => ( + + ))} +
+
+ + +
+
+
+ +
+
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: ` +This story demonstrates the two main patterns for \`listId\` management: + +**Stable listId:** Use when you want seamless updates without disrupting the user's flow. Perfect for drag & drop operations, adding/removing items, or real-time updates. + +**Context-based listId:** Use when external context changes and you want to signal a transition to new content. Perfect for interview prompts, navigation, or switching between different data views. + +The choice between these patterns depends on your UX goals and the nature of the content changes. + `, + }, + }, + }, +}; + +export const HookUsageDemo: Story = { + render: () => { + return ( +
+
+

Hook Usage Pattern

+

+ This demonstrates the typical pattern for using + useVirtualListAnimation: +

+
+            {`import { useVirtualListAnimation } from './useVirtualListAnimation';
+
+function MyVirtualList({ items, listId }) {
+  const containerRef = useRef(null);
+  const [columns, setColumns] = useState(4);
+  
+  // Hook returns everything needed for animations
+  const {
+    displayItems,      // Use this instead of items
+    isTransitioning,   // True during exit animation
+    scope,            // Attach to animation container
+    shouldAnimateItem, // Check if item needs animation
+    getItemDelay,     // Get stagger delay for item
+    captureVisibleItems // Call when virtual rows update
+  } = useVirtualListAnimation({
+    items,
+    listId,
+    containerRef,
+    columns
+  });
+  
+  return (
+    
+
+ {displayItems.map(item => ( + shouldAnimateItem(item.id) ? ( + + {item.name} + + ) : ( +
{item.name}
+ ) + ))} +
+
+ ); +}`} +
+
+ +
+

Key Points:

+
    +
  • + • containerRef: Required for scroll management +
  • +
  • + • displayItems: Use instead of original items + array +
  • +
  • + • listId changes: Trigger exit → enter animations +
  • +
  • + • shouldAnimateItem(): Only animate initially + visible items +
  • +
  • + • getItemDelay(): Provides stagger timing +
  • +
  • + • captureVisibleItems(): Call when virtual rows + update +
  • +
+
+ + +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +This story shows the complete pattern for using the \`useVirtualListAnimation\` hook directly. +The hook handles all animation state management, timing calculations, and transition coordination. + +The key insight is that the hook provides \`displayItems\` instead of using the original \`items\` directly, +allowing it to manage smooth transitions between different item sets. + `, + }, + }, + }, +}; diff --git a/lib/ui/components/VirtualList/MVPList.tsx b/lib/ui/components/VirtualList/MVPList.tsx new file mode 100644 index 000000000..70c4ba22f --- /dev/null +++ b/lib/ui/components/VirtualList/MVPList.tsx @@ -0,0 +1,161 @@ +import { useDirection } from '@radix-ui/react-direction'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { motion } from 'motion/react'; +import type React from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useVirtualListAnimation } from './useVirtualListAnimation'; // adjust path as needed + +type Item = { + id: number; + name: string; +}; + +type Props = { + items: Item[]; + listId: string; // Controlled listId to decide when to animate + ListItem?: React.ComponentType<{ item: Item }>; +}; + +const SPACING_UNIT_PX = 16; // Tailwind's gap-4 and px-4 equivalent +const ITEM_WIDTH = 100; +const ITEM_HEIGHT = 100; + +const DefaultListItem = ({ item }: { item: Item }) => { + return ( +
+ {item.name} +
+ ); +}; + +export default function ResponsiveVirtualGrid({ + items, + listId, + ListItem = DefaultListItem, +}: Props) { + const direction = useDirection(); + const containerRef = useRef(null); + + const [columns, setColumns] = useState(1); // Number of columns based on container width + + // ResizeObserver to determine column count based on container width + useLayoutEffect(() => { + if (!containerRef.current) return; + + const updateColumns = () => { + const containerWidth = containerRef.current?.offsetWidth ?? 0; + const availableWidth = containerWidth - SPACING_UNIT_PX * 2; + const maxColumns = Math.max( + 1, + Math.floor(availableWidth / (ITEM_WIDTH + SPACING_UNIT_PX)), + ); + setColumns(maxColumns); + }; + + updateColumns(); + + const resizeObserver = new ResizeObserver(updateColumns); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + const { + displayItems, + // isTransitioning, + scope, + // animate, + shouldAnimateItem, + getItemDelay, + captureVisibleItems, + } = useVirtualListAnimation({ + items, + listId, + containerRef, + columns, + }); + + const rowCount = Math.ceil(displayItems.length / columns); + const rowHeight = ITEM_HEIGHT + SPACING_UNIT_PX; + + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => containerRef.current, + getItemKey: (index) => displayItems[index]!.id.toString(), + estimateSize: () => rowHeight, + paddingStart: SPACING_UNIT_PX, + paddingEnd: SPACING_UNIT_PX, + isRtl: direction === 'rtl', + }); + + // After virtual rows are available, capture visible items for stagger animation + useEffect(() => { + captureVisibleItems(rowVirtualizer.getVirtualItems()); + }, [rowVirtualizer.getVirtualItems, captureVisibleItems, rowVirtualizer]); + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * columns; + + return ( +
+ {Array.from({ length: columns }).map((_, rowIndex) => { + const itemIndex = startIndex + rowIndex; + if (itemIndex >= displayItems.length) return null; + + const item = displayItems[itemIndex]!; + const shouldAnimate = shouldAnimateItem(item.id); + const delay = getItemDelay(item.id); + + return shouldAnimate ? ( + + + + ) : ( +
+ +
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/lib/ui/components/VirtualList/VirtualList.stories.tsx b/lib/ui/components/VirtualList/VirtualList.stories.tsx new file mode 100644 index 000000000..5f4d622cc --- /dev/null +++ b/lib/ui/components/VirtualList/VirtualList.stories.tsx @@ -0,0 +1,1333 @@ +import { faker } from '@faker-js/faker'; +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { useMemo, useState } from 'react'; +import { + DndStoreProvider, + useDragSource, + useDropTarget, + type DragMetadata, +} from '~/lib/dnd'; + +import { cn } from '~/utils/shadcn'; +import { VirtualList } from './VirtualList'; + +// Sample data type +type SampleItem = { + id: number; + name: string; +}; + +// Generate sample data +const generateItems = (count: number): SampleItem[] => + Array.from({ length: count }, (_, i) => ({ + id: i, + name: faker.person.firstName(), + })); + +const meta: Meta = { + title: 'UI/VirtualList', + component: VirtualList, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +Efficiently renders large lists using virtualization. Supports grid, column, and horizontal layouts with optional selection. + +- **Grid Layout**: Responsive columns based on container width and itemWidth +- **Column Layout**: Fixed columns, items span 100% of column width (itemWidth ignored) +- **Horizontal Layout**: Single row with horizontal scrolling + `, + }, + }, + a11y: { + config: { + rules: [ + // Allow interactive elements without onClickhandler (we have onItemClick) + { id: 'click-events-have-key-events', enabled: false }, + // Allow click without keyboard equivalent (we have Enter/Space) + { id: 'no-static-element-interactions', enabled: false }, + ], + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const Node = ( + item: SampleItem, + _index: number, + isSelected: boolean, +) => { + const classes = cn( + 'flex items-center h-full w-full justify-center rounded-full', + 'bg-linear-145 from-50% to-50%', + 'from-[var(--node-color-seq-1)] to-[var(--node-color-seq-1-dark)]', + 'text-white text-sm font-medium', + 'transition-all duration-200', + isSelected && 'ring ring-white ring-offset-2', + ); + + return ( +
+ {item.name} +
+ ); +}; + +// Simple reusable item renderer for basic rectangular items +const SimpleItemRenderer = ( + item: SampleItem, + _index: number, + isSelected: boolean, +) => { + return ( +
+ {item.name} +
+ ); +}; + +const DataCard = ( + item: SampleItem, + _index: number, + isSelected: boolean, +) => { + return ( +
+ {/* Label section - constrained height */} +
+

{item.name}

+
+ + {/* Data section - fills remaining space */} +
+
+
+ First Name +
+
+ {item.name} +
+
+
+
+ Item ID +
+
+ {item.id} +
+
+
+
+ ); +}; + +// Draggable item component wrapper +const DraggableItem = ({ + item, + isSelected, +}: { + item: SampleItem; + isSelected: boolean; +}) => { + const { dragProps, isDragging } = useDragSource({ + type: 'virtual-list-item', + metadata: item, + announcedName: item.name, + }); + + return ( +
+ {item.name} +
+ ); +}; + +// Draggable item renderer +const DraggableItemRenderer = ( + item: SampleItem, + index: number, + isSelected: boolean, +) => ( + +); + +export const Basic: Story = { + parameters: { + docs: { + description: { + story: ` +**Basic Usage - Read-only List** + +\`\`\`typescript +import { VirtualList } from './VirtualList'; + +type Item = { id: number; name: string }; + +const items: Item[] = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + // ... more items +]; + +// Define how each item should render +const ItemRenderer = (item: Item, index: number, isSelected: boolean) => ( +
+ {item.name} +
+); + +// Use the VirtualList + +\`\`\` + +**Key Points:** +- No \`onItemClick\` = read-only, no pointer cursor +- \`itemRenderer\` defines how each item looks +- \`itemWidth\`/\`itemHeight\` control item dimensions (itemWidth ignored in column layout) +- \`layout="grid"\` creates responsive grid, \`layout="column"\` spans 100% of column width + +**Keyboard Navigation & Accessibility:** +- **Tab** - Focus the VirtualList container +- **Arrow Keys** - Navigate between items (Up/Down/Left/Right in grid, Left/Right only in horizontal) +- **Enter/Space** - Trigger \`onItemClick\` (if provided) for focused item +- **Screen Readers** - Items have proper ARIA roles (\`listbox\`, \`option\`) +- **Focus Indicators** - Focused item shows blue ring outline + `, + }, + }, + }, + render: () => { + const items = useMemo(() => generateItems(200), []); + + return ( +
+

Basic Grid (200 items)

+

+ This list has no click functionality. Clicking items or using keyboard + navigation will not trigger any actions. +

+ +
+ ); + }, +}; + +import { fn } from 'storybook/test'; + +export const ActionOnClick: Story = { + parameters: { + docs: { + description: { + story: ` +**Action on Click - Non-Selection Interaction** + +This example demonstrates using the \`onItemClick\` prop to handle item clicks without managing selection state. + +- Click any item to log an action +- No selection state +- Keyboard navigation: Tab, Arrows, Enter/Space + `, + }, + }, + }, + args: { + onItemClick: fn(), + }, + render: (args) => { + const items = useMemo(() => generateItems(50), []); + + return ( +
+

+ Action on Click (50 items) +

+

+ Click any item to log the click using Storybook Actions. +

+ +
+ ); + }, +}; + +export const Grid: Story = { + parameters: { + docs: { + description: { + story: ` +**Grid with Selection - Complete Example** + +\`\`\`typescript +import { VirtualList } from './VirtualList'; +import { useState } from 'react'; + +type Item = { id: number; name: string }; + +function MyComponent() { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const items: Item[] = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + // ... more items + ]; + + // Handle item clicks - toggle selection + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + // Render function with selection and focus styling + const ItemRenderer = (item: Item, index: number, isSelected: boolean) => ( +
+ {item.name} +
+ ); + + return ( + + ); +} +\`\`\` + +**Selection Management:** +\`\`\`typescript +// Get selected items +const selectedItems = items.filter(item => selectedIds.has(item.id)); + +// Clear selection +setSelectedIds(new Set()); + +// Select all +setSelectedIds(new Set(items.map(item => item.id))); +\`\`\` + +**Keyboard Navigation & Accessibility:** +- **Tab** - Focus the VirtualList container +- **Arrow Keys** - Navigate between items and auto-scroll into view +- **Enter/Space** - Toggle selection for focused item +- **Screen Reader Support** - Announces selections and navigation +- **ARIA Labels** - \`aria-multiselectable="true"\`, \`aria-selected\` states + `, + }, + }, + }, + render: () => { + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + const items = useMemo(() => generateItems(100), []); + + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + return ( +
+

Grid Layout (100 items)

+
+

+ Selected: {selectedIds.size} items +

+ {selectedIds.size > 0 ? ( +

+ Items: {Array.from(selectedIds).slice(0, 10).join(', ')} + {selectedIds.size > 10 && + ` ... and ${selectedIds.size - 10} more`} +

+ ) : ( +

+ Click items to select them or use keyboard navigation. +

+ )} +
+ +
+ ); + }, +}; + +export const Column: Story = { + parameters: { + docs: { + description: { + story: ` +**Column Layout - Fixed Column Count** + +\`\`\`typescript +import { VirtualList } from './VirtualList'; + +const items = generateItems(100); + +const ItemRenderer = (item, index, isSelected) => ( +
+ {item.name} +
+); + + +\`\`\` + +**Key Points:** +- \`layout="column"\` creates fixed column layout +- \`columns\` prop is required (number of columns) +- Use \`itemHeight\` for height (width spans 100% of column automatically) +- \`itemWidth\` prop is ignored in column layout +- Width calculated automatically: \`(100% - gaps) / columns\` + +**Keyboard Navigation:** +- **Arrow Keys** - Up/Down navigate within columns, Left/Right navigate between columns +- **Auto-scroll** - Focused items automatically scroll into view + `, + }, + }, + }, + render: () => { + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + const [columns, setColumns] = useState(2); + const items = useMemo(() => generateItems(50), []); + + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + return ( +
+

+ Column Layout ({columns} columns, 50 items) +

+ +
+ + +
+ +
+

+ Selected: {selectedIds.size} items +

+ {selectedIds.size > 0 ? ( +

+ Items: {Array.from(selectedIds).slice(0, 10).join(', ')} + {selectedIds.size > 10 && + ` ... and ${selectedIds.size - 10} more`} +

+ ) : ( +

+ Click items to select them or use keyboard navigation. +

+ )} +
+ + +
+ ); + }, +}; + +export const Horizontal: Story = { + parameters: { + docs: { + description: { + story: ` +**Horizontal Layout - Scrolling Row** + +\`\`\`typescript +import { VirtualList } from './VirtualList'; + +const items = generateItems(50); + +const ItemRenderer = (item, index, isSelected) => ( +
+ {item.name} +
+); + + +\`\`\` + +**Key Points:** +- \`layout="horizontal"\` creates horizontal scrolling +- Use \`itemWidth\` for width (height fills container automatically) +- Container needs fixed height via className + +**Keyboard Navigation:** +- **Left/Right Arrows** - Navigate between items and auto-scroll into view +- **Up/Down Arrows** - Disabled (no vertical navigation) +- **Enter/Space** - Trigger \`onItemClick\` for focused item +- **Tab** - Focus the scrollable container + `, + }, + }, + }, + render: () => { + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + const items = useMemo(() => generateItems(30), []); + + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + return ( +
+

+ Horizontal Layout (30 items) +

+ +
+

+ Selected: {selectedIds.size} items +

+ {selectedIds.size > 0 ? ( +

+ Items: {Array.from(selectedIds).slice(0, 10).join(', ')} + {selectedIds.size > 10 && + ` ... and ${selectedIds.size - 10} more`} +

+ ) : ( +

+ Click items to select them or use keyboard navigation. +

+ )} +
+ + +
+ ); + }, +}; + +export const LargeDataset: Story = { + parameters: { + docs: { + description: { + story: ` +**Large Dataset - Performance Demo** + +\`\`\`typescript +import { VirtualList } from './VirtualList'; + +// Generate large dataset +const items = Array.from({ length: 10000 }, (_, i) => ({ + id: i, + name: \`Item \${i + 1}\` +})); + +const ItemRenderer = (item, index, isSelected) => ( +
+ {item.name} +
+); + + +\`\`\` + +**Performance Notes:** +- Only visible items are rendered (virtualization) +- Smooth scrolling regardless of dataset size +- Memory efficient - DOM nodes reused +- 10k+ items perform like 100 items + +**Accessibility with Large Data:** +- **Tab Index Management** - Only container and focused item are tabbable +- **Virtual Focus** - Arrow key navigation works across entire dataset +- **Screen Reader** - Announces position (e.g. "Item 5,432 of 10,000") +- **Auto-scroll** - Focused items automatically scroll into view + `, + }, + }, + }, + render: () => { + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + const items = useMemo(() => generateItems(10000), []); + + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + return ( +
+

+ Large Dataset (10,000 items) +

+ +
+

+ Selected: {selectedIds.size} items ( + {((selectedIds.size / items.length) * 100).toFixed(1)}%) +

+ {selectedIds.size > 0 ? ( +

+ Items: {Array.from(selectedIds).slice(0, 15).join(', ')} + {selectedIds.size > 15 && + ` ... and ${selectedIds.size - 15} more`} +

+ ) : ( +

+ Click items to select them or use keyboard navigation. +

+ )} +
+ +
+ ); + }, +}; + +export const DragAndDrop: Story = { + parameters: { + docs: { + description: { + story: ` +**Drag and Drop - Between Two Lists** + +\`\`\`typescript +import { VirtualList } from './VirtualList'; +import { DndStoreProvider, useDragSource, useDropTarget } from '~/lib/dnd'; + +// Draggable item component +const DraggableItem = ({ item, isSelected }) => { + const { dragProps, isDragging } = useDragSource({ + type: 'virtual-list-item', + metadata: item, + announcedName: item.name, + }); + + return ( +
+ {item.name} +
+ ); +}; + +// Item renderer for VirtualList +const DraggableItemRenderer = (item, index, isSelected) => ( + +); + +// Drop zone wrapper around VirtualList +const DropZoneVirtualList = ({ items, onItemReceived, title }) => { + const { dropProps, isOver, willAccept } = useDropTarget({ + id: \`list-\${title}\`, + accepts: ['virtual-list-item'], + onDrop: onItemReceived, + }); + + return ( +
+ +
+ ); +}; + +// Usage +function DragDropLists() { + const [leftItems, setLeftItems] = useState(initialItems); + const [rightItems, setRightItems] = useState([]); + + const moveItem = (item, fromLeft) => { + if (fromLeft) { + setLeftItems(prev => prev.filter(i => i.id !== item.id)); + setRightItems(prev => [...prev, item]); + } else { + setRightItems(prev => prev.filter(i => i.id !== item.id)); + setLeftItems(prev => [...prev, item]); + } + }; + + return ( + +
+ moveItem(metadata, false)} + title="Left" + /> + moveItem(metadata, true)} + title="Right" + /> +
+
+ ); +} +\`\`\` + +**Key Points:** +- No \`onItemClick\` or \`selectedIds\` = no selection interference +- Wrap items with DnD hooks in custom renderer +- Wrap VirtualList in drop zone for drop targets +- Use DndStoreProvider at root level +- Set \`focusable={false}\` to prevent nested interactive controls + +**Keyboard Accessibility for Drag & Drop:** +- **Tab** - Focus draggable items directly (VirtualList container not focusable) +- **Space/Enter** - Start drag operation with keyboard +- **Arrow Keys** - Navigate between drop zones during drag +- **Space/Enter** - Drop item in focused drop zone +- **Escape** - Cancel drag operation +- **Screen Reader** - Announces drag state and valid drop zones + `, + }, + }, + }, + render: () => { + const [leftItems, setLeftItems] = useState( + useMemo(() => generateItems(100), []), + ); + const [rightItems, setRightItems] = useState([]); + + const moveItem = (item: SampleItem, fromLeft: boolean) => { + if (fromLeft) { + setLeftItems((prev) => prev.filter((i) => i.id !== item.id)); + setRightItems((prev) => [...prev, item]); + } else { + setRightItems((prev) => prev.filter((i) => i.id !== item.id)); + setLeftItems((prev) => [...prev, item]); + } + }; + + const DropZoneVirtualList = ({ + items, + onItemReceived, + title, + }: { + items: SampleItem[]; + onItemReceived: (metadata?: DragMetadata) => void; + title: string; + }) => { + const { dropProps, willAccept, isOver, isDragging } = useDropTarget({ + id: `virtual-list-${title.toLowerCase()}`, + accepts: ['virtual-list-item'], + announcedName: `${title} list`, + onDrop: onItemReceived, + }); + + return ( +
+

+ {title} ({items.length} items) +

+ +
+ +
+
+ ); + }; + + return ( + +
+

+ Drag and Drop Between Lists +

+

+ Drag items between the two lists. Items will be moved from one list + to the other. +

+ +
+ { + if (metadata) { + moveItem(metadata as SampleItem, false); + } + }} + title="Left List" + /> + + { + if (metadata) { + moveItem(metadata as SampleItem, true); + } + }} + title="Right List" + /> +
+
+
+ ); + }, +}; + +export const AnimationsCustom: Story = { + parameters: { + docs: { + description: { + story: ` +**Custom Animations & When They Trigger** + +You can provide custom animation configurations using motion/react variants: + +\`\`\`typescript +import { VirtualList } from './VirtualList'; + +// Custom height-based slide animation (TanStack Virtual best practice) +const slideAnimation: ItemAnimationConfig = { + initial: { height: 0, opacity: 0, x: -20 }, + animate: { + height: 'auto', + opacity: 1, + x: 0, + transition: { + type: 'spring', + stiffness: 150, + damping: 20 + } + }, + exit: { + height: 0, + opacity: 0, + x: 20, + transition: { duration: 0.2 } + } +}; + +// Use custom animation + +\`\`\` + +**When Do Animations Trigger in Virtual Lists?** + +⚠️ **Important**: Virtual lists only animate items when they **enter/exit the data array**, NOT when scrolling in/out of view: + +- ✅ **Animate**: Adding new items to the \`items\` array +- ✅ **Animate**: Removing items from the \`items\` array +- ✅ **Animate**: Filtering/sorting that changes the \`items\` array +- ❌ **Don't animate**: Items scrolling into/out of viewport (would hurt performance) + +**Best Use Cases:** +- **Data updates**: New messages, notifications, search results +- **User actions**: Adding/removing items from lists +- **State changes**: Filtering, sorting, or other data transformations + +**Performance Note**: Animations only apply to visible items in the virtual window, keeping performance smooth even with large datasets. + `, + }, + }, + }, + render: () => { + const [items, setItems] = useState(useMemo(() => generateItems(15), [])); + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + // Custom slide animation configuration + const slideAnimation = { + exitAnimation: { + targets: '.item', + keyframes: { opacity: 0, x: 20, scale: 0.9 }, + options: { duration: 0.3 }, + }, + itemVariants: { + initial: { opacity: 0, x: -20, scale: 0.9 }, + animate: { + opacity: 1, + x: 0, + scale: 1, + }, + exit: { + opacity: 0, + x: 20, + scale: 0.9, + }, + }, + }; + + return ( +
+

Custom Animation Example

+ + +
+ ); + }, +}; + +export const CompleteExample: Story = { + parameters: { + docs: { + description: { + story: ` +**Complete example** + + + +// Fixed column layout + + +// Horizontal layout + + +**Features:** +- **Mode dropdown**: Switch between Grid (auto-responsive), Column (fixed), and Horizontal (scroll) layouts +- **Column Dropdown**: Select specific column count when in Column mode (1-12 columns) +- **Renderer Dropdown**: Choose between Node and DataCard renderers for grid/horizontal modes +- **Re-trigger Animation**: Button to generate new items and trigger animations by changing listId +- **Add/Remove Items**: Buttons to dynamically add/remove items for testing layout behavior +- **Selection**: Click items to toggle selection, with visual feedback + + `, + }, + }, + }, + render: () => { + const [mode, setMode] = useState<'grid' | 'column' | 'horizontal'>('grid'); + const [columnCount, setColumnCount] = useState(3); + const [renderer, setRenderer] = useState<'node' | 'datacard'>('node'); + const [items, setItems] = useState(useMemo(() => generateItems(50), [])); + const [selectedIds, setSelectedIds] = useState>( + new Set(), + ); + const [animationTrigger, setAnimationTrigger] = useState(0); // counter to trigger animations + + const handleItemClick = (id: string | number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const addItems = () => { + const startId = items.length; + const newItems = Array.from({ length: 5 }, (_, i) => ({ + id: startId + i, + name: faker.person.firstName(), + })); + setItems((prev) => [...prev, ...newItems]); + }; + + const removeItems = () => { + if (items.length > 5) { + setItems((prev) => prev.slice(0, -5)); + } + }; + + const retriggerAnimation = () => { + // Generate new items with different IDs to trigger fresh animations + const count = Math.floor(Math.random() * 100) + 50; + setItems(generateItems(count)); + setAnimationTrigger((prev) => prev + 1); // Change listId to trigger animations + }; + + const listId = `listid-${animationTrigger}`; + + return ( +
+

+ Virtual List Complete Example +

+ +
+ {/* Mode Selection */} +
+ + +
+ + {/* Column Count Dropdown - only shown in column mode */} + {mode === 'column' && ( +
+ + +
+ )} + + {/* Renderer Selection - only shown for grid/horizontal modes */} + {mode !== 'column' && ( +
+ + +
+ )} + + {/* Re-trigger Animation Button */} + + + {/* Add/Remove Items Buttons */} +
+ + +
+
+ + {/* Current Configuration Display - compact horizontal layout */} +
+ + Mode:{' '} + + {mode === 'grid' + ? 'Grid' + : mode === 'horizontal' + ? 'Horizontal' + : `${columnCount} Col${columnCount > 1 ? 's' : ''}`} + + + + Items: {items.length} + + + Selected: {selectedIds.size} + + + ListId:{' '} + + {listId} + + +
+ + +
+ ); + }, +}; diff --git a/lib/ui/components/VirtualList/VirtualList.tsx b/lib/ui/components/VirtualList/VirtualList.tsx new file mode 100644 index 000000000..a6ea792a7 --- /dev/null +++ b/lib/ui/components/VirtualList/VirtualList.tsx @@ -0,0 +1,402 @@ +import { useDirection } from '@radix-ui/react-direction'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { motion } from 'motion/react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { cn } from '~/utils/shadcn'; +import { + useVirtualListAnimation, + type CustomAnimation, +} from './useVirtualListAnimation'; + +type Item = { + id: number; + name: string; +}; + +export type VirtualListProps = { + items: Item[]; + itemRenderer: ( + item: Item, + index: number, + isSelected: boolean, + ) => React.ReactNode; + layout?: 'grid' | 'column' | 'horizontal'; + columns?: number; + itemWidth?: number; // Note: ignored in column layout - items span 100% of column + itemHeight?: number; + spacingUnit?: number; // spacing unit px (e.g., 16 for gap-4 px-4) + selectedIds?: Set; + onItemClick?: (id: string | number) => void; + className?: string; + ariaLabel?: string; + focusable?: boolean; + listId: string; // Controlled listId to decide when to animate + customAnimation?: CustomAnimation; // Optional custom animation configuration +}; + +export function VirtualList({ + items, + itemRenderer, + layout = 'grid', + columns: columnsOverride, + itemWidth = 100, + itemHeight = 100, + spacingUnit = 16, + selectedIds, + onItemClick, + className, + ariaLabel, + focusable = true, + listId, + customAnimation, +}: VirtualListProps) { + const direction = useDirection(); + const containerRef = useRef(null); + const [columns, setColumns] = useState(1); // Number of columns based on container width + + const { + displayItems, + // isTransitioning, + scope, + // animate, + shouldAnimateItem, + getItemDelay, + captureVisibleItems, + getItemVariants, + } = useVirtualListAnimation({ + items, + listId, + containerRef, + columns, + customAnimation, + }); + + const [activeIndex, setActiveIndex] = useState(-1); + + // ResizeObserver to determine column count based on container width + useLayoutEffect(() => { + // If layout is 'column', use the columns prop or default to 3 + // itemWidth is ignored in column layout - items span 100% of column + if (layout === 'column') { + setColumns(columnsOverride ?? 3); + return; + } + + // If layout is 'horizontal', set to number of items (single row) + if (layout === 'horizontal') { + setColumns(displayItems.length); + return; + } + + // For 'grid' layout, calculate responsive columns based on itemWidth + if (!containerRef.current) return; + + const updateColumns = () => { + const containerWidth = containerRef.current?.offsetWidth ?? 0; + const availableWidth = containerWidth - spacingUnit * 2; + const maxColumns = Math.max( + 1, + Math.floor(availableWidth / (itemWidth + spacingUnit)), + ); + setColumns(maxColumns); + }; + + updateColumns(); + + const resizeObserver = new ResizeObserver(updateColumns); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, [layout, columnsOverride, displayItems.length, spacingUnit, itemWidth]); + + const rowCount = + layout === 'horizontal' + ? displayItems.length + : Math.ceil(displayItems.length / columns); + const rowHeight = + layout === 'horizontal' + ? itemWidth + spacingUnit + : itemHeight + spacingUnit; + + const getItemsPerRow = useCallback(() => { + if (layout === 'column') return columns; // itemWidth ignored - items span column width + if (layout === 'horizontal') return 1; // Each item is its own "row" for virtualizer. This is important for horizontal scrolling. + if (layout === 'grid' && containerRef.current) { + const containerWidth = containerRef.current.clientWidth; + return Math.floor( + (containerWidth + spacingUnit) / (itemWidth + spacingUnit), + ); + } + return 1; + }, [layout, columns, itemWidth, spacingUnit]); + + const itemsPerRow = getItemsPerRow(); + + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => containerRef.current, + getItemKey: (index) => + layout === 'horizontal' + ? displayItems[index]!.id.toString() + : `row-${index}`, + estimateSize: () => rowHeight, + paddingStart: spacingUnit, + paddingEnd: spacingUnit, + isRtl: direction === 'rtl', + horizontal: layout === 'horizontal', + }); + + // After virtual rows are available, capture visible items for stagger animation + useEffect(() => { + captureVisibleItems(virtualizer.getVirtualItems()); + }, [virtualizer.getVirtualItems, captureVisibleItems, virtualizer]); + + const handleItemClick = useCallback( + (itemId: string | number) => { + onItemClick?.(itemId); + }, + [onItemClick], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const itemsPerRow = getItemsPerRow(); + let newIndex = activeIndex; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (layout === 'horizontal') return; + newIndex = Math.min(activeIndex + itemsPerRow, items.length - 1); + break; + case 'ArrowUp': + e.preventDefault(); + if (layout === 'horizontal') return; + newIndex = Math.max(activeIndex - itemsPerRow, 0); + break; + case 'ArrowRight': + e.preventDefault(); + newIndex = Math.min(activeIndex + 1, items.length - 1); + break; + case 'ArrowLeft': + e.preventDefault(); + newIndex = Math.max(activeIndex - 1, 0); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if ( + onItemClick && + activeIndex >= 0 && + activeIndex < items.length && + items[activeIndex] + ) { + handleItemClick(items[activeIndex].id); + } + break; + default: + return; + } + + if (newIndex !== activeIndex && newIndex >= 0) { + setActiveIndex(newIndex); + const rowIndex = Math.floor(newIndex / itemsPerRow); + virtualizer.scrollToIndex(rowIndex, { align: 'auto' }); + } + }, + [ + getItemsPerRow, + activeIndex, + layout, + items, + onItemClick, + handleItemClick, + virtualizer, + ], + ); + + return ( +
= 0 && items[activeIndex] + ? `item-${items[activeIndex].id}` + : undefined, + 'onKeyDown': handleKeyDown, + 'onBlur': () => setActiveIndex(-1), + })} + > +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + if (layout === 'horizontal') { + const item = displayItems[virtualRow.index]; + if (!item) return null; + + const isSelected = selectedIds?.has(item.id) ?? false; + const isActive = virtualRow.index === activeIndex; + const shouldAnimate = shouldAnimateItem(item.id); + const delay = getItemDelay(item.id); + + const baseProps = { + ...(focusable && { + 'id': `item-${item.id}`, + 'role': 'option', + 'aria-selected': isSelected, + }), + onClick: () => handleItemClick(item.id), + className: cn( + 'item', + onItemClick && 'cursor-pointer', + focusable && isActive && 'focused-children', + ), + style: { + width: `${itemWidth}px`, + height: `${itemHeight}px`, + }, + }; + + return ( +
+ {shouldAnimate ? ( + + {itemRenderer(item, virtualRow.index, isSelected)} + + ) : ( +
+ {itemRenderer(item, virtualRow.index, isSelected)} +
+ )} +
+ ); + } + + // Grid / column layout + const startIndex = virtualRow.index * itemsPerRow; + const rowItems = []; + + for ( + let i = 0; + i < itemsPerRow && startIndex + i < displayItems.length; + i++ + ) { + const itemIndex = startIndex + i; + const item = displayItems[itemIndex]; + if (!item) continue; + + const isSelected = selectedIds?.has(item.id) ?? false; + const isActive = itemIndex === activeIndex; + const shouldAnimate = shouldAnimateItem(item.id); + const delay = getItemDelay(item.id); + + const baseProps = { + ...(focusable && { + 'id': `item-${item.id}`, + 'role': 'option', + 'aria-selected': isSelected, + }), + onClick: () => handleItemClick(item.id), + className: cn( + 'item', + onItemClick && 'cursor-pointer', + focusable && isActive && 'focused-children', + ), + style: { + width: + layout === 'column' + ? `calc((100% - ${spacingUnit * (columns - 1)}px) / ${columns})` + : `${itemWidth}px`, + height: `${itemHeight}px`, + }, + }; + + rowItems.push( + shouldAnimate ? ( + + {itemRenderer(item, itemIndex, isSelected)} + + ) : ( +
+ {itemRenderer(item, itemIndex, isSelected)} +
+ ), + ); + } + + return ( +
+ {rowItems} +
+ ); + })} +
+
+ ); +} diff --git a/lib/ui/components/VirtualList/useVirtualListAnimation.ts b/lib/ui/components/VirtualList/useVirtualListAnimation.ts new file mode 100644 index 000000000..cfbc6fcfe --- /dev/null +++ b/lib/ui/components/VirtualList/useVirtualListAnimation.ts @@ -0,0 +1,190 @@ +// Reusable hook to manage animations in a virtual list + +import type { Variants } from 'motion/react'; +import { useAnimate } from 'motion/react'; +import { useEffect, useRef, useState } from 'react'; + +type Item = { id: number }; + +// Custom animation configuration type +// todo: can we reuse motion's types here? +export type CustomAnimation = { + // Exit animation for list transition + exitAnimation?: { + targets: string; + keyframes: Record; + options?: Record; + }; + // Enter animation variants for individual items + itemVariants?: Variants; +}; + +type UseVirtualListAnimationParams = { + items: T[]; + listId: string; // Controlled listId to decide when to animate + containerRef?: React.RefObject; + columns: number; + customAnimation?: CustomAnimation; // Optional custom animation configuration +}; + +const ANIMATION_TOTAL_DURATION = 1.0; + +// Persistent animation history map — survives unmount/remount +const hasAnimatedMap: Record = {}; + +export function useVirtualListAnimation({ + items, + listId, + // containerRef, + columns, + customAnimation, +}: UseVirtualListAnimationParams) { + const [displayItems, setDisplayItems] = useState(items); // Local copy of items, so we can handle transitioning when items change. + const [isTransitioning, setIsTransitioning] = useState(false); // Are we currently animating? + + const [initiallyVisibleItems, setInitiallyVisibleItems] = useState< + Set + >(new Set()); // Track initially visible items for animation + const [hasCapturedInitialItems, setHasCapturedInitialItems] = useState(false); // Have we captured the initial visible items? + + const animatedItemsRef = useRef>(new Set()); // Track items that have been animated + const visibleItemOrderRef = useRef>(new Map()); // Track order of visible items for animation delays + + const prevListIdRef = useRef(null); + const [scope, animate] = useAnimate(); + + // Track whether this is the *true* first render for this listId + const isTrueFirstRender = useRef(false); + if (!hasAnimatedMap[listId]) { + isTrueFirstRender.current = true; + hasAnimatedMap[listId] = true; + } + + // Animation effect controlled by listId changes + useEffect(() => { + if (prevListIdRef.current === null) { + // On mount: only animate if it's the first time we've seen this listId + setDisplayItems(items); + prevListIdRef.current = listId; + return; + } + + if (prevListIdRef.current !== listId) { + // listId changed, so start transition to animate exit/enter sequence + setIsTransitioning(true); + + const exitAnimation = async () => { + // Use custom exit animation if provided, otherwise use default + if (customAnimation?.exitAnimation) { + const { targets, keyframes, options } = customAnimation.exitAnimation; + await animate(targets, keyframes, options); + } else { + // Default: Animate out existing items simultaneously (no stagger) + await animate('.item', { scale: 0, opacity: 0 }, { duration: 0.2 }); + } + + // TODO: This works, but breaks the initial animation. No way around it I can find. + // Disabling makes the animation work, but leaves the user scrolled to wherever they were. + // containerRef.current?.scrollTo({ top: 0 }); + + // Update to new items after exit completes + setDisplayItems(items); + + // Reset animation tracking for new items + animatedItemsRef.current = new Set(); + visibleItemOrderRef.current = new Map(); + setInitiallyVisibleItems(new Set()); + setHasCapturedInitialItems(false); + setIsTransitioning(false); + }; + + void exitAnimation(); + } else { + // No listId change — just update displayItems immediately without animation + setDisplayItems(items); + } + + prevListIdRef.current = listId; + }, [listId, items, animate, customAnimation]); + + // Capture initially visible items for stagger animation + const captureVisibleItems = (virtualRows: { index: number }[]) => { + if (hasCapturedInitialItems || columns === 1 || isTransitioning) return; + + const visibleIds = new Set(); + const itemOrder = new Map(); + let visibleIndex = 0; + + virtualRows.forEach((row) => { + const startIndex = row.index * columns; + for (let i = 0; i < columns; i++) { + const itemIndex = startIndex + i; + if (itemIndex < displayItems.length) { + const itemId = displayItems[itemIndex]!.id; + visibleIds.add(itemId); + itemOrder.set(itemId, visibleIndex++); + } + } + }); + + if (visibleIds.size > 0) { + setInitiallyVisibleItems(visibleIds); + visibleItemOrderRef.current = itemOrder; + setHasCapturedInitialItems(true); + } + }; + + /** + * Should this item animate? + * If it's initially visible and hasn't animated yet, we animate it and mark it as animated. + * Also skips if this listId has already animated before (DnD remount protection). + */ + const shouldAnimateItem = (id: number) => { + if (!isTrueFirstRender.current) return false; + const should = + initiallyVisibleItems.has(id) && !animatedItemsRef.current.has(id); + if (should) animatedItemsRef.current.add(id); + return should; + }; + + /** + * Regardless of the number of items that are being animated, + * we want the animation to finish in ANIMATION_TOTAL_DURATION + * seconds. This helps make the stagger effect more consistent. + */ + const getItemDelay = (id: number) => { + const visibleOrder = visibleItemOrderRef.current.get(id) ?? 0; + const totalVisibleCount = initiallyVisibleItems.size; + return totalVisibleCount > 1 + ? (ANIMATION_TOTAL_DURATION * visibleOrder) / (totalVisibleCount - 1) + : 0; + }; + + /** + * Get animation variants for an item. + * Returns custom variants if provided, otherwise returns default variants. + */ + const getItemVariants = () => { + if (customAnimation?.itemVariants) { + return customAnimation.itemVariants; + } + + // Default variants + return { + initial: { opacity: 0, y: '100%' }, + animate: { opacity: 1, y: '0%' }, + exit: { opacity: 0, y: '-100%' }, + }; + }; + + return { + displayItems, + isTransitioning, + scope, + animate, + shouldAnimateItem, + getItemDelay, + captureVisibleItems, + getItemVariants, + }; +} diff --git a/package.json b/package.json index fb69318d6..3de9813d4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", @@ -48,8 +49,9 @@ "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-form": "^1.12.3", "@tanstack/react-table": "^8.21.3", - "@xmldom/xmldom": "^0.9.8", + "@tanstack/react-virtual": "^3.13.12", "@uploadthing/react": "^7.3.2", + "@xmldom/xmldom": "^0.9.8", "animejs": "^2.2.0", "archiver": "^7.0.1", "async": "^3.2.6", @@ -111,12 +113,13 @@ "zustand": "^5.0.6" }, "devDependencies": { + "@faker-js/faker": "^9.9.0", "@prisma/client": "^6.10.0", - "@storybook/addon-docs": "^9.0.17", - "@storybook/addon-links": "^9.0.17", - "@storybook/addon-onboarding": "^9.0.17", - "@storybook/nextjs": "^9.0.17", - "@storybook/react": "^9.0.17", + "@storybook/addon-a11y": "^9.1.1", + "@storybook/addon-docs": "^9.1.1", + "@storybook/addon-links": "^9.1.1", + "@storybook/addon-onboarding": "^9.1.1", + "@storybook/nextjs": "^9.1.1", "@t3-oss/env-nextjs": "^0.13.8", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/container-queries": "^0.1.1", @@ -147,7 +150,7 @@ "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^4.4.3", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-storybook": "^9.0.17", + "eslint-plugin-storybook": "^9.1.1", "jest": "^30.0.2", "jsdom": "^26.1.0", "knip": "^5.61.2", @@ -155,11 +158,11 @@ "prettier-plugin-tailwindcss": "^0.6.13", "prisma": "^6.10.0", "sass": "^1.89.2", - "storybook": "^9.0.17", + "storybook": "^9.1.1", "tailwindcss": "4.1.11", "tailwindcss-animate": "^1.0.7", "typescript": "5.8.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87f2a6865..7e47858ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-direction': + specifier: ^1.1.1 + version: 1.1.1(@types/react@18.3.18)(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 version: 2.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -83,6 +86,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@uploadthing/react': specifier: ^7.3.2 version: 7.3.2(next@14.2.30(@babel/core@7.27.4)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2))(react@18.3.1)(uploadthing@7.7.2(next@14.2.30(@babel/core@7.27.4)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2))(tailwindcss@4.1.11)) @@ -267,24 +273,27 @@ importers: specifier: ^5.0.6 version: 5.0.6(@types/react@18.3.18)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)) devDependencies: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 '@prisma/client': specifier: ^6.10.0 version: 6.10.0(prisma@6.10.0(typescript@5.8.3))(typescript@5.8.3) + '@storybook/addon-a11y': + specifier: ^9.1.1 + version: 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) '@storybook/addon-docs': - specifier: ^9.0.17 - version: 9.0.18(@types/react@18.3.18)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + specifier: ^9.1.1 + version: 9.1.1(@types/react@18.3.18)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) '@storybook/addon-links': - specifier: ^9.0.17 - version: 9.0.18(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + specifier: ^9.1.1 + version: 9.1.1(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) '@storybook/addon-onboarding': - specifier: ^9.0.17 - version: 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + specifier: ^9.1.1 + version: 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) '@storybook/nextjs': - specifier: ^9.0.17 - version: 9.0.18(esbuild@0.25.5)(next@14.2.30(@babel/core@7.27.4)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(type-fest@2.19.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.100.2(esbuild@0.25.5)) - '@storybook/react': - specifier: ^9.0.17 - version: 9.0.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3) + specifier: ^9.1.1 + version: 9.1.1(esbuild@0.25.5)(next@14.2.30(@babel/core@7.27.4)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(type-fest@2.19.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.100.2(esbuild@0.25.5)) '@t3-oss/env-nextjs': specifier: ^0.13.8 version: 0.13.8(arktype@2.1.20)(typescript@5.8.3)(zod@3.25.67) @@ -376,8 +385,8 @@ importers: specifier: ^2.31.0 version: 2.32.0(@typescript-eslint/parser@8.34.1(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@8.57.1) eslint-plugin-storybook: - specifier: ^9.0.17 - version: 9.0.18(eslint@8.57.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3) + specifier: ^9.1.1 + version: 9.1.1(eslint@8.57.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3) jest: specifier: ^30.0.2 version: 30.0.2(@types/node@22.13.9)(esbuild-register@3.6.0(esbuild@0.25.5))(ts-node@10.9.2(@types/node@22.13.9)(typescript@5.8.3)) @@ -400,8 +409,8 @@ importers: specifier: ^1.89.2 version: 1.89.2 storybook: - specifier: ^9.0.17 - version: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + specifier: ^9.1.1 + version: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) tailwindcss: specifier: 4.1.11 version: 4.1.11 @@ -1356,6 +1365,10 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@floating-ui/core@1.7.0': resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==} @@ -2622,43 +2635,48 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@storybook/addon-docs@9.0.18': - resolution: {integrity: sha512-1mLhaRDx8s1JAF51o56OmwMnIsg4BOQJ8cn+4wbMjh14pDFALrovlFl/BpAXnV1VaZqHjCB4ZWuP+y5CwXEpeQ==} + '@storybook/addon-a11y@9.1.1': + resolution: {integrity: sha512-ZCKxYQmHnisAdpjYeRRD41NfA5UlTFpej0xgGLiAc9PGz264RRP5B+pZUHHNIyEKA9JCDcyc4BPe+xnXZgDjSA==} + peerDependencies: + storybook: ^9.1.1 + + '@storybook/addon-docs@9.1.1': + resolution: {integrity: sha512-CzgvTy3V5X4fe+VPkiZVwPKARlpEBDAKte8ajLAlHJQLFpADdYrBRQ0se6I+kcxva7rZQzdhuH7qjXMDRVcfnw==} peerDependencies: - storybook: ^9.0.18 + storybook: ^9.1.1 - '@storybook/addon-links@9.0.18': - resolution: {integrity: sha512-4Xs/ObvjLQXyrixgxYCleSdcNqKWvncSmNxerib/h1tOPSry7NgJV2oCIc5DLZslolKKqAkAZmgWBY6Qz5Gysw==} + '@storybook/addon-links@9.1.1': + resolution: {integrity: sha512-fYv0cmUzZluEKFP8iuhqu8Wqlf3demRgES7un1C6T7GilzhnMwLFcFjX40qvTC9WsIJ2Uw1G/SPjwTNXJOO5Ng==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.0.18 + storybook: ^9.1.1 peerDependenciesMeta: react: optional: true - '@storybook/addon-onboarding@9.0.18': - resolution: {integrity: sha512-A079BfJ3g3wYOtAuq9cPf2l6JHo+6UzEw1A2AbSNBBNP4hKfXpHcLadIVwuyOxuKjDUWzY5f4dJa3hCMurHXGQ==} + '@storybook/addon-onboarding@9.1.1': + resolution: {integrity: sha512-QHkirYjgHqhJMmD2riVJ4qvkwNif79phEJuMd+Zna2niT5MMkmFSd+lfLgbEYr6ufbL2OWnsoUmxRY5s3RgKQw==} peerDependencies: - storybook: ^9.0.18 + storybook: ^9.1.1 - '@storybook/builder-webpack5@9.0.18': - resolution: {integrity: sha512-EXJKzfb9ZsRrViUYVmrAJZtr1PfEbXwxtgIgFclwC9rSodfYUjBJjFCJbcLW/5x6+uXiABo9PZk/wgWMLqFHrA==} + '@storybook/builder-webpack5@9.1.1': + resolution: {integrity: sha512-4yAF0KHgwqtsiBcgu3FEmctmk3kYALry+YCxi8nLKxi5Qh0laiR7NBKnZ7PsQ5545rAAkGTRu7axYn7y4Dg6jg==} peerDependencies: - storybook: ^9.0.18 + storybook: ^9.1.1 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@storybook/core-webpack@9.0.18': - resolution: {integrity: sha512-tVX4+Xvp66wSrddHRO4eS3XC4JysiIa/Zny4qKO5TJvyBrUI74i3OIiy1r+98X4DRwi+6gbwvpp4dGK0LMbYsw==} + '@storybook/core-webpack@9.1.1': + resolution: {integrity: sha512-z/SFjtZWiKkW66NzKikIGGCnuB3otqL1Q/XrX3AcAAU3UoRXD2cykRC0LwRHF8byxoQe1ZMg5L7/pibMNKORDg==} peerDependencies: - storybook: ^9.0.18 + storybook: ^9.1.1 - '@storybook/csf-plugin@9.0.18': - resolution: {integrity: sha512-MQ3WwXnMua5sX0uYyuO7dC5WOWuJCLqf8CsOn3zQ2ptNoH6hD7DFx5ZOa1uD6VxIuJ3LkA+YqfSRBncomJoRnA==} + '@storybook/csf-plugin@9.1.1': + resolution: {integrity: sha512-MwdtvzzFpkard06pCfDrgRXZiBfWAQICdKh7kzpv1L8SwewsRgUr5WZQuEAVfYdSvCFJbWnNN4KirzPhe5ENCg==} peerDependencies: - storybook: ^9.0.18 + storybook: ^9.1.1 '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -2670,14 +2688,14 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - '@storybook/nextjs@9.0.18': - resolution: {integrity: sha512-Po/PlA6wv6NALSUliM+byIgaH1bt/JjwO5+FQvaeFgez+0T76wgyCDr2PS1nwZpZiPLEYarnL/dMgkaf7nSGig==} + '@storybook/nextjs@9.1.1': + resolution: {integrity: sha512-+7jRAMF38MTi7v+K1+rnnRsgmA91iIdr3QwrN1MoO1ph9Nx9pmiIOwdd7XdjXHJyrc0XmujQND4Iz3fMtgeq+w==} engines: {node: '>=20.0.0'} peerDependencies: next: ^14.1.0 || ^15.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.0.18 + storybook: ^9.1.1 typescript: '*' webpack: ^5.0.0 peerDependenciesMeta: @@ -2686,13 +2704,13 @@ packages: webpack: optional: true - '@storybook/preset-react-webpack@9.0.18': - resolution: {integrity: sha512-fPp9LXNXcCdnuQcePVJmfKpPTxjNHcjkpn8Us76HaRDbwUqeV2REegI/CWYCW4k40eVAbcaKN5/aURpP+PDvTw==} + '@storybook/preset-react-webpack@9.1.1': + resolution: {integrity: sha512-dfCiNubGUpVnMjtL+1benBcBAAoLlNxSw3yKz+TULl5fV6aLoiAqoazQPaRA9WwMTFlH+1iW7Q3PEBNEs9Mk5g==} engines: {node: '>=20.0.0'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.0.18 + storybook: ^9.1.1 typescript: '*' peerDependenciesMeta: typescript: @@ -2704,20 +2722,20 @@ packages: typescript: '>= 4.x' webpack: '>= 4' - '@storybook/react-dom-shim@9.0.18': - resolution: {integrity: sha512-qGR/d9x9qWRRxITaBVQkMnb73kwOm+N8fkbZRxc7U4lxupXRvkMIDh247nn71SYVBnvbh6//AL7P6ghiPWZYjA==} + '@storybook/react-dom-shim@9.1.1': + resolution: {integrity: sha512-L+HCOXvOP+PwKrVS8od9aF+F4hO7zA0Nt1vnpbg2LeAHCxYghrjFVtioe7gSlzrlYdozQrPLY98a4OkDB7KGrw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.0.18 + storybook: ^9.1.1 - '@storybook/react@9.0.18': - resolution: {integrity: sha512-CCH6Vj/O6I07PrhCHxc1pvCWYMfZhRzK7CVHAtrBP9xxnYA7OoXhM2wymuDogml5HW1BKtyVMeQ3oWZXFNgDXQ==} + '@storybook/react@9.1.1': + resolution: {integrity: sha512-F5vRFxDf1fzM6CG88olrzEH03iP6C1YAr4/nr5bkLNs6TNm9Hh7KmRVG2jFtoy5w9uCwbQ9RdY+TrRbBI7n67g==} engines: {node: '>=20.0.0'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^9.0.18 + storybook: ^9.1.1 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: @@ -2899,6 +2917,12 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/store@0.7.2': resolution: {integrity: sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==} @@ -2906,6 +2930,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -4474,12 +4501,12 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-storybook@9.0.18: - resolution: {integrity: sha512-f2FnWjTQkM9kYtbpChVuEo8F04QATBiuxYUdSBR58lWb3NprPKBfmRZC1dTA5NVeLY6geXduDLIPXefwXFz6Ag==} + eslint-plugin-storybook@9.1.1: + resolution: {integrity: sha512-g4/i9yW6cl4TCEMzYyALNvO3d/jB6TDvSs/Pmye7dHDrra2B7dgZJGzmEWILD62brVrLVHNoXgy2dNPtx80kmw==} engines: {node: '>=20.0.0'} peerDependencies: eslint: '>=8' - storybook: ^9.0.18 + storybook: ^9.1.1 eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} @@ -7002,8 +7029,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@9.0.18: - resolution: {integrity: sha512-ruxpEpizwoYQTt1hBOrWyp9trPYWD9Apt1TJ37rs1rzmNQWpSNGJDMg91JV4mUhBChzRvnid/oRBFFCWJz/dfw==} + storybook@9.1.1: + resolution: {integrity: sha512-q6GaGZdVZh6rjOdGnc+4hGTu8ECyhyjQDw4EZNxKtQjDO8kqtuxbFm8l/IP2l+zLVJAatGWKkaX9Qcd7QZxz+Q==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -7932,7 +7959,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.27.6 + '@babel/types': 7.28.1 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -7950,7 +7977,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.27.4) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.0 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -7977,8 +8004,8 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.6 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.1 transitivePeerDependencies: - supports-color @@ -8000,7 +8027,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.27.6 + '@babel/types': 7.28.1 '@babel/helper-plugin-utils@7.27.1': {} @@ -8009,7 +8036,7 @@ snapshots: '@babel/core': 7.27.4 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.0 transitivePeerDependencies: - supports-color @@ -8024,8 +8051,8 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.6 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.1 transitivePeerDependencies: - supports-color @@ -8038,8 +8065,8 @@ snapshots: '@babel/helper-wrap-function@7.27.1': dependencies: '@babel/template': 7.27.2 - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.6 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.1 transitivePeerDependencies: - supports-color @@ -8060,7 +8087,7 @@ snapshots: dependencies: '@babel/core': 7.27.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.0 transitivePeerDependencies: - supports-color @@ -8087,7 +8114,7 @@ snapshots: dependencies: '@babel/core': 7.27.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.0 transitivePeerDependencies: - supports-color @@ -8324,7 +8351,7 @@ snapshots: '@babel/core': 7.27.4 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.0 transitivePeerDependencies: - supports-color @@ -8370,7 +8397,7 @@ snapshots: '@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.4) '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.0 transitivePeerDependencies: - supports-color @@ -8491,7 +8518,7 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.4) - '@babel/types': 7.27.6 + '@babel/types': 7.28.1 transitivePeerDependencies: - supports-color @@ -8671,7 +8698,7 @@ snapshots: dependencies: '@babel/core': 7.27.4 '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.27.6 + '@babel/types': 7.28.1 esutils: 2.0.3 '@babel/preset-react@7.27.1(@babel/core@7.27.4)': @@ -8927,6 +8954,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@faker-js/faker@9.9.0': {} + '@floating-ui/core@1.7.0': dependencies: '@floating-ui/utils': 0.2.9 @@ -10138,33 +10167,39 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@storybook/addon-docs@9.0.18(@types/react@18.3.18)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))': + '@storybook/addon-a11y@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': + dependencies: + '@storybook/global': 5.0.0 + axe-core: 4.10.3 + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) + + '@storybook/addon-docs@9.1.1(@types/react@18.3.18)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': dependencies: '@mdx-js/react': 3.1.0(@types/react@18.3.18)(react@18.3.1) - '@storybook/csf-plugin': 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + '@storybook/csf-plugin': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) '@storybook/icons': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/react-dom-shim': 9.0.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + '@storybook/react-dom-shim': 9.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-links@9.0.18(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))': + '@storybook/addon-links@9.1.1(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': dependencies: '@storybook/global': 5.0.0 - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) optionalDependencies: react: 18.3.1 - '@storybook/addon-onboarding@9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))': + '@storybook/addon-onboarding@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': dependencies: - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) - '@storybook/builder-webpack5@9.0.18(esbuild@0.25.5)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3)': + '@storybook/builder-webpack5@9.1.1(esbuild@0.25.5)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3)': dependencies: - '@storybook/core-webpack': 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + '@storybook/core-webpack': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) case-sensitive-paths-webpack-plugin: 2.4.0 cjs-module-lexer: 1.4.3 css-loader: 6.11.0(webpack@5.100.2(esbuild@0.25.5)) @@ -10172,7 +10207,7 @@ snapshots: fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.8.3)(webpack@5.100.2(esbuild@0.25.5)) html-webpack-plugin: 5.6.3(webpack@5.100.2(esbuild@0.25.5)) magic-string: 0.30.17 - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) style-loader: 3.3.4(webpack@5.100.2(esbuild@0.25.5)) terser-webpack-plugin: 5.3.14(esbuild@0.25.5)(webpack@5.100.2(esbuild@0.25.5)) ts-dedent: 2.2.0 @@ -10189,14 +10224,14 @@ snapshots: - uglify-js - webpack-cli - '@storybook/core-webpack@9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))': + '@storybook/core-webpack@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': dependencies: - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) ts-dedent: 2.2.0 - '@storybook/csf-plugin@9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))': + '@storybook/csf-plugin@9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': dependencies: - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) unplugin: 1.16.1 '@storybook/global@5.0.0': {} @@ -10206,7 +10241,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/nextjs@9.0.18(esbuild@0.25.5)(next@14.2.30(@babel/core@7.27.4)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(type-fest@2.19.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.100.2(esbuild@0.25.5))': + '@storybook/nextjs@9.1.1(esbuild@0.25.5)(next@14.2.30(@babel/core@7.27.4)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.89.2)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(type-fest@2.19.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.100.2(esbuild@0.25.5))': dependencies: '@babel/core': 7.27.4 '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.27.4) @@ -10222,9 +10257,9 @@ snapshots: '@babel/preset-typescript': 7.27.1(@babel/core@7.27.4) '@babel/runtime': 7.26.10 '@pmmmwh/react-refresh-webpack-plugin': 0.5.17(react-refresh@0.14.2)(type-fest@2.19.0)(webpack-hot-middleware@2.26.1)(webpack@5.100.2(esbuild@0.25.5)) - '@storybook/builder-webpack5': 9.0.18(esbuild@0.25.5)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3) - '@storybook/preset-react-webpack': 9.0.18(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3) - '@storybook/react': 9.0.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3) + '@storybook/builder-webpack5': 9.1.1(esbuild@0.25.5)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3) + '@storybook/preset-react-webpack': 9.1.1(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3) + '@storybook/react': 9.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3) '@types/semver': 7.7.0 babel-loader: 9.2.1(@babel/core@7.27.4)(webpack@5.100.2(esbuild@0.25.5)) css-loader: 6.11.0(webpack@5.100.2(esbuild@0.25.5)) @@ -10240,7 +10275,7 @@ snapshots: resolve-url-loader: 5.0.0 sass-loader: 16.0.5(sass@1.89.2)(webpack@5.100.2(esbuild@0.25.5)) semver: 7.7.2 - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) style-loader: 3.3.4(webpack@5.100.2(esbuild@0.25.5)) styled-jsx: 5.1.7(@babel/core@7.27.4)(react@18.3.1) tsconfig-paths: 4.2.0 @@ -10266,9 +10301,9 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@storybook/preset-react-webpack@9.0.18(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3)': + '@storybook/preset-react-webpack@9.1.1(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3)': dependencies: - '@storybook/core-webpack': 9.0.18(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + '@storybook/core-webpack': 9.1.1(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.100.2(esbuild@0.25.5)) '@types/semver': 7.7.0 find-up: 7.0.0 @@ -10278,7 +10313,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) resolve: 1.22.10 semver: 7.7.2 - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) tsconfig-paths: 4.2.0 webpack: 5.100.2(esbuild@0.25.5) optionalDependencies: @@ -10304,19 +10339,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react-dom-shim@9.0.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))': + '@storybook/react-dom-shim@9.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) - '@storybook/react@9.0.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3)': + '@storybook/react@9.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.0.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0)) + '@storybook/react-dom-shim': 9.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) optionalDependencies: typescript: 5.8.3 @@ -10461,10 +10496,18 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-virtual@3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@tanstack/store@0.7.2': {} '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.13.12': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 @@ -11694,7 +11737,7 @@ snapshots: dependencies: cipher-base: 1.0.6 inherits: 2.0.4 - ripemd160: 2.0.1 + ripemd160: 2.0.2 sha.js: 2.4.12 create-hash@1.2.0: @@ -12357,11 +12400,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.0.18(eslint@8.57.1)(storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0))(typescript@5.8.3): + eslint-plugin-storybook@9.1.1(eslint@8.57.1)(storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)))(typescript@5.8.3): dependencies: '@typescript-eslint/utils': 8.34.1(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 - storybook: 9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0) + storybook: 9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) transitivePeerDependencies: - supports-color - typescript @@ -14894,8 +14937,8 @@ snapshots: react-docgen@7.1.1: dependencies: '@babel/core': 7.27.4 - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.6 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.1 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.7 '@types/doctrine': 0.0.9 @@ -15533,12 +15576,13 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@9.0.18(@testing-library/dom@10.4.0)(prettier@3.6.0): + storybook@9.1.1(@testing-library/dom@10.4.0)(prettier@3.6.0)(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.6.3 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@22.13.9)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.0)) '@vitest/spy': 3.2.4 better-opn: 3.0.2 esbuild: 0.25.5 @@ -15551,8 +15595,10 @@ snapshots: transitivePeerDependencies: - '@testing-library/dom' - bufferutil + - msw - supports-color - utf-8-validate + - vite stream-browserify@3.0.0: dependencies: @@ -16300,7 +16346,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.25.0 + browserslist: 4.25.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.1 es-module-lexer: 1.7.0 diff --git a/styles/globals.css b/styles/globals.css index fdeb0a9ff..f7fccba2e 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -462,4 +462,9 @@ em { @apply italic; } + + /* Roving focus children items */ + .focused-children > * { + @apply ring-offset-background ring-accent ring-2 ring-offset-2 outline-none; + } }