@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,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* How many records a list shows until someone asks for another size.
|
|
5
|
+
*
|
|
6
|
+
* 80 is the product default: enough to scan a catalog without paging every
|
|
7
|
+
* twenty-five rows, still small enough that a table stays a table. 25 / 80 / 100
|
|
8
|
+
* are the presets; any other positive integer is valid and is kept.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_PAGE_SIZE = 80;
|
|
11
|
+
export const PAGE_SIZES = [25, 80, 100];
|
|
12
|
+
function clampSize(next) {
|
|
13
|
+
if (!Number.isFinite(next))
|
|
14
|
+
return DEFAULT_PAGE_SIZE;
|
|
15
|
+
return Math.max(1, Math.floor(next));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Cut an in-memory set into pages.
|
|
19
|
+
*
|
|
20
|
+
* `pageSize` is controlled when `onPageSizeChange` is passed — that is how a
|
|
21
|
+
* view context keeps the size across filters. Without it the hook owns the
|
|
22
|
+
* size, defaulting to 80.
|
|
23
|
+
*
|
|
24
|
+
* The current page is clamped in the calculation, not in an effect: if a
|
|
25
|
+
* filter leaves fewer pages than the current one, the next read already
|
|
26
|
+
* lands on the last page that exists.
|
|
27
|
+
*/
|
|
28
|
+
export function usePagination(items, options = {}) {
|
|
29
|
+
const controlled = options.pageSize !== undefined && options.onPageSizeChange !== undefined;
|
|
30
|
+
const [ownedSize, setOwnedSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
|
31
|
+
const [page, setPage] = useState(1);
|
|
32
|
+
const pageSize = clampSize(controlled ? (options.pageSize ?? DEFAULT_PAGE_SIZE) : ownedSize);
|
|
33
|
+
const setPageSize = controlled ? options.onPageSizeChange : setOwnedSize;
|
|
34
|
+
const total = items.length;
|
|
35
|
+
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
36
|
+
const current = Math.min(page, pages);
|
|
37
|
+
const from = total === 0 ? 0 : (current - 1) * pageSize + 1;
|
|
38
|
+
const to = Math.min(current * pageSize, total);
|
|
39
|
+
return {
|
|
40
|
+
pageItems: items.slice(from > 0 ? from - 1 : 0, to),
|
|
41
|
+
bridge: {
|
|
42
|
+
from,
|
|
43
|
+
to,
|
|
44
|
+
total,
|
|
45
|
+
pageSize,
|
|
46
|
+
onPageSizeChange: (next) => {
|
|
47
|
+
setPageSize(clampSize(next));
|
|
48
|
+
setPage(1);
|
|
49
|
+
},
|
|
50
|
+
/* Se mueve desde `current` y no desde `page`, que es el que puede haber
|
|
51
|
+
quedado alto: si estabas en la 3 y un filtro dejó dos páginas, `page`
|
|
52
|
+
sigue en 3 mientras se muestra la 2, y `p - 1` volvía a 2 — o sea que
|
|
53
|
+
«anterior» no hacía nada hasta el segundo clic. */
|
|
54
|
+
...(current > 1 ? { onPrev: () => setPage(current - 1) } : {}),
|
|
55
|
+
...(current < pages ? { onNext: () => setPage(current + 1) } : {}),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Cut a *server-side* set into pages, by cursor.
|
|
61
|
+
*
|
|
62
|
+
* `usePagination` slices an array that is already in hand; this one never holds the whole set.
|
|
63
|
+
* Same `bridge`, so `Pagination` draws it identically — the difference is invisible to a view.
|
|
64
|
+
*
|
|
65
|
+
* **Going back is a walk, not a jump.** A keyset cursor names the last row seen, so it points
|
|
66
|
+
* forward and nothing points back. Rather than fake it with an offset — which would reintroduce
|
|
67
|
+
* exactly the incoherence the cursor exists to avoid — the cursors already walked are kept, and
|
|
68
|
+
* `onPrev` re-fetches with the one before. That is one request, not a re-walk from the start.
|
|
69
|
+
*
|
|
70
|
+
* **Changing the size starts over**, and that is not a limitation: a cursor belongs to the page
|
|
71
|
+
* size it was minted under, so keeping it would land somewhere nobody asked for.
|
|
72
|
+
*/
|
|
73
|
+
export function useCursorPagination(source, options = {}) {
|
|
74
|
+
const controlled = options.pageSize !== undefined && options.onPageSizeChange !== undefined;
|
|
75
|
+
const [ownedSize, setOwnedSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
|
76
|
+
const pageSize = clampSize(controlled ? options.pageSize : ownedSize);
|
|
77
|
+
/* The cursor that opened each page walked so far. `[undefined]` is the first page, which
|
|
78
|
+
opens with no cursor — so `walked.length` is the page number. */
|
|
79
|
+
const [walked, setWalked] = useState([undefined]);
|
|
80
|
+
const [page, setPage] = useState(null);
|
|
81
|
+
const [loading, setLoading] = useState(true);
|
|
82
|
+
const [error, setError] = useState(null);
|
|
83
|
+
const at = walked[walked.length - 1];
|
|
84
|
+
/* Starting over is keyed on what changes the reading — the filter, the ordering, the size.
|
|
85
|
+
Without this an `onPrev` after a filter change would re-fetch a cursor minted for the
|
|
86
|
+
previous reading, and the server would refuse it. */
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setWalked([undefined]);
|
|
89
|
+
}, [source.resetKey, pageSize]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
let current = true;
|
|
92
|
+
setLoading(true);
|
|
93
|
+
source
|
|
94
|
+
.fetch(at, pageSize)
|
|
95
|
+
.then((answer) => {
|
|
96
|
+
if (!current)
|
|
97
|
+
return;
|
|
98
|
+
setPage(answer);
|
|
99
|
+
setError(null);
|
|
100
|
+
})
|
|
101
|
+
.catch((failure) => current && setError(failure))
|
|
102
|
+
.finally(() => current && setLoading(false));
|
|
103
|
+
return () => {
|
|
104
|
+
current = false;
|
|
105
|
+
};
|
|
106
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
107
|
+
}, [at, pageSize, source.resetKey]);
|
|
108
|
+
const items = page?.items ?? [];
|
|
109
|
+
const total = page?.count?.value ?? items.length;
|
|
110
|
+
const from = items.length === 0 ? 0 : (walked.length - 1) * pageSize + 1;
|
|
111
|
+
const to = from === 0 ? 0 : from + items.length - 1;
|
|
112
|
+
return {
|
|
113
|
+
pageItems: [...items],
|
|
114
|
+
loading,
|
|
115
|
+
error,
|
|
116
|
+
bridge: {
|
|
117
|
+
from,
|
|
118
|
+
to,
|
|
119
|
+
total,
|
|
120
|
+
pageSize,
|
|
121
|
+
onPageSizeChange: (next) => {
|
|
122
|
+
const size = clampSize(next);
|
|
123
|
+
if (controlled)
|
|
124
|
+
options.onPageSizeChange(size);
|
|
125
|
+
else
|
|
126
|
+
setOwnedSize(size);
|
|
127
|
+
setWalked([undefined]);
|
|
128
|
+
},
|
|
129
|
+
...(walked.length > 1 ? { onPrev: () => setWalked((so) => so.slice(0, -1)) } : {}),
|
|
130
|
+
...(page?.cursor != null
|
|
131
|
+
? { onNext: () => setWalked((so) => [...so, page.cursor ?? undefined]) }
|
|
132
|
+
: {}),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing rows of a SET. Lives next to pagination because both are how N
|
|
3
|
+
* records are controlled, not how they are drawn.
|
|
4
|
+
*
|
|
5
|
+
* «All» means the ones ON SCREEN: with a filter on, select-all cannot take
|
|
6
|
+
* what the filter hid. Chosen ids survive filtering — the Set stores ids,
|
|
7
|
+
* not positions — so you can choose, filter again, choose more, and act on
|
|
8
|
+
* both batches.
|
|
9
|
+
*/
|
|
10
|
+
export interface Selection {
|
|
11
|
+
chosen: ReadonlySet<string>;
|
|
12
|
+
count: number;
|
|
13
|
+
has: (id: string) => boolean;
|
|
14
|
+
toggle: (id: string) => void;
|
|
15
|
+
toggleAll: () => void;
|
|
16
|
+
/** `true` all, `false` none, `"some"` part — the third state is the half
|
|
17
|
+
* checkbox, and without it the header lies when three of ten are chosen. */
|
|
18
|
+
allState: boolean | "some";
|
|
19
|
+
clear: () => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function useSelection(visible: readonly string[]): Selection;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
export function useSelection(visible) {
|
|
4
|
+
const [chosen, setChosen] = useState(new Set());
|
|
5
|
+
const allState = useMemo(() => {
|
|
6
|
+
if (visible.length === 0)
|
|
7
|
+
return false;
|
|
8
|
+
const inside = visible.filter((id) => chosen.has(id)).length;
|
|
9
|
+
if (inside === 0)
|
|
10
|
+
return false;
|
|
11
|
+
return inside === visible.length ? true : "some";
|
|
12
|
+
}, [visible, chosen]);
|
|
13
|
+
const toggle = useCallback((id) => {
|
|
14
|
+
setChosen((current) => {
|
|
15
|
+
const next = new Set(current);
|
|
16
|
+
if (next.has(id))
|
|
17
|
+
next.delete(id);
|
|
18
|
+
else
|
|
19
|
+
next.add(id);
|
|
20
|
+
return next;
|
|
21
|
+
});
|
|
22
|
+
}, []);
|
|
23
|
+
const toggleAll = useCallback(() => {
|
|
24
|
+
setChosen((current) => {
|
|
25
|
+
const inside = visible.filter((id) => current.has(id)).length;
|
|
26
|
+
const next = new Set(current);
|
|
27
|
+
if (inside === visible.length)
|
|
28
|
+
visible.forEach((id) => next.delete(id));
|
|
29
|
+
else
|
|
30
|
+
visible.forEach((id) => next.add(id));
|
|
31
|
+
return next;
|
|
32
|
+
});
|
|
33
|
+
}, [visible]);
|
|
34
|
+
return {
|
|
35
|
+
chosen,
|
|
36
|
+
count: chosen.size,
|
|
37
|
+
has: (id) => chosen.has(id),
|
|
38
|
+
toggle,
|
|
39
|
+
toggleAll,
|
|
40
|
+
allState,
|
|
41
|
+
clear: () => setChosen(new Set()),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Pages } from "./pages.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a SET is looked at: which presentation, how many fit on a page.
|
|
4
|
+
*
|
|
5
|
+
* ListView and every catalog-like screen were inventing these two pieces of
|
|
6
|
+
* state on their own. They belong together because they answer the same
|
|
7
|
+
* question — «how do I look at N?» — whether N is domain records or the
|
|
8
|
+
* page of a frame.
|
|
9
|
+
*
|
|
10
|
+
* `Present` is generic so a list (`ListPresent`) and a frame (`table` |
|
|
11
|
+
* `chart`) share the hook without importing each other's unions.
|
|
12
|
+
*
|
|
13
|
+
* Controlled when the view context keeps them (catalog filters must not
|
|
14
|
+
* reset the page size). Uncontrolled when the screen is the only owner.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Las maneras de mirar un conjunto. **Vive acá y no en el arquetipo** porque
|
|
18
|
+
* son dos los que preguntan lo mismo —una lista y un marco— y la definición
|
|
19
|
+
* tiene que ser una sola.
|
|
20
|
+
*
|
|
21
|
+
* Era dos: `ListPresent` en `archetype/ListView.tsx` y esta copia. Mientras las
|
|
22
|
+
* dos decían `rows | table | board` nadie lo notaba; agregar `sheet` a una dejó
|
|
23
|
+
* cuatro vistas sin compilar, que es exactamente lo que pasa cuando la misma
|
|
24
|
+
* idea está escrita dos veces.
|
|
25
|
+
*
|
|
26
|
+
* rows / board no hay columnas: nombre, fichas y meta.
|
|
27
|
+
* table las columnas las DECLARA el código.
|
|
28
|
+
* sheet las columnas salen del ARCHIVO — puede haber cuarenta y un
|
|
29
|
+
* millón de filas, así que va virtualizada.
|
|
30
|
+
* report columnas de período y filas en JERARQUÍA que termina en
|
|
31
|
+
* totales. No se ordena: el orden ES el informe.
|
|
32
|
+
* pivot dos EJES a la vez. Una fila es un montón y una columna
|
|
33
|
+
* también; lo que se lee está en el cruce. Tampoco se
|
|
34
|
+
* ordena: el orden de cada eje es el de sus claves.
|
|
35
|
+
*/
|
|
36
|
+
export type ListPresent = "rows" | "table" | "board" | "sheet" | "report" | "pivot";
|
|
37
|
+
export interface SetControl<Row, Present extends string = ListPresent> extends Pages<Row> {
|
|
38
|
+
present: Present;
|
|
39
|
+
setPresent: (next: Present) => void;
|
|
40
|
+
pageSize: number;
|
|
41
|
+
setPageSize: (next: number) => void;
|
|
42
|
+
}
|
|
43
|
+
export declare function useSetControl<Row, Present extends string = ListPresent>(items: readonly Row[], options?: {
|
|
44
|
+
present?: Present;
|
|
45
|
+
onPresentChange?: (next: Present) => void;
|
|
46
|
+
pageSize?: number;
|
|
47
|
+
onPageSizeChange?: (next: number) => void;
|
|
48
|
+
}): SetControl<Row, Present>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { DEFAULT_PAGE_SIZE, usePagination, } from "./pages.js";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
export function useSetControl(items, options = {}) {
|
|
5
|
+
const initialPresent = (options.present ?? "table");
|
|
6
|
+
const [ownedPresent, setOwnedPresent] = useState(initialPresent);
|
|
7
|
+
const [ownedSize, setOwnedSize] = useState(options.pageSize ?? DEFAULT_PAGE_SIZE);
|
|
8
|
+
const presentControlled = options.onPresentChange !== undefined;
|
|
9
|
+
const present = presentControlled
|
|
10
|
+
? (options.present ?? "table")
|
|
11
|
+
: ownedPresent;
|
|
12
|
+
const setPresent = presentControlled ? options.onPresentChange : setOwnedPresent;
|
|
13
|
+
const sizeControlled = options.onPageSizeChange !== undefined;
|
|
14
|
+
const pageSize = sizeControlled ? (options.pageSize ?? DEFAULT_PAGE_SIZE) : ownedSize;
|
|
15
|
+
const setPageSize = sizeControlled ? options.onPageSizeChange : setOwnedSize;
|
|
16
|
+
const pages = usePagination(items, { pageSize, onPageSizeChange: setPageSize });
|
|
17
|
+
return {
|
|
18
|
+
...pages,
|
|
19
|
+
present,
|
|
20
|
+
setPresent,
|
|
21
|
+
pageSize,
|
|
22
|
+
setPageSize,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which BRANCHES of a set are closed.
|
|
3
|
+
*
|
|
4
|
+
* Grouping is the tree: a list split by customer, a table with expandable
|
|
5
|
+
* group rows, a board with the same headers. The collapsed keys belong to the
|
|
6
|
+
* SET, not to one presentation — switching from rows to table must not reopen
|
|
7
|
+
* what the user just closed.
|
|
8
|
+
*
|
|
9
|
+
* **A key here is a pile's PATH**, serialized with `pathKey()`. With one level a
|
|
10
|
+
* plain key was enough, because no two piles could be called the same. With N
|
|
11
|
+
* levels they can: «2026-09» is one month inside «Ferretería Sur» and another inside
|
|
12
|
+
* «Distribuidora Andina», and folding one must not fold the other. What tells them apart is
|
|
13
|
+
* the route that leads to each, so that is what is stored.
|
|
14
|
+
*
|
|
15
|
+
* A hierarchical parent/child present would use the same keys. There is no
|
|
16
|
+
* fourth archetype for «tree»: collapse is how N is folded, the same way
|
|
17
|
+
* pagination is how N is cut.
|
|
18
|
+
*/
|
|
19
|
+
export type TreeControl = {
|
|
20
|
+
collapsed: readonly string[];
|
|
21
|
+
isOpen: (key: string) => boolean;
|
|
22
|
+
toggle: (key: string) => void;
|
|
23
|
+
};
|
|
24
|
+
export declare function toggleCollapsed(collapsed: readonly string[], key: string): string[];
|
|
25
|
+
export declare function useTreeControl(options?: {
|
|
26
|
+
collapsed?: readonly string[];
|
|
27
|
+
onCollapsedChange?: (keys: readonly string[]) => void;
|
|
28
|
+
}): TreeControl;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
export function toggleCollapsed(collapsed, key) {
|
|
4
|
+
return collapsed.includes(key)
|
|
5
|
+
? collapsed.filter((one) => one !== key)
|
|
6
|
+
: [...collapsed, key];
|
|
7
|
+
}
|
|
8
|
+
// One empty array for every render: a fresh `[]` would change `toggle` each time.
|
|
9
|
+
const NONE_COLLAPSED = [];
|
|
10
|
+
export function useTreeControl(options = {}) {
|
|
11
|
+
const [owned, setOwned] = useState(options.collapsed ?? NONE_COLLAPSED);
|
|
12
|
+
const controlled = options.onCollapsedChange !== undefined;
|
|
13
|
+
const collapsed = controlled ? (options.collapsed ?? NONE_COLLAPSED) : owned;
|
|
14
|
+
const setCollapsed = controlled ? options.onCollapsedChange : setOwned;
|
|
15
|
+
const toggle = useCallback((key) => {
|
|
16
|
+
setCollapsed(toggleCollapsed(collapsed, key));
|
|
17
|
+
}, [collapsed, setCollapsed]);
|
|
18
|
+
return {
|
|
19
|
+
collapsed,
|
|
20
|
+
isOpen: (key) => collapsed.includes(key) !== true,
|
|
21
|
+
toggle,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type PaletteCommand } from "./commands.js";
|
|
2
|
+
/**
|
|
3
|
+
* The search box and the list, without the modal around them.
|
|
4
|
+
*
|
|
5
|
+
* Context: split out so the filtering can be rendered on its own — in a test, with a query
|
|
6
|
+
* already typed — since the modal is a portal that only exists in a browser.
|
|
7
|
+
*/
|
|
8
|
+
export declare function CommandSearch({ commands, onRun, query, onQueryChange, placeholder, }: {
|
|
9
|
+
commands: readonly PaletteCommand[];
|
|
10
|
+
onRun: (id: string) => void;
|
|
11
|
+
query?: string;
|
|
12
|
+
onQueryChange?: (next: string) => void;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
15
|
+
/**
|
|
16
|
+
* A modal search over everything the app can do — go to a screen, create a record — opened
|
|
17
|
+
* with ⌘K (Ctrl+K) and run with Enter.
|
|
18
|
+
*
|
|
19
|
+
* Context: it is controlled (`open`, `onOpenChange`) and does not bind the shortcut itself,
|
|
20
|
+
* because an app has ONE palette and many screens: the key is bound once, where the palette
|
|
21
|
+
* lives, with `useCommandPaletteShortcut`. Running a command closes it — the palette is a
|
|
22
|
+
* path, not a place to stay.
|
|
23
|
+
*/
|
|
24
|
+
export declare function CommandPalette({ open, onOpenChange, commands, onRun, placeholder, }: {
|
|
25
|
+
open: boolean;
|
|
26
|
+
onOpenChange: (open: boolean) => void;
|
|
27
|
+
commands: readonly PaletteCommand[];
|
|
28
|
+
/** A command was chosen. The palette closes on its own. */
|
|
29
|
+
onRun: (id: string) => void;
|
|
30
|
+
/** The search box's prompt and its accessible name. By default, the copy table's. */
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
}): import("react").JSX.Element;
|
|
33
|
+
/**
|
|
34
|
+
* Binds ⌘K (Ctrl+K on Windows and Linux) to open the palette, anywhere in the page.
|
|
35
|
+
*
|
|
36
|
+
* Context: both modifiers are accepted on every platform. Telling them apart by the user
|
|
37
|
+
* agent guesses wrong on a Mac with a PC keyboard, and the cost of accepting both is that
|
|
38
|
+
* Ctrl+K on a Mac — «delete to the end of the line» in a text box — opens the palette.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useCommandPaletteShortcut(setOpen: (open: boolean) => void): void;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { commandMatches, groupCommands, } from "./commands.js";
|
|
4
|
+
import { AriaLocale } from "../../foundations/aria.js";
|
|
5
|
+
import { useUiCopy } from "../../foundations/copy.js";
|
|
6
|
+
import { tv } from "../../foundations/variants.js";
|
|
7
|
+
import { Search, X } from "lucide-react";
|
|
8
|
+
import { useEffect, useMemo } from "react";
|
|
9
|
+
import { Autocomplete, Button as AriaButton, Dialog, Header, Input, Keyboard, Menu, MenuItem, MenuSection, Modal, ModalOverlay, SearchField, useFilter, } from "react-aria-components";
|
|
10
|
+
/* ==================================================================== *
|
|
11
|
+
* COMMAND PALETTE — ⌘K, type three letters, Enter.
|
|
12
|
+
*
|
|
13
|
+
* An ERP has a hundred screens and twenty «new» buttons, and the people who
|
|
14
|
+
* use it all day stop wanting to look for any of them: they know the name of
|
|
15
|
+
* what they want. The palette is the shortest path from knowing the name to
|
|
16
|
+
* being there — one key to open, a few letters, Enter — without leaving the
|
|
17
|
+
* keyboard and without knowing in which menu of which module it lives.
|
|
18
|
+
*
|
|
19
|
+
* ┌──────────────────────────────────────────────┐
|
|
20
|
+
* │ 🔍 fact ✕ │
|
|
21
|
+
* ├──────────────────────────────────────────────┤
|
|
22
|
+
* │ IR A… │
|
|
23
|
+
* │ ▌🧾 Facturas │ ← Enter runs it
|
|
24
|
+
* │ CREAR… │
|
|
25
|
+
* │ + Nueva factura ⌘N │
|
|
26
|
+
* └──────────────────────────────────────────────┘
|
|
27
|
+
*
|
|
28
|
+
* React Aria's `Autocomplete` drives it: focus stays in the search box while
|
|
29
|
+
* the arrows walk the list (virtual focus), the first match is highlighted as
|
|
30
|
+
* you type so Enter always does something, and the modal traps focus, closes
|
|
31
|
+
* on Escape and on a click outside, and gives the focus back.
|
|
32
|
+
*
|
|
33
|
+
* The match is the locale's (`useFilter` inside `AriaLocale`): «facturacion»
|
|
34
|
+
* finds «Facturación» because nobody types the accent when they are in a hurry.
|
|
35
|
+
* ==================================================================== */
|
|
36
|
+
const overlay = tv({
|
|
37
|
+
base: "fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-scrim/40 px-4 pt-24 transition-opacity duration-(--duration-quick) ease-(--ease-out)",
|
|
38
|
+
variants: {
|
|
39
|
+
isEntering: { true: "opacity-0" },
|
|
40
|
+
isExiting: { true: "opacity-0" },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const surface = tv({
|
|
44
|
+
base: "w-full max-w-xl overflow-hidden rounded-panel border border-line-strong bg-surface text-ink shadow-window outline-0 transition-[opacity,translate] duration-(--duration-quick) ease-(--ease-out) forced-colors:bg-[Canvas]",
|
|
45
|
+
variants: {
|
|
46
|
+
isEntering: { true: "-translate-y-1 opacity-0" },
|
|
47
|
+
isExiting: { true: "opacity-0" },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const item = tv({
|
|
51
|
+
base: "flex cursor-default items-center gap-2 rounded-control px-2 py-1.5 text-meta text-ink outline-0 select-none",
|
|
52
|
+
variants: {
|
|
53
|
+
isFocused: {
|
|
54
|
+
true: "bg-primary/8 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]",
|
|
55
|
+
},
|
|
56
|
+
isPressed: { true: "bg-primary/12" },
|
|
57
|
+
isDisabled: { true: "text-muted forced-colors:text-[GrayText]" },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* The search box and the list, without the modal around them.
|
|
62
|
+
*
|
|
63
|
+
* Context: split out so the filtering can be rendered on its own — in a test, with a query
|
|
64
|
+
* already typed — since the modal is a portal that only exists in a browser.
|
|
65
|
+
*/
|
|
66
|
+
export function CommandSearch({ commands, onRun, query, onQueryChange, placeholder, }) {
|
|
67
|
+
const copy = useUiCopy();
|
|
68
|
+
const { contains } = useFilter({ sensitivity: "base" });
|
|
69
|
+
const byId = useMemo(() => new Map(commands.map((c) => [c.id, c])), [commands]);
|
|
70
|
+
const groups = useMemo(() => groupCommands(commands), [commands]);
|
|
71
|
+
const hint = placeholder ?? copy.search.placeholder;
|
|
72
|
+
return (_jsxs(Autocomplete, { ...(query === undefined ? {} : { inputValue: query }), ...(onQueryChange === undefined ? {} : { onInputChange: onQueryChange }), filter: (_text, input, node) => {
|
|
73
|
+
const command = byId.get(String(node.key));
|
|
74
|
+
return command === undefined || commandMatches(command, input, contains);
|
|
75
|
+
}, children: [_jsxs(SearchField, { "aria-label": hint, autoFocus: true, className: "group flex items-center gap-2 border-b border-line px-3", children: [_jsx(Search, { className: "size-(--size-icon-md) shrink-0 text-muted", strokeWidth: 2, "aria-hidden": true }), _jsx(Input, { placeholder: hint, className: "h-(--size-control-lg) min-w-0 flex-1 bg-transparent text-body text-ink outline-0 placeholder:text-muted [&::-webkit-search-cancel-button]:hidden" }), _jsx(AriaButton, { className: "inline-flex shrink-0 items-center justify-center rounded-mark p-1 text-muted transition-colors duration-(--duration-instant) group-empty:invisible hover:bg-ink/6 hover:text-ink pressed:bg-ink/10", children: _jsx(X, { className: "size-(--size-icon-md)", strokeWidth: 2, "aria-hidden": true }) })] }), _jsx(Menu, { "aria-label": hint, onAction: (key) => onRun(String(key)), renderEmptyState: () => (_jsx("p", { className: "px-3 py-6 text-center text-meta text-muted italic", children: copy.menu.empty })), className: "max-h-96 overflow-y-auto p-1 outline-0", children: groups.map((group) => (_jsxs(MenuSection, { id: group.section ?? "-", className: "not-first:mt-1", children: [group.section === undefined ? null : (_jsx(Header, { className: "px-2 pt-2 pb-1 text-micro font-medium text-muted", children: group.section })), group.commands.map((command) => {
|
|
76
|
+
const Icon = command.icon;
|
|
77
|
+
return (_jsxs(MenuItem, { id: command.id, textValue: command.label, className: item, children: [_jsx("span", { className: "flex size-(--size-icon-md) shrink-0 items-center justify-center text-muted", children: Icon === undefined ? null : (_jsx(Icon, { className: "size-(--size-icon-md)", strokeWidth: 2, "aria-hidden": true })) }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: command.label }), command.shortcut === undefined ? null : (_jsx(Keyboard, { className: "shrink-0 rounded-mark border border-line px-1.5 font-sans text-micro text-muted tabular-nums", children: command.shortcut }))] }, command.id));
|
|
78
|
+
})] }, group.section ?? ""))) })] }));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A modal search over everything the app can do — go to a screen, create a record — opened
|
|
82
|
+
* with ⌘K (Ctrl+K) and run with Enter.
|
|
83
|
+
*
|
|
84
|
+
* Context: it is controlled (`open`, `onOpenChange`) and does not bind the shortcut itself,
|
|
85
|
+
* because an app has ONE palette and many screens: the key is bound once, where the palette
|
|
86
|
+
* lives, with `useCommandPaletteShortcut`. Running a command closes it — the palette is a
|
|
87
|
+
* path, not a place to stay.
|
|
88
|
+
*/
|
|
89
|
+
export function CommandPalette({ open, onOpenChange, commands, onRun, placeholder, }) {
|
|
90
|
+
const copy = useUiCopy();
|
|
91
|
+
return (_jsx(AriaLocale, { children: _jsx(ModalOverlay, { isOpen: open, onOpenChange: onOpenChange, isDismissable: true, className: overlay, children: _jsx(Modal, { className: surface, children: _jsx(Dialog, { "aria-label": placeholder ?? copy.search.placeholder, className: "outline-0", children: _jsx(CommandSearch, { commands: commands, onRun: (id) => {
|
|
92
|
+
onOpenChange(false);
|
|
93
|
+
onRun(id);
|
|
94
|
+
}, ...(placeholder === undefined ? {} : { placeholder }) }) }) }) }) }));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Binds ⌘K (Ctrl+K on Windows and Linux) to open the palette, anywhere in the page.
|
|
98
|
+
*
|
|
99
|
+
* Context: both modifiers are accepted on every platform. Telling them apart by the user
|
|
100
|
+
* agent guesses wrong on a Mac with a PC keyboard, and the cost of accepting both is that
|
|
101
|
+
* Ctrl+K on a Mac — «delete to the end of the line» in a text box — opens the palette.
|
|
102
|
+
*/
|
|
103
|
+
export function useCommandPaletteShortcut(setOpen) {
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const onKey = (e) => {
|
|
106
|
+
if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey)
|
|
107
|
+
return;
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
setOpen(true);
|
|
110
|
+
};
|
|
111
|
+
document.addEventListener("keydown", onKey);
|
|
112
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
113
|
+
}, [setOpen]);
|
|
114
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type LucideIcon } from "lucide-react";
|
|
2
|
+
/** One entry of the sidebar. With `children` it is a module that opens and closes; without,
|
|
3
|
+
* it is a screen you go to. */
|
|
4
|
+
export interface NavItem {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
icon?: LucideIcon;
|
|
8
|
+
/** What is waiting there: drafts, overdue, to approve. Zero is not drawn — a «0» beside
|
|
9
|
+
* every quiet screen is a column of noise the eye has to read to learn nothing. */
|
|
10
|
+
count?: number;
|
|
11
|
+
children?: readonly NavItem[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The sidebar of an ERP module: modules that open and close, the screens inside them with
|
|
15
|
+
* an icon and what is waiting there, and the row you are on marked.
|
|
16
|
+
*
|
|
17
|
+
* Context: the modules on the way to `current` start open even when `defaultExpanded` does
|
|
18
|
+
* not name them. A sidebar that loads with the current screen folded away answers «where am
|
|
19
|
+
* I» with a closed module, and that is the whole question it exists for.
|
|
20
|
+
*/
|
|
21
|
+
export declare function NavigationTree({ items, current, onNavigate, defaultExpanded, label, }: {
|
|
22
|
+
items: readonly NavItem[];
|
|
23
|
+
/** The id of the screen on view. */
|
|
24
|
+
current?: string;
|
|
25
|
+
/** A screen was chosen — by click, Enter, or the keyboard. Modules only open and close. */
|
|
26
|
+
onNavigate?: (id: string) => void;
|
|
27
|
+
/** The modules that start open. The path to `current` is always added. */
|
|
28
|
+
defaultExpanded?: readonly string[];
|
|
29
|
+
/** The accessible name of the tree. By default, the copy table's name for a module's menus. */
|
|
30
|
+
label?: string;
|
|
31
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { AriaLocale } from "../../foundations/aria.js";
|
|
4
|
+
import { useUiCopy } from "../../foundations/copy.js";
|
|
5
|
+
import { focusRing, tv } from "../../foundations/variants.js";
|
|
6
|
+
import { ChevronRight } from "lucide-react";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
import { Button as AriaButton, Link, NavigationTree as AriaNavigationTree, NavigationTreeItem as AriaNavigationTreeItem, NavigationTreeItemContent, RouterProvider, } from "react-aria-components";
|
|
9
|
+
const row = tv({
|
|
10
|
+
extend: focusRing,
|
|
11
|
+
base: "group relative flex min-w-0 items-center rounded-control text-meta text-ink outline-offset-0 select-none",
|
|
12
|
+
variants: {
|
|
13
|
+
isCurrent: {
|
|
14
|
+
true: "bg-primary/10 font-medium text-primary forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]",
|
|
15
|
+
false: "hover:bg-ink/6",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const link = tv({
|
|
20
|
+
base: "flex h-(--size-control-sm) min-w-0 flex-1 cursor-pointer items-center gap-2 px-2 text-current no-underline outline-0",
|
|
21
|
+
variants: {
|
|
22
|
+
isModule: { true: "font-medium" },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const chevronButton = tv({
|
|
26
|
+
extend: focusRing,
|
|
27
|
+
base: "mr-1 inline-flex shrink-0 items-center justify-center rounded-mark p-1 text-muted transition-colors duration-(--duration-instant) hover:bg-ink/6 hover:text-ink pressed:bg-ink/10",
|
|
28
|
+
});
|
|
29
|
+
/** The marker of the current row: a bar on the leading edge, so the spot reads in the
|
|
30
|
+
* margin before the label is read. */
|
|
31
|
+
const marker = "absolute inset-y-1 start-0 w-0.5 rounded-pill bg-primary forced-colors:bg-[HighlightText]";
|
|
32
|
+
/** The ids of every module on the way to `id`, so the row you are on is never folded away. */
|
|
33
|
+
function pathTo(items, id) {
|
|
34
|
+
if (id === undefined)
|
|
35
|
+
return [];
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
if (item.id === id)
|
|
38
|
+
return [];
|
|
39
|
+
if (item.children === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
const below = pathTo(item.children, id);
|
|
42
|
+
if (below.length > 0 || item.children.some((c) => c.id === id))
|
|
43
|
+
return [item.id, ...below];
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The sidebar of an ERP module: modules that open and close, the screens inside them with
|
|
49
|
+
* an icon and what is waiting there, and the row you are on marked.
|
|
50
|
+
*
|
|
51
|
+
* Context: the modules on the way to `current` start open even when `defaultExpanded` does
|
|
52
|
+
* not name them. A sidebar that loads with the current screen folded away answers «where am
|
|
53
|
+
* I» with a closed module, and that is the whole question it exists for.
|
|
54
|
+
*/
|
|
55
|
+
export function NavigationTree({ items, current, onNavigate, defaultExpanded = [], label, }) {
|
|
56
|
+
const copy = useUiCopy();
|
|
57
|
+
const number = useMemo(() => new Intl.NumberFormat(copy.locale), [copy.locale]);
|
|
58
|
+
const expanded = useMemo(() => [...new Set([...defaultExpanded, ...pathTo(items, current)])],
|
|
59
|
+
// The initial state only: React Aria owns the expansion after the first render.
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
|
+
[]);
|
|
62
|
+
const render = (item, level) => {
|
|
63
|
+
const children = item.children ?? [];
|
|
64
|
+
const module = children.length > 0;
|
|
65
|
+
const Icon = item.icon;
|
|
66
|
+
/* The first step in puts a screen's label on the vertical of its module's label —
|
|
67
|
+
past the module's icon column — so a module and its screens read as one list of
|
|
68
|
+
words. Every step deeper is the system's nesting step. */
|
|
69
|
+
const indent = level > 1
|
|
70
|
+
? {
|
|
71
|
+
paddingInlineStart: `calc(var(--spacing) * 4 + var(--size-icon-md) + var(--spacing-indent) * ${level - 2})`,
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
return (_jsxs(AriaNavigationTreeItem, { id: item.id, textValue: item.label, ...(module ? {} : { href: item.id }), className: ({ isCurrent, isFocusVisible }) => row({ isCurrent, isFocusVisible }), children: [_jsx(NavigationTreeItemContent, { children: ({ isCurrent, isCurrentAncestor, isExpanded }) => (_jsxs(_Fragment, { children: [isCurrent ? _jsx("span", { "aria-hidden": true, className: marker }) : null, _jsxs(Link, { className: link({ isModule: module }), style: indent, children: [Icon === undefined ? null : (_jsx(Icon, { className: `size-(--size-icon-md) shrink-0 ${isCurrent || (isCurrentAncestor && !isExpanded)
|
|
75
|
+
? "text-primary"
|
|
76
|
+
: "text-muted"}`, strokeWidth: 2, "aria-hidden": true })), _jsx("span", { className: `min-w-0 flex-1 truncate ${isCurrentAncestor && !isExpanded ? "text-primary" : ""}`, children: item.label }), item.count === undefined || item.count <= 0 ? null : (_jsx("span", { className: `shrink-0 rounded-pill px-1.5 text-micro leading-normal font-medium tabular-nums ${isCurrent ? "bg-primary/12 text-primary" : "bg-ink/6 text-muted"}`, children: number.format(item.count) }))] }), module ? (_jsx(AriaButton, { slot: "chevron", className: ({ isFocusVisible }) => chevronButton({ isFocusVisible }), children: _jsx(ChevronRight, { className: `size-(--size-icon-sm) transition-transform duration-(--duration-quick) ease-(--ease-out) ${isExpanded ? "rotate-90" : ""}`, strokeWidth: 2, "aria-hidden": true }) })) : null] })) }), children.map((child) => render(child, level + 1))] }, item.id));
|
|
77
|
+
};
|
|
78
|
+
return (_jsx(AriaLocale, { children: _jsx(RouterProvider, { navigate: (id) => onNavigate?.(id), useHref: (id) => `#${id}`, children: _jsx(AriaNavigationTree, { "aria-label": label ?? copy.shell.menus, selectedRoute: current ?? null, defaultExpandedKeys: expanded, className: "flex w-full min-w-0 flex-col gap-px p-2 outline-0", children: items.map((item) => render(item, 1)) }) }) }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
/** Something the palette can run: go to a screen, create a record, open a report. */
|
|
3
|
+
export interface PaletteCommand {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** The heading it is listed under: «Ir a…», «Crear…». Commands without one go first,
|
|
7
|
+
* without a heading. */
|
|
8
|
+
section?: string;
|
|
9
|
+
icon?: LucideIcon;
|
|
10
|
+
/** The key that runs it without the palette, as it should be read: «⌘N», «Alt+F». It is
|
|
11
|
+
* only drawn — the palette is where the shortcut gets learned, not where it is bound. */
|
|
12
|
+
shortcut?: string;
|
|
13
|
+
/** Other words that should find it: «factura» finds «Nueva venta» when the people who use
|
|
14
|
+
* it call it that. */
|
|
15
|
+
keywords?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/** The commands under each heading, headings in the order they first appear. */
|
|
18
|
+
export declare function groupCommands(commands: readonly PaletteCommand[]): {
|
|
19
|
+
section: string | undefined;
|
|
20
|
+
commands: PaletteCommand[];
|
|
21
|
+
}[];
|
|
22
|
+
/**
|
|
23
|
+
* Whether a command answers a query. `contains` is the locale's comparison — React Aria's
|
|
24
|
+
* `useFilter` in the palette — so «facturacion» finds «Facturación» and an uppercase query
|
|
25
|
+
* finds a lowercase label.
|
|
26
|
+
*
|
|
27
|
+
* Every word of the query has to appear, in any order, in the label or the keywords:
|
|
28
|
+
* «nueva fac» finds «Nueva factura», and so does «factura nueva».
|
|
29
|
+
*/
|
|
30
|
+
export declare function commandMatches(command: PaletteCommand, query: string, contains: (text: string, part: string) => boolean): boolean;
|