From e6d2957c87722f74d69e2332fb5c98023e25885c Mon Sep 17 00:00:00 2001 From: Jeeyun Lim Date: Thu, 30 Apr 2020 15:49:20 -0700 Subject: [PATCH] feat(modal): core modal Signed-off-by: Jeeyun Lim --- scripts/ngcc.js | 2 +- .../main-container/_layout.clarity.scss | 12 +- .../utils/i18n/common-strings.interface.ts | 1 + src/clr-core/alert/alert.base.ts | 4 +- src/clr-core/alert/alert.element.spec.ts | 4 +- src/clr-core/alert/alert.stories.ts | 2 +- src/clr-core/alert/app-alert.element.spec.ts | 4 +- src/clr-core/alert/app-alert.stories.ts | 2 +- src/clr-core/import-map.importmap | 3 + .../internal/base/focus-trap.base.spec.ts | 82 ++++++ src/clr-core/internal/base/focus-trap.base.ts | 35 +++ src/clr-core/internal/index.ts | 3 + .../services/common-strings.default.ts | 1 + .../services/common-strings.interface.ts | 5 +- .../services/focus-trap-tracker.service.ts | 25 ++ src/clr-core/internal/utils/dom.spec.ts | 100 ++++++- src/clr-core/internal/utils/dom.ts | 35 +++ .../internal/utils/focus-trap.spec.ts | 250 ++++++++++++++++++ src/clr-core/internal/utils/focus-trap.ts | 113 ++++++++ src/clr-core/internal/utils/key-codes.ts | 14 + src/clr-core/modal/entrypoint.tsconfig.json | 7 + src/clr-core/modal/index.ts | 11 + .../modal/modal-actions.element.spec.ts | 37 +++ src/clr-core/modal/modal-actions.element.ts | 55 ++++ .../modal/modal-content.element.spec.ts | 31 +++ src/clr-core/modal/modal-content.element.ts | 51 ++++ .../modal-header-actions.element.spec.ts | 37 +++ .../modal/modal-header-actions.element.ts | 54 ++++ .../modal/modal-header.element.spec.ts | 37 +++ src/clr-core/modal/modal-header.element.ts | 54 ++++ src/clr-core/modal/modal.element.scss | 99 +++++++ src/clr-core/modal/modal.element.spec.ts | 113 ++++++++ src/clr-core/modal/modal.element.ts | 140 ++++++++++ src/clr-core/modal/modal.stories.mdx | 69 +++++ src/clr-core/modal/modal.stories.ts | 230 ++++++++++++++++ src/clr-core/modal/package.json | 4 + src/clr-core/styles/layout/_layout.scss | 4 + src/clr-core/styles/utils/_close.scss | 30 +++ src/clr-core/test-bundles/bundlesize.json | 2 +- 39 files changed, 1745 insertions(+), 17 deletions(-) create mode 100644 src/clr-core/internal/base/focus-trap.base.spec.ts create mode 100644 src/clr-core/internal/base/focus-trap.base.ts create mode 100644 src/clr-core/internal/services/focus-trap-tracker.service.ts create mode 100644 src/clr-core/internal/utils/focus-trap.spec.ts create mode 100644 src/clr-core/internal/utils/focus-trap.ts create mode 100644 src/clr-core/internal/utils/key-codes.ts create mode 100644 src/clr-core/modal/entrypoint.tsconfig.json create mode 100644 src/clr-core/modal/index.ts create mode 100644 src/clr-core/modal/modal-actions.element.spec.ts create mode 100644 src/clr-core/modal/modal-actions.element.ts create mode 100644 src/clr-core/modal/modal-content.element.spec.ts create mode 100644 src/clr-core/modal/modal-content.element.ts create mode 100644 src/clr-core/modal/modal-header-actions.element.spec.ts create mode 100644 src/clr-core/modal/modal-header-actions.element.ts create mode 100644 src/clr-core/modal/modal-header.element.spec.ts create mode 100644 src/clr-core/modal/modal-header.element.ts create mode 100644 src/clr-core/modal/modal.element.scss create mode 100644 src/clr-core/modal/modal.element.spec.ts create mode 100644 src/clr-core/modal/modal.element.ts create mode 100644 src/clr-core/modal/modal.stories.mdx create mode 100644 src/clr-core/modal/modal.stories.ts create mode 100644 src/clr-core/modal/package.json create mode 100644 src/clr-core/styles/utils/_close.scss diff --git a/scripts/ngcc.js b/scripts/ngcc.js index 5f77b2bec1..acc8d18ee0 100644 --- a/scripts/ngcc.js +++ b/scripts/ngcc.js @@ -37,7 +37,7 @@ module.exports = findPackagesSync(path.join(process.cwd(), 'src')); // './src/clr-core/package.json', // './src/clr-core/badge/package.json', // './src/clr-core/button/package.json', -// './src/clr-core/common/package.json', +// './src/clr-core/internal/package.json', // './src/clr-core/icon-shapes/package.json', // './src/clr-core/icon/package.json', // './src/clr-core/tag/package.json', diff --git a/src/clr-angular/layout/main-container/_layout.clarity.scss b/src/clr-angular/layout/main-container/_layout.clarity.scss index 58b670dac5..bd64e921bd 100644 --- a/src/clr-angular/layout/main-container/_layout.clarity.scss +++ b/src/clr-angular/layout/main-container/_layout.clarity.scss @@ -1,4 +1,4 @@ -// Copyright (c) 2016-2019 VMware, Inc. All Rights Reserved. +// Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. // This software is released under MIT license. // The full license information can be found in LICENSE in the root directory of this project. @@ -79,11 +79,13 @@ } } - body.no-scrolling .main-container .content-container .content-area { + body.no-scrolling, + body[cds-layout='no-scrolling'] { overflow: hidden; - } - body.no-scrolling { - overflow: hidden; + // The selector below targets Clarity-UI and is provided for compatibility + .main-container .content-container .content-area { + overflow: hidden; + } } } diff --git a/src/clr-angular/utils/i18n/common-strings.interface.ts b/src/clr-angular/utils/i18n/common-strings.interface.ts index 3a3db31161..6997dadd66 100644 --- a/src/clr-angular/utils/i18n/common-strings.interface.ts +++ b/src/clr-angular/utils/i18n/common-strings.interface.ts @@ -129,6 +129,7 @@ export interface ClrCommonStrings { * Modal end of content */ modalContentEnd: string; + /** * Datagrid Show columns menu description */ diff --git a/src/clr-core/alert/alert.base.ts b/src/clr-core/alert/alert.base.ts index 44abcace9d..bb805cc9c4 100644 --- a/src/clr-core/alert/alert.base.ts +++ b/src/clr-core/alert/alert.base.ts @@ -49,7 +49,7 @@ const iconMap = { * Base class for alerts. Contains properties and functions common to all alerts. */ export class CdsBaseAlert extends LitElement { - @event() private closedChange: EventEmitter; + @event() private closeChange: EventEmitter; /** If false, the alert will not render the close button. */ @property({ type: Boolean }) @@ -127,6 +127,6 @@ export class CdsBaseAlert extends LitElement { } closeAlert() { - this.closedChange.emit(true); + this.closeChange.emit(true); } } diff --git a/src/clr-core/alert/alert.element.spec.ts b/src/clr-core/alert/alert.element.spec.ts index 984ab2a084..fb157fac49 100644 --- a/src/clr-core/alert/alert.element.spec.ts +++ b/src/clr-core/alert/alert.element.spec.ts @@ -162,10 +162,10 @@ describe('alert element', () => { expect(button.getAttribute('aria-label')).toEqual(service.keys.alertCloseButtonAriaLabel); }); - it('should emit a closedChanged event when close button is clicked', async done => { + it('should emit a closeChange event when close button is clicked', async done => { let value: any; await componentIsStable(component); - component.addEventListener('closedChange', (e: CustomEvent) => { + component.addEventListener('closeChange', (e: CustomEvent) => { value = e.detail; expect(value).toBe(true); done(); diff --git a/src/clr-core/alert/alert.stories.ts b/src/clr-core/alert/alert.stories.ts index cc407d9465..9ea94b16d1 100644 --- a/src/clr-core/alert/alert.stories.ts +++ b/src/clr-core/alert/alert.stories.ts @@ -65,7 +65,7 @@ export const API = () => { .iconTitle=${iconTitle} .size=${size} .status=${alertStatus} - @closedChange=${action('closeChanged')}> + @closeChange=${action('closeChange')}> ${slot} diff --git a/src/clr-core/alert/app-alert.element.spec.ts b/src/clr-core/alert/app-alert.element.spec.ts index 2ce97c96fc..8ef781082f 100644 --- a/src/clr-core/alert/app-alert.element.spec.ts +++ b/src/clr-core/alert/app-alert.element.spec.ts @@ -147,10 +147,10 @@ describe('alert element', () => { expect(button.getAttribute('aria-label')).toEqual(service.keys.alertCloseButtonAriaLabel); }); - it('should emit a closedChanged event when close button is clicked', async done => { + it('should emit a closeChanged event when close button is clicked', async done => { let value: any; await componentIsStable(component); - component.addEventListener('closedChange', (e: CustomEvent) => { + component.addEventListener('closeChange', (e: CustomEvent) => { value = e.detail; expect(value).toBe(true); done(); diff --git a/src/clr-core/alert/app-alert.stories.ts b/src/clr-core/alert/app-alert.stories.ts index 90497bcaa3..9ac3f98243 100644 --- a/src/clr-core/alert/app-alert.stories.ts +++ b/src/clr-core/alert/app-alert.stories.ts @@ -59,7 +59,7 @@ export const API = () => { .iconShape=${iconShape} .iconTitle=${iconTitle} .status=${alertStatus} - @closedChange=${action('closeChanged')}> + @closeChange=${action('closeChanged')}> ${slot} diff --git a/src/clr-core/import-map.importmap b/src/clr-core/import-map.importmap index f713538b3d..8e5f52deb8 100644 --- a/src/clr-core/import-map.importmap +++ b/src/clr-core/import-map.importmap @@ -9,11 +9,13 @@ "lit-html/directives/unsafe-html": "/base/node_modules/lit-html/directives/unsafe-html.js", "ramda/es/anyPass": "/base/node_modules/ramda/es/anyPass.js", "ramda/es/isNil": "/base/node_modules/ramda/es/isNil.js", + "ramda/es/includes": "/base/node_modules/ramda/es/includes.js", "ramda/es/curryN": "/base/node_modules/ramda/es/curryN.js", "ramda/es/path": "/base/node_modules/ramda/es/path.js", "ramda/es/has": "/base/node_modules/ramda/es/has.js", "ramda/es/is": "/base/node_modules/ramda/es/is.js", "ramda/es/isEmpty": "/base/node_modules/ramda/es/isEmpty.js", + "ramda/es/without": "/base/node_modules/ramda/es/without.js", "css-vars-ponyfill": "/base/node_modules/css-vars-ponyfill/dist/css-vars-ponyfill.esm.js", "@clr/core/alert": "/base/dist/clr-core/alert/index.js", @@ -22,6 +24,7 @@ "@clr/core/icon": "/base/dist/clr-core/icon/index.js", "@clr/core/icon-shapes": "/base/dist/clr-core/icon-shapes/index.js", "@clr/core/internal": "/base/dist/clr-core/internal/index.js", + "@clr/core/modal": "/base/dist/clr-core/modal/index.js", "@clr/core/tag": "/base/dist/clr-core/tag/index.js", "@clr/core/test-dropdown": "/base/dist/clr-core/test-dropdown/index.js", "@clr/core/test/utils": "/base/dist/clr-core/test/utils.js" diff --git a/src/clr-core/internal/base/focus-trap.base.spec.ts b/src/clr-core/internal/base/focus-trap.base.spec.ts new file mode 100644 index 0000000000..37fe54568e --- /dev/null +++ b/src/clr-core/internal/base/focus-trap.base.spec.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { createTestElement, removeTestElement, waitForComponent, componentIsStable } from '@clr/core/test/utils'; +import { registerElementSafely } from '../utils/register.js'; +import { CdsBaseFocusTrap } from './focus-trap.base.js'; + +// register the element just for testing purposes +registerElementSafely('cds-base-focus-trap', CdsBaseFocusTrap); + +declare global { + interface HTMLElementTagNameMap { + 'cds-base-focus-trap': CdsBaseFocusTrap; + } +} + +describe('modal element', () => { + describe('basic', () => { + let testElement: HTMLElement; + let component: CdsBaseFocusTrap; + const placeholderText = 'Button Placeholder'; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = ` + + + ${placeholderText} + + + `; + + await waitForComponent('cds-base-focus-trap'); + component = testElement.querySelector('cds-base-focus-trap'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should create the component', async () => { + await componentIsStable(component); + expect(component.innerText).toBe(placeholderText.toUpperCase()); + }); + + it('should enable focus trap', async () => { + await componentIsStable(component); + expect((component as any).focusTrap).toBeDefined(); + }); + }); + + describe('demo mode', () => { + let testElement: HTMLElement; + let component: CdsBaseFocusTrap; + const placeholderText = 'Button Placeholder'; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = ` + + + ${placeholderText} + + + `; + + await waitForComponent('cds-base-focus-trap'); + component = testElement.querySelector('cds-base-focus-trap'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should not create focus trap if demo mode', async () => { + await componentIsStable(component); + expect((component as any).focusTrap).toBeUndefined(); + }); + }); +}); diff --git a/src/clr-core/internal/base/focus-trap.base.ts b/src/clr-core/internal/base/focus-trap.base.ts new file mode 100644 index 0000000000..89d587f43e --- /dev/null +++ b/src/clr-core/internal/base/focus-trap.base.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { html, LitElement } from 'lit-element'; +import { FocusTrap } from '../utils/focus-trap.js'; +import { property } from '../decorators/property.js'; +export class CdsBaseFocusTrap extends LitElement { + protected focusTrap: FocusTrap; + + @property({ type: Boolean }) + private __demoMode = false; + + connectedCallback() { + super.connectedCallback(); + + if (!this.__demoMode) { + this.focusTrap = new FocusTrap(this); + this.focusTrap.enableFocusTrap(); + } + } + disconnectedCallback() { + super.disconnectedCallback(); + + if (!this.__demoMode) { + this.focusTrap.removeFocusTrap(); + } + } + + protected render() { + return html``; + } +} diff --git a/src/clr-core/internal/index.ts b/src/clr-core/internal/index.ts index e5e70ed995..0af9625478 100644 --- a/src/clr-core/internal/index.ts +++ b/src/clr-core/internal/index.ts @@ -16,10 +16,13 @@ export * from './enums/key-codes.js'; export * from './services/common-strings.service.js'; export * from './services/common-strings.interface.js'; export * from './services/common-strings.default.js'; +export * from './services/focus-trap-tracker.service.js'; export * from './utils/conditional.js'; export * from './utils/exists.js'; +export * from './utils/focus-trap.js'; export * from './utils/framework.js'; export * from './utils/identity.js'; +export * from './utils/key-codes.js'; export * from './utils/string.js'; export * from './mixins/css-helpers.js'; export * from './mixins/unique-id.js'; diff --git a/src/clr-core/internal/services/common-strings.default.ts b/src/clr-core/internal/services/common-strings.default.ts index 044fe108f0..e45fcfc85d 100644 --- a/src/clr-core/internal/services/common-strings.default.ts +++ b/src/clr-core/internal/services/common-strings.default.ts @@ -35,6 +35,7 @@ export const commonStringsDefault: ClrCommonStrings = { totalPages: 'Total Pages', minValue: 'Min value', maxValue: 'Max value', + modalCloseButtonAriaLabel: 'Close modal', modalContentStart: 'Beginning of Modal Content', modalContentEnd: 'End of Modal Content', showColumnsMenuDescription: 'Show or hide columns menu', diff --git a/src/clr-core/internal/services/common-strings.interface.ts b/src/clr-core/internal/services/common-strings.interface.ts index 3a3db31161..798224be03 100644 --- a/src/clr-core/internal/services/common-strings.interface.ts +++ b/src/clr-core/internal/services/common-strings.interface.ts @@ -116,11 +116,14 @@ export interface ClrCommonStrings { * Datagrid numeric filter: max */ maxValue: string; + /** + * Modal: Close modal button + */ + modalCloseButtonAriaLabel: string; /** * Datagrid filter toggle button */ datagridFilterAriaLabel?: string; - /** * Modal start of content */ diff --git a/src/clr-core/internal/services/focus-trap-tracker.service.ts b/src/clr-core/internal/services/focus-trap-tracker.service.ts new file mode 100644 index 0000000000..998172029f --- /dev/null +++ b/src/clr-core/internal/services/focus-trap-tracker.service.ts @@ -0,0 +1,25 @@ +/* +* Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. +* This software is released under MIT license. +* The full license information can be found in LICENSE in the root directory of this project. +*/ + +/** + * FocusTrapTracker is a static class that keeps track of the active element with focus trap, + * in case there are multiple in a given page. + */ +export class FocusTrapTracker { + private static focusTrapElements: Element[] = []; + + static setCurrent(el: Element) { + this.focusTrapElements.unshift(el); + } + + static activatePreviousCurrent() { + this.focusTrapElements.shift(); + } + + static getCurrent() { + return this.focusTrapElements[0]; + } +} diff --git a/src/clr-core/internal/utils/dom.spec.ts b/src/clr-core/internal/utils/dom.spec.ts index 01a760070a..6d45a9dcad 100644 --- a/src/clr-core/internal/utils/dom.spec.ts +++ b/src/clr-core/internal/utils/dom.spec.ts @@ -5,7 +5,16 @@ */ import { createTestElement, removeTestElement } from '@clr/core/test/utils'; -import { getElementWidth, getElementWidthUnless, HTMLAttributeTuple, removeAttributes, setAttributes } from './dom.js'; +import { + addAttributeValue, + getElementWidth, + getElementWidthUnless, + HTMLAttributeTuple, + isHTMLElement, + removeAttributes, + removeAttributeValue, + setAttributes, +} from './dom.js'; describe('Functional Helper: ', () => { describe('getElementWidth() ', () => { @@ -52,6 +61,58 @@ describe('Functional Helper: ', () => { }); }); + describe('isHTMLElement() ', () => { + let testElement: any; + + it('returns true if it is an HTMLElement', () => { + testElement = createTestElement(); + expect(isHTMLElement(testElement)).toEqual(true); + }); + + it('returns false if undefined or null', () => { + testElement = undefined; + expect(isHTMLElement(testElement)).toEqual(false); + + testElement = null; + expect(isHTMLElement(testElement)).toEqual(false); + }); + + it('returns false if it is not an HTMLElement', () => { + testElement = 2; + expect(isHTMLElement(testElement)).toEqual(false); + }); + }); + + describe('addAttributeValue() ', () => { + let testElement: HTMLElement; + const attrName = 'myAttr'; + + beforeEach(() => { + testElement = createTestElement(); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('sets the attribute value', () => { + addAttributeValue(testElement, attrName, 'bar'); + expect(testElement.getAttribute(attrName)).toEqual('bar'); + }); + + it('adds to current attribute value', () => { + testElement.setAttribute(attrName, 'foo'); + addAttributeValue(testElement, attrName, 'bar'); + expect(testElement.getAttribute(attrName)).toEqual('foo bar'); + }); + + it('does not add if the value is already present', () => { + testElement.setAttribute(attrName, 'foo'); + addAttributeValue(testElement, attrName, 'foo'); + expect(testElement.getAttribute(attrName)).toEqual('foo'); + }); + }); + describe('setAttributes() ', () => { let testElement: HTMLElement; @@ -162,4 +223,41 @@ describe('Functional Helper: ', () => { }); }); }); + + describe('removeAttributeValues() ', () => { + let testElement: HTMLElement; + const attrName = 'myAttr'; + const testAttrs: HTMLAttributeTuple[] = [ + ['title', 'test element'], + ['aria-hidden', 'true'], + ['data-attr', 'stringified data goes here'], + ]; + + beforeEach(() => { + testElement = createTestElement(); + setAttributes(testElement, ...testAttrs); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('does not do anything if attribute value is not present', () => { + testElement.setAttribute(attrName, 'foo'); + removeAttributeValue(testElement, attrName, 'bar'); + expect(testElement.getAttribute(attrName)).toEqual('foo'); + }); + + it('removes only the value specified', () => { + testElement.setAttribute(attrName, 'foo bar'); + removeAttributeValue(testElement, attrName, 'bar'); + expect(testElement.getAttribute(attrName)).toEqual('foo'); + }); + + it('removes the attribute if the value was the only value for that attribute', () => { + testElement.setAttribute(attrName, 'foo'); + removeAttributeValue(testElement, attrName, 'foo'); + expect(testElement.getAttribute(attrName)).toBeNull(); + }); + }); }); diff --git a/src/clr-core/internal/utils/dom.ts b/src/clr-core/internal/utils/dom.ts index ff2ff3fc1b..110e401f3f 100644 --- a/src/clr-core/internal/utils/dom.ts +++ b/src/clr-core/internal/utils/dom.ts @@ -3,6 +3,8 @@ * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ +import includes from 'ramda/es/includes'; +import without from 'ramda/es/without'; export function getElementWidth(element: HTMLElement, unit = 'px') { if (element) { @@ -18,6 +20,10 @@ export function getElementWidthUnless(element: HTMLElement, unless: boolean) { return ''; } +export function isHTMLElement(el: any) { + return !!el && el instanceof HTMLElement; +} + export type HTMLAttributeTuple = [string, string | boolean]; export function setAttributes(element: HTMLElement, ...attributeTuples: HTMLAttributeTuple[]) { @@ -39,3 +45,32 @@ export function removeAttributes(element: HTMLElement, ...attrs: string[]) { }); } } + +export function addAttributeValue(element: HTMLElement, attr: string, value: string) { + if (element) { + const currentAttrVal = element.getAttribute(attr); + if (!currentAttrVal) { + element.setAttribute(attr, value); + } else if (!includes(value, currentAttrVal.split(' '))) { + // add it only if it is not already there + element.setAttribute(attr, currentAttrVal + ' ' + value); + } + } +} + +export function removeAttributeValue(element: HTMLElement, attr: string, value: string) { + if (element) { + const currentAttrVal = element.getAttribute(attr); + if (currentAttrVal) { + // remove the specified value from the list of values currently set + const attrValues: string[] = without([value], currentAttrVal.split(' ')); + const newAttrValue = attrValues.join(' '); + + if (newAttrValue) { + element.setAttribute(attr, newAttrValue); + } else { + element.removeAttribute(attr); + } + } + } +} diff --git a/src/clr-core/internal/utils/focus-trap.spec.ts b/src/clr-core/internal/utils/focus-trap.spec.ts new file mode 100644 index 0000000000..9c84d2d993 --- /dev/null +++ b/src/clr-core/internal/utils/focus-trap.spec.ts @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { createTestElement, removeTestElement } from '@clr/core/test/utils'; +import { + addReboundElementsToFocusTrapElement, + createFocusTrapReboundElement, + elementIsOutsideFocusTrapElement, + FocusTrap, + FocusTrapElement, + focusElementIfInCurrentFocusTrapElement, + removeReboundElementsFromFocusTrapElement, +} from './focus-trap.js'; +import { FocusTrapTracker } from '../services/focus-trap-tracker.service.js'; + +describe('Focus Trap Utilities: ', () => { + describe('Functional Helper: ', () => { + describe('addReboundElementsToFocusTrapElement()', () => { + let focusTrapElement: FocusTrapElement; + + beforeEach(() => { + focusTrapElement = createTestElement() as FocusTrapElement; + }); + + afterEach(() => { + removeTestElement(focusTrapElement); + }); + + it('adds rebound elements correctly when there are no siblings', () => { + addReboundElementsToFocusTrapElement(focusTrapElement); + + const reboundElements = document.querySelectorAll('.offscreen-focus-rebounder'); + expect(reboundElements.length).toBe(2); + + expect(reboundElements[0].nextSibling).toEqual(focusTrapElement); + expect(focusTrapElement.nextSibling).toEqual(reboundElements[1]); + + removeReboundElementsFromFocusTrapElement(focusTrapElement); + }); + + it('adds rebound elements correctly when there are siblings ', () => { + // this creates a sibling element to focusTrapElement + const siblingEl = document.createElement('div'); + focusTrapElement.parentElement.appendChild(siblingEl); + + addReboundElementsToFocusTrapElement(focusTrapElement); + + const reboundElements = document.querySelectorAll('.offscreen-focus-rebounder'); + expect(reboundElements.length).toBe(2); + + expect(reboundElements[0].nextSibling).toEqual(focusTrapElement); + expect(focusTrapElement.nextSibling).toEqual(reboundElements[1]); + + removeReboundElementsFromFocusTrapElement(focusTrapElement); + }); + }); + + describe('removeReboundElementsFromFocusTrapElement()', () => { + let focusTrapElement: FocusTrapElement; + + beforeEach(() => { + focusTrapElement = createTestElement() as FocusTrapElement; + }); + + afterEach(() => { + removeTestElement(focusTrapElement); + }); + + it('removes rebound elements correctly', () => { + addReboundElementsToFocusTrapElement(focusTrapElement); + removeReboundElementsFromFocusTrapElement(focusTrapElement); + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(0); + }); + + it('does not blow up if no rebound elements', () => { + removeReboundElementsFromFocusTrapElement(focusTrapElement); + expect(removeReboundElementsFromFocusTrapElement).not.toThrow(); + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(0); + }); + }); + + describe('createFocusTrapReboundElement()', () => { + let testElement: HTMLElement; + + beforeEach(() => { + testElement = createFocusTrapReboundElement(); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('creates a focusable offscreen element', () => { + expect(testElement.getAttribute('tabindex')).toBe('0'); + expect(testElement.classList).toContain('offscreen-focus-rebounder'); + }); + }); + + describe('focusElementIfInCurrentFocusTrapElement()', () => { + let focusedElement: HTMLElement; + let focusTrapElement: FocusTrapElement; + + beforeEach(() => { + focusTrapElement = createTestElement() as FocusTrapElement; + }); + + afterEach(() => { + removeTestElement(focusTrapElement); + removeTestElement(focusedElement); + }); + + it('calls focus() if in current focus trap element', () => { + spyOn(focusTrapElement, 'focus'); + + focusedElement = createTestElement(); + FocusTrapTracker.setCurrent(focusTrapElement); + + focusElementIfInCurrentFocusTrapElement(focusedElement, focusTrapElement); + expect(focusTrapElement.focus).toHaveBeenCalled(); + }); + + it('does not call focus() if not in current focus trap element', () => { + spyOn(focusTrapElement, 'focus'); + + focusedElement = createTestElement(); + + focusElementIfInCurrentFocusTrapElement(focusedElement, focusTrapElement); + expect(focusTrapElement.focus).not.toHaveBeenCalled(); + }); + }); + + describe('elementIsOutsideFocusTrapElement()', () => { + let focusedElement: HTMLElement; + let focusTrapElement: FocusTrapElement; + + beforeEach(() => { + focusTrapElement = createTestElement() as FocusTrapElement; + }); + + afterEach(() => { + removeTestElement(focusTrapElement); + if (focusedElement) { + focusedElement.remove(); + } + }); + + it('returns true if element is outside focus trap element', () => { + focusedElement = createTestElement(); + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeTruthy(); + }); + + it('returns true if focused element is top rebound element', () => { + const focusedElement = createTestElement(); + focusTrapElement.topReboundElement = focusedElement; + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeTruthy(); + }); + + it('returns true if focused element is bottom rebound element', () => { + const focusedElement = createTestElement(); + focusTrapElement.bottomReboundElement = focusedElement; + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeTruthy(); + }); + + it('returns false if element is inside focus trap element', () => { + const focusedElement = document.createElement('div') as HTMLElement; + focusTrapElement.appendChild(focusedElement); + expect(elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement)).toBeFalsy(); + }); + }); + }); + + describe('FocusTrap Class: ', () => { + let focusTrap: FocusTrap; + let testElement: HTMLElement; + + describe('enableFocusTrap()', () => { + beforeEach(() => { + testElement = createTestElement(); + focusTrap = new FocusTrap(testElement); + focusTrap.enableFocusTrap(); + }); + + afterEach(() => { + focusTrap.removeFocusTrap(); + removeTestElement(testElement); + }); + + it('should add rebound elements', () => { + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(2); + }); + + it('should have a tab index of 0 to be able to focus', () => { + expect(testElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should add layout attribute to prevent scrolling on body', () => { + expect(document.body.getAttribute('cds-layout')).toContain('no-scrolling'); + }); + + it('should be set itself to current on FocusTrapTracker', () => { + expect(FocusTrapTracker.getCurrent()).toEqual(testElement); + }); + + it('should be focused', () => { + expect(document.activeElement).toEqual(testElement); + }); + + it('should throw an error if enabledFocusTrap is called again', () => { + const secondCall = () => focusTrap.enableFocusTrap(); + expect(secondCall).toThrow(); + }); + }); + + describe('removeFocusTrap()', () => { + let previousFocusedElement: HTMLElement; + + beforeEach(() => { + testElement = createTestElement(); + previousFocusedElement = createTestElement(); + previousFocusedElement.setAttribute('tabindex', '0'); + previousFocusedElement.focus(); + focusTrap = new FocusTrap(testElement); + focusTrap.enableFocusTrap(); + focusTrap.removeFocusTrap(); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should remove rebound elements', () => { + expect(document.querySelectorAll('.offscreen-focus-rebounder').length).toBe(0); + }); + + it('should remove layout attribute to prevent scrolling on body', () => { + expect(document.body.getAttribute('cds-layout')).toBeNull(); + }); + + it('should be set itself to current on FocusTrapTracker', () => { + expect(FocusTrapTracker.getCurrent()).not.toEqual(testElement); + }); + + it('should restore previous focus', () => { + expect(document.activeElement).toEqual(previousFocusedElement); + }); + }); + }); +}); diff --git a/src/clr-core/internal/utils/focus-trap.ts b/src/clr-core/internal/utils/focus-trap.ts new file mode 100644 index 0000000000..15a6c43f5e --- /dev/null +++ b/src/clr-core/internal/utils/focus-trap.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { FocusTrapTracker } from '../services/focus-trap-tracker.service.js'; +import { addAttributeValue, isHTMLElement, removeAttributeValue } from './dom.js'; + +export interface FocusTrapElement extends HTMLElement { + topReboundElement: HTMLElement; + bottomReboundElement: HTMLElement; +} + +export function focusElementIfInCurrentFocusTrapElement( + focusedElement: HTMLElement, + focusTrapElement: FocusTrapElement +) { + if ( + FocusTrapTracker.getCurrent() === focusTrapElement && + elementIsOutsideFocusTrapElement(focusedElement, focusTrapElement) + ) { + focusTrapElement.focus(); + } +} + +export function elementIsOutsideFocusTrapElement(focusedElement: HTMLElement, focusTrapElement: FocusTrapElement) { + return ( + !focusTrapElement.contains(focusedElement) || + focusedElement === focusTrapElement.topReboundElement || + focusedElement === focusTrapElement.bottomReboundElement + ); +} + +export function createFocusTrapReboundElement() { + const offScreenSpan = document.createElement('span'); + offScreenSpan.setAttribute('tabindex', '0'); + offScreenSpan.classList.add('offscreen-focus-rebounder'); + return offScreenSpan; +} + +export function addReboundElementsToFocusTrapElement(focusTrapElement: FocusTrapElement) { + if (focusTrapElement) { + focusTrapElement.topReboundElement = createFocusTrapReboundElement(); + focusTrapElement.bottomReboundElement = createFocusTrapReboundElement(); + + if (focusTrapElement.parentElement) { + focusTrapElement.parentElement.insertBefore(focusTrapElement.topReboundElement, focusTrapElement); + if (focusTrapElement.nextSibling) { + focusTrapElement.parentElement.insertBefore( + focusTrapElement.bottomReboundElement, + focusTrapElement.nextSibling + ); + } else { + focusTrapElement.parentElement.appendChild(focusTrapElement.bottomReboundElement); + } + } + } +} + +export function removeReboundElementsFromFocusTrapElement(focusTrapElement: FocusTrapElement) { + if (focusTrapElement) { + if (focusTrapElement.topReboundElement && focusTrapElement.parentElement) { + focusTrapElement.parentElement.removeChild(focusTrapElement.topReboundElement); + } + if (focusTrapElement.bottomReboundElement && focusTrapElement.parentElement) { + focusTrapElement.parentElement.removeChild(focusTrapElement.bottomReboundElement); + } + // These are here to to make sure that we completely delete all traces of the removed DOM objects. + delete focusTrapElement.topReboundElement; + delete focusTrapElement.bottomReboundElement; + } +} +export class FocusTrap { + focusTrapElement: FocusTrapElement; + private previousFocus: HTMLElement; + private onFocusInEvent: any; + + constructor(public hostElement: HTMLElement) { + this.focusTrapElement = hostElement as FocusTrapElement; + } + + enableFocusTrap() { + if (FocusTrapTracker.getCurrent() === this.focusTrapElement) { + throw new Error('Focus trap is already enabled for this instance.'); + } + + addReboundElementsToFocusTrapElement(this.focusTrapElement); + this.focusTrapElement.setAttribute('tabindex', '0'); + if (document.activeElement && isHTMLElement(document.activeElement)) { + this.previousFocus = document.activeElement as HTMLElement; + } + addAttributeValue(document.body, 'cds-layout', 'no-scrolling'); + FocusTrapTracker.setCurrent(this.focusTrapElement); + this.focusTrapElement.focus(); + this.onFocusInEvent = this.onFocusIn.bind(this); + document.addEventListener('focusin', this.onFocusInEvent); + } + + removeFocusTrap() { + document.removeEventListener('focusin', this.onFocusInEvent); + removeAttributeValue(document.body, 'cds-layout', 'no-scrolling'); + removeReboundElementsFromFocusTrapElement(this.focusTrapElement); + FocusTrapTracker.activatePreviousCurrent(); + if (this.previousFocus) { + this.previousFocus.focus(); + } + } + + private onFocusIn(event: FocusEvent) { + focusElementIfInCurrentFocusTrapElement(event.target as HTMLElement, this.focusTrapElement); + } +} diff --git a/src/clr-core/internal/utils/key-codes.ts b/src/clr-core/internal/utils/key-codes.ts new file mode 100644 index 0000000000..4fa4ffff6d --- /dev/null +++ b/src/clr-core/internal/utils/key-codes.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +export const UP_ARROW = 38; +export const DOWN_ARROW = 40; +export const RIGHT_ARROW = 39; +export const LEFT_ARROW = 37; +export const ENTER = 13; +export const SPACE = 32; +export const TAB = 9; +export const ESC = 27; diff --git a/src/clr-core/modal/entrypoint.tsconfig.json b/src/clr-core/modal/entrypoint.tsconfig.json new file mode 100644 index 0000000000..371c038a5f --- /dev/null +++ b/src/clr-core/modal/entrypoint.tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.lib.json", + "compilerOptions": { + "composite": true + }, + "references": [{ "path": "../internal/entrypoint.tsconfig.json" }] +} diff --git a/src/clr-core/modal/index.ts b/src/clr-core/modal/index.ts new file mode 100644 index 0000000000..1cef6587ec --- /dev/null +++ b/src/clr-core/modal/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +export * from './modal-header.element.js'; +export * from './modal-header-actions.element.js'; +export * from './modal-content.element.js'; +export * from './modal-actions.element.js'; +export * from './modal.element.js'; diff --git a/src/clr-core/modal/modal-actions.element.spec.ts b/src/clr-core/modal/modal-actions.element.spec.ts new file mode 100644 index 0000000000..bd22543fd8 --- /dev/null +++ b/src/clr-core/modal/modal-actions.element.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { CdsModalActions } from '@clr/core/modal'; +import '@clr/core/modal'; +import { componentIsStable, createTestElement, removeTestElement, waitForComponent } from '@clr/core/test/utils'; + +describe('modal-actions element', () => { + let testElement: HTMLElement; + let component: CdsModalActions; + const placeholderContent = 'Modal Placeholder'; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = `${placeholderContent}`; + + await waitForComponent('cds-modal-actions'); + component = testElement.querySelector('cds-modal-actions'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should create the component', async () => { + await componentIsStable(component); + expect(component.innerText).toBe(placeholderContent); + }); + + it('should have a slot attribute of value `modal-actions`', async () => { + await componentIsStable(component); + expect(component.hasAttribute('slot')).toBe(true); + expect(component.getAttribute('slot')).toEqual('modal-actions'); + }); +}); diff --git a/src/clr-core/modal/modal-actions.element.ts b/src/clr-core/modal/modal-actions.element.ts new file mode 100644 index 0000000000..dd11e13cb3 --- /dev/null +++ b/src/clr-core/modal/modal-actions.element.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { baseStyles, registerElementSafely } from '@clr/core/internal'; +import { html, LitElement } from 'lit-element'; + +/** + * Web component modal actions to be used inside modal. + * + * ```typescript + * import '@clr/core/modal'; + * ``` + * + * ```html + * + * + *

My Modal

+ *
+ * + *

Lorem Ipsum

+ *
+ * + * Ok + * + *
+ * ``` + * + * @beta + * @element cds-modal-actions + */ +export class CdsModalActions extends LitElement { + connectedCallback() { + super.connectedCallback(); + this.setAttribute('slot', 'modal-actions'); + } + + render() { + return html``; + } + + static get styles() { + return [baseStyles]; + } +} + +registerElementSafely('cds-modal-actions', CdsModalActions); + +declare global { + interface HTMLElementTagNameMap { + 'cds-modal-actions': CdsModalActions; + } +} diff --git a/src/clr-core/modal/modal-content.element.spec.ts b/src/clr-core/modal/modal-content.element.spec.ts new file mode 100644 index 0000000000..e71ebe4837 --- /dev/null +++ b/src/clr-core/modal/modal-content.element.spec.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { CdsModalContent } from '@clr/core/modal'; +import '@clr/core/modal'; +import { componentIsStable, createTestElement, removeTestElement, waitForComponent } from '@clr/core/test/utils'; + +describe('modal-content element', () => { + let testElement: HTMLElement; + let component: CdsModalContent; + const placeholderContent = 'Modal Placeholder'; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = `${placeholderContent}`; + + await waitForComponent('cds-modal-content'); + component = testElement.querySelector('cds-modal-content'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should create the component', async () => { + await componentIsStable(component); + expect(component.innerText).toBe(placeholderContent); + }); +}); diff --git a/src/clr-core/modal/modal-content.element.ts b/src/clr-core/modal/modal-content.element.ts new file mode 100644 index 0000000000..d3f1fe9930 --- /dev/null +++ b/src/clr-core/modal/modal-content.element.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { baseStyles, registerElementSafely } from '@clr/core/internal'; +import { html, LitElement } from 'lit-element'; + +/** + * Web component modal content to be used inside modal. + * + * ```typescript + * import '@clr/core/modal'; + * ``` + * + * ```html + * + * + *

My Modal

+ *
+ * + *

Lorem Ipsum

+ *
+ * + * Ok + * + *
+ * ``` + * @beta + * @element cds-modal-content + */ +export class CdsModalContent extends LitElement { + render() { + return html` + + `; + } + + static get styles() { + return [baseStyles]; + } +} + +registerElementSafely('cds-modal-content', CdsModalContent); + +declare global { + interface HTMLElementTagNameMap { + 'cds-modal-content': CdsModalContent; + } +} diff --git a/src/clr-core/modal/modal-header-actions.element.spec.ts b/src/clr-core/modal/modal-header-actions.element.spec.ts new file mode 100644 index 0000000000..38fa98eb5c --- /dev/null +++ b/src/clr-core/modal/modal-header-actions.element.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { CdsModalHeaderActions } from '@clr/core/modal'; +import '@clr/core/modal'; +import { componentIsStable, createTestElement, removeTestElement, waitForComponent } from '@clr/core/test/utils'; + +describe('modal-header-actions element', () => { + let testElement: HTMLElement; + let component: CdsModalHeaderActions; + const placeholderContent = 'Modal Placeholder'; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = `${placeholderContent}`; + + await waitForComponent('cds-modal-header-actions'); + component = testElement.querySelector('cds-modal-header-actions'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should create the component', async () => { + await componentIsStable(component); + expect(component.innerText).toBe(placeholderContent); + }); + + it('should have a slot attribute of value `modal-header-actions`', async () => { + await componentIsStable(component); + expect(component.hasAttribute('slot')).toBe(true); + expect(component.getAttribute('slot')).toEqual('modal-header-actions'); + }); +}); diff --git a/src/clr-core/modal/modal-header-actions.element.ts b/src/clr-core/modal/modal-header-actions.element.ts new file mode 100644 index 0000000000..3d4330e865 --- /dev/null +++ b/src/clr-core/modal/modal-header-actions.element.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { baseStyles, registerElementSafely } from '@clr/core/internal'; +import { html, LitElement } from 'lit-element'; + +/** + * Web component modal header actions to be used inside modal. + * + * ```typescript + * import '@clr/core/modal'; + * ``` + * + * ```html + * + * + *

