@12-apps/ui 1.5.2 → 1.6.1
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/package.json +4 -4
- package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
- package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
- package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
- package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
- package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
- package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
- package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
- package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
- package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
- package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
- package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
- package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
- package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
- package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
- package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
- package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
- package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
- package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
- package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
- package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
- package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
- package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
- package/src/components/data-display/DataViews/data-views-types.ts +88 -9
- package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
- package/src/components/data-display/DataViews/index.ts +14 -1
- package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
- package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
- package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
- package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
- package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
- package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
- package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
- package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
- package/src/components/utility/InstallPrompt/index.ts +8 -0
- package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
|
@@ -98,6 +98,58 @@ function SelectionCluster({
|
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The BROWSING half of the toolbar: search + filters on the left, the page's
|
|
103
|
+
* own controls on the right. Split out of {@link ContentToolbar} so that
|
|
104
|
+
* function stays inside the complexity gate — it is layout only, and the
|
|
105
|
+
* decision of whether to show it at all is made by the caller.
|
|
106
|
+
*/
|
|
107
|
+
function BrowsingClusters({
|
|
108
|
+
leadingControls,
|
|
109
|
+
rightControls,
|
|
110
|
+
hasSelection,
|
|
111
|
+
edgeAlign,
|
|
112
|
+
}: {
|
|
113
|
+
leadingControls?: React.ReactNode;
|
|
114
|
+
rightControls: React.ReactNode;
|
|
115
|
+
hasSelection: boolean;
|
|
116
|
+
edgeAlign: boolean;
|
|
117
|
+
}): React.JSX.Element {
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
{/* minWidth:0 lets this cluster be the one that gives up width as the row
|
|
121
|
+
* tightens, which is what the filter overflow measures against. */}
|
|
122
|
+
{leadingControls !== undefined && (
|
|
123
|
+
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 1, minWidth: 0 }}>
|
|
124
|
+
{leadingControls}
|
|
125
|
+
</Box>
|
|
126
|
+
)}
|
|
127
|
+
{/* ml:auto keeps the controls right-aligned even when the cluster wraps to
|
|
128
|
+
* its own row (where justify-content:space-between would otherwise snap a
|
|
129
|
+
* lone item to the left); it's a no-op on a shared row. */}
|
|
130
|
+
<Box
|
|
131
|
+
sx={{
|
|
132
|
+
display: 'flex',
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
// Same single-line rule as the row itself: this cluster wrapping
|
|
135
|
+
// INTERNALLY buys a second line just as surely as the row wrapping
|
|
136
|
+
// does. It bit at exactly 900px, the `md` breakpoint where the "N de
|
|
137
|
+
// N" counter appears and pushes Exibir/Exportar over the edge.
|
|
138
|
+
flexWrap: leadingControls === undefined ? 'wrap' : 'nowrap',
|
|
139
|
+
// Tight (5px) when browsing so the controls fit one line beside the
|
|
140
|
+
// checkbox; roomier (14px) in selection mode where they get their own row.
|
|
141
|
+
gap: hasSelection ? '14px' : '5px',
|
|
142
|
+
ml: 'auto',
|
|
143
|
+
mr: edgeAlign ? -1.5 : 0,
|
|
144
|
+
'& > *': { flexShrink: 0 },
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{rightControls}
|
|
148
|
+
</Box>
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
101
153
|
/**
|
|
102
154
|
* Shared toolbar for content pages (Favorites, Personal Space, Recents, …). The
|
|
103
155
|
* left cluster owns selection chrome — **Select All**, and once items are
|
|
@@ -133,52 +185,56 @@ export function ContentToolbar({
|
|
|
133
185
|
selectAll,
|
|
134
186
|
clearSelection,
|
|
135
187
|
rightControls,
|
|
188
|
+
leadingControls,
|
|
136
189
|
actions,
|
|
137
190
|
selectAllTestId,
|
|
138
191
|
clearAllTestId,
|
|
139
192
|
edgeAlign = false,
|
|
193
|
+
exclusiveSelection = false,
|
|
140
194
|
}: ContentToolbarProps): React.JSX.Element {
|
|
195
|
+
// Under `exclusiveSelection` the two states are alternatives, not neighbours:
|
|
196
|
+
// browsing shows the controls and no checkbox, selecting shows the checkbox
|
|
197
|
+
// and no controls. Anything else double-books the row.
|
|
198
|
+
const showSelection = !exclusiveSelection || hasSelection;
|
|
199
|
+
const showControls = !exclusiveSelection || !hasSelection;
|
|
141
200
|
return (
|
|
142
|
-
//
|
|
143
|
-
//
|
|
201
|
+
// Two clusters on one line. They may wrap onto separate rows ONLY for a
|
|
202
|
+
// toolbar without `leadingControls` — one carrying search + filters is
|
|
203
|
+
// driven by a measured collapse ladder (see `useFilterOverflow`), and a
|
|
204
|
+
// wrap there is the ladder having failed: it would silently buy a second
|
|
205
|
+
// row instead of shedding a control into "Mais", which is the one outcome
|
|
206
|
+
// the ladder exists to prevent.
|
|
144
207
|
<Box
|
|
145
208
|
sx={{
|
|
146
209
|
display: 'flex',
|
|
147
210
|
alignItems: 'center',
|
|
148
211
|
justifyContent: 'space-between',
|
|
149
212
|
gap: 2,
|
|
150
|
-
flexWrap: 'wrap',
|
|
213
|
+
flexWrap: leadingControls === undefined ? 'wrap' : 'nowrap',
|
|
151
214
|
rowGap: 1,
|
|
152
215
|
}}
|
|
153
216
|
>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
217
|
+
{showSelection && (
|
|
218
|
+
<SelectionCluster
|
|
219
|
+
hasSelection={hasSelection}
|
|
220
|
+
selectedCount={selectedCount}
|
|
221
|
+
selectAll={selectAll}
|
|
222
|
+
clearSelection={clearSelection}
|
|
223
|
+
actions={actions}
|
|
224
|
+
selectAllTestId={selectAllTestId}
|
|
225
|
+
clearAllTestId={clearAllTestId}
|
|
226
|
+
edgeAlign={edgeAlign}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
164
229
|
|
|
165
|
-
{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Tight (5px) when browsing so the controls fit one line beside the
|
|
174
|
-
// checkbox; roomier (14px) in selection mode where they get their own row.
|
|
175
|
-
gap: hasSelection ? '14px' : '5px',
|
|
176
|
-
ml: 'auto',
|
|
177
|
-
mr: edgeAlign ? -1.5 : 0,
|
|
178
|
-
}}
|
|
179
|
-
>
|
|
180
|
-
{rightControls}
|
|
181
|
-
</Box>
|
|
230
|
+
{showControls && (
|
|
231
|
+
<BrowsingClusters
|
|
232
|
+
leadingControls={leadingControls}
|
|
233
|
+
rightControls={rightControls}
|
|
234
|
+
hasSelection={hasSelection}
|
|
235
|
+
edgeAlign={edgeAlign}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
182
238
|
</Box>
|
|
183
239
|
);
|
|
184
240
|
}
|
|
@@ -14,6 +14,22 @@ export interface ContentToolbarProps {
|
|
|
14
14
|
clearSelection: () => void;
|
|
15
15
|
/** Page-specific right-aligned controls (ViewSelector, SortByDropdown, …). */
|
|
16
16
|
rightControls: ReactNode;
|
|
17
|
+
/**
|
|
18
|
+
* Controls that share the toolbar's own line, left-aligned after the
|
|
19
|
+
* selection cluster — the search box and the filter controls, which belong on
|
|
20
|
+
* the toolbar rather than on a row of their own. Left unset the toolbar keeps
|
|
21
|
+
* its two-cluster shape.
|
|
22
|
+
*/
|
|
23
|
+
leadingControls?: ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* Selection OWNS the toolbar line instead of sharing it. Nothing selected ⇒
|
|
26
|
+
* no Select All at all (the row is search + filters + controls); something
|
|
27
|
+
* selected ⇒ those give way entirely and only the selection cluster remains.
|
|
28
|
+
*
|
|
29
|
+
* Off by default: a toolbar that already shows Select All permanently keeps
|
|
30
|
+
* doing so, so this cannot move a control on a consumer that did not ask.
|
|
31
|
+
*/
|
|
32
|
+
exclusiveSelection?: boolean;
|
|
17
33
|
/**
|
|
18
34
|
* Selection actions slot, rendered after the count when items are selected
|
|
19
35
|
* (e.g. a "Delete" / "Send email" control). Kept generic — the consumer owns
|
|
@@ -134,6 +150,15 @@ export interface MultiSelectDropdownProps<TValue extends string = string> {
|
|
|
134
150
|
*/
|
|
135
151
|
layout?: 'inline' | 'stacked' | 'pill';
|
|
136
152
|
'data-testid'?: string;
|
|
153
|
+
/**
|
|
154
|
+
* Notified when the menu opens and closes.
|
|
155
|
+
*
|
|
156
|
+
* For a toolbar whose LAYOUT is measured: applying a filter changes this
|
|
157
|
+
* control's label and therefore its width, and re-measuring mid-interaction
|
|
158
|
+
* moves the row under the operator's cursor. A caller that measures uses this
|
|
159
|
+
* to hold still while the menu is open.
|
|
160
|
+
*/
|
|
161
|
+
onOpenChange?: (open: boolean) => void;
|
|
137
162
|
}
|
|
138
163
|
|
|
139
164
|
/* ── FilterTrigger ───────────────────────────────────────────────────────── */
|
|
@@ -67,6 +67,7 @@ export function MultiSelectDropdown<TValue extends string = string>({
|
|
|
67
67
|
noResultsLabel = 'No results',
|
|
68
68
|
layout = 'inline',
|
|
69
69
|
'data-testid': testId,
|
|
70
|
+
onOpenChange,
|
|
70
71
|
}: MultiSelectDropdownProps<TValue>): React.JSX.Element {
|
|
71
72
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
|
72
73
|
const [query, setQuery] = useState('');
|
|
@@ -76,6 +77,7 @@ export function MultiSelectDropdown<TValue extends string = string>({
|
|
|
76
77
|
const close = (): void => {
|
|
77
78
|
setAnchorEl(null);
|
|
78
79
|
setQuery('');
|
|
80
|
+
onOpenChange?.(false);
|
|
79
81
|
};
|
|
80
82
|
const triggerLabel = buildTriggerLabel(selected, options, allLabel);
|
|
81
83
|
// Auto-enable search for long lists (e.g. many categories) unless overridden.
|
|
@@ -83,7 +85,10 @@ export function MultiSelectDropdown<TValue extends string = string>({
|
|
|
83
85
|
const anyExtraChecked = extraOptions?.some((option) => option.checked) ?? false;
|
|
84
86
|
const clearDisabled = selected.size === 0 && !anyExtraChecked;
|
|
85
87
|
|
|
86
|
-
const openMenu = (event: React.MouseEvent<HTMLElement>): void =>
|
|
88
|
+
const openMenu = (event: React.MouseEvent<HTMLElement>): void => {
|
|
89
|
+
setAnchorEl(event.currentTarget);
|
|
90
|
+
onOpenChange?.(true);
|
|
91
|
+
};
|
|
87
92
|
const triggerProps = { triggerLabel, open, onOpen: openMenu, testId };
|
|
88
93
|
|
|
89
94
|
const renderTrigger = (): React.JSX.Element => {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { BeforeInstallPromptEvent, PwaInstallStash } from './InstallPrompt.types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Holds `beforeinstallprompt` from before the app exists.
|
|
5
|
+
*
|
|
6
|
+
* `usePwaInstall` cannot attach its own listener early enough, and no React
|
|
7
|
+
* hook can. Chromium fires the event once, during initial page load, the
|
|
8
|
+
* moment it has a manifest and a registered worker — which in a real app is
|
|
9
|
+
* before hydration, and often before the chunk containing the component has
|
|
10
|
+
* even been downloaded. A listener registered in an effect is not late by
|
|
11
|
+
* milliseconds; it can be late by entire navigations. Nothing calls
|
|
12
|
+
* `preventDefault()` in time, the browser keeps the event, and the page can
|
|
13
|
+
* never ask again.
|
|
14
|
+
*
|
|
15
|
+
* So the capture has to live outside React, and the host has to install it as
|
|
16
|
+
* the very first thing the document does. Two supported ways, in order of
|
|
17
|
+
* reliability:
|
|
18
|
+
*
|
|
19
|
+
* 1. An inline `<script>` in `<head>`, which beats a deferred module bundle:
|
|
20
|
+
*
|
|
21
|
+
* <script>
|
|
22
|
+
* window.addEventListener('beforeinstallprompt', function (e) {
|
|
23
|
+
* e.preventDefault();
|
|
24
|
+
* var s = (window.__pwaInstall = window.__pwaInstall || {});
|
|
25
|
+
* s.event = e; s.firedAt = Date.now();
|
|
26
|
+
* window.dispatchEvent(new Event('pwa-install-available'));
|
|
27
|
+
* });
|
|
28
|
+
* </script>
|
|
29
|
+
*
|
|
30
|
+
* 2. `capturePwaInstallEvent()` as the first statement of the entry module,
|
|
31
|
+
* before `createRoot`. Simpler to wire and correct in most apps, but a
|
|
32
|
+
* module is still deferred, so a very early event can outrun it.
|
|
33
|
+
*
|
|
34
|
+
* Both write the same shape, so the hook does not care which was used.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** Property on `window` holding the captured event. Shared with the inline snippet. */
|
|
38
|
+
export const PWA_INSTALL_STASH_KEY = '__pwaInstall';
|
|
39
|
+
|
|
40
|
+
/** Fired after a capture, so an app already running can adopt the event. */
|
|
41
|
+
export const PWA_INSTALL_AVAILABLE_EVENT = 'pwa-install-available';
|
|
42
|
+
|
|
43
|
+
type StashHost = Window & { [PWA_INSTALL_STASH_KEY]?: PwaInstallStash };
|
|
44
|
+
|
|
45
|
+
/** The stash, or `null` when no capture ever ran (SSR, or a host that skipped wiring). */
|
|
46
|
+
export const readPwaInstallStash = (): PwaInstallStash | null => {
|
|
47
|
+
if (typeof window === 'undefined') return null;
|
|
48
|
+
return (window as StashHost)[PWA_INSTALL_STASH_KEY] ?? null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ensureStash = (): PwaInstallStash => {
|
|
52
|
+
const host = window as StashHost;
|
|
53
|
+
host[PWA_INSTALL_STASH_KEY] ??= { event: null, firedAt: null, installedAt: null };
|
|
54
|
+
return host[PWA_INSTALL_STASH_KEY];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The teardown for the capture currently running, so `resetPwaInstallStash`
|
|
59
|
+
* can unwind completely. Held here rather than on the stash because it must
|
|
60
|
+
* survive the stash being deleted, and because it must never be serialised
|
|
61
|
+
* into the diagnostic payload.
|
|
62
|
+
*/
|
|
63
|
+
let activeTeardown: (() => void) | null = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Starts capturing. Returns a teardown, and is safe to call twice — a second
|
|
67
|
+
* call is a no-op rather than a duplicate listener, so a host that wires both
|
|
68
|
+
* the inline snippet and this function does not double-handle the event.
|
|
69
|
+
*/
|
|
70
|
+
export const capturePwaInstallEvent = (): (() => void) => {
|
|
71
|
+
if (typeof window === 'undefined') return () => undefined;
|
|
72
|
+
|
|
73
|
+
const stash = ensureStash();
|
|
74
|
+
if (stash.capturing === true) return () => undefined;
|
|
75
|
+
stash.capturing = true;
|
|
76
|
+
|
|
77
|
+
const onBeforeInstall = (event: Event): void => {
|
|
78
|
+
// The line the whole component depends on. Without it the browser owns the
|
|
79
|
+
// event, shows at most its own mini-infobar, and discards the handle.
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
stash.event = event as BeforeInstallPromptEvent;
|
|
82
|
+
stash.firedAt = Date.now();
|
|
83
|
+
window.dispatchEvent(new Event(PWA_INSTALL_AVAILABLE_EVENT));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const onInstalled = (): void => {
|
|
87
|
+
stash.installedAt = Date.now();
|
|
88
|
+
stash.event = null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
window.addEventListener('beforeinstallprompt', onBeforeInstall);
|
|
92
|
+
window.addEventListener('appinstalled', onInstalled);
|
|
93
|
+
|
|
94
|
+
const teardown = () => {
|
|
95
|
+
window.removeEventListener('beforeinstallprompt', onBeforeInstall);
|
|
96
|
+
window.removeEventListener('appinstalled', onInstalled);
|
|
97
|
+
stash.capturing = false;
|
|
98
|
+
if (activeTeardown === teardown) activeTeardown = null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
activeTeardown = teardown;
|
|
102
|
+
return teardown;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Returns the page to its pre-capture state: listeners removed, stash gone.
|
|
107
|
+
*
|
|
108
|
+
* Exists for tests and stories, which need a clean page per case. It unwinds
|
|
109
|
+
* the listeners as well as the data, because a leaked capture from a previous
|
|
110
|
+
* case would go on suppressing events that the next case expects to observe.
|
|
111
|
+
*/
|
|
112
|
+
export const resetPwaInstallStash = (): void => {
|
|
113
|
+
if (typeof window === 'undefined') return;
|
|
114
|
+
activeTeardown?.();
|
|
115
|
+
activeTeardown = null;
|
|
116
|
+
delete (window as StashHost)[PWA_INSTALL_STASH_KEY];
|
|
117
|
+
};
|
|
@@ -85,6 +85,54 @@ raised from a timer or an effect.
|
|
|
85
85
|
All copy defaults to English and every string is a prop — this package is
|
|
86
86
|
locale-neutral and the consuming app supplies its own wording.
|
|
87
87
|
|
|
88
|
+
## Wiring the capture
|
|
89
|
+
|
|
90
|
+
**Required on Chromium.** Without it this component can never offer an install,
|
|
91
|
+
and it will fail silently — no error, no warning, just an affordance that never
|
|
92
|
+
appears.
|
|
93
|
+
|
|
94
|
+
Chromium fires `beforeinstallprompt` once, during page load, as soon as it has a
|
|
95
|
+
manifest and a registered worker. That is before hydration, and in a code-split
|
|
96
|
+
app it can be before the chunk holding this component has been downloaded at
|
|
97
|
+
all. No React hook can attach a listener in time, and the event is not reissued
|
|
98
|
+
on request. So the capture has to run outside React, and first.
|
|
99
|
+
|
|
100
|
+
Paste this into `<head>`, above the app bundle. A classic inline script is the
|
|
101
|
+
only thing guaranteed to beat a deferred module:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<script>
|
|
105
|
+
(function () {
|
|
106
|
+
var stash = (window.__pwaInstall = { event: null, firedAt: null, installedAt: null });
|
|
107
|
+
window.addEventListener('beforeinstallprompt', function (event) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
stash.event = event;
|
|
110
|
+
stash.firedAt = Date.now();
|
|
111
|
+
window.dispatchEvent(new Event('pwa-install-available'));
|
|
112
|
+
});
|
|
113
|
+
window.addEventListener('appinstalled', function () {
|
|
114
|
+
stash.installedAt = Date.now();
|
|
115
|
+
stash.event = null;
|
|
116
|
+
});
|
|
117
|
+
})();
|
|
118
|
+
</script>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or call `capturePwaInstallEvent()` as the first statement of your entry module,
|
|
122
|
+
before `createRoot`. Simpler to wire, but a module is still deferred, so a very
|
|
123
|
+
early event can outrun it. Doing both is safe — the capture is idempotent.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { capturePwaInstallEvent } from '@12-apps/ui/utility/InstallPrompt';
|
|
127
|
+
|
|
128
|
+
capturePwaInstallEvent();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`readPwaInstallStash()` exposes what was captured (`event`, `firedAt`,
|
|
132
|
+
`installedAt`), which is worth logging when the prompt does not appear:
|
|
133
|
+
`firedAt` set with no visible affordance means something downstream suppressed
|
|
134
|
+
it, whereas an absent stash means the wiring above is missing.
|
|
135
|
+
|
|
88
136
|
## Hook API
|
|
89
137
|
|
|
90
138
|
`usePwaInstall(options)` returns:
|
|
@@ -107,7 +155,7 @@ locale-neutral and the consuming app supplies its own wording.
|
|
|
107
155
|
- **Storage failures are swallowed.** `localStorage` throws rather than returning
|
|
108
156
|
`null` in Safari private mode and wherever cookies are blocked; an uncaught throw
|
|
109
157
|
would break the page on exactly the browsers this component serves.
|
|
110
|
-
- **A missed
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
158
|
+
- **A missed event does NOT recover.** Chromium fires `beforeinstallprompt` once
|
|
159
|
+
and will not reissue it on request, so a listener that attaches on mount has
|
|
160
|
+
already lost. This is why "Wiring the capture" above is a requirement rather
|
|
161
|
+
than an optimisation.
|
|
@@ -15,6 +15,25 @@ export interface BeforeInstallPromptEvent extends Event {
|
|
|
15
15
|
prompt: () => Promise<void>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What the pre-React capture left on `window` for the hook to pick up.
|
|
20
|
+
*
|
|
21
|
+
* This is the handoff between code that runs before the app exists and code
|
|
22
|
+
* that runs inside it. Written either by `capturePwaInstallEvent()` or by the
|
|
23
|
+
* equivalent inline snippet in the host's HTML, and read on mount — the event
|
|
24
|
+
* has almost always fired long before any component is alive to hear it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PwaInstallStash {
|
|
27
|
+
/** The held event, or `null` if none has fired or it was already spent. */
|
|
28
|
+
event: BeforeInstallPromptEvent | null;
|
|
29
|
+
/** When it was captured. Diagnostic: proves the capture ran and the browser offered. */
|
|
30
|
+
firedAt: number | null;
|
|
31
|
+
/** When `appinstalled` was seen, if it arrived before the app mounted. */
|
|
32
|
+
installedAt: number | null;
|
|
33
|
+
/** Guards against a host wiring both the inline snippet and the function. */
|
|
34
|
+
capturing?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
/**
|
|
19
38
|
* How — or whether — this browser can install the app.
|
|
20
39
|
*
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
export { InstallPrompt } from './InstallPrompt';
|
|
2
2
|
export { usePwaInstall } from './usePwaInstall';
|
|
3
|
+
export {
|
|
4
|
+
capturePwaInstallEvent,
|
|
5
|
+
readPwaInstallStash,
|
|
6
|
+
resetPwaInstallStash,
|
|
7
|
+
PWA_INSTALL_AVAILABLE_EVENT,
|
|
8
|
+
PWA_INSTALL_STASH_KEY,
|
|
9
|
+
} from './InstallPrompt.earlyCapture';
|
|
3
10
|
export type {
|
|
4
11
|
BeforeInstallPromptEvent,
|
|
5
12
|
InstallOutcome,
|
|
6
13
|
InstallPlatform,
|
|
7
14
|
InstallPromptProps,
|
|
8
15
|
PwaInstallState,
|
|
16
|
+
PwaInstallStash,
|
|
9
17
|
UsePwaInstallOptions,
|
|
10
18
|
} from './InstallPrompt.types';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
+
import { PWA_INSTALL_AVAILABLE_EVENT, readPwaInstallStash } from './InstallPrompt.earlyCapture';
|
|
3
4
|
import {
|
|
4
5
|
DEFAULT_DISMISS_DAYS,
|
|
5
6
|
DEFAULT_STORAGE_KEY,
|
|
@@ -50,7 +51,9 @@ const useInstalledState = (onInstalled?: () => void) => {
|
|
|
50
51
|
}, [onInstalled]);
|
|
51
52
|
|
|
52
53
|
useEffect(() => {
|
|
53
|
-
|
|
54
|
+
// `appinstalled` can also land before the app mounts, so the stash is
|
|
55
|
+
// consulted for the same reason the deferred event is.
|
|
56
|
+
setIsInstalled(isStandaloneDisplay() || readPwaInstallStash()?.installedAt != null);
|
|
54
57
|
|
|
55
58
|
const markInstalled = () => {
|
|
56
59
|
setIsInstalled(true);
|
|
@@ -65,29 +68,54 @@ const useInstalledState = (onInstalled?: () => void) => {
|
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
/**
|
|
68
|
-
*
|
|
71
|
+
* Holds the deferred `beforeinstallprompt` event.
|
|
69
72
|
*
|
|
70
|
-
* The
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* The stash written by `capturePwaInstallEvent` is the PRIMARY source, not a
|
|
74
|
+
* fallback. Chromium fires the event once, during initial page load; a
|
|
75
|
+
* listener attached in an effect runs after hydration and, when the component
|
|
76
|
+
* is code-split, after a download that may not happen until a later route. In
|
|
77
|
+
* a real browser it essentially never catches the event, and the event is not
|
|
78
|
+
* reissued on request — so a hook that relies on its own listener reports
|
|
79
|
+
* "cannot install" forever on the one platform that can.
|
|
80
|
+
*
|
|
81
|
+
* The live listeners below therefore cover only the narrow case they can
|
|
82
|
+
* actually serve: a browser re-evaluating installability while the app is
|
|
83
|
+
* already running, and a host that never wired the capture at all.
|
|
75
84
|
*/
|
|
76
85
|
const useDeferredPrompt = () => {
|
|
77
86
|
const deferredRef = useRef<BeforeInstallPromptEvent | null>(null);
|
|
78
87
|
const [promptReady, setPromptReady] = useState(false);
|
|
79
88
|
|
|
80
89
|
useEffect(() => {
|
|
90
|
+
const hold = (event: BeforeInstallPromptEvent) => {
|
|
91
|
+
deferredRef.current = event;
|
|
92
|
+
setPromptReady(true);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Whatever fired before this component existed.
|
|
96
|
+
const held = readPwaInstallStash()?.event;
|
|
97
|
+
if (held) hold(held);
|
|
98
|
+
|
|
99
|
+
// The capture rang the bell after mount: adopt what it is holding.
|
|
100
|
+
const adopt = () => {
|
|
101
|
+
const fresh = readPwaInstallStash()?.event;
|
|
102
|
+
if (fresh) hold(fresh);
|
|
103
|
+
};
|
|
104
|
+
|
|
81
105
|
const capture = (event: Event) => {
|
|
82
106
|
// Without preventDefault the browser owns the event and the handle dies
|
|
83
|
-
// with it
|
|
107
|
+
// with it. The capture has normally done this already; repeating it is
|
|
108
|
+
// harmless and keeps the hook correct on a host that skipped wiring.
|
|
84
109
|
event.preventDefault();
|
|
85
|
-
|
|
86
|
-
setPromptReady(true);
|
|
110
|
+
hold(event as BeforeInstallPromptEvent);
|
|
87
111
|
};
|
|
88
112
|
|
|
113
|
+
window.addEventListener(PWA_INSTALL_AVAILABLE_EVENT, adopt);
|
|
89
114
|
window.addEventListener('beforeinstallprompt', capture);
|
|
90
|
-
return () =>
|
|
115
|
+
return () => {
|
|
116
|
+
window.removeEventListener(PWA_INSTALL_AVAILABLE_EVENT, adopt);
|
|
117
|
+
window.removeEventListener('beforeinstallprompt', capture);
|
|
118
|
+
};
|
|
91
119
|
}, []);
|
|
92
120
|
|
|
93
121
|
const clearPrompt = useCallback(() => {
|