From e8b001abb3fdde8463613f6994757eb97218f95d Mon Sep 17 00:00:00 2001 From: Adam Thompson <2414030+TheSonOfThomp@users.noreply.github.com> Date: Thu, 20 Jun 2024 16:44:55 -0400 Subject: [PATCH] Restructure SubMenu & Implement new Light-in-Light mode [LG-4236, LG-4060, LG-3263, LG-3168, LG-3190] (#2380) * create Menu.styles * installs descendants in menu * extract useMenuHeight * init descendants * pass onItemFocus from provider * abstract out useUpdatedChildren * creates useHighlightReducer * cleanup reducer * skip disabled elements * implement descendant in submenu * Update yarn.lock * rm focus-visible styles we always want focus * fix menu item list style * fix ts errors * rm deprecated hooks * rm debug text * restructure test suite * Create blue-crews-hope.md * Updates stories * adds controlled story * modernizes spec file * Update Menu.stories.tsx * Update SplitButton.spec.tsx * update split button pkg.json * Update yarn.lock * Delete getNewIndex.ts * add // prettier-ignore * mv HighlightReducer Update getUpdatedIndex.ts * Update .gitignore * creates AriaLabelPropsWithChildren type * uses AriaLabelPropsWithChildren in InputOption * Create InputOptionContent generated story * InputOptionContent use tokens, extend className * inputOptionThemeStyles use color tokens * Update titleClassName * create & use InputOptionContext * refactor inputOptionStyles * fix inputoption icon placement & sizing * update icon hover styles * Update Avatar props (#2352) * avatar accepts null text * update generated stories * changeset * Update spotty-ghosts-play.md * add turbo to clean (#2361) * pr * Update .gitignore * create Menu.styles * installs descendants in menu * extract useMenuHeight * init descendants * pass onItemFocus from provider * abstract out useUpdatedChildren * creates useHighlightReducer * cleanup reducer * skip disabled elements * implement descendant in submenu * Update yarn.lock * rm focus-visible styles we always want focus * fix menu item list style * fix ts errors * rm deprecated hooks * rm debug text * restructure test suite * Create blue-crews-hope.md * Updates stories * adds controlled story * modernizes spec file * Update Menu.stories.tsx * adds preserveIconSpace. Update unique classnames * create Menu.styles * installs descendants in menu * extract useMenuHeight * init descendants * pass onItemFocus from provider * abstract out useUpdatedChildren * creates useHighlightReducer * cleanup reducer * skip disabled elements * implement descendant in submenu * Update yarn.lock * rm focus-visible styles we always want focus * fix menu item list style * fix ts errors * rm deprecated hooks * rm debug text * restructure test suite * Create blue-crews-hope.md * Updates stories * adds controlled story * modernizes spec file * Update Menu.stories.tsx * Update SplitButton.spec.tsx * update split button pkg.json * Update yarn.lock * Delete getNewIndex.ts * add // prettier-ignore * mv HighlightReducer Update getUpdatedIndex.ts * update icon hover styles * pr * Update package.json * mv content * WIP: implement input option * update component exports * Create big-wasps-fix.md * Create shaggy-cheetahs-ring.md * Update big-wasps-fix.md * implements preserveIconSpace * Renames selected -> checked * creates separate InputOptionContent.stories * Update big-wasps-fix.md * updates menu item stories * Implement active & destructive styles, add stories * wip dark in light mode * update active wedge to border.primary * create DarkInLightMode story * include darkMode in InputOptionContext * fix renderDarkMenu stories * spread args into InitialOpen story * rm old highlight reducer * rm unused descendant vars * rm checked styles * Update big-wasps-fix.md * Create clean-apricots-provide.md * typo * fix bad merge * revert wedge color to blue.base * revert icon height to default * use disabled prop on `Description` * add style changes to changeset * updates text highlight color targeting * revert implementing of Label component * add description to highlight story * Update MenuItem.styles.ts * fix menu item tests * Update InputOption.style.ts * waitForTransition accepts null arg * WIP * add ref to descendant object * add ref to descendant object * rm controls from controlled story * Creates `useTraceUpdate` hook * create stale descendant test * update spec & stories * do not register descendent if it doesn't exist * Adds getDescendants function * add documentation for `getDescendants` * update docs * use getDescendants within Menu * fix stories TS * add popover as dev dep * Update package.json * mv test utils * Update yarn.lock * Update useControlledState.ts * Create SubMenu.stories.tsx * sub menu uses menu item. create useChildrenHeight * adds keydown to close submenu * Update Menu.spec.tsx * add serve & watch scripts * disable active styles when highlighted * update changesets * add tests for AriaLabelPropsWithChildren * update documentation * Update .gitignore * Update README.md * fix nits * add example to useTraceUpdate * rename var * fix testing lib version * PolyRef x null. PolyProps x PropsWithRef * use latest CLI * update Submenu types * Update styles.ts * clean up submenu tests * InternalMenuItemContent - Prevents nested buttons * lgids * test to ensure no nested buttons * Create slimy-walls-cry.md * Update RecursiveRecord.types.ts * scaffold light mode styles * updates menu light-mode styling * updates dark in light mode styles * update submenu indent styles * cleanup highlight styles * fix initial open logic * add destructive styles to dark-in-light * rm size from SB * Update Menu.stories.tsx * adds transition handler tests in submenu * cleanup tests * Adds tests for more complex menu interactions * ensure focus remains on a submenu after opening * add internal flags to descendants utils * add getByIndex/id to descendants pkg * refactor Highlight reducer * Update SubMenu.tsx * pass getDescendants into highlight reducer * Updates Descendant index properties * handle TransitionExiting in submenu * resolves submenu focus bugs * Update yarn.lock * rm comments * fixes generated stories --------- Co-authored-by: Shaneeza --- .changeset/curly-schools-call.md | 5 + .changeset/rare-coins-raise.md | 5 + .changeset/slimy-walls-cry.md | 12 + .changeset/strange-schools-call.md | 5 + package.json | 4 +- .../descendants/src/DescendantsReducer.ts | 12 +- packages/descendants/src/index.ts | 1 + .../descendants/src/utils/findDOMIndex.ts | 1 + .../src/utils/findDescendantIndexWithId.ts | 2 + .../descendants/src/utils/getDescendant.ts | 21 + packages/descendants/src/utils/index.ts | 2 + .../src/utils/insertDescendantAt.ts | 2 + .../src/utils/isElementPreceding.ts | 5 +- .../src/utils/refreshDescendantIndexes.ts | 12 + packages/descendants/src/utils/removeIndex.ts | 6 + .../InputOptionContent.types.ts | 1 + .../lib/src/types/RecursiveRecord.types.ts | 21 +- packages/menu/package.json | 1 + .../src/HighlightReducer/HighlightReducer.ts | 109 ++++ .../src/HighlightReducer/highlight.types.ts | 44 ++ packages/menu/src/HighlightReducer/index.ts | 2 + .../HighlightReducer/utils/getNextEnabled.ts | 55 +++ .../utils/getNextFromDirection.ts | 33 ++ .../HighlightReducer/utils/getNextIndex.ts | 27 + .../utils/isDescendantsSet.ts | 0 packages/menu/src/Menu.spec.tsx | 211 +++++++- packages/menu/src/Menu.stories.tsx | 60 +-- .../Menu/HighlightReducer/HighlightReducer.ts | 46 -- .../src/Menu/HighlightReducer/highlight.d.ts | 2 - .../menu/src/Menu/HighlightReducer/index.ts | 2 - .../HighlightReducer/utils/getUpdatedIndex.ts | 92 ---- packages/menu/src/Menu/Menu.styles.ts | 48 +- packages/menu/src/Menu/Menu.tsx | 48 +- packages/menu/src/MenuContext/MenuContext.tsx | 6 +- .../menu/src/MenuContext/MenuContext.types.ts | 13 +- packages/menu/src/MenuContext/index.ts | 6 +- .../src/MenuItem/InternalMenuItemContent.tsx | 140 ++++++ .../menu/src/MenuItem/MenuItem.stories.tsx | 78 ++- packages/menu/src/MenuItem/MenuItem.styles.ts | 306 +++++++++--- packages/menu/src/MenuItem/MenuItem.tsx | 88 +--- packages/menu/src/MenuItem/MenuItem.types.ts | 24 +- packages/menu/src/MenuItem/index.ts | 1 + .../src/MenuSeparator/MenuSeparator.styles.ts | 14 +- .../menu/src/MenuSeparator/MenuSeparator.tsx | 19 +- packages/menu/src/SubMenu/SubMenu.spec.tsx | 265 +++++++--- packages/menu/src/SubMenu/SubMenu.stories.tsx | 54 ++ packages/menu/src/SubMenu/SubMenu.styles.ts | 245 +-------- packages/menu/src/SubMenu/SubMenu.tsx | 466 +++++++----------- packages/menu/src/SubMenu/SubMenu.types.ts | 57 +-- packages/menu/src/SubMenu/SubMenuContext.tsx | 23 + packages/menu/src/SubMenu/index.ts | 3 +- .../menu/src/SubMenu/useChildrenHeight.ts | 32 ++ .../menu/src/SubMenu/useControlledState.ts | 4 +- packages/menu/src/constants.ts | 8 + packages/menu/src/styles.ts | 461 +++-------------- yarn.lock | 13 +- 56 files changed, 1767 insertions(+), 1456 deletions(-) create mode 100644 .changeset/curly-schools-call.md create mode 100644 .changeset/rare-coins-raise.md create mode 100644 .changeset/slimy-walls-cry.md create mode 100644 .changeset/strange-schools-call.md create mode 100644 packages/descendants/src/utils/getDescendant.ts create mode 100644 packages/descendants/src/utils/refreshDescendantIndexes.ts create mode 100644 packages/menu/src/HighlightReducer/HighlightReducer.ts create mode 100644 packages/menu/src/HighlightReducer/highlight.types.ts create mode 100644 packages/menu/src/HighlightReducer/index.ts create mode 100644 packages/menu/src/HighlightReducer/utils/getNextEnabled.ts create mode 100644 packages/menu/src/HighlightReducer/utils/getNextFromDirection.ts create mode 100644 packages/menu/src/HighlightReducer/utils/getNextIndex.ts rename packages/menu/src/{Menu => }/HighlightReducer/utils/isDescendantsSet.ts (100%) delete mode 100644 packages/menu/src/Menu/HighlightReducer/HighlightReducer.ts delete mode 100644 packages/menu/src/Menu/HighlightReducer/highlight.d.ts delete mode 100644 packages/menu/src/Menu/HighlightReducer/index.ts delete mode 100644 packages/menu/src/Menu/HighlightReducer/utils/getUpdatedIndex.ts create mode 100644 packages/menu/src/MenuItem/InternalMenuItemContent.tsx create mode 100644 packages/menu/src/SubMenu/SubMenu.stories.tsx create mode 100644 packages/menu/src/SubMenu/SubMenuContext.tsx create mode 100644 packages/menu/src/SubMenu/useChildrenHeight.ts create mode 100644 packages/menu/src/constants.ts diff --git a/.changeset/curly-schools-call.md b/.changeset/curly-schools-call.md new file mode 100644 index 0000000000..510a2b8933 --- /dev/null +++ b/.changeset/curly-schools-call.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/lib': patch +--- + +Fixes `RecursiveRecord` type diff --git a/.changeset/rare-coins-raise.md b/.changeset/rare-coins-raise.md new file mode 100644 index 0000000000..6746fdb608 --- /dev/null +++ b/.changeset/rare-coins-raise.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/descendants': patch +--- + +Updates Descendant index properties after inserting & removing to ensure the index of the Descendant object matches the index within the Descendants list diff --git a/.changeset/slimy-walls-cry.md b/.changeset/slimy-walls-cry.md new file mode 100644 index 0000000000..6b92ce3a50 --- /dev/null +++ b/.changeset/slimy-walls-cry.md @@ -0,0 +1,12 @@ +--- +'@leafygreen-ui/menu': minor +--- + +## Features +- Clicking a submenu item that _does not_ have a click handler or `href` will toggle the submenu +- When focused on a submenu item, pressing the left/right arrow keys will close/open the menu (respectively) + +## Structural changes +- Updates Submenu component to use `InputOption` +- Moves the submenu toggle button to be a sibling of the `InputOption` + - this avoids any potential nesting of `button` elements diff --git a/.changeset/strange-schools-call.md b/.changeset/strange-schools-call.md new file mode 100644 index 0000000000..6fc5d97039 --- /dev/null +++ b/.changeset/strange-schools-call.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/descendants': minor +--- + +Adds & exports `getDescendantById` & `getDescendantByIndex` utilities diff --git a/package.json b/package.json index cd9710d718..3dcdc41417 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ "publish": "yarn changeset publish --public", "slackbot": "lg slackbot release", "start": "npx storybook dev -p 9001 --no-version-updates", + "serve": "npx http-server storybook-static -c5", "test": "lg test", "unlink": "lg unlink", - "validate": "lg validate" + "validate": "lg validate", + "watch": "npx nodemon --watch packages/ -e tsx,ts --exec 'yarn build-storybook --test'" }, "devDependencies": { "@actions/core": "^1.10.1", diff --git a/packages/descendants/src/DescendantsReducer.ts b/packages/descendants/src/DescendantsReducer.ts index dac9d4f0f8..9740cf605b 100644 --- a/packages/descendants/src/DescendantsReducer.ts +++ b/packages/descendants/src/DescendantsReducer.ts @@ -6,6 +6,7 @@ import { findDescendantIndexWithId, findDOMIndex, insertDescendantAt, + refreshDescendantIndexes, removeIndex, } from './utils/'; import { Descendant, DescendantsList } from './Descendants.types'; @@ -65,7 +66,7 @@ export const descendantsReducer = ( if (!isElementRegistered) { // The element is not yet registered - // If there are no tracked descendants, then this element is at index 0, + // 2. If there are no tracked descendants, then this element is at index 0, // Otherwise, check the array of tracked elements to find what index this element should be const element = action.ref.current; const index = findDOMIndex(element, currentState); @@ -78,14 +79,15 @@ export const descendantsReducer = ( index, }; - // Add the new descendant at the given index + // 3. Add the new descendant at the given index const newDescendants = insertDescendantAt( currentState, thisDescendant, index, ); - return newDescendants; + const indexedDescendants = refreshDescendantIndexes(newDescendants); + return indexedDescendants; } return currentState; @@ -121,7 +123,9 @@ export const descendantsReducer = ( if (registeredIndex >= 0) { // If an element exists with the given id, remove it const newDescendants = removeIndex(currentState, registeredIndex); - return newDescendants; + const indexedDescendants = refreshDescendantIndexes(newDescendants); + + return indexedDescendants; } // no change diff --git a/packages/descendants/src/index.ts b/packages/descendants/src/index.ts index 5e88544bdd..93bc1477c0 100644 --- a/packages/descendants/src/index.ts +++ b/packages/descendants/src/index.ts @@ -10,3 +10,4 @@ export { export { useDescendant } from './useDescendant'; export { useDescendantsContext } from './useDescendantsContext'; export { useInitDescendants } from './useInitDescendants'; +export { getDescendantById, getDescendantByIndex } from './utils'; diff --git a/packages/descendants/src/utils/findDOMIndex.ts b/packages/descendants/src/utils/findDOMIndex.ts index 8b4f3fd938..e55f1c703f 100644 --- a/packages/descendants/src/utils/findDOMIndex.ts +++ b/packages/descendants/src/utils/findDOMIndex.ts @@ -21,6 +21,7 @@ import { isElementPreceding } from './isElementPreceding'; * findDOMIndex(, descendants) // 2 (since is the closest _tracked_ DOM node preceding ) * ``` * + * @internal */ export function findDOMIndex( element: T, diff --git a/packages/descendants/src/utils/findDescendantIndexWithId.ts b/packages/descendants/src/utils/findDescendantIndexWithId.ts index e8239ee5d6..ae9d66fdf2 100644 --- a/packages/descendants/src/utils/findDescendantIndexWithId.ts +++ b/packages/descendants/src/utils/findDescendantIndexWithId.ts @@ -5,6 +5,8 @@ import { DescendantsList } from '../Descendants.types'; * @param descendants * @param id * @returns The index of a descendant with the given id + * + * @internal */ export function findDescendantIndexWithId( descendants: DescendantsList, diff --git a/packages/descendants/src/utils/getDescendant.ts b/packages/descendants/src/utils/getDescendant.ts new file mode 100644 index 0000000000..c49ab91a40 --- /dev/null +++ b/packages/descendants/src/utils/getDescendant.ts @@ -0,0 +1,21 @@ +import { Descendant, DescendantsList } from '../Descendants.types'; + +/** + * Returns the Descendant with the provided `id`, or undefined + */ +export const getDescendantById = ( + id: string, + descendants: DescendantsList, +): Descendant | undefined => { + return descendants.find(d => d.id === id); +}; + +/** + * Returns the Descendant at the provided `index`, or undefined + */ +export const getDescendantByIndex = ( + index: number, + descendants: DescendantsList, +): Descendant | undefined => { + return descendants[index]; +}; diff --git a/packages/descendants/src/utils/index.ts b/packages/descendants/src/utils/index.ts index 9ed9c30325..2cbd8c57c5 100644 --- a/packages/descendants/src/utils/index.ts +++ b/packages/descendants/src/utils/index.ts @@ -1,5 +1,7 @@ export { findDescendantIndexWithId } from './findDescendantIndexWithId'; export { findDOMIndex } from './findDOMIndex'; +export { getDescendantById, getDescendantByIndex } from './getDescendant'; export { insertDescendantAt } from './insertDescendantAt'; export { isElementPreceding } from './isElementPreceding'; +export { refreshDescendantIndexes } from './refreshDescendantIndexes'; export { removeIndex } from './removeIndex'; diff --git a/packages/descendants/src/utils/insertDescendantAt.ts b/packages/descendants/src/utils/insertDescendantAt.ts index 2df1b7ba11..4a606b7070 100644 --- a/packages/descendants/src/utils/insertDescendantAt.ts +++ b/packages/descendants/src/utils/insertDescendantAt.ts @@ -6,6 +6,8 @@ import { Descendant } from '../Descendants.types'; * @param item The item to insert into the array * @param index The index to insert the item at * @returns A copy of the array with the item inserted at the specified index + * + * @internal */ export function insertDescendantAt( array: Array>, diff --git a/packages/descendants/src/utils/isElementPreceding.ts b/packages/descendants/src/utils/isElementPreceding.ts index 59d72e9dcd..1396df8a22 100644 --- a/packages/descendants/src/utils/isElementPreceding.ts +++ b/packages/descendants/src/utils/isElementPreceding.ts @@ -2,7 +2,10 @@ * Returns whether ElementA precedes ElementAB in the DOM * * See: https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition - */ export function isElementPreceding(elemA: HTMLElement, elemB: HTMLElement) { + * + * @internal + */ +export function isElementPreceding(elemA: HTMLElement, elemB: HTMLElement) { return Boolean( elemB.compareDocumentPosition(elemA) & Node.DOCUMENT_POSITION_PRECEDING, ); diff --git a/packages/descendants/src/utils/refreshDescendantIndexes.ts b/packages/descendants/src/utils/refreshDescendantIndexes.ts new file mode 100644 index 0000000000..1ce87b9322 --- /dev/null +++ b/packages/descendants/src/utils/refreshDescendantIndexes.ts @@ -0,0 +1,12 @@ +import { DescendantsList } from '../Descendants.types'; + +/** + * Returns a new descendants list with updated indexes. + * + * Call this after inserting/removing from the descendants list + */ +export const refreshDescendantIndexes = ( + descendants: DescendantsList, +): DescendantsList => { + return descendants.map((d, i) => ({ ...d, index: i })); +}; diff --git a/packages/descendants/src/utils/removeIndex.ts b/packages/descendants/src/utils/removeIndex.ts index 4387ad9b78..6e743ef113 100644 --- a/packages/descendants/src/utils/removeIndex.ts +++ b/packages/descendants/src/utils/removeIndex.ts @@ -1,3 +1,9 @@ +/** + * Removes the given index from an array + * + * @internal + */ +// TODO: Move to `lib` export function removeIndex( array: Array, index: number, diff --git a/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts b/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts index 9376d446ec..00b1089357 100644 --- a/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts +++ b/packages/input-option/src/InputOptionContent/InputOptionContent.types.ts @@ -22,6 +22,7 @@ export interface InputOptionContentProps extends ComponentProps<'div'> { rightGlyph?: React.ReactNode; /** + * * Preserves space before the text content for a left glyph. * * Use in menus where some items may or may not have icons/glyphs, diff --git a/packages/lib/src/types/RecursiveRecord.types.ts b/packages/lib/src/types/RecursiveRecord.types.ts index 850ca1b386..1bf4995ff5 100644 --- a/packages/lib/src/types/RecursiveRecord.types.ts +++ b/packages/lib/src/types/RecursiveRecord.types.ts @@ -24,11 +24,16 @@ export type RecursiveRecord< Keys extends Array, Strict extends boolean = true, -> = Keys extends [ - infer Key, // the current union of keys - ...infer Rest, -] - ? Strict extends true - ? Record> - : Partial>> - : Keys; +> = + // If `Keys` is an array with at least 2 indexes + Keys extends [ + infer Key, // the current union of keys + ...infer Rest extends [infer _K, ...infer _R], // (`Keys` has at least 2 indexes if 2nd argument can also be inferred) + ] + ? // If this is strict, then don't use Partial + Strict extends true + ? Record> + : Partial>> + : Keys extends [infer Key] // If Keys has only 1 index + ? Key // return that index + : never; // otherwise there's an error diff --git a/packages/menu/package.json b/packages/menu/package.json index 987e052547..454e3ac92c 100644 --- a/packages/menu/package.json +++ b/packages/menu/package.json @@ -33,6 +33,7 @@ "@leafygreen-ui/polymorphic": "^2.0.0", "@leafygreen-ui/tokens": "^2.9.0", "lodash": "^4.17.21", + "polished": "^4.3.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { diff --git a/packages/menu/src/HighlightReducer/HighlightReducer.ts b/packages/menu/src/HighlightReducer/HighlightReducer.ts new file mode 100644 index 0000000000..20d7228212 --- /dev/null +++ b/packages/menu/src/HighlightReducer/HighlightReducer.ts @@ -0,0 +1,109 @@ +import { type Reducer, useReducer } from 'react'; + +import { + Descendant, + DescendantsList, + getDescendantById, + getDescendantByIndex, +} from '@leafygreen-ui/descendants'; + +import { getNextFromDirection } from './utils/getNextFromDirection'; +import { isDescendantsSet } from './utils/isDescendantsSet'; +import type { + Direction, + HighlightChangeHandler, + HighlightReducerFunction, + HighlightReducerReturnType, + UpdateHighlightAction, +} from './highlight.types'; + +const getInitialHighlight = (descendants: DescendantsList) => + isDescendantsSet(descendants) ? descendants[0] : undefined; + +/** + * Creates a new reducer function for closure for a given `descendants` value + */ +const makeHighlightReducerFunction = + (getDescendants: () => DescendantsList): HighlightReducerFunction => + (currentHighlight, action) => { + const descendants = getDescendants(); + + // If we've received a direction, move the highlight + if (action.direction) { + const nextHighlight = getNextFromDirection( + action.direction, + currentHighlight, + descendants, + ); + return nextHighlight || currentHighlight; + } else if (action.index) { + const nextHighlight = getDescendantByIndex(action.index, descendants); + return nextHighlight; + } else if (action.id) { + const nextHighlight = getDescendantById(action.id, descendants); + return nextHighlight; + } + + return currentHighlight; + }; + +/** + * Custom hook that handles setting the highlighted descendant index, + * and fires any `onChange` side effects + */ +export const useHighlightReducer = ( + /** An accessor for the updated descendants list */ + getDescendants: () => DescendantsList, + /** A callback fired when the highlight changes */ + onChange?: HighlightChangeHandler, +): HighlightReducerReturnType => { + // Create a reducer function + const highlightReducerFunction = makeHighlightReducerFunction(getDescendants); + + // Create the reducer + const [highlight, dispatch] = useReducer< + Reducer + >(highlightReducerFunction, getInitialHighlight(getDescendants())); + + /** + * Custom dispatch that moves the current highlight + * in a given direction + * + * Fires any side-effects in the `onChange` callback + */ + const moveHighlight = (direction: Direction) => { + const updatedHighlight = highlightReducerFunction(highlight, { + direction, + }); + + onChange?.(updatedHighlight); + dispatch({ direction }); + }; + + /** + * Custom dispatch that sets the current highlight + * to a given `index` or `id`. + * + * Fires any side-effects in the `onChange` callback + */ + const setHighlight = (indexOrId: number | string) => { + const action = + typeof indexOrId === 'string' + ? { + id: indexOrId, + } + : { + index: indexOrId, + }; + + const updatedHighlight = highlightReducerFunction(highlight, action); + onChange?.(updatedHighlight); + dispatch(action); + }; + + return { + highlight, + moveHighlight, + setHighlight, + }; +}; diff --git a/packages/menu/src/HighlightReducer/highlight.types.ts b/packages/menu/src/HighlightReducer/highlight.types.ts new file mode 100644 index 0000000000..2ee48b9107 --- /dev/null +++ b/packages/menu/src/HighlightReducer/highlight.types.ts @@ -0,0 +1,44 @@ +import { Reducer } from 'react'; + +import { Descendant } from '@leafygreen-ui/descendants'; + +export type Index = number | undefined; +const Direction = { + Next: 'next', + Prev: 'prev', + First: 'first', + Last: 'last', +} as const; +export type Direction = (typeof Direction)[keyof typeof Direction]; + +export type HighlightChangeHandler = ( + nextHighlight: Descendant | undefined, +) => void; + +export type UpdateHighlightAction = + | { + direction: Direction; + index?: never; + id?: never; + } + | { + index: number; + direction?: never; + id?: never; + } + | { + id: string; + direction?: never; + index?: never; + }; + +export type HighlightReducerFunction = Reducer< + Descendant | undefined, + UpdateHighlightAction +>; + +export interface HighlightReducerReturnType { + highlight: Descendant | undefined; + moveHighlight: (direction: Direction) => void; + setHighlight: (indexOrId: number | string) => void; +} diff --git a/packages/menu/src/HighlightReducer/index.ts b/packages/menu/src/HighlightReducer/index.ts new file mode 100644 index 0000000000..42eaedb911 --- /dev/null +++ b/packages/menu/src/HighlightReducer/index.ts @@ -0,0 +1,2 @@ +export type { Direction, Index } from './highlight.types'; +export { useHighlightReducer } from './HighlightReducer'; diff --git a/packages/menu/src/HighlightReducer/utils/getNextEnabled.ts b/packages/menu/src/HighlightReducer/utils/getNextEnabled.ts new file mode 100644 index 0000000000..3b2b083635 --- /dev/null +++ b/packages/menu/src/HighlightReducer/utils/getNextEnabled.ts @@ -0,0 +1,55 @@ +import { + Descendant, + DescendantsList, + getDescendantByIndex, +} from '@leafygreen-ui/descendants'; + +import { Direction, Index } from '../highlight.types'; + +import { getNextIndex } from './getNextIndex'; + +/** + * Finds the index of the subsequent `enabled` descendant element + */ +export function getNextEnabledIndex( + direction: Direction, + current: Descendant | undefined, + descendants: DescendantsList, +): Index { + // If all descendants are disabled, then we skip this step + if (descendants.every(d => d.props.disabled)) { + return undefined; + } + + let updatedIndex = getNextIndex( + direction, + current?.index ?? 0, + descendants.length, + ); + let item = getDescendantByIndex(updatedIndex, descendants); + + // If the subsequent item is disabled, + // keep searching in that direction for an enabled one + while (item?.props.disabled) { + // If the first/last item is disabled + // start the search in the forward/backward direction + const nextDirection: Direction = (() => { + switch (direction) { + case 'first': + return 'next'; + case 'last': + return 'prev'; + default: + return direction; + } + })(); + updatedIndex = getNextIndex( + nextDirection, + updatedIndex, + descendants.length, + ); + item = descendants[updatedIndex]; + } + + return updatedIndex; +} diff --git a/packages/menu/src/HighlightReducer/utils/getNextFromDirection.ts b/packages/menu/src/HighlightReducer/utils/getNextFromDirection.ts new file mode 100644 index 0000000000..99a5b0fb99 --- /dev/null +++ b/packages/menu/src/HighlightReducer/utils/getNextFromDirection.ts @@ -0,0 +1,33 @@ +import { + Descendant, + DescendantsList, + getDescendantByIndex, +} from '@leafygreen-ui/descendants'; +import { isDefined } from '@leafygreen-ui/lib'; + +import { Direction } from '../highlight.types'; + +import { getNextEnabledIndex } from './getNextEnabled'; +import { isDescendantsSet } from './isDescendantsSet'; + +export const getNextFromDirection = ( + direction: Direction, + current: Descendant | undefined, + descendants: DescendantsList, +): Descendant | undefined => { + // If descendants is not set + // then we don't mutate the index + if (!isDescendantsSet(descendants)) { + return current; + } + + const updatedIndex = getNextEnabledIndex(direction, current, descendants); + + if (isDefined(updatedIndex)) { + const nextDescendant = getDescendantByIndex(updatedIndex, descendants); + + return nextDescendant; + } + + return current; +}; diff --git a/packages/menu/src/HighlightReducer/utils/getNextIndex.ts b/packages/menu/src/HighlightReducer/utils/getNextIndex.ts new file mode 100644 index 0000000000..eac7221d3c --- /dev/null +++ b/packages/menu/src/HighlightReducer/utils/getNextIndex.ts @@ -0,0 +1,27 @@ +import { isDefined } from '@leafygreen-ui/lib'; + +import type { Direction, Index } from '../highlight.types'; + +/** + * Computes the next index given a direction + */ +// prettier-ignore +export function getNextIndex(direction: Direction, currentIndex: number, totalItems: number): number; +// prettier-ignore +export function getNextIndex(direction: Direction, currentIndex: undefined, totalItems: number): undefined; +// prettier-ignore +export function getNextIndex(direction: Direction, currentIndex: Index, totalItems: number): Index { + if (!isDefined(currentIndex)) return currentIndex; + + switch (direction) { + case 'next': + return (currentIndex + 1) % totalItems; + case 'prev': + return (currentIndex - 1 + totalItems) % totalItems; + case 'last': + return totalItems - 1; + case 'first': + default: + return 0; + } +} diff --git a/packages/menu/src/Menu/HighlightReducer/utils/isDescendantsSet.ts b/packages/menu/src/HighlightReducer/utils/isDescendantsSet.ts similarity index 100% rename from packages/menu/src/Menu/HighlightReducer/utils/isDescendantsSet.ts rename to packages/menu/src/HighlightReducer/utils/isDescendantsSet.ts diff --git a/packages/menu/src/Menu.spec.tsx b/packages/menu/src/Menu.spec.tsx index ee8a069f09..ab9e3a7f90 100644 --- a/packages/menu/src/Menu.spec.tsx +++ b/packages/menu/src/Menu.spec.tsx @@ -9,32 +9,42 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Optional } from '@leafygreen-ui/lib'; import { waitForTransition } from '@leafygreen-ui/testing-lib'; +import { LGIDs } from './constants'; import { MenuProps } from './Menu'; -import { Menu, MenuItem, MenuSeparator } from '.'; +import { Menu, MenuItem, MenuSeparator, SubMenu } from '.'; const menuTestId = 'menu-test-id'; const menuTriggerTestId = 'menu-trigger'; const defaultTrigger = ; +const defaultChildren = ( + <> + Item A + + Item B + Item C + +); function waitForTimeout(timeout = 500) { return new Promise(res => setTimeout(res, timeout)); } /** Renders a Menu with the given props */ -function renderMenu({ - trigger = defaultTrigger, - ...rest -}: Omit = {}) { +function renderMenu( + { + trigger = defaultTrigger, + children = defaultChildren, + ...rest + }: Optional = { children: defaultChildren }, +) { const renderResult = render( <>
- Item A - - Item B - Item C + {children} , ); @@ -251,37 +261,78 @@ describe('packages/menu', () => { describe('Down arrow', () => { test('highlights the next option in the menu', async () => { const { openMenu } = renderMenu({}); - const { menuEl, menuItemElements } = await openMenu(); - userEvent.type(menuEl!, '{arrowdown}'); + const { menuItemElements } = await openMenu(); + userEvent.keyboard('{arrowdown}'); expect(menuItemElements[1]).toHaveFocus(); }); test('cycles highlight to the top', async () => { const { openMenu } = renderMenu({}); - const { menuEl, menuItemElements } = await openMenu(); + const { menuItemElements } = await openMenu(); for (let i = 0; i < menuItemElements.length; i++) { - userEvent.type(menuEl!, '{arrowdown}'); + userEvent.keyboard('{arrowdown}'); } expect(menuItemElements[0]).toHaveFocus(); }); + + describe('with submenus', () => { + test('highlights the next submenu item', async () => { + const { queryByTestId, openMenu } = renderMenu({ + children: ( + <> + + A + B + + + ), + }); + + const { menuItemElements } = await openMenu(); + expect(menuItemElements).toHaveLength(3); + expect(queryByTestId('submenu')).toHaveFocus(); + userEvent.keyboard('{arrowdown}'); + expect(queryByTestId('item-a')).toHaveFocus(); + }); + + test('does not highlight closed submenu items', async () => { + const { queryByTestId, openMenu } = renderMenu({ + children: ( + <> + + A + B + + C + + ), + }); + + const { menuItemElements } = await openMenu(); + expect(menuItemElements).toHaveLength(2); + expect(queryByTestId('submenu')).toHaveFocus(); + userEvent.keyboard('{arrowdown}'); + expect(queryByTestId('item-c')).toHaveFocus(); + }); + }); }); describe('Up arrow', () => { test('highlights the previous option in the menu', async () => { const { openMenu } = renderMenu({}); - const { menuEl, menuItemElements } = await openMenu(); + const { menuItemElements } = await openMenu(); - userEvent.type(menuEl!, '{arrowdown}'); - userEvent.type(menuEl!, '{arrowup}'); + userEvent.keyboard('{arrowdown}'); + userEvent.keyboard('{arrowup}'); expect(menuItemElements[0]).toHaveFocus(); }); test('cycles highlight to the bottom', async () => { const { openMenu } = renderMenu({}); - const { menuEl, menuItemElements } = await openMenu(); + const { menuItemElements } = await openMenu(); const lastOption = menuItemElements[menuItemElements.length - 1]; - userEvent.type(menuEl!, '{arrowup}'); + userEvent.keyboard('{arrowup}'); expect(lastOption).toHaveFocus(); }); }); @@ -319,4 +370,128 @@ describe('packages/menu', () => { expect(menuEl).toBeInTheDocument(); }); }); + + // TODO: Consider moving these to Chromatic or Playwright/Cypress + describe('Complex interactions', () => { + test('if a submenu is highlighted, and the toggle is clicked, the submenu remains in focus', async () => { + const onEntered = jest.fn(); + + const { queryByTestId, getByTestId, openMenu } = renderMenu({ + children: ( + <> + + A + B + + C + + ), + }); + + await openMenu(); + expect(queryByTestId('submenu')).toHaveFocus(); + userEvent.click(getByTestId(LGIDs.submenuToggle)!); + await waitForTransition(); + await waitFor(() => { + expect(onEntered).toHaveBeenCalled(); + expect(queryByTestId('submenu')).toHaveFocus(); + }); + }); + + test('if a submenu item is highlighted, and that submenu is closed, focus should move to the submenu parent', async () => { + const onExited = jest.fn(); + const { queryByTestId, getByTestId, openMenu } = renderMenu({ + children: ( + <> + + A + B + + C + + ), + }); + + await openMenu(); + expect(queryByTestId('submenu')).toHaveFocus(); + userEvent.keyboard('{arrowright}'); + userEvent.keyboard('{arrowdown}'); + expect(queryByTestId('item-a')).toHaveFocus(); + + userEvent.click(getByTestId(LGIDs.submenuToggle)!); + await waitForTransition(); + + await waitFor(() => { + expect(onExited).toHaveBeenCalled(); + expect(queryByTestId('submenu')).toHaveFocus(); + }); + }); + + test('when a submenu opens, an element below it should remain highlighted', async () => { + const onEntered = jest.fn(); + + const { queryByTestId, getByTestId, openMenu } = renderMenu({ + children: ( + <> + + A + B + + C + + ), + }); + await openMenu(); + expect(queryByTestId('submenu')).toHaveFocus(); + userEvent.keyboard('{arrowup}'); + expect(queryByTestId('item-c')).toHaveFocus(); + + // Open the submenu + userEvent.click(getByTestId(LGIDs.submenuToggle)!); + + await waitForTransition(); + await waitFor(() => { + expect(onEntered).toHaveBeenCalled(); + expect(queryByTestId('item-c')).toHaveFocus(); + }); + }); + + test('when a submenu closes, an element below it should remain highlighted', async () => { + const onExited = jest.fn(); + + const { queryByTestId, getByTestId, openMenu } = renderMenu({ + children: ( + <> + + A + B + + C + + ), + }); + await openMenu(); + expect(queryByTestId('submenu')).toHaveFocus(); + userEvent.keyboard('{arrowright}'); // open the submenu + userEvent.keyboard('{arrowup}'); + expect(queryByTestId('item-c')).toHaveFocus(); + + // Close the submenu + userEvent.click(getByTestId(LGIDs.submenuToggle)!); + + await waitForTransition(); + await waitFor(() => { + expect(onExited).toHaveBeenCalled(); + expect(queryByTestId('item-c')).toHaveFocus(); + }); + }); + }); }); diff --git a/packages/menu/src/Menu.stories.tsx b/packages/menu/src/Menu.stories.tsx index dfb5542334..20361ed842 100644 --- a/packages/menu/src/Menu.stories.tsx +++ b/packages/menu/src/Menu.stories.tsx @@ -10,6 +10,7 @@ import { StoryObj } from '@storybook/react'; import Button from '@leafygreen-ui/button'; import { css } from '@leafygreen-ui/emotion'; +import Icon from '@leafygreen-ui/icon'; import CaretDown from '@leafygreen-ui/icon/dist/CaretDown'; import CloudIcon from '@leafygreen-ui/icon/dist/Cloud'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; @@ -19,14 +20,7 @@ import { TestUtils } from '@leafygreen-ui/popover'; const { getAlign, getJustify } = TestUtils; import { Size } from './types'; -import { - Menu, - MenuItem, - MenuItemProps, - MenuProps, - MenuSeparator, - SubMenu, -} from '.'; +import { Menu, MenuItem, MenuProps, MenuSeparator, SubMenu } from '.'; const getDecoratorStyles = (args: Partial) => { return css` @@ -92,7 +86,7 @@ export default { } satisfies StoryMetaType; export const LiveExample = { - render: ({ open, size, darkMode, ...args }) => { + render: ({ open, darkMode, ...args }) => { return ( - }> - Menu Item - - } - > + }>Menu Item + }> Menu Item - + Disabled Menu Item } > Disabled Menu Item - - I am a link! - + I am a link! } - active={true} + glyph={} href="http://mongodb.design" - size={size} > - SubMenu Item 1 - SubMenu Item 2 + SubMenu Item 1 + SubMenu Item 2 SubMenu Item 3 - + Support 1 Support 2 + }> + Delete + - Lorem - Ipsum - Adipiscing - Cursus - Ullamcorper - Vulputate - Inceptos - Risus + Lorem + Ipsum + Adipiscing + Cursus + Ullamcorper + Vulputate + Inceptos + Risus ); }, @@ -162,7 +148,7 @@ export const LiveExample = { disableSnapshot: true, }, }, -} satisfies StoryObj; +} satisfies StoryObj; export const InitialOpen = { render: args => { diff --git a/packages/menu/src/Menu/HighlightReducer/HighlightReducer.ts b/packages/menu/src/Menu/HighlightReducer/HighlightReducer.ts deleted file mode 100644 index 39905b22f7..0000000000 --- a/packages/menu/src/Menu/HighlightReducer/HighlightReducer.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type Dispatch, type Reducer, useReducer } from 'react'; - -import { DescendantsList } from '@leafygreen-ui/descendants'; - -import { getUpdatedIndex } from './utils/getUpdatedIndex'; -import { isDescendantsSet } from './utils/isDescendantsSet'; -import type { Direction, Index } from './highlight'; - -const getInitialIndex = (descendants: DescendantsList) => - isDescendantsSet(descendants) ? 0 : undefined; - -/** - * Custom hook that handles setting the highlighted descendant index, - * and fires any `onChange` side effects - */ -export const useHighlightReducer = ( - descendants: DescendantsList, - onChange?: ( - newIndex: Index, - updatedDescendants: DescendantsList, - ) => void, -): [Index, Dispatch] => { - // Initializes a new reducer function - const highlightReducerFunction: Reducer = ( - _index, - direction, - ) => getUpdatedIndex(direction, _index, descendants); - - // Create the reducer - const [index, dispatch] = useReducer>( - highlightReducerFunction, - getInitialIndex(descendants), - ); - - /** - * Custom dispatch that fires any side-effects when the index changes - */ - const updateIndex = (direction: Direction) => { - const updatedIndex = highlightReducerFunction(index, direction); - - onChange?.(updatedIndex, descendants); - dispatch(direction); - }; - - return [index, updateIndex]; -}; diff --git a/packages/menu/src/Menu/HighlightReducer/highlight.d.ts b/packages/menu/src/Menu/HighlightReducer/highlight.d.ts deleted file mode 100644 index 564826a8d8..0000000000 --- a/packages/menu/src/Menu/HighlightReducer/highlight.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type Index = number | undefined; -export type Direction = 'next' | 'prev' | 'first' | 'last'; diff --git a/packages/menu/src/Menu/HighlightReducer/index.ts b/packages/menu/src/Menu/HighlightReducer/index.ts deleted file mode 100644 index 98ca5a6ca3..0000000000 --- a/packages/menu/src/Menu/HighlightReducer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { Direction, Index } from './highlight'; -export { useHighlightReducer } from './HighlightReducer'; diff --git a/packages/menu/src/Menu/HighlightReducer/utils/getUpdatedIndex.ts b/packages/menu/src/Menu/HighlightReducer/utils/getUpdatedIndex.ts deleted file mode 100644 index 23a0ca3b5d..0000000000 --- a/packages/menu/src/Menu/HighlightReducer/utils/getUpdatedIndex.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { DescendantsList } from '@leafygreen-ui/descendants'; -import { isDefined } from '@leafygreen-ui/lib'; - -import type { Direction, Index } from '../highlight'; - -import { isDescendantsSet } from './isDescendantsSet'; - -/** - * Computes the next index given a direction - */ -// prettier-ignore -function getNextIndex(direction: Direction, currentIndex: number, totalItems: number): number; -// prettier-ignore -function getNextIndex(direction: Direction, currentIndex: undefined, totalItems: number): undefined; -// prettier-ignore -function getNextIndex(direction: Direction, currentIndex: Index, totalItems: number): Index { - if (!isDefined(currentIndex)) return currentIndex; - - switch (direction) { - case 'next': - return (currentIndex + 1) % totalItems; - case 'prev': - return (currentIndex - 1 + totalItems) % totalItems; - case 'last': - return totalItems - 1; - case 'first': - default: - return 0; - } -} - -/** - * Finds the index of the subsequent `enabled` descendant element - */ -function getNextEnabledIndex( - direction: Direction, - currentIndex: number, - descendants: DescendantsList, -): Index { - // If all descendants are disabled, then we skip this step - if (descendants.every(d => d.props.disabled)) { - return undefined; - } - - let updatedIndex = getNextIndex(direction, currentIndex, descendants.length); - let item = descendants[updatedIndex]; - - // If the subsequent item is disabled, - // keep searching in that direction for an enabled one - while (item.props.disabled) { - // If the first/last item is disabled - // start the search in the forward/backward direction - const nextDirection: Direction = (() => { - switch (direction) { - case 'first': - return 'next'; - case 'last': - return 'prev'; - default: - return direction; - } - })(); - updatedIndex = getNextIndex( - nextDirection, - updatedIndex, - descendants.length, - ); - item = descendants[updatedIndex]; - } - - return updatedIndex; -} - -export const getUpdatedIndex = ( - direction: Direction, - currentIndex: Index, - descendants: DescendantsList, -): Index => { - // If descendants is not set - // then we don't mutate the index - if (!isDescendantsSet(descendants)) { - return currentIndex; - } - - const updatedIndex = getNextEnabledIndex( - direction, - currentIndex ?? 0, - descendants, - ); - - return updatedIndex; -}; diff --git a/packages/menu/src/Menu/Menu.styles.ts b/packages/menu/src/Menu/Menu.styles.ts index 6512a85635..71b667f22b 100644 --- a/packages/menu/src/Menu/Menu.styles.ts +++ b/packages/menu/src/Menu/Menu.styles.ts @@ -1,23 +1,33 @@ +import { transparentize } from 'polished'; + import { css } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; import { palette } from '@leafygreen-ui/palette'; +import { color, spacing } from '@leafygreen-ui/tokens'; -export const rootMenuStyle = css` - width: 210px; - border-radius: 12px; - overflow: auto; - padding: 14px 0; -`; +import { menuColor } from '../styles'; + +export interface MenuStyleArgs { + theme: Theme; +} -export const rootMenuThemeStyles: Record = { - [Theme.Light]: css` - background-color: ${palette.black}; - border: 1px solid ${palette.black}; - `, - [Theme.Dark]: css` - background-color: ${palette.gray.dark3}; - border: 1px solid ${palette.gray.dark2}; - `, +export const getMenuStyles = ({ theme }: MenuStyleArgs) => { + return css` + width: 210px; + border-radius: ${spacing[300]}px; + overflow: auto; + // FIXME: Should this really be 14px? + padding: ${spacing[300] + spacing[50]}px 0; + + background-color: ${menuColor[theme].background.default}; + border: 1px solid ${menuColor[theme].border.default}; + + /* // Light mode only */ + ${theme === 'light' && + css` + box-shadow: 0 2px 4px 1px ${transparentize(0.85, palette.black)}; + `} + `; }; export const scrollContainerStyle = css` @@ -27,3 +37,11 @@ export const scrollContainerStyle = css` padding-inline-start: 0px; padding: 0px; `; + +// TODO: Remove dark-in-light mode styles +// after https://jira.mongodb.org/browse/LG-3974 +export const getDarkInLightModeMenuStyles = () => css` + box-shadow: unset; + background-color: ${color.dark.background.primary.default}; + border: 1px solid ${color.dark.border.primary.default}; +`; diff --git a/packages/menu/src/Menu/Menu.tsx b/packages/menu/src/Menu/Menu.tsx index 9e1e5c4c2a..4a3aec35f3 100644 --- a/packages/menu/src/Menu/Menu.tsx +++ b/packages/menu/src/Menu/Menu.tsx @@ -3,24 +3,26 @@ import PropTypes from 'prop-types'; import { DescendantsProvider, + getDescendantById, useInitDescendants, } from '@leafygreen-ui/descendants'; import { css, cx } from '@leafygreen-ui/emotion'; import { useBackdropClick, useEventListener } from '@leafygreen-ui/hooks'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { isDefined, keyMap } from '@leafygreen-ui/lib'; +import { isDefined, keyMap, Theme } from '@leafygreen-ui/lib'; import Popover, { Align, Justify } from '@leafygreen-ui/popover'; +import { LGIDs } from '../constants'; +import { useHighlightReducer } from '../HighlightReducer'; import { MenuContext, MenuDescendantsContext, } from '../MenuContext/MenuContext'; import { useMenuHeight } from './utils/useMenuHeight'; -import { useHighlightReducer } from './HighlightReducer'; import { - rootMenuStyle, - rootMenuThemeStyles, + getDarkInLightModeMenuStyles, + getMenuStyles, scrollContainerStyle, } from './Menu.styles'; import { MenuProps } from './Menu.types'; @@ -70,8 +72,7 @@ export const Menu = React.forwardRef(function Menu( }: MenuProps, forwardRef, ) { - const renderDarkMode = renderDarkMenu || darkModeProp; - const { theme, darkMode } = useDarkMode(renderDarkMode); + const { theme, darkMode } = useDarkMode(darkModeProp); const popoverRef = useRef(null); const triggerRef = useRef(null); @@ -100,11 +101,12 @@ export const Menu = React.forwardRef(function Menu( // Tracks the currently highlighted (focused) item index // Fires `.focus()` when the index is updated - const [highlightIndex, updateHighlightIndex] = useHighlightReducer( - descendants, - index => { - if (isDefined(index)) { - const descendantElement = getDescendants()[index]?.ref.current; + const { highlight, moveHighlight, setHighlight } = useHighlightReducer( + getDescendants, + _next => { + if (isDefined(_next)) { + const nextDescendant = getDescendantById(_next.id, getDescendants()); + const descendantElement = nextDescendant?.ref.current; descendantElement?.focus(); } }, @@ -114,7 +116,7 @@ export const Menu = React.forwardRef(function Menu( // Handling on this event ensures that the `descendants` elements // exist in the DOM before attempting to set `focus` const handlePopoverOpen = () => { - updateHighlightIndex('first'); + moveHighlight('first'); }; // Fired on global keyDown event @@ -122,12 +124,12 @@ export const Menu = React.forwardRef(function Menu( switch (e.key) { case keyMap.ArrowDown: e.preventDefault(); // Prevents page scrolling - updateHighlightIndex('next'); + moveHighlight('next'); break; case keyMap.ArrowUp: e.preventDefault(); // Prevents page scrolling - updateHighlightIndex('prev'); + moveHighlight('prev'); break; case keyMap.Tab: @@ -144,7 +146,7 @@ export const Menu = React.forwardRef(function Menu( case keyMap.Space: case keyMap.Enter: if (!open) { - updateHighlightIndex('first'); + moveHighlight('first'); } break; } @@ -176,7 +178,9 @@ export const Menu = React.forwardRef(function Menu( value={{ theme, darkMode, - highlightIndex, + highlight, + setHighlight, + renderDarkMenu, }} > (function Menu( refEl={refEl} adjustOnMutation={adjustOnMutation} onEntered={handlePopoverOpen} + data-testid={LGIDs.root} + data-lgid={LGIDs.root} {...popoverProps} >
({ theme: 'light', darkMode: false, - highlightIndex: undefined, + highlight: undefined, }); +export const useMenuContext = () => useContext(MenuContext); + export default MenuContext; diff --git a/packages/menu/src/MenuContext/MenuContext.types.ts b/packages/menu/src/MenuContext/MenuContext.types.ts index ee6b627355..3a41943903 100644 --- a/packages/menu/src/MenuContext/MenuContext.types.ts +++ b/packages/menu/src/MenuContext/MenuContext.types.ts @@ -1,7 +1,18 @@ +import { Descendant } from '@leafygreen-ui/descendants'; import { Theme } from '@leafygreen-ui/lib'; +import { HighlightReducerReturnType } from '../HighlightReducer/highlight.types'; + export interface MenuContextData { theme: Theme; darkMode: boolean; - highlightIndex?: number; + + /** The index of the currently highlighted (focused) item */ + // highlightIndex?: number; + highlight?: Descendant; + + setHighlight?: HighlightReducerReturnType['setHighlight']; + + /** Whether to a dark menu in light mode */ + renderDarkMenu?: boolean; } diff --git a/packages/menu/src/MenuContext/index.ts b/packages/menu/src/MenuContext/index.ts index ffd8f7ddfd..86352e4de7 100644 --- a/packages/menu/src/MenuContext/index.ts +++ b/packages/menu/src/MenuContext/index.ts @@ -1 +1,5 @@ -export { default as MenuContext, MenuDescendantsContext } from './MenuContext'; +export { + default as MenuContext, + MenuDescendantsContext, + useMenuContext, +} from './MenuContext'; diff --git a/packages/menu/src/MenuItem/InternalMenuItemContent.tsx b/packages/menu/src/MenuItem/InternalMenuItemContent.tsx new file mode 100644 index 0000000000..2f236e69c7 --- /dev/null +++ b/packages/menu/src/MenuItem/InternalMenuItemContent.tsx @@ -0,0 +1,140 @@ +import React from 'react'; + +import { css, cx } from '@leafygreen-ui/emotion'; +import { InputOption, InputOptionContent } from '@leafygreen-ui/input-option'; +import { + InferredPolymorphicProps, + PolymorphicAs, + useInferredPolymorphic, +} from '@leafygreen-ui/polymorphic'; +import { color, spacing } from '@leafygreen-ui/tokens'; + +import { useMenuContext } from '../MenuContext'; +import { useSubMenuContext } from '../SubMenu'; + +import { + getDarkInLightModeMenuItemStyles, + getMenuItemStyles, + getSubMenuItemStyles, +} from './MenuItem.styles'; +import { MenuItemProps, Variant } from './MenuItem.types'; + +export type InternalMenuItemContentProps = InferredPolymorphicProps< + PolymorphicAs, + MenuItemProps +> & { + index: number; +}; + +/** + * Internal component shared by MenuItem and SubMenu + */ +export const InternalMenuItemContent = React.forwardRef< + HTMLElement, + InternalMenuItemContentProps +>( + ( + { + as: asProp, + index, + id, + disabled = false, + active = false, + description, + glyph, + variant = Variant.Default, + children, + className, + rightGlyph, + ...rest + }, + fwdRef, + ) => { + const { as } = useInferredPolymorphic(asProp, rest, 'button'); + + const { theme, darkMode, highlight, renderDarkMenu } = useMenuContext(); + const { depth, hasIcon: parentHasIcon } = useSubMenuContext(); + const isSubMenuItem = depth > 0; + const highlighted = id === highlight?.id; + + const defaultAnchorProps = + as === 'a' + ? { + as, + target: '_self', + rel: '', + } + : {}; + + return ( + 0, + }, + className, + )} + {...defaultAnchorProps} + {...rest} + > + 0, + })} + > +
+ {children} +
+
+
+ ); + }, +); + +InternalMenuItemContent.displayName = 'InternalMenuItemContent'; diff --git a/packages/menu/src/MenuItem/MenuItem.stories.tsx b/packages/menu/src/MenuItem/MenuItem.stories.tsx index 96d86c6931..6f7c4a2771 100644 --- a/packages/menu/src/MenuItem/MenuItem.stories.tsx +++ b/packages/menu/src/MenuItem/MenuItem.stories.tsx @@ -1,9 +1,9 @@ -/* eslint-disable react/jsx-key */ -/* eslint-disable react/display-name */ -import React from 'react'; +/* eslint-disable react/jsx-key, react/display-name, react-hooks/rules-of-hooks */ +import React, { useEffect, useRef, useState } from 'react'; import { InstanceDecorator, StoryMetaType } from '@lg-tools/storybook-utils'; import { StoryObj } from '@storybook/react'; +import { Descendant } from '@leafygreen-ui/descendants'; import { css } from '@leafygreen-ui/emotion'; import Icon, { glyphs } from '@leafygreen-ui/icon'; import { Theme } from '@leafygreen-ui/lib'; @@ -27,13 +27,23 @@ const _withMenuContext = }, }; + const ref = useRef(null); + const [testDescendant, setTestDescendant] = useState(); + useEffect(() => { + setTestDescendant({ + ref, + element: ref.current, + id: ref?.current?.getAttribute('data-id'), + index: Number(ref?.current?.getAttribute('data-index')), + } as Descendant); + }, []); const darkMode = (renderDarkMenu || darkModeProp) ?? false; const theme = darkMode ? Theme.Dark : Theme.Light; return ( - + ); @@ -69,9 +79,6 @@ export default { ], combineArgs: { darkMode: [false, true], - description: [undefined, 'This is a description'], - glyph: [undefined, ], - size: [Size.Default, Size.Large], }, decorator: _withMenuContext(), }, @@ -85,18 +92,18 @@ export const LiveExample = { glyph: undefined, }, argTypes: { + active: { control: 'boolean' }, description: { control: 'text' }, glyph: { control: 'select', options: [undefined, ...Object.keys(glyphs)], }, + highlighted: { control: 'boolean' }, size: { control: 'select', options: Object.values(Size), }, - renderDarkMenu: { - control: 'boolean', - }, + renderDarkMenu: { control: 'boolean' }, }, render: ({ children, glyph, ...args }) => ( // @ts-expect-error - Polymorphic issues - type of href is not compatible @@ -114,6 +121,15 @@ export const LiveExample = { export const Default = { render: () => <>, + parameters: { + generate: { + combineArgs: { + description: [undefined, 'This is a description'], + glyph: [undefined, ], + disabled: [false, true], + }, + }, + }, } satisfies StoryObj; export const Active = { @@ -121,19 +137,34 @@ export const Active = { args: { active: true, }, + parameters: { + generate: { + combineArgs: { + description: [undefined, 'This is a description'], + glyph: [undefined, ], + disabled: [false, true], + }, + }, + }, } satisfies StoryObj; export const Focused = { render: () => <>, args: { highlighted: true, + disabled: false, }, -} satisfies StoryObj; - -export const Disabled = { - render: () => <>, - args: { - disabled: true, + parameters: { + generate: { + combineArgs: { + description: [undefined, 'This is a description'], + glyph: [undefined, ], + disabled: [false, true], + }, + }, + chromatic: { + delay: 100, + }, }, } satisfies StoryObj; @@ -146,6 +177,8 @@ export const Destructive = { parameters: { generate: { combineArgs: { + description: [undefined, 'This is a description'], + glyph: [undefined, ], disabled: [false, true], }, }, @@ -167,7 +200,18 @@ export const DarkInLightMode = { active: [false, true], highlighted: [false, true], disabled: [false, true], + variant: [Variant.Default, Variant.Destructive], }, + excludeCombinations: [ + { + active: true, + highlighted: true, + }, + { + active: true, + variant: Variant.Destructive, + }, + ], }, }, }; diff --git a/packages/menu/src/MenuItem/MenuItem.styles.ts b/packages/menu/src/MenuItem/MenuItem.styles.ts index 127e1dcb20..809c5551a0 100644 --- a/packages/menu/src/MenuItem/MenuItem.styles.ts +++ b/packages/menu/src/MenuItem/MenuItem.styles.ts @@ -1,103 +1,152 @@ -import { css } from '@leafygreen-ui/emotion'; +import { css, cx } from '@leafygreen-ui/emotion'; import { + descriptionClassName, leftGlyphClassName, titleClassName, } from '@leafygreen-ui/input-option'; -import { Theme } from '@leafygreen-ui/lib'; +import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; import { palette } from '@leafygreen-ui/palette'; -import { color, Property, spacing } from '@leafygreen-ui/tokens'; +import { color, spacing } from '@leafygreen-ui/tokens'; -import { Size } from '../types'; +import { LGIDs } from '../constants'; +import { menuColor } from '../styles'; import { Variant } from './MenuItem.types'; +export const menuItemClassName = createUniqueClassName(LGIDs.item); + export const menuItemContainerStyles = css` width: 100%; padding: 0; list-style: none; `; -/** Define colors for the active elements */ -const activeColors = { - [Theme.Light]: { - [Property.Background]: palette.green.light3, - [Property.Text]: palette.green.dark2, - [Property.Icon]: palette.green.dark1, - [Property.Border]: palette.green.dark1, - }, - [Theme.Dark]: { - [Property.Background]: palette.green.dark3, - [Property.Text]: palette.green.base, - [Property.Icon]: palette.green.base, - [Property.Border]: palette.green.base, - }, -} as const satisfies Record>; - interface MenuItemStyleArgs { + active: boolean; + disabled: boolean; + highlighted: boolean; theme: Theme; - size: Size; variant: Variant; - active: boolean; } export const getMenuItemStyles = ({ - theme, - size, active, + disabled, + highlighted, + theme, variant, -}: MenuItemStyleArgs) => css` - display: block; - width: 100%; - min-height: ${spacing[800]}px; - - ${size === Size.Large && - css` - min-height: ${spacing[1200]}px; - // TODO: align on \`large\` size text styles - // https://jira.mongodb.org/browse/LG-4060 - `} - - ${active && - css` - &, - &:hover { - background-color: ${activeColors[theme].background}; - - &:before { - transform: scaleY(1) translateY(-50%); - background-color: ${activeColors[theme].border}; - } +}: MenuItemStyleArgs) => + cx( + // Base styles + css` + display: block; + width: 100%; + min-height: ${spacing[800]}px; + background-color: ${menuColor[theme].background.default}; .${titleClassName} { - color: ${activeColors[theme].text}; - font-weight: bold; + color: ${menuColor[theme].text.default}; } - .${leftGlyphClassName} { - color: ${activeColors[theme].icon}; + color: ${menuColor[theme].icon.default}; } - } - `} + `, + { + // Active + [css` + &, + &:hover { + background-color: ${menuColor[theme].background.active}; - ${variant === Variant.Destructive && - css` - .${titleClassName} { - color: ${color[theme].text.error.default}; - } - .${leftGlyphClassName} { - color: ${color[theme].icon.error.default}; - } - - &:hover { - background-color: ${color[theme].background.error.hover}; - .${titleClassName} { - color: ${color[theme].text.error.hover}; - } - .${leftGlyphClassName} { - color: ${color[theme].icon.error.hover}; - } - } - `} + &:before { + transform: scaleY(1) translateY(-50%); + background-color: ${menuColor[theme].border.active}; + } + + .${titleClassName} { + color: ${menuColor[theme].text.active}; + font-weight: bold; + } + + .${leftGlyphClassName} { + color: ${menuColor[theme].icon.active}; + } + } + `]: active, + + // Highlighted + [css` + &, + &:hover, + &:focus { + background-color: ${menuColor[theme].background.focus}; + + &:before { + transform: scaleY(1) translateY(-50%); + background-color: ${menuColor[theme].border.focus}; + } + + .${titleClassName} { + color: ${menuColor[theme].text.focus}; + } + .${leftGlyphClassName} { + color: ${menuColor[theme].icon.focus}; + } + } + `]: highlighted, + + // Destructive + [css` + .${titleClassName} { + color: ${color[theme].text.error.default}; + } + .${leftGlyphClassName} { + color: ${color[theme].icon.error.default}; + } + + &:hover { + background-color: ${color[theme].background.error.hover}; + .${titleClassName} { + color: ${color[theme].text.error.hover}; + } + .${leftGlyphClassName} { + color: ${color[theme].icon.error.hover}; + } + } + `]: variant === Variant.Destructive, + + // Disabled + [css` + &, + &:hover { + background-color: ${menuColor[theme].background.default}; + .${titleClassName} { + color: ${color[theme].text.disabled.default}; + } + .${leftGlyphClassName} { + color: ${color[theme].icon.disabled.default}; + } + } + `]: disabled, + }, + ); + +export const getSubMenuItemStyles = ({ + theme, + parentHasIcon, +}: { + theme: Theme; + parentHasIcon: boolean; +}) => css` + &:after { + content: ''; + position: absolute; + top: 0; + right: 0; + left: ${parentHasIcon ? spacing[900] : spacing[600]}px; + height: 1px; + background-color: ${menuColor[theme].border.default}; + } `; export const getMenuItemContentStyles = ({ @@ -110,3 +159,118 @@ export const getMenuItemContentStyles = ({ padding-inline-start: ${spacing[300]}px; `} `; + +// TODO: Remove dark-in-light mode styles +// after https://jira.mongodb.org/browse/LG-3974 +export const getDarkInLightModeMenuItemStyles = ({ + active, + variant, + disabled, + highlighted, +}: MenuItemStyleArgs) => { + return cx( + css` + background-color: ${color.dark.background.primary.default}; + + .${titleClassName} { + color: ${color.dark.text.primary.default}; + } + .${descriptionClassName} { + color: ${color.dark.text.secondary.default}; + } + .${leftGlyphClassName} { + color: ${color.dark.icon.primary.default}; + } + + &:hover { + background-color: ${color.dark.background.primary.hover}; + + .${titleClassName} { + color: ${color.dark.text.primary.hover}; + } + .${descriptionClassName} { + color: ${color.dark.text.secondary.hover}; + } + .${leftGlyphClassName} { + color: ${color.dark.icon.primary.hover}; + } + } + `, + { + // Active styles + [css` + &, + &:hover { + background-color: ${color.dark.background.primary.default}; + + .${titleClassName} { + color: ${palette.green.base}; + } + .${leftGlyphClassName} { + color: ${palette.green.base}; + } + + &::before { + background-color: ${palette.green.base}; + } + } + `]: active, + + // Highlighted + [css` + &, + &:hover, + &:focus { + background-color: ${color.dark.background.primary.focus}; + + .${titleClassName} { + color: ${color.dark.text.primary.focus}; + } + .${descriptionClassName} { + color: ${color.dark.text.secondary.focus}; + } + .${leftGlyphClassName} { + color: ${color.dark.icon.primary.focus}; + } + } + `]: highlighted, + + [css` + .${titleClassName} { + color: ${color.dark.text.error.default}; + } + .${leftGlyphClassName} { + color: ${color.dark.icon.error.default}; + } + + &:hover { + background-color: ${color.dark.background.error.hover}; + .${titleClassName} { + color: ${color.dark.text.error.hover}; + } + .${leftGlyphClassName} { + color: ${color.dark.icon.error.hover}; + } + } + `]: variant === Variant.Destructive, + + // Disabled + [css` + &, + &:hover { + background-color: ${color.dark.background.primary.default}; + + .${titleClassName} { + color: ${color.dark.text.disabled.default}; + } + .${descriptionClassName} { + color: ${color.dark.text.disabled.default}; + } + .${leftGlyphClassName} { + color: ${color.dark.icon.disabled.default}; + } + } + `]: disabled, + }, + ); +}; diff --git a/packages/menu/src/MenuItem/MenuItem.tsx b/packages/menu/src/MenuItem/MenuItem.tsx index def69c6d9a..59c1475164 100644 --- a/packages/menu/src/MenuItem/MenuItem.tsx +++ b/packages/menu/src/MenuItem/MenuItem.tsx @@ -1,100 +1,44 @@ -import React, { useContext } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { useDescendant } from '@leafygreen-ui/descendants'; -import { css, cx } from '@leafygreen-ui/emotion'; -import { InputOption, InputOptionContent } from '@leafygreen-ui/input-option'; -import { createUniqueClassName } from '@leafygreen-ui/lib'; -import { - InferredPolymorphic, - useInferredPolymorphicComponent, -} from '@leafygreen-ui/polymorphic'; +import { cx } from '@leafygreen-ui/emotion'; +import { InferredPolymorphic } from '@leafygreen-ui/polymorphic'; -import { MenuContext, MenuDescendantsContext } from '../MenuContext'; -import { Size } from '../types'; +import { LGIDs } from '../constants'; +import { MenuDescendantsContext } from '../MenuContext'; -import { - getMenuItemContentStyles, - getMenuItemStyles, - menuItemContainerStyles, -} from './MenuItem.styles'; -import { MenuItemProps, Variant } from './MenuItem.types'; - -const menuItemClassName = createUniqueClassName('menu_item'); +import { InternalMenuItemContent } from './InternalMenuItemContent'; +import { menuItemClassName, menuItemContainerStyles } from './MenuItem.styles'; +import { MenuItemProps } from './MenuItem.types'; export const MenuItem = InferredPolymorphic( ( - { - as: asProp, - disabled = false, - active = false, - size = Size.Default, - className, - children, - description, - glyph, - variant = Variant.Default, - ...rest - }, + { as, disabled = false, active = false, ...rest }, fwdRef: React.Ref, ) => { - const as = useInferredPolymorphicComponent(asProp, rest, 'button'); - const { theme, darkMode, highlightIndex } = useContext(MenuContext); const { index, ref, id } = useDescendant(MenuDescendantsContext, fwdRef, { active, disabled, }); - const isHighlighted = index === highlightIndex; - return (
  • - - -
    - {children} -
    -
    -
    + />
  • ); }, diff --git a/packages/menu/src/MenuItem/MenuItem.types.ts b/packages/menu/src/MenuItem/MenuItem.types.ts index a3bb7f97c7..3d090a50ab 100644 --- a/packages/menu/src/MenuItem/MenuItem.types.ts +++ b/packages/menu/src/MenuItem/MenuItem.types.ts @@ -1,3 +1,5 @@ +import { FocusEventHandler, ReactElement, ReactNode } from 'react'; + import { Size } from '../types'; const Variant = { @@ -19,17 +21,17 @@ export interface MenuItemProps { * Slot to pass in an Icon rendered to the left of `MenuItem` text. * @type `` component */ - glyph?: React.ReactElement; + glyph?: ReactElement; /** - * Size of the MenuItem component, can be `default` or `large` + * Slot to pass an Icon rendered to the right of the MenuItem */ - size?: Size; + rightGlyph?: ReactElement; /** * Content to appear inside of `` component */ - children?: React.ReactNode; + children?: ReactNode; /** * Determines whether or not the MenuItem is active. @@ -39,15 +41,23 @@ export interface MenuItemProps { /** * Description element displayed below title in MenuItem. */ - description?: React.ReactNode; + description?: ReactNode; /** * Variant of MenuItem */ variant?: Variant; + + /** + * Size of the MenuItem component, can be `default` or `large` + * + * @deprecated - Size no longer has any effect + */ + // TODO: codemod to remove `size` props from existing implementations + size?: Size; } export interface FocusableMenuItemProps { - children: React.ReactElement; - onFocus?: React.FocusEventHandler; + children: ReactElement; + onFocus?: FocusEventHandler; } diff --git a/packages/menu/src/MenuItem/index.ts b/packages/menu/src/MenuItem/index.ts index 12bfa224f3..84c5bd3b5f 100644 --- a/packages/menu/src/MenuItem/index.ts +++ b/packages/menu/src/MenuItem/index.ts @@ -1,3 +1,4 @@ export { FocusableMenuItem } from './FocusableMenuItem'; export { MenuItem } from './MenuItem'; +export { menuItemClassName } from './MenuItem.styles'; export { MenuItemProps, Variant } from './MenuItem.types'; diff --git a/packages/menu/src/MenuSeparator/MenuSeparator.styles.ts b/packages/menu/src/MenuSeparator/MenuSeparator.styles.ts index 276f0bc0c0..efeea07df3 100644 --- a/packages/menu/src/MenuSeparator/MenuSeparator.styles.ts +++ b/packages/menu/src/MenuSeparator/MenuSeparator.styles.ts @@ -1,6 +1,8 @@ import { css } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; +import { color } from '@leafygreen-ui/tokens'; + +import { menuColor } from '../styles'; export const borderStyle = css` height: 16px; @@ -20,12 +22,18 @@ export const borderStyle = css` export const borderThemeStyle: Record = { [Theme.Light]: css` &::before { - background-color: ${palette.gray.dark2}; + background-color: ${menuColor.light.border.default}; } `, [Theme.Dark]: css` &::before { - background-color: ${palette.gray.dark2}; + background-color: ${menuColor.dark.border.default}; } `, }; + +export const borderDarkInLightModeStyles = css` + &::before { + background-color: ${color.dark.border.secondary.default}; + } +`; diff --git a/packages/menu/src/MenuSeparator/MenuSeparator.tsx b/packages/menu/src/MenuSeparator/MenuSeparator.tsx index 05d14fb96a..ec0c6a9ed7 100644 --- a/packages/menu/src/MenuSeparator/MenuSeparator.tsx +++ b/packages/menu/src/MenuSeparator/MenuSeparator.tsx @@ -1,10 +1,15 @@ import React, { useContext } from 'react'; import { cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; import { MenuContext } from '../MenuContext'; -import { borderStyle, borderThemeStyle } from './MenuSeparator.styles'; +import { + borderDarkInLightModeStyles, + borderStyle, + borderThemeStyle, +} from './MenuSeparator.styles'; interface MenuSeparatorProps { /** @@ -14,11 +19,19 @@ interface MenuSeparatorProps { } export function MenuSeparator({ className }: MenuSeparatorProps) { - const { theme } = useContext(MenuContext); + const { theme, renderDarkMenu } = useContext(MenuContext); return (
  • ); } diff --git a/packages/menu/src/SubMenu/SubMenu.spec.tsx b/packages/menu/src/SubMenu/SubMenu.spec.tsx index c957340d14..3dee4884c8 100644 --- a/packages/menu/src/SubMenu/SubMenu.spec.tsx +++ b/packages/menu/src/SubMenu/SubMenu.spec.tsx @@ -1,107 +1,220 @@ import React from 'react'; -import { - render, - waitFor, - waitForElementToBeRemoved, -} from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Menu, MenuItem, SubMenu } from '..'; +import { waitForTransition } from '@leafygreen-ui/testing-lib'; -const subMenu1Id = 'sub-menu-1-id'; -const subMenu2Id = 'sub-menu-2-id'; -const subMenu3Id = 'sub-menu-3-id'; +import { LGIDs } from '../constants'; +import { MenuItem, SubMenu, SubMenuProps } from '..'; + +const subMenuTestId = 'sub-menu-1-id'; const menuItem1Id = 'menu-item-1-id'; const menuItem2Id = 'menu-item-2-id'; -const onClick = jest.fn(); -const renderSubMenu = () => { +const renderSubMenu = (props: Partial = {}) => { const utils = render( - - - Text Content A - - - Text Content B - - - , + + Text Content A + Text Content A + , ); return utils; }; describe('packages/sub-menu', () => { - test('renders a SubMenu open by default, when the SubMenu is active', () => { - const { getByTestId } = renderSubMenu(); - // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render - // https://jira.mongodb.org/browse/LG-2904 - waitFor(() => { - const subMenu = getByTestId(subMenu1Id); + describe('Rendering', () => { + test('renders a SubMenu ', () => { + const { getByTestId } = renderSubMenu(); + const subMenu = getByTestId(subMenuTestId); expect(subMenu).toBeInTheDocument(); - const menuItem = getByTestId(menuItem1Id); - expect(menuItem).toBeInTheDocument(); }); - }); - test('when a SubMenu is clicked, it opens and closes the previously opened SubMenu', async () => { - const { queryByTestId } = renderSubMenu(); - // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render - // https://jira.mongodb.org/browse/LG-2904 - waitFor(async () => { - const subMenuItem = queryByTestId(menuItem1Id); - expect(subMenuItem).not.toBeNull(); - expect(subMenuItem).toBeInTheDocument(); - const subMenu2 = queryByTestId(subMenu2Id); - userEvent.click(subMenu2 as HTMLElement); - await waitForElementToBeRemoved(subMenuItem); - const subMenuItem2 = queryByTestId(menuItem2Id); - expect(subMenuItem2).not.toBeNull(); - expect(subMenuItem2).toBeInTheDocument(); + test('submenu toggle button is not a child of the submenu button', () => { + const { getByTestId } = renderSubMenu(); + const subMenu = getByTestId(subMenuTestId); + const toggle = getByTestId(LGIDs.submenuToggle); + expect(subMenu.contains(toggle)).toBe(false); }); - }); - test('onClick is fired when SubMenu is clicked', async () => { - const { getByTestId } = renderSubMenu(); - // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render - // https://jira.mongodb.org/browse/LG-2904 - waitFor(() => { - const subMenu = getByTestId(subMenu1Id); - userEvent.click(subMenu); - expect(onClick).toHaveBeenCalled(); + test('renders a SubMenu open by default, when the SubMenu is active', () => { + const { getByTestId } = renderSubMenu({ active: true }); + const subMenu = getByTestId(subMenuTestId); + expect(subMenu).toBeInTheDocument(); + const menuItem = getByTestId(menuItem1Id); + expect(menuItem).toBeInTheDocument(); }); - }); - test('renders as a button by default', async () => { - const { getByTestId } = renderSubMenu(); - // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render - // https://jira.mongodb.org/browse/LG-2904 - waitFor(() => { - const subMenu = getByTestId(subMenu1Id); + test('renders a SubMenu open by default, when the initialOpen is true', () => { + const { getByTestId } = renderSubMenu({ initialOpen: true }); + const subMenu = getByTestId(subMenuTestId); + expect(subMenu).toBeInTheDocument(); + const menuItem = getByTestId(menuItem1Id); + expect(menuItem).toBeInTheDocument(); + }); + + test('renders as a button by default', async () => { + const { getByTestId } = renderSubMenu(); + const subMenu = getByTestId(subMenuTestId); expect(subMenu.tagName.toLowerCase()).toBe('button'); }); - }); - test('renders inside an anchor tag when the href prop is set', async () => { - const { getByTestId } = renderSubMenu(); - // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render - // https://jira.mongodb.org/browse/LG-2904 - waitFor(() => { - const subMenu = getByTestId(subMenu2Id); + test('renders inside an anchor tag when the href prop is set', async () => { + const { getByTestId } = renderSubMenu({ href: 'mongo' }); + const subMenu = getByTestId(subMenuTestId); expect(subMenu.tagName.toLowerCase()).toBe('a'); }); - }); - test('renders as `div` tag when the "as" prop is set', async () => { - const { getByTestId } = renderSubMenu(); - // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render - // https://jira.mongodb.org/browse/LG-2904 - waitFor(() => { - const subMenu = getByTestId(subMenu3Id); + test('renders as `div` tag when the "as" prop is set', async () => { + const { getByTestId } = renderSubMenu({ as: 'div' }); + const subMenu = getByTestId(subMenuTestId); expect(subMenu.tagName.toLowerCase()).toBe('div'); }); }); + describe('Interaction', () => { + describe('Mouse', () => { + test('clicking the submenu opens it', async () => { + const { getByTestId, queryByTestId } = renderSubMenu(); + const subMenu = getByTestId(subMenuTestId); + userEvent.click(subMenu); + + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).toBeInTheDocument(); + }); + }); + + test('clicking an open submenu closes it', async () => { + const { getByTestId, queryByTestId } = renderSubMenu({ + initialOpen: true, + }); + const subMenu = getByTestId(subMenuTestId); + userEvent.click(subMenu); + + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).not.toBeInTheDocument(); + }); + }); + + test('clicking the submenu DOES NOT open it if a click handler is provided', async () => { + const onClick = jest.fn(); + const { getByTestId, queryByTestId } = renderSubMenu({ onClick }); + const subMenu = getByTestId(subMenuTestId); + userEvent.click(subMenu); + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).not.toBeInTheDocument(); + }); + }); + + test('onClick is fired', async () => { + const onClick = jest.fn(); + const { getByTestId } = renderSubMenu({ onClick }); + const subMenu = getByTestId(subMenuTestId); + userEvent.click(subMenu); + expect(onClick).toHaveBeenCalled(); + }); + + test('onClick is fired if an href is provided', async () => { + const onClick = jest.fn(); + const { getByTestId } = renderSubMenu({ + onClick, + href: 'mongodb.design', + }); + const subMenu = getByTestId(subMenuTestId); + userEvent.click(subMenu); + expect(onClick).toHaveBeenCalled(); + }); + + test('clicking the submenu toggle button opens it', async () => { + const { getByTestId, queryByTestId } = renderSubMenu(); + const toggle = getByTestId(LGIDs.submenuToggle); + userEvent.click(toggle); + + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).toBeInTheDocument(); + }); + }); + + test('clicking the submenu toggle button closes an open menu', async () => { + const { getByTestId, queryByTestId } = renderSubMenu({ + initialOpen: true, + }); + const toggle = getByTestId(LGIDs.submenuToggle); + userEvent.click(toggle); + + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).not.toBeInTheDocument(); + }); + }); + + test('transition handlers are fired', async () => { + const onEntered = jest.fn(); + const onExited = jest.fn(); + const { getByTestId } = renderSubMenu({ + onEntered, + onExited, + }); + const subMenu = getByTestId(subMenuTestId); + userEvent.click(subMenu); + await waitForTransition(); + + await waitFor(() => { + expect(onEntered).toHaveBeenCalled(); + }); + + userEvent.click(subMenu); + await waitForTransition(); + + await waitFor(() => { + expect(onExited).toHaveBeenCalled(); + }); + }); + }); + + describe('Keyboard', () => { + describe('Arrow Keys', () => { + test('right arrow key opens the menu', async () => { + const { getByTestId, queryByTestId } = renderSubMenu(); + const subMenu = getByTestId(subMenuTestId); + userEvent.type(subMenu, '{arrowright}'); + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).toBeInTheDocument(); + }); + }); + + test('left arrow key closes the menu', async () => { + const { getByTestId, queryByTestId } = renderSubMenu({ + initialOpen: true, + }); + const subMenu = getByTestId(subMenuTestId); + userEvent.type(subMenu, '{arrowleft}'); + await waitFor(() => { + expect(queryByTestId(menuItem1Id)).not.toBeInTheDocument(); + }); + }); + }); + }); + }); + + // // This should be tested in Menu + // eslint-disable-next-line jest/no-commented-out-tests + // test.skip('when a SubMenu is clicked, it opens and closes the previously opened SubMenu', async () => { + // const { queryByTestId } = renderSubMenu(); + // // TODO: Fix redundant rendering in `Menu`. The submenu is closed on initial render, but opens on second render + // // https://jira.mongodb.org/browse/LG-2904 + // waitFor(async () => { + // const subMenuItem = queryByTestId(menuItem1Id); + // expect(subMenuItem).not.toBeNull(); + // expect(subMenuItem).toBeInTheDocument(); + // const subMenu2 = queryByTestId(subMenu2Id); + // userEvent.click(subMenu2 as HTMLElement); + // await waitForElementToBeRemoved(subMenuItem); + // const subMenuItem2 = queryByTestId(menuItem2Id); + // expect(subMenuItem2).not.toBeNull(); + // expect(subMenuItem2).toBeInTheDocument(); + // }); + // }); + /* eslint-disable jest/no-disabled-tests, jest/expect-expect */ describe.skip('Types behave as expected', () => { test('Accepts string as `as` prop', () => { @@ -111,11 +224,7 @@ describe('packages/sub-menu', () => { const As = ({ children }: { children: React.ReactNode }) => ( <>{children} ); - render( - - Test - , - ); + render(Test); }); test.skip('types', () => { diff --git a/packages/menu/src/SubMenu/SubMenu.stories.tsx b/packages/menu/src/SubMenu/SubMenu.stories.tsx new file mode 100644 index 0000000000..d2d1fd9306 --- /dev/null +++ b/packages/menu/src/SubMenu/SubMenu.stories.tsx @@ -0,0 +1,54 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import React from 'react'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import Icon from '@leafygreen-ui/icon'; + +import { MenuProps } from '../Menu'; +import { MenuItem } from '../MenuItem'; + +import { SubMenu } from '.'; + +export default { + title: 'Components/Menu/SubMenu', + component: SubMenu, + args: { + active: false, + }, + parameters: { + default: null, + controls: { + exclude: ['open', 'setOpen'], + }, + }, +} satisfies StoryMetaType>; + +export const LiveExample = { + render: args => { + return ( +
    + + Apple + Banana + Carrot + + }> + JalapeƱo + Habanero + }>Ghost + +
    + ); + }, + args: { + title: 'Sub menu', + description: 'This is a description', + }, +} satisfies StoryObj; diff --git a/packages/menu/src/SubMenu/SubMenu.styles.ts b/packages/menu/src/SubMenu/SubMenu.styles.ts index bd9c8688c6..43bcc99483 100644 --- a/packages/menu/src/SubMenu/SubMenu.styles.ts +++ b/packages/menu/src/SubMenu/SubMenu.styles.ts @@ -1,236 +1,35 @@ -import { css, cx } from '@leafygreen-ui/emotion'; -import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; -import { fontWeights, transitionDuration } from '@leafygreen-ui/tokens'; +import { css } from '@leafygreen-ui/emotion'; +import { createUniqueClassName } from '@leafygreen-ui/lib'; +import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; -export const subMenuContainerClassName = - createUniqueClassName('sub-menu-container'); -export const iconButtonClassName = createUniqueClassName('icon-button'); -export const chevronClassName = createUniqueClassName('icon-button-chevron'); +import { LGIDs } from '../constants'; +import { menuItemContainerStyles } from '../MenuItem/MenuItem.styles'; -export const iconButtonContainerSize = 28; +export const subMenuContainerClassName = createUniqueClassName(LGIDs.submenu); +export const subMenuToggleClassName = createUniqueClassName( + LGIDs.submenu + '-trigger', +); -export const subMenuStyle = css` - padding-right: ${iconButtonContainerSize + 16}px; - align-items: center; - justify-content: flex-start; +export const subMenuContainerStyles = css` + ${menuItemContainerStyles} + position: relative; `; -export const subMenuThemeStyle: Record = { - [Theme.Light]: cx( - subMenuStyle, - css` - background-color: ${palette.black}; - `, - ), - [Theme.Dark]: cx( - subMenuStyle, - css` - background-color: ${palette.gray.dark3}; - - &:hover { - .${iconButtonClassName} { - background-color: ${palette.gray.dark2}; - } - } - `, - ), -}; - -export const subMenuOpenStyle: Record = { - [Theme.Light]: css` - background-color: transparent; - - &:hover { - background-color: ${palette.gray.dark3}; - } - `, - [Theme.Dark]: css` - background-color: transparent; - - &:hover { - background-color: ${palette.gray.dark2}; - } - `, -}; - -export const closedIconStyle: Record = { - [Theme.Light]: css` - transition: color 200ms ease-in-out; - color: ${palette.gray.light1}; - `, - [Theme.Dark]: css` - transition: color 200ms ease-in-out; - color: ${palette.gray.light1}; - `, -}; - -export const openIconStyle: Record = { - [Theme.Light]: css` - color: ${palette.gray.light1}; - `, - [Theme.Dark]: css` - color: ${palette.gray.light1}; - `, -}; - -export const iconButtonStyle = css` +export const submenuToggleStyles = css` position: absolute; - z-index: 1; - right: 8px; - top: 0; - bottom: 0; - margin: auto; - transition: background-color ${transitionDuration.default}ms ease-in-out; + right: ${spacing[300]}px; + // Ensure the trigger is centered regardless of element height + top: 50%; + transform: translateY(-50%); `; -export const iconButtonThemeStyle: Record = { - [Theme.Light]: css` - background-color: ${palette.black}; - - &:hover { - background-color: ${palette.gray.dark2}; - } - `, - [Theme.Dark]: css` - &:hover { - &:before { - background-color: ${palette.gray.dark1}; - } - - svg { - color: ${palette.gray.light3}; - } - } - `, -}; - -export const iconButtonFocusedThemeStyle: Record = { - [Theme.Light]: css` - &:focus-visible { - .${chevronClassName} { - color: ${palette.white}; - } - } - `, - [Theme.Dark]: css` - &:focus-visible { - .${chevronClassName} { - color: ${palette.black}; - } - } - `, -}; - -export const openIconButtonStyle: Record = { - [Theme.Light]: css` - background-color: ${palette.black}; - `, - [Theme.Dark]: css` - background-color: ${palette.gray.dark3}; - `, -}; - -export const ulStyle = css` +export const getSubmenuListStyles = () => css` list-style: none; + margin: 0; padding: 0; - height: 0; + max-height: 0; + height: max-content; overflow: hidden; - transition: height ${transitionDuration.default}ms ease-in-out; + transition: max-height ${transitionDuration.default}ms ease-in-out; position: relative; - - &::before { - content: ''; - position: absolute; - height: 1px; - right: 0; - z-index: 1; - } - - &::before { - top: 0; - } - - &::after { - bottom: 0; - } `; - -export const ulThemeStyles: Record = { - [Theme.Light]: css` - &::before, - &::after { - background-color: ${palette.gray.dark2}; - } - `, - [Theme.Dark]: css` - &::before, - &::after { - background-color: ${palette.gray.dark2}; - } - `, -}; - -export const menuItemText = css` - width: 100%; - font-weight: ${fontWeights.regular}; - font-size: 13px; - line-height: 16px; - padding-left: 16px; - text-shadow: none; -`; - -export const menuItemBorder = css` - position: absolute; - width: 100%; - height: 1px; - background: ${palette.gray.dark2}; - top: 0; -`; - -export const menuItemBorderBottom = css` - ${menuItemBorder}; - top: unset; - bottom: 0; -`; - -export const subItemStyle = css` - // Reassign the variable for specificity - --lg-menu-item-text-color: ${palette.gray.light1}; - position: relative; - min-height: 32px; - - > div { - padding-left: 16px; - } - - &::after { - content: ''; - position: absolute; - height: 1px; - right: 0; - z-index: 1; - bottom: 0; - } -`; - -export const subItemThemeStyle: Record = { - [Theme.Light]: css` - color: ${palette.gray.light1}; - - &::after { - background-color: ${palette.gray.dark2}; - } - `, - [Theme.Dark]: css` - color: ${palette.gray.light2}; - - &:hover { - color: ${palette.white}; - } - - &::after { - background-color: ${palette.gray.dark2}; - } - `, -}; diff --git a/packages/menu/src/SubMenu/SubMenu.tsx b/packages/menu/src/SubMenu/SubMenu.tsx index 798927984e..5e6895eb3e 100644 --- a/packages/menu/src/SubMenu/SubMenu.tsx +++ b/packages/menu/src/SubMenu/SubMenu.tsx @@ -1,5 +1,11 @@ -import React, { useCallback, useContext, useState } from 'react'; +import React, { + KeyboardEventHandler, + MouseEventHandler, + useEffect, + useRef, +} from 'react'; import { Transition } from 'react-transition-group'; +import { EnterHandler, ExitHandler } from 'react-transition-group/Transition'; import PropTypes from 'prop-types'; import { useDescendant } from '@leafygreen-ui/descendants'; @@ -7,349 +13,217 @@ import { css, cx } from '@leafygreen-ui/emotion'; import ChevronDownIcon from '@leafygreen-ui/icon/dist/ChevronDown'; import ChevronUpIcon from '@leafygreen-ui/icon/dist/ChevronUp'; import IconButton from '@leafygreen-ui/icon-button'; -import { getNodeTextContent } from '@leafygreen-ui/lib'; +import { keyMap } from '@leafygreen-ui/lib'; import { InferredPolymorphic, useInferredPolymorphic, } from '@leafygreen-ui/polymorphic'; -import { MenuContext, MenuDescendantsContext } from '../MenuContext'; -import { - activeDescriptionTextStyle, - activeIconStyle, - activeMenuItemContainerStyle, - activeTitleTextStyle, - descriptionTextThemeStyle, - disabledMenuItemContainerThemeStyle, - disabledTextStyle, - focusedMenuItemContainerStyle, - focusedSubMenuItemBorderStyles, - getFocusedStyles, - getHoverStyles, - linkDescriptionTextStyle, - linkStyle, - mainIconBaseStyle, - mainIconThemeStyle, - menuItemContainerStyle, - menuItemContainerThemeStyle, - menuItemHeight, - paddingLeftWithGlyph, - paddingLeftWithoutGlyph, - textContainer, - titleTextStyle, -} from '../styles'; -import { Size } from '../types'; +import { LGIDs } from '../constants'; +import { MenuDescendantsContext, useMenuContext } from '../MenuContext'; +import { InternalMenuItemContent } from '../MenuItem/InternalMenuItemContent'; import { - chevronClassName, - closedIconStyle, - iconButtonClassName, - iconButtonFocusedThemeStyle, - iconButtonStyle, - iconButtonThemeStyle, - menuItemBorder, - menuItemBorderBottom, - menuItemText, - openIconButtonStyle, - openIconStyle, - subItemStyle, - subItemThemeStyle, + getSubmenuListStyles, subMenuContainerClassName, - subMenuOpenStyle, - subMenuThemeStyle, - ulStyle, - ulThemeStyles, + subMenuContainerStyles, + subMenuToggleClassName, + submenuToggleStyles, } from './SubMenu.styles'; -import { SubMenuProps } from './SubMenu.types'; +import { InternalSubMenuProps } from './SubMenu.types'; +import { SubMenuProvider, useSubMenuContext } from './SubMenuContext'; +import { useChildrenHeight } from './useChildrenHeight'; import { useControlledState } from './useControlledState'; -const subMenuItemHeight = 36; - -export const SubMenu = InferredPolymorphic( +export const SubMenu = InferredPolymorphic( ( { + as: asProp, + open: openProp, + setOpen: setOpenProp, + initialOpen = false, title, - children, onClick, - description, + onEntered, + onExited, className, - glyph, - onExited = () => {}, - open: openProp = false, - setOpen: setOpenProp, - active = false, - disabled = false, - size = Size.Default, - as, - ...rest + children, + ...restProps }, - fwdRef: React.Ref, + fwdRef, ): React.ReactElement => { - const { Component } = useInferredPolymorphic(as, rest, 'button'); + const { as, rest } = useInferredPolymorphic(asProp, restProps, 'button'); + const { active, disabled } = rest; + + const { highlight, setHighlight } = useMenuContext(); const { - theme, - darkMode, - highlightIndex: _highlightIndex, - } = useContext(MenuContext); - const { ref } = useDescendant(MenuDescendantsContext, fwdRef, { + index: descendantIndex, + ref: descendantRef, + id: descendantId, + } = useDescendant(MenuDescendantsContext, fwdRef, { active, disabled, }); + const { depth } = useSubMenuContext(); - const [open, setOpen] = useControlledState(false, openProp, setOpenProp); + const [open, setOpen] = useControlledState( + initialOpen, + openProp, + setOpenProp, + ); - const hoverStyles = getHoverStyles(subMenuContainerClassName, theme); - const focusStyles = getFocusedStyles(subMenuContainerClassName, theme); + // Regardless of the `open` state, + // if `active` has been toggled true, + // we open the menu + useEffect(() => { + if (rest.active) { + setOpen(true); + } + }, [rest.active, setOpen]); - const nodeRef = React.useRef(null); + const submenuRef = useRef(null); + const submenuTriggerRef = useRef(null); + const subMenuHeight = useChildrenHeight(submenuRef, [open]); - const [iconButtonElement, setIconButtonElement] = - useState(null); + const ChevronIcon = open ? ChevronDownIcon : ChevronUpIcon; - const onRootClick = useCallback( - ( - e: React.MouseEvent & - React.MouseEvent, - ) => { - if (iconButtonElement?.contains(e.target as HTMLElement)) { - e.preventDefault(); - } else if (onClick) { - onClick(e); - } - }, - [iconButtonElement, onClick], - ); + const handleClick: MouseEventHandler = e => { + if (onClick || rest.href) { + onClick?.(e); + } else { + setOpen(x => !x); + } + }; - const numberOfMenuItems = React.Children.toArray(children).length; + const handleKeydown: KeyboardEventHandler = e => { + switch (e.key) { + case keyMap.ArrowLeft: { + setOpen(false); + break; + } - const ChevronIcon = open ? ChevronDownIcon : ChevronUpIcon; - const chevronIconStyles = cx({ - [openIconStyle[theme]]: open, - [closedIconStyle[theme]]: !open, - }); + case keyMap.ArrowRight: { + setOpen(true); + break; + } + } + }; - const handleChevronClick = (e: React.MouseEvent) => { + const handleToggleClick: MouseEventHandler = e => { + // Prevent links from navigating + e.preventDefault(); // we stop the event from propagating and closing the entire menu e.nativeEvent.stopImmediatePropagation(); - setOpen(o => !o); + e.stopPropagation(); + + setOpen(x => !x); }; - // TODO: This code is duplicated in `MenuItem` - // We should consider combining these. - // See: https://github.com/mongodb/leafygreen-ui/pull/1176 - const isAnchor = Component === 'a'; + // When the submenu has opened + const handleTransitionEntered: EnterHandler = () => { + // this element should be highlighted + if (descendantId === highlight?.id) { + // ensure this element is still focused after transitioning + descendantRef.current?.focus(); + } else { + // Otherwise ensure the focus is on the correct element + highlight?.ref?.current?.focus(); + } + + onEntered?.(); + }; - const updatedGlyph = - glyph && - React.cloneElement(glyph, { - role: 'presentation', - className: cx( - mainIconBaseStyle, - mainIconThemeStyle[theme], - focusStyles.iconStyle, - { - [activeIconStyle[theme]]: active, - }, - glyph.props?.className, - ), - }); + // When the submenu starts to close + const handleTransitionExiting: ExitHandler = () => { + const currentHighlightElement = highlight?.ref?.current; - const baseProps = { - role: 'menuitem', - 'aria-haspopup': true, - onClick: onRootClick, - tabIndex: disabled ? -1 : undefined, - 'aria-disabled': disabled, - // only add a disabled prop if not an anchor - ...(typeof rest.href !== 'string' && { disabled }), - }; + if (currentHighlightElement) { + // if one of this submenu's children is highlighted + // and we close the submenu, + // then focus the main submenu item + const doesSubmenuContainCurrentHighlight = + submenuRef?.current?.contains(currentHighlightElement); - const anchorProps = isAnchor - ? { - target: '_self', - rel: '', + if (doesSubmenuContainCurrentHighlight) { + setHighlight?.(descendantId); + descendantRef?.current?.focus(); } - : {}; + } + }; - const content = ( - <> - {updatedGlyph} -
    -
    - {title} -
    - {description && ( -
    - {description} -
    - )} -
    - - ); + // When the submenu has closed + const handleTransitionExited: ExitHandler = () => { + // When the submenu closes, ensure the focus is on the correct element + highlight?.ref?.current?.focus(); + onExited?.(); + }; return ( -
  • - +
  • - {content} + + {title} + - + - - - - {(state: string) => ( -
      + + + {(state: string) => ( +
        - {/* TODO: Remove map. Replace with SubMenu context. Read from this context in MenuItem */} - {React.Children.map( - children as React.ReactElement, - (child, index) => { - const { className, ...rest } = child.props; - return React.cloneElement(child, { - size: Size.Default, - children: ( - <> -
        - - {child.props.children} - - {index === numberOfMenuItems - 1 && ( -
        - )} - - ), - className: cx( - subItemStyle, - subItemThemeStyle[theme], - css` - // padding-left of the button - padding-left: ${glyph - ? paddingLeftWithGlyph - : paddingLeftWithoutGlyph}px; - &::after { - // this is the width for the button bottom border - width: calc( - 100% - - ${glyph - ? paddingLeftWithGlyph - : paddingLeftWithoutGlyph}px - ); - } - `, - focusedSubMenuItemBorderStyles[theme], - child.props.className, - ), - onClick: ( - e: React.MouseEvent & - React.MouseEvent, - ) => { - child.props?.onClick?.(e); - if (onClick) { - onClick(e); - } - }, - ...rest, - }); - }, - )} -
      - )} -
      - + })} + > + {children} +
    + )} +
    + + ); }, ); diff --git a/packages/menu/src/SubMenu/SubMenu.types.ts b/packages/menu/src/SubMenu/SubMenu.types.ts index 58b01433d0..c46e057356 100644 --- a/packages/menu/src/SubMenu/SubMenu.types.ts +++ b/packages/menu/src/SubMenu/SubMenu.types.ts @@ -1,11 +1,15 @@ import { type Dispatch, type SetStateAction } from 'react'; import { ExitHandler } from 'react-transition-group/Transition'; -import { HTMLElementProps } from '@leafygreen-ui/lib'; +import { + InferredPolymorphicProps, + PolymorphicAs, +} from '@leafygreen-ui/polymorphic'; -import { Size } from '../types'; +import { MenuItemProps } from '../MenuItem'; -export interface SubMenuProps extends HTMLElementProps<'button'> { +export interface InternalSubMenuProps + extends Omit { /** * Determines if `` item appears open */ @@ -17,36 +21,16 @@ export interface SubMenuProps extends HTMLElementProps<'button'> { setOpen?: Dispatch>; /** - * className applied to `SubMenu` root element - */ - className?: string; - - /** - * Content to appear below main text of SubMenu - */ - description?: string | React.ReactElement; - - /** - * Determines if `` item appears disabled - */ - disabled?: boolean; - - /** - * Determines if `` item appears active - */ - active?: boolean; - - /** - * Slot to pass in an Icon rendered to the left of `SubMenu` text. - * - * @type `` component + * Whether the submenu should initially be open. * + * (will be overridden by either `open` or `active`) */ - glyph?: React.ReactElement; + initialOpen?: boolean; /** * Main text rendered in `SubMenu`. */ + // TODO: Should this be a `ReactNode`? title?: string; /** @@ -55,14 +39,19 @@ export interface SubMenuProps extends HTMLElementProps<'button'> { */ children?: React.ReactNode; - onClick?: React.MouseEventHandler; - - onExited?: ExitHandler; - - href?: string; + /** + * Callback fired when the Submenu opens + */ + onEntered?: ExitHandler; /** - * Size of the MenuItem component, can be `default` or `large`. This size only affects the parent MenuItem, nested child MenuItems do not change. + * Callback fired when the Submenu closes */ - size?: Size; + onExited?: ExitHandler; } + +// External only +export type SubMenuProps = InferredPolymorphicProps< + PolymorphicAs, + InternalSubMenuProps +>; diff --git a/packages/menu/src/SubMenu/SubMenuContext.tsx b/packages/menu/src/SubMenu/SubMenuContext.tsx new file mode 100644 index 0000000000..51e50bd6f2 --- /dev/null +++ b/packages/menu/src/SubMenu/SubMenuContext.tsx @@ -0,0 +1,23 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +export interface SubMenuContextData { + depth: number; + hasIcon: boolean; +} + +export const SubMenuContext = createContext({ + depth: 0, + hasIcon: false, +}); + +export const SubMenuProvider = ({ + children, + depth, + hasIcon = false, +}: PropsWithChildren) => ( + + {children} + +); + +export const useSubMenuContext = () => useContext(SubMenuContext); diff --git a/packages/menu/src/SubMenu/index.ts b/packages/menu/src/SubMenu/index.ts index 2a71ccfc0b..3e99bbf493 100644 --- a/packages/menu/src/SubMenu/index.ts +++ b/packages/menu/src/SubMenu/index.ts @@ -1,2 +1,3 @@ export { SubMenu } from './SubMenu'; -export { SubMenuProps } from './SubMenu.types'; +export { InternalSubMenuProps, SubMenuProps } from './SubMenu.types'; +export { useSubMenuContext } from './SubMenuContext'; diff --git a/packages/menu/src/SubMenu/useChildrenHeight.ts b/packages/menu/src/SubMenu/useChildrenHeight.ts new file mode 100644 index 0000000000..9fb68af84e --- /dev/null +++ b/packages/menu/src/SubMenu/useChildrenHeight.ts @@ -0,0 +1,32 @@ +import { DependencyList, RefObject, useEffect, useState } from 'react'; + +/** + * Calculates the cumulative height of an element's children + */ +export const useChildrenHeight = ( + parentRef: RefObject, + // open: boolean, + deps: DependencyList, +): number => { + const [height, setHeight] = useState(0); + + useEffect(() => { + setHeight(calcChildrenHeight(parentRef)); + }, [parentRef, deps]); + + return height; +}; + +const calcChildrenHeight = ( + parentRef: RefObject, +): number => { + if (!parentRef.current) return 0; + + const height = Array.from(parentRef.current.childNodes).reduce((h, child) => { + const childHeight = (child as HTMLElement).clientHeight; + h += childHeight; + return h; + }, 0); + + return height; +}; diff --git a/packages/menu/src/SubMenu/useControlledState.ts b/packages/menu/src/SubMenu/useControlledState.ts index 53428bade1..096528d61f 100644 --- a/packages/menu/src/SubMenu/useControlledState.ts +++ b/packages/menu/src/SubMenu/useControlledState.ts @@ -1,7 +1,7 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; import isUndefined from 'lodash/isUndefined'; -import { consoleOnce } from '@leafygreen-ui/lib'; +import { consoleOnce, isDefined } from '@leafygreen-ui/lib'; export const useControlledState = ( initialState: T, @@ -9,7 +9,7 @@ export const useControlledState = ( setControlledState?: Dispatch>, ): [T, Dispatch>] => { const isControlled = - !isUndefined(controlledState) && !isUndefined(setControlledState); + isDefined(controlledState) && isDefined(setControlledState); const [internalState, setInternalState] = useState(initialState); useEffect(() => { diff --git a/packages/menu/src/constants.ts b/packages/menu/src/constants.ts new file mode 100644 index 0000000000..f9f88bc341 --- /dev/null +++ b/packages/menu/src/constants.ts @@ -0,0 +1,8 @@ +const LGID_ROOT = 'lg-menu'; + +export const LGIDs = { + root: LGID_ROOT, + item: LGID_ROOT + '-menu_item', + submenu: LGID_ROOT + '-submenu', + submenuToggle: LGID_ROOT + '-submenu_toggle', +} as const; diff --git a/packages/menu/src/styles.ts b/packages/menu/src/styles.ts index f2e4cf1194..6d4db4c5f0 100644 --- a/packages/menu/src/styles.ts +++ b/packages/menu/src/styles.ts @@ -1,402 +1,67 @@ -import { css, cx } from '@leafygreen-ui/emotion'; -import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; -import { - fontFamilies, - fontWeights, - transitionDuration, -} from '@leafygreen-ui/tokens'; - -import { iconButtonClassName } from './SubMenu/SubMenu.styles'; -import { Size } from './types'; - export const svgWidth = 24; export const paddingLeftWithGlyph = 54; export const paddingLeftWithoutGlyph = 20; -const indentation = 20; -const wedgeWidth = 4; - -/** - * Base styles - */ -export const menuItemContainerStyle = css` - display: flex; - position: relative; - box-sizing: border-box; - flex-direction: row; - align-items: center; - width: 100%; - margin: unset; - padding-left: ${indentation}px; - padding-right: ${indentation}px; - font-family: ${fontFamilies.default}; - font-size: 13px; - text-align: left; - text-decoration: none; - cursor: pointer; - border: none; - - transition: background-color ${transitionDuration.default}ms ease-in-out; - - &:focus { - outline: none; - text-decoration: none; - } - - &:before { - content: ''; - position: absolute; - width: ${wedgeWidth}px; - left: 0px; - border-radius: 0 ${wedgeWidth}px ${wedgeWidth}px 0; - background-color: transparent; - transition: background-color ${transitionDuration.default}ms ease-in-out; - } - - &:hover { - text-decoration: none; - } -`; - -export const menuItemContainerThemeStyle: Record = { - [Theme.Light]: css` - color: ${palette.white}; - background-color: ${palette.black}; - - &:hover, - &:active { - background-color: ${palette.gray.dark3}; - } - `, - [Theme.Dark]: css` - color: ${palette.gray.light2}; - background-color: ${palette.gray.dark3}; - - &:hover, - &:active { - background-color: ${palette.gray.dark2}; - } - `, -}; - -export const menuItemHeight = (size: Size) => { - return css` - min-height: ${size === Size.Default ? 36 : 45}px; - - &:before { - height: ${size === Size.Default ? 22 : 36}px; - } - `; -}; - -export const textContainer = css` - width: 100%; - overflow: hidden; - padding: 2px 0; -`; - -export const mainIconBaseStyle = css` - margin-right: 16px; - flex-shrink: 0; -`; - -export const mainIconThemeStyle: Record = { - [Theme.Light]: css` - color: ${palette.gray.base}; - `, - [Theme.Dark]: css` - color: ${palette.gray.light1}; - `, -}; - -export const titleTextStyle = css` - display: inline-flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - width: 100%; - font-size: 13px; - font-weight: ${fontWeights.medium}; - - // We create a pseudo element that's the width of the bolded text - // This way there's no layout shift on hover when the text is bolded. - &:after { - content: attr(data-text); - height: 0; - font-weight: ${fontWeights.bold}; - visibility: hidden; - overflow: hidden; - user-select: none; - pointer-events: none; - } -`; - -const descriptionTextStyle = css` - font-size: 13px; - font-weight: ${fontWeights.regular}; - line-height: 16px; -`; - -export const descriptionTextThemeStyle: Record = { - [Theme.Light]: cx( - descriptionTextStyle, - css` - color: ${palette.gray.light1}; - `, - ), - [Theme.Dark]: cx( - descriptionTextStyle, - css` - color: ${palette.gray.light1}; - `, - ), -}; - -export const linkDescriptionTextStyle = css` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -/** - * Hover Styles - */ - -export const getHoverStyles = (containerClass: string, theme: Theme) => ({ - text: css` - .${containerClass} { - &:not(:disabled):hover & { - font-weight: ${fontWeights.bold}; - } - } - `, - activeText: css` - .${containerClass} { - &:not(:disabled):hover & { - color: ${theme === Theme.Light - ? palette.green.base - : palette.green.dark3}; - } - } - `, -}); - -/** - * Active styles - */ -export const activeMenuItemContainerStyle: Record = { - [Theme.Light]: css` - background-color: ${palette.black}; - - &:before { - background-color: ${palette.green.base}; - } - - &:hover { - color: ${palette.green.base}; - &:before { - background-color: ${palette.green.base}; - } - } - `, - [Theme.Dark]: css` - background-color: ${palette.gray.dark3}; - - &:before { - background-color: ${palette.green.base}; - } - - &:hover { - color: ${palette.white}; - - &:before { - background-color: ${palette.green.base}; - } - } - `, -}; - -export const activeTitleTextStyle: Record = { - [Theme.Light]: css` - font-weight: ${fontWeights.bold}; - color: ${palette.green.base}; - `, - [Theme.Dark]: css` - font-weight: ${fontWeights.bold}; - color: ${palette.gray.light2}; - `, -}; - -export const activeDescriptionTextStyle: Record = { - [Theme.Light]: css` - color: ${palette.gray.light1}; - `, - [Theme.Dark]: css` - color: ${palette.gray.light1}; - `, -}; - -export const activeIconStyle: Record = { - [Theme.Light]: css` - color: ${palette.green.base}; - `, - [Theme.Dark]: css` - color: ${palette.green.base}; - `, -}; - -/** - * Disabled styles - */ - -const disabledMenuItemContainerStyle = css` - cursor: not-allowed; - - &:active { - pointer-events: none; - background-color: unset; - } - - &:hover { - &, - &:before { - background-color: unset; - } - } -`; - -export const disabledMenuItemContainerThemeStyle: Record = { - [Theme.Dark]: cx( - disabledMenuItemContainerStyle, - css` - background-color: ${palette.gray.dark3}; - - &:hover { - background-color: ${palette.gray.dark3}; - } - `, - ), - [Theme.Light]: cx( - disabledMenuItemContainerStyle, - css` - background-color: ${palette.black}; - - &:hover { - background-color: ${palette.black}; - } - `, - ), -}; - -export const disabledTextStyle: Record = { - [Theme.Light]: css` - color: ${palette.gray.dark1}; - font-weight: ${fontWeights.regular}; - `, - [Theme.Dark]: css` - color: ${palette.gray.dark1}; - font-weight: ${fontWeights.regular}; - `, -}; - -/** - * Focused styles - */ -export const focusedMenuItemContainerStyle: Record = { - [Theme.Light]: css` - &:focus { - text-decoration: none; - background-color: ${palette.blue.dark3}; - color: ${palette.white}; - - &:before { - background-color: ${palette.blue.light1}; - } - } - - &::-moz-focus-inner { - border: 0; - } - `, - [Theme.Dark]: css` - &:focus { - text-decoration: none; - background-color: ${palette.blue.dark3}; - color: ${palette.blue.light3}; - - &:before { - background-color: ${palette.blue.light1}; - } - - .${iconButtonClassName} { - background-color: ${palette.blue.dark3}; - } - } - - &::-moz-focus-inner { - border: 0; - } - `, -}; - -export const getFocusedStyles = (containerClassName: string, theme: Theme) => { - return { - textStyle: css` - .${containerClassName}:focus & { - color: ${theme === Theme.Light - ? palette.blue.light3 - : palette.blue.light3}; - } - `, - descriptionStyle: css` - .${containerClassName}:focus & { - color: ${theme === Theme.Light - ? palette.blue.light3 - : palette.blue.light3}; - } - `, - iconStyle: css` - .${containerClassName}:focus & { - color: ${theme === Theme.Light - ? palette.blue.light3 - : palette.blue.light3}; - } - `, - }; -}; - -/** - * Destructive styles - */ -export const destructiveTextStyle: Record = { - [Theme.Light]: css` - color: ${palette.red.light1}; - font-weight: ${fontWeights.regular}; - `, - [Theme.Dark]: css` - color: ${palette.red.light1}; - font-weight: ${fontWeights.regular}; - `, -}; - -export const linkStyle = css` - text-decoration: none; -`; - -export const focusedSubMenuItemBorderStyles: Record = { - [Theme.Light]: css` - &:focus { - &::after { - background-color: ${palette.blue.dark3}; - } - } - `, - [Theme.Dark]: css` - &:focus { - &::after { - background-color: ${palette.blue.dark3}; - } - } - `, -}; +import { RecursiveRecord, Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { color, InteractionState, Property } from '@leafygreen-ui/tokens'; + +const MenuInteractionState = { + ...InteractionState, + Active: 'active', +} as const; +export type MenuInteractionState = + (typeof MenuInteractionState)[keyof typeof MenuInteractionState]; + +// Menu dark/light mode colors intentionally do not line up with tokens +export const menuColor = { + [Theme.Light]: { + [Property.Background]: { + [MenuInteractionState.Default]: palette.white, + [MenuInteractionState.Active]: palette.green.light3, + [MenuInteractionState.Hover]: '', // TBD + [MenuInteractionState.Focus]: color.light.background.primary.focus, // ??? + }, + [Property.Border]: { + [MenuInteractionState.Default]: palette.gray.light2, + [MenuInteractionState.Active]: palette.green.dark1, + [MenuInteractionState.Focus]: palette.blue.base, + }, + [Property.Text]: { + [MenuInteractionState.Default]: palette.black, + [MenuInteractionState.Active]: palette.green.dark2, + [MenuInteractionState.Focus]: color.light.text.primary.focus, + }, + [Property.Icon]: { + [MenuInteractionState.Default]: palette.gray.dark1, + [MenuInteractionState.Active]: palette.green.dark1, + [MenuInteractionState.Focus]: color.light.icon.primary.focus, + }, + }, + [Theme.Dark]: { + [Property.Background]: { + [MenuInteractionState.Default]: palette.gray.dark3, + [MenuInteractionState.Active]: palette.green.dark3, + [MenuInteractionState.Hover]: palette.gray.dark2, + [MenuInteractionState.Focus]: color.dark.background.primary.focus, + }, + [Property.Border]: { + [MenuInteractionState.Default]: palette.gray.dark2, + [MenuInteractionState.Focus]: color.dark.border.primary.focus, + [MenuInteractionState.Active]: palette.green.base, + }, + [Property.Text]: { + [MenuInteractionState.Default]: palette.gray.light2, + [MenuInteractionState.Focus]: color.dark.text.primary.focus, + [MenuInteractionState.Active]: palette.green.base, + }, + [Property.Icon]: { + [MenuInteractionState.Default]: palette.gray.light1, + [MenuInteractionState.Active]: palette.green.base, + [MenuInteractionState.Focus]: color.dark.icon.primary.focus, + }, + }, +} as const satisfies RecursiveRecord< + [Theme, Property, MenuInteractionState, string], + false +>; diff --git a/yarn.lock b/yarn.lock index 1e2100ff31..783a9663c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7516,9 +7516,9 @@ camelcase@^7.0.0: integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw== caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001585, caniuse-lite@^1.0.30001587: - version "1.0.30001629" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001629.tgz#907a36f4669031bd8a1a8dbc2fa08b29e0db297e" - integrity sha512-c3dl911slnQhmxUIT4HhYzT7wnBK/XYpGnYLOj4nJBaRiw52Ibe7YxlDaAeRECvA786zCuExhxIUJ2K7nHMrBw== + version "1.0.30001636" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz#b15f52d2bdb95fad32c2f53c0b68032b85188a78" + integrity sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" @@ -13157,6 +13157,13 @@ polished@^4.1.3, polished@^4.2.2: dependencies: "@babel/runtime" "^7.17.8" +polished@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" + integrity sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA== + dependencies: + "@babel/runtime" "^7.17.8" + postcss-modules-extract-imports@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d"