From e381669f9624a02500bdc8ba8837b3cb92bcd743 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 7 Aug 2026 19:51:11 -0400 Subject: [PATCH 1/8] feat: add core RTL support for headers and column resizing --- .../src/examples/slickgrid/example57.html | 24 ++++ .../src/examples/slickgrid/example57.scss | 8 ++ .../src/examples/slickgrid/example57.ts | 58 ++++++++ demos/aurelia/src/my-app.ts | 1 + .../aurelia/test/cypress/e2e/example57.cy.ts | 67 ++++++++++ demos/react/src/examples/slickgrid/App.tsx | 1 + .../src/examples/slickgrid/Example57.tsx | 85 ++++++++++++ .../src/examples/slickgrid/example57.scss | 8 ++ demos/react/test/cypress/e2e/example57.cy.ts | 67 ++++++++++ demos/vanilla/src/app-routing.ts | 2 + demos/vanilla/src/app.html | 16 +-- demos/vanilla/src/examples/example46.html | 20 +++ demos/vanilla/src/examples/example46.scss | 8 ++ demos/vanilla/src/examples/example46.ts | 69 ++++++++++ demos/vue/src/components/Example57.vue | 88 ++++++++++++ demos/vue/src/components/example57.scss | 8 ++ demos/vue/src/router/index.ts | 1 + demos/vue/test/cypress/e2e/example57.cy.ts | 67 ++++++++++ .../src/demos/app-routing.module.ts | 1 + .../demos/examples/example57.component.html | 21 +++ .../demos/examples/example57.component.scss | 8 ++ .../src/demos/examples/example57.component.ts | 65 +++++++++ .../test/cypress/e2e/example57.cy.ts | 67 ++++++++++ .../src/core/__tests__/slickGrid-rtl.spec.ts | 125 ++++++++++++++++++ packages/common/src/core/slickGrid.ts | 59 +++++++-- .../common/src/extensions/slickGridMenu.ts | 6 + .../src/interfaces/gridOption.interface.ts | 3 + packages/common/src/styles/slick-grid.scss | 8 +- packages/common/src/styles/slick-plugins.scss | 7 +- test/cypress/e2e/example46.cy.ts | 71 ++++++++++ test/cypress/e2e/example57.cy.ts | 69 ++++++++++ 31 files changed, 1084 insertions(+), 24 deletions(-) create mode 100644 demos/aurelia/src/examples/slickgrid/example57.html create mode 100644 demos/aurelia/src/examples/slickgrid/example57.scss create mode 100644 demos/aurelia/src/examples/slickgrid/example57.ts create mode 100644 demos/aurelia/test/cypress/e2e/example57.cy.ts create mode 100644 demos/react/src/examples/slickgrid/Example57.tsx create mode 100644 demos/react/src/examples/slickgrid/example57.scss create mode 100644 demos/react/test/cypress/e2e/example57.cy.ts create mode 100644 demos/vanilla/src/examples/example46.html create mode 100644 demos/vanilla/src/examples/example46.scss create mode 100644 demos/vanilla/src/examples/example46.ts create mode 100644 demos/vue/src/components/Example57.vue create mode 100644 demos/vue/src/components/example57.scss create mode 100644 demos/vue/test/cypress/e2e/example57.cy.ts create mode 100644 frameworks/angular-slickgrid/src/demos/examples/example57.component.html create mode 100644 frameworks/angular-slickgrid/src/demos/examples/example57.component.scss create mode 100644 frameworks/angular-slickgrid/src/demos/examples/example57.component.ts create mode 100644 frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts create mode 100644 packages/common/src/core/__tests__/slickGrid-rtl.spec.ts create mode 100644 test/cypress/e2e/example46.cy.ts create mode 100644 test/cypress/e2e/example57.cy.ts diff --git a/demos/aurelia/src/examples/slickgrid/example57.html b/demos/aurelia/src/examples/slickgrid/example57.html new file mode 100644 index 0000000000..00ceddeaf5 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.html @@ -0,0 +1,24 @@ +

+ Example 57: RTL (Right-to-Left) Support + + + code + + +

