Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7f034fa
chore(GamutProvider): useLogicalProperties hook
dreamwasp Apr 1, 2026
108f425
Merge branch 'main' into cass-gmt-1601
dreamwasp Apr 2, 2026
c0b93a2
add release plan
dreamwasp Apr 2, 2026
e9dae8d
test code
dreamwasp Apr 2, 2026
1dcc89a
feat(gamut-styles): add directionIsRtl utility
dreamwasp Apr 2, 2026
f873f06
get directionality
dreamwasp Apr 2, 2026
37924f5
console.log dir
dreamwasp Apr 6, 2026
4f89003
fix syntaxerror
dreamwasp Apr 7, 2026
afe99d5
added release plan
dreamwasp Apr 7, 2026
058aede
keep console.log
dreamwasp Apr 7, 2026
caf7514
version
dreamwasp Apr 7, 2026
3939c6f
new docs
dreamwasp Apr 7, 2026
e0b009f
fix some other shit
dreamwasp Apr 7, 2026
4dd96c0
fix(InfoTip): update rtl styles (#3319)
dreamwasp Apr 8, 2026
2b40dea
version
dreamwasp Apr 8, 2026
ef51010
Update packages/styleguide/src/lib/Meta/Logical and physical CSS prop…
dreamwasp Apr 8, 2026
87977e8
kenny edits
dreamwasp Apr 8, 2026
264423e
Merge branch 'cass-gmt-1601' of github.com:Codecademy/gamut into cass…
dreamwasp Apr 8, 2026
8975574
fix some other stuff
dreamwasp Apr 8, 2026
80f56ab
fix some other stuff
dreamwasp Apr 13, 2026
550e5e9
fix some other stuff
dreamwasp Apr 7, 2026
29ef404
fix(InfoTip): update rtl styles (#3319)
dreamwasp Apr 8, 2026
1fe7c8c
version
dreamwasp Apr 8, 2026
c5ad7fe
kenny edits
dreamwasp Apr 8, 2026
373e16e
Update packages/styleguide/src/lib/Meta/Logical and physical CSS prop…
dreamwasp Apr 8, 2026
46a2950
Merge branch 'cass-gmt-1601' of github.com:Codecademy/gamut into cass…
dreamwasp Apr 13, 2026
f4c3f38
Update packages/styleguide/src/lib/Meta/Logical and physical CSS prop…
dreamwasp Apr 13, 2026
56011cb
fix releases
dreamwasp Apr 13, 2026
efe7e58
add real changelog message
dreamwasp Apr 13, 2026
3c6a8ee
fix(Pagination): Previous and Next Buttons to respect RTL (#3316)
LinKCoding Apr 14, 2026
6efbb0b
update changelog
dreamwasp Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .lintstagedrc.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import micromatch from 'micromatch';

/** Shell-safe argument for paths that may contain spaces (lint-staged runs commands via a shell). */
const shellArg = (file) => JSON.stringify(file);

export default {
// Use custom function to avoid overlaps that could cause race conditions
[`*`]: (allChanges) => {
Expand All @@ -22,14 +25,16 @@ export default {

if (eslintFiles.length) {
commands.push(
`node_modules/@codecademy/eslint-config/bin/eslint-fix.js ${eslintFiles.join(
' '
)}`
`node_modules/@codecademy/eslint-config/bin/eslint-fix.js ${eslintFiles
.map(shellArg)
.join(' ')}`
);
}

// Run nx format, which will run prettier
commands.push(`nx format:write --files ${allChanges}`);
commands.push(
`nx format:write --files ${allChanges.map(shellArg).join(' ')}`
Comment on lines 3 to +36
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i was getting an error when trying to commit files with spaces, this fixes that.

);

return commands;
},
Expand Down
6 changes: 6 additions & 0 deletions .nx/version-plans/version-plan-1775234017199.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
gamut-styles: minor
gamut: minor
---

Updates the Pagination component to have its Previous/Next buttons' icon reflect the correct dir
6 changes: 6 additions & 0 deletions .nx/version-plans/version-plan-1776109780823.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
gamut-styles: minor
gamut: minor
---

feat: add useLogicalProperties + useElementDir hooks. fix: RTL left-center + right-center ToolTip and Pagination issues.
2 changes: 1 addition & 1 deletion packages/gamut-styles/src/AssetProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as React from 'react';

import { webFonts } from './remoteAssets/fonts';
import { coreTheme } from './themes';
import { FontConfig, getFonts } from './utils/fontUtils';
import { FontConfig, getFonts } from './utilities/fontUtils';

/*
* Only preload woff2 fonts, since woff1 are only included as fallbacks.
Expand Down
4 changes: 2 additions & 2 deletions packages/gamut-styles/src/__tests__/AssetProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { setupRtl } from './testUtils';

const renderView = setupRtl(AssetProvider, {});

jest.mock('../utils/fontUtils', () => ({
jest.mock('../utilities/fontUtils', () => ({
getFonts: jest.fn(),
}));

Expand Down Expand Up @@ -43,7 +43,7 @@ jest.mock('../remoteAssets/fonts', () => ({
},
}));

const mockGetFonts = require('../utils/fontUtils').getFonts;
const mockGetFonts = require('../utilities/fontUtils').getFonts;

describe('AssetProvider', () => {
beforeEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/gamut-styles/src/__tests__/fontLoading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { coreTheme, percipioTheme } from '../themes';
// Type assertion to satisfy Theme interface in GamutProvider from theme.d.ts - this lib is typed to the CoreTheme interface
const typedPercipioTheme = percipioTheme as any;

jest.mock('../utils/fontUtils', () => ({
jest.mock('../utilities/fontUtils', () => ({
getFonts: jest.fn(),
}));

Expand All @@ -29,7 +29,7 @@ jest.mock('../remoteAssets/fonts', () => ({
},
}));

const mockGetFonts = require('../utils/fontUtils').getFonts;
const mockGetFonts = require('../utilities/fontUtils').getFonts;

const mockDocumentFonts = {
load: jest.fn(),
Expand Down
2 changes: 1 addition & 1 deletion packages/gamut-styles/src/globals/Typography.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { css, Global } from '@emotion/react';
import * as React from 'react';

import { coreTheme } from '../themes';
import { FontConfig, getFonts } from '../utils/fontUtils';
import { FontConfig, getFonts } from '../utilities/fontUtils';

/**
* Typography component that applies global typography styles to the application.
Expand Down
2 changes: 1 addition & 1 deletion packages/gamut-styles/src/remoteAssets/fonts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FontConfig } from '../utils/fontUtils';
import { FontConfig } from '../utilities/fontUtils';

export const FONT_ASSET_PATH = `https://www.codecademy.com/gamut`;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { elementDir } from '../elementDir';

describe('elementDir', () => {
afterEach(() => {
document.documentElement.removeAttribute('dir');
});

it('returns rtl when the element has dir="rtl"', () => {
const el = document.createElement('div');
el.setAttribute('dir', 'rtl');
expect(elementDir(el)).toBe('rtl');
});

it('returns ltr when the element has dir="ltr"', () => {
const el = document.createElement('div');
el.setAttribute('dir', 'ltr');
expect(elementDir(el)).toBe('ltr');
});

it('falls back to documentElement dir when computed style is empty (JSDOM)', () => {
const el = document.createElement('div');
document.documentElement.setAttribute('dir', 'rtl');
expect(elementDir(el)).toBe('rtl');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from 'react';

import { setupRtl } from '../../__tests__/testUtils';
import { useElementDir } from '../elementDir';

const DirProbe: React.FC = () => (
<span data-testid="dir">{useElementDir()}</span>
);

const WithRef: React.FC = () => {
const ref = React.useRef<HTMLDivElement>(null);
return (
<>
<div data-testid="dir">{useElementDir(ref)}</div>
<div dir="rtl" ref={ref}>
x
</div>
</>
);
};

const WithSpanRef: React.FC = () => {
const ref = React.useRef<HTMLSpanElement>(null);
return (
<>
<span data-testid="dir">{useElementDir(ref)}</span>
<span dir="rtl" ref={ref}>
x
</span>
</>
);
};

const renderDirProbe = setupRtl(DirProbe, {});
const renderWithRef = setupRtl(WithRef, {});
const renderWithSpanRef = setupRtl(WithSpanRef, {});

describe('useElementDir', () => {
afterEach(() => {
document.documentElement.removeAttribute('dir');
});

it('returns rtl when documentElement has dir=rtl and no ref', () => {
document.documentElement.setAttribute('dir', 'rtl');

const { view } = renderDirProbe();
expect(view.getByTestId('dir')).toHaveTextContent('rtl');
});

it('returns ltr when documentElement has dir=ltr and no ref', () => {
document.documentElement.setAttribute('dir', 'ltr');

const { view } = renderDirProbe();
expect(view.getByTestId('dir')).toHaveTextContent('ltr');
});

it('uses ref target when it is an Element', () => {
const { view } = renderWithRef();
expect(view.getByTestId('dir')).toHaveTextContent('rtl');
});

it('accepts non-div element refs (e.g. span)', () => {
const { view } = renderWithSpanRef();
expect(view.getByTestId('dir')).toHaveTextContent('rtl');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Theme, ThemeProvider } from '@emotion/react';
import * as React from 'react';

import { setupRtl } from '../../__tests__/testUtils';
import { coreTheme as theme } from '../../themes';
import { useLogicalProperties } from '../useLogicalProperties';

const ValueReadout: React.FC = () => <span>{`${useLogicalProperties()}`}</span>;

const HookProbe: React.FC<{ themeForHook?: Theme }> = ({
themeForHook = theme,
}) => (
<ThemeProvider theme={themeForHook}>
<ValueReadout />
</ThemeProvider>
);

const renderView = setupRtl(HookProbe, {});

describe('useLogicalProperties', () => {
it('returns false when theme sets useLogicalProperties: false', () => {
const { view } = renderView({
themeForHook: { ...theme, useLogicalProperties: false },
});

view.getByText('false');
});

it('returns true when theme sets useLogicalProperties: true', () => {
const { view } = renderView({
themeForHook: { ...theme, useLogicalProperties: true },
});

view.getByText('true');
});
});
80 changes: 80 additions & 0 deletions packages/gamut-styles/src/utilities/elementDir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { RefObject } from 'react';
import { useEffect, useLayoutEffect, useReducer } from 'react';

/**
* Resolved HTML `dir` keyword: effective writing direction after `dir`, then CSS
* `direction`, then document root.
*/
export type DirValue = 'rtl' | 'ltr';

/**
* Resolves the effective `dir` for an element (`rtl` or `ltr`), including JSDOM where
* `getComputedStyle(el).direction` is often empty while `dir` is set on the root.
*
* @param el - DOM node whose effective direction is resolved.
* @returns `rtl` or `ltr`.
*/
export function elementDir(el: Element): DirValue {
const ownDir = el.getAttribute('dir');
if (ownDir === 'rtl') return 'rtl';
if (ownDir === 'ltr') return 'ltr';
const { direction } = getComputedStyle(el);
if (direction === 'rtl' || direction === 'ltr') {
return direction;
}
return document.documentElement.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr';
}

/**
* Ref whose `current` may be any DOM {@link Element} subclass (`HTMLElement`, `SVGElement`,
* `HTMLButtonElement`, etc.). For structural/minimal types (e.g. tests), intersect with
* `Element` so `current` is still typed as an `Element` (e.g. `Pick<HTMLAnchorElement, 'id'> & Element`).
*
* @template T - DOM element type for `current`; defaults to {@link Element}.
*/
export type ElementDirRef<T extends Element = Element> = RefObject<T | null>;

function resolveElement<T extends Element>(
elementRef: ElementDirRef<T> | undefined
): Element {
return elementRef?.current instanceof Element
? elementRef.current
: document.documentElement;
}

/**
* Returns the effective `dir` for the resolved element (`rtl` or `ltr`), and updates when `dir`
* changes on the document subtree or after layout (so `ref.current` is current).
* Resolution uses {@link elementDir}.
*
* @template T - DOM element type for the optional ref; defaults to {@link Element}.
* @param elementRef - Optional ref; when missing or `current` is not an `Element`, uses `document.documentElement`.
* @returns Effective direction for the resolved element, or `ltr` when `document` is undefined (SSR).
*/
export function useElementDir<T extends Element = Element>(
elementRef?: ElementDirRef<T>
): DirValue {
const [, bump] = useReducer((n: number) => n + 1, 0);

useLayoutEffect(() => {
bump();
}, [elementRef]);

useEffect(() => {
const observer = new MutationObserver(() => {
bump();
});
observer.observe(document.documentElement, {
attributeFilter: ['dir'],
attributes: true,
subtree: true,
});
return () => observer.disconnect();
}, []);

if (typeof document === 'undefined') {
return 'ltr';
}

return elementDir(resolveElement(elementRef));
}
2 changes: 2 additions & 0 deletions packages/gamut-styles/src/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './elementDir';
export * from './themed';
export * from './useLogicalProperties';
12 changes: 12 additions & 0 deletions packages/gamut-styles/src/utilities/useLogicalProperties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useTheme } from '@emotion/react';

/**
* Whether Gamut system props map to logical CSS properties (`marginInlineStart`, etc.)
* vs physical (`marginLeft`, etc.).
*
* `GamutProvider` always merges an explicit boolean (default `false`).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noting that this should change later when the default is also changed

*/
export function useLogicalProperties() {
const theme = useTheme();
return theme?.useLogicalProperties;
}
2 changes: 1 addition & 1 deletion packages/gamut/src/DataList/Controls/FilterControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const FilterControl: React.FC<FilterProps> = ({
alignment={justify === 'left' ? 'bottom-right' : 'bottom-left'}
isOpen
offset={0}
targetRef={target as any}
targetRef={target}
onRequestClose={() => setMenuOpen(false)}
>
<Menu
Expand Down
16 changes: 11 additions & 5 deletions packages/gamut/src/Pagination/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import {
MiniChevronLeftIcon,
MiniChevronRightIcon,
} from '@codecademy/gamut-icons';
import { useMemo, useState } from 'react';
import { useElementDir } from '@codecademy/gamut-styles';
import { useMemo, useRef, useState } from 'react';
import * as React from 'react';

import { HiddenText } from '..';
import { Text } from '..';
import { FlexBox } from '../Box';
import {
AnimatedFadeButton,
Expand Down Expand Up @@ -72,6 +73,8 @@ export const Pagination: React.FC<PaginationProps> = ({
);
const [liveText, setLiveText] = useState('');
const [shownPageArray, setShownPageArray] = useState([0]);
const rootRef = useRef<HTMLDivElement>(null);
const isRtl = useElementDir(rootRef) === 'rtl';

const showSkipToButtons = !!(
(type === undefined && totalPages >= 10) ||
Expand Down Expand Up @@ -145,13 +148,16 @@ export const Pagination: React.FC<PaginationProps> = ({
_: 'initial',
sm: `${showSkipToButtons ? getMinWidth({ chapterSize }) : 'initial'}`,
}}
ref={rootRef}
>
<HiddenText aria-live="polite">{liveText}</HiddenText>
<Text aria-live="polite" screenreader>
{liveText}
</Text>
<AnimatedFadeButton
aria-label={`Navigate back to page ${currentPage - 1}`}
buttonType={variant}
href={navigation}
icon={MiniChevronLeftIcon}
icon={isRtl ? MiniChevronRightIcon : MiniChevronLeftIcon}
showButton={currentPage === 1 ? 'hidden' : 'shown'}
onClick={() => changeHandler(currentPage - 1)}
/>
Expand Down Expand Up @@ -230,7 +236,7 @@ export const Pagination: React.FC<PaginationProps> = ({
aria-label={`Navigate forward to page ${currentPage + 1}`}
buttonType={variant}
href={navigation}
icon={MiniChevronRightIcon}
icon={isRtl ? MiniChevronLeftIcon : MiniChevronRightIcon}
showButton={currentPage === totalPages ? 'hidden' : 'shown'}
onClick={() => changeHandler(currentPage + 1)}
/>
Expand Down
Loading
Loading