@columenlabs/lumen-ui 0.1.113 → 0.1.114

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.
@@ -0,0 +1,23 @@
1
+ /** Focus stays on the trigger: moving it into the pop-up, or back on close, would dismiss a
2
+ * neighbour's pop-up the mouse just opened (Radix closes a pop-up on focus outside). */
3
+ declare const keepFocus: (e: Event) => void;
4
+ /**
5
+ * Open state for a status bar pop-up that opens on hover with a mouse and on tap with a finger or
6
+ * keyboard (a `Popover`'s own trigger). Spread `hover` on the trigger and `content` on the
7
+ * `PopoverContent`.
8
+ */
9
+ export declare function useHoverOpen(): {
10
+ open: boolean;
11
+ setOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
12
+ hover: {
13
+ onPointerEnter: (e: React.PointerEvent) => void;
14
+ onPointerLeave: (e: React.PointerEvent) => void;
15
+ };
16
+ content: {
17
+ onPointerEnter: (e: React.PointerEvent) => void;
18
+ onPointerLeave: (e: React.PointerEvent) => void;
19
+ onOpenAutoFocus: typeof keepFocus;
20
+ onCloseAutoFocus: typeof keepFocus;
21
+ };
22
+ };
23
+ export {};
@@ -0,0 +1,32 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ /** How long a pop-up stays after the mouse leaves, so it can travel from trigger to pop-up. */
3
+ const HOVER_CLOSE_MS = 150;
4
+ /** Focus stays on the trigger: moving it into the pop-up, or back on close, would dismiss a
5
+ * neighbour's pop-up the mouse just opened (Radix closes a pop-up on focus outside). */
6
+ const keepFocus = (e) => e.preventDefault();
7
+ /**
8
+ * Open state for a status bar pop-up that opens on hover with a mouse and on tap with a finger or
9
+ * keyboard (a `Popover`'s own trigger). Spread `hover` on the trigger and `content` on the
10
+ * `PopoverContent`.
11
+ */
12
+ export function useHoverOpen() {
13
+ const [open, setOpen] = useState(false);
14
+ const timer = useRef(undefined);
15
+ useEffect(() => () => clearTimeout(timer.current), []);
16
+ const hover = (on) => (e) => {
17
+ if (e.pointerType !== 'mouse')
18
+ return;
19
+ clearTimeout(timer.current);
20
+ if (on)
21
+ setOpen(true);
22
+ else
23
+ timer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_MS);
24
+ };
25
+ const handlers = { onPointerEnter: hover(true), onPointerLeave: hover(false) };
26
+ return {
27
+ open,
28
+ setOpen,
29
+ hover: handlers,
30
+ content: { ...handlers, onOpenAutoFocus: keepFocus, onCloseAutoFocus: keepFocus },
31
+ };
32
+ }
package/dist/index.d.ts CHANGED
@@ -78,6 +78,7 @@ export { LumenShell, type LumenShellProps, type ShellUser, StatusItem, firstName
78
78
  export { apiJson } from './api-fetch.js';
79
79
  export { cn } from './lib/utils.js';
80
80
  export { type HealthProbe, StatusHealth } from './status-health.js';
81
+ export { type StatusMessage, type StatusMessageTone, StatusMessages, groupStatusMessages, } from './status-messages.js';
81
82
  export { TimeZoneSelect } from './status.js';
82
83
  export { type AccountFacts, type JoinReaders, JoinsProvider, SourceStatusItems, YOURS_COLOR, editedCells, editedKey, frameColor, useFrameEdits, useJoins, usedJoinKeys, } from './joins.js';
83
84
  export { OriginLegend, originsOf } from './origin-legend.js';
package/dist/index.js CHANGED
@@ -85,6 +85,7 @@ export { LumenShell, StatusItem, firstName, initials, maskAmount, useAmountsHidd
85
85
  export { apiJson } from './api-fetch.js';
86
86
  export { cn } from './lib/utils.js';
87
87
  export { StatusHealth } from './status-health.js';
88
+ export { StatusMessages, groupStatusMessages, } from './status-messages.js';
88
89
  export { TimeZoneSelect } from './status.js';
89
90
  export { JoinsProvider, SourceStatusItems, YOURS_COLOR, editedCells, editedKey, frameColor, useFrameEdits, useJoins, usedJoinKeys, } from './joins.js';
90
91
  export { OriginLegend, originsOf } from './origin-legend.js';
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from 'react';
1
+ import type { ReactNode } from 'react';
2
2
  export interface HealthProbe {
3
3
  name: string;
4
4
  /** Up when true, down when false, not checked yet when null. */
@@ -1,12 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // Service health in the status bar ("consolidate into a clickable or hoverable
3
- // pop-up"): one chip with the worst state of every service the app checks, opening a list of each
4
- // service and its latency, on hover with a mouse and on tap with a finger or keyboard.
5
- import { useEffect, useRef, useState } from 'react';
2
+ import { useHoverOpen } from './hooks/use-hover-open.js';
6
3
  import { cn } from './lib/utils.js';
7
4
  import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
8
- /** How long the list stays after the mouse leaves, so it can travel from chip to list. */
9
- const HOVER_CLOSE_MS = 150;
10
5
  /** Keeps the list off the window's edge on a narrow screen. */
11
6
  const COLLISION_PAD = 8;
12
7
  const dot = (ok) => cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive');
@@ -22,20 +17,9 @@ function summary(probes) {
22
17
  return { ok: true, text: 'All up' };
23
18
  }
24
19
  export function StatusHealth({ probes, checked, error, }) {
25
- const [open, setOpen] = useState(false);
26
- const timer = useRef(undefined);
27
- useEffect(() => () => clearTimeout(timer.current), []);
28
- const hover = (on) => (e) => {
29
- if (e.pointerType !== 'mouse')
30
- return;
31
- clearTimeout(timer.current);
32
- if (on)
33
- setOpen(true);
34
- else
35
- timer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_MS);
36
- };
20
+ const { open, setOpen, hover, content: pop } = useHoverOpen();
37
21
  if (!probes.length && !error)
38
22
  return null;
39
23
  const s = error ? { ok: false, text: 'Check failed' } : summary(probes);
40
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsxs(PopoverTrigger, { "aria-label": `Services: ${s.text}`, className: "flex h-6 shrink-0 items-center gap-1.5 rounded-md px-1.5 hover:bg-muted hover:text-foreground", onPointerEnter: hover(true), onPointerLeave: hover(false), children: [_jsx("span", { "aria-hidden": true, className: dot(s.ok) }), "Services", _jsx("span", { className: cn('num', s.ok === false ? 'text-destructive' : 'text-foreground/80'), children: s.text })] }), _jsxs(PopoverContent, { side: "top", align: "end", collisionPadding: COLLISION_PAD, className: "w-auto min-w-56 gap-1.5 text-xs", onPointerEnter: hover(true), onPointerLeave: hover(false), onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsx("ul", { "aria-label": "Services", className: "flex flex-col gap-1", children: probes.map((p) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: dot(p.ok) }), _jsxs("span", { className: "flex-1", children: [p.name, p.note && _jsx("span", { className: "ml-1.5 text-muted-foreground", children: p.note })] }), _jsx("span", { className: cn('num', p.ok === false ? 'text-destructive' : 'text-foreground/80'), children: p.ok === false ? 'down' : p.ms != null ? `${p.ms} ms` : '—' })] }, p.name))) }), error && _jsx("p", { className: "text-destructive", children: error }), checked && (_jsxs("p", { className: "border-t pt-1.5 text-muted-foreground", children: ["Checked ", _jsx("span", { className: "num", children: checked })] }))] })] }));
24
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsxs(PopoverTrigger, { "aria-label": `Services: ${s.text}`, className: "flex h-6 shrink-0 items-center gap-1.5 rounded-md px-1.5 hover:bg-muted hover:text-foreground", ...hover, children: [_jsx("span", { "aria-hidden": true, className: dot(s.ok) }), "Services", _jsx("span", { className: cn('num', s.ok === false ? 'text-destructive' : 'text-foreground/80'), children: s.text })] }), _jsxs(PopoverContent, { side: "top", align: "end", collisionPadding: COLLISION_PAD, className: "w-auto min-w-56 gap-1.5 text-xs", ...pop, children: [_jsx("ul", { "aria-label": "Services", className: "flex flex-col gap-1", children: probes.map((p) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: dot(p.ok) }), _jsxs("span", { className: "flex-1", children: [p.name, p.note && _jsx("span", { className: "ml-1.5 text-muted-foreground", children: p.note })] }), _jsx("span", { className: cn('num', p.ok === false ? 'text-destructive' : 'text-foreground/80'), children: p.ok === false ? 'down' : p.ms != null ? `${p.ms} ms` : '—' })] }, p.name))) }), error && _jsx("p", { className: "text-destructive", children: error }), checked && (_jsxs("p", { className: "border-t pt-1.5 text-muted-foreground", children: ["Checked ", _jsx("span", { className: "num", children: checked })] }))] })] }));
41
25
  }
