feat(editor,quick-entry): page editor with splits, links, attachments and one-line quick entry
This commit is contained in:
1 parent
135a2d9328
commit
7e19684cff
22 files changed
+4067
-10
No files matched your search
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import { formatNumber } from "#lib/format.js";
|
||||
import { evalAmount, isExpression } from "./parse.js";
|
||||
import { INPUT } from "./styles.js";
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
ref = $bindable(null),
|
||||
currency = "",
|
||||
decimals = 2,
|
||||
invalid = false,
|
||||
class: className = "",
|
||||
...rest
|
||||
}: Omit<HTMLInputAttributes, "value"> & {
|
||||
value?: string;
|
||||
ref?: HTMLInputElement | null;
|
||||
currency?: string;
|
||||
decimals?: number;
|
||||
invalid?: boolean;
|
||||
} = $props();
|
||||
|
||||
const result = $derived(evalAmount(value));
|
||||
const preview = $derived(
|
||||
isExpression(value) && result !== null
|
||||
? `= ${formatNumber(result, decimals)}`
|
||||
: ""
|
||||
);
|
||||
|
||||
function normalise() {
|
||||
if (result !== null && result > 0) {
|
||||
value = result.toFixed(decimals);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<input
|
||||
bind:this={ref}
|
||||
{...rest}
|
||||
aria-invalid={invalid ||
|
||||
(value.trim() !== "" && result === null) ||
|
||||
undefined}
|
||||
autocomplete="off"
|
||||
class="{INPUT} num text-right {currency ? "pr-12" : ""}"
|
||||
inputmode="decimal"
|
||||
onblur={normalise}
|
||||
spellcheck="false"
|
||||
bind:value
|
||||
>
|
||||
{#if currency}
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-[12px] text-cladd-fg-soft"
|
||||
>{currency}</span
|
||||
>
|
||||
{/if}
|
||||
{#if preview}
|
||||
<span
|
||||
class="num pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[12px] text-cladd-fg-soft"
|
||||
>{preview}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts" module>
|
||||
export interface Option {
|
||||
data?: unknown;
|
||||
hint?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
meta?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import { INPUT, LIST } from "./styles.js";
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
const STEPS: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 };
|
||||
const CTRL_STEPS: Record<string, number> = { n: 1, p: -1 };
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
selected = $bindable(null),
|
||||
ref = $bindable(null),
|
||||
search,
|
||||
onpick,
|
||||
free = false,
|
||||
async = false,
|
||||
openOnFocus = false,
|
||||
invalid = false,
|
||||
bare = false,
|
||||
class: className = "",
|
||||
inputClass = "",
|
||||
onkeydown: onkey,
|
||||
onblur: onleave,
|
||||
onfocus: onenter,
|
||||
onenter: onsubmit,
|
||||
...rest
|
||||
}: Omit<HTMLInputAttributes, "value" | "onkeydown" | "onblur" | "onfocus"> & {
|
||||
value?: string;
|
||||
selected?: Option | null;
|
||||
ref?: HTMLInputElement | null;
|
||||
search: (query: string) => Option[] | Promise<Option[]>;
|
||||
onpick?: (option: Option) => void;
|
||||
free?: boolean;
|
||||
async?: boolean;
|
||||
openOnFocus?: boolean;
|
||||
invalid?: boolean;
|
||||
bare?: boolean;
|
||||
inputClass?: string;
|
||||
onkeydown?: (event: KeyboardEvent) => void;
|
||||
onblur?: (event: FocusEvent) => void;
|
||||
onfocus?: (event: FocusEvent) => void;
|
||||
onenter?: (event: KeyboardEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
let open = $state(false);
|
||||
let items = $state<Option[]>([]);
|
||||
let active = $state(-1);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ticket = 0;
|
||||
|
||||
const query = $derived(value.trim());
|
||||
const exact = $derived(
|
||||
items.find((item) => item.label.toLowerCase() === query.toLowerCase())
|
||||
);
|
||||
const shown = $derived(
|
||||
free && query && !exact
|
||||
? [...items, { hint: "new", id: "", label: query }]
|
||||
: items
|
||||
);
|
||||
|
||||
async function load(text: string, highlight: boolean) {
|
||||
ticket += 1;
|
||||
const mine = ticket;
|
||||
const result = await search(text);
|
||||
if (mine !== ticket || document.activeElement !== ref) {
|
||||
return;
|
||||
}
|
||||
items = result;
|
||||
open = true;
|
||||
active = highlight && text.trim() ? 0 : -1;
|
||||
}
|
||||
|
||||
function refresh(highlight: boolean) {
|
||||
clearTimeout(timer);
|
||||
if (async) {
|
||||
timer = setTimeout(() => load(value, highlight), DEBOUNCE_MS);
|
||||
} else {
|
||||
load(value, highlight);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearTimeout(timer);
|
||||
ticket += 1;
|
||||
open = false;
|
||||
active = -1;
|
||||
}
|
||||
|
||||
function pick(option: Option) {
|
||||
value = option.label;
|
||||
selected = option.id ? option : null;
|
||||
close();
|
||||
onpick?.(option);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
if (!open) {
|
||||
refresh(true);
|
||||
return;
|
||||
}
|
||||
const count = shown.length;
|
||||
if (count) {
|
||||
active =
|
||||
(active + step + count + (active < 0 && step < 0 ? 1 : 0)) % count;
|
||||
}
|
||||
}
|
||||
|
||||
function stepOf(event: KeyboardEvent): number {
|
||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey;
|
||||
return STEPS[event.key] ?? (ctrl ? CTRL_STEPS[event.key] : 0) ?? 0;
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
onkey?.(event);
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
const step = stepOf(event);
|
||||
if (step) {
|
||||
event.preventDefault();
|
||||
move(step);
|
||||
return;
|
||||
}
|
||||
if (open && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const plain = !(
|
||||
event.shiftKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey
|
||||
);
|
||||
const choice = open && plain ? shown[active] : undefined;
|
||||
if (choice && (event.key === "Enter" || event.key === "Tab")) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
pick(choice);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && plain) {
|
||||
onsubmit?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput({
|
||||
currentTarget,
|
||||
}: Event & { currentTarget: HTMLInputElement }) {
|
||||
({ value } = currentTarget);
|
||||
if (selected && selected.label !== value) {
|
||||
selected = null;
|
||||
}
|
||||
refresh(true);
|
||||
}
|
||||
|
||||
function handleBlur(event: FocusEvent) {
|
||||
if (!selected && exact) {
|
||||
value = exact.label;
|
||||
selected = exact;
|
||||
onpick?.(exact);
|
||||
}
|
||||
close();
|
||||
onleave?.(event);
|
||||
}
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
if (openOnFocus) {
|
||||
refresh(false);
|
||||
}
|
||||
onenter?.(event);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && active >= 0) {
|
||||
document
|
||||
.getElementById(`${uid}-${active}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="{bare ? "" : "relative"} {className}">
|
||||
<input
|
||||
bind:this={ref}
|
||||
{...rest}
|
||||
aria-activedescendant={open && active >= 0 ? `${uid}-${active}` : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="{uid}-list"
|
||||
aria-expanded={open && shown.length > 0}
|
||||
aria-invalid={invalid || undefined}
|
||||
autocomplete="off"
|
||||
class={inputClass || (bare ? "" : INPUT)}
|
||||
onblur={handleBlur}
|
||||
onfocus={handleFocus}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKey}
|
||||
role="combobox"
|
||||
spellcheck="false"
|
||||
{value}
|
||||
>
|
||||
{#if open && shown.length > 0}
|
||||
<div class={LIST} id="{uid}-list" role="listbox">
|
||||
{#each shown as option, index (`${option.id}:${option.label}`)}
|
||||
<div
|
||||
aria-selected={index === active}
|
||||
class="flex cursor-pointer items-baseline gap-2 px-2.5 py-1.5 text-[13px] sm:py-1 {index ===
|
||||
active
|
||||
? "bg-row-cursor"
|
||||
: "hover:bg-row-hover"}"
|
||||
id="{uid}-{index}"
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
pick(option);
|
||||
}}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{#if option.hint === "new"}
|
||||
<span class="text-cladd-fg-soft">Create</span>
|
||||
"{option.label}"
|
||||
{:else}
|
||||
{option.label}
|
||||
{/if}
|
||||
</span>
|
||||
{#if option.hint && option.hint !== "new"}
|
||||
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||
>{option.hint}</span
|
||||
>
|
||||
{/if}
|
||||
{#if option.meta}
|
||||
<span class="shrink-0 text-[11px] text-cladd-fg-softer"
|
||||
>{option.meta}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import Calendar from "@lucide/svelte/icons/calendar";
|
||||
import { untrack } from "svelte";
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import { addDays, today } from "#lib/dates.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { parseDate } from "./parse.js";
|
||||
import { INPUT } from "./styles.js";
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
ref = $bindable(null),
|
||||
invalid = false,
|
||||
class: className = "",
|
||||
...rest
|
||||
}: Omit<HTMLInputAttributes, "value"> & {
|
||||
value?: string;
|
||||
ref?: HTMLInputElement | null;
|
||||
invalid?: boolean;
|
||||
} = $props();
|
||||
|
||||
let text = $state(value);
|
||||
let picker = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const parsed = $derived(parseDate(text, today()));
|
||||
const shown = $derived(parsed ?? value);
|
||||
const label = $derived(
|
||||
shown
|
||||
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
|
||||
: ""
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
untrack(() => {
|
||||
if (next && next !== parseDate(text)) {
|
||||
text = next;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
|
||||
text = event.currentTarget.value;
|
||||
const next = parseDate(text);
|
||||
if (next) {
|
||||
value = next;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (parsed) {
|
||||
text = parsed;
|
||||
value = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (
|
||||
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
||||
!(event.altKey || event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const step =
|
||||
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
|
||||
value = addDays(parsed ?? value ?? today(), step);
|
||||
text = value;
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
picker?.showPicker?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<input
|
||||
bind:this={ref}
|
||||
{...rest}
|
||||
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
|
||||
autocomplete="off"
|
||||
class="{INPUT} num pr-28"
|
||||
onblur={handleBlur}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKey}
|
||||
spellcheck="false"
|
||||
value={text}
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
|
||||
>{label}</span
|
||||
>
|
||||
<button
|
||||
aria-label="Pick a date"
|
||||
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
|
||||
onclick={openPicker}
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<Calendar class="size-3.5" />
|
||||
</button>
|
||||
<input
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
|
||||
onchange={({ currentTarget }) => {
|
||||
({ value } = currentTarget);
|
||||
text = value;
|
||||
}}
|
||||
tabindex="-1"
|
||||
type="date"
|
||||
{value}
|
||||
bind:this={picker}
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { prettyKeys } from "#lib/keys.svelte.js";
|
||||
|
||||
let {
|
||||
label,
|
||||
for: target,
|
||||
error: message = "",
|
||||
keys = "",
|
||||
note,
|
||||
children,
|
||||
class: className = "",
|
||||
}: {
|
||||
label: string;
|
||||
for?: string;
|
||||
error?: string;
|
||||
keys?: string;
|
||||
note?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 {className}">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<label class="text-[12px] text-cladd-fg-soft" for={target}>{label}</label>
|
||||
{#if keys}
|
||||
<span class="kbd hidden lg:inline-flex"
|
||||
>{prettyKeys(keys).join(" ")}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{@render children()}
|
||||
{#if message}
|
||||
<p class="text-[12px] text-expense" role="alert">{message}</p>
|
||||
{/if}
|
||||
{@render note?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import AutocompleteInput, { type Option } from "./AutocompleteInput.svelte";
|
||||
import { WELL } from "./styles.js";
|
||||
|
||||
let {
|
||||
tags = $bindable([]),
|
||||
ref = $bindable(null),
|
||||
search,
|
||||
id,
|
||||
placeholder = "",
|
||||
invalid = false,
|
||||
}: {
|
||||
tags?: string[];
|
||||
ref?: HTMLInputElement | null;
|
||||
search: (query: string, taken: string[]) => Option[];
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
invalid?: boolean;
|
||||
} = $props();
|
||||
|
||||
let text = $state("");
|
||||
|
||||
function add(raw: string) {
|
||||
const names = raw
|
||||
.split(",")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
const known = new Set(tags.map((tag) => tag.toLowerCase()));
|
||||
const fresh = names.filter((name) => !known.has(name.toLowerCase()));
|
||||
if (fresh.length) {
|
||||
tags = [...tags, ...fresh];
|
||||
}
|
||||
text = "";
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
ref?.focus();
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
const plain = !(event.ctrlKey || event.metaKey || event.altKey);
|
||||
if (event.key === "," && plain) {
|
||||
event.preventDefault();
|
||||
add(text);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace" && text === "" && tags.length && plain) {
|
||||
event.preventDefault();
|
||||
tags = tags.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (text.trim()) {
|
||||
event.preventDefault();
|
||||
add(text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{WELL} relative flex min-h-9 w-full flex-wrap items-center gap-1 px-1.5 py-1 sm:min-h-cladd-md sm:py-0.5"
|
||||
>
|
||||
{#each tags as tag, index (tag)}
|
||||
<span
|
||||
class="inline-flex h-6 items-center gap-1 rounded-[5px] bg-cladd-surface-next pr-0.5 pl-1.5 text-[12px] text-cladd-fg sm:h-5"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
aria-label="Remove tag {tag}"
|
||||
class="grid size-4 place-items-center rounded text-cladd-fg-soft hover:bg-black/10 hover:text-cladd-fg"
|
||||
onclick={() => remove(index)}
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<AutocompleteInput
|
||||
bare
|
||||
class="min-w-24 flex-1"
|
||||
free
|
||||
{id}
|
||||
inputClass="h-7 w-full min-w-0 bg-transparent px-1 text-[16px] outline-none placeholder:text-cladd-fg-softer sm:h-6 sm:text-[13px]"
|
||||
{invalid}
|
||||
onblur={() => add(text)}
|
||||
onenter={handleEnter}
|
||||
onkeydown={handleKey}
|
||||
onpick={(option) => add(option.label)}
|
||||
placeholder={tags.length ? "" : placeholder}
|
||||
search={(query) => search(query, tags)}
|
||||
bind:ref
|
||||
bind:value={text}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,162 @@
|
||||
import { addDays, iso, today } from "#lib/dates.js";
|
||||
|
||||
const SPACES = /\s+/g;
|
||||
const NUMBER = /^\d[\d.,]*/;
|
||||
const OPERATOR = /[+\-*/()]/;
|
||||
const ISO_DAY = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
|
||||
const DOTTED = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2,4}))?$/;
|
||||
const RELATIVE = /^[+-]\d{1,4}$/;
|
||||
const EXPRESSION = /\d\s*[+\-*/]\s*[\d(]/;
|
||||
|
||||
function toDecimal(raw: string): number | null {
|
||||
const comma = raw.lastIndexOf(",");
|
||||
const dot = raw.lastIndexOf(".");
|
||||
let text = raw;
|
||||
if (comma !== -1 && dot !== -1) {
|
||||
const decimal = comma > dot ? "," : ".";
|
||||
const thousands = decimal === "," ? "." : ",";
|
||||
text = raw.split(thousands).join("").replace(decimal, ".");
|
||||
} else if (comma !== -1) {
|
||||
const parts = raw.split(",");
|
||||
const fraction = parts.at(-1) ?? "";
|
||||
text =
|
||||
parts.length === 2 && fraction.length <= 2
|
||||
? parts.join(".")
|
||||
: parts.join("");
|
||||
}
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function tokenize(input: string): (number | string)[] | null {
|
||||
const tokens: (number | string)[] = [];
|
||||
let rest = input;
|
||||
while (rest) {
|
||||
const number = rest.match(NUMBER);
|
||||
if (number) {
|
||||
const value = toDecimal(number[0]);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
tokens.push(value);
|
||||
rest = rest.slice(number[0].length);
|
||||
} else if (OPERATOR.test(rest[0])) {
|
||||
tokens.push(rest[0]);
|
||||
rest = rest.slice(1);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function evaluate(tokens: (number | string)[]): number | null {
|
||||
let index = 0;
|
||||
const peek = () => tokens[index];
|
||||
const next = () => {
|
||||
index += 1;
|
||||
return tokens[index - 1];
|
||||
};
|
||||
const factor = (): number | null => {
|
||||
const token = next();
|
||||
if (token === "-") {
|
||||
const value = factor();
|
||||
return value === null ? null : -value;
|
||||
}
|
||||
if (token === "(") {
|
||||
const value = sum();
|
||||
return next() === ")" ? value : null;
|
||||
}
|
||||
return typeof token === "number" ? token : null;
|
||||
};
|
||||
const product = (): number | null => {
|
||||
let left = factor();
|
||||
while (left !== null && (peek() === "*" || peek() === "/")) {
|
||||
const op = next();
|
||||
const right = factor();
|
||||
if (right === null) {
|
||||
return null;
|
||||
}
|
||||
left = op === "*" ? left * right : left / right;
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const sum = (): number | null => {
|
||||
let left = product();
|
||||
while (left !== null && (peek() === "+" || peek() === "-")) {
|
||||
const op = next();
|
||||
const right = product();
|
||||
if (right === null) {
|
||||
return null;
|
||||
}
|
||||
left = op === "+" ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const value = sum();
|
||||
return index === tokens.length && value !== null && Number.isFinite(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export function evalAmount(input: string): number | null {
|
||||
const text = input.replace(SPACES, "");
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const tokens = tokenize(text);
|
||||
return tokens ? evaluate(tokens) : null;
|
||||
}
|
||||
|
||||
export const isExpression = (input: string) =>
|
||||
EXPRESSION.test(input) || input.includes("(");
|
||||
|
||||
export function amountString(input: string, decimals = 2): string {
|
||||
const value = evalAmount(input);
|
||||
return value === null ? "" : value.toFixed(decimals);
|
||||
}
|
||||
|
||||
function valid(year: number, month: number, day: number): string | null {
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.getMonth() === month - 1 && date.getDate() === day
|
||||
? iso(date)
|
||||
: null;
|
||||
}
|
||||
|
||||
const WORDS: Record<string, number> = {
|
||||
tod: 0,
|
||||
today: 0,
|
||||
tom: 1,
|
||||
tomorrow: 1,
|
||||
yd: -1,
|
||||
yest: -1,
|
||||
yesterday: -1,
|
||||
};
|
||||
|
||||
export function parseDate(input: string, base = today()): string | null {
|
||||
const text = input.trim().toLowerCase();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (text in WORDS) {
|
||||
return addDays(base, WORDS[text]);
|
||||
}
|
||||
if (RELATIVE.test(text)) {
|
||||
return addDays(base, Number(text));
|
||||
}
|
||||
const full = text.match(ISO_DAY);
|
||||
if (full) {
|
||||
return valid(Number(full[1]), Number(full[2]), Number(full[3]));
|
||||
}
|
||||
const dotted = text.match(DOTTED);
|
||||
if (dotted) {
|
||||
const year = Number(base.slice(0, 4));
|
||||
const raw = dotted[3] ? Number(dotted[3]) : year;
|
||||
return valid(
|
||||
raw < 100 ? 2000 + raw : raw,
|
||||
Number(dotted[2]),
|
||||
Number(dotted[1])
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const WELL =
|
||||
"cladd-cut cladd-hoverable cladd-focusable-input rounded-cladd-md text-cladd-fg";
|
||||
|
||||
export const INPUT = `${WELL} flex h-9 w-full min-w-0 px-2.5 text-[16px] placeholder:text-cladd-fg-softer disabled:cursor-not-allowed disabled:opacity-60 sm:h-cladd-md sm:text-[13px]`;
|
||||
|
||||
export const LIST =
|
||||
"cladd-surface cladd-outline absolute top-full right-0 left-0 z-50 mt-1 max-h-72 overflow-y-auto rounded-cladd-md py-1 shadow-lg";
|
||||
Reference in new issue
Block a user