@sepale/components 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.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -3
  3. package/dist/components/Accordion.svelte +48 -0
  4. package/dist/components/Accordion.svelte.d.ts +24 -0
  5. package/dist/components/Alert.svelte +107 -0
  6. package/dist/components/Alert.svelte.d.ts +40 -0
  7. package/dist/components/Avatar.svelte +75 -0
  8. package/dist/components/Avatar.svelte.d.ts +37 -0
  9. package/dist/components/Badge.svelte +46 -0
  10. package/dist/components/Badge.svelte.d.ts +22 -0
  11. package/dist/components/Breadcrumbs.svelte +36 -0
  12. package/dist/components/Breadcrumbs.svelte.d.ts +18 -0
  13. package/dist/components/Bubble.svelte +43 -0
  14. package/dist/components/Bubble.svelte.d.ts +23 -0
  15. package/dist/components/Button.svelte +132 -0
  16. package/dist/components/Button.svelte.d.ts +40 -0
  17. package/dist/components/Calendar.svelte +193 -0
  18. package/dist/components/Calendar.svelte.d.ts +60 -0
  19. package/dist/components/Card.svelte +69 -0
  20. package/dist/components/Card.svelte.d.ts +37 -0
  21. package/dist/components/Carousel.svelte +550 -0
  22. package/dist/components/Carousel.svelte.d.ts +124 -0
  23. package/dist/components/Checkbox.svelte +48 -0
  24. package/dist/components/Checkbox.svelte.d.ts +26 -0
  25. package/dist/components/Chip.svelte +60 -0
  26. package/dist/components/Chip.svelte.d.ts +26 -0
  27. package/dist/components/ChipsInput.svelte +158 -0
  28. package/dist/components/ChipsInput.svelte.d.ts +64 -0
  29. package/dist/components/Collapse.svelte +101 -0
  30. package/dist/components/Collapse.svelte.d.ts +46 -0
  31. package/dist/components/ColorPicker.svelte +135 -0
  32. package/dist/components/ColorPicker.svelte.d.ts +44 -0
  33. package/dist/components/Combobox.svelte +496 -0
  34. package/dist/components/Combobox.svelte.d.ts +96 -0
  35. package/dist/components/DataControl.svelte +754 -0
  36. package/dist/components/DataControl.svelte.d.ts +143 -0
  37. package/dist/components/DataHandler.svelte +67 -0
  38. package/dist/components/DataHandler.svelte.d.ts +44 -0
  39. package/dist/components/DatePicker.svelte +185 -0
  40. package/dist/components/DatePicker.svelte.d.ts +56 -0
  41. package/dist/components/Dialog.svelte +194 -0
  42. package/dist/components/Dialog.svelte.d.ts +51 -0
  43. package/dist/components/Divider.svelte +54 -0
  44. package/dist/components/Divider.svelte.d.ts +27 -0
  45. package/dist/components/Dock.svelte +34 -0
  46. package/dist/components/Dock.svelte.d.ts +18 -0
  47. package/dist/components/Drawer.svelte +174 -0
  48. package/dist/components/Drawer.svelte.d.ts +51 -0
  49. package/dist/components/Dropdown.svelte +183 -0
  50. package/dist/components/Dropdown.svelte.d.ts +52 -0
  51. package/dist/components/Editable.svelte +292 -0
  52. package/dist/components/Editable.svelte.d.ts +113 -0
  53. package/dist/components/EmptyState.svelte +64 -0
  54. package/dist/components/EmptyState.svelte.d.ts +29 -0
  55. package/dist/components/Fieldset.svelte +42 -0
  56. package/dist/components/Fieldset.svelte.d.ts +20 -0
  57. package/dist/components/FileDropzone.svelte +154 -0
  58. package/dist/components/FileDropzone.svelte.d.ts +45 -0
  59. package/dist/components/FileExplorer.svelte +643 -0
  60. package/dist/components/FileExplorer.svelte.d.ts +114 -0
  61. package/dist/components/FileInput.svelte +125 -0
  62. package/dist/components/FileInput.svelte.d.ts +51 -0
  63. package/dist/components/FloatingLabel.svelte +38 -0
  64. package/dist/components/FloatingLabel.svelte.d.ts +24 -0
  65. package/dist/components/Footer.svelte +68 -0
  66. package/dist/components/Footer.svelte.d.ts +37 -0
  67. package/dist/components/Grid.svelte +122 -0
  68. package/dist/components/Grid.svelte.d.ts +72 -0
  69. package/dist/components/Hero.svelte +62 -0
  70. package/dist/components/Hero.svelte.d.ts +33 -0
  71. package/dist/components/Input.svelte +184 -0
  72. package/dist/components/Input.svelte.d.ts +65 -0
  73. package/dist/components/Join.svelte +38 -0
  74. package/dist/components/Join.svelte.d.ts +21 -0
  75. package/dist/components/Kanban.svelte +284 -0
  76. package/dist/components/Kanban.svelte.d.ts +93 -0
  77. package/dist/components/Label.svelte +32 -0
  78. package/dist/components/Label.svelte.d.ts +21 -0
  79. package/dist/components/LaptopFrame.svelte +44 -0
  80. package/dist/components/LaptopFrame.svelte.d.ts +21 -0
  81. package/dist/components/List.svelte +95 -0
  82. package/dist/components/List.svelte.d.ts +69 -0
  83. package/dist/components/Loading.svelte +37 -0
  84. package/dist/components/Loading.svelte.d.ts +17 -0
  85. package/dist/components/Menu.svelte +144 -0
  86. package/dist/components/Menu.svelte.d.ts +53 -0
  87. package/dist/components/MobileFrame.svelte +41 -0
  88. package/dist/components/MobileFrame.svelte.d.ts +21 -0
  89. package/dist/components/Navbar.svelte +49 -0
  90. package/dist/components/Navbar.svelte.d.ts +25 -0
  91. package/dist/components/NumberInput.svelte +255 -0
  92. package/dist/components/NumberInput.svelte.d.ts +68 -0
  93. package/dist/components/Pagination.svelte +169 -0
  94. package/dist/components/Pagination.svelte.d.ts +50 -0
  95. package/dist/components/Progress.svelte +65 -0
  96. package/dist/components/Progress.svelte.d.ts +28 -0
  97. package/dist/components/RadialProgress.svelte +48 -0
  98. package/dist/components/RadialProgress.svelte.d.ts +20 -0
  99. package/dist/components/Radio.svelte +50 -0
  100. package/dist/components/Radio.svelte.d.ts +28 -0
  101. package/dist/components/Range.svelte +53 -0
  102. package/dist/components/Range.svelte.d.ts +26 -0
  103. package/dist/components/Rating.svelte +90 -0
  104. package/dist/components/Rating.svelte.d.ts +41 -0
  105. package/dist/components/Select.svelte +338 -0
  106. package/dist/components/Select.svelte.d.ts +74 -0
  107. package/dist/components/Sidebar.svelte +100 -0
  108. package/dist/components/Sidebar.svelte.d.ts +54 -0
  109. package/dist/components/Skeleton.svelte +40 -0
  110. package/dist/components/Skeleton.svelte.d.ts +18 -0
  111. package/dist/components/Stat.svelte +71 -0
  112. package/dist/components/Stat.svelte.d.ts +35 -0
  113. package/dist/components/Stats.svelte +38 -0
  114. package/dist/components/Stats.svelte.d.ts +22 -0
  115. package/dist/components/Steps.svelte +204 -0
  116. package/dist/components/Steps.svelte.d.ts +66 -0
  117. package/dist/components/Subtitle.svelte +44 -0
  118. package/dist/components/Subtitle.svelte.d.ts +26 -0
  119. package/dist/components/Swatch.svelte +61 -0
  120. package/dist/components/Swatch.svelte.d.ts +27 -0
  121. package/dist/components/Switch.svelte +105 -0
  122. package/dist/components/Switch.svelte.d.ts +44 -0
  123. package/dist/components/Table.svelte +243 -0
  124. package/dist/components/Table.svelte.d.ts +121 -0
  125. package/dist/components/Tabs.svelte +142 -0
  126. package/dist/components/Tabs.svelte.d.ts +80 -0
  127. package/dist/components/Text.svelte +43 -0
  128. package/dist/components/Text.svelte.d.ts +25 -0
  129. package/dist/components/Textarea.svelte +76 -0
  130. package/dist/components/Textarea.svelte.d.ts +39 -0
  131. package/dist/components/Title.svelte +46 -0
  132. package/dist/components/Title.svelte.d.ts +29 -0
  133. package/dist/components/Toast.svelte +154 -0
  134. package/dist/components/Toast.svelte.d.ts +49 -0
  135. package/dist/components/Toggle.svelte +48 -0
  136. package/dist/components/Toggle.svelte.d.ts +25 -0
  137. package/dist/components/Tooltip.svelte +70 -0
  138. package/dist/components/Tooltip.svelte.d.ts +34 -0
  139. package/dist/components/WindowFrame.svelte +53 -0
  140. package/dist/components/WindowFrame.svelte.d.ts +24 -0
  141. package/dist/components/accordion-context.d.ts +8 -0
  142. package/dist/components/accordion-context.js +25 -0
  143. package/dist/components/color.d.ts +39 -0
  144. package/dist/components/color.js +92 -0
  145. package/dist/components/data-handler.svelte.d.ts +362 -0
  146. package/dist/components/data-handler.svelte.js +690 -0
  147. package/dist/components/file-icons.d.ts +25 -0
  148. package/dist/components/file-icons.js +46 -0
  149. package/dist/family-types.d.ts +45 -0
  150. package/dist/family-types.js +73 -0
  151. package/dist/generated/classes.d.ts +17 -0
  152. package/dist/generated/classes.js +2511 -0
  153. package/dist/generated/components.d.ts +39 -0
  154. package/dist/generated/components.js +28666 -0
  155. package/dist/generated/registry.d.ts +35 -0
  156. package/dist/generated/registry.js +2658 -0
  157. package/dist/index.d.ts +73 -0
  158. package/dist/index.js +99 -0
  159. package/dist/sepale-types.d.ts +8 -0
  160. package/dist/sepale-types.js +200 -0
  161. package/dist/theme.d.ts +123 -0
  162. package/dist/theme.js +226 -0
  163. package/dist/variants.d.ts +124 -0
  164. package/dist/variants.js +70 -0
  165. package/dist/vendor/air-datepicker/LICENSE.md +21 -0
  166. package/dist/vendor/air-datepicker/README.md +19 -0
  167. package/dist/vendor/air-datepicker/buttonPresets.d.ts +13 -0
  168. package/dist/vendor/air-datepicker/buttonPresets.js +12 -0
  169. package/dist/vendor/air-datepicker/consts.d.ts +18 -0
  170. package/dist/vendor/air-datepicker/consts.js +35 -0
  171. package/dist/vendor/air-datepicker/datepicker.d.ts +289 -0
  172. package/dist/vendor/air-datepicker/datepicker.js +1435 -0
  173. package/dist/vendor/air-datepicker/datepickerBody.d.ts +46 -0
  174. package/dist/vendor/air-datepicker/datepickerBody.js +386 -0
  175. package/dist/vendor/air-datepicker/datepickerButtons.d.ts +25 -0
  176. package/dist/vendor/air-datepicker/datepickerButtons.js +85 -0
  177. package/dist/vendor/air-datepicker/datepickerCell.d.ts +35 -0
  178. package/dist/vendor/air-datepicker/datepickerCell.js +297 -0
  179. package/dist/vendor/air-datepicker/datepickerKeyboard.d.ts +26 -0
  180. package/dist/vendor/air-datepicker/datepickerKeyboard.js +207 -0
  181. package/dist/vendor/air-datepicker/datepickerNav.d.ts +31 -0
  182. package/dist/vendor/air-datepicker/datepickerNav.js +200 -0
  183. package/dist/vendor/air-datepicker/datepickerTime.d.ts +60 -0
  184. package/dist/vendor/air-datepicker/datepickerTime.js +276 -0
  185. package/dist/vendor/air-datepicker/defaults.d.ts +65 -0
  186. package/dist/vendor/air-datepicker/defaults.js +79 -0
  187. package/dist/vendor/air-datepicker/index.d.ts +168 -0
  188. package/dist/vendor/air-datepicker/index.js +3 -0
  189. package/dist/vendor/air-datepicker/locale/ar.d.ts +4 -0
  190. package/dist/vendor/air-datepicker/locale/ar.js +14 -0
  191. package/dist/vendor/air-datepicker/locale/bg.d.ts +4 -0
  192. package/dist/vendor/air-datepicker/locale/bg.js +14 -0
  193. package/dist/vendor/air-datepicker/locale/ca.d.ts +4 -0
  194. package/dist/vendor/air-datepicker/locale/ca.js +14 -0
  195. package/dist/vendor/air-datepicker/locale/cs.d.ts +4 -0
  196. package/dist/vendor/air-datepicker/locale/cs.js +14 -0
  197. package/dist/vendor/air-datepicker/locale/da.d.ts +4 -0
  198. package/dist/vendor/air-datepicker/locale/da.js +14 -0
  199. package/dist/vendor/air-datepicker/locale/de.d.ts +4 -0
  200. package/dist/vendor/air-datepicker/locale/de.js +14 -0
  201. package/dist/vendor/air-datepicker/locale/el.d.ts +4 -0
  202. package/dist/vendor/air-datepicker/locale/el.js +14 -0
  203. package/dist/vendor/air-datepicker/locale/en.d.ts +4 -0
  204. package/dist/vendor/air-datepicker/locale/en.js +14 -0
  205. package/dist/vendor/air-datepicker/locale/es.d.ts +4 -0
  206. package/dist/vendor/air-datepicker/locale/es.js +14 -0
  207. package/dist/vendor/air-datepicker/locale/eu.d.ts +4 -0
  208. package/dist/vendor/air-datepicker/locale/eu.js +14 -0
  209. package/dist/vendor/air-datepicker/locale/fi.d.ts +4 -0
  210. package/dist/vendor/air-datepicker/locale/fi.js +14 -0
  211. package/dist/vendor/air-datepicker/locale/fr.d.ts +4 -0
  212. package/dist/vendor/air-datepicker/locale/fr.js +14 -0
  213. package/dist/vendor/air-datepicker/locale/hr.d.ts +4 -0
  214. package/dist/vendor/air-datepicker/locale/hr.js +14 -0
  215. package/dist/vendor/air-datepicker/locale/hu.d.ts +4 -0
  216. package/dist/vendor/air-datepicker/locale/hu.js +14 -0
  217. package/dist/vendor/air-datepicker/locale/id.d.ts +4 -0
  218. package/dist/vendor/air-datepicker/locale/id.js +14 -0
  219. package/dist/vendor/air-datepicker/locale/it.d.ts +4 -0
  220. package/dist/vendor/air-datepicker/locale/it.js +14 -0
  221. package/dist/vendor/air-datepicker/locale/ja.d.ts +4 -0
  222. package/dist/vendor/air-datepicker/locale/ja.js +14 -0
  223. package/dist/vendor/air-datepicker/locale/ko.d.ts +4 -0
  224. package/dist/vendor/air-datepicker/locale/ko.js +14 -0
  225. package/dist/vendor/air-datepicker/locale/nb.d.ts +4 -0
  226. package/dist/vendor/air-datepicker/locale/nb.js +14 -0
  227. package/dist/vendor/air-datepicker/locale/nl.d.ts +4 -0
  228. package/dist/vendor/air-datepicker/locale/nl.js +14 -0
  229. package/dist/vendor/air-datepicker/locale/pl.d.ts +4 -0
  230. package/dist/vendor/air-datepicker/locale/pl.js +14 -0
  231. package/dist/vendor/air-datepicker/locale/pt-BR.d.ts +4 -0
  232. package/dist/vendor/air-datepicker/locale/pt-BR.js +14 -0
  233. package/dist/vendor/air-datepicker/locale/pt.d.ts +4 -0
  234. package/dist/vendor/air-datepicker/locale/pt.js +14 -0
  235. package/dist/vendor/air-datepicker/locale/ro.d.ts +4 -0
  236. package/dist/vendor/air-datepicker/locale/ro.js +14 -0
  237. package/dist/vendor/air-datepicker/locale/ru.d.ts +4 -0
  238. package/dist/vendor/air-datepicker/locale/ru.js +14 -0
  239. package/dist/vendor/air-datepicker/locale/si.d.ts +4 -0
  240. package/dist/vendor/air-datepicker/locale/si.js +14 -0
  241. package/dist/vendor/air-datepicker/locale/sk.d.ts +4 -0
  242. package/dist/vendor/air-datepicker/locale/sk.js +14 -0
  243. package/dist/vendor/air-datepicker/locale/sl.d.ts +4 -0
  244. package/dist/vendor/air-datepicker/locale/sl.js +14 -0
  245. package/dist/vendor/air-datepicker/locale/sv.d.ts +4 -0
  246. package/dist/vendor/air-datepicker/locale/sv.js +14 -0
  247. package/dist/vendor/air-datepicker/locale/th.d.ts +4 -0
  248. package/dist/vendor/air-datepicker/locale/th.js +14 -0
  249. package/dist/vendor/air-datepicker/locale/tr.d.ts +4 -0
  250. package/dist/vendor/air-datepicker/locale/tr.js +16 -0
  251. package/dist/vendor/air-datepicker/locale/uk.d.ts +4 -0
  252. package/dist/vendor/air-datepicker/locale/uk.js +14 -0
  253. package/dist/vendor/air-datepicker/locale/zh.d.ts +4 -0
  254. package/dist/vendor/air-datepicker/locale/zh.js +14 -0
  255. package/dist/vendor/air-datepicker/typedefs.d.ts +3 -0
  256. package/dist/vendor/air-datepicker/typedefs.js +15 -0
  257. package/dist/vendor/air-datepicker/utils.d.ts +155 -0
  258. package/dist/vendor/air-datepicker/utils.js +395 -0
  259. package/dist/vendor/air-datepicker/withEvents.d.ts +1 -0
  260. package/dist/vendor/air-datepicker/withEvents.js +39 -0
  261. package/package.json +44 -3
