@geckou/ui-core 0.3.0 → 0.4.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 +55 -0
- package/dist/focus-trap.d.ts +42 -0
- package/dist/focus-trap.js +72 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scroll-lock.js +35 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,53 @@ store.getSnapshot() // { isAllValid: false, invalidNames: ['startedOn'] }
|
|
|
81
81
|
- Vue: `FormValidationManager`(`@geckou/ui-vue`)
|
|
82
82
|
- React: `useFormValidation`(`@geckou/ui-react`)
|
|
83
83
|
|
|
84
|
+
### スクロールロック
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { createScrollLock } from '@geckou/ui-core'
|
|
88
|
+
|
|
89
|
+
const lock = createScrollLock()
|
|
90
|
+
lock.toggle(isOpen) // 真偽でロック・解除
|
|
91
|
+
lock.release() // アンマウント時
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
モーダル表示中のページ全体のスクロールを止めます。1 コンポーネント 1 ハンドルを持ち、
|
|
95
|
+
表示状態を `toggle()` に渡します。モーダルを重ねても解除順で壊れないよう内部でロック数を数え、
|
|
96
|
+
最初のロックで元の値を控えて最後の解除で戻します。
|
|
97
|
+
|
|
98
|
+
**スクロールバー幅を補正します。** バーが常時表示される環境(デスクトップの Windows / Linux 等)では
|
|
99
|
+
`overflow: hidden` にした瞬間にバーの幅ぶん内容が横へずれるため、最初のロックで
|
|
100
|
+
`window.innerWidth - document.documentElement.clientWidth` を `body` の `padding-right` に足し、
|
|
101
|
+
最後の解除で元へ戻します(既存のインライン値があれば `calc()` で加算します)。
|
|
102
|
+
|
|
103
|
+
利用側の CSS で `html { scrollbar-gutter: stable }` を指定している場合、
|
|
104
|
+
この差は 0 になるので補正は入りません。固定配置の要素(追従ヘッダー等)も
|
|
105
|
+
ずれないようにしたいときは `scrollbar-gutter` を使うほうが確実です。
|
|
106
|
+
|
|
107
|
+
SSR(`document` が無い環境)では何もしません。
|
|
108
|
+
|
|
109
|
+
### フォーカストラップ
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { handleTabKey, getFocusableElements } from '@geckou/ui-core'
|
|
113
|
+
|
|
114
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
115
|
+
handleTabKey(dialogElement, event, document.activeElement)
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`aria-modal="true"` を出していても、背景を `inert` にしていない限り Tab / Shift+Tab は
|
|
120
|
+
ダイアログの外へ抜けます。`handleTabKey()` に `keydown` を渡すと、コンテナ内の
|
|
121
|
+
フォーカス可能な要素の端で折り返します(末尾で Tab → 先頭、先頭で Shift+Tab → 末尾)。
|
|
122
|
+
|
|
123
|
+
| 関数 | 説明 |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `handleTabKey(container, event, activeElement?)` | Tab / Shift+Tab を端で折り返す。フォーカスを移して既定動作を止めたら `true` を返す。Tab 以外と `container` が無い場合は何もしない |
|
|
126
|
+
| `getFocusableElements(container)` | コンテナ内のフォーカス可能な要素を DOM 順(= Tab 順)で返す。`inert` が付いたものは除く |
|
|
127
|
+
| `FOCUSABLE_SELECTOR` | 上記で使うセレクタ(`tabindex="-1"` と `disabled` を除く) |
|
|
128
|
+
|
|
129
|
+
`ModalBox`(Vue / React)はこれを使っています。
|
|
130
|
+
|
|
84
131
|
### 定数・型
|
|
85
132
|
|
|
86
133
|
```ts
|
|
@@ -92,6 +139,14 @@ import type { Validates, Option, StateVariation, DateObject } from '@geckou/ui-c
|
|
|
92
139
|
|
|
93
140
|
型の一覧は [Vue パッケージの README](../vue/README.md#types) を参照してください。
|
|
94
141
|
|
|
142
|
+
## 0.4.0 の変更
|
|
143
|
+
|
|
144
|
+
- `focus-trap` を追加(`handleTabKey` / `getFocusableElements` / `FOCUSABLE_SELECTOR`)。
|
|
145
|
+
React / Vue の `ModalBox` が Tab の循環に使う
|
|
146
|
+
- `scroll-lock` がロック時にスクロールバー幅を `padding-right` として補正する。
|
|
147
|
+
スクロールバーが常時表示される環境で、モーダルの開閉のたびにページが横へずれていた。
|
|
148
|
+
`body` の `padding-right` を自分で指定している場合は `calc()` で合成される
|
|
149
|
+
|
|
95
150
|
## テスト
|
|
96
151
|
|
|
97
152
|
```bash
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ダイアログ内にフォーカスを閉じ込める(フォーカストラップ)。
|
|
3
|
+
*
|
|
4
|
+
* `aria-modal="true"` を出していても、背景を `inert` にしていない限り
|
|
5
|
+
* Tab / Shift+Tab はダイアログの外の要素へ抜けていく。モーダルの必須要件なので、
|
|
6
|
+
* Vue / React どちらの ModalBox からも同じロジックを使う(scroll-lock と同じ配置)。
|
|
7
|
+
*
|
|
8
|
+
* 使い方は `keydown` を受けて `handleTabKey(container, event, document.activeElement)`
|
|
9
|
+
* に渡すだけ。Tab 以外のキーと、コンテナが無い場合は何もしない。
|
|
10
|
+
*/
|
|
11
|
+
/** フォーカス可能な要素のセレクタ。`tabindex="-1"` はプログラム用なので除く */
|
|
12
|
+
export declare const FOCUSABLE_SELECTOR: string;
|
|
13
|
+
/**
|
|
14
|
+
* DOM の型を要求しない(core をフレームワーク・実行環境から独立に保つため。
|
|
15
|
+
* lib に DOM を足さない)。実体は HTMLElement だが、必要な部分だけを構造的に受ける
|
|
16
|
+
*/
|
|
17
|
+
export type FocusableLike = {
|
|
18
|
+
focus: () => void;
|
|
19
|
+
hasAttribute?: (name: string) => boolean;
|
|
20
|
+
};
|
|
21
|
+
export type FocusTrapContainer = FocusableLike & {
|
|
22
|
+
querySelectorAll: (selectors: string) => ArrayLike<FocusableLike>;
|
|
23
|
+
};
|
|
24
|
+
export type FocusTrapEvent = {
|
|
25
|
+
key: string;
|
|
26
|
+
shiftKey: boolean;
|
|
27
|
+
preventDefault: () => void;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* コンテナ内のフォーカス可能な要素を、DOM 順(= Tab 順)で返す。
|
|
31
|
+
* `inert` が付いた要素はフォーカスできないので除く
|
|
32
|
+
*/
|
|
33
|
+
export declare function getFocusableElements(container: FocusTrapContainer | null): FocusableLike[];
|
|
34
|
+
/**
|
|
35
|
+
* Tab / Shift+Tab を受けて、コンテナの端で折り返す。
|
|
36
|
+
*
|
|
37
|
+
* @param container ダイアログ本体(`role="dialog"` の要素)
|
|
38
|
+
* @param event `keydown` のイベント
|
|
39
|
+
* @param activeElement 現在フォーカスされている要素(`document.activeElement`)
|
|
40
|
+
* @returns フォーカスを移動して既定動作を止めたなら `true`
|
|
41
|
+
*/
|
|
42
|
+
export declare function handleTabKey(container: FocusTrapContainer | null, event: FocusTrapEvent, activeElement?: unknown): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ダイアログ内にフォーカスを閉じ込める(フォーカストラップ)。
|
|
3
|
+
*
|
|
4
|
+
* `aria-modal="true"` を出していても、背景を `inert` にしていない限り
|
|
5
|
+
* Tab / Shift+Tab はダイアログの外の要素へ抜けていく。モーダルの必須要件なので、
|
|
6
|
+
* Vue / React どちらの ModalBox からも同じロジックを使う(scroll-lock と同じ配置)。
|
|
7
|
+
*
|
|
8
|
+
* 使い方は `keydown` を受けて `handleTabKey(container, event, document.activeElement)`
|
|
9
|
+
* に渡すだけ。Tab 以外のキーと、コンテナが無い場合は何もしない。
|
|
10
|
+
*/
|
|
11
|
+
/** フォーカス可能な要素のセレクタ。`tabindex="-1"` はプログラム用なので除く */
|
|
12
|
+
export const FOCUSABLE_SELECTOR = [
|
|
13
|
+
'a[href]',
|
|
14
|
+
'button:not([disabled])',
|
|
15
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
16
|
+
'select:not([disabled])',
|
|
17
|
+
'textarea:not([disabled])',
|
|
18
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
19
|
+
].join(', ');
|
|
20
|
+
/**
|
|
21
|
+
* コンテナ内のフォーカス可能な要素を、DOM 順(= Tab 順)で返す。
|
|
22
|
+
* `inert` が付いた要素はフォーカスできないので除く
|
|
23
|
+
*/
|
|
24
|
+
export function getFocusableElements(container) {
|
|
25
|
+
if (!container)
|
|
26
|
+
return [];
|
|
27
|
+
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) => !element.hasAttribute?.('inert'));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Tab / Shift+Tab を受けて、コンテナの端で折り返す。
|
|
31
|
+
*
|
|
32
|
+
* @param container ダイアログ本体(`role="dialog"` の要素)
|
|
33
|
+
* @param event `keydown` のイベント
|
|
34
|
+
* @param activeElement 現在フォーカスされている要素(`document.activeElement`)
|
|
35
|
+
* @returns フォーカスを移動して既定動作を止めたなら `true`
|
|
36
|
+
*/
|
|
37
|
+
export function handleTabKey(container, event, activeElement) {
|
|
38
|
+
if (event.key !== 'Tab' || !container)
|
|
39
|
+
return false;
|
|
40
|
+
const focusable = getFocusableElements(container);
|
|
41
|
+
if (focusable.length === 0) {
|
|
42
|
+
// フォーカスできる要素が無いなら、外へ抜けさせないために Tab ごと止める
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
container.focus();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const first = focusable[0];
|
|
48
|
+
const last = focusable[focusable.length - 1];
|
|
49
|
+
const active = activeElement;
|
|
50
|
+
const index = active ? focusable.indexOf(active) : -1;
|
|
51
|
+
// 開いた直後はダイアログ自身にフォーカスがある(tabindex="-1")
|
|
52
|
+
const isContainerItself = active === container;
|
|
53
|
+
if (event.shiftKey) {
|
|
54
|
+
// 先頭・ダイアログ自身・トラップの外にいるなら末尾へ回す
|
|
55
|
+
if (index === 0 || isContainerItself || index === -1) {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
last.focus();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
// ダイアログ自身からの Tab はブラウザ既定で先頭へ進むので触らない
|
|
63
|
+
if (isContainerItself)
|
|
64
|
+
return false;
|
|
65
|
+
// 末尾・トラップの外にいるなら先頭へ回す
|
|
66
|
+
if (index === -1 || index === focusable.length - 1) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
first.focus();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/scroll-lock.js
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
* ページ全体のスクロールロック。
|
|
3
3
|
*
|
|
4
4
|
* モーダルを重ねても解除順で壊れないよう、ロック数をカウントする。
|
|
5
|
-
* 最初のロックで元の `overflow` を控え、最後の解除で戻す
|
|
6
|
-
* (無条件に `''`
|
|
5
|
+
* 最初のロックで元の `overflow` と `padding-right` を控え、最後の解除で戻す
|
|
6
|
+
* (無条件に `''` にすると、アプリ側が持っていたインラインの値が消える)。
|
|
7
|
+
*
|
|
8
|
+
* スクロールバーが常時表示される環境(デスクトップの Windows / Linux など)では、
|
|
9
|
+
* `overflow: hidden` にした瞬間にバーの幅ぶんページが広がって内容が横へずれる。
|
|
10
|
+
* 最初のロックでその幅を `padding-right` として足し、最後の解除で元へ戻す。
|
|
11
|
+
* 利用側の CSS で `html { scrollbar-gutter: stable }` を使っているならこの補正は
|
|
12
|
+
* 0px になる(`window.innerWidth` と `clientWidth` が一致するため)。
|
|
7
13
|
*
|
|
8
14
|
* 呼び出し側は 1 コンポーネント 1 ハンドルを持ち、表示状態を `toggle()` に渡して、
|
|
9
15
|
* アンマウント時に `release()` する。同じハンドルから同じ状態を二度要求しても
|
|
@@ -11,10 +17,26 @@
|
|
|
11
17
|
*/
|
|
12
18
|
let lockCount = 0;
|
|
13
19
|
let previousOverflow = '';
|
|
20
|
+
let previousPaddingRight = '';
|
|
21
|
+
function getGlobal() {
|
|
22
|
+
return globalThis;
|
|
23
|
+
}
|
|
14
24
|
function getBody() {
|
|
15
25
|
// SSR では document が無い(Nuxt / Next.js のサーバー側)
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
return getGlobal().document?.body ?? null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 常時表示のスクロールバーが占めている幅。
|
|
30
|
+
* オーバーレイ表示(macOS の既定・モバイル)や `scrollbar-gutter: stable` では 0 になる。
|
|
31
|
+
*/
|
|
32
|
+
function getScrollbarWidth() {
|
|
33
|
+
const global = getGlobal();
|
|
34
|
+
const innerWidth = global.innerWidth;
|
|
35
|
+
const clientWidth = global.document?.documentElement?.clientWidth;
|
|
36
|
+
if (typeof innerWidth !== 'number' || typeof clientWidth !== 'number')
|
|
37
|
+
return 0;
|
|
38
|
+
const width = innerWidth - clientWidth;
|
|
39
|
+
return width > 0 ? width : 0;
|
|
18
40
|
}
|
|
19
41
|
export function createScrollLock() {
|
|
20
42
|
let isLocked = false;
|
|
@@ -26,11 +48,20 @@ export function createScrollLock() {
|
|
|
26
48
|
lockCount += shouldLock ? 1 : -1;
|
|
27
49
|
if (lockCount === 1 && shouldLock) {
|
|
28
50
|
previousOverflow = body.style.overflow;
|
|
51
|
+
previousPaddingRight = body.style.paddingRight;
|
|
29
52
|
body.style.overflow = 'hidden';
|
|
53
|
+
const scrollbarWidth = getScrollbarWidth();
|
|
54
|
+
if (scrollbarWidth > 0) {
|
|
55
|
+
// 既にインラインの padding-right があれば単位が分からないので calc で足す
|
|
56
|
+
body.style.paddingRight = previousPaddingRight
|
|
57
|
+
? `calc(${previousPaddingRight} + ${scrollbarWidth}px)`
|
|
58
|
+
: `${scrollbarWidth}px`;
|
|
59
|
+
}
|
|
30
60
|
}
|
|
31
61
|
else if (lockCount <= 0) {
|
|
32
62
|
lockCount = 0;
|
|
33
63
|
body.style.overflow = previousOverflow;
|
|
64
|
+
body.style.paddingRight = previousPaddingRight;
|
|
34
65
|
}
|
|
35
66
|
};
|
|
36
67
|
return { toggle, release: () => toggle(false) };
|