@nexxtmove/ui 2.6.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 (223) hide show
  1. package/dist/index.js +49 -126
  2. package/dist/nuxt.js +0 -1
  3. package/package.json +38 -36
  4. package/src/attribute-contract.test.ts +151 -0
  5. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  6. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  7. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  8. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  9. package/src/components/atoms/Button/Button.stories.ts +290 -0
  10. package/src/components/atoms/Button/Button.test.ts +406 -0
  11. package/src/components/atoms/Card/Card.stories.ts +45 -0
  12. package/src/components/atoms/Card/Card.test.ts +46 -0
  13. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  14. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  15. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  16. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  17. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  18. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  19. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  20. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  21. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  22. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  23. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  24. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  25. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  26. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  27. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  28. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  29. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  30. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  31. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  32. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  33. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  34. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  35. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  36. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  37. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  38. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  39. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  40. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  41. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  42. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  43. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  44. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  45. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  46. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  47. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  48. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  49. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  50. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  51. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  52. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  53. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  54. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  55. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  56. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  57. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  58. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  59. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  60. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  61. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  62. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  63. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +563 -0
  64. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +689 -0
  65. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +793 -0
  66. package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
  67. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
  68. package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
  69. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
  70. package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
  71. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
  72. package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
  73. package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
  74. package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
  75. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
  76. package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
  77. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
  78. package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
  79. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
  80. package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
  81. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
  82. package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
  83. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
  84. package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
  85. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
  86. package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
  87. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
  88. package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
  89. package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
  90. package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
  91. package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
  92. package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
  93. package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
  94. package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
  95. package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
  96. package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
  97. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
  98. package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
  99. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
  100. package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
  101. package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
  102. package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
  103. package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
  104. package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
  105. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
  106. package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
  107. package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
  108. package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
  109. package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
  110. package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
  111. package/src/components/molecules/InputField/InputField.stories.ts +117 -0
  112. package/src/components/molecules/InputField/InputField.test.ts +138 -0
  113. package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
  114. package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
  115. package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
  116. package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
  117. package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
  118. package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
  119. package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
  120. package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
  121. package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
  122. package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
  123. package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
  124. package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
  125. package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
  126. package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
  127. package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
  128. package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
  129. package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
  130. package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
  131. package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
  132. package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
  133. package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
  134. package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
  135. package/src/components/molecules/Select/Select.stories.ts +264 -0
  136. package/src/components/molecules/Select/Select.test.ts +315 -0
  137. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
  138. package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
  139. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
  140. package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
  141. package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
  142. package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
  143. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  144. package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
  145. package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
  146. package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
  147. package/src/components/molecules/Source/Source.stories.ts +35 -0
  148. package/src/components/molecules/Source/Source.test.ts +45 -0
  149. package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
  150. package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
  151. package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
  152. package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
  153. package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
  154. package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
  155. package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
  156. package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
  157. package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
  158. package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
  159. package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
  160. package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
  161. package/src/components/molecules/Toast/Toast.stories.ts +110 -0
  162. package/src/components/molecules/Toast/Toast.test.ts +103 -0
  163. package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
  164. package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
  165. package/src/components/molecules/Tree/Tree.stories.ts +884 -0
  166. package/src/components/molecules/Tree/Tree.test.ts +1247 -0
  167. package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
  168. package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
  169. package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
  170. package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
  171. package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
  172. package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
  173. package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
  174. package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
  175. package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
  176. package/src/components/organisms/ColorPicker/ColorPicker.test.ts +638 -0
  177. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  178. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  179. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  180. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  181. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  182. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  183. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  184. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  185. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  186. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  187. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  188. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  189. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  190. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  191. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  192. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  193. package/src/components/organisms/Header/Header.stories.ts +75 -0
  194. package/src/components/organisms/Header/Header.test.ts +233 -0
  195. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  196. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  197. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  198. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  199. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  200. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  201. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  202. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  203. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  204. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  205. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  206. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  207. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  208. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  209. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  210. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  211. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  212. package/src/components/organisms/Table/Table.stories.ts +332 -0
  213. package/src/components/organisms/Table/Table.test.ts +320 -0
  214. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  215. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  216. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  217. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  218. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  219. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  220. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  221. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  222. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  223. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,204 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import ModalFooter from './ModalFooter.vue'
