@timeax/form-palette 0.0.30 → 0.0.32

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 CHANGED
@@ -706,6 +706,657 @@ function ConfigPage() {
706
706
  | `title` | `string` | UI title for the popover. |
707
707
  | `filtersSpec` | `ListerFilterSpec` | Specification for the filter UI. |
708
708
 
709
+ ## `useData` hook (Lister preset) — detailed guide
710
+
711
+ `useData` is the lightweight data + search + selection hook used by the **Lister** preset. It’s meant for “fetch list → search it (remote/local/hybrid) → optionally select items by stable IDs”.
712
+
713
+ You’ll typically use it inside lister-style UIs or any picker/list component that wants the same semantics as the Lister engine.
714
+
715
+ ---
716
+
717
+ ### What it manages
718
+
719
+ `useData` manages these concerns for you:
720
+
721
+ 1. **Fetching**
722
+
723
+ * Uses the `ListerProvider` context (`Ctx`) to call `ctx.apiFetchAny(...)`.
724
+ * Supports request building via inline definition inputs (`endpoint`, `method`, `selector`, `buildRequest`, `search`).
725
+
726
+ 2. **Search modes**
727
+
728
+ * **remote**: typing triggers a debounced fetch with search payload.
729
+ * **local**: fetch a “base dataset” once, then search/filter client-side.
730
+ * **hybrid**: fetch remote on typing *and* also supports local filtering rules.
731
+
732
+ 3. **Search targeting**
733
+
734
+ * Uses `searchTarget` to build a provider-compatible search payload via `buildSearchPayloadFromTarget(...)`.
735
+ * Payload can express:
736
+
737
+ * `subject` (search only a specific field)
738
+ * `searchAll` (broad search)
739
+ * `searchOnly` (restrict results to allowed IDs / “only”)
740
+
741
+ 4. **Filters**
742
+
743
+ * Tracks `filters` state and can auto-refetch when filters change (remote/hybrid).
744
+
745
+ 5. **Selection (optional)**
746
+
747
+ * Select **by stable key** (`id`, `value`, or a custom key resolver).
748
+ * Supports `single` or `multiple` selection.
749
+ * Returns **selected objects**, not just IDs.
750
+ * Keeps a cache so selection can survive list changes.
751
+
752
+ ---
753
+
754
+ ### Basic usage
755
+
756
+ ```tsx
757
+ const {
758
+ data,
759
+ visible,
760
+ loading,
761
+
762
+ query,
763
+ setQuery,
764
+
765
+ searchMode,
766
+ setSearchMode,
767
+
768
+ searchTarget,
769
+ setSearchTarget,
770
+
771
+ filters,
772
+ setFilters,
773
+
774
+ refresh,
775
+ } = useData({
776
+ endpoint: "/api/users",
777
+ method: "GET",
778
+ search: { default: "fullName" }, // default subject for subject-search
779
+ });
780
+ ```
781
+
782
+ **Key outputs**
783
+
784
+ * `data`: the latest fetched list
785
+ * `visible`: the list after applying local/hybrid filtering rules
786
+ * `loading` / `error`
787
+ * `query` + `setQuery(...)`
788
+ * `searchMode` + `setSearchMode(...)`
789
+ * `searchTarget` + `setSearchTarget(...)`
790
+ * `filters` + filter helpers
791
+ * `refresh()` and `fetch()` for manual control
792
+
793
+ ---
794
+
795
+ ### Fetching details
796
+
797
+ `useData` builds an **inline lister definition** (via `makeInlineDef`) from:
798
+
799
+ * `endpoint`
800
+ * `method`
801
+ * `selector` (how to extract array from response)
802
+ * `buildRequest` (how to shape params/body/headers)
803
+ * `search` (search spec defaults)
804
+
805
+ Then it calls:
806
+
807
+ ```ts
808
+ ctx.apiFetchAny(inlineDef, filters, {
809
+ query,
810
+ search: buildSearchPayloadFromTarget(searchTarget),
811
+ });
812
+ ```
813
+
814
+ **Last-request-wins**
815
+
816
+ * The hook uses an internal request counter so only the latest request updates state.
817
+
818
+ ---
819
+
820
+ ### Search mode semantics
821
+
822
+ #### `remote`
823
+
824
+ * Every query change triggers a **debounced fetch**.
825
+ * The fetch includes a search payload derived from `searchTarget`.
826
+ * Best when the dataset is large or server-side search is needed.
827
+
828
+ #### `local`
829
+
830
+ * When you switch to `local`, the hook performs a one-time fetch for a “base list”:
831
+
832
+ * `query: ""`
833
+ * `search: undefined`
834
+ * After that, it searches locally over `data` to produce `visible`.
835
+ * No more fetches on query changes.
836
+
837
+ This is ideal when:
838
+
839
+ * you want “download once, search locally”
840
+ * your endpoint can return an appropriate base dataset
841
+
842
+ #### `hybrid`
843
+
844
+ * Behaves like remote (debounced fetch on query changes),
845
+ * but also allows local filtering semantics for `visible`
846
+ (useful if the UI wants to apply `searchOnly` / field search locally too).
847
+
848
+ ---
849
+
850
+ ### `searchTarget` and what it does
851
+
852
+ `searchTarget` is a structured way to describe *how* you want to search.
853
+ `buildSearchPayloadFromTarget(searchTarget)` converts it into a normalized payload the provider understands.
854
+
855
+ Common patterns:
856
+
857
+ * **Subject search** (search a single column/property):
858
+
859
+ * payload contains `subject: "fullName"`
860
+ * **Only restriction** (restrict to a subset of IDs):
861
+
862
+ * payload contains `searchOnly: [...]`
863
+
864
+ In `local`/`hybrid`, `visible` uses that payload to decide:
865
+
866
+ * whether to filter by `searchOnly`
867
+ * whether to search a specific subject field
868
+ * otherwise falls back to a broad string match
869
+
870
+ ---
871
+
872
+ ### Filters
873
+
874
+ You can pass initial filters:
875
+
876
+ ```tsx
877
+ useData({
878
+ endpoint: "/api/users",
879
+ filters: { status: "active" },
880
+ });
881
+ ```
882
+
883
+ And update them via:
884
+
885
+ * `setFilters(next)`
886
+ * `patchFilters(partial)`
887
+ * `clearFilters()`
888
+
889
+ **Auto-fetch on filter change**
890
+
891
+ * Controlled by `autoFetchOnFilterChange` (default: true)
892
+ * Only triggers fetches in `remote`/`hybrid` modes
893
+ * `local` mode does not re-fetch on filter change (by design)
894
+
895
+ ---
896
+
897
+ ### Selection support (optional)
898
+
899
+ Enable selection like:
900
+
901
+ ```tsx
902
+ const d = useData({
903
+ endpoint: "/api/users",
904
+ selection: {
905
+ mode: "multiple",
906
+ key: "id", // or (item) => item.user_id
907
+ prune: "never", // recommended default
908
+ },
909
+ });
910
+ ```
911
+
912
+ **What you get**
913
+
914
+ * `selectionMode`: `"none" | "single" | "multiple"`
915
+ * `selectedIds`: `id | id[] | null`
916
+ * `selected`: the resolved object(s) from the latest list/cache
917
+ * selection helpers:
918
+
919
+ * `select(id|ids)`
920
+ * `deselect(id|ids)`
921
+ * `toggle(id)`
922
+ * `clearSelection()`
923
+ * `isSelected(id)`
924
+ * `getSelection()` (returns object(s), not ids)
925
+
926
+ **Key resolution**
927
+
928
+ * If you don’t provide `selection.key`, it defaults to:
929
+
930
+ * `item.id ?? item.value`
931
+
932
+ **Cache behavior**
933
+
934
+ * The hook maintains an internal `Map<id, item>` cache.
935
+ * This allows `selected` to still return objects even if the latest `data` no longer contains them.
936
+
937
+ **Pruning**
938
+
939
+ * `prune: "missing"` will remove selection IDs that do not exist in the latest fetched list.
940
+ * Default is `"never"` (recommended), because remote searching can change the list and you don’t want selection wiped.
941
+
942
+ ---
943
+
944
+ ### When to use which mode
945
+
946
+ * Use **remote** when:
947
+
948
+ * dataset is large
949
+ * server-side filtering/search is required
950
+ * Use **local** when:
951
+
952
+ * you can fetch a reasonable base dataset once
953
+ * you want fast client-side searching
954
+ * Use **hybrid** when:
955
+
956
+ * you want remote search results but still need local-only behaviors (like `searchOnly` restrictions)
957
+
958
+ ## `useLister` hook (programmatic lister control)
959
+
960
+ `useLister` is the **low-level programmatic API** for the lister engine. Use it when you want to:
961
+
962
+ * Open a lister picker from anywhere (button click, context menu, shortcut)
963
+ * Fetch option lists using the same engine as the `lister` variant (without opening UI)
964
+ * Read and control active lister sessions (query, mode, filters, selection)
965
+ * Register/retrieve reusable lister presets at runtime
966
+
967
+ It must be used inside `<ListerProvider />`.
968
+
969
+ ---
970
+
971
+ ### Import
972
+
973
+ ```tsx
974
+ import { useLister } from "@timeax/form-palette/extra";
975
+ ```
976
+
977
+ ---
978
+
979
+ ### What you get back
980
+
981
+ ```ts
982
+ const { api, store, state, actions } = useLister();
983
+ ```
984
+
985
+ #### 1) `api`
986
+
987
+ A stable object exposing the two core operations:
988
+
989
+ * **`api.open(kindOrDef, filters?, opts?)`**
990
+ Opens the lister UI (popover/panel) and returns a promise that resolves when the user **Apply / Cancel / Close**.
991
+
992
+ * **`api.fetch(kindOrDef, filters?, opts?)`**
993
+ Performs a fetch through the same engine and returns `{ rawList, optionsList }`-style results (depending on your mapping/selector).
994
+ Use this when you want data but don’t want to show the picker UI.
995
+
996
+ Also includes:
997
+
998
+ * **`api.registerPreset(kind, def)`** — register a preset definition by string key
999
+ * **`api.getPreset(kind)`** — retrieve a preset
1000
+
1001
+ > `kindOrDef` can be either a preset key (string) or a full `ListerDefinition` object.
1002
+
1003
+ ---
1004
+
1005
+ #### 2) `store`
1006
+
1007
+ The **global lister store** maintained by the provider.
1008
+
1009
+ * `store.order`: session z-order / focus stack
1010
+ * `store.activeId`: currently focused session id
1011
+ * `store.sessions`: record of all sessions keyed by `sessionId`
1012
+
1013
+ This is useful if you’re building custom UI around sessions.
1014
+
1015
+ ---
1016
+
1017
+ #### 3) `state`
1018
+
1019
+ A convenience accessor for the **active session state**:
1020
+
1021
+ ```ts
1022
+ const active = state; // AnyState | undefined
1023
+ ```
1024
+
1025
+ * `undefined` when no session is open/focused
1026
+ * otherwise the session’s runtime state (query, lists, selection, filters, etc.)
1027
+
1028
+ ---
1029
+
1030
+ #### 4) `actions`
1031
+
1032
+ Direct actions wired to the provider.
1033
+
1034
+ These are **imperative controls** you can call from anywhere.
1035
+
1036
+ ##### Session lifecycle
1037
+
1038
+ * `focus(sessionId)` — bring session to front and make it active
1039
+ * `dispose(sessionId)` — destroy session state and timers
1040
+
1041
+ ##### Finalize
1042
+
1043
+ * `apply(sessionId)` — resolve promise with “apply” and (if ephemeral) close
1044
+ * `cancel(sessionId)` — resolve promise with “cancel” and close
1045
+ * `close(sessionId)` — resolve promise with “close” and close
1046
+
1047
+ ##### Selection
1048
+
1049
+ * `toggle(sessionId, value)`
1050
+ * `select(sessionId, value)`
1051
+ * `deselect(sessionId, value)`
1052
+ * `clear(sessionId)`
1053
+
1054
+ > These operate on the session’s **draftValue** (single id or array of ids).
1055
+
1056
+ ##### Search state
1057
+
1058
+ * `setQuery(sessionId, q)` — updates session query
1059
+ * `setSearchMode(sessionId, mode)` — local/remote/hybrid
1060
+ * `setSearchTarget(sessionId, target)` — persist subject/all/only targeting
1061
+
1062
+ ##### Search execution
1063
+
1064
+ You get two overload-friendly helpers:
1065
+
1066
+ * `searchRemote(sessionId, q, payload?)`
1067
+ * `searchLocal(sessionId, q, payload?)`
1068
+
1069
+ Both accept an optional **payload override** (`{ subject } | { searchAll: true } | { searchOnly: [...] }`).
1070
+ If you omit it, the provider derives payload from `searchTarget`.
1071
+
1072
+ ##### Refresh + positioning
1073
+
1074
+ * `refresh(sessionId)` — re-fetch using latest filters/query/target
1075
+ * `setPosition(sessionId, pos)` — store draggable panel position
1076
+
1077
+ ##### Filters
1078
+
1079
+ Filters are intentionally split into two layers:
1080
+
1081
+ 1. **Ctx-driven filters** (data state)
1082
+
1083
+ * `getFilterCtx(sessionId)` returns a small controller:
1084
+
1085
+ * `ctx.set(key, value)`
1086
+ * `ctx.merge(patch)`
1087
+ * `ctx.unset(key)`
1088
+ * `ctx.clear()`
1089
+ * `ctx.refresh()`
1090
+
1091
+ 2. **Filter option clicks** (UI option-id based)
1092
+
1093
+ * `applyFilterOption(sessionId, optionId)`
1094
+
1095
+ This method toggles a filter option by its **UI id** (not DB value) and recomputes the effective filter payload.
1096
+
1097
+ ##### Visible options (local/hybrid)
1098
+
1099
+ * `getVisibleOptions(sessionId)`
1100
+
1101
+ Returns the current “visible” list after applying:
1102
+
1103
+ * local/hybrid filtering rules
1104
+ * search payload (`subject/searchAll/searchOnly`)
1105
+ * filters
1106
+
1107
+ This is what you typically render in custom UIs.
1108
+
1109
+ ---
1110
+
1111
+ ### Common usage patterns
1112
+
1113
+ #### 1) Open a picker from a button
1114
+
1115
+ ```tsx
1116
+ function PickUserButton() {
1117
+ const { api } = useLister();
1118
+
1119
+ async function pick() {
1120
+ const res = await api.open(
1121
+ {
1122
+ source: { endpoint: "/api/users" },
1123
+ mapping: { optionLabel: "fullName", optionValue: "id" },
1124
+ },
1125
+ {},
1126
+ { title: "Select a user", mode: "single" }
1127
+ );
1128
+
1129
+ if (res.reason === "apply") {
1130
+ console.log("Selected id", res.value);
1131
+ console.log("Selected object", res.details.raw);
1132
+ }
1133
+ }
1134
+
1135
+ return <button onClick={pick}>Pick user</button>;
1136
+ }
1137
+ ```
1138
+
1139
+ #### 2) Fetch options without opening UI
1140
+
1141
+ ```tsx
1142
+ function useUserOptions() {
1143
+ const { api } = useLister();
1144
+
1145
+ return React.useCallback(async () => {
1146
+ const res = await api.fetch(
1147
+ {
1148
+ source: { endpoint: "/api/users" },
1149
+ mapping: { optionLabel: "fullName", optionValue: "id" },
1150
+ },
1151
+ { status: "active" },
1152
+ { query: "", search: { searchAll: true } }
1153
+ );
1154
+
1155
+ return res.options;
1156
+ }, [api]);
1157
+ }
1158
+ ```
1159
+
1160
+ #### 3) Drive the active session imperatively
1161
+
1162
+ ```tsx
1163
+ function ListerDebugControls() {
1164
+ const { state, actions } = useLister();
1165
+
1166
+ if (!state) return null;
1167
+
1168
+ return (
1169
+ <div className="flex gap-2">
1170
+ <button onClick={() => actions.refresh(state.sessionId)}>Refresh</button>
1171
+ <button onClick={() => actions.clear(state.sessionId)}>Clear</button>
1172
+ <button onClick={() => actions.close(state.sessionId)}>Close</button>
1173
+ </div>
1174
+ );
1175
+ }
1176
+ ```
1177
+
1178
+ ---
1179
+
1180
+ ### Notes and gotchas
1181
+
1182
+ * `useLister` does **not** render UI. The UI is rendered by `ListerUI` (and the `lister` variant) which reads the provider store.
1183
+ * Sessions can be **ephemeral** (default) or **persistent** when opened with `ownerKey` (so their filters/target/query survive popover reopen).
1184
+ * In **local** and **hybrid** modes, use `getVisibleOptions(sessionId)` for the UI list, not `state.optionsList`.
1185
+ * `applyFilterOption` expects the **option UI id** (path-like ids), not the DB value.
1186
+
1187
+ ---
1188
+
1189
+ ## `useData` hook (lightweight data fetch + local/remote search)
1190
+
1191
+ `useData` is a small hook that reuses the lister engine’s request semantics (`buildRequest`, `selector`, and `searchTarget → payload`) without opening the lister UI.
1192
+
1193
+ It’s designed for:
1194
+
1195
+ * Fetching and displaying remote lists in normal pages
1196
+ * Supporting remote/hybrid/local search modes
1197
+ * Optional lightweight selection state by stable item key
1198
+
1199
+ It must be used inside `<ListerProvider />` because it calls the provider’s `apiFetchAny` internally.
1200
+
1201
+ ---
1202
+
1203
+ ### When to use `useData` vs `useLister`
1204
+
1205
+ * Use **`useData`** when you want a simple list in your component (table, dropdown, cards) and don’t need the lister panel UI.
1206
+ * Use **`useLister`** when you need to open the lister UI and/or control sessions.
1207
+
1208
+ ---
1209
+
1210
+ ### Minimal usage
1211
+
1212
+ ```tsx
1213
+ function UsersList() {
1214
+ const users = useData({
1215
+ endpoint: "/api/users",
1216
+ selector: "data", // or (body) => body.data
1217
+ search: { default: "fullName" },
1218
+ searchMode: "remote",
1219
+ });
1220
+
1221
+ return (
1222
+ <div>
1223
+ <input
1224
+ value={users.query}
1225
+ onChange={(e) => users.setQuery(e.target.value)}
1226
+ placeholder="Search…"
1227
+ />
1228
+
1229
+ {users.loading ? (
1230
+ <div>Loading…</div>
1231
+ ) : (
1232
+ <ul>
1233
+ {users.visible.map((u: any) => (
1234
+ <li key={u.id}>{u.fullName}</li>
1235
+ ))}
1236
+ </ul>
1237
+ )}
1238
+ </div>
1239
+ );
1240
+ }
1241
+ ```
1242
+
1243
+ ---
1244
+
1245
+ ### Inputs (`UseDataOptions`)
1246
+
1247
+ #### Request configuration
1248
+
1249
+ * `endpoint` (required): URL to fetch
1250
+ * `method`: `GET | POST` (default `GET`)
1251
+ * `selector`: how to extract the list from the response (path or function)
1252
+ * `buildRequest(ctx)`: advanced request builder
1253
+
1254
+ * receives `{ filters, query, cursor }`
1255
+ * returns `{ params, body, headers }`
1256
+
1257
+ #### Search
1258
+
1259
+ * `search`: minimal search config
1260
+
1261
+ * `default`: the default **subject** key when `searchTarget.mode === "subject"`
1262
+ * `searchMode`: `local | remote | hybrid` (default `remote`)
1263
+ * `debounceMs`: debounce for remote/hybrid query typing
1264
+ * `fetchOnMount`: defaults to `true` unless `initial` is provided
1265
+
1266
+ #### Filters
1267
+
1268
+ * `filters`: base filters object
1269
+ * `autoFetchOnFilterChange`: default `true` (only meaningful for remote/hybrid)
1270
+
1271
+ #### Selection (optional)
1272
+
1273
+ If you provide `selection`, the hook exposes selection helpers (`select`, `toggle`, etc.) and returns selected objects (not just ids).
1274
+
1275
+ * `selection.mode`: `single | multiple`
1276
+ * `selection.key`: how to resolve item id
1277
+
1278
+ * string / keyof: `item[key]`
1279
+ * function: `(item) => id`
1280
+ * default: `item.id ?? item.value`
1281
+ * `selection.prune`:
1282
+
1283
+ * `never` (default): keep ids even if the latest list doesn’t include them
1284
+ * `missing`: remove ids not present in the latest fetched list
1285
+
1286
+ ---
1287
+
1288
+ ### Outputs (`UseDataResult`)
1289
+
1290
+ * `data`: last fetched list
1291
+ * `visible`: list after applying local/hybrid filtering using `searchTarget + query`
1292
+ * `loading`, `error`
1293
+ * `query`, `setQuery(q)`
1294
+ * `searchMode`, `setSearchMode(mode)`
1295
+ * `searchTarget`, `setSearchTarget(target)`
1296
+ * `filters`, `setFilters(next)`, `patchFilters(patch)`, `clearFilters()`
1297
+ * `refresh()`
1298
+ * `fetch(override?)`: imperative fetch; supports `{ query, filters, searchTarget }`
1299
+
1300
+ If selection is enabled:
1301
+
1302
+ * `selectedIds`, `selected`
1303
+ * `select(id|ids)`, `deselect(id|ids)`, `toggle(id)`
1304
+ * `isSelected(id)`, `clearSelection()`, `getSelection()`
1305
+
1306
+ ---
1307
+
1308
+ ### Search semantics
1309
+
1310
+ `useData` follows the lister payload model via `buildSearchPayloadFromTarget(searchTarget)`:
1311
+
1312
+ * `target.mode === "subject"` → `{ subject: "fieldName" }`
1313
+ * `target.mode === "all"` → `{ searchAll: true }`
1314
+ * `target.mode === "only"` → `{ searchOnly: [ids...] }`
1315
+
1316
+ **Remote/hybrid:** query typing triggers a debounced fetch.
1317
+
1318
+ **Local:** the hook fetches a base dataset once (when switching to local), then filters client-side.
1319
+
1320
+ ---
1321
+
1322
+ ### Selection example
1323
+
1324
+ ```tsx
1325
+ function UsersChooser() {
1326
+ const users = useData({
1327
+ endpoint: "/api/users",
1328
+ selector: "data",
1329
+ search: { default: "fullName" },
1330
+ searchMode: "remote",
1331
+ selection: { mode: "multiple", key: "id" },
1332
+ });
1333
+
1334
+ return (
1335
+ <div>
1336
+ <input value={users.query} onChange={(e) => users.setQuery(e.target.value)} />
1337
+
1338
+ <ul>
1339
+ {users.visible.map((u: any) => (
1340
+ <li key={u.id}>
1341
+ <label>
1342
+ <input
1343
+ type="checkbox"
1344
+ checked={users.isSelected(u.id)}
1345
+ onChange={() => users.toggle(u.id)}
1346
+ />
1347
+ {u.fullName}
1348
+ </label>
1349
+ </li>
1350
+ ))}
1351
+ </ul>
1352
+
1353
+ <pre>{JSON.stringify(users.getSelection(), null, 2)}</pre>
1354
+ </div>
1355
+ );
1356
+ }
1357
+ ```
1358
+
1359
+
709
1360
  #### JSON Editor Types
710
1361
 
711
1362
  **JsonEditorFieldMap Entry**