fix(ui): theme date/time picker for dark mode
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. This projec
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Mobile admin shell and lists: wide tables no longer expand/clip the page. The shared ScrollArea constrains content width, tables scroll horizontally inside their card, and list Create / pagination actions stay visible in the mobile viewport.
|
- Mobile admin shell and lists: wide tables no longer expand/clip the page. The shared ScrollArea constrains content width, tables scroll horizontally inside their card, and list Create / pagination actions stay visible in the mobile viewport.
|
||||||
- Form and detail layouts wrap more cleanly on narrow screens (action bars, label/value rows).
|
- Form and detail layouts wrap more cleanly on narrow screens (action bars, label/value rows).
|
||||||
|
- Date/time picker in dark mode: replaced the native time input (invisible clock icon + always-light popup) with themed hour/minute selects and a visible Clock icon; set `color-scheme` on light/dark themes so remaining native date controls follow the UI.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Negative account disk-usage values (stale Stalwart quota counters) are shown in red with an info tooltip that explains how to recalculate usage via Tasks (Perform account maintenance operations → Recalculate storage quota usage, or store-wide Reset all user quotas).
|
- Negative account disk-usage values (stale Stalwart quota counters) are shown in red with an info tooltip that explains how to recalculate usage via Tasks (Perform account maintenance operations → Recalculate storage quota usage, or store-wide Reset all user quotas).
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
|
||||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon, Clock } from 'lucide-react';
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
@@ -839,6 +839,67 @@ interface DateTimeFieldProps {
|
|||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIME_HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
const TIME_MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
|
||||||
|
/** Themed hour/minute selects — avoids the native time picker which ignores app dark/light tokens. */
|
||||||
|
function DateTimeTimeSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [hour = '00', minute = '00'] = value.split(':');
|
||||||
|
|
||||||
|
const update = (nextHour: string, nextMinute: string) => {
|
||||||
|
onChange(`${nextHour}:${nextMinute}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center gap-1.5">
|
||||||
|
<Select
|
||||||
|
value={TIME_HOURS.includes(hour) ? hour : '00'}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(nextHour) => update(nextHour, TIME_MINUTES.includes(minute) ? minute : '00')}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.hour', 'Hour')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60" position="popper">
|
||||||
|
{TIME_HOURS.map((h) => (
|
||||||
|
<SelectItem key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground" aria-hidden="true">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={TIME_MINUTES.includes(minute) ? minute : '00'}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(nextMinute) => update(TIME_HOURS.includes(hour) ? hour : '00', nextMinute)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.minute', 'Minute')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60" position="popper">
|
||||||
|
{TIME_MINUTES.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const strValue = typeof value === 'string' ? value : '';
|
const strValue = typeof value === 'string' ? value : '';
|
||||||
@@ -883,7 +944,7 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -896,7 +957,17 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
|
|||||||
: t('field.pickDate', 'Pick a date')}
|
: t('field.pickDate', 'Pick a date')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent
|
||||||
|
className="w-auto p-0"
|
||||||
|
align="start"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-radix-select-content]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={parsed ?? undefined}
|
selected={parsed ?? undefined}
|
||||||
@@ -904,18 +975,17 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
|
|||||||
onSelect={(day) => {
|
onSelect={(day) => {
|
||||||
if (!day) return;
|
if (!day) return;
|
||||||
commit(day, timeValue || '00:00');
|
commit(day, timeValue || '00:00');
|
||||||
setOpen(false);
|
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="border-t p-3">
|
<div className="flex items-center gap-2 border-t bg-background p-3">
|
||||||
<Input
|
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
type="time"
|
<DateTimeTimeSelect
|
||||||
value={timeValue}
|
value={timeValue || '00:00'}
|
||||||
onChange={(e) => {
|
disabled={!parsed}
|
||||||
if (parsed && e.target.value) commit(parsed, e.target.value);
|
onChange={(next) => {
|
||||||
|
if (parsed) commit(parsed, next);
|
||||||
}}
|
}}
|
||||||
aria-label={t('field.time', 'Time')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -196,6 +196,8 @@
|
|||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
"pickDate": "Pick a date",
|
"pickDate": "Pick a date",
|
||||||
|
"hour": "Hour",
|
||||||
|
"minute": "Minute",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"selectEllipsis": "Select...",
|
"selectEllipsis": "Select...",
|
||||||
"selectKey": "Select key...",
|
"selectKey": "Select key...",
|
||||||
|
|||||||
+2
-4
@@ -7,6 +7,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0.017 285.823);
|
--foreground: oklch(0.145 0.017 285.823);
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0.002 285.823);
|
--foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
@@ -410,7 +412,3 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark input[type='number'] {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user