diff --git a/cypress/e2e/example-rtl.cy.ts b/cypress/e2e/example-rtl.cy.ts new file mode 100644 index 00000000..f5b45beb --- /dev/null +++ b/cypress/e2e/example-rtl.cy.ts @@ -0,0 +1,203 @@ +describe('Example - RTL (Right-to-Left) Support', () => { + const titles = [ + 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven', + 'Priority', 'Status', 'Assignee', 'Department', 'Project', 'Completed' + ]; + + beforeEach(() => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example1-simple-rtl.html`); + }); + + // Section 1: Basic Rendering + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Simple Grid (RTL)'); + }); + + it('should have exact Column Titles in the grid', () => { + cy.get('#myGrid') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('should render columns in right-to-left order', () => { + cy.get('#myGrid') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + // Section 2: Configuration & Setup + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#myGrid') + .should('have.class', 'slick-rtl'); + }); + + it('should have RTL option enabled in grid options', () => { + cy.window().then((win) => { + const grid = (win as any).grid; + if (grid && grid.getOptions) { + const options = grid.getOptions(); + expect(options.rtl).to.be.true; + } + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#myGrid') + .find('.slick-cell:first') + .should('have.css', 'direction', 'rtl'); + }); + }); + + // Section 3: UI Interactions + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#myGrid') + .find('.slick-header-column:first .slick-resizable-handle') + .should('exist') + .and('have.css', 'left', '0px'); + }); + + it('should maintain RTL behavior after column resize', () => { + // Resize first column + cy.get('#myGrid') + .find('.slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .trigger('mousemove', { clientX: 30 }) + .trigger('mouseup'); + + // Verify columns still in correct RTL order + cy.get('#myGrid') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + // Section 4: Scrolling Behavior + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('.slick-viewport') + .should('have.prop', 'scrollWidth') + .then((scrollWidth) => { + cy.get('.slick-viewport') + .invoke('width') + .should((viewportWidth) => { + // @ts-ignore - scrollWidth and viewportWidth are numbers + expect(scrollWidth).to.be.greaterThan(viewportWidth); + }); + }); + }); + + it('should scroll horizontally in RTL mode', () => { + cy.get('.slick-viewport') + .then(($viewport) => { + const viewport = $viewport[0]; + viewport.scrollLeft = -200; + cy.wait(100); + expect(viewport.scrollLeft).to.be.lessThan(0); + }); + }); + + it('should update visible range when scrolling in RTL', () => { + let initialFirstColumn = ''; + cy.get('.slick-header-column:visible') + .first() + .invoke('text') + .then((text) => { + initialFirstColumn = text; + }); + + cy.get('.slick-viewport') + .then(($viewport) => { + const viewport = $viewport[0]; + viewport.scrollLeft = -300; + cy.wait(150); + }); + + cy.get('.slick-header-column:visible') + .first() + .invoke('text') + .should((newText) => { + expect(newText).not.to.equal(initialFirstColumn); + }); + }); + + it('should calculate correct visible range in RTL mode', () => { + cy.window().then((win) => { + const grid = (win as any).grid; + if (grid && grid.getVisibleRange) { + const viewport = grid._viewport; + const originalScrollLeft = viewport.scrollLeft; + viewport.scrollLeft = -200; + const range = grid.getVisibleRange(); + expect(range.leftPx).to.be.a('number'); + expect(range.rightPx).to.be.a('number'); + expect(range.rightPx).to.be.greaterThan(range.leftPx); + viewport.scrollLeft = originalScrollLeft; + } + }); + }); + }); + + // Section 5: Edge Cases & Stability + + describe('Edge Cases & Stability', () => { + it('should handle max scroll in RTL mode', () => { + cy.get('.slick-viewport') + .then(($viewport) => { + const viewport = $viewport[0]; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = -maxScroll; + cy.wait(150); + cy.get('.slick-header-column:visible') + .last() + .should('exist'); + }); + }); + + it('should maintain scroll position after column updates', () => { + let currentScrollLeft = 0; + cy.get('.slick-viewport') + .then(($viewport) => { + const viewport = $viewport[0]; + viewport.scrollLeft = -300; + currentScrollLeft = viewport.scrollLeft; + cy.wait(100); + cy.window().then((win) => { + const grid = (win as any).grid; + if (grid && grid.render) { + grid.render(); + } + }); + cy.wait(100); + expect(viewport.scrollLeft).to.equal(currentScrollLeft); + }); + }); + + it('should scroll to the end and display last columns', () => { + cy.get('.slick-viewport') + .then(($viewport) => { + const viewport = $viewport[0]; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = -maxScroll; + cy.wait(300); + }); + + cy.get('.slick-header-column:visible') + .first() + .invoke('text') + .then((text) => { + expect(text).not.to.equal('Title'); + }); + }); + }); +}); diff --git a/examples/example1-simple-rtl.html b/examples/example1-simple-rtl.html new file mode 100644 index 00000000..753a50e0 --- /dev/null +++ b/examples/example1-simple-rtl.html @@ -0,0 +1,103 @@ + + + + + + + + SlickGrid example 1: Basic grid (RTL) + + + + + +

Example 1 Simple Grid (RTL) - ESM

+ + + + + +
+
+
+
+

+ + Demonstrates: +

+
+
    +
  • basic grid with minimal configuration
  • +
  • RTL (right-to-left) support enabled
  • +
+

View Source:

+ +
+ + + + + + + + + + + + diff --git a/examples/index.html b/examples/index.html index 0dc6bdea..3bfd6744 100644 --- a/examples/index.html +++ b/examples/index.html @@ -65,6 +65,7 @@

Basic Use

  • Handling events and context menu
  • Highlighting and flashing cells
  • Adding some formatting
  • +
  • Basic use with RTL support
  • Editing

    diff --git a/src/models/gridOption.interface.ts b/src/models/gridOption.interface.ts index 02fb42db..08720884 100644 --- a/src/models/gridOption.interface.ts +++ b/src/models/gridOption.interface.ts @@ -350,6 +350,9 @@ export interface GridOption { /** Defaults to 400, duration to show the row highlight (e.g. after CRUD executions) */ rowHighlightDuration?: number; + /** Defaults to false, sets the grid direction to RTL (Right-to-Left) for proper rendering of RTL languages */ + rtl?: boolean; + /** * Defaults to "top", what CSS style to we want to use to render each row top offset (we can use "top" or "transform"). * For example, with a default `rowHeight: 22`, the 2nd row will have a `top` offset of 44px and by default have a CSS style of `top: 44px`. diff --git a/src/slick.grid.ts b/src/slick.grid.ts index 186a0ef6..6082f644 100644 --- a/src/slick.grid.ts +++ b/src/slick.grid.ts @@ -334,7 +334,8 @@ export class SlickGrid = Column, O e logSanitizedHtml: false, // log to console when sanitised - recommend true for testing of dev and production mixinDefaults: true, shadowRoot: undefined, - colAutosizeTreatAsLockedBelowWidth: 100 + colAutosizeTreatAsLockedBelowWidth: 100, + rtl: false }; protected _columnDefaults = { @@ -758,8 +759,8 @@ export class SlickGrid = Column, O e this._headerScroller.push(this._headerScrollerR); // Append the columnn containers to the headers - this._headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { left: '-1000px' } }, this._headerScrollerL); - this._headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { left: '-1000px' } }, this._headerScrollerR); + this._headerL = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-left', role: 'row', style: { [this.dirSide]: '-1000px' } }, this._headerScrollerL); + this._headerR = Utils.createDomElement('div', { className: 'slick-header-columns slick-header-columns-right', role: 'row', style: { [this.dirSide]: '-1000px' } }, this._headerScrollerR); // Cache the header columns this._headers = [this._headerL, this._headerR]; @@ -867,6 +868,8 @@ export class SlickGrid = Column, O e if (!this._options.explicitInitialization) { this.finishInitialization(); } + + this.applyRTL(this._options.rtl ?? false); } /** @@ -2173,14 +2176,25 @@ export class SlickGrid = Column, O e if (stretchLeewayOnLeft === null) { stretchLeewayOnLeft = 100000; } - maxPageX = pageX + Math.min(shrinkLeewayOnRight, stretchLeewayOnLeft); - minPageX = pageX - Math.min(shrinkLeewayOnLeft, stretchLeewayOnRight); + + if (this._options.rtl) { + maxPageX = pageX + Math.min(shrinkLeewayOnLeft, stretchLeewayOnRight); + minPageX = pageX - Math.min(shrinkLeewayOnRight, stretchLeewayOnLeft); + } else { + maxPageX = pageX + Math.min(shrinkLeewayOnRight, stretchLeewayOnLeft); + minPageX = pageX - Math.min(shrinkLeewayOnLeft, stretchLeewayOnRight); + } }, onResize: (e, resizeElms) => { const targetEvent = (e as TouchEvent).touches ? (e as TouchEvent).changedTouches[0] : e; this.columnResizeDragging = true; let actualMinWidth; - const d = Math.min(maxPageX, Math.max(minPageX, (targetEvent as MouseEvent).pageX)) - pageX; + let d = Math.min(maxPageX, Math.max(minPageX, (targetEvent as MouseEvent).pageX)) - pageX; + + if (this._options.rtl) { + d = -d; + } + let x; let newCanvasWidthL = 0; let newCanvasWidthR = 0; @@ -3164,8 +3178,14 @@ export class SlickGrid = Column, O e w = this.columns[i].width || 0; rule = this.getColumnCssRules(i); - rule.left.style.left = `${x}px`; - rule.right.style.right = (((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL) - x - w) + 'px'; + + if (this._options.rtl) { + rule.left.style.right = `${x}px`; + rule.right.style.left = (((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL) - x - w) + 'px'; + } else { + rule.left.style.left = `${x}px`; + rule.right.style.right = (((this._options.frozenColumn !== -1 && i > this._options.frozenColumn!) ? this.canvasWidthR : this.canvasWidthL) - x - w) + 'px'; + } // If this column is frozen, reset the css left value since the // column starts in a new viewport. @@ -5074,8 +5094,8 @@ export class SlickGrid = Column, O e const rowHeight = (this._options.rowHeight! - this.cellHeightDiff); const rules = [ - `.${this.uid} .slick-group-header-column { left: 1000px; }`, - `.${this.uid} .slick-header-column { left: 1000px; }`, + `.${this.uid} .slick-group-header-column { ${this.dirSide}: 1000px; }`, + `.${this.uid} .slick-header-column { ${this.dirSide}: 1000px; }`, `.${this.uid} .slick-top-panel { height: ${this._options.topPanelHeight}px; }`, `.${this.uid} .slick-preheader-panel { height: ${this._options.preHeaderPanelHeight}px; }`, `.${this.uid} .slick-topheader-panel { height: ${this._options.topHeaderPanelHeight}px; }`, @@ -6245,11 +6265,22 @@ export class SlickGrid = Column, O e viewportTop ??= this.scrollTop; viewportLeft ??= this.scrollLeft; + let leftPx = viewportLeft; + let rightPx = viewportLeft + this.viewportW; + + if (this._options.rtl) { + // In RTL, scrollLeft represents the offset from the RIGHT edge. + // The viewport's left edge is: maxScroll (far left) - scrollLeft (offset from right) + const maxScroll = this.canvasWidth - this.viewportW; + leftPx = maxScroll - viewportLeft - this.viewportW; + rightPx = maxScroll - viewportLeft; + } + return { top: this.getRowFromPosition(viewportTop), bottom: this.getRowFromPosition(viewportTop + this.viewportH) + 1, - leftPx: viewportLeft, - rightPx: viewportLeft + this.viewportW + leftPx, + rightPx }; } @@ -8319,6 +8350,39 @@ export class SlickGrid = Column, O e return cleanHtml; } + /** + * Returns the CSS property used to hide header columns off-screen by applying a large offset (e.g., `1000px`). + * + * In LTR mode (`rtl: false`), columns are positioned with a negative `left` value to hide them off-screen. + * In RTL mode (`rtl: true`), the same effect is achieved by using a positive `right` value, since the scroll direction is mirrored. + * + * @returns 'right' when RTL is enabled, otherwise 'left' + */ + protected get dirSide() { + return this._options.rtl ? 'right' : 'left'; + } + + /** + * Applies or removes RTL (Right-to-Left) support on the grid container. + * + * When enabled, this method: + * - Adds the `slick-rtl` CSS class for styling + * - Sets the `dir="rtl"` attribute for proper text direction + * When disabled, it removes both the class and attribute. + * This makes the grid self-contained, allowing RTL to work regardless of the page's direction setting. + * + * @param enabled - Whether RTL should be enabled + */ + private applyRTL(enabled: boolean): void { + if (enabled) { + this._container.classList.add('slick-rtl'); + this._container.setAttribute('dir', 'rtl'); + } else { + this._container.classList.remove('slick-rtl'); + this._container.removeAttribute('dir'); + } + } + /////////////////////////////////////////////////////////////// // End Shared Utilities and Accessors /////////////////////////////////////////////////////////////// diff --git a/src/styles/slick-alpine-theme.scss b/src/styles/slick-alpine-theme.scss index 3427af04..87c438c3 100644 --- a/src/styles/slick-alpine-theme.scss +++ b/src/styles/slick-alpine-theme.scss @@ -866,3 +866,9 @@ button.slick-btn { bottom: 0; right: 0; } + +/* RTL (Right-to-Left) Support */ +.slick-rtl .slick-resizable-handle { + right: auto; + left: 0; +} diff --git a/src/styles/slick.grid.scss b/src/styles/slick.grid.scss index 39ccd548..decffbc4 100644 --- a/src/styles/slick.grid.scss +++ b/src/styles/slick.grid.scss @@ -273,3 +273,9 @@ classes should alter those! outline: 0; width: 100%; } + +/* RTL (Right-to-Left) Support */ +.slick-rtl .slick-resizable-handle { + right: auto; + left: -5px; +}