@keyfunc/keybricks-ds 2.4.12 → 2.5.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 (99) hide show
  1. package/DEPLOY.md +4 -4
  2. package/INTEGRATION.md +23 -6
  3. package/_adherence.oxlintrc.json +12 -4
  4. package/changelog.js +14 -0
  5. package/components/_cards/accordion.card.html +1 -1
  6. package/components/_cards/action-chip.card.html +1 -1
  7. package/components/_cards/activity.card.html +1 -1
  8. package/components/_cards/alert.card.html +1 -1
  9. package/components/_cards/app-chrome.card.html +1 -1
  10. package/components/_cards/banner.card.html +1 -1
  11. package/components/_cards/breadcrumb.card.html +1 -1
  12. package/components/_cards/button-1-base.card.html +1 -1
  13. package/components/_cards/button-2-states.card.html +1 -1
  14. package/components/_cards/button-3-sizes-icons.card.html +1 -1
  15. package/components/_cards/button-4-group.card.html +1 -1
  16. package/components/_cards/cards-indicators.card.html +1 -1
  17. package/components/_cards/charts-1-sparkline.card.html +1 -1
  18. package/components/_cards/charts-10-gauge.card.html +1 -1
  19. package/components/_cards/charts-11-funnel.card.html +1 -1
  20. package/components/_cards/charts-12-treemap.card.html +1 -1
  21. package/components/_cards/charts-14-radar.card.html +1 -1
  22. package/components/_cards/charts-15-distribution.card.html +1 -1
  23. package/components/_cards/charts-16-pareto.card.html +1 -1
  24. package/components/_cards/charts-17-candlestick.card.html +1 -1
  25. package/components/_cards/charts-18-sankey.card.html +1 -1
  26. package/components/_cards/charts-19-chord.card.html +1 -1
  27. package/components/_cards/charts-2-line.card.html +1 -1
  28. package/components/_cards/charts-20-sunburst.card.html +1 -1
  29. package/components/_cards/charts-21-worldmap.card.html +1 -1
  30. package/components/_cards/charts-22-gantt.card.html +1 -1
  31. package/components/_cards/charts-23-bar3d.card.html +1 -1
  32. package/components/_cards/charts-24-pie3d.card.html +1 -1
  33. package/components/_cards/charts-25-surface3d.card.html +1 -1
  34. package/components/_cards/charts-26-scatter3d.card.html +1 -1
  35. package/components/_cards/charts-27-ribbon3d.card.html +1 -1
  36. package/components/_cards/charts-3-bar.card.html +1 -1
  37. package/components/_cards/charts-4-barlist.card.html +1 -1
  38. package/components/_cards/charts-5-donut.card.html +1 -1
  39. package/components/_cards/charts-6-scatter.card.html +1 -1
  40. package/components/_cards/charts-7-heatmap.card.html +1 -1
  41. package/components/_cards/charts-8-waterfall.card.html +1 -1
  42. package/components/_cards/charts-9-bullet.card.html +1 -1
  43. package/components/_cards/checkbox.card.html +1 -1
  44. package/components/_cards/codeblock.card.html +1 -1
  45. package/components/_cards/combobox.card.html +1 -1
  46. package/components/_cards/context-menu.card.html +1 -1
  47. package/components/_cards/date-picker.card.html +1 -1
  48. package/components/_cards/diffvalue.card.html +1 -1
  49. package/components/_cards/divider.card.html +1 -1
  50. package/components/_cards/drawer.card.html +1 -1
  51. package/components/_cards/dropdown-menu.card.html +28 -5
  52. package/components/_cards/dropzone.card.html +1 -1
  53. package/components/_cards/empty-state.card.html +1 -1
  54. package/components/_cards/icon-button.card.html +1 -1
  55. package/components/_cards/input.card.html +1 -1
  56. package/components/_cards/kbd.card.html +1 -1
  57. package/components/_cards/keyvalue.card.html +1 -1
  58. package/components/_cards/listrow.card.html +1 -1
  59. package/components/_cards/loading.card.html +1 -1
  60. package/components/_cards/log-viewer.card.html +1 -1
  61. package/components/_cards/multi-select.card.html +1 -1
  62. package/components/_cards/number-stepper.card.html +1 -1
  63. package/components/_cards/pagination.card.html +1 -1
  64. package/components/_cards/people.card.html +1 -1
  65. package/components/_cards/pipeline-node.card.html +1 -1
  66. package/components/_cards/pipeline-palette.card.html +1 -1
  67. package/components/_cards/radio.card.html +1 -1
  68. package/components/_cards/relative-time-picker.card.html +1 -1
  69. package/components/_cards/search.card.html +1 -1
  70. package/components/_cards/segmented-control.card.html +1 -1
  71. package/components/_cards/select.card.html +1 -1
  72. package/components/_cards/smart-table.card.html +1 -1
  73. package/components/_cards/spreadsheet.card.html +1 -1
  74. package/components/_cards/stepper.card.html +1 -1
  75. package/components/_cards/table.card.html +7 -2
  76. package/components/_cards/tabs.card.html +1 -1
  77. package/components/_cards/tag.card.html +1 -1
  78. package/components/_cards/textarea.card.html +1 -1
  79. package/components/_cards/timeline.card.html +1 -1
  80. package/components/_cards/toast.card.html +1 -1
  81. package/components/_cards/toggles.card.html +1 -1
  82. package/components/_cards/tooltip.card.html +1 -1
  83. package/components/_cards/tree.card.html +1 -1
  84. package/components/_shared/view-source.js +15 -185
  85. package/components/data/ListRow.d.ts +6 -3
  86. package/components/data/ListRow.jsx +21 -13
  87. package/components/data/ListRow.vue +25 -15
  88. package/components/data/Table.vue +6 -2
  89. package/components/feedback/ContextMenu.d.ts +3 -1
  90. package/components/feedback/ContextMenu.jsx +46 -8
  91. package/components/feedback/ContextMenu.vue +47 -7
  92. package/components/feedback/Menu.d.ts +5 -1
  93. package/components/feedback/Menu.jsx +6 -4
  94. package/components/feedback/Menu.vue +5 -3
  95. package/components/feedback/Timeline.vue +4 -1
  96. package/components/navigation/Tree.vue +6 -0
  97. package/index.html +6 -56
  98. package/package.json +1 -1
  99. package/tokens/typography.css +2 -1
