diff --git a/packages/gamut-styles/src/GamutProvider.tsx b/packages/gamut-styles/src/GamutProvider.tsx index 98b4bc84202..4cc22d73f1a 100644 --- a/packages/gamut-styles/src/GamutProvider.tsx +++ b/packages/gamut-styles/src/GamutProvider.tsx @@ -27,6 +27,10 @@ export interface GamutProviderProps { * Pass a nonce to the cache to prevent CSP errors */ nonce?: string; + /** + * Whether to use logical properties for the theme + */ + useLogicalProperties?: boolean; } export const GamutContext = React.createContext<{ @@ -47,6 +51,7 @@ export const GamutProvider: React.FC = ({ useGlobals = true, useCache = true, nonce, + useLogicalProperties = true, }) => { const { hasGlobals, hasCache } = useContext(GamutContext); const shouldCreateCache = useCache && !hasCache; @@ -60,6 +65,7 @@ export const GamutProvider: React.FC = ({ const contextValue = { hasGlobals: shouldInsertGlobals, hasCache: shouldCreateCache, + useLogicalProperties, }; const globals = shouldInsertGlobals && ( @@ -71,12 +77,20 @@ export const GamutProvider: React.FC = ({ ); + // Merge useLogicalProperties into theme so variance can access it via props.theme + const themeWithLogicalProperties = { + ...theme, + useLogicalProperties, + }; + if (activeCache.current) { return ( {globals} - {children} + + {children} + ); @@ -85,7 +99,9 @@ export const GamutProvider: React.FC = ({ return ( {globals} - {children} + + {children} + ); }; diff --git a/packages/gamut-styles/src/__tests__/GamutProvider.test.tsx b/packages/gamut-styles/src/__tests__/GamutProvider.test.tsx index e32357589e1..7086e7f0fbf 100644 --- a/packages/gamut-styles/src/__tests__/GamutProvider.test.tsx +++ b/packages/gamut-styles/src/__tests__/GamutProvider.test.tsx @@ -56,7 +56,7 @@ describe(GamutProvider, () => { ), }); - screen.getByText(JSON.stringify(theme)); + screen.getByText(JSON.stringify({ ...theme, useLogicalProperties: true })); }); it('it can have another GamutProvider as a child with creating multiple caches or globals', () => { renderView({ diff --git a/packages/gamut-styles/src/variance/config.ts b/packages/gamut-styles/src/variance/config.ts index 4ba51362f45..d4514ecd18b 100644 --- a/packages/gamut-styles/src/variance/config.ts +++ b/packages/gamut-styles/src/variance/config.ts @@ -1,4 +1,4 @@ -import { transformSize } from '@codecademy/variance'; +import { getPropertyMode, transformSize } from '@codecademy/variance'; export const color = { color: { property: 'color', scale: 'colors' }, @@ -233,36 +233,108 @@ export const margin = { m: { property: 'margin', scale: 'spacing' }, mx: { property: 'margin', - properties: ['marginLeft', 'marginRight'], + properties: { + physical: ['marginLeft', 'marginRight'], + logical: ['marginInlineStart', 'marginInlineEnd'], + }, + resolveProperty: getPropertyMode, scale: 'spacing', }, my: { property: 'margin', - properties: ['marginTop', 'marginBottom'], + properties: { + physical: ['marginTop', 'marginBottom'], + logical: ['marginBlockStart', 'marginBlockEnd'], + }, + resolveProperty: getPropertyMode, scale: 'spacing', }, - mt: { property: 'marginTop', scale: 'spacing' }, - mb: { property: 'marginBottom', scale: 'spacing' }, - mr: { property: 'marginRight', scale: 'spacing' }, - ml: { property: 'marginLeft', scale: 'spacing' }, + mt: { + property: { + physical: 'marginTop', + logical: 'marginBlockStart', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + mb: { + property: { + physical: 'marginBottom', + logical: 'marginBlockEnd', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + mr: { + property: { + physical: 'marginRight', + logical: 'marginInlineEnd', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + ml: { + property: { + physical: 'marginLeft', + logical: 'marginInlineStart', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, } as const; export const padding = { p: { property: 'padding', scale: 'spacing' }, px: { property: 'padding', - properties: ['paddingLeft', 'paddingRight'], + properties: { + physical: ['paddingLeft', 'paddingRight'], + logical: ['paddingInlineStart', 'paddingInlineEnd'], + }, scale: 'spacing', + resolveProperty: getPropertyMode, }, py: { property: 'padding', - properties: ['paddingTop', 'paddingBottom'], + properties: { + physical: ['paddingTop', 'paddingBottom'], + logical: ['paddingBlockStart', 'paddingBlockEnd'], + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + pt: { + property: { + physical: 'paddingTop', + logical: 'paddingBlockStart', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + pb: { + property: { + physical: 'paddingBottom', + logical: 'paddingBlockEnd', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + pr: { + property: { + physical: 'paddingRight', + logical: 'paddingInlineEnd', + }, + scale: 'spacing', + resolveProperty: getPropertyMode, + }, + pl: { + property: { + physical: 'paddingLeft', + logical: 'paddingInlineStart', + }, scale: 'spacing', + resolveProperty: getPropertyMode, }, - pt: { property: 'paddingTop', scale: 'spacing' }, - pb: { property: 'paddingBottom', scale: 'spacing' }, - pr: { property: 'paddingRight', scale: 'spacing' }, - pl: { property: 'paddingLeft', scale: 'spacing' }, } as const; export const space = { diff --git a/packages/gamut-tests/src/index.tsx b/packages/gamut-tests/src/index.tsx index 72005789548..700132e1cd4 100644 --- a/packages/gamut-tests/src/index.tsx +++ b/packages/gamut-tests/src/index.tsx @@ -6,13 +6,19 @@ import { import overArgs from 'lodash/overArgs'; import * as React from 'react'; -// See https://www.notion.so/codecademy/Frontend-Unit-Tests-1cbf4e078a6647559b4583dfb6d3cb18 for more info +// See https://skillsoftdev.atlassian.net/wiki/spaces/779a16d9c7ea452eab11b39cbbe771ce/pages/4441315387/Frontend+Unit+Tests for more info -export const MockGamutProvider: React.FC<{ children?: React.ReactNode }> = ({ - children, -}) => { +export const MockGamutProvider: React.FC<{ + children?: React.ReactNode; + useLogicalProperties?: boolean; +}> = ({ children, useLogicalProperties }) => { return ( - + {children} ); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index 95301bddba8..ec472292184 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -1,6 +1,6 @@ -import { setupRtl } from '@codecademy/gamut-tests'; +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; import { fireEvent } from '@testing-library/dom'; -import { act } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ConnectedForm, ConnectedFormGroup, SubmitButton } from '../../..'; @@ -90,21 +90,33 @@ describe('ConnectedNestedCheckboxes', () => { view.getByLabelText('Fastify'); }); - it('should render checkboxes with proper indentation levels', () => { - const { view } = renderView(); - - const frontendCheckbox = view - .getByLabelText('Frontend Technologies') - .closest('li'); - const reactCheckbox = view.getByLabelText('React').closest('li'); - const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); - const expressCheckbox = view.getByLabelText('Express.js').closest('li'); - - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); - expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); - expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); - expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); - }); + it.each([ + { useLogicalProperties: true, marginLeft: 'marginInlineStart' }, + { useLogicalProperties: false, marginLeft: 'marginLeft' }, + ])( + 'should render checkboxes with proper indentation levels (useLogicalProperties: $useLogicalProperties)', + ({ useLogicalProperties, marginLeft }) => { + render( + + + + ); + + const frontendCheckbox = screen + .getByLabelText('Frontend Technologies') + .closest('li'); + const reactCheckbox = screen.getByLabelText('React').closest('li'); + const nodeCheckbox = screen.getByLabelText('Node.js').closest('li'); + const expressCheckbox = screen + .getByLabelText('Express.js') + .closest('li'); + + expect(frontendCheckbox).toHaveStyle({ [marginLeft]: '0' }); + expect(reactCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); + expect(nodeCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); + expect(expressCheckbox).toHaveStyle({ [marginLeft]: '3rem' }); + } + ); it('should render with unique IDs for each checkbox', () => { const { view } = renderView(); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx index 8f9259fb9c4..a616094c737 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -1,3 +1,4 @@ +import { MockGamutProvider } from '@codecademy/gamut-tests'; import { render } from '@testing-library/react'; import { @@ -475,7 +476,7 @@ describe('ConnectedNestedCheckboxes utils', () => { const mockOnBlur = jest.fn(); it('should render a checked checkbox with correct props', () => { - const state = { checked: true }; + const state = { checked: true, indeterminate: false }; const result = renderCheckbox({ option: mockOption, @@ -522,7 +523,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should render an unchecked checkbox with correct props', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const result = renderCheckbox({ option: mockOption, @@ -544,29 +545,39 @@ describe('ConnectedNestedCheckboxes utils', () => { expect(checkbox).toHaveAttribute('aria-checked', 'false'); }); - it('should apply correct margin based on level', () => { - const state = { checked: false }; - - const result = renderCheckbox({ - option: { ...mockOption, level: 2 }, - state, - name: 'test', - isRequired: false, - isDisabled: false, - onBlur: mockOnBlur, - onChange: mockOnChange, - ref: mockRef, - flatOptions: [{ ...mockOption, level: 2 }], - }); + it.each([ + { useLogicalProperties: true, marginLeft: 'marginInlineStart' }, + { useLogicalProperties: false, marginLeft: 'marginLeft' }, + ])( + 'should apply correct margin based on level (useLogicalProperties: $useLogicalProperties)', + ({ useLogicalProperties, marginLeft }) => { + const state = { checked: false, indeterminate: false }; + + const result = renderCheckbox({ + option: { ...mockOption, level: 2 }, + state, + name: 'test', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + flatOptions: [{ ...mockOption, level: 2 }], + }); - const { container } = render(result); - const listItem = container.querySelector('li'); + const { container } = render( + + {result} + + ); + const listItem = container.querySelector('li'); - expect(listItem).toHaveStyle({ marginLeft: '48px' }); // 2 * 24px - }); + expect(listItem).toHaveStyle({ [marginLeft]: '3rem' }); // 24px * 2 = 48px = 3rem + } + ); it('should handle disabled state', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const result = renderCheckbox({ option: { ...mockOption, disabled: true }, @@ -587,7 +598,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should handle error state', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const result = renderCheckbox({ option: mockOption, @@ -609,7 +620,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should use custom aria-label when provided', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const optionWithAriaLabel = { ...mockOption, 'aria-label': 'Custom aria label', @@ -634,7 +645,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should fallback to label text for aria-label when label is string', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const result = renderCheckbox({ option: mockOption, @@ -655,7 +666,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should use default aria-label when label is not string', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const optionWithElementLabel = { ...mockOption, label: Element Label, @@ -680,7 +691,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should generate aria-controls with all nested descendants', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const flatOptions = [ { value: 'parent', @@ -737,7 +748,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }); it('should not have aria-controls for leaf nodes', () => { - const state = { checked: false }; + const state = { checked: false, indeterminate: false }; const flatOptions = [ { value: 'leaf', diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 7c92c4bd4b7..93ba0585de9 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -1,6 +1,6 @@ -import { setupRtl } from '@codecademy/gamut-tests'; +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; import { fireEvent } from '@testing-library/dom'; -import { act } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GridForm } from '../../../GridForm'; @@ -89,21 +89,33 @@ describe('GridFormNestedCheckboxInput', () => { view.getByLabelText('Fastify'); }); - it('should render checkboxes with proper indentation levels', () => { - const { view } = renderView(); - - const frontendCheckbox = view - .getByLabelText('Frontend Technologies') - .closest('li'); - const reactCheckbox = view.getByLabelText('React').closest('li'); - const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); - const expressCheckbox = view.getByLabelText('Express.js').closest('li'); - - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); - expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); - expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); - expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); - }); + it.each([ + { useLogicalProperties: true, marginLeft: 'marginInlineStart' }, + { useLogicalProperties: false, marginLeft: 'marginLeft' }, + ])( + 'should render checkboxes with proper indentation levels (useLogicalProperties: $useLogicalProperties)', + ({ useLogicalProperties, marginLeft }) => { + render( + + + + ); + + const frontendCheckbox = screen + .getByLabelText('Frontend Technologies') + .closest('li'); + const reactCheckbox = screen.getByLabelText('React').closest('li'); + const nodeCheckbox = screen.getByLabelText('Node.js').closest('li'); + const expressCheckbox = screen + .getByLabelText('Express.js') + .closest('li'); + + expect(frontendCheckbox).toHaveStyle({ [marginLeft]: '0' }); + expect(reactCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); + expect(nodeCheckbox).toHaveStyle({ [marginLeft]: '1.5rem' }); + expect(expressCheckbox).toHaveStyle({ [marginLeft]: '3rem' }); + } + ); it('should render with unique IDs for each checkbox', () => { const { view } = renderView(); diff --git a/packages/gamut/src/List/__tests__/List.test.tsx b/packages/gamut/src/List/__tests__/List.test.tsx index 71f2e500f36..a25cb5ed4a2 100644 --- a/packages/gamut/src/List/__tests__/List.test.tsx +++ b/packages/gamut/src/List/__tests__/List.test.tsx @@ -48,15 +48,22 @@ describe('List', () => { expect(rowEl).toHaveStyle({ columnGap: theme.spacing[40] }); }); + // Note: Only testing one mode here since variant() caches styles after first render. it('configures columns with the correct variants', () => { - const { view } = renderView(); + const useLogicalProperties = true; + const paddingLeft = useLogicalProperties + ? 'paddingInlineStart' + : 'paddingLeft'; + const paddingRight = useLogicalProperties + ? 'paddingInlineEnd' + : 'paddingRight'; + const { view } = renderView(); const colEl = view.getByText('Hello'); expect(colEl).not.toHaveStyle({ py: 16 }); - expect(colEl).toHaveStyle({ paddingLeft: theme.spacing[8] }); - expect(colEl).toHaveStyle({ paddingRight: theme.spacing[8] }); - + expect(colEl).toHaveStyle({ [paddingLeft]: theme.spacing[8] }); + expect(colEl).toHaveStyle({ [paddingRight]: theme.spacing[8] }); expect(colEl).not.toHaveStyle({ position: 'sticky' }); }); diff --git a/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx b/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx index 525723c2a4e..0c6a47dd78d 100644 --- a/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx +++ b/packages/styleguide/.storybook/components/Elements/DocsContainer.tsx @@ -52,6 +52,9 @@ export const DocsContainer: React.FC<{ const globalTheme = (context as any).store.userGlobals?.globals?.theme || 'core'; + const globalLogicalProps = (context as any).store.userGlobals?.globals + ?.logicalProps; + const useLogicalProperties = globalLogicalProps !== 'false'; const { currentTheme } = useMemo(() => { const findThemeStory: keyof typeof themeSpecificStories | undefined = @@ -77,6 +80,7 @@ export const DocsContainer: React.FC<{ cache={createEmotionCache({ speedy: false })} // This is typed to the CoreTheme in theme.d.ts theme={currentTheme as unknown as CoreTheme} + useLogicalProperties={useLogicalProperties} > diff --git a/packages/styleguide/.storybook/preview.ts b/packages/styleguide/.storybook/preview.ts index 6dcd4a6f339..c1bc2b1da6e 100644 --- a/packages/styleguide/.storybook/preview.ts +++ b/packages/styleguide/.storybook/preview.ts @@ -49,6 +49,7 @@ const preview: Preview = { 'ESLint rules', 'Contributing', 'FAQs', + 'Logical and physical CSS properties', 'Stories', ], 'Foundations', @@ -163,6 +164,19 @@ export const globalTypes = { showName: true, }, }, + logicalProps: { + name: 'LogicalProps', + description: 'Toggle between logical and physical CSS properties', + defaultValue: 'true', + toolbar: { + icon: 'transfer', + items: [ + { value: 'true', title: 'Logical' }, + { value: 'false', title: 'Physical' }, + ], + showName: true, + }, + }, }; export const decorators = [withEmotion]; diff --git a/packages/styleguide/.storybook/theming/GamutThemeProvider.tsx b/packages/styleguide/.storybook/theming/GamutThemeProvider.tsx index fec952647b8..0e54d335318 100644 --- a/packages/styleguide/.storybook/theming/GamutThemeProvider.tsx +++ b/packages/styleguide/.storybook/theming/GamutThemeProvider.tsx @@ -34,12 +34,14 @@ type GlobalsContext = { globals: { colorMode: 'light' | 'dark'; theme: keyof typeof themeMap; + logicalProps: 'true' | 'false'; }; }; export const withEmotion = (Story: any, context: GlobalsContext) => { const colorMode = context.globals.colorMode ?? 'light'; const selectedTheme = context.globals.theme; + const useLogicalProperties = context.globals.logicalProps !== 'false'; const background = corePalette[themeBackground[colorMode]]; const storyRef = useRef(null); const currentTheme = themeMap[selectedTheme]; @@ -57,6 +59,7 @@ export const withEmotion = (Story: any, context: GlobalsContext) => { { // Wrap all stories in minimal provider return ( - + + @@ -25,4 +26,19 @@ const SpaceExample = styled.div(system.space); ; ``` +These space props support both physical and logical CSS properties and will render the appropriate properties based on `useLogicalProperties`'s value passed into the `` at the root of your application. + + + You can use the LogicalProps button in the toolbar to + switch between modes. + + } +/> + + + + + diff --git a/packages/styleguide/src/lib/Foundations/System/Props/Space.stories.tsx b/packages/styleguide/src/lib/Foundations/System/Props/Space.stories.tsx new file mode 100644 index 00000000000..d94e10f0a0b --- /dev/null +++ b/packages/styleguide/src/lib/Foundations/System/Props/Space.stories.tsx @@ -0,0 +1,48 @@ +import { Box, Markdown } from '@codecademy/gamut'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Foundations/System/Props/Space', + component: Box, +}; + +export default meta; +type Story = StoryObj; + +export const MarginExample: Story = { + render: () => ( + + This box has{' '} + Inspect + the example to see what CSS properties are rendered. + + ), +}; + +export const PaddingExample: Story = { + render: () => ( + + + This box has{' '} + {' '} + Inspect the example to see what CSS properties are rendered. + + + ), +}; diff --git a/packages/styleguide/src/lib/Foundations/shared/elements.tsx b/packages/styleguide/src/lib/Foundations/shared/elements.tsx index a6b0cf500cf..4d345df50be 100644 --- a/packages/styleguide/src/lib/Foundations/shared/elements.tsx +++ b/packages/styleguide/src/lib/Foundations/shared/elements.tsx @@ -9,6 +9,7 @@ import { } from '@codecademy/gamut-styles'; // eslint-disable-next-line gamut/import-paths import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import kebabCase from 'lodash/kebabCase'; @@ -412,29 +413,61 @@ export const DarkModeTable = () => ( ); /* eslint-disable gamut/import-paths */ +const PropertiesRenderer = ({ + property, + properties, + resolveProperty, +}: { + property: string | { physical: string; logical: string }; + properties?: string[] | { physical: string[]; logical: string[] }; + resolveProperty?: (useLogicalProperties: boolean) => 'logical' | 'physical'; +}) => { + const currentTheme = useTheme() as { useLogicalProperties?: boolean }; + const useLogicalProperties = currentTheme?.useLogicalProperties ?? true; + + const mode = resolveProperty + ? resolveProperty(useLogicalProperties) + : 'physical'; + + const resolvedProperty = + typeof property === 'string' ? property : property[mode]; + + let resolvedProperties: string[]; + if (!properties) { + resolvedProperties = [resolvedProperty]; + } else if (Array.isArray(properties)) { + resolvedProperties = properties; + } else { + resolvedProperties = properties[mode]; + } + + return ( + <> + {resolvedProperties.map((prop) => ( + + {kebabCase(prop)} + + ))} + + ); +}; + const PROPERTIES_COLUMN = { key: 'properties', name: 'Properties', size: 'xl', - render: ({ - property, - properties = [property], - }: { - property: string; - properties: string[]; - }) => - properties.map((property) => ( - - {kebabCase(property)} - - )), + render: (props: { + property: string | { physical: string; logical: string }; + properties?: string[] | { physical: string[]; logical: string[] }; + resolveProperty?: (useLogicalProperties: boolean) => 'logical' | 'physical'; + }) => , }; const SCALE_COLUMN = { @@ -450,7 +483,12 @@ const TRANSFORM_COLUMN = { key: 'transform', name: 'Transform', size: 'fill', - render: ({ transform }: any) => transform && {transform?.name}, + render: ({ transform, resolveProperty }: any) => ( + <> + {transform && {transform?.name}} + {resolveProperty && {resolveProperty?.name}} + + ), }; export const defaultColumns = [ diff --git a/packages/styleguide/src/lib/Meta/About.mdx b/packages/styleguide/src/lib/Meta/About.mdx index 6239464d45c..b159c4fafff 100644 --- a/packages/styleguide/src/lib/Meta/About.mdx +++ b/packages/styleguide/src/lib/Meta/About.mdx @@ -13,6 +13,7 @@ import { parameters as deepControlsParameters } from './Deep Controls Add-On.mdx import { parameters as eslintRulesParameters } from './ESLint rules.mdx'; import { parameters as faqsParameters } from './FAQs.mdx'; import { parameters as installationParameters } from './Installation.mdx'; +import { parameters as logicalPhysicalParameters } from './Logical and physical CSS properties.mdx'; import { parameters as storiesParameters } from './Stories.mdx'; import { parameters as usageGuideParameters } from './Usage Guide.mdx'; @@ -34,9 +35,10 @@ export const parameters = { deepControlsParameters, eslintRulesParameters, faqsParameters, + installationParameters, + logicalPhysicalParameters, storiesParameters, brandParameters, - installationParameters, usageGuideParameters, ])} /> diff --git a/packages/styleguide/src/lib/Meta/Logical and physical CSS properties.mdx b/packages/styleguide/src/lib/Meta/Logical and physical CSS properties.mdx new file mode 100644 index 00000000000..cac1e10d7c8 --- /dev/null +++ b/packages/styleguide/src/lib/Meta/Logical and physical CSS properties.mdx @@ -0,0 +1,123 @@ +import { Meta } from '@storybook/blocks'; + +import { + AboutHeader, + Callout, + Code, + ImageWrapper, + TokenTable, +} from '~styleguide/blocks'; + +export const parameters = { + id: 'Meta/Logical and physical CSS properties', + title: 'Logical and physical CSS properties', + subtitle: + 'Understanding CSS logical and physical properties and how Gamut supports both modes.', + status: 'static', +}; + + + + + +## What are CSS logical properties? + +CSS logical properties are a modern approach to styling that adapts to the writing mode and text direction of your content, rather than being tied to physical screen directions. More information can be found on[MDN: CSS Logical Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values) + +### Physical Properties (Traditional) + +Physical properties reference the physical dimensions of the viewport. For example: + +- `margin-left`, `margin-right`, `margin-top`, `margin-bottom` +- `padding-left`, `padding-right`, `padding-top`, `padding-bottom` + +These work well for left-to-right (LTR) languages but require manual overrides for right-to-left (RTL) languages like Arabic or Hebrew. + +### Logical Properties (Modern) + +Logical properties reference the flow of content: + +- **Inline axis** (text direction): `margin-inline-start`, `margin-inline-end` +- **Block axis** (reading direction): `margin-block-start`, `margin-block-end` + +## Using `useLogicalProperties` in Gamut + +Gamut supports both physical and logical CSS properties through the `useLogicalProperties` prop on `GamutProvider`. This allows you to choose which mode your application uses. By default, `useLogicalProperties` is set to `true`, meaning Gamut will use logical CSS properties. If you want to use physical CSS properties, you have to set `useLogicalProperties` to `false`. + +### Affected Props + +Here are some examples of how physical and logical properties are affected by the `useLogicalProperties` prop: + + {prop}, + }, + { + key: 'physical', + name: 'Physical', + size: 'xl', + render: ({ physical }) => + physical.map((p) => ( + <> + {p}{' '} + + )), + }, + { + key: 'logical', + name: 'Logical', + size: 'xl', + render: ({ logical }) => + logical.map((l) => ( + <> + {l}{' '} + + )), + }, + ]} + rows={[ + { + prop: 'mx', + physical: ['margin-left', 'margin-right'], + logical: ['margin-inline-start', 'margin-inline-end'], + }, + { prop: 'mt', physical: ['margin-top'], logical: ['margin-block-start'] }, + { + prop: 'py', + physical: ['padding-top', 'padding-bottom'], + logical: ['padding-block-start', 'padding-block-end'], + }, + { + prop: 'pb', + physical: ['padding-bottom'], + logical: ['padding-block-end'], + }, + ]} +/> + + + Props like m and p (which set all four sides at + once) are not affected by this setting, as the CSS margin and{' '} + padding shorthands work identically in both modes. + + } +/> + +## Previewing in Storybook + +You can toggle between logical and physical properties in Storybook using the **LogicalProps** toolbar button: + + + +This allows you to preview how components render with either property mode without changing any code. diff --git a/packages/styleguide/src/lib/Meta/Usage Guide.mdx b/packages/styleguide/src/lib/Meta/Usage Guide.mdx index fd7d01107fa..d8298ee7632 100644 --- a/packages/styleguide/src/lib/Meta/Usage Guide.mdx +++ b/packages/styleguide/src/lib/Meta/Usage Guide.mdx @@ -39,7 +39,8 @@ On each page is a toolbar located on the top: 1. Grid (the 2x2 collection of squares) - applies a grid to the preview 2. Color mode selector (the circle icon) - toggles between light and dark mode for rendered code examples 3. Theme switcher (the paintbrush icon) - switches between different design system themes (Core, Admin, LX Studio, Percipio) -4. Outline (dotted square) - applies outlines to elements in the rendered code examples +4. LogicalProps (the two arrows icon) - toggles between physical and logical CSS properties +5. Outline (dotted square) - applies outlines to elements in the rendered code examples ### Theme Switcher @@ -58,6 +59,10 @@ Available themes: The theme switcher works in combination with the color mode selector, so you can test both light and dark variants of each theme. +### LogicalProps + +The LogicalProps button (two arrows icon) provides a menu to select between Logical and Physical CSS properties. + ### Showing code On the bottom right of each canvas (a rendered code example) is a button to show its code: diff --git a/packages/styleguide/src/static/meta/toolbar.png b/packages/styleguide/src/static/meta/toolbar.png index a94fdacf0f9..d5c8cb35b32 100644 Binary files a/packages/styleguide/src/static/meta/toolbar.png and b/packages/styleguide/src/static/meta/toolbar.png differ diff --git a/packages/variance/src/core.ts b/packages/variance/src/core.ts index c74924500db..db6debbffaf 100644 --- a/packages/variance/src/core.ts +++ b/packages/variance/src/core.ts @@ -18,6 +18,7 @@ import { TransformerMap, Variant, } from './types/config'; +import { DirectionalProperties } from './types/properties'; import { BreakpointCache, CSSObject, ThemeProps } from './types/props'; import { getStaticCss } from './utils/getStaticProperties'; import { orderPropNames } from './utils/propNames'; @@ -96,12 +97,21 @@ export const variance = { const { transform = identity, property, - properties = [property], + properties: configProperties, scale, + resolveProperty, } = config; const getScaleValue = createScaleLookup(scale); const alwaysTransform = scale === undefined || isArray(scale); + const isDirectionalProperties = ( + props: typeof configProperties + ): props is DirectionalProperties => + props !== undefined && + !isArray(props) && + 'physical' in props && + 'logical' in props; + return { ...config, prop, @@ -135,18 +145,45 @@ export const variance = { return styles; } + const useLogicalProperties = + (props.theme as { useLogicalProperties?: boolean }) + ?.useLogicalProperties ?? true; + + let resolvedProperties: readonly ( + | string + | { physical: string; logical: string } + )[]; + if (isDirectionalProperties(configProperties)) { + const mode = resolveProperty + ? resolveProperty(useLogicalProperties) + : useLogicalProperties + ? 'logical' + : 'physical'; + resolvedProperties = configProperties[mode]; + } else { + resolvedProperties = configProperties ?? [property]; + } + // for each property look up the scale value from theme if passed and apply any // final transforms to the value - properties.forEach((property) => { + resolvedProperties.forEach((property) => { + let resolvedProperty: string; + if (resolveProperty && typeof property === 'object') { + const mode = resolveProperty(useLogicalProperties); + resolvedProperty = property[mode]; + } else { + resolvedProperty = property as string; + } + let styleValue: ReturnType = intermediateValue; if (useTransform && !isUndefined(styleValue)) { - styleValue = transform(styleValue, property, props); + styleValue = transform(styleValue, resolvedProperty, props); } switch (typeof styleValue) { case 'number': case 'string': - return (styles[property] = styleValue); + return (styles[resolvedProperty] = styleValue); case 'object': return Object.assign(styles, styleValue); default: diff --git a/packages/variance/src/getPropertyMode/getPropertyMode.test.ts b/packages/variance/src/getPropertyMode/getPropertyMode.test.ts new file mode 100644 index 00000000000..ddb8720b241 --- /dev/null +++ b/packages/variance/src/getPropertyMode/getPropertyMode.test.ts @@ -0,0 +1,13 @@ +import { getPropertyMode } from './getPropertyMode'; + +describe('getPropertyMode', () => { + it.each([ + { useLogicalProperties: true, expected: 'logical' }, + { useLogicalProperties: false, expected: 'physical' }, + ])( + 'returns "$expected" when useLogicalProperties is $useLogicalProperties', + ({ useLogicalProperties, expected }) => { + expect(getPropertyMode(useLogicalProperties)).toBe(expected); + } + ); +}); diff --git a/packages/variance/src/getPropertyMode/getPropertyMode.ts b/packages/variance/src/getPropertyMode/getPropertyMode.ts new file mode 100644 index 00000000000..6b2507fc993 --- /dev/null +++ b/packages/variance/src/getPropertyMode/getPropertyMode.ts @@ -0,0 +1,7 @@ +import { PropertyMode } from '../types/properties'; + +export const getPropertyMode = ( + useLogicalProperties: boolean +): PropertyMode => { + return useLogicalProperties ? 'logical' : 'physical'; +}; diff --git a/packages/variance/src/getPropertyMode/index.ts b/packages/variance/src/getPropertyMode/index.ts new file mode 100644 index 00000000000..e85e49cd2a8 --- /dev/null +++ b/packages/variance/src/getPropertyMode/index.ts @@ -0,0 +1 @@ +export * from './getPropertyMode'; diff --git a/packages/variance/src/index.ts b/packages/variance/src/index.ts index 35fb9eb488b..6c1279e8061 100644 --- a/packages/variance/src/index.ts +++ b/packages/variance/src/index.ts @@ -3,3 +3,4 @@ export * from './createTheme'; export * from './types/props'; export * from './transforms'; export * from './scales/createScale'; +export * from './getPropertyMode'; diff --git a/packages/variance/src/types/config.ts b/packages/variance/src/types/config.ts index 860fe1b5b68..c4dc0be88eb 100644 --- a/packages/variance/src/types/config.ts +++ b/packages/variance/src/types/config.ts @@ -1,6 +1,12 @@ import { Theme } from '@emotion/react'; -import { DefaultCSSPropertyValue, PropertyTypes } from './properties'; +import { + DefaultCSSPropertyValue, + DirectionalProperties, + DirectionalProperty, + PropertyMode, + PropertyTypes, +} from './properties'; import { AbstractProps, CSSObject, @@ -14,9 +20,11 @@ import { AllUnionKeys, Key, KeyFromUnion } from './utils'; export type MapScale = Record; export type ArrayScale = readonly (string | number)[] & { length: 0 }; +export type PropertyValue = keyof PropertyTypes | DirectionalProperty; + export interface BaseProperty { - property: keyof PropertyTypes; - properties?: readonly (keyof PropertyTypes)[]; + property: PropertyValue; + properties?: readonly PropertyValue[] | DirectionalProperties; } export interface Prop extends BaseProperty { @@ -26,6 +34,7 @@ export interface Prop extends BaseProperty { prop?: string, props?: AbstractProps ) => string | number | CSSObject; + resolveProperty?: (useLogicalProperties: boolean) => PropertyMode; } export interface AbstractPropTransformer extends Prop { @@ -47,14 +56,24 @@ export type PropertyValues< All extends true ? never : object | any[] >; +// Extract a single property key from PropertyValue for type inference +// Uses 'physical' for directional properties (both physical/logical have same value types) +type BasePropertyKey

= P extends DirectionalProperty ? P['physical'] : P; + export type ScaleValue = Config['scale'] extends keyof Theme - ? keyof Theme[Config['scale']] | PropertyValues + ? + | keyof Theme[Config['scale']] + | PropertyValues> : Config['scale'] extends MapScale - ? keyof Config['scale'] | PropertyValues + ? + | keyof Config['scale'] + | PropertyValues> : Config['scale'] extends ArrayScale - ? Config['scale'][number] | PropertyValues - : PropertyValues; + ? + | Config['scale'][number] + | PropertyValues> + : PropertyValues, true>; export type Scale = ResponsiveProp< ScaleValue | ((theme: Theme) => ScaleValue) diff --git a/packages/variance/src/types/properties.ts b/packages/variance/src/types/properties.ts index f307cb60d2c..61131257ff5 100644 --- a/packages/variance/src/types/properties.ts +++ b/packages/variance/src/types/properties.ts @@ -47,3 +47,15 @@ export interface VendorPropertyTypes export interface CSSPropertyTypes extends PropertyTypes, VendorPropertyTypes {} + +export type PropertyMode = 'logical' | 'physical'; + +export interface DirectionalProperty { + physical: keyof PropertyTypes; + logical: keyof PropertyTypes; +} + +export interface DirectionalProperties { + physical: readonly (keyof PropertyTypes)[]; + logical: readonly (keyof PropertyTypes)[]; +} diff --git a/packages/variance/src/utils/propNames.ts b/packages/variance/src/utils/propNames.ts index f4f75913cc8..3450bdb4f13 100644 --- a/packages/variance/src/utils/propNames.ts +++ b/packages/variance/src/utils/propNames.ts @@ -1,4 +1,5 @@ -import { BaseProperty } from '../types/config'; +import { BaseProperty, PropertyValue } from '../types/config'; +import { DirectionalProperties } from '../types/properties'; const SHORTHAND_PROPERTIES = [ 'border', @@ -36,6 +37,19 @@ const compare = (a: number, b: number) => { return SORT.EQUAL; }; +const isShorthand = (prop: PropertyValue): boolean => + typeof prop === 'string' && SHORTHAND_PROPERTIES.includes(prop); + +const getShorthandIndex = (prop: PropertyValue): number => + typeof prop === 'string' ? SHORTHAND_PROPERTIES.indexOf(prop) : -1; + +const getPropertiesCount = (properties: BaseProperty['properties']): number => { + if (!properties) return 0; + if (Array.isArray(properties)) return properties.length; + // DirectionalProperties object - using physical array length as representative, since the length for logical is the same + return (properties as DirectionalProperties).physical?.length ?? 0; +}; + /** * Orders all properties by the most dependent props * @param config @@ -44,21 +58,18 @@ export const orderPropNames = (config: Record) => Object.keys(config).sort((a, b) => { const { [a]: aConf, [b]: bConf } = config; - const { property: aProp, properties: aProperties = [] } = aConf; - const { property: bProp, properties: bProperties = [] } = bConf; + const { property: aProp, properties: aProperties } = aConf; + const { property: bProp, properties: bProperties } = bConf; - const aIsShorthand = SHORTHAND_PROPERTIES.includes(aProp); - const bIsShorthand = SHORTHAND_PROPERTIES.includes(bProp); + const aIsShorthand = isShorthand(aProp); + const bIsShorthand = isShorthand(bProp); if (aIsShorthand && bIsShorthand) { - const aNum = aProperties.length; - const bNum = bProperties.length; + const aNum = getPropertiesCount(aProperties); + const bNum = getPropertiesCount(bProperties); if (aProp !== bProp) { - return compare( - SHORTHAND_PROPERTIES.indexOf(aProp), - SHORTHAND_PROPERTIES.indexOf(bProp) - ); + return compare(getShorthandIndex(aProp), getShorthandIndex(bProp)); } if (aProp === bProp) {