@vaadin/form-layout 24.7.0-alpha1 → 24.7.0-alpha2

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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2017 - 2024 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { isElementHidden } from '@vaadin/a11y-base/src/focus-utils.js';
7
+ import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
8
+
9
+ /**
10
+ * @polymerMixin
11
+ * @mixes ResizeMixin
12
+ */
13
+ export const FormLayoutMixin = (superClass) =>
14
+ class extends ResizeMixin(superClass) {
15
+ static get properties() {
16
+ return {
17
+ /**
18
+ * @typedef FormLayoutResponsiveStep
19
+ * @type {object}
20
+ * @property {string} minWidth - The threshold value for this step in CSS length units.
21
+ * @property {number} columns - Number of columns. Only natural numbers are valid.
22
+ * @property {string} labelsPosition - Labels position option, valid values: `"aside"` (default), `"top"`.
23
+ */
24
+
25
+ /**
26
+ * Allows specifying a responsive behavior with the number of columns
27
+ * and the label position depending on the layout width.
28
+ *
29
+ * Format: array of objects, each object defines one responsive step
30
+ * with `minWidth` CSS length, `columns` number, and optional
31
+ * `labelsPosition` string of `"aside"` or `"top"`. At least one item is required.
32
+ *
33
+ * #### Examples
34
+ *
35
+ * ```javascript
36
+ * formLayout.responsiveSteps = [{columns: 1}];
37
+ * // The layout is always a single column, labels aside.
38
+ * ```
39
+ *
40
+ * ```javascript
41
+ * formLayout.responsiveSteps = [
42
+ * {minWidth: 0, columns: 1},
43
+ * {minWidth: '40em', columns: 2}
44
+ * ];
45
+ * // Sets two responsive steps:
46
+ * // 1. When the layout width is < 40em, one column, labels aside.
47
+ * // 2. Width >= 40em, two columns, labels aside.
48
+ * ```
49
+ *
50
+ * ```javascript
51
+ * formLayout.responsiveSteps = [
52
+ * {minWidth: 0, columns: 1, labelsPosition: 'top'},
53
+ * {minWidth: '20em', columns: 1},
54
+ * {minWidth: '40em', columns: 2}
55
+ * ];
56
+ * // Default value. Three responsive steps:
57
+ * // 1. Width < 20em, one column, labels on top.
58
+ * // 2. 20em <= width < 40em, one column, labels aside.
59
+ * // 3. Width >= 40em, two columns, labels aside.
60
+ * ```
61
+ *
62
+ * @type {!Array<!FormLayoutResponsiveStep>}
63
+ */
64
+ responsiveSteps: {
65
+ type: Array,
66
+ value() {
67
+ return [
68
+ { minWidth: 0, columns: 1, labelsPosition: 'top' },
69
+ { minWidth: '20em', columns: 1 },
70
+ { minWidth: '40em', columns: 2 },
71
+ ];
72
+ },
73
+ observer: '_responsiveStepsChanged',
74
+ sync: true,
75
+ },
76
+
77
+ /**
78
+ * Current number of columns in the layout
79
+ * @private
80
+ */
81
+ _columnCount: {
82
+ type: Number,
83
+ sync: true,
84
+ },
85
+
86
+ /**
87
+ * Indicates that labels are on top
88
+ * @private
89
+ */
90
+ _labelsOnTop: {
91
+ type: Boolean,
92
+ sync: true,
93
+ },
94
+
95
+ /** @private */
96
+ __isVisible: {
97
+ type: Boolean,
98
+ },
99
+ };
100
+ }
101
+
102
+ static get observers() {
103
+ return ['_invokeUpdateLayout(_columnCount, _labelsOnTop)'];
104
+ }
105
+
106
+ /** @protected */
107
+ ready() {
108
+ // Here we attach a style element that we use for validating
109
+ // CSS values in `responsiveSteps`. We can't add this to the `<template>`,
110
+ // because Polymer will throw it away. We need to create this before
111
+ // `super.ready()`, because `super.ready()` invokes property observers,
112
+ // and the observer for `responsiveSteps` does CSS value validation.
113
+ this.appendChild(this._styleElement);
114
+
115
+ super.ready();
116
+
117
+ this.addEventListener('animationend', this.__onAnimationEnd);
118
+ }
119
+
120
+ constructor() {
121
+ super();
122
+
123
+ this._styleElement = document.createElement('style');
124
+ // Ensure there is a child text node in the style element
125
+ this._styleElement.textContent = ' ';
126
+
127
+ this.__intersectionObserver = new IntersectionObserver(([entry]) => {
128
+ if (!entry.isIntersecting) {
129
+ // Prevent possible jump when layout becomes visible
130
+ this.$.layout.style.opacity = 0;
131
+ }
132
+ if (!this.__isVisible && entry.isIntersecting) {
133
+ this._updateLayout();
134
+ this.$.layout.style.opacity = '';
135
+ }
136
+ this.__isVisible = entry.isIntersecting;
137
+ });
138
+ }
139
+
140
+ /** @protected */
141
+ connectedCallback() {
142
+ super.connectedCallback();
143
+
144
+ requestAnimationFrame(() => this._selectResponsiveStep());
145
+ requestAnimationFrame(() => this._updateLayout());
146
+
147
+ this._observeChildrenColspanChange();
148
+ this.__intersectionObserver.observe(this.$.layout);
149
+ }
150
+
151
+ /** @protected */
152
+ disconnectedCallback() {
153
+ super.disconnectedCallback();
154
+
155
+ this.__mutationObserver.disconnect();
156
+ this.__childObserver.disconnect();
157
+ this.__intersectionObserver.disconnect();
158
+ }
159
+
160
+ /** @private */
161
+ _observeChildrenColspanChange() {
162
+ // Observe changes in form items' `colspan` attribute and update styles
163
+ const mutationObserverConfig = { attributes: true };
164
+
165
+ this.__mutationObserver = new MutationObserver((mutationRecord) => {
166
+ mutationRecord.forEach((mutation) => {
167
+ if (
168
+ mutation.type === 'attributes' &&
169
+ (mutation.attributeName === 'colspan' ||
170
+ mutation.attributeName === 'data-colspan' ||
171
+ mutation.attributeName === 'hidden')
172
+ ) {
173
+ this._updateLayout();
174
+ }
175
+ });
176
+ });
177
+
178
+ // Observe changes to initial children
179
+ [...this.children].forEach((child) => {
180
+ this.__mutationObserver.observe(child, mutationObserverConfig);
181
+ });
182
+
183
+ // Observe changes to lazily added nodes
184
+ this.__childObserver = new MutationObserver((mutations) => {
185
+ const addedNodes = [];
186
+ const removedNodes = [];
187
+
188
+ mutations.forEach((mutation) => {
189
+ addedNodes.push(...this._getObservableNodes(mutation.addedNodes));
190
+ removedNodes.push(...this._getObservableNodes(mutation.removedNodes));
191
+ });
192
+
193
+ addedNodes.forEach((child) => {
194
+ this.__mutationObserver.observe(child, mutationObserverConfig);
195
+ });
196
+
197
+ if (addedNodes.length > 0 || removedNodes.length > 0) {
198
+ this._updateLayout();
199
+ }
200
+ });
201
+
202
+ this.__childObserver.observe(this, { childList: true });
203
+ }
204
+
205
+ /** @private */
206
+ _getObservableNodes(nodeList) {
207
+ const ignore = ['template', 'style', 'dom-repeat', 'dom-if'];
208
+ return Array.from(nodeList).filter(
209
+ (node) => node.nodeType === Node.ELEMENT_NODE && ignore.indexOf(node.localName.toLowerCase()) === -1,
210
+ );
211
+ }
212
+
213
+ /** @private */
214
+ _naturalNumberOrOne(n) {
215
+ if (typeof n === 'number' && n >= 1 && n < Infinity) {
216
+ return Math.floor(n);
217
+ }
218
+ return 1;
219
+ }
220
+
221
+ /** @private */
222
+ _isValidCSSLength(value) {
223
+ // Let us choose a CSS property for validating CSS <length> values:
224
+ // - `border-spacing` accepts `<length> | inherit`, it's the best! But
225
+ // it does not disallow invalid values at all in MSIE :-(
226
+ // - `letter-spacing` and `word-spacing` accept
227
+ // `<length> | normal | inherit`, and disallows everything else, like
228
+ // `<percentage>`, `auto` and such, good enough.
229
+ // - `word-spacing` is used since its shorter.
230
+
231
+ // Disallow known keywords allowed as the `word-spacing` value
232
+ if (value === 'inherit' || value === 'normal') {
233
+ return false;
234
+ }
235
+
236
+ // Use the value in a stylesheet and check the parsed value. Invalid
237
+ // input value results in empty parsed value.
238
+ this._styleElement.firstChild.nodeValue = `#styleElement { word-spacing: ${value}; }`;
239
+
240
+ if (!this._styleElement.sheet) {
241
+ // Stylesheet is not ready, probably not attached to the document yet.
242
+ return true;
243
+ }
244
+
245
+ // Safari 9 sets invalid CSS rules' value to `null`
246
+ return ['', null].indexOf(this._styleElement.sheet.cssRules[0].style.getPropertyValue('word-spacing')) < 0;
247
+ }
248
+
249
+ /** @private */
250
+ _responsiveStepsChanged(responsiveSteps, oldResponsiveSteps) {
251
+ try {
252
+ if (!Array.isArray(responsiveSteps)) {
253
+ throw new Error('Invalid "responsiveSteps" type, an Array is required.');
254
+ }
255
+
256
+ if (responsiveSteps.length < 1) {
257
+ throw new Error('Invalid empty "responsiveSteps" array, at least one item is required.');
258
+ }
259
+
260
+ responsiveSteps.forEach((step) => {
261
+ if (this._naturalNumberOrOne(step.columns) !== step.columns) {
262
+ throw new Error(`Invalid 'columns' value of ${step.columns}, a natural number is required.`);
263
+ }
264
+
265
+ if (step.minWidth !== undefined && !this._isValidCSSLength(step.minWidth)) {
266
+ throw new Error(`Invalid 'minWidth' value of ${step.minWidth}, a valid CSS length required.`);
267
+ }
268
+
269
+ if (step.labelsPosition !== undefined && ['aside', 'top'].indexOf(step.labelsPosition) === -1) {
270
+ throw new Error(
271
+ `Invalid 'labelsPosition' value of ${step.labelsPosition}, 'aside' or 'top' string is required.`,
272
+ );
273
+ }
274
+ });
275
+ } catch (e) {
276
+ if (oldResponsiveSteps && oldResponsiveSteps !== responsiveSteps) {
277
+ console.warn(`${e.message} Using previously set 'responsiveSteps' instead.`);
278
+ this.responsiveSteps = oldResponsiveSteps;
279
+ } else {
280
+ console.warn(`${e.message} Using default 'responsiveSteps' instead.`);
281
+ this.responsiveSteps = [
282
+ { minWidth: 0, columns: 1, labelsPosition: 'top' },
283
+ { minWidth: '20em', columns: 1 },
284
+ { minWidth: '40em', columns: 2 },
285
+ ];
286
+ }
287
+ }
288
+
289
+ this._selectResponsiveStep();
290
+ }
291
+
292
+ /** @private */
293
+ __onAnimationEnd(e) {
294
+ if (e.animationName.indexOf('vaadin-form-layout-appear') === 0) {
295
+ this._selectResponsiveStep();
296
+ }
297
+ }
298
+
299
+ /** @private */
300
+ _selectResponsiveStep() {
301
+ // Iterate through responsiveSteps and choose the step
302
+ let selectedStep;
303
+ const tmpStyleProp = 'background-position';
304
+ this.responsiveSteps.forEach((step) => {
305
+ // Convert minWidth to px units for comparison
306
+ this.$.layout.style.setProperty(tmpStyleProp, step.minWidth);
307
+ const stepMinWidthPx = parseFloat(getComputedStyle(this.$.layout).getPropertyValue(tmpStyleProp));
308
+
309
+ // Compare step min-width with the host width, select the passed step
310
+ if (stepMinWidthPx <= this.offsetWidth) {
311
+ selectedStep = step;
312
+ }
313
+ });
314
+ this.$.layout.style.removeProperty(tmpStyleProp);
315
+
316
+ // Sometimes converting units is not possible, e.g, when element is
317
+ // not connected. Then the `selectedStep` stays `undefined`.
318
+ if (selectedStep) {
319
+ // Apply the chosen responsive step's properties
320
+ this._columnCount = selectedStep.columns;
321
+ this._labelsOnTop = selectedStep.labelsPosition === 'top';
322
+ }
323
+ }
324
+
325
+ /** @private */
326
+ _invokeUpdateLayout() {
327
+ this._updateLayout();
328
+ }
329
+
330
+ /**
331
+ * Update the layout.
332
+ * @protected
333
+ */
334
+ _updateLayout() {
335
+ // Do not update layout when invisible
336
+ if (isElementHidden(this)) {
337
+ return;
338
+ }
339
+
340
+ /*
341
+ The item width formula:
342
+
343
+ itemWidth = colspan / columnCount * 100% - columnSpacing
344
+
345
+ We have to subtract columnSpacing, because the column spacing space is taken
346
+ by item margins of 1/2 * spacing on both sides
347
+ */
348
+
349
+ const style = getComputedStyle(this);
350
+ const columnSpacing = style.getPropertyValue('--vaadin-form-layout-column-spacing');
351
+
352
+ const direction = style.direction;
353
+ const marginStartProp = `margin-${direction === 'ltr' ? 'left' : 'right'}`;
354
+ const marginEndProp = `margin-${direction === 'ltr' ? 'right' : 'left'}`;
355
+
356
+ const containerWidth = this.offsetWidth;
357
+
358
+ let col = 0;
359
+ Array.from(this.children)
360
+ .filter((child) => child.localName === 'br' || getComputedStyle(child).display !== 'none')
361
+ .forEach((child, index, children) => {
362
+ if (child.localName === 'br') {
363
+ // Reset column count on line break
364
+ col = 0;
365
+ return;
366
+ }
367
+
368
+ const attrColspan = child.getAttribute('colspan') || child.getAttribute('data-colspan');
369
+ let colspan;
370
+ colspan = this._naturalNumberOrOne(parseFloat(attrColspan));
371
+
372
+ // Never span further than the number of columns
373
+ colspan = Math.min(colspan, this._columnCount);
374
+
375
+ const childRatio = colspan / this._columnCount;
376
+
377
+ // Note: using 99.9% for 100% fixes rounding errors in MS Edge
378
+ // (< v16), otherwise the items might wrap, resizing is wobbly.
379
+ child.style.width = `calc(${childRatio * 99.9}% - ${1 - childRatio} * ${columnSpacing})`;
380
+
381
+ if (col + colspan > this._columnCount) {
382
+ // Too big to fit on this row, let's wrap it
383
+ col = 0;
384
+ }
385
+
386
+ // At the start edge
387
+ if (col === 0) {
388
+ child.style.setProperty(marginStartProp, '0px');
389
+ } else {
390
+ child.style.removeProperty(marginStartProp);
391
+ }
392
+
393
+ const nextIndex = index + 1;
394
+ const nextLineBreak = nextIndex < children.length && children[nextIndex].localName === 'br';
395
+
396
+ // At the end edge
397
+ if (col + colspan === this._columnCount) {
398
+ child.style.setProperty(marginEndProp, '0px');
399
+ } else if (nextLineBreak) {
400
+ const colspanRatio = (this._columnCount - col - colspan) / this._columnCount;
401
+ child.style.setProperty(
402
+ marginEndProp,
403
+ `calc(${colspanRatio * containerWidth}px + ${colspanRatio} * ${columnSpacing})`,
404
+ );
405
+ } else {
406
+ child.style.removeProperty(marginEndProp);
407
+ }
408
+
409
+ // Move the column counter
410
+ col = (col + colspan) % this._columnCount;
411
+
412
+ if (child.localName === 'vaadin-form-item') {
413
+ if (this._labelsOnTop) {
414
+ if (child.getAttribute('label-position') !== 'top') {
415
+ child.__useLayoutLabelPosition = true;
416
+ child.setAttribute('label-position', 'top');
417
+ }
418
+ } else if (child.__useLayoutLabelPosition) {
419
+ delete child.__useLayoutLabelPosition;
420
+ child.removeAttribute('label-position');
421
+ }
422
+ }
423
+ });
424
+ }
425
+
426
+ /**
427
+ * @protected
428
+ * @override
429
+ */
430
+ _onResize() {
431
+ this._selectResponsiveStep();
432
+ }
433
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2018 - 2024 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import type { CSSResult } from 'lit';
7
+
8
+ export const formLayoutStyles: CSSResult;
9
+
10
+ export const formItemStyles: CSSResult;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2018 - 2024 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { css } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
7
+
8
+ export const formLayoutStyles = css`
9
+ :host {
10
+ display: block;
11
+ max-width: 100%;
12
+ animation: 1ms vaadin-form-layout-appear;
13
+ /* CSS API for host */
14
+ --vaadin-form-item-label-width: 8em;
15
+ --vaadin-form-item-label-spacing: 1em;
16
+ --vaadin-form-item-row-spacing: 1em;
17
+ --vaadin-form-layout-column-spacing: 2em; /* (default) */
18
+ align-self: stretch;
19
+ }
20
+
21
+ @keyframes vaadin-form-layout-appear {
22
+ to {
23
+ opacity: 1 !important; /* stylelint-disable-line keyframe-declaration-no-important */
24
+ }
25
+ }
26
+
27
+ :host([hidden]) {
28
+ display: none !important;
29
+ }
30
+
31
+ #layout {
32
+ display: flex;
33
+
34
+ align-items: baseline; /* default \`stretch\` is not appropriate */
35
+
36
+ flex-wrap: wrap; /* the items should wrap */
37
+ }
38
+
39
+ #layout ::slotted(*) {
40
+ /* Items should neither grow nor shrink. */
41
+ flex-grow: 0;
42
+ flex-shrink: 0;
43
+
44
+ /* Margins make spacing between the columns */
45
+ margin-left: calc(0.5 * var(--vaadin-form-layout-column-spacing));
46
+ margin-right: calc(0.5 * var(--vaadin-form-layout-column-spacing));
47
+ }
48
+
49
+ #layout ::slotted(br) {
50
+ display: none;
51
+ }
52
+ `;
53
+
54
+ export const formItemStyles = css`
55
+ :host {
56
+ display: inline-flex;
57
+ flex-direction: row;
58
+ align-items: baseline;
59
+ margin: calc(0.5 * var(--vaadin-form-item-row-spacing, 1em)) 0;
60
+ }
61
+
62
+ :host([label-position='top']) {
63
+ flex-direction: column;
64
+ align-items: stretch;
65
+ }
66
+
67
+ :host([hidden]) {
68
+ display: none !important;
69
+ }
70
+
71
+ #label {
72
+ width: var(--vaadin-form-item-label-width, 8em);
73
+ flex: 0 0 auto;
74
+ }
75
+
76
+ :host([label-position='top']) #label {
77
+ width: auto;
78
+ }
79
+
80
+ #spacing {
81
+ width: var(--vaadin-form-item-label-spacing, 1em);
82
+ flex: 0 0 auto;
83
+ }
84
+
85
+ #content {
86
+ flex: 1 1 auto;
87
+ }
88
+
89
+ #content ::slotted(.full-width) {
90
+ box-sizing: border-box;
91
+ width: 100%;
92
+ min-width: 0;
93
+ }
94
+ `;
@@ -4,16 +4,10 @@
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
6
  import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
7
- import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
8
7
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
8
+ import { FormLayoutMixin } from './vaadin-form-layout-mixin.js';
9
9
 
10
- export type FormLayoutLabelsPosition = 'aside' | 'top';
11
-
12
- export type FormLayoutResponsiveStep = {
13
- minWidth?: string | 0;
14
- columns: number;
15
- labelsPosition?: FormLayoutLabelsPosition;
16
- };
10
+ export * from './vaadin-form-layout-mixin.js';
17
11
 
18
12
  /**
19
13
  * `<vaadin-form-layout>` is a Web Component providing configurable responsive
@@ -105,51 +99,7 @@ export type FormLayoutResponsiveStep = {
105
99
  * ---|---|---
106
100
  * `--vaadin-form-layout-column-spacing` | Length of the spacing between columns | `2em`
107
101
  */
108
- declare class FormLayout extends ResizeMixin(ElementMixin(ThemableMixin(HTMLElement))) {
109
- /**
110
- * Allows specifying a responsive behavior with the number of columns
111
- * and the label position depending on the layout width.
112
- *
113
- * Format: array of objects, each object defines one responsive step
114
- * with `minWidth` CSS length, `columns` number, and optional
115
- * `labelsPosition` string of `"aside"` or `"top"`. At least one item is required.
116
- *
117
- * #### Examples
118
- *
119
- * ```javascript
120
- * formLayout.responsiveSteps = [{columns: 1}];
121
- * // The layout is always a single column, labels aside.
122
- * ```
123
- *
124
- * ```javascript
125
- * formLayout.responsiveSteps = [
126
- * {minWidth: 0, columns: 1},
127
- * {minWidth: '40em', columns: 2}
128
- * ];
129
- * // Sets two responsive steps:
130
- * // 1. When the layout width is < 40em, one column, labels aside.
131
- * // 2. Width >= 40em, two columns, labels aside.
132
- * ```
133
- *
134
- * ```javascript
135
- * formLayout.responsiveSteps = [
136
- * {minWidth: 0, columns: 1, labelsPosition: 'top'},
137
- * {minWidth: '20em', columns: 1},
138
- * {minWidth: '40em', columns: 2}
139
- * ];
140
- * // Default value. Three responsive steps:
141
- * // 1. Width < 20em, one column, labels on top.
142
- * // 2. 20em <= width < 40em, one column, labels aside.
143
- * // 3. Width >= 40em, two columns, labels aside.
144
- * ```
145
- */
146
- responsiveSteps: FormLayoutResponsiveStep[];
147
-
148
- /**
149
- * Update the layout.
150
- */
151
- protected _updateLayout(): void;
152
- }
102
+ declare class FormLayout extends FormLayoutMixin(ElementMixin(ThemableMixin(HTMLElement))) {}
153
103
 
154
104
  declare global {
155
105
  interface HTMLElementTagNameMap {