@columenlabs/lumen-ui 0.1.57 → 0.1.59
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 +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/lumen-selector.js +20 -8
- package/dist/lumen-view.js +19 -4
- package/dist/pool-share.d.ts +22 -0
- package/dist/pool-share.js +24 -0
- package/dist/share-meter.d.ts +12 -2
- package/dist/share-meter.js +11 -2
- package/dist/view-model.d.ts +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ Every export, its props and an example are on the [docs](https://lumen.columenla
|
|
|
80
80
|
- A metered column (use against an allowance) shows one number per cell with `UnitValue` (right-aligned number, fixed muted unit slot, status dot), never `value [pct]`. Offer both readings through the field's `showAs` (Amount / % of allowance, picked per column in Edit view, saved as `shows`); default amounts for sizes (storage) and shares for capped counts. Put the other reading, the allowance and the cost formula in a hover card. Totals use `rollRender`: sum amounts with their unit; a share is the rows' total over their allowance, never a sum of percents.
|
|
81
81
|
- A floating bulk-action bar over a table's selection: Apply clears the selection and the bar goes away; the toast's Undo restores the change, not the selection. Cancel keeps the selection.
|
|
82
82
|
- Bars in a column line up: use `ShareMeter` (fixed track, fixed right-aligned number), never a bar sized by its label.
|
|
83
|
+
- A shared cap split by who used it (an account's allowance by label): `ShareMeter segments={[{ value, color, label }]}` stacks each part's share of the cap in its own colour on the same fixed track; past the cap the track reads as the total and a tick marks the cap. Roll such rows up with `poolShare(rows, { pool, used, cap })` in a field's `rollRender`: a pool's share is its rows' use over the cap (never a sum or max of their own %), and a group across several pools reads the fullest one (`top`).
|
|
83
84
|
- Meter levels are the kit's, not the app's: a share of a cap reads `ok` (grey) under 75%, `watch` (amber) from 75%, `alert` (red) from 90%. `ShareMeter` takes its colour from the share unless you pass `color`; `UnitValue share={0.8}` tints its number and dot the same way; usage progress cards follow the same levels. For any other mark (a dot, a chip, a "nearest cap" cell) call `meterLevel(share)` and colour it with `METER_LEVEL_COLOR` or `meterValueTone(share)`. Override per app with `configureLumen({ meterLevels: { watch: 0.8 } })` (each level can move alone) or per meter with `levels={{ watch: 0.5 }}`. Don't hand-roll `share >= 0.75` checks.
|
|
84
85
|
- Pace (time to a cap at the recent rate) is the kit's: `burnRate(samples, { per, gauge })` over the window's complete samples (hourly, or daily for `7d`), then `timeToCap(rate, used, cap, resetInHours)`. A lone sample above 3× the window's median counts as 3× the median (`spikes` says how many); a run of hot samples counts in full. Show it with `TimeToCap` (`in 2h 05m`, `At cap`, `Resets first`; `short` on a phone), coloured by the meter level of where the meter lands at reset, and pick the window with `PaceWindowPicker` (6h, 12h, 24h, 7d) in the page header; `usePaceWindow()` reads the same choice everywhere, kept in the viewer's browser (`setDefaultPaceWindow` sets the app's default). Where most hours are empty (median 0) a lone nonzero hour counts as 0, and a window with too little use (`timeToCap(..., { minTotal })`, default 0.1% of the cap) is `thin`: no time to the cap. Don't hand-roll ETAs or a window toggle.
|
|
85
86
|
- A change between periods (this week against last) is `trendChange(now, prev, { minBase })` drawn by `TrendValue`: `▲ 12%` over a big enough earlier period, the plain difference `▲ +3` when it is at or under `minBase`, `▲ >10×` past ten times. A % over a near-empty baseline misleads; never show one.
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export { LumenDateInput, type LumenDateInputProps, stepDate, } from './lumen-dat
|
|
|
21
21
|
export { LumenChoice, type LumenChoiceOption } from './lumen-choice.js';
|
|
22
22
|
export { LumenSecret } from './lumen-secret.js';
|
|
23
23
|
export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.js';
|
|
24
|
-
export { ShareMeter } from './share-meter.js';
|
|
24
|
+
export { type ShareSegment, ShareMeter } from './share-meter.js';
|
|
25
|
+
export { type Pool, poolShare } from './pool-share.js';
|
|
25
26
|
export { METER_LEVEL_COLOR, meterLevel, meterValueTone } from './meter-level.js';
|
|
26
27
|
export { UnitValue } from './unit-value.js';
|
|
27
28
|
export { type BurnRate, burnRate, durationText, PACE_WINDOWS, type Pace, type PaceWindow, paceHours, setDefaultPaceWindow, timeToCap, usePaceWindow, } from './pace.js';
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export { LumenChoice } from './lumen-choice.js';
|
|
|
29
29
|
export { LumenSecret } from './lumen-secret.js';
|
|
30
30
|
export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.js';
|
|
31
31
|
export { ShareMeter } from './share-meter.js';
|
|
32
|
+
export { poolShare } from './pool-share.js';
|
|
32
33
|
export { METER_LEVEL_COLOR, meterLevel, meterValueTone } from './meter-level.js';
|
|
33
34
|
export { UnitValue } from './unit-value.js';
|
|
34
35
|
export { burnRate, durationText, PACE_WINDOWS, paceHours, setDefaultPaceWindow, timeToCap, usePaceWindow, } from './pace.js';
|
package/dist/lumen-selector.js
CHANGED
|
@@ -24,6 +24,8 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from './ui/sheet.js
|
|
|
24
24
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
25
25
|
const SHOWN = 6;
|
|
26
26
|
const SEARCH_PAST = 8;
|
|
27
|
+
/** Room below a trigger (px) under which its list opens above, when there is more room there. */
|
|
28
|
+
const ROOM_BELOW = 320;
|
|
27
29
|
const CHIPS = 4;
|
|
28
30
|
const CREATE = '\u0000create';
|
|
29
31
|
/** The options as groups, in order. */
|
|
@@ -163,9 +165,19 @@ export function LumenSelector(props) {
|
|
|
163
165
|
// focus back out of a list portalled to the body, so its search box could never be typed in.
|
|
164
166
|
const markRef = useRef(null);
|
|
165
167
|
const [host, setHost] = useState(null);
|
|
168
|
+
// The list opens below its trigger, or above when the room below is short and there is more
|
|
169
|
+
// above (a row near the screen's foot); either way it fits the screen, its footer in view.
|
|
170
|
+
const triggerRef = useRef(null);
|
|
171
|
+
const [side, setSide] = useState('bottom');
|
|
166
172
|
useLayoutEffect(() => {
|
|
167
|
-
if (open)
|
|
168
|
-
|
|
173
|
+
if (!open)
|
|
174
|
+
return;
|
|
175
|
+
setHost(markRef.current?.closest('[role="dialog"]') ?? null);
|
|
176
|
+
const r = (chipsRef.current ?? triggerRef.current)?.getBoundingClientRect();
|
|
177
|
+
if (!r)
|
|
178
|
+
return;
|
|
179
|
+
const below = window.innerHeight - r.bottom;
|
|
180
|
+
setSide(below < ROOM_BELOW && r.top > below ? 'top' : 'bottom');
|
|
169
181
|
}, [open]);
|
|
170
182
|
const setOpen = (o) => {
|
|
171
183
|
setOwnOpen(o);
|
|
@@ -225,11 +237,11 @@ export function LumenSelector(props) {
|
|
|
225
237
|
const selected = multiple ? null : (byValue.get(values[0] ?? '') ?? null);
|
|
226
238
|
const Lead = selected?.icon ?? LeadIcon;
|
|
227
239
|
const fieldInside = (_jsxs(_Fragment, { children: [Lead && _jsx(Lead, { className: "text-muted-foreground" }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-left', !selected && !values.length && 'text-muted-foreground'), children: selected ? (selected.content ?? selected.label) : values[0] ? values[0] : placeholder }), _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" })] }));
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
// On a desktop the popup is a column as tall as the screen allows: the search, header and
|
|
241
|
+
// footer keep their height and the list between them scrolls.
|
|
242
|
+
const list = (_jsxs(_Fragment, { children: [header && _jsx("div", { className: "shrink-0", children: header }), _jsx(Base.List, { className: cn('min-h-0 overscroll-contain p-1 data-empty:p-0', phone ? 'flex-1 overflow-y-auto pb-4' : 'max-h-88 flex-1 scroll-pt-8 overflow-y-auto'), children: _jsx(Rows, { shown: rows, picked: picked, multiple: multiple, full: full, expand: expand }) }), _jsx(Base.Empty, { className: "hidden px-2 py-3 text-center text-sm text-muted-foreground group-data-empty/selector:block", children: empty })] }));
|
|
231
243
|
const clearAll = multiple && values.length > 0 && (_jsx("button", { type: "button", className: "text-xs text-muted-foreground hover:text-foreground", onClick: () => props.onChange([]), children: "Clear all" }));
|
|
232
|
-
const searchBox = (_jsxs("div", { className: cn('flex items-center gap-2 border-b px-2', phone ? 'mx-4 h-11 rounded-md border' : 'h-9', !searchable && 'sr-only'), children: [_jsx(SearchIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx(Base.Input, { readOnly: !searchable, placeholder: create ? 'Search or type a new one' : `Search ${count}`, "aria-label": label ? `Search ${label}` : 'Search', className: "h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground max-md:text-[15px]" })] }));
|
|
244
|
+
const searchBox = (_jsxs("div", { className: cn('flex shrink-0 items-center gap-2 border-b px-2', phone ? 'mx-4 h-11 rounded-md border' : 'h-9', !searchable && 'sr-only'), children: [_jsx(SearchIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx(Base.Input, { readOnly: !searchable, placeholder: create ? 'Search or type a new one' : `Search ${count}`, "aria-label": label ? `Search ${label}` : 'Search', className: "h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground max-md:text-[15px]" })] }));
|
|
233
245
|
const rootProps = {
|
|
234
246
|
items: rows.map((g) => ({ value: g.label, items: g.rows.map((o) => o.value) })),
|
|
235
247
|
filteredItems: rows.map((g) => ({ value: g.label, items: g.rows.map((o) => o.value) })),
|
|
@@ -256,8 +268,8 @@ export function LumenSelector(props) {
|
|
|
256
268
|
// No ring on the first button: the person taps what they want.
|
|
257
269
|
onOpenAutoFocus: (e) => e.preventDefault(), className: "group/selector flex max-h-[85dvh] flex-col gap-2 rounded-t-xl pt-2", children: [_jsx("span", { "aria-hidden": true, className: "mx-auto h-1 w-9 rounded-full bg-border" }), _jsxs("div", { className: "flex items-center justify-between px-4", children: [_jsx(SheetTitle, { id: sheetTitle, children: label ?? placeholder }), multiple && (_jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" }))] }), _jsx(SheetDescription, { className: "sr-only", children: multiple ? 'Pick any, then Done' : 'Pick one' }), _jsxs(Base.Root, { ...rootProps, inline: true, open: true, onOpenChange: () => undefined, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [multiple && values.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-4", children: values.map((v) => (_jsx("span", { className: "rounded-sm bg-muted px-2 py-1 text-[13px]", children: labelOf(v) }, v))) })), searchable && searchBox, list] }), multiple && (_jsxs("div", { className: "flex items-center justify-between border-t px-4 pt-3 pb-5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked"] }), clearAll] }))] }) })] }));
|
|
258
270
|
}
|
|
259
|
-
const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side:
|
|
260
|
-
return (_jsxs(Base.Root, { ...rootProps, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [inChips ? (_jsxs(Base.Chips, { ref: chipsRef, className: cn('flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', className), children: [chips, _jsx(Base.Input, { id: id, "aria-label": label, disabled: disabled, readOnly: !searchable, placeholder: values.length ? '' : placeholder, className: "min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground" })] })) : trigger === 'icon' ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Base.Trigger, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { id: id, "data-size": size, "aria-label": label, className: cn(FIELD, trigger === 'chip' && 'rounded-full', className), children: fieldInside })), _jsx("span", { ref: markRef, hidden: true }), popup] }));
|
|
271
|
+
const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side: side, sideOffset: 4, align: align, anchor: inChips ? chipsRef : undefined, className: "pointer-events-auto isolate z-[60]", children: _jsxs(Base.Popup, { "data-slot": "lumen-selector", className: "group/selector flex max-h-(--available-height) max-w-(--available-width) flex-col min-w-[max(var(--anchor-width),14rem)] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", children: [!inChips && searchBox, list, multiple && (_jsxs("div", { className: "flex shrink-0 items-center gap-3 border-t px-2 py-1.5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked", max !== undefined ? ` of ${max}` : ''] }), clearAll, _jsx(Button, { size: "xs", className: "ml-auto", onClick: () => setOpen(false), children: "Done" })] }))] }) }) }));
|
|
272
|
+
return (_jsxs(Base.Root, { ...rootProps, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [inChips ? (_jsxs(Base.Chips, { ref: chipsRef, className: cn('flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', className), children: [chips, _jsx(Base.Input, { id: id, "aria-label": label, disabled: disabled, readOnly: !searchable, placeholder: values.length ? '' : placeholder, className: "min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground" })] })) : trigger === 'icon' ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Base.Trigger, { ref: triggerRef, render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { ref: triggerRef, id: id, "data-size": size, "aria-label": label, className: cn(FIELD, trigger === 'chip' && 'rounded-full', className), children: fieldInside })), _jsx("span", { ref: markRef, hidden: true }), popup] }));
|
|
261
273
|
}
|
|
262
274
|
/**
|
|
263
275
|
* `trigger="none"`: the list anchored to a text box the page owns (the assistant @ and /). The text box
|
package/dist/lumen-view.js
CHANGED
|
@@ -269,9 +269,19 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
|
|
|
269
269
|
const pages = Math.max(1, Math.ceil(tableRows.length / pageSize));
|
|
270
270
|
const page = Math.min(pageIndex, pages - 1);
|
|
271
271
|
const count = rows.length === total ? `${total} rows` : `${rows.length} of ${total} rows`;
|
|
272
|
-
//
|
|
272
|
+
// The page holds while rows change in place (a tick, a label edit, a refresh, a bulk apply): only
|
|
273
|
+
// a new filter (a different set of rows), grouping, sort or page size starts from the first page.
|
|
274
|
+
// A page past the end after rows go is clamped above (`page`).
|
|
275
|
+
const rowSet = useMemo(() => rows
|
|
276
|
+
.map((r) => domain.rowKey(r))
|
|
277
|
+
.sort()
|
|
278
|
+
.join('\u0000'), [rows, domain]);
|
|
273
279
|
// biome-ignore lint/correctness/useExhaustiveDependencies: resets on these changes only
|
|
274
|
-
useEffect(() => setPageIndex(0), [
|
|
280
|
+
useEffect(() => setPageIndex(0), [rowSet, pageSize, groupBy?.key, sort?.key, sort?.desc]);
|
|
281
|
+
const sorted = (s) => {
|
|
282
|
+
setPageIndex(0);
|
|
283
|
+
onSort?.(s);
|
|
284
|
+
};
|
|
275
285
|
// Checked rows with a chart symbol open one compare chart (up to 8).
|
|
276
286
|
const openChart = useSymbolChart();
|
|
277
287
|
const chartField = domain.fields.find((f) => f.chart);
|
|
@@ -291,7 +301,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
|
|
|
291
301
|
// A server's page: the pager counts from the server's total, or the rows seen so far. Server
|
|
292
302
|
// groups page themselves: the header counts them.
|
|
293
303
|
const pager = served?.pages ? (_jsx(LumenPager, { total: served.pages.total ?? served.pages.page * served.pages.size + (served.list?.length ?? 0), page: served.pages.page, size: served.pages.size, onPage: served.pages.onPage, onSize: served.pages.onSize ?? (() => { }), sizes: served.pages.sizes ?? [served.pages.size], noun: served.pages.noun ?? 'groups', more: served.pages.total === null && served.pages.more })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : server ? (_jsx(LumenPager, { total: server.total ?? server.page * server.size + rows.length, page: server.page, size: server.size, onPage: server.onPage, onSize: server.onSize ?? (() => { }), sizes: server.sizes ?? [server.size], more: server.total === null && server.more })) : paging ? (_jsx(LumenPager, { total: tableRows.length, page: page, size: pageSize, onPage: setPageIndex, onSize: setPageSize, noun: rows.length === total ? 'rows' : `of ${total} rows` })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
|
|
294
|
-
const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort:
|
|
304
|
+
const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
|
|
295
305
|
? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
|
|
296
306
|
: undefined }));
|
|
297
307
|
return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, controls: controls, tools: state ? (tools) : (_jsxs(_Fragment, { children: [tools, compare, pager, onColumns && !onAddColumn && on('columns') && (_jsx(ColumnPicker, { domain: domain, columns: columns, onColumns: onColumns })), !pivot && on('download') && (_jsx(IconButton, { icon: DownloadIcon, label: "Download CSV", onClick: () => downloadCsv({
|
|
@@ -575,7 +585,12 @@ function LumenTableRows({ domain, columns = domain.fields, rows, groupBy = null,
|
|
|
575
585
|
const allOn = allKeys.length > 0 && allKeys.every((k) => selection?.keys.has(k));
|
|
576
586
|
const someOn = allKeys.some((k) => selection?.keys.has(k));
|
|
577
587
|
const selectCell = (key, name) => selection && (_jsx(TableCell, { className: "w-8 pr-0", onClick: (e) => e.stopPropagation(), children: _jsx(Checkbox, { checked: selection.keys.has(key), onCheckedChange: (on) => toggle([key], on === true), "aria-label": `Select ${name}` }) }));
|
|
578
|
-
|
|
588
|
+
// The rows whose open state the person flipped: a sub-group that starts open (`open`) lists
|
|
589
|
+
// the closed ones, any other the open ones.
|
|
590
|
+
const [partFlips, setOpenParts] = useState(new Set());
|
|
591
|
+
const openParts = {
|
|
592
|
+
has: (k) => (subgroup?.open && !domain.panel ? !partFlips.has(k) : partFlips.has(k)),
|
|
593
|
+
};
|
|
579
594
|
// A server group's roll-ups would add up its loaded rows only, so they and the Total stay off.
|
|
580
595
|
const hasTotal = total && !serverGroups && shown.some(rollsUp);
|
|
581
596
|
// A group row's label spans the leading columns that roll nothing up.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface Pool<T> {
|
|
2
|
+
key: string;
|
|
3
|
+
used: number;
|
|
4
|
+
cap: number;
|
|
5
|
+
/** `used / cap`; 0 when the pool has no cap (pay as you go). */
|
|
6
|
+
share: number;
|
|
7
|
+
rows: T[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The pools `rows` fall in and the fullest one's share. `cap` is read from each pool's first row
|
|
11
|
+
* (every row of a pool shares it); a cap of 0 or less has no share. Use it in a field's
|
|
12
|
+
* `rollRender`, so a group or Total row reads the share again from its rows.
|
|
13
|
+
*/
|
|
14
|
+
export declare function poolShare<T>(rows: T[], by: {
|
|
15
|
+
pool: (row: T) => string;
|
|
16
|
+
used: (row: T) => number;
|
|
17
|
+
cap: (row: T) => number;
|
|
18
|
+
}): {
|
|
19
|
+
share: number;
|
|
20
|
+
top: Pool<T> | null;
|
|
21
|
+
pools: Pool<T>[];
|
|
22
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Rows that each use part of one shared cap (an account's allowance, split by label or resource)
|
|
2
|
+
// roll up as the pool's use over that cap: never a sum or a max of the rows' own shares. A group
|
|
3
|
+
// that spans several pools reads its fullest one, the cap it will hit first.
|
|
4
|
+
/**
|
|
5
|
+
* The pools `rows` fall in and the fullest one's share. `cap` is read from each pool's first row
|
|
6
|
+
* (every row of a pool shares it); a cap of 0 or less has no share. Use it in a field's
|
|
7
|
+
* `rollRender`, so a group or Total row reads the share again from its rows.
|
|
8
|
+
*/
|
|
9
|
+
export function poolShare(rows, by) {
|
|
10
|
+
const m = new Map();
|
|
11
|
+
for (const r of rows) {
|
|
12
|
+
const key = by.pool(r);
|
|
13
|
+
const p = m.get(key) ?? { key, used: 0, cap: by.cap(r), share: 0, rows: [] };
|
|
14
|
+
p.used += by.used(r);
|
|
15
|
+
p.rows.push(r);
|
|
16
|
+
m.set(key, p);
|
|
17
|
+
}
|
|
18
|
+
const pools = [...m.values()];
|
|
19
|
+
for (const p of pools)
|
|
20
|
+
p.share = p.cap > 0 ? p.used / p.cap : 0;
|
|
21
|
+
pools.sort((a, b) => b.share - a.share);
|
|
22
|
+
const top = pools[0] ?? null;
|
|
23
|
+
return { share: top?.share ?? 0, top, pools };
|
|
24
|
+
}
|
package/dist/share-meter.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { MeterLevels } from '@columenlabs/lumen-ui-types/views';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** One part of a split meter: its own share of the cap (0 to 1 of the whole cap), in its colour. */
|
|
4
|
+
export interface ShareSegment {
|
|
5
|
+
value: number;
|
|
6
|
+
/** A theme token or CSS colour (a label's colour). */
|
|
7
|
+
color: string;
|
|
8
|
+
/** Who it is, read out by screen readers and in the bar's hover (`web 122%`). */
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function ShareMeter({ value, label, color, levels, segments, wide, className, }: {
|
|
12
|
+
/** 0 to 1 fills the bar; past 1 it stays full (with `segments`, the track stretches instead). */
|
|
5
13
|
value: number;
|
|
6
14
|
/** The number beside the bar, e.g. `113%`; left out, the rounded percent. */
|
|
7
15
|
label?: ReactNode;
|
|
@@ -9,6 +17,8 @@ export declare function ShareMeter({ value, label, color, levels, wide, classNam
|
|
|
9
17
|
color?: string;
|
|
10
18
|
/** This meter's own levels (e.g. `{ watch: 0.5 }`); left out, the app's `meterLevels`. */
|
|
11
19
|
levels?: Partial<MeterLevels>;
|
|
20
|
+
/** Splits the bar by who used the cap, largest first; their values add up to `value`. */
|
|
21
|
+
segments?: ShareSegment[];
|
|
12
22
|
/** Fills its cell (a list row) instead of the fixed table width. */
|
|
13
23
|
wide?: boolean;
|
|
14
24
|
className?: string;
|
package/dist/share-meter.js
CHANGED
|
@@ -2,8 +2,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { cn } from './lib/utils.js';
|
|
3
3
|
import { METER_LEVEL_COLOR, meterLevel } from './meter-level.js';
|
|
4
4
|
import { Progress } from './ui/progress.js';
|
|
5
|
-
export function ShareMeter({ value, label, color, levels, wide, className, }) {
|
|
5
|
+
export function ShareMeter({ value, label, color, levels, segments, wide, className, }) {
|
|
6
|
+
const number = (_jsx("span", { className: "num w-11 shrink-0 text-right text-xs", children: label ?? `${Math.round(value * 100)}%` }));
|
|
7
|
+
if (segments?.length) {
|
|
8
|
+
// Past the cap the bar keeps its width: the track reads as the total and the tick as the cap.
|
|
9
|
+
const scale = Math.max(1, value);
|
|
10
|
+
const said = segments
|
|
11
|
+
.filter((s) => s.value > 0)
|
|
12
|
+
.map((s) => `${s.label ?? ''} ${Math.round(s.value * 100)}%`.trim());
|
|
13
|
+
return (_jsxs("span", { className: cn('inline-flex items-center gap-2', wide && 'w-full', className), children: [_jsxs("span", { role: "img", "aria-label": said.join(', '), title: said.join(' · '), "data-slot": "share-segments", className: cn('relative flex h-1.5 shrink-0 overflow-hidden rounded-full bg-muted', wide ? 'w-full shrink' : 'w-14'), children: [segments.map((s, i) => s.value > 0 ? (_jsx("span", { className: "h-full shrink-0 border-background not-first:border-l", style: { width: `${(s.value / scale) * 100}%`, background: s.color } }, i)) : null), value > 1 && (_jsx("span", { "aria-hidden": true, className: "absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground/70", style: { left: `${(1 / scale) * 100}%` } }))] }), number] }));
|
|
14
|
+
}
|
|
6
15
|
return (_jsxs("span", { className: cn('inline-flex items-center gap-2', wide && 'w-full', className), children: [_jsx(Progress, { value: Math.max(0, Math.min(value, 1)) * 100, className: cn('h-1.5 shrink-0 [&>[data-slot=progress-indicator]]:bg-(--meter-color)', wide ? 'w-full shrink' : 'w-14'), style: {
|
|
7
16
|
'--meter-color': color ?? METER_LEVEL_COLOR[meterLevel(value, levels)],
|
|
8
|
-
} }),
|
|
17
|
+
} }), number] }));
|
|
9
18
|
}
|
package/dist/view-model.d.ts
CHANGED
|
@@ -310,6 +310,8 @@ export interface ViewSubgroup<T> {
|
|
|
310
310
|
name?: (row: T) => string;
|
|
311
311
|
/** `under` folds a key even when one row has it (a field's second level of grouping). */
|
|
312
312
|
always?: boolean;
|
|
313
|
+
/** Rows start open, their parts showing (a meter's labels); a click still closes one. */
|
|
314
|
+
open?: boolean;
|
|
313
315
|
}
|
|
314
316
|
/**
|
|
315
317
|
* What a row can open: the domain's own sub-groups, else every groupable field as a second level
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@codemirror/lang-javascript": "6.2.5",
|
|
42
42
|
"@codemirror/lang-json": "6.0.2",
|
|
43
43
|
"@codemirror/lang-sql": "6.10.0",
|
|
44
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
44
|
+
"@columenlabs/lumen-ui-types": "0.1.59",
|
|
45
45
|
"@dnd-kit/core": "6.3.1",
|
|
46
46
|
"@dnd-kit/sortable": "10.0.0",
|
|
47
47
|
"@dnd-kit/utilities": "3.2.2",
|