@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
package/src/errors.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The error vocabulary, mirrored — and the one step that turns a server
3
+ * verdict into refusals sitting on the controls that caused them.
4
+ *
5
+ * Both halves come straight from `stapel_attributes.errors`: the machine code
6
+ * (`ValidationErrorCode`) and the localizable key it maps to
7
+ * (`ERROR_CODE_TO_KEY`). Nothing here invents copy: a "too long" caught by
8
+ * the client mirror and a "too long" caught by the server render the SAME
9
+ * sentence from the SAME key, because they ARE the same key. The alternative
10
+ * — pair-invented wording for the local half — gives a person two sentences
11
+ * for one problem and makes the local one a lie the moment the backend's rule
12
+ * moves.
13
+ *
14
+ * The keys themselves are owned by stapel-attributes and registered with
15
+ * stapel-core (`register_service_errors(ATTRIBUTES_ERRORS)`), so a host that
16
+ * already has a pair's generated error bundle has their copy; the fallback
17
+ * English lives in this package's own i18n bundle for a host that does not.
18
+ */
19
+ import type { FlowError } from "@stapel/core";
20
+ import type {
21
+ FeatureValidationResult,
22
+ ValidationBatchResult,
23
+ ValidationErrorCode,
24
+ } from "./types.js";
25
+
26
+ /**
27
+ * Every `ValidationErrorCode` the engine defines, sorted.
28
+ *
29
+ * Pinned byte-for-byte against `stapel-attributes/tests/golden/
30
+ * error_codes.json` — the corpus the engine generates from its own enum and
31
+ * asserts from both Python and TypeScript. A code added upstream and not here
32
+ * turns `test/contract.test.ts` red.
33
+ */
34
+ export const VALIDATION_ERROR_CODES: readonly ValidationErrorCode[] = [
35
+ "above_maximum",
36
+ "below_minimum",
37
+ "description_too_long",
38
+ "description_too_short",
39
+ "duplicate_slug",
40
+ "empty_options",
41
+ "invalid_config",
42
+ "invalid_format",
43
+ "invalid_type",
44
+ "mandatory_missing",
45
+ "min_greater_than_max",
46
+ "not_allowed",
47
+ "not_in_options",
48
+ "unknown_feature",
49
+ "unknown_feature_type",
50
+ ];
51
+
52
+ /**
53
+ * `ValidationErrorCode` → localizable key, mirroring
54
+ * `stapel_attributes.errors.ERROR_CODE_TO_KEY` exactly — including the three
55
+ * codes that deliberately collapse onto one key (`duplicate_slug` reports as
56
+ * an unknown type; `min_greater_than_max` and `empty_options` are both
57
+ * invalid config). Collapsing them differently here would make the client
58
+ * claim a distinction the server does not draw.
59
+ */
60
+ export const ERROR_CODE_TO_KEY: Readonly<Record<ValidationErrorCode, string>> = {
61
+ below_minimum: "error.400.feature_below_minimum",
62
+ above_maximum: "error.400.feature_above_maximum",
63
+ not_in_options: "error.400.feature_not_in_options",
64
+ invalid_type: "error.400.feature_invalid_type",
65
+ invalid_format: "error.400.feature_invalid_format",
66
+ mandatory_missing: "error.400.feature_mandatory_missing",
67
+ duplicate_slug: "error.400.feature_unknown_type",
68
+ unknown_feature_type: "error.400.feature_unknown_type",
69
+ not_allowed: "error.400.feature_not_allowed",
70
+ unknown_feature: "error.400.feature_unknown",
71
+ invalid_config: "error.400.feature_invalid_config",
72
+ min_greater_than_max: "error.400.feature_invalid_config",
73
+ empty_options: "error.400.feature_invalid_config",
74
+ description_too_short: "error.400.description_too_short",
75
+ description_too_long: "error.400.description_too_long",
76
+ };
77
+
78
+ /** The key a result should render from: what the server said, else the one
79
+ * its code maps to. The server sends `localizable_error` already — this only
80
+ * covers the mirror's rows and a server that ever omits it. */
81
+ export function resultErrorKey(
82
+ result: FeatureValidationResult
83
+ ): string | undefined {
84
+ if (typeof result.localizable_error === "string" && result.localizable_error.length > 0) {
85
+ return result.localizable_error;
86
+ }
87
+ return result.error ? ERROR_CODE_TO_KEY[result.error] : undefined;
88
+ }
89
+
90
+ /**
91
+ * A batch verdict, split into per-control refusals keyed by feature slug.
92
+ *
93
+ * `params.field` is added on the way out. The engine's own params are
94
+ * `{feature, slug}` (`validation.py`) and the fleet's `useFieldError`
95
+ * convention routes on `field` — so without this a perfectly well-formed
96
+ * server refusal lands in a page-level banner instead of under the control
97
+ * whose value caused it, which is the difference between "something is wrong"
98
+ * and "this box is wrong".
99
+ *
100
+ * `ref_value` rides along in params too: `error.400.feature_below_minimum`
101
+ * interpolates the limit, and the limit is what `ref_value` carries.
102
+ */
103
+ export function featureErrorsBySlug(
104
+ batch: ValidationBatchResult
105
+ ): Readonly<Record<string, FlowError>> {
106
+ const out: Record<string, FlowError> = {};
107
+ for (const result of batch.results) {
108
+ if (result.status !== "validation_failed") continue;
109
+ const code = resultErrorKey(result);
110
+ if (code === undefined) continue;
111
+ out[result.slug] = {
112
+ code,
113
+ params: {
114
+ ...(result.params ?? {}),
115
+ field: result.slug,
116
+ slug: result.slug,
117
+ ...(result.ref_value === undefined || result.ref_value === null
118
+ ? {}
119
+ : { ref_value: result.ref_value }),
120
+ },
121
+ status: 400,
122
+ message: result.message ?? undefined,
123
+ language: undefined,
124
+ };
125
+ }
126
+ return out;
127
+ }
package/src/format.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * DISPLAY — the read-only half of every value type.
3
+ *
4
+ * A storefront renders an attribute far more often than it edits one: a card
5
+ * shows badges, a detail page shows the whole spec table, a title carries the
6
+ * `show_at_title` values. Formatting therefore lives HERE, in the main entry,
7
+ * React-free and antd-free, so a card can format a value without pulling a
8
+ * skin into its bundle. `<FeatureBadges>`/`<FeatureValueList>` in `/default`
9
+ * are renderers over this function, not a second implementation of it.
10
+ *
11
+ * It mirrors each type's `format_value(config, dao)` (`stapel_attributes
12
+ * .types.*.type`), with the one honest difference the browser forces:
13
+ * `date` formats with the VIEWER's locale and time zone, where the engine
14
+ * formats with the server's — the same instant, written the way the person
15
+ * reading it writes dates.
16
+ *
17
+ * Unsupported types do not vanish. `formatFeatureValue` returns `undefined`
18
+ * for a type it cannot read, and the caller says so; a silent empty cell
19
+ * where a spec line belongs is the display twin of a dropped mandatory field.
20
+ */
21
+ import type { FeatureDef, FeatureValueDto } from "./types.js";
22
+ import { featureConfig, featureType } from "./types.js";
23
+ import { isBlank } from "./validate.js";
24
+
25
+ /** The types this module can render, sorted — the display half of the
26
+ * builtin set, and asserted equal to the editor half in `contract.test.ts`
27
+ * (a type you can fill in but cannot read back is a half-shipped type). */
28
+ export const FORMATTABLE_TYPES: readonly string[] = [
29
+ "bool",
30
+ "convertible_unit",
31
+ "date",
32
+ "float",
33
+ "header",
34
+ "hex_color",
35
+ "hierarchical_select",
36
+ "int",
37
+ "select",
38
+ "string",
39
+ ];
40
+
41
+ /** Copy the formatter needs from the host's translator — one function, so a
42
+ * caller outside React (a meta-tag builder, a title composer) can pass a
43
+ * plain lookup instead of standing up a provider. */
44
+ export interface FormatOptions {
45
+ /** Resolve an option label / boolean caption. Option labels are translation
46
+ * keys when the config says `translatable_options` (the default). */
47
+ readonly t?: (key: string) => string;
48
+ /** BCP-47 tag for `date`. Defaults to the runtime's own locale. */
49
+ readonly locale?: string;
50
+ }
51
+
52
+ function str(value: unknown): string {
53
+ return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value);
54
+ }
55
+
56
+ function translate(options: FormatOptions | undefined, key: string): string {
57
+ const t = options?.t;
58
+ if (t === undefined) return key;
59
+ const resolved = t(key);
60
+ // A translator that does not know a key conventionally returns the key —
61
+ // which is still the most informative thing available, so it stands.
62
+ return resolved.length > 0 ? resolved : key;
63
+ }
64
+
65
+ /** `{value → label}` for an options-bearing config, in either option shape. */
66
+ function labelOf(
67
+ config: Readonly<Record<string, unknown>>,
68
+ value: unknown,
69
+ options: FormatOptions | undefined
70
+ ): string {
71
+ const raw = config["options"];
72
+ if (Array.isArray(raw)) {
73
+ for (const option of raw) {
74
+ if (option !== null && typeof option === "object") {
75
+ const entry = option as { value?: unknown; label?: unknown };
76
+ if (entry.value === value && typeof entry.label === "string" && entry.label.length > 0) {
77
+ return config["translatable_options"] === false
78
+ ? entry.label
79
+ : translate(options, entry.label);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return str(value);
85
+ }
86
+
87
+ function formatNumber(
88
+ config: Readonly<Record<string, unknown>>,
89
+ value: unknown,
90
+ defaultPrecision: number
91
+ ): string | undefined {
92
+ const parsed = typeof value === "number" ? value : Number(value);
93
+ if (!Number.isFinite(parsed)) return undefined;
94
+ const precision = config["precision"];
95
+ const digits = typeof precision === "number" && precision >= 0 ? precision : defaultPrecision;
96
+ const body = digits > 0 ? parsed.toFixed(digits) : String(Math.trunc(parsed));
97
+ const prefix = str(config["prefix"]);
98
+ const postfix = str(config["postfix"]);
99
+ return `${prefix}${body}${postfix ? ` ${postfix}` : ""}`;
100
+ }
101
+
102
+ /** `precision` → the `Intl` fields the engine's own `strftime` would show. */
103
+ function formatTimestamp(
104
+ seconds: number,
105
+ precision: string,
106
+ locale: string | undefined
107
+ ): string {
108
+ const date = new Date(seconds * 1000);
109
+ switch (precision) {
110
+ case "year":
111
+ return new Intl.DateTimeFormat(locale, { year: "numeric" }).format(date);
112
+ case "month":
113
+ return new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(date);
114
+ case "datetime":
115
+ return new Intl.DateTimeFormat(locale, {
116
+ year: "numeric",
117
+ month: "short",
118
+ day: "numeric",
119
+ hour: "2-digit",
120
+ minute: "2-digit",
121
+ }).format(date);
122
+ default:
123
+ return new Intl.DateTimeFormat(locale, {
124
+ year: "numeric",
125
+ month: "short",
126
+ day: "numeric",
127
+ }).format(date);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * A submitted value, as a person reads it — or `undefined` when there is
133
+ * nothing to show (blank) or nothing this build can read (an unknown type).
134
+ * The two cases are distinguished by {@link isBlank}, so a caller can say
135
+ * "not specified" for one and name the type for the other.
136
+ */
137
+ export function formatFeatureValue(
138
+ feature: FeatureDef,
139
+ dto: FeatureValueDto | undefined,
140
+ options?: FormatOptions
141
+ ): string | undefined {
142
+ const type = featureType(feature);
143
+ if (type === "header" || dto === undefined || isBlank(dto.value)) return undefined;
144
+ const config = featureConfig(feature);
145
+ const value = dto.value;
146
+
147
+ switch (type) {
148
+ case "string":
149
+ return `${str(config["prefix"])}${str(value)}${
150
+ str(config["postfix"]) ? ` ${str(config["postfix"])}` : ""
151
+ }`;
152
+ case "int":
153
+ return formatNumber(config, value, 0);
154
+ case "float":
155
+ return formatNumber(config, value, 2);
156
+ case "bool": {
157
+ const on = value === true || value === 1 || value === "true";
158
+ const label = str(config[on ? "trueLabel" : "falseLabel"]);
159
+ if (label.length > 0) return translate(options, label);
160
+ return on ? "✓" : "—";
161
+ }
162
+ case "select": {
163
+ const items = Array.isArray(value) ? value : [value];
164
+ return items.map((item) => labelOf(config, item, options)).join(", ");
165
+ }
166
+ case "hierarchical_select": {
167
+ const path = Array.isArray(value) ? value : [value];
168
+ return path.map((step) => str(step)).join(" / ");
169
+ }
170
+ case "date": {
171
+ const seconds =
172
+ typeof value === "number"
173
+ ? Math.trunc(value)
174
+ : typeof value === "string" && /^-?\d+$/.test(value.trim())
175
+ ? Number.parseInt(value.trim(), 10)
176
+ : undefined;
177
+ if (seconds === undefined) return undefined;
178
+ return formatTimestamp(seconds, str(config["precision"]) || "date", options?.locale);
179
+ }
180
+ case "hex_color": {
181
+ if (value === null || typeof value !== "object") return undefined;
182
+ const entry = value as { hex?: unknown; simple?: unknown; label?: unknown };
183
+ if (typeof entry.label === "string" && entry.label.length > 0) {
184
+ return translate(options, entry.label);
185
+ }
186
+ return str(entry.simple) || str(entry.hex) || undefined;
187
+ }
188
+ case "convertible_unit": {
189
+ const parsed = typeof value === "number" ? value : Number(value);
190
+ if (!Number.isFinite(parsed)) return undefined;
191
+ const digits = typeof config["precision"] === "number" ? config["precision"] : 2;
192
+ const unit = str(dto["unit"]) || str(config["unit_m"]) || str(config["unit_i"]);
193
+ const body = `${str(config["prefix"])}${parsed.toFixed(digits)}`;
194
+ return unit ? `${body} ${unit}` : body;
195
+ }
196
+ default:
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ /** The `#RRGGBB` a `hex_color` value carries, when it carries one — the swatch
202
+ * a display skin paints beside the label. */
203
+ export function hexColorSwatch(dto: FeatureValueDto | undefined): string | undefined {
204
+ const value = dto?.value;
205
+ if (value === null || value === undefined || typeof value !== "object") return undefined;
206
+ const hex = (value as { hex?: unknown }).hex;
207
+ return typeof hex === "string" && /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(hex.trim())
208
+ ? hex.trim()
209
+ : undefined;
210
+ }
package/src/i18n/es.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+
3
+ /**
4
+ * Spanish bundle — the `@stapel/attributes-react/i18n/es` subpath, opt-in
5
+ * (i18n-shipping.md §2), same shape and same provenance caveat as `./ru`:
6
+ * stapel-attributes ships English only, so this copy is pair-authored and
7
+ * unreviewed, and deliberately worded to match forms-react's copy of the same
8
+ * twelve engine keys.
9
+ */
10
+ export const attributesI18nBundleEs: I18nDictionary = {
11
+ "error.400.feature_below_minimum": "El valor es inferior al mínimo de {feature}",
12
+ "error.400.feature_above_maximum": "El valor supera el máximo de {feature}",
13
+ "error.400.feature_not_in_options": "El valor no está entre las opciones de {feature}",
14
+ "error.400.feature_invalid_type": "Tipo de valor no válido para {feature}",
15
+ "error.400.feature_invalid_format": "Formato no válido para {feature}",
16
+ "error.400.feature_mandatory_missing": "{feature} es obligatorio",
17
+ "error.400.feature_unknown_type": "Tipo de característica desconocido para {feature}",
18
+ "error.400.feature_not_allowed": "{feature} no se admite aquí",
19
+ "error.400.feature_unknown": "Característica desconocida {feature}",
20
+ "error.400.feature_invalid_config": "Configuración no válida de {feature}",
21
+ "error.400.description_too_short": "La descripción debe tener al menos {min_length} caracteres",
22
+ "error.400.description_too_long": "La descripción debe tener como máximo {max_length} caracteres",
23
+
24
+ "attributes.unsupported_type":
25
+ "Esta versión no incluye un editor para el tipo de atributo «{type}», así que no se puede rellenar aquí.",
26
+ "attributes.submit.blocked.unsupported_type":
27
+ "Algunos atributos no se pueden rellenar en esta página: {types}",
28
+ "attributes.untyped_feature": "Este atributo no declara ningún tipo y no se puede editar.",
29
+ "attributes.value.not_set": "Sin especificar",
30
+ "attributes.value.unreadable": "Esta versión no muestra valores de tipo «{type}»",
31
+ "attributes.bool.yes": "Sí",
32
+ "attributes.bool.no": "No",
33
+ "attributes.select.placeholder": "Elegir",
34
+ "attributes.required": "Obligatorio",
35
+ };
36
+
37
+ /** Register the `es` bundle. Call AFTER `registerAttributesI18n` so it
38
+ * overrides the English floor. */
39
+ export function registerAttributesI18nEs(engine: I18nEngine, locale = "es"): void {
40
+ engine.registerBundle(locale, attributesI18nBundleEs);
41
+ }
@@ -0,0 +1,89 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+
3
+ /**
4
+ * `@stapel/attributes-react`'s own translation KEYS (frontend-standard §4.2):
5
+ * nothing in this package renders a literal string — a host resolves these
6
+ * through core's i18n engine (`useT`).
7
+ *
8
+ * Two families live here, and only two:
9
+ *
10
+ * - `attributes.*` — this package's OWN chrome: the unsupported-type notice,
11
+ * the blocked-submit reason, the empty/optional captions, the control
12
+ * placeholders.
13
+ * - `error.400.feature_*` / `error.400.description_*` — the engine's own
14
+ * error catalogue (`stapel_attributes.errors.ATTRIBUTES_ERRORS`, registered
15
+ * with stapel-core), carried here as an English floor so a host that has
16
+ * not installed a pair whose `gen:errors` bundle includes them still shows
17
+ * a sentence rather than a key. A host that HAS one registers it after
18
+ * this bundle and the generated copy wins.
19
+ *
20
+ * ── What is NOT a key here ─────────────────────────────────────────────────
21
+ *
22
+ * A feature's `name` and its options' `label`s are admin-authored content
23
+ * carried in the category's own data, not keys of this package. When
24
+ * `config.translatable_options` is set (the default) those labels ARE
25
+ * translation keys — but they belong to the deployment's catalogue, not to
26
+ * this bundle, which is why the formatter takes a `t` and never a table.
27
+ */
28
+ export const ATTRIBUTES_I18N_KEYS = {
29
+ /** The loud last rung of the resolution ladder. */
30
+ unsupportedType: "attributes.unsupported_type",
31
+ /** Why the submit is off while an undrawable feature is on screen. */
32
+ submitBlockedUnsupportedType: "attributes.submit.blocked.unsupported_type",
33
+ /** A feature whose config declares no type at all. */
34
+ untypedFeature: "attributes.untyped_feature",
35
+ /** Display side: the value is absent. Never rendered as an empty cell. */
36
+ valueNotSet: "attributes.value.not_set",
37
+ /** Display side: the type has no formatter in this build. */
38
+ valueUnreadable: "attributes.value.unreadable",
39
+ boolYes: "attributes.bool.yes",
40
+ boolNo: "attributes.bool.no",
41
+ selectPlaceholder: "attributes.select.placeholder",
42
+ required: "attributes.required",
43
+ } as const;
44
+
45
+ export type AttributesI18nKey =
46
+ (typeof ATTRIBUTES_I18N_KEYS)[keyof typeof ATTRIBUTES_I18N_KEYS];
47
+
48
+ /**
49
+ * The engine's error catalogue, English — a verbatim mirror of
50
+ * `stapel_attributes.errors.ATTRIBUTES_ERRORS`, including its
51
+ * `{feature}`/`{min_length}`/`{max_length}` placeholders. Same key, same
52
+ * sentence, whichever side of the wire noticed the problem.
53
+ */
54
+ export const ATTRIBUTES_ERROR_BUNDLE_EN: I18nDictionary = {
55
+ "error.400.feature_below_minimum": "Value is below minimum for {feature}",
56
+ "error.400.feature_above_maximum": "Value is above maximum for {feature}",
57
+ "error.400.feature_not_in_options": "Value is not in allowed options for {feature}",
58
+ "error.400.feature_invalid_type": "Invalid type for {feature}",
59
+ "error.400.feature_invalid_format": "Invalid format for {feature}",
60
+ "error.400.feature_mandatory_missing": "Mandatory feature {feature} is required",
61
+ "error.400.feature_unknown_type": "Unknown feature type for {feature}",
62
+ "error.400.feature_not_allowed": "Feature {feature} is not allowed here",
63
+ "error.400.feature_unknown": "Unknown feature {feature}",
64
+ "error.400.feature_invalid_config": "Invalid config for {feature}",
65
+ "error.400.description_too_short": "Description must be at least {min_length} characters",
66
+ "error.400.description_too_long": "Description must be at most {max_length} characters",
67
+ };
68
+
69
+ export const attributesI18nBundleEn: I18nDictionary = {
70
+ ...ATTRIBUTES_ERROR_BUNDLE_EN,
71
+ "attributes.unsupported_type":
72
+ "This build has no editor for the “{type}” attribute type, so it cannot be filled in here.",
73
+ "attributes.submit.blocked.unsupported_type":
74
+ "Some attributes cannot be filled in on this page: {types}",
75
+ "attributes.untyped_feature": "This attribute declares no type and cannot be edited.",
76
+ "attributes.value.not_set": "Not specified",
77
+ "attributes.value.unreadable": "Cannot display a “{type}” value in this build",
78
+ "attributes.bool.yes": "Yes",
79
+ "attributes.bool.no": "No",
80
+ "attributes.select.placeholder": "Choose",
81
+ "attributes.required": "Required",
82
+ };
83
+
84
+ /** Register the package's `en` floor into a core i18n engine (call once at
85
+ * startup, before any locale override — the convention every
86
+ * `@stapel/*-react` package follows). */
87
+ export function registerAttributesI18n(engine: I18nEngine, locale = "en"): void {
88
+ engine.registerBundle(locale, attributesI18nBundleEn);
89
+ }
package/src/i18n/ru.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+
3
+ /**
4
+ * Russian bundle — the `@stapel/attributes-react/i18n/ru` subpath, opt-in
5
+ * (i18n-shipping.md §2): the main entry does not import this module, so a
6
+ * host that does not register it never carries these strings (gated by
7
+ * size-limit).
8
+ *
9
+ * PROVENANCE, stated rather than implied: stapel-attributes ships English
10
+ * only (no `translations/` directory — which is why `gen:errors` runs with
11
+ * `ERRORS_LOCALE_EXEMPT_OWNERS=stapel_attributes` for forms-react), so the
12
+ * error copy below is pair-authored, not upstream, and unreviewed. Same grade
13
+ * as forms-react's copy of the same twelve keys, and deliberately the same
14
+ * WORDING — two pairs must not give one refusal two sentences.
15
+ */
16
+ export const attributesI18nBundleRu: I18nDictionary = {
17
+ "error.400.feature_below_minimum": "Значение меньше минимального для «{feature}»",
18
+ "error.400.feature_above_maximum": "Значение больше максимального для «{feature}»",
19
+ "error.400.feature_not_in_options":
20
+ "Значение отсутствует среди допустимых вариантов для «{feature}»",
21
+ "error.400.feature_invalid_type": "Неверный тип значения для «{feature}»",
22
+ "error.400.feature_invalid_format": "Неверный формат значения для «{feature}»",
23
+ "error.400.feature_mandatory_missing": "Поле «{feature}» обязательно для заполнения",
24
+ "error.400.feature_unknown_type": "Неизвестный тип поля «{feature}»",
25
+ "error.400.feature_not_allowed": "Поле «{feature}» здесь недопустимо",
26
+ "error.400.feature_unknown": "Неизвестное поле «{feature}»",
27
+ "error.400.feature_invalid_config": "Неверная конфигурация поля «{feature}»",
28
+ "error.400.description_too_short": "Описание должно содержать не менее {min_length} символов",
29
+ "error.400.description_too_long": "Описание должно содержать не более {max_length} символов",
30
+
31
+ "attributes.unsupported_type":
32
+ "В этой сборке нет редактора для типа характеристики «{type}», заполнить её здесь нельзя.",
33
+ "attributes.submit.blocked.unsupported_type":
34
+ "Часть характеристик нельзя заполнить на этой странице: {types}",
35
+ "attributes.untyped_feature": "У характеристики не указан тип, редактировать её нельзя.",
36
+ "attributes.value.not_set": "Не указано",
37
+ "attributes.value.unreadable": "Значение типа «{type}» в этой сборке не отображается",
38
+ "attributes.bool.yes": "Да",
39
+ "attributes.bool.no": "Нет",
40
+ "attributes.select.placeholder": "Выберите",
41
+ "attributes.required": "Обязательно",
42
+ };
43
+
44
+ /** Register the `ru` bundle. Call AFTER `registerAttributesI18n` so it
45
+ * overrides the English floor. */
46
+ export function registerAttributesI18nRu(engine: I18nEngine, locale = "ru"): void {
47
+ engine.registerBundle(locale, attributesI18nBundleRu);
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `@stapel/attributes-react` — the React value layer for `stapel-attributes`'
3
+ * dynamic feature types.
4
+ *
5
+ * ── What this package is, and what it deliberately is not ──────────────────
6
+ *
7
+ * It is an **L0 package, not a pair**, modelled on `@stapel/image`: no client,
8
+ * no queries, no `docs/schema.json`. Its backend counterpart is an L1 library
9
+ * with no HTTP surface whatsoever (`stapel-attributes/docs/readme.md`) — there
10
+ * is no `/attributes/api/v1/` to pair with. Feature definitions and validation
11
+ * verdicts reach a browser inside the responses of the modules that OWN them
12
+ * (categories, listings), and those pairs depend on this one to draw and check
13
+ * what they carry.
14
+ *
15
+ * It switches on **`config.type`** — the value type (`string`, `int`,
16
+ * `select`, `date`, `hex_color`, `hierarchical_select`, `convertible_unit`,
17
+ * `bool`, `float`, `header`). That is a DIFFERENT axis from the one
18
+ * `@stapel/forms-react` works on, which is `FormField.kind`: the field kinds
19
+ * of the admin form that CONFIGURES a type. Same upstream library, two
20
+ * vocabularies, and the storefront needs this one. A consequence worth
21
+ * stating because it removes an upstream ask: a storefront needs **no
22
+ * catalogue endpoint** for these types — the type arrives in the data, on
23
+ * every feature.
24
+ *
25
+ * ── The three-rung ladder, verbatim from forms-react ───────────────────────
26
+ *
27
+ * explicit `registerValueEditor(type, …)` ← a host's, always wins
28
+ * → the skin's `BUILTIN_VALUE_EDITORS` ← `/default`, ten types
29
+ * → `<UnsupportedValueEditor/>` ← loud, never a skipped field
30
+ *
31
+ * and, while an undrawable feature is on screen, `unsupportedTypeGate` blocks
32
+ * the submit with the reason NAMED. A category can legally carry a type this
33
+ * build has no editor for; drawing nothing would silently drop a feature that
34
+ * may be MANDATORY, and the person would submit a listing they could not
35
+ * complete and be told, by the server, that an attribute they never saw is
36
+ * missing.
37
+ *
38
+ * ── Layout ─────────────────────────────────────────────────────────────────
39
+ *
40
+ * `.` registry + mirror + DTO helpers + display formatting.
41
+ * React types only; no antd, no react-router, no fetch.
42
+ * `./default` the antd skin: ten builtin editors, `<FeatureFields>`,
43
+ * `<FeatureBadges>`, `<FeatureValueList>`.
44
+ * `./i18n/ru` opt-in locale bundles.
45
+ * `./i18n/es`
46
+ */
47
+
48
+ export type {
49
+ FeatureConfig,
50
+ FeatureDef,
51
+ FeatureValueDto,
52
+ FeatureValidationResult,
53
+ FeaturesDto,
54
+ ValidationBatchResult,
55
+ ValidationErrorCode,
56
+ ValidationStatus,
57
+ } from "./types.js";
58
+ export { featureConfig, featureName, featureType } from "./types.js";
59
+
60
+ export {
61
+ UNTYPED_FEATURE,
62
+ registerValueEditor,
63
+ registeredValueEditorTypes,
64
+ resolveValueEditor,
65
+ unregisterValueEditor,
66
+ unsupportedTypeGate,
67
+ unsupportedTypes,
68
+ } from "./registry.js";
69
+ export type { ValueEditor, ValueEditorProps } from "./registry.js";
70
+
71
+ export {
72
+ ERROR_CODE_TO_KEY,
73
+ VALIDATION_ERROR_CODES,
74
+ featureErrorsBySlug,
75
+ resultErrorKey,
76
+ } from "./errors.js";
77
+
78
+ export {
79
+ SIMPLE_COLORS,
80
+ codePointLength,
81
+ isBlank,
82
+ mirrorValidate,
83
+ patternFullMatch,
84
+ validateFeatureValue,
85
+ } from "./validate.js";
86
+
87
+ export { fromFeaturesDto, toFeaturesDto } from "./dto.js";
88
+
89
+ export { FORMATTABLE_TYPES, formatFeatureValue, hexColorSwatch } from "./format.js";
90
+ export type { FormatOptions } from "./format.js";
91
+
92
+ export {
93
+ ATTRIBUTES_ERROR_BUNDLE_EN,
94
+ ATTRIBUTES_I18N_KEYS,
95
+ attributesI18nBundleEn,
96
+ registerAttributesI18n,
97
+ } from "./i18n/keys.js";
98
+ export type { AttributesI18nKey } from "./i18n/keys.js";