swaf/src/assets/views/utils/Field.svelte

484 lines
12 KiB
Svelte

<script lang="ts">
import {locals} from '../../ts/stores.js';
import {FileSize} from "../../../common/FileSize.js";
import Message from "../components/Message.svelte";
import Icon from "./Icon.svelte";
import {getContext} from "svelte";
import {dateToDatetimeLocal, dateToIsoString} from "../../ts/datetime-local.js";
export let type: string;
export let name: string;
type FieldValue = string | number | boolean | Record<string, FieldValue>;
export let value: FieldValue | undefined = undefined;
export let initialValue: FieldValue | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined;
export let icon: string | undefined = undefined;
export let validation = $locals.validation?.[name] as { message: string, value?: string } | undefined;
const formId = getContext('formId');
const fieldId = `${formId}-${name}-field`;
const previousFormData = $locals.previousFormData as FieldValue | undefined || [];
let previousFieldData = previousFormData[name];
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);
value = type !== 'hidden' && previousFieldData || value || initialValue || validation?.value || '';
$: initialDatetimeLocalValue = type === 'datetime-local' && typeof value === 'string' ? dateToDatetimeLocal(new Date(value)) : undefined;
function durationValue(f: string): number {
if (previousFormData[name]) {
return value[f];
}
switch (f) {
case 's':
return value % 60;
case 'm':
return (value - value % 60) / 60 % 60;
case 'h':
return (value - value % 3600) / 3600;
default:
return 0;
}
}
function focusInput(e) {
if (input) {
if (input.type === 'checkbox') {
if (e.target !== label && e.target !== input) {
input.click();
}
} else if (['file', 'color'].indexOf(input.type) >= 0) {
input.click();
} else {
input.focus();
}
} else {
this.querySelector('input')?.focus();
}
}
function handleInput() {
switch (this.type) {
case 'number':
case 'range':
value = +this.value;
break;
case 'file':
handleFileInput();
break;
case 'datetime-local':
value = dateToIsoString(new Date(this.value));
break;
case 'checkbox':
value = !!this.checked;
break;
default:
value = this.value;
break;
}
}
let input: HTMLInputElement;
let label: HTMLLabelElement;
function chooseFile() {
input.click();
}
export let fileList: FileList | undefined = undefined;
function handleFileInput() {
fileList = input.files;
}
let focused = false;
</script>
<style lang="scss">
@import "../../scss/helpers";
.form-field:not(.hidden) {
display: flex;
flex-direction: column;
margin: 16px auto;
.control {
position: relative;
display: flex;
align-items: start;
flex-direction: row;
color: var(--on-input);
background-color: var(--input);
border-radius: 5px;
> :global(.icon) {
--icon-size: 24px;
margin: 18px;
opacity: 0.75;
}
}
label {
position: absolute;
z-index: 1;
left: 8px;
top: 22px;
user-select: none;
font-size: 16px;
transition-property: top, font-size;
transition-duration: 150ms;
transition-timing-function: ease-out;
cursor: text;
}
.has-icon label {
left: 68px;
}
&.disabled {
opacity: 0.5;
&, * {
cursor: not-allowed;
}
}
input, select, textarea {
z-index: 1;
border: 0;
color: inherit;
background: transparent;
font-size: 16px;
border-radius: 5px;
outline-offset: 0;
}
&:not(.empty),
select ~,
[type="file"] ~,
[type="color"] ~,
[type="number"] ~,
.focused ~,
:focus ~,
fieldset {
.sections label, legend, .time-input label {
top: 8px;
font-size: 14px;
}
}
input,
select,
select,
textarea,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after {
display: block;
padding: 32px 8px 8px;
width: 100%;
height: 60px;
}
select {
position: relative;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&::-ms-expand {
display: none;
}
& + :global(.icon) {
position: absolute;
pointer-events: none;
right: 0;
top: 0;
transition: transform 150ms ease-out;
}
// TODO: Temporary
&:focus + :global(.icon) {
transform: rotateX(180deg);
}
}
.textarea-growing-wrapper {
display: grid;
flex-grow: 1;
height: revert;
padding: 0;
&:after {
content: attr(data-value) " ";
color: red;
box-sizing: border-box;
font: inherit;
visibility: hidden;
}
textarea {
height: auto;
resize: none;
font-family: inherit;
overflow: hidden;
}
&:after, textarea {
grid-area: 1 / 1 / 2 / 2;
margin-left: revert;
min-height: 100px;
height: revert;
white-space: pre-wrap;
word-wrap: anywhere;
}
}
.has-icon > {
input,
select,
select,
fieldset,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after,
.textarea-growing-wrapper > textarea {
margin-left: -60px;
padding-left: 68px;
}
.textarea-growing-wrapper > textarea {
margin-left: -68px;
width: calc(100% + 68px);
}
}
input[type=color] {
height: calc(32px + 8px + 32px);
}
&.checkbox, &.color, &.file {
input, label {
cursor: pointer;
}
}
&.checkbox {
.control {
display: flex;
flex-direction: row;
align-items: stretch;
flex-grow: 1;
}
input {
width: 20px;
height: 20px;
margin: auto 8px;
text-align: left;
& ~ .sections {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
label {
position: static;
font-size: 16px;
}
}
}
}
&.file {
input {
@include fake-hide;
}
label {
position: static;
display: block;
padding: 8px;
font-size: revert !important;
}
}
.files {
display: flex;
flex-direction: column;
align-items: start;
.file {
margin: 8px;
padding: 8px;
color: var(--on-primary);
background: var(--primary);
border-radius: 5px;
> div {
display: inline-block;
padding: 8px;
}
.name {
font-size: 20px;
:global(.icon) {
--icon-size: 24px;
margin-right: 8px;
}
}
}
}
fieldset {
display: flex;
flex-direction: row;
align-items: start;
width: 100%;
margin: 0;
padding-top: 8px;
border: 0;
legend {
padding: 8px;
}
.time-input {
position: relative;
flex: 1;
margin: 0;
label {
left: 8px;
}
}
+ {
.error, .hint {
margin-top: -16px;
margin-bottom: 16px;
}
}
}
}
.form-field, fieldset + {
.error, .hint {
padding: 2px 2px 2px 4px;
text-align: left;
font-size: 14px;
color: var(--color);
}
}
</style>
{#if type === 'hidden'}
{#if validation}
<Message type="error" content={validation.message}/>
{/if}
<input type="hidden" name={name} value={value}>
{:else}
<div class="form-field"
class:checkbox={type === 'checkbox'}
class:color={type === 'color'}
class:file={type === 'file'}
class:empty={value === ''}
class:disabled={Object.keys($$restProps).indexOf('disabled') >= 0}>
<div class="control" class:has-icon={icon} on:click={focusInput}>
{#if icon}
<Icon name={icon}/>
{/if}
{#if type === 'duration'}
<fieldset>
<legend>{placeholder}</legend>
{#each Object.keys(extraData) as f}
<div class="time-input">
<input type="number" name="{name}[{f}]" id="{fieldId}-{f}"
value={durationValue(f)}
min="0" max={(f === 's' || f === 'm') && '60' || undefined}
{...$$restProps} on:click={e => e.stopPropagation()}>
<label for="{fieldId}-{f}" on:click={e => e.stopPropagation()}>{extraData[f] || f}</label>
</div>
{/each}
</fieldset>
{:else if type === 'select'}
<select name={name} id={fieldId} {...$$restProps} bind:this={input} bind:value={value} on:input={handleInput}>
{#each extraData as option}
<option value={(option.display === undefined || option.value !== undefined) && (option.value || option)}
>{option.display || option}</option>
{/each}
</select>
<Icon name="chevron-down"/>
{:else if type === 'textarea'}
<div class="textarea-growing-wrapper" class:focused={focused} data-value={value}>
<textarea {name} id={fieldId} {value} {...$$restProps} bind:this={input}
on:input={handleInput}
on:focusin={() => focused = true}
on:focusout={() => focused = false}></textarea>
</div>
{:else if type === 'checkbox'}
<input {type} {name} id={fieldId} checked={!!value} {...$$restProps} bind:this={input}
on:change={handleInput}>
{:else if type === 'datetime-local'}
<input {type} bind:this={input} on:input={handleInput} value={initialDatetimeLocalValue}>
<input type="hidden" {name} {value}>
{:else}
<input {type} {name} id={fieldId} {value} {...$$restProps} bind:this={input} on:input={handleInput}
tabindex={type === 'file' ? '-1' : undefined}>
{/if}
<div class="sections">
{#if type !== 'duration'}
<label for={fieldId} bind:this={label}>{@html placeholder || ''}
<slot/>
</label>
{/if}
{#if type === 'file'}
{#if fileList}
<div class="files">
{#each fileList as file}
<div class="file">
<div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name}
</div>
<div class="size" title="{file.size} bytes">
{FileSize.humanizeFileSize(file.size, true)}
</div>
</div>
{/each}
</div>
{/if}
<button type="button" on:click={chooseFile}>Browse...</button>
{/if}
</div>
</div>
{#if validation}
<div class="error">
<Icon name="alert-circle"/> {validation.message}</div>
{/if}
{#if hint}
<div class="hint">
<Icon name="info"/> {hint}</div>
{/if}
</div>
{/if}