@columenlabs/lumen-ui 0.1.160 → 0.1.162

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/README.md CHANGED
@@ -61,11 +61,11 @@ Use `createDeskQueryClient()` for the app's TanStack Query client: data stays fr
61
61
 
62
62
  **Frame.** `LumenShell` (sidebar with icons-only collapse and a drawer on phone, header, status bar, Settings, profile menu, ⌘K Find), `LumenAppSwitcher` and `LumenSuiteSwitcher` (groups, apps on other sites, a list read from an address), `WhatsNew` (release notes in a drawer), `TasksButton` and `TasksProvider` (background work in the header), `NotificationsSettings`, `McpSettings`, sign-in screens (`LumenAuthStage`, `LumenSecondStep`, `LumenSignInMethods`, `LumenBackupCodes`, `LumenBotCheck` for Cloudflare Turnstile, `authErrorText` for errors in plain words), `StatusHealth`, `LumenBoundary` (keeps a crash inside its card), `LumenPageState` (loading, empty and error), `lumenToast`.
63
63
 
64
- **Pages and views.** `LumenLayout` (a page of cards from one view config: filter, tiles, widgets; Edit layout and Edit widget when you pass `onConfigChange`; a card with `header: false` is its body alone), `LumenGrid` (the 6-column grid under it), `LumenView` and `LumenViewCard` (a table that filters, groups, pivots and totals the rows you load), `LumenTable`, `LumenTokenizedFilter`, `LumenWidget` and `LumenSavedWidget` (the frame every card sits in, with its link square and controls), `LumenCard` (one number and its lines), `LumenPager`, `DetailsTable`, `LumenTree`, `useAppView` (a page whose layout each person can change and reset).
64
+ **Pages and views.** `LumenLayout` (a page of cards from one view config: filter, tiles, widgets; Edit layout and Edit widget when you pass `onConfigChange`; a card with `header: false` is its body alone), `LumenGrid` (the 6-column grid under it), `LumenView` and `LumenViewCard` (a table that filters, groups, pivots and totals the rows you load), `LumenTable`, `LumenTokenizedFilter`, `LumenWidget` and `LumenSavedWidget` (the frame every card sits in, with its link square and controls), `LumenCard` (one number and its lines), `LumenPager`, `DetailsTable`, `LumenTree`, `LumenCalendar` (events by month, week or agenda, in a time zone), `LumenBoard` (items in columns by stage), `useAppView` (a page whose layout each person can change and reset).
65
65
 
66
66
  **Charts.** `LumenChart` (one or more symbols' prices, with indicators), `LumenEChart`, `LumenGeoMap`, growth and price charts, `StackedBars`, `StackedColumns`, `TimeLines` (lines over time, with an optional right axis), `RankedBars` (with each bar's share of the total or of the first bar, and an empty state).
67
67
 
