Skip to content

Commit

Permalink
add support for external anchors
Browse files Browse the repository at this point in the history
  • Loading branch information
claviska committed Aug 15, 2022
1 parent 2372309 commit 70f59aa
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 21 deletions.
31 changes: 30 additions & 1 deletion docs/components/popup.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ const App = () => {

## Examples

### Active
### Activating

Popups are inactive and hidden until the `active` attribute is applied. Removing the attribute will tear down all positioning logic and listeners, meaning you can have many idle popups on the page without affecting performance.

Expand Down Expand Up @@ -304,6 +304,35 @@ const App = () => {
};
```

### External Anchors

By default, anchors are slotted into the popup using the `anchor` slot. If your anchor needs to live outside of the popup, you can pass the anchor's `id` to the `anchor` attribute. Alternatively, you can pass an element reference to the `anchor` property to achieve the same effect without using an `id`.

```html preview
<span id="external-anchor"></span>

<sl-popup anchor="external-anchor" placement="top" active>
<div class="box"></div>
</sl-popup>

<style>
#external-anchor {
display: inline-block;
width: 150px;
height: 150px;
border: dashed 2px var(--sl-color-neutral-600);
margin: 50px 0 0 50px;
}
#external-anchor ~ sl-popup .box {
width: 100px;
height: 50px;
background: var(--sl-color-primary-600);
border-radius: var(--sl-border-radius-medium);
}
</style>
```

### Placement

Use the `placement` attribute to tell the popup the preferred placement of the popup. Note that the actual position will vary to ensure the panel remains in the viewport if you're using positioning features such as `flip` and `shift`.
Expand Down
1 change: 1 addition & 0 deletions docs/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
## Next

- 馃毃 BREAKING: removed the `base` part from `<sl-menu>` and removed an unnecessary `<div>` that made styling more difficult
- Added the `anchor` property to `<sl-popup>` to support external anchors
- Added read-only custom properties `--auto-size-available-width` and `--auto-size-available-height` to `<sl-popup>` to improve support for overflowing popup content
- Added `label` to `<sl-rating>` to improve accessibility for screen readers
- Fixed a bug where auto-size wasn't being applied to `<sl-dropdown>` and `<sl-select>`
Expand Down
64 changes: 44 additions & 20 deletions src/components/popup/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import type { CSSResultGroup } from 'lit';
* operations in your listener or consider debouncing it.
*
* @slot - The popup's content.
* @slot anchor - The element the popup will be anchored to.
* @slot anchor - The element the popup will be anchored to. If the anchor lives outside of the popup, you can use the
* `anchor` attribute or property instead.
*
* @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are
* assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and
Expand All @@ -39,9 +40,15 @@ export default class SlPopup extends LitElement {
@query('.popup') public popup: HTMLElement;
@query('.popup__arrow') private arrowEl: HTMLElement;

private anchor: HTMLElement | null;
private anchorEl: HTMLElement | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;

/**
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide its `id` or a
* reference to it here. If the anchor lives inside the popup, use the `anchor` slot instead.
*/
@property() anchor: Element | string;

/**
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
* down and the popup will be hidden.
Expand Down Expand Up @@ -174,31 +181,43 @@ export default class SlPopup extends LitElement {
this.stop();
}

async handleAnchorSlotChange() {
async handleAnchorChange() {
await this.stop();

this.anchor = this.querySelector<HTMLElement>('[slot="anchor"]');
if (this.anchor && typeof this.anchor === 'string') {
// Locate the anchor by id
const root = this.getRootNode() as Document | ShadowRoot;
this.anchorEl = root.getElementById(this.anchor);
} else if (this.anchor instanceof HTMLElement) {
// Use the anchor's reference
this.anchorEl = this.anchor;
} else {
// Look for a slotted anchor
this.anchorEl = this.querySelector<HTMLElement>('[slot="anchor"]');
}

// If the anchor is a <slot>, we'll use the first assigned element as the target since slots use `display: contents`
// and positioning can't be calculated on them
if (this.anchor instanceof HTMLSlotElement) {
this.anchor = this.anchor.assignedElements({ flatten: true })[0] as HTMLElement;
if (this.anchorEl instanceof HTMLSlotElement) {
this.anchorEl = this.anchorEl.assignedElements({ flatten: true })[0] as HTMLElement;
}

if (!this.anchor) {
throw new Error('Invalid anchor element: no child with slot="anchor" was found.');
if (!this.anchorEl) {
throw new Error(
'Invalid anchor element: no anchor could be found using the anchor slot or the anchor attribute.'
);
}

this.start();
}

private start() {
// We can't start the positioner without an anchor
if (!this.anchor) {
if (!this.anchorEl) {
return;
}

this.cleanup = autoUpdate(this.anchor, this.popup, () => {
this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
this.reposition();
});
}
Expand All @@ -221,26 +240,31 @@ export default class SlPopup extends LitElement {
async updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);

// Start or stop the positioner when active changes
if (changedProps.has('active')) {
// Start or stop the positioner when active changes
if (this.active) {
this.start();
} else {
this.stop();
}
} else {
// All other properties will trigger a reposition when active
if (this.active) {
await this.updateComplete;
this.reposition();
}
}

// Update the anchor when anchor changes
if (changedProps.has('anchor')) {
this.handleAnchorChange();
}

// All other properties will trigger a reposition when active
if (this.active) {
await this.updateComplete;
this.reposition();
}
}

/** Recalculate and repositions the popup. */
reposition() {
// Nothing to do if the popup is inactive or the anchor doesn't exist
if (!this.active || !this.anchor) {
if (!this.active || !this.anchorEl) {
return;
}

Expand Down Expand Up @@ -303,7 +327,7 @@ export default class SlPopup extends LitElement {
);
}

computePosition(this.anchor, this.popup, {
computePosition(this.anchorEl, this.popup, {
placement: this.placement,
middleware,
strategy: this.strategy
Expand Down Expand Up @@ -336,7 +360,7 @@ export default class SlPopup extends LitElement {

render() {
return html`
<slot name="anchor" @slotchange=${this.handleAnchorSlotChange}></slot>
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
<div
part="popup"
Expand Down

0 comments on commit 70f59aa

Please sign in to comment.