diff --git a/.claude/skills/implement-recipe/SKILL.md b/.claude/skills/implement-recipe/SKILL.md index 7d9ff32b..aa8585b3 100644 --- a/.claude/skills/implement-recipe/SKILL.md +++ b/.claude/skills/implement-recipe/SKILL.md @@ -543,6 +543,40 @@ export const useSpinnerRecipe = createUseRecipe( ); ``` +#### Spacing multipliers inside a `setup` callback — use `calc(@token * N)` + +Bare numeric spacing refs like `@0.125` / `@0.25` are **multiplier tokens** that +resolve to a variable *named* `"0.125"`. In a recipe's `base` / `variants` these are +auto-declared on the fly (e.g. spinner/badge size variants use `@0.25`, `@0.5`). But +raw `selector(...)` calls inside a `setup` callback only **reference** — they do NOT +auto-declare. Referencing `@0.125` in a setup selector throws at Storybook build init: +`Variable "0.125" is not defined` (typecheck + theme tests do NOT catch this). + +To scale a token by a fraction inside a setup selector, use an **embedded reference +expression wrapped in `calc()`** as a plain string: + +```ts +(s) => { + const { selector } = s; + selector(".input-date-field", { + gap: "calc(@spacing * 0.125)", // → gap: calc(var(--spacing) * .125) + }); +} +``` + +Styleframe's resolver (`engine/core/src/tokens/resolve.ts`) treats an *exact* `@name` +match as a validated reference (throws if undefined) but parses an *embedded* expression +like `@spacing * 0.125` without validation — it resolves `@spacing` and keeps the rest +literal. This is the same pattern used by dropdown/tooltip/popover (`"calc(@tooltip.arrow.size * -1)"`). + +- **Keep the arithmetic inside `calc(...)`.** Bare `"@spacing * 0.125"` parses but emits + invalid CSS `gap:var(--spacing) * .125` (no `calc` → the browser drops the declaration). +- **Test gotcha:** in the recipe test instance, declare `s.variable("spacing", "1rem")` and + let the calc resolve — do NOT hand-declare a fake `s.variable("0.125", ...)`, which masks + the real build failure. +- **Always run `pnpm --filter @styleframe/storybook build` during verification** — it is the + only check that exercises real token resolution in a fresh instance. + --- ### Deliverable 2: Barrel index diff --git a/.claude/skills/verify-recipe/SKILL.md b/.claude/skills/verify-recipe/SKILL.md index 385f2f07..1c4037fc 100644 --- a/.claude/skills/verify-recipe/SKILL.md +++ b/.claude/skills/verify-recipe/SKILL.md @@ -68,15 +68,25 @@ Parse output. Capture: - Exit status. - For each issue: file:line, rule, message. -### Step 3: (Optional) Storybook spot-check - -If the user wants, offer to start Storybook briefly to verify the story renders: +### Step 3: Storybook build (required) ```bash -pnpm storybook +pnpm --filter @styleframe/storybook build ``` -This is a manual check — the assistant cannot verify rendering automatically. Prompt the user to open the component story in the browser and confirm it looks correct. +Run the **production build**, not just the dev server. This is the **only** check that +exercises real token resolution in a fresh Styleframe instance — typecheck and theme +tests pass even when a recipe references an undefined variable. A `setup` callback that +references an undeclared spacing multiplier (e.g. a raw `selector` using `@0.125`) throws +here with `Variable "0.125" is not defined`; the fix lives in `/implement-recipe` (use +`calc(@spacing * 0.125)` instead — see that skill's Step 12). Capture exit status and any +`Variable "..." is not defined` errors with the originating recipe file. + +#### (Optional) Storybook render spot-check + +If the user wants a visual check, offer to start Storybook (`pnpm storybook`) — but this is +manual; the assistant cannot verify rendering automatically. Prompt the user to open the +story and confirm it looks correct. ### Step 4: Write `verification.md` @@ -115,7 +125,10 @@ Do NOT attempt to fix issues automatically. ## Lint: -## Storybook spot-check: +## Storybook build: + + +## Storybook render spot-check: ## Summary - Total failures: @@ -129,6 +142,7 @@ Do NOT attempt to fix issues automatically. - [ ] `pnpm typecheck` was run from the project root. - [ ] `pnpm --filter @styleframe/theme test` was run. - [ ] `pnpm lint` was run. +- [ ] `pnpm --filter @styleframe/storybook build` was run (catches token-resolution errors). - [ ] Each failure is reported with file:line and a suggested sub-skill. - [ ] `verification.md` is written to `.context/recipe-/`. - [ ] User received a short pass/fail summary. diff --git a/apps/docs/content/docs/05.components/05.forms/06.input-date.md b/apps/docs/content/docs/05.components/05.forms/06.input-date.md new file mode 100644 index 00000000..b435683a --- /dev/null +++ b/apps/docs/content/docs/05.components/05.forms/06.input-date.md @@ -0,0 +1,514 @@ +--- +title: Input Date +description: A segmented date input — a wrapper-owned field surface holding individually focusable date segments separated by muted glyphs, with light, dark, and neutral colors, default, soft, and ghost styles, three sizes, and invalid, disabled, and read-only states through the recipe system. +navigation: + icon: false +--- + +## Overview + +The **Input Date** is a segmented date field: a single visual surface that holds individually focusable parts — day, month, year (and optionally hour, minute) — separated by a muted glyph such as `/` or `–`. It is composed of two recipe parts: + +- **`useInputDateRecipe()`** — the wrapper that owns the visual field: border, background, padding, and the `:focus-within` ring. It is a member of the field recipes family, so it shares the same color, style, and state surface as the [Input](/docs/theme/components/input) recipe. +- **`useInputDateSegmentRecipe()`** — the single editable segment that sits inside the field. It is transparent, inherits typography and color from the wrapper, centers its digits with tabular figures, and paints a highlight only on `:focus`. + +The wrapper owns the entire surface and reveals a primary-colored ring on `:focus-within`, so focusing any segment lights the whole field — just like a native text input. Each segment is the focusable element itself, so keyboard focus moves part to part while the surface stays put. A plain `.input-date-separator` glyph sits between segments, muted via `@color.text-weak`. + +The Input Date recipes integrate directly with the default [design tokens preset](/docs/theme/design-tokens/preset) and generate type-safe utility classes at build time with zero runtime CSS. + +::note +**Styling, not behavior.** These recipes style the field and its segments. Parsing input, advancing focus between segments, clamping values, and opening a calendar popover are interaction concerns you wire up yourself (or with a headless library). Calendar / popover styling lives in the [Calendar](/docs/theme/components/calendar) recipe — this recipe styles the segmented input only. +:: + +## Why use the Input Date recipe? + +The Input Date recipe helps you: + +- **Ship faster with sensible defaults**: Get 3 surface colors, 3 styles, and 3 sizes out of the box with a single set of composable calls. +- **Match the rest of your forms**: The wrapper reuses the Input recipe's color / style / state surface, so a date field shares the same visual language as your text inputs. +- **Maintain consistency**: The focus ring, invalid border, and dark-mode surfaces follow the same design rules everywhere. +- **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, style, 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 Input Date 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/input-date.styleframe.ts"} + +```ts [src/components/input-date.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useInputDateRecipe, useInputDateSegmentRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +const inputDate = useInputDateRecipe(s); +const inputDateSegment = useInputDateSegmentRecipe(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 + +Put the `inputDate` class on the wrapper, nest a `.input-date-field` row inside it, and render one `inputDateSegment` span per part, interleaved with a `.input-date-separator` glyph. The `invalid`, `disabled`, and `readonly` axes accept booleans directly: + +::framework-switcher + +#vue + +```vue [src/components/InputDate.vue] + + + +``` + +#react + +```tsx [src/components/InputDate.tsx] +import { inputDate, inputDateSegment } from "virtual:styleframe"; + +interface InputDateProps { + color?: "light" | "dark" | "neutral"; + variant?: "default" | "soft" | "ghost"; + size?: "sm" | "md" | "lg"; + invalid?: boolean; + disabled?: boolean; + readonly?: boolean; + value?: string; + separator?: string; +} + +export function InputDate({ + color = "neutral", + variant = "default", + size = "md", + invalid = false, + disabled = false, + readonly = false, + value = "12/31/2026", + separator = "/", +}: InputDateProps) { + const segments = value.split(separator); + const focusable = !disabled && !readonly; + + return ( +
+ + {segments.map((segment, index) => ( + + + {segment} + + {index < segments.length - 1 && ( + + )} + + ))} + +
+ ); +} +``` + +#other + +The `inputDate()` and `inputDateSegment()` runtimes each return a class string. Apply them however your framework binds classes: + +```ts [src/components/input-date.ts] +import { inputDate, inputDateSegment } from "virtual:styleframe"; + +const wrapperClasses = inputDate({ color: "neutral", variant: "default", size: "md" }); +// → "input-date _display:inline-flex _align-items:center ..." + +const segmentClasses = inputDateSegment({ size: "md" }); +// → "input-date-segment _display:inline-flex _font-variant-numeric:tabular-nums ..." +``` + +```html [src/components/input-date.html] +
+ + 12 + + 31 + + 2026 + +
+``` + +:: + +#### See it in action + +:::story-preview +--- +story: theme-recipes-forms-inputdate--default +panel: true +--- +::: + +:: + +## Colors + +The Input Date wrapper includes 3 color variants: `light`, `dark`, and `neutral`. Like the [Input](/docs/theme/components/input) and [OTP](/docs/theme/components/otp) recipes, these are neutral-spectrum surface colors rather than status colors — the color sets the field background and border, while the `:focus-within` ring stays `@color.primary`. + +The `neutral` color adapts automatically: a light surface in light mode and a dark surface in dark mode, making it the safest default for general-purpose forms. + +::story-preview +--- +story: theme-recipes-forms-inputdate--neutral +panel: true +--- +:: + +### Color Reference + +::story-preview +--- +story: theme-recipes-forms-inputdate--all-variants +height: 420 +--- +:: + +| Color | Token | Use Case | +|-------|-------|----------| +| `light` | `@color.white` / `@color.gray-200` | Light surfaces, stays light in dark mode | +| `dark` | `@color.gray-900` / `@color.gray-700` | Dark surfaces, stays dark in light mode | +| `neutral` | Adaptive (light ↔ dark) | Default color, adapts to the current color scheme | + +::tip +**Pro tip:** Use `neutral` as your default date-field color. It adapts to the user's color scheme automatically, so you don't need to manage light and dark surfaces separately. +:: + +## Variants + +Three visual styles control how much surface the field shows. All three share the same `:focus-within` ring and invalid border — they differ only in their resting background and border. + +### Default + +`default` draws a bordered field on a solid surface — the most legible option and the right default for forms. + +::story-preview +--- +story: theme-recipes-forms-inputdate--default-variant +panel: true +--- +:: + +### Soft + +`soft` fills the field with a subtle tinted background and a matching border, for a gentler look that still reads as an input. + +::story-preview +--- +story: theme-recipes-forms-inputdate--soft +panel: true +--- +:: + +### Ghost + +`ghost` is transparent until focused, for dense or low-chrome layouts. Pair it with a clear label so the field stays discoverable. + +::story-preview +--- +story: theme-recipes-forms-inputdate--ghost +panel: true +--- +:: + +## Sizes + +Three size variants from `sm` to `lg` control the font size, field padding, and border radius. Pass the same `size` to the wrapper and to each segment so the segment typography and corner radius track the field. + +::story-preview +--- +story: theme-recipes-forms-inputdate--all-sizes +height: 360 +--- +:: + +### Size Reference + +| Size | Font Size | Padding (Y / X) | Border Radius | +|------|-----------|-----------------|---------------| +| `sm` | `@font-size.xs` | `@0.375` / `@0.625` | `@border-radius.sm` | +| `md` | `@font-size.sm` | `@0.5` / `@0.75` | `@border-radius.md` | +| `lg` | `@font-size.md` | `@0.625` / `@0.875` | `@border-radius.md` | + +::note +**Good to know:** The segment recipe carries its own `size` axis that scales the segment font, horizontal padding, and the highlight's corner radius. Spread the same `size` to both parts so they stay in proportion. +:: + +## States + +### Invalid + +Set the `invalid` state when the entered date is wrong or out of range. The field border and the `:focus-within` ring switch to `@color.error`, layered over whatever color and style the field already uses. + +::story-preview +--- +story: theme-recipes-forms-inputdate--invalid +panel: true +--- +:: + +### Disabled + +The `disabled` state dims the field to `0.5` opacity, switches the cursor to `not-allowed`, and blocks pointer interaction. Drop `tabindex` from the segments so the field also leaves the tab order. + +::story-preview +--- +story: theme-recipes-forms-inputdate--disabled +panel: true +--- +:: + +### Read-only + +Applies a subtle background shift and a default cursor while keeping the value selectable. Keep the segments out of the tab order (or non-editable) so the value is shown but not changed. + +::story-preview +--- +story: theme-recipes-forms-inputdate--read-only +panel: true +--- +:: + +::note +**Good to know:** `invalid` is applied before `readonly` and `disabled` in the compound variant order, so an invalid field keeps its error border even when it is also read-only, and a disabled field still dims on top of any other state. +:: + +## Anatomy + +The Input Date is composed of two recipes plus a plain separator selector. + +| Part | Recipe / Selector | Role | +|------|-------------------|------| +| **Field** | `useInputDateRecipe()` | The `.input-date` wrapper — owns the visual field (border, background, padding, `:focus-within` ring) and lays out the inner `.input-date-field` row | +| **Segment** | `useInputDateSegmentRecipe()` | The `.input-date-segment` editable part — transparent, inherits typography, centers digits with tabular figures, and highlights on `:focus` | +| **Separator** | `.input-date-separator` | A muted, non-selectable glyph (`@color.text-weak`) between segments — registered by the wrapper's setup, no axis of its own | + +```html +
+ + 12 + + 31 + + +
+``` + +The wrapper carries the entire color / style / state surface; the segments are transparent and only paint a highlight on focus, so every segment in a field stays visually consistent. + +## Accessibility + +- **Group and label the field.** Wrap the segments in an element with `role="group"` and an `aria-label` (e.g. "Date input") so assistive technology announces them as one control rather than several unrelated fields. +- **Expose each segment as a spinbutton.** Give every editable segment `role="spinbutton"` with `aria-valuenow` / `aria-valuetext` so screen readers announce the current value and arrow-key editing is understood. +- **Keep segments focusable.** Make each segment reachable with `tabindex="0"` (when not disabled or read-only) so keyboard users can move part to part; the wrapper's `:focus-within` ring lights the whole field as they go. +- **Don't rely on color alone.** The `invalid` state changes the border and ring color; pair it with a visible error message so the failure isn't conveyed by color only, satisfying [WCAG 1.4.1](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html). +- **Reflect disabled and read-only.** Drop `tabindex` (and set `aria-disabled`) for `disabled`, and keep segments out of the edit path for `readonly`, so the field behaves correctly for keyboard and assistive-technology users. +- **Verify contrast.** The `:focus-within` ring and the segment focus highlight use `@color.primary`. Default tokens meet WCAG AA; if you override the primary color, verify with the [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/). + +## Customization + +### Overriding Defaults + +Each Input Date composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change: + +```ts [src/components/input-date.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useInputDateRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +const inputDate = useInputDateRecipe(s, { + defaultVariants: { + color: 'neutral', + size: 'lg', + }, +}); + +export default s; +``` + +### Filtering Variants + +If you only need a subset of the available variants, use the `filter` option to limit which values are generated. This reduces the output CSS and keeps your component API focused: + +```ts [src/components/input-date.styleframe.ts] +import { styleframe } from 'virtual:styleframe'; +import { useInputDateRecipe } from '@styleframe/theme'; + +const s = styleframe(); + +// Only generate the neutral color and the default style +const inputDate = useInputDateRecipe(s, { + filter: { + color: ['neutral'], + variant: ['default'], + }, +}); + +export default s; +``` + +::note +**Good to know:** Filtering also removes compound variants and adjusts default variants that reference filtered-out values, so your recipe stays consistent. +:: + +## API Reference + +### `useInputDateRecipe(s, options?)` + +Creates the Input Date wrapper recipe — the `.input-date` field that owns the visual surface (border, background, padding, `:focus-within` ring) and lays out the inner segment row. Its setup callback registers the `.input-date-field` flex row and the muted `.input-date-separator` glyph. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `s` | `Styleframe` | The Styleframe instance | +| `options` | `DeepPartial` | Optional overrides for the recipe configuration | +| `options.base` | `VariantDeclarationsBlock` | Custom base styles for the wrapper | +| `options.variants` | `Variants` | Custom variant definitions for the recipe | +| `options.defaultVariants` | `Record` | Default variant values for the recipe | +| `options.filter` | `Record` | Limit which variant values are generated | + +**Variants:** + +| Variant | Options | Default | +|---------|---------|---------| +| `color` | `light`, `dark`, `neutral` | `neutral` | +| `variant` | `default`, `soft`, `ghost` | `default` | +| `size` | `sm`, `md`, `lg` | `md` | +| `invalid` | `true`, `false` | `false` | +| `disabled` | `true`, `false` | `false` | +| `readonly` | `true`, `false` | `false` | + +### `useInputDateSegmentRecipe(s, options?)` + +Creates the Input Date segment recipe — the `.input-date-segment` editable part that is transparent, inherits typography from the wrapper, centers its digits with tabular figures, and highlights on `:focus` (`@color.primary` background, `@color.white` text). It carries no color or style axis; all surface styling lives on the wrapper. Accepts the same parameters as `useInputDateRecipe`. + +**Variants:** + +| Variant | Options | Default | +|---------|---------|---------| +| `size` | `sm`, `md`, `lg` | `md` | + +[Learn more about recipes →](/docs/api/recipes) + +## Best Practices + +- **Pass `size` to both parts**: Spread the same `size` to the wrapper and the segments so the field and segment typography stay in proportion. +- **Use `neutral` for general forms**: The neutral color adapts to light and dark mode automatically, making it the safest default. +- **Expose segments as spinbuttons**: Give each segment `role="spinbutton"` and keep it focusable so arrow-key editing and screen-reader announcement work. +- **Group and label the field**: Use `role="group"` and an `aria-label` so the segments are announced as a single control. +- **Filter what you don't need**: If your forms use only the neutral color and default style, pass a `filter` option to reduce generated CSS. + +## FAQ + +::accordion + +:::accordion-item{label="Does this recipe handle date parsing and segment focus?" icon="i-lucide-circle-help"} +No — the recipe only styles the field and its segments. Parsing the typed value, advancing focus between segments, clamping ranges, and masking are interaction concerns you implement yourself or delegate to a headless component. Keeping behavior out of the recipe keeps it framework-agnostic and zero-runtime. +::: + +:::accordion-item{label="Does this style the calendar / date picker popover?" icon="i-lucide-circle-help"} +No. This recipe styles the segmented date *input* surface only. The calendar grid and popover styling live in the [Calendar](/docs/theme/components/calendar) recipe — compose the two when you need a full date picker. +::: + +:::accordion-item{label="Why is the color light/dark/neutral instead of primary/success/error?" icon="i-lucide-circle-help"} +The `color` axis controls the field surface, which reflects the form's surface rather than a status. This mirrors the [Input](/docs/theme/components/input) recipe's color model. For an error state, use the `invalid` state, which switches the border and focus ring to `@color.error`. +::: + +:::accordion-item{label="How is the Input Date related to the Input recipe?" icon="i-lucide-circle-help"} +The wrapper shares the Input recipe's color / style / state surface through an internal helper, so the two stay visually consistent — same `:focus-within` ring, invalid border, and dark-mode surfaces. The difference is structural: an Input wraps one transparent native ``, while an Input Date lays out several focusable segments separated by muted glyphs inside the same surface. +::: + +:::accordion-item{label="How do I render different date formats?" icon="i-lucide-circle-help"} +Split your value on the separator and render one `inputDateSegment` per part, interleaving a `.input-date-separator` between them. Change the `separator` (e.g. `/`, `-`, or `:` for time) and the number of segments to match the format you need — the wrapper's `.input-date-field` row lays them out automatically. +::: + +:: diff --git a/apps/docs/content/docs/05.components/05.forms/06.otp.md b/apps/docs/content/docs/05.components/05.forms/07.otp.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/06.otp.md rename to apps/docs/content/docs/05.components/05.forms/07.otp.md diff --git a/apps/docs/content/docs/05.components/05.forms/07.radio.md b/apps/docs/content/docs/05.components/05.forms/08.radio.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/07.radio.md rename to apps/docs/content/docs/05.components/05.forms/08.radio.md diff --git a/apps/docs/content/docs/05.components/05.forms/08.radio-group.md b/apps/docs/content/docs/05.components/05.forms/09.radio-group.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/08.radio-group.md rename to apps/docs/content/docs/05.components/05.forms/09.radio-group.md diff --git a/apps/docs/content/docs/05.components/05.forms/09.select.md b/apps/docs/content/docs/05.components/05.forms/10.select.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/09.select.md rename to apps/docs/content/docs/05.components/05.forms/10.select.md diff --git a/apps/docs/content/docs/05.components/05.forms/10.slider.md b/apps/docs/content/docs/05.components/05.forms/11.slider.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/10.slider.md rename to apps/docs/content/docs/05.components/05.forms/11.slider.md diff --git a/apps/docs/content/docs/05.components/05.forms/11.switch.md b/apps/docs/content/docs/05.components/05.forms/12.switch.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/11.switch.md rename to apps/docs/content/docs/05.components/05.forms/12.switch.md diff --git a/apps/docs/content/docs/05.components/05.forms/12.textarea.md b/apps/docs/content/docs/05.components/05.forms/13.textarea.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/12.textarea.md rename to apps/docs/content/docs/05.components/05.forms/13.textarea.md diff --git a/apps/docs/content/docs/05.components/05.forms/13.toggle.md b/apps/docs/content/docs/05.components/05.forms/14.toggle.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/13.toggle.md rename to apps/docs/content/docs/05.components/05.forms/14.toggle.md diff --git a/apps/docs/content/docs/05.components/05.forms/14.toggle-group.md b/apps/docs/content/docs/05.components/05.forms/15.toggle-group.md similarity index 100% rename from apps/docs/content/docs/05.components/05.forms/14.toggle-group.md rename to apps/docs/content/docs/05.components/05.forms/15.toggle-group.md diff --git a/apps/storybook/src/components/components/input-date/InputDate.vue b/apps/storybook/src/components/components/input-date/InputDate.vue new file mode 100644 index 00000000..cbd08997 --- /dev/null +++ b/apps/storybook/src/components/components/input-date/InputDate.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue b/apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue new file mode 100644 index 00000000..f1ddb31f --- /dev/null +++ b/apps/storybook/src/components/components/input-date/preview/InputDateGrid.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue b/apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue new file mode 100644 index 00000000..fb216ca6 --- /dev/null +++ b/apps/storybook/src/components/components/input-date/preview/InputDateSizeGrid.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/storybook/stories/components/input-date.stories.ts b/apps/storybook/stories/components/input-date.stories.ts new file mode 100644 index 00000000..e4f0a740 --- /dev/null +++ b/apps/storybook/stories/components/input-date.stories.ts @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/vue3-vite"; + +import InputDate from "@/components/components/input-date/InputDate.vue"; +import InputDateGrid from "@/components/components/input-date/preview/InputDateGrid.vue"; +import InputDateSizeGrid from "@/components/components/input-date/preview/InputDateSizeGrid.vue"; + +const colors = ["neutral", "light", "dark"] as const; +const variants = ["default", "soft", "ghost"] as const; +const sizes = ["sm", "md", "lg"] as const; + +const meta = { + title: "Theme/Recipes/Forms/InputDate", + component: InputDate, + tags: ["autodocs"], + parameters: { + layout: "padded", + }, + argTypes: { + color: { + control: "select", + options: colors, + description: "The color variant of the date input", + }, + variant: { + control: "select", + options: variants, + description: "The visual style variant", + }, + size: { + control: "select", + options: sizes, + description: "The size of the date input", + }, + invalid: { + control: "boolean", + description: "Whether the date input is in an invalid state", + }, + disabled: { + control: "boolean", + description: "Whether the date input is disabled", + }, + readonly: { + control: "boolean", + description: "Whether the date input is read-only", + }, + value: { + control: "text", + description: "The date value rendered across the segments", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + color: "neutral", + variant: "default", + size: "md", + invalid: false, + value: "12/31/2026", + }, +}; + +export const AllVariants: StoryObj = { + render: () => ({ + components: { InputDateGrid }, + template: "", + }), +}; + +export const AllSizes: StoryObj = { + render: () => ({ + components: { InputDateSizeGrid }, + 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 DefaultVariant: Story = { + args: { + variant: "default", + }, +}; + +export const Soft: Story = { + args: { + variant: "soft", + }, +}; + +export const Ghost: Story = { + args: { + variant: "ghost", + }, +}; + +// Size stories +export const Small: Story = { + args: { + size: "sm", + }, +}; + +export const Medium: Story = { + args: { + size: "md", + }, +}; + +export const Large: Story = { + args: { + size: "lg", + }, +}; + +// State stories +export const Invalid: Story = { + args: { + invalid: true, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; + +export const ReadOnly: Story = { + args: { + readonly: true, + }, +}; diff --git a/apps/storybook/stories/components/input-date.styleframe.ts b/apps/storybook/stories/components/input-date.styleframe.ts new file mode 100644 index 00000000..2fbf27c2 --- /dev/null +++ b/apps/storybook/stories/components/input-date.styleframe.ts @@ -0,0 +1,43 @@ +import { + useInputDateRecipe, + useInputDateSegmentRecipe, +} from "@styleframe/theme"; +import { styleframe } from "virtual:styleframe"; + +const s = styleframe(); +const { selector } = s; + +// Initialize input-date recipes +export const inputDate = useInputDateRecipe(s); +export const inputDateSegment = useInputDateSegmentRecipe(s); + +// Container styles for story layout +selector(".input-date-grid", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.md", + padding: "@spacing.md", + alignItems: "flex-start", +}); + +selector(".input-date-section", { + display: "flex", + flexDirection: "column", + gap: "@spacing.lg", + padding: "@spacing.md", +}); + +selector(".input-date-row", { + display: "flex", + flexWrap: "wrap", + gap: "@spacing.sm", + alignItems: "center", +}); + +selector(".input-date-label", { + fontSize: "@font-size.sm", + fontWeight: "@font-weight.semibold", + minWidth: "80px", +}); + +export default s; diff --git a/theme/src/recipes/index.ts b/theme/src/recipes/index.ts index a66c1cd3..5007407a 100644 --- a/theme/src/recipes/index.ts +++ b/theme/src/recipes/index.ts @@ -16,6 +16,7 @@ export * from "./dropdown"; export * from "./field-group"; export * from "./hamburger-menu"; export * from "./input"; +export * from "./input-date"; export * from "./media"; export * from "./modal"; export * from "./nav"; diff --git a/theme/src/recipes/input-date/index.ts b/theme/src/recipes/input-date/index.ts new file mode 100644 index 00000000..8807df7f --- /dev/null +++ b/theme/src/recipes/input-date/index.ts @@ -0,0 +1,2 @@ +export * from "./useInputDateRecipe"; +export * from "./useInputDateSegmentRecipe"; diff --git a/theme/src/recipes/input-date/useInputDateRecipe.test.ts b/theme/src/recipes/input-date/useInputDateRecipe.test.ts new file mode 100644 index 00000000..5be6aba4 --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateRecipe.test.ts @@ -0,0 +1,304 @@ +import { styleframe } from "@styleframe/core"; +import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers"; +import { + useFocusWithinModifier, + useHoverModifier, +} from "../../modifiers/usePseudoStateModifiers"; +import { useInputDateRecipe } from "./useInputDateRecipe"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "gap", + "flexGrow", + "minWidth", + "width", + "fontFamily", + "fontSize", + "fontWeight", + "lineHeight", + "border", + "borderWidth", + "borderStyle", + "borderColor", + "borderRadius", + "padding", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "background", + "color", + "userSelect", + "outline", + "outlineWidth", + "outlineStyle", + "outlineColor", + "outlineOffset", + "cursor", + "opacity", + "pointerEvents", + "transitionProperty", + "transitionTimingFunction", + "transitionDuration", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + s.variable("color.text-weakest", "#64748B", { default: true }); + s.variable("color.text-weak", "#475569", { default: true }); + s.variable("color.gray-400", "#94A3B8", { default: true }); + s.variable("spacing", "1rem", { default: true }); + useDarkModifier(s); + useHoverModifier(s); + useFocusWithinModifier(s); + return s; +} + +describe("useInputDateRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("input-date"); + }); + + it("should have correct base styles with a focus-within ring", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.base).toEqual({ + display: "inline-flex", + alignItems: "center", + fontFamily: "inherit", + fontSize: "@font-size.sm", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + borderWidth: "@border-width.thin", + borderStyle: "@border-style.solid", + borderColor: "transparent", + borderRadius: "@border-radius.md", + paddingTop: "@0.5", + paddingBottom: "@0.5", + paddingLeft: "@0.75", + paddingRight: "@0.75", + background: "transparent", + color: "@color.text", + outline: "none", + transitionProperty: "color, background-color, border-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + "&:focus-within": { + outlineWidth: "2px", + outlineStyle: "solid", + outlineColor: "@color.primary", + outlineOffset: "2px", + }, + }); + }); + + describe("variants", () => { + it("should have all color variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(Object.keys(recipe.variants!.color)).toEqual([ + "light", + "dark", + "neutral", + ]); + }); + + it("should have all style variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(Object.keys(recipe.variants!.variant)).toEqual([ + "default", + "soft", + "ghost", + ]); + }); + + it("should have size variants matching the field family", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "md", "lg"]); + }); + + it("should have invalid boolean variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.variants!.invalid).toEqual({ true: {}, false: {} }); + }); + + it("should have disabled boolean variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.variants!.disabled).toEqual({ true: {}, false: {} }); + }); + + it("should have readonly boolean variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.variants!.readonly).toEqual({ true: {}, false: {} }); + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.defaultVariants).toEqual({ + color: "neutral", + variant: "default", + size: "md", + invalid: "false", + disabled: "false", + readonly: "false", + }); + }); + + describe("compound variants", () => { + it("should have 12 compound variants total (9 color×variant + invalid + readonly + disabled)", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + expect(recipe.compoundVariants).toHaveLength(12); + }); + + it("should share the neutral default surface with the input recipe", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + const cv = recipe.compoundVariants!.find( + (v) => v.match.color === "neutral" && v.match.variant === "default", + ); + + expect(cv).toEqual({ + match: { color: "neutral", variant: "default" }, + css: { + background: "@color.white", + borderColor: "@color.gray-200", + color: "@color.text", + "&:hover": { + borderColor: "@color.gray-300", + }, + "&:dark": { + background: "@color.gray-900", + borderColor: "@color.gray-700", + color: "@color.white", + }, + "&:dark:hover": { + borderColor: "@color.gray-600", + }, + }, + }); + }); + + it("should override the focus-within ring when invalid", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + const cv = recipe.compoundVariants!.find( + (v) => v.match.invalid === "true", + ); + + expect(cv).toEqual({ + match: { invalid: "true" }, + css: { + borderColor: "@color.error", + "&:hover": { + borderColor: "@color.error", + }, + "&:focus-within": { + outlineColor: "@color.error", + }, + "&:dark": { + borderColor: "@color.error", + }, + "&:dark:hover": { + borderColor: "@color.error", + }, + }, + }); + }); + + it("should dim and block interaction when disabled", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s); + + const cv = recipe.compoundVariants!.find( + (v) => v.match.disabled === "true", + ); + + expect(cv).toEqual({ + match: { disabled: "true" }, + css: { + opacity: "0.5", + cursor: "not-allowed", + pointerEvents: "none", + }, + }); + }); + }); + + describe("setup callback", () => { + it("should register a .input-date-field selector", () => { + const s = createInstance(); + useInputDateRecipe(s); + + const found = s.root.children.find( + (child) => + child.type === "selector" && + (child as { query: string }).query === ".input-date-field", + ); + + expect(found).toBeDefined(); + }); + + it("should register a .input-date-separator selector", () => { + const s = createInstance(); + useInputDateRecipe(s); + + const found = s.root.children.find( + (child) => + child.type === "selector" && + (child as { query: string }).query === ".input-date-separator", + ); + + expect(found).toBeDefined(); + }); + }); + + describe("filter", () => { + it("should filter color variants", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect(Object.keys(recipe.variants!.color)).toEqual(["neutral"]); + }); + + it("should prune color-scoped compound variants when filtering colors", () => { + const s = createInstance(); + const recipe = useInputDateRecipe(s, { + filter: { color: ["neutral"] }, + }); + + expect( + recipe.compoundVariants!.every( + (cv) => cv.match.color === undefined || cv.match.color === "neutral", + ), + ).toBe(true); + expect(recipe.compoundVariants!.length).toBeLessThan(12); + }); + }); +}); diff --git a/theme/src/recipes/input-date/useInputDateRecipe.ts b/theme/src/recipes/input-date/useInputDateRecipe.ts new file mode 100644 index 00000000..1e60fb43 --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateRecipe.ts @@ -0,0 +1,53 @@ +import { createFieldRecipe } from "../input/createFieldRecipe"; + +/** + * InputDate wrapper recipe. The `.input-date` class sits on a wrapper element + * that contains an optional inline `#prefix`/`#suffix` slot (reusing the Input + * prefix/suffix recipes for a leading calendar icon) and a nested + * `` that lays out the editable date/time + * segments (`.input-date-segment`) and separators (`.input-date-separator`). + * The wrapper owns the visual field (border, background, padding, focus ring via + * `:focus-within`); the inner segments are transparent and inherit typography. + * + * A member of the field recipes family — color (light, dark, neutral), variant + * (default, soft, ghost), size, and the `invalid` / `disabled` / `readonly` + * boolean axes — built from the shared field surface in + * `../input/createFieldRecipe`; the wrapper sits inline (inline-flex, vertically + * centered). Calendar/popover styling is out of scope (see the `calendar` + * recipe). The setup callback turns the shared `.input-date-field` reset into a + * horizontal flex row for the segments and registers the muted separator glyph. + */ +export const useInputDateRecipe = createFieldRecipe( + "input-date", + { + base: { + display: "inline-flex", + alignItems: "center", + }, + }, + (s) => { + const { selector } = s; + + // Lay the segments + separators out as a horizontal row inside the + // shared transparent field container. "calc(@spacing * 0.125)" is an + // embedded reference expression: Styleframe parses the "@spacing" ref and + // keeps the surrounding calc arithmetic, so a raw selector can scale a + // token inline without a pre-declared "0.125" multiplier variable. + selector(".input-date-field", { + display: "flex", + alignItems: "center", + gap: "calc(@spacing * 0.125)", + }); + + // Muted, non-selectable date/range separator glyph (e.g. "/" or "–"). + selector(".input-date-separator", { + color: "@color.text-weak", + userSelect: "none", + paddingLeft: "calc(@spacing * 0.125)", + paddingRight: "calc(@spacing * 0.125)", + "&:dark": { + color: "@color.gray-400", + }, + }); + }, +); diff --git a/theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts b/theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts new file mode 100644 index 00000000..50f57cfa --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateSegmentRecipe.test.ts @@ -0,0 +1,134 @@ +import { styleframe } from "@styleframe/core"; +import { useFocusModifier } from "../../modifiers/usePseudoStateModifiers"; +import { useInputDateSegmentRecipe } from "./useInputDateSegmentRecipe"; + +function createInstance() { + const s = styleframe(); + for (const name of [ + "display", + "alignItems", + "justifyContent", + "boxSizing", + "fontFamily", + "fontWeight", + "fontSize", + "lineHeight", + "fontVariantNumeric", + "paddingTop", + "paddingBottom", + "paddingLeft", + "paddingRight", + "borderRadius", + "background", + "color", + "outline", + "transitionProperty", + "transitionTimingFunction", + "transitionDuration", + ]) { + s.utility(name, ({ value }) => ({ [name]: value })); + } + useFocusModifier(s); + return s; +} + +describe("useInputDateSegmentRecipe", () => { + it("should create a recipe with correct metadata", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.type).toBe("recipe"); + expect(recipe.name).toBe("input-date-segment"); + }); + + it("should have transparent, centered base styles with a focus highlight", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.base).toEqual({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + fontFamily: "inherit", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + fontVariantNumeric: "tabular-nums", + paddingTop: "@0.125", + paddingBottom: "@0.125", + background: "transparent", + color: "inherit", + outline: "none", + transitionProperty: "color, background-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + "&:focus": { + background: "@color.primary", + color: "@color.white", + }, + }); + }); + + it("should have size variants with scaled font, padding and radius", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.variants!.size).toEqual({ + sm: { + fontSize: "@font-size.xs", + paddingLeft: "@0.125", + paddingRight: "@0.125", + borderRadius: "@border-radius.sm", + }, + md: { + fontSize: "@font-size.sm", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.sm", + }, + lg: { + fontSize: "@font-size.md", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.md", + }, + }); + }); + + it("should have correct default variants", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.defaultVariants).toEqual({ size: "md" }); + }); + + it("should not declare a color or variant axis", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s); + + expect(recipe.variants).not.toHaveProperty("color"); + expect(recipe.variants).not.toHaveProperty("variant"); + }); + + describe("config overrides", () => { + it("should allow overriding base styles", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s, { + base: { justifyContent: "flex-start" }, + }); + + expect(recipe.base!.justifyContent).toBe("flex-start"); + }); + }); + + describe("filter", () => { + it("should filter size variants", () => { + const s = createInstance(); + const recipe = useInputDateSegmentRecipe(s, { + filter: { size: ["md"] }, + }); + + expect(Object.keys(recipe.variants!.size)).toEqual(["md"]); + }); + }); +}); diff --git a/theme/src/recipes/input-date/useInputDateSegmentRecipe.ts b/theme/src/recipes/input-date/useInputDateSegmentRecipe.ts new file mode 100644 index 00000000..59b5b889 --- /dev/null +++ b/theme/src/recipes/input-date/useInputDateSegmentRecipe.ts @@ -0,0 +1,65 @@ +import { createUseRecipe } from "../../utils/createUseRecipe"; + +/** + * InputDate segment recipe. The `.input-date-segment` class sits on each + * individually-focusable date/time field part (day, month, year, hour, minute) + * rendered inside the `.input-date-field` row. Like the OTP cell it is the + * focusable element itself, but unlike the cell it is inline and unboxed: it is + * transparent, inherits typography and color from the wrapper, centers its + * digits with tabular figures, and only paints a highlight on `:focus`. + * + * Carries no color/variant axis — the surface lives on the `.input-date` + * wrapper. Its single `size` axis scales the font, horizontal padding, and + * corner radius so the segment tracks the wrapper size. The focus highlight uses + * absolute tokens (`@color.primary` background, `@color.white` text) so it reads + * correctly in both light and dark mode. + */ +export const useInputDateSegmentRecipe = createUseRecipe("input-date-segment", { + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + fontFamily: "inherit", + fontWeight: "@font-weight.normal", + lineHeight: "@line-height.normal", + fontVariantNumeric: "tabular-nums", + paddingTop: "@0.125", + paddingBottom: "@0.125", + background: "transparent", + color: "inherit", + outline: "none", + transitionProperty: "color, background-color", + transitionTimingFunction: "@easing.ease-in-out", + transitionDuration: "150ms", + "&:focus": { + background: "@color.primary", + color: "@color.white", + }, + }, + variants: { + size: { + sm: { + fontSize: "@font-size.xs", + paddingLeft: "@0.125", + paddingRight: "@0.125", + borderRadius: "@border-radius.sm", + }, + md: { + fontSize: "@font-size.sm", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.sm", + }, + lg: { + fontSize: "@font-size.md", + paddingLeft: "@0.25", + paddingRight: "@0.25", + borderRadius: "@border-radius.md", + }, + }, + }, + defaultVariants: { + size: "md", + }, +});