From 7d8cb6fa4870b89731eafc3aafec2de68bdc214e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 12 Feb 2026 16:56:02 -0800 Subject: [PATCH 01/11] add unavailable menu item to RAC and S2 --- .../@react-aria/menu/src/useSubmenuTrigger.ts | 2 +- packages/@react-spectrum/s2/src/Menu.tsx | 59 +++++++++- packages/@react-spectrum/s2/src/index.ts | 4 +- .../s2/stories/Menu.stories.tsx | 39 ++++++- packages/react-aria-components/src/Menu.tsx | 106 ++++++++++++++++-- .../react-aria-components/src/Popover.tsx | 4 +- packages/react-aria-components/src/index.ts | 4 +- 7 files changed, 195 insertions(+), 23 deletions(-) diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 65b98a3c3ca..85ec91e8999 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -43,7 +43,7 @@ export interface AriaSubmenuTriggerProps { shouldUseVirtualFocus?: boolean } -interface SubmenuTriggerProps extends Omit { +interface SubmenuTriggerProps extends Omit { /** Whether the submenu trigger is in an expanded state. */ isOpen: boolean } diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index bb0d8a9fe25..5c521c72a38 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -21,6 +21,8 @@ import { MenuTriggerProps as AriaMenuTriggerProps, SubmenuTrigger as AriaSubmenuTrigger, SubmenuTriggerProps as AriaSubmenuTriggerProps, + UnavailableMenuItemTrigger as AriaUnavailableMenuItemTrigger, + UnavailableMenuItemTriggerProps as AriaUnavailableMenuItemTriggerProps, ContextValue, DEFAULT_SLOT, MenuItemRenderProps, @@ -40,8 +42,9 @@ import {DOMRef, DOMRefValue, GlobalDOMAttributes, PressEvent} from '@react-types import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, KeyboardContext, Text, TextContext} from './Content'; -import {IconContext} from './Icon'; // chevron right removed?? -import {ImageContext} from './Image'; +import {IconContext} from './Icon'; +import {ImageContext} from './Image'; // chevron right removed?? +import InfoCircleIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg'; import {InPopoverContext, Popover, PopoverContext} from './Popover'; import LinkOutIcon from '../ui-icons/LinkOut'; import {mergeStyles} from '../style/runtime'; @@ -317,6 +320,8 @@ let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ let descriptor = style({ gridArea: 'descriptor', + // TODO: currently the unavailable icon is misaligned with the submenu arrow, check with spectrum + placeSelf: 'end', marginStart: 8, '--iconPrimary': { type: 'fill', @@ -324,6 +329,19 @@ let descriptor = style({ } }); +let descriptorIcon = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ + marginEnd: 0, + display: 'block', + size: { + size: { + S: 16, + M: 20, + L: 24, + XL: 26 + } + } +}); + let InternalMenuContext = createContext<{size: 'S' | 'M' | 'L' | 'XL', isSubmenu: boolean, hideLinkOutIcon: boolean}>({ size: 'M', isSubmenu: false, @@ -517,7 +535,24 @@ export function MenuItem(props: MenuItemProps): ReactNode { })({direction})} /> )} - {renderProps.hasSubmenu && ( + {renderProps.hasSubmenu && renderProps.isUnavailable && ( +
+ {/* Need to avoid the icon context set above since that gets a marginEnd that will then propagate to InfoCircleIcon */} + + + +
+ )} + {renderProps.hasSubmenu && !renderProps.isUnavailable && (
+ {props.children[0]} + + {props.children[1]} + + + ); +} + +export {MenuTrigger, SubmenuTrigger, UnavailableMenuItemTrigger}; // This is purely so that storybook generates the types for both Menu and MenuTrigger interface ICombined extends MenuProps, Omit {} diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 0865c16c0cb..aac8e0ea709 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -57,7 +57,7 @@ export {Image, ImageContext} from './Image'; export {ImageCoordinator} from './ImageCoordinator'; export {InlineAlert, InlineAlertContext} from './InlineAlert'; export {Link, LinkContext} from './Link'; -export {MenuItem, MenuTrigger, Menu, MenuSection, SubmenuTrigger, MenuContext} from './Menu'; +export {MenuItem, MenuTrigger, Menu, MenuSection, SubmenuTrigger, UnavailableMenuItemTrigger, MenuContext} from './Menu'; export {Meter, MeterContext} from './Meter'; export {NotificationBadge, NotificationBadgeContext} from './NotificationBadge'; export {NumberField, NumberFieldContext} from './NumberField'; @@ -136,7 +136,7 @@ export type {InlineAlertProps} from './InlineAlert'; export type {ImageProps} from './Image'; export type {ImageCoordinatorProps} from './ImageCoordinator'; export type {LinkProps} from './Link'; -export type {MenuTriggerProps, MenuProps, MenuItemProps, MenuSectionProps, SubmenuTriggerProps} from './Menu'; +export type {MenuTriggerProps, MenuProps, MenuItemProps, MenuSectionProps, SubmenuTriggerProps, UnavailableMenuItemTriggerProps} from './Menu'; export type {MeterProps} from './Meter'; export type {NotificationBadgeProps} from './NotificationBadge'; export type {PickerProps, PickerItemProps, PickerSectionProps} from './Picker'; diff --git a/packages/@react-spectrum/s2/stories/Menu.stories.tsx b/packages/@react-spectrum/s2/stories/Menu.stories.tsx index ffe495dbf39..b4ec9190f56 100644 --- a/packages/@react-spectrum/s2/stories/Menu.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Menu.stories.tsx @@ -14,7 +14,7 @@ import AlignLeft from '../s2wf-icons/S2_Icon_TextAlignLeft_20_N.svg'; import AlignMiddle from '../s2wf-icons/S2_Icon_TextAlignCenter_20_N.svg'; import AlignRight from '../s2wf-icons/S2_Icon_TextAlignRight_20_N.svg'; import Bold from '../s2wf-icons/S2_Icon_TextBold_20_N.svg'; -import {Button, Header, Heading, Image, Keyboard, Menu, MenuItem, MenuProps, MenuSection, MenuTrigger, SubmenuTrigger, Text} from '../src'; +import {Button, Header, Heading, Image, Keyboard, Menu, MenuItem, MenuProps, MenuSection, MenuTrigger, Popover, SubmenuTrigger, Text, UnavailableMenuItemTrigger} from '../src'; import {categorizeArgTypes, getActionArgs} from './utils'; import ClockPendingIcon from '../s2wf-icons/S2_Icon_ClockPending_20_N.svg'; import {CombinedMenu} from '../src/Menu'; @@ -32,6 +32,7 @@ import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg'; import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; import {ReactElement, useState} from 'react'; import {Selection} from 'react-aria-components'; +import {style} from '../style' with {type: 'macro'}; import TextIcon from '../s2wf-icons/S2_Icon_Text_20_N.svg'; import Underline from '../s2wf-icons/S2_Icon_TextUnderline_20_N.svg'; @@ -315,3 +316,39 @@ export const SelectionGroups: StoryObj = { layout: 'padded' } }; + +export const UnavailableMenuItem: Story = { + render: (args) => { + return ( + + + + Favorite + + Edit + +
+ Contact your administrator for permissions to edit this item. +
+
+
+ + Delete + +
+ Contact your administrator for permissions to delete this item. +
+
+
+ + Share + + SMS + Email + + +
+
+ ); + } +}; diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index c6ab3cfb6aa..6d57fc276fe 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -182,6 +182,76 @@ export const SubmenuTrigger = /*#__PURE__*/ createBranchComponent(SubmenuTrigge ); }, props => props.children[0]); +export interface UnavailableMenuItemTriggerProps { + /** + * The contents of the UnavailableMenuItemTrigger. The first child should be an MenuItem (the trigger) and the second child should be the Popover (for the subdialog explaining the unavailable state). + */ + children: ReactElement[], + /** + * Whether the menu item is currently unavailable. + * @default false + */ + isUnavailable?: boolean +} + +class UnavailableMenuItemTriggerNode extends CollectionNode { + static readonly type = 'unavailable'; + + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: (textValue: string, node: Node) => boolean): CollectionNode | null { + let triggerNode = collection.getItem(this.firstChildKey!); + if (triggerNode && filterFn(triggerNode.textValue, this)) { + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; + } + + return null; + } +} + +/** + * A unavailable menu item trigger is used to wrap a unavailable menu item. When the menu item is unavailable, + * selecting it opens the wrapped Popover instead of performing the item's action. + * + * @version alpha + */ +export const UnavailableMenuItemTrigger = /*#__PURE__*/ createBranchComponent(UnavailableMenuItemTriggerNode, (props: UnavailableMenuItemTriggerProps, ref: ForwardedRef, item) => { + let {isUnavailable = false} = props; + let {CollectionBranch} = useContext(CollectionRendererContext); + let state = useContext(MenuStateContext)!; + let rootMenuTriggerState = useContext(RootMenuTriggerStateContext)!; + let submenuTriggerState = useSubmenuTriggerState({triggerKey: item.key}, rootMenuTriggerState); + let submenuRef = useRef(null); + let itemRef = useObjectRef(ref); + let {parentMenuRef, shouldUseVirtualFocus} = useContext(SubmenuTriggerContext)!; + let {submenuTriggerProps, submenuProps, popoverProps} = useSubmenuTrigger({ + parentMenuRef, + submenuRef, + // one of the only distinguishing factors from the submenuTrigger implementation + type: 'dialog', + isDisabled: !isUnavailable, + shouldUseVirtualFocus + }, submenuTriggerState, itemRef); + + return ( + + + {props.children[1]} + + ); +}, props => props.children[0]); + export interface MenuRenderProps { /** * Whether the menu has no items and should display its empty state. @@ -395,7 +465,13 @@ export interface MenuItemRenderProps extends ItemRenderProps { * * @selector [data-open] */ - isOpen: boolean + isOpen: boolean, + /** + * Whether the item is an unavailable menu item. + * + * @selector [data-unavailable] + */ + isUnavailable?: boolean } export interface MenuItemProps extends Omit, 'render'>, PossibleLinkDOMRenderProps<'div', MenuItemRenderProps>, LinkDOMProps, HoverEvents, FocusEvents, PressEvents, Omit, 'onClick'> { @@ -420,20 +496,26 @@ export interface MenuItemProps extends Omit>(null); +interface MenuItemContextProps extends MenuItemProps { + /** Whether the item is an unavailable menu item. */ + isUnavailable?: boolean +} + +const MenuItemContext = createContext>(null); /** * A MenuItem represents an individual action in a Menu. */ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function MenuItem(props: MenuItemProps, forwardedRef: ForwardedRef, item: Node) { - [props, forwardedRef] = useContextProps(props, forwardedRef, MenuItemContext); + let [mergedProps, mergedRef] = useContextProps(props, forwardedRef, MenuItemContext) as [MenuItemContextProps, ForwardedRef]; + forwardedRef = mergedRef; let id = useSlottedContext(MenuItemContext)?.id as string; let state = useContext(MenuStateContext)!; let ref = useObjectRef(forwardedRef); let selectionManager = useContext(SelectionManagerContext)!; let {isVirtualized} = useContext(CollectionRendererContext); let {menuItemProps, labelProps, descriptionProps, keyboardShortcutProps, ...states} = useMenuItem({ - ...props, + ...mergedProps, id, key: item.key, selectionManager, @@ -444,7 +526,7 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function Men isDisabled: states.isDisabled }); let renderProps = useRenderProps({ - ...props, + ...mergedProps, id: undefined, children: item.rendered, defaultClassName: 'react-aria-MenuItem', @@ -454,13 +536,14 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function Men isFocusVisible: states.isFocusVisible, selectionMode: selectionManager.selectionMode, selectionBehavior: selectionManager.selectionBehavior, - hasSubmenu: !!props['aria-haspopup'], - isOpen: props['aria-expanded'] === 'true' + hasSubmenu: !!mergedProps['aria-haspopup'], + isOpen: mergedProps['aria-expanded'] === 'true', + isUnavailable: mergedProps.isUnavailable } }); - let ElementType = props.href ? dom.a : dom.div; - let DOMProps = filterDOMProps(props as any, {global: true}); + let ElementType = mergedProps.href ? dom.a : dom.div; + let DOMProps = filterDOMProps(mergedProps as any, {global: true}); delete DOMProps.id; delete DOMProps.onClick; @@ -475,8 +558,9 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function Men data-pressed={states.isPressed || undefined} data-selected={states.isSelected || undefined} data-selection-mode={selectionManager.selectionMode === 'none' ? undefined : selectionManager.selectionMode} - data-has-submenu={!!props['aria-haspopup'] || undefined} - data-open={props['aria-expanded'] === 'true' || undefined}> + data-has-submenu={!!mergedProps['aria-haspopup'] || undefined} + data-open={mergedProps['aria-expanded'] === 'true' || undefined} + data-unavailable={mergedProps.isUnavailable || undefined}> (null); let containerRef = useRef(null); let groupCtx = useContext(PopoverGroupContext); - let isSubPopover = groupCtx && props.trigger === 'SubmenuTrigger'; + let isSubPopover = groupCtx && (props.trigger === 'SubmenuTrigger' || props.trigger === 'UnavailableMenuItemTrigger'); let {popoverProps, underlayProps, arrowProps, placement, triggerAnchorPoint} = usePopover({ ...props, @@ -188,7 +188,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Automatically render Popover with role=dialog except when isNonModal is true, // or a dialog is already nested inside the popover. - let shouldBeDialog = !props.isNonModal || props.trigger === 'SubmenuTrigger'; + let shouldBeDialog = !props.isNonModal || props.trigger === 'SubmenuTrigger' || props.trigger === 'UnavailableMenuItemTrigger'; let [isDialog, setDialog] = useState(false); useLayoutEffect(() => { if (ref.current) { diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 21cec9cb7c4..7096c19d59a 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -50,7 +50,7 @@ export {Keyboard, KeyboardContext} from './Keyboard'; export {Label, LabelContext} from './Label'; export {Link, LinkContext} from './Link'; export {ListBoxLoadMoreItem, ListBox, ListBoxItem, ListBoxSection, ListBoxContext, ListStateContext} from './ListBox'; -export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger} from './Menu'; +export {Menu, MenuItem, MenuTrigger, MenuSection, MenuContext, MenuStateContext, RootMenuTriggerStateContext, SubmenuTrigger, UnavailableMenuItemTrigger} from './Menu'; export {Meter, MeterContext} from './Meter'; export {Modal, ModalOverlay, ModalContext} from './Modal'; export {NumberField, NumberFieldContext, NumberFieldStateContext} from './NumberField'; @@ -118,7 +118,7 @@ export type {SectionProps, CollectionRenderer} from './Collection'; export type {LabelProps} from './Label'; export type {LinkProps, LinkRenderProps} from './Link'; export type {ListBoxProps, ListBoxRenderProps, ListBoxItemProps, ListBoxItemRenderProps, ListBoxSectionProps, ListBoxLoadMoreItemProps} from './ListBox'; -export type {MenuProps, MenuItemProps, MenuItemRenderProps, MenuTriggerProps, SubmenuTriggerProps, MenuSectionProps} from './Menu'; +export type {MenuProps, MenuItemProps, MenuItemRenderProps, MenuTriggerProps, SubmenuTriggerProps, UnavailableMenuItemTriggerProps, MenuSectionProps} from './Menu'; export type {MeterProps, MeterRenderProps} from './Meter'; export type {ModalOverlayProps, ModalRenderProps} from './Modal'; export type {NumberFieldProps, NumberFieldRenderProps} from './NumberField'; From 60eacd31fb7746f6e106166e1da96dc705c5d254 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 12 Feb 2026 17:00:11 -0800 Subject: [PATCH 02/11] add translations --- packages/@react-spectrum/s2/intl/ar-AE.json | 1 + packages/@react-spectrum/s2/intl/bg-BG.json | 1 + packages/@react-spectrum/s2/intl/cs-CZ.json | 1 + packages/@react-spectrum/s2/intl/da-DK.json | 1 + packages/@react-spectrum/s2/intl/de-DE.json | 1 + packages/@react-spectrum/s2/intl/el-GR.json | 1 + packages/@react-spectrum/s2/intl/en-US.json | 1 + packages/@react-spectrum/s2/intl/es-ES.json | 1 + packages/@react-spectrum/s2/intl/et-EE.json | 1 + packages/@react-spectrum/s2/intl/fi-FI.json | 1 + packages/@react-spectrum/s2/intl/fr-FR.json | 1 + packages/@react-spectrum/s2/intl/he-IL.json | 1 + packages/@react-spectrum/s2/intl/hr-HR.json | 1 + packages/@react-spectrum/s2/intl/hu-HU.json | 1 + packages/@react-spectrum/s2/intl/it-IT.json | 1 + packages/@react-spectrum/s2/intl/ja-JP.json | 1 + packages/@react-spectrum/s2/intl/ko-KR.json | 1 + packages/@react-spectrum/s2/intl/lt-LT.json | 1 + packages/@react-spectrum/s2/intl/lv-LV.json | 1 + packages/@react-spectrum/s2/intl/nb-NO.json | 1 + packages/@react-spectrum/s2/intl/nl-NL.json | 1 + packages/@react-spectrum/s2/intl/pl-PL.json | 1 + packages/@react-spectrum/s2/intl/pt-BR.json | 1 + packages/@react-spectrum/s2/intl/pt-PT.json | 1 + packages/@react-spectrum/s2/intl/ro-RO.json | 1 + packages/@react-spectrum/s2/intl/ru-RU.json | 1 + packages/@react-spectrum/s2/intl/sk-SK.json | 1 + packages/@react-spectrum/s2/intl/sl-SI.json | 1 + packages/@react-spectrum/s2/intl/sr-SP.json | 1 + packages/@react-spectrum/s2/intl/sv-SE.json | 1 + packages/@react-spectrum/s2/intl/tr-TR.json | 1 + packages/@react-spectrum/s2/intl/uk-UA.json | 1 + packages/@react-spectrum/s2/intl/zh-CN.json | 1 + packages/@react-spectrum/s2/intl/zh-TW.json | 1 + packages/@react-spectrum/s2/src/Menu.tsx | 8 ++++++-- 35 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/intl/ar-AE.json b/packages/@react-spectrum/s2/intl/ar-AE.json index 8545d91acfd..a66391647e6 100644 --- a/packages/@react-spectrum/s2/intl/ar-AE.json +++ b/packages/@react-spectrum/s2/intl/ar-AE.json @@ -23,6 +23,7 @@ "label.(optional)": "(اختياري)", "label.(required)": "(مطلوب)", "menu.moreActions": "المزيد من الإجراءات", + "menu.unavailable": "غير مُتوفر، قُم بالتوسيع للحصول على التفاصيل", "notificationbadge.indicatorOnly": "نشاط جديد", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "تحديد…", diff --git a/packages/@react-spectrum/s2/intl/bg-BG.json b/packages/@react-spectrum/s2/intl/bg-BG.json index 02ecdbc3019..c70ca77f057 100644 --- a/packages/@react-spectrum/s2/intl/bg-BG.json +++ b/packages/@react-spectrum/s2/intl/bg-BG.json @@ -23,6 +23,7 @@ "label.(optional)": "(незадължително)", "label.(required)": "(задължително)", "menu.moreActions": "Повече действия", + "menu.unavailable": "Недостъпно, разгънете за подробности", "notificationbadge.indicatorOnly": "Нова дейност", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Изберете…", diff --git a/packages/@react-spectrum/s2/intl/cs-CZ.json b/packages/@react-spectrum/s2/intl/cs-CZ.json index daa436a2556..60ccac47a4c 100644 --- a/packages/@react-spectrum/s2/intl/cs-CZ.json +++ b/packages/@react-spectrum/s2/intl/cs-CZ.json @@ -23,6 +23,7 @@ "label.(optional)": "(volitelně)", "label.(required)": "(požadováno)", "menu.moreActions": "Další akce", + "menu.unavailable": "Není k dispozici, rozbalením zobrazíte podrobnosti", "notificationbadge.indicatorOnly": "Nová aktivita", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Vybrat…", diff --git a/packages/@react-spectrum/s2/intl/da-DK.json b/packages/@react-spectrum/s2/intl/da-DK.json index e6f272123a3..005336329b0 100644 --- a/packages/@react-spectrum/s2/intl/da-DK.json +++ b/packages/@react-spectrum/s2/intl/da-DK.json @@ -23,6 +23,7 @@ "label.(optional)": "(valgfrit)", "label.(required)": "(obligatorisk)", "menu.moreActions": "Flere handlinger", + "menu.unavailable": "Ikke tilgængelig, udvid for detaljer", "notificationbadge.indicatorOnly": "Ny aktivitet", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Vælg…", diff --git a/packages/@react-spectrum/s2/intl/de-DE.json b/packages/@react-spectrum/s2/intl/de-DE.json index 7256c4790e7..8e696210662 100644 --- a/packages/@react-spectrum/s2/intl/de-DE.json +++ b/packages/@react-spectrum/s2/intl/de-DE.json @@ -23,6 +23,7 @@ "label.(optional)": "(optional)", "label.(required)": "(erforderlich)", "menu.moreActions": "Mehr Aktionen", + "menu.unavailable": "Nicht verfügbar, für Details erweitern", "notificationbadge.indicatorOnly": "Neue Aktivität", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Auswählen…", diff --git a/packages/@react-spectrum/s2/intl/el-GR.json b/packages/@react-spectrum/s2/intl/el-GR.json index 90a98fb862e..f4f7d60e37a 100644 --- a/packages/@react-spectrum/s2/intl/el-GR.json +++ b/packages/@react-spectrum/s2/intl/el-GR.json @@ -23,6 +23,7 @@ "label.(optional)": "(προαιρετικό)", "label.(required)": "(απαιτείται)", "menu.moreActions": "Περισσότερες ενέργειες", + "menu.unavailable": "Μη διαθέσιμο, ανάπτυξη για λεπτομέρειες", "notificationbadge.indicatorOnly": "Νέα δραστηριότητα", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Επιλογή…", diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index 01be6111c20..c8375745930 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -23,6 +23,7 @@ "label.(optional)": "(optional)", "label.(required)": "(required)", "menu.moreActions": "More actions", + "menu.unavailable": "Unavailable, expand for details", "notificationbadge.indicatorOnly": "New activity", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Select…", diff --git a/packages/@react-spectrum/s2/intl/es-ES.json b/packages/@react-spectrum/s2/intl/es-ES.json index feffd69320b..6b6551ee497 100644 --- a/packages/@react-spectrum/s2/intl/es-ES.json +++ b/packages/@react-spectrum/s2/intl/es-ES.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcional)", "label.(required)": "(obligatorio)", "menu.moreActions": "Más acciones", + "menu.unavailable": "No disponible, ampliar para más detalles", "notificationbadge.indicatorOnly": "Nueva actividad", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Seleccione…", diff --git a/packages/@react-spectrum/s2/intl/et-EE.json b/packages/@react-spectrum/s2/intl/et-EE.json index 3ee0a81aa6e..a9ac34575f6 100644 --- a/packages/@react-spectrum/s2/intl/et-EE.json +++ b/packages/@react-spectrum/s2/intl/et-EE.json @@ -23,6 +23,7 @@ "label.(optional)": "(valikuline)", "label.(required)": "(nõutav)", "menu.moreActions": "Veel toiminguid", + "menu.unavailable": "Pole kättesaadav, üksikasjade vaatamiseks laiendage", "notificationbadge.indicatorOnly": "Uus tegevus", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Valige…", diff --git a/packages/@react-spectrum/s2/intl/fi-FI.json b/packages/@react-spectrum/s2/intl/fi-FI.json index e541ada653f..2abfc9a4c84 100644 --- a/packages/@react-spectrum/s2/intl/fi-FI.json +++ b/packages/@react-spectrum/s2/intl/fi-FI.json @@ -23,6 +23,7 @@ "label.(optional)": "(valinnainen)", "label.(required)": "(pakollinen)", "menu.moreActions": "Lisää toimintoja", + "menu.unavailable": "Ei saatavilla, laajenna saadaksesi lisätietoja", "notificationbadge.indicatorOnly": "Uusi toiminta", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Valitse…", diff --git a/packages/@react-spectrum/s2/intl/fr-FR.json b/packages/@react-spectrum/s2/intl/fr-FR.json index e298543e539..afef2ad5752 100644 --- a/packages/@react-spectrum/s2/intl/fr-FR.json +++ b/packages/@react-spectrum/s2/intl/fr-FR.json @@ -23,6 +23,7 @@ "label.(optional)": "(facultatif)", "label.(required)": "(requis)", "menu.moreActions": "Autres actions", + "menu.unavailable": "Indisponible, développer pour plus de détails", "notificationbadge.indicatorOnly": "Nouvelle activité", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Sélectionner…", diff --git a/packages/@react-spectrum/s2/intl/he-IL.json b/packages/@react-spectrum/s2/intl/he-IL.json index cac33237926..9fe25ac115b 100644 --- a/packages/@react-spectrum/s2/intl/he-IL.json +++ b/packages/@react-spectrum/s2/intl/he-IL.json @@ -23,6 +23,7 @@ "label.(optional)": "(אופציונלי)", "label.(required)": "(נדרש)", "menu.moreActions": "פעולות נוספות", + "menu.unavailable": "לא זמין, הרחב לפרטים", "notificationbadge.indicatorOnly": "פעילות חדשה", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "בחר…", diff --git a/packages/@react-spectrum/s2/intl/hr-HR.json b/packages/@react-spectrum/s2/intl/hr-HR.json index d785dc1390b..c566c400924 100644 --- a/packages/@react-spectrum/s2/intl/hr-HR.json +++ b/packages/@react-spectrum/s2/intl/hr-HR.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcionalno)", "label.(required)": "(obvezno)", "menu.moreActions": "Dodatne radnje", + "menu.unavailable": "Nije dostupno, proširi za detalje", "notificationbadge.indicatorOnly": "Nova aktivnost", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Odaberite…", diff --git a/packages/@react-spectrum/s2/intl/hu-HU.json b/packages/@react-spectrum/s2/intl/hu-HU.json index 6d826f1259e..f82e54bec92 100644 --- a/packages/@react-spectrum/s2/intl/hu-HU.json +++ b/packages/@react-spectrum/s2/intl/hu-HU.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcionális)", "label.(required)": "(kötelező)", "menu.moreActions": "További lehetőségek", + "menu.unavailable": "Nem érhető el, a részletekért bontsa ki", "notificationbadge.indicatorOnly": "Új tevékenység", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Kiválasztás…", diff --git a/packages/@react-spectrum/s2/intl/it-IT.json b/packages/@react-spectrum/s2/intl/it-IT.json index 87cb218475e..a66e379f5af 100644 --- a/packages/@react-spectrum/s2/intl/it-IT.json +++ b/packages/@react-spectrum/s2/intl/it-IT.json @@ -23,6 +23,7 @@ "label.(optional)": "(facoltativo)", "label.(required)": "(obbligatorio)", "menu.moreActions": "Altre azioni", + "menu.unavailable": "Non disponibile, espandi per i dettagli", "notificationbadge.indicatorOnly": "Nuova attività", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Seleziona…", diff --git a/packages/@react-spectrum/s2/intl/ja-JP.json b/packages/@react-spectrum/s2/intl/ja-JP.json index 4f7c3e0e6e2..bb06130fef8 100644 --- a/packages/@react-spectrum/s2/intl/ja-JP.json +++ b/packages/@react-spectrum/s2/intl/ja-JP.json @@ -23,6 +23,7 @@ "label.(optional)": "(オプション)", "label.(required)": "(必須)", "menu.moreActions": "その他のアクション", + "menu.unavailable": "利用できません。詳しくは、展開して確認してください", "notificationbadge.indicatorOnly": "新規アクティビティ", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "選択…", diff --git a/packages/@react-spectrum/s2/intl/ko-KR.json b/packages/@react-spectrum/s2/intl/ko-KR.json index 2d5e859ba29..e010ac6591c 100644 --- a/packages/@react-spectrum/s2/intl/ko-KR.json +++ b/packages/@react-spectrum/s2/intl/ko-KR.json @@ -23,6 +23,7 @@ "label.(optional)": "(선택 사항)", "label.(required)": "(필수 사항)", "menu.moreActions": "기타 액션", + "menu.unavailable": "사용할 수 없음, 자세히 보려면 펼치기", "notificationbadge.indicatorOnly": "새로운 활동", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "선택…", diff --git a/packages/@react-spectrum/s2/intl/lt-LT.json b/packages/@react-spectrum/s2/intl/lt-LT.json index 7429827c18b..e52c74583a6 100644 --- a/packages/@react-spectrum/s2/intl/lt-LT.json +++ b/packages/@react-spectrum/s2/intl/lt-LT.json @@ -23,6 +23,7 @@ "label.(optional)": "(pasirenkama)", "label.(required)": "(privaloma)", "menu.moreActions": "Daugiau veiksmų", + "menu.unavailable": "Nepasiekiama, norėdami gauti daugiau informacijos, išskleiskite", "notificationbadge.indicatorOnly": "Nauja veikla", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Pasirinkite…", diff --git a/packages/@react-spectrum/s2/intl/lv-LV.json b/packages/@react-spectrum/s2/intl/lv-LV.json index d8ebbec139d..389ea6f8b33 100644 --- a/packages/@react-spectrum/s2/intl/lv-LV.json +++ b/packages/@react-spectrum/s2/intl/lv-LV.json @@ -23,6 +23,7 @@ "label.(optional)": "(neobligāti)", "label.(required)": "(obligāti)", "menu.moreActions": "Citas darbības", + "menu.unavailable": "Nav pieejams, izvērsiet, lai skatītu sīkāku informāciju", "notificationbadge.indicatorOnly": "Jauna aktivitāte", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Izvēlēties…", diff --git a/packages/@react-spectrum/s2/intl/nb-NO.json b/packages/@react-spectrum/s2/intl/nb-NO.json index 22f1735a94a..d53f0d8aa59 100644 --- a/packages/@react-spectrum/s2/intl/nb-NO.json +++ b/packages/@react-spectrum/s2/intl/nb-NO.json @@ -23,6 +23,7 @@ "label.(optional)": "(valgfritt)", "label.(required)": "(obligatorisk)", "menu.moreActions": "Flere handlinger", + "menu.unavailable": "Utilgjengelig, utvid for detaljer", "notificationbadge.indicatorOnly": "Ny aktivitet", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Velg …", diff --git a/packages/@react-spectrum/s2/intl/nl-NL.json b/packages/@react-spectrum/s2/intl/nl-NL.json index ecf1ac9e266..a861126de64 100644 --- a/packages/@react-spectrum/s2/intl/nl-NL.json +++ b/packages/@react-spectrum/s2/intl/nl-NL.json @@ -23,6 +23,7 @@ "label.(optional)": "(optioneel)", "label.(required)": "(vereist)", "menu.moreActions": "Meer handelingen", + "menu.unavailable": "Niet beschikbaar, uitvouwen voor meer informatie", "notificationbadge.indicatorOnly": "Nieuwe activiteit", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selecteren…", diff --git a/packages/@react-spectrum/s2/intl/pl-PL.json b/packages/@react-spectrum/s2/intl/pl-PL.json index f32cae48324..14104c6cbbc 100644 --- a/packages/@react-spectrum/s2/intl/pl-PL.json +++ b/packages/@react-spectrum/s2/intl/pl-PL.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcjonalne)", "label.(required)": "(wymagane)", "menu.moreActions": "Więcej akcji", + "menu.unavailable": "Niedostępne, rozwiń, aby zobaczyć szczegóły", "notificationbadge.indicatorOnly": "Nowa aktywność", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Zaznacz…", diff --git a/packages/@react-spectrum/s2/intl/pt-BR.json b/packages/@react-spectrum/s2/intl/pt-BR.json index cad34acb6ba..b9f826287db 100644 --- a/packages/@react-spectrum/s2/intl/pt-BR.json +++ b/packages/@react-spectrum/s2/intl/pt-BR.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcional)", "label.(required)": "(obrigatório)", "menu.moreActions": "Mais ações", + "menu.unavailable": "Indisponível. Expanda para ver os detalhes", "notificationbadge.indicatorOnly": "Nova atividade", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selecionar…", diff --git a/packages/@react-spectrum/s2/intl/pt-PT.json b/packages/@react-spectrum/s2/intl/pt-PT.json index 5d688a911fe..bb6acd6c981 100644 --- a/packages/@react-spectrum/s2/intl/pt-PT.json +++ b/packages/@react-spectrum/s2/intl/pt-PT.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcional)", "label.(required)": "(obrigatório)", "menu.moreActions": "Mais ações", + "menu.unavailable": "Indisponível, expandir para mais detalhes", "notificationbadge.indicatorOnly": "Nova atividade", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selecionar…", diff --git a/packages/@react-spectrum/s2/intl/ro-RO.json b/packages/@react-spectrum/s2/intl/ro-RO.json index 2ac95333790..050df91e413 100644 --- a/packages/@react-spectrum/s2/intl/ro-RO.json +++ b/packages/@react-spectrum/s2/intl/ro-RO.json @@ -23,6 +23,7 @@ "label.(optional)": "(opţional)", "label.(required)": "(obligatoriu)", "menu.moreActions": "Mai multe acțiuni", + "menu.unavailable": "Indisponibil, extindeți pentru detalii", "notificationbadge.indicatorOnly": "Activitate nouă", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selectați…", diff --git a/packages/@react-spectrum/s2/intl/ru-RU.json b/packages/@react-spectrum/s2/intl/ru-RU.json index e6548526d1a..cfb6c4d1ded 100644 --- a/packages/@react-spectrum/s2/intl/ru-RU.json +++ b/packages/@react-spectrum/s2/intl/ru-RU.json @@ -23,6 +23,7 @@ "label.(optional)": "(дополнительно)", "label.(required)": "(обязательно)", "menu.moreActions": "Дополнительные действия", + "menu.unavailable": "Недоступно, разверните для подробностей", "notificationbadge.indicatorOnly": "Новая активность", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Выбрать…", diff --git a/packages/@react-spectrum/s2/intl/sk-SK.json b/packages/@react-spectrum/s2/intl/sk-SK.json index 001ccfea64d..a29590ac115 100644 --- a/packages/@react-spectrum/s2/intl/sk-SK.json +++ b/packages/@react-spectrum/s2/intl/sk-SK.json @@ -23,6 +23,7 @@ "label.(optional)": "(nepovinné)", "label.(required)": "(povinné)", "menu.moreActions": "Ďalšie akcie", + "menu.unavailable": "Nedostupné, rozbaľte podrobnosti", "notificationbadge.indicatorOnly": "Nová aktivita", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Vybrať…", diff --git a/packages/@react-spectrum/s2/intl/sl-SI.json b/packages/@react-spectrum/s2/intl/sl-SI.json index f48cd9f05fa..39656e853b5 100644 --- a/packages/@react-spectrum/s2/intl/sl-SI.json +++ b/packages/@react-spectrum/s2/intl/sl-SI.json @@ -23,6 +23,7 @@ "label.(optional)": "(opcijsko)", "label.(required)": "(obvezno)", "menu.moreActions": "Več možnosti", + "menu.unavailable": "Ni na voljo, razširite za podrobnosti", "notificationbadge.indicatorOnly": "Nova dejavnost", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Izberite…", diff --git a/packages/@react-spectrum/s2/intl/sr-SP.json b/packages/@react-spectrum/s2/intl/sr-SP.json index 478e13e07df..d6e89eb94fb 100644 --- a/packages/@react-spectrum/s2/intl/sr-SP.json +++ b/packages/@react-spectrum/s2/intl/sr-SP.json @@ -23,6 +23,7 @@ "label.(optional)": "(opciono)", "label.(required)": "(obavezno)", "menu.moreActions": "Dodatne radnje", + "menu.unavailable": "Nije dostupno, proširite za detalje", "notificationbadge.indicatorOnly": "Nova aktivnost", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Izaberite...", diff --git a/packages/@react-spectrum/s2/intl/sv-SE.json b/packages/@react-spectrum/s2/intl/sv-SE.json index 2caf584ba88..12026081606 100644 --- a/packages/@react-spectrum/s2/intl/sv-SE.json +++ b/packages/@react-spectrum/s2/intl/sv-SE.json @@ -23,6 +23,7 @@ "label.(optional)": "(valfritt)", "label.(required)": "(krävs)", "menu.moreActions": "Fler åtgärder", + "menu.unavailable": "Ej tillgänglig, expandera för mer information", "notificationbadge.indicatorOnly": "Ny aktivitet", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Välj…", diff --git a/packages/@react-spectrum/s2/intl/tr-TR.json b/packages/@react-spectrum/s2/intl/tr-TR.json index cbb84153287..ee8f9b014a6 100644 --- a/packages/@react-spectrum/s2/intl/tr-TR.json +++ b/packages/@react-spectrum/s2/intl/tr-TR.json @@ -23,6 +23,7 @@ "label.(optional)": "(isteğe bağlı)", "label.(required)": "(gerekli)", "menu.moreActions": "Daha fazla eylem", + "menu.unavailable": "Kullanılamıyor, ayrıntıları görmek için genişletin", "notificationbadge.indicatorOnly": "Yeni etkinlik", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Seçin…", diff --git a/packages/@react-spectrum/s2/intl/uk-UA.json b/packages/@react-spectrum/s2/intl/uk-UA.json index ff7025bd7da..1446a24e72e 100644 --- a/packages/@react-spectrum/s2/intl/uk-UA.json +++ b/packages/@react-spectrum/s2/intl/uk-UA.json @@ -23,6 +23,7 @@ "label.(optional)": "(необов’язково)", "label.(required)": "(обов’язково)", "menu.moreActions": "Більше дій", + "menu.unavailable": "Недоступно, розгорніть для докладнішої інформації", "notificationbadge.indicatorOnly": "Нова активність", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Вибрати…", diff --git a/packages/@react-spectrum/s2/intl/zh-CN.json b/packages/@react-spectrum/s2/intl/zh-CN.json index 9c94e3a820a..d2d266cbc94 100644 --- a/packages/@react-spectrum/s2/intl/zh-CN.json +++ b/packages/@react-spectrum/s2/intl/zh-CN.json @@ -23,6 +23,7 @@ "label.(optional)": "(可选)", "label.(required)": "(必填)", "menu.moreActions": "更多操作", + "menu.unavailable": "不可用,展开以查看详细信息", "notificationbadge.indicatorOnly": "新活动", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "选择...", diff --git a/packages/@react-spectrum/s2/intl/zh-TW.json b/packages/@react-spectrum/s2/intl/zh-TW.json index 8496c287826..ed50a588af8 100644 --- a/packages/@react-spectrum/s2/intl/zh-TW.json +++ b/packages/@react-spectrum/s2/intl/zh-TW.json @@ -23,6 +23,7 @@ "label.(optional)": "(選填)", "label.(required)": "(必填)", "menu.moreActions": "更多動作", + "menu.unavailable": "無法使用,展開以取得詳細資料", "notificationbadge.indicatorOnly": "新活動", "notificationbadge.plus": "{notifications}+", "picker.placeholder": "選取…", diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 5c521c72a38..b2517cf9dbd 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -46,12 +46,15 @@ import {IconContext} from './Icon'; import {ImageContext} from './Image'; // chevron right removed?? import InfoCircleIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg'; import {InPopoverContext, Popover, PopoverContext} from './Popover'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import LinkOutIcon from '../ui-icons/LinkOut'; import {mergeStyles} from '../style/runtime'; import {Placement, useLocale} from 'react-aria'; import {PressResponder} from '@react-aria/interactions'; import {pressScale} from './pressScale'; import {useGlobalListeners} from '@react-aria/utils'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSpectrumContextProps} from './useSpectrumContextProps'; // viewbox on LinkOut is super weird just because i copied the icon from designs... // need to strip id's from icons @@ -484,6 +487,8 @@ export function MenuItem(props: MenuItemProps): ReactNode { let {size, hideLinkOutIcon} = useContext(InternalMenuContext); let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); let {direction} = useLocale(); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + return ( Date: Fri, 13 Feb 2026 11:58:58 -0800 Subject: [PATCH 03/11] add tests and stories --- .../s2/chromatic/Menu.stories.tsx | 18 ++++- .../@react-spectrum/s2/test/Menu.test.tsx | 63 +++++++++++++++++- .../react-aria-components/example/index.css | 6 ++ packages/react-aria-components/src/Menu.tsx | 2 +- .../stories/Menu.stories.tsx | 38 ++++++++++- .../react-aria-components/test/Menu.test.tsx | 65 ++++++++++++++++++- 6 files changed, 187 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx b/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx index 302189b148e..2ba77070626 100644 --- a/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {BlendModes, DynamicExample, Example, KeyboardShortcuts, PublishAndExport} from '../stories/Menu.stories'; +import {BlendModes, DynamicExample, Example, KeyboardShortcuts, PublishAndExport, UnavailableMenuItem} from '../stories/Menu.stories'; +import {expect} from '@storybook/jest'; import {Menu} from '../src'; import type {Meta, StoryObj} from '@storybook/react'; import {userEvent, within} from '@storybook/test'; @@ -56,3 +57,18 @@ export const Dynamic: Story = { ...DynamicExample, play: async (context) => await Default.play!(context) }; + +export const WithUnavailableItem: Story = { + ...UnavailableMenuItem, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowDown}'); + let body = canvasElement.ownerDocument.body; + await within(body).findByRole('menu'); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowRight}'); + let menus = await within(body).findAllByRole('dialog'); + expect(menus).toHaveLength(2); + } +}; diff --git a/packages/@react-spectrum/s2/test/Menu.test.tsx b/packages/@react-spectrum/s2/test/Menu.test.tsx index 32bfae1ee75..b37166c0d33 100644 --- a/packages/@react-spectrum/s2/test/Menu.test.tsx +++ b/packages/@react-spectrum/s2/test/Menu.test.tsx @@ -11,10 +11,12 @@ */ import {AriaMenuTests} from '../../../react-aria-components/test/AriaMenu.test-util'; -import {Button, Collection, Header, Heading, Menu, MenuItem, MenuSection, MenuTrigger, SubmenuTrigger} from '../src'; +import {Button, Collection, Header, Heading, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SubmenuTrigger, UnavailableMenuItemTrigger} from '../src'; +import {pointerMap} from '@react-aria/test-utils'; import React from 'react'; import {render} from '@react-spectrum/test-utils-internal'; import {Selection} from '@react-types/shared'; +import userEvent from '@testing-library/user-event'; // better to accept items from the test? or just have the test have a requirement that you render a certain-ish structure? // what about the button label? @@ -56,6 +58,65 @@ function SelectionStatic(props) { ); } +describe('Menu unavailable', () => { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + + it('should open popover if isUnavailable is true', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, findByText} = render( + + + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
+ ); + + await user.click(getByRole('button')); + let items = getAllByRole('menuitem'); + expect(items[0]).toHaveAttribute('data-unavailable'); + await user.click(items[0]); + expect(await findByText('Contact your administrator for permissions to delete.')).toBeInTheDocument(); + expect(onAction).not.toHaveBeenCalled(); + + }); + + it('should not open popover when isUnavailable is false and item acts as normal', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, queryByText} = render( + + + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
+ ); + + await user.click(getByRole('button')); + let items = getAllByRole('menuitem'); + expect(items[0]).not.toHaveAttribute('data-unavailable'); + await user.click(items[0]); + expect(onAction).toHaveBeenCalled(); + let menus = getAllByRole('dialog'); + expect(menus).toHaveLength(1); + expect(queryByText('Contact your administrator for permissions to delete.')).toBeNull(); + }); +}); + AriaMenuTests({ prefix: 'spectrum2-static', renderers: { diff --git a/packages/react-aria-components/example/index.css b/packages/react-aria-components/example/index.css index 11e2637ad97..766e7d0056b 100644 --- a/packages/react-aria-components/example/index.css +++ b/packages/react-aria-components/example/index.css @@ -159,6 +159,12 @@ html { justify-self: end; } +.item[data-unavailable]::after { + content: 'ⓘ'; + content: 'ⓘ' / ''; + justify-self: end; +} + .popover[data-trigger=SubmenuTrigger][data-placement="right"] { margin-left: -8px; } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 6d57fc276fe..bcc3ef1f181 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -558,7 +558,7 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function Men data-pressed={states.isPressed || undefined} data-selected={states.isSelected || undefined} data-selection-mode={selectionManager.selectionMode === 'none' ? undefined : selectionManager.selectionMode} - data-has-submenu={!!mergedProps['aria-haspopup'] || undefined} + data-has-submenu={mergedProps['aria-haspopup'] === 'menu' || undefined} data-open={mergedProps['aria-expanded'] === 'true' || undefined} data-unavailable={mergedProps.isUnavailable || undefined}> { ); }; + +export const UnavailableMenuItemExample: MenuStory = () => ( + + + + + Favorite + + Edit + +
+ Contact your administrator for permissions to edit this item. +
+
+
+ + Delete + +
+ Contact your administrator for permissions to delete this item. +
+
+
+ + Share + + + SMS + Email + + + +
+
+
+); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index b46ac184faf..6cbf3831275 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -12,7 +12,7 @@ import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaMenuTests} from './AriaMenu.test-util'; -import {Button, Collection, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField} from '..'; +import {Button, Collection, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField, UnavailableMenuItemTrigger} from '..'; import React, {useState} from 'react'; import {Selection, SelectionMode} from '@react-types/shared'; import {UNSAFE_PortalProvider} from '@react-aria/overlays'; @@ -1737,6 +1737,69 @@ describe('Menu', () => { expect(onPress).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1); }); + + describe('unavailable', () => { + it('should open popover when isUnavailable is true and item is activated', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, findByText} = render( + + + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
+ ); + + await user.click(getByRole('button')); + let items = getAllByRole('menuitem'); + expect(items[0]).toHaveAttribute('data-unavailable'); + expect(items[0]).not.toHaveAttribute('data-has-submenu'); + + await user.click(items[0]); + expect(await findByText('Contact your administrator for permissions to delete.')).toBeInTheDocument(); + expect(onAction).not.toHaveBeenCalled(); + + // Make sure the dialog behavior instead of submenu behavior is being applied here + let dialog = getByRole('dialog'); + expect(document.activeElement).toBe(dialog); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(dialog); + await user.keyboard('{Escape}'); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(items[0]); + }); + + it('should not open popover when isUnavailable is false and item acts as normal', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, queryByText} = render( + + + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
+ ); + + await user.click(getByRole('button')); + let items = getAllByRole('menuitem'); + expect(items[0]).not.toHaveAttribute('data-unavailable'); + await user.click(items[0]); + expect(onAction).toHaveBeenCalled(); + let menus = getAllByRole('menu'); + expect(menus).toHaveLength(1); + expect(queryByText('Contact your administrator for permissions to delete.')).toBeNull(); + }); + }); }); // better to accept items from the test? or just have the test have a requirement that you render a certain-ish structure? From 6f63c72a6a4664dc5447fa0f91568a216c83b2fe Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Feb 2026 12:37:34 -0800 Subject: [PATCH 04/11] dont propagate submenutrigger props if non unavailable --- .../@react-spectrum/s2/test/Menu.test.tsx | 6 +-- packages/react-aria-components/src/Menu.tsx | 7 ++- .../react-aria-components/test/Menu.test.tsx | 45 +++++++++++-------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/@react-spectrum/s2/test/Menu.test.tsx b/packages/@react-spectrum/s2/test/Menu.test.tsx index b37166c0d33..e4426cc2998 100644 --- a/packages/@react-spectrum/s2/test/Menu.test.tsx +++ b/packages/@react-spectrum/s2/test/Menu.test.tsx @@ -92,7 +92,7 @@ describe('Menu unavailable', () => { it('should not open popover when isUnavailable is false and item acts as normal', async () => { let onAction = jest.fn(); - let {getByRole, getAllByRole, queryByText} = render( + let {getByRole, getAllByRole, queryByText, queryAllByRole} = render( @@ -111,8 +111,8 @@ describe('Menu unavailable', () => { expect(items[0]).not.toHaveAttribute('data-unavailable'); await user.click(items[0]); expect(onAction).toHaveBeenCalled(); - let menus = getAllByRole('dialog'); - expect(menus).toHaveLength(1); + let menus = queryAllByRole('dialog'); + expect(menus).toHaveLength(0); expect(queryByText('Contact your administrator for permissions to delete.')).toBeNull(); }); }); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index bcc3ef1f181..80d8b2a4091 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -233,10 +233,15 @@ export const UnavailableMenuItemTrigger = /*#__PURE__*/ createBranchComponent(Un shouldUseVirtualFocus }, submenuTriggerState, itemRef); + // avoid passing non relevant aria attributes like aria-expanded + let menuItemContextValue = isUnavailable + ? {...submenuTriggerProps, isUnavailable, ref: itemRef} + : {isUnavailable, ref: itemRef}; + return ( { let {getByRole, getAllByRole, findByText} = render( - - - Delete - -
Contact your administrator for permissions to delete.
-
-
-
+ + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
); @@ -1776,27 +1778,32 @@ describe('Menu', () => { it('should not open popover when isUnavailable is false and item acts as normal', async () => { let onAction = jest.fn(); - let {getByRole, getAllByRole, queryByText} = render( + let {getByRole, getAllByRole, queryByText, queryAllByRole} = render( - - - Delete - -
Contact your administrator for permissions to delete.
-
-
-
+ + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
); await user.click(getByRole('button')); let items = getAllByRole('menuitem'); expect(items[0]).not.toHaveAttribute('data-unavailable'); + expect(items[0]).not.toHaveAttribute('data-has-submenu'); + expect(items[0]).not.toHaveAttribute('aria-haspopup'); await user.click(items[0]); expect(onAction).toHaveBeenCalled(); - let menus = getAllByRole('menu'); - expect(menus).toHaveLength(1); + act(() => {jest.runAllTimers();}); + let menus = queryAllByRole('menu'); + expect(menus).toHaveLength(0); expect(queryByText('Contact your administrator for permissions to delete.')).toBeNull(); }); }); From 8d51d5029e7cf5acdb6f28084a57d5db290e17cb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Feb 2026 13:07:50 -0800 Subject: [PATCH 05/11] fix test --- packages/react-aria-components/test/Menu.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 44af74da034..42d76526f86 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -1767,10 +1767,10 @@ describe('Menu', () => { expect(onAction).not.toHaveBeenCalled(); // Make sure the dialog behavior instead of submenu behavior is being applied here - let dialog = getByRole('dialog'); - expect(document.activeElement).toBe(dialog); + let dialogs = getAllByRole('dialog'); + expect(document.activeElement).toBe(dialogs[1]); await user.keyboard('{ArrowLeft}'); - expect(document.activeElement).toBe(dialog); + expect(document.activeElement).toBe(dialogs[1]); await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(items[0]); From ba3bbbbf5cd7ee22d68f7b81caacef8eb4388ecd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 17 Feb 2026 15:13:35 -0800 Subject: [PATCH 06/11] add docs for unavailable menu item --- packages/@react-spectrum/s2/src/Menu.tsx | 4 +- .../dev/s2-docs/pages/react-aria/Menu.mdx | 71 ++++++++++++++++--- packages/dev/s2-docs/pages/s2/Menu.mdx | 39 +++++++++- packages/react-aria-components/src/Menu.tsx | 2 +- starters/docs/src/Menu.css | 5 +- starters/docs/src/Menu.tsx | 7 +- starters/docs/src/Popover.tsx | 2 +- starters/tailwind/src/Menu.tsx | 9 ++- 8 files changed, 120 insertions(+), 19 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index b2517cf9dbd..6cd61794ab5 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -540,7 +540,7 @@ export function MenuItem(props: MenuItemProps): ReactNode { })({direction})} />
)} - {renderProps.hasSubmenu && renderProps.isUnavailable && ( + {renderProps.isUnavailable && (
{/* Need to avoid the icon context set above since that gets a marginEnd that will then propagate to InfoCircleIcon */} @@ -556,7 +556,7 @@ export function MenuItem(props: MenuItemProps): ReactNode {
)} - {renderProps.hasSubmenu && !renderProps.isUnavailable && ( + {renderProps.hasSubmenu && (
- - Delete… - ⌘⌫ - + + + + Delete… + + +
+ Contact your administrator for permissions to delete. +
+
+
@@ -84,8 +91,10 @@ export const description = 'Displays a list of actions or options that a user ca ```tsx render type="tailwind" files={["starters/tailwind/src/Menu.tsx"]} "use client"; import {MenuTrigger, SubmenuTrigger, Menu, MenuItem, MenuSection, MenuSeparator} from 'tailwind-starter/Menu'; + import {Popover} from 'tailwind-starter/Popover'; import {Button} from 'tailwind-starter/Button'; import {MoreHorizontal} from 'lucide-react'; + import {UnavailableMenuItemTrigger} from 'react-aria-components'; + alert(`Triggering ${action}`)}> + Favorite + + Delete + +
+ Contact your administrator for permissions to delete. +
+
+
+
+
+ ); +} +``` + ### Separators Separators may be added between menu items or sections in order to create non-labeled groupings. @@ -511,7 +558,7 @@ import {ChevronDown} from 'lucide-react'; -```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button', Popover: 'Popover', Menu: '#menu', MenuItem: '#menuitem', Separator: 'Separator', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger', SelectionIndicator: 'selection#animated-selectionindicator'}} +```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button', Popover: 'Popover', Menu: '#menu', MenuItem: '#menuitem', Separator: 'Separator', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger', UnavailableMenuItemTrigger: '#unavailablemenuitemtrigger', SelectionIndicator: 'selection#animated-selectionindicator'}}