@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.
Files changed (208) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +60 -0
  3. package/package.json +52 -102
  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 +117 -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 -24
  165. package/dist/app-shell-dom.js +0 -573
  166. package/dist/app-shell.d.ts +0 -199
  167. package/dist/app-shell.js +0 -370
  168. package/dist/base.css +0 -52
  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 -862
  175. package/dist/components.css +0 -4568
  176. package/dist/data-display.d.ts +0 -1063
  177. package/dist/data-display.js +0 -2298
  178. package/dist/feedback.d.ts +0 -88
  179. package/dist/feedback.js +0 -281
  180. package/dist/forms.d.ts +0 -378
  181. package/dist/forms.js +0 -1015
  182. package/dist/icons-config.d.ts +0 -25
  183. package/dist/icons-config.js +0 -14
  184. package/dist/icons.d.ts +0 -41
  185. package/dist/icons.js +0 -185
  186. package/dist/index.d.ts +0 -27
  187. package/dist/index.js +0 -34
  188. package/dist/locale/en-US.d.ts +0 -31
  189. package/dist/locale/en-US.js +0 -20
  190. package/dist/locale/zh-CN.d.ts +0 -31
  191. package/dist/locale/zh-CN.js +0 -20
  192. package/dist/navigation.d.ts +0 -609
  193. package/dist/navigation.js +0 -1707
  194. package/dist/overlay.d.ts +0 -304
  195. package/dist/overlay.js +0 -969
  196. package/dist/primitives.d.ts +0 -268
  197. package/dist/primitives.js +0 -764
  198. package/dist/theme-data.d.ts +0 -363
  199. package/dist/theme-data.js +0 -374
  200. package/dist/theme.d.ts +0 -79
  201. package/dist/theme.js +0 -262
  202. package/dist/tokens.css +0 -449
  203. package/dist/tree.d.ts +0 -144
  204. package/dist/tree.js +0 -469
  205. package/dist/virtual-list.d.ts +0 -190
  206. package/dist/virtual-list.js +0 -521
  207. package/dist/workbench.d.ts +0 -136
  208. package/dist/workbench.js +0 -156
