@keyfunc/keybricks-ds 2.4.0 → 2.4.4
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 +26 -4
- package/DESIGN.md +23 -5
- package/INTEGRATION.md +22 -4
- package/_adherence.oxlintrc.json +7 -0
- package/components/_cards/relative-time-picker.card.html +45 -0
- package/components/_cards/smart-table.card.html +23 -10
- package/components/_cards/tree.card.html +49 -3
- package/components/_shared/src-data.js +1 -1
- package/components/_shared/src-forms.js +1 -1
- package/components/_shared/src-navigation.js +1 -1
- package/components/_vue/index.mjs +1 -0
- package/components/data/SmartTable.d.ts +10 -4
- package/components/data/SmartTable.jsx +22 -9
- package/components/data/SmartTable.vue +22 -9
- 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 +1 -0
- package/components/index.js +1 -0
- package/components/navigation/Sidebar.d.ts +1 -1
- package/components/navigation/Tree.d.ts +1 -1
- package/components/navigation/Tree.jsx +21 -5
- package/components/navigation/Tree.vue +16 -4
- package/package.json +2 -2
- package/readme.md +3 -2
- package/readme.zh-CN.md +3 -2
- package/tokens/tokens.json +1 -1
|
@@ -4,22 +4,34 @@ import { IconButton } from "../buttons/IconButton.jsx";
|
|
|
4
4
|
import { Checkbox } from "../forms/Checkbox.jsx";
|
|
5
5
|
import { Avatar } from "../display/Avatar.jsx";
|
|
6
6
|
import { SearchInput } from "../forms/SearchInput.jsx";
|
|
7
|
+
import { Menu } from "../feedback/Menu.jsx";
|
|
7
8
|
|
|
8
9
|
const typeIcon = { text: "type", longText: "align-left", number: "hash", select: "chevron-down", multiSelect: "tag", date: "calendar", person: "user", checkbox: "check-square", url: "link", progress: "bar-chart-2" };
|
|
9
10
|
const Pill = ({ hue = "gray", children }) => <span style={{ display: "inline-flex", alignItems: "center", height: 20, padding: "0 8px", borderRadius: "var(--radius-full)", background: `var(--hue-${hue}-bg)`, color: `var(--hue-${hue}-fg)`, fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--text-xs)", lineHeight: 1, whiteSpace: "nowrap" }}>{children}</span>;
|
|
10
11
|
|
|
11
12
|
/** Notion/Airtable-style database grid ("smart table"): field-type icons in headers, colored select pills, person/date/checkbox/url/progress cells, row hover + expand, inline text/number editing, + New row, add-field column, per-column footer aggregates, and a view toolbar (Hide fields · Filter · Group · Sort · search). */
|
|
12
|
-
/* A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
|
|
13
|
-
|
|
13
|
+
/* A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
|
|
14
|
+
density: "default" rows are --bar-h (36 / 40 / 48) with --cell-px; "compact" rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it; rowHeight (a raw height) overrides density.
|
|
15
|
+
Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline. */
|
|
16
|
+
const DENSITY = { default: { h: "var(--bar-h)", px: "var(--cell-px)" }, compact: { h: "var(--control-h)", px: "8px" } };
|
|
17
|
+
export function SmartTable({ fields = [], rows = [], title = "Grid view", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = "default", onDensityChange, size = "sm", onChange, style }) {
|
|
14
18
|
const [data, setData] = React.useState(rows);
|
|
19
|
+
const [densityState, setDensityState] = React.useState(defaultDensity);
|
|
20
|
+
const density = DENSITY[densityProp || densityState] ? (densityProp || densityState) : "default";
|
|
21
|
+
const setDensity = (d) => { if (!densityProp) setDensityState(d); onDensityChange && onDensityChange(d); };
|
|
22
|
+
const rowH = rowHeight != null ? rowHeight : DENSITY[density].h;
|
|
15
23
|
const [hover, setHover] = React.useState(-1);
|
|
16
24
|
const [editing, setEditing] = React.useState(null);
|
|
17
25
|
const [draft, setDraft] = React.useState("");
|
|
18
26
|
const setCell = (r, key, v) => { const next = data.map((row, i) => i === r ? { ...row, [key]: v } : row); setData(next); onChange && onChange(next); };
|
|
19
27
|
const widths = ["40px", ...fields.map(f => (f.width || 140) + "px"), "40px"];
|
|
20
28
|
const grid = { display: "grid", gridTemplateColumns: widths.join(" "), width: "fit-content", minWidth: "100%" };
|
|
21
|
-
const cellBase = { display: "flex", alignItems: "center", gap: 5, padding: "0
|
|
29
|
+
const cellBase = { display: "flex", alignItems: "center", gap: 5, padding: "0 " + DENSITY[density].px, borderRight: "1px solid var(--border-subtle)", borderBottom: "1px solid var(--border-subtle)", height: rowH, boxSizing: "border-box", overflow: "hidden", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: "var(--text-primary)" };
|
|
22
30
|
const tierIcon = { width: "var(--control-icon)", height: "var(--control-icon)" };
|
|
31
|
+
// Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.
|
|
32
|
+
const stickyTop = { position: "sticky", top: 0, zIndex: 2, background: "var(--bg-inset)" };
|
|
33
|
+
const stickyLeft = { position: "sticky", left: 0, zIndex: 1, background: "var(--bg-inset)" };
|
|
34
|
+
const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 };
|
|
23
35
|
const agg = (f) => {
|
|
24
36
|
if (!f.aggregate) return "";
|
|
25
37
|
const vals = data.map(r => r[f.key]);
|
|
@@ -51,21 +63,22 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
|
|
|
51
63
|
<button type="button" data-wash="strong" style={{ display: "inline-flex", alignItems: "center", gap: 6, height: "var(--control-h)", padding: "0 var(--control-px)", border: "none", borderRadius: "var(--radius-md)", background: "var(--wash, var(--bg-active))", transition: "var(--t-hover)", cursor: "pointer", color: "var(--text-heading)", fontFamily: "var(--font-sans)", fontWeight: "var(--weight-medium)", fontSize: "var(--control-text)", letterSpacing: "var(--control-tracking)" }}><Icon name="layout-grid" size={14} style={tierIcon} />{title}<Icon name="chevron-down" size={14} style={tierIcon} /></button>
|
|
52
64
|
<span style={{ width: 1, height: 16, background: "var(--border-control)", margin: "0 4px" }}></span>
|
|
53
65
|
{tbBtn("eye-off", "Hide fields")}{tbBtn("funnel", "Filter")}{tbBtn("layers", "Group")}{tbBtn("arrow-down", "Sort")}
|
|
66
|
+
<Menu trigger={tbBtn("menu", "Row height")} items={[{ label: "Compact", selected: density === "compact" }, { label: "Default", selected: density === "default" }]} onSelect={(l) => setDensity(l === "Compact" ? "compact" : "default")} />
|
|
54
67
|
<span style={{ flex: 1 }}></span>
|
|
55
68
|
<SearchInput bare placeholder="Search..." style={{ width: 140, gap: 6 }} />
|
|
56
69
|
</div>}
|
|
57
70
|
<div style={{ overflow: "auto", maxHeight: height }}>
|
|
58
71
|
<div style={grid}>
|
|
59
|
-
<span style={{ ...cellBase,
|
|
60
|
-
{fields.map(f => <span key={f.key} style={{ ...cellBase,
|
|
61
|
-
<span style={{ ...cellBase,
|
|
72
|
+
<span style={{ ...cellBase, ...stickyCorner, justifyContent: "center" }}></span>
|
|
73
|
+
{fields.map(f => <span key={f.key} style={{ ...cellBase, ...stickyTop, color: "var(--text-secondary)", fontWeight: "var(--weight-semibold)" }}><Icon name={typeIcon[f.type] || "type"} size={14} style={{ ...tierIcon, color: "var(--text-muted)" }} />{f.label}</span>)}
|
|
74
|
+
<span style={{ ...cellBase, ...stickyTop, justifyContent: "center", padding: 0 }}><IconButton icon="plus" muted label="Add field" /></span>
|
|
62
75
|
{data.map((row, r) => [
|
|
63
|
-
<span key={"n" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, justifyContent: "center", color: "var(--text-muted)", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0,
|
|
76
|
+
<span key={"n" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, ...stickyLeft, justifyContent: "center", color: "var(--text-muted)", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0, cursor: "pointer" }}>{hover === r ? <Icon name="maximize-2" size={14} style={{ ...tierIcon, color: "var(--action-primary)" }} /> : r + 1}</span>,
|
|
64
77
|
...fields.map(f => <span key={r + f.key} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} onDoubleClick={() => { if (f.type === "text" || f.type === "number" || f.type === "date" || f.type === "url") { setEditing({ r, key: f.key }); setDraft(String(row[f.key] == null ? "" : row[f.key])); } }} style={{ ...cellBase, position: "relative", background: hover === r ? "var(--bg-inset)" : "transparent" }}>{renderCell(f, row, r)}</span>),
|
|
65
78
|
<span key={"a" + r} style={{ ...cellBase, background: hover === r ? "var(--bg-inset)" : "transparent" }}></span>,
|
|
66
79
|
])}
|
|
67
|
-
<button type="button" onClick={() => { const empty = {}; fields.forEach(f => empty[f.key] = f.type === "multiSelect" ? [] : f.type === "checkbox" ? false : ""); const next = [...data, empty]; setData(next); onChange && onChange(next); }} style={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", gap: 6, height:
|
|
68
|
-
<span style={{ ...cellBase, borderBottom: "none"
|
|
80
|
+
<button type="button" onClick={() => { const empty = {}; fields.forEach(f => empty[f.key] = f.type === "multiSelect" ? [] : f.type === "checkbox" ? false : ""); const next = [...data, empty]; setData(next); onChange && onChange(next); }} style={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", gap: 6, height: rowH, padding: "0 var(--edge-pad)", border: "none", borderBottom: "1px solid var(--border-subtle)", background: "transparent", cursor: "pointer", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", letterSpacing: "var(--control-tracking)", textAlign: "left" }} onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-inset)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}><Icon name="plus" size={13} />New</button>
|
|
81
|
+
<span style={{ ...cellBase, ...stickyLeft, borderBottom: "none" }}></span>
|
|
69
82
|
{fields.map(f => <span key={"g" + f.key} style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)", justifyContent: "flex-end", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0, color: "var(--text-secondary)" }}>{agg(f)}</span>)}
|
|
70
83
|
<span style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)" }}></span>
|
|
71
84
|
</div>
|
|
@@ -5,17 +5,29 @@ import IconButton from '../buttons/IconButton.vue'
|
|
|
5
5
|
import Checkbox from '../forms/Checkbox.vue'
|
|
6
6
|
import Avatar from '../display/Avatar.vue'
|
|
7
7
|
import SearchInput from '../forms/SearchInput.vue'
|
|
8
|
+
import Menu from '../feedback/Menu.vue'
|
|
8
9
|
// Notion/Airtable-style database grid: field-type header icons, hue pills, person/date/checkbox/url/progress cells, row hover + expand, inline editing, + New row, footer aggregates, view toolbar.
|
|
9
10
|
const typeIcon = { text: 'type', longText: 'align-left', number: 'hash', select: 'chevron-down', multiSelect: 'tag', date: 'calendar', person: 'user', checkbox: 'check-square', url: 'link', progress: 'bar-chart-2' }
|
|
10
|
-
const props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], size: { type: String, default: 'sm' }, style: Object })
|
|
11
|
+
const props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], density: String, defaultDensity: { type: String, default: 'default' }, size: { type: String, default: 'sm' }, style: Object })
|
|
11
12
|
// A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
|
|
12
|
-
|
|
13
|
+
// density: 'default' rows are --bar-h (36 / 40 / 48) with --cell-px; 'compact' rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it (emits density-change; v-model:density to control); rowHeight (a raw height) overrides density.
|
|
14
|
+
// Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.
|
|
15
|
+
const DENSITY = { default: { h: 'var(--bar-h)', px: 'var(--cell-px)' }, compact: { h: 'var(--control-h)', px: '8px' } }
|
|
13
16
|
const tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }
|
|
14
|
-
|
|
17
|
+
// Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.
|
|
18
|
+
const stickyTop = { position: 'sticky', top: 0, zIndex: 2, background: 'var(--bg-inset)' }
|
|
19
|
+
const stickyLeft = { position: 'sticky', left: 0, zIndex: 1, background: 'var(--bg-inset)' }
|
|
20
|
+
const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 }
|
|
21
|
+
const emit = defineEmits(['change', 'update:density', 'densityChange'])
|
|
22
|
+
const densityState = ref(props.defaultDensity)
|
|
23
|
+
const density = computed(() => DENSITY[props.density || densityState.value] ? (props.density || densityState.value) : 'default')
|
|
24
|
+
function setDensity(d) { if (!props.density) densityState.value = d; emit('update:density', d); emit('densityChange', d) }
|
|
25
|
+
const rowH = computed(() => props.rowHeight != null ? (typeof props.rowHeight === 'number' ? props.rowHeight + 'px' : props.rowHeight) : DENSITY[density.value].h)
|
|
26
|
+
const cellPx = computed(() => DENSITY[density.value].px)
|
|
15
27
|
const data = ref(props.rows.map(r => ({ ...r })))
|
|
16
28
|
const hover = ref(-1), editing = ref(null), draft = ref('')
|
|
17
29
|
const gridCols = computed(() => ['40px', ...props.fields.map(f => (f.width || 140) + 'px'), '40px'].join(' '))
|
|
18
|
-
const cellBase = { display: 'flex', alignItems: 'center', gap: '5px', padding: '0
|
|
30
|
+
const cellBase = computed(() => ({ display: 'flex', alignItems: 'center', gap: '5px', padding: '0 ' + cellPx.value, borderRight: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)', boxSizing: 'border-box', overflow: 'hidden', whiteSpace: 'nowrap', fontFamily: 'var(--font-sans)', fontSize: 'var(--control-text)', lineHeight: 'var(--control-leading)', letterSpacing: 'var(--control-tracking)', color: 'var(--text-primary)' }))
|
|
19
31
|
function setCell(r, key, v) { data.value[r][key] = v; emit('change', data.value) }
|
|
20
32
|
function commitEdit(r, f) { setCell(r, f.key, f.type === 'number' && draft.value !== '' && !isNaN(Number(draft.value)) ? Number(draft.value) : draft.value); editing.value = null }
|
|
21
33
|
function startEdit(r, f, row) { if (['text', 'number', 'date', 'url'].includes(f.type || 'text')) { editing.value = { r, key: f.key }; draft.value = String(row[f.key] == null ? '' : row[f.key]) } }
|
|
@@ -35,16 +47,17 @@ function agg(f) {
|
|
|
35
47
|
<button type="button" data-wash="strong" style="display:inline-flex;align-items:center;gap:6px;height:var(--control-h);padding:0 var(--control-px);border:none;border-radius:var(--radius-md);background:var(--wash, var(--bg-active));transition:var(--t-hover);cursor:pointer;color:var(--text-heading);font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--control-text);letter-spacing:var(--control-tracking)"><Icon name="layout-grid" :size="14" :style="tierIcon" />{{ title }}<Icon name="chevron-down" :size="14" :style="tierIcon" /></button>
|
|
36
48
|
<span style="width:1px;height:16px;background:var(--border-control);margin:0 4px" />
|
|
37
49
|
<button v-for="b in [['eye-off','Hide fields'],['funnel','Filter'],['layers','Group'],['arrow-down','Sort']]" :key="b[1]" type="button" class="kb-st-btn"><Icon :name="b[0]" :size="14" :style="tierIcon" />{{ b[1] }}</button>
|
|
50
|
+
<Menu :items="[{ label: 'Compact', selected: density === 'compact' }, { label: 'Default', selected: density === 'default' }]" @select="l => setDensity(l === 'Compact' ? 'compact' : 'default')"><template #trigger><button type="button" class="kb-st-btn"><Icon name="menu" :size="14" :style="tierIcon" />Row height</button></template></Menu>
|
|
38
51
|
<span style="flex:1" />
|
|
39
52
|
<SearchInput bare placeholder="Search..." style="width:140px;gap:6px" />
|
|
40
53
|
</div>
|
|
41
54
|
<div :style="{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }">
|
|
42
55
|
<div :style="{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content', minWidth: '100%' }">
|
|
43
|
-
<span :style="{ ...cellBase, height: rowH,
|
|
44
|
-
<span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH,
|
|
45
|
-
<span :style="{ ...cellBase, height: rowH,
|
|
56
|
+
<span :style="{ ...cellBase, height: rowH, ...stickyCorner }" />
|
|
57
|
+
<span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH, ...stickyTop, color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)' }"><Icon :name="typeIcon[f.type] || 'type'" :size="14" :style="{ ...tierIcon, color: 'var(--text-muted)' }" />{{ f.label }}</span>
|
|
58
|
+
<span :style="{ ...cellBase, height: rowH, ...stickyTop, justifyContent: 'center', padding: 0 }"><IconButton icon="plus" muted label="Add field" /></span>
|
|
46
59
|
<template v-for="(row, r) in data" :key="r">
|
|
47
|
-
<span @mouseenter="hover = r" @mouseleave="hover = -1" :style="{ ...cellBase, height: rowH, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0,
|
|
60
|
+
<span @mouseenter="hover = r" @mouseleave="hover = -1" :style="{ ...cellBase, height: rowH, ...stickyLeft, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, cursor: 'pointer' }">
|
|
48
61
|
<Icon v-if="hover === r" name="maximize-2" :size="14" :style="{ ...tierIcon, color: 'var(--action-primary)' }" /><template v-else>{{ r + 1 }}</template>
|
|
49
62
|
</span>
|
|
50
63
|
<span v-for="f in fields" :key="r + f.key" @mouseenter="hover = r" @mouseleave="hover = -1" @dblclick="startEdit(r, f, row)" :style="{ ...cellBase, height: rowH, position: 'relative', background: hover === r ? 'var(--bg-inset)' : 'transparent' }">
|
|
@@ -61,7 +74,7 @@ function agg(f) {
|
|
|
61
74
|
<span :style="{ ...cellBase, height: rowH, background: hover === r ? 'var(--bg-inset)' : 'transparent' }" />
|
|
62
75
|
</template>
|
|
63
76
|
<button type="button" @click="addRow" class="kb-st-new" :style="{ gridColumn: '1 / -1', height: rowH }"><Icon name="plus" :size="14" :style="tierIcon" />New</button>
|
|
64
|
-
<span :style="{ ...cellBase, height: rowH, borderBottom: 'none'
|
|
77
|
+
<span :style="{ ...cellBase, height: rowH, ...stickyLeft, borderBottom: 'none' }" />
|
|
65
78
|
<span v-for="f in fields" :key="'g' + f.key" :style="{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)', justifyContent: 'flex-end', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, color: 'var(--text-secondary)' }">{{ agg(f) }}</span>
|
|
66
79
|
<span :style="{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)' }" />
|
|
67
80
|
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export type RelativeTimeUnit = 'None' | 'Minute' | 'Hour' | 'Day' | 'Week' | 'Month';
|
|
4
|
+
export interface RelativeTimeValue { amount: number; unit: RelativeTimeUnit }
|
|
5
|
+
/** A unit label is one string (CJK: "小时前") or { one, other } for languages that inflect ("hour ago" / "hours ago"); picked with Intl.PluralRules of `locale`. */
|
|
6
|
+
export type RelativeTimeUnitLabel = string | { one: string; other: string };
|
|
7
|
+
export interface RelativeTimeLabels {
|
|
8
|
+
title: string;
|
|
9
|
+
amount: string;
|
|
10
|
+
unit: string;
|
|
11
|
+
preview: string;
|
|
12
|
+
unlimited: string;
|
|
13
|
+
Minute: RelativeTimeUnitLabel;
|
|
14
|
+
Hour: RelativeTimeUnitLabel;
|
|
15
|
+
Day: RelativeTimeUnitLabel;
|
|
16
|
+
Week: RelativeTimeUnitLabel;
|
|
17
|
+
Month: RelativeTimeUnitLabel;
|
|
18
|
+
}
|
|
19
|
+
/** Relative-time filter: a field-shaped trigger (tier height) opening a small non-modal dialog — amount input, unit select, live "From" preview. Vue: v-model + @change(value, date). */
|
|
20
|
+
export interface RelativeTimePickerProps {
|
|
21
|
+
/** { amount, unit }; unlimited is { amount: 0, unit: "None" } */
|
|
22
|
+
value?: RelativeTimeValue;
|
|
23
|
+
/** Hides the unlimited option; zero still resolves to unlimited for legacy filters. */
|
|
24
|
+
allowUnlimited?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Size tier of the trigger and its dialog; omit to inherit the region's tier. */
|
|
27
|
+
size?: 'sm' | 'md' | 'lg';
|
|
28
|
+
labels?: Partial<RelativeTimeLabels>;
|
|
29
|
+
/** Date formatting and plural rules ("1 hour ago" vs "2 hours ago"). */
|
|
30
|
+
locale?: string;
|
|
31
|
+
/** Defaults to the current time, refreshed when opened or edited. */
|
|
32
|
+
referenceDate?: Date;
|
|
33
|
+
formatDate?: (date: Date) => string;
|
|
34
|
+
formatValue?: (value: RelativeTimeValue) => string;
|
|
35
|
+
/** No mount emission. Unlimited is null. Vue: v-model + @change(value, date). */
|
|
36
|
+
onChange?: (value: RelativeTimeValue, date: Date | null) => void;
|
|
37
|
+
style?: React.CSSProperties;
|
|
38
|
+
}
|
|
39
|
+
export declare function RelativeTimePicker(props: RelativeTimePickerProps): React.JSX.Element;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Icon } from "../icons/Icon.jsx";
|
|
4
|
+
import { Input } from "./Input.jsx";
|
|
5
|
+
import { Select } from "./Select.jsx";
|
|
6
|
+
import { Field } from "./Field.jsx";
|
|
7
|
+
import { RELATIVE_TIME_LABELS, RELATIVE_TIME_UNITS, changeRelativeAmount, changeRelativeUnit, normalizeRelativeTime, relativeTimePosition, relativeTimeText, relativeUnitLabel, resolveRelativeTime } from "./relative-time.js";
|
|
8
|
+
|
|
9
|
+
/** 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
|
|
10
|
+
* with an amount input and a unit select, plus a live "From" preview. Controlled: value = { amount, unit }; onChange(value, date) — unlimited resolves to null.
|
|
11
|
+
* Tier: the trigger is a field (sm 28 · md 32 · lg 40 — omit `size` to inherit the region's tier); the dialog is portalled to <body> and copies the trigger's
|
|
12
|
+
* 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.
|
|
13
|
+
* The amount is a plain numeric text field (no native spinners); unit labels may be { one, other } and follow Intl.PluralRules of `locale`. */
|
|
14
|
+
const glyph = { width: "var(--control-icon)", height: "var(--control-icon)", color: "var(--text-secondary)", flexShrink: 0 };
|
|
15
|
+
export function RelativeTimePicker({ value = { amount: 1, unit: "Hour" }, onChange, allowUnlimited = true, disabled, size, labels, locale, referenceDate, formatDate, formatValue, style }) {
|
|
16
|
+
const [open, setOpen] = React.useState(false);
|
|
17
|
+
const [now, setNow] = React.useState(() => new Date());
|
|
18
|
+
const [position, setPosition] = React.useState({});
|
|
19
|
+
const trigger = React.useRef(null);
|
|
20
|
+
const panel = React.useRef(null);
|
|
21
|
+
const current = normalizeRelativeTime(value);
|
|
22
|
+
const text = { ...RELATIVE_TIME_LABELS, ...labels };
|
|
23
|
+
const units = allowUnlimited ? ["None", ...RELATIVE_TIME_UNITS] : RELATIVE_TIME_UNITS;
|
|
24
|
+
const unitLabel = (unit) => unit === "None" ? text.unlimited : relativeUnitLabel(text, unit, undefined, locale);
|
|
25
|
+
const options = units.map(unitLabel);
|
|
26
|
+
const summary = relativeTimeText(current, text, formatValue, locale);
|
|
27
|
+
const date = resolveRelativeTime(current, referenceDate || now);
|
|
28
|
+
const preview = !date ? text.unlimited : formatDate ? formatDate(date) : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }).format(date);
|
|
29
|
+
|
|
30
|
+
function close(restoreFocus = false) { setOpen(false); if (restoreFocus) trigger.current?.focus(); }
|
|
31
|
+
function update(next) {
|
|
32
|
+
const reference = referenceDate || new Date();
|
|
33
|
+
if (next.unit !== "None" && !resolveRelativeTime(next, reference)) return;
|
|
34
|
+
setNow(reference);
|
|
35
|
+
onChange?.(next, resolveRelativeTime(next, reference));
|
|
36
|
+
}
|
|
37
|
+
React.useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const place = () => setPosition(relativeTimePosition(trigger.current, panel.current));
|
|
41
|
+
const contains = (t) => trigger.current?.contains(t) || panel.current?.contains(t);
|
|
42
|
+
const outside = (e) => { if (!contains(e.target)) close(); };
|
|
43
|
+
const keydown = (e) => { if (e.key === "Escape") { e.preventDefault(); close(true); } };
|
|
44
|
+
place();
|
|
45
|
+
panel.current?.querySelector("input")?.focus();
|
|
46
|
+
document.addEventListener("pointerdown", outside, true);
|
|
47
|
+
document.addEventListener("focusin", outside);
|
|
48
|
+
document.addEventListener("keydown", keydown);
|
|
49
|
+
window.addEventListener("resize", place);
|
|
50
|
+
window.addEventListener("scroll", place, true);
|
|
51
|
+
return () => {
|
|
52
|
+
document.removeEventListener("pointerdown", outside, true);
|
|
53
|
+
document.removeEventListener("focusin", outside);
|
|
54
|
+
document.removeEventListener("keydown", keydown);
|
|
55
|
+
window.removeEventListener("resize", place);
|
|
56
|
+
window.removeEventListener("scroll", place, true);
|
|
57
|
+
};
|
|
58
|
+
}, [open]);
|
|
59
|
+
|
|
60
|
+
const themeRoot = trigger.current?.closest("[data-theme]");
|
|
61
|
+
return (
|
|
62
|
+
<div data-size={size} style={{ display: "inline-flex", width: "100%", minWidth: 0, ...style }}>
|
|
63
|
+
<button ref={trigger} type="button" data-field={disabled ? undefined : ""} disabled={disabled} aria-haspopup="dialog" aria-expanded={open} aria-label={`${text.title}: ${summary}`}
|
|
64
|
+
onClick={() => { if (open) return close(); setNow(new Date()); setPosition(relativeTimePosition(trigger.current)); setOpen(true); }}
|
|
65
|
+
style={{ display: "flex", alignItems: "center", gap: "var(--control-gap)", height: "var(--control-h)", width: "100%", padding: "0 var(--control-px)", borderRadius: "var(--radius-md)",
|
|
66
|
+
border: `1px solid ${open ? "var(--focus-border)" : "var(--field-border, var(--border-control))"}`, background: disabled ? "var(--bg-inset)" : "var(--bg-surface)", color: "var(--text-primary)",
|
|
67
|
+
fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", textAlign: "start",
|
|
68
|
+
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .5 : 1, transition: "var(--t-hover)", boxSizing: "border-box", outline: "none" }}>
|
|
69
|
+
<Icon name="clock" size={16} style={glyph} />
|
|
70
|
+
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{summary}</span>
|
|
71
|
+
<Icon name="chevron-down" size={16} style={{ ...glyph, color: "var(--text-muted)" }} />
|
|
72
|
+
</button>
|
|
73
|
+
{open && createPortal(
|
|
74
|
+
<div ref={panel} role="dialog" aria-label={text.title} lang={trigger.current?.closest("[lang]")?.lang} data-theme={themeRoot && themeRoot !== document.documentElement ? themeRoot.dataset.theme : undefined}
|
|
75
|
+
style={{ 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)",
|
|
76
|
+
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 }}>
|
|
77
|
+
<h3 style={{ margin: "0 0 var(--control-gap)", color: "var(--text-heading)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", fontWeight: "var(--weight-medium)" }}>{text.title}</h3>
|
|
78
|
+
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.4fr)", gap: "var(--group-gap)" }}>
|
|
79
|
+
<Field label={text.amount}><Input inputMode="numeric" pattern="[0-9]*" value={current.amount} onChange={(e) => { const v = e.target.value.replace(/\D/g, ""); if (v !== "" || e.target.value === "") update(changeRelativeAmount(current, v)); }} aria-label={text.amount} /></Field>
|
|
80
|
+
<Field label={text.unit}><Select options={options} value={unitLabel(current.unit)} onChange={(e) => { const i = options.indexOf(e.target.value); if (i >= 0) update(changeRelativeUnit(current, units[i])); }} /></Field>
|
|
81
|
+
</div>
|
|
82
|
+
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "space-between", gap: "var(--control-gap)", marginTop: "var(--group-gap)", paddingTop: "var(--control-gap)", borderTop: "1px solid var(--border-subtle)", color: "var(--text-secondary)" }}>
|
|
83
|
+
<span>{text.preview}</span>
|
|
84
|
+
<span style={{ color: "var(--text-body)", fontVariantNumeric: "tabular-nums", overflowWrap: "anywhere" }}>{preview}</span>
|
|
85
|
+
</div>
|
|
86
|
+
</div>, document.body)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# RelativeTimePicker 相对时间筛选器
|
|
2
|
+
|
|
3
|
+
适用于日志、监控、站内信等按“过去多久”筛选的数据页面。它不是具体日期选择器,也不负责查询或保存业务数据。
|
|
4
|
+
|
|
5
|
+
## 数据契约
|
|
6
|
+
|
|
7
|
+
- 值为 `{ amount: number, unit: 'None' | 'Minute' | 'Hour' | 'Day' | 'Week' | 'Month' }`。
|
|
8
|
+
- 数值为非负整数;0、空值、非法数值归一为 `{ amount: 0, unit: 'None' }`。
|
|
9
|
+
- 从不限时间输入正数时恢复为小时;从不限切换为具体单位时,数值恢复为 1。
|
|
10
|
+
- `allowUnlimited=false` 仅隐藏“不限时间”选项;为兼容原筛选器,输入 0 仍表示不限时间。
|
|
11
|
+
- 分钟、小时按经过的毫秒计算;天、周按本地日历计算;月按日历月计算并夹到目标月的最后一天。
|
|
12
|
+
- 无时区转换;按浏览器所在时区计算。`referenceDate` 可提供固定的计算基准,默认在打开或编辑时取当前时间。
|
|
13
|
+
- `resolveRelativeTime(value, referenceDate?)` 返回 `Date | null`,不修改传入的 Date;不限返回 null。业务系统需要 `Date(0)` 等哨兵值时,在业务适配层转换。
|
|
14
|
+
- 组件挂载不发出变更事件;只在编辑时输出新值和计算日期。父组件更新值时,界面同步回显。
|
|
15
|
+
|
|
16
|
+
## Vue 3
|
|
17
|
+
|
|
18
|
+
```vue
|
|
19
|
+
<script setup>
|
|
20
|
+
import { ref } from 'vue';
|
|
21
|
+
import RelativeTimePicker from '@keyfunc/keybricks-ds/components/forms/RelativeTimePicker.vue';
|
|
22
|
+
import '@keyfunc/keybricks-ds/styles.css';
|
|
23
|
+
|
|
24
|
+
const range = ref({ amount: 1, unit: 'Hour' });
|
|
25
|
+
const labels = {
|
|
26
|
+
title: '相对时间', amount: '数量', unit: '单位', preview: '起始时间', unlimited: '不限时间',
|
|
27
|
+
Minute: '分钟前', Hour: '小时前', Day: '天前', Week: '周前', Month: '个月前',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function onChange(value, date) {
|
|
31
|
+
// 在此处更新业务筛选条件;date 为 null 表示不限时间。
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div data-size="md">
|
|
37
|
+
<RelativeTimePicker v-model="range" :labels="labels" locale="zh-CN" @change="onChange" />
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Vue 事件为 `update:modelValue(value)` 与 `change(value, date)`。`disabled` 禁止打开并关闭已有弹层。
|
|
43
|
+
|
|
44
|
+
## React
|
|
45
|
+
|
|
46
|
+
```jsx
|
|
47
|
+
import React from 'react';
|
|
48
|
+
import { RelativeTimePicker } from '@keyfunc/keybricks-ds';
|
|
49
|
+
import '@keyfunc/keybricks-ds/styles.css';
|
|
50
|
+
|
|
51
|
+
export function LogFilter() {
|
|
52
|
+
const [range, setRange] = React.useState({ amount: 1, unit: 'Hour' });
|
|
53
|
+
return <RelativeTimePicker value={range} onChange={(value, date) => setRange(value)} />;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 外观与可访问性
|
|
58
|
+
|
|
59
|
+
优先在工具栏或表单区域统一设置 `data-size`,单个控件独立使用时可传 `size`。
|
|
60
|
+
尺寸、字体、边框、状态色和浮层阴影均读取语义 Token,样式与其他控件一样内联(2.4.1 起不再有独立的 relative-time.css)。弹层复制触发区域的字体、尺寸、语言及主题,避免挂到 body 后丢失区域密度。悬停与其他输入框一致,来自 `data-field`;字段标签使用 `Field`。
|
|
61
|
+
|
|
62
|
+
触发器是原生 button,支持键盘打开;打开后焦点进入数值输入框。Esc 关闭并将焦点送回触发器;点击外部或 Tab 离开关闭弹层。输入框和单位选择器具有可见标签,弹层使用非模态 dialog 语义。
|
|
63
|
+
|
|
64
|
+
所有文案可通过 `labels` 局部覆盖,单位文案应彼此不同。单位文案可以是一个字符串(中文"小时前",不分单复数),也可以是 `{ one, other }`(英文 "hour ago" / "hours ago"),组件按 `locale` 的复数规则选择;单位下拉框显示 `other` 形式。数量输入是纯数字文本框(`inputmode="numeric"`,无原生上下箭头),非数字字符会被去掉。`formatValue(value)` 可自定义触发器摘要,便于复数或不同语序;`formatDate(date)` 可覆盖默认 Intl 日期格式。组件不依赖业务 i18n、Element Plus、Vuex 或 date-fns。
|
|
65
|
+
|
|
66
|
+
## 本次迁移及验证边界
|
|
67
|
+
|
|
68
|
+
前端 `foundation/timeSelect.vue` 暂时通过同级 UI 仓库源码引用本组件。发布包后,将该文件中的组件、计算函数及类型导入改为对应的 `@keyfunc/keybricks-ds/components/forms/` 路径即可。
|
|
69
|
+
|
|
70
|
+
业务层保留 `initNum`、`initUnit`、`disableUnLimited`、`fromDate` 参数,`onDateChange`、`update:fromDate` 事件,挂载立即通知、`Date(0)` 不限时间语义,以及监控页面读取的 `ref.num` / `ref.timeUnit`。
|
|
71
|
+
|
|
72
|
+
按本次任务约定未执行测试、类型检查或构建,功能及浏览器效果仍需人工验证。重点检查:不限与具体单位切换、月末及跨夏令时日期、父组件回显、sm/md/lg 密度、区域语言及深浅主题、滚动和窗口边缘定位、键盘退出及业务旧事件。
|
|
@@ -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";
|
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";
|
|
@@ -25,7 +25,7 @@ export interface SidebarProps {
|
|
|
25
25
|
onToggleCollapse?: () => void;
|
|
26
26
|
logo?: string;
|
|
27
27
|
brand?: string;
|
|
28
|
-
/** Version line under the footer items ("Version 2.4.
|
|
28
|
+
/** Version line under the footer items ("Version 2.4.4"); the rail shows "v2.4.4" */
|
|
29
29
|
version?: string;
|
|
30
30
|
tone?: "recessed" | "flush" | "contrast" | "custom";
|
|
31
31
|
/** For tone "custom": a SIDEBAR_COLORS key (slate, navy, indigo, violet, plum, wine, forest, teal, umber, charcoal) or any hex */
|
|
@@ -67,7 +67,7 @@ export interface TreeProps {
|
|
|
67
67
|
/** Draw connector lines instead of plain indentation */
|
|
68
68
|
showLine?: boolean;
|
|
69
69
|
draggable?: boolean;
|
|
70
|
-
/** Also allow insert-between: a folder's outer 20% becomes a before/after rule. Off by default — a drag moves a node INTO a folder, and files are never drop targets. */
|
|
70
|
+
/** Also allow insert-between: a folder's outer 20% becomes a before/after rule, and a leaf becomes an insert target over its whole height (top half before, bottom half after) — so leaves can be reordered among themselves. Before / after draw an insert marker (2px line + 6px dot) on the gap between rows, starting at the target's indent. Off by default — a drag moves a node INTO a folder, and files are never drop targets. */
|
|
71
71
|
reorder?: boolean;
|
|
72
72
|
/** Highlights matches and auto-expands their ancestors */
|
|
73
73
|
searchValue?: string;
|