{"name":"tabs","type":"registry:component","title":"Tabs","description":"A set of layered sections of content—known as tab panels—that are displayed one at a time. Built with Lit and styled using Tailwind CSS. Features keyboard navigation and accessible ARIA attributes.","categories":["ui","tabs","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","dependencies":[],"files":[{"path":"registry/ui/tabs/tabs.ts","type":"registry:ui","content":"import { html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport {\n  customElement,\n  property,\n  queryAssignedElements,\n} from \"lit/decorators.js\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\nimport { cn } from \"@/registry/lib/utils\";\n\nexport interface TabsChangeEvent extends CustomEvent {\n  detail: { value: string };\n}\n\nexport interface TabsProperties {\n  value?: string;\n  defaultValue?: string;\n}\n\n@customElement(\"ui-tabs\")\nexport class Tabs extends TW(LitElement) implements TabsProperties {\n  @property({ type: String }) value = \"\";\n  @property({ type: String, attribute: \"default-value\" }) defaultValue = \"\";\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this.setAttribute(\"data-orientation\", \"horizontal\");\n\n    if (!this.value && this.defaultValue) {\n      this.value = this.defaultValue;\n    }\n\n    this.addEventListener(\"tabs-trigger-click\", this.handleTriggerClick);\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\"tabs-trigger-click\", this.handleTriggerClick);\n  }\n\n  private handleTriggerClick = (e: CustomEvent) => {\n    const newValue = e.detail.value;\n    if (newValue !== this.value) {\n      this.value = newValue;\n      this.dispatchEvent(\n        new CustomEvent(\"change\", {\n          detail: { value: newValue },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n  };\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"value\")) {\n      this.updateTriggers();\n      this.updateContents();\n    }\n  }\n\n  private updateTriggers() {\n    const triggers = Array.from(\n      this.querySelectorAll(\"ui-tabs-trigger\"),\n    ) as TabsTrigger[];\n    triggers.forEach((trigger) => {\n      trigger.selected = trigger.value === this.value;\n    });\n  }\n\n  private updateContents() {\n    const contents = Array.from(\n      this.querySelectorAll(\"ui-tabs-content\"),\n    ) as TabsContent[];\n    contents.forEach((content) => {\n      content.active = content.value === this.value;\n    });\n  }\n\n  override render() {\n    return html`<slot></slot>`;\n  }\n}\n\n@customElement(\"ui-tabs-list\")\nexport class TabsList extends TW(LitElement) {\n  @queryAssignedElements({ selector: \"ui-tabs-trigger\" })\n  private triggers!: Array<TabsTrigger>;\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this.setAttribute(\"role\", \"tablist\");\n    this.setAttribute(\"data-orientation\", \"horizontal\");\n  }\n\n  override firstUpdated() {\n    setTimeout(() => {\n      this.updateRovingTabindex();\n    }, 0);\n  }\n\n  private updateRovingTabindex() {\n    const enabledTriggers = this.triggers.filter((t) => !t.disabled);\n    const selectedIndex = enabledTriggers.findIndex((t) => t.selected);\n    const focusIndex = selectedIndex >= 0 ? selectedIndex : 0;\n\n    enabledTriggers.forEach((trigger, index) => {\n      trigger.tabIndex = index === focusIndex ? 0 : -1;\n    });\n  }\n\n  private handleKeyDown = (e: KeyboardEvent) => {\n    const triggers = this.triggers.filter((t) => !t.disabled);\n    if (triggers.length === 0) return;\n\n    const currentIndex = triggers.findIndex(\n      (t) => t === this.shadowRoot?.activeElement,\n    );\n    if (currentIndex === -1) return;\n\n    let nextIndex = currentIndex;\n\n    switch (e.key) {\n      case \"ArrowRight\":\n        e.preventDefault();\n        nextIndex = (currentIndex + 1) % triggers.length;\n        break;\n      case \"ArrowLeft\":\n        e.preventDefault();\n        nextIndex = (currentIndex - 1 + triggers.length) % triggers.length;\n        break;\n      case \"Home\":\n        e.preventDefault();\n        nextIndex = 0;\n        break;\n      case \"End\":\n        e.preventDefault();\n        nextIndex = triggers.length - 1;\n        break;\n      default:\n        return;\n    }\n\n    if (nextIndex !== currentIndex) {\n      triggers.forEach((t, i) => {\n        t.tabIndex = i === nextIndex ? 0 : -1;\n      });\n      const button = triggers[nextIndex].shadowRoot?.querySelector(\"button\");\n      button?.focus();\n      button?.click();\n    }\n  };\n\n  override render() {\n    return html`\n      <div\n        class=${cn(\n          \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\",\n          this.className,\n        )}\n        @keydown=${this.handleKeyDown}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n@customElement(\"ui-tabs-trigger\")\nexport class TabsTrigger extends TW(LitElement) {\n  @property({ type: String }) value = \"\";\n  @property({ type: Boolean }) disabled = false;\n  @property({ type: Boolean }) selected = false;\n  @property({ type: Number }) tabIndex = -1;\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this.setAttribute(\"role\", \"tab\");\n    this.setAttribute(\"data-orientation\", \"horizontal\");\n  }\n\n  private handleClick = () => {\n    if (this.disabled) return;\n\n    this.dispatchEvent(\n      new CustomEvent(\"tabs-trigger-click\", {\n        detail: { value: this.value },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"selected\")) {\n      this.setAttribute(\"aria-selected\", String(this.selected));\n      this.setAttribute(\"data-state\", this.selected ? \"active\" : \"inactive\");\n\n      const button = this.shadowRoot?.querySelector(\"button\");\n      if (button) {\n        const tabs = this.closest(\"ui-tabs\");\n        const content = tabs?.querySelector(\n          `ui-tabs-content[value=\"${this.value}\"]`,\n        );\n        if (content) {\n          button.setAttribute(\n            \"aria-controls\",\n            content.id || `content-${this.value}`,\n          );\n        }\n      }\n    }\n\n    if (changedProperties.has(\"disabled\")) {\n      this.setAttribute(\"aria-disabled\", String(this.disabled));\n    }\n  }\n\n  override render() {\n    return html`\n      <button\n        type=\"button\"\n        class=${cn(\n          \"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n          this.className,\n        )}\n        ?disabled=${this.disabled}\n        tabindex=${this.tabIndex}\n        data-state=${this.selected ? \"active\" : \"inactive\"}\n        @click=${this.handleClick}\n      >\n        <slot></slot>\n      </button>\n    `;\n  }\n}\n\n@customElement(\"ui-tabs-content\")\nexport class TabsContent extends TW(LitElement) {\n  @property({ type: String }) value = \"\";\n  @property({ type: Boolean }) active = false;\n  @property({ type: Boolean }) forceMount = false;\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this.setAttribute(\"role\", \"tabpanel\");\n    this.setAttribute(\"data-orientation\", \"horizontal\");\n\n    if (!this.id) {\n      this.id = `content-${this.value}`;\n    }\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"active\")) {\n      this.setAttribute(\"data-state\", this.active ? \"active\" : \"inactive\");\n\n      const tabs = this.closest(\"ui-tabs\");\n      const trigger = tabs?.querySelector(\n        `ui-tabs-trigger[value=\"${this.value}\"]`,\n      );\n      if (trigger) {\n        this.setAttribute(\n          \"aria-labelledby\",\n          trigger.id || `trigger-${this.value}`,\n        );\n      }\n    }\n  }\n\n  override render() {\n    if (!this.active && !this.forceMount) {\n      return nothing;\n    }\n\n    return html`\n      <div\n        class=${cn(\n          \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n          this.className,\n        )}\n        tabindex=\"0\"\n        ?hidden=${!this.active}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-tabs\": Tabs;\n    \"ui-tabs-list\": TabsList;\n    \"ui-tabs-trigger\": TabsTrigger;\n    \"ui-tabs-content\": TabsContent;\n  }\n\n  interface HTMLElementEventMap {\n    \"tabs-trigger-click\": CustomEvent<{ value: string }>;\n  }\n}\n"},{"path":"registry/ui/tabs/tabs.stories.ts","type":"registry:ui","content":"import \"./tabs\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport type { TabsProperties } from \"./tabs\";\n\ntype TabsArgs = TabsProperties;\n\n/**\n * A set of layered sections of content—known as tab panels—that are displayed\n * one at a time.\n */\nconst meta: Meta<TabsArgs> = {\n  title: \"ui/Tabs\",\n  component: \"ui-tabs\",\n  tags: [\"autodocs\"],\n  parameters: {\n    layout: \"centered\",\n  },\n  argTypes: {\n    value: { control: \"text\" },\n    defaultValue: { control: \"text\" },\n  },\n  args: {\n    defaultValue: \"account\",\n  },\n  render: (args) => html`\n    <ui-tabs\n      .defaultValue=${args.defaultValue || \"\"}\n      .value=${args.value || \"\"}\n      class=\"w-96\"\n    >\n      <ui-tabs-list class=\"grid grid-cols-2\">\n        <ui-tabs-trigger value=\"account\">Account</ui-tabs-trigger>\n        <ui-tabs-trigger value=\"password\">Password</ui-tabs-trigger>\n      </ui-tabs-list>\n      <ui-tabs-content value=\"account\">\n        Make changes to your account here.\n      </ui-tabs-content>\n      <ui-tabs-content value=\"password\">\n        Change your password here.\n      </ui-tabs-content>\n    </ui-tabs>\n  `,\n};\n\nexport default meta;\n\ntype Story = StoryObj<TabsArgs>;\n\n/**\n * The default form of the tabs.\n */\nexport const Default: Story = {};\n\n/**\n * Tabs with three panels.\n */\nexport const ThreeTabs: Story = {\n  args: {\n    defaultValue: \"overview\",\n  },\n  render: (args) => html`\n    <ui-tabs\n      .defaultValue=${args.defaultValue || \"\"}\n      .value=${args.value || \"\"}\n      class=\"w-96\"\n    >\n      <ui-tabs-list class=\"grid grid-cols-3\">\n        <ui-tabs-trigger value=\"overview\">Overview</ui-tabs-trigger>\n        <ui-tabs-trigger value=\"analytics\">Analytics</ui-tabs-trigger>\n        <ui-tabs-trigger value=\"reports\">Reports</ui-tabs-trigger>\n      </ui-tabs-list>\n      <ui-tabs-content value=\"overview\">\n        View your account overview and summary.\n      </ui-tabs-content>\n      <ui-tabs-content value=\"analytics\">\n        Check your analytics and metrics.\n      </ui-tabs-content>\n      <ui-tabs-content value=\"reports\">\n        Download and review your reports.\n      </ui-tabs-content>\n    </ui-tabs>\n  `,\n};\n\n/**\n * Tabs with disabled trigger.\n */\nexport const WithDisabled: Story = {\n  render: (args) => html`\n    <ui-tabs\n      .defaultValue=${args.defaultValue || \"\"}\n      .value=${args.value || \"\"}\n      class=\"w-96\"\n    >\n      <ui-tabs-list class=\"grid grid-cols-3\">\n        <ui-tabs-trigger value=\"account\">Account</ui-tabs-trigger>\n        <ui-tabs-trigger value=\"password\">Password</ui-tabs-trigger>\n        <ui-tabs-trigger value=\"settings\" .disabled=${true}\n          >Settings</ui-tabs-trigger\n        >\n      </ui-tabs-list>\n      <ui-tabs-content value=\"account\">\n        Make changes to your account here.\n      </ui-tabs-content>\n      <ui-tabs-content value=\"password\">\n        Change your password here.\n      </ui-tabs-content>\n      <ui-tabs-content value=\"settings\">\n        Configure your settings.\n      </ui-tabs-content>\n    </ui-tabs>\n  `,\n};\n"}]}