2021-02-26 14:09:13 +00:00
|
|
|
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
|
|
|
|
import styles from 'sass:./details.scss';
|
|
|
|
import { focusVisible } from '../../internal/focus-visible';
|
2020-07-15 21:30:37 +00:00
|
|
|
|
|
|
|
let id = 0;
|
|
|
|
|
|
|
|
/**
|
2020-07-17 10:09:10 +00:00
|
|
|
* @since 2.0
|
2020-07-15 21:30:37 +00:00
|
|
|
* @status stable
|
|
|
|
*
|
2021-02-26 14:09:13 +00:00
|
|
|
* @dependency sl-icon
|
|
|
|
*
|
2020-07-15 21:30:37 +00:00
|
|
|
* @slot - The details' content.
|
|
|
|
* @slot summary - The details' summary. Alternatively, you can use the summary prop.
|
|
|
|
*
|
|
|
|
* @part base - The component's base wrapper.
|
2020-08-02 10:54:17 +00:00
|
|
|
* @part header - The summary header.
|
2020-07-15 21:30:37 +00:00
|
|
|
* @part summary - The details summary.
|
|
|
|
* @part summary-icon - The expand/collapse summary icon.
|
|
|
|
* @part content - The details content.
|
2021-02-26 14:09:13 +00:00
|
|
|
*
|
|
|
|
* @emit sl-show - Emitted when the details opens. Calling `event.preventDefault()` will prevent it from being opened.
|
|
|
|
* @emit after-show - Emitted after the details opens and all transitions are complete.
|
|
|
|
* @emit sl-hide - Emitted when the details closes. Calling `event.preventDefault()` will prevent it from being closed.
|
|
|
|
* @emit after-hide - Emitted after the details closes and all transitions are complete.
|
2020-07-15 21:30:37 +00:00
|
|
|
*/
|
2021-02-26 14:09:13 +00:00
|
|
|
export default class SlDetails extends Shoemaker {
|
|
|
|
static tag = 'sl-details';
|
|
|
|
static props = ['open', 'summary', 'disabled'];
|
|
|
|
static reflect = ['open', 'disabled'];
|
|
|
|
static styles = styles;
|
|
|
|
|
|
|
|
private body: HTMLElement;
|
|
|
|
private componentId = `details-${++id}`;
|
|
|
|
private details: HTMLElement;
|
|
|
|
private header: HTMLElement;
|
|
|
|
private isVisible = false;
|
2020-07-15 21:30:37 +00:00
|
|
|
|
|
|
|
/** Indicates whether or not the details is open. You can use this in lieu of the show/hide methods. */
|
2021-02-26 14:09:13 +00:00
|
|
|
open = false;
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2020-08-03 16:54:47 +00:00
|
|
|
/** The summary to show in the details header. If you need to display HTML, use the `summary` slot instead. */
|
2021-02-26 14:09:13 +00:00
|
|
|
summary = '';
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
/** Disables the details so it can't be toggled. */
|
|
|
|
disabled = false;
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
onReady() {
|
2020-07-15 21:30:37 +00:00
|
|
|
focusVisible.observe(this.details);
|
|
|
|
|
2021-02-05 21:09:05 +00:00
|
|
|
this.body.hidden = !this.open;
|
|
|
|
|
2020-07-15 21:30:37 +00:00
|
|
|
// Show on init if open
|
|
|
|
if (this.open) {
|
2020-08-05 17:56:47 +00:00
|
|
|
this.show();
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
onDisconnect() {
|
2020-08-05 18:54:48 +00:00
|
|
|
focusVisible.unobserve(this.details);
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Shows the alert. */
|
2021-02-26 14:09:13 +00:00
|
|
|
show() {
|
2020-08-13 14:29:31 +00:00
|
|
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
2020-10-13 12:53:44 +00:00
|
|
|
if (this.isVisible) {
|
2020-08-13 14:29:31 +00:00
|
|
|
return;
|
|
|
|
}
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
const slShow = this.emit('sl-show');
|
2020-07-15 21:30:37 +00:00
|
|
|
if (slShow.defaultPrevented) {
|
2020-08-13 14:29:31 +00:00
|
|
|
this.open = false;
|
|
|
|
return;
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
|
2021-02-05 21:09:05 +00:00
|
|
|
this.body.hidden = false;
|
|
|
|
|
2020-08-05 17:56:47 +00:00
|
|
|
if (this.body.scrollHeight === 0) {
|
|
|
|
// When the scroll height can't be measured, use auto. This prevents a borked open state when the details is open
|
2021-03-03 12:59:40 +00:00
|
|
|
// intitially, but not immediately visible (i.e. in a tab panel).
|
2020-08-05 17:56:47 +00:00
|
|
|
this.body.style.height = 'auto';
|
|
|
|
this.body.style.overflow = 'visible';
|
|
|
|
} else {
|
|
|
|
this.body.style.height = `${this.body.scrollHeight}px`;
|
|
|
|
this.body.style.overflow = 'hidden';
|
|
|
|
}
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2020-10-13 12:53:44 +00:00
|
|
|
this.isVisible = true;
|
2020-07-15 21:30:37 +00:00
|
|
|
this.open = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Hides the alert */
|
2021-02-26 14:09:13 +00:00
|
|
|
hide() {
|
2020-08-13 14:29:31 +00:00
|
|
|
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
|
2020-10-13 12:53:44 +00:00
|
|
|
if (!this.isVisible) {
|
2020-08-13 14:29:31 +00:00
|
|
|
return;
|
|
|
|
}
|
2020-07-15 21:30:37 +00:00
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
const slHide = this.emit('sl-hide');
|
2020-07-15 21:30:37 +00:00
|
|
|
if (slHide.defaultPrevented) {
|
2020-08-13 14:29:31 +00:00
|
|
|
this.open = true;
|
|
|
|
return;
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// We can't transition out of `height: auto`, so let's set it to the current height first
|
|
|
|
this.body.style.height = `${this.body.scrollHeight}px`;
|
|
|
|
this.body.style.overflow = 'hidden';
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
this.body.clientWidth; // force a reflow
|
|
|
|
this.body.style.height = '0';
|
|
|
|
});
|
|
|
|
|
2020-10-13 12:53:44 +00:00
|
|
|
this.isVisible = false;
|
2020-07-15 21:30:37 +00:00
|
|
|
this.open = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
handleBodyTransitionEnd(event: TransitionEvent) {
|
|
|
|
const target = event.target as HTMLElement;
|
|
|
|
|
|
|
|
// Ensure we only emit one event when the target element is no longer visible
|
|
|
|
if (event.propertyName === 'height' && target.classList.contains('details__body')) {
|
|
|
|
this.body.style.overflow = this.open ? 'visible' : 'hidden';
|
|
|
|
this.body.style.height = this.open ? 'auto' : '0';
|
2021-02-26 14:09:13 +00:00
|
|
|
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
|
2021-02-05 21:09:05 +00:00
|
|
|
this.body.hidden = !this.open;
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
handleSummaryClick() {
|
|
|
|
if (!this.disabled) {
|
|
|
|
this.open ? this.hide() : this.show();
|
|
|
|
this.header.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
handleSummaryKeyDown(event: KeyboardEvent) {
|
|
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
|
|
event.preventDefault();
|
|
|
|
this.open ? this.hide() : this.show();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
|
|
event.preventDefault();
|
|
|
|
this.hide();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
|
|
event.preventDefault();
|
|
|
|
this.show();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
watchOpen() {
|
|
|
|
this.open ? this.show() : this.hide();
|
|
|
|
}
|
|
|
|
|
2020-07-15 21:30:37 +00:00
|
|
|
render() {
|
2021-02-26 14:09:13 +00:00
|
|
|
return html`
|
2020-07-15 21:30:37 +00:00
|
|
|
<div
|
2021-02-26 14:09:13 +00:00
|
|
|
ref=${(el: HTMLElement) => (this.details = el)}
|
2020-07-15 21:30:37 +00:00
|
|
|
part="base"
|
2021-02-26 14:09:13 +00:00
|
|
|
class=${classMap({
|
2020-07-15 21:30:37 +00:00
|
|
|
details: true,
|
|
|
|
'details--open': this.open,
|
|
|
|
'details--disabled': this.disabled
|
2021-02-26 14:09:13 +00:00
|
|
|
})}
|
2020-07-15 21:30:37 +00:00
|
|
|
>
|
|
|
|
<header
|
2021-02-26 14:09:13 +00:00
|
|
|
ref=${(el: HTMLElement) => (this.header = el)}
|
2020-08-02 10:54:17 +00:00
|
|
|
part="header"
|
2021-02-26 14:09:13 +00:00
|
|
|
id=${`${this.componentId}-header`}
|
2020-07-15 21:30:37 +00:00
|
|
|
class="details__header"
|
|
|
|
role="button"
|
2021-02-26 14:09:13 +00:00
|
|
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
|
|
aria-controls=${`${this.componentId}-content`}
|
|
|
|
aria-disabled=${this.disabled ? 'true' : 'false'}
|
|
|
|
tabindex=${this.disabled ? '-1' : '0'}
|
|
|
|
onclick=${this.handleSummaryClick.bind(this)}
|
|
|
|
onkeydown=${this.handleSummaryKeyDown.bind(this)}
|
2020-07-15 21:30:37 +00:00
|
|
|
>
|
|
|
|
<div part="summary" class="details__summary">
|
2021-02-26 14:09:13 +00:00
|
|
|
<slot name="summary">${this.summary}</slot>
|
2020-07-15 21:30:37 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
<span part="summary-icon" class="details__summary-icon">
|
|
|
|
<sl-icon name="chevron-right" />
|
|
|
|
</span>
|
|
|
|
</header>
|
|
|
|
|
2021-02-26 14:09:13 +00:00
|
|
|
<div
|
|
|
|
ref=${(el: HTMLElement) => (this.body = el)}
|
|
|
|
class="details__body"
|
|
|
|
ontransitionend=${this.handleBodyTransitionEnd.bind(this)}
|
|
|
|
>
|
2020-07-15 21:30:37 +00:00
|
|
|
<div
|
|
|
|
part="content"
|
2021-02-26 14:09:13 +00:00
|
|
|
id=${`${this.componentId}-content`}
|
2020-07-15 21:30:37 +00:00
|
|
|
class="details__content"
|
|
|
|
role="region"
|
2021-02-26 14:09:13 +00:00
|
|
|
aria-labelledby=${`${this.componentId}-header`}
|
2020-07-15 21:30:37 +00:00
|
|
|
>
|
|
|
|
<slot />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
2021-02-26 14:09:13 +00:00
|
|
|
`;
|
2020-07-15 21:30:37 +00:00
|
|
|
}
|
|
|
|
}
|