@profpowell/vb-project-planning 0.1.0
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 +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@profpowell/vb-project-planning",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Project- and UX-planning Web Components for Vanilla Breeze: personas, stories, journeys, empathy maps, impact/effort and quadrant grids, risk and traceability matrices, burndown, roadmap, kanban, story map, gantt, review surface, work items, ADRs, iron triangle, capacity plans, quality targets, and requirement cards.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Patrick Powell",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/ProfPowell/vb-project-planning#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ProfPowell/vb-project-planning.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ProfPowell/vb-project-planning/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"vanilla-breeze",
|
|
18
|
+
"web-components",
|
|
19
|
+
"project-planning",
|
|
20
|
+
"ux",
|
|
21
|
+
"agile",
|
|
22
|
+
"kanban",
|
|
23
|
+
"roadmap"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"main": "./dist/vb-project-planning.js",
|
|
30
|
+
"module": "./src/index.js",
|
|
31
|
+
"style": "./dist/vb-project-planning.css",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"import": "./src/index.js",
|
|
35
|
+
"default": "./dist/vb-project-planning.js"
|
|
36
|
+
},
|
|
37
|
+
"./css": "./src/index.css",
|
|
38
|
+
"./dist/css": "./dist/vb-project-planning.css",
|
|
39
|
+
"./user-persona": "./src/web-components/user-persona/logic.js",
|
|
40
|
+
"./user-story": "./src/web-components/user-story/logic.js",
|
|
41
|
+
"./user-journey": "./src/web-components/user-journey/logic.js",
|
|
42
|
+
"./empathy-map": "./src/web-components/empathy-map/logic.js",
|
|
43
|
+
"./impact-effort": "./src/web-components/impact-effort/logic.js",
|
|
44
|
+
"./quadrant-grid": "./src/web-components/quadrant-grid/logic.js",
|
|
45
|
+
"./risk-register": "./src/web-components/risk-register/logic.js",
|
|
46
|
+
"./traceability-matrix": "./src/web-components/traceability-matrix/logic.js",
|
|
47
|
+
"./burndown-chart": "./src/web-components/burndown-chart/logic.js",
|
|
48
|
+
"./product-roadmap": "./src/web-components/product-roadmap/logic.js",
|
|
49
|
+
"./kanban-board": "./src/web-components/kanban-board/logic.js",
|
|
50
|
+
"./story-map": "./src/web-components/story-map/logic.js",
|
|
51
|
+
"./review-surface": "./src/web-components/review-surface/logic.js",
|
|
52
|
+
"./gantt-chart": "./src/web-components/gantt-chart/logic.js",
|
|
53
|
+
"./work-item": "./src/web-components/work-item/logic.js",
|
|
54
|
+
"./adr-wc": "./src/web-components/adr-wc/logic.js",
|
|
55
|
+
"./iron-triangle": "./src/web-components/iron-triangle/logic.js",
|
|
56
|
+
"./capacity-plan": "./src/web-components/capacity-plan/logic.js",
|
|
57
|
+
"./quality-target": "./src/web-components/quality-target/logic.js",
|
|
58
|
+
"./requirement-card": "./src/web-components/requirement-card/logic.js",
|
|
59
|
+
"./package.json": "./package.json"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "npm run build:js && npm run build:css",
|
|
63
|
+
"build:js": "esbuild src/index.js --bundle --format=esm --outfile=dist/vb-project-planning.js && esbuild src/index.js --bundle --format=esm --minify --outfile=dist/vb-project-planning.min.js",
|
|
64
|
+
"build:css": "esbuild src/index.css --bundle --outfile=dist/vb-project-planning.css && esbuild src/index.css --bundle --minify --outfile=dist/vb-project-planning.min.css",
|
|
65
|
+
"prepublishOnly": "npm run build",
|
|
66
|
+
"test:components": "playwright test"
|
|
67
|
+
},
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"vanilla-breeze": ">=0.1.0"
|
|
70
|
+
},
|
|
71
|
+
"peerDependenciesMeta": {
|
|
72
|
+
"vanilla-breeze": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@playwright/test": "^1.61.1",
|
|
78
|
+
"esbuild": "^0.27.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/index.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* @profpowell/vb-project-planning — light-DOM component styles.
|
|
2
|
+
(Shadow-DOM components bundle their own CSS via logic.js.) */
|
|
3
|
+
@import "./web-components/impact-effort/styles.css";
|
|
4
|
+
@import "./web-components/quadrant-grid/styles.css";
|
|
5
|
+
@import "./web-components/risk-register/styles.css";
|
|
6
|
+
@import "./web-components/traceability-matrix/styles.css";
|
|
7
|
+
@import "./web-components/burndown-chart/styles.css";
|
|
8
|
+
@import "./web-components/product-roadmap/styles.css";
|
|
9
|
+
@import "./web-components/kanban-board/styles.css";
|
|
10
|
+
@import "./web-components/story-map/styles.css";
|
|
11
|
+
@import "./web-components/gantt-chart/styles.css";
|
|
12
|
+
@import "./web-components/iron-triangle/styles.css";
|
|
13
|
+
@import "./web-components/capacity-plan/styles.css";
|
|
14
|
+
@import "./web-components/quality-target/styles.css";
|
package/src/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @profpowell/vb-project-planning — barrel.
|
|
3
|
+
* Side-effect imports register every project-planning component.
|
|
4
|
+
* Consumes Vanilla Breeze tokens/themes via CSS custom properties.
|
|
5
|
+
* Drag-to-reorder components require <drag-surface> (Vanilla Breeze peer).
|
|
6
|
+
*/
|
|
7
|
+
import './web-components/user-persona/logic.js';
|
|
8
|
+
import './web-components/user-story/logic.js';
|
|
9
|
+
import './web-components/user-journey/logic.js';
|
|
10
|
+
import './web-components/empathy-map/logic.js';
|
|
11
|
+
import './web-components/impact-effort/logic.js';
|
|
12
|
+
import './web-components/quadrant-grid/logic.js';
|
|
13
|
+
import './web-components/risk-register/logic.js';
|
|
14
|
+
import './web-components/traceability-matrix/logic.js';
|
|
15
|
+
import './web-components/burndown-chart/logic.js';
|
|
16
|
+
import './web-components/product-roadmap/logic.js';
|
|
17
|
+
import './web-components/kanban-board/logic.js';
|
|
18
|
+
import './web-components/story-map/logic.js';
|
|
19
|
+
import './web-components/review-surface/logic.js';
|
|
20
|
+
import './web-components/gantt-chart/logic.js';
|
|
21
|
+
import './web-components/work-item/logic.js';
|
|
22
|
+
import './web-components/adr-wc/logic.js';
|
|
23
|
+
import './web-components/iron-triangle/logic.js';
|
|
24
|
+
import './web-components/capacity-plan/logic.js';
|
|
25
|
+
import './web-components/quality-target/logic.js';
|
|
26
|
+
import './web-components/requirement-card/logic.js';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle Registry — Vanilla Breeze
|
|
3
|
+
*
|
|
4
|
+
* Provides infrastructure for modular bundles to register effects,
|
|
5
|
+
* components, and activate full bundles at runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── Effect registry ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const effects = new Map()
|
|
11
|
+
let observer = null
|
|
12
|
+
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
13
|
+
|
|
14
|
+
function startObserver() {
|
|
15
|
+
if (observer) return
|
|
16
|
+
observer = new MutationObserver(mutations => {
|
|
17
|
+
for (const mutation of mutations) {
|
|
18
|
+
for (const node of mutation.addedNodes) {
|
|
19
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
|
20
|
+
const el = /** @type {Element} */ (node)
|
|
21
|
+
matchEffects(el)
|
|
22
|
+
el.querySelectorAll('*').forEach(matchEffects)
|
|
23
|
+
}
|
|
24
|
+
for (const node of mutation.removedNodes) {
|
|
25
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
|
26
|
+
const el = /** @type {Element} */ (node)
|
|
27
|
+
unmatchEffects(el)
|
|
28
|
+
el.querySelectorAll('*').forEach(unmatchEffects)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function matchEffects(el) {
|
|
36
|
+
for (const [, def] of effects) {
|
|
37
|
+
if (el.matches?.(def.selector)) {
|
|
38
|
+
if (reducedMotionMq.matches && def.reducedMotionFallback) {
|
|
39
|
+
def.reducedMotionFallback(el)
|
|
40
|
+
} else {
|
|
41
|
+
def.init(el)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unmatchEffects(el) {
|
|
48
|
+
for (const [, def] of effects) {
|
|
49
|
+
if (el.matches?.(def.selector) && def.destroy) {
|
|
50
|
+
def.destroy(el)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register a DOM effect that auto-initializes on matching elements.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} name - Unique effect name
|
|
59
|
+
* @param {{ selector: string, init: (el: Element) => void, destroy?: (el: Element) => void, reducedMotionFallback?: (el: Element) => void }} def
|
|
60
|
+
*/
|
|
61
|
+
export function registerEffect(name, def) {
|
|
62
|
+
effects.set(name, def)
|
|
63
|
+
|
|
64
|
+
// Initialize on existing matching elements
|
|
65
|
+
document.querySelectorAll(def.selector).forEach(el => {
|
|
66
|
+
if (reducedMotionMq.matches && def.reducedMotionFallback) {
|
|
67
|
+
def.reducedMotionFallback(el)
|
|
68
|
+
} else {
|
|
69
|
+
def.init(el)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Start shared observer lazily
|
|
74
|
+
if (document.body) {
|
|
75
|
+
startObserver()
|
|
76
|
+
} else {
|
|
77
|
+
document.addEventListener('DOMContentLoaded', startObserver, { once: true })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Component registry ──────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const components = new Map()
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register a custom element with priority-based conflict resolution.
|
|
87
|
+
* First-wins at equal priority. Higher priority wins over lower.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} tag - Custom element tag name
|
|
90
|
+
* @param {typeof HTMLElement} impl - The element class
|
|
91
|
+
* @param {{ bundle?: string, contract?: string, priority?: number }} [opts]
|
|
92
|
+
*/
|
|
93
|
+
export function registerComponent(tag, impl, opts = {}) {
|
|
94
|
+
const priority = opts.priority ?? 10
|
|
95
|
+
const meta = { impl, bundle: opts.bundle, contract: opts.contract, priority }
|
|
96
|
+
|
|
97
|
+
const existing = components.get(tag)
|
|
98
|
+
|
|
99
|
+
if (customElements.get(tag)) {
|
|
100
|
+
// Already defined in the browser — cannot redefine
|
|
101
|
+
if (!existing || existing.priority >= priority) {
|
|
102
|
+
if (existing && existing.priority === priority && existing.impl !== impl) {
|
|
103
|
+
console.warn(
|
|
104
|
+
`[VB Bundle] Tag <${tag}> already registered by "${existing.bundle}" (priority ${existing.priority}). Skipping "${opts.bundle}".`
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
// Higher priority but can't redefine — warn
|
|
110
|
+
console.warn(
|
|
111
|
+
`[VB Bundle] Tag <${tag}> defined by "${existing.bundle}" cannot be replaced (customElements.define is permanent). "${opts.bundle}" has higher priority but arrived late.`
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (existing && existing.priority >= priority) {
|
|
117
|
+
if (existing.priority === priority) {
|
|
118
|
+
console.warn(
|
|
119
|
+
`[VB Bundle] Tag <${tag}> already registered by "${existing.bundle}". Skipping "${opts.bundle}" (first wins at equal priority).`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
components.set(tag, meta)
|
|
126
|
+
customElements.define(tag, impl)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Activate a full pack by loading its CSS and JS from the CDN path.
|
|
131
|
+
* Convention: cdn/packs/{name}.full.css and cdn/packs/{name}.full.js
|
|
132
|
+
*
|
|
133
|
+
* @param {string} bundleName
|
|
134
|
+
* @param {{ basePath?: string }} [opts]
|
|
135
|
+
* @returns {Promise<void>}
|
|
136
|
+
*/
|
|
137
|
+
export async function activateBundle(bundleName, opts = {}) {
|
|
138
|
+
const base = opts.basePath ?? '/cdn/packs'
|
|
139
|
+
|
|
140
|
+
// Load CSS
|
|
141
|
+
const cssHref = `${base}/${bundleName}.full.css`
|
|
142
|
+
if (!document.querySelector(`link[href="${cssHref}"]`)) {
|
|
143
|
+
const link = document.createElement('link')
|
|
144
|
+
link.rel = 'stylesheet'
|
|
145
|
+
link.href = cssHref
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
link.onload = resolve
|
|
148
|
+
link.onerror = () => reject(new Error(`Failed to load bundle CSS: ${cssHref}`))
|
|
149
|
+
document.head.appendChild(link)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Load JS
|
|
154
|
+
const jsSrc = `${base}/${bundleName}.full.js`
|
|
155
|
+
await import(jsSrc)
|
|
156
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed list diff with move-not-recreate semantics.
|
|
3
|
+
*
|
|
4
|
+
* Reconciles a `nodes` map (key → Element) against `newItems` so that
|
|
5
|
+
* each key in `newItems` ends up rendered in its target container at
|
|
6
|
+
* its target index. Existing nodes are moved rather than recreated;
|
|
7
|
+
* only new keys cause renderItem() to fire.
|
|
8
|
+
*
|
|
9
|
+
* Formal guarantee: a node whose key appears in both the current map
|
|
10
|
+
* and `newItems` is never removed. It may be moved (different parent
|
|
11
|
+
* or different position within the same parent), but the underlying
|
|
12
|
+
* DOM node identity is preserved — so in-flight drag state, focus,
|
|
13
|
+
* and CSS animations survive.
|
|
14
|
+
*
|
|
15
|
+
* The DOM operations used are minimal: `Node.insertBefore`, `Element.remove`,
|
|
16
|
+
* and reads of `parentElement` / `children`. Subclasses are responsible for
|
|
17
|
+
* removing non-managed children (placeholders, etc.) before the diff if
|
|
18
|
+
* they want clean indexing.
|
|
19
|
+
*
|
|
20
|
+
* @template T
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {T[]} opts.newItems
|
|
23
|
+
* @param {Map<unknown, Element>} opts.nodes Mutated in place.
|
|
24
|
+
* @param {(item: T) => unknown} opts.keyOf
|
|
25
|
+
* @param {(item: T) => Element} opts.renderItem Called only for new keys.
|
|
26
|
+
* @param {(item: T, existingEl: Element | null) => Element} opts.containerFor
|
|
27
|
+
* @returns {{ added: unknown[], moved: unknown[], removed: unknown[] }}
|
|
28
|
+
*/
|
|
29
|
+
export function diffByKey({ newItems, nodes, keyOf, renderItem, containerFor }) {
|
|
30
|
+
const added = [];
|
|
31
|
+
const moved = [];
|
|
32
|
+
const removed = [];
|
|
33
|
+
|
|
34
|
+
// 1. Compute new key set for membership checks.
|
|
35
|
+
const newKeys = new Set();
|
|
36
|
+
for (const item of newItems) newKeys.add(keyOf(item));
|
|
37
|
+
|
|
38
|
+
// 2. Remove nodes whose keys are gone. (No node with a still-live key is
|
|
39
|
+
// ever removed — that's the formal preservation guarantee.)
|
|
40
|
+
for (const [key, el] of [...nodes]) {
|
|
41
|
+
if (!newKeys.has(key)) {
|
|
42
|
+
el.remove();
|
|
43
|
+
nodes.delete(key);
|
|
44
|
+
removed.push(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Resolve each item's target container. Group entries per container
|
|
49
|
+
// so we can reconcile each container's order in one pass.
|
|
50
|
+
/** @type {Map<Element, Array<{ item: T, key: unknown, existing: Element | null }>>} */
|
|
51
|
+
const byContainer = new Map();
|
|
52
|
+
for (const item of newItems) {
|
|
53
|
+
const key = keyOf(item);
|
|
54
|
+
const existing = nodes.get(key) || null;
|
|
55
|
+
const container = containerFor(item, existing);
|
|
56
|
+
if (!byContainer.has(container)) byContainer.set(container, []);
|
|
57
|
+
byContainer.get(container)?.push({ item, key, existing });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 4. For each container, place each entry at its target index.
|
|
61
|
+
// insertBefore is a no-op when the node is already at that position,
|
|
62
|
+
// and a move (preserving identity) when it isn't.
|
|
63
|
+
for (const [container, entries] of byContainer) {
|
|
64
|
+
for (let i = 0; i < entries.length; i++) {
|
|
65
|
+
const { item, key, existing } = entries[i];
|
|
66
|
+
let el = existing;
|
|
67
|
+
if (!el) {
|
|
68
|
+
el = renderItem(item);
|
|
69
|
+
nodes.set(key, el);
|
|
70
|
+
added.push(key);
|
|
71
|
+
} else if (el.parentElement !== container) {
|
|
72
|
+
moved.push(key);
|
|
73
|
+
} else {
|
|
74
|
+
// Same container: detect intra-container reorder.
|
|
75
|
+
if (container.children[i] !== el) moved.push(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const target = container.children[i];
|
|
79
|
+
if (target !== el) {
|
|
80
|
+
container.insertBefore(el, target || null);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { added, moved, removed };
|
|
86
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portrait URL utility
|
|
3
|
+
*
|
|
4
|
+
* Generates deterministic portrait image URLs from the faker-js
|
|
5
|
+
* person-portrait CDN assets (CC0 licensed, AI-generated).
|
|
6
|
+
*
|
|
7
|
+
* 200 unique portraits (100 male, 100 female) at 5 sizes:
|
|
8
|
+
* 32, 64, 128, 256, 512 px (square).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* portraitUrl('Sarah Chen');
|
|
12
|
+
* // → 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait/female/128/37.jpg'
|
|
13
|
+
*
|
|
14
|
+
* portraitUrl('Marcus Johnson', 256);
|
|
15
|
+
* // → 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait/male/256/82.jpg'
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CDN = 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait';
|
|
19
|
+
const SIZES = [32, 64, 128, 256, 512];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Simple string hash (same algorithm as _ux-base.js hashColor).
|
|
23
|
+
* @param {string} str
|
|
24
|
+
* @returns {number}
|
|
25
|
+
*/
|
|
26
|
+
function hash(str) {
|
|
27
|
+
let h = 0;
|
|
28
|
+
for (let i = 0; i < str.length; i++) {
|
|
29
|
+
h = str.charCodeAt(i) + ((h << 5) - h);
|
|
30
|
+
}
|
|
31
|
+
return h;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Snap a pixel value to the nearest available portrait size.
|
|
36
|
+
* @param {number} px - Desired size in pixels
|
|
37
|
+
* @returns {number} Closest available size (32, 64, 128, 256, or 512)
|
|
38
|
+
*/
|
|
39
|
+
function snapSize(px) {
|
|
40
|
+
let best = SIZES[0];
|
|
41
|
+
let bestDist = Math.abs(px - best);
|
|
42
|
+
for (let i = 1; i < SIZES.length; i++) {
|
|
43
|
+
const dist = Math.abs(px - SIZES[i]);
|
|
44
|
+
if (dist < bestDist) {
|
|
45
|
+
best = SIZES[i];
|
|
46
|
+
bestDist = dist;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return best;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a deterministic portrait URL from a seed string.
|
|
54
|
+
*
|
|
55
|
+
* The same seed always produces the same portrait. Sex and index are
|
|
56
|
+
* derived from the seed hash, so names cluster naturally — different
|
|
57
|
+
* names get different faces.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} seed - Any string (name, ID, index, etc.)
|
|
60
|
+
* @param {number} [size=128] - Desired image size in px (snapped to nearest available)
|
|
61
|
+
* @returns {string} CDN URL for the portrait JPEG
|
|
62
|
+
*/
|
|
63
|
+
export function portraitUrl(seed, size = 128) {
|
|
64
|
+
const h = hash(String(seed));
|
|
65
|
+
const index = ((h % 100) + 100) % 100;
|
|
66
|
+
const sex = ((h >>> 16) & 1) === 0 ? 'female' : 'male';
|
|
67
|
+
const snapped = snapSize(size);
|
|
68
|
+
return `${CDN}/${sex}/${snapped}/${index}.jpg`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get a portrait URL by explicit numeric index (0–199).
|
|
73
|
+
* 0–99 = female, 100–199 = male.
|
|
74
|
+
*
|
|
75
|
+
* @param {number} index - Portrait index (0–199)
|
|
76
|
+
* @param {number} [size=128] - Desired image size in px
|
|
77
|
+
* @returns {string} CDN URL
|
|
78
|
+
*/
|
|
79
|
+
export function portraitByIndex(index, size = 128) {
|
|
80
|
+
const i = ((index % 200) + 200) % 200;
|
|
81
|
+
const sex = i < 100 ? 'female' : 'male';
|
|
82
|
+
const fileIndex = i < 100 ? i : i - 100;
|
|
83
|
+
const snapped = snapSize(size);
|
|
84
|
+
return `${CDN}/${sex}/${snapped}/${fileIndex}.jpg`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { SIZES as PORTRAIT_SIZES, CDN as PORTRAIT_CDN };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VBCollection — Mixin for "keyed list of children" components.
|
|
3
|
+
*
|
|
4
|
+
* Subclass implements:
|
|
5
|
+
* _renderItem(item) → Element Build a fresh element for a new key.
|
|
6
|
+
* _containerFor(item, existingEl) (Optional) the parent each item lives in.
|
|
7
|
+
* _postRender({ added, moved, removed, items }) (Optional) refresh placeholders / counts.
|
|
8
|
+
*
|
|
9
|
+
* Subclass declares (optional):
|
|
10
|
+
* static keyOf = (item) => item.id Diff key. Default: item.id.
|
|
11
|
+
*
|
|
12
|
+
* Provides:
|
|
13
|
+
* .items getter/setter — assignment runs a keyed diff.
|
|
14
|
+
* _emit(name, detail, source) — tags events with source for loop avoidance.
|
|
15
|
+
*
|
|
16
|
+
* Formal guarantee: any element whose key is present in both the previous
|
|
17
|
+
* and the next items list is preserved (never removed and recreated). The
|
|
18
|
+
* same DOM node may be moved across containers or reordered, but its
|
|
19
|
+
* identity — and any in-flight drag, focus, or animation state — survives.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { diffByKey } from './diff-by-key.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @template {new (...args: any[]) => HTMLElement} TBase
|
|
26
|
+
* @param {TBase} Base
|
|
27
|
+
*/
|
|
28
|
+
export function VBCollection(Base) {
|
|
29
|
+
const BaseAny = /** @type {any} */ (Base);
|
|
30
|
+
return class extends BaseAny {
|
|
31
|
+
static keyOf(item) { return item.id; }
|
|
32
|
+
|
|
33
|
+
/** @type {any[]} */
|
|
34
|
+
#items = [];
|
|
35
|
+
/** @type {Map<unknown, Element>} */
|
|
36
|
+
#nodes = new Map();
|
|
37
|
+
|
|
38
|
+
get items() { return this.#items; }
|
|
39
|
+
set items(value) {
|
|
40
|
+
const next = value || [];
|
|
41
|
+
const result = diffByKey({
|
|
42
|
+
newItems: next,
|
|
43
|
+
nodes: this.#nodes,
|
|
44
|
+
keyOf: /** @type {any} */ (this.constructor).keyOf,
|
|
45
|
+
renderItem: (item) => /** @type {any} */ (this._renderItem(item)),
|
|
46
|
+
containerFor: (item, existing) => /** @type {any} */ (this._containerFor(item, existing)),
|
|
47
|
+
});
|
|
48
|
+
this.#items = next;
|
|
49
|
+
this._postRender({ ...result, items: next });
|
|
50
|
+
this._emit('items-changed', { items: next }, 'api');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subclass hook. Build a fresh element for a new item.
|
|
55
|
+
* Must return Element. Strings/templates are not accepted (see VB-XG5X).
|
|
56
|
+
*/
|
|
57
|
+
_renderItem(_item) {
|
|
58
|
+
throw new Error(`${this.constructor.name}: must implement _renderItem(item)`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Subclass hook. Where this item lives. Default: the element itself.
|
|
63
|
+
* Override to route items into specific child containers (e.g. kanban columns).
|
|
64
|
+
*/
|
|
65
|
+
_containerFor(_item, _existingEl) {
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Subclass hook. Called after every diff. Use to refresh placeholders,
|
|
71
|
+
* counts, WIP markers, empty-state UI.
|
|
72
|
+
*/
|
|
73
|
+
_postRender(_diff) {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Dispatch a tagged event so consumers can avoid feedback loops.
|
|
77
|
+
* @param {string} name Event suffix (e.g. 'items-changed').
|
|
78
|
+
* @param {object} detail Custom payload.
|
|
79
|
+
* @param {'api' | 'attribute' | 'drag' | 'internal' | string} [source='api']
|
|
80
|
+
*/
|
|
81
|
+
_emit(name, detail, source = 'api') {
|
|
82
|
+
this.dispatchEvent(new CustomEvent(`${this.localName}:${name}`, {
|
|
83
|
+
detail: { ...detail, source },
|
|
84
|
+
bubbles: true,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read-only access to the rendered node for a given key.
|
|
90
|
+
* Useful for subclass hooks that want to update an item in place.
|
|
91
|
+
*/
|
|
92
|
+
_nodeFor(key) {
|
|
93
|
+
return this.#nodes.get(key) || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Seed the collection from existing DOM (HTML-first upgrade path).
|
|
98
|
+
* Subclass calls this once after parsing initial children so the next
|
|
99
|
+
* `.items = ...` assignment diffs against existing rendered nodes
|
|
100
|
+
* instead of recreating them.
|
|
101
|
+
*
|
|
102
|
+
* @param {any[]} items
|
|
103
|
+
* @param {Map<unknown, Element>} nodeMap key → existing element
|
|
104
|
+
*/
|
|
105
|
+
_seedCollection(items, nodeMap) {
|
|
106
|
+
this.#items = items;
|
|
107
|
+
this.#nodes.clear();
|
|
108
|
+
for (const [key, el] of nodeMap) this.#nodes.set(key, el);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Update the in-memory items array without running a diff (e.g. after
|
|
113
|
+
* a drag mutates the DOM). Subclass is expected to keep the DOM and
|
|
114
|
+
* the items array in sync; this method just records the new model.
|
|
115
|
+
*
|
|
116
|
+
* @param {any[]} items
|
|
117
|
+
*/
|
|
118
|
+
_setItemsSilently(items) {
|
|
119
|
+
this.#items = items;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VBElement — Minimal base class for Vanilla Breeze web components
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - data-upgraded guard (prevents double initialization on reconnect)
|
|
6
|
+
* - listen() method with automatic cleanup on disconnect
|
|
7
|
+
* - setup()/teardown() lifecycle hooks
|
|
8
|
+
*
|
|
9
|
+
* Does NOT provide: props reflection, template rendering, reactivity.
|
|
10
|
+
* Components that need observedAttributes/attributeChangedCallback
|
|
11
|
+
* declare them normally alongside setup()/teardown().
|
|
12
|
+
*/
|
|
13
|
+
class VBElement extends HTMLElement {
|
|
14
|
+
#cleanups = [];
|
|
15
|
+
/** @type {ElementInternals | undefined} */
|
|
16
|
+
#internals;
|
|
17
|
+
|
|
18
|
+
connectedCallback() {
|
|
19
|
+
if (this.hasAttribute('data-upgraded')) return;
|
|
20
|
+
if (this.setup() === false) return;
|
|
21
|
+
this.setAttribute('data-upgraded', '');
|
|
22
|
+
// Dispatch :upgraded in a microtask so any sibling component definitions
|
|
23
|
+
// queued in the same synchronous script (e.g. a pack's registerComponent
|
|
24
|
+
// calls) complete before consumers react. Without this, listeners that
|
|
25
|
+
// immediately assign .items can hit a race where downstream components
|
|
26
|
+
// (work-item, etc.) aren't defined yet and the default renderer falls
|
|
27
|
+
// back to a plain element.
|
|
28
|
+
queueMicrotask(() => {
|
|
29
|
+
this.dispatchEvent(new CustomEvent(`${this.localName}:upgraded`, { bubbles: true }));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
disconnectedCallback() {
|
|
34
|
+
for (const fn of this.#cleanups) fn();
|
|
35
|
+
this.#cleanups = [];
|
|
36
|
+
this.removeAttribute('data-upgraded');
|
|
37
|
+
this.teardown();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Track an event listener for automatic cleanup on disconnect.
|
|
42
|
+
* @param {EventTarget} target
|
|
43
|
+
* @param {string} event
|
|
44
|
+
* @param {EventListenerOrEventListenerObject} handler
|
|
45
|
+
* @param {AddEventListenerOptions} [opts]
|
|
46
|
+
*/
|
|
47
|
+
listen(target, event, handler, opts) {
|
|
48
|
+
target.addEventListener(event, handler, opts);
|
|
49
|
+
this.#cleanups.push(() => target.removeEventListener(event, handler, opts));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Override in subclass. Return false to abort upgrade.
|
|
54
|
+
* @returns {boolean | void}
|
|
55
|
+
*/
|
|
56
|
+
setup() {}
|
|
57
|
+
|
|
58
|
+
/** Override in subclass for cleanup beyond event listeners. */
|
|
59
|
+
teardown() {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Toggle a CustomStateSet entry targetable via the `:state(name)` CSS selector.
|
|
63
|
+
* Use for component-private flags. For author-facing state, keep using
|
|
64
|
+
* data-* / aria-* attributes — see admin/specs/custom-state-set-research.md.
|
|
65
|
+
*
|
|
66
|
+
* Lazily attaches ElementInternals on first call; subclasses that already
|
|
67
|
+
* attached internals (form-associated components) must hand them over via
|
|
68
|
+
* `_adoptInternals(this.attachInternals())` in their constructor to avoid
|
|
69
|
+
* the double-attach throw.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} name
|
|
72
|
+
* @param {boolean} on
|
|
73
|
+
*/
|
|
74
|
+
setState(name, on) {
|
|
75
|
+
if (!this.#internals) this.#internals = this.attachInternals();
|
|
76
|
+
const states = this.#internals.states;
|
|
77
|
+
try {
|
|
78
|
+
if (on) states.add(name); else states.delete(name);
|
|
79
|
+
} catch {
|
|
80
|
+
// Legacy --name syntax for older Chromium/WebKit releases
|
|
81
|
+
const legacy = `--${name}`;
|
|
82
|
+
if (on) states.add(legacy); else states.delete(legacy);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Hand pre-attached ElementInternals to the base class. Form-associated
|
|
88
|
+
* subclasses call this in their constructor right after attachInternals().
|
|
89
|
+
* @param {ElementInternals} internals
|
|
90
|
+
*/
|
|
91
|
+
_adoptInternals(internals) {
|
|
92
|
+
if (!this.#internals) this.#internals = internals;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { VBElement };
|