Skip to content

Commit 3cd7ce9

Browse files
authored
refactor(popover): Improved popover rendering (#1810)
* Skip visibility state re-render when popover properties are being changed. Now, only the position tracking callback is re-created. * Use the slot controller API for handling slot changes. * Abstracted some code around open state and state updates. * Dropped `watch` decorator and moved relevant logic inside Lit `update` hook where the DOM does exist.
1 parent 7f2dcd2 commit 3cd7ce9

File tree

3 files changed

+133
-98
lines changed

3 files changed

+133
-98
lines changed

src/components/common/util.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,3 +496,10 @@ export function equal<T>(a: unknown, b: T, visited = new WeakSet()): boolean {
496496
export type RequiredProps<T, K extends keyof T> = T & {
497497
[P in K]-?: T[P];
498498
};
499+
500+
export function setStyles(
501+
element: HTMLElement,
502+
styles: Partial<CSSStyleDeclaration>
503+
): void {
504+
Object.assign(element.style, styles);
505+
}

src/components/popover/popover.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ async function waitForPaint(popover: IgcPopoverComponent) {
1616
}
1717

1818
function getFloater(popover: IgcPopoverComponent) {
19-
return popover.shadowRoot!.querySelector('#container') as HTMLElement;
19+
return popover.renderRoot.querySelector('#container') as HTMLElement;
2020
}
2121

2222
function togglePopover() {
@@ -173,7 +173,6 @@ describe('Popover', () => {
173173
const root = await fixture<HTMLElement>(createNonSlottedPopover(true));
174174
popover = root.querySelector('igc-popover') as IgcPopoverComponent;
175175
anchor = root.querySelector('#btn') as HTMLButtonElement;
176-
// await waitForPaint(popover);
177176
});
178177

179178
it('should render a component', async () => {

src/components/popover/popover.ts

Lines changed: 125 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@ import {
1212
shift,
1313
size,
1414
} from '@floating-ui/dom';
15-
import { html, LitElement } from 'lit';
16-
import { property, query, queryAssignedElements } from 'lit/decorators.js';
17-
18-
import { watch } from '../common/decorators/watch.js';
15+
import { html, LitElement, type PropertyValues } from 'lit';
16+
import { property, query } from 'lit/decorators.js';
17+
import {
18+
addSlotController,
19+
type SlotChangeCallbackParameters,
20+
setSlots,
21+
} from '../common/controllers/slot.js';
1922
import { registerComponent } from '../common/definitions/register.js';
2023
import {
2124
first,
2225
getElementByIdFromRoot,
23-
isEmpty,
2426
isString,
2527
roundByDPR,
28+
setStyles,
2629
} from '../common/util.js';
2730
import { styles } from './themes/light/popover.base.css.js';
2831

@@ -56,19 +59,36 @@ export default class IgcPopoverComponent extends LitElement {
5659
public static readonly tagName = 'igc-popover';
5760
public static override styles = styles;
5861

62+
private static _oppositeArrowSide = new Map(
63+
Object.entries({
64+
top: 'bottom',
65+
right: 'left',
66+
bottom: 'top',
67+
left: 'right',
68+
})
69+
);
70+
5971
/* blazorSuppress */
60-
public static register() {
72+
public static register(): void {
6173
registerComponent(IgcPopoverComponent);
6274
}
6375

64-
private dispose?: ReturnType<typeof autoUpdate>;
65-
private target?: Element;
76+
//#region Internal properties and state
6677

67-
@query('#container')
68-
private _container!: HTMLElement;
78+
private _dispose?: ReturnType<typeof autoUpdate>;
79+
private _target?: Element;
6980

70-
@queryAssignedElements({ slot: 'anchor', flatten: true })
71-
private _anchors!: HTMLElement[];
81+
private readonly _slots = addSlotController(this, {
82+
slots: setSlots('anchor'),
83+
onChange: this._handleSlotChange,
84+
});
85+
86+
@query('#container', true)
87+
private readonly _container!: HTMLElement;
88+
89+
//#endregion
90+
91+
//#region Public attributes and properties
7292

7393
/**
7494
* Pass an IDREF or an DOM element reference to use as the
@@ -138,85 +158,111 @@ export default class IgcPopoverComponent extends LitElement {
138158
@property({ type: Number, attribute: 'shift-padding' })
139159
public shiftPadding = 0;
140160

141-
@watch('anchor')
142-
protected anchorChange() {
143-
const newTarget = isString(this.anchor)
144-
? getElementByIdFromRoot(this, this.anchor)
145-
: this.anchor;
161+
//#endregion
162+
163+
//#region Life-cycle hooks
146164

147-
if (newTarget) {
148-
this.target = newTarget;
149-
this._updateState();
165+
protected override update(properties: PropertyValues<this>): void {
166+
if (properties.has('anchor')) {
167+
const target = isString(this.anchor)
168+
? getElementByIdFromRoot(this, this.anchor)
169+
: this.anchor;
170+
171+
if (target) {
172+
this._target = target;
173+
}
150174
}
151-
}
152175

153-
@watch('open', { waitUntilFirstUpdate: true })
154-
protected openChange() {
155-
this.open ? this.show() : this.hide();
156-
}
176+
if (this.hasUpdated && properties.has('open')) {
177+
this._setOpenState(this.open);
178+
}
157179

158-
@watch('arrow', { waitUntilFirstUpdate: true })
159-
@watch('arrowOffset', { waitUntilFirstUpdate: true })
160-
@watch('flip', { waitUntilFirstUpdate: true })
161-
@watch('inline', { waitUntilFirstUpdate: true })
162-
@watch('offset', { waitUntilFirstUpdate: true })
163-
@watch('placement', { waitUntilFirstUpdate: true })
164-
@watch('sameWidth', { waitUntilFirstUpdate: true })
165-
@watch('shift', { waitUntilFirstUpdate: true })
166-
@watch('shiftPadding', { waitUntilFirstUpdate: true })
167-
protected floatingPropChange() {
168180
this._updateState();
181+
super.update(properties);
169182
}
170183

171-
public override async connectedCallback() {
184+
/** @internal */
185+
public override connectedCallback(): void {
172186
super.connectedCallback();
187+
this.updateComplete.then(() => {
188+
this._setOpenState(this.open);
189+
});
190+
}
173191

174-
await this.updateComplete;
175-
if (this.open) {
176-
this.show();
192+
/** @internal */
193+
public override disconnectedCallback(): void {
194+
super.disconnectedCallback();
195+
this._setOpenState(false);
196+
}
197+
198+
//#endregion
199+
200+
private _handleSlotChange({
201+
isDefault,
202+
}: SlotChangeCallbackParameters<unknown>): void {
203+
if (isDefault) {
204+
return;
177205
}
206+
207+
const possibleTarget = first(
208+
this._slots.getAssignedElements('anchor', { flatten: true })
209+
);
210+
211+
if (this.anchor || !possibleTarget) {
212+
return;
213+
}
214+
215+
this._target = possibleTarget;
216+
this._updateState();
178217
}
179218

180-
public override disconnectedCallback() {
181-
this.hide();
182-
super.disconnectedCallback();
219+
//#region Internal open state API
220+
private _setOpenState(state: boolean): void {
221+
state ? this._setDispose() : this._clearDispose();
222+
this._setPopoverState(state);
183223
}
184224

185-
protected show() {
186-
if (!this.target) {
225+
private _setPopoverState(open: boolean): void {
226+
if (!this._target) {
187227
return;
188228
}
229+
open ? this._container?.showPopover() : this._container?.hidePopover();
230+
}
189231

190-
this._showPopover();
232+
private _setDispose(): void {
233+
if (!this._target) {
234+
return;
235+
}
191236

192-
this.dispose = autoUpdate(
193-
this.target,
237+
this._dispose = autoUpdate(
238+
this._target,
194239
this._container,
195240
this._updatePosition.bind(this)
196241
);
197242
}
198243

199-
protected async hide(): Promise<void> {
200-
this._hidePopover();
201-
244+
private _clearDispose(): Promise<void> {
202245
return new Promise((resolve) => {
203-
this.dispose?.();
204-
this.dispose = undefined;
246+
this._dispose?.();
247+
this._dispose = undefined;
205248
resolve();
206249
});
207250
}
208251

209-
private _showPopover() {
210-
this._container?.showPopover();
252+
private async _updateState(): Promise<void> {
253+
if (this.open) {
254+
await this._clearDispose();
255+
this._setDispose();
256+
}
211257
}
212258

213-
private _hidePopover() {
214-
this._container?.hidePopover();
215-
}
259+
//#endregion
260+
261+
//#region Internal position API
216262

217263
private _createMiddleware(): Middleware[] {
218264
const middleware: Middleware[] = [];
219-
const styles = this._container.style;
265+
const container = this._container;
220266

221267
if (this.offset) {
222268
middleware.push(offset(this.offset));
@@ -246,32 +292,24 @@ export default class IgcPopoverComponent extends LitElement {
246292
if (this.sameWidth) {
247293
middleware.push(
248294
size({
249-
apply: ({ rects }) => {
250-
Object.assign(styles, { width: `${rects.reference.width}px` });
251-
},
295+
apply: ({ rects }) =>
296+
setStyles(container, { width: `${rects.reference.width}px` }),
252297
})
253298
);
254299
} else {
255-
Object.assign(styles, { width: '' });
300+
setStyles(container, { width: '' });
256301
}
257302

258303
return middleware;
259304
}
260305

261-
private async _updateState() {
262-
if (this.open) {
263-
await this.hide();
264-
this.show();
265-
}
266-
}
267-
268-
private async _updatePosition() {
269-
if (!(this.open && this.target)) {
306+
private async _updatePosition(): Promise<void> {
307+
if (!(this.open && this._target)) {
270308
return;
271309
}
272310

273311
const { x, y, middlewareData, placement } = await computePosition(
274-
this.target,
312+
this._target,
275313
this._container,
276314
{
277315
placement: this.placement ?? 'bottom-start',
@@ -280,54 +318,45 @@ export default class IgcPopoverComponent extends LitElement {
280318
}
281319
);
282320

283-
Object.assign(this._container.style, {
284-
left: 0,
285-
top: 0,
321+
setStyles(this._container, {
322+
left: '0',
323+
top: '0',
286324
transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`,
287325
});
288326

289-
this._positionArrow(placement, middlewareData);
327+
this._updateArrowPosition(placement, middlewareData);
290328
}
291329

292-
private _positionArrow(placement: Placement, data: MiddlewareData) {
330+
private _updateArrowPosition(placement: Placement, data: MiddlewareData) {
293331
if (!(data.arrow && this.arrow)) {
294332
return;
295333
}
296334

297335
const { x, y } = data.arrow;
336+
const arrow = this.arrow;
337+
const offset = this.arrowOffset;
298338

299339
// The current placement of the popover along the x/y axis
300340
const currentPlacement = first(placement.split('-'));
301341

302342
// The opposite side where the arrow element should render based on the `currentPlacement`
303-
const staticSide = {
304-
top: 'bottom',
305-
right: 'left',
306-
bottom: 'top',
307-
left: 'right',
308-
}[currentPlacement]!;
343+
const staticSide =
344+
IgcPopoverComponent._oppositeArrowSide.get(currentPlacement)!;
309345

310-
this.arrow.part = currentPlacement;
346+
arrow.part = currentPlacement;
311347

312-
Object.assign(this.arrow.style, {
313-
left: x != null ? `${roundByDPR(x + this.arrowOffset)}px` : '',
314-
top: y != null ? `${roundByDPR(y + this.arrowOffset)}px` : '',
348+
setStyles(arrow, {
349+
left: x != null ? `${roundByDPR(x + offset)}px` : '',
350+
top: y != null ? `${roundByDPR(y + offset)}px` : '',
315351
[staticSide]: '-4px',
316352
});
317353
}
318354

319-
private _anchorSlotChange() {
320-
if (this.anchor || isEmpty(this._anchors)) {
321-
return;
322-
}
323-
324-
this.target = this._anchors[0];
325-
this._updateState();
326-
}
355+
//#endregion
327356

328357
protected override render() {
329358
return html`
330-
<slot name="anchor" @slotchange=${this._anchorSlotChange}></slot>
359+
<slot name="anchor"></slot>
331360
<div id="container" part="container" popover="manual">
332361
<slot></slot>
333362
</div>

0 commit comments

Comments
 (0)