/**
* ThemeSelector — light, dark, or whatever the device is set to.
*
* Three miniatures of a screen, the chosen one ringed. A row of words would
* take a third of the space and say the same thing, but "System" is the option
* people hesitate over, and a picture of a screen split down the middle
* explains it in less time than a sentence does.
*
* ```tsx
*
* ```
*
* ## It reads the theme rather than remembering it
*
* There is already one answer to "which theme is this" — Uniwind's — so the
* selector asks for it instead of keeping a copy that can disagree. Change the
* theme anywhere else in the app and the ring moves. Pass `value` to show
* something else, for a settings screen that stages a choice before applying
* it.
*
* ## Light and dark stay inside the family
*
* A reader in Moon who picks Light gets Moon's light theme, not the default
* one. System is the exception, and cannot be otherwise: the device knows only
* light and dark, so following it means following those two.
*/
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react';
import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
import { tv } from 'tailwind-variants';
import { useUniwind } from 'uniwind';
import { Text, textChildren } from '../../primitives/text';
import { useTheme, useThemeMode } from '../../theme/use-theme';
import { ThemePreview, type ThemePreviewVariant } from './theme-preview';
export type { ThemePreviewVariant } from './theme-preview';
/** What the selector chooses between. */
export type ThemeSelection = 'system' | 'light' | 'dark';
/** How big the miniatures are drawn. */
export type ThemeSelectorSize = 'sm' | 'md';
/** The three, in the order a selector draws them when it is not told. */
const OPTIONS: readonly ThemeSelection[] = ['system', 'light', 'dark'];
/** What each one is called, when the caller does not say. */
const LABELS: Record = {
system: 'System',
light: 'Light',
dark: 'Dark',
};
/** How wide the miniature is drawn, per size. */
const PREVIEW_WIDTH: Record = { sm: 72, md: 88 };
const themeSelectorVariants = tv({
slots: {
root: 'gap-3',
legend: 'font-medium',
options: 'flex-row flex-wrap items-start gap-4',
option: 'items-center gap-2',
/*
* The ring lives on a box around the artwork rather than on the artwork
* itself. React Native has no outline, so a border on the preview would
* take a strip out of the drawing every time the option was chosen — the
* miniature would change size as you picked it.
*/
frame: 'overflow-hidden rounded-xl border-2 p-1',
/*
* The artwork's own corners, rounded to sit concentrically inside the
* frame: the frame's radius less the padding between them. A square
* miniature inside a rounded ring reads as a screenshot somebody pasted in
* — the two edges have to curve together or neither should.
*/
preview: 'overflow-hidden rounded-lg',
label: 'text-center',
},
variants: {
selected: {
true: { frame: 'border-primary', label: 'text-foreground' },
false: { frame: 'border-transparent', label: 'text-muted-foreground' },
},
disabled: {
true: { root: 'opacity-40' },
},
},
defaultVariants: { selected: false },
});
interface ThemeSelectorContextValue {
value: ThemeSelection;
select: (value: ThemeSelection) => void;
variant: ThemePreviewVariant;
size: ThemeSelectorSize;
disabled: boolean;
}
const ThemeSelectorContext = createContext(null);
function useThemeSelector(part: string): ThemeSelectorContextValue {
const value = useContext(ThemeSelectorContext);
if (!value) throw new Error(`${part} must be used inside a .`);
return value;
}
/**
* Which of the three the app is on, read from the theme itself.
*
* `hasAdaptiveThemes` is the part that makes this possible: it is true exactly
* while the theme is following the device, which is the one thing the theme's
* own name cannot tell you — `system` resolves to `light` or `dark` the moment
* it is applied, and the two are indistinguishable afterwards.
*/
export function useThemeSelection(): ThemeSelection {
const { hasAdaptiveThemes } = useUniwind();
const { mode } = useThemeMode();
return hasAdaptiveThemes ? 'system' : mode;
}
export interface ThemeSelectorProps extends Omit {
className?: string;
/**
* Show this as chosen instead of whatever the app is actually on. For a
* settings screen that stages a choice before applying it; left unset, the
* selector reads the live theme.
*/
value?: ThemeSelection;
/** Fires with the option pressed, before the theme changes. */
onValueChange?: (value: ThemeSelection) => void;
/**
* Apply the choice. On by default — a theme selector that does not select a
* theme is a radio group. Turn it off to store the choice and apply it
* yourself, which is what an app that persists the preference wants.
*/
applyTheme?: boolean;
/** The heading above the row. Left out, there is none. */
label?: string;
/**
* Which miniature is drawn. `window` is an app screen with a panel on it;
* `card` is a framed card with an accent, cut on the diagonal for system.
*/
variant?: ThemePreviewVariant;
/**
* How wide the miniatures are drawn. `sm` for a settings row that has other
* things on it; `md` when choosing the theme is what the screen is for.
*/
size?: ThemeSelectorSize;
/** Stop the row being pressed, and dim it to say so. */
disabled?: boolean;
/**
* The options, in the order you want them. Left out, the selector draws
* system, light and dark in that order — which is the whole component.
*/
children?: ReactNode;
}
function ThemeSelectorRoot({
className,
value,
onValueChange,
applyTheme = true,
label,
variant = 'window',
size = 'md',
disabled = false,
children,
...props
}: ThemeSelectorProps) {
const slots = themeSelectorVariants({ disabled });
const { setTheme } = useTheme();
const { setMode } = useThemeMode();
const live = useThemeSelection();
const current = value ?? live;
const select = useCallback(
(next: ThemeSelection) => {
onValueChange?.(next);
if (!applyTheme) return;
// `setMode` rather than `setTheme`, so a reader in a named family stays
// in it. Following the device is the one choice that cannot: the device
// has no opinion beyond light and dark.
if (next === 'system') setTheme('system');
else setMode(next);
},
[applyTheme, onValueChange, setTheme, setMode]
);
const context = useMemo(
() => ({ value: current, select, variant, size, disabled }),
[current, select, variant, size, disabled]
);
return (
{label ? (
{label}
) : null}
{children
? textChildren(children)
: OPTIONS.map((option) => (
))}
);
}
export interface ThemeSelectorOptionProps
extends Omit {
className?: string;
/** Which of the three this option chooses. */
value: ThemeSelection;
/** What it is called under the miniature. Defaults to System, Light or Dark. */
label?: string;
/** Replaces the drawn miniature. */
children?: ReactNode;
}
/**
* One of the three.
*
* The whole thing is the target — the miniature and the word under it — because
* at this size the word is the easier of the two to hit and pressing it should
* not miss.
*/
function ThemeSelectorOption({
className,
value,
label,
children,
...props
}: ThemeSelectorOptionProps) {
const context = useThemeSelector('ThemeSelector.Option');
const selected = context.value === value;
const slots = themeSelectorVariants({ selected, disabled: context.disabled });
const name = label ?? LABELS[value];
return (
context.select(value)}
className={slots.option({ className })}
>
{children ?? (
)}
{name}
);
}
ThemeSelectorRoot.displayName = 'ThemeSelector';
ThemeSelectorOption.displayName = 'ThemeSelector.Option';
export const ThemeSelector = Object.assign(ThemeSelectorRoot, {
Option: ThemeSelectorOption,
});