@khanglvm/react 1.2.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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/helpers/createCacheStorage.d.ts +5 -0
- package/dist/helpers/createCacheStorage.js +35 -0
- package/dist/helpers/deepClone.d.ts +1 -0
- package/dist/helpers/deepClone.js +53 -0
- package/dist/helpers/deepEqual.d.ts +8 -0
- package/dist/helpers/deepEqual.js +63 -0
- package/dist/helpers/index.d.ts +8 -0
- package/dist/helpers/index.js +18 -0
- package/dist/helpers/react.d.ts +26 -0
- package/dist/helpers/react.js +44 -0
- package/dist/helpers/types.d.ts +19 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -0
- package/dist/libs/createContextState.d.ts +89 -0
- package/dist/libs/createContextState.js +231 -0
- package/dist/libs/createEventMethod.d.ts +24 -0
- package/dist/libs/createEventMethod.js +51 -0
- package/dist/libs/createStateManager.d.ts +48 -0
- package/dist/libs/createStateManager.js +16 -0
- package/dist/libs/createTranslator.d.ts +120 -0
- package/dist/libs/createTranslator.js +78 -0
- package/docs/helpers.md +127 -0
- package/docs/state.md +169 -0
- package/docs/utilities.md +130 -0
- package/package.json +96 -0
package/docs/helpers.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Helpers and utility types
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
Import these through `@khanglvm/react/helpers`; they are not exported from the package root.
|
|
6
|
+
|
|
7
|
+
## Copying, comparison, and caching
|
|
8
|
+
|
|
9
|
+
### deepClone(value, cache?)
|
|
10
|
+
|
|
11
|
+
Use this when copying a nested value with the same rules used by the state utility. It handles cycles, arrays, plain objects, Date, RegExp, Map, Set, and ArrayBuffer. Functions, AbortSignal, and FormData are retained by reference. An optional WeakMap tracks copies during recursion.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { deepClone } from '@khanglvm/react/helpers'
|
|
15
|
+
|
|
16
|
+
const original = { tags: ['react'], createdAt: new Date('2026-01-01') }
|
|
17
|
+
const copy = deepClone(original)
|
|
18
|
+
copy.tags.push('typescript') // original.tags is unchanged
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
An object spread only copies one level; this function descends into nested data. For standard structured-clone-compatible data, consider the platform's `structuredClone` first. This helper is not a universal clone: it drops custom object prototypes and symbol properties, and typed-array views are rebuilt from their entire backing buffer without preserving their original offset or length. It also expects AbortSignal and FormData globals to exist.
|
|
22
|
+
|
|
23
|
+
### deepEqual(a, b)
|
|
24
|
+
|
|
25
|
+
Use this for value comparison of acyclic objects, arrays, Maps, and Sets when reference equality is insufficient. It also compares Date timestamps, RegExp strings, constructors, and NaN; React element `_owner` fields are skipped.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { deepEqual } from '@khanglvm/react/helpers'
|
|
29
|
+
|
|
30
|
+
deepEqual({ tags: ['react'] }, { tags: ['react'] }) // true
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Maps compare their size, keys, and values. Keys use native Map identity, and values are compared recursively. Sets compare size and membership using native Set identity. Insertion order does not matter. Object keys and Set members must be the same references; matching object contents alone are insufficient.
|
|
34
|
+
|
|
35
|
+
There is no cycle detection. Symbol and non-enumerable properties are not included. Use a comparator designed for those cases when they are part of your data.
|
|
36
|
+
|
|
37
|
+
### createCacheStorage(id?)
|
|
38
|
+
|
|
39
|
+
Use this to retain the previous reference for an equivalent value, keyed by a cache entry and optional group path. Unlike a one-time memoized calculation, callers provide the newly computed value; the cache compares it with the previous one.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createCacheStorage } from '@khanglvm/react/helpers'
|
|
43
|
+
|
|
44
|
+
const { cache, clear, uid } = createCacheStorage()
|
|
45
|
+
const group = uid()
|
|
46
|
+
const first = cache({ count: 1 }, 'summary', group)
|
|
47
|
+
const second = cache({ count: 1 }, 'summary', group)
|
|
48
|
+
console.log(first === second) // true
|
|
49
|
+
clear(group)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`cache(value, entryId, ...groupKeys)` returns the earlier value when `deepEqual` says they match. `clear(...groupKeys)` removes a group path; `uid()` returns a new symbol. All equality limitations above apply.
|
|
53
|
+
|
|
54
|
+
There is no whole-cache clear: `clear()` with no keys does nothing. Supplying a factory ID fixes the entry ID, but its bound `clear()` does not reliably remove the resulting entry. Prefer the unbound factory with explicit groups, as above, and clear groups when their owner is done. Skip this helper when a stable reference or React `useMemo` already solves the problem.
|
|
55
|
+
|
|
56
|
+
## Memo comparison
|
|
57
|
+
|
|
58
|
+
`comparePropsForMemo(keys)` builds a comparator for named props. `compareAllPropsForMemo(previous, next)` compares the keys present in the previous props object. Both use `deepEqual`.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { memo } from 'react'
|
|
62
|
+
import { comparePropsForMemo } from '@khanglvm/react/helpers'
|
|
63
|
+
|
|
64
|
+
type LabelProps = { label: string }
|
|
65
|
+
export const Label = memo(
|
|
66
|
+
function Label({ label }: LabelProps) { return <span>{label}</span> },
|
|
67
|
+
comparePropsForMemo<LabelProps>('label'),
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For all existing keys, `memo(Component, compareAllPropsForMemo)` is the companion shorthand; note the added-prop limitation below.
|
|
72
|
+
|
|
73
|
+
These save writing a custom comparator when bounded nested props need value comparison. Default `memo` is usually the simpler starting point. Include every prop that affects output or behavior, including callbacks; an ignored callback can retain stale data. `compareAllPropsForMemo` can miss a newly added prop because it only visits the previous keys. Both follow the collection identity rules of `deepEqual` and do not support cyclic values.
|
|
74
|
+
|
|
75
|
+
React's [memo documentation](https://react.dev/reference/react/memo#specifying-a-custom-comparison-function) explains custom comparator costs and the need to compare function props. These helpers do not promise a faster render.
|
|
76
|
+
|
|
77
|
+
## Children
|
|
78
|
+
|
|
79
|
+
The child helpers package repeated checks into named functions. Use them only when their exact rules match your component; most components can simply render `children`.
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import {
|
|
83
|
+
isEmptyChildren,
|
|
84
|
+
isPrimitiveChildren,
|
|
85
|
+
isReactFragmentChildren,
|
|
86
|
+
filterComponentFromChildren,
|
|
87
|
+
} from '@khanglvm/react/helpers'
|
|
88
|
+
|
|
89
|
+
isEmptyChildren(null) // true
|
|
90
|
+
isPrimitiveChildren('Hello') // true
|
|
91
|
+
isReactFragmentChildren(<>Hello</>) // true
|
|
92
|
+
|
|
93
|
+
function Tab({ title }: { title: string }) { return <span>{title}</span> }
|
|
94
|
+
const tabs = filterComponentFromChildren(
|
|
95
|
+
[<Tab key="a" title="Overview" />, <span key="b">Other</span>],
|
|
96
|
+
Tab,
|
|
97
|
+
) // only the Tab element
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Helper | Useful check | Exact behavior and limit |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `isIterableChildren(children)` | More than one child slot | Checks `Children.count > 1`; a one-item array returns false. The name does not mean general JavaScript iterability. |
|
|
103
|
+
| `isSingleReactElementChildren(child)` | One non-primitive child slot | Excludes strings, numbers, booleans, null, and undefined. It does not call `isValidElement`; a one-item array can pass. Use React `isValidElement` for element validation. |
|
|
104
|
+
| `isReactFragmentChildren(child)` | A fragment wrapper | Adds a fragment-type string check to the single-child check. It does not inspect the fragment's contents. |
|
|
105
|
+
| `isHTMLElementChildren(child)` | A single non-fragment child | Also accepts custom React components; it does not prove a host HTML element. For that, check `isValidElement(child) && typeof child.type === 'string'`. |
|
|
106
|
+
| `isEmptyChildren(children)` | Absent value | True only for null or undefined; false, an empty string, and an empty array do not pass. |
|
|
107
|
+
| `isPrimitiveChildren(children)` | Text, number, or boolean | Booleans pass even though React does not render them as visible text. |
|
|
108
|
+
| `filterComponentFromChildren(children, Component)` | Direct children of a chosen component type | Uses `Children.toArray`, filters valid elements, and checks exact `child.type` identity. It does not search inside fragments or rendered components. |
|
|
109
|
+
|
|
110
|
+
React documents these traversal boundaries in [Children](https://react.dev/reference/react/Children#caveats). These predicates are convenience checks, not validators for all ReactNode shapes.
|
|
111
|
+
|
|
112
|
+
## Types
|
|
113
|
+
|
|
114
|
+
These exports affect TypeScript only. Use them to reduce repeated type expressions; they do not validate or change runtime values.
|
|
115
|
+
|
|
116
|
+
| Type | Use and example | Limit |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `TAssertFunction<T>` | A generic identity signature preserving a subtype: `TAssertFunction<{ id: string }>` | Declares a function type, not an assertion implementation |
|
|
119
|
+
| `MapKeyType<T>` | Extract a Map key: `MapKeyType<Map<string, number>>` is string | Non-Map inputs resolve to never |
|
|
120
|
+
| `MapValueType<T>` | Extract a Map value: `MapValueType<Map<string, number>>` is number | Non-Map inputs resolve to never |
|
|
121
|
+
| `ExtractValue<T, V>` | Retain a constrained member: `ExtractValue<'a' \| 'b', 'a'>` is 'a' | Returns V; it is not the built-in Extract filter |
|
|
122
|
+
| `TDeepReadonly<T>` | Recursive readonly object/array view: `TDeepReadonly<{ tags: string[] }>` | No runtime freeze; collection handling omits some methods but does not recursively freeze entries or remove every mutator, such as Set.add |
|
|
123
|
+
| `TDeepMutable<T>` | Remove readonly from nested objects/arrays: `TDeepMutable<Readonly<{ count: number }>>` | Built-ins and collection types are largely preserved |
|
|
124
|
+
| `DistributiveOmit<T, K>` | Apply Omit to each union member: `DistributiveOmit<{ kind: 'a'; x: number } \| { kind: 'b'; y: string }, 'kind'>` | A compile-time union transformation |
|
|
125
|
+
| `Prettify<T>` | Expand an object intersection for easier type inspection: `Prettify<{ a: string } & { b: number }>` | Does not transform values |
|
|
126
|
+
|
|
127
|
+
Root exports also include `TContextState` and `TStateManager`, return-type aliases for their factories, plus `LocalizedString` and `TranslationNamespace`, described in the [translation guide](utilities.md#translation).
|
package/docs/state.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# State API
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
Use `createContextState` for a form, editor, or other feature whose components share editable state. It packages selector hooks, Immer updates, and optional rollback, so each feature does not need to assemble those pieces itself. Keep local state in `useState` when one component owns it.
|
|
6
|
+
|
|
7
|
+
React's [`useContext`](https://react.dev/reference/react/useContext) subscribes consumers to the provider value. This utility uses context to locate a store and `useSyncExternalStore` to read selected snapshots. It still notifies all store subscribers, then caches selected values using its own equality function. That is a different subscription mechanism, not a promise that a component never re-renders or that this library is faster than another store.
|
|
8
|
+
|
|
9
|
+
`createContextState<State>(instanceId?)` creates a provider and its hooks. Use an object type for `State`. If you supply an ID, keep it unique to that state utility; the browser uses it to identify a cached context. Declare the factory call outside component renders.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { createContextState } from '@khanglvm/react'
|
|
13
|
+
|
|
14
|
+
type ProfileState = {
|
|
15
|
+
user: { name: string }
|
|
16
|
+
saving: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const {
|
|
20
|
+
Provider,
|
|
21
|
+
useContextState: useProfileState,
|
|
22
|
+
useContextStateValue: useProfileValue,
|
|
23
|
+
useSetContextState: useSetProfile,
|
|
24
|
+
useStateSnapshotGetter: useProfileSnapshot,
|
|
25
|
+
withContextProvider,
|
|
26
|
+
} = createContextState<ProfileState>()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Provider and initialization
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<Provider initialState={{ user: { name: 'Khang' }, saving: false }}>
|
|
33
|
+
<ProfileEditor />
|
|
34
|
+
</Provider>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`initialState` accepts a partial state, but selectors need their fields to exist. Supply a complete initial value. It seeds the provider; use a setter or prop binding for subsequent changes.
|
|
38
|
+
|
|
39
|
+
Hooks outside a provider fall back to a dummy context whose setters do nothing. Keep consumers under the matching provider.
|
|
40
|
+
|
|
41
|
+
## Read and update
|
|
42
|
+
|
|
43
|
+
Inside a component:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
const [name, setState, getSnapshot] = useProfileState(state => state.user.name)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The tuple contains the selected value, a setter, and a snapshot getter. Selectors should be pure: derive their result from state without effects, random values, or timestamps. Treat selected values as read-only even though the returned TypeScript type is mutable. Snapshot equality uses `deepEqual`. It compares Map values and Set membership, using native identity for Map keys and Set members. Cyclic structures are unsupported; see the [helper limits](helpers.md#deepequala-b).
|
|
50
|
+
|
|
51
|
+
The setter accepts either a partial object or an Immer draft callback:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
setState({ saving: true })
|
|
55
|
+
setState(draft => {
|
|
56
|
+
draft.user.name = 'Khang Le'
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A partial object replaces each supplied top-level field. It does not deep-merge nested objects. Use a draft callback for nested edits.
|
|
61
|
+
|
|
62
|
+
`useProfileValue(selector)` returns just the selected value. `useSetProfile()` returns just the setter and does not create a store subscription. A component can still render when its parent or other React state changes.
|
|
63
|
+
|
|
64
|
+
`useProfileSnapshot()` returns a getter without subscribing:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
const getSnapshot = useProfileSnapshot()
|
|
68
|
+
|
|
69
|
+
function handleSave() {
|
|
70
|
+
const name = getSnapshot(state => state.user.name)
|
|
71
|
+
console.log(name)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Snapshots are references to current state, not editable copies. Use the setter for changes.
|
|
76
|
+
|
|
77
|
+
## Revert an update
|
|
78
|
+
|
|
79
|
+
With the default hook settings, the setter records Immer inverse patches and returns a function that applies them:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
const revert = setState(draft => {
|
|
83
|
+
draft.user.name = 'New name'
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// If this change needs to be undone:
|
|
87
|
+
revert()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This is a per-update rollback, not a history manager. An inverse patch can overwrite a later change to the same field. Coordinate pending saves before using it for optimistic updates.
|
|
91
|
+
|
|
92
|
+
Pass `false` to skip patch tracking when rollback is unnecessary:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
const setState = useSetProfile(false)
|
|
96
|
+
// Or: useProfileState(state => state.user.name, false)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In that mode the returned revert function does nothing.
|
|
100
|
+
|
|
101
|
+
## Initialize and bind component props
|
|
102
|
+
|
|
103
|
+
`withContextProvider` wraps a component and can initialize state from its props. Its type parameter describes component props, not the store.
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
type ProfileProps = { name: string }
|
|
107
|
+
|
|
108
|
+
const Profile = withContextProvider<ProfileProps>(
|
|
109
|
+
function ProfileView() {
|
|
110
|
+
const name = useProfileValue(state => state.user.name)
|
|
111
|
+
return <p>{name}</p>
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
initialState: props => ({ user: { name: props.name }, saving: false }),
|
|
115
|
+
bindPropToState: (draft, props) => {
|
|
116
|
+
draft.user.name = props.name
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`bindPropToState` uses the experimental `StateSynchronizer` and runs in an effect after the initial render. It can overwrite local edits to the fields it binds.
|
|
123
|
+
|
|
124
|
+
## Advanced hooks
|
|
125
|
+
|
|
126
|
+
`useSetPostFlush()` returns a registration function for one callback per provider. That callback receives an Immer draft once per microtask batch, before subscribers are notified. A new registration replaces the previous callback; there is no unregister API. See the [implementation](../src/libs/createContextState.tsx) before using it for derived data.
|
|
127
|
+
|
|
128
|
+
`withStateProvider` is the deprecated name for `withContextProvider`. The separate `createStateManager` factory keeps legacy aliases: `useState`, `useStateValue`, `useSetState`, `useSnapshot`, and `withProvider`.
|
|
129
|
+
|
|
130
|
+
### StateSynchronizer directly
|
|
131
|
+
|
|
132
|
+
Use `StateSynchronizer` when an existing provider needs to receive an external value without wrapping the component in another provider. It applies updates in an effect after mount:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
const { StateSynchronizer } = createContextState<{ name: string }>()
|
|
136
|
+
// Render under the Provider returned by this same factory:
|
|
137
|
+
// <StateSynchronizer
|
|
138
|
+
// data={{ name: externalName }}
|
|
139
|
+
// updateStateOnDataChanged={(draft, data) => { draft.name = data.name }}
|
|
140
|
+
// />
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Prefer an ordinary event-handler setter when the change already comes from a user action; prop synchronization introduces another update path.
|
|
144
|
+
|
|
145
|
+
### Legacy factory
|
|
146
|
+
|
|
147
|
+
`createStateManager` is useful while maintaining code that already uses its names. It delegates to the current factory and adds aliases, so it is not a different state engine:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { createStateManager } from '@khanglvm/react'
|
|
151
|
+
const legacy = createStateManager<{ count: number }>()
|
|
152
|
+
// Inside legacy.Provider: legacy.useState(state => state.count)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Server rendering
|
|
156
|
+
|
|
157
|
+
Each Provider creates its own store from `initialState`. Server-side selectors
|
|
158
|
+
read that Provider's store, and their selected snapshots are cached. Reusing a
|
|
159
|
+
factory or `instanceId` shares the Context identity; it does not share live state
|
|
160
|
+
between Providers or server requests.
|
|
161
|
+
|
|
162
|
+
Pass matching initial values on the server and client for hydration. A mounted
|
|
163
|
+
Provider keeps its store, so changing the `initialState` prop does not reset it.
|
|
164
|
+
Use a setter or prop binding for later updates, or mount a new Provider when you
|
|
165
|
+
want a fresh store. Selector caches are cleared when their hooks unmount.
|
|
166
|
+
|
|
167
|
+
For Next.js App Router, use the hooks inside a client component boundary. The
|
|
168
|
+
package includes a client directive on its state entrypoint. Check hydration in
|
|
169
|
+
your framework, especially when initial state includes request-specific data.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Translation and browser events
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
## Translation
|
|
6
|
+
|
|
7
|
+
Use `createTranslator` when you have language variants in a small dictionary and want to select one or substitute text. It saves repeating language lookups. It does not manage locale detection, plural rules, or date and number formatting; use the platform's `Intl` APIs or a fuller i18n system for those needs.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createTranslator } from '@khanglvm/react'
|
|
11
|
+
|
|
12
|
+
const t = createTranslator<'en' | 'vi'>('en')
|
|
13
|
+
const greeting = { en: 'Hello USER_NAME', vi: 'Xin chào USER_NAME' }
|
|
14
|
+
console.log(t(greeting, { USER_NAME: 'Khang' })) // Hello Khang
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The translator selects the current language, returns an empty string for a missing value at runtime, and replaces matching text case-insensitively. Replacement keys become regular-expression patterns and can match substrings. Use distinctive plain tokens such as `USER_NAME`; `{name}` is not a special placeholder syntax, and a replacement key of `name` would leave the braces in the result. Do not pass arbitrary user text as a replacement key.
|
|
18
|
+
|
|
19
|
+
### Typed dictionaries and React hooks
|
|
20
|
+
|
|
21
|
+
`defineLocale<Languages>()` binds the language union once and returns:
|
|
22
|
+
|
|
23
|
+
- `assertTranslation(dictionary)`: checks dictionary shape at compile time and returns a readonly type. It does not freeze or validate runtime data.
|
|
24
|
+
- `createTranslator(language)`: the translator above, bound to your language union.
|
|
25
|
+
- `createTranslatorHook({ translation, usePreferredLanguage })`: creates hooks that use your app's language source.
|
|
26
|
+
|
|
27
|
+
This fits a feature that owns a small dictionary and needs the same keys across its components. The app still owns language selection; the utility does not create a shared language store.
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { createContext, useContext } from 'react'
|
|
31
|
+
import { defineLocale } from '@khanglvm/react'
|
|
32
|
+
|
|
33
|
+
type Language = 'en' | 'vi'
|
|
34
|
+
const LanguageContext = createContext<Language>('en')
|
|
35
|
+
const locale = defineLocale<Language>()
|
|
36
|
+
const translation = locale.assertTranslation({
|
|
37
|
+
actions: { save: { en: 'Save', vi: 'Lưu' } },
|
|
38
|
+
messages: { saved: { en: 'Saved', vi: 'Đã lưu' } },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const { useTranslator, createNamespacedTranslatorHook } =
|
|
42
|
+
locale.createTranslatorHook({
|
|
43
|
+
translation,
|
|
44
|
+
usePreferredLanguage: () => useContext(LanguageContext),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const useActions = createNamespacedTranslatorHook('actions')
|
|
48
|
+
|
|
49
|
+
function SaveButton() {
|
|
50
|
+
const { t, d } = useActions()
|
|
51
|
+
return <button>{t(d.save)}</button>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function App() {
|
|
55
|
+
return (
|
|
56
|
+
<LanguageContext.Provider value="vi">
|
|
57
|
+
<SaveButton />
|
|
58
|
+
</LanguageContext.Provider>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Inside a component, `useTranslator()` returns `{ t, d, language }`. Choose the shape of `d` with:
|
|
64
|
+
|
|
65
|
+
| Call | Dictionary returned |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `useTranslator()` | Entire dictionary |
|
|
68
|
+
| `useTranslator('actions')` | The actions namespace directly |
|
|
69
|
+
| `useTranslator(['actions', 'messages'])` | Object containing both namespaces |
|
|
70
|
+
| `useTranslator('actions', 'messages')` | Object containing both namespaces |
|
|
71
|
+
| `useTranslator(dict => dict.actions)` | Your selector's result |
|
|
72
|
+
| `createNamespacedTranslatorHook('actions')` | A reusable hook returning that namespace |
|
|
73
|
+
|
|
74
|
+
Use a string for one namespace. The current implementation returns the namespace directly for a one-item array, even though its overload declares a keyed object. Selecting namespaces shapes the returned dictionary; it does not dynamically load translation files.
|
|
75
|
+
|
|
76
|
+
The exported types `LocalizedString<Languages>` and `TranslationNamespace<Languages>` describe one translated value and a recursively nested dictionary, respectively.
|
|
77
|
+
|
|
78
|
+
## Browser events
|
|
79
|
+
|
|
80
|
+
Use `createEventMethod` when mounted components need to respond to an action elsewhere, especially when the caller needs an answer. A callback prop is simpler for a direct parent-child relationship. Native event dispatch does not itself collect promises returned by listeners; this utility waits for its listeners and returns their results.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { createEventMethod } from '@khanglvm/react'
|
|
84
|
+
|
|
85
|
+
type EditorEvents = {
|
|
86
|
+
'editor:can-close': (data: { documentId: string }) => boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { useEventListener, emitEvent } = createEventMethod<EditorEvents>()
|
|
90
|
+
|
|
91
|
+
function EditorGuard() {
|
|
92
|
+
useEventListener('editor:can-close', ({ documentId }) => {
|
|
93
|
+
return window.confirm(`Close document ${documentId}?`)
|
|
94
|
+
})
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function CloseButton() {
|
|
99
|
+
async function handleClose() {
|
|
100
|
+
const answers = await emitEvent('editor:can-close', { documentId: 'draft-1' })
|
|
101
|
+
if (answers.length > 0 && answers.every(answer => answer === true)) {
|
|
102
|
+
console.log('The mounted listeners agreed to close')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return <button onClick={handleClose}>Check before closing</button>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default function App() {
|
|
109
|
+
return <><EditorGuard /><CloseButton /></>
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`useEventListener(name, handler)` registers after mount and removes the listener on unmount. A data-taking handler receives an optional `AbortSignal` as its second argument; unmount aborts the controller for the latest emission. The caller must pass that signal to any operation it wants to cancel. Earlier overlapping emissions are not all cancelled.
|
|
114
|
+
|
|
115
|
+
`emitEvent(name, data)` calls listeners concurrently and waits with `Promise.allSettled`. Function-shaped event definitions expose an array of results. A failed listener is logged and contributes `undefined`, even when the declared result type excludes it; validate answers before acting. No listeners means no answers.
|
|
116
|
+
|
|
117
|
+
For notifications, define an object payload:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
type Notifications = { 'profile:saved': { id: string } }
|
|
121
|
+
const notifications = createEventMethod<Notifications>()
|
|
122
|
+
// Inside a mounted component:
|
|
123
|
+
// notifications.useEventListener('profile:saved', ({ id }) => console.log(id))
|
|
124
|
+
// Inside a browser event handler:
|
|
125
|
+
// await notifications.emitEvent('profile:saved', { id: '123' })
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A `() => Result` event can be emitted without data. Avoid relying on the declared first-argument signal for a no-data listener: the current implementation passes data first and signal second in all cases.
|
|
129
|
+
|
|
130
|
+
All factories share a browser-global registry keyed by event name, so separate factory calls do not isolate identical names. Prefix names by feature. Emitting requires `window`; this is not a server event bus, a replay log, or a delivery guarantee for components that have not mounted.
|
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@khanglvm/react",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "React utilities for shared state, typed translations, browser events, and component helpers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"registry": "https://registry.npmjs.org/"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./createContextState": {
|
|
21
|
+
"types": "./dist/libs/createContextState.d.ts",
|
|
22
|
+
"import": "./dist/libs/createContextState.js",
|
|
23
|
+
"default": "./dist/libs/createContextState.js"
|
|
24
|
+
},
|
|
25
|
+
"./createStateManager": {
|
|
26
|
+
"types": "./dist/libs/createStateManager.d.ts",
|
|
27
|
+
"import": "./dist/libs/createStateManager.js",
|
|
28
|
+
"default": "./dist/libs/createStateManager.js"
|
|
29
|
+
},
|
|
30
|
+
"./createEventMethod": {
|
|
31
|
+
"types": "./dist/libs/createEventMethod.d.ts",
|
|
32
|
+
"import": "./dist/libs/createEventMethod.js",
|
|
33
|
+
"default": "./dist/libs/createEventMethod.js"
|
|
34
|
+
},
|
|
35
|
+
"./createTranslator": {
|
|
36
|
+
"types": "./dist/libs/createTranslator.d.ts",
|
|
37
|
+
"import": "./dist/libs/createTranslator.js",
|
|
38
|
+
"default": "./dist/libs/createTranslator.js"
|
|
39
|
+
},
|
|
40
|
+
"./helpers": {
|
|
41
|
+
"types": "./dist/helpers/index.d.ts",
|
|
42
|
+
"import": "./dist/helpers/index.js",
|
|
43
|
+
"default": "./dist/helpers/index.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"README.md",
|
|
49
|
+
"docs",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc && vite build",
|
|
54
|
+
"dev": "vite build --watch",
|
|
55
|
+
"type-check": "tsc --noEmit",
|
|
56
|
+
"clean": "rm -rf dist",
|
|
57
|
+
"prepublishOnly": "npm run test && npm run clean && npm run build && npm run test:package",
|
|
58
|
+
"test": "node --import tsx --test tests/*.test.tsx",
|
|
59
|
+
"test:package": "tsc --noEmit --strict --module NodeNext --moduleResolution NodeNext --jsx react-jsx --target ES2020 tests/package-smoke.tsx && node --import tsx tests/package-smoke.tsx"
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"react",
|
|
63
|
+
"hooks",
|
|
64
|
+
"utilities",
|
|
65
|
+
"typescript",
|
|
66
|
+
"state-management",
|
|
67
|
+
"i18n"
|
|
68
|
+
],
|
|
69
|
+
"author": "Khang Le",
|
|
70
|
+
"license": "MIT",
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@types/react": "^18.2.0",
|
|
76
|
+
"@types/react-dom": "^18.3.7",
|
|
77
|
+
"jsdom": "^26.1.0",
|
|
78
|
+
"react": "^18.2.0",
|
|
79
|
+
"react-dom": "^18.3.1",
|
|
80
|
+
"tsx": "^4.23.13",
|
|
81
|
+
"typescript": "^5.0.0",
|
|
82
|
+
"vite": "^5.0.0",
|
|
83
|
+
"vite-plugin-dts": "^3.0.0"
|
|
84
|
+
},
|
|
85
|
+
"repository": {
|
|
86
|
+
"type": "git",
|
|
87
|
+
"url": "git+https://github.com/khanglvm/react.git"
|
|
88
|
+
},
|
|
89
|
+
"homepage": "https://github.com/khanglvm/react#readme",
|
|
90
|
+
"bugs": {
|
|
91
|
+
"url": "https://github.com/khanglvm/react/issues"
|
|
92
|
+
},
|
|
93
|
+
"dependencies": {
|
|
94
|
+
"immer": "^10.1.1"
|
|
95
|
+
}
|
|
96
|
+
}
|