This commit is contained in:
2026-06-21 12:16:41 +12:00
parent 87878e70fc
commit c9f233dc0e
5 changed files with 199 additions and 72 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hunter-backend"
version = "0.1.31"
version = "0.1.34"
description = "Costing platform MVP backend (API for Hunter)"
requires-python = ">=3.11"
dependencies = [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hunter-app",
"version": "0.1.32",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hunter-app",
"version": "0.1.32",
"version": "0.1.34",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
"version": "0.1.32",
"version": "0.1.34",
"private": true,
"type": "module",
"scripts": {
+9 -1
View File
@@ -18,7 +18,15 @@ export const APP_VERSION: string = packageInfo.version;
export const changelog: ChangelogEntry[] = [
{
version: '0.1.32',
version: '0.1.34',
date: '2026-06-21',
highlights: [
'App: Bug fixes & improvements.',
'App: General improvements.'
]
},
{
version: '0.1.33',
date: '2026-06-21',
highlights: [
'App: Mix Calculator & Ingredients improvements.',
@@ -1,12 +1,13 @@
<script lang="ts">
import { tick } from 'svelte';
import { Check, Plus, Search } from 'lucide-svelte';
import { Check, ChevronDown, Plus, Search, X } from 'lucide-svelte';
// A searchable category picker that also lets you create a new category inline.
// The value is just the category string — picking an existing option keeps
// spelling consistent, while "Create" commits whatever was typed. The menu is
// rendered fixed-position so it escapes the ingredients table's clipped
// (overflow:hidden) scroll container.
// A category picker with search + explicit "create new". Typing only *searches*
// — the committed value changes only when you pick an existing category or
// deliberately choose "Create new category". This keeps spelling consistent and
// makes creating a brand-new category an obvious, intentional action rather than
// a side effect of typing. The menu is portaled to <body> so the ingredients
// table's clipped (overflow:hidden) scroll container can never hide it.
let {
value = $bindable(''),
options = [],
@@ -23,91 +24,133 @@
ariaLabel?: string;
} = $props();
// `query` is the ephemeral search text; `value` is the committed category.
let query = $state('');
let open = $state(false);
let highlighted = $state(-1);
let highlighted = $state(0);
let root = $state<HTMLDivElement | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
let menuStyle = $state('');
const query = $derived(value.trim());
// The input shows the live search text while open, and the committed value when
// closed — so an in-progress search never looks like it changed the field.
const display = $derived(open ? query : value);
const trimmed = $derived(query.trim());
const filtered = $derived.by(() => {
const q = query.toLowerCase();
const q = trimmed.toLowerCase();
if (!q) return options;
return options.filter((option) => option.toLowerCase().includes(q));
});
// Offer "Create" only when the typed text doesn't already exist (case-insensitive).
const exactExists = $derived(options.some((option) => option.toLowerCase() === query.toLowerCase()));
const showCreate = $derived(query.length > 0 && !exactExists);
// Offer "create" only when the typed text isn't already a category.
const exactExists = $derived(options.some((option) => option.toLowerCase() === trimmed.toLowerCase()));
const showCreate = $derived(trimmed.length > 0 && !exactExists);
// Total selectable rows = filtered options, plus the optional create row last.
// Selectable rows = filtered options, then the create row (when shown).
const rowCount = $derived(filtered.length + (showCreate ? 1 : 0));
const createIndex = $derived(showCreate ? filtered.length : -1);
function positionMenu() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; width: ${Math.max(rect.width, 200)}px;`;
menuStyle = `top: ${rect.bottom + 4}px; left: ${rect.left}px; min-width: ${Math.max(rect.width, 220)}px;`;
}
async function openMenu() {
if (disabled) return;
query = value;
open = true;
highlighted = -1;
// Highlight the create row when there's nothing to match, else the first option.
highlighted = 0;
await tick();
positionMenu();
inputEl?.select();
}
function closeMenu() {
open = false;
highlighted = 0;
}
function choose(option: string) {
value = option;
open = false;
highlighted = -1;
closeMenu();
}
function commitTyped() {
value = query;
open = false;
highlighted = -1;
function createNew() {
value = trimmed;
closeMenu();
}
function clear() {
value = '';
query = '';
closeMenu();
inputEl?.focus();
}
function commitHighlighted() {
if (highlighted === createIndex) {
createNew();
} else if (highlighted >= 0 && highlighted < filtered.length) {
choose(filtered[highlighted]);
}
}
function onInput(event: Event) {
value = (event.target as HTMLInputElement).value;
query = (event.target as HTMLInputElement).value;
open = true;
highlighted = -1;
highlighted = 0;
positionMenu();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
open = true;
if (!open) {
openMenu();
return;
}
highlighted = Math.min(highlighted + 1, rowCount - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
highlighted = Math.max(highlighted - 1, 0);
} else if (event.key === 'Enter') {
if (open && highlighted >= 0) {
if (open && rowCount > 0) {
event.preventDefault();
if (highlighted < filtered.length) choose(filtered[highlighted]);
else if (showCreate) commitTyped();
} else {
open = false;
commitHighlighted();
}
} else if (event.key === 'Escape') {
open = false;
highlighted = -1;
if (open) {
event.preventDefault();
closeMenu();
}
}
}
function onFocusOut(event: FocusEvent) {
// The menu lives in <body> (portaled) and its rows use mousedown+preventDefault,
// so a click on a row never blurs the input. Any real blur closes the menu and
// discards the in-progress search (the committed value is untouched).
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
return;
}
open = false;
highlighted = -1;
closeMenu();
}
// Keep the fixed menu glued to the input while scrolling/resizing.
// Move the menu to <body> so no ancestor's overflow/transform can clip it.
function portal(node: HTMLElement) {
if (typeof document !== 'undefined') document.body.appendChild(node);
return {
destroy() {
node.parentNode?.removeChild(node);
}
};
}
// Keep the portaled menu glued to the input while scrolling/resizing.
$effect(() => {
if (!open) return;
const handler = () => positionMenu();
@@ -134,52 +177,62 @@
aria-expanded={open}
role="combobox"
aria-controls={inputId ? `${inputId}-list` : undefined}
value={value}
value={display}
{disabled}
oninput={onInput}
onfocus={openMenu}
onkeydown={onKeydown}
/>
{#if value && !disabled}
<button type="button" class="combo-clear" onmousedown={(e) => { e.preventDefault(); clear(); }} aria-label="Clear category">
<X size={14} strokeWidth={2.4} />
</button>
{:else}
<span class="combo-caret" aria-hidden="true"><ChevronDown size={15} strokeWidth={2.2} /></span>
{/if}
{#if open && !disabled}
<ul class="menu" id={inputId ? `${inputId}-list` : undefined} role="listbox" style={menuStyle}>
{#each filtered as option, i (option)}
<li
class="row"
class:highlighted={i === highlighted}
class:selected={option.toLowerCase() === query.toLowerCase()}
role="option"
aria-selected={option.toLowerCase() === query.toLowerCase()}
onmousedown={(e) => {
e.preventDefault();
choose(option);
}}
onmouseenter={() => (highlighted = i)}
>
<span class="row-label">{option}</span>
{#if option.toLowerCase() === query.toLowerCase()}
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
{/if}
</li>
{/each}
<ul class="menu" use:portal id={inputId ? `${inputId}-list` : undefined} role="listbox" style={menuStyle}>
{#if filtered.length}
<li class="menu-label" aria-hidden="true">Categories</li>
{#each filtered as option, i (option)}
<li
class="row"
class:highlighted={i === highlighted}
class:selected={option.toLowerCase() === value.toLowerCase()}
role="option"
aria-selected={option.toLowerCase() === value.toLowerCase()}
onmousedown={(e) => {
e.preventDefault();
choose(option);
}}
onmouseenter={() => (highlighted = i)}
>
<span class="row-label">{option}</span>
{#if option.toLowerCase() === value.toLowerCase()}
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
{/if}
</li>
{/each}
{/if}
{#if showCreate}
<li
class="row create"
class:highlighted={highlighted === filtered.length}
class:highlighted={highlighted === createIndex}
role="option"
aria-selected={false}
onmousedown={(e) => {
e.preventDefault();
commitTyped();
createNew();
}}
onmouseenter={() => (highlighted = filtered.length)}
onmouseenter={() => (highlighted = createIndex)}
>
<span class="create-icon" aria-hidden="true"><Plus size={14} strokeWidth={2.6} /></span>
<span class="row-label">Create “{query}</span>
<span class="create-icon" aria-hidden="true"><Plus size={15} strokeWidth={2.6} /></span>
<span class="create-text">Create new category <strong>{trimmed}</strong></span>
</li>
{:else if filtered.length === 0}
<li class="row empty">Type to add a category.</li>
<li class="row empty">Start typing to add a category.</li>
{/if}
</ul>
{/if}
@@ -207,12 +260,13 @@
.combo-input {
width: 100%;
min-height: 36px;
padding: 0.38rem 0.5rem 0.38rem 1.65rem;
padding: 0.38rem 1.7rem 0.38rem 1.65rem;
border: 1px solid var(--color-border);
border-radius: 0.42rem;
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: 0.88rem;
text-overflow: ellipsis;
}
.combo-input::placeholder {
@@ -235,9 +289,40 @@
cursor: not-allowed;
}
.combo-caret {
position: absolute;
right: 0.5rem;
display: inline-flex;
color: var(--color-text-muted);
pointer-events: none;
}
.combo-clear {
position: absolute;
right: 0.35rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.combo-clear:hover {
background: var(--color-bg-app);
color: var(--color-text-primary);
}
/* The menu is portaled to <body>, so it can't rely on inherited layout — it
positions itself fixed against the input's rect. */
.menu {
position: fixed;
z-index: 300;
z-index: 400;
margin: 0;
padding: 0.25rem;
list-style: none;
@@ -246,7 +331,16 @@
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 0.55rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
}
.menu-label {
padding: 0.3rem 0.55rem 0.2rem;
color: var(--color-text-muted);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.row {
@@ -286,16 +380,41 @@
flex-shrink: 0;
}
/* The create action is deliberately prominent: a brand-tinted row with a + icon
so "make a new category" reads as a distinct, intentional choice. */
.row.create {
color: var(--color-brand);
font-weight: 650;
border-top: 1px solid var(--color-divider);
margin-top: 0.15rem;
border-top: 1px solid var(--color-divider);
padding-top: 0.5rem;
color: var(--color-brand);
font-weight: 600;
}
.row.create.highlighted {
background: var(--color-brand-tint);
}
.create-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
background: var(--color-brand);
color: var(--color-on-brand);
}
.create-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.create-text strong {
font-weight: 700;
}
</style>