@vobs/layout 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vobs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @vobs/layout
2
+
3
+ App-shell primitives for vobs applications: a `KitLayout` shell with header, sidebar, menu, breadcrumb, and tabs slots, responsive mobile behavior, and a shared layout context.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @vobs/layout
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createComponent, createDOMRenderer, createVobs, setRenderer } from '@vobs/vobs'
15
+ import { KitLayout } from '@vobs/layout'
16
+
17
+ setRenderer(createDOMRenderer())
18
+
19
+ const app = createVobs({
20
+ render: () => createComponent(KitLayout, {
21
+ title: 'Workspace',
22
+ menu: [{ key: '/home', label: 'Home' }],
23
+ children: 'Content'
24
+ })
25
+ })
26
+
27
+ app.mount(document.getElementById('app')!)
28
+ ```
29
+
30
+ The shell renders the default header (with a sidebar toggle), sidebar, menu, and content slot. The sidebar starts collapsed unless `sidebarExpanded` is set; below `mobileBreakpoint` (default 768) a resize listener switches the root to the mobile variant, and the listener is removed on destroy.
31
+
32
+ ## API
33
+
34
+ | Signature | Description |
35
+ | --- | --- |
36
+ | `KitLayout(props: KitLayoutProps)` | Shell with `header`, `sidebar`, `menu`, `breadcrumb`, `tabs`, `footer`, and `mobileBackdrop` slots; passing `null` to a slot drops the default region. Sidebar state is uncontrolled by default or controlled via `sidebarCollapsed` + `onSidebarCollapsedChange`; `mobileOpen`/`onMobileOpenChange` control the mobile drawer. |
37
+ | `KitHeader(props: KitHeaderProps)` | Header bar with `logo`, `title`, sidebar toggle, `userMenu`, and `headerActions`. |
38
+ | `KitSidebar(props: KitSidebarProps)` | Collapsible sidebar rendering menu `items` with `activeKey`, `variant`, mobile `closeLabel`, and `footer`. |
39
+ | `KitMenu(props: KitMenuProps)` | Menu tree from `KitMenuItem` entries (`icon`, `badge`, `href`, `permission`, nested `children`), with `collapsed` and `activeKey`. |
40
+ | `KitBreadcrumb(props: KitBreadcrumbProps)` | Breadcrumb from `KitBreadcrumbItem` entries with `separator` and `onNavigate`. |
41
+ | `KitTabs(props: KitTabsProps)` | Tab strip from `KitTabItem` entries with `value`/`activeKey`, `closable`, and `onSelect`/`onChange`/`onClose`. |
42
+ | `useKitLayout(): KitLayoutContext` | Returns the surrounding layout context (throws outside `KitLayout`): `sidebarCollapsed`, `mobileOpen`, `isMobile` signals plus `toggleSidebar()`, `setSidebarCollapsed()`, `setMobileOpen()`. |
43
+ | `createKitViewport(breakpoint?)` | Standalone `KitViewport` with `width`/`height`/`isMobile` signals and `dispose()`; stops listening after disposal. |
44
+ | `normalizeBreakpoint(value)` | Validates and rounds a breakpoint; throws on non-finite or non-positive values. |
45
+ | `DEFAULT_MOBILE_BREAKPOINT` | `768`. |
46
+
47
+ ## Types
48
+
49
+ LayoutAttributeValue, LayoutChild, LayoutChildren, LayoutCommonProps, KitMenuItem, KitSidebarVariant, KitLayoutProps, KitHeaderProps, KitSidebarProps, KitMenuProps, KitBreadcrumbItem, KitBreadcrumbProps, KitTabItem, KitTabsProps, KitReadonlySignal, KitLayoutContext, KitViewport
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "license": "MIT",
3
+ "files": [
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
7
+ ],
8
+ "name": "@vobs/layout",
9
+ "version": "1.0.0",
10
+ "description": "Optional Kit layout primitives for Vobs.",
11
+ "type": "module",
12
+ "main": "src/index.ts",
13
+ "types": "src/index.ts",
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./layout": "./src/layout.ts",
17
+ "./header": "./src/header.ts",
18
+ "./sidebar": "./src/sidebar.ts",
19
+ "./menu": "./src/menu.ts",
20
+ "./breadcrumb": "./src/breadcrumb.ts",
21
+ "./tabs": "./src/tabs.ts",
22
+ "./context": "./src/context.ts",
23
+ "./viewport": "./src/viewport.ts",
24
+ "./layout.css": "./src/styles/layout.css",
25
+ "./styles.css": "./src/styles/styles.css",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "sideEffects": [
29
+ "./src/styles/*.css"
30
+ ],
31
+ "dependencies": {
32
+ "@vobs/reactivity": "1.0.0",
33
+ "@vobs/vobs": "1.0.0"
34
+ },
35
+ "scripts": {
36
+ "test": "vitest --environment jsdom"
37
+ }
38
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ addEventListener,
3
+ createElement,
4
+ createFragment,
5
+ createText,
6
+ insertBefore,
7
+ insertDynamic,
8
+ setAttribute,
9
+ setProperty,
10
+ type VobsNode
11
+ } from '@vobs/vobs'
12
+ import {
13
+ bindClassList,
14
+ bindCommonAttributes,
15
+ hasProp,
16
+ mountSlot,
17
+ readProp,
18
+ setOptionalAttribute
19
+ } from './utils'
20
+ import type { KitBreadcrumbItem, KitBreadcrumbProps } from './types'
21
+
22
+ export function KitBreadcrumb(props: KitBreadcrumbProps): VobsNode {
23
+ const root = createElement('nav')
24
+ bindClassList(root, props, () => ['vobs-kit-breadcrumb'])
25
+ bindCommonAttributes(root, props, ['items', 'separator', 'onNavigate'])
26
+ if (!hasProp(props, 'aria-label')) setAttribute(root, 'aria-label', 'Breadcrumb')
27
+
28
+ const list = createElement('ol')
29
+ setAttribute(list, 'class', 'vobs-kit-breadcrumb__list')
30
+ insertDynamic(list, null, () => createBreadcrumbItems(props))
31
+ insertBefore(root, list, null)
32
+ if (hasProp(props, 'children')) mountSlot(root, props, 'children')
33
+ return root
34
+ }
35
+
36
+ function createBreadcrumbItems(props: KitBreadcrumbProps): VobsNode {
37
+ const items = readProp<readonly KitBreadcrumbItem[]>(props, 'items', [])
38
+ return createFragment((parent, anchor) => {
39
+ for (let index = 0; index < items.length; index++) {
40
+ const item = items[index]
41
+ const entry = createElement('li')
42
+ const isLast = index === items.length - 1
43
+ setAttribute(entry, 'class', `vobs-kit-breadcrumb__item${isLast ? ' is-current' : ''}`)
44
+ if (item.key !== undefined) setOptionalAttribute(entry, 'data-breadcrumb-key', item.key)
45
+
46
+ if (index > 0) {
47
+ const separator = createElement('span')
48
+ setAttribute(separator, 'class', 'vobs-kit-breadcrumb__separator')
49
+ setAttribute(separator, 'aria-hidden', 'true')
50
+ if (hasProp(props, 'separator')) mountSlot(separator, props, 'separator')
51
+ else insertBefore(separator, createText('/'), null)
52
+ insertBefore(entry, separator, null)
53
+ }
54
+
55
+ const content = createBreadcrumbContent(item, props, index, isLast)
56
+ insertBefore(entry, content, null)
57
+ insertBefore(parent, entry, anchor)
58
+ }
59
+ })
60
+ }
61
+
62
+ function createBreadcrumbContent(
63
+ item: KitBreadcrumbItem,
64
+ props: KitBreadcrumbProps,
65
+ index: number,
66
+ isLast: boolean
67
+ ): VobsNode {
68
+ const isLink = item.href !== undefined && !isLast
69
+ const element = isLink ? createElement('a') : createElement('span')
70
+ setAttribute(element, 'class', 'vobs-kit-breadcrumb__label')
71
+ insertBefore(element, createText(item.label), null)
72
+
73
+ if (isLink) {
74
+ setAttribute(element, 'href', item.href!)
75
+ addEventListener(element, 'click', event => {
76
+ if (item.disabled === true) {
77
+ event.preventDefault?.()
78
+ return
79
+ }
80
+ const handler = readProp<unknown>(props, 'onNavigate', undefined)
81
+ if (typeof handler === 'function') {
82
+ (handler as KitBreadcrumbProps['onNavigate'])!(item, index)
83
+ }
84
+ })
85
+ }
86
+ if (item.disabled === true) {
87
+ setAttribute(element, 'aria-disabled', 'true')
88
+ setProperty(element, 'tabIndex', -1)
89
+ }
90
+ if (isLast) setAttribute(element, 'aria-current', 'page')
91
+ return element
92
+ }
package/src/context.ts ADDED
@@ -0,0 +1,17 @@
1
+ import {
2
+ createInjectionKey,
3
+ inject,
4
+ type InjectionKey
5
+ } from '@vobs/vobs'
6
+ import type { KitLayoutContext } from './types'
7
+
8
+ export const KIT_LAYOUT_KEY: InjectionKey<KitLayoutContext> =
9
+ createInjectionKey<KitLayoutContext>('vobs.kit.layout')
10
+
11
+ export function useKitLayout(): KitLayoutContext {
12
+ const context = inject(KIT_LAYOUT_KEY)
13
+ if (!context) {
14
+ throw new Error('Vobs Layout: 找不到 KitLayout 上下文')
15
+ }
16
+ return context
17
+ }
package/src/header.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { effect } from '@vobs/reactivity'
2
+ import {
3
+ addEventListener,
4
+ createElement,
5
+ createText,
6
+ insertBefore,
7
+ setAttribute,
8
+ type VobsNode
9
+ } from '@vobs/vobs'
10
+ import {
11
+ bindClassList,
12
+ bindCommonAttributes,
13
+ bindTextContent,
14
+ bindUserStyle,
15
+ hasProp,
16
+ mountSlot,
17
+ readProp,
18
+ setOptionalAttribute
19
+ } from './utils'
20
+ import type { KitHeaderProps } from './types'
21
+
22
+ export function KitHeader(props: KitHeaderProps = {}): VobsNode {
23
+ const root = createElement('header')
24
+ const toggleRegion = createElement('div')
25
+ const brand = createElement('div')
26
+ const actions = createElement('div')
27
+
28
+ bindClassList(root, props, () => ['vobs-kit-header'])
29
+ bindCommonAttributes(root, props, [
30
+ 'logo',
31
+ 'title',
32
+ 'sidebarToggle',
33
+ 'userMenu',
34
+ 'headerActions',
35
+ 'toggleLabel',
36
+ 'onToggleSidebar'
37
+ ])
38
+ bindUserStyle(root, props)
39
+ setAttribute(toggleRegion, 'class', 'vobs-kit-header__toggle')
40
+ if (hasProp(props, 'sidebarToggle')) {
41
+ mountSlot(toggleRegion, props, 'sidebarToggle')
42
+ } else {
43
+ insertBefore(toggleRegion, createDefaultToggle(props), null)
44
+ }
45
+
46
+ setAttribute(brand, 'class', 'vobs-kit-header__brand')
47
+ if (readProp(props, 'logo', undefined) !== undefined) {
48
+ const logo = createElement('span')
49
+ setAttribute(logo, 'class', 'vobs-kit-header__logo')
50
+ mountSlot(logo, props, 'logo')
51
+ insertBefore(brand, logo, null)
52
+ }
53
+ if (readProp<string | undefined>(props, 'title', undefined) !== undefined) {
54
+ const title = createElement('h1')
55
+ const text = createText('')
56
+ setAttribute(title, 'class', 'vobs-kit-header__title')
57
+ bindTextContent(text, () => readProp(props, 'title', ''))
58
+ insertBefore(title, text, null)
59
+ insertBefore(brand, title, null)
60
+ }
61
+
62
+ setAttribute(actions, 'class', 'vobs-kit-header__actions')
63
+ if (readProp(props, 'headerActions', undefined) !== undefined) mountSlot(actions, props, 'headerActions')
64
+ if (readProp(props, 'userMenu', undefined) !== undefined) mountSlot(actions, props, 'userMenu')
65
+
66
+ insertBefore(root, toggleRegion, null)
67
+ insertBefore(root, brand, null)
68
+ insertBefore(root, actions, null)
69
+ if (hasProp(props, 'children')) mountSlot(root, props, 'children')
70
+ return root
71
+ }
72
+
73
+ function createDefaultToggle(props: KitHeaderProps): VobsNode {
74
+ const button = createElement('button')
75
+ const label = createText('')
76
+ setAttribute(button, 'type', 'button')
77
+ setAttribute(button, 'class', 'vobs-kit-header__toggle-button')
78
+ bindTextContent(label, () => readProp(props, 'toggleLabel', 'Menu'))
79
+ insertBefore(button, label, null)
80
+ addEventListener(button, 'click', () => {
81
+ const handler = readProp<unknown>(props, 'onToggleSidebar', undefined)
82
+ if (typeof handler === 'function') (handler as KitHeaderProps['onToggleSidebar'])!()
83
+ })
84
+ effectToggleLabel(button, props)
85
+ return button
86
+ }
87
+
88
+ function effectToggleLabel(button: Element, props: KitHeaderProps): void {
89
+ effect(() => {
90
+ setOptionalAttribute(button, 'aria-label', readProp(props, 'toggleLabel', 'Menu'))
91
+ })
92
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export { KitLayout } from './layout'
2
+ export { KitHeader } from './header'
3
+ export { KitSidebar } from './sidebar'
4
+ export { KitMenu } from './menu'
5
+ export { KitBreadcrumb } from './breadcrumb'
6
+ export { KitTabs } from './tabs'
7
+ export { KIT_LAYOUT_KEY, useKitLayout } from './context'
8
+ export {
9
+ createKitViewport,
10
+ DEFAULT_MOBILE_BREAKPOINT,
11
+ normalizeBreakpoint
12
+ } from './viewport'
13
+
14
+ export type {
15
+ KitBreadcrumbItem,
16
+ KitBreadcrumbProps,
17
+ KitHeaderProps,
18
+ KitLayoutContext,
19
+ KitLayoutProps,
20
+ KitMenuItem,
21
+ KitMenuProps,
22
+ KitReadonlySignal,
23
+ KitSidebarProps,
24
+ KitSidebarVariant,
25
+ KitTabItem,
26
+ KitTabsProps,
27
+ KitViewport,
28
+ LayoutAttributeValue,
29
+ LayoutChild,
30
+ LayoutChildren,
31
+ LayoutCommonProps
32
+ } from './types'
@@ -0,0 +1,271 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { createComponent, createDOMRenderer, createElement, createText, createVobs, setRenderer } from '@vobs/vobs'
3
+ import { state } from '@vobs/reactivity'
4
+ import { KitLayout } from './layout'
5
+ import { useKitLayout } from './context'
6
+ import { KitSidebar } from './sidebar'
7
+ import { KitBreadcrumb } from './breadcrumb'
8
+ import { KitTabs } from './tabs'
9
+
10
+ describe('@vobs/layout', () => {
11
+ beforeEach(() => {
12
+ setRenderer(createDOMRenderer())
13
+ window.innerWidth = 1200
14
+ window.innerHeight = 800
15
+ })
16
+
17
+ it('默认插入 Kit header、menu 和内容插槽', () => {
18
+ const container = document.createElement('main')
19
+ const app = createVobs({
20
+ render: () => createComponent(KitLayout, {
21
+ title: 'Workspace',
22
+ menu: [{ key: '/home', label: 'Home' }],
23
+ children: 'Content'
24
+ })
25
+ })
26
+ app.mount(container)
27
+
28
+ expect(container.querySelector('.vobs-kit-layout')).toBeTruthy()
29
+ expect(container.querySelector('.vobs-kit-header__title')?.textContent).toBe('Workspace')
30
+ expect(container.querySelector('.vobs-kit-menu__label')?.textContent).toBe('Home')
31
+ expect(container.querySelector('.vobs-kit-layout__content')?.textContent).toBe('Content')
32
+ expect(container.querySelector('[class*="vui-"]')).toBeNull()
33
+ expect(container.querySelector('[class*="vobs-admin-"]')).toBeNull()
34
+ app.destroy()
35
+ })
36
+
37
+ it('默认侧栏宽度交给 CSS,显式配置才写入内联变量', () => {
38
+ const defaultContainer = document.createElement('main')
39
+ const defaultApp = createVobs({
40
+ render: () => createComponent(KitLayout, { menu: [{ key: '/home', label: 'Home' }] })
41
+ })
42
+ defaultApp.mount(defaultContainer)
43
+
44
+ const defaultRoot = defaultContainer.querySelector('.vobs-kit-layout') as HTMLElement
45
+ expect(defaultRoot.hasAttribute('style')).toBe(false)
46
+ defaultApp.destroy()
47
+
48
+ const configuredContainer = document.createElement('main')
49
+ const configuredApp = createVobs({
50
+ render: () => createComponent(KitLayout, {
51
+ sidebarWidth: 240,
52
+ style: '--vobs-kit-sidebar-width: 260px',
53
+ menu: [{ key: '/home', label: 'Home' }]
54
+ })
55
+ })
56
+ configuredApp.mount(configuredContainer)
57
+
58
+ const configuredRoot = configuredContainer.querySelector('.vobs-kit-layout') as HTMLElement
59
+ expect(configuredRoot.style.getPropertyValue('--vobs-kit-sidebar-width')).toBe('260px')
60
+ configuredApp.destroy()
61
+ })
62
+
63
+ it('侧栏默认收起,显式设置后才默认展开', () => {
64
+ const defaultContainer = document.createElement('main')
65
+ const defaultApp = createVobs({
66
+ render: () => createComponent(KitLayout, {
67
+ menu: [{ key: '/home', label: 'Home' }]
68
+ })
69
+ })
70
+ defaultApp.mount(defaultContainer)
71
+
72
+ expect(defaultContainer.querySelector('.vobs-kit-layout')?.classList.contains(
73
+ 'vobs-kit-layout--sidebar-collapsed'
74
+ )).toBe(true)
75
+ expect(defaultContainer.querySelector('.vobs-kit-layout')?.hasAttribute('sidebarExpanded')).toBe(false)
76
+ defaultApp.destroy()
77
+
78
+ const expandedContainer = document.createElement('main')
79
+ const expandedApp = createVobs({
80
+ render: () => createComponent(KitLayout, {
81
+ sidebarExpanded: true,
82
+ menu: [{ key: '/home', label: 'Home' }]
83
+ })
84
+ })
85
+ expandedApp.mount(expandedContainer)
86
+
87
+ expect(expandedContainer.querySelector('.vobs-kit-layout')?.classList.contains(
88
+ 'vobs-kit-layout--sidebar-collapsed'
89
+ )).toBe(false)
90
+ expandedApp.destroy()
91
+ })
92
+
93
+ it('显式 sidebarCollapsed 优先于默认展开配置', () => {
94
+ const container = document.createElement('main')
95
+ const app = createVobs({
96
+ render: () => createComponent(KitLayout, {
97
+ sidebarExpanded: true,
98
+ sidebarCollapsed: true,
99
+ menu: [{ key: '/home', label: 'Home' }]
100
+ })
101
+ })
102
+ app.mount(container)
103
+
104
+ expect(container.querySelector('.vobs-kit-layout')?.classList.contains(
105
+ 'vobs-kit-layout--sidebar-collapsed'
106
+ )).toBe(true)
107
+ app.destroy()
108
+ })
109
+
110
+ it('非受控状态可以通过默认 header 切换侧栏', async () => {
111
+ const container = document.createElement('main')
112
+ const app = createVobs({
113
+ render: () => createComponent(KitLayout, {
114
+ menu: [{ key: '/home', label: 'Home' }]
115
+ })
116
+ })
117
+ app.mount(container)
118
+
119
+ const root = container.querySelector('.vobs-kit-layout') as HTMLElement
120
+ const toggle = container.querySelector('.vobs-kit-header__toggle-button') as HTMLButtonElement
121
+ expect(root.classList.contains('vobs-kit-layout--sidebar-collapsed')).toBe(true)
122
+ toggle.click()
123
+ await Promise.resolve()
124
+
125
+ expect(root.classList.contains('vobs-kit-layout--sidebar-collapsed')).toBe(false)
126
+ expect(container.querySelector('.vobs-kit-sidebar')?.classList.contains('is-collapsed')).toBe(false)
127
+ app.destroy()
128
+ })
129
+
130
+ it('窗口尺寸变化仍驱动移动端布局,但销毁后停止监听', async () => {
131
+ const container = document.createElement('main')
132
+ const app = createVobs({
133
+ render: () => createComponent(KitLayout, {
134
+ menu: [{ key: '/home', label: 'Home' }]
135
+ })
136
+ })
137
+ app.mount(container)
138
+
139
+ const root = container.querySelector('.vobs-kit-layout')!
140
+ expect(root.classList.contains('vobs-kit-layout--mobile')).toBe(false)
141
+
142
+ window.innerWidth = 600
143
+ window.dispatchEvent(new Event('resize'))
144
+ await Promise.resolve()
145
+ expect(root.classList.contains('vobs-kit-layout--mobile')).toBe(true)
146
+
147
+ window.innerWidth = 1000
148
+ window.dispatchEvent(new Event('resize'))
149
+ await Promise.resolve()
150
+ expect(root.classList.contains('vobs-kit-layout--mobile')).toBe(false)
151
+
152
+ app.destroy()
153
+ window.innerWidth = 600
154
+ window.dispatchEvent(new Event('resize'))
155
+ await Promise.resolve()
156
+ expect(root.classList.contains('vobs-kit-layout--mobile')).toBe(false)
157
+ })
158
+
159
+ it('自定义 header 可以通过 KitLayout 上下文控制布局', async () => {
160
+ function CustomHeader() {
161
+ const layout = useKitLayout()
162
+ const button = createElement('button') as HTMLButtonElement
163
+ button.textContent = 'custom toggle'
164
+ button.addEventListener('click', layout.toggleSidebar)
165
+ return button
166
+ }
167
+
168
+ const container = document.createElement('main')
169
+ const app = createVobs({
170
+ render: () => createComponent(KitLayout, {
171
+ header: () => createComponent(CustomHeader, {}),
172
+ menu: [{ key: '/home', label: 'Home' }]
173
+ })
174
+ })
175
+ app.mount(container)
176
+
177
+ const root = container.querySelector('.vobs-kit-layout') as HTMLElement
178
+ ;(container.querySelector('button') as HTMLButtonElement).click()
179
+ await Promise.resolve()
180
+ expect(root.classList.contains('vobs-kit-layout--sidebar-collapsed')).toBe(false)
181
+ app.destroy()
182
+ })
183
+
184
+ it('受控状态只通过 change 回调请求外部更新', async () => {
185
+ const collapsed = state(false)
186
+ const changes: boolean[] = []
187
+ const props = {
188
+ get sidebarCollapsed() { return collapsed.value },
189
+ onSidebarCollapsedChange(value: boolean) { changes.push(value) },
190
+ menu: [{ key: '/home', label: 'Home' }]
191
+ }
192
+ const container = document.createElement('main')
193
+ const app = createVobs({ render: () => createComponent(KitLayout, props) })
194
+ app.mount(container)
195
+
196
+ const root = container.querySelector('.vobs-kit-layout') as HTMLElement
197
+ ;(container.querySelector('.vobs-kit-header__toggle-button') as HTMLButtonElement).click()
198
+ await Promise.resolve()
199
+ expect(changes).toEqual([true])
200
+ expect(root.classList.contains('vobs-kit-layout--sidebar-collapsed')).toBe(false)
201
+
202
+ collapsed.value = true
203
+ app.update()
204
+ expect(root.classList.contains('vobs-kit-layout--sidebar-collapsed')).toBe(true)
205
+ app.destroy()
206
+ })
207
+
208
+ it('null 插槽可以关闭默认区域,显式 backdrop 支持关闭回调', async () => {
209
+ const mobileOpen = state(true)
210
+ let closed = 0
211
+ const container = document.createElement('main')
212
+ const app = createVobs({
213
+ render: () => createComponent(KitLayout, {
214
+ header: null,
215
+ sidebar: createElement('aside'),
216
+ mobileOpen: mobileOpen.value,
217
+ mobileBackdrop: createElement('div'),
218
+ onMobileOpenChange(value) {
219
+ if (!value) closed++
220
+ },
221
+ onCloseSidebar() { closed++ },
222
+ children: 'Content'
223
+ })
224
+ })
225
+ app.mount(container)
226
+
227
+ const header = container.querySelector('.vobs-kit-layout__header') as HTMLElement
228
+ expect(header.hidden).toBe(true)
229
+ const root = container.querySelector('.vobs-kit-layout') as HTMLElement
230
+ expect(root.classList.contains('vobs-kit-layout--mobile-open')).toBe(true)
231
+ app.destroy()
232
+ expect(closed).toBe(0)
233
+ void mobileOpen
234
+ })
235
+
236
+ it('独立原语接受第三方节点、嵌套菜单、面包屑和 tabs', () => {
237
+ const container = document.createElement('main')
238
+ const externalIcon = createElement('svg')
239
+ const selected: string[] = []
240
+ const app = createVobs({
241
+ render: () => {
242
+ const root = createElement('div')
243
+ root.append(
244
+ createComponent(KitSidebar, {
245
+ items: [{ key: '/group', label: 'Group', icon: externalIcon, children: [{ key: '/child', label: 'Child' }] }],
246
+ activeKey: '/child',
247
+ onSelect: key => selected.push(key)
248
+ }) as Node,
249
+ createComponent(KitBreadcrumb, {
250
+ items: [{ label: 'Home', href: '/' }, { label: 'Current' }]
251
+ }) as Node,
252
+ createComponent(KitTabs, {
253
+ tabs: [{ id: 'home', label: 'Home' }, { id: 'current', label: 'Current' }],
254
+ activeKey: 'current'
255
+ }) as Node,
256
+ createText('') as Node
257
+ )
258
+ return root
259
+ }
260
+ })
261
+ app.mount(container)
262
+
263
+ expect(container.querySelector('.vobs-kit-menu__children')).toBeTruthy()
264
+ expect(container.querySelector('.vobs-kit-menu__icon svg')).toBe(externalIcon)
265
+ expect(container.querySelector('.vobs-kit-breadcrumb [aria-current="page"]')?.textContent).toBe('Current')
266
+ expect(container.querySelector('.vobs-kit-tabs__tab.is-active')?.textContent).toContain('Current')
267
+ ;(container.querySelector('[data-menu-key="/child"] button, [data-menu-key="/child"] a') as HTMLElement)?.click()
268
+ expect(selected).toEqual(['/child'])
269
+ app.destroy()
270
+ })
271
+ })