{"name":"popover","type":"registry:component","title":"Popover","description":"A popover component built with Lit and styled using Tailwind CSS. Uses Floating UI for positioning with support for flip, shift, and arrow options.","categories":["ui","popover","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","registryDependencies":["@lit/button"],"dependencies":["@floating-ui/dom","lucide-static"],"files":[{"path":"registry/ui/popover/popover.ts","type":"registry:ui","content":"import {\n  arrow,\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  type Placement,\n  type Strategy,\n  shift,\n} from \"@floating-ui/dom\";\nimport { css, html, LitElement, type PropertyValues } from \"lit\";\nimport { customElement, property, query, state } from \"lit/decorators.js\";\nimport { TW } from \"@/lib/tailwindMixin\";\nimport { cn } from \"@/lib/utils\";\n\n@customElement(\"ui-popover\")\nexport class Popover extends TW(LitElement) {\n  @property() placement: Placement = \"top\";\n  @property({ type: Number }) distance = 0;\n  @property({ type: Number }) skidding = 0;\n  @property({ type: Boolean }) flip = false;\n  @property({ type: Boolean }) shift = false;\n  @property({ type: Boolean }) arrow = false;\n  @property({ type: Number }) arrowPadding = 0;\n  @property() strategy: Strategy = \"absolute\";\n  @property({ type: Boolean, reflect: true }) active = false;\n  @property({ attribute: false }) anchor?: Element | string;\n\n  @query('[part=\"popup\"]') popup!: HTMLElement;\n  @query('[part=\"arrow\"]') arrowElement?: HTMLElement;\n\n  @state() private currentPlacement: Placement = \"top\";\n\n  private cleanup?: () => void;\n  private anchorEl?: Element | null;\n\n  static styles = css`\n    :host {\n      display: contents;\n    }\n  `;\n\n  override async connectedCallback() {\n    super.connectedCallback();\n    await this.updateComplete;\n    this.handleAnchorChange();\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.stop();\n  }\n\n  override async updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"anchor\")) {\n      await this.handleAnchorChange();\n    }\n\n    if (changedProperties.has(\"active\")) {\n      if (this.active) {\n        this.start();\n      } else {\n        this.stop();\n      }\n    }\n\n    if (this.active) {\n      await this.updateComplete;\n      this.reposition();\n    }\n  }\n\n  private async handleAnchorChange() {\n    await this.stop();\n\n    if (this.anchor && typeof this.anchor === \"string\") {\n      const root = this.getRootNode() as Document | ShadowRoot;\n      this.anchorEl = root.getElementById(this.anchor);\n    } else if (this.anchor instanceof Element) {\n      this.anchorEl = this.anchor;\n    } else if (\n      this.anchor &&\n      typeof this.anchor === \"object\" &&\n      \"getBoundingClientRect\" in this.anchor\n    ) {\n      this.anchorEl = this.anchor;\n    } else {\n      const slot = this.renderRoot.querySelector<HTMLSlotElement>(\n        'slot[name=\"anchor\"]',\n      );\n      this.anchorEl = slot?.assignedElements({ flatten: true })[0] || null;\n    }\n\n    if (this.anchorEl instanceof HTMLSlotElement) {\n      this.anchorEl = this.anchorEl.assignedElements({\n        flatten: true,\n      })[0] as HTMLElement;\n    }\n\n    if (this.anchorEl && this.active) {\n      this.start();\n    }\n  }\n\n  private start() {\n    if (!this.anchorEl || !this.popup) return;\n\n    this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {\n      this.reposition();\n    });\n  }\n\n  private async stop(): Promise<void> {\n    return new Promise((resolve) => {\n      if (this.cleanup) {\n        this.cleanup();\n        this.cleanup = undefined;\n        this.removeAttribute(\"data-current-placement\");\n        requestAnimationFrame(() => resolve());\n      } else {\n        resolve();\n      }\n    });\n  }\n\n  show() {\n    this.active = true;\n  }\n\n  hide() {\n    this.active = false;\n  }\n\n  toggle() {\n    this.active = !this.active;\n  }\n\n  reposition() {\n    if (!this.active || !this.anchorEl || !this.popup) return;\n\n    const middleware = [\n      offset({ mainAxis: this.distance, crossAxis: this.skidding }),\n    ];\n\n    if (this.flip) {\n      middleware.push(flip());\n    }\n\n    if (this.shift) {\n      middleware.push(shift({ padding: 5 }));\n    }\n\n    if (this.arrow && this.arrowElement) {\n      middleware.push(\n        arrow({ element: this.arrowElement, padding: this.arrowPadding }),\n      );\n    }\n\n    computePosition(this.anchorEl, this.popup, {\n      placement: this.placement,\n      middleware,\n      strategy: this.strategy,\n    }).then(({ x, y, placement, middlewareData }) => {\n      Object.assign(this.popup.style, {\n        position: this.strategy,\n        left: `${x}px`,\n        top: `${y}px`,\n      });\n\n      this.currentPlacement = placement;\n      this.setAttribute(\"data-current-placement\", placement);\n\n      if (this.arrow && this.arrowElement && middlewareData.arrow) {\n        const { x: arrowX, y: arrowY } = middlewareData.arrow;\n\n        const staticSide = {\n          top: \"bottom\",\n          right: \"left\",\n          bottom: \"top\",\n          left: \"right\",\n        }[placement.split(\"-\")[0]] as string;\n\n        Object.assign(this.arrowElement.style, {\n          left: arrowX != null ? `${arrowX}px` : \"\",\n          top: arrowY != null ? `${arrowY}px` : \"\",\n          [staticSide]: \"-4px\",\n        });\n      }\n\n      this.dispatchEvent(\n        new CustomEvent(\"popover-reposition\", {\n          detail: { placement, x, y },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    });\n  }\n\n  override render() {\n    return html`\n      <slot name=\"anchor\" @slotchange=${this.handleAnchorChange}></slot>\n      <div\n        class=${cn(\"w-max z-50\", this.active ? \"\" : \"hidden\", this.className)}\n        part=\"popup\"\n        data-placement=${this.currentPlacement}\n      >\n        <slot></slot>\n        ${\n          this.arrow\n            ? html`<div\n              class=\"absolute w-2 h-2 rotate-45 bg-inherit -z-10\"\n              part=\"arrow\"\n            ></div>`\n            : \"\"\n        }\n      </div>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-popover\": Popover;\n  }\n\n  interface HTMLElementEventMap {\n    \"popover-reposition\": CustomEvent<{\n      placement: Placement;\n      x: number;\n      y: number;\n    }>;\n  }\n}\n"},{"path":"registry/ui/popover/popover.stories.ts","type":"registry:ui","content":"import type { Placement, Strategy } from \"@floating-ui/dom\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport \"../button/button.js\";\nimport \"./popover\";\nimport type { Popover } from \"./popover.js\";\n\ntype PopoverArgs = {\n  placement: Placement;\n  distance: number;\n  skidding: number;\n  flip: boolean;\n  shift: boolean;\n  arrow: boolean;\n  arrowPadding: number;\n  strategy: Strategy;\n  active: boolean;\n  content?: string;\n};\n\n/**\n * Displays rich content in a portal, triggered by a button.\n */\nconst meta: Meta<PopoverArgs> = {\n  title: \"ui/Popover\",\n  component: \"ui-popover\",\n  tags: [\"autodocs\"],\n  argTypes: {\n    placement: {\n      control: \"select\",\n      options: [\n        \"top\",\n        \"top-start\",\n        \"top-end\",\n        \"right\",\n        \"right-start\",\n        \"right-end\",\n        \"bottom\",\n        \"bottom-start\",\n        \"bottom-end\",\n        \"left\",\n        \"left-start\",\n        \"left-end\",\n      ],\n    },\n    distance: {\n      control: { type: \"number\", min: 0, max: 50 },\n    },\n    skidding: {\n      control: { type: \"number\", min: -50, max: 50 },\n    },\n    flip: {\n      control: \"boolean\",\n    },\n    shift: {\n      control: \"boolean\",\n    },\n    arrow: {\n      control: \"boolean\",\n    },\n    arrowPadding: {\n      control: { type: \"number\", min: 0, max: 20 },\n    },\n    strategy: {\n      control: \"select\",\n      options: [\"absolute\", \"fixed\"],\n    },\n    active: {\n      control: \"boolean\",\n    },\n    content: {\n      control: \"text\",\n    },\n  },\n  parameters: {\n    layout: \"centered\",\n  },\n  args: {\n    placement: \"top\",\n    distance: 8,\n    skidding: 0,\n    flip: true,\n    shift: true,\n    arrow: false,\n    arrowPadding: 0,\n    strategy: \"absolute\",\n    active: false,\n    content: \"Place content for the popover here.\",\n  },\n  render: (args) => {\n    const handleToggle = (e: Event) => {\n      const button = e.target as HTMLElement;\n      const popover = button.closest(\"ui-popover\") as Popover;\n      popover?.toggle();\n    };\n\n    return html`\n      <div class=\"flex min-h-64 items-center justify-center\">\n        <ui-popover\n          .placement=${args.placement}\n          .distance=${args.distance}\n          .skidding=${args.skidding}\n          .flip=${args.flip}\n          .shift=${args.shift}\n          .arrow=${args.arrow}\n          .arrowPadding=${args.arrowPadding}\n          .strategy=${args.strategy}\n          .active=${args.active}\n        >\n          <ui-button slot=\"anchor\" variant=\"outline\" @click=${handleToggle}>\n            Open\n          </ui-button>\n          <div\n            class=\"w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none\"\n          >\n            ${args.content}\n          </div>\n        </ui-popover>\n      </div>\n    `;\n  },\n};\n\nexport default meta;\n\ntype Story = StoryObj<PopoverArgs>;\n\n/**\n * The default form of the popover.\n */\nexport const Default: Story = {};\n\n/**\n * Popover positioned to the right.\n */\nexport const Right: Story = {\n  args: {\n    placement: \"right\",\n    content: \"Content positioned to the right.\",\n  },\n};\n\n/**\n * Popover positioned to the bottom.\n */\nexport const Bottom: Story = {\n  args: {\n    placement: \"bottom\",\n    content: \"Content positioned to the bottom.\",\n  },\n};\n\n/**\n * Demonstrates imperative control using show(), hide(), and toggle() methods.\n */\nexport const ImperativeControl: Story = {\n  args: {\n    content: \"Use show() and hide() methods for explicit control.\",\n  },\n  render: (args) => {\n    const handleShow = (e: Event) => {\n      const button = e.target as HTMLElement;\n      const popover = button.closest(\"ui-popover\") as Popover;\n      popover?.show();\n    };\n\n    const handleHide = (e: Event) => {\n      const button = e.target as HTMLElement;\n      const popover = button.closest(\"ui-popover\") as Popover;\n      popover?.hide();\n    };\n\n    return html`\n      <div class=\"flex min-h-64 items-center justify-center gap-2\">\n        <ui-popover\n          .placement=${args.placement}\n          .distance=${args.distance}\n          .skidding=${args.skidding}\n          .flip=${args.flip}\n          .shift=${args.shift}\n          .arrow=${args.arrow}\n          .arrowPadding=${args.arrowPadding}\n          .strategy=${args.strategy}\n          .active=${args.active}\n        >\n          <div slot=\"anchor\" class=\"flex gap-2\">\n            <ui-button variant=\"default\" @click=${handleShow}>Show</ui-button>\n            <ui-button variant=\"outline\" @click=${handleHide}>Hide</ui-button>\n          </div>\n          <div\n            class=\"w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none\"\n          >\n            ${args.content}\n          </div>\n        </ui-popover>\n      </div>\n    `;\n  },\n};\n\n/**\n * Custom styling can be applied via the class attribute on the popover.\n */\nexport const CustomStyling: Story = {\n  args: {\n    content:\n      \"This popover has a max-width applied via the class attribute on the ui-popover element. The class is forwarded to the popup container.\",\n  },\n  render: (args) => {\n    const handleToggle = (e: Event) => {\n      const button = e.target as HTMLElement;\n      const popover = button.closest(\"ui-popover\") as Popover;\n      popover?.toggle();\n    };\n\n    return html`\n      <div class=\"flex min-h-64 items-center justify-center\">\n        <ui-popover\n          class=\"max-w-sm\"\n          .placement=${args.placement}\n          .distance=${args.distance}\n          .skidding=${args.skidding}\n          .flip=${args.flip}\n          .shift=${args.shift}\n          .arrow=${args.arrow}\n          .arrowPadding=${args.arrowPadding}\n          .strategy=${args.strategy}\n          .active=${args.active}\n        >\n          <ui-button slot=\"anchor\" variant=\"outline\" @click=${handleToggle}>\n            Open with custom width\n          </ui-button>\n          <div\n            class=\"rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none\"\n          >\n            ${args.content}\n          </div>\n        </ui-popover>\n      </div>\n    `;\n  },\n};\n"}]}