From c0fa11e5ec3f0a8a0ef9a45df6400cdcec62c710 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:11:21 +0100 Subject: [PATCH] feat: add an icon button that always has an accessible name --- components/ui/__tests__/icon-button.test.tsx | 31 ++++++++++++++++++ components/ui/icon-button.tsx | 33 ++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 components/ui/__tests__/icon-button.test.tsx create mode 100644 components/ui/icon-button.tsx diff --git a/components/ui/__tests__/icon-button.test.tsx b/components/ui/__tests__/icon-button.test.tsx new file mode 100644 index 000000000..39efa0367 --- /dev/null +++ b/components/ui/__tests__/icon-button.test.tsx @@ -0,0 +1,31 @@ +import { createRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { IconButton } from '../icon-button'; +import { X } from '@/components/icons'; + +describe('IconButton', () => { + it('is named and titled by its label', () => { + render(); + const button = screen.getByRole('button', { name: 'Close' }); + expect(button).toHaveAttribute('title', 'Close'); + expect(button).toHaveAttribute('type', 'button'); + }); + + it('defaults to ghost icon-sm and takes another size', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toHaveClass('h-8', 'w-8', 'hover:bg-accent'); + rerender(); + expect(screen.getByRole('button')).toHaveClass('h-11', 'w-11'); + }); + + it('forwards the ref and other props', () => { + const ref = createRef(); + const onClick = vi.fn(); + render(); + expect(ref.current).toBe(screen.getByTestId('del')); + expect(ref.current).not.toHaveAttribute('title', 'Delete'); + fireEvent.click(ref.current!); + expect(onClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/components/ui/icon-button.tsx b/components/ui/icon-button.tsx new file mode 100644 index 000000000..92939713c --- /dev/null +++ b/components/ui/icon-button.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { Button, type ButtonProps } from "@/components/ui/button"; + +export interface IconButtonProps extends Omit { + /** What the button does. Becomes the accessible name and the tooltip. */ + label: string; + /** The icon. It gets w-4 in icon-sm and w-5 in icon-lg unless it sets its own size. */ + children: React.ReactNode; +} + +/** + * An icon-only button (R1): ghost, 32px (`icon-sm`) unless told otherwise, + * always named. `title` defaults to the label; pass `title=""` to drop the + * tooltip where another one already shows. + */ +const IconButton = React.forwardRef( + ({ label, variant = "ghost", size = "icon-sm", type = "button", title, children, ...props }, ref) => ( + + ) +); +IconButton.displayName = "IconButton"; + +export { IconButton };