@keyfunc/keybricks-ds 2.5.0 → 2.5.1

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 (60) hide show
  1. package/DEPLOY.md +3 -3
  2. package/INTEGRATION.md +12 -1
  3. package/_adherence.oxlintrc.json +10 -4
  4. package/_ds_bundle.js +20559 -0
  5. package/_src/icon-index.js +1 -0
  6. package/_src/unicons-index.js +1 -0
  7. package/_src/usage-code.js +737 -0
  8. package/changelog.js +7 -0
  9. package/components/charts/BarList.jsx +1 -1
  10. package/components/charts/BarList.vue +8 -3
  11. package/components/charts/Ribbon3D.d.ts +2 -0
  12. package/components/charts/Scatter3D.d.ts +4 -0
  13. package/components/charts/SunburstChart.jsx +6 -1
  14. package/components/charts/SunburstChart.vue +7 -1
  15. package/components/charts/Surface3D.d.ts +2 -0
  16. package/components/charts/WorldMap.jsx +6 -1
  17. package/components/charts/WorldMap.vue +7 -1
  18. package/components/data/Table.jsx +1 -1
  19. package/components/data/Table.vue +1 -1
  20. package/components/display/ProgressBar.jsx +1 -1
  21. package/components/display/ProgressBar.vue +1 -1
  22. package/components/feedback/Accordion.jsx +1 -1
  23. package/components/feedback/Accordion.vue +1 -1
  24. package/components/feedback/ContextMenu.jsx +3 -3
  25. package/components/feedback/ContextMenu.vue +4 -4
  26. package/components/feedback/Drawer.jsx +3 -3
  27. package/components/feedback/Drawer.vue +3 -3
  28. package/components/forms/Checkbox.d.ts +2 -0
  29. package/components/forms/Checkbox.jsx +9 -4
  30. package/components/forms/Checkbox.vue +6 -2
  31. package/components/forms/DatePicker.jsx +3 -1
  32. package/components/forms/DatePicker.vue +2 -1
  33. package/components/forms/Dropzone.jsx +1 -1
  34. package/components/forms/Dropzone.vue +1 -1
  35. package/components/forms/MultiSelect.jsx +5 -3
  36. package/components/forms/MultiSelect.vue +4 -3
  37. package/components/forms/Slider.jsx +1 -1
  38. package/components/forms/Slider.vue +1 -1
  39. package/components/forms/Switch.jsx +4 -4
  40. package/components/forms/Switch.vue +4 -4
  41. package/components/navigation/Sidebar.jsx +2 -2
  42. package/components/navigation/Sidebar.vue +2 -2
  43. package/components/navigation/TopBar.jsx +1 -1
  44. package/components/navigation/TopBar.vue +1 -1
  45. package/components/navigation/Tree.jsx +2 -2
  46. package/components/navigation/Tree.vue +8 -5
  47. package/components/pipeline/DataPreview.jsx +1 -1
  48. package/components/pipeline/DataPreview.vue +1 -1
  49. package/components/pipeline/NodeCard.jsx +1 -1
  50. package/components/pipeline/NodeCard.vue +1 -1
  51. package/components/pipeline/PaletteItem.vue +1 -1
  52. package/components/pipeline/PalettePanel.jsx +3 -3
  53. package/components/pipeline/PalettePanel.vue +2 -2
  54. package/components/tabs/SegmentedControl.jsx +2 -2
  55. package/components/tabs/SegmentedControl.vue +2 -2
  56. package/index.html +1 -1
  57. package/package.json +4 -2
  58. package/tokens/dark.css +1 -1
  59. package/tokens/interaction.css +4 -0
  60. package/tokens/layout.css +3 -0
package/changelog.js CHANGED
@@ -3,6 +3,13 @@
3
3
  Each item: tag (new | improved | fixed | breaking | removed) and the text as [en, zh, ja].
4
4
  INTEGRATION.md keeps the full migration notes; this list is the short, reader-facing summary. */
