mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: pick any calendar colour with a hex field and more shades #90
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user