diff --git a/packages/storybook/src/atoms/underline-tabs/fields.ts b/packages/storybook/src/atoms/underline-tabs/fields.ts new file mode 100644 index 00000000..8f22fe6d --- /dev/null +++ b/packages/storybook/src/atoms/underline-tabs/fields.ts @@ -0,0 +1 @@ +export const FIELDS = ['value', 'onValueChange', 'loop', 'disabled'] as string[]; diff --git a/packages/storybook/src/atoms/underline-tabs/underline-tabs.stories.svelte b/packages/storybook/src/atoms/underline-tabs/underline-tabs.stories.svelte new file mode 100644 index 00000000..9a0ee591 --- /dev/null +++ b/packages/storybook/src/atoms/underline-tabs/underline-tabs.stories.svelte @@ -0,0 +1,107 @@ + + + + {#snippet template(args)} +
+ + + Home + Profile + Settings + + +
+

Home Content

+

+ Welcome to the home tab. This is where your main content lives. +

+
+
+ +
+

Profile Content

+

+ Manage your profile settings and personal information here. +

+
+
+ +
+

Settings Content

+

Configure your application settings and preferences.

+
+
+
+
+ {/snippet} +
+ + + {#snippet template(args)} +
+ + + Home + Profile + Settings + + +
+

Home content is shown here.

+
+
+ +
+

Profile content is shown here.

+
+
+ +
+

Settings content (disabled).

+
+
+
+
+ {/snippet} +
diff --git a/packages/ui/src/custom/underline-tabs/index.ts b/packages/ui/src/custom/underline-tabs/index.ts new file mode 100644 index 00000000..a21e19e3 --- /dev/null +++ b/packages/ui/src/custom/underline-tabs/index.ts @@ -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 +}; diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-content.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-content.svelte new file mode 100644 index 00000000..79690cba --- /dev/null +++ b/packages/ui/src/custom/underline-tabs/underline-tabs-content.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-list.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-list.svelte new file mode 100644 index 00000000..db62dc1f --- /dev/null +++ b/packages/ui/src/custom/underline-tabs/underline-tabs-list.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + + diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte new file mode 100644 index 00000000..65e1d8eb --- /dev/null +++ b/packages/ui/src/custom/underline-tabs/underline-tabs-trigger.svelte @@ -0,0 +1,59 @@ + + +
+ + {@render children?.()} + + {#if state.rootState.hoveredTab === value} +
+ {/if} + {#if state.rootState.opts.value.current === value} +
+ {/if} +
diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs.svelte b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte new file mode 100644 index 00000000..12756b16 --- /dev/null +++ b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte @@ -0,0 +1,33 @@ + + + diff --git a/packages/ui/src/custom/underline-tabs/underline-tabs.svelte.ts b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte.ts new file mode 100644 index 00000000..b176b9a2 --- /dev/null +++ b/packages/ui/src/custom/underline-tabs/underline-tabs.svelte.ts @@ -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(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>[0]) { + this.handleFocus(); + this.opts.onmouseenter.current?.(e); + } + + onmouseleave(e: Parameters>[0]) { + this.handleBlur(); + this.opts.onmouseleave.current?.(e); + } + + onfocus(e: Parameters>[0]) { + this.handleFocus(); + this.opts.onfocus.current?.(e); + } + + onblur(e: Parameters>[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('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()); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index cab08ef8..34138128 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -49,5 +49,6 @@ export { BarChart, LineChart, AreaChart, PieChart, scaleBand } from './base/char export * from './tools'; +export * as UnderlineTabs from './custom/underline-tabs'; export * from './custom/editor/ui'; export * as ImageCropper from './custom/image-cropper';