feat(component): add ComposedModal

Closes #13
This commit is contained in:
Eric Liu 2019-12-23 18:38:33 -08:00
commit fc366a9366
21 changed files with 518 additions and 23 deletions

View file

@ -30,7 +30,7 @@
kind,
disabled,
size,
renderIcon: Add16,
icon: Add16,
iconDescription,
tooltipPosition,
tooltipAlignment

View file

@ -9,15 +9,26 @@
export let href = undefined;
export let tabindex = '0';
export let type = 'button';
export let renderIcon = undefined;
export let icon = undefined;
export let iconDescription = undefined;
export let hasIconOnly = false;
export let tooltipPosition = undefined;
export let tooltipAlignment = undefined;
export let style = undefined;
import { getContext } from 'svelte';
import { cx } from '../../lib';
const ctx = getContext('ComposedModal');
let buttonRef = undefined;
$: {
if (ctx && buttonRef) {
ctx.declareRef({ name: 'buttonRef', ref: buttonRef });
}
}
const _class = cx(
'--btn',
size === 'field' && '--btn--field',
@ -51,28 +62,34 @@
<slot props={buttonProps} />
{:else}
{#if href && !disabled}
<a {...buttonProps} {href} on:click on:mouseover on:mouseenter on:mouseleave>
<a {...buttonProps} on:click on:mouseover on:mouseenter on:mouseleave {href}>
{#if hasIconOnly}
<span class={cx('--assistive-text')}>{iconDescription}</span>
{/if}
<slot />
{#if renderIcon}
{#if icon}
<svelte:component
this={renderIcon}
this={icon}
aria-hidden="true"
class={cx('--btn__icon')}
aria-label={iconDescription} />
{/if}
</a>
{:else}
<button {...buttonProps} on:click on:mouseover on:mouseenter on:mouseleave>
<button
{...buttonProps}
bind:this={buttonRef}
on:click
on:mouseover
on:mouseenter
on:mouseleave>
{#if hasIconOnly}
<span class={cx('--assistive-text')}>{iconDescription}</span>
{/if}
<slot />
{#if renderIcon}
{#if icon}
<svelte:component
this={renderIcon}
this={icon}
aria-hidden="true"
class={cx('--btn__icon')}
aria-label={iconDescription} />

View file

@ -0,0 +1,135 @@
<script>
export let story = undefined;
const { modalBody } = $$props;
import Layout from '../../internal/ui/Layout.svelte';
import Button from '../Button';
import ComposedModal from './ComposedModal.svelte';
import ModalHeader from './ModalHeader.svelte';
import ModalBody from './ModalBody.svelte';
import ModalFooter from './ModalFooter.svelte';
let open = false;
</script>
<Layout>
{#if story === undefined}
<ComposedModal {...$$props.composedModal}>
<ModalHeader {...$$props.modalHeader} />
<ModalBody
{...$$props.modalBody}
aria-label={modalBody.hasScrollingContent ? 'Modal content' : undefined}>
<p>Please see ModalWrapper for more examples and demo of the functionality.</p>
{#if modalBody.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>
{/if}
</ModalBody>
<ModalFooter {...$$props.modalFooter} />
</ComposedModal>
{/if}
{#if story === 'child nodes'}
<ComposedModal {...$$props.composedModal}>
<ModalHeader {...$$props.modalHeader}>
<h1>Testing</h1>
</ModalHeader>
<ModalBody
{...$$props.modalBody}
aria-label={modalBody.hasScrollingContent ? 'Modal content' : undefined}>
<p>Please see ModalWrapper for more examples and demo of the functionality.</p>
{#if modalBody.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>
{/if}
</ModalBody>
<ModalFooter>
<Button kind="secondary">Cancel</Button>
<Button kind={$$props.composedModal.danger ? 'danger' : 'primary'}>Primary</Button>
</ModalFooter>
</ComposedModal>
{/if}
{#if story === 'title'}
<ComposedModal {...$$props.composedModal} open on:close={() => {}} on:submit={() => {}}>
<ModalHeader
{...$$props.modalHeader}
title="Passive modal title as the message. Should be direct and 3 lines or less." />
<ModalBody {...$$props.modalBody} />
<ModalFooter {...$$props.modalFooter} />
</ComposedModal>
{/if}
{#if story === 'trigger'}
<div>
<Button
on:click={() => {
open = true;
}}>
Launch composed modal
</Button>
</div>
<ComposedModal {...$$props.composedModal} {open} on:close={() => (open = false)}>
<ModalHeader {...$$props.modalHeader} />
<ModalBody
{...$$props.modalBody}
aria-label={modalBody.hasScrollingContent ? 'Modal content' : undefined}>
<p>Please see ModalWrapper for more examples and demo of the functionality.</p>
{#if modalBody.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}
</ModalBody>
<ModalFooter {...$$props.modalFooter} />
</ComposedModal>
{/if}
</Layout>

View file

@ -0,0 +1,135 @@
import { withKnobs, select, boolean, text } from '@storybook/addon-knobs';
import Component from './ComposedModal.Story.svelte';
export default { title: 'ComposedModal', decorators: [withKnobs] };
const sizes = {
Default: '',
'Extra small (xs)': 'xs',
'Small (sm)': 'sm',
'Large (lg)': 'lg'
};
export const Default = () => ({
Component,
props: {
composedModal: {
open: boolean('Open (open in <ComposedModal>)', true),
danger: boolean('Danger mode (danger)', false),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
'[data-modal-primary-focus]'
),
size: select('Size (size)', sizes, 'sm')
},
modalHeader: {
label: text('Optional Label (label in <ModalHeader>)', 'Optional Label'),
title: text('Optional title (title in <ModalHeader>)', 'Example'),
iconDescription: text('Close icon description (iconDescription in <ModalHeader>)', 'Close')
},
modalBody: {
hasScrollingContent: boolean('Modal contains scrollable content (hasScrollingContent)', true),
'aria-label': text('ARIA label for content', 'Example modal content')
},
modalFooter: {
primaryButtonText: text('Primary button text (primaryButtonText in <ModalFooter>)', 'Save'),
primaryButtonDisabled: boolean(
'Primary button disabled (primaryButtonDisabled in <ModalFooter>)',
false
),
secondaryButtonText: text('Secondary button text (secondaryButtonText in <ModalFooter>)', '')
}
}
});
export const ChildNodes = () => ({
Component,
props: {
story: 'child nodes',
composedModal: {
open: boolean('Open (open in <ComposedModal>)', true),
danger: boolean('Danger mode (danger)', false),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
'[data-modal-primary-focus]'
),
size: select('Size (size)', sizes, 'sm')
},
modalHeader: {
label: text('Optional Label (label in <ModalHeader>)', 'Optional Label'),
title: text('Optional title (title in <ModalHeader>)', 'Example'),
iconDescription: text('Close icon description (iconDescription in <ModalHeader>)', 'Close')
},
modalBody: {
hasScrollingContent: boolean('Modal contains scrollable content (hasScrollingContent)', true),
'aria-label': text('ARIA label for content', 'Example modal content')
},
modalFooter: {}
}
});
export const TitleOnly = () => ({
Component,
props: {
story: 'title',
composedModal: {
open: boolean('Open (open in <ComposedModal>)', true),
danger: boolean('Danger mode (danger)', false),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
'[data-modal-primary-focus]'
),
size: select('Size (size)', sizes, 'sm')
},
modalHeader: {
label: text('Optional Label (label in <ModalHeader>)', 'Optional Label'),
title: text('Optional title (title in <ModalHeader>)', 'Example'),
iconDescription: text('Close icon description (iconDescription in <ModalHeader>)', 'Close')
},
modalBody: {
hasScrollingContent: boolean('Modal contains scrollable content (hasScrollingContent)', true),
'aria-label': text('ARIA label for content', 'Example modal content')
},
modalFooter: {
primaryButtonText: text('Primary button text (primaryButtonText in <ModalFooter>)', 'Save'),
primaryButtonDisabled: boolean(
'Primary button disabled (primaryButtonDisabled in <ModalFooter>)',
false
),
secondaryButtonText: text('Secondary button text (secondaryButtonText in <ModalFooter>)', '')
}
}
});
export const Trigger = () => ({
Component,
props: {
story: 'trigger',
composedModal: {
open: boolean('Open (open in <ComposedModal>)', true),
danger: boolean('Danger mode (danger)', false),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
'[data-modal-primary-focus]'
),
size: select('Size (size)', sizes, 'sm')
},
modalHeader: {
label: text('Optional Label (label in <ModalHeader>)', 'Optional Label'),
title: text('Optional title (title in <ModalHeader>)', 'Example'),
iconDescription: text('Close icon description (iconDescription in <ModalHeader>)', 'Close')
},
modalBody: {
hasScrollingContent: boolean('Modal contains scrollable content (hasScrollingContent)', true),
'aria-label': text('ARIA label for content', 'Example modal content')
},
modalFooter: {
primaryButtonText: text('Primary button text (primaryButtonText in <ModalFooter>)', 'Save'),
primaryButtonDisabled: boolean(
'Primary button disabled (primaryButtonDisabled in <ModalFooter>)',
false
),
secondaryButtonText: text('Secondary button text (secondaryButtonText in <ModalFooter>)', '')
}
}
});

View file

@ -0,0 +1,91 @@
<script>
let className = undefined;
export { className as class };
export let containerClass = undefined;
export let open = false;
export let danger = false;
export let selectorPrimaryFocus = '[data-modal-primary-focus]';
export let size = undefined;
export let style = undefined;
import { createEventDispatcher, setContext, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import { cx } from '../../lib';
const dispatch = createEventDispatcher();
const refs = {};
let outerModal = undefined;
let innerModal = undefined;
setContext('ComposedModal', {
closeModal: () => {
open = false;
},
submit: () => {
dispatch('submit');
},
declareRef: ({ name, ref }) => {
refs[name] = ref;
}
});
onDestroy(() => {
document.body.classList.remove(cx('--body--with-modal-open'));
});
function focus(element) {
if (element.querySelector(selectorPrimaryFocus)) {
return focusElement.focus();
}
if (refs.buttonRef) {
refs.buttonRef.focus();
}
}
const _containerClass = cx(
'--modal-container',
size && `--modal-container--${size}`,
containerClass
);
$: didOpen = open;
$: _class = cx('--modal', open && 'is-visible', danger && '--modal--danger', className);
$: if (innerModal) {
focus(innerModal);
}
$: {
if (open) {
document.body.classList.add(cx('--body--with-modal-open'));
} else {
dispatch('close');
document.body.classList.remove(cx('--body--with-modal-open'));
}
}
</script>
<div
role="presentation"
tabindex="-1"
bind:this={outerModal}
class={_class}
on:click
on:click={({ target }) => {
if (!innerModal.contains(target)) {
open = false;
}
}}
on:mouseover
on:mouseenter
on:mouseleave
on:transitionend
on:transitionend={() => {
if (didOpen) {
focus(outerModal);
didOpen = false;
}
}}
{style}>
<div bind:this={innerModal} class={_containerClass}>
<slot />
</div>
</div>

View file

@ -0,0 +1,22 @@
<script>
let className = undefined;
export { className as class };
export let hasForm = false;
export let hasScrollingContent = false;
export let style = undefined;
import { cx } from '../../lib';
const _class = cx('--modal-content', hasForm && '--modal-content--with-form', className);
</script>
<div
tabindex={hasScrollingContent ? '0' : undefined}
role={hasScrollingContent ? 'region' : undefined}
class={_class}
{style}>
<slot />
</div>
{#if hasScrollingContent}
<div class={cx('--modal-content--overflow-indicator')} />
{/if}

View file

@ -0,0 +1,37 @@
<script>
let className = undefined;
export { className as class };
export let primaryClass = undefined;
export let primaryButtonText = '';
export let primaryButtonDisabled = false;
export let secondaryClass = undefined;
export let secondaryButtonText = '';
export let danger = false;
export let style = undefined;
import { createEventDispatcher, getContext } from 'svelte';
import { cx } from '../../lib';
import Button from '../Button';
const dispatch = createEventDispatcher();
const { closeModal, submit } = getContext('ComposedModal');
const _footerClass = cx('--modal-footer', className);
</script>
<div class={_footerClass} {style}>
{#if secondaryButtonText}
<Button kind="secondary" class={secondaryClass} on:click={closeModal}>
{secondaryButtonText}
</Button>
{/if}
{#if primaryButtonText}
<Button
class={primaryClass}
kind={danger ? 'danger' : 'primary'}
disabled={primaryButtonDisabled}
on:click={submit}>
{primaryButtonText}
</Button>
{/if}
<slot />
</div>

View file

@ -0,0 +1,43 @@
<script>
let className = undefined;
export { className as class };
export let labelClass = undefined;
export let titleClass = undefined;
export let closeClass = undefined;
export let closeIconClass = undefined;
export let label = undefined;
export let title = '';
export let iconDescription = 'Close';
export let style = undefined;
import Close20 from 'carbon-icons-svelte/lib/Close20';
import { getContext } from 'svelte';
import { cx } from '../../lib';
const { closeModal } = getContext('ComposedModal');
const _class = cx('--modal-header', className);
const _labelClass = cx('--modal-header__label', '--type-delta', labelClass);
const _titleClass = cx('--modal-header__heading', '--type-beta', titleClass);
const _closeClass = cx('--modal-close', closeClass);
const _closeIconClass = cx('--modal-close__icon', closeIconClass);
</script>
<div class={_class} {style}>
{#if label}
<p class={_labelClass}>{label}</p>
{/if}
{#if title}
<p class={_titleClass}>{title}</p>
{/if}
<slot />
<button
type="button"
title={iconDescription}
aria-label={iconDescription}
class={_closeClass}
on:click
on:click={closeModal}>
<Close20 class={_closeIconClass} />
</button>
</div>

View file

@ -0,0 +1,6 @@
import ComposedModal from './ComposedModal.svelte';
export default ComposedModal;
export { default as ModalHeader } from './ModalHeader.svelte';
export { default as ModalBody } from './ModalBody.svelte';
export { default as ModalFooter } from './ModalFooter.svelte';

View file

@ -5,6 +5,7 @@ import Checkbox, { CheckboxSkeleton } from './components/Checkbox';
import ContentSwitcher, { Switch } from './components/ContentSwitcher';
import Copy from './components/Copy';
import CopyButton from './components/CopyButton';
import ComposedModal, { ModalHeader, ModalBody, ModalFooter } from './components/ComposedModal';
import CodeSnippet, { CodeSnippetSkeleton } from './components/CodeSnippet';
import DataTableSkeleton from './components/DataTableSkeleton';
import Form from './components/Form';
@ -77,6 +78,10 @@ export {
ClickableTile,
CodeSnippet,
CodeSnippetSkeleton,
ComposedModal,
ModalHeader,
ModalBody,
ModalFooter,
ContentSwitcher,
Copy,
CopyButton,