splitwise / src /lib /components /SplitEditor.svelte
assafvayner's picture
assafvayner HF Staff
fix(ui): split editor usable without JS, untrack prop snapshots, clean orphan photo on conflict
3b74a95
Raw
History Blame Contribute Delete
4.13 kB
<script lang="ts">
import { browser } from '$app/environment';
import { formatCents, parseAmountToCents, parsePercentToBp } from '$lib/domain/money';
import { allocate } from '$lib/domain/split';
import type { PublicUser, SplitMode } from '$lib/domain/types';
import type { ExpenseFormValues } from '$lib/forms/types';
import { untrack } from 'svelte';
import FormError from './FormError.svelte';
let {
members,
currency,
values,
amount,
error
}: {
members: PublicUser[];
currency: string;
values: ExpenseFormValues;
amount: string;
error?: string;
} = $props();
const modes: { value: SplitMode; label: string }[] = [
{ value: 'equal', label: 'Equally' },
{ value: 'percent', label: 'By %' },
{ value: 'exact', label: 'Exact' }
];
// one-time snapshot of props at mount; not meant to react to later prop changes
let mode = $state<SplitMode>(untrack(() => values.mode));
let participants = $state<string[]>(untrack(() => [...values.participants]));
let pct = $state<Record<string, string>>(untrack(() => ({ ...values.pct })));
let exact = $state<Record<string, string>>(untrack(() => ({ ...values.exact })));
const amountCents = $derived(parseAmountToCents(amount) ?? 0);
const selected = $derived(members.filter((m) => participants.includes(m.id)));
const equalShares = $derived<Record<string, number>>(
amountCents > 0 && selected.length > 0
? allocate(
amountCents,
selected.map((m) => ({ id: m.id, weight: 1 }))
)
: {}
);
const pctTotalBp = $derived(
selected.reduce((acc, m) => acc + (parsePercentToBp(pct[m.id] ?? '') ?? 0), 0)
);
const exactTotal = $derived(
selected.reduce((acc, m) => acc + (parseAmountToCents(exact[m.id] ?? '') ?? 0), 0)
);
</script>
<fieldset class="space-y-3">
<legend class="text-sm font-medium text-gray-700">Split</legend>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-gray-100 p-1">
{#each modes as m (m.value)}
<label
class="cursor-pointer rounded-md py-2 text-center text-sm {mode === m.value
? 'bg-white font-semibold shadow'
: 'text-gray-600'}"
>
<input type="radio" name="mode" value={m.value} bind:group={mode} class="sr-only" />
{m.label}
</label>
{/each}
</div>
<ul class="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{#each members as m (m.id)}
{@const on = participants.includes(m.id)}
<li class="flex items-center gap-3 px-3 py-2">
<input
type="checkbox"
name="participants"
value={m.id}
bind:group={participants}
id="p_{m.id}"
class="h-5 w-5 shrink-0"
/>
<label for="p_{m.id}" class="flex-1 font-normal {on ? '' : 'text-gray-400'}"
>{m.displayName}</label
>
{#if mode === 'equal'}
<span class="text-sm text-gray-500 tabular-nums"
>{on && amountCents > 0 ? formatCents(equalShares[m.id] ?? 0, currency) : ''}</span
>
{:else if mode === 'percent'}
<div class="flex w-28 items-center gap-1">
<input
name="pct_{m.id}"
inputmode="decimal"
placeholder="0"
bind:value={pct[m.id]}
disabled={browser && !on}
class="text-right"
aria-label="Percent for {m.displayName}"
/>
<span class="text-sm text-gray-500">%</span>
</div>
{:else}
<input
name="exact_{m.id}"
inputmode="decimal"
placeholder="0.00"
bind:value={exact[m.id]}
disabled={browser && !on}
class="w-28 text-right"
aria-label="Amount for {m.displayName}"
/>
{/if}
</li>
{/each}
</ul>
{#if mode === 'percent'}
<p class="text-sm tabular-nums {pctTotalBp === 10000 ? 'text-green-700' : 'text-amber-700'}">
Total {(pctTotalBp / 100).toFixed(2)}% · {((10000 - pctTotalBp) / 100).toFixed(2)}% remaining
</p>
{:else if mode === 'exact'}
<p
class="text-sm tabular-nums {exactTotal === amountCents && amountCents > 0
? 'text-green-700'
: 'text-amber-700'}"
>
Assigned {formatCents(exactTotal, currency)} · {formatCents(
amountCents - exactTotal,
currency
)} remaining
</p>
{/if}
<FormError message={error} />
</fieldset>