describe-me 0.7.0 → 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/inspector.ts +29 -10
- package/src/keyboard.ts +5 -0
- package/src/main.ts +4 -2
- 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 +35 -6
- package/src/render-all.ts +4 -2
- package/src/replay-snapshot.ts +18 -8
- package/src/show-sidebar-width.ts +19 -0
- package/src/sidebar-handle.ts +137 -0
- package/src/sidebar-width.ts +79 -0
- package/src/stage.ts +53 -5
- package/src/state-hash.ts +5 -1
- package/src/state.ts +13 -0
- package/src/states-gallery.ts +23 -13
- package/src/style.css +273 -24
- package/src/theme-switch.ts +84 -0
- package/src/viewport-controls.ts +46 -4
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,3 +1,5 @@
|
|
|
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'
|
|
@@ -26,6 +28,32 @@ function keyHint(): HTMLElement {
|
|
|
26
28
|
return hint
|
|
27
29
|
}
|
|
28
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
|
+
|
|
29
57
|
/**
|
|
30
58
|
* The inspector's sections for one test: status and tags, component props,
|
|
31
59
|
* the current frame or the live section, errors.
|
|
@@ -65,16 +93,7 @@ export function renderInspector(): HTMLElement[] {
|
|
|
65
93
|
sections.push(status)
|
|
66
94
|
|
|
67
95
|
if (test.component) {
|
|
68
|
-
|
|
69
|
-
for (const [key, value] of Object.entries(props)) {
|
|
70
|
-
table.append(el('tr', {}, el('td', {}, key), el('td', {}, valueCell(value))))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (!Object.keys(props).length) {
|
|
74
|
-
table.append(el('tr', {}, el('td', {}, el('span', { class: 'hint' }, 'no props'))))
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
sections.push(el('section', {}, el('h3', {}, `component · ${test.component.name}`), table))
|
|
96
|
+
sections.push(propsSection(test, test.component.name, props))
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
if (live) {
|
package/src/keyboard.ts
CHANGED
|
@@ -107,6 +107,11 @@ export function onKey(event: KeyboardEvent): void {
|
|
|
107
107
|
return
|
|
108
108
|
}
|
|
109
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
|
+
|
|
110
115
|
if (isSearchKey(event)) {
|
|
111
116
|
const { search } = regions()
|
|
112
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
|
)
|
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,7 +1,10 @@
|
|
|
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
|
|
@@ -11,8 +14,12 @@ export interface Regions {
|
|
|
11
14
|
tagFilter: HTMLElement
|
|
12
15
|
/** The sidebar's module blocks go here. */
|
|
13
16
|
tree: HTMLElement
|
|
17
|
+
/** The separator on the sidebar's right edge that sets its width. */
|
|
18
|
+
sidebarHandle: HTMLElement
|
|
14
19
|
center: HTMLElement
|
|
15
20
|
inspector: HTMLElement
|
|
21
|
+
/** The grid of sidebar, handle, center and inspector, which holds `--sidebar-width`. */
|
|
22
|
+
columns: HTMLElement
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
let built: Regions | null = null
|
|
@@ -26,7 +33,8 @@ export function regions(): Regions {
|
|
|
26
33
|
return built
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
const
|
|
36
|
+
const headerInfo = el('div', { class: 'header-info' })
|
|
37
|
+
const header = el('header', { class: 'header' }, headerInfo, renderThemeSwitch())
|
|
30
38
|
const banner = el('div', { class: 'version-banner', role: 'status', hidden: true })
|
|
31
39
|
const search = el('input', {
|
|
32
40
|
class: 'sidebar-search',
|
|
@@ -38,14 +46,35 @@ export function regions(): Regions {
|
|
|
38
46
|
const tagFilter = el('div', { class: 'tag-filter', role: 'status', hidden: true })
|
|
39
47
|
const tree = el('div', { class: 'tree' })
|
|
40
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
|
+
|
|
41
60
|
const center = el('div', { class: 'center' })
|
|
42
61
|
const inspector = el('aside', { class: 'inspector' })
|
|
62
|
+
const columns = el('div', { class: 'body' }, sidebar, sidebarHandle, center, inspector)
|
|
43
63
|
|
|
44
|
-
document
|
|
45
|
-
.getElementById('app')!
|
|
46
|
-
.replaceChildren(header, banner, el('div', { class: 'body' }, sidebar, center, inspector))
|
|
64
|
+
document.getElementById('app')!.replaceChildren(header, banner, columns)
|
|
47
65
|
|
|
48
|
-
built = {
|
|
66
|
+
built = {
|
|
67
|
+
headerInfo,
|
|
68
|
+
banner,
|
|
69
|
+
sidebar,
|
|
70
|
+
search,
|
|
71
|
+
tagFilter,
|
|
72
|
+
tree,
|
|
73
|
+
sidebarHandle,
|
|
74
|
+
center,
|
|
75
|
+
inspector,
|
|
76
|
+
columns,
|
|
77
|
+
}
|
|
49
78
|
|
|
50
79
|
return built
|
|
51
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) {
|
package/src/replay-snapshot.ts
CHANGED
|
@@ -4,29 +4,39 @@ import { replayCache } from './replay-cache.js'
|
|
|
4
4
|
import type { SerializedNode } from './serialized-node.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* No specificity and first in `<head>`, so any `html` or `:root` rule of the
|
|
8
|
+
* page wins and a page that declares its own scheme keeps it.
|
|
9
|
+
*/
|
|
10
|
+
const SCHEME_RULE = '<style>:where(:root) { color-scheme: light dark }</style>'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rebuild a snapshot into a fresh sandboxed iframe inside `root`, with a light
|
|
14
|
+
* `color-scheme` on the iframe. Its `<head>` starts with a rule that gives the
|
|
15
|
+
* document the iframe's scheme, then the preview head.
|
|
9
16
|
*/
|
|
10
17
|
export function replaySnapshot(
|
|
11
18
|
root: HTMLElement,
|
|
12
19
|
node: SerializedNode,
|
|
13
20
|
title: string,
|
|
14
21
|
): HTMLIFrameElement {
|
|
22
|
+
// rrweb sets these before it appends the iframe, so the frame never lays out
|
|
23
|
+
// in the viewer's scheme.
|
|
15
24
|
const { iframe } = rebuildIntoSandboxedIframe(node, {
|
|
16
25
|
root,
|
|
17
|
-
iframeAttributes: { title },
|
|
26
|
+
iframeAttributes: { title, style: 'color-scheme: light' },
|
|
18
27
|
cache: replayCache(),
|
|
19
28
|
mirror: createMirror(),
|
|
20
29
|
hackCss: true,
|
|
21
30
|
})
|
|
22
31
|
|
|
23
|
-
const html = previewHeadHtml()
|
|
24
32
|
const head = iframe.contentDocument?.head
|
|
25
33
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
// The rule gives the document the iframe's scheme. The two match, so Chromium
|
|
35
|
+
// paints no opaque backdrop under the page. packages/vitest/src/live-page-html.ts
|
|
36
|
+
// has a copy. The preview head comes next, so the captured styles win ties,
|
|
37
|
+
// as in Storybook. The sandbox (`allow-same-origin` only) never runs its scripts.
|
|
38
|
+
if (head) {
|
|
39
|
+
head.insertAdjacentHTML('afterbegin', `${SCHEME_RULE}${previewHeadHtml()}`)
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
return iframe
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { regions } from './regions.js'
|
|
2
|
+
import { SidebarWidth } from './sidebar-width.js'
|
|
3
|
+
import { state } from './state.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lay the sidebar out at the project's width, clamped to the window, and
|
|
7
|
+
* return it. Cheap and idempotent, so every repaint and every drag frame can call it.
|
|
8
|
+
*/
|
|
9
|
+
export function showSidebarWidth(): number {
|
|
10
|
+
const { columns, sidebarHandle } = regions()
|
|
11
|
+
const chosen = state.manifest ? SidebarWidth.for(state.manifest.root).get() : SidebarWidth.DEFAULT
|
|
12
|
+
const width = SidebarWidth.clamp(chosen)
|
|
13
|
+
|
|
14
|
+
columns.style.setProperty('--sidebar-width', `${width}px`)
|
|
15
|
+
sidebarHandle.setAttribute('aria-valuenow', String(width))
|
|
16
|
+
sidebarHandle.setAttribute('aria-valuemax', String(SidebarWidth.max()))
|
|
17
|
+
|
|
18
|
+
return width
|
|
19
|
+
}
|