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/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 231f2ae73f6..cb0dee4a184 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'; @@ -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'] @@ -287,6 +290,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 +302,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) { 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-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-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/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/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/@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(); 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'}}