@@ -0,0 +1,25 @@
1
+ export type StatusMessageTone = 'info' | 'success' | 'warning' | 'error';
2
+ export interface StatusMessage {
3
+ text: string;
4
+ /** Colours the text and its dot: error red, warning amber, success green; info has no dot. */
5
+ tone?: StatusMessageTone;
6
+ /** How many times it was posted, for an app that counts repeats itself; repeats in the list add up too. */
7
+ count?: number;
8
+ }
9
+ interface Group extends Required<StatusMessage> {
10
+ count: number;
11
+ }
12
+ /** One entry per distinct message (same tone, same words), counted, most severe first. */
13
+ export declare function groupStatusMessages(messages: readonly StatusMessage[]): Group[];
14
+ /**
15
+ * The app's messages in the status bar. Each message is one line that ends in an ellipsis past
16
+ * `--status-message-max`, with its full text on hover or tap; a repeated message shows once with
17
+ * `×N`; past `max` messages (one on a phone), a `+N` chip lists the rest.
18
+ */
19
+ export declare function StatusMessages({ messages, max, className, }: {
20
+ messages: readonly StatusMessage[];
21
+ /** How many messages show at once on a wider screen; the rest go behind the `+N` chip. */
22
+ max?: number;
23
+ className?: string;
24
+ }): import("react").JSX.Element | null;
25
+ export {};
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useHoverOpen } from './hooks/use-hover-open.js';
3
+ import { useIsMobile } from './hooks/use-mobile.js';
4
+ import { cn } from './lib/utils.js';
5
+ import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
6
+ /** Errors first, then warnings, so the message that matters most is the one in view. */
7
+ const RANK = { error: 0, warning: 1, success: 2, info: 3 };
8
+ const TEXT = {
9
+ error: 'text-destructive',
10
+ warning: 'text-warning',
11
+ success: undefined,
12
+ info: undefined,
13
+ };
14
+ const DOT = {
15
+ error: 'bg-destructive',
16
+ warning: 'bg-warning',
17
+ success: 'bg-gain',
18
+ info: undefined,
19
+ };
20
+ /** One entry per distinct message (same tone, same words), counted, most severe first. */
21
+ export function groupStatusMessages(messages) {
22
+ const groups = new Map();
23
+ for (const m of messages) {
24
+ const text = m.text.replace(/\s+/g, ' ').trim();
25
+ if (!text)
26
+ continue;
27
+ const tone = m.tone ?? 'info';
28
+ const key = `${tone}\u0000${text}`;
29
+ const count = Math.max(1, m.count ?? 1);
30
+ const g = groups.get(key);
31
+ if (g)
32
+ g.count += count;
33
+ else
34
+ groups.set(key, { text, tone, count });
35
+ }
36
+ // Map keeps first-seen order; the sort is stable, so equal tones keep it.
37
+ return [...groups.values()].sort((a, b) => RANK[a.tone] - RANK[b.tone]);
38
+ }
39
+ const label = (g) => (g.count > 1 ? `${g.text} (×${g.count})` : g.text);
40
+ function Dot({ tone }) {
41
+ const color = DOT[tone];
42
+ return color ? _jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', color) }) : null;
43
+ }
44
+ /** Keeps the pop-up off the window's edge on a narrow screen. */
45
+ const COLLISION_PAD = 8;
46
+ /**
47
+ * A status bar trigger whose pop-up opens on hover with a mouse and on tap with a finger or
48
+ * keyboard, like the services chip, so a phone can read the full text too (a tooltip never opens
49
+ * on touch). Styled on its own classes only: a trigger owns `data-state`.
50
+ */
51
+ function Pop({ label: name, className, children, content, }) {
52
+ const { open, setOpen, hover, content: pop } = useHoverOpen();
53
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { "aria-label": name, className: className, ...hover, children: children }), _jsx(PopoverContent, { side: "top", align: "start", collisionPadding: COLLISION_PAD, className: "w-auto max-w-sm gap-1.5 text-xs break-words", ...pop, children: content })] }));
54
+ }
55
+ const TRIGGER = 'flex h-6 items-center gap-1.5 rounded-md px-1.5 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none';
56
+ function Line({ group }) {
57
+ return (_jsxs(Pop, { className: cn(TRIGGER, 'min-w-0 max-w-(--status-message-max)'), content: _jsx("p", { className: TEXT[group.tone], children: label(group) }), children: [_jsx(Dot, { tone: group.tone }), _jsx("span", { className: cn('min-w-0 truncate', TEXT[group.tone]), children: group.text }), group.count > 1 && (_jsxs("span", { className: "num shrink-0 text-foreground/80", "aria-label": `${group.count} times`, children: ["\u00D7", group.count] }))] }));
58
+ }
59
+ /**
60
+ * The app's messages in the status bar. Each message is one line that ends in an ellipsis past
61
+ * `--status-message-max`, with its full text on hover or tap; a repeated message shows once with
62
+ * `×N`; past `max` messages (one on a phone), a `+N` chip lists the rest.
63
+ */
64
+ export function StatusMessages({ messages, max = 2, className, }) {
65
+ const phone = useIsMobile();
66
+ const groups = groupStatusMessages(messages);
67
+ if (!groups.length)
68
+ return null;
69
+ const shown = groups.slice(0, phone ? 1 : Math.max(1, max));
70
+ const rest = groups.slice(shown.length);
71
+ return (_jsxs("span", { "aria-live": "polite", className: cn('flex min-w-0 items-center gap-2', className), children: [shown.map((g) => (_jsx(Line, { group: g }, `${g.tone}:${g.text}`))), rest.length > 0 && (_jsxs(Pop, { label: `${rest.length} more messages`, className: cn(TRIGGER, 'num shrink-0 text-foreground/80'), content: _jsx("ul", { className: "flex flex-col gap-1", children: rest.map((g) => (_jsxs("li", { className: "flex items-start gap-1.5", children: [_jsx("span", { className: "flex h-4 items-center", children: _jsx(Dot, { tone: g.tone }) }), _jsx("span", { className: TEXT[g.tone], children: label(g) })] }, `${g.tone}:${g.text}`))) }), children: ["+", rest.length] }))] }));
72
+ }
package/dist/theme.css CHANGED
@@ -88,6 +88,9 @@
88
88
  --shell-assistant-width: 26rem;
89
89
  --shell-header-height: 48px;
90
90
  --shell-status-height: 32px;
91
+ /* A status bar message ends in an ellipsis here (StatusMessages), so it fits beside the bar's
92
+ other items; the full text opens on hover or tap. A phone shows one message, a wider screen two. */
93
+ --status-message-max: 45vw;
91
94
  --tile-width: 8.5rem;
92
95
  --shell-gutter: 12px;
93
96
  --shell-section-gap: 12px;
@@ -209,6 +212,7 @@
209
212
  @media (min-width: 768px) {
210
213
  :root {
211
214
  --shell-gutter: 16px;
215
+ --status-message-max: min(28rem, 30vw);
212
216
  }
213
217
  }
214
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.113",
3
+ "version": "0.1.114",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.113",
52
+ "@columenlabs/lumen-ui-types": "0.1.114",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",