{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "scroll-area",
  "title": "Scroll Area",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/scroll-area/scroll-area.component.ts",
      "content": "import {\n  AfterViewInit,\n  Component,\n  ElementRef,\n  Input,\n  OnDestroy,\n  OnInit,\n  ViewChild,\n  ViewEncapsulation,\n  booleanAttribute,\n  computed,\n  effect,\n  forwardRef,\n  inject,\n  signal,\n  untracked,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\n\nexport type ScrollBarOrientation = 'vertical' | 'horizontal'\nexport type ScrollAreaType = 'auto' | 'always' | 'scroll' | 'hover'\n\n/** Radix ScrollAreaViewportStyle, copied verbatim: hides the native scrollbar on the viewport. */\nconst VIEWPORT_STYLE = `[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}`\n\ninterface Sizes {\n  content: number\n  viewport: number\n  scrollbar: { size: number; paddingStart: number; paddingEnd: number }\n}\n\nconst clamp = (v: number, [lo, hi]: [number, number]) => Math.min(hi, Math.max(lo, v))\nfunction linearScale(input: [number, number], output: [number, number]) {\n  return (value: number) => {\n    if (input[0] === input[1] || output[0] === output[1]) return output[0]\n    const ratio = (output[1] - output[0]) / (input[1] - input[0])\n    return output[0] + ratio * (value - input[0])\n  }\n}\nconst toInt = (value: string) => (value ? parseInt(value, 10) : 0)\nexport function thumbRatio(viewport: number, content: number): number {\n  const ratio = viewport / content\n  return isNaN(ratio) ? 0 : ratio\n}\n/** Radix: thumb length = track length * viewport/content, never below 18px. */\nexport function thumbSize(sizes: Sizes): number {\n  const ratio = thumbRatio(sizes.viewport, sizes.content)\n  const padding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd\n  return Math.max((sizes.scrollbar.size - padding) * ratio, 18)\n}\nfunction scrollFromPointer(\n  pointerPos: number,\n  pointerOffset: number,\n  sizes: Sizes,\n  dir: 'ltr' | 'rtl' = 'ltr',\n): number {\n  const size = thumbSize(sizes)\n  const offset = pointerOffset || size / 2\n  const minPointer = sizes.scrollbar.paddingStart + offset\n  const maxPointer = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - (size - offset)\n  const maxScroll = sizes.content - sizes.viewport\n  const range: [number, number] = dir === 'ltr' ? [0, maxScroll] : [maxScroll * -1, 0]\n  return linearScale([minPointer, maxPointer], range)(pointerPos)\n}\nexport function thumbOffsetFromScroll(scrollPos: number, sizes: Sizes, dir: 'ltr' | 'rtl' = 'ltr'): number {\n  const size = thumbSize(sizes)\n  const padding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd\n  const track = sizes.scrollbar.size - padding\n  const maxScroll = sizes.content - sizes.viewport\n  const range: [number, number] = dir === 'ltr' ? [0, maxScroll] : [maxScroll * -1, 0]\n  return linearScale([0, maxScroll], [0, track - size])(clamp(scrollPos, range))\n}\n\nfunction debounce(fn: () => void, ms: number) {\n  let timer: ReturnType<typeof setTimeout> | undefined\n  const run = () => {\n    clearTimeout(timer)\n    timer = setTimeout(fn, ms)\n  }\n  run.cancel = () => clearTimeout(timer)\n  return run\n}\n\n/**\n * Angular port of UIPKGE ScrollArea (React: Radix ScrollArea). Root > viewport (native\n * scrollbar hidden, overflow:scroll on each axis that has a scrollbar) > content, plus the\n * vertical ScrollBar and Corner React always renders. Add `<ui-scroll-bar orientation=\"horizontal\" />`\n * inside the content for the x-axis, exactly like React. `type` controls visibility like Radix:\n * hover (default: while hovered, when overflowing), scroll (while scrolling), auto (when\n * overflowing), always.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-scroll-area, [ui-scroll-area]',\n  standalone: true,\n  encapsulation: ViewEncapsulation.None,\n  styles: [VIEWPORT_STYLE],\n  imports: [forwardRef(() => UiScrollBarComponent)],\n  host: {\n    '[attr.data-slot]': '\"scroll-area\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.dir]': 'dir',\n    '[class]': 'hostClass',\n    '[style.--radix-scroll-area-corner-width]': 'cornerWidth() + \"px\"',\n    '[style.--radix-scroll-area-corner-height]': 'cornerHeight() + \"px\"',\n    '(pointerenter)': 'onPointerEnter()',\n    '(pointerleave)': 'onPointerLeave()',\n  },\n  template: `\n    <div\n      #viewport\n      data-radix-scroll-area-viewport=\"\"\n      data-uipkge=\"\"\n      data-slot=\"scroll-area-viewport\"\n      [class]=\"viewportClass\"\n      [style.overflow-x]=\"scrollbarXEnabled() > 0 ? 'scroll' : 'hidden'\"\n      [style.overflow-y]=\"scrollbarYEnabled() > 0 ? 'scroll' : 'hidden'\"\n    >\n      <div #content class=\"table min-w-full\"><ng-content /></div>\n    </div>\n    <ui-scroll-bar />\n    @if (hasCorner()) {\n      <div\n        class=\"absolute bottom-0\"\n        [style.width.px]=\"cornerWidth()\"\n        [style.height.px]=\"cornerHeight()\"\n        [style.right]=\"dir === 'ltr' ? '0px' : null\"\n        [style.left]=\"dir === 'rtl' ? '0px' : null\"\n      ></div>\n    }\n  `,\n})\nexport class UiScrollAreaComponent implements OnDestroy {\n  @Input() type: ScrollAreaType = 'hover'\n  @Input() dir: 'ltr' | 'rtl' = 'ltr'\n  @Input() scrollHideDelay = 600\n  @Input('class') className?: string\n\n  @ViewChild('viewport', { static: true }) viewportRef!: ElementRef<HTMLElement>\n  @ViewChild('content', { static: true }) contentRef!: ElementRef<HTMLElement>\n\n  readonly scrollbarXEnabled = signal(0)\n  readonly scrollbarYEnabled = signal(0)\n  /** Currently mounted (visible) scrollbars, for the corner. */\n  readonly scrollbarX = signal<HTMLElement | null>(null)\n  readonly scrollbarY = signal<HTMLElement | null>(null)\n  readonly cornerWidth = signal(0)\n  readonly cornerHeight = signal(0)\n  readonly hoverVisible = signal(false)\n  private hideTimer?: ReturnType<typeof setTimeout>\n  private cornerRo?: ResizeObserver\n\n  readonly hasCorner = computed(() => {\n    const both = !!(this.scrollbarX() && this.scrollbarY())\n    return both && this.cornerWidth() > 0 && this.cornerHeight() > 0\n  })\n\n  get hostClass(): string {\n    return cn('block relative', this.className)\n  }\n\n  get viewportClass(): string {\n    return 'focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1'\n  }\n\n  get viewport(): HTMLElement {\n    return this.viewportRef.nativeElement\n  }\n  get content(): HTMLElement {\n    return this.contentRef.nativeElement\n  }\n\n  onPointerEnter(): void {\n    clearTimeout(this.hideTimer)\n    this.hoverVisible.set(true)\n  }\n\n  onPointerLeave(): void {\n    clearTimeout(this.hideTimer)\n    this.hideTimer = setTimeout(() => this.hoverVisible.set(false), this.scrollHideDelay)\n  }\n\n  /** Called by scrollbars when they mount / unmount (Radix Presence + ref). */\n  setMounted(orientation: ScrollBarOrientation, el: HTMLElement | null): void {\n    const target = orientation === 'horizontal' ? this.scrollbarX : this.scrollbarY\n    if (target() === el) return\n    target.set(el)\n    this.observeCorner()\n  }\n\n  private observeCorner(): void {\n    this.cornerRo?.disconnect()\n    const x = this.scrollbarX()\n    const y = this.scrollbarY()\n    const measure = () => {\n      this.cornerHeight.set(this.type !== 'scroll' && x && y ? x.offsetHeight : 0)\n      this.cornerWidth.set(this.type !== 'scroll' && x && y ? y.offsetWidth : 0)\n    }\n    measure()\n    if (!x || !y || typeof ResizeObserver === 'undefined') return\n    this.cornerRo = new ResizeObserver(() => requestAnimationFrame(measure))\n    this.cornerRo.observe(x)\n    this.cornerRo.observe(y)\n  }\n\n  ngOnDestroy(): void {\n    clearTimeout(this.hideTimer)\n    this.cornerRo?.disconnect()\n  }\n}\n\ntype ScrollState = 'hidden' | 'scrolling' | 'interacting' | 'idle'\nconst SCROLL_MACHINE: Record<ScrollState, Partial<Record<string, ScrollState>>> = {\n  hidden: { SCROLL: 'scrolling' },\n  scrolling: { SCROLL_END: 'idle', POINTER_ENTER: 'interacting' },\n  interacting: { SCROLL: 'interacting', POINTER_LEAVE: 'idle' },\n  idle: { HIDE: 'hidden', SCROLL: 'scrolling', POINTER_ENTER: 'interacting' },\n}\n\n/**\n * ScrollBar (Radix Scrollbar + Thumb). Absolutely positioned against the root, sized and\n * positioned from the viewport's scroll metrics; drag the thumb or press the track to scroll,\n * wheel over it to scroll the viewport. Class strings verbatim from React.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-scroll-bar, [ui-scroll-bar]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"scroll-area-scrollbar\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-orientation]': 'orientation',\n    '[attr.data-state]': 'dataState()',\n    '[attr.hidden]': 'present() ? null : \"\"',\n    '[class]': 'hostClass',\n    '[style]': 'hostStyle()',\n    '(pointerdown)': 'onPointerDown($event)',\n    '(pointermove)': 'onPointerMove($event)',\n    '(pointerup)': 'onPointerUp($event)',\n    '(pointerenter)': 'send(\"POINTER_ENTER\")',\n    '(pointerleave)': 'send(\"POINTER_LEAVE\")',\n  },\n  template: `\n    @if (hasThumb()) {\n      <div\n        #thumb\n        data-uipkge=\"\"\n        data-slot=\"scroll-area-thumb\"\n        data-state=\"visible\"\n        class=\"bg-border relative flex-1 rounded-full\"\n        (pointerdown)=\"onThumbPointerDown($event)\"\n        (pointerup)=\"pointerOffset = 0\"\n      ></div>\n    }\n  `,\n})\nexport class UiScrollBarComponent implements OnInit, AfterViewInit, OnDestroy {\n  /** Inert outside a ScrollArea (Radix throws; kept renderable for previews). */\n  private readonly area = inject(UiScrollAreaComponent, { optional: true })\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n\n  @Input() orientation: ScrollBarOrientation = 'vertical'\n  @Input({ transform: booleanAttribute }) forceMount = false\n  @Input('class') className?: string\n\n  readonly sizes = signal<Sizes>({ content: 0, viewport: 0, scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 } })\n  readonly overflowing = signal(false)\n  readonly scrollState = signal<ScrollState>('hidden')\n  readonly hasThumb = computed(() => {\n    const r = thumbRatio(this.sizes().viewport, this.sizes().content)\n    return r > 0 && r < 1\n  })\n\n  pointerOffset = 0\n  private thumbEl: HTMLElement | null = null\n  private rect: DOMRect | null = null\n  private prevUserSelect = ''\n  private cleanups: (() => void)[] = []\n  private idleTimer?: ReturnType<typeof setTimeout>\n  private readonly scrollEnd = debounce(() => this.send('SCROLL_END'), 100)\n  private readonly measureSoon = debounce(() => this.measure(), 10)\n  private readonly checkOverflowSoon = debounce(() => this.checkOverflow(), 10)\n\n  constructor() {\n    // Presence changes (hover / scroll / overflow) mount or unmount the bar for the corner.\n    effect(() => this.syncMounted())\n  }\n\n  @ViewChild('thumb')\n  set thumb(ref: ElementRef<HTMLElement> | undefined) {\n    this.thumbEl = ref?.nativeElement ?? null\n    this.updateThumbPosition()\n  }\n\n  get isHorizontal(): boolean {\n    return this.orientation === 'horizontal'\n  }\n\n  private get type(): ScrollAreaType {\n    return this.area?.type ?? 'always'\n  }\n\n  /** Radix Presence: whether the scrollbar is mounted for the area's `type`. */\n  present(): boolean {\n    if (!this.area) return true\n    if (this.forceMount) return true\n    switch (this.type) {\n      case 'hover':\n        return this.area.hoverVisible() && this.overflowing()\n      case 'scroll':\n        return this.scrollState() !== 'hidden'\n      case 'auto':\n        return this.overflowing()\n      default:\n        return true\n    }\n  }\n\n  dataState(): 'visible' | 'hidden' {\n    switch (this.type) {\n      case 'hover':\n        return this.area?.hoverVisible() ? 'visible' : 'hidden'\n      case 'scroll':\n        return this.scrollState() === 'hidden' ? 'hidden' : 'visible'\n      case 'auto':\n        return this.overflowing() ? 'visible' : 'hidden'\n      default:\n        return 'visible'\n    }\n  }\n\n  get hostClass(): string {\n    return cn(\n      'flex touch-none p-px transition-colors select-none',\n      this.orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',\n      this.orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',\n      this.className,\n    )\n  }\n\n  /** Radix positions the scrollbar inline (edges depend on dir + the corner size). */\n  hostStyle(): Record<string, string> {\n    const dir = this.area?.dir ?? 'ltr'\n    const size = `${thumbSize(this.sizes())}px`\n    if (this.isHorizontal) {\n      return {\n        position: 'absolute',\n        bottom: '0px',\n        left: dir === 'rtl' ? 'var(--radix-scroll-area-corner-width)' : '0px',\n        right: dir === 'ltr' ? 'var(--radix-scroll-area-corner-width)' : '0px',\n        '--radix-scroll-area-thumb-width': size,\n      }\n    }\n    return {\n      position: 'absolute',\n      top: '0px',\n      ...(dir === 'ltr' ? { right: '0px' } : { left: '0px' }),\n      bottom: 'var(--radix-scroll-area-corner-height)',\n      '--radix-scroll-area-thumb-height': size,\n    }\n  }\n\n  send(event: string): void {\n    if (this.type !== 'scroll') return\n    const next = SCROLL_MACHINE[this.scrollState()][event]\n    if (!next) return\n    this.scrollState.set(next)\n    clearTimeout(this.idleTimer)\n    if (next === 'idle') this.idleTimer = setTimeout(() => this.send('HIDE'), this.area?.scrollHideDelay ?? 600)\n  }\n\n  ngOnInit(): void {\n    if (!this.area) return\n    const counter = this.isHorizontal ? this.area.scrollbarXEnabled : this.area.scrollbarYEnabled\n    counter.update((n) => n + 1)\n    this.cleanups.push(() => counter.update((n) => n - 1))\n  }\n\n  ngAfterViewInit(): void {\n    const area = this.area\n    if (!area) return\n    const viewport = area.viewport\n    let prevPos = this.scrollPos(viewport)\n    const onScroll = () => {\n      this.updateThumbPosition()\n      const pos = this.scrollPos(viewport)\n      if (pos !== prevPos) {\n        this.send('SCROLL')\n        this.scrollEnd()\n      }\n      prevPos = pos\n    }\n    viewport.addEventListener('scroll', onScroll)\n    const onWheel = (event: WheelEvent) => {\n      if (!(event.target instanceof Node) || !this.el.contains(event.target)) return\n      const max = this.sizes().content - this.sizes().viewport\n      const next = this.scrollPos(viewport) + (this.isHorizontal ? event.deltaX : event.deltaY)\n      this.setScroll(next)\n      if (next > 0 && next < max) event.preventDefault()\n    }\n    document.addEventListener('wheel', onWheel, { passive: false })\n    this.cleanups.push(\n      () => viewport.removeEventListener('scroll', onScroll),\n      () => document.removeEventListener('wheel', onWheel),\n      () => area.setMounted(this.orientation, null),\n      () => this.scrollEnd.cancel(),\n      () => this.measureSoon.cancel(),\n      () => this.checkOverflowSoon.cancel(),\n    )\n    if (typeof ResizeObserver !== 'undefined') {\n      const ro = new ResizeObserver(() =>\n        requestAnimationFrame(() => {\n          this.measureSoon()\n          this.checkOverflowSoon()\n          this.syncMounted()\n        }),\n      )\n      ro.observe(this.el)\n      ro.observe(area.content)\n      ro.observe(viewport)\n      this.cleanups.push(() => ro.disconnect())\n    }\n    this.sync()\n  }\n\n  /** Re-read scroll metrics, overflow and presence (what the ResizeObservers trigger). */\n  sync(): void {\n    this.measure()\n    this.checkOverflow()\n    this.syncMounted()\n  }\n\n  /** Radix onResize: content / viewport / track sizes for this axis. */\n  private measure(): void {\n    const viewport = this.area?.viewport\n    if (!viewport) return\n    const cs = getComputedStyle(this.el)\n    this.sizes.set(\n      this.isHorizontal\n        ? {\n            content: viewport.scrollWidth,\n            viewport: viewport.offsetWidth,\n            scrollbar: {\n              size: this.el.clientWidth,\n              paddingStart: toInt(cs.paddingLeft),\n              paddingEnd: toInt(cs.paddingRight),\n            },\n          }\n        : {\n            content: viewport.scrollHeight,\n            viewport: viewport.offsetHeight,\n            scrollbar: {\n              size: this.el.clientHeight,\n              paddingStart: toInt(cs.paddingTop),\n              paddingEnd: toInt(cs.paddingBottom),\n            },\n          },\n    )\n    this.updateThumbPosition()\n  }\n\n  private checkOverflow(): void {\n    const v = this.area?.viewport\n    if (!v) return\n    this.overflowing.set(this.isHorizontal ? v.offsetWidth < v.scrollWidth : v.offsetHeight < v.scrollHeight)\n  }\n\n  private syncMounted(): void {\n    const present = this.present()\n    untracked(() => this.area?.setMounted(this.orientation, present ? this.el : null))\n  }\n\n  private scrollPos(viewport: HTMLElement): number {\n    return this.isHorizontal ? viewport.scrollLeft : viewport.scrollTop\n  }\n\n  private setScroll(pos: number): void {\n    const viewport = this.area?.viewport\n    if (!viewport) return\n    if (this.isHorizontal) viewport.scrollLeft = pos\n    else viewport.scrollTop = pos\n  }\n\n  updateThumbPosition(): void {\n    const viewport = this.area?.viewport\n    if (!viewport || !this.thumbEl) return\n    const offset = thumbOffsetFromScroll(\n      this.scrollPos(viewport),\n      this.sizes(),\n      this.isHorizontal ? this.area!.dir : 'ltr',\n    )\n    this.thumbEl.style.transform = this.isHorizontal\n      ? `translate3d(${offset}px, 0, 0)`\n      : `translate3d(0, ${offset}px, 0)`\n  }\n\n  onThumbPointerDown(event: PointerEvent): void {\n    const r = (event.target as HTMLElement).getBoundingClientRect()\n    this.pointerOffset = this.isHorizontal ? event.clientX - r.left : event.clientY - r.top\n  }\n\n  onPointerDown(event: PointerEvent): void {\n    if (event.button !== 0 || !this.area) return\n    const target = event.target as HTMLElement\n    target.setPointerCapture?.(event.pointerId)\n    this.rect = this.el.getBoundingClientRect()\n    this.prevUserSelect = document.body.style.webkitUserSelect\n    document.body.style.webkitUserSelect = 'none'\n    this.area.viewport.style.scrollBehavior = 'auto'\n    this.dragScroll(event)\n  }\n\n  onPointerMove(event: PointerEvent): void {\n    this.dragScroll(event)\n  }\n\n  onPointerUp(event: PointerEvent): void {\n    const target = event.target as HTMLElement\n    if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId)\n    document.body.style.webkitUserSelect = this.prevUserSelect\n    if (this.area) this.area.viewport.style.scrollBehavior = ''\n    this.rect = null\n  }\n\n  private dragScroll(event: PointerEvent): void {\n    if (!this.rect) return\n    const pos = this.isHorizontal ? event.clientX - this.rect.left : event.clientY - this.rect.top\n    this.setScroll(scrollFromPointer(pos, this.pointerOffset, this.sizes(), this.isHorizontal ? this.area?.dir : 'ltr'))\n  }\n\n  ngOnDestroy(): void {\n    clearTimeout(this.idleTimer)\n    this.cleanups.splice(0).forEach((fn) => fn())\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/scroll-area/scroll-area.component.ts"
    },
    {
      "path": "packages/registry-angular/components/scroll-area/index.ts",
      "content": "export {\n  UiScrollAreaComponent,\n  UiScrollBarComponent,\n  thumbOffsetFromScroll,\n  thumbRatio,\n  thumbSize,\n  type ScrollAreaType,\n  type ScrollBarOrientation,\n} from './scroll-area.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/scroll-area/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Custom scrollbar that always renders the same way across OSes (no flashing native scrollbars on Windows). Radix semantics: native scrollbar hidden, thumb sized and positioned from scroll metrics, draggable thumb, hover / scroll / auto / always visibility. Use for sidebars, dropdown content, and any overflow region you want to feel consistent.",
  "categories": [
    "layout"
  ]
}