Skip to content

Commit a96b8f7

Browse files
committed
docs: AFCL examples render right in the docs page, each in a shadow root instead of an iframe, so Vue, AFCL and their CSS load once per page, and the calendar of the date picker is laid out again AdminForth/2005
1 parent 07d6b83 commit a96b8f7

9 files changed

Lines changed: 192 additions & 103 deletions

File tree

‎documentation/afcl-preview/index.html‎

Lines changed: 0 additions & 14 deletions
This file was deleted.

‎documentation/afcl-preview/main.ts‎

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { defineCustomElement, h, provide, useShadowRoot, type Component } from 'vue';
2+
import { createRouter, createMemoryHistory } from 'vue-router';
3+
import { initI18n } from '@/i18n';
4+
import { AFCL_TELEPORT_TARGET } from '@/afcl/shadowRoot';
5+
import geistCss from '@fontsource-variable/geist?inline';
6+
import geistMonoCss from '@fontsource-variable/geist-mono?inline';
7+
import { coreStore } from './stubs';
8+
import { addShadowRoot, removeShadowRoot } from './shadowStyles';
9+
10+
// <AfclPreview> of the docs calls mountExample() to render its example right in the page: a custom element with a
11+
// shadow root keeps the styles of the docs and of AFCL apart, while Vue, AFCL and their CSS load once per page
12+
13+
const loadExample = import.meta.glob<{ default: Component }>('./examples/*.vue');
14+
15+
// @font-face does not work inside a shadow root, the docs page gets it
16+
const fonts = document.createElement('style');
17+
fonts.textContent = geistCss + geistMonoCss;
18+
document.head.append(fonts);
19+
20+
// the docs page owns the light/dark switch
21+
const docsRoot = document.documentElement;
22+
const examples = new Set<HTMLElement>();
23+
24+
function isDark() {
25+
return docsRoot.dataset.theme === 'dark';
26+
}
27+
28+
new MutationObserver(() => {
29+
coreStore.theme = isDark() ? 'dark' : 'light';
30+
examples.forEach((el) => el.classList.toggle('dark', isDark()));
31+
}).observe(docsRoot, { attributeFilter: ['data-theme'] });
32+
coreStore.theme = isDark() ? 'dark' : 'light';
33+
34+
function exampleRoot(example: Component) {
35+
return {
36+
setup() {
37+
// popups, modals and tooltips of the example go here instead of body, see @/afcl/shadowRoot
38+
const teleportTarget = document.createElement('div');
39+
teleportTarget.style.position = 'relative';
40+
useShadowRoot()!.append(teleportTarget);
41+
provide(AFCL_TELEPORT_TARGET, teleportTarget);
42+
return () => h('div', {
43+
class: 'bg-lightHtml text-lightListTableText dark:bg-darkHtml dark:text-darkListTableText antialiased p-4 sm:p-6',
44+
style: { borderRadius: 'inherit' },
45+
}, h(example));
46+
},
47+
};
48+
}
49+
50+
export async function mountExample(id: string, host: HTMLElement) {
51+
const tag = `afcl-example-${id}`;
52+
if (!customElements.get(tag)) {
53+
const { default: example } = await loadExample[`./examples/${id}.vue`]();
54+
// another preview of the same page could define it meanwhile
55+
if (!customElements.get(tag)) {
56+
customElements.define(tag, defineCustomElement(exampleRoot(example), {
57+
configureApp(app) {
58+
initI18n(app);
59+
app.use(createRouter({ history: createMemoryHistory(), routes: [{ path: '/:path(.*)*', component: { render: () => null } }] }));
60+
},
61+
}));
62+
}
63+
}
64+
const el = document.createElement(tag);
65+
el.classList.toggle('dark', isDark());
66+
addShadowRoot(el.shadowRoot!);
67+
examples.add(el);
68+
host.append(el);
69+
return () => {
70+
examples.delete(el);
71+
removeShadowRoot(el.shadowRoot!);
72+
el.remove();
73+
};
74+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import tailwindCss from '@/index.scss?inline';
2+
3+
// the CSS of the examples is parsed once for the whole docs page and shared by the shadow roots of all examples
4+
5+
// an example does not inherit the font, color and size of the docs text around it
6+
const HOST_CSS = ':host { all: initial; display: block; border-radius: inherit; }';
7+
8+
const tailwindSheet = new CSSStyleSheet();
9+
tailwindSheet.replaceSync(HOST_CSS + tailwindCss);
10+
11+
// plain CSS which a component imports (vue3-json-viewer) would go to document.head, the build hands it here
12+
const componentSheets: CSSStyleSheet[] = [];
13+
const roots = new Set<ShadowRoot>();
14+
15+
function applySheets(root: ShadowRoot) {
16+
root.adoptedStyleSheets = [tailwindSheet, ...componentSheets];
17+
}
18+
19+
export function adoptComponentCss(css: string) {
20+
const sheet = new CSSStyleSheet();
21+
sheet.replaceSync(css);
22+
componentSheets.push(sheet);
23+
roots.forEach(applySheets);
24+
}
25+
26+
export function addShadowRoot(root: ShadowRoot) {
27+
roots.add(root);
28+
applySheets(root);
29+
}
30+
31+
export function removeShadowRoot(root: ShadowRoot) {
32+
roots.delete(root);
33+
}

‎documentation/afcl-preview/vite.config.ts‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import path from 'node:path';
77
import { fileURLToPath } from 'node:url';
88
import { StylesGenerator } from '../../adminforth/modules/styleGenerator.js';
99

10-
// Builds static/afcl-preview: a page which renders one AFCL example of the docs, picked by the URL hash.
11-
// Docs embed it with <AfclPreview id="...">, see src/components/AfclPreview.
10+
// Builds static/afcl-preview: mount.js renders the AFCL examples of the docs right in the docs page, each in a shadow root.
11+
// Docs embed an example with <AfclPreview id="...">, see src/components/AfclPreview.
1212

1313
const previewDir = fileURLToPath(new URL('.', import.meta.url));
1414
const docsDir = path.join(previewDir, '../docs');
@@ -17,12 +17,14 @@ const spaDir = path.join(previewDir, '../../adminforth/spa');
1717
const spaSrc = path.join(spaDir, 'src');
1818
const afclDir = path.join(spaSrc, 'afcl');
1919
const stubs = path.join(previewDir, 'stubs.ts');
20+
const shadowStyles = path.join(previewDir, 'shadowStyles.ts');
2021

2122
const PREVIEW_RE = /<AfclPreview id="([^"]+)"[^>]*>([\s\S]*?)<\/AfclPreview>/g;
2223
const CODE_BLOCK_RE = /```(\w+)[^\n]*\n([\s\S]*?)```/g;
2324
const DIFF_MARKER_RE = /^\s*\/\/\s*diff-(add|remove)\s*$/;
2425
const AFCL_IMPORT_RE = /^import \{([^}]+)\} from '@\/afcl';?$/gm;
2526
const COMPONENT_TAG_RE = /<([A-Z]\w*)/g;
27+
const CSS_IMPORT_RE = /^import ["']([^"']+\.css)["'];?$/gm;
2628

