diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..35abbe9d4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Generated docs files: collapse by default in GitHub PR diffs (linguist-generated). +# Deterministic build outputs (see docs/ARCHITECTURE.md). They stay fully diffable, +# searchable, and in history; this only hides them by default in the Files Changed tab. +docs/generated/** linguist-generated=true +docs/public/search-index.json linguist-generated=true diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 0000000000..d648b0005c --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,126 @@ +name: Dev Release & Preview + +on: + push: + branches: [dev] + workflow_dispatch: + +concurrency: + group: dev-release + cancel-in-progress: false + +permissions: + contents: write + id-token: write # Required for OIDC + +jobs: + dev-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: | + package-lock.json + docs/package-lock.json + + - name: Install npm 11 + run: npm install -g npm@11 + + - name: Install dependencies + run: | + npm ci + cd docs && npm ci --legacy-peer-deps + + # ---- Release libs to npm (dev prerelease channel) ---- + - name: Get last successful commit + uses: nrwl/nx-set-shas@v4 + id: last_successful_commit + with: + main-branch-name: ${{ github.ref_name }} + + - name: Build libs + run: npm run build:prod + + - name: Fix web-component lib README + run: cp libs/web-components/README.md dist/libs/web-components + + - name: Release libs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npx nx affected --target release --parallel=false --base=${{ steps.last_successful_commit.outputs.base }} + + # ---- Build + deploy docs & playground previews to Pages ---- + - name: Build docs + env: + PREVIEW_SITE: https://govalta.github.io + PREVIEW_BASE: /ui-components/dev/ + run: npx nx run docs:build + + - name: Build React playground + env: + PREVIEW_BASE_REACT: /ui-components/dev-react/ + run: npx nx run react-prs:build:production + + - name: Build Angular playground + env: + PREVIEW_BASE_ANGULAR: /ui-components/dev-angular/ + run: npx nx run angular-prs:build:production --base-href=$PREVIEW_BASE_ANGULAR + + # force: false => rebase onto the latest gh-pages instead of force-pushing, + # so a concurrent PR-preview deploy to a different folder is not clobbered. + # clean-exclude protects the sibling folders as a safety net. + - name: Deploy docs + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: ./dist/docs + target-folder: dev + force: false + attempt-limit: 5 + clean: true + clean-exclude: | + pr-preview + pr-preview-react + pr-preview-angular + dev-react + dev-angular + + - name: Deploy React playground + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: ./dist/apps/prs/react + target-folder: dev-react + force: false + attempt-limit: 5 + clean: true + clean-exclude: | + pr-preview + pr-preview-react + pr-preview-angular + dev + dev-angular + + - name: Deploy Angular playground + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: ./dist/apps/prs/angular/browser + target-folder: dev-angular + force: false + attempt-limit: 5 + clean: true + clean-exclude: | + pr-preview + pr-preview-react + pr-preview-angular + dev + dev-react diff --git a/.github/workflows/release-ci.yml b/.github/workflows/main-release.yml similarity index 92% rename from .github/workflows/release-ci.yml rename to .github/workflows/main-release.yml index a078e103b3..b57307e6cc 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/main-release.yml @@ -1,11 +1,9 @@ -name: Release CI +name: Main Release on: push: branches: - main - - next - - dev # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -48,7 +46,7 @@ jobs: - name: Release libs env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx nx affected --target release --parallel=false --base=${{ steps.last_successful_commit.outputs.base }} @@ -56,9 +54,6 @@ jobs: runs-on: ubuntu-latest needs: build - # this will get changed to main in the future - if: github.ref == 'refs/heads/dev' - steps: - uses: actions/checkout@v5 with: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 46f8ffdaca..616a2abe13 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - - next - dev jobs: diff --git a/.releaserc.json b/.releaserc.json index 0def22f89e..5bd376007d 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -3,10 +3,6 @@ "branches": [ "+([0-9])?(.{+([0-9]),x}).x", "main", - { - "name": "next", - "prerelease": true - }, { "name": "dev", "prerelease": true diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index 280b854410..a82e87a40f 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -1,78 +1,102 @@ @if (!isFullPage) { -
-
- - -
+ + + + + + + -
- - - + + + + @for (route of bugRouteDefinitions; track route.path) { + [label]="route.id + ' - ' + route.title" + [url]="baseHref + route.path" + /> + } + + + @for (route of docsRouteDefinitions; track route.path) { + + } + + + @for (route of featureRouteDefinitions; track route.path) { - + [label]="route.id + ' - ' + route.title" + [url]="baseHref + route.path" + /> + } + + - - - - @for (route of bugRouteDefinitions; track route.path) { - - } - - - @for (route of docsRouteDefinitions; track route.path) { - - } - - - @for (route of featureRouteDefinitions; track route.path) { - - } - - -
- -
+ + + + + + + + + + Cancel + Save + + + + + + @for (n of pushDrawerParagraphs; track n) { +

+ Paragraph {{ n }} — Push Drawer V2 now uses goa-scroll-panel internally. Verify + header sticky + actions sticky + content scrolls + outer chrome morphs + (margins/radius/height) as scroll state changes between top / middle / bottom. +

+ } +
+
+ + +
+
-
- -
-
+ } @if (isFullPage) { - + } diff --git a/apps/prs/angular/src/app/app.component.ts b/apps/prs/angular/src/app/app.component.ts index 594c670822..4bcf9f53ae 100644 --- a/apps/prs/angular/src/app/app.component.ts +++ b/apps/prs/angular/src/app/app.component.ts @@ -5,12 +5,16 @@ import { filter } from "rxjs/operators"; import { GoabAppFooter, GoabAppHeader, - GoabMicrositeHeader, + GoabButton, + GoabButtonGroup, + GoabPushDrawer, GoabThemeService, GoabWorkSideMenu, GoabWorkSideMenuItem, GoabWorkSideMenuGroup, + GoabWorkspaceLayout, } from "@abgov/angular-components"; +import { PushDrawerHostService } from "../routes/features/feat3347PushDrawer/push-drawer-host.service"; import { bugRouteDefinitions, docsRouteDefinitions, @@ -34,37 +38,55 @@ const TOKEN_TOGGLE_URL = "#tokens"; RouterOutlet, GoabAppFooter, GoabAppHeader, - GoabMicrositeHeader, + GoabButton, + GoabButtonGroup, + GoabPushDrawer, GoabWorkSideMenu, GoabWorkSideMenuItem, GoabWorkSideMenuGroup, + GoabWorkspaceLayout, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class AppComponent { isFullPage = false; - readonly workSideMenuHeight = "calc(100vh - 10.1875rem)"; + isPushDrawerRoute = false; readonly bugRouteDefinitions = bugRouteDefinitions; readonly featureRouteDefinitions = featureRouteDefinitions; readonly docsRouteDefinitions = docsRouteDefinitions; readonly baseHref = inject(LocationStrategy).getBaseHref(); readonly tokenToggleUrl = TOKEN_TOGGLE_URL; tokenMode: TokenVersion = resolveTokenVersion(); + readonly pushDrawerParagraphs = Array.from({ length: 30 }, (_, i) => i + 1); private fullPageRoutes = ["/features/2885"]; private router = inject(Router); readonly theme = inject(GoabThemeService); + private pushDrawerHost = inject(PushDrawerHostService); + readonly pushDrawerOpen = this.pushDrawerHost.open; constructor() { + const initialUrl = window.location.pathname.startsWith(this.baseHref) + ? "/" + window.location.pathname.slice(this.baseHref.length) + : window.location.pathname; + this.isFullPage = this.fullPageRoutes.includes(initialUrl); + this.isPushDrawerRoute = initialUrl.startsWith("/features/3347-push"); + if (this.isPushDrawerRoute) this.pushDrawerHost.openDrawer(); + this.router.events .pipe(filter((event) => event instanceof NavigationEnd)) .subscribe((event) => { - this.isFullPage = this.fullPageRoutes.includes( - (event as NavigationEnd).urlAfterRedirects, - ); + const url = (event as NavigationEnd).urlAfterRedirects; + this.isFullPage = this.fullPageRoutes.includes(url); + this.isPushDrawerRoute = url.startsWith("/features/3347-push"); + if (this.isPushDrawerRoute) this.pushDrawerHost.openDrawer(); }); } + closePushDrawer() { + this.pushDrawerHost.closeDrawer(); + } + handleNavigate(path: string): void { if (path === TOKEN_TOGGLE_URL) { this.tokenMode = this.tokenMode === "v1" ? "v2" : "v1"; diff --git a/apps/prs/angular/src/routes/bugs/3665/bug3665.component.html b/apps/prs/angular/src/routes/bugs/3665/bug3665.component.html new file mode 100644 index 0000000000..0594d0c865 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3665/bug3665.component.html @@ -0,0 +1,30 @@ +Bug 3665 - Tabs: page jumps when switching tabs + + Switching tabs should not move the viewport. Previously setCurrentTab() called el.focus() + on the selected tab, which triggered the browser's scroll-into-view behavior and jumped the + page. Scroll down so the tabs are partially visible, then switch the framework tab. The page + should now stay in place. + + + +
+ Scroll down to the tabs below. +
+ + + +
+ React content. Switch to Angular or Web Components. +
+
+ +
+ Angular content. The page should not jump. +
+
+ +
+ Web Components content. The page should not jump. +
+
+
diff --git a/apps/prs/angular/src/routes/bugs/3665/bug3665.component.ts b/apps/prs/angular/src/routes/bugs/3665/bug3665.component.ts new file mode 100644 index 0000000000..bd2f74e097 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3665/bug3665.component.ts @@ -0,0 +1,10 @@ +import { Component } from "@angular/core"; +import { GoabTab, GoabTabs, GoabText } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3665", + templateUrl: "./bug3665.component.html", + imports: [GoabTab, GoabTabs, GoabText], +}) +export class Bug3665Component {} diff --git a/apps/prs/angular/src/routes/bugs/3665/bug3665.route.json b/apps/prs/angular/src/routes/bugs/3665/bug3665.route.json new file mode 100644 index 0000000000..4c56bccb00 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3665/bug3665.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3665", + "path": "bugs/3665", + "title": "Tabs page jump on switch" +} diff --git a/apps/prs/angular/src/routes/bugs/3741/bug3741.component.html b/apps/prs/angular/src/routes/bugs/3741/bug3741.component.html new file mode 100644 index 0000000000..feb6e01176 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3741/bug3741.component.html @@ -0,0 +1,50 @@ +Bug 3741 - Pagination example not reproducible + + The "Show number of results per page" example uses a spacer with + hSpacing="fill" to push the pagination controls to the right edge. In Angular + the spacer collapsed to zero width and the controls bunched on the left. After + the fix the spacer fills and the pagination sits flush right. + + + + + + First name + Last name + Age + + + + @for (user of pageUsers; track user.id) { + + {{ user.firstName }} + {{ user.lastName }} + {{ user.age }} + + } + + + + + + Show + + + + + + per page + + + + diff --git a/apps/prs/angular/src/routes/bugs/3741/bug3741.component.ts b/apps/prs/angular/src/routes/bugs/3741/bug3741.component.ts new file mode 100644 index 0000000000..0b127d420a --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3741/bug3741.component.ts @@ -0,0 +1,63 @@ +import { Component } from "@angular/core"; +import { + GoabBlock, + GoabDropdown, + GoabDropdownItem, + GoabPagination, + GoabSpacer, + GoabTable, + GoabText, +} from "@abgov/angular-components"; +import { + GoabDropdownOnChangeDetail, + GoabPaginationOnChangeDetail, +} from "@abgov/ui-components-common"; + +interface User { + id: string; + firstName: string; + lastName: string; + age: number; +} + +const FIRST_NAMES = ["Emma", "Liam", "Olivia", "Noah", "Ava", "James", "Sophia", "William", "Isabella", "Oliver"]; +const LAST_NAMES = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez"]; + +@Component({ + standalone: true, + selector: "abgov-bug3741", + templateUrl: "./bug3741.component.html", + imports: [ + GoabBlock, + GoabDropdown, + GoabDropdownItem, + GoabPagination, + GoabSpacer, + GoabTable, + GoabText, + ], +}) +export class Bug3741Component { + users: User[] = Array.from({ length: 100 }, (_, i) => ({ + id: `user-${i + 1}`, + firstName: FIRST_NAMES[i % FIRST_NAMES.length], + lastName: LAST_NAMES[i % LAST_NAMES.length], + age: 20 + ((i + 1) % 40), + })); + page = 1; + perPage = 10; + + get pageUsers(): User[] { + const offset = (this.page - 1) * this.perPage; + return this.users.slice(offset, offset + this.perPage); + } + + handlePageChange(event: GoabPaginationOnChangeDetail) { + this.page = event.page; + } + + handlePerPageCountChangeEvent(event: GoabDropdownOnChangeDetail) { + this.page = 1; + this.perPage = parseInt(event.value || "10"); + } +} diff --git a/apps/prs/angular/src/routes/bugs/3741/bug3741.route.json b/apps/prs/angular/src/routes/bugs/3741/bug3741.route.json new file mode 100644 index 0000000000..0782afcbee --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3741/bug3741.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3741", + "path": "bugs/3741", + "title": "Pagination spacer fill" +} diff --git a/apps/prs/angular/src/routes/bugs/3837/bug3837.component.html b/apps/prs/angular/src/routes/bugs/3837/bug3837.component.html new file mode 100644 index 0000000000..b182289435 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3837/bug3837.component.html @@ -0,0 +1,53 @@ +Bug 3837 - Tabs: label should not shift horizontally when selection changes + + Click between tabs and observe that labels do not shift horizontally. The active tab bold treatment should be preserved. + + +Test 1: String headings (default) + + Short tab content + Longer tab content + Medium tab content + Another tab content + + +Test 2: Similar-length string headings + + Information content + Documents content + History content + + +Test 3: Slot headings +Tabs using the heading slot rather than a string prop. Verify no shift occurs. + + + Short + Short tab content + + + Much longer label + Longer tab content + + + Medium label + Medium tab content + + + +Test 4: Slot headings with badge +Tabs with a badge alongside the label text in the slot. The ghost text only reserves space for the text portion, so a small shift may still occur here. + + + Notifications + Notifications content + + + Much longer label + Longer tab content + + + History + History content + + diff --git a/apps/prs/angular/src/routes/bugs/3837/bug3837.component.ts b/apps/prs/angular/src/routes/bugs/3837/bug3837.component.ts new file mode 100644 index 0000000000..d8b86c81eb --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3837/bug3837.component.ts @@ -0,0 +1,10 @@ +import { Component } from "@angular/core"; +import { GoabBadge, GoabTab, GoabTabs, GoabText } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3837", + templateUrl: "./bug3837.component.html", + imports: [GoabBadge, GoabTab, GoabTabs, GoabText], +}) +export class Bug3837Component {} diff --git a/apps/prs/angular/src/routes/bugs/3837/bug3837.route.json b/apps/prs/angular/src/routes/bugs/3837/bug3837.route.json new file mode 100644 index 0000000000..82e0586a85 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3837/bug3837.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3837", + "path": "bugs/3837", + "title": "Tabs label shift on selection" +} diff --git a/apps/prs/angular/src/routes/bugs/3921/bug3921.component.html b/apps/prs/angular/src/routes/bugs/3921/bug3921.component.html new file mode 100644 index 0000000000..d492a212c2 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3921/bug3921.component.html @@ -0,0 +1,21 @@ +

3921 - Segmented tab dynamic indicator

+

Resize the browser window. The active tab indicator background should follow the selected tab as the layout reflows.

+ +

Many tabs including a long heading

+ + Daily view + Weekly view + Monthly view + Quarterly view + Yearly view + Custom reporting period view + Archived view + Forecast view + + +

Simple three-tab example

+ + Content one + Content two + Content three + diff --git a/apps/prs/angular/src/routes/bugs/3921/bug3921.component.ts b/apps/prs/angular/src/routes/bugs/3921/bug3921.component.ts new file mode 100644 index 0000000000..887e220dd0 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3921/bug3921.component.ts @@ -0,0 +1,10 @@ +import { Component } from "@angular/core"; +import { GoabTab, GoabTabs } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3921", + templateUrl: "./bug3921.component.html", + imports: [GoabTab, GoabTabs], +}) +export class Bug3921Component {} diff --git a/apps/prs/angular/src/routes/bugs/3921/bug3921.route.json b/apps/prs/angular/src/routes/bugs/3921/bug3921.route.json new file mode 100644 index 0000000000..126d8391ce --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3921/bug3921.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3921", + "path": "bugs/3921", + "title": "Segmented tab dynamic indicator" +} diff --git a/apps/prs/angular/src/routes/bugs/3964/bug3964.component.html b/apps/prs/angular/src/routes/bugs/3964/bug3964.component.html new file mode 100644 index 0000000000..260c56dbce --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3964/bug3964.component.html @@ -0,0 +1,71 @@ +

3964 - Input marked as touched when user tabs in (Angular)

+

+ Tab into a field and tab straight back out without typing: the required error should + appear, because the control was entered and then left. Tabbing in alone, or typing + without leaving, should NOT show the error. Before the fix, the error appeared as soon + as the field received focus. +

+ +

Reactive form (FormControl)

+
+ + + + + + + +
+ +
+
lastName.touched
+
{{ form.get("lastName")?.touched }}
+
bio.touched
+
{{ form.get("bio")?.touched }}
+
+ +

Template-driven form (ngModel)

+

Same behavior should hold for the ngModel binding.

+
+ + + + + + + +
+ +
+
ngModelLastName.touched
+
{{ tdLastName.touched }}
+
ngModelBio.touched
+
{{ tdBio.touched }}
+
diff --git a/apps/prs/angular/src/routes/bugs/3964/bug3964.component.ts b/apps/prs/angular/src/routes/bugs/3964/bug3964.component.ts new file mode 100644 index 0000000000..6a32a79fb3 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3964/bug3964.component.ts @@ -0,0 +1,40 @@ +import { Component } from "@angular/core"; +import { + ReactiveFormsModule, + FormsModule, + FormGroup, + FormControl, + Validators, +} from "@angular/forms"; +import { GoabFormItem, GoabInput, GoabTextArea } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug3964", + templateUrl: "./bug3964.component.html", + imports: [ReactiveFormsModule, FormsModule, GoabFormItem, GoabInput, GoabTextArea], +}) +export class Bug3964Component { + form = new FormGroup({ + lastName: new FormControl("", Validators.required), + bio: new FormControl("", Validators.required), + }); + + // Template-driven (ngModel) values + ngModelLastName = ""; + ngModelBio = ""; + + get lastNameError() { + const control = this.form.get("lastName"); + return control && !control.valid && control.touched && control.hasError("required") + ? "Enter a last name." + : undefined; + } + + get bioError() { + const control = this.form.get("bio"); + return control && !control.valid && control.touched && control.hasError("required") + ? "Enter a bio." + : undefined; + } +} diff --git a/apps/prs/angular/src/routes/bugs/3964/bug3964.route.json b/apps/prs/angular/src/routes/bugs/3964/bug3964.route.json new file mode 100644 index 0000000000..820a05d01a --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/3964/bug3964.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "3964", + "path": "bugs/3964", + "title": "Input touched on tab-in (Angular)" +} diff --git a/apps/prs/angular/src/routes/bugs/4077/bug4077.component.html b/apps/prs/angular/src/routes/bugs/4077/bug4077.component.html new file mode 100644 index 0000000000..3907f4de3f --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/4077/bug4077.component.html @@ -0,0 +1,18 @@ +Bug 4077 - Text component missing heading-2xl + + GoabText now supports a size of "heading-2xl", the largest heading size. It should + render at least as large as "heading-xl". The visual size is driven by the + "--goa-typography-heading-2xl" design token and falls back to "heading-xl" until that + token is published. + + +New size +Heading 2XL + + + +Size scale comparison +Heading 2XL +Heading XL +Heading L +Heading M diff --git a/apps/prs/angular/src/routes/bugs/4077/bug4077.component.ts b/apps/prs/angular/src/routes/bugs/4077/bug4077.component.ts new file mode 100644 index 0000000000..993ec10477 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/4077/bug4077.component.ts @@ -0,0 +1,10 @@ +import { Component } from "@angular/core"; +import { GoabDivider, GoabText } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-bug4077", + templateUrl: "./bug4077.component.html", + imports: [GoabDivider, GoabText], +}) +export class Bug4077Component {} diff --git a/apps/prs/angular/src/routes/bugs/4077/bug4077.route.json b/apps/prs/angular/src/routes/bugs/4077/bug4077.route.json new file mode 100644 index 0000000000..d36d610b22 --- /dev/null +++ b/apps/prs/angular/src/routes/bugs/4077/bug4077.route.json @@ -0,0 +1,6 @@ +{ + "type": "bug", + "id": "4077", + "path": "bugs/4077", + "title": "Text missing heading-2xl" +} diff --git a/apps/prs/angular/src/routes/docs/pagination/pagination.component.html b/apps/prs/angular/src/routes/docs/pagination/pagination.component.html index 63ffdb33c3..140bc534f2 100644 --- a/apps/prs/angular/src/routes/docs/pagination/pagination.component.html +++ b/apps/prs/angular/src/routes/docs/pagination/pagination.component.html @@ -43,6 +43,7 @@

