Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(esl-drag-to-scroll): create mixin to enable drag-to-scroll functionality #2666

Merged
merged 8 commits into from
Sep 18, 2024
1 change: 1 addition & 0 deletions .commitlintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ rules:
- esl-animate
- esl-base-element
- esl-carousel
- esl-drag-to-scroll
- esl-event-listener
- esl-footnotes
- esl-forms
Expand Down
1 change: 1 addition & 0 deletions site/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions site/src/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
ESLTabs,
ESLTab,
ESLScrollbar,
ESLDragToScrollMixin,
ESLAlert,
ESLToggleableDispatcher,
ESLSelect,
Expand Down Expand Up @@ -102,6 +103,7 @@ ESLA11yGroup.register();
ESLTabs.register();

ESLScrollbar.register();
ESLDragToScrollMixin.register();

ESLAlert.register();
ESLAlert.init({
Expand Down
1 change: 1 addition & 0 deletions site/static/assets/examples/drag-to-scroll.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions site/views/components/esl-drag-to-scroll.njk
Original file line number Diff line number Diff line change
@@ -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' %}
74 changes: 74 additions & 0 deletions site/views/examples/esl-drag-to-scroll.njk
Original file line number Diff line number Diff line change
@@ -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
---
<section class="row">
<div class="col-12">
<uip-root>
<script type="text/html"
label="Both (Default)"
uip-snippet
uip-snippet-js="js-snippet-scrollbar">
<section style="resize: both; overflow: hidden; max-width: 100%; height: 500px; max-height: 70vh;" class="border bg-white relative-block">
<div style="max-width: 100%; max-height: 100%;" class="p-4 esl-scrollable-content" esl-drag-to-scroll>
<div style="min-width: 50vw">
<h2>Horizontal scroll appears only if content is too long for the area (resize the section by dragging the lower right corner)</h2>
<!-- paragraph x10 -->
</div>
</div>
<esl-scrollbar class="scrollbar-blue" target="::prev(.esl-scrollable-content)"></esl-scrollbar>
<esl-scrollbar class="scrollbar-blue scrollbar-second" target="::prev(.esl-scrollable-content)" horizontal></esl-scrollbar>
</section>
</script>

<script type="text/html"
label="Vertical"
uip-snippet
uip-snippet-js="js-snippet-scrollbar">
<section class="border bg-white relative-block">
<div class="p-4 esl-scrollable-content" esl-drag-to-scroll="{axis: 'y'}">
<div style="height: 500px">
<h2>Simple vertical drag to scroll example</h2>
<!-- paragraph x25 -->
</div>
</div>
<esl-scrollbar class="scrollbar-blue" target="::prev"></esl-scrollbar>
</section>
</script>

<script type="text/html"
label="Horizontal"
uip-snippet
uip-snippet-js="js-snippet-scrollbar">
<section class="border bg-white relative-block">
<div class="p-4 esl-scrollable-content" esl-drag-to-scroll="{axis: 'x'}">
<div style="width: 3200px;">
<h2>Simple horizontal drag to scroll example</h2>
<!-- paragraph x4 -->
</div>
</div>
<esl-scrollbar class="scrollbar-blue" target="::prev" horizontal></esl-scrollbar>
</section>
</script>

<script id="js-snippet-scrollbar" type="text/plain">
import { ESLScrollbar, ESLDragToScrollMixin } from '@exadel/esl';
ESLScrollbar.register();
ESLDragToScrollMixin.register();
</script>

<uip-snippets class="uip-toolbar" dropdown-view="@xs"></uip-snippets>
<uip-preview></uip-preview>
<uip-editor label="Source code (HTML)" collapsible copy></uip-editor>
<uip-editor source="js" label="Source code (JS)" collapsible collapsed copy></uip-editor>
</uip-root>
</div>
</section>
1 change: 1 addition & 0 deletions src/modules/all.less
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/modules/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
54 changes: 54 additions & 0 deletions src/modules/esl-drag-to-scroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# [ESL](../../../) Drag to Scroll

Version: *1.0.0*

Authors: *Anna Barmina*, *Alexey Stsefanovich (ala'n)*

<a name="intro"></a>

## 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
<div class="esl-scrollable-content" esl-drag-to-scroll>
<!-- Content here -->
</div>
```

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
<div class="esl-scrollable-content" esl-drag-to-scroll="{axis: 'x', cls: 'is-dragging', selection: false}">
<!-- Content here -->
</div>
```
1 change: 1 addition & 0 deletions src/modules/esl-drag-to-scroll/core.less
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import './core/esl-drag-to-scroll.less';
1 change: 1 addition & 0 deletions src/modules/esl-drag-to-scroll/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './core/esl-drag-to-scroll.mixin';
9 changes: 9 additions & 0 deletions src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[esl-drag-to-scroll] {
cursor: grab;

&.dragging {
cursor: grabbing;
user-select: none;
scroll-behavior: auto !important;
}
}
144 changes: 144 additions & 0 deletions src/modules/esl-drag-to-scroll/core/esl-drag-to-scroll.mixin.ts
Original file line number Diff line number Diff line change
@@ -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:
* ```
* <div class="esl-scrollable-content" esl-drag-to-scroll>
* <!-- Content here -->
* </div>
* ```
*/
abarmina marked this conversation as resolved.
Show resolved Hide resolved
@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;
}
}