@stapel/vocabularies-react 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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/MODULE.md +92 -0
- package/README.md +173 -0
- package/dist/api/extensions.d.ts +8 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +8 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/generated/schema.d.ts +234 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +23 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/vocabulariesApi.d.ts +14 -0
- package/dist/api/vocabulariesApi.d.ts.map +1 -0
- package/dist/api/vocabulariesApi.js +4 -0
- package/dist/api/vocabulariesApi.js.map +1 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/default/VocabularyTermSelect.d.ts +24 -0
- package/dist/default/VocabularyTermSelect.d.ts.map +1 -0
- package/dist/default/VocabularyTermSelect.js +75 -0
- package/dist/default/VocabularyTermSelect.js.map +1 -0
- package/dist/default/index.d.ts +16 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +14 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/registry.d.ts +22 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +20 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/VocabulariesProvider.d.ts +19 -0
- package/dist/headless/VocabulariesProvider.d.ts.map +1 -0
- package/dist/headless/VocabulariesProvider.js +15 -0
- package/dist/headless/VocabulariesProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +21 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +38 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +305 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +156 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +33 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +44 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +26 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +43 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queryKeys.d.ts +5 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +28 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +17 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/useTermLabels.d.ts +26 -0
- package/dist/model/useTermLabels.d.ts.map +1 -0
- package/dist/model/useTermLabels.js +58 -0
- package/dist/model/useTermLabels.js.map +1 -0
- package/dist/model/useTermSearch.d.ts +24 -0
- package/dist/model/useTermSearch.d.ts.map +1 -0
- package/dist/model/useTermSearch.js +85 -0
- package/dist/model/useTermSearch.js.map +1 -0
- package/llms.txt +79 -0
- package/manifest.json +466 -0
- package/package.json +108 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/extensions.ts +7 -0
- package/src/api/generated/schema.ts +236 -0
- package/src/api/types.ts +27 -0
- package/src/api/vocabulariesApi.ts +17 -0
- package/src/client.ts +176 -0
- package/src/default/VocabularyTermSelect.tsx +147 -0
- package/src/default/index.ts +15 -0
- package/src/default/types.ts +11 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/registry.ts +32 -0
- package/src/headless/VocabulariesProvider.tsx +20 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +49 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +187 -0
- package/src/i18n/generated/errors.json +351 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +53 -0
- package/src/i18n/ru.ts +54 -0
- package/src/index.ts +92 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queryKeys.ts +35 -0
- package/src/model/runtime.ts +25 -0
- package/src/model/useTermLabels.ts +72 -0
- package/src/model/useTermSearch.ts +118 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createModuleRuntime } from "@stapel/core";
|
|
2
|
+
import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
|
|
3
|
+
import { createVocabulariesApi } from "../api/vocabulariesApi.js";
|
|
4
|
+
import type { VocabulariesApi } from "../api/vocabulariesApi.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The wired vocabularies runtime — core's `ModuleRuntime` bound to this pair's
|
|
8
|
+
* API (slim wave §21/S2: the plumbing lives once in `@stapel/core`'s
|
|
9
|
+
* `createModuleRuntime`/`createModuleContext`; this module only binds the
|
|
10
|
+
* module-prefixed names). The returned `client` is what the host injects
|
|
11
|
+
* into core's `StapelConfigProvider` (as the default or the `"vocabularies"`
|
|
12
|
+
* module client), preserving the client-injection fork seam
|
|
13
|
+
* (frontend-standard §7.2). Auth token/refresh and the verification-403 seam
|
|
14
|
+
* are supplied by the host's auth runtime on the shared client — this pair
|
|
15
|
+
* does not re-implement them.
|
|
16
|
+
*/
|
|
17
|
+
export type VocabulariesRuntime = ModuleRuntime<VocabulariesApi>;
|
|
18
|
+
|
|
19
|
+
export type CreateVocabulariesRuntimeOptions = CreateModuleRuntimeOptions;
|
|
20
|
+
|
|
21
|
+
export function createVocabulariesRuntime(
|
|
22
|
+
options: CreateVocabulariesRuntimeOptions
|
|
23
|
+
): VocabulariesRuntime {
|
|
24
|
+
return createModuleRuntime(createVocabulariesApi, options);
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useTermLabels` — `{code: label}` for codes a page already HOLDS.
|
|
3
|
+
*
|
|
4
|
+
* The other half of the seam. A stored answer on a listing is a list of term
|
|
5
|
+
* CODES (`["apple", "iphone-15-pro"]`); the labels are not stored with it,
|
|
6
|
+
* because a label is translated and a code is not. So a reopened draft, a
|
|
7
|
+
* filter chip, a facet row — anything showing an answer somebody already gave
|
|
8
|
+
* — has to ask for the words.
|
|
9
|
+
*
|
|
10
|
+
* Unlike a search this IS a cacheable resource: the same codes give the same
|
|
11
|
+
* labels until the vocabulary's revision moves, and several controls on one
|
|
12
|
+
* page routinely ask for the same set. So it is a TanStack query keyed through
|
|
13
|
+
* the factory, and the answer is a {@link LoadState} rather than a bare map —
|
|
14
|
+
* `{}` would say "these codes have no labels" in exactly the same words as
|
|
15
|
+
* "nobody has asked yet" and "the request failed" (core `loadState.ts`).
|
|
16
|
+
*
|
|
17
|
+
* The fallback a caller wants is almost always the CODE ITSELF, which is what
|
|
18
|
+
* {@link termLabel} does: the stored answer is the truth, and a blank control
|
|
19
|
+
* is a worse lie than a slug.
|
|
20
|
+
*/
|
|
21
|
+
import { useQuery } from "@tanstack/react-query";
|
|
22
|
+
import { loadStateFromQuery } from "@stapel/core";
|
|
23
|
+
import type { LoadState } from "@stapel/core";
|
|
24
|
+
import type { VocabularyClient } from "../client.js";
|
|
25
|
+
import { vocabulariesQueryKeys } from "./queryKeys.js";
|
|
26
|
+
|
|
27
|
+
export interface TermLabelsOptions {
|
|
28
|
+
readonly vocabulary: string;
|
|
29
|
+
readonly level: string;
|
|
30
|
+
readonly codes: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TermLabels = Readonly<Record<string, string>>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Labels for `codes`, as a load state. Disabled — and therefore `loading`,
|
|
37
|
+
* never `ready` with an empty map — while there is no client, no pointer or
|
|
38
|
+
* nothing to resolve.
|
|
39
|
+
*/
|
|
40
|
+
export function useTermLabels(
|
|
41
|
+
client: VocabularyClient | null,
|
|
42
|
+
options: TermLabelsOptions
|
|
43
|
+
): LoadState<TermLabels> {
|
|
44
|
+
const { vocabulary, level, codes } = options;
|
|
45
|
+
const wanted = codes.filter((code) => code.length > 0);
|
|
46
|
+
const enabled =
|
|
47
|
+
client !== null && vocabulary.length > 0 && level.length > 0 && wanted.length > 0;
|
|
48
|
+
const query = useQuery({
|
|
49
|
+
queryKey: vocabulariesQueryKeys.termLabels(vocabulary, level, wanted),
|
|
50
|
+
enabled,
|
|
51
|
+
queryFn: async (): Promise<TermLabels> => {
|
|
52
|
+
// `enabled` already guarantees this, and the compiler does not know it.
|
|
53
|
+
if (client === null) return {};
|
|
54
|
+
return await client.resolve(vocabulary, level, wanted);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return loadStateFromQuery(query);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The one right way to READ that state for display: a resolved label, or the
|
|
62
|
+
* code itself while the answer is in flight, when the load failed, or when the
|
|
63
|
+
* server omitted the code because it does not know it.
|
|
64
|
+
*
|
|
65
|
+
* All four fall back the same way ON PURPOSE — the code is what the person's
|
|
66
|
+
* answer literally is, so showing it is never wrong, only less kind. A control
|
|
67
|
+
* that needs to distinguish them (an admin screen auditing dead codes) reads
|
|
68
|
+
* the {@link LoadState} directly.
|
|
69
|
+
*/
|
|
70
|
+
export function termLabel(labels: LoadState<TermLabels>, code: string): string {
|
|
71
|
+
return labels.status === "ready" ? (labels.data[code] ?? code) : code;
|
|
72
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useTermSearch` — a typeahead over ONE vocabulary level.
|
|
3
|
+
*
|
|
4
|
+
* The level is the wrong size to hold (529 phone vendors is fine, 14 962
|
|
5
|
+
* models is not), so the options ARE the answer to the current query and the
|
|
6
|
+
* hook's whole job is to make sure the answer on screen belongs to the query
|
|
7
|
+
* on screen:
|
|
8
|
+
*
|
|
9
|
+
* - a keystroke DEBOUNCES (250 ms by default) — one request per pause, not
|
|
10
|
+
* per character;
|
|
11
|
+
* - a new request ABORTS the one before it, and a resolved-but-superseded
|
|
12
|
+
* answer is dropped, because the network does not promise order;
|
|
13
|
+
* - opening is not typing: the first page is fetched immediately (a spinner
|
|
14
|
+
* that starts a quarter second after the click reads as a dead control),
|
|
15
|
+
* ONCE per (vocabulary, level, parent) — antd reports the dropdown as
|
|
16
|
+
* opening on every keystroke, so a naive `open()` is a request storm nobody
|
|
17
|
+
* can see in a screenshot;
|
|
18
|
+
* - a parent CHANGE empties the list: what is listed are the previous
|
|
19
|
+
* parent's children and must not stay pickable.
|
|
20
|
+
*
|
|
21
|
+
* The client is a PARAMETER, not a context read: this hook is one half of the
|
|
22
|
+
* seam `@stapel/attributes-react` declares, and a host wires the client at its
|
|
23
|
+
* composition root. Passing `null` is a supported state (the control draws its
|
|
24
|
+
* unavailable notice) rather than a crash.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
27
|
+
import type { VocabularyClient, VocabularyTerm } from "../client.js";
|
|
28
|
+
|
|
29
|
+
/** The pause after the last keystroke before a query goes to the server. Long
|
|
30
|
+
* enough that typing a word is one request, short enough that it still feels
|
|
31
|
+
* like the control answered. Mirrors the ref editors in attributes-react. */
|
|
32
|
+
export const TERM_SEARCH_DEBOUNCE_MS = 250;
|
|
33
|
+
|
|
34
|
+
export interface TermSearchOptions {
|
|
35
|
+
readonly vocabulary: string;
|
|
36
|
+
readonly level: string;
|
|
37
|
+
/** Code of a term at the level above; narrows the page to its children.
|
|
38
|
+
* `undefined` means the whole level. */
|
|
39
|
+
readonly parent?: string | undefined;
|
|
40
|
+
readonly debounceMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TermSearchState {
|
|
44
|
+
readonly terms: readonly VocabularyTerm[];
|
|
45
|
+
readonly loading: boolean;
|
|
46
|
+
/** A person typed. Debounced and superseding. */
|
|
47
|
+
search: (query: string) => void;
|
|
48
|
+
/** A dropdown opened. Fetches the first page immediately, once per
|
|
49
|
+
* (vocabulary, level, parent). */
|
|
50
|
+
open: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useTermSearch(
|
|
54
|
+
client: VocabularyClient | null,
|
|
55
|
+
options: TermSearchOptions
|
|
56
|
+
): TermSearchState {
|
|
57
|
+
const { vocabulary, level, parent, debounceMs = TERM_SEARCH_DEBOUNCE_MS } = options;
|
|
58
|
+
const [terms, setTerms] = useState<readonly VocabularyTerm[]>([]);
|
|
59
|
+
const [loading, setLoading] = useState(false);
|
|
60
|
+
const inFlight = useRef<AbortController | undefined>(undefined);
|
|
61
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
62
|
+
const asked = useRef(false);
|
|
63
|
+
|
|
64
|
+
const run = useCallback(
|
|
65
|
+
(query: string): void => {
|
|
66
|
+
if (client === null || vocabulary.length === 0 || level.length === 0) return;
|
|
67
|
+
inFlight.current?.abort();
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
inFlight.current = controller;
|
|
70
|
+
setLoading(true);
|
|
71
|
+
client
|
|
72
|
+
.search(vocabulary, level, query, parent, controller.signal)
|
|
73
|
+
.then((found) => {
|
|
74
|
+
if (controller.signal.aborted) return;
|
|
75
|
+
setTerms(found);
|
|
76
|
+
setLoading(false);
|
|
77
|
+
})
|
|
78
|
+
.catch(() => {
|
|
79
|
+
// A superseded request is not a failure and must not clear the list
|
|
80
|
+
// the CURRENT request is about to fill.
|
|
81
|
+
if (controller.signal.aborted) return;
|
|
82
|
+
setTerms([]);
|
|
83
|
+
setLoading(false);
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
[client, vocabulary, level, parent]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
asked.current = false;
|
|
91
|
+
setTerms([]);
|
|
92
|
+
}, [client, vocabulary, level, parent]);
|
|
93
|
+
|
|
94
|
+
useEffect(
|
|
95
|
+
() => () => {
|
|
96
|
+
if (timer.current !== undefined) clearTimeout(timer.current);
|
|
97
|
+
inFlight.current?.abort();
|
|
98
|
+
},
|
|
99
|
+
[]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const search = useCallback(
|
|
103
|
+
(query: string): void => {
|
|
104
|
+
asked.current = true;
|
|
105
|
+
if (timer.current !== undefined) clearTimeout(timer.current);
|
|
106
|
+
timer.current = setTimeout(() => run(query), debounceMs);
|
|
107
|
+
},
|
|
108
|
+
[run, debounceMs]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const open = useCallback((): void => {
|
|
112
|
+
if (asked.current) return;
|
|
113
|
+
asked.current = true;
|
|
114
|
+
run("");
|
|
115
|
+
}, [run]);
|
|
116
|
+
|
|
117
|
+
return { terms, loading, search, open };
|
|
118
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"exactOptionalPropertyTypes": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"isolatedDeclarations": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"outDir": "dist",
|
|
23
|
+
"rootDir": "src"
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|