Phase 2: core converter, live conversion, URL state, highlight interaction

This commit is contained in:
Falkan
2026-03-16 21:34:16 -04:00
parent 65f8bc1b9f
commit 8f835c35d2
9 changed files with 430 additions and 1 deletions

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { Big } from 'big.js';
import type { Unit } from '$lib/types';
import { formatBig } from '$lib/format';
/**
* ConversionResult — displays one conversion result tile.
* Emits onclick (highlight) and onshiftclick (make input unit) events.
*/
let {
value,
unit,
highlighted,
onclick,
onshiftclick
}: {
value: Big;
unit: Unit;
highlighted: boolean;
onclick: () => void;
onshiftclick: () => void;
} = $props();
// Compute once per reactive update; shared between aria-label and display span.
let formattedValue = $derived(formatBig(value));
function handleClick(e: MouseEvent) {
if (e.shiftKey) {
onshiftclick();
} else {
onclick();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="result-tile"
class:highlighted
onclick={handleClick}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') onclick(); }}
aria-label="{formattedValue} {unit.labelPlural}"
aria-pressed={highlighted}
>
<span class="result-value">{formattedValue}</span>
<span class="result-label">{unit.labelPlural}</span>
<span class="result-symbol">{unit.symbol}</span>
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import type { Unit, ResultItem } from '$lib/types';
import { MIN_COLUMN_WIDTH, GRID_GAP } from '$lib/config';
import UnitInput from './UnitInput.svelte';
import UnitDropdown from './UnitDropdown.svelte';
import ConversionResult from './ConversionResult.svelte';
/**
* ConverterCard — groups UnitInput + UnitDropdown + results grid + hotkey hint.
* Emits events upward; does NOT contain NL input (Phase 3).
*/
let {
units,
inputValue,
fromUnitId,
highlightedUnitId,
results,
oninputchange,
onunitchange,
onhighlight,
onsetinput
}: {
units: Unit[];
inputValue: number;
fromUnitId: string;
highlightedUnitId: string | null;
results: ResultItem[];
oninputchange: (v: number) => void;
onunitchange: (id: string) => void;
onhighlight: (id: string) => void;
onsetinput: (id: string) => void;
} = $props();
const gridStyle = `--min-col-width: ${MIN_COLUMN_WIDTH}; --grid-gap: ${GRID_GAP};`;
</script>
<article>
<div class="converter-controls">
<UnitInput value={inputValue} onchange={oninputchange} />
<UnitDropdown {units} value={fromUnitId} onchange={onunitchange} />
</div>
<div class="results-grid" style={gridStyle}>
{#each results as item (item.unit.id)}
<ConversionResult
value={item.convertedValue}
unit={item.unit}
highlighted={item.unit.id === highlightedUnitId}
onclick={() => onhighlight(item.unit.id)}
onshiftclick={() => onsetinput(item.unit.id)}
/>
{/each}
</div>
<p class="hotkey-hint">
<small>Click a result to highlight it · Shift+click to use it as input</small>
</p>
</article>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { Unit } from '$lib/types';
/**
* UnitDropdown — select element populated from a Unit[] prop.
* Emits onchange with the selected unit ID string.
*/
let {
units,
value,
onchange
}: {
units: Unit[];
value: string;
onchange: (id: string) => void;
} = $props();
function handleChange(e: Event) {
const select = e.currentTarget as HTMLSelectElement;
onchange(select.value);
}
</script>
<select value={value} onchange={handleChange} aria-label="From unit">
{#each units as unit (unit.id)}
<option value={unit.id}>{unit.label} ({unit.symbol})</option>
{/each}
</select>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
/**
* UnitInput — controlled numeric input.
* Phase 2: number input only. NL field added in Phase 3.
*/
let {
value,
onchange
}: {
value: number;
onchange: (v: number) => void;
} = $props();
function handleInput(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const parsed = parseFloat(input.value);
if (!isNaN(parsed)) {
onchange(parsed);
}
}
</script>
<input
type="number"
step="any"
value={value}
oninput={handleInput}
aria-label="Input value"
/>