@vobs/ui 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +60 -0
- package/package.json +52 -102
- package/src/activity-rail.ts +125 -0
- package/src/alert.ts +65 -0
- package/src/assets/icons/alert-circle.svg +1 -0
- package/src/assets/icons/alert-triangle.svg +1 -0
- package/src/assets/icons/alert.d27e24.svg +1 -0
- package/src/assets/icons/alert.svg +1 -0
- package/src/assets/icons/apple.svg +1 -0
- package/src/assets/icons/arrow-down.svg +1 -0
- package/src/assets/icons/arrow-expand.svg +1 -0
- package/src/assets/icons/arrow-left.svg +1 -0
- package/src/assets/icons/arrow-minimize.svg +1 -0
- package/src/assets/icons/arrow-right-to-line.svg +1 -0
- package/src/assets/icons/arrow-right.svg +1 -0
- package/src/assets/icons/arrow-up-right.svg +1 -0
- package/src/assets/icons/arrow-up.0c0c0d.svg +1 -0
- package/src/assets/icons/arrow-up.svg +1 -0
- package/src/assets/icons/at.svg +1 -0
- package/src/assets/icons/atom.0c0c0d.svg +1 -0
- package/src/assets/icons/atom.svg +1 -0
- package/src/assets/icons/bar.svg +1 -0
- package/src/assets/icons/bell.svg +1 -0
- package/src/assets/icons/box.svg +1 -0
- package/src/assets/icons/bug.svg +1 -0
- package/src/assets/icons/calendar.svg +1 -0
- package/src/assets/icons/check-circle.33c192.svg +1 -0
- package/src/assets/icons/check-circle.svg +1 -0
- package/src/assets/icons/check.d1d3db.svg +1 -0
- package/src/assets/icons/check.svg +1 -0
- package/src/assets/icons/chevron-down.svg +1 -0
- package/src/assets/icons/chevron-right.svg +1 -0
- package/src/assets/icons/chevron-up.svg +1 -0
- package/src/assets/icons/circle.svg +1 -0
- package/src/assets/icons/clock.svg +1 -0
- package/src/assets/icons/cmd.svg +1 -0
- package/src/assets/icons/code.svg +1 -0
- package/src/assets/icons/columns.svg +1 -0
- package/src/assets/icons/copy.svg +1 -0
- package/src/assets/icons/cpu.svg +1 -0
- package/src/assets/icons/dashboard.0c0c0d.svg +1 -0
- package/src/assets/icons/dashboard.svg +1 -0
- package/src/assets/icons/dollar.svg +1 -0
- package/src/assets/icons/download.svg +1 -0
- package/src/assets/icons/edit.svg +1 -0
- package/src/assets/icons/extensions.svg +1 -0
- package/src/assets/icons/external.svg +1 -0
- package/src/assets/icons/eye.svg +1 -0
- package/src/assets/icons/file-text.svg +1 -0
- package/src/assets/icons/file.svg +1 -0
- package/src/assets/icons/files.svg +1 -0
- package/src/assets/icons/filter.svg +1 -0
- package/src/assets/icons/folder.svg +1 -0
- package/src/assets/icons/gear.svg +1 -0
- package/src/assets/icons/git.svg +1 -0
- package/src/assets/icons/github.svg +1 -0
- package/src/assets/icons/globe.svg +1 -0
- package/src/assets/icons/grid-2x2.svg +1 -0
- package/src/assets/icons/hash.svg +1 -0
- package/src/assets/icons/heart.svg +1 -0
- package/src/assets/icons/help.svg +1 -0
- package/src/assets/icons/home.svg +1 -0
- package/src/assets/icons/image.svg +1 -0
- package/src/assets/icons/info-circle.svg +1 -0
- package/src/assets/icons/info-square.svg +1 -0
- package/src/assets/icons/info.387bff.svg +1 -0
- package/src/assets/icons/info.svg +1 -0
- package/src/assets/icons/key.svg +1 -0
- package/src/assets/icons/layers.svg +1 -0
- package/src/assets/icons/layout.svg +1 -0
- package/src/assets/icons/link.svg +1 -0
- package/src/assets/icons/list.svg +1 -0
- package/src/assets/icons/lock.svg +1 -0
- package/src/assets/icons/log-out.svg +1 -0
- package/src/assets/icons/logo.svg +1 -0
- package/src/assets/icons/mail.svg +1 -0
- package/src/assets/icons/menu.svg +1 -0
- package/src/assets/icons/message-circle.svg +1 -0
- package/src/assets/icons/message-plus.svg +1 -0
- package/src/assets/icons/message-square.svg +1 -0
- package/src/assets/icons/mic.svg +1 -0
- package/src/assets/icons/minus.svg +1 -0
- package/src/assets/icons/moon.svg +1 -0
- package/src/assets/icons/more-h.svg +1 -0
- package/src/assets/icons/mouse.svg +1 -0
- package/src/assets/icons/palette.svg +1 -0
- package/src/assets/icons/panel-bottom.svg +1 -0
- package/src/assets/icons/pause.svg +1 -0
- package/src/assets/icons/play.svg +1 -0
- package/src/assets/icons/plug.svg +1 -0
- package/src/assets/icons/plus.0c0c0d.svg +1 -0
- package/src/assets/icons/plus.svg +1 -0
- package/src/assets/icons/refresh.svg +1 -0
- package/src/assets/icons/ruler.svg +1 -0
- package/src/assets/icons/scroll-text.svg +1 -0
- package/src/assets/icons/search-menu.svg +1 -0
- package/src/assets/icons/search.svg +1 -0
- package/src/assets/icons/send.0c0c0d.svg +1 -0
- package/src/assets/icons/send.svg +1 -0
- package/src/assets/icons/settings.0c0c0d.svg +1 -0
- package/src/assets/icons/settings.svg +1 -0
- package/src/assets/icons/shield.svg +1 -0
- package/src/assets/icons/sidebar-left.svg +1 -0
- package/src/assets/icons/sidebar-right.svg +1 -0
- package/src/assets/icons/sliders.svg +1 -0
- package/src/assets/icons/sparkles.32f08c.svg +1 -0
- package/src/assets/icons/sparkles.svg +1 -0
- package/src/assets/icons/square.svg +1 -0
- package/src/assets/icons/star.svg +1 -0
- package/src/assets/icons/sun.svg +1 -0
- package/src/assets/icons/terminal.svg +1 -0
- package/src/assets/icons/trash.svg +1 -0
- package/src/assets/icons/trending-down.svg +1 -0
- package/src/assets/icons/trending-up.svg +1 -0
- package/src/assets/icons/type.svg +1 -0
- package/src/assets/icons/upload.svg +1 -0
- package/src/assets/icons/user-circle.svg +1 -0
- package/src/assets/icons/user.svg +1 -0
- package/src/assets/icons/users.svg +1 -0
- package/src/assets/icons/wrench.svg +1 -0
- package/src/assets/icons/x-circle.svg +1 -0
- package/src/assets/icons/x.svg +1 -0
- package/src/assets/icons/zap.0c0c0d.svg +1 -0
- package/src/assets/icons/zap.svg +1 -0
- package/src/avatar.ts +64 -0
- package/src/button.ts +88 -0
- package/src/card.ts +59 -0
- package/src/chat-composer.ts +156 -0
- package/src/dialog.ts +127 -0
- package/src/drawer.ts +131 -0
- package/src/editor-tabs.ts +184 -0
- package/src/file-tree.ts +214 -0
- package/src/forms.ts +270 -0
- package/src/icon.ts +117 -0
- package/src/icons.js +165 -0
- package/src/index.test.ts +213 -0
- package/src/index.ts +94 -0
- package/src/kbd.ts +72 -0
- package/src/menu.ts +119 -0
- package/src/nav-list.ts +157 -0
- package/src/overlay.test.ts +66 -0
- package/src/overlay.ts +205 -0
- package/src/page-header.ts +97 -0
- package/src/pagination.ts +163 -0
- package/src/second-batch.test.ts +205 -0
- package/src/stat-card.ts +92 -0
- package/src/status-bar.ts +108 -0
- package/src/styles/base.css +240 -0
- package/src/styles/components.css +1234 -0
- package/{dist → src/styles}/styles.css +2 -0
- package/src/styles/toast.css +57 -0
- package/src/styles/tokens.css +438 -0
- package/src/styles/vui.css +47 -0
- package/src/table.ts +191 -0
- package/src/tabs.ts +100 -0
- package/src/tag.ts +33 -0
- package/src/third-batch.test.ts +137 -0
- package/src/toast.test.ts +55 -0
- package/src/toast.ts +79 -0
- package/src/types.ts +25 -0
- package/src/utils.ts +221 -0
- package/src/workbench-titlebar.ts +137 -0
- package/dist/app-shell-dom.d.ts +0 -24
- package/dist/app-shell-dom.js +0 -573
- package/dist/app-shell.d.ts +0 -199
- package/dist/app-shell.js +0 -370
- package/dist/base.css +0 -52
- package/dist/calendar-picker.d.ts +0 -45
- package/dist/calendar-picker.js +0 -217
- package/dist/calendar.d.ts +0 -95
- package/dist/calendar.js +0 -194
- package/dist/complex-inputs.d.ts +0 -300
- package/dist/complex-inputs.js +0 -862
- package/dist/components.css +0 -4568
- package/dist/data-display.d.ts +0 -1063
- package/dist/data-display.js +0 -2298
- package/dist/feedback.d.ts +0 -88
- package/dist/feedback.js +0 -281
- package/dist/forms.d.ts +0 -378
- package/dist/forms.js +0 -1015
- package/dist/icons-config.d.ts +0 -25
- package/dist/icons-config.js +0 -14
- package/dist/icons.d.ts +0 -41
- package/dist/icons.js +0 -185
- package/dist/index.d.ts +0 -27
- package/dist/index.js +0 -34
- package/dist/locale/en-US.d.ts +0 -31
- package/dist/locale/en-US.js +0 -20
- package/dist/locale/zh-CN.d.ts +0 -31
- package/dist/locale/zh-CN.js +0 -20
- package/dist/navigation.d.ts +0 -609
- package/dist/navigation.js +0 -1707
- package/dist/overlay.d.ts +0 -304
- package/dist/overlay.js +0 -969
- package/dist/primitives.d.ts +0 -268
- package/dist/primitives.js +0 -764
- package/dist/theme-data.d.ts +0 -363
- package/dist/theme-data.js +0 -374
- package/dist/theme.d.ts +0 -79
- package/dist/theme.js +0 -262
- package/dist/tokens.css +0 -449
- package/dist/tree.d.ts +0 -144
- package/dist/tree.js +0 -469
- package/dist/virtual-list.d.ts +0 -190
- package/dist/virtual-list.js +0 -521
- package/dist/workbench.d.ts +0 -136
- package/dist/workbench.js +0 -156
|
@@ -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
|
+
})
|
package/src/stat-card.ts
ADDED
|
@@ -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
|
+
}
|