diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f091389e364..fa85c55332e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: retention-days: 7 chromatic: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest needs: build steps: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d71cb718655..629542d57a0 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -11,7 +11,7 @@ on: default: "false" type: boolean package: - description: "Package to publish (charts, colours, or all)" + description: "Package to publish (charts, colours, components, or all)" required: false default: "all" type: choice @@ -19,6 +19,7 @@ on: - all - charts - colours + - components permissions: id-token: write @@ -66,3 +67,21 @@ jobs: - run: bunx npm publish --access public --provenance ${{ env.DRY_RUN }} working-directory: packages/colours + + publish-components: + if: github.event.inputs.package == 'all' || github.event.inputs.package == 'components' || github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - run: bunx npm publish --access public --provenance ${{ env.DRY_RUN }} + working-directory: packages/components diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 985f1fccd25..f3a0bdfd286 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -7,6 +7,7 @@ import "../packages/components/src/styles/main.scss" const preview: Preview = { decorators: [ChartsDecorator], + tags: ["autodocs"], parameters: { controls: { matchers: { @@ -24,6 +25,12 @@ const preview: Preview = { { name: "dark", value: "#1a1a1a" }, ], }, + docs: { + source: { + type: "code", + }, + toc: true, + }, }, } diff --git a/packages/charts/package.json b/packages/charts/package.json index 0112ce41d37..9dc21a21917 100644 --- a/packages/charts/package.json +++ b/packages/charts/package.json @@ -1,6 +1,6 @@ { "name": "@buildcanada/charts", - "version": "0.1.1", + "version": "0.2.0", "description": "A configurable data visualization library for creating interactive charts.", "type": "module", "main": "src/index.ts", diff --git a/packages/charts/src/components/Button/Button.stories.tsx b/packages/charts/src/components/Button/Button.stories.tsx index 602b66af1d0..1ded1f2716a 100644 --- a/packages/charts/src/components/Button/Button.stories.tsx +++ b/packages/charts/src/components/Button/Button.stories.tsx @@ -6,6 +6,37 @@ import { Button } from "./Button" const meta: Meta = { title: "Components/Button", component: Button, + parameters: { + docs: { + description: { + component: ` +A button component for the charts library with multiple theme variants. + +## Usage + +\`\`\`tsx +import { Button } from "@buildcanada/charts" + + +

+

+ +

+ + ) +} + +// Simple template that renders the dialog with args +function DialogTemplate(args: React.ComponentProps) { + return ( + console.log("Dialog closed")} + > +

+ This dialog doesn't block interaction with the page behind it. +

+
+ ) +} + +export const Default: Story = { + render: (args) => , +} + +export const TopLeft: Story = { + args: { + position: "top-left", + title: "Top Left", + }, + render: (args) => , +} + +export const TopRight: Story = { + args: { + position: "top-right", + title: "Top Right", + }, + render: (args) => , +} + +export const BottomLeft: Story = { + args: { + position: "bottom-left", + title: "Bottom Left", + }, + render: (args) => , +} + +export const BottomRight: Story = { + args: { + position: "bottom-right", + title: "Bottom Right", + }, + render: (args) => , +} + +export const Centered: Story = { + args: { + position: "center", + title: "Centered", + }, + render: (args) => , +} + +export const WithDescription: Story = { + args: { + title: "Notifications", + description: "You have 3 unread messages.", + }, + render: (args) => , +} + +export const CustomOffset: Story = { + args: { + title: "Custom Offset", + offset: 48, + }, + render: (args) => , +} + +export const NoCloseButton: Story = { + args: { + title: "No Close Button", + showCloseButton: false, + description: "Press Escape to close.", + }, + render: (args) => , +} + +export const WideContent: Story = { + args: { + title: "Wide Content", + }, + render: (args) => ( + console.log("Dialog closed")}> +
+

This dialog has wider content and will size accordingly.

+

The dialog always sizes to fit its contents.

+
+
+ ), +} + +export const AllPositions: Story = { + parameters: { + controls: { disable: true }, + }, + render: function AllPositionsDemo() { + const [openDialog, setOpenDialog] = useState(null) + + const positions = [ + { key: "top-left", label: "Top Left" }, + { key: "top-right", label: "Top Right" }, + { key: "bottom-left", label: "Bottom Left" }, + { key: "bottom-right", label: "Bottom Right" }, + { key: "center", label: "Center" }, + ] as const + + return ( + <> +
+ {positions.map(({ key, label }) => ( +
+ {positions.map(({ key, label }) => ( + setOpenDialog(null)} + title={label} + position={key} + > +

+ Positioned: {label} +

+
+ ))} + + ) + }, +} diff --git a/packages/components/src/feedback/Dialog/Dialog.tsx b/packages/components/src/feedback/Dialog/Dialog.tsx new file mode 100644 index 00000000000..5b6ba4f248c --- /dev/null +++ b/packages/components/src/feedback/Dialog/Dialog.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useCallback } from "react" +import cx from "classnames" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faXmark } from "@fortawesome/free-solid-svg-icons" + +export type DialogPosition = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "center" + +export interface DialogProps { + open: boolean + onClose: () => void + children: React.ReactNode + title?: string + description?: string + position?: DialogPosition + className?: string + closeOnEscape?: boolean + showCloseButton?: boolean + ariaLabelledBy?: string + ariaDescribedBy?: string + offset?: number +} + +export function Dialog({ + open, + onClose, + children, + title, + description, + position = "bottom-right", + className, + closeOnEscape = true, + showCloseButton = true, + ariaLabelledBy, + ariaDescribedBy, + offset = 16, +}: DialogProps) { + const dialogRef = useRef(null) + + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + // Handle escape key + useEffect(() => { + if (!closeOnEscape || !open) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleClose() + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [closeOnEscape, open, handleClose]) + + if (!open) return null + + const classes = cx( + "bc-dialog", + `bc-dialog--${position}`, + className + ) + + const titleId = ariaLabelledBy || (title ? "bc-dialog-title" : undefined) + const descriptionId = ariaDescribedBy || (description ? "bc-dialog-description" : undefined) + + const style = { + "--bc-dialog-offset": `${offset}px`, + } as React.CSSProperties + + return ( +
+
+ {(title || showCloseButton) && ( +
+ {title && ( +

+ {title} +

+ )} + {showCloseButton && ( + + )} +
+ )} + {description && ( +

+ {description} +

+ )} +
+ {children} +
+
+
+ ) +} + +export default Dialog diff --git a/packages/components/src/feedback/Dialog/index.ts b/packages/components/src/feedback/Dialog/index.ts new file mode 100644 index 00000000000..b72d7396471 --- /dev/null +++ b/packages/components/src/feedback/Dialog/index.ts @@ -0,0 +1 @@ +export { Dialog, type DialogProps, type DialogPosition } from "./Dialog" diff --git a/packages/components/src/feedback/PopupForm/PopupForm.scss b/packages/components/src/feedback/PopupForm/PopupForm.scss new file mode 100644 index 00000000000..618b8c75cf2 --- /dev/null +++ b/packages/components/src/feedback/PopupForm/PopupForm.scss @@ -0,0 +1,34 @@ +@use "../../styles/tokens" as *; + +/******************************************************************************* + * PopupForm Component + ******************************************************************************/ + +.bc-popup-form { + display: flex; + flex-direction: column; + gap: $space-3; +} + +/******************************************************************************* + * PopupForm Fields + ******************************************************************************/ + +.bc-popup-form__fields { + display: flex; + flex-direction: column; + gap: $space-3; +} + +/******************************************************************************* + * PopupForm Actions + ******************************************************************************/ + +.bc-popup-form__actions { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: $space-2; + padding-top: $space-2; + border-top: 1px solid $border-muted; +} diff --git a/packages/components/src/feedback/PopupForm/PopupForm.stories.tsx b/packages/components/src/feedback/PopupForm/PopupForm.stories.tsx new file mode 100644 index 00000000000..a707b1e62b1 --- /dev/null +++ b/packages/components/src/feedback/PopupForm/PopupForm.stories.tsx @@ -0,0 +1,341 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/react" + +import { PopupForm } from "./PopupForm" +import { Button } from "../../primitives/Button" +import { TextField } from "../../primitives/TextField" +import { Checkbox } from "../../primitives/Checkbox" + +const meta: Meta = { + title: "Components/Feedback/PopupForm", + component: PopupForm, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +A non-modal form panel that composes Dialog with form elements and action buttons. + +## Usage + +\`\`\`tsx +import { PopupForm, TextField, Checkbox } from "@buildcanada/components" + +function MyComponent() { + const [open, setOpen] = useState(false) + + const handleSubmit = (e: React.FormEvent) => { + console.log("Form submitted") + setOpen(false) + } + + return ( + <> + +

+

+ +

+ + ) +} + +// Template that renders the form with args +function PopupFormTemplate(args: React.ComponentProps & { children?: React.ReactNode }) { + return ( + console.log("Form closed")} + onSubmit={() => console.log("Form submitted")} + > + {args.children || ( + <> + + + + )} + + ) +} + +export const Default: Story = { + render: (args) => , +} + +export const WithDescription: Story = { + args: { + title: "Contact Us", + description: "We'd love to hear from you.", + }, + render: (args) => , +} + +export const NewsletterSignup: Story = { + args: { + title: "Subscribe", + description: "Get updates delivered to your inbox.", + submitText: "Subscribe", + }, + render: (args) => ( + + + + + ), +} + +export const TopLeftPosition: Story = { + args: { + title: "Quick Feedback", + position: "top-left", + submitText: "Send", + }, + render: (args) => ( + + + + ), +} + +export const CenteredPosition: Story = { + args: { + title: "Sign In", + position: "center", + submitText: "Sign In", + showCancel: false, + }, + render: (args) => ( + + + + + + ), +} + +export const NoCancel: Story = { + args: { + title: "Required Action", + description: "Please complete this form to continue.", + submitText: "Continue", + showCancel: false, + }, + render: (args) => ( + + + + ), +} + +export const CustomOffset: Story = { + args: { + title: "Settings", + offset: 48, + submitText: "Save", + }, + render: (args) => ( + + + + + ), +} + +export const SubmittingState: Story = { + args: { + title: "Save Changes", + submitText: "Save", + isSubmitting: true, + }, + render: (args) => ( + + + + + ), +} + +export const DisabledSubmit: Story = { + args: { + title: "Subscribe", + submitText: "Subscribe", + submitDisabled: true, + }, + render: (args) => ( + + + + ), +} + +export const AllPositions: Story = { + parameters: { + controls: { disable: true }, + }, + render: function AllPositionsDemo() { + const [openForm, setOpenForm] = useState(null) + + const positions = [ + { key: "top-left", label: "Top Left" }, + { key: "top-right", label: "Top Right" }, + { key: "bottom-left", label: "Bottom Left" }, + { key: "bottom-right", label: "Bottom Right" }, + { key: "center", label: "Center" }, + ] as const + + return ( + <> +
+ {positions.map(({ key, label }) => ( +
+ {positions.map(({ key, label }) => ( + setOpenForm(null)} + onSubmit={() => setOpenForm(null)} + title={label} + position={key} + submitText="Submit" + > + + + ))} + + ) + }, +} diff --git a/packages/components/src/feedback/PopupForm/PopupForm.tsx b/packages/components/src/feedback/PopupForm/PopupForm.tsx new file mode 100644 index 00000000000..de87cb4c6ee --- /dev/null +++ b/packages/components/src/feedback/PopupForm/PopupForm.tsx @@ -0,0 +1,90 @@ +import cx from "classnames" + +import { Dialog, type DialogPosition } from "../Dialog" +import { Button, type ButtonVariant } from "../../primitives/Button" + +export interface PopupFormProps { + open: boolean + onClose: () => void + onSubmit: (e: React.FormEvent) => void + children: React.ReactNode + title?: string + description?: string + position?: DialogPosition + offset?: number + className?: string + submitText?: string + submitVariant?: ButtonVariant + cancelText?: string + cancelVariant?: ButtonVariant + showCancel?: boolean + isSubmitting?: boolean + submitDisabled?: boolean + closeOnEscape?: boolean +} + +export function PopupForm({ + open, + onClose, + onSubmit, + children, + title, + description, + position = "bottom-right", + offset, + className, + submitText = "Submit", + submitVariant = "solid-auburn", + cancelText = "Cancel", + cancelVariant = "outline-charcoal", + showCancel = true, + isSubmitting = false, + submitDisabled = false, + closeOnEscape = true, +}: PopupFormProps) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit(e) + } + + const classes = cx("bc-popup-form", className) + + return ( + +
+
+ {children} +
+
+ {showCancel && ( +
+
+
+ ) +} + +export default PopupForm diff --git a/packages/components/src/feedback/PopupForm/index.ts b/packages/components/src/feedback/PopupForm/index.ts new file mode 100644 index 00000000000..4a3934db3fa --- /dev/null +++ b/packages/components/src/feedback/PopupForm/index.ts @@ -0,0 +1 @@ +export { PopupForm, type PopupFormProps } from "./PopupForm" diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 7051cde4324..ce49c422cee 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -55,3 +55,7 @@ export { StatBlock, type StatBlockProps, type StatBlockSize, type StatBlockTrend // Navigation export { Header, type HeaderProps, type NavItem } from "./navigation/Header" export { Footer, type FooterProps, type FooterLink, type SocialLink } from "./navigation/Footer" + +// Feedback +export { Dialog, type DialogProps, type DialogPosition } from "./feedback/Dialog" +export { PopupForm, type PopupFormProps } from "./feedback/PopupForm" diff --git a/packages/components/src/layout/Container/Container.stories.tsx b/packages/components/src/layout/Container/Container.stories.tsx index 0bc974686ad..031857eda5e 100644 --- a/packages/components/src/layout/Container/Container.stories.tsx +++ b/packages/components/src/layout/Container/Container.stories.tsx @@ -5,17 +5,55 @@ import { Container } from "./Container" const meta: Meta = { title: "Components/Layout/Container", component: Container, + parameters: { + docs: { + description: { + component: ` +A centered container component with configurable max-width breakpoints. + +## Usage + +\`\`\`tsx +import { Container } from "@buildcanada/components" + + +

Content is centered with max-width

+
+\`\`\` + +## Sizes + +- **sm**: 640px max-width +- **md**: 768px max-width +- **lg**: 1024px max-width (default) +- **xl**: 1280px max-width +- **full**: No max-width + +## Semantic HTML + +Use the \`as\` prop for semantic HTML elements: + +\`\`\`tsx + +
Page content
+
+\`\`\` + `, + }, + }, + }, argTypes: { size: { control: "select", options: ["sm", "md", "lg", "xl", "full"], + description: "Maximum width of the container", }, as: { control: "select", options: ["div", "main", "article", "section"], + description: "HTML element to render as", }, }, - tags: ["autodocs"], } export default meta diff --git a/packages/components/src/layout/Divider/Divider.stories.tsx b/packages/components/src/layout/Divider/Divider.stories.tsx index 1b593dd7e43..60120f47aca 100644 --- a/packages/components/src/layout/Divider/Divider.stories.tsx +++ b/packages/components/src/layout/Divider/Divider.stories.tsx @@ -5,21 +5,58 @@ import { Divider } from "./Divider" const meta: Meta = { title: "Components/Layout/Divider", component: Divider, + parameters: { + docs: { + description: { + component: ` +A visual separator for dividing content sections. + +## Usage + +\`\`\`tsx +import { Divider } from "@buildcanada/components" + + +\`\`\` + +## Variants + +- **solid**: Standard solid line +- **dashed**: Dashed line for lighter separation +- **construction**: Blueprint-style pattern (Build Canada brand) + +## Vertical Orientation + +Use \`orientation="vertical"\` for horizontal layouts: + +\`\`\`tsx +
+
Left
+ +
Right
+
+\`\`\` + `, + }, + }, + }, argTypes: { orientation: { control: "radio", options: ["horizontal", "vertical"], + description: "Direction of the divider", }, variant: { control: "select", options: ["solid", "dashed", "construction"], + description: "Visual style of the divider", }, spacing: { control: "select", options: ["none", "sm", "md", "lg"], + description: "Margin around the divider", }, }, - tags: ["autodocs"], } export default meta diff --git a/packages/components/src/layout/Grid/Grid.stories.tsx b/packages/components/src/layout/Grid/Grid.stories.tsx index 07d2b8c1e77..e2588e081e2 100644 --- a/packages/components/src/layout/Grid/Grid.stories.tsx +++ b/packages/components/src/layout/Grid/Grid.stories.tsx @@ -5,25 +5,70 @@ import { Grid, GridItem } from "./Grid" const meta: Meta = { title: "Components/Layout/Grid", component: Grid, + parameters: { + docs: { + description: { + component: ` +A responsive CSS grid layout component with customizable columns and gaps. + +## Usage + +\`\`\`tsx +import { Grid, GridItem } from "@buildcanada/components" + + + Item 1 + Item 2 + Wide Item + +\`\`\` + +## Responsive Columns + +Use \`columnsMd\` and \`columnsLg\` props for responsive layouts: + +\`\`\`tsx + + {/* 1 column on mobile, 2 on tablet, 4 on desktop */} + +\`\`\` + +## GridItem Spanning + +Use \`GridItem\` with the \`span\` prop to make items span multiple columns: + +\`\`\`tsx + + 4 columns + 8 columns + +\`\`\` + `, + }, + }, + }, argTypes: { columns: { control: "select", options: [1, 2, 3, 4, 6, 12], + description: "Number of columns (base/mobile)", }, columnsMd: { control: "select", options: [undefined, 1, 2, 3, 4, 6, 12], + description: "Number of columns at medium breakpoint", }, columnsLg: { control: "select", options: [undefined, 1, 2, 3, 4, 6, 12], + description: "Number of columns at large breakpoint", }, gap: { control: "select", options: ["none", "sm", "md", "lg"], + description: "Gap size between grid items", }, }, - tags: ["autodocs"], } export default meta diff --git a/packages/components/src/layout/Section/Section.stories.tsx b/packages/components/src/layout/Section/Section.stories.tsx index 7e580d5e1a0..70092449f1d 100644 --- a/packages/components/src/layout/Section/Section.stories.tsx +++ b/packages/components/src/layout/Section/Section.stories.tsx @@ -6,17 +6,61 @@ import { Container } from "../Container" const meta: Meta = { title: "Components/Layout/Section", component: Section, + parameters: { + docs: { + description: { + component: ` +A full-width section component with background colors and vertical padding. + +## Usage + +\`\`\`tsx +import { Section, Container } from "@buildcanada/components" + +
+ +

Section Title

+

Section content goes here.

+
+
+\`\`\` + +## Backgrounds + +- **white**: #FFFFFF +- **linen**: #F6ECE3 (Build Canada brand) +- **charcoal**: #272727 (dark mode) + +## Building Page Layouts + +Stack sections to create full page layouts: + +\`\`\`tsx +<> +
+ +
+
+ +
+ +\`\`\` + `, + }, + }, + }, argTypes: { background: { control: "select", options: ["white", "linen", "charcoal"], + description: "Background color of the section", }, spacing: { control: "select", options: ["none", "sm", "md", "lg", "xl"], + description: "Vertical padding (top and bottom)", }, }, - tags: ["autodocs"], } export default meta diff --git a/packages/components/src/layout/Stack/Stack.stories.tsx b/packages/components/src/layout/Stack/Stack.stories.tsx index 13b0b6e5d44..efc359f6ad5 100644 --- a/packages/components/src/layout/Stack/Stack.stories.tsx +++ b/packages/components/src/layout/Stack/Stack.stories.tsx @@ -5,25 +5,70 @@ import { Stack } from "./Stack" const meta: Meta = { title: "Components/Layout/Stack", component: Stack, + parameters: { + docs: { + description: { + component: ` +A flexbox layout component for arranging items in a row or column with consistent spacing. + +## Usage + +\`\`\`tsx +import { Stack } from "@buildcanada/components" + + +
Item 1
+
Item 2
+
Item 3
+
+\`\`\` + +## Horizontal Layout + +\`\`\`tsx + + + + +\`\`\` + +## Spacing Options + +- **none**: 0px +- **xs**: 4px +- **sm**: 8px +- **md**: 16px +- **lg**: 24px +- **xl**: 32px + `, + }, + }, + }, argTypes: { direction: { control: "radio", options: ["vertical", "horizontal"], + description: "Stack direction (column or row)", }, spacing: { control: "select", options: ["none", "xs", "sm", "md", "lg", "xl"], + description: "Gap between items", }, align: { control: "select", options: ["start", "center", "end", "stretch"], + description: "Cross-axis alignment (align-items)", }, justify: { control: "select", options: ["start", "center", "end", "between", "around"], + description: "Main-axis alignment (justify-content)", + }, + wrap: { + description: "Whether items should wrap to next line", }, }, - tags: ["autodocs"], } export default meta diff --git a/packages/components/src/navigation/Footer/Footer.stories.tsx b/packages/components/src/navigation/Footer/Footer.stories.tsx index 6b496f5a21f..958138d7024 100644 --- a/packages/components/src/navigation/Footer/Footer.stories.tsx +++ b/packages/components/src/navigation/Footer/Footer.stories.tsx @@ -8,8 +8,55 @@ const meta: Meta = { component: Footer, parameters: { layout: "fullscreen", + docs: { + description: { + component: ` +A site footer component with logo, navigation links, social links, newsletter signup, and optional quote. + +## Usage + +\`\`\`tsx +import { Footer } from "@buildcanada/components" + +