@astronox/core 0.4.18 → 0.5.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/README.md +2 -2
- package/package.json +1 -1
- package/src/ads/adsense/AdsenseAdBlockingRecoveryHead.astro +30 -0
- package/src/ads/adsense/AdsenseHead.astro +16 -22
- package/src/ads/adsense/AdsenseMessagingBootstrap.astro +219 -0
- package/src/ads/adsense/AdsensePublisherRevocationStyle.astro +5 -0
- package/src/ads/contracts.ts +45 -0
- package/src/patterns/Table/tableFilterController.ts +23 -12
- package/src/patterns/Table/tableFilterSelection.ts +47 -0
- package/src/patterns/Table/tableProgressiveController.ts +25 -11
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ component labels can extend `defaultCoreMessages` through
|
|
|
63
63
|
claims, Firestore, and app-owned mappings.
|
|
64
64
|
- [`R2 publication`](docs/integrations/r2-publication.md): full provider
|
|
65
65
|
snapshots and build-time public projection.
|
|
66
|
-
- [`AdSense`](docs/integrations/adsense.md): typed configuration,
|
|
67
|
-
|
|
66
|
+
- [`AdSense`](docs/integrations/adsense.md): typed configuration, Privacy &
|
|
67
|
+
Messaging policy, annotation exclusions, and deployment.
|
|
68
68
|
- [`release workflow`](docs/release-workflow.md): local links, verification, and
|
|
69
69
|
consumer pinning order.
|
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
type Props = {
|
|
3
|
+
publisherId: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const { publisherId }: Props = Astro.props;
|
|
7
|
+
const scriptSource = `https://fundingchoicesmessages.google.com/i/${encodeURIComponent(publisherId)}?ers=1`;
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<script is:inline async src={scriptSource}></script>
|
|
11
|
+
<script is:inline>
|
|
12
|
+
(() => {
|
|
13
|
+
const signalGooglefcPresent = () => {
|
|
14
|
+
if (window.frames['googlefcPresent']) return;
|
|
15
|
+
|
|
16
|
+
if (!document.body) {
|
|
17
|
+
setTimeout(signalGooglefcPresent, 0);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const frame = document.createElement('iframe');
|
|
22
|
+
frame.name = 'googlefcPresent';
|
|
23
|
+
frame.style.cssText =
|
|
24
|
+
'display: none; width: 0; height: 0; border: none; z-index: -1000; left: -1000px; top: -1000px;';
|
|
25
|
+
document.body.appendChild(frame);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
signalGooglefcPresent();
|
|
29
|
+
})();
|
|
30
|
+
</script>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { AdsenseAdsConfig } from '../contracts';
|
|
3
|
+
import AdsenseAdBlockingRecoveryHead from './AdsenseAdBlockingRecoveryHead.astro';
|
|
4
|
+
import AdsenseMessagingBootstrap from './AdsenseMessagingBootstrap.astro';
|
|
3
5
|
import { toAdsenseClientId } from './provider';
|
|
4
6
|
|
|
5
7
|
type Props = {
|
|
@@ -9,30 +11,22 @@ type Props = {
|
|
|
9
11
|
const { config }: Props = Astro.props;
|
|
10
12
|
const clientId = toAdsenseClientId(config.publisherId);
|
|
11
13
|
const scriptSource = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(clientId)}`;
|
|
12
|
-
const
|
|
14
|
+
const adBlockingRecoveryEnabled = Boolean(
|
|
15
|
+
config.privacyMessaging?.adBlockingRecovery,
|
|
16
|
+
);
|
|
13
17
|
---
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const frame = document.createElement('iframe');
|
|
27
|
-
frame.name = 'googlefcPresent';
|
|
28
|
-
frame.style.cssText =
|
|
29
|
-
'display: none; width: 0; height: 0; border: none; z-index: -1000; left: -1000px; top: -1000px;';
|
|
30
|
-
document.body.appendChild(frame);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
signalGooglefcPresent();
|
|
34
|
-
})();
|
|
35
|
-
</script>
|
|
19
|
+
{
|
|
20
|
+
config.privacyMessaging ? (
|
|
21
|
+
<AdsenseMessagingBootstrap config={config.privacyMessaging} />
|
|
22
|
+
) : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
adBlockingRecoveryEnabled ? (
|
|
27
|
+
<AdsenseAdBlockingRecoveryHead publisherId={config.publisherId} />
|
|
28
|
+
) : null
|
|
29
|
+
}
|
|
36
30
|
|
|
37
31
|
<meta name="google-adsense-account" content={clientId} />
|
|
38
32
|
<script is:inline async crossorigin="anonymous" src={scriptSource}></script>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AdsensePrivacyMessagingConfig } from '../contracts';
|
|
3
|
+
import AdsensePublisherRevocationStyle from './AdsensePublisherRevocationStyle.astro';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
config: AdsensePrivacyMessagingConfig;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const { config }: Props = Astro.props;
|
|
10
|
+
const adBlockingRecovery = config.adBlockingRecovery;
|
|
11
|
+
const publisherLauncher = config.consentRevocation?.launcher === 'publisher';
|
|
12
|
+
const adBlockingEnabled = Boolean(adBlockingRecovery);
|
|
13
|
+
const adBlockingRedisplayIntervalMs =
|
|
14
|
+
adBlockingRecovery?.redisplayIntervalMs ?? 0;
|
|
15
|
+
const adBlockingStorageKey = adBlockingRecovery?.storageKey ?? '';
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
{publisherLauncher ? <AdsensePublisherRevocationStyle /> : null}
|
|
19
|
+
|
|
20
|
+
<script
|
|
21
|
+
is:inline
|
|
22
|
+
define:vars={{
|
|
23
|
+
adBlockingEnabled,
|
|
24
|
+
adBlockingRedisplayIntervalMs,
|
|
25
|
+
adBlockingStorageKey,
|
|
26
|
+
publisherLauncher,
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
(() => {
|
|
30
|
+
const bootstrapKey = Symbol.for('astronox.adsense.messaging');
|
|
31
|
+
if (window[bootstrapKey]) return;
|
|
32
|
+
window[bootstrapKey] = true;
|
|
33
|
+
|
|
34
|
+
const googlefc = (window.googlefc ??= {});
|
|
35
|
+
googlefc.callbackQueue ??= [];
|
|
36
|
+
|
|
37
|
+
if (adBlockingEnabled) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
let storageAvailable = true;
|
|
40
|
+
let storedTimestamp;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const storedValue = window.localStorage.getItem(adBlockingStorageKey);
|
|
44
|
+
const parsedTimestamp = Number(storedValue);
|
|
45
|
+
if (
|
|
46
|
+
storedValue !== null &&
|
|
47
|
+
Number.isFinite(parsedTimestamp) &&
|
|
48
|
+
parsedTimestamp > 0 &&
|
|
49
|
+
parsedTimestamp <= now
|
|
50
|
+
) {
|
|
51
|
+
storedTimestamp = parsedTimestamp;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
storageAvailable = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cooldownActive =
|
|
58
|
+
storedTimestamp !== undefined &&
|
|
59
|
+
now - storedTimestamp < adBlockingRedisplayIntervalMs;
|
|
60
|
+
let adBlockingAuthorized = false;
|
|
61
|
+
let blockerConfirmed = false;
|
|
62
|
+
let timestampPersisted = false;
|
|
63
|
+
|
|
64
|
+
const persistAuthorizationTimestamp = () => {
|
|
65
|
+
if (
|
|
66
|
+
cooldownActive ||
|
|
67
|
+
!storageAvailable ||
|
|
68
|
+
!adBlockingAuthorized ||
|
|
69
|
+
!blockerConfirmed ||
|
|
70
|
+
timestampPersisted
|
|
71
|
+
) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
window.localStorage.setItem(adBlockingStorageKey, String(Date.now()));
|
|
77
|
+
timestampPersisted = true;
|
|
78
|
+
} catch {
|
|
79
|
+
storageAvailable = false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
googlefc.controlledMessagingFunction = (message) => {
|
|
84
|
+
if (!message || typeof message.proceed !== 'function') return;
|
|
85
|
+
|
|
86
|
+
if (cooldownActive) {
|
|
87
|
+
const adBlockingType = googlefc.MessageTypeEnum?.AD_BLOCKING;
|
|
88
|
+
if (typeof adBlockingType === 'number') {
|
|
89
|
+
message.proceed(false, [adBlockingType]);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
message.proceed(true);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
adBlockingAuthorized = true;
|
|
98
|
+
message.proceed(true);
|
|
99
|
+
persistAuthorizationTimestamp();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
googlefc.callbackQueue.push({
|
|
103
|
+
AD_BLOCK_DATA_READY: () => {
|
|
104
|
+
if (
|
|
105
|
+
typeof googlefc.getAdBlockerStatus !== 'function' ||
|
|
106
|
+
!googlefc.AdBlockerStatusEnum
|
|
107
|
+
) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const status = googlefc.getAdBlockerStatus();
|
|
112
|
+
if (typeof status !== 'number' || !Number.isFinite(status)) return;
|
|
113
|
+
|
|
114
|
+
const statusEnum = googlefc.AdBlockerStatusEnum;
|
|
115
|
+
const blockerStatuses = new Set(
|
|
116
|
+
[
|
|
117
|
+
statusEnum.EXTENSION_AD_BLOCKER,
|
|
118
|
+
statusEnum.EXTENSION_LEVEL_AD_BLOCKER,
|
|
119
|
+
statusEnum.NETWORK_LEVEL_AD_BLOCKER,
|
|
120
|
+
].filter(
|
|
121
|
+
(value) => typeof value === 'number' && Number.isFinite(value),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (!blockerStatuses.has(status)) return;
|
|
126
|
+
|
|
127
|
+
blockerConfirmed = true;
|
|
128
|
+
persistAuthorizationTimestamp();
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!publisherLauncher) return;
|
|
134
|
+
|
|
135
|
+
const legacyLauncherSelector = '.google-revocation-link-placeholder';
|
|
136
|
+
const toolbarSelector = '#ft-floating-toolbar';
|
|
137
|
+
const hostObservers = new WeakMap();
|
|
138
|
+
|
|
139
|
+
const disconnectHostObserver = (host) => {
|
|
140
|
+
hostObservers.get(host)?.disconnect();
|
|
141
|
+
hostObservers.delete(host);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const suppressToolbarLauncher = (host, shadowRoot) => {
|
|
145
|
+
const toolbar = shadowRoot.querySelector(toolbarSelector);
|
|
146
|
+
if (!toolbar) return;
|
|
147
|
+
|
|
148
|
+
toolbar
|
|
149
|
+
.querySelectorAll('.ft-reg-button, #ft-reg-bubble')
|
|
150
|
+
.forEach((element) => element.remove());
|
|
151
|
+
|
|
152
|
+
const remainingToolbarAction = toolbar.querySelector(
|
|
153
|
+
'.ft-button-holder button, .ft-bottom-button-holder button',
|
|
154
|
+
);
|
|
155
|
+
if (remainingToolbarAction) return;
|
|
156
|
+
|
|
157
|
+
disconnectHostObserver(host);
|
|
158
|
+
host.remove();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const observeShadowRoot = (host) => {
|
|
162
|
+
const shadowRoot = host.shadowRoot;
|
|
163
|
+
if (!shadowRoot || hostObservers.has(host)) return;
|
|
164
|
+
|
|
165
|
+
suppressToolbarLauncher(host, shadowRoot);
|
|
166
|
+
if (!host.isConnected) return;
|
|
167
|
+
|
|
168
|
+
const observer = new MutationObserver(() => {
|
|
169
|
+
suppressToolbarLauncher(host, shadowRoot);
|
|
170
|
+
});
|
|
171
|
+
observer.observe(shadowRoot, { childList: true, subtree: true });
|
|
172
|
+
hostObservers.set(host, observer);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const inspectBodyChild = (element) => {
|
|
176
|
+
if (element.matches(legacyLauncherSelector)) {
|
|
177
|
+
element.remove();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
element
|
|
182
|
+
.querySelectorAll(legacyLauncherSelector)
|
|
183
|
+
.forEach((launcher) => launcher.remove());
|
|
184
|
+
observeShadowRoot(element);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const startLauncherSuppression = () => {
|
|
188
|
+
if (!document.body) return;
|
|
189
|
+
|
|
190
|
+
for (const child of [...document.body.children]) {
|
|
191
|
+
inspectBodyChild(child);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
new MutationObserver((records) => {
|
|
195
|
+
for (const record of records) {
|
|
196
|
+
for (const node of record.removedNodes) {
|
|
197
|
+
if (node instanceof Element) disconnectHostObserver(node);
|
|
198
|
+
}
|
|
199
|
+
for (const node of record.addedNodes) {
|
|
200
|
+
if (node instanceof Element) inspectBodyChild(node);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}).observe(document.body, { childList: true });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (document.body) {
|
|
207
|
+
startLauncherSuppression();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const documentObserver = new MutationObserver(() => {
|
|
212
|
+
if (!document.body) return;
|
|
213
|
+
|
|
214
|
+
documentObserver.disconnect();
|
|
215
|
+
startLauncherSuppression();
|
|
216
|
+
});
|
|
217
|
+
documentObserver.observe(document.documentElement, { childList: true });
|
|
218
|
+
})();
|
|
219
|
+
</script>
|
package/src/ads/contracts.ts
CHANGED
|
@@ -2,10 +2,25 @@ export type DisabledAdsConfig = {
|
|
|
2
2
|
enabled: false;
|
|
3
3
|
};
|
|
4
4
|
|
|
5
|
+
export type AdsenseAdBlockingRecoveryConfig = {
|
|
6
|
+
redisplayIntervalMs: number;
|
|
7
|
+
storageKey: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type AdsenseConsentRevocationConfig = {
|
|
11
|
+
launcher: 'google' | 'publisher';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AdsensePrivacyMessagingConfig = {
|
|
15
|
+
adBlockingRecovery?: AdsenseAdBlockingRecoveryConfig;
|
|
16
|
+
consentRevocation?: AdsenseConsentRevocationConfig;
|
|
17
|
+
};
|
|
18
|
+
|
|
5
19
|
export type AdsenseAdsConfig = {
|
|
6
20
|
enabled: true;
|
|
7
21
|
provider: 'adsense';
|
|
8
22
|
publisherId: string;
|
|
23
|
+
privacyMessaging?: AdsensePrivacyMessagingConfig;
|
|
9
24
|
};
|
|
10
25
|
|
|
11
26
|
export type AdsConfig = DisabledAdsConfig | AdsenseAdsConfig;
|
|
@@ -22,5 +37,35 @@ export function defineAdsConfig<const TConfig extends AdsConfig>(
|
|
|
22
37
|
if (!/^pub-\d+$/.test(config.publisherId)) {
|
|
23
38
|
throw new Error('Invalid AdSense publisher id');
|
|
24
39
|
}
|
|
40
|
+
|
|
41
|
+
const adBlockingRecovery = config.privacyMessaging?.adBlockingRecovery;
|
|
42
|
+
if (adBlockingRecovery) {
|
|
43
|
+
if (
|
|
44
|
+
!Number.isFinite(adBlockingRecovery.redisplayIntervalMs) ||
|
|
45
|
+
adBlockingRecovery.redisplayIntervalMs <= 0
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'AdSense ad blocking recovery redisplay interval must be positive',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
typeof adBlockingRecovery.storageKey !== 'string' ||
|
|
53
|
+
adBlockingRecovery.storageKey.trim().length === 0
|
|
54
|
+
) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'AdSense ad blocking recovery storage key must not be empty',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const consentLauncher = config.privacyMessaging?.consentRevocation?.launcher;
|
|
62
|
+
if (
|
|
63
|
+
consentLauncher !== undefined &&
|
|
64
|
+
consentLauncher !== 'google' &&
|
|
65
|
+
consentLauncher !== 'publisher'
|
|
66
|
+
) {
|
|
67
|
+
throw new Error('Invalid AdSense consent revocation launcher');
|
|
68
|
+
}
|
|
69
|
+
|
|
25
70
|
return config;
|
|
26
71
|
}
|
|
@@ -8,12 +8,15 @@ import {
|
|
|
8
8
|
hasTableProgressive,
|
|
9
9
|
setTableProgressiveFilters,
|
|
10
10
|
} from './tableProgressiveController';
|
|
11
|
+
import {
|
|
12
|
+
matchesFilterSelection,
|
|
13
|
+
normalizeFilterSelection,
|
|
14
|
+
type TableFilterValues,
|
|
15
|
+
} from './tableFilterSelection';
|
|
11
16
|
import { revealTableStart } from './tableViewport';
|
|
12
17
|
|
|
13
|
-
type FilterValues = Readonly<Record<string, readonly string[]>>;
|
|
14
|
-
|
|
15
18
|
type CachedFilterValues = {
|
|
16
|
-
values:
|
|
19
|
+
values: TableFilterValues;
|
|
17
20
|
normalizedSearch: readonly string[];
|
|
18
21
|
};
|
|
19
22
|
|
|
@@ -92,7 +95,7 @@ export function initTableFilters(root: HTMLElement): () => void {
|
|
|
92
95
|
.normalize('NFD')
|
|
93
96
|
.replace(/\p{Diacritic}/gu, '')
|
|
94
97
|
.toLocaleLowerCase(locale);
|
|
95
|
-
const selections = new Map<string, string>();
|
|
98
|
+
const selections = new Map<string, readonly string[]>();
|
|
96
99
|
const filterValuesCache = new WeakMap<HTMLElement, CachedFilterValues>();
|
|
97
100
|
const rows: HTMLElement[] = [];
|
|
98
101
|
let scheduledFrame: number | null = null;
|
|
@@ -118,7 +121,7 @@ export function initTableFilters(root: HTMLElement): () => void {
|
|
|
118
121
|
const applyFilters = (): void => {
|
|
119
122
|
const query = normalize(search?.value.trim() ?? '');
|
|
120
123
|
const activeSelections = [...selections.entries()].filter(
|
|
121
|
-
([,
|
|
124
|
+
([, values]) => values.length > 0,
|
|
122
125
|
);
|
|
123
126
|
const hasExternalFilter =
|
|
124
127
|
root.hasAttribute(externalFiltersAttribute) ||
|
|
@@ -144,8 +147,10 @@ export function initTableFilters(root: HTMLElement): () => void {
|
|
|
144
147
|
query.length === 0 ||
|
|
145
148
|
cached.normalizedSearch.some((value) => value.includes(query));
|
|
146
149
|
const matchesSelections = activeSelections.every(
|
|
147
|
-
([field,
|
|
148
|
-
|
|
150
|
+
([field, selectedValues]) => {
|
|
151
|
+
const rowValues = cached.values[field] ?? [];
|
|
152
|
+
return matchesFilterSelection(rowValues, selectedValues);
|
|
153
|
+
},
|
|
149
154
|
);
|
|
150
155
|
const visible =
|
|
151
156
|
matchesSearch &&
|
|
@@ -190,7 +195,9 @@ export function initTableFilters(root: HTMLElement): () => void {
|
|
|
190
195
|
|
|
191
196
|
const field = target.dataset.tableFilterField;
|
|
192
197
|
if (!field) return;
|
|
193
|
-
|
|
198
|
+
const values = normalizeFilterSelection(customEvent.detail.values);
|
|
199
|
+
if (values.length > 0) selections.set(field, values);
|
|
200
|
+
else selections.delete(field);
|
|
194
201
|
revealTableStart(table);
|
|
195
202
|
scheduleFilters();
|
|
196
203
|
};
|
|
@@ -215,8 +222,10 @@ export function initTableFilters(root: HTMLElement): () => void {
|
|
|
215
222
|
|
|
216
223
|
for (const field of fields) {
|
|
217
224
|
const fieldName = field.dataset.tableFilterField;
|
|
218
|
-
const
|
|
219
|
-
if (fieldName &&
|
|
225
|
+
const selectedValues = normalizeFilterSelection(getComboboxValues(field));
|
|
226
|
+
if (fieldName && selectedValues.length > 0) {
|
|
227
|
+
selections.set(fieldName, selectedValues);
|
|
228
|
+
}
|
|
220
229
|
}
|
|
221
230
|
registerRows(
|
|
222
231
|
Array.from(
|
|
@@ -248,12 +257,14 @@ export function initTableFilters(root: HTMLElement): () => void {
|
|
|
248
257
|
};
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
function parseFilterValues(value: string | undefined):
|
|
260
|
+
function parseFilterValues(value: string | undefined): TableFilterValues {
|
|
252
261
|
if (!value) return {};
|
|
253
262
|
|
|
254
263
|
try {
|
|
255
264
|
const parsed = JSON.parse(value);
|
|
256
|
-
return parsed && typeof parsed === 'object'
|
|
265
|
+
return parsed && typeof parsed === 'object'
|
|
266
|
+
? (parsed as TableFilterValues)
|
|
267
|
+
: {};
|
|
257
268
|
} catch {
|
|
258
269
|
return {};
|
|
259
270
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type TableFilterValues = Readonly<Record<string, readonly string[]>>;
|
|
2
|
+
|
|
3
|
+
export function normalizeFilterSelection(
|
|
4
|
+
values: readonly string[],
|
|
5
|
+
): readonly string[] {
|
|
6
|
+
return [...new Set(values.filter((value) => value.length > 0))].sort();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeFilterSelections(
|
|
10
|
+
selections: TableFilterValues,
|
|
11
|
+
): TableFilterValues {
|
|
12
|
+
const normalized: Record<string, readonly string[]> = {};
|
|
13
|
+
|
|
14
|
+
for (const [field, values] of Object.entries(selections)) {
|
|
15
|
+
const selection = normalizeFilterSelection(values);
|
|
16
|
+
if (selection.length > 0) normalized[field] = selection;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function matchesFilterSelection(
|
|
23
|
+
values: readonly string[],
|
|
24
|
+
selection: readonly string[],
|
|
25
|
+
): boolean {
|
|
26
|
+
return selection.some((value) => values.includes(value));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function areFilterSelectionsEqual(
|
|
30
|
+
left: TableFilterValues,
|
|
31
|
+
right: TableFilterValues,
|
|
32
|
+
): boolean {
|
|
33
|
+
const leftEntries = Object.entries(left);
|
|
34
|
+
const rightEntries = Object.entries(right);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
leftEntries.length === rightEntries.length &&
|
|
38
|
+
leftEntries.every(([field, values]) => {
|
|
39
|
+
const rightValues = right[field];
|
|
40
|
+
return (
|
|
41
|
+
rightValues !== undefined &&
|
|
42
|
+
values.length === rightValues.length &&
|
|
43
|
+
values.every((value, index) => value === rightValues[index])
|
|
44
|
+
);
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -3,6 +3,12 @@ import type {
|
|
|
3
3
|
TableProgressiveModel,
|
|
4
4
|
} from '../../display/contracts';
|
|
5
5
|
import { tableProgressiveBatchSize } from '../../display/tableProgressive';
|
|
6
|
+
import {
|
|
7
|
+
areFilterSelectionsEqual,
|
|
8
|
+
matchesFilterSelection,
|
|
9
|
+
normalizeFilterSelections,
|
|
10
|
+
type TableFilterValues,
|
|
11
|
+
} from './tableFilterSelection';
|
|
6
12
|
import { resolveTableScrollRoot, revealTableStart } from './tableViewport';
|
|
7
13
|
|
|
8
14
|
const initializedAttribute = 'data-table-progressive-initialized';
|
|
@@ -20,7 +26,7 @@ export type TableRowsAppendedDetail = {
|
|
|
20
26
|
|
|
21
27
|
export type TableProgressiveFilterState = {
|
|
22
28
|
search: string;
|
|
23
|
-
selections:
|
|
29
|
+
selections: TableFilterValues;
|
|
24
30
|
};
|
|
25
31
|
|
|
26
32
|
export type TableProgressiveSortState = {
|
|
@@ -250,9 +256,10 @@ export function initTableProgressive(root: HTMLElement): () => void {
|
|
|
250
256
|
) {
|
|
251
257
|
return false;
|
|
252
258
|
}
|
|
253
|
-
return selections.every(([field,
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
return selections.every(([field, selectedValues]) => {
|
|
260
|
+
const entryValues = entry.filters?.[field] ?? [];
|
|
261
|
+
return matchesFilterSelection(entryValues, selectedValues);
|
|
262
|
+
});
|
|
256
263
|
});
|
|
257
264
|
|
|
258
265
|
if (sortState) {
|
|
@@ -455,8 +462,9 @@ export function initTableProgressive(root: HTMLElement): () => void {
|
|
|
455
462
|
progressive: true,
|
|
456
463
|
refresh,
|
|
457
464
|
setFilters(state) {
|
|
458
|
-
|
|
459
|
-
filterState
|
|
465
|
+
const normalizedState = normalizeFilterState(state);
|
|
466
|
+
if (areFilterStatesEqual(filterState, normalizedState)) return;
|
|
467
|
+
filterState = normalizedState;
|
|
460
468
|
if (prepared) prepareWindow(0, 'refreshing');
|
|
461
469
|
},
|
|
462
470
|
setSort(state) {
|
|
@@ -656,15 +664,21 @@ function areFilterStatesEqual(
|
|
|
656
664
|
left: TableProgressiveFilterState,
|
|
657
665
|
right: TableProgressiveFilterState,
|
|
658
666
|
): boolean {
|
|
659
|
-
if (left.search !== right.search) return false;
|
|
660
|
-
const leftEntries = Object.entries(left.selections);
|
|
661
|
-
const rightEntries = Object.entries(right.selections);
|
|
662
667
|
return (
|
|
663
|
-
|
|
664
|
-
|
|
668
|
+
left.search === right.search &&
|
|
669
|
+
areFilterSelectionsEqual(left.selections, right.selections)
|
|
665
670
|
);
|
|
666
671
|
}
|
|
667
672
|
|
|
673
|
+
function normalizeFilterState(
|
|
674
|
+
state: TableProgressiveFilterState,
|
|
675
|
+
): TableProgressiveFilterState {
|
|
676
|
+
return {
|
|
677
|
+
search: state.search,
|
|
678
|
+
selections: normalizeFilterSelections(state.selections),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
668
682
|
function areSortStatesEqual(
|
|
669
683
|
left: TableProgressiveSortState,
|
|
670
684
|
right: TableProgressiveSortState,
|