@ministryofjustice/frontend 2.2.5 → 3.0.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.
@@ -1,95 +1,49 @@
1
- /* ==========================================================================
2
- #BUTTON GROUP
3
- ========================================================================== */
1
+ $moj-button-hover-colour: #767676;
2
+ $moj-datepicker-mid-grey: #949494;
4
3
 
5
4
  .moj-button-menu {
6
5
  display: inline-block;
7
6
  position: relative;
8
- }
9
-
10
- /* TOGGLE BUTTON */
11
7
 
12
- .moj-button-menu__toggle-button {
13
- display: inline-block;
14
- margin-right: govuk-spacing(2);
15
- margin-bottom: govuk-spacing(2);
16
- width: auto; // Override GDS’s 100% width
17
-
18
- &:last-child {
19
- margin-right: 0;
20
- }
21
-
22
- &:after {
23
- background-repeat: no-repeat;
24
- background-image: url(#{$moj-images-path}icon-arrow-white-down.svg);
25
- content: '';
26
- display: inline-block;
27
- height: 5px;
28
- margin-left: govuk-spacing(2);
29
- width: 10px;
30
- vertical-align: middle;
31
- }
32
- }
33
-
34
- .moj-button-menu__toggle-button:focus {
35
- &:after {
36
- background-image: url(#{$moj-images-path}icon-arrow-black-down.svg);
8
+ > .govuk-button {
9
+ margin-bottom: 0;
37
10
  }
38
11
  }
39
12
 
40
- .moj-button-menu__toggle-button[aria-expanded="true"]:focus {
41
- &:after {
42
- background-image: url(#{$moj-images-path}icon-arrow-black-up.svg);
43
- }
44
- }
45
-
46
- .moj-button-menu__toggle-button:hover {
47
- &:after {
48
- background-image: url(#{$moj-images-path}icon-arrow-white-down.svg);
49
- }
50
- }
51
-
52
- .moj-button-menu__toggle-button[aria-expanded="true"]:hover {
53
- &:after {
54
- background-image: url(#{$moj-images-path}icon-arrow-white-up.svg);
55
- }
13
+ .moj-button-menu__toggle-button {
14
+ display: inline;
56
15
  }
57
16
 
58
- .moj-button-menu__toggle-button[aria-expanded="true"] {
59
- &:after {
60
- background-image: url(#{$moj-images-path}icon-arrow-white-up.svg);
61
- }
17
+ .moj-button-menu__toggle-button span {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ gap: 8px;
62
21
  }
63
22
 
64
- .moj-button-menu__toggle-button--secondary {
65
- margin-bottom: govuk-spacing(1);
66
- margin-right: 0;
67
- &:after {
68
- background-image: url(#{$moj-images-path}icon-arrow-black-down.svg);
69
- }
23
+ .moj-button-menu__toggle-button svg {
24
+ transform: rotate(180deg);
25
+ margin-top: 2px;
70
26
  }
71
27
 
72
- .moj-button-menu__toggle-button--secondary[aria-expanded="true"] {
73
- &:after {
74
- background-image: url(#{$moj-images-path}icon-arrow-black-up.svg);
75
- }
28
+ .moj-button-menu__toggle-button[aria-expanded="true"] svg {
29
+ transform: rotate(0deg);
76
30
  }
77
31
 
78
- .moj-button-menu__toggle-button--secondary:hover {
79
- &:after {
80
- background-image: url(#{$moj-images-path}icon-arrow-black-down.svg);
81
- }
82
- }
32
+ .moj-button-menu__wrapper {
33
+ list-style: none;
34
+ position: absolute;
35
+ margin: 0;
36
+ padding: 0;
37
+ width: 200px;
38
+ top: 43px; //38px button height, 2px shadow, 3px gap
39
+ z-index: 10;
83
40
 
84
- .moj-button-menu__toggle-button--secondary[aria-expanded="true"]:hover {
85
- &:after {
86
- background-image: url(#{$moj-images-path}icon-arrow-black-up.svg);
41
+ &--right {
42
+ right: 0;
87
43
  }
88
44
  }
89
45
 
90
-
91
- /* MENU ITEM */
92
-
46
+ /* Menu items with no JS */
93
47
  .moj-button-menu__item {
94
48
  display: inline-block;
95
49
  margin-right: govuk-spacing(2);
@@ -100,57 +54,65 @@
100
54
  }
101
55
  }
102
56
 
103
- .moj-button-menu [role=menuitem] {
104
- @include govuk-font(19);
105
- background-color: govuk-colour("light-grey");
106
- border: none;
57
+ /* Menu items with JS */
58
+ .moj-button-menu li > .moj-button-menu__item {
59
+ $button-shadow-size: 0;
60
+ @include govuk-font($size: 19, $line-height: 19px);
61
+
107
62
  box-sizing: border-box;
108
- display: block;
63
+ display: inline-block;
64
+ position: relative;
65
+ width: 100%;
66
+ margin-top: 0;
67
+ margin-right: 0;
68
+ margin-left: 0;
109
69
  margin-bottom: 0;
110
70
  padding: govuk-spacing(2);
71
+ border: $govuk-border-width-form-element solid transparent;
72
+ border-radius: 0;
73
+ border-bottom: 1px solid $moj-datepicker-mid-grey;
74
+ color: $govuk-text-colour;
75
+ background-color: govuk-colour("light-grey");
111
76
  text-align: left;
112
- width: 100%;
113
- -webkit-box-sizing: border-box;
77
+ vertical-align: top;
78
+ cursor: pointer;
114
79
  -webkit-appearance: none;
80
+ appearance: none;
115
81
 
116
82
  &:link,
117
- &:visited {
83
+ &:visited,
84
+ &:active,
85
+ &:hover {
86
+ color: $govuk-text-colour;
118
87
  text-decoration: none;
119
- color: govuk-colour("black");
120
88
  }
121
89
 
90
+ &:active,
122
91
  &:hover {
123
- background-color: govuk-colour("mid-grey");
92
+ color: govuk-colour("white");
124
93
  }
125
94
 
126
- &:focus {
127
- outline: 3px solid govuk-colour("yellow");
128
- outline-offset: 0;
129
- position: relative;
130
- z-index: 10;
131
- }
132
- }
133
-
134
- /* MENU WRAPPER */
135
-
136
- .moj-button-menu__wrapper {
137
- font-size: 0; /* Hide whitespace between elements */
138
- }
139
-
140
- .moj-button-menu__wrapper--right {
141
- right: 0;
142
- }
95
+ // Fix unwanted button padding in Firefox
96
+ &::-moz-focus-inner {
97
+ padding: 0;
98
+ border: 0;
99
+ }
143
100
 
144
- .moj-button-menu [role=menu] {
145
- position: absolute;
146
- width: 200px;
147
- z-index: 10;
148
- }
101
+ &:hover {
102
+ background-color: $moj-button-hover-colour;
103
+ }
149
104
 
150
- .moj-button-menu [aria-expanded="true"] + [role=menu] {
151
- display: block;
152
- }
105
+ &:focus {
106
+ border-color: $govuk-focus-colour;
107
+ outline: $govuk-focus-width solid transparent;
108
+ box-shadow: inset 0 0 0 1px $govuk-focus-colour;
109
+ z-index: 10;
110
+ }
153
111
 
154
- .moj-button-menu [aria-expanded="false"] + [role=menu] {
155
- display: none;
112
+ &:focus:not(:active):not(:hover) {
113
+ border-color: $govuk-focus-colour;
114
+ color: $govuk-focus-text-colour;
115
+ background-color: $govuk-focus-colour;
116
+ box-shadow: 0 2px 0 $govuk-focus-text-colour;
117
+ }
156
118
  }
@@ -1,155 +1,324 @@
1
- MOJFrontend.ButtonMenu = function(params) {
2
- this.container = $(params.container);
3
- this.menu = this.container.find('.moj-button-menu__wrapper');
4
- if(params.menuClasses) {
5
- this.menu.addClass(params.menuClasses);
6
- }
7
- this.menu.attr('role', 'menu');
8
- this.mq = params.mq;
9
- this.buttonText = params.buttonText;
10
- this.buttonClasses = params.buttonClasses || '';
11
- this.keys = { esc: 27, up: 38, down: 40, tab: 9 };
12
- this.menu.on('keydown', '[role=menuitem]', $.proxy(this, 'onButtonKeydown'));
13
- this.createToggleButton();
14
- this.setupResponsiveChecks();
15
- $(document).on('click', $.proxy(this, 'onDocumentClick'));
16
- };
1
+ /**
2
+ * @typedef {object} ButtonMenuConfig
3
+ * @property {string} [buttonText=Actions] - Label for the toggle button
4
+ * @property {"left" | "right"} [alignMenu=left] - the alignment of the menu
5
+ * @property {string} [buttonClasses=govuk-button--secondary] - css classes applied to the toggle button
6
+ */
17
7
 
18
- MOJFrontend.ButtonMenu.prototype.onDocumentClick = function(e) {
19
- if(!$.contains(this.container[0], e.target)) {
20
- this.hideMenu();
8
+ /**
9
+ * @param {HTMLElement} $module
10
+ * @param {ButtonMenuConfig} config
11
+ * @constructor
12
+ */
13
+ MOJFrontend.ButtonMenu = function ($module, config = {}) {
14
+ if (!$module) {
15
+ return this;
21
16
  }
22
- };
23
17
 
24
- MOJFrontend.ButtonMenu.prototype.createToggleButton = function() {
25
- this.menuButton = $('<button class="govuk-button moj-button-menu__toggle-button ' + this.buttonClasses + '" type="button" aria-haspopup="true" aria-expanded="false">'+this.buttonText+'</button>');
26
- this.menuButton.on('click', $.proxy(this, 'onMenuButtonClick'));
27
- this.menuButton.on('keydown', $.proxy(this, 'onMenuKeyDown'));
18
+ const schema = Object.freeze({
19
+ properties: {
20
+ buttonText: { type: "string" },
21
+ buttonClasses: { type: "string" },
22
+ alignMenu: { type: "string" },
23
+ },
24
+ });
25
+
26
+ const defaults = {
27
+ buttonText: "Actions",
28
+ alignMenu: "left",
29
+ buttonClasses: "",
30
+ };
31
+ // data attributes override JS config, which overrides defaults
32
+ this.config = this.mergeConfigs(
33
+ defaults,
34
+ config,
35
+ this.parseDataset(schema, $module.dataset),
36
+ );
37
+
38
+ this.$module = $module;
28
39
  };
29
40
 
30
- MOJFrontend.ButtonMenu.prototype.setupResponsiveChecks = function() {
31
- this.mql = window.matchMedia(this.mq);
32
- this.mql.addListener($.proxy(this, 'checkMode'));
33
- this.checkMode(this.mql);
41
+ MOJFrontend.ButtonMenu.prototype.init = function () {
42
+ // If only one button is provided, don't initiate a menu and toggle button
43
+ // if classes have been provided for the toggleButton, apply them to the single item
44
+ if (this.$module.children.length == 1) {
45
+ const button = this.$module.children[0];
46
+ button.classList.forEach((className) => {
47
+ if (className.startsWith("govuk-button-")) {
48
+ button.classList.remove(className);
49
+ }
50
+ button.classList.remove("moj-button-menu__item")
51
+ });
52
+ if (this.config.buttonClasses) {
53
+ button.classList.add(...this.config.buttonClasses.split(" "));
54
+ }
55
+ }
56
+ // Otherwise intialise a button menu
57
+ if (this.$module.children.length > 1) {
58
+ this.initMenu();
59
+ }
34
60
  };
35
61
 
36
- MOJFrontend.ButtonMenu.prototype.checkMode = function(mql) {
37
- if(mql.matches) {
38
- this.enableBigMode();
39
- } else {
40
- this.enableSmallMode();
41
- }
62
+ MOJFrontend.ButtonMenu.prototype.initMenu = function () {
63
+ this.$menu = this.createMenu();
64
+ this.$module.insertAdjacentHTML("afterbegin", this.toggleTemplate());
65
+ this.setupMenuItems();
66
+
67
+ this.$menuToggle = this.$module.querySelector(":scope > button");
68
+ this.items = this.$menu.querySelectorAll("a, button");
69
+
70
+ this.$menuToggle.addEventListener("click", (event) => {
71
+ this.toggleMenu(event);
72
+ });
73
+
74
+ this.$module.addEventListener("keydown", (event) => {
75
+ this.handleKeyDown(event);
76
+ });
77
+
78
+ document.addEventListener("click", (event) => {
79
+ if (!this.$module.contains(event.target)) {
80
+ this.closeMenu(false);
81
+ }
82
+ });
42
83
  };
43
84
 
44
- MOJFrontend.ButtonMenu.prototype.enableSmallMode = function() {
45
- this.container.prepend(this.menuButton);
46
- this.hideMenu();
47
- this.removeButtonClasses();
48
- this.menu.attr('role', 'menu');
49
- this.container.find('.moj-button-menu__item').attr('role', 'menuitem');
85
+ MOJFrontend.ButtonMenu.prototype.createMenu = function () {
86
+ const $menu = document.createElement("ul");
87
+ $menu.setAttribute("role", "list");
88
+ $menu.hidden = true;
89
+ $menu.classList.add("moj-button-menu__wrapper");
90
+ if (this.config.alignMenu == "right") {
91
+ $menu.classList.add("moj-button-menu__wrapper--right");
92
+ }
93
+
94
+ this.$module.appendChild($menu);
95
+ while (this.$module.firstChild !== $menu) {
96
+ $menu.appendChild(this.$module.firstChild);
97
+ }
98
+
99
+ return $menu;
50
100
  };
51
101
 
52
- MOJFrontend.ButtonMenu.prototype.enableBigMode = function() {
53
- this.menuButton.detach();
54
- this.showMenu();
55
- this.addButtonClasses();
56
- this.menu.removeAttr('role');
57
- this.container.find('.moj-button-menu__item').removeAttr('role');
102
+ MOJFrontend.ButtonMenu.prototype.setupMenuItems = function () {
103
+ Array.from(this.$menu.children).forEach((item) => {
104
+ // wrap item in li tag
105
+ const listItem = document.createElement("li");
106
+ this.$menu.insertBefore(listItem, item);
107
+ listItem.appendChild(item);
108
+
109
+ item.setAttribute("tabindex", -1);
110
+
111
+ if (item.tagName == "BUTTON") {
112
+ item.setAttribute("type", "button");
113
+ }
114
+
115
+ item.classList.forEach((className) => {
116
+ if (className.startsWith("govuk-button")) {
117
+ item.classList.remove(className);
118
+ }
119
+ });
120
+
121
+ // add a slight delay after click before closing the menu, makes it *feel* better
122
+ item.addEventListener("click", (event) => {
123
+ setTimeout(() => {
124
+ this.closeMenu(false);
125
+ }, 50);
126
+ });
127
+ });
58
128
  };
59
129
 
60
- MOJFrontend.ButtonMenu.prototype.removeButtonClasses = function() {
61
- this.menu.find('.moj-button-menu__item').each(function(index, el) {
62
- if($(el).hasClass('govuk-button--secondary')) {
63
- $(el).attr('data-secondary', 'true');
64
- $(el).removeClass('govuk-button--secondary');
65
- }
66
- if($(el).hasClass('govuk-button--warning')) {
67
- $(el).attr('data-warning', 'true');
68
- $(el).removeClass('govuk-button--warning');
69
- }
70
- $(el).removeClass('govuk-button');
71
- });
130
+ MOJFrontend.ButtonMenu.prototype.toggleTemplate = function () {
131
+ return `
132
+ <button type="button" class="govuk-button moj-button-menu__toggle-button ${this.config.buttonClasses || ""}" aria-haspopup="true" aria-expanded="false">
133
+ <span>
134
+ ${this.config.buttonText}
135
+ <svg width="11" height="5" viewBox="0 0 11 5" xmlns="http://www.w3.org/2000/svg">
136
+ <path d="M5.5 0L11 5L0 5L5.5 0Z" fill="currentColor"/>
137
+ </svg>
138
+ </span>
139
+ </button>`;
72
140
  };
73
141
 
74
- MOJFrontend.ButtonMenu.prototype.addButtonClasses = function() {
75
- this.menu.find('.moj-button-menu__item').each(function(index, el) {
76
- if($(el).attr('data-secondary') == 'true') {
77
- $(el).addClass('govuk-button--secondary');
78
- }
79
- if($(el).attr('data-warning') == 'true') {
80
- $(el).addClass('govuk-button--warning');
81
- }
82
- $(el).addClass('govuk-button');
83
- });
142
+ /**
143
+ * @returns {boolean}
144
+ */
145
+ MOJFrontend.ButtonMenu.prototype.isOpen = function () {
146
+ return this.$menuToggle.getAttribute("aria-expanded") === "true";
84
147
  };
85
148
 
86
- MOJFrontend.ButtonMenu.prototype.hideMenu = function() {
87
- this.menuButton.attr('aria-expanded', 'false');
149
+ MOJFrontend.ButtonMenu.prototype.toggleMenu = function (event) {
150
+ event.preventDefault();
151
+
152
+ // If menu is triggered with mouse don't move focus to first item
153
+ const keyboardEvent = event.detail == 0;
154
+ const focusIndex = keyboardEvent ? 0 : -1;
155
+
156
+ if (this.isOpen()) {
157
+ this.closeMenu();
158
+ } else {
159
+ this.openMenu(focusIndex);
160
+ }
88
161
  };
89
162
 
90
- MOJFrontend.ButtonMenu.prototype.showMenu = function() {
91
- this.menuButton.attr('aria-expanded', 'true');
163
+ /**
164
+ * Opens the menu and optionally sets the focus to the item with given index
165
+ *
166
+ * @param {number} focusIndex - The index of the item to focus
167
+ */
168
+ MOJFrontend.ButtonMenu.prototype.openMenu = function (focusIndex = 0) {
169
+ this.$menu.hidden = false;
170
+ this.$menuToggle.setAttribute("aria-expanded", "true");
171
+ if (focusIndex !== -1) {
172
+ this.focusItem(focusIndex);
173
+ }
92
174
  };
93
175
 
94
- MOJFrontend.ButtonMenu.prototype.onMenuButtonClick = function() {
95
- this.toggle();
176
+ /**
177
+ * Closes the menu and optionally returns focus back to menuToggle
178
+ *
179
+ * @param {boolean} moveFocus - whether to return focus to the toggle button
180
+ */
181
+ MOJFrontend.ButtonMenu.prototype.closeMenu = function (moveFocus = true) {
182
+ this.$menu.hidden = true;
183
+ this.$menuToggle.setAttribute("aria-expanded", "false");
184
+ if (moveFocus) {
185
+ this.$menuToggle.focus();
186
+ }
96
187
  };
97
188
 
98
- MOJFrontend.ButtonMenu.prototype.toggle = function() {
99
- if(this.menuButton.attr('aria-expanded') == 'false') {
100
- this.showMenu();
101
- this.menu.find('[role=menuitem]').first().focus();
102
- } else {
103
- this.hideMenu();
104
- this.menuButton.focus();
105
- }
189
+ /**
190
+ * Focuses the menu item at the specified index
191
+ *
192
+ * @param {number} index - the index of the item to focus
193
+ */
194
+ MOJFrontend.ButtonMenu.prototype.focusItem = function (index) {
195
+ if (index >= this.items.length) index = 0;
196
+ if (index < 0) index = this.items.length - 1;
197
+
198
+ this.items.item(index)?.focus();
106
199
  };
107
200
 
108
- MOJFrontend.ButtonMenu.prototype.onMenuKeyDown = function(e) {
109
- switch (e.keyCode) {
110
- case this.keys.down:
111
- this.toggle();
112
- break;
113
- }
201
+ MOJFrontend.ButtonMenu.prototype.currentFocusIndex = function () {
202
+ const activeElement = document.activeElement;
203
+ const menuItems = Array.from(this.items);
204
+
205
+ return menuItems.indexOf(activeElement);
114
206
  };
115
207
 
116
- MOJFrontend.ButtonMenu.prototype.onButtonKeydown = function(e) {
117
- switch (e.keyCode) {
118
- case this.keys.up:
119
- e.preventDefault();
120
- this.focusPrevious(e.currentTarget);
121
- break;
122
- case this.keys.down:
123
- e.preventDefault();
124
- this.focusNext(e.currentTarget);
125
- break;
126
- case this.keys.esc:
127
- if(!this.mql.matches) {
128
- this.menuButton.focus();
129
- this.hideMenu();
130
- }
131
- break;
132
- case this.keys.tab:
133
- if(!this.mql.matches) {
134
- this.hideMenu();
135
- }
136
- }
208
+ MOJFrontend.ButtonMenu.prototype.handleKeyDown = function (event) {
209
+ if (event.target == this.$menuToggle) {
210
+ switch (event.key) {
211
+ case "ArrowDown":
212
+ event.preventDefault();
213
+ this.openMenu();
214
+ break;
215
+ case "ArrowUp":
216
+ event.preventDefault();
217
+ this.openMenu(this.items.length - 1);
218
+ break;
219
+ }
220
+ }
221
+
222
+ if (this.$menu.contains(event.target) && this.isOpen()) {
223
+ switch (event.key) {
224
+ case "ArrowDown":
225
+ event.preventDefault();
226
+ if (this.currentFocusIndex() !== -1) {
227
+ this.focusItem(this.currentFocusIndex() + 1);
228
+ }
229
+ break;
230
+ case "ArrowUp":
231
+ event.preventDefault();
232
+ if (this.currentFocusIndex() !== -1) {
233
+ this.focusItem(this.currentFocusIndex() - 1);
234
+ }
235
+ break;
236
+ case "Home":
237
+ event.preventDefault();
238
+ this.focusItem(0);
239
+ break;
240
+ case "End":
241
+ event.preventDefault();
242
+ this.focusItem(this.items.length - 1);
243
+ break;
244
+ }
245
+ }
246
+
247
+ if (event.key == "Escape" && this.isOpen()) {
248
+ this.closeMenu();
249
+ }
250
+ if (event.key == "Tab" && this.isOpen()) {
251
+ this.closeMenu(false);
252
+ }
137
253
  };
138
254
 
139
- MOJFrontend.ButtonMenu.prototype.focusNext = function(currentButton) {
140
- var next = $(currentButton).next();
141
- if(next[0]) {
142
- next.focus();
143
- } else {
144
- this.container.find('[role=menuitem]').first().focus();
145
- }
255
+ /**
256
+ * Parse dataset
257
+ *
258
+ * Loop over an object and normalise each value using {@link normaliseString},
259
+ * optionally expanding nested `i18n.field`
260
+ *
261
+ * @param {{ schema: Schema }} Component - Component class
262
+ * @param {DOMStringMap} dataset - HTML element dataset
263
+ * @returns {Object} Normalised dataset
264
+ */
265
+ MOJFrontend.ButtonMenu.prototype.parseDataset = function (schema, dataset) {
266
+ const parsed = {};
267
+
268
+ for (const [field, attributes] of Object.entries(schema.properties)) {
269
+ if (field in dataset) {
270
+ if (dataset[field]) {
271
+ parsed[field] = dataset[field];
272
+ }
273
+ }
274
+ }
275
+
276
+ return parsed;
146
277
  };
147
278
 
148
- MOJFrontend.ButtonMenu.prototype.focusPrevious = function(currentButton) {
149
- var prev = $(currentButton).prev();
150
- if(prev[0]) {
151
- prev.focus();
152
- } else {
153
- this.container.find('[role=menuitem]').last().focus();
154
- }
279
+ /**
280
+ * Config merging function
281
+ *
282
+ * Takes any number of objects and combines them together, with
283
+ * greatest priority on the LAST item passed in.
284
+ *
285
+ * @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
286
+ * @returns {{ [key: string]: unknown }} A merged config object
287
+ */
288
+ MOJFrontend.ButtonMenu.prototype.mergeConfigs = function (...configObjects) {
289
+ const formattedConfigObject = {};
290
+
291
+ // Loop through each of the passed objects
292
+ for (const configObject of configObjects) {
293
+ for (const key of Object.keys(configObject)) {
294
+ const option = formattedConfigObject[key];
295
+ const override = configObject[key];
296
+
297
+ // Push their keys one-by-one into formattedConfigObject. Any duplicate
298
+ // keys with object values will be merged, otherwise the new value will
299
+ // override the existing value.
300
+ if (typeof option === "object" && typeof override === "object") {
301
+ // @ts-expect-error Index signature for type 'string' is missing
302
+ formattedConfigObject[key] = this.mergeConfigs(option, override);
303
+ } else {
304
+ formattedConfigObject[key] = override;
305
+ }
306
+ }
307
+ }
308
+
309
+ return formattedConfigObject;
155
310
  };
311
+
312
+ /**
313
+ * Schema for component config
314
+ *
315
+ * @typedef {object} Schema
316
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
317
+ */
318
+
319
+ /**
320
+ * Schema property for component config
321
+ *
322
+ * @typedef {object} SchemaProperty
323
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
324
+ */