Skip to content

Commit 56fae3e

Browse files
feat: collapsible panels. (#12)
1 parent 212028d commit 56fae3e

11 files changed

Lines changed: 1201 additions & 164 deletions

File tree

docs/next-steps.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ Focused follow-up work for `@knighted/develop`.
2222
5. **In-browser component testing**
2323
- Explore authoring and running component-focused tests in-browser (for example, a Vitest-compatible flow) using CDN-delivered tooling.
2424
- Define a lightweight test UX that supports writing tests, running them on demand, and displaying results in-app.
25+
26+
6. **CDN failure recovery UX**
27+
- Detect transient CDN/module loading failures and surface a clear recovery action in-app.
28+
- Add a user-triggered retry path (for example, Reload page / Force reload) when runtime bootstrap imports fail.
29+
- Consider an optional automatic one-time retry before showing recovery controls, while avoiding infinite reload loops.

eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export default [
2020
sourceType: 'module',
2121
ecmaVersion: 'latest',
2222
},
23+
rules: {
24+
'no-unused-vars': 'error',
25+
},
2326
},
2427
{
2528
...playwrightConfig,

playwright/app.spec.ts

Lines changed: 270 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,50 @@ const setStylesEditorSource = async (page: Page, source: string) => {
2929
await editorContent.fill(source)
3030
}
3131

32+
const getCollapseButton = (page: Page, panelName: 'component' | 'styles' | 'preview') =>
33+
page.locator(`#collapse-${panelName}`)
34+
35+
const getToolsButton = (page: Page, panelName: 'component' | 'styles') =>
36+
page.locator(`#tools-${panelName}`)
37+
38+
const ensurePanelToolsVisible = async (page: Page, panelName: 'component' | 'styles') => {
39+
const button = getToolsButton(page, panelName)
40+
const isPressed = await button.getAttribute('aria-pressed')
41+
if (isPressed !== 'true') {
42+
await button.click()
43+
}
44+
}
45+
46+
const expectCollapseButtonState = async (
47+
page: Page,
48+
panelName: 'component' | 'styles' | 'preview',
49+
{
50+
axis,
51+
direction,
52+
collapsed,
53+
disabled,
54+
}: {
55+
axis: 'vertical' | 'horizontal'
56+
direction: 'left' | 'right' | 'none'
57+
collapsed: boolean
58+
disabled?: boolean
59+
},
60+
) => {
61+
const button = getCollapseButton(page, panelName)
62+
63+
await expect(button).toHaveAttribute('data-collapse-axis', axis)
64+
await expect(button).toHaveAttribute('data-collapse-direction', direction)
65+
await expect(button).toHaveAttribute('data-collapsed', collapsed ? 'true' : 'false')
66+
67+
if (disabled !== undefined) {
68+
if (disabled) {
69+
await expect(button).toBeDisabled()
70+
} else {
71+
await expect(button).toBeEnabled()
72+
}
73+
}
74+
}
75+
3276
test('renders default playground preview', async ({ page }) => {
3377
await waitForInitialRender(page)
3478

@@ -54,9 +98,219 @@ test('supports layout and theme toggles', async ({ page }) => {
5498
)
5599
})
56100

101+
test('side layout keeps preview panel height within editor stack height', async ({
102+
page,
103+
}) => {
104+
await waitForInitialRender(page)
105+
106+
await page.getByLabel('Use side preview layout').click()
107+
await expect(page.locator('.app-grid')).toHaveClass(/app-grid--preview-right/)
108+
109+
const metrics = await page.evaluate(() => {
110+
const stack = document.querySelector('.panels-stack--editors')
111+
const previewPanel = document.getElementById('preview-panel')
112+
const stackHeight = stack?.getBoundingClientRect().height ?? 0
113+
const previewHeight = previewPanel?.getBoundingClientRect().height ?? 0
114+
const previewOverflowY = previewPanel ? getComputedStyle(previewPanel).overflowY : ''
115+
return { stackHeight, previewHeight, previewOverflowY }
116+
})
117+
118+
expect(metrics.stackHeight).toBeGreaterThan(0)
119+
expect(metrics.previewHeight).toBeGreaterThan(0)
120+
expect(metrics.previewHeight).toBeLessThanOrEqual(metrics.stackHeight + 2)
121+
expect(metrics.previewOverflowY).toBe('hidden')
122+
})
123+
124+
test('side layout config keeps preview scrolling inside preview host', async ({
125+
page,
126+
}) => {
127+
await waitForInitialRender(page)
128+
129+
await page.getByLabel('Use side preview layout').click()
130+
131+
const scrollConfig = await page.evaluate(() => {
132+
const previewPanel = document.getElementById('preview-panel')
133+
const previewHost = document.getElementById('preview-host')
134+
if (!previewPanel || !previewHost) {
135+
return null
136+
}
137+
138+
const panelStyles = getComputedStyle(previewPanel)
139+
const styles = getComputedStyle(previewHost)
140+
return {
141+
panelOverflowY: panelStyles.overflowY,
142+
panelOverflowX: panelStyles.overflowX,
143+
overflowY: styles.overflowY,
144+
minHeight: styles.minHeight,
145+
}
146+
})
147+
148+
expect(scrollConfig).not.toBeNull()
149+
expect(scrollConfig?.panelOverflowY).toBe('hidden')
150+
expect(scrollConfig?.panelOverflowX).toBe('hidden')
151+
expect(['auto', 'scroll']).toContain(scrollConfig?.overflowY)
152+
expect(scrollConfig?.minHeight).toBe('0px')
153+
})
154+
155+
test('expanded component and styles can shrink consistently in side layouts', async ({
156+
page,
157+
}) => {
158+
await waitForInitialRender(page)
159+
160+
for (const layoutLabel of ['Use side preview layout', 'Use left preview layout']) {
161+
await page.getByLabel(layoutLabel).click()
162+
163+
const minHeights = await page.evaluate(() => {
164+
const component = document.getElementById('component-panel')
165+
const styles = document.getElementById('styles-panel')
166+
return {
167+
component: component
168+
? Number.parseFloat(getComputedStyle(component).minHeight)
169+
: 0,
170+
styles: styles ? Number.parseFloat(getComputedStyle(styles).minHeight) : 0,
171+
}
172+
})
173+
174+
expect(minHeights.component).toBeGreaterThanOrEqual(0)
175+
expect(minHeights.styles).toBeGreaterThanOrEqual(0)
176+
expect(Math.abs(minHeights.component - minHeights.styles)).toBeLessThanOrEqual(1)
177+
}
178+
})
179+
180+
test('panel collapse axis and direction adapt to active layout', async ({ page }) => {
181+
await waitForInitialRender(page)
182+
await expect(page.locator('.app-grid')).toHaveClass(/app-grid/)
183+
184+
await expectCollapseButtonState(page, 'component', {
185+
axis: 'horizontal',
186+
direction: 'left',
187+
collapsed: false,
188+
})
189+
await expectCollapseButtonState(page, 'styles', {
190+
axis: 'horizontal',
191+
direction: 'right',
192+
collapsed: false,
193+
})
194+
await expectCollapseButtonState(page, 'preview', {
195+
axis: 'vertical',
196+
direction: 'none',
197+
collapsed: false,
198+
})
199+
200+
await page.getByLabel('Use side preview layout').click()
201+
await expectCollapseButtonState(page, 'preview', {
202+
axis: 'horizontal',
203+
direction: 'right',
204+
collapsed: false,
205+
})
206+
await expectCollapseButtonState(page, 'component', {
207+
axis: 'vertical',
208+
direction: 'none',
209+
collapsed: false,
210+
})
211+
212+
await page.getByLabel('Use left preview layout').click()
213+
await expectCollapseButtonState(page, 'preview', {
214+
axis: 'horizontal',
215+
direction: 'left',
216+
collapsed: false,
217+
})
218+
})
219+
220+
test('prevents collapsing all three panels at once', async ({ page }) => {
221+
await waitForInitialRender(page)
222+
223+
await getCollapseButton(page, 'component').click()
224+
await getCollapseButton(page, 'styles').click()
225+
226+
await expect(page.locator('#component-panel')).toHaveClass(
227+
/panel--collapsed-horizontal/,
228+
)
229+
await expect(page.locator('#styles-panel')).toHaveClass(/panel--collapsed-horizontal/)
230+
231+
await expectCollapseButtonState(page, 'preview', {
232+
axis: 'vertical',
233+
direction: 'none',
234+
collapsed: false,
235+
disabled: true,
236+
})
237+
await expect(getCollapseButton(page, 'preview')).toHaveAttribute(
238+
'title',
239+
'At least one panel must remain expanded.',
240+
)
241+
242+
await getCollapseButton(page, 'component').click()
243+
await expectCollapseButtonState(page, 'preview', {
244+
axis: 'vertical',
245+
direction: 'none',
246+
collapsed: false,
247+
disabled: false,
248+
})
249+
})
250+
251+
test('does not persist panel collapse state across reload', async ({ page }) => {
252+
await waitForInitialRender(page)
253+
254+
await getCollapseButton(page, 'component').click()
255+
await expect(page.locator('#component-panel')).toHaveClass(
256+
/panel--collapsed-horizontal/,
257+
)
258+
await expectCollapseButtonState(page, 'component', {
259+
axis: 'horizontal',
260+
direction: 'left',
261+
collapsed: true,
262+
})
263+
264+
await page.reload()
265+
await waitForInitialRender(page)
266+
267+
await expect(page.locator('#component-panel')).not.toHaveClass(
268+
/panel--collapsed-horizontal|panel--collapsed-vertical/,
269+
)
270+
await expectCollapseButtonState(page, 'component', {
271+
axis: 'horizontal',
272+
direction: 'left',
273+
collapsed: false,
274+
})
275+
})
276+
277+
test('gear tools toggles default inactive and switch active/inactive per panel', async ({
278+
page,
279+
}) => {
280+
await waitForInitialRender(page)
281+
282+
const componentPanel = page.locator('#component-panel')
283+
const stylesPanel = page.locator('#styles-panel')
284+
const componentTools = getToolsButton(page, 'component')
285+
const stylesTools = getToolsButton(page, 'styles')
286+
287+
await expect(componentPanel).toHaveClass(/panel--tools-hidden/)
288+
await expect(stylesPanel).toHaveClass(/panel--tools-hidden/)
289+
await expect(componentTools).toHaveAttribute('aria-pressed', 'false')
290+
await expect(stylesTools).toHaveAttribute('aria-pressed', 'false')
291+
292+
await componentTools.click()
293+
await expect(componentPanel).not.toHaveClass(/panel--tools-hidden/)
294+
await expect(componentTools).toHaveAttribute('aria-pressed', 'true')
295+
await expect(componentTools).toHaveAttribute('title', 'Hide component tools')
296+
297+
await componentTools.click()
298+
await expect(componentPanel).toHaveClass(/panel--tools-hidden/)
299+
await expect(componentTools).toHaveAttribute('aria-pressed', 'false')
300+
await expect(componentTools).toHaveAttribute('title', 'Show component tools')
301+
302+
await stylesTools.click()
303+
await expect(stylesPanel).not.toHaveClass(/panel--tools-hidden/)
304+
await expect(stylesTools).toHaveAttribute('aria-pressed', 'true')
305+
await expect(stylesTools).toHaveAttribute('title', 'Hide styles tools')
306+
})
307+
57308
test('renders in react mode with css modules', async ({ page }) => {
58309
await waitForInitialRender(page)
59310

311+
await ensurePanelToolsVisible(page, 'component')
312+
await ensurePanelToolsVisible(page, 'styles')
313+
60314
await page.getByLabel('ShadowRoot (open)').uncheck()
61315
await page.locator('#render-mode').selectOption('react')
62316
await page.locator('#style-mode').selectOption('module')
@@ -117,21 +371,21 @@ test('jsx syntax errors affect status but not diagnostics toggle severity', asyn
117371
test('requires render button when auto render is disabled', async ({ page }) => {
118372
await waitForInitialRender(page)
119373

374+
await ensurePanelToolsVisible(page, 'component')
375+
await ensurePanelToolsVisible(page, 'styles')
376+
120377
const autoRenderToggle = page.getByLabel('Auto render')
121378
const renderButton = page.getByRole('button', { name: 'Render' })
122379
const styleMode = page.locator('#style-mode')
123-
const styleWarning = page.locator('#style-warning')
124380

125-
await expect(styleWarning).toHaveText('')
126381
await autoRenderToggle.uncheck()
127382
await expect(renderButton).toBeVisible()
128383

129384
await styleMode.selectOption('module')
130-
await expect(styleWarning).toHaveText('')
131385

132386
await renderButton.click()
133387
await expect(page.locator('#status')).toHaveText('Rendered')
134-
await expect(styleWarning).toContainText('CSS Modules are compiled in-browser')
388+
await expect(page.locator('#preview-host pre')).toHaveCount(0)
135389
})
136390

137391
test('persists layout and theme across reload', async ({ page }) => {
@@ -152,30 +406,30 @@ test('persists layout and theme across reload', async ({ page }) => {
152406
test('renders with less style mode', async ({ page }) => {
153407
await waitForInitialRender(page)
154408

409+
await ensurePanelToolsVisible(page, 'styles')
410+
155411
await page.getByLabel('ShadowRoot (open)').uncheck()
156412
await page.locator('#style-mode').selectOption('less')
157413
await expect(page.locator('#status')).toHaveText('Rendered')
158-
await expect(page.locator('#style-warning')).toContainText(
159-
'Less is compiled in-browser via @knighted/css/browser.',
160-
)
161414
await expectPreviewHasRenderedContent(page)
162415
})
163416

164417
test('renders with sass style mode', async ({ page }) => {
165418
await waitForInitialRender(page)
166419

420+
await ensurePanelToolsVisible(page, 'styles')
421+
167422
await page.getByLabel('ShadowRoot (open)').uncheck()
168423
await page.locator('#style-mode').selectOption('sass')
169424
await expect(page.locator('#status')).toHaveText('Rendered')
170-
await expect(page.locator('#style-warning')).toContainText(
171-
'Sass is compiled in-browser via @knighted/css/browser.',
172-
)
173425
await expectPreviewHasRenderedContent(page)
174426
})
175427

176428
test('style compilation errors populate styles diagnostics scope', async ({ page }) => {
177429
await waitForInitialRender(page)
178430

431+
await ensurePanelToolsVisible(page, 'styles')
432+
179433
await page.locator('#style-mode').selectOption('sass')
180434
await setStylesEditorSource(page, '.card { color: $missing; }')
181435

@@ -234,6 +488,8 @@ test('clearing styles keeps diagnostics error state but resets status styling',
234488
}) => {
235489
await waitForInitialRender(page)
236490

491+
await ensurePanelToolsVisible(page, 'component')
492+
237493
await setComponentEditorSource(
238494
page,
239495
["const count: number = 'oops'", 'const App = () => <button>ready</button>'].join(
@@ -268,6 +524,8 @@ test('clear component diagnostics removes type errors and restores rendered stat
268524
}) => {
269525
await waitForInitialRender(page)
270526

527+
await ensurePanelToolsVisible(page, 'component')
528+
271529
await setComponentEditorSource(
272530
page,
273531
["const count: number = 'oops'", 'const App = () => <button>ready</button>'].join(
@@ -298,6 +556,8 @@ test('clear component diagnostics removes type errors and restores rendered stat
298556
test('clear all diagnostics removes style compile diagnostics', async ({ page }) => {
299557
await waitForInitialRender(page)
300558

559+
await ensurePanelToolsVisible(page, 'styles')
560+
301561
await page.locator('#style-mode').selectOption('sass')
302562
await setStylesEditorSource(page, '.card { color: $missing; }')
303563

0 commit comments

Comments
 (0)