@@ -2,24 +2,32 @@ import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
3
  import { IconTile } from "../display/Badge.jsx";
4
4
 
5
- /** Expandable workflow list row: chevron + bordered 32px icon tile + title/meta + right count. */
5
+ const T_TITLE = { fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", color: "var(--text-heading)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" };
6
+ const T_META = { fontFamily: "var(--font-sans)", fontWeight: "var(--weight-regular)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", letterSpacing: "var(--tracking-xs)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" };
7
+ const T_TRAIL = { fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", fontVariantNumeric: "tabular-nums", color: "var(--text-secondary)", whiteSpace: "nowrap", flexShrink: 0 };
8
+
9
+ /** Expandable workflow list row: chevron + 32px hairline icon tile + title / meta + right count.
10
+ * The chevron shows when the row has content to expand (children); the row is a button only when it has onClick —
11
+ * focusable, Enter / Space, aria-expanded, hover wash from tokens/hover.css [data-row]. A row with neither is static.
12
+ * Rows without a chevron keep its 16px slot, so tiles line up in a mixed list. Expanded content starts under the title. */
6
13
  export function ListRow({ icon = "file-text", title, meta, trailing, expanded, onClick, children, style }) {
7
- const [hover, setHover] = React.useState(false);
14
+ const hasContent = children != null && children !== false;
15
+ const clickable = typeof onClick === "function";
16
+ const onKey = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(e); } };
8
17
  return (
9
18
  <div style={{ borderBottom: "1px solid var(--border-card)", ...style }}>
10
- <div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
11
- style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", cursor: "pointer", background: hover ? "var(--bg-inset)" : "transparent", transition: "var(--t-hover)" }}>
12
- <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
13
- <span style={{ color: "var(--text-muted)", transform: expanded ? "rotate(90deg)" : "none", transition: "transform .15s ease", display: "inline-flex" }}><Icon name="chevron-right" size={16} /></span>
14
- <IconTile icon={icon} kind="row" />
15
- <div style={{ display: "flex", flexDirection: "column" }}>
16
- <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", color: "var(--text-heading)" }}>{title}</span>
17
- {meta ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-regular)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-secondary)" }}>{meta}</span> : null}
18
- </div>
19
+ <div data-row={clickable ? "" : undefined} role={clickable ? "button" : undefined} tabIndex={clickable ? 0 : undefined}
20
+ aria-expanded={clickable && hasContent ? !!expanded : undefined} onClick={clickable ? onClick : undefined} onKeyDown={clickable ? onKey : undefined}
21
+ style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px var(--edge-pad)", cursor: clickable ? "pointer" : "default", background: "var(--row-wash, transparent)", outlineOffset: -2, transition: "var(--t-hover)" }}>
22
+ <span aria-hidden="true" style={{ width: 16, flexShrink: 0, display: "inline-flex", color: "var(--text-muted)", transform: expanded ? "rotate(90deg)" : "none", transition: "transform var(--dur-fast) var(--ease-out)" }}>{hasContent ? <Icon name="chevron-right" size={16} /> : null}</span>
23
+ <IconTile icon={icon} kind="row" />
24
+ <div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
25
+ <span style={T_TITLE}>{title}</span>
26
+ {meta ? <span style={T_META}>{meta}</span> : null}
19
27
  </div>
20
- {trailing ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", fontVariantNumeric: "tabular-nums", color: "var(--text-secondary)" }}>{trailing}</span> : null}
28
+ {trailing != null && trailing !== "" ? <span style={T_TRAIL}>{trailing}</span> : null}
21
29
  </div>
22
- {expanded && children ? <div style={{ padding: "0 16px 12px 72px" }}>{children}</div> : null}
30
+ {expanded && hasContent ? <div style={{ padding: "0 var(--edge-pad) 12px calc(var(--edge-pad) + 72px)" }}>{children}</div> : null}
23
31
  </div>
24
32
  );
25
33
  }
@@ -1,25 +1,35 @@
1
1
  <script setup>
2
- import { ref } from 'vue'
2
+ import { computed, getCurrentInstance, useSlots } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
4
  import IconTile from '../display/IconTile.vue'
