@columenlabs/lumen-ui 0.1.75 → 0.1.77

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,19 @@
1
+ /**
2
+ * Phone back closes the open overlay (a Sheet, Dialog or AlertDialog), never the page under it.
3
+ * Opening pushes one history entry on the same URL; back pops it and closes. Closed any other way
4
+ * (X, a tap outside, a swipe, Esc, a pick) the entry is popped again, but only while it is still
5
+ * the current one, so a link followed from inside the overlay keeps its navigation.
6
+ */
7
+ export declare function useBackClose(open: boolean, close: () => void): void;
8
+ /**
9
+ * An overlay root's open state, controlled or not, with `close()` for its parts (the grab handle)
10
+ * and phone back wired in (useBackClose).
11
+ */
12
+ export declare function useOverlayOpen({ open: given, defaultOpen, onOpenChange, }: {
13
+ open?: boolean;
14
+ defaultOpen?: boolean;
15
+ onOpenChange?: (open: boolean) => void;
16
+ }): {
17
+ open: boolean;
18
+ onOpenChange: (o: boolean) => void;
19
+ };
@@ -0,0 +1,53 @@
1
+ import * as React from 'react';
2
+ import { useIsMobile } from './use-mobile.js';
3
+ const KEY = '__lumenOverlay';
4
+ /**
5
+ * Phone back closes the open overlay (a Sheet, Dialog or AlertDialog), never the page under it.
6
+ * Opening pushes one history entry on the same URL; back pops it and closes. Closed any other way
7
+ * (X, a tap outside, a swipe, Esc, a pick) the entry is popped again, but only while it is still
8
+ * the current one, so a link followed from inside the overlay keeps its navigation.
9
+ */
10
+ export function useBackClose(open, close) {
11
+ const phone = useIsMobile();
12
+ const closeRef = React.useRef(close);
13
+ closeRef.current = close;
14
+ React.useEffect(() => {
15
+ if (!phone || !open || typeof window === 'undefined')
16
+ return;
17
+ const id = Math.random().toString(36).slice(2);
18
+ let pushed = false;
19
+ let popped = false;
20
+ const onPop = () => {
21
+ if (window.history.state?.[KEY] === id)
22
+ return;
23
+ popped = true;
24
+ closeRef.current();
25
+ };
26
+ // Pushed a tick later, so a mount that is undone at once (React's strict mode) pushes nothing.
27
+ const timer = window.setTimeout(() => {
28
+ window.history.pushState({ ...window.history.state, [KEY]: id }, '');
29
+ pushed = true;
30
+ window.addEventListener('popstate', onPop);
31
+ }, 0);
32
+ return () => {
33
+ window.clearTimeout(timer);
34
+ window.removeEventListener('popstate', onPop);
35
+ if (pushed && !popped && window.history.state?.[KEY] === id)
36
+ window.history.back();
37
+ };
38
+ }, [phone, open]);
39
+ }
40
+ /**
41
+ * An overlay root's open state, controlled or not, with `close()` for its parts (the grab handle)
42
+ * and phone back wired in (useBackClose).
43
+ */
44
+ export function useOverlayOpen({ open: given, defaultOpen, onOpenChange, }) {
45
+ const [inner, setInner] = React.useState(defaultOpen ?? false);
46
+ const open = given ?? inner;
47
+ const change = React.useCallback((o) => {
48
+ setInner(o);
49
+ onOpenChange?.(o);
50
+ }, [onOpenChange]);
51
+ useBackClose(open, () => change(false));
52
+ return { open, onOpenChange: change };
53
+ }
package/dist/index.d.ts CHANGED
@@ -40,6 +40,7 @@ export { LumenDrawer, type LumenDrawerProps } from './lumen-drawer.js';
40
40
  export { LumenCode, type LumenCodeProps, langOfPath } from './lumen-code.js';
41
41
  export { LumenCodeEditor, type LumenCodeEditorProps, type LumenCodeLang, } from './lumen-code-editor.js';
42
42
  export { RenameField, type RenameFieldProps } from './rename-field.js';
43
+ export { LumenStackedList, type LumenStackedListProps, LumenStackedRow, type LumenStackedRowProps, type StackedColumn, } from './lumen-stacked-list.js';
43
44
  export { ResetButton, type ResetButtonProps } from './reset-button.js';
44
45
  export { type ChartBand, type ChartMarker, GrowthChart, KEY_DATE_KINDS, KeyDateChips, type KeyDateKind, type KeyDateMarker, PALETTE, type Scale, type Series, benchColour, keyDateMarkers, paletteAt, } from './growth-chart.js';
45
46
  export { FoldGroup, NavGroup, type NavItem, type SectionItem, type SectionSubItem, SidebarSection, } from './nav.js';
@@ -79,6 +80,7 @@ export { SourcesLine, useSourceHealth } from './source-health.js';
79
80
  export { TILE_WIDTH, Tile, TileChip, type TileOption, TileRow, TileSpark, TileToggleGroup, } from './tile.js';
80
81
  export { type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, useLumenFilter, } from './lumen-filter.js';
81
82
  export { useIsMobile } from './hooks/use-mobile.js';
83
+ export { useBackClose } from './hooks/use-back-close.js';
82
84
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
83
85
  export { type AppViewState, useAppView } from './app-views.js';
84
86
  export { type ScreenMap, type ScreenOutcome, type ScreenStatus, type WorkspaceOnScreen, type AssistantMove, type AssistantMoveHandlers, type AssistantPage, type AssistantPageType, AssistantScreenProvider, useAssistantMoves, useAssistantPages, useAssistantScreen, useAssistantWorkspace, } from './assistant-screen.js';
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ export { LumenDrawer } from './lumen-drawer.js';
47
47
  export { LumenCode, langOfPath } from './lumen-code.js';
48
48
  export { LumenCodeEditor, } from './lumen-code-editor.js';
49
49
  export { RenameField } from './rename-field.js';
50
+ export { LumenStackedList, LumenStackedRow, } from './lumen-stacked-list.js';
50
51
  export { ResetButton } from './reset-button.js';
51
52
  export { GrowthChart, KEY_DATE_KINDS, KeyDateChips, PALETTE, benchColour, keyDateMarkers, paletteAt, } from './growth-chart.js';
52
53
  export { FoldGroup, NavGroup, SidebarSection, } from './nav.js';
@@ -86,6 +87,7 @@ export { SourcesLine, useSourceHealth } from './source-health.js';
86
87
  export { TILE_WIDTH, Tile, TileChip, TileRow, TileSpark, TileToggleGroup, } from './tile.js';
87
88
  export { LumenTokenizedFilter, useLumenFilter, } from './lumen-filter.js';
88
89
  export { useIsMobile } from './hooks/use-mobile.js';
90
+ export { useBackClose } from './hooks/use-back-close.js';
89
91
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
90
92
  export { useAppView } from './app-views.js';
91
93
  export { AssistantScreenProvider, useAssistantMoves, useAssistantPages, useAssistantScreen, useAssistantWorkspace, } from './assistant-screen.js';
@@ -23,7 +23,7 @@ export function LumenDrawer({ open, onClose, title, description, children, foote
23
23
  // sheet's X already closes it and a second Close only took room.
24
24
  const plain = !footer && !footerStart;
25
25
  const foot = (_jsxs("footer", { "data-slot": "drawer-footer", className: "flex shrink-0 items-center gap-2 border-t bg-muted/50 px-4 py-2.5", children: [footerStart && (_jsx("div", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: footerStart })), _jsx("div", { className: "ml-auto flex items-center gap-2", children: footer ?? (_jsx(SheetClose, { asChild: true, children: _jsx(Button, { type: "button", size: "sm", variant: "outline", children: "Close" }) })) })] }));
26
- return (_jsx(Sheet, { open: open, onOpenChange: (o) => !o && onClose(), modal: isMobile, children: isMobile ? (_jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden", children: [_jsx(SheetHeader, { className: "order-first shrink-0 border-b", children: head }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), !plain && foot] })) : (_jsx(SheetContent, { side: "right", showCloseButton: false,
26
+ return (_jsx(Sheet, { open: open, onOpenChange: (o) => !o && onClose(), modal: isMobile, children: isMobile ? (_jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 overflow-hidden", children: [_jsx(SheetHeader, { className: "order-first shrink-0 border-b pt-3", children: head }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), !plain && foot] })) : (_jsx(SheetContent, { side: "right", showCloseButton: false,
27
27
  // The resize handle's grab zone is wider than the handle, so a drag can start on the
28
28
  // overlay; the panels library marks that pointerdown handled, and then it doesn't close.
29
29
  onPointerDownOutside: (e) => e.detail.originalEvent.defaultPrevented && e.preventDefault(), className: "pointer-events-none! gap-0 border-0 bg-transparent shadow-none data-[side=right]:left-0 data-[side=right]:w-auto data-[side=right]:border-l-0 data-[side=right]:sm:max-w-none", children: _jsxs(ResizablePanelGroup, { orientation: "horizontal", children: [_jsx(ResizablePanel, { minSize: "40%" }), _jsx(ResizableHandle, { withHandle: true, className: "pointer-events-auto" }), _jsx(ResizablePanel, { defaultSize: defaultSize, minSize: minSize, maxSize: maxSize, "data-slot": "lumen-drawer", className: "pointer-events-auto bg-popover shadow-lg", children: _jsxs("section", { className: "flex h-full flex-col overflow-hidden", children: [_jsxs("header", { className: "flex shrink-0 items-center gap-2 border-b px-4 py-3", children: [head, _jsx(SheetClose, { asChild: true, children: _jsx(IconButton, { icon: XIcon, label: "Close", className: "ml-auto" }) })] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: body }), foot] }) })] }) })) }));
@@ -565,7 +565,7 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
565
565
  setField(null);
566
566
  }, children: _jsxs(SheetContent, { side: "bottom", className: "max-h-[85svh] gap-0 rounded-t-xl",
567
567
  // The keyboard would cover the menu: search waits for a tap.
568
- onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "flex-row items-center pb-2", children: [_jsx(SheetTitle, { children: "Filters" }), _jsx(SheetDescription, { className: "sr-only", children: "Pick values to filter the rows." }), tokens.length > 0 && (_jsx(Button, { variant: "ghost", size: "sm", className: "mr-8 ml-auto font-normal text-muted-foreground", onClick: clear, children: "Clear all" }))] }), _jsxs(CommandPrimitive, { shouldFilter: false, loop: true, className: "flex min-h-0 flex-1 flex-col gap-2 px-3 pb-[max(1rem,env(safe-area-inset-bottom))]", children: [_jsx(CommandInput, { value: field ? search : text, onValueChange: field ? setSearch : setText, onKeyDown: onKeyDown, placeholder: searchHint, "aria-label": label, className: "text-base" }), _jsx(CommandList, { className: "max-h-none min-h-0 flex-1", "aria-label": "Filter suggestions", children: menu }), footer] })] }) })] }));
568
+ onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "flex-row items-center pb-2", children: [_jsx(SheetTitle, { children: "Filters" }), _jsx(SheetDescription, { className: "sr-only", children: "Pick values to filter the rows." }), tokens.length > 0 && (_jsx(Button, { variant: "ghost", size: "sm", className: "ml-auto font-normal text-muted-foreground", onClick: clear, children: "Clear all" }))] }), _jsxs(CommandPrimitive, { shouldFilter: false, loop: true, className: "flex min-h-0 flex-1 flex-col gap-2 px-3 pb-[max(1rem,env(safe-area-inset-bottom))]", children: [_jsx(CommandInput, { value: field ? search : text, onValueChange: field ? setSearch : setText, onKeyDown: onKeyDown, placeholder: searchHint, "aria-label": label, className: "text-base" }), _jsx(CommandList, { className: "max-h-none min-h-0 flex-1", "aria-label": "Filter suggestions", children: menu }), footer] })] }) })] }));
569
569
  }
