diff --git a/ecosystem-explorer/src/v1/components/layout/sub-nav.test.tsx b/ecosystem-explorer/src/v1/components/layout/sub-nav.test.tsx new file mode 100644 index 000000000..f3828ac62 --- /dev/null +++ b/ecosystem-explorer/src/v1/components/layout/sub-nav.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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 { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, it, expect } from "vitest"; +import { SubNav } from "./sub-nav"; + +function renderSubNav(props: Parameters[0]) { + return render( + + + + ); +} + +describe("SubNav", () => { + it("renders nothing when both crumbs and actions are empty", () => { + const { container } = renderSubNav({ crumbs: [] }); + expect(container.firstChild).toBeNull(); + }); + + it("renders breadcrumb landmark with each crumb in order", () => { + renderSubNav({ + crumbs: [ + { label: "Collector", href: "/collector" }, + { label: "Components", href: "/collector/components" }, + { label: "otlp" }, + ], + }); + + const nav = screen.getByRole("navigation", { name: /breadcrumb/i }); + expect(nav).toBeInTheDocument(); + + expect(screen.getByRole("link", { name: "Collector" })).toHaveAttribute("href", "/collector"); + expect(screen.getByRole("link", { name: "Components" })).toHaveAttribute( + "href", + "/collector/components" + ); + expect(screen.getByText("otlp")).toHaveAttribute("aria-current", "page"); + }); + + it("renders the last crumb as a non-link with aria-current=page", () => { + renderSubNav({ + crumbs: [{ label: "Home", href: "/" }, { label: "Detail" }], + }); + + expect(screen.queryByRole("link", { name: "Detail" })).toBeNull(); + expect(screen.getByText("Detail")).toHaveAttribute("aria-current", "page"); + }); + + it("renders actions in the right-aligned slot", () => { + renderSubNav({ + crumbs: [{ label: "Home", href: "/" }], + actions: , + }); + + expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument(); + }); + + it("renders actions even when there are no crumbs", () => { + renderSubNav({ + crumbs: [], + actions: , + }); + + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + }); +}); diff --git a/ecosystem-explorer/src/v1/components/layout/sub-nav.tsx b/ecosystem-explorer/src/v1/components/layout/sub-nav.tsx new file mode 100644 index 000000000..805545432 --- /dev/null +++ b/ecosystem-explorer/src/v1/components/layout/sub-nav.tsx @@ -0,0 +1,82 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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. + */ + +/* + * SubNav — breadcrumb row that sits below the navbar on inner pages. + * + * Mirrors opentelemetry.io's `.breadcrumb` pattern (Bootstrap), but tightened + * to the explorer's chrome. The right-side `actions` slot is for page-level + * controls (filter toggles, "Edit on GitHub", etc.). Renders nothing if both + * `crumbs` and `actions` are empty so callers can mount it unconditionally. + */ +import { ChevronRight } from "lucide-react"; +import { Link } from "react-router-dom"; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +export interface SubNavProps { + crumbs: BreadcrumbItem[]; + actions?: React.ReactNode; + className?: string; +} + +export function SubNav({ crumbs, actions, className }: SubNavProps) { + if (crumbs.length === 0 && !actions) return null; + + return ( +
+
+ {crumbs.length > 0 && ( + + )} + {actions &&
{actions}
} +
+
+ ); +} diff --git a/ecosystem-explorer/src/v1/styles/index.css b/ecosystem-explorer/src/v1/styles/index.css index eb65f3ccf..f8e2746c4 100644 --- a/ecosystem-explorer/src/v1/styles/index.css +++ b/ecosystem-explorer/src/v1/styles/index.css @@ -20,11 +20,13 @@ * - tokens.css — v1-only surface-token overrides, scoped via `.v1-app` * - navbar.css — v1 navbar (`.td-navbar`) — mirrors opentelemetry.io * - theme-toggle.css — v1 theme toggle dropdown — mirrors opentelemetry.io + * - sub-nav.css — v1 breadcrumb sub-nav row beneath the navbar * - * Navbar and theme-toggle styles use class selectors (`.td-navbar`, + * All v1 styles use class selectors (`.td-navbar`, `.td-subnav`, * `.td-light-dark-menu__*`) and only match elements rendered by ``, * so they're inert in the legacy bundle even when both branches ship. */ @import "./tokens.css"; @import "./navbar.css"; @import "./theme-toggle.css"; +@import "./sub-nav.css"; diff --git a/ecosystem-explorer/src/v1/styles/sub-nav.css b/ecosystem-explorer/src/v1/styles/sub-nav.css new file mode 100644 index 000000000..25755a239 --- /dev/null +++ b/ecosystem-explorer/src/v1/styles/sub-nav.css @@ -0,0 +1,93 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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. + */ + +/* Pixel-anchored at 16px-rem like the rest of v1 chrome. */ + +.td-subnav { + font-size: 16px; + background-color: hsl(var(--card-hsl)); + border-bottom: 1px solid hsl(var(--border-hsl)); +} + +.td-subnav__container { + width: 100%; + max-width: 1320px; + margin: 0 auto; + padding: 0.5rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2.5rem; + flex-wrap: wrap; +} + +.td-subnav__breadcrumb { + min-width: 0; +} + +.td-subnav__crumbs { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + line-height: 1.25; +} + +.td-subnav__crumb { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.td-subnav__separator { + width: 0.875rem; + height: 0.875rem; + color: hsl(var(--muted-foreground-hsl)); + flex-shrink: 0; +} + +.td-subnav__crumb-label { + color: hsl(var(--muted-foreground-hsl)); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 24rem; +} + +.td-subnav__crumb-label:hover, +.td-subnav__crumb-label:focus-visible { + color: hsl(var(--foreground-hsl)); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.td-subnav__crumb-label--current { + color: hsl(var(--foreground-hsl)); + font-weight: 600; +} + +.td-subnav__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +}