# Clamped list

- Entry point: `@koobiq/components/clamped-text`
- NgModule: `KbqClampedTextModule`
- Page: https://koobiq.io/en/components/clamped-list/overview

## Overview

Clamped List displays long lists compactly, hides items that do not fit the configured limit, and lets users expand the full content.

### Clamped list

By default, 10 items are shown and the rest are hidden. If the hidden portion contains fewer than 6 items, the full list is displayed. These parameters can be changed if needed.

**Example: Clamped-list vertical** (`clamped-list-overview`)

```ts clamped-list-overview-example.ts
import { ChangeDetectionStrategy, Component, signal, viewChildren } from '@angular/core';
import { KbqClampedList, KbqClampedListTrigger } from '@koobiq/components/clamped-text';
import { KbqIcon } from '@koobiq/components/icon';
import { KbqLink, KbqLinkPrefix } from '@koobiq/components/link';

@Component({
    selector: 'clamped-list-overview-example',
    imports: [KbqClampedList, KbqClampedListTrigger, KbqIcon, KbqLink, KbqLinkPrefix],
    template: `
        <!--@TODO update gap with paddings when link underline with text-decoration used (#DS-4778)-->
        <div
            #clampedList="kbqClampedList"
            kbqClampedList
            class="layout-column layout-gap-xxs"
            [items]="items"
            (isCollapsedChange)="focusWhenExpanded($event)"
        >
            @for (item of clampedList.visibleItems(); track item.id) {
                <div class="layout-row layout-align-space-between-start">
                    <a kbqLink target="_blank" [href]="item.url">
                        {{ item.name }}
                    </a>
                    <div style="min-width: 45px;">
                        {{ item.id }}
                    </div>
                </div>
            }
            @if (clampedList.hasToggle()) {
                <a kbqLink kbqClampedListTrigger pseudo>
                    <i
                        kbqLinkPrefix
                        [kbq-icon]="clampedList.isCollapsed() ? 'kbq-chevron-down_16' : 'kbq-chevron-up_16'"
                    ></i>
                    {{
                        clampedList.isCollapsed()
                            ? clampedList.showMoreCountText()
                            : clampedList.localeConfiguration().closeText
                    }}
                </a>
            }
        </div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ClampedListOverviewExample {
    protected readonly collapsed = signal(true);
    protected readonly clampedListItems = viewChildren(KbqLink);
    protected items = [
        {
            id: 'T1557',
            name: 'Adversary-in-the-Middle',
            url: 'https://attack.mitre.org/techniques/T1557/'
        },
        {
            id: 'T1110',
            name: 'Brute Force',
            url: 'https://attack.mitre.org/techniques/T1110/'
        },
        {
            id: 'T1555',
            name: 'Credentials from Password Stores',
            url: 'https://attack.mitre.org/techniques/T1555/'
        },
        {
            id: 'T1212',
            name: 'Exploitation for Credential Access',
            url: 'https://attack.mitre.org/techniques/T1212/'
        },
        {
            id: 'T1187',
            name: 'Forced Authentication',
            url: 'https://attack.mitre.org/techniques/T1187/'
        },
        {
            id: 'T1606',
            name: 'Forge Web Credentials',
            url: 'https://attack.mitre.org/techniques/T1606/'
        },
        {
            id: 'T1056',
            name: 'Input Capture',
            url: 'https://attack.mitre.org/techniques/T1056/'
        },
        {
            id: 'T1556',
            name: 'Modify Authentication Process',
            url: 'https://attack.mitre.org/techniques/T1556/'
        },
        {
            id: 'T1111',
            name: 'Multi-Factor Authentication Interception',
            url: 'https://attack.mitre.org/techniques/T1111/'
        },
        {
            id: 'T1621',
            name: 'Multi-Factor Authentication Request Generation',
            url: 'https://attack.mitre.org/techniques/T1621/'
        },
        {
            id: 'T1040',
            name: 'Network Sniffing',
            url: 'https://attack.mitre.org/techniques/T1040/'
        },
        {
            id: 'T1003',
            name: 'OS Credential Dumping',
            url: 'https://attack.mitre.org/techniques/T1003/'
        },
        {
            id: 'T1528',
            name: 'Steal Application Access Token',
            url: 'https://attack.mitre.org/techniques/T1528/'
        },
        {
            id: 'T1649',
            name: 'Steal or Forge Authentication Certificates',
            url: 'https://attack.mitre.org/techniques/T1649/'
        },
        {
            id: 'T1558',
            name: 'Steal or Forge Kerberos Tickets',
            url: 'https://attack.mitre.org/techniques/T1558/'
        },
        {
            id: 'T1539',
            name: 'Steal Web Session Cookie',
            url: 'https://attack.mitre.org/techniques/T1539/'
        },
        {
            id: 'T1552',
            name: 'Unsecured Credentials',
            url: 'https://attack.mitre.org/techniques/T1552/'
        }
    ];

    protected focusWhenExpanded(isCollapsed: boolean) {
        const isExpanded = !isCollapsed;

        if (isExpanded) {
            const links = this.clampedListItems();

            if (links.length) {
                links[0].focus();
            }
        }
    }
}
```

List items can be inline. For compactness, a short label is used on the expand button.

**Example: Clamped-list** (`clamped-list`)

```ts clamped-list-example.ts
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
import { KbqClampedList, KbqClampedListTrigger } from '@koobiq/components/clamped-text';
import { KbqLink } from '@koobiq/components/link';

@Component({
    selector: 'clamped-list-example',
    imports: [KbqClampedList, KbqClampedListTrigger, KbqLink],
    template: `
        <div class="layout-margin-bottom-l layout-margin-top-l">
            <div
                #clampedList="kbqClampedList"
                kbqClampedList
                class="layout-row layout-wrap"
                [items]="countries"
                [(isCollapsed)]="collapsed"
            >
                @for (item of clampedList.visibleItems(); track item) {
                    <div>
                        <span>
                            <span>{{ item }}</span>
                            @if (clampedList.hasToggle()) {
                                <span>,&nbsp;</span>
                            }
                        </span>
                    </div>
                }
                @if (clampedList.hasToggle()) {
                    <a kbqClampedListTrigger kbqLink pseudo>
                        @if (clampedList.isCollapsed()) {
                            {{ clampedList.exceededItemCount() }} {{ clampedList.localeConfiguration().moreText }}
                        } @else {
                            {{ clampedList.localeConfiguration().closeText }}
                        }
                    </a>
                }
            </div>
        </div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ClampedListExample {
    protected readonly collapsed = model(true);
    protected countries = [
        'Australia',
        'Austria',
        'Argentina',
        'Belgium',
        'Brazil',
        'United Kingdom',
        'Germany',
        'Greece',
        'Denmark',
        'Egypt',
        'India',
        'Spain',
        'Italy',
        'Canada',
        'Mexico',
        'Netherlands',
        'Norway',
        'Poland',
        'Portugal',
        'Russia',
        'United States',
        'Thailand',
        'Turkey',
        'France',
        'Japan'
    ];
}
```

A multi-line list with a centered dot as a separator. The label on the text button is capitalized.

**Example: Clamped-list dotted** (`clamped-list-dotted`)

```ts clamped-list-dotted-example.ts
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
import { KbqClampedList, KbqClampedListTrigger } from '@koobiq/components/clamped-text';
import { KbqLink } from '@koobiq/components/link';

@Component({
    selector: 'clamped-list-dotted-example',
    imports: [KbqClampedList, KbqClampedListTrigger, KbqLink],
    template: `
        <div class="layout-margin-bottom-l layout-margin-top-l">
            <div
                #clampedList="kbqClampedList"
                kbqClampedList
                class="layout-row layout-wrap"
                [items]="countries"
                [(isCollapsed)]="collapsed"
            >
                @for (item of clampedList.visibleItems(); track item) {
                    <div>
                        <span class="example-list-item">
                            <span>{{ item }}</span>
                            @if (clampedList.hasToggle()) {
                                <span
                                    class="example-list-item__divider layout-align-center-center layout-margin-left-xxs layout-margin-right-xxs"
                                >
                                    <svg
                                        width="2"
                                        height="2"
                                        viewBox="0 0 2 2"
                                        fill="none"
                                        xmlns="http://www.w3.org/2000/svg"
                                    >
                                        <path
                                            d="M0.894886 1.78977C0.649621 1.78977 0.439157 1.70194 0.263494 1.52628C0.0878314 1.35062 0 1.14015 0 0.894887C0 0.649621 0.0878314 0.439157 0.263494 0.263494C0.439157 0.0878315 0.649621 0 0.894886 0C1.14015 0 1.35062 0.0878315 1.52628 0.263494C1.70194 0.439157 1.78977 0.649621 1.78977 0.894887C1.78977 1.05729 1.74834 1.20644 1.66548 1.34233C1.58594 1.47822 1.47822 1.58759 1.34233 1.67045C1.20975 1.75 1.06061 1.78977 0.894886 1.78977Z"
                                            fill="#6C7393"
                                        />
                                    </svg>
                                </span>
                            }
                        </span>
                    </div>
                }
                @if (clampedList.hasToggle()) {
                    <a kbqClampedListTrigger kbqLink pseudo>
                        @if (clampedList.isCollapsed()) {
                            {{ clampedList.exceededItemCount() }} {{ clampedList.localeConfiguration().moreText }}
                        } @else {
                            {{ clampedList.localeConfiguration().closeText }}
                        }
                    </a>
                }
            </div>
        </div>
    `,
    styles: `
        .example-list-item {
            display: inline-flex;
        }

        .example-list-item__divider {
            display: inline-flex;
        }
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ClampedListDottedExample {
    protected readonly collapsed = model(true);
    protected countries = [
        'Australia',
        'Austria',
        'Argentina',
        'Belgium',
        'Brazil',
        'United Kingdom',
        'Germany',
        'Greece',
        'Denmark',
        'Egypt',
        'India',
        'Spain',
        'Italy',
        'Canada',
        'Mexico',
        'Netherlands',
        'Norway',
        'Poland',
        'Portugal',
        'Russia',
        'United States',
        'Thailand',
        'Turkey',
        'France',
        'Japan'
    ];
}
```

### Accessibility

The trigger is yours to place, so applying `kbqClampedListTrigger` to it is a manual step: without the directive the trigger is not reachable from the keyboard, and a screen reader does not report whether the list is collapsed or expanded.

The trigger carries no spacing of its own: lay it out with the list.

> The API tab of this page is shared with **Clamped text**: both components ship from the `@koobiq/components/clamped-text` entry point, because they share the trigger directive, the `KbqClampedRoot` contract and one locale section.

## API

### Components

#### KbqClampedText (component)

Clamps projected text to `rows` lines and offers a disclosure control that reveals the rest.
The control only appears once a measurement has shown the content does not fit.

```ts
@Component({ selector: 'kbq-clamped-text', exportAs: 'kbqClampedText' })
class KbqClampedText implements KbqClamped {
    readonly rows = input<number>(kbqClampedTextDefaultMaxRows);
    readonly isCollapsed = model<boolean | undefined>();
    readonly debounceTime = input<number>(0);
    readonly scrollOnCollapse = input<boolean>(true);
    readonly contentId: string;
    readonly hasToggle: Signal<boolean>;
    toggle(event: Event): void;
}
```

- `[rows]`: `number` — Max rows before text is clamped. Defaults to `kbqClampedTextDefaultMaxRows`.
- `[(isCollapsed)]`: `boolean | undefined` — Collapsed state: `true` = collapsed, `false` = expanded, `undefined` = auto.
  Writable half of the `[(isCollapsed)]` two-way binding — written when the user operates the
  toggle, never by the component's own measurement.
- `[debounceTime]`: `number` — Debounce time on resize observer when recalculating toggle and text visibility. Defaults to `0`.
- `[scrollOnCollapse]`: `boolean` — Whether collapsing scrolls the component back into view. Defaults to `true`.
- `[localeOverrides]`: `KbqPartialLocaleData | undefined` — Localized strings for this element and its subtree, keyed by locale section. Every key is optional at
  every depth.
- `contentId`: `string` — Id of the clamped region, published by the toggle as its `aria-controls`.
- `hasToggle`: `Signal<boolean>` — Whether the content overflows `rows` lines by enough to be worth a toggle.
- `toggle()`: `void` — Flips the collapsed state. Stops event propagation.

### Directives

#### KbqClampedList (directive)

Renders a long list compactly: keeps the first `collapsedVisibleCount` items and lets a projected
`kbqClampedListTrigger` reveal the rest.

```ts
@Directive({ selector: '[kbqClampedList]', exportAs: 'kbqClampedList' })
class KbqClampedList<T> implements KbqClamped {
    readonly isCollapsed = model<boolean>(true);
    readonly items = input<T[]>([]);
    readonly collapsedVisibleCount = input<number>(10);
    readonly hiddenThreshold = input<number>(6);
    readonly contentId: string;
    readonly exceededItemCount: Signal<number>;
    readonly hasToggle: Signal<boolean>;
    readonly visibleItems: Signal<T[]>;
    readonly showMoreCountText: Signal<string>;
    readonly localeConfiguration: Signal<KbqClampedTextLocaleConfiguration>;
    toggle(event: Event): void;
}
```

- `[(isCollapsed)]`: `boolean` — Collapsed state: `true` = collapsed, `false` = expanded, `undefined` = expanded.
- `[items]`: `T[]` — The list of items to display.
- `[collapsedVisibleCount]`: `number` — Maximum number of items visible in collapsed state. Defaults to `10`.
- `[hiddenThreshold]`: `number` — Minimum number of hidden items required to show the toggle trigger. Defaults to `6`.
- `[localeOverrides]`: `KbqPartialLocaleData | undefined` — Localized strings for this element and its subtree, keyed by locale section. Every key is optional at
  every depth.
- `contentId`: `string` — Id the trigger points its `aria-controls` at. Keeps an id the host already carries.
- `exceededItemCount`: `Signal<number>` — Number of items hidden when the list is collapsed.
- `hasToggle`: `Signal<boolean>` — Whether the number of hidden items meets the threshold to render the toggle trigger.
- `visibleItems`: `Signal<T[]>` — Slice of items currently rendered — truncated to `minVisibleCount` when collapsed, a full list otherwise.
- `showMoreCountText`: `Signal<string>` — Localized "show more" label with the exceeded item count interpolated into the `{exceededItemCount}` placeholder.
- `localeConfiguration`: `Signal<KbqClampedTextLocaleConfiguration>` — Clamped text locale configuration.
- `toggle()`: `void` — Toggles the collapsed state of the list. Stops event propagation.

#### KbqClampedListTrigger (directive)

Disclosure control for the clamped container it is projected into — `[kbqClampedList]` or
`<kbq-clamped-text>`. Supplies the button semantics itself, so a host element does not have to:
`role`, `tabindex`, `aria-expanded` and `aria-controls` all come from here, and a `role` or
`tabindex` written on the element wins over the defaults.

```ts
@Directive({
    selector: '[kbqClampedListTrigger]',
    exportAs: 'kbqClampedListTrigger'
})
class KbqClampedListTrigger {}
```

### Types

#### KbqClamped (interface)

Contract a clamped container exposes to `KbqClampedListTrigger`.

```ts
interface KbqClamped {
    isCollapsed: Signal<boolean | undefined>;
    hasToggle: Signal<boolean>;
    contentId: string;
    localeConfiguration: Signal<KbqClampedTextLocaleConfiguration>;
    toggle(event: Event): void;
}
```

- `isCollapsed`: `Signal<boolean | undefined>` — Collapsed state: `true` = collapsed, `false` = expanded, `undefined` = auto.
  Behavior for auto may vary according to clamped-list/clamped-text
- `hasToggle`: `Signal<boolean>` — Whether the toggle trigger should be shown.
- `contentId`: `string` — Id of the region the trigger expands and collapses, published as its `aria-controls`.
- `localeConfiguration`: `Signal<KbqClampedTextLocaleConfiguration>` — Reactive locale strings for open/close labels.
- `toggle()`: `void` — Toggles the collapsed state of the list. Stops event propagation.

### Constants

#### KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION (const)

Localization configuration provider.

```ts
const KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION: InjectionToken<KbqClampedTextLocaleConfiguration>;
```

#### KbqClampedRoot (const)

Token the clamped containers of this package provide themselves under, so that
`KbqClampedListTrigger` can drive whichever of them it is projected into without naming a
concrete class.

```ts
const KbqClampedRoot: InjectionToken<KbqClamped>;
```

#### kbqClampedTextDefaultMaxRows (const)

Default maximum number of visible rows for the clamped text component
before truncation and the "collapse/expand" toggle is shown.

```ts
const kbqClampedTextDefaultMaxRows: number;
```

#### kbqClampedTextLocaleConfigurationProvider (const)

Utility provider of `KBQ_CLAMPED_TEXT_LOCALE_CONFIGURATION`. Only the strings you pass are overridden;
the rest keep following the active locale.

```ts
const kbqClampedTextLocaleConfigurationProvider: (
    configuration: KbqDeepPartial<KbqClampedTextLocaleConfiguration>
) => Provider;
```
