diff --git a/.changeset/modernize-action-menu.md b/.changeset/modernize-action-menu.md new file mode 100644 index 00000000000..c50e3b24459 --- /dev/null +++ b/.changeset/modernize-action-menu.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +ActionMenu: Refine menu spacing, typography, icons, interaction styles, and trigger-button typography to match GitHub App. diff --git a/.changeset/modernize-text-input.md b/.changeset/modernize-text-input.md new file mode 100644 index 00000000000..f4ea3e34961 --- /dev/null +++ b/.changeset/modernize-text-input.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +TextInput: Refine typography, spacing, visuals, and interaction styles. diff --git a/.gitignore b/.gitignore index 78cc2319772..b96272f69a7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ storybook-static coverage/ # Playwright +.playwright-mcp/ stats.html blob-report results.json diff --git a/e2e/components/ActionMenu.test.ts b/e2e/components/ActionMenu.test.ts index 6b46c17139f..108dbb8bf80 100644 --- a/e2e/components/ActionMenu.test.ts +++ b/e2e/components/ActionMenu.test.ts @@ -71,6 +71,56 @@ const featureFlagVariants = [ ] as const test.describe('ActionMenu', () => { + test.describe('Modernized menu', () => { + for (const id of [ + 'components-actionmenu-features--modernized-menu', + 'components-actionmenu-features--modernized-menu-with-avatars', + ]) { + for (const theme of themes) { + test(`${id} ${theme} @aat`, async ({page}) => { + await visit(page, { + id, + globals: {colorScheme: theme}, + }) + await expect(page.getByRole('menu')).toBeVisible() + await expect(page).toHaveNoViolations() + }) + } + } + + test('aligns avatars, loading indicators and submenu descriptions', async ({page}) => { + await visit(page, {id: 'components-actionmenu-features--modernized-menu-with-states'}) + const menu = page.getByRole('menu') + await expect(menu.locator('[data-component="Avatar"]')).toHaveCount(3) + for (const avatar of await menu.locator('[data-component="Avatar"]').all()) { + await expect(avatar).toHaveCSS('width', '20px') + await expect(avatar).toHaveCSS('height', '20px') + } + + const loading = page.getByRole('menuitem', {name: /^Loading reviewers/}) + await expect(loading.locator('[data-component="Spinner"] svg')).toHaveCSS('width', '14px') + await expect(loading.locator('[data-component="Spinner"] svg')).toHaveCSS('height', '14px') + + const inactive = page.getByRole('menuitem', {name: 'Request review', exact: true}) + const inactiveIcon = await inactive.locator('svg').boundingBox() + const inactiveLabel = await inactive.locator('[data-component="ActionList.Item.Label"]').boundingBox() + expect(inactiveIcon!.y - inactiveLabel!.y).toBeCloseTo(3, 3) + + const submenu = page.getByRole('menuitem', {name: 'More reviewers', exact: true}) + await expect(submenu.locator('[data-description-variant]')).toHaveCSS('margin-right', '8px') + const chevron = await submenu.locator('[data-component="ActionList.TrailingVisual"] svg').boundingBox() + const label = await submenu.locator('[data-component="ActionList.Item.Label"]').boundingBox() + expect(chevron!.y - label!.y).toBeCloseTo(2, 3) + + await submenu.focus() + await page.keyboard.press('ArrowRight') + await expect(page.getByRole('menuitem', {name: 'Design', exact: true})).toBeVisible() + await page.keyboard.press('ArrowLeft') + await expect(submenu).toBeFocused() + await expect(page.getByRole('menu')).toHaveCount(1) + }) + }) + for (const story of stories) { test.describe(story.title, () => { for (const theme of themes) { diff --git a/e2e/components/TextInput.test.ts b/e2e/components/TextInput.test.ts index d640f166fae..c8113d6da24 100644 --- a/e2e/components/TextInput.test.ts +++ b/e2e/components/TextInput.test.ts @@ -3,6 +3,22 @@ import {visit} from '../test-helpers/storybook' import {themes} from '../test-helpers/themes' test.describe('TextInput', () => { + test.describe('Modernized accessibility', () => { + for (const theme of themes) { + test.describe(theme, () => { + for (const story of ['modernized-input', 'modernized-search', 'modernized-states']) { + test(`${story} @avt`, async ({page}) => { + await visit(page, { + id: `components-textinput-features--${story}`, + globals: {colorScheme: theme}, + }) + await expect(page).toHaveNoViolations() + }) + } + }) + } + }) + test.describe('Default', () => { for (const theme of themes) { test.describe(theme, () => { diff --git a/packages/react/src/ActionList/ActionList.modernize.module.css b/packages/react/src/ActionList/ActionList.modernize.module.css new file mode 100644 index 00000000000..bf296a56ade --- /dev/null +++ b/packages/react/src/ActionList/ActionList.modernize.module.css @@ -0,0 +1,212 @@ +.Overlay { + min-width: calc(var(--base-size-16) * 7); + background-color: var(--bgColor-default); + background-clip: padding-box; + /* stylelint-disable primer/box-shadow -- match GitHub App's floating border shadow */ + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--borderColor-default) 50%, transparent), + 0 6px 12px -3px color-mix(in srgb, color-mix(in srgb, #000 90%, var(--bgColor-default)) 4%, transparent), + 0 6px 18px 0 color-mix(in srgb, color-mix(in srgb, #000 90%, var(--bgColor-default)) 12%, transparent); + /* stylelint-enable primer/box-shadow */ +} + +.ActionList { + --actionList-modernize-icon-size: calc(var(--base-size-16) - var(--base-size-2)); + + padding: var(--base-size-4); + /* stylelint-disable-next-line primer/typography -- GitHub App's menu typography has no matching Primer token */ + font-size: 13px; + /* stylelint-disable-next-line primer/typography -- match the 20px menu row line height */ + line-height: var(--base-size-20); + + & [data-component='ActionList.Item'] { + margin-inline: 0; + /* stylelint-disable-next-line primer/borders -- GitHub App's 8px radius has no matching Primer token */ + border-radius: 8px; + + & > [data-size] { + min-height: var(--base-size-28); + padding-inline: var(--base-size-8); + cursor: default; + border-radius: inherit; + } + + & > [data-size='medium'] { + padding-block: var(--base-size-4); + } + + &:where([data-has-description='true']) [data-component='ActionList.LeadingVisual'] { + min-height: var(--base-size-20); + height: var(--base-size-20); + align-self: start; + + & > [data-component='Avatar'] { + width: var(--base-size-20); + height: var(--base-size-20); + } + } + + &:where([data-inactive='true']:not([data-has-description='true'])) [data-component='ActionList.LeadingVisual'] { + margin-block-start: calc(var(--base-size-6) / 2); + align-self: start; + } + + &:where([aria-haspopup]) [data-component='ActionList.Item--DividerContainer'] > :not(:last-child) { + margin-inline-end: var(--base-size-8); + } + + &:where([aria-haspopup][data-has-description='true']) [data-component='ActionList.TrailingVisual'] { + min-height: 0; + height: var(--actionList-modernize-icon-size); + margin-block-start: var(--base-size-2); + } + } + + & [data-component='ActionList.Item.Label'], + & [data-component='ActionList.Description'], + & [data-component='ActionList.TrailingVisual'] { + font-size: inherit; + line-height: inherit; + } + + & [data-description-variant='block'] { + gap: 0; + } + + & [data-has-description='true'] [data-component='ActionList.Item.Label'] { + font-weight: var(--base-text-weight-medium); + } + + & [data-component='ActionList.Item--DividerContainer'] { + & > :not(:last-child) { + margin-inline-end: var(--base-size-24); + } + } + + & [data-component='KeybindingHint'] { + display: inline-flex; + } + + & [data-kbd-chord] { + min-width: 0; + padding: 0; + font-size: inherit; + line-height: inherit; + color: var(--fgColor-muted); + background: transparent; + border: 0; + gap: 0; + + & > [aria-hidden] { + min-width: var(--actionList-modernize-icon-size); + text-align: center; + } + } + + & [data-component='ActionList.LeadingVisual'] { + min-width: var(--actionList-modernize-icon-size); + min-height: 0; + height: var(--actionList-modernize-icon-size); + align-self: center; + + & > :where(svg, [data-component='Avatar']) { + width: var(--actionList-modernize-icon-size); + height: var(--actionList-modernize-icon-size); + } + } + + & [data-component='ActionList.Selection'] > svg, + & [data-component='ActionList.TrailingVisual'] > svg, + & [data-loading='true'] svg { + width: var(--actionList-modernize-icon-size); + height: var(--actionList-modernize-icon-size); + } + + & [data-is-disabled] { + & [data-component='ActionList.Item.Label'], + & [data-component='ActionList.Description'], + & [data-component='ActionList.LeadingVisual'], + & [data-component='ActionList.TrailingVisual'], + & [data-component='ActionList.Selection'] { + color: var(--fgColor-muted); + + & > svg { + color: inherit; + } + } + } + + & [data-component='ActionList.Divider'] { + margin: var(--base-size-4) var(--base-size-8); + /* stylelint-disable-next-line primer/colors -- match GitHub App's subtle separator */ + background: color-mix(in srgb, var(--borderColor-muted) 30%, transparent); + } + + &[data-dividers='true'] { + & [data-component='ActionList.Item--DividerContainer']::before, + & [data-description-variant='inline']::before { + top: calc(-1 * var(--base-size-4)); + } + } + + & [data-component='ActionList.Group']:not(:first-child) { + margin-block-start: 0; + } + + & [data-component='GroupHeadingWrap'] { + padding: var(--base-size-6) var(--base-size-8) var(--base-size-4); + font-weight: var(--base-text-weight-medium); + /* stylelint-disable-next-line primer/typography -- match GitHub App's 16px caption line height */ + line-height: var(--base-size-16); + + & > span { + font-weight: inherit; + } + } + + & [data-component='ActionList.Item']:not([data-is-disabled], [data-inactive='true'], [data-loading='true']) { + @media (hover: hover) { + &:hover { + cursor: default; + box-shadow: none; + } + } + + &:active { + box-shadow: none; + } + + &:focus-visible { + background: transparent; + box-shadow: none; + } + } + + & + [data-component='ActionList.Item']:where([data-variant='danger']):where( + :not([data-is-disabled], [data-inactive='true'], [data-loading='true']) + ) { + @media (hover: hover) { + &:hover { + background: var(--bgColor-danger-muted); + } + } + + &:active { + background: var(--bgColor-danger-muted); + } + + &:hover, + &:active { + & [data-component='ActionList.Item.Label'], + & [data-component='ActionList.LeadingVisual'], + & [data-component='ActionList.Selection'] { + color: var(--fgColor-danger); + } + + & [data-kbd-chord] { + background-color: transparent; + } + } + } +} diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 11c00a37db7..79cd8a40215 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -9,6 +9,7 @@ import {useMergedRefs, useProvidedRefOrCreate} from '../hooks' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' import {clsx} from 'clsx' import classes from './ActionList.module.css' +import modernizeClasses from './ActionList.modernize.module.css' import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' import {useFeatureFlag} from '../FeatureFlags' @@ -110,7 +111,7 @@ const UnwrappedList = ( {slots.heading} ( +
+ +
+ ), + ], + render: () => ( + + Actions + + + + + + + Copy + Copy to clipboard + + + + + + Edit + Edit this item + + + + + + + Download + Download the file to your computer + + + + + + Delete + This action cannot be undone + + + + + ), + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', {name: 'Actions'})) + const body = within(canvasElement.ownerDocument.body) + await waitFor(() => expect(body.getByRole('menu')).toBeVisible()) + await userEvent.keyboard('{ArrowDown}') + await expect(body.getByRole('menuitem', {name: 'Edit'})).toHaveFocus() + }, +} + +export const ModernizedMenuWithAvatars: StoryObj = { + decorators: ModernizedMenu.decorators, + render: () => ( + + Reviewers + + + + Request review + {[ + {name: 'maximedegreve', description: 'Frontend · Last active 2h ago'}, + {name: 'adrianmg', description: 'Backend · Last active 30m ago'}, + {name: 'daviddossett', description: 'Infrastructure · Last active 1d ago'}, + ].map(({name, description}) => ( + + + + + {name} + {description} + + ))} + + + + + ), + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', {name: 'Reviewers'})) + const body = within(canvasElement.ownerDocument.body) + await waitFor(() => expect(body.getByRole('menu')).toBeVisible()) + }, +} + +export const ModernizedMenuWithAvatarsOnly: StoryObj = { + decorators: ModernizedMenu.decorators, + render: () => ( + + Reviewers + + + + Request review + {['maximedegreve', 'adrianmg', 'daviddossett'].map(name => ( + + + + + {name} + + ))} + + + + + ), + play: ModernizedMenuWithAvatars.play, +} + +export const ModernizedMenuWithStates: StoryObj = { + render: () => ( + + Reviewers + + + + Request review + {['mona', 'octocat', 'hubot'].map(name => ( + + + + + {name} + Available for review + + ))} + + + + + + + + + More reviewers + Choose from another team + + + + + Design + Engineering + + + + + + + + Loading reviewers + + + + + + Request review + + + + + ), + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', {name: 'Reviewers'})) + const body = within(canvasElement.ownerDocument.body) + await waitFor(() => expect(body.getByRole('menu')).toBeVisible()) + await userEvent.keyboard('{ArrowDown}') + await expect(body.getByRole('menuitem', {name: 'octocat'})).toHaveFocus() + }, +} + export const LinksAndActions = () => ( Open menu diff --git a/packages/react/src/ActionMenu/ActionMenu.modernize.module.css b/packages/react/src/ActionMenu/ActionMenu.modernize.module.css new file mode 100644 index 00000000000..c4520c01ef0 --- /dev/null +++ b/packages/react/src/ActionMenu/ActionMenu.modernize.module.css @@ -0,0 +1,6 @@ +.Button:where([data-size='medium']) { + /* stylelint-disable-next-line primer/typography -- GitHub App's button typography has no matching Primer token */ + font-size: 13px; + /* stylelint-disable-next-line primer/typography -- match the 20px control label line height */ + line-height: var(--base-size-20); +} diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index e0f641963bc..c5d95b02d0e 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -21,6 +21,7 @@ import type {JSX} from 'react' import {implementsClassName} from '../utils/testing' import {createRenderCounter} from '../utils/testing/profiler' import {FeatureFlags} from '../FeatureFlags' +import {KeybindingHint} from '../KeybindingHint' // Mock getAnchoredPosition for feature flag tests vi.mock('@primer/behaviors', async () => { @@ -209,6 +210,58 @@ function ExampleWithSubmenus(): JSX.Element { describe('ActionMenu', () => { implementsClassName(ActionMenu.Button) + it('uses compact menu rows without changing standalone ActionLists', async () => { + HTMLRender( + + + + Standalone action + + + Actions + + + + Simple action + + + + + + + + + Search + Find a file + + + + + , + ) + + const trigger = page.getByRole('button', {name: 'Actions', exact: true}) + expect(getComputedStyle(trigger.element()).fontSize).toBe('13px') + const standaloneButton = page.getByRole('button', {name: 'Standalone button', exact: true}) + expect(getComputedStyle(standaloneButton.element()).fontSize).toBe('14px') + await userEvent.click(trigger) + + const simpleItem = page.getByRole('menuitem', {name: /^Simple action/}) + const descriptionItem = page.getByRole('menuitem', {name: 'Search', exact: true}) + await expect.element(descriptionItem).toBeVisible() + + expect(simpleItem.element().getBoundingClientRect().height).toBeCloseTo(28, 3) + expect(descriptionItem.element().getBoundingClientRect().height).toBeCloseTo(48, 3) + expect(descriptionItem.element().querySelector('svg')?.getBoundingClientRect().width).toBeCloseTo(14, 3) + const label = descriptionItem.element().querySelector('[data-component="ActionList.Item.Label"]')! + expect(getComputedStyle(label).fontSize).toBe('13px') + expect(getComputedStyle(label).fontWeight).toBe('500') + expect(getComputedStyle(label).lineHeight).toBe('20px') + + const standaloneItem = page.getByRole('button', {name: 'Standalone action', exact: true}) + expect(standaloneItem.element().getBoundingClientRect().height).toBeCloseTo(32, 3) + }) + it('renders data-component attributes for ActionMenu parts', async () => { const component = HTMLRender() diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index c33997690f5..6e7a6449031 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -15,6 +15,8 @@ import type {MandateProps} from '../utils/types' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {Tooltip} from '../TooltipV2/Tooltip' import styles from './ActionMenu.module.css' +import menuModernizeClasses from './ActionMenu.modernize.module.css' +import modernizeClasses from '../ActionList/ActionList.modernize.module.css' import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue' import {isSlot} from '../utils/is-slot' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types/Slots' @@ -254,10 +256,16 @@ const Anchor: WithSlotMarker< /** this component is syntactical sugar 🍭 */ export type ActionMenuButtonProps = ButtonProps -const MenuButton = React.forwardRef(({...props}, anchorRef) => { +const MenuButton = React.forwardRef(({className, ...props}, anchorRef) => { return ( - export const Link = () => +export const AllSizes = () => ( +
+ + + +
+) + +const kitchenSinkVariants = ['default', 'primary', 'danger', 'invisible', 'link'] as const +const kitchenSinkSizes = ['small', 'medium', 'large'] as const + +export const AllVariants = () => ( + + {kitchenSinkVariants.map(variant => ( + + ))} + +) + +export const AllVariantsWithLeadingVisual = () => ( + + {kitchenSinkVariants.map(variant => ( + + ))} + +) + +export const KitchenSink = { + parameters: { + layout: 'fullscreen', + }, + render: () => ( +
+ + {kitchenSinkSizes.map(size => ( + + + {size} + + + + + Variants + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + With leading visual + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + With trailing visual + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + With both visuals + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + Disabled + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + Inactive + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + With key shortcut + + + {kitchenSinkVariants.map(variant => ( + + ))} + + + + + Block + + + + + + + + + ))} + +
+ ), +} + export const LeadingVisual = () => export const TrailingVisual = () => diff --git a/packages/react/src/Button/ButtonBase.modern.module.css b/packages/react/src/Button/ButtonBase.modern.module.css new file mode 100644 index 00000000000..7be1f19f2e0 --- /dev/null +++ b/packages/react/src/Button/ButtonBase.modern.module.css @@ -0,0 +1,734 @@ +/* Base styles */ +.ButtonBase { + display: flex; + min-width: max-content; + height: var(--control-medium-size); + /* stylelint-disable-next-line primer/spacing */ + padding: 0 var(--control-medium-paddingInline-normal); + font-family: inherit; + font-size: var(--text-body-size-medium); + font-weight: var(--base-text-weight-medium); + color: var(--button-default-fgColor-rest); + text-align: center; + text-decoration: none; + cursor: pointer; + user-select: none; + background-color: transparent; + border: var(--borderWidth-thin) solid; + border-color: var(--button-default-borderColor-rest); + border-radius: var(--borderRadius-medium); + transition: 80ms cubic-bezier(0.65, 0, 0.35, 1); + transition-property: color, fill, background-color, border-color; + appearance: none; + align-items: center; + justify-content: space-between; + gap: var(--base-size-8); + + &.HasLeadingVisual:where(:not([data-icon-only-counter])) { + padding-inline: var(--base-size-8) var(--base-size-12); + + &:where([data-size='small']) { + padding-inline: var(--base-size-6) var(--base-size-8); + } + + &:where([data-size='large']) { + padding-inline: var(--base-size-12) var(--base-size-16); + } + } + + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */ + &:has([data-kbd-chord]) { + padding-inline: calc(var(--base-size-12) - var(--base-size-2)) var(--base-size-8); + + &:where([data-size='small']) { + padding-inline: var(--base-size-8) var(--base-size-6); + } + + &:where([data-size='large']) { + padding-inline: var(--base-size-12) calc(var(--base-size-12) - var(--base-size-2)); + } + } + + &:focus-visible { + @mixin focusOutline; + } + + &:active { + transition: none; + } + + &:where([data-notification-indicator='button'], [data-notification-indicator='icon'].IconButton) { + position: relative; + } + + &:where([data-notification-indicator='leadingVisual']) .LeadingVisual { + position: relative; + } + + &:where([data-notification-indicator='button'])::before, + &:where([data-notification-indicator='icon'].IconButton)::before, + &:where([data-notification-indicator='leadingVisual']) .LeadingVisual::before { + position: absolute; + display: block; + width: var(--base-size-8); + height: var(--base-size-8); + content: ''; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--fgColor-accent); + border-radius: var(--borderRadius-full); + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 0 0 calc(var(--base-size-4) / 2) var(--bgColor-inset); + } + + &:where([data-notification-indicator='button'])::before { + top: calc(var(--base-size-4) / -2); + right: calc(var(--base-size-4) / -2); + } + + &:where([data-notification-indicator='icon'].IconButton)::before { + top: calc(50% - var(--base-size-12)); + right: calc(50% - var(--base-size-12)); + } + + &:where([data-notification-indicator='leadingVisual']) .LeadingVisual::before { + top: calc(var(--base-size-4) * -1); + right: calc(var(--base-size-4) * -1); + } + + &:disabled, + &[aria-disabled='true']:not([data-loading='true']), + &[aria-disabled='true'][data-loading-disabled-styles='true'] { + cursor: not-allowed; + box-shadow: none; + + & .Visual, + & .CounterLabel { + color: inherit; + } + } + + @media (forced-colors: active) { + &:focus { + outline: solid 1px transparent; + } + } + + /* Visuals */ + & :where(.Visual, .VisualWrap) { + display: flex; + pointer-events: none; + } + + & :where(.Visual) { + color: var(--fgColor-muted); + } + + /* IconButton */ + + &:where(.IconButton) { + display: inline-grid; + width: var(--control-medium-size); + min-width: unset; + /* stylelint-disable-next-line primer/spacing */ + padding: unset; + place-content: center; + flex-shrink: 0; + + &:where([data-size='small']) { + width: var(--control-small-size); + } + + &:where([data-size='large']) { + width: var(--control-large-size); + } + } + + /* LinkButton */ + + &:where([href]) { + display: inline-flex; + + &:hover { + text-decoration: none; + } + } + + /* Button layout */ + + & :where(.ButtonContent) { + flex: 1 0 auto; + display: grid; + grid-template-areas: 'leadingVisual text trailingVisual'; + grid-template-columns: min-content minmax(0, auto) min-content; + align-items: center; + align-content: center; + + & > :not(:last-child) { + margin-right: var(--base-size-6); + } + + /* Content alignment */ + + &:where([data-align='center']) { + justify-content: center; + } + + &:where([data-align='start']) { + justify-content: flex-start; + } + } + + & :where([data-component='leadingVisual']) { + grid-area: leadingVisual; + } + + & :where(.Label) { + /* stylelint-disable-next-line primer/typography -- match the 20px control label line height */ + line-height: var(--base-size-20); + white-space: nowrap; + grid-area: text; + } + + & :where([data-component='trailingVisual']) { + grid-area: trailingVisual; + } + + & :where([data-component='trailingAction']) { + margin-right: calc(var(--base-size-4) * -1); + } + + /* Size */ + + &:where([data-size='small']) { + height: var(--control-small-size); + /* stylelint-disable-next-line primer/spacing */ + padding: 0 var(--control-small-paddingInline-condensed); + gap: var(--base-size-6); + font-size: var(--text-body-size-small); + line-height: var(--base-text-lineHeight-normal); + + & .Label { + line-height: var(--base-text-lineHeight-normal); + } + } + + &:where([data-size='large']) { + height: var(--control-large-size); + /* stylelint-disable-next-line primer/spacing */ + padding: 0 var(--control-large-paddingInline-spacious); + gap: var(--base-size-6); + } + + /* Full width */ + + &:where([data-block='block']) { + width: 100%; + } + + /* Wrap label text */ + + &:where([data-label-wrap='true']) { + min-width: fit-content; + height: unset; + min-height: var(--control-medium-size); + + & .ButtonContent { + flex: 1 1 auto; + align-self: stretch; + /* stylelint-disable-next-line primer/spacing */ + padding-block: calc(var(--control-medium-paddingBlock) - var(--base-size-2)); + } + + & .Label { + /* stylelint-disable-next-line declaration-property-value-keyword-no-deprecated */ + word-break: break-word; + white-space: unset; + } + + &:where([data-size='small']) { + min-height: var(--control-small-size); + + & .ButtonContent { + /* stylelint-disable-next-line primer/spacing */ + padding-block: calc(var(--control-small-paddingBlock) - var(--base-size-2)); + } + } + + &:where([data-size='large']) { + min-height: var(--control-large-size); + /* stylelint-disable-next-line primer/spacing */ + padding-inline: var(--control-large-paddingInline-spacious); + + & .ButtonContent { + /* stylelint-disable-next-line primer/spacing */ + padding-block: calc(var(--control-large-paddingBlock) - var(--base-size-2)); + } + } + } + + /* Loading */ + + /* only hide label if there's no leading/trailing visuals + * move spinner to label spot if not leading/trailing visuals + */ + + &:where([data-loading='true']) { + & + .LoadingSpinner:not( + [data-component='leadingVisual'], + [data-component='trailingVisual'], + [data-component='trailingAction'] + ) { + grid-area: text; + margin-right: 0 !important; + place-self: center; + + & + .Label { + visibility: hidden; + } + } + } + + /* Styles for the spinner element displayed when the Button is in a loading state. + * Ensures the spinner is centered within its container. */ + .LoadingSpinner { + display: flex; + align-items: center; + justify-content: center; + } + + /* Default Variant */ + + &:where([data-variant='default']) { + background-color: var(--button-default-bgColor-rest); + box-shadow: var(--button-default-shadow-resting); + + &[aria-expanded='true'] { + background-color: var(--button-default-bgColor-active); + border-color: var(--button-default-borderColor-active); + } + + &:hover { + background-color: var(--button-default-bgColor-hover); + border-color: var(--button-default-borderColor-hover); + } + + &:active { + background-color: var(--button-default-bgColor-active); + border-color: var(--button-default-borderColor-active); + } + + &:disabled, + &[aria-disabled='true']:not([data-loading='true']), + &[aria-disabled='true'][data-loading-disabled-styles='true'] { + color: var(--control-fgColor-disabled); + background-color: var(--button-default-bgColor-disabled); + border-color: var(--button-default-borderColor-disabled); + box-shadow: none; + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-default-bgColor-disabled); + color: var(--buttonKeybindingHint-default-fgColor-disabled); + border-color: var(--buttonKeybindingHint-default-borderColor-disabled); + } + } + + & .CounterLabel { + background-color: var(--buttonCounter-default-bgColor-rest) !important; /* temporarily override our own sx prop */ + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-default-bgColor-rest); + color: var(--buttonKeybindingHint-default-fgColor-rest); + border-color: var(--buttonKeybindingHint-default-borderColor-rest); + } + + &:where(.IconButton) { + color: var(--fgColor-muted); + } + } + + /* Primary variant */ + + &:where([data-variant='primary']) { + color: var(--button-primary-fgColor-rest); + background-color: var(--button-primary-bgColor-rest); + border-color: var(--button-primary-borderColor-rest); + box-shadow: var(--shadow-resting-small); + + &[aria-expanded='true'] { + background-color: var(--button-primary-bgColor-active); + box-shadow: var(--button-primary-shadow-selected); + } + + &:hover { + background-color: var(--button-primary-bgColor-hover); + border-color: var(--button-primary-borderColor-hover); + } + + &:focus-visible { + @mixin focusOutlineOnEmphasis; + } + + &:active { + background-color: var(--button-primary-bgColor-active); + box-shadow: var(--button-primary-shadow-selected); + } + + &:disabled, + &[aria-disabled='true']:not([data-loading='true']), + &[aria-disabled='true'][data-loading-disabled-styles='true'] { + color: var(--button-primary-fgColor-disabled); + background-color: var(--button-primary-bgColor-disabled); + border-color: var(--button-primary-borderColor-disabled); + box-shadow: none; + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-primary-bgColor-disabled); + color: var(--buttonKeybindingHint-primary-fgColor-disabled); + border-color: var(--buttonKeybindingHint-primary-borderColor-disabled); + } + } + + & .CounterLabel { + color: var(--button-primary-fgColor-rest) !important; /* temporarily override our own sx prop */ + background-color: var(--buttonCounter-primary-bgColor-rest) !important; /* temporarily override our own sx prop */ + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-primary-bgColor-rest); + color: var(--buttonKeybindingHint-primary-fgColor-rest); + border-color: var(--buttonKeybindingHint-primary-borderColor-rest); + } + + /* temporarily using the fgColor to match legacy and reduce visual changes- will eventually be iconColor */ + & .Visual { + color: var(--button-primary-fgColor-rest); + } + } + + /* Danger variant */ + + &:where([data-variant='danger']) { + color: var(--button-danger-fgColor-rest); + background-color: var(--button-danger-bgColor-rest); + box-shadow: var(--button-default-shadow-resting); + + &[aria-expanded='true'] { + color: var(--button-danger-fgColor-active); + background-color: var(--button-danger-bgColor-active); + border-color: var(--button-danger-borderColor-active); + box-shadow: var(--button-danger-shadow-selected); + + & .Visual { + color: var(--button-danger-iconColor-hover); + } + } + + &:hover { + color: var(--button-danger-fgColor-hover); + background-color: var(--button-danger-bgColor-hover); + border-color: var(--button-danger-borderColor-hover); + box-shadow: var(--shadow-resting-small); + + & .CounterLabel { + color: var(--buttonCounter-danger-fgColor-hover) !important; /* temporarily override our own sx prop */ + background-color: var(--buttonCounter-danger-bgColor-hover) !important; + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-danger-bgColor-hover); + color: var(--buttonKeybindingHint-danger-fgColor-hover); + border-color: var(--buttonKeybindingHint-danger-borderColor-hover); + transition: 80ms ease-out; + } + + & .Visual { + color: var(--button-danger-iconColor-hover); + } + } + + &:active { + color: var(--button-danger-fgColor-active); + background-color: var(--button-danger-bgColor-active); + border-color: var(--button-danger-borderColor-active); + box-shadow: var(--button-danger-shadow-selected); + + & .CounterLabel { + color: var(--buttonCounter-danger-fgColor-hover) !important; /* temporarily override our own sx prop */ + background-color: var(--buttonCounter-danger-bgColor-hover) !important; + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-danger-bgColor-active); + color: var(--buttonKeybindingHint-danger-fgColor-active); + border-color: var(--buttonKeybindingHint-danger-borderColor-active); + transition: 80ms ease-out; + } + + & .Visual { + color: var(--button-danger-iconColor-hover); + } + } + + &:disabled, + &[aria-disabled='true']:not([data-loading='true']), + &[aria-disabled='true'][data-loading-disabled-styles='true'] { + color: var(--button-danger-fgColor-disabled); + background-color: var(--button-danger-bgColor-disabled); + border-color: var(--button-default-borderColor-disabled); + box-shadow: none; + + & .CounterLabel { + color: var(--buttonCounter-danger-fgColor-disabled) !important; /* temporarily override our own sx prop */ + background-color: var(--buttonCounter-danger-bgColor-disabled) !important; + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-danger-bgColor-disabled); + color: var(--buttonKeybindingHint-danger-fgColor-disabled); + border-color: var(--buttonKeybindingHint-danger-borderColor-disabled); + } + } + + & .CounterLabel { + color: var(--buttonCounter-danger-fgColor-rest) !important; /* temporarily override our own sx prop */ + background-color: var(--buttonCounter-danger-bgColor-rest) !important; + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-danger-bgColor-rest); + color: var(--buttonKeybindingHint-danger-fgColor-rest); + border-color: var(--buttonKeybindingHint-danger-borderColor-rest); + } + + & .Visual { + color: var(--button-danger-iconColor-rest); + } + } + + /* Invisible variant */ + + &:where([data-variant='invisible']) { + border-color: var(--button-invisible-borderColor-rest); + box-shadow: none; + + &[aria-expanded='true'] { + background-color: var(--button-invisible-bgColor-active); + } + + &:hover { + background-color: var(--button-invisible-bgColor-hover); + border-color: var(--button-invisible-borderColor-hover); + + & .Visual { + color: var(--button-invisible-iconColor-hover); + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-invisible-bgColor-hover); + transition: 80ms ease-out; + } + } + + &:active { + background-color: var(--button-invisible-bgColor-active); + + & .Visual { + color: var(--button-invisible-iconColor-hover); + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-invisible-bgColor-active); + transition: 80ms ease-out; + } + } + + &:disabled, + &[aria-disabled='true']:not([data-loading='true']), + &[aria-disabled='true'][data-loading-disabled-styles='true'] { + color: var(--button-invisible-fgColor-disabled); + background-color: var(--button-invisible-bgColor-disabled); + border-color: var(--button-invisible-borderColor-disabled); + box-shadow: none; + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-invisible-bgColor-disabled); + color: var(--buttonKeybindingHint-invisible-fgColor-disabled); + border-color: var(--buttonKeybindingHint-invisible-borderColor-disabled); + } + } + + & .Visual { + color: var(--button-invisible-iconColor-rest); + } + + & .CounterLabel { + background-color: var(--buttonCounter-invisible-bgColor-rest) !important; + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-invisible-bgColor-rest); + color: var(--buttonKeybindingHint-invisible-fgColor-rest); + border-color: var(--buttonKeybindingHint-invisible-borderColor-rest); + } + + &:where(.IconButton) { + color: var(--button-invisible-iconColor-rest); + } + } + + /* Link variant */ + + &:where([data-variant='link']) { + display: inline-flex; + min-width: fit-content; + height: unset; + padding: 0; + font-size: inherit; + color: var(--fgColor-link); + text-align: left; + border: unset; + border-radius: 0; + + &:where([data-no-visuals]:hover:not(:disabled, [aria-disabled='true'], [data-inactive])) { + text-decoration: underline; + } + + &:where(:not([data-no-visuals]):hover:not(:disabled, [aria-disabled='true'], [data-inactive])) .Label { + text-decoration: underline; + } + + &:focus-visible, + &:focus { + outline-offset: 2px; + } + + &:disabled, + &[aria-disabled='true']:not([data-loading='true']), + &[aria-disabled='true'][data-loading-disabled-styles='true'] { + color: var(--control-fgColor-disabled); + background-color: transparent; + border-color: transparent; + } + + & .Label { + line-height: var(--text-body-lineHeight-medium); + white-space: unset; + } + + &:where([data-size='small']) .Label { + line-height: var(--text-body-lineHeight-small); + } + + &:not([data-no-visuals]) .Label { + text-underline-offset: 0.125rem; + } + + &:where([data-inactive]) { + color: var(--button-inactive-fgColor); + background: transparent !important; + } + + & .Visual { + color: var(--fgColor-link); + } + } + + [data-a11y-link-underlines='true'] &:where([data-variant='link']) { + &[data-no-visuals] { + text-decoration: underline; + + &:where(:hover:not(:disabled, [aria-disabled='true'], [data-inactive])) { + text-decoration: none; + } + } + + &:not([data-no-visuals]) { + & .Label { + text-decoration: underline; + } + + &:where(:hover:not(:disabled, [aria-disabled='true'], [data-inactive])) .Label { + text-decoration: none; + } + } + } + + [data-a11y-link-underlines='false'] &:where([data-variant='link']) { + &[data-no-visuals] { + text-decoration: none; + background-image: none; + + &:where(:hover:not(:disabled, [aria-disabled='true'], [data-inactive])) { + text-decoration: underline; + } + } + + &:not([data-no-visuals]) { + background-image: none; + + & .Label { + text-decoration: none; + } + + &:where(:hover:not(:disabled, [aria-disabled='true'], [data-inactive])) .Label { + text-decoration: underline; + } + } + } + + /* Inactive */ + + &:where([data-inactive]), + &:where([data-inactive]):hover, + &:where([data-inactive]):active { + color: var(--button-inactive-fgColor); + cursor: auto; + background-color: var(--button-inactive-bgColor); + /* stylelint-disable-next-line primer/colors */ + border-color: var(--button-inactive-bgColor); + box-shadow: none; + + & .Visual, + & .CounterLabel { + color: inherit !important; + } + + & [data-kbd-chord] { + background: var(--buttonKeybindingHint-inactive-bgColor); + color: var(--buttonKeybindingHint-inactive-fgColor); + border-color: var(--buttonKeybindingHint-inactive-borderColor); + } + } + + /* Icon-only + Counter */ + + &:where([data-icon-only-counter]) { + /* stylelint-disable-next-line primer/spacing */ + padding-inline: var(--control-medium-paddingInline-condensed); + + &:where([data-size='small']) { + /* stylelint-disable-next-line primer/spacing */ + padding-inline: var(--control-xsmall-paddingInline-condensed); + } + + &:where([data-size='large']) { + /* stylelint-disable-next-line primer/spacing */ + padding-inline: var(--control-large-paddingInline-normal); + } + } +} + +.ConditionalWrapper { + display: block; +} + +.ConditionalWrapperLink { + display: inline-flex; +} + +[data-kbd-chord] { + transition: 80ms ease-in; + transition-property: color, background-color, border-color; +} diff --git a/packages/react/src/Button/ButtonBase.modernize.module.css b/packages/react/src/Button/ButtonBase.modernize.module.css new file mode 100644 index 00000000000..35b9eeb517d --- /dev/null +++ b/packages/react/src/Button/ButtonBase.modernize.module.css @@ -0,0 +1,51 @@ +.ButtonBase { + & [data-component='buttonContent'] > :not(:last-child) { + margin-right: var(--base-size-6); + } + + &:where(:not([data-component='IconButton'])) { + font-weight: var(--base-text-weight-semibold); + -webkit-font-smoothing: antialiased; + /* stylelint-disable-next-line primer/borders -- GitHub App's 8px radius has no matching Primer token */ + border-radius: 8px; + } + + &:where(:not([data-variant='link'])) [data-component='text'] { + /* stylelint-disable-next-line primer/typography -- match the 20px control label line height */ + line-height: var(--base-size-20); + } + + &:where([data-size='small'], [data-size='large']) { + gap: var(--base-size-6); + } + + &:where([data-size='small']), + &:where([data-size='small']:not([data-variant='link'])) [data-component='text'] { + line-height: var(--base-text-lineHeight-normal); + } + + &.HasLeadingVisual:where(:not([data-icon-only-counter])) { + padding-inline: var(--base-size-8) var(--base-size-12); + + &:where([data-size='small']) { + padding-inline: var(--base-size-6) var(--base-size-8); + } + + &:where([data-size='large']) { + padding-inline: var(--base-size-12) var(--base-size-16); + } + } + + /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to this CSS Module and a single keyboard chord attribute */ + &:has([data-kbd-chord]) { + padding-inline: calc(var(--base-size-12) - var(--base-size-2)) var(--base-size-8); + + &:where([data-size='small']) { + padding-inline: var(--base-size-8) var(--base-size-6); + } + + &:where([data-size='large']) { + padding-inline: var(--base-size-12) calc(var(--base-size-12) - var(--base-size-2)); + } + } +} diff --git a/packages/react/src/Button/ButtonBase.tsx b/packages/react/src/Button/ButtonBase.tsx index d4747b94d4d..8d3e0771e77 100644 --- a/packages/react/src/Button/ButtonBase.tsx +++ b/packages/react/src/Button/ButtonBase.tsx @@ -10,6 +10,7 @@ import {ConditionalWrapper} from '../internal/components/ConditionalWrapper' import {AriaStatus} from '../live-region' import {clsx} from 'clsx' import classes from './ButtonBase.module.css' +import modernizeClasses from './ButtonBase.modernize.module.css' import {isElement, isValidElementType} from 'react-is' import {useFeatureFlag} from '../FeatureFlags' @@ -111,7 +112,12 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f {...rest} // @ts-ignore temporary disable as we migrate to css modules, until we remove PolymorphicForwardRefComponent ref={mergedRef} - className={clsx(classes.ButtonBase, className)} + className={clsx( + classes.ButtonBase, + modernizeClasses.ButtonBase, + LeadingVisual && modernizeClasses.HasLeadingVisual, + className, + )} data-block={block ? 'block' : null} data-inactive={inactive ? true : undefined} data-loading={Boolean(loading)} diff --git a/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap b/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap index 1051eac1018..a99745de095 100644 --- a/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap +++ b/packages/react/src/Button/__tests__/__snapshots__/Button.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Button > respects block prop 1`] = `