pull/853/head
Cory LaViska 2022-08-05 09:17:58 -04:00
rodzic 59182db564
commit f31d13c424
7 zmienionych plików z 426 dodań i 0 usunięć

Wyświetl plik

@ -78,6 +78,7 @@
"Menlo",
"menuitemcheckbox",
"menuitemradio",
"middlewares",
"minlength",
"monospace",
"mousedown",

Wyświetl plik

@ -77,6 +77,7 @@
- [Format Number](/components/format-number)
- [Include](/components/include)
- [Mutation Observer](/components/mutation-observer)
- [Popup](/components/Popup)
- [Relative Time](/components/relative-time)
- [Resize Observer](/components/resize-observer)
- [Responsive Media](/components/responsive-media)

Wyświetl plik

@ -0,0 +1,112 @@
# Popup
[component-header:sl-popup]
A description of the component goes here.
```html preview
<div class="popup-overview">
<sl-popup placement="top" active>
<span slot="anchor">This is the anchor</span>
<div class="box"></div>
</sl-popup>
</div>
<div class="popup-overview-options">
<sl-select label="Placement" name="placement" value="top" class="popup-overview-select">
<sl-menu-item value="top">top</sl-menu-item>
<sl-menu-item value="top-start">top-start</sl-menu-item>
<sl-menu-item value="top-end">top-end</sl-menu-item>
<sl-menu-item value="bottom">bottom</sl-menu-item>
<sl-menu-item value="bottom-start">bottom-start</sl-menu-item>
<sl-menu-item value="bottom-end">bottom-end</sl-menu-item>
<sl-menu-item value="right">right</sl-menu-item>
<sl-menu-item value="right-start">right-start</sl-menu-item>
<sl-menu-item value="right-end">right-end</sl-menu-item>
<sl-menu-item value="left">left</sl-menu-item>
<sl-menu-item value="left-start">left-start</sl-menu-item>
<sl-menu-item value="left-end">left-end</sl-menu-item>
</sl-select>
<sl-input type="number" name="distance" label="distance" value="0"></sl-input>
<sl-input type="number" name="skidding" label="Skidding" value="0"></sl-input>
<sl-switch name="active" checked>Active</sl-switch>
<sl-switch name="arrow">Arrow</sl-switch>
<sl-switch name="fixed">Fixed</sl-switch>
</div>
<script>
const container = document.querySelector('.popup-overview');
const popup = container.querySelector('sl-popup');
const options = document.querySelector('.popup-overview-options');
const select = options.querySelector('sl-select[name="placement"]');
const distance = options.querySelector('sl-input[name="distance"]');
const skidding = options.querySelector('sl-input[name="skidding"]');
const active = options.querySelector('sl-switch[name="active"]');
const arrow = options.querySelector('sl-switch[name="arrow"]');
const fixed = options.querySelector('sl-switch[name="fixed"]');
select.addEventListener('sl-change', () => (popup.placement = select.value));
distance.addEventListener('sl-input', () => (popup.distance = distance.value));
skidding.addEventListener('sl-input', () => (popup.skidding = skidding.value));
active.addEventListener('sl-change', () => (popup.active = active.checked));
arrow.addEventListener('sl-change', () => (popup.arrow = arrow.checked));
fixed.addEventListener('sl-change', () => (popup.strategy = fixed.checked ? 'fixed' : 'absolute'));
</script>
<style>
.popup-overview {
padding: calc(50px + 1rem);
}
.popup-overview sl-popup {
--arrow-color: var(--sl-color-primary-600);
--arrow-size: 4px;
}
.popup-overview [slot='anchor'] {
border: dashed 2px var(--sl-color-neutral-200);
padding: 0.5rem;
}
.popup-overview .box {
width: 50px;
height: 50px;
background: var(--sl-color-primary-600);
}
.popup-overview-options {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 1rem;
}
.popup-overview-options sl-select {
width: 160px;
}
.popup-overview-options sl-input {
width: 100px;
}
.popup-overview-options + .popup-overview-options {
margin-top: 1rem;
}
</style>
```
## Examples
### Fixed Positioning Strategy
By default, an absolute positioning strategy is used for maximum performance. However, if your content is within a container that has `overflow: auto|hidden` the popup will be clipped. To work around this, you can switch to the fixed positioning strategy by setting the `strategy` attribute to `fixed`.
The fixed positioning strategy allows the content to break out containers that clip them. When using this strategy, it's important to note that the content will be positioned _relative to its containing block_, which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details.
TODO
### Arrows
TODO
[component-metadata:sl-popup]

