@geckou/ui-core 0.1.2 → 0.3.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.
@@ -22,4 +22,5 @@ export declare const MESSAGES: {
22
22
  invalidDay: string;
23
23
  monthOutOfRange: string;
24
24
  dayOutOfRange: (maxDay: number) => string;
25
+ startAfterEnd: string;
25
26
  };
package/dist/constants.js CHANGED
@@ -63,4 +63,5 @@ export const MESSAGES = {
63
63
  invalidDay: '日は2桁の数字で入力してください',
64
64
  monthOutOfRange: '月は01から12の間で入力してください',
65
65
  dayOutOfRange: (maxDay) => `日は01から${maxDay}の間で入力してください`,
66
+ startAfterEnd: '終了日より後の日付は選べません',
66
67
  };
package/dist/date.d.ts CHANGED
@@ -12,7 +12,10 @@ export declare function daysInMonth(year: number, month: number): number;
12
12
  export declare function formatDateValue(value: string, type?: DateType): string;
13
13
  /** YYYY-MM-DD を年・月・日へ分解する。欠けている要素は空文字 */
14
14
  export declare function splitDate(value: string): DateObject;
15
- /** 年・月・日から日付文字列を組み立てる。要素が欠けていれば空文字 */
15
+ /**
16
+ * 年・月・日から日付文字列を組み立てる。要素が欠けていれば空文字。
17
+ * 月・日は 2 桁へゼロ埋めする('2024-1-5' ではなく '2024-01-05')
18
+ */
16
19
  export declare function composeDateValue(dateObject: DateObject, type?: DateType): string;
17
20
  /**
18
21
  * 年・月・日の入力内容を検証する。
package/dist/date.js CHANGED
@@ -7,7 +7,11 @@ function isNumeric(value) {
7
7
  }
8
8
  /** 指定した年月の日数。month は 1 始まり */
9
9
  export function daysInMonth(year, month) {
10
- return new Date(year, month, 0).getDate();
10
+ // new Date(year, ...) は年 0〜99 を 1900 年代として扱う(0 → 1900)。
11
+ // setFullYear なら西暦そのままで解釈される
12
+ const date = new Date(0);
13
+ date.setFullYear(year, month, 0);
14
+ return date.getDate();
11
15
  }
12
16
  /**
13
17
  * 任意の日付文字列を YYYY-MM-DD(type='month' なら YYYY-MM)へ正規化する。
@@ -18,24 +22,43 @@ export function daysInMonth(year, month) {
18
22
  * getFullYear / getMonth / getDate を使って変換する。
19
23
  */
20
24
  export function formatDateValue(value, type = 'date') {
21
- if (!value)
25
+ if (!value) {
22
26
  return '';
23
- const matched = value.match(/^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?/);
27
+ }
28
+ // 末尾を固定する。固定しないと '2024-01-15abc' のような値の頭だけを拾ってしまう。
29
+ // ISO 8601('2024-01-14T23:00:00.000Z')もここでは一致せず、下の Date へ回して
30
+ // ローカル時刻の日付に直す(字面の先頭 10 文字を採ると UTC 日付になり、
31
+ // JST では前日にずれる)
32
+ const matched = value.match(/^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?$/);
24
33
  if (matched) {
25
34
  const [, year, month, day] = matched;
26
- const yearMonth = `${year}-${pad(Number(month))}`;
27
- if (type === 'month')
35
+ const monthNumber = Number(month);
36
+ if (monthNumber < 1 || monthNumber > 12) {
37
+ return '';
38
+ }
39
+ const yearMonth = `${year}-${pad(monthNumber)}`;
40
+ if (type === 'month') {
28
41
  return yearMonth;
42
+ }
29
43
  // type='date' は完全な日付を要求する。日が欠けていれば入力欄には反映しない
30
- return day ? `${yearMonth}-${pad(Number(day))}` : '';
44
+ if (!day) {
45
+ return '';
46
+ }
47
+ const dayNumber = Number(day);
48
+ if (dayNumber < 1 || dayNumber > daysInMonth(Number(year), monthNumber)) {
49
+ return '';
50
+ }
51
+ return `${yearMonth}-${pad(dayNumber)}`;
31
52
  }
32
53
  const parsed = new Date(value);
33
54
  // 不正な日付文字列で toISOString() が throw していたため、先に弾く
34
- if (Number.isNaN(parsed.getTime()))
55
+ if (Number.isNaN(parsed.getTime())) {
35
56
  return '';
57
+ }
36
58
  const yearMonth = `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}`;
37
- if (type === 'month')
59
+ if (type === 'month') {
38
60
  return yearMonth;
61
+ }
39
62
  return `${yearMonth}-${pad(parsed.getDate())}`;
40
63
  }
41
64
  /** YYYY-MM-DD を年・月・日へ分解する。欠けている要素は空文字 */
