@columenlabs/lumen-ui 0.1.308 → 0.1.310

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/dist/index.d.ts CHANGED
@@ -41,6 +41,7 @@ export { DetailsTable, type DetailsTableProps, FactsTable } from './details-tabl
41
41
  export { DrawerSection, type DrawerSectionProps } from './drawer-section.js';
42
42
  export { type DrawerAction, LumenDrawer, type LumenDrawerProps } from './lumen-drawer.js';
43
43
  export { LumenBottomSheet, type LumenBottomSheetProps, type SheetSnap } from './lumen-bottom-sheet.js';
44
+ export { type FloatingRect, LumenFloatingPanel, type LumenFloatingPanelProps, } from './lumen-floating-panel.js';
44
45
  export { LumenCode, type LumenCodeProps, langOfPath } from './lumen-code.js';
45
46
  export { LumenCodeEditor, type LumenCodeEditorProps, type LumenCodeLang, } from './lumen-code-editor.js';
46
47
  export { RenameField, type RenameFieldProps } from './rename-field.js';
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ export { DetailsTable, FactsTable } from './details-table.js';
48
48
  export { DrawerSection } from './drawer-section.js';
49
49
  export { LumenDrawer } from './lumen-drawer.js';
50
50
  export { LumenBottomSheet } from './lumen-bottom-sheet.js';
51
+ export { LumenFloatingPanel, } from './lumen-floating-panel.js';
51
52
  export { LumenCode, langOfPath } from './lumen-code.js';
52
53
  export { LumenCodeEditor, } from './lumen-code-editor.js';
53
54
  export { RenameField } from './rename-field.js';
