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 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.6.1",
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.6.1"
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
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { state } from './state.js'
2
+
3
+ /** A search or a tag narrows the sidebar. Collapsed suites then open, so every match shows. */
4
+ export function filterActive(): boolean {
5
+ return state.query.trim() !== '' || state.tag !== null
6
+ }
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, the
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
- const table = el('table', { class: 'kv' })
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 does not match, ↓ enters at the first match and ↑ at the last.
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 (state.query.trim() === '') {
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 { header, center } = regions()
51
- header.replaceChildren(...renderHeader())
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
+ }
@@ -1,10 +1,14 @@
1
1
  import type { ManifestTest } from '@describe-me/core/types'
2
+ import { shownTags } from './shown-tags.js'
2
3
 
3
- /** Whether every word of the sidebar's search occurs in the test's full name, file or component. */
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((field) =>
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
- * Put the live slot of `test` on the stage, alone. A slot that already shows
30
- * the same page stays, so a size change, an overview or a manifest reload
31
- * keeps the component's state. A new test, a Restart or another base builds
32
- * a new frame with a fresh session.
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
+ }
@@ -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
- /** The props of one component as a table: name, type, and which values the tests covered. */
49
- export function renderPropsTable(coverage: PropCoverage[]): HTMLElement {
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
- return table
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: HTMLElement
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 header = el('header', { class: 'header' })
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 = { header, banner, sidebar, search, tree, center, inspector }
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 { header, banner, center, inspector } = regions()
57
- header.replaceChildren(...renderHeader())
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) {