ng-hub-ui-accordion
Version:
A flexible, accessible, and customizable accordion component for Angular 19, part of the ng-hub-ui family.
766 lines (758 loc) • 46.1 kB
JavaScript
import { NgTemplateOutlet } from '@angular/common';
import * as i0 from '@angular/core';
import { Directive, inject, ElementRef, input, model, contentChild, TemplateRef, output, HostListener, Component, contentChildren, effect, forwardRef, HostBinding, signal, Injectable } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject } from 'rxjs';
class AccordionPanelHeaderDirective {
templateRef;
constructor(templateRef) {
this.templateRef = templateRef;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionPanelHeaderDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.2", type: AccordionPanelHeaderDirective, isStandalone: true, selector: "[hubAccordionPanelHeader]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionPanelHeaderDirective, decorators: [{
type: Directive,
args: [{
selector: '[hubAccordionPanelHeader]'
}]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
/**
* A component that represents a single panel within an accordion.
* Each panel can be expanded or collapsed independently and contains a header and content section.
*
* @example
* ```html
* <hub-accordion-panel [title]="'Panel Title'" [value]="panelValue">
* <ng-template hubAccordionPanelHeader>Custom Header</ng-template>
* Panel Content
* </hub-accordion-panel>
* ```
*
* @implements {OnInit}
*
* @property {number} index - The position of this panel within its parent accordion
* @property {any} value - The value associated with this panel
* @property {WritableSignal<boolean>} collapsed - Signal controlling the panel's collapsed state
* @property {string} title - The title text displayed in the panel's header
* @property {TemplateRef<any>} headerTpt - Template reference for custom header content
*
* @emits {collapseEvent} collapsedChange - Fired when the panel's collapsed state changes
*/
class AccordionPanelComponent {
/**
* The position of this panel within its parent accordion.
*
* @type {number}
*/
index;
/**
* ElementRef for accessing the native element.
* @private
*/
elementRef = inject(ElementRef);
value = input(...(ngDevMode ? [undefined, { debugName: "value" }] : []));
/**
* Signal that controls the collapsed state of the accordion panel.
* When true, the panel is collapsed. When false, the panel is expanded.
* @default true
*/
collapsed = model(true, ...(ngDevMode ? [{ debugName: "collapsed" }] : []));
/**
* The title of the accordion panel.
*
* @type {string}
*/
title = input(...(ngDevMode ? [undefined, { debugName: "title" }] : []));
/**
* Reference to the header template of the accordion panel.
* Uses contentChild query to find a template marked with AccordionPanelHeaderDirective.
* The template reference is read using TemplateRef.
*/
headerTpt = contentChild(AccordionPanelHeaderDirective, { ...(ngDevMode ? { debugName: "headerTpt" } : {}), read: TemplateRef });
/**
* Event emitter that fires when the panel's collapsed state changes.
* Emits a collapseEvent object containing the collapse state and animation details.
* @event
*/
collapsedChange = output();
/**
* Toggles the collapse state of the accordion panel and emits the change event.
* The event includes the panel's index and its new collapsed state.
*
* @emits collapsedChange - Emits an object containing the panel index and collapsed state
*/
toggleCollapse() {
this.collapsed.update((value) => !value);
this.collapsedChange.emit({
index: this.index,
collapsed: this.collapsed(),
uncollapsed: !this.collapsed(),
value: this.value()
});
}
/**
* Handles keyboard navigation for the accordion panel.
* Supports Arrow keys, Home, End, Space, and Enter.
*
* @param event - The keyboard event
*/
onKeyDown(event) {
// Only handle keyboard events on the button element
const target = event.target;
if (!target.classList.contains('hub-accordion-button')) {
return;
}
switch (event.key) {
case 'ArrowDown':
this.focusNextPanel();
event.preventDefault();
break;
case 'ArrowUp':
this.focusPreviousPanel();
event.preventDefault();
break;
case 'Home':
this.focusFirstPanel();
event.preventDefault();
break;
case 'End':
this.focusLastPanel();
event.preventDefault();
break;
case ' ':
case 'Enter':
this.toggleCollapse();
event.preventDefault();
break;
}
}
/**
* Focuses the next accordion panel button.
* @private
*/
focusNextPanel() {
const allPanels = this.getAllPanelButtons();
const currentIndex = this.getCurrentPanelIndex(allPanels);
const nextIndex = (currentIndex + 1) % allPanels.length;
allPanels[nextIndex]?.focus();
}
/**
* Focuses the previous accordion panel button.
* @private
*/
focusPreviousPanel() {
const allPanels = this.getAllPanelButtons();
const currentIndex = this.getCurrentPanelIndex(allPanels);
const previousIndex = currentIndex === 0 ? allPanels.length - 1 : currentIndex - 1;
allPanels[previousIndex]?.focus();
}
/**
* Focuses the first accordion panel button.
* @private
*/
focusFirstPanel() {
const allPanels = this.getAllPanelButtons();
allPanels[0]?.focus();
}
/**
* Focuses the last accordion panel button.
* @private
*/
focusLastPanel() {
const allPanels = this.getAllPanelButtons();
allPanels[allPanels.length - 1]?.focus();
}
/**
* Gets all accordion panel buttons within the parent accordion.
* @private
* @returns Array of button elements
*/
getAllPanelButtons() {
const accordionElement = this.elementRef.nativeElement.closest('.hub-accordion');
if (!accordionElement) {
return [];
}
return Array.from(accordionElement.querySelectorAll('.hub-accordion-button'));
}
/**
* Gets the current panel's index within all panel buttons.
* @private
* @param allPanels - Array of all panel buttons
* @returns Current panel index or 0 if not found
*/
getCurrentPanelIndex(allPanels) {
const currentButton = this.elementRef.nativeElement.querySelector('.hub-accordion-button');
return Math.max(0, allPanels.indexOf(currentButton));
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.2", type: AccordionPanelComponent, isStandalone: true, selector: "hub-accordion-panel", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, collapsed: { classPropertyName: "collapsed", publicName: "collapsed", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { collapsed: "collapsedChange", collapsedChange: "collapsedChange" }, host: { listeners: { "keydown": "onKeyDown($event)" }, classAttribute: "hub-accordion-panel" }, queries: [{ propertyName: "headerTpt", first: true, predicate: AccordionPanelHeaderDirective, descendants: true, read: TemplateRef, isSignal: true }], ngImport: i0, template: "<h2 class=\"hub-accordion-header\">\n\t<button\n\t\tclass=\"hub-accordion-button\"\n\t\ttype=\"button\"\n\t\t[class.collapsed]=\"collapsed()\"\n\t\t[attr.data-bs-target]=\"'#collapse' + index\"\n\t\t[attr.aria-expanded]=\"!collapsed()\"\n\t\t[attr.aria-controls]=\"'collapse' + index\"\n\t\t(click)=\"toggleCollapse()\"\n\t>\n\t\t@if (headerTpt()) {\n\t\t\t<ng-container [ngTemplateOutlet]=\"$any(headerTpt())\"></ng-container>\n\t\t} @else if (title()) {\n\t\t\t{{ title() }}\n\t\t}\n\t</button>\n</h2>\n<div\n\t[attr.id]=\"'collapse' + index\"\n\tclass=\"hub-accordion-collapse\"\n\t[class.hub-accordion-collapse--collapsed]=\"collapsed()\"\n\t[attr.aria-hidden]=\"collapsed()\"\n\t[attr.inert]=\"collapsed() ? '' : null\"\n>\n\t<div class=\"hub-accordion-body\">\n\t\t<ng-content></ng-content>\n\t</div>\n</div>\n", styles: [":host{display:block;color:var(--hub-accordion-color);background-color:var(--hub-accordion-bg);border:var(--hub-accordion-border-width) solid var(--hub-accordion-border-color)}:host:first-of-type{border-top-left-radius:var(--hub-accordion-border-radius);border-top-right-radius:var(--hub-accordion-border-radius)}:host:first-of-type>.hub-accordion-header .hub-accordion-button{border-top-left-radius:var(--hub-accordion-inner-border-radius);border-top-right-radius:var(--hub-accordion-inner-border-radius)}:host:not(:first-of-type){border-top:0}:host:last-of-type{border-bottom-left-radius:var(--hub-accordion-border-radius);border-bottom-right-radius:var(--hub-accordion-border-radius)}:host:last-of-type>.hub-accordion-header .hub-accordion-button.collapsed{border-bottom-left-radius:var(--hub-accordion-inner-border-radius);border-bottom-right-radius:var(--hub-accordion-inner-border-radius)}:host:last-of-type>.hub-accordion-collapse{border-bottom-left-radius:var(--hub-accordion-border-radius);border-bottom-right-radius:var(--hub-accordion-border-radius)}.hub-accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--hub-accordion-btn-padding-y) var(--hub-accordion-btn-padding-x);font-size:1rem;color:var(--hub-accordion-btn-color);text-align:left;background-color:var(--hub-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--hub-accordion-transition);cursor:pointer}.hub-accordion-button:not(.collapsed){color:var(--hub-accordion-active-color);background-color:var(--hub-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--hub-accordion-border-width)) 0 var(--hub-accordion-border-color)}.hub-accordion-button:not(.collapsed):after{background-color:var(--hub-accordion-icon-active-color);transform:var(--hub-accordion-btn-icon-transform)}.hub-accordion-button:after{flex-shrink:0;width:var(--hub-accordion-btn-icon-width);height:var(--hub-accordion-btn-icon-width);margin-left:auto;content:\"\";background-color:var(--hub-accordion-icon-color);-webkit-mask-image:var(--hub-accordion-btn-icon-mask);mask-image:var(--hub-accordion-btn-icon-mask);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-position:center;mask-position:center;transition:var(--hub-accordion-btn-icon-transition)}.hub-accordion-button:hover{z-index:2}.hub-accordion-button:focus{z-index:3;outline:0;border-color:var(--hub-accordion-btn-focus-border-color);box-shadow:var(--hub-accordion-btn-focus-box-shadow)}.hub-accordion-header{margin:0}.hub-accordion-collapse{display:grid;grid-template-rows:1fr;transition:grid-template-rows var(--hub-accordion-collapse-transition-duration) var(--hub-accordion-collapse-transition-easing)}.hub-accordion-collapse.hub-accordion-collapse--collapsed{grid-template-rows:0fr}.hub-accordion-collapse>.hub-accordion-body{min-height:0;overflow:hidden;opacity:1;padding:var(--hub-accordion-body-padding-y) var(--hub-accordion-body-padding-x);transition:opacity var(--hub-accordion-collapse-transition-duration) var(--hub-accordion-collapse-transition-easing),padding var(--hub-accordion-collapse-transition-duration) var(--hub-accordion-collapse-transition-easing)}.hub-accordion-collapse.hub-accordion-collapse--collapsed>.hub-accordion-body{opacity:0;padding-top:0;padding-bottom:0}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionPanelComponent, decorators: [{
type: Component,
args: [{ selector: 'hub-accordion-panel', imports: [NgTemplateOutlet], host: { class: 'hub-accordion-panel' }, template: "<h2 class=\"hub-accordion-header\">\n\t<button\n\t\tclass=\"hub-accordion-button\"\n\t\ttype=\"button\"\n\t\t[class.collapsed]=\"collapsed()\"\n\t\t[attr.data-bs-target]=\"'#collapse' + index\"\n\t\t[attr.aria-expanded]=\"!collapsed()\"\n\t\t[attr.aria-controls]=\"'collapse' + index\"\n\t\t(click)=\"toggleCollapse()\"\n\t>\n\t\t@if (headerTpt()) {\n\t\t\t<ng-container [ngTemplateOutlet]=\"$any(headerTpt())\"></ng-container>\n\t\t} @else if (title()) {\n\t\t\t{{ title() }}\n\t\t}\n\t</button>\n</h2>\n<div\n\t[attr.id]=\"'collapse' + index\"\n\tclass=\"hub-accordion-collapse\"\n\t[class.hub-accordion-collapse--collapsed]=\"collapsed()\"\n\t[attr.aria-hidden]=\"collapsed()\"\n\t[attr.inert]=\"collapsed() ? '' : null\"\n>\n\t<div class=\"hub-accordion-body\">\n\t\t<ng-content></ng-content>\n\t</div>\n</div>\n", styles: [":host{display:block;color:var(--hub-accordion-color);background-color:var(--hub-accordion-bg);border:var(--hub-accordion-border-width) solid var(--hub-accordion-border-color)}:host:first-of-type{border-top-left-radius:var(--hub-accordion-border-radius);border-top-right-radius:var(--hub-accordion-border-radius)}:host:first-of-type>.hub-accordion-header .hub-accordion-button{border-top-left-radius:var(--hub-accordion-inner-border-radius);border-top-right-radius:var(--hub-accordion-inner-border-radius)}:host:not(:first-of-type){border-top:0}:host:last-of-type{border-bottom-left-radius:var(--hub-accordion-border-radius);border-bottom-right-radius:var(--hub-accordion-border-radius)}:host:last-of-type>.hub-accordion-header .hub-accordion-button.collapsed{border-bottom-left-radius:var(--hub-accordion-inner-border-radius);border-bottom-right-radius:var(--hub-accordion-inner-border-radius)}:host:last-of-type>.hub-accordion-collapse{border-bottom-left-radius:var(--hub-accordion-border-radius);border-bottom-right-radius:var(--hub-accordion-border-radius)}.hub-accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--hub-accordion-btn-padding-y) var(--hub-accordion-btn-padding-x);font-size:1rem;color:var(--hub-accordion-btn-color);text-align:left;background-color:var(--hub-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--hub-accordion-transition);cursor:pointer}.hub-accordion-button:not(.collapsed){color:var(--hub-accordion-active-color);background-color:var(--hub-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--hub-accordion-border-width)) 0 var(--hub-accordion-border-color)}.hub-accordion-button:not(.collapsed):after{background-color:var(--hub-accordion-icon-active-color);transform:var(--hub-accordion-btn-icon-transform)}.hub-accordion-button:after{flex-shrink:0;width:var(--hub-accordion-btn-icon-width);height:var(--hub-accordion-btn-icon-width);margin-left:auto;content:\"\";background-color:var(--hub-accordion-icon-color);-webkit-mask-image:var(--hub-accordion-btn-icon-mask);mask-image:var(--hub-accordion-btn-icon-mask);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-position:center;mask-position:center;transition:var(--hub-accordion-btn-icon-transition)}.hub-accordion-button:hover{z-index:2}.hub-accordion-button:focus{z-index:3;outline:0;border-color:var(--hub-accordion-btn-focus-border-color);box-shadow:var(--hub-accordion-btn-focus-box-shadow)}.hub-accordion-header{margin:0}.hub-accordion-collapse{display:grid;grid-template-rows:1fr;transition:grid-template-rows var(--hub-accordion-collapse-transition-duration) var(--hub-accordion-collapse-transition-easing)}.hub-accordion-collapse.hub-accordion-collapse--collapsed{grid-template-rows:0fr}.hub-accordion-collapse>.hub-accordion-body{min-height:0;overflow:hidden;opacity:1;padding:var(--hub-accordion-body-padding-y) var(--hub-accordion-body-padding-x);transition:opacity var(--hub-accordion-collapse-transition-duration) var(--hub-accordion-collapse-transition-easing),padding var(--hub-accordion-collapse-transition-duration) var(--hub-accordion-collapse-transition-easing)}.hub-accordion-collapse.hub-accordion-collapse--collapsed>.hub-accordion-body{opacity:0;padding-top:0;padding-bottom:0}\n"] }]
}], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], collapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsed", required: false }] }, { type: i0.Output, args: ["collapsedChange"] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], headerTpt: [{ type: i0.ContentChild, args: [i0.forwardRef(() => AccordionPanelHeaderDirective), { ...{
read: TemplateRef
}, isSignal: true }] }], collapsedChange: [{ type: i0.Output, args: ["collapsedChange"] }], onKeyDown: [{
type: HostListener,
args: ['keydown', ['$event']]
}] } });
/**
* A component that implements an accordion UI pattern, allowing users to show and hide
* sections of related content. Supports single or multiple expanded panels and flush styling.
* Implements ControlValueAccessor for form integration.
*
* @example
* ```html
* <hub-accordion [multiple]="true" [options]="{flush: true}">
* <hub-accordion-panel>...</hub-accordion-panel>
* <hub-accordion-panel>...</hub-accordion-panel>
* </hub-accordion>
* ```
*
* @implements {ControlValueAccessor}
* @extends {SelectMultipleControlValueAccessor}
*/
class AccordionComponent {
/**
* The current value of the accordion component.
* An array that may contain any type of data.
*/
value = [];
/**
* Input binding that configures the property or attribute to bind to.
* Defines the property that will be bound to the value of the accordion.
* @default undefined
*/
bindValue = input(...(ngDevMode ? [undefined, { debugName: "bindValue" }] : []));
/**
* Configuration options for the accordion component.
*
* @property {Object} options - The options object for configuring the accordion.
* @property {boolean} options.flush - When true, removes some borders and rounded corners to render
* accordions edge-to-edge with their parent container. Defaults to false.
*/
options = input({ flush: false }, { ...(ngDevMode ? { debugName: "options" } : {}), transform: (value) => {
// Input validation and sanitization
if (!value || typeof value !== 'object') {
console.warn('[AccordionComponent] Invalid options provided, using defaults');
return { flush: false };
}
// Ensure flush is a boolean
const flush = typeof value.flush === 'boolean' ? value.flush : false;
return { flush };
} });
/**
* Defines a comparison function to determine if two accordion items are equal.
* Used to track identity of objects in accordion selections.
*
* @param o1 First object to compare
* @param o2 Second object to compare
* @returns {boolean} True if objects are considered equal, false otherwise
* @defaultValue Default implementation uses strict equality (===)
*/
compareWith = input((o1, o2) => o1 === o2, ...(ngDevMode ? [{ debugName: "compareWith" }] : []));
/**
* When true, expanding an accordion item will close all other expanded items.
* Defaults to false.
*/
multiple = input(false, ...(ngDevMode ? [{ debugName: "multiple" }] : []));
/**
* Collection of child AccordionPanelComponent instances within this accordion.
* Uses Angular's contentChildren query to obtain all panel components.
* @type {QueryList<AccordionPanelComponent>}
*/
panels = contentChildren(AccordionPanelComponent, ...(ngDevMode ? [{ debugName: "panels" }] : []));
/**
* Subject for managing component destruction and cleanup.
* @private
*/
destroy$ = new Subject();
/**
* Map to track panel subscriptions for proper cleanup.
* @private
*/
panelSubscriptions = new Map();
/**
* Effect that manages panel changes and their subscriptions.
* Initializes panel indices and subscribes to collapse events for each panel.
* This effect runs whenever the panels signal changes.
*
* Properly manages subscriptions to prevent memory leaks by:
* - Unsubscribing from previous panel subscriptions
* - Creating new subscriptions for current panels
* - Storing subscription references for cleanup
*/
panelsChangeEffect = effect(() => {
// Clean up existing subscriptions
this.panelSubscriptions.forEach(subscription => subscription.unsubscribe());
this.panelSubscriptions.clear();
// Create new subscriptions for current panels
this.panels().forEach((panel, index) => {
panel.index = index;
const subscription = panel.collapsedChange.subscribe((event) => {
this.handlePanelCollapse(event);
});
this.panelSubscriptions.set(index, subscription);
});
}, ...(ngDevMode ? [{ debugName: "panelsChangeEffect" }] : []));
/**
* Gets whether the accordion has the flush styling option enabled
* @returns {boolean} True if the flush option is enabled, false otherwise
*/
get haveFlushClass() {
return this.options().flush;
}
onChange = () => { };
onTouch = () => { };
/**
* Implements the ControlValueAccessor interface to write a new value to the form control.
* Validates and sanitizes the input value before setting it.
* @param obj The value to be written to the form control
*/
writeValue(obj) {
// Handle null/undefined values
if (obj == null) {
this.value = [];
this.handleValue();
return;
}
try {
// Validate and transform the input value
if (this.multiple()) {
// Multiple mode: expect an array
if (Array.isArray(obj)) {
this.value = [...obj]; // Create a copy to avoid reference issues
}
else {
console.warn('[AccordionComponent] Expected array for multiple selection mode, received:', typeof obj);
this.value = [obj]; // Wrap single value in array
}
}
else {
// Single mode: wrap in array for internal consistency
if (Array.isArray(obj)) {
console.warn('[AccordionComponent] Expected single value for single selection mode, received array. Using first element.');
this.value = obj.length > 0 ? [obj[0]] : [];
}
else {
this.value = [obj];
}
}
this.handleValue();
}
catch (error) {
console.error('[AccordionComponent] Error processing writeValue:', error);
this.value = [];
this.handleValue();
}
}
/**
* Registers a callback function that is invoked when the control's value changes in the UI.
* This is part of the ControlValueAccessor interface implementation.
* @param fn - The callback function to register. This function will be called with the new value when the control's value changes.
*/
registerOnChange(fn) {
this.onChange = fn;
}
/**
* Registers a callback function that is called when the control receives a touch event.
* Part of the ControlValueAccessor interface implementation.
* @param fn - The callback function to register. Gets called when the control is touched.
*/
registerOnTouched(fn) {
this.onTouch = fn;
}
/**
* Component cleanup method. Unsubscribes from all panel subscriptions and
* completes the destroy subject to prevent memory leaks.
*/
ngOnDestroy() {
// Clean up all panel subscriptions
this.panelSubscriptions.forEach(subscription => subscription.unsubscribe());
this.panelSubscriptions.clear();
// Complete the destroy subject
this.destroy$.next();
this.destroy$.complete();
}
/**
* Updates the collapse state of all panels based on the current value.
* Each panel is collapsed if its value is not found in the accordion's value array using the compareWith function.
* @internal
*/
handleValue() {
for (const panel of this.panels()) {
const panelComparableValue = this.getComparableValue(panel.value());
panel.collapsed.set(!this.value.find((selectedValue) => this.compareWith()(selectedValue, panelComparableValue)));
}
}
/**
* Handles the collapse/expand event of an accordion panel.
* Manages state consistently and handles both single and multiple selection modes.
*
* @param {CollapseEvent} collapseEvent - Object containing panel collapse event data
*/
handlePanelCollapse(collapseEvent) {
this.updateAccordionValue(collapseEvent.value, collapseEvent.collapsed);
// Handle single-selection mode: close other panels when one expands
if (!collapseEvent.collapsed && !this.multiple()) {
this.closeOtherPanels(collapseEvent.index);
}
}
/**
* Updates the accordion's value based on panel state changes.
* Maintains consistency between single and multiple selection modes.
*
* @param panelValue - The value of the panel being toggled
* @param collapsed - Whether the panel is being collapsed (true) or expanded (false)
* @private
*/
updateAccordionValue(panelValue, collapsed) {
const comparablePanelValue = this.getComparableValue(panelValue);
// Ensure value is always an array for consistent processing
const currentValues = Array.isArray(this.value) ? [...this.value] : [];
if (collapsed) {
// Remove value from selection
this.value = currentValues.filter((selectedValue) => !this.compareWith()(selectedValue, comparablePanelValue));
}
else {
// Add value to selection
if (this.multiple()) {
// Multiple mode: add if not already present
if (!currentValues.some((selectedValue) => this.compareWith()(selectedValue, comparablePanelValue))) {
this.value = [...currentValues, comparablePanelValue];
}
}
else {
// Single mode: replace current selection
this.value = [comparablePanelValue];
}
}
// Emit the appropriate value format based on mode
this.emitValueChange();
}
/**
* Closes all panels except the one at the specified index.
* Used in single-selection mode to ensure only one panel is open at a time.
*
* @param excludeIndex - Index of the panel to keep open
* @private
*/
closeOtherPanels(excludeIndex) {
this.panels().forEach((panel, index) => {
if (index !== excludeIndex) {
panel.collapsed.set(true);
}
});
}
/**
* Emits the current value in the appropriate format for the current mode.
* Single mode emits a single value or null, multiple mode emits an array.
*
* @private
*/
emitValueChange() {
const emittedValue = this.multiple()
? this.value
: (this.value.length > 0 ? this.value[0] : null);
this.onChange(emittedValue);
}
/**
* Returns the value used by selection/comparison logic, applying `bindValue` when configured.
*
* @param sourceValue Raw panel value.
* @returns Comparable value used for internal selection state and emitted form value.
*/
getComparableValue(sourceValue) {
const bindPath = this.bindValue();
if (!bindPath) {
return sourceValue;
}
return this.readByPath(sourceValue, bindPath);
}
/**
* Reads a nested value from an object using dot notation path syntax.
*
* @param source Object to read from.
* @param path Dot notation path (e.g. `id`, `meta.key`).
* @returns Resolved value or `undefined` when the path cannot be resolved.
*/
readByPath(source, path) {
if (source == null || !path) {
return undefined;
}
return path.split('.').reduce((currentValue, segment) => {
if (currentValue == null) {
return undefined;
}
return currentValue[segment];
}, source);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.1.2", type: AccordionComponent, isStandalone: true, selector: "hub-accordion", inputs: { bindValue: { classPropertyName: "bindValue", publicName: "bindValue", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.hub-accordion-flush": "this.haveFlushClass" }, classAttribute: "hub-accordion" }, providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AccordionComponent),
multi: true
}
], queries: [{ propertyName: "panels", predicate: AccordionPanelComponent, isSignal: true }], ngImport: i0, template: "<ng-content></ng-content>\n", styles: [":root,:host{--hub-accordion-color: var(--hub-sys-text-primary, #212529);--hub-accordion-bg: var(--hub-sys-surface-page, #fff);--hub-accordion-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out, border-radius .15s ease;--hub-accordion-border-color: var(--hub-sys-border-color-default, rgba(0, 0, 0, .125));--hub-accordion-border-width: var(--hub-ref-border-width, 1px);--hub-accordion-border-radius: var(--hub-ref-radius-sm, .25rem);--hub-accordion-inner-border-radius: calc( var(--hub-accordion-border-radius, var(--hub-ref-radius-sm, .25rem)) - var( --hub-accordion-border-width, var(--hub-ref-border-width, 1px) ) );--hub-accordion-btn-padding-x: 1.25rem;--hub-accordion-btn-padding-y: var(--hub-ref-space-3, 1rem);--hub-accordion-btn-color: var(--hub-sys-text-primary, #212529);--hub-accordion-btn-bg: var(--hub-sys-surface-page, #fff);--hub-accordion-icon-color: var(--hub-accordion-btn-color, var(--hub-sys-text-primary, #212529));--hub-accordion-icon-active-color: var(--hub-accordion-active-color, var(--hub-sys-color-primary, #0d6efd));--hub-accordion-btn-icon-mask: url(\"data:image/svg+xml;charset=UTF-8,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E\");--hub-accordion-btn-icon: var(--hub-accordion-btn-icon-mask);--hub-accordion-btn-icon-width: 1.25rem;--hub-accordion-btn-icon-transform: rotate(-180deg);--hub-accordion-btn-icon-transition: transform .2s ease-in-out;--hub-accordion-btn-active-icon: var(--hub-accordion-btn-icon-mask);--hub-accordion-btn-focus-border-color: var(--hub-sys-color-primary, #86b7fe);--hub-accordion-btn-focus-box-shadow: 0 0 0 var(--hub-sys-focus-ring-width, .25rem) var(--hub-sys-focus-ring-color, rgba(13, 110, 253, .25));--hub-accordion-collapse-transition-duration: .25s;--hub-accordion-collapse-transition-easing: cubic-bezier(.4, 0, .2, 1);--hub-accordion-body-padding-x: 1.25rem;--hub-accordion-body-padding-y: var(--hub-ref-space-3, 1rem);--hub-accordion-active-color: var(--hub-sys-color-primary, #0c63e4);--hub-accordion-active-bg: var(--hub-sys-color-primary-subtle, #e7f1ff)}:host{display:block}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel{border-right:0;border-left:0;border-radius:0}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel:first-child{border-top:0}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel:last-child{border-bottom:0}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel>.hub-accordion-collapse,:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel>.hub-accordion-header .hub-accordion-button,:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel>.hub-accordion-header .hub-accordion-button.collapsed{border-radius:0}\n"] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionComponent, decorators: [{
type: Component,
args: [{ selector: 'hub-accordion', host: { class: 'hub-accordion' }, providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AccordionComponent),
multi: true
}
], template: "<ng-content></ng-content>\n", styles: [":root,:host{--hub-accordion-color: var(--hub-sys-text-primary, #212529);--hub-accordion-bg: var(--hub-sys-surface-page, #fff);--hub-accordion-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out, border-radius .15s ease;--hub-accordion-border-color: var(--hub-sys-border-color-default, rgba(0, 0, 0, .125));--hub-accordion-border-width: var(--hub-ref-border-width, 1px);--hub-accordion-border-radius: var(--hub-ref-radius-sm, .25rem);--hub-accordion-inner-border-radius: calc( var(--hub-accordion-border-radius, var(--hub-ref-radius-sm, .25rem)) - var( --hub-accordion-border-width, var(--hub-ref-border-width, 1px) ) );--hub-accordion-btn-padding-x: 1.25rem;--hub-accordion-btn-padding-y: var(--hub-ref-space-3, 1rem);--hub-accordion-btn-color: var(--hub-sys-text-primary, #212529);--hub-accordion-btn-bg: var(--hub-sys-surface-page, #fff);--hub-accordion-icon-color: var(--hub-accordion-btn-color, var(--hub-sys-text-primary, #212529));--hub-accordion-icon-active-color: var(--hub-accordion-active-color, var(--hub-sys-color-primary, #0d6efd));--hub-accordion-btn-icon-mask: url(\"data:image/svg+xml;charset=UTF-8,%3Csvg viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E\");--hub-accordion-btn-icon: var(--hub-accordion-btn-icon-mask);--hub-accordion-btn-icon-width: 1.25rem;--hub-accordion-btn-icon-transform: rotate(-180deg);--hub-accordion-btn-icon-transition: transform .2s ease-in-out;--hub-accordion-btn-active-icon: var(--hub-accordion-btn-icon-mask);--hub-accordion-btn-focus-border-color: var(--hub-sys-color-primary, #86b7fe);--hub-accordion-btn-focus-box-shadow: 0 0 0 var(--hub-sys-focus-ring-width, .25rem) var(--hub-sys-focus-ring-color, rgba(13, 110, 253, .25));--hub-accordion-collapse-transition-duration: .25s;--hub-accordion-collapse-transition-easing: cubic-bezier(.4, 0, .2, 1);--hub-accordion-body-padding-x: 1.25rem;--hub-accordion-body-padding-y: var(--hub-ref-space-3, 1rem);--hub-accordion-active-color: var(--hub-sys-color-primary, #0c63e4);--hub-accordion-active-bg: var(--hub-sys-color-primary-subtle, #e7f1ff)}:host{display:block}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel{border-right:0;border-left:0;border-radius:0}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel:first-child{border-top:0}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel:last-child{border-bottom:0}:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel>.hub-accordion-collapse,:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel>.hub-accordion-header .hub-accordion-button,:host(.hub-accordion-flush) ::ng-deep .hub-accordion-panel>.hub-accordion-header .hub-accordion-button.collapsed{border-radius:0}\n"] }]
}], propDecorators: { bindValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "bindValue", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], compareWith: [{ type: i0.Input, args: [{ isSignal: true, alias: "compareWith", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], panels: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => AccordionPanelComponent), { isSignal: true }] }], haveFlushClass: [{
type: HostBinding,
args: ['class.hub-accordion-flush']
}] } });
/**
* Default configuration values.
*/
const DEFAULT_CONFIG = {
animationDuration: 300,
multipleDefault: false,
flushDefault: false,
theme: 'default',
accessibilityEnabled: true,
developmentWarnings: true,
cssPrefix: 'hub-accordion',
animationsEnabled: true
};
/**
* Global configuration service for accordion components.
* Provides centralized configuration management with type safety and validation.
*
* @example
* ```typescript
* // In your app config or module
* export const appConfig: ApplicationConfig = {
* providers: [
* // other providers...
* {
* provide: ACCORDION_CONFIG,
* useValue: {
* theme: 'corporate',
* animationDuration: 500,
* multipleDefault: true
* }
* }
* ]
* };
*
* // Or use the service directly
* constructor(private accordionConfig: AccordionConfigService) {
* this.accordionConfig.updateConfig({
* theme: 'dark',
* animationsEnabled: false
* });
* }
* ```
*/
class AccordionConfigService {
/**
* Signal containing the current global configuration.
* @private
*/
config = signal({ ...DEFAULT_CONFIG }, ...(ngDevMode ? [{ debugName: "config" }] : []));
/**
* Updates the global configuration with partial values.
* Validates input values and provides warnings for invalid options.
*
* @param partialConfig - Partial configuration object to merge with current config
* @example
* ```typescript
* configService.updateConfig({
* theme: 'corporate',
* animationDuration: 500
* });
* ```
*/
updateConfig(partialConfig) {
const validatedConfig = this.validateConfig(partialConfig);
this.config.update(currentConfig => ({
...currentConfig,
...validatedConfig
}));
if (this.config().developmentWarnings) {
this.logConfigUpdate(validatedConfig);
}
}
/**
* Gets the current global configuration as a readonly signal.
*
* @returns Readonly signal with current configuration
* @example
* ```typescript
* const config = configService.getConfig();
* const animationDuration = config().animationDuration;
* ```
*/
getConfig() {
return this.config.asReadonly();
}
/**
* Resets the configuration to default values.
*
* @example
* ```typescript
* configService.resetToDefaults();
* ```
*/
resetToDefaults() {
this.config.set({ ...DEFAULT_CONFIG });
if (this.config().developmentWarnings) {
console.info('[AccordionConfigService] Configuration reset to defaults');
}
}
/**
* Gets a specific configuration value with type safety.
*
* @param key - Configuration key to retrieve
* @returns The configuration value
* @example
* ```typescript
* const duration = configService.getValue('animationDuration');
* ```
*/
getValue(key) {
return this.config()[key];
}
/**
* Validates and sanitizes configuration values.
*
* @param config - Configuration object to validate
* @returns Validated and sanitized configuration
* @private
*/
validateConfig(config) {
const validated = {};
if (config.animationDuration !== undefined) {
if (typeof config.animationDuration === 'number' && config.animationDuration >= 0) {
validated.animationDuration = Math.max(0, Math.min(5000, config.animationDuration));
}
else {
this.warn('animationDuration must be a positive number, using default value');
}
}
if (config.multipleDefault !== undefined) {
if (typeof config.multipleDefault === 'boolean') {
validated.multipleDefault = config.multipleDefault;
}
else {
this.warn('multipleDefault must be a boolean, using default value');
}
}
if (config.flushDefault !== undefined) {
if (typeof config.flushDefault === 'boolean') {
validated.flushDefault = config.flushDefault;
}
else {
this.warn('flushDefault must be a boolean, using default value');
}
}
if (config.theme !== undefined) {
if (typeof config.theme === 'string' && config.theme.length > 0) {
validated.theme = config.theme;
}
else {
this.warn('theme must be a non-empty string, using default value');
}
}
if (config.accessibilityEnabled !== undefined) {
if (typeof config.accessibilityEnabled === 'boolean') {
validated.accessibilityEnabled = config.accessibilityEnabled;
}
else {
this.warn('accessibilityEnabled must be a boolean, using default value');
}
}
if (config.developmentWarnings !== undefined) {
if (typeof config.developmentWarnings === 'boolean') {
validated.developmentWarnings = config.developmentWarnings;
}
}
if (config.cssPrefix !== undefined) {
if (typeof config.cssPrefix === 'string' && config.cssPrefix.length > 0) {
validated.cssPrefix = config.cssPrefix;
}
else {
this.warn('cssPrefix must be a non-empty string, using default value');
}
}
if (config.animationsEnabled !== undefined) {
if (typeof config.animationsEnabled === 'boolean') {
validated.animationsEnabled = config.animationsEnabled;
}
else {
this.warn('animationsEnabled must be a boolean, using default value');
}
}
return validated;
}
/**
* Logs configuration updates in development mode.
*
* @param config - Updated configuration values
* @private
*/
logConfigUpdate(config) {
const keys = Object.keys(config);
if (keys.length > 0) {
console.info('[AccordionConfigService] Configuration updated:', keys.map(key => `${key}: ${config[key]}`).join(', '));
}
}
/**
* Logs warnings for invalid configuration values.
*
* @param message - Warning message
* @private
*/
warn(message) {
if (this.config().developmentWarnings) {
console.warn(`[AccordionConfigService] ${message}`);
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionConfigService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.2", ngImport: i0, type: AccordionConfigService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}] });
/**
* Injection token for providing global accordion configuration.
* Use this token to provide custom configuration at application startup.
*
* @example
* ```typescript
* import { ACCORDION_CONFIG } from 'ng-hub-ui-accordion';
*
* export const appConfig: ApplicationConfig = {
* providers: [
* {
* provide: ACCORDION_CONFIG,
* useValue: {
* theme: 'corporate',
* animationDuration: 500
* }
* }
* ]
* };
* ```
*/
const ACCORDION_CONFIG = 'ACCORDION_CONFIG';
/*
* Public API Surface of accordion
*/
/**
* Generated bundle index. Do not edit.
*/
export { ACCORDION_CONFIG, AccordionComponent, AccordionConfigService, AccordionPanelComponent, AccordionPanelHeaderDirective };
//# sourceMappingURL=ng-hub-ui-accordion.mjs.map