Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
32 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
2bb07ee
chore: Update SelectDropdown Multi select to use logical properties (…
LinKCoding Apr 22, 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
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1775134595003.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
gamut-styles: minor
---

feat(gamut-styles): add useLogicalProperties hook
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');
});
});
70 changes: 70 additions & 0 deletions packages/gamut-styles/src/utilities/elementDir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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. */
Copy link
Copy Markdown
Contributor

@LinKCoding LinKCoding Apr 7, 2026

Choose a reason for hiding this comment

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

nit: could go over the comments on this file with the robot to refactor them in JSDoc styled comments

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.
*/
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`).
*/
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}.
*
* @param elementRef - Optional ref; when missing or `current` is not an `Element`, uses `document.documentElement`.
*/
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 emit logical CSS properties (`marginInlineStart`, etc.)
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.

nit: "emit" feels a little off to me, maybe "map to"? or "resolve to"?

* 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
35 changes: 11 additions & 24 deletions packages/gamut/src/PopoverContainer/PopoverContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { system } from '@codecademy/gamut-styles';
import {
system,
useElementDir,
useLogicalProperties,
} from '@codecademy/gamut-styles';
import { variance } from '@codecademy/variance';
import styled from '@emotion/styled';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
Expand Down Expand Up @@ -50,35 +54,14 @@
const parent = containers?.parent;

// Memoize scrolling parents to avoid expensive DOM traversals
const scrollingParents = useScrollingParents(
targetRef as React.RefObject<HTMLElement | null>
);
const scrollingParents = useScrollingParents(targetRef);

// Keep onRequestClose ref up to date
useEffect(() => {
onRequestCloseRef.current = onRequestClose;
}, [onRequestClose]);

// Detect RTL direction from the target element and watch for attribute changes so the
// position recalculates when changes occur
const [isRtl, setIsRtl] = useState(false);
useEffect(() => {
const checkDirection = () => {
const target = targetRef?.current;
const el = target instanceof Element ? target : document.documentElement;
setIsRtl(getComputedStyle(el).direction === 'rtl');
};

checkDirection();

const observer = new MutationObserver(checkDirection);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['dir'],
subtree: true,
});
return () => observer.disconnect();
}, [targetRef]);
const isRtl = useElementDir(targetRef) === 'rtl';
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.

Thanks for doing this!
fwiw, I made an addition to the popover ticket that involved this clean up: https://skillsoftdev.atlassian.net/browse/GMT-1598?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

And was doing some clean-up here: #3317

but I like your type clean-up more :) will close mine

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.

that is to say, I'd say assign yourself and mark as done if no one gets back to you re: inverting


const popoverPosition = useMemo(() => {
if (parent !== undefined) {
Expand All @@ -95,6 +78,10 @@
return { styles: {}, physicalStyles: undefined };
}, [parent, x, y, offset, alignment, invertAxis, isRtl]);

// Log logical properties to the console TEST CODE
const logicalProperties = useLogicalProperties();
console.log('dir', isRtl, 'logicalProperties', logicalProperties);

Check failure on line 83 in packages/gamut/src/PopoverContainer/PopoverContainer.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

Unexpected console statement
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.

These can be cleaned up now


useEffect(() => {
const target = targetRef?.current;
if (!target) return;
Expand Down
Loading
Loading