Show number of results per page

Show diff --git a/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.component.html b/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.component.html new file mode 100644 index 0000000000..269b259df5 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.component.html @@ -0,0 +1,96 @@ +

Scroll panel

+

+ Mirrors the documentation examples for goab-scroll-panel: a bounded + container with sticky header / footer slots and a scrollable body. +

+ +

Basic scroll panel

+ +
+ Case details +
+
+ +
+ + Cancel + Save changes + +
+
+
+ +
+ @for (n of paragraphs; track n) { +

+ Paragraph {{ n }} — Scrollable body content. The panel will scroll inside its + 480px height, with sticky header and footer pinned. Notice the subtle border + + inset shadow that appears when scrolling indicates more content. +

+ } +
+
+
+ +

Header only

+ +
+ Activity feed +
+
+
+ +
+ @for (n of rows; track n) { +

Row {{ n }} — scroll me, header stays pinned at the top.

+ } +
+
+
+ +

Footer only

+ +
+ + Submit + +
+
+
+ +
+ @for (n of rows; track n) { +

Row {{ n }} — scroll me, footer stays pinned at the bottom.

+ } +
+
+
+ +

Custom height

+

+ The height prop accepts any valid CSS height value (e.g. "600px", "80vh"). + It defaults to "100%" so the panel fills a height-constrained parent. +

