From 23a1f863c23c3a77210c50b28df52f4b2c890ee1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 16:20:44 +0000 Subject: [PATCH 01/24] chore(deps-dev): bump hono from 4.12.14 to 4.12.18 Bumps [hono](https://github.com/honojs/hono) from 4.12.14 to 4.12.18. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.14...v4.12.18) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16c7998487..41960c6ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27559,9 +27559,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "dev": true, "license": "MIT", "engines": { From b109bb9a911b221979f0900c51c0019b8d876057 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 02:40:20 +0000 Subject: [PATCH 02/24] chore(deps-dev): bump fast-uri from 3.1.0 to 3.1.2 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16c7998487..3cde27b29a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25786,9 +25786,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { From 4d2e6a32bd5b377623defe9373e7d129f198a8d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 17:43:56 +0000 Subject: [PATCH 03/24] chore(deps): bump @babel/plugin-transform-modules-systemjs Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.29.0 to 7.29.4. - [Release notes](https://github.com/babel/babel/releases) - [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md) - [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs) --- updated-dependencies: - dependency-name: "@babel/plugin-transform-modules-systemjs" dependency-version: 7.29.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16c7998487..27fdfd60a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2884,9 +2884,9 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", + "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", "dev": true, "license": "MIT", "dependencies": { From 9888f8c9311950ac7b993cb43cfecb44316d6c55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 21:23:33 +0000 Subject: [PATCH 04/24] chore(deps): bump devalue from 5.6.4 to 5.8.1 Bumps [devalue](https://github.com/sveltejs/devalue) from 5.6.4 to 5.8.1. - [Release notes](https://github.com/sveltejs/devalue/releases) - [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md) - [Commits](https://github.com/sveltejs/devalue/compare/v5.6.4...v5.8.1) --- updated-dependencies: - dependency-name: devalue dependency-version: 5.8.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16c7998487..29fbbb62ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23590,9 +23590,9 @@ } }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/devlop": { From 06e54b0ac2688a10de72146bbda4073c9df3143f Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Tue, 19 May 2026 16:31:49 -0600 Subject: [PATCH 05/24] chore(#3770): generate MCP data from docs site content --- .gitignore | 2 + .../guidance/badge-arialabel-icon-only.mdx | 2 +- .../guidance/datepicker-calendar-variant.mdx | 2 +- .../guidance/datepicker-input-variant.mdx | 4 +- .../page-requires-one-column-layout.mdx | 3 - .../checks/component-refs.ts | 37 ++++ .../content-generators/checks/id-refs.ts | 26 +++ .../content-generators/checks/index.ts | 15 ++ .../scripts/content-generators/checks/lib.ts | 81 ++++++++ .../content-generators/checks/prop-refs.ts | 95 +++++++++ .../content-generators/checks/render.ts | 54 +++++ docs/src/scripts/content-generators/config.ts | 44 ++++ docs/src/scripts/content-generators/index.ts | 100 ++++++++++ .../loaders/component-apis.ts | 32 +++ .../content-generators/loaders/components.ts | 35 ++++ .../content-generators/loaders/examples.ts | 96 +++++++++ .../content-generators/loaders/foundations.ts | 30 +++ .../loaders/framework-identifiers.ts | 188 ++++++++++++++++++ .../content-generators/loaders/frontmatter.ts | 164 +++++++++++++++ .../content-generators/loaders/get-started.ts | 32 +++ .../content-generators/loaders/guidance.ts | 42 ++++ .../scripts/content-generators/loaders/lib.ts | 45 +++++ .../loaders/productTypes.ts | 36 ++++ .../content-generators/outputs/mcp-json.ts | 47 +++++ .../scripts/content-generators/sync-to-dcp.sh | 55 +++++ .../content-generators/transforms/aliases.ts | 71 +++++++ .../transforms/link-guidance.ts | 26 +++ docs/src/scripts/content-generators/types.ts | 119 +++++++++++ package-lock.json | 10 + package.json | 1 + 30 files changed, 1487 insertions(+), 7 deletions(-) create mode 100644 docs/src/scripts/content-generators/checks/component-refs.ts create mode 100644 docs/src/scripts/content-generators/checks/id-refs.ts create mode 100644 docs/src/scripts/content-generators/checks/index.ts create mode 100644 docs/src/scripts/content-generators/checks/lib.ts create mode 100644 docs/src/scripts/content-generators/checks/prop-refs.ts create mode 100644 docs/src/scripts/content-generators/checks/render.ts create mode 100644 docs/src/scripts/content-generators/config.ts create mode 100644 docs/src/scripts/content-generators/index.ts create mode 100644 docs/src/scripts/content-generators/loaders/component-apis.ts create mode 100644 docs/src/scripts/content-generators/loaders/components.ts create mode 100644 docs/src/scripts/content-generators/loaders/examples.ts create mode 100644 docs/src/scripts/content-generators/loaders/foundations.ts create mode 100644 docs/src/scripts/content-generators/loaders/framework-identifiers.ts create mode 100644 docs/src/scripts/content-generators/loaders/frontmatter.ts create mode 100644 docs/src/scripts/content-generators/loaders/get-started.ts create mode 100644 docs/src/scripts/content-generators/loaders/guidance.ts create mode 100644 docs/src/scripts/content-generators/loaders/lib.ts create mode 100644 docs/src/scripts/content-generators/loaders/productTypes.ts create mode 100644 docs/src/scripts/content-generators/outputs/mcp-json.ts create mode 100755 docs/src/scripts/content-generators/sync-to-dcp.sh create mode 100644 docs/src/scripts/content-generators/transforms/aliases.ts create mode 100644 docs/src/scripts/content-generators/transforms/link-guidance.ts create mode 100644 docs/src/scripts/content-generators/types.ts diff --git a/.gitignore b/.gitignore index 0c7fd8dc05..961c263c11 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ NOTES.md /test-results __screenshots__ docs/.astro +docs/generated/mcp/ +docs/generated/md-bundle/ # environment .env diff --git a/docs/src/content/guidance/badge-arialabel-icon-only.mdx b/docs/src/content/guidance/badge-arialabel-icon-only.mdx index c9e38f3214..575c8cc1a0 100644 --- a/docs/src/content/guidance/badge-arialabel-icon-only.mdx +++ b/docs/src/content/guidance/badge-arialabel-icon-only.mdx @@ -12,6 +12,6 @@ appliesTo: - badge relatedProps: - ariaLabel - - icon + - iconType status: published --- diff --git a/docs/src/content/guidance/datepicker-calendar-variant.mdx b/docs/src/content/guidance/datepicker-calendar-variant.mdx index ab2db37658..1489476a2e 100644 --- a/docs/src/content/guidance/datepicker-calendar-variant.mdx +++ b/docs/src/content/guidance/datepicker-calendar-variant.mdx @@ -10,7 +10,7 @@ appliesTo: components: - date-picker relatedProps: - - variant + - type status: published --- diff --git a/docs/src/content/guidance/datepicker-input-variant.mdx b/docs/src/content/guidance/datepicker-input-variant.mdx index ff53ce7945..d8e8cbb7fe 100644 --- a/docs/src/content/guidance/datepicker-input-variant.mdx +++ b/docs/src/content/guidance/datepicker-input-variant.mdx @@ -10,12 +10,12 @@ appliesTo: components: - date-picker relatedProps: - - variant + - type status: published --- - + diff --git a/docs/src/content/guidance/page-requires-one-column-layout.mdx b/docs/src/content/guidance/page-requires-one-column-layout.mdx index 903afc9b47..91c9d32457 100644 --- a/docs/src/content/guidance/page-requires-one-column-layout.mdx +++ b/docs/src/content/guidance/page-requires-one-column-layout.mdx @@ -6,9 +6,6 @@ topic: other tags: - layout - page-structure -appliesTo: - components: - - one-column-layout status: published --- diff --git a/docs/src/scripts/content-generators/checks/component-refs.ts b/docs/src/scripts/content-generators/checks/component-refs.ts new file mode 100644 index 0000000000..ab3227115a --- /dev/null +++ b/docs/src/scripts/content-generators/checks/component-refs.ts @@ -0,0 +1,37 @@ +import type { AnyRecord, Finding } from "../types"; +import { collectIdsAndAliases, pushBrokenRefs } from "./lib"; + +/** + * Validates the four cross-references that target component ids: + * - components.relatedComponents + * - examples.components + * - guidance.appliesTo.components + * - productTypes.components + * + * All produce error-severity findings when a referenced id is not in the + * components collection (either as a canonical id or as an alias). + */ +export function checkComponentRefs(records: AnyRecord[]): Finding[] { + const findings: Finding[] = []; + const componentIds = collectIdsAndAliases(records, "components"); + + for (const r of records) { + if (r.collection === "components") { + pushBrokenRefs(findings, r, "relatedComponents", r.relatedComponents, componentIds); + } else if (r.collection === "examples") { + pushBrokenRefs(findings, r, "components", r.components, componentIds); + } else if (r.collection === "guidance") { + pushBrokenRefs( + findings, + r, + "appliesTo.components", + r.appliesTo?.components, + componentIds, + ); + } else if (r.collection === "productTypes") { + pushBrokenRefs(findings, r, "components", r.components, componentIds); + } + } + + return findings; +} diff --git a/docs/src/scripts/content-generators/checks/id-refs.ts b/docs/src/scripts/content-generators/checks/id-refs.ts new file mode 100644 index 0000000000..c8240677ba --- /dev/null +++ b/docs/src/scripts/content-generators/checks/id-refs.ts @@ -0,0 +1,26 @@ +import type { AnyRecord, Finding } from "../types"; +import { collectIds, collectIdsAndAliases, pushBrokenRefs } from "./lib"; + +/** + * Validates cross-references against guidance and example ids: + * - components.relatedGuidance → guidance ids + * - examples.relatedExamples → example ids (and aliases, since examples + * carry old-slug aliases via EXAMPLE_SLUG_ALIASES) + * + * Both produce error-severity findings. + */ +export function checkIdRefs(records: AnyRecord[]): Finding[] { + const findings: Finding[] = []; + const guidanceIds = collectIds(records, "guidance"); + const exampleIds = collectIdsAndAliases(records, "examples"); + + for (const r of records) { + if (r.collection === "components") { + pushBrokenRefs(findings, r, "relatedGuidance", r.relatedGuidance, guidanceIds); + } else if (r.collection === "examples") { + pushBrokenRefs(findings, r, "relatedExamples", r.relatedExamples, exampleIds); + } + } + + return findings; +} diff --git a/docs/src/scripts/content-generators/checks/index.ts b/docs/src/scripts/content-generators/checks/index.ts new file mode 100644 index 0000000000..e568aafdb8 --- /dev/null +++ b/docs/src/scripts/content-generators/checks/index.ts @@ -0,0 +1,15 @@ +import type { AnyRecord, Finding } from "../types"; +import { checkComponentRefs } from "./component-refs"; +import { checkIdRefs } from "./id-refs"; +import { checkPropRefs } from "./prop-refs"; + +/** + * Run all cross-validation checks against the loaded records. + */ +export function runChecks(records: AnyRecord[]): Finding[] { + return [ + ...checkComponentRefs(records), + ...checkIdRefs(records), + ...checkPropRefs(records), + ]; +} diff --git a/docs/src/scripts/content-generators/checks/lib.ts b/docs/src/scripts/content-generators/checks/lib.ts new file mode 100644 index 0000000000..c50b68a5a0 --- /dev/null +++ b/docs/src/scripts/content-generators/checks/lib.ts @@ -0,0 +1,81 @@ +import { distance } from "fastest-levenshtein"; +import type { AnyRecord, Finding } from "../types"; + +const MAX_EDIT_DISTANCE = 2; + +/** Returns the set of ids for records in the given collection. */ +export function collectIds( + records: AnyRecord[], + collection: AnyRecord["collection"], +): Set { + const ids = new Set(); + for (const r of records) { + if (r.collection === collection) ids.add(r.id); + } + return ids; +} + +/** + * Returns the set of ids plus aliases for records in the given collection. + * Lets cross-references match either the canonical id or any legacy/sub-component + * name surfaced on the record's aliases array. + */ +export function collectIdsAndAliases( + records: AnyRecord[], + collection: AnyRecord["collection"], +): Set { + const set = new Set(); + for (const r of records) { + if (r.collection !== collection) continue; + set.add(r.id); + const aliases = (r as { aliases?: string[] }).aliases; + if (Array.isArray(aliases)) for (const a of aliases) set.add(a); + } + return set; +} + +/** + * For each ref in `refs` that does not appear in `validIds`, push an + * error-severity Finding describing the broken reference. Populates + * `hint` when a close-by valid id exists. + */ +export function pushBrokenRefs( + findings: Finding[], + source: AnyRecord, + field: string, + refs: string[] | undefined, + validIds: Set, +): void { + if (!Array.isArray(refs)) return; + for (const ref of refs) { + if (validIds.has(ref)) continue; + findings.push({ + severity: "error", + source: { collection: source.collection, id: source.id }, + field, + brokenRef: ref, + hint: computeHint(ref, validIds), + }); + } +} + +/** + * If any id in `validIds` is within MAX_EDIT_DISTANCE of `ref`, return + * `did you mean ""?`. If multiple ids tie, take the first by + * sort order. Returns undefined if no id is close enough. + */ +export function computeHint(ref: string, validIds: Set): string | undefined { + let best: string | undefined; + let bestDistance = MAX_EDIT_DISTANCE + 1; + + const sorted = [...validIds].sort(); + for (const candidate of sorted) { + const d = distance(ref, candidate); + if (d < bestDistance) { + bestDistance = d; + best = candidate; + } + } + + return best ? `did you mean "${best}"?` : undefined; +} diff --git a/docs/src/scripts/content-generators/checks/prop-refs.ts b/docs/src/scripts/content-generators/checks/prop-refs.ts new file mode 100644 index 0000000000..9168b65509 --- /dev/null +++ b/docs/src/scripts/content-generators/checks/prop-refs.ts @@ -0,0 +1,95 @@ +import type { AnyRecord, ComponentRecord, Finding } from "../types"; +import { computeHint } from "./lib"; + +/** + * Validates guidance.relatedProps against the extracted prop names on the + * components named in guidance.appliesTo.components. + * + * Warn-level. A relatedProps entry is valid if it exists on AT LEAST ONE + * of the linked components, matching the semantic "this guidance is about + * this prop, which lives on one of these components." + * + * Skips guidance atoms whose appliesTo.components references components + * that do not exist; component-refs.ts reports those separately. + */ +export function checkPropRefs(records: AnyRecord[]): Finding[] { + const findings: Finding[] = []; + const componentMap = new Map(); + for (const r of records) { + if (r.collection === "components") componentMap.set(r.id, r); + } + + for (const r of records) { + if (r.collection !== "guidance") continue; + if (!Array.isArray(r.relatedProps) || r.relatedProps.length === 0) continue; + const linkedComponents = r.appliesTo?.components; + if (!Array.isArray(linkedComponents) || linkedComponents.length === 0) { + continue; + } + + // Build the union of props across linked components that exist. + // Missing components are reported by component-refs.ts; skip them here + // to avoid double-reports. + const allValidProps = new Set(); + const knownComponents: string[] = []; + for (const componentId of linkedComponents) { + const component = componentMap.get(componentId); + if (!component) continue; + knownComponents.push(componentId); + for (const propName of extractPropNames(component)) { + allValidProps.add(propName); + } + } + if (knownComponents.length === 0) continue; + + for (const propRef of r.relatedProps) { + if (allValidProps.has(propRef)) continue; + findings.push({ + severity: "warning", + source: { collection: "guidance", id: r.id }, + field: "relatedProps", + brokenRef: propRef, + context: + knownComponents.length === 1 + ? `on component "${knownComponents[0]}"` + : `on components ${knownComponents.map((c) => `"${c}"`).join(", ")}`, + notFoundMessage: "not in extracted props", + hint: computeHint(propRef, allValidProps), + }); + } + } + + return findings; +} + +/** + * Walks the component's api blob (frameworks.{react,angular,webComponents}.props[]) + * and returns the union of all prop names declared across frameworks. + */ +function extractPropNames(component: ComponentRecord): Set { + const names = new Set(); + const api = component.api; + if (!api || typeof api !== "object") return names; + + const frameworks = (api as Record).frameworks; + if (!frameworks || typeof frameworks !== "object") return names; + + for (const key of ["react", "angular", "webComponents"]) { + const framework = (frameworks as Record)[key]; + if (!framework || typeof framework !== "object") continue; + const props = (framework as Record).props; + if (!Array.isArray(props)) continue; + for (const prop of props) { + if ( + typeof prop === "object" && + prop !== null && + "name" in prop && + typeof (prop as { name: unknown }).name === "string" + ) { + names.add((prop as { name: string }).name); + } + } + } + + return names; +} diff --git a/docs/src/scripts/content-generators/checks/render.ts b/docs/src/scripts/content-generators/checks/render.ts new file mode 100644 index 0000000000..6ee7094f67 --- /dev/null +++ b/docs/src/scripts/content-generators/checks/render.ts @@ -0,0 +1,54 @@ +import type { Finding } from "../types"; + +/** + * Render the findings to stderr. Errors first, then warnings, then a summary. + * No-op when there are zero findings. + */ +export function renderFindings(findings: Finding[]): void { + const errors = findings.filter((f) => f.severity === "error"); + const warnings = findings.filter((f) => f.severity === "warning"); + + if (errors.length === 0 && warnings.length === 0) return; + + if (errors.length > 0) { + process.stderr.write(`\n✗ ERRORS (${errors.length})\n`); + renderGroup(errors); + } + + if (warnings.length > 0) { + process.stderr.write(`\n⚠ WARNINGS (${warnings.length})\n`); + renderGroup(warnings); + } + + process.stderr.write( + `\nSummary: ${errors.length} ${plural("error", errors.length)}, ` + + `${warnings.length} ${plural("warning", warnings.length)}.\n\n`, + ); +} + +function renderGroup(findings: Finding[]): void { + const bySource = new Map(); + for (const f of findings) { + const key = `${f.source.collection}/${f.source.id}`; + if (!bySource.has(key)) bySource.set(key, []); + bySource.get(key)!.push(f); + } + + for (const [key, group] of bySource) { + process.stderr.write(`\n ${key}\n`); + for (const f of group) { + process.stderr.write(` ${renderLine(f)}\n`); + } + } +} + +function renderLine(f: Finding): string { + const ref = f.context ? `"${f.brokenRef}" ${f.context}` : `"${f.brokenRef}"`; + const notFound = f.notFoundMessage ?? "not found"; + const tail = f.hint ? `${notFound}, ${f.hint}` : notFound; + return `${f.field} references ${ref} (${tail})`; +} + +function plural(word: string, n: number): string { + return n === 1 ? word : `${word}s`; +} diff --git a/docs/src/scripts/content-generators/config.ts b/docs/src/scripts/content-generators/config.ts new file mode 100644 index 0000000000..acf0c7b344 --- /dev/null +++ b/docs/src/scripts/content-generators/config.ts @@ -0,0 +1,44 @@ +import * as path from "path"; +import { fileURLToPath } from "url"; + +const WORKSPACE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../..", +); + +export const paths = { + workspaceRoot: WORKSPACE_ROOT, + content: { + components: path.join(WORKSPACE_ROOT, "docs/src/content/components"), + examples: path.join(WORKSPACE_ROOT, "docs/src/content/examples"), + guidance: path.join(WORKSPACE_ROOT, "docs/src/content/guidance"), + foundations: path.join(WORKSPACE_ROOT, "docs/src/content/foundations"), + getStarted: path.join(WORKSPACE_ROOT, "docs/src/content/get-started"), + productTypes: path.join(WORKSPACE_ROOT, "docs/src/content/productTypes"), + }, + code: { + componentApis: path.join(WORKSPACE_ROOT, "docs/generated/component-apis"), + libs: path.join(WORKSPACE_ROOT, "libs"), + }, + output: { + mcp: path.join(WORKSPACE_ROOT, "docs/generated/mcp"), + mdBundle: path.join(WORKSPACE_ROOT, "docs/generated/md-bundle"), + }, +} as const; + +export type CollectionName = + | "components" + | "examples" + | "guidance" + | "foundations" + | "get-started" + | "productTypes"; + +export const collectionNames: CollectionName[] = [ + "components", + "examples", + "guidance", + "foundations", + "get-started", + "productTypes", +]; diff --git a/docs/src/scripts/content-generators/index.ts b/docs/src/scripts/content-generators/index.ts new file mode 100644 index 0000000000..be26838afb --- /dev/null +++ b/docs/src/scripts/content-generators/index.ts @@ -0,0 +1,100 @@ +import { loadComponents } from "./loaders/components"; +import { loadExamples } from "./loaders/examples"; +import { loadGuidance } from "./loaders/guidance"; +import { loadFoundations } from "./loaders/foundations"; +import { loadGetStarted } from "./loaders/get-started"; +import { loadProductTypes } from "./loaders/productTypes"; +import { loadComponentApis } from "./loaders/component-apis"; +import { loadFrameworkIdentifiers } from "./loaders/framework-identifiers"; +import { addComponentAliases, addExampleAliases } from "./transforms/aliases"; +import { linkGuidanceToComponents } from "./transforms/link-guidance"; +import { writeMcpJson } from "./outputs/mcp-json"; +import { runChecks } from "./checks"; +import { renderFindings } from "./checks/render"; +import type { AnyRecord, ComponentRecord } from "./types"; + +function main(): void { + const start = performance.now(); + + // 1. Load raw data from all sources. + const componentsRaw = loadComponents(); + const examplesRaw = loadExamples(); + const guidance = loadGuidance(); + const foundations = loadFoundations(); + const getStarted = loadGetStarted(); + const productTypes = loadProductTypes(); + const apis = loadComponentApis(); + const fwIds = loadFrameworkIdentifiers(); + + // 2. Merge code-derived facts (framework ids, API blob) into components. + let components: ComponentRecord[] = componentsRaw.map((c) => { + const fw = fwIds.get(c.id); + const api = apis.get(c.id); + let apiBlob: Record | undefined; + if (api) { + const { componentSlug: _slug, ...rest } = api; + void _slug; + apiBlob = rest; + } + return { + ...c, + webComponentTag: fw?.webComponentTag, + reactClassName: fw?.reactClassName, + angularSelector: fw?.angularSelector, + // Carry extra react class names as a starting point for aliases. + aliases: fw && fw.reactClassNames.length > 1 ? fw.reactClassNames.slice(1) : [], + api: apiBlob, + }; + }); + + // 3. Transforms. + components = addComponentAliases(components); + components = linkGuidanceToComponents(components, guidance); + const examples = addExampleAliases(examplesRaw); + + // 4. Cross-validation checks. + const records: AnyRecord[] = [ + ...components, + ...examples, + ...guidance, + ...foundations, + ...getStarted, + ...productTypes, + ]; + const findings = runChecks(records); + renderFindings(findings); + const errorCount = findings.filter((f) => f.severity === "error").length; + if (errorCount > 0) { + process.stderr.write( + `[content-generators] skipped output because of ${errorCount} ${errorCount === 1 ? "error" : "errors"}. ` + + `Existing files in docs/generated/mcp/ are unchanged.\n`, + ); + process.exit(1); + } + + // 5. Output. + const result = writeMcpJson(records); + const elapsed = Math.round(performance.now() - start); + + // 6. Report. + const withFw = components.filter((c) => c.webComponentTag).length; + const withApi = components.filter((c) => c.api).length; + const withAliases = components.filter((c) => c.aliases && c.aliases.length > 0).length; + const withGuidance = components.filter( + (c) => c.relatedGuidance && c.relatedGuidance.length > 0, + ).length; + const examplesWithAliases = examples.filter((e) => e.aliases.length > 0).length; + const examplesWithProductType = examples.filter((e) => e.productType).length; + + process.stdout.write( + `[content-generators] wrote ${result.written} records in ${elapsed}ms\n` + + ` components: ${components.length} (${withFw} with fw ids, ${withApi} with api, ${withAliases} with aliases, ${withGuidance} with linked guidance)\n` + + ` examples: ${examples.length} (${examplesWithAliases} with aliases, ${examplesWithProductType} with productType)\n` + + ` guidance: ${guidance.length}\n` + + ` foundations: ${foundations.length}\n` + + ` get-started: ${getStarted.length}\n` + + ` productTypes: ${productTypes.length}\n`, + ); +} + +main(); diff --git a/docs/src/scripts/content-generators/loaders/component-apis.ts b/docs/src/scripts/content-generators/loaders/component-apis.ts new file mode 100644 index 0000000000..bb4ca0ade4 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/component-apis.ts @@ -0,0 +1,32 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; + +export interface ComponentApi { + componentSlug: string; + extractedFrom?: string; + frameworks?: Record; + events?: unknown[]; + slots?: unknown[]; + [key: string]: unknown; +} + +export function loadComponentApis(): Map { + const map = new Map(); + const dir = paths.code.componentApis; + if (!fs.existsSync(dir)) return map; + + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith(".json")) continue; + try { + const raw = fs.readFileSync(path.join(dir, file), "utf8"); + const data = JSON.parse(raw) as ComponentApi; + const slug = data.componentSlug ?? file.replace(/\.json$/, ""); + map.set(slug, { ...data, componentSlug: slug }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`[loadComponentApis] failed to parse ${file}: ${message}\n`); + } + } + return map; +} diff --git a/docs/src/scripts/content-generators/loaders/components.ts b/docs/src/scripts/content-generators/loaders/components.ts new file mode 100644 index 0000000000..a45a5668ef --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/components.ts @@ -0,0 +1,35 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asString, asStringArray, listMdxFiles } from "./lib"; +import type { ComponentRecord } from "../types"; + +export function loadComponents(): ComponentRecord[] { + const records: ComponentRecord[] = []; + + for (const filePath of listMdxFiles(paths.content.components)) { + const slug = path.basename(filePath, ".mdx"); + const raw = fs.readFileSync(filePath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + records.push({ + id: asString(data.id) ?? slug, + collection: "components", + name: asString(data.name) ?? slug, + description: asString(data.description), + status: asString(data.status) ?? "stable", + category: asString(data.category) ?? "utilities", + tags: asStringArray(data.tags), + aliases: [], + relatedComponents: asStringArray(data.relatedComponents), + figmaUrl: asString(data.figmaUrl), + hidden: data.hidden === true ? true : undefined, + subcomponent: data.subcomponent === true ? true : undefined, + body: body.trim(), + }); + } + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records; +} diff --git a/docs/src/scripts/content-generators/loaders/examples.ts b/docs/src/scripts/content-generators/loaders/examples.ts new file mode 100644 index 0000000000..8bd67e2ffc --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/examples.ts @@ -0,0 +1,96 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asString, asStringArray, listDirectories } from "./lib"; +import type { ExampleRecord } from "../types"; + +const PAGE_LIKE_SIZES = new Set(["page", "task", "product"]); + +const VALID_SIZES = new Set([ + "interaction", + "section", + "page", + "task", + "product", +]); + +export function loadExamples(): ExampleRecord[] { + const records: ExampleRecord[] = []; + + for (const folder of listDirectories(paths.content.examples)) { + const slug = path.basename(folder); + const indexPath = path.join(folder, "index.mdx"); + if (!fs.existsSync(indexPath)) continue; + + const raw = fs.readFileSync(indexPath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + const sizeRaw = asString(data.size); + if (!sizeRaw || !VALID_SIZES.has(sizeRaw as ExampleRecord["size"])) { + // Schema enforces this on the docs site; skip malformed entries here + // rather than emitting garbage downstream. + continue; + } + const size = sizeRaw as ExampleRecord["size"]; + + const productType = asString(data.productType); + const productTypeNarrowed = + productType === "workspace" || productType === "public-form" + ? productType + : undefined; + + const frameworks = pickFrameworks(folder, data.frameworks, size); + + records.push({ + id: asString(data.id) ?? slug, + collection: "examples", + title: asString(data.title) ?? slug, + description: asString(data.description), + size, + tags: asStringArray(data.tags), + components: asStringArray(data.components), + relatedExamples: asStringArray(data.relatedExamples), + aliases: asStringArray(data.aliases), + status: asString(data.status) ?? "published", + productType: productTypeNarrowed, + frameworks, + previewImage: asString(data.previewImage), + figmaUrl: asString(data.figmaUrl), + accessibilityNotes: asString(data.accessibilityNotes), + hidden: data.hidden === true ? true : undefined, + previewUrl: asString(data.previewUrl), + reactSourceUrl: asString(data.reactSourceUrl), + angularSourceUrl: asString(data.angularSourceUrl), + sourceUrl: asString(data.sourceUrl), + stackblitzUrl: asString(data.stackblitzUrl), + body: body.trim(), + }); + } + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records; +} + +function pickFrameworks( + folder: string, + declared: unknown, + size: ExampleRecord["size"], +): string[] | undefined { + // Trust the frontmatter when it's set (page-like sizes can declare this). + const declaredArr = asStringArray(declared); + if (declaredArr.length > 0) return declaredArr; + + // Page-like sizes are meant to declare frameworks in frontmatter; if absent, + // don't infer from sibling files (page-scale entries often live outside the + // example folder). + if (PAGE_LIKE_SIZES.has(size)) return undefined; + + // Interaction/section entries: detect from sibling files as before. + const detected: string[] = []; + if (fs.existsSync(path.join(folder, "react.tsx"))) detected.push("react"); + if (fs.existsSync(path.join(folder, "angular.html"))) detected.push("angular"); + if (fs.existsSync(path.join(folder, "web-components.html"))) + detected.push("web-components"); + return detected.length > 0 ? detected : undefined; +} diff --git a/docs/src/scripts/content-generators/loaders/foundations.ts b/docs/src/scripts/content-generators/loaders/foundations.ts new file mode 100644 index 0000000000..c9243f2d16 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/foundations.ts @@ -0,0 +1,30 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asString, asStringArray, listMdxFiles } from "./lib"; +import type { FoundationRecord } from "../types"; + +export function loadFoundations(): FoundationRecord[] { + const records: FoundationRecord[] = []; + + for (const filePath of listMdxFiles(paths.content.foundations)) { + const slug = path.basename(filePath, ".mdx"); + const raw = fs.readFileSync(filePath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + records.push({ + id: asString(data.id) ?? slug, + collection: "foundations", + title: asString(data.title) ?? slug, + description: asString(data.description) ?? "", + category: asString(data.category) ?? "design", + tags: asStringArray(data.tags), + status: asString(data.status) ?? "published", + body: body.trim(), + }); + } + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records; +} diff --git a/docs/src/scripts/content-generators/loaders/framework-identifiers.ts b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts new file mode 100644 index 0000000000..859cbbd799 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts @@ -0,0 +1,188 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; + +export interface FrameworkIdentifiers { + webComponentTag?: string; + reactClassName?: string; + angularSelector?: string; + // All React export names found in the wrapper for this slug. + // First entry is the canonical/primary name; the rest are aliases. + reactClassNames: string[]; +} + +const WC_COMPONENTS_DIR = "web-components/src/components"; +const REACT_LIB_DIR = "react-components/src/lib"; +const ANGULAR_LIB_DIR = "angular-components/src/lib/components"; + +// Map is keyed by FOLDER NAME, which matches the doc slug for the +// vast majority of components. Code-identifiers (tag, React class, selector) +// found inside go into the record's fields. Divergence between folder name +// and the embedded tag (e.g. footer/ folder with goa-app-footer tag) is +// expected. The aliases transform turns those divergences into alias entries. + +export function loadFrameworkIdentifiers(): Map { + const bySlug = new Map(); + const getOrInit = (slug: string): FrameworkIdentifiers => { + let r = bySlug.get(slug); + if (!r) { + r = { reactClassNames: [] }; + bySlug.set(slug, r); + } + return r; + }; + + // 1) Web components: walk libs/web-components/src/components//.svelte. + // A folder can host more than one custom element (e.g. temporary-notification/ + // contains both goa-temp-notification and goa-temp-notification-ctrl). Gather + // every tag so the React-side bridge can resolve aux tags to the parent slug; + // the canonical webComponentTag stays the first one found. + const wcRoot = path.join(paths.code.libs, WC_COMPONENTS_DIR); + const tagToWcFolder = new Map(); + if (fs.existsSync(wcRoot)) { + for (const folder of fs.readdirSync(wcRoot, { withFileTypes: true })) { + if (!folder.isDirectory()) continue; + const tags = readWebComponentTags(path.join(wcRoot, folder.name)); + if (tags.length === 0) continue; + const rec = getOrInit(folder.name); + rec.webComponentTag = tags[0]; + for (const tag of tags) { + if (!tagToWcFolder.has(tag)) tagToWcFolder.set(tag, folder.name); + } + } + } + + // 2) React wrappers. Bucket by the IntrinsicElement tag declaration when + // present (most reliable bridge to the web-component slug); fall back to + // folder name for files that don't declare one. + + const reactRoot = path.join(paths.code.libs, REACT_LIB_DIR); + if (fs.existsSync(reactRoot)) { + for (const folder of fs.readdirSync(reactRoot, { withFileTypes: true })) { + if (!folder.isDirectory()) continue; + const folderPath = path.join(reactRoot, folder.name); + + for (const tsxFile of walkFiles(folderPath, ".tsx")) { + if (tsxFile.endsWith(".spec.tsx") || tsxFile.endsWith(".test.tsx")) continue; + const raw = fs.readFileSync(tsxFile, "utf8"); + const exports = extractReactExports(raw); + if (exports.length === 0) continue; + + // Find any IntrinsicElement declarations in this file. + const intrinsicTags = [...raw.matchAll(/IntrinsicElements\s*\{[\s\S]*?\}/g)] + .flatMap((m) => [...m[0].matchAll(/"(goa-[a-z0-9-]+)"\s*:/g)]) + .map((m) => m[1]); + + // Determine the target slug: prefer the WC folder linked to the + // tag (handles textarea/text-area mismatch); otherwise use the + // React folder name as the slug. + const targetSlugs = new Set(); + for (const tag of intrinsicTags) { + const wcFolder = tagToWcFolder.get(tag); + if (wcFolder) targetSlugs.add(wcFolder); + } + if (targetSlugs.size === 0) targetSlugs.add(folder.name); + + for (const slug of targetSlugs) { + const rec = getOrInit(slug); + for (const name of exports) { + if (!rec.reactClassNames.includes(name)) { + rec.reactClassNames.push(name); + } + } + if (!rec.reactClassName) rec.reactClassName = rec.reactClassNames[0]; + } + } + } + } + + // 3) Angular selectors. + const angularRoot = path.join(paths.code.libs, ANGULAR_LIB_DIR); + if (fs.existsSync(angularRoot)) { + for (const folder of fs.readdirSync(angularRoot, { withFileTypes: true })) { + if (!folder.isDirectory()) continue; + const selector = readAngularSelector(path.join(angularRoot, folder.name)); + if (selector) { + const rec = getOrInit(folder.name); + rec.angularSelector = selector; + } + } + } + + return bySlug; +} + +function readWebComponentTags(folderPath: string): string[] { + const tags: string[] = []; + for (const file of fs.readdirSync(folderPath)) { + if (!file.endsWith(".svelte")) continue; + if (file.includes(".test.")) continue; + const raw = fs.readFileSync(path.join(folderPath, file), "utf8"); + // Two customElement forms in this codebase: + // + // + const block = raw.match(//); + if (!block) continue; + const tagMatch = block[0].match(/["'](goa-[a-z0-9-]+)["']/); + if (tagMatch && !tags.includes(tagMatch[1])) tags.push(tagMatch[1]); + } + return tags; +} + +function readReactExports(folderPath: string): string[] { + const names: string[] = []; + for (const file of walkFiles(folderPath, ".tsx")) { + if (file.endsWith(".spec.tsx") || file.endsWith(".test.tsx")) continue; + const raw = fs.readFileSync(file, "utf8"); + for (const n of extractReactExports(raw)) { + if (!names.includes(n)) names.push(n); + } + } + return names; +} + +function readAngularSelector(folderPath: string): string | undefined { + for (const file of fs.readdirSync(folderPath)) { + if (!file.endsWith(".ts")) continue; + if (file.endsWith(".spec.ts") || file.endsWith(".test.ts")) continue; + const raw = fs.readFileSync(path.join(folderPath, file), "utf8"); + const m = raw.match(/selector\s*:\s*["']goab-([a-z0-9-]+)["']/); + if (m) return `goab-${m[1]}`; + } + return undefined; +} + +function walkFiles(dir: string, ext: string): string[] { + const out: string[] = []; + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkFiles(full, ext)); + else if (entry.isFile() && entry.name.endsWith(ext)) out.push(full); + } + return out; +} + +function extractReactExports(src: string): string[] { + const names = new Set(); + for (const m of src.matchAll(/export\s+function\s+(Goab[A-Z][A-Za-z0-9]*)/g)) { + names.add(m[1]); + } + for (const m of src.matchAll(/export\s+const\s+(Goab[A-Z][A-Za-z0-9]*)\s*[:=]/g)) { + names.add(m[1]); + } + for (const block of src.matchAll(/export\s*\{([^}]+)\}/g)) { + for (const part of block[1].split(",")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const asMatch = trimmed.match(/(?:[A-Za-z0-9_]+)\s+as\s+(Goab[A-Z][A-Za-z0-9]*)/); + if (asMatch) { + names.add(asMatch[1]); + continue; + } + const direct = trimmed.match(/^(Goab[A-Z][A-Za-z0-9]*)$/); + if (direct) names.add(direct[1]); + } + } + return [...names]; +} diff --git a/docs/src/scripts/content-generators/loaders/frontmatter.ts b/docs/src/scripts/content-generators/loaders/frontmatter.ts new file mode 100644 index 0000000000..31dd19ab47 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/frontmatter.ts @@ -0,0 +1,164 @@ +// Minimal YAML-frontmatter parser tuned for the shapes used in this repo's +// content collections: scalars, arrays of strings, single-level nested +// objects, inline `[a, b]` arrays. Recursive descent: easier to reason about +// than the stack-based approach for nested objects. + +export interface ParsedFrontmatter { + data: Record; + body: string; +} + +export function parseFrontmatter(raw: string): ParsedFrontmatter { + const lines = raw.split("\n"); + if (lines[0] !== "---") return { data: {}, body: raw }; + + let endIdx = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === "---") { + endIdx = i; + break; + } + } + if (endIdx === -1) return { data: {}, body: raw }; + + const yamlLines = lines.slice(1, endIdx); + const body = lines.slice(endIdx + 1).join("\n"); + const [data] = parseObject(yamlLines, 0, 0); + return { data, body }; +} + +function leadingSpaces(line: string): number { + return line.length - line.trimStart().length; +} + +function isBlankOrComment(line: string | undefined): boolean { + if (line === undefined) return true; + const trimmed = line.trim(); + return trimmed === "" || trimmed.startsWith("#"); +} + +function nextContentLine(lines: string[], startIdx: number): number { + let i = startIdx; + while (i < lines.length && isBlankOrComment(lines[i])) i++; + return i; +} + +function parseObject( + lines: string[], + startIdx: number, + expectedIndent: number, +): [Record, number] { + const obj: Record = {}; + let i = startIdx; + + while (i < lines.length) { + if (isBlankOrComment(lines[i])) { + i++; + continue; + } + const indent = leadingSpaces(lines[i]); + if (indent < expectedIndent) break; + if (indent > expectedIndent) { + // Unexpected deeper indent at this scope. Skip defensively. + i++; + continue; + } + + const trimmed = lines[i].trimStart(); + // Top-level array item shouldn't appear here (parseArray handles those). + if (trimmed.startsWith("- ") || trimmed === "-") break; + + const colon = trimmed.indexOf(":"); + if (colon === -1) { + i++; + continue; + } + const key = trimmed.slice(0, colon).trim(); + const valueText = trimmed.slice(colon + 1).trim(); + + if (valueText === "") { + // Open scope: peek at next content line to decide array vs object. + const nextIdx = nextContentLine(lines, i + 1); + if (nextIdx >= lines.length) { + i++; + continue; + } + const nextIndent = leadingSpaces(lines[nextIdx]); + if (nextIndent <= expectedIndent) { + // Empty scope. + i++; + continue; + } + const nextTrimmed = lines[nextIdx].trimStart(); + if (nextTrimmed.startsWith("- ") || nextTrimmed === "-") { + const [arr, consumed] = parseArray(lines, nextIdx, nextIndent); + obj[key] = arr; + i = consumed; + } else { + const [sub, consumed] = parseObject(lines, nextIdx, nextIndent); + obj[key] = sub; + i = consumed; + } + } else if (valueText.startsWith("[") && valueText.endsWith("]")) { + obj[key] = parseInlineArray(valueText); + i++; + } else { + obj[key] = parseScalar(valueText); + i++; + } + } + + return [obj, i]; +} + +function parseArray( + lines: string[], + startIdx: number, + expectedIndent: number, +): [unknown[], number] { + const arr: unknown[] = []; + let i = startIdx; + + while (i < lines.length) { + if (isBlankOrComment(lines[i])) { + i++; + continue; + } + const indent = leadingSpaces(lines[i]); + if (indent < expectedIndent) break; + if (indent > expectedIndent) { + // Deeper than array item indent. Shouldn't happen for our shapes; skip. + i++; + continue; + } + const trimmed = lines[i].trimStart(); + if (!trimmed.startsWith("- ") && trimmed !== "-") break; + + const valueText = trimmed === "-" ? "" : trimmed.slice(2).trim(); + arr.push(parseScalar(valueText)); + i++; + } + + return [arr, i]; +} + +function parseInlineArray(text: string): unknown[] { + const inner = text.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(",").map((s) => parseScalar(s.trim())); +} + +function parseScalar(text: string): unknown { + if (text === "") return ""; + if (text === "true") return true; + if (text === "false") return false; + if (text === "null" || text === "~") return null; + if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text); + if ( + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'")) + ) { + return text.slice(1, -1); + } + return text; +} diff --git a/docs/src/scripts/content-generators/loaders/get-started.ts b/docs/src/scripts/content-generators/loaders/get-started.ts new file mode 100644 index 0000000000..a6cbcb552c --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/get-started.ts @@ -0,0 +1,32 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asNumber, asString, walkMdxFiles } from "./lib"; +import type { GetStartedRecord } from "../types"; + +export function loadGetStarted(): GetStartedRecord[] { + const records: GetStartedRecord[] = []; + + for (const filePath of walkMdxFiles(paths.content.getStarted)) { + const rel = path.relative(paths.content.getStarted, filePath); + const slug = rel.replace(/\.mdx$/, ""); + const raw = fs.readFileSync(filePath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + records.push({ + id: asString(data.id) ?? slug, + collection: "get-started", + title: asString(data.title) ?? slug, + navLabel: asString(data.navLabel), + description: asString(data.description), + section: asString(data.section) ?? "intro", + order: asNumber(data.order, 0), + status: asString(data.status) ?? "published", + body: body.trim(), + }); + } + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records; +} diff --git a/docs/src/scripts/content-generators/loaders/guidance.ts b/docs/src/scripts/content-generators/loaders/guidance.ts new file mode 100644 index 0000000000..dd8702ae4a --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/guidance.ts @@ -0,0 +1,42 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asString, asStringArray, listMdxFiles } from "./lib"; +import type { GuidanceRecord } from "../types"; + +export function loadGuidance(): GuidanceRecord[] { + const records: GuidanceRecord[] = []; + + for (const filePath of listMdxFiles(paths.content.guidance)) { + const slug = path.basename(filePath, ".mdx"); + const raw = fs.readFileSync(filePath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + const appliesToRaw = data.appliesTo as + | { components?: unknown; contexts?: unknown } + | undefined; + const appliesTo = appliesToRaw + ? { + components: asStringArray(appliesToRaw.components), + contexts: asStringArray(appliesToRaw.contexts), + } + : undefined; + + records.push({ + id: asString(data.id) ?? slug, + collection: "guidance", + type: asString(data.type) ?? "info", + description: asString(data.description) ?? "", + topic: asString(data.topic) ?? "other", + tags: asStringArray(data.tags), + appliesTo, + relatedProps: asStringArray(data.relatedProps), + status: asString(data.status) ?? "published", + body: body.trim(), + }); + } + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records; +} diff --git a/docs/src/scripts/content-generators/loaders/lib.ts b/docs/src/scripts/content-generators/loaders/lib.ts new file mode 100644 index 0000000000..bd772bdc30 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/lib.ts @@ -0,0 +1,45 @@ +import * as fs from "fs"; +import * as path from "path"; + +export function asString(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +export function asStringArray(v: unknown): string[] { + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === "string"); +} + +export function asNumber(v: unknown, fallback = 0): number { + return typeof v === "number" && Number.isFinite(v) ? v : fallback; +} + +export function listMdxFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith(".mdx")) + .map((e) => path.join(dir, e.name)); +} + +export function walkMdxFiles(dir: string, baseDir = dir): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walkMdxFiles(full, baseDir)); + } else if (entry.isFile() && entry.name.endsWith(".mdx")) { + out.push(full); + } + } + return out; +} + +export function listDirectories(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => path.join(dir, e.name)); +} diff --git a/docs/src/scripts/content-generators/loaders/productTypes.ts b/docs/src/scripts/content-generators/loaders/productTypes.ts new file mode 100644 index 0000000000..0182a77a73 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/productTypes.ts @@ -0,0 +1,36 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asString, asStringArray, listDirectories } from "./lib"; +import type { ProductTypeRecord } from "../types"; + +export function loadProductTypes(): ProductTypeRecord[] { + const records: ProductTypeRecord[] = []; + + for (const folder of listDirectories(paths.content.productTypes)) { + const slug = path.basename(folder); + const indexPath = path.join(folder, "index.mdx"); + if (!fs.existsSync(indexPath)) continue; + + const raw = fs.readFileSync(indexPath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + records.push({ + id: asString(data.id) ?? slug, + collection: "productTypes", + title: asString(data.title) ?? slug, + summary: asString(data.summary) ?? "", + heroImage: asString(data.heroImage), + demoUrl: asString(data.demoUrl), + sourceUrl: asString(data.sourceUrl), + tags: asStringArray(data.tags), + components: asStringArray(data.components), + status: asString(data.status) ?? "published", + body: body.trim(), + }); + } + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records; +} diff --git a/docs/src/scripts/content-generators/outputs/mcp-json.ts b/docs/src/scripts/content-generators/outputs/mcp-json.ts new file mode 100644 index 0000000000..718c3f4a87 --- /dev/null +++ b/docs/src/scripts/content-generators/outputs/mcp-json.ts @@ -0,0 +1,47 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import type { AnyRecord } from "../types"; + +export function writeMcpJson(records: AnyRecord[]): { written: number } { + const byCollection = new Map(); + for (const rec of records) { + const list = byCollection.get(rec.collection) ?? []; + list.push(rec); + byCollection.set(rec.collection, list); + } + + fs.mkdirSync(paths.output.mcp, { recursive: true }); + + let written = 0; + for (const [collection, list] of byCollection) { + const dir = path.join(paths.output.mcp, collection); + fs.mkdirSync(dir, { recursive: true }); + for (const rec of list) { + // Flatten nested ids (e.g. "designers/designing-with-ds") into a + // filesystem-safe filename. The canonical id lives in the JSON body. + const filename = rec.id.replace(/\//g, "__") + ".json"; + const file = path.join(dir, filename); + const payload = sortKeys(rec); + fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", "utf8"); + written++; + } + } + return { written }; +} + +// Deterministic key order so byte-identical reruns are possible. Lets a +// downstream freshness check detect content changes via file hash. +function sortKeys(value: T): T { + if (Array.isArray(value)) { + return value.map(sortKeys) as unknown as T; + } + if (value && typeof value === "object") { + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortKeys((value as Record)[key]); + } + return sorted as unknown as T; + } + return value; +} diff --git a/docs/src/scripts/content-generators/sync-to-dcp.sh b/docs/src/scripts/content-generators/sync-to-dcp.sh new file mode 100755 index 0000000000..6262f3c085 --- /dev/null +++ b/docs/src/scripts/content-generators/sync-to-dcp.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Sync the content-generators output into the dcp-monorepo MCP's data/. +# Manual invocation, no idempotency checks, no PR generation. +# Phase 4 replaces this with an automated CI step. +# +# Set DCP_MONOREPO_DATA to the local path of dcp-monorepo's design-system-mcp +# data folder. Example: +# export DCP_MONOREPO_DATA=/path/to/dcp-monorepo/apps/design-system-mcp/data +# bash docs/src/scripts/content-generators/sync-to-dcp.sh + +set -euo pipefail + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../generated/mcp" && pwd)" +DEST="${DCP_MONOREPO_DATA:-}" + +if [[ -z "$DEST" ]]; then + echo "[sync] DCP_MONOREPO_DATA env var not set." >&2 + echo "[sync] Set it to the path of dcp-monorepo's design-system-mcp data folder." >&2 + echo "[sync] Example: export DCP_MONOREPO_DATA=/path/to/dcp-monorepo/apps/design-system-mcp/data" >&2 + exit 1 +fi + +if [[ ! -d "$SRC" ]]; then + echo "[sync] Source not found: $SRC" >&2 + echo "[sync] Run: npx tsx docs/src/scripts/content-generators/index.ts" >&2 + exit 1 +fi + +if [[ ! -d "$DEST" ]]; then + echo "[sync] Destination not found: $DEST" >&2 + exit 1 +fi + +echo "[sync] Clearing old subfolders in $DEST..." +rm -rf \ + "$DEST/components" \ + "$DEST/examples" \ + "$DEST/guidance" \ + "$DEST/foundations" \ + "$DEST/get-started" \ + "$DEST/productTypes" \ + "$DEST/design" \ + "$DEST/development" + +echo "[sync] Copying $SRC -> $DEST" +cp -R "$SRC/." "$DEST/" + +for c in components examples guidance foundations get-started productTypes; do + if [[ -d "$DEST/$c" ]]; then + count=$(find "$DEST/$c" -type f -name '*.json' | wc -l | tr -d ' ') + echo " $c: $count files" + fi +done + +echo "[sync] Done." diff --git a/docs/src/scripts/content-generators/transforms/aliases.ts b/docs/src/scripts/content-generators/transforms/aliases.ts new file mode 100644 index 0000000000..9ffafaec58 --- /dev/null +++ b/docs/src/scripts/content-generators/transforms/aliases.ts @@ -0,0 +1,71 @@ +import type { ComponentRecord, ExampleRecord } from "../types"; + +// Manually curated old → new slug renames. Until the docs site catches up, +// the OLD slug is the current id and there's nothing to alias. Once the docs +// rename a record (e.g. PR #3888 batches), the NEW slug becomes the id and +// the OLD slug becomes the alias here. This list is the bridge during the +// transition. +const EXAMPLE_SLUG_ALIASES: Record = { + // newSlug: [oldSlug1, oldSlug2, ...] + "result-page": ["confirm-that-an-application-was-submitted"], + "question-page": [ + "ask-a-user-one-question-at-a-time", + "give-more-information-before-asking-a-question-a", + ], +}; + +// Legacy or sub-component names that don't show up from code derivation +// (no matching React class, web component tag, or Angular selector). Used +// when a renamed component still appears in older content references, or +// when a sub-component reasonably routes to its parent for navigation. +const COMPONENT_SLUG_ALIASES: Record = { + // newSlug: [oldSlug1, oldSlug2, ...] + "text-area": ["textarea"], + notification: ["notification-banner"], + "menu-button": ["multi-action-button"], + table: ["table-sort-header"], +}; + +export function addComponentAliases(components: ComponentRecord[]): ComponentRecord[] { + return components.map((c) => { + const aliases = new Set(c.aliases); + + if (c.reactClassName && c.reactClassName !== expectedReactName(c.id)) { + // Stale wrapper name that doesn't match the doc slug. + // e.g. GoabAppFooter on footer's aliases. + aliases.add(c.reactClassName); + } + if (c.webComponentTag) { + const tagSlug = c.webComponentTag.replace(/^goa-/, ""); + if (tagSlug !== c.id) aliases.add(tagSlug); + } + if (c.angularSelector) { + const selSlug = c.angularSelector.replace(/^goab-/, ""); + if (selSlug !== c.id) aliases.add(selSlug); + } + const manual = COMPONENT_SLUG_ALIASES[c.id]; + if (manual) for (const s of manual) aliases.add(s); + + return { ...c, aliases: [...aliases].sort() }; + }); +} + +export function addExampleAliases(examples: ExampleRecord[]): ExampleRecord[] { + return examples.map((ex) => { + const aliases = new Set(ex.aliases); + const oldSlugs = EXAMPLE_SLUG_ALIASES[ex.id]; + if (oldSlugs) for (const s of oldSlugs) aliases.add(s); + return { ...ex, aliases: [...aliases].sort() }; + }); +} + +function expectedReactName(slug: string): string { + // Convention: GoabPascalCase(slug). e.g. "form-item" → "GoabFormItem". + return ( + "Goab" + + slug + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join("") + ); +} diff --git a/docs/src/scripts/content-generators/transforms/link-guidance.ts b/docs/src/scripts/content-generators/transforms/link-guidance.ts new file mode 100644 index 0000000000..a4a8226a32 --- /dev/null +++ b/docs/src/scripts/content-generators/transforms/link-guidance.ts @@ -0,0 +1,26 @@ +import type { ComponentRecord, GuidanceRecord } from "../types"; + +// For each component, attach the list of guidance entry ids that target it +// via guidance.appliesTo.components. The MCP `get` on a component can then +// surface guidance inline, so consumers retrieve a component and its related +// guidance in a single call instead of separate searches. +export function linkGuidanceToComponents( + components: ComponentRecord[], + guidance: GuidanceRecord[], +): ComponentRecord[] { + const byComponent = new Map(); + for (const g of guidance) { + const targets = g.appliesTo?.components ?? []; + for (const slug of targets) { + const list = byComponent.get(slug) ?? []; + list.push(g.id); + byComponent.set(slug, list); + } + } + + return components.map((c) => { + const linked = byComponent.get(c.id); + if (!linked || linked.length === 0) return c; + return { ...c, relatedGuidance: [...linked].sort() }; + }); +} diff --git a/docs/src/scripts/content-generators/types.ts b/docs/src/scripts/content-generators/types.ts new file mode 100644 index 0000000000..4072e162c4 --- /dev/null +++ b/docs/src/scripts/content-generators/types.ts @@ -0,0 +1,119 @@ +import type { CollectionName } from "./config"; + +export interface BaseRecord { + id: string; + collection: CollectionName; + body: string; +} + +export interface ComponentRecord extends BaseRecord { + collection: "components"; + name: string; + description?: string; + status: string; + category: string; + tags: string[]; + aliases: string[]; + relatedComponents: string[]; + figmaUrl?: string; + hidden?: boolean; + subcomponent?: boolean; + webComponentTag?: string; + reactClassName?: string; + angularSelector?: string; + // Full API blob from generated/component-apis/*.json (minus componentSlug). + // Contains per-framework props/events/slots. Consumers navigate it. + api?: Record; + // Guidance ids that target this component via guidance.appliesTo.components. + relatedGuidance?: string[]; +} + +export interface ExampleRecord extends BaseRecord { + collection: "examples"; + title: string; + description?: string; + size: "interaction" | "section" | "page" | "task" | "product"; + tags: string[]; + components: string[]; + relatedExamples: string[]; + aliases: string[]; + status: string; + productType?: "workspace" | "public-form"; + frameworks?: string[]; + previewImage?: string; + figmaUrl?: string; + accessibilityNotes?: string; + hidden?: boolean; + // Page-like fields (page, task, product sizes only) + previewUrl?: string; + reactSourceUrl?: string; + angularSourceUrl?: string; + sourceUrl?: string; + stackblitzUrl?: string; +} + +export interface GuidanceRecord extends BaseRecord { + collection: "guidance"; + type: string; + description: string; + topic: string; + tags: string[]; + appliesTo?: { + components?: string[]; + contexts?: string[]; + }; + relatedProps: string[]; + status: string; +} + +export interface FoundationRecord extends BaseRecord { + collection: "foundations"; + title: string; + description: string; + category: string; + tags: string[]; + status: string; +} + +export interface GetStartedRecord extends BaseRecord { + collection: "get-started"; + title: string; + navLabel?: string; + description?: string; + section: string; + order: number; + status: string; +} + +export interface ProductTypeRecord extends BaseRecord { + collection: "productTypes"; + title: string; + summary: string; + heroImage?: string; + demoUrl?: string; + sourceUrl?: string; + tags: string[]; + components: string[]; + status: string; +} + +export type AnyRecord = + | ComponentRecord + | ExampleRecord + | GuidanceRecord + | FoundationRecord + | GetStartedRecord + | ProductTypeRecord; + +export interface Finding { + severity: "error" | "warning"; + source: { collection: CollectionName; id: string }; + field: string; + brokenRef: string; + /** Extra context appended after the broken ref, e.g. `on component "checkbox"`. */ + context?: string; + /** Override the default "not found" suffix, e.g. `not in extracted props`. */ + notFoundMessage?: string; + /** Optional "did you mean..." string populated by the check. */ + hint?: string; +} diff --git a/package-lock.json b/package-lock.json index e163a034c6..d6a4521d52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "astro": "5.18.1", "date-fns": "3.6.0", "dompurify": "3.4.0", + "fastest-levenshtein": "^1.0.16", "flexsearch": "0.8.212", "highlight.js": "11.11.1", "react": "19.2.4", @@ -25802,6 +25803,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", diff --git a/package.json b/package.json index d635c23070..45a1bc635b 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "astro": "5.18.1", "date-fns": "3.6.0", "dompurify": "3.4.0", + "fastest-levenshtein": "^1.0.16", "flexsearch": "0.8.212", "highlight.js": "11.11.1", "react": "19.2.4", From f51b4771c0e3ad2995e7d7292f2570e2c44f0f8f Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 20 May 2026 13:04:59 -0600 Subject: [PATCH 06/24] chore(#3770): recurse into nested example folders --- .../content-generators/loaders/examples.ts | 5 ++--- .../scripts/content-generators/loaders/lib.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/src/scripts/content-generators/loaders/examples.ts b/docs/src/scripts/content-generators/loaders/examples.ts index 8bd67e2ffc..1c60b26cfc 100644 --- a/docs/src/scripts/content-generators/loaders/examples.ts +++ b/docs/src/scripts/content-generators/loaders/examples.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { paths } from "../config"; import { parseFrontmatter } from "./frontmatter"; -import { asString, asStringArray, listDirectories } from "./lib"; +import { asString, asStringArray, findIndexMdxFolders } from "./lib"; import type { ExampleRecord } from "../types"; const PAGE_LIKE_SIZES = new Set(["page", "task", "product"]); @@ -18,10 +18,9 @@ const VALID_SIZES = new Set([ export function loadExamples(): ExampleRecord[] { const records: ExampleRecord[] = []; - for (const folder of listDirectories(paths.content.examples)) { + for (const folder of findIndexMdxFolders(paths.content.examples)) { const slug = path.basename(folder); const indexPath = path.join(folder, "index.mdx"); - if (!fs.existsSync(indexPath)) continue; const raw = fs.readFileSync(indexPath, "utf8"); const { data, body } = parseFrontmatter(raw); diff --git a/docs/src/scripts/content-generators/loaders/lib.ts b/docs/src/scripts/content-generators/loaders/lib.ts index bd772bdc30..46da97b432 100644 --- a/docs/src/scripts/content-generators/loaders/lib.ts +++ b/docs/src/scripts/content-generators/loaders/lib.ts @@ -43,3 +43,21 @@ export function listDirectories(dir: string): string[] { .filter((e) => e.isDirectory()) .map((e) => path.join(dir, e.name)); } + +// Walks the tree for index.mdx files and returns the folder containing each. +// Used by example collections that nest by productType (workspace/case-detail). +export function findIndexMdxFolders(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const folders: string[] = []; + function walk(current: string): void { + if (fs.existsSync(path.join(current, "index.mdx"))) { + folders.push(current); + return; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (entry.isDirectory()) walk(path.join(current, entry.name)); + } + } + walk(dir); + return folders; +} From 227bb978cc7161f0f24f65dc857a296c99f1e782 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 20 May 2026 13:05:03 -0600 Subject: [PATCH 07/24] chore(#3770): resolve guidance references through component aliases --- .../content-generators/checks/prop-refs.ts | 15 ++++++++----- .../transforms/component-lookup.ts | 21 +++++++++++++++++++ .../transforms/link-guidance.ts | 19 +++++++++++------ 3 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 docs/src/scripts/content-generators/transforms/component-lookup.ts diff --git a/docs/src/scripts/content-generators/checks/prop-refs.ts b/docs/src/scripts/content-generators/checks/prop-refs.ts index 9168b65509..fe09aa550e 100644 --- a/docs/src/scripts/content-generators/checks/prop-refs.ts +++ b/docs/src/scripts/content-generators/checks/prop-refs.ts @@ -1,5 +1,6 @@ import type { AnyRecord, ComponentRecord, Finding } from "../types"; import { computeHint } from "./lib"; +import { buildComponentLookup } from "../transforms/component-lookup"; /** * Validates guidance.relatedProps against the extracted prop names on the @@ -9,15 +10,19 @@ import { computeHint } from "./lib"; * of the linked components, matching the semantic "this guidance is about * this prop, which lives on one of these components." * + * Component slugs resolve through aliases, so a guidance atom that points + * at `text-area` validates against the props of the canonical `textarea` + * record. + * * Skips guidance atoms whose appliesTo.components references components * that do not exist; component-refs.ts reports those separately. */ export function checkPropRefs(records: AnyRecord[]): Finding[] { const findings: Finding[] = []; - const componentMap = new Map(); - for (const r of records) { - if (r.collection === "components") componentMap.set(r.id, r); - } + const components = records.filter( + (r): r is ComponentRecord => r.collection === "components", + ); + const lookup = buildComponentLookup(components); for (const r of records) { if (r.collection !== "guidance") continue; @@ -33,7 +38,7 @@ export function checkPropRefs(records: AnyRecord[]): Finding[] { const allValidProps = new Set(); const knownComponents: string[] = []; for (const componentId of linkedComponents) { - const component = componentMap.get(componentId); + const component = lookup.get(componentId); if (!component) continue; knownComponents.push(componentId); for (const propName of extractPropNames(component)) { diff --git a/docs/src/scripts/content-generators/transforms/component-lookup.ts b/docs/src/scripts/content-generators/transforms/component-lookup.ts new file mode 100644 index 0000000000..b6f5b66c40 --- /dev/null +++ b/docs/src/scripts/content-generators/transforms/component-lookup.ts @@ -0,0 +1,21 @@ +import type { ComponentRecord } from "../types"; + +// Builds a slug → component lookup keyed by both canonical id and every alias. +// Lets cross-references that point at legacy slugs (app-footer, text-area, +// notification-banner, multi-action-button) resolve to the current canonical +// record without forcing source MDX edits. +// +// Run after addComponentAliases so aliases are populated. +export function buildComponentLookup( + components: ComponentRecord[], +): Map { + const lookup = new Map(); + for (const c of components) { + lookup.set(c.id, c); + for (const alias of c.aliases ?? []) { + // Don't overwrite a canonical id with an alias collision. + if (!lookup.has(alias)) lookup.set(alias, c); + } + } + return lookup; +} diff --git a/docs/src/scripts/content-generators/transforms/link-guidance.ts b/docs/src/scripts/content-generators/transforms/link-guidance.ts index a4a8226a32..0520aeffee 100644 --- a/docs/src/scripts/content-generators/transforms/link-guidance.ts +++ b/docs/src/scripts/content-generators/transforms/link-guidance.ts @@ -1,26 +1,33 @@ import type { ComponentRecord, GuidanceRecord } from "../types"; +import { buildComponentLookup } from "./component-lookup"; // For each component, attach the list of guidance entry ids that target it // via guidance.appliesTo.components. The MCP `get` on a component can then // surface guidance inline, so consumers retrieve a component and its related // guidance in a single call instead of separate searches. +// +// guidance.appliesTo.components is alias-aware: legacy slugs like "app-footer" +// resolve through the alias map to the canonical "footer" record. export function linkGuidanceToComponents( components: ComponentRecord[], guidance: GuidanceRecord[], ): ComponentRecord[] { - const byComponent = new Map(); + const lookup = buildComponentLookup(components); + const byCanonicalId = new Map>(); for (const g of guidance) { const targets = g.appliesTo?.components ?? []; for (const slug of targets) { - const list = byComponent.get(slug) ?? []; - list.push(g.id); - byComponent.set(slug, list); + const canonical = lookup.get(slug); + if (!canonical) continue; + const set = byCanonicalId.get(canonical.id) ?? new Set(); + set.add(g.id); + byCanonicalId.set(canonical.id, set); } } return components.map((c) => { - const linked = byComponent.get(c.id); - if (!linked || linked.length === 0) return c; + const linked = byCanonicalId.get(c.id); + if (!linked || linked.size === 0) return c; return { ...c, relatedGuidance: [...linked].sort() }; }); } From 519121a291e48e32e2cd681767ff91375dc643b4 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 20 May 2026 13:05:07 -0600 Subject: [PATCH 08/24] chore(#3770): derive canonical svelte from react wrapper --- .../temporary-notification.json | 29 +- .../loaders/framework-identifiers.ts | 251 ++++++++---------- docs/src/scripts/extract-api.ts | 128 ++++++++- 3 files changed, 268 insertions(+), 140 deletions(-) diff --git a/docs/generated/component-apis/temporary-notification.json b/docs/generated/component-apis/temporary-notification.json index c186f7fa79..27e95ff36e 100644 --- a/docs/generated/component-apis/temporary-notification.json +++ b/docs/generated/component-apis/temporary-notification.json @@ -1,6 +1,6 @@ { "componentSlug": "temporary-notification", - "extractedFrom": "libs/web-components/src/components/temporary-notification/TemporaryNotification.svelte", + "extractedFrom": "libs/web-components/src/components/temporary-notification/TemporaryNotificationCtrl.svelte", "frameworks": { "react": { "props": [ @@ -76,6 +76,18 @@ "default": "down", "description": "Direction the notification animates from when appearing or disappearing." }, + { + "name": "horizontal-position", + "type": "\"left\" | \"center\" | \"right\"", + "values": [ + "left", + "center", + "right" + ], + "required": false, + "default": "center", + "description": "Horizontal position of the notification container." + }, { "name": "message", "type": "string", @@ -93,8 +105,8 @@ { "name": "testid", "type": "string", - "required": false, - "default": "", + "required": true, + "default": null, "description": "Sets a data-testid attribute for automated testing." }, { @@ -111,6 +123,17 @@ "default": "basic", "description": "The notification type which determines the visual style and icon." }, + { + "name": "vertical-position", + "type": "\"top\" | \"bottom\"", + "values": [ + "top", + "bottom" + ], + "required": false, + "default": "bottom", + "description": "Vertical position of the notification container." + }, { "name": "visible", "type": "boolean", diff --git a/docs/src/scripts/content-generators/loaders/framework-identifiers.ts b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts index 859cbbd799..cfcf1e5735 100644 --- a/docs/src/scripts/content-generators/loaders/framework-identifiers.ts +++ b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts @@ -1,169 +1,131 @@ import * as fs from "fs"; import * as path from "path"; import { paths } from "../config"; +import { loadComponentApis } from "./component-apis"; export interface FrameworkIdentifiers { webComponentTag?: string; reactClassName?: string; angularSelector?: string; - // All React export names found in the wrapper for this slug. - // First entry is the canonical/primary name; the rest are aliases. + // All Goab class exports from the React wrapper file. First entry is the + // canonical/primary name; the rest (rare in practice) ride along. reactClassNames: string[]; } -const WC_COMPONENTS_DIR = "web-components/src/components"; const REACT_LIB_DIR = "react-components/src/lib"; const ANGULAR_LIB_DIR = "angular-components/src/lib/components"; -// Map is keyed by FOLDER NAME, which matches the doc slug for the -// vast majority of components. Code-identifiers (tag, React class, selector) -// found inside go into the record's fields. Divergence between folder name -// and the embedded tag (e.g. footer/ folder with goa-app-footer tag) is -// expected. The aliases transform turns those divergences into alias entries. +interface ReactWrapperInfo { + file: string; + classNames: string[]; +} +/** + * Derives framework identifiers per docs slug: + * - canonical Svelte file comes from extract-api's `extractedFrom` + * (already resolved via React-wrapper auto-derivation; see extract-api.ts) + * - that Svelte file's `` tag is the truth + * for `webComponentTag` + * - the React wrapper that declares that tag in its IntrinsicElements + * supplies the React class names + * - the Angular wrapper with the matching selector (`goab-`) + * supplies the Angular selector + * + * Replaces the previous folder-conflation approach where every React/Angular + * file inside `/` landed on a single slug derived from the folder + * name. That caused sibling sub-components (e.g. `GoabMenuAction` next to + * `GoabMenuButton`, or `GoabTableSortHeader` next to `GoabTable`) to leak + * their tags/selectors/classes onto the parent slug. + */ export function loadFrameworkIdentifiers(): Map { const bySlug = new Map(); - const getOrInit = (slug: string): FrameworkIdentifiers => { - let r = bySlug.get(slug); - if (!r) { - r = { reactClassNames: [] }; - bySlug.set(slug, r); - } - return r; - }; - - // 1) Web components: walk libs/web-components/src/components//.svelte. - // A folder can host more than one custom element (e.g. temporary-notification/ - // contains both goa-temp-notification and goa-temp-notification-ctrl). Gather - // every tag so the React-side bridge can resolve aux tags to the parent slug; - // the canonical webComponentTag stays the first one found. - const wcRoot = path.join(paths.code.libs, WC_COMPONENTS_DIR); - const tagToWcFolder = new Map(); - if (fs.existsSync(wcRoot)) { - for (const folder of fs.readdirSync(wcRoot, { withFileTypes: true })) { - if (!folder.isDirectory()) continue; - const tags = readWebComponentTags(path.join(wcRoot, folder.name)); - if (tags.length === 0) continue; - const rec = getOrInit(folder.name); - rec.webComponentTag = tags[0]; - for (const tag of tags) { - if (!tagToWcFolder.has(tag)) tagToWcFolder.set(tag, folder.name); - } - } + const apis = loadComponentApis(); + const tagToReact = buildReactWrapperIndex(); + const tagToAngular = buildAngularWrapperIndex(); + + for (const [slug, api] of apis) { + if (!api.extractedFrom) continue; + const svelteFile = path.join(paths.workspaceRoot, api.extractedFrom); + const tag = readSvelteCustomElementTag(svelteFile); + if (!tag) continue; + + const react = tagToReact.get(tag); + const angularSelector = tagToAngular.get(tag); + + bySlug.set(slug, { + webComponentTag: tag, + reactClassName: react?.classNames[0], + reactClassNames: react?.classNames ?? [], + angularSelector, + }); } - // 2) React wrappers. Bucket by the IntrinsicElement tag declaration when - // present (most reliable bridge to the web-component slug); fall back to - // folder name for files that don't declare one. - - const reactRoot = path.join(paths.code.libs, REACT_LIB_DIR); - if (fs.existsSync(reactRoot)) { - for (const folder of fs.readdirSync(reactRoot, { withFileTypes: true })) { - if (!folder.isDirectory()) continue; - const folderPath = path.join(reactRoot, folder.name); - - for (const tsxFile of walkFiles(folderPath, ".tsx")) { - if (tsxFile.endsWith(".spec.tsx") || tsxFile.endsWith(".test.tsx")) continue; - const raw = fs.readFileSync(tsxFile, "utf8"); - const exports = extractReactExports(raw); - if (exports.length === 0) continue; - - // Find any IntrinsicElement declarations in this file. - const intrinsicTags = [...raw.matchAll(/IntrinsicElements\s*\{[\s\S]*?\}/g)] - .flatMap((m) => [...m[0].matchAll(/"(goa-[a-z0-9-]+)"\s*:/g)]) - .map((m) => m[1]); - - // Determine the target slug: prefer the WC folder linked to the - // tag (handles textarea/text-area mismatch); otherwise use the - // React folder name as the slug. - const targetSlugs = new Set(); - for (const tag of intrinsicTags) { - const wcFolder = tagToWcFolder.get(tag); - if (wcFolder) targetSlugs.add(wcFolder); - } - if (targetSlugs.size === 0) targetSlugs.add(folder.name); - - for (const slug of targetSlugs) { - const rec = getOrInit(slug); - for (const name of exports) { - if (!rec.reactClassNames.includes(name)) { - rec.reactClassNames.push(name); - } - } - if (!rec.reactClassName) rec.reactClassName = rec.reactClassNames[0]; - } - } - } - } + return bySlug; +} - // 3) Angular selectors. - const angularRoot = path.join(paths.code.libs, ANGULAR_LIB_DIR); - if (fs.existsSync(angularRoot)) { - for (const folder of fs.readdirSync(angularRoot, { withFileTypes: true })) { - if (!folder.isDirectory()) continue; - const selector = readAngularSelector(path.join(angularRoot, folder.name)); - if (selector) { - const rec = getOrInit(folder.name); - rec.angularSelector = selector; +function buildReactWrapperIndex(): Map { + const byTag = new Map(); + const root = path.join(paths.code.libs, REACT_LIB_DIR); + if (!fs.existsSync(root)) return byTag; + + for (const tsxFile of walkSourceFiles(root, ".tsx")) { + const content = fs.readFileSync(tsxFile, "utf8"); + const tags = extractIntrinsicTags(content); + if (tags.length === 0) continue; + const classNames = extractGoabComponentExports(content); + if (classNames.length === 0) continue; + for (const tag of tags) { + // First wrapper found wins; siblings on the same tag would be a + // structural anomaly to flag separately. + if (!byTag.has(tag)) { + byTag.set(tag, { file: tsxFile, classNames }); } } } - return bySlug; + return byTag; } -function readWebComponentTags(folderPath: string): string[] { - const tags: string[] = []; - for (const file of fs.readdirSync(folderPath)) { - if (!file.endsWith(".svelte")) continue; - if (file.includes(".test.")) continue; - const raw = fs.readFileSync(path.join(folderPath, file), "utf8"); - // Two customElement forms in this codebase: - // - // - const block = raw.match(//); - if (!block) continue; - const tagMatch = block[0].match(/["'](goa-[a-z0-9-]+)["']/); - if (tagMatch && !tags.includes(tagMatch[1])) tags.push(tagMatch[1]); +function buildAngularWrapperIndex(): Map { + const byTag = new Map(); + const root = path.join(paths.code.libs, ANGULAR_LIB_DIR); + if (!fs.existsSync(root)) return byTag; + + for (const tsFile of walkSourceFiles(root, ".ts")) { + const content = fs.readFileSync(tsFile, "utf8"); + const selectorMatch = content.match(/selector\s*:\s*["']goab-([a-z0-9-]+)["']/); + if (!selectorMatch) continue; + const stem = selectorMatch[1]; + const tag = `goa-${stem}`; + const selector = `goab-${stem}`; + if (!byTag.has(tag)) byTag.set(tag, selector); } - return tags; -} -function readReactExports(folderPath: string): string[] { - const names: string[] = []; - for (const file of walkFiles(folderPath, ".tsx")) { - if (file.endsWith(".spec.tsx") || file.endsWith(".test.tsx")) continue; - const raw = fs.readFileSync(file, "utf8"); - for (const n of extractReactExports(raw)) { - if (!names.includes(n)) names.push(n); - } - } - return names; + return byTag; } -function readAngularSelector(folderPath: string): string | undefined { - for (const file of fs.readdirSync(folderPath)) { - if (!file.endsWith(".ts")) continue; - if (file.endsWith(".spec.ts") || file.endsWith(".test.ts")) continue; - const raw = fs.readFileSync(path.join(folderPath, file), "utf8"); - const m = raw.match(/selector\s*:\s*["']goab-([a-z0-9-]+)["']/); - if (m) return `goab-${m[1]}`; - } +function readSvelteCustomElementTag(svelteFile: string): string | undefined { + if (!fs.existsSync(svelteFile)) return undefined; + const content = fs.readFileSync(svelteFile, "utf8"); + // Two `customElement` forms in this codebase: + // + // + const simple = content.match(/customElement\s*=\s*["'](goa-[a-z0-9-]+)["']/); + if (simple) return simple[1]; + const object = content.match(/customElement\s*=\s*\{\{\s*tag\s*:\s*["'](goa-[a-z0-9-]+)["']/); + if (object) return object[1]; return undefined; } -function walkFiles(dir: string, ext: string): string[] { - const out: string[] = []; - if (!fs.existsSync(dir)) return out; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out.push(...walkFiles(full, ext)); - else if (entry.isFile() && entry.name.endsWith(ext)) out.push(full); - } - return out; +function extractIntrinsicTags(src: string): string[] { + return [...src.matchAll(/IntrinsicElements\s*\{[\s\S]*?\}/g)] + .flatMap((m) => [...m[0].matchAll(/["'](goa-[a-z0-9-]+)["']\s*:/g)]) + .map((m) => m[1]); } -function extractReactExports(src: string): string[] { +function extractGoabComponentExports(src: string): string[] { + // Goab classes that are components (functions or constants), not types. + // Filters out *Props/*Detail/*Event type exports. const names = new Set(); for (const m of src.matchAll(/export\s+function\s+(Goab[A-Z][A-Za-z0-9]*)/g)) { names.add(m[1]); @@ -175,7 +137,7 @@ function extractReactExports(src: string): string[] { for (const part of block[1].split(",")) { const trimmed = part.trim(); if (!trimmed) continue; - const asMatch = trimmed.match(/(?:[A-Za-z0-9_]+)\s+as\s+(Goab[A-Z][A-Za-z0-9]*)/); + const asMatch = trimmed.match(/[A-Za-z0-9_]+\s+as\s+(Goab[A-Z][A-Za-z0-9]*)/); if (asMatch) { names.add(asMatch[1]); continue; @@ -184,5 +146,28 @@ function extractReactExports(src: string): string[] { if (direct) names.add(direct[1]); } } - return [...names]; + return [...names].filter( + (n) => !n.endsWith("Props") && !n.endsWith("Detail") && !n.endsWith("Event"), + ); +} + +function walkSourceFiles(root: string, ext: string): string[] { + const out: string[] = []; + if (!fs.existsSync(root)) return out; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile() && entry.name.endsWith(ext)) { + if (entry.name.endsWith(`.spec${ext}`)) continue; + if (entry.name.endsWith(`.test${ext}`)) continue; + out.push(full); + } + } + } + return out; } diff --git a/docs/src/scripts/extract-api.ts b/docs/src/scripts/extract-api.ts index 6eb01bdedc..99ac5ffdbd 100644 --- a/docs/src/scripts/extract-api.ts +++ b/docs/src/scripts/extract-api.ts @@ -1966,6 +1966,116 @@ function removeStaleApiFiles(validComponentNames: string[]): void { } } +// ============================================================================= +// Slug → Svelte resolution +// ============================================================================= + +/** + * Resolves a docs slug to its canonical Svelte file via the React wrapper's + * IntrinsicElements declaration. The wrapper is the source of truth for + * which custom element it presents to the consuming app, so its tag points + * to the matching Svelte file even when the docs slug, the wrapper filename, + * and the Svelte filename don't agree (e.g. docs slug `temporary-notification` + * but the public element lives in `TemporaryNotificationCtrl.svelte`). + * + * Returns undefined when the slug has no resolvable React wrapper, so the + * caller can fall back to the naming-convention heuristic. + */ +function resolveSvelteViaReactWrapper(slug: string): string | undefined { + const reactRoot = path.join(WORKSPACE_ROOT, "libs/react-components/src/lib"); + if (!fs.existsSync(reactRoot)) return undefined; + + const reactFile = findReactWrapperForSlug(slug, reactRoot); + if (!reactFile) return undefined; + + const tag = extractIntrinsicElementTag(fs.readFileSync(reactFile, "utf-8")); + if (!tag) return undefined; + + return findSvelteDeclaringTag(tag); +} + +/** + * Two attempts in order: + * 1. Direct: `.tsx` somewhere under reactRoot. + * 2. Discovery: a Svelte file inside `libs/...//` declares a tag, + * and exactly one React wrapper anywhere wraps that tag. This handles + * `temporary-notification` (no `temporary-notification.tsx` exists, but + * `temporary-notification-ctrl.tsx` wraps `goa-temp-notification-ctrl`, + * which is declared in the slug's Svelte folder). + */ +function findReactWrapperForSlug(slug: string, reactRoot: string): string | undefined { + const direct = findFirstFileByName(reactRoot, `${slug}.tsx`); + if (direct) return direct; + + const componentPath = path.join(UI_COMPONENTS_PATH, slug); + if (!fs.existsSync(componentPath) || !fs.statSync(componentPath).isDirectory()) { + return undefined; + } + + const wrappedFiles = new Set(); + for (const file of fs.readdirSync(componentPath)) { + if (!file.endsWith(".svelte") || file.includes(".test.")) continue; + const tag = extractTagName(fs.readFileSync(path.join(componentPath, file), "utf-8")); + if (!tag) continue; + const wrapper = findReactWrapperForTag(tag, reactRoot); + if (wrapper) wrappedFiles.add(wrapper); + } + + if (wrappedFiles.size === 1) return [...wrappedFiles][0]; + return undefined; +} + +function findReactWrapperForTag(tag: string, reactRoot: string): string | undefined { + const declRegex = new RegExp(`["']${escapeRegExp(tag)}["']\\s*:`); + const stack = [reactRoot]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if ( + entry.isFile() && + entry.name.endsWith(".tsx") && + !entry.name.endsWith(".spec.tsx") && + !entry.name.endsWith(".test.tsx") + ) { + if (declRegex.test(fs.readFileSync(full, "utf-8"))) return full; + } + } + } + return undefined; +} + +function extractIntrinsicElementTag(content: string): string | undefined { + // declare module "react" { ... interface IntrinsicElements { "goa-foo": ... } } + const match = content.match(/IntrinsicElements\s*\{[\s\S]*?["'](goa-[a-z0-9-]+)["']\s*:/); + return match ? match[1] : undefined; +} + +function findSvelteDeclaringTag(tag: string): string | undefined { + const stack = [UI_COMPONENTS_PATH]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if ( + entry.isFile() && + entry.name.endsWith(".svelte") && + !entry.name.includes(".test.") + ) { + const content = fs.readFileSync(full, "utf-8"); + if (extractTagName(content) === tag) return full; + } + } + } + return undefined; +} + // ============================================================================= // Main Extraction // ============================================================================= @@ -1973,11 +2083,21 @@ function removeStaleApiFiles(validComponentNames: string[]): void { function extractComponentAPI(componentName: string): ExtractedComponentAPI | null { const componentPath = path.join(UI_COMPONENTS_PATH, componentName); - // Find the Svelte file by the component's public name first, then fall back - // to any .svelte file inside a matching directory. - const svelteFileName = `${capitalize(toCamelCase(componentName))}.svelte`; - let svelteFilePath = findFirstFileByName(UI_COMPONENTS_PATH, svelteFileName); + // 1. Auto-derive via the React wrapper. The React wrapper's + // IntrinsicElements declaration is the source of truth for which + // custom element it wraps, so its tag points at the canonical + // svelte file. Handles the menu-button / temporary-notification + // case where the docs slug and the public element's filename + // diverge. + let svelteFilePath = resolveSvelteViaReactWrapper(componentName); + + // 2. Fall back to the naming convention (works for the majority). + if (!svelteFilePath) { + const svelteFileName = `${capitalize(toCamelCase(componentName))}.svelte`; + svelteFilePath = findFirstFileByName(UI_COMPONENTS_PATH, svelteFileName) ?? undefined; + } + // 3. Final fallback: any svelte inside a directory matching the slug. if (!svelteFilePath && fs.existsSync(componentPath) && fs.statSync(componentPath).isDirectory()) { const files = fs.readdirSync(componentPath); const svelteFile = files.find((f) => f.endsWith(".svelte")); From eb5ce64010ad652aa1c5ce2aea82e51fa7ac27bc Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 20 May 2026 13:05:11 -0600 Subject: [PATCH 09/24] chore(#3770): model table-sort-header as its own component --- docs/src/content/components/table-sort-header.mdx | 10 ++++++++++ docs/src/content/components/table.mdx | 1 + .../scripts/content-generators/transforms/aliases.ts | 4 +--- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 docs/src/content/components/table-sort-header.mdx diff --git a/docs/src/content/components/table-sort-header.mdx b/docs/src/content/components/table-sort-header.mdx new file mode 100644 index 0000000000..6f30276dfa --- /dev/null +++ b/docs/src/content/components/table-sort-header.mdx @@ -0,0 +1,10 @@ +--- +name: Table Sort Header +description: Sortable column header used inside a table to let users sort rows by that column. +status: stable +category: content-layout +hidden: true +subcomponent: true +relatedComponents: + - table +--- diff --git a/docs/src/content/components/table.mdx b/docs/src/content/components/table.mdx index aa4aeb23c0..8afb082611 100644 --- a/docs/src/content/components/table.mdx +++ b/docs/src/content/components/table.mdx @@ -11,5 +11,6 @@ tags: relatedComponents: - data-grid - pagination + - table-sort-header figmaUrl: https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/Component-library?node-id=27343-613386 --- diff --git a/docs/src/scripts/content-generators/transforms/aliases.ts b/docs/src/scripts/content-generators/transforms/aliases.ts index 9ffafaec58..8583ebd1d1 100644 --- a/docs/src/scripts/content-generators/transforms/aliases.ts +++ b/docs/src/scripts/content-generators/transforms/aliases.ts @@ -16,14 +16,12 @@ const EXAMPLE_SLUG_ALIASES: Record = { // Legacy or sub-component names that don't show up from code derivation // (no matching React class, web component tag, or Angular selector). Used -// when a renamed component still appears in older content references, or -// when a sub-component reasonably routes to its parent for navigation. +// when a renamed component still appears in older content references. const COMPONENT_SLUG_ALIASES: Record = { // newSlug: [oldSlug1, oldSlug2, ...] "text-area": ["textarea"], notification: ["notification-banner"], "menu-button": ["multi-action-button"], - table: ["table-sort-header"], }; export function addComponentAliases(components: ComponentRecord[]): ComponentRecord[] { From 0c5950a471c6f578a90d6e2846c8057e967df398 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 20 May 2026 13:05:15 -0600 Subject: [PATCH 10/24] chore(#3770): clear stale output files before writing --- .../scripts/content-generators/outputs/mcp-json.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/src/scripts/content-generators/outputs/mcp-json.ts b/docs/src/scripts/content-generators/outputs/mcp-json.ts index 718c3f4a87..6db96cbd53 100644 --- a/docs/src/scripts/content-generators/outputs/mcp-json.ts +++ b/docs/src/scripts/content-generators/outputs/mcp-json.ts @@ -13,10 +13,20 @@ export function writeMcpJson(records: AnyRecord[]): { written: number } { fs.mkdirSync(paths.output.mcp, { recursive: true }); + // Clear stale files in each collection folder before writing. Renamed or + // removed records would otherwise leave orphan JSON behind. Errors block + // the orchestrator before this point (see index.ts), so the previous + // output stays as last-known-good on a failed run. let written = 0; for (const [collection, list] of byCollection) { const dir = path.join(paths.output.mcp, collection); - fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(dir)) { + for (const entry of fs.readdirSync(dir)) { + if (entry.endsWith(".json")) fs.unlinkSync(path.join(dir, entry)); + } + } else { + fs.mkdirSync(dir, { recursive: true }); + } for (const rec of list) { // Flatten nested ids (e.g. "designers/designing-with-ds") into a // filesystem-safe filename. The canonical id lives in the JSON body. From b83f602a78681c64b6d679833923a39cb93d49c9 Mon Sep 17 00:00:00 2001 From: Mark E <51723535+Spark450@users.noreply.github.com> Date: Wed, 20 May 2026 16:18:58 -0700 Subject: [PATCH 11/24] docs(#3877): Update Designing with Design System page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(#3877): update designing-with-ds page — remove DS 2.0 refs, slim developer section Co-Authored-By: Claude Sonnet 4.6 * chore(#3877): regenerate search index with updated page title Co-Authored-By: Claude Sonnet 4.6 * chore(#3877): wrap external links with goa-link trailingicon="open" Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- docs/public/search-index.json | 4 +- .../designers/designing-with-ds.mdx | 61 ++++++++----------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/docs/public/search-index.json b/docs/public/search-index.json index be01a451f5..f5c71b4195 100644 --- a/docs/public/search-index.json +++ b/docs/public/search-index.json @@ -2372,8 +2372,8 @@ { "type": "page", "id": "designers/designing-with-ds", - "title": "Designing with Design System 2.0", - "description": "How to start new designs and update existing projects to Design System 2.0", + "title": "Designing with the updated design system", + "description": "How to start using the updated design system in your designs", "status": "published", "category": "get started", "tags": [ diff --git a/docs/src/content/get-started/designers/designing-with-ds.mdx b/docs/src/content/get-started/designers/designing-with-ds.mdx index 3be1641e5b..5a4cd0ccf2 100644 --- a/docs/src/content/get-started/designers/designing-with-ds.mdx +++ b/docs/src/content/get-started/designers/designing-with-ds.mdx @@ -1,63 +1,56 @@ --- id: designers/designing-with-ds -title: Designing with Design System 2.0 -description: How to start new designs and update existing projects to Design System 2.0 +title: Designing with the updated design system +description: How to start using the updated design system in your designs section: designers order: 2 status: published --- +import { withBase } from "@/lib/base-url"; import DropInCallout from "../../../components/DropInCallout.astro"; -Designing with Design System 2.0 -What's new in DS 2.0 and how to start using it in your designs. -

- The Digital Design and Delivery (DDD) Design System has evolved to better support how service teams - design and build digital government services. Design System 2.0 includes an updated design - language, new tokens, refreshed components, and ready-to-use templates. -

+Designing with the updated design system +How to start using the updated design system in your designs. Starting a new design

- Use DS 2.0 templates as your starting point. Templates package layout, navigation, spacing, and - example content that you can adapt to your service. Some templates are built for specific service types - (for example, public-facing forms and workspace applications), and others are more general-purpose + Use updated design system templates as your starting point. Templates package layout, navigation, spacing, and + example content that you can adapt to your service. Some templates are built for specific service types — + for example, public-facing forms and workspace applications. Others are general-purpose starting points you can use across many kinds of pages and flows.

-Starting from an existing design +Updating an existing design file +

Choose one of two approaches depending on how much control you need over the process.

Library swap

- Use a library swap if - your goal is to convert most of the file to DS 2.0 in one pass. + Use a library swap if + your goal is to convert most of the file to the updated design system in one pass.

  • Make a copy of your file before you start.
  • -
  • Expect mixed results. Some screens may need cleanup after the swap.
  • +
  • Expect mixed results — some screens may need cleanup after the swap.
Manual rebuild -

Use a manual rebuild if you need more control.

+

Use a manual rebuild if you need more control over the process.

  • Rebuild your highest value screens first.
  • -
  • Consider using DS 2.0 templates as a base to speed things up.
  • +
  • Use updated design system templates as a base to speed things up.
-Update an existing project to the new design library -Approach 1: Developer-first -

Use this approach in most cases.

-
    -
  1. A developer creates a new branch and completes the upgrade steps.
  2. -
  3. The team runs the upgraded application to see what changed.
  4. -
  5. Designers review the upgraded screens and identify what needs design updates.
  6. -
-

When reviewing the upgraded branch, watch for:

-
    -
  • Spacing and layout rhythm
  • -
  • Typography scale and weight usage
  • -
  • Dense or constrained screens (these often have the highest risk during an update)
  • -
+Updating an existing product +

Choose your approach based on whether design or development work is leading the update.

+ +Developer-first approach +

+ Use this approach in most cases. A developer creates a branch and runs the update so your team + can see what changed in the real product before committing to design work. Designers then review + the updated screens and identify what needs follow-up. +

+

See Updating your product for the full steps.

-Approach 2: Design-first +Design-first approach

Use this approach when you want design work to move ahead of development work.

  1. Identify custom elements that support your users' needs.
  2. @@ -66,6 +59,6 @@ import DropInCallout from "../../../components/DropInCallout.astro";
- In most cases, start with the developer-first approach. It helps you quickly see what changed in your real product, so you can focus design effort where it matters most. + Use this approach in most cases. It helps you focus design effort on what actually changed in your product rather than redesigning based on assumptions. From 01546623c5dd0f0b486e5d828d959bd0f688d2e2 Mon Sep 17 00:00:00 2001 From: Eric Hoff Date: Wed, 20 May 2026 18:07:47 -0600 Subject: [PATCH 12/24] fix(#3679): Add border-box css directives for Table * fix(#3679): Add border-box css directives for Table * test(#3679): add bounded-container case to repro the 1px overflow --------- Co-authored-by: Thomas Jeffery --- .../routes/bugs/3679/bug3679.component.html | 95 +++++++++++++++ .../src/routes/bugs/3679/bug3679.component.ts | 10 ++ .../src/routes/bugs/3679/bug3679.route.json | 6 + .../src/app/routes/bugs/bug3679.route.ts | 10 ++ apps/prs/react/src/routes/bugs/bug3679.tsx | 111 ++++++++++++++++++ .../src/components/table/Table.svelte | 21 ++-- 6 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 apps/prs/angular/src/routes/bugs/3679/bug3679.component.html create mode 100644 apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts create mode 100644 apps/prs/angular/src/routes/bugs/3679/bug3679.route.json create mode 100644 apps/prs/react/src/app/routes/bugs/bug3679.route.ts create mode 100644 apps/prs/react/src/routes/bugs/bug3679.tsx diff --git a/apps/prs/angular/src/routes/bugs/3679/bug3679.component.html b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.html new file mode 100644 index 0000000000..9aaa8d00ab --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.html @@ -0,0 +1,95 @@ +
+

Bug 3679: Table: 1px horizontal overflow from v2 border

+

+ The V2 table container has width: 100% (inline style) and + border: 1px solid but no box-sizing: border-box. The border adds 2px to the total rendered + width, causing 1px horizontal scroll on both sides. +

+ +

Test inside GoabContainer

+ + + + First Name + Last Name + Job + + + + + Joey + Tribbiani + Actor + + + Chandler + Bing + Statistical Analysis and Data Reconfiguration + + + Ross + Geller + Paleontologist + + + +
+
+

Test inside div with width 40%

+ + + + First Name + Last Name + Job + + + + + Rachel + Green + Barrista + + + Monica + Geller + Chef + + + Phoebe + Buffay + Musician + + + +
+
+

Test inside fixed 600px container (table should fill container exactly)

+ + + + First Name + Last Name + Job + + + + + Carol + Willick + Teacher + + + Gunther + Centralperk + Barista + + + Janice + Litman + Sales + + + +
+
\ No newline at end of file diff --git a/apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts new file mode 100644 index 0000000000..28926dfa70 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3679/bug3679.component.ts @@ -0,0 +1,10 @@ +import { Component } from "@angular/core"; +import { GoabContainer, GoabTable } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3679", + templateUrl: "./bug3679.component.html", + imports: [GoabContainer, GoabTable], +}) +export class Bug3679Component {} \ No newline at end of file diff --git a/apps/prs/angular/src/routes/bugs/3679/bug3679.route.json b/apps/prs/angular/src/routes/bugs/3679/bug3679.route.json new file mode 100644 index 0000000000..627a9f3ed3 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3679/bug3679.route.json @@ -0,0 +1,6 @@ +{ + "title": "Table: 1px horizontal overflow from v2 border", + "path": "bugs/3679", + "id": "3679", + "type": "bug" +} \ No newline at end of file diff --git a/apps/prs/react/src/app/routes/bugs/bug3679.route.ts b/apps/prs/react/src/app/routes/bugs/bug3679.route.ts new file mode 100644 index 0000000000..23e5fc678e --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3679.route.ts @@ -0,0 +1,10 @@ +import { Bug3679Route } from "../../../routes/bugs/bug3679"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3679", + path: "bugs/3679", + title: "Table: 1px horizontal overflow from v2 border", + component: Bug3679Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/routes/bugs/bug3679.tsx b/apps/prs/react/src/routes/bugs/bug3679.tsx new file mode 100644 index 0000000000..6414bd3777 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3679.tsx @@ -0,0 +1,111 @@ +import { GoabContainer, GoabTable } from "@abgov/react-components"; + +export function Bug3679Route() { + return ( +
+

Bug 3679: Table: 1px horizontal overflow from v2 border

+

+ The V2 table container has width: 100% (inline style) and + border: 1px solid but no + box-sizing: border-box. The border adds 2px to the total rendered + width, causing 1px horizontal scroll on both sides. +

+ +

Test inside GoabContainer

+ + + + First Name + Last Name + Job + + + + + Joey + Tribbiani + Actor + + + Chandler + Bing + Statistical Analysis and Data Reconfiguration + + + Ross + Geller + Paleontologist + + + +
+
+

Test inside div with width 40%

+ + + + First Name + Last Name + Job + + + + + Rachel + Green + Barrista + + + Monica + Geller + Chef + + + Phoebe + Buffay + Musician + + + +
+
+

+ Test inside fixed 600px container (table should fill container exactly) +

+ + + + First Name + Last Name + Job + + + + + Carol + Willick + Teacher + + + Gunther + Centralperk + Barista + + + Janice + Litman + Sales + + + +
+
+ ); +} diff --git a/libs/web-components/src/components/table/Table.svelte b/libs/web-components/src/components/table/Table.svelte index c185459427..a313d96eca 100644 --- a/libs/web-components/src/components/table/Table.svelte +++ b/libs/web-components/src/components/table/Table.svelte @@ -139,7 +139,10 @@ _headings?.forEach((heading) => { const name = heading.getAttribute("name"); - const direction = heading.getAttribute("direction") as SortDirection | "none" | null; + const direction = heading.getAttribute("direction") as + | SortDirection + | "none" + | null; const sortOrder = Number(heading.getAttribute("sort-order")) || 0; if (name && direction && direction !== "none") { entries.push({ column: name, direction, order: sortOrder }); @@ -154,8 +157,8 @@ if (withoutSortOrder.length > 0) { console.warn( `[goa-table] Multiple headers have initial sort direction but no sort-order set ` + - `[${withoutSortOrder.map((e) => e.column).join(", ")}]. ` + - `Falling back to DOM order. Add sort-order="1", sort-order="2" to set explicit priority.`, + `[${withoutSortOrder.map((e) => e.column).join(", ")}]. ` + + `Falling back to DOM order. Add sort-order="1", sort-order="2" to set explicit priority.`, ); } } @@ -241,12 +244,7 @@ function dispatchSortEvent() { if (sortMode === "multi") { - dispatch( - _rootEl, - "_multisort", - { sorts: _sorts }, - { bubbles: true }, - ); + dispatch(_rootEl, "_multisort", { sorts: _sorts }, { bubbles: true }); } else { const firstSort = _sorts[0]; dispatch( @@ -284,6 +282,10 @@ From 60f1acba831b72b49061450bbe8b3ef5561676d2 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Thu, 21 May 2026 11:45:02 -0600 Subject: [PATCH 13/24] chore(#3770): stop preserving absent entries on re-extraction --- .../temporary-notification.json | 53 ------------------- docs/src/scripts/extract-api.ts | 17 +----- 2 files changed, 2 insertions(+), 68 deletions(-) diff --git a/docs/generated/component-apis/temporary-notification.json b/docs/generated/component-apis/temporary-notification.json index 27e95ff36e..e90e9bb432 100644 --- a/docs/generated/component-apis/temporary-notification.json +++ b/docs/generated/component-apis/temporary-notification.json @@ -58,24 +58,6 @@ }, "webComponents": { "props": [ - { - "name": "action-text", - "type": "string", - "required": false, - "default": "", - "description": "Text for the optional action button. When provided, displays a clickable link button." - }, - { - "name": "animation-direction", - "type": "\"up\" | \"down\"", - "values": [ - "up", - "down" - ], - "required": false, - "default": "down", - "description": "Direction the notification animates from when appearing or disappearing." - }, { "name": "horizontal-position", "type": "\"left\" | \"center\" | \"right\"", @@ -88,20 +70,6 @@ "default": "center", "description": "Horizontal position of the notification container." }, - { - "name": "message", - "type": "string", - "required": false, - "default": "", - "description": "The notification message text to display." - }, - { - "name": "progress", - "type": "number", - "required": false, - "default": "-1", - "description": "Progress value from 0-100. Use -1 to hide the progress bar. Only applies when type is \"progress\"." - }, { "name": "testid", "type": "string", @@ -109,20 +77,6 @@ "default": null, "description": "Sets a data-testid attribute for automated testing." }, - { - "name": "type", - "type": "\"basic\" | \"success\" | \"failure\" | \"indeterminate\" | \"progress\"", - "values": [ - "basic", - "success", - "failure", - "indeterminate", - "progress" - ], - "required": false, - "default": "basic", - "description": "The notification type which determines the visual style and icon." - }, { "name": "vertical-position", "type": "\"top\" | \"bottom\"", @@ -133,13 +87,6 @@ "required": false, "default": "bottom", "description": "Vertical position of the notification container." - }, - { - "name": "visible", - "type": "boolean", - "required": false, - "default": "true", - "description": "Controls whether the notification is visible." } ], "events": [], diff --git a/docs/src/scripts/extract-api.ts b/docs/src/scripts/extract-api.ts index 99ac5ffdbd..f239f2bb06 100644 --- a/docs/src/scripts/extract-api.ts +++ b/docs/src/scripts/extract-api.ts @@ -2294,7 +2294,6 @@ function mergeItemArray( context: string, ): T[] { const existingByName = new Map(existing.map((item) => [item.name, item])); - const nextNames = new Set(next.map((item) => item.name)); // Backfill non-empty descriptions from existing into new items that have empty ones const merged: T[] = next.map((item) => { @@ -2305,18 +2304,6 @@ function mergeItemArray( return item; }); - // Preserve items from existing that are absent in the new extraction - // (but skip deprecated entries — they are intentionally excluded) - for (const existingItem of existing) { - if (!nextNames.has(existingItem.name)) { - if ((existingItem as Record).deprecated) continue; - console.warn( - ` Preserved manually-added entry "${existingItem.name}" in ${context} (not found in extraction — check source)`, - ); - merged.push(existingItem); - } - } - return merged.sort((a, b) => a.name.localeCompare(b.name)); } @@ -2356,8 +2343,8 @@ function saveComponentAPI(api: ExtractedComponentAPI): void { const filePath = path.join(OUTPUT_PATH, `${api.componentSlug}.json`); - // Merge with existing file to preserve manually-added entries that the extractor - // cannot see (e.g. props defined in function-level intersections, or wrapper gaps). + // Merge with existing file to backfill descriptions when the new extraction + // has none for an entry. let merged = api; if (fs.existsSync(filePath)) { try { From 261e93a072270ab2bac4b515ef42a46fc6e1d402 Mon Sep 17 00:00:00 2001 From: Benji Franck Date: Tue, 12 May 2026 11:43:01 -0600 Subject: [PATCH 14/24] fix(#3932): fix tooltip position on Work Side Menu --- .../routes/bugs/3932/bug3932.component.html | 50 ++++++ .../src/routes/bugs/3932/bug3932.component.ts | 22 +++ .../src/routes/bugs/3932/bug3932.route.json | 6 + .../src/app/routes/bugs/bug3932.route.ts | 10 ++ apps/prs/react/src/routes/bugs/bug3932.tsx | 62 +++++++ .../specs/work-side-menu.browser.spec.tsx | 167 ++++++++++++++++++ .../work-side-menu/WorkSideMenu.svelte | 5 +- 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 apps/prs/angular/src/routes/bugs/3932/bug3932.component.html create mode 100644 apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts create mode 100644 apps/prs/angular/src/routes/bugs/3932/bug3932.route.json create mode 100644 apps/prs/react/src/app/routes/bugs/bug3932.route.ts create mode 100644 apps/prs/react/src/routes/bugs/bug3932.tsx diff --git a/apps/prs/angular/src/routes/bugs/3932/bug3932.component.html b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.html new file mode 100644 index 0000000000..e503da5c80 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.html @@ -0,0 +1,50 @@ +
+ + Bug #3932: Work Side Menu tooltips + + This page renders a closed Work Side Menu so tooltip behavior can be tested + manually. + + + Move between multiple items and confirm each tooltip appears in the correct + position and shows the matching label. + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts new file mode 100644 index 0000000000..f227db7e2f --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3932/bug3932.component.ts @@ -0,0 +1,22 @@ +import { Component } from "@angular/core"; +import { + GoabBlock, + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuGroup, + GoabWorkSideMenuItem, +} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3932", + templateUrl: "./bug3932.component.html", + imports: [ + GoabBlock, + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuGroup, + GoabWorkSideMenuItem, + ], +}) +export class Bug3932Component {} \ No newline at end of file diff --git a/apps/prs/angular/src/routes/bugs/3932/bug3932.route.json b/apps/prs/angular/src/routes/bugs/3932/bug3932.route.json new file mode 100644 index 0000000000..ea5a18eeaf --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3932/bug3932.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3932", + "path": "bugs/3932", + "title": "Work Side Menu tooltips" +} \ No newline at end of file diff --git a/apps/prs/react/src/app/routes/bugs/bug3932.route.ts b/apps/prs/react/src/app/routes/bugs/bug3932.route.ts new file mode 100644 index 0000000000..9f8b9e3117 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3932.route.ts @@ -0,0 +1,10 @@ +import Bug3932Route from "../../../routes/bugs/bug3932"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3932", + path: "bugs/3932", + title: "Work Side Menu tooltips", + component: Bug3932Route, +} satisfies PrRouteDefinition; \ No newline at end of file diff --git a/apps/prs/react/src/routes/bugs/bug3932.tsx b/apps/prs/react/src/routes/bugs/bug3932.tsx new file mode 100644 index 0000000000..4de259ceb0 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3932.tsx @@ -0,0 +1,62 @@ +import { + GoabBlock, + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuGroup, + GoabWorkSideMenuItem, +} from "@abgov/react-components"; + +export function Bug3932Route() { + const menuHeight = { "--goa-work-side-menu-height": "500px" } as React.CSSProperties; + return ( +
+ + + Bug #3932: Work Side Menu tooltips + + + This page renders a closed Work Side Menu so tooltip behavior can be tested + manually. + + + Move between multiple items and confirm each tooltip appears in the correct + position and shows the matching label. + + + + + + + + + + + + + } + secondaryContent={ + <> + + + + } + /> + +
+ ); +} + +export default Bug3932Route; diff --git a/libs/react-components/specs/work-side-menu.browser.spec.tsx b/libs/react-components/specs/work-side-menu.browser.spec.tsx index 31a605f5c9..875d6486a6 100644 --- a/libs/react-components/specs/work-side-menu.browser.spec.tsx +++ b/libs/react-components/specs/work-side-menu.browser.spec.tsx @@ -110,6 +110,173 @@ describe("WorkSideMenu", () => { expect(menu.element().classList.contains("closed")).toBeFalsy(); }); + it("should show and hide tooltip for item", async () => { + await page.viewport(1024, 768); + + const Component = () => { + return ( + + } + open={false} + /> + ); + }; + + const result = render(); + const menu = result.getByTestId("menu"); + const menuItem = result.getByTestId("hover-item"); + + vi.useFakeTimers(); + try { + await menuItem.hover(); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const tooltipEl = menu + .element() + .querySelector(".tooltip") as HTMLElement | null; + expect(tooltipEl).toBeTruthy(); + expect(tooltipEl?.classList.contains("show")).toBe(true); + expect(tooltipEl?.textContent?.trim()).toBe("Search"); + expect(tooltipEl?.style.left).not.toBe(""); + expect(tooltipEl?.style.top).not.toBe(""); + }); + + await menuItem.unhover(); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const tooltipEl = menu + .element() + .querySelector(".tooltip") as HTMLElement | null; + expect(tooltipEl).toBeTruthy(); + expect(tooltipEl?.classList.contains("show")).toBe(false); + }); + } finally { + vi.useRealTimers(); + } + }); + + it("should show and hide tooltip for group", async () => { + await page.viewport(1024, 768); + + const Component = () => { + return ( + + + + } + open={false} + /> + ); + }; + + const result = render(); + const menu = result.getByTestId("menu"); + const group = result.getByTestId("hover-group"); + + vi.useFakeTimers(); + try { + await group.hover(); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const tooltipEl = menu + .element() + .querySelector(".tooltip") as HTMLElement | null; + expect(tooltipEl).toBeTruthy(); + expect(tooltipEl?.classList.contains("show")).toBe(true); + expect(tooltipEl?.textContent?.trim()).toBe("Applications"); + expect(tooltipEl?.style.left).not.toBe(""); + expect(tooltipEl?.style.top).not.toBe(""); + }); + + await group.unhover(); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const tooltipEl = menu + .element() + .querySelector(".tooltip") as HTMLElement | null; + expect(tooltipEl).toBeTruthy(); + expect(tooltipEl?.classList.contains("show")).toBe(false); + }); + } finally { + vi.useRealTimers(); + } + }); + + it("should show and hide tooltip for toggle button", async () => { + await page.viewport(1024, 768); + + const Component = () => { + return ( + + } + open={false} + /> + ); + }; + + const result = render(); + const menu = result.getByTestId("work-side-menu"); + const toggle = result.getByTestId("toggle-menu"); + + vi.useFakeTimers(); + try { + await toggle.hover(); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const tooltipEl = menu + .element() + .querySelector(".tooltip") as HTMLElement | null; + expect(tooltipEl).toBeTruthy(); + expect(tooltipEl?.classList.contains("show")).toBe(true); + expect(tooltipEl?.textContent?.trim()).toBe("Expand menu"); + expect(tooltipEl?.style.left).not.toBe(""); + expect(tooltipEl?.style.top).not.toBe(""); + }); + + await toggle.unhover(); + await vi.advanceTimersByTimeAsync(300); + + await vi.waitFor(() => { + const tooltipEl = menu + .element() + .querySelector(".tooltip") as HTMLElement | null; + expect(tooltipEl).toBeTruthy(); + expect(tooltipEl?.classList.contains("show")).toBe(false); + }); + } finally { + vi.useRealTimers(); + } + }); + it("should call onNavigate and prevent default navigation when menu item is clicked", async () => { await page.viewport(1024, 768); const onNavigate = vi.fn(); diff --git a/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte b/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte index a08757c4e7..7cdd29e252 100644 --- a/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte +++ b/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte @@ -375,7 +375,7 @@ data-testid="work-side-menu-background" on:click={handleToggleClick} /> -
+
{#if url} @@ -458,7 +458,10 @@ class="toggle-button" data-testid="toggle-menu" on:click={handleToggleClick} + on:mouseenter={handleToggleHover} + on:mouseleave={handleMouseLeave} aria-label={open ? "Collapse menu" : "Expand menu"} + bind:this={_toggleButtonEl} > Date: Fri, 22 May 2026 19:18:16 +0000 Subject: [PATCH 15/24] chore(deps): bump qs and express Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together. Updates `qs` from 6.15.0 to 6.15.2 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.15.0...v6.15.2) Updates `express` from 4.22.1 to 4.22.2 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/v4.22.2/History.md) - [Commits](https://github.com/expressjs/express/compare/v4.22.1...v4.22.2) --- updated-dependencies: - dependency-name: qs dependency-version: 6.15.2 dependency-type: indirect - dependency-name: express dependency-version: 4.22.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 176 +++++++++++++--------------------------------- 1 file changed, 48 insertions(+), 128 deletions(-) diff --git a/package-lock.json b/package-lock.json index e163a034c6..17c9cf13b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9876,9 +9876,9 @@ } }, "node_modules/@nx/module-federation/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -9890,7 +9890,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -9935,15 +9935,15 @@ "license": "MIT" }, "node_modules/@nx/module-federation/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -9962,7 +9962,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -10083,22 +10083,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nx/module-federation/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/module-federation/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -10351,9 +10335,9 @@ } }, "node_modules/@nx/react/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -10365,7 +10349,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -10410,15 +10394,15 @@ "license": "MIT" }, "node_modules/@nx/react/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -10437,7 +10421,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -10574,22 +10558,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nx/react/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/react/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -10746,9 +10714,9 @@ } }, "node_modules/@nx/rspack/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -10760,7 +10728,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -10841,15 +10809,15 @@ "license": "MIT" }, "node_modules/@nx/rspack/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -10868,7 +10836,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -11004,22 +10972,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nx/rspack/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/rspack/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -13585,9 +13537,9 @@ } }, "node_modules/@rspack/dev-server/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -13599,7 +13551,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -13669,15 +13621,15 @@ "license": "MIT" }, "node_modules/@rspack/dev-server/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -13696,7 +13648,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -13900,22 +13852,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@rspack/dev-server/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@rspack/dev-server/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -41068,9 +41004,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -48804,9 +48740,9 @@ } }, "node_modules/webpack-dev-server/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -48818,7 +48754,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -48888,15 +48824,15 @@ "license": "MIT" }, "node_modules/webpack-dev-server/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -48915,7 +48851,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -49119,22 +49055,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/webpack-dev-server/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/webpack-dev-server/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", From b26f9003fdb6078674b094d3f3b11e79579a93f5 Mon Sep 17 00:00:00 2001 From: Benji Franck Date: Mon, 25 May 2026 16:16:58 -0600 Subject: [PATCH 16/24] fix(#3854): Hide empty menu slots for Work Side Menu in Angular * fix(#3854): Hide empty menu slots for Work Side Menu in Angular * chore: add Angular test for empty secondary and account menus --- .../routes/bugs/3854/bug3854.component.html | 44 ++++++++++++++++++ .../src/routes/bugs/3854/bug3854.component.ts | 18 ++++++++ .../src/routes/bugs/3854/bug3854.route.json | 6 +++ .../work-side-menu/work-side-menu.spec.ts | 45 ++++++++++++++++++- .../work-side-menu/work-side-menu.ts | 30 ++++++++----- 5 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 apps/prs/angular/src/routes/bugs/3854/bug3854.component.html create mode 100644 apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts create mode 100644 apps/prs/angular/src/routes/bugs/3854/bug3854.route.json diff --git a/apps/prs/angular/src/routes/bugs/3854/bug3854.component.html b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.html new file mode 100644 index 0000000000..7a6013912d --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.html @@ -0,0 +1,44 @@ +Bug 3854 - Work Side Menu without extra sections + + This test page renders a Work Side Menu with only primary content. Confirm the + following: +
    +
  • The primary menu is visible.
  • +
  • The secondary and account menu is hidden.
  • +
+
+ +
+ +
+ + + + + + diff --git a/apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts new file mode 100644 index 0000000000..fffe75426a --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3854/bug3854.component.ts @@ -0,0 +1,18 @@ +import { Component } from "@angular/core"; +import { + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuItem, +} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3854", + templateUrl: "./bug3854.component.html", + imports: [GoabText, GoabWorkSideMenu, GoabWorkSideMenuItem], +}) +export class Bug3854Component { + handleNavigate(_path: string): void { + // Intentionally no-op for visual verification. + } +} diff --git a/apps/prs/angular/src/routes/bugs/3854/bug3854.route.json b/apps/prs/angular/src/routes/bugs/3854/bug3854.route.json new file mode 100644 index 0000000000..b2155a2707 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3854/bug3854.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3854", + "path": "bugs/3854", + "title": "Work Side Menu without secondary/account" +} diff --git a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts index 4d75f26cfe..fcd050e73f 100644 --- a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts +++ b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, tick, fakeAsync } from "@angular/core/testing"; import { GoabWorkSideMenu } from "./work-side-menu"; -import { Component } from "@angular/core"; +import { Component, TemplateRef } from "@angular/core"; import { By } from "@angular/platform-browser"; @Component({ @@ -44,13 +44,40 @@ class TestWorkSideMenuComponent { this.navigatedUrl = path; } } + +@Component({ + standalone: true, + imports: [GoabWorkSideMenu], + template: ` + + + +
Primary content
+
+ `, +}) +class TestWorkSideMenuEmptySlotsComponent { + open = true; + heading = "Test heading"; + url = "/test"; + secondaryContent?: TemplateRef; + accountContent?: TemplateRef; +} + describe("GoabBWorkSideMenu", () => { let fixture: ComponentFixture; let component: TestWorkSideMenuComponent; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [TestWorkSideMenuComponent], + imports: [TestWorkSideMenuComponent, TestWorkSideMenuEmptySlotsComponent], }).compileComponents(); fixture = TestBed.createComponent(TestWorkSideMenuComponent); @@ -83,4 +110,18 @@ describe("GoabBWorkSideMenu", () => { expect(component.navigatedUrl).toBe("/dashboard"); })); + + it("should not render secondary or account slot content when those inputs are empty", fakeAsync(() => { + const emptyFixture = TestBed.createComponent(TestWorkSideMenuEmptySlotsComponent); + emptyFixture.detectChanges(); + tick(); // Wait for setTimeout in ngOnInit + emptyFixture.detectChanges(); + + const menuElement = emptyFixture.debugElement.query(By.css("goa-work-side-menu")) + .nativeElement as HTMLElement; + + expect(menuElement.querySelector('[slot="secondary"]')).toBeNull(); + expect(menuElement.querySelector('[slot="account"]')).toBeNull(); + })); + }); diff --git a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts index 7218e747ea..e7655615e5 100644 --- a/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts +++ b/libs/angular-components/src/lib/components/work-side-menu/work-side-menu.ts @@ -27,15 +27,21 @@ import { NgTemplateOutlet } from "@angular/common"; (_toggle)="_onToggle()" (_navigate)="_onNavigate($event)" > -
- -
-
- -
-
- -
+ @if (primaryContent) { +
+ +
+ } + @if (secondaryContent) { +
+ +
+ } + @if (accountContent) { +
+ +
+ } } `, @@ -58,11 +64,11 @@ export class GoabWorkSideMenu implements OnInit { /** Sets a data-testid attribute for automated testing. */ @Input() testId?: string; /** Template reference for the primary navigation slot content. */ - @Input() primaryContent!: TemplateRef; + @Input() primaryContent!: TemplateRef; /** Template reference for the secondary navigation slot content. */ - @Input() secondaryContent!: TemplateRef; + @Input() secondaryContent!: TemplateRef; /** Template reference for the account slot content. */ - @Input() accountContent!: TemplateRef; + @Input() accountContent!: TemplateRef; /** Emits when the side menu is toggled open or closed. */ @Output() onToggle = new EventEmitter(); /** Emits when a navigation link is clicked. Emits the URL as a string. */ From 03cfe65c2cd6f8d34ddb489f1260856eddf40559 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Mon, 25 May 2026 18:42:15 -0600 Subject: [PATCH 17/24] chore(#3770): read framework identifiers from source, not naming conventions --- .../loaders/framework-identifiers.ts | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/src/scripts/content-generators/loaders/framework-identifiers.ts b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts index cfcf1e5735..c5341367b3 100644 --- a/docs/src/scripts/content-generators/loaders/framework-identifiers.ts +++ b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts @@ -54,7 +54,7 @@ export function loadFrameworkIdentifiers(): Map { bySlug.set(slug, { webComponentTag: tag, - reactClassName: react?.classNames[0], + reactClassName: pickReactClassName(tag, react?.classNames ?? []), reactClassNames: react?.classNames ?? [], angularSelector, }); @@ -96,9 +96,21 @@ function buildAngularWrapperIndex(): Map { const selectorMatch = content.match(/selector\s*:\s*["']goab-([a-z0-9-]+)["']/); if (!selectorMatch) continue; const stem = selectorMatch[1]; - const tag = `goa-${stem}`; const selector = `goab-${stem}`; - if (!byTag.has(tag)) byTag.set(tag, selector); + // Read the web component tag the template actually renders rather than + // deriving it from the selector stem. They diverge for wrappers like + // goab-temporary-notification-ctrl, whose tag is goa-temp-notification-ctrl. + const tagMatch = content.match(/<(goa-[a-z0-9-]+)/); + if (!tagMatch) continue; + const tag = tagMatch[1]; + // One tag can have several wrappers (goa-input <- goab-input and + // goab-input-number). Prefer the wrapper whose selector stem matches the + // tag stem; otherwise keep the first seen, which covers the divergent + // goab-temporary-notification-ctrl -> goa-temp-notification-ctrl case. + const tagStem = tag.replace(/^goa-/, ""); + if (!byTag.has(tag) || stem === tagStem) { + byTag.set(tag, selector); + } } return byTag; @@ -151,6 +163,30 @@ function extractGoabComponentExports(src: string): string[] { ); } +/** Canonical React class for a tag: `goa-dropdown-item` -> `GoabDropdownItem`. */ +function tagToReactClassName(tag: string): string { + const pascal = tag + .replace(/^goa-/, "") + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + return `Goab${pascal}`; +} + +/** + * Pick the React class for a tag. A wrapper file can export more than one Goab + * component (e.g. a deprecated alias declared before the real one), so prefer + * the class whose name matches the tag and fall back to the first export. + */ +function pickReactClassName( + tag: string, + classNames: string[], +): string | undefined { + if (classNames.length === 0) return undefined; + const canonical = tagToReactClassName(tag); + return classNames.includes(canonical) ? canonical : classNames[0]; +} + function walkSourceFiles(root: string, ext: string): string[] { const out: string[] = []; if (!fs.existsSync(root)) return out; From 3b35e85350ce70a1861064b9cb89b692f87772b0 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Mon, 25 May 2026 18:42:15 -0600 Subject: [PATCH 18/24] chore(#3770): parse YAML block scalars in the frontmatter loader --- .../content-generators/loaders/frontmatter.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/src/scripts/content-generators/loaders/frontmatter.ts b/docs/src/scripts/content-generators/loaders/frontmatter.ts index 31dd19ab47..a03cd6d1b5 100644 --- a/docs/src/scripts/content-generators/loaders/frontmatter.ts +++ b/docs/src/scripts/content-generators/loaders/frontmatter.ts @@ -99,6 +99,16 @@ function parseObject( obj[key] = sub; i = consumed; } + } else if (/^[|>][+-]?$/.test(valueText)) { + // YAML block scalar: `|` keeps newlines, `>` folds them into spaces. + const [text, consumed] = parseBlockScalar( + lines, + i + 1, + expectedIndent, + valueText[0] as "|" | ">", + ); + obj[key] = text; + i = consumed; } else if (valueText.startsWith("[") && valueText.endsWith("]")) { obj[key] = parseInlineArray(valueText); i++; @@ -148,6 +158,42 @@ function parseInlineArray(text: string): unknown[] { return inner.split(",").map((s) => parseScalar(s.trim())); } +function parseBlockScalar( + lines: string[], + startIdx: number, + parentIndent: number, + style: "|" | ">", +): [string, number] { + const collected: string[] = []; + let blockIndent: number | null = null; + let i = startIdx; + + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === "") { + collected.push(""); + i++; + continue; + } + const indent = leadingSpaces(line); + if (indent <= parentIndent) break; + if (blockIndent === null) blockIndent = indent; + collected.push(line.slice(blockIndent)); + i++; + } + + // Clip chomping (YAML default): drop trailing blank lines. + while (collected.length > 0 && collected[collected.length - 1] === "") { + collected.pop(); + } + + const text = + style === ">" + ? collected.join(" ").replace(/\s+/g, " ").trim() + : collected.join("\n"); + return [text, i]; +} + function parseScalar(text: string): unknown { if (text === "") return ""; if (text === "true") return true; From c474cdd2a970776684df7decb763940f43619b9c Mon Sep 17 00:00:00 2001 From: Mark E <51723535+Spark450@users.noreply.github.com> Date: Wed, 27 May 2026 09:13:57 -0700 Subject: [PATCH 19/24] doc(#3924): add dark mode experimental section to homepage * feat(#3924): add dark mode experimental section to homepage Co-Authored-By: Claude Sonnet 4.6 * docs(#3924): swap illustration, update heading and links in dark mode section Co-Authored-By: Claude Sonnet 4.6 * docs(#3924): bold "experimental capability", flip layout to image-left text-right Co-Authored-By: Claude Sonnet 4.6 * docs(#3924): refine dark mode section layout, links, and responsive image Co-Authored-By: Claude Sonnet 4.6 * docs(#3924): swap dark mode section image for animated SVG Co-Authored-By: Claude Sonnet 4.6 * docs(#3924): update dark mode animation SVG Co-Authored-By: Claude Sonnet 4.6 * Delete docs/public/images/home/dark-mode-illustration-v2.svg Image replaced with animated svg * docs(#3924): update dark mode animation SVG Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../home/dark-mode-feature-animation.svg | 179 ++++++++++++++++++ docs/src/pages/index.astro | 43 +++++ 2 files changed, 222 insertions(+) create mode 100644 docs/public/images/home/dark-mode-feature-animation.svg diff --git a/docs/public/images/home/dark-mode-feature-animation.svg b/docs/public/images/home/dark-mode-feature-animation.svg new file mode 100644 index 0000000000..092d7b6876 --- /dev/null +++ b/docs/public/images/home/dark-mode-feature-animation.svg @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index d925a33b14..2331338fb5 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -76,6 +76,36 @@ import CardLite from '../components/CardLite.astro'; />
+ + +

Understand your service

@@ -280,6 +310,15 @@ import CardLite from '../components/CardLite.astro'; margin: 0 0 var(--goa-space-m); } + .dark-mode-links { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--goa-space-s); + } + .cta-image { flex: 2; min-width: 0; @@ -384,6 +423,10 @@ import CardLite from '../components/CardLite.astro'; .cta-section { flex-direction: column; } + + .dark-mode-section .cta-image { + width: 100%; + } } /* Mobile breakpoint - 767px */ From a007a536880e3156ed8fc19fb517ee3fc255f1a5 Mon Sep 17 00:00:00 2001 From: Mark E <51723535+Spark450@users.noreply.github.com> Date: Wed, 27 May 2026 09:28:40 -0700 Subject: [PATCH 20/24] docs(#3876): replace related guidance with next steps by role on migration guide * docs(#3876): replace related guidance with next steps by role on migration guide Co-Authored-By: Claude Sonnet 4.6 * docs(#3876): remove developer setup steps link from migration guide Co-Authored-By: Claude Sonnet 4.6 * docs(#3876): fix heading ids, remove goa-block, use goa-text for descriptions Co-Authored-By: Claude Sonnet 4.6 * docs(#3876): inline link and description with em dash in next steps section Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- docs/src/content/get-started/migration-guide.mdx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/src/content/get-started/migration-guide.mdx b/docs/src/content/get-started/migration-guide.mdx index 27692407b0..9df4953113 100644 --- a/docs/src/content/get-started/migration-guide.mdx +++ b/docs/src/content/get-started/migration-guide.mdx @@ -101,7 +101,10 @@ import { withBase } from "@/lib/base-url";

  • Is the team prepared to accept reduced support and higher maintenance risk if migration is delayed?
  • - - - Review the setup steps for developers - +

    Next steps by role

    + +

    Designers

    +Designing with the updated design system — how to update your Figma files, the library swap process, and how design and dev migration work together + +

    Developers and technical leads

    +Updating your product — the developer-first upgrade approach, step-by-step instructions, and what to review after the update From 036d4b78c5d06728acb24d6887ce0b7be3a2789b Mon Sep 17 00:00:00 2001 From: Benji Franck Date: Thu, 28 May 2026 14:09:14 -0600 Subject: [PATCH 21/24] fix(#3972): add Acumin Variable typeface --- libs/web-components/src/assets/css/fonts.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libs/web-components/src/assets/css/fonts.css b/libs/web-components/src/assets/css/fonts.css index f82576743e..79509e475a 100644 --- a/libs/web-components/src/assets/css/fonts.css +++ b/libs/web-components/src/assets/css/fonts.css @@ -51,3 +51,14 @@ font-style: normal; font-weight: 400; } + +@font-face { + font-family: "acumin-variable"; + src: url("https://use.typekit.net/af/80814c/0000000000000000774be2a6/31/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff2"), + url("https://use.typekit.net/af/80814c/0000000000000000774be2a6/31/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff"), + url("https://use.typekit.net/af/80814c/0000000000000000774be2a6/31/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("opentype"); + font-display: auto; + font-style: normal; + font-weight: 100 900; + font-stretch: normal; +} From 7e03fed362a9f55f78f86386bdebea250b0e9def Mon Sep 17 00:00:00 2001 From: Benji Franck Date: Thu, 28 May 2026 14:29:38 -0600 Subject: [PATCH 22/24] chore: increase fake timer times in Work Side Menu browser tests --- .../specs/work-side-menu.browser.spec.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/react-components/specs/work-side-menu.browser.spec.tsx b/libs/react-components/specs/work-side-menu.browser.spec.tsx index 875d6486a6..1def59baae 100644 --- a/libs/react-components/specs/work-side-menu.browser.spec.tsx +++ b/libs/react-components/specs/work-side-menu.browser.spec.tsx @@ -139,7 +139,7 @@ describe("WorkSideMenu", () => { vi.useFakeTimers(); try { await menuItem.hover(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(400); await vi.waitFor(() => { const tooltipEl = menu @@ -153,7 +153,7 @@ describe("WorkSideMenu", () => { }); await menuItem.unhover(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(400); await vi.waitFor(() => { const tooltipEl = menu @@ -197,7 +197,7 @@ describe("WorkSideMenu", () => { vi.useFakeTimers(); try { await group.hover(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(400); await vi.waitFor(() => { const tooltipEl = menu @@ -211,7 +211,7 @@ describe("WorkSideMenu", () => { }); await group.unhover(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(400); await vi.waitFor(() => { const tooltipEl = menu @@ -249,7 +249,7 @@ describe("WorkSideMenu", () => { vi.useFakeTimers(); try { await toggle.hover(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(400); await vi.waitFor(() => { const tooltipEl = menu @@ -263,7 +263,7 @@ describe("WorkSideMenu", () => { }); await toggle.unhover(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(400); await vi.waitFor(() => { const tooltipEl = menu From 12d5e3be76e6f8185141832213b45000fbd62c36 Mon Sep 17 00:00:00 2001 From: Benji Franck Date: Thu, 28 May 2026 14:32:50 -0600 Subject: [PATCH 23/24] fix(#3323): use anchor/fixed positioning for Tooltip --- .../bugs/bug3323/bug3323.component.html | 46 ++ .../routes/bugs/bug3323/bug3323.component.ts | 31 ++ .../routes/bugs/bug3323/bug3323.route.json | 6 + .../src/app/routes/bugs/bug3323.route.ts | 10 + apps/prs/react/src/routes/bugs/bug3323.tsx | 83 ++++ .../specs/tooltip.browser.spec.tsx | 291 +++++++----- .../src/components/tooltip/Tooltip.svelte | 433 ++++++++++++++---- .../src/components/tooltip/tooltip.spec.ts | 143 +++--- 8 files changed, 750 insertions(+), 293 deletions(-) create mode 100644 apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html create mode 100644 apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts create mode 100644 apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json create mode 100644 apps/prs/react/src/app/routes/bugs/bug3323.route.ts create mode 100644 apps/prs/react/src/routes/bugs/bug3323.tsx diff --git a/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html new file mode 100644 index 0000000000..3f086a55a8 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.html @@ -0,0 +1,46 @@ +Fix 3323 - Tooltip with fixed positioning + + CSS anchor positioning support in this browser: + {{ supportsCssAnchorPositioning ? "Supported" : "Not supported" }}. + + +Tooltip in callout + + + Hover me + + + +Tooltip in container + + + + + + +Basic tooltip + + Hover me + + +Horizontal alignment + + + + + + + + + + + + + Long Tooltip + + Long tooltip + diff --git a/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts new file mode 100644 index 0000000000..5d316f3590 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.component.ts @@ -0,0 +1,31 @@ +import { Component } from "@angular/core"; +import { + GoabButton, + GoabCallout, + GoabContainer, + GoabIcon, + GoabTooltip, + GoabText, + GoabGrid, +} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3323", + templateUrl: "./bug3323.component.html", + imports: [ + GoabButton, + GoabCallout, + GoabContainer, + GoabIcon, + GoabTooltip, + GoabText, + GoabGrid, + ], +}) +export class Bug3323Component { + readonly supportsCssAnchorPositioning = + typeof CSS !== "undefined" && + typeof CSS.supports === "function" && + CSS.supports("anchor-name: --goa-tooltip-target"); +} diff --git a/apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json new file mode 100644 index 0000000000..b6a94b642b --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/bug3323/bug3323.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3323", + "path": "bugs/bug3323", + "title": "Tooltip with fixed positioning" +} diff --git a/apps/prs/react/src/app/routes/bugs/bug3323.route.ts b/apps/prs/react/src/app/routes/bugs/bug3323.route.ts new file mode 100644 index 0000000000..9fa55ba480 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3323.route.ts @@ -0,0 +1,10 @@ +import { Bug3323Route } from "../../../routes/bugs/bug3323"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3323", + path: "bugs/bug3323", + title: "Tooltip with fixed positioning", + component: Bug3323Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/routes/bugs/bug3323.tsx b/apps/prs/react/src/routes/bugs/bug3323.tsx new file mode 100644 index 0000000000..222897e165 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3323.tsx @@ -0,0 +1,83 @@ +import type { CSSProperties } from "react"; +import { + GoabButton, + GoabCallout, + GoabTooltip, + GoabIcon, + GoabContainer, + GoabText, + GoabGrid, +} from "@abgov/react-components"; + +const supportsCssAnchorPositioning = + typeof CSS !== "undefined" && + typeof CSS.supports === "function" && + CSS.supports("anchor-name: --goa-tooltip-target"); + +export function Bug3323Route() { + return ( + <> + + Fix 3323 - Tooltip with fixed positioning + + + CSS anchor positioning support in this browser:{" "} + {supportsCssAnchorPositioning ? "Supported" : "Not supported"}. + + + + Tooltip in callout + + + + + Hover me + + + + + + Tooltip in container + + + + + + + + + Basic tooltip + + + Hover me + + + + Horizontal alignment + + + + + + + + + + + + + + + Long Tooltip + + + + Long tooltip + + + + ); +} diff --git a/libs/react-components/specs/tooltip.browser.spec.tsx b/libs/react-components/specs/tooltip.browser.spec.tsx index 81ba9db820..165f7e8716 100644 --- a/libs/react-components/specs/tooltip.browser.spec.tsx +++ b/libs/react-components/specs/tooltip.browser.spec.tsx @@ -1,4 +1,5 @@ import { render } from "vitest-browser-react"; +import { page } from "@vitest/browser/context"; import { GoabTooltip, GoabButton } from "../src"; import { expect, describe, it, vi } from "vitest"; @@ -324,7 +325,9 @@ describe("Tooltip Browser Tests", () => { ); }); - it("should apply maxWidth using CSS custom property", async () => { + it("should apply maxWidth for long tooltip content", async () => { + await page.viewport(1280, 800); + const Component = () => { return (
    @@ -338,8 +341,10 @@ describe("Tooltip Browser Tests", () => {
    } maxWidth="300px" + hAlign="right" + testId="test-tooltip" > - Constrained tooltip + Tooltip
    ); @@ -347,34 +352,41 @@ describe("Tooltip Browser Tests", () => { const result = render(); const container = result.getByTestId("container"); + const host = container.element().querySelector("goa-tooltip") as HTMLElement | null; - await vi.waitFor( - () => { - const tooltipEl = container - .element() - .querySelector("goa-tooltip") as HTMLElement | null; - expect(tooltipEl).toBeTruthy(); + vi.useFakeTimers(); + try { + expect(host).toBeTruthy(); - tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await vi.waitFor(() => { + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + expect(hoverTarget).toBeTruthy(); + }); - const tooltipTextEl = - tooltipEl?.shadowRoot?.querySelector(".tooltip-text"); - expect(tooltipTextEl).toBeTruthy(); + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + hoverTarget?.dispatchEvent(new MouseEvent("mouseenter")); - const inlineWidth = tooltipTextEl?.style.width; - expect(inlineWidth).toBeTruthy(); + await vi.advanceTimersByTimeAsync(350); - const numericWidth = parseInt(inlineWidth || "0", 10); - expect(numericWidth).toBeGreaterThan(0); + await vi.waitFor(() => { + const tooltipTextEl = host?.shadowRoot?.querySelector( + ".tooltip-text", + ) as HTMLElement | null; - // Should not exceed declared maxWidth of 300px (padding/internal adjustments already applied) - expect(numericWidth).toBeLessThanOrEqual(300); - }, - { timeout: 3000 }, - ); + expect(tooltipTextEl?.classList.contains("show")).toBe(true); + + const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0; + + expect(renderedWidth).toBeGreaterThan(200); + expect(renderedWidth).toBeLessThanOrEqual(301); + }); + } finally { + vi.useRealTimers(); + } }); it("should accept valid px units for maxWidth", async () => { + await page.viewport(1280, 800); const Component = () => { return (
    @@ -390,34 +402,41 @@ describe("Tooltip Browser Tests", () => { const result = render(); const container = result.getByTestId("container"); + const host = container.element().querySelector("goa-tooltip") as HTMLElement | null; - await vi.waitFor( - () => { - const tooltipEl = container - .element() - .querySelector("goa-tooltip") as HTMLElement | null; - expect(tooltipEl).toBeTruthy(); + vi.useFakeTimers(); + try { + expect(host).toBeTruthy(); - // Trigger tooltip visibility - tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await vi.waitFor(() => { + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + expect(hoverTarget).toBeTruthy(); + }); - const tooltipTextEl = - tooltipEl?.shadowRoot?.querySelector(".tooltip-text"); - expect(tooltipTextEl).toBeTruthy(); + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + hoverTarget?.dispatchEvent(new MouseEvent("mouseenter")); - // Check that width is constrained by maxWidth - const inlineWidth = tooltipTextEl?.style.width; - expect(inlineWidth).toBeTruthy(); + await vi.advanceTimersByTimeAsync(350); - const numericWidth = parseInt(inlineWidth || "0", 10); - expect(numericWidth).toBeGreaterThan(0); - expect(numericWidth).toBeLessThanOrEqual(250); - }, - { timeout: 3000 }, - ); + await vi.waitFor(() => { + const tooltipTextEl = host?.shadowRoot?.querySelector( + ".tooltip-text", + ) as HTMLElement | null; + + expect(tooltipTextEl?.classList.contains("show")).toBe(true); + + const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0; + + expect(renderedWidth).toBeGreaterThan(200); + expect(renderedWidth).toBeLessThanOrEqual(251); + }); + } finally { + vi.useRealTimers(); + } }); it("should use default width when maxWidth has invalid percentage unit", async () => { + await page.viewport(1280, 800); const Component = () => { return (
    @@ -433,37 +452,41 @@ describe("Tooltip Browser Tests", () => { const result = render(); const container = result.getByTestId("container"); + const host = container.element().querySelector("goa-tooltip") as HTMLElement | null; - await vi.waitFor( - () => { - const tooltipEl = container - .element() - .querySelector("goa-tooltip") as HTMLElement | null; - expect(tooltipEl).toBeTruthy(); + vi.useFakeTimers(); + try { + expect(host).toBeTruthy(); - // Trigger tooltip visibility - tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await vi.waitFor(() => { + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + expect(hoverTarget).toBeTruthy(); + }); - const tooltipTextEl = - tooltipEl?.shadowRoot?.querySelector(".tooltip-text"); - expect(tooltipTextEl).toBeTruthy(); + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + hoverTarget?.dispatchEvent(new MouseEvent("mouseenter")); - // Should use default width (400px) since percentage is invalid - const inlineWidth = tooltipTextEl?.style.width; - expect(inlineWidth).toBeTruthy(); + await vi.advanceTimersByTimeAsync(350); - const numericWidth = parseInt(inlineWidth || "0", 10); - expect(numericWidth).toBeGreaterThan(0); + await vi.waitFor(() => { + const tooltipTextEl = host?.shadowRoot?.querySelector( + ".tooltip-text", + ) as HTMLElement | null; - // Should use default maxWidth of 400px (minus padding adjustments) - // The actual width will be constrained by the default 400px maxWidth - expect(numericWidth).toBeLessThanOrEqual(400); - }, - { timeout: 3000 }, - ); + expect(tooltipTextEl?.classList.contains("show")).toBe(true); + + const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0; + + expect(renderedWidth).toBeGreaterThan(300); + expect(renderedWidth).toBeLessThanOrEqual(401); + }); + } finally { + vi.useRealTimers(); + } }); it("should use default width when maxWidth has invalid em unit", async () => { + await page.viewport(1280, 800); const Component = () => { return (
    @@ -479,36 +502,41 @@ describe("Tooltip Browser Tests", () => { const result = render(); const container = result.getByTestId("container"); + const host = container.element().querySelector("goa-tooltip") as HTMLElement | null; - await vi.waitFor( - () => { - const tooltipEl = container - .element() - .querySelector("goa-tooltip") as HTMLElement | null; - expect(tooltipEl).toBeTruthy(); + vi.useFakeTimers(); + try { + expect(host).toBeTruthy(); - // Trigger tooltip visibility - tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await vi.waitFor(() => { + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + expect(hoverTarget).toBeTruthy(); + }); - const tooltipTextEl = - tooltipEl?.shadowRoot?.querySelector(".tooltip-text"); - expect(tooltipTextEl).toBeTruthy(); + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + hoverTarget?.dispatchEvent(new MouseEvent("mouseenter")); - // Should use default width (400px) since em unit is invalid - const inlineWidth = tooltipTextEl?.style.width; - expect(inlineWidth).toBeTruthy(); + await vi.advanceTimersByTimeAsync(350); - const numericWidth = parseInt(inlineWidth || "0", 10); - expect(numericWidth).toBeGreaterThan(0); + await vi.waitFor(() => { + const tooltipTextEl = host?.shadowRoot?.querySelector( + ".tooltip-text", + ) as HTMLElement | null; - // Should use default maxWidth of 400px (minus padding adjustments) - expect(numericWidth).toBeLessThanOrEqual(400); - }, - { timeout: 3000 }, - ); + expect(tooltipTextEl?.classList.contains("show")).toBe(true); + + const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0; + + expect(renderedWidth).toBeGreaterThan(300); + expect(renderedWidth).toBeLessThanOrEqual(401); + }); + } finally { + vi.useRealTimers(); + } }); it("should use default width when maxWidth has invalid ch unit", async () => { + await page.viewport(1280, 800); const Component = () => { return (
    @@ -524,36 +552,41 @@ describe("Tooltip Browser Tests", () => { const result = render(); const container = result.getByTestId("container"); + const host = container.element().querySelector("goa-tooltip") as HTMLElement | null; - await vi.waitFor( - () => { - const tooltipEl = container - .element() - .querySelector("goa-tooltip") as HTMLElement | null; - expect(tooltipEl).toBeTruthy(); + vi.useFakeTimers(); + try { + expect(host).toBeTruthy(); - // Trigger tooltip visibility - tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await vi.waitFor(() => { + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + expect(hoverTarget).toBeTruthy(); + }); - const tooltipTextEl = - tooltipEl?.shadowRoot?.querySelector(".tooltip-text"); - expect(tooltipTextEl).toBeTruthy(); + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + hoverTarget?.dispatchEvent(new MouseEvent("mouseenter")); - // Should use default width (400px) since ch unit is invalid - const inlineWidth = tooltipTextEl?.style.width; - expect(inlineWidth).toBeTruthy(); + await vi.advanceTimersByTimeAsync(350); - const numericWidth = parseInt(inlineWidth || "0", 10); - expect(numericWidth).toBeGreaterThan(0); + await vi.waitFor(() => { + const tooltipTextEl = host?.shadowRoot?.querySelector( + ".tooltip-text", + ) as HTMLElement | null; - // Should use default maxWidth of 400px (minus padding adjustments) - expect(numericWidth).toBeLessThanOrEqual(400); - }, - { timeout: 3000 }, - ); + expect(tooltipTextEl?.classList.contains("show")).toBe(true); + + const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0; + + expect(renderedWidth).toBeGreaterThan(200); + expect(renderedWidth).toBeLessThanOrEqual(401); + }); + } finally { + vi.useRealTimers(); + } }); it("should use default width when maxWidth has completely invalid value", async () => { + await page.viewport(1280, 800); const Component = () => { return (
    @@ -569,32 +602,36 @@ describe("Tooltip Browser Tests", () => { const result = render(); const container = result.getByTestId("container"); + const host = container.element().querySelector("goa-tooltip") as HTMLElement | null; - await vi.waitFor( - () => { - const tooltipEl = container - .element() - .querySelector("goa-tooltip") as HTMLElement | null; - expect(tooltipEl).toBeTruthy(); + vi.useFakeTimers(); + try { + expect(host).toBeTruthy(); - // Trigger tooltip visibility - tooltipEl?.dispatchEvent(new Event("mouseenter", { bubbles: true })); + await vi.waitFor(() => { + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + expect(hoverTarget).toBeTruthy(); + }); - const tooltipTextEl = - tooltipEl?.shadowRoot?.querySelector(".tooltip-text"); - expect(tooltipTextEl).toBeTruthy(); + const hoverTarget = host?.shadowRoot?.querySelector(".tooltip"); + hoverTarget?.dispatchEvent(new MouseEvent("mouseenter")); - // Should use default width (400px) since value is completely invalid - const inlineWidth = tooltipTextEl?.style.width; - expect(inlineWidth).toBeTruthy(); + await vi.advanceTimersByTimeAsync(350); - const numericWidth = parseInt(inlineWidth || "0", 10); - expect(numericWidth).toBeGreaterThan(0); + await vi.waitFor(() => { + const tooltipTextEl = host?.shadowRoot?.querySelector( + ".tooltip-text", + ) as HTMLElement | null; - // Should use default maxWidth of 400px (minus padding adjustments) - expect(numericWidth).toBeLessThanOrEqual(400); - }, - { timeout: 3000 }, - ); + expect(tooltipTextEl?.classList.contains("show")).toBe(true); + + const renderedWidth = tooltipTextEl?.getBoundingClientRect().width ?? 0; + + expect(renderedWidth).toBeGreaterThan(200); + expect(renderedWidth).toBeLessThanOrEqual(401); + }); + } finally { + vi.useRealTimers(); + } }); -}); \ No newline at end of file +}); diff --git a/libs/web-components/src/components/tooltip/Tooltip.svelte b/libs/web-components/src/components/tooltip/Tooltip.svelte index 51632a6ce5..ebf2d10ae6 100644 --- a/libs/web-components/src/components/tooltip/Tooltip.svelte +++ b/libs/web-components/src/components/tooltip/Tooltip.svelte @@ -50,7 +50,7 @@ if (!maxwidth) return ""; // Check for 'px' unit - if (!maxwidth.endsWith('px')) { + if (!maxwidth.endsWith("px")) { return ""; } @@ -78,10 +78,18 @@ let _screenSize = 0; let _rootEl: HTMLElement; let _tooltipEl: HTMLElement; + let _targetEl: HTMLElement; let _initialPosition: Position; + let _computedAlign: Alignment = "center"; let _tooltipVisible = false; let _showTooltipTimeout: ReturnType | undefined; let _hideTooltipTimeout: ReturnType | undefined; + let _positionRafId: number | null = null; + const _needsManualPositioning = + typeof document !== "undefined" && + !("anchorName" in document.documentElement.style); + const _manualGap = 12; + const _manualOffset = 16; // Use a unique id for each tooltip instance. // So screen readers can identify the tooltip instance when @@ -91,17 +99,7 @@ // Reactive $: { - if (_rootEl && _tooltipEl) { - _rootEl.style.setProperty( - "--target-width", - `${_rootEl.getBoundingClientRect().width / 2}px`, - ); - } - } - - // call checkAndAdjustPosition function when content changes - $: { - content && checkAndAdjustPosition(); + content && reconcileTooltipLayout(); // Check position when content changes. } // Hooks @@ -113,24 +111,39 @@ maxwidth = validateMaxWidth(maxwidth); _initialPosition = position; + _computedAlign = halign; _tooltipInstanceId = Math.random().toString(36); - window.addEventListener("resize", checkAndAdjustPosition); - checkAndAdjustPosition(); + window.addEventListener("resize", onWindowResize); }); onDestroy(() => { - window.removeEventListener("resize", checkAndAdjustPosition); + window.removeEventListener("resize", onWindowResize); + clearTimeout(_showTooltipTimeout); clearTimeout(_hideTooltipTimeout); + + if (_needsManualPositioning || _positionRafId !== null) { + stopManualPositioning(); + } }); // Functions const showTooltip = () => { _showTooltipTimeout = setTimeout(() => { + // Measure target width here — layout is guaranteed to be available on hover + updateTargetWidth(); _tooltipVisible = true; - checkAndAdjustPosition(); + + // Popover promotion to the top layer can require one paint before + // dimensions are stable, so size/position on the next frame. + requestAnimationFrame(() => { + reconcileTooltipLayout(); + if (_needsManualPositioning) { + startManualPositioning(); + } + }); }, 300); }; @@ -140,9 +153,37 @@ _hideTooltipTimeout = setTimeout(() => { _tooltipVisible = false; position = _initialPosition; + _computedAlign = halign; + if (_needsManualPositioning || _positionRafId !== null) { + stopManualPositioning(); + } }, 500); }; + function startManualPositioning() { + if (!_needsManualPositioning || _positionRafId !== null) { + return; + } + + const loop = () => { + updateManualPopoverCoordinates(); + _positionRafId = requestAnimationFrame(loop); + }; + + _positionRafId = requestAnimationFrame(loop); + } + + function stopManualPositioning() { + if (_positionRafId !== null) { + cancelAnimationFrame(_positionRafId); + _positionRafId = null; + } + } + + function onWindowResize() { + reconcileTooltipLayout(); + } + // Mouse click also fires on:focus on a tabindex=0 element, but we only want // to reveal the tooltip on keyboard-driven focus so JS state stays aligned // with the CSS :focus-visible rule that drives opacity. @@ -153,84 +194,203 @@ showTooltip(); } - async function checkAndAdjustPosition() { - // angular needs time to render the _tooltipEl - await tick(); - - if (!_tooltipEl || !_rootEl) { - return; - } - - // determine the bounding rectangle of the tooltip and root element - const tooltipRect = _tooltipEl.getBoundingClientRect(); - const rootRect = _rootEl.getBoundingClientRect(); - - const spaceTop = rootRect.top; - const spaceBottom = window.innerHeight - rootRect.bottom; - const spaceLeft = rootRect.left; - const spaceRight = window.innerWidth - rootRect.right; - - const calculatedMaxWidth = maxwidth && maxwidth.endsWith('px') ? parseFloat(maxwidth) : 400; + function applyTooltipWidth( + tooltipRect: DOMRect, + targetRect: DOMRect, + spaceLeft: number, + spaceRight: number, + ) { + const viewportWidth = _screenSize || window.innerWidth; + const calculatedMaxWidth = + maxwidth && maxwidth.endsWith("px") ? parseFloat(maxwidth) : 400; const newWidth = Math.min( - _screenSize * 0.8, + viewportWidth * 0.8, calculatedMaxWidth, tooltipRect.width, Math.max(spaceLeft, spaceRight) - 10, ); const shouldWrapContent = - (maxwidth && maxwidth.endsWith('px')) || - newWidth > rootRect.width || + (maxwidth && maxwidth.endsWith("px")) || + newWidth > targetRect.width || newWidth > spaceLeft || newWidth > spaceRight; - _tooltipEl.style.width = `${newWidth - 32}px`; - if (shouldWrapContent) { - _tooltipEl.style.whiteSpace = "normal"; - } else { - _tooltipEl.style.whiteSpace = "nowrap"; + _tooltipEl.style.width = `${Math.ceil(Math.max(newWidth - 32, 1))}px`; + _tooltipEl.style.whiteSpace = shouldWrapContent ? "normal" : "nowrap"; + } + + function getAdjustedPosition( + currentPosition: Position, + tooltipRect: DOMRect, + spaceTop: number, + spaceBottom: number, + spaceLeft: number, + spaceRight: number, + ): Position { + let adjustedPosition = currentPosition; + + if (currentPosition === "bottom" && tooltipRect.height > spaceBottom) { + adjustedPosition = "top"; + } else if (currentPosition === "top" && tooltipRect.height > spaceTop) { + adjustedPosition = "bottom"; } - let newPosition = position; // use a local variable to determine the new position - let newAlign = halign; // use a local variable to determine the new position + if (currentPosition === "right" && tooltipRect.width > spaceRight) { + adjustedPosition = "left"; + } else if (currentPosition === "left" && tooltipRect.width > spaceLeft) { + adjustedPosition = "right"; + } - // check if there is enough space for the tooltip in the initial position - if (position === "bottom" && tooltipRect.height > spaceBottom) { - newPosition = "top"; - } else if (position === "top" && tooltipRect.height > spaceTop) { - newPosition = "bottom"; + return adjustedPosition; + } + + function resolveTopBottomAlign( + requestedAlign: Alignment, + targetRect: DOMRect, + tooltipRect: DOMRect, + offset: number, + ): Alignment { + const viewportWidth = window.innerWidth; + const centerLeft = + targetRect.left + (targetRect.width - tooltipRect.width) / 2; + const rightLeft = targetRect.left - offset; + const leftLeft = targetRect.right - tooltipRect.width + offset; + + const projectedLeftByAlign = (align: Alignment): number => { + if (align === "left") return leftLeft; + if (align === "right") return rightLeft; + return centerLeft; + }; + + const overflowsViewport = (left: number): boolean => { + const right = left + tooltipRect.width; + return left < 0 || right > viewportWidth; + }; + + const overflowAmount = (left: number): number => { + const right = left + tooltipRect.width; + return Math.max(0, -left) + Math.max(0, right - viewportWidth); + }; + + const requestedLeft = projectedLeftByAlign(requestedAlign); + const requestedOverflows = overflowsViewport(requestedLeft); + + if (!requestedOverflows) { + return requestedAlign; } - // similar check for left and right position - if (position === "right" && tooltipRect.width > spaceRight) { - newPosition = "left"; - } else if (position === "left" && tooltipRect.width > spaceLeft) { - newPosition = "right"; + if (requestedAlign === "right" || requestedAlign === "left") { + const oppositeAlign: Alignment = + requestedAlign === "right" ? "left" : "right"; + const oppositeLeft = projectedLeftByAlign(oppositeAlign); + + if (!overflowsViewport(oppositeLeft)) { + return oppositeAlign; + } + + if (overflowAmount(oppositeLeft) < overflowAmount(requestedLeft)) { + return oppositeAlign; + } + + return requestedAlign; } - // similar check for left and right alignmewnt - if (halign === "right" && tooltipRect.width > spaceRight) { - newAlign = "left"; - } else if (halign === "left" && tooltipRect.width > spaceLeft) { - newAlign = "right"; - } else if ( - halign === "center" && - (position === "top" || position === "bottom") && - (tooltipRect.width / 2 > spaceLeft || tooltipRect.width / 2 > spaceRight) - ) { - newAlign = spaceLeft > spaceRight ? "left" : "right"; + const leftOverflow = overflowAmount(leftLeft); + const rightOverflow = overflowAmount(rightLeft); + return leftOverflow <= rightOverflow ? "left" : "right"; + } + + async function reconcileTooltipLayout() { + // angular needs time to render the _tooltipEl + await tick(); + + if (!_tooltipEl || !_rootEl || !_targetEl || !_tooltipVisible) { + return; } + // determine the bounding rectangle of the tooltip and target element + let tooltipRect = _tooltipEl.getBoundingClientRect(); + const targetRect = _targetEl.getBoundingClientRect(); + + const spaceTop = targetRect.top; + const spaceBottom = window.innerHeight - targetRect.bottom; + const spaceLeft = targetRect.left; + const spaceRight = window.innerWidth - targetRect.right; + applyTooltipWidth(tooltipRect, targetRect, spaceLeft, spaceRight); + + // Re-measure the tooltip rect after applying width/white-space + // since text wrapping can change the tooltip's dimensions + tooltipRect = _tooltipEl.getBoundingClientRect(); + + const newPosition = getAdjustedPosition( + position, + tooltipRect, + spaceTop, + spaceBottom, + spaceLeft, + spaceRight, + ); + + const newAlign = + newPosition === "top" || newPosition === "bottom" + ? resolveTopBottomAlign(halign, targetRect, tooltipRect, _manualOffset) + : "center"; + // update tooltip position position = newPosition; - halign = newAlign; + _computedAlign = newAlign; + } + + function updateManualPopoverCoordinates() { + if (!_tooltipVisible || !_tooltipEl || !_targetEl) return; + + const targetRect = _targetEl.getBoundingClientRect(); + const tooltipRect = _tooltipEl.getBoundingClientRect(); + const gap = _manualGap; + const offset = _manualOffset; + + if (position === "top") { + _tooltipEl.style.top = `${targetRect.top - tooltipRect.height - gap}px`; + _tooltipEl.style.left = `${targetRect.left + (targetRect.width - tooltipRect.width) / 2}px`; + } else if (position === "bottom") { + _tooltipEl.style.top = `${targetRect.bottom + gap}px`; + _tooltipEl.style.left = `${targetRect.left + (targetRect.width - tooltipRect.width) / 2}px`; + } else if (position === "left") { + _tooltipEl.style.top = `${targetRect.top + (targetRect.height - tooltipRect.height) / 2}px`; + _tooltipEl.style.left = `${targetRect.left - tooltipRect.width - gap}px`; + } else if (position === "right") { + _tooltipEl.style.top = `${targetRect.top + (targetRect.height - tooltipRect.height) / 2}px`; + _tooltipEl.style.left = `${targetRect.right + gap}px`; + } + + // Adjust horizontal alignment for top/bottom positions + if (position === "top" || position === "bottom") { + if (_computedAlign === "left") { + // tooltip's right edge aligns with target's right edge + _tooltipEl.style.left = `${targetRect.right - tooltipRect.width + offset}px`; + } else if (_computedAlign === "right") { + // tooltip's left edge aligns with target's left edge + _tooltipEl.style.left = `${targetRect.left - offset}px`; + } + } + } + + function updateTargetWidth() { + if (!_rootEl || !_tooltipEl || !_targetEl) return; + + const targetHalfWidth = `${_targetEl.getBoundingClientRect().width / 2}px`; + _rootEl.style.setProperty("--target-width", targetHalfWidth); } +
    { clearTimeout(_hideTooltipTimeout); @@ -240,19 +400,24 @@ on:focus={handleFocus} on:blur={hideTooltip} data-testid={testid} - role="tooltip" aria-describedby="{_tooltipInstanceId}-tooltip" tabindex="0" style={calculateMargin(mt, mr, mb, ml)} > -
    +
    {#if $$slots.content} @@ -282,50 +447,124 @@ } .tooltip-text { - visibility: hidden; + pointer-events: none; font: var(--goa-tooltip-text-size); background-color: var(--goa-tooltip-color-bg); color: var(--goa-tooltip-color-text); text-align: center; border-radius: var(--goa-tooltip-border-radius); - position: absolute; - z-index: 2; + position: fixed; + z-index: 9999; opacity: 0; - transition: opacity 0.3s; + transition: opacity var(--goa-motion-duration-medium-2) + var(--goa-motion-curve-productive); padding: var(--goa-tooltip-padding); text-align: left; white-space: nowrap; display: flex; flex-direction: column; + overflow: visible; + border-width: 0; + } + + .show.tooltip-text { + opacity: 1; + } + + .use-manual-positioning .tooltip-text { + position: fixed; + z-index: 9999; + top: auto; + bottom: auto; + left: auto; + right: auto; + } + + .use-anchor-based-positioning .tooltip-text { + position-anchor: --goa-tooltip-target; + margin: 0; + inset-block-start: anchor(bottom); + inset-inline-start: anchor(left); } .tooltip-target { margin: var(--goa-tooltip-gap); height: auto; display: flex; + cursor: pointer; } - .tooltip-text.bottom { - top: calc(100% + 10px); + .use-anchor-based-positioning .tooltip-target { + anchor-name: --goa-tooltip-target; } - .tooltip-text.top { - bottom: calc(100% + 10px); + /* Positions */ + + .use-anchor-based-positioning .tooltip-text.bottom { + inset-block-start: anchor(bottom); + inset-inline-start: anchor(center); + translate: -50% var(--goa-space-s); } - .tooltip-text.right { - left: calc(100% + 10px); + .use-anchor-based-positioning .tooltip-text.top { + inset-block-start: anchor(top); + inset-inline-start: anchor(center); + translate: -50% calc(-100% - var(--goa-space-s)); } - .tooltip-text.left { - right: calc(100% + 10px); + .use-anchor-based-positioning .tooltip-text.right { + inset-block-start: anchor(center); + inset-inline-start: anchor(right); + translate: var(--goa-space-s) -50%; } - .tooltip:hover .tooltip-text, - .tooltip:focus-visible .tooltip-text { - opacity: 1; + .use-anchor-based-positioning .tooltip-text.left { + inset-block-start: anchor(center); + inset-inline-start: anchor(left); + translate: calc(-100% - var(--goa-space-s)) -50%; + } + + /* Alignments */ + .use-anchor-based-positioning .tooltip-text.bottom.align-right { + inset-inline-start: anchor(left); + translate: calc(var(--goa-space-m) * -1) var(--goa-space-s); + } + + .use-anchor-based-positioning .tooltip-text.top.align-right { + inset-inline-start: anchor(left); + translate: calc(var(--goa-space-m) * -1) calc(-100% - var(--goa-space-s)); + } + + .use-anchor-based-positioning .tooltip-text.bottom.align-left { + inset-inline-start: anchor(right); + translate: calc(-100% + var(--goa-space-m)) var(--goa-space-s); } + .use-anchor-based-positioning .tooltip-text.top.align-left { + inset-inline-start: anchor(right); + translate: calc(-100% + var(--goa-space-m)) calc(-100% - var(--goa-space-s)); + } + + /* Overflow fallbacks: flip to opposite alignment when tooltip goes off-screen */ + @position-try --align-right-flipped-bottom { + inset-inline-start: anchor(right); + translate: calc(-100% + var(--goa-space-m)) var(--goa-space-s); + } + @position-try --align-right-flipped-top { + inset-inline-start: anchor(right); + translate: calc(-100% + var(--goa-space-m)) calc(-100% - var(--goa-space-s)); + } + @position-try --align-left-flipped-bottom { + inset-inline-start: anchor(left); + translate: calc(var(--goa-space-m) * -1) var(--goa-space-s); + } + @position-try --align-left-flipped-top { + inset-inline-start: anchor(left); + translate: calc(var(--goa-space-m) * -1) calc(-100% - var(--goa-space-s)); + } + + /* Callout */ + .tooltip-text.bottom::before, .tooltip-text.top::before, .tooltip-text.left::before, @@ -371,27 +610,21 @@ .tooltip-text.bottom.align-left::before, .tooltip-text.top.align-left::before { - left: calc(100% - (var(--target-width) + 1rem)); + left: calc(100% - (var(--target-width) + var(--goa-space-m))); } .tooltip-text.bottom.align-right::before, .tooltip-text.top.align-right::before { - left: calc(var(--target-width) + 1rem); + left: calc(var(--target-width) + var(--goa-space-m)); } - .tooltip.align-right { - justify-content: flex-start; - } - .tooltip.align-left { - justify-content: flex-end; + .use-anchor-based-positioning .tooltip-text.bottom.align-right::before, + .use-anchor-based-positioning .tooltip-text.top.align-right::before { + left: calc(var(--target-width) + var(--goa-space-m)); } - .tooltip-text.align-right { - left: 0; - margin-left: -1rem; - } - .tooltip-text.align-left { - right: 0; - margin-right: -1rem; + .use-anchor-based-positioning .tooltip-text.bottom.align-left::before, + .use-anchor-based-positioning .tooltip-text.top.align-left::before { + left: calc(100% - var(--target-width) - var(--goa-space-m)); } diff --git a/libs/web-components/src/components/tooltip/tooltip.spec.ts b/libs/web-components/src/components/tooltip/tooltip.spec.ts index 36655414c9..02666fd847 100644 --- a/libs/web-components/src/components/tooltip/tooltip.spec.ts +++ b/libs/web-components/src/components/tooltip/tooltip.spec.ts @@ -20,25 +20,35 @@ it("shows and hides tooltip on mouseenter/mouseleave", async () => { const tooltipContainer = container.querySelector(".tooltip"); const tooltipEl = container.querySelector(".tooltip-text") as HTMLElement; - // Initially, tooltip should be hidden - expect(tooltipEl.style.visibility).toBe("hidden"); + // Initially, tooltip should be hidden (no show class) + expect(tooltipEl.classList.contains("show")).toBe(false); + expect(tooltipEl.getAttribute("aria-hidden")).toBe("true"); // Simulate mouse enter expect(tooltipContainer).toBeTruthy(); if (!tooltipContainer) return; - await fireEvent.mouseEnter(tooltipContainer); - - await waitFor(() => expect(tooltipEl.style.visibility).toBe("visible"), { - timeout: 350, - }); - - // Simulate mouse leave - await fireEvent.mouseLeave(tooltipContainer); - - await waitFor(() => expect(tooltipEl.style.visibility).toBe("hidden"), { - timeout: 600, - }); + vi.useFakeTimers(); + try { + await fireEvent.mouseEnter(tooltipContainer); + vi.advanceTimersByTime(350); + + await waitFor(() => + expect(tooltipEl.classList.contains("show")).toBe(true), + ); + expect(tooltipEl.getAttribute("aria-hidden")).toBe("false"); + + // Simulate mouse leave + await fireEvent.mouseLeave(tooltipContainer); + vi.advanceTimersByTime(550); + + await waitFor(() => + expect(tooltipEl.classList.contains("show")).toBe(false), + ); + expect(tooltipEl.getAttribute("aria-hidden")).toBe("true"); + } finally { + vi.useRealTimers(); + } }); it("validates the props", async () => { @@ -93,26 +103,29 @@ it.skip("should try and change tooltip position on window resize", async () => { }); it("does not exceed 80% of the screen size or 400px", async () => { - const { container } = render(Tooltip, { content: "Hello, Tooltip!" }); - const tooltipEl = container.querySelector(".tooltip-text") as HTMLElement; - - // Mock getBoundingClientRect to return a large width - tooltipEl.getBoundingClientRect = () => ({ - width: 500, - height: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, + const { container } = render(Tooltip, { + content: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", }); + const tooltipContainer = container.querySelector(".tooltip"); + const tooltipEl = container.querySelector(".tooltip-text") as HTMLElement; - // Simulate window resize to trigger the tooltip"s responsive behavior - global.dispatchEvent(new Event("resize")); - - await tick(); // to wait for Svelte"s reactive updates + expect(tooltipContainer).toBeTruthy(); + if (!tooltipContainer) return; - // Verify that the tooltip"s width has been adjusted - expect(parseInt(tooltipEl.style.width, 10)).toBeLessThanOrEqual(400); + vi.useFakeTimers(); + try { + // Simulate mouse enter + await fireEvent.mouseEnter(tooltipContainer); + vi.advanceTimersByTime(350); + + await waitFor(() => + // Verify that the tooltip"s width has been adjusted + expect(parseInt(tooltipEl.style.width, 10)).toBeLessThanOrEqual(400), + ); + } finally { + vi.useRealTimers(); + } }); it("cursor style remains same on hover", async () => { @@ -122,56 +135,54 @@ it("cursor style remains same on hover", async () => { expect(tooltipContainer).toBeTruthy(); if (!tooltipContainer) return; - const initialCursorStyle = window.getComputedStyle(tooltipContainer).cursor; - await fireEvent.mouseEnter(tooltipContainer); + vi.useFakeTimers(); + try { + const initialCursorStyle = window.getComputedStyle(tooltipContainer).cursor; + await fireEvent.mouseEnter(tooltipContainer); + vi.advanceTimersByTime(350); - await waitFor( - () => { + await waitFor(() => { const cursorStyleOnHover = window.getComputedStyle(tooltipContainer).cursor; expect(cursorStyleOnHover).toBe(initialCursorStyle); - }, - { timeout: 500 }, - ); + }); - await fireEvent.mouseLeave(tooltipContainer); + await fireEvent.mouseLeave(tooltipContainer); + vi.advanceTimersByTime(350); - await waitFor( - () => { + await waitFor(() => { const cursorStyleOnLeave = window.getComputedStyle(tooltipContainer).cursor; expect(cursorStyleOnLeave).toBe(initialCursorStyle); - }, - { timeout: 500 }, - ); + }); + } finally { + vi.useRealTimers(); + } }); it("should render tooltip with maxwidth property", async () => { const { container } = render(Tooltip, { - content: "Hello, Tooltip!", + content: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", maxwidth: "300px", }); + const tooltipContainer = container.querySelector(".tooltip"); const tooltipEl = container.querySelector(".tooltip-text") as HTMLElement; - expect(tooltipEl).toBeTruthy(); - - // Mock getBoundingClientRect to simulate a tooltip that would exceed maxwidth - tooltipEl.getBoundingClientRect = () => ({ - width: 500, - height: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, - x: 0, - y: 0, - toJSON: () => ({}), - }); - - // Trigger resize to apply maxwidth calculation - global.dispatchEvent(new Event("resize")); - await tick(); + expect(tooltipContainer).toBeTruthy(); + if (!tooltipContainer) return; - // The width should be constrained by maxwidth (300px - 32px padding = 268px) - expect(parseInt(tooltipEl.style.width, 10)).toBeLessThanOrEqual(268); -}); \ No newline at end of file + vi.useFakeTimers(); + try { + // Simulate mouse enter + await fireEvent.mouseEnter(tooltipContainer); + vi.advanceTimersByTime(350); + + await waitFor(() => + // The width should be constrained by maxwidth (300px - 32px padding = 268px) + expect(parseInt(tooltipEl.style.width, 10)).toBeLessThanOrEqual(268), + ); + } finally { + vi.useRealTimers(); + } +}); From f9a30cb589867f914621a582d761d0ad58097b39 Mon Sep 17 00:00:00 2001 From: Minh Thy <120135417+vanessatran-ddi@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:29:06 -0600 Subject: [PATCH 24/24] chore: prevent PR check from hanging on playwright browser download * chore: prevent PR check from hanging on playwright browser download * chore --- .github/workflows/pull-request.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2033aa8090..969da32107 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -13,12 +13,26 @@ on: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: mcr.microsoft.com/playwright:v1.59.0-noble + + env: + # the playwright npm postinstall not to download them during `npm ci`. + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + # The container runs as root but GitHub sets HOME=/github/home, which the + # image owns as `pwuser`. Firefox refuses to launch when $HOME is owned by a + # different user, so point HOME at root's own home. + HOME: /root steps: - uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Allow git to use the container workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: actions/setup-node@v6 with: node-version: "24" @@ -39,6 +53,5 @@ jobs: - name: Test run: | if [ -d "./dist" ]; then - npx playwright install --with-deps npm run test:pr fi