Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ankhorage/surface': patch
---

Update Menu.
107 changes: 71 additions & 36 deletions src/components/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { FocusScope } from '../../internal/focus/FocusScope';
import { useFocusManager } from '../../internal/focus/useFocusManager';
import { Portal } from '../../internal/overlay/Portal';
import { resolveOverlayAnimation } from '../../internal/resolvers';
import { Box, Surface } from '../../layout';
import { Box, Inline, Stack, Surface } from '../../layout';
import { ButtonBase } from '../../primitives/button-base';
import { Text } from '../../primitives/text';
import { useTheme } from '../../theme/ThemeContext';
import { resolveNextMenuIndex } from './navigation';
import type { MenuProps } from './types';
import type { MenuAction, MenuProps } from './types';

interface MeasurableNode {
measureInWindow?: (
Expand All @@ -25,7 +25,35 @@ function measureNode(node: unknown, callback: (layout: LayoutRectangle) => void)
});
}

export function Menu({ trigger, items, onDismiss, closeOnSelect = true, testID }: MenuProps) {
function renderActionContent(action: MenuAction, active: boolean) {
const titleColor =
action.intent === 'danger' ? 'danger' : active || action.selected ? 'neutral' : undefined;

return (
<Inline align="center" gap="s" justify="space-between" wrap="nowrap">
{action.leading ? <Box>{action.leading}</Box> : null}
<Box flex={1}>
<Stack gap="xxs">
<Text
color={titleColor}
variant="bodySmall"
weight={action.selected ? 'semiBold' : 'medium'}
>
{action.title}
</Text>
{action.description ? (
<Text emphasis="muted" variant="caption">
{action.description}
</Text>
) : null}
</Stack>
</Box>
{action.trailing ? <Box>{action.trailing}</Box> : null}
</Inline>
);
}

export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }: MenuProps) {
const { theme } = useTheme();
const { bindKeydown } = useFocusManager();
const animation = resolveOverlayAnimation('menu');
Expand All @@ -36,15 +64,34 @@ export function Menu({ trigger, items, onDismiss, closeOnSelect = true, testID }

const closeMenu = React.useCallback(() => {
setOpen(false);
onDismiss?.();
}, [onDismiss]);
if (dismiss) {
dismiss();
}
}, [dismiss]);

const activateAction = React.useCallback(
(action: MenuAction) => {
if (action.disabled) {
return;
}

if (action.activate) {
action.activate();
}

if (closeOnSelect) {
closeMenu();
}
},
[closeMenu, closeOnSelect],
);

const openMenu = React.useCallback(() => {
measureNode(anchorRef.current, setLayout);
const firstEnabledIndex = items.findIndex((item) => !item.disabled);
const firstEnabledIndex = actions.findIndex((action) => !action.disabled);
setActiveIndex(firstEnabledIndex === -1 ? 0 : firstEnabledIndex);
setOpen(true);
}, [items]);
}, [actions]);

React.useEffect(() => {
if (!open) {
Expand All @@ -55,17 +102,14 @@ export function Menu({ trigger, items, onDismiss, closeOnSelect = true, testID }
const { key } = event;
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
event.preventDefault();
setActiveIndex((current) => resolveNextMenuIndex(items, current, key));
setActiveIndex((current) => resolveNextMenuIndex(actions, current, key));
}

if (event.key === 'Enter') {
event.preventDefault();
const activeItem = items[activeIndex];
if (activeItem && !activeItem.disabled) {
activeItem.onPress?.();
if (closeOnSelect) {
closeMenu();
}
const activeAction = actions[activeIndex];
if (activeAction) {
activateAction(activeAction);
}
}

Expand All @@ -74,7 +118,7 @@ export function Menu({ trigger, items, onDismiss, closeOnSelect = true, testID }
closeMenu();
}
});
}, [activeIndex, bindKeydown, closeMenu, closeOnSelect, items, open]);
}, [activateAction, actions, activeIndex, bindKeydown, closeMenu, open]);

