@keyfunc/keybricks-ds 2.6.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.
Files changed (178) hide show
  1. package/DEPLOY.md +3 -3
  2. package/DESIGN.md +21 -5
  3. package/INTEGRATION.md +35 -4
  4. package/_adherence.oxlintrc.json +56 -11
  5. package/_ds_bundle.js +2153 -216
  6. package/_scripts/build-index.mjs +1 -1
  7. package/_src/icon-index.js +1 -1
  8. package/{components/feedback → _vue-services}/MESSAGE_SERVICES.md +2 -2
  9. package/_vue-services/_internal/MessageBoxView.vue +150 -0
  10. package/{components/feedback → _vue-services}/_internal/service-utils.js +0 -12
  11. package/_vue-services/message.js +87 -0
  12. package/assets/icons/icon-index.json +1 -1
  13. package/changelog.js +21 -2
  14. package/components/_cards/accordion.card.html +1 -1
  15. package/components/_cards/action-chip.card.html +1 -1
  16. package/components/_cards/activity.card.html +1 -1
  17. package/components/_cards/alert.card.html +1 -1
  18. package/components/_cards/app-chrome.card.html +1 -1
  19. package/components/_cards/banner.card.html +1 -1
  20. package/components/_cards/breadcrumb.card.html +1 -1
  21. package/components/_cards/button-1-base.card.html +5 -2
  22. package/components/_cards/button-2-states.card.html +5 -5
  23. package/components/_cards/button-3-sizes-icons.card.html +2 -2
  24. package/components/_cards/button-4-group.card.html +2 -2
  25. package/components/_cards/button-5-ai.card.html +34 -0
  26. package/components/_cards/canvas-zoom-controls.card.html +1 -1
  27. package/components/_cards/cards-indicators.card.html +1 -1
  28. package/components/_cards/charts-1-sparkline.card.html +1 -1
  29. package/components/_cards/charts-10-gauge.card.html +1 -1
  30. package/components/_cards/charts-11-funnel.card.html +1 -1
  31. package/components/_cards/charts-12-treemap.card.html +1 -1
  32. package/components/_cards/charts-14-radar.card.html +1 -1
  33. package/components/_cards/charts-15-distribution.card.html +1 -1
  34. package/components/_cards/charts-16-pareto.card.html +1 -1
  35. package/components/_cards/charts-17-candlestick.card.html +1 -1
  36. package/components/_cards/charts-18-sankey.card.html +1 -1
  37. package/components/_cards/charts-19-chord.card.html +1 -1
  38. package/components/_cards/charts-2-line.card.html +1 -1
  39. package/components/_cards/charts-20-sunburst.card.html +1 -1
  40. package/components/_cards/charts-21-worldmap.card.html +1 -1
  41. package/components/_cards/charts-22-gantt.card.html +1 -1
  42. package/components/_cards/charts-23-bar3d.card.html +1 -1
  43. package/components/_cards/charts-24-pie3d.card.html +1 -1
  44. package/components/_cards/charts-25-surface3d.card.html +1 -1
  45. package/components/_cards/charts-26-scatter3d.card.html +1 -1
  46. package/components/_cards/charts-27-ribbon3d.card.html +1 -1
  47. package/components/_cards/charts-3-bar.card.html +1 -1
  48. package/components/_cards/charts-4-barlist.card.html +1 -1
  49. package/components/_cards/charts-5-donut.card.html +1 -1
  50. package/components/_cards/charts-6-scatter.card.html +1 -1
  51. package/components/_cards/charts-7-heatmap.card.html +1 -1
  52. package/components/_cards/charts-8-waterfall.card.html +1 -1
  53. package/components/_cards/charts-9-bullet.card.html +1 -1
  54. package/components/_cards/charts-99-fullpage.card.html +1 -1
  55. package/components/_cards/checkbox.card.html +1 -1
  56. package/components/_cards/codeblock.card.html +1 -1
  57. package/components/_cards/combobox.card.html +1 -1
  58. package/components/_cards/context-menu.card.html +1 -1
  59. package/components/_cards/date-picker.card.html +1 -1
  60. package/components/_cards/diffvalue.card.html +1 -1
  61. package/components/_cards/divider.card.html +1 -1
  62. package/components/_cards/drawer.card.html +1 -1
  63. package/components/_cards/dropdown-menu.card.html +1 -1
  64. package/components/_cards/dropzone.card.html +1 -1
  65. package/components/_cards/empty-state.card.html +1 -1
  66. package/components/_cards/filter-bar.card.html +1 -1
  67. package/components/_cards/icon-button.card.html +1 -1
  68. package/components/_cards/icons-0-usage.card.html +2 -2
  69. package/components/_cards/icons-1-core.card.html +1 -1
  70. package/components/_cards/icons-2-feather.card.html +1 -1
  71. package/components/_cards/icons-2b-lucide.card.html +1 -1
  72. package/components/_cards/icons-3-unicons.card.html +1 -1
  73. package/components/_cards/icons-4-datasource.card.html +1 -1
  74. package/components/_cards/input.card.html +1 -1
  75. package/components/_cards/kbd.card.html +5 -7
  76. package/components/_cards/keyvalue.card.html +1 -1
  77. package/components/_cards/listrow.card.html +1 -1
  78. package/components/_cards/loading.card.html +1 -1
  79. package/components/_cards/log-viewer.card.html +1 -1
  80. package/components/_cards/modal.card.html +8 -2
  81. package/components/_cards/multi-select.card.html +1 -1
  82. package/components/_cards/number-stepper.card.html +1 -1
  83. package/components/_cards/pagination.card.html +1 -1
  84. package/components/_cards/people.card.html +1 -1
  85. package/components/_cards/pipeline-node.card.html +1 -1
  86. package/components/_cards/pipeline-palette.card.html +1 -1
  87. package/components/_cards/radio.card.html +1 -1
  88. package/components/_cards/relative-time-picker.card.html +1 -1
  89. package/components/_cards/search-global.card.html +72 -0
  90. package/components/_cards/search.card.html +1 -1
  91. package/components/_cards/segmented-control.card.html +1 -1
  92. package/components/_cards/select.card.html +1 -1
  93. package/components/_cards/slider.card.html +1 -1
  94. package/components/_cards/smart-table.card.html +1 -1
  95. package/components/_cards/spreadsheet.card.html +1 -1
  96. package/components/_cards/stepper.card.html +1 -1
  97. package/components/_cards/table.card.html +10 -1
  98. package/components/_cards/tabs.card.html +1 -1
  99. package/components/_cards/tag.card.html +1 -1
  100. package/components/_cards/textarea.card.html +1 -1
  101. package/components/_cards/timeline.card.html +1 -1
  102. package/components/_cards/toast.card.html +11 -1
  103. package/components/_cards/toggles.card.html +1 -1
  104. package/components/_cards/tooltip.card.html +1 -1
  105. package/components/_cards/tree.card.html +22 -1
  106. package/components/_vue/index.mjs +2 -0
  107. package/components/buttons/Button.d.ts +14 -5
  108. package/components/buttons/Button.jsx +45 -8
  109. package/components/buttons/Button.vue +40 -38
  110. package/components/buttons/Button.vue.d.ts +4 -1
  111. package/components/buttons/ai-button.js +54 -0
  112. package/components/buttons/buttons.prompt.md +7 -2
  113. package/components/data/Table.d.ts +5 -0
  114. package/components/data/Table.jsx +10 -5
  115. package/components/data/Table.vue +9 -3
  116. package/components/data/data.prompt.md +2 -0
  117. package/components/data/row-open.js +13 -0
  118. package/components/display/Kbd.d.ts +3 -1
  119. package/components/display/Kbd.jsx +8 -3
  120. package/components/display/Kbd.vue +7 -3
  121. package/components/display/display.prompt.md +3 -1
  122. package/components/feedback/Modal.d.ts +65 -7
  123. package/components/feedback/Modal.jsx +52 -22
  124. package/components/feedback/Modal.vue +24 -6
  125. package/components/feedback/Toast.vue +7 -7
  126. package/components/feedback/ToastStack.vue +37 -4
  127. package/components/feedback/feedback.prompt.md +6 -0
  128. package/components/feedback/modal-behavior.js +50 -0
  129. package/components/forms/GlobalSearch.d.ts +83 -0
  130. package/components/forms/GlobalSearch.jsx +172 -0
  131. package/components/forms/GlobalSearch.vue +161 -0
  132. package/components/forms/forms.prompt.md +7 -0
  133. package/components/forms/global-search.js +161 -0
  134. package/components/icons/Icon.d.ts +2 -0
  135. package/components/icons/Icon.jsx +14 -6
  136. package/components/icons/Icon.vue +11 -4
  137. package/components/icons/icon-paths.js +4 -2
  138. package/components/index.d.ts +1 -0
  139. package/components/index.js +1 -0
  140. package/components/navigation/TopBar.d.ts +9 -3
  141. package/components/navigation/TopBar.jsx +5 -7
  142. package/components/navigation/TopBar.vue +5 -6
  143. package/components/navigation/Tree.d.ts +5 -1
  144. package/components/navigation/Tree.jsx +9 -7
  145. package/components/navigation/Tree.vue +7 -6
  146. package/components/navigation/navigation.prompt.md +2 -0
  147. package/covers.js +4 -2
  148. package/gallery.html +1 -1
  149. package/guidelines/brand-logo.html +1 -1
  150. package/guidelines/colors-schemes.html +1 -1
  151. package/guidelines/colors-transparency.html +1 -1
  152. package/guidelines/foundations-borders.html +1 -1
  153. package/guidelines/foundations-chrome.html +1 -1
  154. package/guidelines/foundations-motion.html +1 -1
  155. package/guidelines/foundations-size-tiers.html +1 -1
  156. package/guidelines/foundations-spacing.html +1 -1
  157. package/guidelines/interaction-states.html +1 -1
  158. package/guidelines/type-extended.html +1 -1
  159. package/guidelines/type-mono.html +1 -1
  160. package/guidelines/type-scale.html +1 -1
  161. package/guidelines/type-weights.html +1 -1
  162. package/index.html +10 -10
  163. package/package.json +7 -6
  164. package/readme.md +4 -4
  165. package/readme.zh-CN.md +4 -4
  166. package/site-nav.js +1 -1
  167. package/styles.css +1 -0
  168. package/tokens/ai.css +17 -0
  169. package/tokens/colors.css +1 -1
  170. package/tokens/tokens.d.ts +28 -0
  171. package/tokens/tokens.js +47 -5
  172. package/tokens/tokens.json +123 -5
  173. package/components/feedback/_internal/MessageBoxView.vue +0 -237
  174. package/components/feedback/_internal/MessageView.vue +0 -73
  175. package/components/feedback/message.js +0 -68
  176. /package/{components/feedback → _vue-services}/message-box.d.ts +0 -0
  177. /package/{components/feedback → _vue-services}/message-box.js +0 -0
  178. /package/{components/feedback → _vue-services}/message.d.ts +0 -0
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
2
  import { px } from '../px.js'
