@keyfunc/keybricks-ds 2.3.1 → 2.4.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 (69) hide show
  1. package/DEPLOY.md +10 -4
  2. package/DESIGN.md +44 -1
  3. package/INTEGRATION.md +24 -6
  4. package/_adherence.oxlintrc.json +28 -13
  5. package/assets/logos/datasource/dat.png +0 -0
  6. package/assets/logos/datasource/excel.png +0 -0
  7. package/assets/logos/datasource/ftp.png +0 -0
  8. package/assets/logos/datasource/hierarchy.png +0 -0
  9. package/assets/logos/datasource/hive.png +0 -0
  10. package/assets/logos/datasource/json.png +0 -0
  11. package/assets/logos/datasource/mariadb.png +0 -0
  12. package/assets/logos/datasource/mongodb.png +0 -0
  13. package/assets/logos/datasource/mysql.png +0 -0
  14. package/assets/logos/datasource/odbc.png +0 -0
  15. package/assets/logos/datasource/oracle.png +0 -0
  16. package/assets/logos/datasource/postgresql.png +0 -0
  17. package/assets/logos/datasource/rest-api.png +0 -0
  18. package/assets/logos/datasource/sap-alt.png +0 -0
  19. package/assets/logos/datasource/sap.png +0 -0
  20. package/assets/logos/datasource/soap.png +0 -0
  21. package/assets/logos/datasource/sql-server.png +0 -0
  22. package/components/_cards/app-chrome.card.html +33 -21
  23. package/components/_cards/context-menu.card.html +7 -2
  24. package/components/_cards/icons-0-usage.card.html +88 -0
  25. package/components/_cards/icons-4-datasource.card.html +5 -0
  26. package/components/_cards/segmented-control.card.html +9 -3
  27. package/components/_cards/tabs.card.html +5 -2
  28. package/components/_shared/src-feedback.js +1 -1
  29. package/components/_shared/src-navigation.js +1 -1
  30. package/components/_shared/src-tabs.js +1 -1
  31. package/components/_shared/view-source.js +12 -12
  32. package/components/_vue/index.mjs +4 -1
  33. package/components/feedback/ContextMenu.d.ts +3 -2
  34. package/components/feedback/ContextMenu.jsx +8 -4
  35. package/components/feedback/ContextMenu.vue +9 -6
  36. package/components/feedback/Menu.d.ts +1 -1
  37. package/components/feedback/feedback.prompt.md +1 -1
  38. package/components/index.d.ts +1 -0
  39. package/components/index.js +4 -3
  40. package/components/navigation/SidePanel.d.ts +22 -0
  41. package/components/navigation/SidePanel.jsx +23 -0
  42. package/components/navigation/SidePanel.vue +21 -0
  43. package/components/navigation/Sidebar.d.ts +23 -7
  44. package/components/navigation/Sidebar.jsx +38 -37
  45. package/components/navigation/Sidebar.vue +40 -39
  46. package/components/navigation/TopBar.d.ts +28 -3
  47. package/components/navigation/TopBar.jsx +54 -7
  48. package/components/navigation/TopBar.vue +26 -8
  49. package/components/navigation/UserMenu.vue +27 -0
  50. package/components/navigation/chrome-tones.js +22 -0
  51. package/components/navigation/nav-model.js +21 -18
  52. package/components/navigation/navigation.prompt.md +2 -0
  53. package/components/tabs/CountBadge.vue +12 -0
  54. package/components/tabs/PanelTabs.vue +7 -3
  55. package/components/tabs/PillTabs.vue +7 -3
  56. package/components/tabs/SegmentedControl.d.ts +4 -2
  57. package/components/tabs/SegmentedControl.jsx +5 -3
  58. package/components/tabs/SegmentedControl.vue +4 -3
  59. package/components/tabs/Tabs.d.ts +14 -4
  60. package/components/tabs/Tabs.jsx +35 -20
  61. package/components/tabs/Tabs.vue +7 -3
  62. package/components/tabs/tabs.prompt.md +2 -2
  63. package/package.json +2 -2
  64. package/readme.md +4 -4
  65. package/readme.zh-CN.md +2 -2
  66. package/tokens/layout.css +4 -3
  67. package/tokens/tokens.d.ts +2 -0
  68. package/tokens/tokens.js +4 -1
  69. package/tokens/tokens.json +8 -1
