@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
@@ -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
+ }