3
- import { cloneVNode, computed, h, isVNode, ref } from 'vue'
3
+ import { cloneVNode, computed, h, isVNode, ref, watch, onMounted, onBeforeUnmount } from 'vue'
4
4
  import Icon from '../icons/Icon.vue'
5
+ import { aiStyles, aiSpinDelay, rampAiSpin } from './ai-button.js'
5
6
 
6
7
  // Every dimension comes from the size tier the button sits in (tokens/layout.css): height, text, icon and padding
7
8
  // switch together when a region declares data-size="sm" | "md" | "lg". The `size` prop only stamps that attribute on
@@ -22,13 +23,13 @@ const hovers = { primary: 'var(--action-primary-hover)', dark: 'var(--action-dar
22
23
  const presses = { primary: 'var(--action-primary-active)', dark: 'var(--action-dark-active)', outline: 'var(--bg-press)', ghost: 'var(--bg-press)', 'primary-soft': 'var(--soft-active)', danger: 'var(--danger-active)', 'danger-soft': 'var(--danger-soft-active)' }
23
24
  const colorHoverVariants = { text: 'var(--text-heading)' }
24
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
+ // Button — variant: primary | dark | outline | ghost | primary-soft | danger | danger-soft | text | ai (Prophet AI entry points only); size: sm 28 | md 32 | lg 40 — omit it to inherit the region's tier (the default).
26
27
  const props = defineProps({
27
28
  variant: { type: String, default: 'primary' }, size: String,
28
29
  icon: [String, Object, Function], trailingIcon: [String, Object, Function],
29
30
  iconSize: Number, iconPosition: { type: String, default: 'left' },
30
31
  medium: Boolean, disabled: Boolean, loading: Boolean, fullWidth: Boolean, iconOnly: Boolean,
31
- label: String, text: String, pressed: Boolean, focused: Boolean, style: Object,
32
+ label: String, text: String, hovered: Boolean, pressed: Boolean, focused: Boolean, style: Object,
32
33
  nativeType: { type: String, default: 'button', validator: value => ['button', 'submit', 'reset'].includes(value) },
33
34
  })
34
35
  const emit = defineEmits(['click'])
@@ -47,7 +48,7 @@ const s = computed(() => {
47
48
  if (hover.value || isPressed.value) { base.color = colorHoverVariants[props.variant]; base.textDecoration = 'underline' }
48
49
  if (isPressed.value) base.opacity = .7
49
50
  } else if (isPressed.value) base.background = presses[props.variant]
50
- else if (hover.value) base.background = hovers[props.variant]
51
+ else if (hover.value || props.hovered) base.background = hovers[props.variant]
51
52
  if (isFocused.value) base.boxShadow = 'var(--ring-focus)'
52
53
  }
53
54
  return base
@@ -72,42 +73,43 @@ function onClick(event) {
72
73
  if (busy.value) return
73
74
  emit('click', event)
74
75
  }
76
+ // variant="ai" — see ai-button.js
77
+ const aiEl = ref(null), aiDelay = aiSpinDelay()
78
+ const aiOn = computed(() => !busy.value && (hover.value || props.hovered))
79
+ const ai = computed(() => aiStyles({ on: aiOn.value, press: !busy.value && isPressed.value, busy: busy.value, focused: isFocused.value, medium: props.medium, fullWidth: props.fullWidth, iconOnly: props.iconOnly, delay: aiDelay, style: props.style }))
80
+ const aiIcon = computed(() => (typeof props.icon === 'string' && props.icon) || 'prophet-sparkle')
81
+ let stopRamp = () => {}
82
+ const ramp = (on) => { stopRamp(); stopRamp = rampAiSpin(aiEl.value, on) }
83
+ watch(aiOn, ramp)
84
+ onMounted(() => { if (aiOn.value) ramp(true) })
85
+ onBeforeUnmount(() => stopRamp())
75
86
  </script>
76
87
  <template>
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>
88
+ <button v-if="variant === 'ai'" ref="aiEl" :type="nativeType" data-press="own" :data-size="size" :style="ai.outer" :disabled="busy" :aria-busy="loading" :aria-label="iconOnly ? label : undefined"
89
+ @click="onClick" @mouseenter="hover = true" @mouseleave="hover = false; press = false"
90
+ @mousedown="press = true" @mouseup="press = false" @keydown.enter="press = true" @keydown.space="press = true" @keyup="press = false"
91
+ @focus="focus = $event.target.matches(':focus-visible')" @blur="focus = false">
92
+ <span aria-hidden="true" :style="ai.glow" />
93
+ <span aria-hidden="true" :style="ai.ring" />
94
+ <span :style="ai.surface">
95
+ <span aria-hidden="true" :style="ai.tint" />
96
+ <span v-if="loading" :style="ai.iconBox"><slot name="loading" :size="iSize" :style="iconStyle"><span aria-hidden="true" :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 }" /></slot></span>
97
+ <span v-else :style="ai.iconBox">
98
+ <Icon :name="aiIcon" :size="iSize" :style="{ ...iconStyle, ...ai.iconRest }" />
99
+ <Icon :name="aiIcon" :size="iSize" gradient :style="{ ...iconStyle, ...ai.iconGrad }" />
100
+ </span>
101
+ <span v-if="!iconOnly" :style="ai.label"><slot>{{ text }}</slot></span>
102
+ <slot name="trailing" :size="iSize" :style="iconStyle" :loading="loading"><TrailingIcon v-if="trailingIcon" /></slot>
103
+ </span>
104
+ </button>
105
+ <button v-else :type="nativeType" data-press="own" :data-size="size" :style="s" :disabled="busy" :aria-busy="loading" :aria-label="iconOnly ? label : undefined"
106
+ @click="onClick" @mouseenter="hover = true" @mouseleave="hover = false; press = false"
107
+ @mousedown="press = true" @mouseup="press = false" @keydown.enter="press = true" @keydown.space="press = true" @keyup="press = false"
108
+ @focus="focus = $event.target.matches(':focus-visible')" @blur="focus = false">
109
+ <slot v-if="loading" name="loading" :size="iSize" :style="iconStyle"><span aria-hidden="true" :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 }" /></slot>
110
+ <slot v-else-if="iconPosition === 'left'" name="icon" :size="iSize" :style="iconStyle"><LeadingIcon v-if="icon" /></slot>
105
111
  <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>