@@ -76,7 +76,7 @@ function mount(rootSelector, files){
76
76
  var btn = document.createElement('button');
77
77
  btn.type = 'button';
78
78
  btn.innerHTML = iconSvg() + '<span style="margin-left:6px">View Code</span>';
79
- btn.style.cssText = 'display:inline-flex;align-items:center;height:28px;padding:0 10px;border-radius:8px;border:1px solid var(--border-control,#e5e5e5);background:var(--bg-surface,#fff);color:var(--text-secondary,#737373);font-size:12px;font-family:var(--font-sans,sans-serif);cursor:pointer;';
79
+ btn.style.cssText = 'display:inline-flex;align-items:center;height:28px;box-sizing:border-box;padding:0 8px;border-radius:var(--radius-md,8px);border:1px solid var(--border-control,#e5e5e5);background:var(--bg-surface,#fff);color:var(--text-secondary,#737373);font-size:var(--text-xs,12px);line-height:var(--leading-xs,16px);font-family:var(--font-sans,sans-serif);cursor:pointer;';
80
80
  var panel = document.createElement('div');
81
81
  panel.style.cssText = 'display:none;margin-top:10px;border:1px solid var(--border-control,#e5e5e5);border-radius:8px;overflow:hidden;background:var(--bg-inset,#fafafa);';
82
82
  var headRow = document.createElement('div');
@@ -84,19 +84,19 @@ function mount(rootSelector, files){
84
84
  var tabBar = document.createElement('div');
85
85
  tabBar.style.cssText = 'display:flex;overflow-x:auto;flex:1;';
86
86
  var fwBar = document.createElement('div');
87
- fwBar.style.cssText = 'display:flex;gap:2px;padding:6px 8px;flex-shrink:0;';
87
+ fwBar.style.cssText = 'display:flex;align-items:center;flex-shrink:0;height:28px;box-sizing:border-box;gap:2px;padding:2px;margin:0 8px;border-radius:var(--radius-md,8px);background:var(--bg-inset,#fafafa);border:1px solid var(--border-control,#e5e5e5);';
88
88
  var framework = 'react';
89
89
  ['React','Vue 3'].forEach(function(fw){
90
90
  var fb = document.createElement('button');
91
91
  fb.type = 'button'; fb.textContent = fw;
92
- fb.style.cssText = 'font-size:12px;font-family:var(--font-sans,sans-serif);padding:4px 9px;border-radius:6px;border:1px solid var(--border-control,#e5e5e5);background:var(--bg-surface,#fff);color:var(--text-secondary,#737373);cursor:pointer;';
92
+ fb.style.cssText = 'display:inline-flex;align-items:center;height:22px;font-size:var(--text-xs,12px);line-height:var(--leading-xs,16px);font-weight:var(--weight-medium,500);font-family:var(--font-sans,sans-serif);padding:0 6px;border-radius:var(--radius-sm,4px);border:none;background:transparent;color:var(--text-secondary,#737373);cursor:pointer;white-space:nowrap;';
93
93
  fb.onclick = function(){
94
94
  framework = fw === 'Vue 3' ? 'vue' : 'react';
95
95
  Array.prototype.forEach.call(fwBar.children, function(el){
96
96
  var on = el.textContent === fw;
97
- el.style.background = on ? 'var(--action-primary,#155dfc)' : 'var(--bg-surface,#fff)';
98
- el.style.color = on ? 'var(--on-accent,#fff)' : 'var(--text-secondary,#737373)';
99
- el.style.borderColor = on ? 'var(--action-primary,#155dfc)' : 'var(--border-control,#e5e5e5)';
97
+ el.style.background = on ? 'var(--bg-surface,#fff)' : 'transparent'; el.style.boxShadow = on ? '0 1px 2px rgba(0,0,0,.08)' : 'none';
98
+ el.style.color = on ? 'var(--text-heading,#0a0a0a)' : 'var(--text-secondary,#737373)';
99
+
100
100
  });
101
101
  Array.prototype.forEach.call(tabBar.children, function(el, i){
102
102
  if (tabs[i].html) return;
@@ -108,9 +108,9 @@ function mount(rootSelector, files){
108
108
  };
109
109
  fwBar.appendChild(fb);
110
110
  });
111
- fwBar.firstChild.style.background = 'var(--action-primary,#155dfc)';
112
- fwBar.firstChild.style.color = 'var(--on-accent,#fff)';
113
- fwBar.firstChild.style.borderColor = 'var(--action-primary,#155dfc)';
111
+ fwBar.firstChild.style.background = 'var(--bg-surface,#fff)'; fwBar.firstChild.style.boxShadow = '0 1px 2px rgba(0,0,0,.08)';
112
+ fwBar.firstChild.style.color = 'var(--text-heading,#0a0a0a)';
113
+
114
114
  headRow.appendChild(tabBar); headRow.appendChild(fwBar);
115
115
  var body = document.createElement('div');
116
116
  var tabs = files.slice();
@@ -119,11 +119,11 @@ function mount(rootSelector, files){
119
119
  var box = document.createElement('div');
120
120
  box.style.position = 'relative';
121
121
  var pre = document.createElement('pre');
122
- pre.style.cssText = 'margin:0;padding:14px 44px 14px 16px;overflow:auto;max-height:380px;font-family:var(--font-mono,monospace);font-size:12px;line-height:16.5px;color:var(--text-body,#525252);white-space:pre;';
122
+ pre.style.cssText = 'margin:0;padding:14px 70px 14px 16px;overflow:auto;max-height:380px;font-family:var(--font-mono,monospace);font-size:var(--text-code,12px);line-height:var(--leading-code,18px);color:var(--text-body,#525252);white-space:pre;';
123
123
  var copyBtn = document.createElement('button');
124
124
  copyBtn.type = 'button';
125
125
  copyBtn.innerHTML = copySvg() + '<span style="margin-left:4px">Copy</span>';
126
- copyBtn.style.cssText = 'position:absolute;top:8px;right:10px;display:inline-flex;align-items:center;height:24px;padding:0 8px;border-radius:6px;background:var(--bg-surface,#fff);color:var(--text-secondary,#737373);font-size:12px;font-family:var(--font-sans,sans-serif);cursor:pointer;border:1px solid var(--border-control,#e5e5e5);';
126
+ copyBtn.style.cssText = 'position:absolute;top:8px;right:10px;display:inline-flex;align-items:center;height:28px;box-sizing:border-box;padding:0 8px;border-radius:var(--radius-md,8px);background:var(--bg-surface,#fff);color:var(--text-secondary,#737373);font-size:var(--text-xs,12px);line-height:var(--leading-xs,16px);font-family:var(--font-sans,sans-serif);cursor:pointer;border:1px solid var(--border-control,#e5e5e5);';
127
127
  function setText(text){
128
128
  pre.textContent = text;
129
129
  copyBtn.onclick = function(){
@@ -169,7 +169,7 @@ function mount(rootSelector, files){
169
169
  tb.type = 'button';
170
170
  tb.textContent = t.name;
171
171
  if (t.vueOnly) tb.style.display = 'none';
172
- tb.style.cssText = 'padding:8px 12px;border:none;background:transparent;font-size:12px;font-family:var(--font-sans,sans-serif);cursor:pointer;white-space:nowrap;margin-bottom:-1px;border-bottom:2px solid transparent;';
172
+ tb.style.cssText = 'padding:8px 12px;border:none;background:transparent;font-size:var(--text-xs,12px);line-height:var(--leading-xs,16px);font-family:var(--font-sans,sans-serif);cursor:pointer;white-space:nowrap;margin-bottom:-1px;border-bottom:2px solid transparent;';
173
173
  tb.onclick = function(){ selectTab(i); };
174
174
  tabBar.appendChild(tb);
175
175
  });
@@ -114,10 +114,12 @@ export { default as Breadcrumb } from "../navigation/Breadcrumb.vue";
114
114
  export { default as DocTree } from "../navigation/DocTree.vue";
115
115
  export { default as PageHeading } from "../navigation/PageHeading.vue";
116
116
  export { default as PageToolbar } from "../navigation/PageToolbar.vue";
117
+ export { default as SidePanel } from "../navigation/SidePanel.vue";
117
118
  export { default as Sidebar } from "../navigation/Sidebar.vue";
118
119
  export { default as ThemeToggle } from "../navigation/ThemeToggle.vue";
119
120
  export { default as TopBar } from "../navigation/TopBar.vue";
120
121
  export { default as Tree } from "../navigation/Tree.vue";
122
+ export { default as UserMenu } from "../navigation/UserMenu.vue";
121
123
 
122
124
  /* pipeline/ */
123
125
  export { default as ConfigPanel } from "../pipeline/ConfigPanel.vue";
@@ -132,6 +134,7 @@ export { default as PortLabel } from "../pipeline/PortLabel.vue";
132
134
  export { default as ZoomControls } from "../pipeline/ZoomControls.vue";
133
135
 
134
136
  /* tabs/ */
137
+ export { default as CountBadge } from "../tabs/CountBadge.vue";
135
138
  export { default as PanelTabs } from "../tabs/PanelTabs.vue";
136
139
  export { default as PillTabs } from "../tabs/PillTabs.vue";
137
140
  export { default as SegmentedControl } from "../tabs/SegmentedControl.vue";
@@ -139,7 +142,7 @@ export { default as Tabs } from "../tabs/Tabs.vue";
139
142
  export { default as ViewToggle } from "../tabs/ViewToggle.vue";
140
143
 
141
144
  /* shared values */
142
- export { KEYBRICKS_NAV } from "../navigation/nav-model.js";
145
+ export { KEYBRICKS_MGMT, KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER } from "../navigation/nav-model.js";
143
146
  export { applyStoredTheme } from "../navigation/theme-store.js";
144
147
  export { PIPELINE_CATALOG } from "../pipeline/pipeline-catalog.js";
145
148
  export { VUE_ICON_PATHS } from "../icons/icon-paths.js";
@@ -1,7 +1,8 @@
1
1
  import * as React from "react";
2
- /** Floating context menu — r10, soft shadow, control-height rows at the region's tier (32px / 14px at md), red danger row. */
2
+ /** Floating context menu — r10, soft shadow, control-height rows at the region's tier (32px / 14px at md), red danger row.
3
+ * Action menus keep rows flush; a single-choice menu (any item `selected`) spaces rows 2px apart. Dividers keep 4px above and below. */
3
4
  export interface ContextMenuProps {
4
- items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean }[];
5
+ items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
5
6
  onSelect?: (label: string) => void;
6
7
  /** Size tier of the rows. Omit to inherit the tier of the control that opened the menu (the default). */
7
8
  size?: "sm" | "md" | "lg";
@@ -1,15 +1,19 @@
1
1
  import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
3
 
4
- /** Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides. */
4
+ /** Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
+ * selected: true marks the current choice of a single-choice menu (section switcher, language picker): --bg-hover fill, --weight-medium
6
+ * --text-heading, no checkmark. A menu with any selected item spaces its rows 2px apart (like Sidebar / Tree / DocTree) so a hovered
7
+ * fill never touches the selected one; an action menu (nothing selected) keeps its rows flush. Dividers keep 4px above and below. */
5
8
  export function ContextMenu({ items = [], onSelect, size, style }) {
6
9
  const [hover, setHover] = React.useState(-1);
10
+ const choice = items.some((it) => it && it.selected);
7
11
  return (
8
- <div role="menu" data-size={size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, display: "inline-flex", flexDirection: "column", ...style }}>
9
- {items.map((it, i) => it.divider ? <div key={i} style={{ height: 1, background: "var(--border-subtle)", margin: "4px 0" }} /> : (
12
+ <div role="menu" data-size={size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
13
+ {items.map((it, i) => it.divider ? <div key={i} role="separator" style={{ height: 1, flexShrink: 0, background: "var(--border-subtle)", margin: "4px 0" }} /> : (
10
14
  <button key={i} type="button" onClick={() => onSelect && onSelect(it.label)}
11
15
  onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(-1)}
12
- role="menuitem" style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: hover === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap" }}>
16
+ role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap" }}>
13
17
  {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)" }} /> : null}{it.label}
14
18
  </button>
15
19
  ))}
@@ -1,17 +1,20 @@
1
1
  <script setup>
2
- import { ref } from 'vue'
2
+ import { ref, computed } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
- // Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
- defineProps({ items: { type: Array, default: () => [] }, size: String, style: Object })
4
+ // Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
+ // selected: true marks the current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. A menu with any
6
+ // selected item spaces its rows 2px apart (like Sidebar / Tree / DocTree); an action menu keeps its rows flush. Dividers keep 4px above and below.
7
+ const props = defineProps({ items: { type: Array, default: () => [] }, size: String, style: Object })
8
+ const choice = computed(() => props.items.some(it => it && it.selected))
6
9
  const emit = defineEmits(['select'])
7
10
  const hover = ref(-1)
8
11
  </script>
9
12
  <template>
10
- <div role="menu" :data-size="size" :style="{ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', display: 'inline-flex', flexDirection: 'column', ...style }">
13
+ <div role="menu" :data-size="size" :style="{ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', display: 'inline-flex', flexDirection: 'column', gap: choice ? '2px' : 0, ...style }">
11
14
  <template v-for="(it, i) in items" :key="i">
12
- <div v-if="it.divider" style="height:1px;background:var(--border-subtle);margin:4px 0" />
15
+ <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
13
16
  <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1"
14
- role="menuitem" :style="{ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: hover === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap' }">
17
+ :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="{ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap' }">
15
18
  <Icon v-if="it.icon" :name="it.icon" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)' }" />{{ it.label }}
16
19
  </button>
17
20
  </template>
@@ -2,7 +2,7 @@ import * as React from "react";
2
2
  /** Trigger-anchored dropdown menu (reuses ContextMenu's look). Addition — for "Show ⌄"-style buttons and user menus. */
3
3
  export interface MenuProps {
4
4
  trigger: React.ReactNode;
5
- items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean }[];
5
+ items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
6
6
  onSelect?: (label: string) => void;
7
7
  align?: "start" | "end";
8
8
  style?: React.CSSProperties;
@@ -1,4 +1,4 @@
1
- Feedback & flow: `Stepper` (wizard circles), `ContextMenu`, `ActivityItem`/`DotActivity` (feeds), `LogViewer` (mono logs).
1
+ Feedback & flow: `Stepper` (wizard circles), `ContextMenu` / `Menu` (action menus flush; `selected: true` on one item makes a single-choice menu with 2px row gaps, no checkmark), `ActivityItem`/`DotActivity` (feeds), `LogViewer` (mono logs).
2
2
 
3
3
  ```jsx
4
4
  <Stepper current={1} steps={[{label:"Upload & Parse",icon:"upload"},{label:"Comparison",icon:"file-text"},{label:"Confirmation",icon:"check"},{label:"Execution",icon:"play"},{label:"Results",icon:"eye"}]}/>
@@ -73,6 +73,7 @@ export * from "./icons/Icon";
73
73
  export * from "./icons/Unicon";
74
74
  export * from "./navigation/Breadcrumb";
75
75
  export * from "./navigation/DocTree";
76
+ export * from "./navigation/SidePanel";
76
77
  export * from "./navigation/Sidebar";
77
78
  export * from "./navigation/TopBar";
78
79
  export * from "./navigation/Tree";
@@ -92,8 +92,9 @@ export { Unicon } from "./icons/Unicon.jsx";
92
92
  /* navigation/ */
93
93
  export { Breadcrumb, ThemeToggle, applyStoredTheme } from "./navigation/Breadcrumb.jsx";
94
94
  export { DocTree } from "./navigation/DocTree.jsx";
95
- export { KEYBRICKS_NAV, Sidebar } from "./navigation/Sidebar.jsx";
96
- export { PageHeading, PageToolbar, TopBar } from "./navigation/TopBar.jsx";
95
+ export { SidePanel } from "./navigation/SidePanel.jsx";
96
+ export { KEYBRICKS_MGMT, KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER, SIDEBAR_COLORS, Sidebar } from "./navigation/Sidebar.jsx";
97
+ export { PageHeading, PageToolbar, THEME_OPTIONS, TopBar, UserMenu } from "./navigation/TopBar.jsx";
97
98
  export { Tree } from "./navigation/Tree.jsx";
98
99
 
99
100
  /* pipeline/ */
@@ -106,5 +107,5 @@ export { ZoomControls } from "./pipeline/ZoomControls.jsx";
106
107
 
107
108
  /* tabs/ */
108
109
  export { SegmentedControl } from "./tabs/SegmentedControl.jsx";
109
- export { PanelTabs, PillTabs, Tabs } from "./tabs/Tabs.jsx";
110
+ export { CountBadge, PanelTabs, PillTabs, Tabs } from "./tabs/Tabs.jsx";
110
111
  export { ViewToggle } from "./tabs/ViewToggle.jsx";
@@ -0,0 +1,22 @@
1
+ import * as React from "react";
2
+ /** Side panel docked beside the Sidebar (Documents, Management): 240px, same tone as the rail, grows open from 0 and fades its content in.
3
+ * 56px header: title + actions + close. The body scrolls. Vue: #actions slot, @close. */
4
+ export interface SidePanelProps {
5
+ open?: boolean;
6
+ title?: React.ReactNode;
7
+ /** Extra header buttons left of close (e.g. a muted IconButton "maximize-2" to open the full page) */
8
+ actions?: React.ReactNode;
9
+ /** Shows the close button. Vue: @close (hide it with :closable="false"). */
10
+ onClose?: () => void;
11
+ closeLabel?: string;
12
+ closeIcon?: string;
13
+ /** Same values as Sidebar — keep the two in step */
14
+ tone?: "recessed" | "flush" | "contrast" | "custom";
15
+ color?: string;
16
+ width?: number;
17
+ /** Size tier of the panel body ("sm" for compact document lists) */
18
+ size?: "sm" | "md" | "lg";
19
+ children?: React.ReactNode;
20
+ style?: React.CSSProperties;
21
+ }
22
+ export declare function SidePanel(props: SidePanelProps): JSX.Element;
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ import { IconButton } from "../buttons/IconButton.jsx";
3
+ import { chromeTone } from "./chrome-tones.js";
4
+
5
+ /**
6
+ * Side panel docked beside the Sidebar (Documents, Management). 240px, same tone as the rail, opens by growing from 0
7
+ * (--dur-slow, --ease-out) while its content fades in from 12px left. 56px header: title + actions + close.
8
+ * Body scrolls; put a SegmentedControl, SearchInput, Tree or rows inside. size="sm" makes its controls compact.
9
+ */
10
+ export function SidePanel({ open = true, title, actions, onClose, closeLabel = "Close", closeIcon = "x", tone = "recessed", color, width = 240, size, children, style }) {
11
+ const t = chromeTone(tone, color);
12
+ return (
13
+ <div aria-hidden={open ? undefined : "true"} style={{ width: open ? width : 0, flexShrink: 0, overflow: "hidden", height: "100%", transition: "width var(--dur-slow) var(--ease-out)" }}>
14
+ <div data-theme={t.theme} data-size={size} style={{ ...t.vars, width, height: "100%", background: "var(--sb-bg)", borderRight: "1px solid var(--sb-border)", color: "var(--sb-text)", display: "flex", flexDirection: "column", boxSizing: "border-box", opacity: open ? 1 : 0, transform: open ? "none" : "translateX(-12px)", transition: "opacity var(--dur) var(--ease-out), transform var(--dur-slow) var(--ease-out)", pointerEvents: open ? "auto" : "none", ...style }}>
15
+ <div style={{ height: "var(--topbar-h)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "0 8px 0 16px", boxSizing: "border-box", flexShrink: 0 }}>
16
+ <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", fontWeight: "var(--weight-medium)", color: "var(--text-heading)" }}>{title}</span>
17
+ <div data-size="md" style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>{actions}{onClose ? <IconButton icon={closeIcon} muted label={closeLabel} title={closeLabel} onClick={onClose} /> : null}</div>
18
+ </div>
19
+ <div style={{ flex: 1, minHeight: 0, overflow: "auto", display: "flex", flexDirection: "column" }}>{children}</div>
20
+ </div>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,21 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import IconButton from '../buttons/IconButton.vue'
4
+ import { chromeTone } from './chrome-tones.js'
5
+ // Side panel docked beside the Sidebar (Documents, Management). 240px, same tone as the rail; opens by growing from 0 while
6
+ // its content fades in from 12px left. 56px header: title + #actions slot + close (@close). Default slot is the scrolling body.
7
+ const props = defineProps({ open: { type: Boolean, default: true }, title: String, closable: { type: Boolean, default: true }, closeLabel: { type: String, default: 'Close' }, closeIcon: { type: String, default: 'x' }, tone: { type: String, default: 'recessed' }, color: String, width: { type: Number, default: 240 }, size: String, style: Object })
8
+ const emit = defineEmits(['close'])
9
+ const t = computed(() => chromeTone(props.tone, props.color))
10
+ </script>
11
+ <template>
12
+ <div :aria-hidden="open ? undefined : 'true'" :style="{ width: (open ? width : 0) + 'px', flexShrink: 0, overflow: 'hidden', height: '100%', transition: 'width var(--dur-slow) var(--ease-out)' }">
13
+ <div :data-theme="t.theme" :data-size="size" :style="{ ...t.vars, width: width + 'px', height: '100%', background: 'var(--sb-bg)', borderRight: '1px solid var(--sb-border)', color: 'var(--sb-text)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', opacity: open ? 1 : 0, transform: open ? 'none' : 'translateX(-12px)', transition: 'opacity var(--dur) var(--ease-out), transform var(--dur-slow) var(--ease-out)', pointerEvents: open ? 'auto' : 'none', ...style }">
14
+ <div style="height:var(--topbar-h);display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 8px 0 16px;box-sizing:border-box;flex-shrink:0">
15
+ <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;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)">{{ title }}</span>
16
+ <div data-size="md" style="display:flex;align-items:center;gap:2px;flex-shrink:0"><slot name="actions" /><IconButton v-if="closable" :icon="closeIcon" muted :label="closeLabel" :title="closeLabel" @click="emit('close')" /></div>
17
+ </div>
18
+ <div style="flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column"><slot /></div>
19
+ </div>
20
+ </div>
21
+ </template>
@@ -1,29 +1,45 @@
1
1
  import * as React from "react";
2
2
  /**
3
- * App sidebar — 240px expanded / 64px icon rail. Logo header, nav, expandable sections, pinned footer.
3
+ * App sidebar — 240px expanded / 64px icon rail. 56px brand head (no rule), a 2px-gap nav list, pinned footer items and a version line.
4
+ * Tones: "recessed" (grey rail, the active item is a white tile with a hairline ring — the default), "flush" (white rail),
5
+ * "contrast" (the rail scoped to the dark theme), "custom" (dark scope on a solid colour; see SIDEBAR_COLORS).
4
6
  * @startingPoint section="Chrome" subtitle="240px app sidebar with icon-rail collapse" viewport="240x600"
5
7
  */
6
8
  export interface SidebarItem {
7
- icon: string;
9
+ icon?: string;
8
10
  label: string;
9
- children?: SidebarItem[];
10
- /** Expand this section initially */
11
- open?: boolean;
11
+ /** Draws a chevron on the right: the item opens a SidePanel */
12
+ trailing?: boolean;
13
+ children?: (SidebarItem | string)[];
12
14
  }
13
15
  export interface SidebarProps {
16
+ /** Default KEYBRICKS_NAV */
14
17
  items?: SidebarItem[];
18
+ /** Default KEYBRICKS_NAV_FOOTER (Management, Knowledge Base) */
15
19
  footerItems?: SidebarItem[];
16
20
  /** Active item label */
17
21
  active?: string;
18
22
  onSelect?: (label: string) => void;
19
23
  collapsed?: boolean;
20
- /** Renders the "Collapse" item */
24
+ /** Renders the Collapse / Expand item at the top of the list. Vue: @toggle-collapse (hide it with :show-toggle="false"). */
21
25
  onToggleCollapse?: () => void;
22
26
  logo?: string;
23
27
  brand?: string;
28
+ /** Version line under the footer items ("Version 2.4.0"); the rail shows "v2.4.0" */
29
+ version?: string;
30
+ tone?: "recessed" | "flush" | "contrast" | "custom";
31
+ /** For tone "custom": a SIDEBAR_COLORS key (slate, navy, indigo, violet, plum, wine, forest, teal, umber, charcoal) or any hex */
32
+ color?: string;
33
+ labels?: { collapse?: string; expand?: string };
24
34
  height?: number | string;
25
35
  style?: React.CSSProperties;
26
36
  }
27
37
  export declare function Sidebar(props: SidebarProps): JSX.Element;
28
- /** The product's real nav model. */
38
+ /** The product's main nav (Home … Task Execution); Documents has trailing: true. */
29
39
  export declare const KEYBRICKS_NAV: SidebarItem[];
40
+ /** Pinned footer: Management (trailing) and Knowledge Base. */
41
+ export declare const KEYBRICKS_NAV_FOOTER: SidebarItem[];
42
+ /** Groups for the Management side panel. */
43
+ export declare const KEYBRICKS_MGMT: { icon: string; label: string; children?: string[] }[];
44
+ /** The ten custom rail colours. */
45
+ export declare const SIDEBAR_COLORS: Record<"slate" | "navy" | "indigo" | "violet" | "plum" | "wine" | "forest" | "teal" | "umber" | "charcoal", string>;
@@ -1,56 +1,57 @@
1
1
  import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
- import { KEYBRICKS_NAV } from "./nav-model.js";
3
+ import { KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER, KEYBRICKS_MGMT } from "./nav-model.js";
4
+ import { chromeTone, SIDEBAR_COLORS } from "./chrome-tones.js";
4
5
 
5
- function NavItem({ icon, label, active, indent, collapsed, badge, onClick }) {
6
+ const LABEL = { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--text-base)", lineHeight: "18px", letterSpacing: "var(--tracking-base)", fontWeight: "var(--weight-medium)" };
7
+
8
+ /* One rail row: 36px min, radius-md, 16px icon, medium label, optional trailing chevron (the item opens a side panel). */
9
+ function NavItem({ icon, label, active, indent, collapsed, trailing, onClick }) {
6
10
  const [hover, setHover] = React.useState(false);
7
11
  return (
8
- <div role="button" tabIndex={0} onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
9
- style={{ height: indent ? "var(--h-subnav-item)" : "var(--h-nav-item)", borderRadius: "var(--radius-md)", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "flex-start", gap: 10, padding: collapsed ? "8px 10px" : indent ? "6px 10px" : "8px 10px", boxSizing: "border-box", cursor: "pointer", background: active ? "var(--bg-active)" : hover ? "var(--bg-hover)" : "transparent", color: active ? "var(--text-heading)" : "var(--text-body)", transition: "var(--t-hover)" }}>
10
- <Icon name={icon} size={indent ? 14 : 16} />
11
- {!collapsed ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", whiteSpace: "nowrap", flex: 1 }}>{label}</span> : null}
12
- {!collapsed && badge ? badge : null}
12
+ <div role="button" tabIndex={0} title={collapsed ? label : undefined} aria-current={active ? "page" : undefined} onClick={onClick} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick && onClick(); } }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
13
+ style={{ minHeight: indent ? 32 : 36, borderRadius: "var(--radius-md)", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "flex-start", gap: 10, padding: indent ? "6px 10px" : "8px 10px", boxSizing: "border-box", cursor: "pointer", userSelect: "none", background: active ? "var(--sb-active)" : hover ? "var(--sb-hover)" : "transparent", color: active ? "var(--sb-active-text)" : "var(--sb-text)", boxShadow: active ? "var(--sb-shadow)" : "none", transition: "var(--t-hover)" }}>
14
+ {icon ? <span style={{ display: "inline-flex", flexShrink: 0 }}><Icon name={icon} size={indent ? 14 : 16} /></span> : null}
15
+ {!collapsed ? <span style={LABEL}>{label}</span> : null}
16
+ {!collapsed && trailing ? <span style={{ display: "inline-flex", flexShrink: 0, opacity: 0.6 }}><Icon name="chevron-right" size={14} /></span> : null}
13
17
  </div>
14
18
  );
15
19
  }
16
20
 
17
21
  /**
18
- * Keybricks app sidebar — 240px (collapsed: 64px icon rail). White, hairline right border.
19
- * items: [{icon,label,children:[{icon,label}]}]; active by label; footer items pinned.
22
+ * Keybricks app sidebar — 240px, 64px icon rail when collapsed. A 56px brand head (no rule), a 2px-gap list, pinned footer items and a version line.
23
+ * tone: "recessed" (grey, default) | "flush" (white) | "contrast" (dark) | "custom" (+ color: a SIDEBAR_COLORS key or hex).
24
+ * items / footerItems: [{ icon, label, trailing?, children? }] — trailing draws a chevron (the item opens a SidePanel); children make a collapsible group.
20
25
  */
21
- export function Sidebar({ items = [], footerItems = [{ icon: "circle-help", label: "Help" }, { icon: "settings", label: "Settings" }, { icon: "moon", label: "Dark Mode" }], active, onSelect, collapsed = false, onToggleCollapse, logo = "assets/keybricks-logo.png", brand = "Keybricks", height = "100%", style }) {
22
- const [openSections, setOpen] = React.useState(() => items.filter((i) => i.children && (i.open || (i.children.some((c) => c.label === active)))).map((i) => i.label));
23
- const toggleSection = (l) => setOpen(openSections.includes(l) ? openSections.filter((x) => x !== l) : [...openSections, l]);
26
+ export function Sidebar({ items = KEYBRICKS_NAV, footerItems = KEYBRICKS_NAV_FOOTER, active, onSelect, collapsed = false, onToggleCollapse, logo = "assets/keybricks-logo.png", brand = "Keybricks", version, tone = "recessed", color, labels, height = "100%", style }) {
27
+ const L = { collapse: "Collapse", expand: "Expand", ...labels };
28
+ const t = chromeTone(tone, color);
29
+ const [open, setOpen] = React.useState(() => items.filter((i) => i.children && i.children.some((c) => (c.label || c) === active)).map((i) => i.label));
30
+ const toggle = (l) => setOpen(open.includes(l) ? open.filter((x) => x !== l) : [...open, l]);
31
+ const pick = (l) => onSelect && onSelect(l);
32
+ const list = (arr) => arr.map((it) => it.children ? (
33
+ <div key={it.label} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
34
+ <NavItem icon={it.icon} label={it.label} collapsed={collapsed} trailing={false} active={collapsed && it.children.some((c) => (c.label || c) === active)} onClick={() => (collapsed ? pick(it.label) : toggle(it.label))} />
35
+ {!collapsed && open.includes(it.label) ? <div style={{ marginLeft: 24, display: "flex", flexDirection: "column", gap: 2 }}>{it.children.map((c) => { const lb = c.label || c; return <NavItem key={lb} icon={c.icon} label={lb} indent active={active === lb} onClick={() => pick(lb)} />; })}</div> : null}
36
+ </div>
37
+ ) : <NavItem key={it.label} icon={it.icon} label={it.label} collapsed={collapsed} trailing={it.trailing} active={active === it.label} onClick={() => pick(it.label)} />);
24
38
  return (
25
- <aside style={{ width: collapsed ? "var(--rail-w)" : "var(--sidebar-w)", height, background: "var(--bg-surface)", borderRight: "1px solid var(--border-card)", display: "flex", flexDirection: "column", boxSizing: "border-box", flexShrink: 0, transition: "width .15s ease", ...style }}>
26
- <div style={{ height: "var(--topbar-h)", borderBottom: "1px solid var(--border-card)", display: "flex", alignItems: "center", gap: 10, padding: "0 16px", boxSizing: "border-box", justifyContent: collapsed ? "center" : "flex-start", flexShrink: 0 }}>
27
- <img src={logo} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
28
- {!collapsed ? <span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-lg)", lineHeight: "var(--leading-lg)", letterSpacing: "-.609px", color: "var(--text-primary)" }}>{brand}</span> : null}
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 }}>
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
+ <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}
29
43
  </div>
30
- <nav style={{ flex: 1, overflowY: "auto", padding: 8, display: "flex", flexDirection: "column", gap: 0 }}>
31
- {onToggleCollapse ? <NavItem icon="panel-left" label="Collapse" collapsed={collapsed} onClick={onToggleCollapse} /> : null}
32
- {items.map((it) => it.children ? (
33
- <div key={it.label}>
34
- <div role="button" tabIndex={0} data-wash="" onClick={() => toggleSection(it.label)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSection(it.label); } }} style={{ background: "var(--wash, transparent)", transition: "var(--t-hover)", height: "var(--h-subnav-item)", display: "flex", alignItems: "center", gap: 10, padding: collapsed ? "6px 10px" : "6px 10px", justifyContent: collapsed ? "center" : "flex-start", cursor: "pointer", color: "var(--text-body)", borderRadius: "var(--radius-md)", boxSizing: "border-box" }}>
35
- <Icon name={it.icon} size={16} />
36
- {!collapsed ? <><span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", letterSpacing: "var(--tracking-base)", flex: 1 }}>{it.label}</span><Icon name={openSections.includes(it.label) ? "chevron-up" : "chevron-down"} size={14} /></> : null}
37
- </div>
38
- {!collapsed && openSections.includes(it.label) ? (
39
- <div style={{ marginLeft: 24, display: "flex", flexDirection: "column", gap: 2, paddingTop: 2 }}>
40
- {it.children.map((c) => <NavItem key={c.label} icon={c.icon} label={c.label} indent active={active === c.label} onClick={() => onSelect && onSelect(c.label)} />)}
41
- </div>
42
- ) : null}
43
- </div>
44
- ) : (
45
- <NavItem key={it.label} icon={it.icon} label={it.label} collapsed={collapsed} active={active === it.label} onClick={() => onSelect && onSelect(it.label)} />
46
- ))}
44
+ <nav style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: 8, display: "flex", flexDirection: "column", gap: 2 }}>
45
+ {onToggleCollapse ? <NavItem icon={collapsed ? "panel-left" : "chevron-left"} label={collapsed ? L.expand : L.collapse} collapsed={collapsed} onClick={onToggleCollapse} /> : null}
46
+ {list(items)}
47
47
  </nav>
48
- <div style={{ borderTop: "1px solid var(--border-card)", padding: 8, display: "flex", flexDirection: "column", flexShrink: 0 }}>
49
- {footerItems.map((f) => <NavItem key={f.label} icon={f.icon} label={f.label} collapsed={collapsed} onClick={() => onSelect && onSelect(f.label)} />)}
48
+ <div style={{ padding: 8, display: "flex", flexDirection: "column", gap: 2, flexShrink: 0 }}>
49
+ {list(footerItems)}
50
+ {version ? <span title={version} style={{ padding: collapsed ? "8px 0 4px" : "8px 10px 4px", textAlign: collapsed ? "center" : "left", fontFamily: "var(--font-sans)", fontSize: collapsed ? "var(--text-glyph-min)" : "var(--text-xs)", lineHeight: collapsed ? "var(--leading-2xs)" : "var(--leading-xs)", color: "var(--sb-muted)", whiteSpace: "nowrap", overflow: "hidden" }}>{collapsed ? "v" + String(version).split(" ").pop().replace(/^v/i, "") : version}</span> : null}
50
51
  </div>
51
52
  </aside>
52
53
  );
53
54
  }
54
55
 
55
- /** Default Keybricks nav model (Home dashboard) — defined in nav-model.js, shared with the Vue entry. */
56
- export { KEYBRICKS_NAV };
56
+ /** Nav models and tones — defined in framework-neutral modules, shared with the Vue entry. */
57
+ export { KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER, KEYBRICKS_MGMT, SIDEBAR_COLORS };
@@ -1,49 +1,50 @@
1
1
  <script setup>
2
- import { ref } from 'vue'
2
+ import { ref, computed } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
-
5
- /**
6
- * Keybricks app sidebar — 240px (collapsed: 64px icon rail). White, hairline right border.
7
- * items: [{icon,label,children:[{icon,label}]}]; active by label; footer items pinned.
8
- */
9
- const props = defineProps({
10
- items: { type: Array, default: () => [] },
11
- footerItems: { type: Array, default: () => [{ icon: 'circle-help', label: 'Help' }, { icon: 'settings', label: 'Settings' }, { icon: 'moon', label: 'Dark Mode' }] },
12
- active: String, collapsed: { type: Boolean, default: false },
13
- logo: { type: String, default: 'assets/keybricks-logo.png' }, brand: { type: String, default: 'Keybricks' },
14
- height: { type: [String, Number], default: '100%' }, style: Object, canCollapse: Boolean,
15
- })
4
+ import { KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER } from './nav-model.js'
5
+ import { chromeTone } from './chrome-tones.js'
6
+ // Keybricks app sidebar — 240px, 64px icon rail when collapsed. 56px brand head (no rule), 2px-gap list, pinned footer, version line.
7
+ // tone: "recessed" (grey, default) | "flush" (white) | "contrast" (dark) | "custom" (+ color: a SIDEBAR_COLORS key or hex).
8
+ // items / footerItems: [{ icon, label, trailing?, children? }] — trailing draws a chevron (the item opens a SidePanel); children make a collapsible group.
9
+ const props = defineProps({ items: { type: Array, default: () => KEYBRICKS_NAV }, footerItems: { type: Array, default: () => KEYBRICKS_NAV_FOOTER }, active: String, collapsed: Boolean, logo: { type: String, default: 'assets/keybricks-logo.png' }, brand: { type: String, default: 'Keybricks' }, version: String, tone: { type: String, default: 'recessed' }, color: String, labels: Object, height: { type: String, default: '100%' }, showToggle: { type: Boolean, default: true }, style: Object })
16
10
  const emit = defineEmits(['select', 'toggleCollapse'])
17
- const openSections = ref(props.items.filter(i => i.children && (i.open || i.children.some(c => c.label === props.active))).map(i => i.label))
18
- function toggleSection(l) { openSections.value = openSections.value.includes(l) ? openSections.value.filter(x => x !== l) : [...openSections.value, l] }
19
- function itemStyle(active, indent) { return { height: indent ? 'var(--h-subnav-item)' : 'var(--h-nav-item)', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', gap: '10px', padding: indent ? '6px 10px' : '8px 10px', boxSizing: 'border-box', cursor: 'pointer', background: active ? 'var(--bg-active)' : 'transparent', color: active ? 'var(--text-heading)' : 'var(--text-body)', transition: 'var(--t-hover)' } }
11
+ const L = computed(() => ({ collapse: 'Collapse', expand: 'Expand', ...(props.labels || {}) }))
12
+ const t = computed(() => chromeTone(props.tone, props.color))
13
+ const hover = ref(null)
14
+ const open = ref(props.items.filter(i => i.children && i.children.some(c => (c.label || c) === props.active)).map(i => i.label))
15
+ const toggle = (l) => { open.value = open.value.includes(l) ? open.value.filter(x => x !== l) : [...open.value, l] }
16
+ const lb = (c) => c.label || c
17
+ const LABEL = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', lineHeight: '18px', letterSpacing: 'var(--tracking-base)', fontWeight: 'var(--weight-medium)' }
18
+ function row(key, active, indent) { return { minHeight: indent ? '32px' : '36px', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', justifyContent: props.collapsed ? 'center' : 'flex-start', gap: '10px', padding: indent ? '6px 10px' : '8px 10px', boxSizing: 'border-box', cursor: 'pointer', userSelect: 'none', background: active ? 'var(--sb-active)' : hover.value === key ? 'var(--sb-hover)' : 'transparent', color: active ? 'var(--sb-active-text)' : 'var(--sb-text)', boxShadow: active ? 'var(--sb-shadow)' : 'none', transition: 'var(--t-hover)' } }
19
+ function key(e, fn) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn() } }
20
+ const groups = computed(() => [{ id: 'main', list: props.items }, { id: 'foot', list: props.footerItems }])
21
+ const versionText = computed(() => props.collapsed && props.version ? 'v' + String(props.version).split(' ').pop().replace(/^v/i, '') : props.version)
20
22
  </script>
21
23
  <template>
22
- <aside :style="{ width: collapsed ? 'var(--rail-w)' : 'var(--sidebar-w)', height, background: 'var(--bg-surface)', borderRight: '1px solid var(--border-card)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', flexShrink: 0, transition: 'width .15s ease', ...style }">
23
- <div :style="{ height: 'var(--topbar-h)', borderBottom: '1px solid var(--border-card)', display: 'flex', alignItems: 'center', gap: '10px', padding: '0 16px', boxSizing: 'border-box', justifyContent: collapsed ? 'center' : 'flex-start', flexShrink: 0 }">
24
- <img :src="logo" alt="" style="width:24px;height:24px;object-fit:contain" />
25
- <span v-if="!collapsed" style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-lg);line-height:var(--leading-lg);letter-spacing:-.609px;color:var(--text-primary)">{{ brand }}</span>
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 }">
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
+ <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>
26
28
  </div>
27
- <nav style="flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:0">
28
- <div v-if="canCollapse" role="button" tabindex="0" @click="emit('toggleCollapse')" :style="itemStyle(false, false)"><Icon name="panel-left" :size="16" /><span v-if="!collapsed" style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);white-space:nowrap;flex:1">Collapse</span></div>
29
- <template v-for="it in items" :key="it.label">
30
- <div v-if="it.children">
31
- <div role="button" tabindex="0" data-wash="" @click="toggleSection(it.label)" @keydown.enter.prevent="toggleSection(it.label)" @keydown.space.prevent="toggleSection(it.label)" :style="{ background: 'var(--wash, transparent)', transition: 'var(--t-hover)', height: 'var(--h-subnav-item)', display: 'flex', alignItems: 'center', gap: '10px', padding: '6px 10px', justifyContent: collapsed ? 'center' : 'flex-start', cursor: 'pointer', color: 'var(--text-body)', borderRadius: 'var(--radius-md)', boxSizing: 'border-box' }">
32
- <Icon :name="it.icon" :size="16" />
33
- <template v-if="!collapsed"><span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);letter-spacing:var(--tracking-base);flex:1">{{ it.label }}</span><Icon :name="openSections.includes(it.label) ? 'chevron-up' : 'chevron-down'" :size="14" /></template>
29
+ <template v-for="g in groups" :key="g.id">
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 }">
31
+ <div v-if="g.id === 'main' && showToggle" role="button" tabindex="0" :title="collapsed ? L.expand : undefined" :style="row('__toggle', false)" @click="emit('toggleCollapse')" @keydown="e => key(e, () => emit('toggleCollapse'))" @mouseenter="hover = '__toggle'" @mouseleave="hover = null">
32
+ <span style="display:inline-flex;flex-shrink:0"><Icon :name="collapsed ? 'panel-left' : 'chevron-left'" :size="16" /></span><span v-if="!collapsed" :style="LABEL">{{ collapsed ? L.expand : L.collapse }}</span>
33
+ </div>
34
+ <template v-for="it in g.list" :key="it.label">
35
+ <div role="button" tabindex="0" :title="collapsed ? it.label : undefined" :aria-current="active === it.label ? 'page' : undefined" :style="row(it.label, active === it.label || (collapsed && it.children && it.children.some(c => lb(c) === active)))" @click="it.children && !collapsed ? toggle(it.label) : emit('select', it.label)" @keydown="e => key(e, () => it.children && !collapsed ? toggle(it.label) : emit('select', it.label))" @mouseenter="hover = it.label" @mouseleave="hover = null">
36
+ <span v-if="it.icon" style="display:inline-flex;flex-shrink:0"><Icon :name="it.icon" :size="16" /></span>
37
+ <span v-if="!collapsed" :style="LABEL">{{ it.label }}</span>
38
+ <span v-if="!collapsed && it.trailing" style="display:inline-flex;flex-shrink:0;opacity:.6"><Icon name="chevron-right" :size="14" /></span>
34
39
  </div>
35
- <div v-if="!collapsed && openSections.includes(it.label)" style="margin-left:24px;display:flex;flex-direction:column;gap:2px;padding-top:2px">
36
- <div v-for="c in it.children" :key="c.label" role="button" tabindex="0" @click="emit('select', c.label)" :style="itemStyle(active === c.label, true)"><Icon :name="c.icon" :size="14" /><span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);white-space:nowrap;flex:1">{{ c.label }}</span></div>
40
+ <div v-if="it.children && !collapsed && open.includes(it.label)" style="margin-left:24px;display:flex;flex-direction:column;gap:2px">
41
+ <div v-for="c in it.children" :key="lb(c)" role="button" tabindex="0" :aria-current="active === lb(c) ? 'page' : undefined" :style="row('c:' + lb(c), active === lb(c), true)" @click="emit('select', lb(c))" @keydown="e => key(e, () => emit('select', lb(c)))" @mouseenter="hover = 'c:' + lb(c)" @mouseleave="hover = null">
42
+ <span v-if="c.icon" style="display:inline-flex;flex-shrink:0"><Icon :name="c.icon" :size="14" /></span><span :style="LABEL">{{ lb(c) }}</span>
43
+ </div>
37
44
  </div>
38
- </div>
39
- <div v-else role="button" tabindex="0" @click="emit('select', it.label)" :style="itemStyle(active === it.label, false)"><Icon :name="it.icon" :size="16" /><span v-if="!collapsed" style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);white-space:nowrap;flex:1">{{ it.label }}</span></div>
40
- </template>
41
- </nav>
42
- <div style="border-top:1px solid var(--border-card);padding:8px;display:flex;flex-direction:column;flex-shrink:0">
43
- <div v-for="f in footerItems" :key="f.label" role="button" tabindex="0" @click="emit('select', f.label)" :style="itemStyle(false, false)"><Icon :name="f.icon" :size="16" /><span v-if="!collapsed" style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);white-space:nowrap;flex:1">{{ f.label }}</span></div>
44
- </div>
45
+ </template>
46
+ <span v-if="g.id === 'foot' && version" :title="version" :style="{ padding: collapsed ? '8px 0 4px' : '8px 10px 4px', textAlign: collapsed ? 'center' : 'left', fontFamily: 'var(--font-sans)', fontSize: collapsed ? 'var(--text-glyph-min)' : 'var(--text-xs)', lineHeight: collapsed ? 'var(--leading-2xs)' : 'var(--leading-xs)', color: 'var(--sb-muted)', whiteSpace: 'nowrap', overflow: 'hidden' }">{{ versionText }}</span>
47
+ </component>
48
+ </template>
45
49
  </aside>
46
50
  </template>
47
-
48
- <!-- The product's nav model is KEYBRICKS_NAV in ./nav-model.js — also exported from the Vue entry:
49
- import { Sidebar, KEYBRICKS_NAV } from '@keyfunc/keybricks-ds/vue' -->