describe-me 0.6.1 → 0.8.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/index.html +11 -0
- package/package.json +2 -2
- package/src/auto-canvas.ts +70 -0
- package/src/canvas-of.ts +12 -0
- package/src/collapse-props.ts +52 -0
- package/src/filter-active.ts +6 -0
- package/src/inspector.ts +37 -12
- package/src/keyboard.ts +9 -2
- package/src/main.ts +4 -2
- package/src/matches-filter.ts +12 -0
- package/src/matches-query.ts +7 -3
- package/src/overview.ts +3 -2
- package/src/paint-live.ts +61 -6
- package/src/parse-stage-background.ts +11 -0
- package/src/props-table.ts +19 -15
- package/src/regions.ts +39 -7
- package/src/render-all.ts +4 -2
- package/src/render-tree.ts +7 -4
- package/src/replay-snapshot.ts +18 -8
- package/src/show-sidebar-width.ts +19 -0
- package/src/shown-tags.ts +11 -0
- package/src/sidebar-handle.ts +137 -0
- package/src/sidebar-width.ts +79 -0
- package/src/sidebar.ts +39 -12
- package/src/stage.ts +53 -5
- package/src/state-hash.ts +12 -1
- package/src/state.ts +24 -0
- package/src/states-gallery.ts +23 -13
- package/src/style.css +330 -24
- package/src/sync-selection.ts +16 -3
- package/src/tag-buttons.ts +30 -0
- package/src/tag-filter-bar.ts +24 -0
- package/src/theme-switch.ts +84 -0
- package/src/viewport-controls.ts +46 -4
- package/src/visible-tests.ts +7 -6
package/index.html
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>describe-me</title>
|
|
7
|
+
<script>
|
|
8
|
+
// Before the stylesheet and the body, so the first paint has the stored theme. src/theme-switch.ts writes the key.
|
|
9
|
+
try {
|
|
10
|
+
const theme = localStorage.getItem('describe-me:theme')
|
|
11
|
+
if (theme === 'light' || theme === 'dark') {
|
|
12
|
+
document.documentElement.dataset.theme = theme
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
// Without storage the viewer follows the system.
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
7
18
|
<link rel="stylesheet" href="/src/style.css" />
|
|
8
19
|
</head>
|
|
9
20
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "describe-me",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Living component documentation from your Vitest tests: the viewer and CLI of describe-me",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vitest",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"sideEffects": false,
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"rrweb-snapshot": "^2.1.6",
|
|
48
|
-
"@describe-me/core": "^0.
|
|
48
|
+
"@describe-me/core": "^0.8.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
let probe: CanvasRenderingContext2D | null = null
|
|
2
|
+
|
|
3
|
+
function probeContext(): CanvasRenderingContext2D | null {
|
|
4
|
+
if (!probe) {
|
|
5
|
+
const canvas = document.createElement('canvas')
|
|
6
|
+
canvas.width = 1
|
|
7
|
+
canvas.height = 1
|
|
8
|
+
probe = canvas.getContext('2d', { willReadFrequently: true })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return probe
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The sRGB bytes of a CSS color, alpha last. The canvas parses every syntax
|
|
16
|
+
* the browser knows, `oklch()` and `color()` included. A `fillStyle` it
|
|
17
|
+
* rejects keeps the previous one, so an unknown color reads as transparent.
|
|
18
|
+
*/
|
|
19
|
+
function colorBytes(color: string): Uint8ClampedArray | null {
|
|
20
|
+
const context = probeContext()
|
|
21
|
+
if (!context) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
context.clearRect(0, 0, 1, 1)
|
|
26
|
+
context.fillStyle = 'transparent'
|
|
27
|
+
context.fillStyle = color
|
|
28
|
+
context.fillRect(0, 0, 1, 1)
|
|
29
|
+
|
|
30
|
+
return context.getImageData(0, 0, 1, 1).data
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function paintsDark(element: Element | null, view: Window): boolean {
|
|
34
|
+
const bytes = element ? colorBytes(view.getComputedStyle(element).backgroundColor) : null
|
|
35
|
+
if (!bytes) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const [red = 0, green = 0, blue = 0, alpha = 0] = bytes
|
|
40
|
+
const lightness = (299 * red + 587 * green + 114 * blue) / 1000
|
|
41
|
+
|
|
42
|
+
return alpha >= 255 / 2 && lightness < 128
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function declaresDark(root: Element, view: Window): boolean {
|
|
46
|
+
const names = view.getComputedStyle(root).colorScheme.split(' ')
|
|
47
|
+
|
|
48
|
+
return names.includes('dark') && !names.includes('light')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What `auto` means for a replayed frame: dark when its root declares only a
|
|
53
|
+
* dark scheme, or when `html` or `body` paints a dark `background-color`.
|
|
54
|
+
* Call it while the iframe's `color-scheme` is `light`, so the page's own
|
|
55
|
+
* `prefers-color-scheme: dark` rules do not decide, and neither do the viewer
|
|
56
|
+
* or the system. A document it cannot read is light.
|
|
57
|
+
*/
|
|
58
|
+
export function autoCanvas(iframe: HTMLIFrameElement): 'light' | 'dark' {
|
|
59
|
+
const frameDocument = iframe.contentDocument
|
|
60
|
+
const view = frameDocument?.defaultView
|
|
61
|
+
if (!frameDocument || !view) {
|
|
62
|
+
return 'light'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const root = frameDocument.documentElement
|
|
66
|
+
const dark =
|
|
67
|
+
declaresDark(root, view) || paintsDark(root, view) || paintsDark(frameDocument.body, view)
|
|
68
|
+
|
|
69
|
+
return dark ? 'dark' : 'light'
|
|
70
|
+
}
|
package/src/canvas-of.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { state } from './state.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The canvas a frame stands on. A frame's `color-scheme` is `dark` on the dark
|
|
5
|
+
* canvas and `light` on the other two.
|
|
6
|
+
*/
|
|
7
|
+
export type Canvas = 'light' | 'dark' | 'transparent'
|
|
8
|
+
|
|
9
|
+
/** The canvas the background switch picks, with `auto` standing for what `'auto'` resolves to. */
|
|
10
|
+
export function canvasOf(auto: 'light' | 'dark'): Canvas {
|
|
11
|
+
return state.background === 'auto' ? auto : state.background
|
|
12
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { el } from './el.js'
|
|
2
|
+
import { state } from './state.js'
|
|
3
|
+
|
|
4
|
+
const SHOWN_ROWS = 10
|
|
5
|
+
// A short tail shows in full, so the button never says "Show 1 more".
|
|
6
|
+
const MIN_HIDDEN_ROWS = 3
|
|
7
|
+
|
|
8
|
+
function applyFold(rows: HTMLTableRowElement[], button: HTMLButtonElement, key: string): void {
|
|
9
|
+
const expanded = state.expandedProps.has(key)
|
|
10
|
+
rows.forEach((row, index) => {
|
|
11
|
+
row.hidden = !expanded && index >= SHOWN_ROWS
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
button.textContent = expanded ? 'Show fewer' : `Show ${rows.length - SHOWN_ROWS} more`
|
|
15
|
+
button.setAttribute('aria-expanded', expanded ? 'true' : 'false')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Append the prop rows to `table`, folded to the first few when there are many,
|
|
20
|
+
* with a button under them that unfolds them. `key` names the table in
|
|
21
|
+
* `state.expandedProps`, so a rebuilt table opens as it was left.
|
|
22
|
+
*/
|
|
23
|
+
export function collapseProps(
|
|
24
|
+
table: HTMLTableElement,
|
|
25
|
+
rows: HTMLTableRowElement[],
|
|
26
|
+
key: string,
|
|
27
|
+
): HTMLElement[] {
|
|
28
|
+
table.append(...rows)
|
|
29
|
+
if (rows.length - SHOWN_ROWS < MIN_HIDDEN_ROWS) {
|
|
30
|
+
return [table]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const button = el('button', { type: 'button', class: 'props-more', 'aria-expanded': 'false' })
|
|
34
|
+
button.addEventListener('click', () => {
|
|
35
|
+
if (state.expandedProps.has(key)) {
|
|
36
|
+
state.expandedProps.delete(key)
|
|
37
|
+
} else {
|
|
38
|
+
state.expandedProps.add(key)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
applyFold(rows, button, key)
|
|
42
|
+
if (!state.expandedProps.has(key)) {
|
|
43
|
+
// The rows above it go away, which can move the button out of view.
|
|
44
|
+
button.scrollIntoView({ block: 'nearest' })
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Final before insertion, so the overview's scroll restore sees the real height.
|
|
49
|
+
applyFold(rows, button, key)
|
|
50
|
+
|
|
51
|
+
return [table, button]
|
|
52
|
+
}
|
package/src/inspector.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { collapseProps } from './collapse-props.js'
|
|
1
3
|
import { el } from './el.js'
|
|
2
4
|
import { liveSection } from './live-section.js'
|
|
3
5
|
import { liveShown } from './live-shown.js'
|
|
4
6
|
import { currentTest, state } from './state.js'
|
|
7
|
+
import { tagButtons } from './tag-buttons.js'
|
|
5
8
|
import { valueCell } from './value-cell.js'
|
|
6
9
|
|
|
7
10
|
function keyHint(): HTMLElement {
|
|
@@ -25,9 +28,35 @@ function keyHint(): HTMLElement {
|
|
|
25
28
|
return hint
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/** The props the component got, folded when there are many. Frames and Live of a test share the fold. */
|
|
32
|
+
function propsSection(
|
|
33
|
+
test: ManifestTest,
|
|
34
|
+
name: string,
|
|
35
|
+
props: Record<string, unknown>,
|
|
36
|
+
): HTMLElement {
|
|
37
|
+
const section = el('section', {}, el('h3', {}, `component · ${name}`))
|
|
38
|
+
const table = el('table', { class: 'kv' })
|
|
39
|
+
const entries = Object.entries(props)
|
|
40
|
+
|
|
41
|
+
if (!entries.length) {
|
|
42
|
+
table.append(el('tr', {}, el('td', {}, el('span', { class: 'hint' }, 'no props'))))
|
|
43
|
+
section.append(table)
|
|
44
|
+
|
|
45
|
+
return section
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rows = entries.map(([key, value]) =>
|
|
49
|
+
el('tr', {}, el('td', {}, key), el('td', {}, valueCell(value))),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
section.append(...collapseProps(table, rows, `test:${test.id}`))
|
|
53
|
+
|
|
54
|
+
return section
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
/**
|
|
29
|
-
* The inspector's sections for one test: status, component props,
|
|
30
|
-
* current frame or the live section, errors.
|
|
58
|
+
* The inspector's sections for one test: status and tags, component props,
|
|
59
|
+
* the current frame or the live section, errors.
|
|
31
60
|
*/
|
|
32
61
|
export function renderInspector(): HTMLElement[] {
|
|
33
62
|
const test = currentTest()
|
|
@@ -56,19 +85,15 @@ export function renderInspector(): HTMLElement[] {
|
|
|
56
85
|
),
|
|
57
86
|
)
|
|
58
87
|
|
|
88
|
+
const tags = tagButtons(test)
|
|
89
|
+
if (tags) {
|
|
90
|
+
status.append(tags)
|
|
91
|
+
}
|
|
92
|
+
|
|
59
93
|
sections.push(status)
|
|
60
94
|
|
|
61
95
|
if (test.component) {
|
|
62
|
-
|
|
63
|
-
for (const [key, value] of Object.entries(props)) {
|
|
64
|
-
table.append(el('tr', {}, el('td', {}, key), el('td', {}, valueCell(value))))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!Object.keys(props).length) {
|
|
68
|
-
table.append(el('tr', {}, el('td', {}, el('span', { class: 'hint' }, 'no props'))))
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
sections.push(el('section', {}, el('h3', {}, `component · ${test.component.name}`), table))
|
|
96
|
+
sections.push(propsSection(test, test.component.name, props))
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
if (live) {
|
package/src/keyboard.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { filterActive } from './filter-active.js'
|
|
2
3
|
import { liveShown } from './live-shown.js'
|
|
3
4
|
import { regions } from './regions.js'
|
|
4
5
|
import { rerender } from './rerender.js'
|
|
@@ -36,7 +37,8 @@ function nearestShown(shown: ManifestTest[], id: string, step: 1 | -1): Manifest
|
|
|
36
37
|
/**
|
|
37
38
|
* The next or previous test the sidebar shows. From a test hidden in a
|
|
38
39
|
* collapsed suite, that is the nearest shown row in sidebar order. From a test
|
|
39
|
-
* the search
|
|
40
|
+
* the search or the tag filter leaves out, ↓ enters at the first match and ↑
|
|
41
|
+
* at the last.
|
|
40
42
|
*/
|
|
41
43
|
function neighbour(id: string, step: 1 | -1): ManifestTest | undefined {
|
|
42
44
|
const tests = visibleTests()
|
|
@@ -45,7 +47,7 @@ function neighbour(id: string, step: 1 | -1): ManifestTest | undefined {
|
|
|
45
47
|
return tests[index + step]
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
if (
|
|
50
|
+
if (!filterActive()) {
|
|
49
51
|
return nearestShown(tests, id, step)
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -105,6 +107,11 @@ export function onKey(event: KeyboardEvent): void {
|
|
|
105
107
|
return
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
// The handle takes ← → for its width, and no other shortcut fits while it has focus.
|
|
111
|
+
if (event.target === regions().sidebarHandle) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
if (isSearchKey(event)) {
|
|
109
116
|
const { search } = regions()
|
|
110
117
|
search.focus()
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { renderAll } from './render-all.js'
|
|
|
10
10
|
import { renderFrame } from './render-frame.js'
|
|
11
11
|
import { setRerender } from './rerender.js'
|
|
12
12
|
import { wireSearchField } from './search-field.js'
|
|
13
|
+
import { wireSidebarHandle } from './sidebar-handle.js'
|
|
13
14
|
import { stateHash } from './state-hash.js'
|
|
14
15
|
import { readHash, state } from './state.js'
|
|
15
16
|
import { syncSelection } from './sync-selection.js'
|
|
@@ -45,10 +46,11 @@ window.addEventListener('popstate', onLocationChange)
|
|
|
45
46
|
window.addEventListener('hashchange', onLocationChange)
|
|
46
47
|
listenToLive()
|
|
47
48
|
wireSearchField(regions().search)
|
|
49
|
+
wireSidebarHandle(regions().sidebarHandle)
|
|
48
50
|
|
|
49
51
|
loadManifest().catch((err) => {
|
|
50
|
-
const {
|
|
51
|
-
|
|
52
|
+
const { headerInfo, center } = regions()
|
|
53
|
+
headerInfo.replaceChildren(...renderHeader())
|
|
52
54
|
center.replaceChildren(
|
|
53
55
|
el('div', { class: 'empty' }, `no manifest yet — run vitest first (${String(err)})`),
|
|
54
56
|
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { matchesQuery } from './matches-query.js'
|
|
3
|
+
import { state } from './state.js'
|
|
4
|
+
|
|
5
|
+
/** Whether the sidebar shows the test: it matches the search and carries the tag filtered by. */
|
|
6
|
+
export function matchesFilter(test: ManifestTest, moduleId: string): boolean {
|
|
7
|
+
if (state.tag !== null && !(test.tags ?? []).includes(state.tag)) {
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return matchesQuery(test, moduleId, state.query)
|
|
12
|
+
}
|
package/src/matches-query.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ManifestTest } from '@describe-me/core/types'
|
|
2
|
+
import { shownTags } from './shown-tags.js'
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Whether every word of the sidebar's search occurs in the test's full name,
|
|
6
|
+
* file, component or tags.
|
|
7
|
+
*/
|
|
4
8
|
export function matchesQuery(test: ManifestTest, moduleId: string, query: string): boolean {
|
|
5
9
|
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
6
|
-
const fields = [test.fullName, moduleId, test.component?.name ?? ''].map(
|
|
7
|
-
field.toLowerCase(),
|
|
10
|
+
const fields = [test.fullName, moduleId, test.component?.name ?? '', ...shownTags(test)].map(
|
|
11
|
+
(field) => field.toLowerCase(),
|
|
8
12
|
)
|
|
9
13
|
|
|
10
14
|
return terms.every((term) => fields.some((field) => field.includes(term)))
|
package/src/overview.ts
CHANGED
|
@@ -42,11 +42,12 @@ function renderComponent(
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
section.append(el('h3', {}, 'props'))
|
|
45
|
+
const key = `component:${name}`
|
|
45
46
|
if (doc) {
|
|
46
|
-
section.append(renderPropsTable(computeCoverage(doc, tests)))
|
|
47
|
+
section.append(...renderPropsTable(computeCoverage(doc, tests), key))
|
|
47
48
|
} else {
|
|
48
49
|
const hint = 'no type information (rerun tests with the latest reporter)'
|
|
49
|
-
section.append(renderPropsTable([]), el('div', { class: 'hint' }, hint))
|
|
50
|
+
section.append(...renderPropsTable([], key), el('div', { class: 'hint' }, hint))
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
section.append(
|
package/src/paint-live.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { liveSrc } from './live-src.js'
|
|
|
5
5
|
import { resetLiveSession } from './reset-live-session.js'
|
|
6
6
|
import { state } from './state.js'
|
|
7
7
|
|
|
8
|
+
/** The fade of the live frame and of the underlay. Matches the transitions in `style.css`. */
|
|
9
|
+
const FADE_MS = 150
|
|
10
|
+
|
|
8
11
|
/** The live slot on the stage, when it shows `src` and no Restart came since. */
|
|
9
12
|
function keptSlot(frames: HTMLElement, src: string): HTMLElement | null {
|
|
10
13
|
const slot = frames.querySelector<HTMLElement>(':scope > .live-slot')
|
|
@@ -16,7 +19,7 @@ function keptSlot(frames: HTMLElement, src: string): HTMLElement | null {
|
|
|
16
19
|
/** No `sandbox`: the live page runs the component, its scripts and its timers. */
|
|
17
20
|
function buildSlot(test: ManifestTest, src: string): HTMLElement {
|
|
18
21
|
const iframe = el('iframe', { src, title: `${test.fullName}, live` })
|
|
19
|
-
const slot = el('div', { class: 'stage-slot live-slot' }, iframe)
|
|
22
|
+
const slot = el('div', { class: 'stage-slot live-slot loading' }, iframe)
|
|
20
23
|
|
|
21
24
|
// Never `generatedAt` or `frame`, so the stage never takes it for a recorded frame.
|
|
22
25
|
slot.dataset.test = test.id
|
|
@@ -26,10 +29,59 @@ function buildSlot(test: ManifestTest, src: string): HTMLElement {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
32
|
+
* The slot that stays under the live frame of `test` while it loads: the one
|
|
33
|
+
* already there, or, for a new live frame, the recorded frame of the same test
|
|
34
|
+
* turned into one in place. A frame of another test never stays.
|
|
35
|
+
*/
|
|
36
|
+
function pickUnderlay(frames: HTMLElement, test: ManifestTest, built: boolean): HTMLElement | null {
|
|
37
|
+
const underlay = frames.querySelector<HTMLElement>(':scope > .live-underlay')
|
|
38
|
+
|
|
39
|
+
if (underlay?.dataset.test === test.id) {
|
|
40
|
+
return underlay
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const recorded = frames.querySelector<HTMLElement>(
|
|
44
|
+
':scope > .stage-slot[data-frame]:not(.incoming)',
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (!built || recorded?.dataset.test !== test.id) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Without `frame` the stage never takes it for a recorded frame, and `data-auto`
|
|
52
|
+
// keeps its scheme. `inert`, because a dimmed iframe paints above the live one.
|
|
53
|
+
recorded.classList.add('live-underlay')
|
|
54
|
+
recorded.inert = true
|
|
55
|
+
delete recorded.dataset.frame
|
|
56
|
+
delete recorded.dataset.generatedAt
|
|
57
|
+
|
|
58
|
+
return recorded
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* End the loading state once the live page reported a status, whatever it is.
|
|
63
|
+
* Every paint runs it, and a timer rather than `transitionend` removes the
|
|
64
|
+
* underlay, because reduced motion has no transition to end.
|
|
65
|
+
*/
|
|
66
|
+
function settle(slot: HTMLElement, underlay: HTMLElement | null): void {
|
|
67
|
+
const loading = liveSession.status === 'loading'
|
|
68
|
+
|
|
69
|
+
slot.classList.toggle('loading', loading)
|
|
70
|
+
|
|
71
|
+
if (loading || !underlay || underlay.classList.contains('leaving')) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
underlay.classList.add('leaving')
|
|
76
|
+
setTimeout(() => underlay.remove(), FADE_MS)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Put the live slot of `test` on the stage. A slot that already shows the same
|
|
81
|
+
* page stays, so a size change, an overview or a manifest reload keeps the
|
|
82
|
+
* component's state. A new test, a Restart or another base builds a new frame
|
|
83
|
+
* with a fresh session. A new frame loads see-through over the dimmed recorded
|
|
84
|
+
* frame of the same test, until its page reports a status.
|
|
33
85
|
*/
|
|
34
86
|
export function paintLive(stage: HTMLElement, frames: HTMLElement, test: ManifestTest): void {
|
|
35
87
|
const base = state.preview.base
|
|
@@ -41,21 +93,24 @@ export function paintLive(stage: HTMLElement, frames: HTMLElement, test: Manifes
|
|
|
41
93
|
|
|
42
94
|
const kept = keptSlot(frames, src)
|
|
43
95
|
const slot = kept ?? buildSlot(test, src)
|
|
96
|
+
const underlay = pickUnderlay(frames, test, kept === null)
|
|
44
97
|
const previousTest = frames.querySelector<HTMLElement>(':scope > .stage-slot')?.dataset.test
|
|
45
98
|
|
|
46
99
|
// Slots leave only through `remove()`. Moving one elsewhere would reload its iframe blank.
|
|
47
100
|
for (const child of Array.from(frames.children)) {
|
|
48
|
-
if (child !== slot) {
|
|
101
|
+
if (child !== slot && child !== underlay) {
|
|
49
102
|
child.remove()
|
|
50
103
|
}
|
|
51
104
|
}
|
|
52
105
|
|
|
53
106
|
if (kept) {
|
|
107
|
+
settle(slot, underlay)
|
|
54
108
|
return
|
|
55
109
|
}
|
|
56
110
|
|
|
57
111
|
resetLiveSession(test.id)
|
|
58
112
|
frames.append(slot)
|
|
113
|
+
settle(slot, underlay)
|
|
59
114
|
|
|
60
115
|
if (previousTest !== test.id) {
|
|
61
116
|
stage.scrollTo(0, 0)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** What the stage's canvas shows, and the color scheme of the frames on it. */
|
|
2
|
+
export type StageBackground = 'auto' | 'light' | 'dark' | 'transparent'
|
|
3
|
+
|
|
4
|
+
/** The background a link's `bg` names. Anything else, a missing value included, is `'auto'`. */
|
|
5
|
+
export function parseStageBackground(text: string | null): StageBackground {
|
|
6
|
+
if (text === 'light' || text === 'dark' || text === 'transparent') {
|
|
7
|
+
return text
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return 'auto'
|
|
11
|
+
}
|
package/src/props-table.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { collapseProps } from './collapse-props.js'
|
|
1
2
|
import type { CoverageEntry, PropCoverage } from './coverage.js'
|
|
2
3
|
import { el } from './el.js'
|
|
3
4
|
|
|
@@ -45,28 +46,31 @@ function coveredCell(coverage: PropCoverage): HTMLElement {
|
|
|
45
46
|
return cell
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The props of one component as a table: name, type, and which values the tests
|
|
51
|
+
* covered. A long table folds, and `key` keeps it unfolded across repaints.
|
|
52
|
+
*/
|
|
53
|
+
export function renderPropsTable(coverage: PropCoverage[], key: string): HTMLElement[] {
|
|
50
54
|
const table = el('table', { class: 'kv props' })
|
|
51
55
|
table.append(el('tr', {}, el('th', {}, 'prop'), el('th', {}, 'type'), el('th', {}, 'covered')))
|
|
52
56
|
|
|
53
|
-
for (const item of coverage) {
|
|
54
|
-
table.append(
|
|
55
|
-
el(
|
|
56
|
-
'tr',
|
|
57
|
-
{},
|
|
58
|
-
nameCell(item),
|
|
59
|
-
el('td', { class: 'prop-type' }, item.prop.type),
|
|
60
|
-
coveredCell(item),
|
|
61
|
-
),
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
57
|
if (!coverage.length) {
|
|
66
58
|
table.append(
|
|
67
59
|
el('tr', {}, el('td', { colspan: '3' }, el('span', { class: 'hint' }, 'no props'))),
|
|
68
60
|
)
|
|
61
|
+
|
|
62
|
+
return [table]
|
|
69
63
|
}
|
|
70
64
|
|
|
71
|
-
|
|
65
|
+
const rows = coverage.map((item) =>
|
|
66
|
+
el(
|
|
67
|
+
'tr',
|
|
68
|
+
{},
|
|
69
|
+
nameCell(item),
|
|
70
|
+
el('td', { class: 'prop-type' }, item.prop.type),
|
|
71
|
+
coveredCell(item),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return collapseProps(table, rows, key)
|
|
72
76
|
}
|
package/src/regions.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { el } from './el.js'
|
|
2
|
+
import { SidebarWidth } from './sidebar-width.js'
|
|
3
|
+
import { renderThemeSwitch } from './theme-switch.js'
|
|
2
4
|
|
|
3
5
|
export interface Regions {
|
|
4
|
-
header
|
|
6
|
+
/** The wordmark, counts, issues and run time in the header, which repaints fill. */
|
|
7
|
+
headerInfo: HTMLElement
|
|
5
8
|
/** The version warning under the header, hidden unless the data comes from another release line. */
|
|
6
9
|
banner: HTMLElement
|
|
7
10
|
sidebar: HTMLElement
|
|
8
11
|
/** The sidebar's search field, above the tree. */
|
|
9
12
|
search: HTMLInputElement
|
|
13
|
+
/** The tag the tree is narrowed to, between the search and the tree. Hidden without one. */
|
|
14
|
+
tagFilter: HTMLElement
|
|
10
15
|
/** The sidebar's module blocks go here. */
|
|
11
16
|
tree: HTMLElement
|
|
17
|
+
/** The separator on the sidebar's right edge that sets its width. */
|
|
18
|
+
sidebarHandle: HTMLElement
|
|
12
19
|
center: HTMLElement
|
|
13
20
|
inspector: HTMLElement
|
|
21
|
+
/** The grid of sidebar, handle, center and inspector, which holds `--sidebar-width`. */
|
|
22
|
+
columns: HTMLElement
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
let built: Regions | null = null
|
|
@@ -24,7 +33,8 @@ export function regions(): Regions {
|
|
|
24
33
|
return built
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
const
|
|
36
|
+
const headerInfo = el('div', { class: 'header-info' })
|
|
37
|
+
const header = el('header', { class: 'header' }, headerInfo, renderThemeSwitch())
|
|
28
38
|
const banner = el('div', { class: 'version-banner', role: 'status', hidden: true })
|
|
29
39
|
const search = el('input', {
|
|
30
40
|
class: 'sidebar-search',
|
|
@@ -33,16 +43,38 @@ export function regions(): Regions {
|
|
|
33
43
|
'aria-label': 'Search tests',
|
|
34
44
|
})
|
|
35
45
|
|
|
46
|
+
const tagFilter = el('div', { class: 'tag-filter', role: 'status', hidden: true })
|
|
36
47
|
const tree = el('div', { class: 'tree' })
|
|
37
|
-
const sidebar = el('aside', { class: 'sidebar' }, search, tree)
|
|
48
|
+
const sidebar = el('aside', { class: 'sidebar' }, search, tagFilter, tree)
|
|
49
|
+
// Outside the sidebar, which scrolls, so the handle stays put while the tree moves.
|
|
50
|
+
const sidebarHandle = el('div', {
|
|
51
|
+
class: 'sidebar-handle',
|
|
52
|
+
role: 'separator',
|
|
53
|
+
'aria-orientation': 'vertical',
|
|
54
|
+
'aria-label': 'Sidebar width',
|
|
55
|
+
'aria-valuemin': String(SidebarWidth.MIN),
|
|
56
|
+
tabindex: '0',
|
|
57
|
+
title: 'Drag to resize. Double-click to reset.',
|
|
58
|
+
})
|
|
59
|
+
|
|
38
60
|
const center = el('div', { class: 'center' })
|
|
39
61
|
const inspector = el('aside', { class: 'inspector' })
|
|
62
|
+
const columns = el('div', { class: 'body' }, sidebar, sidebarHandle, center, inspector)
|
|
40
63
|
|
|
41
|
-
document
|
|
42
|
-
.getElementById('app')!
|
|
43
|
-
.replaceChildren(header, banner, el('div', { class: 'body' }, sidebar, center, inspector))
|
|
64
|
+
document.getElementById('app')!.replaceChildren(header, banner, columns)
|
|
44
65
|
|
|
45
|
-
built = {
|
|
66
|
+
built = {
|
|
67
|
+
headerInfo,
|
|
68
|
+
banner,
|
|
69
|
+
sidebar,
|
|
70
|
+
search,
|
|
71
|
+
tagFilter,
|
|
72
|
+
tree,
|
|
73
|
+
sidebarHandle,
|
|
74
|
+
center,
|
|
75
|
+
inspector,
|
|
76
|
+
columns,
|
|
77
|
+
}
|
|
46
78
|
|
|
47
79
|
return built
|
|
48
80
|
}
|
package/src/render-all.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { renderOverviewInspector } from './overview-inspector.js'
|
|
|
5
5
|
import { regions } from './regions.js'
|
|
6
6
|
import { renderFrame } from './render-frame.js'
|
|
7
7
|
import { renderTree } from './render-tree.js'
|
|
8
|
+
import { showSidebarWidth } from './show-sidebar-width.js'
|
|
8
9
|
import { state } from './state.js'
|
|
9
10
|
import { testView } from './test-view.js'
|
|
10
11
|
import { versionBanner } from './version-banner.js'
|
|
@@ -53,13 +54,14 @@ function showTest(center: HTMLElement): void {
|
|
|
53
54
|
|
|
54
55
|
/** Repaint every region from `state`: the overview when a suite is selected, else one test. */
|
|
55
56
|
export function renderAll(): void {
|
|
56
|
-
const {
|
|
57
|
-
|
|
57
|
+
const { headerInfo, banner, center, inspector } = regions()
|
|
58
|
+
headerInfo.replaceChildren(...renderHeader())
|
|
58
59
|
|
|
59
60
|
const text = versionBanner(state.manifest)
|
|
60
61
|
banner.textContent = text ?? ''
|
|
61
62
|
banner.hidden = text === null
|
|
62
63
|
|
|
64
|
+
showSidebarWidth()
|
|
63
65
|
renderTree()
|
|
64
66
|
|
|
65
67
|
if (state.suiteKey) {
|