fix(settings): space the stacked bands, and give each row action a hit area
Adopts NativeSelect for the base-currency, per-post and rule-kind selects. Spacing. In Posts the table, the "Archiving hides a post…" footnote and the add-post row were three siblings with no margin between them at all: the footnote's own line-height was the only gap, so the three read as one undifferentiated block. The footnote now has room and the add row is its own band under a rule. Same for the add-currency row and "Fetch rates now", which sat 8px under the paragraph explaining it. Controls. The rule-summary trigger — the only way into a post's allocation history — measured `h=20 pad=0 rad=0`. It gets a 24px box and the row's hover wash. `up` / `down` / `archive` were three inline-flex siblings with nothing between them, so their hover backgrounds touched and the cell read as one wide slab. "Fetch rates now" was `sm` in a page where every other section-level action is default height, leaving it 4px short of the fields beside it. Also: the add-currency row labelled three of its four fields by placeholder and the fourth with a visible DECIMALS label, directly below a DECIMALS column heading that already said it — so that label read as a heading for the whole row. It becomes an aria-label. "Reset everything…" moves out of the export group it was fourth in and takes a destructive border. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f6be0c8439
commit
25ad22a4e7
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { removeRuleFrom, setRuleFrom } from "../../store/actions.ts";
|
||||
import { currentMonth } from "../../store/index.ts";
|
||||
@@ -47,7 +48,7 @@ export function RuleHistory({ post }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border p-3">
|
||||
<div className="space-y-3 rounded-md border border-budget-rule p-3">
|
||||
{post.rules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Not budgeted. Until a rule starts, this post is allocated nothing —
|
||||
@@ -99,8 +100,8 @@ export function RuleHistory({ post }: Props) {
|
||||
setFrom(next);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
<NativeSelect
|
||||
className="h-8 w-auto text-xs md:text-xs"
|
||||
aria-label="Rule kind"
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
@@ -110,7 +111,7 @@ export function RuleHistory({ post }: Props) {
|
||||
>
|
||||
<option value="percentOfIncome">% of income</option>
|
||||
<option value="fixed">fixed amount</option>
|
||||
</select>
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="font-money h-8 w-24"
|
||||
type="number"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
@@ -74,9 +75,9 @@ export function CurrencySection() {
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<select
|
||||
<NativeSelect
|
||||
id="base-currency"
|
||||
className="h-9 w-40 rounded-md border border-input bg-background px-2 text-sm"
|
||||
className="w-40"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value;
|
||||
@@ -88,7 +89,7 @@ export function CurrencySection() {
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -192,9 +193,9 @@ export function CurrencySection() {
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend disabled:opacity-40"
|
||||
className="-mr-2 text-overspend hover:text-overspend disabled:opacity-40"
|
||||
disabled={used.length > 0}
|
||||
title={
|
||||
used.length > 0
|
||||
@@ -217,7 +218,7 @@ export function CurrencySection() {
|
||||
|
||||
<AddCurrency onAdd={(def) => tryEdit(() => mutate((draft) => addCurrency(draft, def)))} />
|
||||
|
||||
<div className="mt-5 space-y-2 border-t border-budget-rule pt-5">
|
||||
<div className="mt-6 border-t border-budget-rule pt-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="fx-url"
|
||||
@@ -236,16 +237,20 @@ export function CurrencySection() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
<p className="max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
||||
{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app
|
||||
works offline with the rates you type in above, and a currency the service
|
||||
does not know can always be maintained by hand.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()}>
|
||||
<Button variant="outline" className="mt-4" onClick={() => void refresh()}>
|
||||
Fetch rates now
|
||||
</Button>
|
||||
{status && <p className="text-xs text-budget-ink-muted">{status}</p>}
|
||||
{status && (
|
||||
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -277,7 +282,7 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
|
||||
<Input
|
||||
className="font-money h-9 w-24 uppercase"
|
||||
placeholder="JPY"
|
||||
@@ -308,27 +313,23 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number;
|
||||
setSymbol(next);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="new-digits"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Decimals
|
||||
</Label>
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* No visible label. Its three siblings are labelled by placeholder, and
|
||||
the DECIMALS column heading sits directly above this field in the
|
||||
table — a fourth label here read as a heading for the whole row. */}
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
aria-label="Decimal places for the new currency"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" disabled={!canAdd} onClick={add}>
|
||||
Add currency
|
||||
</Button>
|
||||
|
||||
@@ -122,7 +122,6 @@ export function DataSection() {
|
||||
title="Your data"
|
||||
hint="Everything lives in this browser. Export regularly — it is both your backup and how you move your budget to another device."
|
||||
>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={exportJson}>
|
||||
Export JSON
|
||||
@@ -135,7 +134,7 @@ export function DataSection() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="ms-auto border-destructive/40 text-destructive hover:border-destructive hover:bg-destructive/5 hover:text-destructive"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setPending(null);
|
||||
@@ -160,7 +159,7 @@ export function DataSection() {
|
||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{confirmingReset && (
|
||||
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
|
||||
<div className="mt-4 space-y-3 rounded-md border border-destructive/60 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium">This deletes everything in this browser.</p>
|
||||
<p>
|
||||
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "}
|
||||
@@ -186,7 +185,7 @@ export function DataSection() {
|
||||
)}
|
||||
|
||||
{pending && (
|
||||
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
|
||||
<div className="mt-4 space-y-3 rounded-md border border-destructive/60 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium">This replaces everything currently stored.</p>
|
||||
<p>
|
||||
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
||||
@@ -39,7 +40,6 @@ export function PostsSection() {
|
||||
title="Posts"
|
||||
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
|
||||
>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
@@ -64,8 +64,9 @@ export function PostsSection() {
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
<NativeSelect
|
||||
className="h-8 w-auto text-xs md:text-xs"
|
||||
aria-label={`Display currency for ${post.name}`}
|
||||
value={post.currency}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as Currency;
|
||||
@@ -77,33 +78,45 @@ export function PostsSection() {
|
||||
{currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<button
|
||||
{/* Was a bare button: 20px tall, no padding, and the only
|
||||
way into a post's rule history. `size="xs"` gives it a
|
||||
24px hit box and the row's own hover wash, and the
|
||||
negative margin keeps the text aligned with the column
|
||||
heading above it. */}
|
||||
<Button
|
||||
type="button"
|
||||
className="underline decoration-dotted"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-mx-2 max-w-full font-normal underline decoration-dotted underline-offset-2"
|
||||
aria-expanded={openHistory === post.id}
|
||||
onClick={() =>
|
||||
setOpenHistory((id) => (id === post.id ? null : post.id))
|
||||
}
|
||||
>
|
||||
{ruleSummary(post)}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="ghost" disabled={index === 0}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
|
||||
up
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={index === ordered.length - 1}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
|
||||
down
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
|
||||
{post.archived ? "restore" : "archive"}
|
||||
</Button>
|
||||
<td className="py-2">
|
||||
{/* Three inline-flex siblings with nothing between them: the
|
||||
hover backgrounds touched, so the row read as one wide
|
||||
slab rather than three actions. */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button size="xs" variant="ghost" disabled={index === 0}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
|
||||
up
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" disabled={index === ordered.length - 1}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
|
||||
down
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" className="-mr-2"
|
||||
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
|
||||
{post.archived ? "restore" : "archive"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{openHistory === post.id && (
|
||||
@@ -117,12 +130,12 @@ export function PostsSection() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
||||
Archiving hides a post from new purchases while keeping its history. Posts
|
||||
are never deleted, because purchases reference them.
|
||||
</p>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="New post name"
|
||||
|
||||
Reference in New Issue
Block a user