5
- // Expandable workflow list row: chevron + bordered 32px icon tile + title/meta + right count.
6
- defineProps({ icon: { type: String, default: 'file-text' }, title: String, meta: String, trailing: [String, Number], expanded: Boolean, style: Object })
7
- defineEmits(['click'])
8
- const hover = ref(false)
5
+ // Expandable workflow list row: chevron + 32px hairline icon tile + title / meta + right count.
6
+ // The chevron shows when the row has content to expand (default slot); the row is a button only when it has a @click
7
+ // listener — focusable, Enter / Space, aria-expanded, hover wash from tokens/hover.css [data-row]. A row with neither is static.
8
+ // Rows without a chevron keep its 16px slot, so tiles line up in a mixed list. Expanded content starts under the title.
9
+ const props = defineProps({ icon: { type: String, default: 'file-text' }, title: String, meta: String, trailing: [String, Number], expanded: Boolean, style: Object })
10
+ const emit = defineEmits(['click'])
11
+ const slots = useSlots()
12
+ const inst = getCurrentInstance()
13
+ const hasContent = computed(() => !!slots.default)
14
+ const clickable = computed(() => !!(inst && inst.vnode.props && inst.vnode.props.onClick))
15
+ function onKey(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); emit('click', e) } }
16
+ const T_TITLE = { fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', color: 'var(--text-heading)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
17
+ const T_META = { fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-regular)', fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }
18
+ const T_TRAIL = { fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', fontVariantNumeric: 'tabular-nums', color: 'var(--text-secondary)', whiteSpace: 'nowrap', flexShrink: 0 }
9
19
  </script>
10
20
  <template>
11
21
  <div :style="{ borderBottom: '1px solid var(--border-card)', ...style }">
12
- <div @click="$emit('click')" @mouseenter="hover = true" @mouseleave="hover = false" :style="{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', cursor: 'pointer', background: hover ? 'var(--bg-inset)' : 'transparent', transition: 'var(--t-hover)' }">
13
- <div style="display:flex;align-items:center;gap:12px">
14
- <span :style="{ color: 'var(--text-muted)', transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s ease', display: 'inline-flex' }"><Icon name="chevron-right" :size="16" /></span>
15
- <IconTile :icon="icon" kind="row" />
16
- <div style="display:flex;flex-direction:column">
17
- <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading)">{{ title }}</span>
18
- <span v-if="meta" style="font-family:var(--font-sans);font-weight:var(--weight-regular);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)">{{ meta }}</span>
19
- </div>
22
+ <div :data-row="clickable ? '' : undefined" :role="clickable ? 'button' : undefined" :tabindex="clickable ? 0 : undefined"
23
+ :aria-expanded="clickable && hasContent ? !!expanded : undefined" @click="clickable && emit('click', $event)" @keydown="clickable && onKey($event)"
24
+ :style="{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px var(--edge-pad)', cursor: clickable ? 'pointer' : 'default', background: 'var(--row-wash, transparent)', outlineOffset: '-2px', transition: 'var(--t-hover)' }">
25
+ <span aria-hidden="true" :style="{ width: '16px', flexShrink: 0, display: 'inline-flex', color: 'var(--text-muted)', transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }"><Icon v-if="hasContent" name="chevron-right" :size="16" /></span>
26
+ <IconTile :icon="icon" kind="row" />
27
+ <div style="display:flex;flex-direction:column;min-width:0;flex:1">
28
+ <span :style="T_TITLE">{{ title }}</span>
29
+ <span v-if="meta" :style="T_META">{{ meta }}</span>
20
30
  </div>
21
- <span v-if="trailing" style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-variant-numeric:tabular-nums;color:var(--text-secondary)">{{ trailing }}</span>
31
+ <span v-if="trailing != null && trailing !== ''" :style="T_TRAIL">{{ trailing }}</span>
22
32
  </div>
23
- <div v-if="expanded" style="padding:0 16px 12px 72px"><slot /></div>
33
+ <div v-if="expanded && hasContent" style="padding:0 var(--edge-pad) 12px calc(var(--edge-pad) + 72px)"><slot /></div>
24
34
  </div>
25
35
  </template>
@@ -5,7 +5,11 @@ 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
- // Keybricks table. columns: [{key,label,width,minWidth,mono,code,twoLine,align,render}].
8
+ // 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
+ const Cell = (p) => p.v
10
+ Cell.props = ['v']
11
+ // Keybricks table. columns: [{key,label,width,minWidth,mono,code,twoLine,align,render}]. Custom cells: <template #cell-status="{ value, row, index }">…</template>,
12
+ // or render(value, row, index) returning text or a VNode (h()).
9
13
  // twoLine — the name column of a document list: icon tile, title over a description, optional mark ("favorite" = filled star), trailing ⋯
10
14
  // (@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).
11
15
  // tree — rows nest through `children` (or `hasChildren` for rows you load on demand); the twoLine column indents 24px per level with an
@@ -184,7 +188,7 @@ function cellPad(i) { return { ...(i === 0 && props.selectable ? { position: 're
184
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>
185
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)' }">
186
190
  <template v-for="{ r, ri } in [item]" :key="'r'">
187
- <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)">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</span><span v-else :style="T_TITLE">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</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">{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</code><template v-else>{{ c.render ? c.render(r[c.key], r, ri) : r[c.key] }}</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 .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>
188
192
  </template>
189
193
  </tr>
190
194
  </template>
@@ -2,8 +2,10 @@ import * as React from "react";
2
2
  /** Floating context menu — r10, soft shadow, control-height rows at the region's tier (32px / 14px at md), red danger row.
3
3
  * Action menus keep rows flush; a single-choice menu (any item `selected`) spaces rows 2px apart. Dividers keep 4px above and below. */
4
4
  export interface ContextMenuProps {
5
- items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
5
+ items?: { label?: string; icon?: string; /** Colour for this item's icon — any CSS colour, e.g. "var(--cat-join)". Omit for a single-colour icon in the row's text colour. */ iconColor?: string; /** Shown in a 300px panel beside the menu while the item is hovered or focused: the item's icon and label, then this text. */ description?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
6
6
  onSelect?: (label: string) => void;
7
+ /** Side the description panel opens on (default "right"); it flips to the other side when the viewport has no room. */
8
+ descriptionSide?: "left" | "right";
7
9
  /** Size tier of the rows. Omit to inherit the tier of the control that opened the menu (the default). */
8
10
  size?: "sm" | "md" | "lg";
9
11
  style?: React.CSSProperties;
@@ -1,22 +1,60 @@
1
1
  import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
3
 
4
- /** Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
4
+ const HINT_W = 300, HINT_GAP = 8;
5
+
6
+ /** Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, iconColor, description, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
7
  * selected: true marks the current choice of a single-choice menu (section switcher, language picker): --bg-hover fill, --weight-medium
6
8
  * --text-heading, no checkmark. A menu with any selected item spaces its rows 2px apart (like Sidebar / Tree / DocTree) so a hovered
7
- * fill never touches the selected one; an action menu (nothing selected) keeps its rows flush. Dividers keep 4px above and below. */
8
- export function ContextMenu({ items = [], onSelect, size, style }) {
9
+ * fill never touches the selected one; an action menu (nothing selected) keeps its rows flush. Dividers keep 4px above and below.
10
+ * iconColor: any CSS colour for that item's icon (e.g. "var(--cat-join)"); without it the icon takes the row's text colour.
11
+ * description: hovering or focusing the item shows a 300px panel beside the menu — the item's icon and label, then the description.
12
+ * It opens on descriptionSide ("right" by default) and flips to the other side when the viewport has no room. */
13
+ export function ContextMenu({ items = [], onSelect, size, descriptionSide = "right", style }) {
9
14
  const [hover, setHover] = React.useState(-1);
15
+ const [hint, setHint] = React.useState(-1);
16
+ const [side, setSide] = React.useState(descriptionSide);
17
+ const wrap = React.useRef(null);
18
+ const hintId = React.useId();
10
19
  const choice = items.some((it) => it && it.selected);
11
- return (
12
- <div role="menu" data-size={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 }}>
20
+ const hasHint = items.some((it) => it && it.description);
21
+ const show = (i) => {
22
+ setHover(i);
23
+ if (!hasHint) return;
24
+ const it = items[i];
25
+ if (!it || !it.description) { setHint(-1); return; }
26
+ const r = wrap.current && wrap.current.getBoundingClientRect();
27
+ let s = descriptionSide;
28
+ if (r) { if (s === "right" && r.right + HINT_GAP + HINT_W > window.innerWidth && r.left - HINT_GAP - HINT_W >= 0) s = "left"; else if (s === "left" && r.left - HINT_GAP - HINT_W < 0 && r.right + HINT_GAP + HINT_W <= window.innerWidth) s = "right"; }
29
+ setSide(s); setHint(i);
30
+ };
31
+ const onBlur = (e) => { setHover(-1); if (hasHint && !(wrap.current && wrap.current.contains(e.relatedTarget))) setHint(-1); };
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 }}>
13
34
  {items.map((it, i) => it.divider ? <div key={i} role="separator" style={{ height: 1, flexShrink: 0, background: "var(--border-subtle)", margin: "4px 0" }} /> : (
14
35
  <button key={i} type="button" onClick={() => onSelect && onSelect(it.label)}
15
- onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(-1)}
16
- 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 ? "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" }}>
17
- {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)" }} /> : null}{it.label}
36
+ onMouseEnter={() => show(i)} onMouseLeave={() => setHover(-1)} onFocus={() => show(i)} onBlur={onBlur}
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}
18
40
  </button>
19
41
  ))}
20
42
  </div>
21
43
  );
44
+ if (!hasHint) return menu;
45
+ const h = items[hint];
46
+ return (
47
+ <div ref={wrap} data-size={size} onMouseLeave={() => setHint(-1)} style={{ position: "relative", display: "inline-flex" }}>
48
+ {menu}
49
+ {h && h.description ? (
50
+ <div id={hintId} role="tooltip" style={{ position: "absolute", top: 0, [side === "left" ? "right" : "left"]: `calc(100% + ${HINT_GAP}px)`, width: HINT_W, boxSizing: "border-box", padding: "12px 14px", display: "flex", flexDirection: "column", gap: 6, 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)" }}>
51
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
52
+ {h.icon ? <Icon name={h.icon} size={16} style={{ flexShrink: 0, color: h.iconColor || (h.danger ? "var(--danger)" : "var(--text-heading)") }} /> : null}
53
+ <span style={{ fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", fontWeight: "var(--weight-medium)", color: "var(--text-heading)" }}>{h.label}</span>
54
+ </div>
55
+ <span style={{ fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", letterSpacing: "var(--tracking-xs)", color: "var(--text-secondary)", textWrap: "pretty" }}>{h.description}</span>
56
+ </div>
57
+ ) : null}
58
+ </div>
59
+ );
22
60
  }
@@ -1,21 +1,61 @@
1
1
  <script setup>
2
2
  import { ref, computed } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
- // Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
4
+ // Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, iconColor, description, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
5
  // selected: true marks the current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. A menu with any
6
6
  // selected item spaces its rows 2px apart (like Sidebar / Tree / DocTree); an action menu keeps its rows flush. Dividers keep 4px above and below.
7
- const props = defineProps({ items: { type: Array, default: () => [] }, size: String, style: Object })
7
+ // iconColor: any CSS colour for that item's icon; without it the icon takes the row's text colour.
8
+ // description: hovering or focusing the item shows a 300px panel beside the menu (icon + label, then the description), on descriptionSide
9
+ // ("right" by default), flipping to the other side when the viewport has no room.
10
+ const props = defineProps({ items: { type: Array, default: () => [] }, size: String, descriptionSide: { type: String, default: 'right' }, style: Object })
8
11
  const choice = computed(() => props.items.some(it => it && it.selected))
12
+ const hasHint = computed(() => props.items.some(it => it && it.description))
9
13
  const emit = defineEmits(['select'])
10
- const hover = ref(-1)
14
+ const hover = ref(-1), hint = ref(-1), side = ref(props.descriptionSide), wrap = ref(null)
15
+ const HINT_W = 300, HINT_GAP = 8
16
+ const hintId = 'kb-cm-hint-' + Math.random().toString(36).slice(2, 8)
17
+ function show(i) {
18
+ hover.value = i
19
+ if (!hasHint.value) return
20
+ const it = props.items[i]
21
+ if (!it || !it.description) { hint.value = -1; return }
22
+ const r = wrap.value && wrap.value.getBoundingClientRect()
23
+ let s = props.descriptionSide
24
+ if (r) { if (s === 'right' && r.right + HINT_GAP + HINT_W > window.innerWidth && r.left - HINT_GAP - HINT_W >= 0) s = 'left'; else if (s === 'left' && r.left - HINT_GAP - HINT_W < 0 && r.right + HINT_GAP + HINT_W <= window.innerWidth) s = 'right' }
25
+ side.value = s; hint.value = i
26
+ }
27
+ function onBlur(e) { hover.value = -1; if (hasHint.value && !(wrap.value && wrap.value.contains(e.relatedTarget))) hint.value = -1 }
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' })
31
+ const iconStyle = (it) => ({ width: 'var(--control-icon)', height: 'var(--control-icon)', flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) })
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)' }))
11
33
  </script>
12
34
  <template>
13
- <div role="menu" :data-size="size" :style="{ 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 ? '2px' : 0, ...style }">
35
+ <div v-if="hasHint" ref="wrap" :data-size="size" @mouseleave="hint = -1" :style="{ position: 'relative', display: 'inline-flex' }">
36
+ <div role="menu" :style="menuStyle">
37
+ <template v-for="(it, i) in items" :key="i">
38
+ <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
39
+ <button v-else type="button" @click="emit('select', it.label)" @mouseenter="show(i)" @mouseleave="hover = -1" @focus="show(i)" @blur="onBlur"
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 }}
42
+ </button>
43
+ </template>
44
+ </div>
45
+ <div v-if="cur && cur.description" :id="hintId" role="tooltip" :style="hintStyle">
46
+ <div :style="{ display: 'flex', alignItems: 'center', gap: '8px' }">
47
+ <Icon v-if="cur.icon" :name="cur.icon" :size="16" :style="{ flexShrink: 0, color: cur.iconColor || (cur.danger ? 'var(--danger)' : 'var(--text-heading)') }" />
48
+ <span :style="{ fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-heading)' }">{{ cur.label }}</span>
49
+ </div>
50
+ <span :style="{ fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)', textWrap: 'pretty' }">{{ cur.description }}</span>
51
+ </div>
52
+ </div>
53
+ <div v-else role="menu" :data-size="size" :style="menuStyle">
14
54
  <template v-for="(it, i) in items" :key="i">
15
55
  <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
16
- <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1"
17
- :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="{ 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 === 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' }">
18
- <Icon v-if="it.icon" :name="it.icon" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)' }" />{{ it.label }}
56
+ <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1" @focus="hover = i" @blur="hover = -1"
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 }}
19
59
  </button>
