@excom/detect-media 0.1.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/.rush/temp/chunked-rush-logs/detect-media.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/detect-media.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/detect-media.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/detect-media.ts +116 -0
- package/index.ts +21 -0
- package/package.json +45 -0
- package/rush-logs/detect-media.apply-exports.cache.log +1 -0
- package/rush-logs/detect-media.apply-exports.log +1 -0
- package/rush-logs/detect-media.build_docs.cache.log +1 -0
- package/rush-logs/detect-media.build_docs.log +1 -0
- package/rush-logs/detect-media.build_package-metas.cache.log +1 -0
- package/rush-logs/detect-media.build_package-metas.log +1 -0
- package/support/custom-elements.json +120 -0
- package/support/demos/pointer.html +28 -0
- package/support/demos/simple.html +16 -0
- package/support/dist-docs/detect-media.md +150 -0
- package/support/docs/README.md +60 -0
- package/support/package-meta.json +85 -0
- package/support/tests/detect-media.test.ts +219 -0
- package/support/tests/fake-match-media.ts +53 -0
- package/support/tests/pointer.view.test.ts +39 -0
- package/support/tests/simple.view.test.ts +33 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
|
package/config/rig.json
ADDED
package/detect-media.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ConstructorType, Neutron, TEvent } from "@excom/neutron";
|
|
2
|
+
|
|
3
|
+
export interface DetectMediaProvision {
|
|
4
|
+
mediaQuery: string;
|
|
5
|
+
isMatched: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type DetectMediaChangeEvent = TEvent & {
|
|
9
|
+
type: "detect-media-change";
|
|
10
|
+
detail: DetectMediaProvision;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Live media-query facts as attributes — `is-matched` follows
|
|
15
|
+
* `window.matchMedia(mediaQuery).matches` and its `change` event, so CSS and
|
|
16
|
+
* Quark can select on what a media query knows (color scheme, pointer type,
|
|
17
|
+
* viewport width, reduced motion). One element per query.
|
|
18
|
+
*
|
|
19
|
+
* @summary Live media query reflected as `is-matched`.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <detect-media media-query="(pointer: coarse)"></detect-media>
|
|
23
|
+
* <!-- detect-media[is-matched] ... -->
|
|
24
|
+
*
|
|
25
|
+
* @fires detect-media-change - Dispatched every time the query flips (not
|
|
26
|
+
* on mount). `event.detail` is the `.provision` payload.
|
|
27
|
+
* @type DetectMediaChangeEvent
|
|
28
|
+
*/
|
|
29
|
+
export const DetectMedia = Neutron({
|
|
30
|
+
tag: "detect-media",
|
|
31
|
+
props: {
|
|
32
|
+
// options
|
|
33
|
+
/**
|
|
34
|
+
* @option
|
|
35
|
+
* Media query to evaluate, in `window.matchMedia` syntax:
|
|
36
|
+
* `(prefers-color-scheme: dark)`, `(pointer: coarse)`, `(width < 600px)`.
|
|
37
|
+
* Required — missing / empty leaves `is-matched` unset and `provision`
|
|
38
|
+
* `null`. Changing it re-subscribes.
|
|
39
|
+
*/
|
|
40
|
+
mediaQuery: String,
|
|
41
|
+
// state
|
|
42
|
+
/**
|
|
43
|
+
* @state
|
|
44
|
+
* Present while `media-query` matches. Live — follows the query's
|
|
45
|
+
* `change` event.
|
|
46
|
+
*/
|
|
47
|
+
isMatched: Boolean,
|
|
48
|
+
/**
|
|
49
|
+
* @provision
|
|
50
|
+
* `{ mediaQuery, isMatched }` — a new object on first evaluation and on
|
|
51
|
+
* every change; `null` without a query. Not reflected as an attribute.
|
|
52
|
+
* @type DetectMediaProvision
|
|
53
|
+
*/
|
|
54
|
+
provision: Object as unknown as ConstructorType<DetectMediaProvision>,
|
|
55
|
+
// private
|
|
56
|
+
_mql: Object as unknown as ConstructorType<MediaQueryList>,
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
.defineMethods({
|
|
60
|
+
// `change` callback: read the live list, not the event, so a stale
|
|
61
|
+
// dispatch can't disagree with `matches`
|
|
62
|
+
_handleChange: ({ mediaQuery, _mql }) => {
|
|
63
|
+
if (!mediaQuery || !_mql) return;
|
|
64
|
+
const provision: DetectMediaProvision = {
|
|
65
|
+
mediaQuery,
|
|
66
|
+
isMatched: _mql.matches,
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
isMatched: provision.isMatched,
|
|
70
|
+
provision,
|
|
71
|
+
emit: ["detect-media-change", { detail: provision }],
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
// Second `defineMethods` so `_handleChange` is typed on the element here
|
|
76
|
+
.defineMethods({
|
|
77
|
+
// Re-evaluate `media-query`: drop the old list, subscribe to the new
|
|
78
|
+
// one via the tracked listener so Neutron restores it on a move
|
|
79
|
+
_subscribe: ({ mediaQuery, _mql, _handleChange }) => {
|
|
80
|
+
const effects: object[] = [];
|
|
81
|
+
if (_mql) {
|
|
82
|
+
effects.push({
|
|
83
|
+
removeListener: ["change", _handleChange, { target: _mql }],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!mediaQuery) {
|
|
87
|
+
effects.push({ _mql: null, isMatched: false, provision: null });
|
|
88
|
+
return effects;
|
|
89
|
+
}
|
|
90
|
+
const mql = window.matchMedia(mediaQuery);
|
|
91
|
+
effects.push(
|
|
92
|
+
{ _mql: mql, addListener: ["change", _handleChange, { target: mql }] },
|
|
93
|
+
{
|
|
94
|
+
isMatched: mql.matches,
|
|
95
|
+
provision: { mediaQuery, isMatched: mql.matches },
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
return effects;
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
.onPropChanged("mediaQuery", () => ({ _subscribe: [] }))
|
|
102
|
+
.onConnected(
|
|
103
|
+
// First mount flushes via `onPropChanged`. A move keeps the tracked
|
|
104
|
+
// listener; only a real reconnect re-evaluates.
|
|
105
|
+
({ wasMounted, isMoving }) => wasMounted && !isMoving && { _subscribe: [] }
|
|
106
|
+
)
|
|
107
|
+
.onDisconnected(
|
|
108
|
+
// A move restores tracked listeners itself. A real removal drops the
|
|
109
|
+
// list so it can be collected.
|
|
110
|
+
({ isMoving, _mql, _handleChange }) =>
|
|
111
|
+
!isMoving &&
|
|
112
|
+
!!_mql && [
|
|
113
|
+
{ removeListener: ["change", _handleChange, { target: _mql }] },
|
|
114
|
+
{ _mql: null },
|
|
115
|
+
]
|
|
116
|
+
);
|
package/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DetectMedia } from "./detect-media";
|
|
2
|
+
|
|
3
|
+
DetectMedia.define();
|
|
4
|
+
|
|
5
|
+
export { DetectMedia };
|
|
6
|
+
export type {
|
|
7
|
+
DetectMediaChangeEvent,
|
|
8
|
+
DetectMediaProvision,
|
|
9
|
+
} from "./detect-media";
|
|
10
|
+
|
|
11
|
+
type T_HTMLDetectMediaElement = typeof DetectMedia.CustomElement;
|
|
12
|
+
declare global {
|
|
13
|
+
interface HTMLDetectMediaElement extends T_HTMLDetectMediaElement {}
|
|
14
|
+
interface Window {
|
|
15
|
+
HTMLDetectMediaElement: HTMLDetectMediaElement;
|
|
16
|
+
}
|
|
17
|
+
interface HTMLElementTagNameMap {
|
|
18
|
+
"detect-media": HTMLDetectMediaElement;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export type { HTMLDetectMediaElement };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@excom/detect-media",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "<detect-media> custom element",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.13.0"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@excom/neutron": "^0.1.0"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@excom/quark": "^0.1.0",
|
|
16
|
+
"@excom/include-content": "^0.1.0",
|
|
17
|
+
"@excom/quark-sheet": "^0.1.0",
|
|
18
|
+
"@excom/heft-rig": "^0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"url": "excom-dev/nucleus",
|
|
22
|
+
"directory": "packages/detect-media"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/excom-dev/nucleus/tree/main/packages/detect-media/support/docs/README.md",
|
|
25
|
+
"bugs": "https://github.com/excom-dev/nucleus/issues",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"detect-media",
|
|
28
|
+
"neutron",
|
|
29
|
+
"custom-elements"
|
|
30
|
+
],
|
|
31
|
+
"excom": {
|
|
32
|
+
"packageType": "kit-element"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "node node_modules/@excom/heft-rig/scripts/vite-build.mjs",
|
|
36
|
+
"build:watch": "node node_modules/@excom/heft-rig/scripts/vite-build-watch.mjs",
|
|
37
|
+
"format": "node node_modules/@excom/heft-rig/scripts/format.mjs",
|
|
38
|
+
"test": "node node_modules/@excom/heft-rig/scripts/vitest.mjs",
|
|
39
|
+
"coverage": "node node_modules/@excom/heft-rig/scripts/coverage.mjs",
|
|
40
|
+
"dev": "node node_modules/@excom/heft-rig/scripts/vite-dev.mjs",
|
|
41
|
+
"preview": "node node_modules/@excom/heft-rig/scripts/vite-preview.mjs",
|
|
42
|
+
"build:package-metas": "node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs",
|
|
43
|
+
"build:docs": "node node_modules/@excom/heft-rig/scripts/build-docs.mjs"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Caching has been disabled for this project's "apply-exports" command.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This project does not define the caching behavior of the "build:docs" command, so caching has been disabled.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This project does not define the caching behavior of the "build:package-metas" command, so caching has been disabled.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0.0",
|
|
3
|
+
"modules": [
|
|
4
|
+
{
|
|
5
|
+
"kind": "javascript-module",
|
|
6
|
+
"path": "detect-media.ts",
|
|
7
|
+
"declarations": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "class",
|
|
10
|
+
"name": "DetectMedia",
|
|
11
|
+
"customElement": true,
|
|
12
|
+
"tagName": "detect-media",
|
|
13
|
+
"summary": "Live media query reflected as `is-matched`.",
|
|
14
|
+
"description": "Live media-query facts as attributes — `is-matched` follows `window.matchMedia(mediaQuery).matches` and its `change` event, so CSS and Quark can select on what a media query knows (color scheme, pointer type, viewport width, reduced motion). One element per query.",
|
|
15
|
+
"attributes": [
|
|
16
|
+
{
|
|
17
|
+
"name": "media-query",
|
|
18
|
+
"type": {
|
|
19
|
+
"text": "string"
|
|
20
|
+
},
|
|
21
|
+
"description": "Media query to evaluate, in `window.matchMedia` syntax: `(prefers-color-scheme: dark)`, `(pointer: coarse)`, `(width < 600px)`. Required — missing / empty leaves `is-matched` unset and `provision` `null`. Changing it re-subscribes.",
|
|
22
|
+
"fieldName": "mediaQuery"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "is-matched",
|
|
26
|
+
"type": {
|
|
27
|
+
"text": "boolean"
|
|
28
|
+
},
|
|
29
|
+
"description": "Present while `media-query` matches. Live — follows the query's `change` event.",
|
|
30
|
+
"fieldName": "isMatched"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"members": [
|
|
34
|
+
{
|
|
35
|
+
"kind": "field",
|
|
36
|
+
"name": "mediaQuery",
|
|
37
|
+
"type": {
|
|
38
|
+
"text": "string"
|
|
39
|
+
},
|
|
40
|
+
"privacy": "public",
|
|
41
|
+
"readonly": false,
|
|
42
|
+
"description": "Media query to evaluate, in `window.matchMedia` syntax: `(prefers-color-scheme: dark)`, `(pointer: coarse)`, `(width < 600px)`. Required — missing / empty leaves `is-matched` unset and `provision` `null`. Changing it re-subscribes.",
|
|
43
|
+
"_neutron": {
|
|
44
|
+
"surface": "option"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"kind": "field",
|
|
49
|
+
"name": "isMatched",
|
|
50
|
+
"type": {
|
|
51
|
+
"text": "boolean"
|
|
52
|
+
},
|
|
53
|
+
"privacy": "public",
|
|
54
|
+
"readonly": true,
|
|
55
|
+
"description": "Present while `media-query` matches. Live — follows the query's `change` event.",
|
|
56
|
+
"_neutron": {
|
|
57
|
+
"surface": "state"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"kind": "field",
|
|
62
|
+
"name": "provision",
|
|
63
|
+
"type": {
|
|
64
|
+
"text": "DetectMediaProvision",
|
|
65
|
+
"expanded": "{ mediaQuery: string; isMatched: boolean; }"
|
|
66
|
+
},
|
|
67
|
+
"privacy": "public",
|
|
68
|
+
"readonly": false,
|
|
69
|
+
"description": "`{ mediaQuery, isMatched }` — a new object on first evaluation and on every change; `null` without a query. Not reflected as an attribute.",
|
|
70
|
+
"_neutron": {
|
|
71
|
+
"surface": "option"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"events": [
|
|
76
|
+
{
|
|
77
|
+
"name": "detect-media-change",
|
|
78
|
+
"description": "Dispatched every time the query flips (not on mount). `event.detail` is the `.provision` payload.",
|
|
79
|
+
"type": {
|
|
80
|
+
"text": "DetectMediaChangeEvent",
|
|
81
|
+
"expanded": "CustomEvent & { type: \"detect-media-change\"; detail: { mediaQuery: string; isMatched: boolean; }; bubbles: true; cancelable: true; composed: true }"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"_neutron": {
|
|
86
|
+
"provisions": [
|
|
87
|
+
{
|
|
88
|
+
"name": "provision",
|
|
89
|
+
"type": {
|
|
90
|
+
"text": "DetectMediaProvision",
|
|
91
|
+
"expanded": "{ mediaQuery: string; isMatched: boolean; }"
|
|
92
|
+
},
|
|
93
|
+
"description": "`{ mediaQuery, isMatched }` — a new object on first evaluation and on every change; `null` without a query. Not reflected as an attribute.",
|
|
94
|
+
"fieldName": "provision"
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
"exports": [
|
|
101
|
+
{
|
|
102
|
+
"kind": "js",
|
|
103
|
+
"name": "DetectMedia",
|
|
104
|
+
"declaration": {
|
|
105
|
+
"name": "DetectMedia",
|
|
106
|
+
"module": "detect-media.ts"
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"kind": "custom-element-definition",
|
|
111
|
+
"name": "detect-media",
|
|
112
|
+
"declaration": {
|
|
113
|
+
"name": "DetectMedia",
|
|
114
|
+
"module": "detect-media.ts"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<detect-media media-query="(pointer: coarse)">
|
|
3
|
+
<include-content bind-touch>
|
|
4
|
+
<template>
|
|
5
|
+
<p>Touch layout</p>
|
|
6
|
+
</template>
|
|
7
|
+
</include-content>
|
|
8
|
+
<include-content bind-pointer>
|
|
9
|
+
<template>
|
|
10
|
+
<p>Pointer layout</p>
|
|
11
|
+
</template>
|
|
12
|
+
</include-content>
|
|
13
|
+
</detect-media>
|
|
14
|
+
<quark-sheet>
|
|
15
|
+
detect-media[is-matched] include-content[bind-touch] {
|
|
16
|
+
is-active: "";
|
|
17
|
+
}
|
|
18
|
+
detect-media[is-matched] include-content[bind-pointer] {
|
|
19
|
+
is-active: none;
|
|
20
|
+
}
|
|
21
|
+
detect-media:not([is-matched]) include-content[bind-pointer] {
|
|
22
|
+
is-active: "";
|
|
23
|
+
}
|
|
24
|
+
detect-media:not([is-matched]) include-content[bind-touch] {
|
|
25
|
+
is-active: none;
|
|
26
|
+
}
|
|
27
|
+
</quark-sheet>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<detect-media media-query="(prefers-color-scheme: dark)"></detect-media>
|
|
3
|
+
<p>Dark color scheme — <code>is-matched</code> is set.</p>
|
|
4
|
+
<p role="status">Light color scheme — <code>is-matched</code> is absent.</p>
|
|
5
|
+
<small role="note">Live — switch your OS / browser color scheme (or emulate <code>prefers-color-scheme</code> in DevTools) to flip it.</small>
|
|
6
|
+
<style>
|
|
7
|
+
#demo-detect-media-simple {
|
|
8
|
+
&:has(detect-media[is-matched]) p[role="status"] {
|
|
9
|
+
display: none;
|
|
10
|
+
}
|
|
11
|
+
&:has(detect-media:not([is-matched])) p:not([role="status"]) {
|
|
12
|
+
display: none;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
</style>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# detect-media
|
|
2
|
+
|
|
3
|
+
Live media-query facts as attributes — what CSS knows, Quark can now select on. One `<detect-media>` per query; `is-matched` follows `window.matchMedia` and flips as the query does.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
```html
|
|
7
|
+
<div>
|
|
8
|
+
<detect-media media-query="(prefers-color-scheme: dark)"></detect-media>
|
|
9
|
+
<p>Dark color scheme — <code>is-matched</code> is set.</p>
|
|
10
|
+
<p role="status">Light color scheme — <code>is-matched</code> is absent.</p>
|
|
11
|
+
<small role="note">Live — switch your OS / browser color scheme (or emulate <code>prefers-color-scheme</code> in DevTools) to flip it.</small>
|
|
12
|
+
<style>
|
|
13
|
+
#demo-detect-media-simple {
|
|
14
|
+
&:has(detect-media[is-matched]) p[role="status"] {
|
|
15
|
+
display: none;
|
|
16
|
+
}
|
|
17
|
+
&:has(detect-media:not([is-matched])) p:not([role="status"]) {
|
|
18
|
+
display: none;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</style>
|
|
22
|
+
</div>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Live `is-matched`** Follows `MediaQueryList.matches` and its `change` event
|
|
29
|
+
- **Any media query** Color scheme, pointer type, motion, width — whatever `matchMedia` accepts
|
|
30
|
+
- **CSS / Quark selectable** Gate content on `detect-media[is-matched]`, no listeners
|
|
31
|
+
- **Provision + event** `.provision` is `{ mediaQuery, isMatched }`; `detect-media-change` fires on every flip
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
`@excom/detect-media` v0.1.0
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm add @excom/detect-media
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @excom/detect-media
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
yarn add @excom/detect-media
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Import
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import "@excom/detect-media";
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
One element per query. `media-query` takes anything `window.matchMedia` does.
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<detect-media media-query="(pointer: coarse)"></detect-media>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Select on `is-matched` from CSS:
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
body:has(detect-media[media-query="(pointer: coarse)"][is-matched]) .hover-hint {
|
|
70
|
+
display: none;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or from Quark — the attribute, or the provision:
|
|
75
|
+
|
|
76
|
+
```quark
|
|
77
|
+
detect-media[media-query="(pointer: coarse)"][is-matched] ~ nav {
|
|
78
|
+
data-is-touch: "";
|
|
79
|
+
}
|
|
80
|
+
detect-media[media-query="(pointer: coarse)"]:not([is-matched]) ~ nav {
|
|
81
|
+
data-is-touch: none;
|
|
82
|
+
}
|
|
83
|
+
detect-media[media-query="(prefers-color-scheme: dark)"] {
|
|
84
|
+
$app-is-dark: prop("provision").isMatched;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Missing / empty `media-query` leaves `is-matched` unset and `provision` `null`; changing it re-subscribes. `detect-media-change` fires on every flip after mount, never on mount — react to the attribute for the initial state.
|
|
89
|
+
|
|
90
|
+
### API Reference
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#### Attributes
|
|
94
|
+
|
|
95
|
+
| Name | Surface | Type | Default | Values | Description |
|
|
96
|
+
| --- | --- | --- | --- | --- | --- |
|
|
97
|
+
| `media-query` | option | `string` | | | Media query to evaluate, in `window.matchMedia` syntax: `(prefers-color-scheme: dark)`, `(pointer: coarse)`, `(width < 600px)`. Required — missing / empty leaves `is-matched` unset and `provision` `null`. Changing it re-subscribes. |
|
|
98
|
+
| `is-matched` | state | `boolean` | | | Present while `media-query` matches. Live — follows the query's `change` event. |
|
|
99
|
+
|
|
100
|
+
#### Provision
|
|
101
|
+
|
|
102
|
+
| Name | Type | Description |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `provision` | `DetectMediaProvision` (`{ mediaQuery: string; isMatched: boolean; }`) | `{ mediaQuery, isMatched }` — a new object on first evaluation and on every change; `null` without a query. Not reflected as an attribute. |
|
|
105
|
+
|
|
106
|
+
#### Fires
|
|
107
|
+
|
|
108
|
+
| Name | Type | Description |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `detect-media-change` | `DetectMediaChangeEvent` (`CustomEvent & { type: "detect-media-change"; detail: { mediaQuery: string; isMatched: boolean; }; bubbles: true; cancelable: true; composed: true }`) | Dispatched every time the query flips (not on mount). `event.detail` is the `.provision` payload. |
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
### Examples
|
|
115
|
+
|
|
116
|
+
#### Touch vs pointer views
|
|
117
|
+
|
|
118
|
+
Quark flips `is-active` on two `<include-content>` hosts from `(pointer: coarse)` — a true render, so the inactive view is not in the document. Toggle device emulation in DevTools to switch live.
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<div>
|
|
123
|
+
<detect-media media-query="(pointer: coarse)">
|
|
124
|
+
<include-content bind-touch>
|
|
125
|
+
<template>
|
|
126
|
+
<p>Touch layout</p>
|
|
127
|
+
</template>
|
|
128
|
+
</include-content>
|
|
129
|
+
<include-content bind-pointer>
|
|
130
|
+
<template>
|
|
131
|
+
<p>Pointer layout</p>
|
|
132
|
+
</template>
|
|
133
|
+
</include-content>
|
|
134
|
+
</detect-media>
|
|
135
|
+
<quark-sheet>
|
|
136
|
+
detect-media[is-matched] include-content[bind-touch] {
|
|
137
|
+
is-active: "";
|
|
138
|
+
}
|
|
139
|
+
detect-media[is-matched] include-content[bind-pointer] {
|
|
140
|
+
is-active: none;
|
|
141
|
+
}
|
|
142
|
+
detect-media:not([is-matched]) include-content[bind-pointer] {
|
|
143
|
+
is-active: "";
|
|
144
|
+
}
|
|
145
|
+
detect-media:not([is-matched]) include-content[bind-touch] {
|
|
146
|
+
is-active: none;
|
|
147
|
+
}
|
|
148
|
+
</quark-sheet>
|
|
149
|
+
</div>
|
|
150
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# detect-media
|
|
2
|
+
|
|
3
|
+
Live media-query facts as attributes — what CSS knows, Quark can now select on. One `<detect-media>` per query; `is-matched` follows `window.matchMedia` and flips as the query does.
|
|
4
|
+
|
|
5
|
+
<include-content data-demo="simple"></include-content>
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Live `is-matched`** Follows `MediaQueryList.matches` and its `change` event
|
|
10
|
+
- **Any media query** Color scheme, pointer type, motion, width — whatever `matchMedia` accepts
|
|
11
|
+
- **CSS / Quark selectable** Gate content on `detect-media[is-matched]`, no listeners
|
|
12
|
+
- **Provision + event** `.provision` is `{ mediaQuery, isMatched }`; `detect-media-change` fires on every flip
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
<include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
One element per query. `media-query` takes anything `window.matchMedia` does.
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<detect-media media-query="(pointer: coarse)"></detect-media>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Select on `is-matched` from CSS:
|
|
27
|
+
|
|
28
|
+
```css
|
|
29
|
+
body:has(detect-media[media-query="(pointer: coarse)"][is-matched]) .hover-hint {
|
|
30
|
+
display: none;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or from Quark — the attribute, or the provision:
|
|
35
|
+
|
|
36
|
+
```quark
|
|
37
|
+
detect-media[media-query="(pointer: coarse)"][is-matched] ~ nav {
|
|
38
|
+
data-is-touch: "";
|
|
39
|
+
}
|
|
40
|
+
detect-media[media-query="(pointer: coarse)"]:not([is-matched]) ~ nav {
|
|
41
|
+
data-is-touch: none;
|
|
42
|
+
}
|
|
43
|
+
detect-media[media-query="(prefers-color-scheme: dark)"] {
|
|
44
|
+
$app-is-dark: prop("provision").isMatched;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Missing / empty `media-query` leaves `is-matched` unset and `provision` `null`; changing it re-subscribes. `detect-media-change` fires on every flip after mount, never on mount — react to the attribute for the initial state.
|
|
49
|
+
|
|
50
|
+
### API Reference
|
|
51
|
+
|
|
52
|
+
<include-content is-active template-ref="/views/api-reference/api-reference.html"></include-content>
|
|
53
|
+
|
|
54
|
+
### Examples
|
|
55
|
+
|
|
56
|
+
#### Touch vs pointer views
|
|
57
|
+
|
|
58
|
+
Quark flips `is-active` on two `<include-content>` hosts from `(pointer: coarse)` — a true render, so the inactive view is not in the document. Toggle device emulation in DevTools to switch live.
|
|
59
|
+
|
|
60
|
+
<include-content data-demo="pointer"></include-content>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"shortName": "detect-media",
|
|
3
|
+
"package": {
|
|
4
|
+
"name": "@excom/detect-media",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "<detect-media> custom element",
|
|
7
|
+
"peerDependencies": {},
|
|
8
|
+
"excom": {
|
|
9
|
+
"packageType": "kit-element"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"demos": {
|
|
13
|
+
"pointer": "<div>\n <detect-media media-query=\"(pointer: coarse)\">\n <include-content bind-touch>\n <template>\n <p>Touch layout</p>\n </template>\n </include-content>\n <include-content bind-pointer>\n <template>\n <p>Pointer layout</p>\n </template>\n </include-content>\n </detect-media>\n <quark-sheet>\n detect-media[is-matched] include-content[bind-touch] {\n is-active: \"\";\n }\n detect-media[is-matched] include-content[bind-pointer] {\n is-active: none;\n }\n detect-media:not([is-matched]) include-content[bind-pointer] {\n is-active: \"\";\n }\n detect-media:not([is-matched]) include-content[bind-touch] {\n is-active: none;\n }\n </quark-sheet>\n</div>\n",
|
|
14
|
+
"simple": "<div>\n <detect-media media-query=\"(prefers-color-scheme: dark)\"></detect-media>\n <p>Dark color scheme — <code>is-matched</code> is set.</p>\n <p role=\"status\">Light color scheme — <code>is-matched</code> is absent.</p>\n <small role=\"note\">Live — switch your OS / browser color scheme (or emulate <code>prefers-color-scheme</code> in DevTools) to flip it.</small>\n <style>\n #demo-detect-media-simple {\n &:has(detect-media[is-matched]) p[role=\"status\"] {\n display: none;\n }\n &:has(detect-media:not([is-matched])) p:not([role=\"status\"]) {\n display: none;\n }\n }\n </style>\n</div>\n"
|
|
15
|
+
},
|
|
16
|
+
"readme": "<h1 id=\"md-detect-media\">detect-media</h1>\n<p>Live media-query facts as attributes — what CSS knows, Quark can now select on. One <code><detect-media></code> per query; <code>is-matched</code> follows <code>window.matchMedia</code> and flips as the query does.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Live <code>is-matched</code></strong> Follows <code>MediaQueryList.matches</code> and its <code>change</code> event</li>\n<li><strong>Any media query</strong> Color scheme, pointer type, motion, width — whatever <code>matchMedia</code> accepts</li>\n<li><strong>CSS / Quark selectable</strong> Gate content on <code>detect-media[is-matched]</code>, no listeners</li>\n<li><strong>Provision + event</strong> <code>.provision</code> is <code>{ mediaQuery, isMatched }</code>; <code>detect-media-change</code> fires on every flip</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>One element per query. <code>media-query</code> takes anything <code>window.matchMedia</code> does.</p>\n<include-content data-language=\"html\"><template><detect-media media-query=\"(pointer: coarse)\"></detect-media></template></include-content>\n<p>Select on <code>is-matched</code> from CSS:</p>\n<include-content data-language=\"css\"><template>body:has(detect-media[media-query=\"(pointer: coarse)\"][is-matched]) .hover-hint {\n display: none;\n}</template></include-content>\n<p>Or from Quark — the attribute, or the provision:</p>\n<include-content data-language=\"quark\"><template>detect-media[media-query=\"(pointer: coarse)\"][is-matched] ~ nav {\n data-is-touch: \"\";\n}\ndetect-media[media-query=\"(pointer: coarse)\"]:not([is-matched]) ~ nav {\n data-is-touch: none;\n}\ndetect-media[media-query=\"(prefers-color-scheme: dark)\"] {\n $app-is-dark: prop(\"provision\").isMatched;\n}</template></include-content>\n<p>Missing / empty <code>media-query</code> leaves <code>is-matched</code> unset and <code>provision</code> <code>null</code>; changing it re-subscribes. <code>detect-media-change</code> fires on every flip after mount, never on mount — react to the attribute for the initial state.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-touch-vs-pointer-views\">Touch vs pointer views</h4>\n<p>Quark flips <code>is-active</code> on two <code><include-content></code> hosts from <code>(pointer: coarse)</code> — a true render, so the inactive view is not in the document. Toggle device emulation in DevTools to switch live.</p>\n<p><include-content data-demo=\"pointer\"></include-content></p>\n",
|
|
17
|
+
"docs": {
|
|
18
|
+
"readme": "<h1 id=\"md-detect-media\">detect-media</h1>\n<p>Live media-query facts as attributes — what CSS knows, Quark can now select on. One <code><detect-media></code> per query; <code>is-matched</code> follows <code>window.matchMedia</code> and flips as the query does.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Live <code>is-matched</code></strong> Follows <code>MediaQueryList.matches</code> and its <code>change</code> event</li>\n<li><strong>Any media query</strong> Color scheme, pointer type, motion, width — whatever <code>matchMedia</code> accepts</li>\n<li><strong>CSS / Quark selectable</strong> Gate content on <code>detect-media[is-matched]</code>, no listeners</li>\n<li><strong>Provision + event</strong> <code>.provision</code> is <code>{ mediaQuery, isMatched }</code>; <code>detect-media-change</code> fires on every flip</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<p>One element per query. <code>media-query</code> takes anything <code>window.matchMedia</code> does.</p>\n<include-content data-language=\"html\"><template><detect-media media-query=\"(pointer: coarse)\"></detect-media></template></include-content>\n<p>Select on <code>is-matched</code> from CSS:</p>\n<include-content data-language=\"css\"><template>body:has(detect-media[media-query=\"(pointer: coarse)\"][is-matched]) .hover-hint {\n display: none;\n}</template></include-content>\n<p>Or from Quark — the attribute, or the provision:</p>\n<include-content data-language=\"quark\"><template>detect-media[media-query=\"(pointer: coarse)\"][is-matched] ~ nav {\n data-is-touch: \"\";\n}\ndetect-media[media-query=\"(pointer: coarse)\"]:not([is-matched]) ~ nav {\n data-is-touch: none;\n}\ndetect-media[media-query=\"(prefers-color-scheme: dark)\"] {\n $app-is-dark: prop(\"provision\").isMatched;\n}</template></include-content>\n<p>Missing / empty <code>media-query</code> leaves <code>is-matched</code> unset and <code>provision</code> <code>null</code>; changing it re-subscribes. <code>detect-media-change</code> fires on every flip after mount, never on mount — react to the attribute for the initial state.</p>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-touch-vs-pointer-views\">Touch vs pointer views</h4>\n<p>Quark flips <code>is-active</code> on two <code><include-content></code> hosts from <code>(pointer: coarse)</code> — a true render, so the inactive view is not in the document. Toggle device emulation in DevTools to switch live.</p>\n<p><include-content data-demo=\"pointer\"></include-content></p>\n"
|
|
19
|
+
},
|
|
20
|
+
"installation": {
|
|
21
|
+
"name": "@excom/detect-media",
|
|
22
|
+
"shortName": "detect-media",
|
|
23
|
+
"version": "0.1.0",
|
|
24
|
+
"description": "<detect-media> custom element",
|
|
25
|
+
"packageType": "kit-element",
|
|
26
|
+
"cdn": "<script src=\"https://unpkg.com/@excom/kit-utils/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/neutron/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/detect-media@0.1.0/dist/index.umd.min.js\"></script>",
|
|
27
|
+
"install": {
|
|
28
|
+
"npm": "npm install @excom/detect-media"
|
|
29
|
+
},
|
|
30
|
+
"imports": {
|
|
31
|
+
"js": "import \"@excom/detect-media\";",
|
|
32
|
+
"html": "<!-- import path to `node_modules` will depend on your build setup -->\n<script type=\"module\" src=\"/node_modules/@excom/detect-media\"></script>\n<link rel=\"stylesheet\" href=\"/node_modules/@excom/detect-media\">"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": []
|
|
35
|
+
},
|
|
36
|
+
"elementApis": [
|
|
37
|
+
{
|
|
38
|
+
"tag": "detect-media",
|
|
39
|
+
"summary": "Live media query reflected as <code>is-matched</code>.",
|
|
40
|
+
"kind": "class",
|
|
41
|
+
"attributes": [
|
|
42
|
+
{
|
|
43
|
+
"name": "media-query",
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Media query to evaluate, in <code>window.matchMedia</code> syntax: <code>(prefers-color-scheme: dark)</code>, <code>(pointer: coarse)</code>, <code>(width < 600px)</code>. Required — missing / empty leaves <code>is-matched</code> unset and <code>provision</code> <code>null</code>. Changing it re-subscribes.",
|
|
46
|
+
"fieldName": "mediaQuery",
|
|
47
|
+
"surface": "option"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "is-matched",
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"description": "Present while <code>media-query</code> matches. Live — follows the query's <code>change</code> event.",
|
|
53
|
+
"fieldName": "isMatched",
|
|
54
|
+
"surface": "state"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"events": [
|
|
58
|
+
{
|
|
59
|
+
"name": "detect-media-change",
|
|
60
|
+
"description": "Dispatched every time the query flips (not on mount). <code>event.detail</code> is the <code>.provision</code> payload.",
|
|
61
|
+
"type": "DetectMediaChangeEvent",
|
|
62
|
+
"typeExpanded": "CustomEvent & { type: \"detect-media-change\"; detail: { mediaQuery: string; isMatched: boolean; }; bubbles: true; cancelable: true; composed: true }"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"slots": [],
|
|
66
|
+
"cssProperties": [],
|
|
67
|
+
"cssClasses": [],
|
|
68
|
+
"cssAliases": [],
|
|
69
|
+
"listens": [],
|
|
70
|
+
"commands": [],
|
|
71
|
+
"defaultActions": [],
|
|
72
|
+
"expectedChildren": [],
|
|
73
|
+
"provisions": [
|
|
74
|
+
{
|
|
75
|
+
"name": "provision",
|
|
76
|
+
"type": "DetectMediaProvision",
|
|
77
|
+
"typeExpanded": "{ mediaQuery: string; isMatched: boolean; }",
|
|
78
|
+
"description": "<code>{ mediaQuery, isMatched }</code> — a new object on first evaluation and on every change; <code>null</code> without a query. Not reflected as an attribute.",
|
|
79
|
+
"fieldName": "provision"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"exportedFiles": {}
|
|
85
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
describe,
|
|
4
|
+
expect,
|
|
5
|
+
fixture,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
wait,
|
|
9
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
10
|
+
import "../../index";
|
|
11
|
+
import { lastFake, resetFakes, stubMatchMedia } from "./fake-match-media";
|
|
12
|
+
|
|
13
|
+
const COARSE = "(pointer: coarse)";
|
|
14
|
+
const DARK = "(prefers-color-scheme: dark)";
|
|
15
|
+
|
|
16
|
+
const mount = (query?: string) =>
|
|
17
|
+
fixture<HTMLDetectMediaElement>(
|
|
18
|
+
query === undefined
|
|
19
|
+
? `<detect-media></detect-media>`
|
|
20
|
+
: `<detect-media media-query="${query}"></detect-media>`,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
describe("detect-media", () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
document.body.innerHTML = "";
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
resetFakes();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reflects the initial match and publishes a provision without firing change", () => {
|
|
31
|
+
stubMatchMedia({ [COARSE]: true });
|
|
32
|
+
const change = vi.fn();
|
|
33
|
+
document.body.addEventListener("detect-media-change", change);
|
|
34
|
+
|
|
35
|
+
const el = mount(COARSE);
|
|
36
|
+
|
|
37
|
+
expect(window.matchMedia).toHaveBeenCalledWith(COARSE);
|
|
38
|
+
expect(el).dom.to.equalTag(
|
|
39
|
+
`<detect-media is-matched media-query="(pointer: coarse)"></detect-media>`,
|
|
40
|
+
);
|
|
41
|
+
expect(el.provision).toEqual({ mediaQuery: COARSE, isMatched: true });
|
|
42
|
+
// Neutron appends its own options arg, compare the type + handler only
|
|
43
|
+
expect(lastFake().addEventListener.mock.calls[0].slice(0, 2)).toEqual([
|
|
44
|
+
"change",
|
|
45
|
+
expect.any(Function),
|
|
46
|
+
]);
|
|
47
|
+
expect(lastFake().listeners.size).toBe(1);
|
|
48
|
+
expect(change).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("starts unmatched when the query does not match", () => {
|
|
52
|
+
stubMatchMedia({ [COARSE]: false });
|
|
53
|
+
const el = mount(COARSE);
|
|
54
|
+
expect(el).dom.to.equalTag(
|
|
55
|
+
`<detect-media media-query="(pointer: coarse)"></detect-media>`,
|
|
56
|
+
);
|
|
57
|
+
expect(el.provision).toEqual({ mediaQuery: COARSE, isMatched: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("publishes provision with neutron-provision on mount", () => {
|
|
61
|
+
stubMatchMedia();
|
|
62
|
+
const wrap = document.createElement("div");
|
|
63
|
+
document.body.append(wrap);
|
|
64
|
+
const spy = vi.fn();
|
|
65
|
+
wrap.addEventListener("neutron-provision", spy);
|
|
66
|
+
|
|
67
|
+
wrap.innerHTML = `<detect-media media-query="${COARSE}"></detect-media>`;
|
|
68
|
+
const el = wrap.firstElementChild as HTMLDetectMediaElement;
|
|
69
|
+
|
|
70
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(spy.mock.calls[0][0].target).toBe(el);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("follows change: attribute, a new provision object, and detect-media-change", () => {
|
|
75
|
+
stubMatchMedia({ [COARSE]: false });
|
|
76
|
+
const el = mount(COARSE);
|
|
77
|
+
const fake = lastFake();
|
|
78
|
+
const change = vi.fn();
|
|
79
|
+
// bubbles, listen on the parent
|
|
80
|
+
el.parentElement!.addEventListener("detect-media-change", change);
|
|
81
|
+
const before = el.provision;
|
|
82
|
+
|
|
83
|
+
fake.flip(true);
|
|
84
|
+
|
|
85
|
+
expect(el.hasAttribute("is-matched")).toBe(true);
|
|
86
|
+
expect(el.provision).not.toBe(before);
|
|
87
|
+
expect(el.provision).toEqual({ mediaQuery: COARSE, isMatched: true });
|
|
88
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(change.mock.calls[0][0].detail).toBe(el.provision);
|
|
90
|
+
expect(change.mock.calls[0][0].detail.isMatched).toBe(true);
|
|
91
|
+
|
|
92
|
+
fake.flip(false);
|
|
93
|
+
|
|
94
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
95
|
+
expect(el.provision).toEqual({ mediaQuery: COARSE, isMatched: false });
|
|
96
|
+
expect(change).toHaveBeenCalledTimes(2);
|
|
97
|
+
expect(change.mock.calls[1][0].detail.isMatched).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("re-subscribes when media-query changes and drops the old listener", () => {
|
|
101
|
+
stubMatchMedia({ [COARSE]: true, [DARK]: false });
|
|
102
|
+
const el = mount(COARSE);
|
|
103
|
+
const old = lastFake();
|
|
104
|
+
const handler = old.addEventListener.mock.calls[0][1];
|
|
105
|
+
|
|
106
|
+
el.setAttribute("media-query", DARK);
|
|
107
|
+
const next = lastFake();
|
|
108
|
+
|
|
109
|
+
expect(next).not.toBe(old);
|
|
110
|
+
expect(next.media).toBe(DARK);
|
|
111
|
+
expect(old.removeEventListener.mock.calls[0].slice(0, 2)).toEqual([
|
|
112
|
+
"change",
|
|
113
|
+
handler,
|
|
114
|
+
]);
|
|
115
|
+
expect(old.listeners.size).toBe(0);
|
|
116
|
+
expect(next.listeners.size).toBe(1);
|
|
117
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
118
|
+
expect(el.provision).toEqual({ mediaQuery: DARK, isMatched: false });
|
|
119
|
+
|
|
120
|
+
// the old list is inert now
|
|
121
|
+
old.flip(true);
|
|
122
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
123
|
+
|
|
124
|
+
next.flip(true);
|
|
125
|
+
expect(el.hasAttribute("is-matched")).toBe(true);
|
|
126
|
+
expect(el.provision).toEqual({ mediaQuery: DARK, isMatched: true });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("clears is-matched and provision when media-query is emptied", () => {
|
|
130
|
+
stubMatchMedia({ [COARSE]: true });
|
|
131
|
+
const el = mount(COARSE);
|
|
132
|
+
const fake = lastFake();
|
|
133
|
+
expect(el.hasAttribute("is-matched")).toBe(true);
|
|
134
|
+
|
|
135
|
+
el.removeAttribute("media-query");
|
|
136
|
+
|
|
137
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
138
|
+
expect(el.provision).toBe(null);
|
|
139
|
+
expect(el._mql).toBe(null);
|
|
140
|
+
expect(fake.listeners.size).toBe(0);
|
|
141
|
+
expect(window.matchMedia).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("leaves is-matched unset and provision null without a query", () => {
|
|
145
|
+
stubMatchMedia();
|
|
146
|
+
const el = mount();
|
|
147
|
+
expect(el).dom.to.equalTag(`<detect-media></detect-media>`);
|
|
148
|
+
expect(el.provision).toBe(null);
|
|
149
|
+
expect(window.matchMedia).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("removes its listener on disconnect", async () => {
|
|
153
|
+
stubMatchMedia({ [COARSE]: false });
|
|
154
|
+
const el = mount(COARSE);
|
|
155
|
+
const fake = lastFake();
|
|
156
|
+
const handler = fake.addEventListener.mock.calls[0][1];
|
|
157
|
+
|
|
158
|
+
el.remove();
|
|
159
|
+
await wait(0);
|
|
160
|
+
|
|
161
|
+
expect(fake.removeEventListener.mock.calls[0].slice(0, 2)).toEqual([
|
|
162
|
+
"change",
|
|
163
|
+
handler,
|
|
164
|
+
]);
|
|
165
|
+
expect(fake.listeners.size).toBe(0);
|
|
166
|
+
expect(el._mql).toBe(null);
|
|
167
|
+
|
|
168
|
+
fake.flip(true);
|
|
169
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("keeps listening across a synchronous move", async () => {
|
|
173
|
+
stubMatchMedia({ [COARSE]: false });
|
|
174
|
+
const el = mount(COARSE);
|
|
175
|
+
const parent = el.parentElement!;
|
|
176
|
+
const fake = lastFake();
|
|
177
|
+
|
|
178
|
+
el.remove();
|
|
179
|
+
parent.append(el);
|
|
180
|
+
await wait(0);
|
|
181
|
+
|
|
182
|
+
expect(window.matchMedia).toHaveBeenCalledTimes(1);
|
|
183
|
+
expect(el._mql).toBe(fake);
|
|
184
|
+
expect(fake.listeners.size).toBe(1);
|
|
185
|
+
|
|
186
|
+
fake.flip(true);
|
|
187
|
+
expect(el.hasAttribute("is-matched")).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("re-evaluates on a genuine reconnect", async () => {
|
|
191
|
+
stubMatchMedia({ [COARSE]: false });
|
|
192
|
+
const el = mount(COARSE);
|
|
193
|
+
const first = lastFake();
|
|
194
|
+
const change = vi.fn();
|
|
195
|
+
document.body.addEventListener("detect-media-change", change);
|
|
196
|
+
|
|
197
|
+
el.remove();
|
|
198
|
+
await wait(0);
|
|
199
|
+
|
|
200
|
+
// the query now matches, a fresh list reports it
|
|
201
|
+
stubMatchMedia({ [COARSE]: true });
|
|
202
|
+
document.body.append(el);
|
|
203
|
+
await wait(0);
|
|
204
|
+
|
|
205
|
+
const second = lastFake();
|
|
206
|
+
expect(second).not.toBe(first);
|
|
207
|
+
expect(el._mql).toBe(second);
|
|
208
|
+
expect(first.listeners.size).toBe(0);
|
|
209
|
+
expect(second.listeners.size).toBe(1);
|
|
210
|
+
expect(el.hasAttribute("is-matched")).toBe(true);
|
|
211
|
+
expect(el.provision).toEqual({ mediaQuery: COARSE, isMatched: true });
|
|
212
|
+
// a re-evaluation is not a change
|
|
213
|
+
expect(change).not.toHaveBeenCalled();
|
|
214
|
+
|
|
215
|
+
second.flip(false);
|
|
216
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
217
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { vi } from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
2
|
+
|
|
3
|
+
export type FakeMediaQueryList = {
|
|
4
|
+
media: string;
|
|
5
|
+
matches: boolean;
|
|
6
|
+
listeners: Set<EventListener>;
|
|
7
|
+
addEventListener: ReturnType<typeof vi.fn>;
|
|
8
|
+
removeEventListener: ReturnType<typeof vi.fn>;
|
|
9
|
+
/** Set `matches` and dispatch `change` to every subscribed listener. */
|
|
10
|
+
flip: (matches: boolean) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const fakes: FakeMediaQueryList[] = [];
|
|
14
|
+
|
|
15
|
+
const createFake = (media: string, matches: boolean): FakeMediaQueryList => {
|
|
16
|
+
const listeners = new Set<EventListener>();
|
|
17
|
+
const fake: FakeMediaQueryList = {
|
|
18
|
+
media,
|
|
19
|
+
matches,
|
|
20
|
+
listeners,
|
|
21
|
+
addEventListener: vi.fn((_type: string, fn: EventListener) => {
|
|
22
|
+
listeners.add(fn);
|
|
23
|
+
}),
|
|
24
|
+
removeEventListener: vi.fn((_type: string, fn: EventListener) => {
|
|
25
|
+
listeners.delete(fn);
|
|
26
|
+
}),
|
|
27
|
+
flip(next) {
|
|
28
|
+
fake.matches = next;
|
|
29
|
+
const event = Object.assign(new Event("change"), { matches: next, media });
|
|
30
|
+
[...listeners].forEach((fn) => fn(event));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
fakes.push(fake);
|
|
34
|
+
return fake;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stub `window.matchMedia`. Each call returns a fresh fake whose
|
|
39
|
+
* `matches` comes from `matching[query]` (default `false`).
|
|
40
|
+
*/
|
|
41
|
+
export const stubMatchMedia = (matching: Record<string, boolean> = {}) =>
|
|
42
|
+
vi
|
|
43
|
+
.spyOn(window, "matchMedia")
|
|
44
|
+
.mockImplementation(
|
|
45
|
+
(media: string) =>
|
|
46
|
+
createFake(media, matching[media] ?? false) as unknown as MediaQueryList
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export const lastFake = () => fakes[fakes.length - 1];
|
|
50
|
+
|
|
51
|
+
export const resetFakes = () => {
|
|
52
|
+
fakes.length = 0;
|
|
53
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import "@excom/include-content";
|
|
2
|
+
import "@excom/quark-sheet";
|
|
3
|
+
import "../../index";
|
|
4
|
+
import {
|
|
5
|
+
afterEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
vi,
|
|
10
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
11
|
+
import { flush, mountView, readDemo } from "@excom/quark/support/tests/view-helpers";
|
|
12
|
+
import { lastFake, resetFakes, stubMatchMedia } from "./fake-match-media";
|
|
13
|
+
|
|
14
|
+
const COARSE = "(pointer: coarse)";
|
|
15
|
+
|
|
16
|
+
describe("pointer view", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
resetFakes();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("activates the touch or pointer layout from is-matched, live", async () => {
|
|
24
|
+
stubMatchMedia({ [COARSE]: true });
|
|
25
|
+
const { root } = await mountView(readDemo(import.meta.url, "pointer"));
|
|
26
|
+
await flush();
|
|
27
|
+
const touch = root.querySelector("[bind-touch]")!;
|
|
28
|
+
const pointer = root.querySelector("[bind-pointer]")!;
|
|
29
|
+
|
|
30
|
+
expect(touch.hasAttribute("is-active")).toBe(true);
|
|
31
|
+
expect(pointer.hasAttribute("is-active")).toBe(false);
|
|
32
|
+
|
|
33
|
+
lastFake().flip(false);
|
|
34
|
+
await flush();
|
|
35
|
+
|
|
36
|
+
expect(pointer.hasAttribute("is-active")).toBe(true);
|
|
37
|
+
expect(touch.hasAttribute("is-active")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import "../../index";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
9
|
+
import { flush, mountView, readDemo } from "@excom/quark/support/tests/view-helpers";
|
|
10
|
+
import { lastFake, resetFakes, stubMatchMedia } from "./fake-match-media";
|
|
11
|
+
|
|
12
|
+
const DARK = "(prefers-color-scheme: dark)";
|
|
13
|
+
|
|
14
|
+
describe("simple view", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = "";
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
resetFakes();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("reflects the dark color scheme and follows its change", async () => {
|
|
22
|
+
stubMatchMedia({ [DARK]: true });
|
|
23
|
+
const { root } = await mountView(readDemo(import.meta.url, "simple"));
|
|
24
|
+
const el = root.querySelector("detect-media")!;
|
|
25
|
+
|
|
26
|
+
expect(el.getAttribute("media-query")).toBe(DARK);
|
|
27
|
+
expect(el.hasAttribute("is-matched")).toBe(true);
|
|
28
|
+
|
|
29
|
+
lastFake().flip(false);
|
|
30
|
+
await flush();
|
|
31
|
+
expect(el.hasAttribute("is-matched")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|