From 4138dece5ef9beffd1e58f8f44ef8bdc9eac66b3 Mon Sep 17 00:00:00 2001 From: Benjamin Heiss <114084395+elite-benni@users.noreply.github.com> Date: Sat, 2 Dec 2023 18:51:43 +0100 Subject: [PATCH] fix: accordion trigger is now a directive (#64) * fix: accordion is now a directive * fix: moving with tab changes the activeitem of the keymanager * fix(accordion): accordion.component change to directive The accordion does not neet to be a component * fix(accordion): accordion.item change to directive * refactor(accordion): change transition to grid fr transition * docs(accordion): adjust examples to directives * docs: adjust to directives * fix: remove console logs * test(accordion): adjust e2e tests to directives * fix(icon): make icon take host classes into account * feat(accordion): iterate on benni's accordion changes * fix: prevents keys on all accordion triggers * fix: typo * test: add test for brn-accordion-directive Co-authored-by: robingotz BREAKING CHANGE: accordion trigger is now a directive & accordion icon is replaced with hlm-icon + directive --- .../(accordion)/accordion.preview.ts | 201 +++++++++--------- .../documentation/introduction.page.ts | 62 +++--- .../src/app/shared/layout/page.component.ts | 2 +- .../src/integration/accordion/accordion.cy.ts | 30 +-- libs/ui/accordion/accordion.stories.ts | 69 +++--- libs/ui/accordion/brain/src/index.ts | 18 +- .../lib/brn-accordion-content.component.ts | 47 ++-- .../src/lib/brn-accordion-item.component.ts | 27 --- .../src/lib/brn-accordion-item.directive.ts | 24 +++ .../lib/brn-accordion-trigger.component.ts | 57 ----- .../lib/brn-accordion-trigger.directive.ts | 52 +++++ .../brain/src/lib/brn-accordion.component.ts | 61 ------ .../src/lib/brn-accordion.directive.spec.ts | 154 ++++++++++++++ .../brain/src/lib/brn-accordion.directive.ts | 96 +++++++++ libs/ui/accordion/brain/src/test-setup.ts | 5 + libs/ui/accordion/helm/src/index.ts | 6 +- .../lib/hlm-accordion-content.directive.ts | 79 ++----- .../src/lib/hlm-accordion-icon.component.ts | 28 --- .../src/lib/hlm-accordion-icon.directive.ts | 31 +++ .../src/lib/hlm-accordion-item.directive.ts | 13 +- .../lib/hlm-accordion-trigger.directive.ts | 35 ++- .../helm/src/lib/hlm-accordion.directive.ts | 17 +- .../icon/helm/src/lib/hlm-icon.component.ts | 51 ++++- 23 files changed, 654 insertions(+), 511 deletions(-) delete mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion-item.component.ts create mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion-item.directive.ts delete mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion-trigger.component.ts create mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion-trigger.directive.ts delete mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion.component.ts create mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion.directive.spec.ts create mode 100644 libs/ui/accordion/brain/src/lib/brn-accordion.directive.ts delete mode 100644 libs/ui/accordion/helm/src/lib/hlm-accordion-icon.component.ts create mode 100644 libs/ui/accordion/helm/src/lib/hlm-accordion-icon.directive.ts diff --git a/apps/app/src/app/pages/(components)/components/(accordion)/accordion.preview.ts b/apps/app/src/app/pages/(components)/components/(accordion)/accordion.preview.ts index dd5fc35f0..90b26c92a 100644 --- a/apps/app/src/app/pages/(components)/components/(accordion)/accordion.preview.ts +++ b/apps/app/src/app/pages/(components)/components/(accordion)/accordion.preview.ts @@ -1,157 +1,146 @@ import { Component } from '@angular/core'; import { - BrnAccordionComponent, BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, + BrnAccordionDirective, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, } from '@spartan-ng/ui-accordion-brain'; import { HlmAccordionContentDirective, HlmAccordionDirective, - HlmAccordionIconComponent, + HlmAccordionIconDirective, HlmAccordionItemDirective, HlmAccordionTriggerDirective, } from '@spartan-ng/ui-accordion-helm'; +import { HlmIconComponent } from '@spartan-ng/ui-icon-helm'; @Component({ selector: 'spartan-accordion-preview', standalone: true, imports: [ - BrnAccordionComponent, + BrnAccordionDirective, BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, HlmAccordionDirective, HlmAccordionItemDirective, HlmAccordionTriggerDirective, HlmAccordionContentDirective, - HlmAccordionIconComponent, + HlmAccordionIconDirective, + HlmIconComponent, ], template: ` - - - - Is it accessible? - - +
+
+ Yes. It adheres to the WAI-ARIA design pattern. - +
- - - Is it styled - - +
+ Yes. It comes with default styles that match the other components' aesthetics. - +
- - - Is it animated? - - +
+ Yes. It's animated by default, but you can disable it if you prefer. - - +
+
`, }) export class AccordionPreviewComponent {} -export const codeString = ` -import { Component } from '@angular/core'; +export const codeImports = ` import { - BrnAccordionComponent, BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, + BrnAccordionDirective, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, } from '@spartan-ng/ui-accordion-brain'; import { HlmAccordionContentDirective, HlmAccordionDirective, - HlmAccordionIconComponent, + HlmAccordionIconDirective, HlmAccordionItemDirective, HlmAccordionTriggerDirective, } from '@spartan-ng/ui-accordion-helm'; +import { HlmIconComponent } from '@spartan-ng/ui-icon-helm'; +`; -@Component({ - selector: 'spartan-accordion-preview', - standalone: true, - imports: [ - BrnAccordionComponent, - BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, - HlmAccordionDirective, - HlmAccordionItemDirective, - HlmAccordionTriggerDirective, - HlmAccordionContentDirective, - HlmAccordionIconComponent, - ], - template: \` - - - - Is it accessible? - - - Yes. It adheres to the WAI-ARIA design pattern. - +export const codeString = + "import { Component } from '@angular/core';" + + codeImports + + ` + @Component({ + selector: 'spartan-accordion-preview', + standalone: true, + imports: [ + BrnAccordionDirective, + BrnAccordionContentComponent, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, + HlmAccordionDirective, + HlmAccordionItemDirective, + HlmAccordionTriggerDirective, + HlmAccordionContentDirective, + HlmAccordionIconDirective, + HlmIconComponent, + ], + template: \` +
+
+ + Yes. It adheres to the WAI-ARIA design pattern. +
- - - Is it styled - - - - Yes. It comes with default styles that match the other components' aesthetics. - - +
+ + + Yes. It comes with default styles that match the other components' aesthetics. + +
- - - Is it animated? - - - - Yes. It's animated by default, but you can disable it if you prefer. - - - - \`, +
+ + + Yes. It's animated by default, but you can disable it if you prefer. + +
+
+ \`, }) -export class AccordionPreviewComponent {} -`; - -export const codeImports = ` -import { - BrnAccordionComponent, - BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, -} from '@spartan-ng/ui-accordion-brain'; -import { - HlmAccordionContentDirective, - HlmAccordionDirective, - HlmAccordionIconComponent, - HlmAccordionItemDirective, - HlmAccordionTriggerDirective, -} from '@spartan-ng/ui-accordion-helm'; -`; +export class AccordionPreviewComponent {}`; export const codeSkeleton = ` - - - - Is it accessible? - - - - Yes. It adheres to the WAI-ARIA design pattern. - - - +
+
+ + Yes. It adheres to the WAI-ARIA design pattern. +
+
`; diff --git a/apps/app/src/app/pages/(documentation)/documentation/introduction.page.ts b/apps/app/src/app/pages/(documentation)/documentation/introduction.page.ts index ec930f7ad..49518d90c 100644 --- a/apps/app/src/app/pages/(documentation)/documentation/introduction.page.ts +++ b/apps/app/src/app/pages/(documentation)/documentation/introduction.page.ts @@ -4,15 +4,15 @@ import { RouterLink } from '@angular/router'; import { provideIcons } from '@ng-icons/core'; import { radixChevronRight } from '@ng-icons/radix-icons'; import { - BrnAccordionComponent, BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, + BrnAccordionDirective, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, } from '@spartan-ng/ui-accordion-brain'; import { HlmAccordionContentDirective, HlmAccordionDirective, - HlmAccordionIconComponent, + HlmAccordionIconDirective, HlmAccordionItemDirective, HlmAccordionTriggerDirective, } from '@spartan-ng/ui-accordion-helm'; @@ -46,13 +46,13 @@ export const routeMeta: RouteMeta = { SectionIntroComponent, SectionSubHeadingComponent, PageNavComponent, - BrnAccordionComponent, + BrnAccordionDirective, BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, HlmAccordionContentDirective, HlmAccordionDirective, - HlmAccordionIconComponent, + HlmAccordionIconDirective, HlmAccordionItemDirective, HlmAccordionTriggerDirective, RouterLink, @@ -126,47 +126,45 @@ export const routeMeta: RouteMeta = { FAQ - - - +
+
+ It is a collection of full-stack technologies that power end-to-end type-safe Angular development. - +
- - +
+ A collection of Angular UI primitives that are both beautiful and accessible. - - - - +
+
+ A collection of unstyled UI primitives that provide accessibility out of the box. - - - - +
+
+ Directives, sometimes additional components, that give spartan/brain a shadcn look. - - +
+
diff --git a/apps/app/src/app/shared/layout/page.component.ts b/apps/app/src/app/shared/layout/page.component.ts index b60a0a5bc..dca6d6a42 100644 --- a/apps/app/src/app/shared/layout/page.component.ts +++ b/apps/app/src/app/shared/layout/page.component.ts @@ -14,7 +14,7 @@ import { SideNavComponent } from '~/app/shared/layout/side-nav/side-nav.componen template: `
-
+
diff --git a/apps/ui-storybook-e2e/src/integration/accordion/accordion.cy.ts b/apps/ui-storybook-e2e/src/integration/accordion/accordion.cy.ts index 5b6800668..62d408083 100644 --- a/apps/ui-storybook-e2e/src/integration/accordion/accordion.cy.ts +++ b/apps/ui-storybook-e2e/src/integration/accordion/accordion.cy.ts @@ -1,8 +1,8 @@ describe('accordion', () => { const verifyAccordionSetup = () => { - cy.get('brn-accordion').should('have.attr', 'data-state', 'closed'); - cy.get('brn-accordion-item').should('have.length', 3); - cy.get('brn-accordion-item').first().as('firstItem'); + cy.get('[hlmaccordion]').should('have.attr', 'data-state', 'closed'); + cy.get('[hlmAccordionItem]').should('have.length', 3); + cy.get('[hlmAccordionItem]').first().as('firstItem'); cy.get('@firstItem').next().as('secondItem'); cy.get('@secondItem').next().as('thirdItem'); @@ -11,7 +11,7 @@ describe('accordion', () => { cy.get('@thirdItem').should('have.attr', 'data-state', 'closed'); cy.get('@firstItem') - .find('brn-accordion-trigger') + .find('[hlmAccordionTrigger]') .should('have.attr', 'role', 'heading') .should('have.id', 'brn-accordion-trigger-0') .should('have.attr', 'data-state', 'closed') @@ -24,17 +24,17 @@ describe('accordion', () => { }; const verifyAccordionStateOpen = () => { - cy.get('brn-accordion').should('have.attr', 'data-state', 'open'); + cy.get('[hlmaccordion]').should('have.attr', 'data-state', 'open'); }; const verifyAccordionStateClosed = () => { - cy.get('brn-accordion').should('have.attr', 'data-state', 'closed'); + cy.get('[hlmaccordion]').should('have.attr', 'data-state', 'closed'); }; const verifyAccordionFirstItemStateOpen = () => { cy.get('@firstItem').should('have.attr', 'data-state', 'open'); cy.get('@firstItem') - .find('brn-accordion-trigger') + .find('[hlmAccordionTrigger]') .should('have.attr', 'data-state', 'open') .should('have.attr', 'aria-expanded', 'true'); cy.get('@firstItem').find('brn-accordion-content').should('have.attr', 'data-state', 'open'); @@ -43,7 +43,7 @@ describe('accordion', () => { const verifyAccordionFirstItemStateClosed = () => { cy.get('@firstItem').should('have.attr', 'data-state', 'closed'); cy.get('@firstItem') - .find('brn-accordion-trigger') + .find('[hlmAccordionTrigger]') .should('have.attr', 'data-state', 'closed') .should('have.attr', 'aria-expanded', 'false'); cy.get('@firstItem').find('brn-accordion-content').should('have.attr', 'data-state', 'closed'); @@ -52,7 +52,7 @@ describe('accordion', () => { const verifyAccordionSecondItemStateOpen = () => { cy.get('@secondItem').should('have.attr', 'data-state', 'open'); cy.get('@secondItem') - .find('brn-accordion-trigger') + .find('[hlmAccordionTrigger]') .should('have.attr', 'data-state', 'open') .should('have.attr', 'aria-expanded', 'true'); cy.get('@secondItem').find('brn-accordion-content').should('have.attr', 'data-state', 'open'); @@ -61,7 +61,7 @@ describe('accordion', () => { const verifyAccordionSecondItemStateClosed = () => { cy.get('@secondItem').should('have.attr', 'data-state', 'closed'); cy.get('@secondItem') - .find('brn-accordion-trigger') + .find('[hlmAccordionTrigger]') .should('have.attr', 'data-state', 'closed') .should('have.attr', 'aria-expanded', 'false'); cy.get('@secondItem').find('brn-accordion-content').should('have.attr', 'data-state', 'closed'); @@ -77,12 +77,12 @@ describe('accordion', () => { verifyAccordionSetup(); // click trigger first item to open it content - cy.get('@firstItem').find('brn-accordion-trigger').click(); + cy.get('@firstItem').find('[hlmAccordionTrigger]').click(); verifyAccordionStateOpen(); verifyAccordionFirstItemStateOpen(); // click trigger first item to close it content - cy.get('@firstItem').find('brn-accordion-trigger').click(); + cy.get('@firstItem').find('[hlmAccordionTrigger]').click(); verifyAccordionStateClosed(); verifyAccordionFirstItemStateClosed(); }); @@ -91,18 +91,18 @@ describe('accordion', () => { verifyAccordionSetup(); // click trigger first item to open it content - cy.get('@firstItem').find('brn-accordion-trigger').click(); + cy.get('@firstItem').find('[hlmAccordionTrigger]').click(); verifyAccordionStateOpen(); verifyAccordionFirstItemStateOpen(); // click trigger second item to open it content and close previous content - cy.get('@secondItem').find('brn-accordion-trigger').click(); + cy.get('@secondItem').find('[hlmAccordionTrigger]').click(); verifyAccordionStateOpen(); verifyAccordionSecondItemStateOpen(); verifyAccordionFirstItemStateClosed(); // click trigger second item to close it content - cy.get('@secondItem').find('brn-accordion-trigger').click(); + cy.get('@secondItem').find('[hlmAccordionTrigger]').click(); verifyAccordionStateClosed(); verifyAccordionSecondItemStateClosed(); }); diff --git a/libs/ui/accordion/accordion.stories.ts b/libs/ui/accordion/accordion.stories.ts index 23fd45095..839a2197c 100644 --- a/libs/ui/accordion/accordion.stories.ts +++ b/libs/ui/accordion/accordion.stories.ts @@ -1,55 +1,56 @@ +import { radixChevronDown } from '@ng-icons/radix-icons'; import type { Meta, StoryObj } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular'; -import { BrnAccordionComponent, BrnAccordionImports } from './brain/src'; +import { HlmIconComponent, provideIcons } from '../icon/helm/src'; +import { BrnAccordionDirective, BrnAccordionImports } from './brain/src'; import { HlmAccordionImports } from './helm/src'; -const meta: Meta = { +const meta: Meta = { title: 'Accordion', - component: BrnAccordionComponent, + component: BrnAccordionDirective, decorators: [ moduleMetadata({ - imports: [BrnAccordionImports, HlmAccordionImports], + imports: [BrnAccordionImports, HlmAccordionImports, HlmIconComponent], + providers: [provideIcons({ radixChevronDown })], }), ], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { render: () => ({ template: ` - - - - What is SPARTAN - - - - It is a collection of full-stack technologies that provide end-to-end type-safety. - - +
+
+ + Yes. It adheres to the WAI-ARIA design pattern. +
- - - What is SPARTAN Brain - - - - A collection of unstyled UI primitives that provide accessibility out of the box. - - +
+ + + Yes. It comes with default styles that match the other components' aesthetics. + +
- - - What is SPARTAN Helm - - - - Directives, sometimes additional components, that provide shadcn like styles for the Angular ecosystem. - - - +
+ + + Yes. It's animated by default, but you can disable it if you prefer. + +
+
`, }), }; diff --git a/libs/ui/accordion/brain/src/index.ts b/libs/ui/accordion/brain/src/index.ts index 4cca0d2bc..e116b8250 100644 --- a/libs/ui/accordion/brain/src/index.ts +++ b/libs/ui/accordion/brain/src/index.ts @@ -1,20 +1,20 @@ import { NgModule } from '@angular/core'; import { BrnAccordionContentComponent } from './lib/brn-accordion-content.component'; -import { BrnAccordionItemComponent } from './lib/brn-accordion-item.component'; -import { BrnAccordionTriggerComponent } from './lib/brn-accordion-trigger.component'; -import { BrnAccordionComponent } from './lib/brn-accordion.component'; +import { BrnAccordionItemDirective } from './lib/brn-accordion-item.directive'; +import { BrnAccordionTriggerDirective } from './lib/brn-accordion-trigger.directive'; +import { BrnAccordionDirective } from './lib/brn-accordion.directive'; export * from './lib/brn-accordion-content.component'; -export * from './lib/brn-accordion-item.component'; -export * from './lib/brn-accordion-trigger.component'; -export * from './lib/brn-accordion.component'; +export * from './lib/brn-accordion-item.directive'; +export * from './lib/brn-accordion-trigger.directive'; +export * from './lib/brn-accordion.directive'; export const BrnAccordionImports = [ - BrnAccordionComponent, + BrnAccordionDirective, BrnAccordionContentComponent, - BrnAccordionItemComponent, - BrnAccordionTriggerComponent, + BrnAccordionItemDirective, + BrnAccordionTriggerDirective, ] as const; @NgModule({ diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion-content.component.ts b/libs/ui/accordion/brain/src/lib/brn-accordion-content.component.ts index 57f39aa33..a6c58baed 100644 --- a/libs/ui/accordion/brain/src/lib/brn-accordion-content.component.ts +++ b/libs/ui/accordion/brain/src/lib/brn-accordion-content.component.ts @@ -1,58 +1,41 @@ -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - ElementRef, - inject, - signal, - ViewEncapsulation, -} from '@angular/core'; -import { CustomElementClassSettable, provideCustomClassSettableExisting } from '@spartan-ng/ui-core'; -import { BrnAccordionItemComponent } from './brn-accordion-item.component'; +import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core'; +import { CustomElementClassSettable } from '@spartan-ng/ui-core'; +import { BrnAccordionItemDirective } from './brn-accordion-item.directive'; @Component({ selector: 'brn-accordion-content', standalone: true, - providers: [provideCustomClassSettableExisting(() => BrnAccordionContentComponent)], host: { '[attr.data-state]': 'state()', '[attr.aria-labelledby]': 'ariaLabeledBy', role: 'region', - '[style.--brn-collapsible-content-height]': 'initialHeight + "px"', '[id]': 'id', }, template: ` -

- -

+
+

+ +

+
`, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class BrnAccordionContentComponent implements AfterViewInit, CustomElementClassSettable { - private _item = inject(BrnAccordionItemComponent); - private _element = inject(ElementRef).nativeElement; +export class BrnAccordionContentComponent implements CustomElementClassSettable { + private readonly _item = inject(BrnAccordionItemDirective); - public state = this._item.state; - public id = 'brn-accordion-content-' + this._item.id; - public ariaLabeledBy = 'brn-accordion-trigger-' + this._item.id; - protected initialHeight = 0; + public readonly state = this._item.state; + public readonly id = 'brn-accordion-content-' + this._item.id; + public readonly ariaLabeledBy = 'brn-accordion-trigger-' + this._item.id; - private readonly _contentClass = signal(''); - public readonly contentClass = this._contentClass.asReadonly(); + protected readonly _contentClass = signal(''); constructor() { if (!this._item) { - throw Error('Accordion trigger can only be used inside an AccordionItem. Add brnAccordionItem to parent.'); + throw Error('Accordion Content can only be used inside an AccordionItem. Add brnAccordionItem to parent.'); } } - public ngAfterViewInit() { - Promise.resolve().then(() => { - this.initialHeight = this._element.offsetHeight; - }); - } - public setClassToCustomElement(classes: string) { this._contentClass.set(classes); } diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion-item.component.ts b/libs/ui/accordion/brain/src/lib/brn-accordion-item.component.ts deleted file mode 100644 index b4711d9f7..000000000 --- a/libs/ui/accordion/brain/src/lib/brn-accordion-item.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Component, computed, inject } from '@angular/core'; -import { BrnAccordionComponent } from './brn-accordion.component'; - -let itemIdGenerator = 0; - -@Component({ - selector: 'brn-accordion-item', - standalone: true, - host: { - '[attr.data-state]': 'state()', - }, - template: ` - - `, -}) -export class BrnAccordionItemComponent { - private _accordion = inject(BrnAccordionComponent); - - public id = itemIdGenerator++; - public state = computed(() => (this._accordion.openItemIds().includes(this.id) ? 'open' : 'closed')); - - constructor() { - if (!this._accordion) { - throw Error('Accordion trigger can only be used inside an Accordion. Add brnAccordion to ancestor.'); - } - } -} diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion-item.directive.ts b/libs/ui/accordion/brain/src/lib/brn-accordion-item.directive.ts new file mode 100644 index 000000000..f4572aced --- /dev/null +++ b/libs/ui/accordion/brain/src/lib/brn-accordion-item.directive.ts @@ -0,0 +1,24 @@ +import { computed, Directive, inject } from '@angular/core'; +import { BrnAccordionDirective } from './brn-accordion.directive'; + +let itemIdGenerator = 0; + +@Directive({ + selector: '[brnAccordionItem]', + standalone: true, + host: { + '[attr.data-state]': 'state()', + }, +}) +export class BrnAccordionItemDirective { + private readonly _accordion = inject(BrnAccordionDirective); + + public readonly id = itemIdGenerator++; + public readonly state = computed(() => (this._accordion.openItemIds().includes(this.id) ? 'open' : 'closed')); + + constructor() { + if (!this._accordion) { + throw Error('Accordion trigger can only be used inside an Accordion. Add brnAccordion to ancestor.'); + } + } +} diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion-trigger.component.ts b/libs/ui/accordion/brain/src/lib/brn-accordion-trigger.component.ts deleted file mode 100644 index 23aa28e3b..000000000 --- a/libs/ui/accordion/brain/src/lib/brn-accordion-trigger.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component, ElementRef, inject, signal } from '@angular/core'; -import { CustomElementClassSettable, provideCustomClassSettableExisting } from '@spartan-ng/ui-core'; -import { BrnAccordionItemComponent } from './brn-accordion-item.component'; -import { BrnAccordionComponent } from './brn-accordion.component'; - -@Component({ - selector: 'brn-accordion-trigger', - standalone: true, - providers: [provideCustomClassSettableExisting(() => BrnAccordionTriggerComponent)], - host: { - '[attr.data-state]': 'state()', - '[attr.aria-expanded]': 'state() === "open"', - '[attr.aria-controls]': 'ariaControls', - role: 'heading', - 'aria-level': '3', - '[id]': 'id', - }, - template: ` - - `, -}) -export class BrnAccordionTriggerComponent implements CustomElementClassSettable { - private _accordion = inject(BrnAccordionComponent); - private _item = inject(BrnAccordionItemComponent); - private _elementRef = inject(ElementRef); - - public state = this._item.state; - public id = 'brn-accordion-trigger-' + this._item.id; - public ariaControls = 'brn-accordion-content-' + this._item.id; - - private readonly _btnClass = signal(''); - public btnClass = this._btnClass.asReadonly(); - - constructor() { - if (!this._accordion) { - throw Error('Accordion trigger can only be used inside an Accordion. Add brnAccordion to ancestor.'); - } - - if (!this._item) { - throw Error('Accordion trigger can only be used inside an AccordionItem. Add brnAccordionItem to parent.'); - } - } - - public setClassToCustomElement(classes: string) { - this._btnClass.set(classes); - } - - public focus() { - this._elementRef.nativeElement.focus(); - } - - protected toggleAccordionItem() { - this._accordion.toggleItem(this._item.id); - } -} diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion-trigger.directive.ts b/libs/ui/accordion/brain/src/lib/brn-accordion-trigger.directive.ts new file mode 100644 index 000000000..8018d736f --- /dev/null +++ b/libs/ui/accordion/brain/src/lib/brn-accordion-trigger.directive.ts @@ -0,0 +1,52 @@ +import { Directive, ElementRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { rxHostPressedListener } from '@spartan-ng/ui-core'; +import { fromEvent } from 'rxjs'; +import { BrnAccordionItemDirective } from './brn-accordion-item.directive'; +import { BrnAccordionDirective } from './brn-accordion.directive'; + +@Directive({ + selector: '[brnAccordionTrigger]', + standalone: true, + host: { + '[attr.data-state]': 'state()', + '[attr.aria-expanded]': 'state() === "open"', + '[attr.aria-controls]': 'ariaControls', + role: 'heading', + 'aria-level': '3', + '[id]': 'id', + }, +}) +export class BrnAccordionTriggerDirective { + private readonly _accordion = inject(BrnAccordionDirective); + private readonly _item = inject(BrnAccordionItemDirective); + private readonly _elementRef = inject(ElementRef); + private readonly _hostPressedListener = rxHostPressedListener(); + + public readonly state = this._item.state; + public readonly id = 'brn-accordion-trigger-' + this._item.id; + public readonly ariaControls = 'brn-accordion-content-' + this._item.id; + + constructor() { + if (!this._accordion) { + throw Error('Accordion trigger can only be used inside an Accordion. Add brnAccordion to ancestor.'); + } + + if (!this._item) { + throw Error('Accordion trigger can only be used inside an AccordionItem. Add brnAccordionItem to parent.'); + } + this._hostPressedListener.subscribe(() => { + this._accordion.toggleItem(this._item.id); + }); + + fromEvent(this._elementRef.nativeElement, 'focus') + .pipe(takeUntilDestroyed()) + .subscribe(() => { + this._accordion.setActiveItem(this._item.id); + }); + } + + public focus() { + this._elementRef.nativeElement.focus(); + } +} diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion.component.ts b/libs/ui/accordion/brain/src/lib/brn-accordion.component.ts deleted file mode 100644 index 2b44486df..000000000 --- a/libs/ui/accordion/brain/src/lib/brn-accordion.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { FocusKeyManager } from '@angular/cdk/a11y'; -import { AfterContentInit, Component, computed, ContentChildren, Input, QueryList, signal } from '@angular/core'; -import { rxHostListener } from '@spartan-ng/ui-core'; -import { BrnAccordionTriggerComponent } from './brn-accordion-trigger.component'; - -@Component({ - selector: 'brn-accordion', - standalone: true, - host: { - '[attr.data-state]': 'state()', - '[attr.data-orientation]': 'orientation', - }, - template: ` - - `, -}) -export class BrnAccordionComponent implements AfterContentInit { - @Input() - public type: 'single' | 'multiple' = 'single'; - @Input() - public dir: 'ltr' | 'rtl' | null = null; - @Input() - public orientation: 'horizontal' | 'vertical' = 'horizontal'; - - private readonly _openItemIds = signal([]); - public openItemIds = this._openItemIds.asReadonly(); - public state = computed(() => (this._openItemIds().length > 0 ? 'open' : 'closed')); - private _keyManager?: FocusKeyManager; - private _keyDownListener = rxHostListener('keydown'); - - @ContentChildren(BrnAccordionTriggerComponent, { descendants: true }) - public triggers?: QueryList; - - public ngAfterContentInit() { - if (!this.triggers) { - return; - } - this._keyManager = new FocusKeyManager(this.triggers) - .withHorizontalOrientation(this.dir) - .withHomeAndEnd() - .withPageUpDown() - .withWrap(); - - if (this.orientation === 'vertical') { - this._keyManager.withVerticalOrientation(); - } - this._keyDownListener.subscribe((event) => { - this._keyManager?.onKeydown(event as KeyboardEvent); - }); - } - - public toggleItem(id: number) { - if (this._openItemIds().includes(id)) { - this._openItemIds.update((ids) => ids.filter((openId) => id !== openId)); - return; - } else if (this.type === 'single') { - this._openItemIds.set([]); - } - this._openItemIds.update((ids) => [...ids, id]); - } -} diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion.directive.spec.ts b/libs/ui/accordion/brain/src/lib/brn-accordion.directive.spec.ts new file mode 100644 index 000000000..a9c0b069d --- /dev/null +++ b/libs/ui/accordion/brain/src/lib/brn-accordion.directive.spec.ts @@ -0,0 +1,154 @@ +import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { BrnAccordionItemDirective } from './brn-accordion-item.directive'; +import { BrnAccordionTriggerDirective } from './brn-accordion-trigger.directive'; +import { BrnAccordionDirective } from './brn-accordion.directive'; + +describe('BrnAccordionDirective', () => { + const setup = async () => { + const container = await render( + ` +
+
+ + asdf +
+
+ + Yes. It comes with default styles that match the other components' aesthetics. +
+
+ + Yes. It's animated by default, but you can disable it if you prefer. +
+
+ `, + { + imports: [BrnAccordionDirective, BrnAccordionItemDirective, BrnAccordionTriggerDirective], + }, + ); + return { + user: userEvent.setup(), + container, + triggers: screen.getAllByLabelText('trigger'), + accordion: screen.getByLabelText('acco'), + }; + }; + const setupMulti = async () => { + const container = await render( + ` +
+
+ + asdf +
+
+ + Yes. It comes with default styles that match the other components' aesthetics. +
+
+ + Yes. It's animated by default, but you can disable it if you prefer. +
+
+ `, + { + imports: [BrnAccordionDirective, BrnAccordionItemDirective, BrnAccordionTriggerDirective], + }, + ); + return { + user: userEvent.setup(), + container, + triggers: screen.getAllByLabelText('trigger'), + accordion: screen.getByLabelText('acco'), + }; + }; + const validateOpenClosed = async (triggers: HTMLElement[], accordion: HTMLElement, openedTriggers: boolean[]) => { + await waitFor(() => { + expect(triggers[0]).toHaveAttribute('data-state', openedTriggers[0] ? 'open' : 'closed'); + expect(triggers[1]).toHaveAttribute('data-state', openedTriggers[1] ? 'open' : 'closed'); + expect(triggers[2]).toHaveAttribute('data-state', openedTriggers[2] ? 'open' : 'closed'); + const anyOpen = openedTriggers.some((t) => t); + expect(accordion).toHaveAttribute('data-state', anyOpen ? 'open' : 'closed'); + }); + }; + + describe('single accordion', () => { + it('initial state all datastate closed', async () => { + const { triggers, accordion } = await setup(); + await validateOpenClosed(triggers, accordion, [false, false, false]); + expect(accordion).toHaveAttribute('data-orientation', 'vertical'); + }); + it('should open the trigger on click ', async () => { + const { user, triggers, accordion } = await setup(); + await user.click(triggers[0]); + await validateOpenClosed(triggers, accordion, [true, false, false]); + await user.click(triggers[1]); + await validateOpenClosed(triggers, accordion, [false, true, false]); + await user.click(triggers[1]); + await validateOpenClosed(triggers, accordion, [false, false, false]); + await user.click(triggers[2]); + await validateOpenClosed(triggers, accordion, [false, false, true]); + await user.click(triggers[1]); + await validateOpenClosed(triggers, accordion, [false, true, false]); + }); + it('should open the trigger on enter and space ', async () => { + const { user, triggers, accordion } = await setup(); + await user.keyboard('[Tab][Enter]'); + await validateOpenClosed(triggers, accordion, [true, false, false]); + await user.keyboard('[Tab][Enter]'); + await validateOpenClosed(triggers, accordion, [false, true, false]); + await user.keyboard('[Space]'); + await validateOpenClosed(triggers, accordion, [false, false, false]); + await user.keyboard('[Tab][Enter]'); + await validateOpenClosed(triggers, accordion, [false, false, true]); + await user.keyboard('{Shift>}[Tab]{/Shift}[Space]'); + await validateOpenClosed(triggers, accordion, [false, true, false]); + }); + it('should open the trigger on enter and space and prevent default for enter also on second entry', async () => { + const { user, accordion } = await setup(); + const keyboardEventEnter = createEvent.keyDown(accordion, { + key: 'Enter', + code: 'Enter', + which: 13, + keyCode: 13, + }); + await user.keyboard('[Tab][Tab]'); + fireEvent(accordion, keyboardEventEnter); + expect(keyboardEventEnter.defaultPrevented).toBe(true); + }); + }); + describe('multi accordion', () => { + it('initial state all datastate closed', async () => { + const { triggers, accordion } = await setupMulti(); + await validateOpenClosed(triggers, accordion, [false, false, false]); + expect(accordion).toHaveAttribute('data-orientation', 'horizontal'); + }); + it('should open the trigger on click ', async () => { + const { user, triggers, accordion } = await setupMulti(); + + await user.click(triggers[0]); + await validateOpenClosed(triggers, accordion, [true, false, false]); + await user.click(triggers[1]); + await validateOpenClosed(triggers, accordion, [true, true, false]); + await user.click(triggers[1]); + await validateOpenClosed(triggers, accordion, [true, false, false]); + await user.click(triggers[2]); + await validateOpenClosed(triggers, accordion, [true, false, true]); + await user.click(triggers[1]); + await validateOpenClosed(triggers, accordion, [true, true, true]); + }); + }); +}); diff --git a/libs/ui/accordion/brain/src/lib/brn-accordion.directive.ts b/libs/ui/accordion/brain/src/lib/brn-accordion.directive.ts new file mode 100644 index 000000000..24fe884cd --- /dev/null +++ b/libs/ui/accordion/brain/src/lib/brn-accordion.directive.ts @@ -0,0 +1,96 @@ +import { FocusKeyManager } from '@angular/cdk/a11y'; +import { + AfterContentInit, + computed, + ContentChildren, + Directive, + ElementRef, + inject, + Input, + QueryList, + signal, +} from '@angular/core'; +import { BrnAccordionTriggerDirective } from './brn-accordion-trigger.directive'; + +const HORIZONTAL_KEYS_TO_PREVENT_DEFAULT = [ + 'ArrowLeft', + 'ArrowRight', + 'PageDown', + 'PageUp', + 'Home', + 'End', + ' ', + 'Enter', +]; +const VERTICAL_KEYS_TO_PREVENT_DEFAULT = ['ArrowUp', 'ArrowDown', 'PageDown', 'PageUp', 'Home', 'End', ' ', 'Enter']; + +@Directive({ + selector: '[brnAccordion]', + standalone: true, + host: { + '[attr.data-state]': 'state()', + '[attr.data-orientation]': 'orientation', + }, +}) +export class BrnAccordionDirective implements AfterContentInit { + private readonly _el = inject(ElementRef); + private _keyManager?: FocusKeyManager; + + private readonly _openItemIds = signal([]); + public readonly openItemIds = this._openItemIds.asReadonly(); + public readonly state = computed(() => (this._openItemIds().length > 0 ? 'open' : 'closed')); + + @ContentChildren(BrnAccordionTriggerDirective, { descendants: true }) + public triggers?: QueryList; + + @Input() + public type: 'single' | 'multiple' = 'single'; + @Input() + public dir: 'ltr' | 'rtl' | null = null; + @Input() + public orientation: 'horizontal' | 'vertical' = 'vertical'; + + public ngAfterContentInit() { + if (!this.triggers) { + return; + } + this._keyManager = new FocusKeyManager(this.triggers) + .withHomeAndEnd() + .withPageUpDown() + .withWrap(); + + if (this.orientation === 'horizontal') { + this._keyManager.withHorizontalOrientation(this.dir ?? 'ltr').withVerticalOrientation(false); + } + this._el.nativeElement.addEventListener('keydown', (event: KeyboardEvent) => { + this._keyManager?.onKeydown(event as KeyboardEvent); + this.preventDefaultEvents(event as KeyboardEvent); + }); + } + + public setActiveItem(id: number) { + this._keyManager?.setActiveItem(id); + } + + public toggleItem(id: number) { + if (this._openItemIds().includes(id)) { + this._openItemIds.update((ids) => ids.filter((openId) => id !== openId)); + return; + } else if (this.type === 'single') { + this._openItemIds.set([]); + } + this._openItemIds.update((ids) => [...ids, id]); + } + + private preventDefaultEvents(event: KeyboardEvent) { + const trigger = this.triggers?.find((trigger) => trigger.id === document.activeElement?.id); + if (!trigger) return; + if (!('key' in event)) return; + + const keys = + this.orientation === 'horizontal' ? HORIZONTAL_KEYS_TO_PREVENT_DEFAULT : VERTICAL_KEYS_TO_PREVENT_DEFAULT; + if (keys.includes(event.key as string) && event.code !== 'NumpadEnter') { + event.preventDefault(); + } + } +} diff --git a/libs/ui/accordion/brain/src/test-setup.ts b/libs/ui/accordion/brain/src/test-setup.ts index b2dd6e939..9136c4aec 100644 --- a/libs/ui/accordion/brain/src/test-setup.ts +++ b/libs/ui/accordion/brain/src/test-setup.ts @@ -5,4 +5,9 @@ globalThis.ngJest = { errorOnUnknownProperties: true, }, }; +import '@testing-library/jest-dom'; import 'jest-preset-angular/setup-jest'; + +import { toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); diff --git a/libs/ui/accordion/helm/src/index.ts b/libs/ui/accordion/helm/src/index.ts index b2fb77368..ee64d220b 100644 --- a/libs/ui/accordion/helm/src/index.ts +++ b/libs/ui/accordion/helm/src/index.ts @@ -1,13 +1,13 @@ import { NgModule } from '@angular/core'; import { HlmAccordionContentDirective } from './lib/hlm-accordion-content.directive'; -import { HlmAccordionIconComponent } from './lib/hlm-accordion-icon.component'; +import { HlmAccordionIconDirective } from './lib/hlm-accordion-icon.directive'; import { HlmAccordionItemDirective } from './lib/hlm-accordion-item.directive'; import { HlmAccordionTriggerDirective } from './lib/hlm-accordion-trigger.directive'; import { HlmAccordionDirective } from './lib/hlm-accordion.directive'; export * from './lib/hlm-accordion-content.directive'; -export * from './lib/hlm-accordion-icon.component'; +export * from './lib/hlm-accordion-icon.directive'; export * from './lib/hlm-accordion-item.directive'; export * from './lib/hlm-accordion-trigger.directive'; export * from './lib/hlm-accordion.directive'; @@ -17,7 +17,7 @@ export const HlmAccordionImports = [ HlmAccordionItemDirective, HlmAccordionTriggerDirective, HlmAccordionContentDirective, - HlmAccordionIconComponent, + HlmAccordionIconDirective, ] as const; @NgModule({ diff --git a/libs/ui/accordion/helm/src/lib/hlm-accordion-content.directive.ts b/libs/ui/accordion/helm/src/lib/hlm-accordion-content.directive.ts index 2c67260cb..e315bdaad 100644 --- a/libs/ui/accordion/helm/src/lib/hlm-accordion-content.directive.ts +++ b/libs/ui/accordion/helm/src/lib/hlm-accordion-content.directive.ts @@ -1,85 +1,30 @@ -import { isPlatformBrowser } from '@angular/common'; -import { - computed, - Directive, - effect, - ElementRef, - inject, - Injector, - Input, - OnInit, - PLATFORM_ID, - signal, -} from '@angular/core'; -import { hlm, injectCustomClassSettable } from '@spartan-ng/ui-core'; +import { computed, Directive, inject, Input, signal } from '@angular/core'; +import { BrnAccordionContentComponent } from '@spartan-ng/ui-accordion-brain'; +import { hlm } from '@spartan-ng/ui-core'; import { ClassValue } from 'clsx'; @Directive({ selector: '[hlmAccordionContent],brn-accordion-content[hlm]', standalone: true, host: { - '[style.height]': 'cssHeight()', '[class]': '_computedClass()', }, }) -export class HlmAccordionContentDirective implements OnInit { - private readonly _host = injectCustomClassSettable({ optional: true }); - private readonly _element = inject(ElementRef).nativeElement; - private readonly _injector = inject(Injector); - private readonly _platformId = inject(PLATFORM_ID); - - private _changes?: MutationObserver; - - public readonly height = signal('-1'); - public readonly cssHeight = computed(() => (this.height() === '-1' ? 'auto' : this.height())); - public readonly state = signal('closed'); +export class HlmAccordionContentDirective { + private readonly _brn = inject(BrnAccordionContentComponent, { optional: true }); private readonly _userCls = signal(''); + protected readonly _computedClass = computed(() => { + const gridRows = this._brn?.state() === 'open' ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'; + return hlm('text-sm transition-all grid', gridRows, this._userCls()); + }); + @Input() set class(userCls: ClassValue) { this._userCls.set(userCls); } - protected _computedClass = computed(() => this._generateClass()); - private _generateClass() { - return hlm('overflow-hidden text-sm transition-all', this._userCls()); - } - - public ngOnInit() { - this._host?.setClassToCustomElement('pt-1 pb-4'); - - if (isPlatformBrowser(this._platformId)) { - this._changes = new MutationObserver((mutations: MutationRecord[]) => { - mutations.forEach((mutation: MutationRecord) => { - if (mutation.attributeName !== 'data-state') return; - // eslint-disable-next-line - const state = (mutation.target as any).attributes.getNamedItem(mutation.attributeName)?.value; - this.state.set(state); - }); - }); - } - - Promise.resolve().then(() => { - this._changes?.observe(this._element, { - attributes: true, - childList: true, - characterData: true, - }); - }); - - effect( - () => { - const isOpen = this.state() === 'open'; - Promise.resolve().then(() => { - this.height.set( - isOpen ? getComputedStyle(this._element).getPropertyValue('--brn-collapsible-content-height') : '0px', - ); - }); - }, - { - injector: this._injector, - allowSignalWrites: true, - }, - ); + constructor() { + this._brn?.setClassToCustomElement('pt-1 pb-4'); } } diff --git a/libs/ui/accordion/helm/src/lib/hlm-accordion-icon.component.ts b/libs/ui/accordion/helm/src/lib/hlm-accordion-icon.component.ts deleted file mode 100644 index 3f820d686..000000000 --- a/libs/ui/accordion/helm/src/lib/hlm-accordion-icon.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component, Input, computed, signal } from '@angular/core'; -import { hlm } from '@spartan-ng/ui-core'; -import { ClassValue } from 'clsx'; - -@Component({ - selector: 'hlm-accordion-icon', - standalone: true, - template: ` - - - - `, - host: { - '[class]': '_computedClass()', - }, -}) -export class HlmAccordionIconComponent { - private readonly _userCls = signal(''); - @Input() - set class(userCls: ClassValue) { - this._userCls.set(userCls); - } - - protected _computedClass = computed(() => this._generateClass()); - private _generateClass() { - return hlm('inline-block h-4 w-4 transition-transform duration-200', this._userCls()); - } -} diff --git a/libs/ui/accordion/helm/src/lib/hlm-accordion-icon.directive.ts b/libs/ui/accordion/helm/src/lib/hlm-accordion-icon.directive.ts new file mode 100644 index 000000000..8d720635a --- /dev/null +++ b/libs/ui/accordion/helm/src/lib/hlm-accordion-icon.directive.ts @@ -0,0 +1,31 @@ +import { computed, Directive, inject, Input, signal } from '@angular/core'; +import { radixChevronDown } from '@ng-icons/radix-icons'; +import { hlm } from '@spartan-ng/ui-core'; +import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm'; +import { ClassValue } from 'clsx'; + +@Directive({ + selector: 'hlm-icon[hlmAccordionIcon], hlm-icon[hlmAccIcon]', + standalone: true, + providers: [provideIcons({ radixChevronDown })], + host: { + '[class]': '_computedClass()', + }, +}) +export class HlmAccordionIconDirective { + private readonly _hlmIcon = inject(HlmIconComponent); + + private readonly _userCls = signal(''); + protected _computedClass = computed(() => + hlm('inline-block h-4 w-4 transition-transform duration-200', this._userCls()), + ); + + @Input() + set class(userCls: ClassValue) { + this._userCls.set(userCls); + } + + constructor() { + this._hlmIcon.name = 'radixChevronDown'; + } +} diff --git a/libs/ui/accordion/helm/src/lib/hlm-accordion-item.directive.ts b/libs/ui/accordion/helm/src/lib/hlm-accordion-item.directive.ts index c8e100c20..02d8880af 100644 --- a/libs/ui/accordion/helm/src/lib/hlm-accordion-item.directive.ts +++ b/libs/ui/accordion/helm/src/lib/hlm-accordion-item.directive.ts @@ -1,4 +1,5 @@ -import { Directive, Input, computed, signal } from '@angular/core'; +import { computed, Directive, Input, signal } from '@angular/core'; +import { BrnAccordionItemDirective } from '@spartan-ng/ui-accordion-brain'; import { hlm } from '@spartan-ng/ui-core'; import { ClassValue } from 'clsx'; @@ -8,16 +9,16 @@ import { ClassValue } from 'clsx'; host: { '[class]': '_computedClass()', }, + hostDirectives: [BrnAccordionItemDirective], }) export class HlmAccordionItemDirective { private readonly _userCls = signal(''); + protected readonly _computedClass = computed(() => + hlm('flex flex-1 flex-col border-b border-border', this._userCls()), + ); + @Input() set class(userCls: ClassValue) { this._userCls.set(userCls); } - - protected _computedClass = computed(() => this._generateClass()); - private _generateClass() { - return hlm('flex flex-1 flex-col border-b border-border', this._userCls()); - } } diff --git a/libs/ui/accordion/helm/src/lib/hlm-accordion-trigger.directive.ts b/libs/ui/accordion/helm/src/lib/hlm-accordion-trigger.directive.ts index 57fb4ed8e..74feb13e2 100644 --- a/libs/ui/accordion/helm/src/lib/hlm-accordion-trigger.directive.ts +++ b/libs/ui/accordion/helm/src/lib/hlm-accordion-trigger.directive.ts @@ -1,39 +1,28 @@ -import { Directive, Input, computed, signal } from '@angular/core'; -import { hlm, injectCustomClassSettable } from '@spartan-ng/ui-core'; +import { computed, Directive, Input, signal } from '@angular/core'; +import { BrnAccordionTriggerDirective } from '@spartan-ng/ui-accordion-brain'; +import { hlm } from '@spartan-ng/ui-core'; import { ClassValue } from 'clsx'; @Directive({ - selector: '[hlmAccordionTrigger],brn-accordion-trigger[hlm]', + selector: '[hlmAccordionTrigger]', standalone: true, host: { '[style.--tw-ring-offset-shadow]': '"0 0 #000"', '[class]': '_computedClass()', }, + hostDirectives: [BrnAccordionTriggerDirective], }) export class HlmAccordionTriggerDirective { - private _host = injectCustomClassSettable({ optional: true }); - - constructor() { - this._host?.setClassToCustomElement(this._generateClass()); - } - private readonly _userCls = signal(''); + protected _computedClass = computed(() => + hlm( + 'w-full focus-visible:outline-none text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 flex flex-1 items-center justify-between py-4 px-0.5 font-medium underline-offset-4 hover:underline [&[data-state=open]>[hlmAccordionIcon]]:rotate-180 [&[data-state=open]>[hlmAccIcon]]:rotate-180', + this._userCls(), + ), + ); + @Input() set class(inputs: ClassValue) { this._userCls.set(inputs); - // cannot set in effect because it sets a signal - if (this._host) { - this._host.setClassToCustomElement(this._generateClass()); - } - } - - protected _computedClass = computed(() => { - return !this._host ? this._generateClass() : ''; - }); - private _generateClass() { - return hlm( - 'w-full focus-visible:outline-none text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 flex flex-1 items-center justify-between py-4 px-0.5 font-medium underline-offset-4 hover:underline [&[data-state=open]>hlm-accordion-icon]:rotate-180', - this._userCls(), - ); } } diff --git a/libs/ui/accordion/helm/src/lib/hlm-accordion.directive.ts b/libs/ui/accordion/helm/src/lib/hlm-accordion.directive.ts index cce6cfa7b..cddca8ce2 100644 --- a/libs/ui/accordion/helm/src/lib/hlm-accordion.directive.ts +++ b/libs/ui/accordion/helm/src/lib/hlm-accordion.directive.ts @@ -1,23 +1,26 @@ -import { Directive, Input, computed, signal } from '@angular/core'; +import { computed, Directive, inject, Input, signal } from '@angular/core'; +import { BrnAccordionDirective } from '@spartan-ng/ui-accordion-brain'; import { hlm } from '@spartan-ng/ui-core'; import { ClassValue } from 'clsx'; @Directive({ - selector: '[hlmAccordion],brn-accordion[hlm]', + selector: '[hlmAccordion]', standalone: true, host: { '[class]': '_computedClass()', }, + hostDirectives: [BrnAccordionDirective], }) export class HlmAccordionDirective { + private readonly _brn = inject(BrnAccordionDirective); + private readonly _userCls = signal(''); + protected readonly _computedClass = computed(() => + hlm('flex', this._brn.orientation === 'horizontal' ? 'flex-row' : 'flex-col', this._userCls()), + ); + @Input() set class(userCls: ClassValue) { this._userCls.set(userCls); } - - protected _computedClass = computed(() => this._generateClass()); - private _generateClass() { - return hlm('flex flex-col', this._userCls()); - } } diff --git a/libs/ui/icon/helm/src/lib/hlm-icon.component.ts b/libs/ui/icon/helm/src/lib/hlm-icon.component.ts index 3a7c272c7..f13a207c8 100644 --- a/libs/ui/icon/helm/src/lib/hlm-icon.component.ts +++ b/libs/ui/icon/helm/src/lib/hlm-icon.component.ts @@ -1,4 +1,16 @@ -import { ChangeDetectionStrategy, Component, computed, Input, signal, ViewEncapsulation } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + Input, + OnDestroy, + PLATFORM_ID, + signal, + ViewEncapsulation, +} from '@angular/core'; import { IconName, NgIconComponent } from '@ng-icons/core'; import { hlm } from '@spartan-ng/ui-core'; import { cva } from 'class-variance-authority'; @@ -30,6 +42,8 @@ const isDefinedSize = (size: IconSize): size is DefinedSizes => { return DEFINED_SIZES.includes(size as DefinedSizes); }; +const TAILWIND_H_W_PATTERN = /\b(h-\d+|w-\d+)\b/g; + @Component({ selector: 'hlm-icon', standalone: true, @@ -49,7 +63,14 @@ const isDefinedSize = (size: IconSize): size is DefinedSizes => { '[class]': '_computedClass()', }, }) -export class HlmIconComponent { +export class HlmIconComponent implements OnDestroy { + private readonly _host = inject(ElementRef); + private readonly _platformId = inject(PLATFORM_ID); + + private _mutObs?: MutationObserver; + + private readonly _hostClasses = signal(''); + protected readonly _name = signal(''); protected readonly _size = signal('base'); protected readonly _color = signal(undefined); @@ -61,9 +82,33 @@ export class HlmIconComponent { protected readonly _computedClass = computed(() => { const size: IconSize = this._size(); const variant = isDefinedSize(size) ? size : 'none'; - return hlm(iconVariants({ variant }), this.userCls()); + const hostClasses = + variant === 'none' ? this._hostClasses().replace(TAILWIND_H_W_PATTERN, '') : this._hostClasses(); + + return hlm(iconVariants({ variant }), this.userCls(), hostClasses); }); + constructor() { + if (isPlatformBrowser(this._platformId)) { + this._mutObs = new MutationObserver((mutations: MutationRecord[]) => { + mutations.forEach((mutation: MutationRecord) => { + if (mutation.attributeName !== 'class') return; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + this._hostClasses.set(mutation.target?.['className'] ?? ''); + }); + }); + this._mutObs.observe(this._host.nativeElement, { + attributes: true, + }); + } + } + + ngOnDestroy() { + this._mutObs?.disconnect(); + this._mutObs = undefined; + } + @Input({ required: true }) set name(value: IconName | string) { this._name.set(value);