diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000000..96bd0bb4e5 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,40 @@ +name: 'Chromatic' + +on: + push: + branches-ignore: + - renovate/* + - stable-* + paths: + - 'package.json' + - 'yarn.lock' + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '**/*.css' + - '**/*.scss' + - '.github/workflows/chromatic.yml' + +jobs: + chromatic: + name: Run Chromatic + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Build Storybook + run: yarn build-storybook + + - name: Run Chromatic + uses: chromaui/action@v12 + with: + # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + zip: true + storybookBuildDir: 'storybook-static' diff --git a/.gitignore b/.gitignore index b4fb2c946b..db63bc07f0 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ docker-compose.override.yml # Ignore local-only rspec configuration .rspec-local + +*storybook.log +storybook-static diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000000..638806c085 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,16 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../app/javascript/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-docs', + '@storybook/addon-a11y', + '@storybook/addon-vitest', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +}; + +export default config; diff --git a/.storybook/manager.ts b/.storybook/manager.ts new file mode 100644 index 0000000000..53dfaa15ab --- /dev/null +++ b/.storybook/manager.ts @@ -0,0 +1,7 @@ +import { addons } from 'storybook/manager-api'; + +import theme from './storybook-theme'; + +addons.setConfig({ + theme, +}); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000000..0a4f196752 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,18 @@ + diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000000..a0bec9085f --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,29 @@ +import type { Preview } from '@storybook/react-vite'; + +// If you want to run the dark theme during development, +// you can change the below to `/application.scss` +import '../app/javascript/styles/mastodon-light.scss'; + +const preview: Preview = { + // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + layout: 'centered', + + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, +}; + +export default preview; diff --git a/.storybook/storybook-addon-vitest.d.ts b/.storybook/storybook-addon-vitest.d.ts new file mode 100644 index 0000000000..86852faca9 --- /dev/null +++ b/.storybook/storybook-addon-vitest.d.ts @@ -0,0 +1,7 @@ +// The addon package.json incorrectly exports types, so we need to override them here. +// See: https://github.com/storybookjs/storybook/blob/v9.0.4/code/addons/vitest/package.json#L70-L76 +declare module '@storybook/addon-vitest/vitest-plugin' { + export * from '@storybook/addon-vitest/dist/vitest-plugin/index'; +} + +export {}; diff --git a/.storybook/storybook-theme.ts b/.storybook/storybook-theme.ts new file mode 100644 index 0000000000..7a72ba1c75 --- /dev/null +++ b/.storybook/storybook-theme.ts @@ -0,0 +1,7 @@ +import { create } from 'storybook/theming'; + +export default create({ + base: 'light', + brandTitle: 'Mastodon Storybook', + brandImage: 'https://joinmastodon.org/logos/wordmark-black-text.svg', +}); diff --git a/.storybook/vitest.setup.ts b/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..a08badd02f --- /dev/null +++ b/.storybook/vitest.setup.ts @@ -0,0 +1,8 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/react-vite'; + +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/Gemfile.lock b/Gemfile.lock index 519412dea8..f897865bf4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -633,7 +633,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.15) + rack (3.1.16) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (3.0.0) diff --git a/app/javascript/mastodon/components/button/button.stories.tsx b/app/javascript/mastodon/components/button/button.stories.tsx new file mode 100644 index 0000000000..b4cca32abd --- /dev/null +++ b/app/javascript/mastodon/components/button/button.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn, expect } from 'storybook/test'; + +import { Button } from '.'; + +const meta = { + title: 'Components/Button', + component: Button, + args: { + secondary: false, + compact: false, + dangerous: false, + disabled: false, + onClick: fn(), + }, + argTypes: { + text: { + control: 'text', + type: 'string', + description: + 'Alternative way of specifying the button label. Will override `children` if provided.', + }, + type: { + type: 'string', + control: 'text', + table: { + type: { summary: 'string' }, + }, + }, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button')); + await expect(args.onClick).toHaveBeenCalled(); +}; + +const disabledButtonTest: Story['play'] = async ({ + args, + canvas, + userEvent, +}) => { + await userEvent.click(canvas.getByRole('button')); + await expect(args.onClick).not.toHaveBeenCalled(); +}; + +export const Primary: Story = { + args: { + children: 'Primary button', + }, + play: buttonTest, +}; + +export const Secondary: Story = { + args: { + secondary: true, + children: 'Secondary button', + }, + play: buttonTest, +}; + +export const Compact: Story = { + args: { + compact: true, + children: 'Compact button', + }, + play: buttonTest, +}; + +export const Dangerous: Story = { + args: { + dangerous: true, + children: 'Dangerous button', + }, + play: buttonTest, +}; + +export const PrimaryDisabled: Story = { + args: { + ...Primary.args, + disabled: true, + }, + play: disabledButtonTest, +}; + +export const SecondaryDisabled: Story = { + args: { + ...Secondary.args, + disabled: true, + }, + play: disabledButtonTest, +}; diff --git a/app/javascript/mastodon/components/button.tsx b/app/javascript/mastodon/components/button/index.tsx similarity index 93% rename from app/javascript/mastodon/components/button.tsx rename to app/javascript/mastodon/components/button/index.tsx index a527468f65..43f5901c74 100644 --- a/app/javascript/mastodon/components/button.tsx +++ b/app/javascript/mastodon/components/button/index.tsx @@ -22,6 +22,10 @@ interface PropsWithText extends BaseProps { type Props = PropsWithText | PropsChildren; +/** + * Primary UI component for user interaction that doesn't result in navigation. + */ + export const Button: React.FC = ({ type = 'button', onClick, diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx deleted file mode 100644 index 855a1f4fc8..0000000000 --- a/app/javascript/mastodon/features/account/components/account_note.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { is } from 'immutable'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import Textarea from 'react-textarea-autosize'; - -import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; - -const messages = defineMessages({ - placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' }, -}); - -class InlineAlert extends PureComponent { - - static propTypes = { - show: PropTypes.bool, - }; - - state = { - mountMessage: false, - }; - - static TRANSITION_DELAY = 200; - - UNSAFE_componentWillReceiveProps (nextProps) { - if (!this.props.show && nextProps.show) { - this.setState({ mountMessage: true }); - } else if (this.props.show && !nextProps.show) { - setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY); - } - } - - render () { - const { show } = this.props; - const { mountMessage } = this.state; - - return ( - - {mountMessage && } - - ); - } - -} - -class AccountNote extends ImmutablePureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - value: PropTypes.string, - onSave: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - state = { - value: null, - saving: false, - saved: false, - }; - - UNSAFE_componentWillMount () { - this._reset(); - } - - UNSAFE_componentWillReceiveProps (nextProps) { - const accountWillChange = !is(this.props.accountId, nextProps.accountId); - const newState = {}; - - if (accountWillChange && this._isDirty()) { - this._save(false); - } - - if (accountWillChange || nextProps.value === this.state.value) { - newState.saving = false; - } - - if (this.props.value !== nextProps.value) { - newState.value = nextProps.value; - } - - this.setState(newState); - } - - componentWillUnmount () { - if (this._isDirty()) { - this._save(false); - } - } - - setTextareaRef = c => { - this.textarea = c; - }; - - handleChange = e => { - this.setState({ value: e.target.value, saving: false }); - }; - - handleKeyDown = e => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - - if (this.textarea) { - this.textarea.blur(); - } else { - this._save(); - } - } else if (e.keyCode === 27) { - e.preventDefault(); - - this._reset(() => { - if (this.textarea) { - this.textarea.blur(); - } - }); - } - }; - - handleBlur = () => { - if (this._isDirty()) { - this._save(); - } - }; - - _save (showMessage = true) { - this.setState({ saving: true }, () => this.props.onSave(this.state.value)); - - if (showMessage) { - this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000)); - } - } - - _reset (callback) { - this.setState({ value: this.props.value }, callback); - } - - _isDirty () { - return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value; - } - - render () { - const { accountId, intl } = this.props; - const { value, saved } = this.state; - - if (!accountId) { - return null; - } - - return ( -
- - - {this.props.value === undefined ? ( -
- -
- ) : ( -