help-layer 1.0.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/LICENSE +15 -0
- package/README.ja.md +217 -0
- package/README.md +218 -0
- package/dist/help-layer.esm.js +139 -0
- package/dist/help-layer.esm.js.map +7 -0
- package/dist/help-layer.iife.js +139 -0
- package/dist/help-layer.iife.js.map +7 -0
- package/dist/types/blocking-layer.d.ts +6 -0
- package/dist/types/config.d.ts +44 -0
- package/dist/types/dom-builder.d.ts +16 -0
- package/dist/types/floating.d.ts +58 -0
- package/dist/types/geometry.d.ts +39 -0
- package/dist/types/index.d.ts +59 -0
- package/dist/types/markers.d.ts +24 -0
- package/dist/types/matcher.d.ts +79 -0
- package/dist/types/observer.d.ts +32 -0
- package/dist/types/overlap.d.ts +29 -0
- package/dist/types/popup.d.ts +22 -0
- package/dist/types/state.d.ts +10 -0
- package/dist/types/style.d.ts +20 -0
- package/dist/types/toggle.d.ts +41 -0
- package/package.json +81 -0
- package/src/blocking-layer.js +131 -0
- package/src/config.js +81 -0
- package/src/dom-builder.js +59 -0
- package/src/floating.js +122 -0
- package/src/geometry.js +41 -0
- package/src/index.js +40 -0
- package/src/markers.js +185 -0
- package/src/matcher.js +133 -0
- package/src/observer.js +146 -0
- package/src/overlap.js +71 -0
- package/src/popup.js +120 -0
- package/src/state.js +21 -0
- package/src/style.js +183 -0
- package/src/toggle.js +250 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Y1-Effy
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# HelpLayer
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/help-layer)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://github.com/Y1-Effy/HelpLayer)
|
|
6
|
+
|
|
7
|
+
[English](./README.md) | **日本語**
|
|
8
|
+
|
|
9
|
+
既存の Web アプリに**後付けできる、フレームワーク非依存の「解説モード」ライブラリ**です。
|
|
10
|
+
モード ON 中だけ対象要素の近くに「?」マーカーを出し、クリックで説明ポップアップを表示します。
|
|
11
|
+
元アプリのイベントには一切触れず、透明な遮断レイヤーで操作を吸収するので、既存コードを書き換えずに導入できます。
|
|
12
|
+
|
|
13
|
+
- 依存は [`@floating-ui/dom`](https://floating-ui.com/) のみ・軽量(プリビルドの IIFE で約 30KB / min、`@floating-ui/dom` 同梱)
|
|
14
|
+
- Shadow DOM 貫通・SPA の動的要素・マーカー同士の重なり回避・画面端でのポップアップ自動調整に対応
|
|
15
|
+
- ON→OFF で追加した DOM・イベント・スタイルを**完全後始末**
|
|
16
|
+
|
|
17
|
+
## インストール
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install help-layer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
バンドラを使わず `<script>` 1本で導入したい場合は、プリビルドの IIFE を読み込めばグローバル `HelpLayer` が生えます(後述)。
|
|
24
|
+
|
|
25
|
+
## クイックスタート
|
|
26
|
+
|
|
27
|
+
### 1. config オブジェクトで定義する
|
|
28
|
+
|
|
29
|
+
対象要素に `data-help-id` を付け、その値をキーにした説明を渡します。
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<button data-help-id="save">保存</button>
|
|
33
|
+
<button id="help-toggle">解説モード</button>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { initHelpLayer } from 'help-layer';
|
|
38
|
+
|
|
39
|
+
initHelpLayer({
|
|
40
|
+
toggle: '#help-toggle',
|
|
41
|
+
config: {
|
|
42
|
+
save: { title: '保存', text: '入力内容を保存します。' },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. マークアップに直接書く(config なしでも可)
|
|
48
|
+
|
|
49
|
+
説明をマークアップと同居させたい場合は、`data-help-title` / `data-help-text` を要素に直接書くだけで対象になります。
|
|
50
|
+
`config` と併用でき、**同じキーが config にあれば config が優先**されます。
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<button data-help-title="保存" data-help-text="入力内容を保存します。">保存</button>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
initHelpLayer({ toggle: '#help-toggle', config: {} });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `<script>` だけで使う(バンドラなし)
|
|
61
|
+
|
|
62
|
+
CDN から読む場合は、改ざん検知のため **バージョンを固定** し、**SRI(`integrity`)** を付けることを推奨します。
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<script
|
|
66
|
+
src="https://unpkg.com/help-layer@1.0.0/dist/help-layer.iife.js"
|
|
67
|
+
integrity="sha384-……(公開版のハッシュに差し替え)"
|
|
68
|
+
crossorigin="anonymous"></script>
|
|
69
|
+
<script>
|
|
70
|
+
HelpLayer.initHelpLayer({
|
|
71
|
+
toggle: '#help-toggle',
|
|
72
|
+
config: { save: { title: '保存', text: '入力内容を保存します。' } },
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> `integrity` のハッシュは公開した実ファイルから生成します。例:
|
|
78
|
+
> `curl -s https://unpkg.com/help-layer@1.0.0/dist/help-layer.iife.js | openssl dgst -sha384 -binary | openssl base64 -A`
|
|
79
|
+
> (バージョンを固定しないと SRI と不整合になり読み込みが拒否されます。)
|
|
80
|
+
|
|
81
|
+
## 自由配置(要素に紐づけない説明)
|
|
82
|
+
|
|
83
|
+
`position` を指定すると、特定要素ではなくページ座標にマーカーを置けます(画面全体の説明などに)。
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
config: {
|
|
87
|
+
intro: { title: 'この画面について', text: '…', position: { top: 80, left: 560 } },
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
const help = initHelpLayer(options);
|
|
95
|
+
help.enable(); // ON
|
|
96
|
+
help.disable(); // OFF
|
|
97
|
+
help.toggle(); // 反転
|
|
98
|
+
help.isActive(); // boolean
|
|
99
|
+
help.open(key); // 指定キーの説明を開く(OFF 中なら自動で ON)
|
|
100
|
+
help.close(); // 開いている説明を閉じる(モードは ON のまま)
|
|
101
|
+
help.update(newConfig); // config を差し替え(ON 中なら無音で再構築。onEnable/onDisable は呼ばれない)
|
|
102
|
+
help.destroy(); // リスナー解除+完全後始末
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### options
|
|
106
|
+
|
|
107
|
+
| オプション | 型 | 既定 | 説明 |
|
|
108
|
+
|------|------|------|------|
|
|
109
|
+
| `config` | `object` | (必須) | キー→`{ title, text, position? }`。`data-help-id` 値 or 自由配置キー |
|
|
110
|
+
| `toggle` | `string \| HTMLElement` | なし | ON/OFF するトグル要素。省略時は API 制御のみ |
|
|
111
|
+
| `attribute` | `string` | `'data-help-id'` | 対象を示す属性名 |
|
|
112
|
+
| `render` | `(record) => Node \| null` | なし | 本文を自前 DOM で描画。返り値が無ければ安全なテキスト表示にフォールバック(タイトルは常に `record.title`) |
|
|
113
|
+
| `markerLabel` | `string` | `'?'` | マーカーに表示する文字 |
|
|
114
|
+
| `markerPlacement` | `Placement` | `'top-end'` | マーカーを重ねる隅(`top-end`/`top-start`/`bottom-end`/`bottom-start`) |
|
|
115
|
+
| `popupPlacement` | `Placement` | `'bottom-start'` | ポップアップ初期配置(画面端では自動で flip/shift) |
|
|
116
|
+
| `nonce` | `string` | なし | 厳格な CSP(`style-src 'nonce-…'`)下で注入 `<style>` を許可するための nonce(後述) |
|
|
117
|
+
| `silent` | `boolean` | `false` | 未登録キーの警告ログを抑止 |
|
|
118
|
+
|
|
119
|
+
### コールバック
|
|
120
|
+
|
|
121
|
+
| オプション | タイミング |
|
|
122
|
+
|------|------|
|
|
123
|
+
| `onEnable` | モードを ON にした直後 |
|
|
124
|
+
| `onDisable` | モードを OFF にした直後 |
|
|
125
|
+
| `onOpen(record)` | 説明ポップアップを開いた時 |
|
|
126
|
+
| `onClose` | 説明ポップアップを閉じた時 |
|
|
127
|
+
|
|
128
|
+
> ※ ON 中に説明を開いたまま `update()` / `disable()` / `destroy()` すると、後始末で説明が閉じるため `onClose` が一度発火します。
|
|
129
|
+
|
|
130
|
+
### 本文に改行を入れる / リンクを置く
|
|
131
|
+
|
|
132
|
+
本文は安全のため既定で `textContent`(HTML を解釈しない)ですが、`\n` は改行として表示されます。
|
|
133
|
+
リンクや装飾が必要なら `render` で任意の DOM を返してください。
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
initHelpLayer({
|
|
137
|
+
config,
|
|
138
|
+
render(record) {
|
|
139
|
+
if (record.key !== 'save') {
|
|
140
|
+
return null; // 既定のテキスト表示にフォールバック
|
|
141
|
+
}
|
|
142
|
+
const a = document.createElement('a');
|
|
143
|
+
a.href = '/docs/save';
|
|
144
|
+
a.textContent = 'くわしくはこちら';
|
|
145
|
+
return a;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> ⚠️ **セキュリティ:** `render` が返した DOM は**そのまま挿入され、ライブラリ側ではサニタイズしません**。
|
|
151
|
+
> ユーザー入力など未信頼のデータを使う場合は、`innerHTML` で組み立てず `textContent` を使うか、
|
|
152
|
+
> [DOMPurify](https://github.com/cure53/DOMPurify) 等で無害化してから返してください(XSS 防止)。
|
|
153
|
+
> 既定(`render` 未指定)の `title`/`text` 描画は `textContent` なので安全です。
|
|
154
|
+
|
|
155
|
+
## テーマ(CSS カスタムプロパティ)
|
|
156
|
+
|
|
157
|
+
見た目はホスト側 CSS で以下の変数を上書きするだけで変えられます。ダークモード(`prefers-color-scheme: dark`)の
|
|
158
|
+
既定値も内蔵していますが、変数を指定すればそちらが常に優先されます。
|
|
159
|
+
|
|
160
|
+
| 変数 | 既定 | 用途 |
|
|
161
|
+
|------|------|------|
|
|
162
|
+
| `--help-layer-marker-size` | `22px` | マーカー直径 |
|
|
163
|
+
| `--help-layer-marker-bg` | `#2563eb` | マーカー背景色 |
|
|
164
|
+
| `--help-layer-marker-color` | `#fff` | マーカー文字色 |
|
|
165
|
+
| `--help-layer-popup-bg` | `#fff` | ポップアップ背景色 |
|
|
166
|
+
| `--help-layer-popup-color` | `#1f2933` | ポップアップ文字色 |
|
|
167
|
+
| `--help-layer-popup-max-width` | `280px` | ポップアップ最大幅 |
|
|
168
|
+
| `--help-layer-popup-max-height` | `50vh` | ポップアップ本文の最大高さ(超過時は本文のみスクロール) |
|
|
169
|
+
| `--help-layer-accent` | `#1d4ed8` | フォーカスリング色 |
|
|
170
|
+
| `--help-layer-overlay-bg` | `transparent` | 遮断レイヤー(スクリム)背景色。`rgba(0,0,0,0.15)` 等で操作不能状態を可視化 |
|
|
171
|
+
| `--help-layer-overlay-cursor` | `default` | 遮断領域上のカーソル。`not-allowed` / `help` 等 |
|
|
172
|
+
|
|
173
|
+
## 既知の制約
|
|
174
|
+
|
|
175
|
+
- closed な Shadow DOM は JS から到達できないため非対応(open のみ貫通)。
|
|
176
|
+
- マーカーを隅へ重ねるオフセットは既定マーカーサイズ(22px)前提。`--help-layer-marker-size` を大きく変えると
|
|
177
|
+
わずかにズレることがあります。
|
|
178
|
+
|
|
179
|
+
## セキュリティ
|
|
180
|
+
|
|
181
|
+
- 設計上、`title` / `text` の描画は `textContent` のみで、`innerHTML` / `eval` / `new Function` は**一切使いません**。
|
|
182
|
+
- 外部通信(`fetch` 等)・`localStorage` / `cookie` などのストレージ利用も**ありません**(完全ローカル動作)。
|
|
183
|
+
- 唯一、未信頼データが DOM に届きうる経路は `render` オプションです。戻り値はサニタイズされないため、
|
|
184
|
+
ユーザー入力を含む場合は呼び出し側で無害化してください(上記「本文に改行を入れる / リンクを置く」参照)。
|
|
185
|
+
- ランタイム依存は `@floating-ui/dom` のみ。CDN 利用時は前述のとおりバージョン固定+SRI を推奨します。
|
|
186
|
+
|
|
187
|
+
### Content Security Policy(CSP)
|
|
188
|
+
|
|
189
|
+
本ライブラリは `innerHTML` / `eval` を使わないため **Trusted Types(`require-trusted-types-for 'script'`)に
|
|
190
|
+
そのまま対応** しています。位置決めは要素の `.style`(CSSOM)への直接代入で行うため CSP の対象外です。
|
|
191
|
+
|
|
192
|
+
一点だけ注意が必要なのは、見た目用に注入する **`<style>` タグ** です。`style-src` に `'unsafe-inline'` も
|
|
193
|
+
nonce も無い**厳格な CSP** ではこの `<style>` がブロックされ、マーカーやポップアップのスタイルが当たりません。
|
|
194
|
+
`style-src 'nonce-…'` 運用のサイトでは、リクエストごとの nonce を `nonce` オプションで渡してください。
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
// サーバが毎リクエスト発行する nonce(CSP ヘッダの style-src 'nonce-xxxx' と同じ値)を渡す
|
|
198
|
+
initHelpLayer({ config, toggle: '#help-toggle', nonce: pageNonce });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
これで注入される `<style nonce="xxxx">` が CSP に許可され、厳格 CSP 下でも正しく表示されます。
|
|
202
|
+
`'unsafe-inline'` を許可しているサイトや CSP 未設定のサイトでは `nonce` は不要です。
|
|
203
|
+
|
|
204
|
+
## 開発
|
|
205
|
+
|
|
206
|
+
| 目的 | コマンド |
|
|
207
|
+
|------|----------|
|
|
208
|
+
| テスト | `npm test` |
|
|
209
|
+
| Lint / 型チェック / 一括 | `npm run lint` / `npm run typecheck` / `npm run check` |
|
|
210
|
+
| デモ起動 | `npm run demo` |
|
|
211
|
+
| 配布物ビルド | `npm run build`(`dist/` に ESM・IIFE・型定義) |
|
|
212
|
+
|
|
213
|
+
## リポジトリ
|
|
214
|
+
|
|
215
|
+
- ソース: <https://github.com/Y1-Effy/HelpLayer>
|
|
216
|
+
- バグ報告・要望: <https://github.com/Y1-Effy/HelpLayer/issues>
|
|
217
|
+
- ライセンス: [ISC](./LICENSE)
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# HelpLayer
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/help-layer)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://github.com/Y1-Effy/HelpLayer)
|
|
6
|
+
|
|
7
|
+
**English** | [日本語](./README.ja.md)
|
|
8
|
+
|
|
9
|
+
A **framework-agnostic "help mode" library you can drop into any existing web app**.
|
|
10
|
+
While the mode is ON, it shows a "?" marker next to each target element; clicking it opens a description popup.
|
|
11
|
+
It never touches the host app's own event listeners — a transparent blocking layer absorbs interaction instead — so you can adopt it without rewriting existing code.
|
|
12
|
+
|
|
13
|
+
- Only one dependency, [`@floating-ui/dom`](https://floating-ui.com/); lightweight (the prebuilt IIFE is ~30KB minified, with `@floating-ui/dom` bundled in)
|
|
14
|
+
- Pierces Shadow DOM, follows SPA dynamic elements, avoids marker-to-marker overlap, and auto-adjusts the popup at screen edges
|
|
15
|
+
- Fully cleans up the DOM, listeners, and styles it added when you turn it OFF
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install help-layer
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If you'd rather drop it in with a single `<script>` and no bundler, load the prebuilt IIFE and a global `HelpLayer` is exposed (see below).
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### 1. Define targets with a config object
|
|
28
|
+
|
|
29
|
+
Add `data-help-id` to a target element and pass a description keyed by that value.
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<button data-help-id="save">Save</button>
|
|
33
|
+
<button id="help-toggle">Help mode</button>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { initHelpLayer } from 'help-layer';
|
|
38
|
+
|
|
39
|
+
initHelpLayer({
|
|
40
|
+
toggle: '#help-toggle',
|
|
41
|
+
config: {
|
|
42
|
+
save: { title: 'Save', text: 'Saves your input.' },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Write it inline in your markup (no config needed)
|
|
48
|
+
|
|
49
|
+
If you'd rather keep descriptions next to your markup, just add `data-help-title` / `data-help-text` to an element and it becomes a target.
|
|
50
|
+
This can be combined with `config`, and **if the same key exists in `config`, the config wins**.
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<button data-help-title="Save" data-help-text="Saves your input.">Save</button>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
initHelpLayer({ toggle: '#help-toggle', config: {} });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Use it with just a `<script>` (no bundler)
|
|
61
|
+
|
|
62
|
+
When loading from a CDN, we recommend **pinning the version** and adding **SRI (`integrity`)** so tampering is detectable.
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<script
|
|
66
|
+
src="https://unpkg.com/help-layer@1.0.0/dist/help-layer.iife.js"
|
|
67
|
+
integrity="sha384-……(replace with the published file's hash)"
|
|
68
|
+
crossorigin="anonymous"></script>
|
|
69
|
+
<script>
|
|
70
|
+
HelpLayer.initHelpLayer({
|
|
71
|
+
toggle: '#help-toggle',
|
|
72
|
+
config: { save: { title: 'Save', text: 'Saves your input.' } },
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> Generate the `integrity` hash from the actually published file, e.g.:
|
|
78
|
+
> `curl -s https://unpkg.com/help-layer@1.0.0/dist/help-layer.iife.js | openssl dgst -sha384 -binary | openssl base64 -A`
|
|
79
|
+
> (If you don't pin the version, the SRI will mismatch and the browser will refuse to load it.)
|
|
80
|
+
|
|
81
|
+
## Free placement (descriptions not bound to an element)
|
|
82
|
+
|
|
83
|
+
Specify `position` to place a marker at a page coordinate instead of on a specific element (useful for whole-screen descriptions, etc.).
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
config: {
|
|
87
|
+
intro: { title: 'About this screen', text: '…', position: { top: 80, left: 560 } },
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
const help = initHelpLayer(options);
|
|
95
|
+
help.enable(); // ON
|
|
96
|
+
help.disable(); // OFF
|
|
97
|
+
help.toggle(); // flip ON/OFF
|
|
98
|
+
help.isActive(); // boolean
|
|
99
|
+
help.open(key); // open the description for the given key (auto-enables if OFF)
|
|
100
|
+
help.close(); // close the open description (the mode stays ON)
|
|
101
|
+
help.update(newConfig); // replace the config (rebuilds silently if ON; onEnable/onDisable are not called)
|
|
102
|
+
help.destroy(); // detach listeners + full cleanup
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Options
|
|
106
|
+
|
|
107
|
+
| Option | Type | Default | Description |
|
|
108
|
+
|------|------|------|------|
|
|
109
|
+
| `config` | `object` | (required) | key → `{ title, text, position? }`. The key is a `data-help-id` value or a free-placement key |
|
|
110
|
+
| `toggle` | `string \| HTMLElement` | none | the toggle element that switches ON/OFF. If omitted, control is programmatic-only |
|
|
111
|
+
| `attribute` | `string` | `'data-help-id'` | attribute name marking targets |
|
|
112
|
+
| `render` | `(record) => Node \| null` | none | render the body with your own DOM. Falls back to safe text display when nothing is returned (the title is always `record.title`) |
|
|
113
|
+
| `markerLabel` | `string` | `'?'` | the character shown on the marker |
|
|
114
|
+
| `markerPlacement` | `Placement` | `'top-end'` | corner to overlap the marker onto (`top-end`/`top-start`/`bottom-end`/`bottom-start`) |
|
|
115
|
+
| `popupPlacement` | `Placement` | `'bottom-start'` | initial popup placement (flips/shifts automatically at screen edges) |
|
|
116
|
+
| `nonce` | `string` | none | nonce to allow the injected `<style>` under a strict CSP (`style-src 'nonce-…'`); see below |
|
|
117
|
+
| `silent` | `boolean` | `false` | suppress the warning log for unregistered keys |
|
|
118
|
+
|
|
119
|
+
### Callbacks
|
|
120
|
+
|
|
121
|
+
| Option | When it fires |
|
|
122
|
+
|------|------|
|
|
123
|
+
| `onEnable` | right after the mode is turned ON |
|
|
124
|
+
| `onDisable` | right after the mode is turned OFF |
|
|
125
|
+
| `onOpen(record)` | when a description popup is opened |
|
|
126
|
+
| `onClose` | when a description popup is closed |
|
|
127
|
+
|
|
128
|
+
> Note: if a description is open when you call `update()` / `disable()` / `destroy()`, the cleanup closes it, so `onClose` fires once.
|
|
129
|
+
|
|
130
|
+
### Line breaks & links in the body
|
|
131
|
+
|
|
132
|
+
For safety the body is rendered with `textContent` by default (HTML is not interpreted), but `\n` is shown as a line break.
|
|
133
|
+
If you need links or styling, return your own DOM from `render`.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
initHelpLayer({
|
|
137
|
+
config,
|
|
138
|
+
render(record) {
|
|
139
|
+
if (record.key !== 'save') {
|
|
140
|
+
return null; // fall back to the default text display
|
|
141
|
+
}
|
|
142
|
+
const a = document.createElement('a');
|
|
143
|
+
a.href = '/docs/save';
|
|
144
|
+
a.textContent = 'Learn more';
|
|
145
|
+
return a;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> ⚠️ **Security:** the DOM returned by `render` is **inserted as-is and is not sanitized by the library**.
|
|
151
|
+
> If you use untrusted data (e.g. user input), don't build it with `innerHTML` — use `textContent`, or
|
|
152
|
+
> neutralize it with something like [DOMPurify](https://github.com/cure53/DOMPurify) before returning it (to prevent XSS).
|
|
153
|
+
> The default (no `render`) `title`/`text` rendering uses `textContent`, so it is safe.
|
|
154
|
+
|
|
155
|
+
## Theming (CSS custom properties)
|
|
156
|
+
|
|
157
|
+
You can change the look just by overriding the following variables in your host CSS. Dark-mode defaults
|
|
158
|
+
(`prefers-color-scheme: dark`) are built in, but any variable you set always wins via `var()`.
|
|
159
|
+
|
|
160
|
+
| Variable | Default | Purpose |
|
|
161
|
+
|------|------|------|
|
|
162
|
+
| `--help-layer-marker-size` | `22px` | marker diameter |
|
|
163
|
+
| `--help-layer-marker-bg` | `#2563eb` | marker background color |
|
|
164
|
+
| `--help-layer-marker-color` | `#fff` | marker text color |
|
|
165
|
+
| `--help-layer-popup-bg` | `#fff` | popup background color |
|
|
166
|
+
| `--help-layer-popup-color` | `#1f2933` | popup text color |
|
|
167
|
+
| `--help-layer-popup-max-width` | `280px` | popup max width |
|
|
168
|
+
| `--help-layer-popup-max-height` | `50vh` | popup body max height (the body scrolls when exceeded) |
|
|
169
|
+
| `--help-layer-accent` | `#1d4ed8` | focus ring color |
|
|
170
|
+
| `--help-layer-overlay-bg` | `transparent` | blocking-layer (scrim) background; e.g. `rgba(0,0,0,0.15)` to signal the host is inactive |
|
|
171
|
+
| `--help-layer-overlay-cursor` | `default` | cursor over the blocked area; e.g. `not-allowed` / `help` |
|
|
172
|
+
|
|
173
|
+
## Known limitations
|
|
174
|
+
|
|
175
|
+
- Closed Shadow DOM is unreachable from JS, so it is unsupported (only open shadow roots are pierced).
|
|
176
|
+
- The offset that overlaps the marker onto a corner assumes the default marker size (22px). Changing
|
|
177
|
+
`--help-layer-marker-size` significantly may cause a slight drift.
|
|
178
|
+
|
|
179
|
+
## Security
|
|
180
|
+
|
|
181
|
+
- By design, `title` / `text` are rendered with `textContent` only; `innerHTML` / `eval` / `new Function` are **never used**.
|
|
182
|
+
- There is **no external communication** (`fetch`, etc.) and **no storage use** (`localStorage` / `cookie`) — it runs fully locally.
|
|
183
|
+
- The only path through which untrusted data could reach the DOM is the `render` option. Its return value is not sanitized, so
|
|
184
|
+
neutralize it on the caller side if it contains user input (see "Line breaks & links in the body" above).
|
|
185
|
+
- The only runtime dependency is `@floating-ui/dom`. When using a CDN, pin the version and add SRI as noted above.
|
|
186
|
+
|
|
187
|
+
### Content Security Policy (CSP)
|
|
188
|
+
|
|
189
|
+
Because this library never uses `innerHTML` / `eval`, it **works as-is with Trusted Types
|
|
190
|
+
(`require-trusted-types-for 'script'`)**. Positioning is done by assigning directly to an element's `.style` (CSSOM),
|
|
191
|
+
which is outside the scope of CSP.
|
|
192
|
+
|
|
193
|
+
The one thing to watch out for is the **`<style>` tag** injected for appearance. Under a **strict CSP** whose
|
|
194
|
+
`style-src` has neither `'unsafe-inline'` nor a nonce, this `<style>` is blocked and the markers/popup get no styles.
|
|
195
|
+
On sites that operate with `style-src 'nonce-…'`, pass the per-request nonce via the `nonce` option.
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
// pass the nonce your server issues per request (the same value as `style-src 'nonce-xxxx'` in the CSP header)
|
|
199
|
+
initHelpLayer({ config, toggle: '#help-toggle', nonce: pageNonce });
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
This lets the injected `<style nonce="xxxx">` be allowed by the CSP, so it renders correctly even under a strict CSP.
|
|
203
|
+
On sites that allow `'unsafe-inline'` or have no CSP, `nonce` is not needed.
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
| Purpose | Command |
|
|
208
|
+
|------|----------|
|
|
209
|
+
| Test | `npm test` |
|
|
210
|
+
| Lint / typecheck / all | `npm run lint` / `npm run typecheck` / `npm run check` |
|
|
211
|
+
| Run the demo | `npm run demo` |
|
|
212
|
+
| Build the distribution | `npm run build` (emits ESM, IIFE, and type definitions to `dist/`) |
|
|
213
|
+
|
|
214
|
+
## Repository
|
|
215
|
+
|
|
216
|
+
- Source: <https://github.com/Y1-Effy/HelpLayer>
|
|
217
|
+
- Issues & requests: <https://github.com/Y1-Effy/HelpLayer/issues>
|
|
218
|
+
- License: [ISC](./LICENSE)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
var D="help-layer-popup-title";function z(){let e=document.createElement("div");return e.className="help-layer-blocking-layer",e}function B(e,t="?"){let n=document.createElement("button");return n.type="button",n.className="help-layer-marker",n.textContent=t,n.setAttribute("aria-label",`Help: ${e}`),n}function j(){let e=document.createElement("div");e.className="help-layer-popup",e.setAttribute("role","dialog"),e.setAttribute("aria-labelledby",D),e.tabIndex=-1;let t=document.createElement("div");t.className="help-layer-popup__title",t.id=D;let n=document.createElement("div");n.className="help-layer-popup__text";let r=document.createElement("button");return r.type="button",r.className="help-layer-popup__close",r.textContent="\xD7",r.setAttribute("aria-label","Close"),e.append(t,n,r),{root:e,titleEl:t,textEl:n,closeEl:r}}import{autoUpdate as P,computePosition as H,flip as ye,offset as K,shift as xe}from"@floating-ui/dom";function F(e,t){let n=e.width||0,r=e.height||0,o=e.left-t.x,i=e.top-t.y;return{x:o,y:i,left:o,top:i,right:o+n,bottom:i+r,width:n,height:r}}function q(e){return{contextElement:document.body,getBoundingClientRect(){return F(e(),{x:window.scrollX,y:window.scrollY})}}}function Z(e,t,n){e.style.left=`${t}px`,e.style.top=`${n}px`}var T=11;function ge(e){let t=e.endsWith("-start");return{mainAxis:-T,crossAxis:t?T:-T}}function G(e,t,n,r="top-end"){return P(e,t,()=>{H(e,t,{placement:r,middleware:[K(ge(r))]}).then(({x:i,y:p})=>{Z(t,i,p),n&&n()})},{animationFrame:!0})}function V(e,t,n="bottom-start"){let r=()=>{H(e,t,{placement:n,middleware:[K(8),ye({padding:8}),xe({padding:8})]}).then(({x:i,y:p})=>{Z(t,i,p)})},o=P(e,t,r,{animationFrame:!0});return{update:r,cleanup:o}}function U(e,t,n){return P(e,t,n)}function be(e){let t=e.left,n=e.top,r=e.right,o=e.bottom;return`polygon(
|
|
2
|
+
0px 0px, 100% 0px, 100% 100%, 0px 100%, 0px 0px,
|
|
3
|
+
${t}px ${n}px, ${t}px ${o}px, ${r}px ${o}px, ${r}px ${n}px, ${t}px ${n}px
|
|
4
|
+
)`}function W(e,{toggleEl:t,onBackgroundClick:n,isLibraryElement:r,onEscape:o}){let i=z();if(document.body.appendChild(i),e.track(()=>i.remove()),t){let g=U(t,i,()=>{i.style.clipPath=be(t.getBoundingClientRect())});e.track(g)}n&&(i.addEventListener("click",n),e.track(()=>i.removeEventListener("click",n)));let p=document.activeElement;p instanceof HTMLElement&&p!==document.body&&p!==t&&p.blur();let h=c=>{r(c.target)||(c.stopPropagation(),t?t.focus({preventScroll:!0}):c.target instanceof HTMLElement&&c.target.blur())};document.addEventListener("focusin",h,!0),e.track(()=>document.removeEventListener("focusin",h,!0));let d=c=>{r(c.target)||(c.stopPropagation(),c.preventDefault())},y=c=>{if(c.key==="Escape"){c.stopPropagation(),c.preventDefault();return}d(c)},m=c=>{if(c.key==="Escape"){c.stopPropagation(),c.preventDefault(),o&&o();return}d(c)};return document.addEventListener("keydown",m,!0),document.addEventListener("keyup",y,!0),document.addEventListener("keypress",y,!0),e.track(()=>{document.removeEventListener("keydown",m,!0),document.removeEventListener("keyup",y,!0),document.removeEventListener("keypress",y,!0)}),i}function S(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function Y(e){return S(e)&&typeof e.top=="number"&&typeof e.left=="number"}function R(e){if(!S(e))throw new Error("helpConfig must be a plain object");for(let[t,n]of Object.entries(e)){if(!S(n))throw new Error(`helpConfig["${t}"] must be an object`);if(typeof n.title!="string"||n.title==="")throw new Error(`helpConfig["${t}"].title must be a non-empty string`);if(typeof n.text!="string"||n.text==="")throw new Error(`helpConfig["${t}"].text must be a non-empty string`);if(n.position!==void 0&&!Y(n.position))throw new Error(`helpConfig["${t}"].position must be { top: number, left: number }`)}}function X(e){return Object.entries(e).map(([t,n])=>Y(n.position)?{key:t,title:n.title,text:n.text,kind:"free",target:null,position:{top:n.position.top,left:n.position.left}}:{key:t,title:n.title,text:n.text,kind:"element",target:null,position:null})}function J(e,t={}){let n=t.minDistance??26,r=t.iterations??6,o=e.map(i=>({x:i.x,y:i.y}));for(let i=0;i<r;i++){let p=!1;for(let h=0;h<o.length;h++)for(let d=h+1;d<o.length;d++){let y=o[h],m=o[d],c=m.x-y.x,g=m.y-y.y,s=Math.hypot(c,g);if(s>=n)continue;s===0&&(c=1,g=0,s=1);let l=(n-s)/2,f=c/s,a=g/s;y.x-=f*l,y.y-=a*l,m.x+=f*l,m.y+=a*l,p=!0}if(!p)break}return o.map((i,p)=>({dx:i.x-e[p].x,dy:i.y-e[p].y}))}var Q="help-layer-target-highlight";function ve(e){return e.kind==="free"?q(()=>({top:e.position.top,left:e.position.left,width:0,height:0})):e.target}function ee(e,{onMarkerClick:t,onOverlapResolved:n,markerLabel:r="?",markerPlacement:o="top-end"}){let i=new Map,p=null,h=!1;function d(){p=null;let s=[...i.values()];if(s.length<=1){let a=s.length===1?s[0].el:null;a&&a.style.transform&&(a.style.transform="",n&&n());return}s.forEach(a=>{a.el.style.transform=""});let l=s.map(a=>{let u=a.el.getBoundingClientRect();return{x:u.left+u.width/2,y:u.top+u.height/2}}),f=J(l);s.forEach((a,u)=>{let{dx:b,dy:v}=f[u];a.el.style.transform=b||v?`translate(${b}px, ${v}px)`:""}),n&&n()}function y(){p!==null||h||(p=requestAnimationFrame(d))}function m(s){if(i.has(s.id))return;let l=B(s.title,r);document.body.appendChild(l);let f=()=>t(s,l);l.addEventListener("click",f);let a=G(ve(s),l,y,o),u=s.kind==="element"?s.target:null,b=()=>u&&u.classList.add(Q),v=()=>u&&u.classList.remove(Q);u&&(l.addEventListener("mouseenter",b),l.addEventListener("mouseleave",v),l.addEventListener("focus",b),l.addEventListener("blur",v));let w=!1,L=()=>{w||(w=!0,a(),l.removeEventListener("click",f),u&&(l.removeEventListener("mouseenter",b),l.removeEventListener("mouseleave",v),l.removeEventListener("focus",b),l.removeEventListener("blur",v),v()),l.remove(),i.delete(s.id),y())};i.set(s.id,{record:s,el:l,cleanup:L})}function c(s){let l=i.get(s);l&&l.cleanup()}function g(s){s.forEach(m)}return e.track(()=>{h=!0,p!==null&&(cancelAnimationFrame(p),p=null),[...i.values()].forEach(s=>s.cleanup())}),{mount:m,unmount:c,mountAll:g,has(s){return i.has(s)},findByKey(s){for(let l of i.values())if(l.record.key===s)return l;return null}}}function A(e,t,n,r){typeof e.querySelectorAll=="function"&&e.querySelectorAll("*").forEach(o=>{n&&o.matches(t)&&n(o),o.shadowRoot&&(r&&r(o.shadowRoot),A(o.shadowRoot,t,n,r))})}function ne(e,t){let n=[];return A(e,t,r=>n.push(r)),n}function ke(e){let t=[];return A(e,"*",null,n=>t.push(n)),t}function te(e,t){let n=[],r=[];if(e.nodeType!==1)return{matches:n,shadowRoots:r};let o=e;return typeof o.matches=="function"&&o.matches(t)&&n.push(o),o.shadowRoot&&(r.push(o.shadowRoot),A(o.shadowRoot,t,i=>n.push(i),i=>r.push(i))),A(o,t,i=>n.push(i),i=>r.push(i)),{matches:n,shadowRoots:r}}function oe({root:e=document,selector:t,onAdded:n,onRemoved:r}){let o=new Set,i=d=>{for(let y of d)y.addedNodes.forEach(m=>{let{matches:c,shadowRoots:g}=te(m,t);c.forEach(s=>n(s)),g.forEach(h)}),y.removedNodes.forEach(m=>{te(m,t).matches.forEach(c=>r(c))})},p=new MutationObserver(i);function h(d){o.has(d)||(o.add(d),p.observe(d,{childList:!0,subtree:!0}))}return h(e),ke(e).forEach(h),{disconnect(){p.disconnect(),o.clear()}}}var _="data-help-title",M="data-help-text";function O(e="data-help-id"){return`[${e}], [${_}]`}function N(e){let t=new Map;for(let n of e)n.kind==="element"&&t.set(n.key,n);return t}function re(e){return e.filter(t=>t.kind==="free").map(t=>({id:t.key,kind:"free",key:t.key,title:t.title,text:t.text,position:t.position}))}function I(e,t,n="data-help-id"){let r=e.getAttribute(n),o=r!=null?t.get(r):void 0,i=o?o.title:e.getAttribute(_),p=o?o.text:e.getAttribute(M);return!i||!p?null:{id:e,kind:"element",key:r,title:i,text:p,target:e}}function ie(e,t=document,{silent:n=!1,attribute:r="data-help-id"}={}){let o=N(e),i=[];return ne(t,O(r)).forEach(p=>{let h=I(p,o,r);if(!h){if(!n){let d=p.getAttribute(r);console.warn(d!=null?`[help-layer] element with ${r}="${d}" has no matching helpConfig entry or inline ${_}/${M}`:`[help-layer] element needs both ${_} and ${M} (or a ${r} matching helpConfig)`)}return}i.push(h)}),i}function le(e,{onClose:t,render:n,popupPlacement:r="bottom-start"}={}){let{root:o,titleEl:i,textEl:p,closeEl:h}=j();document.body.appendChild(o),h.addEventListener("click",()=>f());let d=null,y=null,m=null;function c(){m&&(m.cleanup(),m=null)}function g(a,u){i.textContent=a.title;let b=n?n(a):null;p.textContent="",b?p.appendChild(b):p.textContent=a.text,o.style.display="block",d=a.id,y=u,c(),m=V(u,o,r),o.focus({preventScroll:!0})}function s(){m&&m.update()}function l(){let a=d!==null;c(),d=null,y=null,o.style.display="none",a&&t&&t()}function f(a){let u=a??y;l(),u&&u.isConnected&&typeof u.focus=="function"&&u.focus({preventScroll:!0})}return e.track(()=>{l(),o.remove()}),{root:o,isOpen(a){return d===a},getOpenId(){return d},open:g,close:f,reposition:s}}function ae(){let e=[];return{track(t){e.push(t)},teardownAll(){for(;e.length>0;)e.pop()()}}}var Ee="data-help-layer-style",we=`
|
|
5
|
+
.help-layer-blocking-layer {
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
/* Default transparent (unchanged). Set --help-layer-overlay-bg to tint it into a scrim that signals
|
|
9
|
+
"the host app is inactive". The clip-path hole isn't painted, so the toggle stays untinted. */
|
|
10
|
+
background: var(--help-layer-overlay-bg, transparent);
|
|
11
|
+
/* Cursor over the blocked area only (the toggle shows through the hole and keeps its own cursor).
|
|
12
|
+
e.g. not-allowed / help makes "this won't respond" obvious without needing a tint. */
|
|
13
|
+
cursor: var(--help-layer-overlay-cursor, default);
|
|
14
|
+
z-index: 2147483000;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.help-layer-marker {
|
|
18
|
+
/* reset of the button element */
|
|
19
|
+
appearance: none;
|
|
20
|
+
-webkit-appearance: none;
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
border: none;
|
|
24
|
+
position: absolute;
|
|
25
|
+
top: 0;
|
|
26
|
+
left: 0;
|
|
27
|
+
width: var(--help-layer-marker-size, 22px);
|
|
28
|
+
height: var(--help-layer-marker-size, 22px);
|
|
29
|
+
border-radius: 50%;
|
|
30
|
+
background: var(--help-layer-marker-bg, #2563eb);
|
|
31
|
+
color: var(--help-layer-marker-color, #fff);
|
|
32
|
+
font-family: sans-serif;
|
|
33
|
+
font-size: 13px;
|
|
34
|
+
font-weight: bold;
|
|
35
|
+
line-height: var(--help-layer-marker-size, 22px);
|
|
36
|
+
text-align: center;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
user-select: none;
|
|
39
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
|
40
|
+
z-index: 2147483001;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.help-layer-marker:focus-visible {
|
|
44
|
+
outline: 3px solid var(--help-layer-accent, #1d4ed8);
|
|
45
|
+
outline-offset: 2px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.help-layer-popup {
|
|
49
|
+
position: absolute;
|
|
50
|
+
top: 0;
|
|
51
|
+
left: 0;
|
|
52
|
+
display: none;
|
|
53
|
+
max-width: var(--help-layer-popup-max-width, 280px);
|
|
54
|
+
background: var(--help-layer-popup-bg, #fff);
|
|
55
|
+
color: var(--help-layer-popup-color, #1f2933);
|
|
56
|
+
border-radius: 6px;
|
|
57
|
+
padding: 12px 14px;
|
|
58
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
59
|
+
font-family: sans-serif;
|
|
60
|
+
font-size: 13px;
|
|
61
|
+
line-height: 1.5;
|
|
62
|
+
z-index: 2147483002;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.help-layer-popup:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.help-layer-popup:focus-visible {
|
|
70
|
+
outline: 3px solid var(--help-layer-accent, #1d4ed8);
|
|
71
|
+
outline-offset: 2px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.help-layer-popup__title {
|
|
75
|
+
font-weight: bold;
|
|
76
|
+
margin-bottom: 4px;
|
|
77
|
+
/* Reserve space so it doesn't overlap the \xD7 button at the top-right. */
|
|
78
|
+
padding-right: 16px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.help-layer-popup__text {
|
|
82
|
+
/* Render the body's
|
|
83
|
+
as line breaks (still textContent, so no XSS risk). */
|
|
84
|
+
white-space: pre-line;
|
|
85
|
+
/* Keep long text from spilling off-screen; only the body scrolls within the popup. */
|
|
86
|
+
max-height: var(--help-layer-popup-max-height, 50vh);
|
|
87
|
+
overflow-y: auto;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.help-layer-popup__close {
|
|
91
|
+
/* reset of the button element */
|
|
92
|
+
appearance: none;
|
|
93
|
+
-webkit-appearance: none;
|
|
94
|
+
position: absolute;
|
|
95
|
+
top: 6px;
|
|
96
|
+
right: 6px;
|
|
97
|
+
width: 22px;
|
|
98
|
+
height: 22px;
|
|
99
|
+
padding: 0;
|
|
100
|
+
border: none;
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
background: transparent;
|
|
103
|
+
color: inherit;
|
|
104
|
+
font-size: 16px;
|
|
105
|
+
line-height: 1;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.help-layer-popup__close:hover {
|
|
110
|
+
background: rgba(0, 0, 0, 0.08);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.help-layer-popup__close:focus-visible {
|
|
114
|
+
outline: 2px solid var(--help-layer-accent, #1d4ed8);
|
|
115
|
+
outline-offset: 1px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* Show an outline on the target element only while the marker is hovered/focused (clarifies "which element this explains").
|
|
120
|
+
* Make only the outline !important so it can beat host-side outline resets.
|
|
121
|
+
*/
|
|
122
|
+
.help-layer-target-highlight {
|
|
123
|
+
outline: 2px solid var(--help-layer-accent, #1d4ed8) !important;
|
|
124
|
+
outline-offset: 2px !important;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* Dark-mode defaults. If the user specifies CSS variables, those always win via var(), so here we
|
|
129
|
+
* only swap the dark fallback values (the properties themselves aren't re-declared).
|
|
130
|
+
*/
|
|
131
|
+
@media (prefers-color-scheme: dark) {
|
|
132
|
+
.help-layer-popup {
|
|
133
|
+
background: var(--help-layer-popup-bg, #1f2933);
|
|
134
|
+
color: var(--help-layer-popup-color, #e5e7eb);
|
|
135
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
`;function se(e){let t=document.createElement("style");return t.setAttribute(Ee,""),e&&t.setAttribute("nonce",e),t.textContent=we,document.head.appendChild(t),t}function pe(e){e.remove()}function Le(e){let t=typeof e=="string"?document.querySelector(e):e;if(!t)throw new Error(`help-layer: toggle element not found for selector "${e}"`);return t}function ce({config:e,toggle:t,onEnable:n,onDisable:r,onOpen:o,onClose:i,silent:p=!1,attribute:h="data-help-id",render:d,markerLabel:y="?",markerPlacement:m="top-end",popupPlacement:c="bottom-start",nonce:g}){let s=e;R(s);let l=t!=null?Le(t):null,f=null,a=null,u=null;function b(){if(f)return;f=ae(),l&&f.track(()=>{l.isConnected&&typeof l.focus=="function"&&l.focus({preventScroll:!0})});let E=se(g);f.track(()=>pe(E));let k=X(s),he=N(k);a=le(f,{onClose:i,render:d,popupPlacement:c}),u=ee(f,{markerLabel:y,markerPlacement:m,onMarkerClick:(x,C)=>{if(a.isOpen(x.id)){a.close();return}a.open(x,C),o&&o(x)},onOverlapResolved:()=>a.reposition()}),u.mountAll(re(k)),u.mountAll(ie(k,document,{silent:p,attribute:h}));let me=oe({selector:O(h),onAdded:x=>{let C=I(x,he,h);C&&!u.has(C.id)&&u.mount(C)},onRemoved:x=>{a.isOpen(x)&&a.close(l??void 0),u.unmount(x)}});f.track(()=>me.disconnect()),W(f,{toggleEl:l,onBackgroundClick:()=>a.close(),isLibraryElement:x=>!!x&&((l?l.contains(x):!1)||a.root.contains(x)||typeof x.closest=="function"&&!!x.closest(".help-layer-marker")),onEscape:()=>{a.getOpenId()!==null?a.close():L()}})}function v(){f&&(f.teardownAll(),f=null,a=null,u=null)}function w(){f||(b(),n&&n())}function L(){f&&(v(),r&&r())}function $(){f?L():w()}function ue(E){if(f||w(),!u||!a)return;let k=u.findByKey(E);if(!k){p||console.warn(`[help-layer] open(): no help marker for key "${E}"`);return}a.open(k.record,k.el),o&&o(k.record)}function fe(){a&&a.close()}function de(E){R(E),s=E,f&&(v(),b())}return l&&l.addEventListener("click",$),{enable:w,disable:L,toggle:$,isActive(){return f!==null},open:ue,close:fe,update:de,destroy(){L(),l&&l.removeEventListener("click",$)}}}function rt(e){return ce(e)}export{rt as initHelpLayer};
|
|
139
|
+
//# sourceMappingURL=help-layer.esm.js.map
|