Skip to content

feat: S2 unavailable menu item#9657

Merged
LFDanLu merged 26 commits intomainfrom
s2_unavailable_menu_item
Feb 27, 2026
Merged

feat: S2 unavailable menu item#9657
LFDanLu merged 26 commits intomainfrom
s2_unavailable_menu_item

Conversation

@LFDanLu
Copy link
Member

@LFDanLu LFDanLu commented Feb 13, 2026

Closes

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

Test both the S2 and RAC implementations in both storybook and docs.

🧢 Your Project:

RSP

@rspbot
Copy link

rspbot commented Feb 13, 2026

@rspbot
Copy link

rspbot commented Feb 17, 2026


### Unavailable menu items

Wrap a `<MenuItem>` and a `<Popover>` with a `<UnavailableMenuItemTrigger>` and apply `isUnavailable` to disable the item's default action and show an informational popover instead.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to have put the Popover directly into the example rather than create a UnavailableMenuItemTrigger wrapper in the starters since I felt it might be confusing to have Popover mentioned here but not show up in the example. Open to opinions though, not too bothered if we wanna go the same route as SubmenuTrigger and wrap the Popover in the starters

Comment on lines +655 to +658
// TODO: consider if we should instead pull the RAC defined trigger here into S2. Feels useful for RAC users though, but
// maybe shouldn't be specific to unavailable. It is very similar to SubmenuTrigger but it renders a dialog type behavior (see useSubmenuTrigger)
// and has the concept of isUnavailable which will make the item render normally when false
function UnavailableMenuItemTrigger(props: UnavailableMenuItemTriggerProps): JSX.Element {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnavailableMenuItemTrigger seems a bit specific in RAC honestly, open to opinions if there should be a more generic naming/component

@LFDanLu LFDanLu changed the title feat: (WIP) S2 unavailable menu item feat: S2 unavailable menu item Feb 17, 2026
@rspbot
Copy link

rspbot commented Feb 17, 2026

@LFDanLu LFDanLu marked this pull request as ready for review February 18, 2026 18:49
Comment on lines +36 to +39
// TODO: this is quite specific, but description and keyboardShortcut above are also very specific
/** Props for the descriptive element in the end slot inside the menu item (e.g. info icon, chevron). */
endSlotProps: DOMAttributes,

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm quite iffy about this, as stated above it is very specific. I think ideally we'd give the user a way to make arbitrary child elements in a MenuItem part of that MenuItem's aria-describedby. I could've opted to just allow the user to provide a generic aria-describedBy to the menu item and then apply ids to various other elements but we already have description and keyboard shortcut handled so figured we may be able to make end slot a thing too

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also note that due to how collection items work in RAC (aka UnavailableMenuItemTrigger in RAC is a collection branch that only renders the popover child as is), I believe that RAC level change is unavoidable since there isn't a good way to generate a id at the S2 UnavailableMenuItemTrigger level and propagate it down via context

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about allowing 'aria-describedby' as a prop and merging that with the ones we generate? Then this could be generated by S2 MenuItem and passed in. The only question would be in what order do we do the merge?

Copy link
Member Author

@LFDanLu LFDanLu Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can go this route instead, just felt potentially confusing to the end user that they'd be expected to pass aria-describedby for some things but that we'd handle doing this for such specific things like keyboard shortcut icon and description.

I think we'd should merge the user's provided value first. Is the concern here the announcement order or more so what would happen if the user provided their own ids to the keyboard icon/description and then used those in their aria-described?

@rspbot
Copy link

rspbot commented Feb 19, 2026

@rspbot
Copy link

rspbot commented Feb 20, 2026

reidbarber
reidbarber previously approved these changes Feb 20, 2026
Copy link
Member

@reidbarber reidbarber left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

… modality

this was for accessibility, ideally users should be moved into submenu/submenu dialogs unless it is hover
@rspbot
Copy link

rspbot commented Feb 26, 2026

@LFDanLu
Copy link
Member Author

LFDanLu commented Feb 26, 2026

}

