{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "overlay-scroll",
  "title": "Overlay Scroll",
  "type": "registry:ui",
  "description": "Slack-style overlay scrollbar. Hides the native scrollbar so the scrolled content uses the full container width (no gutter reservation), then draws a thin auto-fading thumb absolutely positioned on top. Drag-to-scroll via Pointer Events covers mouse, touch, and pen. Vertical only. The component does not enforce a height — give it a bounded height via the parent (`flex-1 min-h-0` in a flex column, or a fixed `h-*` / `max-h-*`).",
  "categories": [
    "layout"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/overlay-scroll/overlay-scroll.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\nconst overlayScrollCss = css`\n  /* A flex column (min-height: 0) so the viewport scrolls when the host is\n     sized by a flex parent or a max-height, not only by an explicit height. */\n  :host {\n    display: flex;\n    flex-direction: column;\n    min-height: 0;\n    position: relative;\n    overflow: hidden;\n  }\n  .overlay-scroll__inner {\n    scrollbar-width: none;\n    -ms-overflow-style: none;\n    overscroll-behavior: contain;\n  }\n  .overlay-scroll__inner::-webkit-scrollbar {\n    display: none;\n    width: 0;\n    height: 0;\n  }\n  .overlay-scroll__thumb {\n    position: absolute;\n    top: 0;\n    border-radius: 2px;\n    background: var(--muted-foreground);\n    opacity: 0;\n    pointer-events: none;\n    touch-action: none;\n    user-select: none;\n    -webkit-user-select: none;\n    transition:\n      opacity 0.2s ease,\n      background-color 0.15s,\n      width 0.12s ease;\n    will-change: transform, opacity;\n  }\n  .overlay-scroll__thumb--draggable {\n    cursor: pointer;\n  }\n  .overlay-scroll__thumb--visible {\n    opacity: 0.4;\n    pointer-events: auto;\n  }\n  :host(:hover) .overlay-scroll__thumb--visible {\n    opacity: 0.6;\n  }\n  :host(:hover) .overlay-scroll__thumb--draggable:hover {\n    opacity: 0.8;\n    width: 8px !important;\n    background: var(--foreground);\n  }\n  .overlay-scroll__thumb--dragging {\n    opacity: 1 !important;\n    background: var(--foreground) !important;\n    width: 8px !important;\n  }\n`\n\n/**\n * <uip-overlay-scroll> — Slack-style auto-fading overlay scrollbar.\n */\nexport class UipOverlayScroll extends LitElement {\n  static styles = [tailwind, overlayScrollCss]\n\n  static properties = {\n    thumbWidth: { type: Number, attribute: 'thumb-width' },\n    thumbOffset: { type: Number, attribute: 'thumb-offset' },\n    idleHideMs: { type: Number, attribute: 'idle-hide-ms' },\n    draggable: { converter: trueByDefault },\n    thumbHeight: { state: true },\n    thumbTop: { state: true },\n    showThumb: { state: true },\n    isDragging: { state: true },\n  }\n\n  thumbWidth = 4\n  thumbOffset = 2\n  idleHideMs = 800\n  draggable = true\n\n  private thumbHeight = 0\n  private thumbTop = 0\n  private showThumb = false\n  private isDragging = false\n\n  private isHovered = false\n  private hideTimer: ReturnType<typeof setTimeout> | null = null\n  private activePointerId: number | null = null\n  private dragStartY = 0\n  private dragStartScrollTop = 0\n  private ro?: ResizeObserver\n  private mo?: MutationObserver\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', 'overlay-scroll')\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.ro?.disconnect()\n    this.mo?.disconnect()\n    if (this.hideTimer) clearTimeout(this.hideTimer)\n  }\n\n  get scrollerEl(): HTMLElement | null {\n    return this.renderRoot?.querySelector('.overlay-scroll__inner') ?? null\n  }\n\n  scrollTo(options?: ScrollToOptions): void\n  scrollTo(x: number, y: number): void\n  scrollTo(xOrOptions?: number | ScrollToOptions, y?: number): void {\n    if (typeof xOrOptions === 'number') {\n      this.scrollerEl?.scrollTo(xOrOptions, y ?? 0)\n    } else if (xOrOptions) {\n      this.scrollerEl?.scrollTo(xOrOptions)\n    }\n  }\n\n  recompute = () => {\n    const el = this.scrollerEl\n    if (!el) return\n    const ratio = el.clientHeight / el.scrollHeight\n    if (!Number.isFinite(ratio) || ratio >= 1) {\n      this.thumbHeight = 0\n      return\n    }\n    const h = Math.max(24, el.clientHeight * ratio)\n    this.thumbHeight = h\n    const maxScroll = el.scrollHeight - el.clientHeight\n    const maxThumb = el.clientHeight - h\n    this.thumbTop = maxScroll > 0 ? (el.scrollTop / maxScroll) * maxThumb : 0\n  }\n\n  private flashThumb() {\n    if (this.thumbHeight === 0) return\n    this.showThumb = true\n    if (this.hideTimer) clearTimeout(this.hideTimer)\n    this.hideTimer = setTimeout(() => {\n      if (!this.isHovered && !this.isDragging) this.showThumb = false\n    }, this.idleHideMs)\n  }\n\n  private onScroll = () => {\n    this.recompute()\n    this.flashThumb()\n  }\n\n  private onMouseEnter = () => {\n    this.isHovered = true\n    this.recompute()\n    if (this.thumbHeight > 0) this.showThumb = true\n  }\n\n  private onMouseLeave = () => {\n    this.isHovered = false\n    if (this.isDragging) return\n    if (this.hideTimer) clearTimeout(this.hideTimer)\n    this.showThumb = false\n  }\n\n  private onPointerMove = (e: PointerEvent) => {\n    if (this.activePointerId !== e.pointerId) return\n    const el = this.scrollerEl\n    if (!el) return\n    const maxScroll = el.scrollHeight - el.clientHeight\n    const maxThumb = el.clientHeight - this.thumbHeight\n    if (maxThumb <= 0) return\n    const scrollRatio = maxScroll / maxThumb\n    el.scrollTop = this.dragStartScrollTop + (e.clientY - this.dragStartY) * scrollRatio\n  }\n\n  private endDrag = (e?: PointerEvent) => {\n    if (e && this.activePointerId !== e.pointerId) return\n    this.isDragging = false\n    const thumb = this.renderRoot?.querySelector<HTMLElement>('.overlay-scroll__thumb')\n    if (thumb && this.activePointerId !== null) {\n      try {\n        thumb.releasePointerCapture(this.activePointerId)\n      } catch {\n        // ignore\n      }\n    }\n    this.activePointerId = null\n    window.removeEventListener('pointermove', this.onPointerMove)\n    window.removeEventListener('pointerup', this.endDrag)\n    window.removeEventListener('pointercancel', this.endDrag)\n    if (!this.isHovered) this.showThumb = false\n  }\n\n  private onThumbPointerDown = (e: PointerEvent) => {\n    if (!this.draggable || !this.scrollerEl) return\n    if (e.pointerType === 'mouse' && e.button !== 0) return\n    e.preventDefault()\n    this.isDragging = true\n    this.activePointerId = e.pointerId\n    this.dragStartY = e.clientY\n    this.dragStartScrollTop = this.scrollerEl.scrollTop\n\n    const thumb = e.currentTarget as HTMLElement\n    thumb.setPointerCapture(e.pointerId)\n    window.addEventListener('pointermove', this.onPointerMove)\n    window.addEventListener('pointerup', this.endDrag)\n    window.addEventListener('pointercancel', this.endDrag)\n  }\n\n  protected firstUpdated() {\n    this.recompute()\n    const scroller = this.scrollerEl\n    if (!scroller) return\n\n    this.ro = new ResizeObserver(this.recompute)\n    this.ro.observe(scroller)\n    this.ro.observe(this)\n\n    this.mo = new MutationObserver(this.recompute)\n    this.mo.observe(this, { childList: true, subtree: true })\n  }\n\n  render() {\n    const thumbStyle = {\n      width: `${this.thumbWidth}px`,\n      right: `var(--ovs-thumb-right, ${this.thumbOffset}px)`,\n      height: `${this.thumbHeight}px`,\n      transform: `translateY(${this.thumbTop}px)`,\n    }\n\n    return html`\n      <div\n        class=\"relative flex min-h-0 w-full flex-1 flex-col overflow-hidden\"\n        @mouseenter=${this.onMouseEnter}\n        @mouseleave=${this.onMouseLeave}\n      >\n        <div\n          part=\"viewport\"\n          data-slot=\"overlay-scroll-viewport\"\n          class=\"overlay-scroll__inner min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto\"\n          @scroll=${this.onScroll}\n        >\n          <slot @slotchange=${this.recompute}></slot>\n        </div>\n\n        <div\n          part=\"thumb\"\n          data-slot=\"overlay-scroll-thumb\"\n          aria-hidden=\"true\"\n          class=${cn(\n            'overlay-scroll__thumb',\n            this.showThumb && 'overlay-scroll__thumb--visible',\n            this.isDragging && 'overlay-scroll__thumb--dragging',\n            this.draggable && 'overlay-scroll__thumb--draggable',\n          )}\n          style=${styleMap(thumbStyle)}\n          @pointerdown=${this.onThumbPointerDown}\n        ></div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-overlay-scroll') || customElements.define('uip-overlay-scroll', UipOverlayScroll)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-overlay-scroll': UipOverlayScroll\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/overlay-scroll/overlay-scroll.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
