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;
|
||||
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">
|
||||
import '@picocss/pico/css/pico.min.css';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</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