From 01a995c60963b96d226ac243157119108a51bf6d Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Sun, 5 Apr 2026 10:15:35 +0700 Subject: [PATCH 1/3] feat: Add Nav recipe with list-style utility Add Nav and NavItem multi-part recipes with size, color, and variant support. Include list-style shorthand utility and Storybook stories. --- .../src/components/components/nav/Nav.vue | 24 + .../src/components/components/nav/NavItem.vue | 33 ++ .../components/nav/preview/NavGrid.vue | 45 ++ .../components/nav/preview/NavSizeGrid.vue | 20 + .../stories/components/nav.stories.ts | 262 +++++++++++ .../stories/components/nav.styleframe.ts | 39 ++ theme/src/presets/useUtilitiesPreset.ts | 4 + theme/src/recipes/index.ts | 1 + theme/src/recipes/nav/index.ts | 2 + .../src/recipes/nav/useNavItemRecipe.test.ts | 435 ++++++++++++++++++ theme/src/recipes/nav/useNavItemRecipe.ts | 298 ++++++++++++ theme/src/recipes/nav/useNavRecipe.test.ts | 186 ++++++++ theme/src/recipes/nav/useNavRecipe.ts | 56 +++ .../typography/useListStyleUtility.test.ts | 20 + .../typography/useListStyleUtility.ts | 10 + 15 files changed, 1435 insertions(+) create mode 100644 apps/storybook/src/components/components/nav/Nav.vue create mode 100644 apps/storybook/src/components/components/nav/NavItem.vue create mode 100644 apps/storybook/src/components/components/nav/preview/NavGrid.vue create mode 100644 apps/storybook/src/components/components/nav/preview/NavSizeGrid.vue create mode 100644 apps/storybook/stories/components/nav.stories.ts create mode 100644 apps/storybook/stories/components/nav.styleframe.ts create mode 100644 theme/src/recipes/nav/index.ts create mode 100644 theme/src/recipes/nav/useNavItemRecipe.test.ts create mode 100644 theme/src/recipes/nav/useNavItemRecipe.ts create mode 100644 theme/src/recipes/nav/useNavRecipe.test.ts create mode 100644 theme/src/recipes/nav/useNavRecipe.ts diff --git a/apps/storybook/src/components/components/nav/Nav.vue b/apps/storybook/src/components/components/nav/Nav.vue new file mode 100644 index 00000000..c3c480e9 --- /dev/null +++ b/apps/storybook/src/components/components/nav/Nav.vue @@ -0,0 +1,24 @@ + + + diff --git a/apps/storybook/src/components/components/nav/NavItem.vue b/apps/storybook/src/components/components/nav/NavItem.vue new file mode 100644 index 00000000..5f9f3c7f --- /dev/null +++ b/apps/storybook/src/components/components/nav/NavItem.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/storybook/src/components/components/nav/preview/NavGrid.vue b/apps/storybook/src/components/components/nav/preview/NavGrid.vue new file mode 100644 index 00000000..32772bab --- /dev/null +++ b/apps/storybook/src/components/components/nav/preview/NavGrid.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/storybook/src/components/components/nav/preview/NavSizeGrid.vue b/apps/storybook/src/components/components/nav/preview/NavSizeGrid.vue new file mode 100644 index 00000000..5e1c29d8 --- /dev/null +++ b/apps/storybook/src/components/components/nav/preview/NavSizeGrid.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/storybook/stories/components/nav.stories.ts b/apps/storybook/stories/components/nav.stories.ts new file mode 100644 index 00000000..2b81da9b --- /dev/null +++ b/apps/storybook/stories/components/nav.stories.ts @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from "@storybook/vue3-vite"; + +import Nav from "../../src/components/components/nav/Nav.vue"; +import NavItem from "../../src/components/components/nav/NavItem.vue"; +import NavGrid from "../../src/components/components/nav/preview/NavGrid.vue"; +import NavSizeGrid from "../../src/components/components/nav/preview/NavSizeGrid.vue"; + +const colors = ["light", "dark", "neutral"] as const; +const variants = ["ghost", "link"] as const; +const sizes = ["sm", "md", "lg"] as const; +const orientations = ["horizontal", "vertical"] as const; + +const meta = { + title: "Theme/Recipes/Nav", + component: Nav, + tags: ["autodocs"], + parameters: { + layout: "padded", + }, + argTypes: { + orientation: { + control: "select", + options: orientations, + description: "The orientation of the nav", + }, + size: { + control: "select", + options: sizes, + description: "The size", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + components: { Nav, NavItem }, + setup() { + return { args }; + }, + template: ` + + `, + }), +}; + +export const AllVariants: StoryObj = { + render: () => ({ + components: { NavGrid }, + template: "", + }), +}; + +export const AllSizes: StoryObj = { + render: () => ({ + components: { NavSizeGrid }, + template: "", + }), +}; + +// Orientation stories + +export const Horizontal: Story = { + args: { + orientation: "horizontal", + }, + render: (args) => ({ + components: { Nav, NavItem }, + setup() { + return { args }; + }, + template: ` + + `, + }), +}; + +export const Vertical: Story = { + args: { + orientation: "vertical", + }, + render: (args) => ({ + components: { Nav, NavItem }, + setup() { + return { args }; + }, + template: ` + + `, + }), +}; + +// Color stories + +export const Light: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; + +export const Dark: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; + +export const Neutral: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; + +// Variant stories + +export const Ghost: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; + +export const Link: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; + +// Size stories + +export const Small: Story = { + args: { + size: "sm", + }, + render: (args) => ({ + components: { Nav, NavItem }, + setup() { + return { args }; + }, + template: ` + + `, + }), +}; + +export const Medium: Story = { + args: { + size: "md", + }, + render: (args) => ({ + components: { Nav, NavItem }, + setup() { + return { args }; + }, + template: ` + + `, + }), +}; + +export const Large: Story = { + args: { + size: "lg", + }, + render: (args) => ({ + components: { Nav, NavItem }, + setup() { + return { args }; + }, + template: ` + + `, + }), +}; + +// Feature stories + +export const Active: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; + +export const Disabled: Story = { + render: () => ({ + components: { Nav, NavItem }, + template: ` + + `, + }), +}; diff --git a/apps/storybook/stories/components/nav.styleframe.ts b/apps/storybook/stories/components/nav.styleframe.ts new file mode 100644 index 00000000..044ecf89 --- /dev/null +++ b/apps/storybook/stories/components/nav.styleframe.ts @@ -0,0 +1,39 @@ +import { useNavRecipe, useNavItemRecipe } from "@styleframe/theme"; +import { styleframe } from "virtual:styleframe"; + +const s = styleframe(); +const { selector } = s; + +export const nav = useNavRecipe(s); +export const navItem = useNavItemRecipe(s); + +// Layout selectors for story grid previews +selector(".nav-grid", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", + padding: "@spacing.md", + alignItems: "flex-start", +}); + +selector(".nav-section", { + display: "flex", + flexDirection: "column", + gap: "@spacing.lg", + padding: "@spacing.md", +}); + +selector(".nav-row", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.sm", + alignItems: "flex-start", +}); + +selector(".nav-label", { + fontSize: "@font-size.sm", + fontWeight: "@font-weight.semibold", + minWidth: "80px", +}); + +export default s; diff --git a/theme/src/presets/useUtilitiesPreset.ts b/theme/src/presets/useUtilitiesPreset.ts index c8233129..8b931ef3 100644 --- a/theme/src/presets/useUtilitiesPreset.ts +++ b/theme/src/presets/useUtilitiesPreset.ts @@ -437,6 +437,7 @@ import { useListStyleImageUtility, useListStylePositionUtility, useListStyleTypeUtility, + useListStyleUtility, useOverflowWrapUtility, useTextAlignUtility, useTextColorUtility, @@ -1523,6 +1524,8 @@ export function useUtilitiesPreset( ); if (hyphens) createHyphensUtility(hyphens); + const createListStyleUtility = useListStyleUtility(s); + const createListStylePositionUtility = useListStylePositionUtility( s, undefined, @@ -2876,6 +2879,7 @@ export function useUtilitiesPreset( undefined, resolveUtilityOptions("line-height"), ), + createListStyleUtility, createListStyleImageUtility: useListStyleImageUtility( s, undefined, diff --git a/theme/src/recipes/index.ts b/theme/src/recipes/index.ts index 7bd04bf0..cd9443ac 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 "./nav"; diff --git a/theme/src/recipes/nav/index.ts b/theme/src/recipes/nav/index.ts new file mode 100644 index 00000000..d07a8983 --- /dev/null +++ b/theme/src/recipes/nav/index.ts @@ -0,0 +1,2 @@ +export * from "./useNavRecipe"; +export * from "./useNavItemRecipe"; diff --git a/theme/src/recipes/nav/useNavItemRecipe.test.ts b/theme/src/recipes/nav/useNavItemRecipe.test.ts new file mode 100644 index 00000000..cea4e047 --- /dev/null +++ b/theme/src/recipes/nav/useNavItemRecipe.test.ts @@ -0,0 +1,435 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { + useHoverModifier, + useFocusModifier, + useFocusVisibleModifier, + useActiveModifier, +} from "../../modifiers/usePseudoStateModifiers"; +import { useDisabledModifier } from "../../modifiers/useFormStateModifiers"; +import { useNavItemRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "cursor", + "background", + "fontWeight", + "lineHeight", + "textDecoration", + "transitionProperty", + "transitionTimingFunction", + "transitionDuration", + "outline", + "outlineWidth", + "outlineStyle", + "outlineColor", + "outlineOffset", + "opacity", + "pointerEvents", + "fontSize", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "color", + "borderColor", + "borderWidth", + "borderRadius", + "textUnderlineOffset", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useDarkModifier(s); + useHoverModifier(s); + useFocusModifier(s); + useFocusVisibleModifier(s); + useActiveModifier(s); + useDisabledModifier(s); + return s; +} + +describe("useNavItemRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("nav-item"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + expect(recipe.base).toEqual({ + display: "inline-flex", + alignItems: "center", + cursor: "pointer", + background: "transparent", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + textDecoration: "none", + transitionProperty: "color, background-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + outline: "none", + "&:hover": { + textDecoration: "none", + }, + "&:focus": { + textDecoration: "none", + }, + "&:focus-visible": { + outlineWidth: "2px", + outlineStyle: "solid", + outlineColor: "@color.primary", + outlineOffset: "2px", + }, + "&:disabled": { + cursor: "not-allowed", + opacity: "0.5", + pointerEvents: "none", + }, + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual(["ghost", "link"]); + }); + + it("should have size variants with correct styles", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + expect(recipe.variants!.size).toEqual({ + sm: { + fontSize: "@font-size.xs", + paddingTop: "@0.25", + paddingBottom: "@0.25", + paddingLeft: "@0.5", + paddingRight: "@0.5", + }, + md: { + fontSize: "@font-size.sm", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.75", + paddingRight: "@0.75", + }, + lg: { + fontSize: "@font-size.md", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@1", + paddingRight: "@1", + }, + }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "ghost", + size: "md", + }); + }); + + describe("compound variants", () => { + it("should have 6 compound variants total", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + // 3 colors × 2 variants = 6 + expect(recipe.compoundVariants).toHaveLength(6); + }); + + it("should have correct light ghost compound variant", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + const lightGhost = recipe.compoundVariants!.find( + (cv) => cv.match.color === "light" && cv.match.variant === "ghost", + ); + + expect(lightGhost).toEqual({ + match: { color: "light", variant: "ghost" }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:focus": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:active": { + background: "@color.gray-200", + }, + "&:dark": { + color: "@color.text-inverted", + }, + "&:dark:hover": { + color: "@color.text-inverted", + background: "@color.gray-100", + }, + "&:dark:focus": { + color: "@color.text-inverted", + background: "@color.gray-100", + }, + "&:dark:active": { + background: "@color.gray-200", + }, + }, + }); + }); + + it("should have correct light link compound variant", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + const lightLink = recipe.compoundVariants!.find( + (cv) => cv.match.color === "light" && cv.match.variant === "link", + ); + + expect(lightLink).toEqual({ + match: { color: "light", variant: "link" }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:focus": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:active": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:dark": { + color: "@color.text-inverted", + }, + "&:dark:hover": { + color: "@color.gray-900", + }, + "&:dark:focus": { + color: "@color.gray-900", + }, + "&:dark:active": { + color: "@color.gray-900", + }, + }, + }); + }); + + it("should have correct dark ghost compound variant", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + const darkGhost = recipe.compoundVariants!.find( + (cv) => cv.match.color === "dark" && cv.match.variant === "ghost", + ); + + expect(darkGhost).toEqual({ + match: { color: "dark", variant: "ghost" }, + css: { + color: "@color.gray-200", + "&:hover": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:focus": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:active": { + background: "@color.gray-750", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:focus": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:active": { + background: "@color.gray-750", + }, + }, + }); + }); + + it("should have correct neutral ghost compound variant with adaptive dark mode", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + const neutralGhost = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "ghost", + ); + + expect(neutralGhost).toEqual({ + match: { color: "neutral", variant: "ghost" }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:focus": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:active": { + background: "@color.gray-200", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:focus": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:active": { + background: "@color.gray-750", + }, + }, + }); + }); + + it("should have correct neutral link compound variant with adaptive dark mode", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s); + + const neutralLink = recipe.compoundVariants!.find( + (cv) => cv.match.color === "neutral" && cv.match.variant === "link", + ); + + expect(neutralLink).toEqual({ + match: { color: "neutral", variant: "link" }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:focus": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:active": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.white", + }, + "&:dark:focus": { + color: "@color.white", + }, + "&:dark:active": { + color: "@color.white", + }, + }, + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s, { + base: { display: "flex" }, + }); + + expect(recipe.base!.display).toBe("flex"); + expect(recipe.base!.cursor).toBe("pointer"); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(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 = useNavItemRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect( + recipe.compoundVariants!.every((cv) => cv.match.color === "neutral"), + ).toBe(true); + expect(recipe.compoundVariants).toHaveLength(2); + }); + + it("should filter variant axis", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s, { + filter: { variant: ["ghost"] }, + }); + + expect(Object.keys(recipe.variants!.variant)).toEqual(["ghost"]); + expect( + recipe.compoundVariants!.every((cv) => cv.match.variant === "ghost"), + ).toBe(true); + }); + + it("should adjust default variants when filtered out", () => { + const s = createInstance(); + const recipe = useNavItemRecipe(s, { + filter: { color: ["light"] }, + }); + + expect(recipe.defaultVariants?.color).toBeUndefined(); + expect(recipe.defaultVariants?.variant).toBe("ghost"); + expect(recipe.defaultVariants?.size).toBe("md"); + }); + }); +}); diff --git a/theme/src/recipes/nav/useNavItemRecipe.ts b/theme/src/recipes/nav/useNavItemRecipe.ts new file mode 100644 index 00000000..449de7a9 --- /dev/null +++ b/theme/src/recipes/nav/useNavItemRecipe.ts @@ -0,0 +1,298 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Nav item recipe for individual navigation links. + * Supports color (light, dark, neutral), variant (ghost, link), and size axes. + */ +export const useNavItemRecipe = createUseRecipe( + "nav-item", + { + base: { + display: "inline-flex", + alignItems: "center", + cursor: "pointer", + background: "transparent", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + textDecoration: "none", + transitionProperty: "color, background-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + outline: "none", + "&:hover": { + textDecoration: "none", + }, + "&:focus": { + textDecoration: "none", + }, + "&:focus-visible": { + outlineWidth: "2px", + outlineStyle: "solid", + outlineColor: "@color.primary", + outlineOffset: "2px", + }, + "&:disabled": { + cursor: "not-allowed", + opacity: "0.5", + pointerEvents: "none", + }, + }, + variants: { + color: { + light: {}, + dark: {}, + neutral: {}, + }, + variant: { + ghost: { + borderRadius: "@border-radius.md", + }, + link: { + background: "transparent", + }, + }, + size: { + sm: { + fontSize: "@font-size.xs", + paddingTop: "@0.25", + paddingBottom: "@0.25", + paddingLeft: "@0.5", + paddingRight: "@0.5", + }, + md: { + fontSize: "@font-size.sm", + paddingTop: "@0.375", + paddingBottom: "@0.375", + paddingLeft: "@0.75", + paddingRight: "@0.75", + }, + lg: { + fontSize: "@font-size.md", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@1", + paddingRight: "@1", + }, + }, + }, + compoundVariants: [ + // Light color (fixed across themes — always dark text for light backgrounds) + { + match: { color: "light" as const, variant: "ghost" as const }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:focus": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:active": { + background: "@color.gray-200", + }, + "&:dark": { + color: "@color.text-inverted", + }, + "&:dark:hover": { + color: "@color.text-inverted", + background: "@color.gray-100", + }, + "&:dark:focus": { + color: "@color.text-inverted", + background: "@color.gray-100", + }, + "&:dark:active": { + background: "@color.gray-200", + }, + }, + }, + { + match: { color: "light" as const, variant: "link" as const }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:focus": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:active": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:dark": { + color: "@color.text-inverted", + }, + "&:dark:hover": { + color: "@color.gray-900", + }, + "&:dark:focus": { + color: "@color.gray-900", + }, + "&:dark:active": { + color: "@color.gray-900", + }, + }, + }, + + // Dark color (fixed across themes — matches neutral's dark mode appearance) + { + match: { color: "dark" as const, variant: "ghost" as const }, + css: { + color: "@color.gray-200", + "&:hover": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:focus": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:active": { + background: "@color.gray-750", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:focus": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:active": { + background: "@color.gray-750", + }, + }, + }, + { + match: { color: "dark" as const, variant: "link" as const }, + css: { + color: "@color.gray-200", + "&:hover": { + color: "@color.white", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:focus": { + color: "@color.white", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:active": { + color: "@color.white", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.white", + }, + "&:dark:focus": { + color: "@color.white", + }, + "&:dark:active": { + color: "@color.white", + }, + }, + }, + + // Neutral color (adaptive: light in light mode, dark in dark mode) + { + match: { color: "neutral" as const, variant: "ghost" as const }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:focus": { + color: "@color.text", + background: "@color.gray-100", + }, + "&:active": { + background: "@color.gray-200", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:focus": { + color: "@color.gray-200", + background: "@color.gray-800", + }, + "&:dark:active": { + background: "@color.gray-750", + }, + }, + }, + { + match: { color: "neutral" as const, variant: "link" as const }, + css: { + color: "@color.text", + "&:hover": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:focus": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:active": { + color: "@color.gray-900", + textDecoration: "underline", + textUnderlineOffset: "4px", + }, + "&:dark": { + color: "@color.gray-200", + }, + "&:dark:hover": { + color: "@color.white", + }, + "&:dark:focus": { + color: "@color.white", + }, + "&:dark:active": { + color: "@color.white", + }, + }, + }, + ], + defaultVariants: { + color: "neutral", + variant: "ghost", + size: "md", + }, + }, + (s) => { + const { selector } = s; + + // Active state: apply semibold weight + selector(".nav-item.-active", { + fontWeight: "600", + }); + + // Disabled state via class modifier (supplements :disabled pseudo) + selector(".nav-item.-disabled", { + cursor: "not-allowed", + opacity: "0.5", + pointerEvents: "none", + }); + }, +); diff --git a/theme/src/recipes/nav/useNavRecipe.test.ts b/theme/src/recipes/nav/useNavRecipe.test.ts new file mode 100644 index 00000000..c9b87505 --- /dev/null +++ b/theme/src/recipes/nav/useNavRecipe.test.ts @@ -0,0 +1,186 @@ +import { styleframe } from "@styleframe/core"; +import { useNavRecipe } from "./index"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "flexWrap", + "alignItems", + "listStyle", + "paddingLeft", + "marginTop", + "marginBottom", + "flexDirection", + "fontSize", + "gap", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + return s; +} + +describe("useNavRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("nav"); + }); + + it("should have correct base styles", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + expect(recipe.base).toEqual({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + listStyle: "none", + paddingLeft: "0", + marginTop: "0", + marginBottom: "0", + }); + }); + + describe("variants", () => { + it("should have orientation variants", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + expect(recipe.variants!.orientation).toEqual({ + horizontal: { + flexDirection: "row", + }, + vertical: { + flexDirection: "column", + alignItems: "flex-start", + }, + }); + }); + + it("should have size variants with correct styles", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + expect(recipe.variants!.size).toEqual({ + sm: { + fontSize: "@font-size.xs", + gap: "@0.25", + }, + md: { + fontSize: "@font-size.sm", + gap: "@0.5", + }, + lg: { + fontSize: "@font-size.md", + gap: "@0.75", + }, + }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + orientation: "horizontal", + size: "md", + }); + }); + + describe("compound variants", () => { + it("should have 2 compound variants total", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + expect(recipe.compoundVariants).toHaveLength(2); + }); + + it("should have horizontal className compound variant", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + const horizontal = recipe.compoundVariants!.find( + (cv) => cv.match.orientation === "horizontal", + ); + + expect(horizontal).toEqual({ + match: { orientation: "horizontal" }, + className: "-horizontal", + }); + }); + + it("should have vertical className compound variant", () => { + const s = createInstance(); + const recipe = useNavRecipe(s); + + const vertical = recipe.compoundVariants!.find( + (cv) => cv.match.orientation === "vertical", + ); + + expect(vertical).toEqual({ + match: { orientation: "vertical" }, + className: "-vertical", + }); + }); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useNavRecipe(s, { + base: { display: "inline-flex" }, + }); + + expect(recipe.base!.display).toBe("inline-flex"); + expect(recipe.base!.flexWrap).toBe("wrap"); + expect(recipe.base!.listStyle).toBe("none"); + }); + }); + + describe("filter", () => { + it("should filter orientation variants", () => { + const s = createInstance(); + const recipe = useNavRecipe(s, { + filter: { orientation: ["horizontal"] }, + }); + + expect(Object.keys(recipe.variants!.orientation)).toEqual(["horizontal"]); + }); + + it("should prune compound variants when filtering orientation", () => { + const s = createInstance(); + const recipe = useNavRecipe(s, { + filter: { orientation: ["horizontal"] }, + }); + + const orientationCompounds = recipe.compoundVariants!.filter( + (cv) => cv.match.orientation !== undefined, + ); + expect(orientationCompounds).toHaveLength(1); + expect(orientationCompounds[0]!.match.orientation).toBe("horizontal"); + }); + + it("should filter size variants", () => { + const s = createInstance(); + const recipe = useNavRecipe(s, { + filter: { size: ["sm", "lg"] }, + }); + + expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "lg"]); + }); + + it("should adjust default variants when filtered out", () => { + const s = createInstance(); + const recipe = useNavRecipe(s, { + filter: { orientation: ["vertical"] }, + }); + + expect(recipe.defaultVariants?.orientation).toBeUndefined(); + expect(recipe.defaultVariants?.size).toBe("md"); + }); + }); +}); diff --git a/theme/src/recipes/nav/useNavRecipe.ts b/theme/src/recipes/nav/useNavRecipe.ts new file mode 100644 index 00000000..2abaf550 --- /dev/null +++ b/theme/src/recipes/nav/useNavRecipe.ts @@ -0,0 +1,56 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * Nav container recipe for navigation lists. + * Supports orientation (horizontal/vertical) and size axes. + */ +export const useNavRecipe = createUseRecipe("nav", { + base: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + listStyle: "none", + paddingLeft: "0", + marginTop: "0", + marginBottom: "0", + }, + variants: { + orientation: { + horizontal: { + flexDirection: "row", + }, + vertical: { + flexDirection: "column", + alignItems: "flex-start", + }, + }, + size: { + sm: { + fontSize: "@font-size.xs", + gap: "@0.25", + }, + md: { + fontSize: "@font-size.sm", + gap: "@0.5", + }, + lg: { + fontSize: "@font-size.md", + gap: "@0.75", + }, + }, + }, + compoundVariants: [ + { + match: { orientation: "horizontal" as const }, + className: "-horizontal", + }, + { + match: { orientation: "vertical" as const }, + className: "-vertical", + }, + ], + defaultVariants: { + orientation: "horizontal", + size: "md", + }, +}); diff --git a/theme/src/utilities/typography/useListStyleUtility.test.ts b/theme/src/utilities/typography/useListStyleUtility.test.ts index 10cdf6fe..fa7a3c8b 100644 --- a/theme/src/utilities/typography/useListStyleUtility.test.ts +++ b/theme/src/utilities/typography/useListStyleUtility.test.ts @@ -5,8 +5,28 @@ import { useListStyleImageUtility, useListStylePositionUtility, useListStyleTypeUtility, + useListStyleUtility, } from "./useListStyleUtility"; +describe("useListStyleUtility", () => { + it("should set correct declarations", () => { + const s = styleframe(); + useListStyleUtility(s, { none: "none" }); + + const utility = s.root.children[0] as Utility; + expect(utility.declarations).toEqual({ listStyle: "none" }); + }); + + it("should compile to correct CSS output", () => { + const s = styleframe(); + useListStyleUtility(s, { none: "none" }); + + const css = consumeCSS(s.root, s.options); + expect(css).toContain("._list-style\\:none {"); + expect(css).toContain("list-style: none;"); + }); +}); + describe("useListStyleTypeUtility", () => { it("should create utility instances with provided values", () => { const s = styleframe(); diff --git a/theme/src/utilities/typography/useListStyleUtility.ts b/theme/src/utilities/typography/useListStyleUtility.ts index 3fc1f005..11c5c555 100644 --- a/theme/src/utilities/typography/useListStyleUtility.ts +++ b/theme/src/utilities/typography/useListStyleUtility.ts @@ -1,6 +1,16 @@ import { createUseUtility } from "../../utils"; import { listStylePositionValues, listStyleTypeValues } from "../../values"; +/** + * Create list-style shorthand utility classes. + */ +export const useListStyleUtility = createUseUtility( + "list-style", + ({ value }) => ({ + listStyle: value, + }), +); + /** * Create list-style-image utility classes. */ From 4bf24fdd71fdfc9e3c14fdd39fe8a537f31816ae Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Sun, 5 Apr 2026 10:16:53 +0700 Subject: [PATCH 2/3] chore: add changeset for nav recipe --- .changeset/add-nav-recipe.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/add-nav-recipe.md diff --git a/.changeset/add-nav-recipe.md b/.changeset/add-nav-recipe.md new file mode 100644 index 00000000..87916a17 --- /dev/null +++ b/.changeset/add-nav-recipe.md @@ -0,0 +1,10 @@ +--- +"@styleframe/theme": minor +"styleframe": minor +--- + +Add Nav recipe with list-style utility + +- Add `useNavRecipe` and `useNavItemRecipe` multi-part recipes with size (xs–xl), color, and variant (pills, underline, default) support +- Add `useListStyleUtility` shorthand utility for the `list-style` CSS property +- Add Nav storybook components, grid previews, and stories From 29ad31a91585654fd642be010a5cdda11ce1b54c Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Sun, 5 Apr 2026 10:59:25 +0700 Subject: [PATCH 3/3] refactor: Use boolean variants for NavItem active/disabled states Replace selector()-based class modifiers (-active, -disabled) with proper boolean variant axes. Add rule to recipe prompt documenting this pattern. Update docs to match. --- .claude/styleframe-recipe-prompt.md | 23 + .../06.components/02.composables/06.nav.md | 572 ++++++++++++++++++ .../src/components/components/nav/NavItem.vue | 10 +- .../src/recipes/nav/useNavItemRecipe.test.ts | 37 +- theme/src/recipes/nav/useNavItemRecipe.ts | 518 ++++++++-------- 5 files changed, 892 insertions(+), 268 deletions(-) create mode 100644 apps/docs/content/docs/06.components/02.composables/06.nav.md diff --git a/.claude/styleframe-recipe-prompt.md b/.claude/styleframe-recipe-prompt.md index 74208a97..50b52930 100644 --- a/.claude/styleframe-recipe-prompt.md +++ b/.claude/styleframe-recipe-prompt.md @@ -670,6 +670,29 @@ defaultVariants: { Custom axes should be simple enums that map to a small number of CSS properties. Complex conditional behavior belongs in compound variants instead. +### Boolean State Axes (active, disabled, block, etc.) + +Boolean component states MUST be modeled as variant axes with string `"true"` / `"false"` keys, not as manual class additions with `selector()` callbacks. Place the state-specific CSS directly in the variant value: + +```ts +active: { + true: { fontWeight: "@font-weight.semibold" }, + false: {}, +}, +disabled: { + true: { cursor: "not-allowed", opacity: "0.5", pointerEvents: "none" }, + false: {}, +}, +``` + +Default to `"false"` in `defaultVariants`. The consuming component passes the state as a string variant prop: + +```ts +recipe({ active: isActive ? "true" : "false" }) +``` + +**NEVER** use `selector()` with class-based modifiers (e.g., `.component.-active`) for states that apply to the element itself. Use `className` compound variants + `selector()` only when the state styles target **child elements** (e.g., `.button-group.-horizontal > .button`). + --- ## Compound Variants diff --git a/apps/docs/content/docs/06.components/02.composables/06.nav.md b/apps/docs/content/docs/06.components/02.composables/06.nav.md new file mode 100644 index 00000000..9d6cc6d7 --- /dev/null +++ b/apps/docs/content/docs/06.components/02.composables/06.nav.md @@ -0,0 +1,572 @@ +--- +title: Nav +description: A navigation component for horizontal and vertical link lists. Supports multiple colors, visual styles, sizes, and active/disabled states through a two-part recipe system. +--- + +## Overview + +The **Nav** is a navigation component used for building horizontal and vertical link lists such as navbars, sidebars, and tab bars. It is composed of two recipe parts: `useNavRecipe()` for the container that controls layout direction and spacing, and `useNavItemRecipe()` for individual navigation links with color, variant, and interactive state options. Each composable creates a fully configured [recipe](/docs/api/recipes) with compound variants that handle the color-variant combinations automatically. + +The Nav recipes integrate directly with the default [design tokens preset](/docs/design-tokens/presets) and generate type-safe utility classes at build time with zero runtime CSS. + +## Why use the Nav recipe? + +The Nav recipe helps you: + +- **Ship faster with sensible defaults**: Get 2 orientations, 3 colors, 2 visual styles, and 3 sizes out of the box with a pair of composable calls. +- **Compose flexible layouts**: Two coordinated recipes (container + item) share the size axis, so your navigation stays internally consistent. +- **Maintain consistency**: Compound variants ensure every color-variant combination follows the same design rules, including hover, focus, active, and dark mode states. +- **Customize without forking**: Override base styles, default variants, or filter out options you don't need — all through the options API. +- **Stay type-safe**: Full TypeScript support means your editor catches invalid color, variant, or size values at compile time. +- **Integrate with your tokens**: Every value references the design tokens preset, so theme changes propagate automatically. + +## Usage + +::steps{level="4"} + +#### Register the recipes + +Add the Nav recipes to a local Styleframe instance. The global `styleframe.config.ts` provides design tokens and utilities, while the component-level file registers the recipes themselves: + +:::code-tree{default-value="src/components/nav.styleframe.ts"} + +```ts [src/components/nav.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useNavRecipe, useNavItemRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +const nav = useNavRecipe(s); +const navItem = useNavItemRecipe(s); + +export default s; +``` + +```ts [styleframe.config.ts] +import { styleframe } from 'styleframe'; +import { useDesignTokensPreset, useUtilitiesPreset } from '@styleframe/theme'; + +const s = styleframe(); + +useDesignTokensPreset(s); +useUtilitiesPreset(s); + +export default s; +``` + +::: + +#### Build the component + +Import the `nav` and `navItem` runtime functions from the virtual module and pass variant props to compute class names: + +:::tabs +::::tabs-item{icon="i-devicon-react" label="React"} + +```ts [src/components/Nav.tsx] +import { nav, navItem } from "virtual:styleframe"; + +interface NavItemProps { + color?: "light" | "dark" | "neutral"; + variant?: "ghost" | "link"; + size?: "sm" | "md" | "lg"; + active?: boolean; + disabled?: boolean; + href?: string; + children?: React.ReactNode; +} + +interface NavProps { + orientation?: "horizontal" | "vertical"; + size?: "sm" | "md" | "lg"; + children?: React.ReactNode; +} + +export function Nav({ + orientation = "horizontal", + size = "md", + children, +}: NavProps) { + return ( + + ); +} + +export function NavItem({ + color = "neutral", + variant = "ghost", + size = "md", + active = false, + disabled = false, + href, + children, +}: NavItemProps) { + return ( + + {children} + + ); +} +``` + +:::: +::::tabs-item{icon="i-devicon-vuejs" label="Vue"} + +```vue [src/components/Nav.vue] + + + +``` + +```vue [src/components/NavItem.vue] + + + +``` + +:::: +::: + +#### See it in action + +:::story-preview +--- +story: theme-recipes-nav--default +panel: true +--- +::: + +:: + +## Orientation + +The Nav container recipe supports two orientations that control the flex layout direction. Horizontal arranges items in a row (ideal for top navbars and tab bars), while vertical stacks items in a column (ideal for sidebars and dropdown menus). + +::story-preview +--- +story: theme-recipes-nav--vertical +panel: true +--- +:: + +### Orientation Reference + +| Orientation | Flex Direction | Alignment | Use Case | +|-------------|---------------|-----------|----------| +| `horizontal` | `row` | `center` | Top-level navbars, tab bars, breadcrumbs | +| `vertical` | `column` | `flex-start` | Sidebars, dropdown menus, stacked navigation | + +::tip +**Pro tip:** Use `horizontal` for top-level navigation and `vertical` for sidebar navigation. The orientation only affects the container — individual items render the same regardless of direction. +:: + +## Colors + +The Nav item recipe includes 3 color variants: `light`, `dark`, and `neutral`. Like the Card recipe, Nav uses neutral-spectrum colors designed for structural navigation elements rather than status communication. Each color is combined with every visual style variant through compound variants, so you get consistent, predictable styling across all combinations — including dark mode overrides. + +The `neutral` color adapts automatically: it uses dark text in light mode and light text in dark mode, making it the safest default for general-purpose navigation. + +::story-preview +--- +story: theme-recipes-nav--dark +panel: true +--- +:: + +### Color Reference + +::story-preview +--- +story: theme-recipes-nav--all-variants +height: 420 +--- +:: + +| Color | Token | Use Case | +|-------|-------|----------| +| `light` | `@color.text` / `@color.gray-*` | Navigation on light backgrounds, stays light-text in dark mode | +| `dark` | `@color.gray-200` | Navigation on dark backgrounds, stays dark appearance in light mode | +| `neutral` | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme | + +::tip +**Pro tip:** Use `neutral` as your default nav item color. It adapts automatically to the user's color scheme, so you don't need to manage light and dark variants separately. +:: + +## Variants + +Two visual style variants control how nav items are rendered. Each variant is combined with the selected color through [compound variants](/docs/api/recipes#compound-variants), so you always get the correct text color and hover behavior for your chosen color. + +### Ghost + +Transparent background that reveals a tinted background on hover. The most common style for navigation, ideal for navbars and sidebars where items should be subtle at rest but clearly interactive on hover. + +::story-preview +--- +story: theme-recipes-nav--ghost +panel: true +--- +:: + +### Link + +Transparent background with colored text that gains an underline on hover. Use for secondary navigation, inline link lists, or when items should look like standard hyperlinks. + +::story-preview +--- +story: theme-recipes-nav--link +panel: true +--- +:: + +## Sizes + +Three size variants from `sm` to `lg` control the font size, gap, and padding of the navigation. The `size` prop affects both the container (font size and gap between items) and individual items (font size and padding). + +::story-preview +--- +story: theme-recipes-nav--large +panel: true +--- +:: + +### Size Reference + +::story-preview +--- +story: theme-recipes-nav--all-sizes +height: 480 +--- +:: + +**Nav Container:** + +| Size | Font Size | Gap | +|------|-----------|-----| +| `sm` | `@font-size.xs` | `@0.25` | +| `md` | `@font-size.sm` | `@0.5` | +| `lg` | `@font-size.md` | `@0.75` | + +**Nav Item:** + +| Size | Font Size | Padding (V / H) | +|------|-----------|-----------------| +| `sm` | `@font-size.xs` | `@0.25` / `@0.5` | +| `md` | `@font-size.sm` | `@0.375` / `@0.75` | +| `lg` | `@font-size.md` | `@0.5` / `@1` | + +::note +**Good to know:** The `size` prop must be passed to both the nav container and each nav item individually. The container controls font size and gap between items, while items control their own padding. +:: + +## Active + +The Nav item recipe includes an `active` boolean variant. Active items receive `font-weight: semibold` to visually distinguish the current page or section from other navigation links. + +::story-preview +--- +story: theme-recipes-nav--active +panel: true +--- +:: + +```ts +// Active item via variant prop +navItem({ color: "neutral", variant: "ghost", size: "md", active: "true" }) +``` + +::tip +**Good practice:** Always pair the active variant with `aria-current="page"` so both sighted users and screen reader users know which page is currently active. +:: + +## Disabled + +The Nav item recipe includes a built-in disabled state through two mechanisms: the `&:disabled` pseudo-class for native ` +``` + +::tip +**Good practice:** If your page has multiple `