# Scroll Container

Use `ui-scroll-container` for activity feeds, inboxes, logs, lists inside drawers, and any surface where users should be able to scroll through progressively loaded items without paging away.

## Import
```ts
import { ScrollContainerComponent, type ScrollContainerDataSource } from 'ui';
```

## Basic infinite list
```ts
import { Component, signal } from '@angular/core';
import { delay, of } from 'rxjs';
import { ScrollContainerComponent, type ScrollContainerDataSource, type Node } from 'ui';

interface BasicItem {
  id: number;
  label: string;
  icon: Node['icon'];
}

@Component({
  selector: 'app-scroll-container-basic-demo',
  standalone: true,
  imports: [ScrollContainerComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:26rem">
      <div
        style="display:flex;flex-wrap:wrap;gap:1rem;align-items:center;padding:0.75rem 0.875rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest)"
      >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Loaded pages: <strong>{{ loadedPages() }}</strong></span
        >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Last page size: <strong>{{ lastPageSize() }}</strong></span
        >
      </div>

      <div
        style="border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest);padding:0.5rem"
      >
        <ui-scroll-container
          [dataSource]="dataSource"
          [pageSize]="10"
          maxHeight="22rem"
          nodeSize="medium"
          appearance="subtle"
          shape="rounded"
          (loadMore)="onLoadMore($event.page, $event.items.length)"
        />
      </div>
    </div>
  `,
})
export class ScrollContainerBasicDemoComponent {
  protected readonly loadedPages = signal(1);
  protected readonly lastPageSize = signal(10);

  protected readonly dataSource: ScrollContainerDataSource<BasicItem> = (page, pageSize) => {
    const start = (page - 1) * pageSize + 1;
    const items = Array.from({ length: pageSize }, (_, index) => ({
      id: start + index,
      label: `Activity row ${start + index}`,
      icon: (index % 2 === 0 ? 'document' : 'clock') as Node['icon'],
    }));

    return of({
      items,
      hasNextPage: page < 5,
      hasPreviousPage: page > 1,
      totalCount: 50,
    }).pipe(delay(250));
  };

  protected onLoadMore(page: number, size: number): void {
    this.loadedPages.set(page);
    this.lastPageSize.set(size);
  }
}
```

## Selectable rows
```ts
import { Component, signal } from '@angular/core';
import { delay, of } from 'rxjs';
import {
  ButtonComponent,
  ScrollContainerComponent,
  type Node,
  type ScrollContainerDataSource,
} from 'ui';

interface SelectionItem {
  id: number;
  label: string;
  icon: Node['icon'];
  meta: string;
}

@Component({
  selector: 'app-scroll-container-selection-demo',
  standalone: true,
  imports: [ButtonComponent, ScrollContainerComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:30rem">
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;padding:0.75rem 0.875rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest)"
      >
        <ui-button variant="secondary" appearance="outline" (click)="clear()"
          >Clear selection</ui-button
        >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Selected: <strong>{{ selectedLabel() || 'none' }}</strong></span
        >
      </div>

      <div
        style="border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest);padding:0.5rem"
      >
        <ui-scroll-container
          [dataSource]="dataSource"
          [pageSize]="12"
          maxHeight="24rem"
          nodeSize="medium"
          appearance="filled"
          shape="rounded"
          [showSelectionIndicator]="true"
          indicatorPosition="horizontal"
          (itemSelect)="selectedLabel.set($event.item.label)"
        />
      </div>
    </div>
  `,
})
export class ScrollContainerSelectionDemoComponent {
  protected readonly selectedLabel = signal('');

  protected readonly dataSource: ScrollContainerDataSource<SelectionItem> = (page, pageSize) => {
    const start = (page - 1) * pageSize + 1;
    const items = Array.from({ length: pageSize }, (_, index) => ({
      id: start + index,
      label: `Inbox thread ${start + index}`,
      icon: (index % 3 === 0 ? 'mail' : 'chat') as Node['icon'],
      meta: index % 2 === 0 ? 'Unread' : 'Updated today',
    }));

    return of({
      items,
      hasNextPage: page < 4,
      hasPreviousPage: page > 1,
      totalCount: 48,
    }).pipe(delay(220));
  };

  protected clear(): void {
    this.selectedLabel.set('');
  }
}
```

## Custom row template
```ts
import { Component } from '@angular/core';
import { delay, of } from 'rxjs';
import { ScrollContainerComponent, type ScrollContainerDataSource } from 'ui';

interface TemplateItem {
  id: number;
  label: string;
  description: string;
  status: string;
}

@Component({
  selector: 'app-scroll-container-custom-template-demo',
  standalone: true,
  imports: [ScrollContainerComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:34rem">
      <div style="font-size:0.875rem;line-height:1.5;color:var(--color-neutral-foreground2-rest)">
        Use a custom row template when each item needs more metadata than a plain node row, but keep
        the layout compact enough to scroll comfortably.
      </div>

      <div
        style="border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest);padding:0.5rem"
      >
        <ui-scroll-container
          [dataSource]="dataSource"
          [pageSize]="8"
          maxHeight="24rem"
          [useNodeComponent]="false"
        >
          <ng-template #itemTemplate let-item>
            <div
              style="display:flex;align-items:flex-start;justify-content:space-between;gap:0.75rem;padding:0.875rem 1rem;border-bottom:1px solid var(--color-neutral-stroke-rest)"
            >
              <div style="display:flex;flex-direction:column;gap:0.1875rem;min-width:0">
                <div
                  style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
                >
                  {{ item.label }}
                </div>
                <div style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)">
                  {{ item.description }}
                </div>
              </div>
              <div
                style="flex:0 0 auto;padding:0.1875rem 0.5rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:999px;background:var(--color-neutral-background2-rest);font-size:0.75rem;color:var(--color-neutral-foreground2-rest)"
              >
                {{ item.status }}
              </div>
            </div>
          </ng-template>
        </ui-scroll-container>
      </div>
    </div>
  `,
})
export class ScrollContainerCustomTemplateDemoComponent {
  protected readonly dataSource: ScrollContainerDataSource<TemplateItem> = (page, pageSize) => {
    const start = (page - 1) * pageSize + 1;
    const items = Array.from({ length: pageSize }, (_, index) => ({
      id: start + index,
      label: `Deployment note ${start + index}`,
      description: 'Short contextual metadata fits well in a denser audit or activity stream.',
      status: index % 2 === 0 ? 'Updated' : 'Review',
    }));

    return of({
      items,
      hasNextPage: page < 3,
      hasPreviousPage: page > 1,
      totalCount: 24,
    }).pipe(delay(260));
  };
}
```

## Programmatic control
```ts
import { Component, signal, viewChild } from '@angular/core';
import { delay, of } from 'rxjs';
import {
  ButtonComponent,
  ScrollContainerComponent,
  type Node,
  type ScrollContainerDataSource,
} from 'ui';