68
- **Pickers and panels.** `LumenSelector` (every picker), `LumenDrawer` with `DrawerSection` (a side panel on desktop, a sheet on phone), `LumenTabs`, `LumenConfirm` (destructive confirms), `LumenForm`, `LumenDateInput`, `LumenChoice`, `LumenSecret`, `LumenFileField` (drop or pick a file), `RenameField`, `ResetButton`, `SaveButton` with `FormIssue` (a Save that says why it can't), `IconPicker`.
68
+ **Pickers and panels.** `LumenSelector` (every picker), `LumenDrawer` with `DrawerSection` (a side panel on desktop, a sheet on phone), `LumenTabs`, `LumenConfirm` (destructive confirms), `LumenForm`, `LumenDateInput`, `LumenChoice`, `LumenSecret`, `LumenFileField` (drop or pick a file), `RenameField`, `ResetButton`, `SaveButton` with `FormIssue` (a Save that says why it can't), `IconPicker`, `LumenPlayer` (video, audio, or slides over audio, with chapters, a following transcript and resume).
69
69
 
70
70
  **Primitives.** The shadcn parts under `@columenlabs/lumen-ui/ui/*` (`ui/button`, `ui/badge`, `ui/input`, ...) and `theme.css` (light and dark tokens).
71
71
 
@@ -0,0 +1,8 @@
1
+ import { type RefObject } from 'react';
2
+ /** Under this width (px) a part lays itself out for a phone. */
3
+ export declare const NARROW = 640;
4
+ /**
5
+ * Whether a part's own box is narrower than `below` px: a phone, or a narrow drawer on a desktop.
6
+ * Before the box is measured it reads the window, so the first paint already fits.
7
+ */
8
+ export declare function useNarrow(ref: RefObject<HTMLElement | null>, below?: number): boolean;
@@ -0,0 +1,25 @@
1
+ import { useLayoutEffect, useState } from 'react';
2
+ /** Under this width (px) a part lays itself out for a phone. */
3
+ export const NARROW = 640;
4
+ /**
5
+ * Whether a part's own box is narrower than `below` px: a phone, or a narrow drawer on a desktop.
6
+ * Before the box is measured it reads the window, so the first paint already fits.
7
+ */
8
+ export function useNarrow(ref, below = NARROW) {
9
+ const [narrow, setNarrow] = useState(() => typeof window === 'undefined' ? false : window.innerWidth < below);
10
+ useLayoutEffect(() => {
11
+ const el = ref.current;
12
+ if (!el)
13
+ return;
14
+ const measure = () => {
15
+ const w = el.clientWidth;
16
+ if (w > 0)
17
+ setNarrow(w < below);
18
+ };
19
+ measure();
20
+ const seen = new ResizeObserver(measure);
21
+ seen.observe(el);
22
+ return () => seen.disconnect();
23
+ }, [ref, below]);
24
+ return narrow;
25
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tells `tell` a value each time the value itself changes, never because `tell` is a new function.
3
+ * The view hears its sort, chips and grouping through these: an app's inline callback (a new
4
+ * function every render) used to re-run the effect, set the app's state, render, and so on without
5
+ * end, asking the server for the same page each time. The value is compared by its JSON, so an
6
+ * equal value in a new object is not a change either. A `tell` that appears later hears the value.
7
+ */
8
+ export declare function useTell<V>(value: V, tell: ((value: V) => void) | undefined): void;
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * Tells `tell` a value each time the value itself changes, never because `tell` is a new function.
4
+ * The view hears its sort, chips and grouping through these: an app's inline callback (a new
5
+ * function every render) used to re-run the effect, set the app's state, render, and so on without
6
+ * end, asking the server for the same page each time. The value is compared by its JSON, so an
7
+ * equal value in a new object is not a change either. A `tell` that appears later hears the value.
8
+ */
9
+ export function useTell(value, tell) {
10
+ const tellRef = React.useRef(tell);
11
+ tellRef.current = tell;
12
+ const valueRef = React.useRef(value);
13
+ valueRef.current = value;
14
+ const sent = React.useRef(null);
15
+ const key = JSON.stringify(value ?? null);
16
+ const has = !!tell;
17
+ React.useEffect(() => {
18
+ const mark = has ? key : null;
19
+ if (mark === null || sent.current === mark)
20
+ return;
21
+ sent.current = mark;
22
+ tellRef.current?.(valueRef.current);
23
+ }, [key, has]);
24
+ }
package/dist/index.d.ts CHANGED
@@ -61,6 +61,9 @@ export { type ClientPoint, type ClientPointKind, type LumenTelemetryOptions, api
61
61
  export { refreshHeaders, withRefreshToken } from './refresh.js';
62
62
  export { type LumenLoad, LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
63
63
  export { LumenBoundary } from './lumen-boundary.js';
64
+ export { type CalendarCategory, type CalendarEvent, type CalendarRange, type CalendarView, LumenCalendar, type LumenCalendarProps, } from './lumen-calendar.js';
65
+ export { type BoardColumn, type BoardItem, LumenBoard, type LumenBoardProps } from './lumen-board.js';
66
+ export { LumenPlayer, type LumenPlayerProps, type PlayerChapter, type PlayerLine, type PlayerSlide, type PlayerSource, type PlayerTrack, playerClock, } from './lumen-player.js';
64
67
  export { LumenPageState } from './lumen-page-state.js';
65
68
  export { type FormIssue, goToIssue, SaveButton } from './form-issues.js';
66
69
  export { LumenForm, type LumenFormField, lumenFormIssues, type LumenFormValue, type LumenFormValues, } from './lumen-form.js';
@@ -92,6 +95,7 @@ export { TILE_WIDTH, Tile, TileChip, type TileOption, TileRow, TileSpark, TileTo
92
95
  export { type FacetCounts, type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
93
96
  export { LumenFilterPanel, LumenFilterPanelButton, type LumenFilterPanelProps, useFilterPanel, } from './lumen-filter-panel.js';
94
97
  export { useIsMobile } from './hooks/use-mobile.js';
98
+ export { useTell } from './hooks/use-tell.js';
95
99
  export { useBackClose } from './hooks/use-back-close.js';
96
100
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
97
101
  export { type AppViewState, useAppView } from './app-views.js';
package/dist/index.js CHANGED
@@ -68,6 +68,9 @@ export { apiRoute, reportClient, reportClientError, reportFailedFetch, stackFing
68
68
  export { refreshHeaders, withRefreshToken } from './refresh.js';
69
69
  export { LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
70
70
  export { LumenBoundary } from './lumen-boundary.js';
71
+ export { LumenCalendar, } from './lumen-calendar.js';
72
+ export { LumenBoard } from './lumen-board.js';
73
+ export { LumenPlayer, playerClock, } from './lumen-player.js';
71
74
  export { LumenPageState } from './lumen-page-state.js';
72
75
  export { goToIssue, SaveButton } from './form-issues.js';
73
76
  export { LumenForm, lumenFormIssues, } from './lumen-form.js';
@@ -99,6 +102,7 @@ export { TILE_WIDTH, Tile, TileChip, TileRow, TileSpark, TileToggleGroup, } from
99
102
  export { LumenTokenizedFilter, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
100
103
  export { LumenFilterPanel, LumenFilterPanelButton, useFilterPanel, } from './lumen-filter-panel.js';
101
104
  export { useIsMobile } from './hooks/use-mobile.js';
105
+ export { useTell } from './hooks/use-tell.js';
102
106
  export { useBackClose } from './hooks/use-back-close.js';
103
107
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.js';
104
108
  export { useAppView } from './app-views.js';
@@ -0,0 +1,35 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface BoardColumn {
3
+ id: string;
4
+ label: string;
5
+ /** The count in the header; the number of its items by default. */
6
+ count?: number;
7
+ }
8
+ export interface BoardItem {
9
+ id: string;
10
+ /** The `BoardColumn` id it sits in. */
11
+ column: string;
12
+ title: string;
13
+ /** One muted line after the title: a due date, an owner. */
14
+ meta?: ReactNode;
15
+ /** Small marks at the card's end (`Badge`, `ColorChip`). */
16
+ badges?: ReactNode;
17
+ }
18
+ export interface LumenBoardProps<T extends BoardItem = BoardItem> {
19
+ columns: readonly BoardColumn[];
20
+ items: readonly T[];
21
+ /** A card was opened: show the app's drawer. */
22
+ onOpen?: (item: T) => void;
23
+ /** Moves a card; with it each card offers a Move to menu (hover, focus or tap on a phone). */
24
+ onMove?: (item: T, toColumn: string) => void;
25
+ /** Draws a card's body in place of the title, meta and badges. Keep it to one line. */
26
+ renderCard?: (item: T) => ReactNode;
27
+ /** A slot before the title: a done tick (a `Checkbox`). */
28
+ tick?: (item: T) => ReactNode;
29
+ /** An empty column's line. */
30
+ empty?: ReactNode;
31
+ /** How tall a column's list may grow before it scrolls (any CSS length). */
32
+ maxHeight?: string;
33
+ className?: string;
34
+ }
35
+ export declare function LumenBoard<T extends BoardItem>({ columns, items, onOpen, onMove, renderCard, tick, empty, maxHeight, className, }: LumenBoardProps<T>): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // LumenBoard: items in columns by stage (a plan's checkpoints, a pipeline's steps). Each column
3
+ // scrolls on its own; the board scrolls sideways inside its own box, never the page. On a narrow
4
+ // box the columns become a snap scroller, one column a screen, with a LumenSelector to jump to one.
5
+ // A card is one line: an optional tick slot, the title (it truncates, the whole of it on hover),
6
+ // then a muted meta and badges. Moving a card is a menu of columns, so it works by keyboard and
7
+ // needs no drag library.
8
+ import { ArrowRightLeftIcon, KanbanIcon } from 'lucide-react';
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
+ import { useNarrow } from './hooks/use-narrow.js';
11
+ import { cn } from './lib/utils.js';
12
+ import { LumenBoundary } from './lumen-boundary.js';
13
+ import { LumenSelector } from './lumen-selector.js';
14
+ import { IconButton } from './page.js';
15
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
16
+ export function LumenBoard({ columns, items, onOpen, onMove, renderCard, tick, empty = 'Nothing here', maxHeight = '70vh', className, }) {
17
+ const box = useRef(null);
18
+ const strip = useRef(null);
19
+ const narrow = useNarrow(box);
20
+ const [shown, setShown] = useState(columns[0]?.id ?? '');
21
+ const byColumn = useMemo(() => {
22
+ const map = new Map(columns.map((c) => [c.id, []]));
23
+ for (const it of items)
24
+ map.get(it.column)?.push(it);
25
+ return map;
26
+ }, [columns, items]);
27
+ // On a phone the picker follows the swipe, and a pick scrolls to its column.
28
+ const onScroll = () => {
29
+ const el = strip.current;
30
+ if (!narrow || !el || !el.clientWidth)
31
+ return;
32
+ const i = Math.round(el.scrollLeft / el.clientWidth);
33
+ const id = columns[i]?.id;
34
+ if (id && id !== shown)
35
+ setShown(id);
36
+ };
37
+ const jump = (id) => {
38
+ setShown(id);
39
+ const el = strip.current;
40
+ const i = columns.findIndex((c) => c.id === id);
41
+ if (el && i >= 0)
42
+ el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' });
43
+ };
44
+ // A column that goes away leaves the picker on the first.
45
+ useEffect(() => {
46
+ if (!columns.some((c) => c.id === shown))
47
+ setShown(columns[0]?.id ?? '');
48
+ }, [columns, shown]);
49
+ const countOf = (c) => c.count ?? byColumn.get(c.id)?.length ?? 0;
50
+ return (_jsxs("div", { ref: box, className: cn('flex min-w-0 flex-col gap-2', className), children: [narrow && columns.length > 1 ? (_jsx(LumenSelector, { label: "Column", icon: KanbanIcon, value: shown, onChange: jump, options: columns.map((c) => ({
51
+ value: c.id,
52
+ label: c.label,
53
+ detail: String(countOf(c)),
54
+ })), className: "w-full" })) : null, _jsx("div", { ref: strip, onScroll: onScroll, className: cn('flex min-w-0 overflow-x-auto overscroll-x-contain', narrow ? 'snap-x snap-mandatory' : 'gap-3 pb-1'), children: columns.map((c) => {
55
+ const list = byColumn.get(c.id) ?? [];
56
+ return (_jsxs("section", { "aria-label": c.label, className: cn('flex shrink-0 flex-col rounded-lg border bg-muted/30', narrow ? 'w-full snap-start' : 'w-72'), children: [_jsxs("header", { className: "flex min-w-0 items-center gap-2 border-b px-3 py-2", children: [_jsx("h3", { className: "min-w-0 flex-1 truncate text-sm font-medium", title: c.label, children: c.label }), _jsx("span", { className: "num shrink-0 text-xs text-muted-foreground", children: countOf(c) })] }), _jsx("div", { className: "overflow-y-auto p-2", style: { maxHeight }, children: _jsx(LumenBoundary, { where: `board:${c.id}`, children: list.length ? (_jsx("ul", { className: "flex flex-col gap-1.5", children: list.map((it) => (_jsx("li", { children: _jsx(BoardCard, { item: it, columns: columns, onOpen: onOpen, onMove: onMove, renderCard: renderCard, tick: tick }) }, it.id))) })) : (_jsx("p", { className: "px-1 py-6 text-center text-sm text-muted-foreground", children: empty })) }) })] }, c.id));
57
+ }) })] }));
58
+ }
59
+ function BoardCard({ item, columns, onOpen, onMove, renderCard, tick, }) {
60
+ const [menu, setMenu] = useState(false);
61
+ return (_jsxs("div", { className: cn('group/card flex min-w-0 items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm shadow-xs hover:bg-muted/60 focus-within:ring-3 focus-within:ring-ring/30', menu && 'bg-muted/60'), children: [tick ? _jsx("span", { className: "flex shrink-0 items-center", children: tick(item) }) : null, _jsx("button", { type: "button", onClick: () => onOpen?.(item), title: item.title, className: "flex min-w-0 flex-1 items-center gap-2 text-left focus-visible:outline-none", children: renderCard ? (renderCard(item)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: item.title }), item.meta ? (_jsx("span", { className: "max-w-24 shrink-0 truncate text-xs text-muted-foreground", children: item.meta })) : null, item.badges ? (_jsx("span", { className: "flex shrink-0 items-center gap-1 whitespace-nowrap", children: item.badges })) : null] })) }), onMove ? (_jsxs(DropdownMenu, { open: menu, onOpenChange: setMenu, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(IconButton, { icon: ArrowRightLeftIcon, label: "Move to", size: "icon-xs", className: cn('shrink-0 opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100 [@media(hover:none)]:opacity-100', menu && 'opacity-100') }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-44", children: [_jsx(DropdownMenuLabel, { children: "Move to" }), _jsx(DropdownMenuRadioGroup, { value: item.column, onValueChange: (to) => to !== item.column && onMove(item, to), children: columns.map((c) => (_jsx(DropdownMenuRadioItem, { value: c.id, children: _jsx("span", { className: "truncate", children: c.label }) }, c.id))) })] })] })) : null] }));
62
+ }
@@ -0,0 +1,50 @@
1
+ export interface CalendarEvent {
2
+ id: string;
3
+ title: string;
4
+ /** An instant (`Date` or ISO string), or a plain `YYYY-MM-DD` for a whole day. */
5
+ start: Date | string;
6
+ /** Inclusive for a plain `YYYY-MM-DD`; an end at midnight does not reach the next day. */
7
+ end?: Date | string;
8
+ allDay?: boolean;
9
+ /** A `categories` id: its colour and filter chip. */
10
+ category?: string;
11
+ /** Any CSS colour; the category's colour by default. */
12
+ color?: string;
13
+ }
14
+ export interface CalendarCategory {
15
+ id: string;
16
+ label: string;
17
+ color: string;
18
+ }
19
+ export type CalendarView = 'month' | 'week' | 'agenda';
20
+ export interface CalendarRange {
21
+ /** The first instant on screen (midnight of the first day in `timeZone`). */
22
+ start: Date;
23
+ /** The instant after the last day on screen. */
24
+ end: Date;
25
+ view: CalendarView;
26
+ }
27
+ export interface LumenCalendarProps<E extends CalendarEvent = CalendarEvent> {
28
+ events: readonly E[];
29
+ categories?: readonly CalendarCategory[];
30
+ /** The open view, controlled; or `defaultView` (Agenda on a narrow box, else Month). */
31
+ view?: CalendarView;
32
+ defaultView?: CalendarView;
33
+ onViewChange?: (view: CalendarView) => void;
34
+ /** A day in the period shown, controlled; or `defaultDate` (today). */
35
+ date?: Date;
36
+ defaultDate?: Date;
37
+ onDateChange?: (date: Date) => void;
38
+ /** An event was clicked: open the app's drawer. */
39
+ onSelect?: (event: E) => void;
40
+ /** The days on screen changed (and once at first): fetch by range. */
41
+ onRangeChange?: (range: CalendarRange) => void;
42
+ /** An IANA zone every day and time is shown in; the browser's by default. */
43
+ timeZone?: string;
44
+ /** 0 Sunday (default) or 1 Monday. */
45
+ weekStartsOn?: 0 | 1;
46
+ /** Shown when the period has no events. */
47
+ empty?: string;
48
+ className?: string;
49
+ }
50
+ export declare function LumenCalendar<E extends CalendarEvent>({ events, categories, view: viewProp, defaultView, onViewChange, date: dateProp, defaultDate, onDateChange, onSelect, onRangeChange, timeZone, weekStartsOn, empty, className, }: LumenCalendarProps<E>): import("react").JSX.Element;
@@ -0,0 +1,308 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // LumenCalendar: an events calendar over plain data. Month, Week (with a time axis) and Agenda,
3
+ // switched with LumenTabs; Today, previous and next as icon buttons; category chips that filter.
4
+ // Every day is worked out in the passed time zone without a date library: an event's instant is
5
+ // read into that zone's calendar day and minute with Intl, and the grid counts whole days.
6
+ // On a narrow box (a phone, or a narrow drawer) Month shows dots with the picked day's list under
7
+ // it, and Agenda is the view it opens on. Nothing here fetches: `onRangeChange` tells the app which
8
+ // days are on screen, so it can read just those.
9
+ import { CalendarCheckIcon, CalendarDaysIcon, CalendarRangeIcon, ChevronLeftIcon, ChevronRightIcon, ListIcon, } from 'lucide-react';
10
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
11
+ import { ColorChip, ColorDot } from './color-dot.js';
12
+ import { useNarrow } from './hooks/use-narrow.js';
13
+ import { cn } from './lib/utils.js';
14
+ import { LumenBoundary } from './lumen-boundary.js';
15
+ import { LumenTabs } from './lumen-tabs.js';
16
+ import { IconButton } from './page.js';
17
+ import { Empty, EmptyHeader, EmptyTitle } from './ui/empty.js';
18
+ import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
19
+ const DAY_MS = 86_400_000;
20
+ /** Most days one event may cover on the grid, so a bad end date never loops for long. */
21
+ const MAX_SPAN = 62;
22
+ /** Events a month cell draws before "+N". */
23
+ const CELL_EVENTS = 3;
24
+ /** The hour the week's time axis opens at when nothing starts earlier. */
25
+ const OPEN_HOUR = 7;
26
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
27
+ // ── Civil days ────────────────────────────────────────────────────────────────
28
+ // A civil day is a whole number: days since 1970-01-01. Its Date at UTC midnight formats with
29
+ // `timeZone: 'UTC'`, so labels never shift by the viewer's own zone.
30
+ const dayOf = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS;
31
+ const asDate = (day, minute = 0) => new Date(day * DAY_MS + minute * 60_000);
32
+ const weekday = (day) => (((day + 4) % 7) + 7) % 7;
33
+ const ymd = (day) => {
34
+ const d = asDate(day);
35
+ return { y: d.getUTCFullYear(), m: d.getUTCMonth() + 1, d: d.getUTCDate() };
36
+ };
37
+ const daysIn = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();
38
+ const addMonths = (day, n) => {
39
+ const c = ymd(day);
40
+ const t = new Date(Date.UTC(c.y, c.m - 1 + n, 1));
41
+ const y = t.getUTCFullYear();
42
+ const m = t.getUTCMonth() + 1;
43
+ return dayOf(y, m, Math.min(c.d, daysIn(y, m)));
44
+ };
45
+ const zoneParts = new Map();
46
+ /** The instant's calendar day and minute of the day in `zone`. */
47
+ function zoned(ms, zone) {
48
+ let f = zoneParts.get(zone);
49
+ if (!f) {
50
+ f = new Intl.DateTimeFormat('en-US', {
51
+ timeZone: zone,
52
+ year: 'numeric',
53
+ month: 'numeric',
54
+ day: 'numeric',
55
+ hour: 'numeric',
56
+ minute: 'numeric',
57
+ hourCycle: 'h23',
58
+ });
59
+ zoneParts.set(zone, f);
60
+ }
61
+ const p = {};
62
+ for (const x of f.formatToParts(new Date(ms)))
63
+ if (x.type !== 'literal')
64
+ p[x.type] = +x.value;
65
+ return {
66
+ day: dayOf(p.year ?? 1970, p.month ?? 1, p.day ?? 1),
67
+ minute: ((p.hour ?? 0) % 24) * 60 + (p.minute ?? 0),
68
+ };
69
+ }
70
+ /** Midnight of a civil day in `zone`, as an instant. */
71
+ function midnight(day, zone) {
72
+ const guess = day * DAY_MS;
73
+ const offset = (ms) => {
74
+ const z = zoned(ms, zone);
75
+ return z.day * DAY_MS + z.minute * 60_000 - Math.floor(ms / 60_000) * 60_000;
76
+ };
77
+ const first = guess - offset(guess);
78
+ return new Date(guess - offset(first));
79
+ }
80
+ function place(ev, zone, colors) {
81
+ const read = (v) => {
82
+ if (typeof v === 'string') {
83
+ const m = v.match(DATE_ONLY);
84
+ if (m)
85
+ return { day: dayOf(Number(m[1]), Number(m[2]), Number(m[3])), minute: 0, plain: true };
86
+ }
87
+ const ms = (v instanceof Date ? v : new Date(v)).getTime();
88
+ return Number.isFinite(ms) ? { ...zoned(ms, zone), plain: false } : null;
89
+ };
90
+ const s = read(ev.start);
91
+ if (!s)
92
+ return null;
93
+ const e = ev.end ? read(ev.end) : null;
94
+ const allDay = !!ev.allDay || s.plain;
95
+ let endDay = s.day;
96
+ let endMin = s.minute + 30;
97
+ if (e) {
98
+ endDay = e.day;
99
+ endMin = (e.day - s.day) * 1440 + e.minute;
100
+ // A timed end at midnight closes the day before.
101
+ if (!e.plain && e.minute === 0 && e.day > s.day)
102
+ endDay = e.day - 1;
103
+ }
104
+ endDay = Math.min(Math.max(endDay, s.day), s.day + MAX_SPAN);
105
+ return {
106
+ ev,
107
+ startDay: s.day,
108
+ endDay,
109
+ startMin: allDay ? 0 : s.minute,
110
+ endMin: allDay ? 1440 : Math.max(endMin, s.minute + 15),
111
+ allDay,
112
+ color: ev.color ?? (ev.category && colors.get(ev.category)) ?? 'var(--primary)',
113
+ };
114
+ }
115
+ /** Side-by-side lanes for one day's timed events that overlap. */
116
+ function lanes(list) {
117
+ const out = [];
118
+ let group = [];
119
+ let ends = [];
120
+ let groupEnd = -1;
121
+ const close = () => {
122
+ for (const g of group)
123
+ g.of = ends.length;
124
+ out.push(...group);
125
+ group = [];
126
+ ends = [];
127
+ };
128
+ for (const p of [...list].sort((a, b) => a.startMin - b.startMin)) {
129
+ if (p.startMin >= groupEnd)
130
+ close();
131
+ let lane = ends.findIndex((end) => end <= p.startMin);
132
+ if (lane < 0)
133
+ lane = ends.push(0) - 1;
134
+ ends[lane] = p.endMin;
135
+ groupEnd = Math.max(groupEnd, p.endMin);
136
+ group.push({ p, lane, of: 0 });
137
+ }
138
+ close();
139
+ return out;
140
+ }
141
+ function useControlled(value, initial, onChange) {
142
+ const [own, setOwn] = useState(initial);
143
+ const set = (v) => {
144
+ if (value === undefined)
145
+ setOwn(v);
146
+ onChange?.(v);
147
+ };
148
+ return [value ?? own, set];
149
+ }
150
+ // ── The part ──────────────────────────────────────────────────────────────────
151
+ export function LumenCalendar({ events, categories, view: viewProp, defaultView, onViewChange, date: dateProp, defaultDate, onDateChange, onSelect, onRangeChange, timeZone, weekStartsOn = 0, empty = 'No events', className, }) {
152
+ const box = useRef(null);
153
+ const narrow = useNarrow(box);
154
+ const zone = timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
155
+ const [view, setView] = useControlled(viewProp, () => defaultView ??
156
+ (typeof window !== 'undefined' && window.innerWidth < 640 ? 'agenda' : 'month'), onViewChange);
157
+ const [date, setDate] = useControlled(dateProp, () => defaultDate ?? new Date(), onDateChange);
158
+ const anchor = zoned(date.getTime(), zone).day;
159
+ const today = zoned(Date.now(), zone).day;
160
+ const [picked, setPicked] = useState(null);
161
+ const [hidden, setHidden] = useState(new Set());
162
+ const colors = useMemo(() => new Map((categories ?? []).map((c) => [c.id, c.color])), [categories]);
163
+ const catLabel = useMemo(() => new Map((categories ?? []).map((c) => [c.id, c.label])), [categories]);
164
+ // Each event on every day it covers, whole days first, then by start.
165
+ const byDay = useMemo(() => {
166
+ const map = new Map();
167
+ for (const ev of events) {
168
+ if (ev.category && hidden.has(ev.category))
169
+ continue;
170
+ const p = place(ev, zone, colors);
171
+ if (!p)
172
+ continue;
173
+ for (let d = p.startDay; d <= p.endDay; d++) {
174
+ const list = map.get(d);
175
+ if (list)
176
+ list.push(p);
177
+ else
178
+ map.set(d, [p]);
179
+ }
180
+ }
181
+ for (const list of map.values())
182
+ list.sort((a, b) => Number(b.allDay) - Number(a.allDay) ||
183
+ a.startDay - b.startDay ||
184
+ a.startMin - b.startMin ||
185
+ a.ev.title.localeCompare(b.ev.title));
186
+ return map;
187
+ }, [events, hidden, zone, colors]);
188
+ // The days on screen: whole weeks around the month, one week, or the month as a list.
189
+ const { y, m } = ymd(anchor);
190
+ const first = dayOf(y, m, 1);
191
+ const lead = (d) => (weekday(d) - weekStartsOn + 7) % 7;
192
+ const [from, to] = view === 'week'
193
+ ? [anchor - lead(anchor), anchor - lead(anchor) + 7]
194
+ : view === 'month'
195
+ ? [
196
+ first - lead(first),
197
+ first - lead(first) + Math.ceil((lead(first) + daysIn(y, m)) / 7) * 7,
198
+ ]
199
+ : [first, first + daysIn(y, m)];
200
+ const ranged = useRef(onRangeChange);
201
+ ranged.current = onRangeChange;
202
+ useEffect(() => {
203
+ ranged.current?.({ start: midnight(from, zone), end: midnight(to, zone), view });
204
+ }, [from, to, zone, view]);
205
+ const fmt = useMemo(() => {
206
+ const f = (o) => new Intl.DateTimeFormat(undefined, { timeZone: 'UTC', ...o });
207
+ return {
208
+ month: f({ month: 'long', year: 'numeric' }),
209
+ range: f({ month: 'short', day: 'numeric', year: 'numeric' }),
210
+ weekday: f({ weekday: narrow ? 'narrow' : 'short' }),
211
+ dayLong: f({ weekday: 'short', month: 'short', day: 'numeric' }),
212
+ time: f({ hour: 'numeric', minute: '2-digit' }),
213
+ hour: f({ hour: 'numeric' }),
214
+ };
215
+ }, [narrow]);
216
+ const timeOf = (p, day) => p.allDay || day !== p.startDay ? 'All day' : fmt.time.format(asDate(p.startDay, p.startMin));
217
+ const title = view === 'week'
218
+ ? fmt.range.formatRange(asDate(from), asDate(to - 1))
219
+ : fmt.month.format(asDate(first));
220
+ const step = (n) => {
221
+ const next = view === 'week' ? anchor + n * 7 : addMonths(anchor, n);
222
+ setPicked(null);
223
+ setDate(midnight(next, zone));
224
+ };
225
+ const unit = view === 'week' ? 'week' : 'month';
226
+ const tabs = [
227
+ { value: 'month', label: 'Month', icon: CalendarDaysIcon },
228
+ { value: 'week', label: 'Week', icon: CalendarRangeIcon },
229
+ { value: 'agenda', label: 'Agenda', icon: ListIcon },
230
+ ];
231
+ // On a narrow month the picked day's list sits under the dots: today when it is on screen.
232
+ const listDay = picked ??
233
+ (view === 'month' && narrow
234
+ ? today >= first && today < first + daysIn(y, m)
235
+ ? today
236
+ : first
237
+ : null);
238
+ const row = (p, day) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => onSelect?.(p.ev), className: "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx("time", { className: "w-16 shrink-0 truncate text-muted-foreground", children: timeOf(p, day) }), _jsx(ColorDot, { color: p.color }), _jsx("span", { className: "min-w-0 flex-1 truncate", title: p.ev.title, children: p.ev.title }), !narrow && p.ev.category && catLabel.has(p.ev.category) ? (_jsx("span", { className: "max-w-40 shrink truncate text-xs text-muted-foreground", children: catLabel.get(p.ev.category) })) : null] }) }, `${p.ev.id}:${day}`));
239
+ const dayList = (day, list) => (_jsxs("section", { "aria-label": fmt.dayLong.format(asDate(day)), children: [_jsxs("h3", { className: "sticky top-0 z-10 flex items-center gap-2 bg-background px-2 py-1 text-xs font-medium text-muted-foreground", children: [_jsx("span", { className: "truncate", children: fmt.dayLong.format(asDate(day)) }), day === today ? _jsx("span", { className: "text-primary", children: "Today" }) : null] }), _jsx("ul", { children: list.map((p) => row(p, day)) })] }, day));
240
+ const nothing = (_jsx(Empty, { className: "py-10", children: _jsx(EmptyHeader, { children: _jsx(EmptyTitle, { className: "text-sm font-normal text-muted-foreground", children: empty }) }) }));
241
+ let body;
242
+ if (view === 'agenda') {
243
+ const days = [];
244
+ for (let d = from; d < to; d++) {
245
+ const list = byDay.get(d);
246
+ if (list?.length)
247
+ days.push(dayList(d, list));
248
+ }
249
+ body = days.length ? _jsx("div", { className: "flex flex-col gap-2", children: days }) : nothing;
250
+ }
251
+ else if (view === 'month') {
252
+ const cells = [];
253
+ for (let d = from; d < to; d++) {
254
+ const list = byDay.get(d) ?? [];
255
+ const out = d < first || d >= first + daysIn(y, m);
256
+ const day = ymd(d).d;
257
+ const pick = () => setPicked(d === picked ? null : d);
258
+ cells.push(_jsxs("div", { className: cn('flex min-w-0 flex-col gap-0.5 border-t border-l p-1', narrow ? 'min-h-12 items-center' : 'min-h-24', out && 'bg-muted/40 text-muted-foreground', d === listDay && 'bg-accent'), children: [_jsx("button", { type: "button", onClick: pick, "aria-label": `${fmt.dayLong.format(asDate(d))}, ${list.length} events`, "aria-pressed": d === listDay, className: cn('num flex size-6 shrink-0 items-center justify-center rounded-full text-xs focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50', d === today ? 'bg-primary font-semibold text-primary-foreground' : 'hover:bg-muted'), children: day }), narrow ? (_jsx("span", { className: "flex h-2 items-center gap-0.5", "aria-hidden": true, children: list.slice(0, CELL_EVENTS).map((p) => (_jsx("span", { className: "size-1.5 rounded-full", style: { background: p.color } }, p.ev.id))) })) : (_jsxs(_Fragment, { children: [list.slice(0, CELL_EVENTS).map((p) => (_jsxs("button", { type: "button", onClick: () => onSelect?.(p.ev), title: p.ev.title, className: "flex w-full min-w-0 items-center gap-1 rounded-sm px-1 text-left text-xs hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: p.color } }), !p.allDay && d === p.startDay ? (_jsx("time", { className: "num shrink-0 text-muted-foreground", children: fmt.time.format(asDate(d, p.startMin)) })) : null, _jsx("span", { className: "min-w-0 truncate", children: p.ev.title })] }, p.ev.id))), list.length > CELL_EVENTS ? (_jsxs("button", { type: "button", onClick: pick, className: "num truncate rounded-sm px-1 text-left text-xs text-muted-foreground hover:bg-muted", children: ["+", list.length - CELL_EVENTS, " more"] })) : null] }))] }, d));
259
+ }
260
+ const heads = [];
261
+ for (let i = 0; i < 7; i++)
262
+ heads.push(_jsx("div", { className: "truncate px-1 py-1 text-center text-xs text-muted-foreground", children: fmt.weekday.format(asDate(from + i)) }, i));
263
+ const listed = listDay !== null ? (byDay.get(listDay) ?? []) : null;
264
+ body = (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "overflow-hidden rounded-md border-r border-b", children: [_jsx("div", { className: "grid grid-cols-7", children: heads }), _jsx("div", { className: "grid grid-cols-7", children: cells })] }), listed !== null && listDay !== null ? (listed.length ? (dayList(listDay, listed)) : (_jsxs("p", { className: "px-2 text-sm text-muted-foreground", children: [fmt.dayLong.format(asDate(listDay)), ": ", empty] }))) : null] }));
265
+ }
266
+ else {
267
+ body = (_jsx(WeekGrid, { from: from, today: today, byDay: byDay, fmt: fmt, onSelect: onSelect, timeOf: timeOf }));
268
+ }
269
+ return (_jsxs("div", { ref: box, className: cn('flex min-w-0 flex-col gap-3', className), children: [_jsx(LumenTabs, { tabs: tabs, value: view, onChange: (v) => {
270
+ setPicked(null);
271
+ setView(v);
272
+ }, actions: _jsxs(_Fragment, { children: [_jsx(IconButton, { icon: CalendarCheckIcon, label: "Today", onClick: () => {
273
+ setPicked(null);
274
+ setDate(new Date());
275
+ } }), _jsx(IconButton, { icon: ChevronLeftIcon, label: `Previous ${unit}`, onClick: () => step(-1) }), _jsx(IconButton, { icon: ChevronRightIcon, label: `Next ${unit}`, onClick: () => step(1) })] }) }), _jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2", children: [_jsx("h2", { className: "min-w-0 truncate text-sm font-semibold", "aria-live": "polite", children: title }), categories?.length ? (_jsx(ToggleGroup, { type: "multiple", size: "sm", "aria-label": "Categories", value: categories.filter((c) => !hidden.has(c.id)).map((c) => c.id), onValueChange: (on) => setHidden(new Set(categories.filter((c) => !on.includes(c.id)).map((c) => c.id))), className: "flex-wrap gap-1", children: categories.map((c) => (_jsx(ToggleGroupItem, { value: c.id, "aria-label": c.label, className: "h-auto rounded-sm p-0 data-[state=off]:opacity-50 data-[state=on]:bg-transparent", children: _jsx(ColorChip, { color: c.color, children: c.label }) }, c.id))) })) : null] }), _jsx(LumenBoundary, { where: "calendar", children: body })] }));
276
+ }
277
+ // ── Week ──────────────────────────────────────────────────────────────────────
278
+ function WeekGrid({ from, today, byDay, fmt, onSelect, timeOf, }) {
279
+ const scroller = useRef(null);
280
+ const days = Array.from({ length: 7 }, (_, i) => from + i);
281
+ const timed = days.map((d) => (byDay.get(d) ?? []).filter((p) => !p.allDay && p.startDay === d));
282
+ const whole = days.map((d) => (byDay.get(d) ?? []).filter((p) => p.allDay || p.startDay !== d));
283
+ const earliest = Math.min(OPEN_HOUR, ...timed.flat().map((p) => Math.floor(p.startMin / 60)));
284
+ // Opening a week scrolls the axis to its first event, or to the morning.
285
+ useLayoutEffect(() => {
286
+ const el = scroller.current;
287
+ const hour = el?.querySelector('[data-hour]');
288
+ if (el && hour)
289
+ el.scrollTop = Math.max(0, (earliest - 0.25) * hour.offsetHeight);
290
+ }, [earliest]);
291
+ const cols = { gridTemplateColumns: '3rem repeat(7, minmax(0, 1fr))' };
292
+ const hours = Array.from({ length: 24 }, (_, h) => h);
293
+ return (_jsxs("div", { className: "flex flex-col rounded-md border", style: { ['--cal-hour']: '3rem' }, children: [_jsxs("div", { className: "grid border-b", style: cols, children: [_jsx("div", {}), days.map((d) => (_jsxs("div", { className: "flex min-w-0 flex-col items-center py-1 text-xs", children: [_jsx("span", { className: "truncate text-muted-foreground", children: fmt.weekday.format(asDate(d)) }), _jsx("span", { className: cn('num flex size-6 items-center justify-center rounded-full', d === today && 'bg-primary font-semibold text-primary-foreground'), children: ymd(d).d })] }, d)))] }), whole.some((l) => l.length) ? (_jsxs("div", { className: "grid border-b", style: cols, children: [_jsx("div", { className: "truncate px-1 py-1 text-right text-xs text-muted-foreground", children: "All day" }), whole.map((list, i) => (_jsx("div", { className: "flex min-w-0 flex-col gap-0.5 border-l p-0.5", children: list.map((p) => (_jsx(WeekEvent, { p: p, onSelect: onSelect, label: p.ev.title }, p.ev.id))) }, days[i])))] })) : null, _jsx("div", { ref: scroller, className: "max-h-[60vh] overflow-y-auto", children: _jsxs("div", { className: "relative grid", style: cols, children: [_jsx("div", { children: hours.map((h) => (_jsx("div", { "data-hour": true, className: "relative pr-1 text-right text-xs text-muted-foreground", style: { height: 'var(--cal-hour)' }, children: h ? (_jsx("span", { className: "num -translate-y-1/2 absolute right-1 whitespace-nowrap", children: fmt.hour.format(asDate(0, h * 60)) })) : null }, h))) }), days.map((d, i) => (_jsxs("div", { className: cn('relative border-l', d === today && 'bg-accent/40'), children: [hours.map((h) => (_jsx("div", { className: "border-t", style: { height: 'var(--cal-hour)' } }, h))), lanes(timed[i] ?? []).map(({ p, lane, of }) => (_jsx("div", { className: "absolute px-px", style: {
294
+ top: `calc(${p.startMin / 60} * var(--cal-hour))`,
295
+ height: `calc(${(Math.min(p.endMin, 1440) - p.startMin) / 60} * var(--cal-hour))`,
296
+ left: `${(lane / of) * 100}%`,
297
+ width: `${100 / of}%`,
298
+ }, children: _jsx(WeekEvent, { p: p, onSelect: onSelect, label: `${timeOf(p, d)} ${p.ev.title}`, tall: true, time: p.endMin - p.startMin >= 45
299
+ ? fmt.time.format(asDate(d, p.startMin))
300
+ : undefined }) }, p.ev.id)))] }, d)))] }) })] }));
301
+ }
302
+ function WeekEvent({ p, onSelect, label, tall, time, }) {
303
+ return (_jsxs("button", { type: "button", title: label, "aria-label": label, onClick: () => onSelect?.(p.ev), className: cn('flex w-full min-w-0 flex-col overflow-hidden rounded-sm border-l-2 px-1 text-left text-xs focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50', tall && 'h-full'), style: {
304
+ borderColor: p.color,
305
+ background: `color-mix(in oklab, ${p.color} 16%, var(--background))`,
306
+ color: `color-mix(in oklab, ${p.color} 45%, var(--foreground))`,
307
+ }, children: [_jsx("span", { className: "w-full truncate font-medium", children: p.ev.title }), time ? _jsx("time", { className: "num w-full truncate opacity-80", children: time }) : null] }));
308
+ }
@@ -89,7 +89,7 @@ export function LumenFilterPanel({ filter, open, onOpenChange, tips, order, titl
89
89
  /** One field: its values ticked in place, or a number field's range. */
90
90
  function Section({ field, filter, facets: { counts, empties }, tip, }) {
91
91
  const { tokens, setTokens } = filter;
92
- const shown = countsShown(filter);
92
+ const shown = countsShown(filter, field.key);
93
93
  const mine = tokens.filter((t) => t.key === field.key);
94
94
  const not = mine.some((t) => t.not);
95
95
  const [more, setMore] = useState(false);
@@ -36,10 +36,13 @@ export declare function filterByTokens<T>(rows: T[], fields: FilterField<T>[], t
36
36
  /** The row's text, for `contains` tokens. */
37
37
  text?: (row: T) => string[]): T[];
38
38
  /** A server's counts per field: each value's rows, and the rows with no value (`none`), over the
39
- * rows every other field's tokens keep. */
39
+ * rows every other field's tokens keep. `counted: false` gives a field's values to pick without
40
+ * counts, for when the server could not count them without reading every row: the box and the
41
+ * panel list the values and show no numbers. */
40
42
  export type FacetCounts = Record<string, {
41
43
  values: Record<string, number>;
42
44
  none?: number;
45
+ counted?: boolean;
43
46
  }>;
44
47
  /**
45
48
  * Each field's values with their counts, shared by the filter box and the filter panel. A field
@@ -50,11 +53,12 @@ export declare function useFacetCounts<T>(filter: LumenFilterState<T>): {
50
53
  counts: Map<string, Map<string, number>>;
51
54
  empties: Map<string, number>;
52
55
  };
53
- /** Whether a count means something: over a server's page only when the server counted. */
56
+ /** Whether a count means something: over a server's page only when the server counted (that
57
+ * field, when `key` is given). */
54
58
  export declare const countsShown: (filter: {
55
59
  partial: boolean;
56
60
  facets: FacetCounts | null;
57
- }) => boolean;
61
+ }, key?: string) => boolean;
58
62
  /** Keeps the tokens and text, and filters `rows` by them. */
59
63
  export declare function useLumenFilter<T>(rows: T[], fields: FilterField<T>[], { text: textOf, initial, partial, facets, }?: {
60
64
  /** Free-text haystack per row (a name, an id), besides the field values. */
@@ -149,8 +149,9 @@ export function useFacetCounts(filter) {
149
149
  return { counts, empties };
150
150
  }, [fields, indexed, tokens, facets]);
151
151
  }
152
- /** Whether a count means something: over a server's page only when the server counted. */
153
- export const countsShown = (filter) => !filter.partial || !!filter.facets;
152
+ /** Whether a count means something: over a server's page only when the server counted (that
153
+ * field, when `key` is given). */
154
+ export const countsShown = (filter, key) => (filter.facets ? key === undefined || filter.facets[key]?.counted !== false : !filter.partial);
154
155
  /** Keeps the tokens and text, and filters `rows` by them. */
155
156
  export function useLumenFilter(rows, fields, { text: textOf, initial = [], partial = false, facets, } = {}) {
156
157
  const [tokens, setRaw] = useState(() => onePerField(initial));
@@ -312,7 +313,7 @@ function NumberCondition({ label, current, tall, count, onSet, }) {
312
313
  export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label = 'Filter', className, saved, onSave, changed, link: given, }) {
313
314
  const { fields, indexed, tokens, setTokens, text, setText, hasText } = filter;
314
315
  // Over one page of a server's rows a count is not the real one: show none, unless the server counted.
315
- const tally = (n) => (countsShown(filter) ? n : '');
316
+ const tally = (n, key) => (countsShown(filter, key) ? n : '');
316
317
  // Text counts are over the rows loaded: none over a server's page.
317
318
  const pageTally = (n) => (filter.partial ? '' : n);
318
319
  // A box its card passes no link takes the card's own (every card carries a square).
@@ -530,14 +531,14 @@ export function LumenTokenizedFilter({ filter, placeholder = 'Filter…', label
530
531
  return (_jsxs(CommandItem, { value: `${key}:${value}`, onSelect: () => toggle(key, value), className: tall, children: [_jsx(Checkbox, { checked: on, tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), prefix && _jsx("span", { className: "shrink-0 text-muted-foreground", children: prefix }), _jsx("span", { className: "min-w-0 truncate", children: value }), _jsx(Button, { variant: "ghost", size: "xs", className: "-my-1 ml-auto h-6 px-1.5 font-normal text-muted-foreground opacity-0 group-data-selected/command-item:opacity-100 max-md:opacity-100", "aria-label": `Only ${value}`, onMouseDown: (e) => e.preventDefault(), onClick: (e) => {
531
532
  e.stopPropagation();
532
533
  only(key, value);
533
- }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: tally(count) })] }, `${key}:${value}`));
534
+ }, children: "Only" }), _jsx(CommandShortcut, { className: cn('ml-0 w-8 text-right', shortcut), children: tally(count, key) })] }, `${key}:${value}`));
534
535
  };
535
536
  // A field's "No <field>" row, pinned above its values: the rows with no value (Exclude: any).
536
537
  const emptyRow = (f, include) => {
537
538
  const n = empties.get(f.key) ?? 0;
538
539
  if (!n && !hasEmpty(f.key))
539
540
  return null;
540
- return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: tally(n) })] }, `${f.key}:${EMPTY_VALUE}`));
541
+ return (_jsxs(CommandItem, { value: `${f.key}:${EMPTY_VALUE}`, onSelect: () => toggleEmpty(f.key, include ? false : undefined), className: tall, children: [_jsx(Checkbox, { checked: hasEmpty(f.key), tabIndex: -1, "aria-hidden": true, className: "pointer-events-none" }), _jsx("span", { className: "min-w-0 truncate text-muted-foreground italic", children: noneOf(f.label) }), _jsx(CommandShortcut, { className: cn('ml-auto w-8 text-right', shortcut), children: tally(n, f.key) })] }, `${f.key}:${EMPTY_VALUE}`));
541
542
  };
542
543
  const summary = (c) => {
543
544
  const w = chipWords(c, fieldOf(c.key), 'full');
@@ -19,6 +19,7 @@ import { ConnectOffer } from './connections.js';
19
19
  import { usePageKey } from './fold.js';
20
20
  import { useGroup } from './group-button.js';
21
21
  import { useIsMobile } from './hooks/use-mobile.js';
22
+ import { useTell } from './hooks/use-tell.js';
22
23
  import { SourceStatusItems, useJoins } from './joins.js';
23
24
  import { useYourData } from './joins.js';
24
25
  import { lazyPart } from './lazy-part.js';
@@ -249,7 +250,8 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
249
250
  savedFiltersRef.current = savedFilters;
250
251
  setTokens(config.filters.default);
251
252
  }, [savedFilters, config.filters.default, setTokens]);
252
- useEffect(() => onTokens?.(filter.tokens), [onTokens, filter.tokens]);
253
+ // Told through a ref, so an app's inline callback never loops (useTell).
254
+ useTell(filter.tokens, onTokens);
253
255
  const group = useGroup(domain, config);
254
256
  // The sort the person set by a header click, held like the chips and grouping (rule C).
255
257
  const [heldSort, setHeldSort] = useState(config.sort);
@@ -262,8 +264,7 @@ function ViewLayout({ crumbs, actions, meta, status, intro, cards: extra, lead,
262
264
  setHeldSort(config.sort);
263
265
  }, [savedSort, config.sort]);
264
266
  // A server's page sorts on the server: it hears each sort held.
265
- const onServerSort = paging?.onSort;
266
- useEffect(() => onServerSort?.(heldSort ?? null), [onServerSort, heldSort]);
267
+ useTell(heldSort ?? null, paging?.onSort);
267
268
  const [details, setDetails] = useState(null);
268
269
  const onDetails = onOpen ?? (domain.details?.length ? setDetails : undefined);
269
270
  const openKey = onOpen ? (openedKey ?? null) : details === null ? null : domain.rowKey(details);
@@ -0,0 +1,58 @@
1
+ export interface PlayerTrack {
2
+ /** A WebVTT file. */
3
+ src: string;
4
+ srcLang: string;
5
+ label: string;
6
+ default?: boolean;
7
+ }
8
+ export interface PlayerChapter {
9
+ /** Seconds from the start. */
10
+ at: number;
11
+ title: string;
12
+ }
13
+ export interface PlayerLine {
14
+ start: number;
15
+ end?: number;
16
+ text: string;
17
+ /** Who says it, before the line. */
18
+ who?: string;
19
+ }
20
+ export interface PlayerSlide {
21
+ /** Seconds into the audio when the slide shows. */
22
+ at: number;
23
+ image: string;
24
+ alt?: string;
25
+ }
26
+ export type PlayerSource = {
27
+ kind: 'slides';
28
+ slides: readonly PlayerSlide[];
29
+ audio: string;
30
+ };
31
+ export interface LumenPlayerProps {
32
+ /** The recording's address; or `source` for slides over audio. */
33
+ src?: string;
34
+ source?: PlayerSource;
35
+ /** `video` or `audio`; read from the address's extension by default (video when unsure). */
36
+ kind?: 'video' | 'audio';
37
+ /** The recording's name, for screen readers and full screen. */
38
+ title?: string;
39
+ poster?: string;
40
+ /** Captions (video). */
41
+ tracks?: readonly PlayerTrack[];
42
+ chapters?: readonly PlayerChapter[];
43
+ transcript?: readonly PlayerLine[];
44
+ /** Where to start, in seconds (a saved place, or a search hit's minute). */
45
+ start?: number;
46
+ /** The place, about every 10 s while playing, on pause and at the end. */
47
+ onProgress?: (seconds: number, info: {
48
+ duration: number | null;
49
+ ended: boolean;
50
+ }) => void;
51
+ onEnded?: () => void;
52
+ /** Plays as soon as it can (the next item of a series, after the last one ended). */
53
+ autoPlay?: boolean;
54
+ className?: string;
55
+ }
56
+ /** `1:02`, `1:02:03`. */
57
+ export declare function playerClock(seconds: number): string;
58
+ export declare function LumenPlayer({ src, source, kind, title, poster, tracks, chapters, transcript, start, onProgress, onEnded, autoPlay, className, }: LumenPlayerProps): import("react").JSX.Element;
@@ -0,0 +1,244 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // LumenPlayer: a recording, video or audio, on the browser's own <video>/<audio> (served with byte
3
+ // ranges, so seeking reads only what it needs) with the kit's controls under it: play, back and
4
+ // forward 15 s, the seek bar and its times, speed, captions and full screen. Chapters jump; a timed
5
+ // transcript lights the line being said, keeps it in view inside its own box and plays from a
6
+ // line on a tap. It starts at `start` and reports its place through `onProgress` about every 10 s
7
+ // while it plays, on pause and at the end, so an app can save where it was left. A slides source
8
+ // is pictures in step with an audio track: the slide shown is the last one whose `at` has passed.
9
+ // Keys work only while the player has focus: space or k plays, j and l skip 15 s, ← and → 5 s,
10
+ // f full screen, c captions.
11
+ import { CaptionsIcon, GaugeIcon, MaximizeIcon, MinimizeIcon, PauseIcon, PlayIcon, RotateCcwIcon, RotateCwIcon, } from 'lucide-react';
12
+ import { useEffect, useRef, useState, } from 'react';
13
+ import { cn } from './lib/utils.js';
14
+ import { LumenSelector } from './lumen-selector.js';
15
+ import { IconButton } from './page.js';
16
+ import { Slider } from './ui/slider.js';
17
+ import { Spinner } from './ui/spinner.js';
18
+ const SPEEDS = ['0.75', '1', '1.25', '1.5', '1.75', '2'];
19
+ const SKIP = 15;
20
+ const NUDGE = 5;
21
+ const REPORT_MS = 10_000;
22
+ /** After a person scrolls the transcript, it stops following for this long. */
23
+ const HANDS_OFF_MS = 5_000;
24
+ const AUDIO = /\.(mp3|m4a|aac|wav|ogg|oga|opus|flac)(\?|#|$)/i;
25
+ /** `1:02`, `1:02:03`. */
26
+ export function playerClock(seconds) {
27
+ const s = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
28
+ const h = Math.floor(s / 3600);
29
+ const m = Math.floor((s % 3600) / 60);
30
+ const ss = String(s % 60).padStart(2, '0');
31
+ return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
32
+ }
33
+ /** The last entry whose time has passed, by binary search over a list sorted by time. */
34
+ function lastAt(list, time, now) {
35
+ let lo = 0;
36
+ let hi = list.length - 1;
37
+ let at = -1;
38
+ while (lo <= hi) {
39
+ const mid = (lo + hi) >> 1;
40
+ if (time(list[mid]) <= now) {
41
+ at = mid;
42
+ lo = mid + 1;
43
+ }
44
+ else
45
+ hi = mid - 1;
46
+ }
47
+ return at;
48
+ }
49
+ export function LumenPlayer({ src, source, kind, title, poster, tracks, chapters, transcript, start, onProgress, onEnded, autoPlay, className, }) {
50
+ const root = useRef(null);
51
+ const media = useRef(null);
52
+ const url = source?.kind === 'slides' ? source.audio : (src ?? '');
53
+ const slides = source?.kind === 'slides' ? source.slides : null;
54
+ const video = !slides && (kind ? kind === 'video' : !AUDIO.test(url));
55
+ const [now, setNow] = useState(start ?? 0);
56
+ const [duration, setDuration] = useState(null);
57
+ const [playing, setPlaying] = useState(false);
58
+ const [waiting, setWaiting] = useState(false);
59
+ const [failed, setFailed] = useState(false);
60
+ const [rate, setRate] = useState('1');
61
+ const [captions, setCaptions] = useState(() => !!tracks?.some((t) => t.default));
62
+ const [full, setFull] = useState(false);
63
+ const last = useRef(0);
64
+ const progress = useRef(onProgress);
65
+ progress.current = onProgress;
66
+ const report = (ended = false) => {
67
+ const el = media.current;
68
+ // Opening a recording and leaving it unplayed saves nothing.
69
+ if (!el || !Number.isFinite(el.currentTime) || (!ended && el.currentTime < 1))
70
+ return;
71
+ last.current = Date.now();
72
+ progress.current?.(Math.round(el.currentTime), {
73
+ duration: Number.isFinite(el.duration) ? Math.round(el.duration) : null,
74
+ ended,
75
+ });
76
+ };
77
+ // Leaving the player (closing its drawer) saves the place too.
78
+ // biome-ignore lint/correctness/useExhaustiveDependencies: report reads refs only, once on leave
79
+ useEffect(() => {
80
+ const el = media.current;
81
+ return () => {
82
+ if (el && !el.ended && el.currentTime >= 1) {
83
+ progress.current?.(Math.round(el.currentTime), {
84
+ duration: Number.isFinite(el.duration) ? Math.round(el.duration) : null,
85
+ ended: false,
86
+ });
87
+ }
88
+ };
89
+ }, [url]);
90
+ useEffect(() => {
91
+ const on = () => setFull(document.fullscreenElement === root.current);
92
+ document.addEventListener('fullscreenchange', on);
93
+ return () => document.removeEventListener('fullscreenchange', on);
94
+ }, []);
95
+ // Captions follow the toggle once the tracks have loaded.
96
+ useEffect(() => {
97
+ const list = media.current?.textTracks;
98
+ if (!list)
99
+ return;
100
+ for (let i = 0; i < list.length; i++) {
101
+ const t = list[i];
102
+ if (t)
103
+ t.mode = captions && i === 0 ? 'showing' : 'hidden';
104
+ }
105
+ }, [captions]);
106
+ const seek = (s, play = false) => {
107
+ const el = media.current;
108
+ if (!el)
109
+ return;
110
+ const end = Number.isFinite(el.duration) ? el.duration : Number.POSITIVE_INFINITY;
111
+ el.currentTime = Math.min(Math.max(0, s), end);
112
+ setNow(el.currentTime);
113
+ if (play)
114
+ void el.play().catch(() => { });
115
+ };
116
+ const toggle = () => {
117
+ const el = media.current;
118
+ if (!el)
119
+ return;
120
+ if (el.paused)
121
+ void el.play().catch(() => { });
122
+ else
123
+ el.pause();
124
+ };
125
+ const fullscreen = () => {
126
+ if (document.fullscreenElement)
127
+ void document.exitFullscreen().catch(() => { });
128
+ else
129
+ void root.current?.requestFullscreen?.().catch(() => { });
130
+ };
131
+ const onKey = (e) => {
132
+ const t = e.target;
133
+ // Fields, menus and the seek bar keep their own keys.
134
+ if (t.closest('input, textarea, select, [role="slider"], [role="combobox"], [role="listbox"]'))
135
+ return;
136
+ const here = media.current?.currentTime ?? 0;
137
+ const keys = {
138
+ ' ': toggle,
139
+ k: toggle,
140
+ j: () => seek(here - SKIP),
141
+ l: () => seek(here + SKIP),
142
+ ArrowLeft: () => seek(here - NUDGE),
143
+ ArrowRight: () => seek(here + NUDGE),
144
+ ...(video ? { f: fullscreen } : {}),
145
+ ...(video && tracks?.length ? { c: () => setCaptions((c) => !c) } : {}),
146
+ };
147
+ const run = keys[e.key];
148
+ // Space on a focused button presses it; the player takes it only on itself or the media.
149
+ if (!run || e.metaKey || e.ctrlKey || e.altKey)
150
+ return;
151
+ if (e.key === ' ' && t.closest('button'))
152
+ return;
153
+ e.preventDefault();
154
+ run();
155
+ };
156
+ const events = {
157
+ ref: media,
158
+ src: url,
159
+ preload: 'metadata',
160
+ playsInline: true,
161
+ onLoadedMetadata: (e) => {
162
+ const el = e.currentTarget;
163
+ setFailed(false);
164
+ setDuration(Number.isFinite(el.duration) ? el.duration : null);
165
+ el.playbackRate = Number(rate);
166
+ if (start && start > 0 && (!Number.isFinite(el.duration) || start < el.duration - 1)) {
167
+ el.currentTime = start;
168
+ setNow(start);
169
+ }
170
+ if (autoPlay)
171
+ void el.play().catch(() => { });
172
+ },
173
+ onDurationChange: (e) => {
174
+ const d = e.currentTarget.duration;
175
+ setDuration(Number.isFinite(d) ? d : null);
176
+ },
177
+ onTimeUpdate: (e) => {
178
+ const el = e.currentTarget;
179
+ setNow(el.currentTime);
180
+ if (!el.paused && Date.now() - last.current >= REPORT_MS)
181
+ report();
182
+ },
183
+ onPlay: () => setPlaying(true),
184
+ onPause: () => {
185
+ setPlaying(false);
186
+ report();
187
+ },
188
+ onWaiting: () => setWaiting(true),
189
+ onPlaying: () => setWaiting(false),
190
+ onCanPlay: () => setWaiting(false),
191
+ onError: () => {
192
+ setWaiting(false);
193
+ setFailed(true);
194
+ },
195
+ onEnded: () => {
196
+ setPlaying(false);
197
+ report(true);
198
+ onEnded?.();
199
+ },
200
+ };
201
+ const slide = slides ? (slides[lastAt(slides, (s) => s.at, now)] ?? slides[0]) : null;
202
+ const chapter = chapters?.length ? lastAt(chapters, (c) => c.at, now) : -1;
203
+ const end = duration ?? 0;
204
+ return (_jsxs("section", { ref: root,
205
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: the player takes its keys while focused
206
+ tabIndex: 0, onKeyDown: onKey, "aria-label": title ? `${title} player` : 'Player', className: cn('flex min-w-0 flex-col gap-3 rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50', full && 'justify-center bg-background p-4', className), children: [_jsxs("div", { className: cn('relative overflow-hidden rounded-md', (video || slides) && 'bg-black', full && 'min-h-0 flex-1'), children: [video ? (_jsx("video", { ...events, poster: poster, title: title, onClick: toggle, className: cn('w-full', full ? 'h-full object-contain' : 'aspect-video'), children: tracks?.map((t) => (_jsx("track", { kind: "captions", src: t.src, srcLang: t.srcLang, label: t.label, default: t.default }, t.src))) }, url)) : (_jsxs(_Fragment, { children: [_jsx("audio", { ...events, title: title, className: "hidden" }, url), slide ? (_jsx("img", { src: slide.image, alt: slide.alt ?? title ?? '', className: cn('w-full object-contain', full ? 'h-full' : 'aspect-video') })) : null] })), waiting && !failed ? (_jsx("span", { className: "absolute inset-0 flex items-center justify-center bg-black/20", children: _jsx(Spinner, { className: "size-6 text-white" }) })) : null, failed ? (_jsxs("div", { role: "alert", className: cn('flex items-center justify-center gap-2 p-4 text-sm', video || slides ? 'absolute inset-0 bg-black/70 text-white' : 'text-destructive'), children: [_jsx("span", { className: "truncate", children: "Could not load this recording" }), _jsx(IconButton, { icon: RotateCwIcon, label: "Try again", variant: "secondary", onClick: () => {
207
+ setFailed(false);
208
+ media.current?.load();
209
+ } })] })) : null] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Slider, { "aria-label": "Seek", min: 0, max: end || 1, step: 1, value: [Math.min(now, end || 1)], disabled: !end, onValueChange: ([v]) => v !== undefined && seek(v), className: "py-2" }), _jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [_jsx(IconButton, { icon: playing ? PauseIcon : PlayIcon, label: playing ? 'Pause' : 'Play', variant: "default", onClick: toggle, disabled: failed }), _jsx(IconButton, { icon: RotateCcwIcon, label: `Back ${SKIP} seconds`, onClick: () => seek(now - SKIP) }), _jsx(IconButton, { icon: RotateCwIcon, label: `Forward ${SKIP} seconds`, onClick: () => seek(now + SKIP) }), _jsxs("span", { className: "num ml-1 min-w-0 truncate text-xs text-muted-foreground", children: [playerClock(now), " / ", duration === null ? '–:––' : playerClock(duration)] }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(LumenSelector, { trigger: "chip", size: "sm", icon: GaugeIcon, label: "Speed", value: rate, display: _jsxs("span", { className: "num", children: [rate, "\u00D7"] }), options: SPEEDS.map((s) => ({ value: s, label: `${s}×` })), search: false, align: "end", onChange: (v) => {
210
+ setRate(v);
211
+ if (media.current)
212
+ media.current.playbackRate = Number(v);
213
+ } }), video && tracks?.length ? (_jsx(IconButton, { icon: CaptionsIcon, label: captions ? 'Hide captions' : 'Show captions', pressed: captions, onClick: () => setCaptions((c) => !c) })) : null, video ? (_jsx(IconButton, { icon: full ? MinimizeIcon : MaximizeIcon, label: full ? 'Exit full screen' : 'Full screen', onClick: fullscreen })) : null] })] })] }), !full && chapters?.length ? (_jsx(PlayerList, { label: "Chapters", children: chapters.map((c, i) => (_jsx(PlayerRow, { at: c.at, current: i === chapter, onPlay: () => seek(c.at, true), children: c.title }, `${c.at}:${c.title}`))) })) : null, !full && transcript?.length ? (_jsx(Transcript, { lines: transcript, now: now, playing: playing, onPlay: (s) => seek(s, true) })) : null] }));
214
+ }
215
+ function PlayerList({ label, children }) {
216
+ return (_jsxs("section", { "aria-label": label, className: "flex flex-col gap-1", children: [_jsx("h3", { className: "text-xs font-medium text-muted-foreground", children: label }), _jsx("ol", { className: "flex flex-col", children: children })] }));
217
+ }
218
+ function PlayerRow({ at, current, onPlay, children, wrap, index, }) {
219
+ return (_jsx("li", { "data-line": index, children: _jsxs("button", { type: "button", onClick: onPlay, "aria-current": current || undefined, className: cn('flex w-full min-w-0 gap-3 rounded-md px-2 py-1 text-left text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50', wrap ? 'items-baseline' : 'items-center', current && 'bg-accent text-accent-foreground'), children: [_jsx("time", { className: "num w-14 shrink-0 text-xs text-muted-foreground", children: playerClock(at) }), _jsx("span", { className: cn('min-w-0 flex-1', wrap ? 'break-words' : 'truncate'), children: children })] }) }));
220
+ }
221
+ function Transcript({ lines, now, playing, onPlay, }) {
222
+ const box = useRef(null);
223
+ const touched = useRef(0);
224
+ const at = lastAt(lines, (l) => l.start, now);
225
+ const line = lines[at];
226
+ const current = line && (line.end === undefined || now < line.end) ? at : -1;
227
+ // While it plays, the line being said stays in view inside the box (never the page), unless the
228
+ // person has just scrolled it themselves.
229
+ useEffect(() => {
230
+ const el = box.current;
231
+ if (!playing || current < 0 || !el || Date.now() - touched.current < HANDS_OFF_MS)
232
+ return;
233
+ const row = el.querySelector(`[data-line="${current}"]`);
234
+ if (!row)
235
+ return;
236
+ const top = row.offsetTop - el.offsetTop;
237
+ if (top < el.scrollTop || top + row.offsetHeight > el.scrollTop + el.clientHeight)
238
+ el.scrollTo({ top: top - el.clientHeight / 3, behavior: 'smooth' });
239
+ }, [current, playing]);
240
+ const mark = () => {
241
+ touched.current = Date.now();
242
+ };
243
+ return (_jsxs("section", { "aria-label": "Transcript", className: "flex flex-col gap-1", children: [_jsx("h3", { className: "text-xs font-medium text-muted-foreground", children: "Transcript" }), _jsx("div", { ref: box, onWheel: mark, onTouchMove: mark, className: "relative max-h-[40vh] overflow-y-auto overscroll-contain", children: _jsx("ol", { className: "flex flex-col", children: lines.map((l, i) => (_jsxs(PlayerRow, { index: i, at: l.start, current: i === current, onPlay: () => onPlay(l.start), wrap: true, children: [l.who ? _jsxs("span", { className: "font-medium", children: [l.who, ": "] }) : null, l.text] }, `${l.start}:${i}`))) }) })] }));
244
+ }
@@ -15,6 +15,7 @@ import { DrawerSection } from './drawer-section.js';
15
15
  import { useFloatingHead } from './floating-head.js';
16
16
  import { FreshnessMark, freshnessLine, freshnessOf } from './freshness.js';
17
17
  import { useIsMobile } from './hooks/use-mobile.js';
18
+ import { useTell } from './hooks/use-tell.js';
18
19
  import { IconButton } from './icon-button.js';
19
20
  import { lazyPart } from './lazy-part.js';
20
21
  import { cn } from './lib/utils.js';
@@ -236,8 +237,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
236
237
  // server groups it.
237
238
  const groupBy = server && !server.groups ? null : pickedGroup;
238
239
  const served = server?.groups && groupBy ? server.groups : null;
239
- const onGroup = server?.groups?.onGroup;
240
- useEffect(() => onGroup?.(groupBy?.key ?? null), [onGroup, groupBy?.key]);
240
+ useTell(groupBy?.key ?? null, server?.groups?.onGroup);
241
241
  const pivot = server ? false : pickedPivot;
242
242
  // Live columns on screen keep their source reading while the market trades (#861).
243
243
  const load = useLiveLoad(sourceLoad, columns.some((c) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.160",
3
+ "version": "0.1.162",
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.160",
52
+ "@columenlabs/lumen-ui-types": "0.1.162",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",