@lion/ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/components/core/src/SlotMixin.js +107 -65
  2. package/components/core/src/browserDetection.js +27 -17
  3. package/components/core/test/SlotMixin.test.js +64 -1
  4. package/components/core/types/SlotMixinTypes.ts +8 -0
  5. package/components/form-core/src/registration/FormRegisteringMixin.js +14 -0
  6. package/components/form-core/test-suites/FormRegistrationMixins.suite.js +57 -2
  7. package/components/form-core/test-suites/form-group/FormGroupMixin.suite.js +1 -1
  8. package/components/form-core/types/registration/FormRegisteringMixinTypes.ts +9 -1
  9. package/components/input-amount/src/parsers.js +11 -3
  10. package/components/input-amount/test/formatters.test.js +11 -0
  11. package/components/input-amount/test/parsers.test.js +32 -4
  12. package/components/input-file/src/LionInputFile.js +2 -1
  13. package/components/input-tel-dropdown/src/LionInputTelDropdown.js +2 -13
  14. package/components/input-tel-dropdown/test-suites/LionInputTelDropdown.suite.js +3 -1
  15. package/components/input-tel-dropdown/types/index.ts +1 -0
  16. package/components/localize/src/number/formatNumber.js +1 -1
  17. package/components/localize/test/number/formatNumber.test.js +9 -0
  18. package/components/overlays/test/local-positioning.test.js +13 -21
  19. package/components/overlays/test/utils-tests/contain-focus.test.js +2 -2
  20. package/components/overlays/test/utils-tests/deep-contains.test.js +3 -3
  21. package/components/overlays/test/utils-tests/get-focusable-elements.test.js +1 -1
  22. package/components/select-rich/src/LionSelectRich.js +1 -1
  23. package/components/select-rich/test/lion-select-rich-interaction.test.js +16 -40
  24. package/components/select-rich/test/lion-select-rich.test.js +16 -14
  25. package/components/select-rich/test-helpers/getSelectRichMembers.js +18 -0
  26. package/custom-elements.json +1318 -205
  27. package/dist-types/components/core/src/SlotMixin.d.ts +5 -1
  28. package/dist-types/components/core/src/SlotMixin.d.ts.map +1 -1
  29. package/dist-types/components/core/src/browserDetection.d.ts +3 -3
  30. package/dist-types/components/core/types/SlotMixinTypes.d.ts +8 -0
  31. package/dist-types/components/core/types/SlotMixinTypes.d.ts.map +1 -1
  32. package/dist-types/components/form-core/src/registration/FormRegisteringMixin.d.ts.map +1 -1
  33. package/dist-types/components/form-core/test-suites/FormRegistrationMixins.suite.d.ts.map +1 -1
  34. package/dist-types/components/form-core/types/registration/FormRegisteringMixinTypes.d.ts +9 -1
  35. package/dist-types/components/form-core/types/registration/FormRegisteringMixinTypes.d.ts.map +1 -1
  36. package/dist-types/components/input-amount/src/parsers.d.ts.map +1 -1
  37. package/dist-types/components/input-file/src/LionInputFile.d.ts +1 -0
  38. package/dist-types/components/input-file/src/LionInputFile.d.ts.map +1 -1
  39. package/dist-types/components/input-tel-dropdown/src/LionInputTelDropdown.d.ts +1 -0
  40. package/dist-types/components/input-tel-dropdown/src/LionInputTelDropdown.d.ts.map +1 -1
  41. package/dist-types/components/input-tel-dropdown/test-suites/LionInputTelDropdown.suite.d.ts.map +1 -1
  42. package/dist-types/components/input-tel-dropdown/types/index.d.ts +1 -0
  43. package/dist-types/components/input-tel-dropdown/types/index.d.ts.map +1 -1
  44. package/dist-types/components/select-rich/test/lion-select-rich-interaction.test.d.ts.map +1 -1
  45. package/dist-types/components/select-rich/test/lion-select-rich.test.d.ts.map +1 -1
  46. package/dist-types/components/select-rich/test-helpers/getSelectRichMembers.d.ts +19 -0
  47. package/dist-types/components/select-rich/test-helpers/getSelectRichMembers.d.ts.map +1 -0
  48. package/dist-types/exports/select-rich-test-helpers.d.ts +2 -0
  49. package/dist-types/exports/select-rich-test-helpers.d.ts.map +1 -0
  50. package/dist-types/tsconfig.tsbuildinfo +1 -1
  51. package/docs/components/checkbox-group/overview.md +2 -2
  52. package/docs/components/collapsible/overview.md +1 -1
  53. package/docs/components/combobox/overview.md +1 -1
  54. package/docs/components/combobox/src/gh-combobox/gh-combobox.js +1 -1
  55. package/docs/components/combobox/src/google-combobox/google-combobox.js +1 -1
  56. package/docs/components/combobox/use-cases.md +1 -1
  57. package/docs/components/dialog/overview.md +1 -1
  58. package/docs/components/fieldset/overview.md +5 -5
  59. package/docs/components/form/overview.md +3 -3
  60. package/docs/components/icon/use-cases.md +1 -1
  61. package/docs/components/input/overview.md +1 -1
  62. package/docs/components/input-amount/overview.md +4 -4
  63. package/docs/components/input-date/overview.md +3 -3
  64. package/docs/components/input-datepicker/overview.md +5 -5
  65. package/docs/components/input-email/overview.md +2 -2
  66. package/docs/components/input-file/overview.md +1 -1
  67. package/docs/components/input-iban/overview.md +2 -2
  68. package/docs/components/input-range/overview.md +2 -2
  69. package/docs/components/input-stepper/overview.md +1 -1
  70. package/docs/components/input-tel/overview.md +1 -1
  71. package/docs/components/input-tel/use-cases.md +6 -6
  72. package/docs/components/input-tel-dropdown/overview.md +1 -1
  73. package/docs/components/input-tel-dropdown/src/intl-input-tel-dropdown.js +1 -0
  74. package/docs/components/listbox/use-cases.md +1 -1
  75. package/docs/components/radio-group/overview.md +3 -3
  76. package/docs/components/select/use-cases.md +1 -1
  77. package/docs/components/select-rich/use-cases.md +1 -1
  78. package/docs/components/tabs/examples.md +1 -1
  79. package/docs/fundamentals/index.md +4 -4
  80. package/docs/fundamentals/node-tools/providence-analytics/LocalConfiguration.md +1 -1
  81. package/docs/fundamentals/node-tools/providence-analytics/QueryResult.md +1 -1
  82. package/docs/fundamentals/node-tools/providence-analytics/analyzer.md +1 -1
  83. package/docs/fundamentals/node-tools/providence-analytics/dashboard.md +1 -1
  84. package/docs/fundamentals/node-tools/providence-analytics/overview.md +3 -3
  85. package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
  86. package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
  87. package/docs/fundamentals/rationales/accessibility.md +1 -1
  88. package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
  89. package/docs/fundamentals/systems/core/overview.md +1 -1
  90. package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
  91. package/docs/fundamentals/systems/form/interaction-states.md +6 -0
  92. package/docs/fundamentals/systems/form/model-value.md +10 -1
  93. package/docs/fundamentals/systems/form/overview.md +28 -28
  94. package/docs/fundamentals/systems/form/validate.md +2 -2
  95. package/docs/fundamentals/systems/localize/overview.md +4 -4
  96. package/docs/fundamentals/systems/overlays/configuration.md +2 -2
  97. package/docs/fundamentals/systems/overlays/overview.md +5 -5
  98. package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
  99. package/docs/fundamentals/tools/helpers/overview.md +1 -1
  100. package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
  101. package/docs/guides/how-to/create-a-custom-field.md +5 -5
  102. package/docs/guides/how-to/create-a-form.md +4 -4
  103. package/docs/guides/how-to/extend-a-native-input.md +2 -2
  104. package/docs/guides/index.md +8 -8
  105. package/docs/guides/principles/index.md +5 -5
  106. package/docs/guides/principles/styling.md +1 -1
  107. package/exports/select-rich-test-helpers.js +1 -0
  108. package/package.json +1 -1
  109. package/select-rich-test-helpers.js +1 -0