@@ -0,0 +1,690 @@
1
+ /**
2
+ * The data handler: the live state of one remote resource — the rows of the
3
+ * current page, the total, the search, the sort, the filter values, the
4
+ * selection, the pending edits and their history — and the requests that read
5
+ * and change it.
6
+ *
7
+ * It draws nothing. `DataHandler.svelte` hands an instance to its children;
8
+ * the same class can be used directly, `new DataHandlerState(…)`. Tables,
9
+ * grids and lists know nothing of it: they are given its rows as if they
10
+ * were static.
11
+ *
12
+ * Where the data comes from, in order:
13
+ * 1. the functions passed as `get` / `update` / `delete` — plain async
14
+ * functions, or SvelteKit remote functions, which are callable the same
15
+ * way;
16
+ * 2. otherwise `path`, a REST resource:
17
+ * GET path?filters…&search=&sort=id-asc&page=&limit= → { data, total }
18
+ * PATCH path/:id body: the changed fields
19
+ * DELETE path body: [ids]
20
+ * 3. otherwise the action is not available (`canUpdate`, `canDelete`).
21
+ */
22
+ const MESSAGES = {
23
+ loadFailed: 'The data could not be loaded.',
24
+ saveFailed: 'The changes could not be saved.',
25
+ deleteFailed: 'The deletion failed.',
26
+ discarded: (n) => `${n} unsaved change${n > 1 ? 's' : ''} discarded.`,
27
+ saved: (n) => `${n} row${n > 1 ? 's' : ''} saved.`,
28
+ deleted: (n) => `${n} row${n > 1 ? 's' : ''} deleted.`,
29
+ exportEmpty: 'There is nothing to export.'
30
+ };
31
+ /** The fields of `value` that differ from `init`, compared one by one. */
32
+ function diff(value, init) {
33
+ const out = {};
34
+ for (const k of Object.keys(value)) {
35
+ if (!Object.is(value[k], init[k]))
36
+ out[k] = value[k];
37
+ }
38
+ return out;
39
+ }
40
+ /* ── The address bar ─────────────────────────────────────────────────── */
41
+ /** Filter values out of a query string — to start a handler where the URL left it. */
42
+ export function parseFilterValues(params, filters) {
43
+ const out = {};
44
+ for (const f of filters) {
45
+ const raw = params.get(f.id);
46
+ if (!raw)
47
+ continue;
48
+ switch (f.type) {
49
+ case 'entity': {
50
+ const [id, ...name] = raw.split(',');
51
+ out[f.id] = { id, name: name.join(',') || undefined };
52
+ break;
53
+ }
54
+ case 'number':
55
+ if (!Number.isNaN(Number(raw)))
56
+ out[f.id] = Number(raw);
57
+ break;
58
+ case 'boolean':
59
+ out[f.id] = raw === 'true';
60
+ break;
61
+ case 'date':
62
+ if (!Number.isNaN(Date.parse(raw)))
63
+ out[f.id] = new Date(raw);
64
+ break;
65
+ case 'select':
66
+ out[f.id] = raw.split(',');
67
+ break;
68
+ case 'text':
69
+ out[f.id] = raw;
70
+ break;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /** Filter values into a query string. */
76
+ export function filterValuesToParams(values, filters) {
77
+ const params = new URLSearchParams();
78
+ for (const f of filters) {
79
+ const v = values[f.id];
80
+ if (v === undefined || v === null || v === '')
81
+ continue;
82
+ switch (f.type) {
83
+ case 'entity': {
84
+ const e = v;
85
+ params.set(f.id, [e.id, e.name].filter(Boolean).join(','));
86
+ break;
87
+ }
88
+ case 'date':
89
+ params.set(f.id, v.toISOString());
90
+ break;
91
+ case 'select':
92
+ if (v.length)
93
+ params.set(f.id, v.join(','));
94
+ break;
95
+ default:
96
+ params.set(f.id, String(v));
97
+ }
98
+ }
99
+ return params;
100
+ }
101
+ /** Everything a handler can start from that a URL can carry. */
102
+ export function parseQuery(params, filters, sorts = []) {
103
+ const sort = params.get('sort');
104
+ const page = Number(params.get('page'));
105
+ const limit = Number(params.get('limit'));
106
+ return {
107
+ filterValues: parseFilterValues(params, filters),
108
+ search: params.get('search') ?? undefined,
109
+ sort: sort ? sorts.find((s) => `${s.id}-${s.value}` === sort) : undefined,
110
+ page: Number.isInteger(page) && page > 0 ? page : undefined,
111
+ limit: Number.isInteger(limit) && limit > 0 ? limit : undefined
112
+ };
113
+ }
114
+ /** A pause longer than this in a field being typed in starts a new entry of the browser history. */
115
+ const TYPING_PAUSE = 2000;
116
+ const historyNavigation = {
117
+ push: (url, state) => history.pushState(state, '', url),
118
+ replace: (url, state) => history.replaceState(state, '', url)
119
+ };
120
+ /* ── The handler ─────────────────────────────────────────────────────── */
121
+ export class DataHandlerState {
122
+ /** A fetch is running. */
123
+ loading = $state(false);
124
+ /** The rows of the current page. */
125
+ rows = $state([]);
126
+ /** How many rows match, over every page. `undefined` until known. */
127
+ total = $state();
128
+ /** Every filter the handler knows, shown or not. */
129
+ filters = $state([]);
130
+ /** The value of each filter. */
131
+ filterValues = $state({});
132
+ search = $state('');
133
+ sort = $state();
134
+ /** From 1. */
135
+ page = $state(1);
136
+ limit = $state(10);
137
+ sorts;
138
+ /** The keys of the selected rows. */
139
+ selection = $derived(this.rows.filter((r) => r.selected).map((r) => r.id));
140
+ /** The rows changed and not yet saved. */
141
+ changed = $derived(this.rows.filter((r) => r.dirty).map((r) => r.id));
142
+ /** Something is changed and not yet saved. */
143
+ dirty = $derived(this.changed.length > 0);
144
+ /** The filters on the page. */
145
+ shownFilters = $derived(this.filters.filter((f) => f.shown && !f.immutable));
146
+ /** The filters that could be added to the page. */
147
+ availableFilters = $derived(this.filters.filter((f) => !f.shown && !f.immutable));
148
+ /** How many pages there are, once the total is known. */
149
+ pages = $derived(this.total === undefined ? undefined : Math.max(1, Math.ceil(this.total / this.limit)));
150
+ /** The edit history. */
151
+ #undo = $state([]);
152
+ #redo = $state([]);
153
+ canUndo = $derived(this.#undo.length > 0);
154
+ canRedo = $derived(this.#redo.length > 0);
155
+ identifier;
156
+ options;
157
+ messages;
158
+ #ticket = 0;
159
+ #timer;
160
+ /** The query of the last fetch that went through, to go back to on "stay". */
161
+ #committed;
162
+ /** The rows per page the handler started with: what an address without a limit means. */
163
+ #startLimit;
164
+ /** The field whose typing wrote the address bar last, and when. */
165
+ #typed;
166
+ constructor(options) {
167
+ this.options = options;
168
+ this.messages = { ...MESSAGES, ...options.messages };
169
+ const id = options.identifier;
170
+ this.identifier =
171
+ typeof id === 'object'
172
+ ? id
173
+ : { to: (row) => String(row[id]), from: (s) => s };
174
+ this.sorts = options.sorts ?? [];
175
+ this.filters = (options.filters ?? []).map((f) => ({
176
+ ...f,
177
+ immutable: !!options.immutableFilters?.includes(f.id),
178
+ shown: options.filterValues?.[f.id] !== undefined
179
+ }));
180
+ this.filterValues = { ...(options.filterValues ?? {}) };
181
+ this.search = options.search ?? '';
182
+ this.sort = options.sort ?? this.sorts[0];
183
+ const limit = options.limit ?? options.init?.data?.length;
184
+ this.limit = Number.isInteger(limit) && limit > 0 ? limit : 10;
185
+ this.#startLimit = this.limit;
186
+ this.page = Math.max(1, options.page ?? 1);
187
+ if (options.init) {
188
+ this.rows = (options.init.data ?? []).map((d, i) => this.#row(d, i));
189
+ this.total = options.init.total;
190
+ }
191
+ this.#committed = this.#query();
192
+ }
193
+ /* ── Capabilities ── */
194
+ get canUpdate() {
195
+ return !!this.options.update || !!this.options.path;
196
+ }
197
+ get canDelete() {
198
+ return !!this.options.delete || !!this.options.path;
199
+ }
200
+ /* ── Fetching ── */
201
+ /**
202
+ * Fetch the current page. Unsaved changes are put to `confirmDiscard`
203
+ * first; "stay" puts the query back where the last fetch left it.
204
+ * `delay` debounces (a search being typed); `replace` rewrites the
205
+ * address bar entry instead of adding one; `typing` names the field being
206
+ * typed in, whose changes in a row make a single entry.
207
+ */
208
+ load(options = {}) {
209
+ clearTimeout(this.#timer);
210
+ if (options.delay)
211
+ return new Promise((resolve) => {
212
+ this.#timer = setTimeout(() => resolve(this.#load(options.replace, options.typing)), options.delay);
213
+ });
214
+ return this.#load(options.replace, options.typing);
215
+ }
216
+ /** Fetch again, as is: after a change made elsewhere. */
217
+ reload() {
218
+ return this.load({ replace: true });
219
+ }
220
+ /**
221
+ * Go back to a query the address bar holds, when the visitor walks the
222
+ * browser history: fetches it without adding an entry. Filters holding a
223
+ * value are shown, the others are not; immutable ones keep their value. On
224
+ * "stay", the address is written back to the rows still on screen.
225
+ */
226
+ async restore(params) {
227
+ clearTimeout(this.#timer);
228
+ if (!(await this.#guard())) {
229
+ this.#write(false);
230
+ return;
231
+ }
232
+ const query = parseQuery(params, this.filters, this.sorts);
233
+ const values = query.filterValues;
234
+ for (const f of this.filters) {
235
+ if (f.immutable)
236
+ values[f.id] = this.filterValues[f.id];
237
+ else
238
+ f.shown = values[f.id] !== undefined;
239
+ }
240
+ this.filterValues = query.filterValues;
241
+ this.search = query.search ?? '';
242
+ this.sort = query.sort ?? this.sorts[0];
243
+ this.page = query.page ?? 1;
244
+ this.limit = query.limit ?? this.#startLimit;
245
+ this.#typed = undefined;
246
+ await this.#fetchPage();
247
+ }
248
+ async #load(replace = false, typing) {
249
+ if (!(await this.#guard())) {
250
+ Object.assign(this, { ...this.#committed, filterValues: { ...this.#committed.filterValues } });
251
+ return;
252
+ }
253
+ await this.#fetchPage(() => {
254
+ const now = Date.now();
255
+ const sameTyping = !!typing && this.#typed?.field === typing && now - this.#typed.at < TYPING_PAUSE;
256
+ this.#typed = typing ? { field: typing, at: now } : undefined;
257
+ if (this.options.history)
258
+ this.#write(replace || sameTyping);
259
+ });
260
+ }
261
+ /** Fetch the query as it stands; `fetched` runs once its rows are in. */
262
+ async #fetchPage(fetched) {
263
+ const ticket = ++this.#ticket;
264
+ this.loading = true;
265
+ try {
266
+ const result = await this.#get(this.#params());
267
+ if (ticket !== this.#ticket)
268
+ return;
269
+ /* Past the last page (rows deleted elsewhere, a narrower filter):
270
+ go to the last one that exists, once. */
271
+ const total = result.total ?? 0;
272
+ const last = Math.max(1, Math.ceil(total / this.limit));
273
+ if (!result.data?.length && total > 0 && this.page > last) {
274
+ this.page = last;
275
+ const again = await this.#get(this.#params());
276
+ if (ticket !== this.#ticket)
277
+ return;
278
+ Object.assign(result, again);
279
+ }
280
+ this.rows = (result.data ?? []).map((d, i) => this.#row(d, i));
281
+ this.total = result.total ?? this.rows.length;
282
+ this.#undo = [];
283
+ this.#redo = [];
284
+ this.#committed = this.#query();
285
+ fetched?.();
286
+ }
287
+ catch (error) {
288
+ if (ticket === this.#ticket)
289
+ this.#error(this.messages.loadFailed, error);
290
+ }
291
+ finally {
292
+ if (ticket === this.#ticket)
293
+ this.loading = false;
294
+ }
295
+ }
296
+ /** May the rows be replaced? Asks when there are unsaved changes. */
297
+ async #guard() {
298
+ const count = this.changed.length;
299
+ if (!count)
300
+ return true;
301
+ if (!this.options.confirmDiscard) {
302
+ this.options.onlog?.(this.messages.discarded(count));
303
+ return true;
304
+ }
305
+ const choice = await this.options.confirmDiscard(count);
306
+ if (choice === 'stay')
307
+ return false;
308
+ if (choice === 'save')
309
+ await this.save();
310
+ return true;
311
+ }
312
+ #query() {
313
+ return {
314
+ filterValues: { ...this.filterValues },
315
+ search: this.search,
316
+ sort: this.sort,
317
+ page: this.page,
318
+ limit: this.limit
319
+ };
320
+ }
321
+ #params(page = this.page, limit = this.limit) {
322
+ return {
323
+ filters: $state.snapshot(this.filterValues),
324
+ search: this.search.trim() || undefined,
325
+ sort: this.sort ? { id: this.sort.id, value: this.sort.value } : undefined,
326
+ limit,
327
+ page
328
+ };
329
+ }
330
+ /** The query, as the address bar and the REST path write it. */
331
+ toSearchParams() {
332
+ const params = filterValuesToParams(this.filterValues, this.filters);
333
+ if (this.search)
334
+ params.set('search', this.search);
335
+ if (this.sort)
336
+ params.set('sort', `${this.sort.id}-${this.sort.value}`);
337
+ params.set('page', String(this.page));
338
+ params.set('limit', String(this.limit));
339
+ return params;
340
+ }
341
+ #write(replace) {
342
+ const ours = this.toSearchParams();
343
+ /* Keep whatever else the address says: it is not ours. */
344
+ const keys = new Set([...this.filters.map((f) => f.id), 'search', 'sort', 'page', 'limit']);
345
+ for (const [k, v] of new URLSearchParams(location.search))
346
+ if (!keys.has(k))
347
+ ours.set(k, v);
348
+ const url = `${location.pathname}?${ours}${location.hash}`;
349
+ const nav = this.options.navigation ?? historyNavigation;
350
+ const state = { datahandler: ours.toString() };
351
+ /* The same query again is not a step back to walk through. */
352
+ if (replace || url === `${location.pathname}${location.search}${location.hash}`)
353
+ nav.replace(url, state);
354
+ else
355
+ nav.push(url, state);
356
+ }
357
+ async #get(params) {
358
+ if (this.options.get)
359
+ return await this.options.get(params);
360
+ if (!this.options.path)
361
+ return { data: [], total: 0 };
362
+ const query = filterValuesToParams(params.filters, this.filters);
363
+ if (params.search)
364
+ query.set('search', params.search);
365
+ if (params.sort)
366
+ query.set('sort', `${params.sort.id}-${params.sort.value}`);
367
+ query.set('page', String(params.page));
368
+ query.set('limit', String(params.limit));
369
+ const res = await this.#fetch(`${this.options.path}?${query}`);
370
+ return (await res.json());
371
+ }
372
+ async #fetch(url, init) {
373
+ const res = await (this.options.fetch ?? fetch)(url, init);
374
+ if (!res.ok)
375
+ throw new Error(`${init?.method ?? 'GET'} ${url}: ${res.status}`);
376
+ return res;
377
+ }
378
+ /* ── Changing the query ── each one fetches. */
379
+ changePage(page) {
380
+ this.page = Math.max(1, page);
381
+ return this.load();
382
+ }
383
+ changeLimit(limit) {
384
+ this.limit = limit > 0 ? limit : 10;
385
+ /* The first row on screen stays on screen. */
386
+ this.page = Math.max(1, Math.floor(((this.#committed.page - 1) * this.#committed.limit) / this.limit) + 1);
387
+ return this.load({ replace: true });
388
+ }
389
+ changeSort(sort) {
390
+ this.sort = sort;
391
+ this.page = 1;
392
+ return this.load();
393
+ }
394
+ /** A new search, fetched once the typing pauses (`delay`, default 300 ms). */
395
+ changeSearch(search, delay = 300) {
396
+ this.search = search;
397
+ this.page = 1;
398
+ return this.load({ delay, typing: 'search' });
399
+ }
400
+ changeFilter(id, value, delay = 0) {
401
+ this.filterValues[id] = value;
402
+ this.total = undefined;
403
+ this.page = 1;
404
+ return this.load({ delay, typing: delay ? id : undefined });
405
+ }
406
+ /** Put a filter on the page, without a value yet. */
407
+ showFilter(id) {
408
+ const f = this.filters.find((f) => f.id === id);
409
+ if (f)
410
+ f.shown = true;
411
+ }
412
+ /** Take a filter off the page, and its value with it. */
413
+ removeFilter(id) {
414
+ const f = this.filters.find((f) => f.id === id);
415
+ if (!f)
416
+ return;
417
+ f.shown = false;
418
+ if (this.filterValues[id] === undefined)
419
+ return;
420
+ return this.changeFilter(id, undefined);
421
+ }
422
+ /* ── Selection ── */
423
+ /** Select one row, or every row of the page. */
424
+ select(id) {
425
+ for (const r of this.rows)
426
+ if (id === undefined || r.id === id)
427
+ r.selected = true;
428
+ }
429
+ /** Unselect one row, or every row. */
430
+ unselect(id) {
431
+ for (const r of this.rows)
432
+ if (id === undefined || r.id === id)
433
+ r.selected = false;
434
+ }
435
+ /** Every row of the page is selected. */
436
+ get allSelected() {
437
+ return this.rows.length > 0 && this.selection.length === this.rows.length;
438
+ }
439
+ /* ── Editing ── */
440
+ #find(id) {
441
+ return this.rows.find((r) => r.id === id);
442
+ }
443
+ /** The part of a row the history records: what it shows and what differs. */
444
+ #state(r) {
445
+ return $state.snapshot(r.value);
446
+ }
447
+ #apply(id, value) {
448
+ const r = this.#find(id);
449
+ if (!r)
450
+ return;
451
+ r.value = { ...r.value, ...value };
452
+ r.updated = diff(r.value, r.init);
453
+ }
454
+ /** Record a step and clear what could be redone: a new change forks the history. */
455
+ #record(step) {
456
+ if (!step.length)
457
+ return;
458
+ this.#undo.push(step);
459
+ this.#redo = [];
460
+ }
461
+ /** Change fields of one row. */
462
+ update(id, patch) {
463
+ const r = this.#find(id);
464
+ if (!r)
465
+ return;
466
+ const before = this.#state(r);
467
+ const fields = typeof patch === 'function' ? patch(r.value) : patch;
468
+ this.#apply(id, fields);
469
+ this.#record([{ id, before, after: this.#state(r) }]);
470
+ }
471
+ /** Take back the last change, of the whole page or of one row. */
472
+ undo(id) {
473
+ if (id === undefined) {
474
+ const step = this.#undo.pop();
475
+ if (!step)
476
+ return;
477
+ for (const s of step)
478
+ this.#apply(s.id, s.before);
479
+ this.#redo.push(step);
480
+ return;
481
+ }
482
+ /* One row: its last change, wherever it sits in the history. A step
483
+ that touched other rows too (a reset of everything) loses only this
484
+ row. Done out of order, it cannot be redone. */
485
+ for (let i = this.#undo.length - 1; i >= 0; i--) {
486
+ const at = this.#undo[i].findIndex((s) => s.id === id);
487
+ if (at < 0)
488
+ continue;
489
+ const s = this.#undo[i].splice(at, 1)[0];
490
+ if (!this.#undo[i].length)
491
+ this.#undo.splice(i, 1);
492
+ this.#apply(id, s.before);
493
+ this.#redo = [];
494
+ return;
495
+ }
496
+ }
497
+ /** Put back the last change undone. */
498
+ redo() {
499
+ const step = this.#redo.pop();
500
+ if (!step)
501
+ return;
502
+ for (const s of step)
503
+ this.#apply(s.id, s.after);
504
+ this.#undo.push(step);
505
+ }
506
+ /** Drop every unsaved change — of one row, or of the page. One undoable step. */
507
+ reset(id) {
508
+ const rows = this.rows.filter((r) => r.dirty && (id === undefined || r.id === id));
509
+ const step = rows.map((r) => {
510
+ const before = this.#state(r);
511
+ r.value = r.init;
512
+ r.updated = {};
513
+ return { id: r.id, before, after: this.#state(r) };
514
+ });
515
+ this.#record(step);
516
+ }
517
+ /** Forget a row's history: it was saved, or deleted. */
518
+ #forget(ids) {
519
+ const out = (steps) => steps.map((st) => st.filter((s) => !ids.includes(s.id))).filter((st) => st.length);
520
+ this.#undo = out(this.#undo);
521
+ this.#redo = out(this.#redo);
522
+ }
523
+ /** Send the changes — of one row, or of every changed row. */
524
+ async save(id) {
525
+ if (!this.canUpdate)
526
+ return;
527
+ const rows = this.rows.filter((r) => r.dirty && (id === undefined || r.id === id));
528
+ const done = [];
529
+ for (const r of rows) {
530
+ try {
531
+ const data = $state.snapshot(r.updated);
532
+ const key = this.identifier.from(r.id);
533
+ if (this.options.update)
534
+ await this.options.update({ id: key, data });
535
+ else
536
+ await this.#fetch(`${this.options.path}/${encodeURIComponent(r.id)}`, {
537
+ method: 'PATCH',
538
+ headers: { 'content-type': 'application/json' },
539
+ body: JSON.stringify(data)
540
+ });
541
+ r.init = r.value;
542
+ r.updated = {};
543
+ done.push(r.id);
544
+ }
545
+ catch (error) {
546
+ this.#error(this.messages.saveFailed, error);
547
+ }
548
+ }
549
+ this.#forget(done);
550
+ if (done.length)
551
+ this.options.onlog?.(this.messages.saved(done.length));
552
+ }
553
+ /** Delete one row, or the selection. Asks `confirmDelete` first. */
554
+ async delete(id) {
555
+ if (!this.canDelete)
556
+ return;
557
+ const ids = id !== undefined ? [id] : [...this.selection];
558
+ if (!ids.length)
559
+ return;
560
+ if (this.options.confirmDelete && !(await this.options.confirmDelete(ids)))
561
+ return;
562
+ try {
563
+ const keys = ids.map((i) => this.identifier.from(i));
564
+ if (this.options.delete)
565
+ await this.options.delete(keys);
566
+ else
567
+ await this.#fetch(this.options.path, {
568
+ method: 'DELETE',
569
+ headers: { 'content-type': 'application/json' },
570
+ body: JSON.stringify(keys)
571
+ });
572
+ }
573
+ catch (error) {
574
+ this.#error(this.messages.deleteFailed, error);
575
+ return;
576
+ }
577
+ this.rows = this.rows.filter((r) => !ids.includes(r.id));
578
+ if (this.total !== undefined)
579
+ this.total = Math.max(0, this.total - ids.length);
580
+ this.#forget(ids);
581
+ this.options.onlog?.(this.messages.deleted(ids.length));
582
+ }
583
+ /* ── Export ── */
584
+ /**
585
+ * Every row matching the current query, fetched by batches, as a CSV file
586
+ * the browser downloads. Returns the file's content, or `undefined` when
587
+ * there was nothing to export.
588
+ *
589
+ * The file is written for a spreadsheet: a UTF-8 BOM (without it Excel
590
+ * reads ANSI and mangles accents), CRLF line ends (RFC 4180), and text
591
+ * starting with `= + - @` prefixed with `'`, so that a cell coming from a
592
+ * user is never run as a formula.
593
+ */
594
+ async export(options = {}) {
595
+ const { name = 'export.csv', max, batch = 100, format, separator = ';', download = true } = options;
596
+ const decimal = options.decimal ?? (separator === ';' ? ',' : '.');
597
+ const rows = [];
598
+ try {
599
+ for (let page = 1;; page++) {
600
+ const want = max === undefined ? batch : Math.min(batch, max - rows.length);
601
+ if (want <= 0)
602
+ break;
603
+ const res = await this.#get(this.#params(page, batch));
604
+ const data = res.data ?? [];
605
+ rows.push(...data.slice(0, want));
606
+ /* Done when a page comes back short, or when the total is reached:
607
+ no batch is ever skipped, the last one included. */
608
+ if (data.length < batch)
609
+ break;
610
+ if (res.total !== undefined && rows.length >= res.total)
611
+ break;
612
+ }
613
+ }
614
+ catch (error) {
615
+ this.#error(this.messages.loadFailed, error);
616
+ return;
617
+ }
618
+ if (!rows.length) {
619
+ this.options.onlog?.(this.messages.exportEmpty);
620
+ return;
621
+ }
622
+ const records = rows.map((r) => (format ? format(r) : r));
623
+ const columns = options.columns ??
624
+ Object.keys(records.reduce((a, b) => (Object.keys(b).length > Object.keys(a).length ? b : a))).map((key) => ({ key, label: key }));
625
+ const cell = (v) => {
626
+ if (v === undefined || v === null)
627
+ return '';
628
+ let s = v instanceof Date ? v.toISOString()
629
+ : typeof v === 'number' ? String(v).replace('.', decimal)
630
+ : typeof v === 'object' ? JSON.stringify(v)
631
+ : String(v);
632
+ // A number is never a formula, a negative one included
633
+ if (typeof v !== 'number' && /^[=+\-@\t\r]/.test(s))
634
+ s = `'${s}`;
635
+ return /["\n\r]/.test(s) || s.includes(separator) ? `"${s.replace(/"/g, '""')}"` : s;
636
+ };
637
+ const csv = '' +
638
+ [
639
+ columns.map((c) => cell(c.label ?? c.key)).join(separator),
640
+ ...records.map((r) => columns.map((c) => cell(r[c.key])).join(separator))
641
+ ].join('\r\n');
642
+ if (download && typeof document !== 'undefined') {
643
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
644
+ const a = document.createElement('a');
645
+ a.href = url;
646
+ a.download = name;
647
+ a.style.display = 'none';
648
+ document.body.append(a);
649
+ a.click();
650
+ a.remove();
651
+ URL.revokeObjectURL(url);
652
+ }
653
+ return csv;
654
+ }
655
+ /* ── Internals ── */
656
+ #error(message, error) {
657
+ if (this.options.onerror)
658
+ this.options.onerror(message, error);
659
+ else
660
+ console.error(message, error);
661
+ }
662
+ /** A row of data, with its own actions. */
663
+ #row(data, index) {
664
+ const id = this.identifier.to(data, index);
665
+ const handler = this;
666
+ return {
667
+ id,
668
+ init: data,
669
+ value: data,
670
+ updated: {},
671
+ selected: false,
672
+ get dirty() {
673
+ return Object.keys(this.updated).length > 0;
674
+ },
675
+ select: () => this.select(id),
676
+ unselect: () => this.unselect(id),
677
+ toggle() {
678
+ if (this.selected)
679
+ handler.unselect(id);
680
+ else
681
+ handler.select(id);
682
+ },
683
+ update: (patch) => this.update(id, patch),
684
+ undo: () => this.undo(id),
685
+ reset: () => this.reset(id),
686
+ save: () => this.save(id),
687
+ delete: () => this.delete(id)
688
+ };
689
+ }
690
+ }