@@ -0,0 +1,163 @@
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
+ resolveSlot,
19
+ setOptionalAttribute
20
+ } from './utils'
21
+ import type { VuiChildren, VuiCommonProps } from './types'
22
+
23
+ export interface PaginationProps extends VuiCommonProps {
24
+ readonly page?: number
25
+ readonly pageCount?: number
26
+ readonly siblingCount?: number
27
+ readonly disabled?: boolean
28
+ readonly previousLabel?: string
29
+ readonly nextLabel?: string
30
+ readonly previousIcon?: VuiChildren
31
+ readonly nextIcon?: VuiChildren
32
+ readonly onChange?: (page: number) => void
33
+ }
34
+
35
+ type PaginationItem = number | 'ellipsis-left' | 'ellipsis-right'
36
+
37
+ export function Pagination(props: PaginationProps = {}): VobsNode {
38
+ const root = createElement('nav')
39
+ bindClassList(root, props, () => ['vui-pagination'])
40
+ bindCommonAttributes(root, props, [
41
+ 'page',
42
+ 'pageCount',
43
+ 'siblingCount',
44
+ 'disabled',
45
+ 'previousLabel',
46
+ 'nextLabel',
47
+ 'previousIcon',
48
+ 'nextIcon',
49
+ 'onChange'
50
+ ])
51
+ if (!hasProp(props, 'aria-label')) setAttribute(root, 'aria-label', 'Pagination')
52
+ insertDynamic(root, null, () => createPaginationItems(props))
53
+ if (hasProp(props, 'children')) mountSlot(root, props, 'children')
54
+ return root
55
+ }
56
+
57
+ function createPaginationItems(props: PaginationProps): VobsNode {
58
+ const pageCount = Math.max(0, Math.floor(readProp(props, 'pageCount', 0)))
59
+ const page = clampPage(readProp(props, 'page', 1), pageCount)
60
+ const siblingCount = Math.max(0, Math.floor(readProp(props, 'siblingCount', 1)))
61
+ const disabled = readProp(props, 'disabled', false)
62
+ const items = pageCount === 0 ? [] : buildPaginationItems(page, pageCount, siblingCount)
63
+
64
+ return createFragment((parent, anchor) => {
65
+ insertBefore(parent, createPaginationControl(props, 'previous', page <= 1 || disabled), anchor)
66
+ for (const item of items) {
67
+ if (typeof item === 'number') {
68
+ insertBefore(parent, createPageButton(item, item === page, disabled, props), anchor)
69
+ } else {
70
+ insertBefore(parent, createEllipsis(item), anchor)
71
+ }
72
+ }
73
+ insertBefore(parent, createPaginationControl(props, 'next', page >= pageCount || pageCount === 0 || disabled), anchor)
74
+ })
75
+ }
76
+
77
+ function createPaginationControl(
78
+ props: PaginationProps,
79
+ direction: 'previous' | 'next',
80
+ disabled: boolean
81
+ ): VobsNode {
82
+ const button = createElement('button')
83
+ setAttribute(button, 'class', 'vui-pagination__item')
84
+ setAttribute(button, 'type', 'button')
85
+ setProperty(button, 'disabled', disabled)
86
+ setOptionalAttribute(button, 'aria-label', direction === 'previous'
87
+ ? readProp(props, 'previousLabel', 'Previous')
88
+ : readProp(props, 'nextLabel', 'Next'))
89
+ if (disabled) setAttribute(button, 'aria-disabled', 'true')
90
+
91
+ const icon = direction === 'previous'
92
+ ? readProp<VuiChildren | undefined>(props, 'previousIcon', undefined)
93
+ : readProp<VuiChildren | undefined>(props, 'nextIcon', undefined)
94
+ const fallback = direction === 'previous' ? '<' : '>'
95
+ const node = icon === undefined ? createText(fallback) : resolveSlot(icon)
96
+ if (node) insertBefore(button, node, null)
97
+
98
+ addEventListener(button, 'click', () => {
99
+ if (disabled) return
100
+ const current = readProp(props, 'page', 1)
101
+ const nextPage = direction === 'previous' ? current - 1 : current + 1
102
+ emitPageChange(props, clampPage(nextPage, readProp(props, 'pageCount', 0)))
103
+ })
104
+ return button
105
+ }
106
+
107
+ function createPageButton(
108
+ page: number,
109
+ active: boolean,
110
+ disabled: boolean,
111
+ props: PaginationProps
112
+ ): VobsNode {
113
+ const button = createElement('button')
114
+ setAttribute(button, 'class', `vui-pagination__item${active ? ' is-active' : ''}`)
115
+ setAttribute(button, 'type', 'button')
116
+ setProperty(button, 'disabled', disabled)
117
+ setAttribute(button, 'aria-label', `Page ${page}`)
118
+ if (active) setAttribute(button, 'aria-current', 'page')
119
+ if (disabled) setAttribute(button, 'aria-disabled', 'true')
120
+ insertBefore(button, createText(String(page)), null)
121
+ addEventListener(button, 'click', () => {
122
+ if (!disabled) emitPageChange(props, page)
123
+ })
124
+ return button
125
+ }
126
+
127
+ function createEllipsis(item: 'ellipsis-left' | 'ellipsis-right'): VobsNode {
128
+ const button = createElement('button')
129
+ setAttribute(button, 'class', 'vui-pagination__item')
130
+ setAttribute(button, 'type', 'button')
131
+ setProperty(button, 'disabled', true)
132
+ setAttribute(button, 'aria-label', item === 'ellipsis-left' ? 'Previous pages' : 'Next pages')
133
+ setAttribute(button, 'aria-hidden', 'true')
134
+ insertBefore(button, createText('...'), null)
135
+ return button
136
+ }
137
+
138
+ function emitPageChange(props: PaginationProps, page: number): void {
139
+ const handler = readProp<unknown>(props, 'onChange', undefined)
140
+ if (typeof handler === 'function') (handler as PaginationProps['onChange'])!(page)
141
+ }
142
+
143
+ function clampPage(page: number, pageCount: number): number {
144
+ if (pageCount <= 0) return 1
145
+ return Math.min(pageCount, Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)))
146
+ }
147
+
148
+ function buildPaginationItems(page: number, pageCount: number, siblingCount: number): PaginationItem[] {
149
+ const totalVisible = siblingCount * 2 + 5
150
+ if (pageCount <= totalVisible) {
151
+ return Array.from({ length: pageCount }, (_, index) => index + 1)
152
+ }
153
+
154
+ const left = Math.max(page - siblingCount, 2)
155
+ const right = Math.min(page + siblingCount, pageCount - 1)
156
+ const items: PaginationItem[] = [1]
157
+
158
+ if (left > 2) items.push('ellipsis-left')
159
+ for (let value = left; value <= right; value++) items.push(value)
160
+ if (right < pageCount - 1) items.push('ellipsis-right')
161
+ items.push(pageCount)
162
+ return items
163
+ }
@@ -0,0 +1,205 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { state } from '@vobs/reactivity'
3
+ import {
4
+ createComponent,
5
+ createDOMRenderer,
6
+ createElement,
7
+ createVobs,
8
+ insertBefore,
9
+ setRenderer
10
+ } from '@vobs/vobs'
11
+ import { Avatar } from './avatar'
12
+ import { Kbd, KbdCombo, KbdRow } from './kbd'
13
+ import { Menu } from './menu'
14
+ import { NavList, type NavGroup } from './nav-list'
15
+ import { PageHeader, PageHeaderAction } from './page-header'
16
+ import { Pagination } from './pagination'
17
+ import { StatCard, StatCardGrid } from './stat-card'
18
+ import { Table, TablePanel, type TableColumn } from './table'
19
+
20
+ describe('@vobs/ui second batch', () => {
21
+ beforeEach(() => {
22
+ setRenderer(createDOMRenderer())
23
+ })
24
+
25
+ it('Menu renders icons, shortcuts, dividers and select callbacks', () => {
26
+ const selected: string[] = []
27
+ const container = document.createElement('main')
28
+ const app = createVobs({
29
+ render: () => createComponent(Menu, {
30
+ items: [
31
+ { id: 'edit', label: 'Edit', shortcut: 'Ctrl+E' },
32
+ { id: 'separator', divider: true },
33
+ { id: 'delete', label: 'Delete', danger: true, disabled: true }
34
+ ],
35
+ onSelect: id => selected.push(id)
36
+ })
37
+ })
38
+ app.mount(container)
39
+
40
+ expect(container.querySelector('.vui-menu')).toBeTruthy()
41
+ expect(container.querySelector('.vui-menu__shortcut')?.textContent).toBe('Ctrl+E')
42
+ expect(container.querySelector('.vui-menu__divider')).toBeTruthy()
43
+ ;(container.querySelector('[data-menu-item-id="edit"]') as HTMLElement).click()
44
+ ;(container.querySelector('[data-menu-item-id="delete"]') as HTMLElement).click()
45
+ expect(selected).toEqual(['edit'])
46
+ app.destroy()
47
+ })
48
+
49
+ it('NavList uses controlled Signal state and native links/buttons', () => {
50
+ const active = state('home')
51
+ const groups: readonly NavGroup[] = [{
52
+ title: 'Workspace',
53
+ items: [
54
+ { id: 'home', label: 'Home' },
55
+ { id: 'settings', label: 'Settings', href: '/settings' }
56
+ ]
57
+ }]
58
+ const container = document.createElement('main')
59
+ const app = createVobs({
60
+ render: () => createComponent(NavList, {
61
+ groups,
62
+ get value() { return active.value },
63
+ onChange: id => { active.value = id }
64
+ })
65
+ })
66
+ app.mount(container)
67
+
68
+ expect(container.querySelector('[data-nav-id="home"]')?.classList.contains('is-active')).toBe(true)
69
+ ;(container.querySelector('[data-nav-id="settings"]') as HTMLElement).click()
70
+ app.update()
71
+ expect(active.value).toBe('settings')
72
+ expect(container.querySelector('[data-nav-id="settings"]')?.getAttribute('aria-current')).toBe('page')
73
+ expect(container.querySelector('a[href="/settings"]')).toBeTruthy()
74
+ app.destroy()
75
+ })
76
+
77
+ it('Pagination builds condensed pages and reports page changes', () => {
78
+ const page = state(5)
79
+ const changes: number[] = []
80
+ const container = document.createElement('main')
81
+ const app = createVobs({
82
+ render: () => createComponent(Pagination, {
83
+ get page() { return page.value },
84
+ pageCount: 20,
85
+ onChange: next => {
86
+ changes.push(next)
87
+ page.value = next
88
+ }
89
+ })
90
+ })
91
+ app.mount(container)
92
+
93
+ expect(container.querySelectorAll('.vui-pagination__item').length).toBe(9)
94
+ expect(container.querySelector('[aria-current="page"]')?.textContent).toBe('5')
95
+ const next = container.querySelector('[aria-label="Next"]') as HTMLButtonElement
96
+ next.click()
97
+ app.update()
98
+ expect(changes).toEqual([6])
99
+ expect(container.querySelector('[aria-current="page"]')?.textContent).toBe('6')
100
+ app.destroy()
101
+ })
102
+
103
+ it('Avatar, PageHeader, StatCard and Kbd expose UIStyle structure', () => {
104
+ const container = document.createElement('main')
105
+ const app = createVobs({
106
+ render: () => {
107
+ const root = createElement('div')
108
+ insertBefore(root, createComponent(Avatar, { fallback: 'A', size: 'lg', shape: 'square' }), null)
109
+ insertBefore(root, createComponent(PageHeader, {
110
+ title: 'Members',
111
+ subtitle: 'Manage access',
112
+ actions: createComponent(PageHeaderAction, { primary: true, children: 'Invite' })
113
+ }), null)
114
+ insertBefore(root, createComponent(StatCardGrid, {
115
+ children: createComponent(StatCard, {
116
+ label: 'Users',
117
+ value: '24',
118
+ delta: '+12%',
119
+ deltaDirection: 'up',
120
+ deltaCaption: 'this month'
121
+ })
122
+ }), null)
123
+ insertBefore(root, createComponent(KbdRow, {
124
+ children: [
125
+ createComponent(Kbd, { children: 'Esc' }),
126
+ createComponent(KbdCombo, { keys: ['Ctrl', 'K'] })
127
+ ]
128
+ }), null)
129
+ return root
130
+ }
131
+ })
132
+ app.mount(container)
133
+
134
+ expect(container.querySelector('.vui-avatar--lg.vui-avatar--square')?.textContent).toBe('A')
135
+ expect(container.querySelector('.vui-pagehead__title')?.textContent).toBe('Members')
136
+ expect(container.querySelector('.vui-pagehead__btn.is-primary')?.textContent).toBe('Invite')
137
+ expect(container.querySelector('.vui-statcard__value')?.textContent).toBe('24')
138
+ expect(container.querySelector('.vui-statcard__delta.is-up')?.textContent).toContain('+12%')
139
+ expect(container.querySelectorAll('.vui-kbd').length).toBe(3)
140
+ app.destroy()
141
+ })
142
+
143
+ it('Avatar 图片样式和 Table 居中对齐不使用内联样式', () => {
144
+ const container = document.createElement('main')
145
+ const app = createVobs({
146
+ render: () => {
147
+ const root = createElement('div')
148
+ insertBefore(root, createComponent(Avatar, { src: '/avatar.png', alt: 'Avery' }), null)
149
+ insertBefore(root, createComponent(Table, {
150
+ columns: [
151
+ { id: 'name', label: 'Member', key: 'name', align: 'center' }
152
+ ],
153
+ rows: [{ name: 'Avery' }]
154
+ }), null)
155
+ return root
156
+ }
157
+ })
158
+ app.mount(container)
159
+
160
+ expect(container.querySelector('.vui-avatar__image')?.hasAttribute('style')).toBe(false)
161
+ const centeredCell = container.querySelector('[data-align="center"]') as HTMLElement
162
+ expect(centeredCell).toBeTruthy()
163
+ expect(centeredCell.hasAttribute('style')).toBe(false)
164
+ app.destroy()
165
+ })
166
+
167
+ it('Table renders columns, keyed row metadata, empty state and panel footer', () => {
168
+ interface Member { id: number; name: string; status: string }
169
+ const columns: readonly TableColumn<Member>[] = [
170
+ { id: 'name', label: 'Member', key: 'name' },
171
+ { id: 'status', label: 'Status', render: row => row.status, align: 'center' },
172
+ { id: 'count', label: 'Count', render: () => '1', align: 'end' }
173
+ ]
174
+ const rows: readonly Member[] = [{ id: 1, name: 'Avery', status: 'Active' }]
175
+ const container = document.createElement('main')
176
+ const app = createVobs({
177
+ render: () => createComponent(TablePanel, {
178
+ columns,
179
+ rows,
180
+ rowKey: row => row.id,
181
+ footer: 'Showing 1 member'
182
+ })
183
+ })
184
+ app.mount(container)
185
+
186
+ expect(container.querySelector('.vui-tablepanel')).toBeTruthy()
187
+ expect(container.querySelectorAll('.vui-table th').length).toBe(3)
188
+ expect(container.querySelector('[data-row-key="1"]')?.textContent).toContain('Avery')
189
+ expect(container.querySelector('.vui-table td.num')?.textContent).toBe('1')
190
+ expect(container.querySelector('.vui-tablepanel__foot')?.textContent).toBe('Showing 1 member')
191
+ app.destroy()
192
+
193
+ const emptyContainer = document.createElement('main')
194
+ const emptyApp = createVobs({
195
+ render: () => createComponent(Table, {
196
+ columns: [{ id: 'name', label: 'Member' }],
197
+ rows: [],
198
+ empty: 'Nothing here'
199
+ })
200
+ })
201
+ emptyApp.mount(emptyContainer)
202
+ expect(emptyContainer.querySelector('tbody')?.textContent).toContain('Nothing here')
203
+ emptyApp.destroy()
204
+ })
205
+ })
@@ -0,0 +1,92 @@
1
+ import { effect } from '@vobs/reactivity'
2
+ import {
3
+ createElement,
4
+ createText,
5
+ insertBefore,
6
+ insertDynamic,
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
+ resolveSlot
19
+ } from './utils'
20
+ import type { VuiChildren, VuiCommonProps } from './types'
21
+
22
+ export type StatDeltaDirection = 'up' | 'down'
23
+
24
+ export interface StatCardProps extends VuiCommonProps {
25
+ readonly label?: string
26
+ readonly value?: VuiChildren
27
+ readonly delta?: VuiChildren
28
+ readonly deltaDirection?: StatDeltaDirection
29
+ readonly deltaIcon?: VuiChildren
30
+ readonly deltaCaption?: VuiChildren
31
+ }
32
+
33
+ export function StatCard(props: StatCardProps = {}): VobsNode {
34
+ const root = createElement('div')
35
+ bindClassList(root, props, () => ['vui-statcard'])
36
+ bindCommonAttributes(root, props, [
37
+ 'label',
38
+ 'value',
39
+ 'delta',
40
+ 'deltaDirection',
41
+ 'deltaIcon',
42
+ 'deltaCaption'
43
+ ])
44
+ bindUserStyle(root, props)
45
+
46
+ if (hasProp(props, 'label')) {
47
+ const label = createElement('span')
48
+ const text = createText('')
49
+ setAttribute(label, 'class', 'vui-statcard__label')
50
+ bindTextContent(text, () => readProp(props, 'label', ''))
51
+ insertBefore(label, text, null)
52
+ insertBefore(root, label, null)
53
+ }
54
+
55
+ if (hasProp(props, 'value')) {
56
+ const value = createElement('span')
57
+ setAttribute(value, 'class', 'vui-statcard__value')
58
+ insertDynamic(value, null, () => resolveSlot(Reflect.get(props, 'value')))
59
+ insertBefore(root, value, null)
60
+ }
61
+
62
+ if (hasProp(props, 'delta') || hasProp(props, 'deltaIcon') || hasProp(props, 'deltaCaption')) {
63
+ const delta = createElement('span')
64
+ effect(() => {
65
+ const direction = readProp<StatDeltaDirection | undefined>(props, 'deltaDirection', undefined)
66
+ setAttribute(delta, 'class', `vui-statcard__delta${direction ? ` is-${direction}` : ''}`)
67
+ })
68
+ if (hasProp(props, 'deltaIcon')) mountSlot(delta, props, 'deltaIcon')
69
+ if (hasProp(props, 'delta')) mountSlot(delta, props, 'delta')
70
+ if (hasProp(props, 'deltaCaption')) {
71
+ const caption = createElement('span')
72
+ setAttribute(caption, 'class', 'vui-statcard__caption')
73
+ mountSlot(caption, props, 'deltaCaption')
74
+ insertBefore(delta, caption, null)
75
+ }
76
+ insertBefore(root, delta, null)
77
+ }
78
+
79
+ if (hasProp(props, 'children')) mountSlot(root, props, 'children')
80
+ return root
81
+ }
82
+
83
+ export interface StatCardGridProps extends VuiCommonProps {}
84
+
85
+ export function StatCardGrid(props: StatCardGridProps = {}): VobsNode {
86
+ const root = createElement('div')
87
+ bindClassList(root, props, () => ['vui-statcard__grid'])
88
+ bindCommonAttributes(root, props)
89
+ bindUserStyle(root, props)
90
+ if (hasProp(props, 'children')) mountSlot(root, props, 'children')
91
+ return root
92
+ }
@@ -0,0 +1,108 @@
1
+ import {
2
+ addEventListener,
3
+ createElement,
4
+ createFragment,
5
+ insertBefore,
6
+ insertDynamic,
7
+ setAttribute,
8
+ type VobsNode
9
+ } from '@vobs/vobs'
10
+ import {
11
+ bindClassList,
12
+ bindCommonAttributes,
13
+ bindUserStyle,
14
+ hasProp,
15
+ mountSlot,
16
+ readProp
17
+ } from './utils'
18
+ import type { VuiChildren, VuiCommonProps } from './types'
19
+
20
+ export type StatusBarDot = 'success' | 'warning' | 'error'
21
+
22
+ export interface StatusBarItem {
23
+ readonly id?: string
24
+ readonly icon?: VuiChildren
25
+ readonly dot?: StatusBarDot
26
+ readonly label?: VuiChildren
27
+ readonly disabled?: boolean
28
+ }
29
+
30
+ export interface StatusBarProps extends VuiCommonProps {
31
+ readonly left?: readonly StatusBarItem[]
32
+ readonly right?: readonly StatusBarItem[]
33
+ readonly onItemClick?: (id: string, event: MouseEvent) => void
34
+ }
35
+
36
+ export function StatusBar(props: StatusBarProps = {}): VobsNode {
37
+ const root = createElement('footer')
38
+ bindClassList(root, props, () => ['vui-statusbar'])
39
+ bindCommonAttributes(root, props, ['left', 'right', 'onItemClick'])
40
+ bindUserStyle(root, props)
41
+ setAttribute(root, 'role', 'status')
42
+
43
+ if (hasProp(props, 'left')) insertDynamic(root, null, () => createStatusGroup(props, 'left'))
44
+ if (hasProp(props, 'right') || hasProp(props, 'children')) {
45
+ const group = createElement('div')
46
+ setAttribute(group, 'class', 'vui-statusbar__group')
47
+ if (hasProp(props, 'right')) insertDynamic(group, null, () => createStatusItems(props, 'right'))
48
+ if (hasProp(props, 'children')) mountSlot(group, props, 'children')
49
+ insertBefore(root, group, null)
50
+ }
51
+ return root
52
+ }
53
+
54
+ function createStatusGroup(props: StatusBarProps, side: 'left' | 'right'): VobsNode {
55
+ const group = createElement('div')
56
+ setAttribute(group, 'class', 'vui-statusbar__group')
57
+ insertDynamic(group, null, () => createStatusItems(props, side))
58
+ return group
59
+ }
60
+
61
+ function createStatusItems(props: StatusBarProps, side: 'left' | 'right'): VobsNode {
62
+ const items = side === 'left'
63
+ ? readProp<readonly StatusBarItem[]>(props, 'left', [])
64
+ : readProp<readonly StatusBarItem[]>(props, 'right', [])
65
+ return createFragment((parent, anchor) => {
66
+ for (const item of items) insertBefore(parent, createStatusItem(item, props), anchor)
67
+ })
68
+ }
69
+
70
+ function createStatusItem(item: StatusBarItem, props: StatusBarProps): VobsNode {
71
+ const root = createElement('span')
72
+ setAttribute(root, 'class', 'vui-statusbar__item')
73
+ if (item.id !== undefined && readProp<unknown>(props, 'onItemClick', undefined)) {
74
+ setAttribute(root, 'role', 'button')
75
+ setAttribute(root, 'tabindex', item.disabled === true ? '-1' : '0')
76
+ }
77
+ if (item.disabled === true) setAttribute(root, 'aria-disabled', 'true')
78
+
79
+ if (item.icon !== undefined) {
80
+ const icon = createElement('span')
81
+ setAttribute(icon, 'class', 'vui-statusbar__icon')
82
+ mountSlot(icon, item, 'icon')
83
+ insertBefore(root, icon, null)
84
+ }
85
+ if (item.dot !== undefined) {
86
+ const dot = createElement('span')
87
+ setAttribute(dot, 'class', `vui-statusbar__dot vui-statusbar__dot--${item.dot}`)
88
+ setAttribute(dot, 'aria-hidden', 'true')
89
+ insertBefore(root, dot, null)
90
+ }
91
+ if (item.label !== undefined) mountSlot(root, item, 'label')
92
+
93
+ const activate = (event: Event): void => {
94
+ if (item.disabled === true || item.id === undefined) return
95
+ const handler = readProp<unknown>(props, 'onItemClick', undefined)
96
+ if (typeof handler === 'function') (handler as StatusBarProps['onItemClick'])!(item.id, event as MouseEvent)
97
+ }
98
+ if (item.id !== undefined) {
99
+ addEventListener(root, 'click', activate)
100
+ addEventListener(root, 'keydown', event => {
101
+ const keyboardEvent = event as KeyboardEvent
102
+ if (keyboardEvent.key !== 'Enter' && keyboardEvent.key !== ' ') return
103
+ keyboardEvent.preventDefault?.()
104
+ activate(keyboardEvent)
105
+ })
106
+ }
107
+ return root
108
+ }