create-procode 0.0.0-stage → 1.0.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/README.md +240 -2
- package/bin/create-procode.mjs +9 -0
- package/package.json +30 -4
- package/src/args.mjs +139 -0
- package/src/cli.mjs +392 -0
- package/src/deps.mjs +195 -0
- package/src/log.mjs +23 -0
- package/src/prompt.mjs +60 -0
- package/src/scaffold.mjs +104 -0
- package/templates/base/README.md.tmpl +183 -0
- package/templates/base/_dotenv.local.example.tmpl +8 -0
- package/templates/base/_dotenv.tmpl +41 -0
- package/templates/base/_doteslintrc.cjs.tmpl +125 -0
- package/templates/base/_dotgitignore.tmpl +32 -0
- package/templates/base/_dotnvmrc.tmpl +1 -0
- package/templates/base/_dotvscode/extensions.json +3 -0
- package/templates/base/index.html.tmpl +18 -0
- package/templates/base/package.json.tmpl +15 -0
- package/templates/base/public/favicon.svg +4 -0
- package/templates/base/src/AdapterProvider/AdapterFactory.ts.tmpl +21 -0
- package/templates/base/src/AdapterProvider/SampleContacts.ts.tmpl +136 -0
- package/templates/base/src/CustomAction.ts.tmpl +47 -0
- package/templates/base/src/CustomAuthorizationProvider.ts.tmpl +77 -0
- package/templates/base/src/Factory/Actions/myAction.ts.tmpl +51 -0
- package/templates/base/src/Factory/Actions/sampleContacts.ts.tmpl +244 -0
- package/templates/base/src/Factory/Actions/setField.ts.tmpl +20 -0
- package/templates/base/src/Factory/CustomRuleEffectFactory.ts.tmpl +30 -0
- package/templates/base/src/Factory/CustomValidationFactory.tsx.tmpl +24 -0
- package/templates/base/src/Factory/CustomWidgetFactory.tsx.tmpl +43 -0
- package/templates/base/src/Factory/RuleEffects/readOnlyEffect.ts.tmpl +27 -0
- package/templates/base/src/Factory/Validations/myValidation.ts.tmpl +52 -0
- package/templates/base/src/Factory/Widgets/MyWidget/index.tsx.tmpl +101 -0
- package/templates/base/src/Factory/Widgets/Notification/index.tsx.tmpl +53 -0
- package/templates/base/src/Factory/Widgets/Notification/notifiFunc.ts.tmpl +9 -0
- package/templates/base/src/Factory/Widgets/SectionNav/index.tsx +193 -0
- package/templates/base/src/Factory/Widgets/SectionNav/style.scss +139 -0
- package/templates/base/src/Module/Sample/Contacts.json.tmpl +965 -0
- package/templates/base/src/Module/index.ts.tmpl +26 -0
- package/templates/base/src/Screens/index.tsx.tmpl +45 -0
- package/templates/base/src/Utils/errorMessages.ts.tmpl +35 -0
- package/templates/base/src/Utils/fieldErrorMapper.ts.tmpl +46 -0
- package/templates/base/src/Utils/logger.ts.tmpl +21 -0
- package/templates/base/src/Utils/responseFormatter.ts.tmpl +39 -0
- package/templates/base/src/Utils/storages.ts.tmpl +35 -0
- package/templates/base/src/assets/styles/index.scss.tmpl +146 -0
- package/templates/base/src/constant.ts.tmpl +53 -0
- package/templates/base/src/icons.ts.tmpl +17 -0
- package/templates/base/src/localizationResolver.ts.tmpl +48 -0
- package/templates/base/src/main.tsx.tmpl +136 -0
- package/templates/base/src/routes.ts.tmpl +20 -0
- package/templates/base/src/vite-env.d.ts.tmpl +20 -0
- package/templates/base/tsconfig.json.tmpl +30 -0
- package/templates/base/tsconfig.node.json.tmpl +11 -0
- package/templates/base/vite.config.ts.tmpl +61 -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,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
|
+
}
|