5
5
  window.KB_CHANGELOG = [
6
+ { v: "2.5.1", date: "2026-10-30", items: [
7
+ { tag: "fixed", t: ["Keyboard support: Checkbox, DatePicker, MultiSelect, Accordion, Dropzone, pipeline nodes and palette, selectable bar lists, and the Sunburst / World map charts can all be used without a mouse.", "键盘支持:复选框、日期选择、多选、折叠面板、上传区、管道节点与节点面板、可点选的条形列表,以及旭日图 / 世界地图,都可以不用鼠标操作。", "キーボード対応:チェックボックス、日付選択、マルチセレクト、アコーディオン、ドロップゾーン、パイプラインのノードとパレット、選択可能なバーリスト、サンバースト / 世界地図をマウスなしで操作可能に。"] },
8
+ { tag: "fixed", t: ["Long text no longer spills: field values, menu items, segments, tree rows and navigation names truncate; drawer titles wrap.", "长文字不再溢出:输入框的值、菜单项、分段选择、树形列表和导航名称会截断;侧滑面板标题会换行。", "長いテキストがはみ出さないように:入力値、メニュー項目、セグメント、ツリー行、ナビゲーション名は省略表示、ドロワーのタイトルは折り返し。"] },
9
+ { tag: "fixed", t: ["Vue: Tree right-click menu event and BarList row selection, matching React.", "Vue:树形列表支持右键菜单事件,条形列表支持点选,与 React 一致。", "Vue:Tree の右クリックメニューイベントと BarList の行選択を React と同様に追加。"] },
10
+ { tag: "fixed", t: ["The documentation pages inside the npm package now open locally — before, every component box stayed empty.", "npm 包里的文档网页现在可以在本地直接打开(之前每个组件框都是空白的)。", "npm パッケージ内のドキュメントページをローカルで開けるように(以前は各コンポーネント枠が空白だった)。"] },
11
+ { tag: "improved", t: ["Animations and shadows use the system tokens throughout; shadows deepen in dark mode.", "动画和阴影统一使用系统规范值,深色模式下阴影自动加深。", "アニメーションと影をシステムのトークンに統一。ダークモードでは影が自動で濃くなる。"] },
12
+ ] },
6
13
  { v: "2.5.0", date: "2026-10-30", items: [
7
14
  { tag: "new", t: ["Every example on this site has a <b>Show code</b> bar: the React and Vue 3 code that builds it, ready to paste.", "网站上每个例子下都有<b>显示代码</b>:给出做出该例子的 React 和 Vue 3 代码,可直接复制使用。", "サイトの各例の下に<b>コードを表示</b>を追加:その例を作る React / Vue 3 のコードをそのまま貼り付けて使える。"] },
8
15
  { tag: "improved", t: ["Vue imports in one line: <code>import { Button } from '@keyfunc/keybricks-ds/vue'</code>.", "Vue 一行引入:<code>import { Button } from '@keyfunc/keybricks-ds/vue'</code>。", "Vue は 1 行でインポート:<code>import { Button } from '@keyfunc/keybricks-ds/vue'</code>。"] },
@@ -13,7 +13,7 @@ export function BarList({ rows = [], max, width = 360, valueFormat = fmtNum, sho
13
13
  <div style={{ display: "flex", flexDirection: "column", gap: 10, width, ...style }}>
14
14
  {rows.map((r) => (
15
15
  <div key={r.label} onMouseEnter={interactive ? () => setHover(r.label) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}
16
- onClick={onSelect ? () => onSelect(r) : undefined}
16
+ onClick={onSelect ? () => onSelect(r) : undefined} role={onSelect ? "button" : undefined} tabIndex={onSelect ? 0 : undefined} onKeyDown={onSelect ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(r); } } : undefined}
17
17
  style={{ opacity: hover == null || hover === r.label ? 1 : 0.5, cursor: onSelect ? "pointer" : "default", transition: "var(--t-hover)" }}>
18
18
  <div style={{ display: "flex", alignItems: "baseline", gap: 8, paddingBottom: 4 }}>
19
19
  <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", color: "var(--text-body)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r.label}</span>
@@ -1,16 +1,21 @@
1
1
  <script setup>
2
- import { computed, ref } from 'vue'
2
+ import { computed, ref, getCurrentInstance } from 'vue'
3
3
  import { fmtNum } from './chart-geometry.js'
4
4
  // Horizontal labeled bars — top-N rankings, budget consumption.
5
5
  const props = defineProps({ rows:{type:Array,default:()=>[]}, max:Number, width:{type:Number,default:360}, barHeight:{type:Number,default:8}, showTrack:{type:Boolean,default:true}, valueFormat:{type:Function,default:fmtNum}, interactive:{type:Boolean,default:true}, style:Object })
6
6
  const top = computed(() => props.max!=null ? props.max : Math.max(1,...props.rows.map(r=>r.value)))
7
7
  const total = computed(() => props.rows.reduce((s,r)=>s+r.value,0)||1)
8
8
  const hover = ref(null)
9
+ // @select(row) — like React's onSelect: a listener makes each row a button (click, Enter / Space).
10
+ const emit = defineEmits(['select'])
11
+ const inst = getCurrentInstance()
12
+ const selectable = computed(() => !!(inst && inst.vnode.props && inst.vnode.props.onSelect))
13
+ function pick(r) { if (selectable.value) emit('select', r) }
9
14
  </script>
10
15
  <template>
11
16
  <div :style="{display:'flex',flexDirection:'column',gap:'10px',width:width+'px',...style}">
12
- <div v-for="r in rows" :key="r.label" @mouseenter="interactive && (hover=r.label)" @mouseleave="interactive && (hover=null)"
13
- :style="{opacity:hover==null||hover===r.label?1:0.5,transition:'var(--t-hover)'}">
17
+ <div v-for="r in rows" :key="r.label" @mouseenter="interactive && (hover=r.label)" @mouseleave="interactive && (hover=null)" :role="selectable ? 'button' : undefined" :tabindex="selectable ? 0 : undefined" @click="pick(r)" @keydown.enter.prevent="pick(r)" @keydown.space.prevent="pick(r)"
18
+ :style="{opacity:hover==null||hover===r.label?1:0.5,transition:'var(--t-hover)',cursor:selectable?'pointer':'default'}">
14
19
  <div style="display:flex;align-items:baseline;gap:8px;padding-bottom:4px">
15
20
  <span style="font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-body);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.label }}</span>
16
21
  <span style="margin-left:auto;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-heading)">{{ valueFormat(r.value) }}</span>
@@ -8,6 +8,8 @@ export interface Ribbon3DProps {
8
8
  yaw?: number; pitch?: number;
9
9
  /** Band thickness as a fraction of its row, 0–1 */ ribbon?: number;
10
10
  interactive?: boolean;
11
+ /** Formats values in the tooltip and axis (default: grouped number). */
12
+ valueFormat?: (v: number) => string;
11
13
  style?: React.CSSProperties;
12
14
  }
13
15
  export declare function Ribbon3D(props: Ribbon3DProps): JSX.Element;
@@ -7,6 +7,10 @@ export interface Scatter3DProps {
7
7
  yaw?: number; pitch?: number;
8
8
  /** Base dot radius */ r?: number;
9
9
  legend?: boolean; interactive?: boolean;
10
+ /** Axis titles (default "x" / "y" / "z"). */
11
+ xLabel?: string; yLabel?: string; zLabel?: string;
12
+ /** Formats values in the tooltip (default: grouped number). */
13
+ valueFormat?: (v: number) => string;
10
14
  style?: React.CSSProperties;
11
15
  }
12
16
  export declare function Scatter3D(props: Scatter3DProps): JSX.Element;
@@ -33,7 +33,12 @@ export function SunburstChart({ data, size = 320, ringWidth, valueFormat = fmtNu
33
33
  return (
34
34
  <div style={{ ...wrapStyle, ...style }}>
35
35
  <div style={{ position: "relative", width: size, height: size }}>
36
- <svg width={size} height={size} role="img">
36
+ <svg width={size} height={size} role="img" aria-label={interactive ? "Sunburst chart — arrow keys move between segments, Enter zooms in, Escape zooms out" : undefined} tabIndex={interactive ? 0 : undefined} onBlur={() => setHover(null)}
37
+ onKeyDown={interactive ? (e) => { const n = segs.length; if (!n) return; const h = hover;
38
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setHover(h == null ? 0 : (h + 1) % n); }
39
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setHover(h == null ? n - 1 : (h - 1 + n) % n); }
40
+ else if ((e.key === "Enter" || e.key === " ") && h != null && segs[h] && segs[h].node.children) { e.preventDefault(); setZoom([...zoom, ...segs[h].path]); setHover(null); }
41
+ else if (e.key === "Escape") { if (zoom.length) setZoom(zoom.slice(0, -1)); setHover(null); } } : undefined}>
37
42
  {segs.map((s, i) => (
38
43
  <path key={i} d={arcPath(size / 2, size / 2, rInner + rw * (s.depth + 1) - 1.5, rInner + rw * s.depth, s.a0 + 0.35, Math.max(s.a0 + 0.5, s.a1 - 0.35))}
39
44
  fill={seriesColor(s.colorIdx)} opacity={hover == null ? 0.92 - s.depth * 0.16 : hover === i ? 1 : 0.28}
@@ -33,11 +33,17 @@ const tip = computed(()=>{
33
33
  const p = polar(props.size/2, props.size/2, g.value.rInner+g.value.rw*(s.depth+0.5), (s.a0+s.a1)/2)
34
34
  return { x:p[0], y:p[1], title:s.node.label, value: props.valueFormat(s.node.value)+' · '+((s.node.value/(g.value.focus.value||1))*100).toFixed(1)+'%' }
35
35
  })
36
+ // One Tab stop for the whole chart: arrows walk the segments (same highlight + tooltip as hover), Enter zooms in, Escape zooms out.
37
+ function onKey(e) { if (!props.interactive) return; const segs = g.value.segs, n = segs.length; if (!n) return; const h = hover.value
38
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); hover.value = h == null ? 0 : (h + 1) % n }
39
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); hover.value = h == null ? n - 1 : (h - 1 + n) % n }
40
+ else if ((e.key === 'Enter' || e.key === ' ') && h != null && segs[h] && segs[h].node.children) { e.preventDefault(); zoom.value = [...zoom.value, ...segs[h].path]; hover.value = null }
41
+ else if (e.key === 'Escape') { if (zoom.value.length) zoom.value = zoom.value.slice(0, -1); hover.value = null } }
36
42
  </script>
37
43
  <template>
38
44
  <div :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
39
45
  <div :style="{position:'relative',width:size+'px',height:size+'px'}">
40
- <svg :width="size" :height="size" role="img">
46
+ <svg :width="size" :height="size" role="img" :aria-label="interactive ? 'Sunburst chart — arrow keys move between segments, Enter zooms in, Escape zooms out' : undefined" :tabindex="interactive ? 0 : undefined" @keydown="onKey" @blur="hover = null">
41
47
  <path v-for="(s,i) in g.segs" :key="i" :d="s.d" :fill="s.color"
42
48
  :opacity="hover==null ? 0.92-s.depth*0.16 : (hover===i?1:0.28)"
43
49
  @mouseenter="interactive && (hover=i)" @mouseleave="interactive && (hover=null)"
@@ -8,6 +8,8 @@ export interface Surface3DProps {
8
8
  /** Cell outlines */ wire?: boolean;
9
9
  /** Drag-to-orbit */ interactive?: boolean;
10
10
  valueFormat?: (v: number) => string;
11
+ /** Axis titles (default "x" / "y"). */
12
+ xLabel?: string; yLabel?: string;
11
13
  style?: React.CSSProperties;
12
14
  }
13
15
  export declare function Surface3D(props: Surface3DProps): JSX.Element;
@@ -80,7 +80,12 @@ export function WorldMap({ values = {}, width = 660, height = 340, projection =
80
80
  const h = hover != null ? paths[hover] : null;
81
81
  return (
82
82
  <div style={{ ...wrapStyle, ...style }}>
83
- <svg width={width} height={height - (legend ? 22 : 0)} role="img">
83
+ <svg width={width} height={height - (legend ? 22 : 0)} role="img" aria-label={interactive ? "Map — arrow keys move between regions" + (onSelect ? ", Enter selects" : "") : undefined} tabIndex={interactive ? 0 : undefined} onBlur={() => setHover(null)}
84
+ onKeyDown={interactive ? (e) => { const n = paths.length; if (!n) return; const h = hover;
85
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setHover(h == null ? 0 : (h + 1) % n); }
86
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setHover(h == null ? n - 1 : (h - 1 + n) % n); }
87
+ else if ((e.key === "Enter" || e.key === " ") && h != null && onSelect) { e.preventDefault(); onSelect(paths[h].name, resolved[paths[h].name]); }
88
+ else if (e.key === "Escape") setHover(null); } : undefined}>
84
89
  {paths.map((p, i) => (
85
90
  <path key={p.name + i} d={p.d} fill={fillOf(p.name)} stroke="var(--bg-surface)" strokeWidth="0.5"
86
91
  onMouseEnter={interactive ? () => setHover(i) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined}
@@ -49,12 +49,18 @@ function fill(name){
49
49
  const swatches = computed(() => props.scale==='diverging'
50
50
  ? ['var(--chart-neg)','color-mix(in srgb, var(--chart-neg) 40%, var(--bg-surface))','var(--chart-track)','color-mix(in srgb, var(--chart-pos) 40%, var(--bg-surface))','var(--chart-pos)']
51
51
  : HEAT)
52
+ // One Tab stop for the whole map: arrows walk the regions (same highlight + tooltip as hover), Enter emits select, Escape clears.
53
+ function onKey(e) { if (!props.interactive) return; const ps = paths.value, n = ps.length; if (!n) return; const h = hover.value
54
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); hover.value = h == null ? 0 : (h + 1) % n }
55
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); hover.value = h == null ? n - 1 : (h - 1 + n) % n }
56
+ else if ((e.key === 'Enter' || e.key === ' ') && h != null) { e.preventDefault(); emit('select', ps[h].name, resolved.value[ps[h].name]) }
57
+ else if (e.key === 'Escape') hover.value = null }
52
58
  </script>
53
59
  <template>
54
60
  <div v-if="err" :style="{width:width+'px',height:height+'px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-xs)',color:'var(--text-muted)',background:'var(--bg-inset)',borderRadius:'var(--radius-lg)',...style}">{{ err }}</div>
55
61
  <div v-else-if="!paths" :style="{width:width+'px',height:height+'px',display:'flex',alignItems:'center',justifyContent:'center',fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums',fontSize:'var(--text-xs)',color:'var(--text-muted)',...style}">loading map…</div>
56
62
  <div v-else :style="{position:'relative',display:'inline-block',lineHeight:0,...style}">
57
- <svg :width="width" :height="plotH" role="img">
63
+ <svg :width="width" :height="plotH" role="img" :aria-label="interactive ? 'Map — arrow keys move between regions, Enter selects' : undefined" :tabindex="interactive ? 0 : undefined" @keydown="onKey" @blur="hover = null">
58
64
  <path v-for="(p,i) in paths" :key="p.name+i" :d="p.d" :fill="fill(p.name)" stroke="var(--bg-surface)" stroke-width="0.5"
59
65
  @mouseenter="interactive && (hover=i)" @mouseleave="interactive && (hover=null)" @click="emit('select', p.name, resolved[p.name])"
60
66
  :style="{opacity:hover==null||hover===i?1:0.75,transition:'opacity var(--dur-fast) var(--ease-out)'}"/>
@@ -89,7 +89,7 @@ function TreeToggle({ open, label, onClick }) {
89
89
  return (
90
90
  <button type="button" aria-expanded={open} aria-label={label} title={label} onClick={(e) => { e.stopPropagation(); onClick(); }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
91
91
  style={{ width: TREE_TOGGLE, height: TREE_TOGGLE, padding: 0, border: "none", borderRadius: "var(--radius-sm)", display: "inline-flex", alignItems: "center", justifyContent: "center", cursor: "pointer", background: hover ? "var(--overlay-hover)" : "transparent", color: hover ? "var(--text-body)" : "var(--text-muted)", transition: "var(--t-hover)" }}>
92
- <Icon name="chevron-right" size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", transform: open ? "rotate(90deg)" : "none", transition: "transform .15s ease" }} />
92
+ <Icon name="chevron-right" size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", transform: open ? "rotate(90deg)" : "none", transition: "transform var(--dur-fast) var(--ease-out)" }} />
93
93
  </button>
94
94
  );
95
95
  }
@@ -188,7 +188,7 @@ function cellPad(i) { return { ...(i === 0 && props.selectable ? { position: 're
188
188
  <tr v-else-if="pinInfo && pinInfo.style === 'labels' && pinInfo.grouped && pos === pinInfo.unStart"><td :colspan="columns.length || 1" :style="GROUP_CELL">{{ L.unavailable }} · {{ pinInfo.un }}</td></tr>
189
189
  <tr data-row="" :aria-level="tree ? item.level + 1 : undefined" :aria-expanded="item.kids ? item.open : undefined" :style="{ ...(isDis(item.r, item.ri) ? { '--row-wash': 'initial' } : null), height: 'var(--bar-h)', background: pinInfo && pinInfo.sel.has(item.ri) ? 'var(--row-wash, var(--bg-inset))' : 'var(--row-wash, transparent)', transition: 'var(--t-hover)', borderBottom: pinInfo && ((pinInfo.rest > 0 && pos === pinInfo.rest - 1) || (pinInfo.unStart > 0 && pos === pinInfo.unStart - 1)) ? '1px solid var(--border-control)' : bordered && pos === view.length - 1 ? 'none' : '1px solid var(--border-subtle)' }">
190
190
  <template v-for="{ r, ri } in [item]" :key="'r'">
191
- <td v-for="(c, i) in columns" :key="c.key" :style="{ ...cellPad(i), ...(c.twoLine ? { paddingTop: 'var(--title-cell-py)', paddingBottom: 'var(--title-cell-py)', maxWidth: 0 } : null), textAlign: c.align || 'left', whiteSpace: 'nowrap', ...(resizable ? { overflow: 'hidden', textOverflow: 'ellipsis' } : null), ...cellFont(c) }"><span v-if="selectable && i === 0" :style="checkSlot" :title="isDis(r, ri) && disabledReason ? disabledReason(r, ri) : undefined"><Checkbox :checked="selected.includes(ri)" :disabled="isDis(r, ri)" @change="v => emit('toggle', ri, v)" /></span><div v-if="c.twoLine" :style="{ display: 'flex', alignItems: 'center', minWidth: px(c.minWidth ?? 180), ...(treeCol === c ? { paddingLeft: (item.level * TREE_INDENT) + 'px' } : null) }"><span v-if="treeCol === c" :style="{ width: TREE_TOGGLE + 'px', flexShrink: 0, marginRight: (TREE_INDENT - TREE_TOGGLE) + 'px', display: 'inline-flex' }"><button v-if="item.kids" type="button" :aria-expanded="item.open" :aria-label="item.open ? L.collapse : L.expand" :title="item.open ? L.collapse : L.expand" @click.stop="toggleRow(item)" @mouseenter="hotToggle = item.key" @mouseleave="hotToggle = null" :style="{ width: TREE_TOGGLE + 'px', height: TREE_TOGGLE + 'px', padding: 0, border: 'none', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: hotToggle === item.key ? 'var(--overlay-hover)' : 'transparent', color: hotToggle === item.key ? 'var(--text-body)' : 'var(--text-muted)', transition: 'var(--t-hover)' }"><Icon name="chevron-right" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)', transform: item.open ? 'rotate(90deg)' : 'none', transition: 'transform .15s ease' }" /></button></span><div style="display:flex;align-items:center;gap:var(--title-cell-tile-gap);flex:1;min-width:0"><template v-for="t in [tl(c, r, ri)]" :key="0"><span v-if="t.icon" :style="{ display: 'inline-flex', flexShrink: 0, cursor: canOpen ? 'pointer' : undefined }" @click="e => canOpen && openRow(r, ri, e)"><IconTile :icon="t.icon" :hue="t.hue || 'neutral'" kind="row" :style="T_TILE" :icon-style="T_ICON" /></span><div style="display:flex;flex-direction:column;gap:var(--title-cell-gap);flex:1;min-width:0"><div style="display:flex;align-items:center;gap:4px;min-width:0"><span v-if="canOpen" role="button" tabindex="0" :style="{ ...T_TITLE, cursor: 'pointer' }" @click="e => openRow(r, ri, e)" @keydown="e => openKey(r, ri, e)"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><span v-else :style="T_TITLE"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><svg v-if="t.mark === 'favorite'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round" role="img" aria-label="Favorite" style="display:block;flex-shrink:0;color:var(--warning)"><path :d="STAR_FILLED" /></svg><span v-else-if="t.mark" role="img" :aria-label="t.mark.label" :style="{ display: 'inline-flex', flexShrink: 0, color: t.mark.color || 'var(--text-muted)' }"><Icon :name="t.mark.icon" :size="14" /></span></div><span v-if="t.desc" :style="T_DESC">{{ t.desc }}</span></div><IconButton v-if="t.more" icon="ellipsis" size="sm" muted :label="L.more" :title="L.more" @click="e => more(r, ri, e)" /></template></div></div><code v-else-if="c.code && (typeof r[c.key] === 'string' || typeof r[c.key] === 'number') && r[c.key] !== ''" :style="codeChip"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></code><template v-else><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></template></td>
191
+ <td v-for="(c, i) in columns" :key="c.key" :style="{ ...cellPad(i), ...(c.twoLine ? { paddingTop: 'var(--title-cell-py)', paddingBottom: 'var(--title-cell-py)', maxWidth: 0 } : null), textAlign: c.align || 'left', whiteSpace: 'nowrap', ...(resizable ? { overflow: 'hidden', textOverflow: 'ellipsis' } : null), ...cellFont(c) }"><span v-if="selectable && i === 0" :style="checkSlot" :title="isDis(r, ri) && disabledReason ? disabledReason(r, ri) : undefined"><Checkbox :checked="selected.includes(ri)" :disabled="isDis(r, ri)" @change="v => emit('toggle', ri, v)" /></span><div v-if="c.twoLine" :style="{ display: 'flex', alignItems: 'center', minWidth: px(c.minWidth ?? 180), ...(treeCol === c ? { paddingLeft: (item.level * TREE_INDENT) + 'px' } : null) }"><span v-if="treeCol === c" :style="{ width: TREE_TOGGLE + 'px', flexShrink: 0, marginRight: (TREE_INDENT - TREE_TOGGLE) + 'px', display: 'inline-flex' }"><button v-if="item.kids" type="button" :aria-expanded="item.open" :aria-label="item.open ? L.collapse : L.expand" :title="item.open ? L.collapse : L.expand" @click.stop="toggleRow(item)" @mouseenter="hotToggle = item.key" @mouseleave="hotToggle = null" :style="{ width: TREE_TOGGLE + 'px', height: TREE_TOGGLE + 'px', padding: 0, border: 'none', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: hotToggle === item.key ? 'var(--overlay-hover)' : 'transparent', color: hotToggle === item.key ? 'var(--text-body)' : 'var(--text-muted)', transition: 'var(--t-hover)' }"><Icon name="chevron-right" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)', transform: item.open ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }" /></button></span><div style="display:flex;align-items:center;gap:var(--title-cell-tile-gap);flex:1;min-width:0"><template v-for="t in [tl(c, r, ri)]" :key="0"><span v-if="t.icon" :style="{ display: 'inline-flex', flexShrink: 0, cursor: canOpen ? 'pointer' : undefined }" @click="e => canOpen && openRow(r, ri, e)"><IconTile :icon="t.icon" :hue="t.hue || 'neutral'" kind="row" :style="T_TILE" :icon-style="T_ICON" /></span><div style="display:flex;flex-direction:column;gap:var(--title-cell-gap);flex:1;min-width:0"><div style="display:flex;align-items:center;gap:4px;min-width:0"><span v-if="canOpen" role="button" tabindex="0" :style="{ ...T_TITLE, cursor: 'pointer' }" @click="e => openRow(r, ri, e)" @keydown="e => openKey(r, ri, e)"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><span v-else :style="T_TITLE"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></span><svg v-if="t.mark === 'favorite'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round" role="img" aria-label="Favorite" style="display:block;flex-shrink:0;color:var(--warning)"><path :d="STAR_FILLED" /></svg><span v-else-if="t.mark" role="img" :aria-label="t.mark.label" :style="{ display: 'inline-flex', flexShrink: 0, color: t.mark.color || 'var(--text-muted)' }"><Icon :name="t.mark.icon" :size="14" /></span></div><span v-if="t.desc" :style="T_DESC">{{ t.desc }}</span></div><IconButton v-if="t.more" icon="ellipsis" size="sm" muted :label="L.more" :title="L.more" @click="e => more(r, ri, e)" /></template></div></div><code v-else-if="c.code && (typeof r[c.key] === 'string' || typeof r[c.key] === 'number') && r[c.key] !== ''" :style="codeChip"><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></code><template v-else><slot :name="'cell-' + c.key" :value="r[c.key]" :row="r" :index="ri"><Cell :v="c.render ? c.render(r[c.key], r, ri) : r[c.key]" /></slot></template></td>
192
192
  </template>
193
193
  </tr>
194
194
  </template>
@@ -11,7 +11,7 @@ export function ProgressBar({ label, value = 50, color = "var(--chart-blue)", sh
11
11
  </div>
12
12
  ) : null}
13
13
  <div style={{ height: 6, borderRadius: "var(--radius-full)", background: "var(--track)", overflow: "hidden" }}>
14
- <div style={{ width: `${value}%`, height: "100%", borderRadius: "var(--radius-full)", background: color, transition: "width .3s ease" }} />
14
+ <div style={{ width: `${value}%`, height: "100%", borderRadius: "var(--radius-full)", background: color, transition: "width var(--dur-slow) var(--ease-out)" }} />
15
15
  </div>
16
16
  </div>
17
17
  );
@@ -9,7 +9,7 @@ defineProps({ label: String, value: { type: Number, default: 50 }, color: { type
9
9
  <span v-if="showValue" style="color:var(--text-heading)">{{ value }}%</span>
10
10
  </div>
11
11
  <div style="height:6px;border-radius:var(--radius-full);background:var(--track);overflow:hidden">
12
- <div :style="{ width: value + '%', height: '100%', borderRadius: 'var(--radius-full)', background: color, transition: 'width .3s ease' }" />
12
+ <div :style="{ width: value + '%', height: '100%', borderRadius: 'var(--radius-full)', background: color, transition: 'width var(--dur-slow) var(--ease-out)' }" />
13
13
  </div>
14
14
  </div>
15
15
  </template>
@@ -8,7 +8,7 @@ export function Accordion({ title, subtitle, defaultOpen, open: openProp, onTogg
8
8
  const toggle = () => (onToggle ? onToggle(!open) : setOpenState(!open));
9
9
  return (
10
10
  <div style={{ borderBottom: "1px solid var(--border-card)", ...style }}>
11
- <div onClick={toggle} data-wash="" style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 8px", margin: "0 -4px", borderRadius: "var(--radius-md)", background: "var(--wash, transparent)", transition: "var(--t-hover)", cursor: "pointer" }}>
11
+ <div role="button" tabIndex={0} aria-expanded={!!open} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }} onClick={toggle} data-wash="" style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 8px", margin: "0 -4px", borderRadius: "var(--radius-md)", background: "var(--wash, transparent)", transition: "var(--t-hover)", cursor: "pointer" }}>
12
12
  <span style={{ color: "var(--text-muted)", display: "inline-flex", transform: open ? "rotate(90deg)" : "none", transition: "transform var(--dur-fast) var(--ease-out)" }}><Icon name="chevron-right" size={14} /></span>
13
13
  <div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
14
14
  <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", letterSpacing: "var(--tracking-base)", color: "var(--text-heading)" }}>{title}</span>
@@ -10,7 +10,7 @@ function toggle() { if (props.open != null) emit('toggle', !open.value); else op
10
10
  </script>
11
11
  <template>
12
12
  <div :style="{ borderBottom: '1px solid var(--border-card)', ...style }">
13
- <div @click="toggle" data-wash="" style="display:flex;align-items:center;gap:10px;padding:14px 8px;margin:0 -4px;border-radius:var(--radius-md);background:var(--wash, transparent);transition:var(--t-hover);cursor:pointer">
13
+ <div role="button" tabindex="0" :aria-expanded="!!open" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle" @click="toggle" data-wash="" style="display:flex;align-items:center;gap:10px;padding:14px 8px;margin:0 -4px;border-radius:var(--radius-md);background:var(--wash, transparent);transition:var(--t-hover);cursor:pointer">
14
14
  <span :style="{ color: 'var(--text-muted)', display: 'inline-flex', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)' }"><Icon name="chevron-right" :size="14" /></span>
15
15
  <div style="display:flex;flex-direction:column;flex:1">
16
16
  <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);letter-spacing:var(--tracking-base);color:var(--text-heading)">{{ title }}</span>
@@ -30,13 +30,13 @@ export function ContextMenu({ items = [], onSelect, size, descriptionSide = "rig
30
30
  };
31
31
  const onBlur = (e) => { setHover(-1); if (hasHint && !(wrap.current && wrap.current.contains(e.relatedTarget))) setHint(-1); };
32
32
  const menu = (
33
- <div role="menu" data-size={hasHint ? undefined : size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
33
+ <div role="menu" data-size={hasHint ? undefined : size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, maxWidth: 320, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
34
34
  {items.map((it, i) => it.divider ? <div key={i} role="separator" style={{ height: 1, flexShrink: 0, background: "var(--border-subtle)", margin: "4px 0" }} /> : (
35
35
  <button key={i} type="button" onClick={() => onSelect && onSelect(it.label)}
36
36
  onMouseEnter={() => show(i)} onMouseLeave={() => setHover(-1)} onFocus={() => show(i)} onBlur={onBlur}
37
37
  aria-describedby={hint === i ? hintId : undefined}
38
- role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i || hint === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap" }}>
39
- {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) }} /> : null}{it.label}
38
+ role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i || hint === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }} title={it.label}>
39
+ {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) }} /> : null}<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>{it.label}</span>
40
40
  </button>
41
41
  ))}
42
42
  </div>
@@ -26,8 +26,8 @@ function show(i) {
26
26
  }
27
27
  function onBlur(e) { hover.value = -1; if (hasHint.value && !(wrap.value && wrap.value.contains(e.relatedTarget))) hint.value = -1 }
28
28
  const cur = computed(() => props.items[hint.value])
29
- const menuStyle = computed(() => ({ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', display: 'inline-flex', flexDirection: 'column', gap: choice.value ? '2px' : 0, ...props.style }))
30
- const rowStyle = (it, i) => ({ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover.value === i || hint.value === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap' })
29
+ const menuStyle = computed(() => ({ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', maxWidth: '320px', display: 'inline-flex', flexDirection: 'column', gap: choice.value ? '2px' : 0, ...props.style }))
30
+ const rowStyle = (it, i) => ({ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover.value === i || hint.value === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' })
31
31
  const iconStyle = (it) => ({ width: 'var(--control-icon)', height: 'var(--control-icon)', flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) })
32
32
  const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value === 'left' ? 'right' : 'left']: `calc(100% + ${HINT_GAP}px)`, width: HINT_W + 'px', boxSizing: 'border-box', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '6px', background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', fontFamily: 'var(--font-sans)', animation: 'kb-fade-in var(--dur) var(--ease-out)' }))
33
33
  </script>
@@ -38,7 +38,7 @@ const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value ==
38
38
  <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
39
39
  <button v-else type="button" @click="emit('select', it.label)" @mouseenter="show(i)" @mouseleave="hover = -1" @focus="show(i)" @blur="onBlur"
40
40
  :aria-describedby="hint === i ? hintId : undefined" :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="rowStyle(it, i)">
41
- <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" />{{ it.label }}
41
+ <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" /><span style="min-width:0;overflow:hidden;text-overflow:ellipsis">{{ it.label }}</span>
42
42
  </button>
43
43
  </template>
44
44
  </div>
@@ -55,7 +55,7 @@ const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value ==
55
55
  <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
56
56
  <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1" @focus="hover = i" @blur="hover = -1"
57
57
  :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="rowStyle(it, i)">
58
- <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" />{{ it.label }}
58
+ <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" /><span style="min-width:0;overflow:hidden;text-overflow:ellipsis">{{ it.label }}</span>
59
59
  </button>
60
60
  </template>
61
61
  </div>
@@ -5,10 +5,10 @@ import { IconButton } from "../buttons/IconButton.jsx";
5
5
  export function Drawer({ open = true, title, subtitle, width = 400, inline, height, size, footer, onClose, children, style }) {
6
6
  if (!open) return null;
7
7
  const panel = (
8
- <div role="dialog" aria-label={title} data-size={size} style={{ width, maxWidth: "100%", height: inline ? (height || 420) : "100%", background: "var(--bg-raised)", borderLeft: "1px solid var(--border-card)", boxShadow: inline ? "var(--shadow-menu)" : "-8px 0 24px rgba(0,0,0,.12)", display: "flex", flexDirection: "column", boxSizing: "border-box", ...(inline ? { border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)" } : null), ...style }}>
8
+ <div role="dialog" aria-label={title} data-size={size} style={{ width, maxWidth: "100%", height: inline ? (height || 420) : "100%", background: "var(--bg-raised)", borderLeft: "1px solid var(--border-card)", boxShadow: inline ? "var(--shadow-menu)" : "var(--shadow-drawer)", display: "flex", flexDirection: "column", boxSizing: "border-box", ...(inline ? { border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)" } : null), ...style }}>
9
9
  <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", padding: "16px 16px 12px", borderBottom: "1px solid var(--border-card)", flexShrink: 0 }}>
10
- <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
11
- <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", color: "var(--text-heading)" }}>{title}</span>
10
+ <div style={{ display: "flex", flexDirection: "column", gap: 3, minWidth: 0 }}>
11
+ <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", color: "var(--text-heading)", minWidth: 0, overflowWrap: "anywhere" }}>{title}</span>
12
12
  {subtitle && <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-secondary)" }}>{subtitle}</span>}
13
13
  </div>
14
14
  <IconButton icon="x" label="Close" onClick={onClose} style={{ margin: "calc((var(--leading-md) - var(--control-h)) / 2) -6px 0 0" }} />
@@ -14,10 +14,10 @@ const contents = { display: 'contents' }
14
14
  :style="inline ? contents : { position: 'fixed', inset: 0, zIndex: 'var(--z-modal)', background: 'var(--bg-scrim)', display: 'flex', justifyContent: 'flex-end', animation: 'kb-fade-in var(--dur-fast) var(--ease-out)' }">
15
15
  <div @click.stop :style="inline ? contents : { height: '100%', animation: 'kb-slide-in var(--dur) var(--ease-out)' }">
16
16
  <div role="dialog" :aria-label="title" :data-size="size"
17
- :style="{ width: px(width), maxWidth: '100%', height: inline ? px(height) : '100%', background: 'var(--bg-raised)', borderLeft: '1px solid var(--border-card)', boxShadow: inline ? 'var(--shadow-menu)' : '-8px 0 24px rgba(0,0,0,.12)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', ...(inline ? { border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)' } : null), ...style }">
17
+ :style="{ width: px(width), maxWidth: '100%', height: inline ? px(height) : '100%', background: 'var(--bg-raised)', borderLeft: '1px solid var(--border-card)', boxShadow: inline ? 'var(--shadow-menu)' : 'var(--shadow-drawer)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', ...(inline ? { border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)' } : null), ...style }">
18
18
  <div style="display:flex;align-items:flex-start;justify-content:space-between;padding:16px 16px 12px;border-bottom:1px solid var(--border-card);flex-shrink:0">
19
- <div style="display:flex;flex-direction:column;gap:3px">
20
- <span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading)">{{ title }}</span>
19
+ <div style="display:flex;flex-direction:column;gap:3px;min-width:0">
20
+ <span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading);min-width:0;overflow-wrap:anywhere">{{ title }}</span>
21
21
  <span v-if="subtitle" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)">{{ subtitle }}</span>
22
22
  </div>
23
23
  <IconButton icon="x" label="Close" :style="{ margin: 'calc((var(--leading-md) - var(--control-h)) / 2) -6px 0 0' }" @click="emit('close')" />
@@ -9,6 +9,8 @@ export interface CheckboxProps {
9
9
  /** Size tier. Omit to inherit the enclosing region's tier (the default). */
10
10
  size?: "sm" | "md" | "lg";
11
11
  disabled?: boolean;
12
+ /** Tab order. Default 0; pass -1 inside a control that owns focus (Tree row, MultiSelect option). */
13
+ tabIndex?: number;
12
14
  style?: React.CSSProperties;
13
15
  }
14
16
  export declare function Checkbox(props: CheckboxProps): JSX.Element;
@@ -1,13 +1,18 @@
1
1
  import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
3
 
4
- /** Checkbox — box at the region's tier (sm 14 · md/lg 16, tokens/layout.css --check-size), 1.5px border, r4; checked = blue fill + white check; indeterminate = blue fill + dash; disabled = 50% opacity. The label is the click target and uses the tier's control text. */
5
- export function Checkbox({ checked, indeterminate, onChange, label, size, disabled, style }) {
4
+ /** Checkbox — box at the region's tier (sm 14 · md/lg 16, tokens/layout.css --check-size), 1.5px border, r4; checked = blue fill + white check; indeterminate = blue fill + dash; disabled = 50% opacity. The label is the click target and uses the tier's control text.
5
+ * Keyboard: the label is role="checkbox" (aria-checked true / false / "mixed"), a Tab stop, Space toggles. Inside a control that owns focus
6
+ * (a Tree row, a MultiSelect option) pass tabIndex={-1} so the row stays the single stop. */
7
+ export function Checkbox({ checked, indeterminate, onChange, label, size, disabled, tabIndex, style }) {
8
+ const flip = () => { if (!disabled && onChange) onChange(!checked); };
6
9
  const box = "var(--check-size)", glyph = { width: "calc(var(--check-size) - 4px)", height: "calc(var(--check-size) - 4px)" };
7
10
  const filled = checked || indeterminate;
8
11
  return (
9
- <label data-size={size} data-check={disabled ? undefined : ""} style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", lineHeight: "var(--control-leading)", gap: "var(--control-gap)", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .5 : 1, ...style }}
10
- onClick={() => !disabled && onChange && onChange(!checked)}>
12
+ <label data-size={size} data-check={disabled ? undefined : ""} role="checkbox" aria-checked={indeterminate && !checked ? "mixed" : !!checked} aria-disabled={disabled || undefined}
13
+ tabIndex={disabled ? -1 : tabIndex ?? 0} onKeyDown={(e) => { if (e.key === " ") { e.preventDefault(); e.stopPropagation(); flip(); } }}
14
+ style={{ outlineOffset: 2, display: "inline-flex", alignItems: "center", verticalAlign: "middle", lineHeight: "var(--control-leading)", gap: "var(--control-gap)", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .5 : 1, ...style }}
15
+ onClick={flip}>
11
16
  <span style={{ width: box, height: box, borderRadius: 4, boxSizing: "border-box", border: filled ? "none" : "1.5px solid var(--check-border, var(--checkbox-border))", background: filled ? "var(--action-primary)" : "var(--bg-surface)", display: "inline-flex", alignItems: "center", justifyContent: "center", color: "var(--on-accent)", flexShrink: 0, transition: "var(--t-hover)" }}>
12
17
  {checked ? <Icon name="check" size={12} strokeWidth={3} style={glyph} /> : indeterminate ? <Icon name="minus" size={12} strokeWidth={3} style={glyph} /> : null}
13
18
  </span>
@@ -2,13 +2,17 @@
2
2
  import { computed } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
4
  // Checkbox — box at the region's tier (sm 14 · md/lg 16, tokens/layout.css --check-size), 1.5px border, r4; checked = blue fill + white check; indeterminate = blue fill + dash; disabled = 50% opacity. The label is the click target and uses the tier's control text.
5
- const props = defineProps({ checked: Boolean, indeterminate: Boolean, label: String, size: String, disabled: Boolean, style: Object })
5
+ // Keyboard: the label is role="checkbox" (aria-checked true / false / "mixed"), a Tab stop, Space toggles. Inside a control that owns focus
6
+ // (a Tree row, a MultiSelect option) pass :tabindex="-1" so the row stays the single stop.
7
+ const props = defineProps({ checked: Boolean, indeterminate: Boolean, label: String, size: String, disabled: Boolean, tabindex: { type: [Number, String], default: 0 }, style: Object })
6
8
  const emit = defineEmits(['change'])
7
9
  const box = 'var(--check-size)', glyph = { width: 'calc(var(--check-size) - 4px)', height: 'calc(var(--check-size) - 4px)' }
8
10
  const filled = computed(() => props.checked || props.indeterminate)
11
+ function flip() { if (!props.disabled) emit('change', !props.checked) }
12
+ function onKey(e) { if (e.key === ' ') { e.preventDefault(); e.stopPropagation(); flip() } }
9
13
  </script>
10
14
  <template>
11
- <label :data-size="size" :data-check="disabled ? undefined : ''" :style="{ display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', lineHeight: 'var(--control-leading)', gap: 'var(--control-gap)', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .5 : 1, ...style }" @click="!disabled && emit('change', !checked)">
15
+ <label :data-size="size" :data-check="disabled ? undefined : ''" role="checkbox" :aria-checked="indeterminate && !checked ? 'mixed' : !!checked" :aria-disabled="disabled || undefined" :tabindex="disabled ? -1 : tabindex" @keydown="onKey" :style="{ outlineOffset: '2px', display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', lineHeight: 'var(--control-leading)', gap: 'var(--control-gap)', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .5 : 1, ...style }" @click="flip">
12
16
  <span :style="{ width: box, height: box, borderRadius: '4px', boxSizing: 'border-box', border: filled ? 'none' : '1.5px solid var(--check-border, var(--checkbox-border))', background: filled ? 'var(--action-primary)' : 'var(--bg-surface)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: 'var(--on-accent)', flexShrink: 0, transition: 'var(--t-hover)' }">
13
17
  <Icon v-if="checked" name="check" :size="12" :stroke-width="3" :style="glyph" />
14
18
  <Icon v-else-if="indeterminate" name="minus" :size="12" :stroke-width="3" :style="glyph" />
@@ -26,7 +26,9 @@ export function DatePicker({ value = "", placeholder = "Select date", disabled,
26
26
  const monthTitle = fmt(locale, { year: "numeric", month: "long" }).format(new Date(view.y, view.m, 1));
27
27
  return (
28
28
  <div ref={root} data-size={size} style={{ position: "relative", width, ...style }}>
29
- <div data-field={disabled ? undefined : ""} onClick={() => !disabled && setOpen(!open)} style={{ ...inputBase, display: "flex", alignItems: "center", justifyContent: "space-between", cursor: disabled ? "not-allowed" : "pointer", borderColor: error ? "var(--danger)" : open ? "var(--focus-border)" : "var(--field-border, var(--border-control))", boxShadow: open ? "0 0 0 2px var(--focus-ring)" : "none", opacity: disabled ? .5 : 1, color: label ? "var(--text-primary)" : "var(--text-muted)" }}>
29
+ <div data-field={disabled ? undefined : ""} role="combobox" aria-haspopup="dialog" aria-expanded={open} aria-disabled={disabled || undefined} tabIndex={disabled ? -1 : 0}
30
+ onKeyDown={(e) => { if (disabled) return; if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { e.preventDefault(); setOpen(true); } else if (e.key === "Escape") setOpen(false); }}
31
+ onClick={() => !disabled && setOpen(!open)} style={{ ...inputBase, display: "flex", alignItems: "center", justifyContent: "space-between", cursor: disabled ? "not-allowed" : "pointer", borderColor: error ? "var(--danger)" : open ? "var(--focus-border)" : "var(--field-border, var(--border-control))", boxShadow: open ? "0 0 0 2px var(--focus-ring)" : "none", opacity: disabled ? .5 : 1, color: label ? "var(--text-primary)" : "var(--text-muted)" }}>
30
32
  {label || placeholder}<span style={{ color: "var(--text-muted)", display: "inline-flex" }}><Icon name="calendar" size={16} style={tierIcon} /></span>
31
33
  </div>
32
34
  {open && <div style={{ position: "absolute", top: "calc(100% + 4px)", left: 0, zIndex: "var(--z-menu)", width: 252, background: "var(--bg-surface)", border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-menu)", padding: 12, boxSizing: "border-box" }}>
@@ -26,10 +26,11 @@ function pick(i) { emit('update:modelValue', dayIso(i)); open.value = false }
26
26
  function onDoc(e) { if (root.value && !root.value.contains(e.target)) open.value = false }
27
27
  onMounted(() => document.addEventListener('mousedown', onDoc))
28
28
  onUnmounted(() => document.removeEventListener('mousedown', onDoc))
29
+ function onFieldKey(e) { if (props.disabled) return; if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); open.value = true } else if (e.key === 'Escape') open.value = false }
29
30
  </script>
30
31
  <template>
31
32
  <div ref="root" :data-size="size" :style="{ position: 'relative', width: typeof width === 'number' ? width + 'px' : width, ...style }">
32
- <div :data-field="disabled ? undefined : ''" @click="!disabled && (open = !open)" :style="{ ...inputBase, display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: disabled ? 'not-allowed' : 'pointer', borderColor: error ? 'var(--danger)' : open ? 'var(--focus-border)' : 'var(--field-border, var(--border-control))', boxShadow: open ? '0 0 0 2px var(--focus-ring)' : 'none', opacity: disabled ? .5 : 1, color: label ? 'var(--text-primary)' : 'var(--text-muted)' }">
33
+ <div :data-field="disabled ? undefined : ''" role="combobox" aria-haspopup="dialog" :aria-expanded="open" :aria-disabled="disabled || undefined" :tabindex="disabled ? -1 : 0" @keydown="onFieldKey" @click="!disabled && (open = !open)" :style="{ ...inputBase, display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: disabled ? 'not-allowed' : 'pointer', borderColor: error ? 'var(--danger)' : open ? 'var(--focus-border)' : 'var(--field-border, var(--border-control))', boxShadow: open ? '0 0 0 2px var(--focus-ring)' : 'none', opacity: disabled ? .5 : 1, color: label ? 'var(--text-primary)' : 'var(--text-muted)' }">
33
34
  {{ label || placeholder }}<span style="color:var(--text-muted);display:inline-flex"><Icon name="calendar" :size="16" :style="tierIcon" /></span>
34
35
  </div>
35
36
  <div v-if="open" style="position:absolute;top:calc(100% + 4px);left:0;z-index:var(--z-menu);width:252px;background:var(--bg-surface);border:1px solid var(--border-card);border-radius:var(--radius-lg);box-shadow:var(--shadow-menu);padding:12px;box-sizing:border-box">
@@ -22,7 +22,7 @@ export function Dropzone({ title = "Click to upload or drag and drop", hint = "J
22
22
  const tone = disabled ? "default" : error ? "error" : dragActive ? "drag" : hover ? "hover" : "default";
23
23
  const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
24
24
  return (
25
- <div onClick={disabled ? undefined : onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
25
+ <div role="button" tabIndex={disabled ? -1 : 0} aria-disabled={disabled || undefined} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!disabled && onClick) onClick(e); } }} onClick={disabled ? undefined : onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
26
26
  onDragEnter={disabled ? undefined : (e) => { stop(e); setDragging(true); }}
27
27
  onDragOver={disabled ? undefined : stop}
28
28
  onDragLeave={disabled ? undefined : (e) => { stop(e); setDragging(false); }}
@@ -17,7 +17,7 @@ function onDrop(e) { stop(e); dragging.value = false; emit('dropFiles', e.dataTr
17
17
  <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);color:var(--text-heading)">{{ file }}</span>
18
18
  <button type="button" data-link="" @click="$emit('remove')" style="display:flex;align-items:center;gap:4px;border:none;background:transparent;cursor:pointer;color:var(--danger);font-family:var(--font-sans);font-size:var(--text-base);padding:0;text-decoration-line:var(--link-line, none);text-underline-offset:2px"><Icon name="trash-2" :size="13" />Remove</button>
19
19
  </div>
20
- <div v-else @click="!disabled && $emit('click')" @mouseenter="hover = true" @mouseleave="hover = false"
20
+ <div v-else role="button" :tabindex="disabled ? -1 : 0" :aria-disabled="disabled || undefined" @keydown.enter.prevent="!disabled && $emit('click')" @keydown.space.prevent="!disabled && $emit('click')" @click="!disabled && $emit('click')" @mouseenter="hover = true" @mouseleave="hover = false"
21
21
  @dragenter="!disabled && ((e) => { stop(e); dragging = true })($event)" @dragover="!disabled && stop($event)"
22
22
  @dragleave="!disabled && ((e) => { stop(e); dragging = false })($event)" @drop="!disabled && onDrop($event)"
23
23
  :style="{ height, borderRadius: 'var(--radius-lg)', border: `1px dashed ${toneColor[tone]}`, background: error ? 'var(--danger-bg)' : dragActive ? 'var(--soft-bg)' : 'transparent', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '4px', cursor: disabled ? 'not-allowed' : 'pointer', boxSizing: 'border-box', opacity: disabled ? .5 : 1, transition: 'var(--t-hover)', ...style }">
@@ -14,7 +14,9 @@ export function MultiSelect({ options = [], value = [], placeholder = "Select...
14
14
  const shown = value.slice(0, maxTags), extra = value.length - shown.length;
15
15
  return (
16
16
  <div ref={root} data-size={size} style={{ position: "relative", width, ...style }}>
17
- <div data-field={disabled ? undefined : ""} onClick={() => !disabled && setOpen(!open)} style={{ minHeight: "var(--control-h)", borderRadius: "var(--radius-md)", border: `1px solid ${open ? "var(--focus-border)" : "var(--field-border, var(--border-control))"}`, boxShadow: open ? "0 0 0 2px var(--focus-ring)" : "none", background: "var(--bg-surface)", padding: "calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) + var(--control-icon) * 2 + 8px) calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) - 4px)", boxSizing: "border-box", display: "flex", alignItems: "center", flexWrap: "wrap", gap: 4, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .5 : 1, position: "relative" }}>
17
+ <div data-field={disabled ? undefined : ""} role="combobox" aria-haspopup="listbox" aria-expanded={open} aria-disabled={disabled || undefined} tabIndex={disabled ? -1 : 0}
18
+ onKeyDown={(e) => { if (disabled) return; if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { e.preventDefault(); setOpen(true); } else if (e.key === "Escape") setOpen(false); }}
19
+ onClick={() => !disabled && setOpen(!open)} style={{ minHeight: "var(--control-h)", borderRadius: "var(--radius-md)", border: `1px solid ${open ? "var(--focus-border)" : "var(--field-border, var(--border-control))"}`, boxShadow: open ? "0 0 0 2px var(--focus-ring)" : "none", background: "var(--bg-surface)", padding: "calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) + var(--control-icon) * 2 + 8px) calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) - 4px)", boxSizing: "border-box", display: "flex", alignItems: "center", flexWrap: "wrap", gap: 4, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .5 : 1, position: "relative" }}>
18
20
  {value.length === 0 && <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: "var(--text-muted)", paddingLeft: 4 }}>{placeholder}</span>}
19
21
  {shown.map(v => <Tag key={v} removable onRemove={() => toggle(v)}>{v}</Tag>)}
20
22
  {extra > 0 && <Tag>+{extra}</Tag>}
@@ -24,9 +26,9 @@ export function MultiSelect({ options = [], value = [], placeholder = "Select...
24
26
  </span>
25
27
  </div>
26
28
  {open && <div style={{ position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: "var(--z-menu)", background: "var(--bg-surface)", border: "1px solid var(--border-card)", borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-menu)", padding: 4, maxHeight: 208, overflowY: "auto" }}>
27
- {options.map(o => <div key={o} role="option" aria-selected={value.includes(o)} tabIndex={0} onClick={() => toggle(o)} style={{ display: "flex", alignItems: "center", gap: 8, height: "var(--control-h)", padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-sm)", cursor: "pointer", background: "transparent" }}
29
+ {options.map(o => <div key={o} role="option" aria-selected={value.includes(o)} tabIndex={0} onClick={() => toggle(o)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(o); } else if (e.key === "Escape") setOpen(false); }} style={{ display: "flex", alignItems: "center", gap: 8, height: "var(--control-h)", padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-sm)", cursor: "pointer", background: "transparent" }}
28
30
  onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-hover)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}>
29
- <Checkbox checked={value.includes(o)} onChange={() => toggle(o)} />
31
+ <Checkbox checked={value.includes(o)} tabIndex={-1} />
30
32
  <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", letterSpacing: "var(--control-tracking)", color: "var(--text-heading)" }}>{o}</span>
31
33
  </div>)}
32
34
  </div>}
@@ -14,10 +14,11 @@ const extra = computed(() => props.modelValue.length - shown.value.length)
14
14
  function onDoc(e) { if (root.value && !root.value.contains(e.target)) open.value = false }
15
15
  onMounted(() => document.addEventListener('mousedown', onDoc))
16
16
  onUnmounted(() => document.removeEventListener('mousedown', onDoc))
17
+ function onFieldKey(e) { if (props.disabled) return; if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); open.value = true } else if (e.key === 'Escape') open.value = false }
17
18
  </script>
18
19
  <template>
19
20
  <div ref="root" :data-size="size" :style="{ position: 'relative', width: typeof width === 'number' ? width + 'px' : width, ...style }">
20
- <div :data-field="disabled ? undefined : ''" @click="!disabled && (open = !open)" :style="{ minHeight: 'var(--control-h)', borderRadius: 'var(--radius-md)', border: `1px solid ${open ? 'var(--focus-border)' : 'var(--field-border, var(--border-control))'}`, boxShadow: open ? '0 0 0 2px var(--focus-ring)' : 'none', background: 'var(--bg-surface)', padding: 'calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) + var(--control-icon) * 2 + 8px) calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) - 4px)', boxSizing: 'border-box', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '4px', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .5 : 1, position: 'relative' }">
21
+ <div :data-field="disabled ? undefined : ''" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-disabled="disabled || undefined" :tabindex="disabled ? -1 : 0" @keydown="onFieldKey" @click="!disabled && (open = !open)" :style="{ minHeight: 'var(--control-h)', borderRadius: 'var(--radius-md)', border: `1px solid ${open ? 'var(--focus-border)' : 'var(--field-border, var(--border-control))'}`, boxShadow: open ? '0 0 0 2px var(--focus-ring)' : 'none', background: 'var(--bg-surface)', padding: 'calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) + var(--control-icon) * 2 + 8px) calc((var(--control-h) - var(--tag-h) - 2px) / 2) calc(var(--control-px) - 4px)', boxSizing: 'border-box', display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '4px', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .5 : 1, position: 'relative' }">
21
22
  <span v-if="modelValue.length === 0" style="font-family:var(--font-sans);font-size:var(--control-text);line-height:var(--control-leading);letter-spacing:var(--control-tracking);color:var(--text-muted);padding-left:4px">{{ placeholder }}</span>
22
23
  <Tag v-for="v in shown" :key="v" removable @remove="toggle(v)">{{ v }}</Tag>
23
24
  <Tag v-if="extra > 0">+{{ extra }}</Tag>
@@ -27,8 +28,8 @@ onUnmounted(() => document.removeEventListener('mousedown', onDoc))
27
28
  </span>
28
29
  </div>
29
30
  <div v-if="open" style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:var(--z-menu);background:var(--bg-surface);border:1px solid var(--border-card);border-radius:var(--radius-md);box-shadow:var(--shadow-menu);padding:4px;max-height:208px;overflow-y:auto">
30
- <div v-for="o in options" :key="o" role="option" :aria-selected="modelValue.includes(o)" tabindex="0" @click="toggle(o)" class="kb-ms-row" style="display:flex;align-items:center;gap:8px;height:var(--control-h);padding:0 10px;box-sizing:border-box;border-radius:var(--radius-sm);cursor:pointer">
31
- <Checkbox :checked="modelValue.includes(o)" @change="toggle(o)" />
31
+ <div v-for="o in options" :key="o" role="option" :aria-selected="modelValue.includes(o)" tabindex="0" @click="toggle(o)" @keydown.enter.prevent="toggle(o)" @keydown.space.prevent="toggle(o)" @keydown.esc="open = false" class="kb-ms-row" style="display:flex;align-items:center;gap:8px;height:var(--control-h);padding:0 10px;box-sizing:border-box;border-radius:var(--radius-sm);cursor:pointer">
32
+ <Checkbox :checked="modelValue.includes(o)" :tabindex="-1" />
32
33
  <span style="font-family:var(--font-sans);font-size:var(--control-text);letter-spacing:var(--control-tracking);color:var(--text-heading)">{{ o }}</span>
33
34
  </div>
34
35
  </div>
@@ -12,7 +12,7 @@ export function Slider({ value = 50, min = 0, max = 100, step = 1, labels, showV
12
12
  <div ref={track} data-thumb={disabled ? undefined : ""} onMouseDown={disabled ? undefined : (e) => { setDrag(true); set(e.clientX); }} style={{ position: "relative", height: 20, display: "flex", alignItems: "center", cursor: disabled ? "not-allowed" : "pointer" }}>
13
13
  <div style={{ position: "absolute", left: 0, right: 0, height: 6, borderRadius: "var(--radius-full)", background: "var(--track)" }}></div>
14
14
  <div style={{ position: "absolute", left: 0, width: pct + "%", height: 6, borderRadius: "var(--radius-full)", background: "var(--action-primary)" }}></div>
15
- <span style={{ position: "absolute", left: pct + "%", transform: "translateX(-50%)", width: 16, height: 16, borderRadius: "var(--radius-full)", background: "var(--bg-surface)", border: "2px solid var(--action-primary)", boxSizing: "border-box", boxShadow: drag ? "0 0 0 4px var(--focus-ring), 0 1px 2px rgba(0,0,0,.15)" : "var(--thumb-ring, 0 1px 2px rgba(0,0,0,.15))", transition: "box-shadow var(--dur-fast) var(--ease-out)" }}></span>
15
+ <span style={{ position: "absolute", left: pct + "%", transform: "translateX(-50%)", width: 16, height: 16, borderRadius: "var(--radius-full)", background: "var(--bg-surface)", border: "2px solid var(--action-primary)", boxSizing: "border-box", boxShadow: drag ? "0 0 0 4px var(--focus-ring), var(--shadow-thumb)" : "var(--thumb-ring, var(--shadow-thumb))", transition: "box-shadow var(--dur-fast) var(--ease-out)" }}></span>
16
16
  {(drag || showValue) && <span style={{ position: "absolute", left: pct + "%", transform: "translateX(-50%)", bottom: 22, background: "var(--tooltip-bg)", color: "var(--tooltip-fg)", borderRadius: "var(--radius-sm)", padding: "2px 6px", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", whiteSpace: "nowrap", pointerEvents: "none" }}>{value}{unit}</span>}
17
17
  </div>
18
18
  {labels && <div style={{ display: "flex", justifyContent: "space-between", paddingTop: 4, fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", color: "var(--text-muted)" }}><span>{min}{unit}</span><span>{max}{unit}</span></div>}
@@ -15,7 +15,7 @@ onUnmounted(up)
15
15
  <div ref="track" :data-thumb="disabled ? undefined : ''" @mousedown="!disabled && (drag = true, set($event.clientX))" :style="{ position: 'relative', height: '20px', display: 'flex', alignItems: 'center', cursor: disabled ? 'not-allowed' : 'pointer' }">
16
16
  <div style="position:absolute;left:0;right:0;height:6px;border-radius:var(--radius-full);background:var(--track)" />
17
17
  <div :style="{ position: 'absolute', left: 0, width: pct + '%', height: '6px', borderRadius: 'var(--radius-full)', background: 'var(--action-primary)' }" />
18
- <span :style="{ position: 'absolute', left: pct + '%', transform: 'translateX(-50%)', width: '16px', height: '16px', borderRadius: 'var(--radius-full)', background: 'var(--bg-surface)', border: '2px solid var(--action-primary)', boxSizing: 'border-box', boxShadow: drag ? '0 0 0 4px var(--focus-ring), 0 1px 2px rgba(0,0,0,.15)' : 'var(--thumb-ring, 0 1px 2px rgba(0,0,0,.15))', transition: 'box-shadow var(--dur-fast) var(--ease-out)' }" />
18
+ <span :style="{ position: 'absolute', left: pct + '%', transform: 'translateX(-50%)', width: '16px', height: '16px', borderRadius: 'var(--radius-full)', background: 'var(--bg-surface)', border: '2px solid var(--action-primary)', boxSizing: 'border-box', boxShadow: drag ? '0 0 0 4px var(--focus-ring), var(--shadow-thumb)' : 'var(--thumb-ring, var(--shadow-thumb))', transition: 'box-shadow var(--dur-fast) var(--ease-out)' }" />
19
19
  <span v-if="drag || showValue" :style="{ position: 'absolute', left: pct + '%', transform: 'translateX(-50%)', bottom: '22px', background: 'var(--tooltip-bg)', color: 'var(--tooltip-fg)', borderRadius: 'var(--radius-sm)', padding: '2px 6px', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', whiteSpace: 'nowrap', pointerEvents: 'none' }">{{ modelValue }}{{ unit }}</span>
20
20
  </div>
21
21
  <div v-if="labels" style="display:flex;justify-content:space-between;padding-top:4px;font-family:var(--font-sans);font-size:var(--text-xs);color:var(--text-muted)"><span>{{ min }}{{ unit }}</span><span>{{ max }}{{ unit }}</span></div>