kopia lustrzana https://github.com/shoelace-style/shoelace
add popup
rodzic
59182db564
commit
f31d13c424
|
@ -78,6 +78,7 @@
|
|||
"Menlo",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"middlewares",
|
||||
"minlength",
|
||||
"monospace",
|
||||
"mousedown",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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;
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
Ładowanie…
Reference in New Issue