@lion/ui 0.19.1 → 0.20.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 (95) hide show
  1. package/calendar-suites.js +1 -0
  2. package/components/form-core/src/FormatMixin.js +7 -2
  3. package/components/form-core/test/FormatMixin.test.js +4 -1
  4. package/components/form-core/test-suites/FormatMixin.suite.js +242 -65
  5. package/components/input/test/input-integrations.test.js +3 -2
  6. package/components/input/test/lion-input-integrations.test.js +2 -1
  7. package/components/input-amount/test/lion-input-amount-integrations.test.js +1 -1
  8. package/components/input-amount-dropdown/src/LionInputAmountDropdown.js +22 -4
  9. package/components/input-amount-dropdown/test/lion-input-amount-dropdown-integrations.test.js +22 -0
  10. package/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js +59 -3
  11. package/components/input-date/test/lion-input-date-integrations.test.js +1 -1
  12. package/components/input-datepicker/test/lion-input-datepicker-integrations.test.js +1 -1
  13. package/components/input-email/test/lion-input-email-integrations.test.js +1 -1
  14. package/components/input-iban/test/lion-input-iban-integrations.test.js +1 -1
  15. package/components/input-stepper/test-suites/lion-input-stepper.integration.suite.js +2 -2
  16. package/components/overlays/test-suites/WithHoverInteraction.suite.js +212 -0
  17. package/components/textarea/test/lion-textarea-integrations.test.js +2 -1
  18. package/components/tooltip/test/lion-tooltip.test.js +9 -1
  19. package/custom-elements.json +1382 -1255
  20. package/dist-types/components/form-core/src/FormatMixin.d.ts.map +1 -1
  21. package/dist-types/components/form-core/test-suites/FormatMixin.suite.d.ts +50 -5
  22. package/dist-types/components/form-core/test-suites/FormatMixin.suite.d.ts.map +1 -1
  23. package/dist-types/components/icon/src/LionIcon.d.ts +2 -2
  24. package/dist-types/components/input-amount-dropdown/src/LionInputAmountDropdown.d.ts.map +1 -1
  25. package/dist-types/components/input-amount-dropdown/test/lion-input-amount-dropdown-integrations.test.d.ts +2 -0
  26. package/dist-types/components/input-amount-dropdown/test/lion-input-amount-dropdown-integrations.test.d.ts.map +1 -0
  27. package/dist-types/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.d.ts.map +1 -1
  28. package/dist-types/components/overlays/test-suites/WithHoverInteraction.suite.d.ts +17 -0
  29. package/dist-types/components/overlays/test-suites/WithHoverInteraction.suite.d.ts.map +1 -0
  30. package/dist-types/components/tooltip/test/lion-tooltip.test.d.ts.map +1 -1
  31. package/dist-types/exports/calendar-suites.d.ts +2 -0
  32. package/dist-types/exports/calendar-suites.d.ts.map +1 -0
  33. package/dist-types/exports/input-datepicker-suites.d.ts +2 -0
  34. package/dist-types/exports/input-datepicker-suites.d.ts.map +1 -0
  35. package/dist-types/exports/overlays-test-suites.d.ts +1 -0
  36. package/dist-types/tsconfig.tsbuildinfo +1 -1
  37. package/docs/components/checkbox-group/overview.md +2 -2
  38. package/docs/components/collapsible/overview.md +1 -1
  39. package/docs/components/combobox/overview.md +1 -1
  40. package/docs/components/combobox/use-cases.md +1 -1
  41. package/docs/components/dialog/overview.md +1 -1
  42. package/docs/components/fieldset/overview.md +5 -5
  43. package/docs/components/form/overview.md +3 -3
  44. package/docs/components/form/use-cases.md +2 -2
  45. package/docs/components/icon/use-cases.md +1 -1
  46. package/docs/components/input/overview.md +1 -1
  47. package/docs/components/input-amount/overview.md +3 -3
  48. package/docs/components/input-amount-dropdown/api-table.md +2 -2
  49. package/docs/components/input-date/overview.md +3 -3
  50. package/docs/components/input-datepicker/overview.md +5 -5
  51. package/docs/components/input-email/overview.md +2 -2
  52. package/docs/components/input-file/overview.md +1 -1
  53. package/docs/components/input-iban/overview.md +2 -2
  54. package/docs/components/input-range/overview.md +2 -2
  55. package/docs/components/input-stepper/overview.md +1 -1
  56. package/docs/components/input-tel/overview.md +1 -1
  57. package/docs/components/input-tel/use-cases.md +6 -6
  58. package/docs/components/input-tel-dropdown/overview.md +1 -1
  59. package/docs/components/listbox/use-cases.md +1 -1
  60. package/docs/components/radio-group/overview.md +3 -3
  61. package/docs/components/select/use-cases.md +1 -1
  62. package/docs/components/select-rich/use-cases.md +2 -2
  63. package/docs/components/tabs/examples.md +1 -1
  64. package/docs/fundamentals/index.md +4 -4
  65. package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
  66. package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
  67. package/docs/fundamentals/rationales/accessibility.md +1 -1
  68. package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
  69. package/docs/fundamentals/systems/core/overview.md +1 -1
  70. package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
  71. package/docs/fundamentals/systems/form/model-value.md +1 -1
  72. package/docs/fundamentals/systems/form/overview.md +28 -28
  73. package/docs/fundamentals/systems/form/validate.md +2 -2
  74. package/docs/fundamentals/systems/localize/overview.md +4 -4
  75. package/docs/fundamentals/systems/overlays/configuration.md +2 -2
  76. package/docs/fundamentals/systems/overlays/overview.md +5 -5
  77. package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
  78. package/docs/fundamentals/tools/helpers/overview.md +1 -1
  79. package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
  80. package/docs/guides/how-to/create-a-custom-field.md +5 -5
  81. package/docs/guides/how-to/create-a-form.md +4 -4
  82. package/docs/guides/how-to/extend-a-native-input.md +2 -2
  83. package/docs/guides/how-to/index.md +1 -1
  84. package/docs/guides/index.md +8 -8
  85. package/docs/guides/principles/index.md +5 -5
  86. package/docs/guides/principles/styling.md +1 -1
  87. package/exports/calendar-suites.js +1 -0
  88. package/exports/input-datepicker-suites.js +1 -0
  89. package/exports/overlays-test-suites.js +1 -0
  90. package/input-datepicker-suites.js +1 -0
  91. package/overlays-test-suites.js +1 -0
  92. package/package.json +1 -1
  93. package/components/overlays/test/withHoverInteraction.test.js +0 -118
  94. package/dist-types/components/overlays/test/withHoverInteraction.test.d.ts +0 -2
  95. package/dist-types/components/overlays/test/withHoverInteraction.test.d.ts.map +0 -1