+ +
Basic grid with RTL (Right-to-Left) support enabled for RTL languages
+ +
+ +
diff --git a/demos/aurelia/src/examples/slickgrid/example57.scss b/demos/aurelia/src/examples/slickgrid/example57.scss new file mode 100644 index 0000000000..2598a7271e --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.scss @@ -0,0 +1,8 @@ +.grid-rtl { + direction: rtl; + + .slick-resizable-handle { + right: auto !important; + left: 0 !important; + } +} diff --git a/demos/aurelia/src/examples/slickgrid/example57.ts b/demos/aurelia/src/examples/slickgrid/example57.ts new file mode 100644 index 0000000000..6be79ccf38 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.ts @@ -0,0 +1,58 @@ +import { Formatters, type AureliaGridInstance, type Column, type GridOption } from 'aurelia-slickgrid'; + +const NB_ITEMS = 100; + +export class Example57 { + gridOptions!: GridOption; + columns: Column[] = []; + dataset: any[] = []; + + constructor() { + this.defineGrid(); + } + + attached() { + this.dataset = this.mockData(NB_ITEMS); + } + + defineGrid() { + this.columns = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso }, + { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + + this.gridOptions = { + enableCellNavigation: true, + enableColumnReorder: true, + enableResizeByContent: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + showColumnHeader: true, + showHeaderRow: true, + showFooterRow: false, + }; + } + + mockData(count: number) { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + } +} diff --git a/demos/aurelia/src/my-app.ts b/demos/aurelia/src/my-app.ts index 3ce7d1a920..098617cf70 100644 --- a/demos/aurelia/src/my-app.ts +++ b/demos/aurelia/src/my-app.ts @@ -62,6 +62,7 @@ const myRoutes: Routeable[] = [ { path: 'example54', component: () => import('./examples/slickgrid/example54.js'), title: '54- AI / Web MCP Toolkit' }, { path: 'example55', component: () => import('./examples/slickgrid/example55.js'), title: '55- Variable Row Height (provider)' }, { path: 'example56', component: () => import('./examples/slickgrid/example56.js'), title: '56- Variable Row Height (metadata)' }, + { path: 'example57', component: () => import('./examples/slickgrid/example57.js'), title: '57- RTL (Right-to-Left) Support' }, { path: 'home', component: () => import('./home-page.js'), title: 'Home' }, ]; @route({ diff --git a/demos/aurelia/test/cypress/e2e/example57.cy.ts b/demos/aurelia/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..9709b015ee --- /dev/null +++ b/demos/aurelia/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,67 @@ +describe('Example 57 - RTL (Right-to-Left) Support', () => { + const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.window().then((win) => cy.spy(win.console, 'log')); + }); + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example57`); + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left) Support'); + }); + + it('should have grid with RTL direction class', () => { + cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + }); + + it('should have exact column titles', () => { + cy.get('.grid-rtl') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should be able to resize a column', () => { + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + const originalWidth = $header.width(); + + // Find and drag the resize handle + cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + .should('exist') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 300, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + // Verify the column width changed + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + expect($resizedHeader.width()).not.to.equal(originalWidth); + }); + }); + }); + + it('should display multiple rows of data', () => { + cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + }); + + it('should have sorting enabled', () => { + cy.get('.grid-rtl') + .find('.slick-header-column') + .first() + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item') + .should('contain', 'Sort Ascending'); + }); + + it('should work correctly with text column content', () => { + cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + }); +}); diff --git a/demos/react/src/examples/slickgrid/App.tsx b/demos/react/src/examples/slickgrid/App.tsx index 7fe024808c..0861e69970 100644 --- a/demos/react/src/examples/slickgrid/App.tsx +++ b/demos/react/src/examples/slickgrid/App.tsx @@ -58,6 +58,7 @@ const routes = [ { path: 'example54', route: '/example54', element: lazy(() => import('./Example54.js')), title: '54- AI / Web MCP Toolkit' }, { path: 'example55', route: '/example55', element: lazy(() => import('./Example55.js')), title: '55- Variable Row Height (provider)' }, { path: 'example56', route: '/example56', element: lazy(() => import('./Example56.js')), title: '56- Variable Row Height (metadata)' }, + { path: 'example57', route: '/example57', element: lazy(() => import('./Example57.js')), title: '57- RTL (Right-to-Left) Support' }, ]; export default function Routes() { diff --git a/demos/react/src/examples/slickgrid/Example57.tsx b/demos/react/src/examples/slickgrid/Example57.tsx new file mode 100644 index 0000000000..1896a014c4 --- /dev/null +++ b/demos/react/src/examples/slickgrid/Example57.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { Formatters, SlickgridReact, type Column, type GridOption } from 'slickgrid-react'; +import './example57.scss'; + +const NB_ITEMS = 100; + +const Example57: React.FC = () => { + const [gridOptions, setGridOptions] = useState(undefined); + const [columns, setColumns] = useState([]); + const [dataset, setDataset] = useState([]); + + useEffect(() => { + defineGrid(); + const mockData = mockDataset(); + setDataset(mockData); + }, []); + + const defineGrid = () => { + const cols: Column[] = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso }, + { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + setColumns(cols); + + const opts: GridOption = { + enableCellNavigation: true, + enableColumnReorder: true, + enableResizeByContent: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + showColumnHeader: true, + showHeaderRow: true, + showFooterRow: false, + }; + setGridOptions(opts); + }; + + const mockDataset = () => { + const data = []; + for (let i = 0; i < NB_ITEMS; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + }; + + return ( +
+

+ Example 57: RTL (Right-to-Left) Support + + see  + + code + + +

+ +
Basic grid with RTL (Right-to-Left) support enabled for RTL languages.
+ +
+ +
+
+ ); +}; + +export default Example57; diff --git a/demos/react/src/examples/slickgrid/example57.scss b/demos/react/src/examples/slickgrid/example57.scss new file mode 100644 index 0000000000..1b571efad5 --- /dev/null +++ b/demos/react/src/examples/slickgrid/example57.scss @@ -0,0 +1,8 @@ +.grid-rtl { + direction: rtl; + + .slick-resizable-handle { + right: auto !important; + left: 0 !important; + } +} diff --git a/demos/react/test/cypress/e2e/example57.cy.ts b/demos/react/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..9709b015ee --- /dev/null +++ b/demos/react/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,67 @@ +describe('Example 57 - RTL (Right-to-Left) Support', () => { + const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.window().then((win) => cy.spy(win.console, 'log')); + }); + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example57`); + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left) Support'); + }); + + it('should have grid with RTL direction class', () => { + cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + }); + + it('should have exact column titles', () => { + cy.get('.grid-rtl') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should be able to resize a column', () => { + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + const originalWidth = $header.width(); + + // Find and drag the resize handle + cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + .should('exist') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 300, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + // Verify the column width changed + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + expect($resizedHeader.width()).not.to.equal(originalWidth); + }); + }); + }); + + it('should display multiple rows of data', () => { + cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + }); + + it('should have sorting enabled', () => { + cy.get('.grid-rtl') + .find('.slick-header-column') + .first() + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item') + .should('contain', 'Sort Ascending'); + }); + + it('should work correctly with text column content', () => { + cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + }); +}); diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index c705c59a91..4987280e8c 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -43,6 +43,7 @@ import Example42 from './examples/example42.js'; import Example43 from './examples/example43.js'; import Example44 from './examples/example44.js'; import Example45 from './examples/example45.js'; +import Example46 from './examples/example46.js'; import Icons from './examples/icons.js'; import type { RouterConfig } from './interfaces.js'; @@ -96,6 +97,7 @@ export class AppRouting { { route: 'example43', name: 'example43', view: './examples/example43.html', viewModel: Example43, title: 'Example43' }, { route: 'example44', name: 'example44', view: './examples/example44.html', viewModel: Example44, title: 'Example44' }, { route: 'example45', name: 'example45', view: './examples/example45.html', viewModel: Example45, title: 'Example45' }, + { route: 'example46', name: 'example46', view: './examples/example46.html', viewModel: Example46, title: 'Example 46 - RTL Support' }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index 4482f40cfc..8756cc7cb8 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -29,8 +29,7 @@

Slickgrid-Universal

Documentation SlickGrid Icons diff --git a/demos/vanilla/src/examples/example46.html b/demos/vanilla/src/examples/example46.html new file mode 100644 index 0000000000..03656a2d46 --- /dev/null +++ b/demos/vanilla/src/examples/example46.html @@ -0,0 +1,20 @@ +
+

+ Example 46 - RTL (Right-to-Left) Support + with column resizing + +

+ +
Basic grid with RTL (Right-to-Left) support enabled
+ +
+
diff --git a/demos/vanilla/src/examples/example46.scss b/demos/vanilla/src/examples/example46.scss new file mode 100644 index 0000000000..1b571efad5 --- /dev/null +++ b/demos/vanilla/src/examples/example46.scss @@ -0,0 +1,8 @@ +.grid-rtl { + direction: rtl; + + .slick-resizable-handle { + right: auto !important; + left: 0 !important; + } +} diff --git a/demos/vanilla/src/examples/example46.ts b/demos/vanilla/src/examples/example46.ts new file mode 100644 index 0000000000..ee252dd3f4 --- /dev/null +++ b/demos/vanilla/src/examples/example46.ts @@ -0,0 +1,69 @@ +import { Formatters, type Column, type GridOption } from '@slickgrid-universal/common'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { ExampleGridOptions } from './example-grid-options.js'; +import './example46.scss'; + +const NB_ITEMS = 10; + +export default class Example46 { + gridOptions!: GridOption; + columns!: Column[]; + dataset!: any[]; + sgb!: SlickVanillaGridBundle; + + attached() { + this.defineGrid(); + this.dataset = this.mockData(NB_ITEMS); + + this.sgb = new Slicker.GridBundle( + document.querySelector('.grid46') as HTMLDivElement, + this.columns, + { ...ExampleGridOptions, ...this.gridOptions }, + this.dataset + ); + } + + dispose() { + this.sgb?.dispose(); + } + + defineGrid() { + this.columns = [ + { id: 'id', name: 'ID', field: 'id', sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', sortable: true, minWidth: 100, type: 'number' }, + { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso }, + { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + + this.gridOptions = { + enableCellNavigation: true, + enableColumnReorder: true, + gridHeight: 500, + gridWidth: 900, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + showColumnHeader: true, + showHeaderRow: true, + showFooterRow: false, + }; + } + + mockData(count: number) { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + } +} diff --git a/demos/vue/src/components/Example57.vue b/demos/vue/src/components/Example57.vue new file mode 100644 index 0000000000..c77485e531 --- /dev/null +++ b/demos/vue/src/components/Example57.vue @@ -0,0 +1,88 @@ + + + diff --git a/demos/vue/src/components/example57.scss b/demos/vue/src/components/example57.scss new file mode 100644 index 0000000000..2598a7271e --- /dev/null +++ b/demos/vue/src/components/example57.scss @@ -0,0 +1,8 @@ +.grid-rtl { + direction: rtl; + + .slick-resizable-handle { + right: auto !important; + left: 0 !important; + } +} diff --git a/demos/vue/src/router/index.ts b/demos/vue/src/router/index.ts index c6f8e0f472..c9f3c5f85b 100644 --- a/demos/vue/src/router/index.ts +++ b/demos/vue/src/router/index.ts @@ -64,6 +64,7 @@ export const routes: RouteRecordRaw[] = [ { path: '/example54', name: '54- AI / Web MCP Toolkit', component: () => import('../components/Example54.vue') }, { path: '/example55', name: '55- Variable Row Height (provider)', component: () => import('../components/Example55.vue') }, { path: '/example56', name: '56- Variable Row Height (metadata)', component: () => import('../components/Example56.vue') }, + { path: '/example57', name: '57- RTL (Right-to-Left) Support', component: () => import('../components/Example57.vue') }, ]; export const router = createRouter({ diff --git a/demos/vue/test/cypress/e2e/example57.cy.ts b/demos/vue/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..9709b015ee --- /dev/null +++ b/demos/vue/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,67 @@ +describe('Example 57 - RTL (Right-to-Left) Support', () => { + const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.window().then((win) => cy.spy(win.console, 'log')); + }); + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example57`); + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left) Support'); + }); + + it('should have grid with RTL direction class', () => { + cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + }); + + it('should have exact column titles', () => { + cy.get('.grid-rtl') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should be able to resize a column', () => { + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + const originalWidth = $header.width(); + + // Find and drag the resize handle + cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + .should('exist') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 300, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + // Verify the column width changed + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + expect($resizedHeader.width()).not.to.equal(originalWidth); + }); + }); + }); + + it('should display multiple rows of data', () => { + cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + }); + + it('should have sorting enabled', () => { + cy.get('.grid-rtl') + .find('.slick-header-column') + .first() + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item') + .should('contain', 'Sort Ascending'); + }); + + it('should work correctly with text column content', () => { + cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + }); +}); diff --git a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts index 945ede0541..ec0a73b75d 100644 --- a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts +++ b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts @@ -58,6 +58,7 @@ export const routes: Routes = [ { path: 'example54', loadComponent: () => import('./examples/example54.component').then((m) => m.Example54Component) }, { path: 'example55', loadComponent: () => import('./examples/example55.component').then((m) => m.Example55Component) }, { path: 'example56', loadComponent: () => import('./examples/example56.component').then((m) => m.Example56Component) }, + { path: 'example57', loadComponent: () => import('./examples/example57.component').then((m) => m.Example57Component) }, { path: '', redirectTo: '/example34', pathMatch: 'full' }, { path: '**', redirectTo: '/example34', pathMatch: 'full' }, ]; diff --git a/frameworks/angular-slickgrid/src/demos/examples/example57.component.html b/frameworks/angular-slickgrid/src/demos/examples/example57.component.html new file mode 100644 index 0000000000..ff76f0e012 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.html @@ -0,0 +1,21 @@ +
+