20
60
  </template>
21
61
  </div>
@@ -2,9 +2,13 @@ import * as React from "react";
2
2
  /** Trigger-anchored dropdown menu (reuses ContextMenu's look). Addition — for "Show ⌄"-style buttons and user menus. */
3
3
  export interface MenuProps {
4
4
  trigger: React.ReactNode;
5
- items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
5
+ items?: { label?: string; icon?: string; /** Colour for this item's icon — any CSS colour, e.g. "var(--cat-join)". Omit for a single-colour icon in the row's text colour. */ iconColor?: string; /** Shown in a 300px panel beside the menu while the item is hovered or focused: the item's icon and label, then this text. */ description?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
6
6
  onSelect?: (label: string) => void;
7
7
  align?: "start" | "end";
8
+ /** Side for item descriptions. Default: away from the trigger's edge — "left" when align="end", "right" when align="start". Flips if the viewport has no room. */
9
+ descriptionSide?: "left" | "right";
10
+ /** Start open (docs and demos). */
11
+ defaultOpen?: boolean;
8
12
  style?: React.CSSProperties;
9
13
  }
10
14
  export declare function Menu(props: MenuProps): JSX.Element;
@@ -1,9 +1,11 @@
1
1
  import React from "react";
2
2
  import { ContextMenu } from "./ContextMenu.jsx";
3
3
 
4
- /** Click-to-open dropdown menu anchored under its trigger (vs. ContextMenu, which opens at a click point). Closes on outside click. */
5
- export function Menu({ trigger, items = [], onSelect, align = "start", style }) {
6
- const [open, setOpen] = React.useState(false);
4
+ /** Click-to-open dropdown menu anchored under its trigger (vs. ContextMenu, which opens at a click point). Closes on outside click.
5
+ * Items take ContextMenu's fields (icon, iconColor, description, …). Item descriptions open on the side away from the trigger's edge:
6
+ * align="end" (trigger at the right) → left, align="start" (trigger at the left) → right; descriptionSide overrides. */
7
+ export function Menu({ trigger, items = [], onSelect, align = "start", descriptionSide, defaultOpen = false, style }) {
8
+ const [open, setOpen] = React.useState(defaultOpen);
7
9
  const ref = React.useRef(null);
8
10
  React.useEffect(() => {
9
11
  if (!open) return;
@@ -16,7 +18,7 @@ export function Menu({ trigger, items = [], onSelect, align = "start", style })
16
18
  <span onClick={() => setOpen((o) => !o)}>{trigger}</span>
17
19
  {open ? (
18
20
  <div style={{ position: "absolute", top: "calc(100% + 6px)", [align === "end" ? "right" : "left"]: 0, zIndex: "var(--z-menu)" }}>
19
- <ContextMenu items={items} onSelect={(l) => { setOpen(false); onSelect && onSelect(l); }} />
21
+ <ContextMenu items={items} descriptionSide={descriptionSide || (align === "end" ? "left" : "right")} onSelect={(l) => { setOpen(false); onSelect && onSelect(l); }} />
20
22
  </div>
21
23
  ) : null}
22
24
  </div>
@@ -2,9 +2,11 @@
2
2
  import { ref, onMounted, onUnmounted } from 'vue'
3
3
  import ContextMenu from './ContextMenu.vue'
4
4
  // Click-to-open dropdown menu anchored under its trigger (vs. ContextMenu, which opens at a click point). Closes on outside click.
5
- const props = defineProps({ items: { type: Array, default: () => [] }, align: { type: String, default: 'start' }, style: Object })
5
+ // Items take ContextMenu's fields (icon, iconColor, description, …). Descriptions open on the side away from the trigger's edge:
6
+ // align="end" → left, align="start" → right; descriptionSide overrides.
7
+ const props = defineProps({ items: { type: Array, default: () => [] }, align: { type: String, default: 'start' }, descriptionSide: String, defaultOpen: Boolean, style: Object })
6
8
  const emit = defineEmits(['select'])
7
- const open = ref(false)
9
+ const open = ref(props.defaultOpen)
8
10
  const root = ref(null)
9
11
  function onDoc(e) { if (root.value && !root.value.contains(e.target)) open.value = false }
10
12
  onMounted(() => document.addEventListener('mousedown', onDoc))
@@ -15,7 +17,7 @@ function select(l) { open.value = false; emit('select', l) }
15
17
  <div ref="root" :style="{ position: 'relative', display: 'inline-flex', ...style }">
16
18
  <span @click="open = !open"><slot name="trigger" /></span>
17
19
  <div v-if="open" :style="{ position: 'absolute', top: 'calc(100% + 6px)', [align === 'end' ? 'right' : 'left']: 0, zIndex: 'var(--z-menu)' }">
18
- <ContextMenu :items="items" @select="select" />
20
+ <ContextMenu :items="items" :description-side="descriptionSide || (align === 'end' ? 'left' : 'right')" @select="select" />
19
21
  </div>
20
22
  </div>
21
23
  </template>
@@ -3,6 +3,9 @@ import Icon from '../icons/Icon.vue'
3
3
  const tlTones = { success: 'var(--success)', danger: 'var(--danger)', warning: 'var(--warning)', info: 'var(--action-primary)', neutral: 'var(--dot-neutral)' }
4
4
  // Vertical activity timeline: outlined icon nodes on a hairline spine. items: [{icon,tone,title,meta,body}].
5
5
  defineProps({ items: { type: Array, default: () => [] }, style: Object })
6
+ // body may be text or a VNode (h(...)); a #body="{ item, index }" slot overrides it.
7
+ const Cell = (p) => p.v
8
+ Cell.props = ['v']
6
9
  </script>
7
10
  <template>
8
11
  <div :style="{ display: 'flex', flexDirection: 'column', ...style }">
@@ -14,7 +17,7 @@ defineProps({ items: { type: Array, default: () => [] }, style: Object })
14
17
  <div :style="{ display: 'flex', flexDirection: 'column', gap: '2px', paddingBottom: i < items.length - 1 ? '18px' : 0, minWidth: 0, paddingTop: '3px' }">
15
18
  <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading)">{{ it.title }}</span>
16
19
  <span v-if="it.meta" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);letter-spacing:var(--tracking-xs);color:var(--text-muted)">{{ it.meta }}</span>
17
- <span v-if="it.body" style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);color:var(--text-body);padding-top:4px">{{ it.body }}</span>
20
+ <span v-if="it.body" style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);color:var(--text-body);padding-top:4px"><slot name="body" :item="it" :index="i"><Cell :v="it.body" /></slot></span>
18
21
  </div>
19
22
  </div>
20
23
  </div>
@@ -34,6 +34,8 @@ const props = defineProps({
34
34
  loadData: Function, rowActions: Function, style: Object,
35
35
  })
36
36
  const emit = defineEmits(['select', 'check', 'expand', 'drop'])
37
+ const Cell = (p) => p.v
38
+ Cell.props = ['v']
37
39
 
38
40
  const data = ref(JSON.parse(JSON.stringify(props.treeData || props.items)))
39
41
  // Compare by value: re-seeding on identity alone would wipe async-loaded children.
@@ -263,6 +265,8 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
263
265
  </span>
264
266
  <span v-if="row.n.tag" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:1;padding:2px 5px;border-radius:var(--radius-sm);background:var(--bg-inset);color:var(--text-secondary);flex-shrink:0">{{ row.n.tag }}</span>
265
267
  <span v-if="row.n.count != null" style="margin-left:auto;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-muted);padding-left:8px">{{ row.n.count }}</span>
268
+ <!-- row actions: #row-actions="{ node }" slot, or rowActions(node) returning a VNode; shown on hover / focus -->
269
+ <span v-if="(rowActions || $slots['row-actions']) && !row.n.disabled" class="kb-tree-actions" @click.stop :style="{ marginLeft: row.n.count != null ? '6px' : 'auto', display: 'flex', gap: '2px', flexShrink: 0 }"><slot name="row-actions" :node="row.n"><Cell v-if="rowActions" :v="rowActions(row.n)" /></slot></span>
266
270
  </div>
267
271
  <div v-if="!rows.length" style="padding:10px 8px;font-family:var(--font-sans);font-size:var(--text-base);color:var(--text-muted)">No matching items</div>
268
272
  </div>
@@ -270,6 +274,8 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
270
274
  <style scoped>
271
275
  .kb-tree-row:not(.is-disabled):hover{background:var(--bg-hover)}
272
276
  .kb-tree-row:focus-visible{outline:2px solid var(--focus-border);outline-offset:-2px}
277
+ .kb-tree-actions{opacity:0;transition:var(--t-hover)}
278
+ .kb-tree-row:hover .kb-tree-actions,.kb-tree-row:focus-within .kb-tree-actions{opacity:1}
273
279
  .kb-tree-spin{animation:kb-tree-spin 900ms linear infinite}
274
280
  @keyframes kb-tree-spin{to{transform:rotate(360deg)}}
275
281
  </style>
package/index.html CHANGED
@@ -59,22 +59,6 @@ section{margin-bottom:52px;scroll-margin-top:78px}
59
59
  .frame-wrap{position:relative;border:1px solid var(--border-card);border-radius:var(--radius-lg);background:var(--bg-surface);overflow:hidden}
60
60
  .frame-wrap iframe{border:0;display:block;transform-origin:top left;background:var(--bg-surface)}
61
61
  .frame-ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-muted);background:var(--bg-inset);letter-spacing:var(--tracking-xs)}