+ +
+ Custom 600px height +
+
+ +
+ Footer pinned at bottom +
+
+
+ +
+ @for (n of tallRows; track n) { +

+ Row {{ n }} — Body scrolls within the explicit 600px height. Swap to "80vh" for + viewport-relative sizing, or leave the prop off (defaults to "100%") when a + parent already constrains height. +

+ } +
+
+
diff --git a/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.component.ts b/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.component.ts new file mode 100644 index 0000000000..6f51765026 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.component.ts @@ -0,0 +1,19 @@ +import { Component } from "@angular/core"; +import { + GoabButton, + GoabButtonGroup, + GoabScrollPanel, + GoabText, +} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-docs-scroll-panel", + templateUrl: "./scroll-panel.component.html", + imports: [GoabScrollPanel, GoabText, GoabButton, GoabButtonGroup], +}) +export class DocsScrollPanelComponent { + paragraphs = Array.from({ length: 15 }, (_, i) => i + 1); + rows = Array.from({ length: 12 }, (_, i) => i + 1); + tallRows = Array.from({ length: 20 }, (_, i) => i + 1); +} diff --git a/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.route.json b/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.route.json new file mode 100644 index 0000000000..a334235302 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/scroll-panel/scroll-panel.route.json @@ -0,0 +1,6 @@ +{ + "type": "docs", + "id": "scroll-panel", + "path": "docs/scroll-panel", + "title": "Scroll Panel" +} diff --git a/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout-scroll-monitor.component.ts b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout-scroll-monitor.component.ts new file mode 100644 index 0000000000..a5013fe019 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout-scroll-monitor.component.ts @@ -0,0 +1,66 @@ +import { Component, computed, inject } from "@angular/core"; +import { + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuItem, + GoabWorkspaceLayout, + GoabWorkspaceLayoutScrollStateService, +} from "@abgov/angular-components"; + +/** + * Scroll-state example, isolated in its own component so its + * GoabWorkspaceLayoutScrollStateService instance does not cross-talk with the + * other workspace layouts stacked on the same docs page (the service is normally + * root-scoped, and "most recent update wins"). + */ +@Component({ + standalone: true, + selector: "abgov-docs-wsl-scroll-monitor", + providers: [GoabWorkspaceLayoutScrollStateService], + imports: [GoabWorkspaceLayout, GoabWorkSideMenu, GoabWorkSideMenuItem, GoabText], + template: ` + +
+ @for (n of rows; track n) { +

Case row {{ n }}

+ } +
+
+ + + My cases + @if (!collapsed()) { + Scroll down to collapse this subtitle. + } + + + + + + + + + + `, +}) +export class DocsWorkspaceLayoutScrollMonitorComponent { + rows = Array.from({ length: 20 }, (_, i) => i + 1); + + private scrollState = inject(GoabWorkspaceLayoutScrollStateService); + + // Collapse once the user scrolls past the top. + collapsed = computed(() => { + const pos = this.scrollState.scrollPosition(); + return pos === "middle" || pos === "at-bottom"; + }); +} diff --git a/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.component.html b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.component.html new file mode 100644 index 0000000000..23ac3c9df9 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.component.html @@ -0,0 +1,135 @@ +

Workspace layout

+

+ Mirrors the documentation examples for goab-workspace-layout. Each + example is bounded to 600px so they stack on this page; in a real app the layout + fills the viewport. +

+ +

Basic workspace

+
+ +
+ @for (n of rows; track n) { +

Case row {{ n }} — scroll this area while the side menu stays in place.

+ } +
+
+
+ + + + + + + + + +

Sticky header

+
+ +
+ @for (n of rows; track n) { +

Case row {{ n }} — scroll this area and the header stays pinned at the top.

+ } +
+
+
+ + + + + + + + +
+ My cases + + Filter + New case + +
+
+ +

Sticky footer

+
+ +
+ @for (n of rows; track n) { +

Case row {{ n }} — scroll this area and the footer stays pinned at the bottom.

+ } +
+
+
+ + + + + + + + +
+ 3 cases selected + + Clear + Export + +
+
+ +

Push drawer

+
+ +
+ Open + @for (n of rows; track n) { +

+ Case row {{ n }} — open the drawer and it appears beside this content, + narrowing it instead of covering it. +

+ } +
+
+
+ + + + + + + + + +

Details for the selected case appear here, beside the page content.

+
+
+ +

Monitor scroll state

+

+ The page header collapses its subtitle once content scrolls past the top, via + GoabWorkspaceLayoutScrollStateService. +

+
+ +
diff --git a/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.component.ts b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.component.ts new file mode 100644 index 0000000000..75c4e35cc9 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.component.ts @@ -0,0 +1,52 @@ +import { Component } from "@angular/core"; +import { + GoabButton, + GoabButtonGroup, + GoabPushDrawer, + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuItem, + GoabWorkspaceLayout, +} from "@abgov/angular-components"; +import { DocsWorkspaceLayoutScrollMonitorComponent } from "./workspace-layout-scroll-monitor.component"; + +@Component({ + standalone: true, + selector: "abgov-docs-workspace-layout", + templateUrl: "./workspace-layout.component.html", + imports: [ + GoabWorkspaceLayout, + GoabWorkSideMenu, + GoabWorkSideMenuItem, + GoabText, + GoabButton, + GoabButtonGroup, + GoabPushDrawer, + DocsWorkspaceLayoutScrollMonitorComponent, + ], + styles: [ + ` + .wsl-demo { + margin-bottom: var(--goa-space-2xl, 3rem); + } + /* goab-workspace-layout fills the viewport (100vh) by default. Bound each + demo so they stack on this page; !important overrides the web + component's :host height rule. */ + :host ::ng-deep goab-workspace-layout, + :host ::ng-deep abgov-docs-wsl-scroll-monitor { + display: block; + } + :host ::ng-deep goa-workspace-layout { + height: 600px !important; + } + `, + ], +}) +export class DocsWorkspaceLayoutComponent { + rows = Array.from({ length: 16 }, (_, i) => i + 1); + open = false; + + handleNavigate(path: string): void { + console.log("navigate", path); + } +} diff --git a/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.route.json b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.route.json new file mode 100644 index 0000000000..b068b96d97 --- /dev/null +++ b/apps/prs/angular/src/routes/docs/workspace-layout/workspace-layout.route.json @@ -0,0 +1,10 @@ +{ + "type": "docs", + "id": "workspace-layout", + "path": "docs/workspace-layout", + "title": "Workspace Layout" +} + + + + diff --git a/apps/prs/angular/src/routes/features/feat3347/feat3347.component.css b/apps/prs/angular/src/routes/features/feat3347/feat3347.component.css new file mode 100644 index 0000000000..9331be9010 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347/feat3347.component.css @@ -0,0 +1,55 @@ +.demo-header, +.ws-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--goa-space-m); + padding: var(--goa-space-m) var(--goa-space-l); +} + +.ws-header__actions { + display: flex; + gap: var(--goa-space-s); +} + +.demo-body, +.ws-body { + padding: var(--goa-space-m) var(--goa-space-l); +} + +.ws-row { + padding: var(--goa-space-s) 0; + border-bottom: var(--goa-border-width-s) solid var(--goa-color-greyscale-150); +} + +.ws-row__title { + font-weight: var(--goa-font-weight-bold); +} + +.ws-row__meta { + color: var(--goa-color-text-secondary); + font-size: var(--goa-font-size-2); +} + +.demo-footer, +.ws-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--goa-space-m); + padding: var(--goa-space-s) var(--goa-space-l); +} + +.ws-footer__count { + color: var(--goa-color-text-secondary); +} + +/* Test 6: bound the notification panel's width (panel itself is 710px tall by + default) and frame it so it visually stands apart from the surrounding page + content. */ +.notification-panel-stage { + width: 360px; + border: var(--goa-border-width-s) solid var(--goa-color-greyscale-200); + border-radius: var(--goa-popover-border-radius); + overflow: hidden; +} diff --git a/apps/prs/angular/src/routes/features/feat3347/feat3347.component.html b/apps/prs/angular/src/routes/features/feat3347/feat3347.component.html new file mode 100644 index 0000000000..a1a3ace28f --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347/feat3347.component.html @@ -0,0 +1,323 @@ +

#3347 Scroll Panel — Workspace shell evaluation

+

Test 1: Bounded panel (the use case it's built for)

+

+ Fixed height 480px. Header + scrollable body + footer. This is where the component + shines. +

+ + +
+
+ Case details + + + + +
+ Close +
+
+ + + + + +
+ @for (n of paragraphs; track n) { +

+ Paragraph {{ n }} — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed + do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. +

+ } +
+
+ + + + + + +

Test 2: Header only / Footer only

+

Verifying optional template slots render correctly.

+ + +
+ Header only — pinned at top +
+
+ + + + + + +
+ @for (n of paragraphs; track n) { +

Row {{ n }} — scroll me, header stays pinned.

+ } +
+
+ + +
+ @for (n of paragraphs; track n) { +

Row {{ n }} — scroll me, footer stays pinned.

+ } +
+
+
+ + + +

Test 3: Default height (100%) inside a constrained parent

+

Parent gives the height context — scroll panel fills it.

+ + +
+ Inherits parent height +
+
+ + + + +
+ +
+ @for (n of paragraphs; track n) { +

Row {{ n }} — body scrolls within the parent's 380px.

+ } +
+
+
+ + + +

Test 4: Drawer V2 (refactored internally with goa-scroll-panel)

+

+ Verifies Drawer V2 still works after its internal scroll mechanics were replaced with + <goa-scroll-panel>. Open and scroll inside — header should stay + pinned at top (with border on scroll), actions footer should stay pinned at bottom (with + border when content overflows above). +

+ + + + Cancel + Save + + + +Open drawer + + + @for (n of paragraphs; track n) { +

+ Paragraph {{ n }} — Drawer V2 now uses goa-scroll-panel internally. Verify header + sticky + actions sticky + content scrolls + borders appear when scrolled + (top/middle/bottom states). +

+ } +
+ + + +

+ Push Drawer V2 also got the scroll-panel treatment — see the dedicated full-page test at + features/3347-push (linked in the side menu as + 3347-push - Scroll Panel - Push Drawer V2) so the drawer's internal + scroll can be verified without page scroll bubbling. +

+ + + + + + +

Test 5: Modal (refactored internally with goa-scroll-panel)

+

+ Verifies Modal still works after its V2 path was refactored to use + <goa-scroll-panel> instead of <goa-scrollable>. V1 + path is unchanged (still uses goa-scrollable + maxheight calc). The + GoabModal React/Angular wrapper hardcodes version="2" + so this test exercises the new path. Open and scroll inside — header should stay pinned + at top with border on scroll, action footer pinned at bottom with border when content + overflows above. +

+ + + + Cancel + Save + + + +Open modal + + + @for (n of paragraphs; track n) { +

+ Paragraph {{ n }} — Modal V2 now uses goa-scroll-panel internally. Verify header + sticky + actions sticky + content scrolls + borders appear when scrolled + (top/middle/bottom states). The pane should remain centered with a 64px gap from + viewport edges. +

+ } +
+ + + + + + +

Test 6: WorkSideNotificationPanel (refactored to use goa-scroll-panel)

+

+ Verifies the notification panel still works after its layout was refactored to use + <goa-scroll-panel> internally (header slot for title + tabs, + default slot for the scrollable notification list, footer slot for "View all" + / "Mark all as read" actions). Rendered inline here (normally lives inside a + popover triggered from a work-side-menu icon). Scroll inside the notification + list to verify sticky header + sticky footer + border feedback on scroll. +

+ +
+ + @for (notif of notifications; track trackById($index, notif)) { + + } + +
+ + + + + + +

Validator case 1: valid calc() height (no error)

+

+ Passes height="calc(20rem + 2rem)". The old regex validator + rejected calc(), so this used to log a false console error. With CSS.supports + it is accepted: no error logged, and the panel scrolls within its explicit + height. Scroll to the bottom and keep scrolling with the pointer over the + panel, the page scrolls next (no scroll trap). +

+ + +
+ Valid calc() height +
+
+ + +
+ @for (n of paragraphs; track n) { +

Row {{ n }} — body content inside a calc() height panel.

+ } +
+
+ + + + + + +

Validator case 2: invalid height (errors, falls back to 100%)

+

+ Passes height="not-a-height". Open the console: it logs a + ScrollPanel height error. Instead of collapsing to content height (an + unscrollable block that swallowed page scroll), the host now falls back to + height: 100%, so inside this 320px parent the panel is bounded and + scrolls normally. +

+ + +
+ Invalid height (fallback) +
+
+ +
+ +
+ @for (n of paragraphs; track n) { +

Row {{ n }} — body content inside an invalid-height panel.

+ } +
+
+
diff --git a/apps/prs/angular/src/routes/features/feat3347/feat3347.component.ts b/apps/prs/angular/src/routes/features/feat3347/feat3347.component.ts new file mode 100644 index 0000000000..ccbc48465c --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347/feat3347.component.ts @@ -0,0 +1,197 @@ +import { Component } from "@angular/core"; +import { + GoabBlock, + GoabButton, + GoabButtonGroup, + GoabDivider, + GoabDrawer, + GoabIconButton, + GoabModal, + GoabScrollPanel, + GoabText, + GoabTooltip, + GoabWorkSideNotificationItem, + GoabWorkSideNotificationPanel, +} from "@abgov/angular-components"; + +type NotificationData = { + id: string; + title: string; + description: string; + timestamp: string; + type: "info" | "success" | "warning" | "critical"; + readStatus: "read" | "unread"; + priority: "normal" | "urgent"; +}; + +@Component({ + standalone: true, + selector: "abgov-feat3347", + templateUrl: "./feat3347.component.html", + styleUrls: ["./feat3347.component.css"], + imports: [ + GoabBlock, + GoabButton, + GoabButtonGroup, + GoabDivider, + GoabDrawer, + GoabIconButton, + GoabModal, + GoabScrollPanel, + GoabText, + GoabTooltip, + GoabWorkSideNotificationItem, + GoabWorkSideNotificationPanel, + ], +}) +export class Feat3347Component { + paragraphs = Array.from({ length: 25 }, (_, i) => i + 1); + selectedCount = 0; + drawerOpen = false; + modalOpen = false; + + toggleSelection() { + this.selectedCount = this.selectedCount > 0 ? 0 : 3; + } + + openDrawer() { + this.drawerOpen = true; + } + + closeDrawer() { + this.drawerOpen = false; + } + + openModal() { + this.modalOpen = true; + } + + closeModal() { + this.modalOpen = false; + } + + // Notification panel test data — enough items to trigger scroll inside the + // panel's 710px height so the scroll-panel sticky borders can be verified. + notifications: NotificationData[] = [ + { + id: "1", + title: "New case assigned", + description: "Case #12345 has been assigned to you for review.", + timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + type: "info", + readStatus: "unread", + priority: "normal", + }, + { + id: "2", + title: "Document uploaded", + description: "A new document was uploaded to Case #12340.", + timestamp: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + type: "success", + readStatus: "unread", + priority: "normal", + }, + { + id: "3", + title: "System maintenance", + description: "Scheduled maintenance tonight at 11 PM MST.", + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + type: "critical", + readStatus: "unread", + priority: "urgent", + }, + { + id: "4", + title: "Action required", + description: "Please review the pending approval request.", + timestamp: this.daysAgo(1, 2), + type: "warning", + readStatus: "unread", + priority: "normal", + }, + { + id: "5", + title: "Deadline approaching", + description: "Case #12300 deadline is in 24 hours.", + timestamp: this.daysAgo(1, 5), + type: "warning", + readStatus: "unread", + priority: "urgent", + }, + { + id: "6", + title: "Comment added", + description: "John Smith commented on Case #12342.", + timestamp: this.daysAgo(1, 8), + type: "info", + readStatus: "unread", + priority: "normal", + }, + { + id: "7", + title: "Case status updated", + description: "Case #12339 status changed to 'In Review'.", + timestamp: this.daysAgo(3, 2), + type: "success", + readStatus: "read", + priority: "normal", + }, + { + id: "8", + title: "New attachment", + description: "PDF document attached to Case #12338.", + timestamp: this.daysAgo(3, 6), + type: "info", + readStatus: "read", + priority: "normal", + }, + { + id: "9", + title: "Weekly report generated", + description: "Your weekly summary report is ready.", + timestamp: this.daysAgo(7, 0), + type: "info", + readStatus: "read", + priority: "normal", + }, + { + id: "10", + title: "Password reminder", + description: "Your password will expire in 30 days.", + timestamp: this.daysAgo(14, 0), + type: "warning", + readStatus: "read", + priority: "normal", + }, + ]; + + trackById(_index: number, item: NotificationData): string { + return item.id; + } + + daysAgo(days: number, hours = 0): string { + const date = new Date(); + date.setDate(date.getDate() - days); + date.setHours(date.getHours() - hours); + return date.toISOString(); + } + + handleNotificationClick(id: string) { + this.notifications = this.notifications.map((notif) => + notif.id === id && notif.readStatus === "unread" + ? { ...notif, readStatus: "read" as const } + : notif, + ); + } + + handleMarkAllRead() { + this.notifications = this.notifications.map((notif) => ({ + ...notif, + readStatus: "read" as const, + })); + } + + handleViewAll() { + console.log("View all notifications"); + } +} diff --git a/apps/prs/angular/src/routes/features/feat3347/feat3347.route.json b/apps/prs/angular/src/routes/features/feat3347/feat3347.route.json new file mode 100644 index 0000000000..754b6b6493 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347/feat3347.route.json @@ -0,0 +1,6 @@ +{ + "title": "Scroll Panel", + "path": "features/3347", + "id": "3347", + "type": "feature" +} diff --git a/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.component.html b/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.component.html new file mode 100644 index 0000000000..523656898e --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.component.html @@ -0,0 +1,27 @@ +

Push Drawer V2 — scroll-panel integration test

+

+ The drawer on the right is rendered at the app shell level (the + pushDrawer slot of goab-workspace-layout in + app.component.html), not inside this page. Scroll inside it and watch the + outer chrome morph: +

+
    +
  • + No scroll: full chrome (16px margin all sides, all corners + rounded). +
  • +
  • + At top: bottom flush (margin-bottom: 0, bottom corners square). +
  • +
  • + Middle: both edges flush, all corners square, header + footer have + border. +
  • +
  • At bottom: top flush (margin-top: 0, top corners square).
  • +
+

+ The heading "Push Drawer V2 test" should also collapse from heading-s → + heading-xs when scrolled past top. +

+ +Open push drawer diff --git a/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.component.ts b/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.component.ts new file mode 100644 index 0000000000..2fb7c53d5a --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.component.ts @@ -0,0 +1,17 @@ +import { Component, inject } from "@angular/core"; +import { GoabButton } from "@abgov/angular-components"; +import { PushDrawerHostService } from "./push-drawer-host.service"; + +@Component({ + standalone: true, + selector: "abgov-feat3347-push", + templateUrl: "./feat3347PushDrawer.component.html", + imports: [GoabButton], +}) +export class Feat3347PushDrawerComponent { + private pushDrawerHost = inject(PushDrawerHostService); + + openPushDrawer() { + this.pushDrawerHost.openDrawer(); + } +} diff --git a/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.route.json b/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.route.json new file mode 100644 index 0000000000..ab0a392265 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347PushDrawer/feat3347PushDrawer.route.json @@ -0,0 +1,6 @@ +{ + "title": "Scroll Panel - Push Drawer V2", + "path": "features/3347-push", + "id": "3347-push", + "type": "feature" +} diff --git a/apps/prs/angular/src/routes/features/feat3347PushDrawer/push-drawer-host.service.ts b/apps/prs/angular/src/routes/features/feat3347PushDrawer/push-drawer-host.service.ts new file mode 100644 index 0000000000..19a1a5aa13 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3347PushDrawer/push-drawer-host.service.ts @@ -0,0 +1,19 @@ +import { Injectable, signal } from "@angular/core"; + +/** + * Shared state for the push drawer that lives in the app shell. Mirrors the + * React playground pattern where the drawer is rendered at the workspace + * layout level and the route just triggers open via outlet context. + */ +@Injectable({ providedIn: "root" }) +export class PushDrawerHostService { + readonly open = signal(false); + + openDrawer() { + this.open.set(true); + } + + closeDrawer() { + this.open.set(false); + } +} diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index 04a769a9bf..4e92abd6eb 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -1,21 +1,23 @@ -import { useState, type CSSProperties } from "react"; -import { Outlet, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { - GoabAppFooter, - GoabAppHeader, - GoabMicrositeHeader, - GoabOneColumnLayout, + GoabButton, + GoabButtonGroup, + GoabPushDrawer, + GoabText, GoabWorkSideMenu, GoabWorkSideMenuGroup, GoabWorkSideMenuItem, useTheme, + GoabWorkspaceLayout, + useGoabWorkspaceLayoutScrollState, } from "@abgov/react-components"; +import { GoabWorkspaceLayoutScrollState } from "@abgov/ui-components-common"; import { bugRouteDefinitions, docsRouteDefinitions, featureRouteDefinitions, } from "./route-manifest"; - import "@abgov/style"; // Runtime V1/V2 token switching. Importing this module applies the currently // selected token set (default V2) to before the app renders. The @@ -27,22 +29,90 @@ import { type TokenVersion, } from "./tokenVersion"; +const PUSH_DRAWER_ROUTE_PATH = "/features/3347-push"; +const pushDrawerTestParagraphs = Array.from({ length: 30 }, (_, i) => i + 1); + +/** Outlet context shape exposed to routes rendered inside App. */ +export interface AppOutletContext { + openPushDrawer: () => void; +} + // Sentinel URL handled by onNavigate below to toggle tokens instead of routing. const TOKEN_TOGGLE_URL = "#tokens"; -const appContentStyle: CSSProperties = { - display: "flex", - minHeight: "calc(100vh - 10.1875rem)", - "--goa-work-side-menu-height": "calc(100vh - 10.1875rem)", -} as CSSProperties; +// Demo: a slotted page-header that shrinks its heading once content scrolls +// under the pinned header. It reads the layout's scroll state via the context +// hook (no prop drilling), using the same middle / at-bottom states that drive +// the scroll cue. The heading size swaps discretely between states. Because it +// reads context, it must be its own component rendered inside the layout, not +// inline JSX created up in App. +function PlaygroundPageHeader() { + const { scrollPosition } = useGoabWorkspaceLayoutScrollState(); + const pinned = + scrollPosition === GoabWorkspaceLayoutScrollState.MIDDLE || + scrollPosition === GoabWorkspaceLayoutScrollState.AT_BOTTOM; + + return ( +
+ + Testing Playground + + + + Secondary + + + Primary action + + +
+ ); +} export function App() { const navigate = useNavigate(); const { mode, toggle } = useTheme(); const isDark = mode === "dark"; + const location = useLocation(); const baseUrl = import.meta.env.BASE_URL; const [tokenMode, setTokenMode] = useState(() => resolveTokenVersion()); + const isPushDrawerRoute = + location.pathname === PUSH_DRAWER_ROUTE_PATH || + location.pathname === `${baseUrl}features/3347-push`; + const [pushDrawerOpen, setPushDrawerOpen] = useState(isPushDrawerRoute); + + // Re-open the drawer each time the user navigates back to the test route so + // closing it once doesn't make the rest of the page useless for the demo. + useEffect(() => { + if (isPushDrawerRoute) setPushDrawerOpen(true); + }, [isPushDrawerRoute]); + + const closePushDrawer = () => setPushDrawerOpen(false); + const pushDrawerActions = ( + + + Save + + + Cancel + + + ); + const handleSideMenuNavigate = (path: string) => { if (path === TOKEN_TOGGLE_URL) { const next: TokenVersion = tokenMode === "v1" ? "v2" : "v1"; @@ -58,78 +128,133 @@ export function App() { navigate(internal); }; - return ( - -
- - -
-
- + const sideMenu = ( + + + + + } + primaryContent={ + <> + + {bugRouteDefinitions.map((route) => ( + ))} + + + + {featureRouteDefinitions.map((route) => ( - - } - primaryContent={ - <> - - {bugRouteDefinitions.map((route) => ( - - ))} - - - - {featureRouteDefinitions.map((route) => ( - - ))} - - - {docsRouteDefinitions.map((route) => ( - - ))} - + ))} + + + {docsRouteDefinitions.map((route) => ( - + ))} + + + + } + /> + ); + + // Sticky page-header slot. This slot is for contextual page-level content + // (page heading, action bars), not the app chrome. See PlaygroundPageHeader, + // which also demos shrinking the heading on scroll via the scroll-state hook. + const pageHeader = ; + // Sticky page-footer slot. Like the header, this is for contextual content + // (e.g. a bulk-action bar that appears when rows are selected), not the app + // footer. GoabAppFooter (with the GoA logo) was the wrong mental model here. + const pageFooter = ( +
+ + 3 rows selected + + + + Clear selection + + + Export selected + + +
+ ); + + const pushDrawer = isPushDrawerRoute || pushDrawerOpen ? ( + + {pushDrawerTestParagraphs.map((n) => ( +

+ Paragraph {n} — Push Drawer V2 now uses goa-scroll-panel internally. Verify + header sticky + actions sticky + content scrolls + outer chrome morphs + (margins/radius/height) as scroll state changes between top / middle / bottom. +

+ ))} +
+ ) : undefined; + + return ( + +
+ setPushDrawerOpen(true), + } satisfies AppOutletContext } /> -
- -
-
- -
- +
); } diff --git a/apps/prs/react/src/app/routes/bugs/bug3665.route.ts b/apps/prs/react/src/app/routes/bugs/bug3665.route.ts new file mode 100644 index 0000000000..94dbd849c2 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3665.route.ts @@ -0,0 +1,10 @@ +import { Bug3665Route } from "../../../routes/bugs/bug3665"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3665", + path: "bugs/3665", + title: "Tabs page jump on switch", + component: Bug3665Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/bugs/bug3741.route.ts b/apps/prs/react/src/app/routes/bugs/bug3741.route.ts new file mode 100644 index 0000000000..a641f9fade --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3741.route.ts @@ -0,0 +1,10 @@ +import { Bug3741Route } from "../../../routes/bugs/bug3741"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3741", + path: "bugs/3741", + title: "Pagination spacer fill", + component: Bug3741Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/bugs/bug3837.route.ts b/apps/prs/react/src/app/routes/bugs/bug3837.route.ts new file mode 100644 index 0000000000..d63ff79179 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3837.route.ts @@ -0,0 +1,10 @@ +import { Bug3837Route } from "../../../routes/bugs/bug3837"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3837", + path: "bugs/3837", + title: "Tabs label shift on selection", + component: Bug3837Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/bugs/bug3921.route.ts b/apps/prs/react/src/app/routes/bugs/bug3921.route.ts new file mode 100644 index 0000000000..2a2c3846f3 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug3921.route.ts @@ -0,0 +1,10 @@ +import { Bug3921Route } from "../../../routes/bugs/bug3921"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "3921", + path: "bugs/3921", + title: "Segmented tab dynamic indicator", + component: Bug3921Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/bugs/bug4077.route.ts b/apps/prs/react/src/app/routes/bugs/bug4077.route.ts new file mode 100644 index 0000000000..9b07d25ac1 --- /dev/null +++ b/apps/prs/react/src/app/routes/bugs/bug4077.route.ts @@ -0,0 +1,10 @@ +import { Bug4077Route } from "../../../routes/bugs/bug4077"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "bug", + id: "4077", + path: "bugs/4077", + title: "Text missing heading-2xl", + component: Bug4077Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/docs/scroll-panel.route.ts b/apps/prs/react/src/app/routes/docs/scroll-panel.route.ts new file mode 100644 index 0000000000..15c306238a --- /dev/null +++ b/apps/prs/react/src/app/routes/docs/scroll-panel.route.ts @@ -0,0 +1,10 @@ +import { DocsScrollPanelRoute } from "../../../routes/docs/scroll-panel/scroll-panel"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "docs", + id: "scroll-panel", + path: "docs/scroll-panel", + title: "Scroll Panel", + component: DocsScrollPanelRoute, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/docs/workspace-layout.route.ts b/apps/prs/react/src/app/routes/docs/workspace-layout.route.ts new file mode 100644 index 0000000000..596771f34a --- /dev/null +++ b/apps/prs/react/src/app/routes/docs/workspace-layout.route.ts @@ -0,0 +1,10 @@ +import { DocsWorkspaceLayoutRoute } from "../../../routes/docs/workspace-layout/workspace-layout"; +import type { PrRouteDefinition } from "../../route-manifest"; + +export default { + type: "docs", + id: "workspace-layout", + path: "docs/workspace-layout", + title: "Workspace Layout", + component: DocsWorkspaceLayoutRoute, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/features/feat3347.route.ts b/apps/prs/react/src/app/routes/features/feat3347.route.ts new file mode 100644 index 0000000000..42369f416f --- /dev/null +++ b/apps/prs/react/src/app/routes/features/feat3347.route.ts @@ -0,0 +1,9 @@ +import { Feat3347Route } from "../../../routes/features/feat3347"; +import type { PrRouteDefinition } from "../../route-manifest"; +export default { + type: "feature", + id: "3347", + path: "features/3347", + title: "Scroll Panel", + component: Feat3347Route, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/app/routes/features/feat3347PushDrawer.route.ts b/apps/prs/react/src/app/routes/features/feat3347PushDrawer.route.ts new file mode 100644 index 0000000000..302093f9bb --- /dev/null +++ b/apps/prs/react/src/app/routes/features/feat3347PushDrawer.route.ts @@ -0,0 +1,9 @@ +import { Feat3347PushDrawerRoute } from "../../../routes/features/feat3347PushDrawer"; +import type { PrRouteDefinition } from "../../route-manifest"; +export default { + type: "feature", + id: "3347-push", + path: "features/3347-push", + title: "Scroll Panel - Push Drawer V2", + component: Feat3347PushDrawerRoute, +} satisfies PrRouteDefinition; diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index 7cc14365be..8849490743 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -20,7 +20,6 @@ root.render( } /> {prRouteDefinitions.map((route) => { const Component = route.component; - return } />; })} diff --git a/apps/prs/react/src/routes/bugs/bug3665.tsx b/apps/prs/react/src/routes/bugs/bug3665.tsx new file mode 100644 index 0000000000..f081b8f69d --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3665.tsx @@ -0,0 +1,49 @@ +import { GoabTab, GoabTabs, GoabText } from "@abgov/react-components"; + +export function Bug3665Route() { + return ( +
+ + Bug #3665: Tabs page jumps when switching tabs + + + Switching tabs should not move the viewport. Previously, setCurrentTab() called + el.focus() on the selected tab, which triggered the browser's scroll-into-view + behavior and jumped the page. To reproduce the old behavior: scroll down so the + tabs are partially visible, then switch the framework tab. The page should now + stay in place. + + + {/* Tall spacer so the tabs sit below the fold and a jump would be obvious. */} +
+ Scroll down to the tabs below. +
+ + + +
+ React content. Switch to Angular or Web Components. +
+
+ +
+ Angular content. The page should not jump. +
+
+ +
+ Web Components content. The page should not jump. +
+
+
+
+ ); +} + +export default Bug3665Route; diff --git a/apps/prs/react/src/routes/bugs/bug3741.tsx b/apps/prs/react/src/routes/bugs/bug3741.tsx new file mode 100644 index 0000000000..7f90812d77 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3741.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { + GoabBlock, + GoabDropdown, + GoabDropdownItem, + GoabPagination, + GoabSpacer, + GoabTable, + GoabText, +} from "@abgov/react-components"; +import type { GoabDropdownOnChangeDetail } from "@abgov/ui-components-common"; + +interface User { + id: string; + firstName: string; + lastName: string; + age: number; +} + +const FIRST_NAMES = ["Emma", "Liam", "Olivia", "Noah", "Ava", "James", "Sophia", "William", "Isabella", "Oliver"]; +const LAST_NAMES = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez"]; + +const USERS: User[] = Array.from({ length: 100 }, (_, i) => ({ + id: `user-${i + 1}`, + firstName: FIRST_NAMES[i % FIRST_NAMES.length], + lastName: LAST_NAMES[i % LAST_NAMES.length], + age: 20 + ((i + 1) % 40), +})); + +export function Bug3741Route() { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + + const offset = (page - 1) * perPage; + const pageUsers = USERS.slice(offset, offset + perPage); + + function handlePerPageCountChangeEvent(event: GoabDropdownOnChangeDetail) { + setPage(1); + setPerPage(parseInt(event.value || "10")); + } + + return ( +
+ + Bug #3741: Pagination example not reproducible + + + The "Show number of results per page" example uses a spacer with + hSpacing="fill" to push the pagination to the right edge. React always + rendered this correctly; this page is the control matching the Angular + fix (bugs/3741). + + + + + + First name + Last name + Age + + + + {pageUsers.map((u) => ( + + {u.firstName} + {u.lastName} + {u.age} + + ))} + + + + + + Show + + + + + + per page + + + setPage(event.page)} + /> + +
+ ); +} + +export default Bug3741Route; diff --git a/apps/prs/react/src/routes/bugs/bug3837.tsx b/apps/prs/react/src/routes/bugs/bug3837.tsx new file mode 100644 index 0000000000..e3eeb4a41a --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3837.tsx @@ -0,0 +1,112 @@ +import { + GoabBlock, + GoabText, + GoabDivider, + GoabDetails, + GoabLink, + GoabTabs, + GoabTab, + GoabBadge, +} from "@abgov/react-components"; + +export function Bug3837Route() { + return ( +
+ + Bug #3837: Tabs label should not shift horizontally when selection changes + + + + + + View on GitHub + + + + + + When switching between tabs, the tab labels shift horizontally. This is caused by a bold + font weight being applied to the active tab, causing the text to take up more space and + nudging adjacent tabs out of position. + + + + + + + Test Cases + + Test 1: String headings (default) + + Click between tabs and observe that the labels do not shift position. The active tab bold + treatment should be preserved. + + + + Short tab content + + + Longer tab content + + + Medium tab content + + + Another tab content + + + + Test 2: Similar-length string headings + + All tabs have similar-length labels to make any shift more visible. Verify no horizontal + movement when switching tabs. + + + + Information content + + + Documents content + + + History content + + + + Test 3: Slot headings (text only) + + Tabs using the heading slot (ReactNode) rather than a string prop. Verify no shift occurs. + + + Short}> + Short tab content + + Much longer label}> + Longer tab content + + Medium label}> + Medium tab content + + + + Test 4: Slot headings with badge + + Tabs with a badge alongside the label text in the slot. The ghost text only reserves space + for the text portion, so a small shift may still occur here. + + + Notifications }> + Notifications content + + Much longer label }> + Longer tab content + + History}> + History content + + +
+ ); +} + +export default Bug3837Route; diff --git a/apps/prs/react/src/routes/bugs/bug3921.tsx b/apps/prs/react/src/routes/bugs/bug3921.tsx new file mode 100644 index 0000000000..3381b2ccb0 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3921.tsx @@ -0,0 +1,46 @@ +import { GoabTab, GoabTabs, GoabText } from "@abgov/react-components"; + +export function Bug3921Route() { + return ( +
+
+ + 3921 - Segmented tab dynamic indicator + + + Resize the browser window. The active tab indicator background should follow the + selected tab as the layout reflows. + +
+ +
+ + Many tabs including a long heading + + + Daily view + Weekly view + Monthly view + Quarterly view + Yearly view + + Custom reporting period view + + Archived view + Forecast view + +
+ +
+ + Simple three-tab example + + + Content one + Content two + Content three + +
+
+ ); +} diff --git a/apps/prs/react/src/routes/bugs/bug4077.tsx b/apps/prs/react/src/routes/bugs/bug4077.tsx new file mode 100644 index 0000000000..b301d39730 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug4077.tsx @@ -0,0 +1,44 @@ +import { GoabDivider, GoabText } from "@abgov/react-components"; + +export function Bug4077Route() { + return ( +
+ + Bug #4077: Text component missing heading-2xl + + + GoabText now supports a size of "heading-2xl", the largest heading size. It + should render at least as large as "heading-xl". The visual size is driven by + the "--goa-typography-heading-2xl" design token and falls back to "heading-xl" + until that token is published. + + + + New size + + + Heading 2XL + + + + + + Size scale comparison + + + Heading 2XL + + + Heading XL + + + Heading L + + + Heading M + +
+ ); +} + +export default Bug4077Route; diff --git a/apps/prs/react/src/routes/docs/pagination/pagination.tsx b/apps/prs/react/src/routes/docs/pagination/pagination.tsx index 3afa298b5f..c5908bdc4b 100644 --- a/apps/prs/react/src/routes/docs/pagination/pagination.tsx +++ b/apps/prs/react/src/routes/docs/pagination/pagination.tsx @@ -101,6 +101,7 @@ export function DocsPaginationRoute() { onChange={handlePerPageCountChangeEvent} value={perPage.toString()} width="9ch" + size="compact" > diff --git a/apps/prs/react/src/routes/docs/scroll-panel/scroll-panel.tsx b/apps/prs/react/src/routes/docs/scroll-panel/scroll-panel.tsx new file mode 100644 index 0000000000..bf52134f79 --- /dev/null +++ b/apps/prs/react/src/routes/docs/scroll-panel/scroll-panel.tsx @@ -0,0 +1,133 @@ +import { + GoabButton, + GoabButtonGroup, + GoabScrollPanel, + GoabText, +} from "@abgov/react-components"; + +const paragraphs = Array.from({ length: 15 }, (_, i) => i + 1); +const rows = Array.from({ length: 12 }, (_, i) => i + 1); +const tallRows = Array.from({ length: 20 }, (_, i) => i + 1); + +export function DocsScrollPanelRoute() { + return ( +
+

Scroll panel

+

+ Mirrors the documentation examples for GoabScrollPanel: a bounded + container with sticky header / footer slots and a scrollable body. +

+ +

Basic scroll panel

+
+ + + Case details + +
+ } + footer={ +
+ + + Cancel + + + Save changes + + +
+ } + > +
+ {paragraphs.map((n) => ( +

+ Paragraph {n} — Scrollable body content. The panel will scroll inside its + 480px height, with sticky header and footer pinned. Notice the subtle + border + inset shadow that appears when scrolling indicates more content. +

+ ))} +
+ +
+ +

Header only

+
+ + + Activity feed + +
+ } + > +
+ {rows.map((n) => ( +

Row {n} — scroll me, header stays pinned at the top.

+ ))} +
+ +
+ +

Footer only

+
+ + + + Submit + + +
+ } + > +
+ {rows.map((n) => ( +

Row {n} — scroll me, footer stays pinned at the bottom.

+ ))} +
+ +
+ +

Custom height

+

+ The height prop accepts any valid CSS height value (e.g. "600px", + "80vh"). It defaults to "100%" so the panel fills a height-constrained parent. +

+
+ + + Custom 600px height + +
+ } + footer={ +
+ Footer pinned at bottom +
+ } + > +
+ {tallRows.map((n) => ( +

+ Row {n} — Body scrolls within the explicit 600px height. Swap to "80vh" + for viewport-relative sizing, or leave the prop off (defaults to "100%") + when a parent already constrains height. +

+ ))} +
+ + + + ); +} diff --git a/apps/prs/react/src/routes/docs/workspace-layout/workspace-layout.css b/apps/prs/react/src/routes/docs/workspace-layout/workspace-layout.css new file mode 100644 index 0000000000..0b91da8d53 --- /dev/null +++ b/apps/prs/react/src/routes/docs/workspace-layout/workspace-layout.css @@ -0,0 +1,10 @@ +.wsl-demo { + margin-bottom: var(--goa-space-2xl, 3rem); +} + +/* GoabWorkspaceLayout renders at 100vh by default. Bound + each demo so they stack on this page; !important overrides the web component's + :host height rule. */ +.wsl-demo goa-workspace-layout { + height: 600px !important; +} diff --git a/apps/prs/react/src/routes/docs/workspace-layout/workspace-layout.tsx b/apps/prs/react/src/routes/docs/workspace-layout/workspace-layout.tsx new file mode 100644 index 0000000000..daf9552add --- /dev/null +++ b/apps/prs/react/src/routes/docs/workspace-layout/workspace-layout.tsx @@ -0,0 +1,186 @@ +import { useState } from "react"; +import { + GoabButton, + GoabButtonGroup, + GoabPushDrawer, + GoabText, + GoabWorkSideMenu, + GoabWorkSideMenuItem, + GoabWorkspaceLayout, + useGoabWorkspaceLayoutScrollState, +} from "@abgov/react-components"; +import "./workspace-layout.css"; + +const rows = Array.from({ length: 16 }, (_, i) => i + 1); +const scrollRows = Array.from({ length: 20 }, (_, i) => i + 1); + +function navigate(path: string) { + console.log("navigate", path); +} + +const sideMenu = ( + navigate(path)} + primaryContent={ + <> + + + + } + /> +); + +const pageHeader = ( +
+ + My cases + + + + Filter + + + New case + + +
+); + +const pageFooter = ( +
+ + 3 cases selected + + + + Clear + + + Export + + +
+); + +// Rendered inside GoabWorkspaceLayout, so the hook reads that layout's scroll state. +function ScrollAwareHeader() { + const { scrollPosition } = useGoabWorkspaceLayoutScrollState(); + const collapsed = scrollPosition === "middle" || scrollPosition === "at-bottom"; + return ( +
+ + My cases + + {!collapsed && ( + + Scroll down to collapse this subtitle. + + )} +
+ ); +} + +export function DocsWorkspaceLayoutRoute() { + const [open, setOpen] = useState(false); + + return ( +
+

Workspace layout

+

+ Mirrors the documentation examples for GoabWorkspaceLayout. Each + example is bounded to 600px so they stack on this page; in a real app the layout + fills the viewport. +

+ +

Basic workspace

+
+ +
+ {rows.map((n) => ( +

Case row {n} — scroll this area while the side menu stays in place.

+ ))} +
+
+
+ +

Sticky header

+
+ +
+ {rows.map((n) => ( +

Case row {n} — scroll this area and the header stays pinned at the top.

+ ))} +
+
+
+ +

Sticky footer

+
+ +
+ {rows.map((n) => ( +

+ Case row {n} — scroll this area and the footer stays pinned at the bottom. +

+ ))} +
+
+
+ +

Push drawer

+
+ setOpen(false)}> +

Details for the selected case appear here, beside the page content.

+ + } + > +
+ setOpen(true)}> + Open + + {rows.map((n) => ( +

+ Case row {n} — open the drawer and it appears beside this content, narrowing it + instead of covering it. +

+ ))} +
+
+
+ +

Monitor scroll state

+

+ The page header collapses its subtitle once content scrolls past the top, via the{" "} + useGoabWorkspaceLayoutScrollState hook. +

+
+ }> +
+ {scrollRows.map((n) => ( +

Case row {n}

+ ))} +
+
+
+
+ ); +} diff --git a/apps/prs/react/src/routes/features/feat3347.tsx b/apps/prs/react/src/routes/features/feat3347.tsx new file mode 100644 index 0000000000..937c44c909 --- /dev/null +++ b/apps/prs/react/src/routes/features/feat3347.tsx @@ -0,0 +1,597 @@ +/** + * Feature #3347: Scroll Panel — Workspace shell evaluation + * + * React mirror of apps/prs/angular/.../feat3347. Verifies: + * 1. Bounded panel (header + scroll + footer) + * 2. Optional header-only / footer-only slots + * 3. Default height (100%) inside a constrained parent + * 4. Drawer V2 (refactored to use goa-scroll-panel internally) + * 5. Modal V2 (refactored to use goa-scroll-panel internally) + * 6. WorkSideNotificationPanel (refactored to use goa-scroll-panel internally) + * 7. Push Drawer V2 (shell-level drawer that pushes the card) + * + * Push Drawer V2 also has its own full-page route at features/3347-push. + */ + +import { useState, type CSSProperties } from "react"; +import { useOutletContext } from "react-router-dom"; +import { + GoabBlock, + GoabButton, + GoabButtonGroup, + GoabDivider, + GoabDrawer, + GoabIconButton, + GoabModal, + GoabScrollPanel, + GoabText, + GoabTooltip, + GoabWorkSideNotificationItem, + GoabWorkSideNotificationPanel, +} from "@abgov/react-components"; +import type { AppOutletContext } from "../../app/app"; + +type NotificationData = { + id: string; + title: string; + description: string; + timestamp: string; + type: "info" | "success" | "warning" | "critical"; + readStatus: "read" | "unread"; + priority: "normal" | "urgent"; +}; + +const paragraphs = Array.from({ length: 25 }, (_, i) => i + 1); + +function daysAgo(days: number, hours = 0): string { + const date = new Date(); + date.setDate(date.getDate() - days); + date.setHours(date.getHours() - hours); + return date.toISOString(); +} + +const initialNotifications: NotificationData[] = [ + { + id: "1", + title: "New case assigned", + description: "Case #12345 has been assigned to you for review.", + timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + type: "info", + readStatus: "unread", + priority: "normal", + }, + { + id: "2", + title: "Document uploaded", + description: "A new document was uploaded to Case #12340.", + timestamp: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + type: "success", + readStatus: "unread", + priority: "normal", + }, + { + id: "3", + title: "System maintenance", + description: "Scheduled maintenance tonight at 11 PM MST.", + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + type: "critical", + readStatus: "unread", + priority: "urgent", + }, + { + id: "4", + title: "Action required", + description: "Please review the pending approval request.", + timestamp: daysAgo(1, 2), + type: "warning", + readStatus: "unread", + priority: "normal", + }, + { + id: "5", + title: "Deadline approaching", + description: "Case #12300 deadline is in 24 hours.", + timestamp: daysAgo(1, 5), + type: "warning", + readStatus: "unread", + priority: "urgent", + }, + { + id: "6", + title: "Comment added", + description: "John Smith commented on Case #12342.", + timestamp: daysAgo(1, 8), + type: "info", + readStatus: "unread", + priority: "normal", + }, + { + id: "7", + title: "Case status updated", + description: "Case #12339 status changed to 'In Review'.", + timestamp: daysAgo(3, 2), + type: "success", + readStatus: "read", + priority: "normal", + }, + { + id: "8", + title: "New attachment", + description: "PDF document attached to Case #12338.", + timestamp: daysAgo(3, 6), + type: "info", + readStatus: "read", + priority: "normal", + }, + { + id: "9", + title: "Weekly report generated", + description: "Your weekly summary report is ready.", + timestamp: daysAgo(7, 0), + type: "info", + readStatus: "read", + priority: "normal", + }, + { + id: "10", + title: "Password reminder", + description: "Your password will expire in 30 days.", + timestamp: daysAgo(14, 0), + type: "warning", + readStatus: "read", + priority: "normal", + }, +]; + +const demoHeaderStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "var(--goa-space-m)", + padding: "var(--goa-space-m) var(--goa-space-l)", +}; + +const demoBodyStyle: CSSProperties = { + padding: "var(--goa-space-m) var(--goa-space-l)", +}; + +const demoFooterStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "var(--goa-space-m)", + padding: "var(--goa-space-s) var(--goa-space-l)", +}; + +const notificationStageStyle: CSSProperties = { + width: "360px", + border: "var(--goa-border-width-s) solid var(--goa-color-greyscale-200)", + borderRadius: "var(--goa-popover-border-radius)", + overflow: "hidden", +}; + +export function Feat3347Route() { + const [drawerOpen, setDrawerOpen] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [modalTallOpen, setModalTallOpen] = useState(false); + const [notifications, setNotifications] = + useState(initialNotifications); + const { openPushDrawer } = useOutletContext(); + + const handleNotificationClick = (id: string) => { + setNotifications((prev) => + prev.map((n) => + n.id === id && n.readStatus === "unread" + ? { ...n, readStatus: "read" as const } + : n, + ), + ); + }; + + const handleMarkAllRead = () => { + setNotifications((prev) => prev.map((n) => ({ ...n, readStatus: "read" as const }))); + }; + + const handleViewAll = () => { + console.log("View all notifications"); + }; + + // ===== Test 1 templates ===== + const t1Header = ( +
+
+ + Case details + + + + +
+ + Close + +
+ ); + + const t1Footer = ( +
+ + Cancel + Save changes + +
+ ); + + // ===== Test 2 templates ===== + const t2HeaderOnly = ( +
+ + Header only — pinned at top + +
+ ); + + const t2FooterOnly = ( +
+ + + Footer only — pinned at bottom + + +
+ ); + + // ===== Test 3 templates ===== + const t3Header = ( +
+ + Inherits parent height + +
+ ); + + const t3Footer = ( +
+ Footer pinned at the bottom +
+ ); + + // ===== Test 4 (drawer) actions ===== + const t4Actions = ( + + setDrawerOpen(false)}> + Save + + setDrawerOpen(false)}> + Cancel + + + ); + + // ===== Test 5 (modal) actions ===== + const t5Actions = ( + + setModalOpen(false)}> + Cancel + + setModalOpen(false)}> + Save + + + ); + + // ===== Test 5b (modal stress: tall header + wrapped actions) ===== + const t5bActions = ( + + setModalTallOpen(false)}> + Cancel + + setModalTallOpen(false)}> + Save draft + + setModalTallOpen(false)}> + Request changes + + setModalTallOpen(false)}> + Approve and submit + + + ); + + return ( + <> +

#3347 Scroll Panel — Workspace shell evaluation

+ + {/* Test 1 */} +

Test 1: Bounded panel (the use case it's built for)

+

+ Fixed height 480px. Header + scrollable body + footer. This is where the component + shines. +

+ +
+ {paragraphs.map((n) => ( +

+ Paragraph {n} — Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed + do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + ea commodo consequat. +

+ ))} +
+
+ + + + {/* Test 2 */} +

Test 2: Header only / Footer only

+

Verifying optional template slots render correctly.

+ + +
+ {paragraphs.map((n) => ( +

Row {n} — scroll me, header stays pinned.

+ ))} +
+
+ + +
+ {paragraphs.map((n) => ( +

Row {n} — scroll me, footer stays pinned.

+ ))} +
+
+
+ + + + {/* Test 3 */} +

Test 3: Default height (100%) inside a constrained parent

+

Parent gives the height context — scroll panel fills it.

+
+ +
+ {paragraphs.map((n) => ( +

Row {n} — body scrolls within the parent's 380px.

+ ))} +
+
+
+ + + + {/* Test 4 — Drawer V2 */} +

Test 4: Drawer V2 (refactored internally with goa-scroll-panel)

+

+ Verifies Drawer V2 still works after its internal scroll mechanics were replaced + with <goa-scroll-panel>. Open and scroll inside — header should + stay pinned at top (with border on scroll), actions footer should stay pinned at + bottom (with border when content overflows above). +

+ setDrawerOpen(true)}> + Open drawer + + setDrawerOpen(false)} + > + {paragraphs.map((n) => ( +

+ Paragraph {n} — Drawer V2 now uses goa-scroll-panel internally. Verify header + sticky + actions sticky + content scrolls + borders appear when scrolled + (top/middle/bottom states). +

+ ))} +
+ + + +

+ Push Drawer V2 also got the scroll-panel treatment — see the dedicated full-page + test at features/3347-push so the drawer's internal scroll can be + verified without page scroll bubbling. +

+ + + + {/* Test 5 — Modal V2 */} +

Test 5: Modal (refactored internally with goa-scroll-panel)

+

+ Verifies Modal still works after its V2 path was refactored to use + <goa-scroll-panel> instead of{" "} + <goa-scrollable>. V1 path is unchanged (still uses + goa-scrollable + maxheight calc). The GoabModal React/ Angular + wrapper hardcodes version="2" so this test exercises the new path. + Open and scroll inside — header should stay pinned at top with border on scroll, + action footer pinned at bottom with border when content overflows above. +

+ setModalOpen(true)}> + Open modal + + setModalOpen(false)} + > + {paragraphs.map((n) => ( +

+ Paragraph {n} — Modal V2 now uses goa-scroll-panel internally. Verify header + sticky + actions sticky + content scrolls + borders appear when scrolled + (top/middle/bottom states). The pane should remain centered with a 64px gap + from viewport edges. +

+ ))} +
+ + + + {/* Test 5b — Modal stress: tall header + wrapped actions */} +

Test 5b: Modal stress — tall heading + wrapped actions

+

+ Stresses the static max-height: calc(100vh - 160px) on{" "} + .modal-pane. The heading wraps to multiple lines and the + actions wrap to two rows, so the header + footer chrome is tall. The pane + is overflow: hidden, so if the combined chrome ever exceeds + the pane height the chrome clips (rather than the middle scrolling).{" "} + Resize the window short to check whether the header and + footer stay fully visible while only the middle shrinks/scrolls. +

+ setModalTallOpen(true)}> + Open tall-chrome modal + + setModalTallOpen(false)} + > + {paragraphs.map((n) => ( +

+ Paragraph {n} — stress content. Watch the header (multi-line heading) + and the footer (wrapped action buttons): both should stay fully + visible and pinned while only this middle area scrolls. +

+ ))} +
+ + + + {/* Test 6 — WorkSideNotificationPanel */} +

Test 6: WorkSideNotificationPanel (refactored to use goa-scroll-panel)

+

+ Verifies the notification panel still works after its layout was refactored to use{" "} + <goa-scroll-panel> internally (header slot for title + tabs, + default slot for the scrollable notification list, footer slot for "View all" / + "Mark all as read" actions). Rendered inline here (normally lives inside a popover + triggered from a work-side-menu icon). Scroll inside the notification list to + verify sticky header + sticky footer + border feedback on scroll. +

+
+ + {notifications.map((notif) => ( + handleNotificationClick(notif.id)} + /> + ))} + +
+ + + +

Validator case 1: valid calc() height (no error)

+

+ Passes height="calc(20rem + 2rem)". The old regex validator + rejected calc(), so this used to log a false console error. With + CSS.supports it is accepted: no error logged, and the panel scrolls within + its explicit height. Scroll to the bottom and keep scrolling with the + pointer over the panel, the page scrolls next (no scroll trap). +

+ + + + Valid calc() height + + + } + > +
+ {paragraphs.map((n) => ( +

Row {n} — body content inside a calc() height panel.

+ ))} +
+
+ + + +

Validator case 2: invalid height (errors, falls back to 100%)

+

+ Passes height="not-a-height". Open the console: it logs a + ScrollPanel height error. Instead of collapsing to content height (an + unscrollable block that swallowed page scroll), the host now falls back to{" "} + height: 100%, so inside this 320px parent the panel is bounded + and scrolls normally. +

+ +
+ + + Invalid height (fallback) + +
+ } + > +
+ {paragraphs.map((n) => ( +

Row {n} — body content inside an invalid-height panel.

+ ))} +
+ + + + + + {/* Test 7 — Push Drawer (shell-level, pushes the card) */} +

Test 7: Push Drawer (refactored to use goa-scroll-panel)

+

+ Opens the push drawer that lives at the app shell level (a sibling of{" "} + GoabWorkspaceLayout in app.tsx), so it pushes + the whole card aside instead of overlaying it. The drawer body uses{" "} + <goa-scroll-panel> internally: scroll inside it to see + the sticky header/footer and the outer chrome morph across scroll + states. (The same drawer also has its own full-page test at{" "} + features/3347-push.) +

+ + Open push drawer + + + ); +} + +export default Feat3347Route; diff --git a/apps/prs/react/src/routes/features/feat3347PushDrawer.tsx b/apps/prs/react/src/routes/features/feat3347PushDrawer.tsx new file mode 100644 index 0000000000..5587a02960 --- /dev/null +++ b/apps/prs/react/src/routes/features/feat3347PushDrawer.tsx @@ -0,0 +1,43 @@ +import { useOutletContext } from "react-router-dom"; +import { GoabButton } from "@abgov/react-components"; +import type { AppOutletContext } from "../../app/app"; + +export function Feat3347PushDrawerRoute() { + const { openPushDrawer } = useOutletContext(); + + return ( +
+

Push Drawer V2 — scroll-panel integration test

+

+ The drawer on the right is rendered at the app shell level (sibling of{" "} + GoabWorkspaceLayout in app.tsx), not inside this page. + Scroll inside it and watch the outer chrome morph: +

+
    +
  • + No scroll: full chrome (16px margin all sides, all corners + rounded). +
  • +
  • + At top: bottom flush (margin-bottom: 0, bottom corners square). +
  • +
  • + Middle: both edges flush, all corners square, header + footer + have border. +
  • +
  • + At bottom: top flush (margin-top: 0, top corners square). +
  • +
+

+ The heading "Push Drawer V2 test" should also collapse from heading-s{" "} + → heading-xs when scrolled past top. +

+ + Open push drawer + +
+ ); +} + +export default Feat3347PushDrawerRoute; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31c85558ac..2070fd5858 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -150,6 +150,7 @@ relatedComponents: [button-group, icon-button] figmaUrl: https://www.figma.com/... hidden: true # optional — hide from nav (used for subcomponents, deprecated, internal) subcomponent: true # optional — show API on parent component page +hideTabs: [examples] # optional, hide tabs on this page (examples, usage, accessibility); Properties always shows --- ``` @@ -360,6 +361,16 @@ grep -rl "subcomponent: true" docs/src/content/components/ Set `hidden: true` in the component's MDX frontmatter. It won't appear in the nav or component grid, but the page still exists if you visit the URL directly. +### Hide a tab on a component page + +Not every component needs all four tabs (for example, a primitive with no curated examples). Set `hideTabs` in the component's MDX frontmatter to drop specific tabs: + +```yaml +hideTabs: [examples] # any of: examples, usage, accessibility +``` + +Properties always shows (it is the API). The page renders only the remaining tabs. Used on `scroll-panel` to drop its empty Examples tab. + ### Add guidance for a component 1. Create `src/content/guidance/.mdx` diff --git a/docs/generated/component-apis/scroll-panel.json b/docs/generated/component-apis/scroll-panel.json new file mode 100644 index 0000000000..6277decdf3 --- /dev/null +++ b/docs/generated/component-apis/scroll-panel.json @@ -0,0 +1,103 @@ +{ + "componentSlug": "scroll-panel", + "extractedFrom": "libs/web-components/src/components/scroll-panel/ScrollPanel.svelte", + "frameworks": { + "react": { + "props": [ + { + "name": "height", + "type": "string", + "required": false, + "default": null, + "description": "Sets the height of the panel. Accepts any valid CSS height value, including calc()/min()/clamp() and viewport units (e.g. \"400px\", \"100%\", \"100vh\", \"calc(100vh - 4rem)\"). Invalid values fall back to \"100%\". Defaults to \"100%\". The parent element must establish a height context for \"100%\" to resolve." + }, + { + "name": "testId", + "type": "string", + "required": false, + "default": null, + "description": "Sets a data-testid attribute for automated testing." + } + ], + "events": [], + "slots": [ + { + "name": "footer", + "type": "ReactNode", + "description": "Content rendered in the sticky footer region.", + "required": false + }, + { + "name": "header", + "type": "ReactNode", + "description": "Content rendered in the sticky header region.", + "required": false + } + ] + }, + "angular": { + "props": [ + { + "name": "height", + "type": "string", + "required": false, + "default": null, + "description": "Sets the height of the panel. Accepts any valid CSS height value, including calc()/min()/clamp() and viewport units (e.g. \"400px\", \"100%\", \"100vh\", \"calc(100vh - 4rem)\"). Invalid values fall back to \"100%\". Defaults to \"100%\". The parent element must establish a height context for \"100%\" to resolve." + }, + { + "name": "testId", + "type": "string", + "required": false, + "default": null, + "description": "Sets the data-testid attribute for automated testing." + } + ], + "events": [], + "slots": [ + { + "name": "footer", + "type": "TemplateRef", + "description": "Content rendered in the sticky footer region.", + "required": false + }, + { + "name": "header", + "type": "TemplateRef", + "description": "Content rendered in the sticky header region.", + "required": false + } + ] + }, + "webComponents": { + "props": [ + { + "name": "height", + "type": "string", + "required": false, + "default": "100%", + "description": "Sets the height of the container. Accepts any valid CSS height value, including calc()/min()/clamp() and viewport units (e.g. \"400px\", \"100%\", \"100vh\", \"calc(100vh - 4rem)\"). Invalid values fall back to \"100%\". Defaults to \"100%\". The parent element must establish a height context for \"100%\" to resolve." + }, + { + "name": "testid", + "type": "string", + "required": false, + "default": "", + "description": "Sets a data-testid attribute for automated testing." + } + ], + "events": [], + "slots": [ + { + "name": "footer", + "description": "Content rendered in the sticky footer region.", + "required": false + }, + { + "name": "header", + "description": "Content rendered in the sticky header region.", + "required": false + } + ] + } + } +} \ No newline at end of file diff --git a/docs/generated/component-apis/text.json b/docs/generated/component-apis/text.json index 7437d8aff7..e80ee24e16 100644 --- a/docs/generated/component-apis/text.json +++ b/docs/generated/component-apis/text.json @@ -223,8 +223,9 @@ }, { "name": "size", - "type": "\"heading-xl\" | \"heading-l\" | \"heading-m\" | \"heading-s\" | \"heading-xs\" | \"heading-2xs\" | \"body-l\" | \"body-m\" | \"body-s\" | \"body-xs\"", + "type": "\"heading-2xl\" | \"heading-xl\" | \"heading-l\" | \"heading-m\" | \"heading-s\" | \"heading-xs\" | \"heading-2xs\" | \"body-l\" | \"body-m\" | \"body-s\" | \"body-xs\"", "values": [ + "heading-2xl", "heading-xl", "heading-l", "heading-m", diff --git a/docs/generated/component-apis/workspace-layout.json b/docs/generated/component-apis/workspace-layout.json new file mode 100644 index 0000000000..44bff889de --- /dev/null +++ b/docs/generated/component-apis/workspace-layout.json @@ -0,0 +1,143 @@ +{ + "componentSlug": "workspace-layout", + "extractedFrom": "libs/web-components/src/components/workspace-layout/WorkspaceLayout.svelte", + "frameworks": { + "react": { + "props": [ + { + "name": "testId", + "type": "string", + "required": false, + "default": null, + "description": "Sets a data-testid attribute for automated testing." + } + ], + "events": [ + { + "name": "onScrollStateChange", + "type": "(detail: GoabWorkspaceLayoutOnScrollStateChangeDetail) => void", + "description": "Called whenever the internal scroll state changes (no-scroll → at-top → middle → at-bottom)", + "frameworks": [ + "react" + ] + } + ], + "slots": [ + { + "name": "pageFooter", + "type": "ReactNode", + "description": "Content rendered in the sticky page footer region.", + "required": false + }, + { + "name": "pageHeader", + "type": "ReactNode", + "description": "Content rendered in the sticky page header region.", + "required": false + }, + { + "name": "pushDrawer", + "type": "ReactNode", + "description": "A GoabPushDrawer rendered as a sibling of the main card, inside the same workspace shell. Use this slot when the page needs a push drawer alongside the workspace layout — it gives the drawer the shell-level height and flex context it expects, so the consumer does not have to wrap the layout in their own flex container. The side menu is not pushed; only the card is.", + "required": false + }, + { + "name": "sideMenu", + "type": "ReactNode", + "description": "Content rendered in the side navigation region (e.g. work-side-menu).", + "required": false + } + ] + }, + "angular": { + "props": [ + { + "name": "testId", + "type": "string", + "required": false, + "default": null, + "description": "Sets the data-testid attribute for automated testing." + } + ], + "events": [ + { + "name": "onScrollStateChange", + "type": "(event: GoabWorkspaceLayoutOnScrollStateChangeDetail) => void", + "description": "Emitted whenever the internal scroll state changes (no-scroll → at-top → middle → at-bottom).", + "frameworks": [ + "angular" + ] + } + ], + "slots": [ + { + "name": "pageFooter", + "type": "TemplateRef", + "description": "Content rendered in the sticky page footer region.", + "required": false + }, + { + "name": "pageHeader", + "type": "TemplateRef", + "description": "Content rendered in the sticky page header region.", + "required": false + }, + { + "name": "pushDrawer", + "type": "TemplateRef", + "description": "A GoabPushDrawer rendered as a sibling of the main card, inside the same workspace shell. Use this slot when the page needs a push drawer alongside the workspace layout — it gives the drawer the shell-level height and flex context it expects, so the consumer does not have to wrap the layout in their own flex container. The side menu is not pushed; only the card is.", + "required": false + }, + { + "name": "sideMenu", + "type": "TemplateRef", + "description": "Content rendered in the side navigation region (e.g. work-side-menu).", + "required": false + } + ] + }, + "webComponents": { + "props": [ + { + "name": "testid", + "type": "string", + "required": false, + "default": "", + "description": "Sets a data-testid attribute for automated testing." + } + ], + "events": [ + { + "name": "_scrollStateChange", + "type": "CustomEvent<{ state: 'no-scroll' | 'at-top' | 'middle' | 'at-bottom'; isScrollable: boolean }>", + "description": "", + "frameworks": [ + "webComponents" + ] + } + ], + "slots": [ + { + "name": "page-footer", + "description": "Content rendered in the sticky page footer region.", + "required": false + }, + { + "name": "page-header", + "description": "Content rendered in the sticky page header region.", + "required": false + }, + { + "name": "push-drawer", + "description": "A GoabPushDrawer rendered as a sibling of the main card, inside the same workspace shell. Use this slot when the page needs a push drawer alongside the workspace layout — it gives the drawer the shell-level height and flex context it expects, so the consumer does not have to wrap the layout in their own flex container. The side menu is not pushed; only the card is.", + "required": false + }, + { + "name": "side-menu", + "description": "Content rendered in the side navigation region (e.g. work-side-menu).", + "required": false + } + ] + } + } +} \ No newline at end of file diff --git a/docs/package-lock.json b/docs/package-lock.json index 194823a78d..c4f28b6e83 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,21 +8,21 @@ "name": "docs", "version": "0.0.1", "dependencies": { - "@abgov/design-tokens-v2": "npm:@abgov/design-tokens@^2.8.0", + "@abgov/design-tokens-v2": "npm:@abgov/design-tokens@^2.9.0", "@abgov/react-components": "*", "@abgov/ui-components-common": "*", "@abgov/web-components": "*", "flexsearch": "^0.7.43" }, "devDependencies": { - "tsx": "^4.19.0" + "tsx": "^4.22.4" } }, "node_modules/@abgov/design-tokens-v2": { "name": "@abgov/design-tokens", - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@abgov/design-tokens/-/design-tokens-2.8.0.tgz", - "integrity": "sha512-roDjWROKgJkrPuqsWLAapYefIyFI8BwNSTMt3IAX6dwmpZOGfbNrAhaAhF1F8toESXgBUA1SyWMA2VKuKcgSjw==" + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@abgov/design-tokens/-/design-tokens-2.9.0.tgz", + "integrity": "sha512-Z3+wHUhLHCKJDXUBR0KpGBod5k5Jk+ehRNUC9J2OJZ0jcK3jF2/Ggeqhhze7+haiS0FdC1cikqT9vCnKZItU8Q==" }, "node_modules/@abgov/react-components": { "version": "6.9.3", @@ -48,9 +48,9 @@ "license": "Apache-2.0" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -65,9 +65,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -82,9 +82,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -150,9 +150,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -167,9 +167,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -184,9 +184,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -201,9 +201,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -235,9 +235,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -252,9 +252,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -269,9 +269,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -286,9 +286,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -320,9 +320,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -337,9 +337,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -354,9 +354,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -439,9 +439,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -456,9 +456,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -473,9 +473,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -489,10 +489,26 @@ "node": ">=18" } }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -503,32 +519,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/flexsearch": { @@ -552,38 +568,43 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-tsconfig": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz", - "integrity": "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==", - "dev": true, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "peer": true, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" diff --git a/docs/package.json b/docs/package.json index 200ba59b04..b4e815b28f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,13 +15,13 @@ "verify-bundle": "npx tsx src/scripts/content-generators/verify-bundle.ts" }, "dependencies": { - "@abgov/design-tokens-v2": "npm:@abgov/design-tokens@^2.8.0", + "@abgov/design-tokens-v2": "npm:@abgov/design-tokens@^2.9.0", "@abgov/react-components": "*", "@abgov/ui-components-common": "*", "@abgov/web-components": "*", "flexsearch": "^0.7.43" }, "devDependencies": { - "tsx": "^4.19.0" + "tsx": "^4.22.4" } } diff --git a/docs/public/images/examples/workspace-layout-diagram.png b/docs/public/images/examples/workspace-layout-diagram.png new file mode 100644 index 0000000000..6b08b21070 Binary files /dev/null and b/docs/public/images/examples/workspace-layout-diagram.png differ diff --git a/docs/public/search-index.json b/docs/public/search-index.json index 1dbc8888b2..f18f7fa12e 100644 --- a/docs/public/search-index.json +++ b/docs/public/search-index.json @@ -562,6 +562,23 @@ ], "slug": "radio-group" }, + { + "type": "component", + "id": "scroll-panel", + "name": "Scroll Panel", + "description": "A bounded container with sticky header and footer slots that scrolls its body content when it overflows.", + "status": "stable", + "category": "utilities", + "tags": [ + "container", + "layout", + "scroll", + "sticky header", + "sticky footer", + "bounded" + ], + "slug": "scroll-panel" + }, { "type": "component", "id": "side-menu", @@ -716,6 +733,23 @@ ], "slug": "work-side-notification-panel" }, + { + "type": "component", + "id": "workspace-layout", + "name": "Workspace Layout", + "description": "A full-page layout for workspace-style applications that wraps your content responsively.", + "status": "stable", + "category": "structure-and-navigation", + "tags": [ + "layout", + "workspace", + "side menu", + "sticky header", + "sticky footer", + "push drawer" + ], + "slug": "workspace-layout" + }, { "type": "example", "id": "add-a-filter-chip", @@ -2356,6 +2390,42 @@ ], "slug": "get-started/ai-tools-and-resources" }, + { + "type": "page", + "id": "parallel-paths-migration", + "title": "Parallel paths: migration alongside feature development", + "description": "How the Alberta Childcare Family Portal team managed a design system migration while actively building new features over approximately 2 months", + "status": "published", + "category": "get started", + "tags": [ + "articles" + ], + "slug": "get-started/articles/parallel-paths-migration" + }, + { + "type": "page", + "id": "two-phase-migration", + "title": "A two-phase migration: early adopter to production", + "description": "How the Caregiver Portal team joined the early adopter program and migrated a new product to the updated design system in 7–8 days across two phases", + "status": "published", + "category": "get started", + "tags": [ + "articles" + ], + "slug": "get-started/articles/two-phase-migration" + }, + { + "type": "page", + "id": "articles", + "title": "Articles", + "description": "Perspectives on design and technology from inside government — exploring the craft of building digital services that truly work for Albertans", + "status": "published", + "category": "get started", + "tags": [ + "intro" + ], + "slug": "get-started/articles" + }, { "type": "page", "id": "automated-accessibility", @@ -2616,7 +2686,7 @@ "type": "example", "id": "workspace", "title": "Workspace", - "description": "The workspace is for productivity-focused services used by service delivery staff as a daily tool to review information, manage records, and complete tasks. The design prioritizes productivity, efficiency, and accuracy so staff can move through their work quickly while ensuring the best outcome for citizens and government.", + "description": "The workspace is a pattern for productivity-focused services that service delivery staff use as a daily tool to review information, manage records, and complete tasks. The design prioritizes efficiency and accuracy. The reference example is a starting point to adapt to your service's context, not a rigid template.", "status": "published", "size": "product", "tags": [ diff --git a/docs/public/thumbnails/workspace-card-view.png b/docs/public/thumbnails/workspace-card-view.png index d93ac713ab..3cbc015c8c 100644 Binary files a/docs/public/thumbnails/workspace-card-view.png and b/docs/public/thumbnails/workspace-card-view.png differ diff --git a/docs/src/components/CodeSnippet.css b/docs/src/components/CodeSnippet.css index 93aca2a9b3..44a52a99d7 100644 --- a/docs/src/components/CodeSnippet.css +++ b/docs/src/components/CodeSnippet.css @@ -132,7 +132,7 @@ overflow-wrap: anywhere; } -code { +.code-snippet code { font-family: "SF Mono", Monaco, "Courier New", monospace; line-height: 1.6; background: transparent !important; diff --git a/docs/src/components/DoDont.astro b/docs/src/components/DoDont.astro index 465b08f442..21611b01f3 100644 --- a/docs/src/components/DoDont.astro +++ b/docs/src/components/DoDont.astro @@ -211,11 +211,22 @@ const config = typeConfig[type]; /* Info type: minimal wrapper for raw content */ .info-content { - color: var(--goa-color-info-text); + color: var(--goa-color-text-default); + font: var(--goa-typography-body-m); margin-bottom: var(--goa-space-l); max-width: 70ch; } + .info-content :global(p), + .info-content :global(li) { + font: var(--goa-typography-body-m); + } + + .info-content .description { + font: var(--goa-typography-body-m); + color: var(--goa-color-text-default); + } + .info-content :global(p) { margin: 0 0 var(--goa-space-m); } diff --git a/docs/src/components/PreviewContainer.astro b/docs/src/components/PreviewContainer.astro index 829d156504..79d1ff448d 100644 --- a/docs/src/components/PreviewContainer.astro +++ b/docs/src/components/PreviewContainer.astro @@ -16,19 +16,21 @@ interface Props { previewUrl?: string; /** External demo URL — used by the "View demo" button. Opens in a new tab. */ demoUrl?: string; - /** Drill-down sub-page URL — used by the "View more" button. Same tab. Pass a raw path; component applies withBase() internally. */ + /** Drill-down sub-page URL — used by the "More info" button. Same tab. Pass a raw path; component applies withBase() internally. */ moreUrl?: string; /** GitHub URL of the React source file or folder. Renders as a React icon button. */ reactSourceUrl?: string; /** GitHub URL of the Angular source file or folder. Renders as an Angular icon button. */ angularSourceUrl?: string; + /** Figma design URL. Renders as a Figma icon button that opens in a new tab. */ + figmaUrl?: string; stackblitzUrl?: string; } -const { image, alt = '', previewUrl, demoUrl, moreUrl, reactSourceUrl, angularSourceUrl, stackblitzUrl } = Astro.props; +const { image, alt = '', previewUrl, demoUrl, moreUrl, reactSourceUrl, angularSourceUrl, figmaUrl, stackblitzUrl } = Astro.props; const resolvedPreviewUrl = previewUrl ? withBase(previewUrl) : undefined; const resolvedMoreUrl = moreUrl ? withBase(moreUrl) : undefined; -const hasActions = Boolean(previewUrl || demoUrl || moreUrl || reactSourceUrl || angularSourceUrl || stackblitzUrl); +const hasActions = Boolean(previewUrl || demoUrl || moreUrl || reactSourceUrl || angularSourceUrl || figmaUrl || stackblitzUrl); const hasImage = Boolean(image); const shouldRender = hasImage || hasActions; --- @@ -82,6 +84,16 @@ const shouldRender = hasImage || hasActions; > )} + {figmaUrl && ( + + + + )} {stackblitzUrl && ( {moreUrl && ( - View more + More info )} {demoUrl && ( diff --git a/docs/src/components/RelatedArticles.astro b/docs/src/components/RelatedArticles.astro new file mode 100644 index 0000000000..23cf81998d --- /dev/null +++ b/docs/src/components/RelatedArticles.astro @@ -0,0 +1,79 @@ +--- +/** + * RelatedArticles.astro + * + * Renders a "Related articles" section at the bottom of an article page. + * Matches the visual pattern of Related examples in the examples section. + */ + +interface Article { + title: string; + url: string; +} + +interface Props { + articles: Article[]; +} + +const { articles } = Astro.props; +--- + +{articles.length > 0 && ( + +)} + + diff --git a/docs/src/content/components/scroll-panel.mdx b/docs/src/content/components/scroll-panel.mdx new file mode 100644 index 0000000000..fe60fb9cea --- /dev/null +++ b/docs/src/content/components/scroll-panel.mdx @@ -0,0 +1,19 @@ +--- +name: Scroll Panel +description: A bounded container with sticky header and footer slots that scrolls its body content when it overflows. +status: stable +category: utilities +tags: + - container + - layout + - scroll + - sticky header + - sticky footer + - bounded +relatedComponents: + - drawer + - modal + - push-drawer + - work-side-notification-panel +hideTabs: [examples] +--- diff --git a/docs/src/content/components/workspace-layout.mdx b/docs/src/content/components/workspace-layout.mdx new file mode 100644 index 0000000000..838a051d2b --- /dev/null +++ b/docs/src/content/components/workspace-layout.mdx @@ -0,0 +1,55 @@ +--- +name: Workspace Layout +description: A full-page layout for workspace-style applications that wraps your content responsively. +status: stable +category: structure-and-navigation +tags: + - layout + - workspace + - side menu + - sticky header + - sticky footer + - push drawer +relatedComponents: + - scroll-panel + - work-side-menu + - drawer + - push-drawer +hideTabs: [examples] +--- + +import { withBase } from "@/lib/base-url"; + +The workspace layout is the shell for workspace-style applications such as case management tools, internal dashboards, and back-office apps. It gives you a side menu, a sticky header and footer, a scrollable content area, and an optional push drawer, all wired together and responsive out of the box. + +## Anatomy + +Diagram of the workspace layout showing its pieces: a work side menu down the left, a sticky header with a heading and actions across the top, the page content in the centre, a sticky footer along the bottom, and a push drawer on the right. + +### Work side menu + +The main navigation for the workspace, built with a [work side menu](/components/work-side-menu). It stays in place while the content scrolls. On narrow screens it hides and opens from the mobile hamburger menu. + +### Sticky header + +A header pinned to the top while the content scrolls. Use it for the page title, key actions, or a toolbar. A soft shadow appears under it once content scrolls past, so people can tell there is more above. It can also collapse as people scroll down. + +### Page content + +Your page content. The layout wraps it in a scrollable card and handles the spacing, so the header and footer stay in place while this area scrolls. + +### Sticky footer + +A footer pinned to the bottom while the content scrolls. Use it for a summary or contextual actions that should stay reachable, such as a bulk action bar that appears once rows are selected. + +### Push drawer + +An optional drawer that opens beside the page content instead of over it. Unlike a modal drawer, it narrows the content rather than covering it, so people can still interact with the main page content while the drawer is open. + +### Mobile hamburger menu + +At narrow screen widths the side menu hides to save space, and the layout adds a hamburger button that opens it. This comes with the layout, you do not wire it up yourself. diff --git a/docs/src/content/config.ts b/docs/src/content/config.ts index c457369e3b..24c72ec8f0 100644 --- a/docs/src/content/config.ts +++ b/docs/src/content/config.ts @@ -33,6 +33,9 @@ const components = defineCollection({ // Visibility hidden: z.boolean().optional(), // Hide from navigation and public views subcomponent: z.boolean().optional(), // Show API on parent component page + hideTabs: z + .array(z.enum(["examples", "usage", "accessibility"])) + .optional(), // Hide specific tabs on this component's page }), }); @@ -275,6 +278,7 @@ const getStarted = defineCollection({ "ai-tools-and-resources", "contribute", "out-of-support", + "articles", ]), // Sort order within section. order: z.number(), diff --git a/docs/src/content/examples/show-number-of-results-per-page/angular.html b/docs/src/content/examples/show-number-of-results-per-page/angular.html index 145e81ef24..678fe3845b 100644 --- a/docs/src/content/examples/show-number-of-results-per-page/angular.html +++ b/docs/src/content/examples/show-number-of-results-per-page/angular.html @@ -24,6 +24,7 @@ (onChange)="handlePerPageCountChangeEvent($event)" [value]="perPage.toString()" width="9ch" + size="compact" > diff --git a/docs/src/content/examples/show-number-of-results-per-page/react.tsx b/docs/src/content/examples/show-number-of-results-per-page/react.tsx index 0d1a780500..4976eedecf 100644 --- a/docs/src/content/examples/show-number-of-results-per-page/react.tsx +++ b/docs/src/content/examples/show-number-of-results-per-page/react.tsx @@ -119,6 +119,7 @@ export function ShowNumberOfResultsPerPage() { onChange={handlePerPageCountChangeEvent} value={perPage.toString()} width="9ch" + size="compact" > diff --git a/docs/src/content/examples/show-number-of-results-per-page/web-components.html b/docs/src/content/examples/show-number-of-results-per-page/web-components.html index 399200986b..f9d95433b8 100644 --- a/docs/src/content/examples/show-number-of-results-per-page/web-components.html +++ b/docs/src/content/examples/show-number-of-results-per-page/web-components.html @@ -16,7 +16,7 @@ Show - + diff --git a/docs/src/content/examples/workspace/case-detail/index.mdx b/docs/src/content/examples/workspace/case-detail/index.mdx index ef0a7d6ad5..c2de6b8db4 100644 --- a/docs/src/content/examples/workspace/case-detail/index.mdx +++ b/docs/src/content/examples/workspace/case-detail/index.mdx @@ -8,6 +8,7 @@ previewUrl: https://digital-service-demo-v2.netlify.app/workspace/application/ef previewImage: /thumbnails/workspace-case-detail.png reactSourceUrl: https://github.com/GovAlta/goa-workspace-playground/tree/main/src/routes/cases angularSourceUrl: https://github.com/GovAlta/goa-workspace-angular/tree/main/src/app/pages/cases +figmaUrl: https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/%E2%9D%96-Component-library?node-id=60159-129834 components: - accordion - badge diff --git a/docs/src/content/examples/workspace/dashboard/index.mdx b/docs/src/content/examples/workspace/dashboard/index.mdx index dedc429277..770ca55154 100644 --- a/docs/src/content/examples/workspace/dashboard/index.mdx +++ b/docs/src/content/examples/workspace/dashboard/index.mdx @@ -8,6 +8,7 @@ previewUrl: https://digital-service-demo-v2.netlify.app/workspace previewImage: /thumbnails/workspace-dashboard.png reactSourceUrl: https://github.com/GovAlta/goa-workspace-playground/tree/main/src/routes/dashboard angularSourceUrl: https://github.com/GovAlta/goa-workspace-angular/tree/main/src/app/pages/dashboard +figmaUrl: https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/%E2%9D%96-Component-library?node-id=60158-598232 components: - container - block diff --git a/docs/src/content/examples/workspace/index-page/index.mdx b/docs/src/content/examples/workspace/index-page/index.mdx index 6980db1f85..ecc9311b8e 100644 --- a/docs/src/content/examples/workspace/index-page/index.mdx +++ b/docs/src/content/examples/workspace/index-page/index.mdx @@ -8,6 +8,7 @@ previewUrl: https://digital-service-demo-v2.netlify.app/workspace/applications previewImage: /thumbnails/workspace-table-view.png reactSourceUrl: https://github.com/GovAlta/goa-workspace-playground/tree/main/src/routes/search angularSourceUrl: https://github.com/GovAlta/goa-workspace-angular/tree/main/src/app/pages/search +figmaUrl: https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/%E2%9D%96-Component-library?node-id=67092-145149 components: - work-side-menu - data-grid diff --git a/docs/src/content/get-started/articles.mdx b/docs/src/content/get-started/articles.mdx new file mode 100644 index 0000000000..7a14664269 --- /dev/null +++ b/docs/src/content/get-started/articles.mdx @@ -0,0 +1,26 @@ +--- +id: articles +title: Articles +description: Perspectives on design and technology from inside government — exploring the craft of building digital services that truly work for Albertans +section: intro +order: 6 +status: published +--- +import { withBase } from "@/lib/base-url"; +import CardLite from "../../components/CardLite.astro"; + +Articles +Perspectives on design and technology from inside government — exploring the craft of building digital services that truly work for Albertans. + +
+ + +
diff --git a/docs/src/content/get-started/articles/parallel-paths-migration.mdx b/docs/src/content/get-started/articles/parallel-paths-migration.mdx new file mode 100644 index 0000000000..7f09e74bc2 --- /dev/null +++ b/docs/src/content/get-started/articles/parallel-paths-migration.mdx @@ -0,0 +1,74 @@ +--- +id: parallel-paths-migration +title: "Parallel paths: migration alongside feature development" +description: How the Alberta Childcare Family Portal team managed a design system migration while actively building new features over approximately 2 months +section: articles +order: 2 +status: published +--- +import { withBase } from "@/lib/base-url"; +import RelatedArticles from "../../../components/RelatedArticles.astro"; + +Parallel paths: migration alongside feature development + +The Alberta Childcare Family Portal helps parents find and access childcare services in Alberta. The team updated to the design system while the product was still in early development. + +