@@ -1,18 +1,37 @@
1
1
  /* eslint-disable class-methods-use-this */
2
+ import { isTemplateResult } from 'lit/directive-helpers.js';
2
3
  import { dedupeMixin } from '@open-wc/dedupe-mixin';
3
4
  import { render } from 'lit';
4
- import { isTemplateResult } from 'lit/directive-helpers.js';
5
5
 
6
6
  /**
7
- * @typedef {import('../types/SlotMixinTypes.js').SlotMixin} SlotMixin
8
- * @typedef {import('../types/SlotMixinTypes.js').SlotsMap} SlotsMap
7
+ * @typedef {{renderBefore:Comment; renderTargetThatRespectsShadowRootScoping: HTMLDivElement}} RenderMetaObj
9
8
  * @typedef {import('../types/SlotMixinTypes.js').SlotFunctionResult} SlotFunctionResult
10
9
  * @typedef {import('../types/SlotMixinTypes.js').SlotRerenderObject} SlotRerenderObject
10
+ * @typedef {import('../types/SlotMixinTypes.js').SlotMixin} SlotMixin
11
+ * @typedef {import('../types/SlotMixinTypes.js').SlotsMap} SlotsMap
11
12
  * @typedef {import('lit').LitElement} LitElement
12
13
  */
13
14
 
