@keyfunc/keybricks-ds 2.6.0 → 2.7.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 +13 -4
- package/INTEGRATION.md +18 -3
- package/_adherence.oxlintrc.json +57 -11
- package/_ds_bundle.js +2137 -186
- package/_scripts/build-index.mjs +1 -1
- package/_src/icon-index.js +1 -1
- package/assets/icons/icon-index.json +1 -1
- package/changelog.js +11 -1
- 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/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 +1 -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 +1 -1
- package/components/_cards/toggles.card.html +1 -1
- package/components/_cards/tooltip.card.html +1 -1
- package/components/_cards/tree.card.html +1 -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/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 +7 -0
- package/components/feedback/Modal.jsx +9 -9
- package/components/feedback/Toast.vue +6 -6
- package/components/feedback/feedback.prompt.md +2 -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/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 +8 -8
- package/package.json +2 -2
- 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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
/** Keyboard shortcut keycap row (20px).
|
|
2
|
+
/** Keyboard shortcut keycap row (20px). Semantic keys adapt to the OS: "mod" → ⌘ on macOS / Ctrl elsewhere; also "alt"/"opt", "shift", "enter", "backspace", "ctrl", "esc". Any other string renders as-is. */
|
|
3
3
|
export interface KbdProps {
|
|
4
4
|
keys?: string[];
|
|
5
|
+
/** "auto" detects from the browser (default); force "mac" or "win" for docs/screenshots. */
|
|
6
|
+
platform?: "auto" | "mac" | "win";
|
|
5
7
|
style?: React.CSSProperties;
|
|
6
8
|
}
|
|
7
9
|
export declare function Kbd(props: KbdProps): JSX.Element;
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
const KBD_MAP = { mod: ["⌘", "Ctrl"], cmd: ["⌘", "Ctrl"], ctrl: ["⌃", "Ctrl"], alt: ["⌥", "Alt"], opt: ["⌥", "Alt"], shift: ["⇧", "Shift"], enter: ["⏎", "Enter"], backspace: ["⌫", "Backspace"], esc: ["Esc", "Esc"] };
|
|
4
|
+
const isMacPlatform = () => typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || "");
|
|
5
|
+
const resolveKbd = (k, mac) => { const m = KBD_MAP[String(k).toLowerCase()]; return m ? m[mac ? 0 : 1] : k; };
|
|
6
|
+
|
|
7
|
+
/** Keyboard shortcut hint. Use semantic keys so it adapts per OS: <Kbd keys={["mod","K"]}/> → ⌘ K on macOS, Ctrl K on Windows/Linux. Literal labels ("⌘", "F5") pass through unchanged. platform: "auto" (default) | "mac" | "win". */
|
|
8
|
+
export function Kbd({ keys = [], platform = "auto", style }) {
|
|
9
|
+
const mac = platform === "auto" ? isMacPlatform() : platform === "mac";
|
|
5
10
|
return (
|
|
6
11
|
<span style={{ display: "inline-flex", gap: 3, ...style }}>
|
|
7
12
|
{keys.map((k, i) => (
|
|
8
|
-
<span key={i} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 20, height: 20, padding: "0 5px", borderRadius: "var(--radius-sm)", background: "var(--bg-inset)", border: "1px solid var(--border-control)", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{k}</span>
|
|
13
|
+
<span key={i} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 20, height: 20, padding: "0 5px", borderRadius: "var(--radius-sm)", background: "var(--bg-inset)", border: "1px solid var(--border-control)", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{resolveKbd(k, mac)}</span>
|
|
9
14
|
))}
|
|
10
15
|
</span>
|
|
11
16
|
);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
// Keyboard shortcut hint
|
|
3
|
-
|
|
2
|
+
// Keyboard shortcut hint. Semantic keys adapt per OS: <Kbd :keys="['mod','K']"/> → ⌘ K on macOS, Ctrl K on Windows/Linux. Literal labels pass through. platform: 'auto' | 'mac' | 'win'.
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
const props = defineProps({ keys: { type: Array, default: () => [] }, platform: { type: String, default: 'auto' }, style: Object })
|
|
5
|
+
const KBD_MAP = { mod: ['⌘', 'Ctrl'], cmd: ['⌘', 'Ctrl'], ctrl: ['⌃', 'Ctrl'], alt: ['⌥', 'Alt'], opt: ['⌥', 'Alt'], shift: ['⇧', 'Shift'], enter: ['⏎', 'Enter'], backspace: ['⌫', 'Backspace'], esc: ['Esc', 'Esc'] }
|
|
6
|
+
const isMac = () => typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || '')
|
|
7
|
+
const labels = computed(() => { const mac = props.platform === 'auto' ? isMac() : props.platform === 'mac'; return props.keys.map(k => { const m = KBD_MAP[String(k).toLowerCase()]; return m ? m[mac ? 0 : 1] : k }) })
|
|
4
8
|
</script>
|
|
5
9
|
<template>
|
|
6
10
|
<span :style="{ display: 'inline-flex', gap: '3px', ...style }">
|
|
7
|
-
<span v-for="(k, i) in
|
|
11
|
+
<span v-for="(k, i) in labels" :key="i" style="display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;border-radius:var(--radius-sm);background:var(--bg-inset);border:1px solid var(--border-control);box-sizing:border-box;font-family:var(--font-sans);font-variant-numeric:tabular-nums;font-size:var(--text-xs);color:var(--text-secondary)">{{ k }}</span>
|
|
8
12
|
</span>
|
|
9
13
|
</template>
|
|
@@ -26,5 +26,7 @@ Cards are flat (no shadow). IconTile hues: source green, join blue, transform or
|
|
|
26
26
|
```jsx
|
|
27
27
|
<Tag onRemove={remove}>Financial Settings</Tag>
|
|
28
28
|
<Divider label="OR"/> <Divider vertical/>
|
|
29
|
-
<Kbd keys={["
|
|
29
|
+
<Kbd keys={["mod","K"]}/> // ⌘ K on macOS, Ctrl K on Windows/Linux
|
|
30
30
|
```
|
|
31
|
+
|
|
32
|
+
Kbd: write modifiers semantically — `mod` (⌘/Ctrl), `alt`/`opt` (⌥/Alt), `shift` (⇧/Shift), `enter` (⏎/Enter), `backspace`, `ctrl`, `esc`. Don't hard-code "⌘": Windows users have no Command key. `platform="mac"|"win"` forces one side for docs.
|
|
@@ -27,6 +27,13 @@ export interface ToastProps {
|
|
|
27
27
|
tone?: "success" | "danger" | "warning" | "info";
|
|
28
28
|
title?: string;
|
|
29
29
|
meta?: string;
|
|
30
|
+
/** Replaces the tone glyph (Vue: #icon slot) */
|
|
31
|
+
icon?: React.ReactNode;
|
|
32
|
+
/** Content in place of title (Vue: default slot) */
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
/** aria-label of the dismiss button (default "Dismiss") */
|
|
35
|
+
closeLabel?: string;
|
|
36
|
+
/** Shows the dismiss button */
|
|
30
37
|
onClose?: () => void;
|
|
31
38
|
style?: React.CSSProperties;
|
|
32
39
|
}
|
|
@@ -43,19 +43,19 @@ export function Modal({ open, title, subtitle, onClose, footer, width = 440, siz
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const toastTones = { success: { icon: "circle-check",
|
|
46
|
+
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
47
|
|
|
48
|
-
/** Single toast row. */
|
|
49
|
-
export function Toast({ tone = "success", title, meta, onClose, style }) {
|
|
50
|
-
const t = toastTones[tone];
|
|
48
|
+
/** 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 }) {
|
|
50
|
+
const t = toastTones[tone] || toastTones.info;
|
|
51
51
|
return (
|
|
52
|
-
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, minWidth: 280, maxWidth: 380,
|
|
53
|
-
<span style={{ color: t.
|
|
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 }}>
|
|
53
|
+
<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
54
|
<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: "
|
|
56
|
-
{meta ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-
|
|
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>
|
|
56
|
+
{meta ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-body)" }}>{meta}</span> : null}
|
|
57
57
|
</div>
|
|
58
|
-
{onClose ? <IconButton icon="x" size="sm" muted onClick={onClose} label=
|
|
58
|
+
{onClose ? <IconButton icon="x" size="sm" muted onClick={onClose} label={closeLabel} style={{ margin: "-2px 0", color: t.fg }} /> : null}
|
|
59
59
|
</div>
|
|
60
60
|
);
|
|
61
61
|
}
|
|
@@ -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',
|
|
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
6
|
const props = defineProps({ tone: { type: String, default: 'success' }, title: String, meta: String, dismissible: Boolean, 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 :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>
|
|
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>
|
|
@@ -33,3 +33,5 @@ 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
|
+
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. Limits and Element Plus mapping: `MESSAGE_SERVICES.md`.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Global Search (TopBar instance of Search) — same props and behaviour as GlobalSearch.jsx; shared logic in ./global-search.js.
|
|
3
|
+
// Collapsed: a quiet field with a ⌘K / Ctrl K hint. Open: a 720px card over the top bar with a scrim. Keys: ↑↓ select · ↵ open ·
|
|
4
|
+
// ⌘↵ / Ctrl Enter ask Prophet AI · Esc back / close. It never shows AI answers: AI entries close the card and emit ask(question).
|
|
5
|
+
// items (local, matched on title / keywords) or query(term) → items | Promise (300ms debounce). deepSearch(term, update) → cancel fn.
|
|
6
|
+
// Events: select(item) · ask(question) · input(value).
|
|
7
|
+
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|
8
|
+
import Icon from '../icons/Icon.vue'
|
|
9
|
+
import IconButton from '../buttons/IconButton.vue'
|
|
10
|
+
import Button from '../buttons/Button.vue'
|
|
11
|
+
import Kbd from '../display/Kbd.vue'
|
|
12
|
+
import SegmentedControl from '../tabs/SegmentedControl.vue'
|
|
13
|
+
import Spinner from '../feedback/Spinner.vue'
|
|
14
|
+
import Skeleton from '../feedback/Skeleton.vue'
|
|
15
|
+
import { GS_ICONS, gsLabels, gsFill, gsIsMac, gsMod, gsFilter, gsSplit, gsRows, gsPos, gsReveal, gsStyles as S, gsAiTile } from './global-search.js'
|
|
16
|
+
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
placeholder: String, inputPlaceholder: String, items: { type: Array, default: () => [] }, query: Function,
|
|
19
|
+
recentSearches: { type: Array, default: () => [] }, recent: { type: Array, default: () => [] }, prompts: { type: Array, default: () => [] },
|
|
20
|
+
deepSearch: Function, labels: Object, inline: Boolean, defaultOpen: Boolean, defaultQuery: { type: String, default: '' }, defaultDeep: Object, style: Object,
|
|
21
|
+
})
|
|
22
|
+
const emit = defineEmits(['select', 'ask', 'input'])
|
|
23
|
+
const L = computed(() => gsLabels(props.labels))
|
|
24
|
+
const open = ref(!!(props.inline || props.defaultOpen)), q = ref(props.defaultQuery), scope = ref(0)
|
|
25
|
+
const found = ref(null), loading = ref(false), sel = ref(0), hover = ref(false), stepsOpen = ref(false)
|
|
26
|
+
const deep = ref(props.defaultDeep ? { query: props.defaultQuery, ...props.defaultDeep } : null)
|
|
27
|
+
const pos = ref({ left: 8, width: 720, maxH: 640 })
|
|
28
|
+
const fieldEl = ref(null), inputEl = ref(null), bodyEl = ref(null)
|
|
29
|
+
const mac = gsIsMac()
|
|
30
|
+
let cancelDeep = null, now = !!props.defaultQuery, timer = null, token = 0
|
|
31
|
+
const term = computed(() => q.value.trim())
|
|
32
|
+
|
|
33
|
+
const stopDeep = () => { if (cancelDeep) cancelDeep(); cancelDeep = null }
|
|
34
|
+
const openCard = () => { if (props.inline) return; pos.value = gsPos(fieldEl.value); open.value = true }
|
|
35
|
+
const close = () => { if (props.inline) return; stopDeep(); deep.value = null; open.value = false; if (fieldEl.value) fieldEl.value.focus() }
|
|
36
|
+
const ask = (text) => { close(); emit('ask', text || '') }
|
|
37
|
+
const startDeep = () => {
|
|
38
|
+
stopDeep(); stepsOpen.value = false
|
|
39
|
+
deep.value = { query: term.value, status: 'running', steps: [] }
|
|
40
|
+
const c = props.deepSearch && props.deepSearch(term.value, (s) => { if (deep.value) deep.value = { ...deep.value, ...s } })
|
|
41
|
+
cancelDeep = typeof c === 'function' ? c : null
|
|
42
|
+
}
|
|
43
|
+
const exitDeep = () => { stopDeep(); deep.value = null }
|
|
44
|
+
|
|
45
|
+
const onKeyK = (e) => { if (gsMod(e, mac) && e.key && e.key.toLowerCase() === 'k') { e.preventDefault(); openCard() } }
|
|
46
|
+
onMounted(() => { if (!props.inline) { document.addEventListener('keydown', onKeyK); if (props.defaultOpen) pos.value = gsPos(fieldEl.value) } })
|
|
47
|
+
onBeforeUnmount(() => { document.removeEventListener('keydown', onKeyK); stopDeep(); clearTimeout(timer) })
|
|
48
|
+
watch(open, (v) => { if (v && !props.inline) nextTick(() => inputEl.value && inputEl.value.focus()) })
|
|
49
|
+
|
|
50
|
+
const search = () => {
|
|
51
|
+
clearTimeout(timer); const t = term.value, my = ++token
|
|
52
|
+
if (!t) { found.value = null; loading.value = false; return }
|
|
53
|
+
const run = () => {
|
|
54
|
+
loading.value = true
|
|
55
|
+
Promise.resolve(props.query ? props.query(t) : gsFilter(props.items, t)).then((r) => { if (my === token) { found.value = r || []; loading.value = false } }, () => { if (my === token) { found.value = []; loading.value = false } })
|
|
56
|
+
}
|
|
57
|
+
if (now) { now = false; run() } else timer = setTimeout(run, 300)
|
|
58
|
+
}
|
|
59
|
+
watch(term, search, { immediate: true })
|
|
60
|
+
watch([found, scope], () => { sel.value = 0 })
|
|
61
|
+
|
|
62
|
+
const model = computed(() => gsRows({ term: term.value, found: found.value, loading: loading.value, scope: scope.value, recent: props.recent, prompts: props.prompts, deep: deep.value, hasDeep: !!props.deepSearch, L: L.value }))
|
|
63
|
+
const rows = computed(() => model.value.rows)
|
|
64
|
+
const cur = computed(() => Math.max(0, Math.min(sel.value, rows.value.length - 1)))
|
|
65
|
+
const bodyRows = computed(() => rows.value.filter((r) => r.kind !== 'ask' && r.kind !== 'deep'))
|
|
66
|
+
const pinRows = computed(() => rows.value.filter((r) => r.kind === 'ask' || r.kind === 'deep'))
|
|
67
|
+
watch(cur, (i) => nextTick(() => gsReveal(bodyEl.value, i)))
|
|
68
|
+
|
|
69
|
+
const activate = (r) => {
|
|
70
|
+
if (!r) return
|
|
71
|
+
if (r.kind === 'result' || r.kind === 'recent') { close(); emit('select', r.item); if (r.item.onSelect) r.item.onSelect() }
|
|
72
|
+
else if (r.kind === 'prompt') ask(r.text)
|
|
73
|
+
else if (r.kind === 'ask') ask(term.value)
|
|
74
|
+
else if (r.kind === 'deep') startDeep()
|
|
75
|
+
}
|
|
76
|
+
const change = (v) => { if (deep.value) exitDeep(); q.value = v; sel.value = 0; emit('input', v) }
|
|
77
|
+
const pickChip = (v) => { now = true; change(v) }
|
|
78
|
+
const clear = () => { change(''); if (inputEl.value) inputEl.value.focus() }
|
|
79
|
+
const key = (e) => {
|
|
80
|
+
if (e.isComposing) return
|
|
81
|
+
const n = rows.value.length
|
|
82
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { if (!n) return; e.preventDefault(); sel.value = (cur.value + (e.key === 'ArrowDown' ? 1 : -1) + n) % n }
|
|
83
|
+
else if (e.key === 'Enter') { e.preventDefault(); if (gsMod(e, mac)) ask(term.value); else activate(rows.value[cur.value]) }
|
|
84
|
+
else if (e.key === 'Escape') { e.preventDefault(); if (deep.value) exitDeep(); else close() }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parts = (r, mark) => (mark ? gsSplit(r.item.title, term.value) : [r.item.title, '', ''])
|
|
88
|
+
const kindOf = (it) => it.kind || 'feature'
|
|
89
|
+
const steps = computed(() => (deep.value && deep.value.steps) || [])
|
|
90
|
+
const runIdx = computed(() => steps.value.findIndex((s) => s.state === 'running'))
|
|
91
|
+
const deepDone = computed(() => !!deep.value && (deep.value.status === 'done' || runIdx.value < 0))
|
|
92
|
+
const concl = computed(() => (deep.value && deep.value.conclusion ? [].concat(deep.value.conclusion) : null))
|
|
93
|
+
const cardStyle = computed(() => ({ ...S.card, ...(props.inline ? props.style : S.cardFixed(pos.value)) }))
|
|
94
|
+
const fieldStyle = computed(() => ({ ...S.field(hover.value), ...props.style }))
|
|
95
|
+
const ai = (on) => gsAiTile(on)
|
|
96
|
+
const scopeLabel = computed({ get: () => L.value.scopes[scope.value], set: (v) => { scope.value = Math.max(0, L.value.scopes.indexOf(v)) } })
|
|
97
|
+
</script>
|
|
98
|
+
<template>
|
|
99
|
+
<button v-if="!inline" ref="fieldEl" type="button" data-field="" aria-haspopup="dialog" :aria-expanded="open" @click="openCard" @mouseenter="hover = true" @mouseleave="hover = false" :style="fieldStyle">
|
|
100
|
+
<span :style="S.fieldIcon"><Icon name="search" :size="16" /></span><span :style="S.fieldLabel">{{ placeholder || L.field }}</span><Kbd :keys="['mod', 'K']" />
|
|
101
|
+
</button>
|
|
102
|
+
<div v-if="open && !inline" :style="S.scrim" @mousedown="close" />
|
|
103
|
+
<div v-if="open" role="dialog" :aria-modal="inline ? undefined : 'true'" :aria-label="L.input" data-size="md" @keydown="key" :style="cardStyle">
|
|
104
|
+
<div :style="S.inputRow">
|
|
105
|
+
<span :style="S.lead"><Spinner v-if="loading" :size="14" color="var(--text-secondary)" /><Icon v-else :name="deep ? 'text-search' : 'search'" :size="16" /></span>
|
|
106
|
+
<input ref="inputEl" :value="q" @input="change($event.target.value)" :placeholder="inputPlaceholder || L.input" :aria-label="inputPlaceholder || L.input" :style="S.input" />
|
|
107
|
+
<span :style="S.trail"><IconButton v-if="q" icon="x" muted :label="L.clear" :title="L.clear" @click="clear" /><Button variant="ai" @click="ask(term)">{{ L.ask }}</Button></span>
|
|
108
|
+
</div>
|
|
109
|
+
<div v-if="term && !deep" :style="S.scope"><SegmentedControl size="sm" :options="L.scopes" v-model="scopeLabel" /><span :style="S.count">{{ loading ? L.searching : found ? gsFill(L.results, { n: model.total }) : '' }}</span></div>
|
|
110
|
+
|
|
111
|
+
<div v-if="deep" :style="{ ...S.body, padding: '0' }"><div :style="S.deep">
|
|
112
|
+
<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>
|
|
113
|
+
<div v-if="steps.length" :style="S.steps">
|
|
114
|
+
<button type="button" data-wash="" :aria-expanded="stepsOpen" :style="S.toggle" @click="stepsOpen = !stepsOpen">
|
|
115
|
+
<template v-if="deepDone"><span :style="{ ...S.stepIcon, color: 'var(--success)' }"><Icon name="circle-check" :size="14" /></span><span>{{ gsFill(L.stepsDone, { n: steps.length }) }}</span><span :style="S.muted">{{ deep.elapsed }}</span></template>
|
|
116
|
+
<template v-else><span :style="S.stepIcon"><Spinner :size="12" color="var(--text-secondary)" /></span><span>{{ steps[runIdx].label }}…</span><span :style="S.muted">{{ gsFill(L.stepOf, { i: runIdx + 1, n: steps.length }) }}</span></template>
|
|
117
|
+
<span :style="S.grow" /><span :style="{ ...S.stepIcon, color: 'var(--text-muted)', transform: stepsOpen ? 'rotate(180deg)' : 'none' }"><Icon name="chevron-down" :size="14" /></span>
|
|
118
|
+
</button>
|
|
119
|
+
<template v-if="stepsOpen"><div v-for="(s, i) in steps" :key="i" :style="S.stepLine">
|
|
120
|
+
<span :style="{ ...S.stepIcon, color: 'var(--success)' }"><Icon v-if="s.state === 'done'" name="circle-check" :size="14" /><Spinner v-else-if="s.state === 'running'" :size="12" color="var(--text-secondary)" /><span v-else :style="S.pending" /></span>
|
|
121
|
+
<span :style="{ color: s.state === 'pending' ? 'var(--text-muted)' : 'var(--text-body)' }">{{ s.label }}</span><span v-if="s.meta" :style="S.muted">{{ s.meta }}</span>
|
|
122
|
+
</div></template>
|
|
123
|
+
</div>
|
|
124
|
+
<div v-if="concl" :style="S.conclusion">
|
|
125
|
+
<span :style="S.concLabel">{{ L.conclusion }}</span>
|
|
126
|
+
<div :style="S.concText"><span v-for="(p, i) in concl" :key="i">{{ p }}</span></div>
|
|
127
|
+
<div v-if="deep.sources && deep.sources.length" :style="S.sources"><span v-for="(s, i) in deep.sources" :key="i" :style="S.source"><Icon name="file-text" :size="12" />{{ s }}</span></div>
|
|
128
|
+
<div data-size="sm" :style="S.actions"><Button variant="primary-soft" icon="file-text" @click="deep.onOpenReport && deep.onOpenReport()">{{ L.openReport }}</Button><Button variant="ghost" icon="prophet-sparkle" @click="ask(deep.query)">{{ L.followUp }}</Button></div>
|
|
129
|
+
</div>
|
|
130
|
+
</div></div>
|
|
131
|
+
|
|
132
|
+
<div v-else ref="bodyEl" role="listbox" :style="S.body">
|
|
133
|
+
<template v-if="!term && recentSearches.length"><div :style="S.head"><span>{{ L.recentSearches }}</span></div><div :style="S.chips"><button v-for="s in recentSearches" :key="s" type="button" :style="S.chip" @mousedown.prevent @click="pickChip(s)"><span :style="S.chipIcon"><Icon name="history" :size="12" /></span>{{ s }}</button></div></template>
|
|
134
|
+
<template v-if="term && loading"><div :style="S.head"><Skeleton :width="'64px'" :height="'10px'" /></div><div v-for="i in 4" :key="i" :style="S.skelRow"><Skeleton :width="'32px'" :height="'32px'" radius="var(--radius-md)" /><span :style="S.skelText"><Skeleton width="46%" :height="'12px'" /><Skeleton width="28%" :height="'10px'" /></span></div></template>
|
|
135
|
+
<div v-else-if="term && found && !bodyRows.length" :style="S.empty">{{ L.noResults }}</div>
|
|
136
|
+
<template v-else><template v-for="r in bodyRows" :key="r.i">
|
|
137
|
+
<div v-if="r.head" :style="S.head"><span>{{ r.head.label }}</span><button v-if="r.head.more" type="button" :style="S.more" @mousedown.prevent @click="scope = r.head.more.scope">{{ gsFill(L.viewAll, { n: r.head.more.n }) }}</button></div>
|
|
138
|
+
<div :data-gs-row="r.i" role="option" :aria-selected="r.i === cur" :style="S.row(r.i === cur)" @mouseenter="sel = r.i" @mousedown.prevent @click="activate(r)">
|
|
139
|
+
<template v-if="r.kind === 'prompt'"><span :style="ai(r.i === cur).outer"><span :style="ai(r.i === cur).inner"><span :style="ai(r.i === cur).tint" /><Icon name="prophet-sparkle" :size="16" :gradient="r.i === cur" :style="ai(r.i === cur).icon" /></span></span><span :style="S.text"><span :style="S.title">{{ r.text }}</span></span></template>
|
|
140
|
+
<template v-else>
|
|
141
|
+
<span :style="S.tile(kindOf(r.item) === 'document' ? r.item.tone : null)"><Icon :name="r.item.icon || GS_ICONS[kindOf(r.item)]" :size="16" /></span>
|
|
142
|
+
<span :style="S.text"><span :style="S.title">{{ parts(r, r.kind === 'result')[0] }}<span v-if="parts(r, r.kind === 'result')[1]" :style="S.mark">{{ parts(r, r.kind === 'result')[1] }}</span>{{ parts(r, r.kind === 'result')[2] }}</span><span v-if="r.item.sub" :style="S.sub">{{ r.item.sub }}</span></span>
|
|
143
|
+
<span v-if="r.item.meta" :style="S.meta">{{ r.item.meta }}</span>
|
|
144
|
+
</template>
|
|
145
|
+
<Kbd v-if="r.i === cur" :keys="['enter']" />
|
|
146
|
+
</div>
|
|
147
|
+
</template></template>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div v-if="!deep && term" :style="S.pinned">
|
|
151
|
+
<div v-for="r in pinRows" :key="r.i" :data-gs-row="r.i" role="option" :aria-selected="r.i === cur" :style="S.row(r.i === cur)" @mouseenter="sel = r.i" @mousedown.prevent @click="activate(r)">
|
|
152
|
+
<template v-if="r.kind === 'ask'"><span :style="ai(r.i === cur).outer"><span :style="ai(r.i === cur).inner"><span :style="ai(r.i === cur).tint" /><Icon name="prophet-sparkle" :size="16" :gradient="r.i === cur" :style="ai(r.i === cur).icon" /></span></span><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']" /></template>
|
|
153
|
+
<template v-else><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><Kbd v-if="r.i === cur" :keys="['enter']" /></template>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div :style="S.footer">
|
|
157
|
+
<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>
|
|
158
|
+
<span :style="S.hint"><Icon name="lock" :size="12" />{{ L.privacy }}</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
@@ -27,3 +27,10 @@ Everything is 12px text in 36px controls; readOnly inputs go #fafafa; disabled =
|
|
|
27
27
|
|
|
28
28
|
## RelativeTimePicker
|
|
29
29
|
Filter by "how long ago" (logs, monitoring, inbox): `<RelativeTimePicker value={{amount:1,unit:"Hour"}} onChange={(v, date) => …}/>` — a field-shaped trigger at the tier height; the dialog has an amount input and a unit select (`allowUnlimited` adds "Any time", which resolves to `null`). `labels` localises every string (unit labels: one string, or `{ one, other }` for plural languages); `formatValue` / `formatDate` override the summary and the preview. Vue: `v-model` + `@change(value, date)`.
|
|
30
|
+
|
|
31
|
+
`GlobalSearch` — the TopBar search (TopBar renders it; pass props through `search`). Collapsed it's a field styled like SearchInput (surface fill, `--border-control` hairline, r8) with a ⌘K / Ctrl K hint — **GlobalSearch exception:** its placeholder is `--text-secondary` (≥4.5:1, the platform's main entry prompt) and hover fills `--bg-inset` as well as darkening the border to `--border-strong`. Plain `SearchInput` keeps its muted placeholder and border-only hover; open it's a 720px card: input row with `Button variant="ai"`, scope bar, grouped results (Features 4 · Documents 5 · Dimension Members 3 · Actions 2 under All), pinned "Ask Prophet AI" and "Deep Search" rows, keyboard footer. Keys: ↑↓ · ↵ open · ⌘↵ / Ctrl Enter ask Prophet AI · Esc back / close (Tab keeps normal focus order). It never shows AI answers — every AI entry closes the card and calls `onAsk(question)`.
|
|
32
|
+
|
|
33
|
+
```jsx
|
|
34
|
+
<GlobalSearch items={items} recent={recent} prompts={prompts} onSelect={open} onAsk={openProphetPanel}/>
|
|
35
|
+
<GlobalSearch onQuery={(term) => api.search(term)} onDeepSearch={(term, update) => runDeep(term, update)} labels={GLOBAL_SEARCH_LABELS.zh}/>
|
|
36
|
+
```
|