diff --git a/src/components/MenuItem/MenuItem.module.css b/src/components/MenuItem/MenuItem.module.css new file mode 100644 index 00000000..d75c22b1 --- /dev/null +++ b/src/components/MenuItem/MenuItem.module.css @@ -0,0 +1,115 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.item { + display: grid; + grid-template: "icon label ." auto "empty1 label empty2" auto / auto auto 1fr; + align-items: center; + justify-items: end; + padding-block: var(--cpd-space-2x); + padding-inline: var(--cpd-space-4x); + box-sizing: border-box; + inline-size: 100%; + color: var(--cpd-color-text-secondary); + background: var(--cpd-color-bg-action-secondary-rest); +} + +.item.interactive { + cursor: pointer; +} + +.item.no-label { + grid-template: "icon ." auto / auto 1fr; +} + +.icon { + grid-area: icon; + margin-inline-end: var(--cpd-space-3x); +} + +.item.no-label .icon { + margin-inline-end: var(--cpd-space-4x); +} + +.label { + grid-area: label; + margin-inline-end: var(--cpd-space-4x); + text-align: start; +} + +.nav-hint { + /* Hidden until the item is hovered over */ + display: none; + flex-shrink: 0; + margin-inline-end: calc(-1 * var(--cpd-space-2x)); +} + +button.item { + appearance: none; + border: none; +} + +.item.with-chevron { + padding-inline-end: var(--cpd-space-2x); +} + +.item[data-kind="primary"] > .label { + color: var(--cpd-color-text-primary); +} + +.item[data-kind="primary"] > .icon { + color: var(--cpd-color-icon-primary); +} + +.item[data-kind="primary"] > .nav-hint { + color: var(--cpd-color-icon-tertiary); +} + +.item[data-kind="critical"] > .label { + color: var(--cpd-color-text-critical-primary); +} + +.item[data-kind="critical"] > .icon, +.item[data-kind="critical"] > .nav-hint { + color: var(--cpd-color-icon-critical-primary); +} + +@media (hover) { + .item.interactive[data-kind="primary"]:hover { + background: var(--cpd-color-bg-action-secondary-hovered); + } + + .item.interactive[data-kind="critical"]:hover { + background: var(--cpd-color-bg-critical-subtle); + } + + /* Replace the children with the navigation hint on hover */ + .item.interactive:hover > .nav-hint { + display: initial; + } + + .item.interactive:hover > .nav-hint ~ * { + display: none; + } +} + +.item.interactive[data-kind="primary"]:active { + background: var(--cpd-color-bg-action-secondary-pressed); +} + +.item.interactive[data-kind="critical"]:active { + background: var(--cpd-color-bg-critical-subtle); +} diff --git a/src/components/MenuItem/MenuItem.stories.tsx b/src/components/MenuItem/MenuItem.stories.tsx new file mode 100644 index 00000000..7301ba6c --- /dev/null +++ b/src/components/MenuItem/MenuItem.stories.tsx @@ -0,0 +1,57 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import ExtensionsIcon from "@vector-im/compound-design-tokens/icons/extensions.svg"; +import ChatIcon from "@vector-im/compound-design-tokens/icons/chat.svg"; +import SettingsLabel from "@vector-im/compound-design-tokens/icons/settings.svg"; + +import { MenuItem as MenuItemComponent } from "./MenuItem"; +import { Text } from "../Typography/Text"; + +export default { + title: "MenuItem", + component: MenuItemComponent, + argTypes: {}, + args: {}, +} as Meta; + +const Template: StoryFn = (args) => ( +
+ + + 99 + + + + + + Third item without a label + + +
+); + +export const Primary = Template.bind({}); +Primary.args = { kind: "primary" }; + +export const Critical = Template.bind({}); +Critical.args = { kind: "critical" }; diff --git a/src/components/MenuItem/MenuItem.test.tsx b/src/components/MenuItem/MenuItem.test.tsx new file mode 100644 index 00000000..dee176d3 --- /dev/null +++ b/src/components/MenuItem/MenuItem.test.tsx @@ -0,0 +1,54 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import React from "react"; +import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg"; +import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg"; +import MicOnOutlineIcon from "@vector-im/compound-design-tokens/icons/mic-on-outline.svg"; + +import { MenuItem } from "./MenuItem"; +import { Text } from "../Typography/Text"; + +describe("MenuItem", () => { + it("renders", () => { + const { asFragment } = render( + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("renders with a child", () => { + const { asFragment } = render( + + + 10 + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it("renders without a label", () => { + const { asFragment } = render( + + Imagine that there might be a volume slider here in place of the label + , + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx new file mode 100644 index 00000000..f914b69c --- /dev/null +++ b/src/components/MenuItem/MenuItem.tsx @@ -0,0 +1,100 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classnames from "classnames"; +import React, { + ComponentPropsWithoutRef, + ComponentType, + ElementType, + SVGAttributes, +} from "react"; +import styles from "./MenuItem.module.css"; +import { Text } from "../Typography/Text"; +import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg"; + +type MenuItemElement = "button" | "label" | "a" | "div"; + +type Props = { + /** + * The element type of this menu item. + * @default button + */ + as?: C; + /** + * The CSS class name. + */ + className?: string; + /** + * The icon to show on this menu item. + */ + Icon: ComponentType>; + /** + * The label to show on this menu item. + */ + // This prop is required because it's rare to not want a label + label: string | undefined; + /** + * The color variant of the menu item. + * @default primary + */ + kind?: "primary" | "critical"; +} & ComponentPropsWithoutRef; + +/** + * An item within a menu, acting either as a navigation button, or simply a + * container for other interactive elements. + */ +export const MenuItem = ({ + as, + className, + Icon, + label, + kind = "primary", + children, + ...props +}: Props) => { + const Component = as ?? ("button" as ElementType); + + return ( + + + {label !== undefined && ( + + {label} + + )} + {/* We use CSS to swap between this navigation hint and the provided + children on hover - see the styles module. */} + {(Component === "button" || Component === "a") && ( + + )} + {children} + + ); +}; diff --git a/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap b/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap new file mode 100644 index 00000000..58e4d5e0 --- /dev/null +++ b/src/components/MenuItem/__snapshots__/MenuItem.test.tsx.snap @@ -0,0 +1,156 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MenuItem > renders 1`] = ` + + + +`; + +exports[`MenuItem > renders with a child 1`] = ` + + + +`; + +exports[`MenuItem > renders without a label 1`] = ` + + + +`; diff --git a/src/index.ts b/src/index.ts index 16637933..5ffa46eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ export { export { IconButton } from "./components/IconButton/IconButton"; export { Label } from "./components/Form/Label"; export { Link } from "./components/Link/Link"; +export { MenuItem } from "./components/MenuItem/MenuItem"; export { Message } from "./components/Form/Message"; export { PasswordControl } from "./components/Form/Controls/Password"; export { Radio } from "./components/Radio/Radio";