@evermade/overflow-slider 1.0.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/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import './overflow-slider.scss';
2
+
3
+ import OverflowSlider from './overflow-slider';
4
+
5
+ import SkipLinksPlugin from './plugins/skip-links';
6
+ import ArrowsPlugin from './plugins/arrows';
7
+ import ScrollIndicatorPlugin from './plugins/scroll-indicator';
8
+ import DragScrollingPlugin from './plugins/drag-scrolling';
9
+ import DotsPlugin from './plugins/dots';
10
+
11
+ export {
12
+ OverflowSlider,
13
+ DragScrollingPlugin,
14
+ SkipLinksPlugin,
15
+ ArrowsPlugin,
16
+ ScrollIndicatorPlugin,
17
+ DotsPlugin
18
+ };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Overflow Slider
3
+ */
4
+
5
+ /* --------------------------------------------------------------
6
+ # Container
7
+ -------------------------------------------------------------- */
8
+
9
+ .overflow-slider {
10
+ overflow: auto;
11
+ width: 100%;
12
+ overflow: auto;
13
+ display: grid;
14
+ grid-auto-flow: column;
15
+ grid-template-columns: max-content;
16
+ max-width: max-content;
17
+ &::-webkit-scrollbar {
18
+ display: none;
19
+ }
20
+ & > * {
21
+ scroll-snap-align: start;
22
+ outline-offset: -2px;
23
+ }
24
+ }
25
+
26
+ /* --------------------------------------------------------------
27
+ # ArrowsPlugin
28
+ -------------------------------------------------------------- */
29
+
30
+ :root {
31
+ --overflow-slider-arrows-size: 1.5rem;
32
+ --overflow-slider-arrows-gap: .5rem;
33
+ --overflow-slider-arrows-inactive-opacity: 0.5;
34
+ }
35
+
36
+ .overflow-slider__arrows {
37
+ display: flex;
38
+ gap: var(--overflow-slider-arrows-gap);
39
+ }
40
+
41
+ .overflow-slider__arrows-button {
42
+ display: flex;
43
+ align-items: center;
44
+ outline-offset: -2px;
45
+ cursor: pointer;
46
+ svg {
47
+ width: var(--overflow-slider-arrows-size);
48
+ height: var(--overflow-slider-arrows-size);
49
+
50
+ }
51
+ &[data-has-content="false"] {
52
+ opacity: var(--overflow-slider-arrows-inactive-opacity);
53
+ }
54
+ }
55
+
56
+ /* --------------------------------------------------------------
57
+ # DotsPlugin
58
+ -------------------------------------------------------------- */
59
+
60
+ :root {
61
+ --overflow-slider-dots-gap: 0.5rem;
62
+ --overflow-slider-dot-size: 0.75rem;
63
+ --overflow-slider-dot-inactive-color: hsla(0, 0%, 0%, 0.1);
64
+ --overflow-slider-dot-active-color: hsla(0, 0%, 0%, 0.8);
65
+ }
66
+
67
+ .overflow-slider__dots {
68
+ display: flex;
69
+ justify-content: center;
70
+ align-items: center;
71
+ ul {
72
+ list-style: none;
73
+ padding: 0;
74
+ margin: 0;
75
+ display: flex;
76
+ flex-wrap: wrap;
77
+ gap: var(--overflow-slider-dots-gap);
78
+ }
79
+ li {
80
+ line-height: 0;
81
+ padding: 0;
82
+ margin: 0;
83
+ }
84
+ }
85
+
86
+ .overflow-slider__dot-item {
87
+ padding: 0;
88
+ margin: 0;
89
+ cursor: pointer;
90
+ outline-offset: 2px;
91
+ width: var(--overflow-slider-dot-size);
92
+ height: var(--overflow-slider-dot-size);
93
+ border-radius: 50%;
94
+ background: var(--overflow-slider-dot-inactive-color);
95
+ position: relative;
96
+ // increase clickable area
97
+ &::after {
98
+ content: '';
99
+ display: block;
100
+ left: calc(-1 * var(--overflow-slider-dots-gap));
101
+ top: calc(-1 * var(--overflow-slider-dots-gap));
102
+ right: calc(-1 * var(--overflow-slider-dots-gap));
103
+ bottom: calc(-1 * var(--overflow-slider-dots-gap));
104
+ position: absolute;
105
+ }
106
+ &[aria-pressed="true"],
107
+ &:focus,
108
+ &:hover {
109
+ background: var(--overflow-slider-dot-active-color);
110
+ }
111
+ }
112
+
113
+ /* --------------------------------------------------------------
114
+ # DragScrollingPlugin
115
+ -------------------------------------------------------------- */
116
+
117
+ // nothing to style
118
+
119
+ /* --------------------------------------------------------------
120
+ # ScrollIndicatorPlugin
121
+ -------------------------------------------------------------- */
122
+
123
+ :root {
124
+ --overflow-slider-scroll-indicator-button-height: 4px;
125
+ --overflow-slider-scroll-indicator-padding: 1rem;
126
+ --overflow-slider-scroll-indicator-button-color: hsla(0, 0%, 0%, 0.75);
127
+ --overflow-slider-scroll-indicator-bar-color: hsla(0, 0%, 0%, 0.25);
128
+ }
129
+
130
+ .overflow-slider__scroll-indicator {
131
+ width: 100%;
132
+ padding-block: var(--overflow-slider-scroll-indicator-padding);
133
+ cursor: pointer;
134
+ position: relative;
135
+ outline: 0;
136
+ &[data-has-overflow="false"] {
137
+ display: none;
138
+ }
139
+ &:focus-visible .overflow-slider__scroll-indicator-button {
140
+ outline: 2px solid;
141
+ outline-offset: 2px;
142
+ }
143
+ }
144
+
145
+ .overflow-slider__scroll-indicator-bar {
146
+ height: 2px;
147
+ background: var(--overflow-slider-scroll-indicator-bar-color);
148
+ width: 100%;
149
+ border-radius: 3px;
150
+ position: absolute;
151
+ top: 50%;
152
+ left: 0;
153
+ transform: translateY(-50%);
154
+ }
155
+
156
+ .overflow-slider__scroll-indicator-button {
157
+ height: var(--overflow-slider-scroll-indicator-button-height);
158
+ background: var(--overflow-slider-scroll-indicator-button-color);
159
+ position: absolute;
160
+ top: calc(50% - calc( var( --overflow-slider-scroll-indicator-button-height ) / 2 ));
161
+ left: 0;
162
+ border-radius: 3px;
163
+ cursor: grab;
164
+ &[data-is-grabbed="true"],
165
+ &:hover {
166
+ --overflow-slider-scroll-indicator-button-height: 6px;
167
+ }
168
+ // increase clickable area to fill container in y-axis
169
+ &::after {
170
+ content: '';
171
+ display: block;
172
+ position: absolute;
173
+ top: calc(-1 * var(--overflow-slider-scroll-indicator-padding));
174
+ bottom: calc(-1 * var(--overflow-slider-scroll-indicator-padding));
175
+ width: 100%;
176
+ }
177
+ }
178
+
179
+ /* --------------------------------------------------------------
180
+ # SkipLinksPlugin
181
+ -------------------------------------------------------------- */
182
+
183
+ // You need a .screen-reader-text class so something like this:
184
+
185
+ // .screen-reader-text {
186
+ // border: 0;
187
+ // clip: rect(1px, 1px, 1px, 1px);
188
+ // clip-path: inset(50%);
189
+ // height: 1px;
190
+ // margin: -1px;
191
+ // overflow: hidden;
192
+ // padding: 0;
193
+ // position: absolute;
194
+ // width: 1px;
195
+ // word-wrap: normal !important;
196
+ // &:focus {
197
+ // background-color: #000;
198
+ // clip: auto !important;
199
+ // clip-path: none;
200
+ // color: #fff;
201
+ // display: block;
202
+ // font-weight: 700;
203
+ // height: auto;
204
+ // outline-offset: -2px;
205
+ // padding: 1rem 1.5rem;
206
+ // text-decoration: none;
207
+ // width: fit-content;
208
+ // z-index: 100000;
209
+ // position: relative;
210
+ // margin-top: 1rem;
211
+ // margin-bottom: 1rem;
212
+ // }
213
+ // }
@@ -0,0 +1,40 @@
1
+ import Slider from './core/slider';
2
+
3
+ import {
4
+ SliderOptions,
5
+ SliderPlugin,
6
+ } from './core/types';
7
+
8
+ export default function OverflowSlider (
9
+ container: HTMLElement,
10
+ options?: SliderOptions,
11
+ plugins?: SliderPlugin[]
12
+ ) {
13
+ try {
14
+
15
+ // check that container HTML element
16
+ if (!(container instanceof Element)) {
17
+ throw new Error(`Container must be HTML element, found ${typeof container}`);
18
+ }
19
+
20
+ const defaults = {
21
+ scrollBehavior: "smooth",
22
+ scrollStrategy: "fullSlide",
23
+ };
24
+
25
+ const sliderOptions = { ...defaults, ...options };
26
+
27
+ // disable smooth scrolling if user prefers reduced motion
28
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
29
+ sliderOptions.scrollBehavior = "auto";
30
+ }
31
+
32
+ return Slider(
33
+ container,
34
+ sliderOptions,
35
+ plugins,
36
+ );
37
+ } catch (e) {
38
+ console.error(e)
39
+ }
40
+ }
@@ -0,0 +1,107 @@
1
+ import { Slider } from '../core/types';
2
+
3
+ const DEFAULT_TEXTS = {
4
+ buttonPrevious: 'Previous items',
5
+ buttonNext: 'Next items',
6
+ };
7
+
8
+ const DEFAULT_ICONS = {
9
+ prev: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.6 3.4l-7.6 7.6 7.6 7.6 1.4-1.4-5-5h12.6v-2h-12.6l5-5z"/></svg>',
10
+ next: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.4 3.4l-1.4 1.4 5 5h-12.6v2h12.6l-5 5 1.4 1.4 7.6-7.6z"/></svg>',
11
+ };
12
+
13
+ const DEFAULT_CLASS_NAMES = {
14
+ navContainer: 'overflow-slider__arrows',
15
+ prevButton: 'overflow-slider__arrows-button overflow-slider__arrows-button--prev',
16
+ nextButton: 'overflow-slider__arrows-button overflow-slider__arrows-button--next',
17
+ };
18
+
19
+ export type ArrowsOptions = {
20
+ texts: {
21
+ buttonPrevious: string;
22
+ buttonNext: string;
23
+ },
24
+ icons: {
25
+ prev: string;
26
+ next: string;
27
+ },
28
+ classNames: {
29
+ navContainer: string;
30
+ prevButton: string;
31
+ nextButton: string;
32
+ },
33
+ container: HTMLElement | null,
34
+ };
35
+
36
+ export default function ArrowsPlugin( args: { [key: string]: any } ) {
37
+ return ( slider: Slider ) => {
38
+
39
+ const options = <ArrowsOptions>{
40
+ texts: {
41
+ ...DEFAULT_TEXTS,
42
+ ...args?.texts || []
43
+ },
44
+ icons: {
45
+ ...DEFAULT_ICONS,
46
+ ...args?.icons || []
47
+ },
48
+ classNames: {
49
+ ...DEFAULT_CLASS_NAMES,
50
+ ...args?.classNames || []
51
+ },
52
+ container: args?.container ?? null,
53
+ };
54
+
55
+ const nav = document.createElement( 'div' );
56
+ nav.classList.add( options.classNames.navContainer );
57
+
58
+ const prev = document.createElement( 'button' );
59
+ prev.setAttribute( 'class', options.classNames.prevButton );
60
+ prev.setAttribute( 'type', 'button' );
61
+ prev.setAttribute( 'aria-label', options.texts.buttonPrevious );
62
+ prev.setAttribute( 'aria-controls', slider.container.getAttribute( 'id' ) ?? '');
63
+ prev.setAttribute( 'data-type', 'prev' );
64
+ prev.innerHTML = options.icons.prev;
65
+ prev.addEventListener( 'click', () => slider.moveToDirection( 'prev' ) );
66
+
67
+ const next = document.createElement( 'button' );
68
+ next.setAttribute( 'class', options.classNames.nextButton );
69
+ next.setAttribute( 'type', 'button' );
70
+ next.setAttribute( 'aria-label', options.texts.buttonNext );
71
+ next.setAttribute( 'aria-controls', slider.container.getAttribute( 'id' ) ?? '');
72
+ next.setAttribute( 'data-type', 'next' );
73
+ next.innerHTML = options.icons.next;
74
+ next.addEventListener( 'click', () => slider.moveToDirection( 'next' ) );
75
+
76
+ // insert buttons to the nav
77
+ nav.appendChild( prev );
78
+ nav.appendChild( next );
79
+
80
+ const update = () => {
81
+ const scrollLeft = slider.container.scrollLeft;
82
+ const scrollWidth = slider.container.scrollWidth;
83
+ const clientWidth = slider.container.clientWidth;
84
+ if ( scrollLeft === 0 ) {
85
+ prev.setAttribute( 'data-has-content', 'false' );
86
+ } else {
87
+ prev.setAttribute( 'data-has-content', 'true' );
88
+ }
89
+ if ( scrollLeft + clientWidth >= scrollWidth ) {
90
+ next.setAttribute( 'data-has-content', 'false' );
91
+ } else {
92
+ next.setAttribute( 'data-has-content', 'true' );
93
+ }
94
+ };
95
+
96
+ if ( options.container ) {
97
+ options.container.appendChild( nav );
98
+ } else {
99
+ slider.container.parentNode?.insertBefore( nav, slider.container.nextSibling );
100
+ }
101
+
102
+ update();
103
+ slider.on( 'scroll', update );
104
+ slider.on( 'contentsChanged', update );
105
+ slider.on( 'containerSizeChanged', update );
106
+ };
107
+ }
@@ -0,0 +1,129 @@
1
+ import { Slider } from '../core/types';
2
+
3
+ export type DotsOptions = {
4
+ texts: {
5
+ dotDescription: string;
6
+ },
7
+ classNames: {
8
+ dotsContainer: string;
9
+ dotsItem: string;
10
+ },
11
+ container: HTMLElement | null,
12
+ };
13
+
14
+ const DEFAULT_TEXTS = {
15
+ dotDescription: 'Page %d of %d',
16
+ };
17
+
18
+ const DEFAULT_CLASS_NAMES = {
19
+ dotsContainer: 'overflow-slider__dots',
20
+ dotsItem: 'overflow-slider__dot-item',
21
+ };
22
+
23
+ export default function DotsPlugin( args: { [key: string]: any } ) {
24
+ return ( slider: Slider ) => {
25
+ const options = <DotsOptions>{
26
+ texts: {
27
+ ...DEFAULT_TEXTS,
28
+ ...args?.texts || []
29
+ },
30
+ classNames: {
31
+ ...DEFAULT_CLASS_NAMES,
32
+ ...args?.classNames || []
33
+ },
34
+ container: args?.container ?? null,
35
+ };
36
+
37
+ const dots = document.createElement( 'div' );
38
+ dots.classList.add( options.classNames.dotsContainer );
39
+
40
+ let pageFocused: number|null = null;
41
+
42
+ const buildDots = () => {
43
+ dots.setAttribute( 'data-has-content', slider.details.hasOverflow.toString() );
44
+ dots.innerHTML = '';
45
+
46
+ const dotsList = document.createElement( 'ul' );
47
+
48
+ const pages = slider.details.amountOfPages;
49
+ const currentPage = slider.details.currentPage;
50
+
51
+ if ( pages <= 1 ) {
52
+ return;
53
+ }
54
+
55
+ for ( let i = 0; i < pages; i++ ) {
56
+ const dotListItem = document.createElement( 'li' );
57
+ const dot = document.createElement( 'button' );
58
+ dot.setAttribute( 'type', 'button' );
59
+ dot.setAttribute( 'class', options.classNames.dotsItem );
60
+ dot.setAttribute( 'aria-label', options.texts.dotDescription.replace( '%d', ( i + 1 ).toString() ).replace( '%d', pages.toString() ) );
61
+ dot.setAttribute( 'aria-pressed', ( i === currentPage ).toString() );
62
+ dot.setAttribute( 'data-page', ( i + 1 ).toString() );
63
+ dotListItem.appendChild( dot );
64
+ dotsList.appendChild( dotListItem );
65
+ dot.addEventListener( 'click', () => activateDot( i + 1 ) );
66
+ dot.addEventListener( 'focus', () => pageFocused = i + 1 );
67
+ dot.addEventListener( 'keydown', ( e ) => {
68
+ const currentPageItem = dots.querySelector( `[aria-pressed="true"]` );
69
+ if ( ! currentPageItem ) {
70
+ return;
71
+ }
72
+ const currentPage = parseInt( currentPageItem.getAttribute( 'data-page' ) ?? '1' );
73
+ if ( e.key === 'ArrowLeft' ) {
74
+ const previousPage = currentPage - 1;
75
+ if ( previousPage > 0 ) {
76
+ const matchingDot = dots.querySelector( `[data-page="${previousPage}"]` );
77
+ if ( matchingDot ) {
78
+ ( <HTMLElement>matchingDot ).focus();
79
+ }
80
+ activateDot( previousPage );
81
+ }
82
+ }
83
+ if ( e.key === 'ArrowRight' ) {
84
+ const nextPage = currentPage + 1;
85
+ if ( nextPage <= pages ) {
86
+ const matchingDot = dots.querySelector( `[data-page="${nextPage}"]` );
87
+ if ( matchingDot ) {
88
+ ( <HTMLElement>matchingDot ).focus();
89
+ }
90
+ activateDot( nextPage );
91
+ }
92
+ }
93
+ } );
94
+ }
95
+
96
+ dots.appendChild( dotsList );
97
+
98
+ // return focus to same page after rebuild
99
+ if ( pageFocused ) {
100
+ const matchingDot = dots.querySelector( `[data-page="${pageFocused}"]` );
101
+ if ( matchingDot ) {
102
+ ( <HTMLElement>matchingDot ).focus();
103
+ }
104
+ }
105
+ };
106
+
107
+ const activateDot = ( page: number ) => {
108
+ const scrollTargetPosition = slider.details.containerWidth * ( page - 1 );
109
+ slider.container.style.scrollBehavior = slider.options.scrollBehavior;
110
+ slider.container.style.scrollSnapType = 'none';
111
+ slider.container.scrollLeft = scrollTargetPosition;
112
+ slider.container.style.scrollBehavior = '';
113
+ slider.container.style.scrollSnapType = '';
114
+ };
115
+
116
+ buildDots();
117
+
118
+ if ( options.container ) {
119
+ options.container.appendChild( dots );
120
+ } else {
121
+ slider.container.parentNode?.insertBefore( dots, slider.container.nextSibling );
122
+ }
123
+
124
+ slider.on( 'detailsChanged', () => {
125
+ buildDots();
126
+ } );
127
+
128
+ };
129
+ };
@@ -0,0 +1,78 @@
1
+ import { Slider } from '../core/types';
2
+
3
+ const DEFAULT_DRAGGED_DISTANCE_THAT_PREVENTS_CLICK = 20;
4
+
5
+ export type DragScrollingOptions = {
6
+ draggedDistanceThatPreventsClick: number,
7
+ };
8
+
9
+ export default function DragScrollingPlugin( args: { [key: string]: any } ) {
10
+ const options = <DragScrollingOptions>{
11
+ draggedDistanceThatPreventsClick: args?.draggedDistanceThatPreventsClick ?? DEFAULT_DRAGGED_DISTANCE_THAT_PREVENTS_CLICK,
12
+ };
13
+ return ( slider: Slider ) => {
14
+ let isMouseDown = false;
15
+ let startX = 0;
16
+ let scrollLeft = 0;
17
+
18
+ const hasOverflow = () => {
19
+ return slider.details.hasOverflow;
20
+ }
21
+
22
+ slider.container.addEventListener('mousedown', (e) => {
23
+ if ( ! hasOverflow() ) {
24
+ return;
25
+ }
26
+ isMouseDown = true;
27
+ startX = e.pageX - slider.container.offsetLeft;
28
+ scrollLeft = slider.container.scrollLeft;
29
+ // change cursor to grabbing
30
+ slider.container.style.cursor = 'grabbing';
31
+ slider.container.style.scrollSnapType = 'none';
32
+ // prevent pointer events on the slides
33
+ // const slides = slider.container.querySelectorAll( ':scope > *' );
34
+ // slides.forEach((slide) => {
35
+ // (<HTMLElement>slide).style.pointerEvents = 'none';
36
+ // });
37
+ // prevent focus going to the slides
38
+ // e.preventDefault();
39
+ // e.stopPropagation();
40
+ });
41
+ window.addEventListener('mouseup', () => {
42
+ if ( ! hasOverflow() ) {
43
+ return;
44
+ }
45
+ isMouseDown = false;
46
+ slider.container.style.cursor = '';
47
+ slider.container.style.scrollSnapType = '';
48
+ setTimeout(() => {
49
+ const slides = slider.container.querySelectorAll( ':scope > *' );
50
+ slides.forEach((slide) => {
51
+ (<HTMLElement>slide).style.pointerEvents = '';
52
+ });
53
+ }, 50);
54
+ });
55
+ window.addEventListener('mousemove', (e) => {
56
+ if ( ! hasOverflow() ) {
57
+ return;
58
+ }
59
+ if (!isMouseDown) {
60
+ return;
61
+ }
62
+ e.preventDefault();
63
+ const x = e.pageX - slider.container.offsetLeft;
64
+ const walk = (x - startX);
65
+ slider.container.scrollLeft = scrollLeft - walk;
66
+
67
+ // if walk is more than 30px, don't allow click event
68
+ // e.preventDefault();
69
+
70
+ const absWalk = Math.abs(walk);
71
+ const slides = slider.container.querySelectorAll( ':scope > *' );
72
+ const pointerEvents = absWalk > options.draggedDistanceThatPreventsClick ? 'none' : '';
73
+ slides.forEach((slide) => {
74
+ (<HTMLElement>slide).style.pointerEvents = pointerEvents;
75
+ });
76
+ });
77
+ };
78
+ };