@yewdiel21/motion-kit 0.10.0 → 0.14.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/README.md +155 -93
- package/dist/auto/index.d.ts +53 -3
- package/dist/auto/index.js +101 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-IKQ5FODN.js → chunk-6P4P7NU6.js} +2 -218
- package/dist/chunk-6P4P7NU6.js.map +1 -0
- package/dist/chunk-CU6SXW3E.js +20334 -0
- package/dist/chunk-CU6SXW3E.js.map +1 -0
- package/dist/{chunk-XAKC2QY7.js → chunk-EY4BJKC4.js} +3 -28
- package/dist/chunk-EY4BJKC4.js.map +1 -0
- package/dist/chunk-QP4QJOEN.js +72 -0
- package/dist/chunk-QP4QJOEN.js.map +1 -0
- package/dist/css/components/activity-feed.css +240 -0
- package/dist/css/components/attachment.css +266 -0
- package/dist/css/components/breadcrumb.css +110 -0
- package/dist/css/components/chart-tooltip.css +168 -0
- package/dist/css/components/chart.css +296 -0
- package/dist/css/components/code-block.css +264 -0
- package/dist/css/components/color-area.css +82 -0
- package/dist/css/components/color-field.css +67 -0
- package/dist/css/components/color-picker.css +131 -0
- package/dist/css/components/color-slider.css +113 -0
- package/dist/css/components/command-search.css +252 -0
- package/dist/css/components/context-menu.css +23 -0
- package/dist/css/components/credit-card.css +288 -0
- package/dist/css/components/data-table.css +591 -0
- package/dist/css/components/date-range-picker.css +168 -0
- package/dist/css/components/date-time-picker.css +178 -0
- package/dist/css/components/drawer.css +58 -0
- package/dist/css/components/editable-table.css +201 -0
- package/dist/css/components/empty-state.css +61 -0
- package/dist/css/components/file-explorer.css +633 -0
- package/dist/css/components/filter-builder.css +232 -0
- package/dist/css/components/hold-confirm.css +87 -0
- package/dist/css/components/hover-card.css +13 -7
- package/dist/css/components/image-preview.css +195 -0
- package/dist/css/components/json-viewer.css +272 -0
- package/dist/css/components/like-button.css +78 -0
- package/dist/css/components/link-preview.css +275 -0
- package/dist/css/components/loading-button.css +77 -0
- package/dist/css/components/multi-select.css +267 -0
- package/dist/css/components/notification-bell.css +127 -0
- package/dist/css/components/password-strength.css +183 -0
- package/dist/css/components/popover.css +25 -0
- package/dist/css/components/progress.css +140 -0
- package/dist/css/components/questionnaire.css +217 -0
- package/dist/css/components/range-calendar.css +186 -0
- package/dist/css/components/range-slider.css +192 -0
- package/dist/css/components/resizable-panel.css +243 -0
- package/dist/css/components/rich-text-editor.css +284 -0
- package/dist/css/components/scratch-card.css +75 -0
- package/dist/css/components/skeleton.css +108 -0
- package/dist/css/components/split-view.css +19 -0
- package/dist/css/components/star-rating.css +80 -0
- package/dist/css/components/stat-card.css +164 -0
- package/dist/css/components/swipe-actions.css +105 -0
- package/dist/css/components/tag-input.css +244 -0
- package/dist/css/components/time-field.css +88 -0
- package/dist/css/components/timeline.css +219 -0
- package/dist/css/components/tooltip.css +79 -0
- package/dist/css/components/tree-view.css +210 -0
- package/dist/css/components/virtual-list.css +195 -0
- package/dist/css/tokens.css +185 -181
- package/dist/elements/index.d.ts +674 -5
- package/dist/elements/index.js +1361 -8
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-GDGwQnJE.d.ts → engine-Cn6IhE3z.d.ts} +1 -1
- package/dist/engine-QECQnGz1.d.ts +33 -0
- package/dist/index.css +9104 -189
- package/dist/index.d.ts +89 -5
- package/dist/index.js +243 -9
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +579 -23
- package/dist/react/index.js +2042 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BmBzKH94.d.ts → reduced-BBUusld6.d.ts} +9 -2
- package/dist/reveal-DEvp6U6T.d.ts +2381 -0
- package/dist/{store-DYb3tTsZ.d.ts → store-DjkTBX7f.d.ts} +2 -2
- package/package.json +1 -1
- package/registry.json +406 -1
- package/src/auto/auto-init.ts +98 -0
- package/src/components/activity-feed/README.md +56 -0
- package/src/components/activity-feed/activity-feed.css +239 -0
- package/src/components/activity-feed/element.ts +36 -0
- package/src/components/activity-feed/engine.ts +351 -0
- package/src/components/activity-feed/react.tsx +32 -0
- package/src/components/attachment/README.md +27 -0
- package/src/components/attachment/attachment.css +265 -0
- package/src/components/attachment/element.ts +50 -0
- package/src/components/attachment/engine.ts +469 -0
- package/src/components/attachment/react.tsx +65 -0
- package/src/components/breadcrumb/README.md +38 -0
- package/src/components/breadcrumb/breadcrumb.css +109 -0
- package/src/components/breadcrumb/element.ts +27 -0
- package/src/components/breadcrumb/engine.ts +249 -0
- package/src/components/breadcrumb/react.tsx +30 -0
- package/src/components/chart/README.md +24 -0
- package/src/components/chart/chart.css +295 -0
- package/src/components/chart/element.ts +41 -0
- package/src/components/chart/engine.ts +811 -0
- package/src/components/chart/react.tsx +50 -0
- package/src/components/chart-tooltip/README.md +53 -0
- package/src/components/chart-tooltip/chart-tooltip.css +167 -0
- package/src/components/chart-tooltip/element.ts +37 -0
- package/src/components/chart-tooltip/engine.ts +442 -0
- package/src/components/chart-tooltip/react.tsx +47 -0
- package/src/components/code-block/README.md +19 -0
- package/src/components/code-block/code-block.css +263 -0
- package/src/components/code-block/element.ts +34 -0
- package/src/components/code-block/engine.ts +254 -0
- package/src/components/code-block/highlight.ts +244 -0
- package/src/components/code-block/react.tsx +94 -0
- package/src/components/color-area/README.md +50 -0
- package/src/components/color-area/color-area.css +81 -0
- package/src/components/color-area/element.ts +26 -0
- package/src/components/color-area/engine.ts +246 -0
- package/src/components/color-area/react.tsx +42 -0
- package/src/components/color-field/README.md +43 -0
- package/src/components/color-field/color-field.css +66 -0
- package/src/components/color-field/element.ts +26 -0
- package/src/components/color-field/engine.ts +174 -0
- package/src/components/color-field/react.tsx +42 -0
- package/src/components/color-picker/README.md +45 -0
- package/src/components/color-picker/color-picker.css +130 -0
- package/src/components/color-picker/element.ts +45 -0
- package/src/components/color-picker/engine.ts +314 -0
- package/src/components/color-picker/react.tsx +47 -0
- package/src/components/color-slider/README.md +55 -0
- package/src/components/color-slider/color-slider.css +112 -0
- package/src/components/color-slider/element.ts +26 -0
- package/src/components/color-slider/engine.ts +234 -0
- package/src/components/color-slider/react.tsx +45 -0
- package/src/components/command-search/README.md +15 -0
- package/src/components/command-search/command-search.css +251 -0
- package/src/components/command-search/element.ts +36 -0
- package/src/components/command-search/engine.ts +356 -0
- package/src/components/command-search/match.ts +53 -0
- package/src/components/command-search/react.tsx +66 -0
- package/src/components/context-menu/README.md +31 -0
- package/src/components/context-menu/context-menu.css +22 -0
- package/src/components/context-menu/element.ts +40 -0
- package/src/components/context-menu/engine.ts +153 -0
- package/src/components/context-menu/react.tsx +57 -0
- package/src/components/credit-card/README.md +20 -0
- package/src/components/credit-card/card.ts +77 -0
- package/src/components/credit-card/credit-card.css +287 -0
- package/src/components/credit-card/element.ts +33 -0
- package/src/components/credit-card/engine.ts +410 -0
- package/src/components/credit-card/react.tsx +36 -0
- package/src/components/data-table/README.md +83 -0
- package/src/components/data-table/data-table.css +590 -0
- package/src/components/data-table/element.ts +54 -0
- package/src/components/data-table/engine.ts +1059 -0
- package/src/components/data-table/react.tsx +94 -0
- package/src/components/date-range-picker/README.md +51 -0
- package/src/components/date-range-picker/date-range-picker.css +167 -0
- package/src/components/date-range-picker/element.ts +52 -0
- package/src/components/date-range-picker/engine.ts +352 -0
- package/src/components/date-range-picker/react.tsx +57 -0
- package/src/components/date-time-picker/README.md +48 -0
- package/src/components/date-time-picker/date-time-picker.css +177 -0
- package/src/components/date-time-picker/element.ts +47 -0
- package/src/components/date-time-picker/engine.ts +350 -0
- package/src/components/date-time-picker/react.tsx +54 -0
- package/src/components/dialog/README.md +103 -102
- package/src/components/dialog/element.ts +123 -118
- package/src/components/dialog/engine.ts +449 -432
- package/src/components/drawer/README.md +28 -0
- package/src/components/drawer/drawer.css +57 -0
- package/src/components/drawer/element.ts +21 -0
- package/src/components/drawer/engine.ts +56 -0
- package/src/components/drawer/react.tsx +85 -0
- package/src/components/editable-table/README.md +67 -0
- package/src/components/editable-table/editable-table.css +200 -0
- package/src/components/editable-table/element.ts +45 -0
- package/src/components/editable-table/engine.ts +775 -0
- package/src/components/editable-table/react.tsx +79 -0
- package/src/components/empty-state/README.md +41 -0
- package/src/components/empty-state/element.ts +23 -0
- package/src/components/empty-state/empty-state.css +60 -0
- package/src/components/empty-state/engine.ts +232 -0
- package/src/components/empty-state/react.tsx +34 -0
- package/src/components/file-explorer/README.md +67 -0
- package/src/components/file-explorer/element.ts +39 -0
- package/src/components/file-explorer/engine.ts +1307 -0
- package/src/components/file-explorer/file-explorer.css +632 -0
- package/src/components/file-explorer/react.tsx +77 -0
- package/src/components/filter-builder/README.md +47 -0
- package/src/components/filter-builder/element.ts +34 -0
- package/src/components/filter-builder/engine.ts +365 -0
- package/src/components/filter-builder/filter-builder.css +231 -0
- package/src/components/filter-builder/react.tsx +36 -0
- package/src/components/hold-confirm/README.md +13 -0
- package/src/components/hold-confirm/element.ts +24 -0
- package/src/components/hold-confirm/engine.ts +216 -0
- package/src/components/hold-confirm/hold-confirm.css +86 -0
- package/src/components/hold-confirm/react.tsx +28 -0
- package/src/components/hover-card/README.md +6 -2
- package/src/components/hover-card/engine.ts +68 -6
- package/src/components/hover-card/hover-card.css +13 -7
- package/src/components/image-preview/README.md +16 -0
- package/src/components/image-preview/element.ts +26 -0
- package/src/components/image-preview/engine.ts +325 -0
- package/src/components/image-preview/image-preview.css +194 -0
- package/src/components/image-preview/react.tsx +43 -0
- package/src/components/json-viewer/README.md +56 -0
- package/src/components/json-viewer/element.ts +37 -0
- package/src/components/json-viewer/engine.ts +440 -0
- package/src/components/json-viewer/json-viewer.css +271 -0
- package/src/components/json-viewer/react.tsx +56 -0
- package/src/components/like-button/README.md +13 -0
- package/src/components/like-button/element.ts +47 -0
- package/src/components/like-button/engine.ts +145 -0
- package/src/components/like-button/like-button.css +77 -0
- package/src/components/like-button/react.tsx +39 -0
- package/src/components/link-preview/README.md +19 -0
- package/src/components/link-preview/element.ts +29 -0
- package/src/components/link-preview/engine.ts +192 -0
- package/src/components/link-preview/link-preview.css +274 -0
- package/src/components/link-preview/react.tsx +101 -0
- package/src/components/loading-button/README.md +45 -0
- package/src/components/loading-button/element.ts +34 -0
- package/src/components/loading-button/engine.ts +201 -0
- package/src/components/loading-button/loading-button.css +76 -0
- package/src/components/loading-button/react.tsx +43 -0
- package/src/components/multi-select/README.md +48 -0
- package/src/components/multi-select/element.ts +28 -0
- package/src/components/multi-select/engine.ts +294 -0
- package/src/components/multi-select/multi-select.css +266 -0
- package/src/components/multi-select/react.tsx +39 -0
- package/src/components/notification-bell/README.md +14 -0
- package/src/components/notification-bell/element.ts +48 -0
- package/src/components/notification-bell/engine.ts +202 -0
- package/src/components/notification-bell/notification-bell.css +126 -0
- package/src/components/notification-bell/react.tsx +57 -0
- package/src/components/password-strength/README.md +17 -0
- package/src/components/password-strength/element.ts +22 -0
- package/src/components/password-strength/engine.ts +212 -0
- package/src/components/password-strength/password-strength.css +182 -0
- package/src/components/password-strength/react.tsx +36 -0
- package/src/components/password-strength/score.ts +62 -0
- package/src/components/popover/README.md +44 -0
- package/src/components/popover/element.ts +31 -0
- package/src/components/popover/engine.ts +114 -0
- package/src/components/popover/popover.css +24 -0
- package/src/components/popover/react.tsx +50 -0
- package/src/components/progress/README.md +17 -0
- package/src/components/progress/element.ts +30 -0
- package/src/components/progress/engine.ts +173 -0
- package/src/components/progress/progress.css +139 -0
- package/src/components/progress/react.tsx +35 -0
- package/src/components/questionnaire/README.md +32 -0
- package/src/components/questionnaire/element.ts +118 -0
- package/src/components/questionnaire/engine.ts +255 -0
- package/src/components/questionnaire/questionnaire.css +216 -0
- package/src/components/questionnaire/react.tsx +140 -0
- package/src/components/range-calendar/README.md +58 -0
- package/src/components/range-calendar/element.ts +42 -0
- package/src/components/range-calendar/engine.ts +390 -0
- package/src/components/range-calendar/range-calendar.css +185 -0
- package/src/components/range-calendar/react.tsx +56 -0
- package/src/components/range-slider/README.md +23 -0
- package/src/components/range-slider/element.ts +53 -0
- package/src/components/range-slider/engine.ts +316 -0
- package/src/components/range-slider/range-slider.css +191 -0
- package/src/components/range-slider/react.tsx +66 -0
- package/src/components/resizable-panel/README.md +61 -0
- package/src/components/resizable-panel/element.ts +36 -0
- package/src/components/resizable-panel/engine.ts +551 -0
- package/src/components/resizable-panel/react.tsx +74 -0
- package/src/components/resizable-panel/resizable-panel.css +242 -0
- package/src/components/rich-text-editor/README.md +57 -0
- package/src/components/rich-text-editor/element.ts +34 -0
- package/src/components/rich-text-editor/engine.ts +673 -0
- package/src/components/rich-text-editor/react.tsx +60 -0
- package/src/components/rich-text-editor/rich-text-editor.css +283 -0
- package/src/components/scratch-card/README.md +15 -0
- package/src/components/scratch-card/element.ts +30 -0
- package/src/components/scratch-card/engine.ts +257 -0
- package/src/components/scratch-card/react.tsx +40 -0
- package/src/components/scratch-card/scratch-card.css +74 -0
- package/src/components/skeleton/README.md +15 -0
- package/src/components/skeleton/element.ts +32 -0
- package/src/components/skeleton/engine.ts +104 -0
- package/src/components/skeleton/react.tsx +41 -0
- package/src/components/skeleton/skeleton.css +107 -0
- package/src/components/split-view/README.md +50 -0
- package/src/components/split-view/element.ts +34 -0
- package/src/components/split-view/engine.ts +114 -0
- package/src/components/split-view/react.tsx +63 -0
- package/src/components/split-view/split-view.css +18 -0
- package/src/components/star-rating/README.md +14 -0
- package/src/components/star-rating/element.ts +26 -0
- package/src/components/star-rating/engine.ts +217 -0
- package/src/components/star-rating/react.tsx +33 -0
- package/src/components/star-rating/star-rating.css +79 -0
- package/src/components/stat-card/README.md +42 -0
- package/src/components/stat-card/element.ts +40 -0
- package/src/components/stat-card/engine.ts +283 -0
- package/src/components/stat-card/react.tsx +36 -0
- package/src/components/stat-card/stat-card.css +163 -0
- package/src/components/swipe-actions/README.md +19 -0
- package/src/components/swipe-actions/element.ts +26 -0
- package/src/components/swipe-actions/engine.ts +260 -0
- package/src/components/swipe-actions/react.tsx +80 -0
- package/src/components/swipe-actions/swipe-actions.css +104 -0
- package/src/components/tag-input/README.md +42 -0
- package/src/components/tag-input/element.ts +39 -0
- package/src/components/tag-input/engine.ts +313 -0
- package/src/components/tag-input/react.tsx +54 -0
- package/src/components/tag-input/tag-input.css +243 -0
- package/src/components/time-field/README.md +54 -0
- package/src/components/time-field/element.ts +38 -0
- package/src/components/time-field/engine.ts +110 -0
- package/src/components/time-field/react.tsx +45 -0
- package/src/components/time-field/time-field.css +87 -0
- package/src/components/timeline/README.md +57 -0
- package/src/components/timeline/element.ts +31 -0
- package/src/components/timeline/engine.ts +300 -0
- package/src/components/timeline/react.tsx +59 -0
- package/src/components/timeline/timeline.css +218 -0
- package/src/components/tooltip/README.md +46 -0
- package/src/components/tooltip/element.ts +22 -0
- package/src/components/tooltip/engine.ts +288 -0
- package/src/components/tooltip/react.tsx +30 -0
- package/src/components/tooltip/tooltip.css +78 -0
- package/src/components/tree-view/README.md +61 -0
- package/src/components/tree-view/element.ts +32 -0
- package/src/components/tree-view/engine.ts +569 -0
- package/src/components/tree-view/react.tsx +78 -0
- package/src/components/tree-view/tree-view.css +209 -0
- package/src/components/virtual-list/README.md +81 -0
- package/src/components/virtual-list/element.ts +42 -0
- package/src/components/virtual-list/engine.ts +678 -0
- package/src/components/virtual-list/react.tsx +101 -0
- package/src/components/virtual-list/virtual-list.css +194 -0
- package/src/core/chips.ts +196 -0
- package/src/core/color.ts +304 -0
- package/src/core/date-range.ts +67 -0
- package/src/core/dates.ts +11 -1
- package/src/core/files.ts +107 -0
- package/src/core/filter.ts +229 -0
- package/src/core/gsap.ts +80 -65
- package/src/core/listbox.ts +332 -0
- package/src/core/mark.ts +29 -0
- package/src/core/position.ts +89 -0
- package/src/core/relative-time.ts +77 -0
- package/src/core/segment-field.ts +373 -0
- package/src/core/segments.ts +153 -0
- package/src/core/tree.ts +116 -0
- package/src/core/virtual.ts +79 -0
- package/src/css/tokens.css +186 -182
- package/src/elements/index.ts +147 -0
- package/src/index.ts +72 -0
- package/src/motion/micro.ts +248 -0
- package/src/motion/sheet.ts +88 -65
- package/src/motion/surface.ts +9 -0
- package/src/react/index.ts +57 -0
- package/dist/chunk-IKQ5FODN.js.map +0 -1
- package/dist/chunk-J6OCFNGU.js +0 -177
- package/dist/chunk-J6OCFNGU.js.map +0 -1
- package/dist/chunk-TFNRIZHU.js +0 -3977
- package/dist/chunk-TFNRIZHU.js.map +0 -1
- package/dist/chunk-XAKC2QY7.js.map +0 -1
- package/dist/reveal-jsX-uwUz.d.ts +0 -625
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { fromJson } from '../../core/dates';
|
|
6
|
+
import { toTime } from '../../core/relative-time';
|
|
7
|
+
import { press, rollDigits, shake } from '../../motion/micro';
|
|
8
|
+
import { createDataTable, type DataColumn, type DataRow, type DataTableInstance, type DataTableOptions, type RowId } from '../data-table/engine';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Editable table: data-table with cells that edit in place. A ring glides from
|
|
12
|
+
* cell to cell with the arrows (.22 s, swing); Enter, F2 or simply typing
|
|
13
|
+
* opens the cell's editor (text, number, date, a choice or a check) with a
|
|
14
|
+
* small pop; Enter keeps it and goes down, Tab goes to the side, Escape lets
|
|
15
|
+
* it be. A value that does not pass shakes and says why. Changed cells keep a
|
|
16
|
+
* dot that pops in and a soft tint; a bar with the count (rolling) folds open
|
|
17
|
+
* above the table with Descartar and Guardar (Ctrl/⌘+S). Saving waits on
|
|
18
|
+
* onSave(): the button spins, then the saved cells flash and their marks fade,
|
|
19
|
+
* or the bar shakes and the changes stay. New rows come in at the top with
|
|
20
|
+
* their first cell open.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type EditorKind = 'text' | 'number' | 'date' | 'select' | 'checkbox';
|
|
24
|
+
|
|
25
|
+
export interface EditableColumn<T extends DataRow = DataRow> extends DataColumn<T> {
|
|
26
|
+
/** Can be edited (default true). */
|
|
27
|
+
editable?: boolean;
|
|
28
|
+
/** The editor (default: from the column's type). */
|
|
29
|
+
editor?: EditorKind;
|
|
30
|
+
required?: boolean;
|
|
31
|
+
min?: number;
|
|
32
|
+
max?: number;
|
|
33
|
+
/** An error message, or nothing when the value is fine. */
|
|
34
|
+
validate?: (value: unknown, row: T) => string | null | undefined | void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RowChange<T extends DataRow = DataRow> {
|
|
38
|
+
id: RowId;
|
|
39
|
+
/** The row as it is now (as it was, for a removed one). */
|
|
40
|
+
row: T;
|
|
41
|
+
/** The values that changed (all of them for a new row). */
|
|
42
|
+
changes: Partial<T>;
|
|
43
|
+
isNew?: boolean;
|
|
44
|
+
removed?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EditableTableOptions<T extends DataRow = DataRow> extends Omit<DataTableOptions<T>, 'columns'> {
|
|
48
|
+
columns?: EditableColumn<T>[];
|
|
49
|
+
/** Keep the changes: resolve when they are saved; reject (or throw) to keep them pending. */
|
|
50
|
+
onSave?: (changes: RowChange<T>[], rows: T[]) => Promise<void> | void;
|
|
51
|
+
/** After every edit. */
|
|
52
|
+
onChange?: (changes: RowChange<T>[]) => void;
|
|
53
|
+
/** A new row's values: shows "Agregar fila". */
|
|
54
|
+
newRow?: () => Partial<T>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EditableTableInstance<T extends DataRow = DataRow> extends Instance {
|
|
58
|
+
/** Open a cell's editor. */
|
|
59
|
+
edit(id: RowId, key: string): void;
|
|
60
|
+
setValue(id: RowId, key: string, value: unknown): boolean;
|
|
61
|
+
addRow(values?: Partial<T>): RowId;
|
|
62
|
+
removeRows(ids: RowId[]): void;
|
|
63
|
+
save(): Promise<boolean>;
|
|
64
|
+
discard(): void;
|
|
65
|
+
setRows(rows: T[]): void;
|
|
66
|
+
readonly changes: RowChange<T>[];
|
|
67
|
+
readonly dirty: boolean;
|
|
68
|
+
readonly rows: T[];
|
|
69
|
+
readonly table: DataTableInstance<T>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
73
|
+
let sequence = 0;
|
|
74
|
+
|
|
75
|
+
const sameValue = (a: unknown, b: unknown) => (a === b || ((a === null || a === undefined || a === '') && (b === null || b === undefined || b === '')));
|
|
76
|
+
|
|
77
|
+
export function createEditableTable<T extends DataRow = DataRow>(root: HTMLElement | null, options: EditableTableOptions<T> = {}): EditableTableInstance<T> {
|
|
78
|
+
if (!root) {
|
|
79
|
+
return inert({
|
|
80
|
+
edit() {}, setValue: () => false, addRow: () => '', removeRows() {}, save: () => Promise.resolve(false), discard() {}, setRows() {},
|
|
81
|
+
changes: [], dirty: false, rows: [], table: null as unknown as DataTableInstance<T>,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
registerMotion();
|
|
85
|
+
const scope = createScope();
|
|
86
|
+
const uid = `mk-et-${++sequence}`;
|
|
87
|
+
const columns: EditableColumn<T>[] = (fromJson(options.columns) ?? []).map((column) => ({ ...column }));
|
|
88
|
+
const rowKey = options.rowKey ?? 'id';
|
|
89
|
+
const idOf = (row: T): RowId => (typeof rowKey === 'function' ? rowKey(row) : (row[rowKey] as RowId));
|
|
90
|
+
/** The rows as last saved. */
|
|
91
|
+
let base: T[] = [...(fromJson(options.rows) ?? [])];
|
|
92
|
+
const edits = new Map<RowId, Partial<T>>();
|
|
93
|
+
let added: T[] = [];
|
|
94
|
+
const removed = new Map<RowId, T>();
|
|
95
|
+
const errors = new Map<string, string>();
|
|
96
|
+
let made = 0;
|
|
97
|
+
let active: { id: RowId; key: string } | null = null;
|
|
98
|
+
let editing: { id: RowId; key: string; editor: HTMLElement; cell: HTMLTableCellElement; done: (save: boolean, move?: 'down' | 'right' | 'left' | null) => boolean } | null = null;
|
|
99
|
+
let saving = false;
|
|
100
|
+
const effects = new Set<gsap.core.Animation>();
|
|
101
|
+
const keep = (animation: gsap.core.Animation | null) => {
|
|
102
|
+
if (!animation) return;
|
|
103
|
+
effects.add(animation);
|
|
104
|
+
// Finished ones leave as new ones arrive: the animation's own onComplete stays untouched.
|
|
105
|
+
for (const effect of effects) if (effect.totalProgress() === 1) effects.delete(effect);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const isEditable = (column: EditableColumn<T> | undefined) => Boolean(column && column.editable !== false && !column.render);
|
|
109
|
+
const columnOf = (key: string) => columns.find((column) => column.key === key);
|
|
110
|
+
const kindOf = (column: EditableColumn<T>): EditorKind =>
|
|
111
|
+
column.editor ?? (column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : column.type === 'select' ? 'select' : column.type === 'boolean' ? 'checkbox' : 'text');
|
|
112
|
+
const cellKey = (id: RowId, key: string) => `${id}\u0000${key}`;
|
|
113
|
+
const merged = (row: T): T => {
|
|
114
|
+
const patch = edits.get(idOf(row));
|
|
115
|
+
return patch ? { ...row, ...patch } : row;
|
|
116
|
+
};
|
|
117
|
+
const display = () => [...added.map(merged), ...base.filter((row) => !removed.has(idOf(row))).map(merged)];
|
|
118
|
+
const originalOf = (id: RowId) => base.find((row) => idOf(row) === id) ?? null;
|
|
119
|
+
const isNew = (id: RowId) => added.some((row) => idOf(row) === id);
|
|
120
|
+
|
|
121
|
+
const changes = (): RowChange<T>[] => {
|
|
122
|
+
const list: RowChange<T>[] = [];
|
|
123
|
+
for (const row of added) list.push({ id: idOf(row), row: merged(row), changes: { ...merged(row) }, isNew: true });
|
|
124
|
+
for (const row of base) {
|
|
125
|
+
const id = idOf(row);
|
|
126
|
+
if (removed.has(id)) {
|
|
127
|
+
list.push({ id, row, changes: {}, removed: true });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const patch = edits.get(id);
|
|
131
|
+
if (patch && Object.keys(patch).length) list.push({ id, row: merged(row), changes: { ...patch } });
|
|
132
|
+
}
|
|
133
|
+
return list;
|
|
134
|
+
};
|
|
135
|
+
const dirtyCount = () => changes().reduce((sum, change) => sum + (change.isNew || change.removed ? 1 : Object.keys(change.changes).length), 0);
|
|
136
|
+
|
|
137
|
+
// ── The table underneath ─────────────────────────────────────────────────
|
|
138
|
+
const userRender = options.onRender;
|
|
139
|
+
// The table draws once while it is made, before the cells can be decorated: that first time is done below.
|
|
140
|
+
let ready = false;
|
|
141
|
+
const table = createDataTable<T>(root, {
|
|
142
|
+
...options,
|
|
143
|
+
columns,
|
|
144
|
+
rows: display(),
|
|
145
|
+
rowKey,
|
|
146
|
+
onRender: (body) => {
|
|
147
|
+
if (ready) decorate(body);
|
|
148
|
+
userRender?.(body);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
scope.add(() => table.destroy());
|
|
152
|
+
const tableElement = table.body.closest('table')!;
|
|
153
|
+
const scroll = root.querySelector<HTMLElement>('[data-mk-dt-scroll]')!;
|
|
154
|
+
scope.attr(tableElement, 'role', 'grid');
|
|
155
|
+
scope.attr(root, 'data-mk-editable', '');
|
|
156
|
+
|
|
157
|
+
// The ring that glides between cells.
|
|
158
|
+
const ring = document.createElement('span');
|
|
159
|
+
ring.dataset.mkEtRing = '';
|
|
160
|
+
ring.setAttribute('aria-hidden', 'true');
|
|
161
|
+
scroll.append(ring);
|
|
162
|
+
|
|
163
|
+
// The bar of pending changes, folded until there are some.
|
|
164
|
+
const bar = document.createElement('div');
|
|
165
|
+
bar.dataset.mkEtSave = '';
|
|
166
|
+
bar.hidden = true;
|
|
167
|
+
const barText = document.createElement('span');
|
|
168
|
+
barText.dataset.mkEtSaveText = '';
|
|
169
|
+
const barCount = document.createElement('span');
|
|
170
|
+
barCount.dataset.mkEtSaveCount = '';
|
|
171
|
+
const barLabel = document.createElement('span');
|
|
172
|
+
barText.append(barCount, barLabel);
|
|
173
|
+
const barError = document.createElement('span');
|
|
174
|
+
barError.dataset.mkEtError = '';
|
|
175
|
+
barError.setAttribute('role', 'alert');
|
|
176
|
+
const discardButton = document.createElement('button');
|
|
177
|
+
discardButton.type = 'button';
|
|
178
|
+
discardButton.dataset.mkEtDiscard = '';
|
|
179
|
+
discardButton.textContent = 'Descartar';
|
|
180
|
+
const saveButton = document.createElement('button');
|
|
181
|
+
saveButton.type = 'button';
|
|
182
|
+
saveButton.dataset.mkEtSaveButton = '';
|
|
183
|
+
saveButton.textContent = 'Guardar';
|
|
184
|
+
bar.append(barText, barError, discardButton, saveButton);
|
|
185
|
+
scroll.before(bar);
|
|
186
|
+
let addButton: HTMLButtonElement | null = null;
|
|
187
|
+
if (options.newRow) {
|
|
188
|
+
addButton = document.createElement('button');
|
|
189
|
+
addButton.type = 'button';
|
|
190
|
+
addButton.dataset.mkDtTool = '';
|
|
191
|
+
addButton.dataset.mkEtAdd = '';
|
|
192
|
+
addButton.innerHTML = '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
|
193
|
+
addButton.append(Object.assign(document.createElement('span'), { textContent: 'Agregar fila' }));
|
|
194
|
+
root.querySelector('[data-mk-dt-tools]')?.prepend(addButton);
|
|
195
|
+
}
|
|
196
|
+
const status = document.createElement('span');
|
|
197
|
+
status.className = 'mk-sr-only';
|
|
198
|
+
status.setAttribute('role', 'status');
|
|
199
|
+
root.append(status);
|
|
200
|
+
scope.add(() => {
|
|
201
|
+
for (const effect of effects) effect.kill();
|
|
202
|
+
effects.clear();
|
|
203
|
+
ring.remove();
|
|
204
|
+
bar.remove();
|
|
205
|
+
addButton?.remove();
|
|
206
|
+
status.remove();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── Cells ────────────────────────────────────────────────────────────────
|
|
210
|
+
const dataCells = (body: HTMLTableSectionElement = table.body) => [...body.querySelectorAll<HTMLTableCellElement>('td[data-key]')];
|
|
211
|
+
const cellOf = (id: RowId, key: string) => table.cellOf(id, key);
|
|
212
|
+
const positionOf = (cell: HTMLTableCellElement) => {
|
|
213
|
+
const tr = cell.parentElement as HTMLTableRowElement;
|
|
214
|
+
const rows = [...table.body.rows].filter((row) => row.dataset.id !== undefined);
|
|
215
|
+
const cells = [...tr.cells].filter((each) => each.dataset.key !== undefined);
|
|
216
|
+
return { row: rows.indexOf(tr), column: cells.indexOf(cell), rows, cells };
|
|
217
|
+
};
|
|
218
|
+
const idFromCell = (cell: HTMLTableCellElement): RowId | null => {
|
|
219
|
+
const raw = (cell.parentElement as HTMLElement).dataset.id;
|
|
220
|
+
if (raw === undefined) return null;
|
|
221
|
+
const row = table.rows.find((each) => String(idOf(each)) === raw);
|
|
222
|
+
return row ? idOf(row) : null;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let restoreFocus = false;
|
|
226
|
+
/** Every time the rows are drawn: roles, the roving cell, marks of changes and errors. */
|
|
227
|
+
const decorate = (body: HTMLTableSectionElement) => {
|
|
228
|
+
for (const tr of body.rows) if (tr.dataset.id !== undefined) tr.setAttribute('aria-rowindex', String(tr.sectionRowIndex + 2));
|
|
229
|
+
const cells = dataCells(body);
|
|
230
|
+
for (const cell of cells) {
|
|
231
|
+
const column = columnOf(cell.dataset.key!);
|
|
232
|
+
const id = idFromCell(cell);
|
|
233
|
+
cell.tabIndex = -1;
|
|
234
|
+
cell.toggleAttribute('data-mk-et-editable', isEditable(column));
|
|
235
|
+
if (isEditable(column)) cell.setAttribute('aria-readonly', 'false');
|
|
236
|
+
else cell.setAttribute('aria-readonly', 'true');
|
|
237
|
+
if (id === null || !column) continue;
|
|
238
|
+
const original = originalOf(id);
|
|
239
|
+
const current = table.rows.find((row) => idOf(row) === id);
|
|
240
|
+
const dirty = isNew(id) || Boolean(original && current && !sameValue(original[column.key], current[column.key]));
|
|
241
|
+
cell.toggleAttribute('data-mk-et-dirty', dirty);
|
|
242
|
+
const error = errors.get(cellKey(id, column.key));
|
|
243
|
+
cell.toggleAttribute('data-mk-et-invalid', Boolean(error));
|
|
244
|
+
if (error) cell.title = error;
|
|
245
|
+
else cell.removeAttribute('title');
|
|
246
|
+
}
|
|
247
|
+
for (const tr of body.rows) tr.toggleAttribute('data-mk-et-new', tr.dataset.id !== undefined && added.some((row) => String(idOf(row)) === tr.dataset.id));
|
|
248
|
+
// The roving cell: the active one if it is on this page, else the first.
|
|
249
|
+
let target = active ? (cellOf(active.id, active.key) ?? null) : null;
|
|
250
|
+
if (!target) {
|
|
251
|
+
target = cells[0] ?? null;
|
|
252
|
+
if (target) {
|
|
253
|
+
const id = idFromCell(target);
|
|
254
|
+
active = id === null ? null : { id, key: target.dataset.key! };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (target) target.tabIndex = 0;
|
|
258
|
+
if (restoreFocus && target) {
|
|
259
|
+
restoreFocus = false;
|
|
260
|
+
target.focus({ preventScroll: true });
|
|
261
|
+
}
|
|
262
|
+
placeRing(false);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const placeRing = (animate: boolean) => {
|
|
266
|
+
const cell = active ? cellOf(active.id, active.key) : null;
|
|
267
|
+
const focused = cell && (document.activeElement === cell || cell.contains(document.activeElement));
|
|
268
|
+
if (!cell || !focused) {
|
|
269
|
+
ring.style.opacity = '0';
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// While rows glide (a transform on the row), the row becomes the cell's offset parent: measure through it.
|
|
273
|
+
const row = cell.parentElement as HTMLElement;
|
|
274
|
+
const inRow = cell.offsetParent === row;
|
|
275
|
+
const target = {
|
|
276
|
+
x: (inRow ? row.offsetLeft : 0) + cell.offsetLeft + tableElement.offsetLeft,
|
|
277
|
+
y: (inRow ? row.offsetTop : 0) + cell.offsetTop + tableElement.offsetTop,
|
|
278
|
+
width: cell.offsetWidth,
|
|
279
|
+
height: cell.offsetHeight,
|
|
280
|
+
};
|
|
281
|
+
const shown = ring.style.opacity === '1';
|
|
282
|
+
ring.style.opacity = '1';
|
|
283
|
+
if (!animate || !shown || reduced()) {
|
|
284
|
+
gsap.killTweensOf(ring);
|
|
285
|
+
gsap.set(ring, target);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
keep(gsap.to(ring, { ...target, duration: 0.22, ease: EASE.swatch, overwrite: 'auto' }));
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const focusCell = (cell: HTMLTableCellElement | null | undefined, animate = true) => {
|
|
292
|
+
if (!cell) return;
|
|
293
|
+
const id = idFromCell(cell);
|
|
294
|
+
if (id === null) return;
|
|
295
|
+
for (const each of dataCells()) each.tabIndex = each === cell ? 0 : -1;
|
|
296
|
+
active = { id, key: cell.dataset.key! };
|
|
297
|
+
cell.focus({ preventScroll: true });
|
|
298
|
+
cell.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
299
|
+
placeRing(animate);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// ── Values ───────────────────────────────────────────────────────────────
|
|
303
|
+
const parse = (column: EditableColumn<T>, raw: unknown): unknown => {
|
|
304
|
+
const kind = kindOf(column);
|
|
305
|
+
if (kind === 'checkbox') return Boolean(raw);
|
|
306
|
+
if (typeof raw !== 'string') return raw;
|
|
307
|
+
const text = raw.trim();
|
|
308
|
+
if (kind === 'number') {
|
|
309
|
+
if (!text) return null;
|
|
310
|
+
// "1250,5" and "1,250.50" both mean what they say.
|
|
311
|
+
const normal = text.includes(',') && !text.includes('.') ? text.replace(',', '.') : text.replace(/,/g, '');
|
|
312
|
+
const value = Number(normal);
|
|
313
|
+
return Number.isFinite(value) ? value : NaN;
|
|
314
|
+
}
|
|
315
|
+
return text;
|
|
316
|
+
};
|
|
317
|
+
const problemWith = (column: EditableColumn<T>, value: unknown, row: T): string | null => {
|
|
318
|
+
const empty = value === null || value === undefined || value === '';
|
|
319
|
+
if (column.required && empty) return `${column.label}: escribe un valor`;
|
|
320
|
+
if (kindOf(column) === 'number' && typeof value === 'number' && Number.isNaN(value)) return `${column.label}: escribe un número`;
|
|
321
|
+
if (typeof value === 'number' && column.min !== undefined && value < column.min) return `${column.label}: el mínimo es ${column.min}`;
|
|
322
|
+
if (typeof value === 'number' && column.max !== undefined && value > column.max) return `${column.label}: el máximo es ${column.max}`;
|
|
323
|
+
if (kindOf(column) === 'date' && !empty && !toTime(value as string)) return `${column.label}: la fecha no es válida`;
|
|
324
|
+
const custom = column.validate?.(value, row);
|
|
325
|
+
return custom ? String(custom) : null;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const report = () => {
|
|
329
|
+
const list = changes();
|
|
330
|
+
options.onChange?.(list);
|
|
331
|
+
root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { changes: list } }));
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** A cell's new value comes in out of a light blur (no movement: it is a table cell). */
|
|
335
|
+
const fadeCell = (cell: HTMLElement) => keep(gsap.fromTo(cell, { opacity: 0.4, filter: 'blur(4px)' }, { opacity: 1, filter: 'blur(0px)', duration: 0.3, ease: 'power1.out', clearProps: 'opacity,filter' }));
|
|
336
|
+
/** Why a value cannot go in a cell (null when it can). */
|
|
337
|
+
const check = (id: RowId, key: string, raw: unknown): string | null => {
|
|
338
|
+
const column = columnOf(key);
|
|
339
|
+
const current = table.rows.find((row) => idOf(row) === id);
|
|
340
|
+
if (!column || !current) return 'Esa celda no existe';
|
|
341
|
+
const value = parse(column, raw);
|
|
342
|
+
return problemWith(column, value, { ...current, [key]: value } as T);
|
|
343
|
+
};
|
|
344
|
+
/** Put a value in a cell; false (and a reason) when it does not pass. */
|
|
345
|
+
const setValue = (id: RowId, key: string, raw: unknown, animate = true): boolean => {
|
|
346
|
+
const column = columnOf(key);
|
|
347
|
+
const current = table.rows.find((row) => idOf(row) === id);
|
|
348
|
+
if (!column || !current) return false;
|
|
349
|
+
const value = parse(column, raw);
|
|
350
|
+
const problem = problemWith(column, value, { ...current, [key]: value } as T);
|
|
351
|
+
if (problem) {
|
|
352
|
+
errors.set(cellKey(id, key), problem);
|
|
353
|
+
barError.textContent = problem;
|
|
354
|
+
paintBar(animate);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
errors.delete(cellKey(id, key));
|
|
358
|
+
barError.textContent = [...errors.values()][0] ?? '';
|
|
359
|
+
const original = originalOf(id);
|
|
360
|
+
const patch = { ...(edits.get(id) ?? {}) } as Partial<T>;
|
|
361
|
+
if (original && sameValue(original[key], value)) delete patch[key as keyof T];
|
|
362
|
+
else (patch as Record<string, unknown>)[key] = value;
|
|
363
|
+
if (Object.keys(patch).length) edits.set(id, patch);
|
|
364
|
+
else edits.delete(id);
|
|
365
|
+
const wasDirty = cellOf(id, key)?.hasAttribute('data-mk-et-dirty') ?? false;
|
|
366
|
+
// Redrawn rows are new elements: the focus comes back to the active cell.
|
|
367
|
+
const focused = document.activeElement;
|
|
368
|
+
restoreFocus = Boolean(focused && table.body.contains(focused));
|
|
369
|
+
table.setRows(display());
|
|
370
|
+
const cell = cellOf(id, key);
|
|
371
|
+
if (cell && animate && !reduced()) {
|
|
372
|
+
fadeCell(cell);
|
|
373
|
+
if (!wasDirty && cell.hasAttribute('data-mk-et-dirty')) cell.setAttribute('data-mk-et-pop', '');
|
|
374
|
+
}
|
|
375
|
+
paintBar(animate);
|
|
376
|
+
report();
|
|
377
|
+
return true;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// ── Editing ──────────────────────────────────────────────────────────────
|
|
381
|
+
const startEdit = (cell: HTMLTableCellElement, typed?: string) => {
|
|
382
|
+
if (saving) return;
|
|
383
|
+
const column = columnOf(cell.dataset.key!);
|
|
384
|
+
const id = idFromCell(cell);
|
|
385
|
+
if (!isEditable(column) || id === null) {
|
|
386
|
+
if (column && !isEditable(column)) keep(shake(cell, 3));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
finishEdit(true);
|
|
390
|
+
const row = table.rows.find((each) => idOf(each) === id)!;
|
|
391
|
+
const value = row[column!.key];
|
|
392
|
+
const kind = kindOf(column!);
|
|
393
|
+
// A check flips at once: no editor.
|
|
394
|
+
if (kind === 'checkbox') {
|
|
395
|
+
setValue(id, column!.key, !value);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
let editor: HTMLInputElement | HTMLSelectElement;
|
|
399
|
+
if (kind === 'select') {
|
|
400
|
+
const select = document.createElement('select');
|
|
401
|
+
for (const option of column!.options ?? []) {
|
|
402
|
+
const element = document.createElement('option');
|
|
403
|
+
element.value = option.value;
|
|
404
|
+
element.textContent = option.label;
|
|
405
|
+
select.append(element);
|
|
406
|
+
}
|
|
407
|
+
select.value = String(value ?? '');
|
|
408
|
+
editor = select;
|
|
409
|
+
} else {
|
|
410
|
+
const input = document.createElement('input');
|
|
411
|
+
input.type = kind === 'number' ? 'text' : kind === 'date' ? 'date' : 'text';
|
|
412
|
+
if (kind === 'number') input.inputMode = 'decimal';
|
|
413
|
+
const date = kind === 'date' ? toTime(value as string) : null;
|
|
414
|
+
input.value = typed ?? (date ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` : value === null || value === undefined ? '' : String(value));
|
|
415
|
+
editor = input;
|
|
416
|
+
}
|
|
417
|
+
editor.dataset.mkEtEditor = '';
|
|
418
|
+
editor.setAttribute('aria-label', `${column!.label}, editar`);
|
|
419
|
+
const previous = [...cell.childNodes];
|
|
420
|
+
cell.replaceChildren(editor);
|
|
421
|
+
cell.setAttribute('data-mk-et-editing', '');
|
|
422
|
+
editor.focus();
|
|
423
|
+
if (editor instanceof HTMLInputElement && editor.type === 'text') {
|
|
424
|
+
if (typed === undefined) editor.select();
|
|
425
|
+
else editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
426
|
+
}
|
|
427
|
+
if (!reduced()) keep(gsap.fromTo(editor, { scale: 0.96, opacity: 0.4 }, { scale: 1, opacity: 1, duration: 0.25, ease: EASE.pop, clearProps: 'transform,opacity' }));
|
|
428
|
+
const done = (save: boolean, move: 'down' | 'right' | 'left' | null = null) => {
|
|
429
|
+
if (!editing || editing.editor !== editor) return true;
|
|
430
|
+
if (save) {
|
|
431
|
+
const problem = check(id, column!.key, editor.value);
|
|
432
|
+
if (problem) {
|
|
433
|
+
errors.set(cellKey(id, column!.key), problem);
|
|
434
|
+
barError.textContent = problem;
|
|
435
|
+
paintBar(true);
|
|
436
|
+
editor.setAttribute('aria-invalid', 'true');
|
|
437
|
+
keep(shake(editor));
|
|
438
|
+
status.textContent = problem;
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Done before the table redraws (the editor goes with the old cell).
|
|
443
|
+
editing = null;
|
|
444
|
+
if (save) setValue(id, column!.key, editor.value);
|
|
445
|
+
else {
|
|
446
|
+
cell.replaceChildren(...previous);
|
|
447
|
+
cell.removeAttribute('data-mk-et-editing');
|
|
448
|
+
errors.delete(cellKey(id, column!.key));
|
|
449
|
+
barError.textContent = [...errors.values()][0] ?? '';
|
|
450
|
+
paintBar(true);
|
|
451
|
+
}
|
|
452
|
+
// After a save the table drew the row again: find the cell anew, then move.
|
|
453
|
+
restoreFocus = false;
|
|
454
|
+
const fresh = cellOf(id, column!.key);
|
|
455
|
+
if (fresh) focusCell(fresh, false);
|
|
456
|
+
if (move) moveFrom(fresh ?? cell, move);
|
|
457
|
+
return true;
|
|
458
|
+
};
|
|
459
|
+
editing = { id, key: column!.key, editor, cell, done };
|
|
460
|
+
editor.addEventListener('keydown', (event) => {
|
|
461
|
+
const key = event as KeyboardEvent;
|
|
462
|
+
key.stopPropagation();
|
|
463
|
+
if (key.key === 'Enter') {
|
|
464
|
+
key.preventDefault();
|
|
465
|
+
done(true, 'down');
|
|
466
|
+
} else if (key.key === 'Escape') {
|
|
467
|
+
key.preventDefault();
|
|
468
|
+
done(false);
|
|
469
|
+
} else if (key.key === 'Tab') {
|
|
470
|
+
key.preventDefault();
|
|
471
|
+
done(true, key.shiftKey ? 'left' : 'right');
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
editor.addEventListener('input', () => editor.removeAttribute('aria-invalid'));
|
|
475
|
+
// Leaving the editor keeps a good value and drops a wrong one.
|
|
476
|
+
editor.addEventListener('blur', () => {
|
|
477
|
+
if (!done(true)) done(false);
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
const finishEdit = (save: boolean) => editing?.done(save);
|
|
481
|
+
|
|
482
|
+
const moveFrom = (cell: HTMLTableCellElement, move: 'down' | 'right' | 'left' | 'up') => {
|
|
483
|
+
const { row, column, rows, cells } = positionOf(cell);
|
|
484
|
+
if (move === 'right' || move === 'left') {
|
|
485
|
+
const step = move === 'right' ? 1 : -1;
|
|
486
|
+
const next = cells[column + step];
|
|
487
|
+
if (next) return focusCell(next);
|
|
488
|
+
// Past the end of a row: the next row's first cell (or the previous row's last).
|
|
489
|
+
const otherRow = rows[row + step];
|
|
490
|
+
const otherCells = otherRow ? [...otherRow.cells].filter((each) => each.dataset.key !== undefined) : [];
|
|
491
|
+
return focusCell(move === 'right' ? otherCells[0] : otherCells[otherCells.length - 1]);
|
|
492
|
+
}
|
|
493
|
+
const otherRow = rows[row + (move === 'down' ? 1 : -1)];
|
|
494
|
+
if (otherRow) focusCell([...otherRow.cells].filter((each) => each.dataset.key !== undefined)[column]);
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// ── The bar ──────────────────────────────────────────────────────────────
|
|
498
|
+
let barShown = false;
|
|
499
|
+
const paintBar = (animate: boolean) => {
|
|
500
|
+
const count = dirtyCount();
|
|
501
|
+
const show = count > 0 || errors.size > 0;
|
|
502
|
+
const text = String(count);
|
|
503
|
+
if (barCount.textContent !== text) {
|
|
504
|
+
if (barShown && animate && !reduced() && barCount.textContent) keep(rollDigits(barCount, text, count > Number(barCount.textContent) ? 1 : -1, 0.3));
|
|
505
|
+
else barCount.textContent = text;
|
|
506
|
+
}
|
|
507
|
+
barLabel.textContent = count === 1 ? ' cambio sin guardar' : ' cambios sin guardar';
|
|
508
|
+
saveButton.disabled = errors.size > 0 || !count || saving;
|
|
509
|
+
discardButton.disabled = saving;
|
|
510
|
+
barError.hidden = !barError.textContent;
|
|
511
|
+
if (show === barShown) return;
|
|
512
|
+
barShown = show;
|
|
513
|
+
gsap.killTweensOf(bar);
|
|
514
|
+
if (show) {
|
|
515
|
+
bar.hidden = false;
|
|
516
|
+
if (animate && !reduced()) keep(gsap.fromTo(bar, { height: 0, opacity: 0, filter: 'blur(6px)', overflow: 'hidden' }, { height: 'auto', opacity: 1, filter: 'blur(0px)', duration: 0.3, ease: EASE.signature, clearProps: 'height,opacity,filter,overflow' }));
|
|
517
|
+
} else if (animate && !reduced()) {
|
|
518
|
+
keep(gsap.to(bar, { height: 0, opacity: 0, overflow: 'hidden', duration: 0.24, ease: 'power2.in', onComplete: () => {
|
|
519
|
+
bar.hidden = true;
|
|
520
|
+
gsap.set(bar, { clearProps: 'height,opacity,overflow' });
|
|
521
|
+
} }));
|
|
522
|
+
} else bar.hidden = true;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const save = async (): Promise<boolean> => {
|
|
526
|
+
finishEdit(true);
|
|
527
|
+
if (saving || errors.size || !dirtyCount()) return false;
|
|
528
|
+
saving = true;
|
|
529
|
+
const list = changes();
|
|
530
|
+
const rows = display();
|
|
531
|
+
saveButton.setAttribute('aria-busy', 'true');
|
|
532
|
+
saveButton.textContent = 'Guardando…';
|
|
533
|
+
paintBar(true);
|
|
534
|
+
keep(press(saveButton));
|
|
535
|
+
try {
|
|
536
|
+
await options.onSave?.(list, rows);
|
|
537
|
+
} catch {
|
|
538
|
+
if (scope.disposed) return false;
|
|
539
|
+
saving = false;
|
|
540
|
+
saveButton.removeAttribute('aria-busy');
|
|
541
|
+
saveButton.textContent = 'Guardar';
|
|
542
|
+
barError.textContent = 'No se pudo guardar: los cambios siguen aquí';
|
|
543
|
+
paintBar(true);
|
|
544
|
+
keep(shake(bar));
|
|
545
|
+
status.textContent = barError.textContent;
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
if (scope.disposed) return true;
|
|
549
|
+
// What was saved is the new starting point.
|
|
550
|
+
const flashed = dataCells().filter((cell) => cell.hasAttribute('data-mk-et-dirty'));
|
|
551
|
+
base = rows.map((row) => ({ ...row }));
|
|
552
|
+
added = [];
|
|
553
|
+
edits.clear();
|
|
554
|
+
removed.clear();
|
|
555
|
+
saving = false;
|
|
556
|
+
saveButton.removeAttribute('aria-busy');
|
|
557
|
+
saveButton.textContent = 'Guardar';
|
|
558
|
+
barError.textContent = '';
|
|
559
|
+
status.textContent = plural(list.length, 'fila guardada', 'filas guardadas');
|
|
560
|
+
const ids = flashed.map((cell) => `${(cell.parentElement as HTMLElement).dataset.id}\u0000${cell.dataset.key}`);
|
|
561
|
+
table.setRows(display());
|
|
562
|
+
if (!reduced()) {
|
|
563
|
+
for (const cell of dataCells()) {
|
|
564
|
+
if (ids.includes(`${(cell.parentElement as HTMLElement).dataset.id}\u0000${cell.dataset.key}`)) cell.setAttribute('data-mk-et-saved', '');
|
|
565
|
+
}
|
|
566
|
+
scope.timeout(() => dataCells().forEach((cell) => cell.removeAttribute('data-mk-et-saved')), 1400);
|
|
567
|
+
}
|
|
568
|
+
paintBar(true);
|
|
569
|
+
report();
|
|
570
|
+
root.dispatchEvent(new CustomEvent('mk:save', { bubbles: true, detail: { changes: list } }));
|
|
571
|
+
return true;
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const discard = () => {
|
|
575
|
+
finishEdit(false);
|
|
576
|
+
if (!dirtyCount() && !errors.size) return;
|
|
577
|
+
const touched = dataCells().filter((cell) => cell.hasAttribute('data-mk-et-dirty')).map((cell) => `${(cell.parentElement as HTMLElement).dataset.id}\u0000${cell.dataset.key}`);
|
|
578
|
+
edits.clear();
|
|
579
|
+
added = [];
|
|
580
|
+
removed.clear();
|
|
581
|
+
errors.clear();
|
|
582
|
+
barError.textContent = '';
|
|
583
|
+
table.setRows(display());
|
|
584
|
+
if (!reduced()) for (const cell of dataCells()) if (touched.includes(`${(cell.parentElement as HTMLElement).dataset.id}\u0000${cell.dataset.key}`)) fadeCell(cell);
|
|
585
|
+
status.textContent = 'Cambios descartados';
|
|
586
|
+
paintBar(true);
|
|
587
|
+
report();
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const addRow = (values?: Partial<T>): RowId => {
|
|
591
|
+
finishEdit(true);
|
|
592
|
+
const fresh = { ...(options.newRow?.() ?? {}), ...(values ?? {}) } as T;
|
|
593
|
+
const key = typeof rowKey === 'string' ? rowKey : 'id';
|
|
594
|
+
let id = idOf(fresh);
|
|
595
|
+
if (id === undefined || id === null || id === '') {
|
|
596
|
+
id = `${uid}-new-${++made}`;
|
|
597
|
+
(fresh as Record<string, unknown>)[key] = id;
|
|
598
|
+
}
|
|
599
|
+
added = [fresh, ...added];
|
|
600
|
+
table.sortBy(null);
|
|
601
|
+
table.setQuery('');
|
|
602
|
+
table.setPage(0);
|
|
603
|
+
table.setRows(display());
|
|
604
|
+
const tr = table.rowOf(id);
|
|
605
|
+
if (tr && !reduced()) keep(gsap.fromTo(tr, { opacity: 0, y: -12 }, { opacity: 1, y: 0, duration: 0.35, ease: 'power2.out', clearProps: 'opacity,transform' }));
|
|
606
|
+
paintBar(true);
|
|
607
|
+
report();
|
|
608
|
+
status.textContent = 'Fila nueva';
|
|
609
|
+
const first = columns.find(isEditable);
|
|
610
|
+
const cell = first ? cellOf(id, first.key) : null;
|
|
611
|
+
if (cell) {
|
|
612
|
+
focusCell(cell, false);
|
|
613
|
+
startEdit(cell);
|
|
614
|
+
}
|
|
615
|
+
return id;
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// ── Pointer and keyboard ─────────────────────────────────────────────────
|
|
619
|
+
scope.on(table.body, 'click', (event) => {
|
|
620
|
+
const cell = (event.target as Element | null)?.closest<HTMLTableCellElement>('td[data-key]');
|
|
621
|
+
if (!cell || cell.hasAttribute('data-mk-et-editing')) return;
|
|
622
|
+
focusCell(cell);
|
|
623
|
+
});
|
|
624
|
+
scope.on(table.body, 'dblclick', (event) => {
|
|
625
|
+
const cell = (event.target as Element | null)?.closest<HTMLTableCellElement>('td[data-key]');
|
|
626
|
+
if (cell && !cell.hasAttribute('data-mk-et-editing')) startEdit(cell);
|
|
627
|
+
});
|
|
628
|
+
scope.on(table.body, 'focusin', (event) => {
|
|
629
|
+
const cell = (event.target as Element | null)?.closest<HTMLTableCellElement>('td[data-key]');
|
|
630
|
+
if (!cell) return;
|
|
631
|
+
const id = idFromCell(cell);
|
|
632
|
+
if (id !== null) active = { id, key: cell.dataset.key! };
|
|
633
|
+
placeRing(true);
|
|
634
|
+
});
|
|
635
|
+
scope.on(table.body, 'focusout', () => {
|
|
636
|
+
scope.timeout(() => {
|
|
637
|
+
if (!table.body.contains(document.activeElement)) ring.style.opacity = '0';
|
|
638
|
+
}, 0);
|
|
639
|
+
});
|
|
640
|
+
scope.on(table.body, 'keydown', (event) => {
|
|
641
|
+
const key = event as KeyboardEvent;
|
|
642
|
+
const cell = (key.target as Element | null)?.closest<HTMLTableCellElement>('td[data-key]');
|
|
643
|
+
if (!cell || cell !== key.target) return;
|
|
644
|
+
const command = key.ctrlKey || key.metaKey;
|
|
645
|
+
const { rows, cells } = positionOf(cell);
|
|
646
|
+
switch (key.key) {
|
|
647
|
+
case 'ArrowRight':
|
|
648
|
+
key.preventDefault();
|
|
649
|
+
return focusCell(cells[cells.indexOf(cell) + 1]);
|
|
650
|
+
case 'ArrowLeft':
|
|
651
|
+
key.preventDefault();
|
|
652
|
+
return focusCell(cells[cells.indexOf(cell) - 1]);
|
|
653
|
+
case 'ArrowDown':
|
|
654
|
+
key.preventDefault();
|
|
655
|
+
return moveFrom(cell, 'down');
|
|
656
|
+
case 'ArrowUp':
|
|
657
|
+
key.preventDefault();
|
|
658
|
+
return moveFrom(cell, 'up');
|
|
659
|
+
case 'Home': {
|
|
660
|
+
key.preventDefault();
|
|
661
|
+
const row = command ? rows[0] : (cell.parentElement as HTMLTableRowElement);
|
|
662
|
+
return focusCell(row ? [...row.cells].find((each) => each.dataset.key !== undefined) : undefined);
|
|
663
|
+
}
|
|
664
|
+
case 'End': {
|
|
665
|
+
key.preventDefault();
|
|
666
|
+
const row = command ? rows[rows.length - 1] : (cell.parentElement as HTMLTableRowElement);
|
|
667
|
+
const all = row ? [...row.cells].filter((each) => each.dataset.key !== undefined) : [];
|
|
668
|
+
return focusCell(all[all.length - 1]);
|
|
669
|
+
}
|
|
670
|
+
case 'Enter':
|
|
671
|
+
case 'F2':
|
|
672
|
+
key.preventDefault();
|
|
673
|
+
return startEdit(cell);
|
|
674
|
+
case 'Delete':
|
|
675
|
+
case 'Backspace': {
|
|
676
|
+
key.preventDefault();
|
|
677
|
+
const column = columnOf(cell.dataset.key!);
|
|
678
|
+
const id = idFromCell(cell);
|
|
679
|
+
if (id === null || !isEditable(column)) return;
|
|
680
|
+
if (kindOf(column!) === 'checkbox') return;
|
|
681
|
+
if (!setValue(id, column!.key, '')) {
|
|
682
|
+
keep(shake(cell, 3));
|
|
683
|
+
status.textContent = barError.textContent ?? '';
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
case ' ': {
|
|
688
|
+
const column = columnOf(cell.dataset.key!);
|
|
689
|
+
if (column && kindOf(column) === 'checkbox') {
|
|
690
|
+
key.preventDefault();
|
|
691
|
+
startEdit(cell);
|
|
692
|
+
}
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Typing starts the editor with what was typed.
|
|
697
|
+
if (!command && !key.altKey && key.key.length === 1) {
|
|
698
|
+
const column = columnOf(cell.dataset.key!);
|
|
699
|
+
if (column && isEditable(column) && ['text', 'number'].includes(kindOf(column))) {
|
|
700
|
+
key.preventDefault();
|
|
701
|
+
startEdit(cell, key.key);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
scope.on(root, 'keydown', (event) => {
|
|
706
|
+
const key = event as KeyboardEvent;
|
|
707
|
+
if ((key.ctrlKey || key.metaKey) && key.key.toLowerCase() === 's') {
|
|
708
|
+
key.preventDefault();
|
|
709
|
+
void save();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
scope.on(saveButton, 'click', () => void save());
|
|
713
|
+
scope.on(discardButton, 'click', discard);
|
|
714
|
+
if (addButton) {
|
|
715
|
+
const button = addButton;
|
|
716
|
+
scope.on(button, 'click', () => {
|
|
717
|
+
keep(press(button));
|
|
718
|
+
addRow();
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
ready = true;
|
|
723
|
+
decorate(table.body);
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
edit(id, key) {
|
|
727
|
+
const cell = cellOf(id, key);
|
|
728
|
+
if (!cell) return;
|
|
729
|
+
focusCell(cell, false);
|
|
730
|
+
startEdit(cell);
|
|
731
|
+
},
|
|
732
|
+
setValue: (id, key, value) => setValue(id, key, value),
|
|
733
|
+
addRow,
|
|
734
|
+
removeRows(ids) {
|
|
735
|
+
finishEdit(false);
|
|
736
|
+
for (const id of ids) {
|
|
737
|
+
if (isNew(id)) added = added.filter((row) => idOf(row) !== id);
|
|
738
|
+
else {
|
|
739
|
+
const row = originalOf(id);
|
|
740
|
+
if (row) removed.set(id, row);
|
|
741
|
+
}
|
|
742
|
+
edits.delete(id);
|
|
743
|
+
for (const key of [...errors.keys()]) if (key.startsWith(`${id}\u0000`)) errors.delete(key);
|
|
744
|
+
}
|
|
745
|
+
table.removeRows(ids);
|
|
746
|
+
paintBar(true);
|
|
747
|
+
report();
|
|
748
|
+
},
|
|
749
|
+
save,
|
|
750
|
+
discard,
|
|
751
|
+
setRows(rows) {
|
|
752
|
+
if (scope.disposed) return;
|
|
753
|
+
finishEdit(false);
|
|
754
|
+
base = [...rows];
|
|
755
|
+
edits.clear();
|
|
756
|
+
added = [];
|
|
757
|
+
removed.clear();
|
|
758
|
+
errors.clear();
|
|
759
|
+
barError.textContent = '';
|
|
760
|
+
table.setRows(display());
|
|
761
|
+
paintBar(false);
|
|
762
|
+
},
|
|
763
|
+
get changes() {
|
|
764
|
+
return changes();
|
|
765
|
+
},
|
|
766
|
+
get dirty() {
|
|
767
|
+
return dirtyCount() > 0;
|
|
768
|
+
},
|
|
769
|
+
get rows() {
|
|
770
|
+
return display();
|
|
771
|
+
},
|
|
772
|
+
table,
|
|
773
|
+
destroy: () => scope.dispose(),
|
|
774
|
+
};
|
|
775
|
+
}
|