From c24f41a289be71e59b73c3483dd1df5318790456 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Sun, 5 Apr 2026 11:12:08 +0700 Subject: [PATCH] feat: Add Modal recipe with storybook and fix card border collapsing Add Modal component recipe system (modal, overlay, header, body, footer) with variants for color, size, and variant styles. Includes storybook stories and Vue preview components. Fix card border adjacency collapsing to use borderWidth instead of borderColor for cleaner rendering. --- .../src/components/components/modal/Modal.vue | 29 ++ .../components/components/modal/ModalBody.vue | 27 ++ .../components/modal/ModalDescription.vue | 3 + .../components/modal/ModalFooter.vue | 27 ++ .../components/modal/ModalHeader.vue | 27 ++ .../components/modal/ModalOverlay.vue | 12 + .../components/modal/ModalTitle.vue | 3 + .../components/modal/preview/ModalGrid.vue | 38 +++ .../modal/preview/ModalSizeGrid.vue | 42 +++ .../stories/components/modal.stories.ts | 159 +++++++++ .../stories/components/modal.styleframe.ts | 61 ++++ theme/src/recipes/card/useCardBodyRecipe.ts | 2 +- theme/src/recipes/card/useCardFooterRecipe.ts | 305 +++++++++--------- theme/src/recipes/card/useCardHeaderRecipe.ts | 8 +- theme/src/recipes/index.ts | 1 + theme/src/recipes/modal/index.ts | 5 + .../recipes/modal/useModalBodyRecipe.test.ts | 184 +++++++++++ theme/src/recipes/modal/useModalBodyRecipe.ts | 177 ++++++++++ .../modal/useModalFooterRecipe.test.ts | 184 +++++++++++ .../src/recipes/modal/useModalFooterRecipe.ts | 177 ++++++++++ .../modal/useModalHeaderRecipe.test.ts | 184 +++++++++++ .../src/recipes/modal/useModalHeaderRecipe.ts | 184 +++++++++++ .../modal/useModalOverlayRecipe.test.ts | 80 +++++ .../recipes/modal/useModalOverlayRecipe.ts | 24 ++ .../src/recipes/modal/useModalRecipe.test.ts | 290 +++++++++++++++++ theme/src/recipes/modal/useModalRecipe.ts | 179 ++++++++++ 26 files changed, 2261 insertions(+), 151 deletions(-) create mode 100644 apps/storybook/src/components/components/modal/Modal.vue create mode 100644 apps/storybook/src/components/components/modal/ModalBody.vue create mode 100644 apps/storybook/src/components/components/modal/ModalDescription.vue create mode 100644 apps/storybook/src/components/components/modal/ModalFooter.vue create mode 100644 apps/storybook/src/components/components/modal/ModalHeader.vue create mode 100644 apps/storybook/src/components/components/modal/ModalOverlay.vue create mode 100644 apps/storybook/src/components/components/modal/ModalTitle.vue create mode 100644 apps/storybook/src/components/components/modal/preview/ModalGrid.vue create mode 100644 apps/storybook/src/components/components/modal/preview/ModalSizeGrid.vue create mode 100644 apps/storybook/stories/components/modal.stories.ts create mode 100644 apps/storybook/stories/components/modal.styleframe.ts create mode 100644 theme/src/recipes/modal/index.ts create mode 100644 theme/src/recipes/modal/useModalBodyRecipe.test.ts create mode 100644 theme/src/recipes/modal/useModalBodyRecipe.ts create mode 100644 theme/src/recipes/modal/useModalFooterRecipe.test.ts create mode 100644 theme/src/recipes/modal/useModalFooterRecipe.ts create mode 100644 theme/src/recipes/modal/useModalHeaderRecipe.test.ts create mode 100644 theme/src/recipes/modal/useModalHeaderRecipe.ts create mode 100644 theme/src/recipes/modal/useModalOverlayRecipe.test.ts create mode 100644 theme/src/recipes/modal/useModalOverlayRecipe.ts create mode 100644 theme/src/recipes/modal/useModalRecipe.test.ts create mode 100644 theme/src/recipes/modal/useModalRecipe.ts diff --git a/apps/storybook/src/components/components/modal/Modal.vue b/apps/storybook/src/components/components/modal/Modal.vue new file mode 100644 index 00000000..36936954 --- /dev/null +++ b/apps/storybook/src/components/components/modal/Modal.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/storybook/src/components/components/modal/ModalBody.vue b/apps/storybook/src/components/components/modal/ModalBody.vue new file mode 100644 index 00000000..4059936c --- /dev/null +++ b/apps/storybook/src/components/components/modal/ModalBody.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/storybook/src/components/components/modal/ModalDescription.vue b/apps/storybook/src/components/components/modal/ModalDescription.vue new file mode 100644 index 00000000..07a5621c --- /dev/null +++ b/apps/storybook/src/components/components/modal/ModalDescription.vue @@ -0,0 +1,3 @@ + diff --git a/apps/storybook/src/components/components/modal/ModalFooter.vue b/apps/storybook/src/components/components/modal/ModalFooter.vue new file mode 100644 index 00000000..4c2be633 --- /dev/null +++ b/apps/storybook/src/components/components/modal/ModalFooter.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/storybook/src/components/components/modal/ModalHeader.vue b/apps/storybook/src/components/components/modal/ModalHeader.vue new file mode 100644 index 00000000..8e5201e3 --- /dev/null +++ b/apps/storybook/src/components/components/modal/ModalHeader.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/storybook/src/components/components/modal/ModalOverlay.vue b/apps/storybook/src/components/components/modal/ModalOverlay.vue new file mode 100644 index 00000000..c6d16151 --- /dev/null +++ b/apps/storybook/src/components/components/modal/ModalOverlay.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/storybook/src/components/components/modal/ModalTitle.vue b/apps/storybook/src/components/components/modal/ModalTitle.vue new file mode 100644 index 00000000..62e7f557 --- /dev/null +++ b/apps/storybook/src/components/components/modal/ModalTitle.vue @@ -0,0 +1,3 @@ + diff --git a/apps/storybook/src/components/components/modal/preview/ModalGrid.vue b/apps/storybook/src/components/components/modal/preview/ModalGrid.vue new file mode 100644 index 00000000..358078ca --- /dev/null +++ b/apps/storybook/src/components/components/modal/preview/ModalGrid.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/storybook/src/components/components/modal/preview/ModalSizeGrid.vue b/apps/storybook/src/components/components/modal/preview/ModalSizeGrid.vue new file mode 100644 index 00000000..23d20a1f --- /dev/null +++ b/apps/storybook/src/components/components/modal/preview/ModalSizeGrid.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/storybook/stories/components/modal.stories.ts b/apps/storybook/stories/components/modal.stories.ts new file mode 100644 index 00000000..e11c590b --- /dev/null +++ b/apps/storybook/stories/components/modal.stories.ts @@ -0,0 +1,159 @@ +import type { Meta, StoryObj } from "@storybook/vue3-vite"; + +import Modal from "@/components/components/modal/Modal.vue"; +import ModalHeader from "@/components/components/modal/ModalHeader.vue"; +import ModalBody from "@/components/components/modal/ModalBody.vue"; +import ModalFooter from "@/components/components/modal/ModalFooter.vue"; +import ModalTitle from "@/components/components/modal/ModalTitle.vue"; +import ModalDescription from "@/components/components/modal/ModalDescription.vue"; +import ModalGrid from "@/components/components/modal/preview/ModalGrid.vue"; +import ModalSizeGrid from "@/components/components/modal/preview/ModalSizeGrid.vue"; + +const colors = ["neutral", "light", "dark"] as const; +const variants = ["solid", "soft", "subtle"] as const; +const sizes = ["sm", "md", "lg"] as const; + +const meta = { + title: "Theme/Recipes/Modal", + component: Modal, + tags: ["autodocs"], + parameters: { + layout: "padded", + }, + argTypes: { + color: { + control: "select", + options: colors, + description: "The color variant of the modal", + }, + variant: { + control: "select", + options: variants, + description: "The visual style variant", + }, + size: { + control: "select", + options: sizes, + description: "The size of the modal", + }, + fullscreen: { + control: "boolean", + description: "Whether the modal is fullscreen", + }, + }, + render: (args) => ({ + components: { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + ModalTitle, + ModalDescription, + }, + setup() { + return { args }; + }, + template: ` + + + Modal Title + + + This is a modal description with some content. + + + Footer content + + + `, + }), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + color: "neutral", + variant: "solid", + size: "md", + }, +}; + +export const AllVariants: StoryObj = { + render: () => ({ + components: { ModalGrid }, + template: "", + }), +}; + +export const AllSizes: StoryObj = { + render: () => ({ + components: { ModalSizeGrid }, + template: "", + }), +}; + +// Individual color stories +export const Neutral: Story = { + args: { + color: "neutral", + }, +}; + +export const Light: Story = { + args: { + color: "light", + }, +}; + +export const Dark: Story = { + args: { + color: "dark", + }, +}; + +// Variant stories +export const Solid: Story = { + args: { + variant: "solid", + }, +}; + +export const Soft: Story = { + args: { + variant: "soft", + }, +}; + +export const Subtle: Story = { + args: { + variant: "subtle", + }, +}; + +// Size stories +export const Small: Story = { + args: { + size: "sm", + }, +}; + +export const Medium: Story = { + args: { + size: "md", + }, +}; + +export const Large: Story = { + args: { + size: "lg", + }, +}; + +// Fullscreen story +export const Fullscreen: Story = { + args: { + fullscreen: true, + }, +}; diff --git a/apps/storybook/stories/components/modal.styleframe.ts b/apps/storybook/stories/components/modal.styleframe.ts new file mode 100644 index 00000000..bad9b482 --- /dev/null +++ b/apps/storybook/stories/components/modal.styleframe.ts @@ -0,0 +1,61 @@ +import { + useModalRecipe, + useModalHeaderRecipe, + useModalBodyRecipe, + useModalFooterRecipe, + useModalOverlayRecipe, +} from "@styleframe/theme"; +import { styleframe } from "virtual:styleframe"; + +const s = styleframe(); +const { selector } = s; + +// Initialize modal recipes +export const modal = useModalRecipe(s); +export const modalHeader = useModalHeaderRecipe(s); +export const modalBody = useModalBodyRecipe(s); +export const modalFooter = useModalFooterRecipe(s); +export const modalOverlay = useModalOverlayRecipe(s); + +// Plain selectors for simple wrappers +selector(".modal-title", { + fontSize: "@font-size.md", + fontWeight: "@font-weight.semibold", + lineHeight: "@line-height.tight", +}); + +selector(".modal-description", { + fontSize: "@font-size.sm", + lineHeight: "@line-height.normal", +}); + +// Container styles for story layout +selector(".modal-grid", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", + padding: "@spacing.md", + alignItems: "flex-start", +}); + +selector(".modal-section", { + display: "flex", + flexDirection: "column", + gap: "@spacing.lg", + padding: "@spacing.md", +}); + +selector(".modal-row", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.sm", + alignItems: "flex-start", +}); + +selector(".modal-label", { + fontSize: "@font-size.sm", + fontWeight: "@font-weight.semibold", + minWidth: "80px", +}); + +export default s; diff --git a/theme/src/recipes/card/useCardBodyRecipe.ts b/theme/src/recipes/card/useCardBodyRecipe.ts index d2cc8617..606566b2 100644 --- a/theme/src/recipes/card/useCardBodyRecipe.ts +++ b/theme/src/recipes/card/useCardBodyRecipe.ts @@ -205,7 +205,7 @@ export const useCardBodyRecipe = createUseRecipe( (s) => { // Collapse bottom border when followed by card footer s.selector(".card-body:has(+ .card-footer)", { - borderBottomColor: "transparent", + borderBottomWidth: "0", }); }, ); diff --git a/theme/src/recipes/card/useCardFooterRecipe.ts b/theme/src/recipes/card/useCardFooterRecipe.ts index 251ad712..12a2a890 100644 --- a/theme/src/recipes/card/useCardFooterRecipe.ts +++ b/theme/src/recipes/card/useCardFooterRecipe.ts @@ -3,200 +3,209 @@ import { createUseRecipe } from "../../utils/createUseRecipe"; /** * Card footer recipe with top separator. */ -export const useCardFooterRecipe = createUseRecipe("card-footer", { - base: { - display: "flex", - alignItems: "center", - gap: "@0.75", - paddingTop: "@0.75", - paddingBottom: "@0.75", - paddingLeft: "@1", - paddingRight: "@1", - borderTopWidth: "@border-width.thin", - borderTopStyle: "@border-style.solid", - borderTopColor: "transparent", - borderBottomWidth: "@border-width.thin", - borderBottomStyle: "@border-style.solid", - borderBottomColor: "transparent", - }, - variants: { - color: { - light: {}, - dark: {}, - neutral: {}, - }, - variant: { - solid: {}, - outline: {}, - soft: {}, - subtle: {}, +export const useCardFooterRecipe = createUseRecipe( + "card-footer", + { + base: { + display: "flex", + alignItems: "center", + gap: "@0.75", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", }, - size: { - sm: { - paddingTop: "@0.5", - paddingBottom: "@0.5", - paddingLeft: "@0.75", - paddingRight: "@0.75", - gap: "@0.5", + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, }, - md: { - paddingTop: "@0.75", - paddingBottom: "@0.75", - paddingLeft: "@1", - paddingRight: "@1", - gap: "@0.75", + variant: { + solid: {}, + outline: {}, + soft: {}, + subtle: {}, }, - lg: { - paddingTop: "@1", - paddingBottom: "@1", - paddingLeft: "@1.25", - paddingRight: "@1.25", - gap: "@1", + size: { + sm: { + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + gap: "@0.5", + }, + md: { + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + gap: "@0.75", + }, + lg: { + paddingTop: "@1", + paddingBottom: "@1", + paddingLeft: "@1.25", + paddingRight: "@1.25", + gap: "@1", + }, }, }, - }, - compoundVariants: [ - // Light - { - match: { color: "light" as const, variant: "solid" as const }, - css: { - borderTopColor: "@color.gray-200", - borderBottomColor: "@color.gray-200", - "&:dark": { + compoundVariants: [ + // Light + { + match: { color: "light" as const, variant: "solid" as const }, + css: { borderTopColor: "@color.gray-200", borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + }, }, }, - }, - { - match: { color: "light" as const, variant: "outline" as const }, - css: { - borderTopColor: "@color.gray-200", - borderBottomColor: "@color.gray-200", - "&:dark": { + { + match: { color: "light" as const, variant: "outline" as const }, + css: { borderTopColor: "@color.gray-200", borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + }, }, }, - }, - { - match: { color: "light" as const, variant: "soft" as const }, - css: { - borderTopColor: "transparent", - borderBottomColor: "transparent", - "&:dark": { + { + match: { color: "light" as const, variant: "soft" as const }, + css: { borderTopColor: "transparent", borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, }, }, - }, - { - match: { color: "light" as const, variant: "subtle" as const }, - css: { - borderTopColor: "@color.gray-300", - borderBottomColor: "@color.gray-300", - "&:dark": { + { + match: { color: "light" as const, variant: "subtle" as const }, + css: { borderTopColor: "@color.gray-300", borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + }, }, }, - }, - // Dark - { - match: { color: "dark" as const, variant: "solid" as const }, - css: { - borderTopColor: "@color.gray-800", - borderBottomColor: "@color.gray-800", - "&:dark": { + // Dark + { + match: { color: "dark" as const, variant: "solid" as const }, + css: { borderTopColor: "@color.gray-800", borderBottomColor: "@color.gray-800", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, }, }, - }, - { - match: { color: "dark" as const, variant: "outline" as const }, - css: { - borderTopColor: "@color.gray-600", - borderBottomColor: "@color.gray-600", - "&:dark": { + { + match: { color: "dark" as const, variant: "outline" as const }, + css: { borderTopColor: "@color.gray-600", borderBottomColor: "@color.gray-600", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, }, }, - }, - { - match: { color: "dark" as const, variant: "soft" as const }, - css: { - borderTopColor: "transparent", - borderBottomColor: "transparent", - "&:dark": { + { + match: { color: "dark" as const, variant: "soft" as const }, + css: { borderTopColor: "transparent", borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, }, }, - }, - { - match: { color: "dark" as const, variant: "subtle" as const }, - css: { - borderTopColor: "@color.gray-600", - borderBottomColor: "@color.gray-600", - "&:dark": { + { + match: { color: "dark" as const, variant: "subtle" as const }, + css: { borderTopColor: "@color.gray-600", borderBottomColor: "@color.gray-600", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, }, }, - }, - // Neutral - { - match: { color: "neutral" as const, variant: "solid" as const }, - css: { - borderTopColor: "@color.gray-200", - borderBottomColor: "@color.gray-200", - "&:dark": { - borderTopColor: "@color.gray-800", - borderBottomColor: "@color.gray-800", + // Neutral + { + match: { color: "neutral" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, }, }, - }, - { - match: { color: "neutral" as const, variant: "outline" as const }, - css: { - borderTopColor: "@color.gray-200", - borderBottomColor: "@color.gray-200", - "&:dark": { - borderTopColor: "@color.gray-800", - borderBottomColor: "@color.gray-800", + { + match: { color: "neutral" as const, variant: "outline" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, }, }, - }, - { - match: { color: "neutral" as const, variant: "soft" as const }, - css: { - borderTopColor: "@color.gray-200", - borderBottomColor: "@color.gray-200", - "&:dark": { - borderTopColor: "@color.gray-700", - borderBottomColor: "@color.gray-700", + { + match: { color: "neutral" as const, variant: "soft" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-700", + borderBottomColor: "@color.gray-700", + }, }, }, - }, - { - match: { color: "neutral" as const, variant: "subtle" as const }, - css: { - borderTopColor: "@color.gray-300", - borderBottomColor: "@color.gray-300", - "&:dark": { - borderTopColor: "@color.gray-600", - borderBottomColor: "@color.gray-600", + { + match: { color: "neutral" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, }, }, + ], + defaultVariants: { + color: "neutral", + variant: "solid", + size: "md", }, - ], - defaultVariants: { - color: "neutral", - variant: "solid", - size: "md", }, -}); + (s) => { + // Collapse bottom border when footer is the last child (overlaps container border) + s.selector(".card-footer:last-child", { + borderBottomWidth: "0", + }); + }, +); diff --git a/theme/src/recipes/card/useCardHeaderRecipe.ts b/theme/src/recipes/card/useCardHeaderRecipe.ts index 649b8d16..d15e499b 100644 --- a/theme/src/recipes/card/useCardHeaderRecipe.ts +++ b/theme/src/recipes/card/useCardHeaderRecipe.ts @@ -203,12 +203,16 @@ export const useCardHeaderRecipe = createUseRecipe( }, }, (s) => { + // Collapse top border when header is the first child (overlaps container border) + s.selector(".card-header:first-child", { + borderTopWidth: "0", + }); // Collapse bottom border when followed by another card part s.selector(".card-header:has(+ .card-body)", { - borderBottomColor: "transparent", + borderBottomWidth: "0", }); s.selector(".card-header:has(+ .card-footer)", { - borderBottomColor: "transparent", + borderBottomWidth: "0", }); }, ); diff --git a/theme/src/recipes/index.ts b/theme/src/recipes/index.ts index 7bd04bf0..4d96e93f 100644 --- a/theme/src/recipes/index.ts +++ b/theme/src/recipes/index.ts @@ -3,3 +3,4 @@ export * from "./button"; export * from "./button-group"; export * from "./callout"; export * from "./card"; +export * from "./modal"; diff --git a/theme/src/recipes/modal/index.ts b/theme/src/recipes/modal/index.ts new file mode 100644 index 00000000..f8f7af0a --- /dev/null +++ b/theme/src/recipes/modal/index.ts @@ -0,0 +1,5 @@ +export * from "./useModalRecipe"; +export * from "./useModalHeaderRecipe"; +export * from "./useModalBodyRecipe"; +export * from "./useModalFooterRecipe"; +export * from "./useModalOverlayRecipe"; diff --git a/theme/src/recipes/modal/useModalBodyRecipe.test.ts b/theme/src/recipes/modal/useModalBodyRecipe.test.ts new file mode 100644 index 00000000..2063bff6 --- /dev/null +++ b/theme/src/recipes/modal/useModalBodyRecipe.test.ts @@ -0,0 +1,184 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { useModalBodyRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "flexDirection", + "gap", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "borderTopWidth", + "borderTopStyle", + "borderTopColor", + "borderBottomWidth", + "borderBottomStyle", + "borderBottomColor", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + return s; +} + +describe("useModalBodyRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("modal-body"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(recipe.base).toEqual({ + display: "flex", + flexDirection: "column", + gap: "@0.5", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "solid", + "soft", + "subtle", + ]); + }); + + it("should have size variants with correct padding", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "md", "lg"]); + expect(recipe.variants!.size.sm).toEqual({ + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + gap: "@0.375", + }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "solid", + size: "md", + }); + }); + + describe("compound variants", () => { + it("should have 9 compound variants total", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + expect(recipe.compoundVariants).toHaveLength(9); + }); + + it("should have correct neutral solid compound variant", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + const neutralSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "solid", + ); + + expect(neutralSolid).toEqual({ + match: { color: "neutral", variant: "solid" }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }); + }); + + it("should have correct light subtle compound variant", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s); + + const lightSubtle = recipe.compoundVariants!.find( + (cv) => cv.match.color === "light" && cv.match.variant === "subtle", + ); + + expect(lightSubtle).toEqual({ + match: { color: "light", variant: "subtle" }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-300", + }, + }, + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s, { + base: { display: "block" }, + }); + + expect(recipe.base!.display).toBe("block"); + expect(recipe.base!.flexDirection).toBe("column"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useModalBodyRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["neutral"]); + expect( + recipe.compoundVariants!.every((cv) => cv.match.color === "neutral"), + ).toBe(true); + expect(recipe.compoundVariants).toHaveLength(3); + }); + }); +}); diff --git a/theme/src/recipes/modal/useModalBodyRecipe.ts b/theme/src/recipes/modal/useModalBodyRecipe.ts new file mode 100644 index 00000000..936bb7a2 --- /dev/null +++ b/theme/src/recipes/modal/useModalBodyRecipe.ts @@ -0,0 +1,177 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Modal body recipe for main content area. + */ +export const useModalBodyRecipe = createUseRecipe( + "modal-body", + { + base: { + display: "flex", + flexDirection: "column", + gap: "@0.5", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + solid: {}, + soft: {}, + subtle: {}, + }, + size: { + sm: { + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + gap: "@0.375", + }, + md: { + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + gap: "@0.5", + }, + lg: { + paddingTop: "@1", + paddingBottom: "@1", + paddingLeft: "@1.25", + paddingRight: "@1.25", + gap: "@0.75", + }, + }, + }, + compoundVariants: [ + // Light + { + match: { color: "light" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + }, + }, + }, + { + match: { color: "light" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "light" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-300", + }, + }, + }, + + // Dark + { + match: { color: "dark" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "dark" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "dark" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, + }, + }, + + // Neutral + { + match: { color: "neutral" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, + }, + }, + ], + defaultVariants: { + color: "neutral", + variant: "solid", + size: "md", + }, + }, + (s) => { + // Collapse bottom border when followed by modal footer + s.selector(".modal-body:has(+ .modal-footer)", { + borderBottomWidth: "0", + }); + }, +); diff --git a/theme/src/recipes/modal/useModalFooterRecipe.test.ts b/theme/src/recipes/modal/useModalFooterRecipe.test.ts new file mode 100644 index 00000000..4b2edac7 --- /dev/null +++ b/theme/src/recipes/modal/useModalFooterRecipe.test.ts @@ -0,0 +1,184 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { useModalFooterRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "gap", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "borderTopWidth", + "borderTopStyle", + "borderTopColor", + "borderBottomWidth", + "borderBottomStyle", + "borderBottomColor", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + return s; +} + +describe("useModalFooterRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("modal-footer"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(recipe.base).toEqual({ + display: "flex", + alignItems: "center", + gap: "@0.75", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "solid", + "soft", + "subtle", + ]); + }); + + it("should have size variants with correct padding", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "md", "lg"]); + expect(recipe.variants!.size.lg).toEqual({ + paddingTop: "@1", + paddingBottom: "@1", + paddingLeft: "@1.25", + paddingRight: "@1.25", + gap: "@1", + }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "solid", + size: "md", + }); + }); + + describe("compound variants", () => { + it("should have 9 compound variants total", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + expect(recipe.compoundVariants).toHaveLength(9); + }); + + it("should have correct neutral solid compound variant", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + const neutralSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "solid", + ); + + expect(neutralSolid).toEqual({ + match: { color: "neutral", variant: "solid" }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }); + }); + + it("should have correct neutral soft compound variant", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s); + + const neutralSoft = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "soft", + ); + + expect(neutralSoft).toEqual({ + match: { color: "neutral", variant: "soft" }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-700", + borderBottomColor: "@color.gray-700", + }, + }, + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s, { + base: { display: "inline-flex" }, + }); + + expect(recipe.base!.display).toBe("inline-flex"); + expect(recipe.base!.alignItems).toBe("center"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useModalFooterRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["neutral"]); + expect( + recipe.compoundVariants!.every((cv) => cv.match.color === "neutral"), + ).toBe(true); + expect(recipe.compoundVariants).toHaveLength(3); + }); + }); +}); diff --git a/theme/src/recipes/modal/useModalFooterRecipe.ts b/theme/src/recipes/modal/useModalFooterRecipe.ts new file mode 100644 index 00000000..9d42a22a --- /dev/null +++ b/theme/src/recipes/modal/useModalFooterRecipe.ts @@ -0,0 +1,177 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Modal footer recipe with top separator. + */ +export const useModalFooterRecipe = createUseRecipe( + "modal-footer", + { + base: { + display: "flex", + alignItems: "center", + gap: "@0.75", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + solid: {}, + soft: {}, + subtle: {}, + }, + size: { + sm: { + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + gap: "@0.5", + }, + md: { + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + gap: "@0.75", + }, + lg: { + paddingTop: "@1", + paddingBottom: "@1", + paddingLeft: "@1.25", + paddingRight: "@1.25", + gap: "@1", + }, + }, + }, + compoundVariants: [ + // Light + { + match: { color: "light" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + }, + }, + }, + { + match: { color: "light" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "light" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + }, + }, + }, + + // Dark + { + match: { color: "dark" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "dark" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "dark" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, + }, + }, + + // Neutral + { + match: { color: "neutral" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "soft" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-700", + borderBottomColor: "@color.gray-700", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, + }, + }, + ], + defaultVariants: { + color: "neutral", + variant: "solid", + size: "md", + }, + }, + (s) => { + // Collapse bottom border when footer is the last child (overlaps container border) + s.selector(".modal-footer:last-child", { + borderBottomWidth: "0", + }); + }, +); diff --git a/theme/src/recipes/modal/useModalHeaderRecipe.test.ts b/theme/src/recipes/modal/useModalHeaderRecipe.test.ts new file mode 100644 index 00000000..05781a9f --- /dev/null +++ b/theme/src/recipes/modal/useModalHeaderRecipe.test.ts @@ -0,0 +1,184 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { useModalHeaderRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "gap", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "borderTopWidth", + "borderTopStyle", + "borderTopColor", + "borderBottomWidth", + "borderBottomStyle", + "borderBottomColor", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + return s; +} + +describe("useModalHeaderRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("modal-header"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(recipe.base).toEqual({ + display: "flex", + alignItems: "center", + gap: "@0.75", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "solid", + "soft", + "subtle", + ]); + }); + + it("should have size variants with correct padding", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "md", "lg"]); + expect(recipe.variants!.size.sm).toEqual({ + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + gap: "@0.5", + }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "solid", + size: "md", + }); + }); + + describe("compound variants", () => { + it("should have 9 compound variants total", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + expect(recipe.compoundVariants).toHaveLength(9); + }); + + it("should have correct neutral solid compound variant", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + const neutralSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "solid", + ); + + expect(neutralSolid).toEqual({ + match: { color: "neutral", variant: "solid" }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }); + }); + + it("should have correct neutral soft compound variant with transparent borders", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s); + + const neutralSoft = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "soft", + ); + + expect(neutralSoft).toEqual({ + match: { color: "neutral", variant: "soft" }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s, { + base: { display: "inline-flex" }, + }); + + expect(recipe.base!.display).toBe("inline-flex"); + expect(recipe.base!.alignItems).toBe("center"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useModalHeaderRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["neutral"]); + expect( + recipe.compoundVariants!.every((cv) => cv.match.color === "neutral"), + ).toBe(true); + expect(recipe.compoundVariants).toHaveLength(3); + }); + }); +}); diff --git a/theme/src/recipes/modal/useModalHeaderRecipe.ts b/theme/src/recipes/modal/useModalHeaderRecipe.ts new file mode 100644 index 00000000..04ba04cc --- /dev/null +++ b/theme/src/recipes/modal/useModalHeaderRecipe.ts @@ -0,0 +1,184 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Modal header recipe with bottom separator. + */ +export const useModalHeaderRecipe = createUseRecipe( + "modal-header", + { + base: { + display: "flex", + alignItems: "center", + gap: "@0.75", + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + borderTopWidth: "@border-width.thin", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + borderBottomWidth: "@border-width.thin", + borderBottomStyle: "@border-style.solid", + borderBottomColor: "transparent", + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + solid: {}, + soft: {}, + subtle: {}, + }, + size: { + sm: { + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + gap: "@0.5", + }, + md: { + paddingTop: "@0.75", + paddingBottom: "@0.75", + paddingLeft: "@1", + paddingRight: "@1", + gap: "@0.75", + }, + lg: { + paddingTop: "@1", + paddingBottom: "@1", + paddingLeft: "@1.25", + paddingRight: "@1.25", + gap: "@1", + }, + }, + }, + compoundVariants: [ + // Light + { + match: { color: "light" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + }, + }, + }, + { + match: { color: "light" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "light" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + }, + }, + }, + + // Dark + { + match: { color: "dark" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "dark" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "dark" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, + }, + }, + + // Neutral + { + match: { color: "neutral" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + borderBottomColor: "@color.gray-200", + "&:dark": { + borderTopColor: "@color.gray-800", + borderBottomColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "soft" as const }, + css: { + borderTopColor: "transparent", + borderBottomColor: "transparent", + "&:dark": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + borderBottomColor: "@color.gray-300", + "&:dark": { + borderTopColor: "@color.gray-600", + borderBottomColor: "@color.gray-600", + }, + }, + }, + ], + defaultVariants: { + color: "neutral", + variant: "solid", + size: "md", + }, + }, + (s) => { + // Collapse top border when header is the first child (overlaps container border) + s.selector(".modal-header:first-child", { + borderTopWidth: "0", + }); + // Collapse bottom border when followed by another modal part + s.selector(".modal-header:has(+ .modal-body)", { + borderBottomWidth: "0", + }); + s.selector(".modal-header:has(+ .modal-footer)", { + borderBottomWidth: "0", + }); + }, +); diff --git a/theme/src/recipes/modal/useModalOverlayRecipe.test.ts b/theme/src/recipes/modal/useModalOverlayRecipe.test.ts new file mode 100644 index 00000000..fb103ccd --- /dev/null +++ b/theme/src/recipes/modal/useModalOverlayRecipe.test.ts @@ -0,0 +1,80 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { useModalOverlayRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "position", + "top", + "left", + "width", + "height", + "background", + "display", + "alignItems", + "justifyContent", + "zIndex", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + return s; +} + +describe("useModalOverlayRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useModalOverlayRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("modal-overlay"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useModalOverlayRecipe(s); + + expect(recipe.base).toEqual({ + position: "fixed", + top: "0", + left: "0", + width: "100%", + height: "100%", + background: "rgba(0, 0, 0, 0.5)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: "@z-index.modal", + "&:dark": { + background: "rgba(0, 0, 0, 0.7)", + }, + }); + }); + + it("should have empty variants", () => { + const s = createInstance(); + const recipe = useModalOverlayRecipe(s); + + expect(recipe.variants).toEqual({}); + }); + + it("should have empty default variants", () => { + const s = createInstance(); + const recipe = useModalOverlayRecipe(s); + + expect(recipe.defaultVariants).toEqual({}); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useModalOverlayRecipe(s, { + base: { position: "absolute" }, + }); + + expect(recipe.base!.position).toBe("absolute"); + expect(recipe.base!.display).toBe("flex"); + }); + }); +}); diff --git a/theme/src/recipes/modal/useModalOverlayRecipe.ts b/theme/src/recipes/modal/useModalOverlayRecipe.ts new file mode 100644 index 00000000..8fc880d2 --- /dev/null +++ b/theme/src/recipes/modal/useModalOverlayRecipe.ts @@ -0,0 +1,24 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Modal overlay/backdrop recipe. + */ +export const useModalOverlayRecipe = createUseRecipe("modal-overlay", { + base: { + position: "fixed", + top: "0", + left: "0", + width: "100%", + height: "100%", + background: "rgba(0, 0, 0, 0.5)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: "@z-index.modal", + "&:dark": { + background: "rgba(0, 0, 0, 0.7)", + }, + }, + variants: {}, + defaultVariants: {}, +}); diff --git a/theme/src/recipes/modal/useModalRecipe.test.ts b/theme/src/recipes/modal/useModalRecipe.test.ts new file mode 100644 index 00000000..d18733f4 --- /dev/null +++ b/theme/src/recipes/modal/useModalRecipe.test.ts @@ -0,0 +1,290 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { useModalRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "flexDirection", + "width", + "maxWidth", + "height", + "borderWidth", + "borderStyle", + "borderColor", + "borderRadius", + "overflow", + "lineHeight", + "boxShadow", + "background", + "color", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + return s; +} + +describe("useModalRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("modal"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(recipe.base).toEqual({ + display: "flex", + flexDirection: "column", + width: "100%", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "transparent", + borderRadius: "@border-radius.lg", + overflow: "hidden", + lineHeight: "@line-height.normal", + boxShadow: "@box-shadow.lg", + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "solid", + "soft", + "subtle", + ]); + }); + + it("should have size variants with correct styles", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(recipe.variants!.size).toEqual({ + sm: { + maxWidth: "400px", + borderRadius: "@border-radius.md", + }, + md: { + maxWidth: "500px", + borderRadius: "@border-radius.lg", + }, + lg: { + maxWidth: "640px", + borderRadius: "@border-radius.lg", + }, + }); + }); + + it("should have fullscreen variants", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(Object.keys(recipe.variants!.fullscreen)).toEqual([ + "true", + "false", + ]); + expect(recipe.variants!.fullscreen.true).toEqual({ + maxWidth: "100%", + height: "100%", + borderRadius: "0", + boxShadow: "none", + }); + expect(recipe.variants!.fullscreen.false).toEqual({}); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "solid", + size: "md", + fullscreen: "false", + }); + }); + + describe("compound variants", () => { + it("should have 9 compound variants total", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + // 3 colors × 3 variants = 9 + expect(recipe.compoundVariants).toHaveLength(9); + }); + + it("should have correct light solid compound variant", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + const lightSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "light" && cv.match.variant === "solid", + ); + + expect(lightSolid).toEqual({ + match: { color: "light", variant: "solid" }, + css: { + background: "@color.white", + color: "@color.text", + borderColor: "@color.gray-200", + "&:dark": { + background: "@color.white", + color: "@color.text-inverted", + borderColor: "@color.gray-200", + }, + }, + }); + }); + + it("should have correct dark solid compound variant", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + const darkSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "dark" && cv.match.variant === "solid", + ); + + expect(darkSolid).toEqual({ + match: { color: "dark", variant: "solid" }, + css: { + background: "@color.gray-900", + color: "@color.text-inverted", + borderColor: "@color.gray-800", + "&:dark": { + background: "@color.gray-900", + color: "@color.text", + borderColor: "@color.gray-800", + }, + }, + }); + }); + + it("should have correct neutral solid compound variant with adaptive dark mode", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + const neutralSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "solid", + ); + + expect(neutralSolid).toEqual({ + match: { color: "neutral", variant: "solid" }, + css: { + background: "@color.white", + color: "@color.text", + borderColor: "@color.gray-200", + "&:dark": { + background: "@color.gray-900", + color: "@color.white", + borderColor: "@color.gray-800", + }, + }, + }); + }); + + it("should have correct neutral subtle compound variant", () => { + const s = createInstance(); + const recipe = useModalRecipe(s); + + const neutralSubtle = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "subtle", + ); + + expect(neutralSubtle).toEqual({ + match: { color: "neutral", variant: "subtle" }, + css: { + background: "@color.gray-100", + color: "@color.gray-700", + borderColor: "@color.gray-300", + "&:dark": { + background: "@color.gray-800", + color: "@color.gray-300", + borderColor: "@color.gray-600", + }, + }, + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useModalRecipe(s, { + base: { display: "inline-flex" }, + }); + + expect(recipe.base!.display).toBe("inline-flex"); + expect(recipe.base!.flexDirection).toBe("column"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useModalRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["neutral"]); + }); + + it("should prune compound variants when filtering colors", () => { + const s = createInstance(); + const recipe = useModalRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect( + recipe.compoundVariants!.every((cv) => cv.match.color === "neutral"), + ).toBe(true); + expect(recipe.compoundVariants).toHaveLength(3); + }); + + it("should filter variant axis", () => { + const s = createInstance(); + const recipe = useModalRecipe(s, { + filter: { variant: ["solid", "soft"] }, + }); + + expect(Object.keys(recipe.variants!.variant)).toEqual(["solid", "soft"]); + expect( + recipe.compoundVariants!.every( + (cv) => cv.match.variant === "solid" || cv.match.variant === "soft", + ), + ).toBe(true); + }); + + it("should adjust default variants when filtered out", () => { + const s = createInstance(); + const recipe = useModalRecipe(s, { + filter: { color: ["light"] }, + }); + + expect(recipe.defaultVariants?.color).toBeUndefined(); + expect(recipe.defaultVariants?.variant).toBe("solid"); + expect(recipe.defaultVariants?.size).toBe("md"); + }); + }); +}); diff --git a/theme/src/recipes/modal/useModalRecipe.ts b/theme/src/recipes/modal/useModalRecipe.ts new file mode 100644 index 00000000..9a21d1a8 --- /dev/null +++ b/theme/src/recipes/modal/useModalRecipe.ts @@ -0,0 +1,179 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Modal container recipe. + * Supports color (light, dark, neutral), variant, size, and fullscreen axes. + */ +export const useModalRecipe = createUseRecipe("modal", { + base: { + display: "flex", + flexDirection: "column", + width: "100%", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "transparent", + borderRadius: "@border-radius.lg", + overflow: "hidden", + lineHeight: "@line-height.normal", + boxShadow: "@box-shadow.lg", + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + solid: {}, + soft: {}, + subtle: {}, + }, + size: { + sm: { + maxWidth: "400px", + borderRadius: "@border-radius.md", + }, + md: { + maxWidth: "500px", + borderRadius: "@border-radius.lg", + }, + lg: { + maxWidth: "640px", + borderRadius: "@border-radius.lg", + }, + }, + fullscreen: { + true: { + maxWidth: "100%", + height: "100%", + borderRadius: "0", + boxShadow: "none", + }, + false: {}, + }, + }, + compoundVariants: [ + // Light color (neutral light-mode values, fixed across themes) + { + match: { color: "light" as const, variant: "solid" as const }, + css: { + background: "@color.white", + color: "@color.text", + borderColor: "@color.gray-200", + "&:dark": { + background: "@color.white", + color: "@color.text-inverted", + borderColor: "@color.gray-200", + }, + }, + }, + { + match: { color: "light" as const, variant: "soft" as const }, + css: { + background: "@color.gray-100", + color: "@color.gray-700", + "&:dark": { + background: "@color.gray-100", + color: "@color.gray-700", + }, + }, + }, + { + match: { color: "light" as const, variant: "subtle" as const }, + css: { + background: "@color.gray-100", + color: "@color.gray-700", + borderColor: "@color.gray-300", + "&:dark": { + background: "@color.gray-100", + color: "@color.gray-700", + borderColor: "@color.gray-300", + }, + }, + }, + + // Dark color (neutral dark-mode values, fixed across themes) + { + match: { color: "dark" as const, variant: "solid" as const }, + css: { + background: "@color.gray-900", + color: "@color.text-inverted", + borderColor: "@color.gray-800", + "&:dark": { + background: "@color.gray-900", + color: "@color.text", + borderColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "dark" as const, variant: "soft" as const }, + css: { + background: "@color.gray-800", + color: "@color.gray-300", + "&:dark": { + background: "@color.gray-800", + color: "@color.gray-300", + }, + }, + }, + { + match: { color: "dark" as const, variant: "subtle" as const }, + css: { + background: "@color.gray-800", + color: "@color.gray-300", + borderColor: "@color.gray-600", + "&:dark": { + background: "@color.gray-800", + color: "@color.gray-300", + borderColor: "@color.gray-600", + }, + }, + }, + + // Neutral color (adaptive: light in light mode, dark in dark mode) + { + match: { color: "neutral" as const, variant: "solid" as const }, + css: { + background: "@color.white", + color: "@color.text", + borderColor: "@color.gray-200", + "&:dark": { + background: "@color.gray-900", + color: "@color.white", + borderColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "soft" as const }, + css: { + background: "@color.gray-100", + color: "@color.gray-700", + "&:dark": { + background: "@color.gray-800", + color: "@color.gray-300", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "subtle" as const }, + css: { + background: "@color.gray-100", + color: "@color.gray-700", + borderColor: "@color.gray-300", + "&:dark": { + background: "@color.gray-800", + color: "@color.gray-300", + borderColor: "@color.gray-600", + }, + }, + }, + ], + defaultVariants: { + color: "neutral", + variant: "solid", + size: "md", + fullscreen: "false", + }, +});