@stapel/categories-react 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/CHANGELOG.md +1 -0
- package/MODULE.md +194 -0
- package/README.md +191 -0
- package/dist/api/categoriesApi.d.ts +121 -0
- package/dist/api/categoriesApi.d.ts.map +1 -0
- package/dist/api/categoriesApi.js +48 -0
- package/dist/api/categoriesApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +2321 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +92 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/catalog/labels.d.ts +103 -0
- package/dist/catalog/labels.d.ts.map +1 -0
- package/dist/catalog/labels.js +56 -0
- package/dist/catalog/labels.js.map +1 -0
- package/dist/catalog/pks.d.ts +25 -0
- package/dist/catalog/pks.d.ts.map +1 -0
- package/dist/catalog/pks.js +38 -0
- package/dist/catalog/pks.js.map +1 -0
- package/dist/catalog/sync.d.ts +103 -0
- package/dist/catalog/sync.d.ts.map +1 -0
- package/dist/catalog/sync.js +114 -0
- package/dist/catalog/sync.js.map +1 -0
- package/dist/catalog/tree.d.ts +119 -0
- package/dist/catalog/tree.d.ts.map +1 -0
- package/dist/catalog/tree.js +166 -0
- package/dist/catalog/tree.js.map +1 -0
- package/dist/default/CatalogPage.d.ts +16 -0
- package/dist/default/CatalogPage.d.ts.map +1 -0
- package/dist/default/CatalogPage.js +13 -0
- package/dist/default/CatalogPage.js.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts +20 -0
- package/dist/default/CategoryBreadcrumbsBar.d.ts.map +1 -0
- package/dist/default/CategoryBreadcrumbsBar.js +35 -0
- package/dist/default/CategoryBreadcrumbsBar.js.map +1 -0
- package/dist/default/CategoryCarouselStrip.d.ts +22 -0
- package/dist/default/CategoryCarouselStrip.d.ts.map +1 -0
- package/dist/default/CategoryCarouselStrip.js +24 -0
- package/dist/default/CategoryCarouselStrip.js.map +1 -0
- package/dist/default/CategoryFeatureList.d.ts +23 -0
- package/dist/default/CategoryFeatureList.d.ts.map +1 -0
- package/dist/default/CategoryFeatureList.js +28 -0
- package/dist/default/CategoryFeatureList.js.map +1 -0
- package/dist/default/CategoryPage.d.ts +43 -0
- package/dist/default/CategoryPage.d.ts.map +1 -0
- package/dist/default/CategoryPage.js +21 -0
- package/dist/default/CategoryPage.js.map +1 -0
- package/dist/default/CategoryPickerField.d.ts +26 -0
- package/dist/default/CategoryPickerField.d.ts.map +1 -0
- package/dist/default/CategoryPickerField.js +40 -0
- package/dist/default/CategoryPickerField.js.map +1 -0
- package/dist/default/CategoryTreePane.d.ts +28 -0
- package/dist/default/CategoryTreePane.d.ts.map +1 -0
- package/dist/default/CategoryTreePane.js +25 -0
- package/dist/default/CategoryTreePane.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/index.d.ts +37 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +28 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +25 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +40 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/CategoriesProvider.d.ts +19 -0
- package/dist/headless/CategoriesProvider.d.ts.map +1 -0
- package/dist/headless/CategoriesProvider.js +15 -0
- package/dist/headless/CategoriesProvider.js.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts +40 -0
- package/dist/headless/CategoryBreadcrumbs.d.ts.map +1 -0
- package/dist/headless/CategoryBreadcrumbs.js +42 -0
- package/dist/headless/CategoryBreadcrumbs.js.map +1 -0
- package/dist/headless/CategoryCarousel.d.ts +48 -0
- package/dist/headless/CategoryCarousel.d.ts.map +1 -0
- package/dist/headless/CategoryCarousel.js +34 -0
- package/dist/headless/CategoryCarousel.js.map +1 -0
- package/dist/headless/CategoryFeatures.d.ts +67 -0
- package/dist/headless/CategoryFeatures.d.ts.map +1 -0
- package/dist/headless/CategoryFeatures.js +46 -0
- package/dist/headless/CategoryFeatures.js.map +1 -0
- package/dist/headless/CategoryPicker.d.ts +69 -0
- package/dist/headless/CategoryPicker.d.ts.map +1 -0
- package/dist/headless/CategoryPicker.js +86 -0
- package/dist/headless/CategoryPicker.js.map +1 -0
- package/dist/headless/CategoryTree.d.ts +55 -0
- package/dist/headless/CategoryTree.d.ts.map +1 -0
- package/dist/headless/CategoryTree.js +51 -0
- package/dist/headless/CategoryTree.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +22 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +31 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +25 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +74 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +407 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +207 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +89 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +134 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +41 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +90 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +78 -0
- package/dist/index.js.map +1 -0
- package/dist/model/catalogStore.d.ts +23 -0
- package/dist/model/catalogStore.d.ts.map +1 -0
- package/dist/model/catalogStore.js +85 -0
- package/dist/model/catalogStore.js.map +1 -0
- package/dist/model/catalogSync.d.ts +49 -0
- package/dist/model/catalogSync.d.ts.map +1 -0
- package/dist/model/catalogSync.js +64 -0
- package/dist/model/catalogSync.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +124 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +147 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +30 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +16 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +31 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +31 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +37 -0
- package/dist/nav/manifest.js.map +1 -0
- package/llms.txt +125 -0
- package/manifest.json +918 -0
- package/nav-manifest.json +44 -0
- package/package.json +119 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/categoriesApi.ts +179 -0
- package/src/api/generated/schema.ts +2323 -0
- package/src/api/types.ts +99 -0
- package/src/catalog/labels.ts +129 -0
- package/src/catalog/pks.ts +35 -0
- package/src/catalog/sync.ts +169 -0
- package/src/catalog/tree.ts +242 -0
- package/src/default/CatalogPage.tsx +40 -0
- package/src/default/CategoryBreadcrumbsBar.tsx +93 -0
- package/src/default/CategoryCarouselStrip.tsx +102 -0
- package/src/default/CategoryFeatureList.tsx +125 -0
- package/src/default/CategoryPage.tsx +114 -0
- package/src/default/CategoryPickerField.tsx +173 -0
- package/src/default/CategoryTreePane.tsx +134 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/index.ts +36 -0
- package/src/default/theme.tsx +67 -0
- package/src/default/types.ts +12 -0
- package/src/headless/CategoriesProvider.tsx +20 -0
- package/src/headless/CategoryBreadcrumbs.tsx +78 -0
- package/src/headless/CategoryCarousel.tsx +80 -0
- package/src/headless/CategoryFeatures.tsx +100 -0
- package/src/headless/CategoryPicker.tsx +173 -0
- package/src/headless/CategoryTree.tsx +115 -0
- package/src/i18n/errorsMap.ts +46 -0
- package/src/i18n/es.ts +92 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +238 -0
- package/src/i18n/generated/errors.json +495 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +156 -0
- package/src/i18n/ru.ts +110 -0
- package/src/index.ts +191 -0
- package/src/model/catalogStore.ts +102 -0
- package/src/model/catalogSync.ts +115 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +226 -0
- package/src/model/queryKeys.ts +50 -0
- package/src/model/runtime.ts +39 -0
- package/src/nav/manifest.ts +66 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<CategoryTreePane>` — one level of the catalogue as a list of links.
|
|
3
|
+
*
|
|
4
|
+
* The four arms of `matchList` are the point of this file, not decoration:
|
|
5
|
+
* "still syncing", "the sync failed", "this category has no sub-categories"
|
|
6
|
+
* and "here they are" are four different sentences, and the third is the one a
|
|
7
|
+
* leaf category legitimately gets. Collapsing any two is the incident
|
|
8
|
+
* `@stapel/core`'s `loadState.ts` was written for.
|
|
9
|
+
*
|
|
10
|
+
* A fifth condition rides alongside them: `truncated`. The sync walk hit its
|
|
11
|
+
* page budget, so what is on screen is a PARTIAL catalogue — which is neither
|
|
12
|
+
* an empty one nor a failed one, and gets its own line rather than being
|
|
13
|
+
* silently indistinguishable from a complete tree.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactElement } from "react";
|
|
16
|
+
import { Alert, Badge, Empty, Flex, List, Spin, Typography } from "antd";
|
|
17
|
+
import { matchList, toFlowError, useDescribeFlowError, useT } from "@stapel/core";
|
|
18
|
+
import { categoryLabel, renderCategoryLabel } from "../catalog/labels.js";
|
|
19
|
+
import type { CategoryNode } from "../catalog/tree.js";
|
|
20
|
+
import { CategoryTree } from "../headless/CategoryTree.js";
|
|
21
|
+
import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js";
|
|
22
|
+
import { ErrorAlert } from "./ErrorAlert.js";
|
|
23
|
+
import { CategoriesSkinTheme } from "./theme.js";
|
|
24
|
+
import type { ThemeModeProp } from "./types.js";
|
|
25
|
+
|
|
26
|
+
export interface CategoryTreePaneProps extends ThemeModeProp {
|
|
27
|
+
/** Render this category's children. Omitted renders the roots. */
|
|
28
|
+
readonly parentId?: number | null;
|
|
29
|
+
/** Render the children of the category at this slug (the `/c/:slug` page). */
|
|
30
|
+
readonly slug?: string;
|
|
31
|
+
/** Path prefix for a row's link. Default `/c`. */
|
|
32
|
+
readonly basePath?: string;
|
|
33
|
+
/** Heading above the list. Omitted renders no heading. */
|
|
34
|
+
readonly titleKey?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CategoryTreePane(props: CategoryTreePaneProps): ReactElement {
|
|
38
|
+
const t = useT();
|
|
39
|
+
const describe = useDescribeFlowError();
|
|
40
|
+
const base = props.basePath ?? "/c";
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<CategoriesSkinTheme
|
|
44
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
45
|
+
>
|
|
46
|
+
<CategoryTree
|
|
47
|
+
{...(props.parentId !== undefined ? { parentId: props.parentId } : {})}
|
|
48
|
+
{...(props.slug !== undefined ? { slug: props.slug } : {})}
|
|
49
|
+
>
|
|
50
|
+
{(bag) => (
|
|
51
|
+
<Flex vertical gap={8} data-testid="categories-tree">
|
|
52
|
+
{props.titleKey !== undefined ? (
|
|
53
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
54
|
+
{t(props.titleKey)}
|
|
55
|
+
</Typography.Title>
|
|
56
|
+
) : null}
|
|
57
|
+
|
|
58
|
+
{bag.truncated ? (
|
|
59
|
+
<Alert
|
|
60
|
+
type="warning"
|
|
61
|
+
showIcon
|
|
62
|
+
data-testid="categories-tree-truncated"
|
|
63
|
+
message={t(CATEGORIES_I18N_KEYS.catalogTruncated)}
|
|
64
|
+
/>
|
|
65
|
+
) : null}
|
|
66
|
+
|
|
67
|
+
{matchList(bag.state, {
|
|
68
|
+
loading: () => (
|
|
69
|
+
<Flex justify="center" style={{ padding: 16 }}>
|
|
70
|
+
<Spin data-testid="categories-tree-loading" />
|
|
71
|
+
</Flex>
|
|
72
|
+
),
|
|
73
|
+
failed: (error) => (
|
|
74
|
+
<ErrorAlert
|
|
75
|
+
testId="categories-tree-failed"
|
|
76
|
+
error={{
|
|
77
|
+
...describe(toFlowError(error)),
|
|
78
|
+
message: t(CATEGORIES_I18N_KEYS.catalogLoadFailed),
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
),
|
|
82
|
+
empty: () => (
|
|
83
|
+
<Empty
|
|
84
|
+
data-testid="categories-tree-empty"
|
|
85
|
+
description={t(
|
|
86
|
+
bag.current === null
|
|
87
|
+
? CATEGORIES_I18N_KEYS.catalogEmpty
|
|
88
|
+
: CATEGORIES_I18N_KEYS.categoryNoSubcategories
|
|
89
|
+
)}
|
|
90
|
+
/>
|
|
91
|
+
),
|
|
92
|
+
ready: (nodes) => (
|
|
93
|
+
<List<CategoryNode>
|
|
94
|
+
data-testid="categories-tree-list"
|
|
95
|
+
size="small"
|
|
96
|
+
dataSource={[...nodes]}
|
|
97
|
+
renderItem={(node) => (
|
|
98
|
+
<List.Item key={node.id} data-category-id={node.id}>
|
|
99
|
+
<Flex
|
|
100
|
+
justify="space-between"
|
|
101
|
+
align="center"
|
|
102
|
+
gap={8}
|
|
103
|
+
style={{ width: "100%" }}
|
|
104
|
+
>
|
|
105
|
+
<Typography.Link
|
|
106
|
+
href={`${base}/${node.category.slug}`}
|
|
107
|
+
data-category-slug={node.category.slug}
|
|
108
|
+
>
|
|
109
|
+
{renderCategoryLabel(
|
|
110
|
+
categoryLabel(node.category),
|
|
111
|
+
t
|
|
112
|
+
)}
|
|
113
|
+
</Typography.Link>
|
|
114
|
+
{node.children.length > 0 ? (
|
|
115
|
+
<Badge
|
|
116
|
+
count={node.children.length}
|
|
117
|
+
color="blue"
|
|
118
|
+
title={t(
|
|
119
|
+
CATEGORIES_I18N_KEYS.categorySubcategories
|
|
120
|
+
)}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
</Flex>
|
|
124
|
+
</List.Item>
|
|
125
|
+
)}
|
|
126
|
+
/>
|
|
127
|
+
),
|
|
128
|
+
})}
|
|
129
|
+
</Flex>
|
|
130
|
+
)}
|
|
131
|
+
</CategoryTree>
|
|
132
|
+
</CategoriesSkinTheme>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error surface this pair's default skin renders, so the split copy
|
|
3
|
+
* core produces reaches a screen the same way everywhere (mirrors the
|
|
4
|
+
* docs/notifications/workspaces etalon).
|
|
5
|
+
*
|
|
6
|
+
* Core's `describeFlowError` splits a failure into the sentence a person
|
|
7
|
+
* reads and the technical detail a support agent quotes (owner report
|
|
8
|
+
* 2026-08-09: the status used to be spliced into the sentence, ending in a
|
|
9
|
+
* bare `" (500)"`). The split only helps if a skin renders both halves at
|
|
10
|
+
* their own weights: `message` at normal weight, `detail` muted and small.
|
|
11
|
+
*
|
|
12
|
+
* `undefined` in — including a `detail` core left `undefined` because there
|
|
13
|
+
* was nothing worth quoting — renders nothing rather than an empty line.
|
|
14
|
+
*/
|
|
15
|
+
import { Alert, Typography } from "antd";
|
|
16
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
17
|
+
import type { FlowErrorDisplay } from "@stapel/core";
|
|
18
|
+
|
|
19
|
+
export function ErrorAlert(props: {
|
|
20
|
+
error: FlowErrorDisplay | undefined;
|
|
21
|
+
style?: CSSProperties | undefined;
|
|
22
|
+
testId?: string | undefined;
|
|
23
|
+
action?: ReactNode;
|
|
24
|
+
}): ReactElement | null {
|
|
25
|
+
const { error } = props;
|
|
26
|
+
if (!error) return null;
|
|
27
|
+
return (
|
|
28
|
+
<Alert
|
|
29
|
+
type="error"
|
|
30
|
+
showIcon
|
|
31
|
+
{...(props.style ? { style: props.style } : {})}
|
|
32
|
+
{...(props.testId ? { "data-testid": props.testId } : {})}
|
|
33
|
+
{...(props.action ? { action: props.action } : {})}
|
|
34
|
+
message={error.message}
|
|
35
|
+
{...(error.detail
|
|
36
|
+
? {
|
|
37
|
+
description: (
|
|
38
|
+
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
39
|
+
{error.detail}
|
|
40
|
+
</Typography.Text>
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
: {})}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@stapel/categories-react/default` — the antd skin over the headless pair.
|
|
3
|
+
*
|
|
4
|
+
* A separate entry point (the convention every pair's `/default` follows) so a
|
|
5
|
+
* host rendering its own catalogue chrome never pulls `antd` into its bundle.
|
|
6
|
+
* The main entry has no visual opinion at all and no import path from it
|
|
7
|
+
* reaches this directory — size-limit and the bundle-purity test are the
|
|
8
|
+
* teeth on that.
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import { createCategoriesRuntime, CategoriesProvider } from "@stapel/categories-react";
|
|
12
|
+
* import { CatalogPage, CategoryPage } from "@stapel/categories-react/default";
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* `CategoryPage` takes `renderListings` — the half of `/c/:slug` that belongs
|
|
16
|
+
* to `@stapel/search-react`, handed in by the container rather than imported
|
|
17
|
+
* across the L2 layer.
|
|
18
|
+
*/
|
|
19
|
+
export { CatalogPage } from "./CatalogPage.js";
|
|
20
|
+
export type { CatalogPageProps } from "./CatalogPage.js";
|
|
21
|
+
export { CategoryPage } from "./CategoryPage.js";
|
|
22
|
+
export type { CategoryPageProps } from "./CategoryPage.js";
|
|
23
|
+
export { CategoryTreePane } from "./CategoryTreePane.js";
|
|
24
|
+
export type { CategoryTreePaneProps } from "./CategoryTreePane.js";
|
|
25
|
+
export { CategoryBreadcrumbsBar } from "./CategoryBreadcrumbsBar.js";
|
|
26
|
+
export type { CategoryBreadcrumbsBarProps } from "./CategoryBreadcrumbsBar.js";
|
|
27
|
+
export { CategoryCarouselStrip } from "./CategoryCarouselStrip.js";
|
|
28
|
+
export type { CategoryCarouselStripProps } from "./CategoryCarouselStrip.js";
|
|
29
|
+
export { CategoryPickerField } from "./CategoryPickerField.js";
|
|
30
|
+
export type { CategoryPickerFieldProps } from "./CategoryPickerField.js";
|
|
31
|
+
export { CategoryFeatureList } from "./CategoryFeatureList.js";
|
|
32
|
+
export type { CategoryFeatureListProps } from "./CategoryFeatureList.js";
|
|
33
|
+
export { CategoriesSkinTheme } from "./theme.js";
|
|
34
|
+
export type { CategoriesSkinThemeProps } from "./theme.js";
|
|
35
|
+
export { ErrorAlert } from "./ErrorAlert.js";
|
|
36
|
+
export type { ThemeModeProp } from "./types.js";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default skin's INTERNAL theme provider — every `/default` surface wraps
|
|
3
|
+
* itself in this, so the skin is readable no matter what the host supplies
|
|
4
|
+
* (tracker #26: a skin set with no internal provider once inherited a theme
|
|
5
|
+
* bridge serving light-mode values inside a dark document — text on
|
|
6
|
+
* background at 1.00:1). The theme derives from `@stapel/tokens` via
|
|
7
|
+
* `@stapel/tokens-antd`'s `toAntdThemeConfig(mode)`; `mode` defaults to what
|
|
8
|
+
* the HOST's document declares (`resolveThemeMode()` — the `data-theme`
|
|
9
|
+
* attribute `tokens.css` keys its dark block on), never to a hardcoded side.
|
|
10
|
+
*
|
|
11
|
+
* This is also what "override styles" means for this pair: a storefront
|
|
12
|
+
* retheming through the §68 token JSON regenerates its `--stapel-*` custom
|
|
13
|
+
* properties and the result page follows with zero code — a far better deal
|
|
14
|
+
* than a prop-per-colour API, and the reason the skin has none.
|
|
15
|
+
*/
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
18
|
+
import { ConfigProvider } from "antd";
|
|
19
|
+
import {
|
|
20
|
+
resolveThemeMode,
|
|
21
|
+
toAntdTheme,
|
|
22
|
+
toAntdThemeConfig,
|
|
23
|
+
} from "@stapel/tokens-antd";
|
|
24
|
+
import type { ThemeMode } from "@stapel/tokens-antd";
|
|
25
|
+
|
|
26
|
+
export interface CategoriesSkinThemeProps {
|
|
27
|
+
/**
|
|
28
|
+
* Light or dark. Defaults to the mode the host's document declares
|
|
29
|
+
* (`resolveThemeMode()`). Pass explicitly to pin a side.
|
|
30
|
+
*/
|
|
31
|
+
readonly mode?: ThemeMode;
|
|
32
|
+
/** Extra styles merged onto the themed root element (layout only — colors
|
|
33
|
+
* come from the mode). */
|
|
34
|
+
readonly style?: CSSProperties;
|
|
35
|
+
readonly children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `<CategoriesSkinTheme/>` — the self-theming wrapper of
|
|
40
|
+
* `@stapel/categories-react/default`. Standalone use is supported (wrap any
|
|
41
|
+
* composition of the skin's parts once); the shipped surfaces
|
|
42
|
+
* (`CatalogPage`, `CategoryPage`, `CategoryTreePane`, `CategoryCarouselStrip`,
|
|
43
|
+
* `CategoryPickerField`, `CategoryFeatureList`, `CategoryBreadcrumbsBar`)
|
|
44
|
+
* each already wrap themselves, and nested antd
|
|
45
|
+
* `ConfigProvider`s merge, so composing them under one `CategoriesSkinTheme`
|
|
46
|
+
* stays correct.
|
|
47
|
+
*/
|
|
48
|
+
export function CategoriesSkinTheme(props: CategoriesSkinThemeProps): ReactElement {
|
|
49
|
+
const mode = props.mode ?? resolveThemeMode();
|
|
50
|
+
const theme = useMemo(() => toAntdThemeConfig(mode), [mode]);
|
|
51
|
+
const token = useMemo(() => toAntdTheme(mode), [mode]);
|
|
52
|
+
return (
|
|
53
|
+
<ConfigProvider theme={theme}>
|
|
54
|
+
<div
|
|
55
|
+
data-categories-skin-root
|
|
56
|
+
data-categories-skin-mode={mode}
|
|
57
|
+
style={{
|
|
58
|
+
color: token.colorText,
|
|
59
|
+
backgroundColor: token.colorBgContainer,
|
|
60
|
+
...props.style,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{props.children}
|
|
64
|
+
</div>
|
|
65
|
+
</ConfigProvider>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared types for the `/default` skin — kept in one place so every
|
|
3
|
+
* surface takes the same `mode` prop and re-exports the same error dialect.
|
|
4
|
+
*/
|
|
5
|
+
export type { FlowError } from "@stapel/core";
|
|
6
|
+
import type { ThemeMode } from "@stapel/tokens-antd";
|
|
7
|
+
|
|
8
|
+
/** Every `/default` surface accepts a theme mode; absent means "whatever the
|
|
9
|
+
* host document declares" (`resolveThemeMode()`), never a hardcoded side. */
|
|
10
|
+
export interface ThemeModeProp {
|
|
11
|
+
readonly mode?: ThemeMode;
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { ModuleProvider } from "../model/context.js";
|
|
3
|
+
import type { CategoriesRuntime } from "../model/runtime.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provides the wired {@link CategoriesRuntime} to every categories hook and
|
|
7
|
+
* headless component below it. Bring your own visual shell — this component
|
|
8
|
+
* renders nothing of its own. (Core's `createModuleContext` provider, bound to
|
|
9
|
+
* this pair — slim wave §21/S2.)
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const runtime = createCategoriesRuntime({ baseUrl: "/categories/api/v1/" });
|
|
13
|
+
* // give runtime.client to core's <StapelConfigProvider config={{ client }}>
|
|
14
|
+
* <CategoriesProvider runtime={runtime}>{app}</CategoriesProvider>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export const CategoriesProvider: (props: {
|
|
18
|
+
runtime: CategoriesRuntime;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}) => ReactElement = ModuleProvider;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
|
+
import type { LoadState } from "@stapel/core";
|
|
4
|
+
import { categoryBreadcrumbs, resolveCategorySlug } from "../catalog/tree.js";
|
|
5
|
+
import type { CategoryNode } from "../catalog/tree.js";
|
|
6
|
+
import { categoryLabel } from "../catalog/labels.js";
|
|
7
|
+
import type { CategoryLabel } from "../catalog/labels.js";
|
|
8
|
+
import { useCategoryCatalog } from "../model/queries.js";
|
|
9
|
+
import type { UseCategoryCatalogOptions } from "../model/queries.js";
|
|
10
|
+
|
|
11
|
+
/** One crumb: the node, and how its caption must be rendered. */
|
|
12
|
+
export interface CategoryCrumb {
|
|
13
|
+
readonly node: CategoryNode;
|
|
14
|
+
/** `kind: "key"` → run `value` through the host's `t`. See
|
|
15
|
+
* `catalog/labels.ts` — names on the wire are translation keys. */
|
|
16
|
+
readonly label: CategoryLabel;
|
|
17
|
+
/** `true` for the last crumb — the page you are on. */
|
|
18
|
+
readonly isCurrent: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CategoryBreadcrumbsBag {
|
|
22
|
+
/**
|
|
23
|
+
* Root → current, inclusive. `empty` means the selected category IS a root
|
|
24
|
+
* (there is nothing above it), which is a different thing from a catalogue
|
|
25
|
+
* that has not loaded and from a slug that does not exist.
|
|
26
|
+
*/
|
|
27
|
+
readonly state: LoadState<readonly CategoryCrumb[]>;
|
|
28
|
+
readonly unknownSlug: boolean;
|
|
29
|
+
refetch(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CategoryBreadcrumbsProps extends UseCategoryCatalogOptions {
|
|
33
|
+
slug?: string;
|
|
34
|
+
categoryId?: number | null;
|
|
35
|
+
children: (bag: CategoryBreadcrumbsBag) => ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The ancestry of the current category, as data.
|
|
40
|
+
*
|
|
41
|
+
* Walks the BUILT index rather than `tn_ancestors_pks`: a crumb must name a
|
|
42
|
+
* category the visitor can actually open, and the built index is what the
|
|
43
|
+
* visitor is being shown. The server's own column is exposed separately
|
|
44
|
+
* (`categoryAncestorIds`) and the two are cross-checked in the suite, so a
|
|
45
|
+
* divergence is a red test rather than a link to nowhere.
|
|
46
|
+
*/
|
|
47
|
+
export function CategoryBreadcrumbs(
|
|
48
|
+
props: CategoryBreadcrumbsProps
|
|
49
|
+
): ReactNode {
|
|
50
|
+
const { slug, categoryId, children, ...catalogOptions } = props;
|
|
51
|
+
const query = useCategoryCatalog(catalogOptions);
|
|
52
|
+
const catalog = loadStateFromQuery(query);
|
|
53
|
+
const index = catalog.status === "ready" ? catalog.data.index : null;
|
|
54
|
+
|
|
55
|
+
let current: CategoryNode | null = null;
|
|
56
|
+
if (index !== null) {
|
|
57
|
+
if (slug !== undefined) current = resolveCategorySlug(index, slug) ?? null;
|
|
58
|
+
else if (categoryId !== null && categoryId !== undefined) {
|
|
59
|
+
current = index.byId.get(categoryId) ?? null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return children({
|
|
64
|
+
state: mapLoad(catalog, (data) => {
|
|
65
|
+
const path = categoryBreadcrumbs(data.index, current?.id);
|
|
66
|
+
return path.map((node, i) => ({
|
|
67
|
+
node,
|
|
68
|
+
label: categoryLabel(node.category),
|
|
69
|
+
isCurrent: i === path.length - 1,
|
|
70
|
+
}));
|
|
71
|
+
}),
|
|
72
|
+
unknownSlug:
|
|
73
|
+
catalog.status === "ready" && slug !== undefined && current === null,
|
|
74
|
+
refetch: () => {
|
|
75
|
+
void query.refetch();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
|
+
import type { LoadState } from "@stapel/core";
|
|
4
|
+
import type { Category } from "../api/types.js";
|
|
5
|
+
import { categoryLabel } from "../catalog/labels.js";
|
|
6
|
+
import type { CategoryLabel } from "../catalog/labels.js";
|
|
7
|
+
import { useCategoryCarousel } from "../model/queries.js";
|
|
8
|
+
|
|
9
|
+
/** One carousel tile. */
|
|
10
|
+
export interface CarouselEntry {
|
|
11
|
+
readonly category: Category;
|
|
12
|
+
readonly label: CategoryLabel;
|
|
13
|
+
/**
|
|
14
|
+
* The tile's icon reference — `carousel_icon`, falling back to
|
|
15
|
+
* `catalog_icon`. Both are OPAQUE STRINGS, deliberately decoupled from
|
|
16
|
+
* stapel-cdn ("an opaque string, resolved by the host if at all",
|
|
17
|
+
* `models.py`). This pair does not build a URL out of one: the host knows
|
|
18
|
+
* its CDN base, this library does not, and a guessed URL is a broken image
|
|
19
|
+
* on every deployment that guessed differently.
|
|
20
|
+
*/
|
|
21
|
+
readonly icon: string | null;
|
|
22
|
+
/** The storefront path for this tile. Slug-based, because that is the
|
|
23
|
+
* route the spec defines — and the reason the client resolves slugs. */
|
|
24
|
+
readonly href: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CategoryCarouselBag {
|
|
28
|
+
/**
|
|
29
|
+
* The tiles. `empty` means the catalogue has no `carousel_enabled`
|
|
30
|
+
* categories — a real configuration, and a landing page that says nothing
|
|
31
|
+
* rather than showing a spinner forever.
|
|
32
|
+
*/
|
|
33
|
+
readonly state: LoadState<readonly CarouselEntry[]>;
|
|
34
|
+
readonly isFetching: boolean;
|
|
35
|
+
refetch(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CategoryCarouselProps {
|
|
39
|
+
/** Path prefix for a tile's link. Default `/c` — the spec's `/c/:slug`. */
|
|
40
|
+
basePath?: string;
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
children: (bag: CategoryCarouselBag) => ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The landing page's category strip.
|
|
47
|
+
*
|
|
48
|
+
* `GET /categories/carousel/` is the one endpoint that arrives ready to
|
|
49
|
+
* render: the server filters `active` AND `carousel_enabled`, orders by
|
|
50
|
+
* `tn_priority` descending, caches the serialization and sends
|
|
51
|
+
* `Cache-Control: public, max-age`. So this component does no filtering of its
|
|
52
|
+
* own — it maps rows to tiles and says which strings still need translating.
|
|
53
|
+
*/
|
|
54
|
+
export function CategoryCarousel(props: CategoryCarouselProps): ReactNode {
|
|
55
|
+
const base = props.basePath ?? "/c";
|
|
56
|
+
const query = useCategoryCarousel(
|
|
57
|
+
props.enabled !== undefined ? { enabled: props.enabled } : {}
|
|
58
|
+
);
|
|
59
|
+
const state = loadStateFromQuery(query);
|
|
60
|
+
|
|
61
|
+
return props.children({
|
|
62
|
+
state: mapLoad(state, (rows) =>
|
|
63
|
+
rows.map((category) => ({
|
|
64
|
+
category,
|
|
65
|
+
label: categoryLabel(category),
|
|
66
|
+
icon:
|
|
67
|
+
category.carousel_icon !== undefined && category.carousel_icon !== ""
|
|
68
|
+
? category.carousel_icon
|
|
69
|
+
: category.catalog_icon !== undefined && category.catalog_icon !== ""
|
|
70
|
+
? category.catalog_icon
|
|
71
|
+
: null,
|
|
72
|
+
href: `${base}/${category.slug}`,
|
|
73
|
+
}))
|
|
74
|
+
),
|
|
75
|
+
isFetching: query.isFetching,
|
|
76
|
+
refetch: () => {
|
|
77
|
+
void query.refetch();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
3
|
+
import type { LoadState } from "@stapel/core";
|
|
4
|
+
import { featureType } from "@stapel/attributes-react";
|
|
5
|
+
import type { CategoryFeature } from "../api/types.js";
|
|
6
|
+
import { featureLabel, featureOptionsAreKeys } from "../catalog/labels.js";
|
|
7
|
+
import type { CategoryLabel } from "../catalog/labels.js";
|
|
8
|
+
import { useCategoryFeatures } from "../model/queries.js";
|
|
9
|
+
|
|
10
|
+
/** One feature of a category, with everything a renderer needs decided. */
|
|
11
|
+
export interface CategoryFeatureEntry {
|
|
12
|
+
readonly feature: CategoryFeature;
|
|
13
|
+
/** `kind: "key"` → translate. `translate: "none"` makes it a literal. */
|
|
14
|
+
readonly label: CategoryLabel;
|
|
15
|
+
/** `config.type`, or `undefined` when the row carries none. An undefined
|
|
16
|
+
* type is an UNSUPPORTED type as far as any renderer is concerned —
|
|
17
|
+
* `@stapel/attributes-react` says so loudly rather than skipping the row. */
|
|
18
|
+
readonly type: string | undefined;
|
|
19
|
+
readonly mandatory: boolean;
|
|
20
|
+
/** Are this feature's option labels translation keys? Only under
|
|
21
|
+
* `translate: "all"` and while `translatable_options` is not false. */
|
|
22
|
+
readonly optionsAreKeys: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CategoryFeaturesBag {
|
|
26
|
+
/**
|
|
27
|
+
* The feature schema. `empty` means the category declares no features and
|
|
28
|
+
* inherits none — common for a root category, and a compose form that says
|
|
29
|
+
* "no extra details for this category" rather than spinning.
|
|
30
|
+
*/
|
|
31
|
+
readonly state: LoadState<readonly CategoryFeatureEntry[]>;
|
|
32
|
+
/**
|
|
33
|
+
* The raw `FeatureDef[]`, ready to hand straight to
|
|
34
|
+
* `@stapel/attributes-react`'s `<FeatureFields>`, `unsupportedTypeGate`,
|
|
35
|
+
* `mirrorValidate` or `formatFeatureValue`. Kept beside the decorated list
|
|
36
|
+
* because those functions take the wire shape, and re-deriving it from
|
|
37
|
+
* `entries` would be a second mapping to keep in step.
|
|
38
|
+
*/
|
|
39
|
+
readonly features: readonly CategoryFeature[];
|
|
40
|
+
/** Only the features marked `show_as_badge` — the card's summary line. */
|
|
41
|
+
readonly badges: readonly CategoryFeature[];
|
|
42
|
+
/** Only the features marked `show_at_title` — the generated title parts. */
|
|
43
|
+
readonly titleParts: readonly CategoryFeature[];
|
|
44
|
+
readonly isFetching: boolean;
|
|
45
|
+
refetch(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CategoryFeaturesProps {
|
|
49
|
+
categoryId: number | null | undefined;
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
children: (bag: CategoryFeaturesBag) => ReactNode;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A category's feature schema — the bridge to `@stapel/attributes-react`.
|
|
56
|
+
*
|
|
57
|
+
* `GET /categories/{id}/features/` resolves inheritance and order server-side
|
|
58
|
+
* and returns `FeatureCompact` rows whose `config` is the polymorphic
|
|
59
|
+
* attributes config, VERBATIM: `FeatureCompactSerializer.get_config` returns
|
|
60
|
+
* `obj.config`, not `get_config_with_defaults()`. So an absent config key
|
|
61
|
+
* means "the type's default", never "off", and this pair does not restate
|
|
62
|
+
* those defaults — attributes-react owns them (its §13.2 note 1).
|
|
63
|
+
*
|
|
64
|
+
* The two consumers this exists for:
|
|
65
|
+
*
|
|
66
|
+
* - the **compose form** (`listings-react`), which draws the features with
|
|
67
|
+
* attributes-react's editor registry and blocks submit on a type this
|
|
68
|
+
* build cannot draw; and
|
|
69
|
+
* - the **facet panel** (`search-react`), whose `categoryFeatures` slot takes
|
|
70
|
+
* exactly `features` below and uses it to caption facet values.
|
|
71
|
+
*
|
|
72
|
+
* Neither of them re-fetches: a host mounts this once per category page.
|
|
73
|
+
*/
|
|
74
|
+
export function CategoryFeatures(props: CategoryFeaturesProps): ReactNode {
|
|
75
|
+
const query = useCategoryFeatures(
|
|
76
|
+
props.categoryId,
|
|
77
|
+
props.enabled !== undefined ? { enabled: props.enabled } : {}
|
|
78
|
+
);
|
|
79
|
+
const state = loadStateFromQuery(query);
|
|
80
|
+
const features = state.status === "ready" ? state.data : [];
|
|
81
|
+
|
|
82
|
+
return props.children({
|
|
83
|
+
state: mapLoad(state, (rows) =>
|
|
84
|
+
rows.map((feature) => ({
|
|
85
|
+
feature,
|
|
86
|
+
label: featureLabel(feature),
|
|
87
|
+
type: featureType(feature),
|
|
88
|
+
mandatory: feature.mandatory === true,
|
|
89
|
+
optionsAreKeys: featureOptionsAreKeys(feature),
|
|
90
|
+
}))
|
|
91
|
+
),
|
|
92
|
+
features,
|
|
93
|
+
badges: features.filter((f) => f.show_as_badge === true),
|
|
94
|
+
titleParts: features.filter((f) => f.show_at_title === true),
|
|
95
|
+
isFetching: query.isFetching,
|
|
96
|
+
refetch: () => {
|
|
97
|
+
void query.refetch();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|