Wyświetl plik

@ -0,0 +1,38 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
export default css`
${componentStyles}
:host {
--arrow-size: 4px;
--arrow-color: var(--sl-color-neutral-0);
--arrow-shadow: none;
display: inline;
}
.popup {
display: block;
position: absolute;
isolation: isolate;
}
.popup--fixed {
position: fixed;
}
.popup:not(.popup--active) {
display: none;
}
.popup__arrow {
position: absolute;
width: calc(var(--arrow-size) * 2);
height: calc(var(--arrow-size) * 2);
transform: rotate(45deg);
background: var(--arrow-color);
box-shadow: var(--arrow-shadow);
z-index: -1;
}
`;

Wyświetl plik

@ -0,0 +1,9 @@
import { expect, fixture, html } from '@open-wc/testing';
describe('<sl-popup>', () => {
it('should render a component', async () => {
const el = await fixture(html` <sl-popup></sl-popup> `);
expect(el).to.exist;
});
});

Wyświetl plik

@ -0,0 +1,264 @@
import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { watch } from '../../internal/watch';
import styles from './popup.styles';
import type { CSSResultGroup } from 'lit';
/**
* @since 2.0
* @status experimental
*
* @slot - The popup's content.
* @slot anchor - The element the popup will be anchored to.
*
* @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
* maybe a border or box shadow.
* @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc.
*
* @cssproperty [--arrow-size=4px] - The size of the arrow.
* @cssproperty [--arrow-color=4px] - The color of the arrow.
*/
@customElement('sl-popup')
export default class SlPopup extends LitElement {
static styles: CSSResultGroup = styles;
@query('.popup') popupEl: HTMLElement;
@query('.popup__arrow') arrowEl: HTMLElement;
private anchor: HTMLElement | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
/** Activates popup logic and shows the popup. */
@property({ type: Boolean, reflect: true }) active = false;
/**
* The preferred placement of the popup. Note that the actual placement will vary as configured to keep the
* panel inside of the viewport.
*/
@property({ reflect: true }) placement:
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'right'
| 'right-start'
| 'right-end'
| 'left'
| 'left-start'
| 'left-end' = 'bottom-start';
/**
* Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if
* overflow is clipped, using a `fixed` position strategy can often workaround it.
*/
@property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute';
/** The distance in pixels from which to offset the panel away from its anchor. */
@property({ type: Number }) distance = 0;
/** The distance in pixels from which to offset the panel along its anchor. */
@property({ type: Number }) skidding = 0;
/** Moves the popup along the axis to keep it in view when clipped. */
@property({ type: Boolean }) shift = false;
/** Attaches an arrow to the popup. */
@property({ type: Boolean }) arrow = false;
/**
* When set, placement of the popup will flip to the opposite site to keep it in view. You can use
* `flipFallbackPlacement` to further configure how the fallback placement is determined.
*/
@property({ type: Boolean }) flip = false;
/**
* If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a
* string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip
* fallback strategy will be used instead.
* */
@property() flipFallbackPlacement: string;
/**
* When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
* the popup should be positioned as it was initially preferred or using the best available fit based on available
* space.
*/
@property() flipFallbackStrategy: 'bestFit' | 'initialPlacement' = 'initialPlacement';
async connectedCallback() {
super.connectedCallback();
// Start the positioner after the first update
await this.updateComplete;
this.start();
}
disconnectedCallback() {
this.stop();
}
async handleAnchorSlotChange() {
await this.stop();
this.anchor = this.querySelector<HTMLElement>('[slot="anchor"]');
if (!this.anchor) {
throw new Error('Invalid anchor element: no child with slot="anchor" was found.');
}
this.start();
}
private start() {
// We can't start the positioner without an anchor
if (!this.anchor) {
return;
}
this.cleanup = autoUpdate(this.anchor, this.popupEl, () => {
this.reposition();
});
}
private async stop(): Promise<void> {
return new Promise(resolve => {
if (this.cleanup) {
this.cleanup();
this.cleanup = undefined;
requestAnimationFrame(() => resolve());
} else {
resolve();
}
});
}
@watch('active', { waitUntilFirstUpdate: true })
handleActiveChange() {
if (this.active) {
this.reposition();
} else {
this.stop();
}
}
@watch('arrow')
@watch('distance')
@watch('flip')
@watch('flipFallbackPlacement')
@watch('flipFallbackStrategy')
@watch('placement')
@watch('shift')
@watch('skidding')
@watch('strategy')
async handlePositionChange() {
if (this.hasUpdated && this.active) {
await this.updateComplete;
this.reposition();
}
}
/** Recalculate and repositions the popup. */
reposition() {
if (!this.anchor) {
throw new Error('Invalid anchor element: no child with slot="anchor" was found.');
}
// Nothing to do if the popup is inactive
if (!this.active) {
return;
}
//
// NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware
//
const middleware = [
// The offset middleware goes first
offset({ mainAxis: this.distance, crossAxis: this.skidding })
];
// Then we flip, as needed
if (this.flip) {
middleware.push(
flip({
// @ts-expect-error - We're converting a string attribute to an array here
//
// TODO - use a custom adapter for this property
//
fallbackPlacement: this.flipFallbackPlacement.split(' ').filter(p => p.trim()),
fallbackStrategy: this.flipFallbackStrategy
})
);
}
// Then we shift, as needed
if (this.shift) {
middleware.push(shift());
}
// Finally, we add an arrow
if (this.arrow) {
middleware.push(
arrow({
element: this.arrowEl,
padding: 10 // min distance from the edge, in pixels
})
);
}
computePosition(this.anchor, this.popupEl, {
placement: this.placement,
middleware,
strategy: this.strategy
}).then(({ x, y, middlewareData, placement }) => {
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
Object.assign(this.popupEl.style, {
left: `${x}px`,
top: `${y}px`
});
if (this.arrow) {
const arrowX = middlewareData.arrow?.x;
const arrowY = middlewareData.arrow?.y;
Object.assign(this.arrowEl.style, {
left: typeof arrowX === 'number' ? `${arrowX}px` : '',
top: typeof arrowY === 'number' ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: 'calc(var(--arrow-size) * -1)'
});
}
});
}
render() {
return html`
<slot name="anchor" @slotchange=${this.handleAnchorSlotChange}></slot>
<div
part="popup"
class=${classMap({
popup: true,
'popup--active': this.active,
'popup--fixed': this.strategy === 'fixed',
'popup--has-arrow': this.arrow
})}
data-placement=${this.placement}
>
<slot></slot>
${this.arrow ? html`<div part="arrow" class="popup__arrow" role="presentation"></div>` : ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-popup': SlPopup;
}
}

Wyświetl plik

@ -28,6 +28,7 @@ export { default as SlMenu } from './components/menu/menu';
export { default as SlMenuItem } from './components/menu-item/menu-item';
export { default as SlMenuLabel } from './components/menu-label/menu-label';
export { default as SlMutationObserver } from './components/mutation-observer/mutation-observer';
export { default as SlPopup } from './components/popup/popup';
export { default as SlProgressBar } from './components/progress-bar/progress-bar';
export { default as SlProgressRing } from './components/progress-ring/progress-ring';
export { default as SlQrCode } from './components/qr-code/qr-code';