From 8c0f840fd7f040070c1ab7488baa28a77623f050 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 30 Oct 2024 20:57:01 +0100 Subject: [PATCH] Add styling page to the docs Adds a "Styling" page to each component that tells users how to customize the component's styles. --- package.json | 2 +- .../component-sidenav/component-sidenav.ts | 4 +- .../component-viewer/component-styling.html | 34 ++++++++ .../component-viewer/component-styling.ts | 79 ++++++++++++++++++ .../component-viewer/component-viewer.spec.ts | 2 +- .../component-viewer/component-viewer.ts | 83 +++++++++---------- src/app/pages/component-viewer/token-name.ts | 42 ++++++++++ .../pages/component-viewer/token-table.html | 53 ++++++++++++ .../pages/component-viewer/token-table.scss | 39 +++++++++ src/app/pages/component-viewer/token-table.ts | 53 ++++++++++++ .../documentation-items.ts | 3 + yarn.lock | 24 +++--- 12 files changed, 357 insertions(+), 61 deletions(-) create mode 100644 src/app/pages/component-viewer/component-styling.html create mode 100644 src/app/pages/component-viewer/component-styling.ts create mode 100644 src/app/pages/component-viewer/token-name.ts create mode 100644 src/app/pages/component-viewer/token-table.html create mode 100644 src/app/pages/component-viewer/token-table.scss create mode 100644 src/app/pages/component-viewer/token-table.ts diff --git a/package.json b/package.json index 2c58f03a..11c6f7f4 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@angular/cdk-experimental": "^19.0.0-next.9", "@angular/common": "^19.0.0-next.10", "@angular/compiler": "^19.0.0-next.10", - "@angular/components-examples": "https://github.com/angular/material2-docs-content.git#4334c0c112b4a55c7257a5837ac94f5464938764", + "@angular/components-examples": "https://github.com/angular/material2-docs-content.git#3e3187172e1edc005a6669fa214e7b4bdc6a230b", "@angular/core": "^19.0.0-next.10", "@angular/forms": "^19.0.0-next.10", "@angular/google-maps": "^19.0.0-next.9", diff --git a/src/app/pages/component-sidenav/component-sidenav.ts b/src/app/pages/component-sidenav/component-sidenav.ts index 6f66f260..4f483289 100644 --- a/src/app/pages/component-sidenav/component-sidenav.ts +++ b/src/app/pages/component-sidenav/component-sidenav.ts @@ -51,6 +51,7 @@ import { ComponentViewer, ComponentViewerModule } from '../component-viewer/component-viewer'; +import {ComponentStyling} from '../component-viewer/component-styling'; // These constants are used by the ComponentSidenav for orchestrating the MatSidenav in a responsive // way. This includes hiding the sidenav, defaulting it to open, changing the mode from over to @@ -165,7 +166,8 @@ const routes: Routes = [{ {path: '', redirectTo: 'overview', pathMatch: 'full'}, {path: 'overview', component: ComponentOverview, pathMatch: 'full'}, {path: 'api', component: ComponentApi, pathMatch: 'full'}, - {path: 'examples', component: ComponentExamples, pathMatch: 'full'} + {path: 'styling', component: ComponentStyling, pathMatch: 'full'}, + {path: 'examples', component: ComponentExamples, pathMatch: 'full'}, ], }, {path: '**', redirectTo: '/404'} diff --git a/src/app/pages/component-viewer/component-styling.html b/src/app/pages/component-viewer/component-styling.html new file mode 100644 index 00000000..5e08edd9 --- /dev/null +++ b/src/app/pages/component-viewer/component-styling.html @@ -0,0 +1,34 @@ +@let item = docItem | async; +@let data = dataStream | async; +@let example = exampleStream | async; +@let hasData = hasDataStream | async; + +@if (!item || !data) { + Loading... +} @else if (!hasData) { + This component does not support style overrides +} @else { +

How to style {{item.id}}

