@obinexusltd/obix-component-loading 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/01-overview.md +26 -0
- package/docs/02-usage.md +46 -0
- package/docs/03-accessibility.md +17 -0
- package/docs/04-state-and-html.md +22 -0
- package/package.json +58 -0
- package/src/index.ts +66 -0
- package/src/types.ts +29 -0
- package/test/loading.test.mjs +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OBINexus Computing — Nnamdi Michael Okpala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @obinexusltd/obix-component-loading
|
|
2
|
+
|
|
3
|
+
**The `ObixLoading` feedback component** — a `role="status"` spinner with an accessible label and an optional full-area `overlay`; starts hidden.
|
|
4
|
+
|
|
5
|
+
Split out of `@obinexusltd/obix-component-feedback` as an independent package.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @obinexusltd/obix-component-loading
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
> **Zero dependencies.** Data-Oriented: `createX(config)` returns
|
|
12
|
+
> `{ name, state, actions, render }`. Actions are pure
|
|
13
|
+
> `(state, …args) => newState`; `render(state)` is deterministic, HTML-escaped
|
|
14
|
+
> markup (an empty string while hidden). `renderX(config, overrides?)` renders
|
|
15
|
+
> in one call.
|
|
16
|
+
|
|
17
|
+
## API
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createLoading, renderLoading } from "@obinexusltd/obix-component-loading";
|
|
21
|
+
|
|
22
|
+
const el = createLoading({ /* see docs/02-usage.md */ });
|
|
23
|
+
el.render(el.state);
|
|
24
|
+
renderLoading({ /* config */ }); // one call
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Actions** — `show(state)` · `hide(state)` · `setLabel(state, label)`
|
|
28
|
+
|
|
29
|
+
## Documentation
|
|
30
|
+
|
|
31
|
+
| # | Guide |
|
|
32
|
+
|---|-------|
|
|
33
|
+
| 01 | [Overview](docs/01-overview.md) |
|
|
34
|
+
| 02 | [Usage & API](docs/02-usage.md) |
|
|
35
|
+
| 03 | [Accessibility](docs/03-accessibility.md) |
|
|
36
|
+
| 04 | [State & rendered HTML](docs/04-state-and-html.md) |
|
|
37
|
+
|
|
38
|
+
## Related
|
|
39
|
+
|
|
40
|
+
Part of the OBIX feedback set: `@obinexusltd/obix-component-``toast` · `alert` · `progress` · `loading`.
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT — OBINexus Computing
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DOPComponent, LoadingConfig, LoadingState } from "./types.js";
|
|
2
|
+
export type { Action, DOPComponent, LoadingConfig, LoadingState } from "./types.js";
|
|
3
|
+
export declare function createLoading(config?: LoadingConfig): DOPComponent<LoadingState>;
|
|
4
|
+
export declare function renderLoading(config?: LoadingConfig, overrides?: Partial<LoadingState>): string;
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE5E,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAsBpF,wBAAgB,aAAa,CAAC,MAAM,GAAE,aAAkB,GAAG,YAAY,CAAC,YAAY,CAAC,CAkBpF;AAGD,wBAAgB,aAAa,CAC3B,MAAM,GAAE,aAAkB,EAC1B,SAAS,GAAE,OAAO,CAAC,YAAY,CAAM,GACpC,MAAM,CAGR"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function esc(value) {
|
|
2
|
+
return String(value ?? "")
|
|
3
|
+
.replace(/&/g, "&")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, """);
|
|
7
|
+
}
|
|
8
|
+
function renderLoadingHtml(state) {
|
|
9
|
+
if (!state.visible)
|
|
10
|
+
return "";
|
|
11
|
+
return (`<div class="obix-loading${state.overlay ? " obix-loading--overlay" : ""}"` +
|
|
12
|
+
` role="status" aria-live="polite" aria-busy="${state.visible}"` +
|
|
13
|
+
` aria-label="${esc(state.label)}">` +
|
|
14
|
+
`<div class="obix-loading__spinner" aria-hidden="true"></div>` +
|
|
15
|
+
`<span class="obix-loading__label">${esc(state.label)}</span>` +
|
|
16
|
+
`</div>`);
|
|
17
|
+
}
|
|
18
|
+
export function createLoading(config = {}) {
|
|
19
|
+
if (!config || typeof config !== "object") {
|
|
20
|
+
throw new TypeError("[obix-component-loading] createLoading: config must be an object");
|
|
21
|
+
}
|
|
22
|
+
const state = {
|
|
23
|
+
visible: false,
|
|
24
|
+
label: config.label ?? "Loading…",
|
|
25
|
+
overlay: config.overlay ?? false,
|
|
26
|
+
};
|
|
27
|
+
const actions = {
|
|
28
|
+
show: (s) => ({ ...s, visible: true }),
|
|
29
|
+
hide: (s) => ({ ...s, visible: false }),
|
|
30
|
+
setLabel: (s, label) => ({ ...s, label: String(label) }),
|
|
31
|
+
};
|
|
32
|
+
return { name: "ObixLoading", state, actions, render: renderLoadingHtml };
|
|
33
|
+
}
|
|
34
|
+
export function renderLoading(config = {}, overrides = {}) {
|
|
35
|
+
const loading = createLoading(config);
|
|
36
|
+
return loading.render({ ...loading.state, ...overrides });
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkBA,SAAS,GAAG,CAAC,KAAc;IACzB,OAAO,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAmB;IAC5C,IAAI,CAAC,KAAK,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IAC9B,OAAO,CACL,2BAA2B,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,EAAE,GAAG;QAC3E,gDAAgD,KAAK,CAAC,OAAO,GAAG;QAChE,gBAAgB,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI;QACpC,8DAA8D;QAC9D,qCAAqC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,SAAS;QAC9D,QAAQ,CACT,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,SAAwB,EAAE;IACtD,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC1C,MAAM,IAAI,SAAS,CAAC,kEAAkE,CAAC,CAAC;IAC1F,CAAC;IAED,MAAM,KAAK,GAAiB;QAC1B,OAAO,EAAE,KAAK;QACd,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,UAAU;QACjC,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,KAAK;KACjC,CAAC;IAEF,MAAM,OAAO,GAAG;QACd,IAAI,EAAE,CAAC,CAAe,EAAgB,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAClE,IAAI,EAAE,CAAC,CAAe,EAAgB,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACnE,QAAQ,EAAE,CAAC,CAAe,EAAE,KAAc,EAAgB,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;KAC9F,CAAC;IAEF,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;AAC5E,CAAC;AAGD,MAAM,UAAU,aAAa,CAC3B,SAAwB,EAAE,EAC1B,YAAmC,EAAE;IAErC,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC,OAAO,OAAO,CAAC,MAAM,CAAC,EAAE,GAAG,OAAO,CAAC,KAAK,EAAE,GAAG,SAAS,EAAE,CAAC,CAAC;AAC5D,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Action<S> = (state: S, ...args: any[]) => S;
|
|
2
|
+
export interface DOPComponent<S> {
|
|
3
|
+
name: string;
|
|
4
|
+
state: S;
|
|
5
|
+
actions: Record<string, Action<S>>;
|
|
6
|
+
render: (state: S) => string;
|
|
7
|
+
}
|
|
8
|
+
export interface LoadingConfig {
|
|
9
|
+
label?: string;
|
|
10
|
+
overlay?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface LoadingState {
|
|
13
|
+
visible: boolean;
|
|
14
|
+
label: string;
|
|
15
|
+
overlay: boolean;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC;AAExD,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,CAAC,CAAC;IACT,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IACnC,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,aAAa;IAE5B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,YAAY;IAE3B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# ObixLoading — Overview
|
|
2
|
+
|
|
3
|
+
`@obinexusltd/obix-component-loading` is one feedback component from the OBIX component set, packaged
|
|
4
|
+
on its own. It is **Data-Oriented** and has **zero runtime dependencies**.
|
|
5
|
+
|
|
6
|
+
## The shape
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const loading = createLoading(config);
|
|
10
|
+
loading.name // "ObixLoading"
|
|
11
|
+
loading.state // the full component state (see docs/04)
|
|
12
|
+
loading.actions // pure (state, …args) => state transitions
|
|
13
|
+
loading.render // (state) => string — deterministic, HTML-escaped
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **No classes, no lifecycle, no DOM.** Data plus pure functions. You own
|
|
17
|
+
the timers, event wiring, and where the HTML string goes.
|
|
18
|
+
- **Deterministic render.** `render(state)` returns the same markup for the
|
|
19
|
+
same state, and an **empty string** while the component is hidden.
|
|
20
|
+
- **Pure actions.** `actions.foo(state, …)` returns a *new* state.
|
|
21
|
+
|
|
22
|
+
## When to use it
|
|
23
|
+
|
|
24
|
+
Use `@obinexusltd/obix-component-loading` when you want just this one piece of feedback UI without
|
|
25
|
+
pulling the whole feedback bundle.
|
|
26
|
+
|
package/docs/02-usage.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# ObixLoading — Usage & API
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { createLoading, renderLoading } from "@obinexusltd/obix-component-loading";
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
## `createLoading(config)`
|
|
8
|
+
|
|
9
|
+
Returns a `DOPComponent`: `{ name, state, actions, render }`. Throws
|
|
10
|
+
`TypeError` when a required field is missing or the wrong type.
|
|
11
|
+
|
|
12
|
+
| Field | Type | Default | Notes |
|
|
13
|
+
|-------|------|---------|-------|
|
|
14
|
+
| `label` | `string` | `"Loading…"` | `aria-label` + visible label text |
|
|
15
|
+
| `overlay` | `boolean` | `false` | Add `obix-loading--overlay` to cover the container |
|
|
16
|
+
|
|
17
|
+
The full `Config` / `State` interfaces are in
|
|
18
|
+
[`src/types.ts`](../src/types.ts), which ships in the package.
|
|
19
|
+
|
|
20
|
+
## `renderLoading(config, overrides?)`
|
|
21
|
+
|
|
22
|
+
One-call render — `createLoading(config).render({ ...state, ...overrides })`.
|
|
23
|
+
Pass `{ visible: true }` as an override to render a normally-hidden state for
|
|
24
|
+
snapshots / SSR:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
renderLoading(config, { visible: true });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Actions
|
|
31
|
+
|
|
32
|
+
`show(state)` · `hide(state)` · `setLabel(state, label)` — state starts `visible: false`.
|
|
33
|
+
|
|
34
|
+
Every action is `(state, …args) => newState` and **pure**. Thread state through
|
|
35
|
+
them (directly or via your store) and re-render:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const l = createLoading({ label: "Loading dashboard…", overlay: true });
|
|
39
|
+
mount.innerHTML = l.render(l.state); // "" — hidden by default
|
|
40
|
+
mount.innerHTML = l.render(l.actions.show(l.state)); // <div role="status" aria-busy="true">
|
|
41
|
+
// … when the fetch resolves:
|
|
42
|
+
mount.innerHTML = l.render(l.actions.hide(l.state)); // ""
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`render(state)` returns `""` whenever the component is not visible — assigning
|
|
46
|
+
it to `innerHTML` cleanly removes the element.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ObixLoading — Accessibility
|
|
2
|
+
|
|
3
|
+
The rendered HTML carries its ARIA inline — there is no separate a11y layer.
|
|
4
|
+
|
|
5
|
+
- Live-region semantics are built in: `role` / `aria-live` / `aria-atomic`
|
|
6
|
+
are set from state (e.g. assertive for errors, polite for status).
|
|
7
|
+
- All interpolated text (messages, labels) is HTML-escaped.
|
|
8
|
+
- Dismiss controls are real `<button type="button">` with an `aria-label`.
|
|
9
|
+
|
|
10
|
+
## Your responsibilities
|
|
11
|
+
|
|
12
|
+
- Insert the rendered string into a container that already exists in the DOM
|
|
13
|
+
so the live region is announced when content changes.
|
|
14
|
+
- Drive timers yourself (e.g. call `dismiss` after `state.duration` ms).
|
|
15
|
+
- Manage focus: for a dismissible alert, return focus to the triggering
|
|
16
|
+
element (`returnFocusId` is carried in state for you to use).
|
|
17
|
+
- Render one instance per container; give toasts a unique `id`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ObixLoading — State & rendered HTML
|
|
2
|
+
|
|
3
|
+
## State
|
|
4
|
+
|
|
5
|
+
The full state object is defined by the `State` interface in
|
|
6
|
+
[`src/types.ts`](../src/types.ts), built from `config` by
|
|
7
|
+
`createLoading`. Plain JSON-serialisable data.
|
|
8
|
+
|
|
9
|
+
## Rendered HTML
|
|
10
|
+
|
|
11
|
+
`render(state)` returns a single element as a string, or **`""`** when the
|
|
12
|
+
component is not visible. Attribute order is fixed; only state-driven
|
|
13
|
+
attributes/branches change between renders.
|
|
14
|
+
|
|
15
|
+
Class names follow the `obix-*` convention
|
|
16
|
+
(`obix-loading`, `obix-loading--<modifier>`).
|
|
17
|
+
No CSS ships with the package.
|
|
18
|
+
|
|
19
|
+
## Testing
|
|
20
|
+
|
|
21
|
+
`test/*.test.mjs` (Node’s built-in runner) asserts the rendered string and
|
|
22
|
+
action purity. Run `npm run build && npm test`.
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@obinexusltd/obix-component-loading",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OBIX ObixLoading — a role=\"status\" spinner with an accessible label and an optional full-area overlay; starts hidden. Data-Oriented, zero dependencies.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "OBINexus Computing — Nnamdi Michael Okpala <okpalan@protonmail.com>",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"private": false,
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./src": "./src/index.ts",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"dist",
|
|
23
|
+
"docs",
|
|
24
|
+
"test",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"directories": {
|
|
29
|
+
"lib": "dist",
|
|
30
|
+
"doc": "docs",
|
|
31
|
+
"test": "test"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
36
|
+
"prepublishOnly": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"obix",
|
|
40
|
+
"component",
|
|
41
|
+
"dop",
|
|
42
|
+
"accessibility",
|
|
43
|
+
"loading",
|
|
44
|
+
"spinner",
|
|
45
|
+
"busy",
|
|
46
|
+
"feedback"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20.11.0"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/obinexusmk2/obix-component-loading.git"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @obinexusltd/obix-component-loading
|
|
3
|
+
*
|
|
4
|
+
* The ObixLoading feedback component — a `role="status"` spinner with an
|
|
5
|
+
* accessible label and an optional full-area `overlay`. Starts hidden; call
|
|
6
|
+
* `show` to display it.
|
|
7
|
+
*
|
|
8
|
+
* Data-Oriented: `createLoading(config)` returns
|
|
9
|
+
* `{ name, state, actions, render }`. Actions are pure
|
|
10
|
+
* `(state, …args) => LoadingState`; `render(state)` is deterministic,
|
|
11
|
+
* HTML-escaped markup, or `""` while hidden. Zero dependencies.
|
|
12
|
+
*
|
|
13
|
+
* Split out of `@obinexusltd/obix-component-feedback`.
|
|
14
|
+
*/
|
|
15
|
+
import type { DOPComponent, LoadingConfig, LoadingState } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export type { Action, DOPComponent, LoadingConfig, LoadingState } from "./types.js";
|
|
18
|
+
|
|
19
|
+
function esc(value: unknown): string {
|
|
20
|
+
return String(value ?? "")
|
|
21
|
+
.replace(/&/g, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">")
|
|
24
|
+
.replace(/"/g, """);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderLoadingHtml(state: LoadingState): string {
|
|
28
|
+
if (!state.visible) return "";
|
|
29
|
+
return (
|
|
30
|
+
`<div class="obix-loading${state.overlay ? " obix-loading--overlay" : ""}"` +
|
|
31
|
+
` role="status" aria-live="polite" aria-busy="${state.visible}"` +
|
|
32
|
+
` aria-label="${esc(state.label)}">` +
|
|
33
|
+
`<div class="obix-loading__spinner" aria-hidden="true"></div>` +
|
|
34
|
+
`<span class="obix-loading__label">${esc(state.label)}</span>` +
|
|
35
|
+
`</div>`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createLoading(config: LoadingConfig = {}): DOPComponent<LoadingState> {
|
|
40
|
+
if (!config || typeof config !== "object") {
|
|
41
|
+
throw new TypeError("[obix-component-loading] createLoading: config must be an object");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const state: LoadingState = {
|
|
45
|
+
visible: false,
|
|
46
|
+
label: config.label ?? "Loading…",
|
|
47
|
+
overlay: config.overlay ?? false,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const actions = {
|
|
51
|
+
show: (s: LoadingState): LoadingState => ({ ...s, visible: true }),
|
|
52
|
+
hide: (s: LoadingState): LoadingState => ({ ...s, visible: false }),
|
|
53
|
+
setLabel: (s: LoadingState, label: unknown): LoadingState => ({ ...s, label: String(label) }),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { name: "ObixLoading", state, actions, render: renderLoadingHtml };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Render a loading indicator's HTML in one call, optionally with state overrides. */
|
|
60
|
+
export function renderLoading(
|
|
61
|
+
config: LoadingConfig = {},
|
|
62
|
+
overrides: Partial<LoadingState> = {},
|
|
63
|
+
): string {
|
|
64
|
+
const loading = createLoading(config);
|
|
65
|
+
return loading.render({ ...loading.state, ...overrides });
|
|
66
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @obinexusltd/obix-component-loading — types.
|
|
3
|
+
* Zero-dependency Data-Oriented component: { name, state, actions, render }.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
7
|
+
|
|
8
|
+
export type Action<S> = (state: S, ...args: any[]) => S;
|
|
9
|
+
|
|
10
|
+
export interface DOPComponent<S> {
|
|
11
|
+
name: string;
|
|
12
|
+
state: S;
|
|
13
|
+
actions: Record<string, Action<S>>;
|
|
14
|
+
render: (state: S) => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoadingConfig {
|
|
18
|
+
/** Accessible label. Default `"Loading…"`. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Cover the containing area with an overlay. Default `false`. */
|
|
21
|
+
overlay?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LoadingState {
|
|
25
|
+
/** Starts `false` — call `show` to display. */
|
|
26
|
+
visible: boolean;
|
|
27
|
+
label: string;
|
|
28
|
+
overlay: boolean;
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createLoading, renderLoading } from "../dist/index.js";
|
|
4
|
+
|
|
5
|
+
test("factory returns a DOP component shape", () => {
|
|
6
|
+
const el = createLoading({ label: "Loading" });
|
|
7
|
+
assert.equal(el.name, "ObixLoading");
|
|
8
|
+
assert.equal(typeof el.render, "function");
|
|
9
|
+
assert.equal(typeof el.actions, "object");
|
|
10
|
+
assert.equal(typeof el.render(el.state), "string");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("config is optional; a non-object arg throws", () => {
|
|
14
|
+
assert.doesNotThrow(() => createLoading());
|
|
15
|
+
assert.doesNotThrow(() => createLoading({}));
|
|
16
|
+
assert.throws(() => createLoading(42), TypeError);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("starts hidden -> render is an empty string until shown", () => {
|
|
20
|
+
const el = createLoading({ label: "Please wait" });
|
|
21
|
+
assert.equal(el.render(el.state), "");
|
|
22
|
+
const shown = el.actions.show(el.state);
|
|
23
|
+
assert.match(el.render(shown), /role="status"/);
|
|
24
|
+
assert.match(el.render(shown), /aria-busy="true"/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("render is deterministic and escapes the label", () => {
|
|
28
|
+
const a = renderLoading({ label: "A & B <x>" }, { visible: true });
|
|
29
|
+
const b = renderLoading({ label: "A & B <x>" }, { visible: true });
|
|
30
|
+
assert.equal(a, b);
|
|
31
|
+
assert.match(a, /A & B <x>/);
|
|
32
|
+
assert.doesNotMatch(a, /<x>/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("actions are pure - input state is untouched", () => {
|
|
36
|
+
const el = createLoading({ label: "x" });
|
|
37
|
+
const before = JSON.stringify(el.state);
|
|
38
|
+
for (const k of Object.keys(el.actions)) el.actions[k](el.state, "y");
|
|
39
|
+
assert.equal(JSON.stringify(el.state), before);
|
|
40
|
+
});
|