Skip to content

Commit

Permalink
feat(cc-popover): init component
Browse files Browse the repository at this point in the history
Fixes #829
  • Loading branch information
pdesoyres-cc committed Nov 8, 2023
1 parent 014c592 commit 0887067
Show file tree
Hide file tree
Showing 5 changed files with 408 additions and 0 deletions.
264 changes: 264 additions & 0 deletions src/components/cc-popover/cc-popover.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import '../cc-button/cc-button.js';
import { css, html, LitElement } from 'lit';
import { createRef, ref } from 'lit/directives/ref.js';
import { dispatchCustomEvent, EventHandler } from '../../lib/events.js';

/**
* @typedef {import('../common.types.js').IconModel} IconModel
* @typedef {import('./cc-popover.types.js').PopoverPosition} PopoverPosition
*/

/**
* A component displaying a floating content next to a button element.
*
* ## Details
*
* ### Button element
*
* The `button` is the element that will trigger the display of the floating content.
* This element is a `<cc-button>`. The `cc-button:click` event will trigger the popover toggle.
*
* ### Popover content
*
* The `content` is the element that will be displayed next to the `button` element.
* This element must be placed in the default slot.
* It will be placed according to the `position` property.
*
* ## Accessibility
*
* The component places necessary aria attribute on the `button` element:
*
* * `aria-expanded`: set to `true` when popover is open, `false` when it is closed.
*
* When the popover is open, pressing `esc` will close the popover and focus the `button` element.
*
* ## Usage
*
* ```html
* <cc-popover>
* <span slot="button-content">Click me</span>
* <div>This is a content to be displayed when button is clicked.</div>
* </cc-popover>
* ```
*
* @cssdisplay block
*
* @event {CustomEvent} cc-popover:open - Fires whenever the popover is opened.
* @event {CustomEvent} cc-popover:close - Fires whenever the popover is closed.
*
* @slot - The area containing the content of the popover.
* @slot button-content - The area containing the button content.
*
* @cssprop {Size} --cc-popover-gap - Sets the gap between the button and the floating area (default 0.4em).
* @cssprop {Number} --cc-popover-z-index - Sets the z-index of the floating content (defaults: `999`).
*/
export class CcPopover extends LitElement {
static get properties () {
return {
accessibleName: { type: String, attribute: 'accessible-name' },
hideText: { type: Boolean, attribute: 'hide-text' },
icon: { type: Object },
isOpen: { type: Boolean, attribute: 'is-open', reflect: true },
position: { type: String },
};
}

constructor () {
super();

/** @type {string|null} Sets the accessibleName property of the underlying `cc-button` element. CAUTION: The accessible name should always start with the visible text if there is one. */
this.accessibleName = null;

/** @type {boolean} Whether the button text should be hidden. */
this.hideText = false;

/** @type {IconModel|null} Sets the button icon. */
this.icon = null;

/** @type {boolean} Whether the popover is opened */
this.isOpen = false;

/** @type {PopoverPosition} Sets the position of the popover relative to the `button` element. */
this.position = 'bottom-left';

/** @type {Ref<HTMLElement>} */
this._contentRef = createRef();

/** @type {Ref<HTMLElement>} */
this._buttonRef = createRef();

this._onOutsideClickHandler = new EventHandler(window, 'click', (event) => {
const contentElement = this._contentRef.value;
if (contentElement != null && !event.composedPath().includes(contentElement)) {
this.close();
}
});

this._onEscapeKeyHandler = new EventHandler(this, 'keydown', (event) => {
if (event.key === 'Escape') {
this.close();
}
});

// Opening a popover must close the last opened popover.
let lastOpenedPopover = null;
this._onCcPopoverOpenHandler = new EventHandler(window, 'cc-popover:open', (event) => {
// We cannot use event.target because events that happen in shadow DOM and when caught from outside the shadow DOM,
// have the host element as the target (and not the real target element inside the shadow DOM).
const popover = event.composedPath()[0];

if (popover !== this) {
lastOpenedPopover = popover;
}
else {
lastOpenedPopover?.close(false);
lastOpenedPopover = null;
}
});
}

// region Public methods

/**
* Opens the popover.
*/
open () {
if (!this.isOpen) {
this.isOpen = true;
dispatchCustomEvent(this, 'open');
}
}

/**
* Closes the popover.
* @param {boolean} [shouldFocus = true] Whereas the button should be focused. This applies only if the popover was opened.
*/
close (shouldFocus = true) {
if (this.isOpen) {
this.isOpen = false;
if (shouldFocus) {
this.focus();
}
dispatchCustomEvent(this, 'close');
}
}

/**
* Toggle the popover display.
*/
toggle () {
if (this.isOpen) {
this.close();
}
else {
this.open();
}
}

/**
* Moves the focus on the button.
*/
focus () {
this._buttonRef.value?.focus();
}

// endregion

// region Lit lifecycle

updated (changedProperties) {
if (changedProperties.has('isOpen')) {
if (this.isOpen) {
this._onOutsideClickHandler.connect();
this._onEscapeKeyHandler.connect();
}
else {
this._onOutsideClickHandler.disconnect();
this._onEscapeKeyHandler.disconnect();
}
}
}

connectedCallback () {
super.connectedCallback();
this._onCcPopoverOpenHandler.connect();
}

disconnectedCallback () {
super.disconnectedCallback();
this._onOutsideClickHandler.disconnect();
this._onEscapeKeyHandler.disconnect();
this._onCcPopoverOpenHandler.disconnect();
}

// endregion

render () {
return html`
<div class="wrapper">
<cc-button
${ref(this._buttonRef)}
.a11yExpanded=${this.isOpen}
.accessibleName=${this.accessibleName}
?hide-text=${this.hideText}
.icon=${this.icon}
@cc-button:click=${this.toggle}
>
<slot name="button-content"></slot>
</cc-button>
${this.isOpen ? html`
<div class="content ${this.position.replace('-', ' ')}" ${ref(this._contentRef)}>
<slot></slot>
</div>
` : null}
</div>
`;
}

static get styles () {
return [
// language=CSS
css`
:host {
display: block;
--cc-popover-gap: 0.4em;
}
.wrapper {
position: relative;
}
.content {
position: absolute;
z-index: var(--cc-popover-z-index, 999);
padding: 0.5em;
border: 1px solid var(--cc-color-border-neutral, #aaa);
background-color: var(--cc-color-bg-default, #fff);
border-radius: var(--cc-border-radius-default, 0.25em);
box-shadow: 0 2px 4px rgb(38 38 38 / 25%),
0 5px 15px rgb(38 38 38 / 25%);
}
.content.bottom {
top: calc(100% + var(--cc-popover-gap));
}
.content.top {
bottom: calc(100% + var(--cc-popover-gap));
}
.content.right {
right: 0;
}
.content.left {
left: 0;
}
`,
];
}
}