2729
// code blocks may show a change with //diff-add and //diff-remove lines, the example renders the result
2830
function applyDiff(code: string) {
@@ -75,9 +77,31 @@ function docsExamples(): Plugin {
7577
};
7678
}
7779

80+
// a component which imports plain CSS (JsonViewer imports the CSS of vue3-json-viewer) would put it into document.head,
81+
// out of reach of the shadow roots of the examples, the CSS goes to their shared styles instead
82+
function componentCssToShadowRoots(): Plugin {
83+
return {
84+
name: 'afcl-component-css-to-shadow-roots',
85+
enforce: 'post',
86+
transform(code, id) {
87+
if (!id.startsWith(spaSrc)) {
88+
return;
89+
}
90+
let n = 0;
91+
const result = code.replace(CSS_IMPORT_RE, (_, css: string) => {
92+
const name = `__afclCss${n++}`;
93+
return `import ${name} from '${css}?inline';\nimport { adoptComponentCss as ${name}Adopt } from '${shadowStyles}';\n${name}Adopt(${name});`;
94+
});
95+
return n ? result : undefined;
96+
},
97+
};
98+
}
99+
78100
export default defineConfig({
79-
base: './',
80-
plugins: [docsExamples(), vue()],
101+
// mount.js is loaded by the docs pages, so the URLs of its chunks and fonts are absolute
102+
base: '/afcl-preview/',
103+
// custom element mode puts the styles of a component into the component, Vue adds them to the shadow root of the example
104+
plugins: [docsExamples(), vue({ customElement: true }), componentCssToShadowRoots()],
81105
resolve: {
82106
alias: [
83107
// the core store and frontend API bring the router and API client along, previews get stand-ins
@@ -90,9 +114,10 @@ export default defineConfig({
90114
postcss: {
91115
plugins: [
92116
tailwindcss({
93-
// flowbite JS (datepicker, tooltip) adds its own classes, the SPA scans it as well
94-
content: [`${spaSrc}/{afcl,components}/**/*.{vue,ts}`, `${spaDir}/node_modules/flowbite/**/*.js`, `${previewDir}/{index.html,main.ts,examples/*.vue}`],
95-
darkMode: 'class',
117+
// the calendar of flowbite-datepicker builds its markup with Tailwind classes in JS, the SPA scans it as well
118+
content: [`${spaSrc}/{afcl,components}/**/*.{vue,ts}`, `${spaDir}/node_modules/flowbite-datepicker/**/*.js`, `${previewDir}/{mount.ts,examples/*.vue}`],
119+
// the dark class is on the custom element of an example, its styles live in its shadow root
120+
darkMode: ['variant', ':host(.dark) &'],
96121
theme: { extend: new StylesGenerator({}).mergeStyles() },
97122
}),
98123
autoprefixer(),
@@ -102,5 +127,12 @@ export default defineConfig({
102127
build: {
103128
outDir: '../static/afcl-preview',
104129
emptyOutDir: true,
130+
modulePreload: { polyfill: false },
131+
rolldownOptions: {
132+
input: path.join(previewDir, 'mount.ts'),
133+
// the docs import mountExample() from it
134+
preserveEntrySignatures: 'exports-only',
135+
output: { entryFileNames: 'mount.js' },
136+
},
105137
},
106138
});

‎documentation/docs/tutorial/03-Customization/15-afcl/01-buttonsAndInputs.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ import { LinkButton } from '@/afcl'
218218

219219
### Single
220220

221-
<AfclPreview id="select" minHeight={250}>
221+
<AfclPreview id="select">
222222

223223
```ts
224224
import { ref } from 'vue'
@@ -244,7 +244,7 @@ const selected = ref(null)
244244

245245
### Multiple
246246

247-
<AfclPreview id="select-multiple" minHeight={250}>
247+
<AfclPreview id="select-multiple">
248248

249249
```ts
250250
import { Select } from '@/afcl'
@@ -277,7 +277,7 @@ const selected = ref([])
277277

278278
You can customize item and selected item using slots.
279279

280-
<AfclPreview id="select-custom-item" minHeight={250}>
280+
<AfclPreview id="select-custom-item">
281281

282282
```ts
283283
import { Select } from '@/afcl'
@@ -324,7 +324,7 @@ const selected = ref(null)
324324

325325
You might need to put some extra item at bottom of list
326326

327-
<AfclPreview id="select-extra-item" minHeight={250}>
327+
<AfclPreview id="select-extra-item">
328328

329329
```ts
330330
import { Select, LinkButton } from '@/afcl'
@@ -358,7 +358,7 @@ const selected = ref(null)
358358

359359
Pass `classesForInput` to style the input of the select.
360360

361-
<AfclPreview id="select-input-classes" minHeight={250}>
361+
<AfclPreview id="select-input-classes">
362362

363363
```ts
364364
import { Select } from '@/afcl'
@@ -550,7 +550,7 @@ const enabled = ref(false)
550550

551551
### Basic
552552

553-
<AfclPreview id="date-picker" minHeight={440}>
553+
<AfclPreview id="date-picker">
554554

555555
```ts
556556
import { ref } from 'vue'

0 commit comments

Comments
 (0)