From 29022526d0b8d3fc5c33fd989d98cda91842ef37 Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Mon, 27 Jul 2026 23:01:02 +0100 Subject: [PATCH 1/7] feat(content-docs): add ContentDocs component with container-query-driven nav panels Adds useContainerBreakpoints, a container-width equivalent of VueUse's useBreakpoints, for layouts where page decoration means viewport width doesn't reflect actual available space. Wires it into ContentDocs' demo page to drive forceOpened/onTop on its nav ExpandingPanels per breakpoint. Also fixes two ExpandingPanel bugs surfaced by exercising forceOpened reactively for the first time: a shared `name` made two force-opened panels mutually exclusive via native
grouping, and forceOpened's programmatic open state was leaking into isPanelOpen and getting stuck after forceOpened reverted to false. --- .../docs-pages/ContentDocs.vue | 87 +++++++++++++ .../expanding-panel/ExpandingPanel.vue | 4 + .../tests/ExpandingPanel.spec.ts | 17 +++ app/components/layout-grids/LayoutGridA.vue | 118 +++++++++--------- .../tests/useContainerBreakpoints.spec.ts | 91 ++++++++++++++ app/composables/useContainerBreakpoints.ts | 71 +++++++++++ app/layouts/default.vue | 1 + app/pages/ui/layout-content-docs.vue | 109 ++++++++++++++++ 8 files changed, 439 insertions(+), 59 deletions(-) create mode 100644 app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue create mode 100644 app/composables/tests/useContainerBreakpoints.spec.ts create mode 100644 app/composables/useContainerBreakpoints.ts create mode 100644 app/pages/ui/layout-content-docs.vue diff --git a/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue b/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue new file mode 100644 index 00000000..35f9fd77 --- /dev/null +++ b/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue b/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue index 656892a6..6cde9c27 100644 --- a/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +++ b/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue @@ -81,6 +81,10 @@ const handleToggle = (event: Event) => { }; const onDetailsToggle = (event: Event) => { + // forceOpened drives the native `open` attribute directly, which fires its own + // 'toggle' event — ignore it here or it leaks into isPanelOpen and gets stuck + // once forceOpened later reverts to false. + if (props.forceOpened) return; isPanelOpen.value = (event.target as HTMLDetailsElement).open; }; diff --git a/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts b/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts index 224a7bd7..fb14eb57 100644 --- a/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +++ b/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts @@ -317,6 +317,23 @@ describe("ExpandingPanel", () => { expect(vm.open).toBe(true); }); + it("does not leak forceOpened into isPanelOpen, so open reverts to false when forceOpened turns back off", async () => { + const wrapper = await mountSuspended(ExpandingPanel, { + props: { name: "force-then-unforce", forceOpened: true }, + }); + const vm = wrapper.vm as unknown as ExpandingPanelInstance; + expect(vm.open).toBe(true); + + // The
element's `open` attribute changing (driven by forceOpened) fires + // its own native 'toggle' event, independent of any user click. + await wrapper.find("details").trigger("toggle"); + await nextTick(); + expect(vm.isPanelOpen).toBe(false); + + await wrapper.setProps({ forceOpened: false }); + expect(vm.open).toBe(false); + }); + // ─── Slots ──────────────────────────────────────────────────────────────── it("renders summary slot content inside .label-wrapper", async () => { diff --git a/app/components/layout-grids/LayoutGridA.vue b/app/components/layout-grids/LayoutGridA.vue index fdeffa21..56acfaf7 100644 --- a/app/components/layout-grids/LayoutGridA.vue +++ b/app/components/layout-grids/LayoutGridA.vue @@ -23,83 +23,83 @@ const props = defineProps({ type: [String, Array] as PropType, default: () => [], }, -}) +}); -const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough) +const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough); watch( () => props.styleClassPassthrough, () => { - resetElementClasses(props.styleClassPassthrough) + resetElementClasses(props.styleClassPassthrough); } -) +); diff --git a/app/composables/tests/useContainerBreakpoints.spec.ts b/app/composables/tests/useContainerBreakpoints.spec.ts new file mode 100644 index 00000000..f7d1fa0b --- /dev/null +++ b/app/composables/tests/useContainerBreakpoints.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { nextTick, ref } from "vue"; +import { useContainerBreakpoints } from "../useContainerBreakpoints"; + +let resizeCallback: ResizeObserverCallback | null = null; + +beforeEach(() => { + resizeCallback = null; + vi.stubGlobal( + "ResizeObserver", + vi.fn((callback: ResizeObserverCallback) => { + resizeCallback = callback; + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }) + ); +}); + +function triggerResize(el: HTMLElement, width: number) { + Object.defineProperty(el, "offsetWidth", { value: width, configurable: true }); + resizeCallback?.( + [{ contentRect: { width, height: 0 } } as unknown as ResizeObserverEntry], + {} as ResizeObserver + ); +} + +describe("useContainerBreakpoints", () => { + it("defaults active to base when narrower than the smallest breakpoint", () => { + const el = ref(document.createElement("div")); + const { active } = useContainerBreakpoints(undefined, el); + expect(active.value).toBe("base"); + }); + + it("updates active as the observed element's width crosses breakpoints", async () => { + const el = ref(document.createElement("div")); + const { active } = useContainerBreakpoints(undefined, el); + + triggerResize(el.value as HTMLElement, 700); + await nextTick(); + expect(active.value).toBe("sm"); + + triggerResize(el.value as HTMLElement, 1300); + await nextTick(); + expect(active.value).toBe("xl"); + }); + + it("greaterOrEqual/smaller reflect the current width against a named breakpoint", async () => { + const el = ref(document.createElement("div")); + const { greaterOrEqual, smaller } = useContainerBreakpoints(undefined, el); + const isLg = greaterOrEqual("lg"); + const isSmallerThanMd = smaller("md"); + + expect(isLg.value).toBe(false); + expect(isSmallerThanMd.value).toBe(true); + + triggerResize(el.value as HTMLElement, 1100); + await nextTick(); + expect(isLg.value).toBe(true); + expect(isSmallerThanMd.value).toBe(false); + }); + + it("between returns true only within the [min, max) range", async () => { + const el = ref(document.createElement("div")); + const { between } = useContainerBreakpoints(undefined, el); + const isTablet = between("sm", "lg"); + + triggerResize(el.value as HTMLElement, 800); + await nextTick(); + expect(isTablet.value).toBe(true); + + triggerResize(el.value as HTMLElement, 1024); + await nextTick(); + expect(isTablet.value).toBe(false); + }); + + it("supports a custom breakpoints map", async () => { + const el = ref(document.createElement("div")); + const { active } = useContainerBreakpoints({ narrow: 400, wide: 900 }, el); + + triggerResize(el.value as HTMLElement, 500); + await nextTick(); + expect(active.value).toBe("narrow"); + + triggerResize(el.value as HTMLElement, 950); + await nextTick(); + expect(active.value).toBe("wide"); + }); +}); diff --git a/app/composables/useContainerBreakpoints.ts b/app/composables/useContainerBreakpoints.ts new file mode 100644 index 00000000..a579395f --- /dev/null +++ b/app/composables/useContainerBreakpoints.ts @@ -0,0 +1,71 @@ +import type { Ref } from "vue"; +import { useElementSize } from "@vueuse/core"; + +export const containerBreakpointsDefault = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + "2xl": 1536, + "4k": 2560, +} as const; + +export type ContainerBreakpointName = keyof typeof containerBreakpointsDefault; + +/** + * useContainerBreakpoints composable + * Container-query equivalent of VueUse's useBreakpoints — tracks an element's + * own width via ResizeObserver instead of the viewport, for layouts where + * page decoration (nav, sidebars) means viewport width != available width. + * @param breakpoints - map of breakpoint name to min-width in px (default: containerBreakpointsDefault) + * @param target - optional existing element ref to observe; creates its own if omitted + * @returns { el, width, active, greater, greaterOrEqual, smaller, smallerOrEqual, between } + */ +export function useContainerBreakpoints( + breakpoints: Record = containerBreakpointsDefault as unknown as Record, + target?: Ref +) { + const el = target ?? ref(null); + const { width } = useElementSize(el); + + const sortedEntries = (Object.entries(breakpoints) as [K, number][]).sort((a, b) => a[1] - b[1]); + + function greaterOrEqual(name: K) { + return computed(() => width.value >= breakpoints[name]); + } + + function greater(name: K) { + return computed(() => width.value > breakpoints[name]); + } + + function smaller(name: K) { + return computed(() => width.value < breakpoints[name]); + } + + function smallerOrEqual(name: K) { + return computed(() => width.value <= breakpoints[name]); + } + + function between(min: K, max: K) { + return computed(() => width.value >= breakpoints[min] && width.value < breakpoints[max]); + } + + const active = computed(() => { + let current: K | "base" = "base"; + for (const [name, minWidth] of sortedEntries) { + if (width.value >= minWidth) current = name; + } + return current; + }); + + return { + el, + width, + active, + greater, + greaterOrEqual, + smaller, + smallerOrEqual, + between, + }; +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 7f853fd6..a448ac4a 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -110,6 +110,7 @@ const responsiveNavLinks = { { name: "Layout Grid A", path: "/ui/layout-grid-a" }, { name: "Layout Grid B", path: "/ui/layout-grid-b" }, { name: "Simple Grid", path: "/ui/simple-grid" }, + { name: "Layout Content Docs", path: "/ui/layout-content-docs" }, { name: "Masonry Grid Simple", path: "/ui/masonry-grid" }, { name: "Masonry Grid Sorted", path: "/ui/masonry-grid-sorted" }, { name: "Masonry Grid Ordered", path: "/ui/masonry-grid-ordered" }, diff --git a/app/pages/ui/layout-content-docs.vue b/app/pages/ui/layout-content-docs.vue new file mode 100644 index 00000000..a6b54638 --- /dev/null +++ b/app/pages/ui/layout-content-docs.vue @@ -0,0 +1,109 @@ + + + From 5f9627e5079b35b7362834a8ae45be3c20def9b5 Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Mon, 27 Jul 2026 23:57:48 +0100 Subject: [PATCH 2/7] feat(content-docs): add ContentDocs component with container-query-driven nav panels Style updates --- ...rcdev-component-content-docs.code-snippets | 125 ++++++++ .../docs-pages/ContentDocs.vue | 281 +++++++++++++++++- .../docs-pages/tests/ContentDocs.spec.ts | 218 ++++++++++++++ app/pages/ui/layout-content-docs.vue | 85 ++---- app/types/components/content-docs.d.ts | 5 + app/types/components/index.ts | 1 + 6 files changed, 649 insertions(+), 66 deletions(-) create mode 100644 .vscode/srcdev-component-content-docs.code-snippets create mode 100644 app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts create mode 100644 app/types/components/content-docs.d.ts diff --git a/.vscode/srcdev-component-content-docs.code-snippets b/.vscode/srcdev-component-content-docs.code-snippets new file mode 100644 index 00000000..7686868b --- /dev/null +++ b/.vscode/srcdev-component-content-docs.code-snippets @@ -0,0 +1,125 @@ +{ + "SRCDEV ContentDocs Basic": { + "description": "ContentDocs with nav + on-this-page items and active-item v-models", + "scope": "vue,html", + "body": [ + "", + " ", + "" + ] + }, + "SRCDEV ContentDocs Custom Labels" : { + "description": "ContentDocs with custom docsNav / docsPageNav panel headings", + "scope": "vue,html", + "body": [ + "", + " ", + "" + ] + }, + "SRCDEV ContentDocs Items Script": { + "description": "docsNavItems / docsPageNavItems + active-item refs for ContentDocs", + "scope": "typescript,vue", + "body": [ + "const docsNavItems: DocsNavItem[] = [", + " { label: \"$1Getting started\", to: \"$2/docs\" },", + "];", + "", + "const docsPageNavItems: DocsNavItem[] = [", + " { label: \"$3Overview\", to: \"$2/docs#overview\" },", + "];", + "", + "const activeNavItem = ref(docsNavItems[0]?.to);", + "const activePageNavItem = ref(undefined);" + ] + }, + "SRCDEV ContentDocs Items With Icons Script": { + "description": "docsNavItems / docsPageNavItems with per-item icons for ContentDocs", + "scope": "typescript,vue", + "body": [ + "const docsNavItems: DocsNavItem[] = [", + " { label: \"$1Getting started\", to: \"$2/docs\", icon: \"$3lucide:rocket\" },", + "];", + "", + "const docsPageNavItems: DocsNavItem[] = [", + " { label: \"$4Overview\", to: \"$2/docs#overview\", icon: \"$5lucide:list\" },", + "];", + "", + "const activeNavItem = ref(docsNavItems[0]?.to);", + "const activePageNavItem = ref(undefined);" + ] + }, + "SRCDEV ContentDocs CSS Override — Icons": { + "description": "CSS override scaffold for ContentDocs nav-link icon tokens (gap, size, and start/end order)", + "scope": "css", + "body": [ + ".$1my-content-docs {", + " .content-docs {", + " --docs-nav-link-icon-gap: $2;", + " --docs-nav-link-icon-size: $3;", + " --docs-nav-link-icon-order: $4rtl; /* icon at the end */", + " --docs-page-nav-link-icon-order: $5ltr; /* icon at the start (default) */", + " }", + "}" + ] + }, + "SRCDEV ContentDocs CSS Override — Shared Theme": { + "description": "CSS override scaffold for ContentDocs heading/panel/link tokens, applied to both docsNav and docsPageNav at once", + "scope": "css", + "body": [ + ".$1my-content-docs {", + " .content-docs {", + " --content-docs-heading-font-size: $2;", + " --content-docs-heading-color: $3;", + " --content-docs-heading-bg: $4;", + " --content-docs-heading-padding-block: $5;", + "", + " --content-docs-panel-bg: $6;", + " --content-docs-panel-padding-block: $7;", + " --content-docs-panel-padding-inline: $8;", + "", + " --content-docs-link-font-size: $9;", + " --content-docs-link-color: $10;", + " --content-docs-link-hover-bg: $11;", + " --content-docs-link-active-bg: $12;", + " --content-docs-link-active-color: $13;", + " }", + "}" + ] + }, + "SRCDEV ContentDocs CSS Override — Per Side": { + "description": "CSS override scaffold for independently theming docsNav vs docsPageNav heading/panel/link tokens", + "scope": "css", + "body": [ + ".$1my-content-docs {", + " .content-docs {", + " /* docsNav (primary) */", + " --content-docs-nav-heading-color: $2;", + " --content-docs-nav-panel-bg: $3;", + " --content-docs-nav-link-active-bg: $4;", + "", + " /* docsPageNav (on-this-page / secondary) */", + " --content-docs-page-nav-heading-color: $5;", + " --content-docs-page-nav-panel-bg: $6;", + " --content-docs-page-nav-link-active-bg: $7;", + " }", + "}" + ] + } +} diff --git a/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue b/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue index 35f9fd77..64ba894d 100644 --- a/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +++ b/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue @@ -1,33 +1,104 @@ diff --git a/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts b/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts new file mode 100644 index 00000000..7973e7ec --- /dev/null +++ b/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { nextTick } from "vue"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import ContentDocs from "../ContentDocs.vue"; + +let resizeCallback: ResizeObserverCallback | null = null; + +beforeEach(() => { + resizeCallback = null; + vi.stubGlobal( + "ResizeObserver", + vi.fn((callback: ResizeObserverCallback) => { + resizeCallback = callback; + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }) + ); +}); + +function triggerResize(el: HTMLElement, width: number) { + Object.defineProperty(el, "offsetWidth", { value: width, configurable: true }); + resizeCallback?.( + [{ contentRect: { width, height: 0 } } as unknown as ResizeObserverEntry], + {} as ResizeObserver + ); +} + +const navItems = [ + { label: "One", to: "/one" }, + { label: "Two", to: "/two" }, +]; +const pageNavItems = [{ label: "Overview", to: "/one#overview" }]; + +describe("ContentDocs", () => { + it("mounts without error", async () => { + const wrapper = await mountSuspended(ContentDocs); + expect(wrapper.vm).toBeTruthy(); + }); + + // ─── docsNav / docsPageNav visibility ────────────────────────────────────── + + it("does not render .docs-nav when docsNavItems is empty", async () => { + const wrapper = await mountSuspended(ContentDocs); + expect(wrapper.find(".docs-nav").exists()).toBe(false); + }); + + it("renders .docs-nav with a link per item when docsNavItems is provided", async () => { + const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } }); + const links = wrapper.find(".docs-nav").findAll("a"); + expect(links).toHaveLength(2); + expect(links[0]?.text()).toBe("One"); + expect(links[0]?.attributes("href")).toBe("/one"); + }); + + it("does not render .docs-page-nav when docsPageNavItems is empty", async () => { + const wrapper = await mountSuspended(ContentDocs); + expect(wrapper.find(".docs-page-nav").exists()).toBe(false); + }); + + it("renders .docs-page-nav with a link per item when docsPageNavItems is provided", async () => { + const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } }); + const links = wrapper.find(".docs-page-nav").findAll("a"); + expect(links).toHaveLength(1); + expect(links[0]?.text()).toBe("Overview"); + }); + + it("renders the docsContent slot", async () => { + const wrapper = await mountSuspended(ContentDocs, { + slots: { docsContent: '

Body

' }, + }); + expect(wrapper.find('[data-testid="body"]').text()).toBe("Body"); + }); + + // ─── labels ───────────────────────────────────────────────────────────── + + it("defaults docsNavLabel to 'Navigation' and docsPageNavLabel to 'On this page'", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, docsPageNavItems: pageNavItems }, + }); + expect(wrapper.find(".docs-nav-heading").text()).toBe("Navigation"); + expect(wrapper.find(".docs-page-nav-heading").text()).toBe("On this page"); + }); + + it("uses custom docsNavLabel / docsPageNavLabel when provided", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { + docsNavItems: navItems, + docsPageNavItems: pageNavItems, + docsNavLabel: "Sections", + docsPageNavLabel: "Contents", + }, + }); + expect(wrapper.find(".docs-nav-heading").text()).toBe("Sections"); + expect(wrapper.find(".docs-page-nav-heading").text()).toBe("Contents"); + }); + + // ─── icons ────────────────────────────────────────────────────────────── + + it("renders an icon on a docsNav item when item.icon is set", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: [{ label: "One", to: "/one", icon: "lucide:home" }] }, + }); + expect(wrapper.find(".docs-nav-link-icon").exists()).toBe(true); + }); + + it("does not render an icon on a docsNav item when item.icon is omitted", async () => { + const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } }); + expect(wrapper.find(".docs-nav-link-icon").exists()).toBe(false); + }); + + it("renders an icon on a docsPageNav item when item.icon is set", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsPageNavItems: [{ label: "Overview", to: "/one#overview", icon: "lucide:list" }] }, + }); + expect(wrapper.find(".docs-page-nav-link-icon").exists()).toBe(true); + }); + + // ─── active item ──────────────────────────────────────────────────────── + + it("applies is-active and aria-current to the link matching activeNavItem", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, activeNavItem: "/two" }, + }); + const links = wrapper.find(".docs-nav").findAll("a"); + expect(links[0]?.classes()).not.toContain("is-active"); + expect(links[1]?.classes()).toContain("is-active"); + expect(links[1]?.attributes("aria-current")).toBe("page"); + }); + + it("emits update:activeNavItem when a docsNav link is clicked", async () => { + const wrapper = await mountSuspended(ContentDocs, { props: { docsNavItems: navItems } }); + await wrapper.find(".docs-nav").findAll("a")[1]?.trigger("click"); + expect(wrapper.emitted("update:activeNavItem")?.[0]).toEqual(["/two"]); + }); + + it("emits update:activePageNavItem when a docsPageNav link is clicked", async () => { + const wrapper = await mountSuspended(ContentDocs, { props: { docsPageNavItems: pageNavItems } }); + await wrapper.find(".docs-page-nav").find("a").trigger("click"); + expect(wrapper.emitted("update:activePageNavItem")?.[0]).toEqual(["/one#overview"]); + }); + + // ─── container-width-driven forceOpened ──────────────────────────────────── + + describe("breakpoint-driven forceOpened", () => { + it("neither panel is forceOpened below 768px (mobile)", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, docsPageNavItems: pageNavItems }, + }); + triggerResize(wrapper.find(".content-docs").element as HTMLElement, 500); + await nextTick(); + + expect(wrapper.find(".docs-nav .icon-wrapper").exists()).toBe(true); + expect(wrapper.find(".docs-page-nav .icon-wrapper").exists()).toBe(true); + }); + + it("only docsPageNav is forceOpened between 768px and 1023px (tablet)", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, docsPageNavItems: pageNavItems }, + }); + triggerResize(wrapper.find(".content-docs").element as HTMLElement, 900); + await nextTick(); + + expect(wrapper.find(".docs-nav .icon-wrapper").exists()).toBe(true); + expect(wrapper.find(".docs-page-nav .icon-wrapper").exists()).toBe(false); + }); + + it("both panels are forceOpened at 1024px and above (desktop)", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, docsPageNavItems: pageNavItems }, + }); + triggerResize(wrapper.find(".content-docs").element as HTMLElement, 1200); + await nextTick(); + + expect(wrapper.find(".docs-nav .icon-wrapper").exists()).toBe(false); + expect(wrapper.find(".docs-page-nav .icon-wrapper").exists()).toBe(false); + }); + }); + + // ─── native
name grouping ──────────────────────────────────────── + + describe("panel name grouping", () => { + it("shares one details name between docsNav and docsPageNav on mobile", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, docsPageNavItems: pageNavItems }, + }); + triggerResize(wrapper.find(".content-docs").element as HTMLElement, 500); + await nextTick(); + + const navName = wrapper.find(".docs-nav details").attributes("name"); + const pageNavName = wrapper.find(".docs-page-nav details").attributes("name"); + expect(navName).toBe(pageNavName); + }); + + it("uses distinct details names on desktop, where both must stay open simultaneously", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { docsNavItems: navItems, docsPageNavItems: pageNavItems }, + }); + triggerResize(wrapper.find(".content-docs").element as HTMLElement, 1200); + await nextTick(); + + const navName = wrapper.find(".docs-nav details").attributes("name"); + const pageNavName = wrapper.find(".docs-page-nav details").attributes("name"); + expect(navName).not.toBe(pageNavName); + }); + }); + + // ─── styleClassPassthrough ──────────────────────────────────────────────── + + it("applies styleClassPassthrough classes to the root element", async () => { + const wrapper = await mountSuspended(ContentDocs, { + props: { styleClassPassthrough: ["custom-class"] }, + }); + expect(wrapper.find(".content-docs").classes()).toContain("custom-class"); + }); +}); diff --git a/app/pages/ui/layout-content-docs.vue b/app/pages/ui/layout-content-docs.vue index a6b54638..b03dd8bf 100644 --- a/app/pages/ui/layout-content-docs.vue +++ b/app/pages/ui/layout-content-docs.vue @@ -9,30 +9,13 @@ -
- - +
+ -
@@ -75,7 +36,7 @@ diff --git a/app/types/components/content-docs.d.ts b/app/types/components/content-docs.d.ts new file mode 100644 index 00000000..4e4f826b --- /dev/null +++ b/app/types/components/content-docs.d.ts @@ -0,0 +1,5 @@ +export interface DocsNavItem { + label: string; + to: string; + icon?: string; +} diff --git a/app/types/components/index.ts b/app/types/components/index.ts index ab2be4f3..18dd5c58 100644 --- a/app/types/components/index.ts +++ b/app/types/components/index.ts @@ -13,3 +13,4 @@ export * from "./alert-mask-core.d" export * from "./hero-text" export * from "./navigation-horizontal.d" export * from "./social-icons-list.d" +export * from "./content-docs.d" From 94e31848d3dc3a4da37b7a7ab7cf2b76e59f87ae Mon Sep 17 00:00:00 2001 From: Simon Cornforth Date: Tue, 28 Jul 2026 00:35:48 +0100 Subject: [PATCH 3/7] feat(content-docs): lock docsNav/docsPageNav to data-driven ExpandingPanels Replaces the docsNav/docsPageNav slots with prop-driven nav items (docsNavItems/docsPageNavItems, DocsNavItem incl. optional icon) and active-item v-models, since every consumer would otherwise have to re-wire the same ExpandingPanel + breakpoint force-open/name-grouping logic themselves. Adds default heading/panel/link styling with a shared + per-side CSS token API, grid-based link layout so icon-less items stay aligned, and start/end icon ordering via a direction flip. Documents the component in .claude/skills/components/content-docs.md. --- .claude/skills/components/content-docs.md | 165 ++++++++++++++++++ .claude/skills/index.md | 1 + ...rcdev-component-content-docs.code-snippets | 4 + .../docs-pages/ContentDocs.vue | 75 +++++--- 4 files changed, 223 insertions(+), 22 deletions(-) create mode 100644 .claude/skills/components/content-docs.md diff --git a/.claude/skills/components/content-docs.md b/.claude/skills/components/content-docs.md new file mode 100644 index 00000000..04f6257d --- /dev/null +++ b/.claude/skills/components/content-docs.md @@ -0,0 +1,165 @@ +# ContentDocs Component + +## Overview + +`ContentDocs` is a docs-page layout shell: a primary nav column, a main content column, and an "on this page" nav column, arranged via CSS `@container` queries (not viewport breakpoints) so the layout adapts to the space actually available — correct even when a consumer page has its own decoration (e.g. a site-wide left nav) eating into the viewport. + +`docsNav` and `docsPageNav` are **not** slots — they're rendered internally as `ExpandingPanel` accordions driven by `docsNavItems`/`docsPageNavItems` props. Only `docsContent` remains a slot, since it's arbitrary page content. The two side panels auto force-open/collapse based on the component's own measured width via `useContainerBreakpoints`, and share a native `
` accordion group only on mobile (see "Breakpoint behaviour" below). + +--- + +## Props reference + +| Prop | Type | Default | Notes | +|------|------|---------|-------| +| `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | Root element tag. | +| `docsNavItems` | `DocsNavItem[]` | `[]` | Items rendered in the `docsNav` panel. Panel (and its `.docs-nav` wrapper) is omitted entirely when empty. | +| `docsPageNavItems` | `DocsNavItem[]` | `[]` | Items rendered in the `docsPageNav` panel. Panel omitted entirely when empty. | +| `docsNavLabel` | `string` | `"Navigation"` | Heading text for the `docsNav` panel's `#summary`. | +| `docsPageNavLabel` | `string` | `"On this page"` | Heading text for the `docsPageNav` panel's `#summary`. | +| `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the root `.content-docs` element. | + +`DocsNavItem` (from `~/types/components`): + +```ts +interface DocsNavItem { + label: string; + to: string; + icon?: string; // Icon name, e.g. "lucide:rocket" +} +``` + +## Model + +| Model | Type | Default | Notes | +|-------|------|---------|-------| +| `v-model:activeNavItem` | `string \| undefined` | `undefined` | The `to` of the currently-active `docsNav` item. Updates automatically on click; bind externally (e.g. to route matching) to control it. | +| `v-model:activePageNavItem` | `string \| undefined` | `undefined` | Same, for `docsPageNav`. | + +Open/expanded state of the two panels is **not** exposed as a model — it's driven entirely by the container-width breakpoint logic (see below), not user-controllable. + +--- + +## Slots + +| Slot | Purpose | +|------|---------| +| `#docsContent` | Main page content. Only slot this component exposes. | + +--- + +## Usage example + +```vue + + + +``` + +--- + +## Breakpoint behaviour + +Widths are measured on the component's own root element (`useContainerBreakpoints`, container-name `contentDocs`), **not** the viewport — this is deliberate, so nested page decoration (nav rails, sidebars) that shrinks the actual available space is correctly accounted for. Thresholds: `tablet: 768px`, `desktop: 1024px`, matching the `@container contentDocs` queries in this component's own ` +``` + +--- + +## Notes + +- `docsContent` visibility is slot-detected (`useSlots().docsContent`); `docsNav`/`docsPageNav` visibility is item-array-length-detected (`docsNavItems.length > 0`) — different mechanisms, since only `docsContent` is still a real slot. +- `NuxtLink` resolved via `resolveComponent("NuxtLink")`, not imported from `#components` — required so this component works inside Storybook (see `feedback_no_components_import_storybook`). +- Auto-imported in Nuxt — no manual import needed. +- File: `app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue` +- Types: `app/types/components/content-docs.d.ts` (`DocsNavItem`) +- Tests: `app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts` +- Demo page: `app/pages/ui/layout-content-docs.vue` diff --git a/.claude/skills/index.md b/.claude/skills/index.md index 2285092a..4aff7733 100644 --- a/.claude/skills/index.md +++ b/.claude/skills/index.md @@ -73,6 +73,7 @@ Each skill is a single markdown file named `-.md`. ├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API ├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour ├── expanding-panel.md — ExpandingPanel v-model, forceOpened, contentIsOnTop overlay mode, slots (summary/icon/content), ARIA wiring, CSS token API + ├── content-docs.md — ContentDocs docs-page shell: prop-driven docsNav/docsPageNav (not slots), DocsNavItem icons, container-width breakpoint behaviour, shared/per-side CSS token API ├── glass-panel.md — GlassPanel props, slots, CSS token API (--glass-panel-bg/border-color/shadow/highlight), theming override ├── navigation-horizontal.md — NavigationHorizontal props, NavItemData type, CSS token API, import path gotcha ├── pricing-card.md — PricingCard: SaaS-style plan card with highlight, feature list, #cta slot for button customization, CSS token API diff --git a/.vscode/srcdev-component-content-docs.code-snippets b/.vscode/srcdev-component-content-docs.code-snippets index 7686868b..19fa546f 100644 --- a/.vscode/srcdev-component-content-docs.code-snippets +++ b/.vscode/srcdev-component-content-docs.code-snippets @@ -99,6 +99,10 @@ " --content-docs-link-hover-bg: $11;", " --content-docs-link-active-bg: $12;", " --content-docs-link-active-color: $13;", + "", + " --content-docs-nav-column-width: $14;", + " --content-docs-page-nav-column-width: $15;", + " --content-docs-page-nav-column-width-tablet: $16;", " }", "}" ] diff --git a/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue b/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue index 64ba894d..0f36870b 100644 --- a/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +++ b/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue @@ -1,6 +1,6 @@