@vlrc-fe/hooks 1.0.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 +125 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +2 -0
- package/dist/index2.cjs +1 -0
- package/dist/index2.js +69 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @vlrc-fe/hooks
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
ai_package_metadata:
|
|
5
|
+
package_name: "@vlrc-fe/hooks"
|
|
6
|
+
version: "workspace:*"
|
|
7
|
+
conventions:
|
|
8
|
+
import_hygiene:
|
|
9
|
+
main_app: "Do NOT manually import. Let Vite auto-import handle it (src/types/auto-imports.d.ts)."
|
|
10
|
+
local_packages: "Always use explicit relative or workspace imports. Packages compile without auto-imports."
|
|
11
|
+
hook_rules:
|
|
12
|
+
state_boundaries: "Prefer TanStack Query for server state. Use Zustand stores inside packages/ or apps/ only for pure UI/client state."
|
|
13
|
+
explicit_types: "Always declare explicit return types on hooks inside packages/ to avoid TS7056 / TS5088 serialization compilation limits."
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
Reusable, optimized, and tree-shaken React Hooks for the VLRC project monorepo.
|
|
17
|
+
|
|
18
|
+
> [!IMPORTANT]
|
|
19
|
+
> **AUTO-IMPORT CONVENTION**: In the main web application (`src/`), **do not write explicit import statements** for any hooks in this package. They are dynamically parsed and registered by Vite into `src/types/auto-imports.d.ts`.
|
|
20
|
+
> **EXPLICIT PACKAGE IMPORTS**: Inside independent packages (like `packages/components` or `packages/hooks`), **always** write explicit imports because packages are compiled standalone using `tsc`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 📚 Agent & Codex Skill Reference
|
|
25
|
+
|
|
26
|
+
This package is fully integrated with the **[platform-core-components Agent Skill](file:///Users/lvtruong/personal/vlrc/.agent/skills/platform-core-components/SKILL.md)**. For modal lifecycle patterns, data-binding examples, and `ConfirmModal` composition, see:
|
|
27
|
+
- **[Dialogs, ConfirmModal & useModalState Bindings](file:///Users/lvtruong/personal/vlrc/.agent/skills/platform-core-components/references/modals.md)**
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 📦 Core Hook: `useModalState`
|
|
32
|
+
|
|
33
|
+
A multi-key modal state manager that handles **open/close lifecycles**, **lazy mounting** (`load` flag), and **delayed DOM unmounting** (via `destroyDelay`) for smooth exit animations. It manages multiple independent modals from a single hook call using a typed key list.
|
|
34
|
+
|
|
35
|
+
### Hook Signature
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
function useModalState<const T extends ReadonlyArray<string>>(
|
|
39
|
+
keyList: T,
|
|
40
|
+
options?: { destroyDelay?: number }
|
|
41
|
+
): {
|
|
42
|
+
modal: Record<T[number], { load: boolean; open: boolean }>;
|
|
43
|
+
openModal: (name: T[number]) => void;
|
|
44
|
+
closeModal: (name: T[number]) => void;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Parameters
|
|
49
|
+
|
|
50
|
+
| Parameter | Type | Default | Description |
|
|
51
|
+
| :--- | :--- | :--- | :--- |
|
|
52
|
+
| `keyList` | `ReadonlyArray<string>` | - | **(Required)** A tuple of string keys identifying each modal, e.g. `['edit', 'delete', 'detail']`. |
|
|
53
|
+
| `options.destroyDelay` | `number` | `300` | Milliseconds to wait after closing before setting `load: false` (unmounting the modal DOM). Set to `0` for instant unmount. |
|
|
54
|
+
|
|
55
|
+
### Return Value
|
|
56
|
+
|
|
57
|
+
| Field | Type | Description |
|
|
58
|
+
| :--- | :--- | :--- |
|
|
59
|
+
| `modal` | `Record<Key, { load: boolean; open: boolean }>` | State map. `load` controls whether the modal component mounts in the DOM. `open` controls its visible/animated state. |
|
|
60
|
+
| `openModal` | `(name: Key) => void` | Sets both `load: true` and `open: true` for the given key. Cancels any pending destroy timer. |
|
|
61
|
+
| `closeModal` | `(name: Key) => void` | Sets `open: false` immediately, then sets `load: false` after `destroyDelay` ms. This two-phase approach allows CSS exit animations to complete before the component unmounts. |
|
|
62
|
+
|
|
63
|
+
### Lifecycle Flow
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
openModal('edit') → { load: true, open: true } // mount + animate in
|
|
67
|
+
closeModal('edit') → { load: true, open: false } // animate out (300ms)
|
|
68
|
+
→ { load: false, open: false } // unmount from DOM
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Example: Managing Multiple Modals
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
const CourseManager = () => {
|
|
75
|
+
const { modal, openModal, closeModal } = useModalState(['edit', 'delete'] as const);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<Button onClick={() => openModal('edit')}>Sửa khóa học</Button>
|
|
80
|
+
<Button variant="destructive" onClick={() => openModal('delete')}>Xóa khóa học</Button>
|
|
81
|
+
|
|
82
|
+
{/* Only mounts when modal.edit.load is true */}
|
|
83
|
+
{modal.edit.load && (
|
|
84
|
+
<Modal open={modal.edit.open} onOpenChange={() => closeModal('edit')}>
|
|
85
|
+
<div className="p-6">
|
|
86
|
+
<h3 className="text-lg font-bold">Chỉnh sửa khóa học</h3>
|
|
87
|
+
{/* Edit form content */}
|
|
88
|
+
</div>
|
|
89
|
+
</Modal>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Destructive confirmation with lazy mount */}
|
|
93
|
+
{modal.delete.load && (
|
|
94
|
+
<ConfirmModal
|
|
95
|
+
open={modal.delete.open}
|
|
96
|
+
title="Xác nhận xóa?"
|
|
97
|
+
description="Khóa học sẽ bị xóa vĩnh viễn."
|
|
98
|
+
variant="destructive"
|
|
99
|
+
onCancel={() => closeModal('delete')}
|
|
100
|
+
onConfirm={async () => {
|
|
101
|
+
await deleteCourseMutation.mutateAsync(courseId);
|
|
102
|
+
closeModal('delete');
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Why Two-Phase Unmounting?
|
|
112
|
+
|
|
113
|
+
The `load` / `open` separation exists because:
|
|
114
|
+
- **`open: false`** triggers exit animations (e.g. Radix Dialog fade-out, scale-down transitions).
|
|
115
|
+
- **`load: false`** removes the component from the React tree entirely after the animation completes.
|
|
116
|
+
- Without this, closing a dialog would instantly unmount its DOM nodes, cutting off any exit animation mid-frame.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 🛠 Package Scripts
|
|
121
|
+
|
|
122
|
+
Managed by Lerna and PNPM Workspaces:
|
|
123
|
+
- Build package: `pnpm build`
|
|
124
|
+
- Version package: `pnpm version:packages`
|
|
125
|
+
- Publish package: `pnpm publish:packages`
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("./index2.cjs");exports.useModalState=e.useModalState;
|
package/dist/index.js
ADDED
package/dist/index2.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let e=require("react");function t(t,n){let r=n?.destroyDelay??300,i=(0,e.useMemo)(()=>JSON.stringify(t),[t]),a=e=>e.reduce((e,t)=>(e[t]={load:!1,open:!1},e),{}),[o,s]=(0,e.useState)(()=>a(t)),c=(0,e.useRef)(new Map);return(0,e.useEffect)(()=>{s(e=>{let n=!1,r={...e};for(let e of t)e in r||(r[e]={load:!1,open:!1},n=!0);return n?r:e})},[t,i]),(0,e.useEffect)(()=>()=>{for(let e of c.current.values())clearTimeout(e);c.current.clear()},[]),{modal:o,openModal:e=>{s(t=>{if(!(e in t))return t;let n=c.current.get(String(e));return n&&(clearTimeout(n),c.current.delete(String(e))),{...t,[e]:{...t[e],open:!0,load:!0}}})},closeModal:e=>{if(s(t=>e in t?{...t,[e]:{...t[e],open:!1}}:t),r<=0){s(t=>e in t?{...t,[e]:{...t[e],load:!1}}:t);return}let t=String(e),n=c.current.get(t);n&&(clearTimeout(n),c.current.delete(t));let i=setTimeout(()=>{s(t=>e in t?{...t,[e]:{...t[e],load:!1}}:t),c.current.delete(t)},r);c.current.set(t,i)}}}exports.useModalState=t;
|
package/dist/index2.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect as e, useMemo as t, useRef as n, useState as r } from "react";
|
|
2
|
+
//#region src/useModalState.ts
|
|
3
|
+
function i(i, a) {
|
|
4
|
+
let o = a?.destroyDelay ?? 300, s = t(() => JSON.stringify(i), [i]), c = (e) => e.reduce((e, t) => (e[t] = {
|
|
5
|
+
load: !1,
|
|
6
|
+
open: !1
|
|
7
|
+
}, e), {}), [l, u] = r(() => c(i)), d = n(/* @__PURE__ */ new Map());
|
|
8
|
+
return e(() => {
|
|
9
|
+
u((e) => {
|
|
10
|
+
let t = !1, n = { ...e };
|
|
11
|
+
for (let e of i) e in n || (n[e] = {
|
|
12
|
+
load: !1,
|
|
13
|
+
open: !1
|
|
14
|
+
}, t = !0);
|
|
15
|
+
return t ? n : e;
|
|
16
|
+
});
|
|
17
|
+
}, [i, s]), e(() => () => {
|
|
18
|
+
for (let e of d.current.values()) clearTimeout(e);
|
|
19
|
+
d.current.clear();
|
|
20
|
+
}, []), {
|
|
21
|
+
modal: l,
|
|
22
|
+
openModal: (e) => {
|
|
23
|
+
u((t) => {
|
|
24
|
+
if (!(e in t)) return t;
|
|
25
|
+
let n = d.current.get(String(e));
|
|
26
|
+
return n && (clearTimeout(n), d.current.delete(String(e))), {
|
|
27
|
+
...t,
|
|
28
|
+
[e]: {
|
|
29
|
+
...t[e],
|
|
30
|
+
open: !0,
|
|
31
|
+
load: !0
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
closeModal: (e) => {
|
|
37
|
+
if (u((t) => e in t ? {
|
|
38
|
+
...t,
|
|
39
|
+
[e]: {
|
|
40
|
+
...t[e],
|
|
41
|
+
open: !1
|
|
42
|
+
}
|
|
43
|
+
} : t), o <= 0) {
|
|
44
|
+
u((t) => e in t ? {
|
|
45
|
+
...t,
|
|
46
|
+
[e]: {
|
|
47
|
+
...t[e],
|
|
48
|
+
load: !1
|
|
49
|
+
}
|
|
50
|
+
} : t);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let t = String(e), n = d.current.get(t);
|
|
54
|
+
n && (clearTimeout(n), d.current.delete(t));
|
|
55
|
+
let r = setTimeout(() => {
|
|
56
|
+
u((t) => e in t ? {
|
|
57
|
+
...t,
|
|
58
|
+
[e]: {
|
|
59
|
+
...t[e],
|
|
60
|
+
load: !1
|
|
61
|
+
}
|
|
62
|
+
} : t), d.current.delete(t);
|
|
63
|
+
}, o);
|
|
64
|
+
d.current.set(t, r);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { i as useModalState };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vlrc-fe/hooks",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Reusable PlatformCore React hooks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"main": "./dist/index.cjs",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "^19.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
27
|
+
"typescript": "~6.0.3",
|
|
28
|
+
"vite": "8.0.14"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json && vite build",
|
|
35
|
+
"lint": "eslint ."
|
|
36
|
+
}
|
|
37
|
+
}
|