v0.1.34
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hunter-backend"
|
name = "hunter-backend"
|
||||||
version = "0.1.31"
|
version = "0.1.34"
|
||||||
description = "Costing platform MVP backend (API for Hunter)"
|
description = "Costing platform MVP backend (API for Hunter)"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.32",
|
"version": "0.1.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.32",
|
"version": "0.1.34",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hunter-app",
|
"name": "hunter-app",
|
||||||
"version": "0.1.32",
|
"version": "0.1.34",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ export const APP_VERSION: string = packageInfo.version;
|
|||||||
|
|
||||||
export const changelog: ChangelogEntry[] = [
|
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',
|
date: '2026-06-21',
|
||||||
highlights: [
|
highlights: [
|
||||||
'App: Mix Calculator & Ingredients improvements.',
|
'App: Mix Calculator & Ingredients improvements.',
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
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.
|
// A category picker with search + explicit "create new". Typing only *searches*
|
||||||
// The value is just the category string — picking an existing option keeps
|
// — the committed value changes only when you pick an existing category or
|
||||||
// spelling consistent, while "Create" commits whatever was typed. The menu is
|
// deliberately choose "Create new category". This keeps spelling consistent and
|
||||||
// rendered fixed-position so it escapes the ingredients table's clipped
|
// makes creating a brand-new category an obvious, intentional action rather than
|
||||||
// (overflow:hidden) scroll container.
|
// 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 {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
options = [],
|
options = [],
|
||||||
@@ -23,91 +24,133 @@
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// `query` is the ephemeral search text; `value` is the committed category.
|
||||||
|
let query = $state('');
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let highlighted = $state(-1);
|
let highlighted = $state(0);
|
||||||
let root = $state<HTMLDivElement | null>(null);
|
let root = $state<HTMLDivElement | null>(null);
|
||||||
let inputEl = $state<HTMLInputElement | null>(null);
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
let menuStyle = $state('');
|
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 filtered = $derived.by(() => {
|
||||||
const q = query.toLowerCase();
|
const q = trimmed.toLowerCase();
|
||||||
if (!q) return options;
|
if (!q) return options;
|
||||||
return options.filter((option) => option.toLowerCase().includes(q));
|
return options.filter((option) => option.toLowerCase().includes(q));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Offer "Create" only when the typed text doesn't already exist (case-insensitive).
|
// Offer "create" only when the typed text isn't already a category.
|
||||||
const exactExists = $derived(options.some((option) => option.toLowerCase() === query.toLowerCase()));
|
const exactExists = $derived(options.some((option) => option.toLowerCase() === trimmed.toLowerCase()));
|
||||||
const showCreate = $derived(query.length > 0 && !exactExists);
|
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 rowCount = $derived(filtered.length + (showCreate ? 1 : 0));
|
||||||
|
const createIndex = $derived(showCreate ? filtered.length : -1);
|
||||||
|
|
||||||
function positionMenu() {
|
function positionMenu() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
const rect = inputEl.getBoundingClientRect();
|
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() {
|
async function openMenu() {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
query = value;
|
||||||
open = true;
|
open = true;
|
||||||
highlighted = -1;
|
// Highlight the create row when there's nothing to match, else the first option.
|
||||||
|
highlighted = 0;
|
||||||
await tick();
|
await tick();
|
||||||
positionMenu();
|
positionMenu();
|
||||||
|
inputEl?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
open = false;
|
||||||
|
highlighted = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function choose(option: string) {
|
function choose(option: string) {
|
||||||
value = option;
|
value = option;
|
||||||
open = false;
|
closeMenu();
|
||||||
highlighted = -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitTyped() {
|
function createNew() {
|
||||||
value = query;
|
value = trimmed;
|
||||||
open = false;
|
closeMenu();
|
||||||
highlighted = -1;
|
}
|
||||||
|
|
||||||
|
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) {
|
function onInput(event: Event) {
|
||||||
value = (event.target as HTMLInputElement).value;
|
query = (event.target as HTMLInputElement).value;
|
||||||
open = true;
|
open = true;
|
||||||
highlighted = -1;
|
highlighted = 0;
|
||||||
positionMenu();
|
positionMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
function onKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
open = true;
|
if (!open) {
|
||||||
|
openMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
highlighted = Math.min(highlighted + 1, rowCount - 1);
|
highlighted = Math.min(highlighted + 1, rowCount - 1);
|
||||||
} else if (event.key === 'ArrowUp') {
|
} else if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
highlighted = Math.max(highlighted - 1, 0);
|
highlighted = Math.max(highlighted - 1, 0);
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
if (open && highlighted >= 0) {
|
if (open && rowCount > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (highlighted < filtered.length) choose(filtered[highlighted]);
|
commitHighlighted();
|
||||||
else if (showCreate) commitTyped();
|
|
||||||
} else {
|
|
||||||
open = false;
|
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
open = false;
|
if (open) {
|
||||||
highlighted = -1;
|
event.preventDefault();
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFocusOut(event: FocusEvent) {
|
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)) {
|
if (root && event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
open = false;
|
closeMenu();
|
||||||
highlighted = -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(() => {
|
$effect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = () => positionMenu();
|
const handler = () => positionMenu();
|
||||||
@@ -134,52 +177,62 @@
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-controls={inputId ? `${inputId}-list` : undefined}
|
aria-controls={inputId ? `${inputId}-list` : undefined}
|
||||||
value={value}
|
value={display}
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={onInput}
|
oninput={onInput}
|
||||||
onfocus={openMenu}
|
onfocus={openMenu}
|
||||||
onkeydown={onKeydown}
|
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}
|
{#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}>
|
||||||
{#each filtered as option, i (option)}
|
{#if filtered.length}
|
||||||
<li
|
<li class="menu-label" aria-hidden="true">Categories</li>
|
||||||
class="row"
|
{#each filtered as option, i (option)}
|
||||||
class:highlighted={i === highlighted}
|
<li
|
||||||
class:selected={option.toLowerCase() === query.toLowerCase()}
|
class="row"
|
||||||
role="option"
|
class:highlighted={i === highlighted}
|
||||||
aria-selected={option.toLowerCase() === query.toLowerCase()}
|
class:selected={option.toLowerCase() === value.toLowerCase()}
|
||||||
onmousedown={(e) => {
|
role="option"
|
||||||
e.preventDefault();
|
aria-selected={option.toLowerCase() === value.toLowerCase()}
|
||||||
choose(option);
|
onmousedown={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
onmouseenter={() => (highlighted = i)}
|
choose(option);
|
||||||
>
|
}}
|
||||||
<span class="row-label">{option}</span>
|
onmouseenter={() => (highlighted = i)}
|
||||||
{#if option.toLowerCase() === query.toLowerCase()}
|
>
|
||||||
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
|
<span class="row-label">{option}</span>
|
||||||
{/if}
|
{#if option.toLowerCase() === value.toLowerCase()}
|
||||||
</li>
|
<span class="row-check" aria-hidden="true"><Check size={14} strokeWidth={2.6} /></span>
|
||||||
{/each}
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showCreate}
|
{#if showCreate}
|
||||||
<li
|
<li
|
||||||
class="row create"
|
class="row create"
|
||||||
class:highlighted={highlighted === filtered.length}
|
class:highlighted={highlighted === createIndex}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={false}
|
aria-selected={false}
|
||||||
onmousedown={(e) => {
|
onmousedown={(e) => {
|
||||||
e.preventDefault();
|
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="create-icon" aria-hidden="true"><Plus size={15} strokeWidth={2.6} /></span>
|
||||||
<span class="row-label">Create “{query}”</span>
|
<span class="create-text">Create new category <strong>“{trimmed}”</strong></span>
|
||||||
</li>
|
</li>
|
||||||
{:else if filtered.length === 0}
|
{: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}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -207,12 +260,13 @@
|
|||||||
.combo-input {
|
.combo-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
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: 1px solid var(--color-border);
|
||||||
border-radius: 0.42rem;
|
border-radius: 0.42rem;
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.combo-input::placeholder {
|
.combo-input::placeholder {
|
||||||
@@ -235,9 +289,40 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 300;
|
z-index: 400;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -246,7 +331,16 @@
|
|||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.55rem;
|
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 {
|
.row {
|
||||||
@@ -286,16 +380,41 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.row.create {
|
||||||
color: var(--color-brand);
|
|
||||||
font-weight: 650;
|
|
||||||
border-top: 1px solid var(--color-divider);
|
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
|
border-top: 1px solid var(--color-divider);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.create.highlighted {
|
||||||
|
background: var(--color-brand-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-icon {
|
.create-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user