feat(component): add Modal

Closes #18
This commit is contained in:
Eric Liu 2019-12-25 13:20:14 -08:00
commit a8f464586a
16 changed files with 300 additions and 15 deletions

View file

@ -46,6 +46,7 @@ Currently, the following components are supported:
- Link
- ListItem
- Loading
- Modal
- Notification
- ToastNotification
- InlineNotification

View file

@ -70,4 +70,4 @@
}</script><style>#root[hidden],
#docs-root[hidden] {
display: none !important;
}</style></head><body><div class="sb-nopreview sb-wrapper"><div class="sb-nopreview_main"><h1 class="sb-nopreview_heading sb-heading">No Preview</h1><p>Sorry, but you either have no stories or none are selected somehow.</p><ul><li>Please check the Storybook config.</li><li>Try reloading the page.</li></ul><p>If the problem persists, check the browser console, or the terminal you've run Storybook from.</p></div></div><div class="sb-errordisplay sb-wrapper"><pre id="error-message" class="sb-heading"></pre><pre class="sb-errordisplay_code"><code id="error-stack"></code></pre></div><div id="root"></div><div id="docs-root"></div><script src="runtime~main.34594522ea7877f23b9b.bundle.js"></script><script src="vendors~main.34594522ea7877f23b9b.bundle.js"></script><script src="main.34594522ea7877f23b9b.bundle.js"></script></body></html>
}</style></head><body><div class="sb-nopreview sb-wrapper"><div class="sb-nopreview_main"><h1 class="sb-nopreview_heading sb-heading">No Preview</h1><p>Sorry, but you either have no stories or none are selected somehow.</p><ul><li>Please check the Storybook config.</li><li>Try reloading the page.</li></ul><p>If the problem persists, check the browser console, or the terminal you've run Storybook from.</p></div></div><div class="sb-errordisplay sb-wrapper"><pre id="error-message" class="sb-heading"></pre><pre class="sb-errordisplay_code"><code id="error-stack"></code></pre></div><div id="root"></div><div id="docs-root"></div><script src="runtime~main.d4781677fb9dd4f4edea.bundle.js"></script><script src="vendors~main.d4781677fb9dd4f4edea.bundle.js"></script><script src="main.d4781677fb9dd4f4edea.bundle.js"></script></body></html>

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"version":3,"file":"main.34594522ea7877f23b9b.bundle.js","sources":["webpack:///main.34594522ea7877f23b9b.bundle.js"],"mappings":"AAAA","sourceRoot":""}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"version":3,"file":"main.d4781677fb9dd4f4edea.bundle.js","sources":["webpack:///main.d4781677fb9dd4f4edea.bundle.js"],"mappings":"AAAA","sourceRoot":""}

View file

@ -1,2 +1,2 @@
!function(modules){function webpackJsonpCallback(data){for(var moduleId,chunkId,chunkIds=data[0],moreModules=data[1],executeModules=data[2],i=0,resolves=[];i<chunkIds.length;i++)chunkId=chunkIds[i],Object.prototype.hasOwnProperty.call(installedChunks,chunkId)&&installedChunks[chunkId]&&resolves.push(installedChunks[chunkId][0]),installedChunks[chunkId]=0;for(moduleId in moreModules)Object.prototype.hasOwnProperty.call(moreModules,moduleId)&&(modules[moduleId]=moreModules[moduleId]);for(parentJsonpFunction&&parentJsonpFunction(data);resolves.length;)resolves.shift()();return deferredModules.push.apply(deferredModules,executeModules||[]),checkDeferredModules()}function checkDeferredModules(){for(var result,i=0;i<deferredModules.length;i++){for(var deferredModule=deferredModules[i],fulfilled=!0,j=1;j<deferredModule.length;j++){var depId=deferredModule[j];0!==installedChunks[depId]&&(fulfilled=!1)}fulfilled&&(deferredModules.splice(i--,1),result=__webpack_require__(__webpack_require__.s=deferredModule[0]))}return result}var installedModules={},installedChunks={1:0},deferredModules=[];function __webpack_require__(moduleId){if(installedModules[moduleId])return installedModules[moduleId].exports;var module=installedModules[moduleId]={i:moduleId,l:!1,exports:{}};return modules[moduleId].call(module.exports,module,module.exports,__webpack_require__),module.l=!0,module.exports}__webpack_require__.m=modules,__webpack_require__.c=installedModules,__webpack_require__.d=function(exports,name,getter){__webpack_require__.o(exports,name)||Object.defineProperty(exports,name,{enumerable:!0,get:getter})},__webpack_require__.r=function(exports){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(exports,"__esModule",{value:!0})},__webpack_require__.t=function(value,mode){if(1&mode&&(value=__webpack_require__(value)),8&mode)return value;if(4&mode&&"object"==typeof value&&value&&value.__esModule)return value;var ns=Object.create(null);if(__webpack_require__.r(ns),Object.defineProperty(ns,"default",{enumerable:!0,value:value}),2&mode&&"string"!=typeof value)for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns},__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module.default}:function getModuleExports(){return module};return __webpack_require__.d(getter,"a",getter),getter},__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)},__webpack_require__.p="";var jsonpArray=window.webpackJsonp=window.webpackJsonp||[],oldJsonpFunction=jsonpArray.push.bind(jsonpArray);jsonpArray.push=webpackJsonpCallback,jsonpArray=jsonpArray.slice();for(var i=0;i<jsonpArray.length;i++)webpackJsonpCallback(jsonpArray[i]);var parentJsonpFunction=oldJsonpFunction;checkDeferredModules()}([]);
//# sourceMappingURL=runtime~main.34594522ea7877f23b9b.bundle.js.map
//# sourceMappingURL=runtime~main.d4781677fb9dd4f4edea.bundle.js.map

