responsive-media 1.0.7 → 1.2.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 +877 -141
- package/dist/base-state.d.ts +182 -0
- package/dist/base-state.js +406 -0
- package/dist/container-state.d.ts +36 -0
- package/dist/container-state.js +107 -0
- package/dist/create-responsive.d.ts +46 -17
- package/dist/create-responsive.js +117 -54
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/media-query.d.ts +17 -0
- package/dist/media-query.js +30 -0
- package/dist/presets.d.ts +48 -0
- package/dist/presets.js +71 -0
- package/dist/react-responsive.d.ts +64 -0
- package/dist/react-responsive.js +95 -0
- package/dist/responsive.enum.d.ts +58 -2
- package/dist/responsive.enum.js +9 -9
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +3 -0
- package/dist/vue-responsive.d.ts +80 -6
- package/dist/vue-responsive.js +159 -24
- package/package.json +83 -49
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { MediaQueryConfig } from './responsive.enum';
|
|
2
|
+
export type ResponsiveState = Record<string, boolean>;
|
|
3
|
+
export type ResponsiveListener = (state: ResponsiveState) => void;
|
|
4
|
+
export interface SetConfigOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Debounce delay in milliseconds for `subscribe` listeners.
|
|
7
|
+
* `on()`, `onEnter()`, `onLeave()`, `once()`, and `waitFor()` are never debounced.
|
|
8
|
+
* Pass `0` to disable (default).
|
|
9
|
+
*/
|
|
10
|
+
debounce?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Explicit breakpoint order for `isAbove()`, `isBelow()`, and `between()`.
|
|
13
|
+
* If omitted, config key insertion order is used.
|
|
14
|
+
* @example ['xs', 'sm', 'md', 'lg', 'xl', '2xl']
|
|
15
|
+
*/
|
|
16
|
+
order?: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface SyncCSSVarsOptions {
|
|
19
|
+
/** Target element. Defaults to `document.documentElement`. */
|
|
20
|
+
element?: HTMLElement;
|
|
21
|
+
/** CSS custom property prefix. Defaults to `'--responsive-'`. */
|
|
22
|
+
prefix?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface EmitDOMEventsOptions {
|
|
25
|
+
/** Custom event prefix. Defaults to `'responsive:'`. */
|
|
26
|
+
prefix?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface WritableSignal<T> {
|
|
29
|
+
value: T;
|
|
30
|
+
}
|
|
31
|
+
export type SignalFactory<T> = (initialValue: T) => WritableSignal<T>;
|
|
32
|
+
export declare abstract class BaseResponsiveState {
|
|
33
|
+
protected state: ResponsiveState;
|
|
34
|
+
protected listeners: Set<ResponsiveListener>;
|
|
35
|
+
protected keyListeners: Map<string, Set<(matches: boolean) => void>>;
|
|
36
|
+
proxy: ResponsiveState;
|
|
37
|
+
protected mediaQueries: Record<string, string>;
|
|
38
|
+
protected snapshot: ResponsiveState;
|
|
39
|
+
protected batching: boolean;
|
|
40
|
+
protected pendingKeyChanges: Map<string, boolean>;
|
|
41
|
+
protected debounceMs: number;
|
|
42
|
+
protected debounceTimer: ReturnType<typeof setTimeout> | null;
|
|
43
|
+
protected order: string[];
|
|
44
|
+
constructor();
|
|
45
|
+
/** Subclass implements the source-specific setup (matchMedia / ResizeObserver). */
|
|
46
|
+
protected abstract setupSources(config: Record<string, MediaQueryConfig>): void;
|
|
47
|
+
/** Subclass cleans up source listeners (matchMedia handlers / ResizeObserver). */
|
|
48
|
+
protected abstract cleanupSources(): void;
|
|
49
|
+
protected beginApplyConfig(): void;
|
|
50
|
+
protected clearStateKeys(newConfig: Record<string, MediaQueryConfig>): void;
|
|
51
|
+
protected endApplyConfig(): void;
|
|
52
|
+
protected applyConfig(config: Record<string, MediaQueryConfig>): void;
|
|
53
|
+
protected notifyKey(key: string, value: boolean): void;
|
|
54
|
+
protected flushNotify(): void;
|
|
55
|
+
protected notify(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Returns a stable snapshot of the current state.
|
|
58
|
+
* Same reference between changes — safe for React's `useSyncExternalStore`.
|
|
59
|
+
*/
|
|
60
|
+
getState<T extends Record<string, boolean> = ResponsiveState>(): T;
|
|
61
|
+
/** Returns the CSS media query strings for each breakpoint key. */
|
|
62
|
+
getMediaQueries(): Record<string, string>;
|
|
63
|
+
/** Returns the configured breakpoint order (or empty array if not set). */
|
|
64
|
+
getOrder(): string[];
|
|
65
|
+
private effectiveOrder;
|
|
66
|
+
/**
|
|
67
|
+
* The first active breakpoint key, or `null`.
|
|
68
|
+
* Reads live state — not affected by debounce.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* if (state.current === 'mobile') { ... }
|
|
72
|
+
*/
|
|
73
|
+
get current(): string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Returns `true` when the current breakpoint comes **after** `key` in the order.
|
|
76
|
+
* Requires an `order` option or relies on config key insertion order.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* // current = 'lg', order = ['xs','sm','md','lg','xl']
|
|
80
|
+
* state.isAbove('sm') // → true
|
|
81
|
+
*/
|
|
82
|
+
isAbove(key: string): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Returns `true` when the current breakpoint comes **before** `key` in the order.
|
|
85
|
+
*/
|
|
86
|
+
isBelow(key: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Returns `true` when the current breakpoint is between `from` and `to` (inclusive).
|
|
89
|
+
*/
|
|
90
|
+
between(from: string, to: string): boolean;
|
|
91
|
+
setConfig(config: Record<string, MediaQueryConfig>, options?: SetConfigOptions): void;
|
|
92
|
+
/** Subscribes to all state changes. Fires immediately with current state. Affected by `debounce`. */
|
|
93
|
+
subscribe(listener: ResponsiveListener): () => void;
|
|
94
|
+
/** Subscribes to a single key. Fires immediately. Never debounced. */
|
|
95
|
+
on(key: string, callback: (matches: boolean) => void): () => void;
|
|
96
|
+
/**
|
|
97
|
+
* Fires `callback` only on `false → true` transitions. Skips initial state.
|
|
98
|
+
* Never debounced.
|
|
99
|
+
*/
|
|
100
|
+
onEnter(key: string, callback: () => void): () => void;
|
|
101
|
+
/**
|
|
102
|
+
* Fires `callback` only on `true → false` transitions. Skips initial state.
|
|
103
|
+
* Never debounced.
|
|
104
|
+
*/
|
|
105
|
+
onLeave(key: string, callback: () => void): () => void;
|
|
106
|
+
/**
|
|
107
|
+
* Fires `callback` on the **next change** to `key`, then auto-unsubscribes.
|
|
108
|
+
* Does NOT fire for the current value. Never debounced.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* state.once('mobile', (matches) => console.log('mobile changed to', matches));
|
|
112
|
+
*/
|
|
113
|
+
once(key: string, callback: (matches: boolean) => void): () => void;
|
|
114
|
+
/**
|
|
115
|
+
* Fires `callback` on the **next global state change**, then auto-unsubscribes.
|
|
116
|
+
* Affected by `debounce`.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* state.onNextChange((s) => console.log('first change:', s));
|
|
120
|
+
*/
|
|
121
|
+
onNextChange(callback: (state: ResponsiveState) => void): () => void;
|
|
122
|
+
/**
|
|
123
|
+
* Fires whenever the **active breakpoint** changes (i.e. `current` changes).
|
|
124
|
+
* Provides `from` and `to` for transition-aware logic. Affected by `debounce`.
|
|
125
|
+
*/
|
|
126
|
+
onBreakpointChange(callback: (from: string | null, to: string | null) => void): () => void;
|
|
127
|
+
/**
|
|
128
|
+
* Returns a Promise that resolves when `key` reaches `expectedValue`.
|
|
129
|
+
* Resolves immediately if condition is already met. Never debounced.
|
|
130
|
+
*/
|
|
131
|
+
waitFor(key: string, expectedValue?: boolean): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* Syncs breakpoint state to CSS custom properties (`1` / `0`).
|
|
134
|
+
* Removes properties for keys deleted by a config change.
|
|
135
|
+
* Returns a stop / cleanup function.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* const stop = state.syncCSSVars({ prefix: '--bp-' });
|
|
139
|
+
* // → --bp-mobile: 1; --bp-desktop: 0; …
|
|
140
|
+
*/
|
|
141
|
+
syncCSSVars(options?: SyncCSSVarsOptions): () => void;
|
|
142
|
+
/**
|
|
143
|
+
* Sets initial state from a server-side snapshot to prevent SSR layout shift.
|
|
144
|
+
* Only updates keys that exist in the current config.
|
|
145
|
+
*/
|
|
146
|
+
hydrate(initialState: Record<string, boolean>): void;
|
|
147
|
+
/**
|
|
148
|
+
* Binds a breakpoint key to a writable signal from any signals library
|
|
149
|
+
* (`@preact/signals-core`, Angular `signal()`, Vue `ref()`, etc.).
|
|
150
|
+
* The signal is kept in sync via `on()`.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* import { signal } from '@preact/signals-core';
|
|
154
|
+
* const mobile = state.toSignal('mobile', signal);
|
|
155
|
+
* mobile.value; // reactive boolean
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* // Vue ref
|
|
159
|
+
* import { ref } from 'vue';
|
|
160
|
+
* const mobile = state.toSignal('mobile', ref);
|
|
161
|
+
*/
|
|
162
|
+
toSignal<T extends WritableSignal<boolean>>(key: string, factory: SignalFactory<boolean>): T;
|
|
163
|
+
/**
|
|
164
|
+
* Dispatches DOM `CustomEvent`s on `target` whenever breakpoints change.
|
|
165
|
+
*
|
|
166
|
+
* Events fired:
|
|
167
|
+
* - `responsive:change` — on any state change (`detail` = full state snapshot)
|
|
168
|
+
* - `responsive:mobile:enter` / `responsive:mobile:leave` — per-key transitions
|
|
169
|
+
*
|
|
170
|
+
* Returns a cleanup / stop function.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* const stop = state.emitDOMEvents(document, { prefix: 'bp:' });
|
|
174
|
+
* document.addEventListener('bp:mobile:enter', () => initDrawer());
|
|
175
|
+
*/
|
|
176
|
+
emitDOMEvents(target?: EventTarget, options?: EmitDOMEventsOptions): () => void;
|
|
177
|
+
/**
|
|
178
|
+
* Removes all `matchMedia` / `ResizeObserver` listeners, clears all
|
|
179
|
+
* subscribers, and cancels any pending debounce timer.
|
|
180
|
+
*/
|
|
181
|
+
destroy(): void;
|
|
182
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { isSSR } from './utils';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Abstract base — shared between ReactiveResponsiveState and ContainerState
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
export class BaseResponsiveState {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.state = {};
|
|
8
|
+
this.listeners = new Set();
|
|
9
|
+
this.keyListeners = new Map();
|
|
10
|
+
this.mediaQueries = {};
|
|
11
|
+
this.snapshot = {};
|
|
12
|
+
this.batching = false;
|
|
13
|
+
this.pendingKeyChanges = new Map();
|
|
14
|
+
this.debounceMs = 0;
|
|
15
|
+
this.debounceTimer = null;
|
|
16
|
+
this.order = [];
|
|
17
|
+
this.proxy = new Proxy(this.state, {
|
|
18
|
+
set: (target, prop, value) => {
|
|
19
|
+
if (target[prop] !== value) {
|
|
20
|
+
target[prop] = value;
|
|
21
|
+
if (this.batching) {
|
|
22
|
+
this.pendingKeyChanges.set(prop, value);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
this.notifyKey(prop, value);
|
|
26
|
+
this.notify();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
get: (target, prop) => target[prop],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
beginApplyConfig() {
|
|
35
|
+
this.batching = true;
|
|
36
|
+
if (this.debounceTimer !== null) {
|
|
37
|
+
clearTimeout(this.debounceTimer);
|
|
38
|
+
this.debounceTimer = null;
|
|
39
|
+
}
|
|
40
|
+
this.mediaQueries = {};
|
|
41
|
+
}
|
|
42
|
+
clearStateKeys(newConfig) {
|
|
43
|
+
Object.keys(this.state).forEach(key => {
|
|
44
|
+
if (!(key in newConfig))
|
|
45
|
+
this.notifyKey(key, false);
|
|
46
|
+
delete this.state[key];
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
endApplyConfig() {
|
|
50
|
+
this.batching = false;
|
|
51
|
+
this.pendingKeyChanges.forEach((v, k) => this.notifyKey(k, v));
|
|
52
|
+
this.pendingKeyChanges.clear();
|
|
53
|
+
this.flushNotify();
|
|
54
|
+
}
|
|
55
|
+
applyConfig(config) {
|
|
56
|
+
this.beginApplyConfig();
|
|
57
|
+
this.cleanupSources();
|
|
58
|
+
this.clearStateKeys(config);
|
|
59
|
+
this.setupSources(config);
|
|
60
|
+
this.endApplyConfig();
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Notification helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
notifyKey(key, value) {
|
|
66
|
+
var _a;
|
|
67
|
+
(_a = this.keyListeners.get(key)) === null || _a === void 0 ? void 0 : _a.forEach(cb => cb(value));
|
|
68
|
+
}
|
|
69
|
+
flushNotify() {
|
|
70
|
+
this.snapshot = { ...this.state };
|
|
71
|
+
this.listeners.forEach(l => l(this.snapshot));
|
|
72
|
+
}
|
|
73
|
+
notify() {
|
|
74
|
+
if (this.debounceMs > 0) {
|
|
75
|
+
if (this.debounceTimer !== null)
|
|
76
|
+
clearTimeout(this.debounceTimer);
|
|
77
|
+
this.debounceTimer = setTimeout(() => {
|
|
78
|
+
this.debounceTimer = null;
|
|
79
|
+
this.flushNotify();
|
|
80
|
+
}, this.debounceMs);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
this.flushNotify();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Public read API
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
/**
|
|
90
|
+
* Returns a stable snapshot of the current state.
|
|
91
|
+
* Same reference between changes — safe for React's `useSyncExternalStore`.
|
|
92
|
+
*/
|
|
93
|
+
getState() {
|
|
94
|
+
return this.snapshot;
|
|
95
|
+
}
|
|
96
|
+
/** Returns the CSS media query strings for each breakpoint key. */
|
|
97
|
+
getMediaQueries() {
|
|
98
|
+
return { ...this.mediaQueries };
|
|
99
|
+
}
|
|
100
|
+
/** Returns the configured breakpoint order (or empty array if not set). */
|
|
101
|
+
getOrder() {
|
|
102
|
+
return [...this.order];
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Ordered breakpoint helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
effectiveOrder() {
|
|
108
|
+
return this.order.length ? this.order : Object.keys(this.state);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The first active breakpoint key, or `null`.
|
|
112
|
+
* Reads live state — not affected by debounce.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* if (state.current === 'mobile') { ... }
|
|
116
|
+
*/
|
|
117
|
+
get current() {
|
|
118
|
+
const ord = this.effectiveOrder();
|
|
119
|
+
for (const key of ord) {
|
|
120
|
+
if (this.state[key])
|
|
121
|
+
return key;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Returns `true` when the current breakpoint comes **after** `key` in the order.
|
|
127
|
+
* Requires an `order` option or relies on config key insertion order.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* // current = 'lg', order = ['xs','sm','md','lg','xl']
|
|
131
|
+
* state.isAbove('sm') // → true
|
|
132
|
+
*/
|
|
133
|
+
isAbove(key) {
|
|
134
|
+
const ord = this.effectiveOrder();
|
|
135
|
+
const cur = this.current;
|
|
136
|
+
return ord.indexOf(cur !== null && cur !== void 0 ? cur : '') > ord.indexOf(key);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Returns `true` when the current breakpoint comes **before** `key` in the order.
|
|
140
|
+
*/
|
|
141
|
+
isBelow(key) {
|
|
142
|
+
const ord = this.effectiveOrder();
|
|
143
|
+
const cur = this.current;
|
|
144
|
+
const curIdx = ord.indexOf(cur !== null && cur !== void 0 ? cur : '');
|
|
145
|
+
const keyIdx = ord.indexOf(key);
|
|
146
|
+
return curIdx !== -1 && curIdx < keyIdx;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Returns `true` when the current breakpoint is between `from` and `to` (inclusive).
|
|
150
|
+
*/
|
|
151
|
+
between(from, to) {
|
|
152
|
+
var _a;
|
|
153
|
+
const ord = this.effectiveOrder();
|
|
154
|
+
const idx = ord.indexOf((_a = this.current) !== null && _a !== void 0 ? _a : '');
|
|
155
|
+
return idx !== -1 && idx >= ord.indexOf(from) && idx <= ord.indexOf(to);
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Configuration
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
setConfig(config, options) {
|
|
161
|
+
if ((options === null || options === void 0 ? void 0 : options.debounce) !== undefined)
|
|
162
|
+
this.debounceMs = options.debounce;
|
|
163
|
+
if ((options === null || options === void 0 ? void 0 : options.order) !== undefined)
|
|
164
|
+
this.order = options.order;
|
|
165
|
+
this.applyConfig(config);
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Subscription API
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
/** Subscribes to all state changes. Fires immediately with current state. Affected by `debounce`. */
|
|
171
|
+
subscribe(listener) {
|
|
172
|
+
this.listeners.add(listener);
|
|
173
|
+
listener(this.snapshot);
|
|
174
|
+
return () => this.listeners.delete(listener);
|
|
175
|
+
}
|
|
176
|
+
/** Subscribes to a single key. Fires immediately. Never debounced. */
|
|
177
|
+
on(key, callback) {
|
|
178
|
+
var _a;
|
|
179
|
+
if (!this.keyListeners.has(key))
|
|
180
|
+
this.keyListeners.set(key, new Set());
|
|
181
|
+
const set = this.keyListeners.get(key);
|
|
182
|
+
set.add(callback);
|
|
183
|
+
callback((_a = this.state[key]) !== null && _a !== void 0 ? _a : false);
|
|
184
|
+
return () => set.delete(callback);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Fires `callback` only on `false → true` transitions. Skips initial state.
|
|
188
|
+
* Never debounced.
|
|
189
|
+
*/
|
|
190
|
+
onEnter(key, callback) {
|
|
191
|
+
var _a;
|
|
192
|
+
let prev = (_a = this.state[key]) !== null && _a !== void 0 ? _a : false;
|
|
193
|
+
return this.on(key, (matches) => {
|
|
194
|
+
const changed = prev !== matches;
|
|
195
|
+
prev = matches;
|
|
196
|
+
if (changed && matches)
|
|
197
|
+
callback();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Fires `callback` only on `true → false` transitions. Skips initial state.
|
|
202
|
+
* Never debounced.
|
|
203
|
+
*/
|
|
204
|
+
onLeave(key, callback) {
|
|
205
|
+
var _a;
|
|
206
|
+
let prev = (_a = this.state[key]) !== null && _a !== void 0 ? _a : false;
|
|
207
|
+
return this.on(key, (matches) => {
|
|
208
|
+
const changed = prev !== matches;
|
|
209
|
+
prev = matches;
|
|
210
|
+
if (changed && !matches)
|
|
211
|
+
callback();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Fires `callback` on the **next change** to `key`, then auto-unsubscribes.
|
|
216
|
+
* Does NOT fire for the current value. Never debounced.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* state.once('mobile', (matches) => console.log('mobile changed to', matches));
|
|
220
|
+
*/
|
|
221
|
+
once(key, callback) {
|
|
222
|
+
let cleanup = () => { };
|
|
223
|
+
let skippedInit = false;
|
|
224
|
+
cleanup = this.on(key, (matches) => {
|
|
225
|
+
if (!skippedInit) {
|
|
226
|
+
skippedInit = true;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
cleanup();
|
|
230
|
+
callback(matches);
|
|
231
|
+
});
|
|
232
|
+
return cleanup;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Fires `callback` on the **next global state change**, then auto-unsubscribes.
|
|
236
|
+
* Affected by `debounce`.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* state.onNextChange((s) => console.log('first change:', s));
|
|
240
|
+
*/
|
|
241
|
+
onNextChange(callback) {
|
|
242
|
+
let cleanup = () => { };
|
|
243
|
+
let skippedInit = false;
|
|
244
|
+
cleanup = this.subscribe((s) => {
|
|
245
|
+
if (!skippedInit) {
|
|
246
|
+
skippedInit = true;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
cleanup();
|
|
250
|
+
callback(s);
|
|
251
|
+
});
|
|
252
|
+
return cleanup;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Fires whenever the **active breakpoint** changes (i.e. `current` changes).
|
|
256
|
+
* Provides `from` and `to` for transition-aware logic. Affected by `debounce`.
|
|
257
|
+
*/
|
|
258
|
+
onBreakpointChange(callback) {
|
|
259
|
+
let prev = this.current;
|
|
260
|
+
return this.subscribe(() => {
|
|
261
|
+
const next = this.current;
|
|
262
|
+
if (prev !== next) {
|
|
263
|
+
callback(prev, next);
|
|
264
|
+
prev = next;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Returns a Promise that resolves when `key` reaches `expectedValue`.
|
|
270
|
+
* Resolves immediately if condition is already met. Never debounced.
|
|
271
|
+
*/
|
|
272
|
+
waitFor(key, expectedValue = true) {
|
|
273
|
+
var _a;
|
|
274
|
+
if (((_a = this.state[key]) !== null && _a !== void 0 ? _a : false) === expectedValue)
|
|
275
|
+
return Promise.resolve();
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
const off = this.on(key, (matches) => {
|
|
278
|
+
if (matches === expectedValue) {
|
|
279
|
+
off();
|
|
280
|
+
resolve();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Utilities
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
/**
|
|
289
|
+
* Syncs breakpoint state to CSS custom properties (`1` / `0`).
|
|
290
|
+
* Removes properties for keys deleted by a config change.
|
|
291
|
+
* Returns a stop / cleanup function.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* const stop = state.syncCSSVars({ prefix: '--bp-' });
|
|
295
|
+
* // → --bp-mobile: 1; --bp-desktop: 0; …
|
|
296
|
+
*/
|
|
297
|
+
syncCSSVars(options) {
|
|
298
|
+
var _a, _b;
|
|
299
|
+
if (isSSR())
|
|
300
|
+
return () => { };
|
|
301
|
+
const el = (_a = options === null || options === void 0 ? void 0 : options.element) !== null && _a !== void 0 ? _a : document.documentElement;
|
|
302
|
+
const prefix = (_b = options === null || options === void 0 ? void 0 : options.prefix) !== null && _b !== void 0 ? _b : '--responsive-';
|
|
303
|
+
let prevKeys = new Set();
|
|
304
|
+
return this.subscribe((s) => {
|
|
305
|
+
const cur = new Set(Object.keys(s));
|
|
306
|
+
prevKeys.forEach(k => { if (!cur.has(k))
|
|
307
|
+
el.style.removeProperty(`${prefix}${k}`); });
|
|
308
|
+
Object.entries(s).forEach(([k, v]) => el.style.setProperty(`${prefix}${k}`, v ? '1' : '0'));
|
|
309
|
+
prevKeys = cur;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Sets initial state from a server-side snapshot to prevent SSR layout shift.
|
|
314
|
+
* Only updates keys that exist in the current config.
|
|
315
|
+
*/
|
|
316
|
+
hydrate(initialState) {
|
|
317
|
+
let changed = false;
|
|
318
|
+
Object.entries(initialState).forEach(([key, value]) => {
|
|
319
|
+
if (key in this.state && this.state[key] !== value) {
|
|
320
|
+
this.state[key] = value;
|
|
321
|
+
this.notifyKey(key, value);
|
|
322
|
+
changed = true;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
if (changed)
|
|
326
|
+
this.flushNotify();
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Binds a breakpoint key to a writable signal from any signals library
|
|
330
|
+
* (`@preact/signals-core`, Angular `signal()`, Vue `ref()`, etc.).
|
|
331
|
+
* The signal is kept in sync via `on()`.
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* import { signal } from '@preact/signals-core';
|
|
335
|
+
* const mobile = state.toSignal('mobile', signal);
|
|
336
|
+
* mobile.value; // reactive boolean
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* // Vue ref
|
|
340
|
+
* import { ref } from 'vue';
|
|
341
|
+
* const mobile = state.toSignal('mobile', ref);
|
|
342
|
+
*/
|
|
343
|
+
toSignal(key, factory) {
|
|
344
|
+
var _a;
|
|
345
|
+
const sig = factory((_a = this.state[key]) !== null && _a !== void 0 ? _a : false);
|
|
346
|
+
this.on(key, (v) => { sig.value = v; });
|
|
347
|
+
return sig;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Dispatches DOM `CustomEvent`s on `target` whenever breakpoints change.
|
|
351
|
+
*
|
|
352
|
+
* Events fired:
|
|
353
|
+
* - `responsive:change` — on any state change (`detail` = full state snapshot)
|
|
354
|
+
* - `responsive:mobile:enter` / `responsive:mobile:leave` — per-key transitions
|
|
355
|
+
*
|
|
356
|
+
* Returns a cleanup / stop function.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* const stop = state.emitDOMEvents(document, { prefix: 'bp:' });
|
|
360
|
+
* document.addEventListener('bp:mobile:enter', () => initDrawer());
|
|
361
|
+
*/
|
|
362
|
+
emitDOMEvents(target = document, options) {
|
|
363
|
+
var _a;
|
|
364
|
+
if (isSSR())
|
|
365
|
+
return () => { };
|
|
366
|
+
const prefix = (_a = options === null || options === void 0 ? void 0 : options.prefix) !== null && _a !== void 0 ? _a : 'responsive:';
|
|
367
|
+
let prev = {};
|
|
368
|
+
let initialized = false;
|
|
369
|
+
return this.subscribe((state) => {
|
|
370
|
+
if (!initialized) {
|
|
371
|
+
initialized = true;
|
|
372
|
+
prev = { ...state };
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
target.dispatchEvent(new CustomEvent(`${prefix}change`, {
|
|
376
|
+
detail: { ...state },
|
|
377
|
+
bubbles: true,
|
|
378
|
+
}));
|
|
379
|
+
const allKeys = new Set([...Object.keys(prev), ...Object.keys(state)]);
|
|
380
|
+
allKeys.forEach(key => {
|
|
381
|
+
const was = prev[key] === true;
|
|
382
|
+
const now = state[key] === true;
|
|
383
|
+
if (!was && now) {
|
|
384
|
+
target.dispatchEvent(new CustomEvent(`${prefix}${key}:enter`, { bubbles: true }));
|
|
385
|
+
}
|
|
386
|
+
else if (was && !now) {
|
|
387
|
+
target.dispatchEvent(new CustomEvent(`${prefix}${key}:leave`, { bubbles: true }));
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
prev = { ...state };
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Removes all `matchMedia` / `ResizeObserver` listeners, clears all
|
|
395
|
+
* subscribers, and cancels any pending debounce timer.
|
|
396
|
+
*/
|
|
397
|
+
destroy() {
|
|
398
|
+
if (this.debounceTimer !== null) {
|
|
399
|
+
clearTimeout(this.debounceTimer);
|
|
400
|
+
this.debounceTimer = null;
|
|
401
|
+
}
|
|
402
|
+
this.cleanupSources();
|
|
403
|
+
this.listeners.clear();
|
|
404
|
+
this.keyListeners.clear();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MediaQueryConfig } from './responsive.enum';
|
|
2
|
+
import { BaseResponsiveState, type SetConfigOptions } from './base-state';
|
|
3
|
+
export declare class ContainerState extends BaseResponsiveState {
|
|
4
|
+
private element;
|
|
5
|
+
private observer;
|
|
6
|
+
private configSnapshot;
|
|
7
|
+
constructor(element: Element, config: Record<string, MediaQueryConfig>, options?: SetConfigOptions);
|
|
8
|
+
protected setupSources(config: Record<string, MediaQueryConfig>): void;
|
|
9
|
+
protected cleanupSources(): void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Creates a `ContainerState` that tracks an element's dimensions via
|
|
13
|
+
* `ResizeObserver` and evaluates breakpoint conditions in JavaScript.
|
|
14
|
+
*
|
|
15
|
+
* The API is identical to `ReactiveResponsiveState` — all subscription
|
|
16
|
+
* methods (`subscribe`, `on`, `onEnter`, `onLeave`, `waitFor`, etc.) work
|
|
17
|
+
* the same way.
|
|
18
|
+
*
|
|
19
|
+
* The `getMediaQueries()` method returns CSS `@container` compatible strings
|
|
20
|
+
* that can be used in stylesheets alongside the JS reactive state.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const card = document.querySelector('.card')!;
|
|
24
|
+
*
|
|
25
|
+
* const cardState = createContainerState(card, {
|
|
26
|
+
* compact: [{ type: 'max-width', value: 300 }],
|
|
27
|
+
* wide: [{ type: 'min-width', value: 600 }],
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* cardState.on('compact', (v) => card.classList.toggle('card--compact', v));
|
|
31
|
+
* cardState.syncCSSVars({ prefix: '--card-' });
|
|
32
|
+
*
|
|
33
|
+
* // Cleanup when no longer needed:
|
|
34
|
+
* cardState.destroy();
|
|
35
|
+
*/
|
|
36
|
+
export declare function createContainerState(element: Element, config: Record<string, MediaQueryConfig>, options?: SetConfigOptions): ContainerState;
|