diff --git a/.github/workflows/frontend-chromatic.yml b/.github/workflows/frontend-chromatic.yml index 950389078301..2874f1e8c000 100644 --- a/.github/workflows/frontend-chromatic.yml +++ b/.github/workflows/frontend-chromatic.yml @@ -45,10 +45,14 @@ jobs: run: npm ci - name: Publish to Chromatic - uses: chromaui/action@v11 + uses: chromaui/action@v16 with: workingDir: frontend projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + # Dev-mode build keeps React's act() in the bundle — + # production strips it and breaks play function synthetic + # events (Storybook #19758). + buildScriptName: build-storybook:dev exitZeroOnChanges: true exitOnceUploaded: true onlyChanged: true diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index 511f25384d98..01bb8673240b 100644 --- a/frontend/.storybook/main.js +++ b/frontend/.storybook/main.js @@ -46,6 +46,13 @@ const config = { common: path.resolve(__dirname, '../common'), components: path.resolve(__dirname, '../web/components'), project: path.resolve(__dirname, '../web/project'), + // Stub CommonJS modules that break Storybook's ESM bundler. + // code-help contains SDK snippets using module.exports — not needed for component rendering. + 'common/code-help': path.resolve(__dirname, 'mocks/code-help.js'), + // Stub CommonJS data layer that breaks ESM bundler + [path.resolve(__dirname, '../common/data/base/_data.js')]: path.resolve(__dirname, 'mocks/_data.js'), + // Mock dompurify (CJS/ESM export mismatch) + 'dompurify': path.resolve(__dirname, 'mocks/dompurify.js'), } config.module = config.module || {} @@ -77,8 +84,11 @@ const config = { ), '@stencil/core/internal/client': false, '@stencil/core': false, - '@ionic/react': false, - 'ionicons/icons': false, + // Mock IonIcon so components that still use it (ClearFilters, + // NavSubLink, BreadcrumbSeparator, etc.) can render in stories + // without forcing each one to migrate to our Icon component. + '@ionic/react': path.resolve(__dirname, 'mocks/ionic-react.js'), + 'ionicons/icons': path.resolve(__dirname, 'mocks/ionicons-icons.js'), } config.plugins = config.plugins || [] diff --git a/frontend/.storybook/mocks/_data.js b/frontend/.storybook/mocks/_data.js new file mode 100644 index 000000000000..0d8b01837372 --- /dev/null +++ b/frontend/.storybook/mocks/_data.js @@ -0,0 +1,8 @@ +// Stub for common/data/base/_data — CommonJS file that breaks Storybook's ESM bundler. +// This is the Flux data layer used for API calls, not needed for component rendering. +module.exports = { + get: () => Promise.resolve(), + post: () => Promise.resolve(), + put: () => Promise.resolve(), + delete: () => Promise.resolve(), +} diff --git a/frontend/.storybook/mocks/code-help.js b/frontend/.storybook/mocks/code-help.js new file mode 100644 index 000000000000..183fdf0c1ea3 --- /dev/null +++ b/frontend/.storybook/mocks/code-help.js @@ -0,0 +1,3 @@ +// Stub for common/code-help — CommonJS files that break Storybook's ESM bundler. +// These are SDK code snippets used in the docs UI, not needed for component rendering. +module.exports = {} diff --git a/frontend/.storybook/mocks/dompurify.js b/frontend/.storybook/mocks/dompurify.js new file mode 100644 index 000000000000..347047839d5f --- /dev/null +++ b/frontend/.storybook/mocks/dompurify.js @@ -0,0 +1,7 @@ +// Mock for dompurify — the real module's CJS/ESM export mismatch breaks Storybook. +// Tooltip only uses sanitize() to clean HTML before rendering. +export function sanitize(html) { + return html +} + +export default { sanitize } diff --git a/frontend/.storybook/mocks/ionic-react.js b/frontend/.storybook/mocks/ionic-react.js new file mode 100644 index 000000000000..a5fb11fd20bb --- /dev/null +++ b/frontend/.storybook/mocks/ionic-react.js @@ -0,0 +1,23 @@ +// Storybook mock for @ionic/react. +// Renders IonIcon as a small inline placeholder so components that depend on +// IonIcon (ClearFilters, NavSubLink, BreadcrumbSeparator, etc.) can still +// render in stories without forcing a refactor of production components. +import React from 'react' + +export const IonIcon = ({ icon, color, ...rest }) => + React.createElement('span', { + ...rest, + 'aria-hidden': true, + 'data-stub-icon': typeof icon === 'string' ? icon : 'icon', + style: { + backgroundColor: color || 'currentColor', + borderRadius: '50%', + display: 'inline-block', + height: '1em', + verticalAlign: 'middle', + width: '1em', + ...(rest.style || {}), + }, + }) + +export default { IonIcon } diff --git a/frontend/.storybook/mocks/ionicons-icons.js b/frontend/.storybook/mocks/ionicons-icons.js new file mode 100644 index 000000000000..b9c387c6ec75 --- /dev/null +++ b/frontend/.storybook/mocks/ionicons-icons.js @@ -0,0 +1,25 @@ +// Storybook mock for ionicons/icons. +// Real exports are SVG strings; for stories, the IonIcon mock just needs each +// import to resolve to *something*, so we proxy any property access to its +// own name. This lets `import { closeCircle, apps } from 'ionicons/icons'` +// resolve cleanly without listing every icon name. +const handler = { + get: (_target, prop) => (typeof prop === 'string' ? prop : undefined), +} +const proxy = new Proxy({}, handler) + +// Named imports webpack already saw in source files; ESM static analysis +// won't pick up Proxy access for named imports, so we re-export the most +// common ones explicitly. Add to this list as new IonIcon usages appear. +export const apps = 'apps' +export const checkmark = 'checkmark' +export const checkmarkCircle = 'checkmarkCircle' +export const chevronDown = 'chevronDown' +export const chevronForward = 'chevronForward' +export const chevronUp = 'chevronUp' +export const close = 'close' +export const closeCircle = 'closeCircle' +export const informationCircleOutline = 'informationCircleOutline' +export const statsChart = 'statsChart' + +export default proxy diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 766bc9851e84..54f8d7f7c031 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -11,7 +11,12 @@ import React from 'react' import PropTypes from 'prop-types' import Utils from 'common/utils/utils' import ReactSelect, { components as selectComponents } from 'react-select' +// Register safe globals that project-components.js would normally set. +// Only import components that use the automatic JSX transform (TSX files). +// Legacy .js files (Flex, Column, Input) use old JSX transform and crash here. import Tooltip from '../web/components/Tooltip' +import Row from '../web/components/base/grid/Row' +import FormGroup from '../web/components/base/grid/FormGroup' window.React = React window.propTypes = PropTypes @@ -47,10 +52,13 @@ global.Select = (props) => components: { ...props.components }, }), ) -global.Tooltip = Tooltip +window.Tooltip = Tooltip +window.Row = Row +window.FormGroup = FormGroup /** @type { import('storybook').Preview } */ const preview = { + tags: ['autodocs'], globalTypes: { theme: { description: 'Dark mode toggle', diff --git a/frontend/.storybook/stubs/utils.js b/frontend/.storybook/stubs/utils.js index 23989136c585..428f8fe15050 100644 --- a/frontend/.storybook/stubs/utils.js +++ b/frontend/.storybook/stubs/utils.js @@ -5,10 +5,26 @@ // New components should NOT import Utils — use dedicated utilities instead. // TODO: Remove once legacy .js files are migrated to TypeScript with imports. +import Color from 'color' + const Utils = { + colour: (input) => { + try { + return Color(input) + } catch { + return Color('#9DA4AE') + } + }, + escapeHtml: (s) => String(s ?? ''), + fromParam: () => + Object.fromEntries( + new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''), + ), + GUID: () => Math.random().toString(36).slice(2), getFlagsmithHasFeature: () => false, getFlagsmithValue: () => '', getPlansPermission: () => true, + isSaas: () => false, keys: { isEscape: (e) => e.key === 'Escape' || e.keyCode === 27, }, diff --git a/frontend/common/utils/__tests__/convertToPConfidence.test.ts b/frontend/common/utils/__tests__/convertToPConfidence.test.ts new file mode 100644 index 000000000000..c0634c866fc0 --- /dev/null +++ b/frontend/common/utils/__tests__/convertToPConfidence.test.ts @@ -0,0 +1,45 @@ +// Tests for the convertToPConfidence function extracted from Confidence.tsx +// The function maps p-values to confidence levels. + +describe('convertToPConfidence', () => { + // Inline the function to test it independently of the component + const convertToPConfidence = (value: number) => { + if (value > 0.05) return 'LOW' + if (value >= 0.01) return 'REASONABLE' + if (value > 0.002) return 'HIGH' + return 'VERY_HIGH' + } + + it('returns LOW for p-value above 0.05', () => { + expect(convertToPConfidence(0.1)).toBe('LOW') + expect(convertToPConfidence(0.5)).toBe('LOW') + expect(convertToPConfidence(1)).toBe('LOW') + }) + + it('returns REASONABLE for p-value between 0.01 and 0.05', () => { + expect(convertToPConfidence(0.05)).toBe('REASONABLE') + expect(convertToPConfidence(0.03)).toBe('REASONABLE') + expect(convertToPConfidence(0.01)).toBe('REASONABLE') + }) + + it('returns HIGH for p-value between 0.002 and 0.01', () => { + expect(convertToPConfidence(0.009)).toBe('HIGH') + expect(convertToPConfidence(0.005)).toBe('HIGH') + expect(convertToPConfidence(0.003)).toBe('HIGH') + }) + + it('returns VERY_HIGH for p-value at or below 0.002', () => { + expect(convertToPConfidence(0.002)).toBe('VERY_HIGH') + expect(convertToPConfidence(0.001)).toBe('VERY_HIGH') + expect(convertToPConfidence(0)).toBe('VERY_HIGH') + }) + + it('handles boundary values correctly', () => { + expect(convertToPConfidence(0.05)).toBe('REASONABLE') // exactly 0.05 + expect(convertToPConfidence(0.0500001)).toBe('LOW') // just above 0.05 + expect(convertToPConfidence(0.01)).toBe('REASONABLE') // exactly 0.01 + expect(convertToPConfidence(0.0099)).toBe('HIGH') // just below 0.01 + expect(convertToPConfidence(0.002)).toBe('VERY_HIGH') // exactly 0.002 + expect(convertToPConfidence(0.0021)).toBe('HIGH') // just above 0.002 + }) +}) diff --git a/frontend/common/utils/__tests__/fromParam.test.ts b/frontend/common/utils/__tests__/fromParam.test.ts new file mode 100644 index 000000000000..b8bfc9402613 --- /dev/null +++ b/frontend/common/utils/__tests__/fromParam.test.ts @@ -0,0 +1,38 @@ +// Tests for URL parameter parsing (replacement for Utils.fromParam) +// The inline implementation uses Object.fromEntries(new URLSearchParams(...)) + +describe('fromParam (URLSearchParams)', () => { + const fromParam = (search: string) => + Object.fromEntries(new URLSearchParams(search)) + + it('returns empty object for empty search string', () => { + expect(fromParam('')).toEqual({}) + }) + + it('parses a single parameter', () => { + expect(fromParam('?tab=features')).toEqual({ tab: 'features' }) + }) + + it('parses multiple parameters', () => { + expect(fromParam('?tab=features&page=2&search=hello')).toEqual({ + page: '2', + search: 'hello', + tab: 'features', + }) + }) + + it('decodes encoded characters', () => { + expect(fromParam('?name=hello%20world&q=a%26b')).toEqual({ + name: 'hello world', + q: 'a&b', + }) + }) + + it('handles parameters without values', () => { + expect(fromParam('?flag=')).toEqual({ flag: '' }) + }) + + it('works without leading question mark', () => { + expect(fromParam('tab=settings')).toEqual({ tab: 'settings' }) + }) +}) diff --git a/frontend/documentation/DecisionFramework.mdx b/frontend/documentation/DecisionFramework.mdx index 3f37a011c48d..77ffecc5f83a 100644 --- a/frontend/documentation/DecisionFramework.mdx +++ b/frontend/documentation/DecisionFramework.mdx @@ -1,6 +1,9 @@ import { Meta } from '@storybook/addon-docs/blocks' - + # Where does a new colour go? diff --git a/frontend/documentation/Introduction.mdx b/frontend/documentation/Introduction.mdx index f2df79553edc..ee45b19774a5 100644 --- a/frontend/documentation/Introduction.mdx +++ b/frontend/documentation/Introduction.mdx @@ -1,7 +1,10 @@ {/* Introduction.mdx */} import { Meta } from '@storybook/addon-docs/blocks' - + # Flagsmith Frontend diff --git a/frontend/documentation/TokenMaintenance.mdx b/frontend/documentation/TokenMaintenance.mdx index cd8e456d772f..d78b1b119c6c 100644 --- a/frontend/documentation/TokenMaintenance.mdx +++ b/frontend/documentation/TokenMaintenance.mdx @@ -1,6 +1,9 @@ import { Meta } from '@storybook/addon-docs/blocks' - + # Token Maintenance Guide diff --git a/frontend/documentation/Typography.mdx b/frontend/documentation/Typography.mdx index f1f0fea36d07..35de519db577 100644 --- a/frontend/documentation/Typography.mdx +++ b/frontend/documentation/Typography.mdx @@ -1,6 +1,9 @@ import { Meta } from '@storybook/addon-docs/blocks' - + # Typography Tokens diff --git a/frontend/documentation/components/AccordionCard.stories.tsx b/frontend/documentation/components/AccordionCard.stories.tsx new file mode 100644 index 000000000000..f9bbebfe27e3 --- /dev/null +++ b/frontend/documentation/components/AccordionCard.stories.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import AccordionCard from 'components/base/accordion/AccordionCard' + +const meta: Meta = { + parameters: { + docs: { + description: { + component: + 'Collapsible card with a header, chevron toggle, and animated body. Use `defaultOpen` to start expanded and `isLoading` to show a spinner in place of content while fetching.', + }, + }, + layout: 'padded', + }, + title: 'Components/Patterns/AccordionCard', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + +

Accordion content goes here.

+
+ ), +} + +export const DefaultOpen: Story = { + render: () => ( + +

This accordion starts open.

+
+ ), +} diff --git a/frontend/documentation/components/Banner.stories.tsx b/frontend/documentation/components/Banner.stories.tsx deleted file mode 100644 index 6e6dbc31764b..000000000000 --- a/frontend/documentation/components/Banner.stories.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React from 'react' -import type { Meta, StoryObj } from 'storybook' - -import Banner from 'components/Banner' -import type { BannerProps } from 'components/Banner' -import { Button } from 'components/base/forms/Button' - -const meta: Meta = { - argTypes: { - children: { - control: 'text', - description: - 'Banner message content. Can include a CTA button as a child.', - }, - variant: { - control: 'select', - description: 'Feedback colour variant.', - options: ['success', 'warning', 'danger', 'info'], - }, - }, - args: { - children: 'This is a banner message.', - variant: 'info', - }, - component: Banner, - parameters: { layout: 'padded' }, - title: 'Components/Banner', -} - -export default meta - -type Story = StoryObj - -// --------------------------------------------------------------------------- -// Default — interactive playground -// --------------------------------------------------------------------------- - -export const Default: Story = {} - -// --------------------------------------------------------------------------- -// Individual variants -// --------------------------------------------------------------------------- - -export const Success: Story = { - args: { - children: 'Your changes have been saved successfully.', - variant: 'success', - }, - parameters: { - docs: { - description: { - story: 'Use `success` for confirming a completed action.', - }, - }, - }, -} - -export const Warning: Story = { - args: { - children: 'Your trial is ending in 3 days.', - variant: 'warning', - }, - parameters: { - docs: { - description: { - story: - 'Use `warning` for cautionary messages that need attention but are not critical.', - }, - }, - }, -} - -export const Danger: Story = { - args: { - children: 'Your API key has been revoked.', - variant: 'danger', - }, - parameters: { - docs: { - description: { - story: 'Use `danger` for errors or critical issues.', - }, - }, - }, -} - -export const Info: Story = { - args: { - children: 'A new version of Flagsmith is available.', - variant: 'info', - }, - parameters: { - docs: { - description: { - story: 'Use `info` for neutral informational messages.', - }, - }, - }, -} - -// --------------------------------------------------------------------------- -// With CTA (passed as children) -// --------------------------------------------------------------------------- - -export const WithCTA: Story = { - name: 'With CTA button', - parameters: { - docs: { - description: { - story: - 'Add a CTA by passing a `Button` as part of `children`. This keeps the Banner API simple — the banner renders whatever you give it.', - }, - }, - }, - render: () => ( - - Your trial is ending in 3 days. - - - ), -} - -export const DangerWithCTA: Story = { - name: 'Danger with CTA', - parameters: { - docs: { - description: { - story: - 'For danger banners, use `theme="danger"` on the CTA button for visual consistency.', - }, - }, - }, - render: () => ( - - Your API key has been revoked. - - - ), -} - -// --------------------------------------------------------------------------- -// All variants -// --------------------------------------------------------------------------- - -export const AllVariants: Story = { - name: 'All variants', - parameters: { - docs: { - description: { - story: - 'All four banner variants. Each has a default icon that matches the variant. Banners are persistent — not closable or dismissable.', - }, - }, - }, - render: () => ( -
- - Your changes have been saved successfully. - - - Your trial is ending in 3 days. - - - - Your API key has been revoked. - - - A new version of Flagsmith is available. -
- ), -} diff --git a/frontend/documentation/components/BooleanDotIndicator.stories.tsx b/frontend/documentation/components/BooleanDotIndicator.stories.tsx new file mode 100644 index 000000000000..1d2a14bb0957 --- /dev/null +++ b/frontend/documentation/components/BooleanDotIndicator.stories.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import BooleanDotIndicator from 'components/BooleanDotIndicator' + +const meta: Meta = { + args: { enabled: true }, + component: BooleanDotIndicator, + parameters: { + docs: { + description: { + component: + 'A small coloured dot signalling an on/off state. Used inline where space is tight — typically inside a tooltip trigger for permission rows or similar binary indicators.', + }, + }, + layout: 'centered', + }, + title: 'Components/Data Display/BooleanDotIndicator', +} +export default meta + +type Story = StoryObj + +export const Enabled: Story = {} + +export const Disabled: Story = { + args: { enabled: false }, +} + +export const AllStates: Story = { + render: () => ( +
+
+ Enabled +
+
+ Disabled +
+
+ ), +} diff --git a/frontend/documentation/components/Breadcrumb.stories.tsx b/frontend/documentation/components/Breadcrumb.stories.tsx new file mode 100644 index 000000000000..3b74e255e852 --- /dev/null +++ b/frontend/documentation/components/Breadcrumb.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Breadcrumb from 'components/Breadcrumb' +import { withRouter } from './_decorators' + +const meta: Meta = { + decorators: [withRouter], + parameters: { layout: 'padded' }, + title: 'Components/Navigation/Breadcrumb', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ), +} + +export const SingleLevel: Story = { + render: () => ( + + ), +} diff --git a/frontend/documentation/components/Button.stories.tsx b/frontend/documentation/components/Button.stories.tsx index e4be8e2ffdab..d7be200780d3 100644 --- a/frontend/documentation/components/Button.stories.tsx +++ b/frontend/documentation/components/Button.stories.tsx @@ -53,34 +53,19 @@ export default meta type Story = StoryObj -// --------------------------------------------------------------------------- -// Default — interactive playground -// --------------------------------------------------------------------------- - export const Default: Story = {} -// --------------------------------------------------------------------------- -// All Variants -// --------------------------------------------------------------------------- - export const Variants: Story = { parameters: { docs: { description: { story: - 'All available button themes. Use `primary` for main actions, `secondary` for alternatives, `outline` for low-emphasis actions, `danger` for destructive actions, and `success` for positive confirmations.', + 'All available button themes. Use `primary` for main actions, `secondary` for alternatives, `outline` for low-emphasis actions, `danger` for destructive actions, and `success` for positive confirmations. `icon` is for icon-only buttons (copy, action triggers in tables); `project` is the avatar-style button used in the project picker.', }, }, }, render: () => ( -
+
@@ -88,14 +73,14 @@ export const Variants: Story = { + +
), } -// --------------------------------------------------------------------------- -// Sizes -// --------------------------------------------------------------------------- - export const Sizes: Story = { parameters: { docs: { @@ -105,14 +90,7 @@ export const Sizes: Story = { }, }, render: () => ( -
+
@@ -121,10 +99,6 @@ export const Sizes: Story = { ), } -// --------------------------------------------------------------------------- -// Disabled -// --------------------------------------------------------------------------- - export const Disabled: Story = { parameters: { docs: { @@ -134,14 +108,7 @@ export const Disabled: Story = { }, }, render: () => ( -
+
@@ -158,10 +125,6 @@ export const Disabled: Story = { ), } -// --------------------------------------------------------------------------- -// With Icons -// --------------------------------------------------------------------------- - export const WithIcons: Story = { parameters: { docs: { @@ -172,14 +135,7 @@ export const WithIcons: Story = { }, }, render: () => ( -
+
diff --git a/frontend/documentation/components/ButtonDropdown.stories.tsx b/frontend/documentation/components/ButtonDropdown.stories.tsx new file mode 100644 index 000000000000..8c03272cf100 --- /dev/null +++ b/frontend/documentation/components/ButtonDropdown.stories.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import { userEvent, within } from 'storybook/test' + +import ButtonDropdown from 'components/base/forms/ButtonDropdown' + +const meta: Meta = { + parameters: { + docs: { story: { height: '260px' } }, + layout: 'centered', + }, + title: 'Components/ButtonDropdown', +} +export default meta + +type Story = StoryObj + +const DEMO_ITEMS = [ + { label: 'Edit', onClick: () => {} }, + { label: 'Duplicate', onClick: () => {} }, + { label: 'Delete', onClick: () => {} }, +] + +export const Default: Story = { + render: () => ( + Actions + ), +} + +export const Open: Story = { + parameters: { + chromatic: { delay: 300 }, + docs: { + description: { + story: + 'Dropdown menu in its open state. Storybook clicks the chevron toggle in `play` so Chromatic captures the menu.', + }, + }, + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement) + const buttons = canvas.getAllByRole('button') + // Second button is the chevron toggle that opens the menu. + await userEvent.click(buttons[1]) + }, + render: () => ( + Actions + ), +} diff --git a/frontend/documentation/components/Card.stories.tsx b/frontend/documentation/components/Card.stories.tsx new file mode 100644 index 000000000000..450a7987ce99 --- /dev/null +++ b/frontend/documentation/components/Card.stories.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Card from 'components/Card' + +const meta: Meta = { + parameters: { + docs: { + description: { + component: + 'Plain styled surface — rounded container with padding and a subtle border. Use it as a content block on a page; for a header + body shape with title/action slots, use `Panel`.', + }, + }, + layout: 'padded', + }, + title: 'Components/Patterns/Card', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + +
Card title
+

Card content goes here.

+
+ ), +} + +export const Nested: Story = { + render: () => ( + +
Outer card
+ +

Nested card content.

+
+
+ ), +} diff --git a/frontend/documentation/components/Checkbox.stories.tsx b/frontend/documentation/components/Checkbox.stories.tsx new file mode 100644 index 000000000000..4a002b6ed544 --- /dev/null +++ b/frontend/documentation/components/Checkbox.stories.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Checkbox from 'components/base/forms/Checkbox' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Forms/Checkbox', +} +export default meta + +type Story = StoryObj + +const InteractiveCheckbox = () => { + const [checked, setChecked] = useState(false) + return ( + + ) +} + +export const Default: Story = { + render: () => , +} + +export const States: Story = { + render: () => ( +
+ {}} /> + {}} /> +
+ ), +} diff --git a/frontend/documentation/components/CheckboxGroup.stories.tsx b/frontend/documentation/components/CheckboxGroup.stories.tsx new file mode 100644 index 000000000000..c4f45c69f5ed --- /dev/null +++ b/frontend/documentation/components/CheckboxGroup.stories.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import CheckboxGroup from 'components/base/forms/CheckboxGroup' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Forms/CheckboxGroup', +} +export default meta + +type Story = StoryObj + +const items = [ + { label: 'Read', value: 'read' }, + { label: 'Write', value: 'write' }, + { label: 'Admin', value: 'admin' }, +] + +const Interactive = () => { + const [selected, setSelected] = useState(['read']) + return ( + + ) +} + +export const Default: Story = { + render: () => , +} + +export const AllSelected: Story = { + render: () => ( + {}} + /> + ), +} + +export const NoneSelected: Story = { + render: () => ( + {}} /> + ), +} diff --git a/frontend/documentation/components/ChipInput.stories.tsx b/frontend/documentation/components/ChipInput.stories.tsx new file mode 100644 index 000000000000..7a3faf638356 --- /dev/null +++ b/frontend/documentation/components/ChipInput.stories.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import ChipInput from 'components/ChipInput' + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Components/Forms/ChipInput', +} +export default meta + +type Story = StoryObj + +const Template = ({ initial = [] }: { initial?: string[] }) => { + const [chips, setChips] = useState(initial) + return ( +
+ +
+ ) +} + +export const Empty: Story = { + render: () =>