wenay-react2 1.0.28 → 1.0.29

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.
@@ -25,6 +25,8 @@ export * from "./src/components/ParametersEngine";
25
25
  export * from "./src/components/Input";
26
26
  export * from "./src/components/Modal";
27
27
  export * from "./src/components/Menu";
28
+ export * from "./src/components/Settings";
29
+ export * from "./src/components/UiSlot";
28
30
  export { MenuBase, TimeNum, MenuElement as LegacyMenuElement } from "./src/menu/menu";
29
31
  export type { tMenuReact, tMenuReactStrictly } from "./src/menu/menu";
30
32
  export * from "./src/menu/menuMouse";
package/lib/common/api.js CHANGED
@@ -37,6 +37,9 @@ export * from "./src/components/Input";
37
37
  export * from "./src/components/Modal";
38
38
  // 11. Menu - depends on hooks, Dnd, and Modal
39
39
  export * from "./src/components/Menu";
40
+ // 11b. Settings dialog + section registry; UI slot with configurable placement
41
+ export * from "./src/components/Settings";
42
+ export * from "./src/components/UiSlot";
40
43
  // 12. MENU - depends on components
41
44
  export { MenuBase, TimeNum, MenuElement as LegacyMenuElement } from "./src/menu/menu";
42
45
  export * from "./src/menu/menuMouse";
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ export type tSettingsSection = {
3
+ key: string;
4
+ name: string;
5
+ render: () => React.ReactNode;
6
+ };
7
+ /** Register an external section. Re-register with the same key replaces the previous one.
8
+ * The returned function removes exactly this registration (a no-op if it was replaced). */
9
+ export declare function registerSettingsSection(s: tSettingsSection): () => void;
10
+ /** Current external sections (static props sections are not included). */
11
+ export declare function getSettingsSections(): readonly tSettingsSection[];
12
+ /** Centered settings dialog: sections column on the left, active section content on the right.
13
+ * Sections = props.sections (first) + everything from registerSettingsSection.
14
+ * Look is themed via --dlg-* CSS variables (dark defaults), same contract as --wnd-*. */
15
+ export declare function SettingsDialog(props: {
16
+ trigger: React.ReactNode;
17
+ sections?: tSettingsSection[];
18
+ defaultSection?: string;
19
+ /** Section buttons: apps pass their own .chip / .chipActive; defaults are minimal library styles */
20
+ sectionClassName?: string;
21
+ sectionActiveClassName?: string;
22
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { renderBy, updateBy } from "../../../updateBy";
5
+ // Module singleton (closure + updateBy subscription, no React context): any module
6
+ // registers a section on mount and removes it on unmount; the dialog re-renders on changes.
7
+ const registry = { list: [] };
8
+ /** Register an external section. Re-register with the same key replaces the previous one.
9
+ * The returned function removes exactly this registration (a no-op if it was replaced). */
10
+ export function registerSettingsSection(s) {
11
+ const i = registry.list.findIndex(e => e.key == s.key);
12
+ if (i == -1)
13
+ registry.list.push(s);
14
+ else
15
+ registry.list.splice(i, 1, s);
16
+ renderBy(registry);
17
+ return () => {
18
+ const j = registry.list.indexOf(s);
19
+ if (j != -1) {
20
+ registry.list.splice(j, 1);
21
+ renderBy(registry);
22
+ }
23
+ };
24
+ }
25
+ /** Current external sections (static props sections are not included). */
26
+ export function getSettingsSections() {
27
+ return registry.list;
28
+ }
29
+ /** Centered settings dialog: sections column on the left, active section content on the right.
30
+ * Sections = props.sections (first) + everything from registerSettingsSection.
31
+ * Look is themed via --dlg-* CSS variables (dark defaults), same contract as --wnd-*. */
32
+ export function SettingsDialog(props) {
33
+ const [open, setOpen] = useState(false);
34
+ const [active, setActive] = useState(props.defaultSection);
35
+ updateBy(registry);
36
+ useEffect(() => {
37
+ if (!open)
38
+ return;
39
+ const onKeyDown = (e) => {
40
+ if (e.key == 'Escape')
41
+ setOpen(false);
42
+ };
43
+ document.addEventListener('keydown', onKeyDown);
44
+ return () => document.removeEventListener('keydown', onKeyDown);
45
+ }, [open]);
46
+ const sections = [...(props.sections ?? []), ...registry.list];
47
+ // fall back to the first section when active was never set or its section unmounted
48
+ const current = sections.find(s => s.key == active) ?? sections[0];
49
+ const base = props.sectionClassName ?? "wenayDlgSection";
50
+ const activeCls = props.sectionActiveClassName ?? "wenayDlgSectionActive";
51
+ return _jsxs(_Fragment, { children: [_jsx("span", { onClick: () => setOpen(true), style: { display: "inline-block", cursor: "pointer" }, children: props.trigger }), open && createPortal(_jsx("div", { className: "wenayDlgScrim", onClick: e => {
52
+ if (e.target == e.currentTarget)
53
+ setOpen(false);
54
+ }, children: _jsxs("div", { className: "wenayDlg", children: [_jsx("div", { className: "wenayDlgNav", children: sections.map(s => (_jsx("div", { className: s.key == current?.key ? `${base} ${activeCls}` : base, onClick: () => setActive(s.key), children: s.name }, s.key))) }), _jsx("div", { className: "wenayDlgContent", children: current?.render() }), _jsx("div", { className: "wenayCloseBtn wenayDlgClose", title: "Close", onClick: () => setOpen(false), children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true", children: _jsx("path", { d: "M2 2 L10 10 M10 2 L2 10", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round" }) }) })] }) }), document.body)] });
55
+ }
@@ -0,0 +1 @@
1
+ export * from './SettingsDialog';
@@ -0,0 +1 @@
1
+ export * from './SettingsDialog';
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ /** One UI block shown in exactly one of several mount points; the point is a persisted setting.
3
+ * Persistence rides the existing staticProps -> Cash mechanics: the app calls Cash.load()/save()
4
+ * the same way it does for window state (ExRNDMap3 etc.).
5
+ * Mount points render <Slot place="..."> themselves and decide nothing: the slot compares
6
+ * the persisted place with its own and renders children only on a match. */
7
+ export declare function createUiSlot<Places extends string>(opts: {
8
+ key: string;
9
+ places: {
10
+ [P in Places]: string;
11
+ };
12
+ def: NoInfer<Places>;
13
+ }): {
14
+ Slot: (p: {
15
+ place: Places;
16
+ children: React.ReactNode;
17
+ }) => React.JSX.Element | null;
18
+ PlacementSetting: (p?: {
19
+ className?: string;
20
+ activeClassName?: string;
21
+ }) => import("react/jsx-runtime").JSX.Element;
22
+ getPlace: () => Places;
23
+ setPlace: (p: Places) => void;
24
+ };
@@ -0,0 +1,33 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { renderBy, updateBy } from "../../../updateBy";
3
+ import { staticGetAdd } from "../../utils/mapMemory";
4
+ /** One UI block shown in exactly one of several mount points; the point is a persisted setting.
5
+ * Persistence rides the existing staticProps -> Cash mechanics: the app calls Cash.load()/save()
6
+ * the same way it does for window state (ExRNDMap3 etc.).
7
+ * Mount points render <Slot place="..."> themselves and decide nothing: the slot compares
8
+ * the persisted place with its own and renders children only on a match. */
9
+ export function createUiSlot(opts) {
10
+ const st = staticGetAdd(opts.key, { place: opts.def });
11
+ // a stored place may no longer exist after an app update - fall back to def
12
+ const isPlace = (p) => typeof p == "string" && p in opts.places;
13
+ const getPlace = () => isPlace(st.place) ? st.place : opts.def;
14
+ const setPlace = (p) => {
15
+ if (st.place == p)
16
+ return;
17
+ st.place = p;
18
+ renderBy(st);
19
+ };
20
+ function Slot(p) {
21
+ updateBy(st);
22
+ return getPlace() == p.place ? _jsx(_Fragment, { children: p.children }) : null;
23
+ }
24
+ /** Segmented row over opts.places; apps pass their own .chip / .chipActive classes */
25
+ function PlacementSetting(p = {}) {
26
+ updateBy(st);
27
+ const cur = getPlace();
28
+ const base = p.className ?? "wenaySegBtn";
29
+ const activeCls = p.activeClassName ?? "wenaySegBtnActive";
30
+ return _jsx("div", { style: { display: "inline-flex", gap: 4, flexWrap: "wrap" }, children: Object.keys(opts.places).map(key => (_jsx("div", { className: key == cur ? `${base} ${activeCls}` : base, onClick: () => setPlace(key), children: opts.places[key] }, key))) });
31
+ }
32
+ return { Slot, PlacementSetting, getPlace, setPlace };
33
+ }
@@ -0,0 +1 @@
1
+ export * from './UiSlot';
@@ -0,0 +1 @@
1
+ export * from './UiSlot';
@@ -44,6 +44,19 @@ export declare const tokens: {
44
44
  readonly closeHover: "#fff";
45
45
  readonly closeHoverBg: "#e5484d";
46
46
  };
