@keyfunc/keybricks-ds 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/DEPLOY.md +4 -4
  2. package/INTEGRATION.md +20 -1
  3. package/_adherence.oxlintrc.json +10 -4
  4. package/_ds_bundle.js +20559 -0
  5. package/_src/icon-index.js +1 -0
  6. package/_src/unicons-index.js +1 -0
  7. package/_src/usage-code.js +737 -0
  8. package/changelog.js +12 -0
  9. package/components/buttons/BUTTON_MIGRATION.md +111 -0
  10. package/components/buttons/Button.vue +59 -12
  11. package/components/buttons/Button.vue.d.ts +44 -0
  12. package/components/charts/BarList.jsx +1 -1
  13. package/components/charts/BarList.vue +8 -3
  14. package/components/charts/Ribbon3D.d.ts +2 -0
  15. package/components/charts/Scatter3D.d.ts +4 -0
  16. package/components/charts/SunburstChart.jsx +6 -1
  17. package/components/charts/SunburstChart.vue +7 -1
  18. package/components/charts/Surface3D.d.ts +2 -0
  19. package/components/charts/WorldMap.jsx +6 -1
  20. package/components/charts/WorldMap.vue +7 -1
  21. package/components/data/Table.jsx +1 -1
  22. package/components/data/Table.vue +1 -1
  23. package/components/display/ProgressBar.jsx +1 -1
  24. package/components/display/ProgressBar.vue +1 -1
  25. package/components/feedback/Accordion.jsx +1 -1
  26. package/components/feedback/Accordion.vue +1 -1
  27. package/components/feedback/ContextMenu.jsx +3 -3
  28. package/components/feedback/ContextMenu.vue +4 -4
  29. package/components/feedback/Drawer.jsx +3 -3
  30. package/components/feedback/Drawer.vue +3 -3
  31. package/components/feedback/MESSAGE_SERVICES.md +71 -0
  32. package/components/feedback/Toast.vue +5 -5
  33. package/components/feedback/_internal/MessageBoxView.vue +237 -0
  34. package/components/feedback/_internal/MessageView.vue +73 -0
  35. package/components/feedback/_internal/service-utils.js +46 -0
  36. package/components/feedback/message-box.d.ts +69 -0
  37. package/components/feedback/message-box.js +92 -0
  38. package/components/feedback/message.d.ts +37 -0
  39. package/components/feedback/message.js +68 -0
  40. package/components/forms/Checkbox.d.ts +2 -0
  41. package/components/forms/Checkbox.jsx +9 -4
  42. package/components/forms/Checkbox.vue +6 -2
  43. package/components/forms/DatePicker.jsx +3 -1
  44. package/components/forms/DatePicker.vue +2 -1
  45. package/components/forms/Dropzone.jsx +1 -1
  46. package/components/forms/Dropzone.vue +1 -1
  47. package/components/forms/MultiSelect.jsx +5 -3
  48. package/components/forms/MultiSelect.vue +4 -3
  49. package/components/forms/Slider.jsx +1 -1
  50. package/components/forms/Slider.vue +1 -1
  51. package/components/forms/Switch.jsx +4 -4
  52. package/components/forms/Switch.vue +4 -4
  53. package/components/navigation/Sidebar.jsx +2 -2
  54. package/components/navigation/Sidebar.vue +2 -2
  55. package/components/navigation/TopBar.jsx +1 -1
  56. package/components/navigation/TopBar.vue +1 -1
  57. package/components/navigation/Tree.jsx +2 -2
  58. package/components/navigation/Tree.vue +8 -5
  59. package/components/pipeline/DataPreview.jsx +1 -1
  60. package/components/pipeline/DataPreview.vue +1 -1
  61. package/components/pipeline/NodeCard.jsx +1 -1
  62. package/components/pipeline/NodeCard.vue +1 -1
  63. package/components/pipeline/PaletteItem.vue +1 -1
  64. package/components/pipeline/PalettePanel.jsx +3 -3
  65. package/components/pipeline/PalettePanel.vue +2 -2
  66. package/components/tabs/SegmentedControl.jsx +2 -2
  67. package/components/tabs/SegmentedControl.vue +2 -2
  68. package/index.html +1 -1
  69. package/package.json +12 -2
  70. package/tokens/dark.css +1 -1
  71. package/tokens/interaction.css +4 -0
  72. package/tokens/layout.css +3 -0
