<Select> uses custom component on Windows

This commit is contained in:
Gregory Schier
2024-06-02 16:57:23 -07:00
parent 36728d1d1f
commit b47ec01f9c
6 changed files with 85 additions and 67 deletions

View File

@@ -14,7 +14,7 @@ import { Editor } from '../core/Editor';
import type { IconProps } from '../core/Icon'; import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import type { SelectOption } from '../core/Select'; import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select'; import { Select } from '../core/Select';
import { Separator } from '../core/Separator'; import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks'; import { HStack, VStack } from '../core/Stacks';
@@ -64,14 +64,14 @@ export function SettingsAppearance() {
return null; return null;
} }
const lightThemes: SelectOption<string>[] = themes const lightThemes: SelectProps<string>['options'] = themes
.filter((theme) => !isThemeDark(theme)) .filter((theme) => !isThemeDark(theme))
.map((theme) => ({ .map((theme) => ({
label: theme.name, label: theme.name,
value: theme.id, value: theme.id,
})); }));
const darkThemes: SelectOption<string>[] = themes const darkThemes: SelectProps<string>['options'] = themes
.filter((theme) => isThemeDark(theme)) .filter((theme) => isThemeDark(theme))
.map((theme) => ({ .map((theme) => ({
label: theme.name, label: theme.name,
@@ -131,8 +131,9 @@ export function SettingsAppearance() {
name="lightTheme" name="lightTheme"
label="Light Theme" label="Light Theme"
size="sm" size="sm"
className="flex-1"
value={activeTheme.light.id} value={activeTheme.light.id}
options={[{ label: 'Light Mode Themes', options: lightThemes }]} options={lightThemes}
onChange={(themeLight) => { onChange={(themeLight) => {
trackEvent('theme', 'update', { theme: themeLight, appearance: 'light' }); trackEvent('theme', 'update', { theme: themeLight, appearance: 'light' });
updateSettings.mutateAsync({ ...settings, themeLight }); updateSettings.mutateAsync({ ...settings, themeLight });
@@ -143,11 +144,12 @@ export function SettingsAppearance() {
<Select <Select
hideLabel hideLabel
name="darkTheme" name="darkTheme"
className="flex-1"
label="Dark Theme" label="Dark Theme"
leftSlot={<Icon icon="moon" />} leftSlot={<Icon icon="moon" />}
size="sm" size="sm"
value={activeTheme.dark.id} value={activeTheme.dark.id}
options={[{ label: 'Dark Mode Themes', options: darkThemes }]} options={darkThemes}
onChange={(themeDark) => { onChange={(themeDark) => {
trackEvent('theme', 'update', { theme: themeDark, appearance: 'dark' }); trackEvent('theme', 'update', { theme: themeDark, appearance: 'dark' });
updateSettings.mutateAsync({ ...settings, themeDark }); updateSettings.mutateAsync({ ...settings, themeDark });

View File

@@ -8,8 +8,8 @@ import { useUpdateWorkspace } from '../../hooks/useUpdateWorkspace';
import { Checkbox } from '../core/Checkbox'; import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading'; import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import { Input } from '../core/Input';
import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow';
import { PlainInput } from '../core/PlainInput';
import { Select } from '../core/Select'; import { Select } from '../core/Select';
import { Separator } from '../core/Separator'; import { Separator } from '../core/Separator';
import { VStack } from '../core/Stacks'; import { VStack } from '../core/Stacks';
@@ -59,7 +59,7 @@ export function SettingsGeneral() {
</div> </div>
</Heading> </Heading>
<VStack className="mt-1 w-full" space={3}> <VStack className="mt-1 w-full" space={3}>
<Input <PlainInput
size="sm" size="sm"
name="requestTimeout" name="requestTimeout"
label="Request Timeout (ms)" label="Request Timeout (ms)"
@@ -68,6 +68,7 @@ export function SettingsGeneral() {
defaultValue={`${workspace.settingRequestTimeout}`} defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => parseInt(value) >= 0} validate={(value) => parseInt(value) >= 0}
onChange={(v) => updateWorkspace.mutate({ settingRequestTimeout: parseInt(v) || 0 })} onChange={(v) => updateWorkspace.mutate({ settingRequestTimeout: parseInt(v) || 0 })}
type="number"
/> />
<Checkbox <Checkbox

View File

@@ -120,7 +120,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{isLoading ? ( {isLoading ? (
<Icon icon="refresh" size={size} className="animate-spin mr-1" /> <Icon icon="refresh" size={size} className="animate-spin mr-1" />
) : leftSlot ? ( ) : leftSlot ? (
<div className="mr-1">{leftSlot}</div> <div className="mr-2">{leftSlot}</div>
) : null} ) : null}
<div <div
className={classNames( className={classNames(

View File

@@ -59,6 +59,7 @@ export interface DropdownProps {
items: DropdownItem[]; items: DropdownItem[];
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
fullWidth?: boolean;
hotKeyAction?: HotkeyAction; hotKeyAction?: HotkeyAction;
} }
@@ -73,7 +74,7 @@ export interface DropdownRef {
} }
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown( export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, onOpen, onClose, hotKeyAction }: DropdownProps, { children, items, onOpen, onClose, hotKeyAction, fullWidth }: DropdownProps,
ref, ref,
) { ) {
const [isOpen, _setIsOpen] = useState<boolean>(false); const [isOpen, _setIsOpen] = useState<boolean>(false);
@@ -153,6 +154,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
<Menu <Menu
ref={menuRef} ref={menuRef}
showTriangle showTriangle
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex} defaultSelectedIndex={defaultSelectedIndex}
items={items} items={items}
triggerShape={triggerRect ?? null} triggerShape={triggerRect ?? null}
@@ -203,6 +205,7 @@ interface MenuProps {
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null; triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void; onClose: () => void;
showTriangle?: boolean; showTriangle?: boolean;
fullWidth?: boolean;
isOpen: boolean; isOpen: boolean;
} }
@@ -211,6 +214,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
className, className,
isOpen, isOpen,
items, items,
fullWidth,
onClose, onClose,
triggerShape, triggerShape,
defaultSelectedIndex, defaultSelectedIndex,
@@ -359,21 +363,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const heightAbove = triggerShape.top; const heightAbove = triggerShape.top;
const heightBelow = docRect.height - triggerShape.bottom; const heightBelow = docRect.height - triggerShape.bottom;
const hSpaceRemaining = docRect.width - triggerShape.left; const hSpaceRemaining = docRect.width - triggerShape.left;
const top = triggerShape?.bottom + 5; const top = triggerShape.bottom + 5;
const onRight = hSpaceRemaining < 200; const onRight = hSpaceRemaining < 200;
const upsideDown = heightAbove > heightBelow && heightBelow < 200; const upsideDown = heightAbove > heightBelow && heightBelow < 200;
const triggerWidth = triggerShape.right - triggerShape.left;
const containerStyles = { const containerStyles = {
top: !upsideDown ? top : undefined, top: !upsideDown ? top : undefined,
bottom: upsideDown ? docRect.height - top : undefined, bottom: upsideDown ? docRect.height - top : undefined,
right: onRight ? docRect.width - triggerShape?.right : undefined, right: onRight ? docRect.width - triggerShape.right : undefined,
left: !onRight ? triggerShape?.left : undefined, left: !onRight ? triggerShape.left : undefined,
minWidth: fullWidth ? triggerWidth : undefined,
}; };
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' }; const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight const triangleStyles = onRight
? { right: width / 2, marginRight: '-0.2rem', ...size } ? { right: width / 2, marginRight: '-0.2rem', ...size }
: { left: width / 2, marginLeft: '-0.2rem', ...size }; : { left: width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles }; return { containerStyles, triangleStyles };
}, [triggerShape]); }, [fullWidth, triggerShape]);
const filteredItems = useMemo( const filteredItems = useMemo(
() => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())), () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),

View File

@@ -50,5 +50,9 @@ export function RadioDropdown<T = string | null>({
[items, extraItems, value, onChange], [items, extraItems, value, onChange],
); );
return <Dropdown items={dropdownItems}>{children}</Dropdown>; return (
<Dropdown fullWidth items={dropdownItems}>
{children}
</Dropdown>
);
} }

View File

@@ -1,9 +1,14 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useOsInfo } from '../../hooks/useOsInfo';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
interface Props<T extends string> { export interface SelectProps<T extends string> {
name: string; name: string;
label: string; label: string;
labelPosition?: 'top' | 'left'; labelPosition?: 'top' | 'left';
@@ -11,22 +16,12 @@ interface Props<T extends string> {
hideLabel?: boolean; hideLabel?: boolean;
value: T; value: T;
leftSlot?: ReactNode; leftSlot?: ReactNode;
options: SelectOption<T>[] | SelectOptionGroup<T>[]; options: RadioDropdownItem<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
size?: 'xs' | 'sm' | 'md' | 'lg'; size?: ButtonProps['size'];
className?: string; className?: string;
} }
export interface SelectOption<T extends string> {
label: string;
value: T;
}
export interface SelectOptionGroup<T extends string> {
label: string;
options: SelectOption<T>[];
}
export function Select<T extends string>({ export function Select<T extends string>({
labelPosition = 'top', labelPosition = 'top',
name, name,
@@ -39,7 +34,8 @@ export function Select<T extends string>({
onChange, onChange,
className, className,
size = 'md', size = 'md',
}: Props<T>) { }: SelectProps<T>) {
const osInfo = useOsInfo();
const [focused, setFocused] = useState<boolean>(false); const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`; const id = `input-${name}`;
return ( return (
@@ -49,7 +45,7 @@ export function Select<T extends string>({
'x-theme-input', 'x-theme-input',
'w-full', 'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent 'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2', labelPosition === 'left' && 'grid grid-cols-[auto_1fr] items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5', labelPosition === 'top' && 'flex-row gap-0.5',
)} )}
> >
@@ -59,45 +55,54 @@ export function Select<T extends string>({
> >
{label} {label}
</label> </label>
<HStack {osInfo?.osType === 'macos' ? (
space={2} <HStack
className={classNames( space={2}
'w-full rounded-md text-fg text-sm font-mono', className={classNames(
'pl-2', 'w-full rounded-md text-fg text-sm font-mono',
'border', 'pl-2',
focused ? 'border-border-focus' : 'border-background-highlight', 'border',
size === 'xs' && 'h-xs', focused ? 'border-border-focus' : 'border-background-highlight',
size === 'sm' && 'h-sm', size === 'xs' && 'h-xs',
size === 'md' && 'h-md', size === 'sm' && 'h-sm',
size === 'lg' && 'h-lg', size === 'md' && 'h-md',
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
>
{options.map((o) =>
'options' in o ? (
<optgroup key={o.label} label={o.label}>
{o.options.map(({ label, value }) => (
<option key={label} value={value}>
{label}
</option>
))}
</optgroup>
) : (
<option key={o.label} value={o.value}>
{o.label}
</option>
),
)} )}
</select> >
</HStack> {leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
>
{options.map((o) => {
if (o.type === 'separator') return null;
return (
<option key={o.label} value={o.value}>
{o.label}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown value={value} onChange={onChange} items={options}>
<Button
className="w-full text-sm font-mono"
justify="start"
variant="border"
size={size}
leftSlot={leftSlot}
forDropdown
>
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}
</Button>
</RadioDropdown>
)}
</div> </div>
); );
} }