diff --git a/.changeset/menu.md b/.changeset/menu.md
new file mode 100644
index 0000000..a9047ac
--- /dev/null
+++ b/.changeset/menu.md
@@ -0,0 +1,5 @@
+---
+'@ankhorage/surface': patch
+---
+
+Update Menu.
diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx
index 8a4bb25..e0800f6 100644
--- a/src/components/menu/Menu.tsx
+++ b/src/components/menu/Menu.tsx
@@ -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?: (
@@ -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 (
+
+ {action.leading ? {action.leading} : null}
+
+
+
+ {action.title}
+
+ {action.description ? (
+
+ {action.description}
+
+ ) : null}
+
+
+ {action.trailing ? {action.trailing} : null}
+
+ );
+}
+
+export function Menu({ trigger, actions, dismiss, closeOnSelect = true, testID }: MenuProps) {
const { theme } = useTheme();
const { bindKeydown } = useFocusManager();
const animation = resolveOverlayAnimation('menu');
@@ -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) {
@@ -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);
}
}
@@ -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 (
@@ -117,7 +161,7 @@ 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 },
@@ -125,40 +169,31 @@ export function Menu({ trigger, items, onDismiss, closeOnSelect = true, testID }
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 (
{
- if (item.disabled) {
- return;
- }
-
- item.onPress?.();
- if (closeOnSelect) {
- closeMenu();
- }
- }}
+ accessibilityState={{ disabled: action.disabled, selected }}
+ disabled={action.disabled}
+ key={action.id}
+ onPress={() => activateAction(action)}
>
-
- {item.label}
-
+ {renderActionContent(action, active)}
);
diff --git a/src/components/menu/index.ts b/src/components/menu/index.ts
index f45dfe6..7f2b9f8 100644
--- a/src/components/menu/index.ts
+++ b/src/components/menu/index.ts
@@ -1,2 +1,2 @@
export { Menu } from './Menu';
-export type { MenuItem, MenuProps } from './types';
+export type { MenuAction, MenuActionIntent, MenuAction as MenuItem, MenuProps } from './types';
diff --git a/src/components/menu/navigation.ts b/src/components/menu/navigation.ts
index 5fa31d5..681c150 100644
--- a/src/components/menu/navigation.ts
+++ b/src/components/menu/navigation.ts
@@ -1,5 +1,5 @@
export function resolveNextMenuIndex(
- items: { disabled?: boolean }[],
+ items: readonly { disabled?: boolean }[],
currentIndex: number,
key: 'ArrowDown' | 'ArrowUp' | 'Home' | 'End',
): number {
diff --git a/src/components/menu/types.ts b/src/components/menu/types.ts
index cd8fd51..3874105 100644
--- a/src/components/menu/types.ts
+++ b/src/components/menu/types.ts
@@ -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;
}
diff --git a/src/examples/DocsExamples.tsx b/src/examples/DocsExamples.tsx
index 7a5f888..52afb4c 100644
--- a/src/examples/DocsExamples.tsx
+++ b/src/examples/DocsExamples.tsx
@@ -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 (
- Surface starter
-
-
+
+
+
+ Accept terms
+ Looks good.
);
}
-export function FormAndOverlayExample() {
+function ToastButton() {
+ const { showToast } = useToast();
+ return (
+
+ );
+}
+
+export function FeedbackExample() {
return (
-
-
-
-
-
-
-
-
-
- Weekly updates
- Product announcements
- Read-only setting
- Invalid fields should reuse the same error color.
+
+
+
+ Hover for tooltip
+
+
-
- Modal content
+
+
+ );
+}
+
+export function OverlayExample() {
+ const [modalVisible, setModalVisible] = React.useState(false);
+ const [drawerVisible, setDrawerVisible] = React.useState(false);
+
+ return (
+
+
+
+
+ setModalVisible(false)} visible={modalVisible}>
+
+
+
+ Modal title
+
+ Modal content
+
+
-
+ setDrawerVisible(false)} visible={drawerVisible}>
Drawer content
@@ -99,9 +113,9 @@ export function NavigationExample() {