@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,138 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { expect, userEvent, within } from 'storybook/test'
3
+ import { ref } from 'vue'
4
+ import EditorAnchor from './EditorAnchor.vue'
5
+ import type { EditorAnchorValue } from './EditorAnchor.vue'
6
+
7
+ export default {
8
+ title: 'Components/Editor Components/Molecules/Editor Anchor',
9
+ component: EditorAnchor,
10
+ decorators: [() => ({ template: '<div class="w-100 mx-auto"><story /></div>' })],
11
+ parameters: {
12
+ // New and clean, so axe fails the run here instead of reporting.
13
+ a11y: { test: 'error' },
14
+ },
15
+ argTypes: {
16
+ label: { control: 'text' },
17
+ showLabel: { control: 'boolean' },
18
+ disabled: { control: 'boolean' },
19
+ },
20
+ } satisfies Meta<typeof EditorAnchor>
21
+
22
+ type Story = StoryObj<typeof EditorAnchor>
23
+
24
+ /**
25
+ * Any computed CSS colour as sRGB channels. Tailwind v4 colours compute to
26
+ * `oklch()`, so the colour is painted on a 1px canvas and read back.
27
+ */
28
+ const toRgb = (color: string) => {
29
+ const context = document.createElement('canvas').getContext('2d')!
30
+ context.fillStyle = color
31
+ context.fillRect(0, 0, 1, 1)
32
+ const [r, g, b] = context.getImageData(0, 0, 1, 1).data
33
+ return [r, g, b]
34
+ }
35
+
36
+ /** WCAG 2 contrast ratio of two CSS colours. */
37
+ const contrast = (a: string, b: string) => {
38
+ const luminance = (color: string) => {
39
+ const [r, g, b] = toRgb(color).map((channel) => {
40
+ const c = channel / 255
41
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
42
+ })
43
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
44
+ }
45
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x)
46
+ return (light + 0.05) / (dark + 0.05)
47
+ }
48
+
49
+ /** Renders the anchor with its own model, starting at `initial`, and shows the value. */
50
+ const withModel =
51
+ (initial: EditorAnchorValue | null): Story['render'] =>
52
+ (args) => ({
53
+ components: { EditorAnchor },
54
+ setup() {
55
+ const anchor = ref<EditorAnchorValue | null>(initial)
56
+ return { args, anchor }
57
+ },
58
+ template: `
59
+ <div class="flex flex-col gap-2">
60
+ <EditorAnchor v-bind="args" v-model="anchor" />
61
+ <p class="small-normal text-gray-700">{{ anchor ? anchor.y + ' ' + anchor.x : 'null' }}</p>
62
+ </div>
63
+ `,
64
+ })
65
+
66
+ export const Default: Story = {
67
+ render: withModel({ x: 'center', y: 'center' }),
68
+ // A focus ring that never shows passes every other check, so the play
69
+ // reaches the chosen cell by Tab and reads the ring off the computed style.
70
+ play: async ({ canvasElement }) => {
71
+ const middle = within(canvasElement).getByRole('radio', { name: 'Midden' })
72
+
73
+ await userEvent.tab()
74
+
75
+ await expect(middle).toHaveFocus()
76
+ await expect(middle.matches(':focus-visible')).toBe(true)
77
+ await expect(getComputedStyle(middle).outlineStyle).not.toBe('none')
78
+ await expect(getComputedStyle(middle).outlineWidth).toBe('2px')
79
+ // The ring is a non-text indicator on white, so it needs 3:1 (WCAG 1.4.11).
80
+ await expect(
81
+ contrast(getComputedStyle(middle).outlineColor, 'rgb(255, 255, 255)'),
82
+ ).toBeGreaterThanOrEqual(3)
83
+ },
84
+ }
85
+
86
+ export const BottomRight: Story = {
87
+ render: withModel({ x: 'right', y: 'bottom' }),
88
+ // The chosen cell stands out by its fill and by a larger dot, not by colour
89
+ // alone: measured, so it holds whatever the tokens resolve to.
90
+ play: async ({ canvasElement }) => {
91
+ const canvas = within(canvasElement)
92
+ const chosen = canvas.getByRole('radio', { name: 'Rechtsonder' })
93
+ const other = canvas.getByRole('radio', { name: 'Linksboven' })
94
+ const dotWidth = (cell: HTMLElement) =>
95
+ cell.querySelector('span')!.getBoundingClientRect().width
96
+
97
+ await expect(chosen).toHaveAttribute('aria-checked', 'true')
98
+ await expect(dotWidth(chosen)).toBeGreaterThan(dotWidth(other))
99
+ await expect(getComputedStyle(chosen).backgroundColor).not.toBe(
100
+ getComputedStyle(other).backgroundColor,
101
+ )
102
+ // The dot is a graphical object, so it needs 3:1 against the fill (WCAG 1.4.11).
103
+ await expect(
104
+ contrast(
105
+ getComputedStyle(chosen.querySelector('span')!).backgroundColor,
106
+ getComputedStyle(chosen).backgroundColor,
107
+ ),
108
+ ).toBeGreaterThanOrEqual(3)
109
+ },
110
+ }
111
+
112
+ /** A stored position that no cell stands for: nothing is checked, the middle takes the tab stop. */
113
+ export const NoChoice: Story = {
114
+ render: withModel(null),
115
+ play: async ({ canvasElement }) => {
116
+ const radios = within(canvasElement).getAllByRole('radio')
117
+
118
+ radios.forEach((radio) => expect(radio).toHaveAttribute('aria-checked', 'false'))
119
+ await userEvent.tab()
120
+ await expect(within(canvasElement).getByRole('radio', { name: 'Midden' })).toHaveFocus()
121
+ },
122
+ }
123
+
124
+ export const Disabled: Story = {
125
+ args: { disabled: true },
126
+ render: withModel({ x: 'left', y: 'top' }),
127
+ // Every cell, the chosen one included, shows that it cannot be used.
128
+ play: async ({ canvasElement }) => {
129
+ const radios = within(canvasElement).getAllByRole('radio')
130
+
131
+ radios.forEach((radio) => expect(getComputedStyle(radio).cursor).toBe('not-allowed'))
132
+ },
133
+ }
134
+
135
+ export const WithLabel: Story = {
136
+ args: { showLabel: true },
137
+ render: withModel({ x: 'center', y: 'top' }),
138
+ }
@@ -0,0 +1,344 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mount, type VueWrapper } from '@vue/test-utils'
3
+ import EditorAnchor, { EDITOR_ANCHOR_OPTIONS, type EditorAnchorValue } from './EditorAnchor.vue'
4
+
5
+ const DUTCH_NAMES = [
6
+ 'Linksboven',
7
+ 'Boven',
8
+ 'Rechtsboven',
9
+ 'Links',
10
+ 'Midden',
11
+ 'Rechts',
12
+ 'Linksonder',
13
+ 'Onder',
14
+ 'Rechtsonder',
15
+ ]
16
+
17
+ const mounted: VueWrapper[] = []
18
+
19
+ /**
20
+ * Mounts with a parent that owns the model, so a chosen cell flows back in as
21
+ * the new value, like `v-model` does. Attached to the document, so focus moves
22
+ * can be read from `document.activeElement`.
23
+ */
24
+ const mountAnchor = (
25
+ modelValue: EditorAnchorValue | null,
26
+ props: Record<string, unknown> = {},
27
+ attrs: Record<string, unknown> = {},
28
+ ) => {
29
+ const wrapper = mount(EditorAnchor, {
30
+ props: {
31
+ modelValue,
32
+ 'onUpdate:modelValue': (value: EditorAnchorValue | null) =>
33
+ wrapper.setProps({ modelValue: value }),
34
+ ...props,
35
+ },
36
+ attrs,
37
+ attachTo: document.body,
38
+ })
39
+ mounted.push(wrapper)
40
+ return wrapper
41
+ }
42
+
43
+ afterEach(() => {
44
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount())
45
+ })
46
+
47
+ const radios = (wrapper: VueWrapper) => wrapper.findAll('[role="radio"]')
48
+
49
+ const names = (wrapper: VueWrapper) =>
50
+ radios(wrapper).map((radio) => radio.attributes('aria-label'))
51
+
52
+ const checked = (wrapper: VueWrapper) =>
53
+ radios(wrapper).findIndex((radio) => radio.attributes('aria-checked') === 'true')
54
+
55
+ const tabStops = (wrapper: VueWrapper) =>
56
+ radios(wrapper)
57
+ .map((radio, index) => (radio.attributes('tabindex') === '0' ? index : null))
58
+ .filter((index) => index !== null)
59
+
60
+ const press = async (wrapper: VueWrapper, key: string) => {
61
+ const focused = radios(wrapper).find((radio) => radio.element === document.activeElement)
62
+ if (!focused) throw new Error('No cell has focus')
63
+ await focused.trigger('keydown', { key })
64
+ }
65
+
66
+ const MIDDLE = { x: 'center', y: 'center' } as const
67
+
68
+ describe('EditorAnchor', () => {
69
+ it('renders a radiogroup of nine radios named with the Dutch defaults', () => {
70
+ const wrapper = mountAnchor(MIDDLE)
71
+
72
+ expect(wrapper.get('[role="radiogroup"]').attributes('aria-label')).toBe(
73
+ 'Plek van de afbeelding',
74
+ )
75
+ expect(names(wrapper)).toEqual(DUTCH_NAMES)
76
+ })
77
+
78
+ it('names the radiogroup with a custom label', () => {
79
+ const wrapper = mountAnchor(MIDDLE, { label: 'Anchor' })
80
+
81
+ expect(wrapper.get('[role="radiogroup"]').attributes('aria-label')).toBe('Anchor')
82
+ })
83
+
84
+ it('shows no header by default', () => {
85
+ const wrapper = mountAnchor(MIDDLE)
86
+
87
+ expect(wrapper.find('p').exists()).toBe(false)
88
+ })
89
+
90
+ it('names the radiogroup by the visible header when showLabel is set', () => {
91
+ const wrapper = mountAnchor(MIDDLE, { showLabel: true })
92
+ const header = wrapper.get('p')
93
+ const group = wrapper.get('[role="radiogroup"]')
94
+
95
+ expect(header.text()).toBe('Plek van de afbeelding')
96
+ expect(group.attributes('aria-labelledby')).toBe(header.attributes('id'))
97
+ expect(group.attributes('aria-label')).toBeUndefined()
98
+ })
99
+
100
+ it('replaces only the overridden message keys', () => {
101
+ const wrapper = mountAnchor(MIDDLE, { messages: { center: 'Centre', bottomRight: 'BR' } })
102
+
103
+ expect(names(wrapper)).toEqual([
104
+ ...DUTCH_NAMES.slice(0, 4),
105
+ 'Centre',
106
+ ...DUTCH_NAMES.slice(5, 8),
107
+ 'BR',
108
+ ])
109
+ })
110
+
111
+ it('puts attrs on the radiogroup', () => {
112
+ const wrapper = mountAnchor(MIDDLE, {}, { 'aria-describedby': 'hint', class: 'shrink-0' })
113
+ const group = wrapper.get('[role="radiogroup"]')
114
+
115
+ expect(group.attributes('aria-describedby')).toBe('hint')
116
+ expect(group.classes()).toContain('shrink-0')
117
+ })
118
+
119
+ it('checks the cell of the model value', () => {
120
+ const wrapper = mountAnchor({ x: 'right', y: 'bottom' })
121
+
122
+ expect(checked(wrapper)).toBe(8)
123
+ expect(
124
+ radios(wrapper).filter((radio) => radio.attributes('aria-checked') === 'true'),
125
+ ).toHaveLength(1)
126
+ })
127
+
128
+ it('checks no cell for null', () => {
129
+ const wrapper = mountAnchor(null)
130
+
131
+ expect(checked(wrapper)).toBe(-1)
132
+ })
133
+
134
+ describe('roving tabindex', () => {
135
+ it('has exactly one tab stop, on the chosen cell', () => {
136
+ const wrapper = mountAnchor({ x: 'left', y: 'top' })
137
+
138
+ expect(tabStops(wrapper)).toEqual([0])
139
+ })
140
+
141
+ it('puts the tab stop on the middle while no cell is chosen', () => {
142
+ const wrapper = mountAnchor(null)
143
+
144
+ expect(tabStops(wrapper)).toEqual([4])
145
+ })
146
+
147
+ it('moves the tab stop along with the choice', async () => {
148
+ const wrapper = mountAnchor(MIDDLE)
149
+
150
+ await radios(wrapper)[2].trigger('click')
151
+
152
+ expect(tabStops(wrapper)).toEqual([2])
153
+ })
154
+ })
155
+
156
+ describe('click', () => {
157
+ it('emits the clicked cell', async () => {
158
+ const wrapper = mountAnchor(MIDDLE)
159
+
160
+ await radios(wrapper)[6].trigger('click')
161
+
162
+ expect(wrapper.emitted('update:modelValue')).toEqual([[{ x: 'left', y: 'bottom' }]])
163
+ })
164
+
165
+ it('emits a cell when none was chosen', async () => {
166
+ const wrapper = mountAnchor(null)
167
+
168
+ await radios(wrapper)[4].trigger('click')
169
+
170
+ expect(wrapper.emitted('update:modelValue')).toEqual([[MIDDLE]])
171
+ })
172
+
173
+ it('emits nothing for the cell that is already chosen', async () => {
174
+ const wrapper = mountAnchor({ x: 'right', y: 'top' })
175
+
176
+ await radios(wrapper)[2].trigger('click')
177
+
178
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
179
+ })
180
+ })
181
+
182
+ describe('keyboard', () => {
183
+ it('moves and chooses in two dimensions', async () => {
184
+ const wrapper = mountAnchor(MIDDLE)
185
+ ;(radios(wrapper)[4].element as HTMLElement).focus()
186
+
187
+ await press(wrapper, 'ArrowUp')
188
+ expect(checked(wrapper)).toBe(1)
189
+ await press(wrapper, 'ArrowRight')
190
+ expect(checked(wrapper)).toBe(2)
191
+ await press(wrapper, 'ArrowDown')
192
+ expect(checked(wrapper)).toBe(5)
193
+ await press(wrapper, 'ArrowLeft')
194
+ expect(checked(wrapper)).toBe(4)
195
+
196
+ expect(document.activeElement).toBe(radios(wrapper)[4].element)
197
+ expect(wrapper.emitted('update:modelValue')).toEqual([
198
+ [{ x: 'center', y: 'top' }],
199
+ [{ x: 'right', y: 'top' }],
200
+ [{ x: 'right', y: 'center' }],
201
+ [MIDDLE],
202
+ ])
203
+ })
204
+
205
+ it('stops at the edge instead of wrapping, and emits nothing there', async () => {
206
+ const wrapper = mountAnchor({ x: 'left', y: 'top' })
207
+ ;(radios(wrapper)[0].element as HTMLElement).focus()
208
+
209
+ await press(wrapper, 'ArrowLeft')
210
+ await press(wrapper, 'ArrowUp')
211
+
212
+ expect(checked(wrapper)).toBe(0)
213
+ expect(document.activeElement).toBe(radios(wrapper)[0].element)
214
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
215
+
216
+ await press(wrapper, 'End')
217
+ await press(wrapper, 'ArrowRight')
218
+ await press(wrapper, 'ArrowDown')
219
+
220
+ expect(checked(wrapper)).toBe(8)
221
+ expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
222
+ })
223
+
224
+ it('jumps to top left on Home and to bottom right on End', async () => {
225
+ const wrapper = mountAnchor(MIDDLE)
226
+ ;(radios(wrapper)[4].element as HTMLElement).focus()
227
+
228
+ await press(wrapper, 'Home')
229
+ expect(checked(wrapper)).toBe(0)
230
+ expect(document.activeElement).toBe(radios(wrapper)[0].element)
231
+
232
+ await press(wrapper, 'End')
233
+ expect(checked(wrapper)).toBe(8)
234
+ expect(document.activeElement).toBe(radios(wrapper)[8].element)
235
+ })
236
+
237
+ it('chooses from the middle when no cell was chosen', async () => {
238
+ const wrapper = mountAnchor(null)
239
+ ;(radios(wrapper)[4].element as HTMLElement).focus()
240
+
241
+ await press(wrapper, 'ArrowDown')
242
+
243
+ expect(wrapper.emitted('update:modelValue')).toEqual([[{ x: 'center', y: 'bottom' }]])
244
+ })
245
+
246
+ // A real, cancelable event: VTU's trigger hides the event it dispatches, so
247
+ // defaultPrevented could not be read from it.
248
+ const dispatch = (wrapper: VueWrapper, init: KeyboardEventInit) => {
249
+ const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
250
+ radios(wrapper)[4].element.dispatchEvent(event)
251
+ return event
252
+ }
253
+
254
+ it('prevents the default of a plain arrow it handles', () => {
255
+ const wrapper = mountAnchor(MIDDLE)
256
+
257
+ expect(dispatch(wrapper, { key: 'ArrowLeft' }).defaultPrevented).toBe(true)
258
+ })
259
+
260
+ it.each([
261
+ ['Alt+ArrowLeft', { key: 'ArrowLeft', altKey: true }],
262
+ ['Meta+ArrowRight', { key: 'ArrowRight', metaKey: true }],
263
+ ['Ctrl+Home', { key: 'Home', ctrlKey: true }],
264
+ ['Shift+End', { key: 'End', shiftKey: true }],
265
+ ])('leaves %s to the browser', (_, init) => {
266
+ const wrapper = mountAnchor(MIDDLE)
267
+
268
+ const event = dispatch(wrapper, init)
269
+
270
+ expect(event.defaultPrevented).toBe(false)
271
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
272
+ expect(checked(wrapper)).toBe(4)
273
+ })
274
+
275
+ it('leaves other keys alone', async () => {
276
+ const wrapper = mountAnchor(MIDDLE)
277
+ ;(radios(wrapper)[4].element as HTMLElement).focus()
278
+
279
+ await press(wrapper, 'a')
280
+
281
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
282
+ })
283
+ })
284
+
285
+ describe('disabled', () => {
286
+ it('disables every cell and marks the group', () => {
287
+ const wrapper = mountAnchor(MIDDLE, { disabled: true })
288
+
289
+ radios(wrapper).forEach((radio) => expect(radio.attributes('disabled')).toBeDefined())
290
+ expect(wrapper.get('[role="radiogroup"]').attributes('aria-disabled')).toBe('true')
291
+ })
292
+
293
+ it('emits nothing on click or on the keyboard', async () => {
294
+ const wrapper = mountAnchor(MIDDLE, { disabled: true })
295
+
296
+ await radios(wrapper)[0].trigger('click')
297
+ await radios(wrapper)[4].trigger('keydown', { key: 'End' })
298
+
299
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
300
+ })
301
+
302
+ it('leaves aria-disabled off when enabled', () => {
303
+ const wrapper = mountAnchor(MIDDLE)
304
+
305
+ expect(wrapper.get('[role="radiogroup"]').attributes('aria-disabled')).toBeUndefined()
306
+ })
307
+ })
308
+ })
309
+
310
+ // The consumer maps its stored positions onto these cells, so the list is a
311
+ // contract: any change here changes what the app recognises.
312
+ describe('EDITOR_ANCHOR_OPTIONS', () => {
313
+ it('lists the nine cells in reading order', () => {
314
+ expect(EDITOR_ANCHOR_OPTIONS).toEqual([
315
+ { x: 'left', y: 'top' },
316
+ { x: 'center', y: 'top' },
317
+ { x: 'right', y: 'top' },
318
+ { x: 'left', y: 'center' },
319
+ { x: 'center', y: 'center' },
320
+ { x: 'right', y: 'center' },
321
+ { x: 'left', y: 'bottom' },
322
+ { x: 'center', y: 'bottom' },
323
+ { x: 'right', y: 'bottom' },
324
+ ])
325
+ })
326
+ })
327
+
328
+ // Recorded before the keyboard moved to the shared useRadioGrid composable: the
329
+ // markup in every state has to stay what it was.
330
+ describe('EditorAnchor markup', () => {
331
+ it.each<
332
+ [string, { modelValue: EditorAnchorValue | null; disabled?: boolean; showLabel?: boolean }]
333
+ >([
334
+ ['a chosen cell', { modelValue: { x: 'right', y: 'bottom' } }],
335
+ ['no choice', { modelValue: null }],
336
+ ['disabled', { modelValue: { x: 'left', y: 'top' }, disabled: true }],
337
+ ['a visible label', { modelValue: { x: 'center', y: 'center' }, showLabel: true }],
338
+ ])('renders unchanged with %s', (_, props) => {
339
+ const wrapper = mount(EditorAnchor, { props, attrs: { 'aria-describedby': 'hint' } })
340
+
341
+ expect(wrapper.html()).toMatchSnapshot()
342
+ wrapper.unmount()
343
+ })
344
+ })
@@ -0,0 +1,87 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import EditorBackgroundSize from './EditorBackgroundSize.vue'
4
+ import type { EditorBackgroundSizeValue } from './EditorBackgroundSize.vue'
5
+
6
+ export default {
7
+ title: 'Components/Editor Components/Molecules/Editor Background Size',
8
+ component: EditorBackgroundSize,
9
+ decorators: [() => ({ template: '<div class="w-100 mx-auto"><story /></div>' })],
10
+ argTypes: {
11
+ label: { control: 'text' },
12
+ bare: { control: 'boolean' },
13
+ },
14
+ } satisfies Meta<typeof EditorBackgroundSize>
15
+
16
+ type Story = StoryObj<typeof EditorBackgroundSize>
17
+
18
+ export const Default: Story = {
19
+ render: (args) => ({
20
+ components: { EditorBackgroundSize },
21
+ setup() {
22
+ const size = ref<EditorBackgroundSizeValue>('bg-cover')
23
+ return { args, size }
24
+ },
25
+ // The value is shown as-is: it is the Tailwind class the editor applies.
26
+ template: `
27
+ <div>
28
+ <EditorBackgroundSize v-bind="args" v-model="size" />
29
+ <p class="small-normal text-gray-700">{{ size }}</p>
30
+ </div>
31
+ `,
32
+ }),
33
+ }
34
+
35
+ export const CustomMessages: Story = {
36
+ args: {
37
+ label: 'Fit',
38
+ messages: { cover: 'Cover', contain: 'Contain', auto: 'Original' },
39
+ },
40
+ render: (args) => ({
41
+ components: { EditorBackgroundSize },
42
+ setup() {
43
+ const size = ref<EditorBackgroundSizeValue>('bg-cover')
44
+ return { args, size }
45
+ },
46
+ template: `<EditorBackgroundSize v-bind="args" v-model="size" />`,
47
+ }),
48
+ }
49
+
50
+ /**
51
+ * A subset: the editor offers only these two here. Passed as contain, cover, and
52
+ * still shown in the canonical order, cover first.
53
+ */
54
+ export const Subset: Story = {
55
+ args: { options: ['bg-contain', 'bg-cover'] },
56
+ render: (args) => ({
57
+ components: { EditorBackgroundSize },
58
+ setup() {
59
+ const size = ref<EditorBackgroundSizeValue>('bg-cover')
60
+ return { args, size }
61
+ },
62
+ template: `<EditorBackgroundSize v-bind="args" v-model="size" />`,
63
+ }),
64
+ }
65
+
66
+ /**
67
+ * Only the options, for a consumer that renders its own block around them. The
68
+ * header and explanation here are the consumer's; the label still names the
69
+ * toolbar for assistive technology.
70
+ */
71
+ export const Bare: Story = {
72
+ args: { bare: true, label: 'Weergave' },
73
+ render: (args) => ({
74
+ components: { EditorBackgroundSize },
75
+ setup() {
76
+ const size = ref<EditorBackgroundSizeValue>('bg-auto')
77
+ return { args, size }
78
+ },
79
+ template: `
80
+ <div>
81
+ <p class="small-normal mb-1 text-gray-900">Weergave</p>
82
+ <p class="extra-small-normal mb-2 text-gray-700">Hoe de afbeelding het blok vult.</p>
83
+ <EditorBackgroundSize v-bind="args" v-model="size" />
84
+ </div>
85
+ `,
86
+ }),
87
+ }