diff --git a/package.json b/package.json index d8d653e5..00bee1cb 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "stylelint-plugin-defensive-css": "^0.9.1", "stylelint-use-logical": "^2.1.0", "stylelint-value-no-unknown-custom-properties": "^4.0.0", + "ts-xor": "^1.3.0", "typescript": "^5.2.2", "vite": "^5.2.11", "vite-plugin-dts": "^3.5.3", diff --git a/playwright/visual.test.ts-snapshots/Nav-Default-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Default-1-chromium-linux.png new file mode 100644 index 00000000..2535beee Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Default-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-1-chromium-linux.png new file mode 100644 index 00000000..dfccce27 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-Disabled-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-Disabled-1-chromium-linux.png new file mode 100644 index 00000000..a9322678 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-Disabled-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-Link-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-Link-1-chromium-linux.png new file mode 100644 index 00000000..dfccce27 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Active-Link-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Default-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Default-1-chromium-linux.png new file mode 100644 index 00000000..4299f5b3 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Default-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Disabled-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Disabled-1-chromium-linux.png new file mode 100644 index 00000000..bbcfd024 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Disabled-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Link-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Link-1-chromium-linux.png new file mode 100644 index 00000000..4299f5b3 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Nav-Item-Link-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Nav-Tab-Role-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Nav-Tab-Role-1-chromium-linux.png new file mode 100644 index 00000000..3a81d19b Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Nav-Tab-Role-1-chromium-linux.png differ diff --git a/src/components/Nav/Nav.module.css b/src/components/Nav/Nav.module.css new file mode 100644 index 00000000..ef1a4111 --- /dev/null +++ b/src/components/Nav/Nav.module.css @@ -0,0 +1,99 @@ +/* Copyright 2023 The Matrix.org Foundation C.I.C. + * + * 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. + */ + +.nav-bar { + border-block-end: var(--cpd-border-width-1) solid var(--cpd-color-gray-400); + margin: var(--cpd-space-6x) 0; + padding: 0; +} + +.nav-bar-items { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: var(--cpd-space-3x); + list-style: none; + padding: 0; + margin: 0; +} + +.nav-tab { + padding: var(--cpd-space-4x) 0; + position: relative; +} + +/* Underline effect */ +.nav-tab::before { + content: ""; + position: absolute; + inset-block-end: 0; + inset-inline: 0; + block-size: 0; + border-radius: var(--cpd-radius-pill-effect) var(--cpd-radius-pill-effect) 0 0; + background-color: var(--cpd-color-bg-action-primary-rest); + transition: height 0.1s ease-in-out; +} + +.nav-tab[data-current]::before { + /* This is not exactly right: designs says 3px, but there are no variables for that */ + block-size: var(--cpd-border-width-4); +} + +.nav-item { + padding-block: var(--cpd-space-1x); + padding-inline: var(--cpd-space-2x); + border-radius: var(--cpd-radius-pill-effect); + cursor: pointer; + appearance: none; + display: flex; + align-items: center; + justify-content: center; + gap: var(--cpd-space-2x); + box-sizing: border-box; + background: transparent; + border: 0; + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-secondary); + text-decoration: none; +} + +@media (hover) { + .nav-item:not([disabled]):hover { + color: var(--cpd-color-text-primary); + background-color: var(--cpd-color-bg-subtle-secondary); + } +} + +.nav-item:focus-visible { + outline: var(--cpd-color-border-focused) var(--cpd-border-width-2) solid; +} + +.nav-item:not([disabled]):active { + color: var(--cpd-color-text-primary); + background-color: var(--cpd-color-bg-subtle-primary); +} + +.nav-item[aria-current] { + color: var(--cpd-color-text-primary); +} + +.nav-item[disabled] { + cursor: not-allowed; + + /* Enable pointer events for svgs even with fill=none */ + pointer-events: all !important; + color: var(--cpd-color-text-disabled); +} diff --git a/src/components/Nav/NavBar.stories.tsx b/src/components/Nav/NavBar.stories.tsx new file mode 100644 index 00000000..94fedd04 --- /dev/null +++ b/src/components/Nav/NavBar.stories.tsx @@ -0,0 +1,105 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// 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, { useEffect, useState } from "react"; +import { NavBar, NavItem } from "."; + +export default { + title: "Nav", + component: NavBar, + tags: ["autodocs"], + parameters: { + controls: { + include: ["aria-label"], + }, + design: { + type: "figma", + url: "https://www.figma.com/file/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?type=design&node-id=669-2723&mode=design&t=9Hy0h7BBDH0kJ2Ow-0", + }, + }, + args: { + "aria-label": "Main", + }, +}; + +export const Default = { + args: { + children: ( + <> + {}}>Info + {}} active> + People + + {}} disabled> + Disabled + + External + + ), + }, +}; + +export const TabRole = { + render: function Component() { + // An example tab implementation + const [activePanelId, setActivePanelId] = useState("panel-2"); + const changeDisplay = (id: string, display: string) => { + const e = document.querySelector(`#${id}`) as HTMLDivElement; + if (e) e.style.display = display; + }; + useEffect(() => { + ["panel-1", "panel-2"].forEach((id) => { + changeDisplay(id, "none"); + }); + changeDisplay(activePanelId, "block"); + }, [activePanelId]); + + return ( +
+ + { + setActivePanelId("panel-1"); + }} + active={activePanelId === "panel-1"} + > + Tab 1 + + { + setActivePanelId("panel-2"); + }} + active={activePanelId === "panel-2"} + > + Tab 2 + + {}} disabled> + Disabled Tab + + +
+ This is panel 1 +
+
+ This is panel 2 +
+
+ This is panel 3 +
+
+ ); + }, +}; diff --git a/src/components/Nav/NavBar.test.tsx b/src/components/Nav/NavBar.test.tsx new file mode 100644 index 00000000..613955a0 --- /dev/null +++ b/src/components/Nav/NavBar.test.tsx @@ -0,0 +1,36 @@ +/* +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 { composeStories } from "@storybook/react"; + +import * as stories from "./NavBar.stories"; + +const { Default, TabRole } = composeStories(stories); + +describe("", () => { + it("render a default nav bar", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("render a tabbed nav bar", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Nav/NavBar.tsx b/src/components/Nav/NavBar.tsx new file mode 100644 index 00000000..6b7816dd --- /dev/null +++ b/src/components/Nav/NavBar.tsx @@ -0,0 +1,87 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// 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 classNames from "classnames"; + +import styles from "./Nav.module.css"; + +type NavBarProps = { + /** + * The CSS class name + */ + className?: string; + + /** + * Require a label for navigation landmarks + */ + "aria-label": string; + + /** + * Accessibility role that defaults to navigation. + * + * If you pass tablist you must also pass `aria-controls` as prop to your NavItem and must + * ensure that the conditions stated in https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#wai-ariaroles,states,andproperties + * are met. + */ + role?: "navigation" | "tablist"; +}; + +/** + * A navigation bar component + */ +export const NavBar = ({ + children, + className, + role, + "aria-label": ariaLabel, + ...rest +}: React.PropsWithChildren) => { + const classes = classNames(className, styles["nav-bar"]); + /** + * We sometimes want to use this NavBar for tabs. + * This is done by passing `role=tablist` to this component. + * By default, this component is used as a navigation bar. + * + * Depending on this role, a different set of accessibility + * attributes need to be applied to the nav/ul element. + */ + const a11yAttributesForNav = + role !== "tablist" + ? /** + * If role isn't tablist, default to navigation. + */ + { role: "navigation", "aria-label": ariaLabel } + : /** + * If role is tablist, give nav presentation role to remove + * any semantic meaning. + */ + { role: "presentation" }; + + /** + * When used as tabs, the tablist role must be applied to ul. + * When used as navigation, no special accessibility attribute + * is needed for the ul element. + */ + const a11yAttributesForUl = + role === "tablist" ? { role: "tablist", "aria-label": ariaLabel } : {}; + + return ( + + ); +}; diff --git a/src/components/Nav/NavItem.stories.tsx b/src/components/Nav/NavItem.stories.tsx new file mode 100644 index 00000000..829e8799 --- /dev/null +++ b/src/components/Nav/NavItem.stories.tsx @@ -0,0 +1,88 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. +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 { NavItem } from "./NavItem"; +import { NavBar } from "."; +import { StoryFn } from "@storybook/react"; + +export default { + title: "Nav/Nav Item", + component: NavItem, + tags: ["autodocs"], + argTypes: { + onClick: { + action: "clicked", + }, + }, + decorators: [ + (Story: StoryFn) => ( + + + + ), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?type=design&node-id=669-2723&mode=design&t=9Hy0h7BBDH0kJ2Ow-0", + }, + }, +}; + +export const Default = { + args: { + children: "Sessions", + }, +}; + +export const Disabled = { + args: { + children: "Sessions", + disabled: true, + }, +}; + +export const Link = { + args: { + children: "Sessions", + href: "https://example.org", + }, +}; + +export const Active = { + args: { + children: "Sessions", + active: true, + }, +}; + +export const ActiveLink = { + args: { + ...Link.args, + active: true, + }, +}; + +export const ActiveDisabled = { + args: { + children: "Sessions", + active: true, + disabled: true, + }, +}; diff --git a/src/components/Nav/NavItem.test.tsx b/src/components/Nav/NavItem.test.tsx new file mode 100644 index 00000000..a8b955e7 --- /dev/null +++ b/src/components/Nav/NavItem.test.tsx @@ -0,0 +1,53 @@ +/* +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 { composeStories } from "@storybook/react"; + +import * as stories from "./NavItem.stories"; + +const { Default, Disabled, Link, Active, ActiveLink, ActiveDisabled } = + composeStories(stories); + +describe("", () => { + it("render a default item", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("render a Disabled item", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("render a Link item", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("render a Active item", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("render a ActiveLink item", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("render a ActiveDisabled item", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Nav/NavItem.tsx b/src/components/Nav/NavItem.tsx new file mode 100644 index 00000000..5238f5c6 --- /dev/null +++ b/src/components/Nav/NavItem.tsx @@ -0,0 +1,126 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. +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, { + AnchorHTMLAttributes, + ButtonHTMLAttributes, + MouseEventHandler, +} from "react"; +import type { XOR } from "ts-xor"; + +import styles from "./Nav.module.css"; + +type NavItemProps = { + active?: boolean; + "aria-controls"?: string; +}; + +type NavItemLinkProps = Omit< + AnchorHTMLAttributes, + "style" | "className" +> & { + href: string; +} & NavItemProps; + +type NavItemButtonProps = Omit< + ButtonHTMLAttributes, + "style" | "className" +> & { + onClick: MouseEventHandler; +} & NavItemProps; + +const NavItemLink = ({ + children, + href, + onClick, + ...rest +}: React.PropsWithChildren) => ( + + {children} + +); + +const NavItemButton = ({ + children, + disabled, + onClick, + ...rest +}: React.PropsWithChildren) => ( + +); + +const renderAsLink = ( + props: React.PropsWithChildren>, +): props is React.PropsWithChildren => !!props.href; + +/** + * A navigation item component to be used with a navigation bar. + * Will render an anchor when href is provided, otherwise a button element. + */ +export const NavItem = ( + props: React.PropsWithChildren>, +) => { + /** + * For accessibility rules related to tabs, + * see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/#wai-ariaroles,states,andproperties + * + * For accessibility rules related to navigation, + * see https://www.digitala11y.com/navigation-role/ + */ + const ariaControls = props["aria-controls"]; + const isUsedAsTabs = !!ariaControls; + const a11yAttributes = isUsedAsTabs + ? { + // when used as tabs + "aria-controls": ariaControls, + role: "tab", + "aria-selected": props.active, + } + : { + // when used as navigation elements + "aria-current": props.active ? true : undefined, + }; + + // All the attributes except `active` can be passed to the button/a element. + const propsForChild = { ...props }; + delete propsForChild["active"]; + + // Depending on whether `href` is in props, we render link/button + let navItem: React.ReactNode; + if (renderAsLink(propsForChild)) { + navItem = ; + } else { + navItem = ; + } + + return ( +
  • + {navItem} +
  • + ); +}; diff --git a/src/components/Nav/__snapshots__/NavBar.test.tsx.snap b/src/components/Nav/__snapshots__/NavBar.test.tsx.snap new file mode 100644 index 00000000..a9789d73 --- /dev/null +++ b/src/components/Nav/__snapshots__/NavBar.test.tsx.snap @@ -0,0 +1,136 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > render a default nav bar 1`] = ` +
    + +
    +`; + +exports[` > render a tabbed nav bar 1`] = ` +
    +
    + + +
    + This is panel 2 +
    + +
    +
    +`; diff --git a/src/components/Nav/__snapshots__/NavItem.test.tsx.snap b/src/components/Nav/__snapshots__/NavItem.test.tsx.snap new file mode 100644 index 00000000..7113a298 --- /dev/null +++ b/src/components/Nav/__snapshots__/NavItem.test.tsx.snap @@ -0,0 +1,161 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > render a Active item 1`] = ` +
    + +
    +`; + +exports[` > render a ActiveDisabled item 1`] = ` +
    + +
    +`; + +exports[` > render a ActiveLink item 1`] = ` +
    + +
    +`; + +exports[` > render a Disabled item 1`] = ` +
    + +
    +`; + +exports[` > render a Link item 1`] = ` +
    + +
    +`; + +exports[` > render a default item 1`] = ` +
    + +
    +`; diff --git a/src/components/Nav/index.ts b/src/components/Nav/index.ts new file mode 100644 index 00000000..fc6577a6 --- /dev/null +++ b/src/components/Nav/index.ts @@ -0,0 +1,16 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// 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. + +export { NavBar } from "./NavBar"; +export { NavItem } from "./NavItem"; diff --git a/src/index.ts b/src/index.ts index 8f788f3d..85d3cdc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export { } from "./components/Typography/Heading"; export { IndicatorIcon } from "./components/Icon/IndicatorIcon/IndicatorIcon"; export { Link } from "./components/Link/Link"; +export { NavBar, NavItem } from "./components/Nav"; export { Menu } from "./components/Menu/Menu"; export { MenuItem } from "./components/Menu/MenuItem"; export { Search } from "./components/Search/Search"; diff --git a/yarn.lock b/yarn.lock index ae48c58c..7adccf41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9461,6 +9461,11 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0: resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== +ts-xor@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-xor/-/ts-xor-1.3.0.tgz#3e59f24f0321f9f10f350e0cee3b534b89a2c70b" + integrity sha512-RLXVjliCzc1gfKQFLRpfeD0rrWmjnSTgj7+RFhoq3KRkUYa8LE/TIidYOzM5h+IdFBDSjjSgk9Lto9sdMfDFEA== + tsconfig-paths@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz#ef78e19039133446d244beac0fd6a1632e2d107c"