570
570
  return (_jsxs(CommandPrimitive, { shouldFilter: false, className: cn('relative w-full', className), loop: true, children: [sizes, _jsxs(Popover, { open: open, onOpenChange: (o) => {
571
571
  setOpen(o);
@@ -264,9 +264,9 @@ export function LumenSelector(props) {
264
264
  if (phone) {
265
265
  // The trigger opens a bottom sheet; the list is the combobox inline in it.
266
266
  const opener = trigger === 'icon' ? (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, disabled: disabled, className: className, onClick: () => setOpen(true), children: LeadIcon && _jsx(LeadIcon, {}) })) : (_jsx("button", { type: "button", id: id, "data-size": size, "aria-label": label, disabled: disabled, className: cn(FIELD, multiple && 'h-auto min-h-8 flex-wrap justify-start py-1', trigger === 'chip' && 'rounded-full', className), onClick: () => setOpen(true), children: multiple ? (values.length ? (_jsxs("span", { className: "flex flex-1 flex-wrap gap-1", children: [values.slice(0, CHIPS).map((v) => (_jsx("span", { className: "rounded-sm bg-muted px-1.5 text-xs leading-[calc(--spacing(5.25))] font-medium", children: labelOf(v) }, v))), values.length > CHIPS && (_jsxs("span", { className: "rounded-sm bg-muted px-1.5 text-xs leading-[calc(--spacing(5.25))]", children: ["+", values.length - CHIPS] }))] })) : (_jsx("span", { className: "flex-1 text-left text-muted-foreground", children: placeholder }))) : (fieldInside) }));
267
- return (_jsxs(_Fragment, { children: [opener, _jsx(Sheet, { open: open, onOpenChange: setOpen, children: _jsxs(SheetContent, { side: "bottom", showCloseButton: false, "aria-labelledby": sheetTitle,
267
+ return (_jsxs(_Fragment, { children: [opener, _jsx(Sheet, { open: open, onOpenChange: setOpen, children: _jsxs(SheetContent, { side: "bottom", "aria-labelledby": sheetTitle,
268
268
  // No ring on the first button: the person taps what they want.
269
- onOpenAutoFocus: (e) => e.preventDefault(), className: "group/selector flex max-h-[85dvh] flex-col gap-2 rounded-t-xl pt-2", children: [_jsx("span", { "aria-hidden": true, className: "mx-auto h-1 w-9 rounded-full bg-border" }), _jsxs("div", { className: "flex items-center justify-between px-4", children: [_jsx(SheetTitle, { id: sheetTitle, children: label ?? placeholder }), multiple && (_jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" }))] }), _jsx(SheetDescription, { className: "sr-only", children: multiple ? 'Pick any, then Done' : 'Pick one' }), _jsxs(Base.Root, { ...rootProps, inline: true, open: true, onOpenChange: () => undefined, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [multiple && values.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-4", children: values.map((v) => (_jsx("span", { className: "rounded-sm bg-muted px-2 py-1 text-[13px]", children: labelOf(v) }, v))) })), searchable && searchBox, list] }), multiple && (_jsxs("div", { className: "flex items-center justify-between border-t px-4 pt-3 pb-5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked"] }), clearAll] }))] }) })] }));
269
+ onOpenAutoFocus: (e) => e.preventDefault(), className: "group/selector flex flex-col gap-2", children: [_jsxs("div", { className: "mt-2 flex min-h-8 items-center justify-between gap-2 pr-12 pl-4", children: [_jsx(SheetTitle, { id: sheetTitle, className: "truncate", children: label ?? placeholder }), multiple && (_jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" }))] }), _jsx(SheetDescription, { className: "sr-only", children: multiple ? 'Pick any, then Done' : 'Pick one' }), _jsxs(Base.Root, { ...rootProps, inline: true, open: true, onOpenChange: () => undefined, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [multiple && values.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-4", children: values.map((v) => (_jsx("span", { className: "rounded-sm bg-muted px-2 py-1 text-[13px]", children: labelOf(v) }, v))) })), searchable && searchBox, list] }), multiple && (_jsxs("div", { className: "flex items-center justify-between border-t px-4 pt-3 pb-5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked"] }), clearAll] }))] }) })] }));
270
270
  }
271
271
  const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side: side, sideOffset: 4, align: align, anchor: inChips ? chipsRef : undefined, className: "pointer-events-auto isolate z-[60]", children: _jsxs(Base.Popup, { "data-slot": "lumen-selector", className: "group/selector flex max-h-(--available-height) max-w-(--available-width) flex-col min-w-[max(var(--anchor-width),14rem)] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", children: [!inChips && searchBox, list, multiple && (_jsxs("div", { className: "flex shrink-0 items-center gap-3 border-t px-2 py-1.5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked", max !== undefined ? ` of ${max}` : ''] }), clearAll, _jsx(Button, { size: "xs", className: "ml-auto", onClick: () => setOpen(false), children: "Done" })] }))] }) }) }));
272
272
  return (_jsxs(Base.Root, { ...rootProps, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [inChips ? (_jsxs(Base.Chips, { ref: chipsRef, className: cn('flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', className), children: [chips, _jsx(Base.Input, { id: id, "aria-label": label, disabled: disabled, readOnly: !searchable, placeholder: values.length ? '' : placeholder, className: "min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground" })] })) : trigger === 'icon' ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Base.Trigger, { ref: triggerRef, render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { ref: triggerRef, id: id, "data-size": size, "aria-label": label, className: cn(FIELD, trigger === 'chip' && 'rounded-full', className), children: fieldInside })), _jsx("span", { ref: markRef, hidden: true }), popup] }));
