- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Dropdown Menu
- Empty
- Field
- Hover Card
- Icon
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Menubar
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Autocomplete
Autocomplete input and dropdown selection with filtering options.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
interface SpartanComponent {
id: string;
value: string;
}
@Component({
selector: 'spartan-autocomplete-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search">
<hlm-autocomplete-input placeholder="Search components" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No components found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (component of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="component.value">
{{ component.value }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompletePreview {
private readonly _components: SpartanComponent[] = [
{ id: 'accordion', value: 'Accordion' },
{ id: 'alert-dialog', value: 'Alert dialog' },
{ id: 'autocomplete', value: 'Autocomplete' },
{ id: 'avatar', value: 'Avatar' },
{ id: 'checkbox', value: 'Checkbox' },
{ id: 'collapsible', value: 'Collapsible' },
{ id: 'combobox', value: 'Combobox' },
{ id: 'command', value: 'Command' },
{ id: 'context-menu', value: 'Context menu' },
{ id: 'data-table', value: 'Data table' },
{ id: 'date-picker', value: 'Date picker' },
{ id: 'dialog', value: 'Dialog' },
{ id: 'field', value: 'Field' },
{ id: 'input', value: 'Input' },
{ id: 'menubar', value: 'Menubar' },
{ id: 'navigation-menu', value: 'Navigation menu' },
{ id: 'popover', value: 'Popover' },
{ id: 'progress', value: 'Progress' },
{ id: 'radio', value: 'Radio' },
{ id: 'scroll-area', value: 'Scroll area' },
{ id: 'select', value: 'Select' },
{ id: 'separator', value: 'Separator' },
{ id: 'skeleton', value: 'Skeleton' },
{ id: 'slider', value: 'Slider' },
{ id: 'sonner', value: 'Sonner' },
{ id: 'spinner', value: 'Spinner' },
{ id: 'switch', value: 'Switch' },
{ id: 'table', value: 'Table' },
{ id: 'tabs', value: 'Tabs' },
{ id: 'textarea', value: 'Textarea' },
{ id: 'toggle', value: 'Toggle' },
{ id: 'toggle-group', value: 'Toggle group' },
{ id: 'tooltip', value: 'Tooltip' },
];
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._components.filter((component) => component.value.toLowerCase().includes(this.search().toLowerCase())),
);
}
export const autocompleteDefaultConfig = `
import { provideBrnAutocompleteConfig } from '@spartan-ng/brain/autocomplete';
provideBrnAutocompleteConfig({
isItemEqualToValue: (itemValue: T, selectedValue: T | null) => Object.is(itemValue, selectedValue),
itemToString: undefined,
});
`;Installation
ng g @spartan-ng/cli:ui autocompletenx g @spartan-ng/cli:ui autocompleteimport { DestroyRef, ElementRef, HostAttributeToken, Injector, PLATFORM_ID, effect, inject, runInInjectionContext } from '@angular/core';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Global map to track class managers per element
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
// Global mutation observer for all elements
let globalObserver: MutationObserver | null = null;
const observedElements = new Set<HTMLElement>();
interface ElementClassManager {
element: HTMLElement;
sources: Map<number, { classes: Set<string>; order: number }>;
baseClasses: Set<string>;
isUpdating: boolean;
nextOrder: number;
hasInitialized: boolean;
restoreRafId: number | null;
/** Transitions are suppressed until the first effect writes correct classes */
transitionsSuppressed: boolean;
/** Original inline transition value to restore after suppression (empty string = none was set) */
previousTransition: string;
/** Original inline transition priority to preserve !important when restoring */
previousTransitionPriority: string;
}
let sourceCounter = 0;
/**
* This function dynamically adds and removes classes for a given element without requiring
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
*
* 1. This will merge the existing classes on the element with the new classes.
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
* 3. Multiple calls to this function on the same element will be merged efficiently.
*/
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
runInInjectionContext(options.injector ?? inject(Injector), () => {
const elementRef = options.elementRef ?? inject(ElementRef);
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
const element = elementRef.nativeElement;
// Create unique identifier for this source
const sourceId = sourceCounter++;
// Get or create the class manager for this element
let manager = elementClassManagers.get(element);
if (!manager) {
// Initialize base classes from variation (host attribute 'class')
const initialBaseClasses = new Set<string>();
if (baseClasses) {
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
}
manager = {
element,
sources: new Map(),
baseClasses: initialBaseClasses,
isUpdating: false,
nextOrder: 0,
hasInitialized: false,
restoreRafId: null,
transitionsSuppressed: false,
previousTransition: '',
previousTransitionPriority: '',
};
elementClassManagers.set(element, manager);
// Setup global observer if needed and register this element
setupGlobalObserver(platformId);
observedElements.add(element);
// Suppress transitions until the first effect writes correct classes and
// the browser has painted them. This prevents CSS transition animations
// during hydration when classes change from SSR state to client state.
if (isPlatformBrowser(platformId)) {
manager.previousTransition = element.style.getPropertyValue('transition');
manager.previousTransitionPriority = element.style.getPropertyPriority('transition');
element.style.setProperty('transition', 'none', 'important');
manager.transitionsSuppressed = true;
}
}
// Assign order once at registration time
const sourceOrder = manager.nextOrder++;
function updateClasses(): void {
// Get the new classes from the computed function
const newClasses = toClassList(computed());
// Update this source's classes, keeping the original order
manager!.sources.set(sourceId, {
classes: new Set(newClasses),
order: sourceOrder,
});
// Update the element
updateElement(manager!);
// Re-enable transitions after the first effect writes correct classes.
// Deferred to next animation frame so the browser paints the class change
// with transitions disabled first, then re-enables them.
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
manager!.restoreRafId = requestAnimationFrame(() => {
manager!.restoreRafId = null;
restoreTransitionSuppression(manager!);
});
}
}
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
if (manager!.restoreRafId !== null) {
cancelAnimationFrame(manager!.restoreRafId);
manager!.restoreRafId = null;
}
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
restoreTransitionSuppression(manager!);
}
// Remove this source from the manager
manager!.sources.delete(sourceId);
// If no more sources, clean up the manager
if (manager!.sources.size === 0) {
cleanupManager(element);
} else {
// Update element without this source's classes
updateElement(manager!);
}
});
/**
* We need this effect to track changes to the computed classes. Ideally, we would use
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
* effect which works in both browser and SSR.
*/
effect(updateClasses);
});
}
function restoreTransitionSuppression(manager: ElementClassManager): void {
const prev = manager.previousTransition;
if (prev) {
manager.element.style.setProperty('transition', prev, manager.previousTransitionPriority || undefined);
} else {
manager.element.style.removeProperty('transition');
}
}
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
function setupGlobalObserver(platformId: Object): void {
if (isPlatformBrowser(platformId) && !globalObserver) {
// Create single global observer that watches the entire document
globalObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target as HTMLElement;
const manager = elementClassManagers.get(element);
// Only process elements we're managing
if (manager && observedElements.has(element)) {
if (manager.isUpdating) continue; // Ignore changes we're making
// Update base classes to include any externally added classes
const currentClasses = toClassList(element.className);
const allSourceClasses = new Set<string>();
// Collect all classes from all sources
for (const source of manager.sources.values()) {
for (const className of source.classes) {
allSourceClasses.add(className);
}
}
// Any classes not from sources become new base classes
manager.baseClasses.clear();
for (const className of currentClasses) {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
}
updateElement(manager);
}
}
}
});
// Start observing the entire document for class attribute changes
globalObserver.observe(document, {
attributes: true,
attributeFilter: ['class'],
subtree: true, // Watch all descendants
});
}
}
function updateElement(manager: ElementClassManager): void {
if (manager.isUpdating) return; // Prevent recursive updates
manager.isUpdating = true;
// Handle initialization: capture base classes after first source registration
if (!manager.hasInitialized && manager.sources.size > 0) {
// Get current classes on element (may include SSR classes)
const currentClasses = toClassList(manager.element.className);
// Get all classes that will be applied by sources
const allSourceClasses = new Set<string>();
for (const source of manager.sources.values()) {
source.classes.forEach((className) => allSourceClasses.add(className));
}
// Only consider classes as "base" if they're not produced by any source
// This prevents SSR-rendered classes from being preserved as base classes
currentClasses.forEach((className) => {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
});
manager.hasInitialized = true;
}
// Get classes from all sources, sorted by registration order (later takes precedence)
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
const allSourceClasses: string[] = [];
for (const [, source] of sortedSources) {
allSourceClasses.push(...source.classes);
}
// Combine base classes with all source classes, ensuring base classes take precedence
const classesToApply =
allSourceClasses.length > 0 || manager.baseClasses.size > 0
? hlm([...allSourceClasses, ...manager.baseClasses])
: '';
// Apply the classes to the element
if (manager.element.className !== classesToApply) {
manager.element.className = classesToApply;
}
manager.isUpdating = false;
}
function cleanupManager(element: HTMLElement): void {
// Remove from global tracking
observedElements.delete(element);
elementClassManagers.delete(element);
// If no more elements being tracked, cleanup global observer
if (observedElements.size === 0 && globalObserver) {
globalObserver.disconnect();
globalObserver = null;
}
}
interface ClassesOptions {
elementRef?: ElementRef<HTMLElement>;
injector?: Injector;
}
// Cache for parsed class lists to avoid repeated string operations
const classListCache = new Map<string, string[]>();
function toClassList(className: string | ClassValue[]): string[] {
// For simple string inputs, use cache to avoid repeated parsing
if (typeof className === 'string' && classListCache.has(className)) {
return classListCache.get(className)!;
}
const result = clsx(className)
.split(' ')
.filter((c) => c.length > 0);
// Cache string results, but limit cache size to prevent memory growth
if (typeof className === 'string' && classListCache.size < 1000) {
classListCache.set(className, result);
}
return result;
}import { BooleanInput } from '@angular/cdk/coercion';
import { BrnAutocomplete, BrnAutocompleteAnchor, BrnAutocompleteClear, BrnAutocompleteContent, BrnAutocompleteEmpty, BrnAutocompleteGroup, BrnAutocompleteInput, BrnAutocompleteInputWrapper, BrnAutocompleteItem, BrnAutocompleteLabel, BrnAutocompleteList, BrnAutocompleteSearch, BrnAutocompleteSeparator, BrnAutocompleteStatus } from '@spartan-ng/brain/autocomplete';
import { BrnPopover, BrnPopoverContent, provideBrnPopoverConfig } from '@spartan-ng/brain/popover';
import { ChangeDetectionStrategy, Component, Directive, booleanAttribute, inject, input } from '@angular/core';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck, lucideSearch, lucideX } from '@ng-icons/lucide';
import { provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
@Directive({
selector: '[hlmAutocompleteContent],hlm-autocomplete-content',
hostDirectives: [BrnAutocompleteContent],
})
export class HlmAutocompleteContent {
constructor() {
classes(
() =>
'group/autocomplete-content bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex max-h-72 w-(--brn-autocomplete-width) min-w-36 flex-col overflow-hidden rounded-md p-0 shadow-md ring-1 duration-100',
);
}
}
@Directive({
selector: '[hlmAutocompleteEmpty],hlm-autocomplete-empty',
hostDirectives: [BrnAutocompleteEmpty],
host: {
'data-slot': 'autocomplete-empty',
},
})
export class HlmAutocompleteEmpty {
constructor() {
classes(
() =>
'text-muted-foreground hidden w-full items-center justify-center gap-2 py-2 text-center text-sm group-data-empty/autocomplete-content:flex',
);
}
}
@Directive({
selector: '[hlmAutocompleteGroup]',
hostDirectives: [BrnAutocompleteGroup],
host: {
'data-slot': 'autocomplete-group',
},
})
export class HlmAutocompleteGroup {
constructor() {
classes(() => 'data-hidden:hidden');
}
}
@Component({
selector: 'hlm-autocomplete-input',
imports: [HlmInputGroupImports, NgIcon, BrnAutocompleteAnchor, BrnAutocompleteClear, BrnAutocompleteInput],
providers: [provideIcons({ lucideSearch, lucideX })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnAutocompleteInputWrapper],
template: `
<hlm-input-group brnAutocompleteAnchor class="w-auto">
<input
brnAutocompleteInput
#autocompleteInput="brnAutocompleteInput"
hlmInputGroupInput
[id]="inputId()"
[placeholder]="placeholder()"
[aria-invalid]="ariaInvalidOverride()"
/>
@if (showSearch()) {
<hlm-input-group-addon>
<ng-icon name="lucideSearch" [class.opacity-50]="autocompleteInput.disabled()" />
</hlm-input-group-addon>
}
@if (showClear()) {
<hlm-input-group-addon align="inline-end">
<button
*brnAutocompleteClear
hlmInputGroupButton
data-slot="autocomplete-clear"
[disabled]="autocompleteInput.disabled()"
size="icon-xs"
variant="ghost"
>
<ng-icon name="lucideX" />
</button>
</hlm-input-group-addon>
}
<ng-content />
</hlm-input-group>
`,
})
export class HlmAutocompleteInput {
private static _id = 0;
public readonly inputId = input<string>(`hlm-autocomplete-input-${HlmAutocompleteInput._id++}`);
public readonly placeholder = input<string>('');
public readonly showSearch = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
public readonly showClear = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
/** Manual override for aria-invalid. When not set, auto-detects from the parent autocomplete error state. */
public readonly ariaInvalidOverride = input<boolean | undefined, BooleanInput>(undefined, {
transform: (v: BooleanInput) => (v === '' || v === undefined ? undefined : booleanAttribute(v)),
alias: 'aria-invalid',
});
}
@Component({
selector: 'hlm-autocomplete-item',
imports: [NgIcon],
providers: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAutocompleteItem, inputs: ['id', 'disabled', 'value'] }],
host: {
'data-slot': 'autocomplete-item',
},
template: `
<ng-content />
@if (_active()) {
<ng-icon
name="lucideCheck"
class="pointer-events-none absolute right-2 flex size-4 items-center justify-center"
aria-hidden="true"
/>
}
`,
})
export class HlmAutocompleteItem {
private readonly _brnAutocompleteItem = inject(BrnAutocompleteItem);
protected readonly _active = this._brnAutocompleteItem.active;
constructor() {
classes(
() =>
`data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-hidden:hidden data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0 [&_ng-icon:not([class*='text-'])]:text-base`,
);
}
}
@Directive({
selector: '[hlmAutocompleteLabel]',
hostDirectives: [{ directive: BrnAutocompleteLabel, inputs: ['id'] }],
host: {
'data-slot': 'autocomplete-label',
},
})
export class HlmAutocompleteLabel {
constructor() {
classes(() => 'text-muted-foreground px-2 py-1.5 text-xs');
}
}
@Directive({
selector: '[hlmAutocompleteList]',
hostDirectives: [{ directive: BrnAutocompleteList, inputs: ['id'] }],
host: {
'data-slot': 'autocomplete-list',
},
})
export class HlmAutocompleteList {
constructor() {
classes(
() =>
'no-scrollbar max-h-[calc(--spacing(72)---spacing(9))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0',
);
}
}
@Directive({
selector: '[hlmAutocompletePortal]',
hostDirectives: [{ directive: BrnPopoverContent, inputs: ['context', 'class'] }],
})
export class HlmAutocompletePortal {}
@Directive({
selector: '[hlmAutocompleteSearch],hlm-autocomplete-search',
providers: [
provideBrnPopoverConfig({
align: 'start',
sideOffset: 6,
}),
provideBrnDialogDefaultOptions({
autoFocus: 'first-heading',
}),
],
hostDirectives: [
{
directive: BrnAutocompleteSearch,
inputs: ['autoHighlight', 'disabled', 'value', 'search', 'itemToString'],
outputs: ['valueChange', 'searchChange'],
},
{
directive: BrnPopover,
inputs: [
'align',
'autoFocus',
'closeDelay',
'closeOnOutsidePointerEvents',
'sideOffset',
'state',
'offsetX',
'restoreFocus',
],
outputs: ['stateChanged', 'closed'],
},
],
host: {
'data-slot': 'autocomplete',
},
})
export class HlmAutocompleteSearch {
constructor() {
classes(() => 'block');
}
}
@Directive({
selector: '[hlmAutocompleteSeparator]',
hostDirectives: [{ directive: BrnAutocompleteSeparator, inputs: ['orientation'] }],
host: {
'data-slot': 'autocomplete-separator',
},
})
export class HlmAutocompleteSeparator {
constructor() {
classes(() => 'bg-border -mx-1 my-1 h-px');
}
}
@Directive({
selector: '[hlmAutocompleteStatus],hlm-autocomplete-status',
hostDirectives: [BrnAutocompleteStatus],
host: {
'data-slot': 'autocomplete-status',
},
})
export class HlmAutocompleteStatus {
constructor() {
classes(() => 'text-muted-foreground flex w-full items-center justify-center gap-2 px-3 py-2 text-center text-sm');
}
}
@Directive({
selector: '[hlmAutocomplete],hlm-autocomplete',
providers: [
provideBrnPopoverConfig({
align: 'start',
sideOffset: 6,
}),
provideBrnDialogDefaultOptions({
autoFocus: 'first-heading',
}),
],
hostDirectives: [
{
directive: BrnAutocomplete,
inputs: ['autoHighlight', 'disabled', 'value', 'search', 'itemToString', 'isItemEqualToValue'],
outputs: ['valueChange', 'searchChange'],
},
{
directive: BrnPopover,
inputs: [
'align',
'autoFocus',
'closeDelay',
'closeOnOutsidePointerEvents',
'sideOffset',
'state',
'offsetX',
'restoreFocus',
],
outputs: ['stateChanged', 'closed'],
},
],
host: {
'data-slot': 'autocomplete',
},
})
export class HlmAutocomplete {
constructor() {
classes(() => 'block');
}
}
export const HlmAutocompleteImports = [
HlmAutocomplete,
HlmAutocompleteContent,
HlmAutocompleteEmpty,
HlmAutocompleteGroup,
HlmAutocompleteInput,
HlmAutocompleteItem,
HlmAutocompleteLabel,
HlmAutocompleteList,
HlmAutocompletePortal,
HlmAutocompleteSearch,
HlmAutocompleteSeparator,
HlmAutocompleteStatus,
] as const;Usage
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';<hlm-autocomplete [(search)]="search">
<hlm-autocomplete-input placeholder="Search tags" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No tags found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (option of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="option"> {{ option }} </hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>Configuration
The autocomplete can be configured via provideBrnAutocompleteConfig or by passing the individual config as input. This is the default autocomplete config:
import { provideBrnAutocompleteConfig } from '@spartan-ng/brain/autocomplete';
provideBrnAutocompleteConfig({
isItemEqualToValue: (itemValue: T, selectedValue: T | null) => Object.is(itemValue, selectedValue),
itemToString: undefined,
});Objects
The autocomplete works out of the box with string values and objects in the shape of { label: string; value: string; } , the label (1) or the value (2) will be used automatically. For other object shapes provide a custom itemToString function to extract the label from the object.
Provide a custom isItemEqualToValue function to determine if an autocomplete item value matches the current selected value. Defaults to Object.is comparison.
Examples
With Clear Button
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
@Component({
selector: 'spartan-autocomplete-clear-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search">
<hlm-autocomplete-input placeholder="Search components" showClear />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No components found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (option of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="option">
{{ option }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteClearPreview {
private readonly _options: string[] = [
'Accordion',
'Alert dialog',
'Autocomplete',
'Avatar',
'Checkbox',
'Collapsible',
'Combobox',
'Command',
'Context menu',
'Data table',
'Date picker',
'Dialog',
'Field',
'Input',
'Menubar',
'Navigation menu',
'Popover',
'Progress',
'Radio',
'Scroll area',
'Select',
'Separator',
'Skeleton',
'Slider',
'Sonner',
'Spinner',
'Switch',
'Table',
'Tabs',
'Textarea',
'Toast',
'Toggle',
'Tooltip',
];
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._options.filter((option) => option.toLowerCase().includes(this.search().toLowerCase())),
);
}Disabled
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
@Component({
selector: 'spartan-autocomplete-disabled-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search" disabled>
<hlm-autocomplete-input placeholder="Search components" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No components found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (option of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="option">
{{ option }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteDisabledPreview {
private readonly _options: string[] = [
'Accordion',
'Alert dialog',
'Autocomplete',
'Avatar',
'Checkbox',
'Collapsible',
'Combobox',
'Command',
'Context menu',
'Data table',
'Date picker',
'Dialog',
'Field',
'Input',
'Menubar',
'Navigation menu',
'Popover',
'Progress',
'Radio',
'Scroll area',
'Select',
'Separator',
'Skeleton',
'Slider',
'Sonner',
'Spinner',
'Switch',
'Table',
'Tabs',
'Textarea',
'Toast',
'Toggle',
'Tooltip',
];
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._options.filter((option) => option.toLowerCase().includes(this.search().toLowerCase())),
);
}Auto highlight
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
interface SpartanComponent {
id: string;
value: string;
}
@Component({
selector: 'spartan-autocomplete-autohighlight-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search" autoHighlight>
<hlm-autocomplete-input placeholder="Search components" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No components found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (component of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="component.value">
{{ component.value }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteAutohighlightPreview {
private readonly _components: SpartanComponent[] = [
{ id: 'accordion', value: 'Accordion' },
{ id: 'alert-dialog', value: 'Alert dialog' },
{ id: 'autocomplete', value: 'Autocomplete' },
{ id: 'avatar', value: 'Avatar' },
{ id: 'checkbox', value: 'Checkbox' },
{ id: 'collapsible', value: 'Collapsible' },
{ id: 'combobox', value: 'Combobox' },
{ id: 'command', value: 'Command' },
{ id: 'context-menu', value: 'Context menu' },
{ id: 'data-table', value: 'Data table' },
{ id: 'date-picker', value: 'Date picker' },
{ id: 'dialog', value: 'Dialog' },
{ id: 'field', value: 'Field' },
{ id: 'input', value: 'Input' },
{ id: 'menubar', value: 'Menubar' },
{ id: 'navigation-menu', value: 'Navigation menu' },
{ id: 'popover', value: 'Popover' },
{ id: 'progress', value: 'Progress' },
{ id: 'radio', value: 'Radio' },
{ id: 'scroll-area', value: 'Scroll area' },
{ id: 'select', value: 'Select' },
{ id: 'separator', value: 'Separator' },
{ id: 'skeleton', value: 'Skeleton' },
{ id: 'slider', value: 'Slider' },
{ id: 'sonner', value: 'Sonner' },
{ id: 'spinner', value: 'Spinner' },
{ id: 'switch', value: 'Switch' },
{ id: 'table', value: 'Table' },
{ id: 'tabs', value: 'Tabs' },
{ id: 'textarea', value: 'Textarea' },
{ id: 'toggle', value: 'Toggle' },
{ id: 'toggle-group', value: 'Toggle group' },
{ id: 'tooltip', value: 'Tooltip' },
];
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._components.filter((component) => component.value.toLowerCase().includes(this.search().toLowerCase())),
);
}Auto highlight (search)
Tag:
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
interface Tag {
id: string;
value: string;
}
@Component({
selector: 'spartan-autocomplete-search-autohighlight-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p class="mb-4">
<span class="font-semibold">Tag:</span>
{{ value() }}
</p>
<hlm-autocomplete-search [(value)]="value" [(search)]="search" autoHighlight>
<hlm-autocomplete-input placeholder="Search tags" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No tags found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (tag of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="tag">
{{ tag.value }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete-search>
`,
})
export class AutocompleteSearchAutohighlightPreview {
private readonly _tags: Tag[] = [
{ id: 't1', value: 'feature' },
{ id: 't2', value: 'fix' },
{ id: 't3', value: 'bug' },
{ id: 't4', value: 'docs' },
{ id: 't5', value: 'internal' },
{ id: 't6', value: 'mobile' },
{ id: 'accordion', value: 'Accordion' },
{ id: 'alert-dialog', value: 'Alert dialog' },
{ id: 'autocomplete', value: 'Autocomplete' },
{ id: 'avatar', value: 'Avatar' },
{ id: 'checkbox', value: 'Checkbox' },
{ id: 'collapsible', value: 'Collapsible' },
{ id: 'combobox', value: 'Combobox' },
{ id: 'command', value: 'Command' },
{ id: 'context-menu', value: 'Context menu' },
{ id: 'data-table', value: 'Data table' },
{ id: 'date-picker', value: 'Date picker' },
{ id: 'dialog', value: 'Dialog' },
{ id: 'field', value: 'Field' },
{ id: 'input', value: 'Input' },
{ id: 'menubar', value: 'Menubar' },
{ id: 'navigation-menu', value: 'Navigation menu' },
{ id: 'popover', value: 'Popover' },
{ id: 'progress', value: 'Progress' },
{ id: 'radio', value: 'Radio' },
{ id: 'scroll-area', value: 'Scroll area' },
{ id: 'select', value: 'Select' },
{ id: 'separator', value: 'Separator' },
{ id: 'skeleton', value: 'Skeleton' },
{ id: 'slider', value: 'Slider' },
{ id: 'sonner', value: 'Sonner' },
{ id: 'spinner', value: 'Spinner' },
{ id: 'switch', value: 'Switch' },
{ id: 'table', value: 'Table' },
{ id: 'tabs', value: 'Tabs' },
{ id: 'textarea', value: 'Textarea' },
{ id: 'toggle', value: 'Toggle' },
{ id: 'toggle-group', value: 'Toggle group' },
{ id: 'tooltip', value: 'Tooltip' },
];
public readonly value = signal<string | null>(null);
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._tags.filter((option) => option.value.toLowerCase().includes(this.search().toLowerCase())),
);
}With Groups
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
@Component({
selector: 'spartan-autocomplete-group-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search">
<hlm-autocomplete-input placeholder="Select a timezone" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No items found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (timezoneGroup of filteredTimezones(); track $index) {
<div hlmAutocompleteGroup>
<div hlmAutocompleteLabel>{{ timezoneGroup.value }}</div>
@for (timezone of timezoneGroup.items; track $index) {
<hlm-autocomplete-item [value]="timezone">{{ timezone }}</hlm-autocomplete-item>
}
</div>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteGroupPreview {
private readonly _timezones = [
{
value: 'Americas',
items: [
'(GMT-5) New York',
'(GMT-8) Los Angeles',
'(GMT-6) Chicago',
'(GMT-5) Toronto',
'(GMT-8) Vancouver',
'(GMT-3) São Paulo',
],
},
{
value: 'Europe',
items: [
'(GMT+0) London',
'(GMT+1) Paris',
'(GMT+1) Berlin',
'(GMT+1) Rome',
'(GMT+1) Madrid',
'(GMT+1) Amsterdam',
],
},
{
value: 'Asia/Pacific',
items: [
'(GMT+9) Tokyo',
'(GMT+8) Shanghai',
'(GMT+8) Singapore',
'(GMT+4) Dubai',
'(GMT+11) Sydney',
'(GMT+9) Seoul',
],
},
];
public readonly search = signal('');
public readonly filteredTimezones = computed(() => {
const search = this.search().toLowerCase();
return this._timezones
.map((timezoneGroup) => ({
...timezoneGroup,
items: timezoneGroup.items.filter((timezone) => timezone.toLowerCase().includes(search)),
}))
.filter((timezoneGroup) => timezoneGroup.items.length > 0);
});
}With Group and Separators
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
@Component({
selector: 'spartan-autocomplete-group-separator-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search">
<hlm-autocomplete-input placeholder="Select a timezone" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No items found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (timezoneGroup of filteredTimezones(); track $index) {
<div hlmAutocompleteGroup>
<div hlmAutocompleteLabel>{{ timezoneGroup.value }}</div>
@for (timezone of timezoneGroup.items; track $index) {
<hlm-autocomplete-item [value]="timezone">{{ timezone }}</hlm-autocomplete-item>
}
<div hlmAutocompleteSeparator></div>
</div>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteGroupSeparatorPreview {
private readonly _timezones = [
{
value: 'Americas',
items: [
'(GMT-5) New York',
'(GMT-8) Los Angeles',
'(GMT-6) Chicago',
'(GMT-5) Toronto',
'(GMT-8) Vancouver',
'(GMT-3) São Paulo',
],
},
{
value: 'Europe',
items: [
'(GMT+0) London',
'(GMT+1) Paris',
'(GMT+1) Berlin',
'(GMT+1) Rome',
'(GMT+1) Madrid',
'(GMT+1) Amsterdam',
],
},
{
value: 'Asia/Pacific',
items: [
'(GMT+9) Tokyo',
'(GMT+8) Shanghai',
'(GMT+8) Singapore',
'(GMT+4) Dubai',
'(GMT+11) Sydney',
'(GMT+9) Seoul',
],
},
];
public readonly search = signal('');
public readonly filteredTimezones = computed(() => {
const search = this.search().toLowerCase();
return this._timezones
.map((timezoneGroup) => ({
...timezoneGroup,
items: timezoneGroup.items.filter((timezone) => timezone.toLowerCase().includes(search)),
}))
.filter((timezoneGroup) => timezoneGroup.items.length > 0);
});
}Object (custom label)
Customize the selected value for object values by providing a custom itemToString function.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
type Country = {
name: string;
code: string;
flag: string;
};
@Component({
selector: 'spartan-autocomplete-countries',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search" [itemToString]="itemToString">
<hlm-autocomplete-input placeholder="Search countries" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No countries found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (option of filteredCountries(); track $index) {
<hlm-autocomplete-item [value]="option">{{ option.flag }} {{ option.name }}</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteCountriesPreview {
private readonly _countries: Country[] = [
{ name: 'Argentina', code: 'AR', flag: '🇦🇷' },
{ name: 'Australia', code: 'AU', flag: '🇦🇺' },
{ name: 'Belgium', code: 'BE', flag: '🇧🇪' },
{ name: 'Brazil', code: 'BR', flag: '🇧🇷' },
{ name: 'Canada', code: 'CA', flag: '🇨🇦' },
{ name: 'China', code: 'CN', flag: '🇨🇳' },
{ name: 'France', code: 'FR', flag: '🇫🇷' },
{ name: 'Germany', code: 'DE', flag: '🇩🇪' },
{ name: 'India', code: 'IN', flag: '🇮🇳' },
{ name: 'Italy', code: 'IT', flag: '🇮🇹' },
{ name: 'Japan', code: 'JP', flag: '🇯🇵' },
{ name: 'Mexico', code: 'MX', flag: '🇲🇽' },
{ name: 'Netherlands', code: 'NL', flag: '🇳🇱' },
{ name: 'Norway', code: 'NO', flag: '🇳🇴' },
{ name: 'Russia', code: 'RU', flag: '🇷🇺' },
{ name: 'South Africa', code: 'ZA', flag: '🇿🇦' },
{ name: 'South Korea', code: 'KR', flag: '🇰🇷' },
{ name: 'Spain', code: 'ES', flag: '🇪🇸' },
{ name: 'Sweden', code: 'SE', flag: '🇸🇪' },
{ name: 'Switzerland', code: 'CH', flag: '🇨🇭' },
{ name: 'United Kingdom', code: 'GB', flag: '🇬🇧' },
{ name: 'United States', code: 'US', flag: '🇺🇸' },
];
public readonly search = signal<string>('');
public readonly itemToString = (item: Country): string => {
return `${item.flag} ${item.name}`;
};
public readonly filteredCountries = computed(() =>
this._countries.filter(
(country) =>
country.name.toLowerCase().includes(this.search().toLowerCase()) ||
`${country.flag} ${country.name}`.toLowerCase().includes(this.search().toLowerCase()),
),
);
}Object (id value)
Use itemToString to resolve the id value as the display string name .
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
type Assignee = {
id: string;
name: string;
};
@Component({
selector: 'spartan-autocomplete-resolve-value-id',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<hlm-autocomplete [(search)]="search" [itemToString]="itemToString">
<hlm-autocomplete-input placeholder="Search reviewers" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No reviewer found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (option of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="option.id">{{ option.name }}</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteResolveValueIdPreview {
private readonly _formBuilder = inject(FormBuilder);
private readonly _assignees: Assignee[] = [
{ id: '1', name: 'Marty McFly' },
{ id: '2', name: 'Doc Brown' },
{ id: '3', name: 'Biff Tannen' },
{ id: '4', name: 'George McFly' },
{ id: '5', name: 'Jennifer Parker' },
{ id: '6', name: 'Emmett Brown' },
{ id: '7', name: 'Einstein' },
{ id: '8', name: 'Clara Clayton' },
{ id: '9', name: 'Needles' },
{ id: '10', name: 'Goldie Wilson' },
{ id: '11', name: 'Marvin Berry' },
{ id: '12', name: 'Lorraine Baines' },
{ id: '13', name: 'Strickland' },
];
public form = this._formBuilder.group({
assignee: new FormControl<string>('8', Validators.required),
});
public readonly search = signal<string>('');
public itemToString = (assigneeId: string) =>
this._assignees.find((assignee) => assignee.id === assigneeId)?.name ?? '';
public readonly filteredOptions = computed(() => {
return this._assignees.filter((assignee) => assignee.name.toLowerCase().includes(this.search().toLowerCase()));
});
submit() {
console.log(this.form.value);
}
}Free-form text
Use hlm-autocomplete-search to allow free-form text input along with option selection. The selected option is transformed to string via itemToString .
Tag:
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
interface Tag {
id: string;
value: string;
}
@Component({
selector: 'spartan-autocomplete-search-preview',
imports: [HlmAutocompleteImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p class="mb-4">
<span class="font-semibold">Tag:</span>
{{ value() }}
</p>
<hlm-autocomplete-search [(value)]="value" [(search)]="search">
<hlm-autocomplete-input placeholder="Search tags" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No tags found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (tag of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="tag">
{{ tag.value }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete-search>
`,
})
export class AutocompleteSearchPreview {
private readonly _tags: Tag[] = [
{ id: 't1', value: 'feature' },
{ id: 't2', value: 'fix' },
{ id: 't3', value: 'bug' },
{ id: 't4', value: 'docs' },
{ id: 't5', value: 'internal' },
{ id: 't6', value: 'mobile' },
{ id: 'accordion', value: 'Accordion' },
{ id: 'alert-dialog', value: 'Alert dialog' },
{ id: 'autocomplete', value: 'Autocomplete' },
{ id: 'avatar', value: 'Avatar' },
{ id: 'checkbox', value: 'Checkbox' },
{ id: 'collapsible', value: 'Collapsible' },
{ id: 'combobox', value: 'Combobox' },
{ id: 'command', value: 'Command' },
{ id: 'context-menu', value: 'Context menu' },
{ id: 'data-table', value: 'Data table' },
{ id: 'date-picker', value: 'Date picker' },
{ id: 'dialog', value: 'Dialog' },
{ id: 'field', value: 'Field' },
{ id: 'input', value: 'Input' },
{ id: 'menubar', value: 'Menubar' },
{ id: 'navigation-menu', value: 'Navigation menu' },
{ id: 'popover', value: 'Popover' },
{ id: 'progress', value: 'Progress' },
{ id: 'radio', value: 'Radio' },
{ id: 'scroll-area', value: 'Scroll area' },
{ id: 'select', value: 'Select' },
{ id: 'separator', value: 'Separator' },
{ id: 'skeleton', value: 'Skeleton' },
{ id: 'slider', value: 'Slider' },
{ id: 'sonner', value: 'Sonner' },
{ id: 'spinner', value: 'Spinner' },
{ id: 'switch', value: 'Switch' },
{ id: 'table', value: 'Table' },
{ id: 'tabs', value: 'Tabs' },
{ id: 'textarea', value: 'Textarea' },
{ id: 'toggle', value: 'Toggle' },
{ id: 'toggle-group', value: 'Toggle group' },
{ id: 'tooltip', value: 'Tooltip' },
];
public readonly value = signal<string | null>(null);
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._tags.filter((option) => option.value.toLowerCase().includes(this.search().toLowerCase())),
);
}Async search
import { ChangeDetectionStrategy, Component, resource, signal } from '@angular/core';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
import { HlmSpinnerImports } from '@spartan-ng/helm/spinner';
interface Movie {
id: string;
title: string;
year: number;
}
@Component({
selector: 'spartan-autocomplete-async-preview',
imports: [HlmAutocompleteImports, HlmSpinnerImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-autocomplete [(search)]="search" [itemToString]="itemToString">
<hlm-autocomplete-input placeholder="Search movies" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-status class="justify-start">
@if (options.error(); as error) {
{{ error }}
} @else if (options.isLoading()) {
<hlm-spinner />
Loading...
} @else if (search().length === 0) {
Type to search movies.
} @else if (options.hasValue() && options.value().length === 0) {
Movie or year "{{ search() }}" does not exist in the Top 100 IMDb movies
} @else if (options.hasValue()) {
{{ options.value().length }} results found
}
</hlm-autocomplete-status>
<div hlmAutocompleteList>
@if (options.hasValue()) {
@for (option of options.value(); track $index) {
<hlm-autocomplete-item [value]="option">
<div class="flex flex-col gap-1">
<p>{{ option.title }}</p>
<p class="text-muted-foreground text-xs">{{ option.year }}</p>
</div>
</hlm-autocomplete-item>
}
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
`,
})
export class AutocompleteAsyncPreview {
public readonly search = signal('');
public itemToString = (item: Movie) => item.title;
public options = resource({
defaultValue: [],
params: () => ({ search: this.search() }),
loader: async ({ params }) => {
const search = params.search;
if (search.length === 0) {
return [];
}
return await this.searchMovies(search.toLowerCase());
},
});
async searchMovies(query: string): Promise<Movie[]> {
// Simulate network delay
await new Promise((resolve) => {
setTimeout(resolve, Math.random() * 500 + 100);
});
// Simulate occasional network errors (1% chance)
if (Math.random() < 0.01 || query === 'will_error') {
throw new Error('Failed to fetch movies. Please try again.');
}
return this._top100Movies.filter(
(movie) => movie.title.toLowerCase().includes(query) || movie.year.toString().includes(query),
);
}
private readonly _top100Movies: Movie[] = [
{ id: '1', title: 'The Shawshank Redemption', year: 1994 },
{ id: '2', title: 'The Godfather', year: 1972 },
{ id: '3', title: 'The Dark Knight', year: 2008 },
{ id: '4', title: 'The Godfather Part II', year: 1974 },
{ id: '5', title: '12 Angry Men', year: 1957 },
{ id: '6', title: 'The Lord of the Rings: The Return of the King', year: 2003 },
{ id: '7', title: "Schindler's List", year: 1993 },
{ id: '8', title: 'Pulp Fiction', year: 1994 },
{ id: '9', title: 'The Lord of the Rings: The Fellowship of the Ring', year: 2001 },
{ id: '10', title: 'The Good, the Bad and the Ugly', year: 1966 },
{ id: '11', title: 'Forrest Gump', year: 1994 },
{ id: '12', title: 'The Lord of the Rings: The Two Towers', year: 2002 },
{ id: '13', title: 'Fight Club', year: 1999 },
{ id: '14', title: 'Inception', year: 2010 },
{ id: '15', title: 'Star Wars: Episode V – The Empire Strikes Back', year: 1980 },
{ id: '16', title: 'The Matrix', year: 1999 },
{ id: '17', title: 'Goodfellas', year: 1990 },
{ id: '18', title: 'Interstellar', year: 2014 },
{ id: '19', title: "One Flew Over the Cuckoo's Nest", year: 1975 },
{ id: '20', title: 'Se7en', year: 1995 },
{ id: '21', title: "It's a Wonderful Life", year: 1946 },
{ id: '22', title: 'The Silence of the Lambs', year: 1991 },
{ id: '23', title: 'Seven Samurai', year: 1954 },
{ id: '24', title: 'Saving Private Ryan', year: 1998 },
{ id: '25', title: 'City of God', year: 2002 },
{ id: '26', title: 'Life Is Beautiful', year: 1997 },
{ id: '27', title: 'The Green Mile', year: 1999 },
{ id: '28', title: 'Star Wars: Episode IV – A New Hope', year: 1977 },
{ id: '29', title: 'Terminator 2: Judgment Day', year: 1991 },
{ id: '30', title: 'Back to the Future', year: 1985 },
{ id: '31', title: 'Spirited Away', year: 2001 },
{ id: '32', title: 'The Pianist', year: 2002 },
{ id: '33', title: 'Psycho', year: 1960 },
{ id: '34', title: 'Parasite', year: 2019 },
{ id: '35', title: 'Gladiator', year: 2000 },
{ id: '36', title: 'Léon: The Professional', year: 1994 },
{ id: '37', title: 'American History X', year: 1998 },
{ id: '38', title: 'The Departed', year: 2006 },
{ id: '39', title: 'Whiplash', year: 2014 },
{ id: '40', title: 'The Prestige', year: 2006 },
{ id: '41', title: 'Grave of the Fireflies', year: 1988 },
{ id: '42', title: 'The Usual Suspects', year: 1995 },
{ id: '43', title: 'Casablanca', year: 1942 },
{ id: '44', title: 'Harakiri', year: 1962 },
{ id: '45', title: 'The Lion King', year: 1994 },
{ id: '46', title: 'The Intouchables', year: 2011 },
{ id: '47', title: 'Modern Times', year: 1936 },
{ id: '48', title: 'The Lives of Others', year: 2006 },
{ id: '49', title: 'Once Upon a Time in the West', year: 1968 },
{ id: '50', title: 'Rear Window', year: 1954 },
{ id: '51', title: 'Alien', year: 1979 },
{ id: '52', title: 'City Lights', year: 1931 },
{ id: '53', title: 'The Shining', year: 1980 },
{ id: '54', title: 'Cinema Paradiso', year: 1988 },
{ id: '55', title: 'Avengers: Infinity War', year: 2018 },
{ id: '56', title: 'Paths of Glory', year: 1957 },
{ id: '57', title: 'Django Unchained', year: 2012 },
{ id: '58', title: 'WALL·E', year: 2008 },
{ id: '59', title: 'Sunset Boulevard', year: 1950 },
{ id: '60', title: 'The Great Dictator', year: 1940 },
{ id: '61', title: 'The Dark Knight Rises', year: 2012 },
{ id: '62', title: 'Princess Mononoke', year: 1997 },
{ id: '63', title: 'Witness for the Prosecution', year: 1957 },
{ id: '64', title: 'Oldboy', year: 2003 },
{ id: '65', title: 'Aliens', year: 1986 },
{ id: '66', title: 'Once Upon a Time in America', year: 1984 },
{ id: '67', title: 'Coco', year: 2017 },
{ id: '68', title: 'Your Name.', year: 2016 },
{ id: '69', title: 'American Beauty', year: 1999 },
{ id: '70', title: 'Braveheart', year: 1995 },
{ id: '71', title: 'Das Boot', year: 1981 },
{ id: '72', title: '3 Idiots', year: 2009 },
{ id: '73', title: 'Toy Story', year: 1995 },
{ id: '74', title: 'Inglourious Basterds', year: 2009 },
{ id: '75', title: 'High and Low', year: 1963 },
{ id: '76', title: 'Amadeus', year: 1984 },
{ id: '77', title: 'Good Will Hunting', year: 1997 },
{ id: '78', title: 'Star Wars: Episode VI – Return of the Jedi', year: 1983 },
{ id: '79', title: 'The Hunt', year: 2012 },
{ id: '80', title: 'Capharnaüm', year: 2018 },
{ id: '81', title: 'Reservoir Dogs', year: 1992 },
{ id: '82', title: 'Eternal Sunshine of the Spotless Mind', year: 2004 },
{ id: '83', title: 'Requiem for a Dream', year: 2000 },
{ id: '84', title: 'Come and See', year: 1985 },
{ id: '85', title: 'Ikiru', year: 1952 },
{ id: '86', title: 'Vertigo', year: 1958 },
{ id: '87', title: 'Lawrence of Arabia', year: 1962 },
{ id: '88', title: 'Citizen Kane', year: 1941 },
{ id: '89', title: 'Memento', year: 2000 },
{ id: '90', title: 'North by Northwest', year: 1959 },
{ id: '91', title: 'Star Wars: Episode III – Revenge of the Sith', year: 2005 },
{ id: '92', title: '2001: A Space Odyssey', year: 1968 },
{ id: '93', title: 'Amélie', year: 2001 },
{ id: '94', title: "Singin' in the Rain", year: 1952 },
{ id: '95', title: 'Apocalypse Now', year: 1979 },
{ id: '96', title: 'Taxi Driver', year: 1976 },
{ id: '97', title: 'Downfall', year: 2004 },
{ id: '98', title: 'The Wolf of Wall Street', year: 2013 },
{ id: '99', title: 'A Clockwork Orange', year: 1971 },
{ id: '100', title: 'Double Indemnity', year: 1944 },
];
}Form
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmFieldImports } from '@spartan-ng/helm/field';
interface SpartanComponent {
id: string;
value: string;
}
@Component({
selector: 'spartan-autocomplete-form-preview',
imports: [HlmAutocompleteImports, ReactiveFormsModule, HlmButton, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="component">Select a component</label>
<hlm-autocomplete formControlName="component" [(search)]="search">
<hlm-autocomplete-input inputId="component" placeholder="e.g. Accordion" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No components found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (component of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="component">
{{ component.value }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete>
</hlm-field>
<hlm-field orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</hlm-field>
</hlm-field-group>
</form>
`,
})
export class AutocompleteFormPreview {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
component: new FormControl<SpartanComponent | null>(null, Validators.required),
});
private readonly _components: SpartanComponent[] = [
{ id: 'accordion', value: 'Accordion' },
{ id: 'alert-dialog', value: 'Alert dialog' },
{ id: 'autocomplete', value: 'Autocomplete' },
{ id: 'avatar', value: 'Avatar' },
{ id: 'checkbox', value: 'Checkbox' },
{ id: 'collapsible', value: 'Collapsible' },
{ id: 'combobox', value: 'Combobox' },
{ id: 'command', value: 'Command' },
{ id: 'context-menu', value: 'Context menu' },
{ id: 'data-table', value: 'Data table' },
{ id: 'date-picker', value: 'Date picker' },
{ id: 'dialog', value: 'Dialog' },
{ id: 'field', value: 'Field' },
{ id: 'input', value: 'Input' },
{ id: 'menubar', value: 'Menubar' },
{ id: 'navigation-menu', value: 'Navigation menu' },
{ id: 'popover', value: 'Popover' },
{ id: 'progress', value: 'Progress' },
{ id: 'radio', value: 'Radio' },
{ id: 'scroll-area', value: 'Scroll area' },
{ id: 'select', value: 'Select' },
{ id: 'separator', value: 'Separator' },
{ id: 'skeleton', value: 'Skeleton' },
{ id: 'slider', value: 'Slider' },
{ id: 'sonner', value: 'Sonner' },
{ id: 'spinner', value: 'Spinner' },
{ id: 'switch', value: 'Switch' },
{ id: 'table', value: 'Table' },
{ id: 'tabs', value: 'Tabs' },
{ id: 'textarea', value: 'Textarea' },
{ id: 'toggle', value: 'Toggle' },
{ id: 'toggle-group', value: 'Toggle group' },
{ id: 'tooltip', value: 'Tooltip' },
];
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._components.filter((component) => component.value.toLowerCase().includes(this.search().toLowerCase())),
);
submit() {
console.log(this.form.value);
}
}Form (Free-form text)
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmAutocompleteImports } from '@spartan-ng/helm/autocomplete';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmFieldImports } from '@spartan-ng/helm/field';
interface Tag {
id: string;
value: string;
}
@Component({
selector: 'spartan-autocomplete-search-form-preview',
imports: [HlmAutocompleteImports, ReactiveFormsModule, HlmButton, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<hlm-field-group>
<hlm-field>
<label hlmFieldLabel for="tag">Create or select a tag</label>
<hlm-autocomplete-search formControlName="tag" [(search)]="search">
<hlm-autocomplete-input inputId="tag" placeholder="e.g. feature" />
<hlm-autocomplete-content *hlmAutocompletePortal>
<hlm-autocomplete-empty>No tags found.</hlm-autocomplete-empty>
<div hlmAutocompleteList>
@for (tag of filteredOptions(); track $index) {
<hlm-autocomplete-item [value]="tag">
{{ tag.value }}
</hlm-autocomplete-item>
}
</div>
</hlm-autocomplete-content>
</hlm-autocomplete-search>
<hlm-field-description>Create a new tag if it doesn't exist.</hlm-field-description>
</hlm-field>
<hlm-field orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</hlm-field>
</hlm-field-group>
</form>
`,
})
export class AutocompleteSearchFormPreview {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
tag: new FormControl<string | null>(null, Validators.required),
});
private readonly _tags: Tag[] = [
{ id: 't1', value: 'feature' },
{ id: 't2', value: 'fix' },
{ id: 't3', value: 'bug' },
{ id: 't4', value: 'docs' },
{ id: 't5', value: 'internal' },
{ id: 't6', value: 'mobile' },
{ id: 'accordion', value: 'Accordion' },
{ id: 'alert-dialog', value: 'Alert dialog' },
{ id: 'autocomplete', value: 'Autocomplete' },
{ id: 'avatar', value: 'Avatar' },
{ id: 'checkbox', value: 'Checkbox' },
{ id: 'collapsible', value: 'Collapsible' },
{ id: 'combobox', value: 'Combobox' },
{ id: 'command', value: 'Command' },
{ id: 'context-menu', value: 'Context menu' },
{ id: 'data-table', value: 'Data table' },
{ id: 'date-picker', value: 'Date picker' },
{ id: 'dialog', value: 'Dialog' },
{ id: 'field', value: 'Field' },
{ id: 'input', value: 'Input' },
{ id: 'menubar', value: 'Menubar' },
{ id: 'navigation-menu', value: 'Navigation menu' },
{ id: 'popover', value: 'Popover' },
{ id: 'progress', value: 'Progress' },
{ id: 'radio', value: 'Radio' },
{ id: 'scroll-area', value: 'Scroll area' },
{ id: 'select', value: 'Select' },
{ id: 'separator', value: 'Separator' },
{ id: 'skeleton', value: 'Skeleton' },
{ id: 'slider', value: 'Slider' },
{ id: 'sonner', value: 'Sonner' },
{ id: 'spinner', value: 'Spinner' },
{ id: 'switch', value: 'Switch' },
{ id: 'table', value: 'Table' },
{ id: 'tabs', value: 'Tabs' },
{ id: 'textarea', value: 'Textarea' },
{ id: 'toggle', value: 'Toggle' },
{ id: 'toggle-group', value: 'Toggle group' },
{ id: 'tooltip', value: 'Tooltip' },
];
public readonly search = signal('');
public readonly filteredOptions = computed(() =>
this._tags.filter((tag) => tag.value.toLowerCase().includes(this.search().toLowerCase())),
);
submit() {
console.log(this.form.value);
}
}Brain API
BrnAutocompleteAnchor
Selector: [brnAutocompleteAnchor]
BrnAutocompleteClear
Selector: [brnAutocompleteClear]
BrnAutocompleteContent
Selector: [brnAutocompleteContent]
BrnAutocompleteEmpty
Selector: [brnAutocompleteEmpty]
BrnAutocompleteGroup
Selector: [brnAutocompleteGroup]
BrnAutocompleteInputWrapper
Selector: [brnAutocompleteInputWrapper]
BrnAutocompleteInput
Selector: input[brnAutocompleteInput]
ExportAs: brnAutocompleteInput
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-autocomplete-input-${++BrnAutocompleteInput._id}` | The id of the autocomplete input |
| aria-invalid | boolean | undefined | undefined | Manual override for aria-invalid. When not set, auto-detects from the parent autocomplete error state. |
BrnAutocompleteItem
Selector: [brnAutocompleteItem]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-autocomplete-item-${++BrnAutocompleteItem._id}` | A unique id for the item |
| value* (required) | T | - | The value this item represents. |
| disabled | boolean | false | - |
BrnAutocompleteLabel
Selector: [brnAutocompleteLabel]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-autocomplete-label-${++BrnAutocompleteLabel._id}` | The id of the autocomplete label |
BrnAutocompleteList
Selector: [brnAutocompleteList]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-autocomplete-list-${++BrnAutocompleteList._id}` | The id of the autocomplete list |
BrnAutocompleteSearch
Selector: [brnAutocomplete]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | Whether the autocomplete is disabled |
| itemToString | AutocompleteItemToString<T> | undefined | this._config.itemToString | A function to convert an item to a string for display. |
| autoHighlight | boolean | this._config.autoHighlight | Whether to auto-highlight the first matching item. |
| value | string | null | null | The selected value of the autocomplete. |
| search | string | - | The current search query. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| valueChange | string | null | null | The selected value of the autocomplete. |
| searchChange | string | - | The current search query. |
BrnAutocompleteSeparator
Selector: [brnAutocompleteSeparator]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | 'horizontal' | 'vertical' | horizontal | - |
BrnAutocompleteStatus
Selector: [brnAutocompleteStatus]
BrnAutocomplete
Selector: [brnAutocomplete]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | Whether the autocomplete is disabled |
| isItemEqualToValue | AutocompleteItemEqualToValue<T> | this._config.isItemEqualToValue | A function to compare an item with the selected value. |
| itemToString | AutocompleteItemToString<T> | undefined | this._config.itemToString | A function to convert an item to a string for display. |
| autoHighlight | boolean | this._config.autoHighlight | Whether to auto-highlight the first matching item. |
| value | T | null | null | The selected value of the autocomplete. |
| search | string | - | The current search query. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| valueChange | T | null | null | The selected value of the autocomplete. |
| searchChange | string | - | The current search query. |
Helm API
HlmAutocompleteContent
Selector: [hlmAutocompleteContent],hlm-autocomplete-content
HlmAutocompleteEmpty
Selector: [hlmAutocompleteEmpty],hlm-autocomplete-empty
HlmAutocompleteGroup
Selector: [hlmAutocompleteGroup]
HlmAutocompleteInput
Selector: hlm-autocomplete-input
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| inputId | string | `hlm-autocomplete-input-${HlmAutocompleteInput._id++}` | - |
| placeholder | string | - | - |
| showSearch | boolean | true | - |
| showClear | boolean | false | - |
| aria-invalid | boolean | undefined | undefined | Manual override for aria-invalid. When not set, auto-detects from the parent autocomplete error state. |
HlmAutocompleteItem
Selector: hlm-autocomplete-item
HlmAutocompleteLabel
Selector: [hlmAutocompleteLabel]
HlmAutocompleteList
Selector: [hlmAutocompleteList]
HlmAutocompletePortal
Selector: [hlmAutocompletePortal]
HlmAutocompleteSearch
Selector: [hlmAutocompleteSearch],hlm-autocomplete-search
HlmAutocompleteSeparator
Selector: [hlmAutocompleteSeparator]
HlmAutocompleteStatus
Selector: [hlmAutocompleteStatus],hlm-autocomplete-status
HlmAutocomplete
Selector: [hlmAutocomplete],hlm-autocomplete
On This Page