@columenlabs/lumen-ui 0.1.103 → 0.1.104

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.
@@ -47,6 +47,8 @@ interface Common<V extends string> {
47
47
  /** The create row's text; `Add "…"` by default. */
48
48
  createLabel?: (query: string) => string;
49
49
  placeholder?: string;
50
+ /** What the closed field shows instead of the picked option's label (a zone's short name, PT). */
51
+ display?: ReactNode;
50
52
  /** Shown when nothing matches. */
51
53
  empty?: string;
52
54
  /** Accessible name; also the phone sheet's title. */
@@ -149,7 +149,7 @@ function Rows({ shown, picked, multiple, full, expand, }) {
149
149
  const FIELD = "flex w-fit min-w-0 items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
150
150
  /** The one picker. See the file header for what every use gets. */
151
151
  export function LumenSelector(props) {
152
- const { options, groups, trigger = 'field', icon: LeadIcon, search = 'auto', create, createLabel = (q) => `Add “${q}”`, placeholder = 'Pick one', empty = 'Nothing matches', label, size = 'default', className, disabled, header, align = 'start', id, } = props;
152
+ const { options, groups, trigger = 'field', icon: LeadIcon, search = 'auto', create, createLabel = (q) => `Add “${q}”`, placeholder = 'Pick one', display, empty = 'Nothing matches', label, size = 'default', className, disabled, header, align = 'start', id, } = props;
153
153
  const multiple = props.multiple === true;
154
154
  const values = multiple
155
155
  ? props.value
@@ -236,7 +236,11 @@ export function LumenSelector(props) {
236
236
  };
237
237
  const selected = multiple ? null : (byValue.get(values[0] ?? '') ?? null);
238
238
  const Lead = selected?.icon ?? LeadIcon;
239
- const fieldInside = (_jsxs(_Fragment, { children: [Lead && _jsx(Lead, { className: "text-muted-foreground" }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-left', !selected && !values.length && 'text-muted-foreground'), children: selected ? (selected.content ?? selected.label) : values[0] ? values[0] : placeholder }), _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" })] }));
239
+ const fieldInside = (_jsxs(_Fragment, { children: [Lead && _jsx(Lead, { className: "text-muted-foreground" }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-left', !selected && !values.length && 'text-muted-foreground'), children: selected
240
+ ? (display ?? selected.content ?? selected.label)
241
+ : values[0]
242
+ ? values[0]
243
+ : placeholder }), _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" })] }));
240
244
  // On a desktop the popup is a column as tall as the screen allows: the search, header and
241
245
  // footer keep their height and the list between them scrolls.
242
246
  const list = (_jsxs(_Fragment, { children: [header && _jsx("div", { className: "shrink-0", children: header }), _jsx(Base.List, { className: cn('min-h-0 overscroll-contain p-1 data-empty:p-0', phone ? 'flex-1 overflow-y-auto pb-4' : 'max-h-88 flex-1 scroll-pt-8 overflow-y-auto'), children: _jsx(Rows, { shown: rows, picked: picked, multiple: multiple, full: full, expand: expand }) }), _jsx(Base.Empty, { className: "hidden px-2 py-3 text-center text-sm text-muted-foreground group-data-empty/selector:block", children: empty })] }));
@@ -416,7 +416,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
416
416
  // Columns a UnitValue has drawn in (`UnitColumns`): their cells share the numbers' edge.
417
417
  const [units, setUnits] = useState(() => new Set());
418
418
  const markUnits = useCallback((key) => setUnits((s) => (s.has(key) ? s : new Set(s).add(key))), []);
419
- const col = colPropsOf(units);
419
+ const colProps = colPropsOf(units);
420
420
  const [flipped, setFlipped] = useState(new Set());
421
421
  const [more, setMore] = useState(new Map());
422
422
  // Under a sub-group's `flat` grouping each row names itself by its sub-group key instead of
@@ -430,6 +430,17 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
430
430
  // Inline chips sit on the first column, next to the row's name, not in their own.
431
431
  const inline = visible.filter((f, i) => i > 0 && chips.some((c) => c.key === f.key && c.placement === 'inline'));
432
432
  const shown = visible.filter((f) => !inline.includes(f));
433
+ // On a narrow phone each row reads as a card (theme.css, `data-card`): its name and one figure
434
+ // (the sorted one, else the first number or date) on the first line, the rest on the second.
435
+ // Numbers and dates keep their width there; only text shortens.
436
+ const short = (f) => f.format !== 'text' || (rows[0] !== undefined && isDateText(f.value(rows[0])));
437
+ const meta = (shown.find((f, i) => i > 0 && f.key === sorting[0]?.id && short(f)) ??
438
+ shown.find((f, i) => i > 0 && short(f)))?.key;
439
+ const cardOf = (f) => f === shown[0] ? 'lead' : f.key === meta ? 'meta' : short(f) ? 'num' : 'text';
440
+ const col = (f, ...more) => ({
441
+ ...colProps(f, ...more),
442
+ 'data-card': f ? cardOf(f) : undefined,
443
+ });
433
444
  const computed = useMemo(() => computedOf(domain, rows, groupBy), [domain, rows, groupBy]);
434
445
  // Values per editable field, offered when a cell is edited: the field's own list when it has
435
446
  // one (every value, in use or not), else the values in the shown rows.
@@ -585,7 +596,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
585
596
  const allKeys = rows.map(keyOf);
586
597
  const allOn = allKeys.length > 0 && allKeys.every((k) => selection?.keys.has(k));
587
598
  const someOn = allKeys.some((k) => selection?.keys.has(k));
588
- const selectCell = (key, name) => selection && (_jsx(TableCell, { className: "w-8 pr-0", onClick: (e) => e.stopPropagation(), children: _jsx(Checkbox, { checked: selection.keys.has(key), onCheckedChange: (on) => toggle([key], on === true), "aria-label": `Select ${name}` }) }));
599
+ const selectCell = (key, name) => selection && (_jsx(TableCell, { "data-card": "select", className: "w-8 pr-0", onClick: (e) => e.stopPropagation(), children: _jsx(Checkbox, { checked: selection.keys.has(key), onCheckedChange: (on) => toggle([key], on === true), "aria-label": `Select ${name}` }) }));
589
600
  // The rows whose open state the person flipped: a sub-group that starts open (`open`) lists
590
601
  // the closed ones, any other the open ones.
591
602
  const [partFlips, setOpenParts] = useState(new Set());
@@ -709,7 +720,7 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
709
720
  };
710
721
  const box = useRef(null);
711
722
  useFloatingHead(box, !inView);
712
- return (_jsx("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((hg) => (_jsxs(TableRow, { children: [selection && (_jsx(TableHead, { className: cn('w-8 pr-0', PINNED_HEAD), children: _jsx(Checkbox, { checked: allOn ? true : someOn ? 'indeterminate' : false, onCheckedChange: (on) => toggle(allKeys, on === true), "aria-label": "Select every row" }) })), hg.headers.map((h, i) => (_jsxs(TableHead, { ...col(shown[i], PINNED_HEAD), style: ruleOf(colorOf(domain, shown[i])), onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
723
+ return (_jsx("div", { ref: box, className: cn(inView ? IN_VIEW : 'overflow-x-auto overflow-y-hidden [--pin-top:0px]', fit && FIT), children: _jsx(UnitColumns.Provider, { value: markUnits, children: _jsxs(Table, { "data-cards": "", children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((hg) => (_jsxs(TableRow, { children: [selection && (_jsx(TableHead, { className: cn('w-8 pr-0', PINNED_HEAD), children: _jsx(Checkbox, { checked: allOn ? true : someOn ? 'indeterminate' : false, onCheckedChange: (on) => toggle(allKeys, on === true), "aria-label": "Select every row" }) })), hg.headers.map((h, i) => (_jsxs(TableHead, { ...col(shown[i], PINNED_HEAD), style: ruleOf(colorOf(domain, shown[i])), onPointerDown: pressStart, onPointerUp: pressEnd, onPointerLeave: pressEnd, onPointerCancel: pressEnd, onContextMenu: (e) => {
713
724
  if (press.current)
714
725
  e.preventDefault();
715
726
  }, "aria-sort": h.column.getIsSorted() === 'asc'
package/dist/shell.d.ts CHANGED
@@ -98,7 +98,7 @@ export interface LumenShellProps {
98
98
  children: ReactNode;
99
99
  }
100
100
  export declare function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, releaseApps, settings, shared, sharedLabel, settingsGroups, user, signOutUrl, profile, security, onRefresh, hideAmounts, onLogs, headerActions, panel, finder, wrap, children, }: LumenShellProps): import("react").JSX.Element;
101
- /** A status bar entry: an optional health dot, a label and a mono value. */
101
+ /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
102
102
  export declare function StatusItem({ label, value, ok, className, }: {
103
103
  label: ReactNode;
104
104
  value?: ReactNode;
package/dist/shell.js CHANGED
@@ -141,7 +141,7 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
141
141
  headerActions,
142
142
  }, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsxs("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-4 overflow-x-auto overflow-y-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: [status, _jsx("div", { ref: setStatusSlot, className: "flex shrink-0 items-center gap-4" }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [health, releases && _jsx(WhatsNew, { api: releases, apps: releaseApps }), statusEnd, _jsx(StatusTimeZone, {})] })] })] }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, security: security, sections: settings, shared: shared, sharedLabel: sharedLabel, groups: settingsGroups }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }));
143
143
  }
144
- /** A status bar entry: an optional health dot, a label and a mono value. */
144
+ /** A status bar entry: an optional health dot, a label and its value ("Mail · Up to date"). */
145
145
  export function StatusItem({ label, value, ok, className, }) {
146
- return (_jsxs("span", { className: cn('flex items-center gap-1.5', value === undefined && 'min-w-0', className), children: [ok !== undefined && (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive') })), _jsx("span", { className: value === undefined ? 'min-w-0 truncate' : 'max-md:hidden', children: label }), value !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "num shrink-0 text-foreground/80", children: value })] }))] }));
146
+ return (_jsxs("span", { className: cn('flex items-center gap-1.5', value === undefined && 'min-w-0', className), children: [ok !== undefined && (_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', ok === null ? 'bg-muted-foreground/40' : ok ? 'bg-gain' : 'bg-destructive') })), _jsx("span", { className: value === undefined ? 'min-w-0 truncate' : 'max-md:hidden', children: label }), value !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "max-md:hidden", children: "\u00B7" }), _jsx("span", { className: "shrink-0 tabular-nums text-foreground/80", children: value })] }))] }));
147
147
  }
package/dist/status.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Time zone picker: on the right of the status bar, and in Settings. */
2
- export declare function TimeZoneSelect({ className, }: {
2
+ export declare function TimeZoneSelect({ className, short, }: {
3
3
  className?: string;
4
+ /** Shows the zone's short name (PT), the way every time on the page names it. */
5
+ short?: boolean;
4
6
  /** Kept for callers; the list flips upward by itself where there is no room below. */
5
7
  side?: 'top' | 'bottom';
6
8
  }): import("react").JSX.Element;
package/dist/status.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cn } from './lib/utils.js';
3
3
  import { LumenSelector } from './lumen-selector.js';
4
- import { TIME_ZONES, setPref, usePrefs } from './prefs.js';
4
+ import { TIME_ZONES, setPref, usePrefs, zoneAbbr } from './prefs.js';
5
5
  /** Time zone picker: on the right of the status bar, and in Settings. */
6
- export function TimeZoneSelect({ className, }) {
6
+ export function TimeZoneSelect({ className, short, }) {
7
7
  const { timeZone } = usePrefs();
8
- return (_jsx(LumenSelector, { label: "Time zone", size: "sm", className: className, options: TIME_ZONES.map((z) => ({ value: z.id, label: z.label })), value: timeZone, onChange: (v) => setPref('timeZone', v) }));
8
+ return (_jsx(LumenSelector, { label: "Time zone", size: "sm", className: className, options: TIME_ZONES.map((z) => ({ value: z.id, label: z.label })), value: timeZone, display: short ? zoneAbbr(timeZone) : undefined, onChange: (v) => setPref('timeZone', v) }));
9
9
  }
10
10
  export function StatusTimeZone({ className }) {
11
- return (_jsx("span", { className: cn('flex items-center', className), children: _jsx(TimeZoneSelect, { side: "top", className: "h-6 border-0 px-1.5 text-xs shadow-none" }) }));
11
+ return (_jsx("span", { className: cn('flex items-center', className), children: _jsx(TimeZoneSelect, { side: "top", short: true, className: "h-6 border-0 px-1.5 text-xs shadow-none" }) }));
12
12
  }
package/dist/theme.css CHANGED
@@ -414,6 +414,82 @@
414
414
  left: 100%;
415
415
  }
416
416
 
417
+ /* A LumenView table on a narrow phone (`data-cards`): each row is a card, so nothing runs off
418
+ the right edge. Line 1: the row's name (shortens) and one figure (`data-card="meta"`); line 2:
419
+ the other values, small and muted. Numbers, dates and their units keep their width; only text
420
+ shortens, in an ellipsis. Group, Total and More rows span the card's width. */
421
+ @media (max-width: 639px) {
422
+ table[data-cards],
423
+ table[data-cards] > tbody,
424
+ table[data-cards] > tfoot {
425
+ display: block;
426
+ width: 100%;
427
+ }
428
+ /* A wide table widens its card (the page scrolls sideways); a card table fits the view. */
429
+ table[data-cards] {
430
+ max-width: calc(var(--view-w, 100%) - 2px);
431
+ }
432
+ table[data-cards] > thead {
433
+ display: none;
434
+ }
435
+ table[data-cards] tr {
436
+ display: flex;
437
+ flex-wrap: wrap;
438
+ align-items: baseline;
439
+ column-gap: 0.75rem;
440
+ row-gap: 0.125rem;
441
+ padding: 0.5rem var(--cell-x);
442
+ }
443
+ table[data-cards] td {
444
+ padding: 0 !important;
445
+ width: auto !important;
446
+ min-width: 0;
447
+ }
448
+ table[data-cards] td[colspan] {
449
+ flex: 1 1 100%;
450
+ }
451
+ table[data-cards] td:not([data-card]):not([colspan]) {
452
+ display: none;
453
+ }
454
+ table[data-cards] td[data-card="select"] {
455
+ order: 0;
456
+ flex: none;
457
+ align-self: center;
458
+ }
459
+ table[data-cards] td[data-card="lead"] {
460
+ order: 1;
461
+ flex: 1 1 0;
462
+ overflow: hidden;
463
+ font-weight: 500;
464
+ }
465
+ table[data-cards] td[data-card="meta"] {
466
+ order: 2;
467
+ flex: none;
468
+ }
469
+ table[data-cards] tr:has(> td[data-card="lead"])::after {
470
+ content: "";
471
+ order: 3;
472
+ flex-basis: 100%;
473
+ }
474
+ table[data-cards] td:is([data-card="num"], [data-card="text"]) {
475
+ order: 4;
476
+ font-size: 0.8125rem;
477
+ color: var(--muted-foreground);
478
+ }
479
+ table[data-cards] td[data-card="num"] {
480
+ flex: none;
481
+ }
482
+ table[data-cards] td[data-card="text"] {
483
+ flex: 0 1 auto;
484
+ overflow: hidden;
485
+ text-overflow: ellipsis;
486
+ }
487
+ table[data-cards] [data-slot="unit-value-unit"] {
488
+ position: static;
489
+ margin-left: 0.25em;
490
+ }
491
+ }
492
+
417
493
  @layer utilities {
418
494
  .bg-gap-hatch {
419
495
  background: repeating-linear-gradient(135deg, var(--gap) 0 3px, var(--gap-soft) 3px 6px);
package/dist/whats-new.js CHANGED
@@ -8,6 +8,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
8
8
  // takeaway carries a chip of the app it is about (`apps` on the takeaway; none: everyone), and a
9
9
  // release where one app has `GROUP_AT` or more is split into one sub-section per app.
10
10
  import { useInfiniteQuery } from '@tanstack/react-query';
11
+ import { SparklesIcon } from 'lucide-react';
11
12
  import { useState } from 'react';
12
13
  import Markdown from 'react-markdown';
13
14
  import { DrawerSection } from './drawer-section.js';
@@ -57,7 +58,7 @@ export function WhatsNew({ api, apps, everyone = 'Global', }) {
57
58
  const releases = q.data.pages.flatMap((p) => p ?? []);
58
59
  const latest = releases[0];
59
60
  const day = (at) => new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone });
60
- return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", className: "num h-6 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: "What's new", children: latest?.tag ?? "What's new" }), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
61
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-1.5 text-muted-foreground text-xs", onClick: () => setOpen(true), title: latest ? `What's new · ${latest.tag}` : "What's new", "aria-label": "What's new", children: [_jsx(SparklesIcon, { className: "size-3.5" }), _jsx("span", { className: "max-md:hidden", children: "What's new" })] }), _jsxs(LumenDrawer, { open: open, onClose: () => setOpen(false), title: "What's new", description: "Key takeaways of each release, newest first", footerStart: releases.length ? `${releases.length} releases` : undefined, footer: _jsxs(_Fragment, { children: [q.hasNextPage && (_jsx(Button, { variant: "outline", size: "sm", disabled: q.isFetchingNextPage, onClick: () => q.fetchNextPage(), children: "Show more" })), _jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Close" })] }), children: [!releases.length && (_jsx("p", { className: "py-2 text-muted-foreground text-sm", children: "No releases yet. Each release's key takeaways show here once it ships." })), releases.map((r, i) => (_jsx(DrawerSection, { title: r.tag, hint: day(r.published_at), defaultOpen: i === 0, children: r.takeaways.length ? (_jsx(Notes, { takeaways: r.takeaways, apps: apps, everyone: everyone })) : (_jsx("p", { className: "py-1 pl-1 text-muted-foreground text-sm", children: "Nothing new for you in this one." })) }, r.tag)))] })] }));
61
62
  }
62
63
  /** One release's takeaways: a flat list, or one sub-section per app once one app has many. */
63
64
  function Notes({ takeaways, apps, everyone, }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.103",
3
+ "version": "0.1.104",
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.103",
52
+ "@columenlabs/lumen-ui-types": "0.1.104",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",