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 { 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,
|
||||||
|
|||||||
@@ -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" },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user