fix(web): keep sidebar scrollbar inside inset

This commit is contained in:
_Kerman
2026-08-04 14:11:44 +08:00
parent b57e29db59
commit c1ac85c15f
7 changed files with 139 additions and 52 deletions

View File

@@ -7,10 +7,11 @@
mid-slide. */
.root {
--dsh-sidebar-inline-padding: 12px;
display: flex;
flex-direction: column;
height: 100%;
padding: 6px 12px;
padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
@@ -189,14 +190,15 @@
max-width: 0;
}
/* Region seat: always mounted so the foot never moves; the browser inside
handles its own wide/rail content. */
/* Region seat: always mounted so the foot never moves. Its trailing margin
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-right: -12px;
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
overflow: hidden;
}

View File

@@ -0,0 +1,38 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one exact selector, keyed by property.
* @param selector - exact selector text.
* @returns the normalized declarations, or undefined when absent.
*/
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return undefined
}
describe('SidebarRoot.module.css inset', () => {
it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
expect(declarations('.regionArea')?.get('margin-right')).toBe(
'calc(-1 * var(--dsh-sidebar-inline-padding))',
)
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
})
})

View File

@@ -4,12 +4,14 @@
rail state renders only the two 36x36 icon controls. */
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 8px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: 12px;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
@@ -173,7 +175,7 @@
min-height: 0;
display: flex;
flex-direction: column;
margin-right: -12px;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
overflow: hidden;
}
@@ -195,7 +197,7 @@
.fade {
position: absolute;
left: 0;
right: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -211,27 +213,19 @@
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; block children keep
their design heights do not shrink under content overflow. Its right
padding is the sole gap between row backgrounds and the sidebar edge; the
outer seats move their clip boundary out so they add no hidden inset. */
/* List: the only scrolling region. Block children keep their design heights
under content overflow. The stable 8px themed scrollbar and the remaining
padding together equal the shell's right inset, with or without overflow. */
.list {
--dsh-session-list-edge-inset: 12px;
--dsh-session-list-scrollbar-width: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: var(--dsh-session-list-edge-inset);
padding-right: calc(var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width));
padding-bottom: 12px;
scrollbar-gutter: stable;
}
.list::-webkit-scrollbar {
width: var(--dsh-session-list-scrollbar-width);
}
.list > [role='treeitem'] + [role='treeitem'],
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;

View File

@@ -238,7 +238,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
<div className={clsx(css.list, css.flatList)} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div>
)}

View File

@@ -1,7 +1,7 @@
/**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
* on disk: the session list keeps one stable right inset for row hover fills,
* with or without overflow, while outer clip seats add no hidden second inset.
* WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
* row fills share the shell's trailing inset, the stable scrollbar counts
* inside it, and flat, grouped, and search views keep their intended rhythm.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
@@ -10,46 +10,56 @@ import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one class rule, keyed by property with whitespace collapsed.
* Declarations of one selector rule, keyed by property with whitespace collapsed.
* Declaration order and trailing semicolons are normalized away.
* @param className - local class name, without the leading dot.
* @param selector - one exact selector, including a leading dot for local classes.
* @returns the rule's declarations, or undefined when no such rule exists.
*/
function declarations(className: string): Map<string, string> | undefined {
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
if (match === null) return undefined
const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies
// noUncheckedIndexedAccess.
for (const part of (match[2] ?? '').split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return found
return undefined
}
describe('WorkspaceBrowser.module.css list', () => {
const root = declarations('root')
const listArea = declarations('listArea')
const list = declarations('list')
const treeBody = declarations('treeBody')
const root = declarations('.root')
const listArea = declarations('.listArea')
const list = declarations('.list')
it('is the scrolling region', () => {
expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto')
})
it('keeps row backgrounds edge-flush with the scrolling region', () => {
expect(root?.get('padding-right')).toBe('12px')
expect(listArea?.get('margin-right')).toBe('-12px')
expect(treeBody?.get('margin-right')).toBeUndefined()
expect(list?.get('margin-right')).toBeUndefined()
expect(list?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
it('counts the themed scrollbar inside the shell trailing inset', () => {
expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
expect(list?.get('padding-right')).toBe(
'calc(var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width))',
)
expect(declarations('.list::-webkit-scrollbar')).toBeUndefined()
})
it('reserves the scrollbar inside the stable visual inset', () => {
it('reserves the scrollbar whether or not the list overflows', () => {
expect(list!.get('scrollbar-gutter')).toBe('stable')
})
it('keeps 2px between rows and 4px between workspace groups', () => {
expect(declarations('.flatList > * + *')?.get('margin-top')).toBe('2px')
expect(declarations(".searchTree > [role='treeitem'] + [role='treeitem']")?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
})
})