loadshape 0.0.0-stage → 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/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # loadshape
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ff154bc: Add React 18 support alongside React 19, preserving forwarded refs, callback cleanup, and inert loading roots.
8
+ - 6426e69: Add Skeleton.Repeat to render a placeholder template multiple times without extra DOM wrappers. The application keeps ownership of placeholder data, loading state, and layout.
9
+
10
+ ## 0.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - Первый публичный Skeleton для React 19: Root и Static с render и ref, заглушка из настоящего компонента, inert и CSS-переменные. В поставку входят ES modules, TypeScript declarations, отдельный CSS в @layer loadshape, примеры подключения и лицензия MIT. Скилл интеграции поставляется в репозитории отдельно от npm-пакета.
15
+ - b6e45b5: Добавлен RouteProgress с неопределённым прогрессом, общей задержкой появления и поддержкой reduced-motion. Отдельный импорт loadshape/tanstack-router подключает полосу к TanStack Router через optional peer; основной импорт библиотеки остаётся независимым от роутера.
16
+ - fc1e157: Добавлен Spinner с размером и цветом из окружения, пропами render и label и поддержкой reduced-motion. Переменная --loadshape_delay задаёт общую задержку появления Skeleton и Spinner; частные --skeleton_delay и --spinner_delay сохраняют приоритет.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Semyon Surkov
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 CHANGED
@@ -1,3 +1,270 @@
1
- # Temporary Holding Version
1
+ # loadshape
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Loading states for React 18 and 19 that use your component's layout and your application's visual tokens.
4
+ Skeleton paints the rendered content, Spinner inherits its size and color, and RouteProgress
5
+ indicates navigation without inventing a percentage.
6
+
7
+ ## Install
8
+
9
+ Requires React 18 or 19.
10
+
11
+ ```sh
12
+ pnpm add loadshape
13
+ ```
14
+
15
+ Import CSS once at the application entry:
16
+
17
+ ```tsx
18
+ import 'loadshape/styles.css';
19
+ ```
20
+
21
+ Styles live in `@layer loadshape`. There is no theme or runtime stylesheet injection.
22
+ The core entry depends only on React; using it does not require TanStack Router.
23
+
24
+ ## Skeleton
25
+
26
+ Render the same component in both states, using realistic placeholder data while loading:
27
+
28
+ ```tsx
29
+ import { Skeleton } from 'loadshape';
30
+
31
+ type Post = { id: string; title: string; excerpt: string };
32
+
33
+ const PLACEHOLDER_POSTS: Post[] = [
34
+ { id: 'placeholder-1', title: 'A typical article title', excerpt: 'A representative summary.' },
35
+ { id: 'placeholder-2', title: 'Another article title', excerpt: 'A slightly longer summary.' },
36
+ ];
37
+
38
+ export function PostList({ data, loading }: { data?: Post[]; loading: boolean }) {
39
+ const posts = data ?? PLACEHOLDER_POSTS;
40
+
41
+ return (
42
+ <section aria-busy={loading || undefined}>
43
+ <Skeleton.Root loading={loading}>
44
+ <Skeleton.Static render={<h2 />}>Recent posts</Skeleton.Static>
45
+ <ul>
46
+ {posts.map((post) => (
47
+ <li key={post.id}>
48
+ <h3>{post.title}</h3>
49
+ <p>{post.excerpt}</p>
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ </Skeleton.Root>
54
+ </section>
55
+ );
56
+ }
57
+ ```
58
+
59
+ Keep the application's existing error and empty branches. Placeholder values must satisfy its
60
+ real types and formatters, including dates and enums. With TanStack Query, provide placeholderData
61
+ and pass isPlaceholderData to loading: placeholder data puts the query in success, so isPending
62
+ is false. Preserve cached real data during background refresh and respect disabled queries.
63
+
64
+ ### Repeated placeholders
65
+
66
+ `Skeleton.Repeat` repeats a render function without adding a DOM wrapper:
67
+
68
+ ```tsx
69
+ const PLACEHOLDER_COUNT = 8;
70
+ const PLACEHOLDER_POST = {
71
+ title: 'A representative article title',
72
+ excerpt: 'A typical summary with realistic length.',
73
+ };
74
+
75
+ <section aria-busy={loading || undefined}>
76
+ <Skeleton.Root loading={loading} render={<ul className={styles.list} />}>
77
+ {loading ? (
78
+ <Skeleton.Repeat count={PLACEHOLDER_COUNT}>
79
+ {(index) => (
80
+ <li>
81
+ <PostCard post={PLACEHOLDER_POST} position={index + 1} />
82
+ </li>
83
+ )}
84
+ </Skeleton.Repeat>
85
+ ) : (
86
+ posts.map((post) => (
87
+ <li key={post.id}>
88
+ <PostCard post={post} />
89
+ </li>
90
+ ))
91
+ )}
92
+ </Skeleton.Root>
93
+ </section>;
94
+ ```
95
+
96
+ `count` is a required non-negative safe integer. Zero renders nothing and does not call
97
+ `children`; invalid counts throw `RangeError`. `children(index)` receives a zero-based index.
98
+ Repeat supplies stable positional keys, so the returned placeholder needs no key.
99
+ For real data, keep entity keys in your normal map.
100
+
101
+ Repeat does not enable skeleton loading, choose data, create list elements, set `aria-busy`,
102
+ or apply layout and spacing. Keep those in the application. If the repeated component already
103
+ contains `Skeleton.Root`, pass its loading prop instead of adding another Root.
104
+
105
+ ### Root and Static
106
+
107
+ | Component | Props | Default element |
108
+ | ----------------- | ----------------------------------------------------------------------------------- | ------------------------------- |
109
+ | `Skeleton.Root` | Required `loading: boolean`, `render`, HTML attributes, `className`, `style`, `ref` | `div` |
110
+ | `Skeleton.Static` | `render`, HTML attributes, `className`, `style`, `ref` | `span` with `display: contents` |
111
+
112
+ Use render to apply the mechanism to an existing element:
113
+
114
+ ```tsx
115
+ <Skeleton.Root loading={loading} render={<table className={styles.table} />}>
116
+ <Skeleton.Static render={<thead />}>
117
+ <tr>
118
+ <th>Name</th>
119
+ <th>Total</th>
120
+ </tr>
121
+ </Skeleton.Static>
122
+ <tbody>{rows}</tbody>
123
+ </Skeleton.Root>
124
+ ```
125
+
126
+ Supply valid elements inside tables, lists, and select; a default span is not a valid child there.
127
+ Custom components passed through render must forward HTML attributes and ref to their DOM root.
128
+ In React 18, wrap these components in forwardRef; React 19 also supports ref as a prop.
129
+ Static preserves its whole subtree, so include only content known without the request. Put a
130
+ dynamic value in its own element beside a static label. A static element anywhere inside a text
131
+ block turns off that block's text lines, because an ancestor's underline would be drawn across it;
132
+ keep Static a direct child of the block and wrap dynamic values in their own elements.
133
+
134
+ Root becomes inert while loading. Place aria-busy on the application's container outside Root,
135
+ since inert removes Root and its descendants from focus and the accessibility tree. Controls
136
+ that remain usable, such as cancellation, belong outside Root even if they are visually static.
137
+
138
+ ### Attributes
139
+
140
+ | Attribute | Meaning |
141
+ | ------------------------- | ---------------------------------------------------- |
142
+ | `data-skeleton="loading"` | Enables the loading paint on Root |
143
+ | `data-skeleton-static` | Preserves a request-independent subtree |
144
+ | `data-skeleton-bone` | Makes an intentionally empty element a bone |
145
+ | `data-skeleton-contents` | Gives the default Static wrapper `display: contents` |
146
+
147
+ Skeleton paints text lines, media, and controls. Container backgrounds, borders, spacing, and SVG
148
+ icons remain visible. It preserves the rendered tree's geometry; it cannot predict unknown text
149
+ length or the number of rows in a response.
150
+
151
+ Reserve image dimensions or aspect-ratio in the component. Give inputs, selects, and buttons the
152
+ application's styling: painting an unstyled native control can change its size in WebKit. A chart
153
+ whose content is entirely SVG needs an application-owned loading representation.
154
+
155
+ ## Spinner
156
+
157
+ ```tsx
158
+ import { Spinner } from 'loadshape';
159
+
160
+ <Spinner />;
161
+ <Spinner label="Loading the report" />;
162
+ ```
163
+
164
+ Spinner defaults to span and accepts label, render, HTML attributes, className, style, and a forwarded
165
+ ref. Its ring is 1em and currentColor; reserve its slot or match the replaced icon to preserve
166
+ layout. It appears after the shared delay and slows rotation threefold under reduced-motion.
167
+
168
+ Without label it is decorative (`aria-hidden`). With label it has role=status and a visually
169
+ hidden text child. Mounting a live region with its initial text does not guarantee an announcement;
170
+ keep an application status region mounted before updating its text when that is required.
171
+
172
+ ## RouteProgress
173
+
174
+ Use the general component with any router or navigation loading signal:
175
+
176
+ ```tsx
177
+ import { RouteProgress } from 'loadshape';
178
+
179
+ <RouteProgress pending={pending} className={styles.progress} />;
180
+ ```
181
+
182
+ It defaults to div and accepts required pending, render, HTML attributes, className, style, and
183
+ forwarded ref. Keep it mounted in the navigation layout. The application owns positioning, width,
184
+ stacking order, color, and accessible status. In normal flow it reserves its height while hidden.
185
+ Completion and cancellation hide it immediately. Reduced-motion shows a stationary full-width bar.
186
+
187
+ If a new operation replaces a pending operation without an intermediate false, give the general
188
+ component a new React key to restart its appearance delay.
189
+
190
+ ### TanStack Router
191
+
192
+ Install the optional peer `@tanstack/react-router` at a compatible `^1.170.41` version, then use
193
+ the adapter inside RouterProvider's persistent root layout:
194
+
195
+ ```tsx
196
+ import { Outlet } from '@tanstack/react-router';
197
+ import { RouteProgress } from 'loadshape/tanstack-router';
198
+
199
+ function RootLayout() {
200
+ return (
201
+ <>
202
+ <RouteProgress className={styles.progress} />
203
+ <Outlet />
204
+ </>
205
+ );
206
+ }
207
+ ```
208
+
209
+ The adapter owns pending and resets the delay when the destination href changes. Its remaining
210
+ props match the general component. Both components are decorative; the application owns aria-busy
211
+ and any accessible navigation status.
212
+
213
+ ## CSS variables
214
+
215
+ Override variables using the application's tokens:
216
+
217
+ ```css
218
+ .progress {
219
+ position: fixed;
220
+ inset-block-start: 0;
221
+ inset-inline: 0;
222
+ color: var(--accent);
223
+ --route_progress_thickness: var(--spacing_half);
224
+ }
225
+ ```
226
+
227
+ | Variable | Default |
228
+ | ---------------------------- | ---------------------------------------------------- |
229
+ | `--loadshape_delay` | `150ms` |
230
+ | `--skeleton_delay` | Shared delay |
231
+ | `--skeleton_color` | `color-mix(in oklab, currentColor 14%, transparent)` |
232
+ | `--skeleton_duration` | `1.4s` |
233
+ | `--skeleton_shimmer_opacity` | `0.35` |
234
+ | `--spinner_delay` | Shared delay |
235
+ | `--spinner_size` | `1em` |
236
+ | `--spinner_thickness` | `0.125em` |
237
+ | `--spinner_duration` | `0.8s` |
238
+ | `--route_progress_delay` | Shared delay |
239
+ | `--route_progress_thickness` | `0.125em` |
240
+ | `--route_progress_duration` | `1.4s` |
241
+
242
+ Component delays override the shared delay. All three components wait before appearing, so short
243
+ loads do not flash. Skeleton shimmer stops under reduced-motion. Variables starting with `--_`
244
+ are internal implementation details.
245
+
246
+ ## Compatibility and scope
247
+
248
+ ES modules and TypeScript declarations are supplied for both JavaScript entry points. CSS must
249
+ be imported separately. CommonJS and legacy CSS engines are outside this release's scope.
250
+ The styles use modern CSS including layers, :has(), color-mix(), masks, and the rotate property.
251
+ Checks cover current Chromium and WebKit, without claiming every browser or screen reader version.
252
+
253
+ loadshape provides loading paint and indicators. Fetching, error and empty states, visual tokens,
254
+ and predicting unknown content remain application responsibilities.
255
+
256
+ ## Local checkout
257
+
258
+ To try an unreleased build, pack the library and install the archive:
259
+
260
+ ```sh
261
+ pnpm --filter loadshape build
262
+ pnpm --filter loadshape pack --pack-destination /tmp
263
+ # In the consuming application:
264
+ pnpm add /tmp/loadshape-0.2.0.tgz
265
+ ```
266
+
267
+ Use pnpm for the archive: npm does not apply `publishConfig.exports` and leaves `source` conditions
268
+ and no license file in the tarball.
269
+
270
+ License: MIT.
@@ -0,0 +1,81 @@
1
+ import { cloneElement, forwardRef, useMemo, version } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ const IS_REACT_19 = Number.parseInt(version, 10) >= 19;
4
+ //#endregion
5
+ //#region src/utils/useRenderElement.ts
6
+ const EVENT_HANDLER = /^on[A-Z]/;
7
+ /**
8
+ * Слитый ref возвращает общую очистку: колбэк-ref в React 19 может вернуть свою очистку, и без
9
+ * этого подписка из него (например, observer.disconnect) копилась бы с каждым рендером.
10
+ */
11
+ function mergeRefs(refs) {
12
+ let cleanups = [];
13
+ const cleanup = () => {
14
+ const pending = cleanups;
15
+ cleanups = [];
16
+ for (const dispose of pending) dispose();
17
+ };
18
+ return (node) => {
19
+ cleanup();
20
+ if (node === null) return;
21
+ cleanups = refs.flatMap((ref) => {
22
+ if (typeof ref === "function") {
23
+ const dispose = ref(node);
24
+ return [typeof dispose === "function" ? dispose : () => ref(null)];
25
+ }
26
+ if (ref) {
27
+ ref.current = node;
28
+ return [() => {
29
+ ref.current = null;
30
+ }];
31
+ }
32
+ return [];
33
+ });
34
+ if (IS_REACT_19) return cleanup;
35
+ };
36
+ }
37
+ function mergeHandlers(own, passed) {
38
+ if (typeof own !== "function" || typeof passed !== "function") return passed ?? own;
39
+ return (...args) => {
40
+ own(...args);
41
+ passed(...args);
42
+ };
43
+ }
44
+ /**
45
+ * Рисует элемент из пропа render с пропами компонента, как render у Base UI: className и style
46
+ * объединяются, обработчики событий вызываются оба, ref сливаются, остальные пропы компонента
47
+ * перебивают пропы элемента. Атрибуты состояния передаются последними и перебивают всё: проект
48
+ * не должен случайно снять data-skeleton или inert, передав свой атрибут с тем же именем.
49
+ */
50
+ function useRenderElement(element, props, state = {}) {
51
+ const own = element.props;
52
+ const merged = { ...props };
53
+ for (const key of Object.keys(props)) if (EVENT_HANDLER.test(key)) merged[key] = mergeHandlers(own[key], props[key]);
54
+ const className = [own.className, props.className].filter(Boolean).join(" ");
55
+ const ownRef = IS_REACT_19 ? own.ref : element.ref;
56
+ const ref = useMemo(() => ownRef || props.ref ? mergeRefs([ownRef, props.ref]) : void 0, [ownRef, props.ref]);
57
+ return cloneElement(element, {
58
+ ...merged,
59
+ ...state,
60
+ className: className || void 0,
61
+ style: own.style || props.style ? {
62
+ ...own.style,
63
+ ...props.style
64
+ } : void 0,
65
+ ref,
66
+ children: state.children ?? props.children ?? own.children
67
+ });
68
+ }
69
+ //#endregion
70
+ //#region src/route-progress/RouteProgress.tsx
71
+ const RouteProgress = forwardRef(function RouteProgress({ pending, render = /* @__PURE__ */ jsx("div", {}), ...props }, ref) {
72
+ return useRenderElement(render, {
73
+ ...props,
74
+ ref
75
+ }, {
76
+ "data-route-progress": pending ? "pending" : "",
77
+ "aria-hidden": true
78
+ });
79
+ });
80
+ //#endregion
81
+ export { useRenderElement as n, IS_REACT_19 as r, RouteProgress as t };
@@ -0,0 +1,19 @@
1
+ import { CSSProperties, ForwardRefExoticComponent, HTMLAttributes, PropsWithoutRef, ReactElement, ReactNode, Ref, RefAttributes } from "react";
2
+ //#region src/utils/useRenderElement.d.ts
3
+ type ElementProps = Record<string, unknown> & {
4
+ className?: string;
5
+ style?: CSSProperties;
6
+ children?: ReactNode;
7
+ ref?: Ref<unknown>;
8
+ };
9
+ type RenderElement = ReactElement<ElementProps>;
10
+ //#endregion
11
+ //#region src/route-progress/RouteProgress.d.ts
12
+ interface RouteProgressProps extends HTMLAttributes<HTMLElement> {
13
+ pending: boolean;
14
+ render?: RenderElement;
15
+ ref?: Ref<HTMLElement>;
16
+ }
17
+ declare const RouteProgress: ForwardRefExoticComponent<PropsWithoutRef<RouteProgressProps> & RefAttributes<HTMLElement>>;
18
+ //#endregion
19
+ export { RouteProgressProps as n, RenderElement as r, RouteProgress as t };
@@ -0,0 +1,68 @@
1
+ import { n as RouteProgressProps, r as RenderElement, t as RouteProgress } from "./index-KGl0rvk4.js";
2
+ import { ForwardRefExoticComponent, HTMLAttributes, PropsWithoutRef, ReactElement, ReactNode, Ref, RefAttributes } from "react";
3
+ //#region src/skeleton/Root.d.ts
4
+ interface RootProps extends HTMLAttributes<HTMLElement> {
5
+ /** Пока true, поддерево рисуется заглушкой той же геометрии. */
6
+ loading: boolean;
7
+ /** Элемент, которым рисуется корень вместо div, например render={<ul />}. */
8
+ render?: RenderElement;
9
+ ref?: Ref<HTMLElement>;
10
+ }
11
+ /**
12
+ * На время загрузки корень получает inert: заглушка не берёт фокус и не читается скринридером.
13
+ * inert убирает из дерева доступности и сам корень, поэтому aria-busy на нём никто бы не
14
+ * услышал; aria-busy ставит проект на свой контейнер вокруг корня.
15
+ */
16
+ declare const Root: ForwardRefExoticComponent<PropsWithoutRef<RootProps> & RefAttributes<HTMLElement>>;
17
+ //#endregion
18
+ //#region src/skeleton/Static.d.ts
19
+ interface StaticProps extends HTMLAttributes<HTMLElement> {
20
+ /** Элемент, который помечается статичным вместо обёртки, например render={<h2 />}. */
21
+ render?: RenderElement;
22
+ ref?: Ref<HTMLElement>;
23
+ }
24
+ /**
25
+ * Без render содержимое оборачивается в span с display: contents из styles.css: у такой обёртки
26
+ * нет своего бокса, поэтому в потоке и во флексе раскладка вокруг статичного не меняется, а
27
+ * className на ней задаёт только наследуемые свойства. Внутри table, thead, tbody, tr, ul и select
28
+ * span ребёнком быть не может, там нужен render с элементом этого уровня, например
29
+ * render={<thead />}.
30
+ */
31
+ declare const Static: ForwardRefExoticComponent<PropsWithoutRef<StaticProps> & RefAttributes<HTMLElement>>;
32
+ //#endregion
33
+ //#region src/skeleton/Repeat.d.ts
34
+ interface RepeatProps {
35
+ /** Количество повторений; неотрицательное безопасное целое число. */
36
+ count: number;
37
+ /** Шаблон элемента. Получает индекс от нуля; данные и loading задаёт приложение. */
38
+ children: (index: number) => ReactNode;
39
+ }
40
+ /** Повторяет шаблон со стабильными ключами без дополнительных DOM-элементов. */
41
+ declare function Repeat({ count, children }: RepeatProps): ReactElement;
42
+ declare namespace index_d_exports {
43
+ export { Repeat, RepeatProps, Root, RootProps, Static, StaticProps };
44
+ }
45
+ //#endregion
46
+ //#region src/spinner/Spinner.d.ts
47
+ /**
48
+ * children не принимаются: с label содержимым элемента становится визуально скрытая подпись, и
49
+ * переданные children молча терялись бы.
50
+ */
51
+ interface SpinnerProps extends Omit<HTMLAttributes<HTMLElement>, "children"> {
52
+ /** Подпись для скринридера; без неё спиннер декоративный. */
53
+ label?: string;
54
+ /** Элемент, которым рисуется спиннер вместо span. */
55
+ render?: RenderElement;
56
+ ref?: Ref<HTMLElement>;
57
+ }
58
+ /**
59
+ * Без label спиннер скрыт от скринридера: обычно он стоит в кнопке или контейнере, где загрузку
60
+ * уже сообщает aria-busy, и второй сигнал был бы лишним. С label он становится role="status", а
61
+ * подпись лежит визуально скрытым текстом внутри, потому что живой регион озвучивает содержимое,
62
+ * а не aria-label. Смонтированный сразу с подписью регион скринридер может не объявить сам, тогда
63
+ * подпись служит именем при навигации; для гарантированного объявления регион статуса держат в
64
+ * разметке заранее.
65
+ */
66
+ export declare const Spinner: ForwardRefExoticComponent<PropsWithoutRef<SpinnerProps> & RefAttributes<HTMLElement>>;
67
+ //#endregion
68
+ export { RouteProgress, type RouteProgressProps, index_d_exports as Skeleton, type SpinnerProps };
package/dist/index.js ADDED
@@ -0,0 +1,78 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
+ import { n as useRenderElement, r as IS_REACT_19, t as RouteProgress } from "./RouteProgress-Cq28cihc.js";
3
+ import { Fragment, forwardRef } from "react";
4
+ import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
5
+ //#region src/skeleton/Root.tsx
6
+ const LOADING_STATE = {
7
+ "data-skeleton": "loading",
8
+ inert: IS_REACT_19 ? true : ""
9
+ };
10
+ /**
11
+ * На время загрузки корень получает inert: заглушка не берёт фокус и не читается скринридером.
12
+ * inert убирает из дерева доступности и сам корень, поэтому aria-busy на нём никто бы не
13
+ * услышал; aria-busy ставит проект на свой контейнер вокруг корня.
14
+ */
15
+ const Root = forwardRef(function Root({ loading, render = /* @__PURE__ */ jsx("div", {}), ...props }, ref) {
16
+ return useRenderElement(render, {
17
+ ...props,
18
+ ref
19
+ }, loading ? LOADING_STATE : {});
20
+ });
21
+ //#endregion
22
+ //#region src/skeleton/Static.tsx
23
+ const STATIC_STATE = { "data-skeleton-static": "" };
24
+ /**
25
+ * Без render содержимое оборачивается в span с display: contents из styles.css: у такой обёртки
26
+ * нет своего бокса, поэтому в потоке и во флексе раскладка вокруг статичного не меняется, а
27
+ * className на ней задаёт только наследуемые свойства. Внутри table, thead, tbody, tr, ul и select
28
+ * span ребёнком быть не может, там нужен render с элементом этого уровня, например
29
+ * render={<thead />}.
30
+ */
31
+ const Static = forwardRef(function Static({ render = /* @__PURE__ */ jsx("span", { "data-skeleton-contents": "" }), ...props }, ref) {
32
+ return useRenderElement(render, {
33
+ ...props,
34
+ ref
35
+ }, STATIC_STATE);
36
+ });
37
+ //#endregion
38
+ //#region src/skeleton/Repeat.tsx
39
+ /** Повторяет шаблон со стабильными ключами без дополнительных DOM-элементов. */
40
+ function Repeat({ count, children }) {
41
+ if (!Number.isSafeInteger(count) || count < 0) throw new RangeError("Skeleton.Repeat count must be a non-negative safe integer.");
42
+ return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from({ length: count }, (_, index) => /* @__PURE__ */ jsx(Fragment, { children: children(index) }, index)) });
43
+ }
44
+ //#endregion
45
+ //#region src/skeleton/index.ts
46
+ var skeleton_exports = /* @__PURE__ */ __exportAll({
47
+ Repeat: () => Repeat,
48
+ Root: () => Root,
49
+ Static: () => Static
50
+ });
51
+ //#endregion
52
+ //#region src/spinner/Spinner.tsx
53
+ /**
54
+ * Без label спиннер скрыт от скринридера: обычно он стоит в кнопке или контейнере, где загрузку
55
+ * уже сообщает aria-busy, и второй сигнал был бы лишним. С label он становится role="status", а
56
+ * подпись лежит визуально скрытым текстом внутри, потому что живой регион озвучивает содержимое,
57
+ * а не aria-label. Смонтированный сразу с подписью регион скринридер может не объявить сам, тогда
58
+ * подпись служит именем при навигации; для гарантированного объявления регион статуса держат в
59
+ * разметке заранее.
60
+ */
61
+ const Spinner = forwardRef(function Spinner({ label, render = /* @__PURE__ */ jsx("span", {}), ...props }, ref) {
62
+ return useRenderElement(render, {
63
+ ...props,
64
+ ref
65
+ }, label ? {
66
+ "data-spinner": "",
67
+ role: "status",
68
+ children: /* @__PURE__ */ jsx("span", {
69
+ "data-spinner-label": "",
70
+ children: label
71
+ })
72
+ } : {
73
+ "data-spinner": "",
74
+ "aria-hidden": true
75
+ });
76
+ });
77
+ //#endregion
78
+ export { RouteProgress, skeleton_exports as Skeleton, Spinner };
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
@@ -0,0 +1,173 @@
1
+ @layer loadshape {
2
+ [data-skeleton="loading"] {
3
+ -webkit-text-fill-color: transparent !important;
4
+ }
5
+
6
+ [data-skeleton="loading"] :where(:not([data-skeleton-static], [data-skeleton-static] *)) {
7
+ text-underline-offset: -.62em !important;
8
+ text-decoration-skip-ink: none !important;
9
+ text-decoration-line: none !important;
10
+ text-decoration-thickness: .72em !important;
11
+ }
12
+
13
+ [data-skeleton="loading"]:where(:not(:has( > :not(a, abbr, b, bdi, bdo, br, cite, code, data, del, dfn, em, i, img, input, ins, kbd, label, mark, output, q, ruby, s, samp, select, small, span, strong, sub, sup, svg, textarea, time, u, var, wbr), [data-skeleton-static])):not(:empty, img, svg, video, canvas, picture, input, select, textarea, button, [role="button"], [data-skeleton-bone], [data-skeleton-static], [data-skeleton-static] *)), [data-skeleton="loading"] :where(:not(:has( > :not(a, abbr, b, bdi, bdo, br, cite, code, data, del, dfn, em, i, img, input, ins, kbd, label, mark, output, q, ruby, s, samp, select, small, span, strong, sub, sup, svg, textarea, time, u, var, wbr), [data-skeleton-static])):not(:is([data-skeleton="loading"], [data-skeleton="loading"] *):not(:has( > :not(a, abbr, b, bdi, bdo, br, cite, code, data, del, dfn, em, i, img, input, ins, kbd, label, mark, output, q, ruby, s, samp, select, small, span, strong, sub, sup, svg, textarea, time, u, var, wbr), [data-skeleton-static])) > *):not(:empty, img, svg, video, canvas, picture, input, select, textarea, button, [role="button"], [data-skeleton-bone], [data-skeleton-static], [data-skeleton-static] *)) {
14
+ --_skeleton_paint: var(--skeleton_color, color-mix(in oklab, currentColor 14%, transparent));
15
+ text-decoration-line: underline !important;
16
+ text-decoration-style: solid !important;
17
+ text-decoration-color: var(--_skeleton_paint) !important;
18
+ text-underline-offset: -.62em !important;
19
+ text-decoration-skip-ink: none !important;
20
+ animation: skeleton_delay 0s var(--skeleton_delay, var(--loadshape_delay, .15s)) backwards !important;
21
+ text-decoration-thickness: .72em !important;
22
+
23
+ @media (prefers-reduced-motion: no-preference) {
24
+ animation: skeleton_delay 0s var(--skeleton_delay, var(--loadshape_delay, .15s)) backwards,
25
+ skeleton_shimmer var(--skeleton_duration, 1.4s) linear var(--skeleton_delay, var(--loadshape_delay, .15s)) infinite !important;
26
+ }
27
+ }
28
+
29
+ [data-skeleton="loading"]:where(img, video, canvas, input, select, textarea, button, [role="button"], [data-skeleton-bone]), [data-skeleton="loading"] :where(img, video, canvas, input, select, textarea, button, [role="button"], [data-skeleton-bone]):where(:not([data-skeleton-static], [data-skeleton-static] *)) {
30
+ --_skeleton_paint: var(--skeleton_color, color-mix(in oklab, currentColor 14%, transparent));
31
+ color: inherit !important;
32
+ background: var(--_skeleton_paint) !important;
33
+ object-position: -100000px 0 !important;
34
+ resize: none !important;
35
+ animation: skeleton_delay 0s var(--skeleton_delay, var(--loadshape_delay, .15s)) backwards !important;
36
+
37
+ @media (prefers-reduced-motion: no-preference) {
38
+ animation: skeleton_delay 0s var(--skeleton_delay, var(--loadshape_delay, .15s)) backwards,
39
+ skeleton_shimmer var(--skeleton_duration, 1.4s) linear var(--skeleton_delay, var(--loadshape_delay, .15s)) infinite !important;
40
+ }
41
+ }
42
+
43
+ [data-skeleton="loading"]:where(button, [role="button"], [data-skeleton-bone]) > :not([data-skeleton-static], [data-skeleton-static] *), [data-skeleton="loading"] :where(button, [role="button"], [data-skeleton-bone]):where(:not([data-skeleton-static], [data-skeleton-static] *)) > :not([data-skeleton-static], [data-skeleton-static] *) {
44
+ visibility: hidden !important;
45
+ }
46
+
47
+ [data-skeleton-contents] {
48
+ display: contents;
49
+ }
50
+
51
+ [data-skeleton="loading"] [data-skeleton-static] {
52
+ -webkit-text-fill-color: currentcolor !important;
53
+ }
54
+
55
+ [data-spinner] {
56
+ box-sizing: border-box;
57
+ inline-size: var(--spinner_size, 1em);
58
+ block-size: var(--spinner_size, 1em);
59
+ vertical-align: -.125em;
60
+ border: var(--spinner_thickness, .125em) solid
61
+ color-mix(in oklab, currentColor 25%, transparent);
62
+ animation: spinner_delay 0s var(--spinner_delay, var(--loadshape_delay, .15s)) backwards,
63
+ spinner_rotate var(--spinner_duration, .8s) linear infinite;
64
+ border-block-start-color: currentColor;
65
+ border-radius: 50%;
66
+ flex-shrink: 0;
67
+ display: inline-block;
68
+ }
69
+
70
+ @media (prefers-reduced-motion: reduce) {
71
+ [data-spinner] {
72
+ animation-duration: 0s, calc(var(--spinner_duration, .8s) * 3);
73
+ }
74
+ }
75
+
76
+ @keyframes spinner_rotate {
77
+ to {
78
+ rotate: 1turn;
79
+ }
80
+ }
81
+
82
+ [data-spinner-label] {
83
+ clip-path: inset(50%);
84
+ white-space: nowrap;
85
+ block-size: 1px;
86
+ inline-size: 1px;
87
+ position: absolute;
88
+ overflow: hidden;
89
+ }
90
+
91
+ @keyframes spinner_delay {
92
+ from, to {
93
+ opacity: 0;
94
+ }
95
+ }
96
+
97
+ [data-route-progress] {
98
+ block-size: var(--route_progress_thickness, .125em);
99
+ opacity: 0;
100
+ pointer-events: none;
101
+ display: block;
102
+ position: relative;
103
+ overflow: hidden;
104
+ }
105
+
106
+ [data-route-progress]:before {
107
+ content: "";
108
+ background: currentColor;
109
+ inline-size: 40%;
110
+ position: absolute;
111
+ inset-block: 0;
112
+ inset-inline-start: 0;
113
+ }
114
+
115
+ [data-route-progress="pending"] {
116
+ opacity: 1;
117
+ animation: route_progress_delay 0s var(--route_progress_delay, var(--loadshape_delay, .15s))
118
+ backwards;
119
+ }
120
+
121
+ [data-route-progress="pending"]:before {
122
+ animation: route_progress_slide var(--route_progress_duration, 1.4s) linear infinite;
123
+ }
124
+
125
+ @media (prefers-reduced-motion: reduce) {
126
+ [data-route-progress="pending"]:before {
127
+ inline-size: 100%;
128
+ animation: none;
129
+ }
130
+ }
131
+
132
+ @keyframes route_progress_delay {
133
+ from, to {
134
+ opacity: 0;
135
+ }
136
+ }
137
+
138
+ @keyframes route_progress_slide {
139
+ from {
140
+ transform: translateX(-100%);
141
+ }
142
+
143
+ to {
144
+ transform: translateX(250%);
145
+ }
146
+ }
147
+
148
+ @keyframes skeleton_shimmer {
149
+ from {
150
+ mask-image: linear-gradient(90deg,
151
+ #000 0 35%,
152
+ rgb(0 0 0 / var(--skeleton_shimmer_opacity, .35)) 50%,
153
+ #000 65% 100%);
154
+ mask-position: 100% 0;
155
+ mask-size: 300% 100%;
156
+ }
157
+
158
+ to {
159
+ mask-image: linear-gradient(90deg,
160
+ #000 0 35%,
161
+ rgb(0 0 0 / var(--skeleton_shimmer_opacity, .35)) 50%,
162
+ #000 65% 100%);
163
+ mask-position: 0 0;
164
+ mask-size: 300% 100%;
165
+ }
166
+ }
167
+
168
+ @keyframes skeleton_delay {
169
+ from, to {
170
+ --_skeleton_paint: transparent;
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,6 @@
1
+ import { n as RouteProgressProps$1 } from "./index-KGl0rvk4.js";
2
+ import { ForwardRefExoticComponent, PropsWithoutRef, RefAttributes } from "react";
3
+ //#region src/tanstack-router.d.ts
4
+ export type RouteProgressProps = Omit<RouteProgressProps$1, "pending">;
5
+ export declare const RouteProgress: ForwardRefExoticComponent<PropsWithoutRef<RouteProgressProps> & RefAttributes<HTMLElement>>;
6
+ //#endregion
@@ -0,0 +1,16 @@
1
+ import { t as RouteProgress$1 } from "./RouteProgress-Cq28cihc.js";
2
+ import { forwardRef } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { useRouterState } from "@tanstack/react-router";
5
+ //#region src/tanstack-router.tsx
6
+ const RouteProgress = forwardRef(function RouteProgress(props, ref) {
7
+ const pending = useRouterState({ select: (state) => state.status === "pending" });
8
+ const destination = useRouterState({ select: (state) => state.location.href });
9
+ return /* @__PURE__ */ jsx(RouteProgress$1, {
10
+ ...props,
11
+ ref,
12
+ pending
13
+ }, destination);
14
+ });
15
+ //#endregion
16
+ export { RouteProgress };
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "loadshape",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Состояния загрузки для React: скелетон из самого компонента, спиннер и полоса перехода",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "**/*.css"
9
+ ],
10
+ "files": [
11
+ "dist",
12
+ "CHANGELOG.md"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./styles.css": "./dist/styles.css",
20
+ "./tanstack-router": {
21
+ "types": "./dist/tanstack-router.d.ts",
22
+ "default": "./dist/tanstack-router.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "peerDependencies": {
27
+ "@tanstack/react-router": "^1.170.41",
28
+ "react": "^18.0.0 || ^19.0.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@tanstack/react-router": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "devDependencies": {
36
+ "@tanstack/react-router": "^1.170.41",
37
+ "@tsdown/css": "^0.23.0",
38
+ "@types/react": "^19.3.0",
39
+ "@typescript/native": "npm:typescript@^7.0.2",
40
+ "react": "^19.3.0",
41
+ "tsdown": "^0.23.0",
42
+ "typescript": "npm:@typescript/typescript6@^6.0.2"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/semyonsurkov/loadshape.git",
50
+ "directory": "packages/loadshape"
51
+ },
52
+ "homepage": "https://github.com/semyonsurkov/loadshape#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/semyonsurkov/loadshape/issues"
55
+ },
56
+ "scripts": {
57
+ "build": "tsdown",
58
+ "dev": "tsdown --watch",
59
+ "typecheck": "tsc --noEmit"
60
+ }
6
61
  }