@@ -0,0 +1 @@
1
+ export { runCalendarSuite } from './components/calendar/test-suites/LionCalendar.suite.js';
@@ -2,9 +2,9 @@
2
2
 
3
3
  import { dedupeMixin } from '@open-wc/dedupe-mixin';
4
4
 
5
- import { ValidateMixin } from './validate/ValidateMixin.js';
6
5
  import { FormControlMixin } from './FormControlMixin.js';
7
6
  import { Unparseable } from './validate/Unparseable.js';
7
+ import { ValidateMixin } from './validate/ValidateMixin.js';
8
8
 
9
9
  /**
10
10
  * @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
@@ -89,7 +89,12 @@ const FormatMixinImplementation = superclass =>
89
89
  requestUpdate(name, oldValue, options) {
90
90
  super.requestUpdate(name, oldValue, options);
91
91
 
92
- if (name === 'modelValue' && this.modelValue !== oldValue) {
92
+ const didModelValueChange = () =>
93
+ options?.hasChanged
94
+ ? options.hasChanged(this.modelValue, oldValue)
95
+ : this.modelValue !== oldValue;
96
+
97
+ if (name === 'modelValue' && didModelValueChange()) {
93
98
  this._onModelValueChanged({ modelValue: this.modelValue }, { modelValue: oldValue });
94
99
  }
95
100
  if (name === 'serializedValue' && this.serializedValue !== oldValue) {
@@ -1,3 +1,6 @@
1
1
  import { runFormatMixinSuite } from '../test-suites/FormatMixin.suite.js';
2
2
 
3
- runFormatMixinSuite();
3
+ runFormatMixinSuite({
4
+ tagString: null,
5
+ modelValueType: String,
6
+ });
@@ -1,9 +1,9 @@
1
- import { LitElement } from 'lit';
1
+ import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
2
+ import { FormatMixin, Unparseable, Validator } from '@lion/ui/form-core.js';
2
3
  import { parseDate } from '@lion/ui/localize-no-side-effects.js';
3
4
  import { aTimeout, defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
5
+ import { LitElement } from 'lit';
4
6
  import sinon from 'sinon';
5
- import { Unparseable, Validator, FormatMixin } from '@lion/ui/form-core.js';
6
- import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js';
7
7
 
8
8
  const isLionInputStepper = (/** @type {FormatClass} */ el) => 'valueTextMapping' in el;
