Phase 2: core converter, live conversion, URL state, highlight interaction
This commit is contained in:
52
src/components/ConversionResult.svelte
Normal file
52
src/components/ConversionResult.svelte
Normal 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>
|
||||
59
src/components/ConverterCard.svelte
Normal file
59
src/components/ConverterCard.svelte
Normal 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>
|
||||
29
src/components/UnitDropdown.svelte
Normal file
29
src/components/UnitDropdown.svelte
Normal 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>
|
||||
30
src/components/UnitInput.svelte
Normal file
30
src/components/UnitInput.svelte
Normal 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"
|
||||
/>
|
||||
Reference in New Issue
Block a user