diff --git a/packages/component-library/.storybook/main.ts b/packages/component-library/.storybook/main.ts index 77afada247..5a5fece876 100644 --- a/packages/component-library/.storybook/main.ts +++ b/packages/component-library/.storybook/main.ts @@ -13,7 +13,8 @@ function getAbsolutePath(value: string) { } const config: StorybookConfig = { stories: [ - '../src/Introduction.mdx', + '../src/Concepts/*.mdx', + '../src/Themes/*.mdx', '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], diff --git a/packages/component-library/.storybook/manager-head.html b/packages/component-library/.storybook/manager-head.html new file mode 100644 index 0000000000..5e01a16d90 --- /dev/null +++ b/packages/component-library/.storybook/manager-head.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/component-library/.storybook/public/favicon.ico b/packages/component-library/.storybook/public/favicon.ico new file mode 100644 index 0000000000..a88b044eb0 Binary files /dev/null and b/packages/component-library/.storybook/public/favicon.ico differ diff --git a/packages/component-library/.storybook/public/global-styles.css b/packages/component-library/.storybook/public/global-styles.css new file mode 100644 index 0000000000..d63ee6204e --- /dev/null +++ b/packages/component-library/.storybook/public/global-styles.css @@ -0,0 +1 @@ +/* Custom Storybook Styling */ diff --git a/packages/component-library/.storybook/public/og.webp b/packages/component-library/.storybook/public/og.webp new file mode 100644 index 0000000000..a98c0f43aa Binary files /dev/null and b/packages/component-library/.storybook/public/og.webp differ diff --git a/packages/component-library/src/AlignedText.stories.tsx b/packages/component-library/src/AlignedText.stories.tsx index 595eacba3e..c9ac70d2eb 100644 --- a/packages/component-library/src/AlignedText.stories.tsx +++ b/packages/component-library/src/AlignedText.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { AlignedText } from './AlignedText'; const meta = { - title: 'AlignedText', + title: 'Components/AlignedText', component: AlignedText, parameters: { layout: 'centered', diff --git a/packages/component-library/src/Block.stories.tsx b/packages/component-library/src/Block.stories.tsx index 1da39fa88a..be3693004a 100644 --- a/packages/component-library/src/Block.stories.tsx +++ b/packages/component-library/src/Block.stories.tsx @@ -4,7 +4,7 @@ import { Block } from './Block'; import { theme } from './theme'; const meta = { - title: 'Block', + title: 'Components/Block', component: Block, parameters: { layout: 'centered', diff --git a/packages/component-library/src/Button.stories.ts b/packages/component-library/src/Button.stories.ts index 9325f4b6d5..aafe00b08b 100644 --- a/packages/component-library/src/Button.stories.ts +++ b/packages/component-library/src/Button.stories.ts @@ -4,7 +4,7 @@ import { fn } from 'storybook/test'; import { Button } from './Button'; const meta = { - title: 'Button', + title: 'Components/Button', component: Button, parameters: { layout: 'centered', diff --git a/packages/component-library/src/Card.stories.tsx b/packages/component-library/src/Card.stories.tsx index edc3221bbf..6cac8b1c6d 100644 --- a/packages/component-library/src/Card.stories.tsx +++ b/packages/component-library/src/Card.stories.tsx @@ -6,7 +6,7 @@ import { Paragraph } from './Paragraph'; import { theme } from './theme'; const meta = { - title: 'Card', + title: 'Components/Card', component: Card, parameters: { layout: 'centered', diff --git a/packages/component-library/src/ColorPicker.stories.tsx b/packages/component-library/src/ColorPicker.stories.tsx index a838e64447..0d96d9adb2 100644 --- a/packages/component-library/src/ColorPicker.stories.tsx +++ b/packages/component-library/src/ColorPicker.stories.tsx @@ -8,7 +8,7 @@ import { Button } from './Button'; import { ColorPicker } from './ColorPicker'; const meta = { - title: 'ColorPicker', + title: 'Components/ColorPicker', component: ColorPicker, parameters: { layout: 'centered', diff --git a/packages/component-library/src/Introduction.mdx b/packages/component-library/src/Concepts/Introduction.mdx similarity index 95% rename from packages/component-library/src/Introduction.mdx rename to packages/component-library/src/Concepts/Introduction.mdx index 8954862d7f..616486e9cf 100644 --- a/packages/component-library/src/Introduction.mdx +++ b/packages/component-library/src/Concepts/Introduction.mdx @@ -1,6 +1,6 @@ import { Meta } from '@storybook/addon-docs/blocks'; - + # Actual Budget Component Library diff --git a/packages/component-library/src/FormError.stories.tsx b/packages/component-library/src/FormError.stories.tsx index a76ff9054f..d03c19abc2 100644 --- a/packages/component-library/src/FormError.stories.tsx +++ b/packages/component-library/src/FormError.stories.tsx @@ -5,7 +5,7 @@ import { Input } from './Input'; import { View } from './View'; const meta = { - title: 'FormError', + title: 'Components/FormError', component: FormError, parameters: { layout: 'centered', diff --git a/packages/component-library/src/InitialFocus.stories.tsx b/packages/component-library/src/InitialFocus.stories.tsx new file mode 100644 index 0000000000..b92008e14d --- /dev/null +++ b/packages/component-library/src/InitialFocus.stories.tsx @@ -0,0 +1,86 @@ +import { type Ref } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { InitialFocus } from './InitialFocus'; +import { Input } from './Input'; +import { View } from './View'; + +const meta = { + title: 'Components/InitialFocus', + component: InitialFocus, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WithInput: Story = { + args: { + children: , + }, + render: args => ( + + + + ), + parameters: { + docs: { + description: { + story: + 'InitialFocus automatically focuses its child element when the component mounts. The input will receive focus and have its text selected.', + }, + }, + }, +}; + +export const WithFunctionChild: Story = { + args: { + children: , + }, + render: () => ( + + + {ref => ( + } + placeholder="Focused via function child" + /> + )} + + + ), + parameters: { + docs: { + description: { + story: + 'InitialFocus can accept a function as child for components that need custom ref handling.', + }, + }, + }, +}; + +export const MultipleInputsOnlyFirstFocused: Story = { + args: { + children: , + }, + render: args => ( + + + + + + ), + parameters: { + docs: { + description: { + story: + 'When multiple inputs are present, only the one wrapped in InitialFocus will receive initial focus.', + }, + }, + }, +}; diff --git a/packages/component-library/src/InlineField.stories.tsx b/packages/component-library/src/InlineField.stories.tsx new file mode 100644 index 0000000000..5fee031416 --- /dev/null +++ b/packages/component-library/src/InlineField.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { InlineField } from './InlineField'; +import { Input } from './Input'; +import { View } from './View'; + +const meta = { + title: 'Components/InlineField', + component: InlineField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'Name', + width: 300, + children: , + }, + parameters: { + docs: { + description: { + story: + 'InlineField displays a label and input side-by-side in a horizontal layout.', + }, + }, + }, +}; + +export const WithCustomLabelWidth: Story = { + args: { + label: 'Email Address', + labelWidth: 120, + width: 400, + children: , + }, + parameters: { + docs: { + description: { + story: + 'Custom label width can be specified to accommodate longer labels.', + }, + }, + }, +}; + +export const MultipleFields: Story = { + args: { + label: 'First Name', + width: 300, + }, + render: args => ( + + + + + + + + + + + + ), + parameters: { + docs: { + description: { + story: + 'Multiple InlineFields stack vertically with consistent label alignment.', + }, + }, + }, +}; + +export const WithPercentageWidth: Story = { + args: { + label: 'Description', + width: '100%', + children: , + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'Width can be specified as a percentage for responsive layouts.', + }, + }, + }, +}; diff --git a/packages/component-library/src/Input.stories.tsx b/packages/component-library/src/Input.stories.tsx new file mode 100644 index 0000000000..fa6f673cea --- /dev/null +++ b/packages/component-library/src/Input.stories.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Input } from './Input'; +import { View } from './View'; + +const meta = { + title: 'Components/Input', + component: Input, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Enter text...', + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'A basic input field with placeholder text.', + }, + }, + }, +}; + +export const WithValue: Story = { + args: { + defaultValue: 'Hello World', + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'Input with a pre-filled value.', + }, + }, + }, +}; + +export const Disabled: Story = { + args: { + defaultValue: 'Disabled input', + disabled: true, + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: + 'Disabled inputs prevent user interaction and display muted text.', + }, + }, + }, +}; + +export const WithOnEnter: Story = { + render: function Render() { + const [submittedValue, setSubmittedValue] = useState(''); + + return ( + + setSubmittedValue(value)} + /> + {submittedValue && Submitted: {submittedValue}} + + ); + }, + parameters: { + docs: { + description: { + story: 'The onEnter callback is triggered when the user presses Enter.', + }, + }, + }, +}; + +export const WithOnEscape: Story = { + render: function Render() { + const [escaped, setEscaped] = useState(false); + + return ( + + setEscaped(true)} + /> + {escaped && Escape pressed!} + + ); + }, + parameters: { + docs: { + description: { + story: + 'The onEscape callback is triggered when the user presses Escape.', + }, + }, + }, +}; + +export const WithOnChangeValue: Story = { + render: function Render() { + const [value, setValue] = useState(''); + + return ( + + setValue(newValue)} + /> + Current value: {value} + + ); + }, + parameters: { + docs: { + description: { + story: + 'The onChangeValue callback provides the new value on each keystroke.', + }, + }, + }, +}; + +export const NumberInput: Story = { + args: { + type: 'number', + placeholder: '0', + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'Input configured for numeric values.', + }, + }, + }, +}; + +export const PasswordInput: Story = { + args: { + type: 'password', + placeholder: 'Enter password', + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'Password input masks the entered text.', + }, + }, + }, +}; diff --git a/packages/component-library/src/Label.stories.tsx b/packages/component-library/src/Label.stories.tsx new file mode 100644 index 0000000000..a14df89b66 --- /dev/null +++ b/packages/component-library/src/Label.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Input } from './Input'; +import { Label } from './Label'; +import { View } from './View'; + +const meta = { + title: 'Components/Label', + component: Label, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Username', + }, + parameters: { + docs: { + description: { + story: 'A basic label component for form fields.', + }, + }, + }, +}; + +export const WithInput: Story = { + args: { + title: 'Email Address', + }, + render: args => ( + + + ), + parameters: { + docs: { + description: { + story: 'Label used with an input field in a vertical layout.', + }, + }, + }, +}; + +export const MultipleLabels: Story = { + args: { + title: 'First Name', + }, + render: args => ( + + + + + + + + + ), + parameters: { + docs: { + description: { + story: 'Multiple labels and inputs in a form layout.', + }, + }, + }, +}; + +export const CustomStyle: Story = { + args: { + title: 'Custom Styled Label', + style: { + fontSize: 16, + color: '#007bff', + textAlign: 'left', + }, + }, + parameters: { + docs: { + description: { + story: 'Label with custom styling applied.', + }, + }, + }, +}; diff --git a/packages/component-library/src/Menu.stories.tsx b/packages/component-library/src/Menu.stories.tsx new file mode 100644 index 0000000000..491aff0dcd --- /dev/null +++ b/packages/component-library/src/Menu.stories.tsx @@ -0,0 +1,243 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SvgAdd, SvgTrash } from './icons/v1'; +import { SvgPencil1 } from './icons/v2'; +import { Menu, type MenuItem } from './Menu'; +import { Text } from './Text'; +import { View } from './View'; + +const meta = { + title: 'Components/Menu', + component: Menu, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const basicItems: Array> = [ + { name: 'edit', text: 'Edit' }, + { name: 'duplicate', text: 'Duplicate' }, + { name: 'delete', text: 'Delete' }, +]; + +export const Default: Story = { + args: { + items: basicItems, + }, + parameters: { + docs: { + description: { + story: 'A basic menu with simple text items.', + }, + }, + }, +}; + +export const WithIcons: Story = { + args: { + items: [ + { name: 'add', text: 'Add New', icon: SvgAdd }, + { name: 'edit', text: 'Edit', icon: SvgPencil1 }, + { name: 'delete', text: 'Delete', icon: SvgTrash }, + ], + }, + parameters: { + docs: { + description: { + story: 'Menu items can include icons for visual clarity.', + }, + }, + }, +}; + +export const WithSeparator: Story = { + args: { + items: [ + { name: 'cut', text: 'Cut' }, + { name: 'copy', text: 'Copy' }, + { name: 'paste', text: 'Paste' }, + Menu.line, + { name: 'delete', text: 'Delete' }, + ], + }, + parameters: { + docs: { + description: { + story: 'Menu.line creates a visual separator between menu sections.', + }, + }, + }, +}; + +export const WithLabel: Story = { + args: { + items: [ + { type: Menu.label, name: 'Actions', text: 'Actions' }, + { name: 'edit', text: 'Edit' }, + { name: 'duplicate', text: 'Duplicate' }, + Menu.line, + { type: Menu.label, name: 'Danger Zone', text: 'Danger Zone' }, + { name: 'delete', text: 'Delete' }, + ], + }, + parameters: { + docs: { + description: { + story: 'Menu.label items create section headers within the menu.', + }, + }, + }, +}; + +export const WithDisabledItems: Story = { + args: { + items: [ + { name: 'edit', text: 'Edit' }, + { name: 'duplicate', text: 'Duplicate', disabled: true }, + { name: 'delete', text: 'Delete' }, + ], + }, + parameters: { + docs: { + description: { + story: 'Disabled menu items are visually muted and non-interactive.', + }, + }, + }, +}; + +export const WithKeyboardShortcuts: Story = { + args: { + items: [ + { name: 'cut', text: 'Cut', key: 'ctrl + X' }, + { name: 'copy', text: 'Copy', key: 'ctrl + C' }, + { name: 'paste', text: 'Paste', key: 'ctrl + V' }, + ], + }, + parameters: { + docs: { + description: { + story: 'Menu items can display keyboard shortcuts.', + }, + }, + }, +}; + +export const WithToggle: Story = { + args: { + items: [], + }, + render: function Render() { + const [settings, setSettings] = useState({ + notifications: true, + darkMode: false, + autoSave: true, + }); + + const items: Array> = [ + { + name: 'notifications', + text: 'Notifications', + toggle: settings.notifications, + }, + { name: 'darkMode', text: 'Dark Mode', toggle: settings.darkMode }, + { name: 'autoSave', text: 'Auto Save', toggle: settings.autoSave }, + ]; + + return ( + { + setSettings(prev => ({ ...prev, [name]: !prev[name] })); + }} + /> + ); + }, + parameters: { + docs: { + description: { + story: 'Menu items can include toggles for boolean settings.', + }, + }, + }, +}; + +export const WithHeaderAndFooter: Story = { + args: { + header: ( + + Menu Title + + ), + footer: ( + + 3 items + + ), + items: basicItems, + }, + parameters: { + docs: { + description: { + story: 'Menus can have custom header and footer content.', + }, + }, + }, +}; + +export const WithTooltips: Story = { + args: { + items: [ + { name: 'edit', text: 'Edit', tooltip: 'Modify this item' }, + { + name: 'duplicate', + text: 'Duplicate', + tooltip: 'Create a copy of this item', + }, + { + name: 'delete', + text: 'Delete', + tooltip: 'Permanently remove this item', + }, + ], + }, + parameters: { + docs: { + description: { + story: 'Menu items can have tooltips for additional context.', + }, + }, + }, +}; + +export const InteractiveExample: Story = { + args: { + items: basicItems, + }, + render: function Render(args) { + const [selected, setSelected] = useState(null); + + return ( + + setSelected(String(name))} /> + {selected && ( + Selected: {selected} + )} + + ); + }, + parameters: { + docs: { + description: { + story: 'Interactive menu that shows the selected item.', + }, + }, + }, +}; diff --git a/packages/component-library/src/Paragraph.stories.tsx b/packages/component-library/src/Paragraph.stories.tsx new file mode 100644 index 0000000000..66fe3b702b --- /dev/null +++ b/packages/component-library/src/Paragraph.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Paragraph } from './Paragraph'; +import { View } from './View'; + +const meta = { + title: 'Components/Paragraph', + component: Paragraph, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: + 'This is a paragraph of text. Paragraphs are used to display blocks of text content with proper line height and spacing.', + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'A basic paragraph with default styling and bottom margin.', + }, + }, + }, +}; + +export const MultipleParagraphs: Story = { + render: () => ( + + + This is the first paragraph. It has a bottom margin to create spacing + between itself and the next paragraph. + + + This is the second paragraph. Notice the consistent spacing between + paragraphs which improves readability. + + + This is the last paragraph. It uses the isLast prop to remove the bottom + margin since there is no following content. + + + ), + parameters: { + docs: { + description: { + story: + 'Multiple paragraphs stack with consistent spacing. Use isLast on the final paragraph.', + }, + }, + }, +}; + +export const IsLast: Story = { + args: { + children: 'This paragraph has no bottom margin because isLast is true.', + isLast: true, + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: + 'When isLast is true, the bottom margin is removed. Useful for the last paragraph in a section.', + }, + }, + }, +}; + +export const WithCustomStyle: Story = { + args: { + children: 'This paragraph has custom styling applied.', + style: { + color: '#007bff', + fontStyle: 'italic', + fontSize: 18, + }, + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: 'Custom styles can be applied to paragraphs.', + }, + }, + }, +}; + +export const LongContent: Story = { + args: { + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.', + }, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + docs: { + description: { + story: + 'Longer paragraphs wrap properly and maintain consistent line height for readability.', + }, + }, + }, +}; diff --git a/packages/component-library/src/Themes/Theming.mdx b/packages/component-library/src/Themes/Theming.mdx new file mode 100644 index 0000000000..5195f29e64 --- /dev/null +++ b/packages/component-library/src/Themes/Theming.mdx @@ -0,0 +1,11 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Theming + +Actual Budget supports customizable themes that allow you to personalize the look and feel of the application. You can switch between built-in themes or create your own custom themes. + +For detailed information on how to create and apply custom themes, please visit the official documentation: + +**[Custom Themes Documentation](https://actualbudget.org/docs/experimental/custom-themes)** diff --git a/upcoming-release-notes/6924.md b/upcoming-release-notes/6924.md new file mode 100644 index 0000000000..87f3006287 --- /dev/null +++ b/upcoming-release-notes/6924.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MikesGlitch] +--- + +Styling updates to the design system, reorganisation and additional component docs