pika-ux 1.0.0-beta.7 โ†’ 1.0.0-beta.9

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.
@@ -0,0 +1,192 @@
1
+ # ScrollableTabs Component
2
+
3
+ A Svelte 5 component for creating scrollable tab interfaces with support for pinned tabs and an add button.
4
+
5
+ ## Features
6
+
7
+ - **Pinned Tabs**: Non-scrollable tabs on the left that remain visible
8
+ - **Scrollable Section**: Horizontally scrollable tabs in the center
9
+ - **Add Button**: Persistent "+ Add" button on the right
10
+ - **Tailwind Styled**: Uses Tailwind CSS classes exclusively
11
+ - **Svelte 5**: Built with Svelte 5 runes ($state, $derived, $effect)
12
+ - **Keyboard Accessible**: Full keyboard navigation support
13
+
14
+ ## Components
15
+
16
+ - `ScrollableTabs.Root` - Main container (provides context)
17
+ - `ScrollableTabs.List` - Tab bar container
18
+ - `ScrollableTabs.PinnedSection` - Container for pinned tabs
19
+ - `ScrollableTabs.ScrollableSection` - Container for scrollable tabs
20
+ - `ScrollableTabs.PinnedTrigger` - Individual pinned tab
21
+ - `ScrollableTabs.Trigger` - Individual scrollable tab
22
+ - `ScrollableTabs.AddButton` - Add new tab button
23
+ - `ScrollableTabs.Content` - Content area for each tab
24
+
25
+ ## Basic Usage
26
+
27
+ ```svelte
28
+ <script lang="ts">
29
+ import * as ScrollableTabs from 'pika-ux/pika/scrollable-tabs';
30
+
31
+ let activeTab = $state('dashboard');
32
+
33
+ function handleAddTab() {
34
+ console.log('Add new tab clicked');
35
+ }
36
+ </script>
37
+
38
+ <ScrollableTabs.Root bind:value={activeTab}>
39
+ <ScrollableTabs.List>
40
+ <!-- Pinned tabs on the left -->
41
+ <ScrollableTabs.PinnedSection>
42
+ <ScrollableTabs.PinnedTrigger value="dashboard">
43
+ ๐Ÿ“Š Dashboard
44
+ </ScrollableTabs.PinnedTrigger>
45
+ <ScrollableTabs.PinnedTrigger value="quick-actions">
46
+ โšก Quick Actions
47
+ </ScrollableTabs.PinnedTrigger>
48
+ </ScrollableTabs.PinnedSection>
49
+
50
+ <!-- Scrollable tabs in the middle -->
51
+ <ScrollableTabs.ScrollableSection>
52
+ <ScrollableTabs.Trigger value="trends">
53
+ ๐Ÿ“ˆ Trends
54
+ </ScrollableTabs.Trigger>
55
+ <ScrollableTabs.Trigger value="notifications">
56
+ ๐Ÿ”” Notifications
57
+ </ScrollableTabs.Trigger>
58
+ <ScrollableTabs.Trigger value="settings">
59
+ ๐Ÿ› ๏ธ Settings
60
+ </ScrollableTabs.Trigger>
61
+ <ScrollableTabs.Trigger value="experiments">
62
+ ๐Ÿงช Experiments
63
+ </ScrollableTabs.Trigger>
64
+ </ScrollableTabs.ScrollableSection>
65
+
66
+ <!-- Add button on the right -->
67
+ <ScrollableTabs.AddButton onclick={handleAddTab} />
68
+ </ScrollableTabs.List>
69
+
70
+ <!-- Tab content -->
71
+ <ScrollableTabs.Content value="dashboard">
72
+ <div class="p-6">
73
+ <h2 class="text-xl font-semibold mb-2">๐Ÿ“Š Dashboard</h2>
74
+ <p>Dashboard content goes here...</p>
75
+ </div>
76
+ </ScrollableTabs.Content>
77
+
78
+ <ScrollableTabs.Content value="quick-actions">
79
+ <div class="p-6">
80
+ <h2 class="text-xl font-semibold mb-2">โšก Quick Actions</h2>
81
+ <p>Quick actions content goes here...</p>
82
+ </div>
83
+ </ScrollableTabs.Content>
84
+
85
+ <!-- More content sections... -->
86
+ </ScrollableTabs.Root>
87
+ ```
88
+
89
+ ## Advanced Usage
90
+
91
+ ### Custom Add Button
92
+
93
+ ```svelte
94
+ <ScrollableTabs.AddButton onclick={handleAddTab}>
95
+ <span class="text-sm">+ New</span>
96
+ </ScrollableTabs.AddButton>
97
+ ```
98
+
99
+ ### Custom Styling
100
+
101
+ ```svelte
102
+ <ScrollableTabs.Root value={activeTab} class="max-w-5xl mx-auto">
103
+ <ScrollableTabs.List class="border-b-2">
104
+ <ScrollableTabs.PinnedSection class="bg-blue-50">
105
+ <!-- Pinned tabs -->
106
+ </ScrollableTabs.PinnedSection>
107
+
108
+ <ScrollableTabs.ScrollableSection class="bg-gray-50">
109
+ <!-- Scrollable tabs -->
110
+ </ScrollableTabs.ScrollableSection>
111
+ </ScrollableTabs.List>
112
+ </ScrollableTabs.Root>
113
+ ```
114
+
115
+ ### Disabled Tabs
116
+
117
+ ```svelte
118
+ <ScrollableTabs.Trigger value="disabled-tab" disabled>
119
+ Disabled Tab
120
+ </ScrollableTabs.Trigger>
121
+ ```
122
+
123
+ ### Controlled State
124
+
125
+ ```svelte
126
+ <script lang="ts">
127
+ let activeTab = $state('dashboard');
128
+
129
+ function handleValueChange(newValue: string) {
130
+ console.log('Tab changed to:', newValue);
131
+ // Perform additional logic here
132
+ }
133
+ </script>
134
+
135
+ <ScrollableTabs.Root
136
+ bind:value={activeTab}
137
+ onValueChange={handleValueChange}
138
+ >
139
+ <!-- ... -->
140
+ </ScrollableTabs.Root>
141
+ ```
142
+
143
+ ## Props
144
+
145
+ ### Root
146
+
147
+ - `value` (string, bindable) - Currently active tab value
148
+ - `onValueChange` (function, optional) - Callback when tab changes
149
+ - `class` (string, optional) - Additional CSS classes
150
+
151
+ ### List
152
+
153
+ - `class` (string, optional) - Additional CSS classes
154
+
155
+ ### PinnedSection / ScrollableSection
156
+
157
+ - `class` (string, optional) - Additional CSS classes
158
+
159
+ ### Trigger / PinnedTrigger
160
+
161
+ - `value` (string, required) - Tab identifier
162
+ - `disabled` (boolean, optional) - Disable tab interaction
163
+ - `class` (string, optional) - Additional CSS classes
164
+
165
+ ### AddButton
166
+
167
+ - `onclick` (function, optional) - Click handler
168
+ - `disabled` (boolean, optional) - Disable button
169
+ - `class` (string, optional) - Additional CSS classes
170
+
171
+ ### Content
172
+
173
+ - `value` (string, required) - Tab identifier to match
174
+ - `class` (string, optional) - Additional CSS classes
175
+
176
+ ## Styling
177
+
178
+ The component uses Tailwind CSS and respects your theme's color variables:
179
+
180
+ - `bg-background`, `text-foreground` - Main background/text
181
+ - `bg-muted`, `text-muted-foreground` - Muted sections
182
+ - `bg-primary`, `text-primary` - Primary color accents
183
+ - `ring-ring` - Focus ring color
184
+
185
+ The scrollable section hides scrollbars by default for a cleaner look.
186
+
187
+ ## Accessibility
188
+
189
+ - Full keyboard navigation support
190
+ - Focus management with visible focus rings
191
+ - ARIA-compliant button roles
192
+ - Disabled state support
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../shadcn/utils.js';
4
+
5
+ interface Props {
6
+ onclick?: () => void;
7
+ class?: string;
8
+ disabled?: boolean;
9
+ children?: Snippet;
10
+ }
11
+
12
+ let { onclick, class: className, disabled = false, children }: Props = $props();
13
+ </script>
14
+
15
+ <div class={cn('flex-shrink-0 px-2', className)}>
16
+ <button
17
+ type="button"
18
+ {onclick}
19
+ {disabled}
20
+ class={cn(
21
+ 'px-3 py-2 rounded-full transition-all',
22
+ 'bg-muted hover:bg-muted/80 text-foreground',
23
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
24
+ 'disabled:pointer-events-none disabled:opacity-50'
25
+ )}
26
+ >
27
+ {#if children}
28
+ {@render children()}
29
+ {:else}
30
+ ๏ผ‹
31
+ {/if}
32
+ </button>
33
+ </div>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { getScrollableTabsContext } from './context.svelte.js';
4
+ import { cn } from '../../shadcn/utils.js';
5
+
6
+ interface Props {
7
+ value: string;
8
+ class?: string;
9
+ children: Snippet;
10
+ }
11
+
12
+ let { value, class: className, children }: Props = $props();
13
+
14
+ const context = getScrollableTabsContext();
15
+
16
+ const isActive = $derived(context.getValue() === value);
17
+ </script>
18
+
19
+ {#if isActive}
20
+ <div class={cn('mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}>
21
+ {@render children()}
22
+ </div>
23
+ {/if}
@@ -0,0 +1,23 @@
1
+ import { getContext, setContext } from 'svelte';
2
+
3
+ const SCROLLABLE_TABS_CONTEXT_KEY = Symbol('SCROLLABLE_TABS_CONTEXT');
4
+
5
+ export interface ScrollableTabsContext {
6
+ getValue: () => string;
7
+ onValueChange: (value: string) => void;
8
+ onClose?: (value: string) => void;
9
+ onPin?: (value: string) => void;
10
+ onUnpin?: (value: string) => void;
11
+ }
12
+
13
+ export function setScrollableTabsContext(context: ScrollableTabsContext) {
14
+ setContext(SCROLLABLE_TABS_CONTEXT_KEY, context);
15
+ }
16
+
17
+ export function getScrollableTabsContext(): ScrollableTabsContext {
18
+ const context = getContext<ScrollableTabsContext>(SCROLLABLE_TABS_CONTEXT_KEY);
19
+ if (!context) {
20
+ throw new Error('ScrollableTabs context not found. Make sure components are used within ScrollableTabs.Root');
21
+ }
22
+ return context;
23
+ }
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ import * as ScrollableTabs from './index.js';
3
+
4
+ let activeTab = $state('dashboard');
5
+
6
+ function handleAddTab() {
7
+ console.log('Add new tab clicked');
8
+ // Add logic to create a new tab
9
+ }
10
+
11
+ const tabs = [
12
+ { value: 'trends', label: '๐Ÿ“ˆ Trends', content: 'Trends analytics and data visualization' },
13
+ { value: 'notifications', label: '๐Ÿ”” Notifications', content: 'Recent notifications and alerts' },
14
+ { value: 'settings', label: '๐Ÿ› ๏ธ Settings', content: 'Application settings and preferences' },
15
+ { value: 'experiments', label: '๐Ÿงช Experiments', content: 'Beta features and experiments' },
16
+ { value: 'integrations', label: '๐Ÿ“ฆ Integrations', content: 'Third-party integrations' },
17
+ { value: 'analytics', label: '๐Ÿ“Š Analytics', content: 'Detailed analytics and reports' },
18
+ { value: 'users', label: '๐Ÿ‘ฅ Users', content: 'User management' },
19
+ { value: 'logs', label: '๐Ÿ“ Logs', content: 'System logs and audit trail' }
20
+ ];
21
+ </script>
22
+
23
+ <div class="w-full max-w-5xl mx-auto p-8">
24
+ <h1 class="text-2xl font-bold mb-6">ScrollableTabs Example</h1>
25
+
26
+ <ScrollableTabs.Root bind:value={activeTab}>
27
+ <ScrollableTabs.List>
28
+ <!-- Pinned tabs on the left -->
29
+ <ScrollableTabs.PinnedSection>
30
+ <ScrollableTabs.PinnedTrigger value="dashboard">๐Ÿ“Š Dashboard</ScrollableTabs.PinnedTrigger>
31
+ <ScrollableTabs.PinnedTrigger value="quick-actions">โšก Quick Actions</ScrollableTabs.PinnedTrigger>
32
+ </ScrollableTabs.PinnedSection>
33
+
34
+ <!-- Scrollable tabs in the middle -->
35
+ <ScrollableTabs.ScrollableSection>
36
+ {#each tabs as tab}
37
+ <ScrollableTabs.Trigger value={tab.value}>
38
+ {tab.label}
39
+ </ScrollableTabs.Trigger>
40
+ {/each}
41
+ </ScrollableTabs.ScrollableSection>
42
+
43
+ <!-- Add button on the right -->
44
+ <ScrollableTabs.AddButton onclick={handleAddTab} />
45
+ </ScrollableTabs.List>
46
+
47
+ <!-- Tab content -->
48
+ <ScrollableTabs.Content value="dashboard">
49
+ <div class="p-6 bg-white border rounded-md min-h-[300px]">
50
+ <h2 class="text-xl font-semibold mb-2">๐Ÿ“Š Dashboard Widget</h2>
51
+ <p class="text-muted-foreground">This is the main dashboard. It contains key metrics and quick access to important features.</p>
52
+ </div>
53
+ </ScrollableTabs.Content>
54
+
55
+ <ScrollableTabs.Content value="quick-actions">
56
+ <div class="p-6 bg-white border rounded-md min-h-[300px]">
57
+ <h2 class="text-xl font-semibold mb-2">โšก Quick Actions</h2>
58
+ <p class="text-muted-foreground">Access frequently used actions and shortcuts here.</p>
59
+ </div>
60
+ </ScrollableTabs.Content>
61
+
62
+ {#each tabs as tab}
63
+ <ScrollableTabs.Content value={tab.value}>
64
+ <div class="p-6 bg-white border rounded-md min-h-[300px]">
65
+ <h2 class="text-xl font-semibold mb-2">{tab.label}</h2>
66
+ <p class="text-muted-foreground">
67
+ {tab.content}
68
+ </p>
69
+ </div>
70
+ </ScrollableTabs.Content>
71
+ {/each}
72
+ </ScrollableTabs.Root>
73
+
74
+ <!-- Display current active tab -->
75
+ <div class="mt-6 p-4 bg-muted rounded-md">
76
+ <p class="text-sm">
77
+ <strong>Active Tab:</strong>
78
+ {activeTab}
79
+ </p>
80
+ </div>
81
+ </div>
@@ -0,0 +1,31 @@
1
+ import Root from './root.svelte';
2
+ import List from './list.svelte';
3
+ import PinnedSection from './pinned-section.svelte';
4
+ import ScrollableSection from './scrollable-section.svelte';
5
+ import Trigger from './trigger.svelte';
6
+ import PinnedTrigger from './pinned-trigger.svelte';
7
+ import AddButton from './add-button.svelte';
8
+ import Content from './content.svelte';
9
+ import OverflowMenu from './overflow-menu.svelte';
10
+
11
+ export {
12
+ Root,
13
+ List,
14
+ PinnedSection,
15
+ ScrollableSection,
16
+ Trigger,
17
+ PinnedTrigger,
18
+ AddButton,
19
+ Content,
20
+ OverflowMenu,
21
+ //
22
+ Root as ScrollableTabs,
23
+ List as ScrollableTabsList,
24
+ PinnedSection as ScrollableTabsPinnedSection,
25
+ ScrollableSection as ScrollableTabsScrollableSection,
26
+ Trigger as ScrollableTabsTrigger,
27
+ PinnedTrigger as ScrollableTabsPinnedTrigger,
28
+ AddButton as ScrollableTabsAddButton,
29
+ Content as ScrollableTabsContent,
30
+ OverflowMenu as ScrollableTabsOverflowMenu
31
+ };
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../shadcn/utils.js';
4
+
5
+ interface Props {
6
+ class?: string;
7
+ children: Snippet;
8
+ }
9
+
10
+ let { class: className, children }: Props = $props();
11
+ </script>
12
+
13
+ <div class={cn('flex items-center rounded-md bg-muted/30', className)}>
14
+ {@render children()}
15
+ </div>
@@ -0,0 +1,119 @@
1
+ <script lang="ts">
2
+ import { getScrollableTabsContext } from './context.svelte.js';
3
+ import { cn } from '../../shadcn/utils.js';
4
+ import * as DropdownMenu from '../../shadcn/dropdown-menu/index.js';
5
+ import { Button } from '../../shadcn/button/index.js';
6
+ import MoreHorizontal from '$icons/lucide/ellipsis';
7
+ import Pin from '$icons/lucide/pin';
8
+ import PinOff from '$icons/lucide/pin-off';
9
+ import X from '$icons/lucide/x';
10
+
11
+ interface Tab {
12
+ value: string;
13
+ label: string;
14
+ isPinned: boolean;
15
+ }
16
+
17
+ interface Props {
18
+ tabs: Tab[];
19
+ class?: string;
20
+ }
21
+
22
+ let { tabs, class: className }: Props = $props();
23
+
24
+ const context = getScrollableTabsContext();
25
+
26
+ const activeValue = $derived(context.getValue());
27
+
28
+ // Separate pinned and unpinned tabs
29
+ const pinnedTabs = $derived(tabs.filter((t) => t.isPinned));
30
+ const unpinnedTabs = $derived(tabs.filter((t) => !t.isPinned));
31
+
32
+ function handleTabClick(value: string) {
33
+ context.onValueChange(value);
34
+ }
35
+
36
+ function handlePin(e: Event, value: string) {
37
+ e.stopPropagation();
38
+ context.onPin?.(value);
39
+ }
40
+
41
+ function handleUnpin(e: Event, value: string) {
42
+ e.stopPropagation();
43
+ context.onUnpin?.(value);
44
+ }
45
+
46
+ function handleClose(e: Event, value: string) {
47
+ e.stopPropagation();
48
+ context.onClose?.(value);
49
+ }
50
+ </script>
51
+
52
+ <div class={cn('flex-shrink-0', className)}>
53
+ <DropdownMenu.Root>
54
+ <DropdownMenu.Trigger>
55
+ <Button variant="ghost" size="icon" class="w-7">
56
+ <MoreHorizontal style="width: 1.2rem; height: 1.2rem;" />
57
+ </Button>
58
+ </DropdownMenu.Trigger>
59
+ <DropdownMenu.Content align="end" class="w-56">
60
+ {#if pinnedTabs.length > 0}
61
+ <DropdownMenu.Group>
62
+ <DropdownMenu.Label>Pinned</DropdownMenu.Label>
63
+ {#each pinnedTabs as tab}
64
+ <DropdownMenu.Item
65
+ onclick={() => handleTabClick(tab.value)}
66
+ class={cn('flex items-center justify-between gap-2', activeValue === tab.value && 'bg-accent')}
67
+ >
68
+ <span class="flex-1 truncate">{tab.label}</span>
69
+ <div class="flex items-center gap-1 flex-shrink-0">
70
+ {#if context.onUnpin}
71
+ <button type="button" onclick={(e) => handleUnpin(e, tab.value)} class="p-0.5 rounded hover:bg-muted transition-colors" aria-label="Unpin tab">
72
+ <PinOff style="width: 0.9rem; height: 0.9rem;" />
73
+ </button>
74
+ {/if}
75
+ {#if context.onClose}
76
+ <button type="button" onclick={(e) => handleClose(e, tab.value)} class="p-0.5 rounded hover:bg-muted transition-colors" aria-label="Close tab">
77
+ <X style="width: 0.9rem; height: 0.9rem;" />
78
+ </button>
79
+ {/if}
80
+ </div>
81
+ </DropdownMenu.Item>
82
+ {/each}
83
+ </DropdownMenu.Group>
84
+ {/if}
85
+
86
+ {#if pinnedTabs.length > 0 && unpinnedTabs.length > 0}
87
+ <DropdownMenu.Separator />
88
+ {/if}
89
+
90
+ {#if unpinnedTabs.length > 0}
91
+ <DropdownMenu.Group>
92
+ {#if pinnedTabs.length > 0}
93
+ <DropdownMenu.Label>Unpinned</DropdownMenu.Label>
94
+ {/if}
95
+ {#each unpinnedTabs as tab}
96
+ <DropdownMenu.Item
97
+ onclick={() => handleTabClick(tab.value)}
98
+ class={cn('flex items-center justify-between gap-2', activeValue === tab.value && 'bg-accent')}
99
+ >
100
+ <span class="flex-1 truncate">{tab.label}</span>
101
+ <div class="flex items-center gap-1 flex-shrink-0">
102
+ {#if context.onPin}
103
+ <button type="button" onclick={(e) => handlePin(e, tab.value)} class="p-0.5 rounded hover:bg-muted transition-colors" aria-label="Pin tab">
104
+ <Pin style="width: 0.9rem; height: 0.9rem;" />
105
+ </button>
106
+ {/if}
107
+ {#if context.onClose}
108
+ <button type="button" onclick={(e) => handleClose(e, tab.value)} class="p-0.5 rounded hover:bg-muted transition-colors" aria-label="Close tab">
109
+ <X style="width: 0.9rem; height: 0.9rem;" />
110
+ </button>
111
+ {/if}
112
+ </div>
113
+ </DropdownMenu.Item>
114
+ {/each}
115
+ </DropdownMenu.Group>
116
+ {/if}
117
+ </DropdownMenu.Content>
118
+ </DropdownMenu.Root>
119
+ </div>
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../shadcn/utils.js';
4
+
5
+ interface Props {
6
+ class?: string;
7
+ children: Snippet;
8
+ onVisibleCountChange?: (visibleCount: number) => void;
9
+ totalTabs?: number;
10
+ }
11
+
12
+ let { class: className, children, onVisibleCountChange, totalTabs = 0 }: Props = $props();
13
+
14
+ let container: HTMLDivElement;
15
+ let visibleCount = $state(totalTabs);
16
+ let isCalculating = false;
17
+ let lastContainerWidth = 0;
18
+ let isExpanding = false;
19
+
20
+ function calculateVisibleTabs() {
21
+ if (!container || totalTabs === 0 || isCalculating) return;
22
+
23
+ isCalculating = true;
24
+
25
+ try {
26
+ const containerWidth = container.clientWidth;
27
+
28
+ // Track if we're expanding or contracting
29
+ isExpanding = containerWidth > lastContainerWidth;
30
+ console.log(`[PinnedSection] Calculate: width=${containerWidth}, last=${lastContainerWidth}, expanding=${isExpanding}, visible=${visibleCount}/${totalTabs}`);
31
+ lastContainerWidth = containerWidth;
32
+
33
+ const tabs = Array.from(container.querySelectorAll('[role="tab"]')) as HTMLElement[];
34
+
35
+ if (tabs.length === 0) {
36
+ // Initial render, show all
37
+ if (visibleCount !== totalTabs) {
38
+ visibleCount = totalTabs;
39
+ onVisibleCountChange?.(totalTabs);
40
+ }
41
+ return;
42
+ }
43
+
44
+ const gap = 4; // gap-1 = 0.25rem = 4px
45
+ const padding = 16; // px-2 = 0.5rem * 2 = 16px
46
+ const menuButtonWidth = 32; // Space for overflow menu button
47
+
48
+ // Measure current visible tabs
49
+ let totalWidth = 0;
50
+ for (let i = 0; i < tabs.length; i++) {
51
+ totalWidth += tabs[i].offsetWidth + (i > 0 ? gap : 0);
52
+ }
53
+
54
+ const availableNoMenu = containerWidth - padding;
55
+ const availableWithMenu = availableNoMenu - menuButtonWidth;
56
+
57
+ console.log(`[PinnedSection] totalWidth=${totalWidth}, availableNoMenu=${availableNoMenu}, availableWithMenu=${availableWithMenu}`);
58
+
59
+ let newCount = visibleCount;
60
+
61
+ // Check if current tabs are overflowing
62
+ if (totalWidth > availableNoMenu) {
63
+ console.log(`[PinnedSection] OVERFLOWING - hiding tabs`);
64
+ // We're overflowing, need to hide tabs
65
+ // Calculate with menu button space
66
+ let accumulatedWidth = 0;
67
+ newCount = 0;
68
+ for (let i = 0; i < tabs.length; i++) {
69
+ const tabWidth = tabs[i].offsetWidth;
70
+ if (accumulatedWidth + tabWidth + (i > 0 ? gap : 0) <= availableWithMenu) {
71
+ accumulatedWidth += tabWidth + (i > 0 ? gap : 0);
72
+ newCount++;
73
+ } else {
74
+ break;
75
+ }
76
+ }
77
+ } else if (visibleCount < totalTabs && isExpanding) {
78
+ console.log(`[PinnedSection] EXPANDING - trying to show more`);
79
+ // We have space and expanding - keep showing more tabs as long as they fit
80
+ // We need to estimate the width of hidden tabs to know if they'll fit
81
+ // Assume average width of current visible tabs
82
+ const avgTabWidth = tabs.length > 0 ? totalWidth / tabs.length : 80;
83
+
84
+ // Calculate how many more tabs we can fit
85
+ let remainingSpace = availableNoMenu - totalWidth;
86
+ let canFitMore = Math.floor(remainingSpace / (avgTabWidth + gap));
87
+
88
+ console.log(`[PinnedSection] avgTabWidth=${avgTabWidth}, remainingSpace=${remainingSpace}, canFitMore=${canFitMore}`);
89
+
90
+ newCount = Math.min(visibleCount + Math.max(1, canFitMore), totalTabs);
91
+ } else {
92
+ console.log(`[PinnedSection] NO CHANGE - visibleCount=${visibleCount}, totalTabs=${totalTabs}, isExpanding=${isExpanding}`);
93
+ }
94
+
95
+ if (visibleCount !== newCount) {
96
+ const oldCount = visibleCount;
97
+ visibleCount = newCount;
98
+ onVisibleCountChange?.(newCount);
99
+
100
+ console.log(`[PinnedSection] Changed from ${oldCount} to ${newCount}`);
101
+
102
+ // If we showed more tabs and haven't reached the total,
103
+ // schedule another check to see if even more will fit
104
+ if (newCount > oldCount && newCount < totalTabs && isExpanding) {
105
+ console.log(`[PinnedSection] Scheduling re-check...`);
106
+ setTimeout(() => calculateVisibleTabs(), 20);
107
+ }
108
+ }
109
+ } finally {
110
+ isCalculating = false;
111
+ }
112
+ }
113
+
114
+ $effect(() => {
115
+ if (container) {
116
+ let timeoutId: ReturnType<typeof setTimeout>;
117
+
118
+ // Initial check with slight delay to ensure content is rendered
119
+ timeoutId = setTimeout(() => calculateVisibleTabs(), 10);
120
+
121
+ // Watch for resize with debouncing
122
+ const resizeObserver = new ResizeObserver(() => {
123
+ clearTimeout(timeoutId);
124
+ timeoutId = setTimeout(() => calculateVisibleTabs(), 50);
125
+ });
126
+ resizeObserver.observe(container);
127
+
128
+ return () => {
129
+ clearTimeout(timeoutId);
130
+ resizeObserver.disconnect();
131
+ };
132
+ }
133
+ });
134
+ </script>
135
+
136
+ <div bind:this={container} class={cn('flex-shrink flex items-center gap-1 px-2 overflow-hidden min-w-0', className)}>
137
+ {@render children()}
138
+ </div>