@keyfunc/keybricks-ds 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/DEPLOY.md +4 -4
  2. package/INTEGRATION.md +20 -1
  3. package/_adherence.oxlintrc.json +10 -4
  4. package/_ds_bundle.js +20559 -0
  5. package/_src/icon-index.js +1 -0
  6. package/_src/unicons-index.js +1 -0
  7. package/_src/usage-code.js +737 -0
  8. package/changelog.js +12 -0
  9. package/components/buttons/BUTTON_MIGRATION.md +111 -0
  10. package/components/buttons/Button.vue +59 -12
  11. package/components/buttons/Button.vue.d.ts +44 -0
  12. package/components/charts/BarList.jsx +1 -1
  13. package/components/charts/BarList.vue +8 -3
  14. package/components/charts/Ribbon3D.d.ts +2 -0
  15. package/components/charts/Scatter3D.d.ts +4 -0
  16. package/components/charts/SunburstChart.jsx +6 -1
  17. package/components/charts/SunburstChart.vue +7 -1
  18. package/components/charts/Surface3D.d.ts +2 -0
  19. package/components/charts/WorldMap.jsx +6 -1
  20. package/components/charts/WorldMap.vue +7 -1
  21. package/components/data/Table.jsx +1 -1
  22. package/components/data/Table.vue +1 -1
  23. package/components/display/ProgressBar.jsx +1 -1
  24. package/components/display/ProgressBar.vue +1 -1
  25. package/components/feedback/Accordion.jsx +1 -1
  26. package/components/feedback/Accordion.vue +1 -1
  27. package/components/feedback/ContextMenu.jsx +3 -3
  28. package/components/feedback/ContextMenu.vue +4 -4
  29. package/components/feedback/Drawer.jsx +3 -3
  30. package/components/feedback/Drawer.vue +3 -3
  31. package/components/feedback/MESSAGE_SERVICES.md +71 -0
  32. package/components/feedback/Toast.vue +5 -5
  33. package/components/feedback/_internal/MessageBoxView.vue +237 -0
  34. package/components/feedback/_internal/MessageView.vue +73 -0
  35. package/components/feedback/_internal/service-utils.js +46 -0
  36. package/components/feedback/message-box.d.ts +69 -0
  37. package/components/feedback/message-box.js +92 -0
  38. package/components/feedback/message.d.ts +37 -0
  39. package/components/feedback/message.js +68 -0
  40. package/components/forms/Checkbox.d.ts +2 -0
  41. package/components/forms/Checkbox.jsx +9 -4
  42. package/components/forms/Checkbox.vue +6 -2
  43. package/components/forms/DatePicker.jsx +3 -1
  44. package/components/forms/DatePicker.vue +2 -1
  45. package/components/forms/Dropzone.jsx +1 -1
  46. package/components/forms/Dropzone.vue +1 -1
  47. package/components/forms/MultiSelect.jsx +5 -3
  48. package/components/forms/MultiSelect.vue +4 -3
  49. package/components/forms/Slider.jsx +1 -1
  50. package/components/forms/Slider.vue +1 -1
  51. package/components/forms/Switch.jsx +4 -4
  52. package/components/forms/Switch.vue +4 -4
  53. package/components/navigation/Sidebar.jsx +2 -2
  54. package/components/navigation/Sidebar.vue +2 -2
  55. package/components/navigation/TopBar.jsx +1 -1
  56. package/components/navigation/TopBar.vue +1 -1
  57. package/components/navigation/Tree.jsx +2 -2
  58. package/components/navigation/Tree.vue +8 -5
  59. package/components/pipeline/DataPreview.jsx +1 -1
  60. package/components/pipeline/DataPreview.vue +1 -1
  61. package/components/pipeline/NodeCard.jsx +1 -1
  62. package/components/pipeline/NodeCard.vue +1 -1
  63. package/components/pipeline/PaletteItem.vue +1 -1
  64. package/components/pipeline/PalettePanel.jsx +3 -3
  65. package/components/pipeline/PalettePanel.vue +2 -2
  66. package/components/tabs/SegmentedControl.jsx +2 -2
  67. package/components/tabs/SegmentedControl.vue +2 -2
  68. package/index.html +1 -1
  69. package/package.json +12 -2
  70. package/tokens/dark.css +1 -1
  71. package/tokens/interaction.css +4 -0
  72. package/tokens/layout.css +3 -0
package/changelog.js CHANGED
@@ -3,6 +3,18 @@
3
3
  Each item: tag (new | improved | fixed | breaking | removed) 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.6.0", date: "2026-11-02", status: "unreleased", items: [
