UNPKG

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
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