@comms-id/company 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,3 +28,17 @@ Every operation is a read, safe to repeat, and is retried after a retryable fail
28
28
  Entries: `.` (types and errors, safe in a browser), `./server` (signs each call), `./browser` (your relay route; never signs), `./fixtures` (LOCAL FIXTURES, no network), `./descriptor` (what `@comms-id/relay` may forward).
29
29
 
30
30
  The package is private until the publishing path exists (#515). `LICENSE` is the consumer client licence.
31
+
32
+ ## The field
33
+
34
+ `@comms-id/company/controller` is the headless controller of a company name or ACN field: input, debounce, suggestions, identifier checks, choice and errors, with the WAI-ARIA combobox attributes and key handler. It has no framework and no DOM. `@comms-id/company-react` (a hook and a component) and `<comms-id-company>` (a web element) run on it, and so can your own UI.
35
+
36
+ ```ts
37
+ import { createCompanyController } from "@comms-id/company/controller";
38
+
39
+ const controller = createCompanyController({ client, onSelect: (record, timing) => console.log(record, timing.ms) });
40
+ controller.subscribe(() => render(controller.getState()));
41
+ controller.setQuery("…");
42
+ ```
43
+
44
+ `src/local/profile.ts` is the only product-specific part: it reads the typed text, calls the client and reduces every reply to the text the UIs show and the record `onSelect` receives. Everything else is generated.
@@ -0,0 +1,15 @@
1
+ import type { CompanyClient } from "./generated/client.js";
2
+ import type { FieldController, FieldControllerOptions, FieldState } from "./runtime/field-types.js";
3
+ import { type ProductRecord, profile } from "./local/profile.js";
4
+ export type { Attributes, FieldIds } from "./runtime/field-aria.js";
5
+ export { handleKey, inputAttributes, listboxAttributes, optionAttributes, optionId, statusAttributes, } from "./runtime/field-aria.js";
6
+ export { DEFAULT_DEBOUNCE_MS, DEFAULT_LIMIT, DEFAULT_MIN_CHARS, idleState } from "./runtime/field-controller.js";
7
+ export type { Chosen, FieldError, FieldProfile, FieldStatus, FieldTiming, Found, Looked, Reading, ReadContext, Run, Searched, Suggestion, SuggestionView, } from "./runtime/field-types.js";
8
+ export type { CompanyClient } from "./generated/client.js";
9
+ export { profile };
10
+ export type { ProductRecord as CompanyRecord };
11
+ export type CompanyController = FieldController<ProductRecord>;
12
+ export type CompanyControllerOptions = Omit<FieldControllerOptions<CompanyClient, ProductRecord>, "profile">;
13
+ export type CompanyState = FieldState<ProductRecord>;
14
+ /** Creates the controller. Nothing is called until the first `setQuery`. */
15
+ export declare const createCompanyController: (options: CompanyControllerOptions) => CompanyController;
@@ -0,0 +1,11 @@
1
+ // Generated by @comms-id/forge-transformers from the service's OpenAPI contract. Do not edit: change the contract and regenerate.
2
+ // Controller entry: the headless controller of the Comms.ID Company field, its WAI-ARIA attributes
3
+ // and key handler, and the product's profile. No framework, no DOM. The React package and the
4
+ // web element run on it.
5
+ import { createFieldController } from "./runtime/field-controller.js";
6
+ import { profile } from "./local/profile.js";
7
+ export { handleKey, inputAttributes, listboxAttributes, optionAttributes, optionId, statusAttributes, } from "./runtime/field-aria.js";
8
+ export { DEFAULT_DEBOUNCE_MS, DEFAULT_LIMIT, DEFAULT_MIN_CHARS, idleState } from "./runtime/field-controller.js";
9
+ export { profile };
10
+ /** Creates the controller. Nothing is called until the first `setQuery`. */
11
+ export const createCompanyController = (options) => createFieldController({ ...options, profile });
@@ -669,6 +669,6 @@ export const operations = {
669
669
  };
670
670
  /** The error codes the contract documents for each operation. */
671
671
  export const errorCodes = {
672
- lookup: ["INVALID_INPUT", "UNAUTHORIZED", "RESIDENCY_INGRESS", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIMIT_REACHED", "CANCELLED", "INTERNAL_ERROR", "UNAVAILABLE", "RESIDENCY_UNAVAILABLE"],
673
- search: ["INVALID_INPUT", "UNAUTHORIZED", "RESIDENCY_INGRESS", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIMIT_REACHED", "CANCELLED", "INTERNAL_ERROR", "UNAVAILABLE", "RESIDENCY_UNAVAILABLE"],
672
+ lookup: ["INVALID_INPUT", "UNAUTHORIZED", "RESIDENCY_INGRESS", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIMIT_REACHED", "FAIR_USE_CEILING", "CANCELLED", "INTERNAL_ERROR", "UNAVAILABLE", "RESIDENCY_UNAVAILABLE"],
673
+ search: ["INVALID_INPUT", "UNAUTHORIZED", "RESIDENCY_INGRESS", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIMIT_REACHED", "FAIR_USE_CEILING", "CANCELLED", "INTERNAL_ERROR", "UNAVAILABLE", "RESIDENCY_UNAVAILABLE"],
674
674
  };
@@ -0,0 +1,11 @@
1
+ import type { CompanyClient } from "../generated/client.js";
2
+ import type { LookupResponse } from "../generated/types.js";
3
+ import type { FieldProfile } from "../runtime/field-types.js";
4
+ /** One company record of the ASIC register snapshot. */
5
+ export type ProductRecord = LookupResponse["records"][number];
6
+ export declare const ATTRIBUTION = "Company data: Australian Securities and Investments Commission, via data.gov.au (CC BY 3.0 AU).";
7
+ /** "001234567" as "001 234 567"; anything else is shown as it is. */
8
+ export declare const formatAcn: (acn: string) => string;
9
+ /** One line for a found company: name, ACN and status. The full record is in `onSelect`. */
10
+ export declare const describeCompany: (record: ProductRecord) => string;
11
+ export declare const profile: FieldProfile<CompanyClient, ProductRecord>;
@@ -0,0 +1,94 @@
1
+ // The Company field's profile: the only company-specific code of the field. It reads the typed
2
+ // text (a nine-digit ACN is looked up, anything else is searched by name), calls the client, and
3
+ // reduces every reply to the text the UIs show and the record the consumer receives. The
4
+ // controller, the React package and the web element are generated and know nothing else.
5
+ import { isCommsIdError } from "../runtime/errors.js";
6
+ export const ATTRIBUTION = "Company data: Australian Securities and Investments Commission, via data.gov.au (CC BY 3.0 AU).";
7
+ const ACN_DIGITS = 9;
8
+ /** The contract allows ten rows at most. */
9
+ const MAX_LIMIT = 10;
10
+ const NUMBER_TEXT = /^[\d\s-]+$/;
11
+ const SEPARATORS = /[\s-]/g;
12
+ /** "001234567" as "001 234 567"; anything else is shown as it is. */
13
+ export const formatAcn = (acn) => acn.length === ACN_DIGITS ? `${acn.slice(0, 3)} ${acn.slice(3, 6)} ${acn.slice(6)}` : acn;
14
+ const nameOf = (record) => record.name ?? record.currentName ?? "Unnamed company";
15
+ const compact = (parts) => parts.filter((part) => part !== null && part !== undefined && part !== "");
16
+ /** One line for a found company: name, ACN and status. The full record is in `onSelect`. */
17
+ export const describeCompany = (record) => compact([nameOf(record), record.acn === null ? undefined : `ACN ${formatAcn(record.acn)}`, record.status]).join(" · ");
18
+ const foundOf = (reply, record) => ({
19
+ record,
20
+ summary: describeCompany(record),
21
+ asAt: reply.activatedAt ?? reply.source.observedAt,
22
+ attribution: reply.attribution,
23
+ });
24
+ /** The service answers an unknown name or ACN with NOT_FOUND: that is "no match", not a failure. */
25
+ const orNothing = async (call) => {
26
+ try {
27
+ return await call;
28
+ }
29
+ catch (cause) {
30
+ if (isCommsIdError(cause) && cause.code === "NOT_FOUND") {
31
+ return;
32
+ }
33
+ throw cause;
34
+ }
35
+ };
36
+ export const profile = {
37
+ attribution: ATTRIBUTION,
38
+ label: "Company name or ACN",
39
+ listLabel: "Company suggestions",
40
+ noun: "company",
41
+ notFound: "No company found.",
42
+ service: "Company search",
43
+ settings: ["status"],
44
+ working: "Looking up the company",
45
+ read: (text, context) => {
46
+ const trimmed = text.trim();
47
+ if (NUMBER_TEXT.test(trimmed)) {
48
+ const acn = trimmed.replace(SEPARATORS, "");
49
+ if (acn.length < ACN_DIGITS) {
50
+ return { kind: "incomplete" };
51
+ }
52
+ if (acn.length > ACN_DIGITS) {
53
+ return { kind: "invalid", reason: "LENGTH", message: "An ACN has 9 digits." };
54
+ }
55
+ return {
56
+ kind: "lookup",
57
+ run: async (client, options) => {
58
+ const reply = await orNothing(client.lookup({ acn }, options));
59
+ const record = reply?.records[0];
60
+ return {
61
+ found: reply === undefined || record === undefined ? undefined : foundOf(reply, record),
62
+ attribution: reply?.attribution,
63
+ };
64
+ },
65
+ };
66
+ }
67
+ if (trimmed.length < context.minChars) {
68
+ return { kind: "incomplete" };
69
+ }
70
+ const status = context.settings.status;
71
+ return {
72
+ kind: "search",
73
+ run: async (client, options) => {
74
+ const request = {
75
+ namePrefix: trimmed,
76
+ limit: Math.min(context.limit, MAX_LIMIT),
77
+ ...(status === undefined ? {} : { status }),
78
+ };
79
+ const reply = await orNothing(client.search(request, options));
80
+ if (reply === undefined) {
81
+ return { suggestions: [], attribution: undefined };
82
+ }
83
+ const suggestions = reply.records.map((record, index) => ({
84
+ key: `${record.acn ?? "none"}-${index}`,
85
+ primary: nameOf(record),
86
+ secondary: compact([record.acn === null ? undefined : `ACN ${formatAcn(record.acn)}`, record.status]).join(" · "),
87
+ // The search already returned the whole record: choosing it needs no second call.
88
+ choose: { kind: "found", found: foundOf(reply, record) },
89
+ }));
90
+ return { suggestions, attribution: reply.attribution };
91
+ },
92
+ };
93
+ },
94
+ };
@@ -0,0 +1,19 @@
1
+ import type { FieldController, FieldState } from "./field-types.js";
2
+ export interface FieldIds {
3
+ readonly input: string;
4
+ readonly listbox: string;
5
+ readonly status: string;
6
+ }
7
+ /** Attribute name to value; an undefined value means the attribute is left off. */
8
+ export type Attributes = Readonly<Record<string, string | undefined>>;
9
+ export declare const optionId: (ids: FieldIds, index: number) => string;
10
+ export declare const inputAttributes: (state: FieldState<unknown>, ids: FieldIds) => Attributes;
11
+ export declare const listboxAttributes: (ids: FieldIds, label: string) => Attributes;
12
+ export declare const optionAttributes: (state: FieldState<unknown>, ids: FieldIds, index: number) => Attributes;
13
+ /** The polite live region that says what just happened. */
14
+ export declare const statusAttributes: (ids: FieldIds) => Attributes;
15
+ /**
16
+ * Handles one key press in the input. Returns true when the key was used, so the caller prevents
17
+ * its default action: Enter in an open list must pick the option, not submit the form.
18
+ */
19
+ export declare const handleKey: (controller: FieldController<unknown>, key: string) => boolean;
@@ -0,0 +1,72 @@
1
+ // Runtime template: copied into a generated client package as src/runtime/field-aria.ts.
2
+ // The accessibility contract of the field, as plain attributes and one key handler. It follows
3
+ // the WAI-ARIA Authoring Practices combobox with a listbox popup (list autocomplete, focus stays
4
+ // in the input, the highlighted option is named by aria-activedescendant). The React package and
5
+ // the web element render these attributes and call the handler unchanged.
6
+ export const optionId = (ids, index) => `${ids.listbox}-option-${index}`;
7
+ export const inputAttributes = (state, ids) => ({
8
+ id: ids.input,
9
+ role: "combobox",
10
+ type: "text",
11
+ autocomplete: "off",
12
+ autocapitalize: "off",
13
+ spellcheck: "false",
14
+ "aria-autocomplete": "list",
15
+ "aria-haspopup": "listbox",
16
+ "aria-expanded": state.open ? "true" : "false",
17
+ "aria-controls": ids.listbox,
18
+ "aria-activedescendant": state.open && state.activeIndex >= 0 ? optionId(ids, state.activeIndex) : undefined,
19
+ "aria-describedby": ids.status,
20
+ "aria-busy": state.status === "loading" || state.status === "resolving" ? "true" : undefined,
21
+ "aria-invalid": state.status === "error" || state.status === "invalid" ? "true" : undefined,
22
+ });
23
+ export const listboxAttributes = (ids, label) => ({
24
+ id: ids.listbox,
25
+ role: "listbox",
26
+ "aria-label": label,
27
+ });
28
+ export const optionAttributes = (state, ids, index) => ({
29
+ id: optionId(ids, index),
30
+ role: "option",
31
+ "aria-selected": index === state.activeIndex ? "true" : "false",
32
+ });
33
+ /** The polite live region that says what just happened. */
34
+ export const statusAttributes = (ids) => ({
35
+ id: ids.status,
36
+ role: "status",
37
+ "aria-live": "polite",
38
+ "aria-atomic": "true",
39
+ });
40
+ /**
41
+ * Handles one key press in the input. Returns true when the key was used, so the caller prevents
42
+ * its default action: Enter in an open list must pick the option, not submit the form.
43
+ */
44
+ export const handleKey = (controller, key) => {
45
+ const state = controller.getState();
46
+ switch (key) {
47
+ case "ArrowDown":
48
+ case "ArrowUp":
49
+ if (state.suggestions.length === 0) {
50
+ return false;
51
+ }
52
+ controller.moveActive(key === "ArrowDown" ? 1 : -1);
53
+ return true;
54
+ case "Enter":
55
+ if (state.open && state.activeIndex >= 0) {
56
+ controller.select();
57
+ return true;
58
+ }
59
+ return false;
60
+ case "Escape":
61
+ if (state.open) {
62
+ controller.close();
63
+ return true;
64
+ }
65
+ return false;
66
+ case "Tab":
67
+ controller.close();
68
+ return false;
69
+ default:
70
+ return false;
71
+ }
72
+ };
@@ -0,0 +1,8 @@
1
+ import type { FieldController, FieldControllerOptions, FieldState } from "./field-types.js";
2
+ export declare const DEFAULT_MIN_CHARS = 3;
3
+ export declare const DEFAULT_DEBOUNCE_MS = 200;
4
+ export declare const DEFAULT_LIMIT = 8;
5
+ /** The state of a controller that has not been used. */
6
+ export declare const idleState: <R>(attribution: string) => FieldState<R>;
7
+ /** Creates the controller. Nothing is called until the first `setQuery`. */
8
+ export declare const createFieldController: <C, R>(options: FieldControllerOptions<C, R>) => FieldController<R>;
@@ -0,0 +1,324 @@
1
+ // Runtime template: copied into a generated client package as src/runtime/field-controller.ts.
2
+ // The headless controller of a field: input, debounce, suggestions, choice, identifier checks and
3
+ // errors, with no framework and no markup. The React package and the web element both run on it.
4
+ //
5
+ // The product-specific part is the profile (src/local/profile.ts): it reads the typed text and
6
+ // says whether to search, look up, wait or refuse. A search shows suggestions, and a chosen
7
+ // suggestion is either used as it is (the search already returned the record) or looked up.
8
+ import { isCommsIdError, outcomeOf } from "./errors.js";
9
+ export const DEFAULT_MIN_CHARS = 3;
10
+ export const DEFAULT_DEBOUNCE_MS = 200;
11
+ export const DEFAULT_LIMIT = 8;
12
+ const NO_SETTINGS = {};
13
+ /** The state of a controller that has not been used. */
14
+ export const idleState = (attribution) => ({
15
+ query: "",
16
+ status: "idle",
17
+ suggestions: [],
18
+ activeIndex: -1,
19
+ open: false,
20
+ error: undefined,
21
+ invalid: undefined,
22
+ resolved: undefined,
23
+ announcement: "",
24
+ attribution,
25
+ });
26
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
27
+ const errorMessage = (service, code, outcome) => {
28
+ if (code === "FAIR_USE_CEILING") {
29
+ return `The ${service.toLowerCase()} limit is reached. Try again later.`;
30
+ }
31
+ if (outcome === "source-unavailable") {
32
+ return `${service} is unavailable. Try again later.`;
33
+ }
34
+ return `${service} failed. Try again.`;
35
+ };
36
+ const toError = (service, cause) => {
37
+ const outcome = outcomeOf(cause);
38
+ const base = isCommsIdError(cause)
39
+ ? { code: cause.code, detail: cause.message, outcome, retryable: cause.retryable, requestId: cause.requestId }
40
+ : {
41
+ code: "UNKNOWN",
42
+ detail: cause instanceof Error ? cause.message : String(cause),
43
+ outcome,
44
+ retryable: false,
45
+ requestId: undefined,
46
+ };
47
+ return { ...base, message: errorMessage(service, base.code, outcome) };
48
+ };
49
+ /** Starts a task whose own try/catch handles every failure; nothing is left unhandled. */
50
+ const run = (task) => {
51
+ task.catch(() => undefined);
52
+ };
53
+ const isOurAbort = (cause) => isCommsIdError(cause) && cause.code === "ABORTED";
54
+ /** Creates the controller. Nothing is called until the first `setQuery`. */
55
+ export const createFieldController = (options) => {
56
+ const { client, profile } = options;
57
+ const minChars = options.minChars ?? DEFAULT_MIN_CHARS;
58
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
59
+ const limit = options.limit ?? DEFAULT_LIMIT;
60
+ const settings = options.settings ?? NO_SETTINGS;
61
+ const now = options.now ?? (() => performance.now());
62
+ let state = idleState(profile.attribution);
63
+ let entries = [];
64
+ const listeners = new Set();
65
+ let sequence = 0;
66
+ let timer;
67
+ let inFlight;
68
+ let retryAction;
69
+ let disposed = false;
70
+ const notify = () => {
71
+ for (const listener of [...listeners]) {
72
+ listener();
73
+ }
74
+ };
75
+ const set = (patch) => {
76
+ state = { ...state, ...patch };
77
+ notify();
78
+ };
79
+ /** Starts a new generation: anything still waiting or in flight becomes stale. */
80
+ const supersede = () => {
81
+ sequence += 1;
82
+ clearTimeout(timer);
83
+ timer = undefined;
84
+ inFlight?.abort();
85
+ inFlight = undefined;
86
+ return sequence;
87
+ };
88
+ const fail = (cause) => {
89
+ const error = toError(profile.service, cause);
90
+ entries = [];
91
+ set({ status: "error", suggestions: [], activeIndex: -1, open: false, error, announcement: error.message });
92
+ };
93
+ const search = async (generation, reading) => {
94
+ const controller = new AbortController();
95
+ inFlight = controller;
96
+ retryAction = () => {
97
+ const next = supersede();
98
+ set({ status: "loading", error: undefined });
99
+ run(search(next, reading));
100
+ };
101
+ try {
102
+ const reply = await reading.run(client, { signal: controller.signal });
103
+ if (generation !== sequence || disposed) {
104
+ return;
105
+ }
106
+ entries = reply.suggestions;
107
+ const found = entries.length > 0;
108
+ set({
109
+ status: found ? "results" : "empty",
110
+ suggestions: entries.map(({ key, primary, secondary }) => ({ key, primary, secondary })),
111
+ activeIndex: -1,
112
+ open: found,
113
+ error: undefined,
114
+ attribution: reply.attribution ?? profile.attribution,
115
+ announcement: found ? `${plural(entries.length, `${profile.noun} suggestion`)} available.` : profile.notFound,
116
+ });
117
+ }
118
+ catch (cause) {
119
+ if (generation !== sequence || disposed || isOurAbort(cause)) {
120
+ return;
121
+ }
122
+ fail(cause);
123
+ }
124
+ };
125
+ const finish = (found, started) => {
126
+ const timing = { ms: Math.round(now() - started) };
127
+ set({
128
+ status: "resolved",
129
+ open: false,
130
+ activeIndex: -1,
131
+ error: undefined,
132
+ invalid: undefined,
133
+ resolved: { found, timing },
134
+ attribution: found.attribution ?? profile.attribution,
135
+ announcement: `Found ${found.summary}.`,
136
+ });
137
+ try {
138
+ options.onSelect?.(found.record, timing);
139
+ }
140
+ catch (thrown) {
141
+ // The consumer's callback must not corrupt the state; surface its error to the page.
142
+ queueMicrotask(() => {
143
+ throw thrown;
144
+ });
145
+ }
146
+ };
147
+ const lookup = async (generation, call) => {
148
+ const controller = new AbortController();
149
+ inFlight = controller;
150
+ retryAction = () => {
151
+ const next = supersede();
152
+ set({ status: "resolving", error: undefined });
153
+ run(lookup(next, call));
154
+ };
155
+ const started = now();
156
+ let reply;
157
+ try {
158
+ reply = await call(client, { signal: controller.signal });
159
+ }
160
+ catch (cause) {
161
+ if (generation !== sequence || disposed || isOurAbort(cause)) {
162
+ return;
163
+ }
164
+ fail(cause);
165
+ return;
166
+ }
167
+ if (generation !== sequence || disposed) {
168
+ return;
169
+ }
170
+ if (reply.found === undefined) {
171
+ entries = [];
172
+ set({
173
+ status: "empty",
174
+ suggestions: [],
175
+ activeIndex: -1,
176
+ open: false,
177
+ error: undefined,
178
+ attribution: reply.attribution ?? profile.attribution,
179
+ announcement: profile.notFound,
180
+ });
181
+ return;
182
+ }
183
+ finish(reply.found, started);
184
+ };
185
+ const setQuery = (text) => {
186
+ if (disposed || text === state.query) {
187
+ return;
188
+ }
189
+ const generation = supersede();
190
+ const reading = profile.read(text, { limit, minChars, settings });
191
+ if (reading.kind === "incomplete") {
192
+ entries = [];
193
+ state = { ...idleState(profile.attribution), query: text, attribution: state.attribution };
194
+ notify();
195
+ return;
196
+ }
197
+ if (reading.kind !== "search") {
198
+ entries = [];
199
+ }
200
+ set({
201
+ query: text,
202
+ status: "loading",
203
+ error: undefined,
204
+ invalid: undefined,
205
+ resolved: undefined,
206
+ announcement: "",
207
+ ...(reading.kind === "search" ? {} : { suggestions: [], activeIndex: -1, open: false }),
208
+ });
209
+ timer = setTimeout(() => {
210
+ timer = undefined;
211
+ if (reading.kind === "invalid") {
212
+ set({
213
+ status: "invalid",
214
+ invalid: { reason: reading.reason, message: reading.message },
215
+ announcement: reading.message,
216
+ });
217
+ }
218
+ else if (reading.kind === "lookup") {
219
+ set({ status: "resolving", announcement: `${profile.working}.` });
220
+ run(lookup(generation, reading.run));
221
+ }
222
+ else {
223
+ run(search(generation, reading));
224
+ }
225
+ }, debounceMs);
226
+ };
227
+ const setActive = (index) => {
228
+ if (disposed || !state.open || index < -1 || index >= state.suggestions.length || index === state.activeIndex) {
229
+ return;
230
+ }
231
+ set({ activeIndex: index });
232
+ };
233
+ const moveActive = (delta) => {
234
+ const count = state.suggestions.length;
235
+ if (disposed || count === 0 || delta === 0) {
236
+ return;
237
+ }
238
+ if (!state.open) {
239
+ set({ open: true, activeIndex: delta > 0 ? 0 : count - 1 });
240
+ return;
241
+ }
242
+ const from = state.activeIndex;
243
+ let next = from + delta;
244
+ if (from === -1) {
245
+ next = delta > 0 ? 0 : count - 1;
246
+ }
247
+ else if (next >= count) {
248
+ next = 0;
249
+ }
250
+ else if (next < 0) {
251
+ next = count - 1;
252
+ }
253
+ set({ activeIndex: next });
254
+ };
255
+ const choose = (chosen, generation, text) => {
256
+ if (chosen.kind === "found") {
257
+ entries = [];
258
+ set({ query: text, suggestions: [], open: false, activeIndex: -1 });
259
+ finish(chosen.found, now());
260
+ return;
261
+ }
262
+ set({
263
+ query: text,
264
+ status: "resolving",
265
+ open: false,
266
+ activeIndex: -1,
267
+ error: undefined,
268
+ invalid: undefined,
269
+ announcement: `${profile.working}.`,
270
+ });
271
+ run(lookup(generation, chosen.run));
272
+ };
273
+ const select = (index) => {
274
+ if (disposed) {
275
+ return;
276
+ }
277
+ const chosen = entries[index ?? state.activeIndex];
278
+ if (chosen === undefined) {
279
+ return;
280
+ }
281
+ const generation = supersede();
282
+ choose(chosen.choose, generation, chosen.primary);
283
+ };
284
+ const close = () => {
285
+ if (disposed || (!state.open && state.activeIndex === -1)) {
286
+ return;
287
+ }
288
+ set({ open: false, activeIndex: -1 });
289
+ };
290
+ const open = () => {
291
+ if (disposed || state.open || state.suggestions.length === 0) {
292
+ return;
293
+ }
294
+ set({ open: true });
295
+ };
296
+ const retry = () => {
297
+ if (disposed || state.status !== "error") {
298
+ return;
299
+ }
300
+ retryAction?.();
301
+ };
302
+ const dispose = () => {
303
+ disposed = true;
304
+ supersede();
305
+ listeners.clear();
306
+ };
307
+ return {
308
+ getState: () => state,
309
+ subscribe: (listener) => {
310
+ listeners.add(listener);
311
+ return () => {
312
+ listeners.delete(listener);
313
+ };
314
+ },
315
+ setQuery,
316
+ moveActive,
317
+ setActive,
318
+ select,
319
+ close,
320
+ open,
321
+ retry,
322
+ dispose,
323
+ };
324
+ };
@@ -0,0 +1,180 @@
1
+ import type { Outcome } from "./errors.js";
2
+ import type { CallOptions } from "./http.js";
3
+ /** A call the profile prepared: it runs against the client when the typing has paused. */
4
+ export type Run<C, T> = (client: C, options: CallOptions) => Promise<T>;
5
+ /** One thing the service found, reduced to the text every UI shows and the record the consumer receives. */
6
+ export interface Found<R> {
7
+ /** The date or time of the data, when the reply says. */
8
+ readonly asAt: string | undefined;
9
+ /** The attribution this reply asks for, when it carries one. */
10
+ readonly attribution: string | undefined;
11
+ readonly record: R;
12
+ /** One line: what was found. */
13
+ readonly summary: string;
14
+ }
15
+ /** What choosing a suggestion does: use the record the search already returned, or look it up. */
16
+ export type Chosen<C, R> = {
17
+ readonly kind: "found";
18
+ readonly found: Found<R>;
19
+ } | {
20
+ readonly kind: "lookup";
21
+ readonly run: Run<C, Looked<R>>;
22
+ };
23
+ export interface Suggestion<C, R> {
24
+ readonly choose: Chosen<C, R>;
25
+ /** Unique in one list. */
26
+ readonly key: string;
27
+ readonly primary: string;
28
+ readonly secondary: string;
29
+ }
30
+ export interface Searched<C, R> {
31
+ readonly attribution: string | undefined;
32
+ readonly suggestions: readonly Suggestion<C, R>[];
33
+ }
34
+ export interface Looked<R> {
35
+ readonly attribution: string | undefined;
36
+ /** Undefined means the service answered and has no such record. */
37
+ readonly found: Found<R> | undefined;
38
+ }
39
+ /** What the profile makes of the typed text. */
40
+ export type Reading<C, R> =
41
+ /** Too little typed to ask. */
42
+ {
43
+ readonly kind: "incomplete";
44
+ }
45
+ /** A whole identifier was typed and it is not valid. Nothing is sent. */
46
+ | {
47
+ readonly kind: "invalid";
48
+ readonly message: string;
49
+ readonly reason: string;
50
+ } | {
51
+ readonly kind: "search";
52
+ readonly run: Run<C, Searched<C, R>>;
53
+ } | {
54
+ readonly kind: "lookup";
55
+ readonly run: Run<C, Looked<R>>;
56
+ };
57
+ export interface ReadContext {
58
+ /** Suggestions asked for. */
59
+ readonly limit: number;
60
+ /** Typed characters needed before a name search starts. */
61
+ readonly minChars: number;
62
+ /** Product settings the page gave (an attribute of the element, a prop of the component). */
63
+ readonly settings: Readonly<Record<string, string | undefined>>;
64
+ }
65
+ /** The only product-specific part of a field: written by hand in the package (src/local/profile.ts). */
66
+ export interface FieldProfile<C, R> {
67
+ /** The standard attribution, shown until a reply carries its own. */
68
+ readonly attribution: string;
69
+ /** The default visible label of the field. */
70
+ readonly label: string;
71
+ /** The accessible name of the list of suggestions. */
72
+ readonly listLabel: string;
73
+ /** What a reply that has no record says: "No company found." */
74
+ readonly notFound: string;
75
+ /** Singular, lower case: "company". Used in "3 company suggestions available." */
76
+ readonly noun: string;
77
+ readonly read: (text: string, context: ReadContext) => Reading<C, R>;
78
+ /** The service in a failure message: "Company search" gives "Company search is unavailable." */
79
+ readonly service: string;
80
+ /** Names of the settings the profile reads. The element observes them as attributes. */
81
+ readonly settings: readonly string[];
82
+ /** One word for the lookup in announcements: "Looking up the company." */
83
+ readonly working: string;
84
+ }
85
+ export type FieldStatus =
86
+ /** Nothing typed, or too little to ask. */
87
+ "idle"
88
+ /** Waiting for the typing to pause, or for a search. */
89
+ | "loading"
90
+ /** Suggestions are available. */
91
+ | "results"
92
+ /** The service answered and found nothing. */
93
+ | "empty"
94
+ /** A suggestion was chosen, or a whole identifier was typed, and the lookup is running. */
95
+ | "resolving"
96
+ /** A record was found. */
97
+ | "resolved"
98
+ /** A whole identifier was typed and it is not valid. Nothing was sent. */
99
+ | "invalid"
100
+ /** The call failed; `error` says how. */
101
+ | "error";
102
+ export interface FieldError {
103
+ readonly code: string;
104
+ /** What the technical error said, for a developer: never shown to the end user. */
105
+ readonly detail: string;
106
+ readonly message: string;
107
+ /** A lookup that failed is never shown as "no match". */
108
+ readonly outcome: Outcome;
109
+ readonly requestId: string | undefined;
110
+ readonly retryable: boolean;
111
+ }
112
+ export interface FieldTiming {
113
+ /** Milliseconds the lookup took, measured in the browser. */
114
+ readonly ms: number;
115
+ }
116
+ /** A suggestion as the UI shows it. */
117
+ export interface SuggestionView {
118
+ readonly key: string;
119
+ readonly primary: string;
120
+ readonly secondary: string;
121
+ }
122
+ export interface FieldState<R> {
123
+ /** Index of the highlighted suggestion, or -1. */
124
+ readonly activeIndex: number;
125
+ /** One sentence for assistive technology: what just happened. Empty when nothing needs saying. */
126
+ readonly announcement: string;
127
+ /** The attribution to show now: the one the last reply carried, or the standard one. */
128
+ readonly attribution: string;
129
+ readonly error: FieldError | undefined;
130
+ /** Set with the status "invalid": why the typed identifier is not valid. */
131
+ readonly invalid: {
132
+ readonly message: string;
133
+ readonly reason: string;
134
+ } | undefined;
135
+ /** Whether the list of suggestions is shown. */
136
+ readonly open: boolean;
137
+ readonly query: string;
138
+ readonly resolved: {
139
+ readonly found: Found<R>;
140
+ readonly timing: FieldTiming;
141
+ } | undefined;
142
+ readonly status: FieldStatus;
143
+ readonly suggestions: readonly SuggestionView[];
144
+ }
145
+ export interface FieldControllerOptions<C, R> {
146
+ /** Any client of the product's API: server, browser (relay) or fixtures. */
147
+ readonly client: C;
148
+ /** Pause after typing before a call starts, in milliseconds. 200 by default. */
149
+ readonly debounceMs?: number;
150
+ /** Suggestions asked for. 8 by default. */
151
+ readonly limit?: number;
152
+ /** Typed characters needed before a name search starts. 3 by default. */
153
+ readonly minChars?: number;
154
+ /** Milliseconds, for timing. performance.now by default. */
155
+ readonly now?: () => number;
156
+ /** Called once when a record has been found, after the state is updated. */
157
+ readonly onSelect?: (record: R, timing: FieldTiming) => void;
158
+ readonly profile: FieldProfile<C, R>;
159
+ /** Product settings (see `profile.settings`). */
160
+ readonly settings?: Readonly<Record<string, string | undefined>>;
161
+ }
162
+ export interface FieldController<R> {
163
+ close: () => void;
164
+ /** Stops every timer and call. The controller does nothing afterwards. */
165
+ dispose: () => void;
166
+ /** The current state. The same object until the state changes. */
167
+ getState: () => FieldState<R>;
168
+ /** Moves the highlight by `delta`, wrapping; opens the list first when it is closed. */
169
+ moveActive: (delta: number) => void;
170
+ open: () => void;
171
+ /** Runs again whatever failed last. */
172
+ retry: () => void;
173
+ /** Chooses the suggestion at `index`, or the highlighted one. */
174
+ select: (index?: number) => void;
175
+ setActive: (index: number) => void;
176
+ /** Text typed. */
177
+ setQuery: (text: string) => void;
178
+ /** Calls the listener after every change. Returns the function that stops it. */
179
+ subscribe: (listener: () => void) => () => void;
180
+ }
@@ -0,0 +1,4 @@
1
+ // Runtime template: copied into a generated client package as src/runtime/field-types.ts, with
2
+ // field-controller.ts and field-aria.ts, when the product has a field UI (generate.config.json,
3
+ // "ui"). The types of the headless field controller and of the per-product profile.
4
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comms-id/company",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client for the Comms.ID Company API: Australian companies from ASIC's public registers: search by name, look up one by ACN",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/browser.d.ts",
20
20
  "default": "./dist/browser.js"
21
21
  },
22
+ "./controller": {
23
+ "types": "./dist/controller.d.ts",
24
+ "default": "./dist/controller.js"
25
+ },
22
26
  "./fixtures": {
23
27
  "types": "./dist/fixtures.d.ts",
24
28
  "default": "./dist/fixtures.js"