Refactor HabitColorPicker to use HABIT_COLOR_CHOICES, update styles and tests
- Replaced HABIT_PALETTES with HABIT_COLOR_CHOICES in HabitColorPicker component and related tests. - Adjusted the layout and styles of the color picker for improved usability. - Updated tests to reflect changes in color selection logic. - Modified WorkspacePanel and SettingRow components to allow optional eyebrow and description props. - Enhanced accessibility tests for calendar navigation. - Updated calendar model to include a 1-month view option. - Refined styles across various components for consistency and improved responsiveness.
This commit is contained in:
@@ -110,13 +110,14 @@ export function CalendarHeatmap({
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}{" "}
|
||||
–{" "}
|
||||
{months !== 1 && <>–{" "}
|
||||
{new Date(`${days.at(-1)!.date}T12:00:00Z`).toLocaleDateString("en", {
|
||||
month: "short",
|
||||
day: months === "custom" ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</>}
|
||||
</span>
|
||||
<div
|
||||
className="ds-month-views"
|
||||
@@ -127,7 +128,7 @@ export function CalendarHeatmap({
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={`${value} months`}
|
||||
aria-label={`${value} ${value === 1 ? "month" : "months"}`}
|
||||
aria-pressed={months === value}
|
||||
onClick={() => setMonths(value)}
|
||||
>
|
||||
|
||||
@@ -63,8 +63,8 @@ export function HabitChart({
|
||||
<div className="ds-goal-body">
|
||||
<div className="ds-goal-completion">
|
||||
{trackingMethod === "count" && <div className="ds-goal-count">
|
||||
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
||||
{children}
|
||||
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
|
||||
</div>}
|
||||
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
|
||||
{trackingMethod === "tasks" && <>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
HABIT_PALETTES,
|
||||
HABIT_COLOR_CHOICES,
|
||||
isHabitColor,
|
||||
previewHabitColors,
|
||||
} from "./HabitColorPicker";
|
||||
import { HABIT_COLORS } from "./calendar-model";
|
||||
|
||||
test("eight suggested palettes contain thirty-two unique, valid habit colors", () => {
|
||||
const colors = HABIT_PALETTES.flatMap((palette) => [...palette.colors]);
|
||||
expect(HABIT_PALETTES.length).toBe(8);
|
||||
expect(HABIT_PALETTES.every((palette) => palette.colors.length === 4)).toBe(true);
|
||||
expect(new Set(colors).size).toBe(32);
|
||||
expect(colors.every(isHabitColor)).toBe(true);
|
||||
test("the continuous color selection contains distinct, valid colors", () => {
|
||||
expect(new Set(HABIT_COLOR_CHOICES).size).toBe(HABIT_COLOR_CHOICES.length);
|
||||
expect(HABIT_COLOR_CHOICES.every(isHabitColor)).toBe(true);
|
||||
});
|
||||
|
||||
test("custom colors accept six hex digits and reject incomplete or malformed colors", () => {
|
||||
|
||||
@@ -24,15 +24,11 @@ export function previewHabitColors(
|
||||
return colors;
|
||||
}
|
||||
|
||||
export const HABIT_PALETTES = [
|
||||
{ name: "Earth", colors: ["#58765b", "#977344", "#a3604d", "#76734e"] },
|
||||
{ name: "Coast", colors: ["#426582", "#427b80", "#657e9c", "#667d70"] },
|
||||
{ name: "Dusk", colors: ["#79618d", "#9c647c", "#736a9c", "#686878"] },
|
||||
{ name: "Forest", colors: ["#386641", "#52734d", "#6b705c", "#3d6b62"] },
|
||||
{ name: "Citrus", colors: ["#b45309", "#a16207", "#9a5b35", "#7a801c"] },
|
||||
{ name: "Blossom", colors: ["#b05276", "#a34e62", "#a65f5a", "#8e5572"] },
|
||||
{ name: "Jewel", colors: ["#6d28a8", "#1d678d", "#087f73", "#a12e54"] },
|
||||
{ name: "Slate", colors: ["#475569", "#52616b", "#65605b", "#404047"] },
|
||||
export const HABIT_COLOR_CHOICES = [
|
||||
"#b44f57", "#b86643", "#a7792c", "#858337",
|
||||
"#5c813f", "#3e805d", "#337e79", "#387b98",
|
||||
"#466db0", "#6263ad", "#885ba6", "#a35488",
|
||||
"#956976", "#89705a", "#667b82", "#606571",
|
||||
] as const;
|
||||
|
||||
export function isHabitColor(value: string) {
|
||||
@@ -42,65 +38,33 @@ export function isHabitColor(value: string) {
|
||||
export function HabitColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
mode?: "demo" | "create" | "edit";
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const id = useId();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const showDetails = !compact || expanded;
|
||||
const valid = isHabitColor(value);
|
||||
const showCustom = expanded || !valid;
|
||||
return (
|
||||
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
|
||||
<legend>Habit color</legend>
|
||||
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
|
||||
<p id={`${id}-hint`} className="ds-visually-hidden">
|
||||
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
|
||||
</p>
|
||||
{compact && (
|
||||
<>
|
||||
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
|
||||
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="ds-button ds-button--text" aria-expanded={expanded} aria-controls={`${id}-details`} onClick={() => setExpanded(!expanded)}>{expanded ? "Fewer colors" : "More colors"}</button>
|
||||
</>
|
||||
)}
|
||||
<div id={`${id}-details`} hidden={!showDetails}>
|
||||
<div className="ds-color-palettes">
|
||||
{HABIT_PALETTES.map((palette) => (
|
||||
<div
|
||||
key={palette.name}
|
||||
className="ds-color-palette"
|
||||
role="group"
|
||||
aria-label={`${palette.name} palette`}
|
||||
>
|
||||
<span>{palette.name}</span>
|
||||
<div className="ds-color-swatches">
|
||||
{palette.colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
aria-label={`${palette.name} ${color}`}
|
||||
aria-pressed={value.toLowerCase() === color}
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
>
|
||||
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
|
||||
{value.toLowerCase() === color ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-color-swatches" role="group" aria-label="Habit colors">
|
||||
{HABIT_COLOR_CHOICES.map((color) => (
|
||||
<button key={color} type="button" aria-label={`Color ${color}`} aria-pressed={value.toLowerCase() === color} title={color} onClick={() => onChange(color)}>
|
||||
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
|
||||
{value.toLowerCase() === color ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="ds-color-custom-toggle" aria-pressed={valid && !HABIT_COLOR_CHOICES.some(color => color === value.toLowerCase())} aria-expanded={showCustom} aria-controls={`${id}-custom`} onClick={() => setExpanded(!showCustom)}>Custom</button>
|
||||
</div>
|
||||
<div id={`${id}-custom`} hidden={!showCustom}>
|
||||
<div className="ds-custom-color">
|
||||
<Field label="Custom color" id={`${id}-native`}>
|
||||
{(inputId) => (
|
||||
@@ -137,7 +101,6 @@ export function HabitColorPicker({
|
||||
className="ds-color-live-preview"
|
||||
aria-label="Live calendar color preview"
|
||||
>
|
||||
<span className="ds-muted">Calendar preview</span>
|
||||
<div
|
||||
className="ds-color-preview-shades"
|
||||
role="img"
|
||||
@@ -152,7 +115,6 @@ export function HabitColorPicker({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="ds-muted">0 → complete</span>
|
||||
</div>
|
||||
)}
|
||||
{!valid && (
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Modal } from "./Modal";
|
||||
|
||||
/** Secondary workspace flows share one modal presentation. */
|
||||
export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel", busy }: {
|
||||
id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
||||
id: string; eyebrow?: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; busy?: boolean;
|
||||
}) {
|
||||
return <Modal id={id} eyebrow={eyebrow} title={title} description={description} onClose={onClose} closeLabel={closeLabel} busy={busy}>{children}</Modal>;
|
||||
}
|
||||
|
||||
/** Consistent label, explanation and control columns for preference forms. */
|
||||
export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) {
|
||||
export function SettingRow({ title, description, children }: { title: string; description?: ReactNode; children: ReactNode }) {
|
||||
const id = useId();
|
||||
return <section className="ds-setting-row" aria-labelledby={id}>
|
||||
<header className="ds-setting-heading"><h3 id={id}>{title}</h3><div>{description}</div></header>
|
||||
|
||||
@@ -190,13 +190,13 @@ test("invalid custom colors associate both guidance and corrective feedback", as
|
||||
|
||||
test("unified calendar retains every date and navigates across months in every range", async () => {
|
||||
await act(async () => root.render(<CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" />));
|
||||
for (const months of [3, 4, 6, 12]) {
|
||||
for (const months of [1, 3, 4, 6, 12]) {
|
||||
await act(async () => button(`${months}m`).click());
|
||||
expect(container.querySelectorAll(".ds-calendar-grid")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".ds-calendar-weekdays")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(months);
|
||||
const dates = [...container.querySelectorAll<HTMLButtonElement>(".ds-day")];
|
||||
expect(dates).toHaveLength({ 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
|
||||
expect(dates).toHaveLength({ 1: 30, 3: 92, 4: 122, 6: 183, 12: 365 }[months]!);
|
||||
expect(new Set(dates.map(day => day.getAttribute("aria-label"))).size).toBe(dates.length);
|
||||
expect(dates.filter(day => day.tabIndex === 0)).toHaveLength(1);
|
||||
const firstDate = new Date(`${dates[0]!.getAttribute("aria-label")!.slice(0, 10)}T00:00:00Z`);
|
||||
|
||||
@@ -104,11 +104,14 @@ test("month presets include exact whole months and cross year and leap-year boun
|
||||
expect(
|
||||
MONTH_VIEWS.map((months) => monthWindow("2026-09-04", months)),
|
||||
).toEqual([
|
||||
{ from: "2026-09-01", to: "2026-09-30" },
|
||||
{ from: "2026-07-01", to: "2026-09-30" },
|
||||
{ from: "2026-06-01", to: "2026-09-30" },
|
||||
{ from: "2026-04-01", to: "2026-09-30" },
|
||||
{ from: "2025-10-01", to: "2026-09-30" },
|
||||
]);
|
||||
expect(monthWindow("2024-02-29", 1)).toEqual({ from: "2024-02-01", to: "2024-02-29" });
|
||||
expect(monthWindow("2026-01-04", 1)).toEqual({ from: "2026-01-01", to: "2026-01-31" });
|
||||
expect(monthWindow("2024-02-29", 3)).toEqual({
|
||||
from: "2023-12-01",
|
||||
to: "2024-02-29",
|
||||
@@ -121,7 +124,7 @@ test("switching month views preserves dated values, complete coverage and future
|
||||
MONTH_VIEWS.map(
|
||||
(months) => visibleCalendarDays(days, months, "2026-09-04").length,
|
||||
),
|
||||
).toEqual([92, 122, 183, 365]);
|
||||
).toEqual([30, 92, 122, 183, 365]);
|
||||
for (const months of MONTH_VIEWS) {
|
||||
const visible = visibleCalendarDays(days, months, "2026-09-04");
|
||||
expect(visible.find((day) => day.date === "2026-09-04")).toEqual(
|
||||
|
||||
@@ -20,7 +20,7 @@ export type CalendarDay = {
|
||||
state: "due" | "not-due" | "future";
|
||||
};
|
||||
|
||||
export const MONTH_VIEWS = [3, 4, 6, 12] as const;
|
||||
export const MONTH_VIEWS = [1, 3, 4, 6, 12] as const;
|
||||
export type MonthView = (typeof MONTH_VIEWS)[number];
|
||||
|
||||
/** Whole calendar months, including the month containing the anchor date. */
|
||||
|
||||
@@ -117,6 +117,7 @@ export function Counter({
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<div className="ds-counter-value">
|
||||
<input
|
||||
className="ds-counter-input"
|
||||
type="text"
|
||||
@@ -133,7 +134,8 @@ export function Counter({
|
||||
if (event.key === "Escape") { event.preventDefault(); updateDraft(String(value)); }
|
||||
}}
|
||||
/>
|
||||
<span className="ds-muted"> / {target}</span>
|
||||
<span className="ds-counter-target ds-muted"> / {target}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
aria-label={`Increase ${label}`}
|
||||
|
||||
Reference in New Issue
Block a user