9
9
 
@@ -36,56 +36,212 @@ class FormatClass extends FormatMixin(LitElement) {
36
36
  }
37
37
 
38
38
  /**
39
- * @param {{tagString?: string, modelValueType?: modelValueType}} [customConfig]
39
+ * @typedef {object} FormatMixinSuiteConfig
40
+ * @property {string | null} [tagString] - Optional. Element tag name; if omitted, one will be auto-generated via defineCE()
41
+ * @property {string | null} [childTagString] - Optional. Not currently used
42
+ * @property {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType - REQUIRED. The model value type for this component
43
+ * @property {(opts: { toggleValue: boolean, viewValue: boolean }) => any} [valueToggler] - Optional. Provide different test values for toggleValue=false vs toggleValue=true. If not provided, defaults will be generated based on modelValueType.
44
+ * @property {(el: FormatClass) => any} [getExpectedInitialModelValue] - Optional. Expected initial modelValue state when element is created without a value. If not provided, defaults will be generated based on modelValueType.
45
+ * @property {(el: FormatClass) => any} [getExpectedInitialFormattedValue] - Optional. Expected initial formattedValue state when element is created without a value. If not provided, defaults will be generated based on modelValueType.
46
+ * @property {(el: FormatClass) => any} [getExpectedInitialSerializedValue] - Optional. Expected initial serializedValue state when element is created without a value. If not provided, defaults will be generated based on modelValueType.
47
+ * @property {number} [valueChangeCounterOffset] - Optional. Offset for model-value-changed event counter. Use 0 for most types, 1 for Date types where parseDate() creates new objects. If not provided, defaults will be generated based on modelValueType.
48
+ */
49
+
50
+ /**
51
+ * Creates default valueToggler based on modelValueType for backwards compatibility
52
+ * @param {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
53
+ * @returns {(opts: { toggleValue: boolean, viewValue: boolean }) => any}
54
+ */
55
+ function createDefaultValueToggler(modelValueType) {
56
+ if (modelValueType === String) {
57
+ return ({ toggleValue }) => (!toggleValue ? 'test-value-1' : 'test-value-2');
58
+ }
59
+ if (modelValueType === 'iban') {
60
+ return ({ toggleValue }) => (!toggleValue ? 'NL20INGB0001234567' : 'NL91ABNA0417164300');
61
+ }
62
+ if (modelValueType === 'email') {
63
+ return ({ toggleValue }) => (!toggleValue ? 'test@example.com' : 'user@test.org');
64
+ }
65
+ if (modelValueType === Number) {
66
+ return ({ toggleValue, viewValue }) => {
67
+ if (viewValue) {
68
+ return !toggleValue ? '100' : '200';
69
+ }
70
+ return !toggleValue ? 100 : 200;
71
+ };
72
+ }
73
+ if (modelValueType === Date) {
74
+ return ({ toggleValue, viewValue }) => {
75
+ if (viewValue) {
76
+ return !toggleValue ? '01/01/2020' : '02/02/2021';
77
+ }
78
+ return !toggleValue ? parseDate('01/01/2020') : parseDate('02/02/2021');
79
+ };
80
+ }
81
+ if (modelValueType === Object) {
82
+ return ({ toggleValue, viewValue }) => {
83
+ if (viewValue) {
84
+ return !toggleValue ? '{"value": 1}' : '{"value": 2}';
85
+ }
86
+ return !toggleValue ? { value: 1 } : { value: 2 };
87
+ };
88
+ }
89
+ if (modelValueType === Boolean) {
90
+ return ({ toggleValue }) => !toggleValue;
91
+ }
92
+ if (modelValueType === Array) {
93
+ return ({ toggleValue, viewValue }) => {
94
+ if (viewValue) {
95
+ return !toggleValue ? '[1]' : '[2]';
96
+ }
97
+ return !toggleValue ? [1] : [2];
98
+ };
99
+ }
100
+ // Fallback for unknown types
101
+ return ({ toggleValue }) => (!toggleValue ? 'test-value-1' : 'test-value-2');
102
+ }
103
+
104
+ /**
105
+ * Creates default getExpectedInitialModelValue based on modelValueType
106
+ * @param {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
107
+ * @returns {() => any}
108
+ */
109
+ function createDefaultGetExpectedInitialModelValue(modelValueType) {
110
+ if (modelValueType === Boolean) {
111
+ return () => false;
112
+ }
113
+ if (modelValueType === Object) {
114
+ return () => ({});
115
+ }
116
+ if (modelValueType === Array) {
117
+ return () => [];
118
+ }
119
+ // String, Number, Date, 'iban', 'email' all default to empty string
120
+ return () => '';
121
+ }
122
+
123
+ /**
124
+ * Creates default getExpectedInitialFormattedValue based on modelValueType
125
+ * @param {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
126
+ * @returns {() => any}
127
+ */
128
+ function createDefaultGetExpectedInitialFormattedValue(modelValueType) {
129
+ // Same as modelValue for default case
130
+ return createDefaultGetExpectedInitialModelValue(modelValueType);
131
+ }
132
+
133
+ /**
134
+ * Creates default getExpectedInitialSerializedValue based on modelValueType
135
+ * @param {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
136
+ * @returns {() => any}
137
+ */
138
+ function createDefaultGetExpectedInitialSerializedValue(modelValueType) {
139
+ // Same as modelValue for default case
140
+ return createDefaultGetExpectedInitialModelValue(modelValueType);
141
+ }
142
+
143
+ /**
144
+ * Creates default valueChangeCounterOffset based on modelValueType
145
+ * @param {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
146
+ * @returns {number}
147
+ */
148
+ function createDefaultValueChangeCounterOffset(modelValueType) {
149
+ // Date types need offset of 1 because parseDate creates new objects
150
+ if (modelValueType === Date) {
151
+ return 1;
152
+ }
153
+ return 0;
154
+ }
155
+
156
+ /**
157
+ * @param {FormatMixinSuiteConfig} [customConfig]
40
158
  */