@@ -0,0 +1,37 @@
1
+ import type { AppContext, Component, VNode } from 'vue';
2
+
3
+ export type MessageType = 'success' | 'info' | 'warning' | 'error';
4
+ export type MessageContent = string | VNode | (() => VNode);
5
+ export interface MessageHandle { close(): void; }
6
+ export interface MessageOptions {
7
+ message?: MessageContent;
8
+ type?: MessageType;
9
+ icon?: string | Component | VNode;
10
+ /** 毫秒;0 表示不自动关闭,默认 3000。 */
11
+ duration?: number;
12
+ showClose?: boolean;
13
+ closeLabel?: string;
14
+ offset?: number;
15
+ zIndex?: number;
16
+ customClass?: string;
17
+ center?: boolean;
18
+ plain?: boolean;
19
+ grouping?: boolean;
20
+ /** 仅允许可信且已由应用净化的 HTML。 */
21
+ dangerouslyUseHTMLString?: boolean;
22
+ appendTo?: string | HTMLElement;
23
+ onClose?: (handle: MessageHandle) => void;
24
+ }
25
+ export type MessageParams = MessageContent | MessageOptions;
26
+ export interface MessageService {
27
+ (options?: MessageParams, appContext?: AppContext | null): MessageHandle;
28
+ success(options: MessageParams, appContext?: AppContext | null): MessageHandle;
29
+ info(options: MessageParams, appContext?: AppContext | null): MessageHandle;
30
+ warning(options: MessageParams, appContext?: AppContext | null): MessageHandle;
31
+ error(options: MessageParams, appContext?: AppContext | null): MessageHandle;
32
+ closeAll(type?: MessageType): void;
33
+ /** 在调用时读取;可随应用语言切换更新。 */
34
+ defaults: Partial<MessageOptions>;
35
+ }
36
+ export declare const Message: MessageService;
37
+ export default Message;
@@ -0,0 +1,68 @@
1
+ import { getCurrentInstance, isVNode, reactive } from 'vue'
2
+ import MessageView from './_internal/MessageView.vue'
3
+ import { mountService, resolveTarget } from './_internal/service-utils.js'
4
+
5
+ const instances = []
6
+ let seed = 0
7
+ const normalize = value => typeof value === 'string' || typeof value === 'function' || isVNode(value)
8
+ ? { message: value } : { ...value }
9
+
10
+ /** Vue 消息服务;样式由 Toast 提供,业务错误脱敏仍由应用处理。 */
11
+ export function Message(value = {}, appContext) {
12
+ if (typeof document === 'undefined') return { close() {} }
13
+ const options = { type: 'info', duration: 3000, offset: 16, ...Message.defaults, ...normalize(value) }
14
+ const target = resolveTarget(options)
15
+ if (options.grouping) {
16
+ const existing = instances.find(item => item.target === target && item.options.message === options.message)
17
+ if (existing) {
18
+ existing.options.repeatNum++
19
+ existing.options.type = options.type
20
+ existing.options.restart++
21
+ return existing.handle
22
+ }
23
+ }
24
+ const state = reactive({ ...options, id: `kb-message-${++seed}`, repeatNum: 1, restart: 0, height: 0, top: options.offset })
25
+ let dispose
26
+ let closed = false
27
+ const handle = { close() {
28
+ if (closed) return
29
+ closed = true
30
+ instances.splice(instances.indexOf(entry), 1)
31
+ dispose?.()
32
+ reflow()
33
+ options.onClose?.(handle)
34
+ } }
35
+ const entry = { options: state, handle, target }
36
+ function reflow() {
37
+ const tops = new Map()
38
+ for (const item of instances) {
39
+ const target = item.target
40
+ item.options.top = Math.max(Number(item.options.offset) || 0, tops.get(target) || 0)
41
+ tops.set(target, item.options.top + item.options.height + 16)
42
+ }
43
+ }
44
+ instances.push(entry)
45
+ try {
46
+ dispose = mountService(MessageView, {
47
+ options: state, onClose: handle.close,
48
+ onHeight: height => { state.height = height; reflow() },
49
+ }, options, appContext || getCurrentInstance()?.appContext)
50
+ } catch (error) {
51
+ instances.splice(instances.indexOf(entry), 1)
52
+ reflow()
53
+ throw error
54
+ }
55
+ reflow()
56
+ return handle
57
+ }
58
+
59
+ for (const type of ['success', 'info', 'warning', 'error']) {
60
+ Message[type] = (value, appContext) => Message({ ...normalize(value), type }, appContext)
61
+ }
62
+ Message.closeAll = type => {
63
+ for (const entry of [...instances]) {
64
+ if (!type || entry.options.type === type) entry.handle.close()
65
+ }
66
+ }
67
+ export default Message
68
+ Message.defaults = {}
@@ -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>
@@ -62,18 +62,18 @@ export function Switch({ checked = false, onChange, disabled, size, tone = "prim
62
62
  const glyphText = pick(knobText, checked);
63
63
  const knobX = checked ? `calc(100% - ${H} + 2px)` : 2;
64
64
 
65
- const knob = <span aria-hidden="true" style={{ position: "absolute", top: 2, left: knobX, width: KNOB, height: KNOB, borderRadius: "var(--radius-full)", background: "var(--bg-surface)", boxShadow: "0 1px 2px rgba(0,0,0,.18)", transition: "left 160ms ease-out", display: "flex", alignItems: "center", justifyContent: "center", color: track, fontSize: TEXT, lineHeight: 1, fontWeight: "var(--weight-semibold)", fontFamily: "var(--font-sans)", overflow: "hidden" }}>
65
+ const knob = <span aria-hidden="true" style={{ position: "absolute", top: 2, left: knobX, width: KNOB, height: KNOB, borderRadius: "var(--radius-full)", background: "var(--bg-surface)", boxShadow: "var(--shadow-thumb)", transition: "left var(--dur) var(--ease-out)", display: "flex", alignItems: "center", justifyContent: "center", color: track, fontSize: TEXT, lineHeight: 1, fontWeight: "var(--weight-semibold)", fontFamily: "var(--font-sans)", overflow: "hidden" }}>
66
66
  {loading
67
67
  ? <span style={{ width: GLYPH, height: GLYPH, borderRadius: "50%", border: `1.5px solid ${track}`, borderRightColor: "transparent", animation: "kb-spin 720ms linear infinite" }}></span>
68
68
  : glyphIcon ? <Icon name={glyphIcon} size={10} strokeWidth={2.5} style={{ width: GLYPH, height: GLYPH }} />
69
69
  : glyphText ? <span>{String(glyphText).slice(0, 1)}</span> : null}
70
70
  </span>;
71
71
 
72
- const insideStyle = (side) => ({ position: "absolute", top: 0, bottom: 0, [side]: 6, display: "flex", alignItems: "center", fontSize: TEXT, lineHeight: 1, fontWeight: "var(--weight-semibold)", fontFamily: "var(--font-sans)", letterSpacing: ".2px", color: "var(--on-accent)", transition: "opacity 160ms ease-out", pointerEvents: "none", whiteSpace: "nowrap" });
72
+ const insideStyle = (side) => ({ position: "absolute", top: 0, bottom: 0, [side]: 6, display: "flex", alignItems: "center", fontSize: TEXT, lineHeight: 1, fontWeight: "var(--weight-semibold)", fontFamily: "var(--font-sans)", letterSpacing: ".2px", color: "var(--on-accent)", transition: "opacity var(--dur) var(--ease-out)", pointerEvents: "none", whiteSpace: "nowrap" });
73
73
 
74
74
  const pill = <span role="switch" aria-checked={checked} aria-busy={loading || undefined} aria-disabled={disabled || undefined} tabIndex={inert ? -1 : 0} onClick={flip}
75
75
  onKeyDown={e => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); flip(); } }}
