@columenlabs/lumen-ui 0.1.57 → 0.1.58

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.
@@ -24,6 +24,8 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from './ui/sheet.js
24
24
  import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
25
25
  const SHOWN = 6;
26
26
  const SEARCH_PAST = 8;
27
+ /** Room below a trigger (px) under which its list opens above, when there is more room there. */
28
+ const ROOM_BELOW = 320;
27
29
  const CHIPS = 4;
28
30
  const CREATE = '\u0000create';
29
31
  /** The options as groups, in order. */
@@ -163,9 +165,19 @@ export function LumenSelector(props) {
163
165
  // focus back out of a list portalled to the body, so its search box could never be typed in.
164
166
  const markRef = useRef(null);
165
167
  const [host, setHost] = useState(null);
168
+ // The list opens below its trigger, or above when the room below is short and there is more
169
+ // above (a row near the screen's foot); either way it fits the screen, its footer in view.
170
+ const triggerRef = useRef(null);
171
+ const [side, setSide] = useState('bottom');
166
172
  useLayoutEffect(() => {
167
- if (open)
168
- setHost(markRef.current?.closest('[role="dialog"]') ?? null);
173
+ if (!open)
174
+ return;
175
+ setHost(markRef.current?.closest('[role="dialog"]') ?? null);
176
+ const r = (chipsRef.current ?? triggerRef.current)?.getBoundingClientRect();
177
+ if (!r)
178
+ return;
179
+ const below = window.innerHeight - r.bottom;
180
+ setSide(below < ROOM_BELOW && r.top > below ? 'top' : 'bottom');
169
181
  }, [open]);
170
182
  const setOpen = (o) => {
171
183
  setOwnOpen(o);
@@ -225,11 +237,11 @@ export function LumenSelector(props) {
225
237
  const selected = multiple ? null : (byValue.get(values[0] ?? '') ?? null);
226
238
  const Lead = selected?.icon ?? LeadIcon;
227
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" })] }));
228
- const list = (_jsxs(_Fragment, { children: [header, _jsx(Base.List, { className: cn('overscroll-contain p-1 data-empty:p-0', phone
229
- ? 'min-h-0 flex-1 overflow-y-auto pb-4'
230
- : 'max-h-[min(22rem,calc(var(--available-height)-3rem))] 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 })] }));
240
+ // On a desktop the popup is a column as tall as the screen allows: the search, header and
241
+ // footer keep their height and the list between them scrolls.
242
+ 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 })] }));
231
243
  const clearAll = multiple && values.length > 0 && (_jsx("button", { type: "button", className: "text-xs text-muted-foreground hover:text-foreground", onClick: () => props.onChange([]), children: "Clear all" }));
232
- const searchBox = (_jsxs("div", { className: cn('flex items-center gap-2 border-b px-2', phone ? 'mx-4 h-11 rounded-md border' : 'h-9', !searchable && 'sr-only'), children: [_jsx(SearchIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx(Base.Input, { readOnly: !searchable, placeholder: create ? 'Search or type a new one' : `Search ${count}`, "aria-label": label ? `Search ${label}` : 'Search', className: "h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground max-md:text-[15px]" })] }));
244
+ const searchBox = (_jsxs("div", { className: cn('flex shrink-0 items-center gap-2 border-b px-2', phone ? 'mx-4 h-11 rounded-md border' : 'h-9', !searchable && 'sr-only'), children: [_jsx(SearchIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx(Base.Input, { readOnly: !searchable, placeholder: create ? 'Search or type a new one' : `Search ${count}`, "aria-label": label ? `Search ${label}` : 'Search', className: "h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground max-md:text-[15px]" })] }));
233
245
  const rootProps = {
234
246
  items: rows.map((g) => ({ value: g.label, items: g.rows.map((o) => o.value) })),
235
247
  filteredItems: rows.map((g) => ({ value: g.label, items: g.rows.map((o) => o.value) })),
@@ -256,8 +268,8 @@ export function LumenSelector(props) {
256
268
  // No ring on the first button: the person taps what they want.
257
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] }))] }) })] }));
258
270
  }