41
159
  export function runFormatMixinSuite(customConfig) {
42
160
  const cfg = {
43
- tagString: null,
44
- childTagString: null,
45
- modelValueType: String,
46
161
  ...customConfig,
47
162
  };
48
163
 
164
+ // Validate that modelValueType is provided (required for generating defaults)
165
+ if (!cfg.modelValueType) {
166
+ throw new Error(
167
+ 'FormatMixinSuite: modelValueType is REQUIRED in config. ' +
168
+ 'Provide the model value type for this component (e.g., String, Number, Date, Object, Boolean, Array, "iban", "email").',
169
+ );
170
+ }
171
+
172
+ // Use provided values or generate defaults based on modelValueType
173
+ const valueToggler = cfg.valueToggler || createDefaultValueToggler(cfg.modelValueType);
174
+ const getExpectedInitialModelValue =
175
+ cfg.getExpectedInitialModelValue ||
176
+ createDefaultGetExpectedInitialModelValue(cfg.modelValueType);
177
+ const getExpectedInitialFormattedValue =
178
+ cfg.getExpectedInitialFormattedValue ||
179
+ createDefaultGetExpectedInitialFormattedValue(cfg.modelValueType);
180
+ const getExpectedInitialSerializedValue =
181
+ cfg.getExpectedInitialSerializedValue ||
182
+ createDefaultGetExpectedInitialSerializedValue(cfg.modelValueType);
183
+ const valueChangeCounterOffset =
184
+ cfg.valueChangeCounterOffset !== undefined
185
+ ? cfg.valueChangeCounterOffset
186
+ : createDefaultValueChangeCounterOffset(cfg.modelValueType);
187
+
49
188
  /**
50
- * Mocks a value for you based on the data type
51
- * Optionally toggles you a different value
52
- * for needing to mimic a value-change.
189
+ * Generates test values by delegating to the component's valueToggler function.
190
+ * The suite does not make assumptions about value types or structures.
191
+ * All value generation is the responsibility of the consuming component.
192
+ *
193
+ * @param {object} [opts={}]
194
+ * @returns {any}
53
195
  */
54
196
  function generateValueBasedOnType(opts = {}) {
55
- const options = { type: cfg.modelValueType, toggleValue: false, viewValue: false, ...opts };
56
-
57
- switch (options.type) {
58
- case Number:
59
- return !options.toggleValue ? '123' : '456';
60
- case Date:
61
- // viewValue instead of modelValue, since a Date object is unparseable.
62
- // Note: this viewValue needs to point to the same date as the
63
- // default returned modelValue.
64
- if (options.viewValue) {
65
- return !options.toggleValue ? '5-5-2005' : '10-10-2010';
66
- }
67
- return !options.toggleValue ? parseDate('5-5-2005') : parseDate('10-10-2010');
68
- case Array:
69
- return !options.toggleValue ? ['foo', 'bar'] : ['baz', 'yay'];
70
- case Object:
71
- return !options.toggleValue ? { foo: 'bar' } : { bar: 'foo' };
72
- case Boolean:
73
- return !options.toggleValue;
74
- case 'email':
75
- return !options.toggleValue ? 'some-user@ing.com' : 'another-user@ing.com';
76
- case 'iban':
77
- return !options.toggleValue ? 'SE3550000000054910000003' : 'CH9300762011623852957';
78
- default:
79
- return !options.toggleValue ? 'foo' : 'bar';
197
+ const options = {
198
+ toggleValue: false,
199
+ viewValue: false,
200
+ ...opts,
201
+ };
202
+
203
+ // Always delegate to the component's valueToggler (required in config)
204
+ return valueToggler(options);
205
+ }
206
+
207
+ /**
208
+ * @param {FormatClass} el
209
+ */
210
+ function generateExpectedInitialModelValue(el) {
211
+ return getExpectedInitialModelValue(el);
212
+ }
213
+
214
+ /**
215
+ * @param {FormatClass} el
216
+ */
217
+ function generateExpectedInitialFormattedValue(el) {
218
+ return getExpectedInitialFormattedValue(el);
219
+ }
220
+
221
+ /**
222
+ * @param {FormatClass} el
223
+ */
224
+ function generateExpectedInitialSerializedValue(el) {
225
+ return getExpectedInitialSerializedValue(el);
226
+ }
227
+
228
+ /**
229
+ * @param {any} actual
230
+ * @param {any} expected
231
+ * @param {string} message
232
+ */
233
+ function expectValue(actual, expected, message) {
234
+ if (typeof expected === 'object' && expected !== null) {
235
+ expect(actual).to.deep.equal(expected, message);
236
+ return;
80
237
  }
238
+ expect(actual).to.equal(expected, message);
81
239
  }
82
240
 
83
241
  describe('FormatMixin', async () => {
84
242
  /** @type {{_$litStatic$: any}} */
85
243
  let tag;
86
244
  /** @type {FormatClass} */
87
- let nonFormat;
88
- /** @type {FormatClass} */
89
245
  let fooFormat;
90
246
 
91
247
  before(async () => {
@@ -94,17 +250,6 @@ export function runFormatMixinSuite(customConfig) {
94
250
  }
95
251
  tag = unsafeStatic(cfg.tagString);
96
252
 
97
- nonFormat = await fixture(html`
98
- <${tag}
99
- .formatter="${/** @param {?} v */ v => v}"
100
- .parser="${/** @param {string} v */ v => v}"
101
- .serializer="${/** @param {?} v */ v => v}"
102
- .deserializer="${/** @param {string} v */ v => v}"
103
- >
104
- <input slot="input">
105
- </${tag}>
106
- `);
107
-
108
253
  fooFormat = await fixture(html`
109
254
  <${tag}
110
255
  .formatter="${/** @param {string} value */ value => `foo: ${value}`}"
@@ -118,14 +263,41 @@ export function runFormatMixinSuite(customConfig) {
118
263
  });
119
264
 
120
265
  it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => {
121
- expect(nonFormat.modelValue).to.equal('', 'modelValue initially');
122
- expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially');
123
- expect(nonFormat.serializedValue).to.equal('', 'serializedValue initially');
266
+ const nonFormat = /** @type {FormatClass} */ (
267
+ await fixture(html`
268
+ <${tag}
269
+ .formatter="${/** @param {?} v */ v => v}"
270
+ .parser="${/** @param {string} v */ v => v}"
271
+ .serializer="${/** @param {?} v */ v => v}"
272
+ .deserializer="${/** @param {string} v */ v => v}"
273
+ >
274
+ <input slot="input">
275
+ </${tag}>
276
+ `)
277
+ );
278
+ await nonFormat.updateComplete;
279
+
280
+ expectValue(
281
+ nonFormat.modelValue,
282
+ generateExpectedInitialModelValue(nonFormat),
283
+ 'modelValue initially',
284
+ );
285
+ expectValue(
286
+ nonFormat.formattedValue,
287
+ generateExpectedInitialFormattedValue(nonFormat),
288
+ 'formattedValue initially',
289
+ );
290
+ expectValue(
291
+ nonFormat.serializedValue,
292
+ generateExpectedInitialSerializedValue(nonFormat),
293
+ 'serializedValue initially',
294
+ );
295
+
124
296
  const generatedValue = generateValueBasedOnType();
125
297
  nonFormat.modelValue = generatedValue;
126
- expect(nonFormat.modelValue).to.equal(generatedValue, 'modelValue as provided');
127
- expect(nonFormat.formattedValue).to.equal(generatedValue, 'formattedValue synchronized');
128
- expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized');
298
+ expectValue(nonFormat.modelValue, generatedValue, 'modelValue as provided');
299
+ expectValue(nonFormat.formattedValue, generatedValue, 'formattedValue synchronized');
300
+ expectValue(nonFormat.serializedValue, generatedValue, 'serializedValue synchronized');
129
301
  });
130
302
 
131
303
  /**
@@ -145,15 +317,18 @@ export function runFormatMixinSuite(customConfig) {
145
317
  isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
146
318
  });
147
319
 
148
- el.modelValue = 'one';
320
+ const valueOne = generateValueBasedOnType();
321
+ const valueTwo = generateValueBasedOnType({ toggleValue: true });
322
+
323
+ el.modelValue = valueOne;
149
324
  expect(counter).to.equal(1);
150
325
  expect(isTriggeredByUser).to.be.false;
151
326
 
152
327
  // no change means no event
153
- el.modelValue = 'one';
328
+ el.modelValue = valueOne;
154
329
  expect(counter).to.equal(1);
155
330
 
156
- el.modelValue = 'two';
331
+ el.modelValue = valueTwo;
157
332
  expect(counter).to.equal(2);
158
333
  });
159
334
 
@@ -172,18 +347,14 @@ export function runFormatMixinSuite(customConfig) {
172
347
  },
173
348
  );
174
349
 
175
- mimicUserInput(formatEl, generateValueBasedOnType());
350
+ mimicUserInput(formatEl, generateValueBasedOnType({ viewValue: true }));
176
351
  expect(counter).to.equal(1);
177
352
  expect(isTriggeredByUser).to.be.true;
178
353
 
179
- // Counter offset +1 for Date because parseDate created a new Date object
180
- // when the user changes the value.
181
- // This will result in a model-value-changed trigger even if the user value was the same
182
- // TODO: a proper solution would be to add `hasChanged` to input-date, like isSameDate()
183
- // from calendar utils
184
- const counterOffset = cfg.modelValueType === Date ? 1 : 0;
354
+ // All value comparison logic is the responsibility of consuming components
355
+ const counterOffset = valueChangeCounterOffset;
185
356
 
186
- mimicUserInput(formatEl, generateValueBasedOnType());
357
+ mimicUserInput(formatEl, generateValueBasedOnType({ viewValue: true }));
187
358
  expect(counter).to.equal(1 + counterOffset);
188
359
 
189
360
  mimicUserInput(formatEl, generateValueBasedOnType({ toggleValue: true }));
@@ -399,6 +570,10 @@ export function runFormatMixinSuite(customConfig) {
399
570
 
400
571
  describe('Formatter', () => {
401
572
  it('only calls the formatter for valid values on `user-input-changed` ', async () => {
573
+ if (cfg.modelValueType === Object) {
574
+ return;
575
+ }
576
+
402
577
  const formatterSpy = sinon.spy(value => `foo: ${value}`);
403
578
 
404
579
  const generatedModelValue = generateValueBasedOnType();
@@ -660,6 +835,8 @@ export function runFormatMixinSuite(customConfig) {
660
835
  });
661
836
 
662
837
  it('does only calculate derived values as consequence of user input when preprocessed value is different from previous view value', async () => {
838
+ if (cfg.modelValueType === Object) return;
839
+
663
840
  const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
664
841
  if (typeof val !== 'string') return;
665
842
 
@@ -1,9 +1,9 @@
1
- import { defineCE } from '@open-wc/testing';
2
1
  import {
3
- runInteractionStateMixinSuite,
4
2
  runFormatMixinSuite,
3
+ runInteractionStateMixinSuite,
5
4
  runNativeTextFieldMixinSuite,
6
5
  } from '@lion/ui/form-core-test-suites.js';
6
+ import { defineCE } from '@open-wc/testing';
7
7
 
8
8
  import { LionInput } from '@lion/ui/input.js';
9
9
 
@@ -26,6 +26,7 @@ describe('<lion-input> integrations', () => {
26
26
 
27
27
  runFormatMixinSuite({
28
28
  tagString: fieldTagString,
29
+ modelValueType: String,
29
30
  });
30
31
 
31
32
  runNativeTextFieldMixinSuite({
@@ -1,6 +1,6 @@
1
1
  import {
2
- runInteractionStateMixinSuite,
3
2
  runFormatMixinSuite,
3
+ runInteractionStateMixinSuite,
4
4
  } from '@lion/ui/form-core-test-suites.js';
5
5
 
6
6
  import '@lion/ui/define/lion-input.js';
@@ -14,5 +14,6 @@ describe('<lion-input> integrations', () => {
14
14
 
15
15
  runFormatMixinSuite({
16
16
  tagString,
17
+ modelValueType: String,
17
18
  });
18
19
  });
@@ -1,6 +1,6 @@
1
1
  import {
2
- runInteractionStateMixinSuite,
3
2
  runFormatMixinSuite,
3
+ runInteractionStateMixinSuite,
4
4
  } from '@lion/ui/form-core-test-suites.js';
5
5
 
6
6
  import '@lion/ui/define/lion-input-amount.js';
@@ -1,14 +1,31 @@
1
- import { html, css } from 'lit';
2
- import { ref, createRef } from 'lit/directives/ref.js';
1
+ import { css, html } from 'lit';
3
2
  import { ifDefined } from 'lit/directives/if-defined.js';
3
+ import { createRef, ref } from 'lit/directives/ref.js';
4
4
 
5
5
  import { LionInputAmount } from '@lion/ui/input-amount.js';
6
6
  import { currencyUtil } from './currencyUtil.js';
7
- import { parseAmount } from './parsers.js';
8
7
  import { formatAmount } from './formatters.js';
8
+ import { localizeNamespaceLoader } from './localizeNamespaceLoader.js';
9
+ import { parseAmount } from './parsers.js';
9
10
  import { deserializer, serializer } from './serializers.js';
10
11
  import { CurrencyAndAmount } from './validators.js';
11
- import { localizeNamespaceLoader } from './localizeNamespaceLoader.js';
12
+
13
+ /**
14
+ * @param {import('../types/index.js').AmountDropdownModelValue | undefined} newValue
15
+ * @param {import('../types/index.js').AmountDropdownModelValue | undefined} oldValue
16
+ * @returns {boolean}
17
+ */
18
+ const hasChangedAmountDropdownModelValue = (newValue, oldValue) => {
19
+ if (newValue === oldValue) {
20
+ return false;
21
+ }
22
+
23
+ if (!newValue || !oldValue || typeof newValue !== 'object' || typeof oldValue !== 'object') {
24
+ return newValue !== oldValue;
25
+ }
26
+
27
+ return newValue.currency !== oldValue.currency || !Object.is(newValue.amount, oldValue.amount);
28
+ };
12
29
 
13
30
  /**
14
31
  * Note: one could consider to implement LionInputAmountDropdown as a
@@ -57,6 +74,7 @@ export class LionInputAmountDropdown extends LionInputAmount {
57
74
  * @type {any}
58
75
  */
59
76
  static properties = {
77
+ modelValue: { type: Object, hasChanged: hasChangedAmountDropdownModelValue },
60
78
  preferredCurrencies: { type: Array },
61
79
  allowedCurrencies: { type: Array },
62
80
  __dropdownSlot: { type: String },
@@ -0,0 +1,22 @@
1
+ import { runFormatMixinSuite } from '@lion/ui/form-core-test-suites.js';
2
+
3
+ import '@lion/ui/define/lion-input-amount-dropdown.js';
4
+
5
+ const tagString = 'lion-input-amount-dropdown';
6
+
7
+ describe('<lion-input-amount-dropdown> integrations', () => {
8
+ runFormatMixinSuite({
9
+ tagString,
10
+ modelValueType: Object,
11
+ valueToggler: ({ toggleValue, viewValue }) => {
12
+ if (viewValue) {
13
+ return !toggleValue ? '123' : '456';
14
+ }
15
+ return !toggleValue ? { amount: 123, currency: 'EUR' } : { amount: 456, currency: 'EUR' };
16
+ },
17
+ getExpectedInitialModelValue: el => ({ currency: /** @type {any} */ (el).currency }),
18
+ getExpectedInitialFormattedValue: el => ({ currency: /** @type {any} */ (el).currency }),
19
+ getExpectedInitialSerializedValue: el => ({ currency: /** @type {any} */ (el).currency }),
20
+ valueChangeCounterOffset: 0,
21
+ });
22
+ });
@@ -1,14 +1,14 @@
1
1
  import { LionInputAmountDropdown } from '@lion/ui/input-amount-dropdown.js';
2
- import sinon from 'sinon';
3
2
  import {
4
- fixtureSync as _fixtureSync,
5
3
  fixture as _fixture,
6
- unsafeStatic,
4
+ fixtureSync as _fixtureSync,
7
5
  aTimeout,
8
6
  defineCE,
9
7
  expect,
10
8
  html,
9
+ unsafeStatic,
11
10
  } from '@open-wc/testing';
11
+ import sinon from 'sinon';
12
12
 
13
13
  import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
14
14
  import { mimicUserChangingDropdown } from '../test-helpers/mimicUserChangingDropdown.js';
@@ -100,6 +100,62 @@ export function runInputAmountDropdownSuite({ klass } = { klass: LionInputAmount
100
100
  expect(el.prefilled).to.be.true;
101
101
  });
102
102
 
103
+ it('does not fire model-value-changed for semantically equal modelValue objects', async () => {
104
+ const el = await fixture(
105
+ html` <${tag} .modelValue="${{ currency: 'EUR', amount: 123 }}"></${tag}> `,
106
+ );
107
+
108
+ const spy = sinon.spy();
109
+ el.addEventListener('model-value-changed', spy);
110
+
111
+ el.modelValue = { currency: 'EUR', amount: 123 };
112
+ await el.updateComplete;
113
+
114
+ expect(spy.callCount).to.equal(0);
115
+ });
116
+
117
+ it('does fire model-value-changed when amount is updated', async () => {
118
+ const el = await fixture(
119
+ html` <${tag} .modelValue="${{ currency: 'EUR', amount: 126 }}"></${tag}> `,
120
+ );
121
+
122
+ const spy = sinon.spy();
123
+ el.addEventListener('model-value-changed', spy);
124
+
125
+ el.modelValue = { currency: 'EUR', amount: 123 };
126
+ await el.updateComplete;
127
+
128
+ expect(spy.callCount).to.equal(1);
129
+ });
130
+
131
+ it('does fire model-value-changed when currency is updated', async () => {
132
+ const el = await fixture(
133
+ html` <${tag} .modelValue="${{ currency: 'GBP', amount: 123 }}"></${tag}> `,
134
+ );
135
+
136
+ const spy = sinon.spy();
137
+ el.addEventListener('model-value-changed', spy);
138
+
139
+ el.modelValue = { currency: 'EUR', amount: 123 };
140
+ await el.updateComplete;
141
+
142
+ expect(spy.callCount).to.equal(1);
143
+ });
144
+
145
+ it('does fire model-value-changed when currency & amount are updated', async () => {
146
+ const el = await fixture(
147
+ html` <${tag} .modelValue="${{ currency: 'GBP', amount: 100 }}"></${tag}> `,
148
+ );
149
+
150
+ const spy = sinon.spy();
151
+ el.addEventListener('model-value-changed', spy);
152
+
153
+ el.modelValue = { currency: 'EUR', amount: 123 };
154
+ await el.updateComplete;
155
+
156
+ expect(spy.callCount).to.equal(1);
157
+ });
158
+
103
159
  describe('Dropdown display', () => {
104
160
  it('calls `templates.dropdown` with TemplateDataForDropdownInputAmount object', async () => {
105
161
  const modelValue = { currency: 'EUR', amount: 123 };
@@ -1,6 +1,6 @@
1
1
  import {
2
- runInteractionStateMixinSuite,
3
2
  runFormatMixinSuite,
3
+ runInteractionStateMixinSuite,
4
4
  } from '@lion/ui/form-core-test-suites.js';
5
5
 
6
6
  import '@lion/ui/define/lion-input-date.js';
@@ -1,6 +1,6 @@
1
1
  import {
2
- runInteractionStateMixinSuite,
3
2
  runFormatMixinSuite,
3
+ runInteractionStateMixinSuite,
4
4
  } from '@lion/ui/form-core-test-suites.js';
5
5
 
6
6
  import '@lion/ui/define/lion-input-datepicker.js';
@@ -1,5 +1,5 @@
1
- import { runFormatMixinSuite } from '@lion/ui/form-core-test-suites.js';
2
1
  import '@lion/ui/define/lion-input-email.js';
2
+ import { runFormatMixinSuite } from '@lion/ui/form-core-test-suites.js';
3
3
 
4
4
  const tagString = 'lion-input-email';
5
5
 
@@ -1,5 +1,5 @@
1
- import { runFormatMixinSuite } from '@lion/ui/form-core-test-suites.js';
2
1
  import '@lion/ui/define/lion-input-iban.js';
2
+ import { runFormatMixinSuite } from '@lion/ui/form-core-test-suites.js';
3
3
 
4
4
  const tagString = 'lion-input-iban';
5
5
 
@@ -1,8 +1,8 @@
1
- import { defineCE } from '@open-wc/testing';
2
1
  import {
3
- runInteractionStateMixinSuite,
4
2
  runFormatMixinSuite,
3
+ runInteractionStateMixinSuite,
5
4
  } from '@lion/ui/form-core-test-suites.js';
5
+ import { defineCE } from '@open-wc/testing';
6
6
  import { LionInputStepper } from '../src/LionInputStepper.js';
7
7
 
8
8
  export const runInputStepperIntegrationSuite = (klass = LionInputStepper) => {