feat: add Underline Tab component (#608)
* feat: add Underline Tab component * chore: pr fixes * chore: add .js extensions
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export const FIELDS = ['value', 'onValueChange', 'loop', 'disabled'] as string[];
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script module>
|
||||||
|
import { UnderlineTabs } from '@cio/ui';
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import { FIELDS } from './fields';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Molecules/UnderlineTabs',
|
||||||
|
component: UnderlineTabs.Root,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
controls: {
|
||||||
|
include: FIELDS
|
||||||
|
}
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
value: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'The value of the currently selected tab',
|
||||||
|
table: { defaultValue: { summary: 'undefined' } }
|
||||||
|
},
|
||||||
|
onValueChange: {
|
||||||
|
control: false,
|
||||||
|
description: 'Callback function called when the selected tab changes',
|
||||||
|
table: {
|
||||||
|
type: { summary: '(value: string) => void' },
|
||||||
|
defaultValue: { summary: 'undefined' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loop: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether tab navigation should loop',
|
||||||
|
table: { defaultValue: { summary: 'true' } }
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether the tabs are disabled',
|
||||||
|
table: { defaultValue: { summary: 'false' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['autodocs']
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default">
|
||||||
|
{#snippet template(args)}
|
||||||
|
<div class="ui:w-[400px]">
|
||||||
|
<UnderlineTabs.Root {...args} value="home">
|
||||||
|
<UnderlineTabs.List>
|
||||||
|
<UnderlineTabs.Trigger value="home">Home</UnderlineTabs.Trigger>
|
||||||
|
<UnderlineTabs.Trigger value="profile">Profile</UnderlineTabs.Trigger>
|
||||||
|
<UnderlineTabs.Trigger value="settings">Settings</UnderlineTabs.Trigger>
|
||||||
|
</UnderlineTabs.List>
|
||||||
|
<UnderlineTabs.Content value="home">
|
||||||
|
<div class="ui:py-4">
|
||||||
|
<h3 class="ui:mb-2 ui:text-lg ui:font-semibold">Home Content</h3>
|
||||||
|
<p class="ui:text-muted-foreground ui:text-sm">
|
||||||
|
Welcome to the home tab. This is where your main content lives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</UnderlineTabs.Content>
|
||||||
|
<UnderlineTabs.Content value="profile">
|
||||||
|
<div class="ui:py-4">
|
||||||
|
<h3 class="ui:mb-2 ui:text-lg ui:font-semibold">Profile Content</h3>
|
||||||
|
<p class="ui:text-muted-foreground ui:text-sm">
|
||||||
|
Manage your profile settings and personal information here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</UnderlineTabs.Content>
|
||||||
|
<UnderlineTabs.Content value="settings">
|
||||||
|
<div class="ui:py-4">
|
||||||
|
<h3 class="ui:mb-2 ui:text-lg ui:font-semibold">Settings Content</h3>
|
||||||
|
<p class="ui:text-muted-foreground ui:text-sm">Configure your application settings and preferences.</p>
|
||||||
|
</div>
|
||||||
|
</UnderlineTabs.Content>
|
||||||
|
</UnderlineTabs.Root>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Disabled Tab">
|
||||||
|
{#snippet template(args)}
|
||||||
|
<div class="w-[400px]">
|
||||||
|
<UnderlineTabs.Root {...args} value="home">
|
||||||
|
<UnderlineTabs.List>
|
||||||
|
<UnderlineTabs.Trigger value="home">Home</UnderlineTabs.Trigger>
|
||||||
|
<UnderlineTabs.Trigger value="profile">Profile</UnderlineTabs.Trigger>
|
||||||
|
<UnderlineTabs.Trigger value="settings" disabled>Settings</UnderlineTabs.Trigger>
|
||||||
|
</UnderlineTabs.List>
|
||||||
|
<UnderlineTabs.Content value="home">
|
||||||
|
<div class="py-4">
|
||||||
|
<p class="text-muted-foreground text-sm">Home content is shown here.</p>
|
||||||
|
</div>
|
||||||
|
</UnderlineTabs.Content>
|
||||||
|
<UnderlineTabs.Content value="profile">
|
||||||
|
<div class="py-4">
|
||||||
|
<p class="text-muted-foreground text-sm">Profile content is shown here.</p>
|
||||||
|
</div>
|
||||||
|
</UnderlineTabs.Content>
|
||||||
|
<UnderlineTabs.Content value="settings">
|
||||||
|
<div class="py-4">
|
||||||
|
<p class="text-muted-foreground text-sm">Settings content (disabled).</p>
|
||||||
|
</div>
|
||||||
|
</UnderlineTabs.Content>
|
||||||
|
</UnderlineTabs.Root>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Root from './underline-tabs.svelte';
|
||||||
|
import Content from './underline-tabs-content.svelte';
|
||||||
|
import List from './underline-tabs-list.svelte';
|
||||||
|
import Trigger from './underline-tabs-trigger.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
List,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as UnderlineTabs,
|
||||||
|
Content as UnderlineTabsContent,
|
||||||
|
List as UnderlineTabsList,
|
||||||
|
Trigger as UnderlineTabsTrigger
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="underline-tabs-content"
|
||||||
|
class={cn('ui:flex-1 ui:outline-none', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), class: className, children, ...restProps }: TabsPrimitive.ListProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.List
|
||||||
|
bind:ref
|
||||||
|
data-slot="underline-tabs-list"
|
||||||
|
class={cn(
|
||||||
|
'ui:text-muted-foreground ui:border-border ui:relative ui:inline-flex ui:h-9 ui:w-full ui:max-w-full ui:items-center ui:justify-start ui:overflow-x-auto ui:border-b',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</TabsPrimitive.List>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global([data-slot='underline-tabs-list']) {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-slot='underline-tabs-list']::-webkit-scrollbar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
import { receive, send, useUnderlineTabsTrigger } from './underline-tabs.svelte.js';
|
||||||
|
import { box } from 'svelte-toolbelt';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value,
|
||||||
|
class: className,
|
||||||
|
onmouseenter,
|
||||||
|
onmouseleave,
|
||||||
|
onfocus,
|
||||||
|
onblur,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: TabsPrimitive.TriggerProps = $props();
|
||||||
|
|
||||||
|
const state = useUnderlineTabsTrigger({
|
||||||
|
value: box.with(() => value),
|
||||||
|
onmouseenter: box.with(() => onmouseenter),
|
||||||
|
onmouseleave: box.with(() => onmouseleave),
|
||||||
|
onfocus: box.with(() => onfocus),
|
||||||
|
onblur: box.with(() => onblur)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ui:relative ui:h-full">
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="underline-tabs-trigger"
|
||||||
|
class={cn(
|
||||||
|
"dark:data-[state=active]:text-foreground data-[state=active]:text-foreground ui:text-muted-foreground ui:z-10 ui:relative ui:inline-flex ui:h-[calc(100%-3px)] ui:flex-1 ui:items-center ui:justify-center ui:gap-1.5 ui:whitespace-nowrap ui:px-3 ui:py-1 ui:text-sm ui:font-medium ui:transition-colors ui:focus:outline-none ui:disabled:pointer-events-none ui:disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
state.rootState.isHovered && state.rootState.hoveredTab === value && 'data-[state=inactive]:text-foreground!',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...state.props}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</TabsPrimitive.Trigger>
|
||||||
|
{#if state.rootState.hoveredTab === value}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'ui:bg-accent ui:z-1 ui:absolute ui:top-0 ui:h-[calc(100%-3px)] ui:w-full ui:rounded-md ui:opacity-0 ui:transition-opacity ui:duration-300 ui:pointer-events-none peer-focus-visible:ui:opacity-100',
|
||||||
|
state.rootState.isHovered && 'ui:opacity-100'
|
||||||
|
)}
|
||||||
|
in:receive={{ key: `${state.rootState.opts.id.current}-tab-hover`, duration: 300 }}
|
||||||
|
out:send={{ key: `${state.rootState.opts.id.current}-tab-hover`, duration: 300 }}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if state.rootState.opts.value.current === value}
|
||||||
|
<div
|
||||||
|
class="ui:bg-primary ui:z-1 ui:absolute ui:-bottom-px ui:h-0.5 ui:w-full ui:pointer-events-none"
|
||||||
|
in:receive={{ key: `${state.rootState.opts.id.current}-tab-active-border`, duration: 200 }}
|
||||||
|
out:send={{ key: `${state.rootState.opts.id.current}-tab-active-border`, duration: 200 }}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as TabsPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '../../tools';
|
||||||
|
import { useUnderlineTabs } from './underline-tabs.svelte.js';
|
||||||
|
import { box } from 'svelte-toolbelt';
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(''),
|
||||||
|
id = uid,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: Omit<TabsPrimitive.RootProps, 'orientation' | 'id'> & { id?: string } = $props();
|
||||||
|
|
||||||
|
useUnderlineTabs({
|
||||||
|
value: box.with(
|
||||||
|
() => value,
|
||||||
|
(v) => (value = v)
|
||||||
|
),
|
||||||
|
id: box.with(() => id)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
orientation="horizontal"
|
||||||
|
data-slot="underline-tabs"
|
||||||
|
class={cn('ui:flex ui:flex-col ui:gap-2', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Context } from 'runed';
|
||||||
|
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
|
||||||
|
import type { Tabs as TabsPrimitive } from 'bits-ui';
|
||||||
|
import { crossfade } from 'svelte/transition';
|
||||||
|
|
||||||
|
export const [send, receive] = crossfade({
|
||||||
|
duration: (d) => Math.sqrt(d * 200)
|
||||||
|
});
|
||||||
|
|
||||||
|
type UnderlineTabsRootProps = WritableBoxedValues<{
|
||||||
|
value: string;
|
||||||
|
}> &
|
||||||
|
ReadableBoxedValues<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
class UnderlineTabsRootState {
|
||||||
|
hoveredTab = $state<string | null>(null);
|
||||||
|
isHovered = $state(false);
|
||||||
|
constructor(readonly opts: UnderlineTabsRootProps) {
|
||||||
|
this.hoveredTab = this.opts.value.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnderlineTabsTriggerProps = ReadableBoxedValues<{
|
||||||
|
value: string;
|
||||||
|
onmouseenter: TabsPrimitive.TriggerProps['onmouseenter'];
|
||||||
|
onmouseleave: TabsPrimitive.TriggerProps['onmouseleave'];
|
||||||
|
onfocus: TabsPrimitive.TriggerProps['onfocus'];
|
||||||
|
onblur: TabsPrimitive.TriggerProps['onblur'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
class UnderlineTabsTriggerState {
|
||||||
|
constructor(
|
||||||
|
readonly opts: UnderlineTabsTriggerProps,
|
||||||
|
readonly rootState: UnderlineTabsRootState
|
||||||
|
) {}
|
||||||
|
|
||||||
|
handleFocus() {
|
||||||
|
this.rootState.isHovered = true;
|
||||||
|
this.rootState.hoveredTab = this.opts.value.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur() {
|
||||||
|
if (this.rootState.hoveredTab === this.opts.value.current) {
|
||||||
|
this.rootState.isHovered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onmouseenter(e: Parameters<NonNullable<TabsPrimitive.TriggerProps['onmouseenter']>>[0]) {
|
||||||
|
this.handleFocus();
|
||||||
|
this.opts.onmouseenter.current?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
onmouseleave(e: Parameters<NonNullable<TabsPrimitive.TriggerProps['onmouseleave']>>[0]) {
|
||||||
|
this.handleBlur();
|
||||||
|
this.opts.onmouseleave.current?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
onfocus(e: Parameters<NonNullable<TabsPrimitive.TriggerProps['onfocus']>>[0]) {
|
||||||
|
this.handleFocus();
|
||||||
|
this.opts.onfocus.current?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
onblur(e: Parameters<NonNullable<TabsPrimitive.TriggerProps['onblur']>>[0]) {
|
||||||
|
this.handleBlur();
|
||||||
|
|
||||||
|
this.opts.onblur.current?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
props = $derived.by(() => ({
|
||||||
|
value: this.opts.value.current,
|
||||||
|
onmouseenter: this.onmouseenter.bind(this),
|
||||||
|
onmouseleave: this.onmouseleave.bind(this),
|
||||||
|
onfocus: this.onfocus.bind(this),
|
||||||
|
onblur: this.onblur.bind(this)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = new Context<UnderlineTabsRootState>('underline-tabs-root-ctx');
|
||||||
|
|
||||||
|
export function useUnderlineTabs(opts: UnderlineTabsRootProps) {
|
||||||
|
return ctx.set(new UnderlineTabsRootState(opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnderlineTabsTrigger(opts: UnderlineTabsTriggerProps) {
|
||||||
|
return new UnderlineTabsTriggerState(opts, ctx.get());
|
||||||
|
}
|
||||||
@@ -49,5 +49,6 @@ export { BarChart, LineChart, AreaChart, PieChart, scaleBand } from './base/char
|
|||||||
|
|
||||||
export * from './tools';
|
export * from './tools';
|
||||||
|
|
||||||
|
export * as UnderlineTabs from './custom/underline-tabs';
|
||||||
export * from './custom/editor/ui';
|
export * from './custom/editor/ui';
|
||||||
export * as ImageCropper from './custom/image-cropper';
|
export * as ImageCropper from './custom/image-cropper';
|
||||||
|
|||||||
Reference in New Issue
Block a user