@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.
- package/dist/components/editor/molecules/EditorColor/EditorColor.vue.d.ts +7 -5
- package/dist/components/editor/molecules/EditorColor/_ColorField.vue.d.ts +4 -1
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +3 -1
- package/dist/index.js +95 -172
- package/dist/nuxt.js +0 -1
- package/package.json +38 -36
- package/src/attribute-contract.test.ts +151 -0
- package/src/components/atoms/AnimatedNumber/AnimatedNumber.stories.ts +36 -0
- package/src/components/atoms/AnimatedNumber/AnimatedNumber.test.ts +21 -0
- package/src/components/atoms/Avatar/Avatar.stories.ts +122 -0
- package/src/components/atoms/Avatar/Avatar.test.ts +134 -0
- package/src/components/atoms/Button/Button.stories.ts +290 -0
- package/src/components/atoms/Button/Button.test.ts +406 -0
- package/src/components/atoms/Card/Card.stories.ts +45 -0
- package/src/components/atoms/Card/Card.test.ts +46 -0
- package/src/components/atoms/Checkbox/Checkbox.stories.ts +35 -0
- package/src/components/atoms/Checkbox/Checkbox.test.ts +55 -0
- package/src/components/atoms/Chip/Chip.stories.ts +274 -0
- package/src/components/atoms/Chip/Chip.test.ts +230 -0
- package/src/components/atoms/EnergyLabel/EnergyLabel.stories.ts +189 -0
- package/src/components/atoms/EnergyLabel/EnergyLabel.test.ts +100 -0
- package/src/components/atoms/FloatingPanel/FloatingPanel.stories.ts +677 -0
- package/src/components/atoms/FloatingPanel/FloatingPanel.test.ts +864 -0
- package/src/components/atoms/Icon/Icon.stories.ts +63 -0
- package/src/components/atoms/Icon/Icon.test.ts +89 -0
- package/src/components/atoms/Loader/Loader.stories.ts +36 -0
- package/src/components/atoms/Loader/Loader.test.ts +40 -0
- package/src/components/atoms/PickerItem/PickerItem.stories.ts +172 -0
- package/src/components/atoms/PickerItem/PickerItem.test.ts +142 -0
- package/src/components/atoms/PickerPreview/PickerPreview.stories.ts +183 -0
- package/src/components/atoms/PickerPreview/PickerPreview.test.ts +137 -0
- package/src/components/atoms/PresenceIndicator/PresenceIndicator.stories.ts +21 -0
- package/src/components/atoms/PropertyListing/PropertyListing.stories.ts +38 -0
- package/src/components/atoms/PropertyListing/PropertyListing.test.ts +46 -0
- package/src/components/atoms/SelectableOption/SelectableOption.stories.ts +131 -0
- package/src/components/atoms/SelectableOption/SelectableOption.test.ts +96 -0
- package/src/components/atoms/Skeleton/Skeleton.stories.ts +27 -0
- package/src/components/atoms/Skeleton/Skeleton.test.ts +28 -0
- package/src/components/atoms/Slider/Slider.stories.ts +151 -0
- package/src/components/atoms/Slider/Slider.test.ts +191 -0
- package/src/components/atoms/Squircle/Squircle.stories.ts +114 -0
- package/src/components/atoms/Squircle/Squircle.test.ts +70 -0
- package/src/components/atoms/Switch/Switch.stories.ts +91 -0
- package/src/components/atoms/Switch/Switch.test.ts +183 -0
- package/src/components/atoms/Tooltip/Tooltip.stories.ts +158 -0
- package/src/components/atoms/Tooltip/Tooltip.test.ts +134 -0
- package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.stories.ts +124 -0
- package/src/components/editor/atoms/EditorDimensionField/EditorDimensionField.test.ts +464 -0
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.stories.ts +266 -0
- package/src/components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.test.ts +421 -0
- package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.stories.ts +365 -0
- package/src/components/editor/atoms/EditorSettingsGroup/EditorSettingsGroup.test.ts +484 -0
- package/src/components/editor/composables/useRadioGrid.test.ts +261 -0
- package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.stories.ts +73 -0
- package/src/components/editor/molecules/EditorAlignItems/EditorAlignItems.test.ts +189 -0
- package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.stories.ts +309 -0
- package/src/components/editor/molecules/EditorAlignmentGrid/EditorAlignmentGrid.test.ts +359 -0
- package/src/components/editor/molecules/EditorAnchor/EditorAnchor.stories.ts +138 -0
- package/src/components/editor/molecules/EditorAnchor/EditorAnchor.test.ts +344 -0
- package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.stories.ts +87 -0
- package/src/components/editor/molecules/EditorBackgroundSize/EditorBackgroundSize.test.ts +262 -0
- package/src/components/editor/molecules/EditorBlur/EditorBlur.stories.ts +115 -0
- package/src/components/editor/molecules/EditorBlur/EditorBlur.test.ts +243 -0
- package/src/components/editor/molecules/EditorBorder/EditorBorder.stories.ts +187 -0
- package/src/components/editor/molecules/EditorBorder/EditorBorder.test.ts +456 -0
- package/src/components/editor/molecules/EditorColor/EditorColor.stories.ts +633 -0
- package/src/components/editor/molecules/EditorColor/EditorColor.test.ts +776 -0
- package/src/components/editor/molecules/EditorColor/EditorColor.vue +7 -5
- package/src/components/editor/molecules/EditorColor/EditorColorGradient.test.ts +832 -0
- package/src/components/editor/molecules/EditorColor/_ColorField.vue +20 -15
- package/src/components/editor/molecules/EditorColor/gradientClass.test.ts +303 -0
- package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.stories.ts +72 -0
- package/src/components/editor/molecules/EditorDashPattern/EditorDashPattern.test.ts +124 -0
- package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.stories.ts +46 -0
- package/src/components/editor/molecules/EditorFlexDirection/EditorFlexDirection.test.ts +97 -0
- package/src/components/editor/molecules/EditorFontSize/EditorFontSize.stories.ts +248 -0
- package/src/components/editor/molecules/EditorFontSize/EditorFontSize.test.ts +288 -0
- package/src/components/editor/molecules/EditorGap/EditorGap.stories.ts +129 -0
- package/src/components/editor/molecules/EditorGap/EditorGap.test.ts +253 -0
- package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.stories.ts +68 -0
- package/src/components/editor/molecules/EditorHeadingType/EditorHeadingType.test.ts +150 -0
- package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.stories.ts +71 -0
- package/src/components/editor/molecules/EditorJustifyContent/EditorJustifyContent.test.ts +161 -0
- package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.stories.ts +204 -0
- package/src/components/editor/molecules/EditorLetterSpacing/EditorLetterSpacing.test.ts +238 -0
- package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.stories.ts +257 -0
- package/src/components/editor/molecules/EditorLineHeight/EditorLineHeight.test.ts +286 -0
- package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.stories.ts +135 -0
- package/src/components/editor/molecules/EditorLineStyle/EditorLineStyle.test.ts +230 -0
- package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.stories.ts +154 -0
- package/src/components/editor/molecules/EditorMaxWidth/EditorMaxWidth.test.ts +112 -0
- package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.stories.ts +206 -0
- package/src/components/editor/molecules/EditorOverrideStatus/EditorOverrideStatus.test.ts +332 -0
- package/src/components/editor/molecules/EditorPadding/EditorPadding.stories.ts +374 -0
- package/src/components/editor/molecules/EditorPadding/EditorPadding.test.ts +712 -0
- package/src/components/editor/molecules/EditorRadius/EditorRadius.stories.ts +64 -0
- package/src/components/editor/molecules/EditorRadius/EditorRadius.test.ts +137 -0
- package/src/components/editor/molecules/EditorShadow/EditorShadow.stories.ts +103 -0
- package/src/components/editor/molecules/EditorShadow/EditorShadow.test.ts +168 -0
- package/src/components/editor/molecules/EditorSize/EditorSize.stories.ts +84 -0
- package/src/components/editor/molecules/EditorSize/EditorSize.test.ts +253 -0
- package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.stories.ts +49 -0
- package/src/components/editor/molecules/EditorSizeLimit/EditorSizeLimit.test.ts +43 -0
- package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.stories.ts +69 -0
- package/src/components/editor/molecules/EditorTextAlign/EditorTextAlign.test.ts +155 -0
- package/src/components/molecules/Announcement/Announcement.stories.ts +224 -0
- package/src/components/molecules/Announcement/Announcement.test.ts +161 -0
- package/src/components/molecules/ContactInfoCard/ContactInfoCard.stories.ts +99 -0
- package/src/components/molecules/ContactInfoCard/ContactInfoCard.test.ts +70 -0
- package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.stories.ts +148 -0
- package/src/components/molecules/CustomerJourneyTile/CustomerJourneyTile.test.ts +271 -0
- package/src/components/molecules/DoughnutChart/DoughnutChart.stories.ts +45 -0
- package/src/components/molecules/DoughnutChart/DoughnutChart.test.ts +205 -0
- package/src/components/molecules/InfoBlock/InfoBlock.stories.ts +84 -0
- package/src/components/molecules/InfoBlock/InfoBlock.test.ts +101 -0
- package/src/components/molecules/InputField/InputField.stories.ts +117 -0
- package/src/components/molecules/InputField/InputField.test.ts +138 -0
- package/src/components/molecules/InputSelect/InputSelect.stories.ts +116 -0
- package/src/components/molecules/InputSelect/InputSelect.test.ts +127 -0
- package/src/components/molecules/ModalFooter/ModalFooter.stories.ts +204 -0
- package/src/components/molecules/ModalFooter/ModalFooter.test.ts +276 -0
- package/src/components/molecules/PageCard/PageCard.stories.ts +100 -0
- package/src/components/molecules/PageCard/PageCard.test.ts +91 -0
- package/src/components/molecules/PageContainer/PageContainer.stories.ts +90 -0
- package/src/components/molecules/PageContainer/PageContainer.test.ts +50 -0
- package/src/components/molecules/PageMockup/PageMockup.stories.ts +38 -0
- package/src/components/molecules/PageMockup/PageMockup.test.ts +20 -0
- package/src/components/molecules/PageTypeTile/PageTypeTile.stories.ts +63 -0
- package/src/components/molecules/PageTypeTile/PageTypeTile.test.ts +41 -0
- package/src/components/molecules/Pagination/Pagination.stories.ts +72 -0
- package/src/components/molecules/Pagination/Pagination.test.ts +130 -0
- package/src/components/molecules/PickerGroup/PickerGroup.stories.ts +90 -0
- package/src/components/molecules/PickerGroup/PickerGroup.test.ts +145 -0
- package/src/components/molecules/PickerList/PickerList.stories.ts +436 -0
- package/src/components/molecules/PickerList/PickerList.test.ts +870 -0
- package/src/components/molecules/PlaceholderInput/PlaceholderInput.stories.ts +48 -0
- package/src/components/molecules/PlaceholderInput/PlaceholderInput.test.ts +107 -0
- package/src/components/molecules/PropertyCard/PropertyCard.stories.ts +46 -0
- package/src/components/molecules/PropertyCard/PropertyCard.test.ts +59 -0
- package/src/components/molecules/Select/Select.stories.ts +264 -0
- package/src/components/molecules/Select/Select.test.ts +315 -0
- package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.stories.ts +84 -0
- package/src/components/molecules/SidebarMenuItem/SidebarMenuItem.test.ts +103 -0
- package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.stories.ts +11 -0
- package/src/components/molecules/SkeletonPropertyCard/SkeletonPropertyCard.test.ts +38 -0
- package/src/components/molecules/SocialIcons/SocialIcons.stories.ts +28 -0
- package/src/components/molecules/SocialIcons/SocialIcons.test.ts +58 -0
- package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
- package/src/components/molecules/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +116 -0
- package/src/components/molecules/SocialMediaType/SocialMediaType.stories.ts +37 -0
- package/src/components/molecules/SocialMediaType/SocialMediaType.test.ts +126 -0
- package/src/components/molecules/Source/Source.stories.ts +35 -0
- package/src/components/molecules/Source/Source.test.ts +45 -0
- package/src/components/molecules/TabbedContent/TabbedContent.stories.ts +96 -0
- package/src/components/molecules/TabbedContent/TabbedContent.test.ts +109 -0
- package/src/components/molecules/Tabs/Tabs.stories.ts +230 -0
- package/src/components/molecules/Tabs/Tabs.test.ts +280 -0
- package/src/components/molecules/TemplateCard/TemplateCard.stories.ts +327 -0
- package/src/components/molecules/TemplateCard/TemplateCard.test.ts +339 -0
- package/src/components/molecules/Textarea/Textarea.stories.ts +216 -0
- package/src/components/molecules/Textarea/Textarea.test.ts +191 -0
- package/src/components/molecules/TimelineEvent/TimelineEvent.stories.ts +319 -0
- package/src/components/molecules/TimelineEvent/TimelineEvent.test.ts +224 -0
- package/src/components/molecules/TimelineLanes/TimelineLanes.stories.ts +29 -0
- package/src/components/molecules/TimelineLanes/TimelineLanes.test.ts +30 -0
- package/src/components/molecules/Toast/Toast.stories.ts +110 -0
- package/src/components/molecules/Toast/Toast.test.ts +103 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.stories.ts +297 -0
- package/src/components/molecules/ToggleGroup/ToggleGroup.test.ts +578 -0
- package/src/components/molecules/Tree/Tree.stories.ts +884 -0
- package/src/components/molecules/Tree/Tree.test.ts +1247 -0
- package/src/components/molecules/Tree/tree-drop.test.ts +223 -0
- package/src/components/organisms/Calendar/Calendar.stories.ts +108 -0
- package/src/components/organisms/Calendar/Calendar.test.ts +279 -0
- package/src/components/organisms/Calendar/_CalendarDayView.test.ts +104 -0
- package/src/components/organisms/Calendar/_CalendarHeader.test.ts +86 -0
- package/src/components/organisms/Calendar/_CalendarMonthView.test.ts +52 -0
- package/src/components/organisms/Calendar/_CalendarTimePicker.test.ts +89 -0
- package/src/components/organisms/Calendar/_CalendarYearView.test.ts +56 -0
- package/src/components/organisms/ColorPicker/ColorPicker.stories.ts +229 -0
- package/src/components/organisms/ColorPicker/ColorPicker.test.ts +696 -0
- package/src/components/organisms/ColorPicker/ColorPicker.vue +12 -3
- package/src/components/organisms/ColorPicker/gradient.test.ts +67 -0
- package/src/components/organisms/ColorPicker/gradientGeometry.test.ts +206 -0
- package/src/components/organisms/Combobox/Combobox.stories.ts +234 -0
- package/src/components/organisms/Combobox/Combobox.test.ts +512 -0
- package/src/components/organisms/Combobox/Combobox.vue +1 -1
- package/src/components/organisms/ComboboxPanel/ComboboxPanel.stories.ts +158 -0
- package/src/components/organisms/ComboboxPanel/ComboboxPanel.test.ts +524 -0
- package/src/components/organisms/ComboboxPanel/ComboboxPanel.vue +1 -1
- package/src/components/organisms/CountrySelect/CountrySelect.stories.ts +139 -0
- package/src/components/organisms/CountrySelect/CountrySelect.test.ts +149 -0
- package/src/components/organisms/DatePicker/DatePicker.stories.ts +163 -0
- package/src/components/organisms/DatePicker/DatePicker.test.ts +214 -0
- package/src/components/organisms/DropdownButton/DropdownButton.stories.ts +123 -0
- package/src/components/organisms/DropdownButton/DropdownButton.test.ts +379 -0
- package/src/components/organisms/FontPicker/FontPicker.stories.ts +476 -0
- package/src/components/organisms/FontPicker/FontPicker.test.ts +873 -0
- package/src/components/organisms/Header/Header.stories.ts +75 -0
- package/src/components/organisms/Header/Header.test.ts +233 -0
- package/src/components/organisms/IconPicker/IconPicker.stories.ts +734 -0
- package/src/components/organisms/IconPicker/IconPicker.test.ts +669 -0
- package/src/components/organisms/IconPicker/_IconPickerGrid.test.ts +184 -0
- package/src/components/organisms/IconPicker/_IconPickerRail.test.ts +102 -0
- package/src/components/organisms/IconPicker/useIconPickerNavigation.test.ts +215 -0
- package/src/components/organisms/Modal/Modal.stories.ts +350 -0
- package/src/components/organisms/Modal/Modal.test.ts +275 -0
- package/src/components/organisms/PickerPanel/PickerPanel.stories.ts +452 -0
- package/src/components/organisms/PickerPanel/PickerPanel.test.ts +358 -0
- package/src/components/organisms/ProgressBar/ProgressBar.stories.ts +100 -0
- package/src/components/organisms/ProgressBar/ProgressBar.test.ts +417 -0
- package/src/components/organisms/ReferenceCard/ReferenceCard.stories.ts +191 -0
- package/src/components/organisms/ReferenceCard/ReferenceCard.test.ts +165 -0
- package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.stories.ts +65 -0
- package/src/components/organisms/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
- package/src/components/organisms/StepperHeader/StepperHeader.stories.ts +91 -0
- package/src/components/organisms/StepperHeader/StepperHeader.test.ts +279 -0
- package/src/components/organisms/Table/Table.stories.ts +332 -0
- package/src/components/organisms/Table/Table.test.ts +320 -0
- package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
- package/src/components/organisms/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
- package/src/components/organisms/TimelineTrack/TimelineTrack.stories.ts +120 -0
- package/src/components/organisms/TimelineTrack/TimelineTrack.test.ts +91 -0
- package/src/components/organisms/Toaster/Toaster.stories.ts +51 -0
- package/src/components/organisms/Toaster/Toaster.test.ts +75 -0
- package/src/components/organisms/VideoControls/VideoControls.stories.ts +792 -0
- package/src/components/organisms/VideoControls/VideoControls.test.ts +1056 -0
- package/src/components/organisms/VideoPlayer/VideoPlayer.stories.ts +327 -0
- 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
|
+
}
|