@lion/ui 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/components/calendar/src/LionCalendar.js +1 -1
  2. package/components/calendar/test/lion-calendar.test.js +67 -47
  3. package/components/calendar/test-suites/LionCalendar.suite.js +79 -0
  4. package/components/form-core/src/choice-group/ChoiceInputMixin.js +1 -1
  5. package/components/input-amount-dropdown/src/LionInputAmountDropdown.js +6 -4
  6. package/components/input-amount-dropdown/test/LionInputAmountDropdown.test.js +20 -1
  7. package/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js +24 -0
  8. package/components/input-datepicker/test/lion-input-datepicker.test.js +13 -4
  9. package/components/input-datepicker/test-suites/LionInputDatepicker.suite.js +35 -0
  10. package/components/input-email/src/LionInputEmail.js +2 -0
  11. package/components/input-email/test/lion-input-email.test.js +6 -0
  12. package/components/input-file/src/validators.js +31 -1
  13. package/components/input-file/test/FileValidators.test.js +31 -0
  14. package/components/input-file/translations/bg.js +1 -0
  15. package/components/input-file/translations/cs.js +1 -0
  16. package/components/input-file/translations/de.js +2 -0
  17. package/components/input-file/translations/en.js +1 -0
  18. package/components/input-file/translations/es.js +1 -0
  19. package/components/input-file/translations/fr.js +1 -0
  20. package/components/input-file/translations/hu.js +1 -0
  21. package/components/input-file/translations/id.js +1 -0
  22. package/components/input-file/translations/it.js +1 -0
  23. package/components/input-file/translations/nl.js +1 -0
  24. package/components/input-file/translations/pl.js +1 -0
  25. package/components/input-file/translations/ro.js +1 -0
  26. package/components/input-file/translations/ru.js +1 -0
  27. package/components/input-file/translations/sk.js +1 -0
  28. package/components/input-file/translations/uk.js +1 -0
  29. package/components/input-file/translations/zh.js +1 -0
  30. package/components/input-tel/src/LionInputTel.js +1 -1
  31. package/components/input-tel/test-suites/LionInputTel.suite.js +7 -0
  32. package/components/overlays/src/configurations/visibility-trigger-partials/withHoverInteraction.js +132 -26
  33. package/components/overlays/test/withHoverInteraction.test.js +118 -0
  34. package/components/switch/src/LionSwitch.js +1 -1
  35. package/components/switch/test/lion-switch.test.js +7 -0
  36. package/custom-elements.json +403 -62
  37. package/dist-types/components/calendar/test/lion-calendar.test.d.ts.map +1 -1
  38. package/dist-types/components/calendar/test-suites/LionCalendar.suite.d.ts +5 -0
  39. package/dist-types/components/calendar/test-suites/LionCalendar.suite.d.ts.map +1 -0
  40. package/dist-types/components/input-amount-dropdown/src/LionInputAmountDropdown.d.ts +0 -9
  41. package/dist-types/components/input-amount-dropdown/src/LionInputAmountDropdown.d.ts.map +1 -1
  42. package/dist-types/components/input-amount-dropdown/test/LionInputAmountDropdown.test.d.ts.map +1 -1
  43. package/dist-types/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.d.ts.map +1 -1
  44. package/dist-types/components/input-datepicker/test/lion-input-datepicker.test.d.ts.map +1 -1
  45. package/dist-types/components/input-datepicker/test-suites/LionInputDatepicker.suite.d.ts +7 -0
  46. package/dist-types/components/input-datepicker/test-suites/LionInputDatepicker.suite.d.ts.map +1 -0
  47. package/dist-types/components/input-email/src/LionInputEmail.d.ts.map +1 -1
  48. package/dist-types/components/input-file/src/validators.d.ts +11 -0
  49. package/dist-types/components/input-file/src/validators.d.ts.map +1 -1
  50. package/dist-types/components/input-file/translations/bg-BG.d.ts +1 -0
  51. package/dist-types/components/input-file/translations/bg.d.ts +1 -0
  52. package/dist-types/components/input-file/translations/cs-CZ.d.ts +1 -0
  53. package/dist-types/components/input-file/translations/cs.d.ts +1 -0
  54. package/dist-types/components/input-file/translations/de-DE.d.ts +1 -0
  55. package/dist-types/components/input-file/translations/de.d.ts +1 -0
  56. package/dist-types/components/input-file/translations/en-AU.d.ts +1 -0
  57. package/dist-types/components/input-file/translations/en-GB.d.ts +1 -0
  58. package/dist-types/components/input-file/translations/en-PH.d.ts +1 -0
  59. package/dist-types/components/input-file/translations/en-US.d.ts +1 -0
  60. package/dist-types/components/input-file/translations/en.d.ts +1 -0
  61. package/dist-types/components/input-file/translations/es-ES.d.ts +1 -0
  62. package/dist-types/components/input-file/translations/es.d.ts +1 -0
  63. package/dist-types/components/input-file/translations/fr-BE.d.ts +1 -0
  64. package/dist-types/components/input-file/translations/fr-FR.d.ts +1 -0
  65. package/dist-types/components/input-file/translations/fr.d.ts +1 -0
  66. package/dist-types/components/input-file/translations/hu-HU.d.ts +1 -0
  67. package/dist-types/components/input-file/translations/hu.d.ts +1 -0
  68. package/dist-types/components/input-file/translations/id-ID.d.ts +1 -0
  69. package/dist-types/components/input-file/translations/id.d.ts +1 -0
  70. package/dist-types/components/input-file/translations/it-IT.d.ts +1 -0
  71. package/dist-types/components/input-file/translations/it.d.ts +1 -0
  72. package/dist-types/components/input-file/translations/nl-BE.d.ts +1 -0
  73. package/dist-types/components/input-file/translations/nl-NL.d.ts +1 -0
  74. package/dist-types/components/input-file/translations/nl.d.ts +1 -0
  75. package/dist-types/components/input-file/translations/pl-PL.d.ts +1 -0
  76. package/dist-types/components/input-file/translations/pl.d.ts +1 -0
  77. package/dist-types/components/input-file/translations/ro-RO.d.ts +1 -0
  78. package/dist-types/components/input-file/translations/ro.d.ts +1 -0
  79. package/dist-types/components/input-file/translations/ru-RU.d.ts +1 -0
  80. package/dist-types/components/input-file/translations/ru.d.ts +1 -0
  81. package/dist-types/components/input-file/translations/sk-SK.d.ts +1 -0
  82. package/dist-types/components/input-file/translations/sk.d.ts +1 -0
  83. package/dist-types/components/input-file/translations/uk-UA.d.ts +1 -0
  84. package/dist-types/components/input-file/translations/uk.d.ts +1 -0
  85. package/dist-types/components/input-file/translations/zh-CN.d.ts +1 -0
  86. package/dist-types/components/input-file/translations/zh.d.ts +1 -0
  87. package/dist-types/components/input-tel/src/LionInputTel.d.ts.map +1 -1
  88. package/dist-types/components/input-tel/test-suites/LionInputTel.suite.d.ts.map +1 -1
  89. package/dist-types/components/overlays/src/configurations/visibility-trigger-partials/withHoverInteraction.d.ts +8 -2
  90. package/dist-types/components/overlays/src/configurations/visibility-trigger-partials/withHoverInteraction.d.ts.map +1 -1
  91. package/dist-types/components/overlays/test/withHoverInteraction.test.d.ts +2 -0
  92. package/dist-types/components/overlays/test/withHoverInteraction.test.d.ts.map +1 -0
  93. package/dist-types/exports/input-file.d.ts +1 -1
  94. package/dist-types/tsconfig.tsbuildinfo +1 -1
  95. package/docs/components/checkbox-group/overview.md +2 -2
  96. package/docs/components/collapsible/overview.md +1 -1
  97. package/docs/components/combobox/overview.md +1 -1
  98. package/docs/components/combobox/use-cases.md +1 -1
  99. package/docs/components/dialog/overview.md +1 -1
  100. package/docs/components/fieldset/overview.md +5 -5
  101. package/docs/components/form/overview.md +3 -3
  102. package/docs/components/form/use-cases.md +2 -2
  103. package/docs/components/icon/use-cases.md +1 -1
  104. package/docs/components/input/overview.md +1 -1
  105. package/docs/components/input-amount/overview.md +3 -3
  106. package/docs/components/input-amount-dropdown/api-table.md +1 -1
  107. package/docs/components/input-date/overview.md +3 -3
  108. package/docs/components/input-datepicker/overview.md +5 -5
  109. package/docs/components/input-email/api-table.md +1 -1
  110. package/docs/components/input-email/overview.md +2 -2
  111. package/docs/components/input-file/overview.md +1 -1
  112. package/docs/components/input-file/use-cases.md +18 -0
  113. package/docs/components/input-iban/overview.md +2 -2
  114. package/docs/components/input-range/overview.md +2 -2
  115. package/docs/components/input-stepper/overview.md +1 -1
  116. package/docs/components/input-tel/api-table.md +1 -1
  117. package/docs/components/input-tel/overview.md +1 -1
  118. package/docs/components/input-tel/use-cases.md +6 -6
  119. package/docs/components/input-tel-dropdown/api-table.md +1 -1
  120. package/docs/components/input-tel-dropdown/overview.md +1 -1
  121. package/docs/components/listbox/use-cases.md +1 -1
  122. package/docs/components/radio-group/overview.md +3 -3
  123. package/docs/components/select/use-cases.md +1 -1
  124. package/docs/components/select-rich/use-cases.md +2 -2
  125. package/docs/components/tabs/examples.md +1 -1
  126. package/docs/components/tooltip/use-cases.md +55 -0
  127. package/docs/fundamentals/index.md +4 -4
  128. package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
  129. package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
  130. package/docs/fundamentals/rationales/accessibility.md +1 -1
  131. package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
  132. package/docs/fundamentals/systems/core/overview.md +1 -1
  133. package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
  134. package/docs/fundamentals/systems/form/model-value.md +1 -1
  135. package/docs/fundamentals/systems/form/overview.md +28 -28
  136. package/docs/fundamentals/systems/form/validate.md +2 -2
  137. package/docs/fundamentals/systems/localize/overview.md +4 -4
  138. package/docs/fundamentals/systems/overlays/configuration.md +2 -2
  139. package/docs/fundamentals/systems/overlays/overview.md +5 -5
  140. package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
  141. package/docs/fundamentals/tools/helpers/overview.md +1 -1
  142. package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
  143. package/docs/guides/how-to/create-a-custom-field.md +5 -5
  144. package/docs/guides/how-to/create-a-form.md +4 -4
  145. package/docs/guides/how-to/extend-a-native-input.md +2 -2
  146. package/docs/guides/how-to/index.md +1 -1
  147. package/docs/guides/index.md +8 -8
  148. package/docs/guides/principles/index.md +5 -5
  149. package/docs/guides/principles/styling.md +1 -1
  150. package/exports/input-file.js +5 -1
  151. package/input-file.js +5 -1
  152. package/package.json +2 -2
