@gateweb/react-utils 1.12.2 → 1.14.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/dist/es/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import dayjs from 'dayjs';
2
- export { Q as QueryProvider, u as useQueryContext } from './queryStore-client-vG-bXFYm.mjs';
3
- import React, { useContext, useMemo, createContext, useState, useCallback } from 'react';
2
+ export { Q as QueryProvider, u as useQueryContext } from './queryStore-client-CFQTVwrg.mjs';
3
+ import React, { useMemo, createContext, useContext, useState, useCallback } from 'react';
4
4
  export { u as useCountdown } from './useCountdown-client-t52WIHfq.mjs';
5
5
  export { d as downloadFile } from './download-client-CnaJ0p_f.mjs';
6
- export { g as getLocalStorage, s as setLocalStorage } from './webStorage-client-Pd-loNCg.mjs';
6
+ export { g as getLocalStorage, s as setLocalStorage } from './webStorage-client-W1DItzhS.mjs';
7
7
 
8
8
  const FILE_SIZE_UNITS$1 = [
9
9
  'Bytes',
@@ -231,11 +231,31 @@ function createEnumLikeObject(obj, name, scene) {
231
231
  }, time);
232
232
  });
233
233
 
234
+ const MimeTypeMap = {
235
+ jpeg: 'image/jpeg',
236
+ jpg: 'image/jpeg',
237
+ gif: 'image/gif',
238
+ png: 'image/png',
239
+ pdf: 'application/pdf',
240
+ zip: 'application/zip',
241
+ csv: 'text/csv',
242
+ ppt: 'application/vnd.ms-powerpoint',
243
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
244
+ xls: 'application/vnd.ms-excel',
245
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
246
+ doc: 'application/msword',
247
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
248
+ txt: 'text/plain'
249
+ };
250
+ const OtherMimeType = 'application/octet-stream';
251
+
234
252
  /**
235
- * 檢查檔案是否為合法的 MIME 類型
253
+ * 檢查檔案是否為合法的檔案類型
254
+ *
255
+ * `accepts` 可同時接受副檔名以及 MIME 類型
236
256
  *
237
257
  * @param file 檔案
238
- * @param accepts 允許的 MIME 類型
258
+ * @param accepts 允許的類型
239
259
  *
240
260
  * @example
241
261
  *
@@ -243,21 +263,24 @@ function createEnumLikeObject(obj, name, scene) {
243
263
  * validateFileType({ type: 'image/png' }, ['image/png', 'image/jpeg']) // true
244
264
  * validateFileType({ type: 'image/png' }, ['image/jpeg']) // false
245
265
  * validateFileType({ type: 'image/png' }, ['image/*']) // true
266
+ * validateFileType({ name: '圖片.png', type: 'image/png' }, ['.png']) // true
246
267
  * ```
247
268
  */ const validateFileType = (file, accepts)=>{
248
269
  if (accepts.length === 0) return true;
249
270
  // 獲取文件的MIME類型
250
271
  const fileMimeType = file.type;
272
+ // 提取副檔名(含 .,且轉小寫)
273
+ const fileExt = file.name.includes('.') ? `.${file.name.split('.').pop().toLowerCase()}` : '';
251
274
  return accepts.some((accept)=>{
275
+ if (accept.startsWith('.')) {
276
+ // 以副檔名檢查,忽略大小寫
277
+ return fileExt === accept.toLowerCase();
278
+ }
252
279
  if (accept === fileMimeType) {
253
280
  return true;
254
281
  }
255
282
  if (accept.endsWith('/*')) {
256
- const acceptedCategory = accept.split('/')[0];
257
- const fileCategory = fileMimeType.split('/')[0];
258
- if (acceptedCategory === fileCategory) {
259
- return true;
260
- }
283
+ return accept.split('/')[0] === fileMimeType.split('/')[0];
261
284
  }
262
285
  return false;
263
286
  });
@@ -284,32 +307,70 @@ function createEnumLikeObject(obj, name, scene) {
284
307
  * getMimeType('xlsx') // 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
285
308
  * getMimeType('doc') // 'application/msword'
286
309
  * getMimeType('docx') // 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
310
+ *
311
+ * @deprecated use `parseFileInfoFromFilename` instead
287
312
  */ const getMimeType = (fileName)=>{
288
- switch((fileName.split('.').pop() || '').toLocaleLowerCase()){
289
- case 'jpeg':
290
- case 'jpg':
291
- return 'image/jpeg';
292
- case 'png':
293
- return 'image/png';
294
- case 'pdf':
295
- return 'application/pdf';
296
- case 'zip':
297
- return 'application/zip';
298
- case 'csv':
299
- return 'text/csv';
300
- case 'xls':
301
- return 'application/vnd.ms-excel';
302
- case 'xlsx':
303
- return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
304
- case 'doc':
305
- return 'application/msword';
306
- case 'docx':
307
- return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
308
- case 'txt':
309
- return 'text/plain';
310
- default:
311
- return 'application/octet-stream';
313
+ const ext = (fileName.split('.').pop() || '').toLowerCase();
314
+ return MimeTypeMap[ext] ?? OtherMimeType;
315
+ };
316
+ /**
317
+ * 用來解析後端在 response header content-disposition 的內容
318
+ *
319
+ * 一般來說格式會有以下兩種
320
+ *
321
+ * - Content-Disposition: attachment; filename="file name.jpg"
322
+ * - Content-Disposition: attachment; filename*=UTF-8''file%20name2.jpg
323
+ *
324
+ * 如果格式正確就會取得檔案名稱(包含副檔名),優先取 filename* 內的內容
325
+ *
326
+ * @param disposition Content-Disposition
327
+ *
328
+ * @example
329
+ *
330
+ * parseFilenameFromDisposition('attachment; filename="file name1.jpg') // file name.jpg
331
+ * parseFilenameFromDisposition('attachment; filename*=UTF-8''file%20name2.jpg') // file name2.jpg
332
+ * parseFilenameFromDisposition('attachment; filename="file name.jpg; filename*=UTF-8''file%20name2.jpg') // file name2.jpg
333
+ */ const parseFilenameFromDisposition = (disposition)=>{
334
+ // 1. 先找 filename*
335
+ const filenameStarMatch = disposition.match(/filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i);
336
+ if (filenameStarMatch && filenameStarMatch[1]) {
337
+ // 依 RFC 5987 格式(UTF-8''URL-ENCODED),要先 decode
338
+ try {
339
+ return decodeURIComponent(filenameStarMatch[1].replace(/(^['"]|['"]$)/g, ''));
340
+ } catch {
341
+ // fallback,如果 decode 失敗,直接傳回原值
342
+ return filenameStarMatch[1];
343
+ }
312
344
  }
345
+ // 2. 沒有 filename*,再找 filename
346
+ const filenameMatch = disposition.match(/filename\s*=\s*("?)([^";]+)\1/i);
347
+ if (filenameMatch && filenameMatch[2]) {
348
+ return filenameMatch[2];
349
+ }
350
+ // 3. 都沒有則 undefined
351
+ return undefined;
352
+ };
353
+ /**
354
+ * 解析 `filename` 回傳檔名、副檔名、MIME type
355
+ *
356
+ * @param filename 檔案名稱
357
+ *
358
+ * @example
359
+ *
360
+ * parseFileInfoFromFilename('image.jpg') // ['image', 'jpg', 'image/jpeg']
361
+ * parseFileInfoFromFilename('image') // ['image', '', 'application/octet-stream']
362
+ */ const parseFileInfoFromFilename = (filename)=>{
363
+ const lastDot = filename.lastIndexOf('.');
364
+ if (lastDot === -1) return [
365
+ filename,
366
+ '',
367
+ OtherMimeType
368
+ ]; // 沒有副檔名
369
+ return [
370
+ filename.slice(0, lastDot),
371
+ filename.slice(lastDot + 1),
372
+ MimeTypeMap[filename.slice(lastDot + 1)] ?? OtherMimeType
373
+ ];
313
374
  };
314
375
 
315
376
  // const isProduction: boolean = process.env.NODE_ENV === 'production';
@@ -439,6 +500,85 @@ const isObject = (value)=>value !== null && typeof value === 'object';
439
500
  }, {
440
501
  ...target
441
502
  });
503
+ /**
504
+ * A utility function to deeply clone an object.
505
+ * @param obj - The object to clone.
506
+ *
507
+ * @example
508
+ *
509
+ * const original = { a: 1, b: { c: 2 } };
510
+ * const cloned = deepClone(original);
511
+ * console.log(cloned); // { a: 1, b: { c: 2 } }
512
+ *
513
+ */ const deepClone = (obj)=>{
514
+ if (obj === null || typeof obj !== 'object') {
515
+ return obj;
516
+ }
517
+ if (obj instanceof Date) {
518
+ return new Date(obj.getTime());
519
+ }
520
+ if (Array.isArray(obj)) {
521
+ return obj.map((item)=>deepClone(item));
522
+ }
523
+ const cloned = Object.keys(obj).reduce((acc, key)=>{
524
+ acc[key] = deepClone(obj[key]);
525
+ return acc;
526
+ }, {});
527
+ return cloned;
528
+ };
529
+
530
+ /**
531
+ * 將嵌套物件的所有屬性設為指定的布林值
532
+ * @param obj - 目標物件
533
+ * @param boolValue - 布林值
534
+ *
535
+ * @example
536
+ * const obj = { a: { b: 1, c: 2 }, d: 3 };
537
+ * const result = setBooleanToNestedObject(obj, true);
538
+ * console.log(result); // { a: { b: true, c: true }, d: true }
539
+ */ const setBooleanToNestedObject = (obj, boolValue)=>Object.entries(obj).reduce((acc, [key, value])=>{
540
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
541
+ // 如果是嵌套物件,遞歸處理
542
+ acc[key] = setBooleanToNestedObject(value, boolValue);
543
+ } else {
544
+ // 如果是基本類型,設為布林值
545
+ acc[key] = boolValue;
546
+ }
547
+ return acc;
548
+ }, {});
549
+ /**
550
+ * 深度合併配置物件的通用工具
551
+ *
552
+ * @param defaultConfig - 預設配置
553
+ * @param customConfig - 自訂配置
554
+ *
555
+ * @example
556
+ * const defaultConfig = { a: true, b: { b1: true, b2: true, b3: false }, c: true, d: false };
557
+ * mergeConfig(defaultConfig, { b: { b1: false }, d: true }); // { a: true, b: { b1: false, b2: true, b3: false }, c: true, d: true }
558
+ * mergeConfig(defaultConfig, { b: false }); // { a: true, b: { b1: false, b2: false, b3: false }, c: true, d: false }
559
+ * mergeConfig(defaultConfig, { b: true }); // { a: true, b: { b1: true, b2: true, b3: true }, c: true, d: false }
560
+ */ const mergeConfig = (defaultConfig, customConfig = {})=>{
561
+ // 深拷貝預設配置以避免修改原始物件
562
+ const result = deepClone(defaultConfig);
563
+ return Object.entries(customConfig).reduce((acc, [key, sourceValue])=>{
564
+ const targetValue = acc[key];
565
+ // 如果來源值為 null 或 undefined,跳過
566
+ if (sourceValue === null || sourceValue === undefined) {
567
+ return acc;
568
+ }
569
+ // 如果目標物件中對應的值是物件,且來源值是布林值
570
+ // 這是特殊情況:用布林值覆蓋整個嵌套物件
571
+ if (typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue) && typeof sourceValue === 'boolean') {
572
+ // 將嵌套物件的所有屬性設為該布林值
573
+ acc[key] = setBooleanToNestedObject(targetValue, sourceValue);
574
+ } else if (typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue) && typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)) {
575
+ acc[key] = mergeConfig(targetValue, sourceValue);
576
+ } else {
577
+ acc[key] = sourceValue;
578
+ }
579
+ return acc;
580
+ }, result);
581
+ };
442
582
 
