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 cd9443ac..527982f0 100644 --- a/theme/src/recipes/index.ts +++ b/theme/src/recipes/index.ts @@ -3,4 +3,5 @@ export * from "./button"; export * from "./button-group"; export * from "./callout"; export * from "./card"; +export * from "./modal"; export * from "./nav"; 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", + }, +});