From 1566b8d9c91d90e93579f137baad56a2cf9eeaad Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:41:58 -0700 Subject: [PATCH] fix(CodeBlock): show action buttons on hover and improve a11y CodeBlock action buttons (copy, wrap) overlapped code content on long first lines. They are now hidden by default and revealed on hover or focus-within with a fade transition that respects prefers-reduced-motion, over a semi-opaque pill matching the code block theme. The buttons expose descriptive aria-labels, with aria-pressed on the wrap toggle, so screen reader users get meaningful announcements instead of the raw icon name. IconButton now honours a caller-supplied aria-label and falls back to the icon name when none is provided. --- .changeset/fix-codeblock-button-overlap.md | 5 +++ src/components/CodeBlock/CodeBlock.test.tsx | 40 ++++++++++++++++++++ src/components/CodeBlock/CodeBlock.tsx | 42 +++++++++++++++------ src/components/IconButton/IconButton.tsx | 16 +++++++- 4 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-codeblock-button-overlap.md create mode 100644 src/components/CodeBlock/CodeBlock.test.tsx diff --git a/.changeset/fix-codeblock-button-overlap.md b/.changeset/fix-codeblock-button-overlap.md new file mode 100644 index 000000000..c4155c3c1 --- /dev/null +++ b/.changeset/fix-codeblock-button-overlap.md @@ -0,0 +1,5 @@ +--- +"@clickhouse/click-ui": patch +--- + +CodeBlock action buttons (copy, wrap) are now hidden by default and revealed on hover or focus-within with a fade transition. Buttons display a semi-opaque background pill matching the code block theme to prevent overlap with code content, and expose descriptive `aria-label`s (with `aria-pressed` on the wrap toggle) for screen reader users. IconButton now honours a caller-supplied `aria-label`, falling back to the icon name when none is provided. diff --git a/src/components/CodeBlock/CodeBlock.test.tsx b/src/components/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 000000000..f031ba2ab --- /dev/null +++ b/src/components/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,40 @@ +import { CodeBlock } from '@/components/CodeBlock'; +import { fireEvent } from '@testing-library/react'; +import { renderCUI } from '@/utils/test-utils'; + +describe('CodeBlock', () => { + it('exposes accessible labels for the action buttons', () => { + const { getByRole } = renderCUI( + + SELECT 1 + + ); + + expect(getByRole('button', { name: 'Copy code' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Wrap long lines' })).toBeInTheDocument(); + }); + + it('toggles the wrap button label and pressed state when clicked', () => { + const { getByRole } = renderCUI( + + SELECT 1 + + ); + + const wrapButton = getByRole('button', { name: 'Wrap long lines' }); + expect(wrapButton).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(wrapButton); + + expect(getByRole('button', { name: 'Disable line wrapping' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); +}); diff --git a/src/components/CodeBlock/CodeBlock.tsx b/src/components/CodeBlock/CodeBlock.tsx index d38edfa59..55ce8b521 100644 --- a/src/components/CodeBlock/CodeBlock.tsx +++ b/src/components/CodeBlock/CodeBlock.tsx @@ -40,12 +40,37 @@ interface CustomRendererProps { useInlineStyles: boolean; } +const ButtonContainer = styled.div<{ $theme?: CodeThemeType }>` + position: absolute; + display: flex; + opacity: 0; + transition: opacity 0.15s ease; + @media (prefers-reduced-motion: reduce) { + transition: none; + } + ${({ theme, $theme }) => { + const themeName = ($theme ?? theme.name) as CodeThemeType; + const bg = theme.click.codeblock[`${themeName}Mode`].color.background.default; + return ` + gap: ${theme.sizes[3]}; + border-radius: ${theme.click.codeblock.radii.all}; + padding: ${theme.sizes[2]}; + top: ${theme.click.codeblock.space.y}; + right: ${theme.click.codeblock.space.x}; + background: color-mix(in srgb, ${bg} 90%, transparent); + `; + }} +`; + const CodeBlockContainer = styled.div<{ $theme?: CodeThemeType }>` width: 100%; width: -webkit-fill-available; width: fill-available; width: stretch; position: relative; + &:hover ${ButtonContainer}, &:focus-within ${ButtonContainer} { + opacity: 1; + } ${({ theme, $theme }) => { const themeName = theme.name as CodeThemeType; @@ -84,16 +109,6 @@ const CodeContent = styled.code` color: inherit; `; -const ButtonContainer = styled.div` - position: absolute; - display: flex; - ${({ theme }) => ` - gap: 0.625rem; - top: ${theme.click.codeblock.space.y}; - right: ${theme.click.codeblock.space.x}; - `} -`; - export const CodeBlock = ({ children, language, @@ -140,13 +155,15 @@ export const CodeBlock = ({ $theme={theme} {...props} > - + {showWrapButton && ( )} @@ -155,6 +172,9 @@ export const CodeBlock = ({ $copied={copied} $error={errorCopy} icon={copied ? 'check' : errorCopy ? 'warning' : 'copy'} + aria-label={ + copied ? 'Code copied' : errorCopy ? 'Failed to copy code' : 'Copy code' + } onClick={copyCodeToClipboard} /> diff --git a/src/components/IconButton/IconButton.tsx b/src/components/IconButton/IconButton.tsx index c697422c8..d0c7fc780 100644 --- a/src/components/IconButton/IconButton.tsx +++ b/src/components/IconButton/IconButton.tsx @@ -26,7 +26,19 @@ const iconButtonVariants = cva(styles.iconbutton, { }); export const IconButton = forwardRef( - ({ type = 'primary', htmlType, icon, size, disabled, className, ...props }, ref) => { + ( + { + type = 'primary', + htmlType, + icon, + size, + disabled, + className, + 'aria-label': ariaLabel, + ...props + }, + ref + ) => { const iconName = icon ? icon.toString() : 'unknown icon'; return ( @@ -37,7 +49,7 @@ export const IconButton = forwardRef( disabled={disabled} ref={ref} role="button" - aria-label={iconName} + aria-label={ariaLabel ?? iconName} >