@eifi1/ui-kit 0.44.0 → 0.46.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 +29 -14
- package/dist/admin/admin-action-log-labels.js +9 -0
- package/dist/admin/admin-action-log-labels.js.map +1 -1
- package/dist/admin/admin-action-log.d.ts +18 -1
- package/dist/admin/admin-action-log.js +25 -16
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.d.ts +15 -0
- package/dist/admin/admin-area-labels.js +6 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +16 -1
- package/dist/admin/admin-overview.js +12 -1
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/mail-test-card-labels.d.ts +3 -0
- package/dist/admin/mail-test-card-labels.js +17 -0
- package/dist/admin/mail-test-card-labels.js.map +1 -0
- package/dist/admin/mail-test-card.d.ts +75 -0
- package/dist/admin/mail-test-card.js +82 -0
- package/dist/admin/mail-test-card.js.map +1 -0
- package/dist/components/appearance-labels.d.ts +10 -0
- package/dist/components/appearance-labels.js +6 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-head.js +2 -2
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-mobile.js +17 -2
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +18 -2
- package/dist/components/data-table-row-actions.js +86 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-shared.d.ts +2 -0
- package/dist/components/data-table-shared.js +17 -2
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +43 -2
- package/dist/components/data-table.js +10 -10
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-format-setting.d.ts +29 -0
- package/dist/components/date-format-setting.js +36 -0
- package/dist/components/date-format-setting.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +8 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tabs.d.ts +3 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -0
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +5 -0
- package/dist/components/translation-review-labels.js +2 -0
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +18 -8
- package/dist/components/translation-review.js +64 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/write-lock-labels.js +2 -1
- package/dist/components/write-lock-labels.js.map +1 -1
- package/dist/components/write-lock.d.ts +33 -3
- package/dist/components/write-lock.js +9 -0
- package/dist/components/write-lock.js.map +1 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +4 -2
- package/dist/feedback/feedback-row-detail.js +17 -26
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-swipe.d.ts +18 -0
- package/dist/feedback/feedback-swipe.js +10 -1
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/i18n/defaults.js +9 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +81 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +25 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +97 -7
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +81 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +81 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +81 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +81 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +81 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +16 -2
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/date-format.d.ts +81 -0
- package/dist/lib/date-format.js +56 -0
- package/dist/lib/date-format.js.map +1 -0
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/format.js +11 -5
- package/dist/lib/format.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +27 -0
- package/dist/notifications/device-zone-report.js +24 -0
- package/dist/notifications/device-zone-report.js.map +1 -0
- package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
- package/dist/notifications/feedback-notification-types-labels.js +4 -1
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +11 -5
- package/dist/notifications/feedback-notification-types.js +2 -1
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +33 -0
- package/dist/notifications/notification-bell.js +74 -0
- package/dist/notifications/notification-bell.js.map +1 -0
- package/dist/notifications/notification-inbox.d.ts +77 -0
- package/dist/notifications/notification-inbox.js +206 -0
- package/dist/notifications/notification-inbox.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +6 -4
- package/dist/notifications/notification-types-setting.js +103 -45
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +19 -7
- package/dist/notifications/notifications-labels.d.ts +41 -7
- package/dist/notifications/notifications-labels.js +18 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-devices-setting.d.ts +20 -3
- package/dist/notifications/push-devices-setting.js +93 -33
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.d.ts +108 -0
- package/dist/notifications/push-worker.js +62 -0
- package/dist/notifications/push-worker.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +29 -17
- package/dist/notifications/quiet-hours-setting.js +9 -41
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +5 -2
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/notifications/use-push-device.d.ts +4 -17
- package/dist/notifications/use-push-device.js +7 -15
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/local-first.d.ts +33 -0
- package/dist/offline/local-first.js +39 -0
- package/dist/offline/local-first.js.map +1 -0
- package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
- package/dist/offline/pwa-update-prompt-labels.js +9 -0
- package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
- package/dist/offline/pwa-update-prompt.d.ts +32 -0
- package/dist/offline/pwa-update-prompt.js +36 -0
- package/dist/offline/pwa-update-prompt.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +125 -0
- package/dist/offline/query-persistence.js +183 -0
- package/dist/offline/query-persistence.js.map +1 -0
- package/dist/offline/service-worker-updates.d.ts +46 -0
- package/dist/offline/service-worker-updates.js +39 -0
- package/dist/offline/service-worker-updates.js.map +1 -0
- package/dist/offline/shared-computer-labels.d.ts +2 -0
- package/dist/offline/shared-computer-labels.js +9 -0
- package/dist/offline/shared-computer-labels.js.map +1 -0
- package/dist/offline/shared-computer.d.ts +44 -0
- package/dist/offline/shared-computer.js +51 -0
- package/dist/offline/shared-computer.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
- package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
- package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
- package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
- package/dist/offline/sign-out-pending-dialog.js +66 -0
- package/dist/offline/sign-out-pending-dialog.js.map +1 -0
- package/dist/offline.d.ts +11 -0
- package/dist/offline.js +33 -0
- package/dist/offline.js.map +1 -0
- package/dist/push-worker.d.ts +2 -0
- package/dist/push-worker.js +14 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/shell/account-menu-text-size.d.ts +33 -0
- package/dist/shell/account-menu-text-size.js +51 -0
- package/dist/shell/account-menu-text-size.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +24 -0
- package/dist/shell/admin-menu.js +12 -3
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell/top-bar-actions.d.ts +6 -2
- package/dist/shell/top-bar-actions.js +1 -0
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell.d.ts +4 -0
- package/dist/shell.js +4 -0
- package/dist/shell.js.map +1 -1
- package/dist/sync/db.d.ts +76 -0
- package/dist/sync/db.js +24 -0
- package/dist/sync/db.js.map +1 -0
- package/dist/sync/engine.d.ts +124 -0
- package/dist/sync/engine.js +474 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hooks.d.ts +30 -0
- package/dist/sync/hooks.js +67 -0
- package/dist/sync/hooks.js.map +1 -0
- package/dist/sync/testing.d.ts +28 -0
- package/dist/sync/testing.js +41 -0
- package/dist/sync/testing.js.map +1 -0
- package/dist/sync/wire.d.ts +50 -0
- package/dist/sync/wire.js +2 -0
- package/dist/sync/wire.js.map +1 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.js +22 -0
- package/dist/sync.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +7 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour-labels.js +1 -0
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +30 -0
- package/dist/tour/tour.js +54 -8
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +99 -1
- package/dist/tour/use-tour-progress.js +228 -12
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +2 -0
- package/dist/tour.js.map +1 -1
- package/package.json +43 -1
- package/src/admin/admin-action-log-labels.ts +9 -0
- package/src/admin/admin-action-log.tsx +41 -5
- package/src/admin/admin-area-labels.ts +20 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card-labels.ts +19 -0
- package/src/admin/mail-test-card.tsx +190 -0
- package/src/components/appearance-labels.ts +16 -0
- package/src/components/data-table-column-settings.tsx +2 -2
- package/src/components/data-table-head.tsx +2 -2
- package/src/components/data-table-mobile.tsx +26 -3
- package/src/components/data-table-row-actions.tsx +118 -2
- package/src/components/data-table-shared.tsx +39 -3
- package/src/components/data-table.tsx +52 -21
- package/src/components/date-format-setting.tsx +64 -0
- package/src/components/swipeable-row.tsx +8 -0
- package/src/components/tabs.tsx +4 -3
- package/src/components/translation-review-editor.tsx +2 -0
- package/src/components/translation-review-labels.ts +7 -0
- package/src/components/translation-review.tsx +102 -14
- package/src/components/write-lock-labels.ts +1 -0
- package/src/components/write-lock.tsx +36 -3
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +18 -23
- package/src/feedback/feedback-swipe.tsx +28 -1
- package/src/i18n/defaults.ts +8 -0
- package/src/i18n/german.ts +90 -4
- package/src/i18n/kit-labels.tsx +31 -1
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +90 -4
- package/src/i18n/locales/fr.ts +90 -4
- package/src/i18n/locales/hu.ts +86 -4
- package/src/i18n/locales/it.ts +90 -4
- package/src/i18n/locales/zh.ts +84 -2
- package/src/i18n/review.ts +15 -1
- package/src/index.ts +45 -0
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +23 -6
- package/src/notifications/device-zone-report.ts +54 -0
- package/src/notifications/feedback-notification-types-labels.ts +10 -1
- package/src/notifications/feedback-notification-types.ts +12 -5
- package/src/notifications/notification-bell.tsx +116 -0
- package/src/notifications/notification-inbox.tsx +304 -0
- package/src/notifications/notification-types-setting.tsx +151 -55
- package/src/notifications/notification-types.ts +20 -8
- package/src/notifications/notifications-labels.ts +69 -11
- package/src/notifications/push-devices-setting.tsx +123 -34
- package/src/notifications/push-worker.ts +160 -0
- package/src/notifications/quiet-hours-setting.tsx +41 -58
- package/src/notifications/time-zone-select.tsx +5 -2
- package/src/notifications/use-push-device.ts +20 -33
- package/src/offline/local-first.ts +76 -0
- package/src/offline/pwa-update-prompt-labels.ts +9 -0
- package/src/offline/pwa-update-prompt.tsx +72 -0
- package/src/offline/query-persistence.ts +356 -0
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline/shared-computer-labels.ts +8 -0
- package/src/offline/shared-computer.tsx +87 -0
- package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
- package/src/offline/sign-out-pending-dialog.tsx +135 -0
- package/src/offline.ts +32 -0
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/admin-menu.tsx +40 -3
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +7 -0
- package/src/sync/db.ts +103 -0
- package/src/sync/engine.ts +699 -0
- package/src/sync/hooks.ts +93 -0
- package/src/sync/testing.ts +73 -0
- package/src/sync/wire.ts +61 -0
- package/src/sync.ts +29 -0
- package/src/tour/tour-catalogue.tsx +7 -1
- package/src/tour/tour-labels.ts +1 -0
- package/src/tour/tour.tsx +105 -9
- package/src/tour/use-tour-progress.ts +378 -12
- package/src/tour.ts +3 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { liveQuery } from "dexie";
|
|
4
|
+
function useSyncWorker(engine) {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (!engine) return;
|
|
7
|
+
engine.start();
|
|
8
|
+
return () => engine.stop();
|
|
9
|
+
}, [engine]);
|
|
10
|
+
}
|
|
11
|
+
const IDLE = {
|
|
12
|
+
phase: "idle",
|
|
13
|
+
online: true,
|
|
14
|
+
counts: { pending: 0, waiting: 0, blocked: 0, rejected: 0 },
|
|
15
|
+
lastSyncedAt: null,
|
|
16
|
+
nextRetryAt: null
|
|
17
|
+
};
|
|
18
|
+
const noEngine = () => () => void 0;
|
|
19
|
+
const idle = () => IDLE;
|
|
20
|
+
function useSyncStatus(engine) {
|
|
21
|
+
const state = useSyncExternalStore(
|
|
22
|
+
engine ? engine.subscribe : noEngine,
|
|
23
|
+
engine ? engine.getState : idle,
|
|
24
|
+
engine ? engine.getState : idle
|
|
25
|
+
);
|
|
26
|
+
const browserOnline = useSyncExternalStore(subscribeOnline, readOnline, () => true);
|
|
27
|
+
const onSync = useCallback(async () => {
|
|
28
|
+
await engine?.syncNow();
|
|
29
|
+
}, [engine]);
|
|
30
|
+
const status = {
|
|
31
|
+
phase: state.phase,
|
|
32
|
+
online: engine ? state.online : browserOnline,
|
|
33
|
+
pending: state.counts.pending,
|
|
34
|
+
waitingForPlan: state.counts.waiting,
|
|
35
|
+
blocked: state.counts.blocked + state.counts.rejected,
|
|
36
|
+
lastSyncedAt: state.lastSyncedAt
|
|
37
|
+
};
|
|
38
|
+
return { status, onSync };
|
|
39
|
+
}
|
|
40
|
+
function subscribeOnline(listener) {
|
|
41
|
+
window.addEventListener("online", listener);
|
|
42
|
+
window.addEventListener("offline", listener);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener("online", listener);
|
|
45
|
+
window.removeEventListener("offline", listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const readOnline = () => navigator.onLine !== false;
|
|
49
|
+
function useSyncQuery(db, query, deps) {
|
|
50
|
+
const [state, setState] = useState({ data: void 0, isLoading: true });
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!db) return;
|
|
53
|
+
const subscription = liveQuery(() => query(db)).subscribe({
|
|
54
|
+
next: (data) => setState({ data, isLoading: false }),
|
|
55
|
+
error: () => setState({ data: void 0, isLoading: false })
|
|
56
|
+
});
|
|
57
|
+
return () => subscription.unsubscribe();
|
|
58
|
+
}, [db, ...deps]);
|
|
59
|
+
return db ? state : NO_COPY;
|
|
60
|
+
}
|
|
61
|
+
const NO_COPY = { data: void 0, isLoading: true };
|
|
62
|
+
export {
|
|
63
|
+
useSyncQuery,
|
|
64
|
+
useSyncStatus,
|
|
65
|
+
useSyncWorker
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=hooks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/sync/hooks.ts"],"sourcesContent":["import { useCallback, useEffect, useState, useSyncExternalStore } from \"react\";\nimport { liveQuery } from \"dexie\";\nimport type { SyncStatus } from \"../shell/sync-status-indicator\";\nimport type { SyncDb } from \"./db\";\nimport type { SyncEngine, SyncEngineState } from \"./engine\";\n\n/**\n * Keep `engine` running while it is mounted: started on mount, stopped on unmount or when\n * the engine changes (a company switch builds a new engine on its own database, D15).\n * `null` runs nothing — signed out, or a computer marked shared. Mount once, in the\n * signed-in layout (kastlan's `useHandoverSyncLoop`).\n */\nexport function useSyncWorker(engine: SyncEngine | null): void {\n useEffect(() => {\n if (!engine) return;\n engine.start();\n return () => engine.stop();\n }, [engine]);\n}\n\nconst IDLE: SyncEngineState = {\n phase: \"idle\",\n online: true,\n counts: { pending: 0, waiting: 0, blocked: 0, rejected: 0 },\n lastSyncedAt: null,\n nextRetryAt: null,\n};\nconst noEngine = () => () => undefined;\nconst idle = () => IDLE;\n\n/**\n * The engine's state as the kit's `SyncStatusIndicator` reads it (§2.3): `pending` = queued\n * and in flight (and the steps' pending), `waitingForPlan` = `waiting`, `blocked` = `blocked`\n * + `rejected`, `lastSyncedAt` from `meta`. `onSync` is \"Sync now\". Without an engine, an\n * idle status that follows the browser's connection.\n */\nexport function useSyncStatus(engine: SyncEngine | null): { status: SyncStatus; onSync: () => Promise<void> } {\n const state = useSyncExternalStore(\n engine ? engine.subscribe : noEngine,\n engine ? engine.getState : idle,\n engine ? engine.getState : idle,\n );\n const browserOnline = useSyncExternalStore(subscribeOnline, readOnline, () => true);\n const onSync = useCallback(async () => {\n await engine?.syncNow();\n }, [engine]);\n const status: SyncStatus = {\n phase: state.phase,\n online: engine ? state.online : browserOnline,\n pending: state.counts.pending,\n waitingForPlan: state.counts.waiting,\n blocked: state.counts.blocked + state.counts.rejected,\n lastSyncedAt: state.lastSyncedAt,\n };\n return { status, onSync };\n}\n\nfunction subscribeOnline(listener: () => void) {\n window.addEventListener(\"online\", listener);\n window.addEventListener(\"offline\", listener);\n return () => {\n window.removeEventListener(\"online\", listener);\n window.removeEventListener(\"offline\", listener);\n };\n}\nconst readOnline = () => navigator.onLine !== false;\n\n/**\n * A read of the copy that follows every change to it (Dexie's `liveQuery`; kastlan's\n * `useLive`). `deps` names what `query` reads; `query` itself is rebuilt every render.\n * `query` gets the app's own database type (`HandoverDb`). Without a database (not open\n * yet) it answers `isLoading: true`, so a page shows no \"empty\" before the copy opens.\n */\nexport function useSyncQuery<T, D extends SyncDb = SyncDb>(\n db: D | null,\n query: (db: D) => Promise<T>,\n deps: readonly unknown[],\n): { data: T | undefined; isLoading: boolean } {\n const [state, setState] = useState<{ data: T | undefined; isLoading: boolean }>({ data: undefined, isLoading: true });\n useEffect(() => {\n if (!db) return;\n const subscription = liveQuery(() => query(db)).subscribe({\n next: (data) => setState({ data, isLoading: false }),\n error: () => setState({ data: undefined, isLoading: false }),\n });\n return () => subscription.unsubscribe();\n // `query` is rebuilt every render; `deps` names what it reads.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [db, ...deps]);\n return db ? state : NO_COPY;\n}\n\nconst NO_COPY = { data: undefined, isLoading: true } as const;\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,UAAU,4BAA4B;AACvE,SAAS,iBAAiB;AAWnB,SAAS,cAAc,QAAiC;AAC7D,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,WAAO,MAAM;AACb,WAAO,MAAM,OAAO,KAAK;AAAA,EAC3B,GAAG,CAAC,MAAM,CAAC;AACb;AAEA,MAAM,OAAwB;AAAA,EAC5B,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,QAAQ,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,EAAE;AAAA,EAC1D,cAAc;AAAA,EACd,aAAa;AACf;AACA,MAAM,WAAW,MAAM,MAAM;AAC7B,MAAM,OAAO,MAAM;AAQZ,SAAS,cAAc,QAAgF;AAC5G,QAAM,QAAQ;AAAA,IACZ,SAAS,OAAO,YAAY;AAAA,IAC5B,SAAS,OAAO,WAAW;AAAA,IAC3B,SAAS,OAAO,WAAW;AAAA,EAC7B;AACA,QAAM,gBAAgB,qBAAqB,iBAAiB,YAAY,MAAM,IAAI;AAClF,QAAM,SAAS,YAAY,YAAY;AACrC,UAAM,QAAQ,QAAQ;AAAA,EACxB,GAAG,CAAC,MAAM,CAAC;AACX,QAAM,SAAqB;AAAA,IACzB,OAAO,MAAM;AAAA,IACb,QAAQ,SAAS,MAAM,SAAS;AAAA,IAChC,SAAS,MAAM,OAAO;AAAA,IACtB,gBAAgB,MAAM,OAAO;AAAA,IAC7B,SAAS,MAAM,OAAO,UAAU,MAAM,OAAO;AAAA,IAC7C,cAAc,MAAM;AAAA,EACtB;AACA,SAAO,EAAE,QAAQ,OAAO;AAC1B;AAEA,SAAS,gBAAgB,UAAsB;AAC7C,SAAO,iBAAiB,UAAU,QAAQ;AAC1C,SAAO,iBAAiB,WAAW,QAAQ;AAC3C,SAAO,MAAM;AACX,WAAO,oBAAoB,UAAU,QAAQ;AAC7C,WAAO,oBAAoB,WAAW,QAAQ;AAAA,EAChD;AACF;AACA,MAAM,aAAa,MAAM,UAAU,WAAW;AAQvC,SAAS,aACd,IACA,OACA,MAC6C;AAC7C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAsD,EAAE,MAAM,QAAW,WAAW,KAAK,CAAC;AACpH,YAAU,MAAM;AACd,QAAI,CAAC,GAAI;AACT,UAAM,eAAe,UAAU,MAAM,MAAM,EAAE,CAAC,EAAE,UAAU;AAAA,MACxD,MAAM,CAAC,SAAS,SAAS,EAAE,MAAM,WAAW,MAAM,CAAC;AAAA,MACnD,OAAO,MAAM,SAAS,EAAE,MAAM,QAAW,WAAW,MAAM,CAAC;AAAA,IAC7D,CAAC;AACD,WAAO,MAAM,aAAa,YAAY;AAAA,EAGxC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;AAChB,SAAO,KAAK,QAAQ;AACtB;AAEA,MAAM,UAAU,EAAE,MAAM,QAAW,WAAW,KAAK;","names":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SyncRequestBody, SyncResponseBody } from "./wire.js";
|
|
2
|
+
/** One scripted answer: a body, a function of the request, or a failure. */
|
|
3
|
+
export type FakeSyncAnswer = SyncResponseBody | ((body: SyncRequestBody) => SyncResponseBody | Promise<SyncResponseBody>) | {
|
|
4
|
+
fail: "offline";
|
|
5
|
+
} | {
|
|
6
|
+
fail: number;
|
|
7
|
+
data?: unknown;
|
|
8
|
+
};
|
|
9
|
+
export interface FakeSyncTransport {
|
|
10
|
+
/** The engine's `send`. */
|
|
11
|
+
send(body: SyncRequestBody, signal?: AbortSignal): Promise<SyncResponseBody>;
|
|
12
|
+
/** Every request received, in order (the body as sent). */
|
|
13
|
+
readonly requests: SyncRequestBody[];
|
|
14
|
+
/** Queue answers for the next requests, in order; past them the default answers. */
|
|
15
|
+
answer(...answers: FakeSyncAnswer[]): void;
|
|
16
|
+
/** The next request meets no connection (an axios-like error with no response). */
|
|
17
|
+
failOffline(): void;
|
|
18
|
+
/** The next request answers `status` (a 5xx, a 402 `{code}`, a 403). */
|
|
19
|
+
failStatus(status: number, data?: unknown): void;
|
|
20
|
+
/** The default answer's server knowledge, raised by one for every request. */
|
|
21
|
+
knowledge: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The default answer echoes every pushed change as applied, with a server id (`pk`, 100 on),
|
|
25
|
+
* and an empty pull besides; `knowledge` goes up by one each request. Script anything else
|
|
26
|
+
* with `answer(...)`, `failOffline()` and `failStatus(...)`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createFakeSyncTransport(): FakeSyncTransport;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
function wireError(fail, data) {
|
|
3
|
+
const err = new Error(fail === "offline" ? "Network Error" : `Request failed with status code ${fail}`);
|
|
4
|
+
return Object.assign(err, {
|
|
5
|
+
isAxiosError: true,
|
|
6
|
+
response: fail === "offline" ? void 0 : { status: fail, data: data ?? {} }
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function createFakeSyncTransport() {
|
|
10
|
+
const script = [];
|
|
11
|
+
let nextPk = 100;
|
|
12
|
+
const transport = {
|
|
13
|
+
requests: [],
|
|
14
|
+
knowledge: 0,
|
|
15
|
+
answer: (...answers) => void script.push(...answers),
|
|
16
|
+
failOffline: () => void script.push({ fail: "offline" }),
|
|
17
|
+
failStatus: (status, data) => void script.push({ fail: status, data }),
|
|
18
|
+
async send(body) {
|
|
19
|
+
transport.requests.push(structuredClone(body));
|
|
20
|
+
const next = script.shift();
|
|
21
|
+
if (next && typeof next === "object" && "fail" in next) {
|
|
22
|
+
throw wireError(next.fail, "data" in next ? next.data : void 0);
|
|
23
|
+
}
|
|
24
|
+
if (typeof next === "function") return next(body);
|
|
25
|
+
if (next) return next;
|
|
26
|
+
transport.knowledge += 1;
|
|
27
|
+
return {
|
|
28
|
+
server_knowledge: transport.knowledge,
|
|
29
|
+
changes: body.changes.map((change) => ({ ...change, pk: change.op === "delete" ? null : nextPk++ })),
|
|
30
|
+
rejections: [],
|
|
31
|
+
refused: null,
|
|
32
|
+
page_cursor: null
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return transport;
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
createFakeSyncTransport
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/sync/testing.ts"],"sourcesContent":["import type { SyncRequestBody, SyncResponseBody } from \"./wire\";\n\n/*\n * `@eifi1/ui-kit/sync/testing` — a scripted `/sync` server for an app's tests of the engine\n * (docs/local-first-harmonization.md §2.3). Pass `transport.send` as the engine's `send`.\n * Imports nothing (no Dexie): it is the engine's `send`, not a store.\n */\n\n/** One scripted answer: a body, a function of the request, or a failure. */\nexport type FakeSyncAnswer =\n | SyncResponseBody\n | ((body: SyncRequestBody) => SyncResponseBody | Promise<SyncResponseBody>)\n | { fail: \"offline\" }\n | { fail: number; data?: unknown };\n\nexport interface FakeSyncTransport {\n /** The engine's `send`. */\n send(body: SyncRequestBody, signal?: AbortSignal): Promise<SyncResponseBody>;\n /** Every request received, in order (the body as sent). */\n readonly requests: SyncRequestBody[];\n /** Queue answers for the next requests, in order; past them the default answers. */\n answer(...answers: FakeSyncAnswer[]): void;\n /** The next request meets no connection (an axios-like error with no response). */\n failOffline(): void;\n /** The next request answers `status` (a 5xx, a 402 `{code}`, a 403). */\n failStatus(status: number, data?: unknown): void;\n /** The default answer's server knowledge, raised by one for every request. */\n knowledge: number;\n}\n\n/** The error an axios client throws: `isAxiosError`, and `response` only with an answer. */\nfunction wireError(fail: \"offline\" | number, data?: unknown): Error {\n const err = new Error(fail === \"offline\" ? \"Network Error\" : `Request failed with status code ${fail}`);\n return Object.assign(err, {\n isAxiosError: true,\n response: fail === \"offline\" ? undefined : { status: fail, data: data ?? {} },\n });\n}\n\n/**\n * The default answer echoes every pushed change as applied, with a server id (`pk`, 100 on),\n * and an empty pull besides; `knowledge` goes up by one each request. Script anything else\n * with `answer(...)`, `failOffline()` and `failStatus(...)`.\n */\nexport function createFakeSyncTransport(): FakeSyncTransport {\n const script: FakeSyncAnswer[] = [];\n let nextPk = 100;\n const transport: FakeSyncTransport = {\n requests: [],\n knowledge: 0,\n answer: (...answers) => void script.push(...answers),\n failOffline: () => void script.push({ fail: \"offline\" }),\n failStatus: (status, data) => void script.push({ fail: status, data }),\n async send(body) {\n transport.requests.push(structuredClone(body));\n const next = script.shift();\n if (next && typeof next === \"object\" && \"fail\" in next) {\n throw wireError(next.fail, \"data\" in next ? next.data : undefined);\n }\n if (typeof next === \"function\") return next(body);\n if (next) return next;\n transport.knowledge += 1;\n return {\n server_knowledge: transport.knowledge,\n changes: body.changes.map((change) => ({ ...change, pk: change.op === \"delete\" ? null : nextPk++ })),\n rejections: [],\n refused: null,\n page_cursor: null,\n };\n },\n };\n return transport;\n}\n"],"mappings":";AA+BA,SAAS,UAAU,MAA0B,MAAuB;AAClE,QAAM,MAAM,IAAI,MAAM,SAAS,YAAY,kBAAkB,mCAAmC,IAAI,EAAE;AACtG,SAAO,OAAO,OAAO,KAAK;AAAA,IACxB,cAAc;AAAA,IACd,UAAU,SAAS,YAAY,SAAY,EAAE,QAAQ,MAAM,MAAM,QAAQ,CAAC,EAAE;AAAA,EAC9E,CAAC;AACH;AAOO,SAAS,0BAA6C;AAC3D,QAAM,SAA2B,CAAC;AAClC,MAAI,SAAS;AACb,QAAM,YAA+B;AAAA,IACnC,UAAU,CAAC;AAAA,IACX,WAAW;AAAA,IACX,QAAQ,IAAI,YAAY,KAAK,OAAO,KAAK,GAAG,OAAO;AAAA,IACnD,aAAa,MAAM,KAAK,OAAO,KAAK,EAAE,MAAM,UAAU,CAAC;AAAA,IACvD,YAAY,CAAC,QAAQ,SAAS,KAAK,OAAO,KAAK,EAAE,MAAM,QAAQ,KAAK,CAAC;AAAA,IACrE,MAAM,KAAK,MAAM;AACf,gBAAU,SAAS,KAAK,gBAAgB,IAAI,CAAC;AAC7C,YAAM,OAAO,OAAO,MAAM;AAC1B,UAAI,QAAQ,OAAO,SAAS,YAAY,UAAU,MAAM;AACtD,cAAM,UAAU,KAAK,MAAM,UAAU,OAAO,KAAK,OAAO,MAAS;AAAA,MACnE;AACA,UAAI,OAAO,SAAS,WAAY,QAAO,KAAK,IAAI;AAChD,UAAI,KAAM,QAAO;AACjB,gBAAU,aAAa;AACvB,aAAO;AAAA,QACL,kBAAkB,UAAU;AAAA,QAC5B,SAAS,KAAK,QAAQ,IAAI,CAAC,YAAY,EAAE,GAAG,QAAQ,IAAI,OAAO,OAAO,WAAW,OAAO,SAAS,EAAE;AAAA,QACnG,YAAY,CAAC;AAAA,QACb,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** One row's delta on the wire. */
|
|
2
|
+
export interface SyncWireChange {
|
|
3
|
+
entity: string;
|
|
4
|
+
id: string;
|
|
5
|
+
op: "upsert" | "delete";
|
|
6
|
+
data?: Record<string, unknown> | null;
|
|
7
|
+
/** The device's edit time on a push (D2). */
|
|
8
|
+
updated_at?: string | null;
|
|
9
|
+
/** The server's id of the row, on a pull (kastlan). */
|
|
10
|
+
pk?: number | null;
|
|
11
|
+
knowledge?: number | null;
|
|
12
|
+
}
|
|
13
|
+
/** Where a paged pull stopped (server-kit `SyncPageCursor`); handed back as it came. */
|
|
14
|
+
export interface SyncPageCursor {
|
|
15
|
+
entity: string;
|
|
16
|
+
knowledge?: number | null;
|
|
17
|
+
id?: string | null;
|
|
18
|
+
window_end: number;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncRequestBody {
|
|
21
|
+
scope: string;
|
|
22
|
+
last_knowledge: number;
|
|
23
|
+
changes: SyncWireChange[];
|
|
24
|
+
/** This client keeps refused changes and reads `refused` and `rejections` (D1). */
|
|
25
|
+
refusals: true;
|
|
26
|
+
/** The device's clock as the body is built, fresh for every request (D2 amendment). */
|
|
27
|
+
client_now: string;
|
|
28
|
+
paged?: boolean;
|
|
29
|
+
page_cursor?: SyncPageCursor | null;
|
|
30
|
+
}
|
|
31
|
+
/** The WHOLE push refused (a lapsed plan): the pull is served all the same. */
|
|
32
|
+
export interface SyncRefusalBody {
|
|
33
|
+
code: string;
|
|
34
|
+
detail: string;
|
|
35
|
+
change_ids: string[];
|
|
36
|
+
}
|
|
37
|
+
/** One pushed change the server did not apply, with its reason. */
|
|
38
|
+
export interface SyncRejectionBody {
|
|
39
|
+
change_id: string;
|
|
40
|
+
code: string;
|
|
41
|
+
detail: string;
|
|
42
|
+
}
|
|
43
|
+
export interface SyncResponseBody {
|
|
44
|
+
server_knowledge: number;
|
|
45
|
+
changes: SyncWireChange[];
|
|
46
|
+
refused?: SyncRefusalBody | null;
|
|
47
|
+
rejections?: SyncRejectionBody[];
|
|
48
|
+
/** Set while the pull did not fit one page; `null` on the last page (D12). */
|
|
49
|
+
page_cursor?: SyncPageCursor | null;
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createSyncDb, SYNC_DB_STORES } from "./sync/db.js";
|
|
2
|
+
export type { CreateSyncDbOptions, OutboxChange, OutboxStatus, SyncDb, SyncDbUpgrade, SyncedRow, SyncMetaEntry } from "./sync/db.js";
|
|
3
|
+
export { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from "./sync/engine.js";
|
|
4
|
+
export type { SyncCounts, SyncEngine, SyncEngineOptions, SyncEngineState, SyncRoundResult, SyncStep, SyncStepContext, SyncStepResult, } from "./sync/engine.js";
|
|
5
|
+
export { useSyncQuery, useSyncStatus, useSyncWorker } from "./sync/hooks.js";
|
|
6
|
+
export type { SyncPageCursor, SyncRefusalBody, SyncRejectionBody, SyncRequestBody, SyncResponseBody, SyncWireChange, } from "./sync/wire.js";
|
|
7
|
+
export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createSyncDb, SYNC_DB_STORES } from "./sync/db.js";
|
|
3
|
+
import { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from "./sync/engine.js";
|
|
4
|
+
import { useSyncQuery, useSyncStatus, useSyncWorker } from "./sync/hooks.js";
|
|
5
|
+
import { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
|
|
6
|
+
export {
|
|
7
|
+
DEFAULT_HOLD_CODES,
|
|
8
|
+
DEFAULT_SYNC_BACKOFF_MS,
|
|
9
|
+
SYNC_DB_STORES,
|
|
10
|
+
createSyncDb,
|
|
11
|
+
createSyncEngine,
|
|
12
|
+
isOfflineError,
|
|
13
|
+
readLocalFirst,
|
|
14
|
+
releaseSyncEngine,
|
|
15
|
+
stampAfter,
|
|
16
|
+
syncEngineFor,
|
|
17
|
+
useSyncQuery,
|
|
18
|
+
useSyncStatus,
|
|
19
|
+
useSyncWorker,
|
|
20
|
+
writeLocalFirst
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=sync.js.map
|
package/dist/sync.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/sync.ts"],"sourcesContent":["// `@eifi1/ui-kit/sync` — local-first phase 3 (docs/local-first-harmonization.md §2.3, 0.46):\n// the sync engine on a Dexie copy, its worker and status hooks, and the live read of the copy.\n//\n// The only entry point that imports `dexie` — an optional peer for that reason. It never loads\n// `/offline`'s TanStack and idb-keyval peers: the leaf utilities both entries export live in\n// one module of their own (src/offline/local-first.ts). NOT re-exported from the main barrel.\nexport { createSyncDb, SYNC_DB_STORES } from \"./sync/db\";\nexport type { CreateSyncDbOptions, OutboxChange, OutboxStatus, SyncDb, SyncDbUpgrade, SyncedRow, SyncMetaEntry } from \"./sync/db\";\nexport { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from \"./sync/engine\";\nexport type {\n SyncCounts,\n SyncEngine,\n SyncEngineOptions,\n SyncEngineState,\n SyncRoundResult,\n SyncStep,\n SyncStepContext,\n SyncStepResult,\n} from \"./sync/engine\";\nexport { useSyncQuery, useSyncStatus, useSyncWorker } from \"./sync/hooks\";\nexport type {\n SyncPageCursor,\n SyncRefusalBody,\n SyncRejectionBody,\n SyncRequestBody,\n SyncResponseBody,\n SyncWireChange,\n} from \"./sync/wire\";\nexport { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from \"./offline/local-first\";\n"],"mappings":";AAMA,SAAS,cAAc,sBAAsB;AAE7C,SAAS,kBAAkB,oBAAoB,yBAAyB,mBAAmB,qBAAqB;AAWhH,SAAS,cAAc,eAAe,qBAAqB;AAS3D,SAAS,gBAAgB,gBAAgB,YAAY,uBAAuB;","names":[]}
|
|
@@ -14,7 +14,12 @@ export interface TourCatalogueCardProps {
|
|
|
14
14
|
onLaunch: (id: string) => void;
|
|
15
15
|
/** "Mark as not done". Without it, the link is not drawn. */
|
|
16
16
|
onReset?: (id: string) => void;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Formats the completion and update days. Default: the kit's medium date in the provider's locale.
|
|
19
|
+
* It gets the completion's ISO instant and the update's bare `"YYYY-MM-DD"` (`updated.date`): read
|
|
20
|
+
* the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,
|
|
21
|
+
* which is UTC midnight and the day before west of UTC (round 0.45, N9).
|
|
22
|
+
*/
|
|
18
23
|
formatDate?: (iso: string) => string;
|
|
19
24
|
labels?: LabelOverride<TourCatalogueLabels>;
|
|
20
25
|
className?: string;
|
|
@@ -44,6 +49,7 @@ export interface TourCatalogueProps extends TourCatalogueFilter {
|
|
|
44
49
|
onReset?: (id: string) => void;
|
|
45
50
|
/** Above the filter bar: the app's page header with its points tile (§6.1). */
|
|
46
51
|
header?: ReactNode;
|
|
52
|
+
/** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */
|
|
47
53
|
formatDate?: (iso: string) => string;
|
|
48
54
|
/** The search's locale (case folding). Default: the provider's. */
|
|
49
55
|
locale?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tour/tour-catalogue.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { Circle, CircleCheck, Clock, Play } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Caption } from \"../components/text\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { SearchField } from \"../components/search-field\";\nimport { Select } from \"../components/select\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from \"./tour-catalogue-labels\";\nimport { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from \"./tour-info\";\nimport type { TourCatalogueFilter, TourStateFilter } from \"./use-tour-url-sync\";\n\nexport { DEFAULT_TOUR_CATALOGUE_LABELS };\nexport type { TourCatalogueLabels };\n\n/* ── The card ───────────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueCardProps {\n tour: TourInfo;\n /** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */\n completedAt?: string | null;\n /** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */\n isNew?: boolean;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\". Without it, the link is not drawn. */\n onReset?: (id: string) => void;\n /** Formats the completion and update days. Default: the kit's medium date in the provider's locale. */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\n/**\n * One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to\n * bottom so the cards sit side by side: the state icon beside a column holding the tour's\n * icon, title, NEW (or \"Updated\", §8) and the app's `badge`, the one-liner and, from `sm`,\n * the longer account. The foot is pinned to the bottom edge, so every Start in a row of\n * cards lands on one line: the points chip, the step count and the duration, the\n * completion (or reset) line, \"Mark as not done\" and Start / Replay. Points, steps and\n * duration are each left out when the tour has none — no gap, no \"0 points\" (KS).\n */\nexport function TourCatalogueCard({\n tour,\n completedAt,\n isNew = false,\n onLaunch,\n onReset,\n formatDate: formatDateProp,\n labels: labelsProp,\n className,\n}: TourCatalogueCardProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const day = formatDateProp ?? ((iso: string) => formatDate(iso, \"medium\"));\n const titleId = useId();\n const done = isTourDone(tour, completedAt);\n const reset = isTourReset(tour, completedAt);\n const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;\n const note = done\n ? completedAt\n ? words.completedOn(day(completedAt))\n : words.completed\n : reset && tour.updated\n ? words.resetOn(day(tour.updated.date), tour.updated.reason)\n : null;\n\n return (\n <Card className={cn(\"flex h-full flex-col p-4\", className)}>\n {/* Title, one-liner and the long body share ONE left edge, in the column beside the\n state icon. */}\n <div className=\"flex flex-1 items-start gap-3\">\n <div className={cn(\"mt-0.5 shrink-0\", done ? \"text-success\" : \"text-muted\")}>\n {done ? <CircleCheck aria-hidden className=\"size-5\" /> : <Circle aria-hidden className=\"size-5\" />}\n <span className=\"sr-only\">{done ? words.completed : words.notCompleted}</span>\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {tour.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 text-brand [&_svg]:size-4\">\n {tour.icon}\n </span>\n )}\n {/* The id on the words, not the heading: the Start button is described by them. */}\n <h3 className=\"font-medium\">\n <span id={titleId}>{tour.title}</span>\n </h3>\n {/* It retires itself the moment the tour is taken — nothing to dismiss. */}\n {isNew && !reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeNew}\n </Chip>\n )}\n {reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeUpdated}\n </Chip>\n )}\n {tour.badge}\n </div>\n {tour.description && <p className=\"mt-1 text-sm text-muted\">{tour.description}</p>}\n {/* `hidden sm:block`: one card per row is the phone, where these paragraphs\n would turn the page into a scroll; the search still reads them. */}\n {tour.about && <p className=\"mt-2 hidden text-sm leading-relaxed text-secondary sm:block\">{tour.about}</p>}\n </div>\n </div>\n <div className=\"mt-3 flex flex-wrap items-end justify-between gap-2\">\n <div className=\"min-w-0\">\n {hasFacts && (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {tour.points != null && (\n <Chip size=\"sm\" tone=\"brand\">\n {words.points(tour.points)}\n </Chip>\n )}\n {tour.steps != null && <Caption as=\"span\">{words.steps(tour.steps)}</Caption>}\n {tour.duration != null && (\n <Caption as=\"span\" className=\"inline-flex items-center gap-1\">\n <Clock aria-hidden className=\"size-3\" />\n {words.duration(tour.duration)}\n </Caption>\n )}\n </div>\n )}\n {note && <p className={cn(\"text-xs text-muted\", hasFacts && \"mt-1\")}>{note}</p>}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {done && onReset && (\n <Button variant=\"link\" tone=\"muted\" size=\"sm\" aria-describedby={titleId} onClick={() => onReset(tour.id)}>\n {words.markUndone}\n </Button>\n )}\n <Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>\n <Play aria-hidden className=\"size-4\" /> {done ? words.replay : words.start}\n </Button>\n </div>\n </div>\n </Card>\n );\n}\n\n/* ── The page body ─────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueProps extends TourCatalogueFilter {\n /** The tours this person may take, already filtered by role and data (§6.3). */\n tours: readonly TourInfo[];\n /** The sections, in the app's order. A tour whose group is not here is not listed. */\n groups: readonly TourGroup[];\n completed: TourCompletions;\n /** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */\n joined?: string | null;\n /** A filter changed — `{query}`, `{group}`, `{state}`, or all three for \"Clear filters\". */\n onChange: (patch: Partial<TourCatalogueFilter>) => void;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\" — `useTourProgress().reset`. */\n onReset?: (id: string) => void;\n /** Above the filter bar: the app's page header with its points tile (§6.1). */\n header?: ReactNode;\n formatDate?: (iso: string) => string;\n /** The search's locale (case folding). Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\nconst STATE_FILTERS: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:\n *\n * - **The filter bar**: a search, a group select and an all / to do / done toggle,\n * stacked below `lg` and side by side from it. Each count is what the OTHER filters\n * leave: the toggle counts the search and the group, each group the search and the\n * state. Under it, while a filter is on, \"Showing X of Y tours\".\n * - **The sections**: one per group, in the app's order — an h2 with its icon and help\n * line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is\n * hidden; when nothing is left, an empty state with \"Clear filters\".\n * - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its\n * URL (`useTourCatalogueUrlState`).\n *\n * A tour reset by an update (§8) is \"to do\" for the toggle and the counts.\n */\nexport function TourCatalogue({\n tours,\n groups,\n completed,\n joined,\n query,\n group: groupProp,\n state: stateProp,\n onChange,\n onLaunch,\n onReset,\n header,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: TourCatalogueProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const uid = useId();\n const { locale: kitLocale } = useKitFormat();\n const locale = localeProp ?? kitLocale;\n // A group or state the list does not have is no filter at all (an old link).\n const group = groups.some((g) => g.id === groupProp) ? groupProp : null;\n const state = STATE_FILTERS.includes(stateProp) ? stateProp : \"all\";\n\n const listed = tours.filter((t) => groups.some((g) => g.id === t.group));\n const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);\n const needle = foldForSearch(query.trim(), locale);\n const matchesQuery = (tour: TourInfo) =>\n !needle || [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));\n const inState = (tour: TourInfo) => (state === \"all\" ? true : state === \"done\" ? done(tour) : !done(tour));\n const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;\n\n const found = listed.filter((t) => matchesQuery(t) && inGroup(t));\n const foundDone = found.filter(done).length;\n const visible = found.filter(inState);\n const groupCount = (id: string | null) => listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;\n const filtering = needle.length > 0 || state !== \"all\" || group !== null;\n const sections = groups\n .map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))\n .filter((g) => g.tours.length > 0);\n\n return (\n <div className={cn(\"space-y-4\", className)}>\n {header}\n <div className=\"flex flex-col gap-2 lg:flex-row lg:items-center\">\n <SearchField\n className=\"lg:max-w-xs lg:flex-1\"\n value={query}\n onChange={(value) => onChange({ query: value })}\n aria-label={words.searchLabel}\n placeholder={words.searchPlaceholder}\n />\n {/* A select, not a chip per group: on a phone those wrap into rows above the list\n they filter, and an app may have ten groups (ka F8). Named, not labelled: a\n floating label makes it taller than the search box beside it (dev #589). */}\n <Select\n className=\"lg:w-60\"\n aria-label={words.groupLabel}\n value={group ?? \"\"}\n onChange={(event) => onChange({ group: event.target.value || null })}\n >\n <option value=\"\">{words.groupAll(groupCount(null))}</option>\n {groups.map((g) => (\n <option key={g.id} value={g.id}>\n {words.groupOption(g.title, groupCount(g.id))}\n </option>\n ))}\n </Select>\n <ToggleGroup\n value={state}\n onChange={(value: TourStateFilter) => onChange({ state: value })}\n aria-label={words.stateLabel}\n className=\"lg:w-auto\"\n options={[\n { value: \"all\", label: words.stateAll(found.length) },\n { value: \"todo\", label: words.stateTodo(found.length - foundDone) },\n { value: \"done\", label: words.stateDone(foundDone) },\n ]}\n />\n </div>\n {filtering && (\n <p role=\"status\" className=\"-mt-2 text-xs text-muted\">\n {words.showing(visible.length, listed.length)}\n </p>\n )}\n\n {visible.length === 0 ? (\n <div className=\"space-y-3\">\n <EmptyState title={words.noMatches} hint={words.noMatchesHint} />\n <div className=\"flex justify-center\">\n <Button variant=\"secondary\" onClick={() => onChange({ query: \"\", group: null, state: \"all\" })}>\n {words.clearFilters}\n </Button>\n </div>\n </div>\n ) : (\n // A GRID, never columns: a section holding one card keeps a cell's width.\n sections.map((section) => (\n <section key={section.id} aria-labelledby={`${uid}-${section.id}`} className=\"space-y-3\">\n <h2 id={`${uid}-${section.id}`} className=\"flex items-center gap-1.5 text-sm font-semibold text-muted\">\n {section.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n {section.icon}\n </span>\n )}\n {section.title}\n </h2>\n {section.help && <p className=\"-mt-2 text-xs text-muted\">{section.help}</p>}\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {section.tours.map((tour) => (\n <TourCatalogueCard\n key={tour.id}\n tour={tour}\n completedAt={completed[tour.id]}\n isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}\n onLaunch={onLaunch}\n onReset={onReset}\n formatDate={formatDate}\n labels={labelsProp}\n />\n ))}\n </div>\n </section>\n ))\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/tour/tour-catalogue.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { Circle, CircleCheck, Clock, Play } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Caption } from \"../components/text\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { SearchField } from \"../components/search-field\";\nimport { Select } from \"../components/select\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from \"./tour-catalogue-labels\";\nimport { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from \"./tour-info\";\nimport type { TourCatalogueFilter, TourStateFilter } from \"./use-tour-url-sync\";\n\nexport { DEFAULT_TOUR_CATALOGUE_LABELS };\nexport type { TourCatalogueLabels };\n\n/* ── The card ───────────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueCardProps {\n tour: TourInfo;\n /** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */\n completedAt?: string | null;\n /** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */\n isNew?: boolean;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\". Without it, the link is not drawn. */\n onReset?: (id: string) => void;\n /**\n * Formats the completion and update days. Default: the kit's medium date in the provider's locale.\n * It gets the completion's ISO instant and the update's bare `\"YYYY-MM-DD\"` (`updated.date`): read\n * the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,\n * which is UTC midnight and the day before west of UTC (round 0.45, N9).\n */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\n/**\n * One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to\n * bottom so the cards sit side by side: the state icon beside a column holding the tour's\n * icon, title, NEW (or \"Updated\", §8) and the app's `badge`, the one-liner and, from `sm`,\n * the longer account. The foot is pinned to the bottom edge, so every Start in a row of\n * cards lands on one line: the points chip, the step count and the duration, the\n * completion (or reset) line, \"Mark as not done\" and Start / Replay. Points, steps and\n * duration are each left out when the tour has none — no gap, no \"0 points\" (KS).\n */\nexport function TourCatalogueCard({\n tour,\n completedAt,\n isNew = false,\n onLaunch,\n onReset,\n formatDate: formatDateProp,\n labels: labelsProp,\n className,\n}: TourCatalogueCardProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const day = formatDateProp ?? ((iso: string) => formatDate(iso, \"medium\"));\n const titleId = useId();\n const done = isTourDone(tour, completedAt);\n const reset = isTourReset(tour, completedAt);\n const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;\n const note = done\n ? completedAt\n ? words.completedOn(day(completedAt))\n : words.completed\n : reset && tour.updated\n ? words.resetOn(day(tour.updated.date), tour.updated.reason)\n : null;\n\n return (\n <Card className={cn(\"flex h-full flex-col p-4\", className)}>\n {/* Title, one-liner and the long body share ONE left edge, in the column beside the\n state icon. */}\n <div className=\"flex flex-1 items-start gap-3\">\n <div className={cn(\"mt-0.5 shrink-0\", done ? \"text-success\" : \"text-muted\")}>\n {done ? <CircleCheck aria-hidden className=\"size-5\" /> : <Circle aria-hidden className=\"size-5\" />}\n <span className=\"sr-only\">{done ? words.completed : words.notCompleted}</span>\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {tour.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 text-brand [&_svg]:size-4\">\n {tour.icon}\n </span>\n )}\n {/* The id on the words, not the heading: the Start button is described by them. */}\n <h3 className=\"font-medium\">\n <span id={titleId}>{tour.title}</span>\n </h3>\n {/* It retires itself the moment the tour is taken — nothing to dismiss. */}\n {isNew && !reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeNew}\n </Chip>\n )}\n {reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeUpdated}\n </Chip>\n )}\n {tour.badge}\n </div>\n {tour.description && <p className=\"mt-1 text-sm text-muted\">{tour.description}</p>}\n {/* `hidden sm:block`: one card per row is the phone, where these paragraphs\n would turn the page into a scroll; the search still reads them. */}\n {tour.about && <p className=\"mt-2 hidden text-sm leading-relaxed text-secondary sm:block\">{tour.about}</p>}\n </div>\n </div>\n <div className=\"mt-3 flex flex-wrap items-end justify-between gap-2\">\n <div className=\"min-w-0\">\n {hasFacts && (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {tour.points != null && (\n <Chip size=\"sm\" tone=\"brand\">\n {words.points(tour.points)}\n </Chip>\n )}\n {tour.steps != null && <Caption as=\"span\">{words.steps(tour.steps)}</Caption>}\n {tour.duration != null && (\n <Caption as=\"span\" className=\"inline-flex items-center gap-1\">\n <Clock aria-hidden className=\"size-3\" />\n {words.duration(tour.duration)}\n </Caption>\n )}\n </div>\n )}\n {note && <p className={cn(\"text-xs text-muted\", hasFacts && \"mt-1\")}>{note}</p>}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {done && onReset && (\n <Button variant=\"link\" tone=\"muted\" size=\"sm\" aria-describedby={titleId} onClick={() => onReset(tour.id)}>\n {words.markUndone}\n </Button>\n )}\n <Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>\n <Play aria-hidden className=\"size-4\" /> {done ? words.replay : words.start}\n </Button>\n </div>\n </div>\n </Card>\n );\n}\n\n/* ── The page body ─────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueProps extends TourCatalogueFilter {\n /** The tours this person may take, already filtered by role and data (§6.3). */\n tours: readonly TourInfo[];\n /** The sections, in the app's order. A tour whose group is not here is not listed. */\n groups: readonly TourGroup[];\n completed: TourCompletions;\n /** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */\n joined?: string | null;\n /** A filter changed — `{query}`, `{group}`, `{state}`, or all three for \"Clear filters\". */\n onChange: (patch: Partial<TourCatalogueFilter>) => void;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\" — `useTourProgress().reset`. */\n onReset?: (id: string) => void;\n /** Above the filter bar: the app's page header with its points tile (§6.1). */\n header?: ReactNode;\n /** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */\n formatDate?: (iso: string) => string;\n /** The search's locale (case folding). Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\nconst STATE_FILTERS: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:\n *\n * - **The filter bar**: a search, a group select and an all / to do / done toggle,\n * stacked below `lg` and side by side from it. Each count is what the OTHER filters\n * leave: the toggle counts the search and the group, each group the search and the\n * state. Under it, while a filter is on, \"Showing X of Y tours\".\n * - **The sections**: one per group, in the app's order — an h2 with its icon and help\n * line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is\n * hidden; when nothing is left, an empty state with \"Clear filters\".\n * - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its\n * URL (`useTourCatalogueUrlState`).\n *\n * A tour reset by an update (§8) is \"to do\" for the toggle and the counts.\n */\nexport function TourCatalogue({\n tours,\n groups,\n completed,\n joined,\n query,\n group: groupProp,\n state: stateProp,\n onChange,\n onLaunch,\n onReset,\n header,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: TourCatalogueProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const uid = useId();\n const { locale: kitLocale } = useKitFormat();\n const locale = localeProp ?? kitLocale;\n // A group or state the list does not have is no filter at all (an old link).\n const group = groups.some((g) => g.id === groupProp) ? groupProp : null;\n const state = STATE_FILTERS.includes(stateProp) ? stateProp : \"all\";\n\n const listed = tours.filter((t) => groups.some((g) => g.id === t.group));\n const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);\n const needle = foldForSearch(query.trim(), locale);\n const matchesQuery = (tour: TourInfo) =>\n !needle || [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));\n const inState = (tour: TourInfo) => (state === \"all\" ? true : state === \"done\" ? done(tour) : !done(tour));\n const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;\n\n const found = listed.filter((t) => matchesQuery(t) && inGroup(t));\n const foundDone = found.filter(done).length;\n const visible = found.filter(inState);\n const groupCount = (id: string | null) => listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;\n const filtering = needle.length > 0 || state !== \"all\" || group !== null;\n const sections = groups\n .map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))\n .filter((g) => g.tours.length > 0);\n\n return (\n <div className={cn(\"space-y-4\", className)}>\n {header}\n <div className=\"flex flex-col gap-2 lg:flex-row lg:items-center\">\n <SearchField\n className=\"lg:max-w-xs lg:flex-1\"\n value={query}\n onChange={(value) => onChange({ query: value })}\n aria-label={words.searchLabel}\n placeholder={words.searchPlaceholder}\n />\n {/* A select, not a chip per group: on a phone those wrap into rows above the list\n they filter, and an app may have ten groups (ka F8). Named, not labelled: a\n floating label makes it taller than the search box beside it (dev #589). */}\n <Select\n className=\"lg:w-60\"\n aria-label={words.groupLabel}\n value={group ?? \"\"}\n onChange={(event) => onChange({ group: event.target.value || null })}\n >\n <option value=\"\">{words.groupAll(groupCount(null))}</option>\n {groups.map((g) => (\n <option key={g.id} value={g.id}>\n {words.groupOption(g.title, groupCount(g.id))}\n </option>\n ))}\n </Select>\n <ToggleGroup\n value={state}\n onChange={(value: TourStateFilter) => onChange({ state: value })}\n aria-label={words.stateLabel}\n className=\"lg:w-auto\"\n options={[\n { value: \"all\", label: words.stateAll(found.length) },\n { value: \"todo\", label: words.stateTodo(found.length - foundDone) },\n { value: \"done\", label: words.stateDone(foundDone) },\n ]}\n />\n </div>\n {filtering && (\n <p role=\"status\" className=\"-mt-2 text-xs text-muted\">\n {words.showing(visible.length, listed.length)}\n </p>\n )}\n\n {visible.length === 0 ? (\n <div className=\"space-y-3\">\n <EmptyState title={words.noMatches} hint={words.noMatchesHint} />\n <div className=\"flex justify-center\">\n <Button variant=\"secondary\" onClick={() => onChange({ query: \"\", group: null, state: \"all\" })}>\n {words.clearFilters}\n </Button>\n </div>\n </div>\n ) : (\n // A GRID, never columns: a section holding one card keeps a cell's width.\n sections.map((section) => (\n <section key={section.id} aria-labelledby={`${uid}-${section.id}`} className=\"space-y-3\">\n <h2 id={`${uid}-${section.id}`} className=\"flex items-center gap-1.5 text-sm font-semibold text-muted\">\n {section.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n {section.icon}\n </span>\n )}\n {section.title}\n </h2>\n {section.help && <p className=\"-mt-2 text-xs text-muted\">{section.help}</p>}\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {section.tours.map((tour) => (\n <TourCatalogueCard\n key={tour.id}\n tour={tour}\n completedAt={completed[tour.id]}\n isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}\n onLaunch={onLaunch}\n onReset={onReset}\n formatDate={formatDate}\n labels={labelsProp}\n />\n ))}\n </div>\n </section>\n ))\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/tour/tour-labels.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tour/tour-labels.ts"],"sourcesContent":["// The English words of ./tour, in a module with no component code: the English catalogue\n// (src/i18n/defaults.ts) imports them without the component, so `en` loads as one chunk\n// like the other languages (0.34, KS U8). ./tour re-exports them.\n\nimport type { TourLabels } from \"./tour\";\n\nexport const DEFAULT_TOUR_LABELS: TourLabels = {\n next: \"Next\",\n back: \"Back\",\n skip: \"Skip\",\n done: \"Done\",\n awaitClickHint: \"Click the highlighted element to continue\",\n step: (c, t) => `${c} / ${t}`,\n};\n"],"mappings":";AAMO,MAAM,sBAAkC;AAAA,EAC7C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,gBAAgB;AAAA,EAChB,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC;AAC7B;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/tour/tour-labels.ts"],"sourcesContent":["// The English words of ./tour, in a module with no component code: the English catalogue\n// (src/i18n/defaults.ts) imports them without the component, so `en` loads as one chunk\n// like the other languages (0.34, KS U8). ./tour re-exports them.\n\nimport type { TourLabels } from \"./tour\";\n\nexport const DEFAULT_TOUR_LABELS: TourLabels = {\n next: \"Next\",\n back: \"Back\",\n skip: \"Skip\",\n done: \"Done\",\n awaitClickHint: \"Click the highlighted element to continue\",\n awaitInputHint: \"Enter a value in the highlighted field to continue\",\n step: (c, t) => `${c} / ${t}`,\n};\n"],"mappings":";AAMO,MAAM,sBAAkC;AAAA,EAC7C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC;AAC7B;","names":[]}
|
package/dist/tour/tour.d.ts
CHANGED
|
@@ -30,6 +30,34 @@ export interface TourStep {
|
|
|
30
30
|
* first, then the tour steps forward on a microtask. The spotlight hole stays
|
|
31
31
|
* click-through to the target; the rest of the page does not. */
|
|
32
32
|
awaitClick?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The spotlighted control is for USING, not only for looking at (0.46, keksdose live
|
|
35
|
+
* #436): the layers it opens sit above the scrim and work. A plain step's overlay is
|
|
36
|
+
* `z-[60]`, over every kit layer, so a field whose number pad (`NumberPadSheet`), a
|
|
37
|
+
* select's menu or a popover opens at `z-50` opened UNDER the shields, where nothing
|
|
38
|
+
* could be tapped. Here the overlay drops to `z-[45]`: still over the top bar and the
|
|
39
|
+
* page (`z-40` and below, still dimmed and shielded), under every `z-50` layer the
|
|
40
|
+
* target opens — so an app's own layer wants `z-50` or more to clear it. Keys pressed
|
|
41
|
+
* in the target or in such a layer are theirs (Escape closes the pad, arrows walk a
|
|
42
|
+
* list); from the card the tour's keys work as on any step. The overlay is not
|
|
43
|
+
* `aria-modal` then, and carries `data-tour-interactive`.
|
|
44
|
+
*
|
|
45
|
+
* The card is in the same layer as the scrim, so an open pad or sheet may cover it
|
|
46
|
+
* until it closes. Requires `target`. A `HoverMenu` (`z-40`, the top bar's menus) stays
|
|
47
|
+
* under the scrim: point an `awaitClick` step at its trigger instead.
|
|
48
|
+
*/
|
|
49
|
+
interactive?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Advance once a value was ENTERED in the spotlighted control (0.46, keksdose live
|
|
52
|
+
* #436) — `awaitClick` for a field. Implies `interactive`. Counted when focus leaves
|
|
53
|
+
* the target (the number pad's Done, a Tab, a tap elsewhere), or Enter is pressed in
|
|
54
|
+
* it, and one of its fields (`input`, `textarea`, `select`: the target itself or
|
|
55
|
+
* inside it) then holds another value than when the step began; a native `change`
|
|
56
|
+
* (a select, a checkbox) counts at once. The card swaps Next for `awaitInputHint`,
|
|
57
|
+
* as for `awaitClick`. A picker whose value is not a field's (the kit's `Select`
|
|
58
|
+
* button) has nothing to compare: call `useTour().next()` from its `onChange`.
|
|
59
|
+
*/
|
|
60
|
+
awaitInput?: boolean;
|
|
33
61
|
/** Runs before the step is shown — e.g. navigate to a route, open a panel.
|
|
34
62
|
* May be async; the step waits for it, then locates the target. */
|
|
35
63
|
beforeStep?: () => void | Promise<void>;
|
|
@@ -41,6 +69,8 @@ export interface TourLabels {
|
|
|
41
69
|
done: string;
|
|
42
70
|
/** Shown in place of the Next button on an `awaitClick` step. */
|
|
43
71
|
awaitClickHint: string;
|
|
72
|
+
/** Shown in place of the Next button on an `awaitInput` step (0.46). */
|
|
73
|
+
awaitInputHint: string;
|
|
44
74
|
/** Step counter, e.g. (2, 7) => "2 / 7". */
|
|
45
75
|
step: (current: number, total: number) => string;
|
|
46
76
|
}
|
package/dist/tour/tour.js
CHANGED
|
@@ -17,7 +17,7 @@ import { useFocusTrap } from "../hooks/use-focus-trap.js";
|
|
|
17
17
|
import { sameRect } from "../hooks/use-anchored-rect.js";
|
|
18
18
|
import { dirOf, horizontalStep } from "../lib/direction.js";
|
|
19
19
|
import { DEFAULT_TOUR_LABELS } from "./tour-labels.js";
|
|
20
|
-
import { completeStored, rememberMigrate } from "./use-tour-progress.js";
|
|
20
|
+
import { completeStored, rememberAccount, rememberMigrate } from "./use-tour-progress.js";
|
|
21
21
|
const TourContext = createContext(null);
|
|
22
22
|
function useTour() {
|
|
23
23
|
const ctx = useContext(TourContext);
|
|
@@ -49,9 +49,11 @@ function TourProvider({
|
|
|
49
49
|
});
|
|
50
50
|
const storageKey = progress?.storageKey;
|
|
51
51
|
const migrate = progress?.migrate;
|
|
52
|
+
const account = progress?.account;
|
|
52
53
|
useLayoutEffect(() => {
|
|
53
54
|
if (storageKey !== void 0) rememberMigrate(storageKey, migrate);
|
|
54
|
-
|
|
55
|
+
if (storageKey !== void 0) rememberAccount(storageKey, account);
|
|
56
|
+
}, [storageKey, migrate, account]);
|
|
55
57
|
const warnedNoTourRef = useRef(false);
|
|
56
58
|
const stepsRef = useRef(null);
|
|
57
59
|
const indexRef = useRef(0);
|
|
@@ -223,14 +225,51 @@ function TourOverlay({
|
|
|
223
225
|
el.addEventListener("click", onClick, { once: true });
|
|
224
226
|
return () => el.removeEventListener("click", onClick);
|
|
225
227
|
}, [ready, spotted, index, step, onNext]);
|
|
228
|
+
const interactive = spotted && !!step.target && !!(step.interactive || step.awaitInput);
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!ready || !spotted || !step.awaitInput || !step.target) return;
|
|
231
|
+
const el = queryVisibleTarget(step.target);
|
|
232
|
+
if (!el) return;
|
|
233
|
+
const read = () => fieldValues(el);
|
|
234
|
+
const before = read();
|
|
235
|
+
let timer = 0;
|
|
236
|
+
let done = false;
|
|
237
|
+
const check = () => {
|
|
238
|
+
window.clearTimeout(timer);
|
|
239
|
+
timer = window.setTimeout(() => {
|
|
240
|
+
if (done || read() === before) return;
|
|
241
|
+
done = true;
|
|
242
|
+
onNext();
|
|
243
|
+
}, 0);
|
|
244
|
+
};
|
|
245
|
+
const onFocusOut = (e) => {
|
|
246
|
+
if (e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) return;
|
|
247
|
+
check();
|
|
248
|
+
};
|
|
249
|
+
const onKeyDown = (e) => {
|
|
250
|
+
if (e.key === "Enter") check();
|
|
251
|
+
};
|
|
252
|
+
el.addEventListener("focusout", onFocusOut);
|
|
253
|
+
el.addEventListener("keydown", onKeyDown);
|
|
254
|
+
el.addEventListener("change", check);
|
|
255
|
+
return () => {
|
|
256
|
+
window.clearTimeout(timer);
|
|
257
|
+
el.removeEventListener("focusout", onFocusOut);
|
|
258
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
259
|
+
el.removeEventListener("change", check);
|
|
260
|
+
};
|
|
261
|
+
}, [ready, spotted, index, step, onNext]);
|
|
226
262
|
useEffect(() => {
|
|
227
263
|
const onKey = (e) => {
|
|
264
|
+
if (interactive && e.target instanceof Node && e.target !== document.body && !rootRef.current?.contains(e.target)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
228
267
|
if (e.key === "Escape") {
|
|
229
268
|
onSkip();
|
|
230
269
|
return;
|
|
231
270
|
}
|
|
232
271
|
if (e.key === "Enter") {
|
|
233
|
-
if (step.awaitClick || ownsKey(e.target, "Enter")) return;
|
|
272
|
+
if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
|
|
234
273
|
e.preventDefault();
|
|
235
274
|
onNext();
|
|
236
275
|
return;
|
|
@@ -246,7 +285,7 @@ function TourOverlay({
|
|
|
246
285
|
};
|
|
247
286
|
document.addEventListener("keydown", onKey);
|
|
248
287
|
return () => document.removeEventListener("keydown", onKey);
|
|
249
|
-
}, [isFirst, step, onNext, onPrev, onSkip]);
|
|
288
|
+
}, [isFirst, step, interactive, onNext, onPrev, onSkip]);
|
|
250
289
|
if (!ready) return null;
|
|
251
290
|
const pad = step.padding ?? 8;
|
|
252
291
|
const spot = rect ? { top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 } : null;
|
|
@@ -255,9 +294,10 @@ function TourOverlay({
|
|
|
255
294
|
"div",
|
|
256
295
|
{
|
|
257
296
|
ref: rootRef,
|
|
258
|
-
className: "pointer-events-none fixed inset-0 z-[60]",
|
|
297
|
+
className: cn("pointer-events-none fixed inset-0", interactive ? "z-[45]" : "z-[60]"),
|
|
259
298
|
role: "dialog",
|
|
260
|
-
"aria-modal": "true",
|
|
299
|
+
"aria-modal": interactive ? void 0 : "true",
|
|
300
|
+
"data-tour-interactive": interactive ? "" : void 0,
|
|
261
301
|
"aria-label": step.title,
|
|
262
302
|
dir,
|
|
263
303
|
children: [
|
|
@@ -316,6 +356,12 @@ function TourOverlay({
|
|
|
316
356
|
document.body
|
|
317
357
|
);
|
|
318
358
|
}
|
|
359
|
+
function fieldValues(el) {
|
|
360
|
+
const fields = el.matches("input,textarea,select") ? [el] : Array.from(el.querySelectorAll("input,textarea,select"));
|
|
361
|
+
return fields.map(
|
|
362
|
+
(f) => f instanceof HTMLInputElement && (f.type === "checkbox" || f.type === "radio") ? String(f.checked) : f.value
|
|
363
|
+
).join("\0");
|
|
364
|
+
}
|
|
319
365
|
function ownsKey(target, key) {
|
|
320
366
|
if (!(target instanceof HTMLElement)) return false;
|
|
321
367
|
if (target.isContentEditable) return true;
|
|
@@ -433,10 +479,10 @@ function TourCard({
|
|
|
433
479
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
434
480
|
/* @__PURE__ */ jsx("span", { className: "whitespace-nowrap text-xs tabular-nums text-placeholder", children: labels.step(index + 1, total) }),
|
|
435
481
|
!isFirst && /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: onBack, className: "px-2.5 py-1 text-xs", children: labels.back }),
|
|
436
|
-
!(step.awaitClick && step.target && rect) && /* @__PURE__ */ jsx(Button, { variant: "brand", onClick: onNext, className: cn("px-2.5 py-1 text-xs"), children: isLast ? labels.done : labels.next })
|
|
482
|
+
!((step.awaitClick || step.awaitInput) && step.target && rect) && /* @__PURE__ */ jsx(Button, { variant: "brand", onClick: onNext, className: cn("px-2.5 py-1 text-xs"), children: isLast ? labels.done : labels.next })
|
|
437
483
|
] })
|
|
438
484
|
] }),
|
|
439
|
-
step.awaitClick && step.target && rect && /* @__PURE__ */ jsx("div", { className: "text-center text-xs italic text-placeholder", children: labels.awaitClickHint })
|
|
485
|
+
(step.awaitClick || step.awaitInput) && step.target && rect && /* @__PURE__ */ jsx("div", { className: "text-center text-xs italic text-placeholder", children: step.awaitInput && !step.awaitClick ? labels.awaitInputHint : labels.awaitClickHint })
|
|
440
486
|
] })
|
|
441
487
|
]
|
|
442
488
|
}
|