refactor(ui): one chip primitive for all four pickers; DateField reuses lib/dates
This commit is contained in:
126
app/src/components/AttrBar.tsx
Normal file
126
app/src/components/AttrBar.tsx
Normal 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" },
|
||||
});
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { haptic } from "../lib/haptics";
|
||||
import { friendlyDate, fromIso, toIso as iso, todayIso } from "../lib/dates";
|
||||
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 WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
|
||||
@@ -13,31 +14,11 @@ const MONTHS = [
|
||||
"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 }) {
|
||||
const initial = value ? fromIso(value) : new Date();
|
||||
const [year, setYear] = useState(initial.getFullYear());
|
||||
const [month, setMonth] = useState(initial.getMonth());
|
||||
const todayIso = iso(new Date());
|
||||
const today = todayIso();
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
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 inMonth = d.getMonth() === month;
|
||||
const selected = dIso === value;
|
||||
const isToday = dIso === todayIso;
|
||||
const isToday = dIso === today;
|
||||
return (
|
||||
<Pressable
|
||||
key={dIso}
|
||||
@@ -120,7 +101,7 @@ export default function DateField({
|
||||
onChange,
|
||||
clearable,
|
||||
}: {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: string | null;
|
||||
onChange: (v: string | null) => void;
|
||||
clearable?: boolean;
|
||||
@@ -134,45 +115,34 @@ export default function DateField({
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: space.lg }}>
|
||||
<View style={s.labelRow}>
|
||||
<SectionHeader>{label}</SectionHeader>
|
||||
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>}
|
||||
</View>
|
||||
<View style={s.rowWrap}>
|
||||
<View style={label ? { marginBottom: space.lg } : undefined}>
|
||||
{!!label && (
|
||||
<View style={s.labelRow}>
|
||||
<SectionHeader>{label}</SectionHeader>
|
||||
{value != null && <Text style={s.chosen}>{friendlyDate(value)}</Text>}
|
||||
</View>
|
||||
)}
|
||||
<ChipRow>
|
||||
{quick.map((q) => (
|
||||
<Pressable
|
||||
<OptionChip
|
||||
key={q.label}
|
||||
style={[s.chip, value === q.v && s.chipActive]}
|
||||
label={q.label}
|
||||
active={value === q.v}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
onChange(value === q.v && clearable ? null : q.v);
|
||||
}}
|
||||
>
|
||||
<Text style={[s.chipText, value === q.v && s.chipTextActive]}>{q.label}</Text>
|
||||
</Pressable>
|
||||
/>
|
||||
))}
|
||||
<Pressable
|
||||
style={[s.chip, open && s.chipActive]}
|
||||
<OptionChip
|
||||
label={value && !quick.some((q) => q.v === value) ? friendlyDate(value) : "Pick a date…"}
|
||||
active={open}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<Text style={[s.chipText, open && s.chipTextActive]}>Pick a date…</Text>
|
||||
</Pressable>
|
||||
{clearable && value != null && (
|
||||
<Pressable
|
||||
style={s.chip}
|
||||
onPress={() => {
|
||||
haptic.tap();
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<Text style={s.chipText}>Clear</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
/>
|
||||
</ChipRow>
|
||||
{open && (
|
||||
<Calendar
|
||||
value={value}
|
||||
@@ -189,18 +159,6 @@ export default function DateField({
|
||||
const s = StyleSheet.create({
|
||||
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
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: {
|
||||
marginTop: space.md,
|
||||
backgroundColor: C.surface,
|
||||
|
||||
@@ -1,62 +1,41 @@
|
||||
// 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
|
||||
// invisible or lost.
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { haptic } from "../lib/haptics";
|
||||
import { formatEstimate } from "../lib/priority";
|
||||
import { C, radius, space, type } from "../lib/theme";
|
||||
import { SectionHeader } from "./ui";
|
||||
import { space } from "../lib/theme";
|
||||
import { ChipRow, OptionChip, SectionHeader } from "./ui";
|
||||
|
||||
const PRESETS = [15, 30, 45, 60, 90, 120];
|
||||
|
||||
/** Omit `label` when the surrounding panel already titles the group. */
|
||||
export default function EstimateChips({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (v: number | null) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<SectionHeader>Estimated time</SectionHeader>
|
||||
<View style={s.rowWrap}>
|
||||
{options.map((m) => {
|
||||
const active = value === m;
|
||||
return (
|
||||
<Pressable
|
||||
key={m}
|
||||
style={({ pressed }) => [
|
||||
s.chip,
|
||||
active && s.chipActive,
|
||||
pressed && !active && { backgroundColor: C.surface2 },
|
||||
]}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
onChange(active ? null : m);
|
||||
}}
|
||||
>
|
||||
<Text style={[s.chipText, active && s.chipTextActive]}>{formatEstimate(m)}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={label ? { marginBottom: space.lg } : undefined}>
|
||||
{!!label && <SectionHeader>{label}</SectionHeader>}
|
||||
<ChipRow>
|
||||
{options.map((m) => (
|
||||
<OptionChip
|
||||
key={m}
|
||||
label={formatEstimate(m) ?? ""}
|
||||
active={value === m}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
onChange(value === m ? null : m);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ChipRow>
|
||||
</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" },
|
||||
});
|
||||
|
||||
@@ -1,63 +1,43 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { haptic } from "../lib/haptics";
|
||||
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 { SectionHeader } from "./ui";
|
||||
import { ChipRow, OptionChip, SectionHeader } from "./ui";
|
||||
|
||||
/** Omit `label` when the surrounding panel already titles the group. */
|
||||
export default function PriorityChips({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: Task["priority"] | null;
|
||||
onChange: (p: Task["priority"]) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={s.wrap}>
|
||||
<SectionHeader>Priority</SectionHeader>
|
||||
<View style={s.rowWrap}>
|
||||
{PRIORITIES.map((p) => {
|
||||
const active = value === p;
|
||||
const color = PRIORITY_COLOR[p];
|
||||
return (
|
||||
<Pressable
|
||||
key={p}
|
||||
style={({ pressed }) => [
|
||||
s.chip,
|
||||
active && { borderColor: color, backgroundColor: PRIORITY_TINT[p] },
|
||||
pressed && !active && { backgroundColor: C.surface2 },
|
||||
]}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
onChange(p);
|
||||
}}
|
||||
>
|
||||
<View style={[s.flag, { backgroundColor: color }]} />
|
||||
<Text style={[s.text, active && { color, fontWeight: "600" }]}>
|
||||
{PRIORITY_LABEL[p]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={label ? { marginBottom: space.lg } : undefined}>
|
||||
{!!label && <SectionHeader>{label}</SectionHeader>}
|
||||
<ChipRow>
|
||||
{PRIORITIES.map((p) => (
|
||||
<OptionChip
|
||||
key={p}
|
||||
label={PRIORITY_LABEL[p]}
|
||||
active={value === p}
|
||||
tone={PRIORITY_COLOR[p]}
|
||||
tint={PRIORITY_TINT[p]}
|
||||
leading={<View style={[s.flag, { backgroundColor: PRIORITY_COLOR[p] }]} />}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
onChange(p);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ChipRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: { marginBottom: space.lg },
|
||||
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 },
|
||||
flag: { width: 9, height: 9, borderRadius: 999, backgroundColor: C.muted },
|
||||
});
|
||||
|
||||
@@ -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>({
|
||||
label,
|
||||
options,
|
||||
@@ -73,29 +114,25 @@ export function Chips<T extends string>({
|
||||
onChange,
|
||||
labels,
|
||||
}: {
|
||||
label: string;
|
||||
label?: string;
|
||||
options: T[];
|
||||
value: T | null;
|
||||
onChange: (v: T) => void;
|
||||
labels?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ marginBottom: space.lg }}>
|
||||
<Text style={u.label}>{label}</Text>
|
||||
<View style={u.chipRow}>
|
||||
{options.map((o) => {
|
||||
const active = value === o;
|
||||
return (
|
||||
<Pressable
|
||||
key={o}
|
||||
style={({ pressed }) => [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]}
|
||||
onPress={() => onChange(o)}
|
||||
>
|
||||
<Text style={[u.chipText, active && u.chipTextActive]}>{labels?.[o] ?? o}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={label ? { marginBottom: space.lg } : undefined}>
|
||||
{!!label && <Text style={u.label}>{label}</Text>}
|
||||
<ChipRow>
|
||||
{options.map((o) => (
|
||||
<OptionChip
|
||||
key={o}
|
||||
label={labels?.[o] ?? o}
|
||||
active={value === o}
|
||||
onPress={() => onChange(o)}
|
||||
/>
|
||||
))}
|
||||
</ChipRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -183,17 +220,18 @@ const u = StyleSheet.create({
|
||||
|
||||
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||
chip: {
|
||||
paddingVertical: space.sm,
|
||||
paddingHorizontal: space.lg - 2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 15,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: C.border,
|
||||
},
|
||||
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||
chipPressed: { backgroundColor: C.surface2 },
|
||||
chipText: { ...type.chip, color: C.textSecondary },
|
||||
chipTextActive: { color: C.accent, fontWeight: "600" },
|
||||
|
||||
button: {
|
||||
backgroundColor: C.accent,
|
||||
|
||||
Reference in New Issue
Block a user