fix: accept a calendar hex colour with or without # and show it with # after typing

This commit is contained in:
Linus Rath
2026-10-31 13:26:36 +01:00
parent 8626cd11ef
commit 826d5ad2be
5 changed files with 52 additions and 5 deletions
+1
View File
@@ -76,6 +76,7 @@
"clear_confirm": "Alle entfernen",
"clear_description": "Alle Termine in diesem Kalender werden gelöscht. Termine, die auch zu einem anderen Kalender gehören, werden nur aus diesem entfernt.",
"clear_title": "Alle Termine entfernen?",
"color_invalid": "Geben Sie eine Farbe als sechs Hex-Ziffern ein, mit oder ohne #, etwa #3b82f6.",
"delete_description": "Der Kalender und alle seine Termine werden gelöscht. Dies kann nicht rückgängig gemacht werden.",
"delete_title": "Kalender löschen?",
"description_field": "Beschreibung",
+1 -1
View File
@@ -95,7 +95,7 @@
"clear_confirm": "Remove all",
"clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.",
"clear_title": "Remove all events?",
"color_invalid": "Enter a color as # and six hex digits, such as #3b82f6.",
"color_invalid": "Enter a color as six hex digits, with or without #, such as #3b82f6.",
"custom_color": "Custom color (hex)",
"delete_description": "The calendar and all of its events will be deleted. This cannot be undone.",
"delete_title": "Delete calendar?",
@@ -27,7 +27,7 @@ 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';
import { customColorGrid, HEX_FIELD_MAX_LENGTH, hexFieldText, normalizeHexColor } from '../../lib/calendar-custom-color';
const CUSTOM_COLOR_GRID = customColorGrid();
import Button from '../Button';
@@ -260,11 +260,14 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
const hex = normalizeHexColor(v);
if (hex) setColor(hex);
}}
// "3b82f6" works as well as "#3b82f6"; leaving the field
// shows it with its "#".
onBlur={() => setHexInput((v) => hexFieldText(v))}
placeholder="#3b82f6"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={7}
maxLength={HEX_FIELD_MAX_LENGTH}
accessibilityLabel={t('calendar.management.custom_color', 'Custom color (hex)')}
style={[styles.input, styles.hexInput, hexInvalid && styles.inputInvalid]}
/>
@@ -283,7 +286,7 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
</View>
{hexInvalid && (
<Text style={styles.error}>
{t('calendar.management.color_invalid', 'Enter a color as # and six hex digits, such as #3b82f6.')}
{t('calendar.management.color_invalid', 'Enter a color as six hex digits, with or without #, such as #3b82f6.')}
</Text>
)}
{moreColors && (
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { customColorGrid, normalizeHexColor } from '../calendar-custom-color';
import { customColorGrid, HEX_FIELD_MAX_LENGTH, hexFieldText, normalizeHexColor } from '../calendar-custom-color';
describe('normalizeHexColor', () => {
it('takes a typed hex colour in its usual spellings', () => {
@@ -23,3 +23,31 @@ describe('customColorGrid', () => {
expect(grid[1][0]).toBe('#c32222');
});
});
describe('the hex field', () => {
it('shows a colour typed without "#" with it once the user leaves the field', () => {
expect(hexFieldText('3b82f6')).toBe('#3b82f6');
expect(hexFieldText('3B82F6')).toBe('#3b82f6');
expect(hexFieldText('#3b82f6')).toBe('#3b82f6');
expect(hexFieldText('3b8')).toBe('#33bb88');
// Not a colour: left as typed for the hint under the field.
expect(hexFieldText('3b82f')).toBe('3b82f');
});
it('takes a pasted colour with spaces around it', () => {
const pasted = ' #3b82f6 ';
expect(pasted.length).toBeLessThanOrEqual(HEX_FIELD_MAX_LENGTH);
expect(normalizeHexColor(pasted.slice(0, HEX_FIELD_MAX_LENGTH))).toBe('#3b82f6');
});
});
describe('the calendar sheet’s hex field', () => {
it('spells the colour out on blur and takes the longer pasted text', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const sheet = fs.readFileSync(path.resolve(__dirname, '../../components/calendar/CalendarEditSheet.tsx'), 'utf8');
const field = sheet.slice(sheet.indexOf('value={hexInput}'), sheet.indexOf('/>', sheet.indexOf('value={hexInput}')));
expect(field).toMatch(/onBlur=\{\(\) => setHexInput\(\(v\) => hexFieldText\(v\)\)\}/);
expect(field).toMatch(/maxLength=\{HEX_FIELD_MAX_LENGTH\}/);
});
});
+15
View File
@@ -10,6 +10,21 @@ export function normalizeHexColor(input: string): string | null {
return `#${hex.toLowerCase()}`;
}
/**
* What the hex field shows once the user leaves it: a colour typed without
* its "#", in capitals or as three digits, spelled out as "#rrggbb"; anything
* else stays as typed, so the hint under the field still applies to it.
*/
export function hexFieldText(input: string): string {
return normalizeHexColor(input) ?? input;
}
/**
* Longest text the hex field takes: "#rrggbb" plus a space either side, as a
* pasted value can carry. Seven cut a pasted " #3b82f6" to an invalid colour.
*/
export const HEX_FIELD_MAX_LENGTH = 9;
function hslToHex(h: number, s: number, l: number): string {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {