@stapel/attributes-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.
Files changed (74) hide show
  1. package/README.md +123 -0
  2. package/dist/default/FeatureBadges.d.ts +33 -0
  3. package/dist/default/FeatureBadges.d.ts.map +1 -0
  4. package/dist/default/FeatureBadges.js +56 -0
  5. package/dist/default/FeatureBadges.js.map +1 -0
  6. package/dist/default/FeatureFields.d.ts +52 -0
  7. package/dist/default/FeatureFields.d.ts.map +1 -0
  8. package/dist/default/FeatureFields.js +60 -0
  9. package/dist/default/FeatureFields.js.map +1 -0
  10. package/dist/default/editors.d.ts +24 -0
  11. package/dist/default/editors.d.ts.map +1 -0
  12. package/dist/default/editors.js +354 -0
  13. package/dist/default/editors.js.map +1 -0
  14. package/dist/default/index.d.ts +27 -0
  15. package/dist/default/index.d.ts.map +1 -0
  16. package/dist/default/index.js +25 -0
  17. package/dist/default/index.js.map +1 -0
  18. package/dist/dto.d.ts +40 -0
  19. package/dist/dto.d.ts.map +1 -0
  20. package/dist/dto.js +64 -0
  21. package/dist/dto.js.map +1 -0
  22. package/dist/errors.d.ts +57 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +98 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/format.d.ts +46 -0
  27. package/dist/format.d.ts.map +1 -0
  28. package/dist/format.js +159 -0
  29. package/dist/format.js.map +1 -0
  30. package/dist/i18n/es.d.ts +13 -0
  31. package/dist/i18n/es.d.ts.map +1 -0
  32. package/dist/i18n/es.js +36 -0
  33. package/dist/i18n/es.js.map +1 -0
  34. package/dist/i18n/keys.d.ts +56 -0
  35. package/dist/i18n/keys.d.ts.map +1 -0
  36. package/dist/i18n/keys.js +80 -0
  37. package/dist/i18n/keys.js.map +1 -0
  38. package/dist/i18n/ru.d.ts +19 -0
  39. package/dist/i18n/ru.d.ts.map +1 -0
  40. package/dist/i18n/ru.js +42 -0
  41. package/dist/i18n/ru.js.map +1 -0
  42. package/dist/index.d.ts +58 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +54 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/registry.d.ts +122 -0
  47. package/dist/registry.d.ts.map +1 -0
  48. package/dist/registry.js +82 -0
  49. package/dist/registry.js.map +1 -0
  50. package/dist/types.d.ts +134 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +35 -0
  53. package/dist/types.js.map +1 -0
  54. package/dist/validate.d.ts +88 -0
  55. package/dist/validate.d.ts.map +1 -0
  56. package/dist/validate.js +410 -0
  57. package/dist/validate.js.map +1 -0
  58. package/manifest.json +96 -0
  59. package/package.json +107 -0
  60. package/src/default/FeatureBadges.tsx +123 -0
  61. package/src/default/FeatureFields.tsx +140 -0
  62. package/src/default/editors.tsx +578 -0
  63. package/src/default/index.ts +34 -0
  64. package/src/dto.ts +78 -0
  65. package/src/errors.ts +127 -0
  66. package/src/format.ts +210 -0
  67. package/src/i18n/es.ts +41 -0
  68. package/src/i18n/keys.ts +89 -0
  69. package/src/i18n/ru.ts +48 -0
  70. package/src/index.ts +98 -0
  71. package/src/registry.ts +167 -0
  72. package/src/types.ts +166 -0
  73. package/src/validate.ts +507 -0
  74. package/tsconfig.json +26 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The DISPLAY half — `<FeatureBadges/>` for a card, `<FeatureValueList/>` for
3
+ * a detail page. Both are renderers over `formatFeatureValue`; neither
4
+ * re-implements a single type's formatting.
5
+ *
6
+ * Two rules they hold that a naive spec table does not:
7
+ *
8
+ * - **An unreadable value says so.** A type this build cannot format renders
9
+ * a named notice, not an empty cell — the display twin of the unsupported
10
+ * editor. A blank cell where a spec line belongs reads as "this listing
11
+ * has no engine size", which is a different and false statement.
12
+ * - **`show_as_badge` / `show_at_title` are the CATEGORY's decision**, made
13
+ * by whoever configured the feature, and this component honours it instead
14
+ * of picking its own first three values.
15
+ */
16
+ import type { ReactElement } from "react";
17
+ import { Descriptions, Flex, Tag, Typography } from "antd";
18
+ import { useI18n, useT } from "@stapel/core";
19
+ import type { FeatureDef, FeaturesDto } from "../types.js";
20
+ import { featureName, featureType } from "../types.js";
21
+ import { formatFeatureValue, hexColorSwatch } from "../format.js";
22
+ import { ATTRIBUTES_I18N_KEYS } from "../i18n/keys.js";
23
+
24
+ export interface FeatureDisplayProps {
25
+ readonly features: readonly FeatureDef[];
26
+ /** The stored values, in the same `{slug: {type, value}}` envelope the
27
+ * composer submitted. */
28
+ readonly values: FeaturesDto;
29
+ }
30
+
31
+ /** Feature + its formatted value, in the order the category declared. */
32
+ function useRows(
33
+ props: FeatureDisplayProps,
34
+ only?: (feature: FeatureDef) => boolean
35
+ ): readonly {
36
+ readonly feature: FeatureDef;
37
+ readonly text: string | undefined;
38
+ readonly swatch: string | undefined;
39
+ }[] {
40
+ const t = useT();
41
+ const { locale } = useI18n();
42
+ return props.features
43
+ .filter((feature) => featureType(feature) !== "header")
44
+ .filter((feature) => (only ? only(feature) : true))
45
+ .map((feature) => {
46
+ const dto = props.values[feature.slug];
47
+ return {
48
+ feature,
49
+ text: formatFeatureValue(feature, dto, { t, locale }),
50
+ swatch: featureType(feature) === "hex_color" ? hexColorSwatch(dto) : undefined,
51
+ };
52
+ });
53
+ }
54
+
55
+ /** A row's value, or the named reason there is none. */
56
+ function ValueText(props: {
57
+ readonly feature: FeatureDef;
58
+ readonly text: string | undefined;
59
+ readonly hasValue: boolean;
60
+ }): ReactElement {
61
+ const t = useT();
62
+ if (props.text !== undefined) return <>{props.text}</>;
63
+ const type = featureType(props.feature);
64
+ // Two different absences, said differently: nothing was entered, versus
65
+ // something was entered that this build cannot read.
66
+ if (!props.hasValue || type === undefined) {
67
+ return (
68
+ <Typography.Text type="secondary">{t(ATTRIBUTES_I18N_KEYS.valueNotSet)}</Typography.Text>
69
+ );
70
+ }
71
+ return (
72
+ <Typography.Text type="warning" data-testid="attributes-unreadable-value">
73
+ {t(ATTRIBUTES_I18N_KEYS.valueUnreadable, { type })}
74
+ </Typography.Text>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The `show_as_badge` values, as antd `Tag`s — what a result card shows under
80
+ * a title. Features with no value are omitted here (a card is a summary, and
81
+ * "not specified" is not a selling point); the detail list below says it
82
+ * explicitly instead.
83
+ */
84
+ export function FeatureBadges(props: FeatureDisplayProps): ReactElement {
85
+ const rows = useRows(props, (feature) => feature.show_as_badge === true);
86
+ const shown = rows.filter((row) => row.text !== undefined);
87
+ return (
88
+ <Flex gap={4} wrap data-testid="attributes-badges">
89
+ {shown.map((row) => (
90
+ <Tag
91
+ key={row.feature.slug}
92
+ {...(row.swatch ? { color: row.swatch } : {})}
93
+ data-testid={`attributes-badge-${row.feature.slug}`}
94
+ >
95
+ {row.text}
96
+ </Tag>
97
+ ))}
98
+ </Flex>
99
+ );
100
+ }
101
+
102
+ /** Every feature and its value — the spec table of a detail page. */
103
+ export function FeatureValueList(props: FeatureDisplayProps): ReactElement {
104
+ const rows = useRows(props);
105
+ return (
106
+ <Descriptions
107
+ column={1}
108
+ size="small"
109
+ data-testid="attributes-value-list"
110
+ items={rows.map((row) => ({
111
+ key: row.feature.slug,
112
+ label: featureName(row.feature),
113
+ children: (
114
+ <ValueText
115
+ feature={row.feature}
116
+ text={row.text}
117
+ hasValue={props.values[row.feature.slug] !== undefined}
118
+ />
119
+ ),
120
+ }))}
121
+ />
122
+ );
123
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `<FeatureFields/>` — a category's features, drawn as antd form rows, and
3
+ * the place the three-rung ladder is actually executed.
4
+ *
5
+ * It holds no state and owns no submit: values and errors come in, changes go
6
+ * out. That is what lets the composer that DOES own the submit
7
+ * (listings-react) keep one source of truth for a draft, and what lets this
8
+ * component be dropped into any form — a filter panel, an admin preview —
9
+ * without dragging a mutation along.
10
+ */
11
+ import type { ReactElement, ReactNode } from "react";
12
+ import { Alert, Form } from "antd";
13
+ import { useFormatFlowError, useT } from "@stapel/core";
14
+ import type { FlowError } from "@stapel/core";
15
+ import type { FeatureDef } from "../types.js";
16
+ import { featureName, featureType } from "../types.js";
17
+ import { resolveValueEditor } from "../registry.js";
18
+ import { BUILTIN_VALUE_EDITORS } from "./editors.js";
19
+ import { ATTRIBUTES_I18N_KEYS } from "../i18n/keys.js";
20
+
21
+ /** Stable per-feature DOM id, so `<label for>` reaches the control. Slugs are
22
+ * `[a-z0-9_]`-shaped by the engine, so this needs no further escaping. */
23
+ export function featureControlId(slug: string): string {
24
+ return `attributes-field-${slug}`;
25
+ }
26
+
27
+ export interface UnsupportedValueEditorProps {
28
+ readonly feature: FeatureDef;
29
+ }
30
+
31
+ /**
32
+ * The loud last rung. NOT a skipped field: a category can legally carry a
33
+ * type this build has no editor for, and drawing nothing would silently drop
34
+ * a feature that may be mandatory — the person would submit a listing they
35
+ * could not complete and be told, by the server, that an attribute they never
36
+ * saw is missing.
37
+ */
38
+ export function UnsupportedValueEditor(
39
+ props: UnsupportedValueEditorProps
40
+ ): ReactElement {
41
+ const t = useT();
42
+ const type = featureType(props.feature);
43
+ return (
44
+ <Alert
45
+ type="warning"
46
+ showIcon
47
+ data-testid="attributes-unsupported-type"
48
+ message={featureName(props.feature)}
49
+ description={
50
+ type === undefined
51
+ ? t(ATTRIBUTES_I18N_KEYS.untypedFeature)
52
+ : t(ATTRIBUTES_I18N_KEYS.unsupportedType, { type })
53
+ }
54
+ />
55
+ );
56
+ }
57
+
58
+ export interface FeatureFieldsProps {
59
+ readonly features: readonly FeatureDef[];
60
+ /** Current answers keyed by slug. The DTO envelope is built at submit time
61
+ * with `toFeaturesDto`, not held here. */
62
+ readonly values: Readonly<Record<string, unknown>>;
63
+ onChange(slug: string, value: unknown): void;
64
+ /** Refusals keyed by slug — mirrored or from the server, folded through
65
+ * `featureErrorsBySlug` either way. */
66
+ readonly errors?: Readonly<Record<string, FlowError>>;
67
+ /** True while a submit is in flight — every editor goes read-only. */
68
+ readonly disabled?: boolean;
69
+ /** Rendered instead of a default `Form.Item` row, for a host with its own
70
+ * field chrome. */
71
+ readonly renderRow?: (row: FeatureRowProps) => ReactNode;
72
+ }
73
+
74
+ export interface FeatureRowProps {
75
+ readonly feature: FeatureDef;
76
+ readonly controlId: string;
77
+ readonly control: ReactNode;
78
+ readonly error: FlowError | undefined;
79
+ readonly required: boolean;
80
+ }
81
+
82
+ function FeatureRow(props: FeatureRowProps): ReactElement {
83
+ const format = useFormatFlowError();
84
+ // A header is a caption: no label, no colon, no required marker — rendering
85
+ // one inside a labelled Form.Item would make a section heading look like a
86
+ // question.
87
+ if (featureType(props.feature) === "header") {
88
+ return <Form.Item style={{ marginBottom: 8 }}>{props.control}</Form.Item>;
89
+ }
90
+ return (
91
+ <Form.Item
92
+ label={featureName(props.feature)}
93
+ htmlFor={props.controlId}
94
+ required={props.required}
95
+ {...(props.error ? { validateStatus: "error" as const, help: format(props.error) } : {})}
96
+ >
97
+ {props.control}
98
+ </Form.Item>
99
+ );
100
+ }
101
+
102
+ /** A category's features as form rows, each drawn by the ladder's winner. */
103
+ export function FeatureFields(props: FeatureFieldsProps): ReactElement {
104
+ const errors = props.errors ?? {};
105
+ return (
106
+ <>
107
+ {props.features.map((feature) => {
108
+ const controlId = featureControlId(feature.slug);
109
+ const type = featureType(feature);
110
+ // The ladder: a host's explicit registration outranks the skin's
111
+ // builtin, and nothing at all is a NOTICE, never an omission.
112
+ const Editor =
113
+ (type === undefined ? null : resolveValueEditor(type)) ??
114
+ (type === undefined ? undefined : BUILTIN_VALUE_EDITORS[type]);
115
+ const control =
116
+ Editor === undefined || Editor === null ? (
117
+ <UnsupportedValueEditor feature={feature} />
118
+ ) : (
119
+ <Editor
120
+ id={controlId}
121
+ feature={feature}
122
+ value={props.values[feature.slug]}
123
+ onChange={(value) => props.onChange(feature.slug, value)}
124
+ error={errors[feature.slug]}
125
+ disabled={props.disabled === true}
126
+ />
127
+ );
128
+ const row: FeatureRowProps = {
129
+ feature,
130
+ controlId,
131
+ control,
132
+ error: errors[feature.slug],
133
+ required: feature.mandatory === true,
134
+ };
135
+ if (props.renderRow) return <div key={feature.slug}>{props.renderRow(row)}</div>;
136
+ return <FeatureRow key={feature.slug} {...row} />;
137
+ })}
138
+ </>
139
+ );
140
+ }