@@ -43,13 +66,24 @@ export function splitDate(value) {
43
66
  const [year = '', month = '', day = ''] = value.split('-');
44
67
  return { year, month, day };
45
68
  }
46
- /** 年・月・日から日付文字列を組み立てる。要素が欠けていれば空文字 */
69
+ /**
70
+ * 年・月・日から日付文字列を組み立てる。要素が欠けていれば空文字。
71
+ * 月・日は 2 桁へゼロ埋めする('2024-1-5' ではなく '2024-01-05')
72
+ */
47
73
  export function composeDateValue(dateObject, type = 'date') {
48
74
  const { year, month, day } = dateObject;
49
75
  const parts = type === 'month' ? [year, month] : [year, month, day];
50
- if (parts.some(part => !part))
76
+ if (parts.some((part) => !part)) {
51
77
  return '';
52
- return parts.join('-');
78
+ }
79
+ // 数字でない入力('ab' 等)を pad(Number(part)) に通すと '2024-NaN-01' のような
80
+ // 日付でない文字列ができる。Number.isFinite だと '1.5' / '0x0a' / ' 1' を通して
81
+ // しまうので、validateDateObject と同じ isNumeric で判定する
82
+ if (parts.some((part) => !isNumeric(part))) {
83
+ return '';
84
+ }
85
+ const [yearPart, ...rest] = parts;
86
+ return [yearPart, ...rest.map((part) => pad(Number(part)))].join('-');
53
87
  }
54
88
  /**
55
89
  * 年・月・日の入力内容を検証する。
@@ -61,10 +95,11 @@ export function validateDateObject(dateObject, options = {}) {
61
95
  const day = type === 'month' ? '' : dateObject.day;
62
96
  const valid = { isValid: true, message: '' };
63
97
  // 全部空で必須でなければ未入力として通す
64
- if ([year, month, day].every(value => !value) && !isRequired)
98
+ if ([year, month, day].every((value) => !value) && !isRequired) {
65
99
  return valid;
100
+ }
66
101
  const requiredValues = type === 'month' ? [year, month] : [year, month, day];
67
- if (requiredValues.some(value => isRequired && !value)) {
102
+ if (requiredValues.some((value) => isRequired && !value)) {
68
103
  return { isValid: false, message: MESSAGES.required };
69
104
  }
70
105
  if (year.length !== 4 || !isNumeric(year)) {
@@ -80,9 +115,10 @@ export function validateDateObject(dateObject, options = {}) {
80
115
  if (monthNumber < 1 || monthNumber > 12) {
81
116
  return { isValid: false, message: MESSAGES.monthOutOfRange };
82
117
  }
118
+ // parseInt('00') は 0(falsy)。null との比較にしないと日 '00' の検査が飛ぶ
83
119
  const dayNumber = day ? parseInt(day, 10) : null;
84
120
  const maxDay = daysInMonth(parseInt(year, 10), monthNumber);
85
- if (dayNumber && (dayNumber < 1 || dayNumber > maxDay)) {
121
+ if (dayNumber !== null && (dayNumber < 1 || dayNumber > maxDay)) {
86
122
  return { isValid: false, message: MESSAGES.dayOutOfRange(maxDay) };
87
123
  }
88
124
  return valid;
@@ -18,8 +18,9 @@ export function createFormValidationStore() {
18
18
  const buildSnapshot = () => {
19
19
  const invalidNames = [];
20
20
  states.forEach((isValid, name) => {
21
- if (!isValid)
21
+ if (!isValid) {
22
22
  invalidNames.push(name);
23
+ }
23
24
  });
24
25
  return { isAllValid: invalidNames.length === 0, invalidNames };
25
26
  };
@@ -29,15 +30,17 @@ export function createFormValidationStore() {
29
30
  const isSame = next.isAllValid === snapshot.isAllValid &&
30
31
  next.invalidNames.length === snapshot.invalidNames.length &&
31
32
  next.invalidNames.every((name, index) => name === snapshot.invalidNames[index]);
32
- if (isSame)
33
+ if (isSame) {
33
34
  return;
35
+ }
34
36
  snapshot = next;
35
- listeners.forEach(listener => listener());
37
+ listeners.forEach((listener) => listener());
36
38
  };
37
39
  return {
38
40
  setValid(name, isValid) {
39
- if (states.get(name) === isValid)
41
+ if (states.get(name) === isValid) {
40
42
  return;
43
+ }
41
44
  states.set(name, isValid);
42
45
  notify();
43
46
  },
@@ -45,13 +48,15 @@ export function createFormValidationStore() {
45
48
  return states.get(name) ?? true;
46
49
  },
47
50
  remove(name) {
48
- if (!states.delete(name))
51
+ if (!states.delete(name)) {
49
52
  return;
53
+ }
50
54
  notify();
51
55
  },
52
56
  reset() {
53
- if (states.size === 0)
57
+ if (states.size === 0) {
54
58
  return;
59
+ }
55
60
  states.clear();
56
61
  notify();
57
62
  },
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export * from './validation.js';
4
4
  export * from './date.js';
5
5
  export * from './text.js';
6
6
  export * from './form-validation-store.js';
7
+ export * from './scroll-lock.js';
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from './validation.js';
4
4
  export * from './date.js';
5
5
  export * from './text.js';
6
6
  export * from './form-validation-store.js';
7
+ export * from './scroll-lock.js';
@@ -0,0 +1,7 @@
1
+ export type ScrollLock = {
2
+ /** 引数の真偽でロック・解除を切り替える。同じ状態の連続呼び出しは無視される */
3
+ toggle: (shouldLock: boolean) => void;
4
+ /** アンマウント時に呼ぶ。ロック中なら解除する */
5
+ release: () => void;
6
+ };
7
+ export declare function createScrollLock(): ScrollLock;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ページ全体のスクロールロック。
3
+ *
4
+ * モーダルを重ねても解除順で壊れないよう、ロック数をカウントする。
5
+ * 最初のロックで元の `overflow` を控え、最後の解除で戻す
6
+ * (無条件に `''` にすると、アプリ側が持っていたインラインの `overflow` が消える)。
7
+ *
8
+ * 呼び出し側は 1 コンポーネント 1 ハンドルを持ち、表示状態を `toggle()` に渡して、
9
+ * アンマウント時に `release()` する。同じハンドルから同じ状態を二度要求しても
10
+ * 数え間違えない。
11
+ */
12
+ let lockCount = 0;
13
+ let previousOverflow = '';
14
+ function getBody() {
15
+ // SSR では document が無い(Nuxt / Next.js のサーバー側)
16
+ const doc = globalThis.document;
17
+ return doc?.body ?? null;
18
+ }
19
+ export function createScrollLock() {
20
+ let isLocked = false;
21
+ const toggle = (shouldLock) => {
22
+ const body = getBody();
23
+ if (!body || isLocked === shouldLock)
24
+ return;
25
+ isLocked = shouldLock;
26
+ lockCount += shouldLock ? 1 : -1;
27
+ if (lockCount === 1 && shouldLock) {
28
+ previousOverflow = body.style.overflow;
29
+ body.style.overflow = 'hidden';
30
+ }
31
+ else if (lockCount <= 0) {
32
+ lockCount = 0;
33
+ body.style.overflow = previousOverflow;
34
+ }
35
+ };
36
+ return { toggle, release: () => toggle(false) };
37
+ }
package/dist/text.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const FULL_WIDTH_ALPHANUMERIC = /[A-Za-z0-9]/g;
2
2
  /** 全角の英数字を半角へ変換する。全角以外の文字はそのまま残す */
