@keyfunc/keybricks-ds 2.7.0 → 2.8.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/DEPLOY.md +3 -3
- package/DESIGN.md +8 -1
- package/INTEGRATION.md +18 -2
- package/_adherence.oxlintrc.json +0 -1
- package/_ds_bundle.js +293 -307
- package/{components/feedback → _vue-services}/MESSAGE_SERVICES.md +2 -2
- package/_vue-services/_internal/MessageBoxView.vue +150 -0
- package/{components/feedback → _vue-services}/_internal/service-utils.js +0 -12
- package/_vue-services/message.js +87 -0
- package/changelog.js +10 -1
- package/components/_cards/modal.card.html +8 -2
- package/components/_cards/table.card.html +10 -1
- package/components/_cards/toast.card.html +11 -1
- package/components/_cards/tree.card.html +22 -1
- package/components/data/Table.d.ts +5 -0
- package/components/data/Table.jsx +10 -5
- package/components/data/Table.vue +9 -3
- package/components/data/data.prompt.md +2 -0
- package/components/data/row-open.js +13 -0
- package/components/feedback/Modal.d.ts +58 -7
- package/components/feedback/Modal.jsx +46 -16
- package/components/feedback/Modal.vue +24 -6
- package/components/feedback/Toast.vue +3 -3
- package/components/feedback/ToastStack.vue +37 -4
- package/components/feedback/feedback.prompt.md +5 -1
- package/components/feedback/modal-behavior.js +50 -0
- package/components/navigation/Tree.d.ts +5 -1
- package/components/navigation/Tree.jsx +9 -7
- package/components/navigation/Tree.vue +7 -6
- package/components/navigation/navigation.prompt.md +2 -0
- package/index.html +3 -3
- package/package.json +6 -5
- package/site-nav.js +1 -1
- package/tokens/tokens.json +1 -1
- package/components/feedback/_internal/MessageBoxView.vue +0 -237
- package/components/feedback/_internal/MessageView.vue +0 -73
- package/components/feedback/message.js +0 -68
- /package/{components/feedback → _vue-services}/message-box.d.ts +0 -0
- /package/{components/feedback → _vue-services}/message-box.js +0 -0
- /package/{components/feedback → _vue-services}/message.d.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vue 消息服务与 Element Plus 迁移
|
|
2
2
|
|
|
3
|
-
2.6.0 新增源码入口,适用于 Vue 3
|
|
3
|
+
2.6.0 新增源码入口,适用于 Vue 3。2.8.0 起两个服务不再自带样式:Message 用 DS 的 `ToastStack`(`placement="top-center"`)和 `Toast` 显示,MessageBox 用 DS 的 `Modal`、`Button`、`Input` / `Textarea` 显示,焦点、Esc、遮罩和滚动锁定都由 `Modal` 负责。改 Toast 或 Modal 的外观,两个服务会自动跟着变。源码位于 `_vue-services/`。
|
|
4
4
|
本次仅补齐源码,未执行测试、类型检查、构建或浏览器验证,也尚未发布。
|
|
5
5
|
|
|
6
6
|
```js
|
|
@@ -28,7 +28,7 @@ const { value } = await MessageBox.prompt('请输入名称', '新建', {
|
|
|
28
28
|
| `ElMessage(options / string / VNode)` | `Message(...)`,返回 `{ close() }` |
|
|
29
29
|
| `success / info / warning / error` | 同名快捷方法 |
|
|
30
30
|
| `duration / showClose / offset / zIndex / customClass / center` | 支持;默认 3000 毫秒,0 不自动关闭 |
|
|
31
|
-
| 消息堆叠与 `grouping` |
|
|
31
|
+
| 消息堆叠与 `grouping` | 同一挂载目标放进一个顶部居中的 ToastStack,间距 8px;分组显示 × n 并重置计时 |
|
|
32
32
|
| 悬停与焦点暂停计时 | 支持,离开后重新计时 |
|
|
33
33
|
| `onClose / closeAll(type?)` | 支持,手动及自动关闭均清理实例 |
|
|
34
34
|
| `MessageBox(options)` | 返回 Promise,弹窗按调用顺序排队 |
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, h, isVNode, onBeforeUnmount, ref } from 'vue'
|
|
3
|
+
import Modal from '../../components/feedback/Modal.vue'
|
|
4
|
+
import Button from '../../components/buttons/Button.vue'
|
|
5
|
+
import Icon from '../../components/icons/Icon.vue'
|
|
6
|
+
import Input from '../../components/forms/Input.vue'
|
|
7
|
+
import Textarea from '../../components/forms/Textarea.vue'
|
|
8
|
+
import { contentNode } from './service-utils.js'
|
|
9
|
+
|
|
10
|
+
// MessageBox 的显示完全交给 DS:Modal(焦点、Esc、遮罩、滚动锁定)、Button、Input / Textarea、Icon。
|
|
11
|
+
// 本组件只负责 Element Plus 风格的选项映射、输入校验和确认 / 取消 / 关闭动作。
|
|
12
|
+
const props = defineProps({ options: { type: Object, required: true } })
|
|
13
|
+
const emit = defineEmits(['action'])
|
|
14
|
+
const error = ref('')
|
|
15
|
+
const validating = ref(false)
|
|
16
|
+
const Content = () => contentNode(props.options.message, props.options.dangerouslyUseHTMLString)
|
|
17
|
+
const Title = () => contentNode(props.options.title)
|
|
18
|
+
const icons = { success: 'circle-check', warning: 'triangle-alert', error: 'triangle-alert', info: 'info' }
|
|
19
|
+
const colors = { success: 'var(--success-text)', warning: 'var(--warning-text)', error: 'var(--danger-text)', info: 'var(--info-fg)' }
|
|
20
|
+
const icon = computed(() => props.options.icon || icons[props.options.type])
|
|
21
|
+
const CustomIcon = () => isVNode(icon.value) ? icon.value : h(icon.value)
|
|
22
|
+
const confirmVariant = computed(() => props.options.confirmButtonClass?.includes('el-button--danger') ? 'danger' : 'primary')
|
|
23
|
+
const titleIsText = computed(() => props.options.title == null || typeof props.options.title === 'string')
|
|
24
|
+
const inputId = computed(() => `${props.options.id}-input`)
|
|
25
|
+
const round = computed(() => props.options.roundButton ? { borderRadius: 'var(--radius-full)' } : null)
|
|
26
|
+
let disposed = false
|
|
27
|
+
let validationRevision = 0
|
|
28
|
+
onBeforeUnmount(() => { disposed = true })
|
|
29
|
+
|
|
30
|
+
function focusInput() {
|
|
31
|
+
const el = document.getElementById(inputId.value)
|
|
32
|
+
const target = el && (el.matches('input, textarea') ? el : el.querySelector('input, textarea'))
|
|
33
|
+
target?.focus()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function confirm() {
|
|
37
|
+
const options = props.options
|
|
38
|
+
if (validating.value || options.confirmButtonDisabled || options.confirmButtonLoading) return
|
|
39
|
+
if (options.showInput) {
|
|
40
|
+
const revision = ++validationRevision
|
|
41
|
+
const value = options.inputValue ?? ''
|
|
42
|
+
validating.value = true
|
|
43
|
+
let message = ''
|
|
44
|
+
try {
|
|
45
|
+
if (options.inputPattern) {
|
|
46
|
+
// 避免全局正则的 lastIndex 导致同一输入校验结果交替。
|
|
47
|
+
const pattern = new RegExp(options.inputPattern.source, options.inputPattern.flags)
|
|
48
|
+
if (!pattern.test(value)) message = options.inputErrorMessage || 'Invalid input'
|
|
49
|
+
}
|
|
50
|
+
if (!message && options.inputValidator) {
|
|
51
|
+
const result = await options.inputValidator(value)
|
|
52
|
+
if (result === false || typeof result === 'string') message = result || options.inputErrorMessage || 'Invalid input'
|
|
53
|
+
}
|
|
54
|
+
} catch (reason) {
|
|
55
|
+
message = options.inputErrorMessage || reason?.message || 'Invalid input'
|
|
56
|
+
} finally {
|
|
57
|
+
validating.value = false
|
|
58
|
+
}
|
|
59
|
+
if (disposed || revision !== validationRevision) return
|
|
60
|
+
error.value = message
|
|
61
|
+
if (message) { focusInput(); return }
|
|
62
|
+
}
|
|
63
|
+
emit('action', 'confirm')
|
|
64
|
+
}
|
|
65
|
+
function changeInput(value) {
|
|
66
|
+
validationRevision++
|
|
67
|
+
props.options.inputValue = value
|
|
68
|
+
error.value = ''
|
|
69
|
+
}
|
|
70
|
+
function onEnter(event) {
|
|
71
|
+
if (event.isComposing || props.options.inputType === 'textarea') return
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
confirm()
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<Modal
|
|
79
|
+
open alert
|
|
80
|
+
:title="titleIsText ? options.title : undefined"
|
|
81
|
+
:width="options.width ?? 440"
|
|
82
|
+
:size="options.size || 'lg'"
|
|
83
|
+
:close-on-esc="options.closeOnPressEscape"
|
|
84
|
+
:close-on-scrim="options.closeOnClickModal"
|
|
85
|
+
:show-close="options.showClose"
|
|
86
|
+
:lock-scroll="options.lockScroll"
|
|
87
|
+
:scrim="options.modal !== false"
|
|
88
|
+
:auto-focus="options.autofocus"
|
|
89
|
+
:z-index="options.zIndex"
|
|
90
|
+
:close-label="options.closeLabel || 'Close'"
|
|
91
|
+
:class="[options.modalClass, options.customClass]"
|
|
92
|
+
@close="emit('action', 'close')"
|
|
93
|
+
>
|
|
94
|
+
<template v-if="!titleIsText" #title><Title /></template>
|
|
95
|
+
<div :id="`${options.id}-content`" :style="{ display: 'flex', alignItems: 'flex-start', gap: '12px', justifyContent: options.center ? 'center' : undefined, textAlign: options.center ? 'center' : undefined, fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', color: 'var(--text-body)' }">
|
|
96
|
+
<span v-if="icon" :style="{ color: colors[options.type] || 'var(--text-secondary)', display: 'inline-flex', flexShrink: 0 }">
|
|
97
|
+
<Icon v-if="typeof icon === 'string'" :name="icon" :size="20" />
|
|
98
|
+
<CustomIcon v-else />
|
|
99
|
+
</span>
|
|
100
|
+
<div style="min-width:0;overflow-wrap:anywhere;white-space:pre-wrap"><Content /></div>
|
|
101
|
+
</div>
|
|
102
|
+
<div v-if="options.showInput" style="display:flex;flex-direction:column;gap:var(--control-gap);margin-top:var(--group-gap)">
|
|
103
|
+
<Textarea
|
|
104
|
+
v-if="options.inputType === 'textarea'"
|
|
105
|
+
:id="inputId" data-autofocus
|
|
106
|
+
:model-value="options.inputValue" :error="!!error"
|
|
107
|
+
:placeholder="options.inputPlaceholder"
|
|
108
|
+
:aria-label="options.inputPlaceholder || (titleIsText && options.title) || 'Input'"
|
|
109
|
+
:aria-invalid="!!error" :aria-describedby="error ? `${options.id}-error` : undefined"
|
|
110
|
+
@update:model-value="changeInput"
|
|
111
|
+
/>
|
|
112
|
+
<Input
|
|
113
|
+
v-else
|
|
114
|
+
:id="inputId" data-autofocus
|
|
115
|
+
:type="options.inputType"
|
|
116
|
+
:model-value="options.inputValue" :error="!!error"
|
|
117
|
+
:placeholder="options.inputPlaceholder"
|
|
118
|
+
:aria-label="options.inputPlaceholder || (titleIsText && options.title) || 'Input'"
|
|
119
|
+
:aria-invalid="!!error" :aria-describedby="error ? `${options.id}-error` : undefined"
|
|
120
|
+
:style="{ width: '100%' }"
|
|
121
|
+
@update:model-value="changeInput"
|
|
122
|
+
@keydown.enter="onEnter"
|
|
123
|
+
/>
|
|
124
|
+
<div v-if="error" :id="`${options.id}-error`" role="alert" style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);color:var(--danger-text)">{{ error }}</div>
|
|
125
|
+
</div>
|
|
126
|
+
<template #footer>
|
|
127
|
+
<div :style="{ display: 'flex', flex: 1, gap: 'var(--control-gap)', justifyContent: options.center ? 'center' : 'flex-end' }">
|
|
128
|
+
<Button
|
|
129
|
+
v-if="options.showCancelButton"
|
|
130
|
+
variant="outline"
|
|
131
|
+
:class="options.cancelButtonClass"
|
|
132
|
+
:style="round"
|
|
133
|
+
:disabled="options.cancelButtonDisabled"
|
|
134
|
+
:loading="options.cancelButtonLoading"
|
|
135
|
+
@click="emit('action', 'cancel')"
|
|
136
|
+
>{{ options.cancelButtonText ?? 'Cancel' }}</Button>
|
|
137
|
+
<Button
|
|
138
|
+
v-if="options.showConfirmButton"
|
|
139
|
+
:variant="confirmVariant"
|
|
140
|
+
:class="options.confirmButtonClass"
|
|
141
|
+
:style="round"
|
|
142
|
+
:data-autofocus="options.showInput ? undefined : ''"
|
|
143
|
+
:disabled="options.confirmButtonDisabled"
|
|
144
|
+
:loading="options.confirmButtonLoading || validating"
|
|
145
|
+
@click="confirm"
|
|
146
|
+
>{{ options.confirmButtonText ?? 'OK' }}</Button>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</Modal>
|
|
150
|
+
</template>
|
|
@@ -32,15 +32,3 @@ export function mountService(component, props, options = {}, appContext) {
|
|
|
32
32
|
container.remove()
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
let locks = 0
|
|
37
|
-
let previousOverflow
|
|
38
|
-
export function lockBody() {
|
|
39
|
-
if (locks++ === 0) {
|
|
40
|
-
previousOverflow = document.body.style.overflow
|
|
41
|
-
document.body.style.overflow = 'hidden'
|
|
42
|
-
}
|
|
43
|
-
return () => {
|
|
44
|
-
if (--locks === 0) document.body.style.overflow = previousOverflow
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getCurrentInstance, h, isVNode, reactive, render } from 'vue'
|
|
2
|
+
import ToastStack from '../components/feedback/ToastStack.vue'
|
|
3
|
+
import { contentNode, resolveTarget } from './_internal/service-utils.js'
|
|
4
|
+
|
|
5
|
+
/* Vue 消息服务。显示交给 DS 的 ToastStack(placement="top-center")和 Toast,本文件只负责调用方式、分组、计时参数和关闭句柄;
|
|
6
|
+
业务错误脱敏仍由应用处理。 */
|
|
7
|
+
const TONE = { success: 'success', info: 'info', warning: 'warning', error: 'danger' }
|
|
8
|
+
const hosts = new Map()
|
|
9
|
+
const entries = new Map()
|
|
10
|
+
let seed = 0
|
|
11
|
+
const normalize = value => typeof value === 'string' || typeof value === 'function' || isVNode(value)
|
|
12
|
+
? { message: value } : { ...value }
|
|
13
|
+
|
|
14
|
+
function onEscape(event) {
|
|
15
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return
|
|
16
|
+
const last = [...entries.values()].pop()
|
|
17
|
+
if (last) last.handle.close()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hostFor(target, options, appContext) {
|
|
21
|
+
let host = hosts.get(target)
|
|
22
|
+
if (host) { host.state.offset = Number(options.offset) || 0; if (options.zIndex != null) host.state.zIndex = options.zIndex; return host }
|
|
23
|
+
const list = reactive([])
|
|
24
|
+
const state = reactive({ offset: Number(options.offset) || 0, zIndex: options.zIndex })
|
|
25
|
+
const container = document.createElement('div')
|
|
26
|
+
target.appendChild(container)
|
|
27
|
+
const vnode = h({ render: () => h(ToastStack, {
|
|
28
|
+
toasts: list, placement: 'top-center', offset: state.offset, zIndex: state.zIndex,
|
|
29
|
+
onDismiss: id => entries.get(id)?.handle.close(),
|
|
30
|
+
}) })
|
|
31
|
+
vnode.appContext = appContext || null
|
|
32
|
+
render(vnode, container)
|
|
33
|
+
if (!hosts.size) document.addEventListener('keydown', onEscape)
|
|
34
|
+
host = { list, state, dispose() {
|
|
35
|
+
render(null, container); container.remove(); hosts.delete(target)
|
|
36
|
+
if (!hosts.size) document.removeEventListener('keydown', onEscape)
|
|
37
|
+
} }
|
|
38
|
+
hosts.set(target, host)
|
|
39
|
+
return host
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Message(options | string | VNode, appContext?) → { close() } */
|
|
43
|
+
export function Message(value = {}, appContext) {
|
|
44
|
+
if (typeof document === 'undefined') return { close() {} }
|
|
45
|
+
const options = { type: 'info', duration: 3000, offset: 16, ...Message.defaults, ...normalize(value) }
|
|
46
|
+
const target = resolveTarget(options)
|
|
47
|
+
if (options.grouping) {
|
|
48
|
+
const existing = [...entries.values()].find(e => e.target === target && e.message === options.message)
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.item.count++
|
|
51
|
+
existing.item.tone = TONE[options.type] || 'info'
|
|
52
|
+
return existing.handle
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const host = hostFor(target, options, appContext || getCurrentInstance()?.appContext)
|
|
56
|
+
const id = `kb-message-${++seed}`
|
|
57
|
+
host.list.push({
|
|
58
|
+
id, tone: TONE[options.type] || 'info', count: 1, duration: Number(options.duration) || 0,
|
|
59
|
+
content: () => contentNode(options.message, options.dangerouslyUseHTMLString),
|
|
60
|
+
icon: options.icon, dismissible: !!options.showClose, closeLabel: options.closeLabel, class: options.customClass,
|
|
61
|
+
style: { minWidth: 'min(280px, 92vw)', maxWidth: '100%', boxSizing: 'border-box', ...(options.center ? { textAlign: 'center' } : null), ...(options.plain ? { boxShadow: 'none' } : null) },
|
|
62
|
+
})
|
|
63
|
+
const item = host.list[host.list.length - 1]
|
|
64
|
+
let closed = false
|
|
65
|
+
const handle = { close() {
|
|
66
|
+
if (closed) return
|
|
67
|
+
closed = true
|
|
68
|
+
entries.delete(id)
|
|
69
|
+
const i = host.list.indexOf(item)
|
|
70
|
+
if (i >= 0) host.list.splice(i, 1)
|
|
71
|
+
if (!host.list.length) host.dispose()
|
|
72
|
+
options.onClose?.(handle)
|
|
73
|
+
} }
|
|
74
|
+
entries.set(id, { item, handle, target, message: options.message, type: options.type })
|
|
75
|
+
return handle
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const type of ['success', 'info', 'warning', 'error']) {
|
|
79
|
+
Message[type] = (value, appContext) => Message({ ...normalize(value), type }, appContext)
|
|
80
|
+
}
|
|
81
|
+
Message.closeAll = type => {
|
|
82
|
+
for (const entry of [...entries.values()]) {
|
|
83
|
+
if (!type || entry.type === type) entry.handle.close()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export default Message
|
|
87
|
+
Message.defaults = {}
|
package/changelog.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/* Keybricks changelog — the source for the Changelog section of index.html. Newest first.
|
|
2
2
|
Each release: v, date (ISO, "" when not recorded), status ("unreleased" until it is tagged), items.
|
|
3
|
-
Each item: tag (new | improved | fixed | breaking | removed) and the text as [en, zh, ja].
|
|
3
|
+
Each item: tag (new | improved | fixed | breaking | removed | rule — a team rule, shown in amber) and the text as [en, zh, ja].
|
|
4
4
|
INTEGRATION.md keeps the full migration notes; this list is the short, reader-facing summary. */
|
|
5
5
|
window.KB_CHANGELOG = [
|
|
6
|
+
{ v: "2.8.0", date: "2026-11-03", status: "unreleased", items: [
|
|
7
|
+
{ tag: "rule", t: ["For notices and dialogs use <code>Toast</code> / <code>ToastStack</code> and <code>Modal</code> (Vue services: <code>Message</code> / <code>MessageBox</code>, built on them). Before building any new component, check the component list; only when nothing there fits may you build one — please check with Felix first.", "提示和弹窗一律使用 <code>Toast</code> / <code>ToastStack</code> 和 <code>Modal</code>(Vue 命令式调用用基于它们的 <code>Message</code> / <code>MessageBox</code>)。新建任何组件前先查组件列表,只有找不到现成可用的才能新建,请先问 Felix。", "通知とダイアログには必ず <code>Toast</code> / <code>ToastStack</code> と <code>Modal</code> を使う(Vue の命令的な呼び出しはそれらを使う <code>Message</code> / <code>MessageBox</code>)。新しいコンポーネントを作る前にコンポーネント一覧を確認し、使えるものがない場合のみ作成可。その際はまず Felix に相談してください。"] },
|
|
8
|
+
{ tag: "improved", t: ["Modal: Esc closes, focus stays inside and returns on close, the page behind stops scrolling; new options to block closing by scrim / Esc / ×, and a must-answer mode.", "弹窗:支持 Esc 关闭,焦点留在弹窗内、关闭后回到原处,打开时背后页面不滚动;新增可选设置:禁止点遮罩 / Esc / × 关闭,以及必须回答模式。", "モーダル:Esc で閉じる、フォーカスを内側に保持し閉じたら元に戻す、背面のスクロールを停止。遮罩 / Esc / × で閉じない設定と、必ず回答するモードを追加。"] },
|
|
9
|
+
{ tag: "improved", t: ["ToastStack: choose the position (top-center, top-right, bottom-right, bottom-center), auto-dismiss with pause on hover, and “× 2” for repeated messages. Toasts are read out by screen readers.", "轻提示堆叠:可设置位置(顶部居中、右上、右下、底部居中),可自动消失、悬停时暂停,重复消息显示“× 2”。读屏软件会读出提示。", "トーストスタック:表示位置(上中央・右上・右下・下中央)を選択可能、ホバーで一時停止する自動クローズ、同じ通知は「× 2」と表示。スクリーンリーダーが読み上げる。"] },
|
|
10
|
+
{ tag: "improved", t: ["Vue Message and MessageBox now render with ToastStack and Modal — no second look to maintain. Their source moved to <code>_vue-services/</code>; imports stay the same.", "Vue 的 Message 和 MessageBox 改为直接用 ToastStack 和 Modal 显示,不再另有一套样式;源码移到 <code>_vue-services/</code>,引用方式不变。", "Vue の Message と MessageBox は ToastStack と Modal で表示するように変更し、別の見た目を持たない。ソースは <code>_vue-services/</code> へ移動、インポート方法は変わらない。"] },
|
|
11
|
+
{ tag: "new", t: ["Table: <code>rowOpen=\"row\"</code> opens a row from a click anywhere on it (hand cursor on the whole row); buttons, checkboxes, inputs, links, the ⋯ menu, the tree arrow and text selection are left alone.", "表格:<code>rowOpen=\"row\"</code> 时点击整行任意空白处即可打开该行(整行显示手型光标);按钮、复选框、输入框、链接、更多菜单、树形箭头和选中文字不受影响。", "テーブル:<code>rowOpen=\"row\"</code> で行のどこをクリックしても開けるように(行全体が手のカーソル)。ボタン、チェックボックス、入力欄、リンク、⋯ メニュー、ツリーの矢印、テキスト選択はそのまま。"] },
|
|
12
|
+
{ tag: "improved", t: ["Tree follows the size tier of its region: 28px rows and 12px text in sm, unchanged in md.", "树形视图跟随所在区域的尺寸档位:sm 档为 28px 行高、12px 文字,md 档外观不变。", "ツリーが領域のサイズ段階に追従:sm は行高 28px・文字 12px、md は従来どおり。"] },
|
|
13
|
+
{ tag: "new", t: ["Tree: <code>icon: false</code> on a node drops its icon without leaving a gap — for a root such as “Documents”.", "树形视图:节点设置 <code>icon: false</code> 时不显示图标、也不留空位,适合“Documents”这类根节点。", "ツリー:ノードに <code>icon: false</code> を指定するとアイコンを表示せず余白も残さない。「Documents」のようなルートに。"] },
|
|
14
|
+
] },
|
|
6
15
|
{ v: "2.7.0", date: "2026-11-02", items: [
|
|
7
16
|
{ tag: "new", t: ["<b>AI button</b> (<code>variant=\"ai\"</code>) for Prophet AI entry points: animated gradient ring, tint and glow on hover.", "<b>AI 按钮</b>(<code>variant=\"ai\"</code>),专用于方知入口:渐变描边缓慢流动,悬停时出现渐变底色和光晕。", "<b>AI ボタン</b>(<code>variant=\"ai\"</code>)を追加。Prophet AI の入口専用:グラデーションの枠が流れ、ホバーで色味と光彩が出る。"] },
|
|
8
17
|
{ tag: "new", t: ["<b>Global Search</b> in the top bar: ⌘K / Ctrl K, scopes, grouped results, Deep Search, and one step to hand a question to Prophet AI.", "顶栏新增<b>全局搜索</b>:⌘K / Ctrl K 唤起,支持范围切换、分组结果、深度搜索,并可一步把问题交给方知。", "トップバーに<b>グローバル検索</b>を追加:⌘K / Ctrl K で起動、スコープ切替、グループ化された結果、ディープサーチ、質問を Prophet AI へ引き継ぎ。"] },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- @dsCard group="Components" viewport="700x520" name="Modal" subtitle="Default · destructive footer · with form content · no subtitle" -->
|
|
1
|
+
<!-- @dsCard group="Components" viewport="700x520" name="Modal" subtitle="Focus · Esc · must-answer dialogs · Default · destructive footer · with form content · no subtitle" -->
|
|
2
2
|
<!DOCTYPE html>
|
|
3
3
|
<html><head><meta charset="utf-8"><script src="../_shared/frame-theme.js"></script><link rel="stylesheet" href="../../styles.css"><link rel="stylesheet" href="../../tokens/hover.css"><link rel="stylesheet" href="../_shared/card-kit.css">
|
|
4
4
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
const { Modal, Button, Field, Input } = window.KeybricksDesignSystem_71231a;
|
|
13
13
|
function Sheet(){
|
|
14
14
|
const [which,setWhich]=React.useState(null);
|
|
15
|
+
const [why,setWhy]=React.useState("");
|
|
16
|
+
const shut=(r)=>{setWhy(r||"button");setWhich(null);};
|
|
15
17
|
return <div>
|
|
16
18
|
<div className="row">
|
|
17
19
|
<Button variant="outline" onClick={()=>setWhich("confirm")}>Open confirm</Button>
|
|
18
20
|
<Button variant="outline" onClick={()=>setWhich("destructive")}>Open destructive</Button>
|
|
19
21
|
<Button variant="outline" onClick={()=>setWhich("form")}>Open with form</Button>
|
|
20
22
|
<Button variant="outline" onClick={()=>setWhich("plain")}>Open, no subtitle</Button>
|
|
23
|
+
<Button variant="outline" onClick={()=>setWhich("must")}>Must answer</Button>
|
|
21
24
|
</div>
|
|
25
|
+
<p style={{margin:"12px 0 0",fontSize:"var(--text-xs)",lineHeight:"17px",color:"var(--text-secondary)",maxWidth:"64ch"}}>Every dialog: focus moves in, Tab stays inside, Esc closes the top one, the page behind stops scrolling, and focus returns to the button that opened it. <b>Must answer</b> uses <code>alert</code>, <code>closeOnScrim={"{false}"}</code>, <code>closeOnEsc={"{false}"}</code> and <code>showClose={"{false}"}</code>, so only its buttons close it. Last close reason: <code>{why||"none"}</code></p>
|
|
22
26
|
<Modal open={which==="confirm"} title="Discard changes?" subtitle="Your edits to this node haven't been saved." onClose={()=>setWhich(null)}
|
|
23
27
|
footer={<React.Fragment><Button variant="outline" onClick={()=>setWhich(null)}>Keep Editing</Button><Button onClick={()=>setWhich(null)}>Discard</Button></React.Fragment>}/>
|
|
24
28
|
<Modal open={which==="destructive"} title="Delete node?" subtitle="“Sales × Product Join” and its 2 edges will be removed." onClose={()=>setWhich(null)}
|
|
@@ -27,7 +31,9 @@ function Sheet(){
|
|
|
27
31
|
footer={<React.Fragment><Button variant="outline" onClick={()=>setWhich(null)}>Cancel</Button><Button onClick={()=>setWhich(null)}>Save</Button></React.Fragment>}>
|
|
28
32
|
<Field label="Node Name" required><Input defaultValue="Sales × Product Join"/></Field>
|
|
29
33
|
</Modal>
|
|
30
|
-
<Modal open={which==="plain"} title="Export complete" onClose={
|
|
34
|
+
<Modal open={which==="plain"} title="Export complete" onClose={shut} footer={<Button onClick={()=>shut()}>Done</Button>}/>
|
|
35
|
+
<Modal open={which==="must"} alert closeOnScrim={false} closeOnEsc={false} showClose={false} title="Publish to production?" subtitle="Users see the new model as soon as it is published." onClose={shut}
|
|
36
|
+
footer={<React.Fragment><Button variant="outline" onClick={()=>shut("cancel")}>Cancel</Button><Button data-autofocus="" onClick={()=>shut("publish")}>Publish</Button></React.Fragment>}/>
|
|
31
37
|
</div>;
|
|
32
38
|
}
|
|
33
39
|
ReactDOM.createRoot(document.getElementById("root")).render(<Sheet/>);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- @dsCard group="Components" viewport="
|
|
1
|
+
<!-- @dsCard group="Components" viewport="700x11729" name="Table" subtitle="Anatomy · densities · cell types (display · editable · per tier) · two-line name · whole-row open · sorting · selection · frames · column resize · states · composition" -->
|
|
2
2
|
<!DOCTYPE html>
|
|
3
3
|
<html><head><meta charset="utf-8"><script src="../_shared/frame-theme.js"></script><link rel="stylesheet" href="../../styles.css"><link rel="stylesheet" href="../../tokens/hover.css"><link rel="stylesheet" href="../_shared/card-kit.css">
|
|
4
4
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
@@ -167,6 +167,11 @@ function Sheet() {
|
|
|
167
167
|
const [srt, setSrt] = React.useState(null);
|
|
168
168
|
const [exp, setExp] = React.useState(["budget"]);
|
|
169
169
|
const [opened, setOpened] = React.useState(null);
|
|
170
|
+
const [rowExp, setRowExp] = React.useState(["budget"]);
|
|
171
|
+
const [rowSel, setRowSel] = React.useState([]);
|
|
172
|
+
const [rowOpened, setRowOpened] = React.useState(null);
|
|
173
|
+
const rowToggle = (w, v, next) => setRowSel(w === "all" ? (next || []) : v ? [...rowSel, w] : rowSel.filter(i => i !== w));
|
|
174
|
+
const openWholeRow = (r) => { if (r.children) setRowExp(rowExp.includes(r.id) ? rowExp.filter(k => k !== r.id) : [...rowExp, r.id]); else setRowOpened(r.name); };
|
|
170
175
|
const [pin, setPin] = React.useState([3]);
|
|
171
176
|
const [lock, setLock] = React.useState([0, 3]);
|
|
172
177
|
const lockFn = lockRule(lock);
|
|
@@ -225,6 +230,10 @@ function Sheet() {
|
|
|
225
230
|
<Table bordered tree columns={DOCS} rows={TREE_ROWS} expandedKeys={exp} onExpandChange={(r, open, keys) => setExp(keys)} onRowOpen={(r) => setOpened(r.name)} />
|
|
226
231
|
<div className="note">Each level indents 24px; the 20px arrow slot plus 4px puts a child's arrow under its parent's tile. Rows without children keep the slot, so tiles line up. Click a tile or a title to open the row (hand cursor; Enter or Space on the title). Sorting reorders siblings only. Opened: <code>{opened || "none"}</code> · <code>expandedKeys</code>: <code>{JSON.stringify(exp)}</code></div>
|
|
227
232
|
</Case>
|
|
233
|
+
<Case k={'rowOpen="row" — click anywhere on a row to open it · whole row shows the hand cursor'}>
|
|
234
|
+
<Table bordered tree selectable rowOpen="row" columns={DOCS} rows={TREE_ROWS} selected={rowSel} onToggle={rowToggle} expandedKeys={rowExp} onExpandChange={(r, open, keys) => setRowExp(keys)} onRowOpen={openWholeRow} onRowMore={(r) => setRowOpened("⋯ " + r.name)} />
|
|
235
|
+
<div className="note">Default <code>rowOpen="title"</code> opens on the tile and the title only (above). With <code>"row"</code> any blank part of a body row calls <code>onRowOpen(row, index, event)</code>. Clicks on the checkbox, the ⋯ menu, the tree arrow, buttons, inputs and links do their own job and never open the row; neither does a click that ends a text selection, nor the header or a resize handle. The tile and the title open the row once, not twice. Here a folder row expands or collapses (the app decides in <code>onRowOpen</code>), a document opens. Last: <code>{rowOpened || "none"}</code></div>
|
|
236
|
+
</Case>
|
|
228
237
|
<Case k="sm · 50px rows — 28px tile, 8px to the text, 12px title, 8px padding"><Table size="sm" bordered columns={DOCS} rows={DOC_ROWS.slice(2, 4)} /></Case>
|
|
229
238
|
<Case k="lg · 68px rows — 40px tile r10, 20px icon, 4px between lines, 14px padding"><Table size="lg" bordered columns={DOCS} rows={DOC_ROWS.slice(2, 4)} /></Case>
|
|
230
239
|
<Case k="md · CJK — description 13/18, row 60px (lang="zh-CN") · labels: the sort and ⋯ tooltips in Chinese"><div lang="zh-CN"><Table bordered labels={ZH_LABELS} columns={[{ ...DOCS[0], label: "名称" }, { ...DOCS[1], label: "详情" }, { ...DOCS[2], label: "修改时间" }]} rows={DOC_ROWS_ZH} /></div></Case>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- @dsCard group="Components" viewport="
|
|
1
|
+
<!-- @dsCard group="Components" viewport="700x1087" name="Toast" subtitle="Placement · auto-dismiss · repeat count · Tones · with/without meta · live ToastStack demo" -->
|
|
2
2
|
<!DOCTYPE html>
|
|
3
3
|
<html><head><meta charset="utf-8"><script src="../_shared/frame-theme.js"></script><link rel="stylesheet" href="../../styles.css"><link rel="stylesheet" href="../../tokens/hover.css"><link rel="stylesheet" href="../_shared/card-kit.css">
|
|
4
4
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
@@ -14,6 +14,9 @@ const { Toast, ToastStack, Button } = window.KeybricksDesignSystem_71231a;
|
|
|
14
14
|
const { KBLive: Live } = window;
|
|
15
15
|
function Sheet(){
|
|
16
16
|
const [toasts,setToasts]=React.useState([]);
|
|
17
|
+
const [place,setPlace]=React.useState("top-center");
|
|
18
|
+
const [stack,setStack]=React.useState([]);
|
|
19
|
+
const push=(tone,title)=>setStack(s=>{const ex=s.find(t=>t.title===title);if(ex)return s.map(t=>t===ex?{...t,count:(t.count||1)+1}:t);return [...s,{id:Date.now()+Math.random(),tone,title}];});
|
|
17
20
|
const notify=(tone,title,meta)=>{const id=Date.now()+Math.random();setToasts(s=>[...s,{id,tone,title,meta}]);setTimeout(()=>setToasts(s=>s.filter(t=>t.id!==id)),3200);};
|
|
18
21
|
return <div>
|
|
19
22
|
<div className="col">
|
|
@@ -28,6 +31,13 @@ function Sheet(){
|
|
|
28
31
|
<Button variant="outline" onClick={()=>notify("danger","Task failed","Retry available")}>Trigger danger</Button>
|
|
29
32
|
</div>
|
|
30
33
|
<ToastStack toasts={toasts} onDismiss={(id)=>setToasts(s=>s.filter(t=>t.id!==id))}/>
|
|
34
|
+
<div style={{borderTop:"1px solid var(--border-subtle)",marginTop:20,paddingTop:16,display:"flex",flexDirection:"column",gap:10}}>
|
|
35
|
+
<span style={{fontSize:"var(--text-xs)",fontWeight:600,color:"var(--text-muted)"}}>ToastStack — placement · duration · repeat count</span>
|
|
36
|
+
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>{["top-center","top-right","bottom-right","bottom-center"].map(p=><Button key={p} variant={p===place?"primary-soft":"outline"} onClick={()=>setPlace(p)}>{p}</Button>)}</div>
|
|
37
|
+
<div style={{display:"flex",flexWrap:"wrap",gap:8}}><Button onClick={()=>push("success","Saved")}>Saved</Button><Button variant="outline" onClick={()=>push("warning","Check the highlighted fields")}>Warning</Button><Button variant="outline" onClick={()=>push("danger","Sync failed")}>Error</Button></div>
|
|
38
|
+
<span style={{fontSize:"var(--text-xs)",lineHeight:"17px",color:"var(--text-secondary)",maxWidth:"64ch"}}><code>placement</code> sets the corner or edge (default <code>bottom-right</code>; the Vue Message service uses <code>top-center</code>). <code>duration={"{3000}"}</code> dismisses each toast after 3 s, paused while the pointer or focus is on it. Clicking the same message again shows <code>× 2</code> instead of stacking a copy, and restarts its timer. Toasts announce themselves to screen readers (danger and warning as alerts).</span>
|
|
39
|
+
</div>
|
|
40
|
+
<ToastStack toasts={stack} placement={place} duration={3000} onDismiss={(id)=>setStack(s=>s.filter(t=>t.id!==id))}/>
|
|
31
41
|
</div>;
|
|
32
42
|
}
|
|
33
43
|
ReactDOM.createRoot(document.getElementById("root")).render(<Sheet/>);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- @dsCard group="Components" viewport="
|
|
1
|
+
<!-- @dsCard group="Components" viewport="700x3385" name="Tree" subtitle="Size tiers · rows without an icon · Cascading checkboxes · disabled nodes · lines · async load · search · drag · reorder · keyboard" -->
|
|
2
2
|
<!DOCTYPE html>
|
|
3
3
|
<html><head><meta charset="utf-8"><script src="../_shared/frame-theme.js"></script><link rel="stylesheet" href="../../styles.css"><link rel="stylesheet" href="../../tokens/hover.css"><link rel="stylesheet" href="../_shared/card-kit.css">
|
|
4
4
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
@@ -178,7 +178,28 @@ function WithActions(){
|
|
|
178
178
|
</div>;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
const ROOTED = [{key:"docs",title:"Documents",icon:false,children:FILES}];
|
|
182
|
+
|
|
183
|
+
function Tiers(){
|
|
184
|
+
return <div>
|
|
185
|
+
<span className="lbl">Size tiers — follows the region's data-size</span>
|
|
186
|
+
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
|
187
|
+
{["sm","md"].map(t=><div key={t} data-size={t}><div className="hint" style={{paddingBottom:4}}>{t==="sm"?"sm · 28px rows · 12px text (13px CJK)":"md · 32px rows · 14px text"}</div><Tree items={FILES} defaultExpanded={["finance"]} defaultActive="close"/></div>)}
|
|
188
|
+
</div>
|
|
189
|
+
<div className="hint">Row height <code>--control-h</code>, text <code>--control-text</code> / <code>--control-leading</code> / <code>--control-tracking</code>, glyph <code>--control-icon</code> − 1px. md is unchanged; lg rows are 40px.</div>
|
|
190
|
+
</div>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function NoIconRoot(){
|
|
194
|
+
return <div>
|
|
195
|
+
<span className="lbl">icon: false — a root without a glyph</span>
|
|
196
|
+
<Tree items={ROOTED} defaultExpanded={["docs","finance"]} defaultActive="docs"/>
|
|
197
|
+
<div className="hint">The node draws no glyph and keeps no gap for one: its title follows the arrow. Works with <code>showIcon</code> on; every other row keeps its glyph.</div>
|
|
198
|
+
</div>;
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
function Sheet(){return <div className="g">
|
|
202
|
+
<Tiers/><NoIconRoot/>
|
|
182
203
|
<Basic/><Checkable/>
|
|
183
204
|
<Strict/><DisabledCase/>
|
|
184
205
|
<Lines/><Multi/>
|
|
@@ -93,6 +93,11 @@ export interface DataTableProps {
|
|
|
93
93
|
onRowMore?: (row: any, index: number, event: React.MouseEvent) => void;
|
|
94
94
|
/** Opens a row from its twoLine cell: the icon tile and the title get a hand cursor and call this on click (the title also on Enter / Space). Vue: `@row-open`. */
|
|
95
95
|
onRowOpen?: (row: any, index: number, event: React.SyntheticEvent) => void;
|
|
96
|
+
/** What opens a row (needs onRowOpen). "title" (default): the twoLine tile and title only. "row": a click anywhere on a body row calls onRowOpen and the whole row
|
|
97
|
+
* shows the hand cursor — except clicks on buttons (the ⋯ menu, the tree arrow), checkboxes, inputs, links, menus, and a click that ends a text selection.
|
|
98
|
+
* The header and the resize handles never open a row. The tile and title still open it once, never twice. Tree folders: decide in onRowOpen
|
|
99
|
+
* whether to expand (expandedKeys) or enter. Vue: `row-open="row"` with `@row-open`. */
|
|
100
|
+
rowOpen?: "title" | "row";
|
|
96
101
|
/** Tree rows: a row nests through `children` (or sets `hasChildren: true` to show the arrow before its children are loaded). The twoLine column indents 24px per level and shows an expand arrow (chevron-right, turned down when open). Sorting reorders siblings only. Selection and event indices are depth-first over every row, so they never move. */
|
|
97
102
|
tree?: boolean;
|
|
98
103
|
/** Row field that identifies a row in expandedKeys (default "id"; falls back to the row's index). */
|
|
@@ -3,6 +3,7 @@ import { Checkbox } from "../forms/Checkbox.jsx";
|
|
|
3
3
|
import { IconTile } from "../display/Badge.jsx";
|
|
4
4
|
import { IconButton } from "../buttons/IconButton.jsx";
|
|
5
5
|
import { Icon } from "../icons/Icon.jsx";
|
|
6
|
+
import { rowOpenFromClick } from "./row-open.js";
|
|
6
7
|
|
|
7
8
|
/* Selection checkbox: sits in the first column's leading padding, not in a column of its own, so the gap to the
|
|
8
9
|
first column's text equals the edge padding (16px; 12px in sm) whatever the table width or column widths (a separate column took a share
|
|
@@ -103,10 +104,10 @@ function TitleCell({ c, item, onRowMore, onRowOpen, labels, tree, onToggleRow })
|
|
|
103
104
|
<div style={{ display: "flex", alignItems: "center", minWidth: c.minWidth ?? 180, ...(tree ? { paddingLeft: item.level * TREE_INDENT } : null) }}>
|
|
104
105
|
{tree ? <span style={{ width: TREE_TOGGLE, flexShrink: 0, marginRight: TREE_INDENT - TREE_TOGGLE, display: "inline-flex" }}>{item.kids ? <TreeToggle open={item.open} label={item.open ? labels.collapse : labels.expand} onClick={() => onToggleRow(item)} /> : null}</span> : null}
|
|
105
106
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--title-cell-tile-gap)", flex: 1, minWidth: 0 }}>
|
|
106
|
-
{icon ? <span onClick={open || undefined} style={{ display: "inline-flex", flexShrink: 0, cursor: open ? "pointer" : undefined }}><IconTile icon={icon} hue={hue || "neutral"} kind="row" style={T_TILE} iconStyle={T_ICON} /></span> : null}
|
|
107
|
+
{icon ? <span data-row-open={open ? "" : undefined} onClick={open || undefined} style={{ display: "inline-flex", flexShrink: 0, cursor: open ? "pointer" : undefined }}><IconTile icon={icon} hue={hue || "neutral"} kind="row" style={T_TILE} iconStyle={T_ICON} /></span> : null}
|
|
107
108
|
<div style={{ display: "flex", flexDirection: "column", gap: "var(--title-cell-gap)", flex: 1, minWidth: 0 }}>
|
|
108
109
|
<div style={{ display: "flex", alignItems: "center", gap: 4, minWidth: 0 }}>
|
|
109
|
-
<span style={{ ...T_TITLE, ...(open ? { cursor: "pointer" } : null) }} {...(open ? { role: "button", tabIndex: 0, onClick: open, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(e); } } } : null)}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</span>
|
|
110
|
+
<span style={{ ...T_TITLE, ...(open ? { cursor: "pointer" } : null) }} {...(open ? { "data-row-open": "", role: "button", tabIndex: 0, onClick: open, onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(e); } } } : null)}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</span>
|
|
110
111
|
{mark ? <TitleMark m={mark} /> : null}
|
|
111
112
|
</div>
|
|
112
113
|
{desc ? <span style={T_DESC}>{desc}</span> : null}
|
|
@@ -124,6 +125,9 @@ function TitleCell({ c, item, onRowMore, onRowOpen, labels, tree, onToggleRow })
|
|
|
124
125
|
* (defaultSort to seed it uncontrolled); compare(a, b, rowA, rowB) overrides the default comparison; manualSort keeps rows as given.
|
|
125
126
|
* twoLine — the name column of a document list: icon tile, title over a description, optional mark ("favorite" = filled star), trailing ⋯
|
|
126
127
|
* (onRowMore(row, index, event)); onRowOpen(row, index, event) makes the tile and the title open the row (hand cursor, Enter / Space on the title).
|
|
128
|
+
* rowOpen — "title" (default): only the tile and the title open a row. "row": a click anywhere on a body row calls onRowOpen(row, index, event) and the
|
|
129
|
+
* whole row shows the hand cursor — except clicks on buttons (⋯, tree arrow), checkboxes, inputs, links, menus, and clicks that end a text
|
|
130
|
+
* selection. The tile and title still open it once (never twice). Tree folders: decide in onRowOpen whether to expand or enter.
|
|
127
131
|
* tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an
|
|
128
132
|
* expand arrow; expandedKeys / defaultExpandedKeys / onExpandChange(row, expanded, keys); rowKey names the key field (default "id").
|
|
129
133
|
* labels — { sortAsc, sortDesc, sortClear, more, expand, collapse } for every word the table draws (English by default).
|
|
@@ -147,7 +151,7 @@ function TitleCell({ c, item, onRowMore, onRowOpen, labels, tree, onToggleRow })
|
|
|
147
151
|
* resizable — a short divider between header cells (hover darkens it, cursor col-resize); drag to set that column's width.
|
|
148
152
|
* Widths live in local state unless you pass columnWidths (then update it from onColumnResize). onColumnResize(key, px) fires on every drag either way. Min width = column.minWidth ?? 48.
|
|
149
153
|
*/
|
|
150
|
-
export function Table({ columns = [], rows = [], size, dense, selectable, selected = [], onToggle, pinSelected, isRowDisabled, disabledReason, headerBorder = true, bordered, framed, resizable, columnWidths, onColumnResize, onRowMore, onRowOpen, sort, defaultSort = null, onSortChange, manualSort, tree, rowKey = "id", expandedKeys, defaultExpandedKeys, onExpandChange, labels, empty, style }) {
|
|
154
|
+
export function Table({ columns = [], rows = [], size, dense, selectable, selected = [], onToggle, pinSelected, isRowDisabled, disabledReason, headerBorder = true, bordered, framed, resizable, columnWidths, onColumnResize, onRowMore, onRowOpen, rowOpen = "title", sort, defaultSort = null, onSortChange, manualSort, tree, rowKey = "id", expandedKeys, defaultExpandedKeys, onExpandChange, labels, empty, style }) {
|
|
151
155
|
const L = { ...DEFAULT_LABELS, ...labels };
|
|
152
156
|
const [localExp, setLocalExp] = React.useState(defaultExpandedKeys || []);
|
|
153
157
|
const expKeys = expandedKeys !== undefined ? expandedKeys : localExp;
|
|
@@ -192,6 +196,7 @@ export function Table({ columns = [], rows = [], size, dense, selectable, select
|
|
|
192
196
|
if (onExpandChange) onExpandChange(item.r, !item.open, next);
|
|
193
197
|
};
|
|
194
198
|
const tier = size || (dense ? "sm" : undefined);
|
|
199
|
+
const rowMode = rowOpen === "row" && !!onRowOpen;
|
|
195
200
|
const flushTop = bordered || framed;
|
|
196
201
|
const colCount = columns.length || 1;
|
|
197
202
|
const [localW, setLocalW] = React.useState({});
|
|
@@ -262,9 +267,9 @@ export function Table({ columns = [], rows = [], size, dense, selectable, select
|
|
|
262
267
|
</thead>
|
|
263
268
|
<tbody>
|
|
264
269
|
{view.map((item, pos) => { const { r, ri } = item; const dis = isDis(r, ri); return (<React.Fragment key={item.key}>{groupAt(pos)}
|
|
265
|
-
<tr data-row="" aria-level={tree ? item.level + 1 : undefined} aria-expanded={item.kids ? item.open : undefined} style={{ ...(dis ? { "--row-wash": "initial" } : null), height: "var(--bar-h)", background: pinInfo && pinInfo.sel.has(ri) ? "var(--row-wash, var(--bg-inset))" : "var(--row-wash, transparent)", transition: "var(--t-hover)", borderBottom: ruleBelow(pos) ? "1px solid var(--border-control)" : bordered && pos === view.length - 1 ? "none" : "1px solid var(--border-subtle)" }}>
|
|
270
|
+
<tr data-row="" aria-level={tree ? item.level + 1 : undefined} aria-expanded={item.kids ? item.open : undefined} onClick={rowMode ? (e) => { if (rowOpenFromClick(e)) onRowOpen(r, ri, e); } : undefined} style={{ ...(dis ? { "--row-wash": "initial" } : null), ...(rowMode ? { cursor: "pointer" } : null), height: "var(--bar-h)", background: pinInfo && pinInfo.sel.has(ri) ? "var(--row-wash, var(--bg-inset))" : "var(--row-wash, transparent)", transition: "var(--t-hover)", borderBottom: ruleBelow(pos) ? "1px solid var(--border-control)" : bordered && pos === view.length - 1 ? "none" : "1px solid var(--border-subtle)" }}>
|
|
266
271
|
{columns.map((c, i) => (
|
|
267
|
-
<td key={c.key} style={{ ...cellPad(i), ...(c.twoLine ? { paddingTop: "var(--title-cell-py)", paddingBottom: "var(--title-cell-py)", maxWidth: 0 } : null), textAlign: c.align || "left", whiteSpace: "nowrap", ...(resizable ? { overflow: "hidden", textOverflow: "ellipsis" } : null), ...cellFont(c) }}>{selectable && i === 0 ? <span style={checkSlot} title={dis && disabledReason ? disabledReason(r, ri) : undefined}><Checkbox checked={selected.includes(ri)} disabled={dis} onChange={(v) => onToggle && onToggle(ri, v)} /></span> : null}{c.twoLine ? <TitleCell c={c} item={item} onRowMore={onRowMore} onRowOpen={onRowOpen} labels={L} tree={tree && c === columns.find((x) => x.twoLine)} onToggleRow={toggleRow} /> : (c.code && (typeof r[c.key] === "string" || typeof r[c.key] === "number") && r[c.key] !== "") ? <code style={codeChip}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</code> : c.render ? c.render(r[c.key], r, ri) : r[c.key]}</td>
|
|
272
|
+
<td key={c.key} style={{ ...cellPad(i), ...(c.twoLine ? { paddingTop: "var(--title-cell-py)", paddingBottom: "var(--title-cell-py)", maxWidth: 0 } : null), textAlign: c.align || "left", whiteSpace: "nowrap", ...(resizable ? { overflow: "hidden", textOverflow: "ellipsis" } : null), ...cellFont(c) }}>{selectable && i === 0 ? <span data-row-skip="" style={checkSlot} title={dis && disabledReason ? disabledReason(r, ri) : undefined}><Checkbox checked={selected.includes(ri)} disabled={dis} onChange={(v) => onToggle && onToggle(ri, v)} /></span> : null}{c.twoLine ? <TitleCell c={c} item={item} onRowMore={onRowMore} onRowOpen={onRowOpen} labels={L} tree={tree && c === columns.find((x) => x.twoLine)} onToggleRow={toggleRow} /> : (c.code && (typeof r[c.key] === "string" || typeof r[c.key] === "number") && r[c.key] !== "") ? <code style={codeChip}>{c.render ? c.render(r[c.key], r, ri) : r[c.key]}</code> : c.render ? c.render(r[c.key], r, ri) : r[c.key]}</td>
|
|
268
273
|
))}
|
|
269
274
|
</tr></React.Fragment>
|
|
270
275
|
); })}
|
|
@@ -5,6 +5,7 @@ import Checkbox from '../forms/Checkbox.vue'
|
|
|
5
5
|
import IconTile from '../display/IconTile.vue'
|
|
6
6
|
import IconButton from '../buttons/IconButton.vue'
|
|
7
7
|
import Icon from '../icons/Icon.vue'
|
|
8
|
+
import { rowOpenFromClick } from './row-open.js'
|
|
8
9
|
// A cell: a #cell-<key> slot ({ value, row, index }) when the page gives one; else column.render's result — text or a VNode from h() — or the raw value.
|
|
9
10
|
const Cell = (p) => p.v
|
|
10
11
|
Cell.props = ['v']
|
|
@@ -12,6 +13,8 @@ Cell.props = ['v']
|
|
|
12
13
|
// or render(value, row, index) returning text or a VNode (h()).
|
|
13
14
|
// twoLine — the name column of a document list: icon tile, title over a description, optional mark ("favorite" = filled star), trailing ⋯
|
|
14
15
|
// (@row-more(row, index, event)); @row-open(row, index, event) makes the tile and the title open the row (hand cursor, Enter / Space on the title).
|
|
16
|
+
// rowOpen — 'title' (default) | 'row': with 'row' a click anywhere on a body row emits row-open (hand cursor on the whole row), except on buttons
|
|
17
|
+
// (⋯, tree arrow), checkboxes, inputs, links, menus, or a click ending a text selection. Tile and title still open once. Tree folders: decide in @row-open.
|
|
15
18
|
// tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an
|
|
16
19
|
// expand arrow; v-model:expandedKeys / defaultExpandedKeys / @expand-change(row, expanded, keys); rowKey names the key field (default "id").
|
|
17
20
|
// labels — { sortAsc, sortDesc, sortClear, more, expand, collapse } for every word the table draws (English by default).
|
|
@@ -35,7 +38,7 @@ Cell.props = ['v']
|
|
|
35
38
|
// isRowDisabled(row, index) — with selectable: the app's lock for a row, called on every render. Locked: checkbox disabled (50%, not-allowed, no hover, no toggle), no row hover wash,
|
|
36
39
|
// cells left to the app. Never changes selected. Select-all / Clear skip locked rows — @toggle('all', v, next) passes the result. disabledReason(row, index) → checkbox tooltip.
|
|
37
40
|
// #empty slot renders inside the table when rows is empty.
|
|
38
|
-
const props = defineProps({ columns: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, size: String, dense: Boolean, selectable: Boolean, pinSelected: [Boolean, String], isRowDisabled: Function, disabledReason: Function, selected: { type: Array, default: () => [] }, headerBorder: { type: Boolean, default: true }, bordered: Boolean, framed: Boolean, resizable: Boolean, columnWidths: Object, sort: { type: Object, default: undefined }, defaultSort: { type: Object, default: null }, manualSort: Boolean, tree: Boolean, rowKey: { type: String, default: 'id' }, expandedKeys: { type: Array, default: undefined }, defaultExpandedKeys: { type: Array, default: () => [] }, labels: Object, style: Object })
|
|
41
|
+
const props = defineProps({ columns: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, size: String, dense: Boolean, selectable: Boolean, pinSelected: [Boolean, String], isRowDisabled: Function, disabledReason: Function, selected: { type: Array, default: () => [] }, headerBorder: { type: Boolean, default: true }, rowOpen: { type: String, default: 'title' }, bordered: Boolean, framed: Boolean, resizable: Boolean, columnWidths: Object, sort: { type: Object, default: undefined }, defaultSort: { type: Object, default: null }, manualSort: Boolean, tree: Boolean, rowKey: { type: String, default: 'id' }, expandedKeys: { type: Array, default: undefined }, defaultExpandedKeys: { type: Array, default: () => [] }, labels: Object, style: Object })
|
|
39
42
|
const emit = defineEmits(['toggle', 'columnResize', 'rowMore', 'rowOpen', 'sortChange', 'update:sort', 'expandChange', 'update:expandedKeys'])
|
|
40
43
|
// Every word the table draws itself; `labels` translates them. The sort tooltip names the NEXT step: unsorted → sortAsc, ascending → sortDesc, descending → sortClear.
|
|
41
44
|
const DEFAULT_LABELS = { sortAsc: 'Sort ascending', sortDesc: 'Sort descending', sortClear: 'Clear sorting', more: 'More actions', expand: 'Expand', collapse: 'Collapse', pinned: 'Selected', others: 'Others', unavailable: 'Unavailable', clear: 'Clear' }
|
|
@@ -45,6 +48,9 @@ const sortLabel = (sorted) => sorted === 'asc' ? L.value.sortDesc : sorted === '
|
|
|
45
48
|
const inst = getCurrentInstance()
|
|
46
49
|
const canOpen = computed(() => !!(inst && inst.vnode.props && (inst.vnode.props.onRowOpen || inst.vnode.props['onRow-open'])))
|
|
47
50
|
function openRow(r, ri, e) { emit('rowOpen', r, ri, e) }
|
|
51
|
+
// rowOpen="row": a click anywhere on a body row opens it (see row-open.js for what is skipped); the tile and title keep their own single open.
|
|
52
|
+
const rowMode = computed(() => props.rowOpen === 'row' && canOpen.value)
|
|
53
|
+
function rowClick(item, e) { if (rowMode.value && rowOpenFromClick(e)) openRow(item.r, item.ri, e) }
|
|
48
54
|
function openKey(r, ri, e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(r, ri, e) } }
|
|
49
55
|
// Tree: 24px per level; a 20px arrow slot + 4px, so a child's arrow sits under its parent's tile.
|
|
50
56
|
const TREE_INDENT = 24, TREE_TOGGLE = 20
|
|
@@ -186,9 +192,9 @@ function cellPad(i) { return { ...(i === 0 && props.selectable ? { position: 're
|
|
|
186
192
|
<tr v-if="pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pinInfo.top && pos === 0"><td :colspan="columns.length || 1" :style="GROUP_CELL"><span style="display:flex;align-items:center;gap:8px">{{ L.pinned }} · {{ pinInfo.n }}<button type="button" data-link="" @click="toggleAll(false)" :style="CLEAR_BTN">{{ L.clear }}</button></span></td></tr>
|
|
187
193
|
<tr v-else-if="pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pos === pinInfo.rest"><td :colspan="columns.length || 1" :style="GROUP_CELL">{{ L.others }} · {{ pinInfo.others }}</td></tr>
|
|
188
194
|
<tr v-else-if="pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pos === pinInfo.unStart"><td :colspan="columns.length || 1" :style="GROUP_CELL">{{ L.unavailable }} · {{ pinInfo.un }}</td></tr>
|
|
189
|
-
<tr data-row="" :aria-level="tree ? item.level + 1 : undefined" :aria-expanded="item.kids ? item.open : undefined" :style="{ ...(isDis(item.r, item.ri) ? { '--row-wash': 'initial' } : null), height: 'var(--bar-h)', background: pinInfo && pinInfo.sel.has(item.ri) ? 'var(--row-wash, var(--bg-inset))' : 'var(--row-wash, transparent)', transition: 'var(--t-hover)', borderBottom: pinInfo && ((pinInfo.rest > 0 && pos === pinInfo.rest - 1) || (pinInfo.unStart > 0 && pos === pinInfo.unStart - 1)) ? '1px solid var(--border-control)' : bordered && pos === view.length - 1 ? 'none' : '1px solid var(--border-subtle)' }">
|
|
195
|
+
<tr data-row="" :aria-level="tree ? item.level + 1 : undefined" :aria-expanded="item.kids ? item.open : undefined" @click="e => rowClick(item, e)" :style="{ ...(isDis(item.r, item.ri) ? { '--row-wash': 'initial' } : null), ...(rowMode ? { cursor: 'pointer' } : null), height: 'var(--bar-h)', background: pinInfo && pinInfo.sel.has(item.ri) ? 'var(--row-wash, var(--bg-inset))' : 'var(--row-wash, transparent)', transition: 'var(--t-hover)', borderBottom: pinInfo && ((pinInfo.rest > 0 && pos === pinInfo.rest - 1) || (pinInfo.unStart > 0 && pos === pinInfo.unStart - 1)) ? '1px solid var(--border-control)' : bordered && pos === view.length - 1 ? 'none' : '1px solid var(--border-subtle)' }">
|
|
190
196
|
<template v-for="{ r, ri } in [item]" :key="'r'">
|
|
191
|
-
<td v-for="(c, i) in columns" :key="c.key" :style="{ ...cellPad(i), ...(c.twoLine ? { paddingTop: 'var(--title-cell-py)', paddingBottom: 'var(--title-cell-py)', maxWidth: 0 } : null), textAlign: c.align || 'left', whiteSpace: 'nowrap', ...(resizable ? { overflow: 'hidden', textOverflow: 'ellipsis' } : null), ...cellFont(c) }"><span v-if="selectable && i === 0" :style="checkSlot" :title="isDis(r, ri) && disabledReason ? disabledReason(r, ri) : undefined"><Checkbox :checked="selected.includes(ri)" :disabled="isDis(r, ri)" @change="v => emit('toggle', ri, v)" /></span><div v-if="c.twoLine" :style="{ display: 'flex', alignItems: 'center', minWidth: px(c.minWidth ?? 180), ...(treeCol === c ? { paddingLeft: (item.level * TREE_INDENT) + 'px' } : null) }"><span v-if="treeCol === c" :style="{ width: TREE_TOGGLE + 'px', flexShrink: 0, marginRight: (TREE_INDENT - TREE_TOGGLE) + 'px', display: 'inline-flex' }"><button v-if="item.kids" type="button" :aria-expanded="item.open" :aria-label="item.open ? L.collapse : L.expand" :title="item.open ? L.collapse : L.expand" @click.stop="toggleRow(item)" @mouseenter="hotToggle = item.key" @mouseleave="hotToggle = null" :style="{ width: TREE_TOGGLE + 'px', height: TREE_TOGGLE + 'px', padding: 0, border: 'none', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: hotToggle === item.key ? 'var(--overlay-hover)' : 'transparent', color: hotToggle === item.key ? 'var(--text-body)' : 'var(--text-muted)', transition: 'var(--t-hover)' }"><Icon name="chevron-right" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)', transform: item.open ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }" /></button></span><div style="display:flex;align-items:center;gap:var(--title-cell-tile-gap);flex:1;min-width:0"><template v-for="t in [tl(c, r, ri)]" :key="0"><span v-if="t.icon" :style="{ display: 'inline-flex', flexShrink: 0, cursor: canOpen ? 'pointer' : undefined }" @click="e => canOpen && openRow(r, ri, e)"><IconTile :icon="t.icon" :hue="t.hue || 'neutral'" kind="row" :style="T_TILE" :icon-style="T_ICON" /></span><div style="display:flex;flex-direction:column;gap:var(--title-cell-gap);flex:1;min-width:0"><div style="display:flex;align-items:center;gap:4px;min-width:0"><span v-if="canOpen" role="button" tabindex="0" :style="{ ...T_TITLE, cursor: 'pointer' }" @click="e => openRow(r, ri, e)" @keydown="e => openKey(r, ri, e)"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><span v-else :style="T_TITLE"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><svg v-if="t.mark === 'favorite'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round" role="img" aria-label="Favorite" style="display:block;flex-shrink:0;color:var(--warning)"><path :d="STAR_FILLED" /></svg><span v-else-if="t.mark" role="img" :aria-label="t.mark.label" :style="{ display: 'inline-flex', flexShrink: 0, color: t.mark.color || 'var(--text-muted)' }"><Icon :name="t.mark.icon" :size="14" /></span></div><span v-if="t.desc" :style="T_DESC">{{ t.desc }}</span></div><IconButton v-if="t.more" icon="ellipsis" size="sm" muted :label="L.more" :title="L.more" @click="e => more(r, ri, e)" /></template></div></div><code v-else-if="c.code && (typeof r[c.key] === 'string' || typeof r[c.key] === 'number') && r[c.key] !== ''" :style="codeChip"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></code><template v-else><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></template></td>
|
|
197
|
+
<td v-for="(c, i) in columns" :key="c.key" :style="{ ...cellPad(i), ...(c.twoLine ? { paddingTop: 'var(--title-cell-py)', paddingBottom: 'var(--title-cell-py)', maxWidth: 0 } : null), textAlign: c.align || 'left', whiteSpace: 'nowrap', ...(resizable ? { overflow: 'hidden', textOverflow: 'ellipsis' } : null), ...cellFont(c) }"><span v-if="selectable && i === 0" data-row-skip="" :style="checkSlot" :title="isDis(r, ri) && disabledReason ? disabledReason(r, ri) : undefined"><Checkbox :checked="selected.includes(ri)" :disabled="isDis(r, ri)" @change="v => emit('toggle', ri, v)" /></span><div v-if="c.twoLine" :style="{ display: 'flex', alignItems: 'center', minWidth: px(c.minWidth ?? 180), ...(treeCol === c ? { paddingLeft: (item.level * TREE_INDENT) + 'px' } : null) }"><span v-if="treeCol === c" :style="{ width: TREE_TOGGLE + 'px', flexShrink: 0, marginRight: (TREE_INDENT - TREE_TOGGLE) + 'px', display: 'inline-flex' }"><button v-if="item.kids" type="button" :aria-expanded="item.open" :aria-label="item.open ? L.collapse : L.expand" :title="item.open ? L.collapse : L.expand" @click.stop="toggleRow(item)" @mouseenter="hotToggle = item.key" @mouseleave="hotToggle = null" :style="{ width: TREE_TOGGLE + 'px', height: TREE_TOGGLE + 'px', padding: 0, border: 'none', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: hotToggle === item.key ? 'var(--overlay-hover)' : 'transparent', color: hotToggle === item.key ? 'var(--text-body)' : 'var(--text-muted)', transition: 'var(--t-hover)' }"><Icon name="chevron-right" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)', transform: item.open ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }" /></button></span><div style="display:flex;align-items:center;gap:var(--title-cell-tile-gap);flex:1;min-width:0"><template v-for="t in [tl(c, r, ri)]" :key="0"><span v-if="t.icon" :data-row-open="canOpen ? '' : undefined" :style="{ display: 'inline-flex', flexShrink: 0, cursor: canOpen ? 'pointer' : undefined }" @click="e => canOpen && openRow(r, ri, e)"><IconTile :icon="t.icon" :hue="t.hue || 'neutral'" kind="row" :style="T_TILE" :icon-style="T_ICON" /></span><div style="display:flex;flex-direction:column;gap:var(--title-cell-gap);flex:1;min-width:0"><div style="display:flex;align-items:center;gap:4px;min-width:0"><span v-if="canOpen" data-row-open="" role="button" tabindex="0" :style="{ ...T_TITLE, cursor: 'pointer' }" @click="e => openRow(r, ri, e)" @keydown="e => openKey(r, ri, e)"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><span v-else :style="T_TITLE"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><svg v-if="t.mark === 'favorite'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round" role="img" aria-label="Favorite" style="display:block;flex-shrink:0;color:var(--warning)"><path :d="STAR_FILLED" /></svg><span v-else-if="t.mark" role="img" :aria-label="t.mark.label" :style="{ display: 'inline-flex', flexShrink: 0, color: t.mark.color || 'var(--text-muted)' }"><Icon :name="t.mark.icon" :size="14" /></span></div><span v-if="t.desc" :style="T_DESC">{{ t.desc }}</span></div><IconButton v-if="t.more" icon="ellipsis" size="sm" muted :label="L.more" :title="L.more" @click="e => more(r, ri, e)" /></template></div></div><code v-else-if="c.code && (typeof r[c.key] === 'string' || typeof r[c.key] === 'number') && r[c.key] !== ''" :style="codeChip"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></code><template v-else><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></template></td>
|
|
192
198
|
</template>
|
|
193
199
|
</tr>
|
|
194
200
|
</template>
|