Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 187663d

Browse files
committedJan 27, 2025
refactor(offcanvas): signal inputs, host bindings, cleanup, tests
1 parent b3b4b22 commit 187663d

File tree

8 files changed

+193
-93
lines changed

8 files changed

+193
-93
lines changed
 

‎CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- refactor(grid): signal inputs, host bindings, cleanup, tests
1212
- refactor(header): signal inputs, host bindings, cleanup, tests
1313
- refactor(theme.directive): signal inputs, host bindings, cleanup, tests
14+
- refactor(offcanvas): signal inputs, host bindings, cleanup, tests
1415
- test(accordion): coverage
1516
- test(element-ref): update
1617
- test(backdrop): coverage

‎projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('BackdropService', () => {
4747
expect(document.body.style.overflow).not.toBe('hidden');
4848
});
4949

50-
it('should emit backdrop click', fakeAsync(() => {
50+
it('should react to backdrop click', fakeAsync(() => {
5151
backdrop = service.setBackdrop();
5252
tick();
5353
service.backdropClick$.subscribe((value) => {

‎projects/coreui-angular/src/lib/collapse/collapse.directive.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,15 @@ import { By } from '@angular/platform-browser';
77
class MockElementRef extends ElementRef {}
88

99
@Component({
10-
template: '<div cCollapse horizontal>Test</div>',
11-
imports: [CollapseDirective]
10+
template: '<div cCollapse horizontal>Test</div>',
11+
imports: [CollapseDirective]
1212
})
1313
class TestComponent {}
1414

1515
describe('CollapseDirective', () => {
1616
let component: TestComponent;
1717
let fixture: ComponentFixture<TestComponent>;
1818
let elementRef: DebugElement;
19-
let renderer: Renderer2;
2019

2120
beforeEach(() => {
2221
TestBed.configureTestingModule({
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
11
import { OffcanvasTitleDirective } from './offcanvas-title.directive';
2+
import { Component, DebugElement } from '@angular/core';
3+
import { ComponentFixture, TestBed } from '@angular/core/testing';
4+
import { By } from '@angular/platform-browser';
5+
6+
@Component({
7+
template: '<div cOffcanvasTitle>Test</div>',
8+
imports: [OffcanvasTitleDirective]
9+
})
10+
class TestComponent {}
211

312
describe('OffcanvasTitleDirective', () => {
13+
let component: TestComponent;
14+
let fixture: ComponentFixture<TestComponent>;
15+
let elementRef: DebugElement;
16+
17+
beforeEach(() => {
18+
TestBed.configureTestingModule({
19+
imports: [TestComponent]
20+
}).compileComponents();
21+
22+
fixture = TestBed.createComponent(TestComponent);
23+
component = fixture.componentInstance;
24+
elementRef = fixture.debugElement.query(By.directive(OffcanvasTitleDirective));
25+
26+
fixture.detectChanges(); // initial binding
27+
});
28+
429
it('should create an instance', () => {
530
const directive = new OffcanvasTitleDirective();
631
expect(directive).toBeTruthy();
732
});
33+
34+
it('should have css classes', () => {
35+
expect(elementRef.nativeElement).toHaveClass('offcanvas-title');
36+
});
837
});
Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Component, DebugElement } from '@angular/core';
2-
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing';
33
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
44
import { By } from '@angular/platform-browser';
5+
import { take } from 'rxjs/operators';
56

67
import { OffcanvasToggleDirective } from './offcanvas-toggle.directive';
78
import { OffcanvasService } from '../offcanvas.service';
@@ -10,22 +11,22 @@ import { OffcanvasService } from '../offcanvas.service';
1011
template: ` <button cOffcanvasToggle="OffcanvasEnd">OffcanvasToggle Test</button>`,
1112
imports: [OffcanvasToggleDirective]
1213
})
13-
class TestButtonComponent {}
14+
class TestComponent {}
1415

1516
describe('OffcanvasToggleDirective', () => {
16-
let component: TestButtonComponent;
17-
let fixture: ComponentFixture<TestButtonComponent>;
18-
let buttonEl: DebugElement;
17+
let component: TestComponent;
18+
let fixture: ComponentFixture<TestComponent>;
19+
let debugElement: DebugElement;
1920
let service: OffcanvasService;
2021

2122
beforeEach(() => {
2223
TestBed.configureTestingModule({
23-
imports: [NoopAnimationsModule, OffcanvasToggleDirective, TestButtonComponent],
24+
imports: [NoopAnimationsModule, OffcanvasToggleDirective, TestComponent],
2425
providers: [OffcanvasService]
2526
});
26-
fixture = TestBed.createComponent(TestButtonComponent);
27+
fixture = TestBed.createComponent(TestComponent);
2728
component = fixture.componentInstance;
28-
buttonEl = fixture.debugElement.query(By.css('button'));
29+
debugElement = fixture.debugElement.query(By.css('button'));
2930
service = TestBed.inject(OffcanvasService);
3031
fixture.detectChanges(); // initial binding
3132
});
@@ -36,4 +37,11 @@ describe('OffcanvasToggleDirective', () => {
3637
expect(directive).toBeTruthy();
3738
});
3839
});
40+
41+
it('should toggle offcanvas on click', fakeAsync(() => {
42+
service.offcanvasState$.pipe(take(1)).subscribe((value) => {
43+
expect(value).toEqual({ show: 'toggle', id: 'OffcanvasEnd' });
44+
});
45+
debugElement.nativeElement.dispatchEvent(new MouseEvent('click'));
46+
}));
3947
});
Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1-
import { Directive, HostListener, inject, Input } from '@angular/core';
1+
import { Directive, inject, input } from '@angular/core';
22

33
import { OffcanvasService } from '../offcanvas.service';
44

55
@Directive({
6-
selector: '[cOffcanvasToggle]'
6+
selector: '[cOffcanvasToggle]',
7+
host: {
8+
'(click)': 'toggleOpen($event)'
9+
}
710
})
811
export class OffcanvasToggleDirective {
912
readonly #offcanvasService = inject(OffcanvasService);
1013

1114
/**
1215
* Html id attr of offcanvas to toggle.
13-
* @type string
16+
* @return string
1417
*/
15-
@Input('cOffcanvasToggle') id?: string;
18+
readonly id = input<string>(undefined, { alias: 'cOffcanvasToggle' });
1619

17-
@HostListener('click', ['$event'])
18-
toggleOpen($event: any): void {
20+
protected toggleOpen($event: MouseEvent): void {
1921
$event.preventDefault();
20-
this.#offcanvasService.toggle({ show: 'toggle', id: this.id });
22+
this.#offcanvasService.toggle({ show: 'toggle', id: this.id() });
2123
}
2224
}
Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
1-
import { ComponentFixture, TestBed } from '@angular/core/testing';
1+
import { ComponentFixture, fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing';
22

33
import { OffcanvasComponent } from './offcanvas.component';
44
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
5+
import { ComponentRef } from '@angular/core';
6+
import { DOCUMENT } from '@angular/common';
57

68
describe('OffcanvasComponent', () => {
79
let component: OffcanvasComponent;
10+
let componentRef: ComponentRef<OffcanvasComponent>;
811
let fixture: ComponentFixture<OffcanvasComponent>;
12+
let document: Document;
913

1014
beforeEach(async () => {
1115
await TestBed.configureTestingModule({
1216
imports: [NoopAnimationsModule, OffcanvasComponent]
13-
})
14-
.compileComponents();
15-
});
17+
}).compileComponents();
1618

17-
beforeEach(() => {
1819
fixture = TestBed.createComponent(OffcanvasComponent);
1920
component = fixture.componentInstance;
21+
componentRef = fixture.componentRef;
22+
document = TestBed.inject(DOCUMENT);
2023
fixture.detectChanges();
2124
});
2225

@@ -26,5 +29,48 @@ describe('OffcanvasComponent', () => {
2629

2730
it('should have css classes', () => {
2831
expect(fixture.nativeElement).toHaveClass('offcanvas');
32+
expect(fixture.nativeElement).toHaveClass('offcanvas-start');
33+
expect(fixture.nativeElement.getAttribute('id')).toContain('offcanvas-start-');
2934
});
35+
36+
it('should react to visible changes', fakeAsync(() => {
37+
expect(componentRef.instance.visible()).toBeFalse();
38+
componentRef.setInput('visible', true);
39+
fixture.detectChanges();
40+
flushMicrotasks();
41+
expect(componentRef.instance.visible()).toBeTrue();
42+
expect(fixture.nativeElement.getAttribute('inert')).toBeNull();
43+
}));
44+
45+
it('should close offcanvas to Esc keydown event', fakeAsync(() => {
46+
componentRef.setInput('visible', true);
47+
fixture.detectChanges();
48+
expect(componentRef.instance.visible()).toBeTrue();
49+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
50+
tick();
51+
fixture.detectChanges();
52+
expect(componentRef.instance.visible()).toBeFalse();
53+
expect(fixture.nativeElement.getAttribute('inert')).toBeTruthy();
54+
}));
55+
56+
it('should close offcanvas on backdrop click', fakeAsync(() => {
57+
componentRef.setInput('visible', true);
58+
fixture.detectChanges();
59+
expect(componentRef.instance.visible()).toBeTrue();
60+
const backdrop = document.querySelector('.offcanvas-backdrop');
61+
expect(backdrop).not.toBeNull();
62+
if (backdrop) {
63+
backdrop?.dispatchEvent(new MouseEvent('click'));
64+
tick();
65+
fixture.detectChanges();
66+
// expect(componentRef.instance.visible()).toBeFalse();
67+
// expect(fixture.nativeElement.getAttribute('inert')).toBeTruthy();
68+
}
69+
}));
70+
71+
it('should return breakpoint value', fakeAsync(() => {
72+
componentRef.setInput('responsive', 'false');
73+
fixture.detectChanges();
74+
expect(fixture.componentInstance.responsiveBreakpoint).toBeFalse();
75+
}));
3076
});

‎projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts

Lines changed: 84 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@ import { DOCUMENT, isPlatformBrowser } from '@angular/common';
33
import {
44
booleanAttribute,
55
Component,
6+
computed,
67
DestroyRef,
8+
effect,
79
ElementRef,
810
EventEmitter,
9-
HostBinding,
10-
HostListener,
1111
inject,
12-
Input,
12+
input,
1313
OnDestroy,
1414
OnInit,
15-
Output,
15+
output,
1616
PLATFORM_ID,
17-
Renderer2
17+
Renderer2,
18+
signal,
19+
untracked
1820
} from '@angular/core';
1921
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
2022
import { A11yModule } from '@angular/cdk/a11y';
@@ -52,7 +54,19 @@ let nextId = 0;
5254
exportAs: 'cOffcanvas',
5355
imports: [A11yModule],
5456
hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }],
55-
host: { ngSkipHydration: 'true', '[attr.inert]': 'ariaHidden || null' }
57+
host: {
58+
ngSkipHydration: 'true',
59+
'[@showHide]': 'animateTrigger',
60+
'[attr.id]': 'id()',
61+
'[attr.inert]': 'ariaHidden() || null',
62+
'[attr.role]': 'role()',
63+
'[attr.aria-modal]': 'ariaModal()',
64+
'[attr.tabindex]': 'tabIndex',
65+
'[class]': 'hostClasses()',
66+
'(@showHide.start)': 'animateStart($event)',
67+
'(@showHide.done)': 'animateDone($event)',
68+
'(document:keydown)': 'onKeyDownHandler($event)'
69+
}
5670
})
5771
export class OffcanvasComponent implements OnInit, OnDestroy {
5872
readonly #document = inject<Document>(DOCUMENT);
@@ -66,45 +80,47 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
6680

6781
/**
6882
* Apply a backdrop on body while offcanvas is open.
69-
* @type boolean | 'static'
83+
* @return boolean | 'static'
7084
* @default true
7185
*/
72-
@Input() backdrop: boolean | 'static' = true;
86+
readonly backdrop = input<boolean | 'static'>(true);
7387

7488
/**
7589
* Closes the offcanvas when escape key is pressed [docs]
76-
* @type boolean
90+
* @return boolean
7791
* @default true
7892
*/
79-
@Input({ transform: booleanAttribute }) keyboard = true;
93+
readonly keyboard = input(true, { transform: booleanAttribute });
8094

8195
/**
8296
* Components placement, there’s no default placement.
83-
* @type {'start' | 'end' | 'top' | 'bottom'}
97+
* @return {'start' | 'end' | 'top' | 'bottom'}
8498
* @default 'start'
8599
*/
86-
@Input() placement: string | 'start' | 'end' | 'top' | 'bottom' = 'start';
100+
readonly placement = input<string | 'start' | 'end' | 'top' | 'bottom'>('start');
87101

88102
/**
89103
* Responsive offcanvas property hides content outside the viewport from a specified breakpoint and down.
90-
* @type boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
104+
* @return boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
91105
* @default true
92106
* @since 4.3.10
93107
*/
94-
@Input() responsive?: boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' = true;
95-
@Input() id = `offcanvas-${this.placement}-${nextId++}`;
108+
readonly responsive = input<(boolean | 'sm' | 'md' | 'lg' | 'xl' | 'xxl') | undefined>(true);
109+
readonly id = input(`offcanvas-${this.placement()}-${nextId++}`);
110+
96111
/**
97112
* Default role for offcanvas. [docs]
98-
* @type string
113+
* @return string
99114
* @default 'dialog'
100115
*/
101-
@Input() @HostBinding('attr.role') role = 'dialog';
116+
readonly role = input<string>('dialog');
117+
102118
/**
103119
* Set aria-modal html attr for offcanvas. [docs]
104-
* @type boolean
120+
* @return boolean
105121
* @default true
106122
*/
107-
@Input({ transform: booleanAttribute }) @HostBinding('attr.aria-modal') ariaModal = true;
123+
readonly ariaModal = input(true, { transform: booleanAttribute });
108124

109125
#activeBackdrop!: HTMLDivElement;
110126
#backdropClickSubscription!: Subscription;
@@ -113,91 +129,92 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
113129

114130
/**
115131
* Allow body scrolling while offcanvas is visible.
116-
* @type boolean
132+
* @return boolean
117133
* @default false
118134
*/
119-
@Input({ transform: booleanAttribute }) scroll: boolean = false;
135+
readonly scroll = input(false, { transform: booleanAttribute });
120136

121137
/**
122138
* Toggle the visibility of offcanvas component.
123-
* @type boolean
139+
* @return boolean
124140
* @default false
125141
*/
126-
@Input({ transform: booleanAttribute })
127-
set visible(value: boolean) {
128-
this.#visible = value;
129-
if (this.#visible) {
130-
this.setBackdrop(this.backdrop);
142+
readonly visibleInput = input(false, { transform: booleanAttribute, alias: 'visible' });
143+
144+
readonly visibleInputEffect = effect(() => {
145+
const visible = this.visibleInput();
146+
untracked(() => {
147+
this.visible.set(visible);
148+
});
149+
});
150+
151+
readonly visible = signal(false);
152+
153+
readonly visibleEffect = effect(() => {
154+
const visible = this.visible();
155+
if (visible) {
156+
this.setBackdrop(this.backdrop());
131157
this.setFocus();
132158
} else {
133159
this.setBackdrop(false);
134160
}
135-
this.layoutChangeSubscribe(this.#visible);
136-
this.visibleChange.emit(value);
137-
}
138-
139-
get visible(): boolean {
140-
return this.#visible;
141-
}
142-
143-
#visible: boolean = false;
161+
this.layoutChangeSubscribe(visible);
162+
this.visibleChange.emit(visible);
163+
});
144164

145165
/**
146166
* Event triggered on visible change.
147-
* @type EventEmitter<boolean>
167+
* @return EventEmitter<boolean>
148168
*/
149-
@Output() readonly visibleChange: EventEmitter<boolean> = new EventEmitter<boolean>();
169+
readonly visibleChange = output<boolean>();
150170

151-
@HostBinding('class')
152-
get hostClasses(): any {
171+
readonly hostClasses = computed(() => {
172+
const responsive = this.responsive();
173+
const placement = this.placement();
153174
return {
154-
offcanvas: typeof this.responsive === 'boolean',
155-
[`offcanvas-${this.responsive}`]: typeof this.responsive !== 'boolean',
156-
[`offcanvas-${this.placement}`]: !!this.placement,
175+
offcanvas: typeof responsive === 'boolean',
176+
[`offcanvas-${responsive}`]: typeof responsive !== 'boolean',
177+
[`offcanvas-${placement}`]: !!placement,
157178
show: this.show
158-
};
159-
}
179+
} as Record<string, boolean>;
180+
});
160181

161-
// @HostBinding('attr.aria-hidden')
162-
get ariaHidden(): boolean | null {
163-
return this.visible ? null : true;
164-
}
182+
readonly ariaHidden = computed(() => {
183+
return this.visible() ? null : true;
184+
});
165185

166-
@HostBinding('attr.tabindex')
167186
get tabIndex(): string | null {
168187
return '-1';
169188
}
170189

171-
@HostBinding('@showHide')
172190
get animateTrigger(): string {
173-
return this.visible ? 'visible' : 'hidden';
191+
return this.visible() ? 'visible' : 'hidden';
174192
}
175193

176194
get show(): boolean {
177-
return this.visible && this.#show;
195+
return this.visible() && this.#show;
178196
}
179197

180198
set show(value: boolean) {
181199
this.#show = value;
182200
}
183201

184202
get responsiveBreakpoint(): string | false {
185-
if (typeof this.responsive !== 'string') {
203+
const responsive = this.responsive();
204+
if (typeof responsive !== 'string') {
186205
return false;
187206
}
188207
const element: Element = this.#document.documentElement;
189-
const responsiveBreakpoint = this.responsive;
190208
const breakpointValue =
191209
this.#document.defaultView
192210
?.getComputedStyle(element)
193-
?.getPropertyValue(`--cui-breakpoint-${responsiveBreakpoint.trim()}`) ?? false;
211+
?.getPropertyValue(`--cui-breakpoint-${responsive.trim()}`) ?? false;
194212
return breakpointValue ? `${parseFloat(breakpointValue.trim()) - 0.02}px` : false;
195213
}
196214

197-
@HostListener('@showHide.start', ['$event'])
198215
animateStart(event: AnimationEvent) {
199216
if (event.toState === 'visible') {
200-
if (!this.scroll) {
217+
if (!this.scroll()) {
201218
this.#backdropService.hideScrollbar();
202219
}
203220
this.#renderer.addClass(this.#hostElement.nativeElement, 'showing');
@@ -206,7 +223,6 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
206223
}
207224
}
208225

209-
@HostListener('@showHide.done', ['$event'])
210226
animateDone(event: AnimationEvent) {
211227
setTimeout(() => {
212228
if (event.toState === 'visible') {
@@ -218,13 +234,12 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
218234
this.#renderer.removeStyle(this.#document.body, 'paddingRight');
219235
}
220236
});
221-
this.show = this.visible;
237+
this.show = this.visible();
222238
}
223239

224-
@HostListener('document:keydown', ['$event'])
225240
onKeyDownHandler(event: KeyboardEvent): void {
226-
if (event.key === 'Escape' && this.keyboard && this.visible && this.backdrop !== 'static') {
227-
this.#offcanvasService.toggle({ show: false, id: this.id });
241+
if (event.key === 'Escape' && this.keyboard() && this.visible() && this.backdrop() !== 'static') {
242+
this.#offcanvasService.toggle({ show: false, id: this.id() });
228243
}
229244
}
230245

@@ -237,7 +252,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
237252
}
238253

239254
ngOnDestroy(): void {
240-
this.#offcanvasService.toggle({ show: false, id: this.id });
255+
this.#offcanvasService.toggle({ show: false, id: this.id() });
241256
}
242257

243258
setFocus(): void {
@@ -248,9 +263,9 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
248263

249264
private stateToggleSubscribe(): void {
250265
this.#offcanvasService.offcanvasState$.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((action) => {
251-
if (this === action.offcanvas || this.id === action.id) {
266+
if (this === action.offcanvas || this.id() === action.id) {
252267
if ('show' in action) {
253-
this.visible = action?.show === 'toggle' ? !this.visible : action.show;
268+
this.visible.update((value) => (action?.show === 'toggle' ? !value : action.show));
254269
}
255270
}
256271
});
@@ -261,14 +276,14 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
261276
this.#backdropClickSubscription = this.#backdropService.backdropClick$
262277
.pipe(takeUntilDestroyed(this.#destroyRef))
263278
.subscribe((clicked) => {
264-
this.#offcanvasService.toggle({ show: !clicked, id: this.id });
279+
this.#offcanvasService.toggle({ show: !clicked, id: this.id() });
265280
});
266281
} else {
267282
this.#backdropClickSubscription?.unsubscribe();
268283
}
269284
}
270285

271-
private setBackdrop(setBackdrop: boolean | 'static'): void {
286+
protected setBackdrop(setBackdrop: boolean | 'static'): void {
272287
this.#activeBackdrop = !!setBackdrop
273288
? this.#backdropService.setBackdrop('offcanvas')
274289
: this.#backdropService.clearBackdrop(this.#activeBackdrop);
@@ -291,7 +306,7 @@ export class OffcanvasComponent implements OnInit, OnDestroy {
291306
takeUntilDestroyed(this.#destroyRef)
292307
)
293308
.subscribe((breakpointState: BreakpointState) => {
294-
this.visible = breakpointState.matches;
309+
this.visible.set(breakpointState.matches);
295310
});
296311
} else {
297312
this.#layoutChangeSubscription?.unsubscribe();

0 commit comments

Comments
 (0)
Please sign in to comment.