@@ -18,4 +18,5 @@ export default {
18
18
  selectTextDuplicateFileName: 'Un fichier portant le même nom de fichier était déjà présent.',
19
19
  selectTextMultipleFile: 'Sélectionnez des fichiers',
20
20
  selectTextSingleFile: 'Sélectionnez un fichier',
21
+ maxFileCountExceeded: 'Le nombre de fichiers téléchargés ne peut pas dépasser {maxFileCount}.',
21
22
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Már volt ilyen nevű fájl.',
18
18
  selectTextMultipleFile: 'Fájl(ok) kiválasztása',
19
19
  selectTextSingleFile: 'Fájl kiválasztása',
20
+ maxFileCountExceeded: 'A feltöltött fájlok száma nem haladhatja meg a {maxFileCount}-ot.',
20
21
  };
@@ -16,4 +16,5 @@ export default {
16
16
  selectTextDuplicateFileName: 'File dengan nama file yang sama sudah ada.',
17
17
  selectTextMultipleFile: 'Pilih beberapa file',
18
18
  selectTextSingleFile: 'Pilih berkas',
19
+ maxFileCountExceeded: 'Jumlah file yang diunggah tidak boleh melebihi {maxFileCount}.',
19
20
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Un file con lo stesso nome file era già presente.',
18
18
  selectTextMultipleFile: 'Seleziona file',
19
19
  selectTextSingleFile: 'Seleziona file',
20
+ maxFileCountExceeded: 'Il numero di file caricati non può superare {maxFileCount}.',
20
21
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Er bestaat al een bestand met dezelfde bestandsnaam.',
18
18
  selectTextMultipleFile: 'Selecteer bestanden',
19
19
  selectTextSingleFile: 'Selecteer bestand',
20
+ maxFileCountExceeded: 'Het aantal geüploade bestanden mag niet meer dan {maxFileCount} zijn.',
20
21
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Plik o tej samej nazwie już istnieje.',
18
18
  selectTextMultipleFile: 'Wybierz pliki',
19
19
  selectTextSingleFile: 'Wybierz plik',
20
+ maxFileCountExceeded: 'Liczba przesłanych plików nie może przekraczać {maxFileCount}.',
20
21
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Există deja un fişier cu acelaşi nume de fişier.',
18
18
  selectTextMultipleFile: 'Selectare fișiere',
19
19
  selectTextSingleFile: 'Selectare fișier',
20
+ maxFileCountExceeded: 'Numărul de fișiere încărcate nu poate depăși {maxFileCount}.',
20
21
  };