View file

@ -1 +1 @@
{"version":3,"file":"runtime~main.34594522ea7877f23b9b.bundle.js","sources":["webpack:///runtime~main.ce61f8335d8fdea2cda4.bundle.js"],"mappings":"AAAA","sourceRoot":""}
{"version":3,"file":"runtime~main.d4781677fb9dd4f4edea.bundle.js","sources":["webpack:///runtime~main.ce61f8335d8fdea2cda4.bundle.js"],"mappings":"AAAA","sourceRoot":""}

View file

@ -1 +0,0 @@
{"version":3,"file":"vendors~main.34594522ea7877f23b9b.bundle.js","sources":["webpack:///vendors~main.9ba103da9a704f605f83.bundle.js"],"mappings":"AAAA;;;;;AAyqeA;;;;;AAi9JA;;;;;AAkkEA;;;;;;;;;AAukBA;;;AA8odA;;;;;;;;AAg/BA;;;;;;;;AAqEA;;;;;;;;AAkTA;;;;;;;AAyrDA;;;;;;;AAy7CA;;;;;;;AAufA;;;;;;;AAfA","sourceRoot":""}

View file

@ -0,0 +1 @@
{"version":3,"file":"vendors~main.d4781677fb9dd4f4edea.bundle.js","sources":["webpack:///vendors~main.d4781677fb9dd4f4edea.bundle.js"],"mappings":"AAAA;;;;;AA2qeA;;;;;AAi9JA;;;;;AAkkEA;;;;;;;;;AAukBA;;;AA8odA;;;;;;;;AAg/BA;;;;;;;;AAqEA;;;;;;;;AAkTA;;;;;;;AAyrDA;;;;;;;AAy7CA;;;;;;;AAsfA;;;;;;;AAfA","sourceRoot":""}

View file

@ -0,0 +1,83 @@
<script>
import Layout from '../../internal/ui/Layout.svelte';
import Button from '../Button';
import Modal from './Modal.svelte';
let open = $$props.open;
</script>
<Layout>
<div>
<Button
on:click={() => {
open = true;
}}>
Launch modal
</Button>
</div>
<Modal
{...$$props}
bind:open
on:click:button--secondary={() => {
console.log('click button secondary');
}}
on:open={() => {
console.log('open');
}}
on:close={() => {
console.log('close');
}}
on:submit={() => {
console.log('submit');
}}>
<p>
This component supports two-way binding by default. Please see ComposedModal for piecemeal
functionality.
</p>
{#if $$props.hasScrollingContent}
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
<h3>Lorem ipsum</h3>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id accumsan augue. Phasellus
consequat augue vitae tellus tincidunt posuere. Curabitur justo urna, consectetur vel elit
iaculis, ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie tellus.
Quisque consectetur non risus eu rutrum.{' '}
</p>
{/if}
</Modal>
</Layout>

View file

@ -0,0 +1,37 @@
import { withKnobs, boolean, text, select } from '@storybook/addon-knobs';
import Component from './Modal.Story.svelte';
export default { title: 'Modal', decorators: [withKnobs] };
const sizes = {
Default: '',
'Extra small (xs)': 'xs',
'Small (sm)': 'sm',
'Large (lg)': 'lg'
};
export const Default = () => ({
Component,
props: {
open: boolean('Open (open)', true),
passiveModal: boolean('Without footer (passiveModal)', false),
danger: boolean('Danger mode (danger)', false),
shouldSubmitOnEnter: boolean('Enter key to submit (shouldSubmitOnEnter)', false),
focusTrap: boolean('Trap focus (focusTrap)', false),
hasScrollingContent: boolean('Modal contains scrollable content (hasScrollingContent)', false),
modalHeading: text('Modal heading (modalHeading)', 'Modal heading'),
modalLabel: text('Optional label (modalLabel)', 'Label'),
modalAriaLabel: text(
'ARIA label, used only if modalLabel not provided (modalAriaLabel)',
'A label to be read by screen readers on the modal root node'
),
primaryButtonText: text('Primary button text (primaryButtonText)', 'Primary Button'),
secondaryButtonText: text('Secondary button text (secondaryButtonText)', 'Secondary Button'),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
'[data-modal-primary-focus]'
),
size: select('Size (size)', sizes),
iconDescription: text('Close icon description (iconDescription)', 'Close the modal')
}
});

