-
Notifications
You must be signed in to change notification settings - Fork 46
feat(evo-react): add fake menu #790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0888cb3
6e99c88
39c443c
4b23dfa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@ebay/skin": patch | ||
| "@evo-web/react": patch | ||
| --- | ||
|
|
||
| Add EvoFakeMenu component. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| # ebay-fake-menu → evo-fake-menu | ||
|
|
||
| EvoFakeMenu uses the same explicit compound-component API as EvoMenu. Add `EvoFakeMenuItems` around all items, badges, and separators. | ||
|
|
||
| **Before:** | ||
|
|
||
| ```tsx | ||
| import { | ||
| EbayFakeMenu, | ||
| EbayFakeMenuItem, | ||
| EbayFakeMenuSeparator, | ||
| } from "@ebay/ui-core-react/ebay-fake-menu"; | ||
|
|
||
| <EbayFakeMenu | ||
| itemMatchesUrl={false} | ||
| onSelect={(_, { index }) => handleSelect(index)} | ||
| > | ||
| <EbayFakeMenuItem | ||
| href="/messages" | ||
| current | ||
| badgeNumber={5} | ||
| badgeAriaLabel="5 unread messages" | ||
| > | ||
| Messages | ||
| </EbayFakeMenuItem> | ||
| <EbayFakeMenuSeparator /> | ||
| </EbayFakeMenu>; | ||
| ``` | ||
|
|
||
| **After:** | ||
|
|
||
| ```tsx | ||
| import { | ||
| EvoFakeMenu, | ||
| EvoFakeMenuItem, | ||
| EvoFakeMenuItemBadge, | ||
| EvoFakeMenuItems, | ||
| EvoFakeMenuSeparator, | ||
| } from "@evo-web/react/fake-menu"; | ||
|
|
||
| <EvoFakeMenu> | ||
| <EvoFakeMenuItems> | ||
| <EvoFakeMenuItem | ||
| href="/messages" | ||
| current | ||
| itemMatchesUrl={false} | ||
| onClick={handleMessagesSelect} | ||
| > | ||
| Messages | ||
| <EvoFakeMenuItemBadge number={5} a11yText="5 unread messages" /> | ||
| </EvoFakeMenuItem> | ||
| <EvoFakeMenuSeparator /> | ||
| </EvoFakeMenuItems> | ||
| </EvoFakeMenu>; | ||
| ``` | ||
|
|
||
| ## Additional changes | ||
|
|
||
| - Add the required `EvoFakeMenuItems` list wrapper. | ||
| - Move root `itemMatchesUrl` to each applicable `EvoFakeMenuItem`. | ||
| - Replace parent `onSelect` with native `onClick` handlers on individual items; callbacks no longer receive child indexes. | ||
| - Move `onKeyDown` to `EvoFakeMenuItems` or an individual item. It now receives only the native keyboard event. | ||
| - Replace `badgeNumber` and `badgeAriaLabel` with `EvoFakeMenuItemBadge`. Rename the accessible label to `a11yText`. | ||
| - Replace `forwardedRef` with the native React 19 `ref`. The root ref now targets a `<span>` instead of a `<div>`; `EvoFakeMenuItems` accepts a list ref. | ||
| - Button item `type` now supports both `"button"` and `"submit"`. | ||
| - Disabled links no longer retain `href`; they receive `aria-disabled="true"`. | ||
| - Anchor items accept `as` for framework link adapters, such as `as={({ href, ...rest }) => <Link {...rest} to={href} />}` for React Router. | ||
| - `classPrefix`, `reverse`, `fixed`, and `fixWidth` are now available to match evo-marko embedding behavior. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # EvoFakeMenu | ||
|
|
||
| ## Documentation | ||
|
|
||
| [Storybook](https://opensource.ebay.com/evo-web/react/?path=/docs/building-blocks-evo-fake-menu--documentation) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { createContext, use, useMemo } from "react"; | ||
| import type { ReactNode } from "react"; | ||
|
|
||
| type FakeMenuContextValue = { | ||
| baseClass: string; | ||
| }; | ||
|
|
||
| const FakeMenuContext = createContext<FakeMenuContextValue | undefined>( | ||
| undefined, | ||
| ); | ||
|
|
||
| export function useFakeMenuContext() { | ||
| const context = use(FakeMenuContext); | ||
| if (!context) { | ||
| throw new Error( | ||
| "Fake menu components must be used within an EvoFakeMenu component", | ||
| ); | ||
| } | ||
| return context; | ||
| } | ||
|
|
||
| type FakeMenuProviderProps = FakeMenuContextValue & { | ||
| children?: ReactNode; | ||
| }; | ||
|
|
||
| export function FakeMenuProvider({ | ||
| baseClass, | ||
| children, | ||
| }: FakeMenuProviderProps) { | ||
| const value = useMemo(() => ({ baseClass }), [baseClass]); | ||
| return <FakeMenuContext value={value}>{children}</FakeMenuContext>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { EvoBadge } from "../badge/badge"; | ||
| import type { EvoFakeMenuItemBadgeProps } from "./types"; | ||
|
|
||
| export function EvoFakeMenuItemBadge(props: EvoFakeMenuItemBadgeProps) { | ||
| return <EvoBadge {...props} type="menu" />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import type { ComponentProps } from "react"; | ||
| import classNames from "classnames"; | ||
| import { EvoIconTick16 } from "../icon/icons/tick-16"; | ||
| import { useFakeMenuContext } from "./context"; | ||
| import type { EvoFakeMenuItemProps } from "./types"; | ||
|
|
||
| export function EvoFakeMenuItem({ | ||
| className, | ||
| current = false, | ||
| itemMatchesUrl = true, | ||
| disabled = false, | ||
| type, | ||
| href, | ||
| as: _as, | ||
| children, | ||
| ...rest | ||
| }: EvoFakeMenuItemProps) { | ||
| const { baseClass } = useFakeMenuContext(); | ||
| const ariaCurrent = current ? (itemMatchesUrl ? "page" : "true") : undefined; | ||
| const itemClassName = classNames(`${baseClass}__item`, className); | ||
|
HenriqueLimas marked this conversation as resolved.
|
||
| const content = ( | ||
| <> | ||
| <span>{children}</span> | ||
| <EvoIconTick16 /> | ||
| </> | ||
| ); | ||
|
|
||
| if (type === "button" || type === "submit") { | ||
| return ( | ||
| <li> | ||
| <button | ||
| {...(rest as ComponentProps<"button">)} | ||
| type={type} | ||
| disabled={disabled} | ||
| aria-current={ariaCurrent} | ||
| className={itemClassName} | ||
| > | ||
| {content} | ||
| </button> | ||
| </li> | ||
| ); | ||
| } | ||
|
|
||
| const Component = _as ?? "a"; | ||
|
|
||
| return ( | ||
| <li> | ||
| <Component | ||
| {...(rest as ComponentProps<"a">)} | ||
| href={disabled ? undefined : href} | ||
| aria-disabled={disabled ? "true" : undefined} | ||
| aria-current={ariaCurrent} | ||
| className={itemClassName} | ||
| > | ||
|
Comment on lines
+48
to
+54
|
||
| {content} | ||
| </Component> | ||
| </li> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import classNames from "classnames"; | ||
| import type { EvoFakeMenuItemsProps } from "./types"; | ||
|
|
||
| export function EvoFakeMenuItems({ | ||
| className, | ||
| children, | ||
| ...rest | ||
| }: EvoFakeMenuItemsProps) { | ||
| return ( | ||
| <ul | ||
| {...rest} | ||
| className={classNames("fake-menu__items", className)} | ||
| tabIndex={-1} | ||
| > | ||
| {children} | ||
| </ul> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import classNames from "classnames"; | ||
| import { useFakeMenuContext } from "./context"; | ||
| import type { EvoFakeMenuSeparatorProps } from "./types"; | ||
|
|
||
| export function EvoFakeMenuSeparator({ | ||
| className, | ||
| ...rest | ||
| }: EvoFakeMenuSeparatorProps) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a |
||
| const { baseClass } = useFakeMenuContext(); | ||
|
|
||
| return ( | ||
| <li> | ||
| <hr | ||
| {...rest} | ||
| className={classNames(`${baseClass}__separator`, className)} | ||
| /> | ||
| </li> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react-vite"; | ||
| import type { ComponentProps } from "react"; | ||
| import { EvoFakeMenu } from "./fake-menu"; | ||
| import { EvoFakeMenuItem } from "./fake-menu-item"; | ||
| import { EvoFakeMenuItemBadge } from "./fake-menu-item-badge"; | ||
| import { EvoFakeMenuItems } from "./fake-menu-items"; | ||
| import { EvoFakeMenuSeparator } from "./fake-menu-separator"; | ||
|
|
||
| function Link({ | ||
| to, | ||
| children, | ||
| ...rest | ||
| }: ComponentProps<"a"> & { to?: string }) { | ||
| return ( | ||
| <a | ||
| data-custom-link="true" | ||
| {...rest} | ||
| href={to} | ||
| onClick={(event) => event.preventDefault()} | ||
| > | ||
| {children} | ||
| </a> | ||
| ); | ||
| } | ||
|
|
||
| const meta: Meta<typeof EvoFakeMenu> = { | ||
| title: "building blocks/evo-fake-menu", | ||
| component: EvoFakeMenu, | ||
| subcomponents: { | ||
| EvoFakeMenuItems, | ||
| EvoFakeMenuItem, | ||
| EvoFakeMenuItemBadge, | ||
| EvoFakeMenuSeparator, | ||
| }, | ||
| tags: ["autodocs"], | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| component: ` | ||
| A compound navigation menu containing links, buttons, badges, and separators. | ||
|
|
||
| ## Usage | ||
|
|
||
| \`\`\`tsx | ||
| import { | ||
| EvoFakeMenu, | ||
| EvoFakeMenuItem, | ||
| EvoFakeMenuItems, | ||
| } from "@evo-web/react/fake-menu"; | ||
| \`\`\` | ||
| `, | ||
| }, | ||
| }, | ||
| }, | ||
| argTypes: { | ||
| classPrefix: { | ||
| control: "text", | ||
| }, | ||
| reverse: { | ||
| control: "boolean", | ||
| }, | ||
| fixed: { | ||
| control: "boolean", | ||
| }, | ||
| fixWidth: { | ||
| control: "boolean", | ||
| }, | ||
| }, | ||
| args: { | ||
| reverse: false, | ||
| fixed: false, | ||
| fixWidth: false, | ||
| }, | ||
| render(args) { | ||
| return ( | ||
| <EvoFakeMenu {...args}> | ||
| <EvoFakeMenuItems> | ||
| <EvoFakeMenuItem href="#" onClick={(event) => event.preventDefault()}> | ||
| Messages | ||
| <EvoFakeMenuItemBadge number={5} a11yText="5 unread messages" /> | ||
| </EvoFakeMenuItem> | ||
| <EvoFakeMenuItem | ||
| href="#" | ||
| current | ||
| onClick={(event) => event.preventDefault()} | ||
| > | ||
| Current page | ||
| </EvoFakeMenuItem> | ||
| <EvoFakeMenuSeparator /> | ||
| <EvoFakeMenuItem type="button">Button item</EvoFakeMenuItem> | ||
| <EvoFakeMenuItem href="#" disabled> | ||
| Disabled link | ||
| </EvoFakeMenuItem> | ||
| </EvoFakeMenuItems> | ||
| </EvoFakeMenu> | ||
| ); | ||
| }, | ||
| }; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof EvoFakeMenu>; | ||
|
|
||
| export const Default: Story = {}; | ||
|
|
||
| export const WithCustomLinkComponent: Story = { | ||
| render: (args) => ( | ||
| <EvoFakeMenu {...args}> | ||
| <EvoFakeMenuItems> | ||
| <EvoFakeMenuItem | ||
| href="/account" | ||
| as={({ href = "", ...rest }) => <Link {...rest} to={href} />} | ||
| > | ||
| Account | ||
| </EvoFakeMenuItem> | ||
| </EvoFakeMenuItems> | ||
| </EvoFakeMenu> | ||
| ), | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| story: ` | ||
| Pass a custom component through the \`as\` prop to replace the native anchor. This example adapts React Router's \`Link\`, which uses \`to\` instead of \`href\`. | ||
|
|
||
| \`\`\`tsx | ||
| import { Link } from "react-router"; | ||
|
|
||
| <EvoFakeMenuItem | ||
| href="/account" | ||
| as={({ href = "", ...rest }) => <Link {...rest} to={href} />} | ||
| > | ||
| Account | ||
| </EvoFakeMenuItem> | ||
| \`\`\` | ||
| `, | ||
| }, | ||
| }, | ||
| }, | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.