- 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
Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
We offer standard (5-7 days), express (2-3 days), and overnight shipping. Free shipping on international orders.
Returns accepted within 30 days. Items must be unused and in original packaging. Refunds processed within 5-7 business days.
Reach us via email, live chat, or phone. We respond within 24 hours during business days.
import { Component } from '@angular/core';
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';
@Component({
selector: 'spartan-accordion-preview',
imports: [HlmAccordionImports],
host: {
class: 'max-w-sm block h-[250px]',
},
template: `
<hlm-accordion>
<hlm-accordion-item>
<hlm-accordion-trigger>What are your shipping options?</hlm-accordion-trigger>
<hlm-accordion-content>
<p>
We offer standard (5-7 days), express (2-3 days), and overnight shipping. Free shipping on international
orders.
</p>
</hlm-accordion-content>
</hlm-accordion-item>
<hlm-accordion-item>
<hlm-accordion-trigger>What is your return policy?</hlm-accordion-trigger>
<hlm-accordion-content>
<p>
Returns accepted within 30 days. Items must be unused and in original packaging. Refunds processed within
5-7 business days.
</p>
</hlm-accordion-content>
</hlm-accordion-item>
<hlm-accordion-item>
<hlm-accordion-trigger>Return Policy</hlm-accordion-trigger>
<hlm-accordion-content>
<p>Reach us via email, live chat, or phone. We respond within 24 hours during business days.</p>
</hlm-accordion-content>
</hlm-accordion-item>
</hlm-accordion>
`,
})
export class AccordionPreview {}Installation
ng g @spartan-ng/cli:ui accordionnx g @spartan-ng/cli:ui accordionimport { 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 { BrnAccordion, BrnAccordionContent, BrnAccordionItem, BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
import { ChangeDetectionStrategy, Component, Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'hlm-accordion-content',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
template: `
<div class="flex flex-col gap-4 pt-0 pb-4 text-balance">
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
);
}
}
@Directive({
selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
})
export class HlmAccordionIcon {
constructor() {
classes(
() =>
'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
hostDirectives: [
{
directive: BrnAccordionItem,
inputs: ['isOpened'],
outputs: ['openedChange'],
},
],
})
export class HlmAccordionItem {
constructor() {
classes(() => 'border-border flex flex-1 flex-col border-b');
}
}
@Directive({
selector: '[hlmAccordionTrigger]',
hostDirectives: [BrnAccordionTrigger],
host: {
'[style.--tw-ring-offset-shadow]': '"0 0 #000"',
},
})
export class HlmAccordionTrigger {
constructor() {
classes(
() =>
'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordion], hlm-accordion',
hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
})
export class HlmAccordion {
constructor() {
classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
}
}
export const HlmAccordionImports = [
HlmAccordion,
HlmAccordionItem,
HlmAccordionTrigger,
HlmAccordionIcon,
HlmAccordionContent,
] as const;import { BrnAccordion, BrnAccordionContent, BrnAccordionItem, BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
import { ChangeDetectionStrategy, Component, Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'hlm-accordion-content',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
template: `
<div class="flex flex-col gap-4 pt-0 pb-4 text-balance">
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
);
}
}
@Directive({
selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
})
export class HlmAccordionIcon {
constructor() {
classes(
() =>
'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
hostDirectives: [
{
directive: BrnAccordionItem,
inputs: ['isOpened'],
outputs: ['openedChange'],
},
],
})
export class HlmAccordionItem {
constructor() {
classes(() => 'border-border flex flex-1 flex-col border-b');
}
}
@Directive({
selector: '[hlmAccordionTrigger]',
hostDirectives: [BrnAccordionTrigger],
host: {
'[style.--tw-ring-offset-shadow]': '"0 0 #000"',
},
})
export class HlmAccordionTrigger {
constructor() {
classes(
() =>
'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordion], hlm-accordion',
hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
})
export class HlmAccordion {
constructor() {
classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
}
}
export const HlmAccordionImports = [
HlmAccordion,
HlmAccordionItem,
HlmAccordionTrigger,
HlmAccordionIcon,
HlmAccordionContent,
] as const;import { BrnAccordion, BrnAccordionContent, BrnAccordionItem, BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
import { ChangeDetectionStrategy, Component, Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'hlm-accordion-content',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
template: `
<div class="flex flex-col gap-4 pt-0 pb-4 text-balance">
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
);
}
}
@Directive({
selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
})
export class HlmAccordionIcon {
constructor() {
classes(
() =>
'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
hostDirectives: [
{
directive: BrnAccordionItem,
inputs: ['isOpened'],
outputs: ['openedChange'],
},
],
})
export class HlmAccordionItem {
constructor() {
classes(() => 'border-border flex flex-1 flex-col border-b');
}
}
@Directive({
selector: '[hlmAccordionTrigger]',
hostDirectives: [BrnAccordionTrigger],
host: {
'[style.--tw-ring-offset-shadow]': '"0 0 #000"',
},
})
export class HlmAccordionTrigger {
constructor() {
classes(
() =>
'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordion], hlm-accordion',
hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
})
export class HlmAccordion {
constructor() {
classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
}
}
export const HlmAccordionImports = [
HlmAccordion,
HlmAccordionItem,
HlmAccordionTrigger,
HlmAccordionIcon,
HlmAccordionContent,
] as const;import { BrnAccordion, BrnAccordionContent, BrnAccordionItem, BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
import { ChangeDetectionStrategy, Component, Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'hlm-accordion-content',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
template: `
<div class="flex flex-col gap-4 pt-0 pb-4 text-balance">
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
);
}
}
@Directive({
selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
})
export class HlmAccordionIcon {
constructor() {
classes(
() =>
'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
hostDirectives: [
{
directive: BrnAccordionItem,
inputs: ['isOpened'],
outputs: ['openedChange'],
},
],
})
export class HlmAccordionItem {
constructor() {
classes(() => 'border-border flex flex-1 flex-col border-b');
}
}
@Directive({
selector: '[hlmAccordionTrigger]',
hostDirectives: [BrnAccordionTrigger],
host: {
'[style.--tw-ring-offset-shadow]': '"0 0 #000"',
},
})
export class HlmAccordionTrigger {
constructor() {
classes(
() =>
'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordion], hlm-accordion',
hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
})
export class HlmAccordion {
constructor() {
classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
}
}
export const HlmAccordionImports = [
HlmAccordion,
HlmAccordionItem,
HlmAccordionTrigger,
HlmAccordionIcon,
HlmAccordionContent,
] as const;import { BrnAccordion, BrnAccordionContent, BrnAccordionItem, BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
import { ChangeDetectionStrategy, Component, Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'hlm-accordion-content',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
template: `
<div class="flex flex-col gap-4 pt-0 pb-4 text-balance">
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
);
}
}
@Directive({
selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
})
export class HlmAccordionIcon {
constructor() {
classes(
() =>
'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
hostDirectives: [
{
directive: BrnAccordionItem,
inputs: ['isOpened'],
outputs: ['openedChange'],
},
],
})
export class HlmAccordionItem {
constructor() {
classes(() => 'border-border flex flex-1 flex-col border-b');
}
}
@Directive({
selector: '[hlmAccordionTrigger]',
hostDirectives: [BrnAccordionTrigger],
host: {
'[style.--tw-ring-offset-shadow]': '"0 0 #000"',
},
})
export class HlmAccordionTrigger {
constructor() {
classes(
() =>
'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordion], hlm-accordion',
hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
})
export class HlmAccordion {
constructor() {
classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
}
}
export const HlmAccordionImports = [
HlmAccordion,
HlmAccordionItem,
HlmAccordionTrigger,
HlmAccordionIcon,
HlmAccordionContent,
] as const;import { BrnAccordion, BrnAccordionContent, BrnAccordionItem, BrnAccordionTrigger } from '@spartan-ng/brain/accordion';
import { ChangeDetectionStrategy, Component, Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { lucideChevronDown } from '@ng-icons/lucide';
import { provideHlmIconConfig } from '@spartan-ng/helm/icon';
import { provideIcons } from '@ng-icons/core';
@Component({
selector: 'hlm-accordion-content',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnAccordionContent, inputs: ['style'] }],
template: `
<div class="flex flex-col gap-4 pt-0 pb-4 text-balance">
<ng-content />
</div>
`,
})
export class HlmAccordionContent {
constructor() {
classes(
() => 'text-sm transition-all data-[state=closed]:h-0 data-[state=open]:h-[var(--brn-accordion-content-height)]',
);
}
}
@Directive({
selector: 'ng-icon[hlmAccordionIcon], ng-icon[hlmAccIcon]',
providers: [provideIcons({ lucideChevronDown }), provideHlmIconConfig({ size: 'sm' })],
})
export class HlmAccordionIcon {
constructor() {
classes(
() =>
'text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordionItem],brn-accordion-item[hlm],hlm-accordion-item',
hostDirectives: [
{
directive: BrnAccordionItem,
inputs: ['isOpened'],
outputs: ['openedChange'],
},
],
})
export class HlmAccordionItem {
constructor() {
classes(() => 'border-border flex flex-1 flex-col border-b');
}
}
@Directive({
selector: '[hlmAccordionTrigger]',
hostDirectives: [BrnAccordionTrigger],
host: {
'[style.--tw-ring-offset-shadow]': '"0 0 #000"',
},
})
export class HlmAccordionTrigger {
constructor() {
classes(
() =>
'group focus-visible:ring-ring focus-visible:ring-offset-background flex w-full items-center justify-between py-4 text-left font-medium transition-all hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none [&[data-state=open]>ng-icon]:rotate-180',
);
}
}
@Directive({
selector: '[hlmAccordion], hlm-accordion',
hostDirectives: [{ directive: BrnAccordion, inputs: ['type', 'dir', 'orientation'] }],
})
export class HlmAccordion {
constructor() {
classes(() => 'flex data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col');
}
}
export const HlmAccordionImports = [
HlmAccordion,
HlmAccordionItem,
HlmAccordionTrigger,
HlmAccordionIcon,
HlmAccordionContent,
] as const;Usage
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';<hlm-accordion>
<hlm-accordion-item>
<hlm-accordion-trigger>Is it accessible?</hlm-accordion-trigger>
<hlm-accordion-content> Yes. It adheres to the WAI-ARIA design pattern. </hlm-accordion-content>
</hlm-accordion-item>
</hlm-accordion>Examples
Multiple
The type input can be set to 'multiple' to allow multiple items to be opened at the same time.
The isOpened input can be used to set the initial state of an accordion item.
import { Component } from '@angular/core';
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';
@Component({
selector: 'spartan-accordion-multiple',
imports: [HlmAccordionImports],
host: {
class: 'max-w-sm h-[380px] flex flex-col justify-between',
},
template: `
<hlm-accordion type="multiple">
@for (item of items; track item.value; let i = $index) {
<hlm-accordion-item [isOpened]="i === 0">
<hlm-accordion-trigger>{{ item.trigger }}</hlm-accordion-trigger>
<hlm-accordion-content>{{ item.content }}</hlm-accordion-content>
</hlm-accordion-item>
}
</hlm-accordion>
`,
})
export class AccordionMultiple {
public items = [
{
value: 'notifications',
trigger: 'Notification Settings',
content:
'Manage how you receive notifications. You can enable email alerts for updates or push notifications for mobile devices.',
},
{
value: 'privacy',
trigger: 'Privacy & Security',
content:
'Control your privacy settings and security preferences. Enable two-factor authentication, manage connected devices, review active sessions, and configure data sharing preferences. You can also download your data or delete your account.',
},
{
value: 'billing',
trigger: 'Billing & Subscription',
content:
'View your current plan, payment history, and upcoming invoices. Update your payment method, change your subscription tier, or cancel your subscription.',
},
];
}Disabled
Use the disabled prop on the hlm-accordion-item to disable an item.
import { Component } from '@angular/core';
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';
@Component({
selector: 'spartan-accordion-disabled',
imports: [HlmAccordionImports],
host: {
class: 'max-w-sm h-[250px]',
},
template: `
<hlm-accordion>
<hlm-accordion-item>
<hlm-accordion-trigger>Can I access my account history?</hlm-accordion-trigger>
<hlm-accordion-content>
Yes, you can view your complete account history including all transactions, plan changes, and support tickets
in the Account History section of your dashboard.
</hlm-accordion-content>
</hlm-accordion-item>
<hlm-accordion-item disabled>
<hlm-accordion-trigger>Premium feature information</hlm-accordion-trigger>
<hlm-accordion-content>
This section contains information about premium features. Upgrade your plan to access this content.
</hlm-accordion-content>
</hlm-accordion-item>
<hlm-accordion-item>
<hlm-accordion-trigger>How do I update my email address?</hlm-accordion-trigger>
<hlm-accordion-content>
You can update your email address in your account settings. You'll receive a verification email at your new
address to confirm the change.
</hlm-accordion-content>
</hlm-accordion-item>
</hlm-accordion>
`,
})
export class AccordionDisabled {}Borders
Add border to the hlm-accordion and border-b last:border-b-0 to the hlm-accordion-item to add borders to the items.
import { Component } from '@angular/core';
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';
@Component({
selector: 'spartan-accordion-borders',
imports: [HlmAccordionImports],
host: {
class: 'max-w-lg h-[320px] flex flex-col justify-between',
},
template: `
<hlm-accordion class="rounded-lg border">
@for (item of items; track $index) {
<hlm-accordion-item class="border-b px-4 last:border-b-0">
<hlm-accordion-trigger>
{{ item.trigger }}
</hlm-accordion-trigger>
<hlm-accordion-content>{{ item.content }}</hlm-accordion-content>
</hlm-accordion-item>
}
</hlm-accordion>
`,
})
export class AccordionBorders {
public items = [
{
value: 'billing',
trigger: 'How does billing work?',
content:
'We offer monthly and annual subscription plans. Billing is charged at the beginning of each cycle, and you can cancel anytime. All plans include automatic backups, 24/7 support, and unlimited team members.',
},
{
value: 'security',
trigger: 'Is my data secure?',
content:
'Yes. We use end-to-end encryption, SOC 2 Type II compliance, and regular third-party security audits. All data is encrypted at rest and in transit using industry-standard protocols.',
},
{
value: 'integration',
trigger: 'What integrations do you support?',
content:
'We integrate with 500+ popular tools including Slack, Zapier, Salesforce, HubSpot, and more. You can also build custom integrations using our REST API and webhooks.',
},
];
}Card
Wrap the hlm-accordion inside a hlm-card component.
Subscription & Billing
Common questions about your account, plans, payments and cancellations.
import { Component } from '@angular/core';
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';
import { HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-accordion-card',
imports: [HlmAccordionImports, HlmCardImports],
host: {
class: 'max-w-lg h-[32rem] md:h-[28rem] flex flex-col justify-between',
},
template: `
<hlm-card class="w-full max-w-sm">
<hlm-card-header>
<h3 hlmCardTitle>Subscription & Billing</h3>
<p hlmCardDescription>Common questions about your account, plans, payments and cancellations.</p>
</hlm-card-header>
<div hlmCardContent>
<hlm-accordion>
@for (item of items; track $index) {
<hlm-accordion-item>
<hlm-accordion-trigger>
{{ item.trigger }}
</hlm-accordion-trigger>
<hlm-accordion-content>
{{ item.content }}
</hlm-accordion-content>
</hlm-accordion-item>
}
</hlm-accordion>
</div>
</hlm-card>
`,
})
export class AccordionCard {
public items = [
{
value: 'plans',
trigger: 'What subscription plans do you offer?',
content:
'We offer three subscription tiers: Starter ($9/month), Professional ($29/month), and Enterprise ($99/month). Each plan includes increasing storage limits, API access, priority support, and team collaboration features.',
},
{
value: 'billing',
trigger: 'How does billing work?',
content:
"Billing occurs automatically at the start of each billing cycle. We accept all major credit cards, PayPal, and ACH transfers for enterprise customers. You'll receive an invoice via email after each payment.",
},
{
value: 'cancel',
trigger: 'How do I cancel my subscription?',
content:
'You can cancel your subscription anytime from your account settings. There are no cancellation fees or penalties. Your access will continue until the end of your current billing period.',
},
];
}RTL
To enable RTL support in spartan-ng, see the RTL configuration guide.
import { Directionality } from '@angular/cdk/bidi';
import { Component, computed, effect, inject, untracked } from '@angular/core';
import { TranslateService, Translations } from '@spartan-ng/app/app/shared/translate.service';
import { HlmAccordionImports } from '@spartan-ng/helm/accordion';
@Component({
selector: 'spartan-accordion-rtl',
imports: [HlmAccordionImports],
providers: [Directionality],
host: {
class: 'max-w-sm h-[250px]',
'[dir]': '_dir()',
},
template: `
<hlm-accordion>
@for (item of _items; track item.value) {
<hlm-accordion-item>
<hlm-accordion-trigger>{{ _t()[item.questionKey] }}</hlm-accordion-trigger>
<hlm-accordion-content>
{{ _t()[item.answerKey] }}
</hlm-accordion-content>
</hlm-accordion-item>
}
</hlm-accordion>
`,
})
export class AccordionRtl {
private readonly _directionality = inject(Directionality);
private readonly _language = inject(TranslateService).language;
private readonly _translations: Translations = {
en: {
dir: 'ltr',
values: {
question1: 'How do I reset my password?',
answer1:
"Click on 'Forgot Password' on the login page, enter your email address, and we'll send you a link to reset your password. ",
question2: 'Can I change my subscription plan?',
answer2:
'Yes, you can upgrade or downgrade your plan at any time from your account settings. Changes will be reflected in your next billing cycle.',
question3: 'What payment methods do you accept?',
answer3:
'We accept all major credit cards, PayPal, and bank transfers. All payments are processed securely through our payment partners.',
},
},
ar: {
dir: 'rtl',
values: {
question1: 'كيف يمكنني إعادة تعيين كلمة المرور؟',
answer1:
"انقر على 'نسيت كلمة المرور' في صفحة تسجيل الدخول، أدخل عنوان بريدك الإلكتروني، وسنرسل لك رابطًا لإعادة تعيين كلمة المرور. سينتهي صلاحية الرابط خلال 24 ساعة.",
question2: 'هل يمكنني تغيير خطة الاشتراك الخاصة بي؟',
answer2: 'نعم، يمكنك ترقية أو تخفيض خطتك في أي وقت من إعدادات حسابك. ستظهر التغييرات في دورة الفوترة التالية.',
question3: 'ما هي طرق الدفع التي تقبلونها؟',
answer3:
'نقبل جميع بطاقات الائتمان الرئيسية و PayPal والتحويلات المصرفية. تتم معالجة جميع المدفوعات بأمان من خلال شركاء الدفع لدينا.',
},
},
he: {
dir: 'rtl',
values: {
question1: 'איך אני מאפס את הסיסמה שלי?',
answer1:
"לחץ על 'שכחתי סיסמה' בעמוד ההתחברות, הזן את כתובת האימייל שלך, ונשלח לך קישור לאיפוס הסיסמה. הקישור יפוג תוך 24 שעות.",
question2: 'האם אני יכול לשנות את תוכנית המנוי שלי?',
answer2:
'כן, אתה יכול לשדרג או להוריד את התוכנית שלך בכל עת מההגדרות של החשבון שלך. השינויים יבואו לידי ביטוי במחזור החיוב הבא.',
question3: 'אילו אמצעי תשלום אתם מקבלים?',
answer3: 'אנו מקבלים כרטיסי אשראי, PayPal והעברות בנקאיות.',
},
},
};
protected readonly _items = [
{
value: 'item-1',
questionKey: 'question1' as const,
answerKey: 'answer1' as const,
},
{
value: 'item-2',
questionKey: 'question2' as const,
answerKey: 'answer2' as const,
},
{
value: 'item-3',
questionKey: 'question3' as const,
answerKey: 'answer3' as const,
},
];
private readonly _translation = computed(() => this._translations[this._language()]);
protected readonly _t = computed(() => this._translation().values);
protected readonly _dir = computed(() => this._translation().dir);
constructor() {
effect(() => {
const dir = this._dir();
untracked(() => this._directionality.valueSignal.set(dir));
});
}
}Brain API
BrnAccordionContent
Selector: brn-accordion-content,[brnAccordionContent]
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| style | string | overflow: hidden | The style to be applied to the host element after the dimensions are calculated. |
BrnAccordionHeader
Selector: [brnAccordionHeader]
BrnAccordionItem
Selector: [brnAccordionItem]
ExportAs: brnAccordionItem
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| isOpened | boolean | false | Whether the item is opened or closed. |
| disabled | boolean | false | Whether the item is disabled. |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| stateChange | 'open' | 'closed' | - | Emits boolean when the item is opened or closed. |
| openedChange | boolean | - | Emits state change when item is opened or closed |
BrnAccordionTrigger
Selector: button[brnAccordionTrigger]
BrnAccordion
Selector: [brnAccordion]
ExportAs: brnAccordion
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| type | 'single' | 'multiple' | single | Whether the accordion is in single or multiple mode. |
| orientation | 'horizontal' | 'vertical' | vertical | The orientation of the accordion, either 'horizontal' or 'vertical'. |
Helm API
HlmAccordionContent
Selector: hlm-accordion-content
HlmAccordionItem
Selector: [hlmAccordionItem],hlm-accordion-item
HlmAccordionTrigger
Selector: hlm-accordion-trigger
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| triggerClass | ClassValue | - | - |
HlmAccordion
Selector: [hlmAccordion], hlm-accordion
On This Page