Migration at a glance

+ +
+ + + + + + + + + + + + + + + + + + + + +
Migration typeNew product with some legacy components
Team1 designer, 1 developer, 1 digital architect, 2 QA
TimelineApproximately 2 months (alongside active feature development)
Product size8–12 screens at time of migration
+
+
+ +

How they approached it

+The team updated while the product was still being built. Of the roughly two months the work spanned, around 30% of the effort went into the design system update — the rest was ongoing feature development which was running in parallel. +On the design side, the team identified what needed to change first, then moved quickly. Once the audit was done, the design updates — swapping and updating components — were quick and straightforward. + +

What went faster than expected

+Design updates were quicker than the team expected. After a clear audit of what needed to change, moving through the updates was fast. The smaller product size at the time of migration helped — 8–12 screens is a manageable scope for a focused review. +Development was smoother than expected given the scope of changes. + +

What took more time than expected

+Finding, installing, and configuring the right packages required more time and attention than the team had budgeted for. +Running parallel branches during the update added coordination overhead. Keeping feature development and update work in sync required careful management, particularly when design system packages were also changing during the same period. +Some token changes affected legacy components in unexpected ways and required investigation. API differences between versions also added time. + +

How they used AI

+Claude was used for design audits — comparing layout, spacing, and sizing across pages and understanding differences between versions. The team found AI most useful when prompts included design system context before asking specific questions. + +

