@foliokit/cms-ui 0.4.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +155 -0
  2. package/eslint.config.mjs +48 -0
  3. package/ng-package.json +19 -0
  4. package/package.json +12 -19
  5. package/project.json +32 -0
  6. package/src/index.ts +7 -0
  7. package/src/lib/about-page/about-page.component.ts +219 -0
  8. package/src/lib/app-shell/app-shell.component.html +57 -0
  9. package/src/lib/app-shell/app-shell.component.scss +204 -0
  10. package/src/lib/app-shell/app-shell.component.ts +88 -0
  11. package/src/lib/app-shell/shell-nav-footer.directive.ts +4 -0
  12. package/src/lib/cms-ui/cms-ui.html +1 -0
  13. package/src/lib/cms-ui/cms-ui.scss +0 -0
  14. package/src/lib/cms-ui/cms-ui.spec.ts +93 -0
  15. package/src/lib/cms-ui/cms-ui.ts +9 -0
  16. package/src/lib/links-page/links-page.component.ts +256 -0
  17. package/src/lib/route-data.ts +27 -0
  18. package/src/lib/shell-config.token.ts +11 -0
  19. package/src/lib/theme.service.ts +44 -0
  20. package/src/styles/_focus.scss +17 -0
  21. package/src/styles/_reset.scss +65 -0
  22. package/src/styles/_theme.scss +76 -0
  23. package/src/styles/_tokens.scss +216 -0
  24. package/src/styles/_typography.scss +146 -0
  25. package/src/styles/index.scss +5 -0
  26. package/src/styles/tokens.css +135 -0
  27. package/tsconfig.json +31 -0
  28. package/tsconfig.lib.json +12 -0
  29. package/tsconfig.lib.prod.json +9 -0
  30. package/tsconfig.spec.json +8 -0
  31. package/esm2022/foliokit-cms-ui.js +0 -5
  32. package/esm2022/foliokit-cms-ui.js.map +0 -1
  33. package/esm2022/index.js +0 -4
  34. package/esm2022/index.js.map +0 -1
  35. package/esm2022/lib/app-shell/app-shell.component.js +0 -52
  36. package/esm2022/lib/app-shell/app-shell.component.js.map +0 -1
  37. package/esm2022/lib/shell-config.token.js +0 -3
  38. package/esm2022/lib/shell-config.token.js.map +0 -1
  39. package/esm2022/lib/theme.service.js +0 -45
  40. package/esm2022/lib/theme.service.js.map +0 -1
  41. package/foliokit-cms-ui.d.ts +0 -5
  42. package/index.d.ts +0 -3
  43. package/lib/app-shell/app-shell.component.d.ts +0 -17
  44. package/lib/shell-config.token.d.ts +0 -9
  45. package/lib/theme.service.d.ts +0 -11
package/README.md CHANGED
@@ -1,3 +1,158 @@
1
1
  # @foliokit/cms-ui
