diff --git a/CHANGELOG.md b/CHANGELOG.md index 7382b1e..22a2079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,44 @@ so it stays clear which part of the repository actually moved. attempt, so it cannot be retried away. A propagation timeout now says so in those words, to stop a future reader from re-diagnosing it as the duplicate of issue #211. +### Added + +#### flex-fields + +- **The `Select` field types now load their `ng-zorro-antd` stylesheet themselves, by bundle name, + exactly the way `abp-tree` loads its own.** ng-zorro-antd ships no component styles, so `` + had been rendering against whatever antd CSS a host happened to have declared. `SelectControlComponent` + and `SelectSearchComponent` now ask for `ng-zorro-antd-select.css` once per application at init; a + host serves it with a single `angular.json` `styles` entry — + `node_modules/ng-zorro-antd/select/style/index.min.css`, `inject: false`, + `bundleName: "ng-zorro-antd-select"` — and can switch the loading off with the new + `DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN` when it bundles that CSS another way. A host that has not + declared the entry now gets one console error naming the missing file and quoting the entry to add, + rather than a silently unstyled control. Shipping an aggregated stylesheet inside the package was + considered and rejected on two counts: ng-zorro-antd declares `less` before `style` in its + `.//style/*` export, so a bare `@import 'ng-zorro-antd/select/style/index.min.css'` + resolves to a non-existent `index.min.css.less` under the Angular CLI's stylesheet bundler, and + freezing a copy of a peer dependency's CSS into this package's release cycle is not an acceptable + substitute. The demo's dead `ng-zorro-antd-tree-select` entry went with it — nothing renders + `nz-tree-select`. See the package README's new "Styles" section for the host-side contract. (#232) +- **`@dignite/ng.flex-fields-ckeditor` loads CKEditor 5's stylesheet the same way, through the same + loader.** `FlexFieldsStyleLoader` is shared across the package family; each package declares its own + bundle constant. The bolt-on used to `@import 'ckeditor5/ckeditor5.css'` from its control + component's stylesheet, which ng-packagr inlined at build time: 241 KB of third-party CSS compiled + into the published `fesm2022` bundle (471 KB, 522 `.ck-editor` rules) and, because a host registers + the field type in its application config, shipped in that host's *initial* bundle whether or not a + rich-text field was ever opened — while pinning the CSS to whatever `ckeditor5` version the package + was built against, though the editor's own JavaScript comes from the host's installed copy via + `await import('ckeditor5')`. `CKEditorControlComponent` now asks for the host's `ckeditor5` bundle at + init: one `angular.json` `styles` entry — `node_modules/ckeditor5/dist/ckeditor5.css`, + `inject: false`, `bundleName: "ckeditor5"`, exported as `CKEDITOR5_STYLE`. The bolt-on's bundle drops + to 36 KB and this repo's demo from a 2.20 MB initial bundle to 1.98 MB (483 kB to 455 kB + transferred), back under the 2 MB budget the build had been warning about. A host that has not + declared the entry gets one console error naming the file and quoting the entry, instead of an editor + that silently renders as blank/collapsed space. `DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN` is + family-wide: `true` silences every bundle loaded through this service, in every + `@dignite/ng.flex-fields*` package. (#232) + ## [10.0.0-rc.16] - 2026-09-05 ### Fixed diff --git a/flex-fields/angular/angular.json b/flex-fields/angular/angular.json index 83a9a04..b4da7e1 100644 --- a/flex-fields/angular/angular.json +++ b/flex-fields/angular/angular.json @@ -58,18 +58,18 @@ }, { "input": "node_modules/ng-zorro-antd/tree/style/index.min.css", - "inject": true, + "inject": false, "bundleName": "ng-zorro-antd-tree" }, { "input": "node_modules/ng-zorro-antd/select/style/index.min.css", - "inject": true, + "inject": false, "bundleName": "ng-zorro-antd-select" }, { - "input": "node_modules/ng-zorro-antd/tree-select/style/index.min.css", - "inject": true, - "bundleName": "ng-zorro-antd-tree-select" + "input": "node_modules/ckeditor5/dist/ckeditor5.css", + "inject": false, + "bundleName": "ckeditor5" }, { "input": "node_modules/@swimlane/ngx-datatable/index.css", diff --git a/flex-fields/angular/projects/flex-fields-ckeditor/README.md b/flex-fields/angular/projects/flex-fields-ckeditor/README.md index 0d4f9f6..3725c91 100644 --- a/flex-fields/angular/projects/flex-fields-ckeditor/README.md +++ b/flex-fields/angular/projects/flex-fields-ckeditor/README.md @@ -15,6 +15,30 @@ need a rich-text field never pay for its dependency weight. Install this package npm install @dignite/ng.flex-fields-ckeditor @ckeditor/ckeditor5-angular ckeditor5 marked ``` +### Styles + +CKEditor 5's UI stylesheet is **served by your host under a fixed name** and fetched the first time a +`CKEditor` field is rendered, rather than compiled into this package. One entry in the `styles` array +of your `angular.json` build target: + +```json +{ "input": "node_modules/ckeditor5/dist/ckeditor5.css", "inject": false, "bundleName": "ckeditor5" } +``` + +It is the `ckeditor5` you just installed: this package's editor JavaScript also comes from your copy, +at runtime, so declaring the CSS the same way keeps the two halves on one version instead of pinning +the stylesheet to whatever version this package was built against. `inject: false` is required, not a +preference — an injected entry is emitted under a content hash in a production build, which no fixed +name can find; the [core package's README](https://github.com/dignite-projects/abp-modules/blob/main/flex-fields/angular/projects/flex-fields/README.md#styles) has the full explanation, +and that section's `ng-zorro-antd-*` entries apply on top of this one if you also use the built-in +field types. + +Without the entry the editor's DOM is still built, but CKEditor's layout never arrives, so the field +renders as blank/collapsed space — and the browser console carries one error naming the file and +quoting the entry to add. `DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN` (from `@dignite/ng.flex-fields`) +switches the loading off for an application that already bundles this CSS some other way; it is +family-wide, so `true` silences the sibling packages' bundles too. + ## Usage Register it alongside the built-ins, in your application config: diff --git a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.css b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.css index baa52f2..8b73921 100644 --- a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.css +++ b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.css @@ -1,22 +1,44 @@ -/* CKEditor 5's own UI framework CSS. Without this, the toolbar/editable DOM CKEditor builds at - runtime is completely unstyled and typically renders as blank/collapsed space - no console error, - since nothing is actually broken, it just has no visual layout. Component-scoped rather than - pushed onto every consumer's global styles.css, which is easy to forget (this is a fix for exactly - that oversight) - see the ViewEncapsulation.None on this component's @Component decorator, required - because CKEditor attaches balloons/dropdowns outside this component's own template subtree, where - Angular's default per-component style scoping would not reach them. */ -@import 'ckeditor5/ckeditor5.css'; - -/* CKEditor 5's balloon/dropdown panels (InlineEditor's floating toolbar, link/table pickers, etc.) - attach to a wrapper appended directly under (see the import's own comment) and default to - z-index 1000 (--ck-z-default: 1, --ck-z-panel: default + 999). ng-bootstrap's modal window sits at - 1055 - higher - so any of those panels opened while a CKEditor is inside an or similar - ng-bootstrap dialog render fully in-DOM and CSS-"visible", but painted underneath the modal, i.e. - invisible to the user despite every element reporting visible/opacity:1. Raising the base above any - Bootstrap-derived dialog z-index (Bootstrap's own scale tops out at 1080) fixes every derived panel - at once; harmless when no modal is present since there is nothing competing for the stacking order. */ +/* CKEditor 5's own UI framework CSS is deliberately NOT part of this file. It used to be - an + `@import 'ckeditor5/ckeditor5.css'` on this line - and ng-packagr inlined it at build time, so + 241 KB of third-party CSS was compiled into this package's JavaScript (a 471 KB fesm bundle + carrying 522 .ck-editor rules) and, because a host registers the field type in its application + config, shipped in that host's initial bundle whether or not a rich-text field was ever opened. It + also pinned the CSS to whatever ckeditor5 version this package was built against, while the editor + itself comes from the host's own installed copy via `await import('ckeditor5')` at runtime. + + The host now serves that file under the fixed bundle name `ckeditor5` and + CKEditorControlComponent.ngOnInit asks for it by name - see CKEDITOR5_STYLE, the + FlexFieldsStyleLoader it goes through, and this package's README for the single angular.json entry + a host adds. A host that forgets the entry gets one console error naming the file and quoting the + entry; the editor's DOM is still built, it just renders as blank/collapsed space until the + stylesheet arrives. + + What remains here is this package's own theming on top of that CSS. It stays unscoped + (ViewEncapsulation.None on the component - CKEditor attaches balloons/dropdowns outside this + component's template subtree, where per-component style scoping would not reach them), and every + rule below had to survive the ordering reversal the move caused: ckeditor5.css used to be the + first thing in this stylesheet and therefore lost every equal-specificity tie to the rules under + it; it now arrives as a appended to after Angular has inserted these styles, so at + equal specificity it wins instead. Each !important below names the upstream declaration it exists + to outrank. */ + +/* CKEditor 5's balloon/dropdown panels (BalloonEditor's floating toolbar, link/table pickers, etc.) + attach to a wrapper appended directly under and default to z-index 1000 (--ck-z-default: 1, + --ck-z-panel: default + 999). ng-bootstrap's modal window sits at 1055 - higher - so any of those + panels opened while a CKEditor is inside an or similar ng-bootstrap dialog render fully + in-DOM and CSS-"visible", but painted underneath the modal, i.e. invisible to the user despite every + element reporting visible/opacity:1. Raising the base above any Bootstrap-derived dialog z-index + (Bootstrap's own scale tops out at 1080) fixes every derived panel at once; harmless when no modal + is present since there is nothing competing for the stacking order. + + !important: ckeditor5.css sets `--ck-z-default: 1` on a bare `:root` of its own - the same selector + at the same specificity - and now arrives after this stylesheet, so without it the stock 1 wins and + every panel goes back to painting under the modal. The only other upstream declaration of this + token is `html.ck-fullscreen, body.ck-fullscreen`, which this !important would also outrank; that + rule belongs to CKEditor's Fullscreen plugin, which buildEditorConfig never registers, so it cannot + apply here - a fork that adds that plugin has to revisit this line. */ :root { - --ck-z-default: 1100; + --ck-z-default: 1100 !important; } /* CKEditor 5 ships no dark-mode palette: ckeditor5.css's own :root block hardcodes the four base @@ -87,10 +109,9 @@ hover/active fill that is always a translucent tint of the current text color, correct in both modes without a per-mode value of its own. - !important on every property: ckeditor5.css's own :root block is only injected once a CKEditor field - is first opened (its multi-megabyte payload is dynamic-imported - see - CKEditorControlComponent.ngOnInit), i.e. after this stylesheet - at equal :root specificity, source - order alone would otherwise let its stock light-mode palette win. Same reasoning as the + !important on every property: ckeditor5.css declares all six of these tokens on a bare `:root` of + its own - the same specificity as this block's `:root` half - and now arrives after this + stylesheet, so source order alone would let its stock light-mode palette win. Same reasoning as the --ck-content-font-color override further down this file. */ :root, body { @@ -113,13 +134,37 @@ body { sets a real border/background unconditionally, focused or not. Reusing those same custom properties here (rather than hardcoding colors) keeps Basic and Full visually consistent and both track whatever theme is active. Unscoped like the z-index override above, for the same reason - (ViewEncapsulation.None - this file is already global by design). */ + (ViewEncapsulation.None - this file is already global by design). + + No !important on the background: nothing upstream sets background or background-color on + .ck.ck-editor__editable_inline itself, so there is no equal-specificity tie for source order to + decide. The one higher-specificity background rule that reaches an editable, + ".ck.ck-editor__main > .ck-editor__editable", is Full-mode-only and resolves to the very same + var(--ck-color-base-background) this line does - it won before the move and still wins, to no + visible difference. */ .ck.ck-editor__editable_inline { background: var(--ck-color-base-background); - border-color: var(--ck-color-base-border); } -/* ckeditor5-content.css hard-codes --ck-content-font-color to #000, entirely independent of +/* The border half of the rule above, deliberately split out and narrowed to the unfocused state. + + It needs to outrank ckeditor5.css's own ".ck.ck-editor__editable_inline { border: 1px solid #0000 }" + - identical specificity, and the shorthand resets border-color, so after the move it would silently + win and put the invisible border back. It must NOT outrank + ".ck.ck-editor__editable:not(.ck-editor__nested-editable).ck-focused { border: var(--ck-focus-ring) }", + which is how a focused editable (Basic and Full alike) gets its blue focus ring: a blanket + `border-color: … !important` at this specificity would beat that rule too and flatten the focus ring + to a plain grey border. `:not(.ck-focused)` is the right narrowing rather than a workaround - this + rule was always about the unfocused state, which is the one that reads as blank space - and it also + makes the declaration outrank the upstream shorthand on specificity alone, with !important left in + as the same belt-and-braces the tokens above use. The only other unfocused-editable border-color + upstream, ".ck.ck-editor__main > .ck-editor__editable:not(.ck-focused)", resolves to the same + var(--ck-color-base-border) as this line, so outranking it changes nothing visible. */ +.ck.ck-editor__editable_inline:not(.ck-focused) { + border-color: var(--ck-color-base-border) !important; +} + +/* ckeditor5.css hard-codes --ck-content-font-color to #000, entirely independent of --ck-color-base-text above - asymmetric with .ck-content's own *background*, which has none of its own and simply inherits whatever --ck-color-base-background resolves to (the rule above is what makes that visible for Basic mode's editable; Full mode gets it unconditionally from @@ -129,9 +174,13 @@ body { Repointing the content token at the same base-text token closes that gap generically, off a token this file already sets above - no further host-specific variable knowledge needed here beyond the Bootstrap/LeptonX fallback chain the dark-theme block above already documents. - !important: ckeditor5-content.css loads later than this file - bundled with the dynamic - import('ckeditor5') in ngOnInit, not the static @import above - so at equal :root specificity, - source order would otherwise let its own #000 default win. Declared on the same `:root, body` + + The .ck-content rules come from the same host-served ckeditor5.css as everything else: the dist + ships ckeditor5-editor.css and ckeditor5-content.css separately, but ckeditor5.css is the two + concatenated, and dist/ckeditor5.js imports no CSS at all - so nothing about .ck-content ever + arrived with the dynamic import('ckeditor5'), contrary to what this comment used to claim. That one + file, appended to after Angular has inserted these styles, is what !important is for here: + at equal :root specificity its own #000 would otherwise win. Declared on the same `:root, body` pair as the block above, for the same reason. */ :root, body { diff --git a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.spec.ts b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.spec.ts index e023937..70d4328 100644 --- a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.spec.ts +++ b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.spec.ts @@ -1,10 +1,12 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { FormGroup, Validators } from '@angular/forms'; import { TestBed } from '@angular/core/testing'; -import { RestService } from '@abp/ng.core'; +import { LazyLoadService, RestService } from '@abp/ng.core'; import { NgxValidateCoreModule } from '@ngx-validate/core'; -import { FlexFieldValue } from '@dignite/ng.flex-fields'; +import { Observable, of } from 'rxjs'; +import { DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, FlexFieldValue } from '@dignite/ng.flex-fields'; import type { Editor } from 'ckeditor5'; +import { CKEDITOR5_STYLE } from './ckeditor-style'; import { CKEditorControlComponent } from './ckeditor-control.component'; import { CKEditorUploadAdapter } from './ckeditor-upload-adapter'; @@ -79,13 +81,29 @@ class OnPushHostComponent { @Input() entity!: FormGroup; } +/** Records what would have been appended to `` - the same stub style-loader.service.spec.ts uses. */ +class LazyLoadServiceStub { + readonly paths: string[] = []; + + load(strategy: { path: string }): Observable { + this.paths.push(strategy.path); + return of(new CustomEvent('load')); + } +} + describe('CKEditorControlComponent', () => { beforeEach(() => { // @ngx-validate/core's validation directive attaches to any [formGroupName]/[formControlName] // element and needs its blueprints token even though TestBed.createComponent() never runs CD here // - view creation alone is enough to construct it. + // + // ckeditor5.css is the host application's to serve; no fixture has that bundle, and the load + // itself is exercised in the `style loading` block below, which opts back in. TestBed.configureTestingModule({ - providers: [{ provide: RestService, useValue: {} }], + providers: [ + { provide: RestService, useValue: {} }, + { provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: true }, + ], imports: [NgxValidateCoreModule.forRoot()], }); }); @@ -216,4 +234,39 @@ describe('CKEditorControlComponent', () => { // ckeditor-editor-config.spec.ts; nothing cheap to add here beyond re-asserting DOM presence, which // the test above already does. }); + + describe('style loading', () => { + let lazyLoadService: LazyLoadServiceStub; + + // Configured after the outer beforeEach, so these providers come later in the testing module's + // provider list and win for both tokens: style loading goes back on, and the append is recorded + // instead of really reaching . + beforeEach(() => { + lazyLoadService = new LazyLoadServiceStub(); + TestBed.configureTestingModule({ + providers: [ + { provide: LazyLoadService, useValue: lazyLoadService }, + { provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: false }, + ], + }); + }); + + it('asks the host for its ckeditor5.css bundle at init', () => { + const { fixture } = build(fieldValue()); + + fixture.detectChanges(); + + // The literal file name rather than `${CKEDITOR5_STYLE.bundleName}.css`: it is the contract with + // the host's angular.json entry, so a rename has to fail here instead of following the constant. + expect(lazyLoadService.paths).toEqual(['ckeditor5.css']); + }); + + it('describes the exact angular.json entry a host has to declare', () => { + // Quoted verbatim by this package's README and by the demo's angular.json. + expect(CKEDITOR5_STYLE).toEqual({ + bundleName: 'ckeditor5', + input: 'node_modules/ckeditor5/dist/ckeditor5.css', + }); + }); + }); }); diff --git a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.ts b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.ts index d1d51f8..d472d5a 100644 --- a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.ts +++ b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-control.component.ts @@ -5,7 +5,8 @@ import { AbstractControl, ReactiveFormsModule, ValidatorFn, Validators } from '@ import { CKEditorModule } from '@ckeditor/ckeditor5-angular'; import type { EditorRelaxedConstructor } from '@ckeditor/ckeditor5-integrations-common'; import type { Editor, EditorConfig } from 'ckeditor5'; -import { FieldTypeControlBase } from '@dignite/ng.flex-fields'; +import { FieldTypeControlBase, FlexFieldsStyleLoader } from '@dignite/ng.flex-fields'; +import { CKEDITOR5_STYLE } from './ckeditor-style'; import { CKEditorContentFormat } from './ckeditor-content-format'; import { CKEditorMode } from './ckeditor-mode'; import { buildEditorConfig, resolveEditorClass } from './ckeditor-editor-config'; @@ -28,13 +29,15 @@ import { CKEditorUploadAdapter } from './ckeditor-upload-adapter'; styleUrl: './ckeditor-control.component.css', // CKEditor 5 attaches its balloons/dropdowns to elements outside this component's own template // subtree (typically appended near document.body), which Angular's default per-component style - // scoping (view encapsulation) would not reach - the imported ckeditor5.css must apply globally - // for the editor to render with any layout at all. See ckeditor-control.component.css. + // scoping (view encapsulation) would not reach - this file's own theming rules have to apply + // globally to reach them. (ckeditor5.css itself is no longer part of these styles: it arrives as a + // the host serves, see ngOnInit.) See ckeditor-control.component.css. encapsulation: ViewEncapsulation.None, imports: [CommonModule, CoreModule, ReactiveFormsModule, CKEditorModule], }) export class CKEditorControlComponent extends FieldTypeControlBase implements OnInit, OnDestroy { private readonly restService = inject(RestService); + private readonly styleLoader = inject(FlexFieldsStyleLoader); /** * Whether this usage ever had a real stored value - captured here because @@ -98,6 +101,10 @@ export class CKEditorControlComponent extends FieldTypeControlBase implements On // `ignoreChangesOutsideZone`) also still schedules a tick for a signal write made outside the zone, // covering (a) without needing `NgZone.run()` at all. async ngOnInit(): Promise { + // Before the import, not after: the request for the host's ckeditor5.css bundle then goes out in + // parallel with the editor's own multi-megabyte chunk instead of queueing behind it. + this.styleLoader.load(CKEDITOR5_STYLE); + const module = await import('ckeditor5'); const configuration = this.fieldValue!.field.configuration; diff --git a/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-style.ts b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-style.ts new file mode 100644 index 0000000..fd4fc08 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields-ckeditor/src/lib/ckeditor-style.ts @@ -0,0 +1,14 @@ +import { StyleBundle } from '@dignite/ng.flex-fields'; + +/** + * CKEditor 5's UI stylesheet — loaded by the `CKEditor` field type's control component. + * + * The host's own copy: this is the same `ckeditor5` package the editor's JavaScript comes from at + * runtime (`await import('ckeditor5')` in `CKEditorControlComponent.ngOnInit`), so the two halves + * cannot drift apart the way a stylesheet compiled into this package would. See + * `FlexFieldsStyleLoader` for the contract and this package's README for the `angular.json` entry. + */ +export const CKEDITOR5_STYLE: StyleBundle = { + bundleName: 'ckeditor5', + input: 'node_modules/ckeditor5/dist/ckeditor5.css', +}; diff --git a/flex-fields/angular/projects/flex-fields-ckeditor/src/public-api.ts b/flex-fields/angular/projects/flex-fields-ckeditor/src/public-api.ts index 9cac9ab..3dad809 100644 --- a/flex-fields/angular/projects/flex-fields-ckeditor/src/public-api.ts +++ b/flex-fields/angular/projects/flex-fields-ckeditor/src/public-api.ts @@ -9,6 +9,7 @@ export * from './lib/ckeditor-control.component'; export * from './lib/ckeditor-editor-config'; export * from './lib/ckeditor-field-type'; export * from './lib/ckeditor-mode'; +export * from './lib/ckeditor-style'; export * from './lib/ckeditor-upload-adapter'; export * from './lib/ckeditor-view.component'; export * from './lib/provide-ckeditor-field-type'; diff --git a/flex-fields/angular/projects/flex-fields-file-explorer/README.md b/flex-fields/angular/projects/flex-fields-file-explorer/README.md index e5459ec..c3c9c87 100644 --- a/flex-fields/angular/projects/flex-fields-file-explorer/README.md +++ b/flex-fields/angular/projects/flex-fields-file-explorer/README.md @@ -16,6 +16,18 @@ package only if you do. npm install @dignite/ng.flex-fields-file-explorer @dignite/ng.file-explorer ``` +### Styles + +This package declares no stylesheet of its own. It renders `@dignite/ng.file-explorer`'s picker, +whose `ngx-datatable` CSS every ABP Angular host already bundles — the three +`@swimlane/ngx-datatable` entries the ABP startup template puts in `angular.json` — and whose +`` loads `ng-zorro-antd-tree.css` itself, by bundle name. + +So the `ng-zorro-antd-tree` entry described in the +[core package's README](https://github.com/dignite-projects/abp-modules/blob/main/flex-fields/angular/projects/flex-fields/README.md#styles) is the one thing to check here; it is the +same entry the `Tree` field types already need, so a host that has flex-fields' built-in types +working has nothing to add for this package. + ## Usage Register it alongside the built-ins, in your application config: diff --git a/flex-fields/angular/projects/flex-fields/README.md b/flex-fields/angular/projects/flex-fields/README.md index dc1aa37..8979e55 100644 --- a/flex-fields/angular/projects/flex-fields/README.md +++ b/flex-fields/angular/projects/flex-fields/README.md @@ -34,6 +34,74 @@ module-scoped `NZ_CONFIG` / `NzConfigService` injection tokens, so `provideNzCon `provideNzI18n()` configure the copy these controls use and not the one ABP's `abp-tree` sees. Pin inside `<21.1.0` if you need a single copy; otherwise expect `abp-tree` to run on ng-zorro defaults. +### Styles + +ng-zorro-antd ships no component styles: every component is `ViewEncapsulation.None` and its CSS is a +separate opt-in the application loads. Two of its stylesheets are therefore **served by your host +under a fixed name** and fetched at runtime by the components that need them. Declare both in the +`styles` array of your `angular.json` build target: + +```json +{ "input": "node_modules/ng-zorro-antd/select/style/index.min.css", "inject": false, "bundleName": "ng-zorro-antd-select" }, +{ "input": "node_modules/ng-zorro-antd/tree/style/index.min.css", "inject": false, "bundleName": "ng-zorro-antd-tree" } +``` + +`ng-zorro-antd-select` is loaded by the `Select` field type's control and search components, which +render ``. `ng-zorro-antd-tree` is loaded by `` from `@abp/ng.components`, which +the `Tree` field types render — that entry is the contract ABP's own Tree component docs prescribe, +and this package neither adds to it nor overrides it. Nothing else needs declaring: the file names +above are the only `ng-zorro-antd` CSS either package asks for. + +`inject: false` is not a preference. `bundleName` is the name asked for at runtime +(`.css`), and under the production `outputHashing: "all"` an **injected** entry is +emitted under a content hash — this repo's demo build produces `fontawesome-all.min-GM54M4UG.css`, +not `fontawesome-all.min.css` — which no fixed name can find. A non-injected entry keeps its literal +file name and stays out of `index.html`, which is exactly what a bundle fetched by name needs. + +If an entry is missing, the control still works — unstyled — and the browser console carries one +error naming the file and quoting the entry to add: + +```text +[@dignite/ng.flex-fields] Could not load "ng-zorro-antd-select.css". This stylesheet is not compiled +into the package: the host serves its own copy of it under a fixed bundle name. Add this entry to the +"styles" array of your angular.json build target and rebuild: … +``` + +An application that already has that CSS some other way — its own `.less` build, an `inject: true` +entry, a CDN `` in `index.html` — should switch the loading off rather than fetch it twice: + +```ts +providers: [ + { provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: true }, // @dignite/ng.flex-fields* + { provide: DISABLE_TREE_STYLE_LOADING_TOKEN, useValue: true }, // @abp/ng.components/tree +] +``` + +The two are independent. `DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN` is family-wide and all-or-nothing: +`true` silences every bundle this package **and its sibling packages** load, so an application that +wants one of them and not the others declares the entries it wants and leaves the token unset. +`DISABLE_TREE_STYLE_LOADING_TOKEN` is ABP's own and covers only `abp-tree`. + +`ng-zorro-antd/style/index.min.css` — ant-design's global reset (`body`/`html`/`h1`-`h6`/`a`/`button`, +etc.) — is neither required nor recommended in a Bootstrap/LeptonX host; this package never loads it +and your app should not either. + +#### Sibling packages + +The bolt-on packages declare their own bundles the same way, through the same loader and the same +token, each documented in its own README: + +- [`@dignite/ng.flex-fields-ckeditor`](https://github.com/dignite-projects/abp-modules/blob/main/flex-fields/angular/projects/flex-fields-ckeditor/README.md#styles) — one entry, for + CKEditor 5's UI stylesheet. +- [`@dignite/ng.flex-fields-file-explorer`](https://github.com/dignite-projects/abp-modules/blob/main/flex-fields/angular/projects/flex-fields-file-explorer/README.md#styles) — no entry + of its own. + +**Maintainers:** the `StyleBundle` constant lives in the package whose component renders the +CSS, not centrally — `NZ_SELECT_STYLE` here (`src/lib/utils/style-loader.service.ts`), `CKEDITOR5_STYLE` +in the CKEditor package. A package that starts rendering a component with its own global CSS defines +the constant there, calls `FlexFieldsStyleLoader.load()` from that component's `ngOnInit`, and updates +its own README "Styles" section and the changelog in the same PR. + ## Field types Eight built-in types, each with up to four role components — **config** (design the field), diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.spec.ts index 3d7bb63..612817d 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.spec.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.spec.ts @@ -1,6 +1,7 @@ import { FormGroup, Validators } from '@angular/forms'; import { TestBed } from '@angular/core/testing'; import { FlexFieldValue } from '../../models'; +import { DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN } from '../../utils'; import { SelectControlComponent } from './select-control.component'; const OPTIONS = [ @@ -38,6 +39,14 @@ function render(field: FlexFieldValue, selected?: unknown) { } describe('SelectControlComponent', () => { + // The ng-zorro-antd stylesheet is the host application's to serve; a fixture has no such bundle, + // and the load is exercised in style-loader.service.spec.ts instead. + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: true }], + }); + }); + it('renders a native select in single mode', () => { const { fixture } = render(fieldValue()); expect(fixture.nativeElement.querySelector('select')).toBeTruthy(); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.ts index d9ddec1..2e2151e 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-control.component.ts @@ -1,8 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NzSelectModule, NzSelectOptionInterface } from 'ng-zorro-antd/select'; import { AbstractControl, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms'; -import { readStringList } from '../../utils'; +import { FlexFieldsStyleLoader, NZ_SELECT_STYLE, readStringList } from '../../utils'; import { FieldTypeControlBase } from '../field-type-control-base'; import { SelectConfiguration } from './select-configuration'; import { SelectListItem, normalizeSelectListItems } from './select-list-item'; @@ -14,11 +14,21 @@ import { SelectListItem, normalizeSelectListItems } from './select-list-item'; styleUrls: ['./select-field.component.scss'], imports: [CommonModule, ReactiveFormsModule, NzSelectModule], }) -export class SelectControlComponent extends FieldTypeControlBase { +export class SelectControlComponent extends FieldTypeControlBase implements OnInit { + private readonly styleLoader = inject(FlexFieldsStyleLoader); + private optionsSource: unknown; private normalizedOptions: SelectListItem[] = []; private selectOptions: NzSelectOptionInterface[] = []; + /** + * `` (multiple mode) has no styles until the host's `ng-zorro-antd-select` bundle is on + * the page — see {@link FlexFieldsStyleLoader} for why it is fetched by name instead of imported. + */ + ngOnInit(): void { + this.styleLoader.load(NZ_SELECT_STYLE); + } + get multiple(): boolean { return !!this.fieldValue?.field.configuration['Select.Multiple']; } diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.spec.ts index 4556813..b69abb0 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.spec.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.spec.ts @@ -1,6 +1,7 @@ import { FormGroup, Validators } from '@angular/forms'; import { TestBed } from '@angular/core/testing'; import { FlexFieldValue } from '../../models'; +import { DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN } from '../../utils'; import { SelectSearchComponent } from './select-search.component'; const OPTIONS = [ @@ -38,6 +39,13 @@ function render(field: FlexFieldValue, selected?: unknown) { } describe('SelectSearchComponent', () => { + // See select-control.component.spec.ts: no fixture has an ng-zorro-antd style bundle to fetch. + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: true }], + }); + }); + it('uses the stored value in single mode', () => { const { values } = render(fieldValue(), 'red'); expect(values.get('color')!.value).toBe('red'); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.ts index 690f969..88c0da0 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/select/select-search.component.ts @@ -1,8 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NzSelectModule, NzSelectOptionInterface } from 'ng-zorro-antd/select'; import { AbstractControl, ReactiveFormsModule } from '@angular/forms'; -import { readStringList } from '../../utils'; +import { FlexFieldsStyleLoader, NZ_SELECT_STYLE, readStringList } from '../../utils'; import { FieldTypeControlBase } from '../field-type-control-base'; import { SelectConfiguration } from './select-configuration'; import { SelectListItem, normalizeSelectListItems } from './select-list-item'; @@ -14,11 +14,18 @@ import { SelectListItem, normalizeSelectListItems } from './select-list-item'; styleUrls: ['./select-field.component.scss'], imports: [CommonModule, ReactiveFormsModule, NzSelectModule], }) -export class SelectSearchComponent extends FieldTypeControlBase { +export class SelectSearchComponent extends FieldTypeControlBase implements OnInit { + private readonly styleLoader = inject(FlexFieldsStyleLoader); + private optionsSource: unknown; private normalizedOptions: SelectListItem[] = []; private selectOptions: NzSelectOptionInterface[] = []; + /** Same `` stylesheet the control component needs; {@link FlexFieldsStyleLoader} loads it once. */ + ngOnInit(): void { + this.styleLoader.load(NZ_SELECT_STYLE); + } + get multiple(): boolean { return !!this.fieldValue?.field.configuration['Select.Multiple']; } diff --git a/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts b/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts index 67dc695..5e56d0b 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from './flex-field-error-message'; +export * from './style-loader.service'; export * from './read-string-list'; export * from './slug-generator'; diff --git a/flex-fields/angular/projects/flex-fields/src/lib/utils/style-loader.service.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/utils/style-loader.service.spec.ts new file mode 100644 index 0000000..fa7dd79 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/utils/style-loader.service.spec.ts @@ -0,0 +1,126 @@ +import { Type } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormGroup } from '@angular/forms'; +import { LazyLoadService } from '@abp/ng.core'; +import { Observable, of, throwError } from 'rxjs'; +import { FlexFieldValue } from '../models'; +import { SelectControlComponent } from '../field-types/select/select-control.component'; +import { SelectSearchComponent } from '../field-types/select/select-search.component'; +import { + DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, + FlexFieldsStyleLoader, + NZ_SELECT_STYLE, +} from './style-loader.service'; + +/** Records what would have been appended to ``, and lets a test decide how the load ends. */ +class LazyLoadServiceStub { + readonly paths: string[] = []; + result: () => Observable = () => of(new CustomEvent('load')); + + load(strategy: { path: string }): Observable { + this.paths.push(strategy.path); + return this.result(); + } +} + +function fieldValue(): FlexFieldValue { + return { + field: { + id: '1', + name: 'color', + displayName: 'Color', + fieldTypeName: 'Select', + configuration: { + 'Select.Multiple': true, + 'Select.Options': [{ Text: 'Red', Value: 'red', Selected: false }], + }, + }, + required: false, + searchable: true, + }; +} + +function render(component: Type): void { + const entity = new FormGroup({ flexFields: new FormGroup({}) }); + const fixture = TestBed.createComponent(component); + fixture.componentRef.setInput('fields', fieldValue()); + fixture.componentRef.setInput('entity', entity); + fixture.componentRef.setInput('parentFieldName', 'flexFields'); + fixture.detectChanges(); +} + +describe('FlexFieldsStyleLoader', () => { + let lazyLoadService: LazyLoadServiceStub; + + function configure(disabled?: boolean): void { + lazyLoadService = new LazyLoadServiceStub(); + TestBed.configureTestingModule({ + providers: [ + { provide: LazyLoadService, useValue: lazyLoadService }, + ...(disabled === undefined + ? [] + : [{ provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: disabled }]), + ], + }); + } + + it('appends the bundle by its fixed file name', () => { + configure(); + TestBed.inject(FlexFieldsStyleLoader).load(NZ_SELECT_STYLE); + expect(lazyLoadService.paths).toEqual(['ng-zorro-antd-select.css']); + }); + + it('loads the bundle once even when several components ask for it', () => { + configure(); + render(SelectControlComponent); + render(SelectSearchComponent); + expect(lazyLoadService.paths).toEqual(['ng-zorro-antd-select.css']); + }); + + it('loads nothing when style loading is disabled', () => { + configure(true); + render(SelectControlComponent); + render(SelectSearchComponent); + expect(lazyLoadService.paths).toEqual([]); + }); + + it('still loads when the token is explicitly false', () => { + configure(false); + render(SelectControlComponent); + expect(lazyLoadService.paths).toEqual(['ng-zorro-antd-select.css']); + }); + + it('reports a missing bundle as a single console error naming the file and the fix', () => { + configure(); + lazyLoadService.result = () => throwError(() => new CustomEvent('error')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + render(SelectControlComponent); + + expect(consoleError).toHaveBeenCalledTimes(1); + const message = consoleError.mock.calls[0][0] as string; + expect(message).toContain('ng-zorro-antd-select.css'); + expect(message).toContain(NZ_SELECT_STYLE.input); + expect(message).toContain('"inject": false'); + } finally { + consoleError.mockRestore(); + } + }); + + it('does not retry the report when a second component asks for the failed bundle', () => { + configure(); + lazyLoadService.result = () => throwError(() => new CustomEvent('error')); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + render(SelectControlComponent); + render(SelectSearchComponent); + + expect(lazyLoadService.paths).toEqual(['ng-zorro-antd-select.css']); + expect(consoleError).toHaveBeenCalledTimes(1); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/utils/style-loader.service.ts b/flex-fields/angular/projects/flex-fields/src/lib/utils/style-loader.service.ts new file mode 100644 index 0000000..9d9f45d --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/utils/style-loader.service.ts @@ -0,0 +1,160 @@ +import { Injectable, InjectionToken, inject } from '@angular/core'; +import { LOADING_STRATEGY, LazyLoadService } from '@abp/ng.core'; + +/** + * One third-party global stylesheet, described the way a host declares it. + * + * The two halves are the two halves of an `angular.json` `styles` entry, so the error this service + * logs when the file is missing can quote the exact entry to add rather than describe it. + */ +export interface StyleBundle { + /** `bundleName` of the host's `styles` entry. The file is served as `.css`. */ + readonly bundleName: string; + /** `input` of that same entry, relative to the host's workspace root. */ + readonly input: string; +} + +/** ``'s stylesheet — loaded by the `Select` field type's control and search components. */ +export const NZ_SELECT_STYLE: StyleBundle = { + bundleName: 'ng-zorro-antd-select', + input: 'node_modules/ng-zorro-antd/select/style/index.min.css', +}; + +/** + * Provide `true` to stop this package family from lazy-loading any third-party stylesheet. + * + * For an application that already gets that CSS some other way — an `inject: true` entry, a global + * `.less` build, a CDN `` in `index.html` — and would otherwise fetch it twice: + * + * ```ts + * providers: [{ provide: DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, useValue: true }] + * ``` + * + * It is family-wide and all-or-nothing: `true` silences every bundle loaded through + * {@link FlexFieldsStyleLoader}, in every `@dignite/ng.flex-fields*` package, not one bundle at a + * time. An application that wants one of them and not the others has to declare the entries it wants + * and leave this token unset. + * + * The counterpart for the `Tree` field type is ABP's own `DISABLE_TREE_STYLE_LOADING_TOKEN` + * (`@abp/ng.components/tree`): `abp-tree` loads `ng-zorro-antd-tree.css` itself, and this token has + * no say over it. + */ +export const DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN = new InjectionToken( + 'DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN', +); + +/** + * `LazyLoadService.load()` retries forever when it is given no retry count: its `retryWhen` notifier + * concatenates the error stream with a `throwError`, and without a `take()` the error stream never + * completes, so the `throwError` is never reached. A host that forgot the `styles` entry would then + * re-request a 404 in a tight loop and never surface an error to subscribe to. + * + * `take()` doesn't buy what its count suggests, either. ABP's pipeline is + * `retryWhen(error$ => concat(error$.pipe(delay(retryDelay), take(retryTimes)), throwError(...)))`, + * and `take(n)` completes the notifier the instant its n-th error triggers a retry — not once that + * retry settles — so `concat` subscribes `throwError` right away and tears the just-started retry + * down before it can succeed. `n` therefore buys `n - 1` *real* retries: the n-th one is only ever + * started and killed. `RETRY_TIMES = 2` means exactly one real retry — the second attempt gets to + * actually succeed or fail — and, if it also fails, the failure is reported roughly one second (two + * `RETRY_DELAY`s) after the first 404. The torn-down attempt's `` is left behind in ``; + * harmless, since if it ever does finish loading, the CSS simply applies. + */ +const RETRY_TIMES = 2; +const RETRY_DELAY = 500; + +/** + * Loads the third-party global stylesheets this package family needs, by bundle name, once per app. + * + * Some of the components these packages render come with their own global CSS that is not part of + * any Angular component's compiled styles — ``'s ng-zorro-antd stylesheet, CKEditor 5's + * UI stylesheet. This service is how that CSS gets onto the page, on the same contract ABP itself + * uses for `abp-tree` in `@abp/ng.components`: the host declares the third-party file in + * `angular.json` under a fixed `bundleName`, and the component that needs it appends + * `` at init. The name is the contract; the file stays the host's own + * copy of the third party's CSS. + * + * **Why by bundle name and not an `@import` from a component stylesheet.** Two separate reasons, one + * per kind of dependency: + * + * 1. *A peer's CSS the bundler cannot reach at all.* ng-zorro-antd ships no component styles: every + * component is `ViewEncapsulation.None` and its CSS is a separate opt-in the application loads + * itself. `@import 'ng-zorro-antd/select/style/index.min.css'` does not resolve under the Angular + * CLI: ng-zorro-antd (21.0.2) declares every `.//style/*` export as + * `{"less": ".//style/*.less", "style": ".../index.min.css"}` — `less` first. + * `@angular/build` bundles stylesheets with `conditions: ['style', 'sass', 'less', …]` + * (`@angular/build/src/tools/esbuild/stylesheets/bundle-options.js`), Node's conditional-exports + * algorithm takes the first *declared* key that is in the condition set, so `less` wins and + * esbuild looks for `index.min.css.less`. Only `angular.json` `styles[].input` — a filesystem + * path, which bypasses `exports` — reaches those files. + * 2. *A dependency's CSS the bundler does reach, and should not.* `ckeditor5` resolves + * `ckeditor5/ckeditor5.css` without trouble (`"./*": "./dist/*"`), and + * `@dignite/ng.flex-fields-ckeditor` used to `@import` it from `CKEditorControlComponent`'s + * stylesheet. ng-packagr inlines an `@import` at build time, so all 241 KB of that file became + * part of the package's JavaScript: the published `fesm2022` bundle was 471 KB carrying 522 + * `.ck-editor` rules, and since a host registers the field type in its application config, that + * CSS landed in the host's *initial* bundle and was downloaded by every visitor whether or not a + * rich-text field was ever opened. It also splits the version in two: the host installs + * `ckeditor5` itself and the editor JavaScript comes from `await import('ckeditor5')` at runtime, + * so an `@import` pins the CSS to whatever version *this* package happened to be built against. + * Asked for by bundle name, both halves are the host's one installed copy. + * + * Shipping a copy of either stylesheet inside these packages was the third option and is rejected + * for one reason covering both: it freezes a snapshot of someone else's CSS into an unrelated + * release cycle, and the host then runs a third party's components against a different version's CSS + * than the one it installed. + * + * Every bundle goes through this one service, so a host declares one entry per file however many + * components ask for it — both `Select` components share {@link NZ_SELECT_STYLE}. See the "Styles" + * section of the README of whichever package owns the constant for the host side. + * + * **Maintainers:** a package in this family that starts rendering a component with its own global + * CSS defines a {@link StyleBundle} constant in *that* package, calls {@link load} from the rendering + * component's `ngOnInit`, and updates that package's README "Styles" section and the changelog in the + * same PR. + */ +@Injectable({ providedIn: 'root' }) +export class FlexFieldsStyleLoader { + private readonly lazyLoadService = inject(LazyLoadService); + private readonly disabled = + inject(DISABLE_FLEX_FIELDS_STYLE_LOADING_TOKEN, { optional: true }) ?? false; + + /** + * Bundles already asked for. + * + * `LazyLoadService` keeps its own `loaded` map, but only writes to it *after* a load succeeds, so + * two components created in the same change-detection pass — a `Select` control and a `Select` + * search on one page — would each append their own ``. This is what makes it once per app. + */ + private readonly requested = new Set(); + + /** Appends `.css` to ``, unless it is already requested or disabled. */ + load(bundle: StyleBundle): void { + if (this.disabled || this.requested.has(bundle.bundleName)) { + return; + } + + this.requested.add(bundle.bundleName); + + this.lazyLoadService + .load( + LOADING_STRATEGY.AppendAnonymousStyleToHead(`${bundle.bundleName}.css`), + RETRY_TIMES, + RETRY_DELAY, + ) + // ABP's abp-tree leaves this stream's error unhandled, which surfaces as a bare + // `CustomEvent {type: 'error'}` in the console with nothing naming the file or the fix. One + // actionable message instead; the control keeps working, only unstyled. + .subscribe({ error: () => this.reportMissingBundle(bundle) }); + } + + private reportMissingBundle(bundle: StyleBundle): void { + console.error( + `[@dignite/ng.flex-fields] Could not load "${bundle.bundleName}.css". This stylesheet is not ` + + 'compiled into the package: the host serves its own copy of it under a fixed bundle name. ' + + 'Add this entry to the "styles" array of your angular.json build target and rebuild:\n' + + ` { "input": "${bundle.input}", "inject": false, "bundleName": "${bundle.bundleName}" }\n` + + 'Until then the control still works, only unstyled. See the "Styles" section of the ' + + '@dignite/ng.flex-fields README, which also links the sibling packages\' own.', + ); + } +}