47
+ /** SettingsDialog chrome (--dlg-*). Dark defaults; apps re-skin via :root[data-theme]. */
48
+ readonly dlg: {
49
+ readonly scrim: "rgba(0, 0, 0, 0.5)";
50
+ /** var(--color-bg-dark) in CSS */
51
+ readonly bg: "#131821";
52
+ /** var(--color-border-common) in CSS */
53
+ readonly border: "1px solid rgb(50, 62, 71)";
54
+ readonly radius: "10px";
55
+ readonly shadow: "0 12px 40px rgba(0, 0, 0, 0.5)";
56
+ /** var(--color-bg-light) in CSS */
57
+ readonly navBg: "#17202e";
58
+ readonly navWidth: "168px";
59
+ };
47
60
  readonly font: {
48
61
  readonly family: "Roboto";
49
62
  readonly sizeBase: "12px";
@@ -46,6 +46,19 @@ export const tokens = {
46
46
  closeHover: '#fff',
47
47
  closeHoverBg: '#e5484d',
48
48
  },
49
+ /** SettingsDialog chrome (--dlg-*). Dark defaults; apps re-skin via :root[data-theme]. */
50
+ dlg: {
51
+ scrim: 'rgba(0, 0, 0, 0.5)',
52
+ /** var(--color-bg-dark) in CSS */
53
+ bg: '#131821',
54
+ /** var(--color-border-common) in CSS */
55
+ border: '1px solid rgb(50, 62, 71)',
56
+ radius: '10px',
57
+ shadow: '0 12px 40px rgba(0, 0, 0, 0.5)',
58
+ /** var(--color-bg-light) in CSS */
59
+ navBg: '#17202e',
60
+ navWidth: '168px',
61
+ },
49
62
  font: {
50
63
  family: 'Roboto',
51
64
  sizeBase: '12px',
@@ -20,12 +20,16 @@ export declare const ObjectStringToDate: (obj: unknown) => void;
20
20
  export declare function CacheFuncMapBase(arr: [k: string, v: Map<string, unknown>][], Save: IServerSaveBasePromise): {
21
21
  load(): Promise<void>;
22
22
  save(): Promise<void>;
23
+ saveDebounced(delay?: number): void;
24
+ flush(): Promise<void>;
23
25
  clear(): Promise<void>;
24
26
  getArr: [k: string, v: Map<string, unknown>][];
25
27
  };
26
28
  export declare function CacheFuncMap(arr: [k: string, v: Map<string, unknown>][]): {
27
29
  load(): Promise<void>;
28
30
  save(): Promise<void>;
31
+ saveDebounced(delay?: number): void;
32
+ flush(): Promise<void>;
29
33
  clear(): Promise<void>;
30
34
  getArr: [k: string, v: Map<string, unknown>][];
31
35
  };
@@ -99,6 +99,43 @@ function isDate(_date) {
99
99
  return ISO_DATE_RE.test(_date);
100
100
  }
101
101
  export function CacheFuncMapBase(arr, Save) {
102
+ const savedPayloadByKey = new Map();
103
+ let saveTimer = null;
104
+ let runningSave = null;
105
+ const getPayloads = () => arr.map(([key, map]) => [key, JSON.stringify([...map.entries()])]);
106
+ const rememberCurrentPayloads = () => {
107
+ savedPayloadByKey.clear();
108
+ for (const [key, payload] of getPayloads())
109
+ savedPayloadByKey.set(key, payload);
110
+ };
111
+ const cancelDebouncedSave = () => {
112
+ if (saveTimer === null)
113
+ return;
114
+ clearTimeout(saveTimer);
115
+ saveTimer = null;
116
+ };
117
+ const saveChangedPayloads = async () => {
118
+ for (const [key, payload] of getPayloads()) {
119
+ if (savedPayloadByKey.get(key) === payload)
120
+ continue;
121
+ if (await Save.set(key, JSON.parse(payload))) {
122
+ savedPayloadByKey.set(key, payload);
123
+ }
124
+ }
125
+ };
126
+ const trackSave = (savePromise) => {
127
+ const trackedPromise = savePromise.catch(() => undefined).finally(() => {
128
+ if (runningSave === trackedPromise)
129
+ runningSave = null;
130
+ });
131
+ runningSave = trackedPromise;
132
+ return savePromise;
133
+ };
134
+ const queueSave = () => {
135
+ if (runningSave === null)
136
+ return trackSave(saveChangedPayloads());
137
+ return trackSave(runningSave.then(saveChangedPayloads, saveChangedPayloads));
138
+ };
102
139
  return {
103
140
  async load() {
104
141
  for (let [k, v] of arr) {
@@ -108,13 +145,25 @@ export function CacheFuncMapBase(arr, Save) {
108
145
  ObjectStringToDate(t);
109
146
  addDataToMap(t, v);
110
147
  }
148
+ rememberCurrentPayloads();
111
149
  },
112
150
  async save() {
113
- for (let [k, v] of arr) {
114
- await Save.set(k, [...v.entries()]);
115
- }
151
+ await queueSave();
152
+ },
153
+ saveDebounced(delay = 800) {
154
+ cancelDebouncedSave();
155
+ saveTimer = setTimeout(() => {
156
+ saveTimer = null;
157
+ void queueSave();
158
+ }, delay);
159
+ },
160
+ async flush() {
161
+ cancelDebouncedSave();
162
+ await queueSave();
116
163
  },
117
164
  async clear() {
165
+ cancelDebouncedSave();
166
+ savedPayloadByKey.clear();
118
167
  for (let [k, v] of arr) {
119
168
  await Save.delete(k);
120
169
  }
@@ -0,0 +1,10 @@
1
+ /** Multiplexer for a single-slot callback API (onX(cb | null)): many subscribers share
2
+ * one slot instead of silently overwriting each other.
3
+ * bind(emit) is called lazily, ONCE, on the first on() - not at creation time, because
4
+ * the slot may still be taken by app initialization. */
5
+ export declare function createCallbackHub<Args extends any[]>(bind: (emit: (...a: Args) => void) => void): {
6
+ /** Subscribe; the returned function unsubscribes without touching other subscribers.
7
+ * The callback is registered BEFORE bind runs, so it also catches synchronous emits. */
8
+ on(cb: (...a: Args) => void): () => void;
9
+ count: () => number;
10
+ };
@@ -0,0 +1,23 @@
1
+ /** Multiplexer for a single-slot callback API (onX(cb | null)): many subscribers share
2
+ * one slot instead of silently overwriting each other.
3
+ * bind(emit) is called lazily, ONCE, on the first on() - not at creation time, because
4
+ * the slot may still be taken by app initialization. */
5
+ export function createCallbackHub(bind) {
6
+ const subs = new Set();
7
+ let bound = false;
8
+ // copy before iterating: an unsubscribe from inside a callback must not skip the rest
9
+ const emit = (...a) => { [...subs].forEach(cb => cb(...a)); };
10
+ return {
11
+ /** Subscribe; the returned function unsubscribes without touching other subscribers.
12
+ * The callback is registered BEFORE bind runs, so it also catches synchronous emits. */
13
+ on(cb) {
14
+ subs.add(cb);
15
+ if (!bound) {
16
+ bound = true;
17
+ bind(emit);
18
+ }
19
+ return () => { subs.delete(cb); };
20
+ },
21
+ count: () => subs.size,
22
+ };
23
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './applyTransactionAsyncUpdate';
2
2
  export * from './arrayPromise';
3
3
  export * from './cache';
4
+ export * from './callbackHub';
4
5
  export * from './inputAutoStep';
5
6
  export * from './mapMemory';
6
7
  export * from './pageVisibilityContext';
@@ -1,6 +1,7 @@
1
1
  export * from './applyTransactionAsyncUpdate';
2
2
  export * from './arrayPromise';
3
3
  export * from './cache';
4
+ export * from './callbackHub';
4
5
  export * from './inputAutoStep';
5
6
  export * from './mapMemory';
6
7
  export * from './pageVisibilityContext';
@@ -10,6 +10,8 @@ export declare function staticGetById<T extends object>(key: any, def: T, id: st
10
10
  export declare const Cash: {
11
11
  load(): Promise<void>;
12
12
  save(): Promise<void>;
13
+ saveDebounced(delay?: number): void;
14
+ flush(): Promise<void>;
13
15
  clear(): Promise<void>;
14
16
  getArr: [k: string, v: Map<string, unknown>][];
15
17
  };
@@ -385,3 +385,72 @@ body {
385
385
  top: var(--wnd-close-top, -12px);
386
386
  right: var(--wnd-close-right, -12px);
387
387
  }
388
+
389
+ /* Settings dialog (SettingsDialog). Themed via --dlg-* (tokens.css), dark defaults. */
390
+ .wenayDlgScrim {
391
+ position: fixed;
392
+ inset: 0;
393
+ z-index: var(--wenay-z-modal, 9999);
394
+ background: var(--dlg-scrim, rgba(0, 0, 0, 0.5));
395
+ display: flex;
396
+ align-items: center;
397
+ justify-content: center;
398
+ }
399
+ .wenayDlg {
400
+ position: relative;
401
+ display: flex;
402
+ width: 640px;
403
+ height: 420px;
404
+ max-width: 92vw;
405
+ max-height: 82vh;
406
+ background: var(--dlg-bg, var(--color-bg-dark));
407
+ border: var(--dlg-border, 1px solid var(--color-border-common));
408
+ border-radius: var(--dlg-radius, 10px);
409
+ box-shadow: var(--dlg-shadow, 0 12px 40px rgba(0, 0, 0, 0.5));
410
+ color: var(--color-text-base);
411
+ overflow: hidden;
412
+ }
413
+ .wenayDlgNav {
414
+ width: var(--dlg-nav-width, 168px);
415
+ flex: none;
416
+ background: var(--dlg-nav-bg, var(--color-bg-light));
417
+ padding: 10px 8px;
418
+ display: flex;
419
+ flex-direction: column;
420
+ gap: 4px;
421
+ overflow-y: auto;
422
+ }
423
+ .wenayDlgContent {
424
+ position: relative;
425
+ flex: 1;
426
+ min-width: 0;
427
+ padding: 14px 40px 14px 16px;
428
+ overflow: auto;
429
+ }
430
+ /* Positioning of the close button in the dialog's top-right corner (over the content column).
431
+ A direct child of .wenayDlg, NOT of the scrollable content, so it does not scroll away. */
432
+ .wenayDlgClose {
433
+ position: absolute;
434
+ top: 8px;
435
+ right: 8px;
436
+ }
437
+ /* Minimal default section/segment buttons; apps override via sectionClassName /
438
+ sectionActiveClassName (SettingsDialog) and className / activeClassName (PlacementSetting) */
439
+ .wenayDlgSection,
440
+ .wenaySegBtn {
441
+ padding: 6px 10px;
442
+ border-radius: 6px;
443
+ cursor: pointer;
444
+ user-select: none;
445
+ text-align: left;
446
+ color: inherit;
447
+ }
448
+ .wenayDlgSection:hover,
449
+ .wenaySegBtn:hover {
450
+ background: rgba(255, 255, 255, 0.08);
451
+ }
452
+ .wenayDlgSectionActive,
453
+ .wenaySegBtnActive {
454
+ background: rgba(255, 255, 255, 0.16);
455
+ color: #fff;
456
+ }
@@ -62,6 +62,16 @@
62
62
  --wnd-close-hover: #fff;
63
63
  --wnd-close-hover-bg: #e5484d;
64
64
 
65
+ /* Settings dialog (SettingsDialog). Dark defaults close to current apps;
66
+ apps re-skin via :root[data-theme='...'] overrides, same contract as --wnd-*. */
67
+ --dlg-scrim: rgba(0, 0, 0, 0.5);
68
+ --dlg-bg: var(--color-bg-dark);
69
+ --dlg-border: 1px solid var(--color-border-common);
70
+ --dlg-radius: 10px;
71
+ --dlg-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
72
+ --dlg-nav-bg: var(--color-bg-light);
73
+ --dlg-nav-width: 168px;
74
+
65
75
  /* Layers */
66
76
  --wenay-z-modal: 9999;
67
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-react2",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "Common react",
5
5
  "strict": true,
6
6
  "main": "dist/index.js",