View file

@ -0,0 +1,159 @@
<script>
let className = undefined;
export { className as class };
export let passiveModal = false;
export let hasForm = false;
export let id = Math.random();
export let modalHeading = undefined; // node
export let modalLabel = undefined; // node
export let open = false;
export let iconDescription = 'Close the modal';
export let primaryButtonDisabled = false;
export let primaryButtonText = '';
export let secondaryButtonText = '';
export let danger = false;
export let shouldSubmitOnEnter = true;
export let hasScrollingContent = false;
export let selectorPrimaryFocus = '[data-modal-primary-focus]';
export let size = undefined;
export let style = undefined;
import { createEventDispatcher, afterUpdate, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import Close20 from 'carbon-icons-svelte/lib/Close20';
import { cx } from '../../lib';
import Button from '../Button';
const dispatch = createEventDispatcher();
let buttonRef = undefined;
let outerModal = undefined;
let innerModal = undefined;
// TODO: reuse in ComposedModal
function focus(element) {
const node = (element || innerModal).querySelector(selectorPrimaryFocus) || buttonRef;
node.focus();
}
afterUpdate(() => {
if (open) {
focus();
dispatch('open');
document.body.classList.add(cx('--body--with-modal-open'));
} else {
dispatch('close');
document.body.classList.remove(cx('--body--with-modal-open'));
}
});
onDestroy(() => {
document.body.classList.remove(cx('--body--with-modal-open'));
});
$: modalInstanceId = `modal-${id}`;
$: modalLabelId = cx(`--modal-header__label--${modalInstanceId}`);
$: modalHeadingId = cx(`--modal-header__heading--${modalInstanceId}`);
$: ariaLabel = modalLabel || $$props['aria-label'] || modalAriaLabel || modalHeading;
</script>
<div
bind:this={outerModal}
role="presentation"
tabindex="-1"
class={cx('--modal', !passiveModal && '--modal-tall', open && 'is-visible', danger && '--modal--danger', className)}
on:keydown
on:keydown={({ key }) => {
if (open) {
if (key === 'Escape') {
open = false;
} else if (shouldSubmitOnEnter && key === 'Enter') {
dispatch('submit');
}
}
}}
on:click
on:click={({ target }) => {
if (!innerModal.contains(target)) {
open = false;
}
}}
on:mouseover
on:mouseenter
on:mouseleave
{id}
{style}>
<div
bind:this={innerModal}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
class={cx('--modal-container', size && `--modal-container--${size}`)}>
<div class={cx('--modal-header')}>
{#if passiveModal}
<button
bind:this={buttonRef}
type="button"
aria-label={iconDescription}
title={iconDescription}
class={cx('--modal-close')}
on:click={() => {
open = false;
}}>
<Close20 aria-label={iconDescription} class={cx('--modal-close__icon')} />
</button>
{/if}
{#if modalLabel}
<h2 id={modalLabelId} class={cx('--modal-header__label')}>
<slot name="label">{modalLabel}</slot>
</h2>
{/if}
<h3 id={modalHeadingId} class={cx('--modal-header__heading')}>
<slot name="heading">{modalHeading}</slot>
</h3>
{#if !passiveModal}
<button
bind:this={buttonRef}
type="button"
aria-label={iconDescription}
title={iconDescription}
class={cx('--modal-close')}
on:click={() => {
open = false;
}}>
<Close20 aria-label={iconDescription} class={cx('--modal-close__icon')} />
</button>
{/if}
</div>
<div
class={cx('--modal-content', hasForm && '--modal-content--with-form', hasScrollingContent && '--modal-scroll-content')}
tabindex={hasScrollingContent ? '0' : undefined}
role={hasScrollingContent ? 'region' : undefined}
aria-label={hasScrollingContent ? ariaLabel : undefined}
aria-labelledby={modalLabel ? modalLabelId : modalHeadingId}>
<slot />
</div>
{#if hasScrollingContent}
<div class={cx('--modal-content--overflow-indicator')} />
{/if}
{#if !passiveModal}
<div class={cx('--modal-footer')}>
<Button
kind="secondary"
on:click={() => {
dispatch('click:button--secondary');
}}>
{secondaryButtonText}
</Button>
<Button
kind={danger ? 'danger' : 'primary'}
disabled={primaryButtonDisabled}
on:click={() => {
dispatch('submit');
}}>
{primaryButtonText}
</Button>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,3 @@
import Modal from './Modal.svelte';
export default Modal;

View file

@ -23,6 +23,7 @@ import InlineLoading from './components/InlineLoading';
import Link from './components/Link';
import ListItem from './components/ListItem';
import Loading from './components/Loading';
import Modal from './components/Modal';
import {
ToastNotification,
InlineNotification,
@ -105,6 +106,7 @@ export {
Icon,
IconSkeleton,
InlineLoading,
Modal,
InlineNotification,
Link,
ListItem,