@@ -0,0 +1,47 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface StackedColumn {
3
+ key: string;
4
+ /** The header over the column; the list draws a header row when any column or `lead` has one. */
5
+ label?: string;
6
+ /** Any CSS track size: `9rem`, `minmax(6rem, auto)`. */
7
+ width: string;
8
+ /** `end` for numbers and controls, so header, values and totals share one edge. */
9
+ align?: 'start' | 'end';
10
+ /** Shown on a phone, stacked at the row's end; other columns are left out there. */
11
+ phone?: boolean;
12
+ }
13
+ export interface LumenStackedListProps {
14
+ columns: StackedColumn[];
15
+ /** The header over the names. */
16
+ lead?: string;
17
+ /** One level that never folds (a short list in a card): no chevron space, compact rows. */
18
+ flat?: boolean;
19
+ children: ReactNode;
20
+ 'aria-label'?: string;
21
+ className?: string;
22
+ }
23
+ export declare function LumenStackedList({ columns, lead, flat, children, className, ...aria }: LumenStackedListProps): import("react").JSX.Element;
24
+ export interface LumenStackedRowProps {
25
+ /** The row's name: drawn on one line (an ellipsis past `--lead-col-max`) and its accessible name. */
26
+ name: string;
27
+ /** Drawn instead of `name`, such as a rename field; `name` stays the accessible name. */
28
+ title?: ReactNode;
29
+ /** Right after the name: a source chip, a ✎ mark. */
30
+ adornments?: ReactNode;
31
+ /** One muted line under the name. */
32
+ meta?: ReactNode;
33
+ /** One value per column, by `StackedColumn.key`. */
34
+ cells?: Record<string, ReactNode>;
35
+ /** Icons after the name, shown on hover or focus only. */
36
+ actions?: ReactNode;
37
+ /** What needs the person (a sign-in that broke): shown at the top while the row is open. */
38
+ notice?: ReactNode;
39
+ /** Rows under this one: LumenStackedRows. */
40
+ children?: ReactNode;
41
+ defaultOpen?: boolean;
42
+ open?: boolean;
43
+ onOpenChange?: (open: boolean) => void;
44
+ /** `muted` dims the row (an item that is off). */
45
+ tone?: 'default' | 'muted';
46
+ }
47
+ export declare function LumenStackedRow({ name, title, adornments, meta, cells, actions, notice, children, defaultOpen, open: openProp, onOpenChange, tone, }: LumenStackedRowProps): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // LumenStackedList: rows that nest and fold, with columns that line up at every level (a source,
3
+ // the groups inside it, the items inside those). Each row is one line: a name (with chips or a mark
4
+ // after it) over an optional muted meta line, then the list's columns. A row with children or a
5
+ // notice has a chevron and starts folded; its notice (what needs the person) shows only while it is
6
+ // open. Row actions sit after the name and show on hover or focus only (DESIGN.md, Actions: icon
7
+ // first). Nothing about what the rows mean lives here: an app fills names, cells and actions.
8
+ // On a phone the columns marked `phone` stack at the row's end and the rest are left out.
9
+ import { ChevronRightIcon } from 'lucide-react';
10
+ import { createContext, useContext, useMemo, useState, } from 'react';
11
+ import { cn } from './lib/utils.js';
12
+ import { Collapsible, CollapsibleContent } from './ui/collapsible.js';
13
+ const Ctx = createContext(null);
14
+ export function LumenStackedList({ columns, lead, flat = false, children, className, ...aria }) {
15
+ const ctx = useMemo(() => ({ columns, depth: 0, flat }), [columns, flat]);
16
+ const style = {
17
+ '--stack-cols': `minmax(0,1fr) ${columns.map((c) => c.width).join(' ')}`,
18
+ };
19
+ const head = lead !== undefined || columns.some((c) => c.label);
20
+ return (_jsx(Ctx.Provider, { value: ctx, children: _jsxs("div", { className: cn('flex min-w-0 flex-col text-sm', className), style: style, children: [head && (_jsxs("div", { "aria-hidden": true, className: "grid grid-cols-(--stack-cols) items-center gap-x-3 px-3 pb-1.5 text-xs font-medium text-muted-foreground max-sm:hidden", children: [_jsx("span", { className: cn(!flat && 'pl-7.5'), children: lead }), _jsx(Cells, { columns: columns, cells: Object.fromEntries(columns.map((c) => [c.key, c.label])) })] })), _jsx("ul", { className: "flex flex-col [&>li:first-child>[data-stack-row]]:border-t-0", ...aria, children: children })] }) }));
21
+ }
22
+ function Cells({ columns, cells, }) {
23
+ return (_jsx("div", { className: "contents max-sm:flex max-sm:flex-col max-sm:items-end max-sm:gap-0.5", children: columns.map((c) => (_jsx("div", { className: cn('flex min-w-0 items-center', c.align === 'end' && 'justify-end text-right', !c.phone && 'max-sm:hidden'), children: cells?.[c.key] }, c.key))) }));
24
+ }
25
+ const isControl = (t) => t instanceof Element &&
26
+ t.closest('button, a, input, select, [role="button"], [role="combobox"], [data-stack-skip]') !==
27
+ null;
28
+ export function LumenStackedRow({ name, title, adornments, meta, cells, actions, notice, children, defaultOpen = false, open: openProp, onOpenChange, tone = 'default', }) {
29
+ const ctx = useContext(Ctx);
30
+ if (!ctx)
31
+ throw new Error('LumenStackedRow outside LumenStackedList');
32
+ const { columns, depth, flat } = ctx;
33
+ const [own, setOwn] = useState(defaultOpen);
34
+ const open = openProp ?? own;
35
+ const setOpen = (o) => {
36
+ if (openProp === undefined)
37
+ setOwn(o);
38
+ onOpenChange?.(o);
39
+ };
40
+ const has = Boolean(children) || Boolean(notice);
41
+ const inner = useMemo(() => ({ columns, depth: depth + 1, flat }), [columns, depth, flat]);
42
+ const indent = { paddingInlineStart: `calc(${depth} * var(--spacing) * 6)` };
43
+ const line = (_jsxs("div", { "data-stack-row": true, className: cn('group/stack grid grid-cols-(--stack-cols) items-center gap-x-3 border-t px-3 max-sm:grid-cols-[minmax(0,1fr)_auto]', depth === 0 && !flat ? 'min-h-14 py-2' : 'min-h-10 py-1.5', depth === 1 && 'bg-muted/40', has && 'cursor-pointer', tone === 'muted' && 'text-muted-foreground'), onClick: has ? (e) => !isControl(e.target) && setOpen(!open) : undefined, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-1.5", style: indent, children: [has ? (_jsx("button", { type: "button", "aria-expanded": open, "aria-label": `${open ? 'Fold' : 'Unfold'} ${name}`, className: "grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted", onClick: () => setOpen(!open), children: _jsx(ChevronRightIcon, { className: cn('size-4 transition-transform', open && 'rotate-90') }) })) : flat ? null : (_jsx("span", { className: "w-6 shrink-0", "aria-hidden": true })), _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-1.5", children: [title ?? (_jsx("span", { title: name, className: cn('max-w-(--lead-col-max) truncate', depth < 2 && 'font-medium'), children: name })), adornments, actions && (_jsx("span", { className: "flex shrink-0 items-center opacity-0 transition-opacity group-focus-within/stack:opacity-100 group-hover/stack:opacity-100", children: actions }))] }), meta && _jsx("div", { className: "truncate text-xs text-muted-foreground", children: meta })] })] }), _jsx(Cells, { columns: columns, cells: cells })] }));
44
+ if (!has)
45
+ return _jsx("li", { children: line });
46
+ return (_jsx("li", { children: _jsxs(Collapsible, { open: open, onOpenChange: setOpen, children: [line, _jsxs(CollapsibleContent, { children: [notice && (_jsx("div", { className: "border-t px-3 py-2", style: {
47
+ paddingInlineStart: `calc(${depth + 1} * var(--spacing) * 6 + var(--spacing) * 3)`,
48
+ }, children: notice })), children && (_jsx(Ctx.Provider, { value: inner, children: _jsx("ul", { className: "flex flex-col", children: children }) }))] })] }) }));
49
+ }
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // A name renamed in place (for a broker link accounts; shared so
3
3
  // An app's bank accounts rename the same way): Enter or leaving the field saves, Escape undoes,
