diff --git a/.changeset/add-tooltip-recipe.md b/.changeset/add-tooltip-recipe.md new file mode 100644 index 00000000..e9c9ebe0 --- /dev/null +++ b/.changeset/add-tooltip-recipe.md @@ -0,0 +1,11 @@ +--- +"@styleframe/theme": minor +"styleframe": minor +--- + +Add Tooltip recipe with arrow sub-recipe and transform utility + +- Add `useTooltipRecipe` with size (`sm`, `md`, `lg`), variant (`solid`, `soft`, `subtle`), and color (`light`, `dark`, `neutral`) variants +- Add `useTooltipArrowRecipe` with CSS border triangle implementation using `@tooltip.arrow.size` variable and `&:after` pseudo-element for border/fill separation +- Add `useTransformUtility` for arbitrary `transform` CSS property values +- Add Tooltip storybook components, grid previews, and stories including freeform rich content example diff --git a/apps/storybook/src/components/components/tooltip/Tooltip.vue b/apps/storybook/src/components/components/tooltip/Tooltip.vue new file mode 100644 index 00000000..2c5dc139 --- /dev/null +++ b/apps/storybook/src/components/components/tooltip/Tooltip.vue @@ -0,0 +1,38 @@ + + + + + + {{ props.label }} + + + + diff --git a/apps/storybook/src/components/components/tooltip/preview/TooltipGrid.vue b/apps/storybook/src/components/components/tooltip/preview/TooltipGrid.vue new file mode 100644 index 00000000..c29a4932 --- /dev/null +++ b/apps/storybook/src/components/components/tooltip/preview/TooltipGrid.vue @@ -0,0 +1,23 @@ + + + + + + {{ variant }} + + + + + + diff --git a/apps/storybook/src/components/components/tooltip/preview/TooltipSizeGrid.vue b/apps/storybook/src/components/components/tooltip/preview/TooltipSizeGrid.vue new file mode 100644 index 00000000..3d51fbcc --- /dev/null +++ b/apps/storybook/src/components/components/tooltip/preview/TooltipSizeGrid.vue @@ -0,0 +1,23 @@ + + + + + + {{ size }} + + + + + + diff --git a/apps/storybook/stories/components/tooltip.stories.ts b/apps/storybook/stories/components/tooltip.stories.ts new file mode 100644 index 00000000..42e9a8ac --- /dev/null +++ b/apps/storybook/stories/components/tooltip.stories.ts @@ -0,0 +1,161 @@ +import type { Meta, StoryObj } from "@storybook/vue3-vite"; + +import Tooltip from "@/components/components/tooltip/Tooltip.vue"; +import TooltipGrid from "@/components/components/tooltip/preview/TooltipGrid.vue"; +import TooltipSizeGrid from "@/components/components/tooltip/preview/TooltipSizeGrid.vue"; + +const colors = ["light", "dark", "neutral"] as const; +const variants = ["solid", "soft", "subtle"] as const; +const sizes = ["sm", "md", "lg"] as const; + +const meta = { + title: "Theme/Recipes/Tooltip", + component: Tooltip, + tags: ["autodocs"], + parameters: { + layout: "padded", + }, + argTypes: { + color: { + control: "select", + options: colors, + description: "The color variant of the tooltip", + }, + variant: { + control: "select", + options: variants, + description: "The visual style variant", + }, + size: { + control: "select", + options: sizes, + description: "The size of the tooltip", + }, + label: { + control: "text", + description: "The text content of the tooltip", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + color: "dark", + variant: "solid", + size: "md", + label: "Tooltip", + }, +}; + +export const AllVariants: StoryObj = { + render: () => ({ + components: { TooltipGrid }, + template: "", + }), +}; + +export const AllSizes: StoryObj = { + render: () => ({ + components: { TooltipSizeGrid }, + template: "", + }), +}; + +export const Freeform: StoryObj = { + render: (args) => ({ + components: { Tooltip }, + setup() { + return { args }; + }, + template: ` + + + + Freeform Tooltip + Tooltips can contain text of virtually any size. This is an example of a freeform tooltip with rich content. + + + + + Light Freeform + This is a light freeform tooltip with formatted text and bold content. + + + + `, + }), + args: { + color: "dark", + variant: "solid", + size: "md", + }, +}; + +// Individual color stories +export const Light: Story = { + args: { + color: "light", + label: "Light Tooltip", + }, +}; + +export const Dark: Story = { + args: { + color: "dark", + label: "Dark Tooltip", + }, +}; + +export const Neutral: Story = { + args: { + color: "neutral", + label: "Neutral Tooltip", + }, +}; + +// Variant stories +export const Solid: Story = { + args: { + variant: "solid", + label: "Solid Tooltip", + }, +}; + +export const Soft: Story = { + args: { + variant: "soft", + label: "Soft Tooltip", + }, +}; + +export const Subtle: Story = { + args: { + variant: "subtle", + label: "Subtle Tooltip", + }, +}; + +// Size stories +export const Small: Story = { + args: { + size: "sm", + label: "Small Tooltip", + }, +}; + +export const Medium: Story = { + args: { + size: "md", + label: "Medium Tooltip", + }, +}; + +export const Large: Story = { + args: { + size: "lg", + label: "Large Tooltip", + }, +}; diff --git a/apps/storybook/stories/components/tooltip.styleframe.ts b/apps/storybook/stories/components/tooltip.styleframe.ts new file mode 100644 index 00000000..088de6c1 --- /dev/null +++ b/apps/storybook/stories/components/tooltip.styleframe.ts @@ -0,0 +1,52 @@ +import { useTooltipRecipe, useTooltipArrowRecipe } from "@styleframe/theme"; +import { styleframe } from "virtual:styleframe"; + +const s = styleframe(); +const { selector } = s; + +// Initialize tooltip recipes +export const tooltip = useTooltipRecipe(s); +export const tooltipArrow = useTooltipArrowRecipe(s); + +// Container styles for story layout +selector(".tooltip-grid", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", + padding: "@spacing.md", + alignItems: "center", +}); + +selector(".tooltip-section", { + display: "flex", + flexDirection: "column", + gap: "@spacing.lg", + padding: "@spacing.md", +}); + +selector(".tooltip-row", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.sm", + alignItems: "center", +}); + +selector(".tooltip-label", { + fontSize: "@font-size.sm", + fontWeight: "@font-weight.semibold", + minWidth: "80px", +}); + +selector(".tooltip-wrapper", { + position: "relative", + display: "inline-flex", + flexDirection: "column", + alignItems: "center", +}); + +selector(".tooltip-arrow-position", { + bottom: "calc(@tooltip.arrow.size * -1)", + left: "calc(50% - @tooltip.arrow.size)", +}); + +export default s; diff --git a/engine/core/src/tokens/utility.ts b/engine/core/src/tokens/utility.ts index eb7791b3..ca79ee5c 100644 --- a/engine/core/src/tokens/utility.ts +++ b/engine/core/src/tokens/utility.ts @@ -64,6 +64,16 @@ function createNamespaceAutogenerate( }; } } + + // Check if variable exists with its exact name (cross-namespace reference) + if (root.variables.some((v) => v.name === variableName)) { + return { + [variableName]: { + type: "reference", + name: variableName, + } satisfies Reference, + }; + } } // No variable found — fall back to first namespace as default @@ -200,7 +210,15 @@ export function createUtilityFunction(parent: Container, root: Root) { } if (!found) { - value = key; + // Check if variable exists with exact key name (cross-namespace reference) + if (root.variables.some((v) => v.name === key)) { + value = { + type: "reference", + name: key, + } satisfies Reference; + } else { + value = key; + } } } } diff --git a/theme/src/recipes/index.ts b/theme/src/recipes/index.ts index 527982f0..458672bf 100644 --- a/theme/src/recipes/index.ts +++ b/theme/src/recipes/index.ts @@ -5,3 +5,4 @@ export * from "./callout"; export * from "./card"; export * from "./modal"; export * from "./nav"; +export * from "./tooltip"; diff --git a/theme/src/recipes/tooltip/index.ts b/theme/src/recipes/tooltip/index.ts new file mode 100644 index 00000000..0f7b6799 --- /dev/null +++ b/theme/src/recipes/tooltip/index.ts @@ -0,0 +1 @@ +export * from "./useTooltipRecipe"; diff --git a/theme/src/recipes/tooltip/useTooltipRecipe.test.ts b/theme/src/recipes/tooltip/useTooltipRecipe.test.ts new file mode 100644 index 00000000..e404076b --- /dev/null +++ b/theme/src/recipes/tooltip/useTooltipRecipe.test.ts @@ -0,0 +1,509 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { useAfterModifier } from "../../modifiers/usePseudoElementModifiers"; +import { useTooltipRecipe, useTooltipArrowRecipe } from "./useTooltipRecipe"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "borderWidth", + "borderStyle", + "borderColor", + "fontWeight", + "fontSize", + "lineHeight", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "borderRadius", + "boxShadow", + "zIndex", + "maxWidth", + "background", + "color", + "width", + "height", + "borderLeftWidth", + "borderLeftStyle", + "borderLeftColor", + "borderRightWidth", + "borderRightStyle", + "borderRightColor", + "borderTopWidth", + "borderTopStyle", + "borderTopColor", + "position", + "left", + "top", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + useAfterModifier(s); + return s; +} + +describe("useTooltipRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("tooltip"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + expect(recipe.base).toEqual({ + display: "inline-flex", + alignItems: "center", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "transparent", + fontWeight: "@font-weight.medium", + fontSize: "@font-size.sm", + lineHeight: "@line-height.normal", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.625", + paddingRight: "@0.625", + borderRadius: "@border-radius.md", + boxShadow: "@box-shadow.sm", + zIndex: "@z-index.tooltip", + maxWidth: "240px", + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "solid", + "soft", + "subtle", + ]); + }); + + it("should have size variants with correct styles", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + expect(recipe.variants!.size).toEqual({ + sm: { + fontSize: "@font-size.xs", + paddingTop: "@0.25", + paddingBottom: "@0.25", + paddingLeft: "@0.5", + paddingRight: "@0.5", + borderRadius: "@border-radius.sm", + }, + md: { + fontSize: "@font-size.sm", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.625", + paddingRight: "@0.625", + borderRadius: "@border-radius.md", + }, + lg: { + fontSize: "@font-size.md", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + borderRadius: "@border-radius.md", + }, + }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "dark", + variant: "solid", + size: "md", + }); + }); + + describe("compound variants", () => { + it("should have 9 compound variants total", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s); + + // 3 colors × 3 variants = 9 + expect(recipe.compoundVariants).toHaveLength(9); + }); + + it("should have correct dark/solid compound variant", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(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 light/solid compound variant", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(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 neutral/solid compound variant with adaptive dark mode", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(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 with adaptive dark mode", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(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 = useTooltipRecipe(s, { + base: { maxWidth: "320px" }, + }); + + expect(recipe.base!.maxWidth).toBe("320px"); + expect(recipe.base!.display).toBe("inline-flex"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s, { + filter: { color: ["dark", "light"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["light", "dark"]); + }); + + it("should prune compound variants when filtering colors", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(s, { + filter: { color: ["dark"] }, + }); + + expect( + recipe.compoundVariants!.every((cv) => cv.match.color === "dark"), + ).toBe(true); + expect(recipe.compoundVariants).toHaveLength(3); + }); + + it("should filter variant axis", () => { + const s = createInstance(); + const recipe = useTooltipRecipe(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 = useTooltipRecipe(s, { + filter: { color: ["light"] }, + }); + + expect(recipe.defaultVariants?.color).toBeUndefined(); + expect(recipe.defaultVariants?.variant).toBe("solid"); + expect(recipe.defaultVariants?.size).toBe("md"); + }); + }); +}); + +describe("useTooltipArrowRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("tooltip-arrow"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + expect(recipe.base).toEqual({ + width: "0", + height: "0", + borderLeftWidth: "calc(@tooltip.arrow.size + 1px)", + borderLeftStyle: "@border-style.solid", + borderLeftColor: "transparent", + borderRightWidth: "calc(@tooltip.arrow.size + 1px)", + borderRightStyle: "@border-style.solid", + borderRightColor: "transparent", + borderTopWidth: "calc(@tooltip.arrow.size + 1px)", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + position: "absolute", + zIndex: "@z-index.tooltip", + "&:after": { + borderLeftWidth: "@tooltip.arrow.size", + borderLeftStyle: "@border-style.solid", + borderLeftColor: "transparent", + borderRightWidth: "@tooltip.arrow.size", + borderRightStyle: "@border-style.solid", + borderRightColor: "transparent", + borderTopWidth: "@tooltip.arrow.size", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + position: "absolute", + left: "calc(@tooltip.arrow.size * -1)", + top: "calc(@tooltip.arrow.size * -1 - 1px)", + zIndex: "0", + }, + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "solid", + "soft", + "subtle", + ]); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "dark", + variant: "solid", + }); + }); + + describe("compound variants", () => { + it("should have 9 compound variants total", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + // 3 colors × 3 variants = 9 + expect(recipe.compoundVariants).toHaveLength(9); + }); + + it("should have correct dark/solid compound variant", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + const darkSolid = recipe.compoundVariants!.find( + (cv) => cv.match.color === "dark" && cv.match.variant === "solid", + ); + + expect(darkSolid).toEqual({ + match: { color: "dark", variant: "solid" }, + css: { + borderTopColor: "@color.gray-800", + "&:after": { + borderTopColor: "@color.gray-900", + }, + "&:dark": { + borderTopColor: "@color.gray-800", + }, + "&:dark:after": { + borderTopColor: "@color.gray-900", + }, + }, + }); + }); + + it("should have correct neutral/solid compound variant with adaptive dark mode", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(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", + "&:after": { + borderTopColor: "@color.white", + }, + "&:dark": { + borderTopColor: "@color.gray-800", + }, + "&:dark:after": { + borderTopColor: "@color.gray-900", + }, + }, + }); + }); + + it("should have correct neutral/subtle compound variant", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s); + + const neutralSubtle = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "subtle", + ); + + expect(neutralSubtle).toEqual({ + match: { color: "neutral", variant: "subtle" }, + css: { + borderTopColor: "@color.gray-300", + "&:after": { + borderTopColor: "@color.gray-100", + }, + "&:dark": { + borderTopColor: "@color.gray-600", + }, + "&:dark:after": { + borderTopColor: "@color.gray-800", + }, + }, + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s, { + base: { width: "12px" }, + }); + + expect(recipe.base!.width).toBe("12px"); + expect(recipe.base!.height).toBe("0"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s, { + filter: { color: ["dark"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["dark"]); + expect(recipe.compoundVariants).toHaveLength(3); + }); + + it("should adjust default variants when filtered out", () => { + const s = createInstance(); + const recipe = useTooltipArrowRecipe(s, { + filter: { color: ["light"] }, + }); + + expect(recipe.defaultVariants?.color).toBeUndefined(); + expect(recipe.defaultVariants?.variant).toBe("solid"); + }); + }); +}); diff --git a/theme/src/recipes/tooltip/useTooltipRecipe.ts b/theme/src/recipes/tooltip/useTooltipRecipe.ts new file mode 100644 index 00000000..123b026d --- /dev/null +++ b/theme/src/recipes/tooltip/useTooltipRecipe.ts @@ -0,0 +1,388 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Tooltip content bubble recipe. + * Supports color (light, dark, neutral), variant, and size axes. + */ +export const useTooltipRecipe = createUseRecipe("tooltip", { + base: { + display: "inline-flex", + alignItems: "center", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "transparent", + fontWeight: "@font-weight.medium", + fontSize: "@font-size.sm", + lineHeight: "@line-height.normal", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.625", + paddingRight: "@0.625", + borderRadius: "@border-radius.md", + boxShadow: "@box-shadow.sm", + zIndex: "@z-index.tooltip", + maxWidth: "240px", + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + solid: {}, + soft: {}, + subtle: {}, + }, + size: { + sm: { + fontSize: "@font-size.xs", + paddingTop: "@0.25", + paddingBottom: "@0.25", + paddingLeft: "@0.5", + paddingRight: "@0.5", + borderRadius: "@border-radius.sm", + }, + md: { + fontSize: "@font-size.sm", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.625", + paddingRight: "@0.625", + borderRadius: "@border-radius.md", + }, + lg: { + fontSize: "@font-size.md", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + borderRadius: "@border-radius.md", + }, + }, + }, + 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: "dark", + variant: "solid", + size: "md", + }, +}); + +/** + * Tooltip arrow recipe. + * Arrow background and border match the tooltip content. + */ +export const useTooltipArrowRecipe = createUseRecipe( + "tooltip-arrow", + { + base: { + width: "0", + height: "0", + borderLeftWidth: "calc(@tooltip.arrow.size + 1px)", + borderLeftStyle: "@border-style.solid", + borderLeftColor: "transparent", + borderRightWidth: "calc(@tooltip.arrow.size + 1px)", + borderRightStyle: "@border-style.solid", + borderRightColor: "transparent", + borderTopWidth: "calc(@tooltip.arrow.size + 1px)", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + position: "absolute", + zIndex: "@z-index.tooltip", + "&:after": { + borderLeftWidth: "@tooltip.arrow.size", + borderLeftStyle: "@border-style.solid", + borderLeftColor: "transparent", + borderRightWidth: "@tooltip.arrow.size", + borderRightStyle: "@border-style.solid", + borderRightColor: "transparent", + borderTopWidth: "@tooltip.arrow.size", + borderTopStyle: "@border-style.solid", + borderTopColor: "transparent", + position: "absolute", + left: "calc(@tooltip.arrow.size * -1)", + top: "calc(@tooltip.arrow.size * -1 - 1px)", + zIndex: "0", + }, + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + solid: {}, + soft: {}, + subtle: {}, + }, + }, + compoundVariants: [ + // Light color (fixed across themes) + { + match: { color: "light" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + "&:after": { + borderTopColor: "@color.white", + }, + "&:dark": { + borderTopColor: "@color.gray-200", + }, + "&:dark:after": { + borderTopColor: "@color.white", + }, + }, + }, + { + match: { color: "light" as const, variant: "soft" as const }, + css: { + borderTopColor: "@color.gray-100", + "&:after": { + borderTopColor: "@color.gray-100", + }, + "&:dark": { + borderTopColor: "@color.gray-100", + }, + "&:dark:after": { + borderTopColor: "@color.gray-100", + }, + }, + }, + { + match: { color: "light" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + "&:after": { + borderTopColor: "@color.gray-100", + }, + "&:dark": { + borderTopColor: "@color.gray-300", + }, + "&:dark:after": { + borderTopColor: "@color.gray-100", + }, + }, + }, + + // Dark color (fixed across themes) + { + match: { color: "dark" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-800", + "&:after": { + borderTopColor: "@color.gray-900", + }, + "&:dark": { + borderTopColor: "@color.gray-800", + }, + "&:dark:after": { + borderTopColor: "@color.gray-900", + }, + }, + }, + { + match: { color: "dark" as const, variant: "soft" as const }, + css: { + borderTopColor: "@color.gray-800", + "&:after": { + borderTopColor: "@color.gray-800", + }, + "&:dark": { + borderTopColor: "@color.gray-800", + }, + "&:dark:after": { + borderTopColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "dark" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-600", + "&:after": { + borderTopColor: "@color.gray-800", + }, + "&:dark": { + borderTopColor: "@color.gray-600", + }, + "&:dark:after": { + borderTopColor: "@color.gray-800", + }, + }, + }, + + // Neutral color (adaptive) + { + match: { color: "neutral" as const, variant: "solid" as const }, + css: { + borderTopColor: "@color.gray-200", + "&:after": { + borderTopColor: "@color.white", + }, + "&:dark": { + borderTopColor: "@color.gray-800", + }, + "&:dark:after": { + borderTopColor: "@color.gray-900", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "soft" as const }, + css: { + borderTopColor: "@color.gray-100", + "&:after": { + borderTopColor: "@color.gray-100", + }, + "&:dark": { + borderTopColor: "@color.gray-800", + }, + "&:dark:after": { + borderTopColor: "@color.gray-800", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "subtle" as const }, + css: { + borderTopColor: "@color.gray-300", + "&:after": { + borderTopColor: "@color.gray-100", + }, + "&:dark": { + borderTopColor: "@color.gray-600", + }, + "&:dark:after": { + borderTopColor: "@color.gray-800", + }, + }, + }, + ], + defaultVariants: { + color: "dark", + variant: "solid", + }, + }, + (s) => { + s.variable("tooltip.arrow.size", "5px", { default: true }); + }, +); diff --git a/theme/src/utilities/transforms/useTransformUtility.ts b/theme/src/utilities/transforms/useTransformUtility.ts index 1602926d..fac4d69e 100644 --- a/theme/src/utilities/transforms/useTransformUtility.ts +++ b/theme/src/utilities/transforms/useTransformUtility.ts @@ -181,3 +181,13 @@ export const useTranslateZUtility = createUseUtility( "translate3d(var(--transform-translate-x), var(--transform-translate-y), var(--transform-translate-z)) rotate(var(--transform-rotate)) skewX(var(--transform-skew-x)) skewY(var(--transform-skew-y)) scaleX(var(--transform-scale-x)) scaleY(var(--transform-scale-y))", }), ); + +/** + * Create transform utility classes. + */ +export const useTransformUtility = createUseUtility( + "transform", + ({ value }) => ({ + transform: value, + }), +); diff --git a/theme/src/utils/createUseRecipe.ts b/theme/src/utils/createUseRecipe.ts index 50b8b1ec..0cf051bf 100644 --- a/theme/src/utils/createUseRecipe.ts +++ b/theme/src/utils/createUseRecipe.ts @@ -116,6 +116,10 @@ export function createUseRecipe< s: Styleframe, options?: DeepPartial> & { filter?: F }, ): Recipe> { + if (setup) { + setup(s); + } + const { filter, ...configOverrides } = options ?? {}; const merged = defu(configOverrides, defaults) as RecipeConfig; const filtered = filter ? applyFilter(merged, filter) : merged; @@ -124,10 +128,6 @@ export function createUseRecipe< ...filtered, }) as Recipe>; - if (setup) { - setup(s); - } - return recipe; }; }
Tooltips can contain text of virtually any size. This is an example of a freeform tooltip with rich content.
This is a light freeform tooltip with formatted text and bold content.