create-procode 0.0.0-stage → 1.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 (68) hide show
  1. package/README.md +266 -2
  2. package/bin/create-procode.mjs +9 -0
  3. package/package.json +30 -4
  4. package/src/args.mjs +145 -0
  5. package/src/cli.mjs +514 -0
  6. package/src/deps.mjs +208 -0
  7. package/src/log.mjs +23 -0
  8. package/src/multiLanguage.mjs +409 -0
  9. package/src/prompt.mjs +60 -0
  10. package/src/scaffold.mjs +104 -0
  11. package/templates/base/README.md.tmpl +185 -0
  12. package/templates/base/_dotenv.local.example.tmpl +8 -0
  13. package/templates/base/_dotenv.tmpl +41 -0
  14. package/templates/base/_doteslintrc.cjs.tmpl +125 -0
  15. package/templates/base/_dotgitignore.tmpl +32 -0
  16. package/templates/base/_dotnvmrc.tmpl +1 -0
  17. package/templates/base/_dotvscode/extensions.json +3 -0
  18. package/templates/base/index.html.tmpl +18 -0
  19. package/templates/base/package.json.tmpl +15 -0
  20. package/templates/base/public/favicon.svg +4 -0
  21. package/templates/base/src/AdapterProvider/AdapterFactory.ts.tmpl +21 -0
  22. package/templates/base/src/AdapterProvider/SampleContacts.ts.tmpl +136 -0
  23. package/templates/base/src/CustomAction.ts.tmpl +47 -0
  24. package/templates/base/src/CustomAuthorizationProvider.ts.tmpl +77 -0
  25. package/templates/base/src/Factory/Actions/myAction.ts.tmpl +51 -0
  26. package/templates/base/src/Factory/Actions/sampleContacts.ts.tmpl +244 -0
  27. package/templates/base/src/Factory/Actions/setField.ts.tmpl +20 -0
  28. package/templates/base/src/Factory/CustomRuleEffectFactory.ts.tmpl +30 -0
  29. package/templates/base/src/Factory/CustomValidationFactory.tsx.tmpl +24 -0
  30. package/templates/base/src/Factory/CustomWidgetFactory.tsx.tmpl +43 -0
  31. package/templates/base/src/Factory/RuleEffects/readOnlyEffect.ts.tmpl +27 -0
  32. package/templates/base/src/Factory/Validations/myValidation.ts.tmpl +52 -0
  33. package/templates/base/src/Factory/Widgets/MyWidget/index.tsx.tmpl +101 -0
  34. package/templates/base/src/Factory/Widgets/Notification/index.tsx.tmpl +53 -0
  35. package/templates/base/src/Factory/Widgets/Notification/notifiFunc.ts.tmpl +9 -0
  36. package/templates/base/src/Factory/Widgets/SectionNav/index.tsx +193 -0
  37. package/templates/base/src/Factory/Widgets/SectionNav/style.scss +139 -0
  38. package/templates/base/src/Module/Sample/Contacts.json.tmpl +965 -0
  39. package/templates/base/src/Module/index.ts.tmpl +26 -0
  40. package/templates/base/src/Screens/index.tsx.tmpl +45 -0
  41. package/templates/base/src/Utils/errorMessages.ts.tmpl +35 -0
  42. package/templates/base/src/Utils/fieldErrorMapper.ts.tmpl +46 -0
  43. package/templates/base/src/Utils/logger.ts.tmpl +21 -0
  44. package/templates/base/src/Utils/responseFormatter.ts.tmpl +39 -0
  45. package/templates/base/src/Utils/storages.ts.tmpl +35 -0
  46. package/templates/base/src/assets/styles/index.scss.tmpl +149 -0
  47. package/templates/base/src/constant.ts.tmpl +53 -0
  48. package/templates/base/src/icons.ts.tmpl +17 -0
  49. package/templates/base/src/localizationResolver.ts.tmpl +48 -0
  50. package/templates/base/src/main.tsx.tmpl +136 -0
  51. package/templates/base/src/routes.ts.tmpl +20 -0
  52. package/templates/base/src/vite-env.d.ts.tmpl +20 -0
  53. package/templates/base/tsconfig.json.tmpl +30 -0
  54. package/templates/base/tsconfig.node.json.tmpl +11 -0
  55. package/templates/base/vite.config.ts.tmpl +90 -0
  56. package/templates/multi-language/locales/fr/core.json +76 -0
  57. package/templates/multi-language/locales/fr/kendo.json +181 -0
  58. package/templates/multi-language/locales/fr/template.json +169 -0
  59. package/templates/multi-language/locales/fr/widget.json +65 -0
  60. package/templates/multi-language/locales/hi/core.json +76 -0
  61. package/templates/multi-language/locales/hi/kendo.json +181 -0
  62. package/templates/multi-language/locales/hi/template.json +169 -0
  63. package/templates/multi-language/locales/hi/widget.json +65 -0
  64. package/templates/multi-language/sample/fr.json +55 -0
  65. package/templates/multi-language/sample/hi.json +55 -0
  66. package/templates/tests/src/Module/schemas.test.ts +50 -0
  67. package/templates/tests-multi-language/src/multiLanguage/enText.ts +68 -0
  68. package/templates/tests-multi-language/src/multiLanguage/translations.test.ts +92 -0
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { WidgetTypes } from "../constant";
3
+ import MyWidget from "./Widgets/MyWidget";
4
+ import SectionNav from "./Widgets/SectionNav";
5
+
6
+ /**
7
+ * Extension point 3 of 5: custom widgets, resolved by WidgetTypes constant.
8
+ *
9
+ * Resolution order across the whole app is custom -> template -> standard, so
10
+ * registering a type name that already exists overrides it EVERYWHERE. Do not
11
+ * register a custom TEXTBOX, DATE, DATERANGE, RADIOGROUP, CHECKBOX or DIALOG —
12
+ * vs-template's grid toolbar depends on those and you will visibly break the
13
+ * global-search box.
14
+ *
15
+ * A widget is presentation only. It receives its value through props and writes
16
+ * back through props.onChange; it never fetches its own data.
17
+ */
18
+ class CustomWidgetFactory {
19
+ private static factory: CustomWidgetFactory;
20
+ private widgetMap = new Map<string, React.FC<any>>();
21
+
22
+ private constructor() {
23
+ this.createWidgetMap();
24
+ }
25
+
26
+ static get() {
27
+ if (!CustomWidgetFactory.factory) {
28
+ CustomWidgetFactory.factory = new CustomWidgetFactory();
29
+ }
30
+ return CustomWidgetFactory.factory;
31
+ }
32
+
33
+ private createWidgetMap(): void {
34
+ this.widgetMap.set(WidgetTypes.MY_WIDGET, MyWidget);
35
+ this.widgetMap.set(WidgetTypes.SECTION_NAV, SectionNav);
36
+ }
37
+
38
+ getWidgets() {
39
+ return this.widgetMap;
40
+ }
41
+ }
42
+
43
+ export default CustomWidgetFactory;
@@ -0,0 +1,27 @@
1
+ import type { RuleEffectFc } from "procode-lowcode-core";
2
+
3
+ /**
4
+ * CUS_READONLY — the element is read-only while the rule's condition is met.
5
+ *
6
+ * A rule effect receives the target element and the condition that matched
7
+ * (undefined when none did) and returns the element's replacement. The engine
8
+ * re-runs every rule until nothing changes (up to 50 passes), on screen init
9
+ * and on every view-model change, so an effect must be:
10
+ *
11
+ * total — write the property on BOTH branches; the engine keeps no copy
12
+ * of the authored value to restore.
13
+ * idempotent — return {} when the element is already right, or the loop
14
+ * never settles.
15
+ * pure — no fetch, dispatch or toast; a new object, never a mutation.
16
+ *
17
+ * Any key written onto the element reaches the widget as a prop. TEXTBOX and
18
+ * TEXTAREA honour `readOnly`; DROPDOWN and COMBOBOX do not — for those use the
19
+ * standard DISABLE_ELEMENT effect instead.
20
+ *
21
+ * { "effect": "CUS_READONLY", "config": { "otherwise": false }, "conditionList": [ … ] }
22
+ */
23
+ export const readOnlyEffect: RuleEffectFc = ({ uiElement, condition, rule }) => {
24
+ const readOnly = condition ? true : Boolean(rule.config?.otherwise);
25
+ if (uiElement.readOnly === readOnly) return {};
26
+ return { modifiedUiElement: { ...uiElement, readOnly } };
27
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A custom validator.
3
+ *
4
+ * THE CONTRACT — the core's ValidationInvoker calls every validator, standard
5
+ * or custom, as
6
+ *
7
+ * validationFc({ validation, viewModel, eventService, value })
8
+ * -> { isValid: boolean; message: string }
9
+ *
10
+ * (procode-core/src/Validation/ValidationInvoker.ts, `executeValidations`) and
11
+ * pushes `message` into the field's error list when `isValid` is false.
12
+ * Returning a bare boolean is the classic mistake: `{ isValid, message }`
13
+ * destructured from `true` is `undefined` twice, so the field collects an
14
+ * `undefined` message and the validation-message HOC throws on render —
15
+ * "Cannot read properties of undefined (reading 'replaceAll')" — taking the
16
+ * whole screen to the error boundary.
17
+ *
18
+ * validation the rule as authored in the schema: { name, message, params, … }
19
+ * value the value at the rule's screenDataField (or the widget's)
20
+ * viewModel the whole { screenData, supportiveData } — for cross-field rules
21
+ *
22
+ * Referenced from a schema as:
23
+ *
24
+ * "validations": [
25
+ * { "name": "REQUIRED", "message": "Please enter a value",
26
+ * "executeOrder": 1, "executeMode": "ONVALUECHANGE|ONSUBMIT" },
27
+ * { "name": "MY_VALIDATION", "message": "Use capitals, digits and underscore only",
28
+ * "executeOrder": 2, "executeMode": "ONVALUECHANGE|ONSUBMIT" }
29
+ * ]
30
+ *
31
+ * Leave emptiness to REQUIRED. A validator that also rejects "" makes a blank
32
+ * optional field show the wrong message. Use the schema's `message` so the
33
+ * text is authored next to the rule, with a fallback for a rule that omits it.
34
+ */
35
+ export const myValidation = ({
36
+ validation,
37
+ value,
38
+ }: {
39
+ validation: { message?: string; params?: Record<string, any> };
40
+ value: any;
41
+ viewModel?: any;
42
+ eventService?: any;
43
+ }): { isValid: boolean; message: string } => {
44
+ if (value === null || value === undefined || value === "") {
45
+ return { isValid: true, message: "" };
46
+ }
47
+ const isValid = /^[A-Z0-9_]+$/.test(String(value));
48
+ return {
49
+ isValid,
50
+ message: isValid ? "" : validation?.message || "Use capitals, digits and underscore only",
51
+ };
52
+ };
@@ -0,0 +1,101 @@
1
+ import { TextBox, Button } from "procode-lowcode-kendo-widget/widgets";
2
+
3
+ /**
4
+ * A custom widget — presentation only, and COMPOSED FROM LIBRARY WIDGETS.
5
+ *
6
+ * Two rules bind every file in this folder:
7
+ *
8
+ * 1. Whatever a custom widget needs inside it — a text box, a dropdown, a
9
+ * date picker, a button, an icon, a dialog — is rendered with the
10
+ * library's component for it, imported from
11
+ * procode-lowcode-kendo-widget/widgets. Not a raw <input>, and NEVER
12
+ * `import { TextBox } from "@progress/kendo-react-inputs"`: that
13
+ * component would sit outside the theme's focus/disabled/invalid
14
+ * treatment, the label and validation HOCs, widgetStyle, registerIcons
15
+ * and the override seam, and look different from the schema TEXTBOX
16
+ * beside it. The ESLint config refuses such an import unless a
17
+ * `procode-escalation:` comment sits on the line above it — legitimate
18
+ * only for a control the catalog does not wrap.
19
+ *
20
+ * 2. It never fetches its own data. A widget that calls an API loses the
21
+ * loading counter and skeletons, status-code toasts, 422 -> field-error
22
+ * routing and a schema-visible endpoint. Data belongs to `apiConfig`
23
+ * plus a standard action bound to a schema event.
24
+ *
25
+ * The props arrive from useWidgetProps and are passed STRAIGHT
26
+ * THROUGH. Every library widget relays change as
27
+ * onChange(null, screenDataField, value, event) — the same shape a schema
28
+ * TEXTBOX raises — so the CUS_MY_ACTION::MODIFY_MODEL chain below sees exactly
29
+ * what it would see on a standard field, and the label, the REQUIRED asterisk
30
+ * and the validation message under the box are drawn by the library.
31
+ *
32
+ * Referenced from a schema as:
33
+ *
34
+ * {
35
+ * "uiElementType": "WIDGET",
36
+ * "widgetType": "MY_WIDGET",
37
+ * "title": "Reference code",
38
+ * "screenDataField": "screenData.referenceCode",
39
+ * "applicableOn": "referenceCode",
40
+ * "skeletonType": "SQUARE_LARGE",
41
+ * "events": {
42
+ * "onChange": { "key": "MODIFY_MODEL" },
43
+ * "onReload": { "key": "CUS_SUGGEST_CODE::MODIFY_MODEL" }
44
+ * }
45
+ * }
46
+ */
47
+ const MyWidget = (props: any) => {
48
+ // props.value — current value at props.screenDataField
49
+ // props.screenDataField — the dot-path this widget is bound to
50
+ // props.onChange — (callBack, fieldPath, newValue, event) => void
51
+ // props.validations — the runtime's current error messages (string[])
52
+ // props.availableValidations — the schema's rules (drives the REQUIRED mark)
53
+ // props.dynamicEvents — EVERY event declared on the element, by name
54
+ // ...plus every other key authored on the element in the schema
55
+
56
+ return (
57
+ <div className="prc-flx-container prc-verticle qo-gap-y-sm qo-padding-sm">
58
+ <TextBox
59
+ name={props.screenDataField}
60
+ screenDataField={props.screenDataField}
61
+ value={props.value}
62
+ onChange={props.onChange}
63
+ onBlur={props.onBlur}
64
+ label={{
65
+ title: props.title,
66
+ isApply: Boolean(props.title),
67
+ position: "top",
68
+ widgetStyle: { className: "qo-label prc-blc-inline-container" },
69
+ }}
70
+ placeHolder={props.placeHolder ?? props.placeholder}
71
+ disabled={props.disabled ?? false}
72
+ // A field a CUSTOM WIDGET renders never passes through a schema, so it
73
+ // never gets a widgetStyle from one and falls back to Kendo's own 30px.
74
+ // qo-h-control puts it on the same 40px ramp as every schema field
75
+ // beside it. `qo-size-*` does not exist in the theme.
76
+ widgetStyle={{ className: "qo-h-control" }}
77
+ validations={props.validations}
78
+ availableValidations={props.availableValidations}
79
+ viewModel={props.viewModel}
80
+ />
81
+
82
+ {/* An event the prop types do not name is reached through dynamicEvents,
83
+ so a renamed schema event shows up as undefined here rather than as a
84
+ type error somewhere else. The library Button calls onClick(null);
85
+ the field path is passed on as the first EVENT ARGUMENT so the
86
+ action (CUS_SUGGEST_CODE) knows which field to fill. */}
87
+ <Button
88
+ title="Suggest a code"
89
+ role="button"
90
+ type="button"
91
+ fillMode="outline"
92
+ togglable={false}
93
+ defaultImageUrl=""
94
+ widgetStyle={{ className: "qo-btn qo-btn-sm qo-btn-outline-default qo-text-primary" }}
95
+ onClick={() => props.dynamicEvents?.onReload?.(null, props.screenDataField)}
96
+ />
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export default MyWidget;
@@ -0,0 +1,53 @@
1
+ import { forwardRef, useImperativeHandle } from "react";
2
+ import { ToastContainer } from "react-toastify";
3
+ import { errorNotify, successNotify } from "./notifiFunc";
4
+ import "react-toastify/dist/ReactToastify.css";
5
+
6
+ /**
7
+ * The notification surface, handed to <Core> as NotificationElement.
8
+ *
9
+ * The core keeps a ref to this component and calls showNotification({ message,
10
+ * type }) on it — for responseConfig toasts, denied actions and validation
11
+ * summaries. It is the app's ONE toast host: a hand-rolled toast anywhere else
12
+ * means two stacks that do not know about each other.
13
+ */
14
+ enum NotifiType {
15
+ SUCCESS = "SUCCESS",
16
+ ERROR = "ERROR",
17
+ }
18
+
19
+ const Notification = forwardRef((_props: any, ref) => {
20
+ const showNotification = ({ message, type }: any) => {
21
+ switch (type) {
22
+ case NotifiType.SUCCESS:
23
+ successNotify(message);
24
+ break;
25
+ case NotifiType.ERROR:
26
+ errorNotify(message);
27
+ break;
28
+ default:
29
+ break;
30
+ }
31
+ };
32
+
33
+ useImperativeHandle(ref, () => ({ showNotification }));
34
+
35
+ return (
36
+ <ToastContainer
37
+ position="bottom-center"
38
+ autoClose={3000}
39
+ hideProgressBar={false}
40
+ newestOnTop
41
+ closeOnClick
42
+ rtl={false}
43
+ pauseOnFocusLoss
44
+ draggable
45
+ pauseOnHover
46
+ theme="colored"
47
+ />
48
+ );
49
+ });
50
+
51
+ Notification.displayName = "Notification";
52
+
53
+ export default Notification;
@@ -0,0 +1,9 @@
1
+ import { toast } from "react-toastify";
2
+
3
+ export const successNotify = (message: string) => {
4
+ toast.success(message);
5
+ };
6
+
7
+ export const errorNotify = (message: string) => {
8
+ toast.error(message);
9
+ };
@@ -0,0 +1,193 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Icon } from "procode-lowcode-kendo-widget/widgets";
3
+
4
+ // procode-escalation: raw-control — L4 cannot compose this because the catalog
5
+ // has no vertical nav-item primitive: the only clickable text widget is BUTTON,
6
+ // whose library component renders a Kendo Button carrying min-height 40px,
7
+ // padding, a 6px radius, a border and the theme's own hover/focus fill
8
+ // A section row is a 38px flush text row with a 3px left accent bar, a tinted
9
+ // active state and `font: inherit`, so every one of those would have to be
10
+ // overridden — which is the anti-pattern the library-first rule exists to
11
+ // prevent, not an instance of it. VS Backoffice's SPOPSectionNav reaches the
12
+ // same conclusion and uses a raw <button> for the row. The ICON inside the row
13
+ // is still the library widget, per the library-first rule.
14
+ // Catalog candidate: a NAV_LIST / SECTION_NAV widget in procode-vs-template.
15
+
16
+ /**
17
+ * SECTION_NAV — the left "Sections" rail on a long form or view screen.
18
+ *
19
+ * Ported from VS Backoffice's `SPOP_SECTION_NAV`
20
+ * (src/Factory/Widgets/SPOPSectionNav/index.tsx), which is the house pattern for
21
+ * this: a white card with a small uppercase heading and badge, a one-line hint,
22
+ * then one row per section with an icon, the active row marked by a left accent
23
+ * bar and a tinted background.
24
+ *
25
+ * **IT SCROLLS, IT DOES NOT SWITCH.** Every section is rendered and the whole
26
+ * form is readable by scrolling; clicking a row just brings that section to the
27
+ * top of the form panel. So the highlight cannot be driven by a click alone — an
28
+ * operator who scrolls past three sections without clicking would leave it
29
+ * stranded on the first. An IntersectionObserver over the section panels keeps
30
+ * it honest, and the click only sets it optimistically so the row responds
31
+ * immediately rather than waiting for the smooth scroll to finish.
32
+ *
33
+ * That is also why this holds its own state instead of writing through an
34
+ * action: the highlight changes on every scroll, and dispatching a store update
35
+ * per scroll frame would be a poor trade for a value nothing else reads. The
36
+ * bound `screenData.sectionNav` still supplies the heading, the badge, the hint,
37
+ * the items and the STARTING section.
38
+ *
39
+ * ---
40
+ *
41
+ * L4 escalation proof: **L0 cannot do this because no widget in the three
42
+ * packages draws a vertical section list — `TAB` draws a horizontal strip of
43
+ * label links with an active class, with no icon, no hint and no vertical
44
+ * card.** The rule engine cannot help either: only
45
+ * SHOW/HIDE effects exist, so a schema-only version has
46
+ * to pre-author an active and an inactive copy of every row and pick one — which
47
+ * is what this screen did before, and which can only ever track a *click*, never
48
+ * the scroll position.
49
+ *
50
+ * Library-first: the glyph is the library `Icon` widget from
51
+ * `procode-lowcode-kendo-widget/widgets`, which resolves a Kendo SVG icon
52
+ * through `loadIcon`. No `@fortawesome/*` import — VS Backoffice's copy uses
53
+ * FontAwesome because that app carries it as a dependency; this one does not,
54
+ * and adding a second icon set is forbidden.
55
+ *
56
+ * Presentation only: it renders what it is given and never fetches.
57
+ *
58
+ * Registered as `WidgetTypes.SECTION_NAV`.
59
+ */
60
+
61
+ type Item = {
62
+ /** Matches the section container's `widgetStyle.id` in the schema. */
63
+ id: string;
64
+ label: string;
65
+ /** A Kendo SVG icon name, as `loadIcon` resolves it. Omitted renders no glyph. */
66
+ icon?: string;
67
+ };
68
+
69
+ type Nav = {
70
+ heading?: string;
71
+ badge?: string;
72
+ hint?: string;
73
+ /** The section highlighted before the observer has anything to say. */
74
+ active?: string;
75
+ items?: Item[];
76
+ };
77
+
78
+ type Props = {
79
+ /** The bound `screenData.<field>` node — see `Nav` above. */
80
+ value?: Nav;
81
+ widgetStyle?: { className?: string; id?: string; inline?: Record<string, unknown> };
82
+ };
83
+
84
+ const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
85
+
86
+ const SectionNav = ({ value, widgetStyle }: Props) => {
87
+ const items = Array.isArray(value?.items) ? (value!.items as Item[]) : [];
88
+ const firstId = text(items[0]?.id);
89
+
90
+ const [active, setActive] = useState(text(value?.active) || firstId);
91
+
92
+ // The ids are the observer's dependency, not the array identity: the schema
93
+ // rebuilds `items` on every render, so depending on the array would tear the
94
+ // observer down and rebuild it constantly.
95
+ const ids = items.map((item) => text(item?.id)).join(",");
96
+
97
+ useEffect(() => {
98
+ const sections = ids
99
+ .split(",")
100
+ .filter(Boolean)
101
+ .map((id) => ({ id, element: document.getElementById(id) }))
102
+ .filter((entry) => entry.element !== null) as { id: string; element: HTMLElement }[];
103
+
104
+ if (sections.length === 0) return;
105
+
106
+ // The sections are tall, so "which one is in view" is ambiguous whenever two
107
+ // are on screen at once. The rule is the topmost one that is intersecting,
108
+ // in schema order, which is what a reader would call the section they are in.
109
+ const visible = new Set<string>();
110
+
111
+ const observer = new IntersectionObserver(
112
+ (entries) => {
113
+ entries.forEach((entry) => {
114
+ const id = sections.find((s) => s.element === entry.target)?.id;
115
+ if (!id) return;
116
+ if (entry.isIntersecting) visible.add(id);
117
+ else visible.delete(id);
118
+ });
119
+
120
+ const topmost = sections.find((section) => visible.has(section.id));
121
+ if (topmost) setActive(topmost.id);
122
+ },
123
+ {
124
+ // Ignore the bottom two-thirds: without it the last section never wins,
125
+ // because a short final panel is on screen at the same time as the one
126
+ // above it and the one above it always sorts first.
127
+ rootMargin: "0px 0px -66% 0px",
128
+ threshold: 0,
129
+ },
130
+ );
131
+
132
+ sections.forEach((section) => observer.observe(section.element));
133
+ return () => observer.disconnect();
134
+ }, [ids]);
135
+
136
+ if (items.length === 0) return null;
137
+
138
+ const onPick = (id: string) => {
139
+ // Optimistic: the row responds now rather than after the smooth scroll. The
140
+ // observer corrects it a moment later if the scroll lands elsewhere.
141
+ setActive(id);
142
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
143
+ };
144
+
145
+ const heading = text(value?.heading) || "Sections";
146
+ const badge = text(value?.badge);
147
+ const hint = text(value?.hint);
148
+
149
+ return (
150
+ <nav
151
+ id={widgetStyle?.id}
152
+ className={`cus-section-nav ${widgetStyle?.className ?? ""}`.trim()}
153
+ style={widgetStyle?.inline}
154
+ aria-label={heading}
155
+ >
156
+ <div className="cus-section-nav__head">
157
+ <span className="cus-section-nav__heading">{heading}</span>
158
+ {badge ? <span className="cus-section-nav__badge">{badge}</span> : null}
159
+ </div>
160
+
161
+ {hint ? <p className="cus-section-nav__hint">{hint}</p> : null}
162
+
163
+ <ul className="cus-section-nav__list">
164
+ {items.map((item) => {
165
+ const id = text(item?.id);
166
+ const isActive = id === active;
167
+ return (
168
+ <li key={id}>
169
+ <button
170
+ type="button"
171
+ className={`cus-section-nav__item${isActive ? " cus-section-nav__item--active" : ""}`}
172
+ aria-current={isActive ? "true" : undefined}
173
+ onClick={() => onPick(id)}
174
+ >
175
+ {item?.icon ? (
176
+ // The row's own <span> carries the label, so the glyph is
177
+ // decorative: aria-hidden on the wrapper, and `title=""` so the
178
+ // Icon widget's required prop adds no second accessible name.
179
+ <span className="cus-section-nav__icon" aria-hidden="true">
180
+ <Icon name={item.icon} title="" fillMode="clear" />
181
+ </span>
182
+ ) : null}
183
+ <span>{text(item?.label)}</span>
184
+ </button>
185
+ </li>
186
+ );
187
+ })}
188
+ </ul>
189
+ </nav>
190
+ );
191
+ };
192
+
193
+ export default SectionNav;
@@ -0,0 +1,139 @@
1
+ // The left "Sections" rail — a white bordered card, a small uppercase heading
2
+ // with a badge, a muted hint, then icon rows with the active one tinted and
3
+ // marked by a left accent bar.
4
+ //
5
+ // Ported from VS Backoffice's `.spop-section-nav`
6
+ // (src/Factory/Widgets/SPOPSectionNav/style.scss). Values are stated as theme
7
+ // tokens rather than the literals that file carries as fallbacks — every one of
8
+ // them is defined by procode-vs-theme 2.6.0, so there is nothing to fall back
9
+ // to.
10
+ .cus-section-nav {
11
+ display: flex;
12
+ flex-direction: column;
13
+
14
+ // Fixed width that cannot be squeezed by the form panel beside it. It lives
15
+ // here rather than on the schema node because the theme has no no-shrink
16
+ // class, and an inline width alone would still let flex shrink the card.
17
+ flex: 0 0 230px;
18
+
19
+ // THE CARD MUST NOT SCROLL AWAY. Two arrangements have to work:
20
+ // - the form panel beside it is the scroller (what Company Edit/View do),
21
+ // so the card is outside it and simply never moves; `sticky` is inert;
22
+ // - an ancestor scrolls instead, and `sticky` pins the card to the top of
23
+ // that scrollport so it stays in view anyway.
24
+ // `align-self: flex-start` is required for both: the flex default `stretch`
25
+ // would make the card full height, leaving sticky no room to travel in.
26
+ align-self: flex-start;
27
+ position: sticky;
28
+ top: 0;
29
+
30
+ gap: var(--qo-space-2);
31
+ padding: var(--qo-space-4) 0 var(--qo-space-2);
32
+ border: var(--qo-border-width-sm) solid var(--qo-border-light);
33
+ border-radius: var(--qo-border-radius-sm);
34
+ background: var(--qo-bg-default);
35
+
36
+ &__head {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: var(--qo-space-2);
40
+ padding: 0 var(--qo-space-4);
41
+ }
42
+
43
+ &__heading {
44
+ font-family: var(--qo-font-family-primary);
45
+ font-size: var(--qo-font-size-xs);
46
+ font-weight: var(--qo-font-weight-semibold);
47
+ letter-spacing: 0.06em;
48
+ text-transform: uppercase;
49
+ color: var(--qo-text-dark);
50
+ }
51
+
52
+ &__badge {
53
+ padding: 1px 6px;
54
+ border: var(--qo-border-width-sm) solid var(--qo-text-primary);
55
+ border-radius: 3px;
56
+ font-family: var(--qo-font-family-primary);
57
+ font-size: var(--qo-font-size-xxs);
58
+ font-weight: var(--qo-font-weight-semibold);
59
+ letter-spacing: 0.04em;
60
+ text-transform: uppercase;
61
+ color: var(--qo-text-primary);
62
+ }
63
+
64
+ &__hint {
65
+ margin: 0;
66
+ padding: 0 var(--qo-space-4);
67
+ font-family: var(--qo-font-family-primary);
68
+ font-size: var(--qo-font-size-sm);
69
+ color: var(--qo-text-secondary);
70
+ }
71
+
72
+ &__list {
73
+ display: flex;
74
+ flex-direction: column;
75
+ margin: var(--qo-space-1) 0 0;
76
+ padding: 0;
77
+ list-style: none;
78
+ }
79
+
80
+ &__item {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 10px;
84
+ width: 100%;
85
+
86
+ // 3px of the left padding is the accent bar's lane, so the label does not
87
+ // shift by 3px when a row becomes active.
88
+ padding: 9px var(--qo-space-4) 9px 11px;
89
+ border: 0;
90
+ border-left: var(--qo-border-width-lg) solid transparent;
91
+ background: none;
92
+ font: inherit;
93
+ font-family: var(--qo-font-family-primary);
94
+ font-size: var(--qo-font-size-md);
95
+ color: var(--qo-text-dark);
96
+ text-align: left;
97
+ cursor: pointer;
98
+
99
+ &:hover {
100
+ background: var(--qo-bg-secondary);
101
+ }
102
+
103
+ // :focus-visible, so a mouse click leaves no ring — the house rule the
104
+ // theme's component layer follows throughout.
105
+ &:focus-visible {
106
+ outline: var(--qo-border-width-sm) solid var(--qo-text-primary);
107
+ outline-offset: -1px;
108
+ }
109
+ }
110
+
111
+ &__item--active {
112
+ border-left-color: var(--qo-text-primary);
113
+ background: var(--qo-bg-primary-soft);
114
+ color: var(--qo-text-primary);
115
+ font-weight: var(--qo-font-weight-semibold);
116
+ }
117
+
118
+ // The library Icon widget renders a Kendo Button, so the row's own padding
119
+ // and hover must not be doubled by the button's.
120
+ &__icon {
121
+ flex: 0 0 auto;
122
+ display: inline-flex;
123
+ color: currentColor;
124
+
125
+ .k-button {
126
+ min-height: 0;
127
+ padding: 0;
128
+ border: 0;
129
+ background: none;
130
+ color: currentColor;
131
+ pointer-events: none;
132
+ }
133
+
134
+ .k-svg-icon {
135
+ width: 14px;
136
+ height: 14px;
137
+ }
138
+ }
139
+ }