@keyfunc/keybricks-ds 2.4.0 → 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.
@@ -99,6 +99,7 @@ export { default as MultiSelect } from "../forms/MultiSelect.vue";
99
99
  export { default as NumberStepper } from "../forms/NumberStepper.vue";
100
100
  export { default as Radio } from "../forms/Radio.vue";
101
101
  export { default as RadioGroup } from "../forms/RadioGroup.vue";
102
+ export { default as RelativeTimePicker } from "../forms/RelativeTimePicker.vue";
102
103
  export { default as SearchInput } from "../forms/SearchInput.vue";
103
104
  export { default as Select } from "../forms/Select.vue";
104
105
  export { default as Slider } from "../forms/Slider.vue";
@@ -24,9 +24,14 @@ export interface SmartTableProps {
24
24
  toolbar?: boolean;
25
25
  /** Scroll-area max height */
26
26
  height?: number | string;
27
- /** Row height; defaults to the tier's bar height (32 at sm) */
27
+ /** Row density. "default" = the tier's bar height (36 at sm) with --cell-px; "compact" = the tier's control height (28 at sm, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (--cell-px is already 8 at sm, so there only the height changes). Header and footer follow. Controlled when set — pair with onDensityChange. */
28
+ density?: "default" | "compact";
29
+ /** Initial density when uncontrolled (default "default"). The toolbar's "Row height" menu switches it. */
30
+ defaultDensity?: "default" | "compact";
31
+ onDensityChange?: (density: "default" | "compact") => void;
32
+ /** Raw row height; overrides density. Prefer density. */
28
33
  rowHeight?: number | string;
29
- /** Size tier of the grid region. Defaults to "sm" — a database grid is a dense surface (32px rows, 12px text, 24px toolbar buttons). */
34
+ /** Size tier of the grid region. Defaults to "sm" — a database grid is a dense surface (36px rows, 12px text, 28px toolbar buttons). */
30
35
  size?: "sm" | "md" | "lg";
31
36
  onChange?: (rows: object[]) => void;
32
37
  style?: React.CSSProperties;
@@ -4,21 +4,28 @@ 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
- export function SmartTable({ fields = [], rows = [], title = "Grid view", toolbar = true, height, rowHeight = "var(--bar-h)", size = "sm", onChange, style }) {
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
+ const DENSITY = { default: { h: "var(--bar-h)", px: "var(--cell-px)" }, compact: { h: "var(--control-h)", px: "8px" } };
16
+ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = "default", onDensityChange, size = "sm", onChange, style }) {
14
17
  const [data, setData] = React.useState(rows);
18
+ const [densityState, setDensityState] = React.useState(defaultDensity);
19
+ const density = DENSITY[densityProp || densityState] ? (densityProp || densityState) : "default";
20
+ const setDensity = (d) => { if (!densityProp) setDensityState(d); onDensityChange && onDensityChange(d); };
21
+ const rowH = rowHeight != null ? rowHeight : DENSITY[density].h;
15
22
  const [hover, setHover] = React.useState(-1);
16
23
  const [editing, setEditing] = React.useState(null);
17
24
  const [draft, setDraft] = React.useState("");
18
25
  const setCell = (r, key, v) => { const next = data.map((row, i) => i === r ? { ...row, [key]: v } : row); setData(next); onChange && onChange(next); };
19
26
  const widths = ["40px", ...fields.map(f => (f.width || 140) + "px"), "40px"];
20
27
  const grid = { display: "grid", gridTemplateColumns: widths.join(" "), width: "fit-content", minWidth: "100%" };
21
- const cellBase = { display: "flex", alignItems: "center", gap: 5, padding: "0 var(--cell-px)", borderRight: "1px solid var(--border-subtle)", borderBottom: "1px solid var(--border-subtle)", height: rowHeight, 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)" };
28
+ 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
29
  const tierIcon = { width: "var(--control-icon)", height: "var(--control-icon)" };
23
30
  const agg = (f) => {
24
31
  if (!f.aggregate) return "";
@@ -51,6 +58,7 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
51
58
  <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
59
  <span style={{ width: 1, height: 16, background: "var(--border-control)", margin: "0 4px" }}></span>
53
60
  {tbBtn("eye-off", "Hide fields")}{tbBtn("funnel", "Filter")}{tbBtn("layers", "Group")}{tbBtn("arrow-down", "Sort")}
61
+ <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
62
  <span style={{ flex: 1 }}></span>
55
63
  <SearchInput bare placeholder="Search..." style={{ width: 140, gap: 6 }} />
56
64
  </div>}
@@ -64,7 +72,7 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
64
72
  ...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
73
  <span key={"a" + r} style={{ ...cellBase, background: hover === r ? "var(--bg-inset)" : "transparent" }}></span>,
66
74
  ])}
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: rowHeight, 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>
75
+ <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>
68
76
  <span style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)" }}></span>
69
77
  {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
78
  <span style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)" }}></span>
@@ -5,17 +5,24 @@ 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
- const rowH = computed(() => props.rowHeight == null ? 'var(--bar-h)' : (typeof props.rowHeight === 'number' ? props.rowHeight + 'px' : props.rowHeight))
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
+ const DENSITY = { default: { h: 'var(--bar-h)', px: 'var(--cell-px)' }, compact: { h: 'var(--control-h)', px: '8px' } }
13
15
  const tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }
14
- const emit = defineEmits(['change'])
16
+ const emit = defineEmits(['change', 'update:density', 'densityChange'])
17
+ const densityState = ref(props.defaultDensity)
18
+ const density = computed(() => DENSITY[props.density || densityState.value] ? (props.density || densityState.value) : 'default')
19
+ function setDensity(d) { if (!props.density) densityState.value = d; emit('update:density', d); emit('densityChange', d) }
20
+ const rowH = computed(() => props.rowHeight != null ? (typeof props.rowHeight === 'number' ? props.rowHeight + 'px' : props.rowHeight) : DENSITY[density.value].h)
21
+ const cellPx = computed(() => DENSITY[density.value].px)
15
22
  const data = ref(props.rows.map(r => ({ ...r })))
16
23
  const hover = ref(-1), editing = ref(null), draft = ref('')
17
24
  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 var(--cell-px)', 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)' }
25
+ 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
26
  function setCell(r, key, v) { data.value[r][key] = v; emit('change', data.value) }
20
27
  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
28
  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,6 +42,7 @@ function agg(f) {
35
42
  <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
43
  <span style="width:1px;height:16px;background:var(--border-control);margin:0 4px" />
37
44
  <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>
45
+ <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
46
  <span style="flex:1" />
39
47
  <SearchInput bare placeholder="Search..." style="width:140px;gap:6px" />
40
48
  </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
+ }
@@ -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";
@@ -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.0"); the rail shows "v2.4.0" */
28
+ /** Version line under the footer items ("Version 2.4.2"); the rail shows "v2.4.2" */
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 */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@keyfunc/keybricks-ds",
3
- "version": "2.4.0",
4
- "description": "Keybricks design system — design tokens, three size tiers and 120 UI components in React and Vue 3, source-only.",
3
+ "version": "2.4.2",
4
+ "description": "Keybricks design system — design tokens, three size tiers and 121 UI components in React and Vue 3, source-only.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "sideEffects": [
package/readme.md CHANGED
@@ -69,7 +69,7 @@ One product: the Keybricks web app (desktop, 1512×~860 design size). Key surfac
69
69
 
70
70
  **For developers integrating this system**
71
71
  - `DESIGN.md` — the rules file for AI coding agents: absolute token values, interaction states, an explicit "do not" list, and voice. Point Claude Code / Cursor / Copilot at this before they generate UI.
72
- - `INTEGRATION.md` — the integration guide: three steps to first paint, the full 120-component index with import paths, token reference, dark mode, interaction states, versioning.
72
+ - `INTEGRATION.md` — the integration guide: three steps to first paint, the full 121-component index with import paths, token reference, dark mode, interaction states, versioning.
73
73
  - `DEPLOY.md` — running the system on GitLab: what ships in the package and what stays on Pages, the entry points, the release flow, and the bundler config each consumer needs.
74
74
  - `index.html` · `gallery.html` — the documentation site: the browsable system and the filterable thumbnail grid, both deployed to GitLab Pages by CI.
75
75
  - `package.json` · `.gitlab-ci.yml` · `_scripts/` — makes the system an installable package: a version tag publishes to the GitLab Package Registry, the default branch redeploys the docs site, and `npm run check` fails the pipeline if the generated tokens or barrels drift from source.
@@ -83,7 +83,7 @@ One product: the Keybricks web app (desktop, 1512×~860 design size). Key surfac
83
83
  ## Components
84
84
  - `components/icons/` — Icon (+ ICON_PATHS glyph data + feather-paths.js, the full Feather fallback), DataSourceLogo (+ DATA_SOURCE_LOGOS), Unicon (Unicons CDN webfont).
85
85
  - `components/buttons/` — Button, IconButton, ActionChip.
86
- - `components/forms/` — Field, Input, Textarea, Select, SearchInput, Checkbox, NumberStepper, Dropzone, Switch, Radio, RadioGroup, Combobox, MultiSelect, DatePicker, Slider.
86
+ - `components/forms/` — Field, Input, Textarea, Select, SearchInput, Checkbox, NumberStepper, Dropzone, Switch, Radio, RadioGroup, Combobox, MultiSelect, DatePicker, Slider, RelativeTimePicker.
87
87
  - `components/tabs/` — Tabs, PanelTabs, PillTabs, ViewToggle, SegmentedControl.
88
88
  - `components/display/` — Card, StatCard, Badge, StatusBadge, IconTile, ProgressBar, Donut, AreaChart, MiniBars, Avatar, AvatarGroup, Tag, Divider, Kbd.
89
89
  - `components/feedback/` — Stepper, ContextMenu, ActivityItem, DotActivity, LogViewer, Tooltip, Modal, Toast, ToastStack, Alert, EmptyState, Skeleton, Spinner, Menu, Accordion, Banner, Drawer, Timeline.
@@ -110,6 +110,7 @@ These extend the original inventory — each is a standard need this product cle
110
110
  - `FilterBar` — Fiori-style list-report filter bar (variant name + expand/collapse + Adapt Filters (n) + Go).
111
111
  - `Combobox` — type-to-filter single-select dropdown; `MultiSelect` — multi-select dropdown (tags + "+n" overflow + clear all).
112
112
  - `DatePicker` — calendar popover date picker; `Slider` — slider (drag bubble + min/max labels).
113
+ - `RelativeTimePicker` — "how long ago" filter (amount + unit dialog, "Any time" → null) for logs and monitoring.
113
114
  - `Drawer` — right-hand slide-out detail panel; `Banner` — page-level notification (distinct from in-card Alert and floating Toast); `Timeline` — vertical activity timeline; `Tree` — general nested tree (cascading checks, async loading, search highlight, drag into folder, keyboard navigation).
114
115
  - `Tag`, `Divider`, `Kbd` — filter tags, separators and shortcut hints that were hand-built over and over in the UI.
115
116
  - `Menu` (dropdown anchored under its trigger, distinct from `ContextMenu`, which opens at the click point), `Accordion` (general collapsible section, distinct from `ListRow`, which is specific to workflow documents).