14
- const isRerenderConfig = (/** @type {SlotFunctionResult} */ o) =>
15
- !Array.isArray(o) && typeof o === 'object' && 'template' in o;
15
+ /**
16
+ * @param {SlotFunctionResult} slotFunctionResult
17
+ * @returns {'template-result'|'node'|'slot-rerender-object'|null}
18
+ */
19
+ function determineSlotFunctionResultType(slotFunctionResult) {
20
+ if (slotFunctionResult instanceof Node) {
21
+ return 'node';
22
+ }
23
+ if (isTemplateResult(slotFunctionResult)) {
24
+ return 'template-result';
25
+ }
26
+ if (
27
+ !Array.isArray(slotFunctionResult) &&
28
+ typeof slotFunctionResult === 'object' &&
29
+ 'template' in slotFunctionResult
30
+ ) {
31
+ return 'slot-rerender-object';
32
+ }
33
+ return null;
34
+ }
16
35
 
17
36
  /**
18
37
  * All intricacies involved in managing light dom can be delegated to SlotMixin. Amongst others, it automatically:
@@ -45,8 +64,8 @@ const SlotMixinImplementation = superclass =>
45
64
  * so that all interactions work as intended and no focus issues can arise (which would be the case
46
65
  * when (cloned) nodes of a render outcome would be moved around)
47
66
  * @private
48
- * @type { Map<string, HTMLDivElement> } */
49
- this.__scopedRenderRoots = new Map();
67
+ * @type { Map<string, RenderMetaObj> } */
68
+ this.__renderMetaPerSlot = new Map();
50
69
  /**
51
70
  * @private
52
71
  * @type {Set<string>}
@@ -79,9 +98,9 @@ const SlotMixinImplementation = superclass =>
79
98
  __rerenderSlot(slotName) {
80
99
  const slotFunctionResult = /** @type {SlotRerenderObject} */ (this.slots[slotName]());
81
100
  this.__renderTemplateInScopedContext({
101
+ renderAsDirectHostChild: slotFunctionResult.renderAsDirectHostChild,
82
102
  template: slotFunctionResult.template,
83
103
  slotName,
84
- shouldRerender: true,
85
104
  });
86
105
  // TODO: this is deprecated, remove later
87
106
  slotFunctionResult.afterRender?.();
