diff --git a/demos/aurelia/src/examples/slickgrid/example57.html b/demos/aurelia/src/examples/slickgrid/example57.html new file mode 100644 index 000000000..da89bf44f --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.html @@ -0,0 +1,24 @@ +

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

+ +
Basic grid with RTL (Right-to-Left) enabled for RTL languages
+ +
+ +
diff --git a/demos/aurelia/src/examples/slickgrid/example57.ts b/demos/aurelia/src/examples/slickgrid/example57.ts new file mode 100644 index 000000000..867aa840b --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.ts @@ -0,0 +1,78 @@ +import { Formatters, type Column, type GridOption } from 'aurelia-slickgrid'; + +const NB_ITEMS = 100; + +export class Example57 { + gridOptions!: GridOption; + columns: Column[] = []; + dataset: any[] = []; + previousBodyDir: string | null = null; + + constructor() { + this.defineGrid(); + } + + attached() { + this.previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + this.dataset = this.mockData(NB_ITEMS); + } + + detached() { + if (this.previousBodyDir) { + document.body.setAttribute('dir', this.previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + } + + 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, + 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 = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + } + + 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 3ce7d1a92..562a2e129 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)' }, { 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 000000000..df73fe98f --- /dev/null +++ b/demos/aurelia/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/demos/react/src/examples/slickgrid/App.tsx b/demos/react/src/examples/slickgrid/App.tsx index 7fe024808..f44e0121f 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)' }, ]; 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 000000000..1ec7ff431 --- /dev/null +++ b/demos/react/src/examples/slickgrid/Example57.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { Formatters, SlickgridReact, type Column, type GridOption } from 'slickgrid-react'; + +const NB_ITEMS = 100; + +const Example57: React.FC = () => { + const [gridOptions, setGridOptions] = useState(undefined); + const [columns, setColumns] = useState([]); + const [dataset, setDataset] = useState([]); + + useEffect(() => { + const previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + + defineGrid(); + const mockData = mockDataset(); + setDataset(mockData); + + return () => { + if (previousBodyDir) { + document.body.setAttribute('dir', previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + }; + }, []); + + 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, + 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 = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + 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 !gridOptions ? null : ( +
+

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

+ +
Basic grid with RTL (Right-to-Left) enabled for RTL languages.
+ +
+ +
+
+ ); +}; + +export default Example57; 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 000000000..df73fe98f --- /dev/null +++ b/demos/react/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index c705c59a9..1976666f5 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: 'Example46' }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index 4482f40cf..8756cc7cb 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 000000000..4bf26370b --- /dev/null +++ b/demos/vanilla/src/examples/example46.html @@ -0,0 +1,22 @@ +
+

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

+ +
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 new file mode 100644 index 000000000..3b29bebb5 --- /dev/null +++ b/demos/vanilla/src/examples/example46.scss @@ -0,0 +1,8 @@ +.grid46 { + direction: rtl; + --slick-header-menu-display: inline-block; +} + +.demo-container.grid46 { + inset-inline-start: 50px; +} diff --git a/demos/vanilla/src/examples/example46.ts b/demos/vanilla/src/examples/example46.ts new file mode 100644 index 000000000..938a5715b --- /dev/null +++ b/demos/vanilla/src/examples/example46.ts @@ -0,0 +1,88 @@ +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 = 100; + +export default class Example46 { + gridOptions!: GridOption; + columns!: Column[]; + dataset!: any[]; + sgb!: SlickVanillaGridBundle; + previousBodyDir: string | null = null; + + attached() { + this.previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + + 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(); + if (this.previousBodyDir) { + document.body.setAttribute('dir', this.previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + } + + 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, + 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 = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 900, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + } + + 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 000000000..b83f68da9 --- /dev/null +++ b/demos/vue/src/components/Example57.vue @@ -0,0 +1,101 @@ + + + diff --git a/demos/vue/src/router/index.ts b/demos/vue/src/router/index.ts index c6f8e0f47..c5973a1de 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)', 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 000000000..df73fe98f --- /dev/null +++ b/demos/vue/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts index 945ede054..ec0a73b75 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/app.component.html b/frameworks/angular-slickgrid/src/demos/app.component.html index c9e6c2a73..a9a4a5b44 100644 --- a/frameworks/angular-slickgrid/src/demos/app.component.html +++ b/frameworks/angular-slickgrid/src/demos/app.component.html @@ -212,6 +212,9 @@ + 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 000000000..2084b3e6c --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.html @@ -0,0 +1,19 @@ +
+

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

+
Basic grid with RTL (Right-to-Left) enabled for RTL languages
+ +
+ +
+
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 000000000..b52790d72 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss @@ -0,0 +1,3 @@ +.grid-rtl { + direction: rtl; +} 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 000000000..0bac8bca1 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts @@ -0,0 +1,89 @@ +import { Component, type OnDestroy, type OnInit } from '@angular/core'; +import { AngularSlickgridComponent, Formatters, 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, OnDestroy { + columns: Column[] = []; + gridOptions!: GridOption; + dataset!: any[]; + hideSubTitle = false; + previousBodyDir: string | null = null; + + ngOnInit(): void { + this.previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + + this.prepareGrid(); + this.dataset = this.mockData(NB_ITEMS); + } + + ngOnDestroy(): void { + if (this.previousBodyDir) { + document.body.setAttribute('dir', this.previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + } + + 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, + 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 = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + } + + 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; + } + + toggleSubTitle() { + this.hideSubTitle = !this.hideSubTitle; + const action = this.hideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + } +} 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 000000000..df73fe98f --- /dev/null +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); 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 000000000..deee4c79d --- /dev/null +++ b/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Column, GridOption } from '../../interfaces/index.js'; +import { SlickEventData } from '../slickCore.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)', () => { + 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, + devMode: { ownerNodeIndex: 0 }, + }; + 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, 'height', { writable: true, configurable: true, value: DEFAULT_GRID_HEIGHT }); + 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); + }); + + it('should apply RTL class and dir attribute on grid container', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + + expect(gridContainer.classList.contains('slick-rtl')).toBe(true); + expect(gridContainer.getAttribute('dir')).toBe('rtl'); + }); + + it('should not apply RTL class or dir in LTR mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: false }); + + expect(gridContainer.classList.contains('slick-rtl')).toBe(false); + expect(gridContainer.getAttribute('dir')).toBeNull(); + }); + }); + + describe('Visible Range in RTL', () => { + it('should calculate leftPx/rightPx with RTL negative scrollLeft convention', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + + const anyGrid = grid as any; + anyGrid.canvasWidth = 2000; + anyGrid.viewportW = 800; + + const range = grid.getVisibleRange(0, -200); + + expect(range.leftPx).toBe(600); + expect(range.rightPx).toBe(1400); + expect(range.rightPx).toBeGreaterThan(range.leftPx); + }); + }); + + 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); + }); + }); + + describe('Column Resizing', () => { + const columns = [ + { id: 'id', field: 'id', name: 'Id', hidden: true }, + { id: 'firstName', field: 'firstName', name: 'First Name', sortable: true, width: 77, previousWidth: 20, rerenderOnResize: true }, + { id: 'lastName', field: 'lastName', name: 'Last Name', sortable: true, minWidth: 35, maxWidth: 78 }, + { id: 'age', field: 'age', name: 'Age', sortable: true, minWidth: 82, width: 86, maxWidth: 88 }, + { id: 'gender', field: 'gender', name: 'Gender', sortable: true }, + ] as Column[]; + const data = [ + { id: 0, firstName: 'John', lastName: 'Doe', age: 30 }, + { id: 1, firstName: 'Jane', lastName: 'Doe', age: 28 }, + ]; + + it('should resize 2nd column that has a "width" defined using default sizing grid options', () => { + grid = new SlickGrid(container, data, columns, { ...defaultOptions, forceFitColumns: false, rtl: true }); + grid.init(); + + const sedOnBeforeResize = new SlickEventData(); + sedOnBeforeResize.addReturnValue(true); + vi.spyOn(grid.onBeforeColumnsResize, 'notify').mockReturnValue(sedOnBeforeResize); + const onColumnsDragSpy = vi.spyOn(grid.onColumnsDrag, 'notify'); + const onColumnsResizedSpy = vi.spyOn(grid.onColumnsResized, 'notify'); + const columnElms = container.querySelectorAll('.slick-header-column'); + const resizeHandleElm = columnElms[1].querySelector('.slick-resizable-handle') as HTMLDivElement; + + const cMouseDownEvent = new CustomEvent('mousedown'); + const bodyMouseMoveEvent = new CustomEvent('mousemove'); + const bodyMouseUpEvent = new CustomEvent('mouseup'); + Object.defineProperty(bodyMouseMoveEvent, 'target', { writable: true, value: resizeHandleElm }); + Object.defineProperty(cMouseDownEvent, 'pageX', { writable: true, value: 9 }); + Object.defineProperty(cMouseDownEvent, 'pageY', { writable: true, value: 12 }); + Object.defineProperty(bodyMouseMoveEvent, 'pageX', { writable: true, value: -22 }); + Object.defineProperty(bodyMouseMoveEvent, 'pageY', { writable: true, value: 13 }); + + // start resizing + resizeHandleElm.dispatchEvent(cMouseDownEvent); + container.dispatchEvent(cMouseDownEvent); + document.body.dispatchEvent(bodyMouseMoveEvent); + expect(columnElms[1].classList.contains('slick-header-column-active')).toBeTruthy(); + expect(onColumnsDragSpy).toHaveBeenCalledWith({ triggeredByColumn: columnElms[1], resizeHandle: resizeHandleElm, grid }, expect.anything(), grid); + + // header click won't get through + const onHeaderClickSpy = vi.spyOn(grid.onHeaderClick, 'notify'); + container.querySelector('.slick-header')!.dispatchEvent(new CustomEvent('click')); + expect(onHeaderClickSpy).not.toHaveBeenCalled(); + + // end resizing + document.body.dispatchEvent(bodyMouseUpEvent); + + vi.advanceTimersByTime(10); + + expect(columnElms[1].classList.contains('slick-header-column-active')).toBeFalsy(); + expect(onColumnsResizedSpy).toHaveBeenCalledWith({ triggeredByColumn: 'lastName', grid }, expect.anything(), grid); + expect(columns[0].width).toBe(80); + expect(columns[1].width).toBe(0); + expect(columns[2].width).toBe(65); + expect(columns[3].width).toBe(86); + expect(columns[4].width).toBe(80); + }); + + it('should not schedule resize auto-scroll timer in RTL when dragging outside viewport', () => { + grid = new SlickGrid(container, data, columns, { + ...defaultOptions, + forceFitColumns: false, + rtl: true, + autoScrollOnColumnResize: true, + }); + grid.init(); + + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const columnElms = container.querySelectorAll('.slick-header-column'); + const resizeHandleElm = columnElms[1].querySelector('.slick-resizable-handle') as HTMLDivElement; + + const cMouseDownEvent = new CustomEvent('mousedown'); + const bodyMouseMoveEvent = new CustomEvent('mousemove'); + Object.defineProperty(bodyMouseMoveEvent, 'target', { writable: true, value: resizeHandleElm }); + Object.defineProperty(cMouseDownEvent, 'pageX', { writable: true, value: 9 }); + Object.defineProperty(cMouseDownEvent, 'pageY', { writable: true, value: 12 }); + // Simulate dragging outside the viewport edge. + Object.defineProperty(bodyMouseMoveEvent, 'pageX', { writable: true, value: -200 }); + Object.defineProperty(bodyMouseMoveEvent, 'pageY', { writable: true, value: 13 }); + Object.defineProperty(bodyMouseMoveEvent, 'clientX', { writable: true, value: 0 }); + + resizeHandleElm.dispatchEvent(cMouseDownEvent); + container.dispatchEvent(cMouseDownEvent); + document.body.dispatchEvent(bodyMouseMoveEvent); + vi.advanceTimersByTime(120); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + expect((grid as any)._columnResizeAutoScrollTimer).toBeUndefined(); + setIntervalSpy.mockRestore(); + }); + }); +}); diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 8f930996a..71062b07d 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 ); @@ -969,6 +970,8 @@ export class SlickGrid = Column, O e if (!this._options.explicitInitialization) { this.finishInitialization(); } + + this.applyRTL(this._options.rtl ?? false); } protected finishInitialization(): void { @@ -2321,6 +2324,13 @@ export class SlickGrid = Column, O e targetPageX: number, resizeCallback: (targetPageX: number) => void ) => { + // TODO: there is a known bug with auto-scroll in RTL, + // so disable it until someone can contribute a fix + if (this._options.rtl) { + stopColumnResizeAutoScroll(); + return; + } + autoScrollClientX = isDefinedNumber(clientX) ? clientX : autoScrollClientX; const viewportOffset = getOffset(this._viewportScrollContainerX); const left = viewportOffset.left; @@ -2377,7 +2387,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 @@ -2556,6 +2571,7 @@ export class SlickGrid = Column, O e this.updateCanvasWidth(); if ( this._options.autoScrollOnColumnResize && + !this._options.rtl && !this._options.forceFitColumns && !(this.hasFrozenColumns() && i <= this._options.frozenColumn!) ) { @@ -2636,8 +2652,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 +2940,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 +3352,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 @@ -5259,11 +5290,21 @@ 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 mode, scrollLeft is the offset from the right edge. + 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, }; } @@ -5756,7 +5797,7 @@ export class SlickGrid = Column, O e protected _handleScroll(eventType: 'mousewheel' | 'scroll' | 'system' = 'system'): boolean { let maxScrollDistanceY = this._viewportScrollContainerY.scrollHeight - this._viewportScrollContainerY.clientHeight; - let maxScrollDistanceX = this._viewportScrollContainerY.scrollWidth - this._viewportScrollContainerY.clientWidth; + let maxScrollDistanceX = this._viewportScrollContainerX.scrollWidth - this._viewportScrollContainerX.clientWidth; // Protect against erroneous clientHeight/Width greater than scrollHeight/Width. // Sometimes seen in Chrome. @@ -8128,4 +8169,27 @@ 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'; + } + + /** Applies/removes RTL state directly on the grid container. */ + 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'); + } + } } diff --git a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts index 3208c0ccd..ceec305b5 100644 --- a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts @@ -149,6 +149,7 @@ describe('GridMenuControl', () => { enableAutoSizeColumns: true, enableGridMenu: true, enableTranslate: true, + rtl: false, backendServiceApi: { service: { buildQuery: vi.fn(), @@ -2792,5 +2793,15 @@ describe('GridMenuControl', () => { expect(control.getAllColumns()).toEqual(columnsMock); expect(control.getVisibleColumns()).toEqual(columnsMock); }); + + it('should open grid menu on the right when using RTL', () => { + gridOptionsMock.rtl = true; + control.init(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLDivElement; + buttonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const gridMenuElm = document.querySelector('.slick-grid-menu') as HTMLDivElement; + + expect(gridMenuElm.classList.contains('dropright')).toBe(true); + }); }); }); diff --git a/packages/common/src/extensions/slickGridMenu.ts b/packages/common/src/extensions/slickGridMenu.ts index 2717a6e89..02f6b2335 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 8db2cbd7a..5f5c2fa61 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/_variables.scss b/packages/common/src/styles/_variables.scss index dd5e72a37..f36316aae 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -359,7 +359,7 @@ $slick-column-picker-item-hover-border: 1px solid #d5d5d5 !d $slick-column-picker-item-hover-color: #fafafa !default; $slick-column-picker-label-margin: 4px !default; $slick-column-picker-label-font-weight: normal !default; -$slick-column-picker-label-text-padding-left: 4px !default; +$slick-column-picker-label-gap: 4px !default; $slick-column-picker-link-background-color: #ffffff !default; $slick-column-picker-list-margin-bottom: 8px !default; $slick-column-picker-opacity-hover: 0.45 !default; @@ -407,6 +407,7 @@ $slick-menu-item-border: 1px solid transparen $slick-menu-item-border-radius: 0px !default; $slick-menu-item-disabled-color: silver !default; $slick-menu-item-font-size: $slick-font-size-base !default; +$slick-menu-item-gap: 4px !default; $slick-menu-item-height: 28px !default; $slick-menu-item-hover-border: 1px solid #d5d5d5 !default; $slick-menu-item-hover-color: #fafafa !default; @@ -416,7 +417,6 @@ $slick-menu-item-white-space: nowrap !default; $slick-menu-icon-font-size: $slick-icon-font-size !default; $slick-menu-icon-line-height: calc(#{$slick-menu-icon-font-size} + 2px) !default; $slick-menu-item-width-when-button: calc(100% - #{$slick-menu-close-btn-width}) !default; -$slick-menu-icon-margin-right: 4px !default; $slick-menu-icon-min-width: 16px !default; $slick-menu-line-height: 24px !default; $slick-menu-min-width: 140px !default; diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index e8e6d679a..307b2bdc8 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -322,6 +322,20 @@ display: flex; } + .slick-rtl { + .slick-preheader-container, + .slick-header-container, + .slick-headerrow { + flex-direction: row-reverse; + } + + .slick-resizable-handle { + right: auto; + inset-inline-end: auto; + left: -5px; + } + } + .slick-pane-top { box-sizing: border-box; border-top: var(--slick-pane-top-border-top, v.$slick-pane-top-border-top); @@ -410,6 +424,10 @@ border-top: 0px !important; border-bottom: 0px !important; float: left; + + .slick-rtl & { + float: right; + } } .slick-header-column { @@ -610,7 +628,7 @@ top: var(--slick-icon-tree-load-fail-sup-top, v.$slick-icon-tree-load-fail-sup-top); left: var(--slick-icon-tree-load-fail-sup-left, v.$slick-icon-tree-load-fail-sup-left); font-size: var(--slick-icon-tree-load-fail-sup-font-size, v.$slick-icon-tree-load-fail-sup-font-size); - right: var(--slick-icon-tree-load-fail-sup-right, v.$slick-icon-tree-load-fail-sup-right); + inset-inline-end: var(--slick-icon-tree-load-fail-sup-right, v.$slick-icon-tree-load-fail-sup-right); color: var(--slick-icon-tree-load-fail-sup-color, v.$slick-icon-tree-load-fail-sup-color); @include svg.generateSvgStyle('slick-icon-load-fail-sup-svg', v.$slick-icon-tree-load-fail-sup-svg-path); } @@ -679,6 +697,7 @@ } } .slick-header-columns { + display: flex; background: var(--slick-grid-header-background, v.$slick-grid-header-background); background-color: var(--slick-header-background-color, v.$slick-header-background-color); @@ -750,7 +769,7 @@ width: 1em; left: auto; font-size: var(--slick-icon-sort-font-size, v.$slick-icon-sort-font-size); - right: var(--slick-icon-sort-position-right, v.$slick-icon-sort-position-right); + inset-inline-end: var(--slick-icon-sort-position-right, v.$slick-icon-sort-position-right); top: var(--slick-icon-sort-position-top, v.$slick-icon-sort-position-top); } .slick-sort-indicator-numbered { @@ -758,7 +777,7 @@ font-size: var(--slick-sort-indicator-number-font-size, v.$slick-sort-indicator-number-font-size); width: var(--slick-sort-indicator-number-width, v.$slick-sort-indicator-number-width); left: var(--slick-sort-indicator-number-left, v.$slick-sort-indicator-number-left); - right: var(--slick-sort-indicator-number-right, v.$slick-sort-indicator-number-right); + inset-inline-end: var(--slick-sort-indicator-number-right, v.$slick-sort-indicator-number-right); top: var(--slick-sort-indicator-number-top, v.$slick-sort-indicator-number-top); } @@ -789,7 +808,7 @@ top: 0; height: 100%; width: 6px; - right: 0; + inset-inline-end: 0; z-index: 4; &:hover { @@ -800,7 +819,7 @@ border-top: var(--slick-header-resizable-hover-border-top, v.$slick-header-resizable-hover-border-top); border-radius: var(--slick-header-resizable-hover-border-radius, v.$slick-header-resizable-hover-border-radius); width: var(--slick-header-resizable-hover-width, v.$slick-header-resizable-hover-width); - right: var(--slick-header-resizable-hover-right, v.$slick-header-resizable-hover-right); + inset-inline-end: var(--slick-header-resizable-hover-right, v.$slick-header-resizable-hover-right); height: var(--slick-header-resizable-hover-height, v.$slick-header-resizable-hover-height); top: var(--slick-header-resizable-hover-top, v.$slick-header-resizable-hover-top); opacity: var(--slick-header-resizable-hover-opacity, v.$slick-header-resizable-hover-opacity); diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 071b4303d..46988c226 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -41,7 +41,6 @@ li.hidden { } .close { - float: right; position: absolute; color: var(--slick-column-picker-close-btn-color, v.$slick-column-picker-close-btn-color); cursor: var(--slick-column-picker-close-btn-cursor, v.$slick-column-picker-close-btn-cursor); @@ -54,7 +53,7 @@ li.hidden { font-size: var(--slick-column-picker-close-btn-font-size, v.$slick-column-picker-close-btn-font-size); background-color: var(--slick-column-picker-close-btn-bg-color, v.$slick-column-picker-close-btn-bg-color); border: var(--slick-column-picker-close-btn-border, v.$slick-column-picker-close-btn-border); - right: var(--slick-column-picker-close-btn-position-right, v.$slick-column-picker-close-btn-position-right); + inset-inline-end: var(--slick-column-picker-close-btn-position-right, v.$slick-column-picker-close-btn-position-right); top: var(--slick-column-picker-close-btn-position-top, v.$slick-column-picker-close-btn-position-top); &:hover { @@ -132,6 +131,7 @@ li.hidden { height: 100%; width: 100%; margin-bottom: 0px; + gap: var(--slick-column-picker-label-gap, v.$slick-column-picker-label-gap); } } @@ -198,7 +198,6 @@ li.hidden { display: inline-flex; align-items: center; flex-grow: 1; - padding-left: var(--slick-column-picker-label-text-padding-left, v.$slick-column-picker-label-text-padding-left); } } } @@ -245,7 +244,7 @@ li.hidden { border: 0; cursor: pointer; position: absolute; - right: 0; + inset-inline-end: 0; z-index: 2; color: var(--slick-grid-menu-icon-btn-color, v.$slick-grid-menu-icon-btn-color); padding: var(--slick-grid-menu-button-padding, v.$slick-grid-menu-button-padding); @@ -305,7 +304,6 @@ li.hidden { .close { cursor: pointer; - float: right; background-color: var(--slick-menu-close-btn-bg-color, v.$slick-menu-close-btn-bg-color); border: var(--slick-menu-close-btn-border, v.$slick-menu-close-btn-border); color: var(--slick-menu-close-btn-color, v.$slick-menu-close-btn-color); @@ -335,6 +333,7 @@ li.hidden { display: flex; align-items: center; margin: 0; + gap: var(--slick-menu-item-gap, v.$slick-menu-item-gap); outline: none; border: var(--slick-menu-item-border, v.$slick-menu-item-border); border-radius: var(--slick-menu-item-border-radius, v.$slick-menu-item-border-radius); @@ -379,7 +378,6 @@ li.hidden { background-repeat: no-repeat; display: inline-block; line-height: var(--slick-menu-icon-line-height, v.$slick-menu-icon-line-height); - margin-right: var(--slick-menu-icon-margin-right, v.$slick-menu-icon-margin-right); vertical-align: middle; min-width: var(--slick-menu-icon-min-width, v.$slick-menu-icon-min-width); } @@ -483,6 +481,11 @@ li.hidden { float: left; margin-bottom: 100px; } +.slick-column-name { + .slick-rtl & { + float: right; + } +} .slick-header-button { /** @@ -529,7 +532,7 @@ li.hidden { // The next few items are already defined in the slick-headermenu file and it should stay that way, *unless* you also replace the button image included there. bottom: 0; top: 0; - right: var(--slick-header-menu-button-margin-right, v.$slick-header-menu-button-margin-right); + inset-inline-end: var(--slick-header-menu-button-margin-right, v.$slick-header-menu-button-margin-right); height: var(--slick-header-menu-button-icon-size, v.$slick-header-menu-button-icon-size); width: var(--slick-header-menu-button-icon-size, v.$slick-header-menu-button-icon-size); @@ -584,8 +587,6 @@ li.hidden { .slick-column-name, .slick-headerrow-column.checkbox-header, .slick-cell-checkboxsel { - text-align: center; - label { line-height: var(--slick-checkbox-icon-container-line-height, v.$slick-checkbox-icon-container-line-height); } @@ -647,6 +648,8 @@ li.hidden { } } +.slick-headerrow-column.checkbox-header, +.slick-cell-checkboxsel, .slick-header-column.header-checkbox-selectall .slick-column-name { text-align: center; margin-right: 0; @@ -961,7 +964,7 @@ li.hidden { padding: var(--slick-draggable-group-toggle-all-padding, v.$slick-draggable-group-toggle-all-padding); position: var(--draggable-group-toggle-all-position, v.$slick-draggable-group-toggle-all-position); top: var(--slick-draggable-group-toggle-all-top, v.$slick-draggable-group-toggle-all-top); - right: var(--slick-draggable-group-toggle-all-right, v.$slick-draggable-group-toggle-all-right); + inset-inline-end: var(--slick-draggable-group-toggle-all-right, v.$slick-draggable-group-toggle-all-right); .slick-group-toggle-all-icon { cursor: pointer; @@ -1294,5 +1297,5 @@ li.hidden { background: var(--slick-drag-selection-handle-color, v.$slick-drag-selection-handle-color); position: absolute; bottom: 0; - right: 0; + inset-inline-end: 0; } diff --git a/test/cypress/e2e/example46.cy.ts b/test/cypress/e2e/example46.cy.ts new file mode 100644 index 000000000..6b13fec59 --- /dev/null +++ b/test/cypress/e2e/example46.cy.ts @@ -0,0 +1,99 @@ +describe('Example 46 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example46`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h3').should('contain', 'Example 46 - RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('.grid46') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('.grid46') + .first() + .then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('.grid46 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('.grid46 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('.grid46 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('.grid46') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); + + describe('Edge Cases & Stability', () => { + it('should handle max horizontal scroll in RTL mode', () => { + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + cy.get('.grid46 .slick-header-column:visible').last().should('exist'); + }); + }); +});