+ Example 57: RTL (Right-to-Left) Support + + + code + + +

+
Basic grid with RTL (Right-to-Left) support enabled for RTL languages
+ +
Basic grid with RTL (Right-to-Left) support enabled
+ +
+ +
+
diff --git a/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss b/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss new file mode 100644 index 0000000000..2598a7271e --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss @@ -0,0 +1,8 @@ +.grid-rtl { + direction: rtl; + + .slick-resizable-handle { + right: auto !important; + left: 0 !important; + } +} diff --git a/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts new file mode 100644 index 0000000000..513c330bb1 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts @@ -0,0 +1,65 @@ +import { Component, type OnInit } from '@angular/core'; +import { AngularSlickgridComponent, Formatters, type AngularGridInstance, type Column, type GridOption } from '../../library'; + +const NB_ITEMS = 100; + +@Component({ + templateUrl: './example57.component.html', + styleUrls: ['./example57.component.scss'], + imports: [AngularSlickgridComponent], +}) +export class Example57Component implements OnInit { + angularGrid!: AngularGridInstance; + columns: Column[] = []; + gridOptions!: GridOption; + dataset!: any[]; + + ngOnInit(): void { + this.prepareGrid(); + this.dataset = this.mockData(NB_ITEMS); + } + + angularGridReady(angularGrid: AngularGridInstance) { + this.angularGrid = angularGrid; + } + + prepareGrid() { + this.columns = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso }, + { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + + this.gridOptions = { + enableCellNavigation: true, + enableColumnReorder: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + showColumnHeader: true, + showHeaderRow: true, + showFooterRow: false, + }; + } + + mockData(count: number) { + const data = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + } +} diff --git a/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts b/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..9709b015ee --- /dev/null +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,67 @@ +describe('Example 57 - RTL (Right-to-Left) Support', () => { + const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.window().then((win) => cy.spy(win.console, 'log')); + }); + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example57`); + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left) Support'); + }); + + it('should have grid with RTL direction class', () => { + cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + }); + + it('should have exact column titles', () => { + cy.get('.grid-rtl') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should be able to resize a column', () => { + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + const originalWidth = $header.width(); + + // Find and drag the resize handle + cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + .should('exist') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 300, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + // Verify the column width changed + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + expect($resizedHeader.width()).not.to.equal(originalWidth); + }); + }); + }); + + it('should display multiple rows of data', () => { + cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + }); + + it('should have sorting enabled', () => { + cy.get('.grid-rtl') + .find('.slick-header-column') + .first() + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item') + .should('contain', 'Sort Ascending'); + }); + + it('should work correctly with text column content', () => { + cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + }); +}); diff --git a/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts b/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts new file mode 100644 index 0000000000..a7ec9ea909 --- /dev/null +++ b/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Column, GridOption } from '../../interfaces/index.js'; +import { SlickGrid } from '../slickGrid.js'; + +vi.useFakeTimers(); + +const DEFAULT_GRID_HEIGHT = 600; +const DEFAULT_GRID_WIDTH = 800; +const gridId = 'grid1'; +const gridUid = 'slickgrid_124343'; +const containerId = 'demo-container'; + +const template = `
+
+
+
+
`; + +describe('SlickGrid RTL (Right-to-Left) Support', () => { + let container: HTMLElement; + let grid: SlickGrid; + const items = [ + { id: 0, name: 'Item 0', value: 10 }, + { id: 1, name: 'Item 1', value: 20 }, + { id: 2, name: 'Item 2', value: 30 }, + ]; + const columns = [ + { id: 'id', field: 'id', name: 'ID', width: 60, resizable: true }, + { id: 'name', field: 'name', name: 'Name', width: 100, resizable: true }, + { id: 'value', field: 'value', name: 'Value', width: 80, resizable: true }, + ] as Column[]; + let defaultOptions: GridOption; + + beforeEach(() => { + defaultOptions = { + enableCellNavigation: true, + columnResizingDelay: 1, + scrollRenderThrottling: 1, + }; + container = document.createElement('div'); + container.id = gridId; + container.innerHTML = template; + container.style.height = `${DEFAULT_GRID_HEIGHT}px`; + container.style.width = `${DEFAULT_GRID_WIDTH}px`; + document.body.appendChild(container); + Object.defineProperty(container, 'clientHeight', { writable: true, configurable: true, value: DEFAULT_GRID_HEIGHT }); + Object.defineProperty(container, 'clientWidth', { writable: true, configurable: true, value: DEFAULT_GRID_WIDTH }); + }); + + afterEach(() => { + document.body.textContent = ''; + grid?.destroy(true); + }); + + describe('RTL Option', () => { + it('should have rtl option set to false by default', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, defaultOptions); + expect(grid.getOptions().rtl).toBe(false); + }); + + it('should enable RTL mode when rtl option is set to true', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + }); + + describe('Column Resizing in RTL', () => { + it('should handle RTL mode with resizable columns', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, editable: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + }); + + describe('applyColumnWidths with RTL', () => { + it('should apply column widths in RTL mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should apply column widths in LTR mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: false }); + expect(grid.getOptions().rtl).toBe(false); + }); + }); + + describe('Resize constraints with RTL', () => { + it('should calculate resize constraints correctly in RTL mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, editable: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should calculate resize constraints correctly in LTR mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: false, editable: true }); + expect(grid.getOptions().rtl).toBe(false); + }); + }); + + describe('Mixed RTL Features', () => { + it('should support RTL with frozen columns', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, frozenColumn: 0 }); + expect(grid.getOptions().rtl).toBe(true); + expect(grid.getOptions().frozenColumn).toBe(0); + }); + + it('should support RTL with sorting', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, enableSorting: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should support RTL with filtering', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, enableFiltering: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + }); +}); diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 8f930996ac..b3e02434ee 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -315,6 +315,7 @@ export class SlickGrid = Column, O e enableMouseWheelScrollHandler: true, doPaging: true, rowTopOffsetRenderType: 'top', + rtl: false, scrollRenderThrottling: 10, suppressCssChangesOnHiddenInit: false, ffMaxSupportedCssHeight: 6000000, @@ -787,12 +788,12 @@ export class SlickGrid = Column, O e // Append the columnn containers to the headers this._headerL = createDomElement( 'div', - { className: 'slick-header-columns slick-header-columns-left', style: { left: '-1000px' }, role: 'row' }, + { className: 'slick-header-columns slick-header-columns-left', style: { [this.dirSide]: '-1000px' }, role: 'row' }, this._headerScrollerL ); this._headerR = createDomElement( 'div', - { className: 'slick-header-columns slick-header-columns-right', style: { left: '-1000px' }, role: 'row' }, + { className: 'slick-header-columns slick-header-columns-right', style: { [this.dirSide]: '-1000px' }, role: 'row' }, this._headerScrollerR ); @@ -2377,7 +2378,12 @@ export class SlickGrid = Column, O e ) => { this.columnResizeDragging = true; let actualMinWidth; - const d = Math.min(maxPageX, Math.max(minPageX, targetPageX)) - pageX; + let d = Math.min(maxPageX, Math.max(minPageX, targetPageX)) - pageX; + + if (this._options.rtl) { + d = -d; + } + let x; let newCanvasWidthL = 0; // oxlint-disable-next-line no-unused-vars @@ -2636,8 +2642,13 @@ export class SlickGrid = Column, O e shrinkLeewayOnLeft += (c.previousWidth || 0) - Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); } } - maxPageX = pageX + Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); - minPageX = pageX - Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + if (this._options.rtl) { + maxPageX = pageX + Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + minPageX = pageX - Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); + } else { + maxPageX = pageX + Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); + minPageX = pageX - Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + } resizeAutoScrollDeltaX = 0; autoScrollClientX = isDefinedNumber((targetEvent as MouseEvent).clientX) ? (targetEvent as MouseEvent).clientX : undefined; stopColumnResizeAutoScroll(); @@ -2919,8 +2930,8 @@ export class SlickGrid = Column, O e (this._options.shadowRoot || document.head).appendChild(this._style); 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; }`, @@ -3331,12 +3342,22 @@ export class SlickGrid = Column, O e w = this.columns[i].hidden ? 0 : this.columns[i].width || 0; rule = this.getColumnCssRules(i); - if (rule.left) { - rule.left.style.left = `${x}px`; - } - if (rule.right) { - rule.right.style.right = - (this._options.frozenColumn !== -1 && i > this._options.frozenColumn! ? this.canvasWidthR : this.canvasWidthL) - x - w + 'px'; + if (this._options.rtl) { + if (rule.left) { + rule.left.style.right = `${x}px`; + } + if (rule.right) { + rule.right.style.left = + (this._options.frozenColumn !== -1 && i > this._options.frozenColumn! ? this.canvasWidthR : this.canvasWidthL) - x - w + 'px'; + } + } else { + if (rule.left) { + rule.left.style.left = `${x}px`; + } + if (rule.right) { + 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 @@ -8128,4 +8149,16 @@ export class SlickGrid = Column, O e sanitizeHtmlString(dirtyHtml: unknown): T { return runOptionalHtmlSanitizer(dirtyHtml, this._options?.sanitizer); } + + /** + * 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(): string { + return this._options.rtl ? 'right' : 'left'; + } } diff --git a/packages/common/src/extensions/slickGridMenu.ts b/packages/common/src/extensions/slickGridMenu.ts index 2717a6e89e..02f6b23350 100644 --- a/packages/common/src/extensions/slickGridMenu.ts +++ b/packages/common/src/extensions/slickGridMenu.ts @@ -146,6 +146,12 @@ export class SlickGridMenu extends MenuBaseClass { } this._userOriginalGridMenu = { ...this.sharedService.gridOptions.gridMenu }; this._addonOptions = { ...this._defaults, ...this.getDefaultGridMenuOptions(), ...this.sharedService.gridOptions.gridMenu }; + + // adjust dropSide for RTL mode (menu should open to the right when button is on the left in RTL) + if (this.sharedService.gridOptions.rtl) { + this._addonOptions.dropSide = 'right'; + } + this.sharedService.gridOptions.gridMenu = this._addonOptions; // merge original user grid menu items with internal items diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index 8db2cbd7a7..5f5c2fa619 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -863,6 +863,9 @@ export interface GridOption { /** Defaults to 400, duration to show the row highlight (e.g. after insert/edit/...) */ rowHighlightDuration?: number; + /** Defaults to false, sets the grid direction to RTL (Right-to-Left) for proper rendering of RTL languages */ + rtl?: boolean; + /** Row Move Manager Plugin options & events */ rowMoveManager?: RowMoveManager; diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index e8e6d679a6..b8c57784fe 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -322,6 +322,12 @@ display: flex; } + [dir='rtl'] .slick-preheader-container, + [dir='rtl'] .slick-header-container, + [dir='rtl'] .slick-headerrow { + flex-direction: row-reverse; + } + .slick-pane-top { box-sizing: border-box; border-top: var(--slick-pane-top-border-top, v.$slick-pane-top-border-top); @@ -409,7 +415,7 @@ border-left: 0px !important; border-top: 0px !important; border-bottom: 0px !important; - float: left; + // float: left; } .slick-header-column { diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 071b4303d1..918daee6df 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -257,6 +257,11 @@ li.hidden { } } +[dir='rtl'] .slick-grid-menu-button { + right: auto; + left: 0; +} + .slick-grid-menu-list { li { width: auto; @@ -480,7 +485,7 @@ li.hidden { * This makes all "float:right" elements after it spill over to the next line * display way below the lower boundary of the column thus hiding them. */ - float: left; + // float: left; margin-bottom: 100px; } diff --git a/test/cypress/e2e/example46.cy.ts b/test/cypress/e2e/example46.cy.ts new file mode 100644 index 0000000000..37022586c7 --- /dev/null +++ b/test/cypress/e2e/example46.cy.ts @@ -0,0 +1,71 @@ +describe('Example 46 - RTL (Right-to-Left) Support', () => { + const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.window().then((win) => cy.spy(win.console, 'log')); + }); + + it('should display Example title', () => { + cy.visit(Cypress.config('baseUrl') as string, { timeout: 200000 }); + // Navigate to the example + cy.get('nav a[href*="example46"]').click(); + cy.get('h3').should('contain', 'Example 46'); + cy.get('h3').should('contain', 'RTL'); + }); + + it('should have grid with RTL direction class', () => { + cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + }); + + it('should have exact column titles', () => { + cy.get('.grid-rtl') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should be able to resize a column', () => { + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + const originalWidth = $header.width(); + + // Find and drag the resize handle + cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + .should('exist') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 300, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + // Verify the column width changed + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + expect($resizedHeader.width()).not.to.equal(originalWidth); + }); + }); + }); + + it('should display multiple rows of data', () => { + cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + }); + + it('should have sorting enabled', () => { + cy.get('.grid-rtl') + .find('.slick-header-column') + .first() + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item') + .first() + .should('contain', 'Sort Ascending'); + }); + + it('should work correctly with text column content', () => { + cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + }); +}); diff --git a/test/cypress/e2e/example57.cy.ts b/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..2a75502d02 --- /dev/null +++ b/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,69 @@ +describe('Example 57 - RTL (Right-to-Left) Support', () => { + const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.window().then((win) => cy.spy(win.console, 'log')); + }); + + it('should display Example title', () => { + cy.visit(Cypress.config('baseUrl') as string, { timeout: 200000 }); + cy.get('h2, h3').should('contain', 'Example 57'); + cy.get('h2, h3').should('contain', 'RTL'); + }); + + it('should have grid with RTL direction class', () => { + cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + }); + + it('should have exact column titles', () => { + cy.get('.grid-rtl') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should be able to resize a column', () => { + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + const originalWidth = $header.width(); + + // Find and drag the resize handle + cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + .should('exist') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 300, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + // Verify the column width changed + cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + expect($resizedHeader.width()).not.to.equal(originalWidth); + }); + }); + }); + + it('should display multiple rows of data', () => { + cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + }); + + it('should have sorting enabled', () => { + cy.get('.grid-rtl') + .find('.slick-header-column') + .first() + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item') + .first() + .should('contain', 'Sort Ascending'); + }); + + it('should work correctly with text column content', () => { + cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + }); +}); From 3f5756fc799482d717641a45680e61c64cee5072 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 8 Aug 2026 00:17:35 -0400 Subject: [PATCH 2/8] chore: fix all the RTL issues and examples --- .../src/examples/slickgrid/example57.html | 4 +- .../src/examples/slickgrid/example57.scss | 8 --- .../src/examples/slickgrid/example57.ts | 32 ++++++--- demos/aurelia/src/my-app.ts | 2 +- .../aurelia/test/cypress/e2e/example57.cy.ts | 20 +++--- demos/react/src/examples/slickgrid/App.tsx | 2 +- .../src/examples/slickgrid/Example57.tsx | 41 +++++++---- .../src/examples/slickgrid/example57.scss | 8 --- demos/react/test/cypress/e2e/example57.cy.ts | 20 +++--- demos/vanilla/src/app-routing.ts | 2 +- demos/vanilla/src/examples/example46.html | 8 +-- demos/vanilla/src/examples/example46.scss | 9 ++- demos/vanilla/src/examples/example46.ts | 36 ++++++---- demos/vue/src/components/Example57.vue | 12 ++-- demos/vue/src/components/example57.scss | 8 --- demos/vue/src/router/index.ts | 2 +- demos/vue/test/cypress/e2e/example57.cy.ts | 20 +++--- .../src/demos/app.component.html | 3 + .../demos/examples/example57.component.html | 6 +- .../src/demos/examples/example57.component.ts | 43 ++++++++---- .../test/cypress/e2e/example57.cy.ts | 20 +++--- .../src/core/__tests__/slickGrid-rtl.spec.ts | 2 +- packages/common/src/styles/_variables.scss | 4 +- packages/common/src/styles/slick-grid.scss | 17 +++-- packages/common/src/styles/slick-plugins.scss | 30 ++++---- test/cypress/e2e/example46.cy.ts | 26 +++---- test/cypress/e2e/example57.cy.ts | 69 ------------------- 27 files changed, 211 insertions(+), 243 deletions(-) delete mode 100644 demos/aurelia/src/examples/slickgrid/example57.scss delete mode 100644 demos/react/src/examples/slickgrid/example57.scss delete mode 100644 demos/vue/src/components/example57.scss delete mode 100644 test/cypress/e2e/example57.cy.ts diff --git a/demos/aurelia/src/examples/slickgrid/example57.html b/demos/aurelia/src/examples/slickgrid/example57.html index 00ceddeaf5..b51a420492 100644 --- a/demos/aurelia/src/examples/slickgrid/example57.html +++ b/demos/aurelia/src/examples/slickgrid/example57.html @@ -1,5 +1,5 @@

- Example 57: RTL (Right-to-Left) Support + Example 57: RTL (Right-to-Left)

-
Basic grid with RTL (Right-to-Left) support enabled for RTL languages
+
Basic grid with RTL (Right-to-Left) enabled for RTL languages
import('./examples/slickgrid/example54.js'), title: '54- AI / Web MCP Toolkit' }, { path: 'example55', component: () => import('./examples/slickgrid/example55.js'), title: '55- Variable Row Height (provider)' }, { path: 'example56', component: () => import('./examples/slickgrid/example56.js'), title: '56- Variable Row Height (metadata)' }, - { path: 'example57', component: () => import('./examples/slickgrid/example57.js'), title: '57- RTL (Right-to-Left) Support' }, + { path: 'example57', component: () => import('./examples/slickgrid/example57.js'), title: '57- RTL (Right-to-Left)' }, { path: 'home', component: () => import('./home-page.js'), title: 'Home' }, ]; @route({ diff --git a/demos/aurelia/test/cypress/e2e/example57.cy.ts b/demos/aurelia/test/cypress/e2e/example57.cy.ts index 9709b015ee..e5c72c28b9 100644 --- a/demos/aurelia/test/cypress/e2e/example57.cy.ts +++ b/demos/aurelia/test/cypress/e2e/example57.cy.ts @@ -1,4 +1,4 @@ -describe('Example 57 - RTL (Right-to-Left) Support', () => { +describe('Example 57 - RTL (Right-to-Left)', () => { const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; beforeEach(() => { @@ -8,26 +8,26 @@ describe('Example 57 - RTL (Right-to-Left) Support', () => { it('should display Example title', () => { cy.visit(`${Cypress.config('baseUrl')}/example57`); - cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left) Support'); + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); }); it('should have grid with RTL direction class', () => { - cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + cy.get('#grid57').should('exist').should('have.css', 'direction', 'rtl'); }); it('should have exact column titles', () => { - cy.get('.grid-rtl') + cy.get('#grid57') .find('.slick-header-columns') .children() .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); it('should be able to resize a column', () => { - cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + cy.get('#grid57 .slick-header-column:nth(1)').then(($header) => { const originalWidth = $header.width(); // Find and drag the resize handle - cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + cy.get('#grid57 .slick-header-column:nth(1) .slick-resizable-handle') .should('exist') .trigger('mousedown', { which: 1 }) .then(() => { @@ -36,18 +36,18 @@ describe('Example 57 - RTL (Right-to-Left) Support', () => { }); // Verify the column width changed - cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + cy.get('#grid57 .slick-header-column:nth(1)').then(($resizedHeader) => { expect($resizedHeader.width()).not.to.equal(originalWidth); }); }); }); it('should display multiple rows of data', () => { - cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + cy.get('#grid57 .slick-row').should('have.length.greaterThan', 10); }); it('should have sorting enabled', () => { - cy.get('.grid-rtl') + cy.get('#grid57') .find('.slick-header-column') .first() .trigger('mouseover') @@ -62,6 +62,6 @@ describe('Example 57 - RTL (Right-to-Left) Support', () => { }); it('should work correctly with text column content', () => { - cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + cy.get('#grid57 .slick-row .slick-cell:nth(1)').should('contain', 'Task'); }); }); diff --git a/demos/react/src/examples/slickgrid/App.tsx b/demos/react/src/examples/slickgrid/App.tsx index 0861e69970..f44e0121fa 100644 --- a/demos/react/src/examples/slickgrid/App.tsx +++ b/demos/react/src/examples/slickgrid/App.tsx @@ -58,7 +58,7 @@ const routes = [ { path: 'example54', route: '/example54', element: lazy(() => import('./Example54.js')), title: '54- AI / Web MCP Toolkit' }, { path: 'example55', route: '/example55', element: lazy(() => import('./Example55.js')), title: '55- Variable Row Height (provider)' }, { path: 'example56', route: '/example56', element: lazy(() => import('./Example56.js')), title: '56- Variable Row Height (metadata)' }, - { path: 'example57', route: '/example57', element: lazy(() => import('./Example57.js')), title: '57- RTL (Right-to-Left) Support' }, + { path: 'example57', route: '/example57', element: lazy(() => import('./Example57.js')), title: '57- RTL (Right-to-Left)' }, ]; export default function Routes() { diff --git a/demos/react/src/examples/slickgrid/Example57.tsx b/demos/react/src/examples/slickgrid/Example57.tsx index 1896a014c4..090bd215fe 100644 --- a/demos/react/src/examples/slickgrid/Example57.tsx +++ b/demos/react/src/examples/slickgrid/Example57.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { Formatters, SlickgridReact, type Column, type GridOption } from 'slickgrid-react'; -import './example57.scss'; const NB_ITEMS = 100; @@ -13,6 +12,11 @@ const Example57: React.FC = () => { defineGrid(); const mockData = mockDataset(); setDataset(mockData); + document.querySelector('body')?.setAttribute('dir', 'rtl'); // ← Enable RTL mode + + return () => { + document.querySelector('body')?.removeAttribute('dir'); // ← Disable RTL mode + }; }, []); const defineGrid = () => { @@ -21,23 +25,32 @@ const Example57: React.FC = () => { { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, - { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso }, - { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, ]; setColumns(cols); const opts: GridOption = { - enableCellNavigation: true, - enableColumnReorder: true, - enableResizeByContent: true, + enableFiltering: true, gridHeight: 500, gridWidth: 700, rowHeight: 33, rtl: true, // ← Enable RTL mode - showColumnHeader: true, - showHeaderRow: true, - showFooterRow: false, }; setGridOptions(opts); }; @@ -58,10 +71,10 @@ const Example57: React.FC = () => { return data; }; - return ( + return !gridOptions ? null : ( ); }; diff --git a/demos/react/src/examples/slickgrid/example57.scss b/demos/react/src/examples/slickgrid/example57.scss deleted file mode 100644 index 1b571efad5..0000000000 --- a/demos/react/src/examples/slickgrid/example57.scss +++ /dev/null @@ -1,8 +0,0 @@ -.grid-rtl { - direction: rtl; - - .slick-resizable-handle { - right: auto !important; - left: 0 !important; - } -} diff --git a/demos/react/test/cypress/e2e/example57.cy.ts b/demos/react/test/cypress/e2e/example57.cy.ts index 9709b015ee..e5c72c28b9 100644 --- a/demos/react/test/cypress/e2e/example57.cy.ts +++ b/demos/react/test/cypress/e2e/example57.cy.ts @@ -1,4 +1,4 @@ -describe('Example 57 - RTL (Right-to-Left) Support', () => { +describe('Example 57 - RTL (Right-to-Left)', () => { const fullTitles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; beforeEach(() => { @@ -8,26 +8,26 @@ describe('Example 57 - RTL (Right-to-Left) Support', () => { it('should display Example title', () => { cy.visit(`${Cypress.config('baseUrl')}/example57`); - cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left) Support'); + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); }); it('should have grid with RTL direction class', () => { - cy.get('.grid-rtl').should('exist').should('have.css', 'direction', 'rtl'); + cy.get('#grid57').should('exist').should('have.css', 'direction', 'rtl'); }); it('should have exact column titles', () => { - cy.get('.grid-rtl') + cy.get('#grid57') .find('.slick-header-columns') .children() .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); }); it('should be able to resize a column', () => { - cy.get('.grid-rtl .slick-header-column:nth(1)').then(($header) => { + cy.get('#grid57 .slick-header-column:nth(1)').then(($header) => { const originalWidth = $header.width(); // Find and drag the resize handle - cy.get('.grid-rtl .slick-header-column:nth(1) .slick-resizable-handle') + cy.get('#grid57 .slick-header-column:nth(1) .slick-resizable-handle') .should('exist') .trigger('mousedown', { which: 1 }) .then(() => { @@ -36,18 +36,18 @@ describe('Example 57 - RTL (Right-to-Left) Support', () => { }); // Verify the column width changed - cy.get('.grid-rtl .slick-header-column:nth(1)').then(($resizedHeader) => { + cy.get('#grid57 .slick-header-column:nth(1)').then(($resizedHeader) => { expect($resizedHeader.width()).not.to.equal(originalWidth); }); }); }); it('should display multiple rows of data', () => { - cy.get('.grid-rtl .slick-row').should('have.length.greaterThan', 10); + cy.get('#grid57 .slick-row').should('have.length.greaterThan', 10); }); it('should have sorting enabled', () => { - cy.get('.grid-rtl') + cy.get('#grid57') .find('.slick-header-column') .first() .trigger('mouseover') @@ -62,6 +62,6 @@ describe('Example 57 - RTL (Right-to-Left) Support', () => { }); it('should work correctly with text column content', () => { - cy.get('.grid-rtl .slick-row:first-child .slick-cell:nth(1)').should('contain', 'Task'); + cy.get('#grid57 .slick-row .slick-cell:nth(1)').should('contain', 'Task'); }); }); diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index 4987280e8c..1976666f59 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -97,7 +97,7 @@ export class AppRouting { { route: 'example43', name: 'example43', view: './examples/example43.html', viewModel: Example43, title: 'Example43' }, { route: 'example44', name: 'example44', view: './examples/example44.html', viewModel: Example44, title: 'Example44' }, { route: 'example45', name: 'example45', view: './examples/example45.html', viewModel: Example45, title: 'Example45' }, - { route: 'example46', name: 'example46', view: './examples/example46.html', viewModel: Example46, title: 'Example 46 - RTL Support' }, + { route: 'example46', name: 'example46', view: './examples/example46.html', viewModel: Example46, title: 'Example46' }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/examples/example46.html b/demos/vanilla/src/examples/example46.html index 03656a2d46..b09aa046d9 100644 --- a/demos/vanilla/src/examples/example46.html +++ b/demos/vanilla/src/examples/example46.html @@ -1,6 +1,6 @@ -
+

- Example 46 - RTL (Right-to-Left) Support + Example 46 - RTL (Right-to-Left) with column resizing

-
Basic grid with RTL (Right-to-Left) support enabled
+
Basic grid with RTL (Right-to-Left) enabled for RTL languages
-
+
diff --git a/demos/vanilla/src/examples/example46.scss b/demos/vanilla/src/examples/example46.scss index 1b571efad5..2e47ef8bc2 100644 --- a/demos/vanilla/src/examples/example46.scss +++ b/demos/vanilla/src/examples/example46.scss @@ -1,8 +1,13 @@ -.grid-rtl { +.grid46 { direction: rtl; - + --slick-header-menu-display: inline-block; + .slick-resizable-handle { right: auto !important; left: 0 !important; } } + +.demo-container.grid46 { + inset-inline-start: 50px; +} diff --git a/demos/vanilla/src/examples/example46.ts b/demos/vanilla/src/examples/example46.ts index ee252dd3f4..7b4e7be532 100644 --- a/demos/vanilla/src/examples/example46.ts +++ b/demos/vanilla/src/examples/example46.ts @@ -3,7 +3,7 @@ import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanil import { ExampleGridOptions } from './example-grid-options.js'; import './example46.scss'; -const NB_ITEMS = 10; +const NB_ITEMS = 100; export default class Example46 { gridOptions!: GridOption; @@ -21,33 +21,45 @@ export default class Example46 { { ...ExampleGridOptions, ...this.gridOptions }, this.dataset ); + document.querySelector('body')?.setAttribute('dir', 'rtl'); // ← Enable RTL mode } dispose() { this.sgb?.dispose(); + document.querySelector('body')?.removeAttribute('dir'); // ← Disable RTL mode } defineGrid() { this.columns = [ - { id: 'id', name: 'ID', field: 'id', sortable: true, minWidth: 60 }, - { id: 'title', name: 'Title', field: 'title', sortable: true, minWidth: 100 }, - { id: 'duration', name: 'Duration (days)', field: 'duration', sortable: true, minWidth: 100, type: 'number' }, - { id: '%', name: '% Complete', field: 'percentComplete', sortable: true, minWidth: 100, type: 'number' }, - { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso }, - { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso }, + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, ]; this.gridOptions = { - enableCellNavigation: true, - enableColumnReorder: true, + enableFiltering: true, gridHeight: 500, gridWidth: 900, rowHeight: 33, rtl: true, // ← Enable RTL mode - showColumnHeader: true, - showHeaderRow: true, - showFooterRow: false, }; } diff --git a/demos/vue/src/components/Example57.vue b/demos/vue/src/components/Example57.vue index c77485e531..9790e5a610 100644 --- a/demos/vue/src/components/Example57.vue +++ b/demos/vue/src/components/Example57.vue @@ -1,7 +1,6 @@