@@ -16,4 +16,5 @@ export default {
16
16
  selectTextDuplicateFileName: 'Файл с таким названием уже существует.',
17
17
  selectTextMultipleFile: 'Выберите файлы',
18
18
  selectTextSingleFile: 'Выберите файл',
19
+ maxFileCountExceeded: 'Количество загруженных файлов не может превышать {maxFileCount}.',
19
20
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Súbor s rovnakým názvom súboru už existoval.',
18
18
  selectTextMultipleFile: 'Vybrať súbory',
19
19
  selectTextSingleFile: 'Vybrať súbor',
20
+ maxFileCountExceeded: 'Počet nahratých súborov nesmie prekročiť {maxFileCount}.',
20
21
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: 'Файл із такою назвою вже існував.',
18
18
  selectTextMultipleFile: 'Виберіть файли',
19
19
  selectTextSingleFile: 'Виберіть файл',
20
+ maxFileCountExceeded: 'Кількість завантажених файлів не може перевищувати {maxFileCount}.',
20
21
  };
@@ -17,4 +17,5 @@ export default {
17
17
  selectTextDuplicateFileName: '已存在具有相同文件名的文件。',
18
18
  selectTextMultipleFile: '选择多个文件',
19
19
  selectTextSingleFile: '选择文件',
20
+ maxFileCountExceeded: '上传的文件数量不能超过 {maxFileCount} 个。',
20
21
  };
