Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-codeblock-button-overlap.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions src/components/CodeBlock/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CodeBlock
language="sql"
showWrapButton
>
SELECT 1
</CodeBlock>
);

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(
<CodeBlock
language="sql"
showWrapButton
>
SELECT 1
</CodeBlock>
);

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'
);
});
});
42 changes: 31 additions & 11 deletions src/components/CodeBlock/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -140,13 +155,15 @@ export const CodeBlock = ({
$theme={theme}
{...props}
>
<ButtonContainer>
<ButtonContainer $theme={theme}>
{showWrapButton && (
<CodeButton
as={IconButton}
$copied={false}
$error={false}
icon="document"
aria-label={wrap ? 'Disable line wrapping' : 'Wrap long lines'}
aria-pressed={wrap}
onClick={wrapElement}
/>
)}
Expand All @@ -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}
/>
</ButtonContainer>
Expand Down
16 changes: 14 additions & 2 deletions src/components/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,19 @@ const iconButtonVariants = cva(styles.iconbutton, {
});

export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ 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 (
Expand All @@ -37,7 +49,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
disabled={disabled}
ref={ref}
role="button"
aria-label={iconName}
aria-label={ariaLabel ?? iconName}
>
<Icon
name={icon}
Expand Down
Loading