What they'd tell another team

+ +- On the development side: + - It's ideal to do the migration in one shot. + - If possible, pause new feature development while your team tackles the upgrade. + - Expect to spend time on package setup and understanding how styling changes affect your application. + - Preparation and awareness of your dependencies matter. +- On the design side: + - Start working with the updated design system early to explore new possibilities. + - The updated design system supports newer patterns and can help you make better design decisions as you go. + + + +Back to articles diff --git a/docs/src/content/get-started/articles/two-phase-migration.mdx b/docs/src/content/get-started/articles/two-phase-migration.mdx new file mode 100644 index 0000000000..bc03e06b84 --- /dev/null +++ b/docs/src/content/get-started/articles/two-phase-migration.mdx @@ -0,0 +1,71 @@ +--- +id: two-phase-migration +title: "A two-phase migration: early adopter to production" +description: How the Caregiver Portal team joined the early adopter program and migrated a new product to the updated design system in 7–8 days across two phases +section: articles +order: 1 +status: published +--- +import { withBase } from "@/lib/base-url"; +import RelatedArticles from "../../../components/RelatedArticles.astro"; + +A two-phase migration: early adopter to production + +The Caregiver Portal is a digital service that supports caregivers navigating child intervention processes. The team joined the early adopters program and migrated before the full release, then later migrated to the production version of the updated design system. + +