return (
<View collapsable={false} ref={anchorRef}>
Expand Down Expand Up @@ -117,48 +161,39 @@ export function Menu({ trigger, items, onDismiss, closeOnSelect = true, testID }
accessibilityRole="menu"
p="xs"
style={{
minWidth: Math.max(layout?.width ?? 0, 180),
minWidth: Math.max(layout?.width ?? 0, 220),
shadowOpacity: 0.12,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
}}
testID={testID}
variant="raised"
>
{items.map((item, index) => {
const selected = index === activeIndex;
{actions.map((action, index) => {
const active = index === activeIndex;
const selected = action.selected ?? active;

return (
<Pressable
accessibilityRole="menuitem"
accessibilityState={{ disabled: item.disabled, selected }}
disabled={item.disabled}
key={item.id}
onPress={() => {
if (item.disabled) {
return;
}

item.onPress?.();
if (closeOnSelect) {
closeMenu();
}
}}
accessibilityState={{ disabled: action.disabled, selected }}
disabled={action.disabled}
key={action.id}
onPress={() => activateAction(action)}
>
<Box
px="m"
py="s"
radius="s"
style={{
backgroundColor: selected
? theme.semantics.action.neutral.softBg
: 'transparent',
opacity: item.disabled ? 0.56 : 1,
opacity: action.disabled ? 0.56 : 1,
}}
testID={testID ? `${testID}-item-${item.id}` : undefined}
testID={testID ? `${testID}-item-${action.id}` : undefined}
>
<Text color={selected ? 'neutral' : undefined} variant="bodySmall">
{item.label}
</Text>
{renderActionContent(action, active)}
</Box>
</Pressable>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/menu/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Menu } from './Menu';
export type { MenuItem, MenuProps } from './types';
export type { MenuAction, MenuActionIntent, MenuAction as MenuItem, MenuProps } from './types';
2 changes: 1 addition & 1 deletion src/components/menu/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export function resolveNextMenuIndex(
items: { disabled?: boolean }[],
items: readonly { disabled?: boolean }[],
currentIndex: number,
key: 'ArrowDown' | 'ArrowUp' | 'Home' | 'End',
): number {
Expand Down
17 changes: 12 additions & 5 deletions src/components/menu/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type React from 'react';

export interface MenuItem {
export type MenuActionIntent = 'default' | 'danger';

export interface MenuAction {
id: string;
label: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
leading?: React.ReactNode;
trailing?: React.ReactNode;
intent?: MenuActionIntent;
disabled?: boolean;
onPress?: (() => void) | undefined;
selected?: boolean;
activate?: () => void;
}

export interface MenuProps {
trigger?: React.ReactNode;
items: MenuItem[];
onDismiss?: (() => void) | undefined;
actions: readonly MenuAction[];
dismiss?: () => void;
closeOnSelect?: boolean;
testID?: string;
}
134 changes: 74 additions & 60 deletions src/examples/DocsExamples.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,91 @@
import React from 'react';

import {
Badge,
Box,
Button,
Checkbox,
Drawer,
Field,
HelperText,
Label,
Menu,
Modal,
Radio,
Stack,
Switch,
Tab,
TabList,
TabPanel,
Tabs,
Text,
Textarea,
TextInput,
ThemeProvider,
} from '../index';
import { Button } from '../components/button';
import { Card } from '../components/card';
import { Checkbox } from '../components/checkbox';
import { Drawer } from '../components/drawer';
import { Field } from '../components/field';
import { HelperText } from '../components/helper-text';
import { Menu } from '../components/menu';
import { Modal } from '../components/modal';
import { Tab, TabList, TabPanel, Tabs } from '../components/tabs';
import { TextInput } from '../components/text-input';
import { ToastProvider, useToast } from '../components/toast';
import { Tooltip } from '../components/tooltip';
import { Box, Stack } from '../layout';
import { Text } from '../primitives/text';
import { ThemeProvider } from '../theme';
import type { ThemeConfig } from '../theme/types';

const docsThemeConfig = {
id: 'docs-example',
name: 'Docs Example',
light: {
harmony: 'monochromatic' as const,
primaryColor: '#2563eb',
},
dark: {
harmony: 'monochromatic' as const,
primaryColor: '#2563eb',
},
const docsThemeConfig: ThemeConfig = {
id: 'docs',
name: 'Docs',
light: { primaryColor: '#2563EB', harmony: 'analogous' },
dark: { primaryColor: '#60A5FA', harmony: 'analogous' },
};

export function ProviderExample() {
export function FormExample() {
return (
<ThemeProvider initialConfig={docsThemeConfig}>
<Stack gap="m" p="l">
<Text variant="body">Surface starter</Text>
<Badge color="success" content="Foundation" />
<Button color="warning" variant="soft">
Continue
</Button>
<Field helperText="Use your work email." label="Email" required>
<TextInput placeholder="you@example.com" />
</Field>
<Checkbox checked>Accept terms</Checkbox>
<HelperText emphasis="muted">Looks good.</HelperText>
</Stack>
</ThemeProvider>
);
}

export function FormAndOverlayExample() {
function ToastButton() {
const { showToast } = useToast();
return (
<Button
onPress={() =>
showToast({ description: 'Your changes were saved.', status: 'success', title: 'Saved' })
}
>
Show toast
</Button>
);
}

export function FeedbackExample() {
return (
<ThemeProvider initialConfig={docsThemeConfig}>
<Stack gap="m" p="l">
<Field helperText="We only use this for sign-in." label="Email">
<TextInput placeholder="name@example.com" />
</Field>
<Field errorText="Bio is required." invalid label="Bio">
<Textarea placeholder="Tell us a little about yourself" />
</Field>
<Stack gap="s">
<Label required>Preferences</Label>
<Checkbox defaultChecked>Weekly updates</Checkbox>
<Radio>Product announcements</Radio>
<Switch readOnly>Read-only setting</Switch>
<HelperText color="error">Invalid fields should reuse the same error color.</HelperText>
<ToastProvider>
<Stack gap="m" p="l">
<Tooltip content="Helpful contextual information">
<Text>Hover for tooltip</Text>
</Tooltip>
<ToastButton />
</Stack>
<Modal visible={false}>
<Text>Modal content</Text>
</ToastProvider>
</ThemeProvider>
);
}

export function OverlayExample() {
const [modalVisible, setModalVisible] = React.useState(false);
const [drawerVisible, setDrawerVisible] = React.useState(false);

return (
<ThemeProvider initialConfig={docsThemeConfig}>
<Stack gap="m" p="l">
<Button onPress={() => setModalVisible(true)}>Open modal</Button>
<Button onPress={() => setDrawerVisible(true)}>Open drawer</Button>
<Modal onDismiss={() => setModalVisible(false)} visible={modalVisible}>
<Card>
<Stack gap="s">
<Text variant="label" weight="semiBold">
Modal title
</Text>
<Text>Modal content</Text>
</Stack>
</Card>
</Modal>
<Drawer visible={false}>
<Drawer onDismiss={() => setDrawerVisible(false)} visible={drawerVisible}>
<Text>Drawer content</Text>
</Drawer>
</Stack>
Expand Down Expand Up @@ -99,9 +113,9 @@ export function NavigationExample() {
</TabPanel>
</Tabs>
<Menu
items={[
{ id: 'edit', label: 'Edit' },
{ disabled: true, id: 'archive', label: 'Archive' },
actions={[
{ id: 'edit', title: 'Edit' },
{ disabled: true, id: 'archive', title: 'Archive' },
]}
trigger={<Text>Open menu</Text>}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type { LabelProps } from './components/label';
export { Label } from './components/label';
export type { ListItemProps } from './components/list-item';
export { ListItem } from './components/list-item';
export type { MenuItem, MenuProps } from './components/menu';
export type { MenuAction, MenuActionIntent, MenuItem, MenuProps } from './components/menu';
export { Menu } from './components/menu';
export type { ModalProps } from './components/modal';
export { Modal } from './components/modal';
Expand Down
Loading