76
- style={{ position: "relative", display: "inline-block", width: w, height: H, borderRadius: "var(--radius-full)", "--sw-track": track, background: "var(--sw-bg, var(--sw-track))", transition: "background 160ms ease-out", cursor: inert ? (loading ? "progress" : "not-allowed") : "pointer", flexShrink: 0, verticalAlign: "middle", outline: "none" }}>
76
+ style={{ position: "relative", display: "inline-block", width: w, height: H, borderRadius: "var(--radius-full)", "--sw-track": track, background: "var(--sw-bg, var(--sw-track))", transition: "background var(--dur) var(--ease-out)", cursor: inert ? (loading ? "progress" : "not-allowed") : "pointer", flexShrink: 0, verticalAlign: "middle", outline: "none" }}>
77
77
  {hasInside && <span style={{ ...insideStyle("left"), opacity: checked ? 1 : 0 }}>{insideOn}</span>}
78
78
  {hasInside && <span style={{ ...insideStyle("right"), opacity: checked ? 0 : 1 }}>{insideOff}</span>}
79
79
  {knob}
@@ -83,7 +83,7 @@ export function Switch({ checked = false, onChange, disabled, size, tone = "prim
83
83
  if (single) return <span data-size={size} data-sw="" style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", gap: "var(--control-gap)", opacity: disabled ? .5 : 1, ...style }}>{pill}<span onClick={flip} style={{ ...capBase, fontWeight: "var(--weight-regular)", color: "var(--text-body)" }}>{label}</span></span>;
84
84
  if (labelOn == null && labelOff == null) return <span data-size={size} style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", opacity: disabled ? .5 : 1, ...style }}>{pill}</span>;
85
85
 
86
- const cap = (text, active) => text == null ? null : <span style={{ ...capBase, fontWeight: active ? "var(--weight-medium)" : "var(--weight-regular)", color: active ? "var(--text-heading)" : "var(--text-muted)", transition: "color 160ms ease-out" }} onClick={flip}>{text}</span>;
86
+ const cap = (text, active) => text == null ? null : <span style={{ ...capBase, fontWeight: active ? "var(--weight-medium)" : "var(--weight-regular)", color: active ? "var(--text-heading)" : "var(--text-muted)", transition: "color var(--dur) var(--ease-out)" }} onClick={flip}>{text}</span>;
87
87
  return <span data-size={size} data-sw="" style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", gap: "var(--control-gap)", opacity: disabled ? .5 : 1, ...style }}>
88
88
  {cap(labelOff, !checked)}{pill}{cap(labelOn, checked)}
89
89
  </span>;
@@ -44,17 +44,17 @@ async function flip() {
44
44
  }
45
45
  emit('update:checked', next); emit('change', next)
46
46
  }
47
- const insideStyle = (side, on) => ({ position: 'absolute', top: 0, bottom: 0, [side]: '6px', display: 'flex', alignItems: 'center', fontSize: TEXT, lineHeight: 1, fontWeight: 'var(--weight-semibold)', fontFamily: 'var(--font-sans)', letterSpacing: '.2px', color: 'var(--on-accent)', transition: 'opacity 160ms ease-out', pointerEvents: 'none', whiteSpace: 'nowrap', opacity: on === props.checked ? 1 : 0 })
48
- const cap = (active) => ({ fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', fontFamily: 'var(--font-sans)', fontWeight: active ? 'var(--weight-medium)' : 'var(--weight-regular)', color: active ? 'var(--text-heading)' : 'var(--text-muted)', transition: 'color 160ms ease-out', cursor: inert.value ? 'default' : 'pointer', userSelect: 'none' })
47
+ const insideStyle = (side, on) => ({ position: 'absolute', top: 0, bottom: 0, [side]: '6px', display: 'flex', alignItems: 'center', fontSize: TEXT, lineHeight: 1, fontWeight: 'var(--weight-semibold)', fontFamily: 'var(--font-sans)', letterSpacing: '.2px', color: 'var(--on-accent)', transition: 'opacity var(--dur) var(--ease-out)', pointerEvents: 'none', whiteSpace: 'nowrap', opacity: on === props.checked ? 1 : 0 })
48
+ const cap = (active) => ({ fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', fontFamily: 'var(--font-sans)', fontWeight: active ? 'var(--weight-medium)' : 'var(--weight-regular)', color: active ? 'var(--text-heading)' : 'var(--text-muted)', transition: 'color var(--dur) var(--ease-out)', cursor: inert.value ? 'default' : 'pointer', userSelect: 'none' })
49
49
  </script>
50
50
  <template>
51
51
  <span :data-size="size" data-sw="" :style="{ display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', gap: 'var(--control-gap)', opacity: disabled ? .5 : 1, ...style }">
52
52
  <span v-if="labelOff != null" :style="cap(!checked)" @click="flip">{{ labelOff }}</span>
53
53
  <span role="switch" :aria-checked="checked" :aria-busy="isLoading || undefined" :aria-disabled="disabled || undefined" :tabindex="inert ? -1 : 0" @click="flip" @keydown.space.prevent="flip" @keydown.enter.prevent="flip"
54
- :style="{ position: 'relative', display: 'inline-block', width: width, height: H, borderRadius: 'var(--radius-full)', '--sw-track': track, background: 'var(--sw-bg, var(--sw-track))', transition: 'background 160ms ease-out', cursor: inert ? (isLoading ? 'progress' : 'not-allowed') : 'pointer', flexShrink: 0, verticalAlign: 'middle', outline: 'none' }">
54
+ :style="{ position: 'relative', display: 'inline-block', width: width, height: H, borderRadius: 'var(--radius-full)', '--sw-track': track, background: 'var(--sw-bg, var(--sw-track))', transition: 'background var(--dur) var(--ease-out)', cursor: inert ? (isLoading ? 'progress' : 'not-allowed') : 'pointer', flexShrink: 0, verticalAlign: 'middle', outline: 'none' }">
55
55
  <span v-if="hasInside" :style="insideStyle('left', true)">{{ inOn }}</span>
56
56
  <span v-if="hasInside" :style="insideStyle('right', false)">{{ inOff }}</span>
57
- <span aria-hidden="true" :style="{ position: 'absolute', top: '2px', left: knobX, width: KNOB, height: KNOB, borderRadius: 'var(--radius-full)', background: 'var(--bg-surface)', boxShadow: '0 1px 2px rgba(0,0,0,.18)', transition: 'left 160ms ease-out', display: 'flex', alignItems: 'center', justifyContent: 'center', color: track, fontSize: TEXT, lineHeight: 1, fontWeight: 'var(--weight-semibold)', fontFamily: 'var(--font-sans)', overflow: 'hidden' }">
57
+ <span aria-hidden="true" :style="{ position: 'absolute', top: '2px', left: knobX, width: KNOB, height: KNOB, borderRadius: 'var(--radius-full)', background: 'var(--bg-surface)', boxShadow: 'var(--shadow-thumb)', transition: 'left var(--dur) var(--ease-out)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: track, fontSize: TEXT, lineHeight: 1, fontWeight: 'var(--weight-semibold)', fontFamily: 'var(--font-sans)', overflow: 'hidden' }">
58
58
  <span v-if="isLoading" :style="{ width: GLYPH, height: GLYPH, borderRadius: '50%', border: '1.5px solid ' + track, borderRightColor: 'transparent', animation: 'kb-spin 720ms linear infinite' }"></span>
59
59
  <Icon v-else-if="glyphIcon" :name="glyphIcon" :size="10" :stroke-width="2.5" :style="{ width: GLYPH, height: GLYPH }" />
60
60
  <span v-else-if="glyphText">{{ glyphText }}</span>
@@ -36,10 +36,10 @@ export function Sidebar({ items = KEYBRICKS_NAV, footerItems = KEYBRICKS_NAV_FOO
36
36
  </div>
37
37
  ) : <NavItem key={it.label} icon={it.icon} label={it.label} collapsed={collapsed} trailing={it.trailing} active={active === it.label} onClick={() => pick(it.label)} />);
38
38
  return (
39
- <aside data-theme={t.theme} style={{ ...t.vars, width: collapsed ? "var(--rail-w)" : "var(--sidebar-w)", height, background: "var(--sb-bg)", borderRight: "1px solid var(--sb-border)", color: "var(--sb-text)", display: "flex", flexDirection: "column", boxSizing: "border-box", flexShrink: 0, transition: "width .15s ease", ...style }}>
39
+ <aside data-theme={t.theme} style={{ ...t.vars, width: collapsed ? "var(--rail-w)" : "var(--sidebar-w)", height, background: "var(--sb-bg)", borderRight: "1px solid var(--sb-border)", color: "var(--sb-text)", display: "flex", flexDirection: "column", boxSizing: "border-box", flexShrink: 0, transition: "width var(--dur-fast) var(--ease-out)", ...style }}>
40
40
  <div style={{ height: "var(--topbar-h)", display: "flex", alignItems: "center", gap: 10, padding: "0 16px", boxSizing: "border-box", justifyContent: collapsed ? "center" : "flex-start", flexShrink: 0 }}>
41
41
  <img src={logo} alt="" style={{ width: 24, height: 24, objectFit: "contain", flexShrink: 0 }} />
42
- {!collapsed ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", fontWeight: "var(--weight-semibold)", color: "var(--sb-brand)", whiteSpace: "nowrap" }}>{brand}</span> : null}
42
+ {!collapsed ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", fontWeight: "var(--weight-semibold)", color: "var(--sb-brand)", whiteSpace: "nowrap", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" }}>{brand}</span> : null}
43
43
  </div>
44
44
  <nav style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: 8, display: "flex", flexDirection: "column", gap: 2 }}>
45
45
  {onToggleCollapse ? <NavItem icon={collapsed ? "panel-left" : "chevron-left"} label={collapsed ? L.expand : L.collapse} collapsed={collapsed} onClick={onToggleCollapse} /> : null}
@@ -21,10 +21,10 @@ const groups = computed(() => [{ id: 'main', list: props.items }, { id: 'foot',
21
21
  const versionText = computed(() => props.collapsed && props.version ? 'v' + String(props.version).split(' ').pop().replace(/^v/i, '') : props.version)
22
22
  </script>
23
23
  <template>
24
- <aside :data-theme="t.theme" :style="{ ...t.vars, width: collapsed ? 'var(--rail-w)' : 'var(--sidebar-w)', height, background: 'var(--sb-bg)', borderRight: '1px solid var(--sb-border)', color: 'var(--sb-text)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', flexShrink: 0, transition: 'width .15s ease', ...style }">
24
+ <aside :data-theme="t.theme" :style="{ ...t.vars, width: collapsed ? 'var(--rail-w)' : 'var(--sidebar-w)', height, background: 'var(--sb-bg)', borderRight: '1px solid var(--sb-border)', color: 'var(--sb-text)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', flexShrink: 0, transition: 'width var(--dur-fast) var(--ease-out)', ...style }">
25
25
  <div :style="{ height: 'var(--topbar-h)', display: 'flex', alignItems: 'center', gap: '10px', padding: '0 16px', boxSizing: 'border-box', justifyContent: collapsed ? 'center' : 'flex-start', flexShrink: 0 }">
26
26
  <img :src="logo" alt="" style="width:24px;height:24px;object-fit:contain;flex-shrink:0" />
27
- <span v-if="!collapsed" style="font-family:var(--font-sans);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);font-weight:var(--weight-semibold);color:var(--sb-brand);white-space:nowrap">{{ brand }}</span>
27
+ <span v-if="!collapsed" style="font-family:var(--font-sans);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);font-weight:var(--weight-semibold);color:var(--sb-brand);white-space:nowrap;min-width:0;overflow:hidden;text-overflow:ellipsis">{{ brand }}</span>
28
28
  </div>
29
29
  <template v-for="g in groups" :key="g.id">
30
30
  <component :is="g.id === 'main' ? 'nav' : 'div'" :style="g.id === 'main' ? { flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px', display: 'flex', flexDirection: 'column', gap: '2px' } : { padding: '8px', display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }">
@@ -56,7 +56,7 @@ export function TopBar({ placeholder = "Search documents, workflows, data...", o
56
56
  <IconButton icon="circle-help" label={helpLabel} title={helpLabel} onClick={onHelp} />
57
57
  {user ? <div role="button" tabIndex={0} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen(!open); } }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
58
58
  style={{ height: 32, display: "flex", alignItems: "center", gap: 8, padding: "0 8px 0 10px", borderRadius: "var(--radius-md)", cursor: "pointer", userSelect: "none", background: open ? "var(--bg-active)" : hover ? "var(--bg-hover)" : "transparent", transition: "var(--t-hover)" }}>
59
- <span style={{ ...TXT, fontWeight: "var(--weight-medium)", color: "var(--text-heading)", whiteSpace: "nowrap" }}>{user.name}</span>
59
+ <span style={{ ...TXT, fontWeight: "var(--weight-medium)", color: "var(--text-heading)", whiteSpace: "nowrap", minWidth: 0, maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis" }}>{user.name}</span>
60
60
  <span style={{ display: "inline-flex", color: "var(--text-muted)" }}><Icon name="chevron-down" size={16} /></span>
61
61
  </div> : null}
62
62
  </>)}
@@ -27,7 +27,7 @@ function key(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault();
27
27
  <IconButton icon="circle-help" :label="helpLabel" :title="helpLabel" @click="emit('help')" />
28
28
  <div v-if="user" role="button" tabindex="0" aria-haspopup="menu" :aria-expanded="open" @click="open = !open" @keydown="key" @mouseenter="hover = true" @mouseleave="hover = false"
29
29
  :style="{ height: '32px', display: 'flex', alignItems: 'center', gap: '8px', padding: '0 8px 0 10px', borderRadius: 'var(--radius-md)', cursor: 'pointer', userSelect: 'none', background: open ? 'var(--bg-active)' : hover ? 'var(--bg-hover)' : 'transparent', transition: 'var(--t-hover)' }">
30
- <span style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-weight:var(--weight-medium);color:var(--text-heading);white-space:nowrap">{{ user.name }}</span>
30
+ <span style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-weight:var(--weight-medium);color:var(--text-heading);white-space:nowrap;min-width:0;max-width:160px;overflow:hidden;text-overflow:ellipsis">{{ user.name }}</span>
31
31
  <span style="display:inline-flex;color:var(--text-muted)"><Icon name="chevron-down" :size="16" /></span>
32
32
  </div>
33
33
  </slot>
@@ -166,7 +166,7 @@ function Row({ item, state, cfg, api }) {
166
166
  {chevron}
167
167
  {cfg.checkable && node.checkable !== false ? (
168
168
  <span onClick={(e) => e.stopPropagation()} style={{ display: "inline-flex", flexShrink: 0 }}>
169
- <Checkbox checked={checked} indeterminate={half} disabled={disabled || node.disableCheckbox} onChange={() => api.check(key)} />
169
+ <Checkbox checked={checked} indeterminate={half} disabled={disabled || node.disableCheckbox} tabIndex={-1} onChange={() => api.check(key)} />
170
170
  </span>
171
171
  ) : null}
172
172
  {cfg.showIcon ? (
@@ -353,7 +353,7 @@ export function Tree({
353
353
  const cfg = { checkable, checkStrictly, multiple, selectable, showIcon, showLine, draggable, searchValue, onContextMenu, rowActions };
354
354
 
355
355
  return (
356
- <div role="tree" aria-multiselectable={multiple || undefined} style={{ display: "flex", flexDirection: "column", gap: showLine ? 0 : 1, ...style }}>
356
+ <div role="tree" aria-multiselectable={multiple || undefined} style={{ display: "flex", flexDirection: "column", gap: showLine ? 0 : 1, minWidth: 0, ...style }}>
357
357
  <style>{"@keyframes kb-tree-spin{to{transform:rotate(360deg)}}"}</style>
358
358
  {rows.length === 0 ? (
359
359
  <div style={{ padding: "10px 8px", fontFamily: "var(--font-sans)", fontSize: "var(--text-base)", color: "var(--text-muted)" }}>No matching items</div>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { ref, computed, watch } from 'vue'
2
+ import { ref, computed, watch, getCurrentInstance } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
4
  import Checkbox from '../forms/Checkbox.vue'
5
5
  // Nested tree view: expand/collapse, single or multiple selection, cascading checkboxes with
@@ -33,7 +33,10 @@ const props = defineProps({
33
33
  reorder: Boolean,
34
34
  loadData: Function, rowActions: Function, style: Object,
35
35
  })
36
- const emit = defineEmits(['select', 'check', 'expand', 'drop'])
36
+ // @context-menu(key, event) — like React's onContextMenu; only a listener suppresses the browser menu.
37
+ const emit = defineEmits(['select', 'check', 'expand', 'drop', 'contextMenu'])
38
+ const treeInst = getCurrentInstance()
39
+ function onCtx(e, key) { if (treeInst && treeInst.vnode.props && treeInst.vnode.props.onContextMenu) { e.preventDefault(); emit('contextMenu', key, e) } }
37
40
  const Cell = (p) => p.v
38
41
  Cell.props = ['v']
39
42
 
@@ -221,7 +224,7 @@ function parts(text) {
221
224
  const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
222
225
  </script>
223
226
  <template>
224
- <div role="tree" :style="{ display: 'flex', flexDirection: 'column', gap: showLine ? 0 : '1px', ...style }">
227
+ <div role="tree" :style="{ display: 'flex', flexDirection: 'column', gap: showLine ? 0 : '1px', minWidth: 0, ...style }">
225
228
  <div v-for="row in rows" :key="row.key" role="treeitem" class="kb-tree-row"
226
229
  :tabindex="focus === row.key || (!focus && row === rows[0]) ? 0 : -1"
227
230
  :aria-expanded="isBranch(row.n) ? openKeys.includes(row.key) : undefined"
@@ -229,7 +232,7 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
229
232
  :draggable="draggable && !row.n.disabled"
230
233
  @dragstart="dragKey = row.key" @dragover="onDragOver($event, row.key)" @dragleave="drop = null"
231
234
  @drop.prevent="onDrop(row.key)" @dragend="dragKey = null; drop = null"
232
- @click="rowClick(row.n)" @keydown="keyDown($event, row)"
235
+ @click="rowClick(row.n)" @contextmenu="onCtx($event, row.key)" @keydown="keyDown($event, row)"
233
236
  :class="{ 'is-disabled': row.n.disabled }"
234
237
  :style="{
235
238
  position: 'relative', minHeight: 'var(--h-tree-item)', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', gap: '6px',
@@ -255,7 +258,7 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
255
258
  <span v-else :style="{ width: showLine ? 0 : '14px', flexShrink: 0 }" />
256
259
  <span v-if="checkable && row.n.checkable !== false" @click.stop style="display:inline-flex;flex-shrink:0">
257
260
  <Checkbox :checked="view.full.has(row.key)" :indeterminate="view.half.has(row.key)"
258
- :disabled="row.n.disabled || row.n.disableCheckbox" @change="check(row.key)" />
261
+ :disabled="row.n.disabled || row.n.disableCheckbox" :tabindex="-1" @change="check(row.key)" />
259
262
  </span>
260
263
  <span v-if="showIcon" :style="{ color: isBranch(row.n) ? 'var(--text-muted)' : docHues[row.n.hue || 'gray'], display: 'inline-flex', flexShrink: 0 }">
261
264
  <Icon :name="row.n.icon || (isBranch(row.n) ? (openKeys.includes(row.key) ? 'folder-open' : 'folder') : 'file-text')" :size="15" />
@@ -7,7 +7,7 @@ import { Table } from "../data/Table.jsx";
7
7
  /** Bottom data-preview drawer — 250px: 40px header (title, Debug Mode, collapse) + dense table. An sm size region: the header's controls are 24px and the table has 32px rows with 12px text. */
8
8
  export function DataPreview({ title = "Data Preview", columns = [], rows = [], debug = false, onDebug, collapsed, onCollapse, size = "sm", style }) {
9
9
  return (
10
- <div data-size={size} style={{ background: "var(--bg-surface)", borderTop: "1px solid var(--border-card)", display: "flex", flexDirection: "column", height: collapsed ? "var(--bar-card-h)" : "var(--drawer-h)", overflow: "hidden", boxSizing: "border-box", flexShrink: 0, transition: "height .15s ease", ...style }}>
10
+ <div data-size={size} style={{ background: "var(--bg-surface)", borderTop: "1px solid var(--border-card)", display: "flex", flexDirection: "column", height: collapsed ? "var(--bar-card-h)" : "var(--drawer-h)", overflow: "hidden", boxSizing: "border-box", flexShrink: 0, transition: "height var(--dur-fast) var(--ease-out)", ...style }}>
11
11
  <div style={{ height: "var(--bar-card-h)", display: "flex", alignItems: "center", gap: "var(--group-gap)", padding: "0 var(--edge-pad)", boxSizing: "border-box", flexShrink: 0 }}>
12
12
  <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", color: "var(--text-heading)" }}>{title}</span>
13
13
  <span style={{ flex: 1 }} />
@@ -7,7 +7,7 @@ defineProps({ title: { type: String, default: 'Data Preview' }, columns: { type:
7
7
  const emit = defineEmits(['update:debug', 'toggleCollapse'])
8
8
  </script>
9
9
  <template>
10
- <div :data-size="size" :style="{ background: 'var(--bg-surface)', borderTop: '1px solid var(--border-card)', display: 'flex', flexDirection: 'column', height: collapsed ? 'var(--bar-card-h)' : 'var(--drawer-h)', overflow: 'hidden', boxSizing: 'border-box', flexShrink: 0, transition: 'height .15s ease', ...style }">
10
+ <div :data-size="size" :style="{ background: 'var(--bg-surface)', borderTop: '1px solid var(--border-card)', display: 'flex', flexDirection: 'column', height: collapsed ? 'var(--bar-card-h)' : 'var(--drawer-h)', overflow: 'hidden', boxSizing: 'border-box', flexShrink: 0, transition: 'height var(--dur-fast) var(--ease-out)', ...style }">
11
11
  <div style="height:var(--bar-card-h);display:flex;align-items:center;gap:var(--group-gap);padding:0 var(--edge-pad);box-sizing:border-box;flex-shrink:0">
12
12
  <span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading)">{{ title }}</span>
13
13
  <span style="flex:1" />
@@ -51,7 +51,7 @@ export function PortLabel({ label = "A", tone = "default", interactive = false,
51
51
  export function NodeCard({ title, subtitle, icon = "table", subtitleIcon, hue = "source", selected, badge, inputs = [], output = true, showMenu = true, onClick, onMenu, onOutputDown, onInputUp, onInputEnter, onInputLeave, outputTone = "default", inputTones = [], style }) {
52
52
  const live = !!(onOutputDown || onInputUp);
53
53
  return (
54
- <div onClick={onClick} style={{ position: "relative", width: 220, height: 90, borderRadius: "var(--radius-node)", background: "var(--bg-surface)", border: `1px solid ${selected ? "var(--selected-border)" : "var(--border-control)"}`, boxShadow: selected ? "var(--shadow-selected)" : "var(--shadow-node)", boxSizing: "border-box", cursor: "pointer", ...style }}>
54
+ <div onClick={onClick} role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} onKeyDown={onClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(e); } } : undefined} style={{ position: "relative", width: 220, height: 90, borderRadius: "var(--radius-node)", background: "var(--bg-surface)", border: `1px solid ${selected ? "var(--selected-border)" : "var(--border-control)"}`, boxShadow: selected ? "var(--shadow-selected)" : "var(--shadow-node)", boxSizing: "border-box", cursor: "pointer", ...style }}>
55
55
  <div style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, height: "100%", boxSizing: "border-box" }}>
56
56
  <span style={{ position: "relative", flexShrink: 0 }}>
57
57
  <IconTile icon={icon} hue={hue} kind="node" />
@@ -29,7 +29,7 @@ const pillBase = { width: '28px', height: '22px', borderRadius: 'var(--radius-fu
29
29
  const tone = i => props.inputTones[i] || 'default'
30
30
  </script>
31
31
  <template>
32
- <div @click="emit('click')" :style="{ position: 'relative', width: '220px', height: '90px', borderRadius: 'var(--radius-node)', background: 'var(--bg-surface)', border: `1px solid ${selected ? 'var(--selected-border)' : 'var(--border-control)'}`, boxShadow: selected ? 'var(--shadow-selected)' : 'var(--shadow-node)', boxSizing: 'border-box', cursor: 'pointer', ...style }">
32
+ <div role="button" tabindex="0" @keydown.enter.prevent="emit('click')" @keydown.space.prevent="emit('click')" @click="emit('click')" :style="{ position: 'relative', width: '220px', height: '90px', borderRadius: 'var(--radius-node)', background: 'var(--bg-surface)', border: `1px solid ${selected ? 'var(--selected-border)' : 'var(--border-control)'}`, boxShadow: selected ? 'var(--shadow-selected)' : 'var(--shadow-node)', boxSizing: 'border-box', cursor: 'pointer', ...style }">
33
33
  <div style="display:flex;align-items:center;gap:12px;padding:16px;height:100%;box-sizing:border-box">
34
34
  <span style="position:relative;flex-shrink:0">
35
35
  <IconTile :icon="icon" :hue="hue" kind="node" />
@@ -8,7 +8,7 @@ const hover = ref(false)
8
8
  </script>
9
9
  <template>
10
10
  <div role="button" tabindex="0" @click="$emit('click')" @mouseenter="hover = true" @mouseleave="hover = false"
11
- :style="{ width: '67.664px', borderRadius: 'var(--radius-lg)', padding: '8px', display: 'flex', flexDirection: 'column', gap: '6px', alignItems: 'center', cursor: 'grab', boxSizing: 'border-box', background: hover ? 'var(--bg-hover)' : 'transparent', transition: 'background .12s ease', ...style }">
11
+ :style="{ width: '67.664px', borderRadius: 'var(--radius-lg)', padding: '8px', display: 'flex', flexDirection: 'column', gap: '6px', alignItems: 'center', cursor: 'grab', boxSizing: 'border-box', background: hover ? 'var(--bg-hover)' : 'transparent', transition: 'background var(--dur-fast) var(--ease-out)', ...style }">
12
12
  <IconTile :icon="icon" :hue="hue" kind="palette" />
13
13
  <span style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);letter-spacing:var(--tracking-xs);color:var(--text-body);text-align:center">{{ label }}</span>
14
14
  </div>