- 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
- Form 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
Combobox
An input combined with a list of predefined items to select.
import { Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-preview',
imports: [HlmComboboxImports],
template: `
<hlm-combobox>
<hlm-combobox-input placeholder="Select a framework" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework.label }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxPreview {
public frameworks = [
{
label: 'AnalogJs',
value: 'analogjs',
},
{
label: 'Angular',
value: 'angular',
},
{
label: 'Vue',
value: 'vue',
},
{
label: 'Nuxt',
value: 'nuxt',
},
{
label: 'React',
value: 'react',
},
{
label: 'NextJs',
value: 'nextjs',
},
];
}
export const comboboxDefaultConfig = `
import { comboboxContainsFilter, provideBrnComboboxConfig } from '@spartan-ng/brain/combobox';
provideBrnComboboxConfig({
filterOptions: {
usage: 'search',
sensitivity: 'base',
ignorePunctuation: true,
},
filter: (itemValue: T, search: string, collator: Intl.Collator, itemToString?: ComboboxItemToString<T>) =>
comboboxContainsFilter(itemValue, search, collator, itemToString),
isItemEqualToValue: (itemValue: T, selectedValue: T | null) => Object.is(itemValue, selectedValue),
itemToString: undefined,
});
`;Installation
ng g @spartan-ng/cli:ui comboboxnx g @spartan-ng/cli:ui comboboximport { 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 type, { BooleanInput } from '@angular/cdk/coercion';
import type, { ClassValue } from 'clsx';
import { BrnCombobox, BrnComboboxAnchor, BrnComboboxChip, BrnComboboxChipInput, BrnComboboxChipRemove, BrnComboboxContent, BrnComboboxEmpty, BrnComboboxGroup, BrnComboboxImports, BrnComboboxInputWrapper, BrnComboboxItem, BrnComboboxLabel, BrnComboboxList, BrnComboboxMultiple, BrnComboboxPopoverTrigger, BrnComboboxSeparator, BrnComboboxStatus, BrnComboboxTrigger, BrnComboboxValue, BrnComboboxValues } from '@spartan-ng/brain/combobox';
import { BrnPopover, BrnPopoverContent, provideBrnPopoverConfig } from '@spartan-ng/brain/popover';
import { ButtonVariants, HlmButton, buttonVariants } from '@spartan-ng/helm/button';
import { ChangeDetectionStrategy, Component, Directive, booleanAttribute, computed, inject, input } from '@angular/core';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { classes, hlm } from '@spartan-ng/helm/utils';
import { lucideCheck, lucideChevronDown, lucideX } from '@ng-icons/lucide';
import { provideBrnDialogDefaultOptions } from '@spartan-ng/brain/dialog';
@Directive({
selector: 'input[hlmComboboxChipInput]',
hostDirectives: [{ directive: BrnComboboxChipInput, inputs: ['id'] }],
host: {
'data-slott': 'combobox-chip-input',
},
})
export class HlmComboboxChipInput {
constructor() {
classes(() => 'placeholder:text-muted-foreground min-w-16 flex-1 outline-none');
}
}
@Directive({
selector: 'button[hlmComboboxChipRemove]',
hostDirectives: [BrnComboboxChipRemove],
host: {
'data-slot': 'combobox-chip-remove',
},
})
export class HlmComboboxChipRemove {
constructor() {
classes(() => ['-ml-1 opacity-50 hover:opacity-100', buttonVariants({ variant: 'ghost', size: 'icon-xs' })]);
}
}
@Component({
selector: 'hlm-combobox-chip',
imports: [NgIcon, HlmComboboxChipRemove],
providers: [provideIcons({ lucideX })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnComboboxChip, inputs: ['value'] }],
host: {
'data-slot': 'combobox-chip',
},
template: `
<ng-content />
@if (showRemove()) {
<button hlmComboboxChipRemove>
<ng-icon name="lucideX" />
</button>
}
`,
})
export class HlmComboboxChip {
public readonly showRemove = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
constructor() {
classes(
() =>
'bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0',
);
}
}
@Directive({
selector: '[hlmComboboxChips],hlm-combobox-chips',
hostDirectives: [BrnComboboxInputWrapper, BrnComboboxAnchor, BrnComboboxPopoverTrigger],
host: {
'data-slott': 'combobox-chips',
},
})
export class HlmComboboxChips {
constructor() {
classes(
() =>
'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 has-data-[slot=combobox-chip]:px-1.5; flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px]',
);
}
}
@Directive({
selector: '[hlmComboboxContent],hlm-combobox-content',
hostDirectives: [BrnComboboxContent],
})
export class HlmComboboxContent {
constructor() {
classes(() => [
'group/combobox-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-combobox-width) min-w-36 flex-col overflow-hidden rounded-md p-0 shadow-md ring-1 duration-100',
// change input group styles in the content
'**:data-[slot=input-group]:bg-input **:data-[slot=input-group]:border-input/30 **:has-[[data-slot=input-group-control]:focus-visible]:border-input **:has-[[data-slot=input-group-control]:focus-visible]:ring-0 **:data-[slot=input-group]:m-1 **:data-[slot=input-group]:mb-0 **:data-[slot=input-group]:h-8 **:data-[slot=input-group]:shadow-none',
]);
}
}
@Directive({
selector: '[hlmComboboxEmpty],hlm-combobox-empty',
hostDirectives: [BrnComboboxEmpty],
host: {
'data-slot': 'combobox-empty',
},
})
export class HlmComboboxEmpty {
constructor() {
classes(
() =>
'text-muted-foreground hidden w-full items-center justify-center gap-2 py-2 text-center text-sm group-data-empty/combobox-content:flex',
);
}
}
@Directive({
selector: '[hlmComboboxGroup]',
hostDirectives: [BrnComboboxGroup],
host: {
'data-slot': 'combobox-group',
},
})
export class HlmComboboxGroup {
constructor() {
classes(() => 'data-hidden:hidden');
}
}
@Component({
selector: 'hlm-combobox-input',
imports: [HlmInputGroupImports, NgIcon, BrnComboboxImports, BrnComboboxPopoverTrigger],
providers: [provideIcons({ lucideChevronDown, lucideX })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnComboboxInputWrapper],
template: `
<hlm-input-group brnComboboxAnchor class="w-auto">
<input
brnComboboxInput
#comboboxInput="brnComboboxInput"
brnComboboxPopoverTrigger
hlmInputGroupInput
[placeholder]="placeholder()"
[attr.aria-invalid]="ariaInvalid() ? 'true' : null"
/>
<hlm-input-group-addon align="inline-end">
@if (showTrigger()) {
<button
brnComboboxPopoverTrigger
hlmInputGroupButton
data-slot="input-group-button"
[disabled]="comboboxInput.disabled()"
size="icon-xs"
variant="ghost"
class="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
>
<ng-icon name="lucideChevronDown" />
</button>
}
@if (showClear()) {
<button
*brnComboboxClear
hlmInputGroupButton
data-slot="combobox-clear"
[disabled]="comboboxInput.disabled()"
size="icon-xs"
variant="ghost"
>
<ng-icon name="lucideX" />
</button>
}
</hlm-input-group-addon>
<ng-content />
</hlm-input-group>
`,
})
export class HlmComboboxInput {
public readonly placeholder = input<string>('');
public readonly showTrigger = input<boolean, BooleanInput>(true, { transform: booleanAttribute });
public readonly showClear = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
// TODO input and input-group styles need to support aria-invalid directly
public readonly ariaInvalid = input<boolean, BooleanInput>(false, {
transform: booleanAttribute,
alias: 'aria-invalid',
});
}
@Component({
selector: 'hlm-combobox-item',
imports: [NgIcon],
providers: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnComboboxItem, inputs: ['id', 'disabled', 'value'] }],
host: {
'data-slot': 'combobox-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 HlmComboboxItem {
private readonly _brnComboboxItem = inject(BrnComboboxItem);
protected readonly _active = this._brnComboboxItem.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: '[hlmComboboxLabel]',
hostDirectives: [{ directive: BrnComboboxLabel, inputs: ['id'] }],
host: {
'data-slot': 'combobox-label',
},
})
export class HlmComboboxLabel {
constructor() {
classes(() => 'text-muted-foreground px-2 py-1.5 text-xs');
}
}
@Directive({
selector: '[hlmComboboxList]',
hostDirectives: [{ directive: BrnComboboxList, inputs: ['id'] }],
host: {
'data-slot': 'combobox-list',
},
})
export class HlmComboboxList {
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: '[hlmComboboxMultiple],hlm-combobox-multiple',
providers: [
provideBrnPopoverConfig({
align: 'start',
sideOffset: 6,
}),
provideBrnDialogDefaultOptions({
autoFocus: 'first-heading',
}),
],
hostDirectives: [
{
directive: BrnComboboxMultiple,
inputs: [
'autoHighlight',
'disabled',
'filter',
'search',
'value',
'itemToString',
'filterOptions',
'isItemEqualToValue',
],
outputs: ['searchChange', 'valueChange'],
},
{
directive: BrnPopover,
inputs: [
'align',
'autoFocus',
'closeDelay',
'closeOnOutsidePointerEvents',
'sideOffset',
'state',
'offsetX',
'restoreFocus',
],
outputs: ['stateChanged', 'closed'],
},
],
host: {
'data-slot': 'combobox',
},
})
export class HlmComboboxMultiple {
constructor() {
classes(() => 'block');
}
}
@Directive({
selector: '[hlmComboboxPortal]',
hostDirectives: [{ directive: BrnPopoverContent, inputs: ['context', 'class'] }],
})
export class HlmComboboxPortal {}
@Directive({
selector: '[hlmComboboxSeparator]',
hostDirectives: [{ directive: BrnComboboxSeparator, inputs: ['orientation'] }],
host: {
'data-slot': 'combobox-separator',
},
})
export class HlmComboboxSeparator {
constructor() {
classes(() => 'bg-border -mx-1 my-1 h-px');
}
}
@Directive({
selector: '[hlmComboboxStatus],hlm-combobox-status',
hostDirectives: [BrnComboboxStatus],
host: {
'data-slot': 'combobox-status',
},
})
export class HlmComboboxStatus {
constructor() {
classes(() => 'text-muted-foreground flex w-full items-center justify-center gap-2 py-2 text-center text-sm');
}
}
@Component({
selector: 'hlm-combobox-trigger',
imports: [NgIcon, HlmButton, BrnComboboxAnchor, BrnComboboxTrigger, BrnComboboxPopoverTrigger],
providers: [provideIcons({ lucideChevronDown })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [BrnComboboxInputWrapper],
template: `
<button
brnComboboxTrigger
brnComboboxAnchor
brnComboboxPopoverTrigger
hlmBtn
data-slot="combobox-trigger"
[class]="_computedClass()"
[variant]="variant()"
>
<ng-content />
<ng-icon name="lucideChevronDown" />
</button>
`,
})
export class HlmComboboxTrigger {
public readonly userClass = input<ClassValue>('', {
alias: 'class',
});
protected readonly _computedClass = computed(() => hlm(this.userClass()));
public readonly variant = input<ButtonVariants['variant']>('outline');
}
@Directive({ selector: '[hlmComboboxValue]', hostDirectives: [BrnComboboxValue] })
export class HlmComboboxValue {}
@Directive({ selector: '[hlmComboboxValues]', hostDirectives: [BrnComboboxValues] })
export class HlmComboboxValues {}
@Directive({
selector: '[hlmCombobox],hlm-combobox',
providers: [
provideBrnPopoverConfig({
align: 'start',
sideOffset: 6,
}),
provideBrnDialogDefaultOptions({
autoFocus: 'first-heading',
}),
],
hostDirectives: [
{
directive: BrnCombobox,
inputs: [
'autoHighlight',
'disabled',
'filter',
'search',
'value',
'itemToString',
'filterOptions',
'isItemEqualToValue',
],
outputs: ['searchChange', 'valueChange'],
},
{
directive: BrnPopover,
inputs: [
'align',
'autoFocus',
'closeDelay',
'closeOnOutsidePointerEvents',
'sideOffset',
'state',
'offsetX',
'restoreFocus',
],
outputs: ['stateChanged', 'closed'],
},
],
host: {
'data-slot': 'combobox',
},
})
export class HlmCombobox {
constructor() {
classes(() => 'block');
}
}
export const HlmComboboxImports = [
HlmCombobox,
HlmComboboxChip,
HlmComboboxChipInput,
HlmComboboxChips,
HlmComboboxContent,
HlmComboboxEmpty,
HlmComboboxGroup,
HlmComboboxInput,
HlmComboboxItem,
HlmComboboxLabel,
HlmComboboxList,
HlmComboboxMultiple,
HlmComboboxPortal,
HlmComboboxSeparator,
HlmComboboxStatus,
HlmComboboxTrigger,
HlmComboboxValue,
HlmComboboxValues,
] as const;Usage
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';<hlm-combobox>
<hlm-combobox-input placeholder="Select a framework" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework.label }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>Configuration
The combobox can be configured via provideBrnComboboxConfig or by passing the individual config as input. This is the default combobox config:
import { comboboxContainsFilter, provideBrnComboboxConfig } from '@spartan-ng/brain/combobox';
provideBrnComboboxConfig({
filterOptions: {
usage: 'search',
sensitivity: 'base',
ignorePunctuation: true,
},
filter: (itemValue: T, search: string, collator: Intl.Collator, itemToString?: ComboboxItemToString<T>) =>
comboboxContainsFilter(itemValue, search, collator, itemToString),
isItemEqualToValue: (itemValue: T, selectedValue: T | null) => Object.is(itemValue, selectedValue),
itemToString: undefined,
});Filter
The combobox matches items using Intl.Collator for robust string comparison. The filterOptions accepts all Intl.CollatorOptions , plus a locale .
The default filter implementation uses a comboboxContainsFilter to check if the item value contains the search string. Provide a custom filter function for other filtering behavior.
Built-in combobox filters are:
comboboxContainsFilter- Returns true if the item value contains the search string.comboboxStartsWithFilter- Returns true if the item value starts with the search string.comboboxEndsWithFilter- Returns true if the item value ends with the search string.
Objects
The combobox 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 a combobox item value matches the current selected value. Defaults to Object.is comparison.
Examples
Multiple select
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-multiple-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox-multiple [(value)]="values">
<hlm-combobox-chips class="max-w-xs">
<ng-template hlmComboboxValues let-values>
@for (value of values; track $index) {
<hlm-combobox-chip [value]="value">{{ value }}</hlm-combobox-chip>
}
</ng-template>
<input hlmComboboxChipInput />
</hlm-combobox-chips>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox-multiple>
`,
})
export class ComboboxMultiplePreview {
public values = signal(['Angular']);
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
}Objects (itemToString)
The itemToString function transforms an item object into a display string. For example, it can resolve an id value to its corresponding name for display.
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmFieldImports } from '@spartan-ng/helm/field';
type Assignee = {
id: string;
name: string;
};
@Component({
selector: 'spartan-combobox-item-to-string-preview',
imports: [HlmComboboxImports, ReactiveFormsModule, HlmButton, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<div hlmFieldGroup>
<div hlmField>
<label hlmFieldLabel>Assign reviewer</label>
<hlm-combobox [(search)]="search" formControlName="assignee" [itemToString]="itemToString">
<hlm-combobox-input placeholder="e.g. Einstein" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (assignee of filteredOptions(); track $index) {
<hlm-combobox-item [value]="assignee.id">{{ assignee.name }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
</div>
<div hlmField orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</div>
</div>
</form>
`,
})
export class ComboboxItemToStringPreview {
private readonly _formBuilder = inject(FormBuilder);
public search = signal('');
public form = this._formBuilder.group({
assignee: new FormControl<string>('8', Validators.required),
});
public itemToString = (assigneeId: string) =>
this._assignees.find((assignee) => assignee.id === assigneeId)?.name ?? '';
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 readonly filteredOptions = computed(() => {
return this._assignees.filter((assignee) => assignee.name.toLowerCase().includes(this.search().toLowerCase()));
});
submit() {
console.log(this.form.value);
}
}With Clear Button
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-clear-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox>
<hlm-combobox-input placeholder="Select a framework" showClear />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxClearPreview {
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
}Disabled (single)
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-disabled-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox disabled>
<hlm-combobox-input placeholder="Select a framework" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxDisabledPreview {
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
}Disabled (multiple)
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-multiple-disabled-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox-multiple [(value)]="values" disabled>
<hlm-combobox-chips class="max-w-xs">
<ng-template hlmComboboxValues let-values>
@for (value of values; track $index) {
<hlm-combobox-chip [value]="value">{{ value }}</hlm-combobox-chip>
}
</ng-template>
<input hlmComboboxChipInput />
</hlm-combobox-chips>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox-multiple>
`,
})
export class ComboboxMultipleDisabledPreview {
public values = signal(['Analog', 'Astro']);
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
}Auto highlight (single)
import { Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-autohighlight-preview',
imports: [HlmComboboxImports],
template: `
<hlm-combobox autoHighlight>
<hlm-combobox-input placeholder="Select a framework" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework.label }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxAutoHighlightPreview {
public frameworks = [
{
label: 'AnalogJs',
value: 'analogjs',
},
{
label: 'Angular',
value: 'angular',
},
{
label: 'Vue',
value: 'vue',
},
{
label: 'Nuxt',
value: 'nuxt',
},
{
label: 'React',
value: 'react',
},
{
label: 'NextJs',
value: 'nextjs',
},
];
}Auto highlight (multiple)
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-multiple-autohighlight-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox-multiple [(value)]="values" autoHighlight>
<hlm-combobox-chips class="max-w-xs">
<ng-template hlmComboboxValues let-values>
@for (value of values; track $index) {
<hlm-combobox-chip [value]="value">{{ value }}</hlm-combobox-chip>
}
</ng-template>
<input hlmComboboxChipInput />
</hlm-combobox-chips>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox-multiple>
`,
})
export class ComboboxMultipleAutoHighlightPreview {
public values = signal(['Angular']);
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
}With Groups
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-group-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox>
<hlm-combobox-input placeholder="Select a timezone" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (timezoneGroup of timezones; track $index) {
<div hlmComboboxGroup>
<div hlmComboboxLabel>{{ timezoneGroup.value }}</div>
@for (timezone of timezoneGroup.items; track $index) {
<hlm-combobox-item [value]="timezone">{{ timezone }}</hlm-combobox-item>
}
</div>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxGroupPreview {
public 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',
],
},
];
}With Groups and Separators
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-group-separator-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox>
<hlm-combobox-input placeholder="Select a timezone" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (timezoneGroup of timezones; track $index) {
<div hlmComboboxGroup>
<div hlmComboboxLabel>{{ timezoneGroup.value }}</div>
@for (timezone of timezoneGroup.items; track $index) {
<hlm-combobox-item [value]="timezone">{{ timezone }}</hlm-combobox-item>
}
<div hlmComboboxSeparator></div>
</div>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxGroupSeparatorPreview {
public 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',
],
},
];
}With Icon Addon
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideGlobe } from '@ng-icons/lucide';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmInputGroupAddon } from '@spartan-ng/helm/input-group';
@Component({
selector: 'spartan-combobox-icon-addon-preview',
imports: [HlmComboboxImports, HlmInputGroupAddon, NgIcon],
providers: [provideIcons({ lucideGlobe })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox>
<hlm-combobox-input placeholder="Select a timezone">
<hlm-input-group-addon>
<ng-icon name="lucideGlobe" />
</hlm-input-group-addon>
</hlm-combobox-input>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (timezoneGroup of timezones; track $index) {
<div hlmComboboxGroup>
<div hlmComboboxLabel>{{ timezoneGroup.value }}</div>
@for (timezone of timezoneGroup.items; track $index) {
<hlm-combobox-item [value]="timezone">{{ timezone }}</hlm-combobox-item>
}
<div hlmComboboxSeparator></div>
</div>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxIconAddonPreview {
public 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',
],
},
];
}Popup
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
@Component({
selector: 'spartan-combobox-popup-preview',
imports: [HlmComboboxImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox autoFocus="first-tabbable">
<hlm-combobox-trigger class="w-64 justify-between font-normal">
<hlm-combobox-value placeholder="Select a country" />
</hlm-combobox-trigger>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-input showTrigger="false" placeholder="Search" showClear />
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (country of countries; track country.code) {
<hlm-combobox-item [value]="country">{{ country.label }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxPopupPreview {
public countries = [
{ code: 'af', value: 'afghanistan', label: 'Afghanistan', continent: 'Asia' },
{ code: 'al', value: 'albania', label: 'Albania', continent: 'Europe' },
{ code: 'dz', value: 'algeria', label: 'Algeria', continent: 'Africa' },
{ code: 'ad', value: 'andorra', label: 'Andorra', continent: 'Europe' },
{ code: 'ao', value: 'angola', label: 'Angola', continent: 'Africa' },
{
code: 'ar',
value: 'argentina',
label: 'Argentina',
continent: 'South America',
},
{ code: 'am', value: 'armenia', label: 'Armenia', continent: 'Asia' },
{ code: 'au', value: 'australia', label: 'Australia', continent: 'Oceania' },
{ code: 'at', value: 'austria', label: 'Austria', continent: 'Europe' },
{ code: 'az', value: 'azerbaijan', label: 'Azerbaijan', continent: 'Asia' },
{
code: 'bs',
value: 'bahamas',
label: 'Bahamas',
continent: 'North America',
},
{ code: 'bh', value: 'bahrain', label: 'Bahrain', continent: 'Asia' },
{ code: 'bd', value: 'bangladesh', label: 'Bangladesh', continent: 'Asia' },
{
code: 'bb',
value: 'barbados',
label: 'Barbados',
continent: 'North America',
},
{ code: 'by', value: 'belarus', label: 'Belarus', continent: 'Europe' },
{ code: 'be', value: 'belgium', label: 'Belgium', continent: 'Europe' },
{ code: 'bz', value: 'belize', label: 'Belize', continent: 'North America' },
{ code: 'bj', value: 'benin', label: 'Benin', continent: 'Africa' },
{ code: 'bt', value: 'bhutan', label: 'Bhutan', continent: 'Asia' },
{
code: 'bo',
value: 'bolivia',
label: 'Bolivia',
continent: 'South America',
},
{
code: 'ba',
value: 'bosnia-and-herzegovina',
label: 'Bosnia and Herzegovina',
continent: 'Europe',
},
{ code: 'bw', value: 'botswana', label: 'Botswana', continent: 'Africa' },
{ code: 'br', value: 'brazil', label: 'Brazil', continent: 'South America' },
{ code: 'bn', value: 'brunei', label: 'Brunei', continent: 'Asia' },
{ code: 'bg', value: 'bulgaria', label: 'Bulgaria', continent: 'Europe' },
{
code: 'bf',
value: 'burkina-faso',
label: 'Burkina Faso',
continent: 'Africa',
},
{ code: 'bi', value: 'burundi', label: 'Burundi', continent: 'Africa' },
{ code: 'kh', value: 'cambodia', label: 'Cambodia', continent: 'Asia' },
{ code: 'cm', value: 'cameroon', label: 'Cameroon', continent: 'Africa' },
{ code: 'ca', value: 'canada', label: 'Canada', continent: 'North America' },
{ code: 'cv', value: 'cape-verde', label: 'Cape Verde', continent: 'Africa' },
{
code: 'cf',
value: 'central-african-republic',
label: 'Central African Republic',
continent: 'Africa',
},
{ code: 'td', value: 'chad', label: 'Chad', continent: 'Africa' },
{ code: 'cl', value: 'chile', label: 'Chile', continent: 'South America' },
{ code: 'cn', value: 'china', label: 'China', continent: 'Asia' },
{
code: 'co',
value: 'colombia',
label: 'Colombia',
continent: 'South America',
},
{ code: 'km', value: 'comoros', label: 'Comoros', continent: 'Africa' },
{ code: 'cg', value: 'congo', label: 'Congo', continent: 'Africa' },
{
code: 'cr',
value: 'costa-rica',
label: 'Costa Rica',
continent: 'North America',
},
{ code: 'hr', value: 'croatia', label: 'Croatia', continent: 'Europe' },
{ code: 'cu', value: 'cuba', label: 'Cuba', continent: 'North America' },
{ code: 'cy', value: 'cyprus', label: 'Cyprus', continent: 'Asia' },
{
code: 'cz',
value: 'czech-republic',
label: 'Czech Republic',
continent: 'Europe',
},
{ code: 'dk', value: 'denmark', label: 'Denmark', continent: 'Europe' },
{ code: 'dj', value: 'djibouti', label: 'Djibouti', continent: 'Africa' },
{
code: 'dm',
value: 'dominica',
label: 'Dominica',
continent: 'North America',
},
{
code: 'do',
value: 'dominican-republic',
label: 'Dominican Republic',
continent: 'North America',
},
{
code: 'ec',
value: 'ecuador',
label: 'Ecuador',
continent: 'South America',
},
{ code: 'eg', value: 'egypt', label: 'Egypt', continent: 'Africa' },
{
code: 'sv',
value: 'el-salvador',
label: 'El Salvador',
continent: 'North America',
},
{
code: 'gq',
value: 'equatorial-guinea',
label: 'Equatorial Guinea',
continent: 'Africa',
},
{ code: 'er', value: 'eritrea', label: 'Eritrea', continent: 'Africa' },
{ code: 'ee', value: 'estonia', label: 'Estonia', continent: 'Europe' },
{ code: 'et', value: 'ethiopia', label: 'Ethiopia', continent: 'Africa' },
{ code: 'fj', value: 'fiji', label: 'Fiji', continent: 'Oceania' },
{ code: 'fi', value: 'finland', label: 'Finland', continent: 'Europe' },
{ code: 'fr', value: 'france', label: 'France', continent: 'Europe' },
{ code: 'ga', value: 'gabon', label: 'Gabon', continent: 'Africa' },
{ code: 'gm', value: 'gambia', label: 'Gambia', continent: 'Africa' },
{ code: 'ge', value: 'georgia', label: 'Georgia', continent: 'Asia' },
{ code: 'de', value: 'germany', label: 'Germany', continent: 'Europe' },
{ code: 'gh', value: 'ghana', label: 'Ghana', continent: 'Africa' },
{ code: 'gr', value: 'greece', label: 'Greece', continent: 'Europe' },
{
code: 'gd',
value: 'grenada',
label: 'Grenada',
continent: 'North America',
},
{
code: 'gt',
value: 'guatemala',
label: 'Guatemala',
continent: 'North America',
},
{ code: 'gn', value: 'guinea', label: 'Guinea', continent: 'Africa' },
{
code: 'gw',
value: 'guinea-bissau',
label: 'Guinea-Bissau',
continent: 'Africa',
},
{ code: 'gy', value: 'guyana', label: 'Guyana', continent: 'South America' },
{ code: 'ht', value: 'haiti', label: 'Haiti', continent: 'North America' },
{
code: 'hn',
value: 'honduras',
label: 'Honduras',
continent: 'North America',
},
{ code: 'hu', value: 'hungary', label: 'Hungary', continent: 'Europe' },
{ code: 'is', value: 'iceland', label: 'Iceland', continent: 'Europe' },
{ code: 'in', value: 'india', label: 'India', continent: 'Asia' },
{ code: 'id', value: 'indonesia', label: 'Indonesia', continent: 'Asia' },
{ code: 'ir', value: 'iran', label: 'Iran', continent: 'Asia' },
{ code: 'iq', value: 'iraq', label: 'Iraq', continent: 'Asia' },
{ code: 'ie', value: 'ireland', label: 'Ireland', continent: 'Europe' },
{ code: 'il', value: 'israel', label: 'Israel', continent: 'Asia' },
{ code: 'it', value: 'italy', label: 'Italy', continent: 'Europe' },
{
code: 'jm',
value: 'jamaica',
label: 'Jamaica',
continent: 'North America',
},
{ code: 'jp', value: 'japan', label: 'Japan', continent: 'Asia' },
{ code: 'jo', value: 'jordan', label: 'Jordan', continent: 'Asia' },
{ code: 'kz', value: 'kazakhstan', label: 'Kazakhstan', continent: 'Asia' },
{ code: 'ke', value: 'kenya', label: 'Kenya', continent: 'Africa' },
{ code: 'kw', value: 'kuwait', label: 'Kuwait', continent: 'Asia' },
{ code: 'kg', value: 'kyrgyzstan', label: 'Kyrgyzstan', continent: 'Asia' },
{ code: 'la', value: 'laos', label: 'Laos', continent: 'Asia' },
{ code: 'lv', value: 'latvia', label: 'Latvia', continent: 'Europe' },
{ code: 'lb', value: 'lebanon', label: 'Lebanon', continent: 'Asia' },
{ code: 'ls', value: 'lesotho', label: 'Lesotho', continent: 'Africa' },
{ code: 'lr', value: 'liberia', label: 'Liberia', continent: 'Africa' },
{ code: 'ly', value: 'libya', label: 'Libya', continent: 'Africa' },
{
code: 'li',
value: 'liechtenstein',
label: 'Liechtenstein',
continent: 'Europe',
},
{ code: 'lt', value: 'lithuania', label: 'Lithuania', continent: 'Europe' },
{ code: 'lu', value: 'luxembourg', label: 'Luxembourg', continent: 'Europe' },
{ code: 'mg', value: 'madagascar', label: 'Madagascar', continent: 'Africa' },
{ code: 'mw', value: 'malawi', label: 'Malawi', continent: 'Africa' },
{ code: 'my', value: 'malaysia', label: 'Malaysia', continent: 'Asia' },
{ code: 'mv', value: 'maldives', label: 'Maldives', continent: 'Asia' },
{ code: 'ml', value: 'mali', label: 'Mali', continent: 'Africa' },
{ code: 'mt', value: 'malta', label: 'Malta', continent: 'Europe' },
{
code: 'mh',
value: 'marshall-islands',
label: 'Marshall Islands',
continent: 'Oceania',
},
{ code: 'mr', value: 'mauritania', label: 'Mauritania', continent: 'Africa' },
{ code: 'mu', value: 'mauritius', label: 'Mauritius', continent: 'Africa' },
{ code: 'mx', value: 'mexico', label: 'Mexico', continent: 'North America' },
{
code: 'fm',
value: 'micronesia',
label: 'Micronesia',
continent: 'Oceania',
},
{ code: 'md', value: 'moldova', label: 'Moldova', continent: 'Europe' },
{ code: 'mc', value: 'monaco', label: 'Monaco', continent: 'Europe' },
{ code: 'mn', value: 'mongolia', label: 'Mongolia', continent: 'Asia' },
{ code: 'me', value: 'montenegro', label: 'Montenegro', continent: 'Europe' },
{ code: 'ma', value: 'morocco', label: 'Morocco', continent: 'Africa' },
{ code: 'mz', value: 'mozambique', label: 'Mozambique', continent: 'Africa' },
{ code: 'mm', value: 'myanmar', label: 'Myanmar', continent: 'Asia' },
{ code: 'na', value: 'namibia', label: 'Namibia', continent: 'Africa' },
{ code: 'nr', value: 'nauru', label: 'Nauru', continent: 'Oceania' },
{ code: 'np', value: 'nepal', label: 'Nepal', continent: 'Asia' },
{
code: 'nl',
value: 'netherlands',
label: 'Netherlands',
continent: 'Europe',
},
{
code: 'nz',
value: 'new-zealand',
label: 'New Zealand',
continent: 'Oceania',
},
{
code: 'ni',
value: 'nicaragua',
label: 'Nicaragua',
continent: 'North America',
},
{ code: 'ne', value: 'niger', label: 'Niger', continent: 'Africa' },
{ code: 'ng', value: 'nigeria', label: 'Nigeria', continent: 'Africa' },
{ code: 'kp', value: 'north-korea', label: 'North Korea', continent: 'Asia' },
{
code: 'mk',
value: 'north-macedonia',
label: 'North Macedonia',
continent: 'Europe',
},
{ code: 'no', value: 'norway', label: 'Norway', continent: 'Europe' },
{ code: 'om', value: 'oman', label: 'Oman', continent: 'Asia' },
{ code: 'pk', value: 'pakistan', label: 'Pakistan', continent: 'Asia' },
{ code: 'pw', value: 'palau', label: 'Palau', continent: 'Oceania' },
{ code: 'ps', value: 'palestine', label: 'Palestine', continent: 'Asia' },
{ code: 'pa', value: 'panama', label: 'Panama', continent: 'North America' },
{
code: 'pg',
value: 'papua-new-guinea',
label: 'Papua New Guinea',
continent: 'Oceania',
},
{
code: 'py',
value: 'paraguay',
label: 'Paraguay',
continent: 'South America',
},
{ code: 'pe', value: 'peru', label: 'Peru', continent: 'South America' },
{ code: 'ph', value: 'philippines', label: 'Philippines', continent: 'Asia' },
{ code: 'pl', value: 'poland', label: 'Poland', continent: 'Europe' },
{ code: 'pt', value: 'portugal', label: 'Portugal', continent: 'Europe' },
{ code: 'qa', value: 'qatar', label: 'Qatar', continent: 'Asia' },
{ code: 'ro', value: 'romania', label: 'Romania', continent: 'Europe' },
{ code: 'ru', value: 'russia', label: 'Russia', continent: 'Europe' },
{ code: 'rw', value: 'rwanda', label: 'Rwanda', continent: 'Africa' },
{ code: 'ws', value: 'samoa', label: 'Samoa', continent: 'Oceania' },
{ code: 'sm', value: 'san-marino', label: 'San Marino', continent: 'Europe' },
{
code: 'sa',
value: 'saudi-arabia',
label: 'Saudi Arabia',
continent: 'Asia',
},
{ code: 'sn', value: 'senegal', label: 'Senegal', continent: 'Africa' },
{ code: 'rs', value: 'serbia', label: 'Serbia', continent: 'Europe' },
{ code: 'sc', value: 'seychelles', label: 'Seychelles', continent: 'Africa' },
{
code: 'sl',
value: 'sierra-leone',
label: 'Sierra Leone',
continent: 'Africa',
},
{ code: 'sg', value: 'singapore', label: 'Singapore', continent: 'Asia' },
{ code: 'sk', value: 'slovakia', label: 'Slovakia', continent: 'Europe' },
{ code: 'si', value: 'slovenia', label: 'Slovenia', continent: 'Europe' },
{
code: 'sb',
value: 'solomon-islands',
label: 'Solomon Islands',
continent: 'Oceania',
},
{ code: 'so', value: 'somalia', label: 'Somalia', continent: 'Africa' },
{
code: 'za',
value: 'south-africa',
label: 'South Africa',
continent: 'Africa',
},
{ code: 'kr', value: 'south-korea', label: 'South Korea', continent: 'Asia' },
{
code: 'ss',
value: 'south-sudan',
label: 'South Sudan',
continent: 'Africa',
},
{ code: 'es', value: 'spain', label: 'Spain', continent: 'Europe' },
{ code: 'lk', value: 'sri-lanka', label: 'Sri Lanka', continent: 'Asia' },
{ code: 'sd', value: 'sudan', label: 'Sudan', continent: 'Africa' },
{
code: 'sr',
value: 'suriname',
label: 'Suriname',
continent: 'South America',
},
{ code: 'se', value: 'sweden', label: 'Sweden', continent: 'Europe' },
{
code: 'ch',
value: 'switzerland',
label: 'Switzerland',
continent: 'Europe',
},
{ code: 'sy', value: 'syria', label: 'Syria', continent: 'Asia' },
{ code: 'tw', value: 'taiwan', label: 'Taiwan', continent: 'Asia' },
{ code: 'tj', value: 'tajikistan', label: 'Tajikistan', continent: 'Asia' },
{ code: 'tz', value: 'tanzania', label: 'Tanzania', continent: 'Africa' },
{ code: 'th', value: 'thailand', label: 'Thailand', continent: 'Asia' },
{ code: 'tl', value: 'timor-leste', label: 'Timor-Leste', continent: 'Asia' },
{ code: 'tg', value: 'togo', label: 'Togo', continent: 'Africa' },
{ code: 'to', value: 'tonga', label: 'Tonga', continent: 'Oceania' },
{
code: 'tt',
value: 'trinidad-and-tobago',
label: 'Trinidad and Tobago',
continent: 'North America',
},
{ code: 'tn', value: 'tunisia', label: 'Tunisia', continent: 'Africa' },
{ code: 'tr', value: 'turkey', label: 'Turkey', continent: 'Asia' },
{
code: 'tm',
value: 'turkmenistan',
label: 'Turkmenistan',
continent: 'Asia',
},
{ code: 'tv', value: 'tuvalu', label: 'Tuvalu', continent: 'Oceania' },
{ code: 'ug', value: 'uganda', label: 'Uganda', continent: 'Africa' },
{ code: 'ua', value: 'ukraine', label: 'Ukraine', continent: 'Europe' },
{
code: 'ae',
value: 'united-arab-emirates',
label: 'United Arab Emirates',
continent: 'Asia',
},
{
code: 'gb',
value: 'united-kingdom',
label: 'United Kingdom',
continent: 'Europe',
},
{
code: 'us',
value: 'united-states',
label: 'United States',
continent: 'North America',
},
{
code: 'uy',
value: 'uruguay',
label: 'Uruguay',
continent: 'South America',
},
{ code: 'uz', value: 'uzbekistan', label: 'Uzbekistan', continent: 'Asia' },
{ code: 'vu', value: 'vanuatu', label: 'Vanuatu', continent: 'Oceania' },
{
code: 'va',
value: 'vatican-city',
label: 'Vatican City',
continent: 'Europe',
},
{
code: 've',
value: 'venezuela',
label: 'Venezuela',
continent: 'South America',
},
{ code: 'vn', value: 'vietnam', label: 'Vietnam', continent: 'Asia' },
{ code: 'ye', value: 'yemen', label: 'Yemen', continent: 'Asia' },
{ code: 'zm', value: 'zambia', label: 'Zambia', continent: 'Africa' },
{ code: 'zw', value: 'zimbabwe', label: 'Zimbabwe', continent: 'Africa' },
];
}Popup Custom
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideEarth } from '@ng-icons/lucide';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
type Country = {
code: string;
value: string;
label: string;
continent: string;
flag: string;
};
@Component({
selector: 'spartan-combobox-popup-custom-preview',
imports: [HlmComboboxImports, NgIcon],
providers: [provideIcons({ lucideEarth })],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox autoFocus="first-tabbable">
<hlm-combobox-trigger class="w-64 justify-between font-normal">
<hlm-combobox-placeholder>
<ng-icon name="lucideEarth" />
Select a country
</hlm-combobox-placeholder>
<ng-template hlmComboboxValueTemplate let-value>
<span>{{ value.flag }} {{ value.label }}</span>
</ng-template>
</hlm-combobox-trigger>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-input showTrigger="false" placeholder="Search" showClear />
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (country of countries; track country.code) {
<hlm-combobox-item [value]="country">{{ country.flag }} {{ country.label }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxPopupCustomPreview {
public countries: Country[] = [
{ code: 'af', value: 'afghanistan', label: 'Afghanistan', continent: 'Asia', flag: '🇦🇫' },
{ code: 'al', value: 'albania', label: 'Albania', continent: 'Europe', flag: '🇦🇱' },
{ code: 'dz', value: 'algeria', label: 'Algeria', continent: 'Africa', flag: '🇩🇿' },
{ code: 'ad', value: 'andorra', label: 'Andorra', continent: 'Europe', flag: '🇦🇩' },
{ code: 'ao', value: 'angola', label: 'Angola', continent: 'Africa', flag: '🇦🇴' },
{
code: 'ar',
value: 'argentina',
label: 'Argentina',
continent: 'South America',
flag: '🇦🇷',
},
{ code: 'am', value: 'armenia', label: 'Armenia', continent: 'Asia', flag: '🇦🇲' },
{ code: 'au', value: 'australia', label: 'Australia', continent: 'Oceania', flag: '🇦🇺' },
{ code: 'at', value: 'austria', label: 'Austria', continent: 'Europe', flag: '🇦🇹' },
{ code: 'az', value: 'azerbaijan', label: 'Azerbaijan', continent: 'Asia', flag: '🇦🇿' },
{
code: 'bs',
value: 'bahamas',
label: 'Bahamas',
continent: 'North America',
flag: '🇧🇸',
},
{ code: 'bh', value: 'bahrain', label: 'Bahrain', continent: 'Asia', flag: '🇧🇭' },
{ code: 'bd', value: 'bangladesh', label: 'Bangladesh', continent: 'Asia', flag: '🇧🇩' },
{
code: 'bb',
value: 'barbados',
label: 'Barbados',
continent: 'North America',
flag: '🇧🇧',
},
{ code: 'by', value: 'belarus', label: 'Belarus', continent: 'Europe', flag: '🇧🇾' },
{ code: 'be', value: 'belgium', label: 'Belgium', continent: 'Europe', flag: '🇧🇪' },
{ code: 'bz', value: 'belize', label: 'Belize', continent: 'North America', flag: '🇧🇿' },
{ code: 'bj', value: 'benin', label: 'Benin', continent: 'Africa', flag: '🇧🇯' },
{ code: 'bt', value: 'bhutan', label: 'Bhutan', continent: 'Asia', flag: '🇧🇹' },
{
code: 'bo',
value: 'bolivia',
label: 'Bolivia',
continent: 'South America',
flag: '🇧🇴',
},
{
code: 'ba',
value: 'bosnia-and-herzegovina',
label: 'Bosnia and Herzegovina',
continent: 'Europe',
flag: '🇧🇦',
},
{ code: 'bw', value: 'botswana', label: 'Botswana', continent: 'Africa', flag: '🇧🇼' },
{ code: 'br', value: 'brazil', label: 'Brazil', continent: 'South America', flag: '🇧🇷' },
{ code: 'bn', value: 'brunei', label: 'Brunei', continent: 'Asia', flag: '🇧🇳' },
{ code: 'bg', value: 'bulgaria', label: 'Bulgaria', continent: 'Europe', flag: '🇧🇬' },
{
code: 'bf',
value: 'burkina-faso',
label: 'Burkina Faso',
continent: 'Africa',
flag: '🇧🇫',
},
{ code: 'bi', value: 'burundi', label: 'Burundi', continent: 'Africa', flag: '🇧🇮' },
{ code: 'kh', value: 'cambodia', label: 'Cambodia', continent: 'Asia', flag: '🇰🇭' },
{ code: 'cm', value: 'cameroon', label: 'Cameroon', continent: 'Africa', flag: '🇨🇲' },
{ code: 'ca', value: 'canada', label: 'Canada', continent: 'North America', flag: '🇨🇦' },
{ code: 'cv', value: 'cape-verde', label: 'Cape Verde', continent: 'Africa', flag: '🇨🇻' },
{
code: 'cf',
value: 'central-african-republic',
label: 'Central African Republic',
continent: 'Africa',
flag: '🇨🇫',
},
{ code: 'td', value: 'chad', label: 'Chad', continent: 'Africa', flag: '🇹🇩' },
{ code: 'cl', value: 'chile', label: 'Chile', continent: 'South America', flag: '🇨🇱' },
{ code: 'cn', value: 'china', label: 'China', continent: 'Asia', flag: '🇨🇳' },
{
code: 'co',
value: 'colombia',
label: 'Colombia',
continent: 'South America',
flag: '🇨🇴',
},
{ code: 'km', value: 'comoros', label: 'Comoros', continent: 'Africa', flag: '🇰🇲' },
{ code: 'cg', value: 'congo', label: 'Congo', continent: 'Africa', flag: '🇨🇬' },
{
code: 'cr',
value: 'costa-rica',
label: 'Costa Rica',
continent: 'North America',
flag: '🇨🇷',
},
{ code: 'hr', value: 'croatia', label: 'Croatia', continent: 'Europe', flag: '🇭🇷' },
{ code: 'cu', value: 'cuba', label: 'Cuba', continent: 'North America', flag: '🇨🇺' },
{ code: 'cy', value: 'cyprus', label: 'Cyprus', continent: 'Asia', flag: '🇨🇾' },
{
code: 'cz',
value: 'czech-republic',
label: 'Czech Republic',
continent: 'Europe',
flag: '🇨🇿',
},
{ code: 'dk', value: 'denmark', label: 'Denmark', continent: 'Europe', flag: '🇩🇰' },
{ code: 'dj', value: 'djibouti', label: 'Djibouti', continent: 'Africa', flag: '🇩🇯' },
{
code: 'dm',
value: 'dominica',
label: 'Dominica',
continent: 'North America',
flag: '🇩🇲',
},
{
code: 'do',
value: 'dominican-republic',
label: 'Dominican Republic',
continent: 'North America',
flag: '🇩🇴',
},
];
}Async search (single)
import { ChangeDetectionStrategy, Component, computed, resource, signal } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmSpinnerImports } from '@spartan-ng/helm/spinner';
interface DirectoryUser {
id: string;
name: string;
username: string;
email: string;
title: string;
}
@Component({
selector: 'spartan-combobox-async-preview',
imports: [HlmComboboxImports, HlmSpinnerImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox [(search)]="search" [itemToString]="itemToString">
<hlm-combobox-input placeholder="Assign reviewer" showClear />
<hlm-combobox-content *hlmComboboxPortal>
@if (showStatus()) {
<hlm-combobox-status>
@if (users.error(); as error) {
{{ error }}
} @else if (users.isLoading()) {
<hlm-spinner />
Loading...
} @else if (search().length === 0) {
Type to search users.
} @else {
No matches for "{{ search() }}".
}
</hlm-combobox-status>
}
@if (!users.isLoading()) {
<hlm-combobox-empty>Try a different search term.</hlm-combobox-empty>
}
<div hlmComboboxList>
@if (users.hasValue()) {
@for (user of users.value(); track user.id) {
<hlm-combobox-item [value]="user">{{ user.name }}</hlm-combobox-item>
}
}
</div>
</hlm-combobox-content>
</hlm-combobox>
`,
})
export class ComboboxAsyncPreview {
public search = signal('');
public itemToString = (user: DirectoryUser) => user.name;
public users = resource({
defaultValue: [],
params: () => ({ search: this.search() }),
loader: async ({ params }) => {
const search = params.search;
if (search.length === 0) {
return [];
}
return await this.searchUsers(search, (item, query) => item.toLowerCase().includes(query.toLowerCase()));
},
});
public showStatus = computed(
() =>
this.users.error() ||
this.users.isLoading() ||
this.search().length === 0 ||
(this.users.hasValue() && this.users.value().length === 0),
);
async searchUsers(query: string, filter: (item: string, query: string) => boolean): Promise<DirectoryUser[]> {
// 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('Network error.');
}
return this._allUsers.filter((user) => {
return (
filter(user.name, query) ||
filter(user.username, query) ||
filter(user.email, query) ||
filter(user.title, query)
);
});
}
private readonly _allUsers: DirectoryUser[] = [
{
id: 'leslie-alexander',
name: 'Leslie Alexander',
username: 'leslie',
email: 'leslie.alexander@example.com',
title: 'Product Manager',
},
{
id: 'kathryn-murphy',
name: 'Kathryn Murphy',
username: 'kathryn',
email: 'kathryn.murphy@example.com',
title: 'Marketing Lead',
},
{
id: 'courtney-henry',
name: 'Courtney Henry',
username: 'courtney',
email: 'courtney.henry@example.com',
title: 'Design Systems',
},
{
id: 'michael-foster',
name: 'Michael Foster',
username: 'michael',
email: 'michael.foster@example.com',
title: 'Engineering Manager',
},
{
id: 'lindsay-walton',
name: 'Lindsay Walton',
username: 'lindsay',
email: 'lindsay.walton@example.com',
title: 'Product Designer',
},
{
id: 'tom-cook',
name: 'Tom Cook',
username: 'tom',
email: 'tom.cook@example.com',
title: 'Frontend Engineer',
},
{
id: 'whitney-francis',
name: 'Whitney Francis',
username: 'whitney',
email: 'whitney.francis@example.com',
title: 'Customer Success',
},
{
id: 'jacob-jones',
name: 'Jacob Jones',
username: 'jacob',
email: 'jacob.jones@example.com',
title: 'Security Engineer',
},
{
id: 'arlene-mccoy',
name: 'Arlene McCoy',
username: 'arlene',
email: 'arlene.mccoy@example.com',
title: 'Data Analyst',
},
{
id: 'marvin-mckinney',
name: 'Marvin McKinney',
username: 'marvin',
email: 'marvin.mckinney@example.com',
title: 'QA Specialist',
},
{
id: 'eleanor-pena',
name: 'Eleanor Pena',
username: 'eleanor',
email: 'eleanor.pena@example.com',
title: 'Operations',
},
{
id: 'jerome-bell',
name: 'Jerome Bell',
username: 'jerome',
email: 'jerome.bell@example.com',
title: 'DevOps Engineer',
},
];
}Async search (multiple)
import { ChangeDetectionStrategy, Component, computed, resource, signal } from '@angular/core';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmSpinnerImports } from '@spartan-ng/helm/spinner';
interface DirectoryUser {
id: string;
name: string;
username: string;
email: string;
title: string;
}
@Component({
selector: 'spartan-combobox-async-multiple-preview',
imports: [HlmComboboxImports, HlmSpinnerImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-combobox-multiple [(search)]="search" [itemToString]="itemToString">
<hlm-combobox-chips class="max-w-xs">
<ng-template hlmComboboxValues let-values>
@for (value of values; track $index) {
<hlm-combobox-chip [value]="value">{{ value.name }}</hlm-combobox-chip>
}
</ng-template>
<input hlmComboboxChipInput />
</hlm-combobox-chips>
<hlm-combobox-content *hlmComboboxPortal>
@if (showStatus()) {
<hlm-combobox-status>
@if (users.error(); as error) {
{{ error }}
} @else if (users.isLoading()) {
<hlm-spinner />
Loading...
} @else if (search().length === 0) {
Type to search users.
} @else {
No matches for "{{ search() }}".
}
</hlm-combobox-status>
}
@if (!users.isLoading()) {
<hlm-combobox-empty>Try a different search term.</hlm-combobox-empty>
}
<div hlmComboboxList>
@if (users.hasValue()) {
@for (user of users.value(); track user.id) {
<hlm-combobox-item [value]="user">{{ user.name }}</hlm-combobox-item>
}
}
</div>
</hlm-combobox-content>
</hlm-combobox-multiple>
`,
})
export class ComboboxAsyncMultiplePreview {
public search = signal('');
public itemToString = (user: DirectoryUser) => user.name;
public users = resource({
defaultValue: [],
params: () => ({ search: this.search() }),
loader: async ({ params }) => {
const search = params.search;
if (search.length === 0) {
return [];
}
return await this.searchUsers(search, (item, query) => item.toLowerCase().includes(query.toLowerCase()));
},
});
public showStatus = computed(
() =>
this.users.error() ||
this.users.isLoading() ||
this.search().length === 0 ||
(this.users.hasValue() && this.users.value().length === 0),
);
async searchUsers(query: string, filter: (item: string, query: string) => boolean): Promise<DirectoryUser[]> {
// 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('Network error.');
}
return this._allUsers.filter((user) => {
return (
filter(user.name, query) ||
filter(user.username, query) ||
filter(user.email, query) ||
filter(user.title, query)
);
});
}
private readonly _allUsers: DirectoryUser[] = [
{
id: 'leslie-alexander',
name: 'Leslie Alexander',
username: 'leslie',
email: 'leslie.alexander@example.com',
title: 'Product Manager',
},
{
id: 'kathryn-murphy',
name: 'Kathryn Murphy',
username: 'kathryn',
email: 'kathryn.murphy@example.com',
title: 'Marketing Lead',
},
{
id: 'courtney-henry',
name: 'Courtney Henry',
username: 'courtney',
email: 'courtney.henry@example.com',
title: 'Design Systems',
},
{
id: 'michael-foster',
name: 'Michael Foster',
username: 'michael',
email: 'michael.foster@example.com',
title: 'Engineering Manager',
},
{
id: 'lindsay-walton',
name: 'Lindsay Walton',
username: 'lindsay',
email: 'lindsay.walton@example.com',
title: 'Product Designer',
},
{
id: 'tom-cook',
name: 'Tom Cook',
username: 'tom',
email: 'tom.cook@example.com',
title: 'Frontend Engineer',
},
{
id: 'whitney-francis',
name: 'Whitney Francis',
username: 'whitney',
email: 'whitney.francis@example.com',
title: 'Customer Success',
},
{
id: 'jacob-jones',
name: 'Jacob Jones',
username: 'jacob',
email: 'jacob.jones@example.com',
title: 'Security Engineer',
},
{
id: 'arlene-mccoy',
name: 'Arlene McCoy',
username: 'arlene',
email: 'arlene.mccoy@example.com',
title: 'Data Analyst',
},
{
id: 'marvin-mckinney',
name: 'Marvin McKinney',
username: 'marvin',
email: 'marvin.mckinney@example.com',
title: 'QA Specialist',
},
{
id: 'eleanor-pena',
name: 'Eleanor Pena',
username: 'eleanor',
email: 'eleanor.pena@example.com',
title: 'Operations',
},
{
id: 'jerome-bell',
name: 'Jerome Bell',
username: 'jerome',
email: 'jerome.bell@example.com',
title: 'DevOps Engineer',
},
];
}Form (single)
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule } from '@angular/forms';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-combobox-form-preview',
imports: [HlmComboboxImports, ReactiveFormsModule, HlmButton, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<div hlmFieldGroup>
<div hlmField>
<label hlmFieldLabel>Select a framework</label>
<hlm-combobox formControlName="framework">
<hlm-combobox-input placeholder="e.g. Analog" />
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox>
</div>
<div hlmField orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</div>
</div>
</form>
`,
})
export class ComboboxFormPreview {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
framework: new FormControl<string | null>(null),
});
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
submit() {
console.log(this.form.value);
}
}Form (multiple)
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule } from '@angular/forms';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmComboboxImports } from '@spartan-ng/helm/combobox';
import { HlmFieldImports } from '@spartan-ng/helm/field';
@Component({
selector: 'spartan-combobox-form-multiple-preview',
imports: [HlmComboboxImports, ReactiveFormsModule, HlmButton, HlmFieldImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'w-full max-w-xs',
},
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<div hlmFieldGroup>
<div hlmField>
<label hlmFieldLabel>Select frameworks</label>
<hlm-combobox-multiple formControlName="framework">
<hlm-combobox-chips class="max-w-xs">
<ng-template hlmComboboxValues let-values>
@for (value of values; track $index) {
<hlm-combobox-chip [value]="value">{{ value }}</hlm-combobox-chip>
}
</ng-template>
<input hlmComboboxChipInput />
</hlm-combobox-chips>
<hlm-combobox-content *hlmComboboxPortal>
<hlm-combobox-empty>No items found.</hlm-combobox-empty>
<div hlmComboboxList>
@for (framework of frameworks; track $index) {
<hlm-combobox-item [value]="framework">{{ framework }}</hlm-combobox-item>
}
</div>
</hlm-combobox-content>
</hlm-combobox-multiple>
</div>
<div hlmField orientation="horizontal">
<button type="submit" hlmBtn [disabled]="form.invalid">Submit</button>
</div>
</div>
</form>
`,
})
export class ComboboxFormMultiplePreview {
private readonly _formBuilder = inject(FormBuilder);
public form = this._formBuilder.group({
framework: new FormControl<string[] | null>(['Analog']),
});
public frameworks = ['Analog', 'Angular', 'Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
submit() {
console.log(this.form.value);
}
}Brain API
BrnComboboxAnchor
Selector: [brnComboboxAnchor]
BrnComboboxChipInput
Selector: input[brnComboboxChipInput]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-combobox-input-${++BrnComboboxChipInput._id}` | The id of the combobox input |
BrnComboboxChipRemove
Selector: button[brnComboboxChipRemove]
BrnComboboxChip
Selector: [brnComboboxChip]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | T | - | - |
BrnComboboxClear
Selector: [brnComboboxClear]
BrnComboboxContent
Selector: [brnComboboxContent]
BrnComboboxEmpty
Selector: [brnComboboxEmpty]
BrnComboboxGroup
Selector: [brnComboboxGroup]
BrnComboboxInputWrapper
Selector: [brnComboboxInputWrapper]
BrnComboboxInput
Selector: input[brnComboboxInput]
ExportAs: brnComboboxInput
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-combobox-input-${++BrnComboboxInput._id}` | The id of the combobox input |
BrnComboboxItem
Selector: [brnComboboxItem]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-combobox-item-${++BrnComboboxItem._id}` | A unique id for the item |
| value* (required) | T | - | The value this item represents. |
| disabled | boolean | false | - |
BrnComboboxLabel
Selector: [brnComboboxLabel]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-combobox-label-${++BrnComboboxLabel._id}` | The id of the combobox label |
BrnComboboxList
Selector: [brnComboboxList]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | `brn-combobox-list-${++BrnComboboxList._id}` | The id of the combobox list |
BrnComboboxMultiple
Selector: [brnCombobox]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | Whether the combobox is disabled |
| filterOptions | ComboboxFilterOptions | {} | Options for filtering the combobox items |
| isItemEqualToValue | ComboboxItemEqualToValue<T> | this._config.isItemEqualToValue | A function to compare an item with the selected value. |
| itemToString | ComboboxItemToString<T> | undefined | this._config.itemToString | A function to convert an item to a string for display. |
| filter | ComboboxFilter<T> | this._config.filter | A custom filter function to use when searching. |
| autoHighlight | boolean | this._config.autoHighlight | Whether to auto-highlight the first matching item. |
| value | T[] | null | null | The selected values of the combobox. |
| search | string | - | The current search query. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| valueChange | T[] | null | null | The selected values of the combobox. |
| searchChange | string | - | The current search query. |
BrnComboboxPlaceholder
Selector: [brnComboboxPlaceholder]
BrnComboboxPopoverTrigger
Selector: [brnComboboxPopoverTrigger]
BrnComboboxSeparator
Selector: [brnComboboxSeparator]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | 'horizontal' | 'vertical' | horizontal | - |
BrnComboboxStatus
Selector: [brnComboboxStatus]
BrnComboboxTrigger
Selector: button[brnComboboxTrigger]
BrnComboboxValueTemplate
Selector: [brnComboboxValueTemplate]
BrnComboboxValue
Selector: [brnComboboxValue]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | - | - |
BrnComboboxValues
Selector: [brnComboboxValues]
BrnCombobox
Selector: [brnCombobox]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | Whether the combobox is disabled |
| filterOptions | ComboboxFilterOptions | {} | Options for filtering the combobox items |
| isItemEqualToValue | ComboboxItemEqualToValue<T> | this._config.isItemEqualToValue | A function to compare an item with the selected value. |
| itemToString | ComboboxItemToString<T> | undefined | this._config.itemToString | A function to convert an item to a string for display. |
| filter | ComboboxFilter<T> | this._config.filter | A custom filter function to use when searching. |
| autoHighlight | boolean | this._config.autoHighlight | Whether to auto-highlight the first matching item. |
| value | T | null | null | The selected value of the combobox. |
| search | string | - | The current search query. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| valueChange | T | null | null | The selected value of the combobox. |
| searchChange | string | - | The current search query. |
Helm API
HlmComboboxChipInput
Selector: input[hlmComboboxChipInput]
HlmComboboxChipRemove
Selector: button[hlmComboboxChipRemove]
HlmComboboxChip
Selector: hlm-combobox-chip
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| showRemove | boolean | true | - |
HlmComboboxChips
Selector: [hlmComboboxChips],hlm-combobox-chips
HlmComboboxContent
Selector: [hlmComboboxContent],hlm-combobox-content
HlmComboboxEmpty
Selector: [hlmComboboxEmpty],hlm-combobox-empty
HlmComboboxGroup
Selector: [hlmComboboxGroup]
HlmComboboxInput
Selector: hlm-combobox-input
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | - | - |
| showTrigger | boolean | true | - |
| showClear | boolean | false | - |
| aria-invalid | boolean | false | - |
HlmComboboxItem
Selector: hlm-combobox-item
HlmComboboxLabel
Selector: [hlmComboboxLabel]
HlmComboboxList
Selector: [hlmComboboxList]
HlmComboboxMultiple
Selector: [hlmComboboxMultiple],hlm-combobox-multiple
HlmComboboxPlaceholder
Selector: [hlmComboboxPlaceholder],hlm-combobox-placeholder
HlmComboboxPortal
Selector: [hlmComboboxPortal]
HlmComboboxSeparator
Selector: [hlmComboboxSeparator]
HlmComboboxStatus
Selector: [hlmComboboxStatus],hlm-combobox-status
HlmComboboxTrigger
Selector: hlm-combobox-trigger
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| class | ClassValue | - | - |
| buttonId | string | `hlm-combobox-trigger-${HlmComboboxTrigger._id++}` | - |
| variant | ButtonVariants['variant'] | outline | - |
HlmComboboxValueTemplate
Selector: [hlmComboboxValueTemplate]
HlmComboboxValue
Selector: [hlmComboboxValue],hlm-combobox-value
HlmComboboxValues
Selector: [hlmComboboxValues]
HlmCombobox
Selector: [hlmCombobox],hlm-combobox
On This Page