@keyfunc/keybricks-ds 2.7.0 → 2.8.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.
- package/DEPLOY.md +3 -3
- package/DESIGN.md +8 -1
- package/INTEGRATION.md +18 -2
- package/_adherence.oxlintrc.json +0 -1
- package/_ds_bundle.js +293 -307
- package/{components/feedback → _vue-services}/MESSAGE_SERVICES.md +2 -2
- package/_vue-services/_internal/MessageBoxView.vue +150 -0
- package/{components/feedback → _vue-services}/_internal/service-utils.js +0 -12
- package/_vue-services/message.js +87 -0
- package/changelog.js +10 -1
- package/components/_cards/modal.card.html +8 -2
- package/components/_cards/table.card.html +10 -1
- package/components/_cards/toast.card.html +11 -1
- package/components/_cards/tree.card.html +22 -1
- package/components/data/Table.d.ts +5 -0
- package/components/data/Table.jsx +10 -5
- package/components/data/Table.vue +9 -3
- package/components/data/data.prompt.md +2 -0
- package/components/data/row-open.js +13 -0
- package/components/feedback/Modal.d.ts +58 -7
- package/components/feedback/Modal.jsx +46 -16
- package/components/feedback/Modal.vue +24 -6
- package/components/feedback/Toast.vue +3 -3
- package/components/feedback/ToastStack.vue +37 -4
- package/components/feedback/feedback.prompt.md +5 -1
- package/components/feedback/modal-behavior.js +50 -0
- package/components/navigation/Tree.d.ts +5 -1
- package/components/navigation/Tree.jsx +9 -7
- package/components/navigation/Tree.vue +7 -6
- package/components/navigation/navigation.prompt.md +2 -0
- package/index.html +3 -3
- package/package.json +6 -5
- package/site-nav.js +1 -1
- package/tokens/tokens.json +1 -1
- package/components/feedback/_internal/MessageBoxView.vue +0 -237
- package/components/feedback/_internal/MessageView.vue +0 -73
- package/components/feedback/message.js +0 -68
- /package/{components/feedback → _vue-services}/message-box.d.ts +0 -0
- /package/{components/feedback → _vue-services}/message-box.js +0 -0
- /package/{components/feedback → _vue-services}/message.d.ts +0 -0
|
@@ -31,3 +31,5 @@ Data lists: `Table` (hairline table, mono cells, selectable; a size region — `
|
|
|
31
31
|
```jsx
|
|
32
32
|
<CodeBlock language="sql" code={"SELECT * FROM sales_orders"}/>
|
|
33
33
|
```
|
|
34
|
+
|
|
35
|
+
`Table rowOpen="row"` — with `onRowOpen` / `@row-open`, a click anywhere on a body row opens it (hand cursor on the whole row); buttons, checkboxes, inputs, links, the ⋯ menu, the tree arrow and text selection are left alone. Default `"title"` keeps opening on the tile and title only.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Table rowOpen="row": decides whether a click on a body row opens it. Clicks on controls inside the row (buttons — the ⋯ menu and
|
|
2
|
+
the tree arrow included — checkboxes, inputs, links, menus), on the title / tile that open the row themselves, and clicks that end a
|
|
3
|
+
text selection do not. Header cells and resize handles sit outside the body rows, so they never reach here. */
|
|
4
|
+
const SKIP = 'button, a[href], input, select, textarea, label, summary, [contenteditable=""], [contenteditable="true"], [role="button"], [role="checkbox"], [role="switch"], [role="radio"], [role="menuitem"], [role="menu"], [role="link"], [role="combobox"], [role="slider"], [data-row-open], [data-row-skip]';
|
|
5
|
+
export function rowOpenFromClick(e) {
|
|
6
|
+
const tr = e.currentTarget, t = e.target;
|
|
7
|
+
if (!tr || e.defaultPrevented || (e.button != null && e.button !== 0)) return false;
|
|
8
|
+
const hit = t && t.closest ? t.closest(SKIP) : null;
|
|
9
|
+
if (hit && tr.contains(hit)) return false;
|
|
10
|
+
const sel = typeof window !== "undefined" && window.getSelection ? window.getSelection() : null;
|
|
11
|
+
if (sel && !sel.isCollapsed && String(sel).length > 0) return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
@@ -7,15 +7,35 @@ export interface TooltipProps {
|
|
|
7
7
|
style?: React.CSSProperties;
|
|
8
8
|
}
|
|
9
9
|
export declare function Tooltip(props: TooltipProps): JSX.Element;
|
|
10
|
-
/** Centered dialog with scrim, title/subtitle, close, footer slot.
|
|
10
|
+
/** Centered dialog with scrim, title/subtitle, close, footer slot. Focus moves into the dialog ([data-autofocus] first), Tab stays inside, and focus returns on close. */
|
|
11
11
|
export interface ModalProps {
|
|
12
12
|
open?: boolean;
|
|
13
|
-
title
|
|
13
|
+
/** Vue: also a #title slot */
|
|
14
|
+
title?: React.ReactNode;
|
|
14
15
|
subtitle?: string;
|
|
15
|
-
|
|
16
|
+
/** Why it closed: "close" (×), "esc" or "scrim". Vue: @close(reason). */
|
|
17
|
+
onClose?: (reason: "close" | "esc" | "scrim") => void;
|
|
18
|
+
/** Esc closes the topmost dialog (default true) */
|
|
19
|
+
closeOnEsc?: boolean;
|
|
20
|
+
/** A click on the scrim closes it (default true; turn off for confirmations that must be answered) */
|
|
21
|
+
closeOnScrim?: boolean;
|
|
22
|
+
/** The × in the header (default true) */
|
|
23
|
+
showClose?: boolean;
|
|
24
|
+
/** Stops the page behind from scrolling while open (default true) */
|
|
25
|
+
lockScroll?: boolean;
|
|
26
|
+
/** Dims the page behind (default true) */
|
|
27
|
+
scrim?: boolean;
|
|
28
|
+
/** Moves focus in on open (default true) */
|
|
29
|
+
autoFocus?: boolean;
|
|
30
|
+
/** role="alertdialog" — for confirmations and warnings that need an answer */
|
|
31
|
+
alert?: boolean;
|
|
32
|
+
/** Overrides --z-modal */
|
|
33
|
+
zIndex?: number | string;
|
|
34
|
+
/** aria-label of the × (default "Close") */
|
|
35
|
+
closeLabel?: string;
|
|
16
36
|
/** Right-aligned footer buttons */
|
|
17
37
|
footer?: React.ReactNode;
|
|
18
|
-
width?: number;
|
|
38
|
+
width?: number | string;
|
|
19
39
|
/** Size tier of the dialog region. Defaults to "lg" (36px controls) regardless of the page behind it — a dialog does not compete for space. */
|
|
20
40
|
size?: "sm" | "md" | "lg";
|
|
21
41
|
children?: React.ReactNode;
|
|
@@ -27,6 +47,8 @@ export interface ToastProps {
|
|
|
27
47
|
tone?: "success" | "danger" | "warning" | "info";
|
|
28
48
|
title?: string;
|
|
29
49
|
meta?: string;
|
|
50
|
+
/** Repeat count: 2 or more shows "× n" after the title */
|
|
51
|
+
count?: number;
|
|
30
52
|
/** Replaces the tone glyph (Vue: #icon slot) */
|
|
31
53
|
icon?: React.ReactNode;
|
|
32
54
|
/** Content in place of title (Vue: default slot) */
|
|
@@ -38,10 +60,39 @@ export interface ToastProps {
|
|
|
38
60
|
style?: React.CSSProperties;
|
|
39
61
|
}
|
|
40
62
|
export declare function Toast(props: ToastProps): JSX.Element;
|
|
41
|
-
|
|
63
|
+
export interface ToastStackItem {
|
|
64
|
+
id: string | number;
|
|
65
|
+
tone?: "success" | "danger" | "warning" | "info";
|
|
66
|
+
title?: string;
|
|
67
|
+
meta?: string;
|
|
68
|
+
/** Content in place of title (Vue: string | VNode | () => VNode) */
|
|
69
|
+
content?: React.ReactNode;
|
|
70
|
+
/** Glyph name or element in place of the tone glyph */
|
|
71
|
+
icon?: string | React.ReactNode;
|
|
72
|
+
/** "× n" after the title; a change restarts the timer */
|
|
73
|
+
count?: number;
|
|
74
|
+
/** ms, overrides the stack's duration; 0 = stay */
|
|
75
|
+
duration?: number;
|
|
76
|
+
/** Shows the × (default true) */
|
|
77
|
+
dismissible?: boolean;
|
|
78
|
+
closeLabel?: string;
|
|
79
|
+
/** Vue: class */
|
|
80
|
+
className?: string;
|
|
81
|
+
style?: React.CSSProperties;
|
|
82
|
+
}
|
|
83
|
+
/** Fixed stack of toasts. Toasts are role="status" (danger / warning: role="alert"), so screen readers announce them. */
|
|
42
84
|
export interface ToastStackProps {
|
|
43
|
-
toasts?:
|
|
44
|
-
|
|
85
|
+
toasts?: ToastStackItem[];
|
|
86
|
+
/** Where the stack sits (default "bottom-right"). Vue Message uses "top-center". */
|
|
87
|
+
placement?: "bottom-right" | "top-center" | "top-right" | "bottom-center";
|
|
88
|
+
/** Distance from the edges in px (default 24) */
|
|
89
|
+
offset?: number;
|
|
90
|
+
/** Auto-dismiss after ms (default 0 = stay). Paused while the pointer or focus is on a toast. */
|
|
91
|
+
duration?: number;
|
|
92
|
+
/** Overrides --z-toast */
|
|
93
|
+
zIndex?: number | string;
|
|
94
|
+
/** "close" (×) or "timeout". Vue: @dismiss(id, reason). */
|
|
95
|
+
onDismiss?: (id: string | number, reason: "close" | "timeout") => void;
|
|
45
96
|
style?: React.CSSProperties;
|
|
46
97
|
}
|
|
47
98
|
export declare function ToastStack(props: ToastStackProps): JSX.Element;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Icon } from "../icons/Icon.jsx";
|
|
3
3
|
import { IconButton } from "../buttons/IconButton.jsx";
|
|
4
|
+
import { modalOpen } from "./modal-behavior.js";
|
|
4
5
|
|
|
5
6
|
/** Hover tooltip — 11px, inverted surface, 6px offset. Wraps its children. */
|
|
6
7
|
export function Tooltip({ label, placement = "top", children, style }) {
|
|
@@ -21,20 +22,29 @@ export function Tooltip({ label, placement = "top", children, style }) {
|
|
|
21
22
|
);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
/** Modal dialog. A size region that defaults to lg (40px controls) whatever the page behind it uses — a dialog does not compete for space; pass `size` to override. 24px padding, footer buttons 8px apart.
|
|
26
|
+
* Keyboard and focus (always): focus moves into the dialog ([data-autofocus] first) and Tab stays inside it; on close focus returns to where it was.
|
|
27
|
+
* Options (defaults keep the usual dialog): closeOnEsc · closeOnScrim · showClose · lockScroll · scrim · alert (role="alertdialog" for confirmations) · zIndex.
|
|
28
|
+
* onClose(reason): "close" (×) | "esc" | "scrim". */
|
|
29
|
+
export function Modal({ open, title, subtitle, onClose, footer, width = 440, size = "lg", alert, closeOnEsc = true, closeOnScrim = true, showClose = true, lockScroll = true, scrim = true, autoFocus = true, zIndex, closeLabel = "Close", children, style }) {
|
|
30
|
+
const panel = React.useRef(null);
|
|
31
|
+
const uid = React.useId();
|
|
32
|
+
const opts = React.useRef({});
|
|
33
|
+
opts.current.closeOnEsc = closeOnEsc;
|
|
34
|
+
opts.current.onEsc = () => onClose && onClose("esc");
|
|
35
|
+
React.useEffect(() => { if (!open) return undefined; return modalOpen(panel.current, Object.assign(opts.current, { lockScroll, autoFocus })); }, [open]);
|
|
27
36
|
if (!open) return null;
|
|
37
|
+
const close = (reason) => onClose && onClose(reason);
|
|
28
38
|
return (
|
|
29
|
-
<div style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal)", background: "var(--bg-scrim)", display: "flex", alignItems: "center", justifyContent: "center", animation: "kb-fade-in var(--dur-fast) var(--ease-out)" }} onClick={
|
|
30
|
-
<div role="dialog" aria-modal="true" data-size={size} onClick={(e) => e.stopPropagation()}
|
|
31
|
-
style={{ width, maxWidth: "92vw", background: "var(--bg-raised)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-modal)", animation: "kb-rise var(--dur) var(--ease-out)", ...style }}>
|
|
39
|
+
<div style={{ position: "fixed", inset: 0, zIndex: zIndex ?? "var(--z-modal)", background: scrim ? "var(--bg-scrim)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", animation: "kb-fade-in var(--dur-fast) var(--ease-out)" }} onClick={closeOnScrim ? () => close("scrim") : undefined}>
|
|
40
|
+
<div ref={panel} role={alert ? "alertdialog" : "dialog"} aria-modal="true" aria-labelledby={uid + "-t"} tabIndex={-1} data-size={size} onClick={(e) => e.stopPropagation()}
|
|
41
|
+
style={{ width, maxWidth: "92vw", maxHeight: "92vh", overflow: "auto", boxSizing: "border-box", outline: "none", background: "var(--bg-raised)", borderRadius: "var(--radius-lg)", border: "1px solid var(--border-card)", boxShadow: "var(--shadow-modal)", animation: "kb-rise var(--dur) var(--ease-out)", ...style }}>
|
|
32
42
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, padding: "24px 24px 0" }}>
|
|
33
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
34
|
-
<span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", color: "var(--text-heading)" }}>{title}</span>
|
|
43
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
|
44
|
+
<span id={uid + "-t"} style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-semibold)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", color: "var(--text-heading)", overflowWrap: "anywhere" }}>{title}</span>
|
|
35
45
|
{subtitle ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-secondary)" }}>{subtitle}</span> : null}
|
|
36
46
|
</div>
|
|
37
|
-
<IconButton icon="x" onClick={
|
|
47
|
+
{showClose ? <IconButton icon="x" onClick={() => close("close")} label={closeLabel} style={{ margin: "calc((var(--leading-md) - var(--control-h)) / 2) -6px 0 0" }} /> : null}
|
|
38
48
|
</div>
|
|
39
49
|
{children ? <div style={{ padding: "20px 24px 0" }}>{children}</div> : null}
|
|
40
50
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--control-gap)", padding: 24 }}>{footer}</div>
|
|
@@ -46,13 +56,13 @@ export function Modal({ open, title, subtitle, onClose, footer, width = 440, siz
|
|
|
46
56
|
const toastTones = { success: { icon: "circle-check", bg: "var(--success-bg)", border: "var(--success)", fg: "var(--success-text)" }, danger: { icon: "triangle-alert", bg: "var(--danger-bg)", border: "var(--danger)", fg: "var(--danger-text)" }, warning: { icon: "triangle-alert", bg: "var(--warning-bg)", border: "var(--warning)", fg: "var(--warning-text)" }, info: { icon: "info", bg: "var(--info-bg)", border: "var(--focus-border)", fg: "var(--info-fg)" } };
|
|
47
57
|
|
|
48
58
|
/** Single toast row — tinted: tone wash over --bg-raised, solid tone border, tone-coloured icon, title and dismiss. */
|
|
49
|
-
export function Toast({ tone = "success", title, meta, icon, closeLabel = "Dismiss", onClose, children, style }) {
|
|
59
|
+
export function Toast({ tone = "success", title, meta, icon, count, closeLabel = "Dismiss", onClose, children, style }) {
|
|
50
60
|
const t = toastTones[tone] || toastTones.info;
|
|
51
61
|
return (
|
|
52
|
-
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, minWidth: 280, maxWidth: 380, backgroundColor: "var(--bg-raised)", backgroundImage: `linear-gradient(${t.bg}, ${t.bg})`, border: `1px solid ${t.border}`, borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-menu)", padding: "12px 12px 12px 14px", animation: "kb-toast-in var(--dur) var(--ease-out)", ...style }}>
|
|
62
|
+
<div role={tone === "danger" || tone === "warning" ? "alert" : "status"} style={{ display: "flex", alignItems: "flex-start", gap: 10, minWidth: 280, maxWidth: 380, backgroundColor: "var(--bg-raised)", backgroundImage: `linear-gradient(${t.bg}, ${t.bg})`, border: `1px solid ${t.border}`, borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-menu)", padding: "12px 12px 12px 14px", animation: "kb-toast-in var(--dur) var(--ease-out)", ...style }}>
|
|
53
63
|
<span style={{ color: t.fg, display: "inline-flex", alignItems: "center", flexShrink: 0, height: "var(--leading-base)" }}>{icon || <Icon name={t.icon} size={16} />}</span>
|
|
54
64
|
<div style={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0 }}>
|
|
55
|
-
<span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", color: t.fg, overflowWrap: "anywhere" }}>{children ?? title}</span>
|
|
65
|
+
<span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", color: t.fg, overflowWrap: "anywhere" }}>{children ?? title}{count > 1 ? <span style={{ color: "var(--text-secondary)", fontWeight: "var(--weight-regular)", fontVariantNumeric: "tabular-nums" }}> × {count}</span> : null}</span>
|
|
56
66
|
{meta ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-body)" }}>{meta}</span> : null}
|
|
57
67
|
</div>
|
|
58
68
|
{onClose ? <IconButton icon="x" size="sm" muted onClick={onClose} label={closeLabel} style={{ margin: "-2px 0", color: t.fg }} /> : null}
|
|
@@ -60,11 +70,31 @@ export function Toast({ tone = "success", title, meta, icon, closeLabel = "Dismi
|
|
|
60
70
|
);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
const STACK_PLACE = {
|
|
74
|
+
"top-center": (o) => ({ top: o, left: "50%", transform: "translateX(-50%)", alignItems: "center" }),
|
|
75
|
+
"top-right": (o) => ({ top: o, right: o, alignItems: "flex-end" }),
|
|
76
|
+
"bottom-right": (o) => ({ bottom: o, right: o, alignItems: "flex-end" }),
|
|
77
|
+
"bottom-center": (o) => ({ bottom: o, left: "50%", transform: "translateX(-50%)", alignItems: "center" }),
|
|
78
|
+
};
|
|
79
|
+
function StackItem({ t, duration, onDismiss }) {
|
|
80
|
+
const [paused, setPaused] = React.useState(false);
|
|
81
|
+
const ms = t.duration ?? duration;
|
|
82
|
+
React.useEffect(() => { if (paused || !(ms > 0)) return undefined; const h = setTimeout(() => onDismiss && onDismiss(t.id, "timeout"), ms); return () => clearTimeout(h); }, [paused, ms, t.count, t.id]);
|
|
83
|
+
const icon = typeof t.icon === "string" ? <Icon name={t.icon} size={16} /> : t.icon;
|
|
65
84
|
return (
|
|
66
|
-
<div style={{
|
|
67
|
-
{
|
|
85
|
+
<div className={t.className} style={{ maxWidth: "92vw" }} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)}>
|
|
86
|
+
<Toast tone={t.tone} title={t.title} meta={t.meta} icon={icon} count={t.count} closeLabel={t.closeLabel} style={t.style} onClose={t.dismissible === false ? undefined : () => onDismiss && onDismiss(t.id, "close")}>{t.content}</Toast>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fixed stack of toasts. placement: "bottom-right" (default) | "top-center" | "top-right" | "bottom-center"; offset from the edges (default 24).
|
|
92
|
+
* duration (ms, default 0 = stay) auto-dismisses — paused while the pointer or focus is on a toast, restarted when its count changes.
|
|
93
|
+
* Items: { id, tone, title, meta, content, icon, count, duration, dismissible, closeLabel, className, style }. onDismiss(id, "close" | "timeout"). */
|
|
94
|
+
export function ToastStack({ toasts = [], onDismiss, placement = "bottom-right", offset = 24, duration = 0, zIndex, style }) {
|
|
95
|
+
return (
|
|
96
|
+
<div style={{ position: "fixed", zIndex: zIndex ?? "var(--z-toast)", display: "flex", flexDirection: "column", gap: 8, ...(STACK_PLACE[placement] || STACK_PLACE["bottom-right"])(offset), ...style }}>
|
|
97
|
+
{toasts.map((t) => <StackItem key={t.id} t={t} duration={duration} onDismiss={onDismiss} />)}
|
|
68
98
|
</div>
|
|
69
99
|
);
|
|
70
100
|
}
|
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { px } from '../px.js'
|
|
3
|
+
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
|
3
4
|
import IconButton from '../buttons/IconButton.vue'
|
|
5
|
+
import { modalOpen } from './modal-behavior.js'
|
|
4
6
|
// Modal dialog. A size region that defaults to lg (40px controls) whatever the page behind it uses — a dialog does not compete for space; pass `size` to override. 24px padding, footer buttons 8px apart.
|
|
5
|
-
|
|
7
|
+
// Keyboard and focus (always): focus moves into the dialog ([data-autofocus] first) and Tab stays inside it; on close focus returns to where it was.
|
|
8
|
+
// Options (defaults keep the usual dialog): closeOnEsc · closeOnScrim · showClose · lockScroll · scrim · alert (role="alertdialog") · zIndex. Emits close(reason): 'close' (×) | 'esc' | 'scrim'.
|
|
9
|
+
// Slots: default (body), #footer, #title (instead of the title prop).
|
|
10
|
+
const props = defineProps({ open: Boolean, title: String, subtitle: String, width: { type: [Number, String], default: 440 }, size: { type: String, default: 'lg' }, alert: Boolean, closeOnEsc: { type: Boolean, default: true }, closeOnScrim: { type: Boolean, default: true }, showClose: { type: Boolean, default: true }, lockScroll: { type: Boolean, default: true }, scrim: { type: Boolean, default: true }, autoFocus: { type: Boolean, default: true }, zIndex: [Number, String], closeLabel: { type: String, default: 'Close' }, style: Object })
|
|
6
11
|
const emit = defineEmits(['close'])
|
|
12
|
+
let seed = 0
|
|
13
|
+
const uid = 'kb-modal-' + Math.random().toString(36).slice(2, 8) + (++seed)
|
|
14
|
+
const panel = ref(null)
|
|
15
|
+
const opts = { onEsc: () => emit('close', 'esc') }
|
|
16
|
+
let cleanup = null
|
|
17
|
+
watch(() => props.closeOnEsc, (v) => { opts.closeOnEsc = v }, { immediate: true })
|
|
18
|
+
watch(() => props.open, async (v) => {
|
|
19
|
+
if (cleanup) { cleanup(); cleanup = null }
|
|
20
|
+
if (!v) return
|
|
21
|
+
await nextTick()
|
|
22
|
+
if (props.open && panel.value) cleanup = modalOpen(panel.value, Object.assign(opts, { lockScroll: props.lockScroll, autoFocus: props.autoFocus }))
|
|
23
|
+
}, { immediate: true })
|
|
24
|
+
onBeforeUnmount(() => { if (cleanup) cleanup() })
|
|
7
25
|
</script>
|
|
8
26
|
<template>
|
|
9
|
-
<div v-if="open" style="position:fixed
|
|
10
|
-
<div role="dialog" aria-modal="true" :data-size="size" @click.stop :style="{ width: px(width), maxWidth: '92vw', background: 'var(--bg-raised)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-modal)', animation: 'kb-rise var(--dur) var(--ease-out)', ...style }">
|
|
27
|
+
<div v-if="open" :style="{ position: 'fixed', inset: 0, zIndex: zIndex ?? 'var(--z-modal)', background: scrim ? 'var(--bg-scrim)' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', animation: 'kb-fade-in var(--dur-fast) var(--ease-out)' }" @click="closeOnScrim && emit('close', 'scrim')">
|
|
28
|
+
<div ref="panel" :role="alert ? 'alertdialog' : 'dialog'" aria-modal="true" :aria-labelledby="uid + '-t'" tabindex="-1" :data-size="size" @click.stop :style="{ width: px(width), maxWidth: '92vw', maxHeight: '92vh', overflow: 'auto', boxSizing: 'border-box', outline: 'none', background: 'var(--bg-raised)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--border-card)', boxShadow: 'var(--shadow-modal)', animation: 'kb-rise var(--dur) var(--ease-out)', ...style }">
|
|
11
29
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px 24px 0">
|
|
12
|
-
<div style="display:flex;flex-direction:column;gap:2px">
|
|
13
|
-
<span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading)">{{ title }}</span>
|
|
30
|
+
<div style="display:flex;flex-direction:column;gap:2px;min-width:0">
|
|
31
|
+
<span :id="uid + '-t'" style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading);overflow-wrap:anywhere"><slot name="title">{{ title }}</slot></span>
|
|
14
32
|
<span v-if="subtitle" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)">{{ subtitle }}</span>
|
|
15
33
|
</div>
|
|
16
|
-
<IconButton icon="x" label="
|
|
34
|
+
<IconButton v-if="showClose" icon="x" :label="closeLabel" :style="{ margin: 'calc((var(--leading-md) - var(--control-h)) / 2) -6px 0 0' }" @click="emit('close', 'close')" />
|
|
17
35
|
</div>
|
|
18
36
|
<div v-if="$slots.default" style="padding:20px 24px 0"><slot /></div>
|
|
19
37
|
<div style="display:flex;justify-content:flex-end;gap:var(--control-gap);padding:24px"><slot name="footer" /></div>
|
|
@@ -3,15 +3,15 @@ import { computed } from 'vue'
|
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
4
|
import IconButton from '../buttons/IconButton.vue'
|
|
5
5
|
const toastTones = { success: { icon: 'circle-check', bg: 'var(--success-bg)', border: 'var(--success)', fg: 'var(--success-text)' }, danger: { icon: 'triangle-alert', bg: 'var(--danger-bg)', border: 'var(--danger)', fg: 'var(--danger-text)' }, warning: { icon: 'triangle-alert', bg: 'var(--warning-bg)', border: 'var(--warning)', fg: 'var(--warning-text)' }, info: { icon: 'info', bg: 'var(--info-bg)', border: 'var(--focus-border)', fg: 'var(--info-fg)' } }
|
|
6
|
-
const props = defineProps({ tone: { type: String, default: 'success' }, title: String, meta: String, dismissible: Boolean, closeLabel: { type: String, default: 'Dismiss' }, style: Object })
|
|
6
|
+
const props = defineProps({ tone: { type: String, default: 'success' }, title: String, meta: String, dismissible: Boolean, count: Number, closeLabel: { type: String, default: 'Dismiss' }, style: Object })
|
|
7
7
|
const emit = defineEmits(['close'])
|
|
8
8
|
const t = computed(() => toastTones[props.tone] || toastTones.info)
|
|
9
9
|
</script>
|
|
10
10
|
<template>
|
|
11
|
-
<div :style="{ display: 'flex', alignItems: 'flex-start', gap: '10px', minWidth: '280px', maxWidth: '380px', backgroundColor: 'var(--bg-raised)', backgroundImage: `linear-gradient(${t.bg}, ${t.bg})`, border: `1px solid ${t.border}`, borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', padding: '12px 12px 12px 14px', animation: 'kb-toast-in var(--dur) var(--ease-out)', ...style }">
|
|
11
|
+
<div :role="tone === 'danger' || tone === 'warning' ? 'alert' : 'status'" :style="{ display: 'flex', alignItems: 'flex-start', gap: '10px', minWidth: '280px', maxWidth: '380px', backgroundColor: 'var(--bg-raised)', backgroundImage: `linear-gradient(${t.bg}, ${t.bg})`, border: `1px solid ${t.border}`, borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', padding: '12px 12px 12px 14px', animation: 'kb-toast-in var(--dur) var(--ease-out)', ...style }">
|
|
12
12
|
<span :style="{ color: t.fg, display: 'inline-flex', alignItems: 'center', flexShrink: 0, height: 'var(--leading-base)' }"><slot name="icon"><Icon :name="t.icon" :size="16" /></slot></span>
|
|
13
13
|
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
|
14
|
-
<span :style="{ fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', color: t.fg, overflowWrap: 'anywhere' }"><slot>{{ title }}</slot></span>
|
|
14
|
+
<span :style="{ fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--text-base)', lineHeight: 'var(--leading-base)', letterSpacing: 'var(--tracking-base)', color: t.fg, overflowWrap: 'anywhere' }"><slot>{{ title }}</slot><span v-if="count > 1" style="color:var(--text-secondary);font-weight:var(--weight-regular);font-variant-numeric:tabular-nums"> × {{ count }}</span></span>
|
|
15
15
|
<span v-if="meta" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-body)">{{ meta }}</span>
|
|
16
16
|
</div>
|
|
17
17
|
<IconButton v-if="dismissible" icon="x" size="sm" muted :label="closeLabel" :style="{ margin: '-2px 0', color: t.fg }" @click="emit('close')" />
|
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed, h, watch, onBeforeUnmount } from 'vue'
|
|
2
3
|
import Toast from './Toast.vue'
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
import Icon from '../icons/Icon.vue'
|
|
5
|
+
// Fixed stack of toasts. placement: 'bottom-right' (default) | 'top-center' | 'top-right' | 'bottom-center'; offset from the edges (default 24).
|
|
6
|
+
// duration (ms, default 0 = stay) auto-dismisses — paused while the pointer or focus is on a toast, restarted when its count changes.
|
|
7
|
+
// toasts: [{ id, tone, title, meta, content (string | VNode | () => VNode), icon (name | VNode), count, duration, dismissible, closeLabel, class, style }]. Emits dismiss(id, 'close' | 'timeout').
|
|
8
|
+
const props = defineProps({ toasts: { type: Array, default: () => [] }, placement: { type: String, default: 'bottom-right' }, offset: { type: Number, default: 24 }, duration: { type: Number, default: 0 }, zIndex: [Number, String], style: Object })
|
|
5
9
|
const emit = defineEmits(['dismiss'])
|
|
10
|
+
const PLACE = {
|
|
11
|
+
'top-center': (o) => ({ top: o + 'px', left: '50%', transform: 'translateX(-50%)', alignItems: 'center' }),
|
|
12
|
+
'top-right': (o) => ({ top: o + 'px', right: o + 'px', alignItems: 'flex-end' }),
|
|
13
|
+
'bottom-right': (o) => ({ bottom: o + 'px', right: o + 'px', alignItems: 'flex-end' }),
|
|
14
|
+
'bottom-center': (o) => ({ bottom: o + 'px', left: '50%', transform: 'translateX(-50%)', alignItems: 'center' }),
|
|
15
|
+
}
|
|
16
|
+
const box = computed(() => ({ position: 'fixed', zIndex: props.zIndex ?? 'var(--z-toast)', display: 'flex', flexDirection: 'column', gap: '8px', ...(PLACE[props.placement] || PLACE['bottom-right'])(props.offset), ...props.style }))
|
|
17
|
+
const timers = new Map(), seen = new Map(), paused = new Set()
|
|
18
|
+
const ms = (t) => t.duration ?? props.duration
|
|
19
|
+
function arm(t) {
|
|
20
|
+
clearTimeout(timers.get(t.id)); timers.delete(t.id)
|
|
21
|
+
if (paused.has(t.id) || !(ms(t) > 0)) return
|
|
22
|
+
timers.set(t.id, setTimeout(() => emit('dismiss', t.id, 'timeout'), ms(t)))
|
|
23
|
+
}
|
|
24
|
+
watch(() => props.toasts.map((t) => t.id + ':' + (t.count || 1) + ':' + ms(t)).join('|'), () => {
|
|
25
|
+
const ids = new Set()
|
|
26
|
+
for (const t of props.toasts) { ids.add(t.id); const sig = (t.count || 1) + ':' + ms(t); if (seen.get(t.id) !== sig) { seen.set(t.id, sig); arm(t) } }
|
|
27
|
+
for (const id of [...seen.keys()]) if (!ids.has(id)) { clearTimeout(timers.get(id)); timers.delete(id); seen.delete(id); paused.delete(id) }
|
|
28
|
+
}, { immediate: true })
|
|
29
|
+
const pause = (t) => { paused.add(t.id); clearTimeout(timers.get(t.id)); timers.delete(t.id) }
|
|
30
|
+
const resume = (t) => { paused.delete(t.id); arm(t) }
|
|
31
|
+
onBeforeUnmount(() => timers.forEach((x) => clearTimeout(x)))
|
|
32
|
+
const Node = (p) => (typeof p.c === 'function' ? p.c() : p.c)
|
|
33
|
+
const IconNode = (p) => (typeof p.c === 'string' ? h(Icon, { name: p.c, size: 16 }) : Node(p))
|
|
6
34
|
</script>
|
|
7
35
|
<template>
|
|
8
|
-
<div :style="
|
|
9
|
-
<
|
|
36
|
+
<div :style="box">
|
|
37
|
+
<div v-for="t in toasts" :key="t.id" :class="t.class" style="max-width:92vw" @mouseenter="pause(t)" @mouseleave="resume(t)" @focusin="pause(t)" @focusout="resume(t)">
|
|
38
|
+
<Toast :tone="t.tone" :title="t.title" :meta="t.meta" :count="t.count" :dismissible="t.dismissible !== false" :close-label="t.closeLabel || 'Dismiss'" :style="t.style" @close="emit('dismiss', t.id, 'close')">
|
|
39
|
+
<template v-if="t.icon" #icon><IconNode :c="t.icon" /></template>
|
|
40
|
+
<template v-if="t.content != null" #default><Node :c="t.content" /></template>
|
|
41
|
+
</Toast>
|
|
42
|
+
</div>
|
|
10
43
|
</div>
|
|
11
44
|
</template>
|
|
@@ -34,4 +34,8 @@ Additions (not in the source file): `Tooltip`, `Modal`, `Toast`/`ToastStack`, `A
|
|
|
34
34
|
</Accordion>
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
**Rule:** notices = `Toast` / `ToastStack`, dialogs = `Modal`. Do not build another notice or dialog; please check with Felix before building a new component.
|
|
38
|
+
|
|
39
|
+
`Modal` options: `closeOnEsc`, `closeOnScrim`, `showClose`, `lockScroll`, `scrim`, `autoFocus` (default true), `alert` for must-answer dialogs; `onClose(reason)`. Put `data-autofocus` on the control that should take focus. `ToastStack`: `placement`, `duration`, items with `count`.
|
|
40
|
+
|
|
41
|
+
Vue only: imperative `Message` (`@keyfunc/keybricks-ds/vue/message`) and `MessageBox` (`@keyfunc/keybricks-ds/vue/message-box`) services, drop-in for most `ElMessage` / `ElMessageBox` calls. Message renders the Keybricks `Toast`; MessageBox renders a DS dialog with DS Buttons. Source lives outside `components/` in `_vue-services/` (Vue-only, so the React docs preview never loads it). Limits and Element Plus mapping: `_vue-services/MESSAGE_SERVICES.md`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* Modal keyboard + focus behaviour, shared by Modal.jsx and Modal.vue (framework-neutral).
|
|
2
|
+
While a dialog is open: focus moves into it ([data-autofocus] first, else the panel), Tab / Shift+Tab cycle inside it,
|
|
3
|
+
Esc closes only the topmost dialog, the page behind stops scrolling (counted, so stacked dialogs unlock once),
|
|
4
|
+
and on close focus returns to the element that had it. */
|
|
5
|
+
const stack = [];
|
|
6
|
+
let locks = 0, prevOverflow = "";
|
|
7
|
+
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
|
8
|
+
|
|
9
|
+
/** Call when the panel is in the DOM. Returns the cleanup to call on close / unmount. opts: { closeOnEsc, lockScroll, autoFocus, onEsc } */
|
|
10
|
+
export function modalOpen(panel, opts) {
|
|
11
|
+
if (!panel || typeof document === "undefined") return () => {};
|
|
12
|
+
const entry = { panel, opts };
|
|
13
|
+
stack.push(entry);
|
|
14
|
+
const top = () => stack[stack.length - 1] === entry;
|
|
15
|
+
const prev = document.activeElement;
|
|
16
|
+
if (opts.lockScroll !== false && locks++ === 0) { prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; }
|
|
17
|
+
const onKey = (e) => {
|
|
18
|
+
if (!top()) return;
|
|
19
|
+
if (e.key === "Escape") {
|
|
20
|
+
if (e.isComposing) return;
|
|
21
|
+
e.preventDefault(); e.stopImmediatePropagation();
|
|
22
|
+
if (entry.opts.closeOnEsc !== false && entry.opts.onEsc) entry.opts.onEsc(e);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (e.key !== "Tab") return;
|
|
26
|
+
const list = [...panel.querySelectorAll(FOCUSABLE)].filter((n) => n.getClientRects().length);
|
|
27
|
+
if (!list.length) { e.preventDefault(); panel.focus(); return; }
|
|
28
|
+
const first = list[0], last = list[list.length - 1], at = document.activeElement;
|
|
29
|
+
if (e.shiftKey && (at === first || !panel.contains(at))) { e.preventDefault(); last.focus(); }
|
|
30
|
+
else if (!e.shiftKey && (at === last || !panel.contains(at))) { e.preventDefault(); first.focus(); }
|
|
31
|
+
};
|
|
32
|
+
const onFocusIn = (e) => { if (top() && !panel.contains(e.target)) panel.focus(); };
|
|
33
|
+
document.addEventListener("keydown", onKey, true);
|
|
34
|
+
document.addEventListener("focusin", onFocusIn);
|
|
35
|
+
if (opts.autoFocus !== false) {
|
|
36
|
+
const a = panel.querySelector("[data-autofocus]");
|
|
37
|
+
const t = (a && (a.matches(FOCUSABLE) ? a : a.querySelector(FOCUSABLE))) || panel;
|
|
38
|
+
try { t.focus({ preventScroll: true }); } catch (err) { t.focus(); }
|
|
39
|
+
if (t.select && t.tagName !== "BUTTON") t.select();
|
|
40
|
+
}
|
|
41
|
+
let done = false;
|
|
42
|
+
return () => {
|
|
43
|
+
if (done) return; done = true;
|
|
44
|
+
document.removeEventListener("keydown", onKey, true);
|
|
45
|
+
document.removeEventListener("focusin", onFocusIn);
|
|
46
|
+
const i = stack.indexOf(entry); if (i >= 0) stack.splice(i, 1);
|
|
47
|
+
if (opts.lockScroll !== false && --locks === 0) document.body.style.overflow = prevOverflow;
|
|
48
|
+
if (prev && prev.isConnected && prev.focus) prev.focus();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -6,7 +6,8 @@ export interface TreeItem {
|
|
|
6
6
|
/** Row text; `label` is accepted as an alias */
|
|
7
7
|
title?: string;
|
|
8
8
|
label?: string;
|
|
9
|
-
|
|
9
|
+
/** Glyph name; false = no glyph on this row and no space kept for one — the text follows the expand arrow (e.g. a "Documents" root). Works with showIcon on. */
|
|
10
|
+
icon?: string | false;
|
|
10
11
|
/** Leaf glyph hue: orange | green | purple | blue | teal | gray */
|
|
11
12
|
hue?: string;
|
|
12
13
|
/** Right-aligned count */
|
|
@@ -37,6 +38,8 @@ export interface TreeDropInfo {
|
|
|
37
38
|
* Nested tree view: expand/collapse, single or multiple selection, cascading checkboxes with
|
|
38
39
|
* indeterminate parents, disabled nodes, connector lines, async loading, search highlight,
|
|
39
40
|
* drag to move, keyboard navigation.
|
|
41
|
+
* Follows the size tier of its region (data-size): row height --control-h (sm 28 · md 32 · lg 40), text --control-text / --control-leading /
|
|
42
|
+
* --control-tracking (sm 12px, 13px in CJK · md 14px), glyph --control-icon − 1px (sm 13 · md 15). md looks exactly as before.
|
|
40
43
|
*/
|
|
41
44
|
export interface TreeProps {
|
|
42
45
|
items?: TreeItem[];
|
|
@@ -63,6 +66,7 @@ export interface TreeProps {
|
|
|
63
66
|
multiple?: boolean;
|
|
64
67
|
/** false = rows only expand, never select */
|
|
65
68
|
selectable?: boolean;
|
|
69
|
+
/** Glyph before each title (default true); a node with icon: false skips it either way */
|
|
66
70
|
showIcon?: boolean;
|
|
67
71
|
/** Draw connector lines instead of plain indentation */
|
|
68
72
|
showLine?: boolean;
|
|
@@ -154,7 +154,7 @@ function Row({ item, state, cfg, api }) {
|
|
|
154
154
|
onKeyDown={(e) => api.keyDown(e, item)}
|
|
155
155
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
|
156
156
|
style={{
|
|
157
|
-
position: "relative", minHeight: "var(--h
|
|
157
|
+
position: "relative", minHeight: "var(--control-h)", borderRadius: "var(--radius-md)", display: "flex", alignItems: "center", gap: 6,
|
|
158
158
|
padding: cfg.showLine ? "0 8px 0 2px" : "5px 8px", paddingLeft: cfg.showLine ? 2 : 8 + depth * INDENT, boxSizing: "border-box",
|
|
159
159
|
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1,
|
|
160
160
|
background: dropAt === "inside" ? "var(--soft-bg)" : selected ? "var(--bg-active)" : hover && !disabled ? "var(--bg-hover)" : "transparent",
|
|
@@ -169,12 +169,12 @@ function Row({ item, state, cfg, api }) {
|
|
|
169
169
|
<Checkbox checked={checked} indeterminate={half} disabled={disabled || node.disableCheckbox} tabIndex={-1} onChange={() => api.check(key)} />
|
|
170
170
|
</span>
|
|
171
171
|
) : null}
|
|
172
|
-
{cfg.showIcon ? (
|
|
172
|
+
{cfg.showIcon && node.icon !== false ? (
|
|
173
173
|
<span style={{ color: branch ? "var(--text-muted)" : docHues[node.hue || "gray"], display: "inline-flex", flexShrink: 0 }}>
|
|
174
|
-
<Icon name={node.icon || (branch ? (open ? "folder-open" : "folder") : "file-text")} size={15} />
|
|
174
|
+
<Icon name={node.icon || (branch ? (open ? "folder-open" : "folder") : "file-text")} size={15} style={{ width: "calc(var(--control-icon) - 1px)", height: "calc(var(--control-icon) - 1px)" }} />
|
|
175
175
|
</span>
|
|
176
176
|
) : null}
|
|
177
|
-
<span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text
|
|
177
|
+
<span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", fontWeight: selected ? "var(--weight-medium)" : "var(--weight-regular)", color: disabled ? "var(--text-muted)" : "var(--text-heading)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
178
178
|
<Highlight text={titleOf(node)} query={cfg.searchValue} />
|
|
179
179
|
</span>
|
|
180
180
|
{node.tag ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: 1, padding: "2px 5px", borderRadius: "var(--radius-sm)", background: "var(--bg-inset)", color: "var(--text-secondary)", flexShrink: 0 }}>{node.tag}</span> : null}
|
|
@@ -182,7 +182,7 @@ function Row({ item, state, cfg, api }) {
|
|
|
182
182
|
{cfg.rowActions && !disabled ? <span style={{ marginLeft: node.count != null ? 6 : "auto", display: "flex", gap: 2, opacity: hover ? 1 : 0, transition: "var(--t-hover)" }} onClick={(e) => e.stopPropagation()}>{cfg.rowActions(node)}</span> : null}
|
|
183
183
|
</div>
|
|
184
184
|
{open && loading && !kids.length ? (
|
|
185
|
-
<div style={{ paddingLeft: 8 + (depth + 1) * INDENT + 14, fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", color: "var(--text-muted)", height: "var(--h
|
|
185
|
+
<div style={{ paddingLeft: 8 + (depth + 1) * INDENT + 14, fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", color: "var(--text-muted)", height: "var(--control-h)", display: "flex", alignItems: "center" }}>Loading…</div>
|
|
186
186
|
) : null}
|
|
187
187
|
</>
|
|
188
188
|
);
|
|
@@ -193,7 +193,9 @@ function Row({ item, state, cfg, api }) {
|
|
|
193
193
|
* (with indeterminate parents and `checkStrictly` opt-out), disabled nodes and disabled
|
|
194
194
|
* checkboxes, connector lines, async child loading, search highlighting, drag to move,
|
|
195
195
|
* and full keyboard navigation. Node shape: { key, title, icon, hue, count, tag, children,
|
|
196
|
-
* disabled, disableCheckbox, selectable, checkable, isLeaf }.
|
|
196
|
+
* disabled, disableCheckbox, selectable, checkable, isLeaf }. icon: false drops that row's glyph (no gap kept), e.g. a root.
|
|
197
|
+
* Follows the size tier of its region: row --control-h (sm 28 · md 32 · lg 40), text --control-text / -leading / -tracking,
|
|
198
|
+
* glyph --control-icon − 1px (md 15, as before).
|
|
197
199
|
*/
|
|
198
200
|
export function Tree({
|
|
199
201
|
items = [], treeData,
|
|
@@ -356,7 +358,7 @@ export function Tree({
|
|
|
356
358
|
<div role="tree" aria-multiselectable={multiple || undefined} style={{ display: "flex", flexDirection: "column", gap: showLine ? 0 : 1, minWidth: 0, ...style }}>
|
|
357
359
|
<style>{"@keyframes kb-tree-spin{to{transform:rotate(360deg)}}"}</style>
|
|
358
360
|
{rows.length === 0 ? (
|
|
359
|
-
<div style={{ padding: "10px 8px", fontFamily: "var(--font-sans)", fontSize: "var(--text-
|
|
361
|
+
<div style={{ padding: "10px 8px", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", color: "var(--text-muted)" }}>No matching items</div>
|
|
360
362
|
) : rows.map((r) => <Row key={r.key} item={r} state={state} cfg={cfg} api={api} />)}
|
|
361
363
|
</div>
|
|
362
364
|
);
|
|
@@ -4,7 +4,8 @@ import Icon from '../icons/Icon.vue'
|
|
|
4
4
|
import Checkbox from '../forms/Checkbox.vue'
|
|
5
5
|
// Nested tree view: expand/collapse, single or multiple selection, cascading checkboxes with
|
|
6
6
|
// indeterminate parents (checkStrictly opts out), disabled nodes, connector lines, async
|
|
7
|
-
// loadData, search highlight, drag to move, keyboard navigation.
|
|
7
|
+
// loadData, search highlight, drag to move, keyboard navigation. A node with icon: false has no glyph and keeps no gap for one.
|
|
8
|
+
// Follows the size tier of its region: row --control-h (sm 28 · md 32 · lg 40), text --control-text / -leading / -tracking, glyph --control-icon − 1px (md 15).
|
|
8
9
|
const docHues = { orange: 'var(--cat-transform)', green: 'var(--cat-source)', purple: 'var(--cat-merge)', blue: 'var(--info-fg)', teal: 'var(--cat-output)', gray: 'var(--text-muted)' }
|
|
9
10
|
const INDENT = 18
|
|
10
11
|
// Insert marker (reorder before / after): a 2px --action-primary line with a 6px dot at its left end, drawn between rows.
|
|
@@ -235,7 +236,7 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
|
|
|
235
236
|
@click="rowClick(row.n)" @contextmenu="onCtx($event, row.key)" @keydown="keyDown($event, row)"
|
|
236
237
|
:class="{ 'is-disabled': row.n.disabled }"
|
|
237
238
|
:style="{
|
|
238
|
-
position: 'relative', minHeight: 'var(--h
|
|
239
|
+
position: 'relative', minHeight: 'var(--control-h)', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', gap: '6px',
|
|
239
240
|
padding: showLine ? '0 8px 0 2px' : '5px 8px', paddingLeft: showLine ? '2px' : (8 + row.depth * INDENT) + 'px', boxSizing: 'border-box',
|
|
240
241
|
cursor: row.n.disabled ? 'not-allowed' : 'pointer', opacity: row.n.disabled ? 0.45 : 1,
|
|
241
242
|
background: dropAt(row.key) === 'inside' ? 'var(--soft-bg)' : selected.includes(row.key) ? 'var(--bg-active)' : 'transparent',
|
|
@@ -260,10 +261,10 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
|
|
|
260
261
|
<Checkbox :checked="view.full.has(row.key)" :indeterminate="view.half.has(row.key)"
|
|
261
262
|
:disabled="row.n.disabled || row.n.disableCheckbox" :tabindex="-1" @change="check(row.key)" />
|
|
262
263
|
</span>
|
|
263
|
-
<span v-if="showIcon" :style="{ color: isBranch(row.n) ? 'var(--text-muted)' : docHues[row.n.hue || 'gray'], display: 'inline-flex', flexShrink: 0 }">
|
|
264
|
-
<Icon :name="row.n.icon || (isBranch(row.n) ? (openKeys.includes(row.key) ? 'folder-open' : 'folder') : 'file-text')" :size="15" />
|
|
264
|
+
<span v-if="showIcon && row.n.icon !== false" :style="{ color: isBranch(row.n) ? 'var(--text-muted)' : docHues[row.n.hue || 'gray'], display: 'inline-flex', flexShrink: 0 }">
|
|
265
|
+
<Icon :name="row.n.icon || (isBranch(row.n) ? (openKeys.includes(row.key) ? 'folder-open' : 'folder') : 'file-text')" :size="15" :style="{ width: 'calc(var(--control-icon) - 1px)', height: 'calc(var(--control-icon) - 1px)' }" />
|
|
265
266
|
</span>
|
|
266
|
-
<span :style="{ fontFamily: 'var(--font-sans)', fontSize: 'var(--text
|
|
267
|
+
<span :style="{ fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', fontWeight: selected.includes(row.key) ? 'var(--weight-medium)' : 'var(--weight-regular)', color: row.n.disabled ? 'var(--text-muted)' : 'var(--text-heading)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }">
|
|
267
268
|
<template v-for="(p, i) in parts(titleOf(row.n))" :key="i"><mark v-if="p.hit" style="background:var(--soft-bg);color:var(--info-fg);font-weight:var(--weight-medium);padding:0;border-radius:2px">{{ p.t }}</mark><template v-else>{{ p.t }}</template></template>
|
|
268
269
|
</span>
|
|
269
270
|
<span v-if="row.n.tag" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:1;padding:2px 5px;border-radius:var(--radius-sm);background:var(--bg-inset);color:var(--text-secondary);flex-shrink:0">{{ row.n.tag }}</span>
|
|
@@ -271,7 +272,7 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
|
|
|
271
272
|
<!-- row actions: #row-actions="{ node }" slot, or rowActions(node) returning a VNode; shown on hover / focus -->
|
|
272
273
|
<span v-if="(rowActions || $slots['row-actions']) && !row.n.disabled" class="kb-tree-actions" @click.stop :style="{ marginLeft: row.n.count != null ? '6px' : 'auto', display: 'flex', gap: '2px', flexShrink: 0 }"><slot name="row-actions" :node="row.n"><Cell v-if="rowActions" :v="rowActions(row.n)" /></slot></span>
|
|
273
274
|
</div>
|
|
274
|
-
<div v-if="!rows.length" style="padding:10px 8px;font-family:var(--font-sans);font-size:var(--text-
|
|
275
|
+
<div v-if="!rows.length" style="padding:10px 8px;font-family:var(--font-sans);font-size:var(--control-text);line-height:var(--control-leading);color:var(--text-muted)">No matching items</div>
|
|
275
276
|
</div>
|
|
276
277
|
</template>
|
|
277
278
|
<style scoped>
|
|
@@ -22,3 +22,5 @@ applyStoredTheme(); // call once at app start
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
App chrome: `<Sidebar tone="recessed" version="Version 2.4.0" onSelect={…}/>` (items default to `KEYBRICKS_NAV`, footer to `KEYBRICKS_NAV_FOOTER`; `trailing: true` items open a `<SidePanel title="Documents" onClose={…}>` docked beside the rail — same `tone`) and `<TopBar user={{ name, email }} userMenu={<UserMenu … theme={t} onThemeChange={setT}/>}/>`.
|
|
25
|
+
|
|
26
|
+
`Tree` follows the size tier of its region (`data-size`): sm 28px rows / 12px text (13px CJK), md 32px / 14px, lg 40px. A node with `icon: false` draws no glyph and keeps no gap for one — use it for a root such as "Documents".
|
package/index.html
CHANGED
|
@@ -113,7 +113,7 @@ section{margin-bottom:52px;scroll-margin-top:78px}
|
|
|
113
113
|
.cl-new{background:var(--info-bg);color:var(--info-fg)}
|
|
114
114
|
.cl-improved{background:var(--bg-hover);color:var(--text-body)}
|
|
115
115
|
.cl-fixed{background:var(--success-bg);color:var(--success-text)}
|
|
116
|
-
.cl-breaking{background:var(--danger-bg);color:var(--danger-text)}
|
|
116
|
+
.cl-breaking{background:var(--danger-bg);color:var(--danger-text)}.cl-rule{background:var(--warning-bg);color:var(--warning-text)}
|
|
117
117
|
.cl-removed{background:var(--bg-hover);color:var(--text-secondary)}
|
|
118
118
|
@media (max-width:720px){.cl-rel{grid-template-columns:minmax(0,1fr);gap:10px}.cl-head{flex-direction:row;align-items:baseline;gap:10px}}
|
|
119
119
|
|
|
@@ -176,7 +176,7 @@ section{margin-bottom:52px;scroll-margin-top:78px}
|
|
|
176
176
|
const GROUPS=[["Brand","品牌","ブランド"],["Colors","颜色","カラー"],["Type","字体排印","タイポグラフィ"],["Foundations","基础规范","基盤仕様"],["Components","组件","コンポーネント"],["Charts","图表","チャート"],["Icons","图标库","アイコン"]];
|
|
177
177
|
|
|
178
178
|
const S={
|
|
179
|
-
brandsub:["Design System · v2.
|
|
179
|
+
brandsub:["Design System · v2.8.0","设计系统 · v2.8.0","デザインシステム · v2.8.0"],
|
|
180
180
|
doc_readme:["Readme","说明文档","ドキュメント"],
|
|
181
181
|
doc_design:["DESIGN.md","下载 DESIGN.md","DESIGN.md"],
|
|
182
182
|
doc_integration:["Integration","接入指南","導入ガイド"],
|
|
@@ -347,7 +347,7 @@ function settleOn(target){
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
/* ---------- changelog (data: changelog.js) ---------- */
|
|
350
|
-
const CL_TAGS={new:["New","新增","新規"],improved:["Improved","改进","改善"],fixed:["Fixed","修复","修正"],breaking:["Breaking","不兼容变更","互換性のない変更"],removed:["Removed","移除","削除"]};
|
|
350
|
+
const CL_TAGS={new:["New","新增","新規"],improved:["Improved","改进","改善"],fixed:["Fixed","修复","修正"],breaking:["Breaking","不兼容变更","互換性のない変更"],rule:["Team rule","团队规则","チームルール"],removed:["Removed","移除","削除"]};
|
|
351
351
|
function renderChangelog(){
|
|
352
352
|
const s=document.createElement("section");s.id="changelog";
|
|
353
353
|
s.appendChild(make("h2","cl-title",S.nav_changelog));
|