@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 +21 -0
- package/README.md +49 -0
- package/package.json +38 -0
- package/src/breadcrumb.ts +92 -0
- package/src/context.ts +17 -0
- package/src/header.ts +92 -0
- package/src/index.ts +32 -0
- package/src/layout.test.ts +271 -0
- package/src/layout.ts +294 -0
- package/src/menu.ts +161 -0
- package/src/sidebar.ts +91 -0
- package/src/styles/layout.css +421 -0
- package/src/styles/styles.css +1 -0
- package/src/tabs.ts +127 -0
- package/src/types.ts +150 -0
- package/src/utils.ts +149 -0
- package/src/viewport.ts +54 -0
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
|
+
})
|