4
- // empty or ↺ goes back to the source's own name, shown as the placeholder.
4
+ // empty or ↺ goes back to the source's own name, shown as the placeholder. Quiet at rest
5
+ // (DESIGN.md, Actions: icon first): a renamed name keeps a ✎ mark, the rest shows on hover or focus.
5
6
  import { PencilIcon, RotateCcwIcon } from 'lucide-react';
6
7
  import { useState } from 'react';
8
+ import { cn } from './lib/utils.js';
7
9
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
8
10
  export function RenameField({ value, original, originLabel, onSave }) {
9
11
  const [typed, setTyped] = useState(value);
@@ -22,5 +24,5 @@ export function RenameField({ value, original, originLabel, onSave }) {
22
24
  setTyped(value);
23
25
  requestAnimationFrame(() => e.currentTarget?.blur());
24
26
  }
25
- }, className: "field-sizing-content min-w-0 px-2 text-sm" }), _jsxs(InputGroupAddon, { align: "inline-end", children: [_jsx(PencilIcon, { className: "size-3" }), value !== original && (_jsx(InputGroupButton, { size: "icon-xs", "aria-label": `Reset to ${original}`, title: `Reset to ${original}`, onClick: () => onSave(null), children: _jsx(RotateCcwIcon, {}) }))] })] }));
27
+ }, className: "field-sizing-content min-w-0 px-2 text-sm" }), _jsxs(InputGroupAddon, { align: "inline-end", children: [_jsx(PencilIcon, { className: cn('size-3 group-focus-within/input-group:opacity-100 group-hover/input-group:opacity-100', value === original && 'opacity-0') }), value !== original && (_jsx(InputGroupButton, { className: "opacity-0 group-focus-within/input-group:opacity-100 group-hover/input-group:opacity-100", size: "icon-xs", "aria-label": `Reset to ${original}`, title: `Reset to ${original}`, onClick: () => onSave(null), children: _jsx(RotateCcwIcon, {}) }))] })] }));
26
28
  }
package/dist/tasks.js CHANGED
@@ -186,7 +186,7 @@ export function TasksButton() {
186
186
  const trigger = (_jsxs(Button, { variant: "ghost", size: list.active ? 'sm' : 'icon-sm', className: cn('relative shrink-0', list.active > 0 && 'gap-1.5 px-2'), "aria-label": label, "data-slot": "tasks-button", children: [list.active > 0 ? _jsx(Ring, { share: share }) : _jsx(ListChecksIcon, {}), list.active > 0 && _jsx("span", { className: "font-num text-xs tabular-nums", children: list.active }), list.unseen > 0 && (_jsx("span", { "aria-hidden": true, "data-slot": "tasks-unseen", className: "absolute top-1 right-0.5 size-1.5 rounded-full bg-edge" }))] }));
187
187
  const body = _jsx(TaskPanel, { list: list, onClose: () => setOpen(false) });
188
188
  if (mobile) {
189
- return (_jsxs(Sheet, { open: open, onOpenChange: onOpen, children: [_jsx(SheetTrigger, { asChild: true, children: trigger }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[75svh] gap-0 rounded-t-2xl p-0", onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "flex-row items-baseline gap-2 border-b px-4 py-3", children: [_jsx(SheetTitle, { children: "Tasks" }), _jsx(SheetDescription, { children: "last 7 days" }), _jsxs("span", { className: "mr-8 ml-auto flex items-center gap-2 self-center", children: [list.active > 0 && (_jsxs("span", { className: "text-xs text-muted-foreground", children: [list.active, " running"] })), _jsx(ClearAll, { list: list })] })] }), body] })] }));
189
+ return (_jsxs(Sheet, { open: open, onOpenChange: onOpen, children: [_jsx(SheetTrigger, { asChild: true, children: trigger }), _jsxs(SheetContent, { side: "bottom", className: "max-h-[75svh] gap-0 rounded-t-2xl p-0", onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs(SheetHeader, { className: "flex-row items-baseline gap-2 border-b px-4 py-3", children: [_jsx(SheetTitle, { children: "Tasks" }), _jsx(SheetDescription, { children: "last 7 days" }), _jsxs("span", { className: "ml-auto flex items-center gap-2 self-center", children: [list.active > 0 && (_jsxs("span", { className: "text-xs text-muted-foreground", children: [list.active, " running"] })), _jsx(ClearAll, { list: list })] })] }), body] })] }));
190
190
  }
191
191
  return (_jsxs(Popover, { open: open, onOpenChange: onOpen, children: [_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(PopoverTrigger, { asChild: true, children: trigger }) }), _jsx(TooltipContent, { children: "Tasks" })] }), _jsxs(PopoverContent, { align: "end", className: "w-100 gap-0 p-0", "aria-label": "Tasks", onOpenAutoFocus: (e) => e.preventDefault(), children: [_jsxs("div", { className: "flex items-baseline gap-2 px-3.5 pt-3 pb-2", children: [_jsx("span", { className: "text-sm font-semibold", children: "Tasks" }), _jsx("span", { className: "text-xs text-muted-foreground", children: "last 7 days" }), _jsxs("span", { className: "ml-auto flex items-center gap-2 self-center", children: [list.active > 0 && (_jsxs("span", { className: "text-xs text-muted-foreground", children: [list.active, " running"] })), _jsx(ClearAll, { list: list })] })] }), body] })] }));