@@ -137,6 +137,7 @@ export class LionInputTel extends LocalizeMixin(LionInput) {
137
137
  */
138
138
  constructor() {
139
139
  super();
140
+ this.autocomplete = 'tel';
140
141
 
141
142
  /**
142
143
  * Determines what the formatter output should look like.
@@ -192,7 +193,6 @@ export class LionInputTel extends LocalizeMixin(LionInput) {
192
193
  */
193
194
  firstUpdated(changedProperties) {
194
195
  super.firstUpdated(changedProperties);
195
-
196
196
  // This will trigger the right keyboard on mobile
197
197
  this._inputNode.inputMode = 'tel';
198
198
  }
@@ -222,6 +222,13 @@ export function runInputTelSuite({ klass = LionInputTel } = {}) {
222
222
  expect(el._inputNode.inputMode).to.equal('tel');
223
223
  });
224
224
 
225
+ it('sets autocomplete to "tel"', async () => {
226
+ const el = await fixture(html` <${tag}></${tag}> `);
227
+ await el.updateComplete;
228
+ // @ts-expect-error [allow-protected] inside tests
229
+ expect(el._inputNode.getAttribute('autocomplete')).to.equal('tel');
230
+ });
231
+
225
232
  it('formats according to locale', async () => {
226
233
  const el = await fixture(
227
234
  html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
@@ -5,14 +5,27 @@
5
5
 
6
6
  // N.B. Below logic is tested in LionTooltip
7
7
 
8
+ // TODO: this is copied from menu system. Move them to common ancestor entrypoint (maybe a core entrypoint for all interactive controls)
9
+ /**
10
+ * @param {HTMLElement|undefined} item
11
+ */
12
+ export function isDisabled(item) {
13
+ return item && (item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true');
14
+ }
15
+
8
16
  /**
9
17
  * Use for tooltips and [flyout menus](https://www.w3.org/WAI/tutorials/menus/flyout/).
10
18
  * Note that it handles both mouse hover and focus interaction.
11
19
  * Provide delayIn and delayOut when the content needs a small delay before being showed
12
- * @param {{ delayIn?: number, delayOut?: number }} options
20
+ * @param {{ isHoverSupported?: boolean, longpressDuration?: number, delayIn?: number, delayOut?: number }} options
13
21
  * @returns {Partial<OverlayConfig>}
14
22
  */
15
- export function withHoverInteraction({ delayIn = 0, delayOut = 300 }) {
23
+ export function withHoverInteraction({
24
+ isHoverSupported = window.matchMedia('(hover: hover)').matches,
25
+ longpressDuration = 500,
26
+ delayOut = 300,
27
+ delayIn = 0,
28
+ } = {}) {
16
29
  return {
17
30
  visibilityTriggerFunction: (
18
31
  /** @type {{ controller: OverlayController }} */ { controller },
@@ -20,53 +33,146 @@ export function withHoverInteraction({ delayIn = 0, delayOut = 300 }) {
20
33
  let isFocused = false;
21
34
  let isHovered = false;
22
35
  /** @type {NodeJS.Timeout} */
23
- let delayTimeout;
36
+ let pendingDelayTimeout;
37
+ /** @type {NodeJS.Timeout} */
38
+ let pendingLongpressTimeout;
39
+ let longpressCompleted = false;
40
+ // A tap fires pointerdown → pointerup → focusin because the browser moves keyboard focus to
41
+ // the tapped element. We only want focusin to open the tooltip when triggered by keyboard (Tab),
42
+ // not by tap. isLastPointerTouch marks that the preceding pointer event was a touch so the
43
+ // focusin handler can skip it.
44
+ let isLastPointerTouch = false;
45
+ // A tap fires the full mouse compatibility sequence: pointerdown → pointerup → mousedown → mouseup → click.
46
+ // Browsers generate these compatibility events automatically so touch works with mouse-only code.
47
+ // After a longpress the same click fires, which would trigger any click handler on the invoker
48
+ // e.g. opening a dialog alongside the tooltip. We register a capture-phase listener at longpress
49
+ // completion to intercept it, as capture runs before bubble so we stop it regardless of registration order.
50
+ /** @type {((e: Event) => void) | null} */
51
+ let pendingClickSuppressor = null;
24
52
 
25
53
  function resetActive() {
26
54
  isFocused = false;
27
55
  isHovered = false;
56
+ longpressCompleted = false;
57
+ if (pendingClickSuppressor) {
58
+ controller.invokerNode?.removeEventListener('click', pendingClickSuppressor, {
59
+ capture: true,
60
+ });
61
+ pendingClickSuppressor = null;
62
+ }
28
63
  }
29
64
 
30
65
  /**
31
- * @param {Event} event
66
+ * @param {{ shouldOpen: boolean, openTimeout?: number, closeTimeout?: number }} cfg
32
67
  */
33
- function handleOpenClosed(event) {
34
- const { type } = event;
68
+ function openClose({ shouldOpen, openTimeout = 0, closeTimeout = 0 }) {
69
+ clearTimeout(pendingDelayTimeout);
70
+ if (shouldOpen && !isDisabled(controller.invokerNode)) {
71
+ pendingDelayTimeout = setTimeout(() => controller.show(), openTimeout);
72
+ } else {
73
+ pendingDelayTimeout = setTimeout(() => controller.hide(), closeTimeout);
74
+ }
75
+ }
35
76
 
36
- clearTimeout(delayTimeout);
77
+ /**
78
+ * @param {Event|PointerEvent} event
79
+ */
80
+ function handleHoverAndFocus(event) {
81
+ const { type } = event;
37
82
  isFocused = type === 'focusout' ? false : isFocused || type === 'focusin';
38
83
  isHovered = type === 'mouseleave' ? false : isHovered || type === 'mouseenter';
84
+ if (!isHoverSupported && type === 'focusin') {
85
+ if (isLastPointerTouch) {
86
+ isLastPointerTouch = false;
87
+ return;
88
+ }
89
+ if (!controller.invokerNode?.matches(':focus-visible')) return;
90
+ }
39
91
  const shouldOpen = isFocused || isHovered;
92
+ openClose({ shouldOpen, openTimeout: delayIn, closeTimeout: delayOut });
93
+ }
40
94
 
41
- if (shouldOpen && !controller._hasDisabledInvoker()) {
42
- delayTimeout = setTimeout(() => {
43
- controller.show();
44
- }, delayIn);
95
+ /** @param {Event} e */
96
+ function preventContextMenu(e) {
97
+ e.preventDefault();
98
+ }
99
+
100
+ /** @param {PointerEvent} event */
101
+ function handleLongpress(event) {
102
+ clearTimeout(pendingLongpressTimeout);
103
+ if (event.pointerType !== 'touch') return;
104
+ const { type } = event;
105
+
106
+ if (type === 'pointerdown') {
107
+ longpressCompleted = false;
108
+ isLastPointerTouch = true;
109
+ pendingLongpressTimeout = setTimeout(() => {
110
+ longpressCompleted = true;
111
+ // Intercept the compatibility click the browser generates at the end of the touch sequence.
112
+ // Capture phase ensures we run before any bubble-phase handler (e.g. a dialog opener).
113
+ pendingClickSuppressor = e => e.stopImmediatePropagation();
114
+ controller.invokerNode?.addEventListener('click', pendingClickSuppressor, {
115
+ once: true, // auto-removes after first click
116
+ capture: true,
117
+ });
118
+ openClose({ shouldOpen: true });
119
+ }, longpressDuration);
45
120
  } else {
46
- delayTimeout = setTimeout(() => {
47
- controller.hide();
48
- }, delayOut);
121
+ openClose({
122
+ shouldOpen: false,
123
+ closeTimeout: longpressCompleted ? longpressDuration : 0,
124
+ });
49
125
  }
50
126
  }
51
127
 
52
128
  return {
53
129
  init: () => {
54
130
  controller.addEventListener('hide', resetActive);
55
- controller.contentNode?.addEventListener('mouseenter', handleOpenClosed);
56
- controller.contentNode?.addEventListener('mouseleave', handleOpenClosed);
57
- controller.invokerNode?.addEventListener('mouseenter', handleOpenClosed);
58
- controller.invokerNode?.addEventListener('mouseleave', handleOpenClosed);
59
- controller.invokerNode?.addEventListener('focusin', handleOpenClosed);
60
- controller.invokerNode?.addEventListener('focusout', handleOpenClosed);
131
+
132
+ controller.invokerNode?.addEventListener('focusin', handleHoverAndFocus);
133
+ controller.invokerNode?.addEventListener('focusout', handleHoverAndFocus);
134
+
135
+ if (isHoverSupported) {
136
+ controller.contentNode?.addEventListener('mouseenter', handleHoverAndFocus);
137
+ controller.contentNode?.addEventListener('mouseleave', handleHoverAndFocus);
138
+ controller.invokerNode?.addEventListener('mouseenter', handleHoverAndFocus);
139
+ controller.invokerNode?.addEventListener('mouseleave', handleHoverAndFocus);
140
+ } else {
141
+ controller.invokerNode?.style.setProperty('-webkit-touch-callout', 'none');
142
+ controller.invokerNode?.style.setProperty('user-select', 'none');
143
+ controller.invokerNode?.style.setProperty('-webkit-user-select', 'none');
144
+ controller.invokerNode?.addEventListener('contextmenu', preventContextMenu);
145
+ controller.invokerNode?.addEventListener('pointerdown', handleLongpress);
146
+ controller.invokerNode?.addEventListener('pointerup', handleLongpress);
147
+ controller.invokerNode?.addEventListener('pointerleave', handleLongpress);
148
+ }
61
149
  },
62
150
  teardown: () => {
63
151
  controller.removeEventListener('hide', resetActive);
64
- controller.contentNode?.removeEventListener('mouseenter', handleOpenClosed);
65
- controller.contentNode?.removeEventListener('mouseleave', handleOpenClosed);
66
- controller.invokerNode?.removeEventListener('mouseenter', handleOpenClosed);
67
- controller.invokerNode?.removeEventListener('mouseleave', handleOpenClosed);
68
- controller.invokerNode?.removeEventListener('focusin', handleOpenClosed);
69
- controller.invokerNode?.removeEventListener('focusout', handleOpenClosed);
152
+
153
+ controller.invokerNode?.removeEventListener('focusin', handleHoverAndFocus);
154
+ controller.invokerNode?.removeEventListener('focusout', handleHoverAndFocus);
155
+
156
+ if (isHoverSupported) {
157
+ controller.contentNode?.removeEventListener('mouseenter', handleHoverAndFocus);
158
+ controller.contentNode?.removeEventListener('mouseleave', handleHoverAndFocus);
159
+ controller.invokerNode?.removeEventListener('mouseenter', handleHoverAndFocus);
160
+ controller.invokerNode?.removeEventListener('mouseleave', handleHoverAndFocus);
161
+ } else {
162
+ controller.invokerNode?.style.removeProperty('-webkit-touch-callout');
163
+ controller.invokerNode?.style.removeProperty('user-select');
164
+ controller.invokerNode?.style.removeProperty('-webkit-user-select');
165
+ controller.invokerNode?.removeEventListener('contextmenu', preventContextMenu);
166
+ controller.invokerNode?.removeEventListener('pointerdown', handleLongpress);
167
+ controller.invokerNode?.removeEventListener('pointerup', handleLongpress);
168
+ controller.invokerNode?.removeEventListener('pointerleave', handleLongpress);
169
+ if (pendingClickSuppressor) {
170
+ controller.invokerNode?.removeEventListener('click', pendingClickSuppressor, {
171
+ capture: true,
172
+ });
173
+ pendingClickSuppressor = null;
174
+ }
175
+ }
70
176
  },
71
177
  };
72
178
  },
@@ -0,0 +1,118 @@
1
+ import { OverlayController } from '@lion/ui/overlays.js';
2
+ import { expect, fixtureSync, html } from '@open-wc/testing';
3
+ import sinon from 'sinon';
4
+ import { withHoverInteraction } from '../src/configurations/visibility-trigger-partials/withHoverInteraction.js';
5
+
6
+ function makeCtrl(opts = {}) {
7
+ return new OverlayController({
8
+ placementMode: 'local',
9
+ contentNode: /** @type {HTMLElement} */ (fixtureSync(html`<div>content</div>`)),
10
+ invokerNode: /** @type {HTMLElement} */ (fixtureSync(html`<button>invoker</button>`)),
11
+ ...withHoverInteraction(opts),
12
+ });
13
+ }
14
+
15
+ describe('withHoverInteraction (isHoverSupported: false)', () => {
16
+ /** @type {sinon.SinonFakeTimers} */
17
+ let clock;
18
+ /** @type {OverlayController} */
19
+ let ctrl;
20
+
21
+ beforeEach(() => {
22
+ clock = sinon.useFakeTimers();
23
+ ctrl = makeCtrl({ isHoverSupported: false });
24
+ });
25
+
26
+ afterEach(() => {
27
+ ctrl.teardown();
28
+ clock.restore();
29
+ });
30
+
31
+ it('opens after holding for longpressDuration (500ms)', async () => {
32
+ ctrl.invokerNode?.dispatchEvent(
33
+ new PointerEvent('pointerdown', { pointerType: 'touch', bubbles: true }),
34
+ );
35
+ clock.runAll();
36
+ await Promise.resolve();
37
+ expect(ctrl.isShown).to.equal(true);
38
+ });
39
+
40
+ it('stays hidden on brief touch: pointerup cancels the timer', () => {
41
+ ctrl.invokerNode?.dispatchEvent(
42
+ new PointerEvent('pointerdown', { pointerType: 'touch', bubbles: true }),
43
+ );
44
+ ctrl.invokerNode?.dispatchEvent(
45
+ new PointerEvent('pointerup', { pointerType: 'touch', bubbles: true }),
46
+ );
47
+ clock.tick(500);
48
+ expect(ctrl.isShown).to.equal(false);
49
+ });
50
+
51
+ it('ignores non-touch pointer events (mouse)', () => {
52
+ ctrl.invokerNode?.dispatchEvent(
53
+ new PointerEvent('pointerdown', { pointerType: 'mouse', bubbles: true }),
54
+ );
55
+ clock.tick(500);
56
+ expect(ctrl.isShown).to.equal(false);
57
+ });
58
+
59
+ it('auto-closes after longpressDuration once press ends', async () => {
60
+ ctrl.invokerNode?.dispatchEvent(
61
+ new PointerEvent('pointerdown', { pointerType: 'touch', bubbles: true }),
62
+ );
63
+ clock.runAll();
64
+ await Promise.resolve();
65
+ expect(ctrl.isShown).to.equal(true);
66
+
67
+ ctrl.invokerNode?.dispatchEvent(
68
+ new PointerEvent('pointerup', { pointerType: 'touch', bubbles: true }),
69
+ );
70
+ await clock.tickAsync(501);
71
+ expect(ctrl.isShown).to.equal(false);
72
+ });
73
+
74
+ it('disables text selection on the invoker on init and restores it on teardown', () => {
75
+ const { invokerNode } = ctrl;
76
+ const getUserSelect = () =>
77
+ invokerNode?.style.getPropertyValue('user-select') ||
78
+ invokerNode?.style.getPropertyValue('-webkit-user-select');
79
+ expect(getUserSelect()).to.equal('none');
80
+
81
+ ctrl.teardown();
82
+ expect(getUserSelect()).to.equal('');
83
+ });
84
+
85
+ it('does not open on tap-triggered focusin', () => {
86
+ ctrl.invokerNode?.dispatchEvent(new Event('focusin', { bubbles: true }));
87
+ clock.tick(300);
88
+ expect(ctrl.isShown).to.equal(false);
89
+ });
90
+
91
+ it('suppresses focusin that immediately follows a touch pointerdown', () => {
92
+ ctrl.invokerNode?.dispatchEvent(
93
+ new PointerEvent('pointerdown', { pointerType: 'touch', bubbles: true }),
94
+ );
95
+ ctrl.invokerNode?.dispatchEvent(
96
+ new PointerEvent('pointerup', { pointerType: 'touch', bubbles: true }),
97
+ );
98
+ ctrl.invokerNode?.dispatchEvent(new Event('focusin', { bubbles: true }));
99
+ clock.tick(1); // fires any 0ms open timer if focusin was not suppressed
100
+ expect(ctrl.isShown).to.equal(false);
101
+ });
102
+
103
+ it('suppresses the click event that fires after a completed longpress', async () => {
104
+ ctrl.invokerNode?.dispatchEvent(
105
+ new PointerEvent('pointerdown', { pointerType: 'touch', bubbles: true }),
106
+ );
107
+ clock.runAll();
108
+ await Promise.resolve();
109
+ expect(ctrl.isShown).to.equal(true);
110
+
111
+ let clickFired = false;
112
+ ctrl.invokerNode?.addEventListener('click', () => {
113
+ clickFired = true;
114
+ });
115
+ ctrl.invokerNode?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
116
+ expect(clickFired).to.equal(false);
117
+ });
118
+ });
@@ -148,7 +148,7 @@ export class LionSwitch extends ScopedElementsMixin(ChoiceInputMixin(LionField))
148
148
  * @protected
149
149
  */
150
150
  _onLabelClick() {
151
- if (this.disabled) {
151
+ if (this.disabled || this.readOnly) {
152
152
  return;
153
153
  }
154
154
  this._inputNode.focus();
@@ -72,6 +72,13 @@ describe('lion-switch', () => {
72
72
  expect(isActiveElement(_inputNode)).to.be.false;
73
73
  });
74
74
 
75
+ it('clicking the label should not toggle the checked state when readonly', async () => {
76
+ const el = await fixture(html`<lion-switch readonly label="Enable Setting"></lion-switch>`);
77
+ const { _labelNode } = getFormControlMembers(el);
78
+ _labelNode.click();
79
+ expect(el.checked).to.be.false;
80
+ });
81
+
75
82
  it('should sync its "disabled" state to child button', async () => {
76
83
  const el = await fixture(html`<lion-switch disabled></lion-switch>`);
77
84
  const { _inputNode } = getSwitchMembers(el);