From 050d612e5161f6688e0582d9f96b25773f107c1d Mon Sep 17 00:00:00 2001 From: jeripeierSBB Date: Mon, 23 Mar 2026 10:43:23 +0100 Subject: [PATCH 1/2] fix: fix api generator --- scripts/api-generator.mts | 222 ++++++------------ src/angular/header/header.module.ts | 2 + .../sidebar-container/sidebar-container.ts | 2 +- src/angular/toast/simple-toast.ts | 1 + 4 files changed, 79 insertions(+), 148 deletions(-) diff --git a/scripts/api-generator.mts b/scripts/api-generator.mts index 1809cc5c..1ae221ae 100644 --- a/scripts/api-generator.mts +++ b/scripts/api-generator.mts @@ -1,123 +1,26 @@ -import { fileURLToPath } from 'url'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { basename, dirname, join, normalize, relative } from 'path'; +import { fileURLToPath } from 'url'; const root = fileURLToPath(new URL('../', import.meta.url)); const documentation = JSON.parse(readFileSync(join(root, `/src/docs/documentation.json`), 'utf8')); const modulesWithLegacySubmodules = ['checkbox', 'link-list', 'radio-button']; +const ignoredFolders = ['core']; /** - * The configuration object used to merge different `api` files in a single one for the angular project. - */ -const mergeConfigAngular = { - alert: ['alert', 'alert-group'], - breadcrumb: ['breadcrumb', 'breadcrumb-group'], - button: [ - 'button', - 'button-link', - 'button-static', - 'accent-button', - 'accent-button-link', - 'accent-button-static', - 'secondary-button', - 'secondary-button-link', - 'secondary-button-static', - 'transparent-button', - 'transparent-button-link', - 'transparent-button-static', - 'mini-button', - 'mini-button-group', - ], - card: ['card', 'card-button', 'card-link', 'card-badge'], - carousel: ['carousel', 'carousel-list', 'carousel-item'], - chip: ['chip', 'chip-group'], - container: ['container', 'sticky-bar'], - datepicker: ['datepicker', 'datepicker-toggle', 'datepicker-previous-day', 'datepicker-next-day'], - dialog: ['dialog', 'dialog-title', 'dialog-content', 'dialog-actions', 'dialog-close-button'], - 'expansion-panel': ['expansion-panel', 'expansion-panel-header', 'expansion-panel-content'], - 'file-selector': ['file-selector', 'file-selector-dropzone'], - 'flip-card': ['flip-card', 'flip-card-summary', 'flip-card-details'], - 'form-field': ['form-field', 'form-field-clear', 'error'], - header: ['header', 'header-button', 'header-link', 'header-environment'], - link: [ - 'link', - 'link-button', - 'link-static', - 'block-link', - 'block-link-button', - 'block-link-static', - ], - menu: ['menu', 'menu-button', 'menu-link'], - 'mini-calendar': ['mini-calendar', 'mini-calendar-month', 'mini-calendar-day'], - navigation: [ - 'navigation', - 'navigation-section', - 'navigation-list', - 'navigation-marker', - 'navigation-link', - 'navigation-button', - ], - option: ['option', 'optgroup', 'option-hint'], - paginator: ['paginator', 'compact-paginator'], - sidebar: [ - 'sidebar', - 'sidebar-container', - 'sidebar-content', - 'sidebar-title', - 'sidebar-close-button', - 'icon-sidebar', - 'icon-sidebar-container', - 'icon-sidebar-content', - 'icon-sidebar-button', - 'icon-sidebar-link', - ], - stepper: ['stepper', 'step', 'step-label'], - table: ['table', 'table-wrapper', 'sort'], - tab: ['tab', 'tab-group', 'tab-label'], - tag: ['tag', 'tag-group'], - teaser: ['teaser', 'teaser-hero', 'teaser-product', 'teaser-product-static'], - 'timetable-form': [ - 'timetable-form', - 'timetable-form-field', - 'timetable-form-details', - 'timetable-form-swap-button', - ], - timetable: [ - 'train-formation', - 'train', - 'train-wagon', - 'train-blocked-passage', - 'timetable-occupancy', - 'timetable-occupancy-icon', - ], - toggle: ['toggle', 'toggle-option', 'toggle-check'], -}; - -/** - * The configuration object used to merge different `api` files in a single one for the angular-experimental project. + * Reads the module names for a given package from meta.ts. + * For each module listed in PACKAGES (from meta.ts), all api files are merged into a single file. */ -const mergeConfigAngularExperimental = { - 'autocomplete-grid': [ - 'autocomplete-grid', - 'autocomplete-grid-row', - 'autocomplete-grid-optgroup', - 'autocomplete-grid-option', - 'autocomplete-grid-cell', - 'autocomplete-grid-button', - ], - 'seat-reservation': [ - 'seat-reservation-area', - 'seat-reservation-graphic', - 'seat-reservation-navigation-coach', - 'seat-reservation-navigation-services', - 'seat-reservation-place-control', - 'seat-reservation-scoped', - ], -}; - -const mergeConfig: Record> = { - angular: mergeConfigAngular, - 'angular-experimental': mergeConfigAngularExperimental, +const getModuleNamesFromMeta = async (projectFolder: string): Promise => { + const { PACKAGES } = await import('../src/docs/app/shared/meta.js'); + const pkg = PACKAGES[projectFolder]; + if (!pkg) { + return []; + } + return pkg.sections + .flatMap((s) => s.entries) + .map((e) => e.link.split('/').at(-1)!) + .filter(Boolean); }; /** @@ -130,68 +33,93 @@ const mergeConfig: Record> = { * * @param projectFolder the name of the package (angular / angular-experimental / ...) */ -const generateApiFiles = (projectFolder: string) => { +const generateApiFiles = async (projectFolder: string) => { const projectPath = join(root, 'src', projectFolder); const outputPath = join(root, 'src/docs/app', projectFolder, 'api'); if (existsSync(outputPath)) { rmSync(outputPath, { recursive: true, force: true }); } mkdirSync(outputPath, { recursive: true }); - scanFoldersAndWriteFiles(projectPath, outputPath); - mergeFilesInModule(outputPath, mergeConfig[projectFolder]); + await mergeFilesInModule(projectPath, outputPath, projectFolder); }; /** * Recursive function which reaches the innermost folder and creates a `.md` file * with the documentation of the objects from compodoc that matches the final path. + * Returns the list of generated api file names. * * @param projectPath path of the source package * @param apiFolder path of the output folder */ -const scanFoldersAndWriteFiles = (projectPath: string, apiFolder: string) => { +const scanFoldersAndWriteFiles = (projectPath: string, apiFolder: string): string[] => { const folders = readdirSync(projectPath, { withFileTypes: true }); const subFolders = folders.filter((e) => e.isDirectory()); - - // Inner folder reached - if ( - subFolders.length === 0 || - modulesWithLegacySubmodules.some((m) => projectPath.endsWith(`/${m}`)) - ) { - // Scan the documentation file and possibly create the docs file. + const currentName = basename(projectPath); + const isLegacy = modulesWithLegacySubmodules.some((m) => projectPath.endsWith(`/${m}`)); + const hasSameNamedSubFolder = subFolders.some((e) => e.name === currentName); + const generated: string[] = []; + + // Scan the current folder unless it has a same-named sub-folder whose docs would + // duplicate it – except for legacy modules which always own their own docs directly. + if (isLegacy || !hasSameNamedSubFolder) { const readmeContent = createReadmeAPI(relative(root, normalize(projectPath))); if (readmeContent) { - const apiFileName = `${basename(projectPath)}-api.md`; - const outPath = join(apiFolder, apiFileName); - writeFileSync(outPath, readmeContent, { encoding: 'utf-8', flag: 'a' }); + const apiFileName = `${currentName}-api.md`; + writeFileSync(join(apiFolder, apiFileName), readmeContent, { encoding: 'utf-8', flag: 'a' }); + generated.push(apiFileName); } - return; } - // Inner folder not reached, go deeper recursively + // Stop recursion at leaf folders or legacy submodule roots + if (subFolders.length === 0 || isLegacy) { + return generated; + } + + // Go deeper recursively into all sub-folders, skipping ignored ones for (const sub of subFolders) { - const subPath = join(projectPath, sub.name); - scanFoldersAndWriteFiles(subPath, apiFolder); + if (!ignoredFolders.includes(sub.name)) { + generated.push(...scanFoldersAndWriteFiles(join(projectPath, sub.name), apiFolder)); + } } + + return generated; }; /** - * Based on the provided `config` object, it creates a single file from several ones. - * The config's values are mapped as `-api.md` files, - * then these files are read and joined as a single file, named as `-api.md`. - * @param path the project path - * @param config the key-values object used to generate the file + * For each module listed in meta.ts for the given package, merges all generated + * `*-api.md` files that belong to that module's folder into a single `-api.md`. + * + * The belonging files are determined by re-scanning `src///` + * – which is identical to what scanFoldersAndWriteFiles already did – so we reuse it + * in dry-run mode (no apiFolder writing needed, we only need the file names). */ -const mergeFilesInModule = (path: string, config: Record): void => { - Object.entries(config).forEach(([mainFile, subFiles]: [string, string[]]) => { - let outputDoc = ''; - subFiles - .map((fileName) => join(path, `${fileName}-api.md`)) - .forEach((file) => { - outputDoc += readFileSync(file, 'utf8'); - rmSync(file, { force: true }); - writeFileSync(join(path, `${mainFile}-api.md`), outputDoc, { encoding: 'utf-8' }); - }); - }); +const mergeFilesInModule = async ( + projectPath: string, + outputPath: string, + projectFolder: string, +): Promise => { + const moduleNames = await getModuleNamesFromMeta(projectFolder); + + for (const moduleName of moduleNames) { + const moduleDir = join(projectPath, moduleName); + if (!existsSync(moduleDir)) { + continue; + } + + // Scan this module's folder – writes the individual api files and returns their names + const belongingFiles = scanFoldersAndWriteFiles(moduleDir, outputPath); + + if (belongingFiles.length <= 1) { + continue; // nothing to merge + } + + const outputDoc = belongingFiles.map((f) => readFileSync(join(outputPath, f), 'utf8')).join(''); + + for (const f of belongingFiles) { + rmSync(join(outputPath, f), { force: true }); + } + writeFileSync(join(outputPath, `${moduleName}-api.md`), outputDoc, { encoding: 'utf-8' }); + } }; /** @@ -419,5 +347,5 @@ const createParametersForTable = (args: any[]): string => { return '-'; }; -generateApiFiles('angular'); -generateApiFiles('angular-experimental'); +await generateApiFiles('angular'); +await generateApiFiles('angular-experimental'); diff --git a/src/angular/header/header.module.ts b/src/angular/header/header.module.ts index 2a1674d8..b0ec5ee5 100644 --- a/src/angular/header/header.module.ts +++ b/src/angular/header/header.module.ts @@ -4,12 +4,14 @@ import { SbbHeader } from './header/header'; import { SbbHeaderButton } from './header-button/header-button'; import { SbbHeaderEnvironment } from './header-environment/header-environment'; import { SbbHeaderLink } from './header-link/header-link'; +import { SbbHeaderScrollOrigin } from './header-scroll-origin/header-scroll-origin'; const SBB_HEADER_EXPORTED_DECLARATIONS = [ SbbHeader, SbbHeaderButton, SbbHeaderEnvironment, SbbHeaderLink, + SbbHeaderScrollOrigin, ]; @NgModule({ diff --git a/src/angular/sidebar/sidebar-container/sidebar-container.ts b/src/angular/sidebar/sidebar-container/sidebar-container.ts index 3d069a06..ed71c126 100644 --- a/src/angular/sidebar/sidebar-container/sidebar-container.ts +++ b/src/angular/sidebar/sidebar-container/sidebar-container.ts @@ -8,7 +8,7 @@ import '@sbb-esta/lyne-elements/sidebar.js'; /** * This is the parent component to one or two ``s that validates the state internally -and coordinates the backdrop and content styling. + * and coordinates the backdrop and content styling. * * @slot - Use the unnamed slot to add `sbb-sidebar` and `sbb-sidebar-content` elements. */ diff --git a/src/angular/toast/simple-toast.ts b/src/angular/toast/simple-toast.ts index e39d69a9..29daabf0 100644 --- a/src/angular/toast/simple-toast.ts +++ b/src/angular/toast/simple-toast.ts @@ -4,6 +4,7 @@ import { SBB_OVERLAY_DATA } from '@sbb-esta/lyne-angular/core/overlay'; /** * A component used to open as the default toast, matching digital.sbb.ch spec. * This should only be used internally by the notification toast service. + * @internal */ @Component({ selector: 'sbb-simple-toast', From 996f05d27b0a44d792419b1ff9a8fafb9f15fc2e Mon Sep 17 00:00:00 2001 From: jeripeierSBB Date: Mon, 23 Mar 2026 10:50:32 +0100 Subject: [PATCH 2/2] feat: add header-scroll-origin --- .../header-scroll-origin.spec.ts | 62 ++++++++++++++ .../header-scroll-origin.ts | 13 +++ src/angular/header/index.ts | 1 + src/angular/header/readme.md | 80 +++++++++++++++++-- yarn.lock | 12 +-- 5 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 src/angular/header/header-scroll-origin/header-scroll-origin.spec.ts create mode 100644 src/angular/header/header-scroll-origin/header-scroll-origin.ts diff --git a/src/angular/header/header-scroll-origin/header-scroll-origin.spec.ts b/src/angular/header/header-scroll-origin/header-scroll-origin.spec.ts new file mode 100644 index 00000000..e62cc3a9 --- /dev/null +++ b/src/angular/header/header-scroll-origin/header-scroll-origin.spec.ts @@ -0,0 +1,62 @@ +import { Component } from '@angular/core'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SbbHeaderScrollOrigin } from './header-scroll-origin'; + +describe(`sbb-header-scroll-origin`, () => { + describe('attribute usage', () => { + let fixture: ComponentFixture, component: TestComponent; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', async () => { + expect(component).toBeDefined(); + expect( + fixture.nativeElement.querySelector('div').hasAttribute('sbb-header-scroll-origin'), + ).toBe(true); + }); + }); + + describe('host directive usage', () => { + let fixture: ComponentFixture, + component: TestComponentWithHostDirectiveApplied; + + beforeEach(async () => { + fixture = TestBed.createComponent(TestComponentWithHostDirectiveApplied); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', async () => { + expect(component).toBeDefined(); + expect( + fixture.nativeElement + .querySelector('sbb-test-div') + .hasAttribute('sbb-header-scroll-origin'), + ).toBe(true); + }); + }); +}); + +@Component({ + template: `
Label
`, + imports: [SbbHeaderScrollOrigin], +}) +class TestComponent {} + +@Component({ + selector: 'sbb-test-div', + template: ``, + hostDirectives: [SbbHeaderScrollOrigin], +}) +class TestComponentWithHostDirective {} + +@Component({ + template: `Label`, + imports: [TestComponentWithHostDirective], +}) +class TestComponentWithHostDirectiveApplied {} diff --git a/src/angular/header/header-scroll-origin/header-scroll-origin.ts b/src/angular/header/header-scroll-origin/header-scroll-origin.ts new file mode 100644 index 00000000..0fe37c94 --- /dev/null +++ b/src/angular/header/header-scroll-origin/header-scroll-origin.ts @@ -0,0 +1,13 @@ +import { Directive } from '@angular/core'; + +/** + * Directive to mark a scroll container as source of scrolling to the `sbb-header`. + * Can be placed on any scrollable element. + */ +@Directive({ + selector: '[sbb-header-scroll-origin]', + host: { + '[attr.sbb-header-scroll-origin]': '""', + }, +}) +export class SbbHeaderScrollOrigin {} diff --git a/src/angular/header/index.ts b/src/angular/header/index.ts index 95c3ead9..bf79589d 100644 --- a/src/angular/header/index.ts +++ b/src/angular/header/index.ts @@ -2,4 +2,5 @@ export * from './header/header'; export * from './header-button/header-button'; export * from './header-environment/header-environment'; export * from './header-link/header-link'; +export * from './header-scroll-origin/header-scroll-origin'; export * from './header.module'; diff --git a/src/angular/header/readme.md b/src/angular/header/readme.md index 0a8d3e41..ff1d0976 100644 --- a/src/angular/header/readme.md +++ b/src/angular/header/readme.md @@ -48,17 +48,23 @@ For the latter, the usage of the `` with `protective-room='panel'` i ``` -### Positioning and visibility +### Scroll behavior -By default, the `` has a fixed position at the top of the page; -when the page is scrolled down, a box-shadow appears below it and the component remains visible. -It's possible to change this behavior by setting the `hideOnScroll` property to `true`, or adding the `hide-on-scroll` -attribute: in this case, the box-shadow is still set, but the component disappears when the page is scrolled down and -then reappears as soon as it's scrolled up. It's also possible to bind this behavior to something other than the `document`, -using the `scrollOrigin` property, which accepts an `HTMLElement` or the id of the element to search for. +By default, the `` listens to scroll events on the `document`. Whenever the page is +scrolled down, a box-shadow appears beneath the header to visually separate it from the content. + +> **Note:** In applications where the page itself does not scroll — such as layouts with an +> [icon sidebar](/angular/components/icon-sidebar/overview) or [sidebar](/angular/components/sidebar/overview) where only the +> content area scrolls — the shadow will never appear unless the correct scroll container is +> configured. Always set a scroll origin in such setups (see below). + +#### Hide on scroll + +Setting the `hide-on-scroll` attribute causes the header to slide out of view when scrolling down +and reappear when scrolling back up. The box-shadow behavior is retained. ```html - + Search