fix: uncontrolled input — stop re-setting value prop on every keystroke, killing focus

This commit is contained in:
Falkan
2026-03-17 19:27:07 -04:00
parent d2045b7a00
commit 78e24017a7
3 changed files with 146 additions and 3 deletions

View File

@@ -0,0 +1,119 @@
---
task: Implement 7 humor-units admin improvements
slug: 20260317-000000_humor-units-7-improvements
effort: comprehensive
phase: execute
progress: 0/64
mode: interactive
started: 2026-03-17T00:00:00Z
updated: 2026-03-17T00:01:00Z
---
## Context
Implementing 7 ordered features for the humor-units SvelteKit admin page. Files: src/lib/types.ts, src/routes/admin/+page.svelte, src/routes/+page.svelte, src/components/ConversionResult.svelte, new src/routes/admin/api/order/+server.ts. Each feature must pass `npm run build` before committing and proceeding.
### Risks
- TypeScript strict mode may reject certain Svelte 5 patterns (e.g., action typing)
- Drag-and-drop with Svelte 5 runes requires careful state management
- Form dirty-check needs accurate initial-value tracking
- Auto-ID edge cases: user clears field, then types again
- The `description` field must be passed through all API layers (POST/PUT) without breaking existing validation
- CSS override of Pico hover must handle both light and dark mode correctly
### Plan
1. Feature 1: Add `description?: string` to Unit type; add textarea in admin unit form; add `use:tooltip` action in ConversionResult.svelte
2. Feature 2: Track form initial values; dirty detection; auto-save on "+ Add" when dirty; highlight invalid fields with `field-error` class
3. Feature 3: HTML5 DnD on group rows and unit rows; new PUT /admin/api/order endpoint
4. Feature 4: Add `sortOrder?` to Group type; select in group form; sort logic in orderedResults derived
5. Feature 5: Auto-ID from label (kebab) in create forms; ID field with manual-edit tracking
6. Feature 6: Add `text-decoration: underline` to `.bulk-clear:hover` and ensure color override
7. Feature 7: Add "delete" to bulkAction dropdown; confirmation UI; DELETE each checked unit
## Criteria
### Feature 1: Unit descriptions / tooltips
- [ ] ISC-1: `description?: string` optional field added to Unit interface in types.ts
- [ ] ISC-2: Unit form has textarea labeled "Description" below existing fields
- [ ] ISC-3: Description value saved via POST /admin/api/units (new unit)
- [ ] ISC-4: Description value saved via PUT /admin/api/units/:id (edit unit)
- [ ] ISC-5: Description value loaded into form when unit is selected for editing
- [ ] ISC-6: `use:tooltip` Svelte action defined in frontend converter page
- [ ] ISC-7: Tooltip action sets `title` attribute after 500ms mouseenter delay
- [ ] ISC-8: Tooltip action clears timeout and removes `title` on mouseleave
- [ ] ISC-9: Tooltip applied to unit label/symbol span in ConversionResult
- [ ] ISC-10: Tooltip NOT set when description is empty or undefined
### Feature 2: Auto-save partial forms + error blocking
- [ ] ISC-11: `isDirty()` function detects changes from initial form values
- [ ] ISC-12: Initial form snapshot captured when form is opened (select/new)
- [ ] ISC-13: Clicking "+ Add Unit" while dirty unit form open attempts auto-save
- [ ] ISC-14: Clicking "+ Add Group" while dirty group form open attempts auto-save
- [ ] ISC-15: Successful auto-save opens new form as intended
- [ ] ISC-16: Failed auto-save displays error inline; does NOT open new form
- [ ] ISC-17: `field-error` CSS class defined with invalid border-color var
- [ ] ISC-18: First invalid/empty required field highlighted with `field-error` on save failure
- [ ] ISC-19: Required unit fields validated: label, labelPlural, symbol, toBase>0, id (kebab)
- [ ] ISC-20: Required group fields validated: label, id (kebab)
- [ ] ISC-21: Form not dirty if user clicked "+ Add" but typed nothing yet
### Feature 3: Drag-to-reorder groups and units
- [ ] ISC-22: Group header rows have `draggable="true"` attribute
- [ ] ISC-23: Group drag cursor is `grab` (CSS)
- [ ] ISC-24: Drag handle `⠿` icon added to unit rows (left side, cursor grab)
- [ ] ISC-25: Groups can be reordered by drag-and-drop within the list
- [ ] ISC-26: Units can be reordered within their group by drag-and-drop
- [ ] ISC-27: Units cannot be dragged between groups via DnD
- [ ] ISC-28: After drop, PUT /admin/api/order called with new order arrays
- [ ] ISC-29: New endpoint `src/routes/admin/api/order/+server.ts` created
- [ ] ISC-30: Order endpoint is auth-gated via authRequest
- [ ] ISC-31: Order endpoint accepts `groups?: string[]` and reorders data.groups
- [ ] ISC-32: Order endpoint accepts `units?: Record<string, string[]>` and reorders units within groups
- [ ] ISC-33: Order endpoint saves data and returns `{ ok: true }`
### Feature 4: Per-group sort order setting
- [ ] ISC-34: `sortOrder?: 'defined' | 'alpha'` added to Group interface in types.ts
- [ ] ISC-35: Group edit form has select for "Display order" (Defined order / Alphabetical)
- [ ] ISC-36: sortOrder value saved via PUT /admin/api/groups/:id
- [ ] ISC-37: sortOrder value loaded into form when group is selected for editing
- [ ] ISC-38: Frontend orderedResults sorts by `label` when group.sortOrder === 'alpha'
- [ ] ISC-39: Frontend uses defined order when sortOrder is undefined or 'defined'
### Feature 5: Auto-ID generation from label
- [ ] ISC-40: `toKebab(s)` helper function defined
- [ ] ISC-41: `idWasManuallyEdited` boolean state tracked for unit create form
- [ ] ISC-42: `idWasManuallyEdited` boolean state tracked for group create form
- [ ] ISC-43: Unit create form ID field live-updates from label while not manually edited
- [ ] ISC-44: Group create form ID field live-updates from label while not manually edited
- [ ] ISC-45: Typing in ID field sets `idWasManuallyEdited = true`, stops auto-update
- [ ] ISC-46: Clearing ID field sets `idWasManuallyEdited = false`, resumes auto-generation
- [ ] ISC-47: On form blur or save attempt: ID generated from label if ID empty and label non-empty
### Feature 6: Clear button hover highlight
- [ ] ISC-48: `.bulk-clear:not(:disabled):hover` CSS has `text-decoration: underline`
- [ ] ISC-49: Hover color uses `--pico-contrast` for dark text in light/Dan mode
- [ ] ISC-50: Background stays transparent on hover
- [ ] ISC-51: Dark mode hover effect not broken (text lightens appropriately)
- [ ] ISC-52: All three Pico vars overridden: `--pico-color`, `--pico-background-color`, `--pico-border-color`
### Feature 7: Bulk delete action
- [ ] ISC-53: "Delete" option added to bulkAction select (value: `'delete'`)
- [ ] ISC-54: Group target row hidden when bulkAction is 'delete'
- [ ] ISC-55: Confirmation section shown: "Delete N units? [Confirm] [Cancel]"
- [ ] ISC-56: N in confirmation reflects current checkedUnitIds.size
- [ ] ISC-57: Confirm calls DELETE /admin/api/units/:id for each checked unit
- [ ] ISC-58: After all deletions, selection cleared and data reloaded
- [ ] ISC-59: Failed deletions shown as formError with count of failures
- [ ] ISC-60: "Cancel" resets bulkAction to '' and clears confirmation state
- [ ] ISC-61: Apply button disabled when bulkAction is 'delete' (confirm/cancel separate UI)
### Anti-criteria
- [ ] ISC-A1: data/units.json and data/config.json not directly modified by feature code
- [ ] ISC-A2: Auth system, session handling unchanged
- [ ] ISC-A3: No global CSS added outside scoped style blocks
- [ ] ISC-A4: No bare :root var overrides for Pico
- [ ] ISC-A5: npm run build passes after each feature implementation
## Decisions
## Verification

View File

@@ -1,7 +1,16 @@
<script lang="ts"> <script lang="ts">
/** /**
* UnitInput — controlled numeric input. * UnitInput — uncontrolled numeric input with one-way push to parent.
* Phase 2: number input only. NL field added in Phase 3. *
* 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 {
@@ -12,6 +21,18 @@
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);
@@ -22,9 +43,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"
/> />

View File

@@ -24,6 +24,9 @@ export interface Unit {
/** Group this unit belongs to, or null if orphaned. */ /** Group this unit belongs to, or null if orphaned. */
group: string | null; group: string | null;
/** Optional human-readable description shown as a tooltip in the converter. */
description?: string;
} }
/** A group of units sharing a base unit and a universal scale factor. */ /** A group of units sharing a base unit and a universal scale factor. */