feat: Add theme switch
This commit is contained in:
38 files changed
+1724
-218
No files matched your search
@@ -173,7 +173,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
};
|
||||
|
||||
const sharedClasses = cn(
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden',
|
||||
'inline-flex items-center justify-center font-medium transition-all duration-200 select-none outline-none relative overflow-hidden cursor-pointer',
|
||||
'active:scale-[0.98]',
|
||||
sizeClasses[size],
|
||||
roundedClasses[rounded],
|
||||
@@ -181,7 +181,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
fullWidth && 'w-full',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed grayscale-[0.5] active:scale-100',
|
||||
isFocusVisible && 'ring-2 ring-[var(--rb-ring)] ring-offset-2',
|
||||
isHovered && !isDisabled && 'brightness-95 dark:brightness-105',
|
||||
isHovered && !isDisabled && 'brightness-90 dark:brightness-110',
|
||||
className
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Switch } from './Switch';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof Switch> = {
|
||||
title: 'Components/Switch',
|
||||
component: Switch,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Switch>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Label',
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
children: 'Selected State',
|
||||
isSelected: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: 'Disabled State',
|
||||
isDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: function ControlledStory() {
|
||||
const [isSelected, setSelected] = useState(false);
|
||||
return (
|
||||
<Switch isSelected={isSelected} onChange={setSelected}>
|
||||
{isSelected ? 'On' : 'Off'}
|
||||
</Switch>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { Switch } from './Switch';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('components/Switch', () => {
|
||||
it('renders correctly with label', () => {
|
||||
render(<Switch>Toggle Label</Switch>);
|
||||
expect(screen.getByLabelText('Toggle Label')).toBeInTheDocument();
|
||||
expect(screen.getByText('Toggle Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles change events', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
render(<Switch onChange={handleChange}>Switch</Switch>);
|
||||
|
||||
const checkbox = screen.getByRole('switch');
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('respects isSelected prop', () => {
|
||||
render(<Switch isSelected onChange={() => {}}>Switch</Switch>);
|
||||
const checkbox = screen.getByRole('switch');
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('respects isDisabled prop', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
render(<Switch isDisabled onChange={handleChange}>Switch</Switch>);
|
||||
|
||||
const checkbox = screen.getByRole('switch');
|
||||
expect(checkbox).toBeDisabled();
|
||||
|
||||
await user.click(checkbox);
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
|
||||
import { useToggleState } from 'react-stately';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
import type { AriaSwitchProps } from 'react-aria';
|
||||
|
||||
/**
|
||||
* Switch visual style variants.
|
||||
*/
|
||||
export type SwitchVariant = 'solid' | 'outline' | 'ghost';
|
||||
|
||||
/**
|
||||
* Switch color themes.
|
||||
*/
|
||||
export type SwitchColor =
|
||||
| 'primary'
|
||||
| 'primary-negative'
|
||||
| 'secondary'
|
||||
| 'secondary-negative'
|
||||
| 'tertiary'
|
||||
| 'tertiary-negative'
|
||||
| 'error'
|
||||
| 'error-negative'
|
||||
| 'neutral'
|
||||
| 'neutral-negative';
|
||||
|
||||
/**
|
||||
* Props for the Switch component.
|
||||
*/
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
/** Optional class name for the outer container */
|
||||
className?: string;
|
||||
/** Optional label for the switch */
|
||||
children?: React.ReactNode;
|
||||
/** Visual style variant */
|
||||
variant?: SwitchVariant;
|
||||
/** Color theme */
|
||||
color?: SwitchColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* A highly accessible toggle switch component.
|
||||
*
|
||||
* @param props - The component props.
|
||||
* @returns A React element representing the switch.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Switch onChange={(isSelected) => console.log(isSelected)}>
|
||||
* Toggle Me
|
||||
* </Switch>
|
||||
* ```
|
||||
*/
|
||||
export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
const { children, className, variant = 'solid', color = 'primary' } = props;
|
||||
const state = useToggleState(props);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const { inputProps } = useSwitch(props, state, ref);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
const getVariantClasses = (): string => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { base: string; text: string }> = {
|
||||
primary: { base: 'var(--rb-primary-base)', text: 'var(--rb-primary-text)' },
|
||||
secondary: { base: 'var(--rb-secondary-base)', text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { base: 'var(--rb-tertiary-base)', text: 'var(--rb-tertiary-text)' },
|
||||
error: { base: 'var(--rb-error-base)', text: 'var(--rb-error-text)' },
|
||||
neutral: { base: 'var(--rb-muted-base)', text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const colorValues = colors[baseColor];
|
||||
const backgroundColor = isNegative ? colorValues.text : colorValues.base;
|
||||
|
||||
if (!state.isSelected) {
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return 'bg-[var(--rb-muted-base)]';
|
||||
case 'outline':
|
||||
return 'bg-transparent border-[var(--rb-muted-base)] border-2';
|
||||
case 'ghost':
|
||||
return 'bg-transparent border-transparent';
|
||||
default:
|
||||
return 'bg-[var(--rb-muted-base)]';
|
||||
}
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return `bg-[${backgroundColor}]`;
|
||||
case 'outline':
|
||||
return `bg-transparent border-[${backgroundColor}] border-2`;
|
||||
case 'ghost':
|
||||
return `bg-[${backgroundColor}]/20 border-transparent`;
|
||||
default:
|
||||
return `bg-[${backgroundColor}]`;
|
||||
}
|
||||
};
|
||||
|
||||
const getThumbClasses = (): string => {
|
||||
const isNegative = color.endsWith('-negative');
|
||||
const baseColor = color.replace('-negative', '');
|
||||
|
||||
const colors: Record<string, { text: string }> = {
|
||||
primary: { text: 'var(--rb-primary-text)' },
|
||||
secondary: { text: 'var(--rb-secondary-text)' },
|
||||
tertiary: { text: 'var(--rb-tertiary-text)' },
|
||||
error: { text: 'var(--rb-error-text)' },
|
||||
neutral: { text: 'var(--rb-text)' },
|
||||
};
|
||||
|
||||
const thumbColor = isNegative ? 'var(--rb-background)' : colors[baseColor].text;
|
||||
|
||||
if (!state.isSelected) {
|
||||
return 'bg-[var(--rb-text)]';
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'solid':
|
||||
return `bg-[${thumbColor}]`;
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return `bg-[${isNegative ? 'var(--rb-text)' : 'var(--rb-' + baseColor + '-base)'}]`;
|
||||
default:
|
||||
return `bg-[${thumbColor}]`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'group inline-flex items-center cursor-pointer select-none gap-3',
|
||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...inputProps} {...focusProps} ref={ref} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
||||
getVariantClasses(),
|
||||
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
|
||||
props.isDisabled && 'grayscale-[0.5]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm',
|
||||
variant === 'outline' ? 'ml-0.5' : 'ml-1',
|
||||
state.isSelected ? (variant === 'outline' ? 'translate-x-4.5' : 'translate-x-5') : 'translate-x-0',
|
||||
getThumbClasses()
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{children && (
|
||||
<Text bold={state.isSelected} color={state.isSelected ? 'default' : 'muted'} size="s" type="label">
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ThemeSwitch } from './ThemeSwitch';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof ThemeSwitch> = {
|
||||
title: 'Components/ThemeSwitch',
|
||||
component: ThemeSwitch,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ThemeSwitch>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,52 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { ThemeSwitch } from './ThemeSwitch';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe('components/ThemeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<ThemeSwitch />);
|
||||
expect(screen.getByLabelText('Toggle dark mode')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Follow system theme')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles theme when switch is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemeSwitch />);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
await user.click(toggle);
|
||||
|
||||
expect(window.localStorage.getItem('ermnvldmr/ui/lib/theme')).toContain('dark');
|
||||
});
|
||||
|
||||
it('sets system theme when auto is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThemeSwitch />);
|
||||
|
||||
const autoButton = screen.getByLabelText('Follow system theme');
|
||||
await user.click(autoButton);
|
||||
|
||||
expect(window.localStorage.getItem('ermnvldmr/ui/lib/theme')).toContain('system');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { useTheme } from '../../lib/theme';
|
||||
import { Button } from '../Button/Button';
|
||||
import { HStack } from '../HStack/HStack';
|
||||
import { Separator } from '../Separator/Separator';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
/**
|
||||
* A composite component for managing theme preferences.
|
||||
* Includes a toggle for Light/Dark and a button for System mode.
|
||||
*
|
||||
* @returns A React element representing the theme switcher.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThemeSwitch />
|
||||
* ```
|
||||
*/
|
||||
export function ThemeSwitch(): React.JSX.Element {
|
||||
const { preference, setPreference, resolvedTheme } = useTheme();
|
||||
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
return (
|
||||
<HStack align="center" gap={4}>
|
||||
<HStack align="center" gap={2}>
|
||||
<Sun className="w-4 h-4 text-[var(--rb-text)] opacity-60" />
|
||||
<Switch
|
||||
aria-label="Toggle dark mode"
|
||||
isSelected={isDark}
|
||||
variant="outline"
|
||||
onChange={(isSelected) => setPreference(isSelected ? 'dark' : 'light')}
|
||||
/>
|
||||
<Moon className="w-4 h-4 text-[var(--rb-text)] opacity-60" />
|
||||
</HStack>
|
||||
<Separator direction="vertical" thinned={false} />
|
||||
<Button
|
||||
aria-label="Follow system theme"
|
||||
color={preference === 'system' ? 'primary' : 'neutral'}
|
||||
renderStartIcon={(className) => <Monitor className={className} />}
|
||||
size="s"
|
||||
variant={preference === 'system' ? 'solid' : 'ghost'}
|
||||
onPress={() => setPreference('system')}
|
||||
>
|
||||
<Text bold={preference === 'system'} size="s" type="label">
|
||||
Auto
|
||||
</Text>
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ export * from './Article/Article';
|
||||
export * from './Blockquote/Blockquote';
|
||||
export * from './Breadcrumbs/Breadcrumbs';
|
||||
export * from './Button/Button';
|
||||
export * from './Code/Code';
|
||||
export * from './Code/CodeBlock/CodeBlock';
|
||||
export * from './Code/index';
|
||||
export * from './Container/Container';
|
||||
export * from './Header/Header';
|
||||
export * from './HStack/HStack';
|
||||
@@ -15,36 +14,9 @@ export * from './Paragraph/Paragraph';
|
||||
export * from './Separator/Separator';
|
||||
export * from './Stack/Stack';
|
||||
export * from './Stub/Stub';
|
||||
export * from './Table/Table';
|
||||
export * from './Switch/Switch';
|
||||
export * from './Table/index';
|
||||
export * from './Text/Text';
|
||||
export * from './ThemeSwitch/ThemeSwitch';
|
||||
export * from './Time/Time';
|
||||
export * from './VStack/VStack';
|
||||
|
||||
export type { StackProps } from './Stack/Stack';
|
||||
export type { ListProps, ListVariant, ListSpacing, ListItemProps } from './List/List';
|
||||
export type {
|
||||
ContainerProps,
|
||||
ContainerBackground,
|
||||
ContainerMaxWidth,
|
||||
ContainerRounded,
|
||||
} from './Container/Container';
|
||||
export type { TextProps, TextType, TextSize, TextColor } from './Text/Text';
|
||||
export type { HeaderProps } from './Header/Header';
|
||||
export type { ParagraphProps } from './Paragraph/Paragraph';
|
||||
export type { ButtonProps, ButtonVariant, ButtonColor, ButtonSize } from './Button/Button';
|
||||
export type { CodeProps, CodeBlockProps } from './Code';
|
||||
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
||||
export type { StubProps } from './Stub/Stub';
|
||||
export type { ImageProps } from './Image/Image';
|
||||
export type { TimeProps } from './Time/Time';
|
||||
export type {
|
||||
TableProps,
|
||||
TableDensity,
|
||||
TableVariant,
|
||||
TablePadding,
|
||||
TableRowProps,
|
||||
TableHeadProps,
|
||||
TableCellProps,
|
||||
TableCellBorder,
|
||||
TableContentProps as TableContentCellProps,
|
||||
} from './Table';
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export * from './index';
|
||||
Reference in new issue
Block a user