@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,93 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { liveQuery } from "dexie";
|
|
3
|
+
import type { SyncStatus } from "../shell/sync-status-indicator";
|
|
4
|
+
import type { SyncDb } from "./db";
|
|
5
|
+
import type { SyncEngine, SyncEngineState } from "./engine";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keep `engine` running while it is mounted: started on mount, stopped on unmount or when
|
|
9
|
+
* the engine changes (a company switch builds a new engine on its own database, D15).
|
|
10
|
+
* `null` runs nothing — signed out, or a computer marked shared. Mount once, in the
|
|
11
|
+
* signed-in layout (kastlan's `useHandoverSyncLoop`).
|
|
12
|
+
*/
|
|
13
|
+
export function useSyncWorker(engine: SyncEngine | null): void {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!engine) return;
|
|
16
|
+
engine.start();
|
|
17
|
+
return () => engine.stop();
|
|
18
|
+
}, [engine]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const IDLE: SyncEngineState = {
|
|
22
|
+
phase: "idle",
|
|
23
|
+
online: true,
|
|
24
|
+
counts: { pending: 0, waiting: 0, blocked: 0, rejected: 0 },
|
|
25
|
+
lastSyncedAt: null,
|
|
26
|
+
nextRetryAt: null,
|
|
27
|
+
};
|
|
28
|
+
const noEngine = () => () => undefined;
|
|
29
|
+
const idle = () => IDLE;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The engine's state as the kit's `SyncStatusIndicator` reads it (§2.3): `pending` = queued
|
|
33
|
+
* and in flight (and the steps' pending), `waitingForPlan` = `waiting`, `blocked` = `blocked`
|
|
34
|
+
* + `rejected`, `lastSyncedAt` from `meta`. `onSync` is "Sync now". Without an engine, an
|
|
35
|
+
* idle status that follows the browser's connection.
|
|
36
|
+
*/
|
|
37
|
+
export function useSyncStatus(engine: SyncEngine | null): { status: SyncStatus; onSync: () => Promise<void> } {
|
|
38
|
+
const state = useSyncExternalStore(
|
|
39
|
+
engine ? engine.subscribe : noEngine,
|
|
40
|
+
engine ? engine.getState : idle,
|
|
41
|
+
engine ? engine.getState : idle,
|
|
42
|
+
);
|
|
43
|
+
const browserOnline = useSyncExternalStore(subscribeOnline, readOnline, () => true);
|
|
44
|
+
const onSync = useCallback(async () => {
|
|
45
|
+
await engine?.syncNow();
|
|
46
|
+
}, [engine]);
|
|
47
|
+
const status: SyncStatus = {
|
|
48
|
+
phase: state.phase,
|
|
49
|
+
online: engine ? state.online : browserOnline,
|
|
50
|
+
pending: state.counts.pending,
|
|
51
|
+
waitingForPlan: state.counts.waiting,
|
|
52
|
+
blocked: state.counts.blocked + state.counts.rejected,
|
|
53
|
+
lastSyncedAt: state.lastSyncedAt,
|
|
54
|
+
};
|
|
55
|
+
return { status, onSync };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function subscribeOnline(listener: () => void) {
|
|
59
|
+
window.addEventListener("online", listener);
|
|
60
|
+
window.addEventListener("offline", listener);
|
|
61
|
+
return () => {
|
|
62
|
+
window.removeEventListener("online", listener);
|
|
63
|
+
window.removeEventListener("offline", listener);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const readOnline = () => navigator.onLine !== false;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A read of the copy that follows every change to it (Dexie's `liveQuery`; kastlan's
|
|
70
|
+
* `useLive`). `deps` names what `query` reads; `query` itself is rebuilt every render.
|
|
71
|
+
* `query` gets the app's own database type (`HandoverDb`). Without a database (not open
|
|
72
|
+
* yet) it answers `isLoading: true`, so a page shows no "empty" before the copy opens.
|
|
73
|
+
*/
|
|
74
|
+
export function useSyncQuery<T, D extends SyncDb = SyncDb>(
|
|
75
|
+
db: D | null,
|
|
76
|
+
query: (db: D) => Promise<T>,
|
|
77
|
+
deps: readonly unknown[],
|
|
78
|
+
): { data: T | undefined; isLoading: boolean } {
|
|
79
|
+
const [state, setState] = useState<{ data: T | undefined; isLoading: boolean }>({ data: undefined, isLoading: true });
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!db) return;
|
|
82
|
+
const subscription = liveQuery(() => query(db)).subscribe({
|
|
83
|
+
next: (data) => setState({ data, isLoading: false }),
|
|
84
|
+
error: () => setState({ data: undefined, isLoading: false }),
|
|
85
|
+
});
|
|
86
|
+
return () => subscription.unsubscribe();
|
|
87
|
+
// `query` is rebuilt every render; `deps` names what it reads.
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [db, ...deps]);
|
|
90
|
+
return db ? state : NO_COPY;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const NO_COPY = { data: undefined, isLoading: true } as const;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { SyncRequestBody, SyncResponseBody } from "./wire";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* `@eifi1/ui-kit/sync/testing` — a scripted `/sync` server for an app's tests of the engine
|
|
5
|
+
* (docs/local-first-harmonization.md §2.3). Pass `transport.send` as the engine's `send`.
|
|
6
|
+
* Imports nothing (no Dexie): it is the engine's `send`, not a store.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One scripted answer: a body, a function of the request, or a failure. */
|
|
10
|
+
export type FakeSyncAnswer =
|
|
11
|
+
| SyncResponseBody
|
|
12
|
+
| ((body: SyncRequestBody) => SyncResponseBody | Promise<SyncResponseBody>)
|
|
13
|
+
| { fail: "offline" }
|
|
14
|
+
| { fail: number; data?: unknown };
|
|
15
|
+
|
|
16
|
+
export interface FakeSyncTransport {
|
|
17
|
+
/** The engine's `send`. */
|
|
18
|
+
send(body: SyncRequestBody, signal?: AbortSignal): Promise<SyncResponseBody>;
|
|
19
|
+
/** Every request received, in order (the body as sent). */
|
|
20
|
+
readonly requests: SyncRequestBody[];
|
|
21
|
+
/** Queue answers for the next requests, in order; past them the default answers. */
|
|
22
|
+
answer(...answers: FakeSyncAnswer[]): void;
|
|
23
|
+
/** The next request meets no connection (an axios-like error with no response). */
|
|
24
|
+
failOffline(): void;
|
|
25
|
+
/** The next request answers `status` (a 5xx, a 402 `{code}`, a 403). */
|
|
26
|
+
failStatus(status: number, data?: unknown): void;
|
|
27
|
+
/** The default answer's server knowledge, raised by one for every request. */
|
|
28
|
+
knowledge: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The error an axios client throws: `isAxiosError`, and `response` only with an answer. */
|
|
32
|
+
function wireError(fail: "offline" | number, data?: unknown): Error {
|
|
33
|
+
const err = new Error(fail === "offline" ? "Network Error" : `Request failed with status code ${fail}`);
|
|
34
|
+
return Object.assign(err, {
|
|
35
|
+
isAxiosError: true,
|
|
36
|
+
response: fail === "offline" ? undefined : { status: fail, data: data ?? {} },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The default answer echoes every pushed change as applied, with a server id (`pk`, 100 on),
|
|
42
|
+
* and an empty pull besides; `knowledge` goes up by one each request. Script anything else
|
|
43
|
+
* with `answer(...)`, `failOffline()` and `failStatus(...)`.
|
|
44
|
+
*/
|
|
45
|
+
export function createFakeSyncTransport(): FakeSyncTransport {
|
|
46
|
+
const script: FakeSyncAnswer[] = [];
|
|
47
|
+
let nextPk = 100;
|
|
48
|
+
const transport: FakeSyncTransport = {
|
|
49
|
+
requests: [],
|
|
50
|
+
knowledge: 0,
|
|
51
|
+
answer: (...answers) => void script.push(...answers),
|
|
52
|
+
failOffline: () => void script.push({ fail: "offline" }),
|
|
53
|
+
failStatus: (status, data) => void script.push({ fail: status, data }),
|
|
54
|
+
async send(body) {
|
|
55
|
+
transport.requests.push(structuredClone(body));
|
|
56
|
+
const next = script.shift();
|
|
57
|
+
if (next && typeof next === "object" && "fail" in next) {
|
|
58
|
+
throw wireError(next.fail, "data" in next ? next.data : undefined);
|
|
59
|
+
}
|
|
60
|
+
if (typeof next === "function") return next(body);
|
|
61
|
+
if (next) return next;
|
|
62
|
+
transport.knowledge += 1;
|
|
63
|
+
return {
|
|
64
|
+
server_knowledge: transport.knowledge,
|
|
65
|
+
changes: body.changes.map((change) => ({ ...change, pk: change.op === "delete" ? null : nextPk++ })),
|
|
66
|
+
rejections: [],
|
|
67
|
+
refused: null,
|
|
68
|
+
page_cursor: null,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
return transport;
|
|
73
|
+
}
|
package/src/sync/wire.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The `/sync` wire as the engine speaks it: server-kit's `eifi1_server_kit.sync` shapes
|
|
3
|
+
* (SyncRequest, SyncResponse, SyncRefusal, SyncRejection, SyncPageCursor; keksdose's, which
|
|
4
|
+
* are canon, D10), plus kastlan's `pk` on a pulled change. Types only.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One row's delta on the wire. */
|
|
8
|
+
export interface SyncWireChange {
|
|
9
|
+
entity: string;
|
|
10
|
+
id: string;
|
|
11
|
+
op: "upsert" | "delete";
|
|
12
|
+
data?: Record<string, unknown> | null;
|
|
13
|
+
/** The device's edit time on a push (D2). */
|
|
14
|
+
updated_at?: string | null;
|
|
15
|
+
/** The server's id of the row, on a pull (kastlan). */
|
|
16
|
+
pk?: number | null;
|
|
17
|
+
knowledge?: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Where a paged pull stopped (server-kit `SyncPageCursor`); handed back as it came. */
|
|
21
|
+
export interface SyncPageCursor {
|
|
22
|
+
entity: string;
|
|
23
|
+
knowledge?: number | null;
|
|
24
|
+
id?: string | null;
|
|
25
|
+
window_end: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SyncRequestBody {
|
|
29
|
+
scope: string;
|
|
30
|
+
last_knowledge: number;
|
|
31
|
+
changes: SyncWireChange[];
|
|
32
|
+
/** This client keeps refused changes and reads `refused` and `rejections` (D1). */
|
|
33
|
+
refusals: true;
|
|
34
|
+
/** The device's clock as the body is built, fresh for every request (D2 amendment). */
|
|
35
|
+
client_now: string;
|
|
36
|
+
paged?: boolean;
|
|
37
|
+
page_cursor?: SyncPageCursor | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The WHOLE push refused (a lapsed plan): the pull is served all the same. */
|
|
41
|
+
export interface SyncRefusalBody {
|
|
42
|
+
code: string;
|
|
43
|
+
detail: string;
|
|
44
|
+
change_ids: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One pushed change the server did not apply, with its reason. */
|
|
48
|
+
export interface SyncRejectionBody {
|
|
49
|
+
change_id: string;
|
|
50
|
+
code: string;
|
|
51
|
+
detail: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SyncResponseBody {
|
|
55
|
+
server_knowledge: number;
|
|
56
|
+
changes: SyncWireChange[];
|
|
57
|
+
refused?: SyncRefusalBody | null;
|
|
58
|
+
rejections?: SyncRejectionBody[];
|
|
59
|
+
/** Set while the pull did not fit one page; `null` on the last page (D12). */
|
|
60
|
+
page_cursor?: SyncPageCursor | null;
|
|
61
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `@eifi1/ui-kit/sync` — local-first phase 3 (docs/local-first-harmonization.md §2.3, 0.46):
|
|
2
|
+
// the sync engine on a Dexie copy, its worker and status hooks, and the live read of the copy.
|
|
3
|
+
//
|
|
4
|
+
// The only entry point that imports `dexie` — an optional peer for that reason. It never loads
|
|
5
|
+
// `/offline`'s TanStack and idb-keyval peers: the leaf utilities both entries export live in
|
|
6
|
+
// one module of their own (src/offline/local-first.ts). NOT re-exported from the main barrel.
|
|
7
|
+
export { createSyncDb, SYNC_DB_STORES } from "./sync/db";
|
|
8
|
+
export type { CreateSyncDbOptions, OutboxChange, OutboxStatus, SyncDb, SyncDbUpgrade, SyncedRow, SyncMetaEntry } from "./sync/db";
|
|
9
|
+
export { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from "./sync/engine";
|
|
10
|
+
export type {
|
|
11
|
+
SyncCounts,
|
|
12
|
+
SyncEngine,
|
|
13
|
+
SyncEngineOptions,
|
|
14
|
+
SyncEngineState,
|
|
15
|
+
SyncRoundResult,
|
|
16
|
+
SyncStep,
|
|
17
|
+
SyncStepContext,
|
|
18
|
+
SyncStepResult,
|
|
19
|
+
} from "./sync/engine";
|
|
20
|
+
export { useSyncQuery, useSyncStatus, useSyncWorker } from "./sync/hooks";
|
|
21
|
+
export type {
|
|
22
|
+
SyncPageCursor,
|
|
23
|
+
SyncRefusalBody,
|
|
24
|
+
SyncRejectionBody,
|
|
25
|
+
SyncRequestBody,
|
|
26
|
+
SyncResponseBody,
|
|
27
|
+
SyncWireChange,
|
|
28
|
+
} from "./sync/wire";
|
|
29
|
+
export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first";
|
|
@@ -30,7 +30,12 @@ export interface TourCatalogueCardProps {
|
|
|
30
30
|
onLaunch: (id: string) => void;
|
|
31
31
|
/** "Mark as not done". Without it, the link is not drawn. */
|
|
32
32
|
onReset?: (id: string) => void;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Formats the completion and update days. Default: the kit's medium date in the provider's locale.
|
|
35
|
+
* It gets the completion's ISO instant and the update's bare `"YYYY-MM-DD"` (`updated.date`): read
|
|
36
|
+
* the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,
|
|
37
|
+
* which is UTC midnight and the day before west of UTC (round 0.45, N9).
|
|
38
|
+
*/
|
|
34
39
|
formatDate?: (iso: string) => string;
|
|
35
40
|
labels?: LabelOverride<TourCatalogueLabels>;
|
|
36
41
|
className?: string;
|
|
@@ -161,6 +166,7 @@ export interface TourCatalogueProps extends TourCatalogueFilter {
|
|
|
161
166
|
onReset?: (id: string) => void;
|
|
162
167
|
/** Above the filter bar: the app's page header with its points tile (§6.1). */
|
|
163
168
|
header?: ReactNode;
|
|
169
|
+
/** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */
|
|
164
170
|
formatDate?: (iso: string) => string;
|
|
165
171
|
/** The search's locale (case folding). Default: the provider's. */
|
|
166
172
|
locale?: string;
|
package/src/tour/tour-labels.ts
CHANGED
package/src/tour/tour.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import { dirOf, horizontalStep } from "../lib/direction";
|
|
|
18
18
|
import type { Direction } from "../lib/direction";
|
|
19
19
|
import { DEFAULT_TOUR_LABELS } from "./tour-labels";
|
|
20
20
|
import type { TourInfo } from "./tour-info";
|
|
21
|
-
import { completeStored, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
|
|
21
|
+
import { completeStored, rememberAccount, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* `start`/`end` follow the reading direction (`end` is the right side in LTR and the
|
|
@@ -48,6 +48,34 @@ export interface TourStep {
|
|
|
48
48
|
* first, then the tour steps forward on a microtask. The spotlight hole stays
|
|
49
49
|
* click-through to the target; the rest of the page does not. */
|
|
50
50
|
awaitClick?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The spotlighted control is for USING, not only for looking at (0.46, keksdose live
|
|
53
|
+
* #436): the layers it opens sit above the scrim and work. A plain step's overlay is
|
|
54
|
+
* `z-[60]`, over every kit layer, so a field whose number pad (`NumberPadSheet`), a
|
|
55
|
+
* select's menu or a popover opens at `z-50` opened UNDER the shields, where nothing
|
|
56
|
+
* could be tapped. Here the overlay drops to `z-[45]`: still over the top bar and the
|
|
57
|
+
* page (`z-40` and below, still dimmed and shielded), under every `z-50` layer the
|
|
58
|
+
* target opens — so an app's own layer wants `z-50` or more to clear it. Keys pressed
|
|
59
|
+
* in the target or in such a layer are theirs (Escape closes the pad, arrows walk a
|
|
60
|
+
* list); from the card the tour's keys work as on any step. The overlay is not
|
|
61
|
+
* `aria-modal` then, and carries `data-tour-interactive`.
|
|
62
|
+
*
|
|
63
|
+
* The card is in the same layer as the scrim, so an open pad or sheet may cover it
|
|
64
|
+
* until it closes. Requires `target`. A `HoverMenu` (`z-40`, the top bar's menus) stays
|
|
65
|
+
* under the scrim: point an `awaitClick` step at its trigger instead.
|
|
66
|
+
*/
|
|
67
|
+
interactive?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Advance once a value was ENTERED in the spotlighted control (0.46, keksdose live
|
|
70
|
+
* #436) — `awaitClick` for a field. Implies `interactive`. Counted when focus leaves
|
|
71
|
+
* the target (the number pad's Done, a Tab, a tap elsewhere), or Enter is pressed in
|
|
72
|
+
* it, and one of its fields (`input`, `textarea`, `select`: the target itself or
|
|
73
|
+
* inside it) then holds another value than when the step began; a native `change`
|
|
74
|
+
* (a select, a checkbox) counts at once. The card swaps Next for `awaitInputHint`,
|
|
75
|
+
* as for `awaitClick`. A picker whose value is not a field's (the kit's `Select`
|
|
76
|
+
* button) has nothing to compare: call `useTour().next()` from its `onChange`.
|
|
77
|
+
*/
|
|
78
|
+
awaitInput?: boolean;
|
|
51
79
|
/** Runs before the step is shown — e.g. navigate to a route, open a panel.
|
|
52
80
|
* May be async; the step waits for it, then locates the target. */
|
|
53
81
|
beforeStep?: () => void | Promise<void>;
|
|
@@ -60,6 +88,8 @@ export interface TourLabels {
|
|
|
60
88
|
done: string;
|
|
61
89
|
/** Shown in place of the Next button on an `awaitClick` step. */
|
|
62
90
|
awaitClickHint: string;
|
|
91
|
+
/** Shown in place of the Next button on an `awaitInput` step (0.46). */
|
|
92
|
+
awaitInputHint: string;
|
|
63
93
|
/** Step counter, e.g. (2, 7) => "2 / 7". */
|
|
64
94
|
step: (current: number, total: number) => string;
|
|
65
95
|
}
|
|
@@ -206,9 +236,12 @@ export function TourProvider({
|
|
|
206
236
|
// The provider's migrate becomes the key's, for readers that pass none (keksdose B-2).
|
|
207
237
|
const storageKey = progress?.storageKey;
|
|
208
238
|
const migrate = progress?.migrate;
|
|
239
|
+
// 0.45: and its `account` (§10.3), so a finish on an account key is sent as readers' are.
|
|
240
|
+
const account = progress?.account;
|
|
209
241
|
useLayoutEffect(() => {
|
|
210
242
|
if (storageKey !== undefined) rememberMigrate(storageKey, migrate);
|
|
211
|
-
|
|
243
|
+
if (storageKey !== undefined) rememberAccount(storageKey, account);
|
|
244
|
+
}, [storageKey, migrate, account]);
|
|
212
245
|
const warnedNoTourRef = useRef(false);
|
|
213
246
|
// Live mirrors so the step-control callbacks can stay stable (identity-safe for
|
|
214
247
|
// consumers) while still reading the current steps/index. Synced in a layout
|
|
@@ -476,6 +509,48 @@ function TourOverlay({
|
|
|
476
509
|
return () => el.removeEventListener("click", onClick);
|
|
477
510
|
}, [ready, spotted, index, step, onNext]);
|
|
478
511
|
|
|
512
|
+
// An interactive step (see `TourStep.interactive`): only while the target is lit — a
|
|
513
|
+
// step whose target is missing centres its card over a full scrim, modal as ever.
|
|
514
|
+
const interactive = spotted && !!step.target && !!(step.interactive || step.awaitInput);
|
|
515
|
+
|
|
516
|
+
// awaitInput: advance once the target's fields hold another value than when the step
|
|
517
|
+
// was armed, checked when focus leaves the target or Enter is pressed in it — on the
|
|
518
|
+
// next macrotask, after the field's own blur/Enter commit has rendered. A value the
|
|
519
|
+
// number pad typed fires no native `change`, so comparing is what catches it.
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
if (!ready || !spotted || !step.awaitInput || !step.target) return;
|
|
522
|
+
const el = queryVisibleTarget(step.target) as HTMLElement | null;
|
|
523
|
+
if (!el) return;
|
|
524
|
+
const read = () => fieldValues(el);
|
|
525
|
+
const before = read();
|
|
526
|
+
let timer = 0;
|
|
527
|
+
let done = false;
|
|
528
|
+
const check = () => {
|
|
529
|
+
window.clearTimeout(timer);
|
|
530
|
+
timer = window.setTimeout(() => {
|
|
531
|
+
if (done || read() === before) return;
|
|
532
|
+
done = true;
|
|
533
|
+
onNext();
|
|
534
|
+
}, 0);
|
|
535
|
+
};
|
|
536
|
+
const onFocusOut = (e: FocusEvent) => {
|
|
537
|
+
if (e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) return;
|
|
538
|
+
check();
|
|
539
|
+
};
|
|
540
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
541
|
+
if (e.key === "Enter") check();
|
|
542
|
+
};
|
|
543
|
+
el.addEventListener("focusout", onFocusOut);
|
|
544
|
+
el.addEventListener("keydown", onKeyDown);
|
|
545
|
+
el.addEventListener("change", check);
|
|
546
|
+
return () => {
|
|
547
|
+
window.clearTimeout(timer);
|
|
548
|
+
el.removeEventListener("focusout", onFocusOut);
|
|
549
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
550
|
+
el.removeEventListener("change", check);
|
|
551
|
+
};
|
|
552
|
+
}, [ready, spotted, index, step, onNext]);
|
|
553
|
+
|
|
479
554
|
// Keyboard: Esc skips, Enter and the reading-direction arrow advance (→ in LTR,
|
|
480
555
|
// ← in RTL), the other arrow goes back. On awaitClick steps Enter is ignored (it
|
|
481
556
|
// would fire the target's click AND advance) — the forward arrow stays as an escape
|
|
@@ -487,12 +562,18 @@ function TourOverlay({
|
|
|
487
562
|
// The same goes for arrows in a text field a step's `action` might contain.
|
|
488
563
|
useEffect(() => {
|
|
489
564
|
const onKey = (e: KeyboardEvent) => {
|
|
565
|
+
// On an interactive step a key pressed in the target, or in a layer it opened (the
|
|
566
|
+
// number pad's Escape, a menu's arrows), is the page's. The card's and the bare
|
|
567
|
+
// page's (focus on <body>) stay the tour's.
|
|
568
|
+
if (interactive && e.target instanceof Node && e.target !== document.body && !rootRef.current?.contains(e.target)) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
490
571
|
if (e.key === "Escape") {
|
|
491
572
|
onSkip();
|
|
492
573
|
return;
|
|
493
574
|
}
|
|
494
575
|
if (e.key === "Enter") {
|
|
495
|
-
if (step.awaitClick || ownsKey(e.target, "Enter")) return;
|
|
576
|
+
if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
|
|
496
577
|
e.preventDefault();
|
|
497
578
|
onNext();
|
|
498
579
|
return;
|
|
@@ -509,7 +590,7 @@ function TourOverlay({
|
|
|
509
590
|
};
|
|
510
591
|
document.addEventListener("keydown", onKey);
|
|
511
592
|
return () => document.removeEventListener("keydown", onKey);
|
|
512
|
-
}, [isFirst, step, onNext, onPrev, onSkip]);
|
|
593
|
+
}, [isFirst, step, interactive, onNext, onPrev, onSkip]);
|
|
513
594
|
|
|
514
595
|
if (!ready) return null;
|
|
515
596
|
|
|
@@ -529,9 +610,12 @@ function TourOverlay({
|
|
|
529
610
|
return createPortal(
|
|
530
611
|
<div
|
|
531
612
|
ref={rootRef}
|
|
532
|
-
|
|
613
|
+
// z-[45] on an interactive step: over the top bar (z-40), under the z-50 layers the
|
|
614
|
+
// target opens. See `TourStep.interactive`.
|
|
615
|
+
className={cn("pointer-events-none fixed inset-0", interactive ? "z-[45]" : "z-[60]")}
|
|
533
616
|
role="dialog"
|
|
534
|
-
aria-modal="true"
|
|
617
|
+
aria-modal={interactive ? undefined : "true"}
|
|
618
|
+
data-tour-interactive={interactive ? "" : undefined}
|
|
535
619
|
aria-label={step.title}
|
|
536
620
|
dir={dir}
|
|
537
621
|
>
|
|
@@ -587,6 +671,18 @@ function TourOverlay({
|
|
|
587
671
|
);
|
|
588
672
|
}
|
|
589
673
|
|
|
674
|
+
/** The values of the fields at or inside `el`, as one string to compare (`awaitInput`). */
|
|
675
|
+
function fieldValues(el: Element): string {
|
|
676
|
+
const fields = el.matches("input,textarea,select") ? [el] : Array.from(el.querySelectorAll("input,textarea,select"));
|
|
677
|
+
return fields
|
|
678
|
+
.map((f) =>
|
|
679
|
+
f instanceof HTMLInputElement && (f.type === "checkbox" || f.type === "radio")
|
|
680
|
+
? String(f.checked)
|
|
681
|
+
: (f as HTMLInputElement).value,
|
|
682
|
+
)
|
|
683
|
+
.join("\u0000");
|
|
684
|
+
}
|
|
685
|
+
|
|
590
686
|
/** Does the focused element handle `key` itself? Enter on a button or link, and any
|
|
591
687
|
* editing key in a text field or select, is the control's — not the tour's. */
|
|
592
688
|
function ownsKey(target: EventTarget | null, key: string): boolean {
|
|
@@ -789,16 +885,16 @@ function TourCard({
|
|
|
789
885
|
way forward on any step whose anchor happens not to be rendered
|
|
790
886
|
for this user (a guest sees no "add account" button), leaving the
|
|
791
887
|
undocumented ArrowRight key as the sole escape. */}
|
|
792
|
-
{!(step.awaitClick && step.target && rect) && (
|
|
888
|
+
{!((step.awaitClick || step.awaitInput) && step.target && rect) && (
|
|
793
889
|
<Button variant="brand" onClick={onNext} className={cn("px-2.5 py-1 text-xs")}>
|
|
794
890
|
{isLast ? labels.done : labels.next}
|
|
795
891
|
</Button>
|
|
796
892
|
)}
|
|
797
893
|
</div>
|
|
798
894
|
</div>
|
|
799
|
-
{step.awaitClick && step.target && rect && (
|
|
895
|
+
{(step.awaitClick || step.awaitInput) && step.target && rect && (
|
|
800
896
|
<div className="text-center text-xs italic text-placeholder">
|
|
801
|
-
{labels.awaitClickHint}
|
|
897
|
+
{step.awaitInput && !step.awaitClick ? labels.awaitInputHint : labels.awaitClickHint}
|
|
802
898
|
</div>
|
|
803
899
|
)}
|
|
804
900
|
</div>
|