@columenlabs/lumen-ui 0.1.159 → 0.1.161

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
+ }
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';
@@ -89,7 +92,8 @@ export { type ConnectedDomain, ConnectOffer, connectionFields, linkGroupId } fro
89
92
  export { FRESHNESS, type Freshness, FreshnessIcon, FreshnessMark, freshnessLine, freshnessOf, } from './freshness.js';
90
93
  export { SourcesLine, useSourceHealth } from './source-health.js';
91
94
  export { TILE_WIDTH, Tile, TileChip, type TileOption, TileRow, TileSpark, TileToggleGroup, } from './tile.js';
92
- export { type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, useLumenFilter, } from './lumen-filter.js';
95
+ export { type FacetCounts, type FilterField, type FilterToken, LumenTokenizedFilter, type LumenFilterState, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
96
+ export { LumenFilterPanel, LumenFilterPanelButton, type LumenFilterPanelProps, useFilterPanel, } from './lumen-filter-panel.js';
93
97
  export { useIsMobile } from './hooks/use-mobile.js';
94
98
  export { useBackClose } from './hooks/use-back-close.js';
95
99
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.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';
@@ -96,7 +99,8 @@ export { ConnectOffer, connectionFields, linkGroupId } from './connections.js';
96
99
  export { FRESHNESS, FreshnessIcon, FreshnessMark, freshnessLine, freshnessOf, } from './freshness.js';
97
100
  export { SourcesLine, useSourceHealth } from './source-health.js';
98
101
  export { TILE_WIDTH, Tile, TileChip, TileRow, TileSpark, TileToggleGroup, } from './tile.js';
99
- export { LumenTokenizedFilter, useLumenFilter, } from './lumen-filter.js';
102
+ export { LumenTokenizedFilter, countsShown, useFacetCounts, useLumenFilter, } from './lumen-filter.js';
103
+ export { LumenFilterPanel, LumenFilterPanelButton, useFilterPanel, } from './lumen-filter-panel.js';
100
104
  export { useIsMobile } from './hooks/use-mobile.js';
101
105
  export { useBackClose } from './hooks/use-back-close.js';
102
106
  export { VIEW_ICONS, iconOf, viewIcon } from './view-icons.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
+ }
@@ -0,0 +1,30 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type LumenFilterState } from './lumen-filter.js';
3
+ export interface LumenFilterPanelProps<T> {
4
+ /** The filter's state, the same one the page's filter box draws. */
5
+ filter: LumenFilterState<T>;
6
+ /** Open (desktop: shown beside the page; phone: the sheet is up). */
7
+ open: boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ /** A field's details, behind an (i) beside its name. Most fields need none. */
10
+ tips?: Record<string, ReactNode>;
11
+ /** The fields in the order shown (default: the filter's own order). Fields left out are not
12
+ * shown here, but still filter from the box. */
13
+ order?: string[];
14
+ /** Its heading (default `Filters`). */
15
+ title?: string;
16
+ className?: string;
17
+ }
18
+ /**
19
+ * Remembers whether the panel is open on this device, per page (`id`). Desktop starts open,
20
+ * a phone closed (its sheet opens from the filter icon).
21
+ */
22
+ export declare function useFilterPanel(id: string): {
23
+ open: boolean;
24
+ onOpenChange: (next: boolean) => void;
25
+ };
26
+ /** The filter icon that shows the panel (or opens its sheet on a phone), with how many fields
27
+ * filter now. On a desktop it shows only while the panel is hidden. */
28
+ export declare function LumenFilterPanelButton<T>({ filter, open, onOpenChange, }: Pick<LumenFilterPanelProps<T>, 'filter' | 'open' | 'onOpenChange'>): import("react").JSX.Element | null;
29
+ /** The filter panel: a side panel on a desktop, a bottom sheet on a phone. */
30
+ export declare function LumenFilterPanel<T>({ filter, open, onOpenChange, tips, order, title, className, }: LumenFilterPanelProps<T>): import("react").JSX.Element | null;