@comms-id/oric-react 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Copyright (c) 2026 COMMS.ID PTY LTD. Proprietary software; not open source.
2
+
3
+ Subject to the Comms.ID policy agreement, you may install, copy, bundle, run and
4
+ deliver this client or component within your applications, adapt the supplied
5
+ component source, and use the designated local functions and fixtures offline.
6
+ Hosted operations are provided by the Comms.ID service.
7
+
8
+ You may keep replies for your own customers and transactions, and your registered
9
+ application may relay requests for its own users.
10
+
11
+ You may not republish these packages as a standalone offering, resell access, offer
12
+ a substitute lookup service, or harvest replies to reconstruct or redistribute a
13
+ source dataset.
14
+
15
+ Preserve this notice and any applicable third-party notices.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @comms-id/oric-react
2
+
3
+ A React hook and a finished component for the Comms.ID Indigenous corporation field: the user types the name, ICN or ABN of an Aboriginal and Torres Strait Islander corporation. Accessible, no layout shift, with the data attribution always shown. React 18 or later; it depends on `@comms-id/oric` and nothing else.
4
+
5
+ ```tsx
6
+ import { createOricBrowserClient } from "@comms-id/oric/browser";
7
+ import { OricLookup } from "@comms-id/oric-react";
8
+
9
+ const client = createOricBrowserClient({ relayUrl: "/api/comms-id/oric" });
10
+
11
+ export const Form = () => (
12
+ <OricLookup
13
+ client={client}
14
+ onSelect={(record, timing) => console.log(record, `${timing.ms} ms`)}
15
+ />
16
+ );
17
+ ```
18
+
19
+ `relayUrl` is your own server route, set up with `@comms-id/relay`: your server signs the call as your registered app, so the browser holds no credential.
20
+
21
+ ## What the field does
22
+
23
+ - **A name:** after the user pauses, it suggests corporations (name, ICN, state and postcode, status) from the ORIC dataset snapshot. The search already returns each whole record, so choosing one needs no second call.
24
+ - **An ICN:** up to eight digits are looked up at once. Longer numbers that are not an ABN wait for more digits; more than eleven digits is refused in the browser.
25
+ - **An ABN:** eleven digits are looked up. An ABN can match more than one corporation, so the matches are listed as suggestions and the user chooses an ICN; the service never guesses a record.
26
+ - The result is one summary line (name, ICN, place, status) on a fixed note line, so the page never moves.
27
+ - "No corporation found." and "Corporation search is unavailable" are different messages; an outage offers "Try again".
28
+
29
+ ## Your own markup: `useOric`
30
+
31
+ ```tsx
32
+ const field = useOric({ client, onSelect });
33
+ return (
34
+ <>
35
+ <input {...field.getInputProps()} />
36
+ <ul {...field.getListboxProps("Matches")} hidden={!field.state.open}>
37
+ {field.state.suggestions.map((s, i) => (
38
+ <li key={s.key} {...field.getOptionProps(i)}>{s.primary}</li>
39
+ ))}
40
+ </ul>
41
+ <p {...field.getStatusProps()}>{field.state.announcement}</p>
42
+ </>
43
+ );
44
+ ```
45
+
46
+ The prop getters carry the WAI-ARIA combobox contract: roles, `aria-expanded`, `aria-controls`, `aria-activedescendant`, key handling (arrows, Enter, Escape, Tab) and a polite live region. Render the attribution yourself when you use the hook: it is `field.state.attribution`.
47
+
48
+ ## The component
49
+
50
+ Props: `client` (required), `label` (default "Corporation name, ICN or ABN"), `onSelect`, `minChars` (3), `debounceMs` (200), `limit` (8), `settings`, `idPrefix`, `name`, `placeholder`, `disabled`, `required`, `className`, `listLabel`, `showTiming` (true), `renderOption`.
51
+
52
+ Style it with the custom properties `--comms-id-text`, `--comms-id-surface`, `--comms-id-border`, `--comms-id-active`, `--comms-id-radius` or the `comms-id-oric__*` class names. It needs no stylesheet.
53
+
54
+ - The list floats under the input; opening it moves nothing. The note line has a reserved height.
55
+ - A failed lookup says why and offers "Try again"; it is never shown as "no match".
56
+ - It renders on the server with the same structure, and works under StrictMode.
57
+
58
+ ## Data attribution
59
+
60
+ Source: Aboriginal and Torres Strait Islander corporations dataset, Office of the Registrar of Indigenous Corporations (ORIC), via data.gov.au (CC BY 3.0 AU). The component always shows the attribution. Do not hide it.
61
+
62
+ The package is private until the publishing path exists (#515). `LICENSE` is the consumer client licence. `src/` is generated by `@comms-id/forge-transformers` and is never edited by hand.
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ import { type SuggestionView } from "./product.js";
3
+ import { type UseFieldOptions } from "./use-field.js";
4
+ export interface FieldProps extends UseFieldOptions {
5
+ readonly className?: string;
6
+ readonly disabled?: boolean;
7
+ /** The visible label of the field. The product's own wording by default. */
8
+ readonly label?: string;
9
+ /** The accessible name of the list of suggestions. */
10
+ readonly listLabel?: string;
11
+ readonly name?: string;
12
+ readonly placeholder?: string;
13
+ /** Your own content for one suggestion. */
14
+ readonly renderOption?: (suggestion: SuggestionView, state: {
15
+ readonly active: boolean;
16
+ }) => ReactNode;
17
+ readonly required?: boolean;
18
+ /** Show how long the lookup took. True by default. */
19
+ readonly showTiming?: boolean;
20
+ }
21
+ export declare const Field: (props: FieldProps) => ReactNode;
@@ -0,0 +1,90 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PRODUCT, PROFILE } from "./product.js";
3
+ import { useField } from "./use-field.js";
4
+ const HIDDEN_FROM_SIGHT = {
5
+ position: "absolute",
6
+ width: 1,
7
+ height: 1,
8
+ margin: -1,
9
+ padding: 0,
10
+ overflow: "hidden",
11
+ clip: "rect(0 0 0 0)",
12
+ whiteSpace: "nowrap",
13
+ border: 0,
14
+ };
15
+ const ROOT = { position: "relative", color: "var(--comms-id-text, inherit)", font: "inherit" };
16
+ const LABEL = { display: "block", marginBottom: 4 };
17
+ const FIELD = { position: "relative" };
18
+ const INPUT = {
19
+ boxSizing: "border-box",
20
+ width: "100%",
21
+ padding: "0.5rem 0.75rem",
22
+ font: "inherit",
23
+ color: "inherit",
24
+ background: "var(--comms-id-surface, #fff)",
25
+ border: "1px solid var(--comms-id-border, #767676)",
26
+ borderRadius: "var(--comms-id-radius, 4px)",
27
+ };
28
+ // The list floats under the input, so opening and closing it moves nothing else on the page.
29
+ const LIST = {
30
+ position: "absolute",
31
+ top: "100%",
32
+ left: 0,
33
+ right: 0,
34
+ zIndex: 1000,
35
+ maxHeight: "16rem",
36
+ overflowY: "auto",
37
+ margin: "4px 0 0",
38
+ padding: 0,
39
+ listStyle: "none",
40
+ background: "var(--comms-id-surface, #fff)",
41
+ color: "var(--comms-id-text, #111)",
42
+ border: "1px solid var(--comms-id-border, #767676)",
43
+ borderRadius: "var(--comms-id-radius, 4px)",
44
+ boxShadow: "0 4px 12px rgb(0 0 0 / 15%)",
45
+ };
46
+ const OPTION = { padding: "0.5rem 0.75rem", cursor: "pointer" };
47
+ // One line with a fixed height: a found record, a message, or an error never moves what follows.
48
+ const NOTE = {
49
+ minHeight: "1.6em",
50
+ margin: "4px 0 0",
51
+ fontSize: "0.85em",
52
+ lineHeight: "1.5em",
53
+ overflow: "hidden",
54
+ textOverflow: "ellipsis",
55
+ whiteSpace: "nowrap",
56
+ };
57
+ const ATTRIBUTION_TEXT = { margin: "2px 0 0", fontSize: "0.75em", lineHeight: 1.4, opacity: 0.8 };
58
+ const FOOTER = {
59
+ ...ATTRIBUTION_TEXT,
60
+ padding: "0.5rem 0.75rem",
61
+ borderTop: "1px solid var(--comms-id-border, #767676)",
62
+ };
63
+ const defaultOption = (suggestion) => (_jsxs(_Fragment, { children: [_jsx("span", { className: `comms-id-${PRODUCT}__name`, children: suggestion.primary }), _jsx("span", { className: `comms-id-${PRODUCT}__secondary`, style: { display: "block", fontSize: "0.85em", opacity: 0.75 }, children: suggestion.secondary })] }));
64
+ const noteFor = (state, showTiming, retry) => {
65
+ if (state.status === "error" && state.error !== undefined) {
66
+ return (_jsxs(_Fragment, { children: [state.error.message, " ", state.error.retryable ? (_jsx("button", { className: `comms-id-${PRODUCT}__retry`, onClick: retry, type: "button", children: "Try again" })) : null] }));
67
+ }
68
+ if (state.status === "invalid" && state.invalid !== undefined) {
69
+ return state.invalid.message;
70
+ }
71
+ if (state.status === "empty") {
72
+ return PROFILE.notFound;
73
+ }
74
+ if (state.status === "resolved" && state.resolved !== undefined) {
75
+ const found = state.resolved.found.summary;
76
+ return showTiming ? `${found} (${state.resolved.timing.ms} ms)` : found;
77
+ }
78
+ return null;
79
+ };
80
+ export const Field = (props) => {
81
+ const { label, name, placeholder, disabled, required, className, listLabel, showTiming, renderOption, ...options } = props;
82
+ const field = useField(options);
83
+ const { state } = field;
84
+ const note = noteFor(state, showTiming !== false, field.retry);
85
+ const base = `comms-id-${PRODUCT}`;
86
+ return (_jsxs("div", { className: className === undefined ? base : `${base} ${className}`, style: ROOT, children: [_jsx("label", { className: `${base}__label`, htmlFor: field.ids.input, style: LABEL, children: label ?? PROFILE.label }), _jsxs("div", { style: FIELD, children: [_jsx("input", { ...field.getInputProps(), className: `${base}__input`, disabled: disabled, name: name, placeholder: placeholder, required: required, style: INPUT }), _jsxs("ul", { ...field.getListboxProps(listLabel ?? PROFILE.listLabel), className: `${base}__list`, hidden: !state.open, style: LIST, children: [state.suggestions.map((suggestion, index) => {
87
+ const active = index === state.activeIndex;
88
+ return (_jsx("li", { ...field.getOptionProps(index), className: `${base}__option`, style: { ...OPTION, background: active ? "var(--comms-id-active, #e8f0fe)" : "transparent" }, children: renderOption === undefined ? defaultOption(suggestion) : renderOption(suggestion, { active }) }, suggestion.key));
89
+ }), _jsx("li", { "aria-hidden": "true", className: `${base}__list-attribution`, style: FOOTER, children: state.attribution })] })] }), _jsx("span", { ...field.getStatusProps(), style: HIDDEN_FROM_SIGHT, children: state.announcement }), _jsx("p", { "aria-hidden": "true", className: `${base}__note`, style: NOTE, children: note }), _jsx("p", { className: `${base}__attribution`, style: ATTRIBUTION_TEXT, children: state.attribution })] }));
90
+ };
@@ -0,0 +1,37 @@
1
+ export type { Attributes, OricClient as Client, OricController as Controller, OricControllerOptions as ControllerOptions, OricRecord as ProductRecord, OricState as State, FieldIds as Ids, SuggestionView, } from "@comms-id/oric/controller";
2
+ export { createOricController as createController, handleKey, idleState, inputAttributes, listboxAttributes, optionAttributes, statusAttributes, } from "@comms-id/oric/controller";
3
+ import type { OricClient } from "@comms-id/oric/controller";
4
+ export declare const PRODUCT = "oric";
5
+ export declare const TAG = "comms-id-oric";
6
+ export declare const PROFILE: import("@comms-id/oric/controller").FieldProfile<OricClient, {
7
+ readonly icn: string;
8
+ readonly name: string;
9
+ readonly statusReason: string | null;
10
+ readonly registeredOn: string | null;
11
+ readonly deregisteredOn: string | null;
12
+ readonly corporationSize: string | null;
13
+ readonly abn: string | null;
14
+ readonly state: string | null;
15
+ readonly postcode: string | null;
16
+ readonly industrySectors: readonly string[];
17
+ readonly acncRegistered: boolean | null;
18
+ readonly financials: ReadonlyArray<{
19
+ readonly year: number;
20
+ readonly totalIncome: string | null;
21
+ readonly totalAssets: string | null;
22
+ readonly employees: string | null;
23
+ }>;
24
+ readonly portalUrl: string | null;
25
+ readonly portalId: string | null;
26
+ readonly liveObservation?: {
27
+ readonly source: "oric-portal-observation";
28
+ readonly observedAt: string;
29
+ readonly baseGeneration: string;
30
+ readonly fields: readonly string[];
31
+ readonly fieldObservedAt: Readonly<Record<string, string>>;
32
+ };
33
+ }>;
34
+ /** The operations of Comms.ID Indigenous corporation. A capability is for one of them. */
35
+ export type VisitorOperation = "lookup" | "search";
36
+ /** A client that asks `get` for the client of each call: a new client, relay or key takes effect at once. */
37
+ export declare const routeClient: (get: (operation: VisitorOperation) => OricClient) => OricClient;
@@ -0,0 +1,12 @@
1
+ // Generated by @comms-id/forge-transformers from the service's OpenAPI contract. Do not edit: change the contract and regenerate.
2
+ // Names the product for the templates next to this file: they import only from here.
3
+ import { profile } from "@comms-id/oric/controller";
4
+ export { createOricController as createController, handleKey, idleState, inputAttributes, listboxAttributes, optionAttributes, statusAttributes, } from "@comms-id/oric/controller";
5
+ export const PRODUCT = "oric";
6
+ export const TAG = "comms-id-oric";
7
+ export const PROFILE = profile;
8
+ /** A client that asks `get` for the client of each call: a new client, relay or key takes effect at once. */
9
+ export const routeClient = (get) => ({
10
+ lookup: (input, options) => get("lookup").lookup(input, options),
11
+ search: (input, options) => get("search").search(input, options),
12
+ });
@@ -0,0 +1,23 @@
1
+ import { type ControllerOptions, type Ids, type State } from "./product.js";
2
+ export interface UseFieldOptions extends ControllerOptions {
3
+ /** Prefix of the element ids. A generated one by default; set it to name the input yourself. */
4
+ readonly idPrefix?: string;
5
+ }
6
+ type Props = Record<string, unknown>;
7
+ export interface UseFieldResult {
8
+ close: () => void;
9
+ /** Props for the text input: role, aria-*, value and the handlers. */
10
+ getInputProps: () => Props;
11
+ /** Props for the list element (role listbox). It must always be rendered; hide it while closed. */
12
+ getListboxProps: (label: string) => Props;
13
+ /** Props for the option at `index` (role option). */
14
+ getOptionProps: (index: number) => Props;
15
+ /** Props for the polite live region that says what just happened. */
16
+ getStatusProps: () => Props;
17
+ readonly ids: Ids;
18
+ retry: () => void;
19
+ select: (index?: number) => void;
20
+ readonly state: State;
21
+ }
22
+ export declare const useField: (options: UseFieldOptions) => UseFieldResult;
23
+ export {};
@@ -0,0 +1,76 @@
1
+ // Template: copied unchanged into a generated React package as src/generated/use-field.ts.
2
+ // The headless hook: the field controller as React state, plus prop getters that carry the
3
+ // WAI-ARIA combobox contract. Use it with your own markup, or use the finished component.
4
+ import { useEffect, useId, useRef, useState, useSyncExternalStore, } from "react";
5
+ import { createController, handleKey, idleState, inputAttributes, listboxAttributes, optionAttributes, PRODUCT, PROFILE, routeClient, statusAttributes, } from "./product.js";
6
+ const EMPTY = { getState: () => idleState(PROFILE.attribution), subscribe: () => () => undefined };
7
+ // React's names for three attributes the framework-neutral helpers write in HTML case.
8
+ const REACT_NAMES = {
9
+ autocomplete: "autoComplete",
10
+ autocapitalize: "autoCapitalize",
11
+ spellcheck: "spellCheck",
12
+ };
13
+ const toProps = (attributes) => Object.fromEntries(Object.entries(attributes)
14
+ .filter(([, value]) => value !== undefined)
15
+ .map(([name, value]) => [REACT_NAMES[name] ?? name, value]));
16
+ const COLONS = /:/g;
17
+ export const useField = (options) => {
18
+ const generated = useId().replace(COLONS, "");
19
+ const prefix = options.idPrefix ?? `comms-id-${PRODUCT}-${generated}`;
20
+ const ids = { input: `${prefix}-input`, listbox: `${prefix}-list`, status: `${prefix}-status` };
21
+ // The latest options, read at call time, so a new client or callback never recreates the controller.
22
+ const latest = useRef(options);
23
+ useEffect(() => {
24
+ latest.current = options;
25
+ });
26
+ const [controller, setController] = useState(undefined);
27
+ const { minChars, debounceMs, limit } = options;
28
+ const settingsKey = JSON.stringify(options.settings ?? {});
29
+ useEffect(() => {
30
+ const created = createController({
31
+ // Every call goes to the client of the moment.
32
+ client: routeClient(() => latest.current.client),
33
+ ...(minChars === undefined ? {} : { minChars }),
34
+ ...(debounceMs === undefined ? {} : { debounceMs }),
35
+ ...(limit === undefined ? {} : { limit }),
36
+ settings: JSON.parse(settingsKey),
37
+ ...(latest.current.now === undefined ? {} : { now: () => latest.current.now?.() ?? 0 }),
38
+ onSelect: (record, timing) => latest.current.onSelect?.(record, timing),
39
+ });
40
+ setController(created);
41
+ return () => {
42
+ created.dispose();
43
+ setController(undefined);
44
+ };
45
+ }, [minChars, debounceMs, limit, settingsKey]);
46
+ const store = controller ?? EMPTY;
47
+ const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
48
+ return {
49
+ state,
50
+ ids,
51
+ getInputProps: () => ({
52
+ ...toProps(inputAttributes(state, ids)),
53
+ value: state.query,
54
+ onChange: (event) => controller?.setQuery(event.target.value),
55
+ onKeyDown: (event) => {
56
+ if (controller !== undefined && handleKey(controller, event.key)) {
57
+ event.preventDefault();
58
+ }
59
+ },
60
+ onFocus: () => controller?.open(),
61
+ onBlur: () => controller?.close(),
62
+ }),
63
+ getListboxProps: (label) => toProps(listboxAttributes(ids, label)),
64
+ getOptionProps: (index) => ({
65
+ ...toProps(optionAttributes(state, ids, index)),
66
+ // The input keeps focus while the pointer chooses an option.
67
+ onMouseDown: (event) => event.preventDefault(),
68
+ onMouseMove: () => controller?.setActive(index),
69
+ onClick: () => controller?.select(index),
70
+ }),
71
+ getStatusProps: () => toProps(statusAttributes(ids)),
72
+ select: (index) => controller?.select(index),
73
+ retry: () => controller?.retry(),
74
+ close: () => controller?.close(),
75
+ };
76
+ };
@@ -0,0 +1,5 @@
1
+ export type { OricRecord, OricState, SuggestionView as OricSuggestion } from "@comms-id/oric/controller";
2
+ export type { FieldProps as OricLookupProps } from "./generated/field.js";
3
+ export { Field as OricLookup } from "./generated/field.js";
4
+ export type { UseFieldOptions as UseOricOptions, UseFieldResult as UseOricResult } from "./generated/use-field.js";
5
+ export { useField as useOric } from "./generated/use-field.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Generated by @comms-id/forge-transformers from the service's OpenAPI contract. Do not edit: change the contract and regenerate.
2
+ export { Field as OricLookup } from "./generated/field.js";
3
+ export { useField as useOric } from "./generated/use-field.js";
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@comms-id/oric-react",
3
+ "version": "0.2.0",
4
+ "description": "React hook and component for the Comms.ID Indigenous corporation field: Indigenous corporation name, ICN or ABN, accessible, no layout shift, with the data attribution",
5
+ "homepage": "https://comms.id",
6
+ "bugs": {
7
+ "url": "https://github.com/comms-id/platform/issues"
8
+ },
9
+ "license": "SEE LICENSE IN LICENSE",
10
+ "author": "COMMS.ID PTY LTD",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/comms-id/platform.git",
14
+ "directory": "packages/oric-react"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "LICENSE",
19
+ "README.md"
20
+ ],
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org"
34
+ },
35
+ "dependencies": {
36
+ "@comms-id/oric": "0.3.0"
37
+ },
38
+ "peerDependencies": {
39
+ "react": ">=18"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "24.19.0",
43
+ "@types/react": "19.3.0",
44
+ "@types/react-dom": "19.3.0",
45
+ "happy-dom": "20.14.5",
46
+ "react": "19.3.0",
47
+ "react-dom": "19.3.0",
48
+ "typescript": "7.0.2",
49
+ "vitest": "5.0.2"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "lint": "biome check .",
54
+ "check-types": "tsc -p tsconfig.json --noEmit",
55
+ "test": "vitest run"
56
+ }
57
+ }