Refine admin PWA dark theme controls
This commit is contained in:
@@ -10,6 +10,7 @@ import { adminPath } from '../../constants';
|
||||
|
||||
const ICON_SIZE = 24;
|
||||
export const BOTTOM_NAV_HEIGHT = 70;
|
||||
export const BOTTOM_NAV_PADDING = 8;
|
||||
|
||||
export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
|
||||
|
||||
@@ -51,7 +52,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
shadowRadius={20}
|
||||
shadowOffset={{ width: 0, height: -8 }}
|
||||
style={{
|
||||
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_PADDING}px)`,
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
}}
|
||||
|
||||
@@ -95,7 +95,7 @@ export const MobileDateTimeInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentPropsWithoutRef<'input'> & ControlProps
|
||||
>(function MobileDateTimeInput({ hasError = false, style, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const borderColor = hasError ? danger : border;
|
||||
|
||||
@@ -114,6 +114,9 @@ export const MobileDateTimeInput = React.forwardRef<
|
||||
borderColor,
|
||||
backgroundColor: surface,
|
||||
color: text,
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxShadow: `0 0 0 0 ${ringColor}`,
|
||||
@@ -135,7 +138,7 @@ export const MobileDateInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentPropsWithoutRef<'input'> & ControlProps
|
||||
>(function MobileDateInput({ hasError = false, style, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const borderColor = hasError ? danger : border;
|
||||
|
||||
@@ -154,6 +157,9 @@ export const MobileDateInput = React.forwardRef<
|
||||
borderColor,
|
||||
backgroundColor: surface,
|
||||
color: text,
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxShadow: `0 0 0 0 ${ringColor}`,
|
||||
@@ -173,7 +179,7 @@ export const MobileDateInput = React.forwardRef<
|
||||
|
||||
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
||||
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
|
||||
const borderColor = hasError ? danger : border;
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const isPassword = type === 'password';
|
||||
@@ -196,6 +202,12 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
||||
backgroundColor={surface}
|
||||
color={text}
|
||||
borderColor={borderColor}
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
...style,
|
||||
} as any}
|
||||
focusStyle={{
|
||||
borderColor: hasError ? danger : primary,
|
||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||
@@ -203,7 +215,6 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
} as any}
|
||||
style={style as any}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -213,7 +224,7 @@ export const MobileTextArea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
|
||||
>(function MobileTextArea({ hasError = false, compact = false, style, onChange, ...props }, ref) {
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
|
||||
const borderColor = hasError ? danger : border;
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
|
||||
@@ -233,6 +244,13 @@ export const MobileTextArea = React.forwardRef<
|
||||
backgroundColor={surface}
|
||||
color={text}
|
||||
borderColor={borderColor}
|
||||
style={{
|
||||
resize: 'vertical',
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
...style,
|
||||
} as any}
|
||||
focusStyle={{
|
||||
borderColor: hasError ? danger : primary,
|
||||
boxShadow: `0 0 0 3px ${ringColor}`,
|
||||
@@ -240,7 +258,6 @@ export const MobileTextArea = React.forwardRef<
|
||||
hoverStyle={{
|
||||
borderColor,
|
||||
} as any}
|
||||
style={{ resize: 'vertical', ...style } as any}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -253,7 +270,8 @@ export function MobileSelect({
|
||||
style,
|
||||
...props
|
||||
}: MobileSelectProps) {
|
||||
const { border, surface, text, primary, danger, subtle, glassSurfaceStrong, surfaceMuted, glassBorder } = useAdminTheme();
|
||||
const { border, surface, text, primary, danger, subtle, glassSurfaceStrong, surfaceMuted, glassBorder, isDark } =
|
||||
useAdminTheme();
|
||||
const borderColor = hasError ? danger : (glassBorder ?? border);
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
const triggerSurface = surfaceMuted ?? glassSurfaceStrong ?? surface;
|
||||
@@ -316,7 +334,12 @@ export function MobileSelect({
|
||||
hoverStyle={{
|
||||
borderColor: borderColor as any,
|
||||
}}
|
||||
style={style as any}
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
...style,
|
||||
} as any}
|
||||
>
|
||||
<Select.Value placeholder={props.placeholder ?? (emptyOption?.label as any) ?? ''} {...({ color: text } as any)} />
|
||||
</Select.Trigger>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, BOTTOM_NAV_HEIGHT, NavKey } from './BottomNav';
|
||||
import { BottomNav, BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING, NavKey } from './BottomNav';
|
||||
import { useMobileNav } from '../hooks/useMobileNav';
|
||||
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
|
||||
import { MobileCard, CTAButton } from './Primitives';
|
||||
@@ -336,7 +336,9 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
gap="$3"
|
||||
width="100%"
|
||||
maxWidth={800}
|
||||
style={{ paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT}px)` }}
|
||||
style={{
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING}px)`,
|
||||
}}
|
||||
>
|
||||
{!online ? (
|
||||
<XStack alignItems="center" justifyContent="center" borderRadius={12} backgroundColor={theme.warningBg} paddingVertical="$2" paddingHorizontal="$3">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Card, YStack, XStack, SizableText as Text, Tabs, Separator } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useAdminTheme } from '../theme';
|
||||
import { BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING } from './BottomNav';
|
||||
import { withAlpha } from './colors';
|
||||
|
||||
export function MobileCard({
|
||||
@@ -393,7 +394,7 @@ export function FloatingActionButton({
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 18,
|
||||
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
|
||||
bottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING + 12}px)`,
|
||||
zIndex: 60,
|
||||
transform: pressed ? 'scale(0.96)' : 'scale(1)',
|
||||
opacity: pressed ? 0.92 : 1,
|
||||
@@ -434,7 +435,10 @@ export function ContentTabs({
|
||||
tabs: { value: string; label: string; content: React.ReactNode }[];
|
||||
header?: React.ReactNode;
|
||||
}) {
|
||||
const { muted, text } = useAdminTheme();
|
||||
const { muted, text, border, glassBorder, accentSoft, primary } = useAdminTheme();
|
||||
const tabBorder = glassBorder ?? border;
|
||||
const activeBg = accentSoft ?? withAlpha(primary, 0.18);
|
||||
const activeBorder = withAlpha(primary, 0.45);
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={value}
|
||||
@@ -444,11 +448,13 @@ export function ContentTabs({
|
||||
flexDirection="column"
|
||||
borderRadius="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderColor={tabBorder}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom">
|
||||
{tabs.map((tab) => (
|
||||
{tabs.map((tab) => {
|
||||
const isActive = value === tab.value;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
@@ -457,19 +463,23 @@ export function ContentTabs({
|
||||
paddingHorizontal="$3"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderWidth={1}
|
||||
borderColor={isActive ? activeBorder : 'transparent'}
|
||||
backgroundColor={isActive ? activeBg : 'transparent'}
|
||||
hoverStyle={{ backgroundColor: '$backgroundHover' }}
|
||||
pressStyle={{ backgroundColor: '$backgroundPress' }}
|
||||
activeStyle={{ backgroundColor: '$backgroundPress' }}
|
||||
activeStyle={{ backgroundColor: activeBg, borderColor: activeBorder }}
|
||||
>
|
||||
<Text
|
||||
fontSize="$sm"
|
||||
fontWeight={value === tab.value ? '700' : '600'}
|
||||
color={value === tab.value ? text : muted}
|
||||
fontWeight={isActive ? '700' : '600'}
|
||||
color={isActive ? text : muted}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{header}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminTheme } from '../theme';
|
||||
import { BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING } from './BottomNav';
|
||||
|
||||
type SheetProps = {
|
||||
open: boolean;
|
||||
@@ -30,7 +31,7 @@ export function MobileSheet({
|
||||
contentSpacing = '$3',
|
||||
padding = '$4',
|
||||
paddingBottom = '$7',
|
||||
bottomOffsetPx = 88,
|
||||
bottomOffsetPx = BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING,
|
||||
}: SheetProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRight, CreditCard, FileText, HelpCircle, User, X } from 'lucide-react';
|
||||
import { XStack, YStack, SizableText as Text, ListItem, YGroup, Switch, Separator } from 'tamagui';
|
||||
import { ChevronRight, CreditCard, FileText, HelpCircle, Moon, Sun, User, X } from 'lucide-react';
|
||||
import { XStack, YStack, SizableText as Text, ListItem, YGroup, Separator } from 'tamagui';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_DATA_EXPORTS_PATH, ADMIN_FAQ_PATH, ADMIN_PROFILE_ACCOUNT_PATH, adminPath } from '../../constants';
|
||||
import { useAdminTheme } from '../theme';
|
||||
import { useAdminTheme, withAlpha } from '../theme';
|
||||
import { MobileSelect } from './FormControls';
|
||||
|
||||
type UserMenuSheetProps = {
|
||||
@@ -30,7 +31,9 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
setLanguage(i18n.language?.startsWith('en') ? 'en' : 'de');
|
||||
}, [i18n.language]);
|
||||
|
||||
const isDark = resolved === 'dark';
|
||||
const themeValue: 'light' | 'dark' = (appearance === 'system' ? resolved : appearance) ?? 'light';
|
||||
const activeToggleBg = theme.accentSoft ?? withAlpha(theme.primary, 0.18);
|
||||
const activeToggleBorder = withAlpha(theme.primary, 0.45);
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
onClose();
|
||||
@@ -239,14 +242,46 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
|
||||
</XStack>
|
||||
}
|
||||
iconAfter={
|
||||
<Switch
|
||||
size="$2"
|
||||
checked={isDark}
|
||||
onCheckedChange={(next: boolean) => updateAppearance(next ? 'dark' : 'light')}
|
||||
aria-label={t('mobileProfile.theme', 'Dark Mode')}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={themeValue}
|
||||
onValueChange={(next: string) => {
|
||||
if (next === 'light' || next === 'dark') {
|
||||
updateAppearance(next);
|
||||
}
|
||||
}}
|
||||
disableDeactivation
|
||||
orientation="horizontal"
|
||||
flexDirection="row"
|
||||
gap="$1.5"
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
{([
|
||||
{ key: 'light', label: t('mobileProfile.themeLight', 'Light'), icon: Sun },
|
||||
{ key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon },
|
||||
] as const).map((option) => {
|
||||
const active = option.key === themeValue;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<ToggleGroup.Item
|
||||
key={option.key}
|
||||
value={option.key}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={active ? activeToggleBorder : theme.border}
|
||||
backgroundColor={active ? activeToggleBg : 'transparent'}
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1.5"
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Icon size={14} color={active ? theme.textStrong : theme.muted} />
|
||||
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? theme.textStrong : theme.muted}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</ToggleGroup.Item>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock('tamagui', () => ({
|
||||
vi.mock('../BottomNav', () => ({
|
||||
BottomNav: () => <div data-testid="bottom-nav" />,
|
||||
BOTTOM_NAV_HEIGHT: 70,
|
||||
BOTTOM_NAV_PADDING: 8,
|
||||
NavKey: {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
vi.mock('tamagui', () => {
|
||||
const Stack = ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>;
|
||||
const Text = ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>;
|
||||
const Switch = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Switch.Thumb = () => <div />;
|
||||
|
||||
const ListItem = ({ title, iconAfter, ...props }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
|
||||
<div {...props}>
|
||||
@@ -36,11 +34,16 @@ vi.mock('tamagui', () => {
|
||||
SizableText: Text,
|
||||
ListItem,
|
||||
YGroup,
|
||||
Switch,
|
||||
Separator: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tamagui/toggle-group', () => ({
|
||||
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
|
||||
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../FormControls', () => ({
|
||||
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
|
||||
}));
|
||||
@@ -66,6 +69,7 @@ vi.mock('../../theme', () => ({
|
||||
glassShadow: 'rgba(15,23,42,0.14)',
|
||||
shadow: 'rgba(0,0,0,0.12)',
|
||||
}),
|
||||
withAlpha: (value: string) => value,
|
||||
}));
|
||||
|
||||
import { UserMenuSheet } from '../UserMenuSheet';
|
||||
|
||||
Reference in New Issue
Block a user