7
+ { tag: "improved", t: ["Vue Button now forwards native click events and supports component / VNode icons, icon / loading / trailing slots, trailing icons, native button types and aria-busy.", "Vue Button 补充原生点击事件、组件 / VNode 图标、icon / loading / trailing 插槽、独立尾部图标、原生按钮类型及 aria-busy。", "Vue Button がネイティブ click イベント、コンポーネント / VNode アイコン、icon / loading / trailing スロット、末尾アイコン、ボタン種別と aria-busy に対応。"] },
8
+ { tag: "new", t: ["Vue Message service: timed and grouped Toast notifications, stacking, close handles, VNode content and app context.", "Vue 新增 Message 服务:支持自动关闭、消息分组与堆叠、关闭句柄、VNode 内容及应用上下文。", "Vue Message サービスを追加:自動終了、グループ化、スタック、終了ハンドル、VNode とアプリコンテキストに対応。"] },
9
+ { tag: "new", t: ["Vue MessageBox service: alert / confirm / prompt, queued dialogs, validation, beforeClose, focus management and configurable labels. See the migration guide for compatibility limits.", "Vue 新增 MessageBox 服务:支持 alert / confirm / prompt、弹窗队列、输入校验、beforeClose、焦点管理及可配置文案;兼容边界见迁移说明。", "Vue MessageBox サービスを追加:alert / confirm / prompt、キュー、入力検証、beforeClose、フォーカス管理とラベル設定。互換性の制限は移行ガイドを参照。"] },
10
+ ] },
11
+ { v: "2.5.1", date: "2026-10-30", items: [
12
+ { tag: "fixed", t: ["Keyboard support: Checkbox, DatePicker, MultiSelect, Accordion, Dropzone, pipeline nodes and palette, selectable bar lists, and the Sunburst / World map charts can all be used without a mouse.", "键盘支持:复选框、日期选择、多选、折叠面板、上传区、管道节点与节点面板、可点选的条形列表,以及旭日图 / 世界地图,都可以不用鼠标操作。", "キーボード対応:チェックボックス、日付選択、マルチセレクト、アコーディオン、ドロップゾーン、パイプラインのノードとパレット、選択可能なバーリスト、サンバースト / 世界地図をマウスなしで操作可能に。"] },
13
+ { tag: "fixed", t: ["Long text no longer spills: field values, menu items, segments, tree rows and navigation names truncate; drawer titles wrap.", "长文字不再溢出:输入框的值、菜单项、分段选择、树形列表和导航名称会截断;侧滑面板标题会换行。", "長いテキストがはみ出さないように:入力値、メニュー項目、セグメント、ツリー行、ナビゲーション名は省略表示、ドロワーのタイトルは折り返し。"] },
14
+ { tag: "fixed", t: ["Vue: Tree right-click menu event and BarList row selection, matching React.", "Vue:树形列表支持右键菜单事件,条形列表支持点选,与 React 一致。", "Vue:Tree の右クリックメニューイベントと BarList の行選択を React と同様に追加。"] },
15
+ { tag: "fixed", t: ["The documentation pages inside the npm package now open locally — before, every component box stayed empty.", "npm 包里的文档网页现在可以在本地直接打开(之前每个组件框都是空白的)。", "npm パッケージ内のドキュメントページをローカルで開けるように(以前は各コンポーネント枠が空白だった)。"] },
16
+ { tag: "improved", t: ["Animations and shadows use the system tokens throughout; shadows deepen in dark mode.", "动画和阴影统一使用系统规范值,深色模式下阴影自动加深。", "アニメーションと影をシステムのトークンに統一。ダークモードでは影が自動で濃くなる。"] },
17
+ ] },
6
18
  { v: "2.5.0", date: "2026-10-30", items: [
7
19
  { tag: "new", t: ["Every example on this site has a <b>Show code</b> bar: the React and Vue 3 code that builds it, ready to paste.", "网站上每个例子下都有<b>显示代码</b>:给出做出该例子的 React 和 Vue 3 代码,可直接复制使用。", "サイトの各例の下に<b>コードを表示</b>を追加:その例を作る React / Vue 3 のコードをそのまま貼り付けて使える。"] },
8
20
  { tag: "improved", t: ["Vue imports in one line: <code>import { Button } from '@keyfunc/keybricks-ds/vue'</code>.", "Vue 一行引入:<code>import { Button } from '@keyfunc/keybricks-ds/vue'</code>。", "Vue は 1 行でインポート:<code>import { Button } from '@keyfunc/keybricks-ds/vue'</code>。"] },
@@ -0,0 +1,111 @@
1
+ # Vue Button 与旧按钮适配
2
+
3
+ 2.6.0(未发布)为 Vue Button 补充适配旧按钮所需的通用能力。
4
+ 原有图标名、variant、默认插槽用法继续支持;本次没有修改 React Button。
5
+ 本次只修改源码与文档,没有执行测试、类型检查、构建或浏览器验证,不能据此宣称已完美替换业务组件。
6
+
7
+ ## 新增接口
8
+
9
+ | 接口 | 行为 |
10
+ | --- | --- |
11
+ | `@click` | 传递原生 MouseEvent;disabled 或 loading 时不发出事件 |
12
+ | `nativeType` | `button / submit / reset`,默认仍为 button |
13
+ | `text` | 默认插槽的后备文本,已有插槽优先 |
14
+ | `icon` | 原 DS 图标名之外,额外接受 Vue 组件或 VNode |
15
+ | `trailingIcon` | 独立右侧图标;loading 时继续显示,适合 popup 下拉箭头 |
16
+ | `#icon` | 覆盖主图标,可放旧 svgicon、字体图标或自定义 SVG;随 loading 隐藏 |
17
+ | `#loading` | 覆盖加载图标;默认继续使用 DS spinner |
18
+ | `#trailing` | 覆盖独立尾部内容,不随 loading 隐藏 |
19
+ | `aria-busy` | 与 loading 同步;原生 disabled 仍由 disabled 或 loading 控制 |
20
+
21
+ icon / loading 插槽接收 `{ size, style }`,trailing 额外接收 loading。
22
+ 默认继承当前尺寸区;显式 iconSize 优先于尺寸区。自定义插槽中的图标由调用方应用传入的尺寸。
23
+ class、id、title、style、原生表单属性以及其他未声明属性继续透传到同一个原生 button 根节点。
24
+ Vue 专用类型文件为 `Button.vue.d.ts`,`Button.d.ts` 仍是 React 类型。
25
+
26
+ ```vue
27
+ <template>
28
+ <KbButton
29
+ :loading="saving"
30
+ :icon="SaveIcon"
31
+ trailing-icon="chevron-down"
32
+ @click="save($event)"
33
+ >
34
+ 保存
35
+ </KbButton>
36
+ </template>
37
+ ```
38
+
39
+ ## actnplanBtn 的适配边界
40
+
41
+ 建议保留 `actnplanBtn.vue` 的路径、props、事件和兼容 class,内部改为 KbButton。
42
+ DS 接收通用接口;`regular / negative / warning / inbody / popup` 以及旧图标名由应用转换。
43
+
44
+ | 原配置 | 应用层转换 |
45
+ | --- | --- |
46
+ | regular | variant=primary |
47
+ | regular + inbody | variant=outline |
48
+ | negative | variant=outline;它通常表示取消,不是危险操作 |
49
+ | warning | variant=danger |
50
+ | disabled / loading | 同名属性,loading 时主图标替换为 spinner,文字保留 |
51
+ | popup | trailingIcon=chevron-down,loading 时仍显示箭头 |
52
+ | text | 保留 `<span class="text">` 默认插槽,兼容已有选择器 |
53
+ | click | 继续传出原生事件,不吞掉 `$event`、冒泡或表单默认行为 |
54
+ | class / id / style | 透传;保留 btn、actnplanButton、原 type class 和 inbody class |
55
+
56
+ 旧 actnplanBtn 没有指定原生 button type,在表单里通常默认提交;严格兼容时适配层应设置 nativeType=submit,并核对各表单的实际使用。
57
+ 不能把旧业务 type 直接绑定到原生 type。外部已经通过原生属性控制 form、name、value 的场景也应保留。
58
+
59
+ ## 图标映射
60
+
61
+ 映射应独立放在应用的 `actnplanButtonIcons.ts`,不要塞进 DS 的通用图标字典。
62
+ 应用先从空格分隔的 class 中提取 glyphicon-*,因此 `glyphicon glyphicon-refresh` 与 `glyphicon-refresh` 进入相同映射。
63
+
64
+ | 旧图标 | 建议的 DS 图标 |
65
+ | --- | --- |
66
+ | 1em-ok / glyphicon-ok | check |
67
+ | 1em-cancel / glyphicon-remove | x |
68
+ | 1em-add / glyphicon-plus | plus |
69
+ | glyphicon-floppy-disk | save |
70
+ | 1em-delete / glyphicon-trash | trash-2 |
71
+ | 1em-refresh / glyphicon-refresh | refresh-cw |
72
+ | 1em-clone | copy |
73
+ | glyphicon-search | search |
74
+ | glyphicon-menu-down | chevron-down |
75
+
76
+ 已经确认对应关系的图标转换为 DS 名称。业务专用 SVG 或无可靠对应的 Glyphicon 通过 icon 插槽保留;不要把未知旧名称直接传入 DS Icon,否则会显示空图标。
77
+ 动态 icon 使用 computed 解析;旧 SVG 使用 watch 加载并忽略过期请求,避免切换按钮状态后显示旧图标。
78
+ 旧 fill 样式仅作用于旧 SVG,DS 图标保留 fill=none、stroke=currentColor。
79
+
80
+ ```vue
81
+ <template>
82
+ <KbButton
83
+ class="btn actnplanButton"
84
+ :class="[type, { inbody }]"
85
+ :variant="variant"
86
+ :disabled="disabled"
87
+ :loading="loading"
88
+ :trailing-icon="popup ? 'chevron-down' : undefined"
89
+ native-type="submit"
90
+ @click="emit('click', $event)"
91
+ >
92
+ <template v-if="icon" #icon="{ size, style }">
93
+ <AppIcon v-if="mappedIcon" :name="mappedIcon" :size="size" :style="style" />
94
+ <span v-else-if="isGlyph" class="glyphicon" :class="glyphClass" aria-hidden="true" />
95
+ <svgicon v-else-if="svgData" :data="svgData" :fill="false" :style="style" />
96
+ </template>
97
+ <span class="text">{{ text }}</span>
98
+ </KbButton>
99
+ </template>
100
+ ```
101
+
102
+ 以上为应用适配层的模板示意,其中 variant、mappedIcon 等由应用解析。
103
+ 图标放在按钮内部即可,不需要 svgContainer,避免把一个按钮拆成多个点击区域。
104
+
105
+ ## 接入顺序
106
+
107
+ 先发布包含本次改动的 DS,再升级应用安装版本,最后修改 actnplanBtn 适配层并移除旧的按钮视觉样式。
108
+ 应用所有版本都需要加载 DS 样式,不能只依赖 V2 的适配器注册入口。
109
+ 同时清理 runtime/modal.vue 中临时添加的默认按钮视觉覆盖,让按钮外观由 DS 统一管理;默认按钮位置可继续由 modal 决定。
110
+ 客户主题 mixin 和外部 `.actnplanButton` 规则需要逐项处理,不能继续用旧的 disabled / fill / hover 规则覆盖新按钮。
111
+ 源码修改不会自动影响应用已安装的旧版本;本轮未切换 actnplanBtn,也未修改应用依赖、lock 文件或生成产物。
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { px } from '../px.js'
3
- import { computed, ref } from 'vue'
3
+ import { cloneVNode, computed, h, isVNode, ref } from 'vue'
4
4
  import Icon from '../icons/Icon.vue'
5
5
 
6
6
  // Every dimension comes from the size tier the button sits in (tokens/layout.css): height, text, icon and padding
@@ -25,11 +25,13 @@ const colorHoverVariants = { text: 'var(--text-heading)' }
25
25
  // Button — variant: primary | dark | outline | ghost | primary-soft | danger | danger-soft | text; size: sm 28 | md 32 | lg 40 — omit it to inherit the region's tier (the default).
26
26
  const props = defineProps({
27
27
  variant: { type: String, default: 'primary' }, size: String,
28
- icon: String, iconSize: Number, iconPosition: { type: String, default: 'left' },
28
+ icon: [String, Object, Function], trailingIcon: [String, Object, Function],
29
+ iconSize: Number, iconPosition: { type: String, default: 'left' },
29
30
  medium: Boolean, disabled: Boolean, loading: Boolean, fullWidth: Boolean, iconOnly: Boolean,
30
- label: String, pressed: Boolean, focused: Boolean, style: Object,
31
+ label: String, text: String, pressed: Boolean, focused: Boolean, style: Object,
32
+ nativeType: { type: String, default: 'button', validator: value => ['button', 'submit', 'reset'].includes(value) },
31
33
  })
32
- defineEmits(['click'])
34
+ const emit = defineEmits(['click'])
33
35
  const hover = ref(false), press = ref(false), focus = ref(false)
34
36
  const busy = computed(() => props.disabled || props.loading)
35
37
  const isPressed = computed(() => press.value || props.pressed)
@@ -52,15 +54,60 @@ const s = computed(() => {
52
54
  })
53
55
  const iSize = computed(() => props.iconSize || 16)
54
56
  const iconStyle = computed(() => props.iconSize ? null : tierIcon)
57
+
58
+ // 旧 SVG、组件和 VNode 都可以由适配层传入,业务图标名称不进入 DS。
59
+ function glyph(value) {
60
+ if (!value) return null
61
+ const attrs = {
62
+ size: iSize.value, width: iSize.value, height: iSize.value, 'aria-hidden': true, focusable: false,
63
+ style: { display: 'block', flexShrink: 0, width: px(iSize.value), height: px(iSize.value), ...iconStyle.value },
64
+ }
65
+ if (typeof value === 'string') return h(Icon, { ...attrs, name: value, size: iSize.value })
66
+ return isVNode(value) ? cloneVNode(value, attrs) : h(value, attrs)
67
+ }
68
+ const LeadingIcon = () => glyph(props.icon)
69
+ const TrailingIcon = () => glyph(props.trailingIcon)
70
+
71
+ function onClick(event) {
72
+ if (busy.value) return
73
+ emit('click', event)
74
+ }
55
75
  </script>
56
76
  <template>
57
- <button type="button" data-press="own" :data-size="size" :style="s" :disabled="busy" :aria-label="iconOnly ? label : undefined"
58
- @click="$emit('click')" @mouseenter="hover = true" @mouseleave="hover = false; press = false"
59
- @mousedown="press = true" @mouseup="press = false" @keydown.enter="press = true" @keydown.space="press = true" @keyup="press = false"
60
- @focus="focus = $event.target.matches(':focus-visible')" @blur="focus = false">
61
- <span v-if="loading" :style="{ display: 'inline-block', width: px(iSize), height: px(iSize), ...iconStyle, borderRadius: 'var(--radius-full)', border: '2px solid currentColor', borderTopColor: 'transparent', opacity: .9, animation: 'kb-spin .7s linear infinite', flexShrink: 0 }" />
62
- <Icon v-else-if="icon && iconPosition === 'left'" :name="icon" :size="iSize" :style="iconStyle" />
63
- <slot v-if="!iconOnly" />
64
- <Icon v-if="!loading && icon && iconPosition === 'right'" :name="icon" :size="iSize" :style="iconStyle" />
77
+ <button
78
+ :type="nativeType"
79
+ data-press="own"
80
+ :data-size="size"
81
+ :style="s"
82
+ :disabled="busy"
83
+ :aria-busy="loading"
84
+ :aria-label="iconOnly ? label : undefined"
85
+ @click="onClick"
86
+ @mouseenter="hover = true"
87
+ @mouseleave="hover = false; press = false"
88
+ @mousedown="press = true"
89
+ @mouseup="press = false"
90
+ @keydown.enter="press = true"
91
+ @keydown.space="press = true"
92
+ @keyup="press = false"
93
+ @focus="focus = $event.target.matches(':focus-visible')"
94
+ @blur="focus = false"
95
+ >
96
+ <slot v-if="loading" name="loading" :size="iSize" :style="iconStyle">
97
+ <span
98
+ aria-hidden="true"
99
+ :style="{ display: 'inline-block', width: px(iSize), height: px(iSize), ...iconStyle, borderRadius: 'var(--radius-full)', border: '2px solid currentColor', borderTopColor: 'transparent', opacity: .9, animation: 'kb-spin .7s linear infinite', flexShrink: 0 }"
100
+ />
101
+ </slot>
102
+ <slot v-else-if="iconPosition === 'left'" name="icon" :size="iSize" :style="iconStyle">
103
+ <LeadingIcon v-if="icon" />
104
+ </slot>
105
+ <slot v-if="!iconOnly">{{ text }}</slot>
106
+ <slot v-if="!loading && iconPosition === 'right'" name="icon" :size="iSize" :style="iconStyle">
107
+ <LeadingIcon v-if="icon" />
108
+ </slot>
109
+ <slot name="trailing" :size="iSize" :style="iconStyle" :loading="loading">
110
+ <TrailingIcon v-if="trailingIcon" />
111
+ </slot>
65
112
  </button>
66
113
  </template>
@@ -0,0 +1,44 @@
1
+ import type { Component, ComponentOptionsMixin, CSSProperties, DefineComponent, PublicProps, SlotsType, VNode } from 'vue';
2
+
3
+ /** Vue 专用声明;同目录 Button.d.ts 继续描述 React 接口。 */
4
+ export interface VueButtonProps {
5
+ variant?: 'primary' | 'dark' | 'outline' | 'ghost' | 'primary-soft' | 'danger' | 'danger-soft' | 'text';
6
+ size?: 'sm' | 'md' | 'lg';
7
+ icon?: string | Component | VNode;
8
+ /** 不随 loading 隐藏,适用于下拉箭头等独立的尾部标记。 */
9
+ trailingIcon?: string | Component | VNode;
10
+ iconSize?: number;
11
+ iconPosition?: 'left' | 'right';
12
+ medium?: boolean;
13
+ disabled?: boolean;
14
+ loading?: boolean;
15
+ fullWidth?: boolean;
16
+ iconOnly?: boolean;
17
+ label?: string;
18
+ /** 默认插槽的后备文本。 */
19
+ text?: string;
20
+ pressed?: boolean;
21
+ focused?: boolean;
22
+ style?: CSSProperties;
23
+ /** 原生按钮类型,默认 button;旧表单适配时可明确传入 submit。 */
24
+ nativeType?: 'button' | 'submit' | 'reset';
25
+ onClick?: (event: MouseEvent) => void;
26
+ }
27
+
28
+ export interface VueButtonIconSlotProps {
29
+ size: number;
30
+ style: CSSProperties | null;
31
+ }
32
+ export interface VueButtonSlots {
33
+ default?: () => VNode[];
34
+ icon?: (props: VueButtonIconSlotProps) => VNode[];
35
+ loading?: (props: VueButtonIconSlotProps) => VNode[];
36
+ trailing?: (props: VueButtonIconSlotProps & { loading: boolean }) => VNode[];
37
+ }
38
+
39
+ declare const Button: DefineComponent<
40
+ VueButtonProps, {}, {}, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin,
41
+ { click: (event: MouseEvent) => void }, string, PublicProps,
42
+ Readonly<VueButtonProps>, {}, SlotsType<VueButtonSlots>
43
+ >;
44
+ export default Button;
@@ -13,7 +13,7 @@ export function BarList({ rows = [], max, width = 360, valueFormat = fmtNum, sho
13
13
  <div style={{ display: "flex", flexDirection: "column", gap: 10, width, ...style }}>
14
14
  {rows.map((r) => (
15
15
  <div key={r.label} onMouseEnter={interactive ? () => setHover(r.label) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}
16
- onClick={onSelect ? () => onSelect(r) : undefined}
16
+ onClick={onSelect ? () => onSelect(r) : undefined} role={onSelect ? "button" : undefined} tabIndex={onSelect ? 0 : undefined} onKeyDown={onSelect ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(r); } } : undefined}
17
17
  style={{ opacity: hover == null || hover === r.label ? 1 : 0.5, cursor: onSelect ? "pointer" : "default", transition: "var(--t-hover)" }}>
18
18
  <div style={{ display: "flex", alignItems: "baseline", gap: 8, paddingBottom: 4 }}>
19
19
  <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", color: "var(--text-body)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.label}</span>
@@ -1,16 +1,21 @@
1
1
  <script setup>
2
- import { computed, ref } from 'vue'
2
+ import { computed, ref, getCurrentInstance } from 'vue'
3
3
  import { fmtNum } from './chart-geometry.js'
4
4
  // Horizontal labeled bars — top-N rankings, budget consumption.
5
5
  const props = defineProps({ rows:{type:Array,default:()=>[]}, max:Number, width:{type:Number,default:360}, barHeight:{type:Number,default:8}, showTrack:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })
6
6
  const top = computed(() => props.max!=null ? props.max : Math.max(1,...props.rows.map(r=>r.value)))
7
7
  const total = computed(() => props.rows.reduce((s,r)=>s+r.value,0)||1)
8
8
  const hover = ref(null)
9
+ // @select(row) — like React's onSelect: a listener makes each row a button (click, Enter / Space).
10
+ const emit = defineEmits(['select'])
11
+ const inst = getCurrentInstance()
12
+ const selectable = computed(() => !!(inst && inst.vnode.props && inst.vnode.props.onSelect))
13
+ function pick(r) { if (selectable.value) emit('select', r) }
9
14
  </script>
10
15
  <template>
11
16
  <div :style="{display:'flex',flexDirection:'column',gap:'10px',width:width+'px',...style}">
12
- <div v-for="r in rows" :key="r.label" @mouseenter="interactive && (hover=r.label)" @mouseleave="interactive && (hover=null)"
13
- :style="{opacity:hover==null||hover===r.label?1:0.5,transition:'var(--t-hover)'}">
17
+ <div v-for="r in rows" :key="r.label" @mouseenter="interactive && (hover=r.label)" @mouseleave="interactive && (hover=null)" :role="selectable ? 'button' : undefined" :tabindex="selectable ? 0 : undefined" @click="pick(r)" @keydown.enter.prevent="pick(r)" @keydown.space.prevent="pick(r)"
18
+ :style="{opacity:hover==null||hover===r.label?1:0.5,transition:'var(--t-hover)',cursor:selectable?'pointer':'default'}">
14
19
  <div style="display:flex;align-items:baseline;gap:8px;padding-bottom:4px">
15
20
  <span style="font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-body);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.label }}</span>
16
21
  <span style="margin-left:auto;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)">{{ valueFormat(r.value) }}</span>
@@ -8,6 +8,8 @@ export interface Ribbon3DProps {
8
8
  yaw?: number; pitch?: number;
9
9
  /** Band thickness as a fraction of its row, 0–1 */ ribbon?: number;
10
10
  interactive?: boolean;
11
+ /** Formats values in the tooltip and axis (default: grouped number). */
12
+ valueFormat?: (v: number) => string;
11
13
  style?: React.CSSProperties;
12
14
  }
13
15
  export declare function Ribbon3D(props: Ribbon3DProps): JSX.Element;
@@ -7,6 +7,10 @@ export interface Scatter3DProps {
7
7
  yaw?: number; pitch?: number;
8
8
  /** Base dot radius */ r?: number;
9
9
  legend?: boolean; interactive?: boolean;
10
+ /** Axis titles (default "x" / "y" / "z"). */
11
+ xLabel?: string; yLabel?: string; zLabel?: string;
12
+ /** Formats values in the tooltip (default: grouped number). */
13
+ valueFormat?: (v: number) => string;
10
14
  style?: React.CSSProperties;
11
15
  }
12
16
  export declare function Scatter3D(props: Scatter3DProps): JSX.Element;
@@ -33,7 +33,12 @@ export function SunburstChart({ data, size = 320, ringWidth, valueFormat = fmtNu
33
33
  return (
34
34
  <div style={{ ...wrapStyle, ...style }}>
35
35
  <div style={{ position: "relative", width: size, height: size }}>
36
- <svg width={size} height={size} role="img">
36
+ <svg width={size} height={size} role="img" aria-label={interactive ? "Sunburst chart — arrow keys move between segments, Enter zooms in, Escape zooms out" : undefined} tabIndex={interactive ? 0 : undefined} onBlur={() => setHover(null)}
37
+ onKeyDown={interactive ? (e) => { const n = segs.length; if (!n) return; const h = hover;
38
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setHover(h == null ? 0 : (h + 1) % n); }
39
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setHover(h == null ? n - 1 : (h - 1 + n) % n); }
40
+ else if ((e.key === "Enter" || e.key === " ") && h != null && segs[h] && segs[h].node.children) { e.preventDefault(); setZoom([...zoom, ...segs[h].path]); setHover(null); }
41
+ else if (e.key === "Escape") { if (zoom.length) setZoom(zoom.slice(0, -1)); setHover(null); } } : undefined}>
37
42
  {segs.map((s, i) => (
38
43
  <path key={i} d={arcPath(size / 2, size / 2, rInner + rw * (s.depth + 1) - 1.5, rInner + rw * s.depth, s.a0 + 0.35, Math.max(s.a0 + 0.5, s.a1 - 0.35))}
39
44
  fill={seriesColor(s.colorIdx)} opacity={hover == null ? 0.92 - s.depth * 0.16 : hover === i ? 1 : 0.28}
@@ -33,11 +33,17 @@ const tip = computed(()=>{
33
33
  const p = polar(props.size/2, props.size/2, g.value.rInner+g.value.rw*(s.depth+0.5), (s.a0+s.a1)/2)
34
34
  return { x:p[0], y:p[1], title:s.node.label, value: props.valueFormat(s.node.value)+' · '+((s.node.value/(g.value.focus.value||1))*100).toFixed(1)+'%' }
35
35
  })
36
+ // One Tab stop for the whole chart: arrows walk the segments (same highlight + tooltip as hover), Enter zooms in, Escape zooms out.
37
+ function onKey(e) { if (!props.interactive) return; const segs = g.value.segs, n = segs.length; if (!n) return; const h = hover.value
38
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); hover.value = h == null ? 0 : (h + 1) % n }
39
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); hover.value = h == null ? n - 1 : (h - 1 + n) % n }
40
+ else if ((e.key === 'Enter' || e.key === ' ') && h != null && segs[h] && segs[h].node.children) { e.preventDefault(); zoom.value = [...zoom.value, ...segs[h].path]; hover.value = null }
41
+ else if (e.key === 'Escape') { if (zoom.value.length) zoom.value = zoom.value.slice(0, -1); hover.value = null } }
36
42
  </script>
37
43
  <template>
38
44
  <div :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
39
45
  <div :style="{position:'relative',width:size+'px',height:size+'px'}">
40
- <svg :width="size" :height="size" role="img">
46
+ <svg :width="size" :height="size" role="img" :aria-label="interactive ? 'Sunburst chart — arrow keys move between segments, Enter zooms in, Escape zooms out' : undefined" :tabindex="interactive ? 0 : undefined" @keydown="onKey" @blur="hover = null">
41
47
  <path v-for="(s,i) in g.segs" :key="i" :d="s.d" :fill="s.color"
42
48
  :opacity="hover==null ? 0.92-s.depth*0.16 : (hover===i?1:0.28)"
43
49
  @mouseenter="interactive && (hover=i)" @mouseleave="interactive && (hover=null)"
@@ -8,6 +8,8 @@ export interface Surface3DProps {
8
8
  /** Cell outlines */ wire?: boolean;
9
9
  /** Drag-to-orbit */ interactive?: boolean;
10
10
  valueFormat?: (v: number) => string;
11
+ /** Axis titles (default "x" / "y"). */
12
+ xLabel?: string; yLabel?: string;
11
13
  style?: React.CSSProperties;
12
14
  }
13
15
  export declare function Surface3D(props: Surface3DProps): JSX.Element;
@@ -80,7 +80,12 @@ export function WorldMap({ values = {}, width = 660, height = 340, projection =
80
80
  const h = hover != null ? paths[hover] : null;
81
81
  return (
82
82
  <div style={{ ...wrapStyle, ...style }}>
83
- <svg width={width} height={height - (legend ? 22 : 0)} role="img">
83
+ <svg width={width} height={height - (legend ? 22 : 0)} role="img" aria-label={interactive ? "Map — arrow keys move between regions" + (onSelect ? ", Enter selects" : "") : undefined} tabIndex={interactive ? 0 : undefined} onBlur={() => setHover(null)}
84
+ onKeyDown={interactive ? (e) => { const n = paths.length; if (!n) return; const h = hover;
85
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setHover(h == null ? 0 : (h + 1) % n); }
86
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setHover(h == null ? n - 1 : (h - 1 + n) % n); }
87
+ else if ((e.key === "Enter" || e.key === " ") && h != null && onSelect) { e.preventDefault(); onSelect(paths[h].name, resolved[paths[h].name]); }
88
+ else if (e.key === "Escape") setHover(null); } : undefined}>
84
89
  {paths.map((p, i) => (
85
90
  <path key={p.name + i} d={p.d} fill={fillOf(p.name)} stroke="var(--bg-surface)" strokeWidth="0.5"
86
91
  onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}
@@ -49,12 +49,18 @@ function fill(name){
49
49
  const swatches = computed(() => props.scale==='diverging'
50
50
  ? ['var(--chart-neg)','color-mix(in srgb, var(--chart-neg) 40%, var(--bg-surface))','var(--chart-track)','color-mix(in srgb, var(--chart-pos) 40%, var(--bg-surface))','var(--chart-pos)']
51
51
  : HEAT)
52
+ // One Tab stop for the whole map: arrows walk the regions (same highlight + tooltip as hover), Enter emits select, Escape clears.
53
+ function onKey(e) { if (!props.interactive) return; const ps = paths.value, n = ps.length; if (!n) return; const h = hover.value
54
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); hover.value = h == null ? 0 : (h + 1) % n }
55
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); hover.value = h == null ? n - 1 : (h - 1 + n) % n }
56
+ else if ((e.key === 'Enter' || e.key === ' ') && h != null) { e.preventDefault(); emit('select', ps[h].name, resolved.value[ps[h].name]) }
57
+ else if (e.key === 'Escape') hover.value = null }
52
58
  </script>
53
59
  <template>
54
60
  <div v-if="err" :style="{width:width+'px',height:height+'px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-xs)',color:'var(--text-muted)',background:'var(--bg-inset)',borderRadius:'var(--radius-lg)',...style}">{{ err }}</div>
55
61
  <div v-else-if="!paths" :style="{width:width+'px',height:height+'px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-xs)',color:'var(--text-muted)',...style}">loading map…</div>
56
62
  <div v-else :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
57
- <svg :width="width" :height="plotH" role="img">
63
+ <svg :width="width" :height="plotH" role="img" :aria-label="interactive ? 'Map — arrow keys move between regions, Enter selects' : undefined" :tabindex="interactive ? 0 : undefined" @keydown="onKey" @blur="hover = null">
58
64
  <path v-for="(p,i) in paths" :key="p.name+i" :d="p.d" :fill="fill(p.name)" stroke="var(--bg-surface)" stroke-width="0.5"
59
65
  @mouseenter="interactive && (hover=i)" @mouseleave="interactive && (hover=null)" @click="emit('select', p.name, resolved[p.name])"
60
66
  :style="{opacity:hover==null||hover===i?1:0.75,transition:'opacity var(--dur-fast) var(--ease-out)'}"/>
@@ -89,7 +89,7 @@ function TreeToggle({ open, label, onClick }) {
89
89
  return (
90
90
  <button type="button" aria-expanded={open} aria-label={label} title={label} onClick={(e) => { e.stopPropagation(); onClick(); }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
91
91
  style={{ width: TREE_TOGGLE, height: TREE_TOGGLE, padding: 0, border: "none", borderRadius: "var(--radius-sm)", display: "inline-flex", alignItems: "center", justifyContent: "center", cursor: "pointer", background: hover ? "var(--overlay-hover)" : "transparent", color: hover ? "var(--text-body)" : "var(--text-muted)", transition: "var(--t-hover)" }}>
92
- <Icon name="chevron-right" size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", transform: open ? "rotate(90deg)" : "none", transition: "transform .15s ease" }} />
92
+ <Icon name="chevron-right" size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", transform: open ? "rotate(90deg)" : "none", transition: "transform var(--dur-fast) var(--ease-out)" }} />
93
93
  </button>
94
94
  );
95
95
  }
@@ -188,7 +188,7 @@ function cellPad(i) { return { ...(i === 0 && props.selectable ? { position: 're
188
188
  <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
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)' }">
190
190
  <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 .15s ease' }" /></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>
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>
192
192
  </template>
193
193
  </tr>
194
194
  </template>
@@ -11,7 +11,7 @@ export function ProgressBar({ label, value = 50, color = "var(--chart-blue)", sh
11
11
  </div>
12
12
  ) : null}
13
13
  <div style={{ height: 6, borderRadius: "var(--radius-full)", background: "var(--track)", overflow: "hidden" }}>
14
- <div style={{ width: `${value}%`, height: "100%", borderRadius: "var(--radius-full)", background: color, transition: "width .3s ease" }} />
14
+ <div style={{ width: `${value}%`, height: "100%", borderRadius: "var(--radius-full)", background: color, transition: "width var(--dur-slow) var(--ease-out)" }} />
15
15
  </div>
16
16
  </div>
17
17
  );
@@ -9,7 +9,7 @@ defineProps({ label: String, value: { type: Number, default: 50 }, color: { type
9
9
  <span v-if="showValue" style="color:var(--text-heading)">{{ value }}%</span>
10
10
  </div>
11
11
  <div style="height:6px;border-radius:var(--radius-full);background:var(--track);overflow:hidden">
12
- <div :style="{ width: value + '%', height: '100%', borderRadius: 'var(--radius-full)', background: color, transition: 'width .3s ease' }" />
12
+ <div :style="{ width: value + '%', height: '100%', borderRadius: 'var(--radius-full)', background: color, transition: 'width var(--dur-slow) var(--ease-out)' }" />
13
13
  </div>
14
14
  </div>
15
15
  </template>
@@ -8,7 +8,7 @@ export function Accordion({ title, subtitle, defaultOpen, open: openProp, onTogg
8
8
  const toggle = () => (onToggle ? onToggle(!open) : setOpenState(!open));
9
9
  return (
10
10
  <div style={{ borderBottom: "1px solid var(--border-card)", ...style }}>
11
- <div onClick={toggle} data-wash="" style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 8px", margin: "0 -4px", borderRadius: "var(--radius-md)", background: "var(--wash, transparent)", transition: "var(--t-hover)", cursor: "pointer" }}>
11
+ <div role="button" tabIndex={0} aria-expanded={!!open} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }} onClick={toggle} data-wash="" style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 8px", margin: "0 -4px", borderRadius: "var(--radius-md)", background: "var(--wash, transparent)", transition: "var(--t-hover)", cursor: "pointer" }}>
12
12
  <span style={{ color: "var(--text-muted)", display: "inline-flex", transform: open ? "rotate(90deg)" : "none", transition: "transform var(--dur-fast) var(--ease-out)" }}><Icon name="chevron-right" size={14} /></span>
13
13
  <div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
14
14
  <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", letterSpacing: "var(--tracking-base)", color: "var(--text-heading)" }}>{title}</span>
@@ -10,7 +10,7 @@ function toggle() { if (props.open != null) emit('toggle', !open.value); else op
10
10
  </script>
11
11
  <template>
12
12
  <div :style="{ borderBottom: '1px solid var(--border-card)', ...style }">
13
- <div @click="toggle" data-wash="" style="display:flex;align-items:center;gap:10px;padding:14px 8px;margin:0 -4px;border-radius:var(--radius-md);background:var(--wash, transparent);transition:var(--t-hover);cursor:pointer">
13
+ <div role="button" tabindex="0" :aria-expanded="!!open" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle" @click="toggle" data-wash="" style="display:flex;align-items:center;gap:10px;padding:14px 8px;margin:0 -4px;border-radius:var(--radius-md);background:var(--wash, transparent);transition:var(--t-hover);cursor:pointer">
14
14
  <span :style="{ color: 'var(--text-muted)', display: 'inline-flex', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }"><Icon name="chevron-right" :size="14" /></span>
15
15
  <div style="display:flex;flex-direction:column;flex:1">
16
16
  <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);letter-spacing:var(--tracking-base);color:var(--text-heading)">{{ title }}</span>
@@ -30,13 +30,13 @@ export function ContextMenu({ items = [], onSelect, size, descriptionSide = "rig
30
30
  };
31
31
  const onBlur = (e) => { setHover(-1); if (hasHint && !(wrap.current && wrap.current.contains(e.relatedTarget))) setHint(-1); };
32
32
  const menu = (
33
- <div role="menu" data-size={hasHint ? undefined : size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
33
+ <div role="menu" data-size={hasHint ? undefined : size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, maxWidth: 320, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
34
34
  {items.map((it, i) => it.divider ? <div key={i} role="separator" style={{ height: 1, flexShrink: 0, background: "var(--border-subtle)", margin: "4px 0" }} /> : (
35
35
  <button key={i} type="button" onClick={() => onSelect && onSelect(it.label)}
36
36
  onMouseEnter={() => show(i)} onMouseLeave={() => setHover(-1)} onFocus={() => show(i)} onBlur={onBlur}
37
37
  aria-describedby={hint === i ? hintId : undefined}
38
- role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i || hint === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap" }}>
39
- {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) }} /> : null}{it.label}
38
+ role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i || hint === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }} title={it.label}>
39
+ {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) }} /> : null}<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>{it.label}</span>
40
40
  </button>
41
41
  ))}
42
42
  </div>
@@ -26,8 +26,8 @@ function show(i) {
26
26
  }
27
27
  function onBlur(e) { hover.value = -1; if (hasHint.value && !(wrap.value && wrap.value.contains(e.relatedTarget))) hint.value = -1 }
28
28
  const cur = computed(() => props.items[hint.value])
29
- const menuStyle = computed(() => ({ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', display: 'inline-flex', flexDirection: 'column', gap: choice.value ? '2px' : 0, ...props.style }))
30
- const rowStyle = (it, i) => ({ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover.value === i || hint.value === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap' })
29
+ const menuStyle = computed(() => ({ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', maxWidth: '320px', display: 'inline-flex', flexDirection: 'column', gap: choice.value ? '2px' : 0, ...props.style }))
30
+ const rowStyle = (it, i) => ({ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover.value === i || hint.value === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' })
31
31
  const iconStyle = (it) => ({ width: 'var(--control-icon)', height: 'var(--control-icon)', flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) })
32
32
  const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value === 'left' ? 'right' : 'left']: `calc(100% + ${HINT_GAP}px)`, width: HINT_W + 'px', boxSizing: 'border-box', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '6px', background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', fontFamily: 'var(--font-sans)', animation: 'kb-fade-in var(--dur) var(--ease-out)' }))
33
33
  </script>
@@ -38,7 +38,7 @@ const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value ==
38
38
  <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
39
39
  <button v-else type="button" @click="emit('select', it.label)" @mouseenter="show(i)" @mouseleave="hover = -1" @focus="show(i)" @blur="onBlur"
40
40
  :aria-describedby="hint === i ? hintId : undefined" :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="rowStyle(it, i)">
41
- <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" />{{ it.label }}
41
+ <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" /><span style="min-width:0;overflow:hidden;text-overflow:ellipsis">{{ it.label }}</span>
42
42
  </button>
43
43
  </template>
44
44
  </div>
@@ -55,7 +55,7 @@ const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value ==
55
55
  <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
56
56
  <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1" @focus="hover = i" @blur="hover = -1"
57
57
  :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="rowStyle(it, i)">
58
- <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" />{{ it.label }}
58
+ <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" /><span style="min-width:0;overflow:hidden;text-overflow:ellipsis">{{ it.label }}</span>
59
59
  </button>
60
60
  </template>
61
61
  </div>
@@ -5,10 +5,10 @@ import { IconButton } from "../buttons/IconButton.jsx";
5
5
  export function Drawer({ open = true, title, subtitle, width = 400, inline, height, size, footer, onClose, children, style }) {
6
6
  if (!open) return null;
7
7
  const panel = (
8
- <div role="dialog" aria-label={title} data-size={size} style={{ width, maxWidth: "100%", height: inline ? (height || 420) : "100%", background: "var(--bg-raised)", borderLeft: "1px solid var(--border-card)", boxShadow: inline ? "var(--shadow-menu)" : "-8px 0 24px rgba(0,0,0,.12)", display: "flex", flexDirection: "column", boxSizing: "border-box", ...(inline ? { border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)" } : null), ...style }}>
8
+ <div role="dialog" aria-label={title} data-size={size} style={{ width, maxWidth: "100%", height: inline ? (height || 420) : "100%", background: "var(--bg-raised)", borderLeft: "1px solid var(--border-card)", boxShadow: inline ? "var(--shadow-menu)" : "var(--shadow-drawer)", display: "flex", flexDirection: "column", boxSizing: "border-box", ...(inline ? { border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)" } : null), ...style }}>
9
9
  <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", padding: "16px 16px 12px", borderBottom: "1px solid var(--border-card)", flexShrink: 0 }}>
10
- <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
11
- <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", color: "var(--text-heading)" }}>{title}</span>
10
+ <div style={{ display: "flex", flexDirection: "column", gap: 3, minWidth: 0 }}>
11
+ <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", color: "var(--text-heading)", minWidth: 0, overflowWrap: "anywhere" }}>{title}</span>
12
12
  {subtitle && <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-secondary)" }}>{subtitle}</span>}
13
13
  </div>
14
14
  <IconButton icon="x" label="Close" onClick={onClose} style={{ margin: "calc((var(--leading-md) - var(--control-h)) / 2) -6px 0 0" }} />