interface SubmenuTriggerProps extends Omit<AriaMenuItemProps, 'key'> {
interface SubmenuTriggerProps extends Omit<AriaMenuItemProps, 'key' | 'onAction'> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically no longer needed since this only was a problem surfaced with the RAC UnavailableItem implementation but kept it for correctness. We may want to actually support firing onAction in a non-submenu case (aka if the overlay opened is more informational/tooltip like), but then we'd need to be able to tell between that case an the standard submenu case

Comment on lines +70 to +74
// needed so combobox/picker does not need to provide slot="title" to their provided
// ContextualHelp (they get the aria-labelled by from the button)
// otherwise, use the heading if available aka unavaiable menu item
[DEFAULT_SLOT]: {styles: headingStyles},
title: {id: titleId, styles: headingStyles}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit interesting, would it be more correct for users to provide the title slot in their ContextualHelp's? It falls back to associating with the trigger button at the moment

marginBottom: space(8) // This only makes it 10px on mobile and should be 12px
});

// TODO: docs to come after release, for now this is just mentioned in unavaiable menu docs
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will need to discuss how we'd want to document this as well as what use cases we'd like to officially support

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnavailableMenuItemTrigger is pretty long of a name lol, opted for wrapping

// Skip this for submenus since hovering a submenutrigger should keep focus on the trigger
useEffect(() => {
if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !isFocusWithin(ref.current)) {
if (isDialog && (props.trigger !== 'SubmenuTrigger' || getInteractionModality() !== 'pointer') && ref.current && !isFocusWithin(ref.current)) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to work from testing, will need to pay special attention to this in our next test session

@rspbot
Copy link

rspbot commented Feb 26, 2026

<Text slot="label">Delete…</Text>
<Keyboard>⌘⌫</Keyboard>
</MenuItem>
<UnavailableMenuItemTrigger isUnavailable>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to add this pattern to RAC, even in the examples? I thought it was pretty Spectrum-specific.

Copy link
Member Author

@LFDanLu LFDanLu Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured it would serve as a good common use example since otherwise I would just have a generic informational popover which felt too much like a tooltip

Comment on lines +253 to +254
Wrap a `<MenuItem>` and a `<ContextualHelpPopover>` with a `<UnavailableMenuItemTrigger>` and apply `isUnavailable` to disable the item's default action and show an informational popover instead.
The `<ContextualHelpPopover>` supports `<Heading slot="title">`, `<Content>`, and `<Footer>` slots.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit verbose IMO. I think we can let the example code speak for itself.

Suggested change
Wrap a `<MenuItem>` and a `<ContextualHelpPopover>` with a `<UnavailableMenuItemTrigger>` and apply `isUnavailable` to disable the item's default action and show an informational popover instead.
The `<ContextualHelpPopover>` supports `<Heading slot="title">`, `<Content>`, and `<Footer>` slots.
Wrap a `<MenuItem>` with an `<UnavailableMenuItemTrigger>` to disable the item's default action and show a [contextual help](ContextualHelp) popover instead.

</SubmenuTrigger>
<UnavailableMenuItemTrigger>
<MenuItem />
<Popover />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be ContextualHelpPopover now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoops good catch. I've decided to also omit ContextualHelpPopover from the API at the bottom until the API settles too.

@rspbot
Copy link

rspbot commented Feb 27, 2026

"label.(optional)": "(選填)",
"label.(required)": "(必填)",
"menu.moreActions": "更多動作",
"menu.unavailable": "無法使用,展開以取得詳細資料",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add this to the migrate intl script so that it isn't removed accidentally in the future if someone uses that to migrate more strings

@rspbot
Copy link

rspbot commented Feb 27, 2026

@rspbot
Copy link

rspbot commented Feb 27, 2026

## API Changes

@react-aria/menu

/@react-aria/menu:AriaMenuItemProps

 AriaMenuItemProps {
   aria-controls?: string
+  aria-describedby?: string
   aria-expanded?: boolean | 'true' | 'false'
   aria-haspopup?: 'menu' | 'dialog'
   aria-label?: string
   id?: string
   key: Key
   onBlur?: (FocusEvent<Target>) => void
   onClick?: (MouseEvent<FocusableElement>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   selectionManager?: SelectionManager
   shouldCloseOnSelect?: boolean
 }

@react-spectrum/s2

/@react-spectrum/s2:TreeView

 TreeView <T extends {}> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   items?: Iterable<T>
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onSelectionChange?: (Selection) => void
-  renderActionBar?: ('all' | Set<Key>) => ReactElement
   renderEmptyState?: (TreeEmptyStateRenderProps) => ReactNode
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:TreeViewProps

 TreeViewProps <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   items?: Iterable<T>
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onSelectionChange?: (Selection) => void
-  renderActionBar?: ('all' | Set<Key>) => ReactElement
   renderEmptyState?: (TreeEmptyStateRenderProps) => ReactNode
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:ContextualHelpPopover

+ContextualHelpPopover {
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children: ReactNode
+  containerPadding?: number = 12
+  crossOffset?: number = 0
+  hideArrow?: boolean = false
+  id?: string
+  isOpen?: boolean
+  offset?: number = 8
+  onOpenChange?: (boolean) => void
+  padding?: 'default' | 'none' = 'default'
+  placement?: Placement = 'bottom'
+  role?: 'dialog' | 'alertdialog' = 'dialog'
+  shouldFlip?: boolean = true
+  size?: 'S' | 'M' | 'L'
+  slot?: string | null
+  styles?: PopoverStylesProp
+  triggerRef?: RefObject<Element | null>
+}

/@react-spectrum/s2:UnavailableMenuItemTrigger

+UnavailableMenuItemTrigger {
+  children: Array<ReactElement>
+  isUnavailable?: boolean = false
+}

/@react-spectrum/s2:ContextualHelpProps

+ContextualHelpProps {
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children: ReactNode
+  containerPadding?: number = 12
+  crossOffset?: number = 0
+  defaultOpen?: boolean
+  id?: string
+  isOpen?: boolean
+  offset?: number = 8
+  onOpenChange?: (boolean) => void
+  placement?: Placement = 'bottom start'
+  shouldFlip?: boolean = true
+  size?: 'XS' | 'S' = 'XS'
+  styles?: StylesProp
+  variant?: 'info' | 'help' = 'help'
+}

/@react-spectrum/s2:ContextualHelpStyleProps

+ContextualHelpStyleProps {
+  variant?: 'info' | 'help' = 'help'
+}

/@react-spectrum/s2:ContextualHelpPopoverProps

+ContextualHelpPopoverProps {
+  UNSAFE_className?: UnsafeClassName
+  UNSAFE_style?: CSSProperties
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children: ReactNode
+  containerPadding?: number = 12
+  crossOffset?: number = 0
+  hideArrow?: boolean = false
+  id?: string
+  isOpen?: boolean
+  offset?: number = 8
+  onOpenChange?: (boolean) => void
+  padding?: 'default' | 'none' = 'default'
+  placement?: Placement = 'bottom'
+  role?: 'dialog' | 'alertdialog' = 'dialog'
+  shouldFlip?: boolean = true
+  size?: 'S' | 'M' | 'L'
+  slot?: string | null
+  styles?: PopoverStylesProp
+  triggerRef?: RefObject<Element | null>
+}

/@react-spectrum/s2:UnavailableMenuItemTriggerProps

+UnavailableMenuItemTriggerProps {
+  children: Array<ReactElement>
+  isUnavailable?: boolean = false
+}

@LFDanLu LFDanLu added this pull request to the merge queue Feb 27, 2026
Merged via the queue into main with commit 32c96fc Feb 27, 2026
29 checks passed
@LFDanLu LFDanLu deleted the s2_unavailable_menu_item branch February 27, 2026 23:49
pioug pushed a commit to pioug/react-spectrum that referenced this pull request Feb 28, 2026
…aseline-tracker

* origin/main:
  feat(S2): S2 ListView (adobe#8878)
  refactor: Centralize expandedKeys logic in TreeCollection (adobe#9711)
  chore: Warn if user has interactive elements in their custom Picker value (adobe#9710)
  feat: S2 unavailable menu item (adobe#9657)
  fix: Ensure that opening a submenu via enter/space moves focus to first item in submenu (adobe#9691)
  fix: prevent docs crash by making template elements always append children into .content (adobe#9703)
  docs(RAC): Add TreeSection docs (adobe#9699)
  docs(S2): add Typography search view (adobe#9524)
  docs(S2): fix clipping in Picker custom value AvatarGroup example (adobe#9702)
  fix: patch additional methods so React doesnt break with template elements (adobe#9385)
  tentative fix (adobe#9635)
  docs(S2): fix icon import clipboard content to add underscore for icons starting with number (adobe#9698)
  feat(S2): add ActionBar support to TreeView (adobe#9695)
  fix: combobox interactoutside (adobe#9646)
  fix: skip native Date fast path when local timezone is overridden via setLocalTimeZone (adobe#9678)
  chore: update storybook to 9 (adobe#8634)
  docs: improve custom render value S2 Picker example (adobe#9682)

# Conflicts:
#	yarn.lock
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants