diff --git a/.changeset/curly-parents-drive.md b/.changeset/curly-parents-drive.md new file mode 100644 index 00000000..572c23f0 --- /dev/null +++ b/.changeset/curly-parents-drive.md @@ -0,0 +1,6 @@ +--- +"@shipfox/react-ui": minor +"@shipfox/vitest": minor +--- + +Add Argos CI Upload Screenshots diff --git a/.changeset/soft-swans-press.md b/.changeset/soft-swans-press.md new file mode 100644 index 00000000..c444e0e4 --- /dev/null +++ b/.changeset/soft-swans-press.md @@ -0,0 +1,5 @@ +--- +"@shipfox/react-ui": minor +--- + +Add Modal components diff --git a/biome.json b/biome.json index c892e91b..bbfed181 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,8 @@ "!**/out", "!**/coverage", "!**/nix/store", - "!**/storybook-static" + "!**/storybook-static", + "!**/screenshots" ], "ignoreUnknown": false }, diff --git a/libs/react/ui/.storybook/preview.tsx b/libs/react/ui/.storybook/preview.tsx index f310edd6..7f66c79b 100644 --- a/libs/react/ui/.storybook/preview.tsx +++ b/libs/react/ui/.storybook/preview.tsx @@ -9,6 +9,17 @@ const withTheme: Decorator = (Story, context) => { const preview: Preview = { decorators: [withTheme], parameters: { + viewport: { + viewports: { + large: { + name: 'Large Viewport', + styles: { + width: '1280px', + height: '2000px', + }, + }, + }, + }, options: { storySort: { method: 'alphabetical', diff --git a/libs/react/ui/index.css b/libs/react/ui/index.css index 8e3c1ecb..9177e34a 100644 --- a/libs/react/ui/index.css +++ b/libs/react/ui/index.css @@ -192,7 +192,7 @@ --background-contrast-pressed: var(--color-neutral-700); --background-contrast-base: var(--color-neutral-900); --background-neutral-background: var(--color-neutral-50); - --background-backdrop-backdrop: var(--color-neutral-0); + --background-backdrop-backdrop: var(--color-alpha-black-64); /* Button Background */ --background-button-transparent-default: var(--color-alpha-white-0); diff --git a/libs/react/ui/package.json b/libs/react/ui/package.json index 95f31ed0..9a35e400 100644 --- a/libs/react/ui/package.json +++ b/libs/react/ui/package.json @@ -42,7 +42,8 @@ "recharts": "^3.1.0", "shiki": "^3.15.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.2.0" + "tailwind-merge": "^3.2.0", + "vaul": "^1.1.1" }, "peerDependencies": { "@tanstack/react-table": "^8.19.3", diff --git a/libs/react/ui/src/components/code-block/code-block-footer.tsx b/libs/react/ui/src/components/code-block/code-block-footer.tsx index 30d6e799..bdde1cd7 100644 --- a/libs/react/ui/src/components/code-block/code-block-footer.tsx +++ b/libs/react/ui/src/components/code-block/code-block-footer.tsx @@ -1,5 +1,6 @@ import {Slot} from '@radix-ui/react-slot'; import {Icon} from 'components/icon/icon'; +import {Text} from 'components/typography'; import type {ComponentProps, HTMLAttributes, ReactNode} from 'react'; import {cn} from 'utils/cn'; @@ -53,22 +54,12 @@ export function CodeBlockFooter({ className={cn('flex w-full items-center justify-start gap-12 px-16 py-12', className)} {...props} > -
- {defaultIcon} -
+ {defaultIcon} {(message || description) && ( -
- {message && ( -
- {message} -
- )} - {description && ( -
- {description} -
- )} -
+ + {message && {message}} + {description && {description}} + )} ); @@ -112,7 +103,7 @@ export function CodeBlockFooterContent({ return ( {children} @@ -130,19 +121,27 @@ export function CodeBlockFooterMessage({ children, ...props }: CodeBlockFooterMessageProps) { - const Comp = asChild ? Slot : 'div'; + if (asChild) { + return ( + + {children} + + ); + } return ( - {children} - + ); } @@ -156,18 +155,26 @@ export function CodeBlockFooterDescription({ children, ...props }: CodeBlockFooterDescriptionProps) { - const Comp = asChild ? Slot : 'div'; + if (asChild) { + return ( + + {children} + + ); + } return ( - {children} - + ); } diff --git a/libs/react/ui/src/components/dynamic-item/dynamic-item.stories.tsx b/libs/react/ui/src/components/dynamic-item/dynamic-item.stories.tsx index 543c5b75..ffa7abb0 100644 --- a/libs/react/ui/src/components/dynamic-item/dynamic-item.stories.tsx +++ b/libs/react/ui/src/components/dynamic-item/dynamic-item.stories.tsx @@ -1,6 +1,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {Button} from 'components/button/button'; import {ItemTitle} from 'components/item'; +import {MovingBorder} from 'components/moving-border'; import {cn} from 'utils/cn'; import illustration1 from '../../assets/illustration-1.svg'; import illustration2 from '../../assets/illustration-2.svg'; @@ -8,7 +9,6 @@ import illustrationBg from '../../assets/illustration-gradient.svg'; import {Avatar} from '../avatar/avatar'; import {AvatarGroup, AvatarGroupTooltip} from '../avatar/avatar-group'; import {Icon} from '../icon/icon'; -import {MovingBorder} from '../moving-border/moving-border'; import {DynamicItem} from './dynamic-item'; const meta = { diff --git a/libs/react/ui/src/components/icon/icon.tsx b/libs/react/ui/src/components/icon/icon.tsx index e231b0fe..ee66db60 100644 --- a/libs/react/ui/src/components/icon/icon.tsx +++ b/libs/react/ui/src/components/icon/icon.tsx @@ -2,6 +2,7 @@ import { type RemixiconComponentType, RiAddLine, RiArrowRightSLine, + RiBookOpenFill, RiCheckLine, RiCloseLine, RiFileCopyLine, @@ -60,6 +61,7 @@ const iconsMap = { copy: RiFileCopyLine, addLine: RiAddLine, chevronRight: RiArrowRightSLine, + bookOpen: RiBookOpenFill, } as const satisfies Record; export type IconName = keyof typeof iconsMap; diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index 5b2ed922..9e3e277c 100644 --- a/libs/react/ui/src/components/index.ts +++ b/libs/react/ui/src/components/index.ts @@ -10,6 +10,7 @@ export * from './inline-tips'; export * from './input'; export * from './item'; export * from './label'; +export * from './modal'; export * from './textarea'; export * from './theme'; export * from './toast'; diff --git a/libs/react/ui/src/components/modal/index.ts b/libs/react/ui/src/components/modal/index.ts new file mode 100644 index 00000000..58e16575 --- /dev/null +++ b/libs/react/ui/src/components/modal/index.ts @@ -0,0 +1,23 @@ +export type { + ModalContentProps, + ModalDescriptionProps, + ModalHeaderProps, + ModalOverlayProps, + ModalTitleProps, +} from './modal'; +export { + Modal, + ModalBody, + ModalClose, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalPortal, + ModalTitle, + ModalTrigger, + modalContentVariants, + modalDefaultTransition, + modalOverlayVariants, +} from './modal'; diff --git a/libs/react/ui/src/components/modal/modal.stories.tsx b/libs/react/ui/src/components/modal/modal.stories.tsx new file mode 100644 index 00000000..1ef24af6 --- /dev/null +++ b/libs/react/ui/src/components/modal/modal.stories.tsx @@ -0,0 +1,384 @@ +import {argosScreenshot} from '@argos-ci/storybook/vitest'; +import type {Meta, StoryObj} from '@storybook/react'; +import {screen, within} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {Button, ButtonLink} from 'components/button'; +import { + CodeBlock, + CodeBlockBody, + CodeBlockContent, + CodeBlockCopyButton, + CodeBlockFilename, + CodeBlockFiles, + CodeBlockFooter, + CodeBlockHeader, + CodeBlockItem, +} from 'components/code-block'; +import {DynamicItem} from 'components/dynamic-item'; +import {Icon} from 'components/icon'; +import {Input} from 'components/input'; +import {ItemTitle} from 'components/item'; +import {Label} from 'components/label'; +import {MovingBorder} from 'components/moving-border'; +import {Text} from 'components/typography'; +import {useState} from 'react'; +import {cn} from 'utils/cn'; +import illustration2 from '../../assets/illustration-2.svg'; +import illustrationBg from '../../assets/illustration-gradient.svg'; +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + ModalTrigger, +} from './modal'; + +const OPEN_MODAL_REGEX = /open modal/i; +const IMPORT_JOBS_REGEX = /import past jobs from github/i; +const GITHUB_ACTIONS_REGEX = /run github actions on shipfox/i; + +const meta = { + title: 'Components/Modal', + component: Modal, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async (ctx) => { + const {canvasElement, step} = ctx; + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await step('Open the modal', async () => { + const triggerButton = canvas.getByRole('button', {name: OPEN_MODAL_REGEX}); + await user.click(triggerButton); + }); + + await step('Wait for dialog to appear and render', async () => { + await screen.findByRole('dialog'); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await argosScreenshot(ctx, 'Default Modal Open'); + }, + render: () => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + Modal Title + + + Modal Title + + + + + This modal automatically adapts between dialog (desktop) and drawer (mobile) based + on screen size. Try resizing your browser window! + + + + + + + + +
+ ); + }, +}; + +export const ImportForm: Story = { + play: async (ctx) => { + const {canvasElement, step} = ctx; + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await step('Open the modal', async () => { + const triggerButton = canvas.getByRole('button', {name: IMPORT_JOBS_REGEX}); + await user.click(triggerButton); + }); + + await step('Wait for dialog to appear and render', async () => { + await screen.findByRole('dialog'); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await argosScreenshot(ctx, 'Import Form Modal Open'); + }, + render: () => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + Import past jobs from Github + + + + Backfill your CI history by importing past runs from your Github repo. We'll + handle the rest by creating a background task to import the data for you. + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+
+ ); + }, +}; + +const diffCode = `jobs: + build: +- runs-on: ubuntu-latest ++ runs-on: shipfox-2vcpu-ubuntu-2404`; + +export const GithubActions: Story = { + parameters: { + viewport: { + defaultViewport: 'large', + }, + }, + play: async (ctx) => { + const {canvasElement, step} = ctx; + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await step('Open the modal', async () => { + const triggerButton = canvas.getByRole('button', {name: GITHUB_ACTIONS_REGEX}); + await user.click(triggerButton); + }); + + await step('Wait for dialog to appear and render', async () => { + await screen.findByRole('dialog'); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await argosScreenshot(ctx, 'Github Actions Modal Open'); + }, + render: () => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + Run GitHub Actions on Shipfox + + +
+ + This will run your jobs on Shipfox's optimized infrastructure. Giving you + faster builds, and dedicated resources. + +
+ illustration-2 +
+
+ +
+ +
+
+ + + + + 6000 free credits/month to run your jobs +
+ } + description="~500 builds/month. No payment required." + rightElement={ + illustration-bg + } + /> +
+
+
+
+
+
+
+ + Update your GitHub Actions workflow + + + See docs + +
+ + Replace the runs-on line in your workflow file to use Shipfox runners. + +
+ + .yml', + code: diffCode, + }, + ]} + defaultValue="yaml" + > + + + {(item) => ( + {item.filename} + )} + + + + + {(item) => ( + + {item.code} + + )} + + + +
+
+ + + +
+
+
+ ); + }, +}; + +export const OpenedModal: Story = { + play: async (ctx) => { + const {canvasElement, step} = ctx; + const canvas = within(canvasElement); + const user = userEvent.setup(); + + await step('Open the modal', async () => { + const triggerButton = canvas.getByRole('button', {name: OPEN_MODAL_REGEX}); + await user.click(triggerButton); + }); + + await step('Wait for dialog to appear and render', async () => { + await screen.findByRole('dialog'); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + await argosScreenshot(ctx, 'Opened Modal State'); + }, + render: () => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + + Modal Title + + + Modal Title + + + + + This modal automatically adapts between dialog (desktop) and drawer (mobile) based + on screen size. Try resizing your browser window! + + + + + + + + +
+ ); + }, +}; diff --git a/libs/react/ui/src/components/modal/modal.tsx b/libs/react/ui/src/components/modal/modal.tsx new file mode 100644 index 00000000..0b6c3bde --- /dev/null +++ b/libs/react/ui/src/components/modal/modal.tsx @@ -0,0 +1,309 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import {cva} from 'class-variance-authority'; +import {Button} from 'components/button'; +import {Icon} from 'components/icon'; +import {Text} from 'components/typography'; +import {motion, type Transition} from 'framer-motion'; +import {useMediaQuery} from 'hooks/useMediaQuery'; +import {type ComponentProps, createContext, useContext} from 'react'; +import {cn} from 'utils/cn'; +import {Drawer as VaulDrawer} from 'vaul'; + +const modalDefaultTransition: Transition = { + type: 'spring', + stiffness: 300, + damping: 30, +}; + +type ModalContextValue = { + breakpoint: string; + isDesktop: boolean; +}; + +const ModalContext = createContext(null); + +function useModalContext() { + const context = useContext(ModalContext); + if (!context) { + throw new Error('Modal components must be used within a Modal component'); + } + return context; +} + +const modalOverlayVariants = cva( + 'fixed inset-0 z-40 bg-background-backdrop-backdrop data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', +); + +const modalContentVariants = cva( + 'fixed left-1/2 top-1/2 z-50 flex flex-col overflow-clip bg-background-neutral-base rounded-16 w-full max-w-[576px] -translate-x-1/2 -translate-y-1/2 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 shadow-tooltip', +); + +function Modal({ + breakpoint = '(min-width: 768px)', + children, + ...props +}: ComponentProps & {breakpoint?: string}) { + const isDesktop = useMediaQuery(breakpoint); + + const contextValue: ModalContextValue = { + breakpoint, + isDesktop, + }; + + const Root = isDesktop ? DialogPrimitive.Root : VaulDrawer.Root; + + return ( + + {children} + + ); +} + +function ModalTrigger(props: ComponentProps) { + const {isDesktop} = useModalContext(); + + if (isDesktop) { + return ; + } + + return ; +} + +function ModalPortal(props: ComponentProps) { + const {isDesktop} = useModalContext(); + + if (isDesktop) { + return ; + } + + return ; +} + +function ModalClose(props: ComponentProps) { + const {isDesktop} = useModalContext(); + + if (isDesktop) { + return ; + } + + return ; +} + +type ModalOverlayProps = ComponentProps & { + animated?: boolean; + transition?: Transition; +}; + +function ModalOverlay({ + className, + animated = true, + transition = modalDefaultTransition, + ...props +}: ModalOverlayProps) { + const {isDesktop} = useModalContext(); + + if (!isDesktop) { + return ; + } + + if (animated) { + return ( + + + + ); + } + + return ; +} + +type ModalContentProps = ComponentProps & { + animated?: boolean; + transition?: Transition; +}; + +function ModalContent({ + className, + children, + animated = true, + transition = modalDefaultTransition, + ...props +}: ModalContentProps) { + const {isDesktop} = useModalContext(); + + if (!isDesktop) { + return ( + + + +
+
+
+
+
+ {children} +
+ + + ); + } + + const baseClasses = cn(modalContentVariants(), className); + + return ( + + + +
+
+ {children} +
+ + + ); +} + +type ModalHeaderProps = ComponentProps<'div'> & { + title?: string; + showEscIndicator?: boolean; + showClose?: boolean; +}; + +function ModalHeader({ + className, + title, + showEscIndicator = true, + showClose = true, + children, + ...props +}: ModalHeaderProps) { + const {isDesktop} = useModalContext(); + + return ( +
+
+ {title ? ( + + {title} + + ) : ( +
{children}
+ )} +
+ {isDesktop && showEscIndicator && ( + + esc + + )} + {showClose && ( + + + + )} +
+
+
+
+ ); +} + +function ModalBody({className, children, ...props}: ComponentProps<'div'>) { + const {isDesktop} = useModalContext(); + + return ( +
+ {children} +
+ ); +} + +function ModalFooter({className, children, ...props}: ComponentProps<'div'>) { + return ( +
+
+
+
{children}
+
+
+ ); +} + +type ModalTitleProps = ComponentProps; + +function ModalTitle({className, ...props}: ModalTitleProps) { + const {isDesktop} = useModalContext(); + + const titleClassName = cn( + 'font-medium text-lg leading-20 overflow-ellipsis overflow-hidden text-foreground-neutral-base', + className, + ); + + if (!isDesktop) { + return ; + } + + return ; +} + +type ModalDescriptionProps = ComponentProps; + +function ModalDescription({className, ...props}: ModalDescriptionProps) { + const {isDesktop} = useModalContext(); + + const descClassName = cn('text-sm leading-20 text-foreground-neutral-subtle', className); + + if (!isDesktop) { + return ; + } + + return ; +} + +export { + Modal, + ModalPortal, + ModalOverlay, + ModalTrigger, + ModalClose, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalTitle, + ModalDescription, + modalContentVariants, + modalOverlayVariants, + modalDefaultTransition, +}; + +export type { + ModalContentProps, + ModalHeaderProps, + ModalOverlayProps, + ModalTitleProps, + ModalDescriptionProps, +}; diff --git a/libs/react/ui/src/components/moving-border/index.ts b/libs/react/ui/src/components/moving-border/index.ts new file mode 100644 index 00000000..df99ab69 --- /dev/null +++ b/libs/react/ui/src/components/moving-border/index.ts @@ -0,0 +1 @@ +export * from './moving-border'; diff --git a/libs/react/ui/src/components/typography/text.tsx b/libs/react/ui/src/components/typography/text.tsx index d4b7c60d..ea4f7ce9 100644 --- a/libs/react/ui/src/components/typography/text.tsx +++ b/libs/react/ui/src/components/typography/text.tsx @@ -24,7 +24,15 @@ export type TextProps = PropsWithChildren> bold?: boolean; }; -export function Text({children, className, size, as, compact, bold, ...props}: TextProps) { +export function Text({ + children, + className, + size, + as, + compact = true, + bold = false, + ...props +}: TextProps) { const Component = as ?? 'p'; return ( (); + private listeners = new Map void>>(); + private changeHandlers = new Map void>(); + + getMatches(query: string): boolean { + if (typeof window === 'undefined') return false; + + if (!this.queries.has(query)) { + this.queries.set(query, window.matchMedia(query)); + this.listeners.set(query, new Set()); + } + + const mediaQuery = this.queries.get(query); + return mediaQuery ? mediaQuery.matches : false; + } + + subscribe(query: string, callback: () => void): () => void { + if (typeof window === 'undefined') { + return () => { + // Cleanup function for SSR - no-op + }; + } + + if (!this.queries.has(query)) { + this.queries.set(query, window.matchMedia(query)); + this.listeners.set(query, new Set()); + } + + const mediaQuery = this.queries.get(query); + const listeners = this.listeners.get(query); + + if (!mediaQuery || !listeners) { + return () => { + // Cleanup function - no-op if query wasn't found + }; + } + + listeners.add(callback); + + if (listeners.size === 1) { + const changeHandler = () => { + for (const cb of listeners) { + cb(); + } + }; + this.changeHandlers.set(query, changeHandler); + mediaQuery.addEventListener('change', changeHandler); + } + + return () => { + listeners.delete(callback); + + if (listeners.size === 0) { + const changeHandler = this.changeHandlers.get(query); + if (changeHandler) { + mediaQuery.removeEventListener('change', changeHandler); + this.changeHandlers.delete(query); + } + this.queries.delete(query); + this.listeners.delete(query); + } + }; + } +} + +const mediaQueryManager = new MediaQueryManager(); + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => mediaQueryManager.getMatches(query)); + + useEffect(() => { + const updateMatches = () => { + setMatches(mediaQueryManager.getMatches(query)); + }; + + const unsubscribe = mediaQueryManager.subscribe(query, updateMatches); + + updateMatches(); + + return unsubscribe; + }, [query]); + + return matches; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b24dc96..8cc796ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -349,6 +349,9 @@ importers: tailwind-merge: specifier: ^3.2.0 version: 3.4.0 + vaul: + specifier: ^1.1.1 + version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.2.4))(@types/react@19.2.4)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) devDependencies: '@argos-ci/cli': specifier: ^3.2.1 @@ -5428,6 +5431,12 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -10709,6 +10718,15 @@ snapshots: uuid@9.0.1: {} + vaul@1.1.2(@types/react-dom@19.1.6(@types/react@19.2.4))(@types/react@19.2.4)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.6(@types/react@19.2.4))(@types/react@19.2.4)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3