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 ( - - - - - -