443
583
  /**
444
584
  * debounce function
@@ -848,8 +988,7 @@ const FILE_SIZE_UNITS = [
848
988
  *
849
989
  * isEmail().test('123') // false
850
990
  * isEmail().test('123@gmail.com') // true
851
- */ const isEmail = ()=>// eslint-disable-next-line
852
- /^(?!\.)(?!.*\.\.)([A-Z0-9_+-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;
991
+ */ const isEmail = ()=>/^(?!\.)(?!.*\.\.)([A-Z0-9_+-\\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\\-]*\.)+[A-Z]{2,}$/i;
853
992
  /**
854
993
  * 日期字串
855
994
  *
@@ -1048,6 +1187,29 @@ const FILE_SIZE_UNITS = [
1048
1187
  // 再用 dayjs 驗證是否為有效日期
1049
1188
  return dayjs(dateString, format, true).isValid();
1050
1189
  };
1190
+ /**
1191
+ * 判斷 `value` 是否為 `null` 或 `undefined`
1192
+ *
1193
+ * @param value 值
1194
+ *
1195
+ * @example
1196
+ * isNil(null); // true
1197
+ * isNil(undefined); // true
1198
+ * isNil(0); // false
1199
+ * isNil(''); // false
1200
+ * isNil(false); // false
1201
+ * isNil({}); // false
1202
+ *
1203
+ * // TypeScript 型別縮窄應用
1204
+ * function example(input?: string | null) {
1205
+ * if (isNil(input)) {
1206
+ * // input 型別會自動縮窄成 null | undefined
1207
+ * return 'No value';
1208
+ * }
1209
+ * // input 型別自動為 string
1210
+ * return input.toUpperCase();
1211
+ * }
1212
+ */ const isNil = (value)=>value === null || value === undefined;
1051
1213
 
1052
1214
  /**
1053
1215
  * Creates a strongly-typed React Context and Provider pair for data sharing.
@@ -1057,7 +1219,7 @@ const FILE_SIZE_UNITS = [
1057
1219
  * and a Provider component for supplying context values.
1058
1220
  *
1059
1221
  * @template T - The value type for the context.
1060
- * @returns {Object} An object containing:
1222
+ * @returns {object} An object containing:
1061
1223
  * - useDataContext: A custom hook to access the context value. Throws an error if used outside the provider.
1062
1224
  * - DataProvider: A Provider component to wrap your component tree and supply the context value.
1063
1225
  *
@@ -1224,4 +1386,4 @@ function mergeRefs(refs) {
1224
1386
  return dayjs(endMonth, 'YYYYMM').subtract(1911, 'year').format('YYYYMM').substring(1);
1225
1387
  };
1226
1388
 
1227
- export { ByteSize, adToRocEra, camelCase2PascalCase, camelCase2SnakeCase, camelString2PascalString, camelString2SnakeString, convertBytes, createDataContext, createEnumLikeObject, debounce, deepMerge, extractEnumLikeObject, fakeApi, formatAmount, formatBytes, formatStarMask, generatePeriodArray, getCurrentPeriod, getMimeType, invariant, isChinese, isDateString, isDateTimeString, isEmail, isEnglish, isEqual, isNonZeroStart, isNumber, isNumberAtLeastN, isNumberN, isNumberNM, isServer, isTWMobile, isTWPhone, isTimeString, isValidPassword, maskString, mergeRefs, objectToSearchParams, omit, omitByValue, pascalCase2CamelCase, pascalCase2SnakeCase, pascalString2CamelString, pascalString2SnakeString, pick, pickByValue, rocEraToAd, searchParamsToObject, snakeCase2CamelCase, snakeCase2PascalCase, snakeString2CamelString, snakeString2PascalString, throttle, useValue, validTaxId, validateDateString, validateFileType, wait };
1389
+ export { ByteSize, MimeTypeMap, OtherMimeType, adToRocEra, camelCase2PascalCase, camelCase2SnakeCase, camelString2PascalString, camelString2SnakeString, convertBytes, createDataContext, createEnumLikeObject, debounce, deepClone, deepMerge, extractEnumLikeObject, fakeApi, formatAmount, formatBytes, formatStarMask, generatePeriodArray, getCurrentPeriod, getMimeType, invariant, isChinese, isDateString, isDateTimeString, isEmail, isEnglish, isEqual, isNil, isNonZeroStart, isNumber, isNumberAtLeastN, isNumberN, isNumberNM, isServer, isTWMobile, isTWPhone, isTimeString, isValidPassword, maskString, mergeConfig, mergeRefs, objectToSearchParams, omit, omitByValue, parseFileInfoFromFilename, parseFilenameFromDisposition, pascalCase2CamelCase, pascalCase2SnakeCase, pascalString2CamelString, pascalString2SnakeString, pick, pickByValue, rocEraToAd, searchParamsToObject, snakeCase2CamelCase, snakeCase2PascalCase, snakeString2CamelString, snakeString2PascalString, throttle, useValue, validTaxId, validateDateString, validateFileType, wait };
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import React, { useRef, useContext, createContext } from 'react';
2
+ import React, { useRef, createContext, useContext } from 'react';
3
3
  import { createStore } from 'zustand';
4
4
  import { useStoreWithEqualityFn } from 'zustand/traditional';
5
5
 
@@ -187,5 +187,18 @@ type TChangeKeyType<T, K extends keyof T, V> = Omit<T, K> & {
187
187
  type MapToType<T extends Record<any, any>, A = any> = {
188
188
  [K in keyof T]: A;
189
189
  };
190
+ /**
191
+ * 覆寫 T 指定屬性的型別,保留其他屬性。
192
+ *
193
+ * @template T 原始型別
194
+ * @template R 欲覆寫的 key-value 結構
195
+ *
196
+ * @example
197
+ * type User = { id: number; name: string }
198
+ * type FormUser = OverrideProps<User, { id: string }> // { id: string; name: string }
199
+ */
200
+ type OverrideProps<T, R extends {
201
+ [K in keyof R]: any;
202
+ }> = Omit<T, keyof R> & R;
190
203
 
191
- export type { AtLeastOne, DeepPartial, Entries, MapToString, MapToType, OnlyOne, PopArgs, PushArgs, ShiftArgs, TChangeKeyType, TExtractValueType, UnshiftArgs };
204
+ export type { AtLeastOne, DeepPartial, Entries, MapToString, MapToType, OnlyOne, OverrideProps, PopArgs, PushArgs, ShiftArgs, TChangeKeyType, TExtractValueType, UnshiftArgs };
@@ -20,7 +20,7 @@
20
20
  } else {
21
21
  currentObject = JSON.parse(storage);
22
22
  }
23
- } catch (error) {
23
+ } catch (_error) {
24
24
  return undefined;
25
25
  }
26
26
  // let currentObject = JSON.parse(storage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gateweb/react-utils",
3
- "version": "1.12.2",
3
+ "version": "1.14.0",
4
4
  "description": "React Utils for GateWeb",
5
5
  "homepage": "https://github.com/GatewebSolutions/react-utils",
6
6
  "files": [
@@ -36,33 +36,38 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "dayjs": "^1.11.13",
39
- "react": "^19.0.0",
40
- "react-dom": "^19.0.0",
39
+ "react": "^19.1.0",
40
+ "react-dom": "^19.1.0",
41
41
  "use-sync-external-store": "^1.5.0",
42
- "zustand": "^5.0.3"
42
+ "zustand": "^5.0.6"
43
43
  },
44
44
  "devDependencies": {
45
- "@commitlint/cli": "^19.5.0",
46
- "@commitlint/config-conventional": "^19.5.0",
47
- "@gateweb/eslint-config-gateweb": "^1.0.6",
45
+ "@commitlint/cli": "^19.8.1",
46
+ "@commitlint/config-conventional": "^19.8.1",
47
+ "@gateweb/eslint-config-gateweb": "^2.1.1",
48
48
  "@testing-library/jest-dom": "^6.6.3",
49
- "@testing-library/react": "^16.2.0",
50
- "@types/jest": "^29.5.13",
51
- "@types/node": "^22.7.7",
52
- "@types/react": "^19.0.8",
53
- "@vitest/coverage-v8": "^2.1.4",
54
- "@vitest/ui": "2.1.4",
49
+ "@testing-library/react": "^16.3.0",
50
+ "@types/jest": "^30.0.0",
51
+ "@types/node": "^24.1.0",
52
+ "@types/react": "^19.1.8",
53
+ "@vitest/coverage-v8": "^3.2.4",
54
+ "@vitest/ui": "3.2.4",
55
55
  "bunchee": "^5.6.1",
56
- "eslint": "^8",
57
- "husky": "^9.1.6",
58
- "jest": "^29.7.0",
59
- "jest-environment-jsdom": "^29.7.0",
60
- "lint-staged": "^15.2.10",
61
- "prettier": "^3.3.3",
62
- "ts-jest": "^29.2.5",
56
+ "eslint": "^9.31.0",
57
+ "husky": "^9.1.7",
58
+ "jest": "^30.0.5",
59
+ "jest-environment-jsdom": "^30.0.5",
60
+ "lint-staged": "^16.1.2",
61
+ "prettier": "^3.6.2",
62
+ "ts-jest": "^29.4.0",
63
63
  "ts-node": "^10.9.2",
64
- "typescript": "^5.6.3",
65
- "vitest": "^2.1.4"
64
+ "typescript": "^5.8.3",
65
+ "vitest": "^3.2.4"
66
+ },
67
+ "commitlint": {
68
+ "extends": [
69
+ "@commitlint/config-conventional"
70
+ ]
66
71
  },
67
72
  "scripts": {
68
73
  "run-code": "ts-node src/period.ts",