@keyfunc/keybricks-ds 2.4.11 → 2.4.13

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.
package/DEPLOY.md CHANGED
@@ -55,7 +55,7 @@ deploys the documentation site. Watch it under *Build → Pipelines*.
55
55
  **5. Cut the first release.**
56
56
 
57
57
  ```bash
58
- git tag v2.4.11 && git push --tags
58
+ git tag v2.4.13 && git push --tags
59
59
  ```
60
60
 
61
61
  That tag runs `generated:check` → `version:check` → `npm publish`. Confirm under *Deploy →
@@ -187,7 +187,7 @@ GitLab injects `CI_JOB_TOKEN` — use that instead and no secret is stored anywh
187
187
  **Install — straight from git** (no registry, no publish; works today)
188
188
 
189
189
  ```json
190
- "@keyfunc/keybricks-ds": "git+ssh://git@gitlab.epm2demo.cn/Felix/keybricks-ds.git#v2.4.11"
190
+ "@keyfunc/keybricks-ds": "git+ssh://git@gitlab.epm2demo.cn/Felix/keybricks-ds.git#v2.4.13"
191
191
  ```
192
192
 
193
193
  Because the package has no build step, a git dependency needs no `prepare` script — npm
@@ -237,7 +237,7 @@ semantic layer. That is what makes dark mode cost nothing.
237
237
  ## Runtime checks
238
238
 
239
239
  `_tests/` holds browser pages that exercise the real components: `react-runtime.html` (the compiled
240
- bundle), `table-selection.html` (Table loaded from source: checkbox spacing at every width, code chips, two-line rows, sorting), `addrow-react.html` / `addrow-vue.html` (the SmartTable add row in both frameworks, from source: colour, weight, hover hooks, keyboard, `stickyAddRow`), `smart-table-density.html` (SmartTable from source: default / compact rows, the Row height menu, controlled density), `relative-time-picker.html` (RelativeTimePicker from source: helpers, tiers, dialog, keyboard), `tree-reorder.html` (Tree from source: insert-between on leaves and folder edges, self/subtree rejection), `vue-runtime.html` (the `.vue` files, loaded with vue3-sfc-loader), `cards-interaction*.html`
240
+ bundle), `table-selection.html` (Table loaded from source: checkbox spacing at every width, code chips, two-line rows, sorting), `menu-react.html` / `menu-vue.html` (Menu / ContextMenu coloured icons, item descriptions, side by alignment and flip), `addrow-react.html` / `addrow-vue.html` (the SmartTable add row in both frameworks, from source: colour, weight, hover hooks, keyboard, `stickyAddRow`), `smart-table-density.html` (SmartTable from source: default / compact rows, the Row height menu, controlled density), `relative-time-picker.html` (RelativeTimePicker from source: helpers, tiers, dialog, keyboard), `tree-reorder.html` (Tree from source: insert-between on leaves and folder edges, self/subtree rejection), `vue-runtime.html` (the `.vue` files, loaded with vue3-sfc-loader), `cards-interaction*.html`
241
241
  (clicks through the documentation demos) and `icon-rules.html` (the Keybricks glyphs against the drawing rules). Serve the repo root (`npx serve .`), open a page and read the
242
242
  console — every line starts with PASS or FAIL. Run them after changing a component in either framework.
243
243
  `measure-cards.html` is a tool, not a test: it measures each documentation page at its card width so the
package/INTEGRATION.md CHANGED
@@ -31,7 +31,7 @@ Not using a registry yet? A git dependency works with no publishing at all — t
31
31
  has no build step, so npm can use the checkout as-is:
32
32
 
33
33
  ```json
34
- "@keyfunc/keybricks-ds": "git+ssh://git@gitlab.epm2demo.cn/Felix/keybricks-ds.git#v2.4.11"
34
+ "@keyfunc/keybricks-ds": "git+ssh://git@gitlab.epm2demo.cn/Felix/keybricks-ds.git#v2.4.13"
35
35
  ```
36
36
 
37
37
  **Source-only package.** `.jsx` and `.vue` files are published as authored, so your
@@ -521,6 +521,17 @@ Pin a minor range and update deliberately:
521
521
  - **Tokens outside CSS.** `tokens.json` / `tokens.js` / `tokens.d.ts` gained the tier tokens
522
522
  (`--control-*`, `--bar-h`, `--group-gap`, …) — 352 light tokens in all (2.0.2 adds `--danger-soft-hover`).
523
523
 
524
+ ### 2.4.13
525
+
526
+ - **`Menu` / `ContextMenu` items take `iconColor` and `description`.** `iconColor` is any CSS colour for that item's icon (e.g. `var(--cat-join)`); omit it and the icon keeps the row's text colour. `description` shows a 300px panel beside the menu while the item is hovered or focused — its icon and label, then the text — linked with `aria-describedby`.
527
+ - **Panel side:** `Menu` opens it away from the trigger's edge — `align="end"` → left, `align="start"` → right; `ContextMenu` defaults to right. Both flip when the viewport has no room; `descriptionSide` overrides.
528
+ - `Menu` gains `defaultOpen` (docs and demos). Menus without descriptions render exactly as before.
529
+
530
+ ### 2.4.12
531
+
532
+ - **Documentation site only — no component or token changes.** The View Code source text and the icon search indexes moved to `_src/` at the repo root, out of the compiled bundle the docs pages load (1.9 MB → 0.8 MB). `assets/icons/icon-index.json` and `unicons-index.json` stay for apps; the `.js` copies of those indexes are gone from `assets/icons/`.
533
+ - The home page loads the cards you jump to from the sidebar right away (they used to wait for a scroll), and the Pages job publishes gzip copies of text files.
534
+
524
535
  ### 2.4.11
525
536
 
