@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,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useUiCopy } from "../../foundations/copy.js";
|
|
4
|
+
import { Tab, TabList, Tabs as AriaTabs } from "react-aria-components";
|
|
5
|
+
/* El color de la notificación. Estático y en una tabla porque Tailwind no puede
|
|
6
|
+
componer una clase con un pedazo variable: `bg-${tone}/12` no existe en la
|
|
7
|
+
hoja. */
|
|
8
|
+
const NOTICE_TONE = {
|
|
9
|
+
neutral: "bg-ink/8 text-muted",
|
|
10
|
+
info: "bg-info/12 text-info",
|
|
11
|
+
good: "bg-good/12 text-good",
|
|
12
|
+
warn: "bg-warn/14 text-warn",
|
|
13
|
+
bad: "bg-bad/12 text-bad",
|
|
14
|
+
accent: "bg-accent/14 text-accent",
|
|
15
|
+
};
|
|
16
|
+
const NOTICE_DOT = {
|
|
17
|
+
neutral: "bg-muted",
|
|
18
|
+
info: "bg-info",
|
|
19
|
+
good: "bg-good",
|
|
20
|
+
warn: "bg-warn",
|
|
21
|
+
bad: "bg-bad",
|
|
22
|
+
accent: "bg-accent",
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The strip only: what each tab opens is drawn by whoever uses it (`Record`, `Chatter`).
|
|
26
|
+
*
|
|
27
|
+
* Built on React Aria's `Tabs`, so the strip is a real tab list for the keyboard: the
|
|
28
|
+
* arrows move between tabs and select them, Home/End jump to the ends, and only the open
|
|
29
|
+
* tab is in the Tab order. The hand-written version had the roles and none of the keys.
|
|
30
|
+
*/
|
|
31
|
+
export function Tabs({ tabs, current, onSelect, label, }) {
|
|
32
|
+
const copy = useUiCopy();
|
|
33
|
+
return (_jsx(AriaTabs, { selectedKey: current, onSelectionChange: (key) => onSelect?.(String(key)), children: _jsx(TabList, { "aria-label": label ?? copy.record.faces, className: "flex min-w-0 flex-wrap items-end gap-1", children: tabs.map((a) => {
|
|
34
|
+
const Icon = a.icon;
|
|
35
|
+
const empty = a.count === 0;
|
|
36
|
+
/* Un cero no pide nada, así que no se dibuja. Es la diferencia con el
|
|
37
|
+
contador, donde «0» es una respuesta. */
|
|
38
|
+
const notice = a.notice?.count === 0 ? undefined : a.notice;
|
|
39
|
+
return (_jsx(Tab, { id: a.id, ...(notice === undefined
|
|
40
|
+
? {}
|
|
41
|
+
: { "aria-label": `${a.label} · ${notice.label}` }), className: ({ isSelected }) => `inline-flex shrink-0 cursor-default items-center px-3 py-2 text-meta outline-0 transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary ${isSelected
|
|
42
|
+
? "-mb-px rounded-t-control border border-b-0 border-line-strong bg-surface font-medium text-ink"
|
|
43
|
+
: "border border-transparent text-muted hover:text-ink"}`, children: ({ isSelected }) => (
|
|
44
|
+
/* The `title` goes on the content and not on the tab: React Aria does not
|
|
45
|
+
forward it, and on the content the native tooltip still covers the
|
|
46
|
+
whole tab. */
|
|
47
|
+
_jsxs("span", { className: "inline-flex items-center gap-1.5", ...(notice === undefined ? {} : { title: notice.label }), children: [Icon === undefined ? null : (_jsx(Icon, { className: `size-(--size-icon-sm) shrink-0 ${isSelected ? "text-ink" : "text-muted"}`, strokeWidth: 2, "aria-hidden": true })), a.label, a.count === undefined ? null : (_jsx("span", { className: `rounded-pill px-1.5 text-micro tabular-nums ${empty
|
|
48
|
+
? "bg-ink/5 text-muted/70"
|
|
49
|
+
: isSelected
|
|
50
|
+
? "bg-ink/8 text-ink"
|
|
51
|
+
: "bg-ink/8 text-muted"}`, children: a.count.toLocaleString(copy.locale) })), notice === undefined ? null : notice.count === undefined ? (
|
|
52
|
+
/* Sin número, un punto. La palabra no se dibuja pero está: en el
|
|
53
|
+
`title` y en el nombre accesible de la pestaña. */
|
|
54
|
+
_jsx("span", { className: `size-1.5 shrink-0 rounded-pill ${NOTICE_DOT[notice.tone ?? "bad"]}`, "aria-hidden": true })) : (_jsx("span", { className: `rounded-pill px-1.5 text-micro font-medium tabular-nums ${NOTICE_TONE[notice.tone ?? "bad"]}`, "aria-hidden": true, children: notice.count.toLocaleString(copy.locale) }))] })) }, a.id));
|
|
55
|
+
}) }) }));
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type TabItem, type TabNotice, Tabs } from "./Tabs.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Tabs } from "./Tabs.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Tone } from "../../foundations/types.js";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
export type { Tone };
|
|
4
|
+
/** The classes of a tone on a surface — the capsule's colors, shared with the chips of
|
|
5
|
+
* `Tags` so a classification reads the same as a value and as a removable chip. */
|
|
6
|
+
export declare function tagTone(tone: Tone, surface: "canvas" | "chrome"): string;
|
|
7
|
+
export declare function Tag({ children, tone, surface,
|
|
8
|
+
/** Context: a one-word classification sometimes needs a sentence that does not
|
|
9
|
+
* fit. The sentence goes into the `title` and not underneath, because the
|
|
10
|
+
* line below is taken up by the row's metadata. */
|
|
11
|
+
title, }: {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
tone?: Tone;
|
|
14
|
+
surface?: "canvas" | "chrome";
|
|
15
|
+
title?: string;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
/** The status dot.
|
|
18
|
+
*
|
|
19
|
+
* **It never goes alone.** A colored dot without its word next to it forces
|
|
20
|
+
* you to learn a legend that is written down nowhere, and it says nothing to
|
|
21
|
+
* someone who cannot tell red from green. The dot speeds up the scanning of a
|
|
22
|
+
* column; the word is what informs. That is why `label` is mandatory and gets
|
|
23
|
+
* drawn — if it needs to be hidden, it is hidden with `aria` and not by
|
|
24
|
+
* deleting it.
|
|
25
|
+
*
|
|
26
|
+
* `pulse` for what is ALIVE: a bank statement still importing. It is the only animation in
|
|
27
|
+
* the system, and it exists because «importando» and «importado» differ by one
|
|
28
|
+
* letter and that is far too little for a state that changes on its own. */
|
|
29
|
+
export declare function StatusDot({ tone, label, pulse, surface, }: {
|
|
30
|
+
tone?: Tone;
|
|
31
|
+
/** ReactNode and not `string`: the label is visible text, and sometimes it
|
|
32
|
+
* carries a figure or a name already formatted by whoever composes. */
|
|
33
|
+
label: ReactNode;
|
|
34
|
+
pulse?: boolean;
|
|
35
|
+
surface?: "canvas" | "chrome";
|
|
36
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Each tone on its two surfaces. The state does not change meaning when
|
|
3
|
+
* crossing from the canvas to the chrome, but its value does: `good` #2F6B3C
|
|
4
|
+
* gives 1.7:1 on #1C1A17 and the lightened one has to be used. */
|
|
5
|
+
const TAG_TONE = {
|
|
6
|
+
neutral: {
|
|
7
|
+
canvas: "border border-line-strong bg-ink/5 text-ink",
|
|
8
|
+
chrome: "border border-chrome-muted/30 bg-chrome-ink/10 text-chrome-ink",
|
|
9
|
+
},
|
|
10
|
+
info: {
|
|
11
|
+
canvas: "border border-info/25 bg-info/10 text-info",
|
|
12
|
+
chrome: "border border-chrome-link/30 bg-chrome-link/15 text-chrome-link",
|
|
13
|
+
},
|
|
14
|
+
good: {
|
|
15
|
+
canvas: "border border-good/25 bg-good/10 text-good",
|
|
16
|
+
chrome: "border border-chrome-good/30 bg-chrome-good/15 text-chrome-good",
|
|
17
|
+
},
|
|
18
|
+
warn: {
|
|
19
|
+
canvas: "border border-warn/30 bg-warn/10 text-warn",
|
|
20
|
+
chrome: "border border-chrome-warn/35 bg-chrome-warn/15 text-chrome-warn",
|
|
21
|
+
},
|
|
22
|
+
bad: {
|
|
23
|
+
canvas: "border border-bad/25 bg-bad/10 text-bad",
|
|
24
|
+
chrome: "border border-chrome-bad/35 bg-chrome-bad/15 text-chrome-bad",
|
|
25
|
+
},
|
|
26
|
+
accent: {
|
|
27
|
+
// The gold as TEXT only exists on the chrome (7.39:1). On the canvas it
|
|
28
|
+
// gives 2.43:1, so there this tag falls back to ink on a gold wash: the
|
|
29
|
+
// brand is visible and the word is readable.
|
|
30
|
+
canvas: "border border-accent/40 bg-accent/18 text-ink",
|
|
31
|
+
chrome: "border border-chrome-accent/40 bg-chrome-accent/15 text-chrome-accent",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/** The classes of a tone on a surface — the capsule's colors, shared with the chips of
|
|
35
|
+
* `Tags` so a classification reads the same as a value and as a removable chip. */
|
|
36
|
+
export function tagTone(tone, surface) {
|
|
37
|
+
return TAG_TONE[tone][surface];
|
|
38
|
+
}
|
|
39
|
+
export function Tag({ children, tone = "neutral", surface = "canvas",
|
|
40
|
+
/** Context: a one-word classification sometimes needs a sentence that does not
|
|
41
|
+
* fit. The sentence goes into the `title` and not underneath, because the
|
|
42
|
+
* line below is taken up by the row's metadata. */
|
|
43
|
+
title, }) {
|
|
44
|
+
return (_jsx("span", { title: title, className: `inline-flex shrink-0 items-center rounded-pill px-2 py-0.5 text-micro font-medium whitespace-nowrap ${TAG_TONE[tone][surface]}`, children: children }));
|
|
45
|
+
}
|
|
46
|
+
const DOT_TONE = {
|
|
47
|
+
neutral: { canvas: "bg-muted", chrome: "bg-chrome-muted" },
|
|
48
|
+
info: { canvas: "bg-info", chrome: "bg-chrome-link" },
|
|
49
|
+
good: { canvas: "bg-good", chrome: "bg-chrome-good" },
|
|
50
|
+
warn: { canvas: "bg-warn", chrome: "bg-chrome-warn" },
|
|
51
|
+
bad: { canvas: "bg-bad", chrome: "bg-chrome-bad" },
|
|
52
|
+
accent: { canvas: "bg-accent", chrome: "bg-chrome-accent" },
|
|
53
|
+
};
|
|
54
|
+
/** The status dot.
|
|
55
|
+
*
|
|
56
|
+
* **It never goes alone.** A colored dot without its word next to it forces
|
|
57
|
+
* you to learn a legend that is written down nowhere, and it says nothing to
|
|
58
|
+
* someone who cannot tell red from green. The dot speeds up the scanning of a
|
|
59
|
+
* column; the word is what informs. That is why `label` is mandatory and gets
|
|
60
|
+
* drawn — if it needs to be hidden, it is hidden with `aria` and not by
|
|
61
|
+
* deleting it.
|
|
62
|
+
*
|
|
63
|
+
* `pulse` for what is ALIVE: a bank statement still importing. It is the only animation in
|
|
64
|
+
* the system, and it exists because «importando» and «importado» differ by one
|
|
65
|
+
* letter and that is far too little for a state that changes on its own. */
|
|
66
|
+
export function StatusDot({ tone = "neutral", label, pulse = false, surface = "canvas", }) {
|
|
67
|
+
return (_jsxs("span", { className: "inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap", children: [_jsxs("span", { className: "relative flex size-1.5 shrink-0", children: [pulse ? (_jsx("span", { className: `absolute inline-flex size-full animate-ping rounded-pill opacity-70 ${DOT_TONE[tone][surface]}` })) : null, _jsx("span", { className: `relative inline-flex size-1.5 rounded-pill ${DOT_TONE[tone][surface]}` })] }), _jsx("span", { className: `text-micro ${surface === "chrome" ? "text-chrome-muted" : "text-muted"}`, children: label })] }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Surface, Tone } from "../../foundations/types.js";
|
|
2
|
+
import type { LucideIcon } from "lucide-react";
|
|
3
|
+
export type TagItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** What clarifies it without having to read it whole: which table a field
|
|
7
|
+
* comes from, which symbol a comparison is. It goes muted and inside. */
|
|
8
|
+
note?: string;
|
|
9
|
+
icon?: LucideIcon;
|
|
10
|
+
/** Choose it. Without this the chip is a value and is not pressed. */
|
|
11
|
+
onSelect?: () => void;
|
|
12
|
+
/** Remove it, when removing it means something. It is a separate button
|
|
13
|
+
* inside the chip — and Backspace/Delete on the focused chip — so removing
|
|
14
|
+
* is not the same as choosing. */
|
|
15
|
+
onRemove?: () => void;
|
|
16
|
+
/** The accessible name of the × . React Aria already names it «Remove» +
|
|
17
|
+
* the chip; this replaces the verb when the chip needs a more exact one
|
|
18
|
+
* («quitar el filtro»). */
|
|
19
|
+
removeLabel?: string;
|
|
20
|
+
/** It is chosen right now. */
|
|
21
|
+
selected?: boolean;
|
|
22
|
+
/** It is MARKED by the keyboard of someone else — the search input that
|
|
23
|
+
* drives a list of suggestions — which is different from chosen: marked is
|
|
24
|
+
* «Enter takes this one», chosen is «this one is already set». */
|
|
25
|
+
active?: boolean;
|
|
26
|
+
/** What the chip MEANS, the same tones as `Tag`: a customer category «crédito vencido»
|
|
27
|
+
* in `bad`. Neutral by default — most tags classify, they do not warn. Chosen and
|
|
28
|
+
* marked chips keep their own paint: that state is what the strip is saying then. */
|
|
29
|
+
tone?: Tone;
|
|
30
|
+
title?: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function Tags({ items, surface, label, labelledBy, stack, multiple, }: {
|
|
33
|
+
items: readonly TagItem[];
|
|
34
|
+
/** By default it inherits the surface that wraps it. */
|
|
35
|
+
surface?: Surface;
|
|
36
|
+
/** The accessible name of the strip. A strip of values is read as text and
|
|
37
|
+
* does not need one; a strip that can be pressed or removed is a control,
|
|
38
|
+
* and a control without a name is announced as «grid» and nothing else. */
|
|
39
|
+
label?: string;
|
|
40
|
+
/** The id of a visible label that names the strip — the label of the `Field` it lives
|
|
41
|
+
* in. It wins over `label`. */
|
|
42
|
+
labelledBy?: string;
|
|
43
|
+
/**
|
|
44
|
+
* One below the other, and not on a line that wraps.
|
|
45
|
+
*
|
|
46
|
+
* The difference is not aesthetic: a strip ACCOMPANIES —they are the parts of
|
|
47
|
+
* something being written, and are read together— and a list IS CHOSEN FROM,
|
|
48
|
+
* and there the eye goes down a column and so does the keyboard. Twelve
|
|
49
|
+
* attributes wrapped in three lines are twelve things to sweep in a zigzag to
|
|
50
|
+
* find one.
|
|
51
|
+
*/
|
|
52
|
+
stack?: boolean;
|
|
53
|
+
/** When chips carry `selected`, more than one can be chosen at a time. By
|
|
54
|
+
* default the choice is ONE — a join, a grain, a period — and the strip is
|
|
55
|
+
* announced as single-select. */
|
|
56
|
+
multiple?: boolean;
|
|
57
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { bySurface, useSurface } from "../../foundations/surface.js";
|
|
5
|
+
import { cn, focusRing } from "../../foundations/variants.js";
|
|
6
|
+
import { tagTone } from "./Tag.js";
|
|
7
|
+
import { X } from "lucide-react";
|
|
8
|
+
import { Button as AriaButton, Tag as AriaTag, TagGroup, TagList, } from "react-aria-components";
|
|
9
|
+
export function Tags({ items, surface, label, labelledBy, stack = false, multiple = false, }) {
|
|
10
|
+
const sup = useSurface(surface);
|
|
11
|
+
if (items.length === 0)
|
|
12
|
+
return null;
|
|
13
|
+
const interactive = items.some((i) => i.onSelect !== undefined || i.onRemove !== undefined);
|
|
14
|
+
if (!interactive)
|
|
15
|
+
return _jsx(StillStrip, { items: items, surface: sup, label: label, stack: stack });
|
|
16
|
+
return (_jsx(AriaLocale, { children: _jsx(ChipGroup, { items: items, surface: sup, label: label, labelledBy: labelledBy, stack: stack, multiple: multiple }) }));
|
|
17
|
+
}
|
|
18
|
+
const STRIP = "flex min-w-0 flex-wrap items-center gap-1";
|
|
19
|
+
const STACK = "flex min-w-0 flex-col items-stretch gap-0.5";
|
|
20
|
+
/** A strip nobody presses: text in a row, a list only when it is named. */
|
|
21
|
+
function StillStrip({ items, surface, label, stack, }) {
|
|
22
|
+
const listed = label !== undefined;
|
|
23
|
+
return (_jsx("span", { className: stack ? STACK : STRIP, ...(listed ? { role: "list", "aria-label": label } : {}), children: items.map((item) => (_jsx("span", { ...(listed ? { role: "listitem" } : {}), ...(item.title === undefined ? {} : { title: item.title }), className: shell({ item, surface, stack, removable: false, pressable: false }), children: _jsx(Inside, { item: item }) }, item.id))) }));
|
|
24
|
+
}
|
|
25
|
+
function ChipGroup({ items, surface, label, labelledBy, stack, multiple, }) {
|
|
26
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
27
|
+
const removable = items.some((i) => i.onRemove !== undefined);
|
|
28
|
+
/* Selection is React Aria's only when the chips SAY whether they are chosen: then the
|
|
29
|
+
strip is announced as a selectable list and each chip as selected or not. Chips that
|
|
30
|
+
only act (`onSelect` without `selected`) are actions, and pressing one does not leave
|
|
31
|
+
anything «selected» behind. */
|
|
32
|
+
const selecting = items.some((i) => i.selected !== undefined && i.onSelect !== undefined);
|
|
33
|
+
const chosen = new Set(items.filter((i) => i.selected === true).map((i) => i.id));
|
|
34
|
+
/* The choice is the consumer's: the strip reports which chip was pressed and waits for the
|
|
35
|
+
next `selected`. In a single choice the chip that ARRIVES is the one pressed; pressing
|
|
36
|
+
the chosen one again reports that one, the way a radio reports the click on itself. */
|
|
37
|
+
const onSelectionChange = (next) => {
|
|
38
|
+
if (next === "all")
|
|
39
|
+
return;
|
|
40
|
+
const keys = new Set([...next].map(String));
|
|
41
|
+
const added = [...keys].filter((k) => !chosen.has(k));
|
|
42
|
+
const dropped = [...chosen].filter((k) => !keys.has(k));
|
|
43
|
+
const pressed = multiple ? [...added, ...dropped] : added.length > 0 ? added : dropped;
|
|
44
|
+
for (const key of pressed)
|
|
45
|
+
byId.get(key)?.onSelect?.();
|
|
46
|
+
};
|
|
47
|
+
/* Backspace on a chip removes it — and only the chips that can be removed: a chip without
|
|
48
|
+
`onRemove` in a strip that has some is still a value. */
|
|
49
|
+
const onRemove = (keys) => {
|
|
50
|
+
for (const key of keys)
|
|
51
|
+
byId.get(String(key))?.onRemove?.();
|
|
52
|
+
};
|
|
53
|
+
return (_jsx(TagGroup
|
|
54
|
+
/* The name falls back to the chips themselves: «cliente: Andina, estado: vencida» says
|
|
55
|
+
what the strip holds, which is better than an anonymous grid. */
|
|
56
|
+
, { ...(labelledBy === undefined
|
|
57
|
+
? { "aria-label": label ?? items.map((i) => i.label).join(", ") }
|
|
58
|
+
: { "aria-labelledby": labelledBy }), ...(removable ? { onRemove } : {}), ...(selecting
|
|
59
|
+
? {
|
|
60
|
+
selectionMode: multiple ? "multiple" : "single",
|
|
61
|
+
selectedKeys: chosen,
|
|
62
|
+
onSelectionChange,
|
|
63
|
+
}
|
|
64
|
+
: {}),
|
|
65
|
+
/* Escape belongs to whatever holds the strip — the popover it lives in closes with it —
|
|
66
|
+
and not to «clear the selection», which would erase the choice without a word. */
|
|
67
|
+
escapeKeyBehavior: "none", className: "min-w-0", children: _jsx(TagList, { items: items, className: stack ? STACK : STRIP, children: (item) => (_jsxs(AriaTag, { id: item.id, textValue: item.note === undefined ? item.label : `${item.label} ${item.note}`, ...(!selecting && item.onSelect !== undefined
|
|
68
|
+
? { onAction: item.onSelect }
|
|
69
|
+
: {}), ...(item.title === undefined ? {} : { title: item.title }), className: ({ isFocusVisible, isHovered }) => cn(shell({
|
|
70
|
+
item,
|
|
71
|
+
surface,
|
|
72
|
+
stack,
|
|
73
|
+
removable: item.onRemove !== undefined,
|
|
74
|
+
pressable: item.onSelect !== undefined,
|
|
75
|
+
}), item.onSelect !== undefined && isHovered && item.active !== true
|
|
76
|
+
? bySurface(surface, "border-ink/40", "border-chrome-ink/40")
|
|
77
|
+
: "", "outline-offset-1", focusRing({ isFocusVisible })), children: [_jsx(Inside, { item: item }), item.onRemove === undefined ? null : _jsx(Remove, { item: item, surface: surface })] })) }) }));
|
|
78
|
+
}
|
|
79
|
+
function paint(item, surface) {
|
|
80
|
+
if (item.active === true) {
|
|
81
|
+
return bySurface(surface, "border border-primary bg-primary text-surface", "border border-chrome-link bg-chrome-link text-chrome");
|
|
82
|
+
}
|
|
83
|
+
if (item.selected === true) {
|
|
84
|
+
return bySurface(surface, "border border-accent/40 bg-accent/18 text-ink", "border border-chrome-link/40 bg-chrome-link/25 text-chrome-ink");
|
|
85
|
+
}
|
|
86
|
+
if (item.tone !== undefined && item.tone !== "neutral")
|
|
87
|
+
return tagTone(item.tone, surface);
|
|
88
|
+
return bySurface(surface, "border border-line-strong bg-ink/5 text-ink", "border border-chrome-muted/30 bg-chrome-ink/12 text-chrome-ink");
|
|
89
|
+
}
|
|
90
|
+
/* Stacked, each chip takes its whole line: a column of chips of different widths does not
|
|
91
|
+
read as a list, it reads as a mess — and the blank on the right is what lets it be
|
|
92
|
+
pressed without aiming. */
|
|
93
|
+
function shell({ item, surface, stack, removable, pressable, }) {
|
|
94
|
+
return cn(stack ? "flex w-full justify-start" : "inline-flex shrink-0", "min-w-0 items-center gap-1 rounded-pill py-0.5 text-micro transition-colors duration-(--duration-instant)", paint(item, surface), removable ? "pr-0.5 pl-2" : "px-2", pressable ? "cursor-pointer" : "cursor-default");
|
|
95
|
+
}
|
|
96
|
+
function Inside({ item }) {
|
|
97
|
+
const Icon = item.icon;
|
|
98
|
+
return (_jsxs(_Fragment, { children: [Icon === undefined ? null : (_jsx(Icon, { className: "size-3 shrink-0 opacity-70", strokeWidth: 2, "aria-hidden": true })), _jsx("span", { className: "min-w-0 truncate", children: item.label }), item.note === undefined ? null : (_jsxs("span", { className: "shrink-0 opacity-60", children: ["\u00B7 ", item.note] }))] }));
|
|
99
|
+
}
|
|
100
|
+
/* The × is React Aria's `remove` slot: it removes THIS chip and never presses it, so
|
|
101
|
+
removing a pressable chip does not also choose it. */
|
|
102
|
+
function Remove({ item, surface }) {
|
|
103
|
+
return (_jsx(AriaButton, { slot: "remove", ...(item.removeLabel === undefined ? {} : { "aria-label": item.removeLabel }), className: ({ isFocusVisible }) => cn("inline-flex size-4 shrink-0 cursor-default items-center justify-center rounded-pill transition-colors duration-(--duration-instant)", bySurface(surface, "hover:bg-ink/15 pressed:bg-ink/20", "hover:bg-chrome-ink/25 pressed:bg-chrome-ink/30"), focusRing({ isFocusVisible })), children: _jsx(X, { className: "size-3", strokeWidth: 2.5, "aria-hidden": true }) }));
|
|
104
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type FormMode } from "../field/mode.js";
|
|
2
|
+
import { type TagOption } from "./choices.js";
|
|
3
|
+
export type TagsFieldProps = {
|
|
4
|
+
/** The ids of the chosen tags, in the order they were added. */
|
|
5
|
+
value: readonly string[];
|
|
6
|
+
onChange?: (next: string[]) => void;
|
|
7
|
+
/** Every tag that can be put on the record — chosen ones included, for their labels. */
|
|
8
|
+
options: readonly TagOption[];
|
|
9
|
+
/**
|
|
10
|
+
* Create a tag from the typed text and return its new id (or a promise of it); the field
|
|
11
|
+
* then adds it. Without it there is no «create» row — the catalog is closed.
|
|
12
|
+
*
|
|
13
|
+
* Context: the id is returned and not invented here because the record is created by the
|
|
14
|
+
* backend (Odoo's `name_create`); the consumer also appends the new option to `options`.
|
|
15
|
+
*/
|
|
16
|
+
onCreate?: (label: string) => string | Promise<string>;
|
|
17
|
+
/** The input's placeholder while no tag is chosen. Defaults to the copy table's «Add…». */
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
id?: string;
|
|
21
|
+
describedBy?: string;
|
|
22
|
+
invalid?: boolean;
|
|
23
|
+
/** Forces the mode. By default it inherits the form's: in read mode it draws `Tags`. */
|
|
24
|
+
mode?: FormMode;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A many2many as removable chips plus an inline combobox that adds one more.
|
|
28
|
+
*
|
|
29
|
+
* Works inside `<Field label=…>`: the field's label names both the input and the strip of
|
|
30
|
+
* chips, and its hint and error describe the input. In read mode it is `Tags`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function TagsField({ mode, ...props }: TagsFieldProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { AriaLocale } from "../../foundations/aria.js";
|
|
4
|
+
import { fillCopy, useUiCopy } from "../../foundations/copy.js";
|
|
5
|
+
import { cn } from "../../foundations/variants.js";
|
|
6
|
+
import { fieldGroupStyles, listOptionStyles } from "../field/aria.js";
|
|
7
|
+
import { useFieldIds } from "../field/context.js";
|
|
8
|
+
import { ReadValue } from "../field/Field.js";
|
|
9
|
+
import { useFormMode } from "../field/mode.js";
|
|
10
|
+
import { Popover } from "../overlay/Popover.js";
|
|
11
|
+
import { tagChoices, withoutTag, withTag, } from "./choices.js";
|
|
12
|
+
import { Tags } from "./Tags.js";
|
|
13
|
+
import { Plus } from "lucide-react";
|
|
14
|
+
import { useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
15
|
+
import { ComboBox as AriaComboBox, ComboBoxStateContext, Group, Input, ListBox, ListBoxItem, useFilter, useLocale, } from "react-aria-components";
|
|
16
|
+
/* ==================================================================== *
|
|
17
|
+
* TAGSFIELD — a many2many written as chips: the categories of a customer, the labels of an
|
|
18
|
+
* invoice.
|
|
19
|
+
*
|
|
20
|
+
* Context: the read form of this value is `Tags` — a strip of chips. Editing it is the same
|
|
21
|
+
* strip plus ONE way in: the chips stay where they are read (removable with their × or
|
|
22
|
+
* Backspace), and an input right after the last one adds the next. A separate «add tag»
|
|
23
|
+
* button that opens a list would be two controls for one value, and a multi-select with
|
|
24
|
+
* checkboxes hides what is chosen behind a closed field — the opposite of a many2many,
|
|
25
|
+
* whose whole point is that the categories are seen.
|
|
26
|
+
*
|
|
27
|
+
* ── React Aria's tag group and React Aria's combobox, side by side ──
|
|
28
|
+
*
|
|
29
|
+
* The chips are `Tags` (React Aria's `TagGroup`): one tab stop, arrows between chips,
|
|
30
|
+
* Backspace or Delete removes the focused one. The input is React Aria's `ComboBox`: typing
|
|
31
|
+
* filters in the copy table's locale («exportacion» finds «exportación»), arrows walk the
|
|
32
|
+
* list, Enter adds. Two additions are ours, both what a person who types tags expects:
|
|
33
|
+
* while typing, the FIRST match is marked, so Enter adds it (typing «may» and Enter is the
|
|
34
|
+
* whole gesture, and the row it takes is the tinted one); and Backspace in the empty input
|
|
35
|
+
* removes the last chip.
|
|
36
|
+
*
|
|
37
|
+
* The chosen tags never come back as options — choosing «mayorista» twice is a duplicate,
|
|
38
|
+
* not a choice. «Create «…»» is offered only when `onCreate` is given and the text names no
|
|
39
|
+
* existing tag (see `tagChoices`).
|
|
40
|
+
*
|
|
41
|
+
* The value is a plain `string[]` of ids, in the order they were added — what the backend
|
|
42
|
+
* stores in a many2many, never React Aria's `Selection`.
|
|
43
|
+
* ==================================================================== */
|
|
44
|
+
/** The key of the «create» row. Not an id: it cannot collide with one. */
|
|
45
|
+
const CREATE = "\u0000create";
|
|
46
|
+
/** The chosen tags as chips, in the order of `value`. An id missing from `options` is still
|
|
47
|
+
* drawn (as itself): hiding it would hide data the record has. */
|
|
48
|
+
function chosenOptions(options, value) {
|
|
49
|
+
const byId = new Map(options.map((option) => [option.value, option]));
|
|
50
|
+
return value.map((id) => byId.get(id) ?? { value: id, label: id });
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A many2many as removable chips plus an inline combobox that adds one more.
|
|
54
|
+
*
|
|
55
|
+
* Works inside `<Field label=…>`: the field's label names both the input and the strip of
|
|
56
|
+
* chips, and its hint and error describe the input. In read mode it is `Tags`.
|
|
57
|
+
*/
|
|
58
|
+
export function TagsField({ mode, ...props }) {
|
|
59
|
+
if (useFormMode(mode) === "read") {
|
|
60
|
+
const chosen = chosenOptions(props.options, props.value);
|
|
61
|
+
if (chosen.length === 0)
|
|
62
|
+
return _jsx(ReadValue, { value: null });
|
|
63
|
+
return (_jsx("span", { className: "flex min-h-(--size-control-md) items-center", children: _jsx(Tags, { items: chosen.map((option) => ({
|
|
64
|
+
id: option.value,
|
|
65
|
+
label: option.label,
|
|
66
|
+
...(option.tone === undefined ? {} : { tone: option.tone }),
|
|
67
|
+
})) }) }));
|
|
68
|
+
}
|
|
69
|
+
/* The filter reads the locale from React Aria's context, so the box that provides it has
|
|
70
|
+
to be ABOVE the component that asks for it. */
|
|
71
|
+
return (_jsx(AriaLocale, { children: _jsx(EditableTagsField, { ...props }) }));
|
|
72
|
+
}
|
|
73
|
+
function EditableTagsField({ value, onChange, options, onCreate, placeholder, disabled = false, id: ownId, describedBy: ownDescribedBy, invalid: ownInvalid, }) {
|
|
74
|
+
const { id, labelledBy, describedBy, invalid } = useFieldIds({
|
|
75
|
+
id: ownId,
|
|
76
|
+
describedBy: ownDescribedBy,
|
|
77
|
+
invalid: ownInvalid,
|
|
78
|
+
});
|
|
79
|
+
const copy = useUiCopy();
|
|
80
|
+
const { contains } = useFilter({ sensitivity: "base" });
|
|
81
|
+
const { locale } = useLocale();
|
|
82
|
+
const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", usage: "search" }), [locale]);
|
|
83
|
+
const [text, setText] = useState("");
|
|
84
|
+
const boxRef = useRef(null);
|
|
85
|
+
const [width, setWidth] = useState(null);
|
|
86
|
+
const inputRef = useRef(null);
|
|
87
|
+
/* The value after an `await` is the one of THEN, not the one of the render that started
|
|
88
|
+
creating: a tag removed meanwhile must not come back. */
|
|
89
|
+
const latest = useRef(value);
|
|
90
|
+
latest.current = value;
|
|
91
|
+
const { available, create } = tagChoices(options, value, text, { contains, same: (a, b) => collator.compare(a.trim(), b) === 0 }, onCreate !== undefined && !disabled);
|
|
92
|
+
const listed = create === null
|
|
93
|
+
? available
|
|
94
|
+
: [
|
|
95
|
+
...available,
|
|
96
|
+
{ value: CREATE, label: fillCopy(copy.inlineEdit.createTag, { name: create }) },
|
|
97
|
+
];
|
|
98
|
+
const commit = (key) => {
|
|
99
|
+
if (key === null)
|
|
100
|
+
return;
|
|
101
|
+
const chosen = String(key);
|
|
102
|
+
setText("");
|
|
103
|
+
if (chosen !== CREATE) {
|
|
104
|
+
onChange?.(withTag(latest.current, chosen));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (create === null || onCreate === undefined)
|
|
108
|
+
return;
|
|
109
|
+
void Promise.resolve(onCreate(create)).then((created) => onChange?.(withTag(latest.current, created)));
|
|
110
|
+
};
|
|
111
|
+
const remove = (tagId) => {
|
|
112
|
+
const next = withoutTag(value, tagId);
|
|
113
|
+
onChange?.(next);
|
|
114
|
+
/* The last chip leaves and takes its strip with it: the focus goes to the input instead
|
|
115
|
+
of falling to the page. */
|
|
116
|
+
if (next.length === 0)
|
|
117
|
+
inputRef.current?.focus();
|
|
118
|
+
};
|
|
119
|
+
const chips = chosenOptions(options, value).map((option) => ({
|
|
120
|
+
id: option.value,
|
|
121
|
+
label: option.label,
|
|
122
|
+
...(option.tone === undefined ? {} : { tone: option.tone }),
|
|
123
|
+
...(disabled ? {} : { onRemove: () => remove(option.value) }),
|
|
124
|
+
}));
|
|
125
|
+
return (
|
|
126
|
+
/* The chips sit beside the combobox, not inside it: React Aria builds the combobox's
|
|
127
|
+
list from what it holds, and a tag group in there is read as part of that list. The
|
|
128
|
+
popover is anchored to the whole box and as wide as it — the list continues the field,
|
|
129
|
+
and the input alone is a sliver after the last chip. */
|
|
130
|
+
_jsxs(Group, { ref: boxRef, isDisabled: disabled, isInvalid: invalid, className: (state) => fieldGroupStyles({
|
|
131
|
+
...state,
|
|
132
|
+
/* The box grows with the chips: five categories wrap to a second line instead of
|
|
133
|
+
scrolling out of sight. */
|
|
134
|
+
className: "h-auto min-h-(--size-control-md) flex-wrap gap-1 py-1 pl-1.5",
|
|
135
|
+
}), children: [chips.length === 0 ? null : (_jsx(Tags, { items: chips, ...(labelledBy === undefined ? {} : { labelledBy }) })), _jsxs(AriaComboBox, { id: id, "aria-labelledby": labelledBy, "aria-describedby": describedBy, items: listed, selectedKey: null, onSelectionChange: commit, inputValue: text, onInputChange: setText,
|
|
136
|
+
/* Arriving at the field shows what can still be added, the way a many2one opens its
|
|
137
|
+
list on focus. With every tag chosen and no text there is nothing to show, and the
|
|
138
|
+
list stays closed. */
|
|
139
|
+
menuTrigger: "focus", onOpenChange: (isOpen) => {
|
|
140
|
+
if (isOpen)
|
|
141
|
+
setWidth(boxRef.current?.getBoundingClientRect().width ?? null);
|
|
142
|
+
}, isInvalid: invalid, isDisabled: disabled, className: "flex min-w-24 flex-1", children: [_jsx(TagInput, { inputRef: inputRef, placeholder: value.length === 0 ? (placeholder ?? copy.inlineEdit.addTag) : undefined, firstKey: listed[0]?.value ?? null, onTake: commit, onBackspace: () => {
|
|
143
|
+
const last = value.at(-1);
|
|
144
|
+
if (last !== undefined)
|
|
145
|
+
onChange?.(withoutTag(value, last));
|
|
146
|
+
}, empty: text === "" }), _jsx(Popover, { triggerRef: boxRef, ...(width === null
|
|
147
|
+
? {}
|
|
148
|
+
: { style: { "--trigger-width": `${width}px` } }), placement: "bottom start", className: "w-(--trigger-width)", children: _jsx(ListBox, { items: listed, className: "max-h-[inherit] overflow-auto p-1 outline-0", children: (item) => (_jsx(ListBoxItem, { id: item.value, textValue: item.label, className: (state) => cn(listOptionStyles(state), item.value === CREATE ? "text-primary" : ""), children: item.value === CREATE ? (_jsxs(_Fragment, { children: [_jsx(Plus, { "aria-hidden": true, className: "size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "truncate font-medium", children: item.label })] })) : (_jsx("span", { className: "truncate", children: item.label })) })) }) })] })] }));
|
|
149
|
+
}
|
|
150
|
+
/** The input after the chips. It reads the combobox's state to know whether the keyboard
|
|
151
|
+
* marked an option — Enter with nothing marked takes the first match. */
|
|
152
|
+
function TagInput({ inputRef, placeholder, firstKey, onTake, onBackspace, empty, }) {
|
|
153
|
+
const state = useContext(ComboBoxStateContext);
|
|
154
|
+
/* While typing, the first match is MARKED — so the row Enter is going to take is the row
|
|
155
|
+
the eye sees tinted, not a guess. Only when the keyboard has not marked another one. */
|
|
156
|
+
const open = state?.isOpen === true;
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (state === null || !open || empty || firstKey === null)
|
|
159
|
+
return;
|
|
160
|
+
const focused = state.selectionManager.focusedKey;
|
|
161
|
+
if (focused === null || state.collection.getItem(focused) === null) {
|
|
162
|
+
state.selectionManager.setFocusedKey(firstKey);
|
|
163
|
+
}
|
|
164
|
+
}, [state, open, empty, firstKey]);
|
|
165
|
+
const onKeyDown = (event) => {
|
|
166
|
+
if (event.key === "Backspace" && empty) {
|
|
167
|
+
onBackspace();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (event.key !== "Enter" || empty || firstKey === null)
|
|
171
|
+
return;
|
|
172
|
+
if (state?.selectionManager.focusedKey != null)
|
|
173
|
+
return;
|
|
174
|
+
/* Stopped in the capture phase, so React Aria's own Enter — «commit nothing, reset the
|
|
175
|
+
text» — does not run after ours. */
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
event.stopPropagation();
|
|
178
|
+
state?.close();
|
|
179
|
+
onTake(firstKey);
|
|
180
|
+
};
|
|
181
|
+
return (_jsx(Input, { ref: inputRef, placeholder: placeholder, onKeyDownCapture: onKeyDown, className: "h-(--size-control-sm) min-w-0 flex-1 bg-transparent px-1 text-meta text-ink outline-0 placeholder:text-muted disabled:cursor-not-allowed" }));
|
|
182
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Tone } from "../../foundations/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* One tag that can be put on a record: what is stored (`value`, the id) and what is read
|
|
4
|
+
* (`label`), plus the tone it MEANS when it means something.
|
|
5
|
+
*
|
|
6
|
+
* Context: the same plain shape as `ChoiceOption` — the categories of a customer and the
|
|
7
|
+
* labels of an invoice come from data, and the ids go back to the backend as they came.
|
|
8
|
+
*/
|
|
9
|
+
export interface TagOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** Neutral by default: «mayorista» classifies, it does not warn. */
|
|
13
|
+
tone?: Tone;
|
|
14
|
+
}
|
|
15
|
+
/** How the typed text is compared — the locale's, handed in by the component. */
|
|
16
|
+
export type TagMatch = {
|
|
17
|
+
/** `label` contains `query`, ignoring case and accents. */
|
|
18
|
+
contains: (label: string, query: string) => boolean;
|
|
19
|
+
/** `a` and `b` are the same word, ignoring case and accents. */
|
|
20
|
+
same: (a: string, b: string) => boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* What the inline input offers for a given text.
|
|
24
|
+
*
|
|
25
|
+
* Context: pure and outside the component because it is the contract of the field — the
|
|
26
|
+
* chosen ones never come back as options (choosing «mayorista» twice is a duplicate, not a
|
|
27
|
+
* choice), the typed text filters, and «create» is offered only when the text names NO
|
|
28
|
+
* existing tag, chosen or not: creating «Mayorista» next to «mayorista» is how a catalog
|
|
29
|
+
* ends up with both.
|
|
30
|
+
*/
|
|
31
|
+
export declare function tagChoices(options: readonly TagOption[], value: readonly string[], text: string, match: TagMatch, creatable: boolean): {
|
|
32
|
+
available: TagOption[];
|
|
33
|
+
create: string | null;
|
|
34
|
+
};
|
|
35
|
+
/** The value with one more id at the end; the same value when it is already there. */
|
|
36
|
+
export declare function withTag(value: readonly string[], id: string): string[];
|
|
37
|
+
/** The value without that id. */
|
|
38
|
+
export declare function withoutTag(value: readonly string[], id: string): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the inline input offers for a given text.
|
|
3
|
+
*
|
|
4
|
+
* Context: pure and outside the component because it is the contract of the field — the
|
|
5
|
+
* chosen ones never come back as options (choosing «mayorista» twice is a duplicate, not a
|
|
6
|
+
* choice), the typed text filters, and «create» is offered only when the text names NO
|
|
7
|
+
* existing tag, chosen or not: creating «Mayorista» next to «mayorista» is how a catalog
|
|
8
|
+
* ends up with both.
|
|
9
|
+
*/
|
|
10
|
+
export function tagChoices(options, value, text, match, creatable) {
|
|
11
|
+
const chosen = new Set(value);
|
|
12
|
+
const query = text.trim();
|
|
13
|
+
const available = options.filter((option) => !chosen.has(option.value) && (query === "" || match.contains(option.label, query)));
|
|
14
|
+
const create = creatable && query !== "" && !options.some((option) => match.same(option.label, query))
|
|
15
|
+
? query
|
|
16
|
+
: null;
|
|
17
|
+
return { available, create };
|
|
18
|
+
}
|
|
19
|
+
/** The value with one more id at the end; the same value when it is already there. */
|
|
20
|
+
export function withTag(value, id) {
|
|
21
|
+
return value.includes(id) ? [...value] : [...value, id];
|
|
22
|
+
}
|
|
23
|
+
/** The value without that id. */
|
|
24
|
+
export function withoutTag(value, id) {
|
|
25
|
+
return value.filter((one) => one !== id);
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* Saying a state without writing a sentence: the capsule and the dot. */
|
|
2
|
+
export { StatusDot, Tag } from "./Tag.js";
|
|
3
|
+
/* La tira de fichas: la misma en la barra, en el desplegable y en cualquier
|
|
4
|
+
lugar donde se dibuje una lista de palabras cortas. */
|
|
5
|
+
export { Tags } from "./Tags.js";
|
|
6
|
+
/* El many2many: las fichas elegidas más una entrada que agrega la siguiente. */
|
|
7
|
+
export {} from "./choices.js";
|
|
8
|
+
export { TagsField } from "./TagsField.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export declare function Toolbar({ label, children, orientation, }: {
|
|
3
|
+
/** What the group is, read aloud on entering it: «Acciones de las líneas». A screen with
|
|
4
|
+
* two toolbars is two unnamed groups otherwise. */
|
|
5
|
+
label: string;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
orientation?: "horizontal" | "vertical";
|
|
8
|
+
}): import("react").JSX.Element;
|
|
9
|
+
/** A hairline between two groups of a `Toolbar`, across its direction. */
|
|
10
|
+
export declare function ToolbarSeparator({ orientation, }: {
|
|
11
|
+
/** `vertical` (the default) splits a horizontal toolbar; `horizontal`, a vertical one. */
|
|
12
|
+
orientation?: "horizontal" | "vertical";
|
|
13
|
+
}): import("react").JSX.Element;
|