Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 8 additions & 17 deletions src/lib/tabs/TabItem.svelte
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
<script lang="ts">
import { getContext } from "svelte";
import { writable } from "svelte/store";
import { type TabitemProps as Props, type TabCtxType, tabItem, tabs } from ".";
import { type TabitemProps as Props, tabItem } from ".";
import { getTabContext } from "./Tabs.svelte";

let { children, titleSlot, open = false, title = "Tab title", activeClass, inactiveClass, class: className, disabled, tabStyle, ...restProps }: Props = $props();
let { children, titleSlot, open = false, title = "Tab title", class: className, disabled, ...restProps }: Props = $props();

const ctx: TabCtxType = getContext("ctx");
let compoTabStyle = $derived(tabStyle ? tabStyle : ctx.tabStyle || "full");
const ctx = getTabContext() ?? {};

const { active, inactive } = $derived(tabs({ tabStyle: compoTabStyle, hasDivider: true }));
let selected = ctx.selected ?? writable<HTMLElement>();
// Generate a unique ID for this tab button
const tabId = `tab-${Math.random().toString(36).substring(2)}`;

function init(node: HTMLElement) {
selected.set(node);
ctx.selected = node;

const destroy = selected.subscribe((x) => {
if (x !== node) {
$effect(() => {
if (ctx.selected !== node) {
open = false;
}
});

return { destroy };
}

const { base, button, content } = $derived(tabItem({ open, disabled }));
Expand All @@ -38,7 +32,7 @@
aria-selected={open}
{disabled}
class={button({
class: open ? (activeClass ?? active()) : (inactiveClass ?? inactive())
class: open ? ctx.activeClass : ctx.inactiveClass
})}
>
{#if titleSlot}
Expand All @@ -65,9 +59,6 @@
@props:titleSlot: Snippet;
@props:open: boolean = false;
@props:title: string = "Tab title";
@props:activeClass: string;
@props:inactiveClass: string;
@props:class: string;
@props:disabled: boolean;
@props:tabStyle: "full" | "pill" | "underline" | "none";
-->
45 changes: 31 additions & 14 deletions src/lib/tabs/Tabs.svelte
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
<script lang="ts" module>
const contextKey = Symbol("tab-context");
const setTabContext = (ctx: TabCtxType) => setContext(contextKey, ctx);

export const getTabContext = () => getContext(contextKey) as ReturnType<typeof setTabContext>;
</script>

<script lang="ts">
import { writable } from "svelte/store";
import { setContext } from "svelte";
import { getContext, setContext } from "svelte";
import { type TabsProps as Props, type TabCtxType, tabs } from ".";

let { children, tabStyle = "none", ulClass, contentClass, divider = true, ...restProps }: Props = $props();
let { children, tabStyle = "none", tabSize = "md", ulClass, contentClass, activeClass, inactiveClass, divider = true, ...restProps }: Props = $props();

const { base, content, divider: dividerClass } = $derived(tabs({ tabStyle, hasDivider: divider }));
const { base, active, inactive, content, divider: dividerClass } = $derived(tabs({ tabStyle, tabSize, hasDivider: divider }));

// Generate a unique ID for the tab panel
const panelId = `tab-panel-${Math.random().toString(36).substring(2)}`;

const ctx: TabCtxType = {
get tabStyle() {
return tabStyle;
let selectedStore = $state.raw<HTMLElement>();

setTabContext({
get activeClass() {
return active({ class: activeClass });
},
get inactiveClass() {
return inactive({ class: inactiveClass });
},
get selected() {
return selectedStore;
},
set selected(v: HTMLElement | undefined) {
selectedStore = v;
},
selected: writable<HTMLElement>(),
panelId // Add panelId to the context
};
});

let dividerBool = $derived(["full", "pill"].includes(tabStyle) ? false : divider);

setContext("ctx", ctx);

function init(node: HTMLElement) {
const destroy = ctx.selected.subscribe((x: HTMLElement) => {
if (x) node.replaceChildren(x);
$effect(() => {
if (!selectedStore) return;
node.replaceChildren(selectedStore);
});
return { destroy };
}
</script>

Expand All @@ -44,7 +58,10 @@
## Props
@props: children: Snippet;
@props:tabStyle: "full" | "pill" | "underline" | "none" = "none";
@props:tabSize: "xs" | "sm" | "md" = "md";
@props:ulClass: string;
@props:contentClass: string;
@props:inactiveClass: string;
@props:activeClass: string;
@props:divider: boolean = true;
-->
14 changes: 6 additions & 8 deletions src/lib/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import TabItem from "./TabItem.svelte";
import Tabs from "./Tabs.svelte";
import type { Snippet } from "svelte";
import type { HTMLAttributes, HTMLLiAttributes } from "svelte/elements";
import { type Writable } from "svelte/store";
import { tabs, tabItem } from "./theme";

interface TabsProps extends HTMLAttributes<HTMLUListElement> {
children: Snippet;
tabStyle?: "full" | "pill" | "underline" | "none";
tabSize?: "xs" | "sm" | "md";
ulClass?: string;
activeClass?: string;
inactiveClass?: string;
contentClass?: string;
divider?: boolean;
class?: string;
Expand All @@ -19,18 +21,14 @@ interface TabitemProps extends HTMLLiAttributes {
titleSlot?: Snippet;
open?: boolean;
title?: string;
activeClass?: string;
inactiveClass?: string;
class?: string;
disabled?: boolean;
tabStyle?: "full" | "pill" | "underline" | "none";
}

interface TabCtxType {
activeClass?: string;
inactiveClass?: string;
tabStyle?: "full" | "pill" | "underline" | "none";
selected: Writable<HTMLElement>;
activeClass: string;
inactiveClass: string;
selected?: HTMLElement;
panelId: string;
}

Expand Down
29 changes: 22 additions & 7 deletions src/lib/tabs/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,43 @@ export const tabs = tv({
base: "flex flex-wrap space-x-2 rtl:space-x-reverse",
content: "p-4 bg-gray-50 rounded-lg dark:bg-gray-800 mt-4",
divider: "h-px bg-gray-200 dark:bg-gray-700",
active: "p-4 text-primary-600 bg-gray-100 rounded-t-lg dark:bg-gray-800 dark:text-primary-500",
inactive: "p-4 text-gray-500 rounded-t-lg hover:text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
active: "text-primary-600 bg-gray-100 rounded-t-lg dark:bg-gray-800 dark:text-primary-500",
inactive: "text-gray-500 rounded-t-lg hover:text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
},
variants: {
tabStyle: {
full: {
active: "p-4 w-full rounded-none group-first:rounded-s-lg group-last:rounded-e-lg text-gray-900 bg-gray-100 focus:ring-4 focus:ring-primary-300 focus:outline-none dark:bg-gray-700 dark:text-white",
inactive: "p-4 w-full rounded-none group-first:rounded-s-lg group-last:rounded-e-lg text-gray-500 dark:text-gray-400 bg-white hover:text-gray-700 hover:bg-gray-50 focus:ring-4 focus:ring-primary-300 focus:outline-none dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
active: "w-full rounded-none group-first:rounded-s-lg group-last:rounded-e-lg text-gray-900 bg-gray-100 focus:ring-4 focus:ring-primary-300 focus:outline-none dark:bg-gray-700 dark:text-white",
inactive: "w-full rounded-none group-first:rounded-s-lg group-last:rounded-e-lg text-gray-500 dark:text-gray-400 bg-white hover:text-gray-700 hover:bg-gray-50 focus:ring-4 focus:ring-primary-300 focus:outline-none dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
},
pill: {
active: "py-3 px-4 text-white bg-primary-600 rounded-lg",
inactive: "py-3 px-4 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
},
underline: {
base: "-mb-px",
active: "p-4 text-primary-600 border-b-2 border-primary-600 dark:text-primary-500 dark:border-primary-500 bg-transparent",
inactive: "p-4 border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 text-gray-500 dark:text-gray-400 bg-transparent"
active: "text-primary-600 border-b-2 border-primary-600 dark:text-primary-500 dark:border-primary-500 bg-transparent",
inactive: "border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 text-gray-500 dark:text-gray-400 bg-transparent"
},
none: {
active: "",
inactive: ""
}
},
tabSize: {
xs: {
active: "p-1",
inactive: "p-1"
},
sm: {
active: "p-2",
inactive: "p-2"
},
md: {
active: "p-4",
inactive: "p-4"
}
},
hasDivider: {
true: {}
}
Expand All @@ -43,7 +57,8 @@ export const tabs = tv({
],
defaultVariants: {
tabStyle: "none",
hasDivider: true
hasDivider: true,
tabSize: "md"
}
});

Expand Down
15 changes: 12 additions & 3 deletions src/routes/components/tabs/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@

let tabStyle: TabsProps["tabStyle"] = $state("none") as NonNullable<TabsProps["tabStyle"]>;
const tabStyles = Object.keys(tabs.variants.tabStyle);
let tabSize: TabsProps["tabSize"] = $state("md") as NonNullable<TabsProps["tabSize"]>;
const tabSizes = Object.keys(tabs.variants.tabSize);

// code generator
let generatedCode = $derived(
(() => {
let props = [];
if (tabStyle !== "none") props.push(` style="${tabStyle}"`);
if (tabStyle !== "none") props.push(` tabStyle="${tabStyle}"`);
if (tabSize !== "md") props.push(` tabSize="${tabSize}"`);

return `<Tab${props}>
return `<Tabs${props.join("")}>
<TabItem open title="Profile">
<p class="text-sm text-gray-500 dark:text-gray-400">
<b>Profile:</b>
Expand Down Expand Up @@ -111,7 +114,7 @@

<H2>Interactive Tab Builder</H2>
<CodeWrapper>
<Tabs {tabStyle} ulClass={tabStyle === "full" ? "flex flex-nowrap rounded-lg divide-x rtl:divide-x-reverse divide-gray-200 shadow dark:divide-gray-700 space-x-0" : ""}>
<Tabs {tabStyle} {tabSize} ulClass={tabStyle === "full" ? "flex flex-nowrap rounded-lg divide-x rtl:divide-x-reverse divide-gray-200 shadow dark:divide-gray-700 space-x-0" : ""}>
<TabItem open title={tabStyle === "full" ? "" : "Profile"} class={tabStyle === "full" ? "w-full" : ""}>
{#snippet titleSlot()}Profile{/snippet}
<p class="text-sm text-gray-500 dark:text-gray-400">
Expand Down Expand Up @@ -158,6 +161,12 @@
{/if}
{/each}
</div>
<div class="my-4 flex flex-wrap space-x-4">
<Label class="mb-4 w-full font-bold">Size</Label>
{#each tabSizes as option}
<Radio labelClass="w-24 my-1" name="table_size" bind:group={tabSize} value={option}>{option}</Radio>
{/each}
</div>
{#snippet codeblock()}
<DynamicCodeBlockHighlight handleExpandClick={handleBuilderExpandClick} expand={builderExpand} showExpandButton={showBuilderExpandButton} code={generatedCode} />
{/snippet}
Expand Down