@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.65 → 2.0.0-next.67

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 (60) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +15992 -15907
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +138 -30
  4. package/dist/automation/automation-button/automation-button.css.js +9 -9
  5. package/dist/automation/valve-analoge-two-way-icon/valve-analog-two-way-icon.d.ts +1 -0
  6. package/dist/automation/valve-analoge-two-way-icon/valve-analog-two-way-icon.d.ts.map +1 -1
  7. package/dist/automation/valve-analoge-two-way-icon/valve-analog-two-way-icon.js +9 -28
  8. package/dist/automation/valve-analoge-two-way-icon/valve-analog-two-way-icon.js.map +1 -1
  9. package/dist/components/accordion-card/accordion-card.css.js +1 -1
  10. package/dist/components/dropdown-button/dropdown-button.d.ts +11 -0
  11. package/dist/components/dropdown-button/dropdown-button.d.ts.map +1 -1
  12. package/dist/components/dropdown-button/dropdown-button.js +24 -2
  13. package/dist/components/dropdown-button/dropdown-button.js.map +1 -1
  14. package/dist/components/keyboard-numeric/keyboard-numeric.d.ts +1 -2
  15. package/dist/components/keyboard-numeric/keyboard-numeric.d.ts.map +1 -1
  16. package/dist/components/keyboard-numeric/keyboard-numeric.js +10 -15
  17. package/dist/components/keyboard-numeric/keyboard-numeric.js.map +1 -1
  18. package/dist/components/number-input-field/number-input-field.d.ts +9 -0
  19. package/dist/components/number-input-field/number-input-field.d.ts.map +1 -1
  20. package/dist/components/number-input-field/number-input-field.js +30 -1
  21. package/dist/components/number-input-field/number-input-field.js.map +1 -1
  22. package/dist/components/number-input-field/number-input-format.d.ts +9 -0
  23. package/dist/components/number-input-field/number-input-format.d.ts.map +1 -1
  24. package/dist/components/number-input-field/number-input-format.js +33 -2
  25. package/dist/components/number-input-field/number-input-format.js.map +1 -1
  26. package/dist/components/stepper-box/stepper-box.css.js +0 -6
  27. package/dist/components/stepper-box/stepper-box.css.js.map +1 -1
  28. package/dist/components/stepper-box/stepper-box.d.ts +3 -13
  29. package/dist/components/stepper-box/stepper-box.d.ts.map +1 -1
  30. package/dist/components/stepper-box/stepper-box.js.map +1 -1
  31. package/dist/components/toggle-button-group/toggle-button-group.d.ts +14 -0
  32. package/dist/components/toggle-button-group/toggle-button-group.d.ts.map +1 -1
  33. package/dist/components/toggle-button-group/toggle-button-group.js +25 -5
  34. package/dist/components/toggle-button-group/toggle-button-group.js.map +1 -1
  35. package/dist/integration-systems/integration-fleet-button/integration-fleet-button.css.js +4 -0
  36. package/dist/integration-systems/integration-fleet-button/integration-fleet-button.css.js.map +1 -1
  37. package/dist/integration-systems/integration-fleet-button/integration-fleet-button.d.ts +1 -0
  38. package/dist/integration-systems/integration-fleet-button/integration-fleet-button.d.ts.map +1 -1
  39. package/dist/integration-systems/integration-fleet-button/integration-fleet-button.js +13 -1
  40. package/dist/integration-systems/integration-fleet-button/integration-fleet-button.js.map +1 -1
  41. package/docs/GettingStarted.mdx +28 -12
  42. package/package.json +1 -1
  43. package/src/automation/automation-button/automation-button.css +9 -9
  44. package/src/automation/valve-analoge-two-way-icon/valve-analog-two-way-icon.ts +9 -28
  45. package/src/components/accordion-card/accordion-card.css +1 -1
  46. package/src/components/dropdown-button/dropdown-button.stories.ts +22 -0
  47. package/src/components/dropdown-button/dropdown-button.ts +33 -4
  48. package/src/components/keyboard-numeric/keyboard-numeric.ts +12 -18
  49. package/src/components/number-input-field/number-input-field.spec.ts +134 -0
  50. package/src/components/number-input-field/number-input-field.ts +47 -0
  51. package/src/components/number-input-field/number-input-format.spec.ts +62 -0
  52. package/src/components/number-input-field/number-input-format.ts +59 -2
  53. package/src/components/stepper-box/stepper-box.css +0 -6
  54. package/src/components/stepper-box/stepper-box.ts +3 -13
  55. package/src/components/toggle-button-group/toggle-button-group.ts +36 -5
  56. package/src/components/toggle-button-group/toggle-button.stories.ts +31 -0
  57. package/src/integration-systems/integration-fleet-button/integration-fleet-button.css +4 -0
  58. package/src/integration-systems/integration-fleet-button/integration-fleet-button.stories.ts +9 -0
  59. package/src/integration-systems/integration-fleet-button/integration-fleet-button.ts +10 -1
  60. package/src/navigation-instruments/readout/readout.stories.ts +0 -6
