feat(mobile): use native select for mobile setting (#9236)

![CleanShot 2024-12-21 at 12.01.32.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/8f29afcc-3541-4081-9f8f-f74e3ba08c4d.gif)
This commit is contained in:
CatsJuice
2024-12-24 03:24:51 +00:00
parent 17c2293986
commit 3a8d90d861
3 changed files with 86 additions and 37 deletions

View File

@@ -6,6 +6,7 @@ export const root = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
position: 'relative',
}); });
export const label = style([ export const label = style([
bodyRegular, bodyRegular,
@@ -15,3 +16,11 @@ export const icon = style({
fontSize: 24, fontSize: 24,
color: cssVarV2('icon/primary'), color: cssVarV2('icon/primary'),
}); });
export const nativeSelect = style({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
});

View File

@@ -5,7 +5,9 @@ import {
type CSSProperties, type CSSProperties,
type HTMLAttributes, type HTMLAttributes,
type ReactNode, type ReactNode,
useCallback,
useMemo, useMemo,
useRef,
} from 'react'; } from 'react';
import * as styles from './dropdown-select.css'; import * as styles from './dropdown-select.css';
@@ -28,6 +30,7 @@ export interface SettingDropdownSelectProps<
) => void; ) => void;
emitValue?: E; emitValue?: E;
menuOptions?: Omit<MenuProps, 'items' | 'children'>; menuOptions?: Omit<MenuProps, 'items' | 'children'>;
native?: boolean;
} }
export const SettingDropdownSelect = < export const SettingDropdownSelect = <
@@ -40,12 +43,26 @@ export const SettingDropdownSelect = <
onChange, onChange,
className, className,
menuOptions, menuOptions,
native = true,
...attrs ...attrs
}: SettingDropdownSelectProps<V, E>) => { }: SettingDropdownSelectProps<V, E>) => {
const selectedItem = useMemo( const selectedItem = useMemo(
() => options.find(opt => opt.value === value), () => options.find(opt => opt.value === value),
[options, value] [options, value]
); );
if (native) {
return (
<NativeSettingDropdownSelect
options={options}
value={value}
emitValue={emitValue as any}
onChange={onChange}
{...attrs}
/>
);
}
return ( return (
<MobileMenu <MobileMenu
items={options.map(opt => ( items={options.map(opt => (
@@ -76,3 +93,59 @@ export const SettingDropdownSelect = <
</MobileMenu> </MobileMenu>
); );
}; };
export const NativeSettingDropdownSelect = <
V extends string = string,
E extends boolean | undefined = true,
>({
options = [],
value,
emitValue = true,
onChange,
className,
...attrs
}: Omit<SettingDropdownSelectProps<V, E>, 'native'>) => {
const nativeSelectRef = useRef<HTMLSelectElement>(null);
const selectedItem = useMemo(
() => options.find(opt => opt.value === value),
[options, value]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
const opt = options.find(opt => opt.value === value);
if (emitValue) {
onChange?.(e.target.value as any);
} else {
onChange?.(opt as any);
}
},
[emitValue, onChange, options]
);
return (
<div
data-testid="dropdown-select-trigger"
className={clsx(styles.root, className)}
{...attrs}
>
<span className={styles.label}>{selectedItem?.label ?? ''}</span>
<ArrowDownSmallIcon className={styles.icon} />
<select
className={styles.nativeSelect}
ref={nativeSelectRef}
value={value}
onChange={handleChange}
data-testid="native-dropdown-select-trigger"
>
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
};

View File

@@ -13,48 +13,15 @@ test('can open settings', async ({ page }) => {
test('can change theme', async ({ page }) => { test('can change theme', async ({ page }) => {
await openSettings(page); await openSettings(page);
await page const select = page
.getByTestId('setting-row') .getByTestId('setting-row')
.filter({ .filter({
hasText: 'Color mode', hasText: 'Color mode',
}) })
.getByTestId('dropdown-select-trigger') .getByTestId('native-dropdown-select-trigger');
.click();
await expect( await select.selectOption('light');
page.getByRole('dialog').filter({ await select.selectOption('dark');
has: page.getByRole('menuitem', { name: 'Light' }),
})
).toBeVisible();
await page.getByRole('menuitem', { name: 'Dark' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
}); });
test('can close change theme popover by clicking outside', async ({ page }) => {
await openSettings(page);
await page
.getByTestId('setting-row')
.filter({
hasText: 'Color mode',
})
.getByTestId('dropdown-select-trigger')
.click();
const themePopover = page.getByRole('dialog').filter({
has: page.getByRole('menuitem', { name: 'Light' }),
});
await expect(themePopover).toBeVisible();
// get a mouse position that is 10px offset to the top of theme popover
// and click
const mousePosition = await themePopover.boundingBox();
if (!mousePosition) {
throw new Error('theme popover is not visible');
}
await page.mouse.click(mousePosition.x, mousePosition.y - 10);
await expect(themePopover).not.toBeVisible();
});