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.
- package/dist/cli/template-files/pnpm-workspace.yaml +1 -1
- package/package.json +13 -13
- package/src/.DS_Store +0 -0
- package/src/icons/lucide/index.d.ts +397 -40
- package/src/pika/scrollable-tabs/README.md +192 -0
- package/src/pika/scrollable-tabs/add-button.svelte +33 -0
- package/src/pika/scrollable-tabs/content.svelte +23 -0
- package/src/pika/scrollable-tabs/context.svelte.ts +23 -0
- package/src/pika/scrollable-tabs/example.svelte +81 -0
- package/src/pika/scrollable-tabs/index.ts +31 -0
- package/src/pika/scrollable-tabs/list.svelte +15 -0
- package/src/pika/scrollable-tabs/overflow-menu.svelte +119 -0
- package/src/pika/scrollable-tabs/pinned-section.svelte +138 -0
- package/src/pika/scrollable-tabs/pinned-trigger.svelte +81 -0
- package/src/pika/scrollable-tabs/root.svelte +34 -0
- package/src/pika/scrollable-tabs/scrollable-section.svelte +120 -0
- package/src/pika/scrollable-tabs/trigger.svelte +82 -0
- package/src/shadcn/carousel/carousel-content.svelte +36 -31
- package/src/shadcn/carousel/carousel-item.svelte +22 -18
- package/src/shadcn/carousel/carousel-next.svelte +29 -22
- package/src/shadcn/carousel/carousel-previous.svelte +29 -22
- package/src/shadcn/carousel/carousel.svelte +77 -73
- package/src/shadcn/carousel/context.ts +37 -32
- package/src/shadcn/toggle-group/toggle-group.svelte +23 -28
- package/src/pika/index.ts +0 -29
- package/src/shadcn/index.ts +0 -40
|
@@ -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>
|