From d3f6d6aea39134ec7d71b8cc73f811339d50c6f0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:22:48 +0100 Subject: [PATCH] feat: pick any calendar colour with a hex field and more shades #90 --- src/components/calendar/CalendarEditSheet.tsx | 88 ++++++++++++++++++- .../__tests__/calendar-custom-color.test.ts | 25 ++++++ src/lib/calendar-custom-color.ts | 29 ++++++ 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/calendar-custom-color.test.ts create mode 100644 src/lib/calendar-custom-color.ts diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index eb435147..4ad84188 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -27,6 +27,9 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; +import { customColorGrid, normalizeHexColor } from '../../lib/calendar-custom-color'; + +const CUSTOM_COLOR_GRID = customColorGrid(); import Button from '../Button'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetInsets } from '../../lib/use-layout'; @@ -150,6 +153,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend const [name, setName] = React.useState(''); const [color, setColor] = React.useState(CALENDAR_COLOR_PALETTE[0]); const [description, setDescription] = React.useState(''); + // The colour as typed in the hex field; a pick from the swatches fills it. + const [hexInput, setHexInput] = React.useState(''); + const [moreColors, setMoreColors] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); // The calendar's default reminders (#1209): the owner's to set, for an @@ -165,7 +171,10 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend React.useEffect(() => { if (!visible) return; setName(calendar?.name ?? ''); - setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]); + const initialColor = calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]; + setColor(initialColor); + setHexInput(initialColor); + setMoreColors(false); setDescription(calendar?.description ?? ''); setTimedReminders(initialTimed); setAllDayReminders(initialAllDay); @@ -173,9 +182,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend setSaving(false); }, [visible, calendar, initialTimed, initialAllDay]); + const pickColor = (col: string) => { + setColor(col); + setHexInput(col); + }; + const hexInvalid = normalizeHexColor(hexInput) === null; + const handleSave = async () => { const trimmed = name.trim(); - if (!trimmed || saving) return; + if (!trimmed || saving || hexInvalid) return; setSaving(true); setError(null); try { @@ -224,7 +239,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend return ( setColor(col)} + onPress={() => pickColor(col)} style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]} accessibilityRole="radio" accessibilityLabel={calendarColorName(col, t)} @@ -235,6 +250,62 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend ); })} + + + { + setHexInput(v); + const hex = normalizeHexColor(v); + if (hex) setColor(hex); + }} + placeholder="#3b82f6" + placeholderTextColor={c.textMuted} + autoCapitalize="none" + autoCorrect={false} + maxLength={7} + accessibilityLabel={t('calendar.management.custom_color', 'Custom colour (hex)')} + style={[styles.input, styles.hexInput, hexInvalid && styles.inputInvalid]} + /> + setMoreColors((v) => !v)} + style={styles.moreColors} + accessibilityRole="button" + accessibilityState={{ expanded: moreColors }} + > + + {moreColors + ? t('calendar.management.fewer_colors', 'Fewer colours') + : t('calendar.management.more_colors', 'More colours')} + + + + {hexInvalid && ( + + {t('calendar.management.color_invalid', 'Enter a colour as # and six hex digits, such as #3b82f6.')} + + )} + {moreColors && ( + + {CUSTOM_COLOR_GRID.map((row, r) => ( + + {row.map((col) => { + const active = color.toLowerCase() === col; + return ( + pickColor(col)} + style={[styles.gridSwatch, { backgroundColor: col }, active && styles.swatchActive]} + accessibilityRole="radio" + accessibilityLabel={col} + accessibilityState={{ selected: active }} + /> + ); + })} + + ))} + + )} {t('calendar.management.description', 'Description')} { void handleSave(); }} - disabled={!name.trim() || saving} + disabled={!name.trim() || saving || hexInvalid} icon={saving ? : undefined} > {calendar ? t('common.save', 'Save') : t('common.create', 'Create')} @@ -336,6 +407,15 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', }, swatchActive: { borderWidth: 2, borderColor: c.text }, + hexRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginTop: spacing.xs }, + hexPreview: { width: 28, height: 28, borderRadius: radius.full, borderWidth: 1, borderColor: c.border }, + hexInput: { flex: 1 }, + inputInvalid: { borderColor: c.error }, + moreColors: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm }, + moreColorsText: { ...typography.captionMedium, color: c.primary }, + grid: { gap: 4, marginTop: spacing.xs }, + gridRow: { flexDirection: 'row', gap: 4 }, + gridSwatch: { flex: 1, aspectRatio: 1, borderRadius: radius.sm, maxWidth: 28 }, error: { ...typography.caption, color: c.error }, footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md }, diff --git a/src/lib/__tests__/calendar-custom-color.test.ts b/src/lib/__tests__/calendar-custom-color.test.ts new file mode 100644 index 00000000..e8f534bb --- /dev/null +++ b/src/lib/__tests__/calendar-custom-color.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { customColorGrid, normalizeHexColor } from '../calendar-custom-color'; + +describe('normalizeHexColor', () => { + it('takes a typed hex colour in its usual spellings', () => { + expect(normalizeHexColor('#1A2B3C')).toBe('#1a2b3c'); + expect(normalizeHexColor('1a2b3c')).toBe('#1a2b3c'); + expect(normalizeHexColor(' #abc ')).toBe('#aabbcc'); + }); + + it('refuses what is no hex colour', () => { + for (const bad of ['', '#12345', 'red', '#ggg000', '#1234567']) expect(normalizeHexColor(bad)).toBeNull(); + }); +}); + +describe('customColorGrid', () => { + it('offers distinct hex colours in rows of twelve', () => { + const grid = customColorGrid(); + expect(grid).toHaveLength(4); + const all = grid.flat(); + expect(all.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true); + expect(new Set(all).size).toBe(all.length); + expect(grid[1][0]).toBe('#c32222'); + }); +}); diff --git a/src/lib/calendar-custom-color.ts b/src/lib/calendar-custom-color.ts new file mode 100644 index 00000000..2217b414 --- /dev/null +++ b/src/lib/calendar-custom-color.ts @@ -0,0 +1,29 @@ +// Custom calendar colours (native #90): a hex field and a grid of shades +// under the presets, where the webmail offers a colour input +// (components/settings/calendar-management-settings.tsx). + +/** `input` as "#rrggbb" (from "#abc", "abc", "#AABBCC"…), or null when it is no hex colour. */ +export function normalizeHexColor(input: string): string | null { + const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(input.trim()); + if (!m) return null; + const hex = m[1].length === 3 ? m[1].split('').map((ch) => ch + ch).join('') : m[1]; + return `#${hex.toLowerCase()}`; +} + +function hslToHex(h: number, s: number, l: number): string { + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + const v = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + return Math.round(v * 255).toString(16).padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +} + +/** Rows of shades (dark, mid, light) over twelve hues, then a grey row. */ +export function customColorGrid(): string[][] { + const hues = Array.from({ length: 12 }, (_, i) => i * 30); + const rows = [0.3, 0.45, 0.65].map((l) => hues.map((h) => hslToHex(h, 0.7, l))); + rows.push(Array.from({ length: 12 }, (_, i) => hslToHex(0, 0, 0.1 + (i * 0.8) / 11))); + return rows; +}