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