@geckou/ui-core 0.4.0 → 0.6.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/README.md CHANGED
@@ -128,6 +128,36 @@ const onKeyDown = (event: KeyboardEvent) => {
128
128
 
129
129
  `ModalBox`(Vue / React)はこれを使っています。
130
130
 
131
+ ### モーダルの重なり順
132
+
133
+ ```ts
134
+ import { createModalLayer } from '@geckou/ui-core'
135
+
136
+ const layer = createModalLayer()
137
+ layer.toggle(isOpen, dialogElement) // 表示状態と、判定に使う要素(要素は必須)
138
+ layer.isTopmost() // キー入力を処理してよいのは true のときだけ
139
+ layer.release() // アンマウント時
140
+ ```
141
+
142
+ モーダルを重ねたとき、Escape や Tab を処理してよいのは最前面の 1 枚だけです。
143
+ `ModalBox` はハンドラを `document` に bubble で登録するため、重なると全部が同じ
144
+ イベントを受け取ります。実行順は DOM の深さではなく登録順で決まる(React は
145
+ `onClose` の同一性が変わると再登録され、effect は子から先に走る)ので、
146
+ 順序には頼れません。
147
+
148
+ 判定の決め手は **DOM の包含関係**です。入れ子のモーダルは内側が外側の中に
149
+ 描画されるので、他のレイヤーを内包しているものは外側だと分かります。
150
+ 互いに内包しない(入れ子でない)モーダルが並んだときだけ、後から開いたものを
151
+ 最前面とします。
152
+
153
+ | メソッド | 説明 |
154
+ |---|---|
155
+ | `toggle(shouldBeActive, element)` | 真偽で登録・解除する。`element` は最前面判定に使う要素(ダイアログ本体)。省略可にすると要素の無いレイヤーが積まれて包含判定が効かなくなるため必須(未取得なら明示的に `null`) |
156
+ | `isTopmost()` | このレイヤーが最前面か。登録していなければ `false` |
157
+ | `release()` | アンマウント時に呼ぶ。登録中なら解除する |
158
+
159
+ `ModalBox`(Vue / React)はこれを使っています。
160
+
131
161
  ### 定数・型
132
162
 
133
163
  ```ts
@@ -139,6 +169,11 @@ import type { Validates, Option, StateVariation, DateObject } from '@geckou/ui-c
139
169
 
140
170
  型の一覧は [Vue パッケージの README](../vue/README.md#types) を参照してください。
141
171
 
172
+ ## 0.6.0 の変更
173
+
174
+ - `modal-stack` を追加(`createModalLayer`)。重なったモーダルのうち最前面の 1 枚を
175
+ DOM の包含関係で決める。React / Vue の `ModalBox` が Escape / Tab の担当判定に使う
176
+
142
177
  ## 0.4.0 の変更
143
178
 
144
179
  - `focus-trap` を追加(`handleTabKey` / `getFocusableElements` / `FOCUSABLE_SELECTOR`)。
@@ -17,6 +17,9 @@ export declare const FOCUSABLE_SELECTOR: string;
17
17
  export type FocusableLike = {
18
18
  focus: () => void;
19
19
  hasAttribute?: (name: string) => boolean;
20
+ closest?: (selectors: string) => unknown;
21
+ matches?: (selectors: string) => boolean;
22
+ checkVisibility?: () => boolean;
20
23
  };
21
24
  export type FocusTrapContainer = FocusableLike & {
22
25
  querySelectorAll: (selectors: string) => ArrayLike<FocusableLike>;
@@ -28,7 +31,18 @@ export type FocusTrapEvent = {
28
31
  };
29
32
  /**
30
33
  * コンテナ内のフォーカス可能な要素を、DOM 順(= Tab 順)で返す。
31
- * `inert` が付いた要素はフォーカスできないので除く
34
+ *
35
+ * セレクタだけでは「実際にはフォーカスできない要素」が混ざる。
36
+ * 混ざると端の要素が no-op になって Tab が止まるか、preventDefault を挟んだ後に
37
+ * ブラウザ既定の移動が起きてダイアログの外へ抜けるため、ここで落とす。
38
+ *
39
+ * - `inert` 配下(閉じた SlideDownUi / DropdownUi の中身)。自身の属性だけでは足りない
40
+ * - `<fieldset disabled>` 配下(`:disabled` は継承する。`:not([disabled])` では消せない)
41
+ * - `display: none` で描画されていないもの(`hidden` な TabUI のパネルの中身など)。
42
+ * 引数なしの `checkVisibility()` は `visibility: hidden` / `opacity: 0` は落とさない
43
+ *
44
+ * `closest` / `matches` / `checkVisibility` は任意(`FocusableLike` は DOM 型を
45
+ * 要求しない)。持たない実装では従来どおりの結果になる
32
46
  */
33
47
  export declare function getFocusableElements(container: FocusTrapContainer | null): FocusableLike[];
34
48
  /**
@@ -19,12 +19,28 @@ export const FOCUSABLE_SELECTOR = [
19
19
  ].join(', ');
20
20
  /**
21
21
  * コンテナ内のフォーカス可能な要素を、DOM 順(= Tab 順)で返す。
22
- * `inert` が付いた要素はフォーカスできないので除く
22
+ *
23
+ * セレクタだけでは「実際にはフォーカスできない要素」が混ざる。
24
+ * 混ざると端の要素が no-op になって Tab が止まるか、preventDefault を挟んだ後に
25
+ * ブラウザ既定の移動が起きてダイアログの外へ抜けるため、ここで落とす。
26
+ *
27
+ * - `inert` 配下(閉じた SlideDownUi / DropdownUi の中身)。自身の属性だけでは足りない
28
+ * - `<fieldset disabled>` 配下(`:disabled` は継承する。`:not([disabled])` では消せない)
29
+ * - `display: none` で描画されていないもの(`hidden` な TabUI のパネルの中身など)。
30
+ * 引数なしの `checkVisibility()` は `visibility: hidden` / `opacity: 0` は落とさない
31
+ *
32
+ * `closest` / `matches` / `checkVisibility` は任意(`FocusableLike` は DOM 型を
33
+ * 要求しない)。持たない実装では従来どおりの結果になる
23
34
  */
24
35
  export function getFocusableElements(container) {
25
36
  if (!container)
26
37
  return [];
27
- return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) => !element.hasAttribute?.('inert'));
38
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) =>
39
+ // closest を持たない実装のために、自身の inert も見る(DOM では冗長)
40
+ !element.hasAttribute?.('inert') &&
41
+ !element.closest?.('[inert]') &&
42
+ !element.matches?.(':disabled') &&
43
+ (element.checkVisibility?.() ?? true));
28
44
  }
29
45
  /**
30
46
  * Tab / Shift+Tab を受けて、コンテナの端で折り返す。
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from './text.js';
6
6
  export * from './form-validation-store.js';
7
7
  export * from './scroll-lock.js';
8
8
  export * from './focus-trap.js';
9
+ export * from './modal-stack.js';
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export * from './text.js';
6
6
  export * from './form-validation-store.js';
7
7
  export * from './scroll-lock.js';
8
8
  export * from './focus-trap.js';
9
+ export * from './modal-stack.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 開いているモーダルの重なり順。
3
+ *
4
+ * ModalBox は Escape / Tab のハンドラを `document` に bubble で登録するため、
5
+ * モーダルを重ねると全部のハンドラが同じイベントを受け取る。実行順は DOM の
6
+ * 深さではなく登録順で決まる(React は onClose の同一性が変わると再登録されて
7
+ * 順序が入れ替わり、React の effect は子から先に走る)ので、順序には頼れない。
8
+ *
9
+ * そこで「誰が最前面か」をここで判定する。決め手は DOM の包含関係。
10
+ * 入れ子のモーダルは内側が外側の中に描画されるので、他のレイヤーを内包している
11
+ * ものは外側だと分かる。互いに内包しない(入れ子でない)モーダルが並んだときだけ、
12
+ * 後から開いたものを最前面とする。
13
+ *
14
+ * 呼び出し側は 1 コンポーネント 1 ハンドルを持ち、表示状態と自分の要素を
15
+ * `toggle()` に渡して、アンマウント時に `release()` する。
16
+ */
17
+ /**
18
+ * 包含判定しか使わないので、DOM の型は要求しない
19
+ * (core をフレームワーク・実行環境から独立に保つため。lib に DOM を足さない)。
20
+ * メソッド記法なので引数は双変になり、`HTMLElement` をそのまま渡せる
21
+ */
22
+ type ElementLike = {
23
+ contains(other: ElementLike | null): boolean;
24
+ };
25
+ export type ModalLayer = {
26
+ /**
27
+ * 引数の真偽で登録・解除を切り替える。
28
+ * `element` には最前面判定に使う要素(ダイアログ本体)を渡す。
29
+ * 省略可にすると `toggle(true)` だけで要素の無いレイヤーが積まれ、
30
+ * 包含判定が効かなくなるので必須にしている(未取得なら明示的に null)
31
+ */
32
+ toggle: (shouldBeActive: boolean, element: ElementLike | null) => void;
33
+ /** このレイヤーが最前面か。キーイベントを処理してよいのは true のときだけ */
34
+ isTopmost: () => boolean;
35
+ /** アンマウント時に呼ぶ。登録中なら解除する */
36
+ release: () => void;
37
+ };
38
+ export declare function createModalLayer(): ModalLayer;
39
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 開いているモーダルの重なり順。
3
+ *
4
+ * ModalBox は Escape / Tab のハンドラを `document` に bubble で登録するため、
5
+ * モーダルを重ねると全部のハンドラが同じイベントを受け取る。実行順は DOM の
6
+ * 深さではなく登録順で決まる(React は onClose の同一性が変わると再登録されて
7
+ * 順序が入れ替わり、React の effect は子から先に走る)ので、順序には頼れない。
8
+ *
9
+ * そこで「誰が最前面か」をここで判定する。決め手は DOM の包含関係。
10
+ * 入れ子のモーダルは内側が外側の中に描画されるので、他のレイヤーを内包している
11
+ * ものは外側だと分かる。互いに内包しない(入れ子でない)モーダルが並んだときだけ、
12
+ * 後から開いたものを最前面とする。
13
+ *
14
+ * 呼び出し側は 1 コンポーネント 1 ハンドルを持ち、表示状態と自分の要素を
15
+ * `toggle()` に渡して、アンマウント時に `release()` する。
16
+ */
17
+ const layers = [];
18
+ function contains(layer, other) {
19
+ return Boolean(layer.element && other.element && layer.element.contains(other.element));
20
+ }
21
+ export function createModalLayer() {
22
+ const layer = { element: null };
23
+ let isActive = false;
24
+ const toggle = (shouldBeActive, element) => {
25
+ // 要素は再描画で差し替わりうるので、状態が変わらなくても取り直す
26
+ layer.element = shouldBeActive ? element : null;
27
+ if (isActive === shouldBeActive)
28
+ return;
29
+ isActive = shouldBeActive;
30
+ if (shouldBeActive) {
31
+ layers.push(layer);
32
+ return;
33
+ }
34
+ const index = layers.lastIndexOf(layer);
35
+ if (index >= 0)
36
+ layers.splice(index, 1);
37
+ };
38
+ const isTopmost = () => {
39
+ if (!isActive)
40
+ return false;
41
+ // 他のレイヤーを内包しているものは「外側」なので候補から外す
42
+ const innermost = layers.filter((candidate) => !layers.some((other) => other !== candidate && contains(candidate, other)));
43
+ return innermost[innermost.length - 1] === layer;
44
+ };
45
+ return { toggle, isTopmost, release: () => toggle(false, null) };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geckou/ui-core",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Framework-agnostic logic shared by @geckou/ui-vue and @geckou/ui-react",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",