interface ProgrammaticItem {
  id: number;
  label: string;
  icon: Node['icon'];
}

@Component({
  selector: 'app-scroll-container-programmatic-demo',
  standalone: true,
  imports: [ButtonComponent, ScrollContainerComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:30rem">
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;padding:0.75rem 0.875rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest)"
      >
        <ui-button variant="secondary" appearance="outline" size="small" (click)="scrollToTop()"
          >Scroll to top</ui-button
        >
        <ui-button variant="secondary" appearance="outline" size="small" (click)="scrollToBottom()"
          >Scroll to bottom</ui-button
        >
        <ui-button variant="secondary" appearance="outline" size="small" (click)="refresh()"
          >Refresh</ui-button
        >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Status: <strong>{{ status() }}</strong></span
        >
      </div>

      <div
        style="border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest);padding:0.5rem"
      >
        <ui-scroll-container
          #container
          [dataSource]="dataSource"
          [pageSize]="10"
          maxHeight="22rem"
          nodeSize="small"
          appearance="subtle"
        />
      </div>
    </div>
  `,
})
export class ScrollContainerProgrammaticDemoComponent {
  protected readonly container = viewChild<ScrollContainerComponent<ProgrammaticItem>>('container');
  protected readonly status = signal('idle');

  protected readonly dataSource: ScrollContainerDataSource<ProgrammaticItem> = (page, pageSize) => {
    const start = (page - 1) * pageSize + 1;
    const items = Array.from({ length: pageSize }, (_, index) => ({
      id: start + index,
      label: `Log line ${start + index}`,
      icon: (index % 2 === 0 ? 'document' : 'text_bullet_list_ltr') as Node['icon'],
    }));

    return of({
      items,
      hasNextPage: page < 6,
      hasPreviousPage: page > 1,
      totalCount: 60,
    }).pipe(delay(150));
  };

  protected scrollToTop(): void {
    this.container()?.scrollToTop();
    this.status.set('Scrolled to top');
  }

  protected scrollToBottom(): void {
    this.container()?.scrollToBottom();
    this.status.set('Scrolled to bottom');
  }

  protected refresh(): void {
    this.container()?.refresh();
    this.status.set('Refreshed data source');
  }
}
```

## Load lifecycle events
```ts
import { Component, signal } from '@angular/core';
import { delay, of } from 'rxjs';
import {
  ButtonComponent,
  ScrollContainerComponent,
  type Node,
  type ScrollContainerDataSource,
} from 'ui';

interface EventItem {
  id: number;
  label: string;
  icon: Node['icon'];
}

@Component({
  selector: 'app-scroll-container-events-demo',
  standalone: true,
  imports: [ButtonComponent, ScrollContainerComponent],
  template: `
    <div style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:30rem">
      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;padding:0.75rem 0.875rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest)"
      >
        <ui-button variant="secondary" appearance="outline" (click)="reset()"
          >Reset counters</ui-button
        >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Load events: <strong>{{ loadMoreCount() }}</strong></span
        >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Complete: <strong>{{ completed() ? 'yes' : 'no' }}</strong></span
        >
      </div>

      <div
        style="border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest);padding:0.5rem"
      >
        <ui-scroll-container
          [dataSource]="dataSource"
          [pageSize]="6"
          maxHeight="20rem"
          nodeSize="medium"
          appearance="subtle"
          (loadMore)="onLoadMore()"
          (loadComplete)="completed.set(true)"
        />
      </div>
    </div>
  `,
})
export class ScrollContainerEventsDemoComponent {
  protected readonly loadMoreCount = signal(0);
  protected readonly completed = signal(false);

  protected readonly dataSource: ScrollContainerDataSource<EventItem> = (page, pageSize) => {
    const start = (page - 1) * pageSize + 1;
    const items = Array.from({ length: pageSize }, (_, index) => ({
      id: start + index,
      label: `Background job ${start + index}`,
      icon: (index % 2 === 0 ? 'arrow_sync' : 'checkmark_circle') as Node['icon'],
    }));

    return of({
      items,
      hasNextPage: page < 3,
      hasPreviousPage: page > 1,
      totalCount: 18,
    }).pipe(delay(180));
  };

  protected reset(): void {
    this.loadMoreCount.set(0);
    this.completed.set(false);
  }

  protected onLoadMore(): void {
    this.loadMoreCount.update(count => count + 1);
  }
}
```

## Inbox or activity panel composition
```ts
import { Component, signal } from '@angular/core';
import { delay, of } from 'rxjs';
import {
  ButtonComponent,
  MessageBarComponent,
  ScrollContainerComponent,
  SearchComponent,
  type Node,
  type ScrollContainerDataSource,
} from 'ui';

interface InboxItem {
  id: number;
  label: string;
  icon: Node['icon'];
}

@Component({
  selector: 'app-scroll-container-inbox-panel-demo',
  standalone: true,
  imports: [ButtonComponent, MessageBarComponent, ScrollContainerComponent, SearchComponent],
  template: `
    <div
      style="display:flex;flex-direction:column;gap:1rem;width:100%;max-width:34rem;padding:1rem;border:1px solid var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background-rest)"
    >
      <div
        style="display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:1rem"
      >
        <div>
          <div style="font-size:0.9375rem;font-weight:600">Inbox panel</div>
          <div style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)">
            A scroll container often sits inside a real panel with search, status, and compact
            actions.
          </div>
        </div>
        <ui-button
          variant="secondary"
          appearance="outline"
          size="small"
          (click)="lastAction.set('Marked all as read')"
        >
          Mark all as read
        </ui-button>
      </div>

      <ui-message-bar
        variant="info"
        title="12 new updates"
        message="Scroll through recent reviews, mentions, and task activity."
        appearance="subtle"
      />

      <ui-search placeholder="Search messages" style="width:100%" />

      <div
        style="border:1px solid var(--color-neutral-stroke-rest);border-radius:0.875rem;background:var(--color-neutral-background-rest);padding:0.5rem"
      >
        <ui-scroll-container
          [dataSource]="dataSource"
          [pageSize]="8"
          maxHeight="20rem"
          nodeSize="medium"
          appearance="subtle"
          shape="rounded"
          [asButton]="true"
          (itemClick)="lastAction.set($event.item.label)"
        />
      </div>

      <div
        style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:center;padding:0.75rem 0.875rem;border:1px dashed var(--color-neutral-stroke-rest);border-radius:1rem;background:var(--color-neutral-background2-rest)"
      >
        <span style="font-size:0.8125rem;color:var(--color-neutral-foreground2-rest)"
          >Last action: <strong>{{ lastAction() || 'none' }}</strong></span
        >
      </div>
    </div>
  `,
})
export class ScrollContainerInboxPanelDemoComponent {
  protected readonly lastAction = signal('');

  protected readonly dataSource: ScrollContainerDataSource<InboxItem> = (page, pageSize) => {
    const start = (page - 1) * pageSize + 1;
    const items = Array.from({ length: pageSize }, (_, index) => ({
      id: start + index,
      label:
        index % 2 === 0
          ? `Review request ${start + index}`
          : `Mention from design ${start + index}`,
      icon: (index % 2 === 0 ? 'document' : 'mail') as Node['icon'],
    }));

    return of({
      items,
      hasNextPage: page < 4,
      hasPreviousPage: page > 1,
      totalCount: 32,
    }).pipe(delay(220));
  };
}
```

## Accessibility

### Scrollable region semantics
`ui-scroll-container` delegates the scrollable shell to `ui-scroll-panel` and exposes an accessible label for the scroll region through `ariaLabel`. Provide a more specific label when the default text is not enough for the surrounding screen.

### Keyboard behavior
Keyboard behavior depends on the rendered row content.

| Key | Action |
| --- | --- |
| Tab / Shift+Tab | Move focus into interactive rows or controls inside the scroll region. |
| Arrow keys / Page keys | Follow the browser and inner control behavior for scrolling and focused rows. |
| Enter / Space | Activate the focused row when rows are rendered as interactive `ui-node` buttons or custom controls. |

### Custom templates and row meaning
When you replace the default node rendering with a custom template, preserve a clear row structure and accessible interactive targets. If a custom row becomes clickable, make sure that behavior remains discoverable and keyboard reachable.