+ Styles from the {{item.packageName}}/{{item.id}} package can be customized using + @if (data.length === 1) { + the {{data[0].overridesMixin}} mixin. + } @else { + the @for (current of data; track current.name) {{{$last ? ' and ' : ($first ? '' : ', ')}}{{current.overridesMixin}}} mixins. + } + {{data.length === 1 ? 'This mixin accepts' : 'These mixins accept'}} a set of tokens that control how the components will look, either for the entire app or under a specific selector. {{example ? 'For example:' : ''}} + + @if (example) { +
+
{{example}}
+
+ } + + You can find the full list of supported mixins and tokens below. + +
+ @for (current of data; track current.name) { +

Tokens supported by {{current.overridesMixin}}

+ + } +
+} diff --git a/src/app/pages/component-viewer/component-styling.ts b/src/app/pages/component-viewer/component-styling.ts new file mode 100644 index 00000000..9b7fcbd5 --- /dev/null +++ b/src/app/pages/component-viewer/component-styling.ts @@ -0,0 +1,79 @@ +import {Component, inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {AsyncPipe} from '@angular/common'; +import {Observable} from 'rxjs'; +import {map, shareReplay, switchMap} from 'rxjs/operators'; +import {ComponentViewer} from './component-viewer'; +import {DocItem} from '../../shared/documentation-items/documentation-items'; +import {Token, TokenTable} from './token-table'; + +interface StyleOverridesData { + name: string; + overridesMixin: string; + tokens: Token[]; +} + +@Injectable({providedIn: 'root'}) +class TokenService { + private _cache: Record> = {}; + + constructor(private _http: HttpClient) {} + + getTokenData(item: DocItem): Observable { + const url = `/docs-content/tokens/${item.packageName}/${item.id}/${item.id}.json`; + + if (this._cache[url]) { + return this._cache[url]; + } + + const stream = this._http.get(url).pipe(shareReplay(1)); + this._cache[url] = stream; + return stream; + } +} + +@Component({ + selector: 'component-styling', + templateUrl: './component-styling.html', + standalone: true, + imports: [AsyncPipe, TokenTable], +}) +export class ComponentStyling { + private componentViewer = inject(ComponentViewer); + private tokenService = inject(TokenService); + protected docItem = this.componentViewer.componentDocItem; + protected dataStream = + this.docItem.pipe(switchMap(item => this.tokenService.getTokenData(item))); + protected hasDataStream = this.dataStream.pipe( + map(data => data.length > 0 && data.some(d => d.tokens.length > 0))); + + protected exampleStream = this.dataStream.pipe(map(data => { + const mixin = data.find(d => d.tokens.length > 0); + + if (!mixin) { + return null; + } + + // Pick out a couple of color tokens to show as examples. + const firstToken = mixin.tokens.find(token => token.type === 'color'); + const secondToken = mixin.tokens.find(token => token.type === 'color' && token !== firstToken); + + if (!firstToken) { + return null; + } + + const lines = [ + `@use '@angular/material' as mat;`, + ``, + `// Customize the entire app. Change :root to your selector if you want to scope the styles.`, + `:root {`, + ` @include mat.${mixin.overridesMixin}((`, + ` ${firstToken.overridesName}: orange,`, + ...(secondToken ? [` ${secondToken.overridesName}: red,`] : []), + ` ));`, + `}`, + ]; + + return lines.join('\n'); + })); +} diff --git a/src/app/pages/component-viewer/component-viewer.spec.ts b/src/app/pages/component-viewer/component-viewer.spec.ts index 59aaf606..6fa2097f 100644 --- a/src/app/pages/component-viewer/component-viewer.spec.ts +++ b/src/app/pages/component-viewer/component-viewer.spec.ts @@ -44,7 +44,7 @@ describe('ComponentViewer', () => { throw Error(`Unable to find DocItem: '${docItemsId}' in section: 'material'.`); } const expected = `${docItem.name}`; - expect(component._componentPageTitle.title).toEqual(expected); + expect(component.componentPageTitle.title).toEqual(expected); }); }); diff --git a/src/app/pages/component-viewer/component-viewer.ts b/src/app/pages/component-viewer/component-viewer.ts index 9c3ea879..b19d6f61 100644 --- a/src/app/pages/component-viewer/component-viewer.ts +++ b/src/app/pages/component-viewer/component-viewer.ts @@ -51,32 +51,47 @@ export class ComponentViewer implements OnDestroy { sections: Set = new Set(['overview', 'api']); private _destroyed = new Subject(); - constructor(_route: ActivatedRoute, private router: Router, - public _componentPageTitle: ComponentPageTitle, - public docItems: DocumentationItems) { - const routeAndParentParams = [_route.params]; - if (_route.parent) { - routeAndParentParams.push(_route.parent.params); + constructor( + route: ActivatedRoute, + private router: Router, + public componentPageTitle: ComponentPageTitle, + readonly docItems: DocumentationItems) { + const routeAndParentParams = [route.params]; + if (route.parent) { + routeAndParentParams.push(route.parent.params); } // Listen to changes on the current route for the doc id (e.g. button/checkbox) and the // parent route for the section (material/cdk). combineLatest(routeAndParentParams).pipe( - map((params: Params[]) => ({id: params[0]['id'], section: params[1]['section']})), - map((docIdAndSection: {id: string, section: string}) => - ({doc: docItems.getItemById(docIdAndSection.id, docIdAndSection.section), - section: docIdAndSection.section}), takeUntil(this._destroyed)) - ).subscribe((docItemAndSection: {doc: DocItem | undefined, section: string}) => { - if (docItemAndSection.doc !== undefined) { - this.componentDocItem.next(docItemAndSection.doc); - this._componentPageTitle.title = `${docItemAndSection.doc.name}`; - - if (docItemAndSection.doc.examples && docItemAndSection.doc.examples.length) { - this.sections.add('examples'); - } else { - this.sections.delete('examples'); - } + map((params: Params[]) => { + const id = params[0]['id']; + const section = params[1]['section']; + + return ({ + doc: docItems.getItemById(id, section), + section: section + }); + }, + takeUntil(this._destroyed)) + ).subscribe(({doc, section}) => { + if (!doc) { + this.router.navigate(['/' + section]); + return; + } + + this.componentDocItem.next(doc); + componentPageTitle.title = `${doc.name}`; + + if (doc.hasStyling) { + this.sections.add('styling'); } else { - this.router.navigate(['/' + docItemAndSection.section]); + this.sections.delete('styling'); + } + + if (doc.examples && doc.examples.length) { + this.sections.add('examples'); + } else { + this.sections.delete('examples'); } }); } @@ -159,14 +174,6 @@ export class ComponentBaseView implements OnInit, OnDestroy { ], }) export class ComponentOverview extends ComponentBaseView { - constructor( - componentViewer: ComponentViewer, - breakpointObserver: BreakpointObserver, - changeDetectorRef: ChangeDetectorRef - ) { - super(componentViewer, breakpointObserver, changeDetectorRef); - } - getOverviewDocumentUrl(doc: DocItem) { // Use the explicit overview path if specified. Otherwise, compute an overview path based // on the package name and doc item id. Overviews for components are commonly stored in a @@ -191,14 +198,6 @@ export class ComponentOverview extends ComponentBaseView { ], }) export class ComponentApi extends ComponentBaseView { - constructor( - componentViewer: ComponentViewer, - breakpointObserver: BreakpointObserver, - changeDetectorRef: ChangeDetectorRef - ) { - super(componentViewer, breakpointObserver, changeDetectorRef); - } - getApiDocumentUrl(doc: DocItem) { const apiDocId = doc.apiDocId || `${doc.packageName}-${doc.id}`; return `/docs-content/api-docs/${apiDocId}.html`; @@ -215,15 +214,7 @@ export class ComponentApi extends ComponentBaseView { AsyncPipe, ], }) -export class ComponentExamples extends ComponentBaseView { - constructor( - componentViewer: ComponentViewer, - breakpointObserver: BreakpointObserver, - changeDetectorRef: ChangeDetectorRef - ) { - super(componentViewer, breakpointObserver, changeDetectorRef); - } -} +export class ComponentExamples extends ComponentBaseView {} @NgModule({ imports: [ diff --git a/src/app/pages/component-viewer/token-name.ts b/src/app/pages/component-viewer/token-name.ts new file mode 100644 index 00000000..0661a7d8 --- /dev/null +++ b/src/app/pages/component-viewer/token-name.ts @@ -0,0 +1,42 @@ +import {Component, input, inject} from '@angular/core'; +import {MatIconButton} from '@angular/material/button'; +import {Clipboard} from '@angular/cdk/clipboard'; +import {MatIcon} from '@angular/material/icon'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {MatTooltip} from '@angular/material/tooltip'; + +@Component({ + selector: 'token-name', + standalone: true, + template: ` + {{name()}} + + `, + styles: ` + :host { + display: flex; + align-items: center; + + button { + margin-left: 8px; + } + } + `, + imports: [MatIconButton, MatIcon, MatTooltip], +}) +export class TokenName { + private clipboard = inject(Clipboard); + private snackbar = inject(MatSnackBar); + + name = input.required(); + + protected copy(name: string): void { + const message = this.clipboard.copy(name) ? 'Copied token name' : 'Failed to copy token name'; + this.snackbar.open(message, undefined, {duration: 2500}); + } +} diff --git a/src/app/pages/component-viewer/token-table.html b/src/app/pages/component-viewer/token-table.html new file mode 100644 index 00000000..3001fa4e --- /dev/null +++ b/src/app/pages/component-viewer/token-table.html @@ -0,0 +1,53 @@ +
+ + Filter by name + + + + + Filter by type + + @for (type of types; track $index) { + {{type | titlecase}} + } + + + + +
+ +
+ + + + + + + + + + + @for (token of filteredTokens(); track token.overridesName) { + + + + + + } @empty { + + + + } + +
NameTypeBased on system token
{{token.type | titlecase}} + @if (token.derivedFrom) { + + } @else { + None + } +
No tokens match the current set of filters
+
diff --git a/src/app/pages/component-viewer/token-table.scss b/src/app/pages/component-viewer/token-table.scss new file mode 100644 index 00000000..0defe3e7 --- /dev/null +++ b/src/app/pages/component-viewer/token-table.scss @@ -0,0 +1,39 @@ +:host { + display: block; +} + +table { + table-layout: fixed; +} + +thead { + position: sticky; + top: 0; + left: 0; + background: #fdfbff; + z-index: 1; +} + +.filters { + display: flex; + align-items: center; + margin: 24px 0; + width: 100%; +} + +.filters mat-form-field { + margin-right: 16px; +} + +.name-field { + width: 380px; + max-width: 100%; +} + +.type-header { + width: 10%; +} + +.system-header { + width: 30%; +} diff --git a/src/app/pages/component-viewer/token-table.ts b/src/app/pages/component-viewer/token-table.ts new file mode 100644 index 00000000..1e3b4d90 --- /dev/null +++ b/src/app/pages/component-viewer/token-table.ts @@ -0,0 +1,53 @@ +import {ChangeDetectionStrategy, Component, computed, input, signal} from '@angular/core'; +import {TitleCasePipe} from '@angular/common'; +import {MatButtonModule} from '@angular/material/button'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {TokenName} from './token-name'; + +type TokenType = 'base' | 'color' | 'typography' | 'density'; + +export interface Token { + name: string; + overridesName: string; + prefix: string; + type: TokenType; + derivedFrom?: string; +} + +@Component({ + selector: 'token-table', + templateUrl: './token-table.html', + styleUrl: './token-table.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + TokenName, + TitleCasePipe, + ] +}) +export class TokenTable { + tokens = input.required(); + + protected nameFilter = signal(''); + protected typeFilter = signal(null); + protected types: TokenType[] = ['base', 'color', 'typography', 'density']; + protected filteredTokens = computed(() => { + const name = this.nameFilter().trim(); + const typeFilter = this.typeFilter(); + + return this.tokens().filter(token => + (!name || token.overridesName.includes(name)) && + (!typeFilter || token.type === typeFilter)); + }); + + protected reset() { + this.nameFilter.set(''); + this.typeFilter.set(null); + } +} diff --git a/src/app/shared/documentation-items/documentation-items.ts b/src/app/shared/documentation-items/documentation-items.ts index 817a3eb0..399408c0 100644 --- a/src/app/shared/documentation-items/documentation-items.ts +++ b/src/app/shared/documentation-items/documentation-items.ts @@ -30,6 +30,8 @@ export interface DocItem { overviewPath?: string; /** List of additional API docs. */ additionalApiDocs?: AdditionalApiDoc[]; + /** Whether the doc item can display styling information. */ + hasStyling?: boolean; } export interface DocSection { @@ -596,6 +598,7 @@ export class DocumentationItems { function processDocs(packageName: string, docs: DocItem[]): DocItem[] { for (const doc of docs) { doc.packageName = packageName; + doc.hasStyling = packageName === 'material'; doc.examples = exampleNames.filter(key => key.match(RegExp(`^${doc.exampleSpecs.prefix}`)) && !doc.exampleSpecs.exclude?.some(excludeName => key.indexOf(excludeName) === 0)); diff --git a/yarn.lock b/yarn.lock index 82f3bcf6..b6cbe8d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -482,22 +482,22 @@ __metadata: languageName: node linkType: hard -"@angular/components-examples@https://github.com/angular/material2-docs-content.git#4334c0c112b4a55c7257a5837ac94f5464938764": - version: 19.0.0-next.9+sha-57d9a2f - resolution: "@angular/components-examples@https://github.com/angular/material2-docs-content.git#commit=4334c0c112b4a55c7257a5837ac94f5464938764" +"@angular/components-examples@https://github.com/angular/material2-docs-content.git#3e3187172e1edc005a6669fa214e7b4bdc6a230b": + version: 19.1.0-next.0+sha-6b3a371 + resolution: "@angular/components-examples@https://github.com/angular/material2-docs-content.git#commit=3e3187172e1edc005a6669fa214e7b4bdc6a230b" dependencies: tslib: "npm:^2.3.0" peerDependencies: - "@angular/cdk": 19.0.0-next.9+sha-57d9a2f - "@angular/cdk-experimental": 19.0.0-next.9+sha-57d9a2f + "@angular/cdk": 19.1.0-next.0+sha-6b3a371 + "@angular/cdk-experimental": 19.1.0-next.0+sha-6b3a371 "@angular/common": ^19.0.0-0 || ^19.1.0-0 || ^19.2.0-0 || ^19.3.0-0 || ^20.0.0-0 "@angular/core": ^19.0.0-0 || ^19.1.0-0 || ^19.2.0-0 || ^19.3.0-0 || ^20.0.0-0 - "@angular/material": 19.0.0-next.9+sha-57d9a2f - "@angular/material-date-fns-adapter": 19.0.0-next.9+sha-57d9a2f - "@angular/material-experimental": 19.0.0-next.9+sha-57d9a2f - "@angular/material-luxon-adapter": 19.0.0-next.9+sha-57d9a2f - "@angular/material-moment-adapter": 19.0.0-next.9+sha-57d9a2f - checksum: 10c0/557b12db31d56a8187b2cda9685732bb6789f7c9fe4c9cdccf692635c316fd7c14c56d25596c96214ae94959ea5895e87db118ec51fc98832936cac0748319e7 + "@angular/material": 19.1.0-next.0+sha-6b3a371 + "@angular/material-date-fns-adapter": 19.1.0-next.0+sha-6b3a371 + "@angular/material-experimental": 19.1.0-next.0+sha-6b3a371 + "@angular/material-luxon-adapter": 19.1.0-next.0+sha-6b3a371 + "@angular/material-moment-adapter": 19.1.0-next.0+sha-6b3a371 + checksum: 10c0/e2f34c111ff87d8be5ab58c3a08f70faaaac713523b9a69acfd76f1831a735ff1a590c399da024c1b7312b420c6d698fd17a971f46a57e95c383a756a683a8af languageName: node linkType: hard @@ -12725,7 +12725,7 @@ __metadata: "@angular/common": "npm:^19.0.0-next.10" "@angular/compiler": "npm:^19.0.0-next.10" "@angular/compiler-cli": "npm:^19.0.0-next.10" - "@angular/components-examples": "https://github.com/angular/material2-docs-content.git#4334c0c112b4a55c7257a5837ac94f5464938764" + "@angular/components-examples": "https://github.com/angular/material2-docs-content.git#3e3187172e1edc005a6669fa214e7b4bdc6a230b" "@angular/core": "npm:^19.0.0-next.10" "@angular/forms": "npm:^19.0.0-next.10" "@angular/google-maps": "npm:^19.0.0-next.9"