@@ -13,6 +13,11 @@ export interface LumenBottomSheetProps {
13
13
  onSnap: (snap: SheetSnap) => void;
14
14
  /** Its name for screen readers. */
15
15
  title: string;
16
+ /**
17
+ * Its top row (tabs, a title): shown at every snap, and part of the drag area with the grab bar,
18
+ * so a drag that starts anywhere on it moves the sheet. Taps on its buttons still reach them.
19
+ */
20
+ header?: React.ReactNode;
16
21
  /** What it holds. It stays mounted at every snap, so running work inside keeps going. */
17
22
  children: React.ReactNode;
18
23
  className?: string;
@@ -22,4 +27,4 @@ export interface LumenBottomSheetProps {
22
27
  * drags above it reach the page. The person drags its handle between `snaps` (peek, half, full);
23
28
  * it never closes, so what it holds is never unmounted. Built on vaul's snap points.
24
29
  */
25
- export declare function LumenBottomSheet({ snaps, snap, onSnap, title, children, className, }: LumenBottomSheetProps): React.JSX.Element;
30
+ export declare function LumenBottomSheet({ snaps, snap, onSnap, title, header, children, className, }: LumenBottomSheetProps): React.JSX.Element;
@@ -46,7 +46,7 @@ function useScreenHeight() {
46
46
  * drags above it reach the page. The person drags its handle between `snaps` (peek, half, full);
47
47
  * it never closes, so what it holds is never unmounted. Built on vaul's snap points.
48
48
  */
49
- export function LumenBottomSheet({ snaps, snap, onSnap, title, children, className, }) {
49
+ export function LumenBottomSheet({ snaps, snap, onSnap, title, header, children, className, }) {
50
50
  const h = useScreenHeight();
51
51
  const inset = useSafeBottom();
52
52
  // A px snap rests above the home bar: vaul gets it with the bar's height added.
@@ -63,7 +63,10 @@ export function LumenBottomSheet({ snaps, snap, onSnap, title, children, classNa
63
63
  const [typing, setTyping] = React.useState(false);
64
64
  const lifted = typing && kb ? Math.min(rested, kb.height) : undefined;
65
65
  const shown = lifted ?? rested;
66
- return (_jsx(Drawer.Root, { open: true, modal: false, dismissible: false, handleOnly: true, snapPoints: rests, activeSnapPoint: rests[at] ?? rest(snap), setActiveSnapPoint: (s) => {
66
+ return (_jsx(Drawer.Root, { open: true, modal: false, dismissible: false,
67
+ // The grab bar and the header drag it; the body is marked no-drag, so its lists and chat
68
+ // scroll and never move the sheet. (Only the 4px bar used to drag, so most swipes missed.)
69
+ snapPoints: rests, activeSnapPoint: rests[at] ?? rest(snap), setActiveSnapPoint: (s) => {
67
70
  const i = s == null ? -1 : rests.indexOf(s);
68
71
  const next = snaps[i];
69
72
  if (next !== undefined)
@@ -75,5 +78,5 @@ export function LumenBottomSheet({ snaps, snap, onSnap, title, children, classNa
75
78
  setTyping(false);
76
79
  }, "data-lifted": lifted === undefined ? undefined : '', style: lifted === undefined || !kb
77
80
  ? undefined
78
- : { top: kb.top + kb.height - lifted, bottom: 'auto', height: lifted }, className: cn('fixed inset-x-0 bottom-0 z-40 flex h-dvh flex-col rounded-t-2xl border-t bg-popover text-popover-foreground shadow-[0_-4px_16px_rgb(0_0_0/0.15)] outline-none', className), children: [_jsx(Drawer.Title, { className: "sr-only", children: title }), _jsxs("div", { className: "flex min-h-0 flex-col overflow-hidden", style: { height: shown }, children: [_jsx(Drawer.Handle, { "aria-label": "Drag to resize", className: "mx-auto! my-2! h-1! w-9! shrink-0 rounded-full! bg-border! opacity-100!" }), _jsx("div", { className: cn('flex min-h-0 flex-1 flex-col', lifted === undefined && 'pb-[env(safe-area-inset-bottom)]'), children: children })] })] }) }) }) }));
81
+ : { top: kb.top + kb.height - lifted, bottom: 'auto', height: lifted }, className: cn('fixed inset-x-0 bottom-0 z-40 flex h-dvh flex-col rounded-t-2xl border-t bg-popover text-popover-foreground shadow-[0_-4px_16px_rgb(0_0_0/0.15)] outline-none', className), children: [_jsx(Drawer.Title, { className: "sr-only", children: title }), _jsxs("div", { className: "flex min-h-0 flex-col overflow-hidden", style: { height: shown }, children: [_jsx("div", { className: "flex h-5 shrink-0 items-center justify-center", children: _jsx(Drawer.Handle, { "aria-label": "Drag to resize", className: "m-0! h-1! w-9! shrink-0 rounded-full! bg-border! opacity-100!" }) }), header !== undefined && _jsx("div", { className: "shrink-0", children: header }), _jsx("div", { "data-vaul-no-drag": "", className: cn('flex min-h-0 flex-1 flex-col', lifted === undefined && 'pb-[env(safe-area-inset-bottom)]'), children: children })] })] }) }) }) }));
79
82
  }
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ /** Where the panel sits in its box, and how big it is, px. */
3
+ export interface FloatingRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface LumenFloatingPanelProps {
10
+ /** Its name for screen readers, and the fold button's tooltip. */
11
+ title: string;
12
+ /** Its top row (tabs, a title), between the grip and the fold button. Shown folded too. */
13
+ header?: React.ReactNode;
14
+ /** What it holds. It stays mounted while folded, so running work inside keeps going. */
15
+ children: React.ReactNode;
16
+ /** Where it starts on a device that has not moved it yet. */
17
+ defaultRect?: Partial<FloatingRect>;
18
+ /** Kept on this device under this key: where the person left it, its size and whether it is folded. */
19
+ storageKey?: string;
20
+ /** Folded to its header, controlled (left out, it keeps its own and the device remembers it). */
21
+ folded?: boolean;
22
+ onFold?: (folded: boolean) => void;
23
+ minWidth?: number;
24
+ minHeight?: number;
25
+ className?: string;
26
+ }
27
+ /**
28
+ * A see-through panel that floats over a live page (a 3D view, a map) inside its parent: the person
29
+ * drags it by its grip, resizes it from its edges and corner, and folds it to its header. Its box is
30
+ * the parent (which must be `relative`), and it is kept inside it when the box changes size (a phone
31
+ * turning). Taps outside it reach the page. Built on react-rnd.
32
+ */
33
+ export declare function LumenFloatingPanel({ title, header, children, defaultRect, storageKey, folded: foldedProp, onFold, minWidth, minHeight, className, }: LumenFloatingPanelProps): React.JSX.Element;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDownIcon, ChevronUpIcon, GripVerticalIcon } from 'lucide-react';
3
+ import * as React from 'react';
4
+ import { Rnd } from 'react-rnd';
5
+ import { cn } from './lib/utils.js';
6
+ import { Button } from './ui/button.js';
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
8
+ const DEFAULT = { x: 12, y: 12, width: 330, height: 300 };
9
+ function load(key) {
10
+ if (!key)
11
+ return {};
12
+ try {
13
+ return JSON.parse(localStorage.getItem(key) ?? '{}') ?? {};
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ /**
20
+ * A see-through panel that floats over a live page (a 3D view, a map) inside its parent: the person
21
+ * drags it by its grip, resizes it from its edges and corner, and folds it to its header. Its box is
22
+ * the parent (which must be `relative`), and it is kept inside it when the box changes size (a phone
23
+ * turning). Taps outside it reach the page. Built on react-rnd.
24
+ */
25
+ export function LumenFloatingPanel({ title, header, children, defaultRect, storageKey, folded: foldedProp, onFold, minWidth = 240, minHeight = 160, className, }) {
26
+ const saved = React.useMemo(() => load(storageKey), [storageKey]);
27
+ const [rect, setRect] = React.useState(saved.rect ?? { ...DEFAULT, ...defaultRect });
28
+ const [foldedOwn, setFoldedOwn] = React.useState(!!saved.folded);
29
+ const folded = foldedProp ?? foldedOwn;
30
+ const setFolded = (f) => {
31
+ setFoldedOwn(f);
32
+ onFold?.(f);
33
+ };
34
+ const box = React.useRef(null);
35
+ const [room, setRoom] = React.useState(null);
36
+ React.useLayoutEffect(() => {
37
+ const el = box.current;
38
+ if (!el)
39
+ return;
40
+ const read = () => setRoom({ w: el.clientWidth, h: el.clientHeight });
41
+ read();
42
+ const seen = new ResizeObserver(read);
43
+ seen.observe(el);
44
+ return () => seen.disconnect();
45
+ }, []);
46
+ React.useEffect(() => {
47
+ if (!storageKey)
48
+ return;
49
+ try {
50
+ localStorage.setItem(storageKey, JSON.stringify({ rect, folded }));
51
+ }
52
+ catch { }
53
+ }, [storageKey, rect, folded]);
54
+ // Inside the box whatever its size now: smaller than the box, and moved back in if it hangs out.
55
+ const fit = (r) => {
56
+ if (!room)
57
+ return r;
58
+ const width = Math.max(Math.min(minWidth, room.w), Math.min(r.width, room.w));
59
+ const height = Math.max(Math.min(minHeight, room.h), Math.min(r.height, room.h));
60
+ return {
61
+ width,
62
+ height,
63
+ x: Math.max(0, Math.min(r.x, room.w - width)),
64
+ y: Math.max(0, Math.min(r.y, room.h - height)),
65
+ };
66
+ };
67
+ const shown = fit(rect);
68
+ return (_jsx("div", { ref: box, className: "pointer-events-none absolute inset-0 z-20", children: room && (_jsx(Rnd, { bounds: "parent", position: { x: shown.x, y: shown.y }, size: { width: shown.width, height: folded ? 'auto' : shown.height }, minWidth: minWidth, minHeight: folded ? undefined : minHeight, dragHandleClassName: "lumen-float-grip", enableResizing: folded
69
+ ? false
70
+ : { right: true, bottom: true, bottomRight: true, left: true, bottomLeft: true },
71
+ // A finger needs a fuller edge than a mouse: 12px edges, a 24px corner.
72
+ resizeHandleStyles: {
73
+ right: { width: 12, right: -6 },
74
+ left: { width: 12, left: -6 },
75
+ bottom: { height: 12, bottom: -6 },
76
+ bottomRight: { width: 24, height: 24, right: -6, bottom: -6 },
77
+ bottomLeft: { width: 24, height: 24, left: -6, bottom: -6 },
78
+ }, onDragStop: (_e, d) => setRect((r) => ({ ...r, x: d.x, y: d.y })), onResizeStop: (_e, _dir, el, _delta, pos) => setRect({ x: pos.x, y: pos.y, width: el.offsetWidth, height: el.offsetHeight }), className: "pointer-events-auto", children: _jsxs("section", { "data-slot": "lumen-floating-panel", "aria-label": title, className: cn('flex h-full flex-col overflow-hidden rounded-xl border bg-popover/85 text-popover-foreground shadow-lg backdrop-blur-md', className), children: [_jsxs("div", { className: "flex shrink-0 items-center gap-0.5 border-b pr-1", children: [_jsx("span", { className: "lumen-float-grip flex h-11 w-8 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground active:cursor-grabbing", "aria-hidden": true, children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: header }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": folded ? `Open ${title}` : `Fold ${title}`, "aria-expanded": !folded, onClick: () => setFolded(!folded), children: folded ? _jsx(ChevronDownIcon, {}) : _jsx(ChevronUpIcon, {}) }) }), _jsx(TooltipContent, { children: folded ? `Open ${title}` : `Fold ${title}` })] })] }), _jsx("div", { className: cn('flex min-h-0 flex-1 flex-col', folded && 'hidden'), children: children })] }) })) }));
79
+ }
@@ -12,10 +12,16 @@ export interface LumenTab<V extends string = string> {
12
12
  /** Left out of the row (a tab this page does not have). */
13
13
  hidden?: boolean;
14
14
  }
15
- export declare function LumenTabs<V extends string>({ tabs, value, onChange, actions, }: {
15
+ export declare function LumenTabs<V extends string>({ tabs, value, onChange, actions, variant, }: {
16
16
  tabs: LumenTab<V>[];
17
17
  value: V | undefined;
18
18
  onChange: (value: V) => void;
19
19
  /** What only the open tab uses, at the row's right end. */
20
20
  actions?: ReactNode;
21
+ /**
22
+ * `row` (default): icon and name side by side, the rest in More. `stacked`: a phone panel's bar,
23
+ * the tabs sharing the row evenly, each its icon over a short name. `icons`: icons only, each
24
+ * named in its tooltip, for a small floating panel.
25
+ */
26
+ variant?: 'row' | 'stacked' | 'icons';
21
27
  }): import("react").JSX.Element;
@@ -10,12 +10,26 @@ import { useLayoutEffect, useRef, useState } from 'react';
10
10
  import { cn } from './lib/utils.js';
11
11
  import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
12
12
  import { Tabs, TabsList, TabsTrigger } from './ui/tabs.js';
13
+ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
13
14
  /** The row's gap between tabs (`gap-6` in TabsList). */
14
15
  const GAP = 24;
15
16
  /** The More button, drawn like a tab. */
16
17
  const MORE = 'relative inline-flex shrink-0 items-center gap-1.5 pb-2 text-sm leading-4 font-medium whitespace-nowrap outline-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full hover:text-foreground focus-visible:rounded-sm focus-visible:ring-3 focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0';
17
- export function LumenTabs({ tabs, value, onChange, actions, }) {
18
+ export function LumenTabs({ tabs, value, onChange, actions, variant = 'row', }) {
18
19
  const shown = tabs.filter((t) => !t.hidden);
20
+ if (variant !== 'row')
21
+ return (_jsx(EvenTabs, { tabs: shown, value: value, onChange: onChange, actions: actions, icons: variant === 'icons' }));
22
+ return _jsx(RowTabs, { tabs: shown, value: value, onChange: onChange, actions: actions });
23
+ }
24
+ /** A bar of tabs that share the row evenly: icon over name, or icons alone with a tooltip. */
25
+ function EvenTabs({ tabs, value, onChange, actions, icons, }) {
26
+ return (_jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [_jsx(Tabs, { value: value, onValueChange: (v) => onChange(v), className: "min-w-0 flex-1", children: _jsx(TabsList, { className: "w-full gap-0", children: tabs.map((t) => {
27
+ const name = typeof t.label === 'string' ? t.label : undefined;
28
+ const trigger = (_jsxs(TabsTrigger, { value: t.value, "aria-label": icons ? name : undefined, className: cn('relative min-h-11 min-w-0 flex-1 justify-center py-1 after:inset-x-3', !icons && 'flex-col gap-0.5 text-xs leading-3 [&_svg]:size-5'), children: [_jsx(t.icon, {}), !icons && _jsx("span", { className: "max-w-full truncate", children: t.label }), t.after && (_jsx("span", { className: cn('text-xs', !icons ? 'absolute top-0.5 left-1/2 ml-2' : ''), children: t.after }))] }, t.value));
29
+ return icons ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: trigger }), _jsx(TooltipContent, { children: t.label })] }, t.value)) : (trigger);
30
+ }) }) }), actions] }));
31
+ }
32
+ function RowTabs({ tabs: shown, value, onChange, actions, }) {
19
33
  const box = useRef(null);
20
34
  const ruler = useRef(null);
21
35
  // How many tabs fit before the More menu; all of them until measured.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.308",
3
+ "version": "0.1.310",
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.308",
52
+ "@columenlabs/lumen-ui-types": "0.1.310",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",
@@ -75,6 +75,7 @@
75
75
  "react-intersection-observer": "9.16.0",
76
76
  "react-markdown": "10.1.0",
77
77
  "react-resizable-panels": "^4.14.3",
78
+ "react-rnd": "10.5.3",
78
79
  "react-zoom-pan-pinch": "4.2.0",
79
80
  "recharts": "^3.10.1",
80
81
  "remark-gfm": "4.0.1",