refactor(ui): one chip primitive for all four pickers; DateField reuses lib/dates

This commit is contained in:
2026-09-01 22:48:35 +10:00
parent 5f610e506b
commit 5638aa9030
5 changed files with 253 additions and 172 deletions

View File

@@ -0,0 +1,126 @@
// The attribute bar: one wrapping row of chips that doubles as the task's
// summary and as the entry point to every optional field.
//
// The old form stacked seven labelled control groups down the screen, so
// capturing "call Sam" meant scrolling past a calendar, a repeat picker and an
// estimate picker to reach the save button. Here each field collapses to a
// single chip: unset chips read as a quiet placeholder, set chips show their
// value in the accent. Exactly one panel is open at a time, so the screen
// never grows past roughly a phone height.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { C, radius, space, type } from "../lib/theme";
import Icon, { type IconName } from "./Icon";
export type Attr = {
key: string;
icon: IconName;
/** Chip label while the field is unset. */
placeholder: string;
/** Chip label once set — this is what makes the bar readable as a summary. */
value?: string | null;
/** Chip accent when set. Defaults to the app accent. */
tone?: string;
/** Revealed under the bar when the chip is tapped. */
panel: React.ReactNode;
/** Offered as "Clear" in the open panel's header. Omit for required fields. */
onClear?: () => void;
};
export default function AttrBar({ attrs }: { attrs: Attr[] }) {
const [open, setOpen] = useState<string | null>(null);
const active = attrs.find((a) => a.key === open) ?? null;
return (
<View>
<View style={s.row}>
{attrs.map((a) => {
const set = !!a.value;
const tone = a.tone ?? C.accent;
const isOpen = a.key === open;
return (
<Pressable
key={a.key}
onPress={() => {
haptic.tap();
setOpen(isOpen ? null : a.key);
}}
style={({ pressed }) => [
s.chip,
set && { borderColor: tone, backgroundColor: `${tone}14` },
isOpen && { borderColor: tone, backgroundColor: `${tone}22` },
pressed && { opacity: 0.7 },
]}
>
<Icon name={a.icon} size={15} color={set || isOpen ? tone : C.muted} bg={C.bg} />
<Text style={[s.chipText, (set || isOpen) && { color: tone, fontWeight: "600" }]}>
{a.value || a.placeholder}
</Text>
</Pressable>
);
})}
</View>
{!!active && (
<View style={s.panel}>
<View style={s.panelHead}>
<Text style={s.panelTitle}>{active.placeholder}</Text>
{!!active.onClear && !!active.value && (
<Pressable
onPress={() => {
haptic.select();
active.onClear?.();
}}
hitSlop={8}
style={({ pressed }) => pressed && { opacity: 0.6 }}
>
<Text style={s.clear}>Clear</Text>
</Pressable>
)}
<Pressable onPress={() => setOpen(null)} hitSlop={10} style={({ pressed }) => pressed && { opacity: 0.6 }}>
<Icon name="close" size={17} color={C.muted} />
</Pressable>
</View>
{active.panel}
</View>
)}
</View>
);
}
const s = StyleSheet.create({
row: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: space.sm - 1,
paddingLeft: space.md - 2,
paddingRight: space.lg - 2,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
backgroundColor: C.surface,
},
chipText: { ...type.chip, color: C.textSecondary },
panel: {
marginTop: space.md,
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
paddingHorizontal: space.lg,
paddingBottom: space.md,
},
panelHead: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingTop: space.md,
paddingBottom: space.xs,
},
panelTitle: { ...type.section, color: C.textSecondary, flex: 1 },
clear: { ...type.meta, color: C.accent, fontWeight: "600" },
});

View File

@@ -3,8 +3,9 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { friendlyDate, fromIso, toIso as iso, todayIso } from "../lib/dates";
import { C, radius, space, type } from "../lib/theme"; import { C, radius, space, type } from "../lib/theme";
import { SectionHeader } from "./ui"; import { ChipRow, OptionChip, SectionHeader } from "./ui";
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"]; const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
@@ -13,31 +14,11 @@ const MONTHS = [
"July", "August", "September", "October", "November", "December", "July", "August", "September", "October", "November", "December",
]; ];
function iso(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function fromIso(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, m - 1, d);
}
function pretty(s: string): string {
const d = fromIso(s);
const today = new Date();
const diff = Math.round((d.getTime() - new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / DAY_MS);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
const wd = d.toLocaleDateString("en-AU", { weekday: "short" });
return `${wd} ${d.getDate()} ${MONTHS[d.getMonth()].slice(0, 3)}${d.getFullYear() !== today.getFullYear() ? ` ${d.getFullYear()}` : ""}`;
}
function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) => void }) { function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) => void }) {
const initial = value ? fromIso(value) : new Date(); const initial = value ? fromIso(value) : new Date();
const [year, setYear] = useState(initial.getFullYear()); const [year, setYear] = useState(initial.getFullYear());
const [month, setMonth] = useState(initial.getMonth()); const [month, setMonth] = useState(initial.getMonth());
const todayIso = iso(new Date()); const today = todayIso();
const weeks = useMemo(() => { const weeks = useMemo(() => {
const first = new Date(year, month, 1); const first = new Date(year, month, 1);
@@ -85,7 +66,7 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string)
const dIso = iso(d); const dIso = iso(d);
const inMonth = d.getMonth() === month; const inMonth = d.getMonth() === month;
const selected = dIso === value; const selected = dIso === value;
const isToday = dIso === todayIso; const isToday = dIso === today;
return ( return (
<Pressable <Pressable
key={dIso} key={dIso}
@@ -120,7 +101,7 @@ export default function DateField({
onChange, onChange,
clearable, clearable,
}: { }: {
label: string; label?: string;
value: string | null; value: string | null;
onChange: (v: string | null) => void; onChange: (v: string | null) => void;
clearable?: boolean; clearable?: boolean;
@@ -134,45 +115,34 @@ export default function DateField({
]; ];
return ( return (
<View style={{ marginBottom: space.lg }}> <View style={label ? { marginBottom: space.lg } : undefined}>
<View style={s.labelRow}> {!!label && (
<SectionHeader>{label}</SectionHeader> <View style={s.labelRow}>
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>} <SectionHeader>{label}</SectionHeader>
</View> {value != null && <Text style={s.chosen}>{friendlyDate(value)}</Text>}
<View style={s.rowWrap}> </View>
)}
<ChipRow>
{quick.map((q) => ( {quick.map((q) => (
<Pressable <OptionChip
key={q.label} key={q.label}
style={[s.chip, value === q.v && s.chipActive]} label={q.label}
active={value === q.v}
onPress={() => { onPress={() => {
haptic.select(); haptic.select();
onChange(value === q.v && clearable ? null : q.v); onChange(value === q.v && clearable ? null : q.v);
}} }}
> />
<Text style={[s.chipText, value === q.v && s.chipTextActive]}>{q.label}</Text>
</Pressable>
))} ))}
<Pressable <OptionChip
style={[s.chip, open && s.chipActive]} label={value && !quick.some((q) => q.v === value) ? friendlyDate(value) : "Pick a date…"}
active={open}
onPress={() => { onPress={() => {
haptic.select(); haptic.select();
setOpen(!open); setOpen(!open);
}} }}
> />
<Text style={[s.chipText, open && s.chipTextActive]}>Pick a date…</Text> </ChipRow>
</Pressable>
{clearable && value != null && (
<Pressable
style={s.chip}
onPress={() => {
haptic.tap();
onChange(null);
}}
>
<Text style={s.chipText}>Clear</Text>
</Pressable>
)}
</View>
{open && ( {open && (
<Calendar <Calendar
value={value} value={value}
@@ -189,18 +159,6 @@ export default function DateField({
const s = StyleSheet.create({ const s = StyleSheet.create({
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
chosen: { ...type.chip, color: C.accent, fontWeight: "600" }, chosen: { ...type.chip, color: C.accent, fontWeight: "600" },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
paddingVertical: 9,
paddingHorizontal: 15,
borderRadius: radius.pill,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
chipText: { ...type.chip, color: C.textSecondary },
chipTextActive: { color: C.accent, fontWeight: "600" },
calendar: { calendar: {
marginTop: space.md, marginTop: space.md,
backgroundColor: C.surface, backgroundColor: C.surface,

View File

@@ -1,62 +1,41 @@
// Preset estimate chips — one tap beats a numeric keyboard. A non-preset // Preset estimate chips — one tap beats a numeric keyboard. A non-preset
// value (from quick-add, e.g. ~25m) shows up as its own chip so it's never // value (from quick-add, e.g. ~25m) shows up as its own chip so it's never
// invisible or lost. // invisible or lost.
import { Pressable, StyleSheet, Text, View } from "react-native"; import { View } from "react-native";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { formatEstimate } from "../lib/priority"; import { formatEstimate } from "../lib/priority";
import { C, radius, space, type } from "../lib/theme"; import { space } from "../lib/theme";
import { SectionHeader } from "./ui"; import { ChipRow, OptionChip, SectionHeader } from "./ui";
const PRESETS = [15, 30, 45, 60, 90, 120]; const PRESETS = [15, 30, 45, 60, 90, 120];
/** Omit `label` when the surrounding panel already titles the group. */
export default function EstimateChips({ export default function EstimateChips({
value, value,
onChange, onChange,
label,
}: { }: {
value: number | null; value: number | null;
onChange: (v: number | null) => void; onChange: (v: number | null) => void;
label?: string;
}) { }) {
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS; const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
return ( return (
<View style={s.wrap}> <View style={label ? { marginBottom: space.lg } : undefined}>
<SectionHeader>Estimated time</SectionHeader> {!!label && <SectionHeader>{label}</SectionHeader>}
<View style={s.rowWrap}> <ChipRow>
{options.map((m) => { {options.map((m) => (
const active = value === m; <OptionChip
return ( key={m}
<Pressable label={formatEstimate(m) ?? ""}
key={m} active={value === m}
style={({ pressed }) => [ onPress={() => {
s.chip, haptic.select();
active && s.chipActive, onChange(value === m ? null : m);
pressed && !active && { backgroundColor: C.surface2 }, }}
]} />
onPress={() => { ))}
haptic.select(); </ChipRow>
onChange(active ? null : m);
}}
>
<Text style={[s.chipText, active && s.chipTextActive]}>{formatEstimate(m)}</Text>
</Pressable>
);
})}
</View>
</View> </View>
); );
} }
const s = StyleSheet.create({
wrap: { marginBottom: space.lg },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
paddingVertical: 9,
paddingHorizontal: 15,
borderRadius: radius.pill,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
chipText: { ...type.chip, color: C.textSecondary },
chipTextActive: { color: C.accent, fontWeight: "600" },
});

View File

@@ -1,63 +1,43 @@
import { Pressable, StyleSheet, Text, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority"; import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority";
import { C, radius, space, type } from "../lib/theme"; import { C, space } from "../lib/theme";
import type { Task } from "../lib/types"; import type { Task } from "../lib/types";
import { SectionHeader } from "./ui"; import { ChipRow, OptionChip, SectionHeader } from "./ui";
/** Omit `label` when the surrounding panel already titles the group. */
export default function PriorityChips({ export default function PriorityChips({
value, value,
onChange, onChange,
label,
}: { }: {
value: Task["priority"] | null; value: Task["priority"] | null;
onChange: (p: Task["priority"]) => void; onChange: (p: Task["priority"]) => void;
label?: string;
}) { }) {
return ( return (
<View style={s.wrap}> <View style={label ? { marginBottom: space.lg } : undefined}>
<SectionHeader>Priority</SectionHeader> {!!label && <SectionHeader>{label}</SectionHeader>}
<View style={s.rowWrap}> <ChipRow>
{PRIORITIES.map((p) => { {PRIORITIES.map((p) => (
const active = value === p; <OptionChip
const color = PRIORITY_COLOR[p]; key={p}
return ( label={PRIORITY_LABEL[p]}
<Pressable active={value === p}
key={p} tone={PRIORITY_COLOR[p]}
style={({ pressed }) => [ tint={PRIORITY_TINT[p]}
s.chip, leading={<View style={[s.flag, { backgroundColor: PRIORITY_COLOR[p] }]} />}
active && { borderColor: color, backgroundColor: PRIORITY_TINT[p] }, onPress={() => {
pressed && !active && { backgroundColor: C.surface2 }, haptic.select();
]} onChange(p);
onPress={() => { }}
haptic.select(); />
onChange(p); ))}
}} </ChipRow>
>
<View style={[s.flag, { backgroundColor: color }]} />
<Text style={[s.text, active && { color, fontWeight: "600" }]}>
{PRIORITY_LABEL[p]}
</Text>
</Pressable>
);
})}
</View>
</View> </View>
); );
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
wrap: { marginBottom: space.lg }, flag: { width: 9, height: 9, borderRadius: 999, backgroundColor: C.muted },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 7,
paddingVertical: 9,
paddingHorizontal: 14,
borderRadius: radius.pill,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
flag: { width: 9, height: 9, borderRadius: 999 },
text: { ...type.chip, color: C.textSecondary },
}); });

View File

@@ -66,6 +66,47 @@ export function Field({ label, style, ...props }: TextInputProps & { label: stri
); );
} }
/** One wrapping row of option chips. The single source of chip geometry. */
export function ChipRow({ children }: { children: React.ReactNode }) {
return <View style={u.chipRow}>{children}</View>;
}
/**
* A single selectable chip. `tone` colours the selected border/text and `tint`
* its fill — priority chips pass their flag colour, everything else falls back
* to the app accent.
*/
export function OptionChip({
label,
active,
onPress,
tone = C.accent,
tint = C.accentSoft,
leading,
}: {
label: string;
active?: boolean;
onPress: () => void;
tone?: string;
tint?: string;
leading?: React.ReactNode;
}) {
return (
<Pressable
style={({ pressed }) => [
u.chip,
active && { borderColor: tone, backgroundColor: tint },
pressed && !active && u.chipPressed,
]}
onPress={onPress}
>
{leading}
<Text style={[u.chipText, active && { color: tone, fontWeight: "600" }]}>{label}</Text>
</Pressable>
);
}
/** Labelled chip group. Omit `label` when the surrounding panel already titles it. */
export function Chips<T extends string>({ export function Chips<T extends string>({
label, label,
options, options,
@@ -73,29 +114,25 @@ export function Chips<T extends string>({
onChange, onChange,
labels, labels,
}: { }: {
label: string; label?: string;
options: T[]; options: T[];
value: T | null; value: T | null;
onChange: (v: T) => void; onChange: (v: T) => void;
labels?: Record<string, string>; labels?: Record<string, string>;
}) { }) {
return ( return (
<View style={{ marginBottom: space.lg }}> <View style={label ? { marginBottom: space.lg } : undefined}>
<Text style={u.label}>{label}</Text> {!!label && <Text style={u.label}>{label}</Text>}
<View style={u.chipRow}> <ChipRow>
{options.map((o) => { {options.map((o) => (
const active = value === o; <OptionChip
return ( key={o}
<Pressable label={labels?.[o] ?? o}
key={o} active={value === o}
style={({ pressed }) => [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]} onPress={() => onChange(o)}
onPress={() => onChange(o)} />
> ))}
<Text style={[u.chipText, active && u.chipTextActive]}>{labels?.[o] ?? o}</Text> </ChipRow>
</Pressable>
);
})}
</View>
</View> </View>
); );
} }
@@ -183,17 +220,18 @@ const u = StyleSheet.create({
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: { chip: {
paddingVertical: space.sm, flexDirection: "row",
paddingHorizontal: space.lg - 2, alignItems: "center",
gap: 7,
paddingVertical: 9,
paddingHorizontal: 15,
borderRadius: radius.pill, borderRadius: radius.pill,
backgroundColor: C.surface, backgroundColor: C.surface,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border, borderColor: C.border,
}, },
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
chipPressed: { backgroundColor: C.surface2 }, chipPressed: { backgroundColor: C.surface2 },
chipText: { ...type.chip, color: C.textSecondary }, chipText: { ...type.chip, color: C.textSecondary },
chipTextActive: { color: C.accent, fontWeight: "600" },
button: { button: {
backgroundColor: C.accent, backgroundColor: C.accent,