From f3f957a7f514967614b3162bf33d67008e800e18 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 27 Feb 2026 15:39:17 -0800 Subject: [PATCH 1/4] fix: Ensure that opening a submenu via enter/space moves focus to first item in submenu (#9691) * fix: Ensure that opening a submenu via enter/space moves focus to first item in submenu * typo --- packages/@react-aria/menu/src/useMenuItem.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 231f2ae73f6..56831ae48b5 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -13,7 +13,7 @@ import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject} from '@react-types/shared'; import {filterDOMProps, getEventTarget, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; -import {isFocusVisible, useFocusable, useHover, useKeyboard, usePress} from '@react-aria/interactions'; +import {isFocusVisible, setInteractionModality, useFocusable, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; import {MouseEvent, useRef} from 'react'; import {SelectionManager} from '@react-stately/selection'; @@ -287,6 +287,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re case ' ': interaction.current = {pointerType: 'keyboard', key: ' '}; (getEventTarget(e) as HTMLElement).click(); + + // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus + // to the newly opened submenu's first item. + setInteractionModality('keyboard'); break; case 'Enter': interaction.current = {pointerType: 'keyboard', key: 'Enter'}; @@ -295,6 +299,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re if ((getEventTarget(e) as HTMLElement).tagName !== 'A') { (getEventTarget(e) as HTMLElement).click(); } + + // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus + // to the newly opened submenu's first item. + setInteractionModality('keyboard'); break; default: if (!isTrigger) { From 32c96fc50f5ab3bf71b275a7439997c1e807d91a Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 27 Feb 2026 15:39:39 -0800 Subject: [PATCH 2/4] feat: S2 unavailable menu item (#9657) * add unavailable menu item to RAC and S2 * add translations * add tests and stories * dont propagate submenutrigger props if non unavailable * fix test * add docs for unavailable menu item * fix docs lint * fix styling and copy * add endSlotProps so we can associate arbitrary elements with the menu item description * shift icon down 1px * adding alpha badge to RAC unavailable menu * adding code mod * fix lint and update todo copy * period * change to use aria-describedby via props * pull out ContextualHelpPopover and reuse submenuTrigger * update S2 menu tests, code mod, docs * fix styling and slot of contextual help * update examples, not sure why docs wont run * fix docs and automatically move focus into the Popover if not pointer modality this was for accessibility, ideally users should be moved into submenu/submenu dialogs unless it is hover * forgot one todo * review comments * add menu strings to intl script * remove RAC example --- packages/@react-aria/menu/src/useMenuItem.ts | 5 +- .../@react-aria/menu/src/useSubmenuTrigger.ts | 2 +- .../s2/chromatic/Menu.stories.tsx | 18 ++- 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 + .../@react-spectrum/s2/src/ContextualHelp.tsx | 120 ++++++++++++------ packages/@react-spectrum/s2/src/Menu.tsx | 116 ++++++++++++++--- packages/@react-spectrum/s2/src/index.ts | 7 +- .../s2/stories/Menu.stories.tsx | 50 +++++++- .../@react-spectrum/s2/test/Menu.test.tsx | 67 +++++++++- packages/dev/codemods/src/s1-to-s2/UPGRADE.md | 4 + .../__tests__/__snapshots__/menu.test.ts.snap | 29 +++++ .../src/s1-to-s2/__tests__/menu.test.ts | 21 ++- .../src/s1-to-s2/src/codemods/codemod.ts | 36 ++++-- .../ContextualHelpTrigger/transform.ts | 37 ++++++ .../src/codemods/components/Item/transform.ts | 1 + .../src/codemods/shared/transforms.ts | 4 +- packages/dev/s2-docs/pages/s2/Menu.mdx | 37 +++++- packages/dev/s2-docs/pages/s2/migrating.mdx | 5 + packages/dev/s2-docs/src/OptimisticToc.tsx | 10 +- .../react-aria-components/example/index.css | 14 +- .../react-aria-components/src/Popover.tsx | 4 +- .../stories/Menu.stories.tsx | 79 +++++++++++- .../react-aria-components/test/Menu.test.tsx | 66 ++++++++++ scripts/migrateIntl.mjs | 1 + 57 files changed, 682 insertions(+), 85 deletions(-) create mode 100644 packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 56831ae48b5..cb0dee4a184 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -97,6 +97,9 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K /** Identifies the menu item's popup element whose contents or presence is controlled by the menu item. */ 'aria-controls'?: string, + /** Identifies the element(s) that describe the menu item. */ + 'aria-describedby'?: string, + /** Override of the selection manager. By default, `state.selectionManager` is used. */ selectionManager?: SelectionManager } @@ -177,7 +180,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re role, 'aria-label': props['aria-label'], 'aria-labelledby': labelId, - 'aria-describedby': [descriptionId, keyboardId].filter(Boolean).join(' ') || undefined, + 'aria-describedby': [props['aria-describedby'], descriptionId, keyboardId].filter(Boolean).join(' ') || undefined, 'aria-controls': props['aria-controls'], 'aria-haspopup': hasPopup, 'aria-expanded': props['aria-expanded'] 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/chromatic/Menu.stories.tsx b/packages/@react-spectrum/s2/chromatic/Menu.stories.tsx index e8832eaf4c2..80a5e1ca1bd 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/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/ContextualHelp.tsx b/packages/@react-spectrum/s2/src/ContextualHelp.tsx index 7cd5a31f5e0..6d445704021 100644 --- a/packages/@react-spectrum/s2/src/ContextualHelp.tsx +++ b/packages/@react-spectrum/s2/src/ContextualHelp.tsx @@ -1,11 +1,11 @@ import {ActionButton} from './ActionButton'; import {AriaLabelingProps, DOMProps, FocusableRef, FocusableRefValue} from '@react-types/shared'; -import {ContentContext, FooterContext, HeadingContext} from './Content'; +import {ContentContext, FooterContext, HeadingContext, TextContext as SpectrumTextContext} from './Content'; import {ContextValue, DEFAULT_SLOT, Provider, Dialog as RACDialog, TextContext} from 'react-aria-components'; import {createContext, forwardRef, ReactNode} from 'react'; import {dialogInner} from './Dialog'; import {DialogTrigger, DialogTriggerProps} from './DialogTrigger'; -import {filterDOMProps, mergeProps, useLabels} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, useLabels, useSlotId} from '@react-aria/utils'; import HelpIcon from '../s2wf-icons/S2_Icon_HelpCircle_20_N.svg'; import InfoIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg'; // @ts-ignore @@ -18,6 +18,78 @@ import {StyleProps} from './style-utils' with { type: 'macro' }; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useSpectrumContextProps} from './useSpectrumContextProps'; +export interface ContextualHelpPopoverProps extends PopoverDialogProps { + /** + * The children of the contextual help popover. Supports Heading, Content, and Footer elements. */ + children: ReactNode +} + +const wrappingDiv = style({ + minWidth: 268, + width: 268, + padding: 24, + boxSizing: 'border-box', + height: 'full' +}); + +const headingStyles = style({ + font: 'heading-xs', + margin: 0, + 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 +/** + * A popover with contextual help styling that supports Heading, Content, and Footer. + */ +export function ContextualHelpPopover(props: ContextualHelpPopoverProps) { + let {children, ...popoverProps} = props; + let titleId = useSlotId(); + return ( + +
+ + + {children} + + +
+
+ ); +} + export interface ContextualHelpStyleProps { /** * Indicates whether contents are informative or provides helpful guidance. @@ -45,14 +117,6 @@ export interface ContextualHelpProps extends size?: 'XS' | 'S' } -const wrappingDiv = style({ - minWidth: 268, - width: 268, - padding: 24, - boxSizing: 'border-box', - height: 'full' -}); - export const ContextualHelpContext = createContext, FocusableRefValue>>(null); /** @@ -102,42 +166,14 @@ export const ContextualHelp = forwardRef(function ContextualHelp(props: Contextu isQuiet> {variant === 'info' ? : } - -
- - - {children} - - -
-
+ crossOffset={crossOffset}> + {children} + ); }); diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index bb0d8a9fe25..54e3772f1ee 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -34,21 +34,25 @@ import {centerBaseline} from './CenterBaseline'; import {centerPadding, control, controlFont, controlSize, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronRightIcon from '../ui-icons/Chevron'; -import {createContext, forwardRef, JSX, ReactNode, useContext, useRef, useState} from 'react'; +import {createContext, forwardRef, JSX, ReactElement, ReactNode, useContext, useRef, useState} from 'react'; import {divider} from './Divider'; import {DOMRef, DOMRefValue, GlobalDOMAttributes, PressEvent} from '@react-types/shared'; 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'; +// @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 {useGlobalListeners, useId} 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 @@ -317,6 +321,7 @@ let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ let descriptor = style({ gridArea: 'descriptor', + 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, @@ -331,6 +349,7 @@ let InternalMenuContext = createContext<{size: 'S' | 'M' | 'L' | 'XL', isSubmenu }); let InternalMenuTriggerContext = createContext | null>(null); +let UnavailableContext = createContext(false); let wrappingDiv = style({ display: 'flex', @@ -459,6 +478,33 @@ const linkIconSize = { XL: 'XL' } as const; +interface UnavailableIconWrapperProps { + direction: 'ltr' | 'rtl', + size: 'S' | 'M' | 'L' | 'XL', + id?: string +} + +function UnavailableIconWrapper(props: UnavailableIconWrapperProps) { + let {direction, size, id} = props; + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + + return ( +
+ + + +
+ ); +} + export function MenuItem(props: MenuItemProps): ReactNode { let ref = useRef(null); let isLink = props.href != null; @@ -466,9 +512,13 @@ 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 isUnavailable = useContext(UnavailableContext); + let infoIconId = useId(); + return ( )} {renderProps.hasSubmenu && ( -
- -
+ isUnavailable + ? + : ( +
+ +
+ ) )} @@ -611,7 +665,37 @@ function SubmenuTrigger(props: SubmenuTriggerProps): JSX.Element { ); } -export {MenuTrigger, SubmenuTrigger}; +export interface UnavailableMenuItemTriggerProps { + /** + * The contents of the UnavailableMenuItemTrigger. The first child should be a MenuItem and the second child be a ContextualHelpPopover. + */ + children: ReactElement[], + /** + * Whether the menu item is currently unavailable. + * @default false + */ + isUnavailable?: boolean +} + +function UnavailableMenuItemTrigger(props: UnavailableMenuItemTriggerProps): JSX.Element { + let {isUnavailable = false, children} = props; + if (isUnavailable) { + return ( + + + {children[0]} + + {children[1]} + + + + ); + } + + return children[0] as JSX.Element; +} + +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..e2e27f4655f 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -37,7 +37,7 @@ export {ColorSwatch, ColorSwatchContext} from './ColorSwatch'; export {ColorSwatchPicker, ColorSwatchPickerContext} from './ColorSwatchPicker'; export {ColorWheel, ColorWheelContext} from './ColorWheel'; export {ComboBox, ComboBoxItem, ComboBoxSection, ComboBoxContext} from './ComboBox'; -export {ContextualHelp, ContextualHelpContext} from './ContextualHelp'; +export {ContextualHelp, ContextualHelpContext, ContextualHelpPopover} from './ContextualHelp'; export {DateField, DateFieldContext} from './DateField'; export {DatePicker, DatePickerContext} from './DatePicker'; export {DateRangePicker, DateRangePickerContext} from './DateRangePicker'; @@ -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'; @@ -119,6 +119,7 @@ export type {ColorSwatchProps} from './ColorSwatch'; export type {ColorSwatchPickerProps} from './ColorSwatchPicker'; export type {ColorWheelProps} from './ColorWheel'; export type {ComboBoxProps, ComboBoxItemProps, ComboBoxSectionProps} from './ComboBox'; +export type {ContextualHelpProps, ContextualHelpStyleProps, ContextualHelpPopoverProps} from './ContextualHelp'; export type {DateFieldProps} from './DateField'; export type {DatePickerProps} from './DatePicker'; export type {DateRangePickerProps} from './DateRangePicker'; @@ -136,7 +137,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..1ba6e103ad9 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, Content, ContextualHelpPopover, Footer, Header, Heading, Image, Keyboard, Link, Menu, MenuItem, MenuProps, MenuSection, MenuTrigger, 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'; @@ -315,3 +315,51 @@ export const SelectionGroups: StoryObj = { layout: 'padded' } }; + +export const UnavailableMenuItem: Story = { + render: (args) => { + return ( + + + + Favorite + + Edit + + Permission Denied + + + Contact your administrator for permissions to edit this item. + + +
+ Learn more +
+
+
+ + Delete + + Permission Denied + + + Contact your administrator for permissions to delete this item. + + +
+ Learn more +
+
+
+ + Share + + SMS + Email + + +
+
+ ); + } +}; diff --git a/packages/@react-spectrum/s2/test/Menu.test.tsx b/packages/@react-spectrum/s2/test/Menu.test.tsx index 32bfae1ee75..134a997ad00 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, Content, ContextualHelpPopover, Header, Heading, Menu, MenuItem, MenuSection, MenuTrigger, 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,69 @@ 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 + + Permission required + Contact your administrator for permissions to delete. + + + + + ); + + await user.click(getByRole('button')); + let items = getAllByRole('menuitem'); + await user.click(items[0]); + expect(await findByText('Contact your administrator for permissions to delete.')).toBeInTheDocument(); + expect(onAction).not.toHaveBeenCalled(); + + let dialog = getAllByRole('dialog')[1]; + let title = await findByText('Permission required'); + expect(dialog).toHaveAttribute('aria-labelledby', title.id); + }); + + it('should not open popover when isUnavailable is false and item acts as normal', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, queryByText, queryAllByRole} = render( + + + + + Delete + + Permission required + 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 = queryAllByRole('dialog'); + expect(menus).toHaveLength(0); + expect(queryByText('Contact your administrator for permissions to delete.')).toBeNull(); + }); +}); + AriaMenuTests({ prefix: 'spectrum2-static', renderers: { diff --git a/packages/dev/codemods/src/s1-to-s2/UPGRADE.md b/packages/dev/codemods/src/s1-to-s2/UPGRADE.md index 56156f54447..b59cef21811 100644 --- a/packages/dev/codemods/src/s1-to-s2/UPGRADE.md +++ b/packages/dev/codemods/src/s1-to-s2/UPGRADE.md @@ -62,6 +62,10 @@ Note that `[PENDING]` indicates that future changes will occur before the final - Remove `validationState=“valid”` (it is no longer supported) - [PENDING] Comment out `onLoadMore` (it has not been implemented yet) +## ContextualHelpTrigger +- Component has been renamed to `UnavailableMenuItemTrigger` +- Replace `Dialog` with `ContextualHelpPopover`, and child elements with their S2 counterparts. + ## Dialog - Update children to move render props from being the second child of `DialogTrigger` to being a child of `Dialog` - Remove `onDismiss` and use `onOpenChange` on the `DialogTrigger`, or `onDismiss` on the `DialogContainer` instead diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap index 8d7b1b88efa..ad8c4f9bbbd 100644 --- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap +++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap @@ -151,6 +151,35 @@ const items = [ " `; +exports[`Renames ContextualHelpTrigger to UnavailableMenuItemTrigger and Dialog to ContextualHelpPopover 1`] = ` +"import { + UnavailableMenuItemTrigger, + ContextualHelpPopover, + MenuItem, + Menu, + MenuTrigger, + Button, + Dialog, + Heading, + Content, +} from "@react-spectrum/s2"; + + + + + Undo + Redo + + Cut + + Cut + Please select text for Cut to be enabled. + + + +" +`; + exports[`Static - Renames Item to MenuItem, Section to MenuSection 1`] = ` "import { MenuItem, diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/menu.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/menu.test.ts index 4c34fb025ba..489289df985 100644 --- a/packages/dev/codemods/src/s1-to-s2/__tests__/menu.test.ts +++ b/packages/dev/codemods/src/s1-to-s2/__tests__/menu.test.ts @@ -144,7 +144,7 @@ const items = [ {item.name} {(item) => renderSubmenu(item)} - + ) } else { @@ -198,3 +198,22 @@ const sections = [ `); + +test('Renames ContextualHelpTrigger to UnavailableMenuItemTrigger and Dialog to ContextualHelpPopover', ` +import {Menu, MenuTrigger, ContextualHelpTrigger, Item, Button, Dialog, Heading, Content} from '@adobe/react-spectrum'; + + + + + Undo + Redo + + Cut + + Cut + Please select text for Cut to be enabled. + + + + +`); diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts index b9e8d13c285..ce3467f356a 100644 --- a/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts +++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts @@ -27,6 +27,10 @@ availableComponents.delete('Provider'); // Replaced by ActionButtonGroup and ToggleButtonGroup availableComponents.add('ActionGroup'); +// components renamed between v3 and S2 +let renamedComponents: Record = { + ContextualHelpTrigger: 'UnavailableMenuItemTrigger' +}; interface Options { /** Comma separated list of components to transform. If not specified, all available components will be transformed. */ @@ -43,6 +47,8 @@ export default function transformer(file: FileInfo, api: API, options: Options): }); let root = j(file.source); let componentsToTransform = options.components ? new Set(options.components.split(',').filter(s => availableComponents.has(s))) : availableComponents; + let v3ComponentsToRename = new Set(Object.keys(renamedComponents)); + let S2ComponentsToImport = new Set(); let bindings: Binding[] = []; let importedComponents = new Map(); @@ -62,9 +68,17 @@ export default function transformer(file: FileInfo, api: API, options: Options): if (binding) { let isUsed = false; for (let path of binding.referencePaths) { - if (path.parentPath?.isJSXMemberExpression() && componentsToTransform.has(path.parentPath.node.property.name) && path.parentPath.parentPath.parentPath?.isJSXElement()) { - importedComponents.set(path.parentPath.node.property.name, clonedSpecifier); - elements.push([path.parentPath.node.property.name, path.parentPath.parentPath.parentPath]); + let propName = path.parentPath?.isJSXMemberExpression() && path.parentPath.node.property.name; + if (propName && path.parentPath!.parentPath?.parentPath?.isJSXElement()) { + if (componentsToTransform.has(propName)) { + importedComponents.set(propName, clonedSpecifier); + elements.push([propName, path.parentPath!.parentPath.parentPath]); + } else if (v3ComponentsToRename.has(propName)) { + S2ComponentsToImport.add(renamedComponents[propName]); + elements.push([propName, path.parentPath!.parentPath.parentPath]); + } else { + isUsed = true; + } } else { isUsed = true; } @@ -85,12 +99,16 @@ export default function transformer(file: FileInfo, api: API, options: Options): typeof specifier.local.name === 'string' && specifier.imported.type === 'Identifier' && typeof specifier.imported.name === 'string' && - componentsToTransform.has(specifier.imported.name) + (componentsToTransform.has(specifier.imported.name) || v3ComponentsToRename.has(specifier.imported.name)) ) { - // e.g. import {Button} from '@adobe/react-spectrum'; + // e.g. import {Button} from '@adobe/react-spectrum'; or import {ContextualHelpTrigger} from '@adobe/react-spectrum'; let binding = path.scope.getBinding(specifier.local.name); if (binding) { - importedComponents.set(specifier.imported.name, specifier); + if (componentsToTransform.has(specifier.imported.name)) { + importedComponents.set(specifier.imported.name, specifier); + } else { + S2ComponentsToImport.add(renamedComponents[specifier.imported.name]); + } bindings.push(binding); for (let path of binding.referencePaths) { if (path.parentPath?.isJSXOpeningElement() && path.parentPath.parentPath.isJSXElement()) { @@ -222,11 +240,14 @@ export default function transformer(file: FileInfo, api: API, options: Options): lastImportPath!.insertAfter(macroImport); } - if (importedComponents.size) { + if (importedComponents.size || S2ComponentsToImport.size) { // Add imports to existing @react-spectrum/s2 import if it exists, otherwise add a new one. let importSpecifiers = new Set([...importedComponents] .filter(([c]) => c !== 'Flex' && c !== 'Grid' && c !== 'View' && c !== 'Item' && c !== 'Section' && c !== 'ActionGroup') .map(([, specifier]) => specifier)); + for (let s2Name of S2ComponentsToImport) { + importSpecifiers.add(t.importSpecifier(t.identifier(s2Name), t.identifier(s2Name))); + } let existingImport = root.find(j.ImportDeclaration, { source: {value: '@react-spectrum/s2'} @@ -282,4 +303,3 @@ export default function transformer(file: FileInfo, api: API, options: Options): root.find(j.Program).get('body', 0).node.comments = leadingComments; return root.toSource().replace(/assert\s*\{\s*type:\s*"macro"\s*\}/g, 'with { type: "macro" }'); } - diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts new file mode 100644 index 00000000000..ba514b3a203 --- /dev/null +++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts @@ -0,0 +1,37 @@ +import {addComponentImport, getName} from '../../shared/utils'; +import {getComponents} from '../../../getComponents'; +import {NodePath} from '@babel/traverse'; +import * as t from '@babel/types'; + +let availableComponents = getComponents(); + +/** + * Transforms ContextualHelpTrigger: + * - Rename ContextualHelpTrigger to UnavailableMenuItemTrigger. + * - Replace the old Dialog with ContextualHelpPopover. + */ +export default function transformContextualHelpTrigger(path: NodePath): void { + let program = path.findParent((p) => t.isProgram(p.node)) as NodePath; + let localName = addComponentImport(program, 'UnavailableMenuItemTrigger'); + + // replace ContextualHelpTrigger with UnavailableMenuItemTrigger + path.node.openingElement.name = t.jsxIdentifier(localName); + if (path.node.closingElement) { + path.node.closingElement.name = t.jsxIdentifier(localName); + } + + // replace Dialog with ContextualHelpPopover + let dialog = path.node.children.filter((c): c is t.JSXElement => t.isJSXElement(c))[1]; + if (dialog && t.isJSXIdentifier(dialog.openingElement.name)) { + let name = getName(path, dialog.openingElement.name); + if (name === 'Dialog') { + let contextualHelpPopover = availableComponents.has('ContextualHelpPopover') + ? addComponentImport(program, 'ContextualHelpPopover') + : 'ContextualHelpPopover'; + dialog.openingElement.name = t.jsxIdentifier(contextualHelpPopover); + if (dialog.closingElement) { + dialog.closingElement.name = t.jsxIdentifier(contextualHelpPopover); + } + } + } +} diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Item/transform.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Item/transform.ts index 9d8eee7501c..241a9f2f3ce 100644 --- a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Item/transform.ts +++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Item/transform.ts @@ -16,6 +16,7 @@ export default function transformItem(path: NodePath): void { // Update Items based on parent collection component updateComponentWithinCollection(path, {parentComponentName: 'Menu', newComponentName: 'MenuItem'}); updateComponentWithinCollection(path, {parentComponentName: 'ActionMenu', newComponentName: 'MenuItem'}); + updateComponentWithinCollection(path, {parentComponentName: 'ContextualHelpTrigger', newComponentName: 'MenuItem'}); updateComponentWithinCollection(path, {parentComponentName: 'TagGroup', newComponentName: 'Tag'}); updateComponentWithinCollection(path, {parentComponentName: 'Breadcrumbs', newComponentName: 'Breadcrumb'}); updateComponentWithinCollection(path, {parentComponentName: 'Picker', newComponentName: 'PickerItem'}); diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts index eaf1e909e76..bb884dded54 100644 --- a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts +++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts @@ -443,7 +443,7 @@ export function updateComponentWithinCollection( // Collections currently implemented // TODO: Add 'ActionGroup', 'ListBox', 'ListView' once implemented - const collectionItemParents = new Set(['Menu', 'ActionMenu', 'TagGroup', 'Breadcrumbs', 'Picker', 'ComboBox', 'ListBox', 'TabList', 'TabPanels', 'Collection']); + const collectionItemParents = new Set(['Menu', 'ActionMenu', 'TagGroup', 'Breadcrumbs', 'Picker', 'ComboBox', 'ListBox', 'TabList', 'TabPanels', 'Collection', 'ContextualHelpTrigger']); if ( t.isJSXElement(path.node) && @@ -489,7 +489,7 @@ export function updateComponentWithinCollection( export function commentIfParentCollectionNotDetected( path: NodePath ): void { - const collectionItemParents = new Set(['Menu', 'ActionMenu', 'TagGroup', 'Breadcrumbs', 'Picker', 'ComboBox', 'ListBox', 'TabList', 'TabPanels', 'ActionGroup', 'ActionButtonGroup', 'ToggleButtonGroup', 'ListBox', 'ListView', 'Collection', 'SearchAutocomplete', 'Accordion', 'ActionBar', 'StepList']); + const collectionItemParents = new Set(['Menu', 'ActionMenu', 'TagGroup', 'Breadcrumbs', 'Picker', 'ComboBox', 'ListBox', 'TabList', 'TabPanels', 'ActionGroup', 'ActionButtonGroup', 'ToggleButtonGroup', 'ListBox', 'ListView', 'Collection', 'SearchAutocomplete', 'Accordion', 'ActionBar', 'StepList', 'ContextualHelpTrigger']); if ( t.isJSXElement(path.node) ) { diff --git a/packages/dev/s2-docs/pages/s2/Menu.mdx b/packages/dev/s2-docs/pages/s2/Menu.mdx index 0162fe79669..5cecb90fa7a 100644 --- a/packages/dev/s2-docs/pages/s2/Menu.mdx +++ b/packages/dev/s2-docs/pages/s2/Menu.mdx @@ -248,6 +248,33 @@ import {Menu, MenuTrigger, MenuItem, SubmenuTrigger, ActionButton} from '@react- ``` +### Unavailable items + +Wrap a `` with an `` to disable the item's default action and show a [contextual help](ContextualHelp) popover instead. + +```tsx render docs={docs.exports.UnavailableMenuItemTrigger} links={docs.links} props={['isUnavailable']} initialProps={{isUnavailable: true}} type="s2" wide +"use client"; +import {ActionButton, Content, ContextualHelpPopover, Heading, Menu, MenuTrigger, MenuItem, UnavailableMenuItemTrigger} from '@react-spectrum/s2'; + +function Example(props) { + return ( + + Actions + alert(`Triggering ${action}`)}> + Favorite + + Delete + + Permission required + Contact your administrator for permissions to delete. + + + + + ); +} +``` + ### Links Use the `href` prop on a `` to create a link. See the [getting started guide](getting-started) to learn how to integrate with your framework. @@ -476,7 +503,7 @@ import {MenuTrigger, Menu, MenuItem, ActionButton} from '@react-spectrum/s2'; ## API -```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button', Menu: '#menu', MenuItem: '#menuitem', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger', Icon: 'icons', Image: 'Image', Text: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span', Keyboard: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/kbd', Header: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/header', Heading: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements'}} +```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button', Menu: '#menu', MenuItem: '#menuitem', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger', UnavailableMenuItemTrigger: '#unavailablemenuitemtrigger', Popover: 'Popover', Icon: 'icons', Image: 'Image', Text: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span', Keyboard: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/kbd', Header: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/header', Heading: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements'}} + + + 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..95ad7f94b31 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -1737,6 +1737,72 @@ describe('Menu', () => { expect(onPress).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1); }); + + describe('submenutrigger non menu content', () => { + it('opens popover and does not call onAction', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, findByRole, findByText} = render( + + + + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
+
+ ); + + await user.click(getByRole('button')); + await findByRole('menu'); + let menuItems = getAllByRole('menuitem'); + await user.click(menuItems[0]); + expect(await findByText('Contact your administrator for permissions to delete.')).toBeInTheDocument(); + expect(onAction).not.toHaveBeenCalled(); + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(2); + expect(document.activeElement).toBe(menuItems[0]); + await user.keyboard('{Escape}'); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(getByRole('button')); + }); + + it('should auto focus the Popover if in keyboard modality', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, findByRole, findByText} = render( + + + + + + Delete + +
Contact your administrator for permissions to delete.
+
+
+
+
+
+ ); + + let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button'), interactionType: 'keyboard'}); + await menuTester.open(); + await findByRole('menu'); + await menuTester.selectOption({option: 0}); + expect(await findByText('Contact your administrator for permissions to delete.')).toBeInTheDocument(); + expect(onAction).not.toHaveBeenCalled(); + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(2); + expect(document.activeElement).toBe(dialogs[1]); + await user.keyboard('{Escape}'); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(menuTester.options()[0]); + }); + }); }); // better to accept items from the test? or just have the test have a requirement that you render a certain-ish structure? diff --git a/scripts/migrateIntl.mjs b/scripts/migrateIntl.mjs index 1bd0c44b6d2..c7ec7e076b8 100644 --- a/scripts/migrateIntl.mjs +++ b/scripts/migrateIntl.mjs @@ -34,6 +34,7 @@ let stringsToAllow = new Set([ 'inlinealert.negative', 'inlinealert.notice', 'inlinealert.positive', + 'menu.unavailable', 'picker.placeholder', 'slider.minimum', 'slider.maximum', From 742aecab12f3d5d851a6bc071d8330525c4fdecf Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 27 Feb 2026 15:40:26 -0800 Subject: [PATCH 3/4] chore: Warn if user has interactive elements in their custom Picker value (#9710) * chore: Warn if user has interactive elements in their custom Picker value * run check if selected value changes --- packages/@react-spectrum/s2/src/Picker.tsx | 48 +++++++++++++++++-- .../@react-spectrum/s2/test/Picker.test.tsx | 34 +++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index dab71456292..b092ec8a610 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -29,6 +29,7 @@ import { ListLayout, Provider, SectionProps, + SelectStateContext, SelectValue, Virtualizer } from 'react-aria-components'; @@ -50,6 +51,7 @@ import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {control, controlBorderRadius, controlFont, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {createHideableComponent} from '@react-aria/collections'; +import {createShadowTreeWalker, getOwnerDocument, isFocusable, useGlobalListeners, useSlotId} from '@react-aria/utils'; import { Divider, listbox, @@ -76,9 +78,8 @@ import {PressResponder} from '@react-aria/interactions'; import {pressScale} from './pressScale'; import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; -import React, {createContext, forwardRef, ReactNode, useContext, useMemo, useRef, useState} from 'react'; +import React, {createContext, forwardRef, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {useFocusableRef} from '@react-spectrum/utils'; -import {useGlobalListeners, useSlotId} from '@react-aria/utils'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -491,6 +492,13 @@ const avatarSize = { XL: 26 } as const; +// https://w3c.github.io/aria/#widget_roles +let INTERACTIVE_ARIA_ROLES = new Set([ + 'application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'menuitem', + 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'separator', + 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem' +]); + interface PickerButtonInnerProps extends PickerStyleProps, Omit, Pick, 'loadingState' | 'renderValue'> { loadingCircle: ReactNode, buttonRef: RefObject @@ -511,6 +519,36 @@ const PickerButton = createHideableComponent(function PickerButton { + if (process.env.NODE_ENV === 'production' || !renderValue) { + return; + } + + if (!renderValueRef.current) { + return; + } + + let doc = getOwnerDocument(renderValueRef.current); + let walker = createShadowTreeWalker( + doc, + renderValueRef.current, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node: Element) { + let role = node.getAttribute('role'); + let interactive = isFocusable(node) || (role != null && INTERACTIVE_ARIA_ROLES.has(role)); + return interactive ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + } + } + ); + let next = walker.nextNode(); + if (next) { + console.warn('Picker\'s value should not have interactive children for accessibility.'); + } + }, [state.selectedItems, renderValue]); // For mouse interactions, pickers open on press start. When the popover underlay appears // it covers the trigger button, causing onPressEnd to fire immediately and no press scaling @@ -601,7 +639,11 @@ const PickerButton = createHideableComponent(function PickerButton - {renderedValue} + {renderValue ? ( +
+ {renderedValue} +
+ ) : renderedValue} ); }} diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx index 6df38302768..35efe33433c 100644 --- a/packages/@react-spectrum/s2/test/Picker.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -165,6 +165,40 @@ describe('Picker', () => { expect(tree.getByTestId('custom-value')).toHaveTextContent('Chocolate, Vanilla'); }); + it('should warn if the custom render value output has a interactive child', async () => { + let spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let items = [ + {id: 'chocolate', name: 'Chocolate'}, + {id: 'strawberry', name: 'Strawberry'}, + {id: 'vanilla', name: 'Vanilla'} + ]; + let renderValue = jest.fn(() => ( + +
test
+
+ )); + let tree = render( + + {(item: any) => {item.name}} + + ); + + // expect the placeholder to be rendered when no items are selected + expect(tree.queryByTestId('custom-value')).toBeNull(); + + let selectTester = testUtilUser.createTester('Select', {root: tree.container, interactionType: 'mouse'}); + await selectTester.open(); + await selectTester.selectOption({option: 0}); + await selectTester.selectOption({option: 2}); + await selectTester.close(); + + expect(spy).toHaveBeenCalledWith('Picker\'s value should not have interactive children for accessibility.'); + }); + it('should support contextual help', async () => { // Issue with how we don't render the contextual help button in the fake DOM since PressResponder isn't using createHideableComponent let warn = jest.spyOn(global.console, 'warn').mockImplementation(); From 745de5be324d716ce07454f6fa1b9b79073d5a2c Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 27 Feb 2026 16:10:17 -0800 Subject: [PATCH 4/4] refactor: Centralize expandedKeys logic in TreeCollection (#9711) * refactor: Centralize expandedKeys logic in TreeCollection * fix level logic --- .../collections/src/BaseCollection.ts | 10 +- .../@react-aria/collections/src/Document.ts | 2 +- .../gridlist/src/useGridListItem.ts | 6 +- .../selection/src/ListKeyboardDelegate.ts | 54 +---- packages/react-aria-components/src/Tree.tsx | 204 +++++------------- 5 files changed, 66 insertions(+), 210 deletions(-) diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 739f85995c2..70c55c859fe 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -135,11 +135,11 @@ export class SectionNode extends FilterableNode { * custom collection behaviors. */ export class BaseCollection implements ICollection> { - private keyMap: Map> = new Map(); - private firstKey: Key | null = null; - private lastKey: Key | null = null; - private frozen = false; - private itemCount: number = 0; + protected keyMap: Map> = new Map(); + protected firstKey: Key | null = null; + protected lastKey: Key | null = null; + protected frozen = false; + protected itemCount: number = 0; get size(): number { return this.itemCount; diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 1bde2a2fdb0..d4beac8d736 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -278,7 +278,7 @@ export class ElementNode extends BaseNode { get level(): number { if (this.parentNode instanceof ElementNode) { - return this.parentNode.level + (this.node?.type === 'item' ? 1 : 0); + return this.parentNode.level + (this.parentNode.node?.type === 'item' ? 1 : 0); } return 0; diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index b3ff34350c8..04873c2cee0 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -100,12 +100,16 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let isExpanded = hasChildRows ? state.expandedKeys.has(node.key) : undefined; let setSize = 1; + let index = node.index; if (node.level >= 0 && node?.parentKey != null) { let parent = state.collection.getItem(node.parentKey); if (parent) { // siblings must exist because our original node exists let siblings = getDirectChildren(parent, state.collection); setSize = [...siblings].filter(row => row.type === 'item').length; + if (index > 0 && siblings[0].type !== 'item') { + index -= 1; // subtract one for the parent item's content node + } } } else { setSize = [...state.collection].filter(row => row.level === 0 && row.type === 'item').length; @@ -114,7 +118,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt treeGridRowProps = { 'aria-expanded': isExpanded, 'aria-level': node.level + 1, - 'aria-posinset': node?.index + 1, + 'aria-posinset': index + 1, 'aria-setsize': setSize }; } diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 11f4422c044..21239d23dad 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -23,8 +23,7 @@ interface ListKeyboardDelegateOptions { direction?: Direction, disabledKeys?: Set, disabledBehavior?: DisabledBehavior, - layoutDelegate?: LayoutDelegate, - expandedKeys?: Set + layoutDelegate?: LayoutDelegate } export class ListKeyboardDelegate implements KeyboardDelegate { @@ -37,7 +36,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { private orientation?: Orientation; private direction?: Direction; private layoutDelegate: LayoutDelegate; - private expandedKeys?: Set; constructor(collection: Collection>, disabledKeys: Set, ref: RefObject, collator?: Intl.Collator, expandedKeys?: Set); constructor(options: ListKeyboardDelegateOptions); @@ -53,7 +51,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.direction = opts.direction; this.layout = opts.layout || 'stack'; this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref); - this.expandedKeys = opts.expandedKeys; } else { this.collection = args[0]; this.disabledKeys = args[1]; @@ -63,7 +60,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.orientation = 'vertical'; this.disabledBehavior = 'all'; this.layoutDelegate = new DOMLayoutDelegate(this.ref); - this.expandedKeys = args[4]; } // If this is a vertical stack, remove the left/right methods completely @@ -92,50 +88,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return null; } - // Returns the first key that's visible starting from and inclusive of the provided key - private findNextVisible(key: Key | null): Key | null { - let node = key ? this.collection.getItem(key) : null; - if (!node) { - return null; - } - - // If the node's parent is expanded, then we can assume that this is a visible node - if (node.parentKey && this.expandedKeys?.has(node.parentKey)) { - return node.key; - } - - // If the node's parent is not expanded, find the top-most non-expanded node since it's possible for them to be nested - let parentNode = node.parentKey ? this.collection.getItem(node.parentKey) : null; - // if the the parent node is not a section, and the parent node is not included in expanded keys - while (parentNode && parentNode.type !== 'section' && node && node.parentKey && this.expandedKeys && !this.expandedKeys.has(parentNode.key)) { - node = this.collection.getItem(node.parentKey); - parentNode = node && node.parentKey ? this.collection.getItem(node.parentKey) : null; - } - - return node?.key ?? null; - } - - // Returns the first key that's visible and non-disabled starting from and inclusive of the provided key - private findNextNonDisabledVisible(key: Key | null, getNext: (key: Key) => Key | null) { - let nextKey = key; - while (nextKey !== null) { - let visibleKey = this.findNextVisible(nextKey); - // If visibleKey is null, that means there are no visibleKeys (don't feel like this is a real use case though, I would assume that there is always one visible node) - if (visibleKey == null) { - return null; - } - - let node = this.collection.getItem(visibleKey); - if (node?.type === 'item' && !this.isDisabled(node)) { - return visibleKey; - } - - nextKey = getNext(visibleKey); - } - - return null; - } - getNextKey(key: Key): Key | null { let nextKey: Key | null = key; nextKey = this.collection.getKeyAfter(nextKey); @@ -249,10 +201,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { getLastKey(): Key | null { let key = this.collection.getLastKey(); - // we only need to check for visible keys if items can be expanded/collapsed - if (this.expandedKeys) { - return this.findNextNonDisabledVisible(key, key => this.collection.getKeyBefore(key)); - } return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 0336e312bcc..004e7aff58b 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -11,6 +11,7 @@ */ import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSection, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; +import {BaseCollection, Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, SectionNode, useCachedChildren} from '@react-aria/collections'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import { @@ -27,12 +28,11 @@ import { useContextProps, useRenderProps } from './utils'; -import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, SectionNode, useCachedChildren} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionProps} from './Collection'; import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents, RefObject, SelectionMode} from '@react-types/shared'; import {DragAndDropContext, DropIndicatorContext, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; -import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; +import {DraggableCollectionState, DroppableCollectionState, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; import {GridListHeader, GridListHeaderContext, GridListHeaderInnerContext, GridListHeaderProps} from './GridList'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; @@ -40,41 +40,36 @@ import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useControlledState} from '@react-stately/utils'; -class TreeCollection implements ICollection> { - private keyMap: Map> = new Map(); - private itemCount: number = 0; - private expandedKeys; - private collection; - - constructor(opts) { - let {collection, lastExpandedKeys, expandedKeys} = opts; - let {keyMap, itemCount} = generateKeyMap(collection, {expandedKeys}); - // Use generated keyMap because it contains the modified collection nodes (aka it adjusts the indexes so that they ignore the existence of the Content items) - // Also adjusts the levels of node inside of a section - this.keyMap = keyMap; - this.collection = collection; - this.itemCount = itemCount; - this.expandedKeys = expandedKeys; - // We do this so React knows to re-render since the same item won't cause a new render but a clone creating a new object with the same value will + +class TreeCollection extends BaseCollection { + private expandedKeys: Set = new Set(); + + withExpandedKeys(lastExpandedKeys: Set, expandedKeys: Set) { + let collection = this.clone(); + collection.expandedKeys = expandedKeys; + + // Clone ancestor section nodes so React knows to re-render since the same item won't cause a new render but a clone creating a new object with the same value will // Without this change, the items won't expand and collapse when virtualized inside a section - TreeCollection.cloneAncestorSections(expandedKeys, lastExpandedKeys, this.keyMap, (k) => this.getItem(k)); - TreeCollection.cloneAncestorSections(lastExpandedKeys, expandedKeys, this.keyMap, (k) => this.getItem(k)); + TreeCollection.cloneAncestorSections(expandedKeys, lastExpandedKeys, collection); + TreeCollection.cloneAncestorSections(lastExpandedKeys, expandedKeys, collection); + + collection.frozen = this.frozen; + return collection; } // diff lastExpandedKeys and expandedKeys so we only clone what has changed private static cloneAncestorSections( keys: Iterable, excludeSet: Set, - keyMap: Map>, - getItem: (key: Key) => Node | null + collection: TreeCollection ) { for (let key of keys) { if (!excludeSet.has(key)) { let currentKey: Key | null = key; while (currentKey != null) { - let item = getItem(currentKey) as CollectionNode; + let item = collection.getItem(currentKey) as CollectionNode; if (item?.type === 'section') { - keyMap.set(currentKey, item.clone()); + collection.keyMap.set(currentKey, item.clone()); break; } else { currentKey = item?.parentKey ?? null; @@ -101,28 +96,22 @@ class TreeCollection implements ICollection> { } } - get size() { - return this.itemCount; - } - - getKeys() { - return this.keyMap.keys(); - } - - getItem(key: Key): Node | null { - return this.keyMap.get(key) || null; - } + getLastKey() { + // Find the deepest expanded child. We don't use collection.getLastKey() here + // because that will return the deepest child regardless of expandedKeys. + // Instead, start from the last top-level key and walk down. + let key = this.lastKey; + if (key == null) { + return null; + } - at(): Node { - throw new Error('Not implemented'); - } + let node = this.getItem(key) as CollectionNode; - getFirstKey() { - return this.collection.getFirstKey(); - } + while (node?.lastChildKey != null && (node.type !== 'item' || this.expandedKeys.has(node.key))) { + node = this.getItem(node.lastChildKey) as CollectionNode; + } - getLastKey() { - return this.collection.getLastKey(); + return node?.key; } getKeyAfter(key: Key) { @@ -159,12 +148,8 @@ class TreeCollection implements ICollection> { if (node.prevKey != null) { node = this.getItem(node.prevKey) as CollectionNode; - while (node && node.type !== 'item' && node.lastChildKey != null) { - node = this.getItem(node.lastChildKey) as CollectionNode; - } - // If the lastChildKey is expanded, check its lastChildKey - while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { + while (node && (node.type !== 'item' || this.expandedKeys.has(node.key)) && node.lastChildKey != null) { node = this.getItem(node.lastChildKey) as CollectionNode; } @@ -175,24 +160,23 @@ class TreeCollection implements ICollection> { } getChildren(key: Key): Iterable> { - let keyMap = this.keyMap; let self = this; return { *[Symbol.iterator]() { - let parent = keyMap.get(key); - let node = parent?.firstChildKey ? keyMap.get(parent.firstChildKey) : null; + let parent = self.getItem(key) as CollectionNode | null; + let node = parent?.firstChildKey != null ? self.getItem(parent.firstChildKey) as CollectionNode : null; if (parent && parent.type === 'section' && node) { // Stop once either the node is null or the node is the parent's sibling while (node && node.key !== parent.nextKey) { - yield keyMap.get(node.key) as Node; + yield self.getItem(node.key)!; // This will include content nodes which we skip in ListLayout let key = self.getKeyAfter(node.key); - node = key ? keyMap.get(key) : undefined; + node = key != null ? self.getItem(key)! as CollectionNode : null; } } else { while (node) { yield node as Node; - node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + node = node.nextKey != null ? self.getItem(node.nextKey)! as CollectionNode : null; } } } @@ -274,7 +258,7 @@ export const Tree = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tree}> + } createCollection={() => new TreeCollection()}> {collection => } ); @@ -293,7 +277,7 @@ const EXPANSION_KEYS = { interface TreeInnerProps { props: TreeProps, - collection: ICollection, + collection: TreeCollection, treeRef: RefObject } @@ -333,12 +317,12 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne let [lastCollection, setLastCollection] = useState(collection); let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys); - let [flattenedCollection, setFlattenedCollection] = useState(() => new TreeCollection({collection, lastExpandedKeys: new Set(), expandedKeys})); + let [flattenedCollection, setFlattenedCollection] = useState(() => collection.withExpandedKeys(lastExpandedKeys, expandedKeys)); // if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection) { - setFlattenedCollection(new TreeCollection({collection, lastExpandedKeys, expandedKeys})); + setFlattenedCollection(collection.withExpandedKeys(lastExpandedKeys, expandedKeys)); setLastCollection(collection); setLastExpandedKeys(expandedKeys); } @@ -353,26 +337,10 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne disabledBehavior }); - // useSelectableList is not aware of expandedKeys, so create a new ListKeyboardDelegate which will handle that - let keyboardDelegate = useMemo(() => - new ListKeyboardDelegate({ - collection: state.collection, - collator, - ref, - disabledKeys: state.selectionManager.disabledKeys, - disabledBehavior: state.selectionManager.disabledBehavior, - direction, - layoutDelegate, - expandedKeys - }), - [state.collection, collator, ref, state.selectionManager.disabledKeys, state.selectionManager.disabledBehavior, direction, layoutDelegate, expandedKeys] - ); - let {gridProps} = useTree({ ...props, isVirtualized, - layoutDelegate, - keyboardDelegate + layoutDelegate }, state, ref); let dragState: DraggableCollectionState | undefined = undefined; @@ -404,6 +372,17 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne }); let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(state.collection, ref, {direction}); treeDropTargetDelegate.setup(dropTargetDelegate, state, direction); + + let keyboardDelegate = new ListKeyboardDelegate({ + collection: state.collection, + collator, + ref, + disabledKeys: state.selectionManager.disabledKeys, + disabledBehavior: state.selectionManager.disabledBehavior, + direction, + layoutDelegate + }); + droppableCollection = dragAndDropHooks.useDroppableCollection!( { keyboardDelegate, @@ -930,81 +909,6 @@ export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoa ); }); -interface TreeGridCollectionOptions { - expandedKeys: Set -} - -interface FlattenedTree { - keyMap: Map>, - itemCount: number -} - -function generateKeyMap(collection: TreeCollection, opts: TreeGridCollectionOptions): FlattenedTree { - let { - expandedKeys = new Set() - } = opts; - let keyMap: Map> = new Map(); - // Need to count the items here because BaseCollection will return the full item count regardless if items are hidden via collapsed rows - let itemCount = 0; - let parentLookup: Map = new Map(); - - let visitNode = (node: Node, isInSection: boolean) => { - if (node.type === 'item' || node.type === 'loader') { - let parentKey = node?.parentKey; - let clone = {...node}; - if (parentKey != null) { - // TODO: assumes that non item content node (aka TreeItemContent always placed before Collection) will be always placed before the child rows. If we can't make this assumption then we can filter out - // every non-item per level and assign indicies based off the node's position in said filtered array - let hasContentNode = [...collection.getChildren(parentKey)][0].type !== 'item'; - if (hasContentNode) { - clone.index = node?.index != null ? node?.index - 1 : 0; - } - - if (isInSection) { - if (node.type === 'item') { - clone.level = node?.level != null ? node?.level - 1 : 0; - } - } - - // For loader nodes that have a parent (aka non-root level loaders), these need their levels incremented by 1 for parity with their sibiling rows - // (Collection only increments the level if it is a "item" type node). - if (node.type === 'loader') { - clone.level = node.level + 1; - } - - keyMap.set(clone.key, clone as CollectionNode); - } else { - keyMap.set(node.key, node as CollectionNode); - } - - // Grab the modified node from the key map so our flattened list and modified key map point to the same nodes - let modifiedNode = keyMap.get(node.key) || node; - if (modifiedNode.level === 0 || (modifiedNode.parentKey != null && expandedKeys.has(modifiedNode.parentKey) && parentLookup.get(modifiedNode.parentKey))) { - if (modifiedNode.type === 'item') { - itemCount++; - } - - parentLookup.set(modifiedNode.key, true); - } - } else if (node.type !== null) { - keyMap.set(node.key, node as CollectionNode); - } - - for (let child of collection.getChildren(node.key)) { - visitNode(child, isInSection); - } - }; - - for (let node of collection) { - visitNode(node, node.type === 'section'); - } - - return { - keyMap, - itemCount - }; -} - function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef): JSX.Element | null { ref = useObjectRef(ref); let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!;