window.customElements.define('cc-popover', CcPopover);
95 changes: 95 additions & 0 deletions src/components/cc-popover/cc-popover.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import './cc-popover.js';
import {
iconRemixArrowLeftDownLine as iconArrowLeftDown,
iconRemixArrowLeftUpLine as iconArrowLeftUp,
iconRemixArrowRightDownLine as iconArrowRightDown,
iconRemixArrowRightUpLine as iconArrowRightUp,
} from '../../assets/cc-remix.icons.js';
import { makeStory } from '../../stories/lib/make-story.js';
import { enhanceStoriesNames } from '../../stories/lib/story-names.js';

export default {
title: '🧬 Atoms/<cc-popover>',
component: 'cc-popover',
};

const conf = {
component: 'cc-popover',
displayMode: 'flex-wrap',
// language=CSS
css: `
cc-popover {
margin: 2.5em;
}
cc-popover div {
white-space: nowrap;
}
`,
};

const items = [
{
innerHTML: '<div>This is the popover content</div>',
hideText: true,
icon: iconArrowLeftUp,
position: 'top-right',
accessibleName: 'Click me to toggle popover',
},
{
innerHTML: '<div>This is the popover content</div>',
hideText: true,
icon: iconArrowRightUp,
position: 'top-left',
accessibleName: 'Click me to toggle popover',
},
{
innerHTML: '<div>This is the popover content</div>',
hideText: true,
icon: iconArrowLeftDown,
position: 'bottom-right',
accessibleName: 'Click me to toggle popover',
},
{
innerHTML: '<div>This is the popover content</div>',
hideText: true,
icon: iconArrowRightDown,
position: 'bottom-left',
accessibleName: 'Click me to toggle popover',
},
];

export const defaultStory = makeStory(conf, {
items,
});

export const withButtonText = makeStory(conf, {
items: items.map((item) => ({
...item,
hideText: false,
icon: null,
innerHTML: `<span slot="button-content">Click me</span>${item.innerHTML}`,
})),
});

export const withButtonTextAndIcon = makeStory(conf, {
items: items.map((item) => ({
...item,
hideText: false,
innerHTML: `<span slot="button-content">Click me</span>${item.innerHTML}`,
})),
});

export const withFocusableContent = makeStory(conf, {
items: items.map((item) => ({
...item,
innerHTML: '<div>This is the popover content with <cc-button>Button</cc-button></div>',
})),
});

enhanceStoriesNames({
defaultStory,
withButtonText,
withButtonTextAndIcon,
withFocusableContent,
});
11 changes: 11 additions & 0 deletions src/components/cc-popover/cc-popover.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-env node, mocha */

import { testAccessibility } from '../../../test/helpers/accessibility.js';
import { getStories } from '../../../test/helpers/get-stories.js';
import * as storiesModule from './cc-popover.stories.js';

const storiesToTest = getStories(storiesModule);

describe(`Component: ${storiesModule.default.component}`, function () {
testAccessibility(storiesToTest);
});
1 change: 1 addition & 0 deletions src/components/cc-popover/cc-popover.types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type PopoverPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';

0 comments on commit 0887067

Please sign in to comment.