feat: YOLO toggle inline layout; yoloLabel/yoloDescription configurable via config.json
This commit is contained in:
@@ -1,16 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* UnitInput — uncontrolled numeric input with one-way push to parent.
|
* UnitInput — controlled numeric input.
|
||||||
*
|
|
||||||
* The input owns its own DOM value. We only push changes upward via onchange.
|
|
||||||
* We deliberately do NOT set value={value} on the input element — that would
|
|
||||||
* cause Svelte to re-render the input on every parent state update, resetting
|
|
||||||
* the cursor position and stealing focus mid-typing.
|
|
||||||
*
|
|
||||||
* The parent can pass a new `value` prop if it needs to imperatively set the
|
|
||||||
* input (e.g. Ctrl+click adopts a converted value). We detect that via $effect
|
|
||||||
* and update the DOM node directly, only when the value actually differs from
|
|
||||||
* what's currently in the input.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,18 +11,6 @@
|
|||||||
onchange: (v: number) => void;
|
onchange: (v: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let inputEl = $state<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
// Sync parent-driven value changes into the DOM — but only when the parent
|
|
||||||
// is actually setting a new value (e.g. Ctrl+click), not on every keystroke.
|
|
||||||
$effect(() => {
|
|
||||||
if (!inputEl) return;
|
|
||||||
const current = parseFloat(inputEl.value);
|
|
||||||
if (isNaN(current) || current !== value) {
|
|
||||||
inputEl.value = String(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
const input = e.currentTarget as HTMLInputElement;
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
const parsed = parseFloat(input.value);
|
const parsed = parseFloat(input.value);
|
||||||
@@ -43,9 +21,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
|
value={value}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
aria-label="Input value"
|
aria-label="Input value"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface AppConfig {
|
|||||||
adminPath: string;
|
adminPath: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
|
/** Label shown next to the YOLO mode toggle. Defaults to "YOLO mode". */
|
||||||
|
yoloLabel?: string;
|
||||||
|
/** Description shown inline after the label. Defaults to "(cross-group conversions)". */
|
||||||
|
yoloDescription?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read units.json synchronously. Returns parsed UnitsData. */
|
/** Read units.json synchronously. Returns parsed UnitsData. */
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { loadData } from '$lib/server/data';
|
import { loadData, loadConfig } from '$lib/server/data';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = () => {
|
export const load: PageServerLoad = () => {
|
||||||
const data = loadData();
|
const data = loadData();
|
||||||
|
const config = loadConfig();
|
||||||
return {
|
return {
|
||||||
units: data.units,
|
units: data.units,
|
||||||
groups: data.groups
|
groups: data.groups,
|
||||||
|
yoloLabel: config.yoloLabel ?? 'YOLO mode',
|
||||||
|
yoloDescription: config.yoloDescription ?? '(cross-group conversions)'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
* Supports same-group and cross-group (YOLO) conversions.
|
* Supports same-group and cross-group (YOLO) conversions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let { data }: { data: { units: Unit[]; groups: Group[] } } = $props();
|
let { data }: { data: { units: Unit[]; groups: Group[]; yoloLabel: string; yoloDescription: string } } = $props();
|
||||||
|
|
||||||
let units: Unit[] = $derived(data.units);
|
let units: Unit[] = $derived(data.units);
|
||||||
let groups: Group[] = $derived(data.groups);
|
let groups: Group[] = $derived(data.groups);
|
||||||
|
let yoloLabel: string = $derived(data.yoloLabel);
|
||||||
|
let yoloDescription: string = $derived(data.yoloDescription);
|
||||||
|
|
||||||
// ── Reactive state ──────────────────────────────────────────────────────────
|
// ── Reactive state ──────────────────────────────────────────────────────────
|
||||||
let inputValue = $state<number>(1);
|
let inputValue = $state<number>(1);
|
||||||
@@ -94,6 +96,8 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── URL sync ────────────────────────────────────────────────────────────────
|
// ── URL sync ────────────────────────────────────────────────────────────────
|
||||||
|
// Use history.replaceState directly — goto() triggers a SvelteKit navigation
|
||||||
|
// which re-renders the page and steals focus from the input on every keystroke.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -104,7 +108,7 @@
|
|||||||
}
|
}
|
||||||
const next = params.toString();
|
const next = params.toString();
|
||||||
if (next !== window.location.search.slice(1)) {
|
if (next !== window.location.search.slice(1)) {
|
||||||
goto(`?${next}`, { replaceState: true, noScroll: true });
|
history.replaceState(history.state, '', `?${next}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,7 +180,10 @@
|
|||||||
<div class="yolo-toggle-row">
|
<div class="yolo-toggle-row">
|
||||||
<label class="yolo-label">
|
<label class="yolo-label">
|
||||||
<input type="checkbox" bind:checked={yoloMode} role="switch" />
|
<input type="checkbox" bind:checked={yoloMode} role="switch" />
|
||||||
YOLO mode <small>(cross-group conversions)</small>
|
<span class="yolo-label-text">{yoloLabel}</span>
|
||||||
|
{#if yoloDescription}
|
||||||
|
<small class="yolo-desc">{yoloDescription}</small>
|
||||||
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,17 +205,24 @@
|
|||||||
<style>
|
<style>
|
||||||
.yolo-toggle-row {
|
.yolo-toggle-row {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yolo-label {
|
.yolo-label {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yolo-label-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yolo-desc {
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yolo-marker {
|
.yolo-marker {
|
||||||
|
|||||||
Reference in New Issue
Block a user