diff --git a/.commitlintrc.yml b/.commitlintrc.yml index 72c6e9510..395aaba85 100644 --- a/.commitlintrc.yml +++ b/.commitlintrc.yml @@ -35,6 +35,7 @@ rules: - esl-animate - esl-base-element - esl-carousel + - esl-drag-to-scroll - esl-event-listener - esl-footnotes - esl-forms diff --git a/site/site.yml b/site/site.yml index 437dbf146..3b4c52db8 100644 --- a/site/site.yml +++ b/site/site.yml @@ -33,3 +33,4 @@ rewriteRules: "src/modules/esl-image/README.md": "/components/esl-image" "src/modules/esl-image-utils/README.md": "/components/esl-image-utils" "src/modules/esl-carousel/README.md": "/components/esl-carousel" + "src/modules/esl-drag-to-scroll/README.md": "/components/esl-drag-to-scroll" diff --git a/site/src/site.ts b/site/src/site.ts index a196de508..b9f0a5060 100644 --- a/site/src/site.ts +++ b/site/src/site.ts @@ -22,6 +22,7 @@ import { ESLTabs, ESLTab, ESLScrollbar, + ESLDragToScrollMixin, ESLAlert, ESLToggleableDispatcher, ESLSelect, @@ -102,6 +103,7 @@ ESLA11yGroup.register(); ESLTabs.register(); ESLScrollbar.register(); +ESLDragToScrollMixin.register(); ESLAlert.register(); ESLAlert.init({ diff --git a/site/static/assets/examples/drag-to-scroll.svg b/site/static/assets/examples/drag-to-scroll.svg new file mode 100644 index 000000000..2d5d8decf --- /dev/null +++ b/site/static/assets/examples/drag-to-scroll.svg @@ -0,0 +1 @@ + diff --git a/site/views/components/esl-drag-to-scroll.njk b/site/views/components/esl-drag-to-scroll.njk new file mode 100644 index 000000000..59f6cfb22 --- /dev/null +++ b/site/views/components/esl-drag-to-scroll.njk @@ -0,0 +1,13 @@ +--- +layout: content +title: ESL Drag to Scroll +seoTitle: ESL Drag to Scroll - custom mixin to enable drag-to-scroll functionality for the element +name: ESL Drag to Scroll +tags: [components, new] +aside: + source: src/modules/esl-drag-to-scroll + examples: + - esl-drag-to-scroll +--- + +{% mdRender 'src/modules/esl-drag-to-scroll/README.md', 'intro' %} diff --git a/site/views/examples/esl-drag-to-scroll.njk b/site/views/examples/esl-drag-to-scroll.njk new file mode 100644 index 000000000..034f4ee54 --- /dev/null +++ b/site/views/examples/esl-drag-to-scroll.njk @@ -0,0 +1,74 @@ +--- +layout: content +title: ESL Drag to Scroll +seoTitle: Handle drag-to-scroll functionality for any scrollable content using ESL Mixin +name: Drag to Scroll +tags: [examples, playground] +icon: examples/drag-to-scroll.svg +aside: + components: + - esl-drag-to-scroll +--- +
+
+ + + + + + + + + + + + + + +
+
diff --git a/src/modules/all.less b/src/modules/all.less index bc75ab234..996f7c23c 100644 --- a/src/modules/all.less +++ b/src/modules/all.less @@ -5,6 +5,7 @@ @import './esl-media/core.less'; @import './esl-scrollbar/core.less'; +@import './esl-drag-to-scroll/core.less'; @import './esl-a11y-group/core.less'; @import './esl-toggleable/core.less'; diff --git a/src/modules/all.ts b/src/modules/all.ts index 4c764ffa5..85be1b728 100644 --- a/src/modules/all.ts +++ b/src/modules/all.ts @@ -31,6 +31,7 @@ export * from './esl-open-state/core'; // Scrollbar export * from './esl-scrollbar/core'; +export * from './esl-drag-to-scroll/core'; // Alert export * from './esl-alert/core'; diff --git a/src/modules/esl-drag-to-scroll/README.md b/src/modules/esl-drag-to-scroll/README.md new file mode 100644 index 000000000..37dfad5b3 --- /dev/null +++ b/src/modules/esl-drag-to-scroll/README.md @@ -0,0 +1,54 @@ +# [ESL](../../../) Drag to Scroll + +Version: *1.0.0* + +Authors: *Anna Barmina*, *Alexey Stsefanovich (ala'n)* + + + +## ESL Drag To Scroll Mixin + +`ESLDragToScrollMixin` (`esl-drag-to-scroll`) is a custom attribute that enables drag-to-scroll functionality for any scrollable container element. +This mixin enhances user experience by allowing intuitive drag-based scrolling, making it easier to navigate through content. + +### Configuration +The mixin uses a primary attribute, `esl-drag-to-scroll`, with optional configuration passed as a JSON attribute value. + +**Configuration options:** +- `axis` (string) - determines the scrolling axis to control. Possible values: + - `'both'` - both horizontal and vertical scrolling (by default); + - `'x'` - horizontal scrolling only; + - `'y'` - vertical scrolling only. +- `cls` (string) - class to apply to the element during dragging to indicate the drag state. + By default, the class `dragging` is applied. +- `tolerance` (number) - a minimum distance to move before the drag action starts. + By default, the value is 10. +- `selection` (boolean) - Determines whether the text should prevent the drag-scroll action. The default value is true. + +**Default Configuration"** +The default configuration for the mixin is as follows: +```json +{ + "axis": "both", + "cls": "dragging", + "tolerance": 10, + "selection": true +} +``` + +### Usage + +To use the mixin, apply the `esl-drag-to-scroll` attribute to your scrollable container element. +```html +
+ +
+``` + +You can also provide **custom configuration** through the JSON attribute. +For example, to enable horizontal scrolling only, disable text selection, and use a custom class `is-dragging` during the drag. +```html +
+ +
+``` diff --git a/src/modules/esl-drag-to-scroll/core.less b/src/modules/esl-drag-to-scroll/core.less new file mode 100644 index 000000000..a8204a6a4 --- /dev/null +++ b/src/modules/esl-drag-to-scroll/core.less @@ -0,0 +1 @@ +@import './core/esl-drag-to-scroll.less'; diff --git a/src/modules/esl-drag-to-scroll/core.ts b/src/modules/esl-drag-to-scroll/core.ts new file mode 100644 index 000000000..4011cbe7e --- /dev/null +++ b/src/modules/esl-drag-to-scroll/core.ts @@ -0,0 +1 @@ +export * from './core/esl-drag-to-scroll.mixin'; diff --git a/src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.less b/src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.less new file mode 100644 index 000000000..5aa4381af --- /dev/null +++ b/src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.less @@ -0,0 +1,9 @@ +[esl-drag-to-scroll] { + cursor: grab; + + &.dragging { + cursor: grabbing; + user-select: none; + scroll-behavior: auto !important; + } +} diff --git a/src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.mixin.ts b/src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.mixin.ts new file mode 100644 index 000000000..6f13254f2 --- /dev/null +++ b/src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.mixin.ts @@ -0,0 +1,144 @@ +import {ESLMixinElement} from '../../esl-mixin-element/core'; +import {listen, memoize} from '../../esl-utils/decorators'; +import {ExportNs} from '../../esl-utils/environment/export-ns'; + +import type {Point} from '../../esl-utils/dom/point'; +import {evaluate} from '../../esl-utils/misc/format'; + +/** + * ESLDragToScrollConfig - configuration options for the ESLDragToScrollMixin + */ +export interface ESLDragToScrollConfig { + /** Determines the scrolling axis. Options are 'x', 'y', or 'both' (default) */ + axis: 'x' | 'y' | 'both'; + /** Class name to apply during dragging. Defaults to 'dragging' */ + cls: string; + /** Min distance in pixels to activate dragging mode. Defaults to 10 */ + tolerance: number; + /** Prevent dragging if text is selected or not. Defaults to true */ + selection: boolean; +} + +/** + * ESLDragToScrollMixin - mixin to enable drag-to-scroll functionality for any scrollable container element + * @author Anna Barmina, Alexey Stsefanovich (ala'n) + * + * Use example: + * ``` + *
+ * + *
+ * ``` + */ +@ExportNs('DragToScrollMixin') +export class ESLDragToScrollMixin extends ESLMixinElement { + public static override is = 'esl-drag-to-scroll'; + + /** Default configuration object */ + public static DEFAULT_CONFIG: ESLDragToScrollConfig = { + axis: 'both', + cls: 'dragging', + tolerance: 10, + selection: true + }; + + /** Initial pointer event when dragging starts */ + private startEvent: PointerEvent; + + private startScrollLeft: number = 0; + private startScrollTop: number = 0; + + private _isDragging: boolean = false; + + /** Flag indicating whether dragging is in progress */ + public get isDragging(): boolean { + return this._isDragging; + } + private set isDragging(value: boolean) { + this._isDragging = value; + this.$$cls(this.config.cls, value); + } + + /** + * Mixin configuration (merged with default) + * @see ESLDragToScrollConfig + */ + @memoize() + public get config(): ESLDragToScrollConfig { + const config = evaluate(this.$$attr(ESLDragToScrollMixin.is)!, {}); + return {...ESLDragToScrollMixin.DEFAULT_CONFIG, ...config}; + } + public set config(value: ESLDragToScrollConfig | string) { + const serialized = typeof value === 'string' ? value : JSON.stringify(value); + this.$$attr(ESLDragToScrollMixin.is, serialized); + } + + /** Flag indicating whether text is selected */ + public get hasSelection(): boolean { + const selection = document.getSelection(); + if (!selection || !this.config.selection) return false; + // Prevents draggable state if the text is selected + return !selection.isCollapsed && this.$host.contains(selection.anchorNode); + } + + protected override attributeChangedCallback(name: string, oldValue: string, newValue: string): void { + memoize.clear(this, 'config'); + } + + /** @returns the offset of the pointer event relative to the start event */ + public getEventOffset(event: PointerEvent): Point { + if (!this.startEvent || event.type === 'pointercancel') return {x: 0, y: 0}; + const x = this.startEvent.clientX - event.clientX; + const y = this.startEvent.clientY - event.clientY; + return {x, y}; + } + + /** Scrolls the host element by the specified offset */ + public scrollBy(offset: Point): void { + if (this.config.axis === 'x' || this.config.axis === 'both') { + this.$host.scrollLeft = this.startScrollLeft + offset.x; + } + + if (this.config.axis === 'y' || this.config.axis === 'both') { + this.$host.scrollTop = this.startScrollTop + offset.y; + } + } + + /** Handles the pointerdown event to start dragging */ + @listen('pointerdown') + private onPointerDown(event: PointerEvent): void { + this.startEvent = event; + this.startScrollLeft = this.$host.scrollLeft; + this.startScrollTop = this.$host.scrollTop; + + this.$$on({group: 'pointer'}); + } + + /** Handles the pointermove event to perform scrolling while dragging */ + @listen({auto: false, event: 'pointermove', group: 'pointer'}) + private onPointerMove(event: PointerEvent): void { + const offset = this.getEventOffset(event); + + if (!this.isDragging) { + // Stop tracking if there was a selection before dragging started + if (this.hasSelection) return this.onPointerUp(event); + // Does not start dragging mode if offset have not reached tolerance + if (Math.abs(Math.max(Math.abs(offset.x), Math.abs(offset.y))) < this.config.tolerance) return; + this.isDragging = true; + } + + this.$host.setPointerCapture(event.pointerId); + this.scrollBy(offset); + } + + /** Handles the pointerup and pointercancel events to stop dragging */ + @listen({auto: false, event: 'pointerup pointercancel', group: 'pointer'}) + private onPointerUp(event: PointerEvent): void { + this.$$off({group: 'pointer'}); + if (this.$host.hasPointerCapture(event.pointerId)) { + this.$host.releasePointerCapture(event.pointerId); + } + this.scrollBy(this.getEventOffset(event)); + this.isDragging = false; + } +}