@geckou/ui-core 0.5.0 → 0.7.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 +45 -1
- package/dist/date.d.ts +7 -0
- package/dist/date.js +14 -0
- package/dist/focus-trap.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/modal-stack.d.ts +39 -0
- package/dist/modal-stack.js +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
formatDateValue,
|
|
37
37
|
splitDate,
|
|
38
38
|
composeDateValue,
|
|
39
|
+
normalizeDateObject,
|
|
39
40
|
validateDateObject,
|
|
40
41
|
daysInMonth,
|
|
41
42
|
} from '@geckou/ui-core'
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
| `formatDateValue(value, type?)` | `YYYY-MM-DD`(`type='month'` なら `YYYY-MM`)へ正規化。**`toISOString()` を使わないためタイムゾーンで日付がずれない**。不正な文字列は空文字を返す |
|
|
47
48
|
| `splitDate(value)` | `YYYY-MM-DD` を `{ year, month, day }` へ分解 |
|
|
48
49
|
| `composeDateValue(dateObject, type?)` | 年月日から日付文字列を組み立てる。要素が欠けていれば空文字 |
|
|
50
|
+
| `normalizeDateObject(dateObject)` | 月・日を 2 桁へゼロ埋めする(`'1'` → `'01'`)。入力途中の値を検証する前に通す |
|
|
49
51
|
| `validateDateObject(dateObject, { type, isRequired })` | 桁数・月の範囲・その月に存在する日かを検証し `{ isValid, message }` を返す |
|
|
50
52
|
| `daysInMonth(year, month)` | 指定した年月の日数(`month` は 1 始まり。うるう年を考慮) |
|
|
51
53
|
|
|
@@ -124,7 +126,37 @@ const onKeyDown = (event: KeyboardEvent) => {
|
|
|
124
126
|
|---|---|
|
|
125
127
|
| `handleTabKey(container, event, activeElement?)` | Tab / Shift+Tab を端で折り返す。フォーカスを移して既定動作を止めたら `true` を返す。Tab 以外と `container` が無い場合は何もしない |
|
|
126
128
|
| `getFocusableElements(container)` | コンテナ内のフォーカス可能な要素を DOM 順(= Tab 順)で返す。`inert` が付いたものは除く |
|
|
127
|
-
| `FOCUSABLE_SELECTOR` | 上記で使うセレクタ(`tabindex="-1"` と `disabled`
|
|
129
|
+
| `FOCUSABLE_SELECTOR` | 上記で使うセレクタ(`tabindex="-1"` と `disabled` を除く。`contenteditable` / `audio[controls]` / `video[controls]` / `summary` / `iframe` を含む) |
|
|
130
|
+
|
|
131
|
+
`ModalBox`(Vue / React)はこれを使っています。
|
|
132
|
+
|
|
133
|
+
### モーダルの重なり順
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { createModalLayer } from '@geckou/ui-core'
|
|
137
|
+
|
|
138
|
+
const layer = createModalLayer()
|
|
139
|
+
layer.toggle(isOpen, dialogElement) // 表示状態と、判定に使う要素(要素は必須)
|
|
140
|
+
layer.isTopmost() // キー入力を処理してよいのは true のときだけ
|
|
141
|
+
layer.release() // アンマウント時
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
モーダルを重ねたとき、Escape や Tab を処理してよいのは最前面の 1 枚だけです。
|
|
145
|
+
`ModalBox` はハンドラを `document` に bubble で登録するため、重なると全部が同じ
|
|
146
|
+
イベントを受け取ります。実行順は DOM の深さではなく登録順で決まる(React は
|
|
147
|
+
`onClose` の同一性が変わると再登録され、effect は子から先に走る)ので、
|
|
148
|
+
順序には頼れません。
|
|
149
|
+
|
|
150
|
+
判定の決め手は **DOM の包含関係**です。入れ子のモーダルは内側が外側の中に
|
|
151
|
+
描画されるので、他のレイヤーを内包しているものは外側だと分かります。
|
|
152
|
+
互いに内包しない(入れ子でない)モーダルが並んだときだけ、後から開いたものを
|
|
153
|
+
最前面とします。
|
|
154
|
+
|
|
155
|
+
| メソッド | 説明 |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `toggle(shouldBeActive, element)` | 真偽で登録・解除する。`element` は最前面判定に使う要素(ダイアログ本体)。省略可にすると要素の無いレイヤーが積まれて包含判定が効かなくなるため必須(未取得なら明示的に `null`) |
|
|
158
|
+
| `isTopmost()` | このレイヤーが最前面か。登録していなければ `false` |
|
|
159
|
+
| `release()` | アンマウント時に呼ぶ。登録中なら解除する |
|
|
128
160
|
|
|
129
161
|
`ModalBox`(Vue / React)はこれを使っています。
|
|
130
162
|
|
|
@@ -139,6 +171,18 @@ import type { Validates, Option, StateVariation, DateObject } from '@geckou/ui-c
|
|
|
139
171
|
|
|
140
172
|
型の一覧は [Vue パッケージの README](../vue/README.md#types) を参照してください。
|
|
141
173
|
|
|
174
|
+
## 0.7.0 の変更
|
|
175
|
+
|
|
176
|
+
- `normalizeDateObject()` を追加(`DatePicker` が blur 時の正規化に使う)
|
|
177
|
+
- `FOCUSABLE_SELECTOR` に `[contenteditable]` / `audio[controls]` / `video[controls]` /
|
|
178
|
+
`summary` / `iframe` を追加。ダイアログ末尾がリッチエディタや埋め込みのときに
|
|
179
|
+
その手前で折り返していたのが直る
|
|
180
|
+
|
|
181
|
+
## 0.6.0 の変更
|
|
182
|
+
|
|
183
|
+
- `modal-stack` を追加(`createModalLayer`)。重なったモーダルのうち最前面の 1 枚を
|
|
184
|
+
DOM の包含関係で決める。React / Vue の `ModalBox` が Escape / Tab の担当判定に使う
|
|
185
|
+
|
|
142
186
|
## 0.4.0 の変更
|
|
143
187
|
|
|
144
188
|
- `focus-trap` を追加(`handleTabKey` / `getFocusableElements` / `FOCUSABLE_SELECTOR`)。
|
package/dist/date.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ export declare function splitDate(value: string): DateObject;
|
|
|
17
17
|
* 月・日は 2 桁へゼロ埋めする('2024-1-5' ではなく '2024-01-05')
|
|
18
18
|
*/
|
|
19
19
|
export declare function composeDateValue(dateObject: DateObject, type?: DateType): string;
|
|
20
|
+
/**
|
|
21
|
+
* 月・日を 2 桁へゼロ埋めする('1' → '01')。年はそのまま。
|
|
22
|
+
*
|
|
23
|
+
* 入力途中の値をそのまま検証すると「月は2桁の数字で入力してください」になるため、
|
|
24
|
+
* 欄を離れた時点でこれを通してから検証する
|
|
25
|
+
*/
|
|
26
|
+
export declare function normalizeDateObject(dateObject: DateObject): DateObject;
|
|
20
27
|
/**
|
|
21
28
|
* 年・月・日の入力内容を検証する。
|
|
22
29
|
* type='month' のときは日を見ない。
|
package/dist/date.js
CHANGED
|
@@ -85,6 +85,20 @@ export function composeDateValue(dateObject, type = 'date') {
|
|
|
85
85
|
const [yearPart, ...rest] = parts;
|
|
86
86
|
return [yearPart, ...rest.map((part) => pad(Number(part)))].join('-');
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* 月・日を 2 桁へゼロ埋めする('1' → '01')。年はそのまま。
|
|
90
|
+
*
|
|
91
|
+
* 入力途中の値をそのまま検証すると「月は2桁の数字で入力してください」になるため、
|
|
92
|
+
* 欄を離れた時点でこれを通してから検証する
|
|
93
|
+
*/
|
|
94
|
+
export function normalizeDateObject(dateObject) {
|
|
95
|
+
const padPart = (value) => value.length === 1 && isNumeric(value) ? pad(Number(value)) : value;
|
|
96
|
+
return {
|
|
97
|
+
year: dateObject.year,
|
|
98
|
+
month: padPart(dateObject.month),
|
|
99
|
+
day: padPart(dateObject.day),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
88
102
|
/**
|
|
89
103
|
* 年・月・日の入力内容を検証する。
|
|
90
104
|
* type='month' のときは日を見ない。
|
package/dist/focus-trap.js
CHANGED
|
@@ -15,6 +15,13 @@ export const FOCUSABLE_SELECTOR = [
|
|
|
15
15
|
'input:not([disabled]):not([type="hidden"])',
|
|
16
16
|
'select:not([disabled])',
|
|
17
17
|
'textarea:not([disabled])',
|
|
18
|
+
// 既定でフォーカスを受ける要素。漏らすと、ダイアログの末尾がリッチエディタ
|
|
19
|
+
// (contenteditable)や埋め込み(iframe)のときにその手前で折り返してしまう
|
|
20
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
21
|
+
'audio[controls]',
|
|
22
|
+
'video[controls]',
|
|
23
|
+
'summary',
|
|
24
|
+
'iframe',
|
|
18
25
|
'[tabindex]:not([tabindex="-1"])',
|
|
19
26
|
].join(', ');
|
|
20
27
|
/**
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|