Phase 2: core converter, live conversion, URL state, highlight interaction
This commit is contained in:
115
src/app.css
Normal file
115
src/app.css
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/* app.css — global styles for humor-units */
|
||||||
|
|
||||||
|
/* CSS custom properties (set dynamically via inline style from ConverterCard) */
|
||||||
|
:root {
|
||||||
|
--min-col-width: 160px;
|
||||||
|
--grid-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Results grid ──────────────────────────────────────────────────────────── */
|
||||||
|
.results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--min-col-width), 1fr));
|
||||||
|
gap: var(--grid-gap);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Result tile ───────────────────────────────────────────────────────────── */
|
||||||
|
.result-tile {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--pico-muted-border-color, #ccc);
|
||||||
|
border-radius: var(--pico-border-radius, 0.25rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tile:hover {
|
||||||
|
background-color: var(--pico-secondary-background, rgba(0, 0, 0, 0.04));
|
||||||
|
border-color: var(--pico-primary, #1095c1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tile.highlighted {
|
||||||
|
border-color: var(--pico-primary, #1095c1);
|
||||||
|
background-color: var(--pico-primary-background, rgba(16, 149, 193, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pico-muted-color, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-symbol {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--pico-muted-color, #666);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Highlighted result (above grid) ──────────────────────────────────────── */
|
||||||
|
.highlighted-result {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: var(--pico-border-radius, 0.25rem);
|
||||||
|
background-color: var(--pico-primary-background, rgba(16, 149, 193, 0.08));
|
||||||
|
border: 2px solid var(--pico-primary, #1095c1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted-label {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--pico-color, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlighted-symbol {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pico-muted-color, #666);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Converter controls ────────────────────────────────────────────────────── */
|
||||||
|
.converter-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.converter-controls input[type='number'] {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.converter-controls select {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hotkey hint ───────────────────────────────────────────────────────────── */
|
||||||
|
.hotkey-hint {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--pico-muted-color, #666);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
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"
|
||||||
|
/>
|
||||||
16
src/lib/format.ts
Normal file
16
src/lib/format.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Big } from 'big.js';
|
||||||
|
import { FRACTIONAL_DIGITS, INTEGER_DIGITS } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Big value for display.
|
||||||
|
* Uses FRACTIONAL_DIGITS decimal places (fixed-point).
|
||||||
|
* If INTEGER_DIGITS is set, zero-pads the integer portion to that width.
|
||||||
|
*/
|
||||||
|
export function formatBig(v: Big): string {
|
||||||
|
const fixed = v.toFixed(FRACTIONAL_DIGITS);
|
||||||
|
if (INTEGER_DIGITS === undefined) return fixed;
|
||||||
|
|
||||||
|
const [intPart, fracPart] = fixed.split('.');
|
||||||
|
const paddedInt = intPart.padStart(INTEGER_DIGITS, '0');
|
||||||
|
return fracPart !== undefined ? `${paddedInt}.${fracPart}` : paddedInt;
|
||||||
|
}
|
||||||
@@ -32,3 +32,9 @@ export interface UnitGroup {
|
|||||||
label: string;
|
label: string;
|
||||||
units: Unit[];
|
units: Unit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One computed conversion result — unit plus its converted Big value. */
|
||||||
|
export interface ResultItem {
|
||||||
|
unit: Unit;
|
||||||
|
convertedValue: import('big.js').Big;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '@picocss/pico/css/pico.min.css';
|
import '@picocss/pico/css/pico.min.css';
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1 +1,122 @@
|
|||||||
<h1>Humor Units</h1>
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { florpUnits } from '$lib/units/definitions';
|
||||||
|
import { convertById } from '$lib/convert';
|
||||||
|
import { FRACTIONAL_DIGITS } from '$lib/config';
|
||||||
|
import { formatBig } from '$lib/format';
|
||||||
|
import type { Unit, ResultItem } from '$lib/types';
|
||||||
|
import ConverterCard from '../components/ConverterCard.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converter page — owns all reactive state.
|
||||||
|
* Phase 2: live conversion, URL state, highlight interaction.
|
||||||
|
* Phase 3 will add NL input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const units: Unit[] = florpUnits;
|
||||||
|
|
||||||
|
// ── Reactive state ──────────────────────────────────────────────────────────
|
||||||
|
let inputValue = $state<number>(1);
|
||||||
|
let fromUnitId = $state<string>(units[0].id);
|
||||||
|
let highlightedUnitId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// ── Initialize from URL params on first client-side load ───────────────────
|
||||||
|
if (browser) {
|
||||||
|
const params = page.url.searchParams;
|
||||||
|
const pValue = params.get('value');
|
||||||
|
const pFrom = params.get('from');
|
||||||
|
const pHighlight = params.get('highlight');
|
||||||
|
|
||||||
|
if (pValue !== null) {
|
||||||
|
const n = parseFloat(pValue);
|
||||||
|
if (!isNaN(n)) inputValue = n;
|
||||||
|
}
|
||||||
|
if (pFrom !== null && units.some((u) => u.id === pFrom)) {
|
||||||
|
fromUnitId = pFrom;
|
||||||
|
}
|
||||||
|
if (pHighlight !== null && units.some((u) => u.id === pHighlight)) {
|
||||||
|
highlightedUnitId = pHighlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived: results for all units except fromUnit ─────────────────────────
|
||||||
|
let results = $derived<ResultItem[]>(
|
||||||
|
units
|
||||||
|
.filter((u) => u.id !== fromUnitId)
|
||||||
|
.map((u) => ({
|
||||||
|
unit: u,
|
||||||
|
convertedValue: convertById(inputValue, fromUnitId, u.id, units)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Derived: highlighted result (unit + value) sourced from results array ──
|
||||||
|
// The highlighted unit is always a non-from unit, so it's always in results.
|
||||||
|
let highlightedResult = $derived(
|
||||||
|
highlightedUnitId !== null
|
||||||
|
? (results.find((r) => r.unit.id === highlightedUnitId) ?? null)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── URL sync: update URL only when serialized params actually change ────────
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('value', String(inputValue));
|
||||||
|
params.set('from', fromUnitId);
|
||||||
|
if (highlightedUnitId !== null) {
|
||||||
|
params.set('highlight', highlightedUnitId);
|
||||||
|
}
|
||||||
|
const next = params.toString();
|
||||||
|
if (next !== window.location.search.slice(1)) {
|
||||||
|
goto(`?${next}`, { replaceState: true, noScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Event handlers ──────────────────────────────────────────────────────────
|
||||||
|
function handleUnitChange(id: string) {
|
||||||
|
fromUnitId = id;
|
||||||
|
// Clear highlight if the selected unit becomes the new from-unit.
|
||||||
|
if (highlightedUnitId === id) {
|
||||||
|
highlightedUnitId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHighlight(id: string) {
|
||||||
|
highlightedUnitId = id === highlightedUnitId ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetInput(id: string) {
|
||||||
|
// Shift+click: promote result unit to from-unit.
|
||||||
|
// Re-express the current value in terms of the new from-unit.
|
||||||
|
const convertedValue = convertById(inputValue, fromUnitId, id, units);
|
||||||
|
inputValue = parseFloat(convertedValue.toFixed(FRACTIONAL_DIGITS));
|
||||||
|
fromUnitId = id;
|
||||||
|
highlightedUnitId = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Humor Units — Converter</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if highlightedResult !== null}
|
||||||
|
<div class="highlighted-result">
|
||||||
|
<span class="highlighted-value">{formatBig(highlightedResult.convertedValue)}</span>
|
||||||
|
<span class="highlighted-label">{highlightedResult.unit.labelPlural}</span>
|
||||||
|
<span class="highlighted-symbol">{highlightedResult.unit.symbol}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ConverterCard
|
||||||
|
{units}
|
||||||
|
{inputValue}
|
||||||
|
{fromUnitId}
|
||||||
|
{highlightedUnitId}
|
||||||
|
{results}
|
||||||
|
oninputchange={(v) => (inputValue = v)}
|
||||||
|
onunitchange={handleUnitChange}
|
||||||
|
onhighlight={handleHighlight}
|
||||||
|
onsetinput={handleSetInput}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user