112
+ <slot v-if="!loading && iconPosition === 'right'" name="icon" :size="iSize" :style="iconStyle"><LeadingIcon v-if="icon" /></slot>
113
+ <slot name="trailing" :size="iSize" :style="iconStyle" :loading="loading"><TrailingIcon v-if="trailingIcon" /></slot>
112
114
  </button>
113
115
  </template>
@@ -2,7 +2,8 @@ import type { Component, ComponentOptionsMixin, CSSProperties, DefineComponent,
2
2
 
3
3
  /** Vue 专用声明;同目录 Button.d.ts 继续描述 React 接口。 */
4
4
  export interface VueButtonProps {
5
- variant?: 'primary' | 'dark' | 'outline' | 'ghost' | 'primary-soft' | 'danger' | 'danger-soft' | 'text';
5
+ /** ai:仅用于 Prophet AI(方知)入口;它的 icon 只接受 DS 图标名,默认 prophet-sparkle。 */
6
+ variant?: 'primary' | 'dark' | 'outline' | 'ghost' | 'primary-soft' | 'danger' | 'danger-soft' | 'text' | 'ai';
6
7
  size?: 'sm' | 'md' | 'lg';
7
8
  icon?: string | Component | VNode;
8
9
  /** 不随 loading 隐藏,适用于下拉箭头等独立的尾部标记。 */
@@ -17,6 +18,8 @@ export interface VueButtonProps {
17
18
  label?: string;
18
19
  /** 默认插槽的后备文本。 */
19
20
  text?: string;
21
+ /** 强制悬停样式,用于规范展示。 */
22
+ hovered?: boolean;
20
23
  pressed?: boolean;
21
24
  focused?: boolean;
22
25
  style?: CSSProperties;
@@ -0,0 +1,54 @@
1
+ /* Button variant="ai" — the Prophet AI (方知) entry treatment, shared by Button.jsx and Button.vue.
2
+ Same outer box as every Button at the tier (height, radius, padding); the 1px border is replaced by a 1px ring:
3
+ at rest a gradient comet travels round a neutral track; on hover the full ring, an 8% gradient tint, a soft glow and a
4
+ gradient icon fade in and the ring speeds up. Tokens: tokens/ai.css. One CSS animation (kb-ai-spin) drives the angle;
5
+ hover speed is ramped with Animation.playbackRate, so the angle never jumps. */
6
+ /* The animated angle needs a registered <angle> property and a keyframe on it — injected once, so the token files stay plain. */
7
+ if (typeof document !== "undefined" && !document.querySelector("style[data-kb-ai]")) {
8
+ const el = document.createElement("style");
9
+ el.setAttribute("data-kb-ai", "");
10
+ el.textContent = "@property --kb-ai-angle{syntax:'<angle>';inherits:true;initial-value:0deg}@keyframes kb-ai-spin{to{--kb-ai-angle:360deg}}";
11
+ document.head.appendChild(el);
12
+ }
13
+ const FADE = "opacity 300ms var(--ease-out)";
14
+ const FULL = "conic-gradient(from var(--kb-ai-angle), var(--ai-gradient-stops))";
15
+ const COMET = "conic-gradient(from var(--kb-ai-angle), var(--ai-ring-rest-track) 0 50%, var(--ai-gradient-start) 78%, var(--ai-gradient-end) 100%)";
16
+ const layer = { position: "absolute", inset: "0", borderRadius: "inherit", pointerEvents: "none", transition: FADE };
17
+
18
+ /** A random phase per instance, so neighbouring rings never move in lockstep. */
19
+ export const aiSpinDelay = () => "-" + (Math.random() * 5).toFixed(2) + "s";
20
+
21
+ /** on = hovered (and not busy). Lengths are strings so the same objects work in React and Vue. */
22
+ export function aiStyles({ on, press, busy, focused, medium, fullWidth, iconOnly, delay, style }) {
23
+ const tint = press ? "var(--ai-tint-press)" : "var(--ai-tint)";
24
+ return {
25
+ outer: { position: "relative", isolation: "isolate", display: fullWidth ? "flex" : "inline-flex", alignItems: "stretch", width: fullWidth ? "100%" : iconOnly ? "var(--control-h)" : undefined, height: "var(--control-h)", padding: "var(--ai-ring-width)", border: "none", borderRadius: "var(--radius-md)", boxSizing: "border-box", background: COMET, animation: "kb-ai-spin var(--ai-spin-rest) linear " + delay + " infinite", cursor: busy ? "default" : "pointer", opacity: busy ? .55 : undefined, boxShadow: focused && !busy ? "var(--ring-focus)" : undefined, fontFamily: "var(--font-sans)", fontWeight: medium ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: "var(--text-body)", whiteSpace: "nowrap", verticalAlign: "middle", ...style },
26
+ glow: { ...layer, zIndex: "-1", background: FULL, filter: "blur(var(--ai-glow-blur))", opacity: on ? "var(--ai-glow-opacity)" : "0" },
27
+ ring: { ...layer, background: FULL, opacity: on ? "1" : "0" },
28
+ surface: { position: "relative", flex: "1 1 auto", display: "flex", alignItems: "center", justifyContent: "center", gap: iconOnly ? "0" : "6px", padding: iconOnly ? "0" : "0 calc(var(--control-px) - var(--ai-ring-width))", borderRadius: "calc(var(--radius-md) - var(--ai-ring-width))", background: "var(--bg-surface)", overflow: "hidden" },
29
+ tint: { ...layer, background: "linear-gradient(90deg, color-mix(in srgb, var(--ai-gradient-start) " + tint + ", transparent), color-mix(in srgb, var(--ai-gradient-end) " + tint + ", transparent))", opacity: on || press ? "1" : "0" },
30
+ iconBox: { position: "relative", display: "inline-flex", flexShrink: "0" },
31
+ iconRest: { transition: FADE, opacity: on ? "0" : "1" },
32
+ iconGrad: { position: "absolute", inset: "0", transition: FADE, opacity: on ? "1" : "0" },
33
+ label: { position: "relative" },
34
+ };
35
+ }
36
+
37
+ const secs = (v, d) => { v = (v || "").trim(); const n = parseFloat(v); return isNaN(n) ? d : /ms$/.test(v) ? n / 1000 : n; };
38
+ /** Ease the ring's speed toward rest (fast=false) or hover (fast=true). Returns a cancel function. */
39
+ export function rampAiSpin(el, fast) {
40
+ if (!el || !el.getAnimations || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)) return () => {};
41
+ const anim = el.getAnimations().find((a) => a.animationName === "kb-ai-spin");
42
+ if (!anim) return () => {};
43
+ const cs = getComputedStyle(el);
44
+ const target = fast ? secs(cs.getPropertyValue("--ai-spin-rest"), 5) / secs(cs.getPropertyValue("--ai-spin-hover"), 1.2) : 1;
45
+ let raf = 0, last = performance.now();
46
+ const step = (t) => {
47
+ const dt = Math.min(0.05, (t - last) / 1000); last = t;
48
+ const r = anim.playbackRate + (target - anim.playbackRate) * Math.min(1, dt * 5);
49
+ if (Math.abs(target - r) < 0.01) { anim.playbackRate = target; return; }
50
+ anim.playbackRate = r; raf = requestAnimationFrame(step);
51
+ };
52
+ raf = requestAnimationFrame(step);
53
+ return () => cancelAnimationFrame(raf);
54
+ }
@@ -2,7 +2,8 @@ Buttons: `Button` (primary/dark/outline/ghost/primary-soft), `IconButton` (squar
2
2
 
3
3
  ```jsx
4
4
  <Button icon="send">Submit</Button>
5
- <Button variant="dark" icon="ai-sparkles">AI Assistant</Button>
5
+ <Button variant="dark" icon="upload-cloud">Publish</Button>
6
+ <Button variant="ai">Ask Prophet AI</Button> // 中文:问方知 — icon defaults to prophet-sparkle
6
7
  <Button variant="ghost" icon="play">Run Test</Button>
7
8
  <Button variant="text">Cancel</Button>
8
9
  <Button variant="primary-soft" icon="eye">Data Preview</Button>
@@ -17,4 +18,8 @@ Buttons: `Button` (primary/dark/outline/ghost/primary-soft), `IconButton` (squar
17
18
  <ActionChip tone="success">Mark as New</ActionChip>
18
19
  ```
19
20
 
20
- Primary = the single blue CTA per view. Dark = brand-level actions (AI Assistant, Show). Ghost = no box at rest, grey wash on hover — the default for actions in card and section headers, table toolbars and panel chrome ("Export", "Filter", "Run Test", "Back"); same height as outline so it lines up. `text` = bare label, underline on hover — links that happen to be buttons ("View all", "Clear filters"), never beside a boxed button and never a toolbar action. Emphasis is always the variant, never the size: a quieter action gets `ghost` or `text`, not `size="sm"`. Danger/danger-soft = destructive actions. lg (36px) comes from an lg region — dialogs and wizard footers; `Modal` sets it for you. A `size` prop on a single button is only for a control that stands alone, never to adjust one button inside a bar. `loading` swaps the icon for a spinner and disables the button without changing its size; `iconOnly` needs a `label` for accessibility; `ButtonGroup` fuses adjacent buttons into one segmented control or a split button (main action + attached icon-only).
21
+ Primary = the single blue CTA per view. Dark = brand-level actions that should outweigh the page but not compete with the blue CTA (Publish, Show). Never for AI — Prophet AI entries use `ai`. Ghost = no box at rest, grey wash on hover — the default for actions in card and section headers, table toolbars and panel chrome ("Export", "Filter", "Run Test", "Back"); same height as outline so it lines up. `text` = bare label, underline on hover — links that happen to be buttons ("View all", "Clear filters"), never beside a boxed button and never a toolbar action. Emphasis is always the variant, never the size: a quieter action gets `ghost` or `text`, not `size="sm"`. Danger/danger-soft = destructive actions. lg (36px) comes from an lg region — dialogs and wizard footers; `Modal` sets it for you. A `size` prop on a single button is only for a control that stands alone, never to adjust one button inside a bar. `loading` swaps the icon for a spinner and disables the button without changing its size; `iconOnly` needs a `label` for accessibility; `ButtonGroup` fuses adjacent buttons into one segmented control or a split button (main action + attached icon-only).
22
+
23
+ `ai` = Prophet AI (方知) entry points only — never a generic primary action, at most one per region. Same box, tiers and generic states (focus, disabled, loading) as every Button; only the surface differs: a 1px travelling gradient ring at rest, and on hover the full ring, an 8% gradient tint, a soft glow and a gradient icon, with the ring speeding up. It only triggers (opens the Prophet AI panel); it never shows AI content in place. Tokens live in `tokens/ai.css` (`--ai-*`); `<Icon gradient/>` renders the gradient glyph.
24
+
25
+ Legacy adapters (both frameworks): `trailingIcon` (e.g. "chevron-down" on a menu button, stays visible while loading), `nativeType` ("submit" for old forms), `text` (fallback label), element / component icons, `aria-busy`. Vue adds `#icon` / `#loading` / `#trailing` slots and has its own types in `Button.vue.d.ts`. Migrating the old actnplanBtn: `BUTTON_MIGRATION.md`.
@@ -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>
@@ -31,3 +31,5 @@ Data lists: `Table` (hairline table, mono cells, selectable; a size region — `
31
31
  ```jsx
32
32
  <CodeBlock language="sql" code={"SELECT * FROM sales_orders"}/>
33
33
  ```
34
+
35
+ `Table rowOpen="row"` — with `onRowOpen` / `@row-open`, a click anywhere on a body row opens it (hand cursor on the whole row); buttons, checkboxes, inputs, links, the ⋯ menu, the tree arrow and text selection are left alone. Default `"title"` keeps opening on the tile and title only.
@@ -0,0 +1,13 @@
1
+ /* Table rowOpen="row": decides whether a click on a body row opens it. Clicks on controls inside the row (buttons — the ⋯ menu and
2
+ the tree arrow included — checkboxes, inputs, links, menus), on the title / tile that open the row themselves, and clicks that end a
3
+ text selection do not. Header cells and resize handles sit outside the body rows, so they never reach here. */
4
+ const SKIP = 'button, a[href], input, select, textarea, label, summary, [contenteditable=""], [contenteditable="true"], [role="button"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [role="menu"], [role="link"], [role="combobox"], [role="slider"], [data-row-open], [data-row-skip]';
5
+ export function rowOpenFromClick(e) {
6
+ const tr = e.currentTarget, t = e.target;
7
+ if (!tr || e.defaultPrevented || (e.button != null && e.button !== 0)) return false;
8
+ const hit = t && t.closest ? t.closest(SKIP) : null;
9
+ if (hit && tr.contains(hit)) return false;
10
+ const sel = typeof window !== "undefined" && window.getSelection ? window.getSelection() : null;
11
+ if (sel && !sel.isCollapsed && String(sel).length > 0) return false;
12
+ return true;
13
+ }
@@ -1,7 +1,9 @@
1
1
  import * as React from "react";
2
- /** Keyboard shortcut keycap row (20px). Addition — for a power-user data tool's shortcut hints. */
2
+ /** Keyboard shortcut keycap row (20px). Semantic keys adapt to the OS: "mod" → ⌘ on macOS / Ctrl elsewhere; also "alt"/"opt", "shift", "enter", "backspace", "ctrl", "esc". Any other string renders as-is. */
3
3
  export interface KbdProps {
4
4
  keys?: string[];
5
+ /** "auto" detects from the browser (default); force "mac" or "win" for docs/screenshots. */
6
+ platform?: "auto" | "mac" | "win";
5
7
  style?: React.CSSProperties;
6
8
  }
7
9
  export declare function Kbd(props: KbdProps): JSX.Element;
@@ -1,11 +1,16 @@
1
1
  import React from "react";
2
2
 
3
- /** Keyboard shortcut hint, e.g. <Kbd keys={["⌘","K"]}/>. */
4
- export function Kbd({ keys = [], style }) {
3
+ const KBD_MAP = { mod: ["⌘", "Ctrl"], cmd: ["⌘", "Ctrl"], ctrl: ["⌃", "Ctrl"], alt: ["⌥", "Alt"], opt: ["⌥", "Alt"], shift: ["⇧", "Shift"], enter: ["⏎", "Enter"], backspace: ["⌫", "Backspace"], esc: ["Esc", "Esc"] };
4
+ const isMacPlatform = () => typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || "");
5
+ const resolveKbd = (k, mac) => { const m = KBD_MAP[String(k).toLowerCase()]; return m ? m[mac ? 0 : 1] : k; };
6
+
7
+ /** Keyboard shortcut hint. Use semantic keys so it adapts per OS: <Kbd keys={["mod","K"]}/> → ⌘ K on macOS, Ctrl K on Windows/Linux. Literal labels ("⌘", "F5") pass through unchanged. platform: "auto" (default) | "mac" | "win". */
8
+ export function Kbd({ keys = [], platform = "auto", style }) {
9
+ const mac = platform === "auto" ? isMacPlatform() : platform === "mac";
5
10
  return (
6
11
  <span style={{ display: "inline-flex", gap: 3, ...style }}>
7
12
  {keys.map((k, i) => (
8
- <span key={i} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 20, height: 20, padding: "0 5px", borderRadius: "var(--radius-sm)", background: "var(--bg-inset)", border: "1px solid var(--border-control)", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{k}</span>
13
+ <span key={i} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 20, height: 20, padding: "0 5px", borderRadius: "var(--radius-sm)", background: "var(--bg-inset)", border: "1px solid var(--border-control)", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{resolveKbd(k, mac)}</span>
9
14
  ))}
10
15
  </span>
11
16
  );
@@ -1,9 +1,13 @@
1
1
  <script setup>
2
- // Keyboard shortcut hint, e.g. <Kbd :keys="['⌘','K']"/>.
3
- defineProps({ keys: { type: Array, default: () => [] }, style: Object })
2
+ // Keyboard shortcut hint. Semantic keys adapt per OS: <Kbd :keys="['mod','K']"/> → ⌘ K on macOS, Ctrl K on Windows/Linux. Literal labels pass through. platform: 'auto' | 'mac' | 'win'.
3
+ import { computed } from 'vue'
4
+ const props = defineProps({ keys: { type: Array, default: () => [] }, platform: { type: String, default: 'auto' }, style: Object })
5
+ const KBD_MAP = { mod: ['⌘', 'Ctrl'], cmd: ['⌘', 'Ctrl'], ctrl: ['⌃', 'Ctrl'], alt: ['⌥', 'Alt'], opt: ['⌥', 'Alt'], shift: ['⇧', 'Shift'], enter: ['⏎', 'Enter'], backspace: ['⌫', 'Backspace'], esc: ['Esc', 'Esc'] }
6
+ const isMac = () => typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || '')
7
+ const labels = computed(() => { const mac = props.platform === 'auto' ? isMac() : props.platform === 'mac'; return props.keys.map(k => { const m = KBD_MAP[String(k).toLowerCase()]; return m ? m[mac ? 0 : 1] : k }) })
4
8
  </script>
5
9
  <template>
6
10
  <span :style="{ display: 'inline-flex', gap: '3px', ...style }">
7
- <span v-for="(k, i) in keys" :key="i" style="display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-sm);background:var(--bg-inset);border:1px solid var(--border-control);box-sizing:border-box;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)">{{ k }}</span>
11
+ <span v-for="(k, i) in labels" :key="i" style="display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-sm);background:var(--bg-inset);border:1px solid var(--border-control);box-sizing:border-box;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)">{{ k }}</span>
8
12
  </span>
9
13
  </template>
@@ -26,5 +26,7 @@ Cards are flat (no shadow). IconTile hues: source green, join blue, transform or
26
26
  ```jsx
27
27
  <Tag onRemove={remove}>Financial Settings</Tag>
28
28
  <Divider label="OR"/> <Divider vertical/>
29
- <Kbd keys={["⌘","K"]}/>
29
+ <Kbd keys={["mod","K"]}/> // ⌘ K on macOS, Ctrl K on Windows/Linux
30
30
  ```
31
+
32
+ Kbd: write modifiers semantically — `mod` (⌘/Ctrl), `alt`/`opt` (⌥/Alt), `shift` (⇧/Shift), `enter` (⏎/Enter), `backspace`, `ctrl`, `esc`. Don't hard-code "⌘": Windows users have no Command key. `platform="mac"|"win"` forces one side for docs.
@@ -7,15 +7,35 @@ export interface TooltipProps {
7
7
  style?: React.CSSProperties;
8
8
  }
9
9
  export declare function Tooltip(props: TooltipProps): JSX.Element;
10
- /** Centered dialog with scrim, title/subtitle, close, footer slot. Addition. */
10
+ /** Centered dialog with scrim, title/subtitle, close, footer slot. Focus moves into the dialog ([data-autofocus] first), Tab stays inside, and focus returns on close. */
11
11
  export interface ModalProps {
12
12
  open?: boolean;
13
- title?: string;
13
+ /** Vue: also a #title slot */
14
+ title?: React.ReactNode;
14
15
  subtitle?: string;
15
- onClose?: () => void;
16
+ /** Why it closed: "close" (×), "esc" or "scrim". Vue: @close(reason). */
17
+ onClose?: (reason: "close" | "esc" | "scrim") => void;
18
+ /** Esc closes the topmost dialog (default true) */
19
+ closeOnEsc?: boolean;
20
+ /** A click on the scrim closes it (default true; turn off for confirmations that must be answered) */
21
+ closeOnScrim?: boolean;
22
+ /** The × in the header (default true) */
23
+ showClose?: boolean;
24
+ /** Stops the page behind from scrolling while open (default true) */
25
+ lockScroll?: boolean;
26
+ /** Dims the page behind (default true) */
27
+ scrim?: boolean;
28
+ /** Moves focus in on open (default true) */
29
+ autoFocus?: boolean;
30
+ /** role="alertdialog" — for confirmations and warnings that need an answer */
31
+ alert?: boolean;
32
+ /** Overrides --z-modal */
33
+ zIndex?: number | string;
34
+ /** aria-label of the × (default "Close") */
35
+ closeLabel?: string;
16
36
  /** Right-aligned footer buttons */
17
37
  footer?: React.ReactNode;
18
- width?: number;
38
+ width?: number | string;
19
39
  /** Size tier of the dialog region. Defaults to "lg" (36px controls) regardless of the page behind it — a dialog does not compete for space. */
20
40
  size?: "sm" | "md" | "lg";
21
41
  children?: React.ReactNode;
@@ -27,14 +47,52 @@ export interface ToastProps {
27
47
  tone?: "success" | "danger" | "warning" | "info";
28
48
  title?: string;
29
49
  meta?: string;
50
+ /** Repeat count: 2 or more shows "× n" after the title */
51
+ count?: number;
52
+ /** Replaces the tone glyph (Vue: #icon slot) */
53
+ icon?: React.ReactNode;
54
+ /** Content in place of title (Vue: default slot) */
55
+ children?: React.ReactNode;
56
+ /** aria-label of the dismiss button (default "Dismiss") */
57
+ closeLabel?: string;
58
+ /** Shows the dismiss button */
30
59
  onClose?: () => void;
31
60
  style?: React.CSSProperties;
32
61
  }
33
62
  export declare function Toast(props: ToastProps): JSX.Element;
34
- /** Fixed bottom-right toast stack. */
63
+ export interface ToastStackItem {
64
+ id: string | number;
65
+ tone?: "success" | "danger" | "warning" | "info";
66
+ title?: string;
67
+ meta?: string;
68
+ /** Content in place of title (Vue: string | VNode | () => VNode) */
69
+ content?: React.ReactNode;
70
+ /** Glyph name or element in place of the tone glyph */
71
+ icon?: string | React.ReactNode;
72
+ /** "× n" after the title; a change restarts the timer */
73
+ count?: number;
74
+ /** ms, overrides the stack's duration; 0 = stay */
75
+ duration?: number;
76
+ /** Shows the × (default true) */
77
+ dismissible?: boolean;
78
+ closeLabel?: string;
79
+ /** Vue: class */
80
+ className?: string;
81
+ style?: React.CSSProperties;
82
+ }
83
+ /** Fixed stack of toasts. Toasts are role="status" (danger / warning: role="alert"), so screen readers announce them. */
35
84
  export interface ToastStackProps {
36
- toasts?: { id: string | number; tone?: string; title?: string; meta?: string }[];
37
- onDismiss?: (id: string | number) => void;
85
+ toasts?: ToastStackItem[];
86
+ /** Where the stack sits (default "bottom-right"). Vue Message uses "top-center". */
87
+ placement?: "bottom-right" | "top-center" | "top-right" | "bottom-center";
88
+ /** Distance from the edges in px (default 24) */
89
+ offset?: number;
90
+ /** Auto-dismiss after ms (default 0 = stay). Paused while the pointer or focus is on a toast. */
91
+ duration?: number;
92
+ /** Overrides --z-toast */
93
+ zIndex?: number | string;
94
+ /** "close" (×) or "timeout". Vue: @dismiss(id, reason). */
95
+ onDismiss?: (id: string | number, reason: "close" | "timeout") => void;
38
96
  style?: React.CSSProperties;
39
97
  }
40
98
  export declare function ToastStack(props: ToastStackProps): JSX.Element;