@vobs/ui 0.2.0 → 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 +1 -1
- package/README.md +60 -0
- package/package.json +52 -102
- package/src/activity-rail.ts +125 -0
- package/src/alert.ts +65 -0
- package/src/assets/icons/alert-circle.svg +1 -0
- package/src/assets/icons/alert-triangle.svg +1 -0
- package/src/assets/icons/alert.d27e24.svg +1 -0
- package/src/assets/icons/alert.svg +1 -0
- package/src/assets/icons/apple.svg +1 -0
- package/src/assets/icons/arrow-down.svg +1 -0
- package/src/assets/icons/arrow-expand.svg +1 -0
- package/src/assets/icons/arrow-left.svg +1 -0
- package/src/assets/icons/arrow-minimize.svg +1 -0
- package/src/assets/icons/arrow-right-to-line.svg +1 -0
- package/src/assets/icons/arrow-right.svg +1 -0
- package/src/assets/icons/arrow-up-right.svg +1 -0
- package/src/assets/icons/arrow-up.0c0c0d.svg +1 -0
- package/src/assets/icons/arrow-up.svg +1 -0
- package/src/assets/icons/at.svg +1 -0
- package/src/assets/icons/atom.0c0c0d.svg +1 -0
- package/src/assets/icons/atom.svg +1 -0
- package/src/assets/icons/bar.svg +1 -0
- package/src/assets/icons/bell.svg +1 -0
- package/src/assets/icons/box.svg +1 -0
- package/src/assets/icons/bug.svg +1 -0
- package/src/assets/icons/calendar.svg +1 -0
- package/src/assets/icons/check-circle.33c192.svg +1 -0
- package/src/assets/icons/check-circle.svg +1 -0
- package/src/assets/icons/check.d1d3db.svg +1 -0
- package/src/assets/icons/check.svg +1 -0
- package/src/assets/icons/chevron-down.svg +1 -0
- package/src/assets/icons/chevron-right.svg +1 -0
- package/src/assets/icons/chevron-up.svg +1 -0
- package/src/assets/icons/circle.svg +1 -0
- package/src/assets/icons/clock.svg +1 -0
- package/src/assets/icons/cmd.svg +1 -0
- package/src/assets/icons/code.svg +1 -0
- package/src/assets/icons/columns.svg +1 -0
- package/src/assets/icons/copy.svg +1 -0
- package/src/assets/icons/cpu.svg +1 -0
- package/src/assets/icons/dashboard.0c0c0d.svg +1 -0
- package/src/assets/icons/dashboard.svg +1 -0
- package/src/assets/icons/dollar.svg +1 -0
- package/src/assets/icons/download.svg +1 -0
- package/src/assets/icons/edit.svg +1 -0
- package/src/assets/icons/extensions.svg +1 -0
- package/src/assets/icons/external.svg +1 -0
- package/src/assets/icons/eye.svg +1 -0
- package/src/assets/icons/file-text.svg +1 -0
- package/src/assets/icons/file.svg +1 -0
- package/src/assets/icons/files.svg +1 -0
- package/src/assets/icons/filter.svg +1 -0
- package/src/assets/icons/folder.svg +1 -0
- package/src/assets/icons/gear.svg +1 -0
- package/src/assets/icons/git.svg +1 -0
- package/src/assets/icons/github.svg +1 -0
- package/src/assets/icons/globe.svg +1 -0
- package/src/assets/icons/grid-2x2.svg +1 -0
- package/src/assets/icons/hash.svg +1 -0
- package/src/assets/icons/heart.svg +1 -0
- package/src/assets/icons/help.svg +1 -0
- package/src/assets/icons/home.svg +1 -0
- package/src/assets/icons/image.svg +1 -0
- package/src/assets/icons/info-circle.svg +1 -0
- package/src/assets/icons/info-square.svg +1 -0
- package/src/assets/icons/info.387bff.svg +1 -0
- package/src/assets/icons/info.svg +1 -0
- package/src/assets/icons/key.svg +1 -0
- package/src/assets/icons/layers.svg +1 -0
- package/src/assets/icons/layout.svg +1 -0
- package/src/assets/icons/link.svg +1 -0
- package/src/assets/icons/list.svg +1 -0
- package/src/assets/icons/lock.svg +1 -0
- package/src/assets/icons/log-out.svg +1 -0
- package/src/assets/icons/logo.svg +1 -0
- package/src/assets/icons/mail.svg +1 -0
- package/src/assets/icons/menu.svg +1 -0
- package/src/assets/icons/message-circle.svg +1 -0
- package/src/assets/icons/message-plus.svg +1 -0
- package/src/assets/icons/message-square.svg +1 -0
- package/src/assets/icons/mic.svg +1 -0
- package/src/assets/icons/minus.svg +1 -0
- package/src/assets/icons/moon.svg +1 -0
- package/src/assets/icons/more-h.svg +1 -0
- package/src/assets/icons/mouse.svg +1 -0
- package/src/assets/icons/palette.svg +1 -0
- package/src/assets/icons/panel-bottom.svg +1 -0
- package/src/assets/icons/pause.svg +1 -0
- package/src/assets/icons/play.svg +1 -0
- package/src/assets/icons/plug.svg +1 -0
- package/src/assets/icons/plus.0c0c0d.svg +1 -0
- package/src/assets/icons/plus.svg +1 -0
- package/src/assets/icons/refresh.svg +1 -0
- package/src/assets/icons/ruler.svg +1 -0
- package/src/assets/icons/scroll-text.svg +1 -0
- package/src/assets/icons/search-menu.svg +1 -0
- package/src/assets/icons/search.svg +1 -0
- package/src/assets/icons/send.0c0c0d.svg +1 -0
- package/src/assets/icons/send.svg +1 -0
- package/src/assets/icons/settings.0c0c0d.svg +1 -0
- package/src/assets/icons/settings.svg +1 -0
- package/src/assets/icons/shield.svg +1 -0
- package/src/assets/icons/sidebar-left.svg +1 -0
- package/src/assets/icons/sidebar-right.svg +1 -0
- package/src/assets/icons/sliders.svg +1 -0
- package/src/assets/icons/sparkles.32f08c.svg +1 -0
- package/src/assets/icons/sparkles.svg +1 -0
- package/src/assets/icons/square.svg +1 -0
- package/src/assets/icons/star.svg +1 -0
- package/src/assets/icons/sun.svg +1 -0
- package/src/assets/icons/terminal.svg +1 -0
- package/src/assets/icons/trash.svg +1 -0
- package/src/assets/icons/trending-down.svg +1 -0
- package/src/assets/icons/trending-up.svg +1 -0
- package/src/assets/icons/type.svg +1 -0
- package/src/assets/icons/upload.svg +1 -0
- package/src/assets/icons/user-circle.svg +1 -0
- package/src/assets/icons/user.svg +1 -0
- package/src/assets/icons/users.svg +1 -0
- package/src/assets/icons/wrench.svg +1 -0
- package/src/assets/icons/x-circle.svg +1 -0
- package/src/assets/icons/x.svg +1 -0
- package/src/assets/icons/zap.0c0c0d.svg +1 -0
- package/src/assets/icons/zap.svg +1 -0
- package/src/avatar.ts +64 -0
- package/src/button.ts +88 -0
- package/src/card.ts +59 -0
- package/src/chat-composer.ts +156 -0
- package/src/dialog.ts +127 -0
- package/src/drawer.ts +131 -0
- package/src/editor-tabs.ts +184 -0
- package/src/file-tree.ts +214 -0
- package/src/forms.ts +270 -0
- package/src/icon.ts +117 -0
- package/src/icons.js +165 -0
- package/src/index.test.ts +213 -0
- package/src/index.ts +94 -0
- package/src/kbd.ts +72 -0
- package/src/menu.ts +119 -0
- package/src/nav-list.ts +157 -0
- package/src/overlay.test.ts +66 -0
- package/src/overlay.ts +205 -0
- package/src/page-header.ts +97 -0
- package/src/pagination.ts +163 -0
- package/src/second-batch.test.ts +205 -0
- package/src/stat-card.ts +92 -0
- package/src/status-bar.ts +108 -0
- package/src/styles/base.css +240 -0
- package/src/styles/components.css +1234 -0
- package/{dist → src/styles}/styles.css +2 -0
- package/src/styles/toast.css +57 -0
- package/src/styles/tokens.css +438 -0
- package/src/styles/vui.css +47 -0
- package/src/table.ts +191 -0
- package/src/tabs.ts +100 -0
- package/src/tag.ts +33 -0
- package/src/third-batch.test.ts +137 -0
- package/src/toast.test.ts +55 -0
- package/src/toast.ts +79 -0
- package/src/types.ts +25 -0
- package/src/utils.ts +221 -0
- package/src/workbench-titlebar.ts +137 -0
- package/dist/app-shell-dom.d.ts +0 -24
- package/dist/app-shell-dom.js +0 -573
- package/dist/app-shell.d.ts +0 -199
- package/dist/app-shell.js +0 -370
- package/dist/base.css +0 -52
- package/dist/calendar-picker.d.ts +0 -45
- package/dist/calendar-picker.js +0 -217
- package/dist/calendar.d.ts +0 -95
- package/dist/calendar.js +0 -194
- package/dist/complex-inputs.d.ts +0 -300
- package/dist/complex-inputs.js +0 -862
- package/dist/components.css +0 -4568
- package/dist/data-display.d.ts +0 -1063
- package/dist/data-display.js +0 -2298
- package/dist/feedback.d.ts +0 -88
- package/dist/feedback.js +0 -281
- package/dist/forms.d.ts +0 -378
- package/dist/forms.js +0 -1015
- package/dist/icons-config.d.ts +0 -25
- package/dist/icons-config.js +0 -14
- package/dist/icons.d.ts +0 -41
- package/dist/icons.js +0 -185
- package/dist/index.d.ts +0 -27
- package/dist/index.js +0 -34
- package/dist/locale/en-US.d.ts +0 -31
- package/dist/locale/en-US.js +0 -20
- package/dist/locale/zh-CN.d.ts +0 -31
- package/dist/locale/zh-CN.js +0 -20
- package/dist/navigation.d.ts +0 -609
- package/dist/navigation.js +0 -1707
- package/dist/overlay.d.ts +0 -304
- package/dist/overlay.js +0 -969
- package/dist/primitives.d.ts +0 -268
- package/dist/primitives.js +0 -764
- package/dist/theme-data.d.ts +0 -363
- package/dist/theme-data.js +0 -374
- package/dist/theme.d.ts +0 -79
- package/dist/theme.js +0 -262
- package/dist/tokens.css +0 -449
- package/dist/tree.d.ts +0 -144
- package/dist/tree.js +0 -469
- package/dist/virtual-list.d.ts +0 -190
- package/dist/virtual-list.js +0 -521
- package/dist/workbench.d.ts +0 -136
- package/dist/workbench.js +0 -156
package/src/menu.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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 { VuiChildren, VuiCommonProps } from './types'
|
|
21
|
+
|
|
22
|
+
export interface MenuItem {
|
|
23
|
+
readonly id?: string
|
|
24
|
+
readonly label?: string
|
|
25
|
+
readonly icon?: VobsNode | (() => VobsNode | null | undefined)
|
|
26
|
+
readonly shortcut?: string
|
|
27
|
+
readonly danger?: boolean
|
|
28
|
+
readonly disabled?: boolean
|
|
29
|
+
readonly divider?: boolean
|
|
30
|
+
readonly content?: VuiChildren
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MenuProps extends VuiCommonProps {
|
|
34
|
+
readonly items?: readonly MenuItem[]
|
|
35
|
+
readonly onSelect?: (id: string, event: MouseEvent) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Menu(props: MenuProps = {}): VobsNode {
|
|
39
|
+
const root = createElement('div')
|
|
40
|
+
bindClassList(root, props, () => ['vui-menu'])
|
|
41
|
+
bindCommonAttributes(root, props, ['items', 'onSelect'])
|
|
42
|
+
if (!hasProp(props, 'role')) setAttribute(root, 'role', 'menu')
|
|
43
|
+
|
|
44
|
+
if (hasProp(props, 'items')) {
|
|
45
|
+
insertDynamic(root, null, () => createMenuItems(props))
|
|
46
|
+
}
|
|
47
|
+
if (hasProp(props, 'children')) mountSlot(root, props, 'children')
|
|
48
|
+
return root
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function MenuDivider(props: VuiCommonProps = {}): VobsNode {
|
|
52
|
+
const root = createElement('div')
|
|
53
|
+
bindClassList(root, props, () => ['vui-menu__divider'])
|
|
54
|
+
bindCommonAttributes(root, props)
|
|
55
|
+
setAttribute(root, 'role', 'separator')
|
|
56
|
+
return root
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createMenuItems(props: MenuProps): VobsNode {
|
|
60
|
+
const items = readProp<readonly MenuItem[]>(props, 'items', [])
|
|
61
|
+
return createFragment((parent, anchor) => {
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const node = item.divider === true
|
|
64
|
+
? createMenuDividerNode(item)
|
|
65
|
+
: createMenuItemNode(item, props)
|
|
66
|
+
insertBefore(parent, node, anchor)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createMenuDividerNode(item: MenuItem): VobsNode {
|
|
72
|
+
const divider = createElement('div')
|
|
73
|
+
setAttribute(divider, 'class', 'vui-menu__divider')
|
|
74
|
+
setAttribute(divider, 'role', 'separator')
|
|
75
|
+
setOptionalAttribute(divider, 'data-menu-item-id', item.id)
|
|
76
|
+
return divider
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createMenuItemNode(item: MenuItem, props: MenuProps): VobsNode {
|
|
80
|
+
const button = createElement('button')
|
|
81
|
+
setAttribute(button, 'class', `vui-menu__item${item.danger === true ? ' vui-menu__item--danger' : ''}`)
|
|
82
|
+
setAttribute(button, 'type', 'button')
|
|
83
|
+
setAttribute(button, 'role', 'menuitem')
|
|
84
|
+
setOptionalAttribute(button, 'data-menu-item-id', item.id)
|
|
85
|
+
if (item.disabled === true) {
|
|
86
|
+
setProperty(button, 'disabled', true)
|
|
87
|
+
setAttribute(button, 'aria-disabled', 'true')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (item.icon !== undefined) {
|
|
91
|
+
const icon = createElement('span')
|
|
92
|
+
setAttribute(icon, 'class', 'vui-menu__icon')
|
|
93
|
+
mountSlot(icon, item, 'icon')
|
|
94
|
+
insertBefore(button, icon, null)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (item.label !== undefined) {
|
|
98
|
+
const label = createElement('span')
|
|
99
|
+
setAttribute(label, 'class', 'vui-menu__label')
|
|
100
|
+
insertBefore(label, createText(item.label), null)
|
|
101
|
+
insertBefore(button, label, null)
|
|
102
|
+
} else if (item.content !== undefined) {
|
|
103
|
+
mountSlot(button, item, 'content')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (item.shortcut !== undefined) {
|
|
107
|
+
const shortcut = createElement('span')
|
|
108
|
+
setAttribute(shortcut, 'class', 'vui-menu__shortcut')
|
|
109
|
+
insertBefore(shortcut, createText(item.shortcut), null)
|
|
110
|
+
insertBefore(button, shortcut, null)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
addEventListener(button, 'click', event => {
|
|
114
|
+
if (item.disabled === true || item.id === undefined) return
|
|
115
|
+
const handler = readProp<unknown>(props, 'onSelect', undefined)
|
|
116
|
+
if (typeof handler === 'function') (handler as MenuProps['onSelect'])!(item.id, event as MouseEvent)
|
|
117
|
+
})
|
|
118
|
+
return button
|
|
119
|
+
}
|
package/src/nav-list.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
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 { VuiChildren, VuiCommonProps } from './types'
|
|
22
|
+
|
|
23
|
+
export interface NavItem {
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly label: string
|
|
26
|
+
readonly icon?: VobsNode | (() => VobsNode | null | undefined)
|
|
27
|
+
readonly badge?: VuiChildren
|
|
28
|
+
readonly href?: string
|
|
29
|
+
readonly target?: string
|
|
30
|
+
readonly rel?: string
|
|
31
|
+
readonly active?: boolean
|
|
32
|
+
readonly disabled?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NavGroup {
|
|
36
|
+
readonly id?: string
|
|
37
|
+
readonly title?: string
|
|
38
|
+
readonly items: readonly NavItem[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NavListProps extends VuiCommonProps {
|
|
42
|
+
readonly groups?: readonly NavGroup[]
|
|
43
|
+
readonly value?: string
|
|
44
|
+
readonly defaultValue?: string
|
|
45
|
+
readonly onChange?: (id: string, event: MouseEvent) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function NavList(props: NavListProps = {}): VobsNode {
|
|
49
|
+
const root = createElement('nav')
|
|
50
|
+
const internalValue = state<string | undefined>(undefined)
|
|
51
|
+
|
|
52
|
+
bindClassList(root, props, () => ['vui-navlist'])
|
|
53
|
+
bindCommonAttributes(root, props, ['groups', 'value', 'defaultValue', 'onChange'])
|
|
54
|
+
insertDynamic(root, null, () => createNavGroups(props, internalValue))
|
|
55
|
+
if (hasProp(props, 'children')) mountSlot(root, props, 'children')
|
|
56
|
+
return root
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createNavGroups(
|
|
60
|
+
props: NavListProps,
|
|
61
|
+
internalValue: { value: string | undefined }
|
|
62
|
+
): VobsNode {
|
|
63
|
+
const groups = readProp<readonly NavGroup[]>(props, 'groups', [])
|
|
64
|
+
const selected = activeId(props, groups, internalValue)
|
|
65
|
+
return createFragment((parent, anchor) => {
|
|
66
|
+
for (const group of groups) {
|
|
67
|
+
const groupRoot = createElement('div')
|
|
68
|
+
setAttribute(groupRoot, 'class', 'vui-navlist__group')
|
|
69
|
+
setOptionalAttribute(groupRoot, 'data-nav-group-id', group.id)
|
|
70
|
+
|
|
71
|
+
if (group.title !== undefined) {
|
|
72
|
+
const title = createElement('div')
|
|
73
|
+
setAttribute(title, 'class', 'vui-navlist__group-title')
|
|
74
|
+
insertBefore(title, createText(group.title), null)
|
|
75
|
+
insertBefore(groupRoot, title, null)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const item of group.items) {
|
|
79
|
+
insertBefore(groupRoot, createNavItem(item, props, selected, internalValue), null)
|
|
80
|
+
}
|
|
81
|
+
insertBefore(parent, groupRoot, anchor)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createNavItem(
|
|
87
|
+
item: NavItem,
|
|
88
|
+
props: NavListProps,
|
|
89
|
+
selected: string | undefined,
|
|
90
|
+
internalValue: { value: string | undefined }
|
|
91
|
+
): VobsNode {
|
|
92
|
+
const active = item.id === selected
|
|
93
|
+
const element = createElement(item.href === undefined ? 'button' : 'a')
|
|
94
|
+
setAttribute(element, 'class', `vui-navlist__item${active ? ' is-active' : ''}`)
|
|
95
|
+
if (item.href !== undefined) setAttribute(element, 'role', 'link')
|
|
96
|
+
setOptionalAttribute(element, 'data-nav-id', item.id)
|
|
97
|
+
if (item.href !== undefined) {
|
|
98
|
+
setAttribute(element, 'href', item.href)
|
|
99
|
+
setOptionalAttribute(element, 'target', item.target)
|
|
100
|
+
setOptionalAttribute(element, 'rel', item.rel)
|
|
101
|
+
} else {
|
|
102
|
+
setAttribute(element, 'type', 'button')
|
|
103
|
+
}
|
|
104
|
+
if (active) setAttribute(element, 'aria-current', 'page')
|
|
105
|
+
if (item.disabled === true) {
|
|
106
|
+
setAttribute(element, 'aria-disabled', 'true')
|
|
107
|
+
setProperty(element, 'tabIndex', -1)
|
|
108
|
+
if (item.href === undefined) setProperty(element, 'disabled', true)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (item.icon !== undefined) {
|
|
112
|
+
const icon = createElement('span')
|
|
113
|
+
setAttribute(icon, 'class', 'vui-navlist__icon')
|
|
114
|
+
mountSlot(icon, item, 'icon')
|
|
115
|
+
insertBefore(element, icon, null)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const label = createElement('span')
|
|
119
|
+
setAttribute(label, 'class', 'vui-navlist__label')
|
|
120
|
+
insertBefore(label, createText(item.label), null)
|
|
121
|
+
insertBefore(element, label, null)
|
|
122
|
+
|
|
123
|
+
if (item.badge !== undefined) {
|
|
124
|
+
const badge = createElement('span')
|
|
125
|
+
setAttribute(badge, 'class', 'vui-navlist__badge')
|
|
126
|
+
mountSlot(badge, item, 'badge')
|
|
127
|
+
insertBefore(element, badge, null)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addEventListener(element, 'click', event => {
|
|
131
|
+
if (item.disabled === true) {
|
|
132
|
+
event.preventDefault?.()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (readProp<string | undefined>(props, 'value', undefined) === undefined) internalValue.value = item.id
|
|
136
|
+
const handler = readProp<unknown>(props, 'onChange', undefined)
|
|
137
|
+
if (typeof handler === 'function') (handler as NavListProps['onChange'])!(item.id, event as MouseEvent)
|
|
138
|
+
})
|
|
139
|
+
return element
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function activeId(
|
|
143
|
+
props: NavListProps,
|
|
144
|
+
groups: readonly NavGroup[],
|
|
145
|
+
internalValue: { value: string | undefined }
|
|
146
|
+
): string | undefined {
|
|
147
|
+
const controlled = readProp<string | undefined>(props, 'value', undefined)
|
|
148
|
+
if (controlled !== undefined) return controlled
|
|
149
|
+
if (internalValue.value !== undefined) return internalValue.value
|
|
150
|
+
const defaultValue = readProp<string | undefined>(props, 'defaultValue', undefined)
|
|
151
|
+
if (defaultValue !== undefined) return defaultValue
|
|
152
|
+
for (const group of groups) {
|
|
153
|
+
const active = group.items.find(item => item.active === true)
|
|
154
|
+
if (active) return active.id
|
|
155
|
+
}
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { createComponent, createDOMRenderer, createVobs, setRenderer } from '@vobs/vobs'
|
|
3
|
+
import { Drawer } from './drawer'
|
|
4
|
+
import { createDOMPortalAdapter, createFocusTrap, createOverlayManager } from './overlay'
|
|
5
|
+
|
|
6
|
+
describe('@vobs/ui overlay protocol', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
setRenderer(createDOMRenderer())
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('portal adapter mounts outside the component placeholder and cleans up on destroy', () => {
|
|
12
|
+
const container = document.createElement('main')
|
|
13
|
+
const portalTarget = document.createElement('aside')
|
|
14
|
+
const app = createVobs({
|
|
15
|
+
render: () => createComponent(Drawer, {
|
|
16
|
+
open: true,
|
|
17
|
+
title: 'Details',
|
|
18
|
+
portal: createDOMPortalAdapter(),
|
|
19
|
+
portalTarget,
|
|
20
|
+
children: 'Portal body'
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
app.mount(container)
|
|
24
|
+
|
|
25
|
+
expect(container.querySelector('.vui-drawer')).toBeNull()
|
|
26
|
+
expect(portalTarget.querySelector('.vui-drawer')?.textContent).toContain('Portal body')
|
|
27
|
+
app.destroy()
|
|
28
|
+
expect(portalTarget.querySelector('.vui-drawer')).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('focus trap cycles Tab and restores the previously focused element', () => {
|
|
32
|
+
const outside = document.createElement('button')
|
|
33
|
+
const root = document.createElement('div')
|
|
34
|
+
const first = document.createElement('button')
|
|
35
|
+
const last = document.createElement('button')
|
|
36
|
+
root.append(first, last)
|
|
37
|
+
document.body.append(outside, root)
|
|
38
|
+
outside.focus()
|
|
39
|
+
|
|
40
|
+
const trap = createFocusTrap(root)
|
|
41
|
+
trap.activate()
|
|
42
|
+
expect(document.activeElement).toBe(first)
|
|
43
|
+
last.focus()
|
|
44
|
+
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
|
|
45
|
+
expect(document.activeElement).toBe(first)
|
|
46
|
+
trap.deactivate()
|
|
47
|
+
expect(document.activeElement).toBe(outside)
|
|
48
|
+
trap.dispose()
|
|
49
|
+
root.remove()
|
|
50
|
+
outside.remove()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('overlay manager preserves stack order and notifies subscribers', () => {
|
|
54
|
+
const manager = createOverlayManager()
|
|
55
|
+
const snapshots: string[][] = []
|
|
56
|
+
const stop = manager.subscribe(entries => snapshots.push(entries.map(entry => entry.id)))
|
|
57
|
+
manager.open({ id: 'dialog', modal: true })
|
|
58
|
+
manager.open({ id: 'menu' })
|
|
59
|
+
manager.bringToFront('dialog')
|
|
60
|
+
manager.close('menu')
|
|
61
|
+
stop()
|
|
62
|
+
|
|
63
|
+
expect(manager.entries.map(entry => entry.id)).toEqual(['dialog'])
|
|
64
|
+
expect(snapshots).toEqual([[], ['dialog'], ['dialog', 'menu'], ['menu', 'dialog'], ['dialog']])
|
|
65
|
+
})
|
|
66
|
+
})
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { getCurrentOwner, onDispose } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
createComment,
|
|
4
|
+
insertBefore,
|
|
5
|
+
isVobsFragment,
|
|
6
|
+
type VobsNode
|
|
7
|
+
} from '@vobs/vobs'
|
|
8
|
+
|
|
9
|
+
export interface VuiPortalAdapter<Target = unknown> {
|
|
10
|
+
mount(node: VobsNode, target: Target): void
|
|
11
|
+
unmount(node: VobsNode, target: Target): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createPortal<Target>(
|
|
15
|
+
node: VobsNode,
|
|
16
|
+
adapter: VuiPortalAdapter<Target>,
|
|
17
|
+
target: Target
|
|
18
|
+
): VobsNode {
|
|
19
|
+
const placeholder = createComment('vui:portal')
|
|
20
|
+
const owner = getCurrentOwner()
|
|
21
|
+
if (!owner) throw new Error('Vobs UI: createPortal 必须在组件 Owner 作用域内调用')
|
|
22
|
+
adapter.mount(node, target)
|
|
23
|
+
const cleanup = (): void => adapter.unmount(node, target)
|
|
24
|
+
owner.onDispose(cleanup)
|
|
25
|
+
return placeholder
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createDOMPortalAdapter(): VuiPortalAdapter<Node> {
|
|
29
|
+
return {
|
|
30
|
+
mount(node, target): void {
|
|
31
|
+
insertBefore(target, node, null)
|
|
32
|
+
},
|
|
33
|
+
unmount(node, target): void {
|
|
34
|
+
removePortalNode(node, target)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FocusTrapOptions {
|
|
40
|
+
readonly initialFocus?: HTMLElement | (() => HTMLElement | null)
|
|
41
|
+
readonly restoreFocus?: boolean
|
|
42
|
+
readonly onEscape?: (event: KeyboardEvent) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FocusTrapHandle {
|
|
46
|
+
readonly active: boolean
|
|
47
|
+
activate(): void
|
|
48
|
+
deactivate(): void
|
|
49
|
+
dispose(): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createFocusTrap(
|
|
53
|
+
root: HTMLElement,
|
|
54
|
+
options: FocusTrapOptions = {}
|
|
55
|
+
): FocusTrapHandle {
|
|
56
|
+
let isActive = false
|
|
57
|
+
let disposed = false
|
|
58
|
+
let previouslyFocused: Element | null = null
|
|
59
|
+
|
|
60
|
+
const focusable = (): HTMLElement[] => [...root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)]
|
|
61
|
+
.filter(element => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true')
|
|
62
|
+
|
|
63
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
64
|
+
if (!isActive || disposed) return
|
|
65
|
+
if (event.key === 'Escape') {
|
|
66
|
+
options.onEscape?.(event)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (event.key !== 'Tab') return
|
|
70
|
+
const elements = focusable()
|
|
71
|
+
if (elements.length === 0) {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
root.focus()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const first = elements[0]
|
|
77
|
+
const last = elements[elements.length - 1]
|
|
78
|
+
const current = document.activeElement
|
|
79
|
+
if (event.shiftKey && current === first) {
|
|
80
|
+
event.preventDefault()
|
|
81
|
+
last.focus()
|
|
82
|
+
} else if (!event.shiftKey && current === last) {
|
|
83
|
+
event.preventDefault()
|
|
84
|
+
first.focus()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const activate = (): void => {
|
|
89
|
+
if (disposed || isActive) return
|
|
90
|
+
isActive = true
|
|
91
|
+
previouslyFocused = document.activeElement
|
|
92
|
+
root.addEventListener('keydown', onKeyDown)
|
|
93
|
+
const initial = resolveInitialFocus(options.initialFocus) ?? focusable()[0] ?? root
|
|
94
|
+
if (!root.hasAttribute('tabindex') && initial === root) root.setAttribute('tabindex', '-1')
|
|
95
|
+
initial.focus()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const deactivate = (): void => {
|
|
99
|
+
if (!isActive) return
|
|
100
|
+
isActive = false
|
|
101
|
+
root.removeEventListener('keydown', onKeyDown)
|
|
102
|
+
if (options.restoreFocus !== false
|
|
103
|
+
&& typeof HTMLElement !== 'undefined'
|
|
104
|
+
&& previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
|
|
105
|
+
previouslyFocused = null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handle: FocusTrapHandle = {
|
|
109
|
+
get active(): boolean { return isActive },
|
|
110
|
+
activate,
|
|
111
|
+
deactivate,
|
|
112
|
+
dispose(): void {
|
|
113
|
+
if (disposed) return
|
|
114
|
+
deactivate()
|
|
115
|
+
disposed = true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (getCurrentOwner()) onDispose(() => handle.dispose())
|
|
119
|
+
return handle
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface OverlayEntry {
|
|
123
|
+
readonly id: string
|
|
124
|
+
readonly modal?: boolean
|
|
125
|
+
readonly closeOnEscape?: boolean
|
|
126
|
+
readonly onEscape?: () => void
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface OverlayManager {
|
|
130
|
+
readonly entries: readonly OverlayEntry[]
|
|
131
|
+
open(entry: OverlayEntry): void
|
|
132
|
+
close(id: string): void
|
|
133
|
+
bringToFront(id: string): void
|
|
134
|
+
subscribe(listener: (entries: readonly OverlayEntry[]) => void): () => void
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createOverlayManager(): OverlayManager {
|
|
138
|
+
let entries: OverlayEntry[] = []
|
|
139
|
+
const listeners = new Set<(entries: readonly OverlayEntry[]) => void>()
|
|
140
|
+
const notify = (): void => {
|
|
141
|
+
const snapshot = entries.slice()
|
|
142
|
+
for (const listener of listeners) listener(snapshot)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
get entries(): readonly OverlayEntry[] { return entries },
|
|
147
|
+
open(entry): void {
|
|
148
|
+
entries = [...entries.filter(current => current.id !== entry.id), entry]
|
|
149
|
+
notify()
|
|
150
|
+
},
|
|
151
|
+
close(id): void {
|
|
152
|
+
const next = entries.filter(entry => entry.id !== id)
|
|
153
|
+
if (next.length === entries.length) return
|
|
154
|
+
entries = next
|
|
155
|
+
notify()
|
|
156
|
+
},
|
|
157
|
+
bringToFront(id): void {
|
|
158
|
+
const entry = entries.find(current => current.id === id)
|
|
159
|
+
if (!entry || entries[entries.length - 1] === entry) return
|
|
160
|
+
entries = [...entries.filter(current => current !== entry), entry]
|
|
161
|
+
notify()
|
|
162
|
+
},
|
|
163
|
+
subscribe(listener): () => void {
|
|
164
|
+
listeners.add(listener)
|
|
165
|
+
listener(entries.slice())
|
|
166
|
+
return () => listeners.delete(listener)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveInitialFocus(
|
|
172
|
+
target: FocusTrapOptions['initialFocus']
|
|
173
|
+
): HTMLElement | null {
|
|
174
|
+
if (!target) return null
|
|
175
|
+
return typeof target === 'function' ? target() : target
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function removePortalNode(node: VobsNode, target: Node): void {
|
|
179
|
+
if (isVobsFragment(node)) {
|
|
180
|
+
let current = node.start.nextSibling
|
|
181
|
+
while (current && current !== node.end) {
|
|
182
|
+
const next = current.nextSibling
|
|
183
|
+
target.removeChild(current)
|
|
184
|
+
current = next
|
|
185
|
+
}
|
|
186
|
+
if (node.start.parentNode === target) target.removeChild(node.start)
|
|
187
|
+
if (node.end.parentNode === target) target.removeChild(node.end)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (node.parentNode === target) target.removeChild(node)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const FOCUSABLE_SELECTOR = [
|
|
194
|
+
'a[href]',
|
|
195
|
+
'area[href]',
|
|
196
|
+
'button',
|
|
197
|
+
'input',
|
|
198
|
+
'select',
|
|
199
|
+
'textarea',
|
|
200
|
+
'iframe',
|
|
201
|
+
'object',
|
|
202
|
+
'embed',
|
|
203
|
+
'[contenteditable]',
|
|
204
|
+
'[tabindex]'
|
|
205
|
+
].join(',')
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { effect } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
addEventListener,
|
|
4
|
+
createElement,
|
|
5
|
+
createText,
|
|
6
|
+
insertBefore,
|
|
7
|
+
setAttribute,
|
|
8
|
+
setProperty,
|
|
9
|
+
type VobsNode
|
|
10
|
+
} from '@vobs/vobs'
|
|
11
|
+
import {
|
|
12
|
+
bindClassList,
|
|
13
|
+
bindCommonAttributes,
|
|
14
|
+
bindTextContent,
|
|
15
|
+
bindUserStyle,
|
|
16
|
+
hasProp,
|
|
17
|
+
mountSlot,
|
|
18
|
+
readProp,
|
|
19
|
+
setOptionalAttribute
|
|
20
|
+
} from './utils'
|
|
21
|
+
import type { VuiChildren, VuiCommonProps } from './types'
|
|
22
|
+
|
|
23
|
+
export interface PageHeaderProps extends VuiCommonProps {
|
|
24
|
+
readonly title?: string
|
|
25
|
+
readonly subtitle?: string
|
|
26
|
+
readonly actions?: VuiChildren
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function PageHeader(props: PageHeaderProps = {}): VobsNode {
|
|
30
|
+
const root = createElement('header')
|
|
31
|
+
const main = createElement('div')
|
|
32
|
+
bindClassList(root, props, () => ['vui-pagehead'])
|
|
33
|
+
bindCommonAttributes(root, props, ['title', 'subtitle', 'actions'])
|
|
34
|
+
bindUserStyle(root, props)
|
|
35
|
+
|
|
36
|
+
setAttribute(main, 'class', 'vui-pagehead__main')
|
|
37
|
+
if (hasProp(props, 'title')) {
|
|
38
|
+
const title = createElement('h1')
|
|
39
|
+
const text = createText('')
|
|
40
|
+
setAttribute(title, 'class', 'vui-pagehead__title')
|
|
41
|
+
bindTextContent(text, () => readProp(props, 'title', ''))
|
|
42
|
+
insertBefore(title, text, null)
|
|
43
|
+
insertBefore(main, title, null)
|
|
44
|
+
}
|
|
45
|
+
if (hasProp(props, 'subtitle')) {
|
|
46
|
+
const subtitle = createElement('p')
|
|
47
|
+
const text = createText('')
|
|
48
|
+
setAttribute(subtitle, 'class', 'vui-pagehead__subtitle')
|
|
49
|
+
bindTextContent(text, () => readProp(props, 'subtitle', ''))
|
|
50
|
+
insertBefore(subtitle, text, null)
|
|
51
|
+
insertBefore(main, subtitle, null)
|
|
52
|
+
}
|
|
53
|
+
if (hasProp(props, 'children')) mountSlot(main, props, 'children')
|
|
54
|
+
insertBefore(root, main, null)
|
|
55
|
+
|
|
56
|
+
if (hasProp(props, 'actions')) {
|
|
57
|
+
const actions = createElement('div')
|
|
58
|
+
setAttribute(actions, 'class', 'vui-pagehead__actions')
|
|
59
|
+
mountSlot(actions, props, 'actions')
|
|
60
|
+
insertBefore(root, actions, null)
|
|
61
|
+
}
|
|
62
|
+
return root
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PageHeaderActionProps extends VuiCommonProps {
|
|
66
|
+
readonly href?: string
|
|
67
|
+
readonly primary?: boolean
|
|
68
|
+
readonly disabled?: boolean
|
|
69
|
+
readonly onClick?: (event: MouseEvent) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function PageHeaderAction(props: PageHeaderActionProps = {}): VobsNode {
|
|
73
|
+
const root = createElement(props.href === undefined ? 'button' : 'a')
|
|
74
|
+
bindClassList(root, props, () => [
|
|
75
|
+
'vui-pagehead__btn',
|
|
76
|
+
readProp(props, 'primary', false) ? 'is-primary' : undefined
|
|
77
|
+
])
|
|
78
|
+
bindCommonAttributes(root, props, ['href', 'primary', 'disabled', 'onClick'])
|
|
79
|
+
if (props.href === undefined) setAttribute(root, 'type', 'button')
|
|
80
|
+
else setAttribute(root, 'href', props.href)
|
|
81
|
+
effect(() => {
|
|
82
|
+
const disabled = readProp(props, 'disabled', false)
|
|
83
|
+
setOptionalAttribute(root, 'aria-disabled', disabled ? 'true' : undefined)
|
|
84
|
+
if (props.href === undefined) setProperty(root, 'disabled', disabled)
|
|
85
|
+
})
|
|
86
|
+
if (hasProp(props, 'children')) mountSlot(root, props, 'children')
|
|
87
|
+
|
|
88
|
+
addEventListener(root, 'click', event => {
|
|
89
|
+
if (readProp(props, 'disabled', false)) {
|
|
90
|
+
event.preventDefault?.()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const handler = readProp<unknown>(props, 'onClick', undefined)
|
|
94
|
+
if (typeof handler === 'function') (handler as PageHeaderActionProps['onClick'])!(event as MouseEvent)
|
|
95
|
+
})
|
|
96
|
+
return root
|
|
97
|
+
}
|