v0.1.34
This commit is contained in:
@@ -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 = [
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "hunter-app",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,22 +177,31 @@
|
||||
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}>
|
||||
<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() === query.toLowerCase()}
|
||||
class:selected={option.toLowerCase() === value.toLowerCase()}
|
||||
role="option"
|
||||
aria-selected={option.toLowerCase() === query.toLowerCase()}
|
||||
aria-selected={option.toLowerCase() === value.toLowerCase()}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
choose(option);
|
||||
@@ -157,29 +209,30 @@
|
||||
onmouseenter={() => (highlighted = i)}
|
||||
>
|
||||
<span class="row-label">{option}</span>
|
||||
{#if option.toLowerCase() === query.toLowerCase()}
|
||||
{#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>
|
||||
|
||||
Reference in New Issue
Block a user