@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 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
@@ -0,0 +1,2 @@
1
+ import { useModalState as e } from "./index2.js";
2
+ export { e as useModalState };
@@ -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
+ }