ngx-opalbytes-components 1.29.0 → 1.30.0
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 +138 -0
- package/fesm2022/ngx-opalbytes-components.mjs +338 -5
- package/fesm2022/ngx-opalbytes-components.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/shared/components/index.ts +4 -1
- package/src/lib/shared/components/popover/index.ts +3 -0
- package/src/lib/shared/components/popover/popover.css +149 -0
- package/src/lib/shared/components/popover/popover.html +44 -0
- package/src/lib/shared/components/popover/popover.model.ts +19 -0
- package/src/lib/shared/components/popover/popover.spec.ts +22 -0
- package/src/lib/shared/components/popover/popover.stories.ts +0 -0
- package/src/lib/shared/components/popover/popover.ts +312 -0
- package/src/lib/shared/components/progress-bar/index.ts +2 -0
- package/src/lib/shared/components/progress-bar/progress-bar.css +141 -0
- package/src/lib/shared/components/progress-bar/progress-bar.html +39 -0
- package/src/lib/shared/components/progress-bar/progress-bar.model.ts +8 -0
- package/src/lib/shared/components/progress-bar/progress-bar.spec.ts +94 -0
- package/src/lib/shared/components/progress-bar/progress-bar.stories.ts +53 -0
- package/src/lib/shared/components/progress-bar/progress-bar.ts +50 -0
- package/src/lib/shared/components/progress-spinner/index.ts +2 -0
- package/src/lib/shared/components/progress-spinner/progress-spinner.css +90 -0
- package/src/lib/shared/components/progress-spinner/progress-spinner.html +50 -0
- package/src/lib/shared/components/progress-spinner/progress-spinner.model.ts +8 -0
- package/src/lib/shared/components/progress-spinner/progress-spinner.spec.ts +85 -0
- package/src/lib/shared/components/progress-spinner/progress-spinner.stories.ts +73 -0
- package/src/lib/shared/components/progress-spinner/progress-spinner.ts +71 -0
- package/types/ngx-opalbytes-components.d.ts +181 -2
package/package.json
CHANGED
|
@@ -35,4 +35,7 @@ export * from "./radio/radio-group.component"
|
|
|
35
35
|
export * from "./radio/radio-button.component"
|
|
36
36
|
export * from "./checkbox/checkbox.component"
|
|
37
37
|
export * from "./footer/footer"
|
|
38
|
-
export * from "./breadcrumb/breadcrumb"
|
|
38
|
+
export * from "./breadcrumb/breadcrumb"
|
|
39
|
+
export * from "./progress-bar/progress-bar"
|
|
40
|
+
export * from "./progress-spinner/progress-spinner"
|
|
41
|
+
export * from "./popover/popover"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: contents;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.cao-popover {
|
|
6
|
+
--_bg: var(--cao-popover-bg, #ffffff);
|
|
7
|
+
--_text: var(--cao-popover-text, #111827);
|
|
8
|
+
--_border: var(--cao-popover-border-color, #e5e7eb);
|
|
9
|
+
--_radius: var(--cao-popover-radius, 8px);
|
|
10
|
+
--_padding: var(--cao-popover-padding, 12px 16px);
|
|
11
|
+
--_shadow: var(--cao-popover-shadow, 0 10px 25px -5px rgba(17, 24, 39, 0.15), 0 4px 10px -6px rgba(17, 24, 39, 0.12));
|
|
12
|
+
--_focus: var(--cao-popover-focus-ring, rgba(79, 70, 229, 0.35));
|
|
13
|
+
--_arrow-size: 10px;
|
|
14
|
+
--_arrow-offset: 16px;
|
|
15
|
+
|
|
16
|
+
position: relative;
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
border: 1px solid var(--_border);
|
|
19
|
+
border-radius: var(--_radius);
|
|
20
|
+
background-color: var(--_bg);
|
|
21
|
+
box-shadow: var(--_shadow);
|
|
22
|
+
color: var(--_text);
|
|
23
|
+
font-size: 0.875rem;
|
|
24
|
+
line-height: 1.5;
|
|
25
|
+
outline: none;
|
|
26
|
+
animation: cao-popover-in 150ms ease-out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.cao-popover:focus-visible {
|
|
30
|
+
box-shadow: var(--_shadow), 0 0 0 3px var(--_focus);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.cao-popover__content {
|
|
34
|
+
padding: var(--_padding);
|
|
35
|
+
overflow-wrap: anywhere;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.cao-popover__content--with-close {
|
|
39
|
+
padding-right: 40px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.cao-popover__close {
|
|
43
|
+
position: absolute;
|
|
44
|
+
top: 8px;
|
|
45
|
+
right: 8px;
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: center;
|
|
49
|
+
width: 24px;
|
|
50
|
+
height: 24px;
|
|
51
|
+
padding: 0;
|
|
52
|
+
border: 0;
|
|
53
|
+
border-radius: 4px;
|
|
54
|
+
background: none;
|
|
55
|
+
color: #6b7280;
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.cao-popover__close:hover {
|
|
60
|
+
background-color: #f3f4f6;
|
|
61
|
+
color: #111827;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.cao-popover__close:focus-visible {
|
|
65
|
+
outline: 2px solid var(--_focus);
|
|
66
|
+
outline-offset: 1px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* Seta */
|
|
70
|
+
.cao-popover__arrow {
|
|
71
|
+
position: absolute;
|
|
72
|
+
width: var(--_arrow-size);
|
|
73
|
+
height: var(--_arrow-size);
|
|
74
|
+
background-color: var(--_bg);
|
|
75
|
+
transform: rotate(45deg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.cao-popover--bottom .cao-popover__arrow {
|
|
79
|
+
top: calc(var(--_arrow-size) / -2 - 1px);
|
|
80
|
+
border-top: 1px solid var(--_border);
|
|
81
|
+
border-left: 1px solid var(--_border);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.cao-popover--top .cao-popover__arrow {
|
|
85
|
+
bottom: calc(var(--_arrow-size) / -2 - 1px);
|
|
86
|
+
border-right: 1px solid var(--_border);
|
|
87
|
+
border-bottom: 1px solid var(--_border);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.cao-popover--right .cao-popover__arrow {
|
|
91
|
+
left: calc(var(--_arrow-size) / -2 - 1px);
|
|
92
|
+
border-bottom: 1px solid var(--_border);
|
|
93
|
+
border-left: 1px solid var(--_border);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.cao-popover--left .cao-popover__arrow {
|
|
97
|
+
right: calc(var(--_arrow-size) / -2 - 1px);
|
|
98
|
+
border-top: 1px solid var(--_border);
|
|
99
|
+
border-right: 1px solid var(--_border);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Posição da seta ao longo da borda (topo/base) */
|
|
103
|
+
.cao-popover--top.cao-popover--align-center .cao-popover__arrow,
|
|
104
|
+
.cao-popover--bottom.cao-popover--align-center .cao-popover__arrow {
|
|
105
|
+
left: calc(50% - var(--_arrow-size) / 2);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cao-popover--top.cao-popover--align-start .cao-popover__arrow,
|
|
109
|
+
.cao-popover--bottom.cao-popover--align-start .cao-popover__arrow {
|
|
110
|
+
left: var(--_arrow-offset);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.cao-popover--top.cao-popover--align-end .cao-popover__arrow,
|
|
114
|
+
.cao-popover--bottom.cao-popover--align-end .cao-popover__arrow {
|
|
115
|
+
right: var(--_arrow-offset);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Posição da seta ao longo da borda (esquerda/direita) */
|
|
119
|
+
.cao-popover--left.cao-popover--align-center .cao-popover__arrow,
|
|
120
|
+
.cao-popover--right.cao-popover--align-center .cao-popover__arrow {
|
|
121
|
+
top: calc(50% - var(--_arrow-size) / 2);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.cao-popover--left.cao-popover--align-start .cao-popover__arrow,
|
|
125
|
+
.cao-popover--right.cao-popover--align-start .cao-popover__arrow {
|
|
126
|
+
top: var(--_arrow-offset);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.cao-popover--left.cao-popover--align-end .cao-popover__arrow,
|
|
130
|
+
.cao-popover--right.cao-popover--align-end .cao-popover__arrow {
|
|
131
|
+
bottom: var(--_arrow-offset);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@keyframes cao-popover-in {
|
|
135
|
+
from {
|
|
136
|
+
opacity: 0;
|
|
137
|
+
transform: scale(0.97);
|
|
138
|
+
}
|
|
139
|
+
to {
|
|
140
|
+
opacity: 1;
|
|
141
|
+
transform: scale(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@media (prefers-reduced-motion: reduce) {
|
|
146
|
+
.cao-popover {
|
|
147
|
+
animation: none;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<ng-template #panelTemplate>
|
|
2
|
+
<div
|
|
3
|
+
[ngClass]="'cao-popover'"
|
|
4
|
+
tabindex="-1"
|
|
5
|
+
[id]="panelId"
|
|
6
|
+
[class]="styleClass()"
|
|
7
|
+
[class.cao-popover--top]="placement() === 'top'"
|
|
8
|
+
[class.cao-popover--bottom]="placement() === 'bottom'"
|
|
9
|
+
[class.cao-popover--left]="placement() === 'left'"
|
|
10
|
+
[class.cao-popover--right]="placement() === 'right'"
|
|
11
|
+
[class.cao-popover--align-start]="align() === 'start'"
|
|
12
|
+
[class.cao-popover--align-center]="align() === 'center'"
|
|
13
|
+
[class.cao-popover--align-end]="align() === 'end'"
|
|
14
|
+
[class.cao-popover--no-arrow]="!showArrow()"
|
|
15
|
+
[attr.role]="role() || null"
|
|
16
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
17
|
+
[attr.aria-labelledby]="ariaLabelledBy() || null"
|
|
18
|
+
[attr.data-cy]="dataCy() || null"
|
|
19
|
+
[style.width]="width() || null"
|
|
20
|
+
[style.max-width]="maxWidth() || null"
|
|
21
|
+
[style.--cao-popover-bg]="bgColor() || null"
|
|
22
|
+
[style.--cao-popover-text]="textColor() || null"
|
|
23
|
+
[style.--cao-popover-border-color]="borderColor() || null"
|
|
24
|
+
[style.--cao-popover-radius]="borderRadius() || null"
|
|
25
|
+
[style.--cao-popover-padding]="padding() || null"
|
|
26
|
+
(mouseenter)="panelHoverChange.emit(true)"
|
|
27
|
+
(mouseleave)="panelHoverChange.emit(false)">
|
|
28
|
+
@if (showArrow()) {
|
|
29
|
+
<span class="cao-popover__arrow" aria-hidden="true"></span>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
<div class="cao-popover__content" [class.cao-popover__content--with-close]="showCloseIcon()">
|
|
33
|
+
<ng-content />
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
@if (showCloseIcon()) {
|
|
37
|
+
<button type="button" class="cao-popover__close" [attr.aria-label]="closeAriaLabel()" (click)="onCloseClick()">
|
|
38
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
|
|
39
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
40
|
+
</svg>
|
|
41
|
+
</button>
|
|
42
|
+
}
|
|
43
|
+
</div>
|
|
44
|
+
</ng-template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ElementRef } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/** Lado do elemento de origem em que o popover é exibido. */
|
|
4
|
+
export type CaoPopoverPosition = 'top' | 'bottom' | 'left' | 'right';
|
|
5
|
+
|
|
6
|
+
/** Alinhamento do popover em relação ao elemento de origem. */
|
|
7
|
+
export type CaoPopoverAlign = 'start' | 'center' | 'end';
|
|
8
|
+
|
|
9
|
+
/** Evento que aciona o popover quando usado com `CaoPopoverTriggerDirective`. */
|
|
10
|
+
export type CaoPopoverTrigger = 'click' | 'hover' | 'focus';
|
|
11
|
+
|
|
12
|
+
/** Elemento de origem (âncora) do popover. */
|
|
13
|
+
export type CaoPopoverTarget = HTMLElement | ElementRef<HTMLElement> | null | undefined;
|
|
14
|
+
|
|
15
|
+
export interface ICaoPopoverShowOptions {
|
|
16
|
+
/** Move o foco para dentro do popover ao abrir. Padrão: valor de `focusOnShow`. */
|
|
17
|
+
focus?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { CaoPopoverComponent } from './popover';
|
|
4
|
+
|
|
5
|
+
describe('CaoPopover', () => {
|
|
6
|
+
let component: CaoPopoverComponent;
|
|
7
|
+
let fixture: ComponentFixture<CaoPopoverComponent>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [CaoPopoverComponent],
|
|
12
|
+
}).compileComponents();
|
|
13
|
+
|
|
14
|
+
fixture = TestBed.createComponent(CaoPopoverComponent);
|
|
15
|
+
component = fixture.componentInstance;
|
|
16
|
+
await fixture.whenStable();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should create', () => {
|
|
20
|
+
expect(component).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { ConnectedOverlayPositionChange, Overlay, OverlayRef, ConnectedPosition } from '@angular/cdk/overlay';
|
|
2
|
+
import { TemplatePortal } from '@angular/cdk/portal';
|
|
3
|
+
import { CommonModule } from '@angular/common';
|
|
4
|
+
import {
|
|
5
|
+
ChangeDetectionStrategy,
|
|
6
|
+
Component,
|
|
7
|
+
DestroyRef,
|
|
8
|
+
ElementRef,
|
|
9
|
+
TemplateRef,
|
|
10
|
+
ViewContainerRef,
|
|
11
|
+
booleanAttribute,
|
|
12
|
+
inject,
|
|
13
|
+
input,
|
|
14
|
+
numberAttribute,
|
|
15
|
+
output,
|
|
16
|
+
signal,
|
|
17
|
+
viewChild,
|
|
18
|
+
} from '@angular/core';
|
|
19
|
+
|
|
20
|
+
import { Subscription } from 'rxjs';
|
|
21
|
+
|
|
22
|
+
import { CaoPopoverAlign, CaoPopoverPosition, CaoPopoverTarget, ICaoPopoverShowOptions } from './popover.model';
|
|
23
|
+
|
|
24
|
+
export const CAO_POPOVER_POSITION_PREFIX = 'cao-popover-pos-';
|
|
25
|
+
|
|
26
|
+
const OPPOSITE: Record<CaoPopoverPosition, CaoPopoverPosition> = {
|
|
27
|
+
top: 'bottom',
|
|
28
|
+
bottom: 'top',
|
|
29
|
+
left: 'right',
|
|
30
|
+
right: 'left',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PERPENDICULAR: Record<CaoPopoverPosition, CaoPopoverPosition[]> = {
|
|
34
|
+
top: ['right', 'left'],
|
|
35
|
+
bottom: ['right', 'left'],
|
|
36
|
+
left: ['bottom', 'top'],
|
|
37
|
+
right: ['bottom', 'top'],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function toConnectedPosition(position: CaoPopoverPosition, align: CaoPopoverAlign, offset: number): ConnectedPosition {
|
|
41
|
+
const panelClass = `${CAO_POPOVER_POSITION_PREFIX}${position}`;
|
|
42
|
+
|
|
43
|
+
if (position === 'top' || position === 'bottom') {
|
|
44
|
+
return {
|
|
45
|
+
originX: align,
|
|
46
|
+
originY: position === 'bottom' ? 'bottom' : 'top',
|
|
47
|
+
overlayX: align,
|
|
48
|
+
overlayY: position === 'bottom' ? 'top' : 'bottom',
|
|
49
|
+
offsetY: position === 'bottom' ? offset : -offset,
|
|
50
|
+
panelClass,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const vertical = align === 'start' ? 'top' : align === 'end' ? 'bottom' : 'center';
|
|
55
|
+
return {
|
|
56
|
+
originX: position === 'right' ? 'end' : 'start',
|
|
57
|
+
originY: vertical,
|
|
58
|
+
overlayX: position === 'right' ? 'start' : 'end',
|
|
59
|
+
overlayY: vertical,
|
|
60
|
+
offsetX: position === 'right' ? offset : -offset,
|
|
61
|
+
panelClass,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Posição preferida seguida das alternativas (oposta e perpendiculares) usadas quando não há espaço. */
|
|
66
|
+
export function buildPopoverPositions(
|
|
67
|
+
position: CaoPopoverPosition,
|
|
68
|
+
align: CaoPopoverAlign,
|
|
69
|
+
offset: number,
|
|
70
|
+
): ConnectedPosition[] {
|
|
71
|
+
const order = [position, OPPOSITE[position], ...PERPENDICULAR[position]];
|
|
72
|
+
return order.map((item) => toConnectedPosition(item, align, offset));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const FOCUSABLE_SELECTOR =
|
|
76
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
77
|
+
|
|
78
|
+
let nextId = 0;
|
|
79
|
+
|
|
80
|
+
@Component({
|
|
81
|
+
selector: 'cao-popover',
|
|
82
|
+
templateUrl: './popover.html',
|
|
83
|
+
imports: [CommonModule],
|
|
84
|
+
styleUrl: './popover.css',
|
|
85
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
86
|
+
exportAs: 'caoPopover',
|
|
87
|
+
})
|
|
88
|
+
export class CaoPopoverComponent {
|
|
89
|
+
private readonly overlay = inject(Overlay);
|
|
90
|
+
private readonly viewContainerRef = inject(ViewContainerRef);
|
|
91
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
92
|
+
private readonly panelTemplate = viewChild.required<TemplateRef<unknown>>('panelTemplate');
|
|
93
|
+
|
|
94
|
+
private overlayRef: OverlayRef | null = null;
|
|
95
|
+
private origin: HTMLElement | null = null;
|
|
96
|
+
private subscriptions = new Subscription();
|
|
97
|
+
private focusMoved = false;
|
|
98
|
+
private readonly openState = signal(false);
|
|
99
|
+
|
|
100
|
+
protected readonly panelId = `cao-popover-${nextId++}`;
|
|
101
|
+
protected readonly placement = signal<CaoPopoverPosition>('bottom');
|
|
102
|
+
|
|
103
|
+
/** Lado do elemento de origem em que o popover aparece. Se não houver espaço, o CDK escolhe outro lado. */
|
|
104
|
+
readonly position = input<CaoPopoverPosition>('bottom');
|
|
105
|
+
/** Alinhamento em relação ao elemento de origem. */
|
|
106
|
+
readonly align = input<CaoPopoverAlign>('center');
|
|
107
|
+
/** Distância (px) entre a origem e o popover. */
|
|
108
|
+
readonly offset = input(10, { transform: numberAttribute });
|
|
109
|
+
/** Exibe a seta apontando para a origem. */
|
|
110
|
+
readonly showArrow = input(true, { transform: booleanAttribute });
|
|
111
|
+
/** Fecha ao clicar fora do popover. */
|
|
112
|
+
readonly dismissable = input(true, { transform: booleanAttribute });
|
|
113
|
+
/** Fecha ao pressionar `Esc`. */
|
|
114
|
+
readonly closeOnEscape = input(true, { transform: booleanAttribute });
|
|
115
|
+
/** Exibe o botão de fechar no canto superior direito. */
|
|
116
|
+
readonly showCloseIcon = input(false, { transform: booleanAttribute });
|
|
117
|
+
/** Move o foco para o primeiro elemento focável do popover ao abrir (e devolve à origem ao fechar). */
|
|
118
|
+
readonly focusOnShow = input(true, { transform: booleanAttribute });
|
|
119
|
+
|
|
120
|
+
/** Largura fixa do popover (ex.: `'320px'`). */
|
|
121
|
+
readonly width = input<string>('');
|
|
122
|
+
/** Largura máxima. */
|
|
123
|
+
readonly maxWidth = input<string>('360px');
|
|
124
|
+
readonly bgColor = input<string>('');
|
|
125
|
+
readonly textColor = input<string>('');
|
|
126
|
+
readonly borderColor = input<string>('');
|
|
127
|
+
readonly borderRadius = input<string>('');
|
|
128
|
+
readonly padding = input<string>('');
|
|
129
|
+
/** Classe(s) CSS adicionais aplicadas ao painel do popover. */
|
|
130
|
+
readonly styleClass = input<string>('');
|
|
131
|
+
/** Classe(s) CSS adicionais aplicadas ao painel do overlay (CDK). */
|
|
132
|
+
readonly panelClass = input<string | string[]>('');
|
|
133
|
+
|
|
134
|
+
readonly role = input<string>('dialog');
|
|
135
|
+
readonly ariaLabel = input<string>('');
|
|
136
|
+
readonly ariaLabelledBy = input<string>('');
|
|
137
|
+
readonly closeAriaLabel = input<string>('Fechar');
|
|
138
|
+
readonly dataCy = input<string>('');
|
|
139
|
+
|
|
140
|
+
/** Emitido após o popover ser aberto. */
|
|
141
|
+
readonly shown = output<void>();
|
|
142
|
+
/** Emitido após o popover ser fechado. */
|
|
143
|
+
readonly hidden = output<void>();
|
|
144
|
+
/** Emitido quando o ponteiro entra/sai do painel (usado pelo gatilho `hover`). */
|
|
145
|
+
readonly panelHoverChange = output<boolean>();
|
|
146
|
+
|
|
147
|
+
/** `true` enquanto o popover está aberto. */
|
|
148
|
+
readonly isOpen = this.openState.asReadonly();
|
|
149
|
+
|
|
150
|
+
constructor() {
|
|
151
|
+
this.destroyRef.onDestroy(() => this.dispose());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Abre o popover se estiver fechado (ou aberto em outra origem); fecha se já estiver aberto na mesma origem. */
|
|
155
|
+
toggle(event?: Event | null, target?: CaoPopoverTarget, options: ICaoPopoverShowOptions = {}): void {
|
|
156
|
+
const origin = this.resolveOrigin(event, target);
|
|
157
|
+
if (!origin) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.openState() && this.origin === origin) {
|
|
161
|
+
this.hide();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.show(event, origin, options);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Abre o popover ancorado em `target` (ou em `event.currentTarget`). */
|
|
168
|
+
show(event?: Event | null, target?: CaoPopoverTarget, options: ICaoPopoverShowOptions = {}): void {
|
|
169
|
+
const origin = this.resolveOrigin(event, target);
|
|
170
|
+
if (!origin) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (this.openState()) {
|
|
174
|
+
if (this.origin === origin) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.close(false);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.origin = origin;
|
|
181
|
+
this.placement.set(this.position());
|
|
182
|
+
|
|
183
|
+
const positionStrategy = this.overlay
|
|
184
|
+
.position()
|
|
185
|
+
.flexibleConnectedTo(origin)
|
|
186
|
+
.withPositions(buildPopoverPositions(this.position(), this.align(), this.offset()))
|
|
187
|
+
.withFlexibleDimensions(false)
|
|
188
|
+
.withPush(false)
|
|
189
|
+
.withViewportMargin(8);
|
|
190
|
+
|
|
191
|
+
const overlayRef = this.overlay.create({
|
|
192
|
+
positionStrategy,
|
|
193
|
+
scrollStrategy: this.overlay.scrollStrategies.reposition(),
|
|
194
|
+
panelClass: this.panelClass(),
|
|
195
|
+
});
|
|
196
|
+
this.overlayRef = overlayRef;
|
|
197
|
+
|
|
198
|
+
this.subscriptions.add(positionStrategy.positionChanges.subscribe((change) => this.onPositionChange(change)));
|
|
199
|
+
this.subscriptions.add(
|
|
200
|
+
overlayRef.outsidePointerEvents().subscribe((event) => {
|
|
201
|
+
if (this.dismissable() && !this.origin?.contains(event.target as Node)) {
|
|
202
|
+
this.close(false);
|
|
203
|
+
}
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
this.subscriptions.add(
|
|
207
|
+
overlayRef.keydownEvents().subscribe((event) => {
|
|
208
|
+
if (event.key === 'Escape' && this.closeOnEscape()) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
this.close(true);
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
overlayRef.attach(new TemplatePortal(this.panelTemplate(), this.viewContainerRef));
|
|
216
|
+
this.openState.set(true);
|
|
217
|
+
this.setOriginAria(origin, true);
|
|
218
|
+
|
|
219
|
+
if (options.focus ?? this.focusOnShow()) {
|
|
220
|
+
this.focusPanel(overlayRef.overlayElement);
|
|
221
|
+
}
|
|
222
|
+
this.shown.emit();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Fecha o popover. */
|
|
226
|
+
hide(): void {
|
|
227
|
+
this.close(true);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Recalcula a posição (útil se a origem mudou de lugar ou o conteúdo mudou de tamanho). */
|
|
231
|
+
reposition(): void {
|
|
232
|
+
this.overlayRef?.updatePosition();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Indica se o nó está dentro do painel do popover aberto. */
|
|
236
|
+
containsNode(node: Node | null | undefined): boolean {
|
|
237
|
+
return !!node && !!this.overlayRef?.overlayElement.contains(node);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
protected onCloseClick(): void {
|
|
241
|
+
this.close(true);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private close(restoreFocus: boolean): void {
|
|
245
|
+
const overlayRef = this.overlayRef;
|
|
246
|
+
if (!overlayRef) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const origin = this.origin;
|
|
250
|
+
const shouldRestoreFocus = restoreFocus && this.focusMoved;
|
|
251
|
+
|
|
252
|
+
this.overlayRef = null;
|
|
253
|
+
this.origin = null;
|
|
254
|
+
this.focusMoved = false;
|
|
255
|
+
this.subscriptions.unsubscribe();
|
|
256
|
+
this.subscriptions = new Subscription();
|
|
257
|
+
overlayRef.dispose();
|
|
258
|
+
|
|
259
|
+
this.openState.set(false);
|
|
260
|
+
this.setOriginAria(origin, false);
|
|
261
|
+
if (shouldRestoreFocus) {
|
|
262
|
+
origin?.focus({ preventScroll: true });
|
|
263
|
+
}
|
|
264
|
+
this.hidden.emit();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private dispose(): void {
|
|
268
|
+
this.subscriptions.unsubscribe();
|
|
269
|
+
this.overlayRef?.dispose();
|
|
270
|
+
this.overlayRef = null;
|
|
271
|
+
this.origin = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private onPositionChange(change: ConnectedOverlayPositionChange): void {
|
|
275
|
+
const panelClass = change.connectionPair.panelClass;
|
|
276
|
+
const value = Array.isArray(panelClass) ? panelClass[0] : panelClass;
|
|
277
|
+
if (typeof value === 'string' && value.startsWith(CAO_POPOVER_POSITION_PREFIX)) {
|
|
278
|
+
this.placement.set(value.slice(CAO_POPOVER_POSITION_PREFIX.length) as CaoPopoverPosition);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private resolveOrigin(event?: Event | null, target?: CaoPopoverTarget): HTMLElement | null {
|
|
283
|
+
if (target) {
|
|
284
|
+
return target instanceof ElementRef ? target.nativeElement : target;
|
|
285
|
+
}
|
|
286
|
+
return (event?.currentTarget ?? event?.target ?? null) as HTMLElement | null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private focusPanel(overlayElement: HTMLElement): void {
|
|
290
|
+
const panel = overlayElement.querySelector<HTMLElement>('.cao-popover');
|
|
291
|
+
const target = panel?.querySelector<HTMLElement>(`.cao-popover__content ${FOCUSABLE_SELECTOR}`) ?? panel;
|
|
292
|
+
if (target) {
|
|
293
|
+
target.focus({ preventScroll: true });
|
|
294
|
+
this.focusMoved = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private setOriginAria(origin: HTMLElement | null, open: boolean): void {
|
|
299
|
+
if (!origin) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
origin.setAttribute('aria-expanded', String(open));
|
|
303
|
+
if (open) {
|
|
304
|
+
if (this.role() === 'dialog') {
|
|
305
|
+
origin.setAttribute('aria-haspopup', 'dialog');
|
|
306
|
+
}
|
|
307
|
+
origin.setAttribute('aria-controls', this.panelId);
|
|
308
|
+
} else {
|
|
309
|
+
origin.removeAttribute('aria-controls');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|