@keyfunc/keybricks-ds 2.3.1 → 2.4.2
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 +11 -5
- package/DESIGN.md +50 -1
- package/INTEGRATION.md +33 -6
- package/_adherence.oxlintrc.json +35 -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/relative-time-picker.card.html +45 -0
- package/components/_cards/segmented-control.card.html +9 -3
- package/components/_cards/smart-table.card.html +4 -1
- package/components/_cards/tabs.card.html +5 -2
- package/components/_shared/src-data.js +1 -1
- package/components/_shared/src-feedback.js +1 -1
- package/components/_shared/src-forms.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 +5 -1
- package/components/data/SmartTable.d.ts +7 -2
- package/components/data/SmartTable.jsx +12 -4
- package/components/data/SmartTable.vue +12 -4
- 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/forms/RelativeTimePicker.d.ts +39 -0
- package/components/forms/RelativeTimePicker.jsx +89 -0
- package/components/forms/RelativeTimePicker.prompt.md +72 -0
- package/components/forms/RelativeTimePicker.vue +81 -0
- package/components/forms/forms.prompt.md +3 -0
- package/components/forms/relative-time.d.ts +12 -0
- package/components/forms/relative-time.js +90 -0
- package/components/index.d.ts +2 -0
- package/components/index.js +5 -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 +6 -5
- package/readme.zh-CN.md +4 -3
- 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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import Icon from '../icons/Icon.vue'
|
|
4
|
+
import Input from './Input.vue'
|
|
5
|
+
import Select from './Select.vue'
|
|
6
|
+
import Field from './Field.vue'
|
|
7
|
+
import { RELATIVE_TIME_LABELS, RELATIVE_TIME_UNITS, changeRelativeAmount, changeRelativeUnit, normalizeRelativeTime, relativeTimePosition, relativeTimeText, relativeUnitLabel, resolveRelativeTime } from './relative-time.js'
|
|
8
|
+
// Relative-time filter ("1 hour ago", "any time") for log, monitoring and inbox pages: a field-shaped trigger that opens a small non-modal dialog
|
|
9
|
+
// with an amount input and a unit select, plus a live "From" preview. v-model = { amount, unit }; @change(value, date) — unlimited resolves to null.
|
|
10
|
+
// Tier: the trigger is a field (sm 28 · md 32 · lg 40 — omit `size` to inherit the region's tier); the dialog teleports to <body> and copies the trigger's
|
|
11
|
+
// tier, language and theme so it keeps the region's density. Styling is inline from tokens like every other control; hover comes from data-field.
|
|
12
|
+
// The amount is a plain numeric text field (no native spinners); unit labels may be { one, other } and follow Intl.PluralRules of `locale`.
|
|
13
|
+
const props = defineProps({ modelValue: { type: Object, default: () => ({ amount: 1, unit: 'Hour' }) }, allowUnlimited: { type: Boolean, default: true }, disabled: Boolean, size: String, labels: Object, locale: String, referenceDate: Date, formatDate: Function, formatValue: Function, style: Object })
|
|
14
|
+
const emit = defineEmits(['update:modelValue', 'change'])
|
|
15
|
+
const trigger = ref(null), panel = ref(null), open = ref(false), position = ref({}), now = ref(new Date()), lang = ref(undefined), theme = ref(undefined)
|
|
16
|
+
const value = computed(() => normalizeRelativeTime(props.modelValue))
|
|
17
|
+
const text = computed(() => ({ ...RELATIVE_TIME_LABELS, ...props.labels }))
|
|
18
|
+
const units = computed(() => props.allowUnlimited ? ['None', ...RELATIVE_TIME_UNITS] : RELATIVE_TIME_UNITS)
|
|
19
|
+
const unitLabel = (unit) => unit === 'None' ? text.value.unlimited : relativeUnitLabel(text.value, unit, undefined, props.locale)
|
|
20
|
+
const options = computed(() => units.value.map(unitLabel))
|
|
21
|
+
const summary = computed(() => relativeTimeText(value.value, text.value, props.formatValue, props.locale))
|
|
22
|
+
const date = computed(() => resolveRelativeTime(value.value, props.referenceDate || now.value))
|
|
23
|
+
const preview = computed(() => !date.value ? text.value.unlimited : props.formatDate ? props.formatDate(date.value) : new Intl.DateTimeFormat(props.locale, { dateStyle: 'medium', timeStyle: 'medium' }).format(date.value))
|
|
24
|
+
const glyph = { width: 'var(--control-icon)', height: 'var(--control-icon)', color: 'var(--text-secondary)', flexShrink: 0 }
|
|
25
|
+
const triggerStyle = computed(() => ({ display: 'flex', alignItems: 'center', gap: 'var(--control-gap)', height: 'var(--control-h)', width: '100%', padding: '0 var(--control-px)', borderRadius: 'var(--radius-md)',
|
|
26
|
+
border: `1px solid ${open.value ? 'var(--focus-border)' : 'var(--field-border, var(--border-control))'}`, background: props.disabled ? 'var(--bg-inset)' : 'var(--bg-surface)', color: 'var(--text-primary)',
|
|
27
|
+
fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', textAlign: 'start',
|
|
28
|
+
cursor: props.disabled ? 'not-allowed' : 'pointer', opacity: props.disabled ? .5 : 1, transition: 'var(--t-hover)', boxSizing: 'border-box', outline: 'none' }))
|
|
29
|
+
const panelStyle = computed(() => ({ position: 'fixed', zIndex: 'var(--z-menu)', maxHeight: 'calc(100vh - 16px)', overflowY: 'auto', padding: 'var(--edge-pad)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)',
|
|
30
|
+
background: 'var(--bg-raised)', boxShadow: 'var(--shadow-menu)', color: 'var(--text-body)', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', boxSizing: 'border-box', ...position.value }))
|
|
31
|
+
function update(next) {
|
|
32
|
+
now.value = new Date()
|
|
33
|
+
if (next.unit !== 'None' && !resolveRelativeTime(next, props.referenceDate || now.value)) return
|
|
34
|
+
emit('update:modelValue', next); emit('change', next, resolveRelativeTime(next, props.referenceDate || now.value))
|
|
35
|
+
}
|
|
36
|
+
function updateAmount(raw) { const digits = String(raw).replace(/\D/g, ''); if (digits !== '' || raw === '') update(changeRelativeAmount(value.value, digits)) }
|
|
37
|
+
function updateUnit(label) { const i = options.value.indexOf(label); if (i >= 0) update(changeRelativeUnit(value.value, units.value[i])) }
|
|
38
|
+
function place() { if (trigger.value) position.value = relativeTimePosition(trigger.value, panel.value) }
|
|
39
|
+
function close(restoreFocus = false) { open.value = false; if (restoreFocus) trigger.value?.focus() }
|
|
40
|
+
async function toggle() {
|
|
41
|
+
if (props.disabled) return
|
|
42
|
+
if (open.value) return close()
|
|
43
|
+
now.value = new Date()
|
|
44
|
+
lang.value = trigger.value.closest('[lang]')?.lang
|
|
45
|
+
const themeRoot = trigger.value.closest('[data-theme]'); theme.value = themeRoot === document.documentElement ? undefined : themeRoot?.dataset.theme
|
|
46
|
+
place(); open.value = true
|
|
47
|
+
await nextTick(); place(); panel.value?.querySelector('input')?.focus()
|
|
48
|
+
}
|
|
49
|
+
function outside(e) { if (!trigger.value?.contains(e.target) && !panel.value?.contains(e.target)) close() }
|
|
50
|
+
function keydown(e) { if (e.key === 'Escape') { e.preventDefault(); close(true) } }
|
|
51
|
+
function focusout() { nextTick(() => { if (!trigger.value?.contains(document.activeElement) && !panel.value?.contains(document.activeElement)) close() }) }
|
|
52
|
+
function listen(add) {
|
|
53
|
+
const m = add ? 'addEventListener' : 'removeEventListener'
|
|
54
|
+
document[m]('pointerdown', outside, true); document[m]('keydown', keydown); document[m]('focusin', focusout); window[m]('resize', place); window[m]('scroll', place, true)
|
|
55
|
+
}
|
|
56
|
+
watch(open, listen)
|
|
57
|
+
watch(() => props.disabled, (d) => { if (d) close() })
|
|
58
|
+
onBeforeUnmount(() => listen(false))
|
|
59
|
+
</script>
|
|
60
|
+
<template>
|
|
61
|
+
<div :data-size="size" :style="{ display: 'inline-flex', width: '100%', minWidth: 0, ...style }">
|
|
62
|
+
<button ref="trigger" type="button" :data-field="disabled ? undefined : ''" :disabled="disabled" aria-haspopup="dialog" :aria-expanded="open" :aria-label="`${text.title}: ${summary}`" :style="triggerStyle" @click="toggle">
|
|
63
|
+
<Icon name="clock" :size="16" :style="glyph" />
|
|
64
|
+
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ summary }}</span>
|
|
65
|
+
<Icon name="chevron-down" :size="16" :style="{ ...glyph, color: 'var(--text-muted)' }" />
|
|
66
|
+
</button>
|
|
67
|
+
<Teleport to="body">
|
|
68
|
+
<div v-if="open" ref="panel" role="dialog" :aria-label="text.title" :lang="lang" :data-theme="theme" :style="panelStyle">
|
|
69
|
+
<h3 style="margin:0 0 var(--control-gap);color:var(--text-heading);font-size:var(--control-text);line-height:var(--control-leading);font-weight:var(--weight-medium)">{{ text.title }}</h3>
|
|
70
|
+
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:var(--group-gap)">
|
|
71
|
+
<Field :label="text.amount"><Input inputmode="numeric" pattern="[0-9]*" :model-value="String(value.amount)" :aria-label="text.amount" @update:model-value="updateAmount" /></Field>
|
|
72
|
+
<Field :label="text.unit"><Select :options="options" :model-value="unitLabel(value.unit)" @update:model-value="updateUnit" /></Field>
|
|
73
|
+
</div>
|
|
74
|
+
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:var(--control-gap);margin-top:var(--group-gap);padding-top:var(--control-gap);border-top:1px solid var(--border-subtle);color:var(--text-secondary)">
|
|
75
|
+
<span>{{ text.preview }}</span>
|
|
76
|
+
<span style="color:var(--text-body);font-variant-numeric:tabular-nums;overflow-wrap:anywhere">{{ preview }}</span>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</Teleport>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
@@ -24,3 +24,6 @@ Everything is 12px text in 36px controls; readOnly inputs go #fafafa; disabled =
|
|
|
24
24
|
<RadioGroup options={["New","Overwrite","Skip"]} value={v} onChange={setV}/>
|
|
25
25
|
<RadioGroup variant="cards" row value={m} onChange={setM} options={[{label:"Merge",value:"merge",description:"Keep target values, add new keys"},{label:"Overwrite",value:"overwrite",description:"Target takes source values"}]}/>
|
|
26
26
|
```
|
|
27
|
+
|
|
28
|
+
## RelativeTimePicker
|
|
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)`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RelativeTimeLabels, RelativeTimeUnit, RelativeTimeValue } from './RelativeTimePicker';
|
|
2
|
+
|
|
3
|
+
export declare const RELATIVE_TIME_UNITS: Exclude<RelativeTimeUnit, 'None'>[];
|
|
4
|
+
export declare const RELATIVE_TIME_LABELS: RelativeTimeLabels;
|
|
5
|
+
export declare function normalizeRelativeTime(value?: { amount?: unknown; unit?: unknown }): RelativeTimeValue;
|
|
6
|
+
export declare function changeRelativeAmount(value: RelativeTimeValue, amount: number | string): RelativeTimeValue;
|
|
7
|
+
export declare function changeRelativeUnit(value: RelativeTimeValue, unit: RelativeTimeUnit): RelativeTimeValue;
|
|
8
|
+
export declare function resolveRelativeTime(value: RelativeTimeValue, referenceDate?: Date): Date | null;
|
|
9
|
+
export declare function relativeTimeText(value: RelativeTimeValue, labels: RelativeTimeLabels, formatValue?: (value: RelativeTimeValue) => string, locale?: string): string;
|
|
10
|
+
/** Unit label for an amount (Intl.PluralRules of locale); without amount, the generic form for the unit select. */
|
|
11
|
+
export declare function relativeUnitLabel(labels: RelativeTimeLabels, unit: RelativeTimeUnit, amount?: number, locale?: string): string;
|
|
12
|
+
export declare function relativeTimePosition(trigger: HTMLElement, panel?: HTMLElement | null): Record<string, string>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Framework-neutral relative-time values. No application store, translations or date library.
|
|
2
|
+
export const RELATIVE_TIME_UNITS = ['Minute', 'Hour', 'Day', 'Week', 'Month'];
|
|
3
|
+
// A unit label is a string ("小时前" — the same for every amount) or { one, other } for languages that inflect ("1 hour ago" / "2 hours ago").
|
|
4
|
+
export const RELATIVE_TIME_LABELS = {
|
|
5
|
+
title: 'Relative time', amount: 'Amount', unit: 'Unit', preview: 'From', unlimited: 'Any time',
|
|
6
|
+
Minute: { one: 'minute ago', other: 'minutes ago' }, Hour: { one: 'hour ago', other: 'hours ago' },
|
|
7
|
+
Day: { one: 'day ago', other: 'days ago' }, Week: { one: 'week ago', other: 'weeks ago' }, Month: { one: 'month ago', other: 'months ago' },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** The label of a unit for a given amount (plural rules of `locale`); omit amount for the generic form shown in the unit select. */
|
|
11
|
+
export function relativeUnitLabel(labels, unit, amount, locale) {
|
|
12
|
+
const l = labels[unit];
|
|
13
|
+
if (l == null || typeof l === 'string') return l;
|
|
14
|
+
if (amount == null) return l.other;
|
|
15
|
+
let rule = 'other';
|
|
16
|
+
try { rule = new Intl.PluralRules(locale).select(amount); } catch (e) { rule = amount === 1 ? 'one' : 'other'; }
|
|
17
|
+
return l[rule] || l.other || l.one;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeRelativeTime(value) {
|
|
21
|
+
const amount = Math.floor(Number(value?.amount));
|
|
22
|
+
const unit = RELATIVE_TIME_UNITS.includes(value?.unit) ? value.unit : 'None';
|
|
23
|
+
return Number.isFinite(amount) && amount > 0 && unit !== 'None'
|
|
24
|
+
? { amount, unit }
|
|
25
|
+
: { amount: 0, unit: 'None' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function changeRelativeAmount(value, amount) {
|
|
29
|
+
return normalizeRelativeTime({ amount, unit: value.unit === 'None' ? 'Hour' : value.unit });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function changeRelativeUnit(value, unit) {
|
|
33
|
+
return normalizeRelativeTime({ amount: unit === 'None' ? 0 : value.amount || 1, unit });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Unlimited is null; consumers decide whether their backend needs an epoch sentinel. */
|
|
37
|
+
export function resolveRelativeTime(value, referenceDate = new Date()) {
|
|
38
|
+
const { amount, unit } = normalizeRelativeTime(value);
|
|
39
|
+
if (!amount) return null;
|
|
40
|
+
const date = new Date(referenceDate);
|
|
41
|
+
if (!Number.isFinite(date.getTime())) return null;
|
|
42
|
+
if (unit === 'Minute' || unit === 'Hour') {
|
|
43
|
+
date.setTime(date.getTime() - amount * (unit === 'Minute' ? 60000 : 3600000));
|
|
44
|
+
} else if (unit === 'Month') {
|
|
45
|
+
// Match calendar-month subtraction: March 31 minus one month is February's last day.
|
|
46
|
+
const day = date.getDate();
|
|
47
|
+
date.setDate(1);
|
|
48
|
+
date.setMonth(date.getMonth() - amount);
|
|
49
|
+
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
|
50
|
+
date.setDate(Math.min(day, lastDay));
|
|
51
|
+
} else {
|
|
52
|
+
// Calendar days/weeks preserve local wall time across daylight-saving boundaries.
|
|
53
|
+
date.setDate(date.getDate() - amount * (unit === 'Week' ? 7 : 1));
|
|
54
|
+
}
|
|
55
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function relativeTimeText(value, labels, formatValue, locale) {
|
|
59
|
+
return formatValue ? formatValue(value) : value.unit === 'None'
|
|
60
|
+
? labels.unlimited : `${value.amount} ${relativeUnitLabel(labels, value.unit, value.amount, locale)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fixed, viewport-clamped placement; retain the trigger's inherited density and language. */
|
|
64
|
+
export function relativeTimePosition(trigger, panel) {
|
|
65
|
+
const rect = trigger.getBoundingClientRect();
|
|
66
|
+
const computed = getComputedStyle(trigger);
|
|
67
|
+
// 弹层挂载到 body 后,需要高于触发器所在的抽屉、对话框等层叠上下文。
|
|
68
|
+
let zIndex = Number.parseFloat(computed.getPropertyValue('--z-menu')) || 0;
|
|
69
|
+
for (let element = trigger; element; element = element.parentElement) {
|
|
70
|
+
const ancestorZIndex = Number.parseFloat(getComputedStyle(element).zIndex);
|
|
71
|
+
if (Number.isFinite(ancestorZIndex)) zIndex = Math.max(zIndex, ancestorZIndex + 1);
|
|
72
|
+
}
|
|
73
|
+
const width = Math.min(336, window.innerWidth - 16);
|
|
74
|
+
const height = panel?.offsetHeight || 200;
|
|
75
|
+
const below = rect.bottom + 4;
|
|
76
|
+
const top = below + height <= window.innerHeight - 8
|
|
77
|
+
? below : Math.max(8, rect.top - height - 4);
|
|
78
|
+
const style = {
|
|
79
|
+
zIndex: String(zIndex),
|
|
80
|
+
width: `${width}px`,
|
|
81
|
+
left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
|
|
82
|
+
top: `${top}px`,
|
|
83
|
+
};
|
|
84
|
+
for (const name of computed) {
|
|
85
|
+
if (/^--(control-|font-|weight-|leading-|tracking-|group-gap|edge-pad)/.test(name)) {
|
|
86
|
+
style[name] = computed.getPropertyValue(name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return style;
|
|
90
|
+
}
|
package/components/index.d.ts
CHANGED
|
@@ -65,6 +65,7 @@ export * from "./forms/Input";
|
|
|
65
65
|
export * from "./forms/MultiSelect";
|
|
66
66
|
export * from "./forms/NumberStepper";
|
|
67
67
|
export * from "./forms/Radio";
|
|
68
|
+
export * from "./forms/RelativeTimePicker";
|
|
68
69
|
export * from "./forms/SearchInput";
|
|
69
70
|
export * from "./forms/Select";
|
|
70
71
|
export * from "./forms/Slider";
|
|
@@ -73,6 +74,7 @@ export * from "./icons/Icon";
|
|
|
73
74
|
export * from "./icons/Unicon";
|
|
74
75
|
export * from "./navigation/Breadcrumb";
|
|
75
76
|
export * from "./navigation/DocTree";
|
|
77
|
+
export * from "./navigation/SidePanel";
|
|
76
78
|
export * from "./navigation/Sidebar";
|
|
77
79
|
export * from "./navigation/TopBar";
|
|
78
80
|
export * from "./navigation/Tree";
|
package/components/index.js
CHANGED
|
@@ -80,6 +80,7 @@ export { Input, Textarea } from "./forms/Input.jsx";
|
|
|
80
80
|
export { MultiSelect } from "./forms/MultiSelect.jsx";
|
|
81
81
|
export { NumberStepper } from "./forms/NumberStepper.jsx";
|
|
82
82
|
export { Radio, RadioGroup } from "./forms/Radio.jsx";
|
|
83
|
+
export { RelativeTimePicker } from "./forms/RelativeTimePicker.jsx";
|
|
83
84
|
export { SearchInput } from "./forms/SearchInput.jsx";
|
|
84
85
|
export { Select } from "./forms/Select.jsx";
|
|
85
86
|
export { Slider } from "./forms/Slider.jsx";
|
|
@@ -92,8 +93,9 @@ export { Unicon } from "./icons/Unicon.jsx";
|
|
|
92
93
|
/* navigation/ */
|
|
93
94
|
export { Breadcrumb, ThemeToggle, applyStoredTheme } from "./navigation/Breadcrumb.jsx";
|
|
94
95
|
export { DocTree } from "./navigation/DocTree.jsx";
|
|
95
|
-
export {
|
|
96
|
-
export {
|
|
96
|
+
export { SidePanel } from "./navigation/SidePanel.jsx";
|
|
97
|
+
export { KEYBRICKS_MGMT, KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER, SIDEBAR_COLORS, Sidebar } from "./navigation/Sidebar.jsx";
|
|
98
|
+
export { PageHeading, PageToolbar, THEME_OPTIONS, TopBar, UserMenu } from "./navigation/TopBar.jsx";
|
|
97
99
|
export { Tree } from "./navigation/Tree.jsx";
|
|
98
100
|
|
|
99
101
|
/* pipeline/ */
|
|
@@ -106,5 +108,5 @@ export { ZoomControls } from "./pipeline/ZoomControls.jsx";
|
|
|
106
108
|
|
|
107
109
|
/* tabs/ */
|
|
108
110
|
export { SegmentedControl } from "./tabs/SegmentedControl.jsx";
|
|
109
|
-
export { PanelTabs, PillTabs, Tabs } from "./tabs/Tabs.jsx";
|
|
111
|
+
export { CountBadge, PanelTabs, PillTabs, Tabs } from "./tabs/Tabs.jsx";
|
|
110
112
|
export { ViewToggle } from "./tabs/ViewToggle.jsx";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** Side panel docked beside the Sidebar (Documents, Management): 240px, same tone as the rail, grows open from 0 and fades its content in.
|
|
3
|
+
* 56px header: title + actions + close. The body scrolls. Vue: #actions slot, @close. */
|
|
4
|
+
export interface SidePanelProps {
|
|
5
|
+
open?: boolean;
|
|
6
|
+
title?: React.ReactNode;
|
|
7
|
+
/** Extra header buttons left of close (e.g. a muted IconButton "maximize-2" to open the full page) */
|
|
8
|
+
actions?: React.ReactNode;
|
|
9
|
+
/** Shows the close button. Vue: @close (hide it with :closable="false"). */
|
|
10
|
+
onClose?: () => void;
|
|
11
|
+
closeLabel?: string;
|
|
12
|
+
closeIcon?: string;
|
|
13
|
+
/** Same values as Sidebar — keep the two in step */
|
|
14
|
+
tone?: "recessed" | "flush" | "contrast" | "custom";
|
|
15
|
+
color?: string;
|
|
16
|
+
width?: number;
|
|
17
|
+
/** Size tier of the panel body ("sm" for compact document lists) */
|
|
18
|
+
size?: "sm" | "md" | "lg";
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
style?: React.CSSProperties;
|
|
21
|
+
}
|
|
22
|
+
export declare function SidePanel(props: SidePanelProps): JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { IconButton } from "../buttons/IconButton.jsx";
|
|
3
|
+
import { chromeTone } from "./chrome-tones.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Side panel docked beside the Sidebar (Documents, Management). 240px, same tone as the rail, opens by growing from 0
|
|
7
|
+
* (--dur-slow, --ease-out) while its content fades in from 12px left. 56px header: title + actions + close.
|
|
8
|
+
* Body scrolls; put a SegmentedControl, SearchInput, Tree or rows inside. size="sm" makes its controls compact.
|
|
9
|
+
*/
|
|
10
|
+
export function SidePanel({ open = true, title, actions, onClose, closeLabel = "Close", closeIcon = "x", tone = "recessed", color, width = 240, size, children, style }) {
|
|
11
|
+
const t = chromeTone(tone, color);
|
|
12
|
+
return (
|
|
13
|
+
<div aria-hidden={open ? undefined : "true"} style={{ width: open ? width : 0, flexShrink: 0, overflow: "hidden", height: "100%", transition: "width var(--dur-slow) var(--ease-out)" }}>
|
|
14
|
+
<div data-theme={t.theme} data-size={size} style={{ ...t.vars, width, height: "100%", background: "var(--sb-bg)", borderRight: "1px solid var(--sb-border)", color: "var(--sb-text)", display: "flex", flexDirection: "column", boxSizing: "border-box", opacity: open ? 1 : 0, transform: open ? "none" : "translateX(-12px)", transition: "opacity var(--dur) var(--ease-out), transform var(--dur-slow) var(--ease-out)", pointerEvents: open ? "auto" : "none", ...style }}>
|
|
15
|
+
<div style={{ height: "var(--topbar-h)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "0 8px 0 16px", boxSizing: "border-box", flexShrink: 0 }}>
|
|
16
|
+
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--text-base)", lineHeight: "var(--leading-base)", letterSpacing: "var(--tracking-base)", fontWeight: "var(--weight-medium)", color: "var(--text-heading)" }}>{title}</span>
|
|
17
|
+
<div data-size="md" style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>{actions}{onClose ? <IconButton icon={closeIcon} muted label={closeLabel} title={closeLabel} onClick={onClose} /> : null}</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div style={{ flex: 1, minHeight: 0, overflow: "auto", display: "flex", flexDirection: "column" }}>{children}</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import IconButton from '../buttons/IconButton.vue'
|
|
4
|
+
import { chromeTone } from './chrome-tones.js'
|
|
5
|
+
// Side panel docked beside the Sidebar (Documents, Management). 240px, same tone as the rail; opens by growing from 0 while
|
|
6
|
+
// its content fades in from 12px left. 56px header: title + #actions slot + close (@close). Default slot is the scrolling body.
|
|
7
|
+
const props = defineProps({ open: { type: Boolean, default: true }, title: String, closable: { type: Boolean, default: true }, closeLabel: { type: String, default: 'Close' }, closeIcon: { type: String, default: 'x' }, tone: { type: String, default: 'recessed' }, color: String, width: { type: Number, default: 240 }, size: String, style: Object })
|
|
8
|
+
const emit = defineEmits(['close'])
|
|
9
|
+
const t = computed(() => chromeTone(props.tone, props.color))
|
|
10
|
+
</script>
|
|
11
|
+
<template>
|
|
12
|
+
<div :aria-hidden="open ? undefined : 'true'" :style="{ width: (open ? width : 0) + 'px', flexShrink: 0, overflow: 'hidden', height: '100%', transition: 'width var(--dur-slow) var(--ease-out)' }">
|
|
13
|
+
<div :data-theme="t.theme" :data-size="size" :style="{ ...t.vars, width: width + 'px', height: '100%', background: 'var(--sb-bg)', borderRight: '1px solid var(--sb-border)', color: 'var(--sb-text)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', opacity: open ? 1 : 0, transform: open ? 'none' : 'translateX(-12px)', transition: 'opacity var(--dur) var(--ease-out), transform var(--dur-slow) var(--ease-out)', pointerEvents: open ? 'auto' : 'none', ...style }">
|
|
14
|
+
<div style="height:var(--topbar-h);display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 8px 0 16px;box-sizing:border-box;flex-shrink:0">
|
|
15
|
+
<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;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)">{{ title }}</span>
|
|
16
|
+
<div data-size="md" style="display:flex;align-items:center;gap:2px;flex-shrink:0"><slot name="actions" /><IconButton v-if="closable" :icon="closeIcon" muted :label="closeLabel" :title="closeLabel" @click="emit('close')" /></div>
|
|
17
|
+
</div>
|
|
18
|
+
<div style="flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column"><slot /></div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
/**
|
|
3
|
-
* App sidebar — 240px expanded / 64px icon rail.
|
|
3
|
+
* App sidebar — 240px expanded / 64px icon rail. 56px brand head (no rule), a 2px-gap nav list, pinned footer items and a version line.
|
|
4
|
+
* Tones: "recessed" (grey rail, the active item is a white tile with a hairline ring — the default), "flush" (white rail),
|
|
5
|
+
* "contrast" (the rail scoped to the dark theme), "custom" (dark scope on a solid colour; see SIDEBAR_COLORS).
|
|
4
6
|
* @startingPoint section="Chrome" subtitle="240px app sidebar with icon-rail collapse" viewport="240x600"
|
|
5
7
|
*/
|
|
6
8
|
export interface SidebarItem {
|
|
7
|
-
icon
|
|
9
|
+
icon?: string;
|
|
8
10
|
label: string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/** Draws a chevron on the right: the item opens a SidePanel */
|
|
12
|
+
trailing?: boolean;
|
|
13
|
+
children?: (SidebarItem | string)[];
|
|
12
14
|
}
|
|
13
15
|
export interface SidebarProps {
|
|
16
|
+
/** Default KEYBRICKS_NAV */
|
|
14
17
|
items?: SidebarItem[];
|
|
18
|
+
/** Default KEYBRICKS_NAV_FOOTER (Management, Knowledge Base) */
|
|
15
19
|
footerItems?: SidebarItem[];
|
|
16
20
|
/** Active item label */
|
|
17
21
|
active?: string;
|
|
18
22
|
onSelect?: (label: string) => void;
|
|
19
23
|
collapsed?: boolean;
|
|
20
|
-
/** Renders the
|
|
24
|
+
/** Renders the Collapse / Expand item at the top of the list. Vue: @toggle-collapse (hide it with :show-toggle="false"). */
|
|
21
25
|
onToggleCollapse?: () => void;
|
|
22
26
|
logo?: string;
|
|
23
27
|
brand?: string;
|
|
28
|
+
/** Version line under the footer items ("Version 2.4.2"); the rail shows "v2.4.2" */
|
|
29
|
+
version?: string;
|
|
30
|
+
tone?: "recessed" | "flush" | "contrast" | "custom";
|
|
31
|
+
/** For tone "custom": a SIDEBAR_COLORS key (slate, navy, indigo, violet, plum, wine, forest, teal, umber, charcoal) or any hex */
|
|
32
|
+
color?: string;
|
|
33
|
+
labels?: { collapse?: string; expand?: string };
|
|
24
34
|
height?: number | string;
|
|
25
35
|
style?: React.CSSProperties;
|
|
26
36
|
}
|
|
27
37
|
export declare function Sidebar(props: SidebarProps): JSX.Element;
|
|
28
|
-
/** The product's
|
|
38
|
+
/** The product's main nav (Home … Task Execution); Documents has trailing: true. */
|
|
29
39
|
export declare const KEYBRICKS_NAV: SidebarItem[];
|
|
40
|
+
/** Pinned footer: Management (trailing) and Knowledge Base. */
|
|
41
|
+
export declare const KEYBRICKS_NAV_FOOTER: SidebarItem[];
|
|
42
|
+
/** Groups for the Management side panel. */
|
|
43
|
+
export declare const KEYBRICKS_MGMT: { icon: string; label: string; children?: string[] }[];
|
|
44
|
+
/** The ten custom rail colours. */
|
|
45
|
+
export declare const SIDEBAR_COLORS: Record<"slate" | "navy" | "indigo" | "violet" | "plum" | "wine" | "forest" | "teal" | "umber" | "charcoal", string>;
|
|
@@ -1,56 +1,57 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Icon } from "../icons/Icon.jsx";
|
|
3
|
-
import { KEYBRICKS_NAV } from "./nav-model.js";
|
|
3
|
+
import { KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER, KEYBRICKS_MGMT } from "./nav-model.js";
|
|
4
|
+
import { chromeTone, SIDEBAR_COLORS } from "./chrome-tones.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const LABEL = { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--text-base)", lineHeight: "18px", letterSpacing: "var(--tracking-base)", fontWeight: "var(--weight-medium)" };
|
|
7
|
+
|
|
8
|
+
/* One rail row: 36px min, radius-md, 16px icon, medium label, optional trailing chevron (the item opens a side panel). */
|
|
9
|
+
function NavItem({ icon, label, active, indent, collapsed, trailing, onClick }) {
|
|
6
10
|
const [hover, setHover] = React.useState(false);
|
|
7
11
|
return (
|
|
8
|
-
<div role="button" tabIndex={0} onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
|
9
|
-
style={{
|
|
10
|
-
<Icon name={icon} size={indent ? 14 : 16}
|
|
11
|
-
{!collapsed ? <span style={
|
|
12
|
-
{!collapsed &&
|
|
12
|
+
<div role="button" tabIndex={0} title={collapsed ? label : undefined} aria-current={active ? "page" : undefined} onClick={onClick} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick && onClick(); } }} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
|
13
|
+
style={{ minHeight: indent ? 32 : 36, borderRadius: "var(--radius-md)", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "flex-start", gap: 10, padding: indent ? "6px 10px" : "8px 10px", boxSizing: "border-box", cursor: "pointer", userSelect: "none", background: active ? "var(--sb-active)" : hover ? "var(--sb-hover)" : "transparent", color: active ? "var(--sb-active-text)" : "var(--sb-text)", boxShadow: active ? "var(--sb-shadow)" : "none", transition: "var(--t-hover)" }}>
|
|
14
|
+
{icon ? <span style={{ display: "inline-flex", flexShrink: 0 }}><Icon name={icon} size={indent ? 14 : 16} /></span> : null}
|
|
15
|
+
{!collapsed ? <span style={LABEL}>{label}</span> : null}
|
|
16
|
+
{!collapsed && trailing ? <span style={{ display: "inline-flex", flexShrink: 0, opacity: 0.6 }}><Icon name="chevron-right" size={14} /></span> : null}
|
|
13
17
|
</div>
|
|
14
18
|
);
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* Keybricks app sidebar — 240px
|
|
19
|
-
*
|
|
22
|
+
* Keybricks app sidebar — 240px, 64px icon rail when collapsed. A 56px brand head (no rule), a 2px-gap list, pinned footer items and a version line.
|
|
23
|
+
* tone: "recessed" (grey, default) | "flush" (white) | "contrast" (dark) | "custom" (+ color: a SIDEBAR_COLORS key or hex).
|
|
24
|
+
* items / footerItems: [{ icon, label, trailing?, children? }] — trailing draws a chevron (the item opens a SidePanel); children make a collapsible group.
|
|
20
25
|
*/
|
|
21
|
-
export function Sidebar({ items =
|
|
22
|
-
const
|
|
23
|
-
const
|
|
26
|
+
export function Sidebar({ items = KEYBRICKS_NAV, footerItems = KEYBRICKS_NAV_FOOTER, active, onSelect, collapsed = false, onToggleCollapse, logo = "assets/keybricks-logo.png", brand = "Keybricks", version, tone = "recessed", color, labels, height = "100%", style }) {
|
|
27
|
+
const L = { collapse: "Collapse", expand: "Expand", ...labels };
|
|
28
|
+
const t = chromeTone(tone, color);
|
|
29
|
+
const [open, setOpen] = React.useState(() => items.filter((i) => i.children && i.children.some((c) => (c.label || c) === active)).map((i) => i.label));
|
|
30
|
+
const toggle = (l) => setOpen(open.includes(l) ? open.filter((x) => x !== l) : [...open, l]);
|
|
31
|
+
const pick = (l) => onSelect && onSelect(l);
|
|
32
|
+
const list = (arr) => arr.map((it) => it.children ? (
|
|
33
|
+
<div key={it.label} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
34
|
+
<NavItem icon={it.icon} label={it.label} collapsed={collapsed} trailing={false} active={collapsed && it.children.some((c) => (c.label || c) === active)} onClick={() => (collapsed ? pick(it.label) : toggle(it.label))} />
|
|
35
|
+
{!collapsed && open.includes(it.label) ? <div style={{ marginLeft: 24, display: "flex", flexDirection: "column", gap: 2 }}>{it.children.map((c) => { const lb = c.label || c; return <NavItem key={lb} icon={c.icon} label={lb} indent active={active === lb} onClick={() => pick(lb)} />; })}</div> : null}
|
|
36
|
+
</div>
|
|
37
|
+
) : <NavItem key={it.label} icon={it.icon} label={it.label} collapsed={collapsed} trailing={it.trailing} active={active === it.label} onClick={() => pick(it.label)} />);
|
|
24
38
|
return (
|
|
25
|
-
<aside style={{ width: collapsed ? "var(--rail-w)" : "var(--sidebar-w)", height, background: "var(--bg
|
|
26
|
-
<div style={{ height: "var(--topbar-h)",
|
|
27
|
-
<img src={logo} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
|
28
|
-
{!collapsed ? <span style={{ fontFamily: "var(--font-sans)",
|
|
39
|
+
<aside data-theme={t.theme} style={{ ...t.vars, width: collapsed ? "var(--rail-w)" : "var(--sidebar-w)", height, background: "var(--sb-bg)", borderRight: "1px solid var(--sb-border)", color: "var(--sb-text)", display: "flex", flexDirection: "column", boxSizing: "border-box", flexShrink: 0, transition: "width .15s ease", ...style }}>
|
|
40
|
+
<div style={{ height: "var(--topbar-h)", display: "flex", alignItems: "center", gap: 10, padding: "0 16px", boxSizing: "border-box", justifyContent: collapsed ? "center" : "flex-start", flexShrink: 0 }}>
|
|
41
|
+
<img src={logo} alt="" style={{ width: 24, height: 24, objectFit: "contain", flexShrink: 0 }} />
|
|
42
|
+
{!collapsed ? <span style={{ fontFamily: "var(--font-sans)", fontSize: "var(--text-md)", lineHeight: "var(--leading-md)", letterSpacing: "var(--tracking-md)", fontWeight: "var(--weight-semibold)", color: "var(--sb-brand)", whiteSpace: "nowrap" }}>{brand}</span> : null}
|
|
29
43
|
</div>
|
|
30
|
-
<nav style={{ flex: 1, overflowY: "auto", padding: 8, display: "flex", flexDirection: "column", gap:
|
|
31
|
-
{onToggleCollapse ? <NavItem icon="panel-left"
|
|
32
|
-
{items
|
|
33
|
-
<div key={it.label}>
|
|
34
|
-
<div role="button" tabIndex={0} data-wash="" onClick={() => toggleSection(it.label)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleSection(it.label); } }} style={{ background: "var(--wash, transparent)", transition: "var(--t-hover)", height: "var(--h-subnav-item)", display: "flex", alignItems: "center", gap: 10, padding: collapsed ? "6px 10px" : "6px 10px", justifyContent: collapsed ? "center" : "flex-start", cursor: "pointer", color: "var(--text-body)", borderRadius: "var(--radius-md)", boxSizing: "border-box" }}>
|
|
35
|
-
<Icon name={it.icon} size={16} />
|
|
36
|
-
{!collapsed ? <><span style={{ fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-base)", letterSpacing: "var(--tracking-base)", flex: 1 }}>{it.label}</span><Icon name={openSections.includes(it.label) ? "chevron-up" : "chevron-down"} size={14} /></> : null}
|
|
37
|
-
</div>
|
|
38
|
-
{!collapsed && openSections.includes(it.label) ? (
|
|
39
|
-
<div style={{ marginLeft: 24, display: "flex", flexDirection: "column", gap: 2, paddingTop: 2 }}>
|
|
40
|
-
{it.children.map((c) => <NavItem key={c.label} icon={c.icon} label={c.label} indent active={active === c.label} onClick={() => onSelect && onSelect(c.label)} />)}
|
|
41
|
-
</div>
|
|
42
|
-
) : null}
|
|
43
|
-
</div>
|
|
44
|
-
) : (
|
|
45
|
-
<NavItem key={it.label} icon={it.icon} label={it.label} collapsed={collapsed} active={active === it.label} onClick={() => onSelect && onSelect(it.label)} />
|
|
46
|
-
))}
|
|
44
|
+
<nav style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: 8, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
45
|
+
{onToggleCollapse ? <NavItem icon={collapsed ? "panel-left" : "chevron-left"} label={collapsed ? L.expand : L.collapse} collapsed={collapsed} onClick={onToggleCollapse} /> : null}
|
|
46
|
+
{list(items)}
|
|
47
47
|
</nav>
|
|
48
|
-
<div style={{
|
|
49
|
-
{footerItems
|
|
48
|
+
<div style={{ padding: 8, display: "flex", flexDirection: "column", gap: 2, flexShrink: 0 }}>
|
|
49
|
+
{list(footerItems)}
|
|
50
|
+
{version ? <span title={version} style={{ padding: collapsed ? "8px 0 4px" : "8px 10px 4px", textAlign: collapsed ? "center" : "left", fontFamily: "var(--font-sans)", fontSize: collapsed ? "var(--text-glyph-min)" : "var(--text-xs)", lineHeight: collapsed ? "var(--leading-2xs)" : "var(--leading-xs)", color: "var(--sb-muted)", whiteSpace: "nowrap", overflow: "hidden" }}>{collapsed ? "v" + String(version).split(" ").pop().replace(/^v/i, "") : version}</span> : null}
|
|
50
51
|
</div>
|
|
51
52
|
</aside>
|
|
52
53
|
);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
export { KEYBRICKS_NAV };
|
|
56
|
+
/** Nav models and tones — defined in framework-neutral modules, shared with the Vue entry. */
|
|
57
|
+
export { KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER, KEYBRICKS_MGMT, SIDEBAR_COLORS };
|
|
@@ -1,49 +1,50 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { ref } from 'vue'
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
3
|
import Icon from '../icons/Icon.vue'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const props = defineProps({
|
|
10
|
-
items: { type: Array, default: () => [] },
|
|
11
|
-
footerItems: { type: Array, default: () => [{ icon: 'circle-help', label: 'Help' }, { icon: 'settings', label: 'Settings' }, { icon: 'moon', label: 'Dark Mode' }] },
|
|
12
|
-
active: String, collapsed: { type: Boolean, default: false },
|
|
13
|
-
logo: { type: String, default: 'assets/keybricks-logo.png' }, brand: { type: String, default: 'Keybricks' },
|
|
14
|
-
height: { type: [String, Number], default: '100%' }, style: Object, canCollapse: Boolean,
|
|
15
|
-
})
|
|
4
|
+
import { KEYBRICKS_NAV, KEYBRICKS_NAV_FOOTER } from './nav-model.js'
|
|
5
|
+
import { chromeTone } from './chrome-tones.js'
|
|
6
|
+
// Keybricks app sidebar — 240px, 64px icon rail when collapsed. 56px brand head (no rule), 2px-gap list, pinned footer, version line.
|
|
7
|
+
// tone: "recessed" (grey, default) | "flush" (white) | "contrast" (dark) | "custom" (+ color: a SIDEBAR_COLORS key or hex).
|
|
8
|
+
// items / footerItems: [{ icon, label, trailing?, children? }] — trailing draws a chevron (the item opens a SidePanel); children make a collapsible group.
|
|
9
|
+
const props = defineProps({ items: { type: Array, default: () => KEYBRICKS_NAV }, footerItems: { type: Array, default: () => KEYBRICKS_NAV_FOOTER }, active: String, collapsed: Boolean, logo: { type: String, default: 'assets/keybricks-logo.png' }, brand: { type: String, default: 'Keybricks' }, version: String, tone: { type: String, default: 'recessed' }, color: String, labels: Object, height: { type: String, default: '100%' }, showToggle: { type: Boolean, default: true }, style: Object })
|
|
16
10
|
const emit = defineEmits(['select', 'toggleCollapse'])
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
const L = computed(() => ({ collapse: 'Collapse', expand: 'Expand', ...(props.labels || {}) }))
|
|
12
|
+
const t = computed(() => chromeTone(props.tone, props.color))
|
|
13
|
+
const hover = ref(null)
|
|
14
|
+
const open = ref(props.items.filter(i => i.children && i.children.some(c => (c.label || c) === props.active)).map(i => i.label))
|
|
15
|
+
const toggle = (l) => { open.value = open.value.includes(l) ? open.value.filter(x => x !== l) : [...open.value, l] }
|
|
16
|
+
const lb = (c) => c.label || c
|
|
17
|
+
const LABEL = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--font-sans)', fontSize: 'var(--text-base)', lineHeight: '18px', letterSpacing: 'var(--tracking-base)', fontWeight: 'var(--weight-medium)' }
|
|
18
|
+
function row(key, active, indent) { return { minHeight: indent ? '32px' : '36px', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', justifyContent: props.collapsed ? 'center' : 'flex-start', gap: '10px', padding: indent ? '6px 10px' : '8px 10px', boxSizing: 'border-box', cursor: 'pointer', userSelect: 'none', background: active ? 'var(--sb-active)' : hover.value === key ? 'var(--sb-hover)' : 'transparent', color: active ? 'var(--sb-active-text)' : 'var(--sb-text)', boxShadow: active ? 'var(--sb-shadow)' : 'none', transition: 'var(--t-hover)' } }
|
|
19
|
+
function key(e, fn) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn() } }
|
|
20
|
+
const groups = computed(() => [{ id: 'main', list: props.items }, { id: 'foot', list: props.footerItems }])
|
|
21
|
+
const versionText = computed(() => props.collapsed && props.version ? 'v' + String(props.version).split(' ').pop().replace(/^v/i, '') : props.version)
|
|
20
22
|
</script>
|
|
21
23
|
<template>
|
|
22
|
-
<aside :style="{ width: collapsed ? 'var(--rail-w)' : 'var(--sidebar-w)', height, background: 'var(--bg
|
|
23
|
-
<div :style="{ height: 'var(--topbar-h)',
|
|
24
|
-
<img :src="logo" alt="" style="width:24px;height:24px;object-fit:contain" />
|
|
25
|
-
<span v-if="!collapsed" style="font-family:var(--font-sans);font-
|
|
24
|
+
<aside :data-theme="t.theme" :style="{ ...t.vars, width: collapsed ? 'var(--rail-w)' : 'var(--sidebar-w)', height, background: 'var(--sb-bg)', borderRight: '1px solid var(--sb-border)', color: 'var(--sb-text)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', flexShrink: 0, transition: 'width .15s ease', ...style }">
|
|
25
|
+
<div :style="{ height: 'var(--topbar-h)', display: 'flex', alignItems: 'center', gap: '10px', padding: '0 16px', boxSizing: 'border-box', justifyContent: collapsed ? 'center' : 'flex-start', flexShrink: 0 }">
|
|
26
|
+
<img :src="logo" alt="" style="width:24px;height:24px;object-fit:contain;flex-shrink:0" />
|
|
27
|
+
<span v-if="!collapsed" style="font-family:var(--font-sans);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);font-weight:var(--weight-semibold);color:var(--sb-brand);white-space:nowrap">{{ brand }}</span>
|
|
26
28
|
</div>
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
<template v-for="g in groups" :key="g.id">
|
|
30
|
+
<component :is="g.id === 'main' ? 'nav' : 'div'" :style="g.id === 'main' ? { flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px', display: 'flex', flexDirection: 'column', gap: '2px' } : { padding: '8px', display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }">
|
|
31
|
+
<div v-if="g.id === 'main' && showToggle" role="button" tabindex="0" :title="collapsed ? L.expand : undefined" :style="row('__toggle', false)" @click="emit('toggleCollapse')" @keydown="e => key(e, () => emit('toggleCollapse'))" @mouseenter="hover = '__toggle'" @mouseleave="hover = null">
|
|
32
|
+
<span style="display:inline-flex;flex-shrink:0"><Icon :name="collapsed ? 'panel-left' : 'chevron-left'" :size="16" /></span><span v-if="!collapsed" :style="LABEL">{{ collapsed ? L.expand : L.collapse }}</span>
|
|
33
|
+
</div>
|
|
34
|
+
<template v-for="it in g.list" :key="it.label">
|
|
35
|
+
<div role="button" tabindex="0" :title="collapsed ? it.label : undefined" :aria-current="active === it.label ? 'page' : undefined" :style="row(it.label, active === it.label || (collapsed && it.children && it.children.some(c => lb(c) === active)))" @click="it.children && !collapsed ? toggle(it.label) : emit('select', it.label)" @keydown="e => key(e, () => it.children && !collapsed ? toggle(it.label) : emit('select', it.label))" @mouseenter="hover = it.label" @mouseleave="hover = null">
|
|
36
|
+
<span v-if="it.icon" style="display:inline-flex;flex-shrink:0"><Icon :name="it.icon" :size="16" /></span>
|
|
37
|
+
<span v-if="!collapsed" :style="LABEL">{{ it.label }}</span>
|
|
38
|
+
<span v-if="!collapsed && it.trailing" style="display:inline-flex;flex-shrink:0;opacity:.6"><Icon name="chevron-right" :size="14" /></span>
|
|
34
39
|
</div>
|
|
35
|
-
<div v-if="!collapsed &&
|
|
36
|
-
<div v-for="c in it.children" :key="c
|
|
40
|
+
<div v-if="it.children && !collapsed && open.includes(it.label)" style="margin-left:24px;display:flex;flex-direction:column;gap:2px">
|
|
41
|
+
<div v-for="c in it.children" :key="lb(c)" role="button" tabindex="0" :aria-current="active === lb(c) ? 'page' : undefined" :style="row('c:' + lb(c), active === lb(c), true)" @click="emit('select', lb(c))" @keydown="e => key(e, () => emit('select', lb(c)))" @mouseenter="hover = 'c:' + lb(c)" @mouseleave="hover = null">
|
|
42
|
+
<span v-if="c.icon" style="display:inline-flex;flex-shrink:0"><Icon :name="c.icon" :size="14" /></span><span :style="LABEL">{{ lb(c) }}</span>
|
|
43
|
+
</div>
|
|
37
44
|
</div>
|
|
38
|
-
</
|
|
39
|
-
<
|
|
40
|
-
</
|
|
41
|
-
</
|
|
42
|
-
<div style="border-top:1px solid var(--border-card);padding:8px;display:flex;flex-direction:column;flex-shrink:0">
|
|
43
|
-
<div v-for="f in footerItems" :key="f.label" role="button" tabindex="0" @click="emit('select', f.label)" :style="itemStyle(false, false)"><Icon :name="f.icon" :size="16" /><span v-if="!collapsed" style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);white-space:nowrap;flex:1">{{ f.label }}</span></div>
|
|
44
|
-
</div>
|
|
45
|
+
</template>
|
|
46
|
+
<span v-if="g.id === 'foot' && version" :title="version" :style="{ padding: collapsed ? '8px 0 4px' : '8px 10px 4px', textAlign: collapsed ? 'center' : 'left', fontFamily: 'var(--font-sans)', fontSize: collapsed ? 'var(--text-glyph-min)' : 'var(--text-xs)', lineHeight: collapsed ? 'var(--leading-2xs)' : 'var(--leading-xs)', color: 'var(--sb-muted)', whiteSpace: 'nowrap', overflow: 'hidden' }">{{ versionText }}</span>
|
|
47
|
+
</component>
|
|
48
|
+
</template>
|
|
45
49
|
</aside>
|
|
46
50
|
</template>
|
|
47
|
-
|
|
48
|
-
<!-- The product's nav model is KEYBRICKS_NAV in ./nav-model.js — also exported from the Vue entry:
|
|
49
|
-
import { Sidebar, KEYBRICKS_NAV } from '@keyfunc/keybricks-ds/vue' -->
|