@keyfunc/keybricks-ds 2.6.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 +21 -5
- package/INTEGRATION.md +35 -4
- package/_adherence.oxlintrc.json +56 -11
- package/_ds_bundle.js +2153 -216
- package/_scripts/build-index.mjs +1 -1
- package/_src/icon-index.js +1 -1
- 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/assets/icons/icon-index.json +1 -1
- package/changelog.js +21 -2
- package/components/_cards/accordion.card.html +1 -1
- package/components/_cards/action-chip.card.html +1 -1
- package/components/_cards/activity.card.html +1 -1
- package/components/_cards/alert.card.html +1 -1
- package/components/_cards/app-chrome.card.html +1 -1
- package/components/_cards/banner.card.html +1 -1
- package/components/_cards/breadcrumb.card.html +1 -1
- package/components/_cards/button-1-base.card.html +5 -2
- package/components/_cards/button-2-states.card.html +5 -5
- package/components/_cards/button-3-sizes-icons.card.html +2 -2
- package/components/_cards/button-4-group.card.html +2 -2
- package/components/_cards/button-5-ai.card.html +34 -0
- package/components/_cards/canvas-zoom-controls.card.html +1 -1
- package/components/_cards/cards-indicators.card.html +1 -1
- package/components/_cards/charts-1-sparkline.card.html +1 -1
- package/components/_cards/charts-10-gauge.card.html +1 -1
- package/components/_cards/charts-11-funnel.card.html +1 -1
- package/components/_cards/charts-12-treemap.card.html +1 -1
- package/components/_cards/charts-14-radar.card.html +1 -1
- package/components/_cards/charts-15-distribution.card.html +1 -1
- package/components/_cards/charts-16-pareto.card.html +1 -1
- package/components/_cards/charts-17-candlestick.card.html +1 -1
- package/components/_cards/charts-18-sankey.card.html +1 -1
- package/components/_cards/charts-19-chord.card.html +1 -1
- package/components/_cards/charts-2-line.card.html +1 -1
- package/components/_cards/charts-20-sunburst.card.html +1 -1
- package/components/_cards/charts-21-worldmap.card.html +1 -1
- package/components/_cards/charts-22-gantt.card.html +1 -1
- package/components/_cards/charts-23-bar3d.card.html +1 -1
- package/components/_cards/charts-24-pie3d.card.html +1 -1
- package/components/_cards/charts-25-surface3d.card.html +1 -1
- package/components/_cards/charts-26-scatter3d.card.html +1 -1
- package/components/_cards/charts-27-ribbon3d.card.html +1 -1
- package/components/_cards/charts-3-bar.card.html +1 -1
- package/components/_cards/charts-4-barlist.card.html +1 -1
- package/components/_cards/charts-5-donut.card.html +1 -1
- package/components/_cards/charts-6-scatter.card.html +1 -1
- package/components/_cards/charts-7-heatmap.card.html +1 -1
- package/components/_cards/charts-8-waterfall.card.html +1 -1
- package/components/_cards/charts-9-bullet.card.html +1 -1
- package/components/_cards/charts-99-fullpage.card.html +1 -1
- package/components/_cards/checkbox.card.html +1 -1
- package/components/_cards/codeblock.card.html +1 -1
- package/components/_cards/combobox.card.html +1 -1
- package/components/_cards/context-menu.card.html +1 -1
- package/components/_cards/date-picker.card.html +1 -1
- package/components/_cards/diffvalue.card.html +1 -1
- package/components/_cards/divider.card.html +1 -1
- package/components/_cards/drawer.card.html +1 -1
- package/components/_cards/dropdown-menu.card.html +1 -1
- package/components/_cards/dropzone.card.html +1 -1
- package/components/_cards/empty-state.card.html +1 -1
- package/components/_cards/filter-bar.card.html +1 -1
- package/components/_cards/icon-button.card.html +1 -1
- package/components/_cards/icons-0-usage.card.html +2 -2
- package/components/_cards/icons-1-core.card.html +1 -1
- package/components/_cards/icons-2-feather.card.html +1 -1
- package/components/_cards/icons-2b-lucide.card.html +1 -1
- package/components/_cards/icons-3-unicons.card.html +1 -1
- package/components/_cards/icons-4-datasource.card.html +1 -1
- package/components/_cards/input.card.html +1 -1
- package/components/_cards/kbd.card.html +5 -7
- package/components/_cards/keyvalue.card.html +1 -1
- package/components/_cards/listrow.card.html +1 -1
- package/components/_cards/loading.card.html +1 -1
- package/components/_cards/log-viewer.card.html +1 -1
- package/components/_cards/modal.card.html +8 -2
- package/components/_cards/multi-select.card.html +1 -1
- package/components/_cards/number-stepper.card.html +1 -1
- package/components/_cards/pagination.card.html +1 -1
- package/components/_cards/people.card.html +1 -1
- package/components/_cards/pipeline-node.card.html +1 -1
- package/components/_cards/pipeline-palette.card.html +1 -1
- package/components/_cards/radio.card.html +1 -1
- package/components/_cards/relative-time-picker.card.html +1 -1
- package/components/_cards/search-global.card.html +72 -0
- package/components/_cards/search.card.html +1 -1
- package/components/_cards/segmented-control.card.html +1 -1
- package/components/_cards/select.card.html +1 -1
- package/components/_cards/slider.card.html +1 -1
- package/components/_cards/smart-table.card.html +1 -1
- package/components/_cards/spreadsheet.card.html +1 -1
- package/components/_cards/stepper.card.html +1 -1
- package/components/_cards/table.card.html +10 -1
- package/components/_cards/tabs.card.html +1 -1
- package/components/_cards/tag.card.html +1 -1
- package/components/_cards/textarea.card.html +1 -1
- package/components/_cards/timeline.card.html +1 -1
- package/components/_cards/toast.card.html +11 -1
- package/components/_cards/toggles.card.html +1 -1
- package/components/_cards/tooltip.card.html +1 -1
- package/components/_cards/tree.card.html +22 -1
- package/components/_vue/index.mjs +2 -0
- package/components/buttons/Button.d.ts +14 -5
- package/components/buttons/Button.jsx +45 -8
- package/components/buttons/Button.vue +40 -38
- package/components/buttons/Button.vue.d.ts +4 -1
- package/components/buttons/ai-button.js +54 -0
- package/components/buttons/buttons.prompt.md +7 -2
- 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/display/Kbd.d.ts +3 -1
- package/components/display/Kbd.jsx +8 -3
- package/components/display/Kbd.vue +7 -3
- package/components/display/display.prompt.md +3 -1
- package/components/feedback/Modal.d.ts +65 -7
- package/components/feedback/Modal.jsx +52 -22
- package/components/feedback/Modal.vue +24 -6
- package/components/feedback/Toast.vue +7 -7
- package/components/feedback/ToastStack.vue +37 -4
- package/components/feedback/feedback.prompt.md +6 -0
- package/components/feedback/modal-behavior.js +50 -0
- package/components/forms/GlobalSearch.d.ts +83 -0
- package/components/forms/GlobalSearch.jsx +172 -0
- package/components/forms/GlobalSearch.vue +161 -0
- package/components/forms/forms.prompt.md +7 -0
- package/components/forms/global-search.js +161 -0
- package/components/icons/Icon.d.ts +2 -0
- package/components/icons/Icon.jsx +14 -6
- package/components/icons/Icon.vue +11 -4
- package/components/icons/icon-paths.js +4 -2
- package/components/index.d.ts +1 -0
- package/components/index.js +1 -0
- package/components/navigation/TopBar.d.ts +9 -3
- package/components/navigation/TopBar.jsx +5 -7
- package/components/navigation/TopBar.vue +5 -6
- 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/covers.js +4 -2
- package/gallery.html +1 -1
- package/guidelines/brand-logo.html +1 -1
- package/guidelines/colors-schemes.html +1 -1
- package/guidelines/colors-transparency.html +1 -1
- package/guidelines/foundations-borders.html +1 -1
- package/guidelines/foundations-chrome.html +1 -1
- package/guidelines/foundations-motion.html +1 -1
- package/guidelines/foundations-size-tiers.html +1 -1
- package/guidelines/foundations-spacing.html +1 -1
- package/guidelines/interaction-states.html +1 -1
- package/guidelines/type-extended.html +1 -1
- package/guidelines/type-mono.html +1 -1
- package/guidelines/type-scale.html +1 -1
- package/guidelines/type-weights.html +1 -1
- package/index.html +10 -10
- package/package.json +7 -6
- package/readme.md +4 -4
- package/readme.zh-CN.md +4 -4
- package/site-nav.js +1 -1
- package/styles.css +1 -0
- package/tokens/ai.css +17 -0
- package/tokens/colors.css +1 -1
- package/tokens/tokens.d.ts +28 -0
- package/tokens/tokens.js +47 -5
- package/tokens/tokens.json +123 -5
- 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
|
@@ -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>
|
|
@@ -43,28 +53,48 @@ export function Modal({ open, title, subtitle, onClose, footer, width = 440, siz
|
|
|
43
53
|
);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
const toastTones = { success: { icon: "circle-check",
|
|
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
|
-
/** Single toast row. */
|
|
49
|
-
export function Toast({ tone = "success", title, meta, onClose, style }) {
|
|
50
|
-
const t = toastTones[tone];
|
|
58
|
+
/** Single toast row — tinted: tone wash over --bg-raised, solid tone border, tone-coloured icon, title and dismiss. */
|
|
59
|
+
export function Toast({ tone = "success", title, meta, icon, count, closeLabel = "Dismiss", onClose, children, style }) {
|
|
60
|
+
const t = toastTones[tone] || toastTones.info;
|
|
51
61
|
return (
|
|
52
|
-
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, minWidth: 280, maxWidth: 380,
|
|
53
|
-
<span style={{ color: t.
|
|
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 }}>
|
|
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: "var(--text-
|
|
56
|
-
{meta ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-
|
|
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>
|
|
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
|
-
{onClose ? <IconButton icon="x" size="sm" muted onClick={onClose} label=
|
|
68
|
+
{onClose ? <IconButton icon="x" size="sm" muted onClick={onClose} label={closeLabel} style={{ margin: "-2px 0", color: t.fg }} /> : null}
|
|
59
69
|
</div>
|
|
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>
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
4
|
import IconButton from '../buttons/IconButton.vue'
|
|
5
|
-
const toastTones = { success: { icon: 'circle-check',
|
|
6
|
-
const props = defineProps({ tone: { type: String, default: 'success' }, title: String, meta: String, dismissible: Boolean, closeLabel: { type: String, default: 'Dismiss' }, style: Object })
|
|
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, 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',
|
|
12
|
-
<span :style="{ color: t.
|
|
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
|
+
<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="
|
|
15
|
-
<span v-if="meta" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-
|
|
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
|
+
<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
|
-
<IconButton v-if="dismissible" icon="x" size="sm" muted :label="closeLabel" :style="{ margin: '-2px 0' }" @click="emit('close')" />
|
|
17
|
+
<IconButton v-if="dismissible" icon="x" size="sm" muted :label="closeLabel" :style="{ margin: '-2px 0', color: t.fg }" @click="emit('close')" />
|
|
18
18
|
</div>
|
|
19
19
|
</template>
|
|
@@ -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>
|
|
@@ -33,3 +33,9 @@ Additions (not in the source file): `Tooltip`, `Modal`, `Toast`/`ToastStack`, `A
|
|
|
33
33
|
Failed tasks retry up to 3 times with exponential backoff.
|
|
34
34
|
</Accordion>
|
|
35
35
|
```
|
|
36
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** One searchable thing. kind groups it (Features 4 · Documents 5 · Dimension Members 3 · Actions 2 under All). */
|
|
3
|
+
export interface GlobalSearchItem {
|
|
4
|
+
kind?: "feature" | "document" | "member" | "action";
|
|
5
|
+
title: string;
|
|
6
|
+
/** Second line, e.g. "Finance / Reports · updated 2h ago" */
|
|
7
|
+
sub?: string;
|
|
8
|
+
/** Right-hand meta, e.g. the document type */
|
|
9
|
+
meta?: string;
|
|
10
|
+
/** Glyph; defaults by kind (layout-grid · file-text · database · zap) */
|
|
11
|
+
icon?: string;
|
|
12
|
+
/** Documents only: category hue of the tile (--cat-<tone>-bg / --cat-<tone>) */
|
|
13
|
+
tone?: "source" | "join" | "transform" | "merge" | "output";
|
|
14
|
+
/** Matched after the title (path, kind, owner …), not highlighted */
|
|
15
|
+
keywords?: string[];
|
|
16
|
+
onSelect?: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface GlobalSearchStep { label: string; meta?: string; state: "done" | "running" | "pending" }
|
|
19
|
+
/** Deep Search progress, pushed through the update callback of onDeepSearch. */
|
|
20
|
+
export interface GlobalSearchDeepState {
|
|
21
|
+
status?: "running" | "done";
|
|
22
|
+
steps?: GlobalSearchStep[];
|
|
23
|
+
/** e.g. "42 s" */
|
|
24
|
+
elapsed?: string;
|
|
25
|
+
/** Paragraphs of the cited conclusion */
|
|
26
|
+
conclusion?: string | string[];
|
|
27
|
+
/** Source chip labels; their count is the citation count */
|
|
28
|
+
sources?: string[];
|
|
29
|
+
onOpenReport?: () => void;
|
|
30
|
+
}
|
|
31
|
+
/** Every string in the card. GLOBAL_SEARCH_LABELS.en is the default; .zh is the drafted Chinese (Prophet AI = 方知). {n} {q} {t} {i} are filled in. */
|
|
32
|
+
export interface GlobalSearchLabels {
|
|
33
|
+
field?: string; input?: string; ask?: string; clear?: string;
|
|
34
|
+
/** All · Features · Documents · Data · Actions — in that order */
|
|
35
|
+
scopes?: string[];
|
|
36
|
+
groups?: { feature?: string; document?: string; member?: string; action?: string };
|
|
37
|
+
recentSearches?: string; recent?: string; prompts?: string; viewAll?: string;
|
|
38
|
+
results?: string; searching?: string; noResults?: string;
|
|
39
|
+
askRow?: string; askSub?: string; deepRow?: string; deepSub?: string;
|
|
40
|
+
deepTitle?: string; running?: string; doneStatus?: string; stepsDone?: string; stepOf?: string;
|
|
41
|
+
conclusion?: string; openReport?: string; followUp?: string;
|
|
42
|
+
select?: string; close?: string; privacy?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Global Search — the TopBar instance of Search. Collapsed: a field styled like SearchInput (min 400px, grows to fit its placeholder, never truncated)
|
|
46
|
+
* with a ⌘K / Ctrl K hint. Exception to SearchInput: placeholder --text-secondary (≥4.5:1) and hover fills --bg-inset as well as darkening the border. Click or ⌘K / Ctrl K opens a 720px card over the top bar with a scrim. Keys: ↑↓ select (wraps) · ↵ open ·
|
|
47
|
+
* ⌘↵ / Ctrl Enter ask Prophet AI · Esc back (Deep Search) / close. It never renders AI answers: every AI entry closes the card and
|
|
48
|
+
* calls onAsk(question) so the separate Prophet AI panel can open.
|
|
49
|
+
*/
|
|
50
|
+
export interface GlobalSearchProps {
|
|
51
|
+
/** Collapsed field text (default labels.field) */
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
/** Input placeholder inside the open card (default labels.input) */
|
|
54
|
+
inputPlaceholder?: string;
|
|
55
|
+
/** Local data, matched case-insensitively on title, then keywords */
|
|
56
|
+
items?: GlobalSearchItem[];
|
|
57
|
+
/** Remote search instead of items: runs after a 300ms typing pause; the card shows a spinner and skeleton rows until it resolves */
|
|
58
|
+
onQuery?: (term: string) => GlobalSearchItem[] | Promise<GlobalSearchItem[]>;
|
|
59
|
+
/** Chips in the empty state; clicking one searches immediately */
|
|
60
|
+
recentSearches?: string[];
|
|
61
|
+
/** Recently opened items in the empty state (3) */
|
|
62
|
+
recent?: GlobalSearchItem[];
|
|
63
|
+
/** "Try Asking Prophet AI" prompts in the empty state (3) */
|
|
64
|
+
prompts?: string[];
|
|
65
|
+
/** A result or recent item was chosen (the card closes) */
|
|
66
|
+
onSelect?: (item: GlobalSearchItem) => void;
|
|
67
|
+
/** Hand-off to the Prophet AI panel with the question ("" for an empty query). The card has already closed. */
|
|
68
|
+
onAsk?: (question: string) => void;
|
|
69
|
+
/** Shows the pinned Deep Search row. Called when it's chosen; push progress through update; return a cancel function (called on Esc / typing / close). */
|
|
70
|
+
onDeepSearch?: (term: string, update: (state: GlobalSearchDeepState) => void) => void | (() => void);
|
|
71
|
+
/** Receives the query as the user types */
|
|
72
|
+
onInput?: (value: string) => void;
|
|
73
|
+
labels?: GlobalSearchLabels;
|
|
74
|
+
/** Renders the open card in place (no field, no scrim) — for docs and specimens */
|
|
75
|
+
inline?: boolean;
|
|
76
|
+
defaultOpen?: boolean;
|
|
77
|
+
defaultQuery?: string;
|
|
78
|
+
/** Start in Deep Search mode with this state (specimens) */
|
|
79
|
+
defaultDeep?: GlobalSearchDeepState;
|
|
80
|
+
style?: React.CSSProperties;
|
|
81
|
+
}
|
|
82
|
+
export declare function GlobalSearch(props: GlobalSearchProps): JSX.Element;
|
|
83
|
+
export declare const GLOBAL_SEARCH_LABELS: { en: Required<GlobalSearchLabels>; zh: Required<GlobalSearchLabels> };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Icon } from "../icons/Icon.jsx";
|
|
3
|
+
import { IconButton } from "../buttons/IconButton.jsx";
|
|
4
|
+
import { Button } from "../buttons/Button.jsx";
|
|
5
|
+
import { Kbd } from "../display/Kbd.jsx";
|
|
6
|
+
import { SegmentedControl } from "../tabs/SegmentedControl.jsx";
|
|
7
|
+
import { Spinner, Skeleton } from "../feedback/Alert.jsx";
|
|
8
|
+
import { GS_ICONS, gsLabels, gsFill, gsIsMac, gsMod, gsFilter, gsSplit, gsRows, gsPos, gsReveal, gsStyles as S, gsAiTile } from "./global-search.js";
|
|
9
|
+
|
|
10
|
+
/** Default copy: .en (default) and .zh (drafted; Prophet AI = 方知). Pass one, or your own, as labels. */
|
|
11
|
+
export { GLOBAL_SEARCH_LABELS } from "./global-search.js";
|
|
12
|
+
|
|
13
|
+
function AiTile({ on }) {
|
|
14
|
+
const t = gsAiTile(on);
|
|
15
|
+
return <span style={t.outer}><span style={t.inner}><span style={t.tint} /><Icon name="prophet-sparkle" size={16} gradient={on} style={t.icon} /></span></span>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Global Search (TopBar instance of Search). Collapsed: a quiet field with a ⌘K / Ctrl K hint. Open (click, ⌘K / Ctrl K): a 720px card over the
|
|
19
|
+
* top bar with a scrim — input row, scope bar, grouped results, pinned Ask Prophet AI / Deep Search rows, keyboard footer.
|
|
20
|
+
* It never shows AI answers: every AI entry closes the card and calls onAsk(question) so the Prophet AI panel can open.
|
|
21
|
+
* items: local data matched by title / keywords; or onQuery(term) → items | Promise<items> (debounced 300ms).
|
|
22
|
+
* onDeepSearch(term, update) runs Deep Search: push { status, steps, elapsed, conclusion, sources, onOpenReport } through update; return a cancel fn. */
|
|
23
|
+
export function GlobalSearch({ placeholder, inputPlaceholder, items = [], onQuery, recentSearches = [], recent = [], prompts = [], onSelect, onAsk, onDeepSearch, onInput, labels, inline, defaultOpen, defaultQuery = "", defaultDeep, style }) {
|
|
24
|
+
const L = gsLabels(labels);
|
|
25
|
+
const [open, setOpen] = React.useState(!!(inline || defaultOpen));
|
|
26
|
+
const [q, setQ] = React.useState(defaultQuery);
|
|
27
|
+
const [scope, setScope] = React.useState(0);
|
|
28
|
+
const [found, setFound] = React.useState(null);
|
|
29
|
+
const [loading, setLoading] = React.useState(false);
|
|
30
|
+
const [sel, setSel] = React.useState(0);
|
|
31
|
+
const [deep, setDeep] = React.useState(defaultDeep ? { query: defaultQuery, ...defaultDeep } : null);
|
|
32
|
+
const [stepsOpen, setStepsOpen] = React.useState(false);
|
|
33
|
+
const [hover, setHover] = React.useState(false);
|
|
34
|
+
const [pos, setPos] = React.useState({ left: 8, width: 720, maxH: 640 });
|
|
35
|
+
const fieldRef = React.useRef(null), inputRef = React.useRef(null), bodyRef = React.useRef(null);
|
|
36
|
+
const cancelDeep = React.useRef(null), now = React.useRef(!!defaultQuery);
|
|
37
|
+
const mac = React.useMemo(gsIsMac, []);
|
|
38
|
+
const term = q.trim();
|
|
39
|
+
|
|
40
|
+
const stopDeep = () => { if (cancelDeep.current) cancelDeep.current(); cancelDeep.current = null; };
|
|
41
|
+
const openCard = () => { if (inline) return; setPos(gsPos(fieldRef.current)); setOpen(true); };
|
|
42
|
+
const close = () => { if (inline) return; stopDeep(); setDeep(null); setOpen(false); if (fieldRef.current) fieldRef.current.focus(); };
|
|
43
|
+
const ask = (text) => { close(); if (onAsk) onAsk(text || ""); };
|
|
44
|
+
const startDeep = () => {
|
|
45
|
+
stopDeep(); setStepsOpen(false);
|
|
46
|
+
setDeep({ query: term, status: "running", steps: [] });
|
|
47
|
+
const c = onDeepSearch && onDeepSearch(term, (s) => setDeep((d) => (d ? { ...d, ...s } : d)));
|
|
48
|
+
cancelDeep.current = typeof c === "function" ? c : null;
|
|
49
|
+
};
|
|
50
|
+
const exitDeep = () => { stopDeep(); setDeep(null); };
|
|
51
|
+
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (defaultOpen && !inline) setPos(gsPos(fieldRef.current));
|
|
54
|
+
if (inline) return;
|
|
55
|
+
const h = (e) => { if (gsMod(e, mac) && e.key && e.key.toLowerCase() === "k") { e.preventDefault(); setPos(gsPos(fieldRef.current)); setOpen(true); } };
|
|
56
|
+
document.addEventListener("keydown", h);
|
|
57
|
+
return () => { document.removeEventListener("keydown", h); stopDeep(); };
|
|
58
|
+
}, [inline, mac]);
|
|
59
|
+
React.useEffect(() => { if (open && !inline && inputRef.current) inputRef.current.focus(); }, [open]);
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
if (!term) { setFound(null); setLoading(false); return; }
|
|
62
|
+
let live = true;
|
|
63
|
+
const run = () => {
|
|
64
|
+
setLoading(true);
|
|
65
|
+
Promise.resolve(onQuery ? onQuery(term) : gsFilter(items, term)).then((r) => { if (live) { setFound(r || []); setLoading(false); } }, () => { if (live) { setFound([]); setLoading(false); } });
|
|
66
|
+
};
|
|
67
|
+
let t = null;
|
|
68
|
+
if (now.current) { now.current = false; run(); } else t = setTimeout(run, 300);
|
|
69
|
+
return () => { live = false; if (t) clearTimeout(t); };
|
|
70
|
+
}, [term]);
|
|
71
|
+
React.useEffect(() => { setSel(0); }, [found, scope]);
|
|
72
|
+
|
|
73
|
+
const { rows, total } = gsRows({ term, found, loading, scope, recent, prompts, deep, hasDeep: !!onDeepSearch, L });
|
|
74
|
+
const cur = Math.max(0, Math.min(sel, rows.length - 1));
|
|
75
|
+
React.useEffect(() => { gsReveal(bodyRef.current, cur); }, [cur]);
|
|
76
|
+
|
|
77
|
+
const activate = (r) => {
|
|
78
|
+
if (!r) return;
|
|
79
|
+
if (r.kind === "result" || r.kind === "recent") { close(); if (onSelect) onSelect(r.item); if (r.item.onSelect) r.item.onSelect(); }
|
|
80
|
+
else if (r.kind === "prompt") ask(r.text);
|
|
81
|
+
else if (r.kind === "ask") ask(term);
|
|
82
|
+
else if (r.kind === "deep") startDeep();
|
|
83
|
+
};
|
|
84
|
+
const change = (v) => { if (deep) exitDeep(); setQ(v); setSel(0); if (onInput) onInput(v); };
|
|
85
|
+
const pickChip = (v) => { now.current = true; change(v); };
|
|
86
|
+
const key = (e) => {
|
|
87
|
+
if (e.nativeEvent && e.nativeEvent.isComposing) return;
|
|
88
|
+
const n = rows.length;
|
|
89
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (!n) return; e.preventDefault(); setSel((cur + (e.key === "ArrowDown" ? 1 : -1) + n) % n); }
|
|
90
|
+
else if (e.key === "Enter") { e.preventDefault(); if (gsMod(e, mac)) ask(term); else activate(rows[cur]); }
|
|
91
|
+
else if (e.key === "Escape") { e.preventDefault(); if (deep) exitDeep(); else close(); }
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const rowProps = (r) => ({ key: r.i, "data-gs-row": r.i, role: "option", "aria-selected": r.i === cur, style: S.row(r.i === cur), onMouseEnter: () => setSel(r.i), onMouseDown: (e) => e.preventDefault(), onClick: () => activate(r) });
|
|
95
|
+
const head = (r) => r.head ? <div key={"h" + r.i} style={S.head}><span>{r.head.label}</span>{r.head.more ? <button type="button" style={S.more} onMouseDown={(e) => e.preventDefault()} onClick={() => setScope(r.head.more.scope)}>{gsFill(L.viewAll, { n: r.head.more.n })}</button> : null}</div> : null;
|
|
96
|
+
const enter = (r) => (r.i === cur ? <Kbd keys={["enter"]} /> : null);
|
|
97
|
+
const itemRow = (r, mark) => {
|
|
98
|
+
const it = r.item, k = it.kind || "feature", parts = mark ? gsSplit(it.title, term) : [it.title, "", ""];
|
|
99
|
+
return <React.Fragment key={r.i}>{head(r)}<div {...rowProps(r)}>
|
|
100
|
+
<span style={S.tile(k === "document" ? it.tone : null)}><Icon name={it.icon || GS_ICONS[k]} size={16} /></span>
|
|
101
|
+
<span style={S.text}><span style={S.title}>{parts[0]}{parts[1] ? <span style={S.mark}>{parts[1]}</span> : null}{parts[2]}</span>{it.sub ? <span style={S.sub}>{it.sub}</span> : null}</span>
|
|
102
|
+
{it.meta ? <span style={S.meta}>{it.meta}</span> : null}{enter(r)}
|
|
103
|
+
</div></React.Fragment>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let body;
|
|
107
|
+
if (deep) {
|
|
108
|
+
const steps = deep.steps || [], n = steps.length, ri = steps.findIndex((s) => s.state === "running"), run = steps[ri];
|
|
109
|
+
body = <div style={{ ...S.body, padding: 0 }}><div style={S.deep}>
|
|
110
|
+
<div style={S.deepHead}><span style={S.tile()}><Icon name="text-search" size={16} /></span><span style={S.text}><span style={S.deepTitle}>{gsFill(L.deepTitle, { q: deep.query })}</span><span style={S.sub}>{deep.status === "done" ? gsFill(L.doneStatus, { t: deep.elapsed || "", n: (deep.sources || []).length }) : L.running}</span></span></div>
|
|
111
|
+
{n ? <div style={S.steps}>
|
|
112
|
+
<button type="button" data-wash="" aria-expanded={stepsOpen} style={S.toggle} onClick={() => setStepsOpen(!stepsOpen)}>
|
|
113
|
+
{deep.status === "done" || !run
|
|
114
|
+
? <><span style={{ ...S.stepIcon, color: "var(--success)" }}><Icon name="circle-check" size={14} /></span><span>{gsFill(L.stepsDone, { n })}</span><span style={S.muted}>{deep.elapsed}</span></>
|
|
115
|
+
: <><span style={S.stepIcon}><Spinner size={12} color="var(--text-secondary)" /></span><span>{run.label}…</span><span style={S.muted}>{gsFill(L.stepOf, { i: ri + 1, n })}</span></>}
|
|
116
|
+
<span style={S.grow} /><span style={{ ...S.stepIcon, color: "var(--text-muted)", transform: stepsOpen ? "rotate(180deg)" : "none" }}><Icon name="chevron-down" size={14} /></span>
|
|
117
|
+
</button>
|
|
118
|
+
{stepsOpen ? steps.map((s, i) => <div key={i} style={S.stepLine}>
|
|
119
|
+
<span style={{ ...S.stepIcon, color: "var(--success)" }}>{s.state === "done" ? <Icon name="circle-check" size={14} /> : s.state === "running" ? <Spinner size={12} color="var(--text-secondary)" /> : <span style={S.pending} />}</span>
|
|
120
|
+
<span style={{ color: s.state === "pending" ? "var(--text-muted)" : "var(--text-body)" }}>{s.label}</span>{s.meta ? <span style={S.muted}>{s.meta}</span> : null}
|
|
121
|
+
</div>) : null}
|
|
122
|
+
</div> : null}
|
|
123
|
+
{deep.conclusion ? <div style={S.conclusion}>
|
|
124
|
+
<span style={S.concLabel}>{L.conclusion}</span>
|
|
125
|
+
<div style={S.concText}>{[].concat(deep.conclusion).map((p, i) => <span key={i}>{p}</span>)}</div>
|
|
126
|
+
{deep.sources && deep.sources.length ? <div style={S.sources}>{deep.sources.map((s, i) => <span key={i} style={S.source}><Icon name="file-text" size={12} />{s}</span>)}</div> : null}
|
|
127
|
+
<div data-size="sm" style={S.actions}><Button variant="primary-soft" icon="file-text" onClick={deep.onOpenReport}>{L.openReport}</Button><Button variant="ghost" icon="prophet-sparkle" onClick={() => ask(deep.query)}>{L.followUp}</Button></div>
|
|
128
|
+
</div> : null}
|
|
129
|
+
</div></div>;
|
|
130
|
+
} else if (!term) {
|
|
131
|
+
body = <div ref={bodyRef} role="listbox" style={S.body}>
|
|
132
|
+
{recentSearches.length ? <><div style={S.head}><span>{L.recentSearches}</span></div><div style={S.chips}>{recentSearches.map((s) => <button key={s} type="button" style={S.chip} onMouseDown={(e) => e.preventDefault()} onClick={() => pickChip(s)}><span style={S.chipIcon}><Icon name="history" size={12} /></span>{s}</button>)}</div></> : null}
|
|
133
|
+
{rows.map((r) => r.kind === "recent" ? itemRow(r, false) : <React.Fragment key={r.i}>{head(r)}<div {...rowProps(r)}><AiTile on={r.i === cur} /><span style={S.text}><span style={S.title}>{r.text}</span></span>{enter(r)}</div></React.Fragment>)}
|
|
134
|
+
</div>;
|
|
135
|
+
} else {
|
|
136
|
+
const res = rows.filter((r) => r.kind === "result");
|
|
137
|
+
body = <div ref={bodyRef} role="listbox" style={S.body}>
|
|
138
|
+
{loading ? <><div style={S.head}><Skeleton width={64} height={10} /></div>{[0, 1, 2, 3].map((i) => <div key={i} style={S.skelRow}><Skeleton width={32} height={32} radius="var(--radius-md)" /><span style={S.skelText}><Skeleton width="46%" height={12} /><Skeleton width="28%" height={10} /></span></div>)}</>
|
|
139
|
+
: found && !res.length ? <div style={S.empty}>{L.noResults}</div> : res.map((r) => itemRow(r, true))}
|
|
140
|
+
</div>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pinned = !deep && term ? <div style={S.pinned}>{rows.filter((r) => r.kind === "ask" || r.kind === "deep").map((r) => r.kind === "ask"
|
|
144
|
+
? <div {...rowProps(r)}><AiTile on={r.i === cur} /><span style={S.text}><span style={S.title}>{gsFill(L.askRow, { q: term })}</span><span style={S.sub}>{L.askSub}</span></span><Kbd keys={["mod", "enter"]} /></div>
|
|
145
|
+
: <div {...rowProps(r)}><span style={S.tile()}><Icon name="text-search" size={16} /></span><span style={S.text}><span style={S.title}>{gsFill(L.deepRow, { q: term })}</span><span style={S.sub}>{L.deepSub}</span></span>{enter(r)}</div>)}</div> : null;
|
|
146
|
+
|
|
147
|
+
const card = (
|
|
148
|
+
<div role="dialog" aria-modal={inline ? undefined : "true"} aria-label={L.input} data-size="md" onKeyDown={key} style={{ ...S.card, ...(inline ? null : S.cardFixed(pos)), ...(inline ? style : null) }}>
|
|
149
|
+
<div style={S.inputRow}>
|
|
150
|
+
<span style={S.lead}>{loading ? <Spinner size={14} color="var(--text-secondary)" /> : <Icon name={deep ? "text-search" : "search"} size={16} />}</span>
|
|
151
|
+
<input ref={inputRef} value={q} onChange={(e) => change(e.target.value)} placeholder={inputPlaceholder || L.input} aria-label={inputPlaceholder || L.input} style={S.input} />
|
|
152
|
+
<span style={S.trail}>{q ? <IconButton icon="x" muted label={L.clear} title={L.clear} onClick={() => { change(""); if (inputRef.current) inputRef.current.focus(); }} /> : null}<Button variant="ai" onClick={() => ask(term)}>{L.ask}</Button></span>
|
|
153
|
+
</div>
|
|
154
|
+
{term && !deep ? <div style={S.scope}><SegmentedControl size="sm" options={L.scopes} value={L.scopes[scope]} onChange={(v) => setScope(Math.max(0, L.scopes.indexOf(v)))} /><span style={S.count}>{loading ? L.searching : found ? gsFill(L.results, { n: total }) : ""}</span></div> : null}
|
|
155
|
+
{body}
|
|
156
|
+
{pinned}
|
|
157
|
+
<div style={S.footer}>
|
|
158
|
+
<span style={S.hints}><span style={S.hint}><Kbd keys={["↑", "↓"]} />{L.select}</span><span style={S.hint}><Kbd keys={["mod", "enter"]} />{L.ask}</span><span style={S.hint}><Kbd keys={["esc"]} />{L.close}</span></span>
|
|
159
|
+
<span style={S.hint}><Icon name="lock" size={12} />{L.privacy}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
if (inline) return card;
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<button ref={fieldRef} type="button" data-field="" aria-haspopup="dialog" aria-expanded={open} onClick={openCard} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{ ...S.field(hover), ...style }}>
|
|
167
|
+
<span style={S.fieldIcon}><Icon name="search" size={16} /></span><span style={S.fieldLabel}>{placeholder || L.field}</span><Kbd keys={["mod", "K"]} />
|
|
168
|
+
</button>
|
|
169
|
+
{open ? <><div style={S.scrim} onMouseDown={close} />{card}</> : null}
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
}
|