@stll/ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import { CalendarCell, CalendarEntryButton, CalendarEntrySurface, CalendarGrid, CalendarHeaderCell, CalendarHeaderRow } from "./primitives.js";
2
+ import { CalendarDateRange, ResourceCalendarLaneLayout, ResourceCalendarLanePlacement, ResourceCalendarPlacement, assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutResourceCalendarEntries, nextCalendarDate } from "./resource-calendar.logic.js";
3
+ import { ResourceCalendar, ResourceCalendarColumn, ResourceCalendarEntry, ResourceCalendarEntryTone, ResourceCalendarResource } from "./resource-calendar.js";
4
+ export { CalendarCell, type CalendarDateRange, CalendarEntryButton, CalendarEntrySurface, CalendarGrid, CalendarHeaderCell, CalendarHeaderRow, ResourceCalendar, type ResourceCalendarColumn, type ResourceCalendarEntry, type ResourceCalendarEntryTone, type ResourceCalendarLaneLayout, type ResourceCalendarLanePlacement, type ResourceCalendarPlacement, type ResourceCalendarResource, assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutResourceCalendarEntries, nextCalendarDate };
@@ -0,0 +1,4 @@
1
+ import { CalendarCell, CalendarEntryButton, CalendarEntrySurface, CalendarGrid, CalendarHeaderCell, CalendarHeaderRow } from "./primitives.js";
2
+ import { assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutResourceCalendarEntries, nextCalendarDate } from "./resource-calendar.logic.js";
3
+ import { ResourceCalendar } from "./resource-calendar.js";
4
+ export { CalendarCell, CalendarEntryButton, CalendarEntrySurface, CalendarGrid, CalendarHeaderCell, CalendarHeaderRow, ResourceCalendar, assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutResourceCalendarEntries, nextCalendarDate };
@@ -0,0 +1,10 @@
1
+ import * as React$1 from "react";
2
+ //#region src/calendar/primitives.d.ts
3
+ declare const CalendarGrid: ({ className, ...props }: React$1.ComponentProps<"div">) => React$1.JSX.Element;
4
+ declare const CalendarHeaderRow: ({ className, ...props }: React$1.ComponentProps<"div">) => React$1.JSX.Element;
5
+ declare const CalendarHeaderCell: ({ className, ...props }: React$1.ComponentProps<"div">) => React$1.JSX.Element;
6
+ declare const CalendarCell: ({ className, ...props }: React$1.ComponentProps<"div">) => React$1.JSX.Element;
7
+ declare const CalendarEntryButton: ({ className, ...props }: React$1.ComponentProps<"button">) => React$1.JSX.Element;
8
+ declare const CalendarEntrySurface: ({ className, ...props }: React$1.ComponentProps<"article">) => React$1.JSX.Element;
9
+ //#endregion
10
+ export { CalendarCell, CalendarEntryButton, CalendarEntrySurface, CalendarGrid, CalendarHeaderCell, CalendarHeaderRow };
@@ -0,0 +1,36 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/calendar/primitives.tsx
4
+ const CalendarGrid = ({ className, ...props }) => /* @__PURE__ */ jsx("div", {
5
+ className: cn("grid", className),
6
+ "data-slot": "calendar-grid",
7
+ ...props
8
+ });
9
+ const CalendarHeaderRow = ({ className, ...props }) => /* @__PURE__ */ jsx("div", {
10
+ className: cn("grid border-b", className),
11
+ "data-slot": "calendar-header-row",
12
+ ...props
13
+ });
14
+ const CalendarHeaderCell = ({ className, ...props }) => /* @__PURE__ */ jsx("div", {
15
+ className: cn("text-muted-foreground border-e px-2 py-2 text-center text-xs font-medium last:border-e-0", className),
16
+ "data-slot": "calendar-header-cell",
17
+ ...props
18
+ });
19
+ const CalendarCell = ({ className, ...props }) => /* @__PURE__ */ jsx("div", {
20
+ className: cn("border-e border-b", className),
21
+ "data-slot": "calendar-cell",
22
+ ...props
23
+ });
24
+ const CalendarEntryButton = ({ className, ...props }) => /* @__PURE__ */ jsx("button", {
25
+ className: cn("focus-visible:ring-ring min-w-0 rounded-md text-start text-xs outline-none focus-visible:ring-2 focus-visible:ring-offset-1", className),
26
+ "data-slot": "calendar-entry",
27
+ type: "button",
28
+ ...props
29
+ });
30
+ const CalendarEntrySurface = ({ className, ...props }) => /* @__PURE__ */ jsx("article", {
31
+ className: cn("min-w-0 rounded-md text-start text-xs", className),
32
+ "data-slot": "calendar-entry",
33
+ ...props
34
+ });
35
+ //#endregion
36
+ export { CalendarCell, CalendarEntryButton, CalendarEntrySurface, CalendarGrid, CalendarHeaderCell, CalendarHeaderRow };
@@ -0,0 +1,35 @@
1
+ import { ReactElement, ReactNode } from "react";
2
+ //#region src/calendar/resource-calendar.d.ts
3
+ type ResourceCalendarColumn = {
4
+ date: string;
5
+ label: ReactNode;
6
+ meta?: ReactNode;
7
+ };
8
+ type ResourceCalendarResource = {
9
+ id: string;
10
+ label: ReactNode;
11
+ meta?: ReactNode;
12
+ };
13
+ type ResourceCalendarEntryTone = "accent" | "neutral" | "warning" | "destructive";
14
+ type ResourceCalendarEntry = {
15
+ accessibleLabel: string;
16
+ endDateExclusive: string;
17
+ id: string;
18
+ label: ReactNode;
19
+ meta?: ReactNode;
20
+ resourceId: string;
21
+ startDate: string;
22
+ tone?: ResourceCalendarEntryTone;
23
+ };
24
+ type ResourceCalendarProps = {
25
+ ariaLabel: string;
26
+ columns: readonly ResourceCalendarColumn[];
27
+ empty?: ReactNode;
28
+ entries: readonly ResourceCalendarEntry[];
29
+ onSelectEntry?: (entry: ResourceCalendarEntry) => void;
30
+ resourceHeader: ReactNode;
31
+ resources: readonly ResourceCalendarResource[];
32
+ };
33
+ declare const ResourceCalendar: ({ ariaLabel, columns, empty, entries, onSelectEntry, resourceHeader, resources }: ResourceCalendarProps) => ReactElement;
34
+ //#endregion
35
+ export { ResourceCalendar, ResourceCalendarColumn, ResourceCalendarEntry, ResourceCalendarEntryTone, ResourceCalendarResource };
@@ -0,0 +1,184 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { CalendarCell, CalendarEntryButton, CalendarEntrySurface, CalendarHeaderCell, CalendarHeaderRow } from "./primitives.js";
3
+ import { assertConsecutiveCalendarDates, layoutResourceCalendarEntries, nextCalendarDate } from "./resource-calendar.logic.js";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { useId } from "react";
6
+ //#region src/calendar/resource-calendar.tsx
7
+ const ResourceCalendar = ({ ariaLabel, columns, empty, entries, onSelectEntry, resourceHeader, resources }) => {
8
+ assertResourceCalendarContract({
9
+ columns,
10
+ entries,
11
+ resources
12
+ });
13
+ const calendarId = useId();
14
+ if (resources.length === 0) return /* @__PURE__ */ jsx("div", {
15
+ "data-slot": "resource-calendar-empty",
16
+ children: empty
17
+ });
18
+ const gridStyle = { gridTemplateColumns: `14rem repeat(${String(columns.length)}, minmax(7rem, 1fr))` };
19
+ const calendarWidthStyle = { minWidth: `${String(14 + columns.length * 7)}rem` };
20
+ const visibleRange = {
21
+ endDateExclusive: nextCalendarDate(columns.at(-1)?.date ?? ""),
22
+ startDate: columns.at(0)?.date ?? ""
23
+ };
24
+ const entriesByResource = /* @__PURE__ */ new Map();
25
+ for (const entry of entries) {
26
+ const existing = entriesByResource.get(entry.resourceId);
27
+ if (existing === void 0) {
28
+ entriesByResource.set(entry.resourceId, [entry]);
29
+ continue;
30
+ }
31
+ existing.push(entry);
32
+ }
33
+ const getColumnHeaderId = (index) => `${calendarId}-column-${String(index)}`;
34
+ return /* @__PURE__ */ jsx("section", {
35
+ "aria-colcount": columns.length + 1,
36
+ "aria-label": ariaLabel,
37
+ "aria-rowcount": resources.length + 1,
38
+ className: "border-border bg-card overflow-x-auto rounded-xl border",
39
+ "data-slot": "resource-calendar",
40
+ role: "table",
41
+ children: /* @__PURE__ */ jsxs("div", {
42
+ className: "w-full",
43
+ style: calendarWidthStyle,
44
+ children: [/* @__PURE__ */ jsxs(CalendarHeaderRow, {
45
+ role: "row",
46
+ style: gridStyle,
47
+ children: [/* @__PURE__ */ jsx(CalendarHeaderCell, {
48
+ "aria-colindex": 1,
49
+ className: "bg-card sticky start-0 z-30 text-start font-semibold tracking-wide uppercase",
50
+ role: "columnheader",
51
+ children: resourceHeader
52
+ }), columns.map((column, index) => /* @__PURE__ */ jsxs(CalendarHeaderCell, {
53
+ "aria-colindex": index + 2,
54
+ id: getColumnHeaderId(index),
55
+ role: "columnheader",
56
+ children: [/* @__PURE__ */ jsx("span", {
57
+ className: "block",
58
+ children: column.label
59
+ }), column.meta === void 0 ? null : /* @__PURE__ */ jsx("span", {
60
+ className: "text-foreground mt-0.5 block font-semibold",
61
+ children: column.meta
62
+ })]
63
+ }, column.date))]
64
+ }), resources.map((resource, resourceIndex) => {
65
+ const resourceEntries = entriesByResource.get(resource.id) ?? [];
66
+ const layout = layoutResourceCalendarEntries(resourceEntries, visibleRange);
67
+ const resourceEntryById = new Map(resourceEntries.map((entry) => [entry.id, entry]));
68
+ const visibleEntriesByColumn = /* @__PURE__ */ new Map();
69
+ for (const placement of layout.placements) {
70
+ const entry = resourceEntryById.get(placement.entryId);
71
+ if (entry === void 0) throw new TypeError(`Resource calendar layout references unknown entry ${placement.entryId}`);
72
+ const existing = visibleEntriesByColumn.get(placement.columnStart);
73
+ if (existing === void 0) {
74
+ visibleEntriesByColumn.set(placement.columnStart, [{
75
+ entry,
76
+ placement
77
+ }]);
78
+ continue;
79
+ }
80
+ existing.push({
81
+ entry,
82
+ placement
83
+ });
84
+ }
85
+ const resourceHeaderId = `${calendarId}-resource-${String(resourceIndex)}`;
86
+ const resourceRowStyle = {
87
+ ...gridStyle,
88
+ minHeight: `${String(Math.max(5, layout.rowCount * 3.75))}rem`
89
+ };
90
+ return /* @__PURE__ */ jsxs("div", {
91
+ className: "border-border relative grid min-h-20 border-b last:border-b-0",
92
+ "data-slot": "resource-calendar-row",
93
+ role: "row",
94
+ style: resourceRowStyle,
95
+ children: [/* @__PURE__ */ jsxs(CalendarCell, {
96
+ "aria-colindex": 1,
97
+ "aria-rowindex": resourceIndex + 2,
98
+ className: "bg-card sticky start-0 z-20 border-b-0 px-4 py-3",
99
+ id: resourceHeaderId,
100
+ role: "rowheader",
101
+ children: [/* @__PURE__ */ jsx("span", {
102
+ className: "block truncate text-sm font-semibold",
103
+ children: resource.label
104
+ }), resource.meta === void 0 ? null : /* @__PURE__ */ jsx("span", {
105
+ className: "text-muted-foreground mt-1 block truncate text-xs",
106
+ children: resource.meta
107
+ })]
108
+ }), columns.map((column, columnIndex) => {
109
+ const columnStart = columnIndex + 2;
110
+ const visibleEntries = visibleEntriesByColumn.get(columnStart) ?? [];
111
+ return /* @__PURE__ */ jsx(CalendarCell, {
112
+ "aria-colindex": columnStart,
113
+ "aria-labelledby": `${resourceHeaderId} ${getColumnHeaderId(columnIndex)}`,
114
+ "aria-rowindex": resourceIndex + 2,
115
+ className: "bg-background/40 relative border-b-0 last:border-e-0",
116
+ role: "gridcell",
117
+ children: visibleEntries.map(({ entry, placement }) => {
118
+ const labelledBy = [resourceHeaderId, ...Array.from({ length: placement.span }, (_, index) => getColumnHeaderId(columnIndex + index))].join(" ");
119
+ return /* @__PURE__ */ jsx(ResourceCalendarEntryView, {
120
+ entry,
121
+ labelledBy,
122
+ onSelectEntry,
123
+ placement,
124
+ rowCount: layout.rowCount
125
+ }, entry.id);
126
+ })
127
+ }, column.date);
128
+ })]
129
+ }, resource.id);
130
+ })]
131
+ })
132
+ });
133
+ };
134
+ const RESOURCE_CALENDAR_ENTRY_TONES = {
135
+ accent: "bg-primary text-primary-foreground",
136
+ destructive: "border-destructive/32 bg-destructive/12 text-foreground border",
137
+ neutral: "border-border bg-muted text-foreground border",
138
+ warning: "border-warning/30 bg-warning/15 text-foreground border"
139
+ };
140
+ const ResourceCalendarEntryView = ({ entry, labelledBy, onSelectEntry, placement, rowCount }) => {
141
+ const className = cn("h-full w-full px-3 py-2 font-medium shadow-sm", RESOURCE_CALENDAR_ENTRY_TONES[entry.tone ?? "accent"]);
142
+ const style = {
143
+ blockSize: `${String(100 / rowCount)}%`,
144
+ insetBlockStart: `${String((placement.rowStart - 1) * 100 / rowCount)}%`,
145
+ insetInlineStart: 0,
146
+ inlineSize: `calc(${String(placement.span)} * 100%)`
147
+ };
148
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
149
+ className: "block truncate",
150
+ children: entry.label
151
+ }), entry.meta === void 0 ? null : /* @__PURE__ */ jsx("span", {
152
+ className: "block truncate opacity-75",
153
+ children: entry.meta
154
+ })] });
155
+ return /* @__PURE__ */ jsx("div", {
156
+ className: "absolute z-10 min-w-0 p-2",
157
+ style,
158
+ children: onSelectEntry === void 0 ? /* @__PURE__ */ jsx(CalendarEntrySurface, {
159
+ "aria-describedby": labelledBy,
160
+ "aria-label": entry.accessibleLabel,
161
+ className,
162
+ children: content
163
+ }) : /* @__PURE__ */ jsx(CalendarEntryButton, {
164
+ "aria-describedby": labelledBy,
165
+ "aria-label": entry.accessibleLabel,
166
+ className,
167
+ onClick: () => onSelectEntry(entry),
168
+ children: content
169
+ })
170
+ });
171
+ };
172
+ const assertUniqueIds = (ids, label) => {
173
+ if (new Set(ids).size !== ids.length) throw new TypeError(`${label} ids must be unique`);
174
+ };
175
+ const assertResourceCalendarContract = ({ columns, entries, resources }) => {
176
+ assertConsecutiveCalendarDates(columns.map(({ date }) => date));
177
+ assertUniqueIds(resources.map(({ id }) => id), "Resource calendar resource");
178
+ assertUniqueIds(entries.map(({ id }) => id), "Resource calendar entry");
179
+ const resourceIds = new Set(resources.map(({ id }) => id));
180
+ const orphan = entries.find(({ resourceId }) => !resourceIds.has(resourceId));
181
+ if (orphan !== void 0) throw new TypeError(`Resource calendar entry ${orphan.id} references an unknown resource`);
182
+ };
183
+ //#endregion
184
+ export { ResourceCalendar };
@@ -0,0 +1,28 @@
1
+ //#region src/calendar/resource-calendar.logic.d.ts
2
+ type CalendarDateRange = {
3
+ endDateExclusive: string;
4
+ startDate: string;
5
+ };
6
+ type ResourceCalendarPlacement = {
7
+ columnStart: number;
8
+ span: number;
9
+ };
10
+ type ResourceCalendarLanePlacement = ResourceCalendarPlacement & {
11
+ entryId: string;
12
+ rowStart: number;
13
+ };
14
+ type ResourceCalendarLaneLayout = {
15
+ placements: ResourceCalendarLanePlacement[];
16
+ rowCount: number;
17
+ };
18
+ declare const getResourceCalendarPlacement: ({ entry, visibleRange }: {
19
+ entry: CalendarDateRange;
20
+ visibleRange: CalendarDateRange;
21
+ }) => ResourceCalendarPlacement | null;
22
+ declare const assertConsecutiveCalendarDates: (dates: readonly string[]) => void;
23
+ declare const nextCalendarDate: (value: string) => string;
24
+ declare const layoutResourceCalendarEntries: (entries: readonly (CalendarDateRange & {
25
+ id: string;
26
+ })[], visibleRange: CalendarDateRange) => ResourceCalendarLaneLayout;
27
+ //#endregion
28
+ export { CalendarDateRange, ResourceCalendarLaneLayout, ResourceCalendarLanePlacement, ResourceCalendarPlacement, assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutResourceCalendarEntries, nextCalendarDate };
@@ -0,0 +1,88 @@
1
+ //#region src/calendar/resource-calendar.logic.ts
2
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
3
+ const DAY_IN_MS = 864e5;
4
+ const toUTCDate = (value) => {
5
+ if (!ISO_DATE_PATTERN.test(value)) return null;
6
+ const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
7
+ return date.toISOString().slice(0, 10) === value ? date : null;
8
+ };
9
+ const differenceInCalendarDays = (later, earlier) => {
10
+ const laterDate = toUTCDate(later);
11
+ const earlierDate = toUTCDate(earlier);
12
+ if (laterDate === null || earlierDate === null) throw new RangeError("Calendar dates must use normalized YYYY-MM-DD values");
13
+ return (laterDate.getTime() - earlierDate.getTime()) / DAY_IN_MS;
14
+ };
15
+ const getResourceCalendarPlacement = ({ entry, visibleRange }) => {
16
+ const visibleDayCount = differenceInCalendarDays(visibleRange.endDateExclusive, visibleRange.startDate);
17
+ const entryDayCount = differenceInCalendarDays(entry.endDateExclusive, entry.startDate);
18
+ if (visibleDayCount <= 0 || entryDayCount <= 0) throw new RangeError("Calendar date ranges must be non-empty and half-open");
19
+ if (entry.startDate >= visibleRange.endDateExclusive || entry.endDateExclusive <= visibleRange.startDate) return null;
20
+ const clippedStart = entry.startDate < visibleRange.startDate ? visibleRange.startDate : entry.startDate;
21
+ const clippedEnd = entry.endDateExclusive > visibleRange.endDateExclusive ? visibleRange.endDateExclusive : entry.endDateExclusive;
22
+ return {
23
+ columnStart: differenceInCalendarDays(clippedStart, visibleRange.startDate) + 2,
24
+ span: differenceInCalendarDays(clippedEnd, clippedStart)
25
+ };
26
+ };
27
+ const assertConsecutiveCalendarDates = (dates) => {
28
+ if (dates.length === 0) throw new RangeError("A resource calendar needs at least one date column");
29
+ const first = dates.at(0);
30
+ if (first === void 0 || toUTCDate(first) === null) throw new RangeError("Resource calendar date columns must be consecutive normalized dates");
31
+ for (let index = 1; index < dates.length; index += 1) {
32
+ const previous = dates.at(index - 1);
33
+ const current = dates.at(index);
34
+ if (previous === void 0 || current === void 0 || differenceInCalendarDays(current, previous) !== 1) throw new RangeError("Resource calendar date columns must be consecutive normalized dates");
35
+ }
36
+ };
37
+ const nextCalendarDate = (value) => {
38
+ const date = toUTCDate(value);
39
+ if (date === null) throw new RangeError("Calendar dates must use normalized YYYY-MM-DD values");
40
+ date.setUTCDate(date.getUTCDate() + 1);
41
+ const nextDate = date.toISOString().slice(0, 10);
42
+ if (toUTCDate(nextDate) === null) throw new RangeError("Calendar dates must have a following normalized YYYY-MM-DD value");
43
+ return nextDate;
44
+ };
45
+ const layoutResourceCalendarEntries = (entries, visibleRange) => {
46
+ const visibleEntries = [];
47
+ const entryIds = /* @__PURE__ */ new Set();
48
+ for (const entry of entries) {
49
+ if (entryIds.has(entry.id)) throw new TypeError("Resource calendar entry ids must be unique");
50
+ entryIds.add(entry.id);
51
+ const placement = getResourceCalendarPlacement({
52
+ entry,
53
+ visibleRange
54
+ });
55
+ if (placement === null) continue;
56
+ visibleEntries.push({
57
+ columnStart: placement.columnStart,
58
+ entryId: entry.id,
59
+ span: placement.span
60
+ });
61
+ }
62
+ visibleEntries.sort((left, right) => {
63
+ const startDifference = left.columnStart - right.columnStart;
64
+ if (startDifference !== 0) return startDifference;
65
+ if (left.entryId < right.entryId) return -1;
66
+ if (left.entryId > right.entryId) return 1;
67
+ return 0;
68
+ });
69
+ const laneEnds = [];
70
+ const placements = [];
71
+ for (const entry of visibleEntries) {
72
+ const firstAvailableLane = laneEnds.findIndex((laneEnd) => laneEnd <= entry.columnStart);
73
+ const lane = firstAvailableLane === -1 ? laneEnds.length : firstAvailableLane;
74
+ laneEnds[lane] = entry.columnStart + entry.span;
75
+ placements.push({
76
+ columnStart: entry.columnStart,
77
+ entryId: entry.entryId,
78
+ rowStart: lane + 1,
79
+ span: entry.span
80
+ });
81
+ }
82
+ return {
83
+ placements,
84
+ rowCount: Math.max(1, laneEnds.length)
85
+ };
86
+ };
87
+ //#endregion
88
+ export { assertConsecutiveCalendarDates, getResourceCalendarPlacement, layoutResourceCalendarEntries, nextCalendarDate };
@@ -10,7 +10,7 @@
10
10
  */