Migration at a glance

+ +
+ + + + + + + + + + + + + + + + + + + + +
Migration typeNew product
Team1 designer, 1 developer
Timeline7–8 days across two migration phases
Product sizeNew product — small codebase
+
+
+ +

How they approached it

+The team migrated in two phases. The first move was from the legacy design system to the experimental library (Early adopter program), which took 2–3 days. The second phase, from the experimental library to the production release, took another 4–5 days. +The developer started with documentation, installed the correct packages, got the base implementation running, and then replaced custom styles with design tokens. Most of the time went into that last step. Some components had hundreds of lines of custom CSS to review and replace. +The designer started working with the updated design system before the production release. Staying close to design system standards from the start reduced rework later. + +

What went faster than expected

+Tokens were easier and faster to implement than the team expected. Once the packages were in place, replacing values with tokens was straightforward. On the design side, pulling Figma components directly from the library removed a lot of manual design work. +No major bugs were encountered. Most components just worked. + +

What took more time than expected

+Initial setup — finding the right packages and getting everything configured — took longer than expected. Factor this into your planning, even if your team is comfortable with the tooling. +Cleaning up custom styles also took time. For a new product this was manageable, but teams with more accumulated custom code should expect this phase to take longer. +A small number of component gaps required workarounds. The team needed to create a "data card" component manually, and certain components lack read-only states or the ability to be dismissed. Each gap required a design decision and, at times, extra implementation work. + +

How they used AI

+The developer used GitHub Copilot throughout the migration and recommends it — with one condition: set it up with your environment and documentation before you start. +In practice, Copilot identified custom code and styles and suggested alternative tokens based on the design system documentation. Overall it was helpful for both building and verifying, speeding up development significantly. + +

What they'd tell another team

+
    +
  • Check that your application is running on a modern version of Angular or React before you start. Older applications require more effort to migrate. Updating your framework version before migration will help reduce the overall effort.
  • +
  • Follow the documentation closely, replace old styles gradually, verify as you go, and set up AI tooling early to assist with the migration.
  • +
+ + + +Back to articles diff --git a/docs/src/content/guidance/scroll-panel-shared-scroll-primitive.mdx b/docs/src/content/guidance/scroll-panel-shared-scroll-primitive.mdx new file mode 100644 index 0000000000..564c2323c7 --- /dev/null +++ b/docs/src/content/guidance/scroll-panel-shared-scroll-primitive.mdx @@ -0,0 +1,22 @@ +--- +id: scroll-panel-shared-scroll-primitive +type: info +description: Scroll panel keeps a header and footer in place while the content between them scrolls. +topic: usage +tags: + - scroll + - layout +appliesTo: + components: + - scroll-panel +status: published +--- + +You usually will not add scroll panel yourself. It works inside several other components, giving each one the same scrolling and the same soft shadow when there is more to see: + +- [Modal](/components/modal) scrolls its content between the fixed heading and the buttons. +- [Drawer](/components/drawer) and [push drawer](/components/push-drawer) scroll their content under a sticky header. +- [Work side notification panel](/components/work-side-notification-panel) scrolls its list under the tabs. +- [Work side menu](/components/work-side-menu) scrolls its navigation under a sticky heading. + +You can also use it on its own when you need a box whose content scrolls while a header or footer stays in place. diff --git a/docs/src/content/guidance/workspace-layout-host-at-viewport-root.mdx b/docs/src/content/guidance/workspace-layout-host-at-viewport-root.mdx new file mode 100644 index 0000000000..d09c120f81 --- /dev/null +++ b/docs/src/content/guidance/workspace-layout-host-at-viewport-root.mdx @@ -0,0 +1,15 @@ +--- +id: workspace-layout-host-at-viewport-root +type: tip +description: The workspace layout fills the viewport, so host it directly in the router outlet, not inside another scrolling container. +topic: layout +tags: + - layout + - workspace +appliesTo: + components: + - workspace-layout +status: published +--- + +The workspace layout fills the full height of the viewport and manages its own scrolling inside the content area. Place it directly in your router outlet or page root. If you nest it inside another element that scrolls, the sticky header and footer will not stay in place as expected. diff --git a/docs/src/content/guidance/workspace-layout-reading-scroll-state.mdx b/docs/src/content/guidance/workspace-layout-reading-scroll-state.mdx new file mode 100644 index 0000000000..bc709b44d4 --- /dev/null +++ b/docs/src/content/guidance/workspace-layout-reading-scroll-state.mdx @@ -0,0 +1,17 @@ +--- +id: workspace-layout-reading-scroll-state +type: info +description: Read the layout's scroll state to respond as people scroll, such as collapsing the header or recording when someone reaches the bottom of a list. +topic: interaction +tags: + - scroll + - interaction +appliesTo: + components: + - workspace-layout +status: published +--- + +The layout tracks how far the content has scrolled (at the top, in the middle, or at the bottom) and lets you read that to respond. Common uses are collapsing a tall header as people scroll down, showing a back to top button, or recording an analytics event when someone reaches the bottom of a long list. + +Read it with the `useGoabWorkspaceLayoutScrollState` hook in React, or the `GoabWorkspaceLayoutScrollStateService` in Angular. In web components, listen for the `_scrollStateChange` event. See the Properties tab for the full event payload. diff --git a/docs/src/content/guidance/workspace-layout-when-to-use.mdx b/docs/src/content/guidance/workspace-layout-when-to-use.mdx new file mode 100644 index 0000000000..f0f5acc4de --- /dev/null +++ b/docs/src/content/guidance/workspace-layout-when-to-use.mdx @@ -0,0 +1,17 @@ +--- +id: workspace-layout-when-to-use +type: do +description: Use the workspace layout for worker tools where pages share a side menu and people scroll long content while header and footer actions stay in reach. +topic: usage +tags: + - layout + - workspace +appliesTo: + components: + - workspace-layout +status: published +--- + +Reach for the workspace layout when you are building a tool people use to get work done, such as case management, internal dashboards, or back-office apps. It suits pages that share a side menu and where people scroll through long content while the header and footer actions stay in reach. + +It is built for these worker applications, not public citizen-facing pages. diff --git a/docs/src/content/productTypes/workspace/index.mdx b/docs/src/content/productTypes/workspace/index.mdx index d3b5d25f72..1a1402a86a 100644 --- a/docs/src/content/productTypes/workspace/index.mdx +++ b/docs/src/content/productTypes/workspace/index.mdx @@ -1,7 +1,7 @@ --- id: workspace title: Workspace -summary: The workspace is for productivity-focused services used by service delivery staff as a daily tool to review information, manage records, and complete tasks. The design prioritizes productivity, efficiency, and accuracy so staff can move through their work quickly while ensuring the best outcome for citizens and government.
Workspaces should be adapted to fit each service's context and user needs. The reference example is a starting point to expand on, not a rigid template. +summary: The workspace is a pattern for productivity-focused services that service delivery staff use as a daily tool to review information, manage records, and complete tasks. The design prioritizes efficiency and accuracy. The reference example is a starting point to adapt to your service's context, not a rigid template. heroImage: /thumbnails/workspace-preview.png demoUrl: https://digital-service-demo-v2.netlify.app/workspace sourceUrl: https://github.com/GovAlta/goa-workspace-playground @@ -25,31 +25,26 @@ components: status: published --- -View demo - import LinkCard from "../../../components/LinkCard.astro"; import PreviewContainer from "../../../components/PreviewContainer.astro"; +import { withBase } from "@/lib/base-url"; -
- - +
+ View demo + React template + Angular template
-Primary user: Service delivery staff +## Page types -## Pages +Common pages seen in workspace products that you can combine and adapt to fit the work your service supports. -Combine and adapt these to fit the work your service supports. + + All of the page examples are built using the workspace layout component. It is a responsive shell with a side menu, sticky page header and footer, and an optional push drawer. On small screens, the side menu collapses into a hamburger menu. + + ### Dashboard @@ -62,6 +57,7 @@ The landing view of a workspace, showing staff their counts, trends, and assigne reactSourceUrl="https://github.com/GovAlta/goa-workspace-playground/tree/main/src/routes/dashboard" angularSourceUrl="https://github.com/GovAlta/goa-workspace-angular/tree/main/src/app/pages/dashboard" moreUrl="/examples/workspace/dashboard" + figmaUrl="https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/%E2%9D%96-Component-library?node-id=60158-598232" /> ### Index page @@ -75,6 +71,7 @@ The page where staff scan and pick records to work on, with filters, search, and reactSourceUrl="https://github.com/GovAlta/goa-workspace-playground/tree/main/src/routes/search" angularSourceUrl="https://github.com/GovAlta/goa-workspace-angular/tree/main/src/app/pages/search" moreUrl="/examples/workspace/index-page" + figmaUrl="https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/%E2%9D%96-Component-library?node-id=67092-145149" /> ### Case detail @@ -88,51 +85,54 @@ A single-record view for reviewing and acting on one case. reactSourceUrl="https://github.com/GovAlta/goa-workspace-playground/tree/main/src/routes/cases" angularSourceUrl="https://github.com/GovAlta/goa-workspace-angular/tree/main/src/app/pages/cases" moreUrl="/examples/workspace/case-detail" + figmaUrl="https://www.figma.com/design/Jpy1Ea5qglwnp1SgGnagY9/%E2%9D%96-Component-library?node-id=60159-129834" /> --- -## Patterns +## Interaction patterns -Patterns that show up across multiple workspace pages. +Common interaction patterns that show up across multiple workspace pages. ### Data views Workers need different ways to view, scan, and act on records depending on the task. Choose the data view that best supports the task. -- **Table view.** Dense, scannable rows with sortable columns. Best for users who need to compare fields across many records at once. -- **Card view.** Each record as a card showing key details grouped into sections. Useful when records have more complex data that doesn't fit neatly into columns. -- **Expandable list view.** Compact rows that expand inline to reveal detail. A middle ground between the density of a table and the richness of cards. - -Any view can be combined with grouping (for example, group by priority or status) to organize records into collapsible sections. Users can also search, filter by status using [tabs](/components/tabs), sort with multi-level sorting, and manage active filters with [filter chips](/components/filter-chip). +- **Table view:** Dense, scannable rows with sortable columns. Best for users who need to compare fields across many records at once. +- **Card view:** Each record as a card showing key details grouped into sections. Useful when records have more complex data that doesn't fit neatly into columns. +- **Expandable list view:** Compact rows that expand inline to reveal detail. A middle ground between the density of a table and the richness of cards. - +
+ Table view with sortable columns Card view of case files Expandable list view with grouping +
+ +Any view can be combined with grouping (for example, group by priority or status) to organize records into collapsible sections. Users can also search, filter by status using [tabs](/components/tabs), sort with multi-level sorting, and manage active filters with [filter chips](/components/filter-chip). ### Sidebar navigation -Use the [work side menu](/components/work-side-menu) as the primary navigation for a workspace. +Use the [work side menu](/components/work-side-menu) as the primary navigation for a workspace. The [workspace layout](/components/workspace-layout) provides this navigation, including the collapse to a hamburger on mobile. Keep top-level navigation focused on the main areas of work. When a user opens a record or case, switch to contextual navigation that supports that task. This puts the user into a focused mode with relevant navigation items (like sections of an application, comments, reminders) and a back link to return to the main view. The sidebar can adapt to what the user is doing, it doesn't have to show everything at once. @@ -142,17 +142,17 @@ Keep top-level navigation focused on the main areas of work. When a user opens a Main workspace navigation

Workspace main sidebar navigation with Dashboard and Applications items

- Contextual case navigation + Secondary contextual navigation

