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 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/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/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: + + + +
+ +
+ + + + + + 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/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/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/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/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/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/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/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/docs/generated/component-apis/temporary-notification.json b/docs/generated/component-apis/temporary-notification.json index c186f7fa79..e90e9bb432 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": [ @@ -59,64 +59,34 @@ "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\"", + "name": "horizontal-position", + "type": "\"left\" | \"center\" | \"right\"", "values": [ - "up", - "down" + "left", + "center", + "right" ], "required": false, - "default": "down", - "description": "Direction the notification animates from when appearing or disappearing." - }, - { - "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\"." + "default": "center", + "description": "Horizontal position of the notification container." }, { "name": "testid", "type": "string", - "required": false, - "default": "", + "required": true, + "default": null, "description": "Sets a data-testid attribute for automated testing." }, { - "name": "type", - "type": "\"basic\" | \"success\" | \"failure\" | \"indeterminate\" | \"progress\"", + "name": "vertical-position", + "type": "\"top\" | \"bottom\"", "values": [ - "basic", - "success", - "failure", - "indeterminate", - "progress" + "top", + "bottom" ], "required": false, - "default": "basic", - "description": "The notification type which determines the visual style and icon." - }, - { - "name": "visible", - "type": "boolean", - "required": false, - "default": "true", - "description": "Controls whether the notification is visible." + "default": "bottom", + "description": "Vertical position of the notification container." } ], "events": [], 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/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/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/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.

Manual rebuild -

Use a manual rebuild if you need more control.

+

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

-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:

- +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. 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 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/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'; /> + +
    +
    +

    Dark mode

    +

    + Dark mode is available as an experimental capability in the design system. Try it in your service and share what you learn — your feedback helps shape how we develop it further. +

    + +
    +
    + Animated dark mode feature toggle with subtle grid movement +
    +
    +

    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 */ 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..fe09aa550e --- /dev/null +++ b/docs/src/scripts/content-generators/checks/prop-refs.ts @@ -0,0 +1,100 @@ +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 + * 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." + * + * 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 components = records.filter( + (r): r is ComponentRecord => r.collection === "components", + ); + const lookup = buildComponentLookup(components); + + 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 = lookup.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..1c60b26cfc --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/examples.ts @@ -0,0 +1,95 @@ +import * as fs from "fs"; +import * as path from "path"; +import { paths } from "../config"; +import { parseFrontmatter } from "./frontmatter"; +import { asString, asStringArray, findIndexMdxFolders } 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 findIndexMdxFolders(paths.content.examples)) { + const slug = path.basename(folder); + const indexPath = path.join(folder, "index.mdx"); + + 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..c5341367b3 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/framework-identifiers.ts @@ -0,0 +1,209 @@ +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 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 REACT_LIB_DIR = "react-components/src/lib"; +const ANGULAR_LIB_DIR = "angular-components/src/lib/components"; + +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 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: pickReactClassName(tag, react?.classNames ?? []), + reactClassNames: react?.classNames ?? [], + angularSelector, + }); + } + + return bySlug; +} + +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 byTag; +} + +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 selector = `goab-${stem}`; + // 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; +} + +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 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 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]); + } + 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].filter( + (n) => !n.endsWith("Props") && !n.endsWith("Detail") && !n.endsWith("Event"), + ); +} + +/** 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; + 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/content-generators/loaders/frontmatter.ts b/docs/src/scripts/content-generators/loaders/frontmatter.ts new file mode 100644 index 0000000000..a03cd6d1b5 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/frontmatter.ts @@ -0,0 +1,210 @@ +// 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 (/^[|>][+-]?$/.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++; + } 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 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; + 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..46da97b432 --- /dev/null +++ b/docs/src/scripts/content-generators/loaders/lib.ts @@ -0,0 +1,63 @@ +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)); +} + +// 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; +} 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..6db96cbd53 --- /dev/null +++ b/docs/src/scripts/content-generators/outputs/mcp-json.ts @@ -0,0 +1,57 @@ +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 }); + + // 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); + 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. + 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..8583ebd1d1 --- /dev/null +++ b/docs/src/scripts/content-generators/transforms/aliases.ts @@ -0,0 +1,69 @@ +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. +const COMPONENT_SLUG_ALIASES: Record = { + // newSlug: [oldSlug1, oldSlug2, ...] + "text-area": ["textarea"], + notification: ["notification-banner"], + "menu-button": ["multi-action-button"], +}; + +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/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 new file mode 100644 index 0000000000..0520aeffee --- /dev/null +++ b/docs/src/scripts/content-generators/transforms/link-guidance.ts @@ -0,0 +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 lookup = buildComponentLookup(components); + const byCanonicalId = new Map>(); + for (const g of guidance) { + const targets = g.appliesTo?.components ?? []; + for (const slug of targets) { + 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 = byCanonicalId.get(c.id); + if (!linked || linked.size === 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/docs/src/scripts/extract-api.ts b/docs/src/scripts/extract-api.ts index 6eb01bdedc..f239f2bb06 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")); @@ -2174,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) => { @@ -2185,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)); } @@ -2236,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 { 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. */ 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/react-components/specs/work-side-menu.browser.spec.tsx b/libs/react-components/specs/work-side-menu.browser.spec.tsx index 31a605f5c9..1def59baae 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(400); + + 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(400); + + 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(400); + + 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(400); + + 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(400); + + 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(400); + + 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/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; +} 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 @@
    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(); + } +}); 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} > =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 +10336,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 +10350,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 +10395,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 +10422,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 +10559,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 +10715,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 +10729,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 +10810,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 +10837,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 +10973,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 +13538,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 +13552,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 +13622,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 +13649,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 +13853,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", @@ -23590,9 +23527,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": { @@ -25786,9 +25723,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": [ { @@ -25802,6 +25739,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", @@ -27559,9 +27505,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": { @@ -41068,9 +41014,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 +48750,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 +48764,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 +48834,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 +48861,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 +49065,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", 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",