-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
5 changed files
with
408 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export type PopoverPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; |
Oops, something went wrong.