feat(component): add DatePicker

Closes #15
This commit is contained in:
Eric Liu 2019-12-28 18:11:05 -08:00
commit 6ca56d67a6
23 changed files with 602 additions and 21 deletions

View file

@ -0,0 +1,21 @@
<script>
let className = undefined;
export { className as class };
export let id = Math.random();
export let range = false;
export let style = undefined;
import { cx, fillArray } from '../../lib';
</script>
<div on:click on:mouseover on:mouseenter on:mouseleave class={cx('--form-item')} {style}>
<div
class={cx('--date-picker', '--skeleton', range && '--date-picker--range', !range && '--date-picker--short', !range && '--date-picker--simple', className)}>
{#each fillArray(range ? 2 : 1) as input, i (input)}
<div class={cx('--date-picker-container')}>
<label class={cx('--label')} for={id} />
<div class={cx('--date-picker__input', '--skeleton')} />
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,65 @@
<script>
export let story = undefined;
import Layout from '../../internal/ui/Layout.svelte';
import DatePicker from './DatePicker.svelte';
import DatePickerSkeleton from './DatePicker.Skeleton.svelte';
import DatePickerInput from './DatePickerInput.svelte';
let datePickerType = 'simple';
let value = '';
</script>
<Layout>
{#if story === 'skeleton'}
<DatePickerSkeleton range />
{:else if story === 'single'}
<div>
<DatePicker
{...$$props.datePicker}
bind:value
datePickerType="single"
on:change={({ detail }) => {
console.log('change', detail);
}}>
<DatePickerInput
{...$$props.datePickerInput}
on:close={() => {
console.log('on:close');
}}
on:input={() => {
console.log('on:input');
}} />
</DatePicker>
<button
on:click|preventDefault={() => {
value = '12/12/2020';
}}
style="margin-top: 1rem">
Set date to 12/12/2020
</button>
</div>
{:else if story === 'range'}
<DatePicker {...$$props.datePicker} bind:value datePickerType="range">
<DatePickerInput
{...$$props.datePickerInput}
id="date-picker-input-id-start"
labelText="Start date" />
<DatePickerInput
{...$$props.datePickerInput}
id="date-picker-input-id-end"
labelText="End date" />
</DatePicker>
{:else}
<DatePicker
{...$$props.datePicker}
bind:datePickerType
bind:value
on:change={({ detail }) => {
console.log('on:change', detail);
}}>
<DatePickerInput {...$$props.datePickerInput} />
</DatePicker>
{/if}
</Layout>

View file

@ -0,0 +1,99 @@
import { withKnobs, select, text, boolean } from '@storybook/addon-knobs';
import Component from './DatePicker.Story.svelte';
export default { title: 'DatePicker', decorators: [withKnobs] };
const patterns = {
'Short (d{1,2}/d{4})': 'd{1,2}/d{4}',
'Regular (d{1,2}/d{1,2}/d{4})': 'd{1,2}/d{1,2}/d{4}'
};
export const Default = () => ({
Component,
props: {
datePicker: {
id: 'date-picker',
light: boolean('Light variant (light in <DatePicker>)', false),
short: boolean('Use shorter width (short in <DatePicker>)', false)
},
datePickerInput: {
id: 'date-picker-input-id',
labelText: text('Label text (labelText in <DatePickerInput>)', 'Date Picker label'),
hideLabel: boolean('Hide label (hideLabel)', false),
pattern: select('The date format (pattern in <DatePickerInput>)', patterns, 'd{1,2}/d{4}'),
placeholder: text('Placeholder text (placeholder in <DatePickerInput>)', 'mm/dd/yyyy'),
disabled: boolean('Disabled (disabled in <DatePickerInput>)', false),
invalid: boolean('Show form validation UI (invalid in <DatePickerInput>)', false),
invalidText: text(
'Form validation UI content (invalidText in <DatePickerInput>)',
'A valid value is required'
),
iconDescription: text(
'Icon description (iconDescription in <DatePickerInput>)',
'Icon description'
)
}
}
});
Default.story = { name: 'Default (simple)' };
export const Single = () => ({
Component,
props: {
story: 'single',
datePicker: {
id: 'date-picker',
light: boolean('Light variant (light in <DatePicker>)', false),
dateFormat: text('The date format (dateFormat in <DatePicker>)', 'm/d/Y')
},
datePickerInput: {
id: 'date-picker-input-id',
labelText: text('Label text (labelText in <DatePickerInput>)', 'Date Picker label'),
hideLabel: boolean('Hide label (hideLabel)', false),
pattern: select('The date format (pattern in <DatePickerInput>)', patterns, 'd{1,2}/d{4}'),
placeholder: text('Placeholder text (placeholder in <DatePickerInput>)', 'mm/dd/yyyy'),
disabled: boolean('Disabled (disabled in <DatePickerInput>)', false),
invalid: boolean('Show form validation UI (invalid in <DatePickerInput>)', false),
invalidText: text(
'Form validation UI content (invalidText in <DatePickerInput>)',
'A valid value is required'
),
iconDescription: text(
'Icon description (iconDescription in <DatePickerInput>)',
'Icon description'
)
}
}
});
export const Range = () => ({
Component,
props: {
story: 'range',
datePicker: {
id: 'date-picker',
light: boolean('Light variant (light in <DatePicker>)', false),
dateFormat: text('The date format (dateFormat in <DatePicker>)', 'm/d/Y')
},
datePickerInput: {
id: 'date-picker-input-id',
labelText: text('Label text (labelText in <DatePickerInput>)', 'Date Picker label'),
hideLabel: boolean('Hide label (hideLabel)', false),
pattern: select('The date format (pattern in <DatePickerInput>)', patterns, 'd{1,2}/d{4}'),
placeholder: text('Placeholder text (placeholder in <DatePickerInput>)', 'mm/dd/yyyy'),
disabled: boolean('Disabled (disabled in <DatePickerInput>)', false),
invalid: boolean('Show form validation UI (invalid in <DatePickerInput>)', false),
invalidText: text(
'Form validation UI content (invalidText in <DatePickerInput>)',
'A valid value is required'
),
iconDescription: text(
'Icon description (iconDescription in <DatePickerInput>)',
'Icon description'
)
}
}
});
export const Skeleton = () => ({ Component, props: { story: 'skeleton' } });

View file

@ -0,0 +1,145 @@
<script>
let className = undefined;
export { className as class };
export let id = Math.random();
export let short = false;
export let datePickerType = 'simple';
export let dateFormat = 'm/d/Y';
export let locale = 'en';
export let value = '';
export let appendTo = document.body;
export let minDate = null;
export let maxDate = null;
export let light = false;
export let style = undefined;
import { createEventDispatcher, setContext, afterUpdate, onDestroy } from 'svelte';
import { writable, derived } from 'svelte/store';
import { createCalendar } from './flatpickr';
import { cx } from '../../lib';
const dispatch = createEventDispatcher();
let inputs = writable([]);
let inputIds = derived(inputs, _ => _.map(({ id }) => id));
let inputsById = derived(inputs, _ => _.reduce((a, c) => ({ ...a, [c.id]: c }), {}));
let labelTextEmpty = derived(inputs, _ => _.filter(({ labelText }) => !!labelText).length === 0);
let inputValue = writable(value);
let mode = writable(datePickerType);
let range = derived(mode, _ => _ === 'range');
let hasCalendar = derived(mode, _ => _ === 'single' || _ === 'range');
let calendar = undefined;
let datePickerRef = undefined;
let inputRef = undefined;
let inputRefTo = undefined;
setContext('DatePicker', {
range,
inputValue,
hasCalendar,
add: data => {
inputs.update(_ => [..._, data]);
},
declareRef: ({ id, ref }) => {
if ($inputIds.indexOf(id) === 0) {
inputRef = ref;
} else {
inputRefTo = ref;
}
},
updateValue: ({ id, type, value }) => {
if ((!calendar && type === 'input') || type === 'change') {
inputValue.set(value);
}
if (!calendar && type === 'change') {
dispatch('change', value);
}
},
blurInput: relatedTarget => {
if (calendar && !calendar.calendarContainer.contains(relatedTarget)) {
calendar.close();
}
},
openCalendar: () => {
calendar.open();
},
focusCalendar: () => {
(
calendar.selectedDateElem ||
calendar.todayDateElem ||
calendar.calendarContainer.querySelector('.flatpickr-day[tabindex]') ||
calendar.calendarContainer
).focus();
}
});
afterUpdate(() => {
if ($hasCalendar && !calendar) {
calendar = createCalendar({
options: {
appendTo,
dateFormat,
defaultDate: $inputValue,
locale,
maxDate,
minDate,
mode: $mode
},
base: inputRef,
input: inputRefTo,
dispatch: event => {
const detail = { selectedDates: calendar.selectedDates };
if ($range) {
detail.dateStr = {
from: inputRef.value,
to: inputRefTo.value
};
} else {
detail.dateStr = inputRef.value;
}
return dispatch(event, detail);
}
});
}
if (calendar && !$range) {
calendar.setDate($inputValue);
}
});
onDestroy(() => {
if (calendar) {
calendar.destroy();
}
});
// $: hasCalendar.set($mode === 'single' || $mode === 'range');
$: inputValue.set(value);
$: value = $inputValue;
</script>
<svelte:body
on:click={({ target }) => {
if (!calendar || !calendar.isOpen) {
return;
}
if (datePickerRef && datePickerRef.contains(target)) {
return;
}
if (!calendar.calendarContainer.contains(target)) {
calendar.close();
}
}} />
<div on:click on:mouseover on:mouseenter on:mouseleave class={cx('--form-item', className)} {style}>
<div
bind:this={datePickerRef}
class={cx('--date-picker', short && '--date-picker--short', light && '--date-picker--light', datePickerType && `--date-picker--${datePickerType}`, datePickerType === 'range' && $labelTextEmpty && '--date-picker--nolabel')}
{id}>
<slot />
</div>
</div>

View file

@ -0,0 +1,92 @@
<script>
let className = undefined;
export { className as class };
export let id = Math.random();
export let iconDescription = '';
export let labelText = '';
export let hideLabel = false;
export let pattern = '\\d{1,2}\\/\\d{1,2}\\/\\d{4}';
export let type = 'text';
export let placeholder = '';
export let disabled = false;
export let invalid = false;
export let invalidText = '';
export let style = undefined;
import { getContext, onMount } from 'svelte';
import Calendar16 from 'carbon-icons-svelte/lib/Calendar16';
import { cx } from '../../lib';
const {
range,
add,
hasCalendar,
declareRef,
updateValue,
blurInput,
openCalendar,
focusCalendar,
inputValue
} = getContext('DatePicker');
let inputRef = undefined;
let iconRef = undefined;
add({ id, labelText });
onMount(() => {
declareRef({ id, ref: inputRef });
});
</script>
<div
class={cx('--date-picker-container', !labelText && '--date-picker--nolabel', className)}
{style}>
{#if labelText}
<label
class={cx('--label', hideLabel && '--visually-hidden', disabled && '--label--disabled')}
for={id}>
{labelText}
</label>
{/if}
<div class={cx('--date-picker-input__wrapper')}>
<input
bind:this={inputRef}
data-invalid={invalid || undefined}
class={cx('--date-picker__input')}
on:input
on:input={({ target }) => {
updateValue({ id, type: 'input', value: target.value });
}}
on:change={({ target }) => {
updateValue({ id, type: 'change', value: target.value });
}}
on:keydown
on:keydown={({ key }) => {
if (key === 'ArrowDown') {
focusCalendar();
}
}}
on:blur
on:blur={({ relatedTarget }) => {
blurInput(relatedTarget);
}}
{id}
{placeholder}
{type}
{pattern}
{disabled}
value={!$range ? $inputValue : undefined} />
{#if $hasCalendar}
<Calendar16
role="img"
class={cx('--date-picker__icon')}
aria-label={iconDescription}
title={iconDescription}
on:click={openCalendar} />
{/if}
</div>
{#if invalid}
<div class={cx('--form-requirement')}>{invalidText}</div>
{/if}
</div>

View file

@ -0,0 +1,79 @@
import flatpickr from 'flatpickr';
import l10n from 'flatpickr/dist/l10n';
import rangePlugin from 'flatpickr/dist/plugins/rangePlugin';
import { cx } from '../../lib';
l10n.en.weekdays.shorthand.forEach((_, index) => {
const shorthand = _.slice(0, 2);
l10n.en.weekdays.shorthand[index] = shorthand === 'Th' ? 'Th' : shorthand.charAt(0);
});
function updateClasses(instance) {
const { calendarContainer, days, daysContainer, weekdayContainer, selectedDates } = instance;
calendarContainer.classList.add(cx('--date-picker__calendar'));
calendarContainer.querySelector('.flatpickr-month').classList.add(cx('--date-picker__month'));
weekdayContainer.classList.add(cx('--date-picker__weekdays'));
weekdayContainer.querySelectorAll('.flatpickr-weekday').forEach(node => {
node.classList.add(cx('--date-picker__weekday'));
});
daysContainer.classList.add(cx('--date-picker__days'));
days.querySelectorAll('.flatpickr-day').forEach(node => {
node.classList.add(cx('--date-picker__day'));
if (node.classList.contains('today') && selectedDates.length > 0) {
node.classList.add('no-border');
} else if (node.classList.contains('today') && selectedDates.length === 0) {
node.classList.remove('no-border');
}
});
}
function updateMonthNode(instance) {
const monthText = instance.l10n.months.longhand[instance.currentMonth];
const staticMonthNode = instance.monthNav.querySelector('.cur-month');
if (staticMonthNode) {
staticMonthNode.textContent = monthText;
} else {
const monthSelectNode = instance.monthsDropdownContainer;
const span = document.createElement('span');
span.setAttribute('class', 'cur-month');
span.textContent = monthText;
monthSelectNode.parentNode.replaceChild(span, monthSelectNode);
}
}
function createCalendar({ options, base, input, dispatch }) {
return new flatpickr(base, {
...options,
allowInput: true,
disableMobile: true,
clickOpens: true,
locale: l10n[options.locale],
plugins: [options.mode === 'range' && new rangePlugin({ position: 'left', input })].filter(
Boolean
),
nextArrow:
'<svg width="16px" height="16px" viewBox="0 0 16 16"><polygon points="11,8 6,13 5.3,12.3 9.6,8 5.3,3.7 6,3 "/><rect width="16" height="16" style="fill: none" /></svg>',
prevArrow:
'<svg width="16px" height="16px" viewBox="0 0 16 16"><polygon points="5,8 10,3 10.7,3.7 6.4,8 10.7,12.3 10,13 "/><rect width="16" height="16" style="fill: none" /></svg>',
onChange: () => {
dispatch('change');
},
onClose: () => {
dispatch('close');
},
onMonthChange: ({}, {}, instance) => {
updateMonthNode(instance);
},
onOpen: ({}, {}, instance) => {
dispatch('open');
updateClasses(instance);
updateMonthNode(instance);
}
});
}
export { createCalendar };

View file

@ -0,0 +1,4 @@
import DatePicker from './DatePicker.svelte';
export default DatePicker;
export { default as DatePickerInput } from './DatePickerInput.svelte';