@vobs/ui 0.3.0 → 1.1.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.
Files changed (212) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +60 -0
  3. package/package.json +52 -110
  4. package/src/activity-rail.ts +125 -0
  5. package/src/alert.ts +65 -0
  6. package/src/assets/icons/alert-circle.svg +1 -0
  7. package/src/assets/icons/alert-triangle.svg +1 -0
  8. package/src/assets/icons/alert.d27e24.svg +1 -0
  9. package/src/assets/icons/alert.svg +1 -0
  10. package/src/assets/icons/apple.svg +1 -0
  11. package/src/assets/icons/arrow-down.svg +1 -0
  12. package/src/assets/icons/arrow-expand.svg +1 -0
  13. package/src/assets/icons/arrow-left.svg +1 -0
  14. package/src/assets/icons/arrow-minimize.svg +1 -0
  15. package/src/assets/icons/arrow-right-to-line.svg +1 -0
  16. package/src/assets/icons/arrow-right.svg +1 -0
  17. package/src/assets/icons/arrow-up-right.svg +1 -0
  18. package/src/assets/icons/arrow-up.0c0c0d.svg +1 -0
  19. package/src/assets/icons/arrow-up.svg +1 -0
  20. package/src/assets/icons/at.svg +1 -0
  21. package/src/assets/icons/atom.0c0c0d.svg +1 -0
  22. package/src/assets/icons/atom.svg +1 -0
  23. package/src/assets/icons/bar.svg +1 -0
  24. package/src/assets/icons/bell.svg +1 -0
  25. package/src/assets/icons/box.svg +1 -0
  26. package/src/assets/icons/bug.svg +1 -0
  27. package/src/assets/icons/calendar.svg +1 -0
  28. package/src/assets/icons/check-circle.33c192.svg +1 -0
  29. package/src/assets/icons/check-circle.svg +1 -0
  30. package/src/assets/icons/check.d1d3db.svg +1 -0
  31. package/src/assets/icons/check.svg +1 -0
  32. package/src/assets/icons/chevron-down.svg +1 -0
  33. package/src/assets/icons/chevron-right.svg +1 -0
  34. package/src/assets/icons/chevron-up.svg +1 -0
  35. package/src/assets/icons/circle.svg +1 -0
  36. package/src/assets/icons/clock.svg +1 -0
  37. package/src/assets/icons/cmd.svg +1 -0
  38. package/src/assets/icons/code.svg +1 -0
  39. package/src/assets/icons/columns.svg +1 -0
  40. package/src/assets/icons/copy.svg +1 -0
  41. package/src/assets/icons/cpu.svg +1 -0
  42. package/src/assets/icons/dashboard.0c0c0d.svg +1 -0
  43. package/src/assets/icons/dashboard.svg +1 -0
  44. package/src/assets/icons/dollar.svg +1 -0
  45. package/src/assets/icons/download.svg +1 -0
  46. package/src/assets/icons/edit.svg +1 -0
  47. package/src/assets/icons/extensions.svg +1 -0
  48. package/src/assets/icons/external.svg +1 -0
  49. package/src/assets/icons/eye.svg +1 -0
  50. package/src/assets/icons/file-text.svg +1 -0
  51. package/src/assets/icons/file.svg +1 -0
  52. package/src/assets/icons/files.svg +1 -0
  53. package/src/assets/icons/filter.svg +1 -0
  54. package/src/assets/icons/folder.svg +1 -0
  55. package/src/assets/icons/gear.svg +1 -0
  56. package/src/assets/icons/git.svg +1 -0
  57. package/src/assets/icons/github.svg +1 -0
  58. package/src/assets/icons/globe.svg +1 -0
  59. package/src/assets/icons/grid-2x2.svg +1 -0
  60. package/src/assets/icons/hash.svg +1 -0
  61. package/src/assets/icons/heart.svg +1 -0
  62. package/src/assets/icons/help.svg +1 -0
  63. package/src/assets/icons/home.svg +1 -0
  64. package/src/assets/icons/image.svg +1 -0
  65. package/src/assets/icons/info-circle.svg +1 -0
  66. package/src/assets/icons/info-square.svg +1 -0
  67. package/src/assets/icons/info.387bff.svg +1 -0
  68. package/src/assets/icons/info.svg +1 -0
  69. package/src/assets/icons/key.svg +1 -0
  70. package/src/assets/icons/layers.svg +1 -0
  71. package/src/assets/icons/layout.svg +1 -0
  72. package/src/assets/icons/link.svg +1 -0
  73. package/src/assets/icons/list.svg +1 -0
  74. package/src/assets/icons/lock.svg +1 -0
  75. package/src/assets/icons/log-out.svg +1 -0
  76. package/src/assets/icons/logo.svg +1 -0
  77. package/src/assets/icons/mail.svg +1 -0
  78. package/src/assets/icons/menu.svg +1 -0
  79. package/src/assets/icons/message-circle.svg +1 -0
  80. package/src/assets/icons/message-plus.svg +1 -0
  81. package/src/assets/icons/message-square.svg +1 -0
  82. package/src/assets/icons/mic.svg +1 -0
  83. package/src/assets/icons/minus.svg +1 -0
  84. package/src/assets/icons/moon.svg +1 -0
  85. package/src/assets/icons/more-h.svg +1 -0
  86. package/src/assets/icons/mouse.svg +1 -0
  87. package/src/assets/icons/palette.svg +1 -0
  88. package/src/assets/icons/panel-bottom.svg +1 -0
  89. package/src/assets/icons/pause.svg +1 -0
  90. package/src/assets/icons/play.svg +1 -0
  91. package/src/assets/icons/plug.svg +1 -0
  92. package/src/assets/icons/plus.0c0c0d.svg +1 -0
  93. package/src/assets/icons/plus.svg +1 -0
  94. package/src/assets/icons/refresh.svg +1 -0
  95. package/src/assets/icons/ruler.svg +1 -0
  96. package/src/assets/icons/scroll-text.svg +1 -0
  97. package/src/assets/icons/search-menu.svg +1 -0
  98. package/src/assets/icons/search.svg +1 -0
  99. package/src/assets/icons/send.0c0c0d.svg +1 -0
  100. package/src/assets/icons/send.svg +1 -0
  101. package/src/assets/icons/settings.0c0c0d.svg +1 -0
  102. package/src/assets/icons/settings.svg +1 -0
  103. package/src/assets/icons/shield.svg +1 -0
  104. package/src/assets/icons/sidebar-left.svg +1 -0
  105. package/src/assets/icons/sidebar-right.svg +1 -0
  106. package/src/assets/icons/sliders.svg +1 -0
  107. package/src/assets/icons/sparkles.32f08c.svg +1 -0
  108. package/src/assets/icons/sparkles.svg +1 -0
  109. package/src/assets/icons/square.svg +1 -0
  110. package/src/assets/icons/star.svg +1 -0
  111. package/src/assets/icons/sun.svg +1 -0
  112. package/src/assets/icons/terminal.svg +1 -0
  113. package/src/assets/icons/trash.svg +1 -0
  114. package/src/assets/icons/trending-down.svg +1 -0
  115. package/src/assets/icons/trending-up.svg +1 -0
  116. package/src/assets/icons/type.svg +1 -0
  117. package/src/assets/icons/upload.svg +1 -0
  118. package/src/assets/icons/user-circle.svg +1 -0
  119. package/src/assets/icons/user.svg +1 -0
  120. package/src/assets/icons/users.svg +1 -0
  121. package/src/assets/icons/wrench.svg +1 -0
  122. package/src/assets/icons/x-circle.svg +1 -0
  123. package/src/assets/icons/x.svg +1 -0
  124. package/src/assets/icons/zap.0c0c0d.svg +1 -0
  125. package/src/assets/icons/zap.svg +1 -0
  126. package/src/avatar.ts +64 -0
  127. package/src/button.ts +88 -0
  128. package/src/card.ts +59 -0
  129. package/src/chat-composer.ts +156 -0
  130. package/src/dialog.ts +127 -0
  131. package/src/drawer.ts +131 -0
  132. package/src/editor-tabs.ts +184 -0
  133. package/src/file-tree.ts +214 -0
  134. package/src/forms.ts +270 -0
  135. package/src/icon.ts +118 -0
  136. package/src/icons.js +165 -0
  137. package/src/index.test.ts +213 -0
  138. package/src/index.ts +94 -0
  139. package/src/kbd.ts +72 -0
  140. package/src/menu.ts +119 -0
  141. package/src/nav-list.ts +157 -0
  142. package/src/overlay.test.ts +66 -0
  143. package/src/overlay.ts +205 -0
  144. package/src/page-header.ts +97 -0
  145. package/src/pagination.ts +163 -0
  146. package/src/second-batch.test.ts +205 -0
  147. package/src/stat-card.ts +92 -0
  148. package/src/status-bar.ts +108 -0
  149. package/src/styles/base.css +240 -0
  150. package/src/styles/components.css +1234 -0
  151. package/{dist → src/styles}/styles.css +2 -0
  152. package/src/styles/toast.css +57 -0
  153. package/src/styles/tokens.css +438 -0
  154. package/src/styles/vui.css +47 -0
  155. package/src/table.ts +191 -0
  156. package/src/tabs.ts +100 -0
  157. package/src/tag.ts +33 -0
  158. package/src/third-batch.test.ts +137 -0
  159. package/src/toast.test.ts +55 -0
  160. package/src/toast.ts +79 -0
  161. package/src/types.ts +25 -0
  162. package/src/utils.ts +221 -0
  163. package/src/workbench-titlebar.ts +137 -0
  164. package/dist/app-shell-dom.d.ts +0 -30
  165. package/dist/app-shell-dom.js +0 -913
  166. package/dist/app-shell.d.ts +0 -235
  167. package/dist/app-shell.js +0 -415
  168. package/dist/base.css +0 -51
  169. package/dist/calendar-picker.d.ts +0 -45
  170. package/dist/calendar-picker.js +0 -217
  171. package/dist/calendar.d.ts +0 -95
  172. package/dist/calendar.js +0 -194
  173. package/dist/complex-inputs.d.ts +0 -300
  174. package/dist/complex-inputs.js +0 -866
  175. package/dist/components.css +0 -4756
  176. package/dist/data-display.d.ts +0 -1094
  177. package/dist/data-display.js +0 -2341
  178. package/dist/dialog.d.ts +0 -26
  179. package/dist/dialog.js +0 -94
  180. package/dist/feedback.d.ts +0 -88
  181. package/dist/feedback.js +0 -281
  182. package/dist/forms.d.ts +0 -378
  183. package/dist/forms.js +0 -1019
  184. package/dist/icons-config.d.ts +0 -25
  185. package/dist/icons-config.js +0 -14
  186. package/dist/icons.d.ts +0 -41
  187. package/dist/icons.js +0 -185
  188. package/dist/index.d.ts +0 -31
  189. package/dist/index.js +0 -40
  190. package/dist/locale/en-US.d.ts +0 -31
  191. package/dist/locale/en-US.js +0 -20
  192. package/dist/locale/zh-CN.d.ts +0 -31
  193. package/dist/locale/zh-CN.js +0 -20
  194. package/dist/navigation.d.ts +0 -609
  195. package/dist/navigation.js +0 -1707
  196. package/dist/overlay.d.ts +0 -308
  197. package/dist/overlay.js +0 -974
  198. package/dist/page-state.d.ts +0 -23
  199. package/dist/page-state.js +0 -96
  200. package/dist/primitives.d.ts +0 -268
  201. package/dist/primitives.js +0 -764
  202. package/dist/theme-data.d.ts +0 -394
  203. package/dist/theme-data.js +0 -417
  204. package/dist/theme.d.ts +0 -97
  205. package/dist/theme.js +0 -301
  206. package/dist/tokens.css +0 -570
  207. package/dist/tree.d.ts +0 -144
  208. package/dist/tree.js +0 -475
  209. package/dist/virtual-list.d.ts +0 -190
  210. package/dist/virtual-list.js +0 -538
  211. package/dist/workbench.d.ts +0 -136
  212. 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
+ }
@@ -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
+ }