@nexxtmove/ui 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/dist/components/editor/molecules/EditorBorder/EditorBorder.vue.d.ts +12 -0
  2. package/dist/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue.d.ts +16 -0
  3. package/dist/index.js +1094 -1135
  4. package/dist/nuxt.js +0 -1
  5. package/package.json +38 -36
  6. package/src/attribute-contract.test.ts +151 -0
  7. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  8. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  9. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  10. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  11. package/src/components/atoms/Button/Button.stories.ts +290 -0
  12. package/src/components/atoms/Button/Button.test.ts +406 -0
  13. package/src/components/atoms/Card/Card.stories.ts +45 -0
  14. package/src/components/atoms/Card/Card.test.ts +46 -0
  15. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  16. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  17. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  18. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  19. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  20. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  21. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  22. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  23. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  24. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  25. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  26. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  27. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  28. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  29. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  30. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  31. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  32. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  33. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  34. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  35. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  36. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  37. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  38. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  39. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  40. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  41. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  42. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  43. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  44. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  45. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  46. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  47. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  48. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  49. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  50. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  51. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  52. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  53. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  54. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  55. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  56. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  57. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  58. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  59. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  60. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  61. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  62. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  63. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  64. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  65. package/src/components/editor/molecules/EditorBorder/EditorBorder.vue +58 -2
  66. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
  67. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
  68. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
  69. package/src/components/editor/molecules/EditorColor/_ColorField.vue +7 -29
  70. package/src/components/editor/molecules/EditorColor/_ThemeRoleSwatches.vue +57 -0
  71. package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
  72. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
  73. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
  74. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
  75. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
  76. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
  77. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
  78. package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
  79. package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
  80. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
  81. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
  82. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
  83. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
  84. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
  85. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
  86. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
  87. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
  88. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
  89. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
  90. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
  91. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
  92. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
  93. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
  94. package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
  95. package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
  96. package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
  97. package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
  98. package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
  99. package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
  100. package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
  101. package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
  102. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
  103. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
  104. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
  105. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
  106. package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
  107. package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
  108. package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
  109. package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
  110. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
  111. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
  112. package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
  113. package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
  114. package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
  115. package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
  116. package/src/components/molecules/InputField/InputField.stories.ts +117 -0
  117. package/src/components/molecules/InputField/InputField.test.ts +138 -0
  118. package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
  119. package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
  120. package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
  121. package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
  122. package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
  123. package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
  124. package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
  125. package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
  126. package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
  127. package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
  128. package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
  129. package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
  130. package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
  131. package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
  132. package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
  133. package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
  134. package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
  135. package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
  136. package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
  137. package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
  138. package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
  139. package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
  140. package/src/components/molecules/Select/Select.stories.ts +264 -0
  141. package/src/components/molecules/Select/Select.test.ts +315 -0
  142. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
  143. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
  144. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
  145. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
  146. package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
  147. package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
  148. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  149. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
  150. package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
  151. package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
  152. package/src/components/molecules/Source/Source.stories.ts +35 -0
  153. package/src/components/molecules/Source/Source.test.ts +45 -0
  154. package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
  155. package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
  156. package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
  157. package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
  158. package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
  159. package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
  160. package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
  161. package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
  162. package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
  163. package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
  164. package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
  165. package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
  166. package/src/components/molecules/Toast/Toast.stories.ts +110 -0
  167. package/src/components/molecules/Toast/Toast.test.ts +103 -0
  168. package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
  169. package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
  170. package/src/components/molecules/Tree/Tree.stories.ts +884 -0
  171. package/src/components/molecules/Tree/Tree.test.ts +1247 -0
  172. package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
  173. package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
  174. package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
  175. package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
  176. package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
  177. package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
  178. package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
  179. package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
  180. package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
  181. package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
  182. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  183. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  184. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  185. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  186. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  187. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  188. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  189. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  190. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  191. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  192. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  193. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  194. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  195. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  196. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  197. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  198. package/src/components/organisms/Header/Header.stories.ts +75 -0
  199. package/src/components/organisms/Header/Header.test.ts +233 -0
  200. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  201. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  202. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  203. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  204. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  205. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  206. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  207. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  208. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  209. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  210. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  211. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  212. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  213. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  214. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  215. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  216. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  217. package/src/components/organisms/Table/Table.stories.ts +332 -0
  218. package/src/components/organisms/Table/Table.test.ts +320 -0
  219. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  220. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  221. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  222. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  223. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  224. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  225. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  226. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  227. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  228. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,873 @@
