@component-anatomy/core 0.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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/controller.d.ts +3 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/index.cjs +486 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.iife.js +488 -0
- package/dist/index.iife.js.map +7 -0
- package/dist/index.js +463 -0
- package/dist/index.js.map +7 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/registry.d.ts +25 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/theme.d.ts +30 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/controller.ts +200 -0
- package/src/index.ts +14 -0
- package/src/overlay.ts +231 -0
- package/src/registry.ts +77 -0
- package/src/theme.ts +118 -0
- package/src/types.ts +115 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type AnatomyPartDefinition = {
|
|
2
|
+
/** Matches the value of data-part="..." on the DOM element */
|
|
3
|
+
id: string;
|
|
4
|
+
/** Display name shown in the documentation panel and overlay label */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Raw Markdown string — rendered by the integration layer */
|
|
7
|
+
description?: string;
|
|
8
|
+
};
|
|
9
|
+
export type AnatomyEvent = 'part:enter' | 'part:leave';
|
|
10
|
+
export type AnatomyEventHandler = (partId: string) => void;
|
|
11
|
+
/** Names of the built-in visual presets. */
|
|
12
|
+
export type AnatomyPresetName = 'default' | 'minimal' | 'contrast' | 'blueprint';
|
|
13
|
+
/**
|
|
14
|
+
* Theme tokens. All optional — anything you don't set falls back to the
|
|
15
|
+
* preset, then to global `--ca-*` CSS variables, then to the default look.
|
|
16
|
+
*/
|
|
17
|
+
export type AnatomyTheme = {
|
|
18
|
+
/**
|
|
19
|
+
* Shorthand: a single brand color. Overlay border, overlay background
|
|
20
|
+
* (15% wash) and label background are derived from it automatically.
|
|
21
|
+
*/
|
|
22
|
+
accent?: string;
|
|
23
|
+
/** Overlay fill. Any CSS color. */
|
|
24
|
+
overlayBg?: string;
|
|
25
|
+
/** Overlay border color. */
|
|
26
|
+
overlayBorder?: string;
|
|
27
|
+
/** Overlay border width — number (px) or CSS length. */
|
|
28
|
+
overlayBorderWidth?: string | number;
|
|
29
|
+
/** Overlay border style: 'solid' | 'dashed' | 'dotted'… */
|
|
30
|
+
overlayBorderStyle?: string;
|
|
31
|
+
/** Overlay corner radius — number (px) or CSS length. */
|
|
32
|
+
overlayRadius?: string | number;
|
|
33
|
+
/** Label chip background. */
|
|
34
|
+
labelBg?: string;
|
|
35
|
+
/** Label chip text color. */
|
|
36
|
+
labelFg?: string;
|
|
37
|
+
/** Label chip font-family. */
|
|
38
|
+
labelFont?: string;
|
|
39
|
+
/** Label chip font-size — number (px) or CSS length. */
|
|
40
|
+
labelFontSize?: string | number;
|
|
41
|
+
/** z-index of overlays and labels. */
|
|
42
|
+
zIndex?: number;
|
|
43
|
+
/** Show/hide transition duration — number (ms) or CSS time. */
|
|
44
|
+
transitionMs?: string | number;
|
|
45
|
+
};
|
|
46
|
+
export type OverlayRenderContext = {
|
|
47
|
+
/** The part being highlighted. */
|
|
48
|
+
part: AnatomyPartDefinition;
|
|
49
|
+
/** The DOM element this overlay box covers. */
|
|
50
|
+
element: HTMLElement;
|
|
51
|
+
/** Index of this element among all elements matching the part (0-based). */
|
|
52
|
+
index: number;
|
|
53
|
+
};
|
|
54
|
+
export type OverlayOptions = {
|
|
55
|
+
/** Show the floating name label chip. Default: true. */
|
|
56
|
+
label?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Custom label content. Return a string (used as textContent) or a Node
|
|
59
|
+
* (appended). Return null/undefined to fall back to the part name.
|
|
60
|
+
*/
|
|
61
|
+
renderLabel?: (ctx: OverlayRenderContext) => string | Node | null | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Called after each overlay box is created and positioned — mutate it,
|
|
64
|
+
* append children, add classes. The box is `position: fixed` and sized
|
|
65
|
+
* to the target element.
|
|
66
|
+
*/
|
|
67
|
+
decorateOverlay?: (box: HTMLElement, ctx: OverlayRenderContext) => void;
|
|
68
|
+
/** Extra class name(s) added to every overlay box. */
|
|
69
|
+
className?: string;
|
|
70
|
+
/** Inflate the highlight box by N pixels on every side. Default: 0. */
|
|
71
|
+
padding?: number;
|
|
72
|
+
};
|
|
73
|
+
export type AnatomyOptions = {
|
|
74
|
+
/** The component preview container — where data-part elements live */
|
|
75
|
+
root: HTMLElement;
|
|
76
|
+
/** The documentation panel container — where data-anatomy-item elements live */
|
|
77
|
+
panel?: HTMLElement;
|
|
78
|
+
/** Part definitions. If omitted, auto-discovers from data-part values in the DOM */
|
|
79
|
+
parts?: AnatomyPartDefinition[];
|
|
80
|
+
/** Named visual preset. Default: 'default'. */
|
|
81
|
+
preset?: AnatomyPresetName;
|
|
82
|
+
/** Per-instance theme token overrides (applied on top of the preset). */
|
|
83
|
+
theme?: AnatomyTheme;
|
|
84
|
+
/** Overlay rendering options and hooks. */
|
|
85
|
+
overlay?: OverlayOptions;
|
|
86
|
+
};
|
|
87
|
+
export type AnatomyController = {
|
|
88
|
+
/** Programmatically activate a part (overlay + panel highlight) */
|
|
89
|
+
highlight(partId: string): void;
|
|
90
|
+
/** Deactivate all parts */
|
|
91
|
+
unhighlight(): void;
|
|
92
|
+
/** Re-query the DOM — call after dynamic updates */
|
|
93
|
+
refresh(): void;
|
|
94
|
+
/** Tear down all listeners and overlays */
|
|
95
|
+
destroy(): void;
|
|
96
|
+
/** Subscribe to part activation events. Returns an unsubscribe function. */
|
|
97
|
+
on(event: AnatomyEvent, handler: AnatomyEventHandler): () => void;
|
|
98
|
+
/** The resolved part definitions (explicit or auto-discovered). */
|
|
99
|
+
getParts(): AnatomyPartDefinition[];
|
|
100
|
+
/** Swap theme tokens at runtime. Pass a preset name, tokens, or both. */
|
|
101
|
+
setTheme(theme: AnatomyTheme, preset?: AnatomyPresetName): void;
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@component-anatomy/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Framework-agnostic runtime for interactive component anatomy documentation",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Julien Déramond",
|
|
7
|
+
"homepage": "https://github.com/julien-deramond/component-anatomy#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/julien-deramond/component-anatomy",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/julien-deramond/component-anatomy/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"component-anatomy",
|
|
18
|
+
"design-system",
|
|
19
|
+
"documentation",
|
|
20
|
+
"dom",
|
|
21
|
+
"overlay",
|
|
22
|
+
"anatomy",
|
|
23
|
+
"interactive",
|
|
24
|
+
"framework-agnostic"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist/index.cjs",
|
|
34
|
+
"types": "./dist/index.d.ts"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"esbuild": "^0.21.0",
|
|
47
|
+
"typescript": "^5.4.5",
|
|
48
|
+
"vitest": "^2.1.0",
|
|
49
|
+
"jsdom": "^24.1.0"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18.0.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "node build.mjs",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { AnatomyRegistry } from './registry.js';
|
|
2
|
+
import { AnatomyOverlay } from './overlay.js';
|
|
3
|
+
import { resolveThemeVars } from './theme.js';
|
|
4
|
+
import type {
|
|
5
|
+
AnatomyOptions,
|
|
6
|
+
AnatomyController,
|
|
7
|
+
AnatomyEvent,
|
|
8
|
+
AnatomyEventHandler,
|
|
9
|
+
AnatomyPartDefinition,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
|
|
12
|
+
/** Capitalizes an ID like "slider-thumb" → "Slider Thumb" */
|
|
13
|
+
function idToName(id: string): string {
|
|
14
|
+
return id
|
|
15
|
+
.replace(/[-_]/g, ' ')
|
|
16
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createController(options: AnatomyOptions): AnatomyController {
|
|
20
|
+
const { root, panel } = options;
|
|
21
|
+
|
|
22
|
+
const registry = new AnatomyRegistry(root);
|
|
23
|
+
const overlay = new AnatomyOverlay(
|
|
24
|
+
resolveThemeVars(options.preset, options.theme),
|
|
25
|
+
options.overlay ?? {}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Resolve part definitions: explicit or auto-discovered
|
|
29
|
+
let parts: AnatomyPartDefinition[] = options.parts ??
|
|
30
|
+
registry.partIds().map((id) => ({ id, name: idToName(id) }));
|
|
31
|
+
|
|
32
|
+
function findPart(partId: string): AnatomyPartDefinition {
|
|
33
|
+
return parts.find((p) => p.id === partId) ?? { id: partId, name: idToName(partId) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Event emitter
|
|
37
|
+
const listeners = new Map<AnatomyEvent, Set<AnatomyEventHandler>>();
|
|
38
|
+
|
|
39
|
+
function emit(event: AnatomyEvent, partId: string) {
|
|
40
|
+
listeners.get(event)?.forEach((fn) => fn(partId));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cleanup registry
|
|
44
|
+
const cleanupFns: Array<() => void> = [];
|
|
45
|
+
|
|
46
|
+
function highlight(partId: string, source: 'preview' | 'panel' = 'panel') {
|
|
47
|
+
const elements = registry.query().get(partId) ?? [];
|
|
48
|
+
overlay.show(elements, findPart(partId));
|
|
49
|
+
|
|
50
|
+
// Mark matching panel items active + scroll into view when triggered from preview
|
|
51
|
+
if (panel) {
|
|
52
|
+
panel.querySelectorAll<HTMLElement>('[data-anatomy-item]').forEach((el) => {
|
|
53
|
+
if (el.dataset.anatomyItem === partId) {
|
|
54
|
+
el.setAttribute('data-active', '');
|
|
55
|
+
// Only auto-scroll when hover comes from the component, not the panel itself
|
|
56
|
+
if (source === 'preview') {
|
|
57
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
el.removeAttribute('data-active');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
emit('part:enter', partId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unhighlight() {
|
|
69
|
+
overlay.hide();
|
|
70
|
+
|
|
71
|
+
if (panel) {
|
|
72
|
+
panel.querySelectorAll<HTMLElement>('[data-anatomy-item]').forEach((el) => {
|
|
73
|
+
el.removeAttribute('data-active');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// emit part:leave with empty string as signal (last active unknown at this point)
|
|
78
|
+
emit('part:leave', '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function attachElementListeners() {
|
|
82
|
+
const map = registry.query();
|
|
83
|
+
|
|
84
|
+
map.forEach((elements, partId) => {
|
|
85
|
+
elements.forEach((el) => {
|
|
86
|
+
// Mouse only — no focus/blur here.
|
|
87
|
+
// Preview elements are a live component; adding tabstops breaks
|
|
88
|
+
// their natural tab order and confuses assistive technology.
|
|
89
|
+
// Keyboard navigation is handled via the panel entries only.
|
|
90
|
+
const enter = () => highlight(partId, 'preview');
|
|
91
|
+
const leave = () => unhighlight();
|
|
92
|
+
el.addEventListener('mouseenter', enter);
|
|
93
|
+
el.addEventListener('mouseleave', leave);
|
|
94
|
+
cleanupFns.push(() => {
|
|
95
|
+
el.removeEventListener('mouseenter', enter);
|
|
96
|
+
el.removeEventListener('mouseleave', leave);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function attachPanelListeners() {
|
|
103
|
+
if (!panel) return;
|
|
104
|
+
|
|
105
|
+
panel.querySelectorAll<HTMLElement>('[data-anatomy-item]').forEach((el) => {
|
|
106
|
+
const partId = el.dataset.anatomyItem ?? '';
|
|
107
|
+
const enter = () => highlight(partId, 'panel');
|
|
108
|
+
const leave = () => unhighlight();
|
|
109
|
+
el.addEventListener('mouseenter', enter);
|
|
110
|
+
el.addEventListener('mouseleave', leave);
|
|
111
|
+
el.addEventListener('focus', enter);
|
|
112
|
+
el.addEventListener('blur', leave);
|
|
113
|
+
cleanupFns.push(() => {
|
|
114
|
+
el.removeEventListener('mouseenter', enter);
|
|
115
|
+
el.removeEventListener('mouseleave', leave);
|
|
116
|
+
el.removeEventListener('focus', enter);
|
|
117
|
+
el.removeEventListener('blur', leave);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Scroll + resize → reposition active overlays
|
|
123
|
+
const onScroll = () => overlay.reposition();
|
|
124
|
+
const onResize = () => overlay.reposition();
|
|
125
|
+
|
|
126
|
+
const resizeObserver = new ResizeObserver(() => overlay.reposition());
|
|
127
|
+
|
|
128
|
+
function setup() {
|
|
129
|
+
attachElementListeners();
|
|
130
|
+
attachPanelListeners();
|
|
131
|
+
|
|
132
|
+
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
133
|
+
window.addEventListener('resize', onResize, { passive: true });
|
|
134
|
+
resizeObserver.observe(root);
|
|
135
|
+
|
|
136
|
+
// Watch for dynamic DOM changes
|
|
137
|
+
const stopObserving = registry.observe(() => {
|
|
138
|
+
teardownListeners();
|
|
139
|
+
// Re-resolve auto-discovered parts if none were explicitly provided
|
|
140
|
+
if (!options.parts) {
|
|
141
|
+
parts = registry.partIds().map((id) => ({ id, name: idToName(id) }));
|
|
142
|
+
}
|
|
143
|
+
attachElementListeners();
|
|
144
|
+
attachPanelListeners();
|
|
145
|
+
});
|
|
146
|
+
cleanupFns.push(stopObserving);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function teardownListeners() {
|
|
150
|
+
// Run all registered cleanup functions except the MutationObserver (last one)
|
|
151
|
+
// We rebuild element/panel listeners on refresh but keep the observer running
|
|
152
|
+
const observerCleanup = cleanupFns.pop(); // MutationObserver cleanup is last
|
|
153
|
+
cleanupFns.forEach((fn) => fn());
|
|
154
|
+
cleanupFns.length = 0;
|
|
155
|
+
if (observerCleanup) cleanupFns.push(observerCleanup); // put it back
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
setup();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
highlight,
|
|
162
|
+
unhighlight,
|
|
163
|
+
|
|
164
|
+
refresh() {
|
|
165
|
+
unhighlight();
|
|
166
|
+
teardownListeners();
|
|
167
|
+
if (!options.parts) {
|
|
168
|
+
parts = registry.partIds().map((id) => ({ id, name: idToName(id) }));
|
|
169
|
+
}
|
|
170
|
+
attachElementListeners();
|
|
171
|
+
attachPanelListeners();
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
destroy() {
|
|
175
|
+
unhighlight();
|
|
176
|
+
cleanupFns.forEach((fn) => fn());
|
|
177
|
+
cleanupFns.length = 0;
|
|
178
|
+
window.removeEventListener('scroll', onScroll, { capture: true });
|
|
179
|
+
window.removeEventListener('resize', onResize);
|
|
180
|
+
resizeObserver.disconnect();
|
|
181
|
+
overlay.destroy();
|
|
182
|
+
registry.destroy();
|
|
183
|
+
listeners.clear();
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
on(event, handler) {
|
|
187
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
188
|
+
listeners.get(event)!.add(handler);
|
|
189
|
+
return () => listeners.get(event)?.delete(handler);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
getParts() {
|
|
193
|
+
return parts.slice();
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
setTheme(theme, preset) {
|
|
197
|
+
overlay.setVars(resolveThemeVars(preset, theme));
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AnatomyPartDefinition,
|
|
3
|
+
AnatomyOptions,
|
|
4
|
+
AnatomyController,
|
|
5
|
+
AnatomyEvent,
|
|
6
|
+
AnatomyEventHandler,
|
|
7
|
+
AnatomyTheme,
|
|
8
|
+
AnatomyPresetName,
|
|
9
|
+
OverlayOptions,
|
|
10
|
+
OverlayRenderContext,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
export { createController as createAnatomy } from './controller.js';
|
|
14
|
+
export { presets, resolveThemeVars } from './theme.js';
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnatomyPartDefinition,
|
|
3
|
+
OverlayOptions,
|
|
4
|
+
OverlayRenderContext,
|
|
5
|
+
} from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AnatomyOverlay — renders position:fixed highlight boxes + name label chips
|
|
9
|
+
* over target elements. Injected into document.body to escape ancestor
|
|
10
|
+
* stacking contexts and CSS transforms.
|
|
11
|
+
*
|
|
12
|
+
* Theming: the injected stylesheet reads `--ca-*` custom properties with
|
|
13
|
+
* built-in fallbacks (the "default" look). Per-instance themes are applied
|
|
14
|
+
* as inline custom properties on each overlay/label element, so multiple
|
|
15
|
+
* controllers with different themes can coexist on one page while global
|
|
16
|
+
* `--ca-*` variables keep working as a page-wide default.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const OVERLAY_CLASS = 'ca-overlay';
|
|
20
|
+
const LABEL_CLASS = 'ca-overlay-label';
|
|
21
|
+
const STYLE_ID = 'ca-overlay-styles';
|
|
22
|
+
|
|
23
|
+
function injectStyles() {
|
|
24
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
25
|
+
const style = document.createElement('style');
|
|
26
|
+
style.id = STYLE_ID;
|
|
27
|
+
style.textContent = `
|
|
28
|
+
.${OVERLAY_CLASS} {
|
|
29
|
+
position: fixed;
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
box-sizing: border-box;
|
|
32
|
+
border-radius: var(--ca-overlay-radius, 4px);
|
|
33
|
+
background: var(--ca-overlay-bg, rgba(99, 102, 241, 0.18));
|
|
34
|
+
outline: var(--ca-overlay-border-width, 2px)
|
|
35
|
+
var(--ca-overlay-border-style, solid)
|
|
36
|
+
var(--ca-overlay-border, rgba(99, 102, 241, 0.75));
|
|
37
|
+
outline-offset: 1px;
|
|
38
|
+
z-index: var(--ca-overlay-z, 9998);
|
|
39
|
+
transition: opacity var(--ca-transition, 150ms) ease;
|
|
40
|
+
}
|
|
41
|
+
.${OVERLAY_CLASS}.ca-overlay--hidden {
|
|
42
|
+
opacity: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Name label chip — floats above the overlay */
|
|
46
|
+
.${LABEL_CLASS} {
|
|
47
|
+
position: fixed;
|
|
48
|
+
pointer-events: none;
|
|
49
|
+
z-index: var(--ca-overlay-z, 9999);
|
|
50
|
+
display: inline-flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 4px;
|
|
53
|
+
padding: 2px 7px 2px 5px;
|
|
54
|
+
border-radius: 4px;
|
|
55
|
+
background: var(--ca-label-bg, rgba(79, 70, 229, 1));
|
|
56
|
+
color: var(--ca-label-fg, #fff);
|
|
57
|
+
font-family: var(--ca-label-font, ui-monospace, 'Cascadia Code', 'Fira Mono', monospace);
|
|
58
|
+
font-size: var(--ca-label-font-size, 11px);
|
|
59
|
+
font-weight: 500;
|
|
60
|
+
letter-spacing: 0.02em;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
line-height: 1.6;
|
|
63
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
|
64
|
+
transition: opacity var(--ca-transition, 150ms) ease;
|
|
65
|
+
}
|
|
66
|
+
.${LABEL_CLASS}.ca-overlay--hidden {
|
|
67
|
+
opacity: 0;
|
|
68
|
+
}
|
|
69
|
+
.${LABEL_CLASS}::before {
|
|
70
|
+
content: '';
|
|
71
|
+
display: inline-block;
|
|
72
|
+
width: 5px;
|
|
73
|
+
height: 5px;
|
|
74
|
+
border-radius: 50%;
|
|
75
|
+
background: color-mix(in srgb, var(--ca-label-fg, #fff) 60%, transparent);
|
|
76
|
+
flex-shrink: 0;
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
document.head.appendChild(style);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function applyVars(el: HTMLElement, vars: Record<string, string>) {
|
|
83
|
+
for (const [prop, value] of Object.entries(vars)) {
|
|
84
|
+
el.style.setProperty(prop, value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class AnatomyOverlay {
|
|
89
|
+
private overlays: HTMLDivElement[] = [];
|
|
90
|
+
private labels: HTMLDivElement[] = [];
|
|
91
|
+
private activeElements: HTMLElement[] = [];
|
|
92
|
+
private activePart: AnatomyPartDefinition | null = null;
|
|
93
|
+
private rafId: number | null = null;
|
|
94
|
+
|
|
95
|
+
private vars: Record<string, string>;
|
|
96
|
+
private options: OverlayOptions;
|
|
97
|
+
|
|
98
|
+
constructor(vars: Record<string, string> = {}, options: OverlayOptions = {}) {
|
|
99
|
+
this.vars = vars;
|
|
100
|
+
this.options = options;
|
|
101
|
+
injectStyles();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Replace the per-instance theme variables. Re-applies to any overlays
|
|
106
|
+
* currently on screen.
|
|
107
|
+
*/
|
|
108
|
+
setVars(vars: Record<string, string>): void {
|
|
109
|
+
this.vars = vars;
|
|
110
|
+
if (this.overlays.length > 0 || this.labels.length > 0) {
|
|
111
|
+
[...this.overlays, ...this.labels].forEach((el) => {
|
|
112
|
+
el.removeAttribute('style'); // clear stale vars (positioning restored below)
|
|
113
|
+
applyVars(el, vars);
|
|
114
|
+
});
|
|
115
|
+
this._position();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Show highlight overlays + label chip over each of the given elements.
|
|
121
|
+
*/
|
|
122
|
+
show(elements: HTMLElement[], part: AnatomyPartDefinition): void {
|
|
123
|
+
this.hide();
|
|
124
|
+
this.activeElements = elements;
|
|
125
|
+
this.activePart = part;
|
|
126
|
+
|
|
127
|
+
const showLabel = this.options.label !== false;
|
|
128
|
+
|
|
129
|
+
elements.forEach((element, index) => {
|
|
130
|
+
const ctx: OverlayRenderContext = { part, element, index };
|
|
131
|
+
|
|
132
|
+
const div = document.createElement('div');
|
|
133
|
+
div.className = `${OVERLAY_CLASS} ca-overlay--hidden`;
|
|
134
|
+
if (this.options.className) div.className += ` ${this.options.className}`;
|
|
135
|
+
div.setAttribute('aria-hidden', 'true');
|
|
136
|
+
applyVars(div, this.vars);
|
|
137
|
+
document.body.appendChild(div);
|
|
138
|
+
this.overlays.push(div);
|
|
139
|
+
|
|
140
|
+
if (this.options.decorateOverlay) {
|
|
141
|
+
this.options.decorateOverlay(div, ctx);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (showLabel) {
|
|
145
|
+
const label = document.createElement('div');
|
|
146
|
+
label.className = `${LABEL_CLASS} ca-overlay--hidden`;
|
|
147
|
+
label.setAttribute('aria-hidden', 'true');
|
|
148
|
+
applyVars(label, this.vars);
|
|
149
|
+
|
|
150
|
+
const custom = this.options.renderLabel?.(ctx);
|
|
151
|
+
if (typeof custom === 'string') label.textContent = custom;
|
|
152
|
+
else if (custom instanceof Node) label.appendChild(custom);
|
|
153
|
+
else label.textContent = part.name;
|
|
154
|
+
|
|
155
|
+
document.body.appendChild(label);
|
|
156
|
+
this.labels.push(label);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Position after paint so getBoundingClientRect is accurate
|
|
161
|
+
requestAnimationFrame(() => {
|
|
162
|
+
this._position();
|
|
163
|
+
this.overlays.forEach((o) => o.classList.remove('ca-overlay--hidden'));
|
|
164
|
+
this.labels.forEach((l) => l.classList.remove('ca-overlay--hidden'));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Remove all overlays and labels from the DOM.
|
|
170
|
+
*/
|
|
171
|
+
hide(): void {
|
|
172
|
+
if (this.rafId !== null) {
|
|
173
|
+
cancelAnimationFrame(this.rafId);
|
|
174
|
+
this.rafId = null;
|
|
175
|
+
}
|
|
176
|
+
this.overlays.forEach((o) => o.remove());
|
|
177
|
+
this.labels.forEach((l) => l.remove());
|
|
178
|
+
this.overlays = [];
|
|
179
|
+
this.labels = [];
|
|
180
|
+
this.activeElements = [];
|
|
181
|
+
this.activePart = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Recompute overlay positions. Call on scroll or resize.
|
|
186
|
+
*/
|
|
187
|
+
reposition(): void {
|
|
188
|
+
if (this.overlays.length === 0) return;
|
|
189
|
+
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
|
|
190
|
+
this.rafId = requestAnimationFrame(() => {
|
|
191
|
+
this._position();
|
|
192
|
+
this.rafId = null;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
destroy(): void {
|
|
197
|
+
this.hide();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private _position(): void {
|
|
201
|
+
const LABEL_OFFSET = 6; // px gap between label and overlay top edge
|
|
202
|
+
const LABEL_HEIGHT = 22; // approximate label height
|
|
203
|
+
const pad = this.options.padding ?? 0;
|
|
204
|
+
|
|
205
|
+
this.activeElements.forEach((el, i) => {
|
|
206
|
+
const overlay = this.overlays[i];
|
|
207
|
+
const label = this.labels[i];
|
|
208
|
+
if (!overlay) return;
|
|
209
|
+
|
|
210
|
+
const rect = el.getBoundingClientRect();
|
|
211
|
+
const top = rect.top - pad;
|
|
212
|
+
const left = rect.left - pad;
|
|
213
|
+
const width = rect.width + pad * 2;
|
|
214
|
+
const height = rect.height + pad * 2;
|
|
215
|
+
|
|
216
|
+
// Position the highlight box
|
|
217
|
+
overlay.style.top = `${top}px`;
|
|
218
|
+
overlay.style.left = `${left}px`;
|
|
219
|
+
overlay.style.width = `${width}px`;
|
|
220
|
+
overlay.style.height = `${height}px`;
|
|
221
|
+
|
|
222
|
+
if (!label) return;
|
|
223
|
+
|
|
224
|
+
// Place label chip above the overlay; flip below if too close to top
|
|
225
|
+
const labelTop = top - LABEL_HEIGHT - LABEL_OFFSET;
|
|
226
|
+
const finalTop = labelTop < 4 ? top + height + LABEL_OFFSET : labelTop;
|
|
227
|
+
label.style.top = `${finalTop}px`;
|
|
228
|
+
label.style.left = `${left}px`;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnatomyRegistry — discovers and tracks [data-part] elements within a root.
|
|
3
|
+
* Intentionally stateless between calls: query() always reads the live DOM.
|
|
4
|
+
*/
|
|
5
|
+
export class AnatomyRegistry {
|
|
6
|
+
private root: HTMLElement;
|
|
7
|
+
private observer: MutationObserver | null = null;
|
|
8
|
+
|
|
9
|
+
constructor(root: HTMLElement) {
|
|
10
|
+
this.root = root;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns a Map of partId → matching HTMLElements.
|
|
15
|
+
* Always queries the live DOM — never returns a stale cache.
|
|
16
|
+
*/
|
|
17
|
+
query(): Map<string, HTMLElement[]> {
|
|
18
|
+
const map = new Map<string, HTMLElement[]>();
|
|
19
|
+
const nodes = this.root.querySelectorAll<HTMLElement>('[data-part]');
|
|
20
|
+
|
|
21
|
+
nodes.forEach((el) => {
|
|
22
|
+
const id = el.dataset.part;
|
|
23
|
+
if (!id) return;
|
|
24
|
+
const existing = map.get(id) ?? [];
|
|
25
|
+
existing.push(el);
|
|
26
|
+
map.set(id, existing);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns all unique part IDs present in the DOM, in document order.
|
|
34
|
+
*/
|
|
35
|
+
partIds(): string[] {
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
const result: string[] = [];
|
|
38
|
+
this.root.querySelectorAll<HTMLElement>('[data-part]').forEach((el) => {
|
|
39
|
+
const id = el.dataset.part;
|
|
40
|
+
if (id && !seen.has(id)) {
|
|
41
|
+
seen.add(id);
|
|
42
|
+
result.push(id);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Watches the root for DOM mutations and calls the callback when [data-part]
|
|
50
|
+
* elements are added or removed. Returns a cleanup function.
|
|
51
|
+
*/
|
|
52
|
+
observe(callback: () => void): () => void {
|
|
53
|
+
this.observer = new MutationObserver((mutations) => {
|
|
54
|
+
const relevant = mutations.some((m) =>
|
|
55
|
+
Array.from(m.addedNodes).concat(Array.from(m.removedNodes)).some(
|
|
56
|
+
(n) =>
|
|
57
|
+
n instanceof HTMLElement &&
|
|
58
|
+
(n.hasAttribute('data-part') ||
|
|
59
|
+
n.querySelector('[data-part]') !== null)
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
if (relevant) callback();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.observer.observe(this.root, { childList: true, subtree: true });
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
this.observer?.disconnect();
|
|
69
|
+
this.observer = null;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
destroy() {
|
|
74
|
+
this.observer?.disconnect();
|
|
75
|
+
this.observer = null;
|
|
76
|
+
}
|
|
77
|
+
}
|