3
3
  export function convertFullWidthToHalfWidth(value) {
4
- return value.replace(FULL_WIDTH_ALPHANUMERIC, character => String.fromCharCode(character.charCodeAt(0) - 0xfee0));
4
+ return value.replace(FULL_WIDTH_ALPHANUMERIC, (character) => String.fromCharCode(character.charCodeAt(0) - 0xfee0));
5
5
  }
@@ -17,14 +17,15 @@ export function isEmptyValue(value) {
17
17
  * 新規インスタンスは lastIndex = 0 なので、sticky の意味を保ったまま結果が安定する。
18
18
  */
19
19
  export function runValidates(value, validates) {
20
- if (isEmptyValue(value))
20
+ if (isEmptyValue(value)) {
21
21
  return [];
22
+ }
22
23
  return validates
23
- .filter(validate => {
24
+ .filter((validate) => {
24
25
  const regex = new RegExp(validate.regex.source, validate.regex.flags);
25
26
  return !regex.test(String(value));
26
27
  })
27
- .map(validate => validate.message);
28
+ .map((validate) => validate.message);
28
29
  }
29
30
  /**
30
31
  * 必須チェックと validates をまとめて適用する。
@@ -32,7 +33,8 @@ export function runValidates(value, validates) {
32
33
  */
33
34
  export function validateInputValue(value, options = {}) {
34
35
  const { isRequired = false, validates = [] } = options;
35
- if (isEmptyValue(value))
36
+ if (isEmptyValue(value)) {
36
37
  return isRequired ? [MESSAGES.required] : [];
38
+ }
37
39
  return runValidates(value, validates);
38
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geckou/ui-core",
3
- "version": "0.1.2",
3
+ "version": "0.3.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",
@@ -34,10 +34,12 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
37
- "type-check": "tsc --noEmit",
37
+ "lint": "eslint src tests",
38
+ "lint:fix": "eslint src tests --fix",
39
+ "prepublishOnly": "yarn build",
38
40
  "test": "vitest run",
39
41
  "test:watch": "vitest",
40
- "prepublishOnly": "yarn build"
42
+ "type-check": "tsc --noEmit"
41
43
  },
42
44
  "devDependencies": {
43
45
  "typescript": "^5.5.3",