259
- const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side: "bottom", 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 max-w-(--available-width) 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 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" })] }))] }) }) }));
260
- 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, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { 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] }));
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
+ 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] }));
261
273
  }
262
274
  /**
263
275
  * `trigger="none"`: the list anchored to a text box the page owns (the assistant @ and /). The text box
@@ -269,9 +269,19 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
269
269
  const pages = Math.max(1, Math.ceil(tableRows.length / pageSize));
270
270
  const page = Math.min(pageIndex, pages - 1);
271
271
  const count = rows.length === total ? `${total} rows` : `${rows.length} of ${total} rows`;
272
- // A new filter or page size starts from the first page.
272
+ // The page holds while rows change in place (a tick, a label edit, a refresh, a bulk apply): only
273
+ // a new filter (a different set of rows), grouping, sort or page size starts from the first page.
274
+ // A page past the end after rows go is clamped above (`page`).
275
+ const rowSet = useMemo(() => rows
276
+ .map((r) => domain.rowKey(r))
277
+ .sort()
278
+ .join('\u0000'), [rows, domain]);
273
279
  // biome-ignore lint/correctness/useExhaustiveDependencies: resets on these changes only
274
- useEffect(() => setPageIndex(0), [rows, pageSize]);
280
+ useEffect(() => setPageIndex(0), [rowSet, pageSize, groupBy?.key, sort?.key, sort?.desc]);
281
+ const sorted = (s) => {
282
+ setPageIndex(0);
283
+ onSort?.(s);
284
+ };
275
285
  // Checked rows with a chart symbol open one compare chart (up to 8).
276
286
  const openChart = useSymbolChart();
277
287
  const chartField = domain.fields.find((f) => f.chart);
@@ -291,7 +301,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
291
301
  // A server's page: the pager counts from the server's total, or the rows seen so far. Server
292
302
  // groups page themselves: the header counts them.
293
303
  const pager = served?.pages ? (_jsx(LumenPager, { total: served.pages.total ?? served.pages.page * served.pages.size + (served.list?.length ?? 0), page: served.pages.page, size: served.pages.size, onPage: served.pages.onPage, onSize: served.pages.onSize ?? (() => { }), sizes: served.pages.sizes ?? [served.pages.size], noun: served.pages.noun ?? 'groups', more: served.pages.total === null && served.pages.more })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : server ? (_jsx(LumenPager, { total: server.total ?? server.page * server.size + rows.length, page: server.page, size: server.size, onPage: server.onPage, onSize: server.onSize ?? (() => { }), sizes: server.sizes ?? [server.size], more: server.total === null && server.more })) : paging ? (_jsx(LumenPager, { total: tableRows.length, page: page, size: pageSize, onPage: setPageIndex, onSize: setPageSize, noun: rows.length === total ? 'rows' : `of ${total} rows` })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
294
- const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort: onSort, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
304
+ const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
295
305
  ? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
296
306
  : undefined }));
297
307
  return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, controls: controls, tools: state ? (tools) : (_jsxs(_Fragment, { children: [tools, compare, pager, onColumns && !onAddColumn && on('columns') && (_jsx(ColumnPicker, { domain: domain, columns: columns, onColumns: onColumns })), !pivot && on('download') && (_jsx(IconButton, { icon: DownloadIcon, label: "Download CSV", onClick: () => downloadCsv({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "@codemirror/lang-javascript": "6.2.5",
42
42
  "@codemirror/lang-json": "6.0.2",
43
43
  "@codemirror/lang-sql": "6.10.0",
44
- "@columenlabs/lumen-ui-types": "0.1.57",
44
+ "@columenlabs/lumen-ui-types": "0.1.58",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",