Workspace contextual sidebar showing case-specific navigation items @@ -161,7 +161,7 @@ Keep top-level navigation focused on the main areas of work. When a user opens a ### Sticky action bar -A scroll-aware header that pins to the top of the content area as the user scrolls. Use it to keep the most important tools visible. +A scroll-aware header that pins to the top of the content area as the user scrolls. Use it to keep the most important tools visible. The [workspace layout](/components/workspace-layout) provides this as its sticky page header. Set the content for each page based on the task: @@ -171,14 +171,14 @@ Set the content for each page based on the task: Do not pin everything. Keep only the information and actions people need to continue their work without losing context. Workspace sticky action bar ### Push drawer -Use a [push drawer](/components/push-drawer) for focused work that should happen without leaving the current page. +Use a [push drawer](/components/push-drawer) for focused work that should happen without leaving the current page. The [workspace layout](/components/workspace-layout) hosts this as a push drawer that narrows the main card rather than overlaying it. This works well for actions such as: @@ -190,7 +190,7 @@ This works well for actions such as: Use it when users need to keep the main record in view while completing the task. Workspace push drawer @@ -201,12 +201,12 @@ Use it when users need to keep the main record in view while completing the task Workspace patterns are supported by component features designed for denser, more productive interfaces: -- **Compact inputs.** Every input component now has a `compact` variant that reduces height and spacing. This lets you build tighter forms and toolbars without sacrificing usability. -- **Compact badges and notifications.** [Badges](/components/badge) and [notification banners](/components/notification) also have compact sizing to fit within dense data views and action bars. -- **Badge variants.** [Badges](/components/badge) now include `strong` and `subtle` emphasis options, giving you control over visual weight depending on context. -- **Data grid.** The [data grid](/components/data-grid) works with the table component to support accessible, keyboard-navigable data views. Use them together when the user needs to move through records efficiently without relying on a mouse. -- **Segmented tabs.** A new [tab](/components/tab) variant that works as a segmented toggle for switching between views or modes within a workspace, rather than navigating between pages. -- **Enhanced filter chips.** [Filter chips](/components/filter-chip) now support secondary text and secondary icons, making them more useful for richer filtering in workspace data views. +- **Compact inputs:** Every input component now has a `compact` variant that reduces height and spacing. This lets you build tighter forms and toolbars without sacrificing usability. +- **Compact badges and notifications:** [Badges](/components/badge) and [notification banners](/components/notification) also have compact sizing to fit within dense data views and action bars. +- **Badge variants:** [Badges](/components/badge) now include `strong` and `subtle` emphasis options, giving you control over visual weight depending on context. +- **Data grid:** The [data grid](/components/data-grid) works with the table component to support accessible, keyboard-navigable data views. Use them together when the user needs to move through records efficiently without relying on a mouse. +- **Segmented tabs:** A new [tab](/components/tab) variant that works as a segmented toggle for switching between views or modes within a workspace, rather than navigating between pages. +- **Enhanced filter chips:** [Filter chips](/components/filter-chip) now support secondary text and secondary icons, making them more useful for richer filtering in workspace data views. --- @@ -227,7 +227,7 @@ Use the workspace when your service needs to support regular, task-based work, s ## When not to use -- Citizen-facing services. Use the [public form](/examples/public-form) pattern instead. +- Public facing applications or forms. Use the [public form](/examples/public-form) pattern instead. - Content-only pages that do not involve case management. - Simple internal tools that do not need structured case processing. @@ -251,12 +251,3 @@ Use one of the workspace templates as a starting point for your service. Each te description="GitHub template for building a workspace with Angular" />
- ---- - -## Considerations - -- The reference demo is a spike. Component customizations have not gone through the production pipeline yet. -- There should be significant variability within a workspace to meet each service's specific context and user needs. -- The demo is a starting point to expand on and adapt, not a rigid template to copy exactly. -- Desktop uses a card container pattern for the main content area. This is removed on mobile for space. diff --git a/docs/src/data/configurations/index.ts b/docs/src/data/configurations/index.ts index cbfbe46429..5c471cf66e 100644 --- a/docs/src/data/configurations/index.ts +++ b/docs/src/data/configurations/index.ts @@ -70,6 +70,8 @@ export { dataGridConfigurations } from "./data-grid"; export { modalConfigurations } from "./modal"; export { drawerConfigurations } from "./drawer"; export { pushDrawerConfigurations } from "./push-drawer"; +export { scrollPanelConfigurations } from "./scroll-panel"; +export { workspaceLayoutConfigurations } from "./workspace-layout"; export { popoverConfigurations } from "./popover"; export { accordionConfigurations } from "./accordion"; export { detailsConfigurations } from "./details"; @@ -137,6 +139,8 @@ import { dataGridConfigurations } from "./data-grid"; import { modalConfigurations } from "./modal"; import { drawerConfigurations } from "./drawer"; import { pushDrawerConfigurations } from "./push-drawer"; +import { scrollPanelConfigurations } from "./scroll-panel"; +import { workspaceLayoutConfigurations } from "./workspace-layout"; import { popoverConfigurations } from "./popover"; import { accordionConfigurations } from "./accordion"; import { detailsConfigurations } from "./details"; @@ -214,6 +218,8 @@ export const configurationRegistry: ConfigurationRegistry = { modal: modalConfigurations, drawer: drawerConfigurations, "push-drawer": pushDrawerConfigurations, + "scroll-panel": scrollPanelConfigurations, + "workspace-layout": workspaceLayoutConfigurations, popover: popoverConfigurations, accordion: accordionConfigurations, details: detailsConfigurations, diff --git a/docs/src/data/configurations/scroll-panel.ts b/docs/src/data/configurations/scroll-panel.ts new file mode 100644 index 0000000000..2e8ebc59fe --- /dev/null +++ b/docs/src/data/configurations/scroll-panel.ts @@ -0,0 +1,306 @@ +/** + * Scroll Panel Component Configurations + * + * Bounded container with sticky header / footer slots and a scrollable body. + * Renders a subtle border on header/footer + inset shadows on content when the + * body overflows, so users know there's more content above or below. + */ + +import type { ComponentConfigurations } from "./types"; + +export const scrollPanelConfigurations: ComponentConfigurations = { + componentSlug: "scroll-panel", + componentName: "Scroll Panel", + defaultConfigurationId: "basic", + + // ScrollPanel fills its parent — strip the sandbox's default padding so the + // panel sits flush to the preview edges, matching how it appears in real use. + previewStyle: "padding: 0;", + + configurations: [ + { + id: "basic", + name: "Basic scroll panel", + description: "Header + scrollable body + footer with a fixed height.", + code: { + react: { + ts: `const paragraphs = Array.from({ length: 15 }, (_, i) => i + 1);`, + jsx: ` + + Case details + + + } + footer={ +
+ + Cancel + Save changes + +
+ } +> +
+ {paragraphs.map((n) => ( +

+ Paragraph {n} — Scrollable body content. The panel will scroll inside its + 480px height, with sticky header and footer pinned. Notice the subtle + border + inset shadow that appears when scrolling indicates more content. +

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + paragraphs = Array.from({ length: 15 }, (_, i) => i + 1); +}`, + template: ` +
+ Case details +
+
+ +
+ + Cancel + Save changes + +
+
+ + +
+ @for (n of paragraphs; track n) { +

+ Paragraph {{ n }} — Scrollable body content. The panel will scroll inside + its 480px height, with sticky header and footer pinned. Notice the subtle + border + inset shadow that appears when scrolling indicates more content. +

+ } +
+
`, + }, + webComponents: ` +
+ Case details +
+
+
+ + Cancel + Save changes + +
+
+`, + }, + }, + { + id: "header-only", + name: "Header only", + description: "Sticky header pinned at the top with a scrollable body — no footer slot.", + code: { + react: { + ts: `const rows = Array.from({ length: 12 }, (_, i) => i + 1);`, + jsx: ` + + Activity feed + + + } +> +
+ {rows.map((n) => ( +

Row {n} — scroll me, header stays pinned at the top.

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + rows = Array.from({ length: 12 }, (_, i) => i + 1); +}`, + template: ` +
+ Activity feed +
+
+ + +
+ @for (n of rows; track n) { +

Row {{ n }} — scroll me, header stays pinned at the top.

+ } +
+
`, + }, + webComponents: ` +
+ Activity feed +
+
+
+`, + }, + }, + { + id: "footer-only", + name: "Footer only", + description: "Sticky footer pinned at the bottom with a scrollable body — no header slot.", + code: { + react: { + ts: `const rows = Array.from({ length: 12 }, (_, i) => i + 1);`, + jsx: ` + + + Submit + + + + } +> +
+ {rows.map((n) => ( +

Row {n} — scroll me, footer stays pinned at the bottom.

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + rows = Array.from({ length: 12 }, (_, i) => i + 1); +}`, + template: ` +
+ + Submit + +
+
+ + +
+ @for (n of rows; track n) { +

Row {{ n }} — scroll me, footer stays pinned at the bottom.

+ } +
+
`, + }, + webComponents: ` + +
+ + Submit + +
+
+`, + }, + }, + { + id: "custom-height", + name: "Custom height", + description: + "The `height` prop accepts any valid CSS height value (e.g. \"600px\", \"80vh\"). It defaults to \"100%\" so the panel can fill a height-constrained parent — useful when the panel is inside a div, modal, or drawer that already establishes the viewport bounds.", + code: { + react: { + ts: `const rows = Array.from({ length: 20 }, (_, i) => i + 1);`, + jsx: ` + + Custom 600px height + + + } + footer={ +
+ Footer pinned at bottom +
+ } +> +
+ {rows.map((n) => ( +

+ Row {n} — Body scrolls within the explicit 600px height. Swap to + "80vh" for viewport-relative sizing, or leave the prop off (defaults + to "100%") when a parent already constrains height. +

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + rows = Array.from({ length: 20 }, (_, i) => i + 1); +}`, + template: ` +
+ Custom 600px height +
+
+ +
+ Footer pinned at bottom +
+
+ + +
+ @for (n of rows; track n) { +

+ Row {{ n }} — Body scrolls within the explicit 600px height. Swap to + "80vh" for viewport-relative sizing, or leave the prop off (defaults + to "100%") when a parent already constrains height. +

+ } +
+
`, + }, + webComponents: ` +
+ Custom 600px height +
+
+
+ Footer pinned at bottom +
+
+`, + }, + }, + ], +}; diff --git a/docs/src/data/configurations/text.ts b/docs/src/data/configurations/text.ts index 49c746f308..1115abc8af 100644 --- a/docs/src/data/configurations/text.ts +++ b/docs/src/data/configurations/text.ts @@ -28,6 +28,8 @@ export const textConfigurations: ComponentConfigurations = { description: "All heading text sizes", code: { react: ` +Heading 2XL + Heading XL Heading L @@ -41,6 +43,8 @@ export const textConfigurations: ComponentConfigurations = { Heading 2XS `, angular: ` +Heading 2XL + Heading XL Heading L @@ -54,6 +58,8 @@ export const textConfigurations: ComponentConfigurations = { Heading 2XS `, webComponents: ` +Heading 2XL + Heading XL Heading L diff --git a/docs/src/data/configurations/workspace-layout.ts b/docs/src/data/configurations/workspace-layout.ts new file mode 100644 index 0000000000..75939d803f --- /dev/null +++ b/docs/src/data/configurations/workspace-layout.ts @@ -0,0 +1,505 @@ +/** + * Workspace Layout Component Configurations + * + * Full-page shell for worker applications: a side menu, a sticky page header, + * a scrollable content area, an optional sticky footer for contextual actions, + * and an optional push drawer. + * + * The component sets its own height to 100vh. The preview bounds it with an + * inline height so it renders as a contained workspace instead of filling the + * whole docs page (inline style overrides the :host height rule). + */ + +import type { ComponentConfigurations } from "./types"; + +export const workspaceLayoutConfigurations: ComponentConfigurations = { + componentSlug: "workspace-layout", + componentName: "Workspace Layout", + defaultConfigurationId: "basic", + + // The layout fills its parent — strip the sandbox padding so it sits flush. + previewStyle: "padding: 0;", + + configurations: [ + { + id: "basic", + name: "Basic workspace", + description: + "A side menu and a scrollable content area. The simplest shell, before you add a sticky header, footer, or push drawer.", + code: { + react: { + ts: `const rows = Array.from({ length: 16 }, (_, i) => i + 1); + +const sideMenu = ( + navigate(path)} + primaryContent={ + <> + + + + + } + /> +);`, + jsx: ` +
+ {rows.map((n) => ( +

+ Case row {n} — scroll this area while the side menu stays in place. +

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + rows = Array.from({ length: 16 }, (_, i) => i + 1); + handleNavigate(path: string) { + // route to path + } +}`, + template: ` +
+ @for (n of rows; track n) { +

Case row {{ n }} — scroll this area while the side menu stays in place.

+ } +
+
+ + + + + + + + +`, + }, + webComponents: ` + + + + + + +
+
+`, + }, + }, + { + id: "sticky-header", + name: "Sticky header", + description: + "Add a sticky page header for the page title and key actions. It stays pinned while the content scrolls, and a soft shadow appears under it once content scrolls past.", + code: { + react: { + ts: `const rows = Array.from({ length: 16 }, (_, i) => i + 1); + +const sideMenu = ( + navigate(path)} + primaryContent={ + <> + + + + } + /> +); + +const pageHeader = ( +
+ My cases + + Filter + New case + +
+);`, + jsx: ` +
+ {rows.map((n) => ( +

+ Case row {n} — scroll this area and the header stays pinned at the top. +

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + rows = Array.from({ length: 16 }, (_, i) => i + 1); + handleNavigate(path: string) { + // route to path + } +}`, + template: ` +
+ @for (n of rows; track n) { +

Case row {{ n }} — scroll this area and the header stays pinned at the top.

+ } +
+
+ + + + + + + + + +
+ My cases + + Filter + New case + +
+
`, + }, + webComponents: ` + + + + + +
+ My cases + + Filter + New case + +
+ +
+
+`, + }, + }, + { + id: "sticky-footer", + name: "Sticky footer", + description: + "Add a sticky page footer for a summary or contextual actions, such as a bar of bulk actions. It stays pinned to the bottom while the content scrolls, with a soft shadow above it once content scrolls underneath.", + code: { + react: { + ts: `const rows = Array.from({ length: 16 }, (_, i) => i + 1); + +const sideMenu = ( + navigate(path)} + primaryContent={ + <> + + + + } + /> +); + +const pageFooter = ( +
+ 3 cases selected + + Clear + Export + +
+);`, + jsx: ` +
+ {rows.map((n) => ( +

+ Case row {n} — scroll this area and the footer stays pinned at the bottom. +

+ ))} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + rows = Array.from({ length: 16 }, (_, i) => i + 1); + handleNavigate(path: string) { + // route to path + } +}`, + template: ` +
+ @for (n of rows; track n) { +

Case row {{ n }} — scroll this area and the footer stays pinned at the bottom.

+ } +
+
+ + + + + + + + + +
+ 3 cases selected + + Clear + Export + +
+
`, + }, + webComponents: ` + + + + + + + +
+ 3 cases selected + + Clear + Export + +
+
+`, + }, + }, + { + id: "push-drawer", + name: "Push drawer", + description: + "An optional drawer that opens beside the content instead of over it. It narrows the content rather than covering it, so people can still work with the page while the drawer is open. The side menu stays in place.", + code: { + react: { + ts: `const [open, setOpen] = useState(false); + +const sideMenu = ( + navigate(path)} + primaryContent={ + <> + + + + } + /> +); + +const drawer = ( + setOpen(false)}> + {/* drawer content */} + +);`, + jsx: ` +
+ setOpen(true)}>Open + {/* main page content */} +
+
`, + }, + angular: { + ts: `export class SomeComponent { + open = false; + handleNavigate(path: string) { + // route to path + } +}`, + template: ` +
+ Open + +
+
+ + + + + + + + + + + + +`, + }, + webComponents: ` + + + + + +
+ Open +
+
+ + +
+
+
+`, + }, + }, + { + id: "monitor-scroll-state", + name: "Monitor scroll state", + description: + "React to scroll progress inside the layout. Here the page header collapses its subtitle once content scrolls past the top. React exposes the useGoabWorkspaceLayoutScrollState hook, Angular the GoabWorkspaceLayoutScrollStateService (signals), and the web component a _scrollStateChange event. The scroll states are no-scroll, at-top, middle, and at-bottom.", + code: { + react: { + ts: `import { + GoabWorkSideMenu, + GoabWorkSideMenuItem, + GoabWorkspaceLayout, + useGoabWorkspaceLayoutScrollState, +} from "@abgov/react-components"; + +const rows = Array.from({ length: 20 }, (_, i) => i + 1); + +const sideMenu = ( + + + + + } + /> +); + +function PageHeader() { + const { scrollPosition } = useGoabWorkspaceLayoutScrollState(); + const collapsed = + scrollPosition === "middle" || scrollPosition === "at-bottom"; + return ( +
+

My cases

+ {!collapsed && ( +

Scroll down to collapse this subtitle.

+ )} +
+ ); +}`, + jsx: `}> +
+ {rows.map((n) => ( +

Case row {n}

+ ))} +
+
`, + }, + angular: { + ts: `import { computed, inject } from "@angular/core"; +import { GoabWorkspaceLayoutScrollStateService } from "@abgov/angular-components"; + +export class SomeComponent { + rows = Array.from({ length: 20 }, (_, i) => i + 1); + + // The service is populated by goab-workspace-layout; read its signals anywhere. + private scrollState = inject(GoabWorkspaceLayoutScrollStateService); + + // Collapse once the user scrolls past the top. + collapsed = computed(() => { + const pos = this.scrollState.scrollPosition(); + return pos === "middle" || pos === "at-bottom"; + }); +}`, + template: ` +
+ @for (n of rows; track n) { +

Case row {{ n }}

+ } +
+
+ + +

My cases

+ @if (!collapsed()) { +

Scroll down to collapse this subtitle.

+ } +
+ + + + + + + +`, + }, + webComponents: ` + + + + + +
+ My cases + Scroll down to collapse this subtitle. +
+ +
+
+`, + }, + }, + ], +}; diff --git a/docs/src/layouts/DocumentationPageLayout.astro b/docs/src/layouts/DocumentationPageLayout.astro index bf7355fd71..3297b7e0fd 100644 --- a/docs/src/layouts/DocumentationPageLayout.astro +++ b/docs/src/layouts/DocumentationPageLayout.astro @@ -233,13 +233,13 @@ const getStartedNav = await getGetStartedNav(); margin-bottom: var(--goa-space-xs); } - /* Links */ - .prose-content :global(a) { + /* Links — excludes card components that happen to use as their root */ + .prose-content :global(a:not(.card-lite):not(.related-card)) { color: var(--goa-color-interactive-default); text-decoration: underline; } - .prose-content :global(a:hover) { + .prose-content :global(a:not(.card-lite):not(.related-card):hover) { color: var(--goa-color-interactive-hover); } diff --git a/docs/src/layouts/ExamplesPageLayout.astro b/docs/src/layouts/ExamplesPageLayout.astro index 860ec99154..5e5b55e91a 100644 --- a/docs/src/layouts/ExamplesPageLayout.astro +++ b/docs/src/layouts/ExamplesPageLayout.astro @@ -118,6 +118,21 @@ const getStartedNav = await getGetStartedNav(); padding: var(--goa-space-xl) var(--card-padding-h); } + /* Inline code chips in the slotted MDX content (matches documentation pages) */ + .content-card :global(code) { + font-family: "Roboto Mono", monospace; + font-size: var(--goa-font-size-2); + background: var(--goa-color-greyscale-100, #f5f5f5); + padding: 0.125em 0.375em; + border-radius: var(--goa-border-radius-xs); + color: var(--goa-color-text-default, #333); + } + + .content-card :global(pre code) { + background: none; + padding: 0; + } + /* Tablet: modal drawer mode (push drawer switches at 1023px) */ @media (max-width: 1023px) { .content-card { diff --git a/docs/src/layouts/PreviewLayout.astro b/docs/src/layouts/PreviewLayout.astro index f6b2a5f064..fe512396ea 100644 --- a/docs/src/layouts/PreviewLayout.astro +++ b/docs/src/layouts/PreviewLayout.astro @@ -23,6 +23,7 @@ interface Props { backLabel?: string; reactSourceUrl?: string; angularSourceUrl?: string; + figmaUrl?: string; } const { @@ -31,6 +32,7 @@ const { backLabel = "Back to Examples", reactSourceUrl, angularSourceUrl, + figmaUrl, } = Astro.props; const resolvedBackUrl = withBase(backUrl); @@ -76,6 +78,16 @@ const resolvedBackUrl = withBase(backUrl); >
)} + {figmaUrl && ( + + + + )}
@@ -276,72 +184,55 @@ data-first-focus="true" aria-labelledby={_showCloseButton ? "goa-drawer-heading" : undefined} > - - {#if _showCloseButton} -
- {#if heading || $$slots.heading} - {#if heading} - {#if version === "2"} - {heading} + + {#if _showCloseButton} +
+ {#if heading || $$slots.heading} + {#if heading} + {#if version === "2"} + {heading} + {:else} + {heading} + {/if} {:else} - {heading} + {/if} - {:else} - {/if} - {/if} - - -
- {/if} - - -
- {#if version === "1"} - -
- -
-
- {:else} + + +
+ {/if} + +
+
+ + {#if $$slots.actions} +
+ +
{/if} -
- - - {#if $$slots.actions} -
- -
- {/if} +
@@ -358,24 +249,10 @@ transition: opacity 200ms ease-out; } - /* Shadow styles for scrollable content */ - .root.top .drawer-content { - box-shadow: inset 0 -8px 8px -8px rgba(0, 0, 0, 0.3); - } - - .root.bottom .drawer-content { - box-shadow: inset 0 8px 8px -8px rgba(0, 0, 0, 0.3); - } - - .root.middle .drawer-content { - box-shadow: - inset 0 8px 8px -8px rgba(0, 0, 0, 0.2), - inset 0 -8px 8px -8px rgba(0, 0, 0, 0.2); - } - - /* V2: Remove scroll shadows (sticky elements provide visual feedback) */ - .root .drawer.v2 .drawer-content { - box-shadow: none !important; + /* scroll-panel owns scroll mechanics and the sticky header/footer scroll cue (V1 + V2) */ + .drawer goa-scroll-panel { + flex: 1 1 auto; + min-height: 0; } /* Background overlay */ @@ -437,32 +314,9 @@ margin-top: -0.1875rem; } - /* V2: Show header border when scrolled from top (middle or bottom position) */ - .root.middle .drawer.v2 .header, - .root.bottom .drawer.v2 .header { - border-bottom: var(--goa-border-width-s) solid - var(--goa-color-greyscale-200); - } - - /* Content styles */ - .drawer-content { - box-shadow: none; - flex: 0 1 auto; /* Don't grow, but can shrink - keeps actions below content */ - min-height: 0; /* Allow flexbox to shrink this element */ - overflow: hidden; /* Contain the scrollable content */ - } - - /* V2: drawer-content scrolls and takes remaining space */ - .v2.drawer-right .drawer-content, - .v2.drawer-left .drawer-content, - .v2.drawer-bottom .drawer-content { - flex: 1 1 auto; /* Take remaining space */ - overflow-y: auto; /* V2 scrolls here, not via goa-scrollable */ - } - .scroll-content { padding: var(--goa-drawer-content-padding-vertical, var(--goa-space-l)) - var(--goa-drawer-content-padding-horizontal, var(--goa-space-xl)); + var(--goa-drawer-content-padding-horizontal, var(--goa-space-l)); } /* Remove margin-top from first child in content to prevent double spacing */ @@ -489,12 +343,6 @@ border-top: none; /* Remove border by default */ } - /* V2: Show actions border when has overflow AND not at bottom (top or middle position) */ - .root.top .drawer.v2 .drawer-actions, - .root.middle .drawer.v2 .drawer-actions { - border-top: var(--goa-border-width-s) solid var(--goa-color-greyscale-200); - } - /* V2: Bottom drawer actions rounded corners */ .v2.drawer-bottom .drawer-actions { border-bottom-left-radius: var(--goa-drawer-border-radius, 24px); @@ -553,8 +401,7 @@ .drawer-right { right: var(--drawer-offset); - height: auto; /* Content-driven height */ - min-height: 100vh; /* V1: Full height background */ + height: 100vh; /* V1: bound to viewport so the scroll-panel scrolls internally */ transform: translateX(100%); box-shadow: var(--goa-drawer-right-shadow); } @@ -569,7 +416,6 @@ top: var(--goa-drawer-offset, 0); /* No bottom positioning - allows height: auto to work naturally */ height: auto; - min-height: 0; /* Override V1 min-height */ /* Max-height accounts for BOTH top and bottom margins (modal stays floating) */ max-height: calc(100vh - 2 * var(--goa-drawer-offset, 0)); overflow-y: hidden; /* No scroll on drawer itself */ @@ -590,17 +436,11 @@ transform: translateX(0); /* Slide in */ } - /* V2: When scrolled to bottom, add bottom constraint to lift drawer up */ - .root.bottom .v2.drawer-right { - bottom: var(--goa-drawer-offset, 0); - } - /* Left */ .drawer-left { left: var(--drawer-offset); - height: auto; /* Content-driven height */ - min-height: 100vh; /* V1: Full height background */ + height: 100vh; /* V1: bound to viewport so the scroll-panel scrolls internally */ transform: translateX(-100%); box-shadow: var(--goa-drawer-left-shadow); } @@ -615,7 +455,6 @@ top: var(--goa-drawer-offset, 0); /* No bottom positioning - allows height: auto to work naturally */ height: auto; - min-height: 0; /* Override V1 min-height */ /* Max-height accounts for BOTH top and bottom margins (modal stays floating) */ max-height: calc(100vh - 2 * var(--goa-drawer-offset, 0)); overflow-y: hidden; /* No scroll on drawer itself */ @@ -635,9 +474,4 @@ left: var(--goa-drawer-offset, 0); transform: translateX(0); /* Slide in */ } - - /* V2: When scrolled to bottom, add bottom constraint to lift drawer up */ - .root.bottom .v2.drawer-left { - bottom: var(--goa-drawer-offset, 0); - } diff --git a/libs/web-components/src/components/drawer/drawer.spec.ts b/libs/web-components/src/components/drawer/drawer.spec.ts index a7dffe574a..91e4077f0d 100644 --- a/libs/web-components/src/components/drawer/drawer.spec.ts +++ b/libs/web-components/src/components/drawer/drawer.spec.ts @@ -165,61 +165,4 @@ describe("Drawer", () => { expect(header).toBeTruthy(); }); }); - - it("updates scroll position correctly", async () => { - const el = render(DrawerWrapper, { - position: "right", - heading: "Heading", - content: "Content".repeat(100), // Create long content to enable scrolling - testid: "drawer", - }); - - const buttonEl = await el.findByTestId("open"); - - // Open the drawer and wait for it to be visible - await fireEvent.click(buttonEl); - const drawerEl = await el.findByTestId("drawer"); - await waitFor(() => { - expect(drawerEl.style.pointerEvents).toBe("auto"); - }); - - const scrollableEl = drawerEl.querySelector("goa-scrollable"); - expect(scrollableEl).toBeTruthy(); - - // Wait for the drawer to be fully rendered - await waitFor(() => { - expect(scrollableEl).toBeTruthy(); - }); - - // Simulate scroll events - await fireEvent( - scrollableEl as Element, - new CustomEvent("_scroll", { - detail: { scrollTop: 0, scrollHeight: 1000, offsetHeight: 500 }, - }), - ); - await waitFor(() => { - expect(drawerEl.classList.contains("top")).toBe(true); - }); - - await fireEvent( - scrollableEl as Element, - new CustomEvent("_scroll", { - detail: { scrollTop: 250, scrollHeight: 1000, offsetHeight: 500 }, - }), - ); - await waitFor(() => { - expect(drawerEl?.classList.contains("middle")).toBe(true); - }); - - await fireEvent( - scrollableEl as Element, - new CustomEvent("_scroll", { - detail: { scrollTop: 500, scrollHeight: 1000, offsetHeight: 500 }, - }), - ); - await waitFor(() => { - expect(drawerEl.classList.contains("bottom")).toBe(true); - }); - }); }); diff --git a/libs/web-components/src/components/modal/Modal.svelte b/libs/web-components/src/components/modal/Modal.svelte index 0e9ce8d64c..4105d7043b 100644 --- a/libs/web-components/src/components/modal/Modal.svelte +++ b/libs/web-components/src/components/modal/Modal.svelte @@ -43,15 +43,9 @@ // ******* let _rootEl: HTMLElement | null = null; - let _scrollPos: "top" | "middle" | "bottom" | null = "top"; - let _scrollEl: HTMLElement | undefined; - let _headerEl: HTMLElement | undefined; let _isOpen: boolean = false; - let _actionsHeight: number; let _headingSlotHasContent = false; let _actionsSlotHasContent = false; - let _headerHeight: number; - let _edgeMargin: number = 128; //64px top edge + 64px bottom edge // Type verification const [CALLOUT_VARIANT, validateCalloutVariant] = typeValidator( @@ -81,12 +75,6 @@ // DDIDS-1288 $: setTimeout(() => (_isOpen = toBoolean(open)), 1); - // Show the shadow at the top of the content after scrolling down - $: if (_isOpen && _scrollEl) { - const hasScroll = _scrollEl.scrollHeight > _scrollEl.offsetHeight; - _scrollPos = hasScroll ? "top" : null; - } - $: _transitionTime = transition === "none" ? 0 : transition === "slow" ? 400 : 200; @@ -188,25 +176,6 @@ break; } }; - - function handleScroll(e: CustomEvent) { - const hasScroll = e.detail.scrollHeight > e.detail.offsetHeight; - if (!_isOpen || !hasScroll) return; - - // top - if (e.detail.scrollTop == 0) { - _scrollPos = "top"; - } else if ( - // bottom - Math.abs( - e.detail.scrollHeight - e.detail.scrollTop - e.detail.offsetHeight, - ) < 1 - ) { - _scrollPos = "bottom"; - } else { - _scrollPos = "middle"; - } - } {#if _isOpen} @@ -216,8 +185,8 @@ in:fade={{ duration: _transitionTime }} out:fade={{ delay: _transitionTime, duration: _transitionTime }} data-testid={testid} - class={`modal ${_scrollPos ?? ""}`} - style={`--maxwidth: ${maxwidth}; --actions-height: ${_actionsHeight}px; --header-height: ${_headerHeight}`} + class="modal" + style={`--maxwidth: ${maxwidth}`} role="presentation" bind:this={_rootEl} > @@ -244,70 +213,63 @@ {/if}
-
-
- + +
@@ -444,18 +406,11 @@ } .modal-content { - margin: var(--goa-modal-content-margin-mobile, 0 -1.5rem); - padding: var(--goa-modal-content-padding, 0); + margin: 0; + padding: 0 var(--goa-modal-scrollable-padding-mobile, var(--goa-space-m)); box-shadow: none; } - :host { - --scrollable-padding: var( - --goa-modal-scrollable-padding-mobile, - var(--goa-scrollable-padding-mobile) - ); - } - /* V2 Mobile Overrides */ .v2 .content header { padding: var(--goa-modal-heading-padding-mobile, var(--goa-space-m)); @@ -471,7 +426,7 @@ .v2 .modal-content { padding: var( --goa-modal-content-padding-mobile, - var(--goa-space-l) var(--goa-space-m) var(--goa-space-xl) + var(--goa-space-xl) var(--goa-space-m) var(--goa-space-xl) var(--goa-space-m) ); } @@ -490,15 +445,16 @@ } .modal-content { - margin: var(--goa-modal-content-margin, 0 -2rem); - padding: var(--goa-modal-content-padding, 0); + margin: 0; + padding: 0 var(--goa-modal-scrollable-padding-desktop, var(--goa-space-l)); box-shadow: none; } - :host { - --scrollable-padding: var( - --goa-modal-scrollable-padding-desktop, - var(--goa-scrollable-padding-desktop) + .v2 .modal-content { + padding: var( + --goa-modal-content-padding-desktop, + var(--goa-space-l) var(--goa-space-l) var(--goa-space-xl) + var(--goa-space-l) ); } } @@ -517,13 +473,32 @@ border: none; } + /* Bound the pane so scroll-panel inside has a flex height context. 160px = + 128px edge margin (64px top + 64px bottom) + 32px (matches the prior V1 + maxheight calc's extra --goa-space-xl term), */ + .modal-pane { + max-height: calc(100vh - 160px); + overflow: hidden; + } + + .content { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + goa-scroll-panel { + flex: 1 1 auto; + min-height: 0; + } + .modal-content :global(::slotted(:last-child)) { margin-bottom: var(--goa-space-m) !important; } - /* V2: Remove margins from slotted content to prevent spacing issues */ - .v2 .modal-content :global(::slotted(*)) { - margin: 0 !important; + .v2 .modal-content :global(::slotted(:first-child)) { + margin-top: 0 !important; } .modal-title { @@ -553,20 +528,6 @@ padding: 0; } - .modal.top .modal-content { - box-shadow: inset 0 -8px 8px -8px rgba(0, 0, 0, 0.3); - } - - .modal.bottom .modal-content { - box-shadow: inset 0 8px 8px -8px rgba(0, 0, 0, 0.3); - } - - .modal.middle .modal-content { - box-shadow: - inset 0 8px 8px -8px rgba(0, 0, 0, 0.2), - inset 0 -8px 8px -8px rgba(0, 0, 0, 0.2); - } - /* V2 Callout Styles */ .v2 .modal-heading-content { display: flex; diff --git a/libs/web-components/src/components/push-drawer/PushDrawerInternal.svelte b/libs/web-components/src/components/push-drawer/PushDrawerInternal.svelte index 192bfa1e9e..a71264cdb7 100644 --- a/libs/web-components/src/components/push-drawer/PushDrawerInternal.svelte +++ b/libs/web-components/src/components/push-drawer/PushDrawerInternal.svelte @@ -12,8 +12,12 @@ /> @@ -190,70 +141,62 @@ aria-labelledby="goa-drawer-heading" style="--goa-push-drawer-width: {width};" > -
-
- {#if heading || $$slots.heading} - {#if heading} - {#if version === "2"} - - {heading} - + +
+
+ {#if heading || $$slots.heading} + {#if heading} + {#if version === "2"} + + {heading} + + {:else} + + {heading} + + {/if} {:else} - - {heading} - + {/if} - {:else} - {/if} - {/if} - - + + +
-
- -
-
- + +
+
+ +
-
- - {#if $$slots.actions} -
- -
- {/if} + + {#if $$slots.actions} +
+ +
+ {/if} +
diff --git a/libs/web-components/src/components/scroll-panel/ScrollPanelWrapper.test.svelte b/libs/web-components/src/components/scroll-panel/ScrollPanelWrapper.test.svelte new file mode 100644 index 0000000000..7b5e7d9b40 --- /dev/null +++ b/libs/web-components/src/components/scroll-panel/ScrollPanelWrapper.test.svelte @@ -0,0 +1,18 @@ + + + + + + {#if header} +
{header}
+ {/if} +
{@html content}
+ {#if footer} + + {/if} +
diff --git a/libs/web-components/src/components/tabs/Tabs.svelte b/libs/web-components/src/components/tabs/Tabs.svelte index 6dba4e53d6..a05348f885 100644 --- a/libs/web-components/src/components/tabs/Tabs.svelte +++ b/libs/web-components/src/components/tabs/Tabs.svelte @@ -36,6 +36,7 @@ let _segmentedTransitionDuration: number = 0; let _previousTabIndex: number = 1; let _visibilityObserver: IntersectionObserver | null = null; + let _resizeObserver: ResizeObserver | null = null; const MIN_TRANSITION_DURATION = 200; const DURATION_PER_PIXEL = 0.2; @@ -63,6 +64,10 @@ _visibilityObserver.disconnect(); _visibilityObserver = null; } + if (_resizeObserver) { + _resizeObserver.disconnect(); + _resizeObserver = null; + } }); // ========= @@ -208,7 +213,7 @@ link.setAttribute("aria-controls", `tabpanel-${index + 1}`); // Store text content for CSS pseudo-element (prevents layout shift when font-weight changes) - if (variant === "segmented") { + if (variant === "segmented" || version === "2") { const textContent = headingEl.textContent?.trim() || ""; if (textContent) { link.setAttribute("data-text", textContent); @@ -245,6 +250,16 @@ }); _visibilityObserver.observe(_tabsEl); } + + // The indicator is positioned absolutely from a one-time measurement, so it + // drifts out of alignment when the tabs reflow (long heading wraps, window + // resize). Re-measure whenever the container's size changes. + if (_tabsEl && !_resizeObserver) { + _resizeObserver = new ResizeObserver(() => { + updateSegmentedIndicatorPosition({ withAnimation: false }); + }); + _resizeObserver.observe(_tabsEl); + } }); } } @@ -351,7 +366,7 @@ if (isCurrent) { currentLocation = (el as HTMLLinkElement).href; if (!skipFocus) { - el.focus(); + el.focus({ preventScroll: true }); } } }); @@ -765,6 +780,19 @@ } } + /* Prevent layout shift when bold font is applied to selected tab */ + .v2:not(.segmented) :global([role="tab"][data-text]) { + flex-direction: column; + } + + .v2:not(.segmented) :global([role="tab"][data-text]::before) { + content: attr(data-text); + font: var(--goa-tab-typography-selected); + height: 0; + visibility: hidden; + overflow: hidden; + } + /* ======================================== Segmented Variant (Pill/Button style tabs) ======================================== */ diff --git a/libs/web-components/src/components/tabs/tabs.spec.ts b/libs/web-components/src/components/tabs/tabs.spec.ts index adabfffb1b..3a717f4e35 100644 --- a/libs/web-components/src/components/tabs/tabs.spec.ts +++ b/libs/web-components/src/components/tabs/tabs.spec.ts @@ -109,6 +109,24 @@ describe("Tabs", () => { }); }); + it("should focus the selected tab with preventScroll to avoid page jump", async () => { + const { container } = render(Tabs); + + const tab2Link = (await waitFor(() => { + const link = container.querySelector("a#tab-2") as HTMLElement; + expect(link).toBeTruthy(); + return link; + })) as HTMLElement; + + const focusSpy = vi.spyOn(tab2Link, "focus"); + + await fireEvent.click(tab2Link); + + await waitFor(() => { + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + }); + }); + it("should select the last tab if the tab exceeds the number of tabs", async () => { const result = render(Tabs, { initialtab: 3 }); diff --git a/libs/web-components/src/components/text/Text.svelte b/libs/web-components/src/components/text/Text.svelte index 7321af7a0a..59f564b0f6 100644 --- a/libs/web-components/src/components/text/Text.svelte +++ b/libs/web-components/src/components/text/Text.svelte @@ -5,6 +5,7 @@ export type TextElement = "span" | "div" | "p"; type HeadingSize = + | "heading-2xl" | "heading-xl" | "heading-l" | "heading-m" @@ -77,6 +78,8 @@ } switch (effectiveSize) { + case "heading-2xl": + return "l"; case "heading-xl": return "l"; case "heading-l": @@ -106,6 +109,7 @@ } switch (effectiveSize) { + case "heading-2xl": case "heading-xl": case "heading-l": case "heading-m": @@ -155,6 +159,20 @@ margin: 0; } + /* + * heading-2xl (like heading-2xs) exists only in the V2 token set. When a + * consumer has only V1 tokens loaded the token is undefined, so it falls back + * to the nearest size that exists in V1 (heading-xl) rather than collapsing to + * unstyled text. Sizes that exist in both token sets (heading-xl through + * heading-xs) reference their token directly and need no fallback. + */ + .heading-2xl { + font: var(--goa-typography-heading-2xl, var(--goa-typography-heading-xl)); + letter-spacing: var( + --goa-typography-heading-2xl-letter-spacing, + var(--goa-typography-heading-xl-letter-spacing) + ); + } .heading-xl { font: var(--goa-typography-heading-xl); letter-spacing: var(--goa-typography-heading-xl-letter-spacing); @@ -193,6 +211,14 @@ } @media (--mobile) { + /* V2-only size; falls back to the nearest V1 size. See the note on the desktop .heading-2xl rule above. */ + .heading-2xl { + font: var(--goa-typography-mobile-heading-2xl, var(--goa-typography-mobile-heading-xl)); + letter-spacing: var( + --goa-typography-mobile-heading-2xl-letter-spacing, + var(--goa-typography-mobile-heading-xl-letter-spacing) + ); + } .heading-xl { font: var(--goa-typography-mobile-heading-xl); letter-spacing: var(--goa-typography-mobile-heading-xl-letter-spacing); 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 7cdd29e252..5b881b5c10 100644 --- a/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte +++ b/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte @@ -16,8 +16,15 @@ /> + +