diff --git a/.changeset/avatar-css-layer.md b/.changeset/avatar-css-layer.md new file mode 100644 index 00000000000..b484de59933 --- /dev/null +++ b/.changeset/avatar-css-layer.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Avatar: Add CSS layer support for component styles diff --git a/package-lock.json b/package-lock.json index fcb2f82a26f..d6539391f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10110,6 +10110,13 @@ "@types/node": "*" } }, + "node_modules/@types/css-tree": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.11.tgz", + "integrity": "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "dev": true, @@ -28628,6 +28635,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.2", + "@types/css-tree": "^2.3.11", "@types/lodash.groupby": "4.6.9", "@types/lodash.isempty": "4.4.9", "@types/lodash.isobject": "3.0.9", @@ -28653,6 +28661,7 @@ "concurrently": "9.1.2", "copyfiles": "2.4.1", "cross-env": "7.0.3", + "css-tree": "^2.3.1", "fast-glob": "3.3.2", "filesize": "10.1.6", "front-matter": "4.0.2", diff --git a/packages/react/package.json b/packages/react/package.json index 78df4dae326..7c00e1021b8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -125,6 +125,7 @@ "@testing-library/react": "^16.3.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.5.2", + "@types/css-tree": "^2.3.11", "@types/lodash.groupby": "4.6.9", "@types/lodash.isempty": "4.4.9", "@types/lodash.isobject": "3.0.9", @@ -150,6 +151,7 @@ "concurrently": "9.1.2", "copyfiles": "2.4.1", "cross-env": "7.0.3", + "css-tree": "^2.3.1", "fast-glob": "3.3.2", "filesize": "10.1.6", "front-matter": "4.0.2", diff --git a/packages/react/src/Avatar/Avatar.module.css b/packages/react/src/Avatar/Avatar.module.css index fc67531b9a7..da221791acd 100644 --- a/packages/react/src/Avatar/Avatar.module.css +++ b/packages/react/src/Avatar/Avatar.module.css @@ -1,34 +1,36 @@ -:where(.Avatar) { - display: inline-block; - width: var(--avatarSize-regular); - height: var(--avatarSize-regular); - overflow: hidden; /* Ensure page layout in Firefox should images fail to load */ - /* stylelint-disable-next-line primer/typography */ - line-height: 1; - vertical-align: middle; - border-radius: 50%; - /* stylelint-disable-next-line primer/box-shadow */ - box-shadow: 0 0 0 1px var(--avatar-borderColor); +@layer primer.components.Avatar { + :where(.Avatar) { + display: inline-block; + width: var(--avatarSize-regular); + height: var(--avatarSize-regular); + overflow: hidden; /* Ensure page layout in Firefox should images fail to load */ + /* stylelint-disable-next-line primer/typography */ + line-height: 1; + vertical-align: middle; + border-radius: 50%; + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 0 0 1px var(--avatar-borderColor); - &:where([data-square]) { - /* stylelint-disable-next-line primer/borders */ - border-radius: clamp(4px, calc(var(--avatarSize-regular) - 24px), var(--borderRadius-medium)); - } - - &:where([data-responsive]) { - @media screen and (--viewportRange-narrow) { - width: var(--avatarSize-narrow); - height: var(--avatarSize-narrow); + &:where([data-square]) { + /* stylelint-disable-next-line primer/borders */ + border-radius: clamp(4px, calc(var(--avatarSize-regular) - 24px), var(--borderRadius-medium)); } - @media screen and (--viewportRange-regular) { - width: var(--avatarSize-regular); - height: var(--avatarSize-regular); - } + &:where([data-responsive]) { + @media screen and (--viewportRange-narrow) { + width: var(--avatarSize-narrow); + height: var(--avatarSize-narrow); + } + + @media screen and (--viewportRange-regular) { + width: var(--avatarSize-regular); + height: var(--avatarSize-regular); + } - @media screen and (--viewportRange-wide) { - width: var(--avatarSize-wide); - height: var(--avatarSize-wide); + @media screen and (--viewportRange-wide) { + width: var(--avatarSize-wide); + height: var(--avatarSize-wide); + } } } } diff --git a/packages/react/src/__tests__/css-layers.test.ts b/packages/react/src/__tests__/css-layers.test.ts new file mode 100644 index 00000000000..b9baf8d2fe4 --- /dev/null +++ b/packages/react/src/__tests__/css-layers.test.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs' +import path from 'node:path' +import {generate, parse} from 'css-tree' +import type {StyleSheet} from 'css-tree' +import {describe, expect, test} from 'vitest' + +const allowlist = new Set([path.resolve(import.meta.dirname, '../Avatar/Avatar.module.css')]) +const files = Array.from(allowlist).map(file => { + return [path.basename(file), file] +}) + +const CSS_LAYER_REGEX = /^primer\.components\.[A-Z][A-Za-z0-9]+$/ + +describe('CSS Layers', () => { + describe.each(files)('%s', (_name, filename) => { + const contents = fs.readFileSync(filename, 'utf8') + const ast = parse(contents, { + filename, + }) as StyleSheet + + test('uses CSS Layer', () => { + const first = ast.children.first + + expect(first?.type).toBe('Atrule') + if (!first || first.type !== 'Atrule') throw new Error('Expected stylesheet to start with an @layer at-rule') + + expect(first.name).toBe('layer') + }) + + test('CSS Layer matches naming conventions', () => { + const first = ast.children.first + + expect(first?.type).toBe('Atrule') + if (!first || first.type !== 'Atrule') throw new Error('Expected stylesheet to start with an @layer at-rule') + + const layerName = first.prelude ? generate(first.prelude).trim() : '' + expect(layerName).toMatch(CSS_LAYER_REGEX) + }) + }) +}) diff --git a/packages/react/vitest.config.browser.mts b/packages/react/vitest.config.browser.mts index 21893362d8e..03bce19b8ae 100644 --- a/packages/react/vitest.config.browser.mts +++ b/packages/react/vitest.config.browser.mts @@ -43,6 +43,7 @@ export default defineConfig({ '**/*.types.test.tsx', 'src/__tests__/exports.test.ts', 'src/__tests__/storybook.test.tsx', + 'src/__tests__/css-layers.test.ts', ], include: ['src/**/*.test.?(c|m)[jt]s?(x)'], setupFiles: ['config/vitest/browser/setup.ts'], diff --git a/packages/react/vitest.config.mts b/packages/react/vitest.config.mts index a9b57f621f0..e7f5b50cf2b 100644 --- a/packages/react/vitest.config.mts +++ b/packages/react/vitest.config.mts @@ -23,7 +23,7 @@ export default defineConfig({ }, test: { name: '@primer/react (node)', - include: ['src/__tests__/exports.test.ts', 'src/__tests__/storybook.test.tsx'], + include: ['src/__tests__/exports.test.ts', 'src/__tests__/storybook.test.tsx', 'src/__tests__/css-layers.test.ts'], environment: 'node', detectAsyncLeaks: true, },