{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "bottom-navigation",
  "title": "Bottom Navigation",
  "type": "registry:ui",
  "description": "Mobile bottom tab bar with icon + label items. Supports value/defaultValue for the active item, an active color, fixed positioning at the viewport bottom, badges on items, and a `to` prop for link integration.",
  "categories": [
    "navigation"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/bottom-navigation/bottom-navigation.ts",
      "content": "import { LitElement, css, html, isServer, nothing, type TemplateResult } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport type { IconNode } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nexport interface BottomNavItem {\n  value: string\n  label: string\n  icon?: IconNode | string\n  badge?: string | number\n  to?: string\n}\n\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\n/**\n * <uip-bottom-navigation> — Bottom navigation bar with sliding pill indicator.\n */\nexport class UipBottomNavigation extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    items: { type: Array },\n    value: { reflect: true },\n    defaultValue: { attribute: 'default-value' },\n    activeColor: { attribute: 'active-color' },\n    fixed: { type: Boolean, converter: trueByDefault },\n    showIndicator: { attribute: 'show-indicator', converter: trueByDefault },\n    safeArea: { attribute: 'safe-area', converter: trueByDefault },\n    indicatorStyle: { state: true },\n  }\n\n  items: BottomNavItem[] = []\n  value = ''\n  defaultValue = ''\n  activeColor = 'text-primary'\n  fixed = true\n  showIndicator = true\n  safeArea = true\n\n  private indicatorStyle: Record<string, string> = { opacity: '0' }\n  private firstPosition = true\n  private ro?: ResizeObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'bottom-navigation')\n    this.setAttribute('role', 'navigation')\n    this.setAttribute('aria-label', 'Bottom navigation')\n\n    if (!this.value && this.defaultValue) {\n      this.value = this.defaultValue\n    } else if (!this.value && this.items.length > 0) {\n      this.value = this.items[0]?.value ?? ''\n    }\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.ro?.disconnect()\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (isServer) return\n\n    if (this.fixed) {\n      this.setAttribute('data-fixed', '')\n    } else {\n      this.removeAttribute('data-fixed')\n    }\n\n    if (changed.has('value') || changed.has('items') || changed.has('showIndicator')) {\n      if (changed.has('items') || changed.has('showIndicator')) {\n        this.firstPosition = true\n      }\n      this.updateIndicator()\n    }\n\n    if (!this.ro && this.renderRoot) {\n      const root = this.renderRoot.querySelector('nav')\n      if (root) {\n        this.ro = new ResizeObserver(() => this.updateIndicator())\n        this.ro.observe(root)\n      }\n    }\n  }\n\n  private motionSafeTransition() {\n    if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n      return 'none'\n    }\n    return this.firstPosition\n      ? 'none'\n      : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1)'\n  }\n\n  private updateIndicator() {\n    if (!this.showIndicator || isServer) {\n      this.indicatorStyle = { opacity: '0' }\n      return\n    }\n\n    const root = this.renderRoot?.querySelector('nav')\n    if (!root) return\n\n    const activeItem = root.querySelector<HTMLElement>('[data-slot=\"bottom-navigation-item\"][data-active]')\n    if (!activeItem) {\n      this.indicatorStyle = { opacity: '0' }\n      return\n    }\n\n    const iconWrap = activeItem.querySelector<HTMLElement>('[data-slot=\"bottom-navigation-icon\"]') ?? activeItem\n    const rootRect = root.getBoundingClientRect()\n    const iconRect = iconWrap.getBoundingClientRect()\n\n    const padX = 14\n    const pillH = 32\n    const pillW = Math.max(iconRect.width + padX * 2, 56)\n    const left = iconRect.left - rootRect.left + root.scrollLeft + (iconRect.width - pillW) / 2\n    const top = iconRect.top - rootRect.top + root.scrollTop + (iconRect.height - pillH) / 2\n    const transition = this.motionSafeTransition()\n\n    this.indicatorStyle = {\n      width: `${pillW}px`,\n      height: `${pillH}px`,\n      transform: `translate3d(${left}px, ${top}px, 0)`,\n      opacity: '1',\n      transition,\n    }\n    this.firstPosition = false\n  }\n\n  private handleSelect(item: BottomNavItem) {\n    this.value = item.value\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: item.value }, bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('select', { detail: { item }, bubbles: true, composed: true }))\n  }\n\n  private renderIcon(itemIcon?: IconNode | string, isActive = false): TemplateResult | typeof nothing {\n    if (!itemIcon) return nothing\n    const iconClass = cn(\n      'size-5 transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none',\n      isActive ? 'scale-110' : 'scale-100',\n    )\n    if (Array.isArray(itemIcon)) {\n      return icon(itemIcon as IconNode, 'nav-icon', iconClass)\n    }\n    if (typeof itemIcon === 'string') {\n      return html`<span class=${iconClass} .innerHTML=${itemIcon}></span>`\n    }\n    return nothing\n  }\n\n  render() {\n    return html`\n      <nav\n        part=\"base\"\n        data-uipkge=\"\"\n        data-slot=\"bottom-navigation\"\n        class=${cn(\n          'border-border bg-background/95 z-50 flex items-stretch justify-around border-t backdrop-blur-sm',\n          this.fixed ? 'fixed inset-x-0 bottom-0' : 'relative',\n          this.safeArea && 'pb-[env(safe-area-inset-bottom)]',\n        )}\n      >\n        ${this.showIndicator\n          ? html`\n              <span\n                part=\"indicator\"\n                data-slot=\"bottom-navigation-indicator\"\n                aria-hidden=\"true\"\n                class=\"bg-primary/10 pointer-events-none absolute top-0 left-0 z-0 rounded-full will-change-transform\"\n                style=${styleMap(this.indicatorStyle)}\n              ></span>\n            `\n          : nothing}\n\n        ${this.items.map((item) => {\n          const isActive = this.value === item.value\n          const itemClass = cn(\n            'focus-visible:ring-ring/50 relative z-10 flex min-h-12 flex-1 flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 text-xs transition-colors duration-200 outline-none focus-visible:ring-[3px] motion-reduce:transition-none',\n            isActive ? this.activeColor : 'text-muted-foreground hover:text-foreground',\n          )\n\n          const inner = html`\n            <span data-slot=\"bottom-navigation-icon\" class=\"relative flex items-center justify-center\">\n              ${this.renderIcon(item.icon, isActive)}\n              ${item.badge !== undefined && item.badge !== ''\n                ? html`\n                    <span\n                      class=\"bg-destructive text-destructive-foreground absolute -top-1.5 -right-2 flex min-w-4 items-center justify-center rounded-full px-1 text-xs leading-4 font-medium\"\n                    >\n                      ${item.badge}\n                    </span>\n                  `\n                : nothing}\n            </span>\n            <span\n              class=${cn(\n                'max-w-full truncate px-1 transition-[opacity,font-weight] duration-200 motion-reduce:transition-none',\n                isActive ? 'font-medium' : 'font-normal',\n              )}\n            >\n              ${item.label}\n            </span>\n          `\n\n          if (item.to) {\n            return html`\n              <a\n                href=${item.to}\n                part=\"item\"\n                data-slot=\"bottom-navigation-item\"\n                ?data-active=${isActive}\n                aria-current=${isActive ? 'page' : nothing}\n                class=${itemClass}\n                @click=${(e: MouseEvent) => {\n                  this.handleSelect(item)\n                }}\n              >\n                ${inner}\n              </a>\n            `\n          }\n\n          return html`\n            <button\n              type=\"button\"\n              part=\"item\"\n              data-slot=\"bottom-navigation-item\"\n              ?data-active=${isActive}\n              aria-current=${isActive ? 'page' : nothing}\n              class=${itemClass}\n              @click=${() => this.handleSelect(item)}\n            >\n              ${inner}\n            </button>\n          `\n        })}\n      </nav>\n    `\n  }\n}\n\ncustomElements.get('uip-bottom-navigation') || customElements.define('uip-bottom-navigation', UipBottomNavigation)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-bottom-navigation': UipBottomNavigation\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/bottom-navigation/bottom-navigation.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