62
- .code-area{margin-top:14px}
63
- .code-btn{display:inline-flex;align-items:center;gap:6px;height:28px;box-sizing:border-box;padding:0 8px;line-height:var(--leading-xs);border-radius:var(--radius-md);border:1px solid var(--border-control);background:var(--bg-surface);color:var(--text-secondary);font-size:var(--text-xs);font-family:var(--font-sans);cursor:pointer;transition:var(--t-hover);letter-spacing:var(--tracking-xs)}
64
- .code-btn:hover{background:var(--bg-hover);color:var(--text-heading)}
65
- .code-panel{display:none;margin-top:10px;border:1px solid var(--border-card);border-radius:var(--radius-lg);overflow:hidden;background:var(--bg-inset)}
66
- .code-panel.open{display:block}
67
- .code-head{display:flex;align-items:center;justify-content:space-between;background:var(--bg-surface);border-bottom:1px solid var(--border-subtle)}
68
- .code-tabs{display:flex;flex:1;overflow-x:auto;scrollbar-width:none}
69
- .code-tabs::-webkit-scrollbar{display:none}
70
- .code-tab{padding:8px 12px;border:none;background:transparent;font-size:var(--text-xs);font-family:var(--font-sans);color:var(--text-secondary);cursor:pointer;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:var(--tracking-xs)}
71
- .code-tab.on{color:var(--text-heading);border-bottom-color:var(--action-primary)}
72
- .code-fw{display:flex;gap:3px;padding:6px 8px;flex-shrink:0}
73
- .code-fw{display:flex;align-items:center;flex-shrink:0;height:28px;box-sizing:border-box;gap:2px;padding:2px;margin:0 8px;border-radius:var(--radius-md);background:var(--bg-inset);border:1px solid var(--border-control)}.fw-btn{display:inline-flex;align-items:center;height:22px;padding:0 6px;font-size:var(--text-xs);line-height:var(--leading-xs);border-radius:var(--radius-sm);border:none;background:transparent;color:var(--text-secondary);font-family:var(--font-sans);font-weight:var(--weight-medium);white-space:nowrap;cursor:pointer;transition:var(--t-hover);letter-spacing:var(--tracking-xs)}.fw-btn:not(.on):hover{color:var(--text-heading)}
74
- .fw-btn.on{background:var(--bg-surface);color:var(--text-heading);box-shadow:0 1px 2px rgba(0,0,0,.08)}
75
- .code-body{position:relative}
76
- .code-body pre{margin:0;padding:14px 70px 14px 16px;overflow:auto;max-height:420px;font-family:var(--font-mono);font-size:var(--text-code);line-height:var(--leading-code);color:var(--text-body);white-space:pre}
77
- .copy-btn{position:absolute;top:8px;right:10px;display:inline-flex;align-items:center;height:28px;box-sizing:border-box;padding:0 8px;border-radius:var(--radius-md);border:1px solid var(--border-control);background:var(--bg-surface);color:var(--text-secondary);font-size:var(--text-xs);line-height:var(--leading-xs);font-family:var(--font-sans);cursor:pointer;letter-spacing:var(--tracking-xs)}
78
62
 