My Modal

+ *
+ * + *

Lorem Ipsum

+ *
+ * + * Ok + * + *
+ * ``` + * @beta + * @element cds-modal-header-actions + */ +export class CdsModalHeaderActions extends LitElement { + connectedCallback() { + super.connectedCallback(); + this.setAttribute('slot', 'modal-header-actions'); + } + + render() { + return html``; + } + + static get styles() { + return [baseStyles]; + } +} + +registerElementSafely('cds-modal-header-actions', CdsModalHeaderActions); + +declare global { + interface HTMLElementTagNameMap { + 'cds-modal-header-actions': CdsModalHeaderActions; + } +} diff --git a/src/clr-core/modal/modal-header.element.spec.ts b/src/clr-core/modal/modal-header.element.spec.ts new file mode 100644 index 0000000000..b4bd9cb0b5 --- /dev/null +++ b/src/clr-core/modal/modal-header.element.spec.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { CdsModalHeader } from '@clr/core/modal'; +import '@clr/core/modal'; +import { componentIsStable, createTestElement, removeTestElement, waitForComponent } from '@clr/core/test/utils'; + +describe('modal-header element', () => { + let testElement: HTMLElement; + let component: CdsModalHeader; + const placeholderContent = 'Modal Placeholder'; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = `${placeholderContent}`; + + await waitForComponent('cds-modal-header'); + component = testElement.querySelector('cds-modal-header'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should create the component', async () => { + await componentIsStable(component); + expect(component.innerText).toBe(placeholderContent); + }); + + it('should have a slot attribute of value `modal-header`', async () => { + await componentIsStable(component); + expect(component.hasAttribute('slot')).toBe(true); + expect(component.getAttribute('slot')).toEqual('modal-header'); + }); +}); diff --git a/src/clr-core/modal/modal-header.element.ts b/src/clr-core/modal/modal-header.element.ts new file mode 100644 index 0000000000..a6c3308077 --- /dev/null +++ b/src/clr-core/modal/modal-header.element.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { baseStyles, registerElementSafely } from '@clr/core/internal'; +import { html, LitElement } from 'lit-element'; + +/** + * Web component modal header to be used inside modal. + * + * ```typescript + * import '@clr/core/modal'; + * ``` + * + * ```html + * + * + *

My Modal

+ *
+ * + *

Lorem Ipsum

+ *
+ * + * Ok + * + *
+ * ``` + * @beta + * @element cds-modal-header + */ +export class CdsModalHeader extends LitElement { + connectedCallback() { + super.connectedCallback(); + this.setAttribute('slot', 'modal-header'); + } + + render() { + return html``; + } + + static get styles() { + return [baseStyles]; + } +} + +registerElementSafely('cds-modal-header', CdsModalHeader); + +declare global { + interface HTMLElementTagNameMap { + 'cds-modal-header': CdsModalHeader; + } +} diff --git a/src/clr-core/modal/modal.element.scss b/src/clr-core/modal/modal.element.scss new file mode 100644 index 0000000000..c3389ddc0b --- /dev/null +++ b/src/clr-core/modal/modal.element.scss @@ -0,0 +1,99 @@ +@import './../styles/tokens/generated/index'; +@import './../styles/mixins/utils'; +@import './../styles/utils/close'; + +:host { + --backdrop-opacity: 0.85; + --backdrop-background: #{$cds-token-color-neutral-900}; + --box-shadow-color: #{$cds-token-color-action-500}; + --border-radius: #{$cds-token-global-border-radius}; + --border: #{$cds-token-color-action-600}; + --background: #{$cds-token-color-neutral-0}; + --close-icon-color: #{$cds-token-color-neutral-600}; + --close-icon-color-hover: #{$cds-token-color-neutral-700}; + --content-box-shadow-color: rgba(0, 0, 0, 0.2); + --width: calc(8 * #{$cds-token-space-size-13}); +} + +:host([size='sm']) { + --width: calc(4 * #{$cds-token-space-size-13}); +} + +:host([size='lg']) { + --width: calc(12 * #{$cds-token-space-size-13}); +} + +:host([size='xl']) { + --width: calc(16 * #{$cds-token-space-size-13}); +} + +.private-host { + position: fixed; + top: 0; + bottom: 0; + right: 0; + left: 0; + z-index: 1050; // TODO: restore using map-get to get the z-index +} + +button.close { + font-size: $cds-token-space-size-8; + line-height: $cds-token-space-size-8; + + &:focus, + &:hover, + &:active { + --close-icon-color: var(--close-icon-color-hover); + } + + cds-icon { + fill: var(--close-icon-color); + // per measurement, this results in an icon that is 16x16... + @include equilateral($cds-token-space-size-8); + } +} + +.modal-dialog { + position: relative; + z-index: 1050; // TODO: restore using map-get to get the z-index + width: var(--width); + max-width: 100%; +} + +.modal-content { + background: var(--background); + border: var(--border-radius); + border-radius: var(--border-radius); + box-shadow: 0 $cds-token-space-size-1 $cds-token-space-size-2 $cds-token-space-size-2 var(--content-box-shadow-color); +} + +.modal-backdrop { + position: fixed; + top: 0; + bottom: 0; + right: 0; + left: 0; + background: var(--backdrop-background); + opacity: var(--backdrop-opacity); + z-index: 1040; // TODO: restore using map-get to get the z-index +} + +.modal-body { + // This doesn't do much, but at least with several paragraphs in the content + // it doesn't mess up the modal's proportions. + max-height: 70vh; + overflow-y: auto; + overflow-x: hidden; +} + +@media screen and (max-width: $cds-token-layout-width-sm) and (orientation: landscape) { + .modal-body { + max-height: 55vh; + } +} + +@media screen and (max-width: $cds-token-layout-width-xs) { + .modal-body { + max-height: 55vh; + } +} diff --git a/src/clr-core/modal/modal.element.spec.ts b/src/clr-core/modal/modal.element.spec.ts new file mode 100644 index 0000000000..29ab735852 --- /dev/null +++ b/src/clr-core/modal/modal.element.spec.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { CommonStringsServiceInternal } from '@clr/core/internal'; +import { CdsModal } from '@clr/core/modal'; +import '@clr/core/modal'; +import { + componentIsStable, + createTestElement, + getComponentSlotContent, + removeTestElement, + waitForComponent, +} from '@clr/core/test/utils'; + +describe('modal element', () => { + let testElement: HTMLElement; + let component: CdsModal; + const placeholderHeader = 'I have a nice title'; + const placeholderContent = '

But not much to say...

'; + const placeholderActionText = 'Ok'; + const placeholderAction = `${placeholderActionText}`; + + beforeEach(async () => { + testElement = createTestElement(); + testElement.innerHTML = ` + + ${placeholderHeader} + ${placeholderContent} + ${placeholderAction} + + `; + + await waitForComponent('cds-modal'); + component = testElement.querySelector('cds-modal'); + }); + + afterEach(() => { + removeTestElement(testElement); + }); + + it('should create the component', async () => { + await componentIsStable(component); + const slots = getComponentSlotContent(component); + expect(slots.default).toBe(`${placeholderContent}`); + expect(slots['modal-header']).toBe(`${placeholderHeader}`); + // since cds-button further adds and modifies the element we simply test that it contains the button text + expect(slots['modal-actions']).toContain(`${placeholderActionText}`); + }); + + it('should support closable option', async () => { + await componentIsStable(component); + expect(component.closable).toBe(true); + expect(component.hasAttribute('closable')).toBe(true); + let button = component.shadowRoot.querySelector('button.close'); + expect(button).not.toBeNull(); + expect(button.classList.contains('close')).toBe(true); + + component.closable = false; + await componentIsStable(component); + expect(component.hasAttribute('closable')).toBe(false); + button = component.shadowRoot.querySelector('button.close'); + expect(button).toBeNull(); + }); + + it('should set close button aria label using Common Strings Service', async () => { + const service = new CommonStringsServiceInternal(); + + await componentIsStable(component); + const button = component.shadowRoot.querySelector('button.close'); + expect(button).not.toBeNull(); + expect(button.hasAttribute('aria-label')).toBe(true); + expect(button.getAttribute('aria-label')).toEqual(service.keys.modalCloseButtonAriaLabel); + }); + + it('should have text based boundaries for screen readers', async () => { + await componentIsStable(component); + const messages = component.shadowRoot.querySelectorAll('[cds-layout="display:screen-reader-only"]'); + expect(messages[0].innerText).toBe('Beginning of Modal Content'); + expect(messages[1].innerText).toBe('End of Modal Content'); + }); + + it('should emit a closeChange event on escape', async done => { + let value: any; + await componentIsStable(component); + + component.addEventListener('closeChange', (e: CustomEvent) => { + value = e.detail; + expect(value).toBe(true); + done(); + }); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + await componentIsStable(component); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Esc' })); + }); + + it('should emit a closeChange event when close button is clicked', async done => { + let value: any; + await componentIsStable(component); + component.addEventListener('closeChange', (e: CustomEvent) => { + value = e.detail; + expect(value).toBe(true); + done(); + }); + + const button = component.shadowRoot.querySelector('button.close'); + expect(button).toBeDefined(); + button.click(); + }); +}); diff --git a/src/clr-core/modal/modal.element.ts b/src/clr-core/modal/modal.element.ts new file mode 100644 index 0000000000..93777e8db0 --- /dev/null +++ b/src/clr-core/modal/modal.element.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { + applyMixins, + baseStyles, + CommonStringsService, + CssHelpers, + ESC, + event, + EventEmitter, + property, + registerElementSafely, + UniqueId, +} from '@clr/core/internal'; +import { html } from 'lit-element'; +import { CdsBaseFocusTrap } from '../internal/base/focus-trap.base.js'; +import { styles } from './modal.element.css.js'; + +class ModalMixinClass extends CdsBaseFocusTrap {} + +applyMixins(ModalMixinClass, [UniqueId, CssHelpers]); + +/** + * Web component modal. + * + * ```typescript + * import '@clr/core/modal'; + * ``` + * + * ```html + * + * + *

