@dream-encode/wp-js-plugin-utils 0.4.0 → 0.6.1

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.
@@ -4,9 +4,9 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.default = void 0;
7
- var _i18n = require("@wordpress/i18n");
8
7
  var _components = require("@wordpress/components");
9
8
  var _element = require("@wordpress/element");
9
+ var _i18n = require("@wordpress/i18n");
10
10
  var _jsxRuntime = require("react/jsx-runtime");
11
11
  /**
12
12
  * "Dismiss All" button used inside the notifications drawer.
@@ -4,12 +4,12 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.default = void 0;
7
- var _i18n = require("@wordpress/i18n");
8
7
  var _components = require("@wordpress/components");
9
8
  var _element = require("@wordpress/element");
10
- var _time = require("../../utils/time");
11
- var _dates = require("../../utils/dates");
9
+ var _i18n = require("@wordpress/i18n");
12
10
  var _useValueChangeEffect = _interopRequireDefault(require("../../hooks/useValueChangeEffect"));
11
+ var _dates = require("../../utils/dates");
12
+ var _time = require("../../utils/time");
13
13
  var _jsxRuntime = require("react/jsx-runtime");
14
14
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
15
15
  /**
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _components = require("@wordpress/components");
8
+ var _element = require("@wordpress/element");
9
+ var _i18n = require("@wordpress/i18n");
10
+ var _Notices = _interopRequireDefault(require("../components/Notices"));
11
+ var _SettingsRail = _interopRequireDefault(require("./SettingsRail"));
12
+ var _useActiveSection = _interopRequireDefault(require("./useActiveSection"));
13
+ var _jsxRuntime = require("react/jsx-runtime");
14
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
15
+ /**
16
+ * Settings page shell built around a vertical section rail.
17
+ *
18
+ * Sections are declared rather than nested, so the rail, the open pane, the unsaved
19
+ * markers and the deep link all read from one list. Each entry accepts:
20
+ *
21
+ * key Unique section key, and the URL hash that opens it.
22
+ * title Rail label and pane heading.
23
+ * group Optional rail group heading. Consecutive sections sharing a group
24
+ * sit under one heading.
25
+ * description Optional sentence under the pane heading.
26
+ * badge Optional count shown against the rail item.
27
+ * type 'actions' for a section whose controls run immediately.
28
+ * render Called with the settings object, returns the pane content.
29
+ *
30
+ * A page declaring a single section renders without the rail, since a rail holding
31
+ * one item is chrome rather than navigation. Its status readout moves to the header.
32
+ *
33
+ * A section is marked as edited by matching the `section` declared on each field in
34
+ * `createUseSettings` against the section key, so a field does not have to be listed
35
+ * twice.
36
+ *
37
+ * Everything the page writes goes through the single Save in the header, which names
38
+ * how many fields it will write. A section of type 'actions' hides Save entirely,
39
+ * because nothing on such a section is written by it - a button there has already
40
+ * done its work by the time it returns. Unsaved edits made elsewhere are still
41
+ * reachable from an actions section through the count, which opens the first section
42
+ * holding one.
43
+ *
44
+ * @since 0.6.0
45
+ * @param {Object} props
46
+ * @param {string} props.title Page title.
47
+ * @param {string} [props.appVersion] Optional version shown beside the title.
48
+ * @param {Object} props.settings Object returned from a `useSettings` hook.
49
+ * @param {Array} props.sections Section definitions.
50
+ * @param {Object} [props.status] Optional readout for the rail foot, as `{ tone, text }`.
51
+ * @param {Function} [props.onSave] Optional override for the save handler.
52
+ * @param {string} [props.textDomain] Text domain for translated UI strings.
53
+ * @return {JSX.Element}
54
+ */const RailSettingsPage = ({
55
+ title,
56
+ appVersion,
57
+ settings,
58
+ sections = [],
59
+ status,
60
+ onSave,
61
+ textDomain = 'default'
62
+ }) => {
63
+ const {
64
+ settingsLoaded,
65
+ settingsSaving,
66
+ saveSettings,
67
+ isDirty = false,
68
+ dirtyCount = 0,
69
+ dirtySections = [],
70
+ discardChanges
71
+ } = settings;
72
+ const [activeKey, selectSection] = (0, _useActiveSection.default)(sections);
73
+ const activeSection = sections.find(section => section.key === activeKey) || sections[0];
74
+ const isActionsSection = 'actions' === activeSection?.type;
75
+ const showRail = sections.length > 1;
76
+ (0, _element.useEffect)(() => {
77
+ if (!isDirty) {
78
+ return undefined;
79
+ }
80
+ const handleBeforeUnload = event => {
81
+ event.preventDefault();
82
+ event.returnValue = '';
83
+ };
84
+ window.addEventListener('beforeunload', handleBeforeUnload);
85
+ return () => {
86
+ window.removeEventListener('beforeunload', handleBeforeUnload);
87
+ };
88
+ }, [isDirty]);
89
+ const handleSave = async event => {
90
+ event.preventDefault();
91
+ if ('function' === typeof onSave) {
92
+ await onSave(event);
93
+ return;
94
+ }
95
+ await saveSettings();
96
+ };
97
+ const unsavedLabel = (0, _i18n.sprintf)(/* translators: %d: number of settings fields holding an unsaved edit. */
98
+ (0, _i18n._n)('%d unsaved change', '%d unsaved changes', dirtyCount, textDomain), dirtyCount);
99
+ const saveLabel = () => {
100
+ if (settingsSaving) {
101
+ return (0, _i18n.__)('Saving…', textDomain);
102
+ }
103
+ if (!isDirty) {
104
+ return (0, _i18n.__)('Saved', textDomain);
105
+ }
106
+ return (0, _i18n.__)('Save', textDomain);
107
+ };
108
+ if (!activeSection) {
109
+ return null;
110
+ }
111
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
112
+ className: "de-settings",
113
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
114
+ className: "de-settings__header",
115
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
116
+ className: "de-settings__identity",
117
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("h1", {
118
+ className: "de-settings__title",
119
+ children: title
120
+ }), !!appVersion && /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
121
+ className: "de-settings__version",
122
+ children: ["v", appVersion]
123
+ })]
124
+ }), !showRail && !!status?.text && /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
125
+ className: `de-settings__status is-${status.tone || 'neutral'}`,
126
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
127
+ className: "de-settings__status-led",
128
+ "aria-hidden": "true"
129
+ }), status.text]
130
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
131
+ className: "de-settings__header-actions",
132
+ children: isActionsSection ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
133
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
134
+ className: "de-settings__note",
135
+ children: (0, _i18n.__)('Actions here run immediately.', textDomain)
136
+ }), isDirty && dirtySections.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Button, {
137
+ variant: "tertiary",
138
+ onClick: () => selectSection(dirtySections[0]),
139
+ children: unsavedLabel
140
+ })]
141
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
142
+ children: [isDirty && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
143
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
144
+ className: "de-settings__unsaved",
145
+ children: unsavedLabel
146
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Button, {
147
+ variant: "tertiary",
148
+ onClick: discardChanges,
149
+ disabled: settingsSaving,
150
+ children: (0, _i18n.__)('Discard', textDomain)
151
+ })]
152
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Button, {
153
+ variant: "primary",
154
+ isBusy: settingsSaving,
155
+ disabled: !settingsLoaded || settingsSaving || !isDirty,
156
+ onClick: handleSave,
157
+ children: saveLabel()
158
+ })]
159
+ })
160
+ })]
161
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
162
+ className: showRail ? 'de-settings__body' : 'de-settings__body is-solo',
163
+ children: [showRail && /*#__PURE__*/(0, _jsxRuntime.jsx)(_SettingsRail.default, {
164
+ sections: sections,
165
+ activeKey: activeSection.key,
166
+ onSelect: selectSection,
167
+ dirtySections: dirtySections,
168
+ status: status,
169
+ textDomain: textDomain
170
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
171
+ className: "de-settings__pane",
172
+ id: `de-settings-pane-${activeSection.key}`,
173
+ role: "tabpanel",
174
+ "aria-labelledby": `de-settings-tab-${activeSection.key}`,
175
+ tabIndex: 0,
176
+ children: !settingsLoaded ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Placeholder, {
177
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Spinner, {})
178
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
179
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_Notices.default, {}), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
180
+ className: "de-settings__pane-head",
181
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("h2", {
182
+ children: activeSection.title
183
+ }), !!activeSection.description && /*#__PURE__*/(0, _jsxRuntime.jsx)("p", {
184
+ children: activeSection.description
185
+ })]
186
+ }), activeSection.render(settings)]
187
+ })
188
+ })]
189
+ })]
190
+ });
191
+ };
192
+ var _default = exports.default = RailSettingsPage;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _element = require("@wordpress/element");
8
+ var _i18n = require("@wordpress/i18n");
9
+ var _jsxRuntime = require("react/jsx-runtime");
10
+ /**
11
+ * Vertical section navigation for the settings shell.
12
+ *
13
+ * Implements the tab pattern: one item is tabbable at a time and the arrow keys move
14
+ * between them, so the rail costs a single tab stop no matter how many sections a
15
+ * plugin registers.
16
+ *
17
+ * @since 0.6.0
18
+ * @param {Object} props
19
+ * @param {Array} props.sections Section definitions.
20
+ * @param {string} props.activeKey Key of the open section.
21
+ * @param {Function} props.onSelect Called with a section key when one is chosen.
22
+ * @param {Array} [props.dirtySections] Keys of sections holding an unsaved edit.
23
+ * @param {Object} [props.status] Optional readout for the rail foot, as `{ tone, text }`.
24
+ * @param {string} [props.textDomain] Text domain for translated UI strings.
25
+ * @return {JSX.Element}
26
+ */const SettingsRail = ({
27
+ sections,
28
+ activeKey,
29
+ onSelect,
30
+ dirtySections = [],
31
+ status,
32
+ textDomain = 'default'
33
+ }) => {
34
+ const itemRefs = (0, _element.useRef)({});
35
+ const moveFocus = (fromIndex, step) => {
36
+ const nextIndex = (fromIndex + step + sections.length) % sections.length;
37
+ const nextKey = sections[nextIndex].key;
38
+ onSelect(nextKey);
39
+ itemRefs.current[nextKey]?.focus();
40
+ };
41
+ const handleKeyDown = (event, index) => {
42
+ if ('ArrowDown' === event.key || 'ArrowRight' === event.key) {
43
+ event.preventDefault();
44
+ moveFocus(index, 1);
45
+ return;
46
+ }
47
+ if ('ArrowUp' === event.key || 'ArrowLeft' === event.key) {
48
+ event.preventDefault();
49
+ moveFocus(index, -1);
50
+ return;
51
+ }
52
+ if ('Home' === event.key) {
53
+ event.preventDefault();
54
+ moveFocus(-1, 1);
55
+ return;
56
+ }
57
+ if ('End' === event.key) {
58
+ event.preventDefault();
59
+ moveFocus(0, -1);
60
+ }
61
+ };
62
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
63
+ className: "de-settings__rail",
64
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
65
+ className: "de-settings__rail-inner",
66
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
67
+ className: "de-settings__rail-list",
68
+ role: "tablist",
69
+ "aria-orientation": "vertical",
70
+ "aria-label": (0, _i18n.__)('Settings sections', textDomain),
71
+ children: sections.map((section, index) => {
72
+ const previousGroup = index > 0 ? sections[index - 1].group : null;
73
+ const isActive = section.key === activeKey;
74
+ const isDirty = dirtySections.includes(section.key);
75
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
76
+ children: [!!section.group && section.group !== previousGroup && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
77
+ className: "de-settings__rail-group",
78
+ role: "presentation",
79
+ children: section.group
80
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("button", {
81
+ type: "button",
82
+ role: "tab",
83
+ id: `de-settings-tab-${section.key}`,
84
+ className: "de-settings__rail-item",
85
+ "aria-selected": isActive,
86
+ "aria-controls": isActive ? `de-settings-pane-${section.key}` : undefined,
87
+ tabIndex: isActive ? 0 : -1,
88
+ ref: element => {
89
+ itemRefs.current[section.key] = element;
90
+ },
91
+ onClick: () => onSelect(section.key),
92
+ onKeyDown: event => handleKeyDown(event, index),
93
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
94
+ className: "de-settings__rail-title",
95
+ children: section.title
96
+ }), undefined !== section.badge && null !== section.badge && /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
97
+ className: "de-settings__rail-badge",
98
+ children: section.badge
99
+ }), isDirty && /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
100
+ className: "de-settings__rail-dirty",
101
+ role: "img",
102
+ "aria-label": (0, _i18n.__)('Unsaved changes', textDomain)
103
+ })]
104
+ })]
105
+ }, section.key);
106
+ })
107
+ }), !!status?.text && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
108
+ className: "de-settings__rail-foot",
109
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
110
+ className: `de-settings__status is-${status.tone || 'neutral'}`,
111
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
112
+ className: "de-settings__status-led",
113
+ "aria-hidden": "true"
114
+ }), status.text]
115
+ })
116
+ })]
117
+ })
118
+ });
119
+ };
120
+ var _default = exports.default = SettingsRail;
@@ -10,17 +10,46 @@ var _apiFetch = _interopRequireDefault(require("@wordpress/api-fetch"));
10
10
  var _data = require("@wordpress/data");