79
63
  /* overview */
80
64
  #overview{margin-bottom:64px}
@@ -192,7 +176,7 @@ section{margin-bottom:52px;scroll-margin-top:78px}
192
176
  const GROUPS=[["Brand","品牌","ブランド"],["Colors","颜色","カラー"],["Type","字体排印","タイポグラフィ"],["Foundations","基础规范","基盤仕様"],["Components","组件","コンポーネント"],["Charts","图表","チャート"],["Icons","图标库","アイコン"]];
193
177
 
194
178
  const S={
195
- brandsub:["Design System · v2.4.12","设计系统 · v2.4.12","デザインシステム · v2.4.12"],
179
+ brandsub:["Design System · v2.5.0","设计系统 · v2.5.0","デザインシステム · v2.5.0"],
196
180
  doc_readme:["Readme","说明文档","ドキュメント"],
197
181
  doc_design:["DESIGN.md","下载 DESIGN.md","DESIGN.md"],
198
182
  doc_integration:["Integration","接入指南","導入ガイド"],
@@ -258,6 +242,7 @@ function setLang(l){
258
242
  document.getElementById("filter").placeholder=pick(S.filter_ph);
259
243
  document.querySelectorAll('a[data-readme]').forEach(a=>{a.href=(l==="zh"||l==="both")?"readme.zh-CN.md":"readme.md";});
260
244
  if(typeof fitChangelogTags==="function")fitChangelogTags();
245
+ syncFrames();
261
246
  }
262
247
 
263
248
  /* ---------- overview ---------- */
@@ -451,6 +436,8 @@ function renderNav(){
451
436
  let followSystem=true;
452
437
  const themeOf=()=>html.getAttribute("data-theme")||"light";
453
438
  function pushTheme(f){
439
+ /* Cards also need the site language: the "Show code" bars under each example follow it. */
440
+ try{f.contentWindow&&f.contentWindow.postMessage({kb:"lang",lang},"*");}catch(e){}
454
441
  if(f.dataset.alwaysdark==="1"||f.dataset.selfmanaged==="1")return;
455
442
  const t=themeOf();
456
443
  try{f.contentWindow&&f.contentWindow.postMessage({kb:"theme",theme:t},"*");}catch(e){}
@@ -473,49 +460,12 @@ if(mq&&mq.addEventListener)mq.addEventListener("change",e=>{if(followSystem)setT
473
460
  document.getElementById("themeToggle").addEventListener("click",()=>setTheme(themeOf()==="dark"?"light":"dark",true));
474
461
 
475
462
  /* ---------- frames: lazy load, fit to width, grow with content ---------- */
476
- /* Cards serve their source over postMessage (works from file:// too, where the host
477
- cannot read the frame's DOM); the View Code UI is rendered here, below the box. */
478
- const CODE_ICON='<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>';
479
- function buildCode(f,files){
480
- if(f.__code||!files||!files.length)return;
481
- const area=document.createElement("div");area.className="code-area";
482
- const btn=document.createElement("button");btn.className="code-btn";
483
- btn.innerHTML=CODE_ICON+'<span>View Code</span>';
484
- const panel=document.createElement("div");panel.className="code-panel";
485
- const head=document.createElement("div");head.className="code-head";
486
- const tabs=document.createElement("div");tabs.className="code-tabs";
487
- const fwb=document.createElement("div");fwb.className="code-fw";
488
- head.appendChild(tabs);head.appendChild(fwb);
489
- const body=document.createElement("div");body.className="code-body";
490
- const pre=document.createElement("pre");
491
- const copy=document.createElement("button");copy.className="copy-btn";copy.textContent="Copy";
492
- body.appendChild(pre);body.appendChild(copy);
493
- panel.appendChild(head);panel.appendChild(body);
494
- area.appendChild(btn);area.appendChild(panel);
495
- f.__section.appendChild(area);
496
- let framework="react",active=0,open=false;const cache={};
497
- files.forEach((t,i)=>{const b=document.createElement("button");b.className="code-tab";b.textContent=t.name;b.onclick=()=>{active=i;render();};tabs.appendChild(b);});
498
- [["react","React"],["vue","Vue 3"]].forEach(([k,lab])=>{const b=document.createElement("button");b.className="fw-btn";b.textContent=lab;b.onclick=()=>{framework=k;render();};fwb.appendChild(b);});
499
- const hidden=(t)=>(framework==="vue"&&t.reactOnly)||(framework==="react"&&t.vueOnly);
500
- function render(){
501
- if(hidden(files[active])){const k=files.findIndex(t=>!hidden(t));if(k>=0)active=k;}
502
- [...tabs.children].forEach((el,i)=>{el.classList.toggle("on",i===active);el.style.display=hidden(files[i])?"none":"";el.textContent=framework==="vue"?(files[i].vueName||files[i].name.replace(/\.jsx$/,".vue")):files[i].name;});
503
- [...fwb.children].forEach((el,i)=>el.classList.toggle("on",i===(framework==="react"?0:1)));
504
- const key=framework+"|"+files[active].path;
505
- if(cache[key]!=null)pre.textContent=cache[key];
506
- else{pre.textContent="Loading…";try{f.contentWindow.postMessage({kb:"src-get",path:files[active].path,framework},"*");}catch(e){}}
507
- }
508
- copy.onclick=()=>{const t=pre.textContent;try{navigator.clipboard.writeText(t);}catch(e){}copy.textContent="Copied";setTimeout(()=>{copy.textContent="Copy";},1200);};
509
- btn.onclick=()=>{open=!open;panel.classList.toggle("open",open);btn.querySelector("span").textContent=open?"Hide Code":"View Code";if(open)render();};
510
- f.__code={receive(m){cache[m.framework+"|"+m.path]=m.text;if(open&&files[active].path===m.path&&framework===m.framework)pre.textContent=m.text;}};
511
- }
463
+ /* Cards ask for the site theme over postMessage (works from file:// too). Usage code ("Show code") lives inside each card. */
512
464
  addEventListener("message",e=>{
513
465
  const m=e.data;if(!m||!m.kb)return;
514
466
  const f=[...document.querySelectorAll("iframe")].find(x=>{try{return x.contentWindow===e.source;}catch(err){return false;}});
515
467
  if(!f)return;
516
- if(m.kb==="src-manifest")buildCode(f,m.files);
517
- else if(m.kb==="src-text"&&f.__code)f.__code.receive(m);
518
- else if(m.kb==="theme-ask")pushTheme(f);
468
+ if(m.kb==="theme-ask")pushTheme(f);
519
469
  });
520
470
  function fit(f){
521
471
  const wrap=f.__wrap,avail=wrap.clientWidth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyfunc/keybricks-ds",
3
- "version": "2.4.12",
3
+ "version": "2.5.0",
4
4
  "description": "Keybricks design system — design tokens, three size tiers and 121 UI components in React and Vue 3, source-only.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -39,7 +39,8 @@
39
39
  [lang|="zh"]{--font-sans:'Inter','PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
40
40
  [lang|="ja"]{--font-sans:'Inter','Hiragino Kaku Gothic ProN','Hiragino Sans','Yu Gothic UI','Meiryo','Noto Sans JP',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
41
41
  [lang|="ko"]{--font-sans:'Inter','Apple SD Gothic Neo','Malgun Gothic','Noto Sans KR',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
42
- :lang(zh),:lang(ja),:lang(ko){font-family:var(--font-sans)}
42
+ /* Set the font only where a language is declared; plain text inherits it, so descendant icon and code fonts keep their own family (e.g. AG Grid selection icons). */
43
+ :where([lang|="zh"],[lang|="ja"],[lang|="ko"]){font-family:var(--font-sans)}
43
44
  [lang|="zh"],[lang|="ja"],[lang|="ko"]{--weight-medium:500;--weight-semibold:500;
44
45
  --text-glyph-min:12px;--text-2xs:12px;--text-xs:13px;--leading-xs:18px;--text-sm:13px;--leading-sm:18px;
45
46
  --tracking-2xs:0;--tracking-xs:0;--tracking-sm:0;--tracking-base:0;--tracking-md:0;--tracking-lg:0;--tracking-xl:0;--tracking-num:0;--tracking-2xl:0;--tracking-3xl:0;--tracking-4xl:0;--tracking-display:0}