@nexxtmove/ui 2.6.0 → 2.8.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 (229) hide show
  1. package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +7 -5
  2. package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +4 -1
  3. package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +3 -1
  4. package/dist/index.js +95 -172
  5. package/dist/nuxt.js +0 -1
  6. package/package.json +38 -36
  7. package/src/attribute-contract.test.ts +151 -0
  8. package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
  9. package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
  10. package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
  11. package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
  12. package/src/components/atoms/Button/Button.stories.ts +290 -0
  13. package/src/components/atoms/Button/Button.test.ts +406 -0
  14. package/src/components/atoms/Card/Card.stories.ts +45 -0
  15. package/src/components/atoms/Card/Card.test.ts +46 -0
  16. package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
  17. package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
  18. package/src/components/atoms/Chip/Chip.stories.ts +274 -0
  19. package/src/components/atoms/Chip/Chip.test.ts +230 -0
  20. package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
  21. package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
  22. package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
  23. package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
  24. package/src/components/atoms/Icon/Icon.stories.ts +63 -0
  25. package/src/components/atoms/Icon/Icon.test.ts +89 -0
  26. package/src/components/atoms/Loader/Loader.stories.ts +36 -0
  27. package/src/components/atoms/Loader/Loader.test.ts +40 -0
  28. package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
  29. package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
  30. package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
  31. package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
  32. package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
  33. package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
  34. package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
  35. package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
  36. package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
  37. package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
  38. package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
  39. package/src/components/atoms/Slider/Slider.stories.ts +151 -0
  40. package/src/components/atoms/Slider/Slider.test.ts +191 -0
  41. package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
  42. package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
  43. package/src/components/atoms/Switch/Switch.stories.ts +91 -0
  44. package/src/components/atoms/Switch/Switch.test.ts +183 -0
  45. package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
  46. package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
  47. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
  48. package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
  49. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
  50. package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
  51. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
  52. package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
  53. package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
  54. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
  55. package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
  56. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
  57. package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
  58. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
  59. package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
  60. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
  61. package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
  62. package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
  63. package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
  64. package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
  65. package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
  66. package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +633 -0
  67. package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +776 -0
  68. package/src/components/editor/molecules/EditorColor/EditorColor.vue +7 -5
  69. package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +832 -0
  70. package/src/components/editor/molecules/EditorColor/_ColorField.vue +20 -15
  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 +696 -0
  182. package/src/components/organisms/ColorPicker/ColorPicker.vue +12 -3
  183. package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
  184. package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
  185. package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
  186. package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
  187. package/src/components/organisms/Combobox/Combobox.vue +1 -1
  188. package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
  189. package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
  190. package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
  191. package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
  192. package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
  193. package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
  194. package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
  195. package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
  196. package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
  197. package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
  198. package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
  199. package/src/components/organisms/Header/Header.stories.ts +75 -0
  200. package/src/components/organisms/Header/Header.test.ts +233 -0
  201. package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
  202. package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
  203. package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
  204. package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
  205. package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
  206. package/src/components/organisms/Modal/Modal.stories.ts +350 -0
  207. package/src/components/organisms/Modal/Modal.test.ts +275 -0
  208. package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
  209. package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
  210. package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
  211. package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
  212. package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
  213. package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
  214. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
  215. package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  216. package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
  217. package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
  218. package/src/components/organisms/Table/Table.stories.ts +332 -0
  219. package/src/components/organisms/Table/Table.test.ts +320 -0
  220. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
  221. package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
  222. package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
  223. package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
  224. package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
  225. package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
  226. package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
  227. package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
  228. package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
  229. package/src/components/organisms/VideoPlayer/VideoPlayer.test.ts +439 -0