@@ -2,6 +2,7 @@ import {LitElement, html, svg, unsafeCSS} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
3
  import compentStyle from './valve-analog-two-way-icon.css?inline';
4
4
  import {customElement} from '../../decorator.js';
5
+ import '../../icons/icon-twoway-analog-closed.js';
5
6
 
6
7
  @customElement('obc-valve-analog-two-way-icon')
7
8
  export class ObcValveAnalogTwoWayIcon extends LitElement {
@@ -13,36 +14,16 @@ export class ObcValveAnalogTwoWayIcon extends LitElement {
13
14
  const transform = this.vertical ? 'transform: rotate(90deg);' : '';
14
15
  if (this.closed) {
15
16
  return html` <div class="wrapper" style="${transform}">
16
- <svg
17
- width="24"
18
- height="24"
19
- viewBox="0 0 24 24"
20
- fill="none"
21
- xmlns="http://www.w3.org/2000/svg"
22
- >
23
- <path
24
- fill-rule="evenodd"
25
- clip-rule="evenodd"
26
- d="M3 16.8892L10 12.8059V10.4546L3 6.37127V16.8892ZM11 9.88024V13.3802L2.75194 18.1916C2.41861 18.386 2 18.1456 2 17.7597V5.50075C2 5.11486 2.41861 4.87442 2.75193 5.06886L11 9.88024ZM21 16.8892V6.37127L14 10.4546V12.8059L21 16.8892ZM13 13.3802L21.2481 18.1916C21.5814 18.386 22 18.1456 22 17.7597V5.50075C22 5.11486 21.5814 4.87442 21.2481 5.06886L13 9.88024V13.3802ZM12 22.6301C11.4477 22.6301 11 22.1824 11 21.6301V15.6301C11 15.0778 11.4477 14.6301 12 14.6301C12.5523 14.6301 13 15.0778 13 15.6301V21.6301C13 22.1824 12.5523 22.6301 12 22.6301Z"
27
- fill="var(--automation-device-secondary-color)"
28
- />
29
- <path
30
- d="M10 12.8059L3 16.8892V6.37127L10 10.4546V12.8059Z"
31
- fill="var(--automation-device-tertiary-color)"
32
- />
33
- <path
34
- d="M21 6.37127V16.8892L14 12.8059V10.4546L21 6.37127Z"
35
- fill="var(--automation-device-tertiary-color)"
36
- />
37
- </svg>
17
+ <obi-twoway-analog-closed useCssColor> </obi-twoway-analog-closed>
38
18
  </div>`;
39
19
  }
40
20
 
41
21
  const handleRotation = -(1 - this.value / 100) * 90;
22
+ const handleTranslation = (1 - this.value / 100) * 2;
42
23
  const handle = svg`
43
- <g transform="rotate(${handleRotation} 12 18.5)">
44
- <path fill-rule="evenodd" clip-rule="evenodd" d="M9.49942 20.1303H14.4994C15.3278 20.1303 15.9994 19.4587 15.9994 18.6303C15.9994 17.8019 15.3278 17.1303 14.4994 17.1303H9.49942C8.67099 17.1303 7.99942 17.8019 7.99942 18.6303C7.99942 19.4587 8.67099 20.1303 9.49942 20.1303ZM9.49942 19.1303L14.4994 19.1303C14.7756 19.1303 14.9994 18.9064 14.9994 18.6303C14.9994 18.3542 14.7756 18.1303 14.4994 18.1303L9.49942 18.1303C9.22328 18.1303 8.99942 18.3542 8.99942 18.6303C8.99942 18.9064 9.22328 19.1303 9.49942 19.1303Z" fill="var(--automation-device-tertiary-color)"/>
45
- <path d="M14.4994 19.1303L9.49942 19.1303C9.22328 19.1303 8.99942 18.9064 8.99942 18.6303C8.99942 18.3542 9.22328 18.1303 9.49942 18.1303L14.4994 18.1303C14.7756 18.1303 14.9994 18.3542 14.9994 18.6303C14.9994 18.9064 14.7756 19.1303 14.4994 19.1303Z" fill="var(--automation-device-primary-color)"/>
24
+ <g transform="translate(0, ${handleTranslation}) rotate(${handleRotation} 12 3.5) ">
25
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 5H14.5C15.3284 5 16 4.32843 16 3.5C16 2.67157 15.3284 2 14.5 2H9.5C8.67157 2 8 2.67157 8 3.5C8 4.32843 8.67157 5 9.5 5ZM9.5 4L14.5 4C14.7761 4 15 3.77614 15 3.5C15 3.22386 14.7761 3 14.5 3L9.5 3C9.22386 3 9 3.22386 9 3.5C9 3.77614 9.22386 4 9.5 4Z" fill="var(--automation-device-tertiary-color)"/>
26
+ <path d="M9.5 4L14.5 4C14.7761 4 15 3.77614 15 3.5C15 3.22386 14.7761 3 14.5 3L9.5 3C9.22386 3 9 3.22386 9 3.5C9 3.77614 9.22386 4 9.5 4Z" fill="var(--automation-device-primary-color)"/>
46
27
  </g>
47
28
  `;
48
29
 
@@ -60,11 +41,11 @@ export class ObcValveAnalogTwoWayIcon extends LitElement {
60
41
  xmlns="http://www.w3.org/2000/svg"
61
42
  >
62
43
  <path
63
- d="M2 5.50075V17.7597C2 18.1456 2.41861 18.386 2.75194 18.1916L10.5714 13.6302H13.4286L21.2481 18.1916C21.5814 18.386 22 18.1456 22 17.7597V5.50075C22 5.11486 21.5814 4.87442 21.2481 5.06886L13.4286 9.63024H10.5714L2.75193 5.06886C2.41861 4.87442 2 5.11486 2 5.50075Z"
44
+ d="M11 11L3.5547 6.03645C2.89015 5.59342 2 6.06981 2 6.8685V19.1315C2 19.9302 2.89015 20.4066 3.5547 19.9635L11 15H13L20.4453 19.9635C21.1099 20.4066 22 19.9302 22 19.1315V6.8685C22 6.06981 21.1099 5.59342 20.4453 6.03645L13 11H11ZM3 6.8685L3 19.1315L10.6972 14H13.3028L21 19.1315V6.8685L13.3028 12H10.6972L3 6.8685Z"
64
45
  fill="var(--automation-device-tertiary-color)"
65
46
  />
66
47
  <path
67
- d="M3 6.3712V16.8891L10.3011 12.6302H13.6989L21 16.8891V6.3712L13.6989 10.6302H10.3011L3 6.3712Z"
48
+ d="M10.6972 12H13.3028L21 6.8685V19.1315L13.3028 14H10.6972L3 19.1315V6.8685L10.6972 12Z"
68
49
  fill="var(--automation-device-primary-color)"
69
50
  />
70
51
  ${handle}
@@ -104,7 +85,7 @@ export class ObcValveAnalogTwoWayIcon extends LitElement {
104
85
  <defs>
105
86
  <clipPath id="clip0">
106
87
  <path
107
- d="M3 6.3712V16.8891L10.3011 12.6302H13.6989L21 16.8891V6.3712L13.6989 10.6302H10.3011L3 6.3712Z"
88
+ d="M10.6972 12H13.3028L21 6.8685V19.1315L13.3028 14H10.6972L3 19.1315V6.8685L10.6972 12Z"
108
89
  />
109
90
  </clipPath>
110
91
  </defs>
@@ -158,7 +158,7 @@
158
158
 
159
159
  .label-description {
160
160
  @mixin font-label;
161
- flex: 1 0 0;
161
+ flex: 0 0 auto;
162
162
  width: 100%;
163
163
  overflow: hidden;
164
164
  color: var(--element-neutral-color);
@@ -25,6 +25,12 @@ const meta: Meta<ObcDropdownButton> = {
25
25
  openTop: {
26
26
  control: 'boolean',
27
27
  },
28
+ allowEmptySelection: {
29
+ control: 'boolean',
30
+ },
31
+ placeholder: {
32
+ control: 'text',
33
+ },
28
34
  },
29
35
  args: {
30
36
  options: [
@@ -171,3 +177,19 @@ export const DisabledWithIcon: Story = {
171
177
  </obc-dropdown-button>
172
178
  `,
173
179
  };
180
+
181
+ export const EmptySelection: Story = {
182
+ args: {
183
+ value: 'none',
184
+ allowEmptySelection: true,
185
+ placeholder: 'Select a brand',
186
+ },
187
+ render: (args) => html`
188
+ <obc-dropdown-button
189
+ .options=${args.options}
190
+ .value=${args.value}
191
+ .allowEmptySelection=${args.allowEmptySelection}
192
+ .placeholder=${args.placeholder}
193
+ ></obc-dropdown-button>
194
+ `,
195
+ };
@@ -49,6 +49,8 @@ export type DropdownButtonOption = {
49
49
  * - `options` (Array): List of selectable options, each with a `value` (string), `label` (string), and optional `level` (number) for indentation.
50
50
  * - `value` (string): The currently selected option's value. If not set, defaults to the first option.
51
51
  * - `fullWidth` (boolean): Expands the component to fill its container when true. Default is false.
52
+ * - `allowEmptySelection` (boolean): When true, a `value` that does not match any option leaves the button with no option selected, showing `placeholder` instead of defaulting to the first option. Default is false.
53
+ * - `placeholder` (string): Text shown when nothing is selected and `allowEmptySelection` is true. Default is an empty string.
52
54
  *
53
55
  * ### Events
54
56
  * - `dropdown-change` – Fired when the user selects a different option. The event detail includes `{ value, label }` of the selected option.
@@ -102,6 +104,17 @@ export class ObcDropdownButton extends LitElement {
102
104
  */
103
105
  @property({type: Boolean}) fullWidth = false;
104
106
 
107
+ /**
108
+ * If true, a `value` that does not match any option leaves the button with no option selected,
109
+ * showing `placeholder` instead of defaulting to the first option. Default is false.
110
+ */
111
+ @property({type: Boolean}) allowEmptySelection = false;
112
+
113
+ /**
114
+ * Text shown when nothing is selected and `allowEmptySelection` is true. Default is an empty string.
115
+ */
116
+ @property({type: String}) placeholder = '';
117
+
105
118
  /**
106
119
  * Controls the button's display type.
107
120
  * - `label`: Text label only (default)
@@ -142,10 +155,21 @@ export class ObcDropdownButton extends LitElement {
142
155
  this.selectedLabel = '';
143
156
  return;
144
157
  }
145
- this.selectedValue = this.value || this.options[0].value;
146
- this.selectedLabel = this.value
147
- ? this.options.find((item) => item.value === this.value)?.label || ''
148
- : this.options[0].label;
158
+ const match = this.value
159
+ ? this.options.find((item) => item.value === this.value)
160
+ : undefined;
161
+ if (match) {
162
+ this.selectedValue = match.value;
163
+ this.selectedLabel = match.label;
164
+ return;
165
+ }
166
+ if (this.allowEmptySelection) {
167
+ this.selectedValue = '';
168
+ this.selectedLabel = this.placeholder;
169
+ return;
170
+ }
171
+ this.selectedValue = this.options[0].value;
172
+ this.selectedLabel = this.options[0].label;
149
173
  }
150
174
 
151
175
  override render() {
@@ -172,6 +196,11 @@ export class ObcDropdownButton extends LitElement {
172
196
  </div>
173
197
  </div>
174
198
  <select @change=${this.changeHandler} ?disabled=${this.disabled}>
199
+ ${this.allowEmptySelection && this.selectedValue === ''
200
+ ? html`<option value="" disabled selected hidden>
201
+ ${this.placeholder}
202
+ </option>`
203
+ : nothing}
175
204
  ${this.options.map((item) => {
176
205
  const indent = item.level ? (item.level - 1) * 2 : 0;
177
206
  const indentText = [];
@@ -79,6 +79,10 @@ const ALLOWED_CHARS = [
79
79
 
80
80
  const OPERATORS = ['+', '-', '–', '×', '÷', '*', '/'];
81
81
 
82
+ const ALLOWED_CHARS_PATTERN = `^[${ALLOWED_CHARS.map((c) =>
83
+ c.replace(/[-.*+?^${}()|[\]\\]/g, '\\$&')
84
+ ).join('')}]*$`;
85
+
82
86
  @customElement('obc-keyboard-numeric')
83
87
  export class ObcKeyboardNumeric extends LitElement {
84
88
  @property({type: String}) type: ObcKeyboardNumericType =
@@ -118,6 +122,13 @@ export class ObcKeyboardNumeric extends LitElement {
118
122
  @state() private content: ObcKeyboardNumericContent =
119
123
  ObcKeyboardNumericContent.Numbers;
120
124
 
125
+ private get effectiveValidationPattern(): string {
126
+ if (!this.validationPattern) {
127
+ return ALLOWED_CHARS_PATTERN;
128
+ }
129
+ return `^(?=${ALLOWED_CHARS_PATTERN})(?=${this.validationPattern}).*$`;
130
+ }
131
+
121
132
  /** Validates if a character can be added to the current value */
122
133
  private canAddCharacter(char: string): boolean {
123
134
  const potentialValue = this.value + char;
@@ -150,11 +161,6 @@ export class ObcKeyboardNumeric extends LitElement {
150
161
  return true;
151
162
  }
152
163
 
153
- /** Validates that all characters in a value are allowed */
154
- private isValidValue(value: string): boolean {
155
- return [...value].every((char) => ALLOWED_CHARS.includes(char));
156
- }
157
-
158
164
  private handleCloseClick() {
159
165
  this.dispatchEvent(
160
166
  new CustomEvent('close-click', {bubbles: true, composed: true})
@@ -254,19 +260,6 @@ export class ObcKeyboardNumeric extends LitElement {
254
260
  const input = e.target as ObcNumberInputField;
255
261
  const newValue = input.displayValue;
256
262
 
257
- if (this.validationPattern && newValue) {
258
- const regex = new RegExp(this.validationPattern);
259
- if (!regex.test(newValue)) {
260
- input.displayOverride = this.value;
261
- return;
262
- }
263
- }
264
-
265
- if (newValue && !this.isValidValue(newValue)) {
266
- input.displayOverride = this.value;
267
- return;
268
- }
269
-
270
263
  this.value = newValue;
271
264
  this.dispatchValueChange();
272
265
  }
@@ -359,6 +352,7 @@ export class ObcKeyboardNumeric extends LitElement {
359
352
  .textAlign=${this.inputFieldTextAlign}
360
353
  .size=${ObcNumberInputFieldSize.Large}
361
354
  .helperText=${this.helperText}
355
+ .validationPattern=${this.effectiveValidationPattern}
362
356
  ?hasLeadingIcon=${this.hasLeadingIcon}
363
357
  placeholder="00.0"
364
358
  @input=${this.handleInput}
@@ -218,4 +218,138 @@ describe('obc-number-input-field', () => {
218
218
  expect(input.value).toBe('10.');
219
219
  });
220
220
  });
221
+
222
+ describe('beforeinput filtering', () => {
223
+ function dispatchBeforeInput(
224
+ target: HTMLInputElement,
225
+ data: string | null,
226
+ inputType = 'insertText'
227
+ ): InputEvent {
228
+ const event = new InputEvent('beforeinput', {
229
+ data,
230
+ inputType,
231
+ cancelable: true,
232
+ bubbles: true,
233
+ });
234
+ target.dispatchEvent(event);
235
+ return event;
236
+ }
237
+
238
+ beforeEach(async () => {
239
+ el.decimalSeparator = '.';
240
+ el.groupSeparator = ',';
241
+ input.focus();
242
+ input.value = '';
243
+ await el.updateComplete;
244
+ });
245
+
246
+ it('blocks letters', () => {
247
+ const event = dispatchBeforeInput(input, 'a');
248
+ expect(event.defaultPrevented).toBe(true);
249
+ });
250
+
251
+ it('blocks scientific notation', () => {
252
+ input.value = '1';
253
+ input.setSelectionRange(1, 1);
254
+ const event = dispatchBeforeInput(input, 'e');
255
+ expect(event.defaultPrevented).toBe(true);
256
+ });
257
+
258
+ it('blocks symbols', () => {
259
+ const event = dispatchBeforeInput(input, '!');
260
+ expect(event.defaultPrevented).toBe(true);
261
+ });
262
+
263
+ it('allows digits', () => {
264
+ const event = dispatchBeforeInput(input, '1');
265
+ expect(event.defaultPrevented).toBe(false);
266
+ });
267
+
268
+ it('allows decimal separator', () => {
269
+ const event = dispatchBeforeInput(input, '.');
270
+ expect(event.defaultPrevented).toBe(false);
271
+ });
272
+
273
+ it('allows comma when locale uses it', () => {
274
+ const event = dispatchBeforeInput(input, ',');
275
+ expect(event.defaultPrevented).toBe(false);
276
+ });
277
+
278
+ it('allows leading sign', () => {
279
+ const event = dispatchBeforeInput(input, '-');
280
+ expect(event.defaultPrevented).toBe(false);
281
+ });
282
+
283
+ it('allows deletions (null data)', () => {
284
+ input.value = '12';
285
+ input.setSelectionRange(2, 2);
286
+ const event = dispatchBeforeInput(input, null, 'deleteContentBackward');
287
+ expect(event.defaultPrevented).toBe(false);
288
+ });
289
+
290
+ it('blocks paste containing letters', () => {
291
+ const event = new InputEvent('beforeinput', {
292
+ data: null,
293
+ inputType: 'insertFromPaste',
294
+ cancelable: true,
295
+ bubbles: true,
296
+ });
297
+ const dt = new DataTransfer();
298
+ dt.setData('text/plain', '12abc34');
299
+ Object.defineProperty(event, 'dataTransfer', {value: dt});
300
+ input.dispatchEvent(event);
301
+ expect(event.defaultPrevented).toBe(true);
302
+ });
303
+
304
+ it('allows paste of numeric string with separators', () => {
305
+ const event = new InputEvent('beforeinput', {
306
+ data: null,
307
+ inputType: 'insertFromPaste',
308
+ cancelable: true,
309
+ bubbles: true,
310
+ });
311
+ const dt = new DataTransfer();
312
+ dt.setData('text/plain', '1,234.5');
313
+ Object.defineProperty(event, 'dataTransfer', {value: dt});
314
+ input.dispatchEvent(event);
315
+ expect(event.defaultPrevented).toBe(false);
316
+ });
317
+
318
+ it('honours custom decimalSeparator', async () => {
319
+ el.decimalSeparator = ',';
320
+ el.groupSeparator = ' ';
321
+ await el.updateComplete;
322
+
323
+ const dot = dispatchBeforeInput(input, '.');
324
+ expect(dot.defaultPrevented).toBe(true);
325
+
326
+ const comma = dispatchBeforeInput(input, ',');
327
+ expect(comma.defaultPrevented).toBe(false);
328
+ });
329
+
330
+ it('honours custom validationPattern', async () => {
331
+ el.validationPattern = '^[0-9]*$';
332
+ await el.updateComplete;
333
+
334
+ const minus = dispatchBeforeInput(input, '-');
335
+ expect(minus.defaultPrevented).toBe(true);
336
+
337
+ const digit = dispatchBeforeInput(input, '1');
338
+ expect(digit.defaultPrevented).toBe(false);
339
+ });
340
+
341
+ it('does not block when disabled', async () => {
342
+ el.disabled = true;
343
+ await el.updateComplete;
344
+ const event = dispatchBeforeInput(input, 'a');
345
+ expect(event.defaultPrevented).toBe(false);
346
+ });
347
+
348
+ it('does not block when readonly', async () => {
349
+ el.readonly = true;
350
+ await el.updateComplete;
351
+ const event = dispatchBeforeInput(input, 'a');
352
+ expect(event.defaultPrevented).toBe(false);
353
+ });
354
+ });
221
355
  });
@@ -13,6 +13,7 @@ import {classMap} from 'lit/directives/class-map.js';
13
13
  import {customElement} from '../../decorator.js';
14
14
  import {
15
15
  formatNumberForDisplay,
16
+ isAllowedIntermediateInput,
16
17
  NumberInputFormatOptions,
17
18
  parseNumberInput,
18
19
  removeGroupingFromDisplay,
@@ -113,6 +114,14 @@ export class ObcNumberInputField extends LitElement {
113
114
  @property({type: Number}) minFractionDigits = 0;
114
115
  @property({type: Number}) maxFractionDigits?: number | undefined;
115
116
 
117
+ /**
118
+ * Optional regex pattern. When set, any keystroke or paste whose resulting
119
+ * value does not match this pattern is blocked at the `beforeinput` stage.
120
+ * When unset, a permissive numeric filter (digits, sign, active separators,
121
+ * whitespace) is applied instead.
122
+ */
123
+ @property({type: String}) validationPattern = '';
124
+
116
125
  @state() private hasFocus = false;
117
126
  @state() private displayText = '';
118
127
  @state() private previousValue = NaN;
@@ -150,6 +159,43 @@ export class ObcNumberInputField extends LitElement {
150
159
  this.dispatchInput();
151
160
  }
152
161
 
162
+ private isProjectedValueAllowed(projected: string): boolean {
163
+ if (this.validationPattern) {
164
+ let regex: RegExp;
165
+ try {
166
+ // Anchor the consumer pattern so it must match the whole projected
167
+ // value rather than any substring (e.g. `[0-9]` must not pass `a1`).
168
+ regex = new RegExp(`^(?:${this.validationPattern})$`);
169
+ } catch {
170
+ // Invalid pattern: fall back to the numeric filter instead of
171
+ // allowing arbitrary input.
172
+ return isAllowedIntermediateInput(projected, this.getFormatOptions());
173
+ }
174
+ return regex.test(projected);
175
+ }
176
+ return isAllowedIntermediateInput(projected, this.getFormatOptions());
177
+ }
178
+
179
+ private onBeforeInput(e: InputEvent) {
180
+ if (this.disabled || this.readonly) return;
181
+ const input = e.target as HTMLInputElement;
182
+
183
+ const incoming = e.data ?? e.dataTransfer?.getData('text/plain') ?? null;
184
+ // Deletions, history, and formatting (`insertLineBreak`, `historyUndo`,
185
+ // `deleteContentBackward`, etc.) have `data === null` and no transferable
186
+ // text — always allow them.
187
+ if (incoming === null) return;
188
+
189
+ const start = input.selectionStart ?? input.value.length;
190
+ const end = input.selectionEnd ?? input.value.length;
191
+ const projected =
192
+ input.value.slice(0, start) + incoming + input.value.slice(end);
193
+
194
+ if (!this.isProjectedValueAllowed(projected)) {
195
+ e.preventDefault();
196
+ }
197
+ }
198
+
153
199
  private onFocus() {
154
200
  this.hasFocus = true;
155
201
  const source = this.displayOverride || this.displayText;
@@ -444,6 +490,7 @@ export class ObcNumberInputField extends LitElement {
444
490
  hasHelperOrError ? 'helper-text' : undefined
445
491
  )}
446
492
  autocomplete="off"
493
+ @beforeinput=${this.onBeforeInput}
447
494
  @input=${this.onInput}
448
495
  />
449
496
  ${unitInside
@@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest';
2
2
  import {
3
3
  formatNumberForDisplay,
4
4
  getLocaleNumberSeparators,
5
+ isAllowedIntermediateInput,
5
6
  parseNumberInput,
6
7
  removeGroupingFromDisplay,
7
8
  valuesEqual,
@@ -22,6 +23,17 @@ describe('parseNumberInput', () => {
22
23
  expect(parseNumberInput('10,')).toBe(10);
23
24
  });
24
25
 
26
+ it('parses grouped numbers with mixed separators', () => {
27
+ expect(parseNumberInput('1,234.5')).toBe(1234.5);
28
+ expect(parseNumberInput('1.234,5')).toBe(1234.5);
29
+ expect(parseNumberInput('1 234,5')).toBe(1234.5);
30
+ expect(parseNumberInput("1'234.5")).toBe(1234.5);
31
+ });
32
+
33
+ it('parses grouped integers', () => {
34
+ expect(parseNumberInput('1,234,567')).toBe(1234567);
35
+ });
36
+
25
37
  it('returns NaN for sign-only input', () => {
26
38
  expect(Number.isNaN(parseNumberInput('-'))).toBe(true);
27
39
  expect(Number.isNaN(parseNumberInput('+'))).toBe(true);
@@ -153,3 +165,53 @@ describe('valuesEqual', () => {
153
165
  expect(valuesEqual(1, 2)).toBe(false);
154
166
  });
155
167
  });
168
+
169
+ describe('isAllowedIntermediateInput', () => {
170
+ const enUsOptions = {decimalSeparator: '.', groupSeparator: ','};
171
+
172
+ it('allows empty string', () => {
173
+ expect(isAllowedIntermediateInput('', enUsOptions)).toBe(true);
174
+ });
175
+
176
+ it('allows digits', () => {
177
+ expect(isAllowedIntermediateInput('123', enUsOptions)).toBe(true);
178
+ });
179
+
180
+ it('allows leading sign', () => {
181
+ expect(isAllowedIntermediateInput('-', enUsOptions)).toBe(true);
182
+ expect(isAllowedIntermediateInput('+12.5', enUsOptions)).toBe(true);
183
+ });
184
+
185
+ it('allows trailing decimal separator', () => {
186
+ expect(isAllowedIntermediateInput('10.', enUsOptions)).toBe(true);
187
+ expect(isAllowedIntermediateInput(',5', enUsOptions)).toBe(true);
188
+ });
189
+
190
+ it('allows grouped numbers with default separators', () => {
191
+ expect(isAllowedIntermediateInput('1,234.5', enUsOptions)).toBe(true);
192
+ });
193
+
194
+ it('rejects letters', () => {
195
+ expect(isAllowedIntermediateInput('12a')).toBe(false);
196
+ expect(isAllowedIntermediateInput('e')).toBe(false);
197
+ });
198
+
199
+ it('rejects symbols outside the allowed set', () => {
200
+ expect(isAllowedIntermediateInput('12!')).toBe(false);
201
+ expect(isAllowedIntermediateInput('12$')).toBe(false);
202
+ });
203
+
204
+ it('rejects sign in non-leading position', () => {
205
+ expect(isAllowedIntermediateInput('1-2')).toBe(false);
206
+ });
207
+
208
+ it('honours custom decimal separator', () => {
209
+ const opts = {decimalSeparator: ',', groupSeparator: ' '};
210
+ expect(isAllowedIntermediateInput('1 234,5', opts)).toBe(true);
211
+ expect(isAllowedIntermediateInput('1,234.5', opts)).toBe(false);
212
+ });
213
+
214
+ it('allows whitespace', () => {
215
+ expect(isAllowedIntermediateInput(' 12 ')).toBe(true);
216
+ });
217
+ });
@@ -26,12 +26,69 @@ export function parseNumberInput(display: string): number {
26
26
  return NaN;
27
27
  }
28
28
 
29
- const normalized = trimmed.replace(',', '.');
29
+ // Drop whitespace and apostrophe grouping that may appear in pasted or
30
+ // locale-formatted numbers (e.g. `1 234`, `1'234`).
31
+ let normalized = trimmed.replace(/[\s']/g, '');
32
+
33
+ const commaCount = (normalized.match(/,/g) ?? []).length;
34
+ const dotCount = (normalized.match(/\./g) ?? []).length;
35
+
36
+ if (commaCount > 0 && dotCount > 0) {
37
+ // Both separators present: the right-most one is the decimal separator,
38
+ // the other is a grouping separator.
39
+ const decimalSeparator =
40
+ normalized.lastIndexOf(',') > normalized.lastIndexOf('.') ? ',' : '.';
41
+ const groupSeparator = decimalSeparator === ',' ? '.' : ',';
42
+ normalized = normalized.split(groupSeparator).join('');
43
+ normalized = normalized.replace(decimalSeparator, '.');
44
+ } else if (commaCount > 1) {
45
+ // Multiple commas without a dot can only be grouping separators.
46
+ normalized = normalized.split(',').join('');
47
+ } else if (commaCount === 1) {
48
+ // A single comma is treated as the decimal separator.
49
+ normalized = normalized.replace(',', '.');
50
+ } else if (dotCount > 1) {
51
+ // Multiple dots without a comma can only be grouping separators.
52
+ normalized = normalized.split('.').join('');
53
+ }
30
54
 
31
55
  const n = Number(normalized);
32
56
  return Number.isFinite(n) ? n : NaN;
33
57
  }
34
58
 
59
+ function escapeRegex(value: string): string {
60
+ return value.replace(/[-.*+?^${}()|[\]\\]/g, '\\$&');
61
+ }
62
+
63
+ /**
64
+ * Permissive per-keystroke validator used to reject non-numeric characters
65
+ * before they reach the input. Allows half-typed states like `10.`, `-`, `,5`,
66
+ * `1,234.`; final correctness is enforced by `parseNumberInput` at commit.
67
+ *
68
+ * Accepts: optional leading sign, digits, the active decimal separator,
69
+ * the active group separator, and whitespace.
70
+ */
71
+ export function isAllowedIntermediateInput(
72
+ value: string,
73
+ options: NumberInputFormatOptions = {}
74
+ ): boolean {
75
+ if (value === '') return true;
76
+
77
+ const localeSeparators = getLocaleNumberSeparators();
78
+ const decimalSeparator =
79
+ options.decimalSeparator ?? localeSeparators.decimalSeparator;
80
+ const groupSeparator =
81
+ options.groupSeparator ?? localeSeparators.groupSeparator;
82
+
83
+ const allowedChars = new Set<string>([decimalSeparator]);
84
+ if (groupSeparator) {
85
+ allowedChars.add(groupSeparator);
86
+ }
87
+ const allowedClass = [...allowedChars].map(escapeRegex).join('') + '0-9 \\t';
88
+ const pattern = new RegExp(`^[+\\-]?[${allowedClass}]*$`);
89
+ return pattern.test(value);
90
+ }
91
+
35
92
  function clampFractionDigits(
36
93
  minFractionDigits: number | undefined,
37
94
  maxFractionDigits: number | undefined
@@ -110,7 +167,7 @@ export function removeGroupingFromDisplay(
110
167
  return display;
111
168
  }
112
169
 
113
- const escaped = groupSeparator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
170
+ const escaped = escapeRegex(groupSeparator);
114
171
  return display.replace(new RegExp(escaped, 'g'), '');
115
172
  }
116
173
 
@@ -41,9 +41,3 @@
41
41
  :host([disabled]) .helper-text {
42
42
  color: var(--element-disabled-color);
43
43
  }
44
-
45
- :host([disabled]) .field-wrapper.has-unit-slot .unit-slot {
46
- color: var(--on-normal-disabled-color);
47
- border-color: var(--normal-disabled-border-color);
48
- background: var(--normal-disabled-background-color);
49
- }