@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/src/layout.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { effect, memo, state } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
addEventListener,
|
|
4
|
+
createComponent,
|
|
5
|
+
createElement,
|
|
6
|
+
createText,
|
|
7
|
+
insertBefore,
|
|
8
|
+
insertDynamic,
|
|
9
|
+
provide,
|
|
10
|
+
setAttribute,
|
|
11
|
+
setProperty,
|
|
12
|
+
type VobsNode
|
|
13
|
+
} from '@vobs/vobs'
|
|
14
|
+
import { KIT_LAYOUT_KEY } from './context'
|
|
15
|
+
import { KitHeader } from './header'
|
|
16
|
+
import { KitSidebar } from './sidebar'
|
|
17
|
+
import {
|
|
18
|
+
bindClassList,
|
|
19
|
+
bindCommonAttributes,
|
|
20
|
+
bindUserStyle,
|
|
21
|
+
hasProp,
|
|
22
|
+
mountSlot,
|
|
23
|
+
readProp,
|
|
24
|
+
resolveSlot,
|
|
25
|
+
setOptionalAttribute
|
|
26
|
+
} from './utils'
|
|
27
|
+
import {
|
|
28
|
+
createKitViewport,
|
|
29
|
+
DEFAULT_MOBILE_BREAKPOINT,
|
|
30
|
+
normalizeBreakpoint
|
|
31
|
+
} from './viewport'
|
|
32
|
+
import type {
|
|
33
|
+
KitLayoutContext,
|
|
34
|
+
KitLayoutProps,
|
|
35
|
+
KitMenuItem,
|
|
36
|
+
KitSidebarVariant
|
|
37
|
+
} from './types'
|
|
38
|
+
|
|
39
|
+
export function KitLayout(props: KitLayoutProps = {}): VobsNode {
|
|
40
|
+
const root = createElement('div')
|
|
41
|
+
const header = createElement('div')
|
|
42
|
+
const body = createElement('div')
|
|
43
|
+
const sidebar = createElement('div')
|
|
44
|
+
const main = createElement('main')
|
|
45
|
+
const breadcrumb = createElement('div')
|
|
46
|
+
const tabs = createElement('div')
|
|
47
|
+
const content = createElement('div')
|
|
48
|
+
const backdrop = createElement('div')
|
|
49
|
+
const footer = createElement('footer')
|
|
50
|
+
|
|
51
|
+
const breakpoint = normalizeBreakpoint(
|
|
52
|
+
readProp(props, 'mobileBreakpoint', DEFAULT_MOBILE_BREAKPOINT)
|
|
53
|
+
)
|
|
54
|
+
const viewport = createKitViewport(breakpoint)
|
|
55
|
+
const sidebarExpanded = readProp(props, 'sidebarExpanded', false)
|
|
56
|
+
const internalCollapsed = state(
|
|
57
|
+
readProp<boolean | undefined>(props, 'sidebarCollapsed', undefined) ?? !sidebarExpanded
|
|
58
|
+
)
|
|
59
|
+
const internalMobileOpen = state(readProp<boolean | undefined>(props, 'mobileOpen', undefined) ?? false)
|
|
60
|
+
const collapsed = memo(() => readProp<boolean | undefined>(props, 'sidebarCollapsed', undefined) ?? internalCollapsed.value)
|
|
61
|
+
const mobileOpen = memo(() => readProp<boolean | undefined>(props, 'mobileOpen', undefined) ?? internalMobileOpen.value)
|
|
62
|
+
const headerVisible = state(!isProvided(props, 'header'))
|
|
63
|
+
const sidebarVisible = state(isProvided(props, 'sidebar') || hasProp(props, 'menu'))
|
|
64
|
+
const backdropVisible = state(!isProvided(props, 'mobileBackdrop'))
|
|
65
|
+
const breadcrumbVisible = state(false)
|
|
66
|
+
const tabsVisible = state(false)
|
|
67
|
+
const footerVisible = state(false)
|
|
68
|
+
|
|
69
|
+
const context: KitLayoutContext = {
|
|
70
|
+
sidebarCollapsed: collapsed,
|
|
71
|
+
mobileOpen,
|
|
72
|
+
isMobile: viewport.isMobile,
|
|
73
|
+
breakpoint,
|
|
74
|
+
|
|
75
|
+
toggleSidebar(): void {
|
|
76
|
+
if (viewport.isMobile.value) context.setMobileOpen(!mobileOpen.value)
|
|
77
|
+
else context.setSidebarCollapsed(!collapsed.value)
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
setSidebarCollapsed(value: boolean): void {
|
|
81
|
+
if (!isBoolean(value)) throw new Error('VOBS_KIT001: sidebarCollapsed 必须是 boolean')
|
|
82
|
+
if (readProp<boolean | undefined>(props, 'sidebarCollapsed', undefined) === undefined) {
|
|
83
|
+
internalCollapsed.value = value
|
|
84
|
+
}
|
|
85
|
+
const handler = readProp<unknown>(props, 'onSidebarCollapsedChange', undefined)
|
|
86
|
+
if (typeof handler === 'function') {
|
|
87
|
+
(handler as KitLayoutProps['onSidebarCollapsedChange'])!(value)
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
setMobileOpen(value: boolean): void {
|
|
92
|
+
if (!isBoolean(value)) throw new Error('VOBS_KIT001: mobileOpen 必须是 boolean')
|
|
93
|
+
if (readProp<boolean | undefined>(props, 'mobileOpen', undefined) === undefined) {
|
|
94
|
+
internalMobileOpen.value = value
|
|
95
|
+
}
|
|
96
|
+
const handler = readProp<unknown>(props, 'onMobileOpenChange', undefined)
|
|
97
|
+
if (typeof handler === 'function') (handler as KitLayoutProps['onMobileOpenChange'])!(value)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
provide(KIT_LAYOUT_KEY, context)
|
|
101
|
+
|
|
102
|
+
bindClassList(root, props, () => [
|
|
103
|
+
'vobs-kit-layout',
|
|
104
|
+
collapsed.value ? 'vobs-kit-layout--sidebar-collapsed' : undefined,
|
|
105
|
+
viewport.isMobile.value ? 'vobs-kit-layout--mobile' : undefined,
|
|
106
|
+
mobileOpen.value ? 'vobs-kit-layout--mobile-open' : undefined
|
|
107
|
+
])
|
|
108
|
+
bindCommonAttributes(root, props, [
|
|
109
|
+
'header',
|
|
110
|
+
'sidebar',
|
|
111
|
+
'breadcrumb',
|
|
112
|
+
'tabs',
|
|
113
|
+
'footer',
|
|
114
|
+
'mobileBackdrop',
|
|
115
|
+
'menu',
|
|
116
|
+
'logo',
|
|
117
|
+
'userMenu',
|
|
118
|
+
'headerActions',
|
|
119
|
+
'activeKey',
|
|
120
|
+
'sidebarExpanded',
|
|
121
|
+
'sidebarCollapsed',
|
|
122
|
+
'mobileOpen',
|
|
123
|
+
'sidebarWidth',
|
|
124
|
+
'mobileBreakpoint',
|
|
125
|
+
'sidebarVariant',
|
|
126
|
+
'onSidebarCollapsedChange',
|
|
127
|
+
'onMobileOpenChange',
|
|
128
|
+
'onToggleSidebar',
|
|
129
|
+
'onCloseSidebar'
|
|
130
|
+
])
|
|
131
|
+
bindUserStyle(root, props, () => {
|
|
132
|
+
const sidebarWidth = readProp<number | undefined>(props, 'sidebarWidth', undefined)
|
|
133
|
+
if (sidebarWidth === undefined) return ''
|
|
134
|
+
return `--vobs-kit-sidebar-width: ${normalizeSidebarWidth(sidebarWidth)}px`
|
|
135
|
+
})
|
|
136
|
+
setAttribute(header, 'class', 'vobs-kit-layout__header')
|
|
137
|
+
insertDynamic(header, null, () => createHeaderSlot(props, context, headerVisible))
|
|
138
|
+
bindVisibility(header, headerVisible)
|
|
139
|
+
|
|
140
|
+
setAttribute(body, 'class', 'vobs-kit-layout__body')
|
|
141
|
+
setAttribute(sidebar, 'class', 'vobs-kit-layout__sidebar')
|
|
142
|
+
setAttribute(main, 'class', 'vobs-kit-layout__main')
|
|
143
|
+
setAttribute(breadcrumb, 'class', 'vobs-kit-layout__breadcrumb')
|
|
144
|
+
setAttribute(tabs, 'class', 'vobs-kit-layout__tabs')
|
|
145
|
+
setAttribute(content, 'class', 'vobs-kit-layout__content')
|
|
146
|
+
|
|
147
|
+
insertDynamic(sidebar, null, () => createSidebarSlot(props, context, sidebarVisible))
|
|
148
|
+
bindVisibility(sidebar, sidebarVisible)
|
|
149
|
+
|
|
150
|
+
insertDynamic(breadcrumb, null, () => createNamedSlot(props, 'breadcrumb', breadcrumbVisible))
|
|
151
|
+
bindVisibility(breadcrumb, breadcrumbVisible)
|
|
152
|
+
insertDynamic(tabs, null, () => createNamedSlot(props, 'tabs', tabsVisible))
|
|
153
|
+
bindVisibility(tabs, tabsVisible)
|
|
154
|
+
if (hasProp(props, 'children')) mountSlot(content, props, 'children')
|
|
155
|
+
|
|
156
|
+
insertBefore(main, breadcrumb, null)
|
|
157
|
+
insertBefore(main, tabs, null)
|
|
158
|
+
insertBefore(main, content, null)
|
|
159
|
+
insertBefore(body, sidebar, null)
|
|
160
|
+
insertBefore(body, main, null)
|
|
161
|
+
|
|
162
|
+
setAttribute(backdrop, 'class', 'vobs-kit-layout__backdrop')
|
|
163
|
+
insertDynamic(backdrop, null, () => createBackdropSlot(props, context, sidebarVisible, backdropVisible))
|
|
164
|
+
bindVisibility(backdrop, memo(() => backdropVisible.value && sidebarVisible.value && mobileOpen.value))
|
|
165
|
+
|
|
166
|
+
setAttribute(footer, 'class', 'vobs-kit-layout__footer')
|
|
167
|
+
insertDynamic(footer, null, () => {
|
|
168
|
+
const value = isProvided(props, 'footer') ? resolveSlot(readProp(props, 'footer', undefined)) : null
|
|
169
|
+
footerVisible.value = value !== null
|
|
170
|
+
return value
|
|
171
|
+
})
|
|
172
|
+
bindVisibility(footer, footerVisible)
|
|
173
|
+
|
|
174
|
+
insertBefore(root, header, null)
|
|
175
|
+
insertBefore(root, body, null)
|
|
176
|
+
insertBefore(root, backdrop, null)
|
|
177
|
+
insertBefore(root, footer, null)
|
|
178
|
+
return root
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createHeaderSlot(
|
|
182
|
+
props: KitLayoutProps,
|
|
183
|
+
context: KitLayoutContext,
|
|
184
|
+
visible: { value: boolean }
|
|
185
|
+
): VobsNode | null {
|
|
186
|
+
if (isProvided(props, 'header')) {
|
|
187
|
+
const value = resolveSlot(readProp(props, 'header', undefined))
|
|
188
|
+
visible.value = value !== null
|
|
189
|
+
return value
|
|
190
|
+
}
|
|
191
|
+
visible.value = true
|
|
192
|
+
return createComponent(KitHeader, {
|
|
193
|
+
get logo() { return readProp(props, 'logo', undefined) },
|
|
194
|
+
get title() { return readProp<string | undefined>(props, 'title', undefined) },
|
|
195
|
+
get headerActions() { return readProp(props, 'headerActions', undefined) },
|
|
196
|
+
get userMenu() { return readProp(props, 'userMenu', undefined) },
|
|
197
|
+
onToggleSidebar: () => {
|
|
198
|
+
context.toggleSidebar()
|
|
199
|
+
const handler = readProp<unknown>(props, 'onToggleSidebar', undefined)
|
|
200
|
+
if (typeof handler === 'function') (handler as KitLayoutProps['onToggleSidebar'])!()
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createSidebarSlot(
|
|
206
|
+
props: KitLayoutProps,
|
|
207
|
+
context: KitLayoutContext,
|
|
208
|
+
visible: { value: boolean }
|
|
209
|
+
): VobsNode | null {
|
|
210
|
+
if (isProvided(props, 'sidebar')) {
|
|
211
|
+
const value = resolveSlot(readProp(props, 'sidebar', undefined))
|
|
212
|
+
visible.value = value !== null
|
|
213
|
+
return value
|
|
214
|
+
}
|
|
215
|
+
if (!hasProp(props, 'menu')) {
|
|
216
|
+
visible.value = false
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
visible.value = true
|
|
220
|
+
return createComponent(KitSidebar, {
|
|
221
|
+
get menu() { return readProp<readonly KitMenuItem[]>(props, 'menu', []) },
|
|
222
|
+
get collapsed() { return context.sidebarCollapsed.value },
|
|
223
|
+
get mobileOpen() { return context.mobileOpen.value },
|
|
224
|
+
get activeKey() { return readProp<string | undefined>(props, 'activeKey', undefined) },
|
|
225
|
+
get variant() { return readProp<KitSidebarVariant>(props, 'sidebarVariant', 'default') },
|
|
226
|
+
get onSelect() { return readProp<KitLayoutProps['onMenuSelect'] | undefined>(props, 'onMenuSelect', undefined) },
|
|
227
|
+
onClose: () => {
|
|
228
|
+
context.setMobileOpen(false)
|
|
229
|
+
const handler = readProp<unknown>(props, 'onCloseSidebar', undefined)
|
|
230
|
+
if (typeof handler === 'function') (handler as KitLayoutProps['onCloseSidebar'])!()
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function createNamedSlot(
|
|
236
|
+
props: KitLayoutProps,
|
|
237
|
+
name: 'breadcrumb' | 'tabs',
|
|
238
|
+
visible: { value: boolean }
|
|
239
|
+
): VobsNode | null {
|
|
240
|
+
const value = hasProp(props, name) ? resolveSlot(readProp(props, name, undefined)) : null
|
|
241
|
+
visible.value = value !== null
|
|
242
|
+
return value
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createBackdropSlot(
|
|
246
|
+
props: KitLayoutProps,
|
|
247
|
+
context: KitLayoutContext,
|
|
248
|
+
sidebarVisible: { value: boolean },
|
|
249
|
+
visible: { value: boolean }
|
|
250
|
+
): VobsNode | null {
|
|
251
|
+
if (isProvided(props, 'mobileBackdrop')) {
|
|
252
|
+
const value = resolveSlot(readProp(props, 'mobileBackdrop', undefined))
|
|
253
|
+
visible.value = value !== null
|
|
254
|
+
return value
|
|
255
|
+
}
|
|
256
|
+
if (!sidebarVisible.value) {
|
|
257
|
+
visible.value = false
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
visible.value = true
|
|
261
|
+
const button = createElement('button')
|
|
262
|
+
setAttribute(button, 'type', 'button')
|
|
263
|
+
setAttribute(button, 'class', 'vobs-kit-layout__backdrop-button')
|
|
264
|
+
setAttribute(button, 'aria-label', 'Close navigation')
|
|
265
|
+
insertBefore(button, createText(''), null)
|
|
266
|
+
addEventListener(button, 'click', () => {
|
|
267
|
+
context.setMobileOpen(false)
|
|
268
|
+
const handler = readProp<unknown>(props, 'onCloseSidebar', undefined)
|
|
269
|
+
if (typeof handler === 'function') (handler as KitLayoutProps['onCloseSidebar'])!()
|
|
270
|
+
})
|
|
271
|
+
return button
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function bindVisibility(node: Element, visible: { value: boolean }): void {
|
|
275
|
+
effect(() => {
|
|
276
|
+
setProperty(node, 'hidden', !visible.value)
|
|
277
|
+
setOptionalAttribute(node, 'aria-hidden', visible.value ? undefined : 'true')
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function normalizeSidebarWidth(value: number): number {
|
|
282
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
283
|
+
throw new Error('VOBS_KIT001: sidebarWidth 必须是大于 0 的有限数字')
|
|
284
|
+
}
|
|
285
|
+
return Math.round(value)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isBoolean(value: unknown): value is boolean {
|
|
289
|
+
return typeof value === 'boolean'
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isProvided(props: object, name: string): boolean {
|
|
293
|
+
return hasProp(props, name) && Reflect.get(props, name) !== undefined
|
|
294
|
+
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { state } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
addEventListener,
|
|
4
|
+
createElement,
|
|
5
|
+
createFragment,
|
|
6
|
+
createText,
|
|
7
|
+
insertBefore,
|
|
8
|
+
insertDynamic,
|
|
9
|
+
setAttribute,
|
|
10
|
+
setProperty,
|
|
11
|
+
type VobsNode
|
|
12
|
+
} from '@vobs/vobs'
|
|
13
|
+
import {
|
|
14
|
+
bindClassList,
|
|
15
|
+
bindCommonAttributes,
|
|
16
|
+
hasProp,
|
|
17
|
+
mountSlot,
|
|
18
|
+
readProp,
|
|
19
|
+
setOptionalAttribute
|
|
20
|
+
} from './utils'
|
|
21
|
+
import type { KitMenuItem, KitMenuProps } from './types'
|
|
22
|
+
|
|
23
|
+
export function KitMenu(props: KitMenuProps): VobsNode {
|
|
24
|
+
const root = createElement('ul')
|
|
25
|
+
const expanded = state<ReadonlySet<string>>(new Set())
|
|
26
|
+
|
|
27
|
+
bindClassList(root, props, () => ['vobs-kit-menu'])
|
|
28
|
+
bindCommonAttributes(root, props, ['items', 'collapsed', 'activeKey', 'onSelect'])
|
|
29
|
+
if (!hasProp(props, 'role')) setAttribute(root, 'role', 'menu')
|
|
30
|
+
insertDynamic(root, null, () => createMenuTree(props, expanded))
|
|
31
|
+
if (hasProp(props, 'children')) mountSlot(root, props, 'children')
|
|
32
|
+
return root
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createMenuTree(
|
|
36
|
+
props: KitMenuProps,
|
|
37
|
+
expanded: { value: ReadonlySet<string> }
|
|
38
|
+
): VobsNode {
|
|
39
|
+
const items = readProp<readonly KitMenuItem[]>(props, 'items', [])
|
|
40
|
+
const activeKey = readProp<string | undefined>(props, 'activeKey', undefined)
|
|
41
|
+
const collapsed = readProp(props, 'collapsed', false)
|
|
42
|
+
|
|
43
|
+
return createFragment((parent, anchor) => {
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
insertBefore(parent, createMenuItem(item, props, activeKey, collapsed, expanded), anchor)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createMenuItem(
|
|
51
|
+
item: KitMenuItem,
|
|
52
|
+
props: KitMenuProps,
|
|
53
|
+
activeKey: string | undefined,
|
|
54
|
+
collapsed: boolean,
|
|
55
|
+
expanded: { value: ReadonlySet<string> }
|
|
56
|
+
): VobsNode {
|
|
57
|
+
const li = createElement('li')
|
|
58
|
+
const children = item.children ?? []
|
|
59
|
+
const hasChildren = children.length > 0
|
|
60
|
+
const active = item.key === activeKey
|
|
61
|
+
const activeDescendant = children.some(child => hasActiveDescendant(child, activeKey))
|
|
62
|
+
const isExpanded = expanded.value.has(item.key) || activeDescendant
|
|
63
|
+
|
|
64
|
+
setAttribute(li, 'class', `vobs-kit-menu__item${active || activeDescendant ? ' is-active' : ''}`)
|
|
65
|
+
setOptionalAttribute(li, 'data-menu-key', item.key)
|
|
66
|
+
|
|
67
|
+
if (hasChildren) {
|
|
68
|
+
const groupButton = createElement('button')
|
|
69
|
+
setAttribute(groupButton, 'type', 'button')
|
|
70
|
+
setAttribute(groupButton, 'class', 'vobs-kit-menu__link vobs-kit-menu__group-toggle')
|
|
71
|
+
setAttribute(groupButton, 'aria-expanded', isExpanded ? 'true' : 'false')
|
|
72
|
+
if (collapsed) setOptionalAttribute(groupButton, 'title', item.label)
|
|
73
|
+
appendMenuContent(groupButton, item, collapsed)
|
|
74
|
+
addEventListener(groupButton, 'click', () => {
|
|
75
|
+
toggleExpanded(expanded, item.key)
|
|
76
|
+
if (item.href !== undefined) emitSelect(props, item)
|
|
77
|
+
})
|
|
78
|
+
insertBefore(li, groupButton, null)
|
|
79
|
+
|
|
80
|
+
if (isExpanded) {
|
|
81
|
+
const nested = createElement('ul')
|
|
82
|
+
setAttribute(nested, 'class', 'vobs-kit-menu__children')
|
|
83
|
+
setAttribute(nested, 'role', 'menu')
|
|
84
|
+
for (const child of children) {
|
|
85
|
+
insertBefore(nested, createMenuItem(child, props, activeKey, collapsed, expanded), null)
|
|
86
|
+
}
|
|
87
|
+
insertBefore(li, nested, null)
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const link = createElement(item.href === undefined ? 'button' : 'a')
|
|
91
|
+
setAttribute(link, 'class', 'vobs-kit-menu__link')
|
|
92
|
+
if (item.href === undefined) setAttribute(link, 'type', 'button')
|
|
93
|
+
else {
|
|
94
|
+
setAttribute(link, 'href', item.href)
|
|
95
|
+
setOptionalAttribute(link, 'target', item.target)
|
|
96
|
+
setOptionalAttribute(link, 'rel', item.rel)
|
|
97
|
+
}
|
|
98
|
+
if (active) setAttribute(link, 'aria-current', 'page')
|
|
99
|
+
if (item.disabled === true) {
|
|
100
|
+
setAttribute(link, 'aria-disabled', 'true')
|
|
101
|
+
setProperty(link, 'disabled', true)
|
|
102
|
+
}
|
|
103
|
+
if (collapsed) setOptionalAttribute(link, 'title', item.label)
|
|
104
|
+
appendMenuContent(link, item, collapsed)
|
|
105
|
+
addEventListener(link, 'click', event => {
|
|
106
|
+
if (item.disabled === true) {
|
|
107
|
+
event.preventDefault?.()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
emitSelect(props, item)
|
|
111
|
+
})
|
|
112
|
+
insertBefore(li, link, null)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return li
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function appendMenuContent(parent: Element, item: KitMenuItem, collapsed: boolean): void {
|
|
119
|
+
if (item.icon !== undefined) {
|
|
120
|
+
const icon = createElement('span')
|
|
121
|
+
setAttribute(icon, 'class', 'vobs-kit-menu__icon')
|
|
122
|
+
mountSlot(icon, item, 'icon')
|
|
123
|
+
insertBefore(parent, icon, null)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const label = createElement('span')
|
|
127
|
+
setAttribute(label, 'class', 'vobs-kit-menu__label')
|
|
128
|
+
insertBefore(label, createText(item.label), null)
|
|
129
|
+
insertBefore(parent, label, null)
|
|
130
|
+
|
|
131
|
+
if (item.badge !== undefined && !collapsed) {
|
|
132
|
+
const badge = createElement('span')
|
|
133
|
+
setAttribute(badge, 'class', 'vobs-kit-menu__badge')
|
|
134
|
+
mountSlot(badge, item, 'badge')
|
|
135
|
+
insertBefore(parent, badge, null)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (item.children && item.children.length > 0) {
|
|
139
|
+
const expand = createElement('span')
|
|
140
|
+
setAttribute(expand, 'class', 'vobs-kit-menu__expand')
|
|
141
|
+
setAttribute(expand, 'aria-hidden', 'true')
|
|
142
|
+
insertBefore(parent, expand, null)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toggleExpanded(expanded: { value: ReadonlySet<string> }, key: string): void {
|
|
147
|
+
const next = new Set(expanded.value)
|
|
148
|
+
if (next.has(key)) next.delete(key)
|
|
149
|
+
else next.add(key)
|
|
150
|
+
expanded.value = next
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function emitSelect(props: KitMenuProps, item: KitMenuItem): void {
|
|
154
|
+
const handler = readProp<unknown>(props, 'onSelect', undefined)
|
|
155
|
+
if (typeof handler === 'function') (handler as KitMenuProps['onSelect'])!(item.key, item)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasActiveDescendant(item: KitMenuItem, activeKey: string | undefined): boolean {
|
|
159
|
+
if (item.key === activeKey) return true
|
|
160
|
+
return item.children?.some(child => hasActiveDescendant(child, activeKey)) ?? false
|
|
161
|
+
}
|
package/src/sidebar.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { effect } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
addEventListener,
|
|
4
|
+
createComponent,
|
|
5
|
+
createElement,
|
|
6
|
+
createText,
|
|
7
|
+
insertBefore,
|
|
8
|
+
insertDynamic,
|
|
9
|
+
setAttribute,
|
|
10
|
+
type VobsNode
|
|
11
|
+
} from '@vobs/vobs'
|
|
12
|
+
import { KitMenu } from './menu'
|
|
13
|
+
import {
|
|
14
|
+
bindClassList,
|
|
15
|
+
bindCommonAttributes,
|
|
16
|
+
bindTextContent,
|
|
17
|
+
bindUserStyle,
|
|
18
|
+
hasProp,
|
|
19
|
+
mountSlot,
|
|
20
|
+
readProp,
|
|
21
|
+
setOptionalAttribute
|
|
22
|
+
} from './utils'
|
|
23
|
+
import type { KitMenuItem, KitSidebarProps } from './types'
|
|
24
|
+
|
|
25
|
+
export function KitSidebar(props: KitSidebarProps = {}): VobsNode {
|
|
26
|
+
const root = createElement('aside')
|
|
27
|
+
const close = createElement('button')
|
|
28
|
+
|
|
29
|
+
bindClassList(root, props, () => [
|
|
30
|
+
'vobs-kit-sidebar',
|
|
31
|
+
readProp(props, 'collapsed', false) ? 'is-collapsed' : undefined,
|
|
32
|
+
readProp(props, 'mobileOpen', false) ? 'is-mobile-open' : undefined,
|
|
33
|
+
readProp<KitSidebarProps['variant']>(props, 'variant', 'default') === 'centered' ? 'is-centered' : undefined
|
|
34
|
+
])
|
|
35
|
+
bindCommonAttributes(root, props, [
|
|
36
|
+
'items',
|
|
37
|
+
'menu',
|
|
38
|
+
'collapsed',
|
|
39
|
+
'mobileOpen',
|
|
40
|
+
'activeKey',
|
|
41
|
+
'variant',
|
|
42
|
+
'closeLabel',
|
|
43
|
+
'footer',
|
|
44
|
+
'onSelect',
|
|
45
|
+
'onClose'
|
|
46
|
+
])
|
|
47
|
+
bindUserStyle(root, props)
|
|
48
|
+
setAttribute(close, 'type', 'button')
|
|
49
|
+
setAttribute(close, 'class', 'vobs-kit-sidebar__close')
|
|
50
|
+
effect(() => {
|
|
51
|
+
setOptionalAttribute(close, 'aria-label', readProp(props, 'closeLabel', 'Close navigation'))
|
|
52
|
+
})
|
|
53
|
+
insertBefore(close, createCloseLabel(props), null)
|
|
54
|
+
addEventListener(close, 'click', () => {
|
|
55
|
+
const handler = readProp<unknown>(props, 'onClose', undefined)
|
|
56
|
+
if (typeof handler === 'function') (handler as KitSidebarProps['onClose'])!()
|
|
57
|
+
})
|
|
58
|
+
insertBefore(root, close, null)
|
|
59
|
+
|
|
60
|
+
insertDynamic(root, null, () => createDefaultMenu(props))
|
|
61
|
+
if (hasProp(props, 'footer')) {
|
|
62
|
+
const footer = createElement('div')
|
|
63
|
+
setAttribute(footer, 'class', 'vobs-kit-sidebar__footer')
|
|
64
|
+
mountSlot(footer, props, 'footer')
|
|
65
|
+
insertBefore(root, footer, null)
|
|
66
|
+
}
|
|
67
|
+
if (hasProp(props, 'children')) mountSlot(root, props, 'children')
|
|
68
|
+
return root
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createDefaultMenu(props: KitSidebarProps): VobsNode | null {
|
|
72
|
+
const items = readProp<readonly KitMenuItem[] | undefined>(props, 'items', undefined)
|
|
73
|
+
?? readProp<readonly KitMenuItem[]>(props, 'menu', [])
|
|
74
|
+
if (items.length === 0 && hasProp(props, 'children')) return null
|
|
75
|
+
|
|
76
|
+
return createComponent(KitMenu, {
|
|
77
|
+
get items() { return items },
|
|
78
|
+
get collapsed() { return readProp(props, 'collapsed', false) },
|
|
79
|
+
get activeKey() { return readProp<string | undefined>(props, 'activeKey', undefined) },
|
|
80
|
+
get onSelect() { return readProp<KitSidebarProps['onSelect'] | undefined>(props, 'onSelect', undefined) }
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createCloseLabel(props: KitSidebarProps): VobsNode {
|
|
85
|
+
const text = createElement('span')
|
|
86
|
+
const content = createText('')
|
|
87
|
+
setAttribute(text, 'class', 'vobs-kit-sidebar__close-label')
|
|
88
|
+
bindTextContent(content, () => readProp(props, 'closeLabel', 'Close navigation'))
|
|
89
|
+
insertBefore(text, content, null)
|
|
90
|
+
return text
|
|
91
|
+
}
|