@@ -94,14 +113,8 @@ const SlotMixinImplementation = superclass =>
94
113
  update(changedProperties) {
95
114
  super.update(changedProperties);
96
115
 
97
- if (this.__slotsThatNeedRerender.size) {
98
- for (const slotName of Array.from(this.__slotsThatNeedRerender)) {
99
- this.__rerenderSlot(slotName);
100
- }
101
- }
102
-
103
- if (this.__isFirstSlotUpdate) {
104
- this.__isFirstSlotUpdate = false;
116
+ for (const slotName of this.__slotsThatNeedRerender) {
117
+ this.__rerenderSlot(slotName);
105
118
  }
106
119
  }
107
120
 
@@ -110,32 +123,59 @@ const SlotMixinImplementation = superclass =>
110
123
  * @param {object} opts
111
124
  * @param {import('lit').TemplateResult} opts.template
112
125
  * @param {string} opts.slotName
113
- * @param {boolean} [opts.shouldRerender] false when TemplateResult, true when SlotRerenderObject
126
+ * @param {boolean} [opts.renderAsDirectHostChild] when false, the render parent (wrapper div) will be kept in the light dom
127
+ * @returns {void}
114
128
  */
115
- __renderTemplateInScopedContext({ template, slotName, shouldRerender }) {
116
- // @ts-expect-error wait for browser support
117
- const supportsScopedRegistry = !!ShadowRoot.prototype.createElement;
118
- const registryRoot = supportsScopedRegistry ? this.shadowRoot : document;
119
-
120
- let renderTarget;
121
- // Reuse the existing offline renderTargets for results consistent with that of rendering to one target (shadow dom)
122
- if (this.__scopedRenderRoots.has(slotName)) {
123
- renderTarget = this.__scopedRenderRoots.get(slotName);
124
- } else {
129
+ __renderTemplateInScopedContext({ template, slotName, renderAsDirectHostChild }) {
130
+ const isFirstRender = !this.__renderMetaPerSlot.has(slotName);
131
+ if (isFirstRender) {
132
+ // @ts-expect-error wait for browser support
133
+ const supportsScopedRegistry = !!ShadowRoot.prototype.createElement;
134
+ const registryRoot = supportsScopedRegistry ? this.shadowRoot || document : document;
135
+
125
136
  // @ts-expect-error wait for browser support
126
- renderTarget = registryRoot.createElement('div');
127
- if (shouldRerender) {
128
- renderTarget.slot = slotName;
129
- this.appendChild(renderTarget);
137
+ const renderTargetThatRespectsShadowRootScoping = registryRoot.createElement('div');
138
+ const startComment = document.createComment(`_start_slot_${slotName}_`);
139
+ const endComment = document.createComment(`_end_slot_${slotName}_`);
140
+
141
+ renderTargetThatRespectsShadowRootScoping.appendChild(startComment);
142
+ renderTargetThatRespectsShadowRootScoping.appendChild(endComment);
143
+
144
+ // Providing all options breaks Safari; keep host and creationScope
145
+ const { creationScope, host } = this.renderOptions;
146
+ render(template, renderTargetThatRespectsShadowRootScoping, {
147
+ renderBefore: endComment,
148
+ creationScope,
149
+ host,
150
+ });
151
+
152
+ if (renderAsDirectHostChild) {
153
+ const nodes = Array.from(renderTargetThatRespectsShadowRootScoping.childNodes);
154
+ this.__appendNodes({ nodes, renderParent: this, slotName });
155
+ } else {
156
+ renderTargetThatRespectsShadowRootScoping.slot = slotName;
157
+ this.appendChild(renderTargetThatRespectsShadowRootScoping);
130
158
  }
131
- this.__scopedRenderRoots.set(slotName, renderTarget);
159
+
160
+ this.__renderMetaPerSlot.set(slotName, {
161
+ renderTargetThatRespectsShadowRootScoping,
162
+ renderBefore: endComment,
163
+ });
164
+
165
+ return;
132
166
  }
133
167
 
134
- // Providing all options breaks Safari; keep host and creationScope
135
- const { creationScope, host } = this.renderOptions;
136
- render(template, renderTarget, { creationScope, host });
168
+ // Rerender
169
+ const { renderBefore, renderTargetThatRespectsShadowRootScoping } =
170
+ /** @type {RenderMetaObj} */ (this.__renderMetaPerSlot.get(slotName));
171
+
172
+ const rerenderTarget = renderAsDirectHostChild
173
+ ? this
174
+ : renderTargetThatRespectsShadowRootScoping;
137
175
 
138
- return renderTarget;
176
+ // Providing all options breaks Safari: we keep host and creationScope
177
+ const { creationScope, host } = this.renderOptions;
178
+ render(template, rerenderTarget, { creationScope, host, renderBefore });
139
179
  }
140
180
 
141
181
  /**
@@ -145,13 +185,8 @@ const SlotMixinImplementation = superclass =>
145
185
  * which can be used as a render target for most
146
186
  * @param {string} options.slotName For the first render, it's best to use slotName
147
187
  */
148
- __appendNodesForOneTimeRender({ nodes, renderParent = this, slotName }) {
188
+ __appendNodes({ nodes, renderParent = this, slotName }) {
149
189
  for (const node of nodes) {
150
- if (!(node instanceof Node)) {
151
- return;
152
- }
153
- // Here, we add the slot name to the node that is an element
154
- // (ignoring helper comment nodes that might be in our nodes array)
155
190
  if (node instanceof Element && slotName && slotName !== '') {
156
191
  node.setAttribute('slot', slotName);
157
192
  }
@@ -182,29 +217,36 @@ const SlotMixinImplementation = superclass =>
182
217
  this.__privateSlots.add(slotName);
183
218
  }
184
219
 
185
- if (isTemplateResult(slotFunctionResult)) {
186
- const renderTarget = this.__renderTemplateInScopedContext({
187
- template: slotFunctionResult,
188
- slotName,
189
- });
190
- const nodes = Array.from(renderTarget.childNodes);
191
- this.__appendNodesForOneTimeRender({ nodes, renderParent: this, slotName });
192
- } else if (slotFunctionResult instanceof Node) {
193
- const nodes = [/** @type {Node} */ (slotFunctionResult)];
194
- this.__appendNodesForOneTimeRender({ nodes, renderParent: this, slotName });
195
- } else if (isRerenderConfig(slotFunctionResult)) {
196
- // Rerenderable slots are scheduled in the "updated loop"
197
- this.__slotsThatNeedRerender.add(slotName);
198
-
199
- // For backw. compat, we allow a first render on connectedCallback
200
- if (slotFunctionResult.firstRenderOnConnected) {
201
- this.__rerenderSlot(slotName);
202
- }
203
- } else {
204
- throw new Error(
205
- `Slot "${slotName}" configured inside "get slots()" (in prototype) of ${this.constructor.name} may return these types: TemplateResult | Node | {template:TemplateResult, afterRender?:function} | undefined.
206
- You provided: ${slotFunctionResult}`,
207
- );
220
+ const slotFunctionResultType = determineSlotFunctionResultType(slotFunctionResult);
221
+
222
+ switch (slotFunctionResultType) {
223
+ case 'template-result':
224
+ this.__renderTemplateInScopedContext({
225
+ template: /** @type {import('lit').TemplateResult} */ (slotFunctionResult),
226
+ renderAsDirectHostChild: true,
227
+ slotName,
228
+ });
229
+ break;
230
+ case 'node':
231
+ this.__appendNodes({
232
+ nodes: [/** @type {Node} */ (slotFunctionResult)],
233
+ renderParent: this,
234
+ slotName,
235
+ });
236
+ break;
237
+ case 'slot-rerender-object':
238
+ // Rerenderable slots are scheduled in the "update loop"
239
+ this.__slotsThatNeedRerender.add(slotName);
240
+ // For backw. compat, we allow a first render on connectedCallback
241
+ if (/** @type {SlotRerenderObject} */ (slotFunctionResult).firstRenderOnConnected) {
242
+ this.__rerenderSlot(slotName);
243
+ }
244
+ break;
245
+ default:
246
+ throw new Error(
247
+ `Slot "${slotName}" configured inside "get slots()" (in prototype) of ${this.constructor.name} may return these types: TemplateResult | Node | {template:TemplateResult, afterRender?:function} | undefined.
248
+ You provided: ${slotFunctionResult}`,
249
+ );
208
250
  }
209
251
  }
210
252
  }
@@ -1,17 +1,27 @@
1
+ import { isServer } from 'lit';
2
+
1
3
  /**
2
4
  * From https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome
3
- * @param {string} [flavor]
5
+ * @param {string} [flavor='google-chrome']
4
6
  */
5
7
  function checkChrome(flavor = 'google-chrome') {
6
- const isChromium = /** @type {window & { chrome?: boolean}} */ (window).chrome;
8
+ if (isServer) {
9
+ return flavor === 'google-chrome';
10
+ }
11
+
12
+ const isChromium = /** @type {window & { chrome?: boolean}} */ (globalThis).chrome;
13
+
7
14
  if (flavor === 'chromium') {
8
15
  return isChromium;
9
16
  }
10
- const winNav = window.navigator;
11
- const vendorName = winNav.vendor;
12
- const isOpera = typeof (/** @type {window & { opr?: boolean}} */ (window).opr) !== 'undefined';
13
- const isIEedge = winNav.userAgent.indexOf('Edge') > -1;
14
- const isIOSChrome = winNav.userAgent.match('CriOS');
17
+ const winNav = globalThis.navigator;
18
+ const vendorName = winNav?.vendor;
19
+ const isOpera =
20
+ typeof (/** @type {window & { opr?: boolean}} */ (globalThis).opr) !== 'undefined';
21
+ // @ts-ignore
22
+ const isIEedge = globalThis.userAgent?.indexOf('Edge') > -1;
23
+ // @ts-ignore
24
+ const isIOSChrome = globalThis.userAgent?.match('CriOS');
15
25
 
16
26
  if (flavor === 'ios') {
17
27
  return isIOSChrome;
@@ -31,18 +41,18 @@ function checkChrome(flavor = 'google-chrome') {
31
41
  }
32
42
 
33
43
  export const browserDetection = {
34
- isIE11: /Trident/.test(window.navigator.userAgent),
44
+ isIE11: /Trident/.test(globalThis.navigator?.userAgent),
35
45
  isChrome: checkChrome(),
36
46
  isIOSChrome: checkChrome('ios'),
37
47
  isChromium: checkChrome('chromium'),
38
- isFirefox: navigator.userAgent.toLowerCase().indexOf('firefox') > -1,
39
- isMac: navigator.appVersion.indexOf('Mac') !== -1,
40
- isIOS: /iPhone|iPad|iPod/i.test(navigator.userAgent),
48
+ isFirefox: globalThis.navigator?.userAgent.toLowerCase().indexOf('firefox') > -1,
49
+ isMac: globalThis.navigator?.appVersion?.indexOf('Mac') !== -1,
50
+ isIOS: /iPhone|iPad|iPod/i.test(globalThis.navigator?.userAgent),
41
51
  isMacSafari:
42
- navigator.vendor &&
43
- navigator.vendor.indexOf('Apple') > -1 &&
44
- navigator.userAgent &&
45
- navigator.userAgent.indexOf('CriOS') === -1 &&
46
- navigator.userAgent.indexOf('FxiOS') === -1 &&
47
- navigator.appVersion.indexOf('Mac') !== -1,
52
+ globalThis.navigator?.vendor &&
53
+ globalThis.navigator?.vendor.indexOf('Apple') > -1 &&
54
+ globalThis.navigator?.userAgent &&
55
+ globalThis.navigator?.userAgent.indexOf('CriOS') === -1 &&
56
+ globalThis.navigator?.userAgent.indexOf('FxiOS') === -1 &&
57
+ globalThis.navigator?.appVersion.indexOf('Mac') !== -1,
48
58
  };
@@ -208,6 +208,7 @@ describe('SlotMixin', () => {
208
208
  ...super.slots,
209
209
  'focusable-node': () => ({
210
210
  template: html`<input /> `,
211
+ renderAsDirectHostChild: false,
211
212
  }),
212
213
  };
213
214
  }
@@ -225,7 +226,6 @@ describe('SlotMixin', () => {
225
226
  },
226
227
  );
227
228
  const el = /** @type {* & SlotHost} */ (await fixture(`<${tag}></${tag}>`));
228
-
229
229
  el._focusableNode.focus();
230
230
  expect(document.activeElement).to.equal(el._focusableNode);
231
231
 
@@ -298,6 +298,69 @@ describe('SlotMixin', () => {
298
298
  expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
299
299
  });
300
300
 
301
+ it('allows for rerendering complex shadow root into slot as a direct child', async () => {
302
+ const complexSlotTagName = defineCE(
303
+ class extends LitElement {
304
+ render() {
305
+ return html`
306
+ <input />
307
+ <button>I will be focused</button>
308
+ `;
309
+ }
310
+
311
+ get _buttonNode() {
312
+ // @ts-expect-error
313
+ return this.shadowRoot.querySelector('button');
314
+ }
315
+ },
316
+ );
317
+
318
+ const complexSlotTag = unsafeStatic(complexSlotTagName);
319
+
320
+ const tagName = defineCE(
321
+ // @ts-expect-error
322
+ class extends SlotMixin(LitElement) {
323
+ static properties = { currentValue: Number };
324
+
325
+ constructor() {
326
+ super();
327
+ this.currentValue = 0;
328
+ }
329
+
330
+ get slots() {
331
+ return {
332
+ ...super.slots,
333
+ 'focusable-node': () => ({
334
+ template: html`<${complexSlotTag}> </${complexSlotTag}> `,
335
+ renderAsDirectHostChild: true,
336
+ }),
337
+ };
338
+ }
339
+
340
+ render() {
341
+ return html`<slot name="focusable-node"></slot>`;
342
+ }
343
+
344
+ get _focusableNode() {
345
+ return /** @type HTMLSpanElement */ (
346
+ Array.from(this.children).find(elm => elm.slot === 'focusable-node')
347
+ );
348
+ }
349
+ },
350
+ );
351
+ const el = /** @type {* & SlotHost} */ (await fixture(`<${tagName}></${tagName}>`));
352
+
353
+ el._focusableNode._buttonNode.focus();
354
+
355
+ expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
356
+
357
+ el.currentValue = 1;
358
+ await el.updateComplete;
359
+
360
+ expect(document.activeElement).to.equal(el._focusableNode);
361
+ expect(el._focusableNode.shadowRoot.activeElement).to.equal(el._focusableNode._buttonNode);
362
+ });
363
+
301
364
  describe('firstRenderOnConnected (for backwards compatibility)', () => {
302
365
  it('does render on connected when firstRenderOnConnected:true', async () => {
303
366
  // Start with elem that does not render on connectedCallback
@@ -18,6 +18,14 @@ export type SlotRerenderObject = {
18
18
  * For new components, please align with ReactiveElement/LitElement reactive cycle callbacks.
19
19
  */
20
20
  firstRenderOnConnected?: boolean;
21
+ /**
22
+ * This is recommended to set to true always, as its behavior is usually desired and more in line with slot nodes that
23
+ * are not configured as rerenderable.
24
+ * For backward compatibility, it is set to false by default.
25
+ * When not configured, content is wrapped in a div (this can be problematic for ::slotted css selectors and for
26
+ * querySelectors that expect [slot=x] to have some semantic or (presentational) value).
27
+ */
28
+ renderAsDirectHostChild?: boolean;
21
29
  };
22
30
 
23
31
  export type SlotFunctionResult = TemplateResult | Element | SlotRerenderObject | undefined;
@@ -28,6 +28,19 @@ const FormRegisteringMixinImplementation = superclass =>
28
28
  * @type {FormRegistrarHost | undefined}
29
29
  */
30
30
  this._parentFormGroup = undefined;
31
+ /**
32
+ * To encourage accessibility best practices, `form-element-register` events
33
+ * do not pierce through shadow roots. This forces the developer to create form groups and fieldsets that automatically allow the creation of accessible relationships in the same dom tree.
34
+ Use this option if you know what you're doing. It will then be possible to nest FormControls
35
+ inside shadow dom. See https://lion-web.netlify.app/fundamentals/rationales/accessibility/#shadow-roots-and-accessibility
36
+ */
37
+ this.allowCrossRootRegistration = false;
38
+ }
39
+
40
+ static get properties() {
41
+ return {
42
+ allowCrossRootRegistration: { type: Boolean, attribute: 'allow-cross-root-registration' },
43
+ };
31
44
  }
32
45
 
33
46
  connectedCallback() {
@@ -36,6 +49,7 @@ const FormRegisteringMixinImplementation = superclass =>
36
49
  new CustomEvent('form-element-register', {
37
50
  detail: { element: this },
38
51
  bubbles: true,
52
+ composed: Boolean(this.allowCrossRootRegistration),
39
53
  }),
40
54
  );
41
55
  }
@@ -1,11 +1,12 @@
1
- import { LitElement } from 'lit';
2
1
  import { uuid } from '@lion/ui/core.js';
3
- import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
4
2
  import {
5
3
  FormRegisteringMixin,
6
4
  FormRegistrarMixin,
7
5
  FormRegistrarPortalMixin,
8
6
  } from '@lion/ui/form-core.js';
7
+ import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
8
+ import { LitElement } from 'lit';
9
+ import sinon from 'sinon';
9
10
 
10
11
  /**
11
12
  * @typedef {Object} customConfig
@@ -256,6 +257,60 @@ export const runRegistrationSuite = customConfig => {
256
257
  ]);
257
258
  });
258
259
 
260
+ describe('FormRegisteringMixin', () => {
261
+ it('propagates the form-element-register event through the shadowDom', async () => {
262
+ const eventSpy = sinon.spy();
263
+ const withShadowFormControlStr = defineCE(
264
+ class extends FormRegistrarMixin(LitElement) {
265
+ render() {
266
+ return html`
267
+ <${childTag}
268
+ id="child"
269
+ @form-element-register=${eventSpy}
270
+ allow-cross-root-registration
271
+ >
272
+ </${childTag}>`;
273
+ }
274
+ },
275
+ );
276
+ const withShadowFormControlTag = unsafeStatic(withShadowFormControlStr);
277
+
278
+ const el = /** @type {RegistrarClass} */ (
279
+ await fixture(html`
280
+ <${withShadowFormControlTag}>
281
+ </${withShadowFormControlTag}>
282
+ `)
283
+ );
284
+
285
+ expect(eventSpy).to.have.been.calledOnce;
286
+ expect(eventSpy.getCall(0).args[0].composed).to.equal(true);
287
+ expect(el.formElements).to.deep.equal([el.shadowRoot?.querySelector('#child')]);
288
+ });
289
+ it('dispatches the form-element-register event with compose true if allowCrossRootRegistration is set', async () => {
290
+ const eventSpy = sinon.spy();
291
+ /** @type {RegisteringClass} */ (
292
+ await fixture(html`
293
+ <${childTag}
294
+ @form-element-register=${eventSpy}
295
+ allow-cross-root-registration
296
+ >
297
+ </${childTag}>
298
+ `)
299
+ );
300
+ expect(eventSpy).to.have.been.calledOnce;
301
+ expect(eventSpy.getCall(0).args[0].composed).to.equal(true);
302
+ });
303
+ it('dispatches the form-element-register event with compose false if allowCrossRootRegistration is not set', async () => {
304
+ const eventSpy = sinon.spy();
305
+ /** @type {RegisteringClass} */ (
306
+ await fixture(html`
307
+ <${childTag} @form-element-register=${eventSpy}></${childTag}>
308
+ `)
309
+ );
310
+ expect(eventSpy).to.have.been.calledOnce;
311
+ expect(eventSpy.getCall(0).args[0].composed).to.equal(false);
312
+ });
313
+ });
259
314
  describe('FormRegistrarPortalMixin', () => {
260
315
  it('forwards registrations to the .registrationTarget', async () => {
261
316
  const el = /** @type {RegistrarClass} */ (
@@ -666,7 +666,7 @@ export function runFormGroupMixinSuite(cfg = {}) {
666
666
 
667
667
  await triggerFocusFor(hobbyInputNode);
668
668
  await triggerFocusFor(genderInputNode);
669
- const button = /** @type {FormGroup} */ (await fixture(html`<button></button>`));
669
+ const button = /** @type {FormGroup} */ (await fixture(html`<button>Button</button>`));
670
670
  button.focus();
671
671
 
672
672
  expect(el.touched).to.be.true;
@@ -1,5 +1,5 @@
1
- import { LitElement } from 'lit';
2
1
  import { Constructor } from '@open-wc/dedupe-mixin';
2
+ import { LitElement } from 'lit';
3
3
 
4
4
  import { FormRegistrarHost } from './FormRegistrarMixinTypes.js';
5
5
 
@@ -8,6 +8,14 @@ export declare class FormRegisteringHost {
8
8
  * The name the host is registered with to a parent
9
9
  */
10
10
  name: string;
11
+ /**
12
+ * To encourage accessibility best practices, `form-element-register` events
13
+ * do not pierce through shadow roots. This forces the developer to create form groups and fieldsets that
14
+ * automatically allow the creation of accessible relationships in the same dom tree.
15
+ * Use this option if you know what you're doing. It will then be possible to nest FormControls
16
+ * inside shadow dom. See https://lion-web.netlify.app/fundamentals/rationales/accessibility#shadow-roots-and-accessibility
17
+ */
18
+ allowCrossRootRegistration: boolean;
11
19
 
12
20
  /**
13
21
  * The registrar this FormControl registers to, Usually a descendant of FormGroup or
@@ -11,7 +11,8 @@ import { parseNumber, getFractionDigits } from '@lion/ui/localize-no-side-effect
11
11
  * @return {number} new value with rounded up decimals
12
12
  */
13
13
  function round(value, decimals) {
14
- if (typeof decimals === 'undefined') {
14
+ const numberContainsExponent = value?.toString().includes('e');
15
+ if (typeof decimals === 'undefined' || numberContainsExponent) {
15
16
  return Number(value);
16
17
  }
17
18
  return Number(`${Math.round(Number(`${value}e${decimals}`))}e-${decimals}`);
@@ -30,10 +31,17 @@ function round(value, decimals) {
30
31
  * @param {FormatOptions} [givenOptions] Locale Options
31
32
  */
32
33
  export function parseAmount(value, givenOptions) {
34
+ const unmatchedInput = value.match(/[^0-9,.\- ]/g);
35
+ // for the full paste behavior documentation:
36
+ // ./docs/components/input-amount/use-cases.md#paste-behavior
37
+ if (unmatchedInput && givenOptions?.mode !== 'pasted') {
38
+ return undefined;
39
+ }
40
+
33
41
  const number = parseNumber(value, givenOptions);
34
42
 
35
- if (typeof number !== 'number') {
36
- return number;
43
+ if (typeof number !== 'number' || Number.isNaN(number)) {
44
+ return undefined;
37
45
  }
38
46
 
39
47
  /** @type {FormatOptions} */
@@ -74,4 +74,15 @@ describe('formatAmount()', () => {
74
74
  localizeManager.locale = 'nl-NL';
75
75
  expect(formatAmount(12345678)).to.equal('12.345.678,00');
76
76
  });
77
+
78
+ // TODO: make it work with big numbers, e.g. make use of BigInt
79
+ it.skip('rounds up big numbers', async () => {
80
+ expect(formatAmount(1e21, { locale: 'en-GB', currency: 'EUR' })).to.equal(
81
+ '1,000,000,000,000,000,000,000.00',
82
+ );
83
+ // eslint-disable-next-line no-loss-of-precision
84
+ expect(formatAmount(12345678987654321.42, { locale: 'en-GB', currency: 'EUR' })).to.equal(
85
+ '12,345,678,987,654,321.42',
86
+ );
87
+ });
77
88
  });
@@ -1,11 +1,20 @@
1
1
  import { expect } from '@open-wc/testing';
2
- import { localize } from '@lion/ui/localize.js';
3
-
2
+ import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js';
3
+ import { localizeTearDown } from '@lion/ui/localize-test-helpers.js';
4
4
  import { parseAmount } from '@lion/ui/input-amount.js';
5
5
 
6
6
  describe('parseAmount()', async () => {
7
+ const localizeManager = getLocalizeManager();
8
+
9
+ beforeEach(() => {
10
+ localizeManager.locale = 'en-GB';
11
+ });
12
+
13
+ afterEach(() => {
14
+ localizeTearDown();
15
+ });
16
+
7
17
  it('with currency set to correct amount of decimals', async () => {
8
- localize.locale = 'en-GB';
9
18
  expect(
10
19
  parseAmount('1.015', {
11
20
  currency: 'EUR',
@@ -29,7 +38,26 @@ describe('parseAmount()', async () => {
29
38
  });
30
39
 
31
40
  it('with no currency keeps all decimals', async () => {
32
- localize.locale = 'en-GB';
33
41
  expect(parseAmount('1.015')).to.equal(1.015);
34
42
  });
43
+
44
+ // TODO: make it work with big numbers, e.g. make use of BigInt
45
+ it.skip('rounds up big numbers', async () => {
46
+ // eslint-disable-next-line no-loss-of-precision
47
+ expect(parseAmount('999999999999999999999,42')).to.equal(999999999999999999999.42);
48
+ // eslint-disable-next-line no-loss-of-precision
49
+ expect(parseAmount('12,345,678,987,654,321.42')).to.equal(12345678987654321.42);
50
+ });
51
+
52
+ it('returns undefined if an invalid value is entered', async () => {
53
+ expect(parseAmount('foo')).to.equal(undefined);
54
+ expect(parseAmount('foo1')).to.equal(undefined);
55
+ expect(parseAmount('EUR 1,50')).to.equal(undefined);
56
+ expect(parseAmount('--1')).to.equal(undefined);
57
+ });
58
+
59
+ it('ignores letters when "pasted" mode used', async () => {
60
+ expect(parseAmount('foo1', { mode: 'pasted' })).to.equal(1);
61
+ expect(parseAmount('EUR 1,50', { mode: 'pasted' })).to.equal(1.5);
62
+ });
35
63
  });
@@ -84,6 +84,7 @@ export class LionInputFile extends ScopedElementsMixin(LocalizeMixin(LionField))
84
84
  .multiple=${this.multiple}
85
85
  ></lion-selected-file-list>
86
86
  `,
87
+ renderAsDirectHostChild: true,
87
88
  }),
88
89
  };
89
90
  }
@@ -183,7 +184,7 @@ export class LionInputFile extends ScopedElementsMixin(LocalizeMixin(LionField))
183
184
  */
184
185
  get _fileListNode() {
185
186
  return /** @type {LionSelectedFileList} */ (
186
- Array.from(this.children).find(child => child.slot === 'selected-file-list')?.children[0]
187
+ Array.from(this.children).find(child => child.slot === 'selected-file-list')
187
188
  );
188
189
  }
189
190