feat(component): add FileUploader

Closes #16
This commit is contained in:
Eric Liu 2019-12-23 20:59:56 -08:00
commit 85c4a14b2a
25 changed files with 546 additions and 28 deletions

View file

@ -23,13 +23,10 @@
let buttonRef = undefined;
$: {
if (ctx && buttonRef) {
ctx.declareRef({ name: 'buttonRef', ref: buttonRef });
}
$: if (ctx && buttonRef) {
ctx.declareRef({ name: 'buttonRef', ref: buttonRef });
}
const _class = cx(
$: _class = cx(
'--btn',
size === 'field' && '--btn--field',
(size === 'small' || small) && '--btn--sm',
@ -47,7 +44,7 @@
hasIconOnly && tooltipAlignment && `--tooltip--align-${tooltipAlignment}`,
className
);
const buttonProps = {
$: buttonProps = {
role: 'button',
type: href && !disabled ? undefined : type,
tabindex,

View file

@ -25,7 +25,7 @@
);
</script>
<table on:click on:mouseover on:mouseenter on:mouseleave {style} class={_class}>
<table on:click on:mouseover on:mouseenter on:mouseleave class={_class} {style}>
<thead>
<tr>
{#each columns as column, i (column)}

View file

@ -0,0 +1,17 @@
<script>
let className = undefined;
export { className as class };
export let style = undefined;
import SkeletonText from '../SkeletonText';
import { ButtonSkeleton } from '../Button';
import { cx } from '../../lib';
const _class = cx('--form-item', className);
</script>
<div on:click on:mouseover on:mouseenter on:mouseleave class={_class} {style}>
<SkeletonText heading width="100px" />
<SkeletonText width="225px" class={cx('--label-description')} />
<ButtonSkeleton />
</div>

View file

@ -0,0 +1,65 @@
<script>
export let story = undefined;
import Layout from '../../internal/ui/Layout.svelte';
import { cx } from '../../lib';
import Button from '../Button';
import FileUploader from './FileUploader.svelte';
import FileUploaderButton from './FileUploaderButton.svelte';
import FileUploaderItem from './FileUploaderItem.svelte';
import FileUploaderDropContainer from './FileUploaderDropContainer.svelte';
import FileUploaderSkeleton from './FileUploader.Skeleton.svelte';
let files = [];
$: disabled = files.length === 0;
</script>
<Layout>
<div>
{#if story === 'button'}
<FileUploaderButton {...$$props} />
{:else if story === 'drop container'}
<FileUploaderDropContainer
{...$$props}
on:add={({ detail }) => {
console.log(detail);
}} />
{:else if story === 'item'}
<FileUploaderItem
{...$$props}
on:delete={({ detail }) => {
console.log(detail);
}}
on:click={() => {
console.log('click');
}} />
{:else if story === 'uploader'}
<div class={cx('--file__container')}>
<FileUploader
{...$$props}
bind:files
on:add={({ detail }) => {
console.log('add', detail);
}}
on:remove={({ detail }) => {
console.log('remove', detail);
}} />
<Button
kind="secondary"
size="small"
style={'margin-top: 1rem'}
{disabled}
on:click={() => {
files = [];
}}>
Clear File{files.length === 1 ? '' : 's'}
</Button>
</div>
{:else if story === 'skeleton'}
<div style="width: 500px">
<FileUploaderSkeleton />
</div>
{/if}
</div>
</Layout>

View file

@ -0,0 +1,98 @@
import { withKnobs, text, select, boolean, number, array } from '@storybook/addon-knobs';
import Component from './FileUploader.Story.svelte';
export default { title: 'FileUploader', decorators: [withKnobs] };
const buttonKinds = {
'Primary (primary)': 'primary',
'Secondary (secondary)': 'secondary',
'Danger (danger)': 'danger',
'Ghost (ghost)': 'ghost',
'Tertiary (tertiary)': 'tertiary'
};
const filenameStatuses = {
'Edit (edit)': 'edit',
'Complete (complete)': 'complete',
'Uploading (uploading)': 'uploading'
};
export const FileUploaderButton = () => ({
Component,
props: {
story: 'button',
kind: select('Button kind (kind)', buttonKinds, 'primary'),
labelText: text('Label text (labelText)', 'Add files'),
name: text('Form item name: (name)', ''),
multiple: boolean('Supports multiple files (multiple)', true),
disabled: boolean('Disabled (disabled)', false),
disableLabelChanges: boolean(
'Prevent the label from being replaced with file selected file (disableLabelChanges)',
false
),
role: text('ARIA role of the button (role)', 'button'),
tabindex: text('Tab index (tabindex)', '0')
}
});
FileUploaderButton.story = { name: 'FileUploaderButton' };
export const FileUploader = () => ({
Component,
props: {
story: 'uploader',
labelTitle: text('The label title (labelTitle)', 'Upload'),
labelDescription: text(
'The label description (labelDescription)',
'only .jpg files at 500mb or less'
),
buttonLabel: text('The button label (buttonLabel)', 'Add files'),
status: select('Status for file name (status)', filenameStatuses, 'edit'),
accept: array('Accepted file extensions (accept)', ['.jpg', '.png'], ','),
name: text('Form item name: (name)', ''),
multiple: boolean('Supports multiple files (multiple)', true),
iconDescription: text('Close button icon description (iconDescription)', 'Clear file')
}
});
FileUploader.story = { name: 'FileUploader' };
export const FileUploaderItem = () => ({
Component,
props: {
story: 'item',
name: text('Filename (name)', 'README.md'),
status: select('Status for file name (status)', filenameStatuses, 'edit'),
iconDescription: text('Close button icon description (iconDescription)', 'Clear file'),
invalid: boolean('Invalid (invalid)', false),
errorSubject: text('Error subject (errorSubject)', 'File size exceeds limit'),
errorBody: text(
'Error body (errorBody)',
'500kb max file size. Select a new file and try again.'
)
}
});
FileUploaderItem.story = { name: 'FileUploaderItem' };
export const FileUploaderDropContainer = () => ({
Component,
props: {
story: 'drop container',
labelText: text('Label text (labelText)', 'Drag and drop files here or click to upload'),
name: text('Form item name (name)', ''),
multiple: boolean('Supports multiple files (multiple)', true),
accept: array(
'Accepted MIME types or file extensions (accept)',
['image/jpeg', 'image/png'],
','
),
disabled: boolean('Disabled (disabled)', false),
role: text('ARIA role of the button (role)', ''),
tabindex: number('Tab index (tabindex)', '0')
}
});
FileUploaderDropContainer.story = { name: 'FileUploaderDropContainer' };
export const Skeleton = () => ({ Component, props: { story: 'skeleton' } });

View file

@ -0,0 +1,77 @@
<script>
let className = undefined;
export { className as class };
export let files = [];
export let name = '';
export let labelDescription = '';
export let labelTitle = '';
export let iconDescription = 'Provide icon description';
export let status = 'uploading';
export let buttonLabel = '';
export let kind = 'primary';
export let multiple = false;
export let accept = [];
export let style = undefined;
import { createEventDispatcher } from 'svelte';
import { cx } from '../../lib';
import Filename from './Filename.svelte';
import FileUploaderButton from './FileUploaderButton.svelte';
const dispatch = createEventDispatcher();
const _class = cx('--form-item', className);
// let files = [];
let prevFiles = [];
$: {
if (files.length > prevFiles.length) {
dispatch('add', files);
} else {
dispatch(
'remove',
prevFiles.filter(_ => !files.includes(_))
);
}
prevFiles = [...files];
}
</script>
<div on:click on:mouseover on:mouseenter on:mouseleave class={_class} {style}>
<strong class={cx('--file--label')}>{labelTitle}</strong>
<p class={cx('--label-description')}>{labelDescription}</p>
<FileUploaderButton
disableLabelChanges
labelText={buttonLabel}
on:change
on:change={({ target }) => {
files = [...target.files].map(({ name }) => name);
}}
{accept}
{name}
{multiple}
{kind} />
<div class={cx('--file-container')}>
{#each files as name, i (name)}
<span class={cx('--file__selected-file')}>
<p class={cx('--file-filename')}>{name}</p>
<span class={cx('--file__state-container')}>
<Filename
on:keydown
on:keydown={({ key }) => {
if (key === ' ' || key === 'Enter') {
files = files.filter((_, index) => index !== i);
}
}}
on:click
on:click={evt => {
files = files.filter((_, index) => index !== i);
}}
{iconDescription}
{status} />
</span>
</span>
{/each}
</div>
</div>

View file

@ -0,0 +1,64 @@
<script>
let className = undefined;
export { className as class };
export let disableLabelChanges = false;
export let id = Math.random();
export let labelText = 'Add file';
export let multiple = false;
export let name = '';
export let role = 'button';
export let tabindex = '0';
export let kind = 'primary';
export let accept = [];
export let disabled = false;
export let style = undefined;
import { cx } from '../../lib';
const _class = cx(
'--btn',
'--btn--sm',
kind && `--btn--${kind}`,
disabled && '--btn--disabled',
className
);
let inputRef = undefined;
</script>
<label
tabindex={disabled ? '-1' : tabindex}
aria-disabled={disabled}
class={_class}
for={id}
on:keydown
on:keydown={({ key }) => {
if (key === ' ' || key === 'Enter') {
inputRef.click();
}
}}
{style}>
<span {role}>{labelText}</span>
</label>
<input
bind:this={inputRef}
type="file"
tabindex="-1"
class={cx('--visually-hidden')}
on:change|stopPropagation
on:change|stopPropagation={({ target }) => {
const files = target.files;
const length = files.length;
if (files && !disableLabelChanges) {
labelText = length > 1 ? `${length} files` : files[0].name;
}
}}
on:click
on:click={event => {
event.target.value = null;
}}
{id}
{disabled}
{multiple}
{accept}
{name} />

View file

@ -0,0 +1,85 @@
<script>
let className = undefined;
export { className as class };
export let name = '';
export let role = 'button';
export let id = Math.random();
export let disabled = false;
export let tabindex = '0';
export let labelText = 'Add file';
export let multiple = false;
export let accept = [];
export let validateFiles = files => files;
export let style = undefined;
import { createEventDispatcher } from 'svelte';
import SkeletonText from '../SkeletonText';
import { ButtonSkeleton } from '../Button';
import { cx } from '../../lib';
const dispatch = createEventDispatcher();
const _labelClass = cx('--file-browse-btn', disabled && '--file-browse-btn--disabled');
let over = false;
let inputRef = undefined;
$: _class = cx('--file__drop-container', over && '--file__drop-container--drag-over', className);
</script>
<div
class={cx('--file')}
on:dragover
on:dragover|preventDefault|stopPropagation={({ dataTransfer }) => {
if (!disabled) {
over = true;
dataTransfer.dropEffect = 'copy';
}
}}
on:dragleave
on:dragleave|preventDefault|stopPropagation={({ dataTransfer }) => {
if (!disabled) {
over = false;
dataTransfer.dropEffect = 'move';
}
}}
on:drop
on:drop|preventDefault|stopPropagation={({ dataTransfer }) => {
if (!disabled) {
over = false;
dispatch('add', validateFiles(dataTransfer.files));
}
}}
{style}>
<label
class={_labelClass}
for={id}
on:keydown
on:keydown={({ key }) => {
if (key === ' ' || key === 'Enter') {
inputRef.click();
}
}}
{tabindex}>
<div class={_class} {role}>
{labelText}
<input
bind:this={inputRef}
type="file"
tabindex="-1"
class={cx('--file-input')}
on:change
on:change={({ target }) => {
dispatch('add', validateFiles(target.files));
}}
on:click
on:click={({ target }) => {
target.value = null;
}}
{id}
{disabled}
{accept}
{name}
{multiple} />
</div>
</label>
</div>

View file

@ -0,0 +1,49 @@
<script>
let className = undefined;
export { className as class };
export let id = Math.random();
export let status = 'uploading';
export let iconDescription = '';
export let name = '';
export let invalid = false;
export let errorSubject = '';
export let errorBody = '';
export let style = undefined;
import { createEventDispatcher } from 'svelte';
import { cx } from '../../lib';
import Filename from './Filename.svelte';
const dispatch = createEventDispatcher();
const _class = cx(
'--file__selected-file',
invalid && '--file__selected-file--invalid',
className
);
</script>
<span on:mouseover on:mouseenter on:mouseleave class={_class} {style}>
<p class={cx('--file-filename')}>{name}</p>
<span class={cx('--file__state-container')}>
<Filename
{iconDescription}
{status}
{invalid}
on:keydown={({ key }) => {
if (key === ' ' || key === 'Enter') {
dispatch('delete', id);
}
}}
on:click={() => {
dispatch('delete', id);
}} />
</span>
{#if invalid && errorSubject}
<div class={cx('--form-requirement')}>
<div class={cx('--form-requirement__title')}>{errorSubject}</div>
{#if errorBody}
<p class={cx('--form-requirement__supplement')}>{errorBody}</p>
{/if}
</div>
{/if}
</span>

View file

@ -0,0 +1,43 @@
<script>
let className = undefined;
export { className as class };
export let status = 'uploading';
export let iconDescription = '';
export let invalid = false;
export let tabindex = '0';
export let style = undefined;
import Close16 from 'carbon-icons-svelte/lib/Close16';
import CheckmarkFilled16 from 'carbon-icons-svelte/lib/CheckmarkFilled16';
import WarningFilled16 from 'carbon-icons-svelte/lib/WarningFilled16';
import { cx } from '../../lib';
import Loading from '../Loading';
</script>
{#if status === 'uploading'}
<Loading description={iconDescription} withOverlay={false} small class={className} {style} />
{/if}
{#if status === 'edit'}
{#if invalid}
<WarningFilled16 class={cx('--file-invalid')} />
{/if}
<!-- TODO: forward keydown event to Svelte icon -->
<Close16
class={cx('--file-close', className)}
aria-label={iconDescription}
title={iconDescription}
on:click
on:keydown
{tabindex}
{style} />
{/if}
{#if status === 'complete'}
<CheckmarkFilled16
class={cx('--file-complete', className)}
aria-label={iconDescription}
title={iconDescription}
{tabindex}
{style} />
{/if}

View file

@ -0,0 +1,7 @@
import FileUploader from './FileUploader.svelte';
export default FileUploader;
export { default as FileUploaderButton } from './FileUploaderButton.svelte';
export { default as FileUploaderItem } from './FileUploaderItem.svelte';
export { default as FileUploaderDropContainer } from './FileUploaderDropContainer.svelte';
export { default as Filename } from './Filename.svelte';

View file

@ -10,7 +10,7 @@ export const Default = () => ({
disabled: boolean('Disabled (disabled in <Tab>)', false),
href: text('The href for tab (href in <Tab>)', '#'),
role: text('ARIA role (role in <Tab>)', 'presentation'),
tabindex: number('Tab index (tabindex in <Tab>)', 0)
tabindex: text('Tab index (tabindex in <Tab>)', '0')
},
tabsProps: {
className: 'some-class',

View file

@ -48,7 +48,7 @@ export const Expandable = () => ({
Component,
props: {
story: 'expandable',
tabIndex: number('Tab index (tabIndex)', 0),
tabindex: text('Tab index (tabindex)', '0'),
expanded: boolean('Expanded (expanded)', false),
tileMaxHeight: number('Max height (tileMaxHeight)', 0),
tileCollapsedIconText: text(