@keyfunc/keybricks-ds 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPLOY.md +10 -4
- package/DESIGN.md +44 -1
- package/INTEGRATION.md +24 -6
- package/_adherence.oxlintrc.json +28 -13
- package/assets/logos/datasource/dat.png +0 -0
- package/assets/logos/datasource/excel.png +0 -0
- package/assets/logos/datasource/ftp.png +0 -0
- package/assets/logos/datasource/hierarchy.png +0 -0
- package/assets/logos/datasource/hive.png +0 -0
- package/assets/logos/datasource/json.png +0 -0
- package/assets/logos/datasource/mariadb.png +0 -0
- package/assets/logos/datasource/mongodb.png +0 -0
- package/assets/logos/datasource/mysql.png +0 -0
- package/assets/logos/datasource/odbc.png +0 -0
- package/assets/logos/datasource/oracle.png +0 -0
- package/assets/logos/datasource/postgresql.png +0 -0
- package/assets/logos/datasource/rest-api.png +0 -0
- package/assets/logos/datasource/sap-alt.png +0 -0
- package/assets/logos/datasource/sap.png +0 -0
- package/assets/logos/datasource/soap.png +0 -0
- package/assets/logos/datasource/sql-server.png +0 -0
- package/components/_cards/app-chrome.card.html +33 -21
- package/components/_cards/context-menu.card.html +7 -2
- package/components/_cards/icons-0-usage.card.html +88 -0
- package/components/_cards/icons-4-datasource.card.html +5 -0
- package/components/_cards/segmented-control.card.html +9 -3
- package/components/_cards/tabs.card.html +5 -2
- package/components/_shared/src-feedback.js +1 -1
- package/components/_shared/src-navigation.js +1 -1
- package/components/_shared/src-tabs.js +1 -1
- package/components/_shared/view-source.js +12 -12
- package/components/_vue/index.mjs +4 -1
- package/components/feedback/ContextMenu.d.ts +3 -2
- package/components/feedback/ContextMenu.jsx +8 -4
- package/components/feedback/ContextMenu.vue +9 -6
- package/components/feedback/Menu.d.ts +1 -1
- package/components/feedback/feedback.prompt.md +1 -1
- package/components/index.d.ts +1 -0
- package/components/index.js +4 -3
- package/components/navigation/SidePanel.d.ts +22 -0
- package/components/navigation/SidePanel.jsx +23 -0
- package/components/navigation/SidePanel.vue +21 -0
- package/components/navigation/Sidebar.d.ts +23 -7
- package/components/navigation/Sidebar.jsx +38 -37
- package/components/navigation/Sidebar.vue +40 -39
- package/components/navigation/TopBar.d.ts +28 -3
- package/components/navigation/TopBar.jsx +54 -7
- package/components/navigation/TopBar.vue +26 -8
- package/components/navigation/UserMenu.vue +27 -0
- package/components/navigation/chrome-tones.js +22 -0
- package/components/navigation/nav-model.js +21 -18
- package/components/navigation/navigation.prompt.md +2 -0
- package/components/tabs/CountBadge.vue +12 -0
- package/components/tabs/PanelTabs.vue +7 -3
- package/components/tabs/PillTabs.vue +7 -3
- package/components/tabs/SegmentedControl.d.ts +4 -2
- package/components/tabs/SegmentedControl.jsx +5 -3
- package/components/tabs/SegmentedControl.vue +4 -3
- package/components/tabs/Tabs.d.ts +14 -4
- package/components/tabs/Tabs.jsx +35 -20
- package/components/tabs/Tabs.vue +7 -3
- package/components/tabs/tabs.prompt.md +2 -2
- package/package.json +2 -2
- package/readme.md +4 -4
- package/readme.zh-CN.md +2 -2
- package/tokens/layout.css +4 -3
- package/tokens/tokens.d.ts +2 -0
- package/tokens/tokens.js +4 -1
- package/tokens/tokens.json +8 -1
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
/** 56px top bar
|
|
2
|
+
/** 56px top bar: bare search left; dark AI Assistant, notifications, help and the user trigger right. The trigger opens a UserMenu. */
|
|
3
3
|
export interface TopBarProps {
|
|
4
4
|
placeholder?: string;
|
|
5
|
-
/** Replaces the default bell + AI Assistant pair */
|
|
6
|
-
actions?: React.ReactNode;
|
|
7
5
|
/** Receives the new search string */
|
|
8
6
|
onSearch?: (value: string) => void;
|
|
7
|
+
/** Replaces the whole right-hand group */
|
|
8
|
+
actions?: React.ReactNode;
|
|
9
|
+
aiLabel?: string;
|
|
10
|
+
onAi?: () => void;
|
|
11
|
+
onNotifications?: () => void;
|
|
12
|
+
onHelp?: () => void;
|
|
13
|
+
notificationsLabel?: string;
|
|
14
|
+
helpLabel?: string;
|
|
15
|
+
/** Shows the user trigger (name + chevron); omit to hide it */
|
|
16
|
+
user?: { name: string; email?: string };
|
|
17
|
+
/** Content of the menu the trigger opens (default: a UserMenu with name and email) */
|
|
18
|
+
userMenu?: React.ReactNode;
|
|
9
19
|
style?: React.CSSProperties;
|
|
10
20
|
}
|
|
11
21
|
export declare function TopBar(props: TopBarProps): JSX.Element;
|
|
22
|
+
/** Account menu: 288px, avatar + name + email, an Appearance row (icon-only light / dark / system SegmentedControl), then ContextMenu rows. */
|
|
23
|
+
export interface UserMenuProps {
|
|
24
|
+
name?: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
/** Label of the selected theme option ("Light" | "Dark" | "System" by default). The Appearance row shows when theme or onThemeChange is set. Vue: v-model:theme. */
|
|
27
|
+
theme?: string;
|
|
28
|
+
onThemeChange?: (label: string) => void;
|
|
29
|
+
themeOptions?: { label: string; icon: string }[];
|
|
30
|
+
appearanceLabel?: string;
|
|
31
|
+
items?: { label?: string; icon?: string; danger?: boolean; divider?: boolean }[];
|
|
32
|
+
onSelect?: (label: string) => void;
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
export declare function UserMenu(props: UserMenuProps): JSX.Element;
|
|
36
|
+
export declare const THEME_OPTIONS: { label: string; icon: string }[];
|
|
12
37
|
/** Editor toolbar: back, 14px/600 title, "• Editing", right actions. */
|
|
13
38
|
export interface PageToolbarProps {
|
|
14
39
|
title?: string;
|
|
@@ -3,17 +3,64 @@ import { Icon } from "../icons/Icon.jsx";
|
|
|
3
3
|
import { IconButton } from "../buttons/IconButton.jsx";
|
|
4
4
|
import { Button } from "../buttons/Button.jsx";
|
|
5
5
|
import { SearchInput } from "../forms/SearchInput.jsx";
|
|
6
|
+
import { Avatar } from "../display/Avatar.jsx";
|
|
7
|
+
import { SegmentedControl } from "../tabs/SegmentedControl.jsx";
|
|
8
|
+
import { ContextMenu } from "../feedback/ContextMenu.jsx";
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
const TXT = { fontFamily: "var(--font-sans)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)" };
|
|
11
|
+
const ONE = { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" };
|
|
12
|
+
export const THEME_OPTIONS = [{ label: "Light", icon: "sun" }, { label: "Dark", icon: "moon" }, { label: "System", icon: "monitor" }];
|
|
13
|
+
|
|
14
|
+
/** Account menu (opened from the TopBar's user trigger): avatar + name + email, an Appearance row with a light / dark / system switch, then rows.
|
|
15
|
+
* theme: "Light" | "Dark" | "System" (labels of themeOptions). items: [{ icon, label, danger?, divider? }] → onSelect(label). */
|
|
16
|
+
export function UserMenu({ name, email, theme, onThemeChange, themeOptions = THEME_OPTIONS, appearanceLabel = "Appearance", items = [], onSelect, style }) {
|
|
17
|
+
return (
|
|
18
|
+
<div role="menu" style={{ width: 288, background: "var(--bg-raised)", border: "1px solid var(--border-card)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-menu)", padding: 4, boxSizing: "border-box", ...style }}>
|
|
19
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 12px" }}>
|
|
20
|
+
<Avatar name={name} size={32} />
|
|
21
|
+
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
|
22
|
+
<span style={{ ...TXT, ...ONE, fontWeight: "var(--weight-medium)", color: "var(--text-heading)" }}>{name}</span>
|
|
23
|
+
{email ? <span style={{ ...ONE, fontFamily: "var(--font-sans)", fontSize: "var(--text-xs)", lineHeight: "var(--leading-xs)", color: "var(--text-secondary)" }}>{email}</span> : null}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div style={{ height: 1, background: "var(--border-subtle)", margin: "4px 0" }} />
|
|
27
|
+
{onThemeChange || theme ? <div style={{ height: 40, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "0 12px", boxSizing: "border-box" }}>
|
|
28
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}><span style={{ display: "inline-flex", color: "var(--text-body)" }}><Icon name="sliders" size={16} /></span><span style={{ ...TXT, ...ONE, color: "var(--text-heading)" }}>{appearanceLabel}</span></div>
|
|
29
|
+
<SegmentedControl iconOnly options={themeOptions} value={theme} onChange={onThemeChange} style={{ flexShrink: 0 }} />
|
|
30
|
+
</div> : null}
|
|
31
|
+
{items.length ? <ContextMenu items={items} onSelect={onSelect} style={{ display: "flex", border: "none", boxShadow: "none", padding: 0, minWidth: 0, background: "transparent" }} /> : null}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 56px top bar: bare search on the left; AI Assistant (dark), notifications, help and the user trigger on the right.
|
|
37
|
+
* The user trigger opens a UserMenu below it (closes on outside click). actions replaces the right-hand group entirely. */
|
|
38
|
+
export function TopBar({ placeholder = "Search documents, workflows, data...", onSearch, actions, aiLabel = "AI Assistant", onAi, onNotifications, onHelp, notificationsLabel = "Notifications", helpLabel = "Help", user, userMenu, style }) {
|
|
39
|
+
const [open, setOpen] = React.useState(false);
|
|
40
|
+
const ref = React.useRef(null);
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
const off = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
44
|
+
const esc = (e) => { if (e.key === "Escape") setOpen(false); };
|
|
45
|
+
document.addEventListener("mousedown", off); document.addEventListener("keydown", esc);
|
|
46
|
+
return () => { document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); };
|
|
47
|
+
}, [open]);
|
|
48
|
+
const [hover, setHover] = React.useState(false);
|
|
9
49
|
return (
|
|
10
|
-
<header style={{ height: "var(--topbar-h)", background: "var(--bg-surface)", borderBottom: "1px solid var(--border-card)", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 24px", boxSizing: "border-box", flexShrink: 0, ...style }}>
|
|
11
|
-
<SearchInput bare placeholder={placeholder} onChange={onSearch} style={{
|
|
12
|
-
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
50
|
+
<header style={{ height: "var(--topbar-h)", background: "var(--bg-surface)", borderBottom: "1px solid var(--border-card)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "0 24px", boxSizing: "border-box", flexShrink: 0, minWidth: 0, position: "relative", ...style }}>
|
|
51
|
+
<SearchInput bare placeholder={placeholder} onChange={onSearch} style={{ flex: "0 1 576px", minWidth: 0 }} />
|
|
52
|
+
<div ref={ref} style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
|
|
13
53
|
{actions || (<>
|
|
14
|
-
<
|
|
15
|
-
<
|
|
54
|
+
<Button variant="dark" icon="ai" onClick={onAi}>{aiLabel}</Button>
|
|
55
|
+
<IconButton icon="bell" label={notificationsLabel} title={notificationsLabel} onClick={onNotifications} />
|
|
56
|
+
<IconButton icon="circle-help" label={helpLabel} title={helpLabel} onClick={onHelp} />
|
|
57
|
+
{user ? <div role="button" tabIndex={0} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen(!open); } }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
|
58
|
+
style={{ height: 32, display: "flex", alignItems: "center", gap: 8, padding: "0 8px 0 10px", borderRadius: "var(--radius-md)", cursor: "pointer", userSelect: "none", background: open ? "var(--bg-active)" : hover ? "var(--bg-hover)" : "transparent", transition: "var(--t-hover)" }}>
|
|
59
|
+
<span style={{ ...TXT, fontWeight: "var(--weight-medium)", color: "var(--text-heading)", whiteSpace: "nowrap" }}>{user.name}</span>
|
|
60
|
+
<span style={{ display: "inline-flex", color: "var(--text-muted)" }}><Icon name="chevron-down" size={16} /></span>
|
|
61
|
+
</div> : null}
|
|
16
62
|
</>)}
|
|
63
|
+
{open && user ? <div style={{ position: "absolute", right: 24, top: 52, zIndex: "var(--z-menu)" }}>{userMenu || <UserMenu name={user.name} email={user.email} />}</div> : null}
|
|
17
64
|
</div>
|
|
18
65
|
</header>
|
|
19
66
|
);
|
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import Icon from '../icons/Icon.vue'
|
|
2
4
|
import IconButton from '../buttons/IconButton.vue'
|
|
3
5
|
import Button from '../buttons/Button.vue'
|
|
4
6
|
import SearchInput from '../forms/SearchInput.vue'
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import UserMenu from './UserMenu.vue'
|
|
8
|
+
// 56px top bar: bare search on the left; AI Assistant (dark), notifications, help and the user trigger on the right.
|
|
9
|
+
// The user trigger opens a UserMenu below it (closes on outside click / Escape); #user-menu slot replaces its content.
|
|
10
|
+
// #actions slot replaces the whole right-hand group.
|
|
11
|
+
const props = defineProps({ placeholder: { type: String, default: 'Search documents, workflows, data...' }, aiLabel: { type: String, default: 'AI Assistant' }, notificationsLabel: { type: String, default: 'Notifications' }, helpLabel: { type: String, default: 'Help' }, user: Object, style: Object })
|
|
12
|
+
const emit = defineEmits(['search', 'ai', 'notifications', 'help'])
|
|
13
|
+
const open = ref(false), hover = ref(false), root = ref(null)
|
|
14
|
+
const off = (e) => { if (open.value && root.value && !root.value.contains(e.target)) open.value = false }
|
|
15
|
+
const esc = (e) => { if (e.key === 'Escape') open.value = false }
|
|
16
|
+
onMounted(() => { document.addEventListener('mousedown', off); document.addEventListener('keydown', esc) })
|
|
17
|
+
onUnmounted(() => { document.removeEventListener('mousedown', off); document.removeEventListener('keydown', esc) })
|
|
18
|
+
function key(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open.value = !open.value } }
|
|
8
19
|
</script>
|
|
9
20
|
<template>
|
|
10
|
-
<header :style="{ height: 'var(--topbar-h)', background: 'var(--bg-surface)', borderBottom: '1px solid var(--border-card)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 24px', boxSizing: 'border-box', flexShrink: 0, ...style }">
|
|
11
|
-
<SearchInput bare :placeholder="placeholder" @update:modelValue="v => emit('search', v)" style="
|
|
12
|
-
<div style="display:flex;align-items:center;gap:8px">
|
|
21
|
+
<header :style="{ height: 'var(--topbar-h)', background: 'var(--bg-surface)', borderBottom: '1px solid var(--border-card)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', padding: '0 24px', boxSizing: 'border-box', flexShrink: 0, minWidth: 0, position: 'relative', ...style }">
|
|
22
|
+
<SearchInput bare :placeholder="placeholder" @update:modelValue="v => emit('search', v)" style="flex:0 1 576px;min-width:0" />
|
|
23
|
+
<div ref="root" style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
13
24
|
<slot name="actions">
|
|
14
|
-
<
|
|
15
|
-
<
|
|
25
|
+
<Button variant="dark" icon="ai" @click="emit('ai')">{{ aiLabel }}</Button>
|
|
26
|
+
<IconButton icon="bell" :label="notificationsLabel" :title="notificationsLabel" @click="emit('notifications')" />
|
|
27
|
+
<IconButton icon="circle-help" :label="helpLabel" :title="helpLabel" @click="emit('help')" />
|
|
28
|
+
<div v-if="user" role="button" tabindex="0" aria-haspopup="menu" :aria-expanded="open" @click="open = !open" @keydown="key" @mouseenter="hover = true" @mouseleave="hover = false"
|
|
29
|
+
:style="{ height: '32px', display: 'flex', alignItems: 'center', gap: '8px', padding: '0 8px 0 10px', borderRadius: 'var(--radius-md)', cursor: 'pointer', userSelect: 'none', background: open ? 'var(--bg-active)' : hover ? 'var(--bg-hover)' : 'transparent', transition: 'var(--t-hover)' }">
|
|
30
|
+
<span style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-weight:var(--weight-medium);color:var(--text-heading);white-space:nowrap">{{ user.name }}</span>
|
|
31
|
+
<span style="display:inline-flex;color:var(--text-muted)"><Icon name="chevron-down" :size="16" /></span>
|
|
32
|
+
</div>
|
|
16
33
|
</slot>
|
|
34
|
+
<div v-if="open && user" style="position:absolute;right:24px;top:52px;z-index:var(--z-menu)"><slot name="user-menu"><UserMenu :name="user.name" :email="user.email" :show-appearance="false" /></slot></div>
|
|
17
35
|
</div>
|
|
18
36
|
</header>
|
|
19
37
|
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import Icon from '../icons/Icon.vue'
|
|
3
|
+
import Avatar from '../display/Avatar.vue'
|
|
4
|
+
import SegmentedControl from '../tabs/SegmentedControl.vue'
|
|
5
|
+
import ContextMenu from '../feedback/ContextMenu.vue'
|
|
6
|
+
// Account menu (opened from the TopBar's user trigger): avatar + name + email, an Appearance row with a light / dark / system switch
|
|
7
|
+
// (v-model:theme, labels of themeOptions), then rows (items → @select(label)).
|
|
8
|
+
defineProps({ name: String, email: String, theme: String, themeOptions: { type: Array, default: () => [{ label: 'Light', icon: 'sun' }, { label: 'Dark', icon: 'moon' }, { label: 'System', icon: 'monitor' }] }, appearanceLabel: { type: String, default: 'Appearance' }, showAppearance: { type: Boolean, default: true }, items: { type: Array, default: () => [] }, style: Object })
|
|
9
|
+
const emit = defineEmits(['update:theme', 'select'])
|
|
10
|
+
</script>
|
|
11
|
+
<template>
|
|
12
|
+
<div role="menu" :style="{ width: '288px', background: 'var(--bg-raised)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', padding: '4px', boxSizing: 'border-box', ...style }">
|
|
13
|
+
<div style="display:flex;align-items:center;gap:10px;padding:8px 12px">
|
|
14
|
+
<Avatar :name="name" :size="32" />
|
|
15
|
+
<div style="display:flex;flex-direction:column;min-width:0">
|
|
16
|
+
<span style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);font-weight:var(--weight-medium);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ name }}</span>
|
|
17
|
+
<span v-if="email" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ email }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<div style="height:1px;background:var(--border-subtle);margin:4px 0"></div>
|
|
21
|
+
<div v-if="showAppearance" style="height:40px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 12px;box-sizing:border-box">
|
|
22
|
+
<div style="display:flex;align-items:center;gap:10px;min-width:0"><span style="display:inline-flex;color:var(--text-body)"><Icon name="sliders" :size="16" /></span><span style="font-family:var(--font-sans);font-size:var(--text-base);line-height:var(--leading-base);color:var(--text-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ appearanceLabel }}</span></div>
|
|
23
|
+
<SegmentedControl icon-only :options="themeOptions" :model-value="theme" @update:model-value="v => emit('update:theme', v)" style="flex-shrink:0" />
|
|
24
|
+
</div>
|
|
25
|
+
<ContextMenu v-if="items.length" :items="items" @select="l => emit('select', l)" :style="{ display: 'flex', border: 'none', boxShadow: 'none', padding: 0, minWidth: 0, background: 'transparent' }" />
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// App chrome tones — framework-neutral, shared by Sidebar and SidePanel in React and Vue.
|
|
2
|
+
// recessed (default): grey rail, the active item is a white tile with a hairline ring.
|
|
3
|
+
// flush: white rail, neutral selected fill. contrast: the rail is scoped to the dark theme.
|
|
4
|
+
// custom: dark scope on a solid colour (SIDEBAR_COLORS or any hex); washes are white at 10 / 16 %.
|
|
5
|
+
// chromeTone() returns the data-theme for the rail and the CSS custom properties it sets on itself:
|
|
6
|
+
// --sb-* drive the rail's own surfaces; --bg-hover / --bg-active / … are remapped so components inside
|
|
7
|
+
// (SearchInput, SegmentedControl, Tree) sit correctly on the rail colour.
|
|
8
|
+
export const SIDEBAR_COLORS = { slate: '#1e293b', navy: '#14305c', indigo: '#312e81', violet: '#4c1d95', plum: '#701a75', wine: '#7f1d1d', forest: '#14532d', teal: '#134e4a', umber: '#78350f', charcoal: '#262626' }
|
|
9
|
+
const wash = (p) => 'color-mix(in srgb, var(--on-accent) ' + p + '%, transparent)'
|
|
10
|
+
const BASE = { '--sb-border': 'var(--border-card)', '--sb-text': 'var(--text-body)', '--sb-brand': 'var(--text-primary)', '--sb-active-text': 'var(--text-heading)', '--sb-muted': 'var(--text-muted)' }
|
|
11
|
+
const GREY = { ...BASE, '--sb-bg': 'var(--bg-table-header)', '--sb-active': 'var(--bg-surface)', '--sb-hover': 'var(--bg-press)', '--sb-shadow': '0 0 0 1px var(--border-card)', '--bg-hover': 'var(--bg-press)', '--bg-active': 'var(--bg-surface)', '--bg-inset': 'var(--overlay-hover)' }
|
|
12
|
+
const WHITE = { ...BASE, '--sb-bg': 'var(--bg-surface)', '--sb-active': 'var(--bg-active)', '--sb-hover': 'var(--bg-hover)', '--sb-shadow': 'none' }
|
|
13
|
+
export function chromeTone(tone, color) {
|
|
14
|
+
const t = tone || 'recessed'
|
|
15
|
+
if (t === 'flush') return { theme: undefined, vars: WHITE }
|
|
16
|
+
if (t === 'contrast') return { theme: 'dark', vars: GREY }
|
|
17
|
+
if (t === 'custom') {
|
|
18
|
+
const bg = SIDEBAR_COLORS[color] || color || SIDEBAR_COLORS.slate
|
|
19
|
+
return { theme: 'dark', vars: { ...GREY, '--sb-bg': bg, '--sb-border': wash(12), '--sb-active': wash(16), '--sb-hover': wash(10), '--sb-shadow': 'none', '--bg-hover': wash(10), '--bg-press': wash(16), '--bg-active': wash(16), '--bg-inset': wash(10), '--border-control': wash(16) } }
|
|
20
|
+
}
|
|
21
|
+
return { theme: undefined, vars: GREY }
|
|
22
|
+
}
|
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
// The product's
|
|
2
|
-
//
|
|
1
|
+
// The product's nav model. Framework-neutral: re-exported by the React entry and exported from the Vue entry.
|
|
2
|
+
// KEYBRICKS_NAV — the main rail; trailing: true marks an item that opens a side panel (chevron on the right).
|
|
3
|
+
// KEYBRICKS_NAV_FOOTER — pinned at the bottom of the rail. KEYBRICKS_MGMT — the Management side panel's groups.
|
|
3
4
|
export const KEYBRICKS_NAV = [
|
|
4
5
|
{ icon: 'house', label: 'Home' },
|
|
5
|
-
{ icon: 'file-text', label: 'Documents' },
|
|
6
|
-
{ icon: '
|
|
6
|
+
{ icon: 'file-text', label: 'Documents', trailing: true },
|
|
7
|
+
{ icon: 'journal', label: 'Voucher' },
|
|
8
|
+
{ icon: 'credit-card', label: 'Fund Payment Plan' },
|
|
7
9
|
{ icon: 'database', label: 'Data Integration' },
|
|
10
|
+
{ icon: 'git-branch', label: 'Data Flow' },
|
|
8
11
|
{ icon: 'reconciliation', label: 'Reconciliation' },
|
|
9
12
|
{ icon: 'consolidation', label: 'Consolidation' },
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
},
|
|
23
|
-
]
|
|
13
|
+
{ icon: 'task-execution', label: 'Task Execution' },
|
|
14
|
+
]
|
|
15
|
+
export const KEYBRICKS_NAV_FOOTER = [
|
|
16
|
+
{ icon: 'settings', label: 'Management', trailing: true },
|
|
17
|
+
{ icon: 'book-open', label: 'Knowledge Base' },
|
|
18
|
+
]
|
|
19
|
+
export const KEYBRICKS_MGMT = [
|
|
20
|
+
{ icon: 'box', label: 'Modeling', children: ['Dimension', 'Model', 'Environment', 'Journal Template', 'Detail Template', 'Relationship'] },
|
|
21
|
+
{ icon: 'repeat', label: 'Workflow', children: ['Workflow', 'Requests', 'Task Tracker'] },
|
|
22
|
+
{ icon: 'shield', label: 'Security Management', children: ['Users', 'Roles', 'Permissions'] },
|
|
23
|
+
{ icon: 'refresh-cw', label: 'System Migration' },
|
|
24
|
+
{ icon: 'circle-check', label: 'Validation Rules' },
|
|
25
|
+
{ icon: 'settings-2', label: 'Control Center' },
|
|
26
|
+
]
|
|
@@ -20,3 +20,5 @@ Set `logo` to the correct relative path to `assets/keybricks-logo.png`.
|
|
|
20
20
|
<ThemeToggle dark={dark} onToggle={setDark}/> // controlled
|
|
21
21
|
applyStoredTheme(); // call once at app start
|
|
22
22
|
```
|
|
23
|
+
|
|
24
|
+
App chrome: `<Sidebar tone="recessed" version="Version 2.4.0" onSelect={…}/>` (items default to `KEYBRICKS_NAV`, footer to `KEYBRICKS_NAV_FOOTER`; `trailing: true` items open a `<SidePanel title="Documents" onClose={…}>` docked beside the rail — same `tone`) and `<TopBar user={{ name, email }} userMenu={<UserMenu … theme={t} onThemeChange={setT}/>}/>`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
// Count badge beside a segment / pill label: --count-h tall (sm 16 · md 18 · lg 20), 6px sides, full radius, 12px tabular figures
|
|
4
|
+
// (digits are Latin, so not bumped in CJK). Fill: the alpha wash --neutral-alpha-8, so it darkens any surface by the same step (white tab,
|
|
5
|
+
// #f5f5f5 active pill, dark theme); text tells selected from unselected: --text-heading vs --text-body (a lighter grey fails 4.5:1 at 12px).
|
|
6
|
+
// Hidden for 0 or a missing count unless showZero, which draws a muted 0.
|
|
7
|
+
const props = defineProps({ count: Number, selected: Boolean, showZero: Boolean, style: Object })
|
|
8
|
+
const zero = computed(() => props.count === 0 || props.count == null)
|
|
9
|
+
</script>
|
|
10
|
+
<template>
|
|
11
|
+
<span v-if="!zero || showZero" aria-hidden="true" :style="{ display: 'inline-flex', alignItems: 'center', height: 'var(--count-h)', padding: '0 6px', borderRadius: 'var(--radius-full)', background: 'var(--neutral-alpha-8)', color: zero ? 'var(--text-muted)' : selected ? 'var(--text-heading)' : 'var(--text-body)', fontFamily: 'var(--font-sans)', fontSize: '12px', lineHeight: 'var(--count-h)', fontWeight: 'var(--weight-medium)', letterSpacing: 0, fontVariantNumeric: 'tabular-nums', flexShrink: 0, ...style }">{{ zero ? 0 : count }}</span>
|
|
12
|
+
</template>
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import CountBadge from './CountBadge.vue'
|
|
2
4
|
// Panel tabs (Node Info · Calc Rules · …): a lighter underline tab bar at the region's bar height with regular-weight control text. Put the panel in an sm region for the compact 32px/12px look.
|
|
3
|
-
|
|
5
|
+
// tabs: string[] | { label, count?, showZero? }[] — a count renders as a CountBadge 6px right of the label; active is matched by label.
|
|
6
|
+
const props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })
|
|
4
7
|
const emit = defineEmits(['change'])
|
|
8
|
+
const items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))
|
|
5
9
|
</script>
|
|
6
10
|
<template>
|
|
7
11
|
<div role="tablist" :data-size="size" :style="{ display: 'flex', borderBottom: '1px solid var(--border-subtle)', ...style }">
|
|
8
|
-
<button v-for="t in
|
|
9
|
-
:style="{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t === active ? '2px solid var(--action-primary)' : '2px solid transparent', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t === active ? 'var(--action-primary)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }">{{ t }}
|
|
12
|
+
<button v-for="t in items" :key="t.label" type="button" role="tab" :aria-selected="t.label === active" :data-ink="t.label === active ? undefined : ''" @click="emit('change', t.label)"
|
|
13
|
+
:style="{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t.label === active ? '2px solid var(--action-primary)' : '2px solid transparent', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--action-primary)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }">{{ t.label }}<CountBadge :count="t.count" :selected="t.label === active" :show-zero="t.showZero ?? showZero" /></button>
|
|
10
14
|
</div>
|
|
11
15
|
</template>
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import CountBadge from './CountBadge.vue'
|
|
2
4
|
// Text pill filter tabs. Active = tinted pill. Pills are control-height (sm 28 · md 32 · lg 40); omit `size` to inherit.
|
|
3
|
-
|
|
5
|
+
// tabs: string[] | { label, count?, showZero? }[] — a count draws a CountBadge 6px right of the label; active is matched by label.
|
|
6
|
+
const props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })
|
|
4
7
|
const emit = defineEmits(['change'])
|
|
8
|
+
const items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))
|
|
5
9
|
</script>
|
|
6
10
|
<template>
|
|
7
11
|
<div role="tablist" :data-size="size" :style="{ display: 'flex', gap: 'var(--control-gap)', ...style }">
|
|
8
|
-
<button v-for="t in
|
|
9
|
-
:style="{ background: t === active ? 'var(--bg-active)' : 'var(--wash, transparent)', border: 'none', cursor: 'pointer', height: 'var(--control-h)', padding: '0 var(--control-px)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t === active ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }">{{ t }}
|
|
12
|
+
<button v-for="t in items" :key="t.label" type="button" role="tab" :aria-selected="t.label === active" :data-ink="t.label === active ? undefined : ''" :data-wash="t.label === active ? undefined : ''" @click="emit('change', t.label)"
|
|
13
|
+
:style="{ background: t.label === active ? 'var(--bg-active)' : 'var(--wash, transparent)', border: 'none', cursor: 'pointer', height: 'var(--control-h)', padding: '0 var(--control-px)', borderRadius: 'var(--radius-md)', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', whiteSpace: 'nowrap' }">{{ t.label }}<CountBadge :count="t.count" :selected="t.label === active" :show-zero="t.showZero ?? showZero" /></button>
|
|
10
14
|
</div>
|
|
11
15
|
</template>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
/** Inset segmented control with a raised active segment — switches views of the same data, applies instantly. ← → move between enabled segments. */
|
|
3
3
|
export interface SegmentedControlProps {
|
|
4
|
-
/** Plain labels, or { label, icon?, disabled? } entries. Value is matched by label. */
|
|
5
|
-
options?: (string | { label: string; icon?: string; disabled?: boolean })[];
|
|
4
|
+
/** Plain labels, or { label, icon?, disabled?, count?, showZero? } entries. Value is matched by label; count renders as a CountBadge 6px right of the label (hidden in iconOnly). */
|
|
5
|
+
options?: (string | { label: string; icon?: string; disabled?: boolean; count?: number; showZero?: boolean })[];
|
|
6
6
|
value?: string;
|
|
7
7
|
onChange?: (value: string) => void;
|
|
8
8
|
/** Size tier: sm 28 · md 32 · lg 40. Omit to inherit the enclosing region's tier (the default). */
|
|
@@ -13,6 +13,8 @@ export interface SegmentedControlProps {
|
|
|
13
13
|
disabled?: boolean;
|
|
14
14
|
/** Stretch to the container, segments share the width evenly */
|
|
15
15
|
fullWidth?: boolean;
|
|
16
|
+
/** Show a muted 0 badge for zero counts on every option (an option's own showZero wins). */
|
|
17
|
+
showZero?: boolean;
|
|
16
18
|
style?: React.CSSProperties;
|
|
17
19
|
}
|
|
18
20
|
export declare function SegmentedControl(props: SegmentedControlProps): JSX.Element;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Icon } from "../icons/Icon.jsx";
|
|
3
|
+
import { CountBadge } from "./Tabs.jsx";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.
|
|
6
|
-
* options: string[] | { label, icon?, disabled? }[] value/onChange by label
|
|
7
|
+
* options: string[] | { label, icon?, disabled?, count?, showZero? }[] value/onChange by label
|
|
8
|
+
* count draws a CountBadge 6px right of the label (hidden for 0 unless showZero — per option or on the control); the match stays by label.
|
|
7
9
|
* size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier · iconOnly hides labels (label becomes aria-label / title)
|
|
8
10
|
* disabled greys the whole control · fullWidth stretches segments evenly
|
|
9
11
|
* Keyboard: ← → move between enabled segments.
|
|
10
12
|
*/
|
|
11
13
|
const tierIcon = { width: "var(--control-icon)", height: "var(--control-icon)" };
|
|
12
|
-
export function SegmentedControl({ options = [], value, onChange, size, iconOnly, disabled, fullWidth, style }) {
|
|
14
|
+
export function SegmentedControl({ options = [], value, onChange, size, iconOnly, disabled, fullWidth, showZero, style }) {
|
|
13
15
|
const opts = options.map((o) => (typeof o === "string" ? { label: o } : o));
|
|
14
16
|
const enabled = opts.filter((o) => !o.disabled);
|
|
15
17
|
const onKey = (e) => {
|
|
@@ -27,7 +29,7 @@ export function SegmentedControl({ options = [], value, onChange, size, iconOnly
|
|
|
27
29
|
<button key={o.label} type="button" role="tab" aria-selected={is} aria-label={iconOnly ? o.label : undefined} title={iconOnly ? o.label : undefined} disabled={off} tabIndex={is ? 0 : -1} data-ink={is || off ? undefined : ""}
|
|
28
30
|
onClick={() => !off && onChange && onChange(o.label)}
|
|
29
31
|
style={{ height: "calc(var(--control-h) - 6px)", padding: iconOnly ? "0 calc(var(--control-px) - 4px)" : "0 calc(var(--control-px) - 2px)", flex: fullWidth ? 1 : "none", justifyContent: "center", display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-sm)", border: "none", cursor: off ? "not-allowed" : "pointer", background: is ? "var(--bg-surface)" : "transparent", boxShadow: is ? "0 1px 2px rgba(0,0,0,.08)" : "none", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--text-heading)" : "var(--ink, var(--text-secondary))", opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: "nowrap", transition: "var(--t-hover)" }}>
|
|
30
|
-
{o.icon ? <Icon name={o.icon} size={16} style={tierIcon} /> : null}{iconOnly ? null : o.label}
|
|
32
|
+
{o.icon ? <Icon name={o.icon} size={16} style={tierIcon} /> : null}{iconOnly ? null : o.label}{iconOnly ? null : <CountBadge count={o.count} selected={is} showZero={o.showZero ?? showZero} />}
|
|
31
33
|
</button>
|
|
32
34
|
);
|
|
33
35
|
})}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed } from 'vue'
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
|
+
import CountBadge from './CountBadge.vue'
|
|
4
5
|
// Segmented control — inset track, raised active segment. Switches views of the same data, applies instantly.
|
|
5
|
-
// options: string[] | { label, icon?, disabled? }[] · v-model by label · size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier
|
|
6
|
+
// options: string[] | { label, icon?, disabled?, count?, showZero? }[] · v-model by label · count draws a CountBadge 6px right of the label (hidden for 0 unless showZero) · size sm 28 | md 32 | lg 40 — omit it to inherit the region's tier
|
|
6
7
|
// iconOnly hides labels (label → aria-label/title) · disabled greys the control · fullWidth stretches segments · ← → keyboard.
|
|
7
8
|
const tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }
|
|
8
|
-
const props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, size: String, iconOnly: Boolean, disabled: Boolean, fullWidth: Boolean, style: Object })
|
|
9
|
+
const props = defineProps({ options: { type: Array, default: () => [] }, modelValue: String, size: String, iconOnly: Boolean, disabled: Boolean, fullWidth: Boolean, showZero: Boolean, style: Object })
|
|
9
10
|
const emit = defineEmits(['update:modelValue'])
|
|
10
11
|
const opts = computed(() => props.options.map(o => typeof o === 'string' ? { label: o } : o))
|
|
11
12
|
const enabled = computed(() => opts.value.filter(o => !o.disabled))
|
|
@@ -21,7 +22,7 @@ function onKey(e) {
|
|
|
21
22
|
<div role="tablist" :data-size="size" :aria-disabled="disabled || undefined" @keydown="onKey" :style="{ display: fullWidth ? 'flex' : 'inline-flex', height: 'var(--control-h)', padding: '2px', gap: '2px', background: 'var(--bg-inset)', border: '1px solid var(--border-control)', borderRadius: 'var(--radius-md)', boxSizing: 'border-box', opacity: disabled ? .5 : 1, ...style }">
|
|
22
23
|
<button v-for="o in opts" :key="o.label" type="button" role="tab" :aria-selected="modelValue === o.label" :aria-label="iconOnly ? o.label : undefined" :title="iconOnly ? o.label : undefined" :disabled="disabled || o.disabled" :tabindex="modelValue === o.label ? 0 : -1" :data-ink="modelValue === o.label || disabled || o.disabled ? undefined : ''" @click="pick(o)"
|
|
23
24
|
:style="{ height: 'calc(var(--control-h) - 6px)', padding: iconOnly ? '0 calc(var(--control-px) - 4px)' : '0 calc(var(--control-px) - 2px)', flex: fullWidth ? 1 : 'none', justifyContent: 'center', display: 'inline-flex', alignItems: 'center', gap: '6px', borderRadius: 'var(--radius-sm)', border: 'none', cursor: (disabled || o.disabled) ? 'not-allowed' : 'pointer', background: modelValue === o.label ? 'var(--bg-surface)' : 'transparent', boxShadow: modelValue === o.label ? '0 1px 2px rgba(0,0,0,.08)' : 'none', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: modelValue === o.label ? 'var(--text-heading)' : 'var(--ink, var(--text-secondary))', opacity: o.disabled && !disabled ? .45 : 1, whiteSpace: 'nowrap', transition: 'var(--t-hover)' }">
|
|
24
|
-
<Icon v-if="o.icon" :name="o.icon" :size="16" :style="tierIcon" /><template v-if="!iconOnly">{{ o.label }}
|
|
25
|
+
<Icon v-if="o.icon" :name="o.icon" :size="16" :style="tierIcon" /><template v-if="!iconOnly">{{ o.label }}<CountBadge :count="o.count" :selected="modelValue === o.label" :show-zero="o.showZero ?? showZero" /></template>
|
|
25
26
|
</button>
|
|
26
27
|
</div>
|
|
27
28
|
</template>
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
/** Underline tabs (Import/Export). Active: blue text + 2px blue underline on hairline baseline. The bar is the region's bar height (sm 32 · md 40 · lg 44). */
|
|
2
|
+
/** Underline tabs (Import/Export). Active: blue text + 2px blue underline on hairline baseline. The bar is the region's bar height (sm 32 · md 40 · lg 44). A tab's count renders as a neutral CountBadge right of the label. */
|
|
3
|
+
export interface TabItem { label: string; /** Count badge 6px right of the label; hidden when 0 or missing unless showZero. */ count?: number; showZero?: boolean; }
|
|
3
4
|
export interface TabsProps {
|
|
4
|
-
|
|
5
|
+
/** Plain labels or { label, count?, showZero? }. Active is matched by label. */
|
|
6
|
+
tabs?: (string | TabItem)[];
|
|
7
|
+
/** Show a muted 0 badge for zero counts on every tab (a tab's own showZero wins). */
|
|
8
|
+
showZero?: boolean;
|
|
5
9
|
active?: string;
|
|
6
10
|
onChange?: (tab: string) => void;
|
|
7
11
|
/** Size tier. Omit to inherit the enclosing region's tier (the default). */
|
|
@@ -11,5 +15,11 @@ export interface TabsProps {
|
|
|
11
15
|
export declare function Tabs(props: TabsProps): JSX.Element;
|
|
12
16
|
/** Lighter underline tabs for panels (Node Info / Calc Rules / ...), regular weight; an sm region gives the compact 32px/12px look. */
|
|
13
17
|
export declare function PanelTabs(props: TabsProps): JSX.Element;
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
18
|
+
/** @deprecated use TabItem */
|
|
19
|
+
export type PillTab = TabItem;
|
|
20
|
+
export type PillTabsProps = TabsProps;
|
|
21
|
+
/** Text pill filter tabs at control height; active gets a tinted pill. A tab's count renders as a CountBadge 6px right of the label. */
|
|
22
|
+
export declare function PillTabs(props: PillTabsProps): JSX.Element;
|
|
23
|
+
/** Count badge beside a segment / pill label: --count-h tall (sm 16 · md 18 · lg 20), 6px sides, full radius, 12px tabular figures. Fill --neutral-alpha-8 on any surface; text --text-body, --text-heading when selected. Renders nothing for 0 or undefined unless showZero (muted 0). */
|
|
24
|
+
export interface CountBadgeProps { count?: number; selected?: boolean; showZero?: boolean; style?: React.CSSProperties; }
|
|
25
|
+
export declare function CountBadge(props: CountBadgeProps): JSX.Element | null;
|
package/components/tabs/Tabs.jsx
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/* tabs: string[] | { label, count?, showZero? }[] — shared by Tabs, PanelTabs and PillTabs; active is matched by label. */
|
|
4
|
+
const norm = (tabs) => tabs.map((t) => (typeof t === "string" ? { label: t } : t));
|
|
5
|
+
|
|
6
|
+
/** Underline tabs on a hairline baseline (Import/Export). Active = blue text + 2px blue underline. The bar is the region's bar height (sm 32 · md 40 · lg 44) with the tier's control text; omit `size` to inherit.
|
|
7
|
+
* A tab's count renders as a CountBadge 6px right of the label (the badge stays neutral under the blue active tab). */
|
|
8
|
+
export function Tabs({ tabs = [], active, onChange, size, showZero, style }) {
|
|
5
9
|
return (
|
|
6
10
|
<div role="tablist" data-size={size} style={{ display: "flex", gap: 4, borderBottom: "1px solid var(--border-control)", ...style }}>
|
|
7
|
-
{tabs.map((t) => {
|
|
8
|
-
const is = t === active;
|
|
11
|
+
{norm(tabs).map((t) => {
|
|
12
|
+
const is = t.label === active;
|
|
9
13
|
return (
|
|
10
|
-
<button key={t} type="button" role="tab" aria-selected={is} data-ink={is ? undefined : ""} onClick={() => onChange && onChange(t)}
|
|
11
|
-
style={{ background: "transparent", border: "none", cursor: "pointer", height: "var(--bar-h)", boxSizing: "border-box", padding: "0 var(--control-px)", marginBottom: -1, borderBottom: is ? "2px solid var(--action-primary)" : "2px solid transparent", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--action-primary)" : "var(--ink, var(--text-body))", whiteSpace: "nowrap" }}>
|
|
12
|
-
{t}
|
|
14
|
+
<button key={t.label} type="button" role="tab" aria-selected={is} data-ink={is ? undefined : ""} onClick={() => onChange && onChange(t.label)}
|
|
15
|
+
style={{ background: "transparent", border: "none", cursor: "pointer", height: "var(--bar-h)", boxSizing: "border-box", padding: "0 var(--control-px)", marginBottom: -1, borderBottom: is ? "2px solid var(--action-primary)" : "2px solid transparent", display: "inline-flex", alignItems: "center", gap: 6, fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--action-primary)" : "var(--ink, var(--text-body))", whiteSpace: "nowrap" }}>
|
|
16
|
+
{t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />
|
|
13
17
|
</button>
|
|
14
18
|
);
|
|
15
19
|
})}
|
|
@@ -18,15 +22,15 @@ export function Tabs({ tabs = [], active, onChange, size, style }) {
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
/** Panel tabs (Node Info · Calc Rules · Output Fields · SQL Preview): a lighter underline tab bar at the region's bar height with regular-weight control text. Put the panel in an sm region for the compact 32px/12px look. */
|
|
21
|
-
export function PanelTabs({ tabs = [], active, onChange, size, style }) {
|
|
25
|
+
export function PanelTabs({ tabs = [], active, onChange, size, showZero, style }) {
|
|
22
26
|
return (
|
|
23
27
|
<div role="tablist" data-size={size} style={{ display: "flex", borderBottom: "1px solid var(--border-subtle)", ...style }}>
|
|
24
|
-
{tabs.map((t) => {
|
|
25
|
-
const is = t === active;
|
|
28
|
+
{norm(tabs).map((t) => {
|
|
29
|
+
const is = t.label === active;
|
|
26
30
|
return (
|
|
27
|
-
<button key={t} type="button" role="tab" aria-selected={is} data-ink={is ? undefined : ""} onClick={() => onChange && onChange(t)}
|
|
28
|
-
style={{ background: "transparent", border: "none", cursor: "pointer", height: "var(--bar-h)", boxSizing: "border-box", padding: "0 var(--control-px)", marginBottom: -1, borderBottom: is ? "2px solid var(--action-primary)" : "2px solid transparent", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--action-primary)" : "var(--ink, var(--text-secondary))", whiteSpace: "nowrap" }}>
|
|
29
|
-
{t}
|
|
31
|
+
<button key={t.label} type="button" role="tab" aria-selected={is} data-ink={is ? undefined : ""} onClick={() => onChange && onChange(t.label)}
|
|
32
|
+
style={{ background: "transparent", border: "none", cursor: "pointer", height: "var(--bar-h)", boxSizing: "border-box", padding: "0 var(--control-px)", marginBottom: -1, borderBottom: is ? "2px solid var(--action-primary)" : "2px solid transparent", display: "inline-flex", alignItems: "center", gap: 6, fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--action-primary)" : "var(--ink, var(--text-secondary))", whiteSpace: "nowrap" }}>
|
|
33
|
+
{t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />
|
|
30
34
|
</button>
|
|
31
35
|
);
|
|
32
36
|
})}
|
|
@@ -34,16 +38,27 @@ export function PanelTabs({ tabs = [], active, onChange, size, style }) {
|
|
|
34
38
|
);
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
|
|
42
|
+
/* Count badge beside a segment / pill label: --count-h tall (sm 16 · md 18 · lg 20), 6px sides, full radius, 12px tabular figures
|
|
43
|
+
(digits are Latin, so the size is not bumped in CJK). The fill is the alpha wash --neutral-alpha-8, so it darkens whatever it sits on by the
|
|
44
|
+
same step (white tab, #f5f5f5 active pill, dark theme); the text tells selected from unselected: --text-heading vs --text-body
|
|
45
|
+
(a lighter grey fails 4.5:1 on a grey fill at 12px). Hidden for 0 or a missing count unless showZero, which draws a muted 0. */
|
|
46
|
+
export function CountBadge({ count, selected, showZero, style }) {
|
|
47
|
+
const zero = count === 0 || count == null;
|
|
48
|
+
if (zero && !showZero) return null;
|
|
49
|
+
return <span aria-hidden="true" style={{ display: "inline-flex", alignItems: "center", height: "var(--count-h)", padding: "0 6px", borderRadius: "var(--radius-full)", background: "var(--neutral-alpha-8)", color: zero ? "var(--text-muted)" : selected ? "var(--text-heading)" : "var(--text-body)", fontFamily: "var(--font-sans)", fontSize: 12, lineHeight: "var(--count-h)", fontWeight: "var(--weight-medium)", letterSpacing: 0, fontVariantNumeric: "tabular-nums", flexShrink: 0, ...style }}>{zero ? 0 : count}</span>;
|
|
50
|
+
}
|
|
51
|
+
/** Text pill filter tabs (For Review · To be Submitted · For Approval). Active = tinted pill. Pills are control-height (sm 28 · md 32 · lg 40); omit `size` to inherit.
|
|
52
|
+
* tabs: string[] | { label, count?, showZero? }[] — a count draws a CountBadge 6px right of the label; active is matched by label. */
|
|
53
|
+
export function PillTabs({ tabs = [], active, onChange, size, showZero, style }) {
|
|
39
54
|
return (
|
|
40
55
|
<div role="tablist" data-size={size} style={{ display: "flex", gap: "var(--control-gap)", ...style }}>
|
|
41
|
-
{tabs.map((t) => {
|
|
42
|
-
const is = t === active;
|
|
56
|
+
{norm(tabs).map((t) => {
|
|
57
|
+
const is = t.label === active;
|
|
43
58
|
return (
|
|
44
|
-
<button key={t} type="button" role="tab" aria-selected={is} data-ink={is ? undefined : ""} data-wash={is ? undefined : ""} onClick={() => onChange && onChange(t)}
|
|
45
|
-
style={{ background: is ? "var(--bg-active)" : "var(--wash, transparent)", border: "none", cursor: "pointer", height: "var(--control-h)", padding: "0 var(--control-px)", borderRadius: "var(--radius-md)", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--text-heading)" : "var(--ink, var(--text-secondary))", whiteSpace: "nowrap" }}>
|
|
46
|
-
{t}
|
|
59
|
+
<button key={t.label} type="button" role="tab" aria-selected={is} data-ink={is ? undefined : ""} data-wash={is ? undefined : ""} onClick={() => onChange && onChange(t.label)}
|
|
60
|
+
style={{ background: is ? "var(--bg-active)" : "var(--wash, transparent)", border: "none", cursor: "pointer", height: "var(--control-h)", padding: "0 var(--control-px)", borderRadius: "var(--radius-md)", display: "inline-flex", alignItems: "center", gap: 6, fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: is ? "var(--text-heading)" : "var(--ink, var(--text-secondary))", whiteSpace: "nowrap" }}>
|
|
61
|
+
{t.label}<CountBadge count={t.count} selected={is} showZero={t.showZero ?? showZero} />
|
|
47
62
|
</button>
|
|
48
63
|
);
|
|
49
64
|
})}
|
package/components/tabs/Tabs.vue
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import CountBadge from './CountBadge.vue'
|
|
2
4
|
// Underline tabs on a hairline baseline (Import/Export). Active = blue text + 2px blue underline. The bar is the region's bar height (sm 32 · md 40 · lg 44) with the tier's control text; omit `size` to inherit.
|
|
3
|
-
|
|
5
|
+
// tabs: string[] | { label, count?, showZero? }[] — a count renders as a CountBadge 6px right of the label (neutral under the blue active tab); active is matched by label.
|
|
6
|
+
const props = defineProps({ tabs: { type: Array, default: () => [] }, active: String, size: String, showZero: Boolean, style: Object })
|
|
4
7
|
const emit = defineEmits(['change'])
|
|
8
|
+
const items = computed(() => props.tabs.map(t => typeof t === 'string' ? { label: t } : t))
|
|
5
9
|
</script>
|
|
6
10
|
<template>
|
|
7
11
|
<div role="tablist" :data-size="size" :style="{ display: 'flex', gap: '4px', borderBottom: '1px solid var(--border-control)', ...style }">
|
|
8
|
-
<button v-for="t in
|
|
9
|
-
:style="{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t === active ? '2px solid var(--action-primary)' : '2px solid transparent', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t === active ? 'var(--action-primary)' : 'var(--ink, var(--text-body))', whiteSpace: 'nowrap' }">{{ t }}
|
|
12
|
+
<button v-for="t in items" :key="t.label" type="button" role="tab" :aria-selected="t.label === active" :data-ink="t.label === active ? undefined : ''" @click="emit('change', t.label)"
|
|
13
|
+
:style="{ background: 'transparent', border: 'none', cursor: 'pointer', height: 'var(--bar-h)', boxSizing: 'border-box', padding: '0 var(--control-px)', marginBottom: '-1px', borderBottom: t.label === active ? '2px solid var(--action-primary)' : '2px solid transparent', display: 'inline-flex', alignItems: 'center', gap: '6px', fontFamily: 'var(--font-sans)', fontWeight: 'var(--weight-medium)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: t.label === active ? 'var(--action-primary)' : 'var(--ink, var(--text-body))', whiteSpace: 'nowrap' }">{{ t.label }}<CountBadge :count="t.count" :selected="t.label === active" :show-zero="t.showZero ?? showZero" /></button>
|
|
10
14
|
</div>
|
|
11
15
|
</template>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Tab systems: `Tabs` (underline — page level), `PanelTabs` (lighter underline — config panel, put the panel in an sm region), `PillTabs` (pill filters), `ViewToggle` (list/grid icons). All read the region's size tier: bars are 32 / 40 / 44px, pills 24 / 32 / 36px.
|
|
2
2
|
|
|
3
3
|
```jsx
|
|
4
|
-
<Tabs tabs={["Import","Export"]} active={tab} onChange={setTab}/>
|
|
4
|
+
<Tabs tabs={[{label:"Import",count:3},"Export"]} active={tab} onChange={setTab}/> // any tab kind takes { label, count }
|
|
5
5
|
<PanelTabs tabs={["Node Info","Calc Rules","Output Fields","SQL Preview"]} active={t} onChange={setT}/>
|
|
6
|
-
<PillTabs tabs={["For Review","To be Submitted","For Approval"]} active={p} onChange={setP}/>
|
|
6
|
+
<PillTabs tabs={[{label:"For Review",count:8},{label:"To be Submitted",count:3},"For Approval"]} active={p} onChange={setP}/> // count → badge right of the label; 0 hidden unless showZero
|
|
7
7
|
<ViewToggle value={view} onChange={setView}/>
|
|
8
8
|
```
|
|
9
9
|
|