192
192
  }
@@ -1,7 +1,7 @@
1
1
  import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
2
2
  import type * as React from 'react';
3
3
  import { Button } from './button.js';
4
- declare function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>): React.JSX.Element;
4
+ declare function AlertDialog({ open: given, defaultOpen, onOpenChange: changed, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>): React.JSX.Element;
5
5
  declare function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>): React.JSX.Element;
6
6
  declare function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>): React.JSX.Element;
7
7
  declare function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>): React.JSX.Element;
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from '../lib/utils.js';
3
3
  import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
4
+ import { useOverlayOpen } from '../hooks/use-back-close.js';
4
5
  import { usePhoneScreen } from '../hooks/use-keyboard-fit.js';
5
6
  import { Button } from './button.js';
6
- function AlertDialog({ ...props }) {
7
- return _jsx(AlertDialogPrimitive.Root, { "data-slot": "alert-dialog", ...props });
7
+ function AlertDialog({ open: given, defaultOpen, onOpenChange: changed, ...props }) {
8
+ // Phone back closes it, as its Cancel does (useBackClose).
9
+ const state = useOverlayOpen({ open: given, defaultOpen, onOpenChange: changed });
10
+ return _jsx(AlertDialogPrimitive.Root, { "data-slot": "alert-dialog", ...state, ...props });
8
11
  }
9
12
  function AlertDialogTrigger({ ...props }) {
10
13
  return _jsx(AlertDialogPrimitive.Trigger, { "data-slot": "alert-dialog-trigger", ...props });
@@ -1,6 +1,6 @@
1
1
  import { Dialog as DialogPrimitive } from 'radix-ui';
2
2
  import type * as React from 'react';
3
- declare function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>): React.JSX.Element;
3
+ declare function Dialog({ open: given, defaultOpen, onOpenChange: changed, ...props }: React.ComponentProps<typeof DialogPrimitive.Root>): React.JSX.Element;
4
4
  declare function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>): React.JSX.Element;
5
5
  declare function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>): React.JSX.Element;
6
6
  declare function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>): React.JSX.Element;
package/dist/ui/dialog.js CHANGED
@@ -3,9 +3,12 @@ import { cn } from '../lib/utils.js';
3
3
  import { Dialog as DialogPrimitive } from 'radix-ui';
4
4
  import { Button } from './button.js';
5
5
  import { XIcon } from 'lucide-react';
6
+ import { useOverlayOpen } from '../hooks/use-back-close.js';
6
7
  import { usePhoneScreen } from '../hooks/use-keyboard-fit.js';