My Modal

+ *
+ * + *

Lorem Ipsum

+ *
+ * + * Ok + * + *
+ * ``` + * + * @element cds-modal + */ +export class CdsModal extends ModalMixinClass { + static get styles() { + return [baseStyles, styles]; + } + @event() private closeChange: EventEmitter; + + /** If false, the modal will not render the close button. */ + @property({ type: Boolean }) + closable = true; + + /** Sets the overall height and width of the modal and icon based on value */ + @property({ type: String }) + size: 'default' | 'sm' | 'lg' | 'xl'; + + private idForAriaLabel = `${this._idPrefix}${this._uniqueId}`; + + render() { + return html` +
+ + +
+ `; + } + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('keydown', this.fireEventOnEscape); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('keydown', this.fireEventOnEscape); + } + + closeModal() { + this.closeChange.emit(true); + } + + private fireEventOnEscape = (e: KeyboardEvent) => { + if (e.keyCode === ESC || e.key === 'Esc') { + this.closeModal(); + } + }; +} + +export interface CdsModal extends ModalMixinClass, UniqueId, CssHelpers {} + +registerElementSafely('cds-modal', CdsModal); + +declare global { + interface HTMLElementTagNameMap { + 'cds-modal': CdsModal; + } +} diff --git a/src/clr-core/modal/modal.stories.mdx b/src/clr-core/modal/modal.stories.mdx new file mode 100644 index 0000000000..f12c68d277 --- /dev/null +++ b/src/clr-core/modal/modal.stories.mdx @@ -0,0 +1,69 @@ +import { Meta, Props, Story, Preview, API } from '@storybook/addon-docs/blocks'; +import { html, LitElement } from 'lit-element'; + + + +# Modal + +Modals provide information or help a user complete a task. They require the user to take an action to dismiss them. + +## Installation + +To use the modal component import the component in your JavaScript. + +```typescript +import '@clr/core/modal'; +``` + +```html + + +

