feat: pick any calendar colour with a hex field and more shades #90

This commit is contained in:
Linus Rath
2026-10-31 04:22:48 +01:00
parent 64eb97f4c3
commit d3f6d6aea3
3 changed files with 138 additions and 4 deletions
+84 -4
View File
@@ -27,6 +27,9 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { useLocaleStore, type TranslateFn } from '../../stores/locale-store';
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; 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 Button from '../Button';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout'; import { useSheetInsets } from '../../lib/use-layout';
@@ -150,6 +153,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
const [name, setName] = React.useState(''); const [name, setName] = React.useState('');
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]); const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
const [description, setDescription] = React.useState(''); 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 [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
// The calendar's default reminders (#1209): the owner's to set, for an // 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(() => { React.useEffect(() => {
if (!visible) return; if (!visible) return;
setName(calendar?.name ?? ''); 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 ?? ''); setDescription(calendar?.description ?? '');
setTimedReminders(initialTimed); setTimedReminders(initialTimed);
setAllDayReminders(initialAllDay); setAllDayReminders(initialAllDay);
@@ -173,9 +182,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
setSaving(false); setSaving(false);
}, [visible, calendar, initialTimed, initialAllDay]); }, [visible, calendar, initialTimed, initialAllDay]);
const pickColor = (col: string) => {
setColor(col);
setHexInput(col);
};
const hexInvalid = normalizeHexColor(hexInput) === null;
const handleSave = async () => { const handleSave = async () => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed || saving) return; if (!trimmed || saving || hexInvalid) return;
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
@@ -224,7 +239,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
return ( return (
<Pressable <Pressable
key={col} key={col}
onPress={() => setColor(col)} onPress={() => pickColor(col)}
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]} style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
accessibilityRole="radio" accessibilityRole="radio"
accessibilityLabel={calendarColorName(col, t)} accessibilityLabel={calendarColorName(col, t)}
@@ -235,6 +250,62 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
); );
})} })}
</View> </View>
<View style={styles.hexRow}>
<View style={[styles.hexPreview, { backgroundColor: hexInvalid ? 'transparent' : color }]} />
<TextInput
value={hexInput}
onChangeText={(v) => {
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]}
/>
<Pressable
onPress={() => setMoreColors((v) => !v)}
style={styles.moreColors}
accessibilityRole="button"
accessibilityState={{ expanded: moreColors }}
>
<Text style={styles.moreColorsText}>
{moreColors
? t('calendar.management.fewer_colors', 'Fewer colours')
: t('calendar.management.more_colors', 'More colours')}
</Text>
</Pressable>
</View>
{hexInvalid && (
<Text style={styles.error}>
{t('calendar.management.color_invalid', 'Enter a colour as # and six hex digits, such as #3b82f6.')}
</Text>
)}
{moreColors && (
<View style={styles.grid}>
{CUSTOM_COLOR_GRID.map((row, r) => (
<View key={r} style={styles.gridRow}>
{row.map((col) => {
const active = color.toLowerCase() === col;
return (
<Pressable
key={col}
onPress={() => pickColor(col)}
style={[styles.gridSwatch, { backgroundColor: col }, active && styles.swatchActive]}
accessibilityRole="radio"
accessibilityLabel={col}
accessibilityState={{ selected: active }}
/>
);
})}
</View>
))}
</View>
)}
<Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text> <Text style={styles.label}>{t('calendar.management.description', 'Description')}</Text>
<TextInput <TextInput
value={description} value={description}
@@ -282,7 +353,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
<Button <Button
size="sm" size="sm"
onPress={() => { void handleSave(); }} onPress={() => { void handleSave(); }}
disabled={!name.trim() || saving} disabled={!name.trim() || saving || hexInvalid}
icon={saving ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined} icon={saving ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
> >
{calendar ? t('common.save', 'Save') : t('common.create', 'Create')} {calendar ? t('common.save', 'Save') : t('common.create', 'Create')}
@@ -336,6 +407,15 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center', justifyContent: 'center',
}, },
swatchActive: { borderWidth: 2, borderColor: c.text }, 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 }, error: { ...typography.caption, color: c.error },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md },
sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md }, sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md },
@@ -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');
});
});
+29
View File
@@ -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;
}