Spaces:
Running
Running
assafvayner HF Staff
fix(ui): split editor usable without JS, untrack prop snapshots, clean orphan photo on conflict
3b74a95 | <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> | |