11
11
  var _notices = require("@wordpress/notices");
12
12
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
13
+ /**
14
+ * Serialize a value so two settings payloads can be compared by content.
15
+ *
16
+ * Object keys are sorted before serializing. The working values and the values that
17
+ * came back from the REST endpoint routinely carry the same keys in a different
18
+ * order - an object rebuilt through a spread is the ordinary case - and a plain
19
+ * `JSON.stringify` comparison reports that as an edit.
20
+ *
21
+ * @since 0.6.0
22
+ * @param {*} value Value to serialize.
23
+ * @return {string} Stable serialization of the value.
24
+ */
25
+ const stableStringify = value => {
26
+ if (null === value || undefined === value || 'object' !== typeof value) {
27
+ return JSON.stringify(value ?? null);
28
+ }
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map(item => stableStringify(item)).join(',')}]`;
31
+ }
32
+ const pairs = Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
33
+ return `{${pairs.join(',')}}`;
34
+ };
35
+
13
36
  /**
14
37
  * Create a `useSettings` hook for a plugin.
15
38
  *
16
39
  * Reads/writes settings via the WordPress core REST settings endpoint
17
40
  * (`/wp/v2/settings`), keyed by `optionName`. Each entry in `fields`
18
- * defines a field with `key` (option key), optional `defaultValue`, and
19
- * optional state name overrides.
41
+ * defines a field with `key` (option key), optional `defaultValue`,
42
+ * optional state name overrides, and an optional `section` naming the
43
+ * settings section the field belongs to.
44
+ *
45
+ * The hook keeps the values it loaded, so it can report what has been edited
46
+ * since. That is what lets a settings page enable Save only when there is
47
+ * something to write, say how much it will write, mark the sections holding an
48
+ * edit, and offer a Discard that actually restores something.
20
49
  *
21
50
  * @param {Object} config Hook configuration.
22
51
  * @param {string} config.optionName Top-level option key on the settings endpoint.
23
- * @param {Array} config.fields Field definitions: `{ key, defaultValue, stateName, setterName }`.
52
+ * @param {Array} config.fields Field definitions: `{ key, defaultValue, stateName, setterName, section }`.
24
53
  * @param {string} [config.textDomain] Text domain for translated notice strings.
25
54
  * @param {string} [config.path] REST path (default: '/wp/v2/settings').
26
55
  * @return {Function} A `useSettings` React hook.
@@ -37,6 +66,13 @@ const createUseSettings = config => {
37
66
  }
38
67
  const toCamel = key => key.replace(/[_-](.)/g, (_m, c) => c.toUpperCase());
39
68
  const ucFirst = s => s.charAt(0).toUpperCase() + s.slice(1);
69
+ const sectionByStateName = {};
70
+ fields.forEach(field => {
71
+ if (!field.section) {
72
+ return;
73
+ }
74
+ sectionByStateName[field.stateName || toCamel(field.key)] = field.section;
75
+ });
40
76
  return function useSettings() {
41
77
  const {
42
78
  createSuccessNotice,
@@ -50,6 +86,8 @@ const createUseSettings = config => {
50
86
  initialState[stateName] = field.defaultValue !== undefined ? field.defaultValue : '';
51
87
  });
52
88
  const [values, setValues] = (0, _element.useState)(initialState);
89
+ const [baseline, setBaseline] = (0, _element.useState)(null);
90
+ const baselineTaken = (0, _element.useRef)(false);
53
91
  const setField = stateName => value => {
54
92
  setValues(prev => ({
55
93
  ...prev,
@@ -74,27 +112,67 @@ const createUseSettings = config => {
74
112
  updateSettingsLoaded(true);
75
113
  });
76
114
  }, []);
115
+ (0, _element.useEffect)(() => {
116
+ if (!settingsLoaded || baselineTaken.current) {
117
+ return;
118
+ }
119
+ baselineTaken.current = true;
120
+ setBaseline(values);
121
+ }, [settingsLoaded, values]);
122
+ const dirtyFields = (0, _element.useMemo)(() => {
123
+ if (!baseline) {
124
+ return [];
125
+ }
126
+ return Object.keys(values).filter(key => {
127
+ return stableStringify(values[key]) !== stableStringify(baseline[key]);
128
+ });
129
+ }, [values, baseline]);
130
+ const dirtySections = (0, _element.useMemo)(() => {
131
+ const sections = [];
132
+ dirtyFields.forEach(stateName => {
133
+ const section = sectionByStateName[stateName];
134
+ if (section && !sections.includes(section)) {
135
+ sections.push(section);
136
+ }
137
+ });
138
+ return sections;
139
+ }, [dirtyFields]);
140
+ const discardChanges = () => {
141
+ if (!baseline) {
142
+ return;
143
+ }
144
+ setValues(baseline);
145
+ };
77
146
  const saveSettings = async () => {
78
147
  updateSettingsSaving(true);
148
+ const written = values;
79
149
  const optionData = {};
80
150
  fields.forEach(field => {
81
151
  const stateName = field.stateName || toCamel(field.key);
82
152
  optionData[field.key] = values[stateName];
83
153
  });
84
- const saveResult = await (0, _apiFetch.default)({
85
- path,
86
- method: 'POST',
87
- data: {
88
- [optionName]: optionData
89
- }
90
- });
91
- if (!saveResult) {
154
+ let saveResult;
155
+ try {
156
+ saveResult = await (0, _apiFetch.default)({
157
+ path,
158
+ method: 'POST',
159
+ data: {
160
+ [optionName]: optionData
161
+ }
162
+ });
163
+ } catch (error) {
92
164
  updateSettingsSaving(false);
93
165
  createErrorNotice((0, _i18n.sprintf)(/* translators: %s: Error message. */
94
- (0, _i18n.__)('Error saving settings: %s.', textDomain), saveResult?.message ?? (0, _i18n.__)('Unknown error', textDomain)));
166
+ (0, _i18n.__)('Error saving settings: %s.', textDomain), error?.message ?? (0, _i18n.__)('Unknown error', textDomain)));
95
167
  return;
96
168
  }
97
169
  updateSettingsSaving(false);
170
+ if (!saveResult) {
171
+ createErrorNotice((0, _i18n.sprintf)(/* translators: %s: Error message. */
172
+ (0, _i18n.__)('Error saving settings: %s.', textDomain), (0, _i18n.__)('Unknown error', textDomain)));
173
+ return;
174
+ }
175
+ setBaseline(written);
98
176
  createSuccessNotice((0, _i18n.__)('Settings saved.', textDomain));
99
177
  };
100
178
  const exposed = {
@@ -104,7 +182,12 @@ const createUseSettings = config => {
104
182
  updateSettingsSaving,
105
183
  values,
106
184
  setValues,
107
- saveSettings
185
+ saveSettings,
186
+ isDirty: dirtyFields.length > 0,
187
+ dirtyCount: dirtyFields.length,
188
+ dirtyFields,
189
+ dirtySections,
190
+ discardChanges
108
191
  };
109
192
  fields.forEach(field => {
110
193
  const stateName = field.stateName || toCamel(field.key);
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _i18n = require("@wordpress/i18n");
8
+ /**
9
+ * Describe a plugin log level as a status readout for the settings rail.
10
+ *
11
+ * Debug is called out in amber because it is the level that costs something to leave
12
+ * switched on, and a settings page is usually the only place that gets noticed.
13
+ *
14
+ * @since 0.6.0
15
+ * @param {string} level Configured log level.
16
+ * @param {string} [textDomain] Text domain for translated UI strings.
17
+ * @return {Object} Status readout, as `{ tone, text }`.
18
+ */
19
+ const getLogLevelStatus = (level, textDomain = 'default') => {
20
+ if (!level || 'off' === level) {
21
+ return {
22
+ tone: 'neutral',
23
+ text: (0, _i18n.__)('Logging off', textDomain)
24
+ };
25
+ }
26
+ const label = level.charAt(0).toUpperCase() + level.slice(1);
27
+ return {
28
+ tone: 'debug' === level ? 'warn' : 'ok',
29
+ text: (0, _i18n.sprintf)(/* translators: %s: configured log level, such as Error or Debug. */
30
+ (0, _i18n.__)('Logging: %s', textDomain), label)
31
+ };
32
+ };
33
+ var _default = exports.default = getLogLevelStatus;
@@ -9,12 +9,40 @@ Object.defineProperty(exports, "AdminSettingsPage", {
9
9
  return _AdminSettingsPage.default;
10
10
  }
11
11
  });
12
+ Object.defineProperty(exports, "RailSettingsPage", {
13
+ enumerable: true,
14
+ get: function () {
15
+ return _RailSettingsPage.default;
16
+ }
17
+ });
18
+ Object.defineProperty(exports, "SettingsRail", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _SettingsRail.default;
22
+ }
23
+ });
12
24
  Object.defineProperty(exports, "createUseSettings", {
13
25
  enumerable: true,
14
26
  get: function () {
15
27
  return _createUseSettings.default;
16
28
  }
17
29
  });
30
+ Object.defineProperty(exports, "getLogLevelStatus", {
31
+ enumerable: true,
32
+ get: function () {
33
+ return _getLogLevelStatus.default;
34
+ }
35
+ });
36
+ Object.defineProperty(exports, "useActiveSection", {
37
+ enumerable: true,
38
+ get: function () {
39
+ return _useActiveSection.default;
40
+ }
41
+ });
18
42
  var _createUseSettings = _interopRequireDefault(require("./createUseSettings"));
19
43
  var _AdminSettingsPage = _interopRequireDefault(require("./AdminSettingsPage"));
44
+ var _RailSettingsPage = _interopRequireDefault(require("./RailSettingsPage"));
45
+ var _SettingsRail = _interopRequireDefault(require("./SettingsRail"));
46
+ var _getLogLevelStatus = _interopRequireDefault(require("./getLogLevelStatus"));
47
+ var _useActiveSection = _interopRequireDefault(require("./useActiveSection"));
20
48
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -0,0 +1,461 @@
1
+ /*
2
+ * Styling for the RailSettingsPage shell.
3
+ *
4
+ * A settings page needs both mixins:
5
+ *
6
+ * @use "@dream-encode/wp-js-plugin-utils/dist/settings/styles/settings-rail" as rail;
7
+ *
8
+ * @include rail.de-wp-plugin-utils-settings-rail-page;
9
+ *
10
+ * #my-plugin-settings {
11
+ * @include rail.de-wp-plugin-utils-settings-rail;
12
+ * }
13
+ */
14
+
15
+ /*
16
+ * Page level resets, included at the top level rather than inside the mount point.
17
+ *
18
+ * The rail runs the full width of the screen, which means clearing the left padding
19
+ * WordPress puts on the content column for its own admin menu.
20
+ */
21
+ @mixin de-wp-plugin-utils-settings-rail-page {
22
+ #wpcontent {
23
+ padding-left: 0;
24
+ }
25
+
26
+ .auto-fold #wpcontent {
27
+ padding-left: 0;
28
+ }
29
+
30
+ #wpbody-content {
31
+ padding-bottom: 0;
32
+ }
33
+ }
34
+
35
+ @mixin de-wp-plugin-utils-settings-rail {
36
+ --de-settings-rail-width: 232px;
37
+ --de-settings-header-height: 61px;
38
+ --de-settings-admin-bar: 32px;
39
+ --de-settings-surface: #ffffff;
40
+ --de-settings-ground: #f0f0f1;
41
+ --de-settings-line: #dcdcde;
42
+ --de-settings-ink: #1d2327;
43
+ --de-settings-ink-soft: #3c434a;
44
+ --de-settings-ink-faint: #646970;
45
+ --de-settings-blue: #2271b1;
46
+ --de-settings-blue-dark: #0a4b78;
47
+ --de-settings-blue-wash: #f0f6fc;
48
+ --de-settings-amber: #bd8600;
49
+ --de-settings-green: #00733f;
50
+
51
+ box-sizing: border-box;
52
+
53
+ * {
54
+ box-sizing: border-box;
55
+ }
56
+
57
+ .de-settings {
58
+ display: flex;
59
+ flex-direction: column;
60
+ min-height: calc( 100vh - var( --de-settings-admin-bar ) );
61
+ background: var( --de-settings-ground );
62
+ }
63
+
64
+ .de-settings__header {
65
+ position: sticky;
66
+ top: var( --de-settings-admin-bar );
67
+ z-index: 10;
68
+ display: flex;
69
+ align-items: center;
70
+ gap: 16px;
71
+ min-height: var( --de-settings-header-height );
72
+ padding: 10px 24px;
73
+ background: var( --de-settings-surface );
74
+ border-bottom: 1px solid var( --de-settings-line );
75
+ box-shadow: 0 1px 2px rgba( 0, 0, 0, 0.04 );
76
+ }
77
+
78
+ .de-settings__identity {
79
+ display: flex;
80
+ align-items: baseline;
81
+ gap: 10px;
82
+ min-width: 0;
83
+ }
84
+
85
+ .de-settings__title {
86
+ margin: 0;
87
+ padding: 0;
88
+ font-size: 1.3rem;
89
+ font-weight: 600;
90
+ line-height: 1.3;
91
+ color: var( --de-settings-ink );
92
+ }
93
+
94
+ .de-settings__version {
95
+ font-size: 12px;
96
+ color: var( --de-settings-ink-faint );
97
+ font-variant-numeric: tabular-nums;
98
+ }
99
+
100
+ .de-settings__header-actions {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 10px;
104
+ margin-left: auto;
105
+ flex: none;
106
+ }
107
+
108
+ .de-settings__unsaved {
109
+ display: inline-flex;
110
+ align-items: center;
111
+ gap: 7px;
112
+ font-size: 13px;
113
+ color: var( --de-settings-amber );
114
+
115
+ &::before {
116
+ content: '';
117
+ width: 8px;
118
+ height: 8px;
119
+ border-radius: 50%;
120
+ background: var( --de-settings-amber );
121
+ }
122
+ }
123
+
124
+ .de-settings__note {
125
+ font-size: 13px;
126
+ color: var( --de-settings-ink-faint );
127
+ }
128
+
129
+ .de-settings__body {
130
+ display: grid;
131
+ grid-template-columns: var( --de-settings-rail-width ) minmax( 0, 1fr );
132
+ flex: 1;
133
+ }
134
+
135
+ .de-settings__body.is-solo {
136
+ grid-template-columns: minmax( 0, 1fr );
137
+ }
138
+
139
+ .de-settings__rail {
140
+ background: var( --de-settings-surface );
141
+ border-right: 1px solid var( --de-settings-line );
142
+ }
143
+
144
+ .de-settings__rail-inner {
145
+ position: sticky;
146
+ top: calc( var( --de-settings-admin-bar ) + var( --de-settings-header-height ) );
147
+ display: flex;
148
+ flex-direction: column;
149
+ max-height: calc( 100vh - var( --de-settings-admin-bar ) - var( --de-settings-header-height ) );
150
+ padding-top: 8px;
151
+ }
152
+
153
+ .de-settings__rail-list {
154
+ overflow-y: auto;
155
+ padding-bottom: 12px;
156
+ }
157
+
158
+ .de-settings__rail-group {
159
+ padding: 14px 16px 5px;
160
+ font-size: 11px;
161
+ font-weight: 600;
162
+ letter-spacing: 0.08em;
163
+ text-transform: uppercase;
164
+ color: #8c8f94;
165
+ }
166
+
167
+ .de-settings__rail-item {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 8px;
171
+ width: 100%;
172
+ padding: 9px 14px 9px 13px;
173
+ border: 0;
174
+ border-left: 3px solid transparent;
175
+ background: none;
176
+ font-size: 13px;
177
+ line-height: 1.4;
178
+ text-align: left;
179
+ color: var( --de-settings-ink-soft );
180
+ cursor: pointer;
181
+
182
+ &:hover {
183
+ background: #f6f7f7;
184
+ color: var( --de-settings-blue );
185
+ }
186
+
187
+ &:focus-visible {
188
+ outline: 2px solid var( --de-settings-blue );
189
+ outline-offset: -2px;
190
+ }
191
+
192
+ &[aria-selected='true'] {
193
+ background: var( --de-settings-blue-wash );
194
+ border-left-color: var( --de-settings-blue );
195
+ color: var( --de-settings-blue-dark );
196
+ font-weight: 600;
197
+ }
198
+ }
199
+
200
+ .de-settings__rail-title {
201
+ flex: 1;
202
+ min-width: 0;
203
+ }
204
+
205
+ .de-settings__rail-badge {
206
+ flex: none;
207
+ padding: 1px 7px;
208
+ border-radius: 9px;
209
+ background: var( --de-settings-ground );
210
+ font-size: 11px;
211
+ font-weight: 400;
212
+ color: var( --de-settings-ink-faint );
213
+ font-variant-numeric: tabular-nums;
214
+ }
215
+
216
+ .de-settings__rail-dirty {
217
+ flex: none;
218
+ width: 7px;
219
+ height: 7px;
220
+ border-radius: 50%;
221
+ background: var( --de-settings-amber );
222
+ }
223
+
224
+ .de-settings__rail-foot {
225
+ margin-top: auto;
226
+ padding: 12px 16px;
227
+ border-top: 1px solid var( --de-settings-line );
228
+ }
229
+
230
+ .de-settings__status {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 8px;
234
+ font-size: 12px;
235
+ color: var( --de-settings-ink-faint );
236
+
237
+ .de-settings__status-led {
238
+ flex: none;
239
+ width: 8px;
240
+ height: 8px;
241
+ border-radius: 50%;
242
+ background: #8c8f94;
243
+ }
244
+
245
+ &.is-ok .de-settings__status-led {
246
+ background: var( --de-settings-green );
247
+ box-shadow: 0 0 0 3px rgba( 0, 115, 63, 0.14 );
248
+ }
249
+
250
+ &.is-warn .de-settings__status-led {
251
+ background: var( --de-settings-amber );
252
+ box-shadow: 0 0 0 3px rgba( 189, 134, 0, 0.16 );
253
+ }
254
+ }
255
+
256
+ .de-settings__pane {
257
+ min-width: 0;
258
+ padding: 24px 28px 48px;
259
+
260
+ &:focus {
261
+ outline: none;
262
+ }
263
+ }
264
+
265
+ .de-settings__pane-head {
266
+ margin-bottom: 18px;
267
+
268
+ h2 {
269
+ margin: 0 0 4px;
270
+ padding: 0;
271
+ font-size: 1.05rem;
272
+ font-weight: 600;
273
+ color: var( --de-settings-ink );
274
+ }
275
+
276
+ p {
277
+ margin: 0;
278
+ max-width: 78ch;
279
+ font-size: 13px;
280
+ color: var( --de-settings-ink-faint );
281
+ }
282
+ }
283
+
284
+ .de-settings__card {
285
+ max-width: 1080px;
286
+ padding: 20px 22px;
287
+ background: var( --de-settings-surface );
288
+ border: 1px solid var( --de-settings-line );
289
+ border-radius: 4px;
290
+
291
+ & + & {
292
+ margin-top: 16px;
293
+ }
294
+ }
295
+
296
+ .de-settings__fields {
297
+ display: grid;
298
+ gap: 20px;
299
+ }
300
+
301
+ .de-settings__action {
302
+ display: flex;
303
+ align-items: flex-start;
304
+ gap: 24px;
305
+ padding: 16px 0;
306
+ border-bottom: 1px solid #f0f0f1;
307
+
308
+ &:first-child {
309
+ padding-top: 0;
310
+ }
311
+
312
+ &:last-child {
313
+ padding-bottom: 0;
314
+ border-bottom: 0;
315
+ }
316
+ }
317
+
318
+ .de-settings__action-description {
319
+ flex: 1;
320
+ min-width: 0;
321
+
322
+ strong {
323
+ display: block;
324
+ font-size: 13px;
325
+ color: var( --de-settings-ink );
326
+ }
327
+
328
+ p {
329
+ margin: 4px 0 0;
330
+ font-size: 12.5px;
331
+ line-height: 1.5;
332
+ color: var( --de-settings-ink-faint );
333
+ max-width: 78ch;
334
+ }
335
+ }
336
+
337
+ .de-settings__action-button {
338
+ flex: none;
339
+ }
340
+
341
+ .de-settings__field {
342
+ max-width: 640px;
343
+
344
+ .components-base-control,
345
+ .components-base-control__field,
346
+ .components-number-control {
347
+ width: 100%;
348
+ margin-bottom: 0;
349
+
350
+ .components-flex {
351
+ width: 100%;
352
+ }
353
+ }
354
+
355
+ label,
356
+ .components-base-control__label {
357
+ font-weight: 600;
358
+ }
359
+
360
+ input[type='email'],
361
+ input[type='number'],
362
+ input[type='password'],
363
+ input[type='search'],
364
+ input[type='tel'],
365
+ input[type='text'],
366
+ input[type='url'],
367
+ textarea {
368
+ width: 100%;
369
+ }
370
+
371
+ input:disabled,
372
+ textarea:disabled {
373
+ background: #f6f7f7;
374
+ border-color: #dcdcde;
375
+ color: #a7aaad;
376
+ }
377
+
378
+ .components-input-control__prefix,
379
+ .components-input-control__suffix {
380
+ padding: 0 12px;
381
+ background: #f0f0f1;
382
+ margin: 0;
383
+ }
384
+ }
385
+
386
+ .components-placeholder {
387
+ background: var( --de-settings-surface );
388
+ border: 1px solid var( --de-settings-line );
389
+ box-shadow: none;
390
+
391
+ .components-placeholder__fieldset {
392
+ justify-content: center;
393
+ }
394
+ }
395
+
396
+ .components-notice-list {
397
+ z-index: 20;
398
+ }
399
+
400
+ @media screen and ( max-width: 1100px ) {
401
+ .de-settings__body {
402
+ grid-template-columns: minmax( 0, 1fr );
403
+ }
404
+
405
+ .de-settings__rail {
406
+ border-right: 0;
407
+ border-bottom: 1px solid var( --de-settings-line );
408
+ }
409
+
410
+ .de-settings__rail-inner {
411
+ position: static;
412
+ max-height: none;
413
+ padding-top: 0;
414
+ }
415
+
416
+ .de-settings__rail-list {
417
+ display: flex;
418
+ overflow-x: auto;
419
+ padding-bottom: 0;
420
+ }
421
+
422
+ .de-settings__rail-group {
423
+ display: none;
424
+ }
425
+
426
+ .de-settings__rail-item {
427
+ width: auto;
428
+ white-space: nowrap;
429
+ border-left: 0;
430
+ border-bottom: 3px solid transparent;
431
+
432
+ &[aria-selected='true'] {
433
+ border-left-color: transparent;
434
+ border-bottom-color: var( --de-settings-blue );
435
+ }
436
+ }
437
+
438
+ .de-settings__rail-foot {
439
+ margin-top: 0;
440
+ border-top: 1px solid var( --de-settings-line );
441
+ }
442
+
443
+ .de-settings__pane {
444
+ padding: 20px 16px 40px;
445
+ }
446
+ }
447
+
448
+ @media screen and ( max-width: 782px ) {
449
+ --de-settings-admin-bar: 46px;
450
+
451
+ .de-settings__header {
452
+ flex-wrap: wrap;
453
+ gap: 8px 12px;
454
+ }
455
+
456
+ .de-settings__header-actions {
457
+ margin-left: 0;
458
+ width: 100%;
459
+ }
460
+ }
461
+ }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _element = require("@wordpress/element");
8
+ /**
9
+ * Read the section key carried in the URL hash.
10
+ *
11
+ * @since 0.6.0
12
+ * @param {Array} keys Known section keys.
13
+ * @return {string|null} Matching section key, or null when the hash names nothing known.
14
+ */
15
+ const readSectionFromHash = keys => {
16
+ const hash = window.location.hash.replace(/^#/, '');
17
+ return keys.includes(hash) ? hash : null;
18
+ };
19
+
20
+ /**
21
+ * Keep the open settings section in the URL.
22
+ *
23
+ * A settings section is worth linking to - in a ticket, in a message, in
24
+ * documentation - and a page that only ever opens on its first section cannot be
25
+ * linked to at all. The hash is written with `replaceState` rather than pushed, so
26
+ * the browser Back button still leaves the settings page instead of walking back
27
+ * through the sections that were opened.
28
+ *
29
+ * @since 0.6.0
30
+ * @param {Array} sections Section definitions.
31
+ * @return {Array} The active section key and a setter for it.
32
+ */
33
+ const useActiveSection = sections => {
34
+ const keySignature = sections.map(section => section.key).join('|');
35
+ const keys = (0, _element.useMemo)(() => {
36
+ return keySignature.split('|').filter(Boolean);
37
+ }, [keySignature]);
38
+ const [activeKey, setActiveKey] = (0, _element.useState)(() => {
39
+ return readSectionFromHash(keys) || keys[0] || '';
40
+ });
41
+ (0, _element.useEffect)(() => {
42
+ const handleHashChange = () => {
43
+ const nextKey = readSectionFromHash(keys);
44
+ if (nextKey) {
45
+ setActiveKey(nextKey);
46
+ }
47
+ };
48
+ window.addEventListener('hashchange', handleHashChange);
49
+ return () => {
50
+ window.removeEventListener('hashchange', handleHashChange);
51
+ };
52
+ }, [keys]);
53
+ (0, _element.useEffect)(() => {
54
+ if (keys.length && !keys.includes(activeKey)) {
55
+ setActiveKey(keys[0]);
56
+ }
57
+ }, [keys, activeKey]);
58
+ const selectSection = (0, _element.useCallback)(key => {
59
+ setActiveKey(key);
60
+ window.history.replaceState(null, '', `#${key}`);
61
+ }, []);
62
+ return [activeKey, selectSection];
63
+ };
64
+ var _default = exports.default = useActiveSection;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dream-encode/wp-js-plugin-utils",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "description": "Common JS functionality used by custom WP plugins.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -25,6 +25,7 @@
25
25
  "./postcss": "./dist/webpack/postcss.config.js",
26
26
  "./settings": "./dist/settings/index.js",
27
27
  "./settings/styles": "./dist/settings/styles/_settings-page.scss",
28
+ "./settings/styles/rail": "./dist/settings/styles/_settings-rail.scss",
28
29
  "./data-migrations": "./dist/data-migrations/index.js",
29
30
  "./components": "./dist/components/index.js",
30
31
  "./components/notifications-drawer/styles": "./dist/components/NotificationsDrawer/styles/_notifications-drawer.scss",