4
+ import NexxtModal from '../../organisms/Modal/Modal.vue'
5
+ import NexxtButton from '../../atoms/Button/Button.vue'
6
+
7
+ export default {
8
+ title: 'Components/Molecules/Modal Footer',
9
+ component: ModalFooter,
10
+ } satisfies Meta<typeof ModalFooter>
11
+
12
+ type Story = StoryObj<typeof ModalFooter>
13
+
14
+ // The footer only reads correctly in the place it is used, so every story renders
15
+ // it in the real Modal's `footer` slot rather than on a blank canvas.
16
+ //
17
+ // They start CLOSED, and must stay that way. The docs page renders every story on
18
+ // one screen, so a modal that opens on mount stacks itself on top of the others --
19
+ // eight overlapping dialogs, with only the last one reachable. Opening via the
20
+ // trigger costs one click and keeps the docs page readable. `Modal.stories.ts`
21
+ // follows the same convention throughout.
22
+ const inModal = (
23
+ title: string,
24
+ body: string,
25
+ footerAttrs: string,
26
+ handlers = '@cancel="open = false" @confirm="open = false"',
27
+ ) => ({
28
+ components: { ModalFooter, NexxtModal, NexxtButton },
29
+ setup() {
30
+ const open = ref(false)
31
+ return { open }
32
+ },
33
+ template: `
34
+ <div>
35
+ <NexxtButton v-if="!open" @click="open = true">Open modal</NexxtButton>
36
+ <NexxtModal v-model="open" title="${title}">
37
+ <p>${body}</p>
38
+ <template #footer>
39
+ <ModalFooter cancel-label="Annuleren" ${footerAttrs} ${handlers} />
40
+ </template>
41
+ </NexxtModal>
42
+ </div>
43
+ `,
44
+ })
45
+
46
+ /**
47
+ * The two-button shape. Both labels are required props: the library has no i18n
48
+ * layer, so copy comes from the consumer.
49
+ */
50
+ export const Default: Story = {
51
+ render: () =>
52
+ inModal('Wijzigingen opslaan', 'Weet je zeker dat je verder wilt?', 'confirm-label="Opslaan"'),
53
+ }
54
+
55
+ /**
56
+ * `confirmVariant="danger"` — the delete-confirmation shape. The destructive
57
+ * action has to be the one that stands out, with cancel staying quiet next to it.
58
+ */
59
+ export const DangerConfirm: Story = {
60
+ render: () =>
61
+ inModal(
62
+ 'Contact verwijderen',
63
+ 'Dit contact wordt permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.',
64
+ 'confirm-label="Verwijderen" confirm-variant="danger"',
65
+ ),
66
+ }
67
+
68
+ /**
69
+ * `confirmLoading` on the action button — the state between submitting and the modal
70
+ * closing. Cancel stays reachable so a slow request is not a trap.
71
+ */
72
+ export const Loading: Story = {
73
+ render: () =>
74
+ inModal('Wijzigingen opslaan', 'Bezig met opslaan…', 'confirm-label="Opslaan" confirm-loading'),
75
+ }
76
+
77
+ /**
78
+ * `confirmLoading` on a `danger` confirm, which is where a spinner most needs to be
79
+ * legible: a light spinner has to hold up on the red button, not only on the
80
+ * default one.
81
+ */
82
+ export const LoadingDanger: Story = {
83
+ render: () =>
84
+ inModal(
85
+ 'Contact verwijderen',
86
+ 'Bezig met verwijderen…',
87
+ 'confirm-label="Verwijderen" confirm-variant="danger" confirm-loading',
88
+ ),
89
+ }
90
+
91
+ /**
92
+ * `confirmDisabled` on the action button — the form is incomplete, so confirming is not
93
+ * offered yet. Visually distinct from `confirmLoading`: nothing is happening here.
94
+ */
95
+ export const Disabled: Story = {
96
+ render: () =>
97
+ inModal(
98
+ 'Wijzigingen opslaan',
99
+ 'Vul eerst alle verplichte velden in.',
100
+ 'confirm-label="Opslaan" confirm-disabled',
101
+ ),
102
+ }
103
+
104
+ /**
105
+ * `confirmIcon` on the action button. The prop exists because a scripted
106
+ * conversion dropped `icon` from two real modals without anything failing —
107
+ * it type-checked and ran, it just rendered no icon.
108
+ */
109
+ export const ConfirmIcon: Story = {
110
+ render: () =>
111
+ inModal(
112
+ 'Wijzigingen opslaan',
113
+ 'De bevestigknop draagt een icoon naast zijn label.',
114
+ 'confirm-label="Opslaan" confirm-icon="floppy-disk"',
115
+ ),
116
+ }
117
+
118
+ /** A cancel label that says what leaving actually does. */
119
+ export const CustomCancelLabel: Story = {
120
+ render: () =>
121
+ inModal(
122
+ 'Concept verlaten',
123
+ 'Je wijzigingen gaan verloren.',
124
+ 'cancel-label="Terug naar bewerken" confirm-label="Verlaten" confirm-variant="danger"',
125
+ ),
126
+ }
127
+
128
+ /**
129
+ * Every state next to each other, outside a modal, so the set can be compared in
130
+ * one glance — and so the footer's own alignment is visible without the modal's
131
+ * own `justify-end` footer slot doing part of the work.
132
+ */
133
+ export const AllStates: Story = {
134
+ render: () => ({
135
+ components: { ModalFooter },
136
+ setup: () => ({
137
+ rows: [
138
+ { label: 'default', props: { confirmLabel: 'Opslaan' } },
139
+ {
140
+ label: 'confirmVariant="danger"',
141
+ props: { confirmLabel: 'Verwijderen', confirmVariant: 'danger' },
142
+ },
143
+ { label: 'confirmLoading', props: { confirmLabel: 'Opslaan', confirmLoading: true } },
144
+ { label: 'confirmDisabled', props: { confirmLabel: 'Opslaan', confirmDisabled: true } },
145
+ {
146
+ label: 'custom cancelLabel',
147
+ props: { cancelLabel: 'Terug naar bewerken', confirmLabel: 'Verlaten' },
148
+ },
149
+ ],
150
+ }),
151
+ template: `
152
+ <div style="display: flex; flex-direction: column; gap: 8px; max-width: 560px; font-family: system-ui, sans-serif;">
153
+ <div v-for="row in rows" :key="row.label">
154
+ <p style="margin: 0 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #374151;">
155
+ {{ row.label }}
156
+ </p>
157
+ <!-- Modal supplies this padding via its own #footer wrapper; outside a
158
+ Modal the component deliberately adds none, so the story does. -->
159
+ <div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px 24px;">
160
+ <ModalFooter v-bind="row.props" />
161
+ </div>
162
+ </div>
163
+ </div>
164
+ `,
165
+ }),
166
+ }
167
+
168
+ /**
169
+ * Both events wired up, with a log, so it is visible that `cancel` and `confirm`
170
+ * are emitted rather than the footer closing the modal itself. Closing stays the
171
+ * caller's decision — which is what lets `confirm` await a request first.
172
+ */
173
+ export const Events: Story = {
174
+ render: () => ({
175
+ components: { ModalFooter, NexxtModal, NexxtButton },
176
+ setup() {
177
+ const open = ref(false)
178
+ const log = ref<string[]>([])
179
+ const record = (event: string) => {
180
+ log.value.push(event)
181
+ open.value = false
182
+ }
183
+ return { open, log, record }
184
+ },
185
+ template: `
186
+ <div style="display: flex; flex-direction: column; gap: 12px; align-items: flex-start;">
187
+ <NexxtButton v-if="!open" @click="open = true">Open modal</NexxtButton>
188
+ <NexxtModal v-model="open" title="Wijzigingen opslaan">
189
+ <p>Bevestig of annuleer om het event te zien.</p>
190
+ <template #footer>
191
+ <ModalFooter
192
+ confirm-label="Opslaan"
193
+ @cancel="record('cancel')"
194
+ @confirm="record('confirm')"
195
+ />
196
+ </template>
197
+ </NexxtModal>
198
+ <ul style="margin: 0; padding: 0; list-style: none; font-size: 12px; color: #374151;">
199
+ <li v-for="(entry, i) in log" :key="i">emitted: {{ entry }}</li>
200
+ </ul>
201
+ </div>
202
+ `,
203
+ }),
204
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import ModalFooter from './ModalFooter.vue'
4
+
5
+ const CANCEL_LABEL = 'Annuleren'
6
+
7
+ const mountFooter = (props: Record<string, unknown> = {}) =>
8
+ mount(ModalFooter, { props: { confirmLabel: 'Opslaan', cancelLabel: CANCEL_LABEL, ...props } })
9
+
10
+ // Buttons are located by their visible label, never by index, so a footer that
11
+ // swapped the two buttons around cannot make these assertions pass by accident.
12
+ const byLabel = (wrapper: ReturnType<typeof mountFooter>, label: string) => {
13
+ const found = wrapper.findAll('button').filter((b) => b.text().includes(label))
14
+ expect(found).toHaveLength(1)
15
+ return found[0]
16
+ }
17
+
18
+ const action = (wrapper: ReturnType<typeof mountFooter>) => byLabel(wrapper, 'Opslaan')
19
+ const cancel = (wrapper: ReturnType<typeof mountFooter>, label = CANCEL_LABEL) =>
20
+ byLabel(wrapper, label)
21
+
22
+ describe('ModalFooter labels', () => {
23
+ it('renders the required confirm label', () => {
24
+ expect(mountFooter().text()).toContain('Opslaan')
25
+ })
26
+
27
+ it('renders the supplied cancel label', () => {
28
+ const wrapper = mountFooter({ cancelLabel: 'Sluiten' })
29
+
30
+ expect(cancel(wrapper, 'Sluiten').exists()).toBe(true)
31
+ expect(wrapper.text()).not.toContain(CANCEL_LABEL)
32
+ })
33
+
34
+ it('renders exactly two buttons', () => {
35
+ expect(mountFooter().findAll('button')).toHaveLength(2)
36
+ })
37
+ })
38
+
39
+ describe('ModalFooter events', () => {
40
+ it('emits confirm, and only confirm, when the action button is clicked', async () => {
41
+ const wrapper = mountFooter()
42
+ await action(wrapper).trigger('click')
43
+
44
+ expect(wrapper.emitted('confirm')).toHaveLength(1)
45
+ expect(wrapper.emitted('cancel')).toBeUndefined()
46
+ })
47
+
48
+ it('emits cancel, and only cancel, when the cancel button is clicked', async () => {
49
+ const wrapper = mountFooter()
50
+ await cancel(wrapper).trigger('click')
51
+
52
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
53
+ expect(wrapper.emitted('confirm')).toBeUndefined()
54
+ })
55
+
56
+ it('emits cancel from a renamed cancel button too', async () => {
57
+ const wrapper = mountFooter({ cancelLabel: 'Sluiten' })
58
+ await cancel(wrapper, 'Sluiten').trigger('click')
59
+
60
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
61
+ })
62
+ })
63
+
64
+ describe('ModalFooter confirmDisabled', () => {
65
+ it('disables the action button and leaves cancel usable', async () => {
66
+ const wrapper = mountFooter({ confirmDisabled: true })
67
+
68
+ expect(action(wrapper).attributes('disabled')).toBeDefined()
69
+ expect(cancel(wrapper).attributes('disabled')).toBeUndefined()
70
+
71
+ await cancel(wrapper).trigger('click')
72
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
73
+ })
74
+
75
+ it('leaves both buttons enabled when confirmDisabled is not set', () => {
76
+ const wrapper = mountFooter()
77
+
78
+ expect(action(wrapper).attributes('disabled')).toBeUndefined()
79
+ expect(cancel(wrapper).attributes('disabled')).toBeUndefined()
80
+ })
81
+
82
+ it('does not emit confirm while confirmDisabled', async () => {
83
+ const wrapper = mountFooter({ confirmDisabled: true })
84
+ await action(wrapper).trigger('click')
85
+
86
+ expect(wrapper.emitted('confirm')).toBeUndefined()
87
+ })
88
+ })
89
+
90
+ describe('ModalFooter cancelDisabled', () => {
91
+ it('disables the cancel button and leaves the action usable', () => {
92
+ const wrapper = mountFooter({ cancelDisabled: true })
93
+
94
+ // Which button, not merely that one is disabled: wiring this to the wrong
95
+ // button is the plausible slip and it would still look like it works.
96
+ expect(cancel(wrapper).attributes('disabled')).toBeDefined()
97
+ expect(action(wrapper).attributes('disabled')).toBeUndefined()
98
+ })
99
+
100
+ it('does not emit cancel while cancelDisabled', async () => {
101
+ const wrapper = mountFooter({ cancelDisabled: true })
102
+
103
+ await cancel(wrapper).trigger('click')
104
+
105
+ expect(wrapper.emitted('cancel')).toBeUndefined()
106
+ })
107
+
108
+ it('still emits confirm while cancel is disabled', async () => {
109
+ const wrapper = mountFooter({ cancelDisabled: true })
110
+
111
+ await action(wrapper).trigger('click')
112
+
113
+ expect(wrapper.emitted('confirm')).toHaveLength(1)
114
+ })
115
+
116
+ it('leaves cancel enabled when cancelDisabled is unset', () => {
117
+ expect(cancel(mountFooter()).attributes('disabled')).toBeUndefined()
118
+ })
119
+ })
120
+
121
+ describe('ModalFooter confirmIcon', () => {
122
+ // The icon exists because a scripted conversion silently dropped `icon` from
123
+ // two action buttons: it type-checked, linted and ran, it just rendered no
124
+ // icon. So these assert which button carries it, not that one does.
125
+ it('renders the icon on the action button and not on cancel', () => {
126
+ const wrapper = mountFooter({ confirmIcon: 'check' })
127
+
128
+ expect(action(wrapper).find('i.fa-check').exists()).toBe(true)
129
+ expect(cancel(wrapper).find('i').exists()).toBe(false)
130
+ })
131
+
132
+ it('renders no icon on either button when confirmIcon is unset', () => {
133
+ const wrapper = mountFooter()
134
+
135
+ expect(action(wrapper).find('i').exists()).toBe(false)
136
+ expect(cancel(wrapper).find('i').exists()).toBe(false)
137
+ })
138
+
139
+ it('keeps the label alongside the icon rather than replacing it', () => {
140
+ const wrapper = mountFooter({ confirmIcon: 'check' })
141
+
142
+ expect(action(wrapper).text()).toContain('Opslaan')
143
+ })
144
+
145
+ it('accepts an icon name bound at runtime', async () => {
146
+ const wrapper = mountFooter({ confirmIcon: 'check' })
147
+ await wrapper.setProps({ confirmIcon: 'floppy-disk' })
148
+
149
+ expect(action(wrapper).find('i.fa-floppy-disk').exists()).toBe(true)
150
+ expect(action(wrapper).find('i.fa-check').exists()).toBe(false)
151
+ })
152
+ })
153
+
154
+ describe('ModalFooter confirmLoading', () => {
155
+ // The spinner is Font Awesome's, not a Tailwind utility, so matching it here is
156
+ // safe from the @source generation trap.
157
+ const spinnerIn = (button: ReturnType<typeof action>) =>
158
+ button.find('i.fa-spinner-third').exists()
159
+
160
+ it('shows the busy indicator on the action button only', () => {
161
+ const wrapper = mountFooter({ confirmLoading: true })
162
+
163
+ expect(spinnerIn(action(wrapper))).toBe(true)
164
+ expect(spinnerIn(cancel(wrapper))).toBe(false)
165
+ })
166
+
167
+ it('shows no busy indicator on either button when confirmLoading is unset', () => {
168
+ const wrapper = mountFooter()
169
+
170
+ expect(spinnerIn(action(wrapper))).toBe(false)
171
+ expect(spinnerIn(cancel(wrapper))).toBe(false)
172
+ })
173
+
174
+ it('keeps cancel clickable while the action is loading', async () => {
175
+ const wrapper = mountFooter({ confirmLoading: true })
176
+ await cancel(wrapper).trigger('click')
177
+
178
+ expect(wrapper.emitted('cancel')).toHaveLength(1)
179
+ })
180
+
181
+ it('moves the busy indicator when confirmLoading is toggled at runtime', async () => {
182
+ const wrapper = mountFooter()
183
+ expect(spinnerIn(action(wrapper))).toBe(false)
184
+
185
+ await wrapper.setProps({ confirmLoading: true })
186
+ expect(spinnerIn(action(wrapper))).toBe(true)
187
+ expect(spinnerIn(cancel(wrapper))).toBe(false)
188
+ })
189
+ })
190
+
191
+ describe('ModalFooter confirmVariant', () => {
192
+ // The cancel button has a fixed variant of its own; confirmVariant must not
193
+ // reach it. Compared against a baseline mount rather than a hardcoded value, so
194
+ // this stays true if the cancel styling is ever changed deliberately.
195
+ it.each(['primary', 'secondary', 'success', 'danger', 'warning'])(
196
+ 'passes the variant "%s" through to the action button, leaving cancel alone',
197
+ (variant) => {
198
+ const baseline = cancel(mountFooter()).attributes('data-variant')
199
+ const wrapper = mountFooter({ confirmVariant: variant })
200
+
201
+ expect(action(wrapper).attributes('data-variant')).toBe(variant)
202
+ expect(cancel(wrapper).attributes('data-variant')).toBe(baseline)
203
+ },
204
+ )
205
+
206
+ it('falls through to the button default when no variant is supplied', () => {
207
+ expect(action(mountFooter()).attributes('data-variant')).toBe('primary')
208
+ })
209
+
210
+ it('accepts a variant computed at runtime rather than a literal', async () => {
211
+ // mirrors the real call sites: `options.variant ?? 'primary'` and
212
+ // `isLast ? 'success' : 'primary'`
213
+ const options: { variant?: string } = {}
214
+ const wrapper = mountFooter({ confirmVariant: options.variant ?? 'primary' })
215
+ expect(action(wrapper).attributes('data-variant')).toBe('primary')
216
+
217
+ const isLast = true
218
+ await wrapper.setProps({ confirmVariant: isLast ? 'success' : 'primary' })
219
+ expect(action(wrapper).attributes('data-variant')).toBe('success')
220
+ })
221
+
222
+ it('re-renders the action button when the variant changes', async () => {
223
+ const wrapper = mountFooter({ confirmVariant: 'primary' })
224
+ await wrapper.setProps({ confirmVariant: 'danger' })
225
+
226
+ expect(action(wrapper).attributes('data-variant')).toBe('danger')
227
+ expect(wrapper.emitted('confirm')).toBeUndefined()
228
+ })
229
+ })
230
+
231
+ describe('ModalFooter layout', () => {
232
+ it('is a single-root component, so fallthrough attributes have one home', () => {
233
+ // A comment node before the root makes this multi-root, and the compiler
234
+ // strips comments in production only -- so a consumer's class would land
235
+ // somewhere different in dev than in prod. Guarding that here.
236
+ const wrapper = mountFooter()
237
+ const row = wrapper.find('button').element.parentElement
238
+
239
+ expect(wrapper.element).toBe(row)
240
+ expect(wrapper.classes().length).toBeGreaterThan(0)
241
+ })
242
+
243
+ it('puts a consumer class, id and data attribute on the row itself', () => {
244
+ const wrapper = mount(ModalFooter, {
245
+ props: { confirmLabel: 'Opslaan', cancelLabel: CANCEL_LABEL },
246
+ attrs: { class: 'consumer-class', id: 'consumer-id', 'data-test': 'footer' },
247
+ })
248
+ const row = wrapper.find('button').element.parentElement as HTMLElement
249
+
250
+ expect(row.classList.contains('consumer-class')).toBe(true)
251
+ expect(row.getAttribute('id')).toBe('consumer-id')
252
+ expect(row.getAttribute('data-test')).toBe('footer')
253
+ })
254
+
255
+ it('adds no padding of its own, because the modal footer slot supplies it', () => {
256
+ const classes = mountFooter().classes()
257
+
258
+ expect(classes.length).toBeGreaterThan(0)
259
+ expect(classes.filter((c) => /^-?p[xytblrse]?-/.test(c))).toEqual([])
260
+ })
261
+
262
+ it('renders both buttons as siblings in one row element', () => {
263
+ const wrapper = mountFooter()
264
+ const parents = wrapper.findAll('button').map((b) => b.element.parentElement)
265
+
266
+ expect(parents[0]?.tagName).toBe('DIV')
267
+ expect(parents[0]).toBe(parents[1])
268
+ })
269
+
270
+ it('renders cancel before the action button in document order', () => {
271
+ const wrapper = mountFooter()
272
+ const labels = wrapper.findAll('button').map((b) => b.text())
273
+
274
+ expect(labels).toEqual([CANCEL_LABEL, 'Opslaan'])
275
+ })
276
+ })
@@ -0,0 +1,100 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import NexxtDropdownButton from '../../organisms/DropdownButton/DropdownButton.vue'
3
+ import PageCard from './PageCard.vue'
4
+
5
+ export default {
6
+ title: 'Components/Molecules/Page Card',
7
+ component: PageCard,
8
+ } satisfies Meta<typeof PageCard>
9
+
10
+ type Story = StoryObj<typeof PageCard>
11
+
12
+ const ACTIONS_SLOT = `
13
+ <template #actions>
14
+ <NexxtDropdownButton
15
+ icon-only
16
+ variant="secondary"
17
+ trigger-icon="ellipsis"
18
+ aria-label="Pagina-menu"
19
+ :options="[{ label: 'Bekijken', action: () => {} }, { label: 'Verwijderen', action: () => {} }]"
20
+ />
21
+ </template>
22
+ `
23
+
24
+ export const Default: Story = {
25
+ args: {
26
+ title: 'Welkomstpagina',
27
+ mockup: 'introduction',
28
+ icon: 'hand-wave',
29
+ },
30
+ render: (args) => ({
31
+ components: { PageCard },
32
+ setup() {
33
+ return { args }
34
+ },
35
+ template: '<PageCard v-bind="args" class="max-w-sm" />',
36
+ }),
37
+ }
38
+
39
+ export const WithMenu: Story = {
40
+ args: {
41
+ title: 'Over ons',
42
+ mockup: 'about',
43
+ icon: 'users',
44
+ selectable: true,
45
+ },
46
+ render: (args) => ({
47
+ components: { PageCard, NexxtDropdownButton },
48
+ setup() {
49
+ return { args }
50
+ },
51
+ template: `<PageCard v-bind="args" class="max-w-sm">${ACTIONS_SLOT}</PageCard>`,
52
+ }),
53
+ }
54
+
55
+ export const Selected: Story = {
56
+ args: {
57
+ title: 'Waarde-indicatie',
58
+ mockup: 'valuation',
59
+ icon: 'circle-euro',
60
+ selectable: true,
61
+ selected: true,
62
+ },
63
+ render: (args) => ({
64
+ components: { PageCard },
65
+ setup() {
66
+ return { args }
67
+ },
68
+ template: '<PageCard v-bind="args" class="max-w-sm" />',
69
+ }),
70
+ }
71
+
72
+ export const Dragging: Story = {
73
+ args: {
74
+ title: 'Courtage',
75
+ mockup: 'packages',
76
+ icon: 'piggy-bank',
77
+ dragging: true,
78
+ },
79
+ render: (args) => ({
80
+ components: { PageCard },
81
+ setup() {
82
+ return { args }
83
+ },
84
+ template: '<PageCard v-bind="args" class="max-w-sm" />',
85
+ }),
86
+ }
87
+
88
+ export const EmptyPreview: Story = {
89
+ args: {
90
+ title: 'Overige pagina',
91
+ icon: 'file-lines',
92
+ },
93
+ render: (args) => ({
94
+ components: { PageCard },
95
+ setup() {
96
+ return { args }
97
+ },
98
+ template: '<PageCard v-bind="args" class="max-w-sm" />',
99
+ }),
100
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import PageCard from './PageCard.vue'
4
+
5
+ describe('PageCard', () => {
6
+ it('renders the title and icon in the footer', () => {
7
+ const wrapper = mount(PageCard, { props: { title: 'Welkomstpagina', icon: 'hand-wave' } })
8
+
9
+ expect(wrapper.find('h3').text()).toBe('Welkomstpagina')
10
+ expect(wrapper.find('.fa-hand-wave').exists()).toBe(true)
11
+ })
12
+
13
+ it('renders the preview and actions slots', () => {
14
+ const wrapper = mount(PageCard, {
15
+ props: { title: 'Welkomstpagina' },
16
+ slots: {
17
+ preview: '<svg data-test="mockup" />',
18
+ actions: '<button data-test="menu">…</button>',
19
+ },
20
+ })
21
+
22
+ expect(wrapper.find('[data-test="mockup"]').exists()).toBe(true)
23
+ expect(wrapper.find('[data-test="menu"]').exists()).toBe(true)
24
+ })
25
+
26
+ it('draws the mockup for the given type in the preview area', () => {
27
+ const wrapper = mount(PageCard, { props: { title: 'Over ons', mockup: 'about' } })
28
+
29
+ expect(wrapper.find('svg').exists()).toBe(true)
30
+ })
31
+
32
+ it('lets the preview slot win over the mockup', () => {
33
+ const wrapper = mount(PageCard, {
34
+ props: { title: 'Over ons', mockup: 'about' },
35
+ slots: { preview: '<img data-test="thumb" />' },
36
+ })
37
+
38
+ expect(wrapper.find('[data-test="thumb"]').exists()).toBe(true)
39
+ expect(wrapper.find('svg').exists()).toBe(false)
40
+ })
41
+
42
+ it('renders a div and no select button by default', () => {
43
+ const wrapper = mount(PageCard, { props: { title: 'Welkomstpagina' } })
44
+
45
+ expect(wrapper.element.tagName).toBe('DIV')
46
+ expect(wrapper.find('button').exists()).toBe(false)
47
+ })
48
+
49
+ it('renders a stretched button with the title as name when selectable', () => {
50
+ const wrapper = mount(PageCard, { props: { title: 'Welkomstpagina', selectable: true } })
51
+
52
+ const button = wrapper.find('button')
53
+ expect(button.attributes('type')).toBe('button')
54
+ expect(button.attributes('aria-label')).toBe('Welkomstpagina')
55
+ expect(button.classes()).toContain('inset-0')
56
+ // The heading must stay real document structure next to the button, not inside it.
57
+ expect(wrapper.find('h3').element.closest('button')).toBeNull()
58
+ })
59
+
60
+ it('emits select from the button', async () => {
61
+ const wrapper = mount(PageCard, { props: { title: 'Welkomstpagina', selectable: true } })
62
+
63
+ await wrapper.find('button').trigger('click')
64
+
65
+ expect(wrapper.emitted('select')).toHaveLength(1)
66
+ })
67
+
68
+ it('draws the blue border when selected or dragging', () => {
69
+ const selected = mount(PageCard, {
70
+ props: { title: 'Welkomstpagina', selectable: true, selected: true },
71
+ })
72
+ const dragging = mount(PageCard, { props: { title: 'Welkomstpagina', dragging: true } })
73
+ const plain = mount(PageCard, { props: { title: 'Welkomstpagina' } })
74
+
75
+ expect(selected.classes()).toContain('border-cornflower-blue-500')
76
+ expect(selected.find('button').attributes('aria-current')).toBe('true')
77
+ expect(dragging.classes()).toContain('border-cornflower-blue-500')
78
+ expect(dragging.classes()).toContain('opacity-60')
79
+ expect(plain.classes()).toContain('border-gray-200')
80
+ })
81
+
82
+ it('passes class and attributes through to the card', () => {
83
+ const wrapper = mount(PageCard, {
84
+ props: { title: 'Welkomstpagina' },
85
+ attrs: { class: 'max-w-md', 'data-key': '7' },
86
+ })
87
+
88
+ expect(wrapper.classes()).toContain('max-w-md')
89
+ expect(wrapper.attributes('data-key')).toBe('7')
90
+ })
91
+ })