Break new datetime picker into granular components
This commit is contained in:
parent
f078039aa4
commit
de0f87fa32
|
@ -1,493 +0,0 @@
|
||||||
<script>
|
|
||||||
import "@spectrum-css/calendar/dist/index-vars.css"
|
|
||||||
import "@spectrum-css/inputgroup/dist/index-vars.css"
|
|
||||||
import "@spectrum-css/textfield/dist/index-vars.css"
|
|
||||||
import "@spectrum-css/picker/dist/index-vars.css"
|
|
||||||
import Popover from "../../Popover/Popover.svelte"
|
|
||||||
import dayjs from "dayjs"
|
|
||||||
import { createEventDispatcher } from "svelte"
|
|
||||||
import Select from "../Select.svelte"
|
|
||||||
import Icon from "../../Icon/Icon.svelte"
|
|
||||||
import ActionButton from "../../ActionButton/ActionButton.svelte"
|
|
||||||
|
|
||||||
export let id = null
|
|
||||||
export let disabled = false
|
|
||||||
export let error = null
|
|
||||||
export let enableTime = true
|
|
||||||
export let value = null
|
|
||||||
export let placeholder = null
|
|
||||||
export let appendTo = undefined
|
|
||||||
export let ignoreTimezones = false
|
|
||||||
export let time24hr = false
|
|
||||||
export let range = false
|
|
||||||
export let flatpickr
|
|
||||||
export let useKeyboardShortcuts = true
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
const DaysOfWeek = [
|
|
||||||
"Monday",
|
|
||||||
"Tuesday",
|
|
||||||
"Wednesday",
|
|
||||||
"Thursday",
|
|
||||||
"Friday",
|
|
||||||
"Saturday",
|
|
||||||
"Sunday",
|
|
||||||
]
|
|
||||||
const MonthsOfYear = [
|
|
||||||
"January",
|
|
||||||
"February",
|
|
||||||
"March",
|
|
||||||
"April",
|
|
||||||
"May",
|
|
||||||
"June",
|
|
||||||
"July",
|
|
||||||
"August",
|
|
||||||
"September",
|
|
||||||
"October",
|
|
||||||
"November",
|
|
||||||
"December",
|
|
||||||
]
|
|
||||||
|
|
||||||
let isOpen = false
|
|
||||||
let anchor
|
|
||||||
let calendar
|
|
||||||
let today = dayjs()
|
|
||||||
let calendarDate
|
|
||||||
|
|
||||||
$: parsedValue = parseValue(value)
|
|
||||||
$: displayValue = getDisplayValue(parsedValue)
|
|
||||||
$: calendarDate = dayjs(parsedValue || today).startOf("month")
|
|
||||||
$: mondays = getMondays(calendarDate)
|
|
||||||
|
|
||||||
const clearDateOnBackspace = event => {
|
|
||||||
// Ignore if we're typing a value
|
|
||||||
if (document.activeElement?.tagName.toLowerCase() === "input") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
|
|
||||||
handleChange(null)
|
|
||||||
calendar?.hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onOpen = () => {
|
|
||||||
calendarDate = dayjs(parsedValue || today).startOf("month")
|
|
||||||
isOpen = true
|
|
||||||
if (useKeyboardShortcuts) {
|
|
||||||
document.addEventListener("keyup", clearDateOnBackspace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
isOpen = false
|
|
||||||
if (useKeyboardShortcuts) {
|
|
||||||
document.removeEventListener("keyup", clearDateOnBackspace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseValue = value => {
|
|
||||||
// Sanity check that we have a valid value
|
|
||||||
const parsedDate = dayjs(value)
|
|
||||||
if (!parsedDate?.isValid()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// By rounding to the nearest second we avoid locking up in an endless
|
|
||||||
// loop in the builder, caused by potentially enriching {{ now }} to every
|
|
||||||
// millisecond.
|
|
||||||
return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDisplayValue = value => {
|
|
||||||
if (!value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (!enableTime) {
|
|
||||||
return value.format("MMMM D YYYY")
|
|
||||||
} else {
|
|
||||||
return value.format("MMMM D YYYY, HH:mm")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMondays = monthStart => {
|
|
||||||
if (!monthStart?.isValid()) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
let monthEnd = monthStart.endOf("month")
|
|
||||||
let calendarStart = monthStart.startOf("week")
|
|
||||||
const numWeeks = Math.ceil((monthEnd.diff(calendarStart, "day") + 1) / 7)
|
|
||||||
|
|
||||||
let mondays = []
|
|
||||||
for (let i = 0; i < numWeeks; i++) {
|
|
||||||
mondays.push(calendarStart.add(i, "weeks"))
|
|
||||||
}
|
|
||||||
return mondays
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = date => {
|
|
||||||
if (!date) {
|
|
||||||
dispatch("change", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newValue = date.toISOString()
|
|
||||||
const noTimezone = enableTime && ignoreTimezones
|
|
||||||
|
|
||||||
// For date-only fields, construct a manual timestamp string without a time
|
|
||||||
// or time zone
|
|
||||||
if (!enableTime) {
|
|
||||||
const year = date.year()
|
|
||||||
const month = `${date.month() + 1}`.padStart(2, "0")
|
|
||||||
const day = `${date.date()}`.padStart(2, "0")
|
|
||||||
newValue = `${year}-${month}-${day}T00:00:00.000`
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
|
|
||||||
// time picked, without timezone
|
|
||||||
else if (noTimezone) {
|
|
||||||
const offset = new Date().getTimezoneOffset() * 60000
|
|
||||||
newValue = new Date(date.valueOf() - offset).toISOString().slice(0, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch("change", newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMinuteChange = e => {
|
|
||||||
handleChange(parsedValue.minute(parseInt(e.target.value)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHourChange = e => {
|
|
||||||
handleChange(parsedValue.hour(parseInt(e.target.value)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDateChange = date => {
|
|
||||||
// Select this date at midnight if no current date
|
|
||||||
if (!parsedValue) {
|
|
||||||
handleChange(date)
|
|
||||||
}
|
|
||||||
// Otherwise persist selected time
|
|
||||||
else {
|
|
||||||
handleChange(
|
|
||||||
parsedValue.year(date.year()).month(date.month()).date(date.date())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCalendarYearChange = e => {
|
|
||||||
calendarDate = calendarDate.year(parseInt(e.target.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanNumber = ({ max, pad, fallback }) => {
|
|
||||||
return e => {
|
|
||||||
if (e.target.value) {
|
|
||||||
const value = parseInt(e.target.value)
|
|
||||||
if (isNaN(value)) {
|
|
||||||
e.target.value = fallback
|
|
||||||
} else {
|
|
||||||
e.target.value = Math.min(max, value).toString().padStart(pad, "0")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
e.target.value = fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitization utils
|
|
||||||
const cleanYear = cleanNumber({ max: 9999, pad: 0, fallback: today.year() })
|
|
||||||
const cleanHour = cleanNumber({ max: 23, pad: 2, fallback: "00" })
|
|
||||||
const cleanMinute = cleanNumber({ max: 59, pad: 2, fallback: "00" })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={anchor}
|
|
||||||
class:is-disabled={disabled}
|
|
||||||
class:is-invalid={!!error}
|
|
||||||
class="spectrum-InputGroup spectrum-Datepicker"
|
|
||||||
class:is-focused={isOpen}
|
|
||||||
aria-readonly="false"
|
|
||||||
aria-required="false"
|
|
||||||
aria-haspopup="true"
|
|
||||||
on:click={calendar.show}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="spectrum-Textfield spectrum-InputGroup-textfield"
|
|
||||||
class:is-disabled={disabled}
|
|
||||||
class:is-invalid={!!error}
|
|
||||||
>
|
|
||||||
{#if !!error}
|
|
||||||
<svg
|
|
||||||
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
|
|
||||||
focusable="false"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use xlink:href="#spectrum-icon-18-Alert" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<input
|
|
||||||
{disabled}
|
|
||||||
data-input
|
|
||||||
type="text"
|
|
||||||
class="spectrum-Textfield-input spectrum-InputGroup-input"
|
|
||||||
class:is-disabled={disabled}
|
|
||||||
{placeholder}
|
|
||||||
{id}
|
|
||||||
value={displayValue}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
|
|
||||||
tabindex="-1"
|
|
||||||
class:is-disabled={disabled}
|
|
||||||
class:is-invalid={!!error}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="spectrum-Icon spectrum-Icon--sizeM"
|
|
||||||
focusable="false"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-label="Calendar"
|
|
||||||
>
|
|
||||||
<use xlink:href="#spectrum-icon-18-Calendar" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
bind:this={calendar}
|
|
||||||
on:open={onOpen}
|
|
||||||
on:close={onClose}
|
|
||||||
{anchor}
|
|
||||||
align="left"
|
|
||||||
>
|
|
||||||
<div class="spectrum-Calendar">
|
|
||||||
<div class="spectrum-Calendar-header">
|
|
||||||
<div
|
|
||||||
class="spectrum-Calendar-title"
|
|
||||||
role="heading"
|
|
||||||
aria-live="assertive"
|
|
||||||
aria-atomic="true"
|
|
||||||
>
|
|
||||||
<div class="month-selector">
|
|
||||||
<Select
|
|
||||||
autoWidth
|
|
||||||
placeholder={null}
|
|
||||||
options={MonthsOfYear.map((m, idx) => ({ label: m, value: idx }))}
|
|
||||||
value={calendarDate.month()}
|
|
||||||
on:change={e => (calendarDate = calendarDate.month(e.detail))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
class="custom-num-input"
|
|
||||||
type="number"
|
|
||||||
value={calendarDate.year()}
|
|
||||||
min="0"
|
|
||||||
max="9999"
|
|
||||||
onclick="this.select()"
|
|
||||||
on:change={handleCalendarYearChange}
|
|
||||||
on:input={cleanYear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
aria-label="Previous"
|
|
||||||
title="Previous"
|
|
||||||
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-prevMonth"
|
|
||||||
on:click={() => (calendarDate = calendarDate.subtract(1, "month"))}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="spectrum-Icon spectrum-UIIcon-ChevronLeft100"
|
|
||||||
focusable="false"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="Next"
|
|
||||||
title="Next"
|
|
||||||
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-nextMonth"
|
|
||||||
on:click={() => (calendarDate = calendarDate.add(1, "month"))}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="spectrum-Icon spectrum-UIIcon-ChevronRight100"
|
|
||||||
focusable="false"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="spectrum-Calendar-body"
|
|
||||||
tabindex="0"
|
|
||||||
aria-readonly="true"
|
|
||||||
aria-disabled="false"
|
|
||||||
>
|
|
||||||
<table role="presentation" class="spectrum-Calendar-table">
|
|
||||||
<thead role="presentation">
|
|
||||||
<tr>
|
|
||||||
{#each DaysOfWeek as day}
|
|
||||||
<th scope="col" class="spectrum-Calendar-tableCell">
|
|
||||||
<abbr class="spectrum-Calendar-dayOfWeek" title={day}>
|
|
||||||
{day[0]}
|
|
||||||
</abbr>
|
|
||||||
</th>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody role="presentation">
|
|
||||||
{#each mondays as monday}
|
|
||||||
<tr>
|
|
||||||
{#each [0, 1, 2, 3, 4, 5, 6] as dayOffset}
|
|
||||||
{@const date = monday.add(dayOffset, "days")}
|
|
||||||
{@const outsideMonth = date.month() !== calendarDate.month()}
|
|
||||||
<td
|
|
||||||
class="spectrum-Calendar-tableCell"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-selected="false"
|
|
||||||
aria-invalid="false"
|
|
||||||
title={date.format("dddd, MMMM D, YYYY")}
|
|
||||||
on:click={() => handleDateChange(date)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
class="spectrum-Calendar-date"
|
|
||||||
class:is-outsideMonth={outsideMonth}
|
|
||||||
class:is-today={!outsideMonth && date.isSame(today, "day")}
|
|
||||||
class:is-selected={date.isSame(parsedValue, "day")}
|
|
||||||
>
|
|
||||||
{date.date()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if parsedValue && enableTime}
|
|
||||||
<div class="time-picker">
|
|
||||||
<input
|
|
||||||
class="custom-num-input"
|
|
||||||
type="number"
|
|
||||||
value={parsedValue.hour().toString().padStart(2, "0")}
|
|
||||||
min="0"
|
|
||||||
max="23"
|
|
||||||
onclick="this.select()"
|
|
||||||
on:input={cleanHour}
|
|
||||||
on:change={handleHourChange}
|
|
||||||
/>
|
|
||||||
<span>:</span>
|
|
||||||
<input
|
|
||||||
class="custom-num-input"
|
|
||||||
type="number"
|
|
||||||
value={parsedValue.minute().toString().padStart(2, "0")}
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
onclick="this.select()"
|
|
||||||
on:input={cleanMinute}
|
|
||||||
on:change={handleMinuteChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Date label overrides */
|
|
||||||
.spectrum-Textfield-input {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.spectrum-Textfield:not(.is-disabled):hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.spectrum-Datepicker {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.spectrum-Datepicker .spectrum-Textfield {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.is-disabled {
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Calendar overrides */
|
|
||||||
.spectrum-Calendar {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: stretch;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-header button {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-date.is-outsideMonth {
|
|
||||||
visibility: visible;
|
|
||||||
color: var(--spectrum-global-color-gray-400);
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-date.is-today:before {
|
|
||||||
border-color: var(--spectrum-global-color-gray-400);
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-date.is-today {
|
|
||||||
border-color: var(--spectrum-global-color-gray-400);
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-date.is-selected:not(.is-range-selection) {
|
|
||||||
background: var(--spectrum-global-color-blue-400);
|
|
||||||
}
|
|
||||||
.spectrum-Calendar-nextMonth,
|
|
||||||
.spectrum-Calendar-prevMonth {
|
|
||||||
order: 1;
|
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Month and year selector */
|
|
||||||
.month-selector :global(.spectrum-Picker),
|
|
||||||
.custom-num-input {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
color: var(--spectrum-alias-text-color);
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 130ms ease-out;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
.month-selector :global(.spectrum-Picker:hover),
|
|
||||||
.month-selector :global(.spectrum-Picker.is-open),
|
|
||||||
.custom-num-input:hover {
|
|
||||||
background: var(--spectrum-global-color-gray-200);
|
|
||||||
}
|
|
||||||
.month-selector :global(.spectrum-Picker-label) {
|
|
||||||
font-size: var(--spectrum-calendar-title-text-size);
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--spectrum-alias-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time picker */
|
|
||||||
.time-picker {
|
|
||||||
margin-top: 4px;
|
|
||||||
border-top: 1px solid var(--spectrum-global-color-gray-200);
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.time-picker span {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 18px;
|
|
||||||
z-index: -1;
|
|
||||||
color: var(--spectrum-global-color-gray-700);
|
|
||||||
}
|
|
||||||
.time-picker .custom-num-input:first-child {
|
|
||||||
margin-right: -16px;
|
|
||||||
}
|
|
||||||
.time-picker .custom-num-input:last-child {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -0,0 +1,206 @@
|
||||||
|
<script>
|
||||||
|
import "@spectrum-css/calendar/dist/index-vars.css"
|
||||||
|
import { cleanInput } from "./utils"
|
||||||
|
import Select from "../../Select.svelte"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
export let value
|
||||||
|
export let onChange
|
||||||
|
|
||||||
|
const DaysOfWeek = [
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
"Sunday",
|
||||||
|
]
|
||||||
|
const MonthsOfYear = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
]
|
||||||
|
|
||||||
|
const now = dayjs()
|
||||||
|
let calendarDate
|
||||||
|
|
||||||
|
$: calendarDate = dayjs(value || now).startOf("month")
|
||||||
|
$: mondays = getMondays(calendarDate)
|
||||||
|
|
||||||
|
const getMondays = monthStart => {
|
||||||
|
if (!monthStart?.isValid()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let monthEnd = monthStart.endOf("month")
|
||||||
|
let calendarStart = monthStart.startOf("week")
|
||||||
|
const numWeeks = Math.ceil((monthEnd.diff(calendarStart, "day") + 1) / 7)
|
||||||
|
|
||||||
|
let mondays = []
|
||||||
|
for (let i = 0; i < numWeeks; i++) {
|
||||||
|
mondays.push(calendarStart.add(i, "weeks"))
|
||||||
|
}
|
||||||
|
return mondays
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCalendarYearChange = e => {
|
||||||
|
calendarDate = calendarDate.year(parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateChange = date => {
|
||||||
|
const base = value || now
|
||||||
|
onChange(base.year(date.year()).month(date.month()).date(date.date()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanYear = cleanInput({ max: 9999, pad: 0, fallback: now.year() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="spectrum-Calendar">
|
||||||
|
<div class="spectrum-Calendar-header">
|
||||||
|
<div
|
||||||
|
class="spectrum-Calendar-title"
|
||||||
|
role="heading"
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<div class="month-selector">
|
||||||
|
<Select
|
||||||
|
autoWidth
|
||||||
|
placeholder={null}
|
||||||
|
options={MonthsOfYear.map((m, idx) => ({ label: m, value: idx }))}
|
||||||
|
value={calendarDate.month()}
|
||||||
|
on:change={e => (calendarDate = calendarDate.month(e.detail))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="custom-num-input"
|
||||||
|
type="number"
|
||||||
|
value={calendarDate.year()}
|
||||||
|
min="0"
|
||||||
|
max="9999"
|
||||||
|
onclick="this.select()"
|
||||||
|
on:change={handleCalendarYearChange}
|
||||||
|
on:input={cleanYear}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Previous"
|
||||||
|
title="Previous"
|
||||||
|
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-prevMonth"
|
||||||
|
on:click={() => (calendarDate = calendarDate.subtract(1, "month"))}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="spectrum-Icon spectrum-UIIcon-ChevronLeft100"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Next"
|
||||||
|
title="Next"
|
||||||
|
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-nextMonth"
|
||||||
|
on:click={() => (calendarDate = calendarDate.add(1, "month"))}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="spectrum-Icon spectrum-UIIcon-ChevronRight100"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="spectrum-Calendar-body"
|
||||||
|
tabindex="0"
|
||||||
|
aria-readonly="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
<table role="presentation" class="spectrum-Calendar-table">
|
||||||
|
<thead role="presentation">
|
||||||
|
<tr>
|
||||||
|
{#each DaysOfWeek as day}
|
||||||
|
<th scope="col" class="spectrum-Calendar-tableCell">
|
||||||
|
<abbr class="spectrum-Calendar-dayOfWeek" title={day}>
|
||||||
|
{day[0]}
|
||||||
|
</abbr>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody role="presentation">
|
||||||
|
{#each mondays as monday}
|
||||||
|
<tr>
|
||||||
|
{#each [0, 1, 2, 3, 4, 5, 6] as dayOffset}
|
||||||
|
{@const date = monday.add(dayOffset, "days")}
|
||||||
|
{@const outsideMonth = date.month() !== calendarDate.month()}
|
||||||
|
<td
|
||||||
|
class="spectrum-Calendar-tableCell"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-selected="false"
|
||||||
|
aria-invalid="false"
|
||||||
|
title={date.format("dddd, MMMM D, YYYY")}
|
||||||
|
on:click={() => handleDateChange(date)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
class="spectrum-Calendar-date"
|
||||||
|
class:is-outsideMonth={outsideMonth}
|
||||||
|
class:is-today={!outsideMonth && date.isSame(now, "day")}
|
||||||
|
class:is-selected={date.isSame(value, "day")}
|
||||||
|
>
|
||||||
|
{date.date()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Calendar overrides */
|
||||||
|
.spectrum-Calendar {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-header button {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-date.is-outsideMonth {
|
||||||
|
visibility: visible;
|
||||||
|
color: var(--spectrum-global-color-gray-400);
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-date.is-today:before {
|
||||||
|
border-color: var(--spectrum-global-color-gray-400);
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-date.is-today {
|
||||||
|
border-color: var(--spectrum-global-color-gray-400);
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-date.is-selected:not(.is-range-selection) {
|
||||||
|
background: var(--spectrum-global-color-blue-400);
|
||||||
|
}
|
||||||
|
.spectrum-Calendar-nextMonth,
|
||||||
|
.spectrum-Calendar-prevMonth {
|
||||||
|
order: 1;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,98 @@
|
||||||
|
<script>
|
||||||
|
import "@spectrum-css/inputgroup/dist/index-vars.css"
|
||||||
|
import "@spectrum-css/textfield/dist/index-vars.css"
|
||||||
|
import Icon from "../../../Icon/Icon.svelte"
|
||||||
|
|
||||||
|
export let anchor
|
||||||
|
export let disabled
|
||||||
|
export let error
|
||||||
|
export let focused
|
||||||
|
export let placeholder
|
||||||
|
export let id
|
||||||
|
export let value
|
||||||
|
export let icon = "Calendar"
|
||||||
|
export let enableTime
|
||||||
|
export let timeOnly
|
||||||
|
|
||||||
|
$: displayValue = getDisplayValue(value)
|
||||||
|
|
||||||
|
const getDisplayValue = value => {
|
||||||
|
if (!value?.isValid()) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (!enableTime) {
|
||||||
|
return value.format("MMMM D YYYY")
|
||||||
|
} else if (timeOnly) {
|
||||||
|
return value.format("HH:mm")
|
||||||
|
} else {
|
||||||
|
return value.format("MMMM D YYYY, HH:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={anchor}
|
||||||
|
class:is-disabled={disabled}
|
||||||
|
class:is-invalid={!!error}
|
||||||
|
class="spectrum-InputGroup spectrum-Datepicker"
|
||||||
|
class:is-focused={focused}
|
||||||
|
aria-readonly="false"
|
||||||
|
aria-required="false"
|
||||||
|
aria-haspopup="true"
|
||||||
|
on:click
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="spectrum-Textfield spectrum-InputGroup-textfield"
|
||||||
|
class:is-disabled={disabled}
|
||||||
|
class:is-invalid={!!error}
|
||||||
|
>
|
||||||
|
{#if !!error}
|
||||||
|
<svg
|
||||||
|
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
|
||||||
|
focusable="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use xlink:href="#spectrum-icon-18-Alert" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
{disabled}
|
||||||
|
data-input
|
||||||
|
type="text"
|
||||||
|
class="spectrum-Textfield-input spectrum-InputGroup-input"
|
||||||
|
class:is-disabled={disabled}
|
||||||
|
{placeholder}
|
||||||
|
{id}
|
||||||
|
value={displayValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
|
||||||
|
tabindex="-1"
|
||||||
|
class:is-disabled={disabled}
|
||||||
|
class:is-invalid={!!error}
|
||||||
|
>
|
||||||
|
<Icon name={icon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Date label overrides */
|
||||||
|
.spectrum-Textfield-input {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.spectrum-Textfield:not(.is-disabled):hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.spectrum-Datepicker {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.spectrum-Datepicker .spectrum-Textfield {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.is-disabled {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,170 @@
|
||||||
|
<script>
|
||||||
|
import Popover from "../../../Popover/Popover.svelte"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import TimePicker from "./TimePicker.svelte"
|
||||||
|
import Calendar from "./Calendar.svelte"
|
||||||
|
import DateTimeInput from "./DateTimeField.svelte"
|
||||||
|
|
||||||
|
export let id = null
|
||||||
|
export let disabled = false
|
||||||
|
export let error = null
|
||||||
|
export let enableTime = true
|
||||||
|
export let value = null
|
||||||
|
export let placeholder = null
|
||||||
|
export let timeOnly = false
|
||||||
|
export let ignoreTimezones = false
|
||||||
|
export let range = false
|
||||||
|
export let flatpickr
|
||||||
|
export let useKeyboardShortcuts = true
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
let isOpen = false
|
||||||
|
let anchor
|
||||||
|
let popover
|
||||||
|
|
||||||
|
$: parsedValue = parseValue(value)
|
||||||
|
$: showCalendar = !timeOnly
|
||||||
|
$: showTime = enableTime || timeOnly
|
||||||
|
|
||||||
|
const clearDateOnBackspace = event => {
|
||||||
|
// Ignore if we're typing a value
|
||||||
|
if (document.activeElement?.tagName.toLowerCase() === "input") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
|
||||||
|
handleChange(null)
|
||||||
|
popover?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpen = () => {
|
||||||
|
isOpen = true
|
||||||
|
if (useKeyboardShortcuts) {
|
||||||
|
document.addEventListener("keyup", clearDateOnBackspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
isOpen = false
|
||||||
|
if (useKeyboardShortcuts) {
|
||||||
|
document.removeEventListener("keyup", clearDateOnBackspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseValue = value => {
|
||||||
|
// Sanity check that we have a valid value
|
||||||
|
const parsedDate = dayjs(value)
|
||||||
|
if (!parsedDate?.isValid()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// By rounding to the nearest second we avoid locking up in an endless
|
||||||
|
// loop in the builder, caused by potentially enriching {{ now }} to every
|
||||||
|
// millisecond.
|
||||||
|
return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = date => {
|
||||||
|
if (!date) {
|
||||||
|
dispatch("change", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let newValue = date.toISOString()
|
||||||
|
|
||||||
|
// If time only set date component to 2000-01-01
|
||||||
|
if (timeOnly) {
|
||||||
|
newValue = `2000-01-01T${newValue.split("T")[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// For date-only fields, construct a manual timestamp string without a time
|
||||||
|
// or time zone
|
||||||
|
else if (!enableTime) {
|
||||||
|
const year = date.year()
|
||||||
|
const month = `${date.month() + 1}`.padStart(2, "0")
|
||||||
|
const day = `${date.date()}`.padStart(2, "0")
|
||||||
|
newValue = `${year}-${month}-${day}T00:00:00.000`
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
|
||||||
|
// time picked, without timezone
|
||||||
|
else if (enableTime && ignoreTimezones) {
|
||||||
|
const offset = new Date().getTimezoneOffset() * 60000
|
||||||
|
newValue = new Date(date.valueOf() - offset).toISOString().slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch("change", newValue)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DateTimeInput
|
||||||
|
bind:anchor
|
||||||
|
{disabled}
|
||||||
|
{error}
|
||||||
|
{placeholder}
|
||||||
|
{id}
|
||||||
|
{enableTime}
|
||||||
|
{timeOnly}
|
||||||
|
focused={isOpen}
|
||||||
|
value={parsedValue}
|
||||||
|
on:click={popover?.show}
|
||||||
|
icon={timeOnly ? "Clock" : "Calendar"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
bind:this={popover}
|
||||||
|
on:open={onOpen}
|
||||||
|
on:close={onClose}
|
||||||
|
{anchor}
|
||||||
|
align="left"
|
||||||
|
>
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="date-time-popover">
|
||||||
|
{#if showCalendar}
|
||||||
|
<Calendar value={parsedValue} onChange={handleChange} />
|
||||||
|
{/if}
|
||||||
|
{#if showCalendar && showTime}
|
||||||
|
<hr />
|
||||||
|
{/if}
|
||||||
|
{#if showTime}
|
||||||
|
<TimePicker value={parsedValue} onChange={handleChange} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
hr {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
height: 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--spectrum-global-color-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style inputs */
|
||||||
|
.date-time-popover :global(.spectrum-Picker),
|
||||||
|
.date-time-popover :global(input[type="number"]) {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 130ms ease-out;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.date-time-popover :global(.spectrum-Picker:hover),
|
||||||
|
.date-time-popover :global(.spectrum-Picker.is-open),
|
||||||
|
.date-time-popover :global(input[type="number"]:hover) {
|
||||||
|
background: var(--spectrum-global-color-gray-200);
|
||||||
|
}
|
||||||
|
.date-time-popover :global(.spectrum-Picker-label) {
|
||||||
|
font-size: var(--spectrum-calendar-title-text-size);
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script>
|
||||||
|
import { cleanInput } from "./utils"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
export let value
|
||||||
|
export let onChange
|
||||||
|
|
||||||
|
const now = dayjs()
|
||||||
|
|
||||||
|
$: displayValue = value || now
|
||||||
|
|
||||||
|
const handleHourChange = e => {
|
||||||
|
onChange(displayValue.hour(parseInt(e.target.value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinuteChange = e => {
|
||||||
|
onChange(displayValue.minute(parseInt(e.target.value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" })
|
||||||
|
const cleanMinute = cleanInput({ max: 59, pad: 2, fallback: "00" })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="time-picker">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={displayValue.hour().toString().padStart(2, "0")}
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
onclick="this.select()"
|
||||||
|
on:input={cleanHour}
|
||||||
|
on:change={handleHourChange}
|
||||||
|
/>
|
||||||
|
<span>:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={displayValue.minute().toString().padStart(2, "0")}
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
onclick="this.select()"
|
||||||
|
on:input={cleanMinute}
|
||||||
|
on:change={handleMinuteChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.time-picker {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.time-picker span {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
z-index: -1;
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
}
|
||||||
|
.time-picker input:first-child {
|
||||||
|
margin-right: -16px;
|
||||||
|
}
|
||||||
|
.time-picker input:last-child {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,14 @@
|
||||||
|
export const cleanInput = ({ max, pad, fallback }) => {
|
||||||
|
return e => {
|
||||||
|
if (e.target.value) {
|
||||||
|
const value = parseInt(e.target.value)
|
||||||
|
if (isNaN(value)) {
|
||||||
|
e.target.value = fallback
|
||||||
|
} else {
|
||||||
|
e.target.value = Math.min(max, value).toString().padStart(pad, "0")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.target.value = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -54,7 +54,7 @@ export { default as Notification } from "./Notification/Notification.svelte"
|
||||||
export { default as SideNavigation } from "./SideNavigation/Navigation.svelte"
|
export { default as SideNavigation } from "./SideNavigation/Navigation.svelte"
|
||||||
export { default as SideNavigationItem } from "./SideNavigation/Item.svelte"
|
export { default as SideNavigationItem } from "./SideNavigation/Item.svelte"
|
||||||
export { default as DatePicker } from "./Form/DatePicker.svelte"
|
export { default as DatePicker } from "./Form/DatePicker.svelte"
|
||||||
export { default as BBDatePicker } from "./Form/Core/BBDatePicker.svelte"
|
export { default as DateTimePicker } from "./Form/Core/DateTimePicker/DateTimePicker.svelte"
|
||||||
export { default as Multiselect } from "./Form/Multiselect.svelte"
|
export { default as Multiselect } from "./Form/Multiselect.svelte"
|
||||||
export { default as Context } from "./context"
|
export { default as Context } from "./context"
|
||||||
export { default as Table } from "./Table/Table.svelte"
|
export { default as Table } from "./Table/Table.svelte"
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
Body,
|
Body,
|
||||||
Search,
|
Search,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
BBDatePicker,
|
DateTimePicker,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import Spinner from "components/common/Spinner.svelte"
|
import Spinner from "components/common/Spinner.svelte"
|
||||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||||
|
@ -212,7 +212,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h1>Date only</h1>
|
<h1>Date only</h1>
|
||||||
<div style="width: 240px;">
|
<div style="width: 240px;">
|
||||||
<BBDatePicker
|
<DateTimePicker
|
||||||
enableTime={false}
|
enableTime={false}
|
||||||
value={foo}
|
value={foo}
|
||||||
on:change={e => (foo = e.detail)}
|
on:change={e => (foo = e.detail)}
|
||||||
|
@ -220,17 +220,25 @@
|
||||||
</div>
|
</div>
|
||||||
<h1>Date time</h1>
|
<h1>Date time</h1>
|
||||||
<div style="width: 240px;">
|
<div style="width: 240px;">
|
||||||
<BBDatePicker enableTime value={foo} on:change={e => (foo = e.detail)} />
|
<DateTimePicker
|
||||||
|
enableTime
|
||||||
|
value={foo}
|
||||||
|
on:change={e => (foo = e.detail)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1>Date time no timezone</h1>
|
<h1>Date time no timezone</h1>
|
||||||
<div style="width: 240px;">
|
<div style="width: 240px;">
|
||||||
<BBDatePicker
|
<DateTimePicker
|
||||||
enableTime
|
enableTime
|
||||||
ignoreTimezones
|
ignoreTimezones
|
||||||
value={foo}
|
value={foo}
|
||||||
on:change={e => (foo = e.detail)}
|
on:change={e => (foo = e.detail)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<h1>Time only</h1>
|
||||||
|
<div style="width: 240px;">
|
||||||
|
<DateTimePicker timeOnly value={foo} on:change={e => (foo = e.detail)} />
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
|
Loading…
Reference in New Issue