2
2
  Part of the [Folio](https://github.com/doug-williamson/foliokit) ecosystem.
3
3
  > This package is in early development. API is unstable.
4
+
5
+ ---
6
+
7
+ ## ⚠️ Required: Angular Material theme setup
8
+
9
+ `AppShellComponent` and all page components use Angular Material. Due to how
10
+ Angular Material M3 theming works, the `@include mat.theme(...)` mixin **must
11
+ be called in your application's global stylesheet** — it cannot be bundled
12
+ inside a library.
13
+
14
+ If you skip this step, Material components will render without colour, elevation,
15
+ or typography. This is the most common issue new consumers encounter.
16
+
17
+ Add the following to your global `styles.scss`:
18
+
19
+ ```scss
20
+ @use '@angular/material' as mat;
21
+
22
+ // Light theme (also serves as the default)
23
+ html,
24
+ html[data-theme='light'] {
25
+ @include mat.theme((
26
+ color: (
27
+ theme-type: light,
28
+ primary: mat.$cyan-palette,
29
+ tertiary: mat.$cyan-palette,
30
+ ),
31
+ typography: 'Plus Jakarta Sans',
32
+ density: 0,
33
+ ));
34
+ }
35
+
36
+ // Dark theme
37
+ html[data-theme='dark'] {
38
+ @include mat.theme((
39
+ color: (
40
+ theme-type: dark,
41
+ primary: mat.$cyan-palette,
42
+ tertiary: mat.$cyan-palette,
43
+ ),
44
+ typography: 'Plus Jakarta Sans',
45
+ density: 0,
46
+ ));
47
+ }
48
+ ```
49
+
50
+ `ThemeService` (exported from this package) manages the `[data-theme]` attribute
51
+ on `<html>` and persists the user's preference to `localStorage`.
52
+
53
+ ---
54
+
55
+ ## Getting started
56
+
57
+ ### 1. Provide the shell configuration
58
+
59
+ In your `app.config.ts`, register `SHELL_CONFIG` alongside `provideFolioKit()`:
60
+
61
+ ```ts
62
+ import { provideFolioKit } from '@foliokit/cms-core';
63
+ import { SHELL_CONFIG } from '@foliokit/cms-ui';
64
+
65
+ export const appConfig: ApplicationConfig = {
66
+ providers: [
67
+ provideRouter(routes),
68
+ provideAnimationsAsync(),
69
+ provideHttpClient(withFetch()),
70
+ provideMarkdown(),
71
+ provideFolioKit({ firebaseConfig: environment.firebase }),
72
+ {
73
+ provide: SHELL_CONFIG,
74
+ useValue: {
75
+ appName: 'My Site',
76
+ nav: [
77
+ { label: 'Blog', path: '/blog' },
78
+ { label: 'About', path: '/about' },
79
+ ],
80
+ },
81
+ },
82
+ ],
83
+ };
84
+ ```
85
+
86
+ ### 2. Wrap your app in the shell
87
+
88
+ ```ts
89
+ import { AppShellComponent } from '@foliokit/cms-ui';
90
+
91
+ @Component({
92
+ selector: 'app-root',
93
+ standalone: true,
94
+ imports: [AppShellComponent, RouterOutlet],
95
+ template: `
96
+ <folio-app-shell>
97
+ <router-outlet />
98
+ </folio-app-shell>
99
+ `,
100
+ })
101
+ export class AppComponent {}
102
+ ```
103
+
104
+ ### 3. Import design tokens
105
+
106
+ In `angular.json` (or `project.json`) styles array:
107
+
108
+ ```jsonc
109
+ "styles": [
110
+ "node_modules/@foliokit/cms-ui/styles/tokens.css",
111
+ "src/styles.scss"
112
+ ]
113
+ ```
114
+
115
+ Or via SCSS:
116
+
117
+ ```scss
118
+ @use '@foliokit/cms-ui/styles/tokens';
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Design Tokens
124
+
125
+ `@foliokit/cms-ui` ships a set of CSS custom properties that define the FolioKit
126
+ design system: color palette, semantic theme tokens (light/dark), typography,
127
+ border radii, shadows, and component tokens.
128
+
129
+ Tokens resolve against the `[data-theme]` attribute on a parent element (typically
130
+ `<html>`). Use `ThemeService` to switch programmatically:
131
+
132
+ ```ts
133
+ import { ThemeService } from '@foliokit/cms-ui';
134
+
135
+ // In a component or service:
136
+ readonly theme = inject(ThemeService);
137
+
138
+ toggleDark() {
139
+ this.theme.toggle();
140
+ }
141
+ ```
142
+
143
+ ### Token Categories
144
+
145
+ | Prefix | Purpose |
146
+ |--------|---------|
147
+ | `--slate-*`, `--cloud-*`, `--teal-*`, `--violet-*` | Base color palette |
148
+ | `--bg`, `--bg-subtle` | Page backgrounds |
149
+ | `--surface-0` .. `--surface-3` | Elevated surface layers |
150
+ | `--border`, `--border-strong`, `--border-accent` | Borders |
151
+ | `--text-primary`, `--text-secondary`, `--text-muted`, `--text-accent` | Typography colors |
152
+ | `--btn-primary-bg`, `--btn-primary-text`, `--btn-primary-hover` | Button tokens |
153
+ | `--logo-bg`, `--logo-text`, `--logo-dot` | Logo tokens |
154
+ | `--shadow-sm` .. `--shadow-xl` | Elevation shadows |
155
+ | `--focus-ring`, `--focus-border` | Focus indicators |
156
+ | `--nav-active-bg`, `--nav-active-color` | Navigation highlights |
157
+ | `--font-display`, `--font-body`, `--font-mono` | Font stacks |
158
+ | `--r-xs` .. `--r-2xl` | Border radii |
@@ -0,0 +1,48 @@
1
+ import nx from '@nx/eslint-plugin';
2
+ import baseConfig from '../../eslint.config.mjs';
3
+
4
+ export default [
5
+ ...baseConfig,
6
+ {
7
+ files: ['**/*.json'],
8
+ rules: {
9
+ '@nx/dependency-checks': [
10
+ 'error',
11
+ {
12
+ ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
13
+ },
14
+ ],
15
+ },
16
+ languageOptions: {
17
+ parser: await import('jsonc-eslint-parser'),
18
+ },
19
+ },
20
+ ...nx.configs['flat/angular'],
21
+ ...nx.configs['flat/angular-template'],
22
+ {
23
+ files: ['**/*.ts'],
24
+ rules: {
25
+ '@angular-eslint/directive-selector': [
26
+ 'error',
27
+ {
28
+ type: 'attribute',
29
+ prefix: 'lib',
30
+ style: 'camelCase',
31
+ },
32
+ ],
33
+ '@angular-eslint/component-selector': [
34
+ 'error',
35
+ {
36
+ type: 'element',
37
+ prefix: 'lib',
38
+ style: 'kebab-case',
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ {
44
+ files: ['**/*.html'],
45
+ // Override or add rules here
46
+ rules: {},
47
+ },
48
+ ];
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/libs/cms-ui",
4
+ "lib": {
5
+ "entryFile": "src/index.ts"
6
+ },
7
+ "assets": [
8
+ {
9
+ "input": "src/styles",
10
+ "glob": "**/*.scss",
11
+ "output": "styles"
12
+ },
13
+ {
14
+ "input": "src/styles",
15
+ "glob": "tokens.css",
16
+ "output": "styles"
17
+ }
18
+ ]
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foliokit/cms-ui",
3
- "version": "0.4.2",
3
+ "version": "1.0.1",
4
4
  "description": "Angular shell layout components and theme service for FolioKit CMS",
5
5
  "keywords": [
6
6
  "angular",
@@ -12,7 +12,7 @@
12
12
  "license": "MIT",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/dougwilliamson/foliokit"
15
+ "url": "git+https://github.com/dougwilliamson/foliokit.git"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
@@ -22,26 +22,19 @@
22
22
  "@angular/common": "^21.2.4",
23
23
  "@angular/core": "^21.2.4",
24
24
  "@angular/material": "^21.2.2",
25
- "@foliokit/cms-core": "^0.4.2"
26
- },
27
- "peerDependenciesMeta": {
28
- "@foliokit/cms-core": {
29
- "optional": true
30
- }
25
+ "@angular/platform-browser": "^21.2.4",
26
+ "@angular/router": "^21.2.4",
27
+ "@foliokit/cms-core": "^1.0.0",
28
+ "ngx-markdown": "^21.1.0",
29
+ "rxjs": "~7.8.0"
31
30
  },
32
31
  "sideEffects": false,
33
- "module": "esm2022/foliokit-cms-ui.js",
34
- "typings": "foliokit-cms-ui.d.ts",
35
32
  "exports": {
36
- "./package.json": {
37
- "default": "./package.json"
33
+ "./styles": {
34
+ "sass": "./styles/index.scss"
38
35
  },
39
- ".": {
40
- "types": "./foliokit-cms-ui.d.ts",
41
- "default": "./esm2022/foliokit-cms-ui.js"
36
+ "./styles/*": {
37
+ "sass": "./styles/_*.scss"
42
38
  }
43
- },
44
- "dependencies": {
45
- "tslib": "^2.3.0"
46
39
  }
47
- }
40
+ }
package/project.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "cms-ui",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/cms-ui/src",
5
+ "prefix": "lib",
6
+ "projectType": "library",
7
+ "tags": [],
8
+ "targets": {
9
+ "build": {
10
+ "executor": "@nx/angular:ng-packagr-lite",
11
+ "outputs": ["{workspaceRoot}/dist/{projectRoot}"],
12
+ "options": {
13
+ "project": "libs/cms-ui/ng-package.json",
14
+ "tsConfig": "libs/cms-ui/tsconfig.lib.json"
15
+ },
16
+ "configurations": {
17
+ "production": {
18
+ "tsConfig": "libs/cms-ui/tsconfig.lib.prod.json"
19
+ },
20
+ "development": {}
21
+ },
22
+ "defaultConfiguration": "production"
23
+ },
24
+ "test": {
25
+ "executor": "@nx/angular:unit-test",
26
+ "options": {}
27
+ },
28
+ "lint": {
29
+ "executor": "@nx/eslint:lint"
30
+ }
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './lib/app-shell/app-shell.component';
2
+ export * from './lib/app-shell/shell-nav-footer.directive';
3
+ export * from './lib/theme.service';
4
+ export * from './lib/shell-config.token';
5
+ export * from './lib/about-page/about-page.component';
6
+ export * from './lib/links-page/links-page.component';
7
+ export * from './lib/route-data';
@@ -0,0 +1,219 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ effect,
6
+ inject,
7
+ PLATFORM_ID,
8
+ } from '@angular/core';
9
+ import { isPlatformBrowser } from '@angular/common';
10
+ import { ActivatedRoute } from '@angular/router';
11
+ import { toSignal } from '@angular/core/rxjs-interop';
12
+ import { map } from 'rxjs';
13
+ import { Meta, Title } from '@angular/platform-browser';
14
+ import { MarkdownModule } from 'ngx-markdown';
15
+ import { MatIconModule } from '@angular/material/icon';
16
+ import type { AboutPageConfig } from '@foliokit/cms-core';
17
+ import { ThemeService } from '../theme.service';
18
+
19
+ @Component({
20
+ selector: 'cms-about-page',
21
+ standalone: true,
22
+ changeDetection: ChangeDetectionStrategy.OnPush,
23
+ imports: [MarkdownModule, MatIconModule],
24
+ styles: [`
25
+ :host { display: block; }
26
+
27
+ .about-container {
28
+ max-width: 600px;
29
+ margin: 48px auto;
30
+ padding: 0 24px;
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ }
35
+
36
+ .avatar--xl {
37
+ width: 96px;
38
+ height: 96px;
39
+ border-radius: 50%;
40
+ background: var(--logo-bg);
41
+ color: var(--logo-text);
42
+ font-family: var(--font-body);
43
+ font-weight: 600;
44
+ font-size: 32px;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ overflow: hidden;
49
+ flex-shrink: 0;
50
+
51
+ img {
52
+ width: 100%;
53
+ height: 100%;
54
+ object-fit: cover;
55
+ }
56
+ }
57
+
58
+ .about-name {
59
+ font-family: var(--font-display);
60
+ font-size: 1.5rem;
61
+ font-weight: 600;
62
+ letter-spacing: -0.015em;
63
+ color: var(--text-primary);
64
+ text-align: center;
65
+ margin-top: 16px;
66
+ }
67
+
68
+ .about-tagline {
69
+ font-size: 16px;
70
+ line-height: 1.75;
71
+ color: var(--text-secondary);
72
+ text-align: center;
73
+ max-width: 480px;
74
+ margin: 8px auto 0;
75
+ }
76
+
77
+ .social-links {
78
+ display: flex;
79
+ justify-content: center;
80
+ flex-wrap: wrap;
81
+ gap: 8px;
82
+ margin-top: 20px;
83
+ }
84
+
85
+ .social-link {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ gap: 6px;
89
+ border: 1px solid var(--border-strong);
90
+ border-radius: var(--r-md);
91
+ padding: 6px 12px;
92
+ font-size: 12px;
93
+ font-weight: 500;
94
+ color: var(--text-secondary);
95
+ background: var(--surface-0);
96
+ text-decoration: none;
97
+ transition: background 0.12s, color 0.12s;
98
+
99
+ mat-icon {
100
+ font-size: 18px;
101
+ width: 18px;
102
+ height: 18px;
103
+ }
104
+
105
+ &:hover {
106
+ background: var(--surface-2);
107
+ color: var(--text-primary);
108
+ }
109
+ }
110
+
111
+ .about-prose {
112
+ width: 100%;
113
+ margin-top: 32px;
114
+ }
115
+
116
+ .about-divider {
117
+ width: 100%;
118
+ border: none;
119
+ border-top: 1px solid var(--border);
120
+ margin: 24px 0 0;
121
+ }
122
+ `],
123
+ template: `
124
+ @if (about()) {
125
+ <div class="about-container">
126
+ <div class="avatar--xl">
127
+ @if (avatarSrc()) {
128
+ <img [src]="avatarSrc()" [alt]="about()!.photoAlt || about()!.headline" />
129
+ } @else {
130
+ {{ initials() }}
131
+ }
132
+ </div>
133
+
134
+ <h1 class="about-name">{{ about()!.headline }}</h1>
135
+
136
+ @if (about()!.subheadline) {
137
+ <p class="about-tagline">{{ about()!.subheadline }}</p>
138
+ }
139
+
140
+ @if (about()!.socialLinks?.length) {
141
+ <div class="social-links">
142
+ @for (link of about()!.socialLinks; track link.url) {
143
+ <a
144
+ class="social-link"
145
+ [href]="link.url"
146
+ target="_blank"
147
+ rel="noopener noreferrer"
148
+ >
149
+ <mat-icon>link</mat-icon>
150
+ {{ link.label || link.platform }}
151
+ </a>
152
+ }
153
+ </div>
154
+ }
155
+
156
+ <hr class="about-divider" />
157
+
158
+ <div class="about-prose folio-prose">
159
+ <markdown [data]="about()!.bio" />
160
+ </div>
161
+ </div>
162
+ } @else {
163
+ <p style="padding: 40px; text-align: center; color: var(--text-muted)">No content available.</p>
164
+ }
165
+ `,
166
+ })
167
+ export class AboutPageComponent {
168
+ private readonly route = inject(ActivatedRoute);
169
+ private readonly meta = inject(Meta);
170
+ private readonly title = inject(Title);
171
+ private readonly platformId = inject(PLATFORM_ID);
172
+ readonly theme = inject(ThemeService);
173
+
174
+ readonly about = toSignal(
175
+ this.route.data.pipe(map((data) => (data['about'] as AboutPageConfig) ?? null)),
176
+ { initialValue: (this.route.snapshot.data['about'] as AboutPageConfig) ?? null },
177
+ );
178
+
179
+ protected readonly avatarSrc = computed(() =>
180
+ this.theme.isDark() && this.about()?.photoUrlDark
181
+ ? this.about()!.photoUrlDark!
182
+ : this.about()!.photoUrl,
183
+ );
184
+
185
+ protected readonly initials = computed(() => {
186
+ const headline = this.about()?.headline ?? '';
187
+ return headline
188
+ .split(' ')
189
+ .slice(0, 2)
190
+ .map((w: string) => w[0])
191
+ .join('')
192
+ .toUpperCase();
193
+ });
194
+
195
+ constructor() {
196
+ effect(() => {
197
+ const a = this.about();
198
+ if (!a) return;
199
+ if (!isPlatformBrowser(this.platformId)) return;
200
+
201
+ this.title.setTitle(a.seo?.title ?? a.headline);
202
+
203
+ if (a.seo?.description) {
204
+ this.meta.updateTag({ name: 'description', content: a.seo.description });
205
+ }
206
+ if (a.seo?.ogImage) {
207
+ this.meta.updateTag({ property: 'og:image', content: a.seo.ogImage });
208
+ }
209
+ if (a.seo?.canonicalUrl) {
210
+ this.meta.updateTag({ rel: 'canonical', href: a.seo.canonicalUrl });
211
+ }
212
+ if (a.seo?.noIndex) {
213
+ this.meta.updateTag({ name: 'robots', content: 'noindex' });
214
+ } else {
215
+ this.meta.removeTag('name="robots"');
216
+ }
217
+ });
218
+ }
219
+ }
@@ -0,0 +1,57 @@
1
+ <div class="folio-shell-container">
2
+ <mat-sidenav-container>
3
+ <mat-sidenav
4
+ [mode]="isMobile() ? 'over' : 'side'"
5
+ [opened]="sidenavOpen()"
6
+ [class.icon-rail]="isIconRail()"
7
+ (openedChange)="sidenavOpen.set($event)"
8
+ >
9
+ <div class="folio-nav-wrapper">
10
+ <div class="folio-nav-body">
11
+ <ng-content select="[shellNav]" />
12
+ </div>
13
+ @if (navFooter) {
14
+ <div class="folio-nav-footer">
15
+ <ng-content select="[shellNavFooter]" />
16
+ </div>
17
+ }
18
+ </div>
19
+ </mat-sidenav>
20
+
21
+ <mat-sidenav-content class="folio-content">
22
+ <mat-toolbar class="folio-toolbar">
23
+ @if (isMobile()) {
24
+ <button mat-icon-button aria-label="Toggle navigation" (click)="toggleSidenav()">
25
+ <mat-icon>menu</mat-icon>
26
+ </button>
27
+ }
28
+
29
+ <div class="folio-logo-mark">
30
+ <span class="folio-logo-mark-f">F</span>
31
+ <div class="folio-logo-dot"></div>
32
+ </div>
33
+ <span class="folio-app-name">{{ config.appName }}</span>
34
+
35
+ <span class="flex-1"></span>
36
+
37
+ <ng-content select="[shellHeaderActions]" />
38
+
39
+ @if (config.showAuth) {
40
+ <ng-content select="[shellAuthSlot]" />
41
+ }
42
+
43
+ <button
44
+ mat-icon-button
45
+ [attr.aria-label]="theme.scheme() === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
46
+ (click)="toggleTheme()"
47
+ >
48
+ <mat-icon>{{ theme.scheme() === 'dark' ? 'light_mode' : 'dark_mode' }}</mat-icon>
49
+ </button>
50
+ </mat-toolbar>
51
+
52
+ <main class="shell-main">
53
+ <ng-content />
54
+ </main>
55
+ </mat-sidenav-content>
56
+ </mat-sidenav-container>
57
+ </div>