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" },
});