UNPKG

@carbon/ibm-products-web-components

Version:

Carbon for IBM Products Web Components

684 lines (676 loc) 27.4 kB
/** * 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/components/button/index.js'; import '@carbon/web-components/es/components/layer/index.js'; import '@carbon/web-components/es/components/button/button-set-base.js'; import '@carbon/web-components/es/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" @slotchange=${this._checkSetHasSlot}></slot> </h2> <h3 class=${`${blockClassModalHeader}__heading ${blockClass}__heading`} ?hidden=${!this._hasTitle} > <slot name="title" @slotchange=${this._checkSetHasSlot}></slot> </h3> <div class=${`${blockClass}__header-description`} ?hidden=${!this._hasDescription} > <slot name="description" @slotchange=${this._checkSetHasSlot}></slot> </div> </div>`; const headerActionsTemplate = html ` <div class=${`${blockClass}__header-actions`} ?hidden=${!this._hasHeaderActions || this.width === 'narrow'} > <slot name="header-actions" @slotchange=${this._checkSetHasSlot}></slot> </div>`; const headerTemplate = html ` <cds-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-layer level="1" class=${`${blockClass}__header-content`} >${headerFieldsTemplate}${headerActionsTemplate}</cds-layer >` : html `<div>${headerFieldsTemplate}${headerActionsTemplate}</div>`} <div class=${`${blockClass}__header-navigation`} ?hidden=${!this._hasHeaderNavigation || this.width === 'narrow'} > <slot name="header-navigation" @slotchange=${this._checkSetHasSlot} ></slot> </div> <slot name="slug" @slotchange=${this._handleSlugChange}></slot> ${this.hasCloseIcon || (this === null || this === void 0 ? void 0 : this._actionsCount) === 0 ? html `<cds-modal-close-button close-button-label=${closeIconDescription} @click=${this._handleUserInitiatedClose} ></cds-modal-close-button>` : ''} </cds-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} @click=${this._handleClickContainer} > <!-- Header --> ${headerTemplate} <!-- Body --> <cds-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" @slotchange=${this._checkSetHasSlot} ></slot> </div>` : ''} <div class=${`${blockClass}__right`}> <div class=${`${blockClass}__main`}> <div class=${`${blockClass}__content`}> <cds-layer level="0"> <slot></slot> </cds-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" @slotchange=${this._checkSetHasSlot} ></slot> </div>` : ''} </div> <!-- Action buttons --> <cds-button-set-base class=${`${blockClass}__buttons ${blockClass}__button-container`} actions-multiple=${actionsMultiple} ?tearsheet-wide=${width === 'wide'} ?hidden=${this._actionsCount === 0} > <slot name="actions" @slotchange=${this._handleActionsChange} ></slot> </cds-button-set-base> </div> </cds-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-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