diff --git a/src/components/NewItemButton/NewItemButton.stories.tsx b/src/components/NewItemButton/NewItemButton.stories.tsx new file mode 100644 index 00000000..143e6615 --- /dev/null +++ b/src/components/NewItemButton/NewItemButton.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { NewItemButton } from './NewItemButton'; + +import { NewItemButtonDocs } from '@/docs-components/NewItemButtonDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'NewItemButton', + component: NewItemButton, + tags: ['autodocs'], + argTypes: {}, + args: { + state: 'normal', + text: 'text', + }, + parameters: { + docs: { + description: { + component: + 'A dedicated button for creating new items, such as files, events, or tasks, typically placed in a prominent location and distinguished by an icon or label.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + state: 'normal', + text: 'New category', + }, +}; + +export const Alert: Story = { + args: { + state: 'alert', + text: 'Alert!', + }, +}; + +export const Disabled: Story = { + args: { + state: 'disabled', + text: 'Disabled', + }, +}; diff --git a/src/components/NewItemButton/NewItemButton.styles.ts b/src/components/NewItemButton/NewItemButton.styles.ts new file mode 100644 index 00000000..8bddf157 --- /dev/null +++ b/src/components/NewItemButton/NewItemButton.styles.ts @@ -0,0 +1,69 @@ +import { NewItemButtonState } from './NewItemButtonState.type'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type NewItemButtonConfig = { + state?: Partial>; + innerElements?: { + text?: BaseProps; + }; +} & BaseProps; + +export const defaultConfig = { + display: 'inline-flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: '$space-component-gap-small', + minH: '120px', + minWidth: '120px', + h: '100%', + w: '100%', + borderWidth: '$border-width-small', + borderStyle: '$border-style-dashed', + borderRadius: '$border-radius-large', + padding: '$space-component-padding-xLarge', + text: '$typo-body-medium', + textAlign: 'center', + whiteSpace: 'nowrap', + color: '$color-action-neutral-normal', + backgroundColor: '$color-interaction-background-formField', + transition: true, + transitionDuration: 200, + outline: { + focus: 'solid', + }, + outlineColor: { + focus: '$color-interaction-focus-default', + }, + outlineWidth: { + focus: '$border-width-focus', + }, + outlineOffset: 1, + state: { + normal: { + borderColor: { + _: '$color-border-neutral-subtle', + hover: '$color-interaction-border-hover', + }, + }, + alert: { + borderColor: '$color-interaction-border-alert', + }, + disabled: { + borderColor: '$color-border-neutral-subtle', + opacity: '$opacity-disabled', + pointerEvents: 'none', + }, + }, + innerElements: { + text: { + text: '$typo-body-medium', + color: '$color-content-primary', + }, + }, +} as const satisfies NewItemButtonConfig; + +export const newItemButtonStyles = { + defaultConfig, +}; diff --git a/src/components/NewItemButton/NewItemButton.test.tsx b/src/components/NewItemButton/NewItemButton.test.tsx new file mode 100644 index 00000000..5d98a0cd --- /dev/null +++ b/src/components/NewItemButton/NewItemButton.test.tsx @@ -0,0 +1,53 @@ +import { vi } from 'vitest'; + +import { NewItemButton } from './NewItemButton'; +import { NewItemButtonState } from './NewItemButtonState.type'; +import { render, screen, fireEvent } from '../../tests/render'; + +describe('NewItemButton', () => { + const states: NewItemButtonState[] = ['normal', 'alert', 'disabled']; + + it('should render the NewItemButton ', () => { + render(); + const button = screen.getByTestId('new-item-button'); + expect(button).toBeInTheDocument(); + }); + + it('should be disabled if disabled state is passed', () => { + render(); + const button = screen.getByTestId('new-item-button'); + expect(button).toBeDisabled(); + expect(button).toHaveStyle('pointer-events: none'); + expect(button).toHaveStyle('opacity: 0.5'); + }); + + states.forEach((state) => { + describe(`State: ${state}`, () => { + it('should render the NewItemButton', () => { + render(); + const button = screen.getByTestId('new-item-button'); + expect(button).toBeInTheDocument(); + }); + + it('should render correct text', () => { + render(); + const button = screen.getByTestId('new-item-button'); + expect(button).toHaveTextContent('Hello there!'); + }); + + it('should handle onClick properly when clicked', () => { + const onClickMock = vi.fn(); + render(); + + const button = screen.getByTestId('new-item-button'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + if (state !== 'disabled') { + expect(onClickMock).toHaveBeenCalled(); + } else { + expect(onClickMock).not.toHaveBeenCalled(); + } + }); + }); + }); +}); diff --git a/src/components/NewItemButton/NewItemButton.tsx b/src/components/NewItemButton/NewItemButton.tsx new file mode 100644 index 00000000..621ff56f --- /dev/null +++ b/src/components/NewItemButton/NewItemButton.tsx @@ -0,0 +1,28 @@ +import { Icon } from '@virtuslab/tetrisly-icons'; +import { useMemo, type FC } from 'react'; + +import { NewItemButtonProps } from './NewItemButtons.props'; +import { stylesBuilder } from './stylesBuilder'; + +import { tet } from '@/tetrisly'; + +export const NewItemButton: FC = ({ + state = 'normal', + text, + custom, + ...rest +}) => { + const styles = useMemo(() => stylesBuilder(state, custom), [custom, state]); + + return ( + + + {!!text && {text}} + + ); +}; diff --git a/src/components/NewItemButton/NewItemButtonState.type.ts b/src/components/NewItemButton/NewItemButtonState.type.ts new file mode 100644 index 00000000..34ed6c2e --- /dev/null +++ b/src/components/NewItemButton/NewItemButtonState.type.ts @@ -0,0 +1 @@ +export type NewItemButtonState = 'normal' | 'alert' | 'disabled'; diff --git a/src/components/NewItemButton/NewItemButtons.props.ts b/src/components/NewItemButton/NewItemButtons.props.ts new file mode 100644 index 00000000..4331f8c7 --- /dev/null +++ b/src/components/NewItemButton/NewItemButtons.props.ts @@ -0,0 +1,10 @@ +import { ButtonHTMLAttributes } from 'react'; + +import { NewItemButtonConfig } from './NewItemButton.styles'; +import { NewItemButtonState } from './NewItemButtonState.type'; + +export type NewItemButtonProps = { + state?: NewItemButtonState | undefined; + text?: string; + custom?: NewItemButtonConfig; +} & Omit, 'disabled' | 'color'>; diff --git a/src/components/NewItemButton/index.ts b/src/components/NewItemButton/index.ts new file mode 100644 index 00000000..68344e98 --- /dev/null +++ b/src/components/NewItemButton/index.ts @@ -0,0 +1,3 @@ +export { NewItemButton } from './NewItemButton'; +export type { NewItemButtonProps } from './NewItemButtons.props'; +export { newItemButtonStyles } from './NewItemButton.styles'; diff --git a/src/components/NewItemButton/stylesBuilder.ts b/src/components/NewItemButton/stylesBuilder.ts new file mode 100644 index 00000000..c3ee56fd --- /dev/null +++ b/src/components/NewItemButton/stylesBuilder.ts @@ -0,0 +1,35 @@ +import { NewItemButtonConfig, defaultConfig } from './NewItemButton.styles'; +import { NewItemButtonState } from './NewItemButtonState.type'; + +import { mergeConfigWithCustom } from '@/services'; +import { BaseProps } from '@/types/BaseProps'; + +type NewItemButtonStyleBuilder = { + container: BaseProps; + text: BaseProps; +}; + +export const stylesBuilder = ( + state: NewItemButtonState, + custom?: NewItemButtonConfig, +): NewItemButtonStyleBuilder => { + const { + innerElements, + state: containerState, + ...container + } = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + + const { text } = innerElements; + const containerStyles = containerState[state]; + + return { + container: { + ...container, + ...containerStyles, + }, + text, + }; +}; diff --git a/src/docs-components/NewItemButtonDocs.tsx b/src/docs-components/NewItemButtonDocs.tsx new file mode 100644 index 00000000..cde0667c --- /dev/null +++ b/src/docs-components/NewItemButtonDocs.tsx @@ -0,0 +1,42 @@ +import { SectionHeader } from './common/SectionHeader'; +import { States } from './common/States'; + +import { NewItemButton } from '@/components/NewItemButton'; +import { tet } from '@/tetrisly'; + +const states = ['normal', 'alert', 'disabled'] as const; + +export const NewItemButtonDocs = () => ( + + + State + + + + + {states.map((state) => ( + + + + ))} + + + +); diff --git a/src/index.ts b/src/index.ts index cfb53891..492b3ff3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from './components/InlineMessage'; export * from './components/InlineSearchInput'; export * from './components/Label'; export * from './components/Loader'; +export * from './components/NewItemButton'; export * from './components/Popover'; export * from './components/RadioButton'; export * from './components/RadioButtonGroup'; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 8d1bea2d..e512203a 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -973,7 +973,7 @@ const fixedTokens = { borderStyles: { '$border-style-none': 'none', '$border-style-solid': 'solid', - '$border-style-dashed': 'dashed solid', + '$border-style-dashed': 'dashed', }, fonts: { '$font-family-primary': 'Inter' }, fontSizes: {