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} >