@sincpro/web-ui 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/dist/components/assist/AssistMark.d.ts +64 -0
- package/dist/components/assist/AssistMark.js +108 -0
- package/dist/components/assist/AssistPending.d.ts +24 -0
- package/dist/components/assist/AssistPending.js +26 -0
- package/dist/components/assist/AssistSuggestion.d.ts +42 -0
- package/dist/components/assist/AssistSuggestion.js +55 -0
- package/dist/components/assist/ChangeReview.d.ts +56 -0
- package/dist/components/assist/ChangeReview.js +121 -0
- package/dist/components/assist/assist.d.ts +77 -0
- package/dist/components/assist/assist.js +87 -0
- package/dist/components/assist/index.d.ts +5 -0
- package/dist/components/assist/index.js +7 -0
- package/dist/components/chatter/Chatter.d.ts +161 -0
- package/dist/components/chatter/Chatter.js +232 -0
- package/dist/components/chatter/Messages.d.ts +66 -0
- package/dist/components/chatter/Messages.js +43 -0
- package/dist/components/chatter/Timeline.d.ts +21 -0
- package/dist/components/chatter/Timeline.js +30 -0
- package/dist/components/chatter/index.d.ts +3 -0
- package/dist/components/chatter/index.js +4 -0
- package/dist/components/form/Form.d.ts +52 -0
- package/dist/components/form/Form.js +49 -0
- package/dist/components/form/Sheet.d.ts +43 -0
- package/dist/components/form/Sheet.js +83 -0
- package/dist/components/form/index.d.ts +2 -0
- package/dist/components/form/index.js +4 -0
- package/dist/components/frame/Chart.d.ts +13 -0
- package/dist/components/frame/Chart.js +24 -0
- package/dist/components/frame/FrameView.d.ts +33 -0
- package/dist/components/frame/FrameView.js +125 -0
- package/dist/components/frame/index.d.ts +3 -0
- package/dist/components/frame/index.js +3 -0
- package/dist/components/frame/model.d.ts +43 -0
- package/dist/components/frame/model.js +101 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.js +11 -0
- package/dist/components/lines/LineItems.d.ts +74 -0
- package/dist/components/lines/LineItems.js +229 -0
- package/dist/components/lines/index.d.ts +1 -0
- package/dist/components/lines/index.js +2 -0
- package/dist/components/lines/lines.d.ts +23 -0
- package/dist/components/lines/lines.js +26 -0
- package/dist/components/listing/ColumnsMenu.d.ts +20 -0
- package/dist/components/listing/ColumnsMenu.js +46 -0
- package/dist/components/listing/GroupingLevels.d.ts +35 -0
- package/dist/components/listing/GroupingLevels.js +45 -0
- package/dist/components/listing/Listing.d.ts +27 -0
- package/dist/components/listing/Listing.js +86 -0
- package/dist/components/listing/ListingBar.d.ts +73 -0
- package/dist/components/listing/ListingBar.js +105 -0
- package/dist/components/listing/Pagination.d.ts +36 -0
- package/dist/components/listing/Pagination.js +69 -0
- package/dist/components/listing/RecordCard.d.ts +33 -0
- package/dist/components/listing/RecordCard.js +22 -0
- package/dist/components/listing/RecordLink.d.ts +7 -0
- package/dist/components/listing/RecordLink.js +22 -0
- package/dist/components/listing/Row.d.ts +106 -0
- package/dist/components/listing/Row.js +122 -0
- package/dist/components/listing/SortMenu.d.ts +62 -0
- package/dist/components/listing/SortMenu.js +105 -0
- package/dist/components/listing/ViewSwitcher.d.ts +24 -0
- package/dist/components/listing/ViewSwitcher.js +16 -0
- package/dist/components/listing/columns.d.ts +47 -0
- package/dist/components/listing/columns.js +72 -0
- package/dist/components/listing/grouping.d.ts +35 -0
- package/dist/components/listing/grouping.js +47 -0
- package/dist/components/listing/index.d.ts +17 -0
- package/dist/components/listing/index.js +20 -0
- package/dist/components/listing/modelColumns.d.ts +50 -0
- package/dist/components/listing/modelColumns.js +103 -0
- package/dist/components/listing/pages.d.ts +78 -0
- package/dist/components/listing/pages.js +135 -0
- package/dist/components/listing/selection.d.ts +21 -0
- package/dist/components/listing/selection.js +43 -0
- package/dist/components/listing/set.d.ts +48 -0
- package/dist/components/listing/set.js +24 -0
- package/dist/components/listing/tree.d.ts +28 -0
- package/dist/components/listing/tree.js +23 -0
- package/dist/components/navigation/CommandPalette.d.ts +40 -0
- package/dist/components/navigation/CommandPalette.js +114 -0
- package/dist/components/navigation/NavigationTree.d.ts +31 -0
- package/dist/components/navigation/NavigationTree.js +79 -0
- package/dist/components/navigation/commands.d.ts +30 -0
- package/dist/components/navigation/commands.js +30 -0
- package/dist/components/navigation/index.d.ts +3 -0
- package/dist/components/navigation/index.js +4 -0
- package/dist/components/record/Breadcrumb.d.ts +42 -0
- package/dist/components/record/Breadcrumb.js +98 -0
- package/dist/components/record/DirtyActions.d.ts +26 -0
- package/dist/components/record/DirtyActions.js +22 -0
- package/dist/components/record/Pager.d.ts +18 -0
- package/dist/components/record/Pager.js +33 -0
- package/dist/components/record/RecordHeader.d.ts +71 -0
- package/dist/components/record/RecordHeader.js +72 -0
- package/dist/components/record/RecordSkeleton.d.ts +3 -0
- package/dist/components/record/RecordSkeleton.js +21 -0
- package/dist/components/record/SmartButtons.d.ts +35 -0
- package/dist/components/record/SmartButtons.js +38 -0
- package/dist/components/record/StatusBar.d.ts +51 -0
- package/dist/components/record/StatusBar.js +168 -0
- package/dist/components/record/ThumbBar.d.ts +36 -0
- package/dist/components/record/ThumbBar.js +86 -0
- package/dist/components/record/index.d.ts +6 -0
- package/dist/components/record/index.js +8 -0
- package/dist/components/search/CriteriaBuilder.d.ts +58 -0
- package/dist/components/search/CriteriaBuilder.js +278 -0
- package/dist/components/search/CriteriaChips.d.ts +22 -0
- package/dist/components/search/CriteriaChips.js +64 -0
- package/dist/components/search/FilterBuilder.d.ts +142 -0
- package/dist/components/search/FilterBuilder.js +421 -0
- package/dist/components/search/JoinWord.d.ts +8 -0
- package/dist/components/search/JoinWord.js +47 -0
- package/dist/components/search/JsonEditor.d.ts +19 -0
- package/dist/components/search/JsonEditor.js +78 -0
- package/dist/components/search/SearchBar.d.ts +78 -0
- package/dist/components/search/SearchBar.js +387 -0
- package/dist/components/search/criteria.d.ts +116 -0
- package/dist/components/search/criteria.js +159 -0
- package/dist/components/search/datePeriods.d.ts +122 -0
- package/dist/components/search/datePeriods.js +134 -0
- package/dist/components/search/dateRanges.d.ts +54 -0
- package/dist/components/search/dateRanges.js +89 -0
- package/dist/components/search/index.d.ts +9 -0
- package/dist/components/search/index.js +20 -0
- package/dist/components/search/model.d.ts +54 -0
- package/dist/components/search/model.js +37 -0
- package/dist/components/search/query.d.ts +166 -0
- package/dist/components/search/query.js +436 -0
- package/dist/components/shell/AppFrame.d.ts +14 -0
- package/dist/components/shell/AppFrame.js +80 -0
- package/dist/components/shell/TopBar.d.ts +73 -0
- package/dist/components/shell/TopBar.js +54 -0
- package/dist/components/shell/headerSlot.d.ts +17 -0
- package/dist/components/shell/headerSlot.js +21 -0
- package/dist/components/shell/index.d.ts +2 -0
- package/dist/components/shell/index.js +3 -0
- package/dist/foundations/aria.d.ts +14 -0
- package/dist/foundations/aria.js +18 -0
- package/dist/foundations/calendar.d.ts +28 -0
- package/dist/foundations/calendar.js +183 -0
- package/dist/foundations/copy.d.ts +619 -0
- package/dist/foundations/copy.es.d.ts +2 -0
- package/dist/foundations/copy.es.js +377 -0
- package/dist/foundations/copy.js +472 -0
- package/dist/foundations/density.d.ts +16 -0
- package/dist/foundations/density.js +21 -0
- package/dist/foundations/grouping.d.ts +281 -0
- package/dist/foundations/grouping.js +0 -0
- package/dist/foundations/index.d.ts +14 -0
- package/dist/foundations/index.js +30 -0
- package/dist/foundations/layout.d.ts +9 -0
- package/dist/foundations/layout.js +9 -0
- package/dist/foundations/pivot.d.ts +222 -0
- package/dist/foundations/pivot.js +198 -0
- package/dist/foundations/pivotSheet.d.ts +61 -0
- package/dist/foundations/pivotSheet.js +0 -0
- package/dist/foundations/slots.d.ts +67 -0
- package/dist/foundations/slots.js +63 -0
- package/dist/foundations/sticky.d.ts +53 -0
- package/dist/foundations/sticky.js +150 -0
- package/dist/foundations/surface.d.ts +9 -0
- package/dist/foundations/surface.js +26 -0
- package/dist/foundations/theme.d.ts +42 -0
- package/dist/foundations/theme.js +88 -0
- package/dist/foundations/types.d.ts +23 -0
- package/dist/foundations/types.js +50 -0
- package/dist/foundations/variants.d.ts +19 -0
- package/dist/foundations/variants.js +48 -0
- package/dist/foundations/xlsx.d.ts +86 -0
- package/dist/foundations/xlsx.js +0 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/primitives/block/Block.d.ts +66 -0
- package/dist/primitives/block/Block.js +34 -0
- package/dist/primitives/block/Line.d.ts +18 -0
- package/dist/primitives/block/Line.js +45 -0
- package/dist/primitives/block/SectionGrid.d.ts +30 -0
- package/dist/primitives/block/SectionGrid.js +29 -0
- package/dist/primitives/block/Steps.d.ts +76 -0
- package/dist/primitives/block/Steps.js +43 -0
- package/dist/primitives/block/index.d.ts +4 -0
- package/dist/primitives/block/index.js +7 -0
- package/dist/primitives/button/Button.d.ts +137 -0
- package/dist/primitives/button/Button.js +234 -0
- package/dist/primitives/button/index.d.ts +1 -0
- package/dist/primitives/button/index.js +2 -0
- package/dist/primitives/cell/CellStack.d.ts +47 -0
- package/dist/primitives/cell/CellStack.js +33 -0
- package/dist/primitives/cell/index.d.ts +1 -0
- package/dist/primitives/cell/index.js +3 -0
- package/dist/primitives/choice/ComboBox.d.ts +16 -0
- package/dist/primitives/choice/ComboBox.js +52 -0
- package/dist/primitives/choice/RadioGroup.d.ts +18 -0
- package/dist/primitives/choice/RadioGroup.js +50 -0
- package/dist/primitives/choice/SearchField.d.ts +24 -0
- package/dist/primitives/choice/SearchField.js +27 -0
- package/dist/primitives/choice/SegmentedControl.d.ts +16 -0
- package/dist/primitives/choice/SegmentedControl.js +69 -0
- package/dist/primitives/choice/Slider.d.ts +16 -0
- package/dist/primitives/choice/Slider.js +46 -0
- package/dist/primitives/choice/Switch.d.ts +14 -0
- package/dist/primitives/choice/Switch.js +65 -0
- package/dist/primitives/choice/ThemeSwitcher.d.ts +10 -0
- package/dist/primitives/choice/ThemeSwitcher.js +25 -0
- package/dist/primitives/choice/index.d.ts +8 -0
- package/dist/primitives/choice/index.js +11 -0
- package/dist/primitives/choice/options.d.ts +28 -0
- package/dist/primitives/choice/options.js +6 -0
- package/dist/primitives/date/Calendar.d.ts +31 -0
- package/dist/primitives/date/Calendar.js +110 -0
- package/dist/primitives/date/DatePicker.d.ts +34 -0
- package/dist/primitives/date/DatePicker.js +99 -0
- package/dist/primitives/date/index.d.ts +2 -0
- package/dist/primitives/date/index.js +4 -0
- package/dist/primitives/dialog/Dialog.d.ts +118 -0
- package/dist/primitives/dialog/Dialog.js +121 -0
- package/dist/primitives/dialog/index.d.ts +1 -0
- package/dist/primitives/dialog/index.js +2 -0
- package/dist/primitives/disclosure/Disclosure.d.ts +36 -0
- package/dist/primitives/disclosure/Disclosure.js +52 -0
- package/dist/primitives/disclosure/index.d.ts +1 -0
- package/dist/primitives/disclosure/index.js +2 -0
- package/dist/primitives/field/Checks.d.ts +57 -0
- package/dist/primitives/field/Checks.js +72 -0
- package/dist/primitives/field/DropZone.d.ts +42 -0
- package/dist/primitives/field/DropZone.js +52 -0
- package/dist/primitives/field/Field.d.ts +166 -0
- package/dist/primitives/field/Field.js +240 -0
- package/dist/primitives/field/Suggest.d.ts +48 -0
- package/dist/primitives/field/Suggest.js +43 -0
- package/dist/primitives/field/aria.d.ts +61 -0
- package/dist/primitives/field/aria.js +70 -0
- package/dist/primitives/field/context.d.ts +30 -0
- package/dist/primitives/field/context.js +13 -0
- package/dist/primitives/field/index.d.ts +7 -0
- package/dist/primitives/field/index.js +11 -0
- package/dist/primitives/field/layout.d.ts +24 -0
- package/dist/primitives/field/layout.js +10 -0
- package/dist/primitives/field/mode.d.ts +9 -0
- package/dist/primitives/field/mode.js +13 -0
- package/dist/primitives/hint/Hint.d.ts +31 -0
- package/dist/primitives/hint/Hint.js +42 -0
- package/dist/primitives/hint/index.d.ts +1 -0
- package/dist/primitives/hint/index.js +1 -0
- package/dist/primitives/index.d.ts +19 -0
- package/dist/primitives/index.js +20 -0
- package/dist/primitives/menu/Menu.d.ts +100 -0
- package/dist/primitives/menu/Menu.js +59 -0
- package/dist/primitives/menu/index.d.ts +1 -0
- package/dist/primitives/menu/index.js +2 -0
- package/dist/primitives/notice/Callout.d.ts +49 -0
- package/dist/primitives/notice/Callout.js +28 -0
- package/dist/primitives/notice/Notice.d.ts +45 -0
- package/dist/primitives/notice/Notice.js +61 -0
- package/dist/primitives/notice/Toast.d.ts +55 -0
- package/dist/primitives/notice/Toast.js +142 -0
- package/dist/primitives/notice/index.d.ts +3 -0
- package/dist/primitives/notice/index.js +5 -0
- package/dist/primitives/overlay/Popover.d.ts +6 -0
- package/dist/primitives/overlay/Popover.js +27 -0
- package/dist/primitives/overlay/PopoverTrigger.d.ts +55 -0
- package/dist/primitives/overlay/PopoverTrigger.js +38 -0
- package/dist/primitives/overlay/Tooltip.d.ts +38 -0
- package/dist/primitives/overlay/Tooltip.js +74 -0
- package/dist/primitives/overlay/index.d.ts +3 -0
- package/dist/primitives/overlay/index.js +5 -0
- package/dist/primitives/picker/Picker.d.ts +48 -0
- package/dist/primitives/picker/Picker.js +150 -0
- package/dist/primitives/picker/index.d.ts +1 -0
- package/dist/primitives/picker/index.js +2 -0
- package/dist/primitives/surface/Peek.d.ts +60 -0
- package/dist/primitives/surface/Peek.js +71 -0
- package/dist/primitives/surface/index.d.ts +1 -0
- package/dist/primitives/surface/index.js +2 -0
- package/dist/primitives/table/CellEditor.d.ts +14 -0
- package/dist/primitives/table/CellEditor.js +83 -0
- package/dist/primitives/table/DataTable.d.ts +135 -0
- package/dist/primitives/table/DataTable.js +244 -0
- package/dist/primitives/table/EditableTable.d.ts +96 -0
- package/dist/primitives/table/EditableTable.js +423 -0
- package/dist/primitives/table/PivotTable.d.ts +14 -0
- package/dist/primitives/table/PivotTable.js +0 -0
- package/dist/primitives/table/ReportTable.d.ts +38 -0
- package/dist/primitives/table/ReportTable.js +106 -0
- package/dist/primitives/table/RowActions.d.ts +16 -0
- package/dist/primitives/table/RowActions.js +28 -0
- package/dist/primitives/table/SheetTable.d.ts +70 -0
- package/dist/primitives/table/SheetTable.js +197 -0
- package/dist/primitives/table/Table.d.ts +106 -0
- package/dist/primitives/table/Table.js +116 -0
- package/dist/primitives/table/index.d.ts +7 -0
- package/dist/primitives/table/index.js +9 -0
- package/dist/primitives/tabs/Tabs.d.ts +91 -0
- package/dist/primitives/tabs/Tabs.js +56 -0
- package/dist/primitives/tabs/index.d.ts +1 -0
- package/dist/primitives/tabs/index.js +1 -0
- package/dist/primitives/tag/Tag.d.ts +36 -0
- package/dist/primitives/tag/Tag.js +68 -0
- package/dist/primitives/tag/Tags.d.ts +57 -0
- package/dist/primitives/tag/Tags.js +104 -0
- package/dist/primitives/tag/TagsField.d.ts +32 -0
- package/dist/primitives/tag/TagsField.js +182 -0
- package/dist/primitives/tag/choices.d.ts +38 -0
- package/dist/primitives/tag/choices.js +26 -0
- package/dist/primitives/tag/index.d.ts +4 -0
- package/dist/primitives/tag/index.js +8 -0
- package/dist/primitives/toolbar/Toolbar.d.ts +13 -0
- package/dist/primitives/toolbar/Toolbar.js +36 -0
- package/dist/primitives/toolbar/index.d.ts +1 -0
- package/dist/primitives/toolbar/index.js +2 -0
- package/dist/primitives/value/Copyable.d.ts +37 -0
- package/dist/primitives/value/Copyable.js +50 -0
- package/dist/primitives/value/Fecha.d.ts +12 -0
- package/dist/primitives/value/Fecha.js +80 -0
- package/dist/primitives/value/Meter.d.ts +29 -0
- package/dist/primitives/value/Meter.js +46 -0
- package/dist/primitives/value/Metric.d.ts +42 -0
- package/dist/primitives/value/Metric.js +38 -0
- package/dist/primitives/value/Person.d.ts +90 -0
- package/dist/primitives/value/Person.js +208 -0
- package/dist/primitives/value/Relation.d.ts +69 -0
- package/dist/primitives/value/Relation.js +57 -0
- package/dist/primitives/value/Totals.d.ts +26 -0
- package/dist/primitives/value/Totals.js +4 -0
- package/dist/primitives/value/Value.d.ts +77 -0
- package/dist/primitives/value/Value.js +119 -0
- package/dist/primitives/value/index.d.ts +8 -0
- package/dist/primitives/value/index.js +9 -0
- package/dist/tokens.css +1025 -0
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +3 -0
- package/dist/views/list/List.d.ts +113 -0
- package/dist/views/list/List.js +97 -0
- package/dist/views/list/ListView.d.ts +6 -0
- package/dist/views/list/ListView.js +269 -0
- package/dist/views/list/columns.d.ts +18 -0
- package/dist/views/list/columns.js +27 -0
- package/dist/views/list/derive.d.ts +76 -0
- package/dist/views/list/derive.js +181 -0
- package/dist/views/list/index.d.ts +2 -0
- package/dist/views/list/index.js +4 -0
- package/dist/views/list/presentations.d.ts +39 -0
- package/dist/views/list/presentations.js +103 -0
- package/dist/views/list/types.d.ts +272 -0
- package/dist/views/list/types.js +1 -0
- package/dist/views/list/useNarrowList.d.ts +17 -0
- package/dist/views/list/useNarrowList.js +32 -0
- package/dist/views/record/Record.d.ts +126 -0
- package/dist/views/record/Record.js +141 -0
- package/dist/views/record/index.d.ts +1 -0
- package/dist/views/record/index.js +2 -0
- package/package.json +109 -4
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { byDensity, useDensity } from "../../foundations/density.js";
|
|
5
|
+
import { pathKey, pileIsOpen, pileItems, pilesOf, } from "../../foundations/grouping.js";
|
|
6
|
+
import { HANDHELD_WIDTH } from "../../foundations/layout.js";
|
|
7
|
+
import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
|
|
8
|
+
import { IconButton } from "../button/Button.js";
|
|
9
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
10
|
+
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
|
|
11
|
+
import { useKeyboard } from "react-aria";
|
|
12
|
+
/**
|
|
13
|
+
* Where each column goes in the stacked card, defaults resolved: a declared `stack`
|
|
14
|
+
* wins; otherwise the first undeclared column is the title (unless one was declared),
|
|
15
|
+
* the last undeclared numeric one is the amount (unless one was declared), and the rest
|
|
16
|
+
* are detail.
|
|
17
|
+
*/
|
|
18
|
+
export function stackPlaces(columns) {
|
|
19
|
+
const titleAt = columns.some((c) => c.stack === "title")
|
|
20
|
+
? -1
|
|
21
|
+
: columns.findIndex((c) => c.stack === undefined);
|
|
22
|
+
const amountAt = columns.some((c) => c.stack === "amount")
|
|
23
|
+
? -1
|
|
24
|
+
: columns.findLastIndex((c, i) => c.stack === undefined && c.numeric === true && i !== titleAt);
|
|
25
|
+
return columns.map((c, i) => c.stack ?? (i === titleAt ? "title" : i === amountAt ? "amount" : "detail"));
|
|
26
|
+
}
|
|
27
|
+
/** A brief that says nothing is left out of the line, separator included. */
|
|
28
|
+
function says(node) {
|
|
29
|
+
return node !== null && node !== undefined && node !== false && node !== "";
|
|
30
|
+
}
|
|
31
|
+
/** The label of a group band — its field, its value, how many rows it holds. The same
|
|
32
|
+
* in the table's band and the stacked list's. */
|
|
33
|
+
function pileTag(pile, locale) {
|
|
34
|
+
return (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "shrink-0 text-micro text-muted/70", children: pile.field }), _jsx("span", { className: "truncate text-micro font-medium text-ink", children: pile.label }), _jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro tabular-nums text-muted", children: pile.count.toLocaleString(locale) })] }));
|
|
35
|
+
}
|
|
36
|
+
/** The focus ring of an operable row: drawn on the row, inset, so it is not cut by the
|
|
37
|
+
* box and does not move the cells. */
|
|
38
|
+
const RING = "outline-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary forced-colors:focus-visible:outline-[Highlight]";
|
|
39
|
+
/** What can take the focus in a row. Filtered again in `firstControl`: React Aria's
|
|
40
|
+
* hidden form mirrors (a `<select>` behind a `Select`) match this and are not stops. */
|
|
41
|
+
const FOCUSABLE = 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), a[href], [tabindex]';
|
|
42
|
+
/** The first control of a row being edited: the first one in an editable cell, else
|
|
43
|
+
* anything focusable in the row (a column without `edit` may still hold a button). */
|
|
44
|
+
function firstControl(row) {
|
|
45
|
+
const stops = (scope) => [...row.querySelectorAll(scope)].find((el) => el.tabIndex >= 0 && el.closest('[aria-hidden="true"], [inert]') === null) ?? null;
|
|
46
|
+
return stops(`[data-edit] :is(${FOCUSABLE})`) ?? stops(FOCUSABLE);
|
|
47
|
+
}
|
|
48
|
+
/** Keys a control inside the row already answers with `Enter`: a button presses, a
|
|
49
|
+
* link follows, a text area breaks the line, an open list picks its option. */
|
|
50
|
+
function ownsEnter(target) {
|
|
51
|
+
return (target instanceof Element &&
|
|
52
|
+
target.closest('button, a[href], textarea, [role="button"], [role="combobox"][aria-expanded="true"]') !== null);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* One body row. Its own component so that it can hold React Aria's `useKeyboard`
|
|
56
|
+
* (a hook per row) and the timer that decides whether the focus really left it.
|
|
57
|
+
* The same row is a `<tr>` in the table and an `<li>` in the stacked list: one
|
|
58
|
+
* keyboard model, two shapes.
|
|
59
|
+
*/
|
|
60
|
+
function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, children, }) {
|
|
61
|
+
const leaving = useRef(null);
|
|
62
|
+
const stay = () => {
|
|
63
|
+
if (leaving.current !== null)
|
|
64
|
+
clearTimeout(leaving.current);
|
|
65
|
+
leaving.current = null;
|
|
66
|
+
};
|
|
67
|
+
useEffect(() => stay, []);
|
|
68
|
+
/* React Aria's `useKeyboard` stops the propagation of what it handles and lets
|
|
69
|
+
the rest through (`continuePropagation`), so an `Escape` the row did not use
|
|
70
|
+
still reaches the dialog or the drawer around the table. */
|
|
71
|
+
const { keyboardProps } = useKeyboard({
|
|
72
|
+
isDisabled: !editable,
|
|
73
|
+
onKeyDown: (e) => {
|
|
74
|
+
if (!editing) {
|
|
75
|
+
/* Only on the row itself: a link drawn inside a reading cell keeps its
|
|
76
|
+
own `Enter`. */
|
|
77
|
+
if (e.target !== e.currentTarget)
|
|
78
|
+
return e.continuePropagation();
|
|
79
|
+
if (e.key === "Enter" || e.key === "F2") {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
onStart(true);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (e.key === "ArrowDown" ||
|
|
85
|
+
e.key === "ArrowUp" ||
|
|
86
|
+
e.key === "Home" ||
|
|
87
|
+
e.key === "End") {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
onMove(e.key);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
return e.continuePropagation();
|
|
93
|
+
}
|
|
94
|
+
if (e.key === "Escape") {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
onCancel();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
e.continuePropagation();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const props = {
|
|
103
|
+
...keyboardProps,
|
|
104
|
+
/* `Enter` commits in the CAPTURE phase: React Aria's number field answers
|
|
105
|
+
`Enter` itself and stops it, so it would never bubble up to the row. Taking
|
|
106
|
+
it on the way down —and only from the row's own cells, not from a list
|
|
107
|
+
open in a portal— commits from any control. Focusing the row first lets
|
|
108
|
+
that field write the value it was holding (it commits on blur). */
|
|
109
|
+
onKeyDownCapture: editing
|
|
110
|
+
? (e) => {
|
|
111
|
+
if (e.key !== "Enter" || e.nativeEvent.isComposing)
|
|
112
|
+
return;
|
|
113
|
+
if (!(e.target instanceof Node) || !e.currentTarget.contains(e.target))
|
|
114
|
+
return;
|
|
115
|
+
if (ownsEnter(e.target))
|
|
116
|
+
return;
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
e.stopPropagation();
|
|
119
|
+
onCommit(true);
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
122
|
+
...(editable
|
|
123
|
+
? {
|
|
124
|
+
/* While it is being edited the row itself is not a stop: `Shift+Tab`
|
|
125
|
+
from its first control leaves it (and commits) instead of landing on
|
|
126
|
+
the row it is already in. */
|
|
127
|
+
tabIndex: tabbable && !editing ? 0 : -1,
|
|
128
|
+
"aria-keyshortcuts": editing ? undefined : "Enter F2",
|
|
129
|
+
onFocus: () => {
|
|
130
|
+
stay();
|
|
131
|
+
onFocusRow();
|
|
132
|
+
},
|
|
133
|
+
onBlur: editing
|
|
134
|
+
? () => {
|
|
135
|
+
/* Commit only if the focus left the WHOLE row — and the row is
|
|
136
|
+
not only its DOM. A `Select`'s list opens in a portal, outside
|
|
137
|
+
the `<tr>`, so "the next focused element is not inside" would
|
|
138
|
+
close the row the moment its list opened. The focus event of a
|
|
139
|
+
portal still bubbles through React's tree back to this row, so
|
|
140
|
+
the commit waits one tick: if the focus landed anywhere that
|
|
141
|
+
belongs to the row, `onFocus` above cancels it. */
|
|
142
|
+
stay();
|
|
143
|
+
leaving.current = setTimeout(() => {
|
|
144
|
+
leaving.current = null;
|
|
145
|
+
onCommit(false);
|
|
146
|
+
}, 0);
|
|
147
|
+
}
|
|
148
|
+
: undefined,
|
|
149
|
+
onClick: editing ? undefined : () => onStart(false),
|
|
150
|
+
}
|
|
151
|
+
: {}),
|
|
152
|
+
className,
|
|
153
|
+
"data-row-id": rowId,
|
|
154
|
+
};
|
|
155
|
+
return as === "li" ? (_jsx("li", { ref: rowRef, ...props, children: children })) : (_jsx("tr", { ref: rowRef, ...props, children: children }));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A table whose rows are edited in place, one row at a time.
|
|
159
|
+
*
|
|
160
|
+
* Mouse: touching a row opens it. Keyboard: the rows are one tab stop, the arrows walk
|
|
161
|
+
* them, `Enter` / `F2` open one, `Tab` walks its controls, `Enter` commits and `Escape`
|
|
162
|
+
* puts the row back as it was; both return the focus to the row. Leaving the row
|
|
163
|
+
* (click outside, tabbing past its last control) commits.
|
|
164
|
+
*
|
|
165
|
+
* Narrower than `--container-handheld`, each row is a stacked card (see the file's
|
|
166
|
+
* header): the same keys, and `onOpen` —when given— instead of editing in place.
|
|
167
|
+
*/
|
|
168
|
+
export function EditableTable({ rows, columns, getRowId, onChange, onRemove, onAdd, addLabel,
|
|
169
|
+
/** The footer with the aggregates, already formatted. `null` on the columns
|
|
170
|
+
* that do not add up. Whoever composes computes it, because whoever composes
|
|
171
|
+
* knows what adding up means in their domain —a percentage is not summed, it
|
|
172
|
+
* is averaged. */
|
|
173
|
+
totals, empty, grouping,
|
|
174
|
+
/** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
|
|
175
|
+
* compone, por el mismo motivo que `totals`: sumar significa cosas distintas
|
|
176
|
+
* según el dominio, y un porcentaje se promedia, no se suma. */
|
|
177
|
+
groupTotal, height, layout = "auto", onOpen, }) {
|
|
178
|
+
const copy = useUiCopy();
|
|
179
|
+
const d = useDensity();
|
|
180
|
+
const box = useScrollBox(height === undefined ? {} : { height });
|
|
181
|
+
const [editing, setEditing] = useState(null);
|
|
182
|
+
/* The row that holds the one tab stop of the body: the last one focused. */
|
|
183
|
+
const [active, setActive] = useState(null);
|
|
184
|
+
const rowNodes = useRef(new Map());
|
|
185
|
+
/* The row as it was when editing began, to put it back on `Escape`; `touched` says
|
|
186
|
+
whether any control wrote to it since, so a cancel without changes changes nothing. */
|
|
187
|
+
const before = useRef(null);
|
|
188
|
+
/* Set when the keyboard opened the row: its first control takes the focus once
|
|
189
|
+
the controls exist. A click opens it without moving the focus. */
|
|
190
|
+
const focusOnOpen = useRef(null);
|
|
191
|
+
/* La secuencia que se dibuja: montones y filas intercalados. Sin agrupado, las
|
|
192
|
+
filas tal cual — una tabla que no agrupa no paga nada por poder hacerlo. */
|
|
193
|
+
const drawn = useMemo(() => {
|
|
194
|
+
const piles = pilesOf(rows, grouping);
|
|
195
|
+
if (piles === null)
|
|
196
|
+
return rows.map((row) => ({ row }));
|
|
197
|
+
const out = [];
|
|
198
|
+
const walk = (list) => {
|
|
199
|
+
for (const pile of list) {
|
|
200
|
+
out.push({ pile });
|
|
201
|
+
if (!pileIsOpen(pile, grouping))
|
|
202
|
+
continue;
|
|
203
|
+
if (pile.piles.length > 0)
|
|
204
|
+
walk(pile.piles);
|
|
205
|
+
else
|
|
206
|
+
for (const row of pile.items)
|
|
207
|
+
out.push({ row });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
walk(piles);
|
|
211
|
+
return out;
|
|
212
|
+
}, [rows, grouping]);
|
|
213
|
+
/* The width of the box decides, not the window's: a drawer on a wide screen is as
|
|
214
|
+
narrow as a phone. Measured before paint, so a phone never sees the table first. */
|
|
215
|
+
const frame = useRef(null);
|
|
216
|
+
const [narrow, setNarrow] = useState(false);
|
|
217
|
+
useLayoutEffect(() => {
|
|
218
|
+
const el = frame.current;
|
|
219
|
+
if (layout !== "auto" || el === null)
|
|
220
|
+
return;
|
|
221
|
+
/* A box that measures zero is hidden (a closed tab): it keeps what it had. */
|
|
222
|
+
const measure = (width) => {
|
|
223
|
+
if (width > 0)
|
|
224
|
+
setNarrow(width < HANDHELD_WIDTH);
|
|
225
|
+
};
|
|
226
|
+
measure(el.getBoundingClientRect().width);
|
|
227
|
+
if (typeof ResizeObserver === "undefined")
|
|
228
|
+
return;
|
|
229
|
+
const watch = new ResizeObserver(([entry]) => {
|
|
230
|
+
if (entry !== undefined)
|
|
231
|
+
measure(entry.contentRect.width);
|
|
232
|
+
});
|
|
233
|
+
watch.observe(el);
|
|
234
|
+
return () => watch.disconnect();
|
|
235
|
+
}, [layout]);
|
|
236
|
+
const stacked = layout === "stacked" || (layout === "auto" && narrow);
|
|
237
|
+
const editable = onChange !== undefined;
|
|
238
|
+
/* A card that opens the consumer's form is operable even with nothing to edit in
|
|
239
|
+
place: it has a tab stop, takes Enter and answers a press. */
|
|
240
|
+
const opens = stacked && onOpen !== undefined;
|
|
241
|
+
const operable = editable || opens;
|
|
242
|
+
const cell = byDensity(d, "px-3 py-1.5", "px-2 py-1");
|
|
243
|
+
const add = addLabel ?? copy.editable.add;
|
|
244
|
+
const emptyText = empty ?? copy.editable.empty;
|
|
245
|
+
/* The rows in the order they are drawn: what the arrows walk. */
|
|
246
|
+
const order = drawn.flatMap((entry) => entry.row === undefined ? [] : [getRowId(entry.row)]);
|
|
247
|
+
const stop = active !== null && order.includes(active) ? active : (order[0] ?? null);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
const id = focusOnOpen.current;
|
|
250
|
+
if (id === null || id !== editing)
|
|
251
|
+
return;
|
|
252
|
+
focusOnOpen.current = null;
|
|
253
|
+
const node = rowNodes.current.get(id);
|
|
254
|
+
if (node !== undefined)
|
|
255
|
+
(firstControl(node) ?? node).focus();
|
|
256
|
+
}, [editing]);
|
|
257
|
+
const start = (id, row, viaKeyboard) => {
|
|
258
|
+
if (opens) {
|
|
259
|
+
setActive(id);
|
|
260
|
+
onOpen?.(id);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
before.current = { id, row, touched: false };
|
|
264
|
+
focusOnOpen.current = viaKeyboard ? id : null;
|
|
265
|
+
setActive(id);
|
|
266
|
+
setEditing(id);
|
|
267
|
+
};
|
|
268
|
+
/* Keyed by the row: a commit that arrives late (the blur of the row you clicked
|
|
269
|
+
away from) must not close the row the click just opened. */
|
|
270
|
+
const close = (id) => setEditing((current) => (current === id ? null : current));
|
|
271
|
+
const commit = (id, refocus) => {
|
|
272
|
+
if (refocus)
|
|
273
|
+
rowNodes.current.get(id)?.focus();
|
|
274
|
+
if (before.current?.id === id)
|
|
275
|
+
before.current = null;
|
|
276
|
+
close(id);
|
|
277
|
+
};
|
|
278
|
+
const cancel = (id) => {
|
|
279
|
+
/* The focus goes back to the row FIRST: a control that writes its value on blur
|
|
280
|
+
(a number field) writes it now, and the old row is put back after it, not
|
|
281
|
+
before. */
|
|
282
|
+
rowNodes.current.get(id)?.focus();
|
|
283
|
+
const was = before.current;
|
|
284
|
+
before.current = null;
|
|
285
|
+
if (was !== null && was.id === id && was.touched)
|
|
286
|
+
onChange?.(id, was.row);
|
|
287
|
+
close(id);
|
|
288
|
+
};
|
|
289
|
+
/* What a control writes, marking the row as touched for `Escape`. */
|
|
290
|
+
const setter = (id) => (next) => {
|
|
291
|
+
if (before.current?.id === id)
|
|
292
|
+
before.current.touched = true;
|
|
293
|
+
onChange?.(id, next);
|
|
294
|
+
};
|
|
295
|
+
/* The wiring a row needs, the same for the table's `<tr>` and the stacked `<li>`. */
|
|
296
|
+
const rowWiring = (id, row) => ({
|
|
297
|
+
rowId: id,
|
|
298
|
+
rowRef: (node) => {
|
|
299
|
+
if (node === null)
|
|
300
|
+
rowNodes.current.delete(id);
|
|
301
|
+
else
|
|
302
|
+
rowNodes.current.set(id, node);
|
|
303
|
+
},
|
|
304
|
+
editable: operable,
|
|
305
|
+
editing: editing === id,
|
|
306
|
+
tabbable: stop === id,
|
|
307
|
+
onStart: (viaKeyboard) => start(id, row, viaKeyboard),
|
|
308
|
+
onCommit: (refocus) => commit(id, refocus),
|
|
309
|
+
onCancel: () => cancel(id),
|
|
310
|
+
onMove: (key) => move(id, key),
|
|
311
|
+
onFocusRow: () => setActive(id),
|
|
312
|
+
});
|
|
313
|
+
const move = (from, key) => {
|
|
314
|
+
const at = order.indexOf(from);
|
|
315
|
+
const to = key === "Home"
|
|
316
|
+
? 0
|
|
317
|
+
: key === "End"
|
|
318
|
+
? order.length - 1
|
|
319
|
+
: key === "ArrowDown"
|
|
320
|
+
? Math.min(at + 1, order.length - 1)
|
|
321
|
+
: Math.max(at - 1, 0);
|
|
322
|
+
const id = order[to];
|
|
323
|
+
if (id === undefined)
|
|
324
|
+
return;
|
|
325
|
+
setActive(id);
|
|
326
|
+
rowNodes.current.get(id)?.focus();
|
|
327
|
+
};
|
|
328
|
+
/* ── the stacked list: one card per row, below `--container-handheld` ── */
|
|
329
|
+
const places = stackPlaces(columns);
|
|
330
|
+
const placed = (place) => columns.filter((_, i) => places[i] === place);
|
|
331
|
+
const titles = placed("title");
|
|
332
|
+
const details = placed("detail");
|
|
333
|
+
const amounts = placed("amount");
|
|
334
|
+
const trails = placed("trail");
|
|
335
|
+
const pad = byDensity(d, "px-3 py-2.5", "px-2 py-1.5");
|
|
336
|
+
const card = (row) => {
|
|
337
|
+
const id = getRowId(row);
|
|
338
|
+
const isEditing = editing === id;
|
|
339
|
+
const set = setter(id);
|
|
340
|
+
const parts = details.flatMap((c) => {
|
|
341
|
+
const said = c.brief === undefined ? null : c.brief(row);
|
|
342
|
+
if (c.brief !== undefined)
|
|
343
|
+
return says(said) ? [{ id: c.id, node: said }] : [];
|
|
344
|
+
return [
|
|
345
|
+
{
|
|
346
|
+
id: c.id,
|
|
347
|
+
node: (_jsxs(_Fragment, { children: [c.header, " ", c.render(row)] })),
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
});
|
|
351
|
+
const controls = editable && isEditing ? columns.filter((c) => c.edit !== undefined) : [];
|
|
352
|
+
return (_jsxs(EditRow, { as: "li", ...rowWiring(id, row), className: `group/row flex min-w-0 flex-col gap-1 rounded-control border-b border-line ${pad} ${isEditing ? "bg-primary/5" : "hover:bg-ink/4"} ${operable ? RING : ""} ${operable && !isEditing ? (opens ? "cursor-pointer" : "cursor-text") : ""}`, children: [_jsxs("div", { className: "flex min-w-0 items-start gap-2", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5 text-meta text-ink", children: titles.map((c) => (_jsx("div", { className: "min-w-0", children: c.render(row) }, c.id))) }), trails.map((c) => (_jsx("div", { className: "shrink-0", children: c.render(row) }, c.id))), isEditing && onRemove !== undefined ? (_jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", onClick: () => onRemove(id) })) : null] }), parts.length === 0 && amounts.length === 0 ? null : (_jsxs("div", { className: "flex min-w-0 items-baseline gap-3 text-meta", children: [_jsx("span", { className: "min-w-0 flex-1 text-muted tabular-nums", children: parts.map((p, i) => (_jsxs(Fragment, { children: [i > 0 ? " · " : null, p.node] }, p.id))) }), amounts.map((c) => (
|
|
353
|
+
/* The amount is read by its figure on screen and by its name aloud: a
|
|
354
|
+
lone «Bs. 1.296,00» at the end of a card does not say what it is. */
|
|
355
|
+
_jsxs("span", { className: "shrink-0 text-right font-medium whitespace-nowrap text-ink tabular-nums", children: [_jsxs("span", { className: "sr-only", children: [c.header, " "] }), c.render(row)] }, c.id)))] })), controls.length === 0 ? null : (
|
|
356
|
+
/* Each control under the column's name: a cell had the header above it, a
|
|
357
|
+
card has nothing, and a number box that does not say what it holds is a
|
|
358
|
+
guess. The name is drawn for the eye; the control carries its own label. */
|
|
359
|
+
_jsx("div", { className: "grid grid-cols-2 gap-x-3 gap-y-2 pt-1.5", children: controls.map((c) => (_jsxs("div", { "data-edit": "", className: `flex min-w-0 flex-col gap-1 ${c.numeric === true ? "" : "col-span-2"}`, children: [_jsx("span", { "aria-hidden": "true", className: "text-micro text-muted", children: c.header }), c.edit?.(row, set)] }, c.id))) }))] }, id));
|
|
360
|
+
};
|
|
361
|
+
const list = (_jsxs(_Fragment, { children: [_jsx("ul", { "data-stacked": "", className: "flex min-w-0 flex-col px-1", children: rows.length === 0 ? (_jsx("li", { className: `text-meta text-muted italic ${pad}`, children: emptyText })) : (drawn.map((entry) => {
|
|
362
|
+
if (entry.pile !== undefined) {
|
|
363
|
+
const pile = entry.pile;
|
|
364
|
+
const inside = pile.piles.length === 0 ? pile.items : pileItems(pile.piles);
|
|
365
|
+
return (
|
|
366
|
+
/* The band says the group and, at the right, what its rows add up to in
|
|
367
|
+
the column that is the card's amount: the figure under the figures. */
|
|
368
|
+
_jsxs("li", { className: `flex min-w-0 items-center gap-3 bg-ground text-meta ${pad}`, style: pile.depth > 0
|
|
369
|
+
? {
|
|
370
|
+
paddingLeft: `calc(var(--spacing-indent) * ${pile.depth} + 0.625rem)`,
|
|
371
|
+
}
|
|
372
|
+
: undefined, children: [_jsx("span", { className: "min-w-0 flex-1", children: pileTag(pile, copy.locale) }), amounts.map((c) => (_jsx("span", { className: "shrink-0 text-ink tabular-nums", children: groupTotal?.(c, inside) ?? null }, c.id)))] }, `pile:${pathKey(pile.path)}`));
|
|
373
|
+
}
|
|
374
|
+
return card(entry.row);
|
|
375
|
+
})) }), totals === undefined ? null : (
|
|
376
|
+
/* The footer's aggregates, one per line with the column's name: under a card
|
|
377
|
+
there is no column to stand under. */
|
|
378
|
+
_jsx("dl", { className: `mx-1 flex flex-col gap-1 border-t border-line-strong ${pad}`, children: columns.map((c, i) => {
|
|
379
|
+
const total = totals[i];
|
|
380
|
+
if (total === null || total === undefined)
|
|
381
|
+
return null;
|
|
382
|
+
return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-meta", children: [_jsx("dt", { className: "text-muted", children: c.header }), _jsx("dd", { className: "font-semibold text-ink tabular-nums", children: total })] }, c.id));
|
|
383
|
+
}) }))] }));
|
|
384
|
+
return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, className: `min-w-0 ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [columns.map((c) => (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width }, className: `border-b border-line-strong text-meta font-medium whitespace-nowrap text-muted ${cell} ${c.numeric === true ? "text-right" : "text-left"}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("th", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length + (onRemove === undefined ? 0 : 1), className: `text-meta text-muted italic ${cell}`, children: emptyText }) })) : (drawn.map((entry) => {
|
|
385
|
+
if (entry.pile !== undefined) {
|
|
386
|
+
const pile = entry.pile;
|
|
387
|
+
const inside = pile.piles.length === 0 ? pile.items : pileItems(pile.piles);
|
|
388
|
+
return (
|
|
389
|
+
/* La banda respeta la cuadrícula: el rótulo en la primera
|
|
390
|
+
columna y cada total bajo la suya. Un total en un rincón
|
|
391
|
+
obliga a buscar de qué columna es; alineado no hay nada que
|
|
392
|
+
buscar. */
|
|
393
|
+
_jsxs("tr", { children: [columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
|
|
394
|
+
? {
|
|
395
|
+
paddingLeft: `calc(var(--spacing-indent) * ${pile.depth} + 0.625rem)`,
|
|
396
|
+
}
|
|
397
|
+
: undefined, children: ci === 0
|
|
398
|
+
? pileTag(pile, copy.locale)
|
|
399
|
+
: (groupTotal?.(c, inside) ?? null) }, c.id))), onRemove === undefined ? null : _jsx("td", { className: "w-0 bg-ground" })] }, `pile:${pathKey(pile.path)}`));
|
|
400
|
+
}
|
|
401
|
+
const row = entry.row;
|
|
402
|
+
const id = getRowId(row);
|
|
403
|
+
const isEditing = editing === id;
|
|
404
|
+
/* The wash lives on the cells. A background on the row paints
|
|
405
|
+
edge to edge, and `border-collapse` would ignore a radius. */
|
|
406
|
+
const wash = isEditing ? "bg-primary/5" : "group-hover/row:bg-ink/4";
|
|
407
|
+
const mark = `border-b border-line first:rounded-l-control last:rounded-r-control ${wash}`;
|
|
408
|
+
const set = setter(id);
|
|
409
|
+
return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row),
|
|
410
|
+
/* The focus ring is drawn on the row, inset, so it is not cut by
|
|
411
|
+
the box and does not move the cells. */
|
|
412
|
+
className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [columns.map((c) => {
|
|
413
|
+
const control = isEditing && c.edit !== undefined && editable;
|
|
414
|
+
return (_jsx("td", { ...(control ? { "data-edit": "" } : {}), className: `text-meta text-ink ${mark} ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: control ? c.edit?.(row, set) : c.render(row) }, c.id));
|
|
415
|
+
}), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${mark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id));
|
|
416
|
+
})) }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined ? null : (
|
|
417
|
+
/* The «add» goes under the grid, as text, not as a button away from it:
|
|
418
|
+
what is about to happen is that one more row shows up right here.
|
|
419
|
+
The hover is the same inset mark as a row: the shell lines the label
|
|
420
|
+
up with the cells, and the button itself is the rounded wash. It sits
|
|
421
|
+
outside the scroll box, so a table with its own height keeps it in view. */
|
|
422
|
+
_jsx("div", { className: "px-1 py-0.5", children: _jsxs("button", { type: "button", onClick: onAdd, className: `flex w-full items-center gap-1.5 rounded-control text-left text-meta text-primary transition-colors duration-(--duration-instant) hover:bg-primary/5 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-primary ${byDensity(d, "px-3 py-1", "px-2 py-0.5")}`, children: [_jsx(Plus, { className: "size-(--size-icon-sm)", strokeWidth: 2.5, "aria-hidden": true }), add] }) }))] }));
|
|
423
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Pivot, type PivotKey } from "../../foundations/pivot.js";
|
|
2
|
+
export type PivotTableProps = {
|
|
3
|
+
pivot: Pivot;
|
|
4
|
+
/** Qué pasa al tocar un cruce. Sin esto los números no son botones: una celda
|
|
5
|
+
* que no lleva a ningún lado no tiene por qué prometer que sí. */
|
|
6
|
+
onCell?: (row: PivotKey, column: PivotKey) => void;
|
|
7
|
+
/** Con alto, la grilla scrollea sola y el encabezado se pega a su borde; sin
|
|
8
|
+
* alto scrollea la página. Es la misma regla que las otras tres tablas y vive
|
|
9
|
+
* entera en `useScrollBox`. */
|
|
10
|
+
height?: number | string;
|
|
11
|
+
/** Qué es esta grilla. Se lee en voz alta y se dibuja. */
|
|
12
|
+
caption?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function PivotTable({ pivot, onCell, height, caption }: PivotTableProps): import("react").JSX.Element;
|
|
Binary file
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type ReportRowKind = "section" | "group" | "line" | "total";
|
|
2
|
+
export interface ReportRow {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** The indent. It comes from the consumer and not from the nesting, because a
|
|
6
|
+
* second-level total lives next to its siblings and not inside them. */
|
|
7
|
+
level?: number;
|
|
8
|
+
kind?: ReportRowKind;
|
|
9
|
+
/** One value per column, in the same order. `null` is «not applicable» and is
|
|
10
|
+
* drawn empty — different from `0`, which is an amount. */
|
|
11
|
+
values: readonly (number | null)[];
|
|
12
|
+
/** What hangs underneath when it opens. Its presence is what puts the chevron
|
|
13
|
+
* there. */
|
|
14
|
+
children?: readonly ReportRow[];
|
|
15
|
+
/** The note on the line: what goes into this account, where the number comes
|
|
16
|
+
* from. */
|
|
17
|
+
note?: string;
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
}
|
|
20
|
+
export interface ReportColumn {
|
|
21
|
+
id: string;
|
|
22
|
+
header: string;
|
|
23
|
+
/** For a percentage or a variation column, which is not formatted the same
|
|
24
|
+
* way. */
|
|
25
|
+
format?: (value: number) => string;
|
|
26
|
+
}
|
|
27
|
+
export declare function ReportTable({ columns, rows,
|
|
28
|
+
/** Which groups start open. By default, none: a balance sheet with everything
|
|
29
|
+
* expanded is four hundred lines, and what gets looked at first are the
|
|
30
|
+
* totals. */
|
|
31
|
+
defaultOpen, caption, height, }: {
|
|
32
|
+
columns: readonly ReportColumn[];
|
|
33
|
+
rows: readonly ReportRow[];
|
|
34
|
+
defaultOpen?: readonly string[];
|
|
35
|
+
/** What says what this report is about. It is read out loud and it is drawn. */
|
|
36
|
+
caption?: string;
|
|
37
|
+
height?: number;
|
|
38
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { currentCopyLocale, fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { byDensity, useDensity } from "../../foundations/density.js";
|
|
5
|
+
import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
|
|
6
|
+
import { ChevronRight, Info } from "lucide-react";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
/* `useGrouping: "always"` and not the default: without it, four-digit numbers
|
|
9
|
+
lose the thousands separator —`2023.04` next to `258,015.31` in en-US, or
|
|
10
|
+
`2023,04` next to `258.015,31` in es-ES— and in a column of amounts that
|
|
11
|
+
inconsistency reads as a data error. */
|
|
12
|
+
function amountFormat(locale) {
|
|
13
|
+
return new Intl.NumberFormat(locale, {
|
|
14
|
+
minimumFractionDigits: 2,
|
|
15
|
+
maximumFractionDigits: 2,
|
|
16
|
+
/* The assertion is because of the type definitions, not because of the
|
|
17
|
+
engine: `"always"` is ES2023 and TypeScript's `lib` still declares
|
|
18
|
+
`useGrouping` as a boolean. The runtime understands it, and a boolean here
|
|
19
|
+
is not enough — `true` still leaves four-digit numbers without a
|
|
20
|
+
separator. */
|
|
21
|
+
useGrouping: "always",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const INDENT = 1.25;
|
|
25
|
+
/** The look of each kind of row. It is up here and not spread across the JSX
|
|
26
|
+
* because it is THE decision table of the component: it reads in one go and it
|
|
27
|
+
* changes in one place.
|
|
28
|
+
*
|
|
29
|
+
* `cell` goes on EVERY cell and not on the `<tr>`: the first column is `sticky`
|
|
30
|
+
* and needs a background of its own to cover what passes underneath, so a
|
|
31
|
+
* background on the row plus another one on the pinned cell gives two layers of
|
|
32
|
+
* the same alpha and the band comes out of two greys. The hover of a clickable
|
|
33
|
+
* row is the same: a layer inside each cell, rounded at the two ends, so the
|
|
34
|
+
* pinned name stays opaque and the mark does not run off the sheet. */
|
|
35
|
+
const SKIN = {
|
|
36
|
+
section: { row: "", cell: "bg-ink/8", text: "font-semibold text-ink" },
|
|
37
|
+
group: { row: "", cell: "bg-surface", text: "font-medium text-ink" },
|
|
38
|
+
line: { row: "", cell: "bg-surface", text: "text-ink" },
|
|
39
|
+
total: {
|
|
40
|
+
row: "border-t border-line-strong",
|
|
41
|
+
cell: "bg-surface",
|
|
42
|
+
text: "font-semibold text-ink",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
function Amount({ value, format }) {
|
|
46
|
+
if (value === null)
|
|
47
|
+
return _jsx("span", { className: "text-line-strong", children: "\u00B7" });
|
|
48
|
+
const zero = value === 0;
|
|
49
|
+
const negative = value < 0;
|
|
50
|
+
return (_jsx("span", { className: `tabular-nums ${zero ? "text-muted" : negative ? "text-bad" : "text-ink"}`, children: format === undefined ? amountFormat(currentCopyLocale()).format(value) : format(value) }));
|
|
51
|
+
}
|
|
52
|
+
function ReportLine({ row, columns, open, onToggle, compact, }) {
|
|
53
|
+
const copy = useUiCopy();
|
|
54
|
+
const kind = row.kind ?? "line";
|
|
55
|
+
const skin = SKIN[kind];
|
|
56
|
+
const level = row.level ?? 0;
|
|
57
|
+
const collapsible = row.children !== undefined && row.children.length > 0;
|
|
58
|
+
const isOpen = open.has(row.id);
|
|
59
|
+
const clickable = row.onClick !== undefined;
|
|
60
|
+
return (_jsxs(_Fragment, { children: [_jsxs("tr", { className: `group/row ${skin.row} ${clickable ? "cursor-pointer" : ""}`, onClick: row.onClick, children: [_jsxs("th", { scope: "row",
|
|
61
|
+
/* `sticky left-0` so that the account name does not go away when you
|
|
62
|
+
scroll twelve comparison columns to the right. An amount without its
|
|
63
|
+
line is a loose number. */
|
|
64
|
+
className: `sticky left-0 z-10 text-left font-normal ${skin.cell} ${compact ? "py-1 pr-4 pl-2" : "py-1.5 pr-6 pl-3"}`, style: { paddingLeft: `${(compact ? 0.5 : 0.75) + level * INDENT}rem` }, children: [clickable ? (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 rounded-l-control group-hover/row:bg-primary/5" })) : null, _jsxs("span", { className: `relative flex min-w-0 items-center gap-1.5 text-meta ${skin.text}`, children: [collapsible ? (_jsx("button", { type: "button", onClick: (e) => {
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
onToggle(row.id);
|
|
67
|
+
}, "aria-expanded": isOpen, "aria-label": fillCopy(isOpen ? copy.report.close : copy.report.open, {
|
|
68
|
+
name: row.label,
|
|
69
|
+
}), className: "-ml-1 inline-flex size-4 shrink-0 items-center justify-center rounded-pill text-muted transition-colors duration-(--duration-instant) hover:bg-ink/8 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: _jsx(ChevronRight, { className: `size-(--size-icon-sm) transition-transform duration-(--duration-instant) ${isOpen ? "rotate-90" : ""}`, strokeWidth: 2 }) })) : (
|
|
70
|
+
/* The slot for the chevron is reserved anyway: without it, a leaf
|
|
71
|
+
account and a collapsible one would start on different
|
|
72
|
+
verticals and the hierarchy would stop reading. */
|
|
73
|
+
_jsx("span", { className: "-ml-1 size-4 shrink-0", "aria-hidden": true })), _jsx("span", { className: "truncate", children: row.label })] })] }), columns.map((c, i) => (_jsxs("td", { className: `text-right text-meta whitespace-nowrap ${clickable ? "relative" : ""} ${skin.cell} ${compact ? "px-2 py-1" : "px-3 py-1.5"}`, children: [clickable ? (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 group-hover/row:bg-primary/5" })) : null, kind === "section" ? null : (_jsx("span", { className: "relative", children: _jsx(Amount, { value: row.values[i] ?? null, ...(c.format === undefined ? {} : { format: c.format }) }) }))] }, c.id))), _jsxs("td", { className: `w-0 ${clickable ? "relative" : ""} ${skin.cell} ${compact ? "pr-2" : "pr-3"}`, children: [clickable ? (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 rounded-r-control group-hover/row:bg-primary/5" })) : null, row.note === undefined ? null : (_jsx("span", { title: row.note, className: "relative inline-flex text-muted transition-colors duration-(--duration-instant) hover:text-ink", children: _jsx(Info, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-label": row.note }) }))] })] }), !collapsible || !isOpen
|
|
74
|
+
? null
|
|
75
|
+
: row.children?.map((child) => (_jsx(ReportLine, { row: child, columns: columns, open: open, onToggle: onToggle, compact: compact }, child.id)))] }));
|
|
76
|
+
}
|
|
77
|
+
export function ReportTable({ columns, rows,
|
|
78
|
+
/** Which groups start open. By default, none: a balance sheet with everything
|
|
79
|
+
* expanded is four hundred lines, and what gets looked at first are the
|
|
80
|
+
* totals. */
|
|
81
|
+
defaultOpen, caption, height, }) {
|
|
82
|
+
const copy = useUiCopy();
|
|
83
|
+
const d = useDensity();
|
|
84
|
+
const compact = d === "compact";
|
|
85
|
+
const [open, setOpen] = useState(() => new Set(defaultOpen ?? []));
|
|
86
|
+
const caja = useScrollBox(height === undefined ? {} : { height });
|
|
87
|
+
function toggle(id) {
|
|
88
|
+
setOpen((prev) => {
|
|
89
|
+
const n = new Set(prev);
|
|
90
|
+
if (n.has(id))
|
|
91
|
+
n.delete(id);
|
|
92
|
+
else
|
|
93
|
+
n.add(id);
|
|
94
|
+
return n;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
/* Misma decisión que las otras dos tablas, en el mismo lugar: con alto la
|
|
99
|
+
caja scrollea y la pila arranca de cero adentro; sin alto y entrando, no
|
|
100
|
+
hay caja y el encabezado se pega debajo de la barra de la lista. Antes
|
|
101
|
+
esto era `overflow-auto` siempre, así que el encabezado heredaba el
|
|
102
|
+
desplazamiento de la barra y quedaba empujado 68 px hacia adentro de su
|
|
103
|
+
propia caja — banda en blanco arriba y títulos de columna cayendo entre
|
|
104
|
+
las filas. */
|
|
105
|
+
_jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsxs("table", { className: "w-full border-collapse px-1", children: [caption === undefined ? null : (_jsx("caption", { className: `text-left text-meta text-muted ${byDensity(d, "px-3 pb-2", "px-2 pb-1.5")}`, children: caption })), _jsx("thead", { className: "sticky z-20 bg-ground", style: stickyTop, children: _jsxs("tr", { className: "border-b border-line-strong", children: [_jsx("th", { className: `sticky left-0 z-10 bg-ground text-left ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: _jsx("span", { className: "sr-only", children: copy.report.concept }) }), columns.map((c) => (_jsx("th", { scope: "col", className: `text-right text-meta font-medium whitespace-nowrap text-muted ${compact ? "px-2 py-1.5" : "px-3 py-2"}`, children: c.header }, c.id))), _jsx("th", { className: "w-0" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsx(ReportLine, { row: r, columns: columns, open: open, onToggle: toggle, compact: compact }, r.id))) })] }) }));
|
|
106
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
export interface RowAction {
|
|
3
|
+
id: string;
|
|
4
|
+
/** What is read out loud, in the `title` and in the menu. */
|
|
5
|
+
label: string;
|
|
6
|
+
icon: LucideIcon;
|
|
7
|
+
tone?: "normal" | "action" | "danger";
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
onSelect: () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function RowActions({ actions,
|
|
12
|
+
/** How many stay visible before the rest fall into the `⋯`. */
|
|
13
|
+
inline, }: {
|
|
14
|
+
actions: readonly RowAction[];
|
|
15
|
+
inline?: number;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { IconButton } from "../button/Button.js";
|
|
5
|
+
import { Menu } from "../menu/Menu.js";
|
|
6
|
+
import { MoreHorizontal } from "lucide-react";
|
|
7
|
+
export function RowActions({ actions,
|
|
8
|
+
/** How many stay visible before the rest fall into the `⋯`. */
|
|
9
|
+
inline = 2, }) {
|
|
10
|
+
const copy = useUiCopy();
|
|
11
|
+
if (actions.length === 0)
|
|
12
|
+
return _jsx("td", { className: "w-0" });
|
|
13
|
+
/* With only one left over there is no `⋯`: a menu with a single item is one
|
|
14
|
+
extra click to reach the same button. */
|
|
15
|
+
const inlineActions = actions.length <= inline + 1 ? actions : actions.slice(0, inline);
|
|
16
|
+
const overflow = actions.slice(inlineActions.length);
|
|
17
|
+
const byId = new Map(actions.map((a) => [a.id, a]));
|
|
18
|
+
return (_jsx("td", { className: "w-0 py-1 pr-2 pl-1 align-middle whitespace-nowrap", children: _jsxs("div", { onClick: (e) => e.stopPropagation(), className: "flex items-center justify-end gap-0.5 opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100", children: [inlineActions.map((a) => (_jsx(IconButton, { icon: a.icon, label: a.label, size: "sm", ...(a.tone === undefined ? {} : { tone: a.tone }), ...(a.disabled === undefined ? {} : { disabled: a.disabled }), onClick: a.onSelect }, a.id))), overflow.length === 0 ? null : (_jsx(Menu, { label: copy.selection.more, icon: MoreHorizontal, iconOnly: true, chevron: false, align: "end", groups: [
|
|
19
|
+
{
|
|
20
|
+
options: overflow.map((a) => ({
|
|
21
|
+
id: a.id,
|
|
22
|
+
label: a.label,
|
|
23
|
+
icon: a.icon,
|
|
24
|
+
...(a.disabled === undefined ? {} : { disabled: a.disabled }),
|
|
25
|
+
})),
|
|
26
|
+
},
|
|
27
|
+
], onSelect: (id) => byId.get(id)?.onSelect() }))] }) }));
|
|
28
|
+
}
|