@rific/core 0.1.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 +147 -0
- package/dist/index.d.mts +74 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +122 -0
- package/dist/index.mjs +92 -0
- package/package.json +89 -0
- package/src/OptionalModule.ts +19 -0
- package/src/createModuleConfig.tsx +42 -0
- package/src/createSettingsContext.tsx +75 -0
- package/src/createSettingsSlice.ts +113 -0
- package/src/index.ts +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jay Deaton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @rific/core
|
|
2
|
+
|
|
3
|
+
Required foundation for `@rific/*` packages: a home for cross-cutting utilities every
|
|
4
|
+
`@rific`-consuming app can use directly.
|
|
5
|
+
|
|
6
|
+
- `createSettingsContext` — a generic Context/Provider/hook factory for a live, patchable settings
|
|
7
|
+
object whose persistence the consuming app owns.
|
|
8
|
+
- `createSettingsSlice` — the same idea for Redux: a generic reducer + action-creator factory, with
|
|
9
|
+
zero dependency on `@reduxjs/toolkit` (or any Redux library at all).
|
|
10
|
+
- `createModuleConfig` — a generic module-level config singleton, for injecting an optional peer
|
|
11
|
+
module (`react-native-paper`, `expo-camera`, etc.) without a hard dependency on it.
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
`feedback-press` (sound + haptics), `scroll-view`, and `auto-paper`'s `ThemeProvider` each hand-roll
|
|
16
|
+
the same ~30-line shape independently: a Context holding `{settings, set}`, a Provider taking
|
|
17
|
+
`initialValue`/`onChange` so the app decides where (or whether) the setting is persisted, and an
|
|
18
|
+
inert no-Provider-mounted default so nothing crashes if it's used without one. Each of those same
|
|
19
|
+
three packages *also* independently hand-rolls an equivalent Redux slice — the exact same
|
|
20
|
+
`createAction`/`.match()`/if-chain reducer, copy-pasted verbatim across all four files that need it
|
|
21
|
+
(`hapticSlice.ts`, `soundSlice.ts`, `scrollViewSlice.ts`, `themeSlice.ts`). And `drawer`, `scanner`,
|
|
22
|
+
and `resizable-input` each hand-roll the same module-level `let config` / `configureX` / `getXConfig`
|
|
23
|
+
/ `XProvider` singleton for one-time optional-peer injection. `createSettingsContext`,
|
|
24
|
+
`createSettingsSlice`, and `createModuleConfig` are each one of those shapes, written once.
|
|
25
|
+
|
|
26
|
+
## `createSettingsContext` — Context-based settings
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createSettingsContext } from '@rific/core'
|
|
30
|
+
|
|
31
|
+
export type OrientationLockSettings = { locked: boolean }
|
|
32
|
+
|
|
33
|
+
export const { Provider: OrientationLockProvider, useSettings: useOrientationLockSettings } =
|
|
34
|
+
createSettingsContext<OrientationLockSettings>({ locked: false })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// App root
|
|
39
|
+
<OrientationLockProvider
|
|
40
|
+
initialValue={{ locked: persistedLockOrientation }}
|
|
41
|
+
onChange={(settings) => dispatch(gameActions.setLockOrientation(settings.locked))}
|
|
42
|
+
>
|
|
43
|
+
<App />
|
|
44
|
+
</OrientationLockProvider>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For the common case of a caller that only needs one field of the settings object as a plain
|
|
48
|
+
`{value, setValue}` pair (mirrors `useOrientationLock`/`useSoundSettings`/`useHapticSettings`'s own
|
|
49
|
+
shape), wrap the generated `useSettings` with `createSettingHook`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createSettingHook } from '@rific/core'
|
|
53
|
+
|
|
54
|
+
export const useOrientationLock = createSettingHook(useOrientationLockSettings, 'locked')
|
|
55
|
+
// const { value: locked, setValue: setLocked } = useOrientationLock()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A settings object with several fields a caller patches together at once (e.g. sound + haptics) is
|
|
59
|
+
better served calling the generated `useSettings()` directly and patching as needed, rather than one
|
|
60
|
+
`createSettingHook` per field.
|
|
61
|
+
|
|
62
|
+
## `createSettingsSlice` — Redux-based settings
|
|
63
|
+
|
|
64
|
+
Same settings shape, for apps that persist through Redux instead of (or alongside) a Context. No
|
|
65
|
+
dependency on `@reduxjs/toolkit`: works with RTK stores, vanilla Redux, or any reducer-based state
|
|
66
|
+
container.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { createSettingsSlice } from '@rific/core'
|
|
70
|
+
|
|
71
|
+
export type HapticSettings = { vibrate: boolean }
|
|
72
|
+
|
|
73
|
+
export const { actions: hapticActions, reducer: hapticReducer } = createSettingsSlice('haptic', {
|
|
74
|
+
initialState: { vibrate: true } as HapticSettings
|
|
75
|
+
})
|
|
76
|
+
// hapticActions.initialize({ vibrate: false }), hapticActions.setVibrate(false)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
By default every field of `initialState` gets its own `setX` action (`setVibrate`, `setEnabled`,
|
|
80
|
+
...) and `initialize` replaces the whole state wholesale — matching the fleet's most common shape.
|
|
81
|
+
Three optional knobs cover the rest:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
export const { actions: themeActions, reducer: themeReducer, createReducer: createThemeReducer, selectors } = createSettingsSlice('theme', {
|
|
85
|
+
initialState: { appearance: 'system', blur: true, color: '#6750a4', harmony: 'split-complementary' } as ThemeState,
|
|
86
|
+
initializeMode: 'merge', // initialize(patch) merges instead of replacing
|
|
87
|
+
selectors: ['appearance', 'color'] // adds selectAppearance(state)/selectColor(state)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// createReducer lets a consumer override the default initial state at construction time —
|
|
91
|
+
// independent of the runtime `initialize` action:
|
|
92
|
+
const reducer = createThemeReducer({ color: DEFAULT_APP_COLOR })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
To expose zero per-field setters (only `initialize`), pass `fieldSetters: []` — useful for a
|
|
96
|
+
settings object an app only ever replaces wholesale, never patches one field at a time.
|
|
97
|
+
|
|
98
|
+
## `createModuleConfig` — optional peer-module injection
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createModuleConfig } from '@rific/core'
|
|
102
|
+
import type { OptionalModule } from '@rific/core'
|
|
103
|
+
|
|
104
|
+
export type MyPackageConfig = {
|
|
105
|
+
paper?: OptionalModule<PaperModuleShape>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const { configure: configureMyPackage, getConfig: getMyPackageConfig, Provider: MyPackageProvider } =
|
|
109
|
+
createModuleConfig<MyPackageConfig>()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
// App root — either call configure directly, or mount the generated Provider:
|
|
114
|
+
<MyPackageProvider paper={RNPaper}>
|
|
115
|
+
<App />
|
|
116
|
+
</MyPackageProvider>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Plain module-level state, not React Context — this is one-time app setup ("does this app have
|
|
120
|
+
`react-native-paper`?"), not per-render reactive state, so calling `configure` again after
|
|
121
|
+
components have already rendered won't retroactively update them. Fine for startup config, not for
|
|
122
|
+
runtime toggling.
|
|
123
|
+
|
|
124
|
+
## `OptionalModule<T>`
|
|
125
|
+
|
|
126
|
+
Names the convention several packages already follow for the `paper`/`camera`/`autoPaper`-style
|
|
127
|
+
props above: mirror only the small slice of the injected module's shape you actually use as a local
|
|
128
|
+
type (never `typeof import('the-real-package')`, which still forces the type-checker to resolve the
|
|
129
|
+
real module), accept it as an explicit prop rather than auto-detecting it (Metro doesn't rewrite a
|
|
130
|
+
`require()`-in-`try/catch` call into its module graph inside an ESM build, so auto-detection breaks
|
|
131
|
+
silently), and degrade gracefully when it's omitted. `OptionalModule<T>` is just `T | undefined` —
|
|
132
|
+
its only value is giving that convention one name to point back to instead of re-deriving the
|
|
133
|
+
reasoning fresh in every package's own doc comment.
|
|
134
|
+
|
|
135
|
+
## Install
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install @rific/core
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Peer Dependencies
|
|
142
|
+
|
|
143
|
+
- `react` (>=19.0.0)
|
|
144
|
+
- `react-native` (>=0.76.0) — not imported by anything in this package yet (every export here is
|
|
145
|
+
plain `react`), but declared up front to match every other `@rific`/`@tastic` foundation package,
|
|
146
|
+
since this is meant to grow into a home for other cross-cutting utilities the same way
|
|
147
|
+
`@tastic/core` did.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type ModuleConfigProviderProps<T> = T & {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
};
|
|
6
|
+
type ModuleConfigResult<T extends object> = {
|
|
7
|
+
configure: (next: Partial<T>) => void;
|
|
8
|
+
getConfig: () => T;
|
|
9
|
+
Provider: (props: ModuleConfigProviderProps<T>) => React.JSX.Element;
|
|
10
|
+
};
|
|
11
|
+
declare function createModuleConfig<T extends object>(defaults?: T): ModuleConfigResult<T>;
|
|
12
|
+
|
|
13
|
+
type SettingsContextValue<T> = {
|
|
14
|
+
settings: T;
|
|
15
|
+
set: (patch: Partial<T>) => void;
|
|
16
|
+
};
|
|
17
|
+
type SettingsProviderProps<T> = {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
initialValue?: Partial<T>;
|
|
20
|
+
onChange?: (settings: T) => void;
|
|
21
|
+
};
|
|
22
|
+
type SettingsContextResult<T> = {
|
|
23
|
+
Context: React.Context<SettingsContextValue<T>>;
|
|
24
|
+
Provider: (props: SettingsProviderProps<T>) => React.JSX.Element;
|
|
25
|
+
useSettings: () => SettingsContextValue<T>;
|
|
26
|
+
};
|
|
27
|
+
declare function createSettingsContext<T extends object>(defaults: T): SettingsContextResult<T>;
|
|
28
|
+
declare function createSettingHook<T extends object, K extends keyof T>(useSettings: () => SettingsContextValue<T>, key: K): () => {
|
|
29
|
+
value: T[K];
|
|
30
|
+
setValue: (value: T[K]) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type SettingsAction<P> = {
|
|
34
|
+
payload: P;
|
|
35
|
+
type: string;
|
|
36
|
+
};
|
|
37
|
+
type ActionCreator<P> = {
|
|
38
|
+
(payload: P): SettingsAction<P>;
|
|
39
|
+
type: string;
|
|
40
|
+
match: (action: {
|
|
41
|
+
type: string;
|
|
42
|
+
}) => action is SettingsAction<P>;
|
|
43
|
+
};
|
|
44
|
+
type Capitalized<K extends PropertyKey> = K extends string ? Capitalize<K> : never;
|
|
45
|
+
type FieldSetterActions<T, Fields extends keyof T> = {
|
|
46
|
+
[K in Fields as `set${Capitalized<K>}`]: ActionCreator<T[K]>;
|
|
47
|
+
};
|
|
48
|
+
type FieldSelectors<T, Fields extends keyof T> = {
|
|
49
|
+
[K in Fields as `select${Capitalized<K>}`]: (state: T) => T[K];
|
|
50
|
+
};
|
|
51
|
+
type SettingsReducer<T> = (state: T | undefined, action: {
|
|
52
|
+
type: string;
|
|
53
|
+
}) => T;
|
|
54
|
+
type InitializeMode = 'replace' | 'merge';
|
|
55
|
+
type InitializePayload<T, Mode extends InitializeMode> = Mode extends 'merge' ? Partial<T> : T;
|
|
56
|
+
type CreateSettingsSliceOptions<T extends object, Mode extends InitializeMode, SetterFields extends keyof T, SelectorFields extends keyof T> = {
|
|
57
|
+
initialState: T;
|
|
58
|
+
initializeMode?: Mode;
|
|
59
|
+
fieldSetters?: SetterFields[];
|
|
60
|
+
selectors?: SelectorFields[];
|
|
61
|
+
};
|
|
62
|
+
type SettingsSliceResult<T extends object, Mode extends InitializeMode, SetterFields extends keyof T, SelectorFields extends keyof T> = {
|
|
63
|
+
actions: {
|
|
64
|
+
initialize: ActionCreator<InitializePayload<T, Mode>>;
|
|
65
|
+
} & FieldSetterActions<T, SetterFields>;
|
|
66
|
+
reducer: SettingsReducer<T>;
|
|
67
|
+
createReducer: (overrideInitialState?: Partial<T>) => SettingsReducer<T>;
|
|
68
|
+
selectors: FieldSelectors<T, SelectorFields>;
|
|
69
|
+
};
|
|
70
|
+
declare function createSettingsSlice<T extends object, Mode extends InitializeMode = 'replace', SetterFields extends keyof T = keyof T, SelectorFields extends keyof T = never>(namespace: string, options: CreateSettingsSliceOptions<T, Mode, SetterFields, SelectorFields>): SettingsSliceResult<T, Mode, SetterFields, SelectorFields>;
|
|
71
|
+
|
|
72
|
+
type OptionalModule<T> = T | undefined;
|
|
73
|
+
|
|
74
|
+
export { type CreateSettingsSliceOptions, type InitializeMode, type ModuleConfigProviderProps, type ModuleConfigResult, type OptionalModule, type SettingsAction, type SettingsContextResult, type SettingsContextValue, type SettingsProviderProps, type SettingsReducer, type SettingsSliceResult, createModuleConfig, createSettingHook, createSettingsContext, createSettingsSlice };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type ModuleConfigProviderProps<T> = T & {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
};
|
|
6
|
+
type ModuleConfigResult<T extends object> = {
|
|
7
|
+
configure: (next: Partial<T>) => void;
|
|
8
|
+
getConfig: () => T;
|
|
9
|
+
Provider: (props: ModuleConfigProviderProps<T>) => React.JSX.Element;
|
|
10
|
+
};
|
|
11
|
+
declare function createModuleConfig<T extends object>(defaults?: T): ModuleConfigResult<T>;
|
|
12
|
+
|
|
13
|
+
type SettingsContextValue<T> = {
|
|
14
|
+
settings: T;
|
|
15
|
+
set: (patch: Partial<T>) => void;
|
|
16
|
+
};
|
|
17
|
+
type SettingsProviderProps<T> = {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
initialValue?: Partial<T>;
|
|
20
|
+
onChange?: (settings: T) => void;
|
|
21
|
+
};
|
|
22
|
+
type SettingsContextResult<T> = {
|
|
23
|
+
Context: React.Context<SettingsContextValue<T>>;
|
|
24
|
+
Provider: (props: SettingsProviderProps<T>) => React.JSX.Element;
|
|
25
|
+
useSettings: () => SettingsContextValue<T>;
|
|
26
|
+
};
|
|
27
|
+
declare function createSettingsContext<T extends object>(defaults: T): SettingsContextResult<T>;
|
|
28
|
+
declare function createSettingHook<T extends object, K extends keyof T>(useSettings: () => SettingsContextValue<T>, key: K): () => {
|
|
29
|
+
value: T[K];
|
|
30
|
+
setValue: (value: T[K]) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type SettingsAction<P> = {
|
|
34
|
+
payload: P;
|
|
35
|
+
type: string;
|
|
36
|
+
};
|
|
37
|
+
type ActionCreator<P> = {
|
|
38
|
+
(payload: P): SettingsAction<P>;
|
|
39
|
+
type: string;
|
|
40
|
+
match: (action: {
|
|
41
|
+
type: string;
|
|
42
|
+
}) => action is SettingsAction<P>;
|
|
43
|
+
};
|
|
44
|
+
type Capitalized<K extends PropertyKey> = K extends string ? Capitalize<K> : never;
|
|
45
|
+
type FieldSetterActions<T, Fields extends keyof T> = {
|
|
46
|
+
[K in Fields as `set${Capitalized<K>}`]: ActionCreator<T[K]>;
|
|
47
|
+
};
|
|
48
|
+
type FieldSelectors<T, Fields extends keyof T> = {
|
|
49
|
+
[K in Fields as `select${Capitalized<K>}`]: (state: T) => T[K];
|
|
50
|
+
};
|
|
51
|
+
type SettingsReducer<T> = (state: T | undefined, action: {
|
|
52
|
+
type: string;
|
|
53
|
+
}) => T;
|
|
54
|
+
type InitializeMode = 'replace' | 'merge';
|
|
55
|
+
type InitializePayload<T, Mode extends InitializeMode> = Mode extends 'merge' ? Partial<T> : T;
|
|
56
|
+
type CreateSettingsSliceOptions<T extends object, Mode extends InitializeMode, SetterFields extends keyof T, SelectorFields extends keyof T> = {
|
|
57
|
+
initialState: T;
|
|
58
|
+
initializeMode?: Mode;
|
|
59
|
+
fieldSetters?: SetterFields[];
|
|
60
|
+
selectors?: SelectorFields[];
|
|
61
|
+
};
|
|
62
|
+
type SettingsSliceResult<T extends object, Mode extends InitializeMode, SetterFields extends keyof T, SelectorFields extends keyof T> = {
|
|
63
|
+
actions: {
|
|
64
|
+
initialize: ActionCreator<InitializePayload<T, Mode>>;
|
|
65
|
+
} & FieldSetterActions<T, SetterFields>;
|
|
66
|
+
reducer: SettingsReducer<T>;
|
|
67
|
+
createReducer: (overrideInitialState?: Partial<T>) => SettingsReducer<T>;
|
|
68
|
+
selectors: FieldSelectors<T, SelectorFields>;
|
|
69
|
+
};
|
|
70
|
+
declare function createSettingsSlice<T extends object, Mode extends InitializeMode = 'replace', SetterFields extends keyof T = keyof T, SelectorFields extends keyof T = never>(namespace: string, options: CreateSettingsSliceOptions<T, Mode, SetterFields, SelectorFields>): SettingsSliceResult<T, Mode, SetterFields, SelectorFields>;
|
|
71
|
+
|
|
72
|
+
type OptionalModule<T> = T | undefined;
|
|
73
|
+
|
|
74
|
+
export { type CreateSettingsSliceOptions, type InitializeMode, type ModuleConfigProviderProps, type ModuleConfigResult, type OptionalModule, type SettingsAction, type SettingsContextResult, type SettingsContextValue, type SettingsProviderProps, type SettingsReducer, type SettingsSliceResult, createModuleConfig, createSettingHook, createSettingsContext, createSettingsSlice };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createModuleConfig: () => createModuleConfig,
|
|
24
|
+
createSettingHook: () => createSettingHook,
|
|
25
|
+
createSettingsContext: () => createSettingsContext,
|
|
26
|
+
createSettingsSlice: () => createSettingsSlice
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/createModuleConfig.tsx
|
|
31
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
32
|
+
function createModuleConfig(defaults = {}) {
|
|
33
|
+
let config = defaults;
|
|
34
|
+
const configure = (next) => {
|
|
35
|
+
config = { ...config, ...next };
|
|
36
|
+
};
|
|
37
|
+
const getConfig = () => config;
|
|
38
|
+
function Provider({ children, ...rest }) {
|
|
39
|
+
configure(rest);
|
|
40
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children });
|
|
41
|
+
}
|
|
42
|
+
return { configure, getConfig, Provider };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/createSettingsContext.tsx
|
|
46
|
+
var import_react = require("react");
|
|
47
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
48
|
+
function createSettingsContext(defaults) {
|
|
49
|
+
const Context = (0, import_react.createContext)({ settings: defaults, set: () => {
|
|
50
|
+
} });
|
|
51
|
+
function Provider({ children, initialValue, onChange }) {
|
|
52
|
+
const [settings, setSettings] = (0, import_react.useState)(() => ({ ...defaults, ...initialValue }));
|
|
53
|
+
const onChangeRef = (0, import_react.useRef)(onChange);
|
|
54
|
+
onChangeRef.current = onChange;
|
|
55
|
+
const set = (0, import_react.useCallback)((patch) => {
|
|
56
|
+
setSettings((prev) => {
|
|
57
|
+
const next = { ...prev, ...patch };
|
|
58
|
+
onChangeRef.current?.(next);
|
|
59
|
+
return next;
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Context.Provider, { value: { settings, set }, children });
|
|
63
|
+
}
|
|
64
|
+
function useSettings() {
|
|
65
|
+
return (0, import_react.useContext)(Context);
|
|
66
|
+
}
|
|
67
|
+
return { Context, Provider, useSettings };
|
|
68
|
+
}
|
|
69
|
+
function createSettingHook(useSettings, key) {
|
|
70
|
+
return function useSetting() {
|
|
71
|
+
const { settings, set } = useSettings();
|
|
72
|
+
const setValue = (0, import_react.useCallback)((value) => set({ [key]: value }), [set]);
|
|
73
|
+
return { value: settings[key], setValue };
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/createSettingsSlice.ts
|
|
78
|
+
function capitalize(key) {
|
|
79
|
+
const s = String(key);
|
|
80
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
81
|
+
}
|
|
82
|
+
function createActionCreator(type) {
|
|
83
|
+
const actionCreator = ((payload) => ({ payload, type }));
|
|
84
|
+
actionCreator.type = type;
|
|
85
|
+
actionCreator.match = (action) => action.type === type;
|
|
86
|
+
return actionCreator;
|
|
87
|
+
}
|
|
88
|
+
function createSettingsSlice(namespace, options) {
|
|
89
|
+
const { initialState, selectors = [] } = options;
|
|
90
|
+
const initializeMode = options.initializeMode ?? "replace";
|
|
91
|
+
const fieldSetters = options.fieldSetters ?? Object.keys(initialState);
|
|
92
|
+
const initialize = createActionCreator(`${namespace}/initialize`);
|
|
93
|
+
const setterEntries = fieldSetters.map((field) => {
|
|
94
|
+
const actionName = `set${capitalize(field)}`;
|
|
95
|
+
const creator = createActionCreator(`${namespace}/${actionName}`);
|
|
96
|
+
return { actionName, field, creator };
|
|
97
|
+
});
|
|
98
|
+
const actions = {
|
|
99
|
+
initialize,
|
|
100
|
+
...Object.fromEntries(setterEntries.map(({ actionName, creator }) => [actionName, creator]))
|
|
101
|
+
};
|
|
102
|
+
const reduce = (state, action) => {
|
|
103
|
+
if (initialize.match(action)) return initializeMode === "merge" ? { ...state, ...action.payload } : action.payload;
|
|
104
|
+
for (const { field, creator } of setterEntries) {
|
|
105
|
+
if (creator.match(action)) return { ...state, [field]: action.payload };
|
|
106
|
+
}
|
|
107
|
+
return state;
|
|
108
|
+
};
|
|
109
|
+
function createReducer(overrideInitialState) {
|
|
110
|
+
const initial = { ...initialState, ...overrideInitialState };
|
|
111
|
+
return (state = initial, action) => reduce(state, action);
|
|
112
|
+
}
|
|
113
|
+
const selectorsObj = Object.fromEntries(selectors.map((field) => [`select${capitalize(field)}`, (state) => state[field]]));
|
|
114
|
+
return { actions, reducer: createReducer(), createReducer, selectors: selectorsObj };
|
|
115
|
+
}
|
|
116
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
117
|
+
0 && (module.exports = {
|
|
118
|
+
createModuleConfig,
|
|
119
|
+
createSettingHook,
|
|
120
|
+
createSettingsContext,
|
|
121
|
+
createSettingsSlice
|
|
122
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/createModuleConfig.tsx
|
|
2
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
3
|
+
function createModuleConfig(defaults = {}) {
|
|
4
|
+
let config = defaults;
|
|
5
|
+
const configure = (next) => {
|
|
6
|
+
config = { ...config, ...next };
|
|
7
|
+
};
|
|
8
|
+
const getConfig = () => config;
|
|
9
|
+
function Provider({ children, ...rest }) {
|
|
10
|
+
configure(rest);
|
|
11
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
12
|
+
}
|
|
13
|
+
return { configure, getConfig, Provider };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/createSettingsContext.tsx
|
|
17
|
+
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
18
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
19
|
+
function createSettingsContext(defaults) {
|
|
20
|
+
const Context = createContext({ settings: defaults, set: () => {
|
|
21
|
+
} });
|
|
22
|
+
function Provider({ children, initialValue, onChange }) {
|
|
23
|
+
const [settings, setSettings] = useState(() => ({ ...defaults, ...initialValue }));
|
|
24
|
+
const onChangeRef = useRef(onChange);
|
|
25
|
+
onChangeRef.current = onChange;
|
|
26
|
+
const set = useCallback((patch) => {
|
|
27
|
+
setSettings((prev) => {
|
|
28
|
+
const next = { ...prev, ...patch };
|
|
29
|
+
onChangeRef.current?.(next);
|
|
30
|
+
return next;
|
|
31
|
+
});
|
|
32
|
+
}, []);
|
|
33
|
+
return /* @__PURE__ */ jsx2(Context.Provider, { value: { settings, set }, children });
|
|
34
|
+
}
|
|
35
|
+
function useSettings() {
|
|
36
|
+
return useContext(Context);
|
|
37
|
+
}
|
|
38
|
+
return { Context, Provider, useSettings };
|
|
39
|
+
}
|
|
40
|
+
function createSettingHook(useSettings, key) {
|
|
41
|
+
return function useSetting() {
|
|
42
|
+
const { settings, set } = useSettings();
|
|
43
|
+
const setValue = useCallback((value) => set({ [key]: value }), [set]);
|
|
44
|
+
return { value: settings[key], setValue };
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/createSettingsSlice.ts
|
|
49
|
+
function capitalize(key) {
|
|
50
|
+
const s = String(key);
|
|
51
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
52
|
+
}
|
|
53
|
+
function createActionCreator(type) {
|
|
54
|
+
const actionCreator = ((payload) => ({ payload, type }));
|
|
55
|
+
actionCreator.type = type;
|
|
56
|
+
actionCreator.match = (action) => action.type === type;
|
|
57
|
+
return actionCreator;
|
|
58
|
+
}
|
|
59
|
+
function createSettingsSlice(namespace, options) {
|
|
60
|
+
const { initialState, selectors = [] } = options;
|
|
61
|
+
const initializeMode = options.initializeMode ?? "replace";
|
|
62
|
+
const fieldSetters = options.fieldSetters ?? Object.keys(initialState);
|
|
63
|
+
const initialize = createActionCreator(`${namespace}/initialize`);
|
|
64
|
+
const setterEntries = fieldSetters.map((field) => {
|
|
65
|
+
const actionName = `set${capitalize(field)}`;
|
|
66
|
+
const creator = createActionCreator(`${namespace}/${actionName}`);
|
|
67
|
+
return { actionName, field, creator };
|
|
68
|
+
});
|
|
69
|
+
const actions = {
|
|
70
|
+
initialize,
|
|
71
|
+
...Object.fromEntries(setterEntries.map(({ actionName, creator }) => [actionName, creator]))
|
|
72
|
+
};
|
|
73
|
+
const reduce = (state, action) => {
|
|
74
|
+
if (initialize.match(action)) return initializeMode === "merge" ? { ...state, ...action.payload } : action.payload;
|
|
75
|
+
for (const { field, creator } of setterEntries) {
|
|
76
|
+
if (creator.match(action)) return { ...state, [field]: action.payload };
|
|
77
|
+
}
|
|
78
|
+
return state;
|
|
79
|
+
};
|
|
80
|
+
function createReducer(overrideInitialState) {
|
|
81
|
+
const initial = { ...initialState, ...overrideInitialState };
|
|
82
|
+
return (state = initial, action) => reduce(state, action);
|
|
83
|
+
}
|
|
84
|
+
const selectorsObj = Object.fromEntries(selectors.map((field) => [`select${capitalize(field)}`, (state) => state[field]]));
|
|
85
|
+
return { actions, reducer: createReducer(), createReducer, selectors: selectorsObj };
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
createModuleConfig,
|
|
89
|
+
createSettingHook,
|
|
90
|
+
createSettingsContext,
|
|
91
|
+
createSettingsSlice
|
|
92
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rific/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Required foundation for @rific/* packages: createSettingsContext + createSettingsSlice for a live, patchable settings object (Context and Redux, respectively), and createModuleConfig for one-time optional-peer-module injection",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react-native",
|
|
7
|
+
"react",
|
|
8
|
+
"settings",
|
|
9
|
+
"context",
|
|
10
|
+
"provider",
|
|
11
|
+
"state",
|
|
12
|
+
"redux"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/jayrdeaton/react-native-core#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/jayrdeaton/react-native-core/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/jayrdeaton/react-native-core.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Jay Deaton",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"type": "commonjs",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"react-native": "./src/index.ts",
|
|
29
|
+
"browser": "./src/index.ts",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.mjs",
|
|
32
|
+
"require": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"main": "dist/index.js",
|
|
36
|
+
"module": "dist/index.mjs",
|
|
37
|
+
"react-native": "src/index.ts",
|
|
38
|
+
"types": "dist/index.d.ts",
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"!src/__tests__",
|
|
43
|
+
"!src/__mocks__"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"build:watch": "tsup --watch",
|
|
48
|
+
"fix": "eslint --fix",
|
|
49
|
+
"lint": "eslint",
|
|
50
|
+
"prepublishOnly": "npm run build",
|
|
51
|
+
"release": "git push --follow-tags",
|
|
52
|
+
"release:major": "npm version major && npm run release",
|
|
53
|
+
"release:minor": "npm version minor && npm run release",
|
|
54
|
+
"release:patch": "npm version patch && npm run release",
|
|
55
|
+
"test": "jest",
|
|
56
|
+
"test:watch": "jest --watchAll",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"verify": "npm run lint && npm test && npm run typecheck && npm run build",
|
|
59
|
+
"preversion": "npm run verify"
|
|
60
|
+
},
|
|
61
|
+
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@infinitetoken/eslint-config": "^0.2.0",
|
|
64
|
+
"@infinitetoken/jest-config": "^0.3.0",
|
|
65
|
+
"@infinitetoken/tsconfig": "^0.4.1",
|
|
66
|
+
"@testing-library/dom": "^10.4.1",
|
|
67
|
+
"@testing-library/react": "^16.3.2",
|
|
68
|
+
"@types/jest": "^30.0.0",
|
|
69
|
+
"@types/react": "^19.0.0",
|
|
70
|
+
"eslint": "^9.39.4",
|
|
71
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
72
|
+
"eslint-plugin-react-native": "^5.0.0",
|
|
73
|
+
"jest": "^30.4.2",
|
|
74
|
+
"jest-environment-jsdom": "^30.4.1",
|
|
75
|
+
"prettier": "^3.8.3",
|
|
76
|
+
"react": "^19.0.0",
|
|
77
|
+
"react-dom": "^19.0.0",
|
|
78
|
+
"react-native": "^0.85.3",
|
|
79
|
+
"tsup": "^8.0.0",
|
|
80
|
+
"typescript": "^6.0.3"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"react": ">=19.0.0",
|
|
84
|
+
"react-native": ">=0.76.0"
|
|
85
|
+
},
|
|
86
|
+
"publishConfig": {
|
|
87
|
+
"access": "public"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Names the convention feedback-press, drawer, scanner, and auto-paper each already follow (and
|
|
2
|
+
// each re-explain from scratch in their own doc comments) for injecting an optional peer module
|
|
3
|
+
// (react-native-paper, expo-camera, expo-blur, react-native-reanimated, etc.) without a hard
|
|
4
|
+
// dependency on it:
|
|
5
|
+
//
|
|
6
|
+
// 1. Auto-detection via a require()-in-try/catch call doesn't work reliably: Metro doesn't rewrite
|
|
7
|
+
// that call into its module graph inside an ESM (.mjs) build, so module-level auto-detection
|
|
8
|
+
// silently breaks the moment a consumer's bundler resolves the package's ESM entry point.
|
|
9
|
+
// 2. So instead, accept the module as an explicit injected prop (e.g. `paper?: OptionalModule<PaperModuleShape>`)
|
|
10
|
+
// and have the consuming app pass `import * as RNPaper from 'react-native-paper'` itself.
|
|
11
|
+
// 3. Mirror only the small slice of the module's shape you actually use as a local type — never
|
|
12
|
+
// `typeof import('the-real-package')`, which still forces the type-checker to resolve the real
|
|
13
|
+
// module and defeats the point of not hard-depending on it.
|
|
14
|
+
// 4. Degrade gracefully when the prop is omitted (a plain fallback UI, a no-op), never throw.
|
|
15
|
+
//
|
|
16
|
+
// This type is deliberately trivial (`T | undefined`) — its value is giving that convention one
|
|
17
|
+
// name every package's own doc comment can point back to, instead of re-deriving the same
|
|
18
|
+
// reasoning independently each time a new optional peer gets injected.
|
|
19
|
+
export type OptionalModule<T> = T | undefined
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
// Generalizes the module-level config-singleton shape duplicated identically across
|
|
4
|
+
// @rific/drawer's DrawerConfig.tsx, @rific/scanner's ScannerConfig.tsx, and
|
|
5
|
+
// @rific/resizable-input's ResizableInputConfig.tsx — each hand-rolls the same five pieces
|
|
6
|
+
// (a module-level `let config`, `configureX`/`getXConfig`, an `XProviderProps` type, and an
|
|
7
|
+
// `XProvider` that just calls `configureX` synchronously during render) to inject optional peer
|
|
8
|
+
// modules (react-native-paper, expo-camera, etc.) without a hard dependency on them. Plain
|
|
9
|
+
// module-level state rather than React Context, deliberately: this is one-time app setup ("does
|
|
10
|
+
// this app have react-native-paper?"), not per-render reactive state — see each real package's own
|
|
11
|
+
// comment on why a Provider-only API would be more ceremony than the problem needs. Not reactive:
|
|
12
|
+
// calling `configure` again after components have already rendered won't retroactively update
|
|
13
|
+
// them, which is fine for startup config, not for runtime toggling.
|
|
14
|
+
|
|
15
|
+
export type ModuleConfigProviderProps<T> = T & { children: ReactNode }
|
|
16
|
+
|
|
17
|
+
export type ModuleConfigResult<T extends object> = {
|
|
18
|
+
configure: (next: Partial<T>) => void
|
|
19
|
+
getConfig: () => T
|
|
20
|
+
// Thin wrapper around `configure` for consumers who'd rather mount a Provider than call the
|
|
21
|
+
// setup function directly. Calls `configure` synchronously during render (not in an effect), so
|
|
22
|
+
// the config is already set by the time any descendant component renders — effects run
|
|
23
|
+
// bottom-up after children have already rendered once, which would be one render too late here.
|
|
24
|
+
Provider: (props: ModuleConfigProviderProps<T>) => React.JSX.Element
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createModuleConfig<T extends object>(defaults: T = {} as T): ModuleConfigResult<T> {
|
|
28
|
+
let config: T = defaults
|
|
29
|
+
|
|
30
|
+
const configure = (next: Partial<T>) => {
|
|
31
|
+
config = { ...config, ...next }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const getConfig = (): T => config
|
|
35
|
+
|
|
36
|
+
function Provider({ children, ...rest }: ModuleConfigProviderProps<T>) {
|
|
37
|
+
configure(rest as Partial<T>)
|
|
38
|
+
return <>{children}</>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { configure, getConfig, Provider }
|
|
42
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export type SettingsContextValue<T> = {
|
|
4
|
+
settings: T
|
|
5
|
+
set: (patch: Partial<T>) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SettingsProviderProps<T> = {
|
|
9
|
+
children: ReactNode
|
|
10
|
+
// Rehydrates from the consuming app's own storage at mount time — this factory (like every
|
|
11
|
+
// hand-rolled settings Provider it replaces) deliberately does no persistence of its own. The app
|
|
12
|
+
// decides where, or whether, `settings` gets saved.
|
|
13
|
+
initialValue?: Partial<T>
|
|
14
|
+
// Fires with the full settings object on every `set` call — the app's hook into persisting it.
|
|
15
|
+
onChange?: (settings: T) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SettingsContextResult<T> = {
|
|
19
|
+
Context: React.Context<SettingsContextValue<T>>
|
|
20
|
+
Provider: (props: SettingsProviderProps<T>) => React.JSX.Element
|
|
21
|
+
useSettings: () => SettingsContextValue<T>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Builds one {Context, Provider, useSettings} triple for a single settings shape T. Matches the
|
|
25
|
+
// initialValue/onChange/patch-based contract already duplicated by hand, near-identically, across
|
|
26
|
+
// this fleet's own settings Providers (feedback-press's sound + haptics, scroll-view's own settings,
|
|
27
|
+
// auto-paper's ThemeProvider, @tastic/core's orientation-lock context) — every one of those is this
|
|
28
|
+
// same ~30-line shape typed out separately. `defaults` seeds both the Context's own inert
|
|
29
|
+
// no-Provider-mounted fallback (settings read as `defaults`, `set` silently drops — the same
|
|
30
|
+
// "nothing crashes, it just never resolves" degradation every one of those packages already
|
|
31
|
+
// documents for a missing Provider) and the Provider's real initial state, patched by `initialValue`.
|
|
32
|
+
export function createSettingsContext<T extends object>(defaults: T): SettingsContextResult<T> {
|
|
33
|
+
const Context = createContext<SettingsContextValue<T>>({ settings: defaults, set: () => {} })
|
|
34
|
+
|
|
35
|
+
function Provider({ children, initialValue, onChange }: SettingsProviderProps<T>) {
|
|
36
|
+
const [settings, setSettings] = useState<T>(() => ({ ...defaults, ...initialValue }))
|
|
37
|
+
|
|
38
|
+
// Read via a ref rather than closed over directly, so `set`'s identity never changes just
|
|
39
|
+
// because the caller passed a fresh `onChange` closure this render — a consumer that memoizes
|
|
40
|
+
// off `set`'s stability (or hands it deep into a tree) doesn't re-render every time the app's
|
|
41
|
+
// own onChange callback is redefined.
|
|
42
|
+
const onChangeRef = useRef(onChange)
|
|
43
|
+
onChangeRef.current = onChange
|
|
44
|
+
|
|
45
|
+
const set = useCallback((patch: Partial<T>) => {
|
|
46
|
+
setSettings((prev) => {
|
|
47
|
+
const next = { ...prev, ...patch }
|
|
48
|
+
onChangeRef.current?.(next)
|
|
49
|
+
return next
|
|
50
|
+
})
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
return <Context.Provider value={{ settings, set }}>{children}</Context.Provider>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useSettings(): SettingsContextValue<T> {
|
|
57
|
+
return useContext(Context)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { Context, Provider, useSettings }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Thin convenience for the common single-field case — the {value, setValue} shape this fleet's own
|
|
64
|
+
// useOrientationLock/useSoundSettings/useHapticSettings hooks each hand-roll individually, for a
|
|
65
|
+
// caller that only cares about one field of a larger settings object rather than the whole
|
|
66
|
+
// patch-based contract. Not every consumer needs this — one juggling several fields at once (e.g.
|
|
67
|
+
// feedback-press's own sound/haptic sub-settings) is still better served calling useSettings()
|
|
68
|
+
// directly and patching as needed.
|
|
69
|
+
export function createSettingHook<T extends object, K extends keyof T>(useSettings: () => SettingsContextValue<T>, key: K) {
|
|
70
|
+
return function useSetting(): { value: T[K]; setValue: (value: T[K]) => void } {
|
|
71
|
+
const { settings, set } = useSettings()
|
|
72
|
+
const setValue = useCallback((value: T[K]) => set({ [key]: value } as unknown as Partial<T>), [set])
|
|
73
|
+
return { value: settings[key], setValue }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Generalizes the Redux slice half of the same duplicated shape createSettingsContext already
|
|
2
|
+
// covers for the Context half. feedback-press's hapticSlice.ts/soundSlice.ts, scroll-view's
|
|
3
|
+
// scrollViewSlice.ts, and auto-paper's themeSlice.ts each hand-roll an identical createAction/
|
|
4
|
+
// .match()/if-chain reducer — the exact block below is copy-pasted verbatim across all four today.
|
|
5
|
+
// This factory reproduces every real behavior those four files need (including auto-paper's own
|
|
6
|
+
// wrinkles: a merging `initialize`, an overridable reducer factory, and per-field selectors) as
|
|
7
|
+
// plain config, not new logic.
|
|
8
|
+
|
|
9
|
+
export type SettingsAction<P> = { payload: P; type: string }
|
|
10
|
+
|
|
11
|
+
type ActionCreator<P> = {
|
|
12
|
+
(payload: P): SettingsAction<P>
|
|
13
|
+
type: string
|
|
14
|
+
match: (action: { type: string }) => action is SettingsAction<P>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Capitalized<K extends PropertyKey> = K extends string ? Capitalize<K> : never
|
|
18
|
+
|
|
19
|
+
type FieldSetterActions<T, Fields extends keyof T> = {
|
|
20
|
+
[K in Fields as `set${Capitalized<K>}`]: ActionCreator<T[K]>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type FieldSelectors<T, Fields extends keyof T> = {
|
|
24
|
+
[K in Fields as `select${Capitalized<K>}`]: (state: T) => T[K]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SettingsReducer<T> = (state: T | undefined, action: { type: string }) => T
|
|
28
|
+
|
|
29
|
+
// 'replace' (the default, and what haptic/sound/scrollView all do): `initialize`'s payload becomes
|
|
30
|
+
// the entire next state, verbatim — matches how every real call site already dispatches it (the
|
|
31
|
+
// app's own onChange handler always hands back a complete settings object, never a partial one),
|
|
32
|
+
// so `initialize` takes the full T. 'merge' (only auto-paper's theme needs this): the payload is
|
|
33
|
+
// spread over the existing state instead, so `initialize` takes a Partial<T> — a caller can seed
|
|
34
|
+
// just part of it.
|
|
35
|
+
export type InitializeMode = 'replace' | 'merge'
|
|
36
|
+
|
|
37
|
+
type InitializePayload<T, Mode extends InitializeMode> = Mode extends 'merge' ? Partial<T> : T
|
|
38
|
+
|
|
39
|
+
export type CreateSettingsSliceOptions<T extends object, Mode extends InitializeMode, SetterFields extends keyof T, SelectorFields extends keyof T> = {
|
|
40
|
+
initialState: T
|
|
41
|
+
initializeMode?: Mode
|
|
42
|
+
// Which fields get a dedicated setX action (e.g. `vibrate` -> `setVibrate`). Defaults to every key
|
|
43
|
+
// of initialState — matches haptic/sound/theme, which each expose one setter per field.
|
|
44
|
+
// scrollView needs this passed as `[]` explicitly: it has 6 fields but zero per-field setters,
|
|
45
|
+
// relying on `initialize` alone.
|
|
46
|
+
fieldSetters?: SetterFields[]
|
|
47
|
+
// Which fields get a generated selectX(state) => state[field]. Defaults to none — only auto-paper's
|
|
48
|
+
// theme slice has these today.
|
|
49
|
+
selectors?: SelectorFields[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SettingsSliceResult<T extends object, Mode extends InitializeMode, SetterFields extends keyof T, SelectorFields extends keyof T> = {
|
|
53
|
+
actions: { initialize: ActionCreator<InitializePayload<T, Mode>> } & FieldSetterActions<T, SetterFields>
|
|
54
|
+
reducer: SettingsReducer<T>
|
|
55
|
+
// The auto-paper wrinkle: a factory that lets a consumer override the default initial state at
|
|
56
|
+
// construction time (e.g. seeding a per-app default color), independent of the runtime `initialize`
|
|
57
|
+
// action. `reducer` above is just `createReducer()` with no override.
|
|
58
|
+
createReducer: (overrideInitialState?: Partial<T>) => SettingsReducer<T>
|
|
59
|
+
selectors: FieldSelectors<T, SelectorFields>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function capitalize<K extends PropertyKey>(key: K): Capitalized<K> {
|
|
63
|
+
const s = String(key)
|
|
64
|
+
return (s.charAt(0).toUpperCase() + s.slice(1)) as Capitalized<K>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createActionCreator<P>(type: string): ActionCreator<P> {
|
|
68
|
+
const actionCreator = ((payload: P) => ({ payload, type })) as ActionCreator<P>
|
|
69
|
+
actionCreator.type = type
|
|
70
|
+
actionCreator.match = (action: { type: string }): action is SettingsAction<P> => action.type === type
|
|
71
|
+
return actionCreator
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `namespace` prefixes every action type (e.g. 'haptic/initialize', 'theme/setAppearance') so
|
|
75
|
+
// multiple slices combined in one store never collide — matches the fleet's existing convention
|
|
76
|
+
// exactly (hapticSlice's own action types are literally 'haptic/initialize' etc. today).
|
|
77
|
+
export function createSettingsSlice<T extends object, Mode extends InitializeMode = 'replace', SetterFields extends keyof T = keyof T, SelectorFields extends keyof T = never>(namespace: string, options: CreateSettingsSliceOptions<T, Mode, SetterFields, SelectorFields>): SettingsSliceResult<T, Mode, SetterFields, SelectorFields> {
|
|
78
|
+
const { initialState, selectors = [] } = options
|
|
79
|
+
const initializeMode: InitializeMode = options.initializeMode ?? 'replace'
|
|
80
|
+
const fieldSetters = options.fieldSetters ?? (Object.keys(initialState) as SetterFields[])
|
|
81
|
+
|
|
82
|
+
// Internally untyped (any payload) — the precise conditional typing based on `Mode` lives only
|
|
83
|
+
// in the exported return type below; the runtime logic itself doesn't need to know Mode at the
|
|
84
|
+
// type level, only the `initializeMode` string value it already has.
|
|
85
|
+
const initialize = createActionCreator<Partial<T> | T>(`${namespace}/initialize`)
|
|
86
|
+
const setterEntries = fieldSetters.map((field) => {
|
|
87
|
+
const actionName = `set${capitalize(field)}`
|
|
88
|
+
const creator = createActionCreator<T[typeof field]>(`${namespace}/${actionName}`)
|
|
89
|
+
return { actionName, field, creator }
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const actions = {
|
|
93
|
+
initialize,
|
|
94
|
+
...Object.fromEntries(setterEntries.map(({ actionName, creator }) => [actionName, creator]))
|
|
95
|
+
} as unknown as SettingsSliceResult<T, Mode, SetterFields, SelectorFields>['actions']
|
|
96
|
+
|
|
97
|
+
const reduce = (state: T, action: { type: string }): T => {
|
|
98
|
+
if (initialize.match(action)) return initializeMode === 'merge' ? { ...state, ...(action.payload as Partial<T>) } : (action.payload as T)
|
|
99
|
+
for (const { field, creator } of setterEntries) {
|
|
100
|
+
if (creator.match(action)) return { ...state, [field]: action.payload }
|
|
101
|
+
}
|
|
102
|
+
return state
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createReducer(overrideInitialState?: Partial<T>): SettingsReducer<T> {
|
|
106
|
+
const initial = { ...initialState, ...overrideInitialState }
|
|
107
|
+
return (state: T = initial, action: { type: string }): T => reduce(state, action)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const selectorsObj = Object.fromEntries(selectors.map((field) => [`select${capitalize(field)}`, (state: T) => state[field]])) as unknown as SettingsSliceResult<T, Mode, SetterFields, SelectorFields>['selectors']
|
|
111
|
+
|
|
112
|
+
return { actions, reducer: createReducer(), createReducer, selectors: selectorsObj }
|
|
113
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { ModuleConfigProviderProps, ModuleConfigResult } from './createModuleConfig'
|
|
2
|
+
export { createModuleConfig } from './createModuleConfig'
|
|
3
|
+
export type { SettingsContextResult, SettingsContextValue, SettingsProviderProps } from './createSettingsContext'
|
|
4
|
+
export { createSettingHook, createSettingsContext } from './createSettingsContext'
|
|
5
|
+
export type { CreateSettingsSliceOptions, InitializeMode, SettingsAction, SettingsReducer, SettingsSliceResult } from './createSettingsSlice'
|
|
6
|
+
export { createSettingsSlice } from './createSettingsSlice'
|
|
7
|
+
export type { OptionalModule } from './OptionalModule'
|