@camp.dev/bones 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/README.md +138 -0
- package/dist/core/attributes.d.mts +25 -0
- package/dist/core/attributes.mjs +36 -0
- package/dist/css/auto.css +1092 -0
- package/dist/css/bones.css +156 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/react/bones.d.mts +15 -0
- package/dist/react/bones.mjs +27 -0
- package/dist/react/create-bones.d.mts +23 -0
- package/dist/react/create-bones.mjs +73 -0
- package/dist/react/index.d.mts +4 -0
- package/dist/react/index.mjs +4 -0
- package/package.json +71 -0
- package/src/css/auto.css +1092 -0
- package/src/css/bones.css +156 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Bones
|
|
2
|
+
|
|
3
|
+
[](https://bundlejs.com/?q=%40camp.dev%2Fbones)
|
|
4
|
+
|
|
5
|
+
Skeleton loaders designed for React Server Components and streaming. ~2.7 kB gzipped, 0 dependencies.
|
|
6
|
+
|
|
7
|
+
With React Server Components, your component renders once on the server. There's no re-render from "loading" to "loaded," so `{data || <Skeleton />}` doesn't work anymore. The typical workaround is writing a separate skeleton component for every piece of UI and passing it as a Suspense fallback.
|
|
8
|
+
|
|
9
|
+
Bones skips the duplication. You write your markup once and it handles both states. The skeleton and the real UI are the same component, so they can't drift apart.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
`createBones` accepts data or a promise of data. While loading, its `bone` function returns HTML attributes that style elements as skeletons via CSS. Once the data resolves, `bone` returns an empty object and your component renders normally. There are no hooks and no context providers.
|
|
14
|
+
|
|
15
|
+
- Works in Server Components. No hooks, no context, no `'use client'`.
|
|
16
|
+
- One component handles both loading and loaded states.
|
|
17
|
+
- Pass a promise as a prop. Bones wires up Suspense for you.
|
|
18
|
+
- Skeletons are pure CSS, themed with custom properties.
|
|
19
|
+
- Loading elements get `aria-busy="true"` automatically.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @camp.dev/bones
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Import the CSS once in your root layout or entry point:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import "@camp.dev/bones/css";
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Entry points
|
|
34
|
+
|
|
35
|
+
| Import | Contents |
|
|
36
|
+
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
+
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
|
|
38
|
+
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
|
|
39
|
+
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
|
|
40
|
+
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
|
|
41
|
+
|
|
42
|
+
React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.
|
|
43
|
+
|
|
44
|
+
## Basic usage
|
|
45
|
+
|
|
46
|
+
Pass data (or a promise of data) to `createBones`. Spread the `bone` function's return value onto elements that should show skeletons while loading.
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { createBones } from "@camp.dev/bones/react";
|
|
50
|
+
|
|
51
|
+
function ProfileCard({ user }: { user: Promise<User> | User }) {
|
|
52
|
+
const { bone, data, lines } = createBones(user);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div>
|
|
56
|
+
<img src={data?.avatar} width={80} height={80} {...bone("block")} />
|
|
57
|
+
<h3 {...bone("text", { length: 10 })}>{data?.name}</h3>
|
|
58
|
+
{lines(data?.bio, 3, (item) => (
|
|
59
|
+
<p>{item}</p>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Wrap components that receive promises in `<Bones>`. It creates a Suspense boundary and generates the skeleton fallback for you:
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import { Bones } from "@camp.dev/bones/react";
|
|
70
|
+
|
|
71
|
+
export default function Page() {
|
|
72
|
+
return (
|
|
73
|
+
<Bones>
|
|
74
|
+
<ProfileCard user={fetchUser()} />
|
|
75
|
+
</Bones>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
While the promise is pending, `<Bones>` renders the same `<ProfileCard>` tree with skeletons visible. Once it resolves, the real content swaps in.
|
|
81
|
+
|
|
82
|
+
## Bone types
|
|
83
|
+
|
|
84
|
+
| Type | Use for | Example |
|
|
85
|
+
| ------------- | ------------------------------ | ------------------------------------ |
|
|
86
|
+
| `"text"` | Headings, paragraphs, labels | `<h2 {...bone("text")}>` |
|
|
87
|
+
| `"block"` | Images, avatars, thumbnails | `<img src={…} {...bone("block")} />` |
|
|
88
|
+
| `"container"` | Wrappers with complex children | `<div {...bone("container")}>` |
|
|
89
|
+
|
|
90
|
+
## Previewing skeletons
|
|
91
|
+
|
|
92
|
+
Use `forceBones` to see a component's skeleton state without setting up real data:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import { createBones, forceBones } from "@camp.dev/bones/react";
|
|
96
|
+
|
|
97
|
+
<ProfileCard user={forceBones} />;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
To force an entire subtree into skeleton mode at once, wrap it with `<BonesForce>`:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { BonesForce } from "@camp.dev/bones/react";
|
|
104
|
+
|
|
105
|
+
<BonesForce>
|
|
106
|
+
<ProfileCard />
|
|
107
|
+
<PostList />
|
|
108
|
+
</BonesForce>;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Automatic skeletons
|
|
112
|
+
|
|
113
|
+
For markup you haven't wired up with `bone()` — third-party components, server-rendered HTML, anything without explicit attributes — import the auto stylesheet. It imports `/css` itself, so this one file is a complete setup:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import "@camp.dev/bones/auto.css";
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Set `aria-busy="true"` on the loading region and every unmarked leaf inside it becomes a skeleton, no `bone()` calls required:
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<section aria-busy="true">
|
|
123
|
+
<h2>Title</h2>
|
|
124
|
+
<p>Summary text goes here.</p>
|
|
125
|
+
</section>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed.
|
|
129
|
+
|
|
130
|
+
Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
vp install # install dependencies
|
|
136
|
+
vp test # run tests
|
|
137
|
+
vp pack # build the library
|
|
138
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/core/attributes.d.ts
|
|
2
|
+
type BoneType = "text" | "block" | "container";
|
|
3
|
+
declare const MIN_MAX_BRAND: unique symbol;
|
|
4
|
+
interface MinMax {
|
|
5
|
+
readonly [MIN_MAX_BRAND]: true;
|
|
6
|
+
readonly min: number;
|
|
7
|
+
readonly max: number;
|
|
8
|
+
}
|
|
9
|
+
declare function minMax(min: number, max: number): MinMax;
|
|
10
|
+
declare function isMinMax(value: unknown): value is MinMax;
|
|
11
|
+
interface BoneOptions {
|
|
12
|
+
length?: number | MinMax;
|
|
13
|
+
contained?: boolean;
|
|
14
|
+
}
|
|
15
|
+
interface BoneAttributes {
|
|
16
|
+
"data-bone": BoneType;
|
|
17
|
+
"aria-busy": true;
|
|
18
|
+
src?: string;
|
|
19
|
+
style?: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
declare const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
22
|
+
declare function resolveLength(length: number | MinMax | undefined, callIndex: number): number | undefined;
|
|
23
|
+
declare function boneAttributes(type: BoneType, options?: BoneOptions, callIndex?: number): BoneAttributes;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { BoneAttributes, BoneOptions, BoneType, MinMax, TRANSPARENT_PIXEL, boneAttributes, isMinMax, minMax, resolveLength };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/core/attributes.ts
|
|
2
|
+
const MIN_MAX_BRAND = Symbol("minMax");
|
|
3
|
+
function minMax(min, max) {
|
|
4
|
+
return {
|
|
5
|
+
[MIN_MAX_BRAND]: true,
|
|
6
|
+
min,
|
|
7
|
+
max
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function isMinMax(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && MIN_MAX_BRAND in value;
|
|
12
|
+
}
|
|
13
|
+
const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
14
|
+
function resolveLength(length, callIndex) {
|
|
15
|
+
if (length == null) return void 0;
|
|
16
|
+
if (typeof length === "number") return length;
|
|
17
|
+
const range = length.max - length.min + 1;
|
|
18
|
+
return length.min + (callIndex * 7 + 3) % range;
|
|
19
|
+
}
|
|
20
|
+
function boneAttributes(type, options, callIndex = 0) {
|
|
21
|
+
const attrs = {
|
|
22
|
+
"data-bone": type,
|
|
23
|
+
"aria-busy": true
|
|
24
|
+
};
|
|
25
|
+
if (type === "text") {
|
|
26
|
+
const style = {};
|
|
27
|
+
if (options?.contained) style["--bone-contained"] = 1;
|
|
28
|
+
const length = resolveLength(options?.length, callIndex);
|
|
29
|
+
if (length) style["--bone-length"] = length;
|
|
30
|
+
if (Object.keys(style).length > 0) attrs.style = style;
|
|
31
|
+
}
|
|
32
|
+
if (type === "block") attrs.src = TRANSPARENT_PIXEL;
|
|
33
|
+
return attrs;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { TRANSPARENT_PIXEL, boneAttributes, isMinMax, minMax, resolveLength };
|