@ng-zen/cli 22.4.1 → 22.5.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/eslint-config/index.js +130 -0
- package/package.json +14 -1
- package/schematics/ng-add/eslint-config.js +87 -0
- package/schematics/ng-add/eslint-config.js.map +1 -0
- package/schematics/ng-add/eslint-config.ts +98 -0
- package/schematics/ng-add/icon-library/icon-library.js +2 -1
- package/schematics/ng-add/icon-library/icon-library.js.map +1 -1
- package/schematics/ng-add/icon-library/icon-library.ts +2 -1
- package/schematics/ng-add/index.js +14 -2
- package/schematics/ng-add/index.js.map +1 -1
- package/schematics/ng-add/index.ts +14 -3
- package/schematics/ng-add/schema.js.map +1 -1
- package/schematics/ng-add/schema.json +5 -0
- package/schematics/ng-add/schema.ts +1 -0
- package/schematics/ui/files/avatar/avatar.ts +1 -1
- package/schematics/ui/files/checkbox/checkbox.stories.ts +1 -1
- package/schematics/ui/files/checkbox/checkbox.ts +2 -2
- package/schematics/ui/files/dialog/dialog.service.stories.ts +3 -3
- package/schematics/ui/files/dialog/dialog.service.ts +2 -2
- package/schematics/ui/files/dialog/dialog.spec.ts +6 -6
- package/schematics/ui/files/dialog/dialog.ts +1 -1
- package/schematics/ui/files/form-control/form-control.ts +5 -5
- package/schematics/ui/files/icon/icon.ts +5 -5
- package/schematics/ui/files/input/input.stories.ts +4 -4
- package/schematics/ui/files/input/input.ts +1 -1
- package/schematics/ui/files/pin/index.ts +3 -0
- package/schematics/ui/files/pin/pin-item.spec.ts +151 -0
- package/schematics/ui/files/pin/pin-item.ts +115 -0
- package/schematics/ui/files/pin/pin.spec.ts +153 -0
- package/schematics/ui/files/pin/pin.stories.ts +164 -0
- package/schematics/ui/files/pin/pin.ts +49 -0
- package/schematics/ui/files/pin/pin.types.ts +15 -0
- package/schematics/ui/files/popover/popover.spec.ts +10 -7
- package/schematics/ui/files/popover/popover.stories.ts +1 -1
- package/schematics/ui/files/radio/radio.stories.ts +3 -3
- package/schematics/ui/files/radio/radio.ts +3 -3
- package/schematics/ui/files/switch/switch.stories.ts +1 -1
- package/schematics/ui/index.js +2 -4
- package/schematics/ui/index.js.map +1 -1
- package/schematics/ui/index.ts +2 -4
- package/schematics/ui/schema.js.map +1 -1
- package/schematics/ui/schema.json +1 -0
- package/schematics/ui/schema.ts +1 -0
- package/utils/apply-file-template.util.js.map +1 -1
- package/utils/apply-file-template.util.ts +3 -3
|
@@ -8,13 +8,13 @@ import { ZenDialogService } from './dialog.service';
|
|
|
8
8
|
@Component({
|
|
9
9
|
template: `
|
|
10
10
|
<dialog
|
|
11
|
+
zen-dialog
|
|
11
12
|
[backdrop]="backdrop()"
|
|
12
13
|
[closeOnEscape]="closeOnEscape()"
|
|
13
14
|
[header]="header()"
|
|
14
15
|
[id]="id()"
|
|
15
16
|
[size]="size()"
|
|
16
17
|
[(open)]="isOpen"
|
|
17
|
-
zen-dialog
|
|
18
18
|
>
|
|
19
19
|
<p>Dialog content</p>
|
|
20
20
|
</dialog>
|
|
@@ -34,8 +34,8 @@ class DialogTestComponent {
|
|
|
34
34
|
@Component({
|
|
35
35
|
template: `
|
|
36
36
|
<p>{{ message() }}</p>
|
|
37
|
-
<button (click)="onConfirm()">Confirm</button>
|
|
38
|
-
<button (click)="onCancel()">Cancel</button>
|
|
37
|
+
<button type="button" (click)="onConfirm()">Confirm</button>
|
|
38
|
+
<button type="button" (click)="onCancel()">Cancel</button>
|
|
39
39
|
`,
|
|
40
40
|
standalone: true,
|
|
41
41
|
})
|
|
@@ -58,7 +58,7 @@ function getDialogEl(fixture: ComponentFixture<unknown>): HTMLDialogElement | nu
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function getDialogElFromBody(): HTMLDialogElement | null {
|
|
61
|
-
return document.body.querySelector('dialog[zen-dialog]')
|
|
61
|
+
return document.body.querySelector('dialog[zen-dialog]');
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
describe('ZenDialog', () => {
|
|
@@ -212,7 +212,7 @@ describe('ZenDialogService', () => {
|
|
|
212
212
|
const service = TestBed.inject(ZenDialogService);
|
|
213
213
|
|
|
214
214
|
const ref = service.open(TestDialogContent, {
|
|
215
|
-
inputs: { message: 'Hello from service' }
|
|
215
|
+
inputs: { message: 'Hello from service' },
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
expect(ref.componentInstance.message()).toBe('Hello from service');
|
|
@@ -230,7 +230,7 @@ describe('ZenDialogService', () => {
|
|
|
230
230
|
},
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
-
const confirmBtn = document.body.querySelector('button')
|
|
233
|
+
const confirmBtn = document.body.querySelector('button')!;
|
|
234
234
|
confirmBtn?.click();
|
|
235
235
|
|
|
236
236
|
expect(confirmHandler).toHaveBeenCalledWith('confirmed');
|
|
@@ -44,7 +44,7 @@ type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
|
44
44
|
<header class="zen-dialog-header">
|
|
45
45
|
<h2>{{ header() }}</h2>
|
|
46
46
|
@if (closable()) {
|
|
47
|
-
<button
|
|
47
|
+
<button aria-label="Close dialog" class="zen-dialog-close" type="button" (click)="onClose()">
|
|
48
48
|
<zen-icon [icon]="closeIcon" [size]="20" />
|
|
49
49
|
</button>
|
|
50
50
|
}
|
|
@@ -40,9 +40,6 @@ import { FormValueControl } from '@angular/forms/signals';
|
|
|
40
40
|
},
|
|
41
41
|
})
|
|
42
42
|
export abstract class ZenFormControl<Value> implements FormValueControl<Value> {
|
|
43
|
-
/** The underlying value of the control. Subclasses must provide their own implementation using `model()`. */
|
|
44
|
-
abstract readonly value: ModelSignal<Value>;
|
|
45
|
-
|
|
46
43
|
/** Validation errors for the field. Auto-bound by the Signal Forms directive. */
|
|
47
44
|
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
|
|
48
45
|
/** Whether the control is disabled. Auto-bound by the Signal Forms directive. */
|
|
@@ -68,12 +65,15 @@ export abstract class ZenFormControl<Value> implements FormValueControl<Value> {
|
|
|
68
65
|
/** Emitted when the field is blurred. The {@link FormField} directive listens to this output to mark the field as touched. */
|
|
69
66
|
readonly touch = output<void>();
|
|
70
67
|
|
|
68
|
+
/** The underlying value of the control. Subclasses must provide their own implementation using `model()`. */
|
|
69
|
+
abstract readonly value: ModelSignal<Value>;
|
|
70
|
+
|
|
71
71
|
// @ignore
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
focus(_options?: FocusOptions): void {}
|
|
74
74
|
|
|
75
75
|
// @ignore
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
reset(): void {}
|
|
78
78
|
|
|
79
79
|
/** Should be called by the subclass when the control's value changes as a result of user interaction. */
|
|
@@ -29,17 +29,17 @@ export type IconSvgObject = readonly (readonly [string, Record<string, string |
|
|
|
29
29
|
selector: 'zen-icon',
|
|
30
30
|
template: `
|
|
31
31
|
<svg
|
|
32
|
+
fill="none"
|
|
33
|
+
stroke="currentColor"
|
|
34
|
+
stroke-linecap="round"
|
|
35
|
+
stroke-linejoin="round"
|
|
36
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
32
37
|
[attr.height]="size()"
|
|
33
38
|
[attr.stroke-width]="calculatedStrokeWidth()"
|
|
34
39
|
[attr.viewBox]="'0 0 ' + BASE_SIZE + ' ' + BASE_SIZE"
|
|
35
40
|
[attr.width]="size()"
|
|
36
41
|
[innerHTML]="safeSvgContent()"
|
|
37
42
|
[style.color]="color()"
|
|
38
|
-
fill="none"
|
|
39
|
-
stroke="currentColor"
|
|
40
|
-
stroke-linecap="round"
|
|
41
|
-
stroke-linejoin="round"
|
|
42
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
43
43
|
></svg>
|
|
44
44
|
`,
|
|
45
45
|
styles: [':host { display: inline-flex; }'],
|
|
@@ -89,7 +89,7 @@ export const WithFormRoot: Story = {
|
|
|
89
89
|
standalone: true,
|
|
90
90
|
template: `
|
|
91
91
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; max-width: 300px;">
|
|
92
|
-
<zen-input [formField]="form.name"
|
|
92
|
+
<zen-input placeholder="Type something" [formField]="form.name" />
|
|
93
93
|
@if (form.name().invalid()) {
|
|
94
94
|
<p style="color: red; margin: 0; font-size: 0.875rem;">
|
|
95
95
|
{{ form.name().errors()![0].message }}
|
|
@@ -108,9 +108,9 @@ class InputSignalFormComponent {
|
|
|
108
108
|
@Component({
|
|
109
109
|
standalone: true,
|
|
110
110
|
template: `
|
|
111
|
-
<form
|
|
112
|
-
<zen-input [formField]="loginForm.email"
|
|
113
|
-
<zen-input
|
|
111
|
+
<form style="display: flex; flex-direction: column; gap: 0.5rem; max-width: 300px;" [formRoot]="loginForm">
|
|
112
|
+
<zen-input placeholder="Email" [formField]="loginForm.email" />
|
|
113
|
+
<zen-input placeholder="Password" type="password" [formField]="loginForm.password" />
|
|
114
114
|
<button type="submit">Sign in</button>
|
|
115
115
|
</form>
|
|
116
116
|
`,
|
|
@@ -37,6 +37,7 @@ import { ZenFormControl } from '../form-control';
|
|
|
37
37
|
selector: 'zen-input',
|
|
38
38
|
template: `
|
|
39
39
|
<input
|
|
40
|
+
#inputRef
|
|
40
41
|
[aria-invalid]="invalid() || null"
|
|
41
42
|
[disabled]="disabled()"
|
|
42
43
|
[placeholder]="placeholder()"
|
|
@@ -44,7 +45,6 @@ import { ZenFormControl } from '../form-control';
|
|
|
44
45
|
[type]="type()"
|
|
45
46
|
[value]="value()"
|
|
46
47
|
(input)="onInput(inputRef.value)"
|
|
47
|
-
#inputRef
|
|
48
48
|
/>
|
|
49
49
|
`,
|
|
50
50
|
styleUrls: ['./input.scss'],
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Component, provideZonelessChangeDetection, signal } from '@angular/core';
|
|
2
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ZenButton } from '../button';
|
|
6
|
+
import { ZenPin } from './pin';
|
|
7
|
+
import type { PinPosition } from './pin.types';
|
|
8
|
+
import { ZenPinItem } from './pin-item';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
template: `
|
|
12
|
+
<button #trigger id="trigger" type="button" zen-button zenPin>
|
|
13
|
+
Trigger
|
|
14
|
+
<span zenPinItem [zenPinFlush]="flushed()" [zenPinOffset]="offset()" [zenPinPosition]="position()">
|
|
15
|
+
Pinned content
|
|
16
|
+
</span>
|
|
17
|
+
</button>
|
|
18
|
+
`,
|
|
19
|
+
imports: [ZenPin, ZenButton, ZenPinItem],
|
|
20
|
+
})
|
|
21
|
+
class HostComponent {
|
|
22
|
+
readonly position = signal<PinPosition>('top right');
|
|
23
|
+
readonly offset = signal<string | number>('0');
|
|
24
|
+
readonly flushed = signal<boolean>(false);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getWrapper(fixture: ComponentFixture<unknown>): HTMLElement | null {
|
|
28
|
+
return fixture.nativeElement.querySelector('.zen-pin-wrapper') as HTMLElement | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('ZenPinItem', () => {
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
await TestBed.configureTestingModule({
|
|
34
|
+
imports: [HostComponent],
|
|
35
|
+
providers: [provideZonelessChangeDetection()],
|
|
36
|
+
}).compileComponents();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function createFixture(): ComponentFixture<HostComponent> {
|
|
40
|
+
const fixture = TestBed.createComponent(HostComponent);
|
|
41
|
+
fixture.detectChanges();
|
|
42
|
+
return fixture;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('sets default position-area to top right', () => {
|
|
46
|
+
expect(getWrapper(createFixture())!.style.getPropertyValue('position-area')).toBe('top right');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('applies overlap translate by default', () => {
|
|
50
|
+
expect(getWrapper(createFixture())!.style.getPropertyValue('translate')).toBe('-50% 50%');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not set margin by default', () => {
|
|
54
|
+
expect(getWrapper(createFixture())!.style.getPropertyValue('margin')).toBe('');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.each([
|
|
58
|
+
['top', '0 50%'],
|
|
59
|
+
['bottom', '0 -50%'],
|
|
60
|
+
['left', '50% 0'],
|
|
61
|
+
['right', '-50% 0'],
|
|
62
|
+
['center', '0 0'],
|
|
63
|
+
['top left', '50% 50%'],
|
|
64
|
+
['top right', '-50% 50%'],
|
|
65
|
+
['bottom left', '50% -50%'],
|
|
66
|
+
['bottom right', '-50% -50%'],
|
|
67
|
+
['top center', '0 50%'],
|
|
68
|
+
['center left', '50% 0'],
|
|
69
|
+
['center right', '-50% 0'],
|
|
70
|
+
['bottom center', '0 -50%'],
|
|
71
|
+
] as const)('computes %s translate as %s', (position, expected) => {
|
|
72
|
+
const fixture = createFixture();
|
|
73
|
+
fixture.componentInstance.position.set(position);
|
|
74
|
+
fixture.detectChanges();
|
|
75
|
+
|
|
76
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('translate')).toBe(expected);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('removes translate when flush is true', () => {
|
|
80
|
+
const fixture = createFixture();
|
|
81
|
+
fixture.componentInstance.flushed.set(true);
|
|
82
|
+
fixture.detectChanges();
|
|
83
|
+
|
|
84
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('translate')).toBe('');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('sets margin from string offset', () => {
|
|
88
|
+
const fixture = createFixture();
|
|
89
|
+
fixture.componentInstance.offset.set('10px');
|
|
90
|
+
fixture.detectChanges();
|
|
91
|
+
|
|
92
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('10px');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('sets margin from numeric offset', () => {
|
|
96
|
+
const fixture = createFixture();
|
|
97
|
+
fixture.componentInstance.offset.set(15);
|
|
98
|
+
fixture.detectChanges();
|
|
99
|
+
|
|
100
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('15px');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('removes margin when offset is zero string', () => {
|
|
104
|
+
const fixture = createFixture();
|
|
105
|
+
fixture.componentInstance.offset.set('10px');
|
|
106
|
+
fixture.detectChanges();
|
|
107
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('10px');
|
|
108
|
+
|
|
109
|
+
fixture.componentInstance.offset.set('0');
|
|
110
|
+
fixture.detectChanges();
|
|
111
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('removes margin when offset is zero number', () => {
|
|
115
|
+
const fixture = createFixture();
|
|
116
|
+
fixture.componentInstance.offset.set(15);
|
|
117
|
+
fixture.detectChanges();
|
|
118
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('15px');
|
|
119
|
+
|
|
120
|
+
fixture.componentInstance.offset.set(0);
|
|
121
|
+
fixture.detectChanges();
|
|
122
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps overlap translate when offset is set', () => {
|
|
126
|
+
const fixture = createFixture();
|
|
127
|
+
fixture.componentInstance.offset.set('10px');
|
|
128
|
+
fixture.detectChanges();
|
|
129
|
+
|
|
130
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('translate')).toBe('-50% 50%');
|
|
131
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('10px');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sets margin without translate when flush and offset', () => {
|
|
135
|
+
const fixture = createFixture();
|
|
136
|
+
fixture.componentInstance.flushed.set(true);
|
|
137
|
+
fixture.componentInstance.offset.set('8px');
|
|
138
|
+
fixture.detectChanges();
|
|
139
|
+
|
|
140
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('translate')).toBe('');
|
|
141
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('8px');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('removes wrapper from DOM on destroy', () => {
|
|
145
|
+
const fixture = createFixture();
|
|
146
|
+
expect(getWrapper(fixture)).toBeTruthy();
|
|
147
|
+
|
|
148
|
+
fixture.destroy();
|
|
149
|
+
expect(getWrapper(fixture)).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { booleanAttribute, DestroyRef, Directive, effect, ElementRef, inject, input, Renderer2 } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
import { ZenPin } from './pin';
|
|
4
|
+
import type { PinPosition } from './pin.types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ZenPinItem is a directive that pins an element as an overlay relative to a
|
|
8
|
+
* `[zenPin]` anchor. Each pinned element gets its own absolutely-positioned
|
|
9
|
+
* wrapper using CSS Anchor Positioning.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
*
|
|
13
|
+
* ```html
|
|
14
|
+
* <button zen-button zenPin>
|
|
15
|
+
* Notifications
|
|
16
|
+
* <zen-badge zenPinItem zenPinPosition="top right" [zenPinOffset]="5px">3</zen-badge>
|
|
17
|
+
* <zen-icon zenPinItem zenPinPosition="bottom right" />
|
|
18
|
+
* </button>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @author Konrad Stępień
|
|
22
|
+
* @license {@link https://github.com/kstepien3/ng-zen/blob/master/LICENSE|BSD-2-Clause}
|
|
23
|
+
* @see [GitHub](https://github.com/kstepien3/ng-zen)
|
|
24
|
+
* @see [CSS Anchor Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning)
|
|
25
|
+
*/
|
|
26
|
+
@Directive({
|
|
27
|
+
selector: '[zenPinItem]',
|
|
28
|
+
})
|
|
29
|
+
export class ZenPinItem {
|
|
30
|
+
/** Position of the pin relative to the anchor element. */
|
|
31
|
+
readonly zenPinPosition = input<PinPosition>('top right');
|
|
32
|
+
|
|
33
|
+
/** Fine-tune the margin to offset the pin from the position-area edge. Accepts CSS value or number (px). */
|
|
34
|
+
readonly zenPinOffset = input<string | number>('0');
|
|
35
|
+
|
|
36
|
+
/** Whether the pin sits flush at the edge without overlapping the anchor. */
|
|
37
|
+
readonly zenPinFlush = input<boolean, unknown>(false, { transform: booleanAttribute });
|
|
38
|
+
|
|
39
|
+
private readonly el = inject(ElementRef);
|
|
40
|
+
private readonly renderer = inject(Renderer2);
|
|
41
|
+
private readonly zenPin = inject(ZenPin);
|
|
42
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
43
|
+
|
|
44
|
+
private wrapper: HTMLElement | null = null;
|
|
45
|
+
|
|
46
|
+
constructor() {
|
|
47
|
+
effect(() => {
|
|
48
|
+
if (!this.wrapper) {
|
|
49
|
+
this.createWrapper();
|
|
50
|
+
}
|
|
51
|
+
this.updatePosition();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.destroyRef.onDestroy(() => this.cleanup());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private createWrapper(): void {
|
|
58
|
+
const wrapper = this.renderer.createElement('div');
|
|
59
|
+
this.renderer.addClass(wrapper, 'zen-pin-wrapper');
|
|
60
|
+
this.renderer.setStyle(wrapper, 'position', 'absolute');
|
|
61
|
+
this.renderer.setStyle(wrapper, 'position-anchor', this.zenPin.anchorName);
|
|
62
|
+
this.renderer.setStyle(wrapper, 'align-self', 'anchor-center');
|
|
63
|
+
this.renderer.setStyle(wrapper, 'justify-self', 'anchor-center');
|
|
64
|
+
|
|
65
|
+
const itemEl = this.el.nativeElement;
|
|
66
|
+
const parent = this.renderer.parentNode(itemEl);
|
|
67
|
+
this.renderer.removeChild(parent, itemEl);
|
|
68
|
+
this.renderer.appendChild(wrapper, itemEl);
|
|
69
|
+
|
|
70
|
+
const hostParent = this.renderer.parentNode(this.zenPin.hostEl);
|
|
71
|
+
this.renderer.appendChild(hostParent, wrapper);
|
|
72
|
+
|
|
73
|
+
this.wrapper = wrapper;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private updatePosition(): void {
|
|
77
|
+
if (!this.wrapper) return;
|
|
78
|
+
|
|
79
|
+
this.renderer.setStyle(this.wrapper, 'position-area', this.zenPinPosition());
|
|
80
|
+
|
|
81
|
+
if (this.zenPinFlush()) {
|
|
82
|
+
this.renderer.removeStyle(this.wrapper, 'translate');
|
|
83
|
+
} else {
|
|
84
|
+
this.renderer.setStyle(this.wrapper, 'translate', this.computeCenterOffset(this.zenPinPosition(), 50));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const o = this.zenPinOffset();
|
|
88
|
+
if (o !== '0' && o !== 0) {
|
|
89
|
+
this.renderer.setStyle(this.wrapper, 'margin', typeof o === 'number' ? `${o}px` : o);
|
|
90
|
+
} else {
|
|
91
|
+
this.renderer.removeStyle(this.wrapper, 'margin');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private cleanup(): void {
|
|
96
|
+
if (!this.wrapper) return;
|
|
97
|
+
const parent = this.renderer.parentNode(this.wrapper);
|
|
98
|
+
if (parent) {
|
|
99
|
+
this.renderer.removeChild(parent, this.wrapper);
|
|
100
|
+
}
|
|
101
|
+
this.wrapper = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private computeCenterOffset(position: PinPosition, pct: number): string {
|
|
105
|
+
const parts = position.split(' ') as [string] | [string, string];
|
|
106
|
+
const verticals = new Set(['top', 'bottom', 'center']);
|
|
107
|
+
const [v, h] = parts.length === 2 ? parts : verticals.has(parts[0]) ? [parts[0], 'center'] : ['center', parts[0]];
|
|
108
|
+
|
|
109
|
+
const hFactor = h === 'right' ? -1 : h === 'left' ? 1 : 0;
|
|
110
|
+
const vFactor = v === 'top' ? 1 : v === 'bottom' ? -1 : 0;
|
|
111
|
+
const x = hFactor * pct;
|
|
112
|
+
const y = vFactor * pct;
|
|
113
|
+
return `${x ? x + '%' : '0'} ${y ? y + '%' : '0'}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Component, provideZonelessChangeDetection, signal } from '@angular/core';
|
|
2
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ZenButton } from '../button';
|
|
6
|
+
import { ZenPin } from './pin';
|
|
7
|
+
import type { PinPosition } from './pin.types';
|
|
8
|
+
import { ZenPinItem } from './pin-item';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
template: `
|
|
12
|
+
<button #trigger id="trigger" type="button" zen-button zenPin>
|
|
13
|
+
Trigger
|
|
14
|
+
<span zenPinItem [zenPinFlush]="flushed()" [zenPinOffset]="offset()" [zenPinPosition]="position()">
|
|
15
|
+
Pinned content
|
|
16
|
+
</span>
|
|
17
|
+
</button>
|
|
18
|
+
`,
|
|
19
|
+
imports: [ZenPin, ZenButton, ZenPinItem],
|
|
20
|
+
})
|
|
21
|
+
class HostComponent {
|
|
22
|
+
readonly position = signal<PinPosition>('top right');
|
|
23
|
+
readonly offset = signal<string | number>('0');
|
|
24
|
+
readonly flushed = signal<boolean>(false);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Component({
|
|
28
|
+
template: `
|
|
29
|
+
<button zen-button zenPin>
|
|
30
|
+
Trigger
|
|
31
|
+
<span zenPinItem>First pin</span>
|
|
32
|
+
<span zenPinItem>Second pin</span>
|
|
33
|
+
</button>
|
|
34
|
+
`,
|
|
35
|
+
imports: [ZenPin, ZenButton, ZenPinItem],
|
|
36
|
+
})
|
|
37
|
+
class MultiHostComponent {}
|
|
38
|
+
|
|
39
|
+
function getWrappers(fixture: ComponentFixture<unknown>): HTMLElement[] {
|
|
40
|
+
return Array.from(fixture.nativeElement.querySelectorAll('.zen-pin-wrapper') as NodeListOf<HTMLElement>);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getWrapper(fixture: ComponentFixture<unknown>, index = 0): HTMLElement | null {
|
|
44
|
+
return getWrappers(fixture)[index] ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('ZenPin', () => {
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
await TestBed.configureTestingModule({
|
|
50
|
+
imports: [HostComponent, MultiHostComponent],
|
|
51
|
+
providers: [provideZonelessChangeDetection()],
|
|
52
|
+
}).compileComponents();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function createFixture(): ComponentFixture<HostComponent> {
|
|
56
|
+
const fixture = TestBed.createComponent(HostComponent);
|
|
57
|
+
fixture.detectChanges();
|
|
58
|
+
return fixture;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('creates a wrapper element with class zen-pin-wrapper', () => {
|
|
62
|
+
const fixture = createFixture();
|
|
63
|
+
expect(getWrapper(fixture)).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('moves element content into wrapper', () => {
|
|
67
|
+
const fixture = createFixture();
|
|
68
|
+
expect(getWrapper(fixture)!.textContent).toContain('Pinned content');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sets anchor-name on host and position-anchor on wrapper', () => {
|
|
72
|
+
const fixture = createFixture();
|
|
73
|
+
const trigger = fixture.nativeElement.querySelector('#trigger') as HTMLElement;
|
|
74
|
+
const wrapper = getWrapper(fixture)!;
|
|
75
|
+
|
|
76
|
+
expect(trigger.style.getPropertyValue('anchor-name')).toMatch(/^--zen-pin-/);
|
|
77
|
+
expect(wrapper.style.getPropertyValue('position-anchor')).toBe(trigger.style.getPropertyValue('anchor-name'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('sets align-self and justify-self to anchor-center', () => {
|
|
81
|
+
const fixture = createFixture();
|
|
82
|
+
const wrapper = getWrapper(fixture)!;
|
|
83
|
+
|
|
84
|
+
expect(wrapper.style.getPropertyValue('align-self')).toBe('anchor-center');
|
|
85
|
+
expect(wrapper.style.getPropertyValue('justify-self')).toBe('anchor-center');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('sets position-area from zenPinPosition input', () => {
|
|
89
|
+
const fixture = createFixture();
|
|
90
|
+
const wrapper = getWrapper(fixture)!;
|
|
91
|
+
|
|
92
|
+
expect(wrapper.style.getPropertyValue('position-area')).toBe('top right');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('updates position-area when position changes', () => {
|
|
96
|
+
const fixture = createFixture();
|
|
97
|
+
fixture.componentInstance.position.set('bottom left');
|
|
98
|
+
fixture.detectChanges();
|
|
99
|
+
|
|
100
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('position-area')).toBe('bottom left');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('sets margin from zenPinOffset string input', () => {
|
|
104
|
+
const fixture = createFixture();
|
|
105
|
+
fixture.componentInstance.offset.set('10px');
|
|
106
|
+
fixture.detectChanges();
|
|
107
|
+
|
|
108
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('10px');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('sets margin from zenPinOffset number input', () => {
|
|
112
|
+
const fixture = createFixture();
|
|
113
|
+
fixture.componentInstance.offset.set(15);
|
|
114
|
+
fixture.detectChanges();
|
|
115
|
+
|
|
116
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('margin')).toBe('15px');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([
|
|
120
|
+
['top right', '-50% 50%'],
|
|
121
|
+
['top left', '50% 50%'],
|
|
122
|
+
['bottom right', '-50% -50%'],
|
|
123
|
+
] as const)('computes center-directed translate for position %s when not flush', (position, expected) => {
|
|
124
|
+
const fixture = createFixture();
|
|
125
|
+
fixture.componentInstance.position.set(position);
|
|
126
|
+
fixture.detectChanges();
|
|
127
|
+
|
|
128
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('translate')).toBe(expected);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('removes translate when flush is true', () => {
|
|
132
|
+
const fixture = createFixture();
|
|
133
|
+
fixture.componentInstance.flushed.set(true);
|
|
134
|
+
fixture.detectChanges();
|
|
135
|
+
|
|
136
|
+
expect(getWrapper(fixture)!.style.getPropertyValue('translate')).toBe('');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('creates a wrapper per zen-pin-item', () => {
|
|
140
|
+
const fixture = TestBed.createComponent(MultiHostComponent);
|
|
141
|
+
fixture.detectChanges();
|
|
142
|
+
|
|
143
|
+
expect(getWrappers(fixture)).toHaveLength(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('removes wrapper on host destroy', () => {
|
|
147
|
+
const fixture = createFixture();
|
|
148
|
+
expect(getWrapper(fixture)).toBeTruthy();
|
|
149
|
+
|
|
150
|
+
fixture.destroy();
|
|
151
|
+
expect(getWrapper(fixture)).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|