diff --git a/superset-frontend/src/components/IconButton/IconButton.stories.tsx b/superset-frontend/src/components/IconButton/IconButton.stories.tsx index 0cc10f32f82ba26a330bdbecc972a8497c18fe04..3b47e4a0c321b8d602ae81c897ad2ccfac5896ed 100644 --- a/superset-frontend/src/components/IconButton/IconButton.stories.tsx +++ b/superset-frontend/src/components/IconButton/IconButton.stories.tsx @@ -16,42 +16,37 @@ * specific language governing permissions and limitations * under the License. */ -import IconButton, { IconButtonProps } from '.'; +import { Meta, StoryObj } from '@storybook/react'; +import { IconButton } from 'src/components/IconButton'; -export default { - title: 'IconButton', +const meta: Meta<typeof IconButton> = { + title: 'Components/IconButton', component: IconButton, + argTypes: { + onClick: { action: 'clicked' }, + }, + parameters: { + a11y: { + enabled: true, + }, + }, }; -export const InteractiveIconButton = (args: IconButtonProps) => ( - <IconButton - buttonText={args.buttonText} - altText={args.altText} - icon={args.icon} - href={args.href} - target={args.target} - htmlType={args.htmlType} - /> -); +export default meta; -InteractiveIconButton.args = { - buttonText: 'This is the IconButton text', - altText: 'This is an example of non-default alt text', - href: 'https://preset.io/', - target: '_blank', +type Story = StoryObj<typeof IconButton>; + +export const Default: Story = { + args: { + buttonText: 'Default IconButton', + altText: 'Default icon button alt text', + }, }; -InteractiveIconButton.argTypes = { - icon: { - defaultValue: '/images/icons/sql.svg', - control: { - type: 'select', - }, - options: [ - '/images/icons/sql.svg', - '/images/icons/server.svg', - '/images/icons/image.svg', - 'Click to see example alt text', - ], +export const CustomIcon: Story = { + args: { + buttonText: 'Custom icon IconButton', + altText: 'Custom icon button alt text', + icon: '/images/sqlite.png', }, }; diff --git a/superset-frontend/src/components/IconButton/IconButton.test.jsx b/superset-frontend/src/components/IconButton/IconButton.test.jsx deleted file mode 100644 index aa37d8949091d5d7ffe2e2c8074e14d127a38512..0000000000000000000000000000000000000000 --- a/superset-frontend/src/components/IconButton/IconButton.test.jsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { render, screen } from 'spec/helpers/testing-library'; -import IconButton from 'src/components/IconButton'; - -const defaultProps = { - buttonText: 'This is the IconButton text', - icon: '/images/icons/sql.svg', -}; - -describe('IconButton', () => { - it('renders an IconButton', () => { - render(<IconButton {...defaultProps} />); - - const icon = screen.getByRole('img'); - const buttonText = screen.getByText(/this is the iconbutton text/i); - - expect(icon).toBeVisible(); - expect(buttonText).toBeVisible(); - }); -}); diff --git a/superset-frontend/src/components/IconButton/IconButton.test.tsx b/superset-frontend/src/components/IconButton/IconButton.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc45fe003ecc6a8d886791e88f64e00a1eccaa43 --- /dev/null +++ b/superset-frontend/src/components/IconButton/IconButton.test.tsx @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen, fireEvent } from 'spec/helpers/testing-library'; +import { IconButton } from 'src/components/IconButton'; + +const defaultProps = { + buttonText: 'This is the IconButton text', + icon: '/images/icons/sql.svg', +}; + +describe('IconButton', () => { + it('renders an IconButton with icon and text', () => { + render(<IconButton {...defaultProps} />); + + const icon = screen.getByRole('img'); + const buttonText = screen.getByText(/this is the iconbutton text/i); + + expect(icon).toBeVisible(); + expect(buttonText).toBeVisible(); + }); + + it('is keyboard accessible and has correct aria attributes', () => { + render(<IconButton {...defaultProps} />); + + const button = screen.getByRole('button'); + + expect(button).toHaveAttribute('tabIndex', '0'); + expect(button).toHaveAttribute('aria-label', defaultProps.buttonText); + }); + + it('handles Enter and Space key presses', () => { + const mockOnClick = jest.fn(); + render(<IconButton {...defaultProps} onClick={mockOnClick} />); + + const button = screen.getByRole('button'); + + fireEvent.keyDown(button, { key: 'Enter', code: 'Enter' }); + expect(mockOnClick).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(mockOnClick).toHaveBeenCalledTimes(2); + }); + + it('uses custom alt text when provided', () => { + const customAltText = 'Custom Alt Text'; + render( + <IconButton + buttonText="Custom Alt Text Button" + icon="/images/icons/sql.svg" + altText={customAltText} + />, + ); + + const icon = screen.getByAltText(customAltText); + expect(icon).toBeVisible(); + }); + + it('displays tooltip with button text', () => { + render(<IconButton {...defaultProps} />); + + const tooltipTrigger = screen.getByText(/this is the iconbutton text/i); + expect(tooltipTrigger).toBeVisible(); + }); + + it('calls onClick handler when clicked', () => { + const mockOnClick = jest.fn(); + render(<IconButton {...defaultProps} onClick={mockOnClick} />); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/superset-frontend/src/components/IconButton/index.tsx b/superset-frontend/src/components/IconButton/index.tsx index f6b0650776f48eb63285dea46f80b83edf9089d3..b8ec447a5ea70b4ff701d118b2cb09b3c326182c 100644 --- a/superset-frontend/src/components/IconButton/index.tsx +++ b/superset-frontend/src/components/IconButton/index.tsx @@ -16,129 +16,90 @@ * specific language governing permissions and limitations * under the License. */ -import { styled } from '@superset-ui/core'; -import Button, { ButtonProps as AntdButtonProps } from 'src/components/Button'; + +// eslint-disable-next-line +import { Typography } from 'src/components'; +import { Tooltip } from 'src/components/Tooltip'; +import Card, { CardProps } from 'src/components/Card'; import { Icons } from 'src/components/Icons'; -import LinesEllipsis from 'react-lines-ellipsis'; +import { SupersetTheme, css } from '@superset-ui/core'; -export interface IconButtonProps extends AntdButtonProps { +export interface IconButtonProps extends CardProps { buttonText: string; icon: string; altText?: string; } -const StyledButton = styled(Button)` - height: auto; - display: flex; - flex-direction: column; - padding: 0; -`; - -const StyledImage = styled.div` - padding: ${({ theme }) => theme.gridUnit * 4}px; - height: ${({ theme }) => theme.gridUnit * 18}px; - margin: ${({ theme }) => theme.gridUnit * 3}px 0; - - .default-db-icon { - font-size: 36px; - color: ${({ theme }) => theme.colors.grayscale.base}; - margin-right: 0; - span:first-of-type { - margin-right: 0; - } - } - - &:first-of-type { - margin-right: 0; - } - - img { - width: ${({ theme }) => theme.gridUnit * 10}px; - height: ${({ theme }) => theme.gridUnit * 10}px; - margin: 0; - &:first-of-type { - margin-right: 0; - } - } - svg { - &:first-of-type { - margin-right: 0; +const IconButton: React.FC<IconButtonProps> = ({ + buttonText, + icon, + altText, + ...cardProps +}) => { + const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { + if (e.key === 'Enter' || e.key === ' ') { + if (cardProps.onClick) { + (cardProps.onClick as React.EventHandler<React.SyntheticEvent>)(e); + } + if (e.key === ' ') { + e.preventDefault(); + } } - } -`; - -const StyledInner = styled.div` - max-height: calc(1.5em * 2); - white-space: break-spaces; - - &:first-of-type { - margin-right: 0; - } - - .LinesEllipsis { - &:first-of-type { - margin-right: 0; - } - } -`; - -const StyledBottom = styled.div` - padding: ${({ theme }) => theme.gridUnit * 4}px 0; - border-radius: 0 0 ${({ theme }) => theme.borderRadius}px - ${({ theme }) => theme.borderRadius}px; - background-color: ${({ theme }) => theme.colors.grayscale.light4}; - width: 100%; - line-height: 1.5em; - overflow: hidden; - white-space: no-wrap; - text-overflow: ellipsis; - - &:first-of-type { - margin-right: 0; - } -`; + cardProps.onKeyDown?.(e); + }; -const IconButton = styled( - ({ icon, altText, buttonText, ...props }: IconButtonProps) => ( - <StyledButton {...props}> - <StyledImage> - {icon && <img src={icon} alt={altText} />} - {!icon && ( - <Icons.DatabaseOutlined - className="default-db-icon" - aria-label="default-icon" - /> - )} - </StyledImage> + const renderIcon = () => { + const iconContent = icon ? ( + <img + src={icon} + alt={altText || buttonText} + css={css` + width: 100%; + height: 120px; + object-fit: contain; + `} + /> + ) : ( + <div + css={css` + display: flex; + align-content: center; + align-items: center; + height: 120px; + `} + > + <Icons.DatabaseOutlined + css={css` + font-size: 48px; + `} + aria-label="default-icon" + /> + </div> + ); - <StyledBottom> - <StyledInner> - <LinesEllipsis - text={buttonText} - maxLine="2" - basedOn="words" - trimRight - /> - </StyledInner> - </StyledBottom> - </StyledButton> - ), -)` - text-transform: none; - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - font-weight: ${({ theme }) => theme.typography.weights.normal}; - color: ${({ theme }) => theme.colors.grayscale.dark2}; - border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - margin: 0; - width: 100%; + return iconContent; + }; - &:hover, - &:focus { - background-color: ${({ theme }) => theme.colors.grayscale.light5}; - color: ${({ theme }) => theme.colors.grayscale.dark2}; - border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; - box-shadow: 4px 4px 20px ${({ theme }) => theme.colors.grayscale.light2}; - } -`; + return ( + <Card + hoverable + role="button" + tabIndex={0} + aria-label={buttonText} + onKeyDown={handleKeyDown} + cover={renderIcon()} + css={(theme: SupersetTheme) => ({ + padding: theme.gridUnit * 3, + textAlign: 'center', + ...cardProps.style, + })} + {...cardProps} + > + <Tooltip title={buttonText}> + <Typography.Text ellipsis>{buttonText}</Typography.Text> + </Tooltip> + </Card> + ); +}; -export default IconButton; +export { IconButton }; diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 6efe47bdd261507b027dae1c7634ff5e2366053b..8b359cc141b153827b73aaa2dafddaea7d915b6b 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -45,7 +45,7 @@ import { AntdSelect, Upload } from 'src/components'; import Alert from 'src/components/Alert'; import Modal from 'src/components/Modal'; import Button from 'src/components/Button'; -import IconButton from 'src/components/IconButton'; +import { IconButton } from 'src/components/IconButton'; import InfoTooltip from 'src/components/InfoTooltip'; import withToasts from 'src/components/MessageToasts/withToasts'; import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';