7
- function Dialog({ ...props }) {
8
- return _jsx(DialogPrimitive.Root, { "data-slot": "dialog", ...props });
8
+ function Dialog({ open: given, defaultOpen, onOpenChange: changed, ...props }) {
9
+ // Phone back closes it (useBackClose).
10
+ const state = useOverlayOpen({ open: given, defaultOpen, onOpenChange: changed });
11
+ return _jsx(DialogPrimitive.Root, { "data-slot": "dialog", ...state, ...props });
9
12
  }
10
13
  function DialogTrigger({ ...props }) {
11
14
  return _jsx(DialogPrimitive.Trigger, { "data-slot": "dialog-trigger", ...props });
@@ -22,10 +25,12 @@ function DialogOverlay({ className, ...props }) {
22
25
  function DialogContent({ className, children, showCloseButton = true, ...props }) {
23
26
  // On a phone it is a full screen pinned to the top, its footer a bar at the top (usePhoneScreen).
24
27
  const style = usePhoneScreen(props.style);
25
- return (_jsxs(DialogPortal, { children: [_jsx(DialogOverlay, {}), _jsxs(DialogPrimitive.Content, { "data-slot": "dialog-content", className: cn('fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-(--dialog-pad) text-sm [--dialog-pad:--spacing(4)] text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm max-md:inset-0 max-md:h-full max-md:max-h-none max-md:w-full max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 max-md:content-start max-md:overflow-y-auto max-md:rounded-none max-md:pt-[calc(var(--dialog-pad)+env(safe-area-inset-top))] max-md:ring-0 max-md:end-fade max-md:[--end-fade:var(--dialog-pad)] data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', className), ...props, style: style, children: [children, showCloseButton && (_jsx(DialogPrimitive.Close, { "data-slot": "dialog-close", asChild: true, children: _jsxs(Button, { variant: "ghost", className: "absolute top-2 right-2 max-md:top-[calc(1.25rem+env(safe-area-inset-top))]", size: "icon-sm", children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }) }))] })] }));
28
+ return (_jsxs(DialogPortal, { children: [_jsx(DialogOverlay, {}), _jsxs(DialogPrimitive.Content, { "data-slot": "dialog-content", "data-close": showCloseButton || undefined, className: cn('group/dialog fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-(--dialog-pad) text-sm [--dialog-pad:--spacing(4)] text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm max-md:inset-0 max-md:h-full max-md:max-h-none max-md:w-full max-md:max-w-none max-md:translate-x-0 max-md:translate-y-0 max-md:content-start max-md:overflow-y-auto max-md:rounded-none max-md:pt-[calc(var(--dialog-pad)+env(safe-area-inset-top))] max-md:ring-0 max-md:end-fade max-md:[--end-fade:var(--dialog-pad)] data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95', className), ...props, style: style, children: [children, showCloseButton && (_jsx(DialogPrimitive.Close, { "data-slot": "dialog-close", asChild: true, children: _jsxs(Button, { variant: "ghost", className: "absolute top-2 right-2 max-md:top-[calc(1.25rem+env(safe-area-inset-top))]", size: "icon-sm", children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }) }))] })] }));
26
29
  }
27
30
  function DialogHeader({ className, ...props }) {
28
- return (_jsx("div", { "data-slot": "dialog-header", className: cn('flex flex-col gap-2', className), ...props }));
31
+ return (_jsx("div", { "data-slot": "dialog-header",
32
+ // Its title stops short of the dialog's X, never running under it.
33
+ className: cn('flex flex-col gap-2 group-data-close/dialog:pr-8', className), ...props }));
29
34
  }
30
35
  /** A dialog's scrolling part: it reaches the dialog's edges, so its scroll bar sits on the frame and
31
36
  * the focus rings of its fields are never cut by the scroll box. It and the
@@ -1,10 +1,11 @@
1
1
  import { Dialog as SheetPrimitive } from 'radix-ui';
2
- import type * as React from 'react';
3
- declare function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>): React.JSX.Element;
2
+ import * as React from 'react';
3
+ declare function Sheet({ open: given, defaultOpen, onOpenChange: changed, ...props }: React.ComponentProps<typeof SheetPrimitive.Root>): React.JSX.Element;
4
4
  declare function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>): React.JSX.Element;
5
5
  declare function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>): React.JSX.Element;
6
6
  declare function SheetContent({ className, children, side, showCloseButton, ...props }: React.ComponentProps<typeof SheetPrimitive.Content> & {
7
7
  side?: 'top' | 'right' | 'bottom' | 'left';
8
+ /** Left out on desktop only: a phone's bottom sheet always has its X. */
8
9
  showCloseButton?: boolean;
9
10
  }): React.JSX.Element;
10
11
  declare function SheetHeader({ className, ...props }: React.ComponentProps<'div'>): React.JSX.Element;
package/dist/ui/sheet.js CHANGED
@@ -3,10 +3,64 @@ import { cn } from '../lib/utils.js';
3
3
  import { Dialog as SheetPrimitive } from 'radix-ui';
4
4
  import { Button } from './button.js';
5
5
  import { XIcon } from 'lucide-react';
6
+ import * as React from 'react';
7
+ import { useOverlayOpen } from '../hooks/use-back-close.js';
6
8
  import { useKeyboardFit } from '../hooks/use-keyboard-fit.js';
7
9
  import { useIsMobile } from '../hooks/use-mobile.js';
8
- function Sheet({ ...props }) {
9
- return _jsx(SheetPrimitive.Root, { "data-slot": "sheet", ...props });
10
+ /** Closes the sheet from inside it (the grab handle's swipe). */
11
+ const SheetCloseContext = React.createContext(() => undefined);
12
+ function Sheet({ open: given, defaultOpen, onOpenChange: changed, ...props }) {
13
+ // Phone back closes it (useBackClose).
14
+ const { open, onOpenChange } = useOverlayOpen({
15
+ open: given,
16
+ defaultOpen,
17
+ onOpenChange: changed,
18
+ });
19
+ const close = React.useCallback(() => onOpenChange(false), [onOpenChange]);
20
+ return (_jsx(SheetCloseContext.Provider, { value: close, children: _jsx(SheetPrimitive.Root, { "data-slot": "sheet", open: open, onOpenChange: onOpenChange, ...props }) }));
21
+ }
22
+ /** How far a swipe down on the grab handle must pull the sheet before letting go closes it. */
23
+ const SWIPE_CLOSE = 80;
24
+ /**
25
+ * A bottom sheet's grab handle on a phone: a wide strip at its top that drags the sheet down, and
26
+ * a pull past SWIPE_CLOSE (or a quick flick) closes it; a shorter one springs back.
27
+ */
28
+ function SheetGrab() {
29
+ const close = React.useContext(SheetCloseContext);
30
+ const drag = React.useRef(null);
31
+ const move = (dy, el) => {
32
+ el.style.transform = dy > 0 ? `translateY(${dy}px)` : '';
33
+ };
34
+ return (_jsx("div", { "data-slot": "sheet-grab", "aria-hidden": true, className: "absolute inset-x-0 top-0 z-10 flex h-6 cursor-grab touch-none justify-center pt-2", onPointerDown: (e) => {
35
+ const el = e.currentTarget.closest('[data-slot=sheet-content]');
36
+ if (!el)
37
+ return;
38
+ e.currentTarget.setPointerCapture(e.pointerId);
39
+ el.style.transition = 'none';
40
+ drag.current = { y: e.clientY, t: e.timeStamp, el };
41
+ }, onPointerMove: (e) => {
42
+ if (drag.current)
43
+ move(e.clientY - drag.current.y, drag.current.el);
44
+ }, onPointerUp: (e) => {
45
+ const d = drag.current;
46
+ drag.current = null;
47
+ if (!d)
48
+ return;
49
+ const dy = e.clientY - d.y;
50
+ const flick = dy > 24 && dy / Math.max(1, e.timeStamp - d.t) > 0.5;
51
+ d.el.style.transition = '';
52
+ if (dy > SWIPE_CLOSE || flick)
53
+ close();
54
+ else
55
+ move(0, d.el);
56
+ }, onPointerCancel: () => {
57
+ const d = drag.current;
58
+ drag.current = null;
59
+ if (d) {
60
+ d.el.style.transition = '';
61
+ move(0, d.el);
62
+ }
63
+ }, children: _jsx("span", { className: "h-1 w-9 rounded-full bg-border" }) }));
10
64
  }
11
65
  function SheetTrigger({ ...props }) {
12
66
  return _jsx(SheetPrimitive.Trigger, { "data-slot": "sheet-trigger", ...props });
@@ -21,36 +75,53 @@ function SheetOverlay({ className, ...props }) {
21
75
  return (_jsx(SheetPrimitive.Overlay, { "data-slot": "sheet-overlay", className: cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-y-0 left-0 right-(--assistant-docked,0px) z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', className), ...props }));
22
76
  }
23
77
  function SheetContent({ className, children, side = 'right', showCloseButton = true, ...props }) {
24
- // On a phone a bottom sheet is a full screen pinned to the top, like a Dialog (usePhoneScreen), so
25
- // the fields a person types in sit high. With the keyboard up a top or full screen sheet ends at
26
- // the keyboard; a side sheet spans only the visible part.
78
+ // On a phone a bottom sheet rises from the bottom and stops short of the top, so the dimmed page
79
+ // above it shows and a tap there closes it; it carries a grab handle (swipe down to close), its
80
+ // X and the safe areas. With the keyboard up a sheet ends at the keyboard; a top sheet or a phone
81
+ // bottom sheet starts at the top of what is visible.
27
82
  const kb = useKeyboardFit();
28
83
  const phone = useIsMobile();
29
- const screen = side === 'top' || (side === 'bottom' && phone);
84
+ const card = side === 'bottom' && phone;
85
+ const closable = showCloseButton || card;
30
86
  const style = !kb
31
87
  ? props.style
32
- : screen
88
+ : side === 'top'
33
89
  ? { ...props.style, top: kb.top, maxHeight: kb.height, height: kb.height }
34
- : side === 'bottom'
35
- ? { ...props.style, bottom: kb.below, maxHeight: kb.height - 8 }
36
- : { ...props.style, top: kb.top, height: kb.height };
37
- return (_jsxs(SheetPortal, { children: [_jsx(SheetOverlay, {}), _jsxs(SheetPrimitive.Content, { "data-slot": "sheet-content", "data-side": side, className: cn('group/sheet bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-(--assistant-docked,0px) data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:border-b max-md:data-[side=bottom]:top-0 max-md:data-[side=bottom]:h-full max-md:data-[side=bottom]:max-h-none max-md:data-[side=bottom]:rounded-none max-md:data-[side=bottom]:border-t-0 max-md:data-[side=bottom]:pt-[env(safe-area-inset-top)] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', className), ...props, style: style,
90
+ : card
91
+ ? {
92
+ ...props.style,
93
+ top: kb.top + CARD_GAP,
94
+ bottom: 'auto',
95
+ maxHeight: kb.height - CARD_GAP,
96
+ height: kb.height - CARD_GAP,
97
+ }
98
+ : side === 'bottom'
99
+ ? { ...props.style, bottom: kb.below, maxHeight: kb.height - 8 }
100
+ : { ...props.style, top: kb.top, height: kb.height };
101
+ return (_jsxs(SheetPortal, { children: [_jsx(SheetOverlay, {}), _jsxs(SheetPrimitive.Content, { "data-slot": "sheet-content", "data-side": side, "data-card": card || undefined, "data-close": closable || undefined, className: cn('group/sheet bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-(--assistant-docked,0px) data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=left]:pt-[env(safe-area-inset-top)] data-[side=right]:pt-[env(safe-area-inset-top)]', className,
102
+ // After the caller's classes: a phone's bottom sheet always keeps its shape and room.
103
+ card &&
104
+ 'max-h-[calc(100dvh-env(safe-area-inset-top)-2.75rem)] rounded-t-2xl border-t-0 pt-5'), ...props, style: style,
38
105
  // A click or focus in the docked the assistant panel leaves the sheet open, so the person can ask
39
106
  // about what it shows (LumenShell).
40
107
  onInteractOutside: (e) => {
41
108
  if (e.target?.closest?.('[data-assistant-docked]'))
42
109
  e.preventDefault();
43
110
  props.onInteractOutside?.(e);
44
- }, children: [children, showCloseButton && (_jsx(SheetPrimitive.Close, { "data-slot": "sheet-close", asChild: true, children: _jsxs(Button, { variant: "ghost", className: "absolute top-3 right-3 max-md:group-data-[side=bottom]/sheet:top-[calc(0.75rem+env(safe-area-inset-top))]", size: "icon-sm", children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }) }))] })] }));
111
+ }, children: [card && _jsx(SheetGrab, {}), children, card && (_jsx("div", { "aria-hidden": true, className: "h-[env(safe-area-inset-bottom)] shrink-0" })), closable && (_jsx(SheetPrimitive.Close, { "data-slot": "sheet-close", asChild: true, children: _jsxs(Button, { variant: "ghost", className: "absolute top-3 right-3 z-20 group-data-card/sheet:top-8 group-data-[side=left]/sheet:top-[calc(0.75rem+env(safe-area-inset-top))] group-data-[side=right]/sheet:top-[calc(0.75rem+env(safe-area-inset-top))]", size: "icon-sm", children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }) }))] })] }));
45
112
  }
113
+ /** Room a phone's bottom sheet leaves above it, px: the dimmed page a tap closes it on. */
114
+ const CARD_GAP = 44;
46
115
  function SheetHeader({ className, ...props }) {
47
- return (_jsx("div", { "data-slot": "sheet-header", className: cn('gap-0.5 p-4 flex flex-col', className), ...props }));
116
+ return (_jsx("div", { "data-slot": "sheet-header",
117
+ // Its title stops short of the sheet's X, never running under it.
118
+ className: cn('gap-0.5 p-4 flex flex-col group-data-close/sheet:pr-12', className), ...props }));
48
119
  }
49
120
  function SheetFooter({ className, ...props }) {
50
121
  return (_jsx("div", { "data-slot": "sheet-footer", className: cn('gap-2 p-4 mt-auto flex flex-col', className), ...props }));
51
122
  }
52
123
  function SheetTitle({ className, ...props }) {
53
- return (_jsx(SheetPrimitive.Title, { "data-slot": "sheet-title", className: cn('text-foreground text-base font-medium cn-font-heading', className), ...props }));
124
+ return (_jsx(SheetPrimitive.Title, { "data-slot": "sheet-title", className: cn('text-foreground text-base font-medium cn-font-heading min-w-0', className), ...props }));
54
125
  }
55
126
  function SheetDescription({ className, ...props }) {
56
127
  return (_jsx(SheetPrimitive.Description, { "data-slot": "sheet-description", className: cn('text-muted-foreground text-sm', className), ...props }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "@codemirror/lang-javascript": "6.2.5",
46
46
  "@codemirror/lang-json": "6.0.2",
47
47
  "@codemirror/lang-sql": "6.10.0",
48
- "@columenlabs/lumen-ui-types": "0.1.75",
48
+ "@columenlabs/lumen-ui-types": "0.1.77",
49
49
  "@dnd-kit/core": "6.3.1",
50
50
  "@dnd-kit/sortable": "10.0.0",
51
51
  "@dnd-kit/utilities": "3.2.2",