11
11
  declare const buttonAccessibleDisabledClass = "cursor-not-allowed opacity-64";
12
12
  declare const buttonVariants: (props?: ({
13
- size?: "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "sm" | "xl" | "xs" | null | undefined;
13
+ size?: "icon" | "sm" | "default" | "lg" | "icon-lg" | "icon-sm" | "icon-xl" | "icon-xs" | "xl" | "xs" | null | undefined;
14
14
  variant?: "link" | "default" | "destructive" | "destructive-outline" | "ghost" | "outline" | "secondary" | null | undefined;
15
15
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
16
16
  //#endregion
@@ -3,9 +3,13 @@ import { ReactNode, RefObject } from "react";
3
3
  type OutlineItem = {
4
4
  id: string;
5
5
  label: string;
6
+ /** What the entry contains, after the label that names it. The label
7
+ * stays whole; the title is what truncates when the row is narrow. */
8
+ title?: string;
6
9
  /** Nesting depth among included items; drives indent + tick taper. */
7
10
  level: number;
8
- /** Optional trailing annotation in the panel (e.g. a page number). */
11
+ /** Optional trailing annotation in the panel (e.g. a page number or a
12
+ * provision range); never truncated. */
9
13
  meta?: string;
10
14
  /** Optional CSS custom-property name colouring this entry's tick + chip
11
15
  * (e.g. "--option-blue"). Defaults to the neutral foreground. */
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { cn } from "../lib/utils.js";
3
3
  import { Tooltip, TooltipContent as TooltipPopup, TooltipTrigger } from "./tooltip.js";
4
- import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
6
6
  //#region src/components/outline-rail.tsx
7
7
  /**
@@ -23,6 +23,8 @@ const TICK_BASE_WIDTH = 6;
23
23
  const TICK_LEVEL_STEP = 2;
24
24
  const TICK_MAX_LEVEL = 5;
25
25
  const RAIL_MAX_TICKS = 40;
26
+ /** The entry as one line of text: label, then its title when it has one. */
27
+ const entryText = (item) => item.title === void 0 ? item.label : `${item.label} ${item.title}`;
26
28
  const tickWidth = (level) => {
27
29
  return TICK_BASE_WIDTH + (TICK_MAX_LEVEL - Math.min(Math.max(level, 0), TICK_MAX_LEVEL)) * TICK_LEVEL_STEP;
28
30
  };
@@ -199,7 +201,8 @@ const OutlineRail = ({ items, scrollContainerRef, resolvePct, onJump, activeId,
199
201
  }
200
202
  onJumpRef.current(id, container);
201
203
  }, [activeId, scrollContainerRef]);
202
- const toggleCollapse = useCallback((id, level, rowEl) => {
204
+ const toggleCollapse = useCallback((id, rowEl) => {
205
+ const rowTop = rowEl?.getBoundingClientRect().top;
203
206
  setToggled((prev) => new Set(prev).add(id));
204
207
  setCollapsed((prev) => {
205
208
  const next = new Set(prev);
@@ -209,9 +212,8 @@ const OutlineRail = ({ items, scrollContainerRef, resolvePct, onJump, activeId,
209
212
  });
210
213
  requestAnimationFrame(() => {
211
214
  const panel = panelRef.current;
212
- if (!panel || !rowEl) return;
213
- const target = panel.getBoundingClientRect().top + level * ROW_H;
214
- panel.scrollTop += rowEl.getBoundingClientRect().top - target;
215
+ if (!panel || !rowEl || rowTop === void 0) return;
216
+ panel.scrollTop += rowEl.getBoundingClientRect().top - rowTop;
215
217
  });
216
218
  }, []);
217
219
  const openPanel = useCallback(() => {
@@ -261,7 +263,7 @@ const OutlineRail = ({ items, scrollContainerRef, resolvePct, onJump, activeId,
261
263
  "aria-expanded": !isCollapsed,
262
264
  "aria-label": isCollapsed ? "Expand" : "Collapse",
263
265
  className: "text-muted-foreground hover:text-foreground flex size-5 shrink-0 items-center justify-center",
264
- onClick: (event) => toggleCollapse(node.item.id, node.item.level, event.currentTarget.parentElement),
266
+ onClick: (event) => toggleCollapse(node.item.id, event.currentTarget.parentElement),
265
267
  style: { marginInlineStart: indent - 4 },
266
268
  type: "button",
267
269
  children: /* @__PURE__ */ jsx(Chevron, { open: !isCollapsed })
@@ -277,12 +279,21 @@ const OutlineRail = ({ items, scrollContainerRef, resolvePct, onJump, activeId,
277
279
  }),
278
280
  /* @__PURE__ */ jsxs(Tooltip, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
279
281
  render: /* @__PURE__ */ jsx("button", {
280
- className: cn("min-w-0 flex-1 truncate py-1.5 text-start text-[13px] leading-snug", rowTextClass(isActive, hasChildren)),
282
+ className: cn("flex min-w-0 flex-1 items-baseline gap-1.5 py-1.5 text-start text-[13px] leading-snug", rowTextClass(isActive, hasChildren)),
281
283
  onClick: () => jumpTo(node.item.id),
282
284
  type: "button"
283
285
  }),
284
- children: node.item.label
285
- }), /* @__PURE__ */ jsx(TooltipPopup, { children: node.item.label })] }),
286
+ children: node.item.title === void 0 ? /* @__PURE__ */ jsx("span", {
287
+ className: "min-w-0 truncate",
288
+ children: node.item.label
289
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
290
+ className: "shrink-0 font-medium",
291
+ children: node.item.label
292
+ }), /* @__PURE__ */ jsx("span", {
293
+ className: "min-w-0 truncate font-normal",
294
+ children: node.item.title
295
+ })] })
296
+ }), /* @__PURE__ */ jsx(TooltipPopup, { children: entryText(node.item) })] }),
286
297
  node.item.meta !== void 0 && /* @__PURE__ */ jsx("span", {
287
298
  className: "text-foreground-placeholder shrink-0 ps-2 text-[11px] tabular-nums",
288
299
  children: node.item.meta
@@ -0,0 +1,2 @@
1
+ import { TableColumnCapabilities, TableColumnDescriptor, TableSchema, duplicateColumnIds, findTableColumn, hideableColumnIds, sortableColumnIds, tableColumnIds, tableColumnSizing, visibleColumnIds } from "./schema.js";
2
+ export { type TableColumnCapabilities, type TableColumnDescriptor, type TableSchema, duplicateColumnIds, findTableColumn, hideableColumnIds, sortableColumnIds, tableColumnIds, tableColumnSizing, visibleColumnIds };
@@ -0,0 +1,2 @@
1
+ import { duplicateColumnIds, findTableColumn, hideableColumnIds, sortableColumnIds, tableColumnIds, tableColumnSizing, visibleColumnIds } from "./schema.js";
2
+ export { duplicateColumnIds, findTableColumn, hideableColumnIds, sortableColumnIds, tableColumnIds, tableColumnSizing, visibleColumnIds };
@@ -0,0 +1,70 @@
1
+ //#region src/data-table/schema.d.ts
2
+ /**
3
+ * A table's schema: which columns exist, what each can do, and how wide it
4
+ * starts. Not how a cell draws — that is the caller's, and it is the reason
5
+ * this module holds no React.
6
+ *
7
+ * The split matters because "which columns does this view have, and which of
8
+ * them can be sorted, hidden, resized or pinned" is a question about data, and
9
+ * a question about data can be answered by a test. It used to be answerable
10
+ * only by rendering the table and asking TanStack.
11
+ */
12
+ /** What a reader may do to a column. */
13
+ type TableColumnCapabilities = {
14
+ sort: boolean;
15
+ hide: boolean;
16
+ resize: boolean;
17
+ pin: boolean;
18
+ };
19
+ /**
20
+ * One column.
21
+ *
22
+ * `render` is whatever the caller needs to draw the column: this module never
23
+ * looks inside it, which is what keeps the schema free of any idea about what
24
+ * a row holds.
25
+ */
26
+ type TableColumnDescriptor<TRender> = {
27
+ /** Unique within the schema, and stable across renders. */
28
+ id: string;
29
+ /** Header text. Empty for a column whose header draws no text. */
30
+ label: string;
31
+ render: TRender;
32
+ /** Starting width, in pixels. */
33
+ size: number;
34
+ /** Narrowest this column may be resized to; the schema's default if absent. */
35
+ minSize?: number | undefined;
36
+ capabilities: TableColumnCapabilities;
37
+ /**
38
+ * Metadata columns read quieter than content ones; a utility column
39
+ * (a selection checkbox, an add-column affordance) draws no value at all.
40
+ */
41
+ emphasis: "content" | "metadata" | "utility";
42
+ };
43
+ type TableSchema<TRender> = {
44
+ columns: readonly TableColumnDescriptor<TRender>[];
45
+ /** Narrowest any column may be resized to. */
46
+ defaultMinSize: number;
47
+ };
48
+ /** Every column a schema declares, in order. */
49
+ declare const tableColumnIds: <TRender>(schema: TableSchema<TRender>) => string[];
50
+ declare const sortableColumnIds: <TRender>(schema: TableSchema<TRender>) => string[];
51
+ declare const hideableColumnIds: <TRender>(schema: TableSchema<TRender>) => string[];
52
+ declare const findTableColumn: <TRender>(schema: TableSchema<TRender>, id: string) => TableColumnDescriptor<TRender> | undefined;
53
+ /**
54
+ * Which columns a view shows.
55
+ *
56
+ * A column that cannot be hidden stays visible whatever the stored hidden list
57
+ * says, so a stale list — a column that lost its hide capability while it was
58
+ * hidden — cannot strand the table without its select or name column.
59
+ */
60
+ declare const visibleColumnIds: <TRender>(schema: TableSchema<TRender>, hiddenColumnIds: readonly string[]) => string[];
61
+ /** The starting width of every column, keyed by id. */
62
+ declare const tableColumnSizing: <TRender>(schema: TableSchema<TRender>) => Record<string, number>;
63
+ /**
64
+ * A duplicate column id silently drops a column: the table keys by id, so the
65
+ * second declaration wins and the first disappears with no error anywhere.
66
+ * Callers that build a schema from user data (a property list) check here.
67
+ */
68
+ declare const duplicateColumnIds: <TRender>(schema: TableSchema<TRender>) => string[];
69
+ //#endregion
70
+ export { TableColumnCapabilities, TableColumnDescriptor, TableSchema, duplicateColumnIds, findTableColumn, hideableColumnIds, sortableColumnIds, tableColumnIds, tableColumnSizing, visibleColumnIds };
@@ -0,0 +1,40 @@
1
+ //#region src/data-table/schema.ts
2
+ /** Every column a schema declares, in order. */
3
+ const tableColumnIds = (schema) => schema.columns.map((column) => column.id);
4
+ const withCapability = (schema, capability) => schema.columns.filter((column) => column.capabilities[capability]).map((column) => column.id);
5
+ const sortableColumnIds = (schema) => withCapability(schema, "sort");
6
+ const hideableColumnIds = (schema) => withCapability(schema, "hide");
7
+ const findTableColumn = (schema, id) => schema.columns.find((column) => column.id === id);
8
+ /**
9
+ * Which columns a view shows.
10
+ *
11
+ * A column that cannot be hidden stays visible whatever the stored hidden list
12
+ * says, so a stale list — a column that lost its hide capability while it was
13
+ * hidden — cannot strand the table without its select or name column.
14
+ */
15
+ const visibleColumnIds = (schema, hiddenColumnIds) => {
16
+ const hidden = new Set(hiddenColumnIds);
17
+ return schema.columns.filter((column) => !column.capabilities.hide || !hidden.has(column.id)).map((column) => column.id);
18
+ };
19
+ /** The starting width of every column, keyed by id. */
20
+ const tableColumnSizing = (schema) => {
21
+ const sizing = {};
22
+ for (const column of schema.columns) sizing[column.id] = column.size;
23
+ return sizing;
24
+ };
25
+ /**
26
+ * A duplicate column id silently drops a column: the table keys by id, so the
27
+ * second declaration wins and the first disappears with no error anywhere.
28
+ * Callers that build a schema from user data (a property list) check here.
29
+ */
30
+ const duplicateColumnIds = (schema) => {
31
+ const seen = /* @__PURE__ */ new Set();
32
+ const duplicates = /* @__PURE__ */ new Set();
33
+ for (const column of schema.columns) {
34
+ if (seen.has(column.id)) duplicates.add(column.id);
35
+ seen.add(column.id);
36
+ }
37
+ return [...duplicates];
38
+ };
39
+ //#endregion
40
+ export { duplicateColumnIds, findTableColumn, hideableColumnIds, sortableColumnIds, tableColumnIds, tableColumnSizing, visibleColumnIds };