1
+ import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
4
+ import FontPicker, { type NexxtFontPickerFont } from './FontPicker.vue'
5
+
6
+ // Positioning is FloatingPanel's concern and has its own tests; here the panel is simply placed
7
+ vi.mock('@floating-ui/vue', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('@floating-ui/vue')>()
9
+ return {
10
+ ...actual,
11
+ useFloating: () => ({
12
+ floatingStyles: ref({}),
13
+ placement: ref('bottom-start'),
14
+ isPositioned: ref(true),
15
+ }),
16
+ autoUpdate: () => () => {},
17
+ }
18
+ })
19
+
20
+ /** Records what the component observes, so a test can play the browser's part. */
21
+ class FakeIntersectionObserver {
22
+ static instances: FakeIntersectionObserver[] = []
23
+ observed: Element[] = []
24
+ constructor(
25
+ public callback: IntersectionObserverCallback,
26
+ public options?: IntersectionObserverInit,
27
+ ) {
28
+ FakeIntersectionObserver.instances.push(this)
29
+ }
30
+ /**
31
+ * Families the "browser" shows. When set, every new observer reports its rows once, right after
32
+ * observing them, as a real IntersectionObserver does: a re-observe re-reports what is in view.
33
+ */
34
+ static visible: Set<string> | null = null
35
+ private initialReport = false
36
+ observe(element: Element) {
37
+ this.observed.push(element)
38
+ const visible = FakeIntersectionObserver.visible
39
+ if (!visible || this.initialReport) return
40
+ this.initialReport = true
41
+ queueMicrotask(() => {
42
+ const entries = this.observed.map(
43
+ (target) =>
44
+ ({
45
+ target,
46
+ isIntersecting: visible.has((target as HTMLElement).dataset.fontFamily ?? ''),
47
+ }) as IntersectionObserverEntry,
48
+ )
49
+ if (entries.length) this.callback(entries, this as unknown as IntersectionObserver)
50
+ })
51
+ }
52
+ unobserve() {}
53
+ disconnected = false
54
+ disconnect() {
55
+ this.observed = []
56
+ this.disconnected = true
57
+ }
58
+ /** Reports the rows of these families as in view (or out of it). */
59
+ report(families: string[], isIntersecting = true) {
60
+ const entries = this.observed
61
+ .filter((element) => families.includes((element as HTMLElement).dataset.fontFamily ?? ''))
62
+ .map((target) => ({ target, isIntersecting }) as IntersectionObserverEntry)
63
+ this.callback(entries, this as unknown as IntersectionObserver)
64
+ }
65
+ }
66
+
67
+ const FONTS: NexxtFontPickerFont[] = [
68
+ { family: 'Arial', source: 'web-safe' },
69
+ { family: 'Georgia', source: 'web-safe' },
70
+ { family: 'Courier New', source: 'web-safe' },
71
+ { family: 'Roboto', source: 'google' },
72
+ { family: 'Montserrat', source: 'google' },
73
+ { family: 'Lato', source: 'google' },
74
+ ]
75
+
76
+ const WEB_SAFE = FONTS.filter(({ source }) => source === 'web-safe')
77
+
78
+ type Props = Partial<InstanceType<typeof FontPicker>['$props']>
79
+
80
+ const mounted: VueWrapper[] = []
81
+
82
+ const mountPicker = (props: Props = {}) => {
83
+ const holder: { wrapper?: VueWrapper } = {}
84
+ const wrapper = mount(FontPicker, {
85
+ props: {
86
+ fonts: FONTS,
87
+ 'onUpdate:modelValue': (value: string | null) =>
88
+ holder.wrapper?.setProps({ modelValue: value }),
89
+ ...props,
90
+ },
91
+ attachTo: document.body,
92
+ })
93
+ holder.wrapper = wrapper
94
+ mounted.push(wrapper)
95
+ return wrapper
96
+ }
97
+
98
+ const triggerOf = (wrapper: VueWrapper) => wrapper.find('button[aria-haspopup="listbox"]')
99
+ const inputOf = (wrapper: VueWrapper) => wrapper.find('input')
100
+ const listboxOf = (wrapper: VueWrapper) => wrapper.find('[role="listbox"]')
101
+ const optionOf = (wrapper: VueWrapper, name: string) =>
102
+ wrapper.findAll('[role="option"]').find((option) => option.text().startsWith(name))!
103
+ const activeOption = (wrapper: VueWrapper) =>
104
+ document.getElementById(inputOf(wrapper).attributes('aria-activedescendant')!)
105
+ const descriptionOf = (option: Element) =>
106
+ document.getElementById(option.getAttribute('aria-describedby') ?? '')?.textContent
107
+
108
+ const open = async (wrapper: VueWrapper) => {
109
+ await triggerOf(wrapper).trigger('click')
110
+ await flushPromises()
111
+ }
112
+
113
+ const keydown = async (target: Element, key: string) => {
114
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
115
+ await flushPromises()
116
+ }
117
+
118
+ /** Focusable elements in tab order (no positive tabindex in use here). */
119
+ const tabStops = (root: Element) =>
120
+ Array.from(root.querySelectorAll<HTMLElement>('input, button, a[href], [tabindex]')).filter(
121
+ (element) => element.tabIndex >= 0 && !(element as HTMLButtonElement).disabled,
122
+ )
123
+
124
+ describe('FontPicker', () => {
125
+ beforeEach(() => {
126
+ FakeIntersectionObserver.instances = []
127
+ FakeIntersectionObserver.visible = null
128
+ vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
129
+ })
130
+
131
+ afterEach(() => {
132
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount())
133
+ vi.unstubAllGlobals()
134
+ vi.useRealTimers()
135
+ })
136
+
137
+ describe('trigger', () => {
138
+ it('shows the chosen family in its own font, with the sans stack as fallback', () => {
139
+ const wrapper = mountPicker({ modelValue: 'Lato' })
140
+ const value = triggerOf(wrapper).find('[id$="-value"]')
141
+
142
+ expect(value.text()).toBe('Lato')
143
+ expect((value.element as HTMLElement).style.fontFamily).toContain('Lato')
144
+ expect((value.element as HTMLElement).style.fontFamily).toContain('var(--font-sans)')
145
+ })
146
+
147
+ it('shows the inherit label and its family, in that family', () => {
148
+ const wrapper = mountPicker({ inherit: { label: 'Huisstijl', family: 'Montserrat' } })
149
+ const value = triggerOf(wrapper).find('[id$="-value"]')
150
+
151
+ expect(value.text().replace(/\s+/g, ' ')).toBe('Huisstijl · Montserrat')
152
+ expect((value.element as HTMLElement).style.fontFamily).toContain('Montserrat')
153
+ })
154
+
155
+ it('shows only the inherit label, in the sans font, when the house style has no font', () => {
156
+ const wrapper = mountPicker({ inherit: { label: 'Huisstijl' } })
157
+ const value = triggerOf(wrapper).find('[id$="-value"]')
158
+
159
+ expect(value.text()).toBe('Huisstijl')
160
+ expect((value.element as HTMLElement).style.fontFamily).toBe('')
161
+ })
162
+
163
+ it('shows the placeholder when nothing is chosen, for null and the empty string', async () => {
164
+ const wrapper = mountPicker({ modelValue: null })
165
+ expect(triggerOf(wrapper).text()).toBe('Kies een lettertype')
166
+
167
+ await wrapper.setProps({ modelValue: '', placeholder: 'Kies…' })
168
+ expect(triggerOf(wrapper).text()).toBe('Kies…')
169
+ })
170
+
171
+ // Only the "Huisstijl" part may not shrink; the family behind it truncates
172
+ it('lets a long family truncate instead of widening the trigger', () => {
173
+ const wrapper = mountPicker({ inherit: { label: 'Huisstijl', family: 'A'.repeat(80) } })
174
+ const [primary, detail] = triggerOf(wrapper).findAll('[id$="-value"] > span')
175
+
176
+ expect(primary.classes()).toContain('shrink-0')
177
+ expect(detail.classes()).toEqual(expect.arrayContaining(['min-w-0', 'truncate']))
178
+ })
179
+
180
+ it('names itself with the label and the value in the editor', () => {
181
+ const wrapper = mountPicker({
182
+ ariaLabel: 'Lettertype',
183
+ inherit: { label: 'Huisstijl', family: 'Montserrat' },
184
+ })
185
+
186
+ expect(triggerOf(wrapper).attributes('aria-label')).toBe('Lettertype, Huisstijl · Montserrat')
187
+ })
188
+
189
+ it('is labelled by the visible label and the value in a form', () => {
190
+ const wrapper = mountPicker({ label: 'Lettertype', modelValue: 'Georgia' })
191
+ const label = wrapper.find('label')
192
+ const value = triggerOf(wrapper).find('[id$="-value"]')
193
+
194
+ expect(label.text()).toBe('Lettertype')
195
+ expect(label.attributes('for')).toBe(triggerOf(wrapper).attributes('id'))
196
+ expect(triggerOf(wrapper).attributes('aria-labelledby')).toBe(
197
+ `${label.attributes('id')} ${value.attributes('id')}`,
198
+ )
199
+ expect(triggerOf(wrapper).attributes('aria-label')).toBeUndefined()
200
+ })
201
+
202
+ it('is a button that announces a listbox popup', async () => {
203
+ const wrapper = mountPicker()
204
+ const trigger = triggerOf(wrapper)
205
+
206
+ expect(trigger.attributes('type')).toBe('button')
207
+ expect(trigger.attributes('aria-expanded')).toBe('false')
208
+
209
+ await open(wrapper)
210
+ expect(trigger.attributes('aria-expanded')).toBe('true')
211
+ })
212
+
213
+ it('opens on ArrowDown', async () => {
214
+ const wrapper = mountPicker()
215
+
216
+ await keydown(triggerOf(wrapper).element, 'ArrowDown')
217
+
218
+ expect(listboxOf(wrapper).exists()).toBe(true)
219
+ expect(wrapper.emitted('open')).toHaveLength(1)
220
+ })
221
+
222
+ it('does not open while disabled', async () => {
223
+ const wrapper = mountPicker({ disabled: true })
224
+
225
+ await open(wrapper)
226
+
227
+ expect(triggerOf(wrapper).attributes('disabled')).toBeDefined()
228
+ expect(triggerOf(wrapper).classes()).toEqual(
229
+ expect.arrayContaining(['opacity-50', 'cursor-not-allowed']),
230
+ )
231
+ expect(listboxOf(wrapper).exists()).toBe(false)
232
+ expect(wrapper.emitted('open')).toBeUndefined()
233
+ })
234
+ })
235
+
236
+ describe('panel', () => {
237
+ it('emits open, focuses the search field and highlights the current value', async () => {
238
+ const wrapper = mountPicker({ modelValue: 'Montserrat' })
239
+
240
+ await open(wrapper)
241
+
242
+ expect(wrapper.emitted('open')).toHaveLength(1)
243
+ expect(document.activeElement).toBe(inputOf(wrapper).element)
244
+ expect(activeOption(wrapper)?.textContent).toContain('Montserrat')
245
+ })
246
+
247
+ it('marks the current value with a check and the description "Huidige keuze"', async () => {
248
+ const wrapper = mountPicker({ modelValue: 'Georgia' })
249
+ await open(wrapper)
250
+
251
+ const current = optionOf(wrapper, 'Georgia')
252
+ expect(current.find('i[aria-hidden="true"]').exists()).toBe(true)
253
+ expect(descriptionOf(current.element)).toBe('Huidige keuze')
254
+ expect(optionOf(wrapper, 'Arial').attributes('aria-describedby')).toBeUndefined()
255
+ })
256
+
257
+ it('lists inherit first without a group, then "Standaard", then "Google Fonts"', async () => {
258
+ const wrapper = mountPicker({ inherit: { label: 'Huisstijl', family: 'Montserrat' } })
259
+ await open(wrapper)
260
+
261
+ const listbox = listboxOf(wrapper).element
262
+ const first = listbox.firstElementChild!
263
+ expect(first.getAttribute('role')).toBe('option')
264
+ expect(first.textContent?.replace(/\s+/g, ' ')).toContain('Huisstijl · Montserrat')
265
+
266
+ const groups = wrapper.findAll('[role="group"] [role="presentation"]').map((h) => h.text())
267
+ expect(groups).toEqual(['Standaard', 'Google Fonts'])
268
+ })
269
+
270
+ // A collapsible group carries aria-expanded on its role="group"
271
+ it('renders the groups as not collapsible', async () => {
272
+ const wrapper = mountPicker()
273
+ await open(wrapper)
274
+
275
+ const groups = listboxOf(wrapper).findAll('[role="group"]')
276
+ expect(groups).toHaveLength(2)
277
+ groups.forEach((group) => expect(group.attributes('aria-expanded')).toBeUndefined())
278
+ })
279
+
280
+ it('renders every row in its own font', async () => {
281
+ const wrapper = mountPicker()
282
+ await open(wrapper)
283
+
284
+ for (const { family } of FONTS) {
285
+ const row = optionOf(wrapper, family).find('[data-font-family]')
286
+ expect((row.element as HTMLElement).style.fontFamily).toContain(family)
287
+ }
288
+ })
289
+
290
+ // Same step as the grouped options of NexxtComboboxPanel; ungrouped rows stay level
291
+ it('indents grouped rows, and leaves inherit and an off-catalogue value level', async () => {
292
+ const wrapper = mountPicker({
293
+ modelValue: 'Comic Neue',
294
+ inherit: { label: 'Huisstijl', family: 'Montserrat' },
295
+ })
296
+ await open(wrapper)
297
+
298
+ const rowOf = (name: string) => optionOf(wrapper, name).find('[data-font-family]')
299
+ expect(rowOf('Huisstijl').classes()).not.toContain('pl-3')
300
+ expect(rowOf('Comic Neue').classes()).not.toContain('pl-3')
301
+ expect(rowOf('Arial').classes()).toContain('pl-3')
302
+ expect(rowOf('Roboto').classes()).toContain('pl-3')
303
+ })
304
+
305
+ it('finds the inherit option on its family name too', async () => {
306
+ const wrapper = mountPicker({ inherit: { label: 'Huisstijl', family: 'Montserrat' } })
307
+ await open(wrapper)
308
+
309
+ await inputOf(wrapper).setValue('montser')
310
+
311
+ // the visible row only: the description sits in a `hidden` span inside the option
312
+ const labels = wrapper
313
+ .findAll('[role="option"] > span:not([hidden])')
314
+ .map((row) => row.text().replace(/\s+/g, ' '))
315
+ expect(labels).toEqual(['Huisstijl · Montserrat', 'Montserrat'])
316
+ })
317
+
318
+ it('joins the current and the inherit description on the inherit option', async () => {
319
+ const wrapper = mountPicker({ inherit: { label: 'Huisstijl', family: 'Montserrat' } })
320
+ await open(wrapper)
321
+
322
+ expect(descriptionOf(optionOf(wrapper, 'Huisstijl').element)).toBe(
323
+ 'Huidige keuze. Volgt de huisstijl, ook als die later verandert',
324
+ )
325
+ })
326
+
327
+ it('keeps a current value that is not in the catalogue at the top', async () => {
328
+ const wrapper = mountPicker({ modelValue: 'Comic Neue' })
329
+ await open(wrapper)
330
+
331
+ const first = listboxOf(wrapper).element.firstElementChild!
332
+ expect(first.textContent).toContain('Comic Neue')
333
+ expect(activeOption(wrapper)).toBe(first)
334
+ })
335
+
336
+ it('names the listbox, the search field and the panel', async () => {
337
+ const wrapper = mountPicker({ ariaLabel: 'Lettertype' })
338
+ await open(wrapper)
339
+
340
+ expect(listboxOf(wrapper).attributes('aria-label')).toBe('Lettertypes')
341
+ expect(inputOf(wrapper).attributes('aria-label')).toBe('Lettertype zoeken')
342
+ expect(inputOf(wrapper).attributes('placeholder')).toBe('Lettertype zoeken')
343
+ expect(wrapper.find('[role="group"][aria-label]').attributes('aria-label')).toBe('Lettertype')
344
+ })
345
+
346
+ it('merges custom messages per key', async () => {
347
+ const wrapper = mountPicker({
348
+ messages: { groupWebSafe: 'Web-safe', listLabel: 'Fonts' },
349
+ })
350
+ await open(wrapper)
351
+
352
+ expect(listboxOf(wrapper).attributes('aria-label')).toBe('Fonts')
353
+ const groups = wrapper.findAll('[role="group"] [role="presentation"]').map((h) => h.text())
354
+ expect(groups).toEqual(['Web-safe', 'Google Fonts'])
355
+ })
356
+ })
357
+
358
+ describe('choosing', () => {
359
+ it('writes the family on click, closes and puts focus back on the trigger', async () => {
360
+ const wrapper = mountPicker({ modelValue: 'Arial' })
361
+ await open(wrapper)
362
+
363
+ await optionOf(wrapper, 'Lato').trigger('click')
364
+ await flushPromises()
365
+
366
+ expect(wrapper.emitted('update:modelValue')).toEqual([['Lato']])
367
+ expect(listboxOf(wrapper).exists()).toBe(false)
368
+ expect(wrapper.emitted('close')).toHaveLength(1)
369
+ expect(document.activeElement).toBe(triggerOf(wrapper).element)
370
+ })
371
+
372
+ it('writes the highlighted family on Enter, and never on a highlight move', async () => {
373
+ const wrapper = mountPicker({ modelValue: 'Arial' })
374
+ await open(wrapper)
375
+
376
+ await keydown(inputOf(wrapper).element, 'ArrowDown')
377
+ await keydown(inputOf(wrapper).element, 'ArrowDown')
378
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
379
+
380
+ await keydown(inputOf(wrapper).element, 'Enter')
381
+
382
+ expect(wrapper.emitted('update:modelValue')).toEqual([['Courier New']])
383
+ expect(document.activeElement).toBe(triggerOf(wrapper).element)
384
+ })
385
+
386
+ it('writes null for the inherit option', async () => {
387
+ const wrapper = mountPicker({ modelValue: 'Arial', inherit: { label: 'Huisstijl' } })
388
+ await open(wrapper)
389
+
390
+ await optionOf(wrapper, 'Huisstijl').trigger('click')
391
+
392
+ expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
393
+ })
394
+
395
+ it('clears the search on the first Escape and closes on the second', async () => {
396
+ const wrapper = mountPicker()
397
+ await open(wrapper)
398
+ await inputOf(wrapper).setValue('rob')
399
+
400
+ await keydown(inputOf(wrapper).element, 'Escape')
401
+ expect((inputOf(wrapper).element as HTMLInputElement).value).toBe('')
402
+ expect(listboxOf(wrapper).exists()).toBe(true)
403
+
404
+ await keydown(inputOf(wrapper).element, 'Escape')
405
+ expect(listboxOf(wrapper).exists()).toBe(false)
406
+ expect(document.activeElement).toBe(triggerOf(wrapper).element)
407
+ })
408
+
409
+ it('closes without moving focus when focus leaves panel and trigger', async () => {
410
+ const outside = document.createElement('button')
411
+ document.body.append(outside)
412
+ const wrapper = mountPicker()
413
+ await open(wrapper)
414
+
415
+ outside.focus()
416
+ await flushPromises()
417
+
418
+ expect(listboxOf(wrapper).exists()).toBe(false)
419
+ expect(document.activeElement).toBe(outside)
420
+ outside.remove()
421
+ })
422
+
423
+ it('stays open while focus moves inside the panel', async () => {
424
+ const wrapper = mountPicker()
425
+ await open(wrapper)
426
+
427
+ ;(listboxOf(wrapper).element as HTMLElement).focus()
428
+ await flushPromises()
429
+
430
+ expect(listboxOf(wrapper).exists()).toBe(true)
431
+ })
432
+ })
433
+
434
+ describe('states', () => {
435
+ it('shows web-safe families and six skeleton rows under "Google Fonts" while loading', async () => {
436
+ const wrapper = mountPicker({ fonts: WEB_SAFE, catalogueStatus: 'loading' })
437
+ await open(wrapper)
438
+
439
+ const listbox = listboxOf(wrapper)
440
+ expect(listbox.attributes('aria-busy')).toBe('true')
441
+ expect(wrapper.findAll('[role="option"]').map((o) => o.text())).toEqual([
442
+ 'Arial',
443
+ 'Georgia',
444
+ 'Courier New',
445
+ ])
446
+
447
+ const placeholder = listbox.element.lastElementChild!
448
+ expect(placeholder.getAttribute('aria-hidden')).toBe('true')
449
+ expect(placeholder.textContent).toContain('Google Fonts')
450
+ expect(placeholder.querySelectorAll('.animate-pulse')).toHaveLength(6)
451
+ })
452
+
453
+ it('keeps the skeletons instead of the empty state for a search without results while loading', async () => {
454
+ const wrapper = mountPicker({ fonts: WEB_SAFE, catalogueStatus: 'loading' })
455
+ await open(wrapper)
456
+
457
+ await inputOf(wrapper).setValue('zzz')
458
+
459
+ expect(wrapper.text()).not.toContain('Geen lettertypes gevonden')
460
+ expect(listboxOf(wrapper).findAll('.animate-pulse')).toHaveLength(6)
461
+ })
462
+
463
+ it('shows only web-safe families and a notice with a retry button on error', async () => {
464
+ const wrapper = mountPicker({ fonts: WEB_SAFE, catalogueStatus: 'error' })
465
+ await open(wrapper)
466
+
467
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(3)
468
+ const notice = wrapper.find('[role="status"]:not([aria-live])')
469
+ expect(notice.text()).toContain('Google Fonts konden niet worden geladen.')
470
+ // outside the listbox: the notice is no option
471
+ expect(listboxOf(wrapper).element.contains(notice.element)).toBe(false)
472
+
473
+ await notice.find('button').trigger('click')
474
+ expect(wrapper.emitted('retry')).toHaveLength(1)
475
+ })
476
+
477
+ it('shows the empty state with a clear button for a search without results', async () => {
478
+ const wrapper = mountPicker()
479
+ await open(wrapper)
480
+
481
+ await inputOf(wrapper).setValue('zzz')
482
+ await new Promise((resolve) => setTimeout(resolve, 0))
483
+
484
+ expect(wrapper.text()).toContain('Geen lettertypes gevonden')
485
+ expect(wrapper.text()).toContain('Niets gevonden voor ‘zzz’.')
486
+ const clear = wrapper.findAll('button').find((b) => b.text() === 'Zoekopdracht wissen')!
487
+ await clear.trigger('click')
488
+ expect((inputOf(wrapper).element as HTMLInputElement).value).toBe('')
489
+ })
490
+
491
+ // search field, listbox, and the one button of the state that is showing
492
+ it.each([
493
+ ['ready', '', 2],
494
+ ['error', '', 3],
495
+ ['ready', 'zzz', 2],
496
+ ] as const)('has at most three tab stops (%s, search "%s")', async (status, term, count) => {
497
+ const wrapper = mountPicker({ catalogueStatus: status })
498
+ await open(wrapper)
499
+ if (term) await inputOf(wrapper).setValue(term)
500
+ await flushPromises()
501
+
502
+ const panel = wrapper.find('.z-50').element
503
+ expect(tabStops(panel)).toHaveLength(count)
504
+ })
505
+ })
506
+
507
+ // The status only adds skeletons or a notice; it never takes away what `fonts` holds
508
+ describe('catalogue status with a filled catalogue', () => {
509
+ const groupOf = (wrapper: VueWrapper, name: string) =>
510
+ optionOf(wrapper, name).element.closest('[role="group"]')?.textContent ?? null
511
+
512
+ it.each(['loading', 'error'] as const)(
513
+ 'keeps every family, and the chosen Google font in its group, while %s',
514
+ async (catalogueStatus) => {
515
+ const wrapper = mountPicker({ modelValue: 'Lato', catalogueStatus })
516
+ await open(wrapper)
517
+
518
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(FONTS.length)
519
+ expect(groupOf(wrapper, 'Lato')).toContain('Google Fonts')
520
+ expect(activeOption(wrapper)?.textContent).toContain('Lato')
521
+ },
522
+ )
523
+
524
+ it('marks the list busy but shows no skeletons next to a Google group that is there', async () => {
525
+ const wrapper = mountPicker({ catalogueStatus: 'loading' })
526
+ await open(wrapper)
527
+
528
+ expect(listboxOf(wrapper).attributes('aria-busy')).toBe('true')
529
+ expect(listboxOf(wrapper).findAll('.animate-pulse')).toHaveLength(0)
530
+ })
531
+
532
+ it('does not move the chosen font or the highlight on a refresh that fails', async () => {
533
+ const wrapper = mountPicker({ modelValue: 'Lato' })
534
+ await open(wrapper)
535
+ const before = activeOption(wrapper)
536
+
537
+ await wrapper.setProps({ catalogueStatus: 'loading' })
538
+ await wrapper.setProps({ catalogueStatus: 'error' })
539
+ await flushPromises()
540
+
541
+ expect(groupOf(wrapper, 'Lato')).toContain('Google Fonts')
542
+ expect(activeOption(wrapper)).toBe(before)
543
+ expect(wrapper.find('[role="status"]:not([aria-live])').exists()).toBe(true)
544
+ })
545
+ })
546
+
547
+ it('lists a family that occurs twice in the catalogue once', async () => {
548
+ const wrapper = mountPicker({
549
+ fonts: [
550
+ ...FONTS,
551
+ { family: 'Lato', source: 'google' },
552
+ { family: 'Arial', source: 'google' },
553
+ ],
554
+ })
555
+ await open(wrapper)
556
+
557
+ const labels = wrapper.findAll('[role="option"] [data-font-family]').map((row) => row.text())
558
+ expect(labels.filter((label) => label === 'Lato')).toHaveLength(1)
559
+ // the first occurrence wins, group included
560
+ expect(optionOf(wrapper, 'Arial').element.closest('[role="group"]')?.textContent).toContain(
561
+ 'Standaard',
562
+ )
563
+ expect(labels).toHaveLength(FONTS.length)
564
+ })
565
+
566
+ it('keeps the search term and the highlight when the catalogue arrives with the panel open', async () => {
567
+ const wrapper = mountPicker({ fonts: WEB_SAFE, catalogueStatus: 'loading' })
568
+ await open(wrapper)
569
+ // "o": Georgia and Courier New now, Roboto, Montserrat and Lato once the catalogue is in
570
+ await inputOf(wrapper).setValue('o')
571
+ await keydown(inputOf(wrapper).element, 'ArrowDown')
572
+ expect(activeOption(wrapper)?.textContent).toContain('Courier New')
573
+
574
+ await wrapper.setProps({ fonts: FONTS, catalogueStatus: 'ready' })
575
+ await flushPromises()
576
+
577
+ expect((inputOf(wrapper).element as HTMLInputElement).value).toBe('o')
578
+ expect(wrapper.findAll('[role="option"]')).toHaveLength(5)
579
+ expect(activeOption(wrapper)?.textContent).toContain('Courier New')
580
+ })
581
+
582
+ it('emits nothing when the inherit option is chosen on a null model', async () => {
583
+ const wrapper = mountPicker({ modelValue: null, inherit: { label: 'Huisstijl' } })
584
+ await open(wrapper)
585
+
586
+ await optionOf(wrapper, 'Huisstijl').trigger('click')
587
+
588
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
589
+ expect(listboxOf(wrapper).exists()).toBe(false)
590
+ })
591
+
592
+ // `''` is a stored value from before; the component itself writes `null`
593
+ it('writes null when the inherit option is chosen on a stored empty string', async () => {
594
+ const wrapper = mountPicker({ modelValue: '', inherit: { label: 'Huisstijl' } })
595
+ await open(wrapper)
596
+
597
+ await optionOf(wrapper, 'Huisstijl').trigger('click')
598
+
599
+ expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
600
+ })
601
+
602
+ describe('accessible name warning', () => {
603
+ it('warns once in development when neither label nor ariaLabel is given', () => {
604
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
605
+
606
+ mountPicker()
607
+
608
+ expect(warn).toHaveBeenCalledOnce()
609
+ expect(warn.mock.calls[0][0]).toContain('[NexxtFontPicker]')
610
+ warn.mockRestore()
611
+ })
612
+
613
+ it.each([{ label: 'Lettertype' }, { ariaLabel: 'Lettertype' }])(
614
+ 'stays silent with %o',
615
+ (props) => {
616
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
617
+
618
+ mountPicker(props)
619
+
620
+ expect(warn).not.toHaveBeenCalled()
621
+ warn.mockRestore()
622
+ },
623
+ )
624
+ })
625
+
626
+ describe('loading faces', () => {
627
+ it('requests the family of the current value on mount', async () => {
628
+ const loadFont = vi.fn()
629
+ mountPicker({ modelValue: 'Lato', loadFont })
630
+ await flushPromises()
631
+
632
+ expect(loadFont).toHaveBeenCalledWith('Lato')
633
+ })
634
+
635
+ it('requests the inherit family on mount when the model is null', async () => {
636
+ const loadFont = vi.fn()
637
+ mountPicker({ inherit: { label: 'Huisstijl', family: 'Montserrat' }, loadFont })
638
+ await flushPromises()
639
+
640
+ expect(loadFont).toHaveBeenCalledWith('Montserrat')
641
+ })
642
+
643
+ it('observes the rows with the listbox as root and a 150px margin', async () => {
644
+ const wrapper = mountPicker({ loadFont: vi.fn() })
645
+ await open(wrapper)
646
+
647
+ const observer = FakeIntersectionObserver.instances.at(-1)!
648
+ expect(observer.options?.root).toBe(listboxOf(wrapper).element)
649
+ expect(observer.options?.rootMargin).toBe('150px 0px')
650
+ expect(observer.observed).toHaveLength(FONTS.length)
651
+ })
652
+
653
+ it('requests the rows in view 100ms after scrolling rests, each family once', async () => {
654
+ const loadFont = vi.fn()
655
+ const wrapper = mountPicker({ loadFont })
656
+ await open(wrapper)
657
+ vi.useFakeTimers()
658
+ const observer = FakeIntersectionObserver.instances.at(-1)!
659
+
660
+ observer.report(['Arial', 'Roboto'])
661
+ vi.advanceTimersByTime(50)
662
+ // scrolled on: Arial left the view before scrolling came to rest
663
+ observer.report(['Arial'], false)
664
+ observer.report(['Lato'])
665
+ vi.advanceTimersByTime(99)
666
+ expect(loadFont).not.toHaveBeenCalled()
667
+
668
+ vi.advanceTimersByTime(1)
669
+ expect(loadFont.mock.calls.map(([family]) => family).sort()).toEqual(['Lato', 'Roboto'])
670
+
671
+ observer.report(['Lato'])
672
+ vi.advanceTimersByTime(100)
673
+ expect(loadFont).toHaveBeenCalledTimes(2)
674
+ })
675
+
676
+ it('starts once a loader arrives after mount: the current family and the rows in view', async () => {
677
+ const wrapper = mountPicker({ modelValue: 'Lato' })
678
+ await open(wrapper)
679
+ expect(FakeIntersectionObserver.instances).toHaveLength(0)
680
+
681
+ const loadFont = vi.fn()
682
+ await wrapper.setProps({ loadFont })
683
+ await flushPromises()
684
+ expect(loadFont).toHaveBeenCalledWith('Lato')
685
+
686
+ vi.useFakeTimers()
687
+ FakeIntersectionObserver.instances.at(-1)!.report(['Arial', 'Lato'])
688
+ vi.advanceTimersByTime(100)
689
+ expect(loadFont.mock.calls.map(([family]) => family)).toEqual(['Lato', 'Arial'])
690
+ })
691
+
692
+ it('does not ask a new loader again for families already requested', async () => {
693
+ const first = vi.fn()
694
+ const wrapper = mountPicker({ modelValue: 'Lato', loadFont: first })
695
+ await flushPromises()
696
+
697
+ const second = vi.fn()
698
+ await wrapper.setProps({ loadFont: second })
699
+ await open(wrapper)
700
+ vi.useFakeTimers()
701
+ FakeIntersectionObserver.instances.at(-1)!.report(['Lato', 'Roboto'])
702
+ vi.advanceTimersByTime(100)
703
+
704
+ expect(first).toHaveBeenCalledWith('Lato')
705
+ expect(second.mock.calls.map(([family]) => family)).toEqual(['Roboto'])
706
+ })
707
+
708
+ it('ignores a loader that fails', async () => {
709
+ const loadFont = vi.fn(() => Promise.reject(new Error('offline')))
710
+ const wrapper = mountPicker({ modelValue: 'Lato', loadFont })
711
+ await flushPromises()
712
+
713
+ expect(loadFont).toHaveBeenCalledOnce()
714
+ expect(triggerOf(wrapper).text()).toBe('Lato')
715
+ })
716
+
717
+ it('asks again for a family whose load failed, once its row is back in view', async () => {
718
+ let robotoCalls = 0
719
+ const loadFont = vi.fn((family: string) => {
720
+ if (family !== 'Roboto') return Promise.resolve()
721
+ robotoCalls++
722
+ return robotoCalls === 1 ? Promise.reject(new Error('offline')) : Promise.resolve()
723
+ })
724
+ const wrapper = mountPicker({ loadFont })
725
+ await open(wrapper)
726
+ vi.useFakeTimers()
727
+ const observer = FakeIntersectionObserver.instances.at(-1)!
728
+
729
+ observer.report(['Roboto'])
730
+ vi.advanceTimersByTime(100)
731
+ await vi.runAllTimersAsync()
732
+
733
+ observer.report(['Roboto'], false)
734
+ observer.report(['Roboto'])
735
+ vi.advanceTimersByTime(100)
736
+
737
+ expect(robotoCalls).toBe(2)
738
+ })
739
+
740
+ // A real observer re-reports every row in view when it is created, and every search, item
741
+ // change or new loader identity creates one; that alone must not retry a failed family
742
+ describe('a loader that keeps failing', () => {
743
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 150))
744
+
745
+ const mountFailing = async () => {
746
+ FakeIntersectionObserver.visible = new Set(['Roboto'])
747
+ const calls: string[] = []
748
+ const failing = (family: string) => {
749
+ calls.push(family)
750
+ return Promise.reject(new Error('offline'))
751
+ }
752
+ const wrapper = mountPicker({ ariaLabel: 'Lettertype', loadFont: failing })
753
+ await open(wrapper)
754
+ await settle()
755
+ return { wrapper, calls, failing }
756
+ }
757
+
758
+ it('is asked once while the row stays in view, however much is typed', async () => {
759
+ const { wrapper, calls } = await mountFailing()
760
+ expect(calls).toEqual(['Roboto'])
761
+
762
+ for (const term of ['r', 'ro', 'rob', 'robo']) {
763
+ await inputOf(wrapper).setValue(term)
764
+ await settle()
765
+ }
766
+
767
+ expect(FakeIntersectionObserver.instances.length).toBeGreaterThan(4)
768
+ expect(calls).toEqual(['Roboto'])
769
+ })
770
+
771
+ it('is asked once while the row stays in view, with a new loader identity', async () => {
772
+ const { wrapper, calls, failing } = await mountFailing()
773
+
774
+ await wrapper.setProps({ loadFont: (family: string) => failing(family) })
775
+ await settle()
776
+ await wrapper.setProps({ loadFont: (family: string) => failing(family) })
777
+ await settle()
778
+
779
+ expect(calls).toEqual(['Roboto'])
780
+ })
781
+
782
+ it('is asked again after the row has been out of view and comes back', async () => {
783
+ const { calls } = await mountFailing()
784
+ const observer = FakeIntersectionObserver.instances.at(-1)!
785
+
786
+ observer.report(['Roboto'], false)
787
+ observer.report(['Roboto'])
788
+ await settle()
789
+
790
+ expect(calls).toEqual(['Roboto', 'Roboto'])
791
+ })
792
+
793
+ it('is asked again when the panel opens again', async () => {
794
+ const { wrapper, calls } = await mountFailing()
795
+
796
+ await keydown(inputOf(wrapper).element, 'Escape')
797
+ await open(wrapper)
798
+ await settle()
799
+
800
+ expect(calls).toEqual(['Roboto', 'Roboto'])
801
+ })
802
+ })
803
+
804
+ it('does not ask again for a family that loaded', async () => {
805
+ const loadFont = vi.fn(() => Promise.resolve())
806
+ const wrapper = mountPicker({ loadFont })
807
+ await open(wrapper)
808
+ vi.useFakeTimers()
809
+ const observer = FakeIntersectionObserver.instances.at(-1)!
810
+
811
+ observer.report(['Roboto'])
812
+ vi.advanceTimersByTime(100)
813
+ await vi.runAllTimersAsync()
814
+ observer.report(['Roboto'], false)
815
+ observer.report(['Roboto'])
816
+ vi.advanceTimersByTime(100)
817
+
818
+ expect(loadFont).toHaveBeenCalledOnce()
819
+ })
820
+
821
+ it('keeps a row choosable and in its fallback stack when the loader throws', async () => {
822
+ const loadFont = vi.fn(() => {
823
+ throw new Error('blocked')
824
+ })
825
+ const wrapper = mountPicker({ modelValue: 'Arial', loadFont })
826
+ await open(wrapper)
827
+ vi.useFakeTimers()
828
+ FakeIntersectionObserver.instances.at(-1)!.report(['Lato'])
829
+ vi.advanceTimersByTime(100)
830
+ vi.useRealTimers()
831
+ await flushPromises()
832
+
833
+ expect(loadFont).toHaveBeenCalledWith('Lato')
834
+ const row = optionOf(wrapper, 'Lato').find('[data-font-family]').element as HTMLElement
835
+ expect(row.style.fontFamily).toContain('var(--font-sans)')
836
+
837
+ await optionOf(wrapper, 'Lato').trigger('click')
838
+ expect(wrapper.emitted('update:modelValue')).toEqual([['Lato']])
839
+ })
840
+
841
+ it('disconnects the observer when the panel closes', async () => {
842
+ const wrapper = mountPicker({ loadFont: vi.fn() })
843
+ await open(wrapper)
844
+ const observer = FakeIntersectionObserver.instances.at(-1)!
845
+
846
+ await keydown(inputOf(wrapper).element, 'Escape')
847
+
848
+ expect(observer.disconnected).toBe(true)
849
+ })
850
+
851
+ it('calls no loader after unmount for a debounce that was still running', async () => {
852
+ const loadFont = vi.fn()
853
+ const wrapper = mountPicker({ loadFont })
854
+ await open(wrapper)
855
+ vi.useFakeTimers()
856
+
857
+ FakeIntersectionObserver.instances.at(-1)!.report(['Roboto'])
858
+ vi.advanceTimersByTime(50)
859
+ wrapper.unmount()
860
+ mounted.splice(mounted.indexOf(wrapper), 1)
861
+ vi.advanceTimersByTime(100)
862
+
863
+ expect(loadFont).not.toHaveBeenCalled()
864
+ })
865
+
866
+ it('observes nothing without a loader', async () => {
867
+ const wrapper = mountPicker()
868
+ await open(wrapper)
869
+
870
+ expect(FakeIntersectionObserver.instances).toHaveLength(0)
871
+ })
872
+ })
873
+ })