@carbon/ibm-products-web-components
Version:
Carbon for IBM Products Web Components
684 lines (676 loc) • 27.5 kB
JavaScript
/**
* Copyright IBM Corp. 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { __decorate } from 'tslib';
import { LitElement, html } from 'lit';
import { query, queryAssignedElements, state, property } from 'lit/decorators.js';
import { prefix, carbonPrefix, selectorTabbable } from '../../globals/settings.js';
import HostListener from '@carbon/web-components/es/globals/decorators/host-listener.js';
import HostListenerMixin from '@carbon/web-components/es/globals/mixins/host-listener.js';
import styles from './tearsheet.scss.js';
import pconsole from '../../globals/internal/pconsole.js';
import { carbonElement } from '@carbon/web-components/es/globals/decorators/carbon-element.js';
import '@carbon/web-components/es-custom/components/button/index.js';
import '@carbon/web-components/es-custom/components/layer/index.js';
import '@carbon/web-components/es-custom/components/button/button-set-base.js';
import '@carbon/web-components/es-custom/components/modal/index.js';
import { TEARSHEET_INFLUENCER_PLACEMENT, TEARSHEET_INFLUENCER_WIDTH, TEARSHEET_WIDTH } from './defs.js';
/**
* @license
*
* Copyright IBM Corp. 2023, 2024
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
var CDSTearsheet_1;
const maxStackDepth = 3;
const PRECEDING =
// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope
Node.DOCUMENT_POSITION_PRECEDING | Node.DOCUMENT_POSITION_CONTAINS;
const FOLLOWING =
// eslint-disable-next-line ssr-friendly/no-dom-globals-in-module-scope
Node.DOCUMENT_POSITION_FOLLOWING | Node.DOCUMENT_POSITION_CONTAINED_BY;
const blockClass = `${prefix}--tearsheet`;
const blockClassModalHeader = `${carbonPrefix}--modal-header`;
const blockClassActionSet = `${prefix}--action-set`;
/**
* Tries to focus on the given elements and bails out if one of them is successful.
*
* @param elements The elements.
* @param reverse `true` to go through the list in reverse order.
* @returns `true` if one of the attempts is successful, `false` otherwise.
*/
function tryFocusElements(elements, reverse) {
{
for (let i = elements.length - 1; i >= 0; --i) {
const elem = elements[i];
elem.focus();
if (elem.ownerDocument.activeElement === elem) {
return true;
}
}
}
return false;
}
/**
* Tearsheet.
*
* @element c4p-tearsheet
* @csspart dialog The dialog.
* @fires c4p-tearsheet-beingclosed
* The custom event fired before this tearsheet is being closed upon a user gesture.
* Cancellation of this event stops the user-initiated action of closing this tearsheet.
* @fires c4p-tearsheet-closed - The custom event fired after this tearsheet is closed upon a user gesture.
*/
let CDSTearsheet = CDSTearsheet_1 = class CDSTearsheet extends HostListenerMixin(LitElement) {
constructor() {
super(...arguments);
/**
* The element that had focus before this tearsheet gets open.
*/
this._launcher = null;
this._actionsCount = 0;
this._hasHeaderActions = false;
this._hasLabel = false;
this._hasSlug = false;
this._hasTitle = false;
this._hasDescription = false;
this._hasInfluencerLeft = false;
this._hasInfluencerRight = false;
this._isOpen = false;
this._hasHeaderNavigation = false;
/**
* Handles `click` event on this element.
*
* @param event The event.
*/
this._handleClick = (event) => {
if (event.composedPath().indexOf(this.shadowRoot) < 0 &&
!this.preventCloseOnClickOutside) {
this._handleUserInitiatedClose(event.target);
}
};
/**
* Handles `blur` event on this element.
*
* @param event The event.
* @param event.target The event target.
* @param event.relatedTarget The event relatedTarget.
*/
this._handleBlur = async ({ target, relatedTarget }) => {
var _a;
if (!this._topOfStack()) {
return;
}
const {
// condensedActions,
open, _startSentinelNode: startSentinelNode, _endSentinelNode: endSentinelNode, } = this;
const oldContains = target !== this && this.contains(target);
const currentContains = relatedTarget !== this &&
(this.contains(relatedTarget) ||
(((_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.contains(relatedTarget)) &&
relatedTarget !== startSentinelNode &&
relatedTarget !== endSentinelNode));
// Performs focus wrapping if _all_ of the following is met:
// * This tearsheet is open
// * The viewport still has focus
// * Tearsheet body used to have focus but no longer has focus
const { selectorTabbable: selectorTabbableForTearsheet } = this
.constructor;
if (open && relatedTarget && oldContains && !currentContains) {
const comparisonResult = target.compareDocumentPosition(relatedTarget);
if (relatedTarget === startSentinelNode || comparisonResult & PRECEDING) {
await this.constructor._delay();
if (!tryFocusElements(this.querySelectorAll(selectorTabbableForTearsheet)) &&
relatedTarget !== this) {
this.focus();
}
}
else if (relatedTarget === endSentinelNode ||
comparisonResult & FOLLOWING) {
await this.constructor._delay();
if (!tryFocusElements(this.querySelectorAll(selectorTabbableForTearsheet))) {
this.focus();
}
}
}
};
this._handleKeydown = ({ key, target }) => {
if ((key === 'Esc' || key === 'Escape') && this._topOfStack()) {
this._handleUserInitiatedClose(target);
}
};
/**
* Optional aria label for the tearsheet
*/
this.ariaLabel = '';
/**
* Sets the close button icon description
*/
this.closeIconDescription = 'Close';
/**
* Enable a close icon ('x') in the header area of the tearsheet. By default,
* (when this prop is omitted, or undefined or null) a tearsheet does not
* display a close icon if there are navigation actions ("transactional
* tearsheet") and displays one if there are no navigation actions ("passive
* tearsheet"), and that behavior can be overridden if required by setting
* this prop to either true or false.
*/
this.hasCloseIcon = false;
/**
* The placement of the influencer section, 'left' or 'right'.
*/
this.influencerPlacement = TEARSHEET_INFLUENCER_PLACEMENT.RIGHT;
/**
* The width of the influencer section, 'narrow' or 'wide'.
*/
this.influencerWidth = TEARSHEET_INFLUENCER_WIDTH.NARROW;
/**
* `true` if the tearsheet should be open.
*/
this.open = false;
/**
* Prevent closing on click outside of tearsheet
*/
this.preventCloseOnClickOutside = false;
/**
* The width of the influencer section, 'narrow' or 'wide'.
*/
this.width = TEARSHEET_WIDTH.NARROW;
this._checkUpdateActionSizes = () => {
if (this._actions) {
for (let i = 0; i < this._actions.length; i++) {
this._actions[i].setAttribute('size', this.width === 'wide' ? '2xl' : 'xl');
}
}
};
this._maxActions = 4;
// Data structure to communicate the state of tearsheet stacking
// (i.e. when more than one tearsheet is open). Each tearsheet supplies a
// handler to be called whenever the stacking of the tearsheets changes, which
// happens when a tearsheet opens or closes. The 'open' array contains one
// handler per OPEN tearsheet ordered from lowest to highest in visual z-order.
// The 'all' array contains all the handlers for open and closed tearsheets.
this._stackDepth = -1;
this._stackPosition = -1;
this._topOfStack = () => {
return this._stackDepth === this._stackPosition;
};
this._notifyStack = () => {
CDSTearsheet_1._stack.all.forEach((handler) => {
handler(Math.min(CDSTearsheet_1._stack.open.length, maxStackDepth), CDSTearsheet_1._stack.open.indexOf(handler) + 1);
});
};
this._handleStackChange = (newDepth, newPosition) => {
this._stackDepth = newDepth;
this._stackPosition = newPosition;
if (this._stackDepth > 1 && this._stackPosition > 0) {
this.setAttribute('stack-position', `${newPosition}`);
this.setAttribute('stack-depth', `${this._stackDepth}`);
}
else {
this.removeAttribute('stack-position');
this.removeAttribute('stack-depth');
}
};
this._updateStack = () => {
if (this.open) {
CDSTearsheet_1._stack.open.push(this._handleStackChange);
}
else {
const indexOpen = CDSTearsheet_1._stack.open.indexOf(this._handleStackChange);
if (indexOpen >= 0) {
CDSTearsheet_1._stack.open.splice(indexOpen, 1);
}
}
this._notifyStack();
};
this.actionsMultiple = ['', 'single', 'double', 'triple'][this._actionsCount];
this._checkSetOpen = () => {
const { _tearsheet: tearsheet } = this;
if (tearsheet && this._isOpen) {
// wait until the tearsheet has transitioned off the screen to remove
tearsheet.addEventListener('transitionend', () => {
this._isOpen = false;
});
}
else {
// allow the html to render before animating in the tearsheet
window.requestAnimationFrame(() => {
this._isOpen = this.open;
});
}
};
}
_checkSetHasSlot(e) {
var _a, _b;
const t = e.target;
const dataPostfix = t.getAttribute('data-postfix');
const postfix = dataPostfix ? `-${dataPostfix}` : '';
// snake `ab-cd-ef` to _has camel case _hasAbCdEf
const hasName = `_has-${t.name}${postfix}`.replace(/-./g, (c) => c[1].toUpperCase());
this[hasName] = ((_b = (_a = t === null || t === void 0 ? void 0 : t.assignedElements()) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0;
}
/**
* Handles `click` event on the modal container.
*
* @param event The event.
*/
_handleClickContainer(event) {
if (event.target.matches(this.constructor.selectorCloseButton)) {
this._handleUserInitiatedClose(event.target);
}
}
/**
* Handles user-initiated close request of this tearsheet.
*
* @param triggeredBy The element that triggered this close request.
*/
_handleUserInitiatedClose(triggeredBy) {
if (this.open) {
const init = {
bubbles: true,
cancelable: true,
composed: true,
detail: {
triggeredBy,
},
};
if (this.dispatchEvent(new CustomEvent(this.constructor.eventBeforeClose, init))) {
this.open = false;
this.dispatchEvent(new CustomEvent(this.constructor.eventClose, init));
}
}
}
_handleSlugChange(e) {
const childItems = e.target.assignedElements();
this._hasSlug = childItems.length > 0;
if (this._hasSlug) {
childItems[0].setAttribute('size', 'sm');
this.setAttribute('slug', '');
}
else {
this.removeAttribute('slug');
}
}
_handleActionsChange(e) {
var _a;
const target = e.target;
const actions = target === null || target === void 0 ? void 0 : target.assignedElements();
const actionsCount = (_a = actions === null || actions === void 0 ? void 0 : actions.length) !== null && _a !== void 0 ? _a : 0;
if (actionsCount > this._maxActions) {
this._actionsCount = this._maxActions;
console.error(`Too many tearsheet actions, max ${this._maxActions}.`);
}
else {
this._actionsCount = actionsCount;
}
for (let i = 0; i < (actions === null || actions === void 0 ? void 0 : actions.length); i++) {
if (i + 1 > this._maxActions) {
// hide excessive tearsheet actions
actions[i].setAttribute('hidden', 'true');
actions[i].setAttribute(`data-actions-limit-${this._maxActions}-exceeded`, `${actions.length}`);
}
else {
actions[i].classList.add(`${blockClassActionSet}__action-button`);
}
}
this._checkUpdateActionSizes();
}
connectedCallback() {
super.connectedCallback();
CDSTearsheet_1._stack.all.push(this._handleStackChange);
}
disconnectedCallback() {
super.disconnectedCallback();
const indexAll = CDSTearsheet_1._stack.all.indexOf(this._handleStackChange);
CDSTearsheet_1._stack.all.splice(indexAll, 1);
const indexOpen = CDSTearsheet_1._stack.all.indexOf(this._handleStackChange);
CDSTearsheet_1._stack.open.splice(indexOpen, 1);
}
render() {
const { closeIconDescription, influencerPlacement, influencerWidth, open, width, } = this;
const actionsMultiple = ['', 'single', 'double', 'triple'][this._actionsCount];
const headerFieldsTemplate = html `<div
class=${`${blockClass}__header-fields`}
>
<h2 class=${`${blockClassModalHeader}__label`} ?hidden=${!this._hasLabel}>
<slot name="label" =${this._checkSetHasSlot}></slot>
</h2>
<h3
class=${`${blockClassModalHeader}__heading ${blockClass}__heading`}
?hidden=${!this._hasTitle}
>
<slot name="title" =${this._checkSetHasSlot}></slot>
</h3>
<div
class=${`${blockClass}__header-description`}
?hidden=${!this._hasDescription}
>
<slot name="description" =${this._checkSetHasSlot}></slot>
</div>
</div>`;
const headerActionsTemplate = html ` <div
class=${`${blockClass}__header-actions`}
?hidden=${!this._hasHeaderActions || this.width === 'narrow'}
>
<slot name="header-actions" =${this._checkSetHasSlot}></slot>
</div>`;
const headerTemplate = html ` <cds-custom-modal-header
class=${`${blockClass}__header`}
?has-close-icon=${this.hasCloseIcon || (this === null || this === void 0 ? void 0 : this._actionsCount) === 0}
?has-navigation=${this._hasHeaderNavigation && this.width === 'wide'}
?has-header-actions=${this._hasHeaderActions && this.width === 'wide'}
?has-actions=${(this === null || this === void 0 ? void 0 : this._actionsCount) > 0}
?has-slug=${this === null || this === void 0 ? void 0 : this._hasSlug}
width=${width}
>
${this.width === TEARSHEET_WIDTH.WIDE
? html `<cds-custom-layer level="1" class=${`${blockClass}__header-content`}
>${headerFieldsTemplate}${headerActionsTemplate}</cds-custom-layer
>`
: html `<div>${headerFieldsTemplate}${headerActionsTemplate}</div>`}
<div
class=${`${blockClass}__header-navigation`}
?hidden=${!this._hasHeaderNavigation || this.width === 'narrow'}
>
<slot
name="header-navigation"
=${this._checkSetHasSlot}
></slot>
</div>
<slot name="slug" =${this._handleSlugChange}></slot>
${this.hasCloseIcon || (this === null || this === void 0 ? void 0 : this._actionsCount) === 0
? html `<cds-custom-modal-close-button
close-button-label=${closeIconDescription}
=${this._handleUserInitiatedClose}
></cds-custom-modal-close-button>`
: ''}
</cds-custom-modal-header>`;
if (this._stackPosition <= this._stackDepth) {
return html `
<a
id="start-sentinel"
class="${prefix}--visually-hidden"
href="javascript:void 0"
role="navigation"
></a>
<div
aria-label=${this.ariaLabel}
class=${`${blockClass}__container ${carbonPrefix}--modal-container ${carbonPrefix}--modal-container--sm`}
part="dialog"
role="complementary"
?open=${this._isOpen}
?opening=${open && !this._isOpen}
?closing=${!open && this._isOpen}
width=${width}
stack-position=${this._stackPosition}
stack-depth=${this._stackDepth}
=${this._handleClickContainer}
>
<!-- Header -->
${headerTemplate}
<!-- Body -->
<cds-custom-modal-body class=${`${blockClass}__body`} width=${width}>
<!-- Influencer when on left -->
${influencerPlacement !== TEARSHEET_INFLUENCER_PLACEMENT.RIGHT
? html `<div
class=${`${blockClass}__influencer`}
?wide=${influencerWidth === 'wide'}
?hidden=${!this._hasInfluencerLeft ||
this.width === TEARSHEET_WIDTH.NARROW}
>
<slot
name="influencer"
data-postfix="left"
=${this._checkSetHasSlot}
></slot>
</div>`
: ''}
<div class=${`${blockClass}__right`}>
<div class=${`${blockClass}__main`}>
<div class=${`${blockClass}__content`}>
<cds-custom-layer level="0">
<slot></slot>
</cds-custom-layer>
</div>
<!-- Influencer when on right -->
${influencerPlacement === TEARSHEET_INFLUENCER_PLACEMENT.RIGHT
? html `<div
class=${`${blockClass}__influencer`}
?wide=${influencerWidth}
?hidden=${!this._hasInfluencerRight ||
this.width === TEARSHEET_WIDTH.NARROW}
>
<slot
name="influencer"
data-postfix="right"
=${this._checkSetHasSlot}
></slot>
</div>`
: ''}
</div>
<!-- Action buttons -->
<cds-custom-button-set-base
class=${`${blockClass}__buttons ${blockClass}__button-container`}
actions-multiple=${actionsMultiple}
?tearsheet-wide=${width === 'wide'}
?hidden=${this._actionsCount === 0}
>
<slot
name="actions"
=${this._handleActionsChange}
></slot>
</cds-custom-button-set-base>
</div>
</cds-custom-modal-body>
</div>
<a
id="end-sentinel"
class="${prefix}--visually-hidden"
href="javascript:void 0"
role="navigation"
></a>
`;
}
else {
pconsole.warn('Tearsheet not rendered: maximum stacking depth exceeded.');
return null;
}
}
async updated(changedProperties) {
if (changedProperties.has('width')) {
this._checkUpdateActionSizes();
}
if (process.env.NODE_ENV === 'development' &&
(changedProperties.has('width') ||
changedProperties.has('_hasHeaderNavigation') ||
changedProperties.has('_hasInfluencerLeft') ||
changedProperties.has('_hasInfluencerRight') ||
changedProperties.has('_hasHeaderActions'))) {
if (this.width === 'narrow') {
if (this._hasHeaderNavigation) {
console.error(`Header navigation is not permitted in narrow Tearsheet.`);
}
if (this._hasInfluencerLeft || this._hasInfluencerRight) {
console.error(`Influencer is not permitted in narrow Tearsheet.`);
}
if (this._hasHeaderActions) {
console.error(`Header actions are not permitted in narrow Tearsheet.`);
}
}
}
if (changedProperties.has('open')) {
this._updateStack();
this._checkSetOpen();
if (this.open) {
this._launcher = this.ownerDocument.activeElement;
const focusNode = this.selectorInitialFocus &&
this.querySelector(this.selectorInitialFocus);
await this.constructor._delay();
if (focusNode) {
// For cases where a `carbon-web-components` component (e.g. `<cds-custom-button>`) being `primaryFocusNode`,
// where its first update/render cycle that makes it focusable happens after `<c4p-tearsheet>`'s first update/render cycle
focusNode.focus();
}
else if (!tryFocusElements(this.querySelectorAll(this.constructor.selectorTabbable))) {
this.focus();
}
}
else if (this._launcher &&
typeof this._launcher.focus === 'function') {
this._launcher.focus();
this._launcher = null;
}
}
}
/**
* @param ms The number of milliseconds.
* @returns A promise that is resolves after the given milliseconds.
*/
static _delay(ms = 0) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* A selector selecting buttons that should close this modal.
*/
static get selectorCloseButton() {
return `[data-modal-close],${carbonPrefix}-modal-close-button`;
}
/**
* A selector selecting tabbable nodes.
*/
static get selectorTabbable() {
return selectorTabbable;
}
/**
* The name of the custom event fired before this tearsheet is being closed upon a user gesture.
* Cancellation of this event stops the user-initiated action of closing this tearsheet.
*/
static get eventBeforeClose() {
return `${prefix}-tearsheet-beingclosed`;
}
/**
* The name of the custom event fired after this tearsheet is closed upon a user gesture.
*/
static get eventClose() {
return `${prefix}-tearsheet-closed`;
}
/**
* The name of the custom event fired on clicking the navigate back button
*/
static get eventNavigateBack() {
return `${prefix}-tearsheet-header-navigate-back`;
}
};
CDSTearsheet._stack = {
open: [],
all: [],
};
CDSTearsheet.styles = styles; // `styles` here is a `CSSResult` generated by custom WebPack loader
__decorate([
query('#start-sentinel')
], CDSTearsheet.prototype, "_startSentinelNode", void 0);
__decorate([
query('#end-sentinel')
], CDSTearsheet.prototype, "_endSentinelNode", void 0);
__decorate([
query(`.${blockClass}__container`)
], CDSTearsheet.prototype, "_tearsheet", void 0);
__decorate([
queryAssignedElements({
slot: 'actions',
selector: `${carbonPrefix}-button`,
})
], CDSTearsheet.prototype, "_actions", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_actionsCount", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasHeaderActions", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasLabel", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasSlug", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasTitle", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasDescription", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasInfluencerLeft", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasInfluencerRight", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_isOpen", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_hasHeaderNavigation", void 0);
__decorate([
HostListener('click')
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
], CDSTearsheet.prototype, "_handleClick", void 0);
__decorate([
HostListener('shadowRoot:focusout')
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
], CDSTearsheet.prototype, "_handleBlur", void 0);
__decorate([
HostListener('document:keydown')
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
], CDSTearsheet.prototype, "_handleKeydown", void 0);
__decorate([
property({ reflect: true, attribute: 'aria-label' })
], CDSTearsheet.prototype, "ariaLabel", void 0);
__decorate([
property({ reflect: true, attribute: 'close-icon-description' })
], CDSTearsheet.prototype, "closeIconDescription", void 0);
__decorate([
property({ reflect: true, type: Boolean, attribute: 'has-close-icon' })
], CDSTearsheet.prototype, "hasCloseIcon", void 0);
__decorate([
property({ reflect: true, attribute: 'influencer-placement' })
], CDSTearsheet.prototype, "influencerPlacement", void 0);
__decorate([
property({ reflect: true, attribute: 'influencer-width' })
], CDSTearsheet.prototype, "influencerWidth", void 0);
__decorate([
property({ type: Boolean, reflect: true })
], CDSTearsheet.prototype, "open", void 0);
__decorate([
property({ type: Boolean, attribute: 'prevent-close-on-click-outside' })
], CDSTearsheet.prototype, "preventCloseOnClickOutside", void 0);
__decorate([
property({
reflect: true,
attribute: 'selector-initial-focus',
type: String,
})
], CDSTearsheet.prototype, "selectorInitialFocus", void 0);
__decorate([
property({ reflect: true, attribute: 'width' })
], CDSTearsheet.prototype, "width", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_stackDepth", void 0);
__decorate([
state()
], CDSTearsheet.prototype, "_stackPosition", void 0);
CDSTearsheet = CDSTearsheet_1 = __decorate([
carbonElement(`${prefix}-tearsheet`)
], CDSTearsheet);
var CDSTearsheet$1 = CDSTearsheet;
export { TEARSHEET_INFLUENCER_PLACEMENT, TEARSHEET_INFLUENCER_WIDTH, TEARSHEET_WIDTH, CDSTearsheet$1 as default };
//# sourceMappingURL=tearsheet.js.map