I have a nice title

+
+ +
+

But not much to say...

+
+
+ +
+ Cancel + Ok +
+
+
+``` + +## Size - Default + + + + + +## Size - Small + + + + + +## Size - Large + + + + + +## Size - Extra Large + + + + + +## Custom Styles + + + + + +## API + + diff --git a/src/clr-core/modal/modal.stories.ts b/src/clr-core/modal/modal.stories.ts new file mode 100644 index 0000000000..9d0a221354 --- /dev/null +++ b/src/clr-core/modal/modal.stories.ts @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { cssGroup, propertiesGroup, setStyles } from '@clr/core/internal'; +import '@clr/core/modal'; +import { action } from '@storybook/addon-actions'; +import { boolean, color as colorKnob, select, text } from '@storybook/addon-knobs'; +import { html } from 'lit-html'; +import { unsafeHTML } from 'lit-html/directives/unsafe-html'; + +export default { + title: 'Components/Modal/Stories', + component: 'cds-modal', + parameters: { + options: { showPanel: true }, + design: { + type: 'figma', + url: 'https://www.figma.com/file/v2mkhzKQdhECXOx8BElgdA/Clarity-UI-Library---light-2.2.0?node-id=0%3A548', + }, + }, +}; + +function htmlDecode(input: string) { + const e = document.createElement('div'); + e.innerHTML = input; + return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue; +} + +export const API = () => { + const modalHeaderSlot = text('cds-modal-header', '

My Modal

', propertiesGroup); + const modalContentSlot = text( + 'cds-modal-content', + '

This is a modal.

', + propertiesGroup + ); + const modalFooterSlot = text( + 'cds-modal-footer', + '
Ok
', + propertiesGroup + ); + + const closable = boolean('closable', true, propertiesGroup); + const size = select('size', { '(default)': 'default', sm: 'sm', lg: 'lg', xl: 'xl' }, undefined, propertiesGroup); + + const backdropOpacity = text('--backdrop-opacity', undefined, cssGroup); + const backdropColor = colorKnob('--backdrop-color', undefined, cssGroup); + const backgroundColor = colorKnob('--background-color', undefined, cssGroup); + const boxShadowColor = colorKnob('--box-shadow-color', undefined, cssGroup); + const borderRadius = text('--border-radius', undefined, cssGroup); + const borderColor = colorKnob('--border-color', undefined, cssGroup); + const closeIconColor = colorKnob('--close-icon-color', undefined, cssGroup); + const closeIconColorHover = colorKnob('--close-icon-color-hover', undefined, cssGroup); + const contentBoxShadowColor = colorKnob('--content-box-shadow-color', undefined, cssGroup); + const width = text('--width', undefined, cssGroup); + + return html` + + + ${unsafeHTML(htmlDecode(modalHeaderSlot))} + + ${unsafeHTML(htmlDecode(modalContentSlot))} + + + ${unsafeHTML(htmlDecode(modalFooterSlot))} + + + `; +}; + +export const defaultSize = () => { + return html` + + + +