@@ -0,0 +1,792 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { expect, userEvent, waitFor, within } from 'storybook/test'
3
+ import { defineComponent, ref, type PropType } from 'vue'
4
+ import VideoControls from './VideoControls.vue'
5
+ import type { NexxtVideoControlsProps } from './VideoControls.vue'
6
+
7
+ /**
8
+ * A 14-second VP9 tour from .storybook/public (a villa with a pool, a white interior, an attic),
9
+ * so the bar is seen over light and darker frames in one video. WebM, so the Chromium of CI
10
+ * plays it too.
11
+ */
12
+ const VIDEO = '/demo-tour.webm'
13
+ /** The same tour cut to 9:16 (540×960), as a phone or TikTok video. */
14
+ const VERTICAL_VIDEO = '/demo-tour-vertical.webm'
15
+
16
+ /** Below 320px the time goes, below 160px sound and full screen (and with them the volume). */
17
+ const WIDTHS = [480, 380, 300, 150]
18
+
19
+ type ControlsArgs = Omit<NexxtVideoControlsProps, 'target' | 'fullscreenTarget'>
20
+
21
+ /** A video with the controls on it, as a player lays them out: the root is the full-screen target. */
22
+ const Stage = defineComponent({
23
+ components: { VideoControls },
24
+ props: {
25
+ width: { type: Number, default: 480 },
26
+ src: { type: String, default: VIDEO },
27
+ controls: { type: Object as PropType<ControlsArgs>, default: () => ({}) },
28
+ },
29
+ setup: () => ({ video: ref<HTMLVideoElement | null>(null), root: ref(null) }),
30
+ template: `
31
+ <div
32
+ ref="root"
33
+ class="relative shrink-0 overflow-hidden rounded-lg bg-black"
34
+ :style="{ width: width + 'px' }"
35
+ :data-width="width"
36
+ >
37
+ <video ref="video" :src="src" :muted="true" loop playsinline preload="auto" class="block w-full" />
38
+ <VideoControls :target="video" :fullscreen-target="root" v-bind="controls" />
39
+ </div>
40
+ `,
41
+ })
42
+
43
+ const meta = {
44
+ title: 'Components/Organisms/VideoControls',
45
+ component: VideoControls,
46
+ // The new stories are clean: axe runs as a gate here, not in the repo-wide reporting mode
47
+ parameters: { a11y: { test: 'error' } },
48
+ args: {
49
+ target: null,
50
+ variant: 'full',
51
+ name: 'Rondleiding',
52
+ autoHide: true,
53
+ hideDelay: 2500,
54
+ },
55
+ argTypes: {
56
+ target: { control: false },
57
+ fullscreenTarget: { control: false },
58
+ variant: { control: 'inline-radio', options: ['full', 'toggle'] },
59
+ showMute: { control: 'boolean', if: { arg: 'variant', eq: 'toggle' } },
60
+ name: { control: 'text' },
61
+ autoHide: { control: 'boolean' },
62
+ hideDelay: { control: 'number' },
63
+ },
64
+ } satisfies Meta<typeof VideoControls>
65
+
66
+ export default meta
67
+
68
+ type Story = StoryObj<typeof meta>
69
+
70
+ /** The args the stage passes on; the stage brings its own video and full-screen root. */
71
+ const controlsOf = ({
72
+ variant,
73
+ showMute,
74
+ name,
75
+ autoHide,
76
+ hideDelay,
77
+ messages,
78
+ }: ControlsArgs): ControlsArgs => ({
79
+ variant,
80
+ showMute,
81
+ name,
82
+ autoHide,
83
+ hideDelay,
84
+ messages,
85
+ })
86
+
87
+ const single: Story['render'] = (args) => ({
88
+ components: { Stage },
89
+ setup: () => ({ controls: controlsOf(args) }),
90
+ template: `<Stage :controls="controls" />`,
91
+ })
92
+
93
+ /**
94
+ * The bar. Over the light interior the dark capsule carries the contrast; over the darker
95
+ * frames the white hairline outlines it.
96
+ */
97
+ export const Full: Story = { render: single }
98
+
99
+ /**
100
+ * The corner: play/pause, with mute to its left on hover or focus. It hides while playing like
101
+ * the bar. Tab runs mute → play, as seen; play keeps its place when mute appears.
102
+ */
103
+ export const Toggle: Story = {
104
+ args: { variant: 'toggle' },
105
+ render: single,
106
+ play: async ({ canvasElement }) => {
107
+ const canvas = within(canvasElement)
108
+ const video = canvasElement.querySelector('video')!
109
+ const play = canvas.getByRole('button', { name: 'Afspelen: Rondleiding' })
110
+ const mute = canvas.getByRole('button', { name: 'Geluid aan: Rondleiding' })
111
+ // Loaded first, so the video has its final height before anything is measured
112
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(1), { timeout: 5000 })
113
+ const before = play.getBoundingClientRect()
114
+ await expect(getComputedStyle(mute).opacity).toBe('0')
115
+
116
+ // A pointer over the video brings mute, to the left of play, which stays where it was
117
+ await userEvent.hover(video)
118
+ await waitFor(() => expect(getComputedStyle(mute).opacity).toBe('1'))
119
+ await expect(mute.getBoundingClientRect().right).toBeLessThanOrEqual(
120
+ play.getBoundingClientRect().left,
121
+ )
122
+ const after = play.getBoundingClientRect()
123
+ await expect([after.left, after.top]).toEqual([before.left, before.top])
124
+
125
+ const speaker = () => glyphLeft(mute.querySelector('[data-sound-glyph] > *')!)
126
+ const mutedLeft = await speaker()
127
+ await userEvent.click(mute)
128
+ await expect(video.muted).toBe(false)
129
+ // Muted (speaker with a cross) to full (speaker with waves): the speaker stays put
130
+ await expect(Math.abs((await speaker()) - mutedLeft)).toBeLessThanOrEqual(0.5)
131
+ await expect(canvas.getByRole('button', { name: 'Geluid uit: Rondleiding' })).toBe(mute)
132
+ await userEvent.unhover(video)
133
+
134
+ // The keyboard reaches mute first, then play; the ring holds 3:1 as on the bar
135
+ mute.blur()
136
+ await userEvent.tab()
137
+ await expect(mute).toHaveFocus()
138
+ await waitFor(() => expect(getComputedStyle(mute).opacity).toBe('1'))
139
+ const style = getComputedStyle(mute)
140
+ await expect(style.outlineStyle).toBe('solid')
141
+ const ringColor = style.boxShadow
142
+ .split(/,(?![^(]*\))/)
143
+ .find((shadow) => / 2px$/.test(shadow.trim()))
144
+ ?.match(/(?:rgba?|oklch|oklab|color)\([^)]*\)/)?.[0]
145
+ await expect(
146
+ contrast(composite(style.outlineColor), composite(ringColor!)),
147
+ ).toBeGreaterThanOrEqual(3)
148
+ // The corner ring lies on the frame itself: on white the blue band carries it, on black the white
149
+ for (const frame of ['#ffffff', '#000000']) {
150
+ const backdrop = composite(frame)
151
+ const best = Math.max(
152
+ contrast(composite(ringColor!), backdrop),
153
+ contrast(composite(style.outlineColor), backdrop),
154
+ )
155
+ await expect(best).toBeGreaterThanOrEqual(3)
156
+ }
157
+ await userEvent.tab()
158
+ await expect(play).toHaveFocus()
159
+ },
160
+ }
161
+
162
+ /**
163
+ * The toggle without mute, for a background video that is always muted and may only be paused.
164
+ * Play/pause keeps the toggle's corner; hover and focus bring nothing else.
165
+ */
166
+ export const ToggleWithoutMute: Story = {
167
+ name: 'Toggle zonder geluid',
168
+ args: { variant: 'toggle', showMute: false },
169
+ render: single,
170
+ play: async ({ canvasElement }) => {
171
+ const canvas = within(canvasElement)
172
+ const video = canvasElement.querySelector('video')!
173
+ const stage = canvasElement.querySelector<HTMLElement>('[data-width]')!
174
+ const play = canvas.getByRole('button', { name: 'Afspelen: Rondleiding' })
175
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(1), { timeout: 5000 })
176
+ await expect(canvas.getAllByRole('button')).toHaveLength(1)
177
+
178
+ // The same corner as the toggle with mute: 12px from the right and the bottom
179
+ const corner = () => {
180
+ const box = play.getBoundingClientRect()
181
+ const frame = stage.getBoundingClientRect()
182
+ return [Math.round(frame.right - box.right), Math.round(frame.bottom - box.bottom)]
183
+ }
184
+ const before = corner()
185
+ await expect(before).toEqual([12, 12])
186
+
187
+ await userEvent.hover(video)
188
+ await expect(canvas.getAllByRole('button')).toHaveLength(1)
189
+ await expect(corner()).toEqual(before)
190
+ await userEvent.unhover(video)
191
+
192
+ await userEvent.tab()
193
+ await expect(play).toHaveFocus()
194
+ },
195
+ }
196
+
197
+ /** Whether an element takes part in the layout: `display: none` on it or an ancestor says no. */
198
+ const shown = (element: Element | null | undefined) => Boolean(element?.checkVisibility())
199
+
200
+ /**
201
+ * Where a Font Awesome glyph really is: its `::before` is swapped for a span with the same
202
+ * character and font for a moment, laid out the way the pseudo-element is. The `<i>` box says
203
+ * nothing, as the glyph is centred inside it (as in NexxtIconPicker's stories).
204
+ */
205
+ const glyphLeft = async (icon: Element) => {
206
+ await document.fonts.ready
207
+ const style = document.createElement('style')
208
+ style.textContent = '[data-glyph-measure]::before { content: none !important; }'
209
+ document.head.append(style)
210
+ const before = getComputedStyle(icon, '::before')
211
+ const span = document.createElement('span')
212
+ span.textContent = /^"(.*?)"/u.exec(before.content)?.[1] ?? ''
213
+ Object.assign(span.style, {
214
+ fontFamily: before.fontFamily,
215
+ fontWeight: before.fontWeight,
216
+ fontSize: before.fontSize,
217
+ lineHeight: before.lineHeight,
218
+ })
219
+ icon.setAttribute('data-glyph-measure', '')
220
+ icon.append(span)
221
+ const { left, width } = span.getBoundingClientRect()
222
+ span.remove()
223
+ icon.removeAttribute('data-glyph-measure')
224
+ style.remove()
225
+ await expect(width).toBeGreaterThan(0)
226
+ return left
227
+ }
228
+
229
+ /**
230
+ * Opens the volume popover of a stage with the focus on its sound button, and checks that it
231
+ * lies inside the player, clear of the rounded corners, so the player's overflow never clips it.
232
+ */
233
+ const expectPopoverInside = async (stage: HTMLElement) => {
234
+ const sound = stage.querySelector<HTMLElement>('button[aria-label^="Geluid"]')!
235
+ const popoverOf = () => stage.querySelector<HTMLElement>('[data-volume-popover]')
236
+ sound.focus()
237
+ await waitFor(() => expect(getComputedStyle(popoverOf()!).opacity).toBe('1'))
238
+ const popover = popoverOf()!
239
+
240
+ const box = popover.firstElementChild!.getBoundingClientRect()
241
+ const frame = stage.getBoundingClientRect()
242
+ const radius = parseFloat(getComputedStyle(stage).borderTopLeftRadius)
243
+ await expect(box.top).toBeGreaterThanOrEqual(frame.top + radius)
244
+ await expect(box.left).toBeGreaterThanOrEqual(frame.left)
245
+ await expect(box.right).toBeLessThanOrEqual(frame.right)
246
+ sound.blur()
247
+ }
248
+
249
+ /** Every part of the bar in one stage, found without the accessibility tree (hidden parts too). */
250
+ const parts = (stage: Element) => ({
251
+ play: stage.querySelector('button[aria-label="Afspelen"]'),
252
+ time: [...stage.querySelectorAll('[aria-hidden="true"]')].find((el) =>
253
+ /\d:\d\d \/ \d:\d\d/.test(el.textContent ?? ''),
254
+ ),
255
+ timeline: stage.querySelector('[aria-label="Tijdlijn"]'),
256
+ sound: stage.querySelector('button[aria-label^="Geluid"]'),
257
+ volume: stage.querySelector('[aria-label="Volume"]'),
258
+ fullscreen: stage.querySelector('button[aria-label^="Volledig scherm"]'),
259
+ })
260
+
261
+ /**
262
+ * Whether the stylesheets hold a rule for `selector` inside a media query matching `media`.
263
+ * Tailwind writes a variant it does not know as nothing at all, so this proves the variant
264
+ * compiled, independent of the device the test runs on.
265
+ */
266
+ const hasRuleInMedia = (selector: string, media: string) => {
267
+ const walk = (rules: CSSRuleList, inMedia: boolean): boolean =>
268
+ [...rules].some((rule) => {
269
+ if (rule instanceof CSSMediaRule) {
270
+ return walk(rule.cssRules, inMedia || rule.conditionText.includes(media))
271
+ }
272
+ if (rule instanceof CSSStyleRule) {
273
+ if (rule.selectorText.includes(selector) && inMedia) return true
274
+ return walk(rule.cssRules, inMedia)
275
+ }
276
+ return 'cssRules' in rule && walk((rule as CSSGroupingRule).cssRules, inMedia)
277
+ })
278
+
279
+ return [...document.styleSheets].some((sheet) => {
280
+ try {
281
+ return walk(sheet.cssRules, false)
282
+ } catch {
283
+ return false
284
+ }
285
+ })
286
+ }
287
+
288
+ /**
289
+ * A 9:16 video, at 360 and 270px wide: in portrait the bar is narrow, so the time is gone at
290
+ * 270. The bar stays inside the video's width, and the volume popover inside the video.
291
+ */
292
+ export const Vertical: Story = {
293
+ render: (args) => ({
294
+ components: { Stage },
295
+ setup: () => ({ controls: controlsOf(args), VERTICAL_VIDEO }),
296
+ template: `
297
+ <div class="flex flex-wrap items-end gap-4">
298
+ <Stage v-for="width in [360, 270]" :key="width" :src="VERTICAL_VIDEO" :width="width" :controls="controls" />
299
+ </div>
300
+ `,
301
+ }),
302
+ play: async ({ canvasElement }) => {
303
+ const stages = [...canvasElement.querySelectorAll<HTMLElement>('[data-width]')]
304
+ await expect(stages).toHaveLength(2)
305
+
306
+ for (const stage of stages) {
307
+ const width = Number(stage.dataset.width)
308
+ const video = stage.querySelector('video')!
309
+ const { play, time, timeline, sound } = parts(stage)
310
+
311
+ // Loaded in portrait, and drawn undistorted at the stage's width
312
+ await waitFor(() => expect(video.videoHeight / video.videoWidth).toBeCloseTo(16 / 9, 2), {
313
+ timeout: 5000,
314
+ })
315
+ const box = video.getBoundingClientRect()
316
+ await expect(box.width).toBe(width)
317
+ await expect(Math.abs(box.height - (width * 16) / 9)).toBeLessThan(1)
318
+
319
+ await expect(shown(play)).toBe(true)
320
+ await expect(shown(timeline)).toBe(true)
321
+ await expect(shown(sound)).toBe(true)
322
+ // The popover appears once the player is measured tall enough
323
+ await waitFor(() => expect(shown(stage.querySelector('[aria-label="Volume"]'))).toBe(true))
324
+ await expect(shown(time)).toBe(width >= 320)
325
+ await expectPopoverInside(stage)
326
+
327
+ const capsule = stage.querySelector('[role="group"]')!.getBoundingClientRect()
328
+ const frame = stage.getBoundingClientRect()
329
+ await expect(capsule.left).toBeGreaterThanOrEqual(frame.left)
330
+ await expect(capsule.right).toBeLessThanOrEqual(frame.right)
331
+ await expect(capsule.bottom).toBeLessThanOrEqual(frame.bottom)
332
+ }
333
+ },
334
+ }
335
+
336
+ /**
337
+ * At 480, 380, 300 and 150px video width. Play and the timeline always stay;
338
+ * the volume goes below 384px, the time below 320px, sound and full screen below 160px. The
339
+ * breakpoints are container queries on the bar, so they follow the video, not the viewport.
340
+ * On a touch screen (`pointer: coarse`) there is never a volume slider.
341
+ */
342
+ export const Widths: Story = {
343
+ render: (args) => ({
344
+ components: { Stage },
345
+ setup: () => {
346
+ const controls = controlsOf(args)
347
+ return { controls, WIDTHS }
348
+ },
349
+ template: `
350
+ <div class="flex flex-wrap items-end gap-4">
351
+ <Stage v-for="width in WIDTHS" :key="width" :width="width" :controls="controls" />
352
+ </div>
353
+ `,
354
+ }),
355
+ play: async ({ canvasElement }) => {
356
+ const stages = [...canvasElement.querySelectorAll<HTMLElement>('[data-width]')]
357
+ await expect(stages).toHaveLength(4)
358
+
359
+ for (const stage of stages) {
360
+ const width = Number(stage.getAttribute('data-width'))
361
+ const video = stage.querySelector('video')!
362
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(1), { timeout: 5000 })
363
+ const { play, time, timeline, sound, fullscreen } = parts(stage)
364
+
365
+ await expect(shown(play)).toBe(true)
366
+ await expect(shown(timeline)).toBe(true)
367
+ await expect(shown(time)).toBe(width >= 320)
368
+ await expect(shown(sound)).toBe(width >= 160)
369
+ // No touch screen here: the popover is there wherever the sound button is and the
370
+ // player is tall enough (the 150px one is neither)
371
+ await waitFor(() =>
372
+ expect(shown(stage.querySelector('[aria-label="Volume"]'))).toBe(width >= 160),
373
+ )
374
+ if (width >= 160) await expectPopoverInside(stage)
375
+ // Full screen is only offered where the frame allows it, as with any embed
376
+ if (fullscreen) await expect(shown(fullscreen)).toBe(width >= 160)
377
+ // The timeline keeps its room even in the narrowest bar
378
+ await expect(timeline!.getBoundingClientRect().width).toBeGreaterThanOrEqual(48)
379
+ }
380
+
381
+ await expect(hasRuleInMedia('pointer-coarse\\:opacity-100', 'pointer: coarse')).toBe(true)
382
+ },
383
+ }
384
+
385
+ /**
386
+ * Tab runs Play → Timeline → Sound → Volume (→ Full screen); the sliders follow the APG keys,
387
+ * and Space and M work with the focus anywhere in the bar, and nowhere else.
388
+ */
389
+ export const Keyboard: Story = {
390
+ render: single,
391
+ play: async ({ canvasElement }) => {
392
+ const canvas = within(canvasElement)
393
+ const video = canvasElement.querySelector('video')!
394
+ const timeline = canvas.getByRole('slider', { name: 'Tijdlijn' })
395
+ const capsule = canvas.getByRole('group', { name: 'Videobediening: Rondleiding' })
396
+
397
+ await waitFor(() => expect(timeline).toHaveAttribute('aria-valuemax', '13'), { timeout: 5000 })
398
+ // Waited for: the popover appears once the player is measured tall enough
399
+ const volume = await canvas.findByRole('slider', { name: 'Volume' })
400
+
401
+ await userEvent.tab()
402
+ await expect(canvas.getByRole('button', { name: 'Afspelen' })).toHaveFocus()
403
+ await userEvent.tab()
404
+ await expect(timeline).toHaveFocus()
405
+ await userEvent.tab()
406
+ await expect(canvas.getByRole('button', { name: 'Geluid aan' })).toHaveFocus()
407
+ await userEvent.tab()
408
+ await expect(volume).toHaveFocus()
409
+
410
+ timeline.focus()
411
+ await userEvent.keyboard('{ArrowRight}')
412
+ await expect(timeline).toHaveAttribute('aria-valuetext', '0:05 van 0:13')
413
+ await expect(timeline).toHaveAttribute('aria-valuenow', '5')
414
+ await userEvent.keyboard('{Home}')
415
+ await expect(timeline).toHaveAttribute('aria-valuetext', '0:00 van 0:13')
416
+ await userEvent.keyboard('{End}')
417
+ await expect(timeline).toHaveAttribute('aria-valuetext', '0:13 van 0:13')
418
+ await userEvent.keyboard('{Home}')
419
+
420
+ // Space on the timeline plays, and the bar stays while the focus is in it
421
+ await userEvent.keyboard(' ')
422
+ await waitFor(() => expect(video.paused).toBe(false))
423
+ await expect(canvas.getByRole('button', { name: 'Pauzeren' })).toBeInTheDocument()
424
+ // Past the hide delay on the video's own clock, rather than a fixed sleep
425
+ const playedFrom = video.currentTime
426
+ await waitFor(() => expect(video.currentTime - playedFrom).toBeGreaterThan(2.8), {
427
+ timeout: 10000,
428
+ })
429
+ await expect(getComputedStyle(capsule).opacity).toBe('1')
430
+ await userEvent.keyboard(' ')
431
+ await waitFor(() => expect(video.paused).toBe(true))
432
+
433
+ // M from the volume slider: the muted video gets its sound back
434
+ volume.focus()
435
+ await userEvent.keyboard('m')
436
+ await expect(video.muted).toBe(false)
437
+ await userEvent.keyboard('{ArrowDown}')
438
+ await expect(volume).toHaveAttribute('aria-valuetext', '90 procent')
439
+
440
+ // Outside the bar the keys are the page's
441
+ volume.blur()
442
+ await userEvent.keyboard('m')
443
+ await userEvent.keyboard(' ')
444
+ await expect(video.muted).toBe(false)
445
+ await expect(video.paused).toBe(true)
446
+ },
447
+ }
448
+
449
+ /**
450
+ * The demo has a real sound track. It starts muted (as an autoplay must); M brings the sound
451
+ * back, the volume slider steps it, Home on the slider mutes, and the sound button unmutes at
452
+ * half volume rather than at nothing.
453
+ */
454
+ export const Sound: Story = {
455
+ render: single,
456
+ play: async ({ canvasElement }) => {
457
+ const canvas = within(canvasElement)
458
+ const video = canvasElement.querySelector('video')! as HTMLVideoElement & {
459
+ webkitAudioDecodedByteCount?: number
460
+ }
461
+
462
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(2), { timeout: 5000 })
463
+ // Waited for: the popover appears once the player is measured tall enough
464
+ const volume = await canvas.findByRole('slider', { name: 'Volume' })
465
+ await HTMLMediaElement.prototype.play.call(video)
466
+ // There is sound to mute: the browser decodes the audio track
467
+ await waitFor(() => expect(video.webkitAudioDecodedByteCount ?? 1).toBeGreaterThan(0))
468
+ await expect(canvas.getByRole('button', { name: 'Geluid aan' })).toBeInTheDocument()
469
+ await expect(volume).toHaveAttribute('aria-valuetext', 'Gedempt')
470
+
471
+ volume.focus()
472
+ await userEvent.keyboard('m')
473
+ await expect(video.muted).toBe(false)
474
+ await expect(canvas.getByRole('button', { name: 'Geluid uit' })).toBeInTheDocument()
475
+ await userEvent.keyboard('{ArrowDown}')
476
+ await expect(video.volume).toBeCloseTo(0.9)
477
+ await expect(volume).toHaveAttribute('aria-valuetext', '90 procent')
478
+
479
+ await userEvent.keyboard('{Home}')
480
+ await expect(video.muted).toBe(true)
481
+ await expect(volume).toHaveAttribute('aria-valuetext', 'Gedempt')
482
+
483
+ await userEvent.click(canvas.getByRole('button', { name: 'Geluid aan' }))
484
+ await expect(video.muted).toBe(false)
485
+ await expect(video.volume).toBe(0.5)
486
+ await expect(volume).toHaveAttribute('aria-valuetext', '50 procent')
487
+ video.pause()
488
+
489
+ // The speaker holds still while the waves change: the glyph's left edge is the same at 0,
490
+ // low, middle, high and muted
491
+ const speaker = () => glyphLeft(canvasElement.querySelector('[data-sound-glyph] > *')!)
492
+ const lefts: number[] = []
493
+ volume.focus()
494
+ for (const keys of [
495
+ '{Home}',
496
+ '{ArrowUp}{ArrowUp}{ArrowUp}',
497
+ '{ArrowUp}{ArrowUp}',
498
+ '{End}',
499
+ 'm',
500
+ ]) {
501
+ await userEvent.keyboard(keys)
502
+ lefts.push(await speaker())
503
+ }
504
+ await expect(video.muted).toBe(true)
505
+ for (const left of lefts) await expect(Math.abs(left - lefts[0])).toBeLessThanOrEqual(0.5)
506
+ },
507
+ }
508
+
509
+ /**
510
+ * The knobs stay inside their tracks: at 0 the timeline knob does not cover the time, and at
511
+ * the end it does not run past the timeline; the volume knob likewise between its neighbours.
512
+ */
513
+ export const KnobsInsideTracks: Story = {
514
+ render: single,
515
+ play: async ({ canvasElement }) => {
516
+ const canvas = within(canvasElement)
517
+ const stage = canvasElement.querySelector('[data-width]')!
518
+ const timeline = canvas.getByRole('slider', { name: 'Tijdlijn' })
519
+ const time = parts(stage).time!
520
+ const rect = (element: Element) => element.getBoundingClientRect()
521
+ const knobOf = (slider: HTMLElement) => slider.lastElementChild!
522
+
523
+ await waitFor(() => expect(timeline).toHaveAttribute('aria-valuemax', '13'), { timeout: 5000 })
524
+ // Waited for: the popover appears once the player is measured tall enough
525
+ const volume = await canvas.findByRole('slider', { name: 'Volume' })
526
+
527
+ timeline.focus()
528
+ for (const key of ['{Home}', '{End}']) {
529
+ await userEvent.keyboard(key)
530
+ const knob = rect(knobOf(timeline))
531
+ await expect(knob.left).toBeGreaterThanOrEqual(rect(time).right)
532
+ await expect(knob.left).toBeGreaterThanOrEqual(rect(timeline).left)
533
+ await expect(knob.right).toBeLessThanOrEqual(rect(timeline).right)
534
+ }
535
+
536
+ volume.focus()
537
+ for (const key of ['{Home}', '{End}']) {
538
+ await userEvent.keyboard(key)
539
+ const knob = rect(knobOf(volume))
540
+ await expect(knob.bottom).toBeLessThanOrEqual(rect(volume).bottom)
541
+ await expect(knob.top).toBeGreaterThanOrEqual(rect(volume).top)
542
+ }
543
+
544
+ // The opening round the track is even: as far from its ends to the top and bottom edge as
545
+ // from its sides to the left and right edge; and the track runs on the sound button's axis
546
+ const popoverBox = rect(volume.parentElement!)
547
+ const trackBox = rect(volume.firstElementChild!)
548
+ const gaps = [
549
+ trackBox.top - popoverBox.top,
550
+ popoverBox.bottom - trackBox.bottom,
551
+ trackBox.left - popoverBox.left,
552
+ popoverBox.right - trackBox.right,
553
+ ]
554
+ for (const gap of gaps) await expect(Math.abs(gap - gaps[0])).toBeLessThanOrEqual(0.5)
555
+
556
+ const centre = (box: DOMRect) => box.left + box.width / 2
557
+ const sound = canvas.getByRole('button', { name: /^Geluid/ })
558
+ await expect(
559
+ Math.abs(centre(rect(volume.firstElementChild!)) - centre(rect(sound))),
560
+ ).toBeLessThanOrEqual(0.5)
561
+ },
562
+ }
563
+
564
+ /**
565
+ * While the video plays and nothing moves, the bar fades out after 2500 ms (opacity over
566
+ * 300 ms, no movement) but stays in the tab order: focus on a button brings it straight back.
567
+ */
568
+ export const AutoHide: Story = {
569
+ render: single,
570
+ play: async ({ canvasElement }) => {
571
+ const canvas = within(canvasElement)
572
+ const video = canvasElement.querySelector('video')!
573
+ const capsule = canvas.getByRole('group', { name: 'Videobediening: Rondleiding' })
574
+
575
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(2), { timeout: 5000 })
576
+ // The media method, not a story's play function (which the Storybook lint takes it for)
577
+ await HTMLMediaElement.prototype.play.call(video)
578
+ await expect(getComputedStyle(capsule).opacity).toBe('1')
579
+
580
+ await waitFor(() => expect(getComputedStyle(capsule).opacity).toBe('0'), { timeout: 4000 })
581
+ await expect(getComputedStyle(capsule).transitionDuration).toBe('0.3s')
582
+ await expect(getComputedStyle(capsule).pointerEvents).toBe('none')
583
+
584
+ await userEvent.tab()
585
+ await expect(canvas.getByRole('button', { name: 'Pauzeren' })).toHaveFocus()
586
+ await expect(getComputedStyle(capsule).transitionDuration).toBe('0.15s')
587
+ await waitFor(() => expect(getComputedStyle(capsule).opacity).toBe('1'))
588
+
589
+ // Keyboard focus keeps it up while the video plays
590
+ // Past the hide delay on the video's own clock, rather than a fixed sleep
591
+ const playedFrom = video.currentTime
592
+ await waitFor(() => expect(video.currentTime - playedFrom).toBeGreaterThan(2.8), {
593
+ timeout: 10000,
594
+ })
595
+ await expect(getComputedStyle(capsule).opacity).toBe('1')
596
+
597
+ // Mouse clicks (pause, play) keep the focus on the button, but no longer the bar
598
+ const play = canvas.getByRole('button', { name: 'Pauzeren' })
599
+ await userEvent.click(play)
600
+ await userEvent.click(play)
601
+ await waitFor(() => expect(video.paused).toBe(false))
602
+ await userEvent.unhover(play)
603
+ await expect(play).toHaveFocus()
604
+ await waitFor(() => expect(getComputedStyle(capsule).opacity).toBe('0'), { timeout: 4000 })
605
+
606
+ // The next key brings it back, and it stays
607
+ await userEvent.keyboard('{Shift}')
608
+ await waitFor(() => expect(getComputedStyle(capsule).opacity).toBe('1'))
609
+ video.pause()
610
+ },
611
+ }
612
+
613
+ /** sRGB of a CSS colour composited over `backdrop`, via a 1px canvas (handles oklch and alpha). */
614
+ const composite = (color: string, backdrop = 'transparent') => {
615
+ const context = document.createElement('canvas').getContext('2d')!
616
+ context.fillStyle = backdrop
617
+ context.fillRect(0, 0, 1, 1)
618
+ context.fillStyle = color
619
+ context.fillRect(0, 0, 1, 1)
620
+ const [r, g, b] = context.getImageData(0, 0, 1, 1).data
621
+ return [r, g, b] as const
622
+ }
623
+
624
+ const luminance = ([r, g, b]: readonly number[]) => {
625
+ const [lr, lg, lb] = [r, g, b].map((channel) => {
626
+ const c = channel / 255
627
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
628
+ })
629
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb
630
+ }
631
+
632
+ const contrast = (a: readonly number[], b: readonly number[]) => {
633
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
634
+ return (hi + 0.05) / (lo + 0.05)
635
+ }
636
+
637
+ /**
638
+ * The focus ring is two bands: white directly around the control, blue-600 outside it. The
639
+ * white band holds 3:1 against the capsule over a white and over a black frame, and the blue
640
+ * band holds 3:1 against the white one, so the ring reads on every frame.
641
+ */
642
+ export const FocusRing: Story = {
643
+ render: single,
644
+ play: async ({ canvasElement }) => {
645
+ const canvas = within(canvasElement)
646
+ const capsule = canvas.getByRole('group', { name: 'Videobediening: Rondleiding' })
647
+
648
+ // Loaded, so the player has its height and the volume popover is there
649
+ const video = canvasElement.querySelector('video')!
650
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(1), { timeout: 5000 })
651
+
652
+ await userEvent.tab()
653
+ const button = canvas.getByRole('button', { name: 'Afspelen' })
654
+ await expect(button).toHaveFocus()
655
+
656
+ const style = getComputedStyle(button)
657
+ await expect(style.outlineStyle).toBe('solid')
658
+ await expect(style.outlineWidth).toBe('2px')
659
+ await expect(style.outlineOffset).toBe('2px')
660
+
661
+ // Tailwind stacks several shadows, most of them transparent; the ring is the one with a spread
662
+ const ringColor = style.boxShadow
663
+ .split(/,(?![^(]*\))/)
664
+ .find((shadow) => / 2px$/.test(shadow.trim()))
665
+ ?.match(/(?:rgba?|oklch|oklab|color)\([^)]*\)/)?.[0]
666
+ await expect(ringColor).toBeDefined()
667
+
668
+ const capsuleColor = getComputedStyle(capsule.querySelector('[data-scrim]')!).backgroundColor
669
+ const outline = composite(style.outlineColor)
670
+ const ring = composite(ringColor!)
671
+
672
+ for (const frame of ['#ffffff', '#000000']) {
673
+ const backdrop = composite(capsuleColor, frame)
674
+ await expect(contrast(ring, backdrop)).toBeGreaterThanOrEqual(3)
675
+ }
676
+ await expect(contrast(outline, ring)).toBeGreaterThanOrEqual(3)
677
+
678
+ // The timeline's ring sits on its knob, which appears with the focus
679
+ await userEvent.tab()
680
+ const knob = canvas.getByRole('slider', { name: 'Tijdlijn' }).lastElementChild!
681
+ await waitFor(() => expect(getComputedStyle(knob).opacity).toBe('1'))
682
+ await expect(getComputedStyle(knob).outlineStyle).toBe('solid')
683
+
684
+ // The volume slider's ring is the same two bands, on its knob in the popover
685
+ await userEvent.tab()
686
+ await userEvent.tab()
687
+ const volume = await canvas.findByRole('slider', { name: 'Volume' })
688
+ await expect(volume).toHaveFocus()
689
+ const volumeKnob = getComputedStyle(volume.lastElementChild!)
690
+ await expect(volumeKnob.outlineStyle).toBe('solid')
691
+ await expect(contrast(composite(volumeKnob.outlineColor), ring)).toBeGreaterThanOrEqual(3)
692
+ },
693
+ }
694
+
695
+ /** The alpha of a computed colour (`oklab(0 0 0 / 0.5)`, `rgba(…, 0.25)`), 1 without one. */
696
+ const alphaOf = (color: string) => Number(color.match(/[/,]\s*([\d.]+)\)$/)?.[1] ?? 1)
697
+
698
+ /**
699
+ * The scrim on the real frames: the lightest stretch of the demo (the white room, 5.1–8.8 s, clear of the crossfades) is
700
+ * drawn with the same 4px blur as `backdrop-blur-xs`, the capsule's black alpha laid over it,
701
+ * and every pixel under each part checked. White time text ≥ 4.5:1, icons and knob ≥ 3:1, and
702
+ * the progress against the remaining track ≥ 3:1, on the worst pixel of the worst frame.
703
+ */
704
+ export const ScrimContrast: Story = {
705
+ render: single,
706
+ play: async ({ canvasElement }) => {
707
+ const stage = canvasElement.querySelector<HTMLElement>('[data-width]')!
708
+ const video = stage.querySelector('video')!
709
+ const capsule = stage.querySelector('[role="group"]')!
710
+ const timeline = stage.querySelector<HTMLElement>('[aria-label="Tijdlijn"]')!
711
+ const { time } = parts(stage)
712
+
713
+ const scrimLayer = capsule.querySelector('[data-scrim]')!
714
+
715
+ await waitFor(() => expect(video.readyState).toBeGreaterThanOrEqual(2), { timeout: 5000 })
716
+ await expect(getComputedStyle(scrimLayer).backdropFilter).toBe('blur(4px)')
717
+ const scrim = alphaOf(getComputedStyle(scrimLayer).backgroundColor)
718
+ const track = alphaOf(getComputedStyle(timeline.firstElementChild!).backgroundColor)
719
+
720
+ // The volume popover, open, blurs the video itself: no ancestor of it is a backdrop root
721
+ await userEvent.hover(stage.querySelector('button[aria-label^="Geluid"]')!)
722
+ const popover = await waitFor(() => {
723
+ const element = stage.querySelector<HTMLElement>('[data-volume-popover]')!
724
+ expect(getComputedStyle(element).opacity).toBe('1')
725
+ return element.firstElementChild as HTMLElement
726
+ })
727
+ await expect(getComputedStyle(popover).backdropFilter).toBe('blur(4px)')
728
+ for (let node = popover.parentElement; node && node !== stage; node = node.parentElement) {
729
+ await expect(getComputedStyle(node).backdropFilter).toBe('none')
730
+ }
731
+ const volumeTrack = popover.querySelector('[role="slider"]')!.firstElementChild!
732
+ const popoverScrim = alphaOf(getComputedStyle(popover).backgroundColor)
733
+ const volumeTrackAlpha = alphaOf(getComputedStyle(volumeTrack).backgroundColor)
734
+
735
+ const frame = video.getBoundingClientRect()
736
+ const canvas = document.createElement('canvas')
737
+ canvas.width = frame.width
738
+ canvas.height = frame.height
739
+ const context = canvas.getContext('2d', { willReadFrequently: true })!
740
+ context.filter = 'blur(4px)'
741
+
742
+ /** The lowest contrast of white against `under` (a pixel's colour) over an element's area. */
743
+ const worst = (element: Element, under: (rgb: number[]) => number[], alpha = scrim) => {
744
+ const box = element.getBoundingClientRect()
745
+ const { data } = context.getImageData(
746
+ Math.round(box.left - frame.left),
747
+ Math.round(box.top - frame.top),
748
+ Math.max(1, Math.round(box.width)),
749
+ Math.max(1, Math.round(box.height)),
750
+ )
751
+ let lowest = Infinity
752
+ for (let index = 0; index < data.length; index += 4) {
753
+ const behind = [data[index], data[index + 1], data[index + 2]].map((c) => c * (1 - alpha))
754
+ lowest = Math.min(lowest, contrast([255, 255, 255], under(behind)))
755
+ }
756
+ return lowest
757
+ }
758
+ const result = { text: Infinity, icons: Infinity, progress: Infinity, volume: Infinity }
759
+
760
+ for (const at of [5.1, 5.6, 6.1, 6.6, 7.1, 7.6, 8.1, 8.6, 8.8]) {
761
+ video.currentTime = at
762
+ await new Promise((resolve) => video.addEventListener('seeked', resolve, { once: true }))
763
+ context.drawImage(video, 0, 0, frame.width, frame.height)
764
+
765
+ result.text = Math.min(
766
+ result.text,
767
+ worst(time!, (rgb) => rgb),
768
+ )
769
+ result.icons = Math.min(
770
+ result.icons,
771
+ worst(capsule, (rgb) => rgb),
772
+ )
773
+ result.progress = Math.min(
774
+ result.progress,
775
+ worst(timeline, (rgb) => rgb.map((c) => c * (1 - track) + 255 * track)),
776
+ )
777
+ result.volume = Math.min(
778
+ result.volume,
779
+ worst(
780
+ volumeTrack,
781
+ (rgb) => rgb.map((c) => c * (1 - volumeTrackAlpha) + 255 * volumeTrackAlpha),
782
+ popoverScrim,
783
+ ),
784
+ )
785
+ }
786
+
787
+ await expect(result.text).toBeGreaterThanOrEqual(4.5)
788
+ await expect(result.icons).toBeGreaterThanOrEqual(3)
789
+ await expect(result.progress).toBeGreaterThanOrEqual(3)
790
+ await expect(result.volume).toBeGreaterThanOrEqual(3)
791
+ },
792
+ }