526
537
  - **`SmartTable` add row** — the "+ New" row at the end of the grid is the system's add-row: `--action-primary` text and 13px plus at `--weight-medium`; hover `--bg-inset` with `--action-primary-hover` (`[data-addrow]` in `tokens/hover.css` — load `keybricks.css`, or add `tokens/hover.css` if you import `styles.css` alone). It is a focusable `role="button"` (Enter / Space add a row) with the global focus ring drawn inset.
@@ -16,7 +16,6 @@
16
16
  "patterns": [
17
17
  {
18
18
  "group": [
19
- "assets/icons/**",
20
19
  "changelog.js",
21
20
  "components/**",
22
21
  "components/_shared/**",
@@ -186,8 +185,12 @@
186
185
  "message": "<ConfigPanel> size must be one of 'sm' | 'md' | 'lg'."
187
186
  },
188
187
  {
189
- "selector": "JSXOpeningElement[name.name='ContextMenu'] > JSXAttribute > JSXIdentifier[name!=/^(?:items|onSelect|size|style|key|ref|className|style|children)$/]",
190
- "message": "<ContextMenu> doesn't accept that prop. Declared props: items, onSelect, size, style."
188
+ "selector": "JSXOpeningElement[name.name='ContextMenu'] > JSXAttribute > JSXIdentifier[name!=/^(?:items|onSelect|descriptionSide|size|style|key|ref|className|style|children)$/]",
189
+ "message": "<ContextMenu> doesn't accept that prop. Declared props: items, onSelect, descriptionSide, size, style."
190
+ },
191
+ {
192
+ "selector": "JSXOpeningElement[name.name='ContextMenu'] > JSXAttribute[name.name='descriptionSide'] > Literal[value!=/^(?:left|right)$/]",
193
+ "message": "<ContextMenu> descriptionSide must be one of 'left' | 'right'."
191
194
  },
192
195
  {
193
196
  "selector": "JSXOpeningElement[name.name='ContextMenu'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md|lg)$/]",
@@ -326,13 +329,17 @@
326
329
  "message": "<Lucide> doesn't accept that prop. Declared props: name, size, strokeWidth, color, className, style."
327
330
  },
328
331
  {
329
- "selector": "JSXOpeningElement[name.name='Menu'] > JSXAttribute > JSXIdentifier[name!=/^(?:trigger|items|onSelect|align|style|key|ref|className|style|children)$/]",
330
- "message": "<Menu> doesn't accept that prop. Declared props: trigger, items, onSelect, align, style."
332
+ "selector": "JSXOpeningElement[name.name='Menu'] > JSXAttribute > JSXIdentifier[name!=/^(?:trigger|items|onSelect|align|descriptionSide|defaultOpen|style|key|ref|className|style|children)$/]",
333
+ "message": "<Menu> doesn't accept that prop. Declared props: trigger, items, onSelect, align, descriptionSide, defaultOpen, style."
331
334
  },
332
335
  {
333
336
  "selector": "JSXOpeningElement[name.name='Menu'] > JSXAttribute[name.name='align'] > Literal[value!=/^(?:start|end)$/]",
334
337
  "message": "<Menu> align must be one of 'start' | 'end'."
335
338
  },
339
+ {
340
+ "selector": "JSXOpeningElement[name.name='Menu'] > JSXAttribute[name.name='descriptionSide'] > Literal[value!=/^(?:left|right)$/]",
341
+ "message": "<Menu> descriptionSide must be one of 'left' | 'right'."
342
+ },
336
343
  {
337
344
  "selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|placement|children|style|key|ref|className|style|children)$/]",
338
345
  "message": "<Tooltip> doesn't accept that prop. Declared props: label, placement, children, style."
package/changelog.js CHANGED
@@ -3,6 +3,14 @@
3
3
  Each item: tag (new | improved | fixed | breaking | removed) and the text as [en, zh, ja].
4
4
  INTEGRATION.md keeps the full migration notes; this list is the short, reader-facing summary. */
5
5
  window.KB_CHANGELOG = [
6
+ { v: "2.4.13", date: "2026-10-29", items: [
7
+ { tag: "new", t: ["Menu items can show a description: hover or focus an item and a panel beside the menu explains it. It opens on the side away from the trigger and flips when there is no room.", "菜单项可以显示说明:悬停或用键盘选中某项时,菜单旁边会出现说明面板。面板出现在远离按钮的一侧,空间不够时自动换到另一侧。", "メニュー項目に説明を表示可能に。項目にホバーまたはフォーカスすると、メニューの横に説明パネルが開く。トリガーと反対側に開き、余白がなければ反対側へ切り替わる。"] },
8
+ { tag: "new", t: ["Menu item icons can be coloured with <code>iconColor</code>; without it they stay single-colour.", "菜单项图标可用 <code>iconColor</code> 设为彩色;不设置则保持单色。", "メニュー項目のアイコンを <code>iconColor</code> で色付け可能に。指定しなければ単色のまま。"] },
9
+ ] },
10
+ { v: "2.4.12", date: "2026-10-28", items: [
11
+ { tag: "fixed", t: ["Home page: cards you jump to from the sidebar load right away instead of staying on “loading…” until you scroll.", "首页:从侧边栏跳转到的组件卡片会立即加载,不再一直显示“加载中…”直到滚动页面。", "ホーム:サイドバーからジャンプしたカードがすぐ読み込まれるように。スクロールするまで「読み込み中…」のままだった問題を修正。"] },
12
+ { tag: "improved", t: ["The documentation site loads faster: its component bundle is less than half the size (1.9 MB → 0.8 MB), and files are served compressed.", "文档站加载更快:组件包体积减少一半以上(1.9 MB → 0.8 MB),文件改为压缩传输。", "ドキュメントサイトの読み込みを高速化:コンポーネントバンドルを半分以下に(1.9 MB → 0.8 MB)、ファイルを圧縮配信。"] },
13
+ ] },
6
14
  { v: "2.4.11", date: "2026-10-28", items: [
7
15
  { tag: "improved", t: ["<code>SmartTable</code> “+ New” row: blue text and plus at medium weight, a grey hover, and keyboard support (Tab, then Enter or Space).", "<code>SmartTable</code> 的 “+ New” 行改为蓝色文字和加号、中等字重,悬停显示灰底,并支持键盘操作(Tab 选中后按 Enter 或空格)。", "<code>SmartTable</code> の「+ New」行を青い文字とプラス、ミディアムの太さに。ホバーでグレーの背景、キーボード操作(Tab のあと Enter / Space)に対応。"] },
8
16
  { tag: "new", t: ["<code>SmartTable</code> <code>stickyAddRow</code> keeps the add row at the bottom while the table scrolls; <code>labels.newRow</code> sets its text.", "<code>SmartTable</code> 新增 <code>stickyAddRow</code>:表格滚动时新增行固定在底部;<code>labels.newRow</code> 可设置文字。", "<code>SmartTable</code> に <code>stickyAddRow</code> を追加:スクロール中も追加行を下端に固定。<code>labels.newRow</code> で文字を設定。"] },
@@ -1,4 +1,4 @@
1
- <!-- @dsCard group="Components" viewport="700x420" name="Dropdown Menu" subtitle="Button · icon-only · ghost text · avatar triggers — icon items & plain-text items" -->
1
+ <!-- @dsCard group="Components" viewport="700x980" name="Dropdown Menu" subtitle="Button · icon-only · ghost text · avatar triggers — icon items & plain-text items · coloured icons · item descriptions" -->
2
2
  <!DOCTYPE html>
3
3
  <html><head><meta charset="utf-8"><script src="../_shared/frame-theme.js"></script><link rel="stylesheet" href="../../styles.css"><link rel="stylesheet" href="../../tokens/hover.css"><link rel="stylesheet" href="../_shared/card-kit.css">
4
4
  <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
@@ -6,10 +6,18 @@
6
6
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
7
7
  <script src="../../_ds_bundle.js"></script>
8
8
  <script src="../_shared/view-source.js"></script>
9
- <style>body{margin:0;background:var(--bg-surface);padding:16px;font-family:var(--font-sans);box-sizing:border-box}.g{display:grid;grid-template-columns:repeat(3,1fr);gap:28px 16px}.lbl{font-size:var(--text-xs);color:var(--text-muted);display:block;padding-bottom:10px}</style></head>
9
+ <style>body{margin:0;background:var(--bg-surface);padding:16px;font-family:var(--font-sans);box-sizing:border-box}.g{display:grid;grid-template-columns:repeat(3,1fr);gap:28px 16px}.lbl{font-size:var(--text-xs);color:var(--text-muted);display:block;padding-bottom:10px}.sec{margin-top:36px;padding-top:16px;border-top:1px solid var(--border-subtle)}.note{font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary);margin:0 0 12px;text-wrap:pretty}.bar{display:flex;align-items:center;gap:8px;height:250px;align-items:flex-start}</style></head>
10
10
  <body><div id="root"></div>
11
11
  <script type="text/babel">
12
- const { Menu, Button, IconButton, Avatar } = window.KeybricksDesignSystem_71231a;
12
+ const { Menu, Button, IconButton, Avatar, SearchInput } = window.KeybricksDesignSystem_71231a;
13
+ const MODEL_TYPES = [
14
+ { label: "Consolidation Model", icon: "consolidation", iconColor: "var(--cat-transform)", description: "For statutory or management consolidation. Consolidation needs consolidation groups, so attach an Ownership Model. If the data spans several currencies, also attach a Rate Model to convert it." },
15
+ { label: "Planning/Forecasting Model", icon: "trending-up", iconColor: "var(--cat-join)", description: "For budgeting or forecasting. If the data spans several currencies, attach a Rate Model to convert it." },
16
+ { label: "Rate Model", icon: "dimension-currency", iconColor: "var(--cat-source)", description: "Stores the exchange rates used to convert between currencies." },
17
+ { label: "Ownership Model", icon: "ownership", iconColor: "var(--cat-merge)", description: "Stores the consolidation group structure: the entities in each group, direct and indirect holdings, control and consolidation percentages." },
18
+ ];
19
+ /* Demo only: hover the second item of each open menu so the description panel shows without a pointer. */
20
+ const preHover = () => document.querySelectorAll(".pre [role=menu]").forEach(m => { const b = m.querySelectorAll("[role=menuitem]")[1]; if (b) b.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); });
13
21
  const withIcons = [{label:"Preview",icon:"eye"},{label:"Duplicate",icon:"copy"},{divider:true},{label:"Delete",icon:"trash-2",danger:true}];
14
22
  const plainText = [{label:"Name"},{label:"Date modified"},{label:"Size"},{divider:true},{label:"Custom order"}];
15
23
  function Sheet(){return <div className="g">
@@ -33,6 +41,21 @@ function Sheet(){return <div className="g">
33
41
  <Menu trigger={<Button variant="outline" icon="filter" iconPosition="right">Filter</Button>} items={[{label:"All"},{label:"Active only"},{label:"Archived only"}]}/>
34
42
  </div>
35
43
  </div>;}
36
- ReactDOM.createRoot(document.getElementById("root")).render(<Sheet/>);
37
- if (window.KBViewSource) setTimeout(function(){ KBViewSource.mount("#root", [{"name":"Menu.jsx","path":"../feedback/Menu.jsx"},{"name":"Button.jsx","path":"../buttons/Button.jsx"},{"name":"IconButton.jsx","path":"../buttons/IconButton.jsx"},{"name":"Avatar.jsx","path":"../display/Avatar.jsx"}]); }, 0);
44
+ function Described(){return <div>
45
+ <div className="sec"><span className="lbl">Coloured icons + descriptions — trigger at the right: <code>align="end"</code>, the description opens on the left</span>
46
+ <p className="note">Each item takes <code>iconColor</code> (any colour token; omit it for a single-colour icon) and <code>description</code>. Hovering or focusing an item shows its icon, label and description in a 300px panel beside the menu. The panel opens on the side away from the trigger's edge and flips if the window has no room there.</p>
47
+ <div className="bar pre" style={{justifyContent:"flex-end"}}>
48
+ <SearchInput placeholder="Search models" style={{width:200}}/>
49
+ <Menu align="end" defaultOpen trigger={<Button variant="primary" icon="plus">New Model</Button>} items={MODEL_TYPES}/>
50
+ </div>
51
+ </div>
52
+ <div className="sec"><span className="lbl">Trigger at the far left: <code>align="start"</code>, the description opens on the right · single-colour icons</span>
53
+ <div className="bar pre">
54
+ <Menu defaultOpen trigger={<Button variant="outline" icon="plus">New Model</Button>} items={MODEL_TYPES.map(({ iconColor, ...it }) => it)}/>
55
+ </div>
56
+ </div>
57
+ </div>;}
58
+ ReactDOM.createRoot(document.getElementById("root")).render(<><Sheet/><Described/></>);
59
+ setTimeout(preHover, 300);
60
+ if (window.KBViewSource) setTimeout(function(){ KBViewSource.mount("#root", [{"name":"Menu.jsx","path":"../feedback/Menu.jsx"},{"name":"ContextMenu.jsx","path":"../feedback/ContextMenu.jsx"},{"name":"Button.jsx","path":"../buttons/Button.jsx"},{"name":"IconButton.jsx","path":"../buttons/IconButton.jsx"},{"name":"Avatar.jsx","path":"../display/Avatar.jsx"}]); }, 0);
38
61
  </script></body></html>
@@ -6,7 +6,7 @@
6
6
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
7
7
  <script src="../../_ds_bundle.js"></script>
8
8
  <script src="../_shared/svg-download.js"></script>
9
- <script src="../../assets/icons/icon-index.js"></script>
9
+ <script src="../../_src/icon-index.js"></script>
10
10
  <style>body{margin:0;background:var(--bg-surface);padding:14px 16px;box-sizing:border-box}</style></head>
11
11
  <body><div id="root"></div>
12
12
  <script type="text/babel">
@@ -6,7 +6,7 @@
6
6
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
7
7
  <script src="../../_ds_bundle.js"></script>
8
8
  <script src="../_shared/svg-download.js"></script>
9
- <script src="../../assets/icons/icon-index.js"></script>
9
+ <script src="../../_src/icon-index.js"></script>
10
10
  <style>body{margin:0;background:var(--bg-surface);padding:16px;font-family:var(--font-sans);box-sizing:border-box}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:2px;padding-top:12px}.tile{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 2px 8px;border-radius:8px;cursor:pointer;border:1px solid transparent}.tile:hover{background:var(--bg-hover)}.tile span{font-size:var(--text-xs);line-height:16px;color:var(--text-secondary);max-width:100%;text-align:center;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta{font-size:var(--text-xs);color:var(--text-muted);padding-top:8px}</style></head>
11
11
  <body><div id="root"></div>
12
12
  <script type="text/babel">
@@ -6,7 +6,7 @@
6
6
  <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
7
7
  <script src="../../_ds_bundle.js"></script>
8
8
  <script src="../_shared/svg-download.js"></script>
9
- <script src="../../assets/icons/unicons-index.js"></script>
9
+ <script src="../../_src/unicons-index.js"></script>
10
10
  <style>body{margin:0;background:var(--bg-surface);padding:16px;font-family:var(--font-sans);box-sizing:border-box}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:2px;padding-top:12px}.tile{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 2px 8px;border-radius:8px;cursor:pointer;border:1px solid transparent}.tile:hover{background:var(--bg-hover)}.tile span{font-size:var(--text-xs);line-height:16px;color:var(--text-secondary);max-width:100%;text-align:center;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.meta{font-size:var(--text-xs);color:var(--text-muted);padding-top:8px;line-height:1.5}</style></head>
11
11
  <body><div id="root"></div>
12
12
  <script type="text/babel">
@@ -1,13 +1,17 @@
1
1
  (function(){
2
2
  var bundles = {};
3
- /* Component source is loaded on demand, one script per folder (components/_shared/src-<dir>.js).
3
+ /* Component source is loaded on demand, one script per folder (_src/src-<dir>.js at the project root).
4
+ It lives outside components/ so the design-system compiler keeps it out of _ds_bundle.js — ~870 KB of
5
+ source text every card and the home page would otherwise download and parse just to show View Code.
6
+ _src/ also holds the icon search indexes (icon-index.js, unicons-index.js) that only the icon gallery cards load;
7
+ assets/icons/*.json keep the same data for apps.
4
8
  A <script> tag works from file:// and from any host; fetch() of a sibling source file does not. */
5
9
  function loadBundle(dir, done){
6
10
  if (!dir || bundles[dir] === 'done') return done();
7
11
  if (bundles[dir]) { bundles[dir].push(done); return; }
8
12
  bundles[dir] = [done];
9
13
  var s = document.createElement('script');
10
- s.src = '../_shared/src-' + dir + '.js';
14
+ s.src = '../../_src/src-' + dir + '.js';
11
15
  s.onload = s.onerror = function(){
12
16
  var q = bundles[dir]; bundles[dir] = 'done';
13
17
  q.forEach(function(fn){ fn(); });
@@ -2,8 +2,10 @@ import * as React from "react";
2
2
  /** Floating context menu — r10, soft shadow, control-height rows at the region's tier (32px / 14px at md), red danger row.
3
3
  * Action menus keep rows flush; a single-choice menu (any item `selected`) spaces rows 2px apart. Dividers keep 4px above and below. */
4
4
  export interface ContextMenuProps {
5
- items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
5
+ items?: { label?: string; icon?: string; /** Colour for this item's icon — any CSS colour, e.g. "var(--cat-join)". Omit for a single-colour icon in the row's text colour. */ iconColor?: string; /** Shown in a 300px panel beside the menu while the item is hovered or focused: the item's icon and label, then this text. */ description?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
6
6
  onSelect?: (label: string) => void;
7
+ /** Side the description panel opens on (default "right"); it flips to the other side when the viewport has no room. */
8
+ descriptionSide?: "left" | "right";
7
9
  /** Size tier of the rows. Omit to inherit the tier of the control that opened the menu (the default). */
8
10
  size?: "sm" | "md" | "lg";
9
11
  style?: React.CSSProperties;
@@ -1,22 +1,60 @@
1
1
  import React from "react";
2
2
  import { Icon } from "../icons/Icon.jsx";
3
3
 
4
- /** Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
4
+ const HINT_W = 300, HINT_GAP = 8;
5
+
6
+ /** Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, iconColor, description, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
7
  * selected: true marks the current choice of a single-choice menu (section switcher, language picker): --bg-hover fill, --weight-medium
6
8
  * --text-heading, no checkmark. A menu with any selected item spaces its rows 2px apart (like Sidebar / Tree / DocTree) so a hovered
7
- * fill never touches the selected one; an action menu (nothing selected) keeps its rows flush. Dividers keep 4px above and below. */
8
- export function ContextMenu({ items = [], onSelect, size, style }) {
9
+ * fill never touches the selected one; an action menu (nothing selected) keeps its rows flush. Dividers keep 4px above and below.
10
+ * iconColor: any CSS colour for that item's icon (e.g. "var(--cat-join)"); without it the icon takes the row's text colour.
11
+ * description: hovering or focusing the item shows a 300px panel beside the menu — the item's icon and label, then the description.
12
+ * It opens on descriptionSide ("right" by default) and flips to the other side when the viewport has no room. */
13
+ export function ContextMenu({ items = [], onSelect, size, descriptionSide = "right", style }) {
9
14
  const [hover, setHover] = React.useState(-1);
15
+ const [hint, setHint] = React.useState(-1);
16
+ const [side, setSide] = React.useState(descriptionSide);
17
+ const wrap = React.useRef(null);
18
+ const hintId = React.useId();
10
19
  const choice = items.some((it) => it && it.selected);
11
- return (
12
- <div role="menu" data-size={size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
20
+ const hasHint = items.some((it) => it && it.description);
21
+ const show = (i) => {
22
+ setHover(i);
23
+ if (!hasHint) return;
24
+ const it = items[i];
25
+ if (!it || !it.description) { setHint(-1); return; }
26
+ const r = wrap.current && wrap.current.getBoundingClientRect();
27
+ let s = descriptionSide;
28
+ if (r) { if (s === "right" && r.right + HINT_GAP + HINT_W > window.innerWidth && r.left - HINT_GAP - HINT_W >= 0) s = "left"; else if (s === "left" && r.left - HINT_GAP - HINT_W < 0 && r.right + HINT_GAP + HINT_W <= window.innerWidth) s = "right"; }
29
+ setSide(s); setHint(i);
30
+ };
31
+ const onBlur = (e) => { setHover(-1); if (hasHint && !(wrap.current && wrap.current.contains(e.relatedTarget))) setHint(-1); };
32
+ const menu = (
33
+ <div role="menu" data-size={hasHint ? undefined : size} style={{ background: "var(--bg-surface)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-menu)", padding: 4, minWidth: 148, display: "inline-flex", flexDirection: "column", gap: choice ? 2 : 0, ...style }}>
13
34
  {items.map((it, i) => it.divider ? <div key={i} role="separator" style={{ height: 1, flexShrink: 0, background: "var(--border-subtle)", margin: "4px 0" }} /> : (
14
35
  <button key={i} type="button" onClick={() => onSelect && onSelect(it.label)}
15
- onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(-1)}
16
- role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap" }}>
17
- {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)" }} /> : null}{it.label}
36
+ onMouseEnter={() => show(i)} onMouseLeave={() => setHover(-1)} onFocus={() => show(i)} onBlur={onBlur}
37
+ aria-describedby={hint === i ? hintId : undefined}
38
+ role={choice ? "menuitemradio" : "menuitem"} aria-checked={choice ? !!it.selected : undefined} style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--control-h)", flexShrink: 0, padding: "0 10px", boxSizing: "border-box", borderRadius: "var(--radius-md)", border: "none", cursor: "pointer", background: it.selected || hover === i || hint === i ? "var(--bg-hover)" : "transparent", fontFamily: "var(--font-sans)", fontWeight: it.selected ? "var(--weight-medium)" : "var(--weight-regular)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: it.danger ? "var(--danger)" : "var(--text-heading)", textAlign: "left", whiteSpace: "nowrap" }}>
39
+ {it.icon ? <Icon name={it.icon} size={16} style={{ width: "var(--control-icon)", height: "var(--control-icon)", flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) }} /> : null}{it.label}
18
40
  </button>
19
41
  ))}
20
42
  </div>
21
43
  );
44
+ if (!hasHint) return menu;
45
+ const h = items[hint];
46
+ return (
47
+ <div ref={wrap} data-size={size} onMouseLeave={() => setHint(-1)} style={{ position: "relative", display: "inline-flex" }}>
48
+ {menu}
49
+ {h && h.description ? (
50
+ <div id={hintId} role="tooltip" style={{ position: "absolute", top: 0, [side === "left" ? "right" : "left"]: `calc(100% + ${HINT_GAP}px)`, width: HINT_W, boxSizing: "border-box", padding: "12px 14px", display: "flex", flexDirection: "column", gap: 6, background: "var(--bg-surface)", border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-menu)", fontFamily: "var(--font-sans)", animation: "kb-fade-in var(--dur) var(--ease-out)" }}>
51
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
52
+ {h.icon ? <Icon name={h.icon} size={16} style={{ flexShrink: 0, color: h.iconColor || (h.danger ? "var(--danger)" : "var(--text-heading)") }} /> : null}
53
+ <span style={{ fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", fontWeight: "var(--weight-medium)", color: "var(--text-heading)" }}>{h.label}</span>
54
+ </div>
55
+ <span style={{ fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", letterSpacing: "var(--tracking-xs)", color: "var(--text-secondary)", textWrap: "pretty" }}>{h.description}</span>
56
+ </div>
57
+ ) : null}
58
+ </div>
59
+ );
22
60
  }
@@ -1,21 +1,61 @@
1
1
  <script setup>
2
2
  import { ref, computed } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
- // Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
4
+ // Right-click context menu (Preview / Duplicate / Delete). items: [{label, icon, iconColor, description, danger, divider, selected}]. Rows are control-height at the region's tier (a menu inherits the tier of the control that opened it); `size` overrides.
5
5
  // selected: true marks the current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. A menu with any
6
6
  // selected item spaces its rows 2px apart (like Sidebar / Tree / DocTree); an action menu keeps its rows flush. Dividers keep 4px above and below.
7
- const props = defineProps({ items: { type: Array, default: () => [] }, size: String, style: Object })
7
+ // iconColor: any CSS colour for that item's icon; without it the icon takes the row's text colour.
8
+ // description: hovering or focusing the item shows a 300px panel beside the menu (icon + label, then the description), on descriptionSide
9
+ // ("right" by default), flipping to the other side when the viewport has no room.
10
+ const props = defineProps({ items: { type: Array, default: () => [] }, size: String, descriptionSide: { type: String, default: 'right' }, style: Object })
8
11
  const choice = computed(() => props.items.some(it => it && it.selected))
12
+ const hasHint = computed(() => props.items.some(it => it && it.description))
9
13
  const emit = defineEmits(['select'])
10
- const hover = ref(-1)
14
+ const hover = ref(-1), hint = ref(-1), side = ref(props.descriptionSide), wrap = ref(null)
15
+ const HINT_W = 300, HINT_GAP = 8
16
+ const hintId = 'kb-cm-hint-' + Math.random().toString(36).slice(2, 8)
17
+ function show(i) {
18
+ hover.value = i
19
+ if (!hasHint.value) return
20
+ const it = props.items[i]
21
+ if (!it || !it.description) { hint.value = -1; return }
22
+ const r = wrap.value && wrap.value.getBoundingClientRect()
23
+ let s = props.descriptionSide
24
+ if (r) { if (s === 'right' && r.right + HINT_GAP + HINT_W > window.innerWidth && r.left - HINT_GAP - HINT_W >= 0) s = 'left'; else if (s === 'left' && r.left - HINT_GAP - HINT_W < 0 && r.right + HINT_GAP + HINT_W <= window.innerWidth) s = 'right' }
25
+ side.value = s; hint.value = i
26
+ }
27
+ function onBlur(e) { hover.value = -1; if (hasHint.value && !(wrap.value && wrap.value.contains(e.relatedTarget))) hint.value = -1 }
28
+ const cur = computed(() => props.items[hint.value])
29
+ const menuStyle = computed(() => ({ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', display: 'inline-flex', flexDirection: 'column', gap: choice.value ? '2px' : 0, ...props.style }))
30
+ const rowStyle = (it, i) => ({ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover.value === i || hint.value === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap' })
31
+ const iconStyle = (it) => ({ width: 'var(--control-icon)', height: 'var(--control-icon)', flexShrink: 0, ...(it.iconColor ? { color: it.iconColor } : null) })
32
+ const hintStyle = computed(() => ({ position: 'absolute', top: 0, [side.value === 'left' ? 'right' : 'left']: `calc(100% + ${HINT_GAP}px)`, width: HINT_W + 'px', boxSizing: 'border-box', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '6px', background: 'var(--bg-surface)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', fontFamily: 'var(--font-sans)', animation: 'kb-fade-in var(--dur) var(--ease-out)' }))
11
33
  </script>
12
34
  <template>
13
- <div role="menu" :data-size="size" :style="{ background: 'var(--bg-surface)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-menu)', padding: '4px', minWidth: '148px', display: 'inline-flex', flexDirection: 'column', gap: choice ? '2px' : 0, ...style }">
35
+ <div v-if="hasHint" ref="wrap" :data-size="size" @mouseleave="hint = -1" :style="{ position: 'relative', display: 'inline-flex' }">
36
+ <div role="menu" :style="menuStyle">
37
+ <template v-for="(it, i) in items" :key="i">
38
+ <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
39
+ <button v-else type="button" @click="emit('select', it.label)" @mouseenter="show(i)" @mouseleave="hover = -1" @focus="show(i)" @blur="onBlur"
40
+ :aria-describedby="hint === i ? hintId : undefined" :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="rowStyle(it, i)">
41
+ <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" />{{ it.label }}
42
+ </button>
43
+ </template>
44
+ </div>
45
+ <div v-if="cur && cur.description" :id="hintId" role="tooltip" :style="hintStyle">
46
+ <div :style="{ display: 'flex', alignItems: 'center', gap: '8px' }">
47
+ <Icon v-if="cur.icon" :name="cur.icon" :size="16" :style="{ flexShrink: 0, color: cur.iconColor || (cur.danger ? 'var(--danger)' : 'var(--text-heading)') }" />
48
+ <span :style="{ fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', fontWeight: 'var(--weight-medium)', color: 'var(--text-heading)' }">{{ cur.label }}</span>
49
+ </div>
50
+ <span :style="{ fontSize: 'var(--text-xs)', lineHeight: 'var(--leading-xs)', letterSpacing: 'var(--tracking-xs)', color: 'var(--text-secondary)', textWrap: 'pretty' }">{{ cur.description }}</span>
51
+ </div>
52
+ </div>
53
+ <div v-else role="menu" :data-size="size" :style="menuStyle">
14
54
  <template v-for="(it, i) in items" :key="i">
15
55
  <div v-if="it.divider" role="separator" :style="{ height: '1px', flexShrink: 0, background: 'var(--border-subtle)', margin: '4px 0' }" />
16
- <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1"
17
- :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="{ display: 'flex', alignItems: 'center', gap: '10px', height: 'var(--control-h)', flexShrink: 0, padding: '0 10px', boxSizing: 'border-box', borderRadius: 'var(--radius-md)', border: 'none', cursor: 'pointer', background: it.selected || hover === i ? 'var(--bg-hover)' : 'transparent', fontFamily: 'var(--font-sans)', fontWeight: it.selected ? 'var(--weight-medium)' : 'var(--weight-regular)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: it.danger ? 'var(--danger)' : 'var(--text-heading)', textAlign: 'left', whiteSpace: 'nowrap' }">
18
- <Icon v-if="it.icon" :name="it.icon" :size="16" :style="{ width: 'var(--control-icon)', height: 'var(--control-icon)' }" />{{ it.label }}
56
+ <button v-else type="button" @click="emit('select', it.label)" @mouseenter="hover = i" @mouseleave="hover = -1" @focus="hover = i" @blur="hover = -1"
57
+ :role="choice ? 'menuitemradio' : 'menuitem'" :aria-checked="choice ? !!it.selected : undefined" :style="rowStyle(it, i)">
58
+ <Icon v-if="it.icon" :name="it.icon" :size="16" :style="iconStyle(it)" />{{ it.label }}
19
59
  </button>
20
60
  </template>
21
61
  </div>
@@ -2,9 +2,13 @@ import * as React from "react";
2
2
  /** Trigger-anchored dropdown menu (reuses ContextMenu's look). Addition — for "Show ⌄"-style buttons and user menus. */
3
3
  export interface MenuProps {
4
4
  trigger: React.ReactNode;
5
- items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
5
+ items?: { label?: string; icon?: string; /** Colour for this item's icon — any CSS colour, e.g. "var(--cat-join)". Omit for a single-colour icon in the row's text colour. */ iconColor?: string; /** Shown in a 300px panel beside the menu while the item is hovered or focused: the item's icon and label, then this text. */ description?: string; danger?: boolean; divider?: boolean; /** Current choice of a single-choice menu: --bg-hover fill, --weight-medium --text-heading, no checkmark. Any selected item spaces the rows 2px apart. */ selected?: boolean }[];
6
6
  onSelect?: (label: string) => void;
7
7
  align?: "start" | "end";
8
+ /** Side for item descriptions. Default: away from the trigger's edge — "left" when align="end", "right" when align="start". Flips if the viewport has no room. */
9
+ descriptionSide?: "left" | "right";
10
+ /** Start open (docs and demos). */
11
+ defaultOpen?: boolean;
8
12
  style?: React.CSSProperties;
9
13
  }
10
14
  export declare function Menu(props: MenuProps): JSX.Element;
@@ -1,9 +1,11 @@
1
1
  import React from "react";
2
2
  import { ContextMenu } from "./ContextMenu.jsx";
3
3
 
4
- /** Click-to-open dropdown menu anchored under its trigger (vs. ContextMenu, which opens at a click point). Closes on outside click. */
5
- export function Menu({ trigger, items = [], onSelect, align = "start", style }) {
6
- const [open, setOpen] = React.useState(false);
4
+ /** Click-to-open dropdown menu anchored under its trigger (vs. ContextMenu, which opens at a click point). Closes on outside click.
5
+ * Items take ContextMenu's fields (icon, iconColor, description, …). Item descriptions open on the side away from the trigger's edge:
6
+ * align="end" (trigger at the right) → left, align="start" (trigger at the left) → right; descriptionSide overrides. */
7
+ export function Menu({ trigger, items = [], onSelect, align = "start", descriptionSide, defaultOpen = false, style }) {
8
+ const [open, setOpen] = React.useState(defaultOpen);
7
9
  const ref = React.useRef(null);
8
10
  React.useEffect(() => {
9
11
  if (!open) return;
@@ -16,7 +18,7 @@ export function Menu({ trigger, items = [], onSelect, align = "start", style })
16
18
  <span onClick={() => setOpen((o) => !o)}>{trigger}</span>
17
19
  {open ? (
18
20
  <div style={{ position: "absolute", top: "calc(100% + 6px)", [align === "end" ? "right" : "left"]: 0, zIndex: "var(--z-menu)" }}>
19
- <ContextMenu items={items} onSelect={(l) => { setOpen(false); onSelect && onSelect(l); }} />
21
+ <ContextMenu items={items} descriptionSide={descriptionSide || (align === "end" ? "left" : "right")} onSelect={(l) => { setOpen(false); onSelect && onSelect(l); }} />
20
22
  </div>
21
23
  ) : null}
22
24
  </div>
@@ -2,9 +2,11 @@
2
2
  import { ref, onMounted, onUnmounted } from 'vue'
3
3
  import ContextMenu from './ContextMenu.vue'
4
4
  // Click-to-open dropdown menu anchored under its trigger (vs. ContextMenu, which opens at a click point). Closes on outside click.
5
- const props = defineProps({ items: { type: Array, default: () => [] }, align: { type: String, default: 'start' }, style: Object })
5
+ // Items take ContextMenu's fields (icon, iconColor, description, …). Descriptions open on the side away from the trigger's edge:
6
+ // align="end" → left, align="start" → right; descriptionSide overrides.
7
+ const props = defineProps({ items: { type: Array, default: () => [] }, align: { type: String, default: 'start' }, descriptionSide: String, defaultOpen: Boolean, style: Object })
6
8
  const emit = defineEmits(['select'])
7
- const open = ref(false)
9
+ const open = ref(props.defaultOpen)
8
10
  const root = ref(null)
9
11
  function onDoc(e) { if (root.value && !root.value.contains(e.target)) open.value = false }
10
12
  onMounted(() => document.addEventListener('mousedown', onDoc))
@@ -15,7 +17,7 @@ function select(l) { open.value = false; emit('select', l) }
15
17
  <div ref="root" :style="{ position: 'relative', display: 'inline-flex', ...style }">
16
18
  <span @click="open = !open"><slot name="trigger" /></span>
17
19
  <div v-if="open" :style="{ position: 'absolute', top: 'calc(100% + 6px)', [align === 'end' ? 'right' : 'left']: 0, zIndex: 'var(--z-menu)' }">
18
- <ContextMenu :items="items" @select="select" />
20
+ <ContextMenu :items="items" :description-side="descriptionSide || (align === 'end' ? 'left' : 'right')" @select="select" />
19
21
  </div>
20
22
  </div>
21
23
  </template>
package/index.html CHANGED
@@ -192,7 +192,7 @@ section{margin-bottom:52px;scroll-margin-top:78px}
192
192
  const GROUPS=[["Brand","品牌","ブランド"],["Colors","颜色","カラー"],["Type","字体排印","タイポグラフィ"],["Foundations","基础规范","基盤仕様"],["Components","组件","コンポーネント"],["Charts","图表","チャート"],["Icons","图标库","アイコン"]];
193
193
 
194
194
  const S={
195
- brandsub:["Design System · v2.4.11","设计系统 · v2.4.11","デザインシステム · v2.4.11"],
195
+ brandsub:["Design System · v2.4.13","设计系统 · v2.4.13","デザインシステム · v2.4.13"],
196
196
  doc_readme:["Readme","说明文档","ドキュメント"],
197
197
  doc_design:["DESIGN.md","下载 DESIGN.md","DESIGN.md"],
198
198
  doc_integration:["Integration","接入指南","導入ガイド"],
@@ -355,7 +355,7 @@ function settleOn(target){
355
355
  const pin=()=>{const d=target.getBoundingClientRect().top-72;if(Math.abs(d)>1)window.scrollTo({top:sc.scrollTop+d,behavior:"instant"});markNav();};
356
356
  const ro=new ResizeObserver(pin);ro.observe(lib);document.querySelectorAll("#view-library .frame-wrap").forEach(w=>ro.observe(w));
357
357
  const evs=["wheel","touchmove","keydown","mousedown"];
358
- const off=()=>{ro.disconnect();markNav();clearTimeout(t);evs.forEach(e=>window.removeEventListener(e,off,true));if(settleOff===off)settleOff=null;};
358
+ const off=()=>{ro.disconnect();markNav();clearTimeout(t);evs.forEach(e=>window.removeEventListener(e,off,true));if(settleOff===off)settleOff=null;loadNearFrames();};
359
359
  evs.forEach(e=>window.addEventListener(e,off,{capture:true,passive:true}));
360
360
  const t=setTimeout(off,10000);settleOff=off;
361
361
  pin();requestAnimationFrame(pin);
@@ -527,21 +527,29 @@ function fit(f){
527
527
  f.style.transform=scale<1?"scale("+scale+")":"none";
528
528
  wrap.style.height=Math.round(h*scale)+"px";
529
529
  }
530
+ /* A card loads when it comes within 500px of the viewport — also while a jump is being pinned (settleOn): the pin
531
+ exists to absorb exactly those cards growing. (An early return here once left the jumped-to cards on "loading…" until
532
+ the reader scrolled, since an observer reports an element only when its intersection changes.) */
533
+ let frameIO=null;
534
+ function loadFrame(f){
535
+ if(!f.dataset.src)return;
536
+ if(frameIO)frameIO.unobserve(f);
537
+ f.addEventListener("load",()=>{
538
+ if(f.__ph)f.__ph.remove();
539
+ pushTheme(f);fit(f);
540
+ [120,400,1000,2000].forEach(d=>setTimeout(()=>{pushTheme(f);fit(f);},d));
541
+ try{new f.contentWindow.ResizeObserver(()=>fit(f)).observe(f.contentDocument.documentElement);}catch(err){}
542
+ });
543
+ f.src=f.dataset.src;f.removeAttribute("data-src");
544
+ }
545
+ /* Safety net after a pinned jump ends: load whatever pending card is now near the viewport. */
546
+ function loadNearFrames(){
547
+ document.querySelectorAll("iframe[data-src]").forEach(f=>{const r=f.__wrap?f.__wrap.getBoundingClientRect():f.getBoundingClientRect();if(r.bottom>-500&&r.top<innerHeight+500)loadFrame(f);});
548
+ }
530
549
  function watchFrames(){
531
550
  const frames=[...document.querySelectorAll("iframe[data-src]")];
532
- const io=new IntersectionObserver(es=>{
533
- if(settleOff)return;
534
- es.forEach(e=>{
535
- if(!e.isIntersecting)return;
536
- const f=e.target;io.unobserve(f);
537
- f.addEventListener("load",()=>{
538
- if(f.__ph)f.__ph.remove();
539
- pushTheme(f);fit(f);
540
- [120,400,1000,2000].forEach(d=>setTimeout(()=>{pushTheme(f);fit(f);},d));
541
- try{new f.contentWindow.ResizeObserver(()=>fit(f)).observe(f.contentDocument.documentElement);}catch(err){}
542
- });
543
- f.src=f.dataset.src;f.removeAttribute("data-src");
544
- });
551
+ const io=frameIO=new IntersectionObserver(es=>{
552
+ es.forEach(e=>{if(e.isIntersecting)loadFrame(e.target);});
545
553
  },{rootMargin:"500px 0px"});
546
554
  frames.forEach(f=>io.observe(f));
547
555
  addEventListener("resize",()=>document.querySelectorAll("iframe").forEach(f=>{if(f.__wrap&&!f.dataset.src)fit(f);}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyfunc/keybricks-ds",
3
- "version": "2.4.11",
3
+ "version": "2.4.13",
4
4
  "description": "Keybricks design system — design tokens, three size tiers and 121 UI components in React and Vue 3, source-only.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",