My Modal

+
+ +
+

Lorem Ipsum

+
+
+ +
+ Cancel + Ok +
+
+
+ `; +}; + +export const small = () => { + return html` + + + +

My Modal

+
+ +
+

Lorem Ipsum

+
+
+ +
+ Cancel + Ok +
+
+
+ `; +}; + +export const large = () => { + return html` + + + +

My Modal

+
+ +
+

Lorem Ipsum

+
+
+ +
+ Cancel + Ok +
+
+
+ `; +}; + +export const extraLarge = () => { + return html` + + + +

My Modal

+
+ +
+

Lorem Ipsum

+
+
+ +
+ Cancel + Ok +
+
+
+ `; +}; + +export const customStyles = () => { + return html` + + + +

My Modal

+
+ +
+

Lorem Ipsum

+
+
+ +
+ Cancel + Ok +
+
+
+ `; +}; diff --git a/src/clr-core/modal/package.json b/src/clr-core/modal/package.json new file mode 100644 index 0000000000..1983bfdabc --- /dev/null +++ b/src/clr-core/modal/package.json @@ -0,0 +1,4 @@ +{ + "sideEffects": true, + "name": "@clr/core/modal" +} diff --git a/src/clr-core/styles/layout/_layout.scss b/src/clr-core/styles/layout/_layout.scss index aeceb01330..42a0d0670f 100644 --- a/src/clr-core/styles/layout/_layout.scss +++ b/src/clr-core/styles/layout/_layout.scss @@ -17,3 +17,7 @@ :host { box-sizing: border-box; } + +body[cds-layout='no-scrolling'] { + overflow: hidden; +} diff --git a/src/clr-core/styles/utils/_close.scss b/src/clr-core/styles/utils/_close.scss new file mode 100644 index 0000000000..b956cbe534 --- /dev/null +++ b/src/clr-core/styles/utils/_close.scss @@ -0,0 +1,30 @@ +// Copyright (c) 2016-2020 VMware, Inc. All Rights Reserved. +// This software is released under MIT license. +// The full license information can be found in LICENSE in the root directory of this project. + +// scss-lint:disable QualifyingElement +button.close { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; +} +// scss-lint:enable QualifyingElement + +.close { + font-size: $cds-token-space-size-12; + transition: color linear 0.2s; + font-weight: $cds-token-typography-font-weight-light; + text-shadow: none; + + &:focus, + &:hover, + &:active { + opacity: 1; + } + + &:focus { + @include include-outline-style-form-fields(); + } +} diff --git a/src/clr-core/test-bundles/bundlesize.json b/src/clr-core/test-bundles/bundlesize.json index 9ea19873fc..19caf38833 100644 --- a/src/clr-core/test-bundles/bundlesize.json +++ b/src/clr-core/test-bundles/bundlesize.json @@ -26,7 +26,7 @@ }, { "path": "./dist/test-bundles/webpack.bundle.js", - "maxSize": "18 kB" + "maxSize": "19 kB" } ] }