refactor(ui): huge progress on detailwinding

This commit is contained in:
melvinchia3636
2026-04-20 09:11:06 +08:00
parent 348a504e73
commit 022ccc3a7b
64 changed files with 2537 additions and 1642 deletions

View File

@@ -8,10 +8,10 @@ You are a specialist at migrating lifeforge-ui components from Tailwind CSS to p
Before touching ANY file, you MUST:
1. Read the full de-tailwind instruction file at `.github/instructions/de-tailwind.instructions.md`.
2. Read the **complete** source file you are about to modify — understand every conditional class, dark-mode variant, hover/focus state, and context selector.
3. Read the primitive component prop types by searching for `Box`, `Flex`, `Grid`, `Text` exports in `packages/lifeforge-ui/src/components/primitives/`.
4. Consult the space token table in the instructions to map every Tailwind spacing value before writing.
1. Read the **complete** source file you are about to modify — understand every conditional class, dark-mode variant, hover/focus state, and context selector.
2. Read the **primitive component prop types** (see §1 below) — know exactly which props are available so you don't reach for `style={}` when a prop exists.
3. Consult the **space token table** (see §2 below) to map every Tailwind spacing value before writing.
4. Understand the **system architecture** (see §0.5 below) before writing any `.css.ts` file.
## Constraints
@@ -22,121 +22,588 @@ Before touching ANY file, you MUST:
- DO NOT add comments, docstrings, or explanations to code you did not change.
- DO NOT refactor logic, rename variables, or make any change beyond the styling migration.
- ONLY work on files under `packages/lifeforge-ui/src/`.
- **Prefer `asChild` over `.css.ts`**: before writing a new `.css.ts` style, check whether wrapping the target element with a primitive using `asChild` achieves the same result. Using `asChild` eliminates wrapper DOM nodes AND removes the need for a CSS export entirely. Only fall back to `.css.ts` for properties that primitives cannot express (e.g. `transition`, complex selectors, context selectors like `.bordered &`).
- **`asChild` MUST be tried first — ALWAYS, before writing any `.css.ts` export.** For every non-primitive element that needs color, spacing, or layout styling, ask: "can a primitive with `asChild` express this?" If yes, use `asChild`. Writing a `.css.ts` export when `asChild` would have worked is a mistake. Only reach for `.css.ts` when primitives are genuinely incapable of expressing the property: `transition`, `cursor`, `borderWidth/style`, complex `boxShadow`, and context selectors (`.bordered &`, `.has-bg-image &`).
## Approach
---
1. **Audit the file** — catalogue every Tailwind class found; classify each as layout, theme-adaptive color, structural/interactive, or context-selector.
## §0.5 — System Architecture
2. **Plan primitive replacements** — map `flex`, `grid`, bare `div`/`span`/`p` wrappers to `<Flex>`, `<Grid>`, `<Box>`, `<Text>` with correct props using §3 of the instructions.
### Source files
3. **Prefer `asChild` over `.css.ts`** — for every element where you would write a new `.css.ts` export, first check whether the element is a 3rd-party or Headless UI component that accepts `className`. If it does, wrap it with a primitive using `asChild` instead:
```tsx
// ✅ Preferred — no .css.ts export needed
<Box asChild p="md" rounded="lg" bg={{ base: 'bg-50', dark: 'bg-900' }}>
<HeadlessUIComponent />
</Box>
| Path | Purpose |
|------|---------|
| `src/system/colors.ts` | `colors` map (sprinkle values), `bg[n]` / `custom[n]` objects (for `style()` calls), `ColorToken` type |
| `src/system/vars.css.ts` | `vars` design tokens — `space`, `radii`, `fontSize`, `lineHeight`, `fontWeight` |
| `src/system/sprinkles.css.ts` | `commonProperties` + `themeColorProperties` + `commonSprinkles` |
| `src/system/types.ts` | `ThemeCondition`, `ThemeConditionProp<T>`, all prop interfaces |
| `src/system/responsive.ts` | `responsiveConditions`, `normalizeResponsiveProp`, `ResponsiveProp<T>` |
| `src/system/layout-utils.ts` | `getResponsiveLayoutStyles`, `resolveCommonSprinkleProps` |
| `src/system/index.ts` | Barrel — everything above re-exported |
// ❌ Only if primitive props cannot express the property
<HeadlessUIComponent className={styles.wrapper} />
```
Reserve `.css.ts` exports exclusively for properties primitives cannot express: `transition`, `cursor`, `border-width/style`, complex `box-shadow`, hover/focus interactive states, and context selectors (`.bordered &`, `.has-bg-image &`).
All imports from the system use the `@/system` alias.
4. **Apply the theme-color decision tree for every colored element:**
```
Is it a container primitive (Box/Flex/Grid/Container/Section)?
└─ YES → use `bg` prop: bg={{ base: 'bg-50', dark: 'bg-900' }}
use `shadow` prop for box-shadow
└─ NO → Is it a <Text>?
└─ YES → use `color`/`bg` props: color={{ base: 'bg-500', dark: 'bg-50' }}
└─ NO → Can `asChild` on a primitive replace the wrapper?
└─ YES → use `asChild`
└─ NO → write a .css.ts using themeColorProperties sprinkle (Pattern B)
```
### `ThemeConditionProp<T>`
5. **For components (`.tsx`)** — create or update the sibling `ComponentName.css.ts`:
- **Pattern A — `style()`**: for `transition`, `cursor`, `borderWidth`, complex shadows, and `.bordered &` / `.has-bg-image &` context selectors that sprinkles cannot express.
- **Pattern B — `themeColorProperties` sprinkle**: for theme-adaptive `backgroundColor`, `color`, or `borderColor` on non-primitive child elements.
```ts
import { createSprinkles } from '@vanilla-extract/sprinkles'
import { themeColorProperties } from '@/system'
const sprinkles = createSprinkles(themeColorProperties)
export const title = sprinkles({ color: { base: 'bg-500', dark: 'bg-50' } })
```
A union type that accepts either a plain value **or** a per-condition map:
6. **For stories (`.stories.tsx`)** — use primitives only; no `.css.ts`.
```ts
type ThemeConditionProp<T> = T | Partial<Record<ThemeCondition, T>>
// ThemeCondition = 'base' | 'dark' | 'hover' | 'darkHover' | 'hasBgImage' | 'darkHasBgImage'
```
7. **Apply edits** — replace layout `div`/`span` wrappers and `className` props with primitives; import `* as styles from './ComponentName.css'`; use `clsx()` for conditional class composition.
The six conditions map to selectors:
8. **Verify** — search the edited file for any remaining Tailwind class names and fix them. Run `get_errors` to confirm zero TypeScript errors.
9. **Update `TAILWIND_MIGRATION.md`** — after `get_errors` passes, mark the migrated component(s) as `[x]` in `packages/lifeforge-ui/TAILWIND_MIGRATION.md` and update the progress summary table (Migrated count, Total, and % Done for the affected category row and the **Total** row).
## Styling Categorisation Rules
| Property type | Destination |
|---|---|
| `display`, `flex-direction`, `gap`, `padding`, `margin`, `width`, `height`, `overflow`, `position`, `inset` | Primitive prop |
| `background-color` on a container primitive | **`bg` prop** — `bg={{ base: 'bg-50', dark: 'bg-900' }}` |
| `background-color` on a non-primitive child element | **Pattern B** — `.css.ts` sprinkle |
| `color` on `<Text>` | **`color` prop** — `color={{ base: 'bg-500', dark: 'bg-50' }}` |
| `color` on a non-Text element | **Pattern B** — `.css.ts` sprinkle |
| `border-color` | **Pattern B** — `.css.ts` sprinkle |
| `border-radius` | **`rounded` prop** on container OR `borderRadius: vars.radii.*` in `.css.ts` |
| `box-shadow` (`shadow-custom`) | **`shadow` prop** on container primitive |
| `box-shadow` (complex/non-standard) | **Pattern A** — `.css.ts` `style()` |
| `transition`, `cursor`, `hover`, `focus`, `active` | **Pattern A** — `.css.ts` `style()` |
| Dark-mode variants | **`bg`/`color` prop** (preferred) OR **Pattern B** OR **Pattern A** selectors |
| Context selectors (`.bordered &`, `.has-bg-image &`) | **Pattern A** — `.css.ts` `style()` selectors |
| `font-weight`, `font-size` | `<Text weight= size=>` props |
| Spacing not in token table | `style={{ ... }}` inline on the primitive |
## `ThemeConditionProp` — condition keys
All `bg`, `color`, and `borderColor` (sprinkle) props accept a per-condition map:
| Key | Selector |
|-----|----------|
| `base` | (default) |
| Condition | Selector |
|-----------|----------|
| `base` | (default — no selector) |
| `dark` | `.dark &` |
| `hover` | `&:hover` |
| `darkHover` | `.dark &:hover` |
| `hasBgImage` | `.has-bg-image &` |
| `darkHasBgImage` | `.dark .has-bg-image &` |
### `themeColorProperties` (from `@/system`)
A sprinkle `defineProperties` block that applies the 6 conditions above to
`backgroundColor`, `color`, and `borderColor` — all keyed by `ColorToken`
(`'transparent' | 'bg-50'…'bg-950' | 'custom-50'…'custom-900'`).
All container primitives compose this into their `createSprinkles(...)` call,
which is what enables the `bg` prop. Component `.css.ts` files can also compose
it when they need theme-adaptive per-element colors.
### Color token helpers
| Import | Use in |
|--------|--------|
| `colors` (from `@/system`) | Sprinkle property values in `defineProperties({ properties: { color: colors } })` |
| `bg[n]` (from `@/system`) | `style()` calls — e.g. `backgroundColor: bg[50]` |
| `custom[n]` (from `@/system`) | `style()` calls — e.g. `color: custom[500]` |
| `withOpacity(token, 0.1)` (from `@/system`) | opacity-modified colors — `withOpacity(bg[500], 0.2)` |
> **NO raw hex strings or hardcoded `var(--color-*)` in `.css.ts` files.**
> Always use `bg[n]`, `custom[n]`, or `withOpacity(...)`.
---
## §1 — Primitive Components
Import from `@components/primitives`.
### Container primitives — shared props
All five container primitives (`Box`, `Flex`, `Grid`, `Container`, `Section`)
share these additional props beyond their own layout props:
| Prop | Type | Description |
|------|------|-------------|
| `bg` | `ThemeConditionProp<ColorToken>` | Theme-adaptive background color |
| `rounded` | `ResponsiveProp<RadiusToken>` | Border radius token |
| `shadow` | `boolean` | Adds `var(--custom-shadow)` box shadow |
The `bg` prop accepts either a flat token or a per-condition map:
```tsx
<Box bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }} shadow />
<Text color={{ base: 'bg-500', dark: 'bg-50', hover: 'bg-800' }} />
// flat — same in all conditions
<Box bg="bg-50" />
// adaptive — different per theme condition
<Box bg={{ base: 'bg-50', dark: 'bg-900' }} />
<Flex bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }} />
<Grid bg={{ base: 'bg-100', hasBgImage: 'transparent', darkHasBgImage: 'transparent' }} />
```
## Token helpers (`.css.ts` only)
### `Box`
| Need | Import from `@/system` |
|------|------------------------|
| Background by shade | `bg[50]` … `bg[950]` |
| Accent by shade | `custom[50]` … `custom[900]` |
| With opacity | `withOpacity(bg[500], 0.2)` |
General-purpose block element. Accepts all [Layout Props] + [Margin Props].
```tsx
<Box
as="section" // any HTML tag (default: div)
display="block" // 'block' | 'inline' | 'inline-block' | 'none' | 'contents'
bg={{ base: 'bg-50', dark: 'bg-900' }} // ThemeConditionProp<ColorToken>
rounded="md" // border-radius token: 'none'|'sm'|'md'|'lg'|'xl'|'2xl'|'3xl'|'full'
shadow // adds var(--custom-shadow)
// Layout props (CSS strings, responsive):
width="100%"
minWidth="0"
maxWidth="48rem"
height="100%"
// Positioning:
position="relative" // 'static'|'relative'|'absolute'|'fixed'|'sticky'
inset="0"
top="0" right="0" bottom="0" left="0"
overflow="hidden" // 'visible'|'hidden'|'scroll'|'auto'
overflowX="auto"
overflowY="hidden"
// Grid-child props (CSS strings):
gridColumn="span 2 / span 2"
gridRow="span 2 / span 2"
flexBasis="0" flexGrow="1" flexShrink="0"
// Padding (SpaceToken):
p="md" px="lg" py="sm" pt="xs" pr="md" pb="sm" pl="xl"
// Margin (SpaceToken):
m="md" mx="auto" my="lg" mt="xs" mr="sm" mb="md" ml="xl"
className="extra-class"
style={{ /* arbitrary overrides */ }}
/>
```
### `Flex`
Flexbox container.
```tsx
<Flex
as="h2"
display="flex" // 'flex' | 'inline-flex' | 'none'
direction="row" // 'row'|'column'|'row-reverse'|'column-reverse'
align="center" // 'stretch'|'center'|'start'|'end'|'baseline'
justify="between" // 'start'|'center'|'between'|'around'|'evenly'|'end'
wrap="wrap" // 'nowrap'|'wrap'|'wrap-reverse'
gap="md" // SpaceToken
gapX="sm" gapY="lg"
flexShrink="0" // CSS string '0'|'1'
flexGrow="1"
bg={{ base: 'bg-50', dark: 'bg-900' }}
shadow
// + all Box layout/margin/padding props
/>
```
### `Grid`
CSS Grid container.
```tsx
<Grid
columns="repeat(3, minmax(0, 1fr))" // CSS string → gridTemplateColumns
rows="repeat(3, minmax(0, 1fr))" // CSS string → gridTemplateRows
gap="lg"
gapX="sm" gapY="md"
align="center" // 'stretch'|'center'|'start'|'end'|'baseline'
justify="between" // 'start'|'center'|'end'|'between'
flow="row" // 'row'|'column'|'dense'|'row dense'|'column dense'
bg={{ base: 'bg-50', dark: 'bg-900' }}
shadow
// + all Box layout/margin/padding props
/>
```
### `Text`
Inline text/span. Renders as `<span>` by default.
```tsx
<Text
as="p" // any HTML tag
size="lg" // 'sm'|'base'|'lg'|'xl'|'2xl'|...|'9xl'
// color accepts ThemeConditionProp — flat or per-condition:
color="bg-600"
color={{ base: 'bg-500', dark: 'bg-50' }}
// Named semantic colors: 'default'(bg-900) | 'muted'(bg-500) | 'primary'(custom-500) | 'inherit'
// Full palette: 'bg-50'…'bg-950' | 'custom-50'…'custom-900'
bg={{ base: 'bg-100', dark: 'bg-800' }} // backgroundColor, same token set as color
weight="semibold" // 'normal'|'medium'|'semibold'|'bold'
align="center" // 'left'|'center'|'right'
decoration="underline"
transform="uppercase"
wrap="nowrap"
whiteSpace="pre-wrap" // 'normal'|'nowrap'|'pre'|'pre-line'|'pre-wrap'|'break-spaces'
wordBreak="break-all" // 'normal'|'break-all'|'keep-all'
overflowWrap="anywhere" // 'normal'|'break-word'|'anywhere'
truncate // overflow:hidden + ellipsis (shorthand boolean — list BEFORE other props)
lineClamp={3}
// Margin + Padding props
m="sm" mx="auto" mt="xs"
p="sm" px="md"
/>
```
> **`color` and `bg` on `Text` use `ThemeConditionProp`, not `ResponsiveProp`.**
> They respond to dark mode / hover conditions, not breakpoints.
### `asChild` pattern
All primitives support `asChild`. When set, the primitive merges its sprinkle classes and inline style variables onto the single child element instead of rendering a wrapper DOM node:
```tsx
// ✅ Preferred — no extra DOM node, no .css.ts export needed
<Box asChild p="md" rounded="lg" bg={{ base: 'bg-50', dark: 'bg-900' }}>
<HeadlessUIComponent />
</Box>
// ✅ Text asChild — applies color/weight to any element
<Text asChild color={{ base: 'bg-400', dark: 'bg-600' }} weight="medium">
<Flex align="center" gap="sm">...</Flex>
</Text>
```
> **Important:** when using `asChild`, the child must accept and forward both `className` AND `style`. CSS variables from sprinkles are injected via the inline `style` prop — if the child does not forward `style`, theme-adaptive colors will silently fail.
> **`asChild` is not optional.** It is the default choice for any non-primitive element that needs primitive-expressible styling. `.css.ts` is the last resort — only when `asChild` cannot physically express the required CSS property.
### Responsive props
All `SpaceToken` and most layout/size props accept a responsive object:
```tsx
<Text size={{ base: 'lg', sm: 'xl' }} />
<Flex p={{ base: 'sm', sm: 'md' }} />
```
Breakpoints: `base` | `sm` (640px) | `md` (768px) | `lg` (1024px) | `xl` (1280px) | `2xl` (1536px)
---
## §2 — Space Token Reference
`--spacing` is Tailwind's default `0.25rem` per unit.
| SpaceToken | Value | Tailwind equivalent |
|------------|-------|---------------------|
| `none` | `0` | `0` |
| `xs` | `calc(var(--spacing) * 1)` = 0.25rem | `1` |
| `sm` | `calc(var(--spacing) * 2)` = 0.5rem | `2` |
| `md` | `calc(var(--spacing) * 4)` = 1rem | `4` |
| `lg` | `calc(var(--spacing) * 6)` = 1.5rem | `6` |
| `xl` | `calc(var(--spacing) * 8)` = 2rem | `8` |
| `2xl` | `calc(var(--spacing) * 12)` = 3rem | `12` |
| `3xl` | `calc(var(--spacing) * 16)` = 4rem | `16` |
Spacing values **not** in this table (e.g. `gap-3` = 0.75rem, arbitrary `mt-[30%]`) must use an inline `style` prop:
```tsx
<Grid style={{ gap: '0.75rem', marginTop: '30%' }} />
```
---
## §3 — Common Tailwind → Primitive Mapping
| Tailwind class | Primitive equivalent |
|---|---|
| `flex` | `<Flex>` |
| `flex flex-col` | `<Flex direction="column">` |
| `flex items-center justify-center` | `<Flex align="center" justify="center">` |
| `flex items-center justify-between` | `<Flex align="center" justify="between">` |
| `flex-center` (utility) | `<Flex align="center" justify="center">` |
| `grid grid-cols-3` | `<Grid columns="repeat(3, minmax(0, 1fr))">` |
| `gap-6` | `gap="lg"` |
| `p-16` | `p="3xl"` |
| `px-16` | `px="3xl"` |
| `w-full` | `width="100%"` |
| `h-full` | `height="100%"` |
| `min-w-0` | `minWidth="0"` |
| `min-w-64` | `minWidth="16rem"` |
| `size-full` | `width="100%" height="100%"` on `<Box>` or `<Flex>` |
| `col-span-2` | `gridColumn="span 2 / span 2"` on a wrapping `<Box>` |
| `row-span-2` | `gridRow="span 2 / span 2"` on a wrapping `<Box>` |
| `shrink-0` | `flexShrink="0"` |
| `text-bg-500` | `<Text color="bg-500">` |
| `text-bg-500 dark:text-bg-50` | `<Text color={{ base: 'bg-500', dark: 'bg-50' }}>` |
| `text-lg` | `<Text size="lg">` |
| `font-semibold` | `<Text weight="semibold">` |
| `truncate` | `<Text truncate>`**shorthand must come first** |
| `text-lg sm:text-xl` | `<Text size={{ base: 'lg', sm: 'xl' }}>` |
| `overflow-hidden` | `overflow="hidden"` |
| `position-relative` | `position="relative"` |
| `mb-1` | `mb="xs"` |
| `p-2 sm:p-4` | `p={{ base: 'sm', sm: 'md' }}` |
| `bg-bg-50 dark:bg-bg-900` | `bg={{ base: 'bg-50', dark: 'bg-900' }}` on a container |
| `hover:bg-bg-100 dark:hover:bg-bg-800` | `bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }}` |
| `shadow-custom` | `shadow` on any container primitive |
---
## §4 — Styling Categorisation
| Property type | Destination |
|---|---|
| `display`, `flex-direction`, `gap`, `padding`, `margin`, `width`, `height`, `overflow`, `position`, `inset` | **Primitive prop** |
| `backgroundColor` of a container primitive | **`bg` prop** — `bg={{ base: 'bg-50', dark: 'bg-900' }}` |
| `backgroundColor` of a non-primitive child | **`asChild` on `<Box>`/`<Flex>`** (always first) — only use `.css.ts` sprinkle if `asChild` is impossible |
| `color` of a `<Text>` | **`color` prop** — `color={{ base: 'bg-500', dark: 'bg-50' }}` |
| `color` of a non-Text element | **`asChild` with `<Text>`** — `.css.ts` sprinkle only if the element cannot accept/forward `className` + `style` |
| `borderColor` | **`.css.ts` sprinkle** (Pattern B) |
| `borderRadius` | **`rounded` prop** on container OR `borderRadius: vars.radii.*` in `.css.ts` |
| `box-shadow` (`shadow-custom`) | **`shadow` prop** on container primitive |
| `box-shadow` (complex/non-standard) | **Pattern A**`.css.ts` `style()` |
| `transition`, `cursor`, `hover`, `focus`, `active` | **Pattern A**`.css.ts` `style()` |
| Dark-mode variants | **`bg`/`color` prop** (preferred) OR **Pattern B** OR **Pattern A** selectors |
| Context selectors (`.bordered &`, `.has-bg-image &`) | **Pattern A**`.css.ts` `style()` selectors |
| `font-weight`, `font-size` | **`<Text weight= size=>`** props |
| Spacing not in token table | **`style={{ ... }}`** inline on the primitive |
### Theme-adaptive color decision tree
**Follow this tree strictly, top to bottom. Do not skip ahead to `.css.ts`.**
```
Is the element a container primitive (Box/Flex/Grid/Container/Section)?
└─ YES → use `bg` prop: <Box bg={{ base: 'bg-50', dark: 'bg-900' }}>
use `shadow` prop for box-shadow
└─ NO → Is it a <Text>?
└─ YES → use `color`/`bg` props: <Text color={{ base: 'bg-500', dark: 'bg-50' }}>
└─ NO → MANDATORY: try `asChild` on a primitive first.
Does the element accept and forward className + style?
└─ YES → use `asChild`. STOP. Do not write a .css.ts export.
└─ NO → only now may you write a .css.ts sprinkle (Pattern B)
```
> **If you find yourself writing a `.css.ts` export for color or background, stop and ask: could `asChild` have handled this? If yes, revert and use `asChild`.**
---
## §5 — `.css.ts` Patterns
### Pattern A: `style()` for structural/interactive-only styles
Use for `boxShadow`, `transition`, `cursor`, `borderWidth`, and rules that depend on **parent context selectors** (`.bordered &`) where a sprinkle cannot be used.
```ts
import { style } from '@vanilla-extract/css'
import { bg, custom, withOpacity, vars } from '@/system'
export const wrapper = style({
borderRadius: vars.radii.lg,
transition: 'all 0.2s',
backgroundColor: bg[50],
selectors: {
'.dark &': { backgroundColor: bg[900] },
'.bordered &': { borderWidth: '2px', borderStyle: 'solid' }
}
})
```
### Pattern B: `themeColorProperties` sprinkle for theme-adaptive colors on child elements
When a **non-primitive element inside a component** needs adaptive `backgroundColor`, `color`, or `borderColor` across dark / hover / hasBgImage conditions:
```ts
import { createSprinkles } from '@vanilla-extract/sprinkles'
import { style } from '@vanilla-extract/css'
import { themeColorProperties, vars } from '@/system'
const sprinkles = createSprinkles(themeColorProperties)
export const iconWrapper = style([
{ borderRadius: vars.radii.lg },
sprinkles({ backgroundColor: { base: 'bg-100', dark: 'bg-800' } })
])
export const titleText = sprinkles({
color: { base: 'bg-500', dark: 'bg-50' }
})
export const optionInactive = sprinkles({
color: { base: 'bg-500', hover: 'bg-800', darkHover: 'bg-50' }
})
```
`themeColorProperties` supports:
- `backgroundColor: ColorToken | Partial<Record<ThemeCondition, ColorToken>>`
- `color: ColorToken | Partial<Record<ThemeCondition, ColorToken>>`
- `borderColor: ColorToken | Partial<Record<ThemeCondition, ColorToken>>`
### Token helpers (`.css.ts` only)
| Need | Code |
|------|------|
| Background color | `bg[50]``bg[950]` |
| Accent color | `custom[50]``custom[900]` |
| Color with opacity | `withOpacity(bg[500], 0.1)` |
| Border radius | `vars.radii.sm/md/lg/xl/2xl/3xl/full` |
| Space value | `vars.space.xs/sm/md/lg/xl/2xl/3xl` |
| Font size | `vars.fontSize.*` |
| Font weight | `vars.fontWeight.*` |
| Font size | `vars.fontSize.sm/base/lg/xl/.../9xl` |
| Font weight | `vars.fontWeight.normal/medium/semibold/bold` |
| Box shadow | `boxShadow: 'var(--custom-shadow)'` |
## Space Token Quick Reference
### Common Tailwind utility → `.css.ts` mapping
| SpaceToken | Tailwind |
| Tailwind utility | `.css.ts` equivalent |
|---|---|
| `none` | `0` |
| `xs` | `1` (0.25rem) |
| `sm` | `2` (0.5rem) |
| `md` | `4` (1rem) |
| `lg` | `6` (1.5rem) |
| `xl` | `8` (2rem) |
| `2xl` | `12` (3rem) |
| `3xl` | `16` (4rem) |
| `component-bg` | `bg={{ base: 'bg-50', dark: 'bg-900' }}` on primitive (preferred) OR `backgroundColor: bg[50]` + `.dark &` selector |
| `component-bg-lighter` | `backgroundColor: bg[100]` + `.dark &``withOpacity(bg[800], 0.5)` |
| `shadow-custom` | `shadow` prop on primitive OR `boxShadow: 'var(--custom-shadow)'` |
| `border-bg-500/20` | `borderColor: withOpacity(bg[500], 0.2)` |
| `bg-bg-500/10` | `backgroundColor: withOpacity(bg[500], 0.1)` |
| `text-bg-500 dark:text-bg-50` | `<Text color={{ base: 'bg-500', dark: 'bg-50' }}>` OR `sprinkles({ color: { base: 'bg-500', dark: 'bg-50' } })` in css.ts |
| `in-[.bordered]:border-2` | `selectors: { '.bordered &': { borderWidth: '2px', borderStyle: 'solid' } }` |
| `hover:bg-bg-100` | `bg={{ ..., hover: 'bg-100' }}` on primitive OR `sprinkles({ backgroundColor: { hover: 'bg-100' } })` |
| `transition-all` | `transition: 'all 0.2s'` |
| `rounded-lg` | `rounded="lg"` prop on primitive, or `borderRadius: vars.radii.lg` in `.css.ts` |
| `text-2xl sm:text-3xl` | `<Text size={{ base: '2xl', sm: '3xl' }}>` |
Any Tailwind spacing value not in this table (e.g. `gap-3`, `mt-5`, `p-10`) must use an inline `style` prop with the raw `rem` value.
---
## Preservation Checklist
## §6 — Workflow
### Components (`.tsx` + `.css.ts`)
1. **Audit the file** — catalogue every Tailwind class; classify as layout, theme-adaptive color, structural/interactive, or context-selector.
2. **Plan primitive replacements** — map `flex`/`grid`/`div`/`span` wrappers to `<Flex>`/`<Grid>`/`<Box>`/`<Text>` with correct props using §3.
3. **`asChild` is mandatory before `.css.ts`** — for every styled non-primitive element, you MUST evaluate `asChild` first. Only proceed to `.css.ts` if `asChild` is structurally impossible (element does not forward `className`/`style`, or the property is `transition`/`cursor`/context selector).
4. **Create `ComponentName.css.ts`** only for what genuinely cannot be expressed via primitives — one export per logical role, named by purpose.
5. **Import** with `import * as styles from './ComponentName.css'`; compose with `clsx()`.
### Stories (`.stories.tsx`)
- Use primitives only. No `.css.ts`.
- Replace every `<div className="flex ...">` with `<Flex>`, every `<div>` wrapper with `<Box>`, every `<span>`/`<p>` with `<Text>`.
- Move `col-span-*` / `row-span-*` from child `className` to a wrapping `<Box gridColumn="..." gridRow="...">`.
- Remove unused imports; add `Box`, `Flex`, `Grid`, `Text` from `@components/primitives`.
### Prop ordering lint rule
JSX props must be **alphabetical**, except:
- `ref` — always **first**
- Boolean shorthand props (e.g. `truncate`, `shadow`) — always **before** other props
---
## §7 — Examples
### Story: grid wrapper with spanning children
```tsx
// Before
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-6 p-16">
<Widget className="col-span-2 row-span-2" />
</div>
// After
<Grid
columns="repeat(3, minmax(0, 1fr))"
gap="lg"
height="100%"
p="3xl"
rows="repeat(3, minmax(0, 1fr))"
width="100%"
>
<Box gridColumn="span 2 / span 2" gridRow="span 2 / span 2">
<Widget />
</Box>
</Grid>
```
### Component: themed wrapper — bg on primitive + shadow prop
```tsx
// Before
<div className="shadow-custom component-bg border-bg-500/20 flex size-full flex-col gap-6 rounded-lg p-4 in-[.bordered]:border-2">
// Widget.css.ts — only what primitives can't express
import { style } from '@vanilla-extract/css'
import { withOpacity, bg } from '@/system'
export const wrapper = style({
borderColor: withOpacity(bg[500], 0.2),
selectors: {
'.bordered &': { borderWidth: '2px', borderStyle: 'solid' }
}
})
// After (Widget.tsx) — bg and shadow on primitive props
<Flex
shadow
bg={{ base: 'bg-50', dark: 'bg-900' }}
className={styles.wrapper}
direction="column"
gap="lg"
height="100%"
p="md"
rounded="lg"
width="100%"
>
```
### Component: child element theme colors — themeColorProperties sprinkle
```tsx
// Before
<div className="flex rounded-lg p-2 sm:p-4 bg-bg-100 dark:bg-bg-800/50 mb-1">
<h3 className="w-full min-w-0 truncate text-bg-500 dark:text-bg-50 text-lg">
// Widget.css.ts
import { createSprinkles } from '@vanilla-extract/sprinkles'
import { style } from '@vanilla-extract/css'
import { themeColorProperties, vars } from '@/system'
const sprinkles = createSprinkles(themeColorProperties)
export const iconWrapper = style([
{ borderRadius: vars.radii.lg },
sprinkles({ backgroundColor: { base: 'bg-100', dark: 'bg-800' } })
])
export const titleText = sprinkles({
color: { base: 'bg-500', dark: 'bg-50' }
})
// After (Widget.tsx)
<Flex
className={styles.iconWrapper}
mb="xs"
p={{ base: 'sm', sm: 'md' }}
>
<Text
truncate
as="h3"
className={styles.titleText}
size="lg"
style={{ width: '100%', minWidth: 0 }}
>
```
### Text with theme-adaptive color via prop (no .css.ts needed)
```tsx
// Before
<span className="text-bg-500 dark:text-bg-50 hover:text-bg-800">
// After — ThemeConditionProp directly on Text
<Text color={{ base: 'bg-500', dark: 'bg-50', hover: 'bg-800' }}>
```
### Interactive card — bg with hover/darkHover conditions
```tsx
// Before
<div className="bg-bg-50 dark:bg-bg-900 hover:bg-bg-100 dark:hover:bg-bg-800 cursor-pointer transition-all">
// Card.css.ts — only transition/cursor in style()
import { style } from '@vanilla-extract/css'
export const interactive = style({ cursor: 'pointer', transition: 'all 0.2s' })
// After (Card.tsx)
<Box
shadow
bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }}
className={styles.interactive}
rounded="lg"
>
```
### asChild: applying primitive props to a third-party component
```tsx
// Before
<ComboboxButton className="text-bg-500 size-5">
<Icon icon="tabler:chevron-down" />
</ComboboxButton>
// After — no new .css.ts needed
<Text asChild color="bg-500" style={{ height: '1.25rem', width: '1.25rem' }}>
<ComboboxButton>
<Icon icon="tabler:chevron-down" />
</ComboboxButton>
</Text>
```
---
## §8 — Preservation Checklist
Before marking a file done, verify:
@@ -149,6 +616,15 @@ Before marking a file done, verify:
- [ ] Zero Tailwind class names remain in the file
- [ ] `get_errors` reports zero TypeScript errors
---
## §9 — Completion Steps
After `get_errors` passes:
1. Mark the migrated component(s) as `[x]` in `packages/lifeforge-ui/TAILWIND_MIGRATION.md`.
2. Update the progress summary table — Migrated count, Total, and % Done for the affected category row and the **Total** row.
## Output Format
After completing a file migration, report:
@@ -156,3 +632,4 @@ After completing a file migration, report:
2. Which `.css.ts` file(s) were created or updated.
3. A brief summary of any non-obvious mapping decisions (e.g. spacing values that needed `style={{}}`).
4. Confirmation that `get_errors` passed with zero errors.

View File

@@ -1,609 +0,0 @@
---
applyTo: "packages/lifeforge-ui/src/**"
---
# De-Tailwind Instructions for lifeforge-ui
## Overview
Replace all Tailwind utility classes in components and stories with vanilla-extract
CSS-in-JS and the `lifeforge-ui` primitive components. The goal is zero Tailwind
in component/story source files while **preserving every pixel of styling logic**.
---
## Step 0 — Read Before You Write
Before touching any file, read:
1. The **full component source** — understand every conditional class, every
dark-mode variant, every hover/focus state.
2. The **primitive component prop types** (see §1 below) — know exactly which
props are available so you don't reach for `style={}` when a prop exists.
3. The **space token table** (see §2 below) — map Tailwind spacing to tokens
before writing a single line.
4. The **system architecture** (see §0.5 below) — understand `ThemeConditionProp`
and `themeColorProperties` before writing any `.css.ts` file.
---
## §0.5 — System Architecture
### Source files
| Path | Purpose |
|------|---------|
| `src/system/colors.ts` | `colors` map (sprinkle values), `bg[n]` / `custom[n]` objects (for `style()` calls), `ColorToken` type |
| `src/system/vars.css.ts` | `vars` design tokens — `space`, `radii`, `fontSize`, `lineHeight`, `fontWeight` |
| `src/system/sprinkles.css.ts` | `commonProperties` + `themeColorProperties` + `commonSprinkles` |
| `src/system/types.ts` | `ThemeCondition`, `ThemeConditionProp<T>`, all prop interfaces |
| `src/system/responsive.ts` | `responsiveConditions`, `normalizeResponsiveProp`, `ResponsiveProp<T>` |
| `src/system/layout-utils.ts` | `getResponsiveLayoutStyles`, `resolveCommonSprinkleProps` |
| `src/system/index.ts` | Barrel — everything above re-exported |
All imports from the system use the `@/system` alias.
### `ThemeConditionProp<T>`
A union type that accepts either a plain value **or** a per-condition map:
```ts
type ThemeConditionProp<T> = T | Partial<Record<ThemeCondition, T>>
// ThemeCondition = 'base' | 'dark' | 'hover' | 'darkHover' | 'hasBgImage' | 'darkHasBgImage'
```
The six conditions map to selectors:
| Condition | Selector |
|-----------|----------|
| `base` | (default — no selector) |
| `dark` | `.dark &` |
| `hover` | `&:hover` |
| `darkHover` | `.dark &:hover` |
| `hasBgImage` | `.has-bg-image &` |
| `darkHasBgImage` | `.dark .has-bg-image &` |
### `themeColorProperties` (from `@/system`)
A sprinkle `defineProperties` block that applies the 6 conditions above to
`backgroundColor`, `color`, and `borderColor` — all keyed by `ColorToken`
(`'transparent' | 'bg-50'…'bg-950' | 'custom-50'…'custom-900'`).
All container primitives compose this into their `createSprinkles(...)` call,
which is what enables the `bg` prop. Component `.css.ts` files can also compose
it when they need theme-adaptive per-element colors.
### Color token helpers
| Import | Use in |
|--------|--------|
| `colors` (from `@/system`) | Sprinkle property values in `defineProperties({ properties: { color: colors } })` |
| `bg[n]` (from `@/system`) | `style()` calls — e.g. `backgroundColor: bg[50]` |
| `custom[n]` (from `@/system`) | `style()` calls — e.g. `color: custom[500]` |
| `withOpacity(token, 0.1)` (from `@/system`) | opacity-modified colors — `withOpacity(bg[500], 0.2)` |
> **NO raw hex strings or hardcoded `var(--color-*)` in `.css.ts` files.**
> Always use `bg[n]`, `custom[n]`, or `withOpacity(...)`.
---
## §1 — Primitive Components
Import from `@components/primitives`.
### Container primitives — shared props
All five container primitives (`Box`, `Flex`, `Grid`, `Container`, `Section`)
share these additional props beyond their own layout props:
| Prop | Type | Description |
|------|------|-------------|
| `bg` | `ThemeConditionProp<ColorToken>` | Theme-adaptive background color |
| `rounded` | `ResponsiveProp<RadiusToken>` | Border radius token |
| `shadow` | `boolean` | Adds `var(--custom-shadow)` box shadow |
The `bg` prop accepts either a flat token or a per-condition map:
```tsx
// flat — same in all conditions
<Box bg="bg-50" />
// adaptive — different per theme condition
<Box bg={{ base: 'bg-50', dark: 'bg-900' }} />
<Flex bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }} />
<Grid bg={{ base: 'bg-100', hasBgImage: 'transparent', darkHasBgImage: 'transparent' }} />
```
### `Box`
General-purpose block element. Accepts all [Layout Props] + [Margin Props].
```tsx
<Box
as="section" // any HTML tag (default: div)
display="block" // 'block' | 'inline' | 'inline-block' | 'none' | 'contents'
bg={{ base: 'bg-50', dark: 'bg-900' }} // ThemeConditionProp<ColorToken>
rounded="md" // border-radius token: 'none'|'sm'|'md'|'lg'|'xl'|'2xl'|'3xl'|'full'
shadow // adds var(--custom-shadow)
// Layout props (CSS strings, responsive):
width="100%"
minWidth="0"
maxWidth="48rem"
height="100%"
// Positioning:
position="relative" // 'static'|'relative'|'absolute'|'fixed'|'sticky'
inset="0"
top="0" right="0" bottom="0" left="0"
overflow="hidden" // 'visible'|'hidden'|'scroll'|'auto'
overflowX="auto"
overflowY="hidden"
// Grid-child props (CSS strings):
gridColumn="span 2 / span 2"
gridRow="span 2 / span 2"
flexBasis="0" flexGrow="1" flexShrink="0"
// Padding (SpaceToken):
p="md" px="lg" py="sm" pt="xs" pr="md" pb="sm" pl="xl"
// Margin (SpaceToken):
m="md" mx="auto" my="lg" mt="xs" mr="sm" mb="md" ml="xl"
className="extra-class"
style={{ /* arbitrary overrides */ }}
/>
```
### `Flex`
Flexbox container.
```tsx
<Flex
as="h2"
display="flex" // 'flex' | 'inline-flex' | 'none'
direction="row" // 'row'|'column'|'row-reverse'|'column-reverse'
align="center" // 'stretch'|'center'|'start'|'end'|'baseline'
justify="between" // 'start'|'center'|'between'|'around'|'evenly'|'end'
wrap="wrap" // 'nowrap'|'wrap'|'wrap-reverse'
gap="md" // SpaceToken
gapX="sm" gapY="lg"
flexShrink="0" // CSS string '0'|'1'
flexGrow="1"
bg={{ base: 'bg-50', dark: 'bg-900' }}
shadow
// + all Box layout/margin/padding props
/>
```
### `Grid`
CSS Grid container.
```tsx
<Grid
columns="repeat(3, minmax(0, 1fr))" // CSS string → gridTemplateColumns
rows="repeat(3, minmax(0, 1fr))" // CSS string → gridTemplateRows
gap="lg"
gapX="sm" gapY="md"
align="center" // 'stretch'|'center'|'start'|'end'|'baseline'
justify="between" // 'start'|'center'|'end'|'between'
flow="row" // 'row'|'column'|'dense'|'row dense'|'column dense'
bg={{ base: 'bg-50', dark: 'bg-900' }}
shadow
// + all Box layout/margin/padding props
/>
```
### `Text`
Inline text/span. Renders as `<span>` by default.
```tsx
<Text
as="p" // any HTML tag
size="lg" // 'sm'|'base'|'lg'|'xl'|'2xl'|...|'9xl'
// color accepts ThemeConditionProp — flat or per-condition:
color="bg-600"
color={{ base: 'bg-500', dark: 'bg-50' }}
// Named semantic colors: 'default'(bg-900) | 'muted'(bg-500) | 'primary'(custom-500) | 'inherit'
// Full palette: 'bg-50'…'bg-950' | 'custom-50'…'custom-900'
bg={{ base: 'bg-100', dark: 'bg-800' }} // backgroundColor, same token set as color
weight="semibold" // 'normal'|'medium'|'semibold'|'bold'
align="center" // 'left'|'center'|'right'
decoration="underline"
transform="uppercase"
wrap="nowrap"
whiteSpace="pre-wrap" // 'normal'|'nowrap'|'pre'|'pre-line'|'pre-wrap'|'break-spaces'
wordBreak="break-all" // 'normal'|'break-all'|'keep-all'
overflowWrap="anywhere" // 'normal'|'break-word'|'anywhere'
truncate // overflow:hidden + ellipsis (shorthand boolean — list BEFORE other props)
lineClamp={3}
// Margin + Padding props
m="sm" mx="auto" mt="xs"
p="sm" px="md"
/>
```
> **`color` and `bg` on `Text` use `ThemeConditionProp`, not `ResponsiveProp`.**
> They respond to dark mode / hover conditions, not breakpoints.
### Responsive props
All `SpaceToken` and most layout/size props accept a responsive object:
```tsx
<Text size={{ base: 'lg', sm: 'xl' }} />
<Flex p={{ base: 'sm', sm: 'md' }} />
```
Breakpoints: `base` | `sm` (640px) | `md` (768px) | `lg` (1024px) | `xl` (1280px) | `2xl` (1536px)
---
## §2 — Space Token Reference
`--spacing` is Tailwind's default `0.25rem` per unit.
| SpaceToken | Value | Tailwind equivalent |
|------------|------------------------|-----------------------|
| `none` | `0` | `0` |
| `xs` | `calc(var(--spacing) * 1)` = 0.25rem | `1` |
| `sm` | `calc(var(--spacing) * 2)` = 0.5rem | `2` |
| `md` | `calc(var(--spacing) * 4)` = 1rem | `4` |
| `lg` | `calc(var(--spacing) * 6)` = 1.5rem | `6` |
| `xl` | `calc(var(--spacing) * 8)` = 2rem | `8` |
| `2xl` | `calc(var(--spacing) * 12)` = 3rem | `12` |
| `3xl` | `calc(var(--spacing) * 16)` = 4rem | `16` |
Spacing values **not** in the token table (e.g. `gap-3` = 0.75rem, arbitrary
`mt-[30%]`) must use an inline `style` prop:
```tsx
<Grid style={{ gap: '0.75rem', marginTop: '30%' }} />
```
---
## §3 — Common Tailwind → Primitive Mapping
| Tailwind class | Primitive equivalent |
|------------------------------------|----------------------------------------------------------|
| `flex` | `<Flex>` |
| `flex flex-col` | `<Flex direction="column">` |
| `flex items-center justify-center` | `<Flex align="center" justify="center">` |
| `flex items-center justify-between`| `<Flex align="center" justify="between">` |
| `flex-center` (utility) | `<Flex align="center" justify="center">` |
| `grid grid-cols-3` | `<Grid columns="repeat(3, minmax(0, 1fr))">` |
| `gap-6` | `gap="lg"` |
| `p-16` | `p="3xl"` |
| `px-16` | `px="3xl"` |
| `w-full` | `width="100%"` |
| `h-full` | `height="100%"` |
| `min-w-0` | `minWidth="0"` |
| `min-w-64` | `minWidth="16rem"` |
| `size-full` | `width="100%" height="100%"` on a `<Box>` or `<Flex>` |
| `col-span-2` | `gridColumn="span 2 / span 2"` on a wrapping `<Box>` |
| `row-span-2` | `gridRow="span 2 / span 2"` on a wrapping `<Box>` |
| `shrink-0` | `flexShrink="0"` |
| `text-bg-500` | `<Text color="bg-500">` |
| `text-bg-500 dark:text-bg-50` | `<Text color={{ base: 'bg-500', dark: 'bg-50' }}>` |
| `text-lg` | `<Text size="lg">` |
| `font-semibold` | `<Text weight="semibold">` |
| `truncate` | `<Text truncate>`**shorthand must come first** |
| `text-lg sm:text-xl` | `<Text size={{ base: 'lg', sm: 'xl' }}>` |
| `overflow-hidden` | `overflow="hidden"` |
| `position-relative` | `position="relative"` |
| `mb-1` | `mb="xs"` |
| `p-2 sm:p-4` | `p={{ base: 'sm', sm: 'md' }}` |
| `bg-bg-50 dark:bg-bg-900` | `bg={{ base: 'bg-50', dark: 'bg-900' }}` on a container |
| `hover:bg-bg-100 dark:hover:bg-bg-800` | `bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }}` |
| `shadow-custom` | `shadow` on any container primitive |
---
## §4 — De-Tailwinding Stories (`.stories.tsx`)
**Rule: Use primitives only. No `.css.ts` needed.**
### Checklist
- [ ] Replace every `<div className="flex ...">` with `<Flex>` + corresponding props
- [ ] Replace every `<div className="grid ...">` with `<Grid>` + corresponding props
- [ ] Replace every `<div>` wrapper (no layout) with `<Box>`
- [ ] Replace `<p className="text-...">` with `<Text as="p" ...>`
- [ ] Replace `<span className="text-...">` with `<Text ...>`
- [ ] Move `col-span-*` / `row-span-*` from the child component `className` to a
wrapping `<Box gridColumn="..." gridRow="...">` around it
- [ ] Spacing not in token table → `style={{ ... }}`
- [ ] Remove the `tailwindcss/colors` import if `COLORS` is only used for story
data (keep it if it feeds `iconColor` props etc.)
- [ ] Update imports: add `Box`, `Flex`, `Grid`, `Text` from `@components/primitives`,
remove any now-unused imports
### Prop ordering lint rules
JSX props must be **alphabetical**, except:
- `ref` — always **first**
- Boolean shorthand props (e.g. `truncate`) — always **before** other props
---
## §5 — De-Tailwinding Components (`.tsx` + `.css.ts`)
**Rule: Use primitives for layout structure; create `ComponentName.css.ts` for
all theming, color, shadow, hover, dark-mode, and state-variant styles.**
### Decision tree — which approach for theme-adaptive colors?
```
Is the element a container primitive (Box/Flex/Grid/Container/Section)?
└─ YES → use the `bg` prop: <Box bg={{ base: 'bg-50', dark: 'bg-900' }}>
use `shadow` prop for box-shadow
└─ NO → Is it a <Text>?
└─ YES → use `color`/`bg` props: <Text color={{ base: 'bg-500', dark: 'bg-50' }}>
└─ NO → write a .css.ts using themeColorProperties sprinkle (see below)
```
### Workflow
1. **Identify styling categories** in the component:
- *Layout* (display, flex/grid, gap, padding, margin, width, height,
overflow, position) → **primitive props**
- *Adaptive background/color/border* on a container primitive → **`bg` prop** or **`shadow` prop**
- *Adaptive background/color/border* on a non-primitive element → **`.css.ts` sprinkle**
- *Interactive* (hover, focus, active, transition) not coverable by primitive props → **`.css.ts`**
- *Context selectors* (`.bordered &`, `.has-bg-image &`) → **`.css.ts`**
- *Responsive font sizes / padding* that map to tokens → **responsive primitive props**
2. **Create `ComponentName.css.ts`** next to the component file only when needed.
3. **Export one `style()` or `sprinkles(...)` class per logical role** — name by
purpose, not by Tailwind class names.
4. **Import and use** with `import * as styles from './ComponentName.css'` and
`className={clsx(styles.foo, condition && styles.bar)}`.
### `.css.ts` — two patterns
#### Pattern A: `style()` for structural/interactive-only styles
Use for `boxShadow`, `transition`, `cursor`, `borderWidth`, and rules that
depend on **parent context selectors** (`.bordered &`) where a sprinkle cannot
be used.
```ts
import { style } from '@vanilla-extract/css'
import { bg, custom, withOpacity } from '@/system'
import { vars } from '@/system'
export const wrapper = style({
// Structural / non-theme
borderRadius: vars.radii.lg,
transition: 'all 0.2s',
// Theme-adaptive via selectors (only when themeColorProperties sprinkle can't be used)
backgroundColor: bg[50],
selectors: {
'.dark &': { backgroundColor: bg[900] },
'.bordered &': { borderWidth: '2px', borderStyle: 'solid' }
}
})
```
#### Pattern B: `themeColorProperties` sprinkle for theme-adaptive colors on child elements
When a **non-primitive element inside a component** needs adaptive `backgroundColor`,
`color`, or `borderColor` across dark / hover / hasBgImage conditions, create a
local sprinkle from `themeColorProperties`:
```ts
import { createSprinkles } from '@vanilla-extract/sprinkles'
import { style } from '@vanilla-extract/css'
import { themeColorProperties } from '@/system'
const sprinkles = createSprinkles(themeColorProperties)
// Then call sprinkles() directly as a className value:
export const iconWrapper = style([
{ borderRadius: vars.radii.lg },
sprinkles({ backgroundColor: { base: 'bg-100', dark: 'bg-800' } })
])
export const titleText = sprinkles({
color: { base: 'bg-500', dark: 'bg-50' }
})
export const optionInactive = sprinkles({
color: { base: 'bg-500', hover: 'bg-800', darkHover: 'bg-50' }
})
```
`themeColorProperties` supports:
- `backgroundColor: ColorToken | Partial<Record<ThemeCondition, ColorToken>>`
- `color: ColorToken | Partial<Record<ThemeCondition, ColorToken>>`
- `borderColor: ColorToken | Partial<Record<ThemeCondition, ColorToken>>`
### Token helpers
| Need | Code |
|-----------------------------|-------------------------------------------|
| Background color (style) | `bg[50]``bg[950]` |
| Accent color (style) | `custom[50]``custom[900]` |
| Color with opacity (style) | `withOpacity(bg[500], 0.1)` |
| Border radius | `vars.radii.sm/md/lg/xl/2xl/3xl/full` |
| Space value | `vars.space.xs/sm/md/lg/xl/2xl/3xl` |
| Font size | `vars.fontSize.sm/base/lg/xl/.../9xl` |
| Font weight | `vars.fontWeight.normal/medium/semibold/bold` |
| Box shadow | `boxShadow: 'var(--custom-shadow)'` |
### Common Tailwind utility → `.css.ts` mapping
| Tailwind utility | `.css.ts` equivalent |
|-----------------------------|--------------------------------------------------------------|
| `component-bg` | `bg={{ base: 'bg-50', dark: 'bg-900' }}` on primitive (preferred) OR `backgroundColor: bg[50]` + `.dark &` selector |
| `component-bg-lighter` | `backgroundColor: bg[100]` + `.dark &``withOpacity(bg[800], 0.5)` |
| `shadow-custom` | `shadow` prop on primitive OR `boxShadow: 'var(--custom-shadow)'` |
| `border-bg-500/20` | `borderColor: withOpacity(bg[500], 0.2)` |
| `bg-bg-500/10` | `backgroundColor: withOpacity(bg[500], 0.1)` |
| `text-bg-500 dark:text-bg-50` | `<Text color={{ base: 'bg-500', dark: 'bg-50' }}>` OR `sprinkles({ color: { base: 'bg-500', dark: 'bg-50' } })` in css.ts |
| `in-[.bordered]:border-2` | `selectors: { '.bordered &': { borderWidth: '2px', borderStyle: 'solid' } }` |
| `hover:bg-bg-100` | `bg={{ ..., hover: 'bg-100' }}` on primitive OR `sprinkles({ backgroundColor: { hover: 'bg-100' } })` |
| `transition-all` | `transition: 'all 0.2s'` |
| `rounded-lg` | `rounded="lg"` prop on primitive, or `borderRadius: vars.radii.lg` in `.css.ts` |
| `text-2xl sm:text-3xl` | `<Text size={{ base: '2xl', sm: '3xl' }}>` |
### What stays in `.css.ts` vs moves to primitive props
| Property | Where |
|----------|-------|
| `display`, `flexDirection`, `gap`, `padding`, `margin`, `width`, `height`, `overflow`, `position` | **Primitive prop** |
| `backgroundColor` of a container primitive | **`bg` prop** (ThemeConditionProp) |
| `backgroundColor` of a non-primitive child | **`.css.ts` sprinkle** (Pattern B) |
| `color` of a `<Text>` | **`color` prop** (ThemeConditionProp) |
| `color` of a non-Text element | **`.css.ts` sprinkle** (Pattern B) |
| `borderColor` | **`.css.ts` sprinkle** (Pattern B) |
| `borderRadius` | **`rounded` prop** on container, or `.css.ts` for non-primitive |
| `boxShadow` of a container primitive | **`shadow` prop** |
| `boxShadow` (complex / non-standard) | **`.css.ts`** `style()` |
| `transition`, `cursor`, `hover`, `focus` | **`.css.ts`** `style()` |
| Dark-mode selectors | **`.css.ts`** `style()` selectors OR `themeColorProperties` sprinkle |
| Context selectors (`.bordered &`, `.has-bg-image &`) | **`.css.ts`** `style()` selectors |
| `fontWeight`, `fontSize` | **`<Text weight= size=>`** |
---
## §6 — Preservation Checklist
Before calling done, verify every item from the original:
- [ ] All conditional class logic is reproduced (variant props, boolean flags)
- [ ] All dark-mode styles are present (`.dark &` selectors in `.css.ts`)
- [ ] All responsive styles are present (`@media` blocks or responsive props)
- [ ] All hover/focus/active states are present
- [ ] All context-selector styles are present (`.bordered &`, `.has-bg-image &`)
- [ ] `iconColor` / dynamic inline styles that can't be tokenised are still
passed as `style={{ backgroundColor: ... }}` on the relevant primitive
- [ ] No Tailwind utility classes remain in the file (search for `className="`
containing any bare Tailwind class names)
- [ ] `get_errors` reports zero TypeScript errors after all edits
---
## §7 — Examples
### Story: grid wrapper with spanning children
```tsx
// Before
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-6 p-16">
<Widget className="col-span-2 row-span-2" />
</div>
// After
<Grid
columns="repeat(3, minmax(0, 1fr))"
gap="lg"
height="100%"
p="3xl"
rows="repeat(3, minmax(0, 1fr))"
width="100%"
>
<Box gridColumn="span 2 / span 2" gridRow="span 2 / span 2">
<Widget />
</Box>
</Grid>
```
### Component: themed wrapper div — bg on primitive + shadow prop
```tsx
// Before
<div className="shadow-custom component-bg border-bg-500/20 flex size-full flex-col gap-6 rounded-lg p-4 in-[.bordered]:border-2">
// Widget.css.ts — only what primitives can't express
import { style } from '@vanilla-extract/css'
import { withOpacity, bg } from '@/system'
export const wrapper = style({
borderColor: withOpacity(bg[500], 0.2),
selectors: {
'.bordered &': { borderWidth: '2px', borderStyle: 'solid' }
}
})
// After (Widget.tsx) — bg and shadow handled by primitive props
<Flex
shadow
bg={{ base: 'bg-50', dark: 'bg-900' }}
className={styles.wrapper}
direction="column"
gap="lg"
height="100%"
p="md"
rounded="lg"
width="100%"
>
```
### Component: child element theme colors — themeColorProperties sprinkle
```tsx
// Before
<div className="flex rounded-lg p-2 sm:p-4 bg-bg-100 dark:bg-bg-800/50 mb-1">
<h3 className="w-full min-w-0 truncate text-bg-500 dark:text-bg-50 text-lg">
// Widget.css.ts
import { createSprinkles } from '@vanilla-extract/sprinkles'
import { style } from '@vanilla-extract/css'
import { themeColorProperties, bg, withOpacity, vars } from '@/system'
const sprinkles = createSprinkles(themeColorProperties)
export const iconWrapper = style([
{ borderRadius: vars.radii.lg },
sprinkles({ backgroundColor: { base: 'bg-100', dark: 'bg-800' } })
])
export const titleText = sprinkles({
color: { base: 'bg-500', dark: 'bg-50' }
})
// After (Widget.tsx)
<Flex
className={styles.iconWrapper}
mb="xs"
p={{ base: 'sm', sm: 'md' }}
>
<Text
truncate
as="h3"
className={styles.titleText}
size="lg"
style={{ width: '100%', minWidth: 0 }}
>
```
### Text with theme-adaptive color via prop (no .css.ts needed)
```tsx
// Before
<span className="text-bg-500 dark:text-bg-50 hover:text-bg-800">
// After — ThemeConditionProp directly on Text
<Text color={{ base: 'bg-500', dark: 'bg-50', hover: 'bg-800' }}>
```
### Interactive card — bg with hover/darkHover conditions
```tsx
// Before
<div className="bg-bg-50 dark:bg-bg-900 hover:bg-bg-100 dark:hover:bg-bg-800 cursor-pointer transition-all">
// Card.css.ts — only transition/cursor remain in style()
import { style } from '@vanilla-extract/css'
export const interactive = style({ cursor: 'pointer', transition: 'all 0.2s' })
// After (Card.tsx)
<Box
shadow
bg={{ base: 'bg-50', dark: 'bg-900', hover: 'bg-100', darkHover: 'bg-800' }}
className={styles.interactive}
rounded="lg"
>
```

View File

@@ -62,20 +62,20 @@ Components are considered **migrated** when they contain zero Tailwind utility c
- [x] `ComboboxInput`
- [x] `CurrencyInput`
- [x] `DateInput`
- [ ] `FAB`
- [x] `FAB`
- [x] `FileInput`
- [x] `IconInput`
- [ ] `Listbox`
- [x] `ListboxInput`
- [ ] `LocationInput`
- [x] `NumberInput`
- [ ] `QRCodeScanner`
- [x] `QRCodeScanner`
- [x] `RRuleInput`
- [ ] `SearchInput`
- [ ] `SliderInput`
- [x] `SearchInput`
- [x] `SliderInput`
- [x] `Switch`
- [ ] `TagsInput`
- [ ] `TextAreaInput`
- [x] `TextAreaInput`
- [x] `TextInput`
- [x] `TextInput/components/TextInputBox`
@@ -127,8 +127,8 @@ Components are considered **migrated** when they contain zero Tailwind utility c
| Auth | 0 | 7 | 0% |
| Data Display | 8 | 8 | 100% |
| Feedback | 5 | 5 | 100% |
| Inputs | 13 | 22 | 59% |
| Inputs | 18 | 22 | 82% |
| Layout | 5 | 5 | 100% |
| Navigation | 3 | 9 | 33% |
| Overlays | 5 | 9 | 56% |
| **Total** | **46** |**73** | **63%**|
| **Total** | **51** |**73** | **70%**|

View File

@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css'
import { bg } from '@/system'
export const colorDot = style({
selectors: {
'.group:focus-within &': { borderColor: bg[400] },
'.dark .group:focus-within &': { borderColor: bg[700] }
}
})

View File

@@ -21,6 +21,21 @@ export const Default: Story = {
render: args => {
const [color, setColor] = useState<string>('')
return <Index {...args} onChange={setColor} value={color} />
return <Index {...args} value={color} onChange={setColor} />
}
}
export const PlainVariant: Story = {
args: {
label: 'Cube Color',
value: '',
namespace: 'namespace',
variant: 'plain'
},
render: args => {
const [color, setColor] = useState<string>('')
return <Index {...args} value={color} onChange={setColor} />
}
}

View File

@@ -1,15 +1,16 @@
import clsx from 'clsx'
import { useRef } from 'react'
import { useModalStore } from '@components/overlays'
import { Flex } from '@components/primitives'
import { Bordered, Box, Flex, Text } from '@components/primitives'
import InputActionButton from '../shared/components/InputActionButton'
import InputIcon from '../shared/components/InputIcon'
import InputLabel from '../shared/components/InputLabel'
import InputWrapper from '../shared/components/InputWrapper'
import Placeholder from '../shared/components/Placeholder'
import useInputLabel from '../shared/hooks/useInputLabel'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
import { colorDot } from './ColorInput.css'
import ColorPickerModal from './ColorPickerModal'
interface ColorInputProps {
@@ -68,7 +69,7 @@ function ColorInput({
icon="tabler:palette"
/>
)}
<Flex align="center" gap="sm" pr="md" width="100%">
<Flex align="center" gap="sm" position="relative" width="100%">
{variant === 'classic' && label && (
<InputLabel
active={!!value}
@@ -77,48 +78,67 @@ function ColorInput({
required={required}
/>
)}
<div
className={clsx(
'flex w-full items-center gap-2',
variant === 'classic' ? 'mt-6 mr-4 pl-4' : ''
)}
<Flex
align="center"
gap="sm"
pb={variant === 'classic' ? 'sm' : undefined}
pl={variant === 'classic' ? 'none' : undefined}
pr={variant === 'classic' ? 'md' : undefined}
pt={variant === 'classic' ? 'xl' : undefined}
width="100%"
>
<div
className={`group-focus-within:border-bg-400 dark:group-focus-within:border-bg-700 mt-0.5 size-3 shrink-0 rounded-full border border-transparent`}
style={{
backgroundColor: value?.match(/^#[0-9A-F]{6}$/i)
? value
: undefined
}}
/>
<input
ref={autoFocusableRef(autoFocus, ref)}
className={clsx(
'focus:placeholder:text-bg-500 w-full min-w-28 rounded-lg bg-transparent tracking-wide focus:outline-hidden',
variant === 'classic'
? 'h-8 p-6 pl-0 placeholder:text-transparent'
: 'h-7 p-0'
)}
placeholder="#FFFFFF"
value={value}
onBlur={e => {
onChange(e.target.value.trim().toUpperCase())
}}
onChange={e => {
onChange(e.target.value)
}}
/>
</div>
<InputActionButton
icon="tabler:color-picker"
onClick={() => {
open(ColorPickerModal, {
value,
onChange
})
}}
/>
<Bordered
asChild
borderColor={variant === 'classic' ? 'transparent' : undefined}
borderStyle="solid"
borderWidth="1px"
>
<Box
className={colorDot}
flexShrink="0"
height="0.75em"
rounded="full"
style={{
marginTop: '0.125rem',
backgroundColor: value?.match(/^#[0-9A-F]{6}$/i)
? value
: undefined
}}
width="0.75em"
/>
</Bordered>
<Placeholder
color={variant === 'classic' ? 'transparent' : 'default'}
focusColor="default"
>
<Box asChild minWidth="7em" rounded="lg" width="100%">
<Text asChild tracking="wider">
<input
ref={autoFocusableRef(autoFocus, ref)}
placeholder="#FFFFFF"
value={value}
onBlur={e => {
onChange(e.target.value.trim().toUpperCase())
}}
onChange={e => {
onChange(e.target.value)
}}
/>
</Text>
</Box>
</Placeholder>
</Flex>
</Flex>
<InputActionButton
icon="tabler:color-picker"
variant={variant}
onClick={() => {
open(ColorPickerModal, {
value,
onChange
})
}}
/>
</InputWrapper>
)
}

View File

@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css'
export const tailwindButton = style({
backgroundColor: 'var(--color-teal-500) !important',
selectors: {
'&:hover': {
backgroundColor: 'var(--color-teal-600) !important'
}
}
})

View File

@@ -1,7 +1,9 @@
import { useCallback } from 'react'
import { Button, useModalStore } from '../../../../..'
import { Box, Flex } from '../../../../../components/primitives'
import { useColorPickerModalStore } from '../stores/useColorPickerModalStore'
import * as styles from './PaletteButtons.css'
import FlatUIColorsModal from './modals/FlatUIColorsModal'
import MorandiColorPaletteModal from './modals/ModandiColorPaletteModal'
import TailwindCSSColorsModal from './modals/TailwindCSSColorsModal'
@@ -28,35 +30,38 @@ function PaletteButtons() {
)
return (
<div className="mt-6 w-full space-y-2">
<Button
className="w-full"
icon="tabler:palette"
namespace="common.modals"
variant="secondary"
onClick={handleColorPaletteModalOpen('flatUiColors')}
>
colorPicker.buttons.flatUiColors
</Button>
<Button
className="w-full"
icon="tabler:flower"
namespace="common.modals"
variant="secondary"
onClick={handleColorPaletteModalOpen('morandi')}
>
colorPicker.buttons.morandiColorPalette
</Button>
<Button
className="w-full bg-teal-500! hover:bg-teal-600!"
icon="tabler:brand-tailwind"
namespace="common.modals"
variant="primary"
onClick={handleColorPaletteModalOpen('tailwind')}
>
colorPicker.buttons.tailwindCssColorPalette
</Button>
</div>
<Flex direction="column" gap="sm" mt="lg" width="100%">
<Box asChild width="100%">
<Button
icon="tabler:palette"
namespace="common.modals"
variant="secondary"
onClick={handleColorPaletteModalOpen('flatUiColors')}
>
colorPicker.buttons.flatUiColors
</Button>
</Box>
<Box asChild width="100%">
<Button
icon="tabler:flower"
namespace="common.modals"
variant="secondary"
onClick={handleColorPaletteModalOpen('morandi')}
>
colorPicker.buttons.morandiColorPalette
</Button>
</Box>
<Box asChild className={styles.tailwindButton} width="100%">
<Button
icon="tabler:brand-tailwind"
namespace="common.modals"
variant="primary"
onClick={handleColorPaletteModalOpen('tailwind')}
>
colorPicker.buttons.tailwindCssColorPalette
</Button>
</Box>
</Flex>
)
}

View File

@@ -0,0 +1,26 @@
import { style } from '@vanilla-extract/css'
import { bg, withOpacity } from '@/system'
export const card = style({
selectors: {
'.dark &': {
backgroundColor: withOpacity(bg[800], 0.7) + ' !important'
}
}
})
export const colorButton = style({
boxShadow: 'var(--custom-shadow)',
aspectRatio: '1',
cursor: 'pointer'
})
export const colorButtonSelected = style({
boxShadow: `0 0 0 2px ${bg[100]}, 0 0 0 4px ${bg[900]}, var(--custom-shadow)`,
selectors: {
'.dark &': {
boxShadow: `0 0 0 2px ${bg[900]}, 0 0 0 4px ${bg[50]}, var(--custom-shadow)`
}
}
})

View File

@@ -4,7 +4,9 @@ import tinycolor from 'tinycolor2'
import { Card } from '@components/layout'
import { ModalHeader } from '@components/overlays'
import { Box, Flex, Grid, Text } from '@components/primitives'
import * as styles from './FlatUIColorsModal.css'
import PALETTES from './constants/palettes.json'
function FlatUIColorsModal({
@@ -18,51 +20,72 @@ function FlatUIColorsModal({
}
}) {
return (
<div className="min-w-[60vw]">
<Box style={{ minWidth: '60vw' }}>
<ModalHeader
icon="tabler:palette"
title="colorPicker.modals.flatUiColors"
onClose={onClose}
/>
<div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
<Grid
columns={{
base: 'repeat(1, minmax(0, 1fr))',
sm: 'repeat(auto-fill, minmax(300px, 1fr))'
}}
style={{ gap: '0.75rem' }}
>
{PALETTES.map(({ name, icon, colors }) => (
<Card key={name} className="dark:bg-bg-800/70 space-y-2">
<div className="mb-4 flex items-center space-x-3">
<Icon className="size-6" icon={icon || 'tabler:palette'} />
<span className="text-lg font-medium">{name}</span>
</div>
<div className="grid grid-cols-5 gap-2">
<Card key={name} className={styles.card}>
<Flex align="center" mb="md" style={{ gap: '0.75rem' }}>
<Icon
icon={icon || 'tabler:palette'}
style={{ width: '1.5rem', height: '1.5rem' }}
/>
<Text as="span" size="lg" weight="medium">
{name}
</Text>
</Flex>
<Grid columns="repeat(5, minmax(0, 1fr))" style={{ gap: '0.5rem' }}>
{colors.map((flatUiColor, index) => (
<button
<Flex
key={index}
className={`flex-center shadow-custom aspect-square size-full cursor-pointer rounded-md ${
color === flatUiColor &&
'ring-bg-900 ring-offset-bg-100 dark:ring-bg-50 dark:ring-offset-bg-900 ring-2 ring-offset-2'
}`}
style={{ backgroundColor: flatUiColor }}
onClick={() => {
setColor(flatUiColor)
onClose()
}}
asChild
align="center"
height="100%"
justify="center"
rounded="md"
width="100%"
>
{color === flatUiColor && (
<Icon
className={clsx(
'size-8',
tinycolor(flatUiColor).isLight()
? 'text-bg-800'
: 'text-bg-50'
)}
icon="tabler:check"
/>
)}
</button>
<button
className={clsx(
styles.colorButton,
color === flatUiColor && styles.colorButtonSelected
)}
style={{ backgroundColor: flatUiColor }}
onClick={() => {
setColor(flatUiColor)
onClose()
}}
>
{color === flatUiColor && (
<Icon
icon="tabler:check"
style={{
width: '2rem',
height: '2rem',
color: tinycolor(flatUiColor).isLight()
? 'var(--color-bg-800)'
: 'var(--color-bg-50)'
}}
/>
)}
</button>
</Flex>
))}
</div>
</Grid>
</Card>
))}
</div>
</div>
</Grid>
</Box>
)
}

View File

@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css'
import { bg } from '@/system'
export const colorButton = style({
boxShadow: 'var(--custom-shadow)',
aspectRatio: '1',
cursor: 'pointer'
})
export const colorButtonSelected = style({
boxShadow: `0 0 0 2px ${bg[100]}, 0 0 0 4px ${bg[900]}, var(--custom-shadow)`,
selectors: {
'.dark &': {
boxShadow: `0 0 0 2px ${bg[900]}, 0 0 0 4px ${bg[50]}, var(--custom-shadow)`
}
}
})

View File

@@ -4,7 +4,9 @@ import { sortFn } from 'color-sorter'
import tinycolor from 'tinycolor2'
import { ModalHeader } from '@components/overlays'
import { Box, Flex, Grid } from '@components/primitives'
import * as styles from './MorandiColorPaletteModal.css'
import { MORANDI_COLORS } from './constants/morandi_colors'
function MorandiColorPaletteModal({
@@ -18,42 +20,56 @@ function MorandiColorPaletteModal({
onClose: () => void
}) {
return (
<div className="min-w-[60vw]">
<Box style={{ minWidth: '60vw' }}>
<ModalHeader
icon="tabler:flower"
title="colorPicker.modals.morandiColorPalette"
onClose={onClose}
/>
<div className="grid w-full grid-cols-[repeat(auto-fit,minmax(4rem,1fr))] gap-3 p-4 pt-0">
<Grid
columns="repeat(auto-fit, minmax(4rem, 1fr))"
pb="md"
px="md"
style={{ gap: '0.75rem' }}
>
{MORANDI_COLORS.sort(sortFn).map((morandiColor, index) => (
<button
<Flex
key={index}
className={clsx(
'flex-center shadow-custom aspect-square size-full cursor-pointer rounded-md',
color === morandiColor &&
'ring-bg-900 ring-offset-bg-100 dark:ring-bg-50 dark:ring-offset-bg-900 ring-2 ring-offset-2'
)}
style={{ backgroundColor: morandiColor }}
onClick={() => {
setColor(morandiColor)
onClose()
}}
asChild
align="center"
height="100%"
justify="center"
rounded="md"
width="100%"
>
{color === morandiColor && (
<Icon
className={clsx(
'size-8',
tinycolor(morandiColor).isLight()
? 'text-bg-800'
: 'text-bg-50'
)}
icon="tabler:check"
/>
)}
</button>
<button
className={clsx(
styles.colorButton,
color === morandiColor && styles.colorButtonSelected
)}
style={{ backgroundColor: morandiColor }}
onClick={() => {
setColor(morandiColor)
onClose()
}}
>
{color === morandiColor && (
<Icon
icon="tabler:check"
style={{
width: '2rem',
height: '2rem',
color: tinycolor(morandiColor).isLight()
? 'var(--color-bg-800)'
: 'var(--color-bg-50)'
}}
/>
)}
</button>
</Flex>
))}
</div>
</div>
</Grid>
</Box>
)
}

View File

@@ -0,0 +1,15 @@
import { style } from '@vanilla-extract/css'
import { vars } from '@/system'
export const colorGroupLabel = style({
marginTop: vars.space.md,
marginBottom: vars.space.md,
width: '7rem',
textAlign: 'left',
'@media': {
'screen and (min-width: 640px)': {
marginBottom: vars.space.sm
}
}
})

View File

@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css'
import { bg } from '@/system'
export const colorButton = style({
boxShadow: 'var(--custom-shadow)',
aspectRatio: '1',
cursor: 'pointer'
})
export const colorButtonSelected = style({
boxShadow: `0 0 0 2px ${bg[100]}, 0 0 0 4px ${bg[900]}, var(--custom-shadow)`,
selectors: {
'.dark &': {
boxShadow: `0 0 0 2px ${bg[900]}, 0 0 0 4px ${bg[50]}, var(--custom-shadow)`
}
}
})

View File

@@ -4,6 +4,10 @@ import { converter, formatHex, parse } from 'culori'
import { memo, useMemo } from 'react'
import tinycolor from 'tinycolor2'
import { Box, Flex, Text } from '@components/primitives'
import * as styles from './ColorItem.css'
function ColorItem({
name,
value,
@@ -21,29 +25,37 @@ function ColorItem({
)
return (
<li key={value} className="w-full">
<button
className={clsx(
'flex-center shadow-custom aspect-square w-full cursor-pointer rounded-md',
selected === value &&
'ring-bg-900 ring-offset-bg-100 dark:ring-bg-50 dark:ring-offset-bg-900 ring-2 ring-offset-2'
)}
style={{ backgroundColor: value }}
onClick={() => onSelect(colorHex)}
>
{selected === colorHex && (
<Icon
className={clsx(
tinycolor(colorHex).isLight() ? 'text-bg-800' : 'text-bg-50',
'size-8'
)}
icon="tabler:check"
/>
)}
</button>
<p className="mt-2 text-xs font-medium">{name}</p>
<code className="text-bg-500 block text-xs font-medium">{colorHex}</code>
</li>
<Box as="li" width="100%">
<Flex asChild align="center" justify="center" rounded="md" width="100%">
<button
className={clsx(
styles.colorButton,
selected === value && styles.colorButtonSelected
)}
style={{ backgroundColor: value }}
onClick={() => onSelect(colorHex)}
>
{selected === colorHex && (
<Icon
icon="tabler:check"
style={{
width: '2rem',
height: '2rem',
color: tinycolor(colorHex).isLight()
? 'var(--color-bg-800)'
: 'var(--color-bg-50)'
}}
/>
)}
</button>
</Flex>
<Text as="p" mt="sm" size="sm" weight="medium">
{name}
</Text>
<Text as="code" color="bg-500" display="block" size="sm" weight="medium">
{colorHex}
</Text>
</Box>
)
}

View File

@@ -1,7 +1,9 @@
import colors from 'tailwindcss/colors'
import { ModalHeader } from '@components/overlays'
import { Box, Flex, Grid, Text } from '@components/primitives'
import * as styles from './TailwindCSSColorsModal.css'
import ColorItem from './components/ColorItem'
function TailwindCSSColorsModal({
@@ -15,13 +17,13 @@ function TailwindCSSColorsModal({
onClose: () => void
}) {
return (
<div className="min-w-[70vw]">
<Box style={{ minWidth: '70vw' }}>
<ModalHeader
icon="tabler:brand-tailwind"
title="colorPicker.modals.morandiColorPalette"
onClose={onClose}
/>
<div className="space-y-3">
<Flex direction="column" style={{ gap: '0.75rem' }}>
{([...Object.keys(colors)] as Array<keyof typeof colors>)
.filter(
colorGroup =>
@@ -35,13 +37,21 @@ function TailwindCSSColorsModal({
].includes(colorGroup)
)
.map((colorGroup, index) => (
<div key={colorGroup} className="flex flex-col sm:flex-row">
<h2 className="my-4 w-28 text-left text-xl font-medium sm:mb-2 sm:text-base">
<Flex key={colorGroup} direction={{ base: 'column', sm: 'row' }}>
<Text
as="h2"
className={styles.colorGroupLabel}
size={{ base: 'xl', sm: 'base' }}
weight="medium"
>
{colorGroup[0].toUpperCase() + colorGroup.slice(1)}
</h2>
<ul
</Text>
<Grid
as="ul"
key={index}
className="grid w-full grid-cols-[repeat(auto-fit,minmax(4rem,1fr))] flex-wrap gap-3 pt-0"
columns="repeat(auto-fit, minmax(4rem, 1fr))"
width="100%"
style={{ gap: '0.75rem' }}
>
{Object.entries(
colors[colorGroup] as Record<string, string>
@@ -57,11 +67,11 @@ function TailwindCSSColorsModal({
}}
/>
))}
</ul>
</div>
</Grid>
</Flex>
))}
</div>
</div>
</Flex>
</Box>
)
}

View File

@@ -1,16 +1,35 @@
.w-color-editable-input {
@apply px-0!;
padding-left: 0 !important;
padding-right: 0 !important;
}
.w-color-colorful {
width: 100% !important;
}
.w-color-editable-input input {
font-size: 1rem !important;
font-weight: 500 !important;
letter-spacing: 0.1rem !important;
@apply bg-bg-50! dark:bg-bg-800! shadow-custom! text-bg-800! dark:text-bg-100! mb-2 rounded-md! py-2! text-center focus:outline-hidden;
background-color: var(--color-bg-50) !important;
box-shadow: var(--custom-shadow) !important;
color: var(--color-bg-800) !important;
margin-bottom: 0.5rem !important;
border-radius: var(--radius-md) !important;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
text-align: center;
outline: none;
}
&.hex {
color: rgb(244 244 245) !important;
}
.dark .w-color-editable-input input {
background-color: var(--color-bg-800) !important;
color: var(--color-bg-100) !important;
}
.w-color-editable-input.hex input {
color: var(--editable-input-color) !important;
background-color: var(--editable-input-bg) !important;
}
.w-color-editable-input span {

View File

@@ -4,6 +4,7 @@ import tinycolor from 'tinycolor2'
import { Button } from '@components/inputs'
import { ModalHeader } from '@components/overlays'
import { Box, Flex } from '@components/primitives'
import PaletteButtons from './components/PaletteButtons'
import { useColorPickerModalStore } from './stores/useColorPickerModalStore'
@@ -50,115 +51,112 @@ function ColorPickerModal({
}, [value])
return (
<div className="sm:min-w-[28rem]!">
<Box minWidth={{ sm: '28rem' }}>
<ModalHeader
icon="tabler:color-picker"
title="colorPicker.title"
onClose={onClose}
/>
<Colorful
disableAlpha
className="w-full!"
color={innerColor}
onChange={handleColorChange}
/>
<style
dangerouslySetInnerHTML={{
__html: `.w-color-editable-input.hex input {
background-color: ${innerColor} !important;
color: ${checkContrast(innerColor)} !important;
}`
<Colorful disableAlpha color={innerColor} onChange={handleColorChange} />
<Box
asChild
mt="md"
style={{
// @ts-expect-error - CSS variables
'--editable-input-bg': innerColor,
'--editable-input-color': checkContrast(innerColor)
}}
/>
<EditableInput
className="hex mt-4 border-0 text-2xl font-semibold"
label="Hex"
value={innerColor}
onChange={handleInputChange}
/>
<div className="mt-4 flex w-full min-w-0 gap-4">
>
<EditableInput
className="hex"
label="Hex"
value={innerColor}
onChange={handleInputChange}
/>
</Box>
<Flex gap="md" minWidth="0" mt="md" width="100%">
{['R', 'G', 'B'].map(type => (
<EditableInput
key={type}
className="rgb w-full min-w-0 flex-1 border-0 text-2xl font-semibold"
label={type}
value={tinycolor(innerColor)
.toRgb()
[type.toLowerCase() as 'r' | 'g' | 'b'].toString()}
onChange={e => {
const oldColor = tinycolor(innerColor).toRgb()
<Box key={type} asChild flex="1" style={{ minWidth: 0 }} width="100%">
<EditableInput
className="rgb"
label={type}
value={tinycolor(innerColor)
.toRgb()
[type.toLowerCase() as 'r' | 'g' | 'b'].toString()}
onChange={e => {
const oldColor = tinycolor(innerColor).toRgb()
const newColor =
type === 'R'
? tinycolor({
r: Number(e.target.value),
g: oldColor.g,
b: oldColor.b
})
: type === 'G'
const newColor =
type === 'R'
? tinycolor({
r: oldColor.r,
g: Number(e.target.value),
r: Number(e.target.value),
g: oldColor.g,
b: oldColor.b
})
: tinycolor({
r: oldColor.r,
g: oldColor.g,
b: Number(e.target.value)
})
: type === 'G'
? tinycolor({
r: oldColor.r,
g: Number(e.target.value),
b: oldColor.b
})
: tinycolor({
r: oldColor.r,
g: oldColor.g,
b: Number(e.target.value)
})
setInnerColor(newColor.toHexString())
}}
/>
setInnerColor(newColor.toHexString())
}}
/>
</Box>
))}
</div>
<div className="mt-4 flex w-full min-w-0 gap-4">
</Flex>
<Flex gap="md" minWidth="0" mt="md" width="100%">
{['H', 'S', 'V'].map(type => (
<EditableInput
key={type}
className="hsl w-full min-w-0 flex-1 border-0 text-2xl font-semibold"
label={type}
value={(
tinycolor(innerColor).toHsv()[
type.toLowerCase() as 'h' | 's' | 'v'
] * (type === 'H' ? 1 : 100)
).toFixed(type === 'H' ? 0 : 2)}
onChange={e => {
const oldColor = tinycolor(innerColor).toHsv()
<Box key={type} asChild flex="1" style={{ minWidth: 0 }} width="100%">
<EditableInput
className="hsl"
label={type}
value={(
tinycolor(innerColor).toHsv()[
type.toLowerCase() as 'h' | 's' | 'v'
] * (type === 'H' ? 1 : 100)
).toFixed(type === 'H' ? 0 : 2)}
onChange={e => {
const oldColor = tinycolor(innerColor).toHsv()
const newColor =
type === 'H'
? tinycolor({
h: Number(e.target.value),
s: oldColor.s,
v: oldColor.v
})
: type === 'S'
const newColor =
type === 'H'
? tinycolor({
h: oldColor.h,
s: Number(e.target.value) / 100,
h: Number(e.target.value),
s: oldColor.s,
v: oldColor.v
})
: tinycolor({
h: oldColor.h,
s: oldColor.s,
v: Number(e.target.value) / 100
})
: type === 'S'
? tinycolor({
h: oldColor.h,
s: Number(e.target.value) / 100,
v: oldColor.v
})
: tinycolor({
h: oldColor.h,
s: oldColor.s,
v: Number(e.target.value) / 100
})
setInnerColor(newColor.toHexString())
}}
/>
setInnerColor(newColor.toHexString())
}}
/>
</Box>
))}
</div>
</Flex>
<PaletteButtons />
<Button
className="mt-6 w-full"
icon="tabler:check"
onClick={confirmColor}
>
Select
</Button>
</div>
<Box asChild mt="lg" width="100%">
<Button icon="tabler:check" onClick={confirmColor}>
Select
</Button>
</Box>
</Box>
)
}

View File

@@ -2,6 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import colors from 'tailwindcss/colors'
import { Box } from '@components/primitives'
import ComboboxOption from './components/ComboboxOption'
import ComboboxInput from './index'
@@ -31,7 +33,11 @@ export default meta
type Story = StoryObj<typeof meta>
const COUNTRIES = [
{ value: 'us', name: 'United States', icon: 'circle-flags:us' },
{
value: 'us',
name: 'United States wiegjewiojgiowrjgiorwjiobji3ojgioweg',
icon: 'circle-flags:us'
},
{ value: 'uk', name: 'United Kingdom', icon: 'circle-flags:uk' },
{ value: 'jp', name: 'Japan', icon: 'circle-flags:jp' },
{ value: 'fr', name: 'France', icon: 'circle-flags:fr' },
@@ -64,7 +70,7 @@ export const Default: Story = {
)
return (
<div className="w-96">
<Box width="24rem">
<ComboboxInput
{...args}
displayValue={(country: (typeof COUNTRIES)[0] | null) =>
@@ -84,7 +90,7 @@ export const Default: Story = {
/>
))}
</ComboboxInput>
</div>
</Box>
)
}
}
@@ -109,7 +115,7 @@ export const Disabled: Story = {
const [, setQuery] = useState('')
return (
<div className="w-96">
<Box width="24rem">
<ComboboxInput
{...args}
displayValue={(country: (typeof COUNTRIES)[0] | null) =>
@@ -129,7 +135,7 @@ export const Disabled: Story = {
/>
))}
</ComboboxInput>
</div>
</Box>
)
}
}
@@ -161,7 +167,7 @@ export const Required: Story = {
)
return (
<div className="w-96">
<Box width="24rem">
<ComboboxInput
{...args}
displayValue={(country: (typeof COUNTRIES)[0] | null) =>
@@ -181,7 +187,233 @@ export const Required: Story = {
/>
))}
</ComboboxInput>
</div>
</Box>
)
}
}
/**
* The plain variant renders as a compact rounded box without an underline or floating label.
*/
export const PlainVariant: Story = {
args: {
variant: 'plain',
value: null,
onChange: () => {},
onQueryChanged: () => {},
displayValue: () => '',
children: <></>
},
render: args => {
const [value, onChange] = useState(COUNTRIES[0])
const [query, setQuery] = useState('')
const filteredCountries =
query === ''
? COUNTRIES
: COUNTRIES.filter(country =>
country.name.toLowerCase().includes(query.toLowerCase())
)
return (
<Box width="16rem">
<ComboboxInput
{...args}
displayValue={(country: (typeof COUNTRIES)[0] | null) =>
country?.name || ''
}
value={value}
onChange={onChange}
onQueryChanged={setQuery}
>
{filteredCountries.map(country => (
<ComboboxOption
key={country.value}
color={colors.blue[500]}
icon={country.icon}
label={country.name}
value={country}
/>
))}
</ComboboxInput>
</Box>
)
}
}
const PRIORITY_LEVELS = [
{ value: 'low', label: 'Low', color: colors.green[500] },
{ value: 'medium', label: 'Medium', color: colors.yellow[500] },
{ value: 'high', label: 'High', color: colors.orange[500] },
{ value: 'critical', label: 'Critical', color: colors.red[500] }
]
/**
* Options with a color dot instead of an icon — useful for status/priority pickers.
*/
export const OptionsWithColorDot: Story = {
args: {
label: 'Priority',
icon: 'tabler:flag',
value: null,
onChange: () => {},
onQueryChanged: () => {},
displayValue: () => '',
children: <></>
},
render: args => {
const [value, onChange] = useState(PRIORITY_LEVELS[0])
const [query, setQuery] = useState('')
const filtered =
query === ''
? PRIORITY_LEVELS
: PRIORITY_LEVELS.filter(p =>
p.label.toLowerCase().includes(query.toLowerCase())
)
return (
<Box width="24rem">
<ComboboxInput
{...args}
displayValue={(p: (typeof PRIORITY_LEVELS)[0] | null) =>
p?.label || ''
}
value={value}
onChange={onChange}
onQueryChanged={setQuery}
>
{filtered.map(p => (
<ComboboxOption
key={p.value}
color={p.color}
label={p.label}
value={p}
/>
))}
</ComboboxInput>
</Box>
)
}
}
/**
* Options with `noCheckmark` — the selected indicator is hidden.
*/
export const OptionsWithNoCheckmark: Story = {
args: {
label: 'Country',
icon: 'tabler:world',
value: null,
onChange: () => {},
onQueryChanged: () => {},
displayValue: () => '',
children: <></>
},
render: args => {
const [value, onChange] = useState(COUNTRIES[0])
const [query, setQuery] = useState('')
const filteredCountries =
query === ''
? COUNTRIES
: COUNTRIES.filter(country =>
country.name.toLowerCase().includes(query.toLowerCase())
)
return (
<Box width="24rem">
<ComboboxInput
{...args}
displayValue={(country: (typeof COUNTRIES)[0] | null) =>
country?.name || ''
}
value={value}
onChange={onChange}
onQueryChanged={setQuery}
>
{filteredCountries.map(country => (
<ComboboxOption
key={country.value}
noCheckmark
color={colors.blue[500]}
icon={country.icon}
label={country.name}
value={country}
/>
))}
</ComboboxInput>
</Box>
)
}
}
const ALL_TIMEZONES = [
{
value: 'utc',
label: 'UTC (Coordinated Universal Time) wiugwu4giju43igj43o'
},
{ value: 'us-eastern', label: 'US/Eastern (UTC-5)' },
{ value: 'us-central', label: 'US/Central (UTC-6)' },
{ value: 'us-mountain', label: 'US/Mountain (UTC-7)' },
{ value: 'us-pacific', label: 'US/Pacific (UTC-8)' },
{ value: 'eu-london', label: 'Europe/London (UTC+0)' },
{ value: 'eu-paris', label: 'Europe/Paris (UTC+1)' },
{ value: 'eu-berlin', label: 'Europe/Berlin (UTC+1)' },
{ value: 'eu-moscow', label: 'Europe/Moscow (UTC+3)' },
{ value: 'asia-kualalumpur', label: 'Asia/Kuala_Lumpur (UTC+8)' },
{ value: 'asia-dubai', label: 'Asia/Dubai (UTC+4)' },
{ value: 'asia-kolkata', label: 'Asia/Kolkata (UTC+5:30)' },
{ value: 'asia-bangkok', label: 'Asia/Bangkok (UTC+7)' },
{ value: 'asia-shanghai', label: 'Asia/Shanghai (UTC+8)' },
{ value: 'asia-tokyo', label: 'Asia/Tokyo (UTC+9)' },
{ value: 'pacific-auckland', label: 'Pacific/Auckland (UTC+12)' }
]
/**
* A large list of options demonstrating search filtering across many items.
*/
export const LargeOptionList: Story = {
args: {
label: 'Timezone',
icon: 'tabler:clock',
value: null,
onChange: () => {},
onQueryChanged: () => {},
displayValue: () => '',
children: <></>
},
render: args => {
const [value, onChange] = useState(ALL_TIMEZONES[0])
const [query, setQuery] = useState('')
const filtered =
query === ''
? ALL_TIMEZONES
: ALL_TIMEZONES.filter(tz =>
tz.label.toLowerCase().includes(query.toLowerCase())
)
return (
<Box width="24rem">
<ComboboxInput
{...args}
displayValue={(tz: (typeof ALL_TIMEZONES)[0] | null) =>
tz?.label || ''
}
value={value}
onChange={onChange}
onQueryChanged={setQuery}
>
{filtered.map(tz => (
<ComboboxOption key={tz.value} label={tz.label} value={tz} />
))}
</ComboboxInput>
</Box>
)
}
}

View File

@@ -1,7 +1,13 @@
import { ComboboxInput as HeadlessComboboxInput } from '@headlessui/react'
import clsx from 'clsx'
import {
ComboboxButton,
ComboboxInput as HeadlessComboboxInput
} from '@headlessui/react'
import { Icon } from '@iconify/react'
import { useCallback, useMemo } from 'react'
import { Box, Flex, Text } from '@components/primitives'
import InputActionButton from '../shared/components/InputActionButton'
import InputIcon from '../shared/components/InputIcon'
import InputLabel from '../shared/components/InputLabel'
import useInputLabel from '../shared/hooks/useInputLabel'
@@ -102,40 +108,75 @@ function ComboboxInput<T>({
className={className}
disabled={disabled}
setQuery={setQuery}
variant={variant}
value={value}
variant={variant}
onChange={handleChange}
onClick={focusInput}
>
<div className="group relative flex w-full items-center">
<Flex align="center" className="group" position="relative" width="100%">
{variant === 'classic' && icon && (
<InputIcon
active={isActive}
className="absolute left-6"
icon={icon}
/>
<Box position="absolute">
<InputIcon active={isActive} icon={icon} />
</Box>
)}
{variant === 'classic' && label && (
<InputLabel
isCombobox
isListboxOrCombobox
active={isActive}
label={inputLabel}
required={required === true}
/>
<Box
asChild
style={{
marginLeft: 'calc(var(--spacing) * 14)'
}}
>
<InputLabel
isCombobox
isListboxOrCombobox
active={isActive}
label={inputLabel}
required={required === true}
/>
</Box>
)}
<HeadlessComboboxInput
ref={autoFocusableRef(autoFocus)}
className={clsx(
'relative flex w-full items-center gap-2 rounded-lg bg-transparent! text-left focus:outline-hidden',
variant === 'classic' ? 'mt-10 mb-3 pr-5 pl-17' : 'h-7 p-0'
)}
displayValue={displayValue}
onChange={e => {
setQuery(e.target.value)
}}
/>
</div>
<Box
asChild
bg="transparent"
mt={variant === 'classic' ? 'md' : undefined}
overflow="hidden"
pb={variant === 'classic' ? 'sm' : undefined}
position="relative"
pr="3xl"
pt={variant === 'classic' ? 'md' : undefined}
rounded="lg"
style={
variant === 'classic'
? { paddingLeft: '3.5rem' }
: {
paddingTop: '1.25rem',
paddingBottom: '1.25rem',
paddingLeft: '1.25rem'
}
}
width="100%"
>
<Text asChild truncate align="left">
<HeadlessComboboxInput
ref={autoFocusableRef(autoFocus)}
displayValue={displayValue}
onChange={e => {
setQuery(e.target.value)
}}
/>
</Text>
</Box>
<Box asChild mr={variant === 'plain' ? 'sm' : 'md'}>
<InputActionButton asChild icon="" variant={variant}>
<ComboboxButton>
<Icon
icon="heroicons:chevron-up-down-16-solid"
style={{ width: '1.25em', height: '1.25em' }}
/>
</ComboboxButton>
</InputActionButton>
</Box>
</Flex>
<ComboboxOptions>{children}</ComboboxOptions>
</ComboboxInputWrapper>
)

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css'
import { custom } from '@/system'
export const dataOpen = style({
selectors: {
'&[data-open]': { borderColor: custom[500] }
}
})

View File

@@ -1,6 +1,11 @@
import { Combobox } from '@headlessui/react'
import clsx from 'clsx'
import { inputWrapperRecipe } from '@components/inputs/shared/components/InputWrapper/InputWrapper.css'
import { Flex } from '@components/primitives'
import * as styles from './ComboboxInputWrapper.css'
function ComboboxInputWrapper<T>({
value,
onChange,
@@ -21,25 +26,31 @@ function ComboboxInputWrapper<T>({
variant?: 'classic' | 'plain'
}) {
return (
<Combobox
as="div"
className={clsx(
'relative flex cursor-text items-center gap-1 transition-all',
variant === 'classic'
? 'border-bg-500 in-[.bordered]:border-bg-500/20 component-bg-lighter-with-hover shadow-custom focus-within:border-custom-500! data-[open]:border-custom-500! rounded-t-lg border-b-2 in-[.bordered]:rounded-lg in-[.bordered]:border-2'
: 'component-bg-lighter-with-hover rounded-lg p-4 px-5',
className,
disabled ? 'pointer-events-none! opacity-50' : ''
)}
value={value}
onChange={onChange}
onClick={onClick}
onClose={() => {
setQuery('')
}}
<Flex
asChild
align="center"
gap="xs"
position="relative"
shadow={variant === 'classic'}
width="100%"
>
{children}
</Combobox>
<Combobox
as="div"
className={clsx(
inputWrapperRecipe({ variant, disabled: disabled ?? false }),
styles.dataOpen,
className
)}
value={value}
onChange={onChange}
onClick={onClick}
onClose={() => {
setQuery('')
}}
>
{children}
</Combobox>
</Flex>
)
}

View File

@@ -0,0 +1,19 @@
import { style } from '@vanilla-extract/css'
import { bg, withOpacity } from '@/system'
export const option = style({
cursor: 'pointer',
transition: 'all 0.2s',
selectors: {
'&:not(:first-child)': {
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: bg[200]
},
'.dark &:not(:first-child)': {
borderTopColor: withOpacity(bg[700], 0.5)
},
'.dark &:hover': { backgroundColor: withOpacity(bg[700], 0.5) }
}
})

View File

@@ -1,6 +1,9 @@
import { ComboboxOption as HeadlessComboboxOption } from '@headlessui/react'
import { Icon } from '@iconify/react'
import clsx from 'clsx'
import { Bordered, Box, Flex, Text } from '@components/primitives'
import { option } from './ComboboxOption.css'
function ComboboxOption({
value,
@@ -18,57 +21,93 @@ function ComboboxOption({
noCheckmark?: boolean
}) {
return (
<HeadlessComboboxOption
className="flex-between hover:bg-bg-200 dark:hover:bg-bg-700/50 relative flex cursor-pointer gap-8 p-4 transition-all select-none"
value={value}
<Flex
asChild
align="center"
bg={{ hover: 'bg-200' }}
className={option}
gap="xl"
justify="between"
p="md"
position="relative"
>
{({ selected }: { selected: boolean }) => (
<>
<div
className={clsx(
'flex w-full items-center',
color !== undefined ? 'gap-3' : 'gap-2',
selected && 'text-bg-800 dark:text-bg-100 font-semibold',
iconAtEnd && 'flex-between flex flex-row-reverse'
)}
>
{icon !== undefined ? (
<span
className={clsx('shrink-0 rounded-md', color ? 'p-2' : 'pr-2')}
style={
color !== undefined
? {
backgroundColor: color + '20',
color
}
: {}
}
<HeadlessComboboxOption value={value}>
{({ selected }: { selected: boolean }) => (
<>
<Text
asChild
color={selected ? { base: 'bg-800', dark: 'bg-100' } : undefined}
weight={selected ? 'semibold' : undefined}
>
<Flex
align="center"
direction={iconAtEnd ? 'row-reverse' : undefined}
gap={color === undefined ? 'sm' : undefined}
justify={iconAtEnd ? 'between' : undefined}
style={color !== undefined ? { gap: '0.75rem' } : undefined}
width="100%"
>
{typeof icon === 'string' ? (
<Icon className="size-5 shrink-0" icon={icon} />
{icon !== undefined ? (
<Box
as="span"
flexShrink="0"
p={color !== undefined ? 'sm' : undefined}
pr={color === undefined ? 'sm' : undefined}
rounded="md"
style={
color !== undefined
? {
backgroundColor: color + '20',
color
}
: {}
}
>
{typeof icon === 'string' ? (
<Icon
icon={icon}
style={{
width: '1.25rem',
height: '1.25rem',
flexShrink: 0
}}
/>
) : (
icon
)}
</Box>
) : (
icon
color !== undefined && (
<Bordered
as="span"
display="block"
flexShrink="0"
height="1rem"
rounded="full"
style={{ backgroundColor: color }}
width="1rem"
/>
)
)}
</span>
) : (
color !== undefined && (
<span
className="border-bg-200 dark:border-bg-700 block size-4 shrink-0 rounded-full border"
style={{ backgroundColor: color }}
/>
)
<Text truncate style={{ width: '100%', minWidth: 0 }}>
{label}
</Text>
</Flex>
</Text>
{!noCheckmark && selected && (
<Text
asChild
color="custom-500"
size="lg"
style={{ display: 'block', flexShrink: 0 }}
>
<Icon icon="tabler:check" />
</Text>
)}
<div className="w-full min-w-0 truncate">{label}</div>
</div>
{!noCheckmark && selected && (
<Icon
className="text-custom-500 block shrink-0 text-lg"
icon="tabler:check"
/>
)}
</>
)}
</HeadlessComboboxOption>
</>
)}
</HeadlessComboboxOption>
</Flex>
)
}

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css'
export const options = style({
selectors: {
'&:empty': { visibility: 'hidden' },
'&:focus': { outline: 'none' },
'&[data-closed]': { transform: 'scale(0.95)', opacity: 0 }
}
})

View File

@@ -1,28 +1,37 @@
import { ComboboxOptions as HeadlessComboBoxOptions } from '@headlessui/react'
import clsx from 'clsx'
import { Bordered, Text } from '@components/primitives'
import * as styles from './ComboboxOptions.css'
function ComboboxOptions({
children,
customWidth,
lighter = false
customWidth
}: {
children: React.ReactNode
customWidth?: string
lighter?: boolean
}) {
return (
<HeadlessComboBoxOptions
transition
anchor="bottom start"
className={clsx(
customWidth ?? 'w-[var(--input-width)]',
'divide-bg-200 border-bg-200 dark:border-bg-700 z-9999 divide-y overflow-auto rounded-md border',
lighter ? 'bg-bg-50' : 'bg-bg-100',
'text-bg-500 dark:divide-bg-700/50 dark:border-bg-700 dark:bg-bg-800 text-base shadow-lg transition duration-100 ease-out [--anchor-gap:22px] empty:invisible focus:outline-hidden data-closed:scale-95 data-closed:opacity-0'
)}
>
{children}
</HeadlessComboBoxOptions>
<Text asChild color="bg-500" size="base">
<Bordered
asChild
shadow
bg={{ base: 'bg-100', dark: 'bg-800' }}
className={styles.options}
overflowY="auto"
rounded="md"
style={{
// @ts-expect-error - headlessui CSS variable
'--anchor-gap': '12px',
width: customWidth ?? 'var(--input-width)'
}}
zIndex="9999"
>
<HeadlessComboBoxOptions anchor="bottom start">
{children}
</HeadlessComboBoxOptions>
</Bordered>
</Text>
)
}

View File

@@ -1,55 +0,0 @@
import { recipe } from '@vanilla-extract/recipes'
import { vars } from '@/system'
export const currencyInputContainerRecipe = recipe({
variants: {
variant: {
classic: {
marginTop: vars.space.lg, // 6
height: vars.space.xl, // 8
padding: vars.space.lg, // 6
paddingLeft: vars.space.sm, // 4
},
plain: {
height: '1.75rem', // 7
padding: 0
}
}
},
defaultVariants: {
variant: 'classic'
}
})
export const currencyInputFieldRecipe = recipe({
base: {
width: '100%',
backgroundColor: 'transparent',
letterSpacing: '0.05em', // tracking-wider
outline: 'none',
selectors: {
'&:focus': {
outline: 'none'
},
'&:focus::placeholder': {
color: 'var(--color-bg-500)'
}
}
},
variants: {
variant: {
classic: {
selectors: {
'&::placeholder': {
color: 'transparent'
}
}
},
plain: {}
}
},
defaultVariants: {
variant: 'classic'
}
})

View File

@@ -2,17 +2,14 @@
import { useEffect, useRef, useState } from 'react'
import CurrencyInput from 'react-currency-input-field'
import { Flex, Text } from '@components/primitives'
import { Box, Flex, Text } from '@components/primitives'
import InputIcon from '../shared/components/InputIcon'
import InputLabel from '../shared/components/InputLabel'
import InputWrapper from '../shared/components/InputWrapper'
import Placeholder from '../shared/components/Placeholder'
import useInputLabel from '../shared/hooks/useInputLabel'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
import {
currencyInputContainerRecipe,
currencyInputFieldRecipe
} from './CurrencyInput.css'
export interface CurrencyInputProps {
/** The currency symbol to display, or the currency code (e.g., "$", "€", "USD"). */
@@ -84,7 +81,7 @@ function CurrencyInputComponent({
{variant === 'classic' && icon && (
<InputIcon active={!!innerValue} hasError={!!errorMsg} icon={icon} />
)}
<Flex width="100%" align="center" gap="sm">
<Flex width="100%" position="relative" align="center" gap="sm">
{variant === 'classic' && label && (
<InputLabel
active={!!innerValue}
@@ -96,7 +93,12 @@ function CurrencyInputComponent({
<Flex
align="center"
gap="sm"
className={currencyInputContainerRecipe({ variant })}
width="100%"
pt={variant === 'classic' ? 'xl' : undefined}
pr={variant === 'classic' ? 'md' : undefined}
pb={variant === 'classic' ? 'sm' : undefined}
pl={variant === 'classic' ? 'none' : undefined}
p={variant === 'plain' ? 'xs' : undefined}
>
{currency && (focused || !!innerValue) && (
<Text
@@ -108,31 +110,39 @@ function CurrencyInputComponent({
{currency}
</Text>
)}
<CurrencyInput
ref={autoFocusableRef(autoFocus, inputRef)}
className={currencyInputFieldRecipe({ variant })}
decimalsLimit={2}
name={label}
placeholder={placeholder}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
<Placeholder
color={variant === 'classic' ? 'transparent' : 'default'}
focusColor="default"
>
<Box width="100%" asChild>
<Text asChild tracking="wider">
<CurrencyInput
ref={autoFocusableRef(autoFocus, inputRef)}
decimalsLimit={2}
name={label}
placeholder={placeholder}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
const numericValue = parseFloat(innerValue)
const numericValue = parseFloat(innerValue)
if (!isNaN(numericValue)) {
onChange(numericValue)
} else {
onChange(0)
}
}}
value={innerValue}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onValueChange={(value: any) => {
setInnerValue(value)
onChange(Number(value))
}}
/>
if (!isNaN(numericValue)) {
onChange(numericValue)
} else {
onChange(0)
}
}}
value={innerValue}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onValueChange={(value: any) => {
setInnerValue(value)
onChange(Number(value))
}}
/>
</Text>
</Box>
</Placeholder>
</Flex>
</Flex>
</InputWrapper>

View File

@@ -1,21 +1,6 @@
import { style } from '@vanilla-extract/css'
import { bg, vars } from '@/system'
export const datePickerInputClassic = style({
marginTop: vars.space.lg,
height: '3.25rem',
paddingLeft: vars.space.sm,
paddingRight: vars.space.md,
selectors: {
'&::placeholder': { color: 'transparent' }
}
})
export const datePickerInputPlain = style({
height: '1.75rem',
padding: '0'
})
import { bg } from '@/system'
export const weekDayRed = style({
// Tailwind red-500 — no system token available for this palette

View File

@@ -67,7 +67,28 @@ export const Required: Story = {
onChange: () => {},
label: 'Date',
icon: 'tabler:calendar',
required: true
required: true,
variant: 'classic'
},
render: args => {
const [date, setDate] = useState(args.value)
return (
<Box p="2xl">
<DateInput {...args} value={date} onChange={setDate} />
</Box>
)
}
}
export const PlaintVariant: Story = {
args: {
value: '2026-04-20T00:29:27.683Z',
label: 'Date',
icon: 'tabler:calendar',
required: true,
variant: 'plain'
},
render: args => {

View File

@@ -1,4 +1,3 @@
import clsx from 'clsx'
import dayjs from 'dayjs'
import { useRef, useState } from 'react'
import DatePicker from 'react-datepicker'
@@ -11,6 +10,7 @@ import InputActionButton from '../shared/components/InputActionButton'
import InputIcon from '../shared/components/InputIcon'
import InputLabel from '../shared/components/InputLabel'
import InputWrapper from '../shared/components/InputWrapper'
import Placeholder from '../shared/components/Placeholder'
import useInputLabel from '../shared/hooks/useInputLabel'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
import * as styles from './DateInput.css'
@@ -87,7 +87,7 @@ function DateInput({
isFocused={isCalendarOpen}
/>
)}
<Flex align="center" gap="sm" width="100%">
<Flex align="center" gap="sm" position="relative" width="100%">
{variant === 'classic' && label && (
<InputLabel
active={!!value}
@@ -97,61 +97,68 @@ function DateInput({
required={required === true}
/>
)}
<Box asChild rounded="lg" width="100%">
<DatePicker
ref={autoFocusableRef(autoFocus, ref, e => {
e.input?.focus()
})}
shouldCloseOnSelect
calendarClassName={
tinycolor(derivedThemeColor).isLight()
? 'theme-light'
: 'theme-dark'
}
className={clsx(
variant === 'classic'
? styles.datePickerInputClassic
: styles.datePickerInputPlain
)}
dateFormat={hasTime ? 'MMMM d, yyyy h:mm aa' : 'MMMM d, yyyy'}
formatWeekDay={(date: string) => {
return date.slice(0, 3)
}}
placeholderText={`August 7, ${dayjs().year()}${
hasTime ? ' 08:07 AM' : ''
}`}
popperClassName="-mx-13"
popperPlacement="bottom-start"
portalId="app"
renderCustomHeader={CalendarHeader}
selected={value || null}
showPopperArrow={false}
showTimeSelect={hasTime}
weekDayClassName={(date: Date) => {
const isWeekend = date.getDay() === 0
return isWeekend ? styles.weekDayRed : styles.weekDayMuted
}}
onCalendarClose={() => {
setCalendarOpen(false)
}}
onCalendarOpen={() => {
setCalendarOpen(true)
}}
onChange={(value: Date | null) => onChange(value)}
/>
</Box>
{!!value && (
<Box asChild mr={variant === 'classic' ? 'md' : undefined}>
<InputActionButton
icon="tabler:x"
onClick={() => {
onChange(null)
<Placeholder
color={variant === 'classic' ? 'transparent' : 'default'}
focusColor="default"
>
<Box
asChild
pb={variant === 'classic' ? 'sm' : undefined}
pl={variant === 'classic' ? 'none' : undefined}
pr={variant === 'classic' ? 'md' : undefined}
pt={variant === 'classic' ? 'xl' : undefined}
rounded="lg"
width="100%"
>
<DatePicker
ref={autoFocusableRef(autoFocus, ref, e => {
e.input?.focus()
})}
shouldCloseOnSelect
calendarClassName={
tinycolor(derivedThemeColor).isLight()
? 'theme-light'
: 'theme-dark'
}
dateFormat={hasTime ? 'MMMM d, yyyy h:mm aa' : 'MMMM d, yyyy'}
formatWeekDay={(date: string) => {
return date.slice(0, 3)
}}
placeholderText={`August 7, ${dayjs().year()}${
hasTime ? ' 08:07 AM' : ''
}`}
popperClassName="-mx-13"
popperPlacement="bottom-start"
portalId="app"
renderCustomHeader={CalendarHeader}
selected={value || null}
showPopperArrow={false}
showTimeSelect={hasTime}
weekDayClassName={(date: Date) => {
const isWeekend = date.getDay() === 0
return isWeekend ? styles.weekDayRed : styles.weekDayMuted
}}
onCalendarClose={() => {
setCalendarOpen(false)
}}
onCalendarOpen={() => {
setCalendarOpen(true)
}}
onChange={(value: Date | null) => onChange(value)}
/>
</Box>
)}
</Placeholder>
</Flex>
{!!value && (
<InputActionButton
icon="tabler:x"
variant={variant}
onClick={() => {
onChange(null)
}}
/>
)}
</InputWrapper>
)
}

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ContextMenu, ContextMenuItem } from '@components/overlays'
import { Box, Flex, Text } from '@components/primitives'
import Fab from './FAB'
@@ -21,9 +22,9 @@ export const Default: Story = {
visibilityBreakpoint: false
},
render: props => (
<div className="h-48">
<Fab {...props} className="fixed right-6 bottom-6" />
</div>
<Box style={{ height: '12rem' }}>
<Fab {...props} />
</Box>
)
}
@@ -37,9 +38,9 @@ export const WithContextMenu: Story = {
},
render: props => {
return (
<div className="h-48">
<Box style={{ height: '12rem' }}>
<ContextMenu
buttonComponent={<Fab {...props} className="static!" />}
buttonComponent={<Fab {...props} style={{ position: 'static' }} />}
side="top"
styles={{
wrapper: {
@@ -62,7 +63,7 @@ export const WithContextMenu: Story = {
onClick={() => {}}
/>
</ContextMenu>
</div>
</Box>
)
}
}
@@ -73,12 +74,18 @@ export const WithVisibilityBreakpoint: Story = {
visibilityBreakpoint: 'md'
},
render: props => (
<div className="flex-center relative h-full w-full">
<p className="text-bg-500 text-lg">
<Flex
align="center"
height="100%"
justify="center"
position="relative"
width="100%"
>
<Text as="p" color="bg-500" size="lg">
Resize the viewport to see the FAB hide at the &apos;md&apos; breakpoint
and below.
</p>
<Fab {...props} className="fixed right-6 bottom-6" />
</div>
</Text>
<Fab {...props} />
</Flex>
)
}

View File

@@ -1,4 +1,4 @@
import clsx from 'clsx'
import { Box } from '@components/primitives'
import Button from '../Button'
@@ -13,25 +13,25 @@ function FAB({
}: {
/** The icon identifier string. Defaults to 'tabler:plus'. */
icon?: string
/** The responsive breakpoint at which the FAB should be hidden. Defaults to 'sm'. */
/** The responsive breakpoint at which the FAB should be hidden. Defaults to 'md'. */
visibilityBreakpoint?: 'sm' | 'md' | 'lg' | 'xl' | false
} & React.ComponentProps<typeof Button>) {
return (
<Button
{...props}
className={clsx(
'fixed right-6 bottom-6 z-[9992] shadow-lg',
visibilityBreakpoint &&
{
sm: 'sm:hidden',
md: 'md:hidden',
lg: 'lg:hidden',
xl: 'xl:hidden'
}[visibilityBreakpoint],
props.className
)}
icon={icon}
/>
<Box
asChild
shadow
bottom="1.5em"
display={
visibilityBreakpoint
? { base: 'none', [visibilityBreakpoint]: 'block' }
: 'block'
}
position="fixed"
right="1.5em"
zIndex="9992"
>
<Button {...props} icon={icon} />
</Box>
)
}

View File

@@ -88,7 +88,7 @@ function IconInput({
{variant === 'classic' && (
<InputIcon active={!!value} hasError={!!errorMsg} icon="tabler:icons" />
)}
<Flex align="center" gap="sm" pr="md" width="100%">
<Flex align="center" gap="sm" position="relative" pr="md" width="100%">
{variant === 'classic' && label && (
<InputLabel
active={!!value}
@@ -100,13 +100,13 @@ function IconInput({
<div
className={clsx(
'flex w-full items-center gap-2',
variant === 'classic' ? 'mt-6 pl-4' : ''
variant === 'classic' ? 'mt-4 p-4 pb-2 pl-0' : ''
)}
>
<span className="icon-input-icon size-5 shrink-0">
<span className="icon-input-icon size-4 shrink-0">
<Icon
className={clsx(
'size-5 shrink-0',
'size-4 shrink-0',
!value &&
'pointer-events-none opacity-0 group-focus-within:opacity-100'
)}
@@ -118,9 +118,7 @@ function IconInput({
autoComplete="off"
className={clsx(
'focus:placeholder:text-bg-500 w-full rounded-lg bg-transparent tracking-wide focus:outline-none',
variant === 'classic'
? 'h-8 py-6 placeholder:text-transparent'
: 'h-7 p-0'
variant === 'classic' ? 'placeholder:text-transparent' : 'h-7 p-0'
)}
disabled={disabled}
name={label}

View File

@@ -65,8 +65,8 @@ export const Default: Story = {
}
className="w-96"
disabled={false}
onChange={onChange}
value={value}
onChange={onChange}
>
{OPTIONS.map(({ color, icon, text, value }, index) => (
<ListboxOption
@@ -131,8 +131,8 @@ export const MultipleSelection: Story = {
</span>
}
disabled={false}
onChange={onChange}
value={value}
onChange={onChange}
>
{OPTIONS.map(({ color, icon, text, value }, index) => (
<ListboxOption

View File

@@ -1,8 +1,10 @@
import { ListboxButton } from '@headlessui/react'
import { Icon } from '@iconify/react'
import clsx from 'clsx'
import { useCallback, useMemo } from 'react'
import { Box } from '@/index'
import InputActionButton from '../shared/components/InputActionButton'
import InputIcon from '../shared/components/InputIcon'
import InputLabel from '../shared/components/InputLabel'
import useInputLabel from '../shared/hooks/useInputLabel'
@@ -105,12 +107,7 @@ function ListboxInput<T>({
onChange={onChange}
onClick={focusInput}
>
<ListboxButton
className={clsx(
'group flex w-full items-center sm:min-w-64',
variant === 'classic' ? 'pl-6' : ''
)}
>
<ListboxButton className="group flex w-full items-center sm:min-w-64">
{icon && (
<InputIcon
active={isActive}
@@ -120,33 +117,32 @@ function ListboxInput<T>({
/>
)}
{variant === 'classic' && label && (
<InputLabel
isListboxOrCombobox
active={isActive}
hasError={!!errorMsg}
label={inputLabel}
required={required === true}
/>
<Box
asChild
style={{
marginLeft: 'calc(var(--spacing) * 14)'
}}
>
<InputLabel
isListboxOrCombobox
active={isActive}
hasError={!!errorMsg}
label={inputLabel}
required={required === true}
/>
</Box>
)}
<div
className={clsx(
'relative flex min-h-[1.2rem] w-full min-w-0 items-center gap-2 rounded-lg text-left focus:outline-hidden',
variant === 'classic' ? 'mt-10 mb-3 pr-10 pl-5' : 'h-7 pr-8'
variant === 'classic' ? 'mt-4 p-4 pb-2 pl-0' : 'h-7 pr-8'
)}
>
{variant === 'classic' ? isActive && buttonContent : buttonContent}
</div>
<span
className={clsx(
'pointer-events-none absolute inset-y-0 right-0 flex items-center',
variant === 'classic' ? 'mt-1 mr-2 pr-4' : 'pr-2'
)}
>
<Icon
className="text-bg-400 dark:text-bg-600 group-data-open:text-bg-800 dark:group-data-open:text-bg-100 size-6"
icon="heroicons:chevron-up-down-16-solid"
/>
</span>
<Box asChild mr="sm" position="absolute" right="0">
<InputActionButton icon="heroicons:chevron-up-down-16-solid" />
</Box>
</ListboxButton>
<ListboxOptions portal={!(multiple && hasActionButton)}>
{children}

View File

@@ -25,7 +25,7 @@ function ListboxInputWrapper<T>({
size?: 'small' | 'default'
}) {
return (
<div className={clsx('flex-1 space-y-2', className)}>
<div className={clsx('space-y-2', className)}>
<Listbox
as="div"
className={clsx(

View File

@@ -15,7 +15,7 @@ export const Default: Story = {
value: 0,
onChange: () => {},
label: 'Price',
icon: 'tabler:currency-dollar'
label: 'Age',
icon: 'tabler:calendar'
}
}

View File

@@ -0,0 +1,23 @@
import { globalStyle, style } from '@vanilla-extract/css'
export const scannerContainer = style({
height: '100% !important',
width: '100% !important'
})
globalStyle(`${scannerContainer} svg`, {
height: '100% !important',
width: '100% !important'
})
globalStyle(`${scannerContainer} div div div:has(svg)`, {
display: 'none'
})
export const scannerVideo = style({
left: '50% !important',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
position: 'absolute !important' as any,
top: '50% !important',
transform: 'translate(-50%, -50%) !important'
})

View File

@@ -1,6 +1,9 @@
import { Scanner } from '@yudiel/react-qr-scanner'
import { ModalHeader } from '@components/overlays'
import { Box } from '@components/primitives'
import * as styles from './QRCodeScanner.css'
function QRCodeScanner({
onClose,
@@ -38,20 +41,25 @@ function QRCodeScanner({
}
}) {
return (
<>
<Box minWidth="30vw">
<ModalHeader
icon="tabler:qrcode"
title="qrCodeScanner"
onClose={onClose}
/>
<div className="relative aspect-square h-full w-full">
<Box
height="100%"
overflow="hidden"
position="relative"
rounded="lg"
style={{ aspectRatio: '1 / 1' }}
width="100%"
>
<Scanner
allowMultiple={false}
classNames={{
container:
'size-full! [&_svg]:size-full! [&_div_div_div:has(svg)]:hidden',
video:
'top-1/2! left-1/2! absolute! -translate-x-1/2! -translate-y-1/2!'
container: styles.scannerContainer,
video: styles.scannerVideo
}}
formats={formats}
onScan={codes => {
@@ -59,8 +67,8 @@ function QRCodeScanner({
onScanned(codes[0].rawValue)
}}
/>
</div>
</>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,34 @@
import { style } from '@vanilla-extract/css'
import { bg, custom, withOpacity } from '@/system'
export const searchWrapper = style({
borderColor: withOpacity(bg[500], 0.2),
transition: 'all 0.2s',
selectors: {
'.bordered &': {
borderWidth: '2px',
borderStyle: 'solid'
},
'.has-bg-image &': {
backgroundColor: withOpacity(bg[50], 0.5),
backdropFilter: 'blur(4px)'
},
'.dark .has-bg-image &': {
backgroundColor: withOpacity(bg[900], 0.5),
backdropFilter: 'blur(4px)'
},
'.has-bg-image &:hover': {
backgroundColor: withOpacity(bg[100], 0.5)
},
'.dark .has-bg-image &:hover': {
backgroundColor: withOpacity(bg[800], 0.5)
}
}
})
export const searchInput = style({
backgroundColor: 'transparent',
caretColor: custom[500],
width: '100%'
})

View File

@@ -3,6 +3,7 @@ import type { StoryObj, Meta as _Meta } from '@storybook/react-vite'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { Box, Flex, Text } from '@components/primitives'
import { WithQuery } from '@components/utilities'
import SearchInput from './SearchInput'
@@ -25,9 +26,9 @@ export const Default: Story = {
const [value, onChange] = useState('')
return (
<div className="w-full px-32">
<Box style={{ paddingLeft: '8rem', paddingRight: '8rem' }} width="100%">
<SearchInput {...args} value={value} onChange={onChange} />
</div>
</Box>
)
}
}
@@ -44,9 +45,9 @@ export const CustomIcon: Story = {
},
render: args => (
<div className="w-full px-32">
<Box style={{ paddingLeft: '8rem', paddingRight: '8rem' }} width="100%">
<SearchInput {...args} />
</div>
</Box>
)
}
@@ -68,9 +69,9 @@ export const WithActionButton: Story = {
const [value, onChange] = useState('')
return (
<div className="w-full px-32">
<Box style={{ paddingLeft: '8rem', paddingRight: '8rem' }} width="100%">
<SearchInput {...args} value={value} onChange={onChange} />
</div>
</Box>
)
}
}
@@ -123,40 +124,69 @@ export interface Review {
const ProductSuggestionItem = ({ product }: { product: ProductElement }) => {
return (
<div
key={product.id}
className="hover:bg-bg-100 dark:hover:bg-bg-800 flex cursor-pointer items-center gap-4 rounded-md px-4 py-3 transition-all"
<Flex
align="center"
bg={{ darkHover: 'bg-800', hover: 'bg-100' }}
gap="md"
px="md"
rounded="md"
style={{
cursor: 'pointer',
paddingBottom: '0.75rem',
paddingTop: '0.75rem',
transition: 'all 0.2s'
}}
onClick={() => alert(`Selected product: ${product.title}`)}
>
<img
alt={product.title}
className="size-12 shrink-0 rounded-md object-cover"
src={product.thumbnail}
style={{
borderRadius: 'var(--radius-md)',
flexShrink: 0,
height: '3rem',
objectFit: 'cover',
width: '3rem'
}}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-bg-500 text-sm">{product.category}</span>
<span className="truncate font-medium">{product.title}</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<div className="flex items-center gap-1">
<span className="text-custom-500 font-semibold">
<Flex direction="column" flexGrow="1" minWidth="0">
<Text color="bg-500" size="sm">
{product.category}
</Text>
<Text truncate weight="medium">
{product.title}
</Text>
</Flex>
<Flex
align="end"
direction="column"
flexShrink="0"
style={{ gap: '0.25rem' }}
>
<Flex align="center" style={{ gap: '0.25rem' }}>
<Text color="primary" weight="semibold">
$
{(product.price * (1 - product.discountPercentage / 100)).toFixed(
2
)}
</span>
</Text>
{product.discountPercentage > 0 && (
<span className="text-bg-500 text-sm line-through">
<Text color="bg-500" decoration="line-through" size="sm">
${product.price.toFixed(2)}
</span>
</Text>
)}
</div>
<div className="text-bg-500 flex items-center gap-1 text-sm">
<Icon className="size-4" icon="tabler:star" />
{product.rating.toFixed(1)}
</div>
</div>
</div>
</Flex>
<Text asChild color="bg-500" size="sm">
<Flex align="center" style={{ gap: '0.25rem' }}>
<Icon
icon="tabler:star"
style={{ height: '1rem', width: '1rem' }}
/>
{product.rating.toFixed(1)}
</Flex>
</Text>
</Flex>
</Flex>
)
}
@@ -244,7 +274,10 @@ function SearchWithSuggestions() {
})
return (
<div className="h-128 w-full px-32">
<Box
style={{ height: '32rem', paddingLeft: '8rem', paddingRight: '8rem' }}
width="100%"
>
<SearchInput
{...args}
debounceMs={300}
@@ -260,15 +293,21 @@ function SearchWithSuggestions() {
<ProductSuggestionItem key={product.id} product={product} />
))
) : (
<div className="text-bg-500 px-4 py-3 text-center">
<Text
align="center"
as="div"
color="bg-500"
px="md"
style={{ paddingBottom: '0.75rem', paddingTop: '0.75rem' }}
>
No suggestions found.
</div>
</Text>
)}
</>
)}
</WithQuery>
</SearchInput>
</div>
</Box>
)
}
}

View File

@@ -7,8 +7,11 @@ import { useTranslation } from 'react-i18next'
import { useDivSize } from 'shared'
import { Card } from '@components/layout'
import { Box, Flex, Text } from '@components/primitives'
import Button from '../Button'
import Placeholder from '../shared/components/Placeholder'
import * as styles from './SearchInput.css'
interface SearchInputProps {
/** The icon to display in the search input. Should be a valid icon name from Iconify. */
@@ -142,89 +145,123 @@ function SearchInput({
}
return (
<div
<Box
ref={containerRef}
className="relative w-full"
onBlur={handleBlur}
onFocus={() => setIsFocused(true)}
position="relative"
width="100%"
>
<search
className={clsx(
'shadow-custom border-bg-500/20 component-bg-with-hover relative flex min-h-14 w-full cursor-text items-center gap-3 rounded-lg p-4 transition-all in-[.bordered]:border-2',
className
)}
<Flex
shadow
align="center"
as="search"
bg={{
base: 'bg-50',
dark: 'bg-900',
hover: 'bg-100',
darkHover: 'bg-800'
}}
className={clsx(styles.searchWrapper, className)}
onClick={e => {
e.currentTarget.querySelector('input')?.focus()
}}
p="md"
position="relative"
rounded="lg"
style={{ cursor: 'text', gap: '0.75rem', minHeight: '3.5rem' }}
width="100%"
>
<Icon className="text-bg-500 size-5 shrink-0" icon={icon} />
<input
autoComplete="one-time-code"
autoCorrect="off"
className={clsx(
'caret-custom-500 placeholder:text-bg-500 w-full bg-transparent',
actionButtonProps ? 'pr-20' : 'pr-10'
)}
data-form-type="other"
data-lpignore="true"
placeholder={t([`search`, `Search ${searchTarget}`], {
item: t([
`${namespace}:items.${_.camelCase(searchTarget)}`,
`${namespace}:items.${searchTarget}`,
`${namespace}:${_.camelCase(searchTarget)}`,
`${namespace}:${searchTarget}`,
`common.misc:items.${_.camelCase(searchTarget)}`,
`common.misc:items.${searchTarget}`,
`common.misc:${_.camelCase(searchTarget)}`,
`common.misc:${searchTarget}`,
searchTarget
])
})}
type="text"
value={displayValue}
onChange={e => {
handleChange(e.target.value)
}}
onKeyUp={onKeyUp}
/>
<div className="absolute top-1/2 right-4 flex -translate-y-1/2 items-center gap-2">
<Text
asChild
color="bg-500"
style={{ flexShrink: 0, height: '1.25rem', width: '1.25rem' }}
>
<Icon icon={icon} />
</Text>
<Placeholder>
<input
autoComplete="one-time-code"
autoCorrect="off"
className={styles.searchInput}
data-form-type="other"
data-lpignore="true"
placeholder={t([`search`, `Search ${searchTarget}`], {
item: t([
`${namespace}:items.${_.camelCase(searchTarget)}`,
`${namespace}:items.${searchTarget}`,
`${namespace}:${_.camelCase(searchTarget)}`,
`${namespace}:${searchTarget}`,
`common.misc:items.${_.camelCase(searchTarget)}`,
`common.misc:items.${searchTarget}`,
`common.misc:${_.camelCase(searchTarget)}`,
`common.misc:${searchTarget}`,
searchTarget
])
})}
style={{ paddingRight: actionButtonProps ? '5rem' : '2.5rem' }}
type="text"
value={displayValue}
onChange={e => {
handleChange(e.target.value)
}}
onKeyUp={onKeyUp}
/>
</Placeholder>
<Flex
align="center"
gap="sm"
position="absolute"
style={{ right: '1rem', top: '50%', transform: 'translateY(-50%)' }}
>
<Button
className={clsx(
'size-8 p-0',
displayValue ? 'visible opacity-100' : 'invisible opacity-0'
)}
icon="tabler:x"
style={{
height: '2rem',
opacity: displayValue ? 1 : 0,
padding: 0,
visibility: displayValue ? 'visible' : 'hidden',
width: '2rem'
}}
variant="plain"
onClick={handleClear}
/>
{actionButtonProps && (
<Button
{...actionButtonProps}
className={clsx('size-8 p-0', actionButtonProps.className)}
style={{
height: '2rem',
padding: 0,
width: '2rem',
...actionButtonProps.style
}}
variant={actionButtonProps.variant || 'plain'}
/>
)}
</div>
</search>
</Flex>
</Flex>
{children && (
<Card
className={clsx(
'absolute top-2 w-full overflow-hidden p-0! transition-all',
shouldShowChildren
? 'visible opacity-100'
: 'pointer-events-none invisible opacity-0'
)}
overflow="hidden"
style={{
height: children && shouldShowChildren ? childrenHeight : 0
height: children && shouldShowChildren ? childrenHeight : 0,
opacity: shouldShowChildren ? 1 : 0,
padding: 0,
pointerEvents: shouldShowChildren ? undefined : 'none',
position: 'absolute',
top: '0.5rem',
transition: 'all 0.2s',
visibility: shouldShowChildren ? 'visible' : 'hidden'
}}
width="100%"
onMouseDown={e => e.preventDefault()}
>
<div ref={childrenRef} className="p-4">
<Box ref={childrenRef} p="md">
{children}
</div>
</Box>
</Card>
)}
</div>
</Box>
)
}

View File

@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { Box } from '@components/primitives'
import SliderInput from './SliderInput'
const meta = {
@@ -11,20 +13,91 @@ export default meta
type Story = StoryObj<typeof meta>
function SliderStory(args: React.ComponentProps<typeof SliderInput>) {
const [value, onChange] = useState(args.value)
return (
<Box width={{ base: '12rem', sm: '24rem' }}>
<SliderInput {...args} value={value} onChange={onChange} />
</Box>
)
}
export const Default: Story = {
args: {
icon: 'tabler:rotate',
label: 'Slider Input',
value: 0,
label: 'Rotation',
value: 45,
onChange: () => {}
},
render: args => {
const [value, onChange] = useState(args.value)
return (
<div className="w-48 sm:w-96">
<SliderInput {...args} value={value} onChange={onChange} />
</div>
)
}
render: args => <SliderStory {...args} />
}
export const WithoutLabel: Story = {
args: {
value: 50,
onChange: () => {}
},
render: args => <SliderStory {...args} />
}
export const Required: Story = {
args: {
icon: 'tabler:star',
label: 'Rating',
value: 0,
required: true,
onChange: () => {}
},
render: args => <SliderStory {...args} />
}
export const Disabled: Story = {
args: {
icon: 'tabler:volume',
label: 'Volume',
value: 30,
disabled: true,
onChange: () => {}
},
render: args => <SliderStory {...args} />
}
export const CustomRange: Story = {
args: {
icon: 'tabler:temperature',
label: 'Temperature',
value: 20,
min: -20,
max: 50,
step: 0.5,
onChange: () => {}
},
render: args => <SliderStory {...args} />
}
export const Percentage: Story = {
args: {
icon: 'tabler:percent',
label: 'Opacity',
value: 80,
min: 0,
max: 100,
step: 5,
onChange: () => {}
},
render: args => <SliderStory {...args} />
}
export const StepLarge: Story = {
args: {
icon: 'tabler:clock',
label: 'Duration (minutes)',
value: 30,
min: 0,
max: 120,
step: 15,
onChange: () => {}
},
render: args => <SliderStory {...args} />
}

View File

@@ -1,6 +1,8 @@
import { Icon } from '@iconify/react'
import clsx from 'clsx'
import { Box, Flex, Text } from '@components/primitives'
import useInputLabel from '../shared/hooks/useInputLabel'
interface SliderInputProps {
@@ -47,52 +49,106 @@ function SliderInput({
const inputLabel = useInputLabel({ namespace, label: label ?? '' })
return (
<div className={clsx('w-full', wrapperClassName)}>
<Box className={wrapperClassName} width="100%">
{icon && label && (
<div className="flex-between mb-4 w-full min-w-0 gap-8">
<div className="text-bg-400 dark:text-bg-600 flex min-w-0 items-center gap-2 font-medium tracking-wide">
<Icon className="size-6 shrink-0" icon={icon} />
<div className="flex w-full min-w-0 items-center gap-2">
<div className="w-full min-w-0 truncate">{inputLabel}</div>
{required && <span className="text-red-500">*</span>}
</div>
</div>
<div className="flex items-center gap-2">
<span>{value}</span>
<span className="text-bg-500 text-xs">/{max}</span>
</div>
</div>
)}
<div className="w-full">
<input
className={clsx(
'range range-primary bg-bg-200 dark:bg-bg-800 w-full',
className
)}
disabled={disabled}
max={max}
min={min}
step={step}
type="range"
value={value}
onChange={e => {
onChange(parseFloat(e.target.value))
}}
/>
<div className="mb-4 flex w-full justify-between px-2.5 text-xs">
{[min, ((min + max) / 2).toFixed(1), max].map((label, index) => (
<div
key={`title-${label}-${index}`}
className="bg-bg-300 dark:bg-bg-700 relative h-2 w-0.5 rounded-full"
<Flex
align="center"
gap="xl"
justify="between"
mb="md"
minWidth="0"
width="100%"
>
<Text
asChild
color={{ base: 'bg-400', dark: 'bg-600' }}
weight="medium"
>
<Flex
align="center"
gap="sm"
minWidth="0"
style={{ letterSpacing: '0.025em' }}
>
<div className="text-bg-400 dark:text-bg-600 absolute -bottom-5 left-1/2 -translate-x-1/2 font-medium">
{label}
</div>
</div>
<Icon
icon={icon}
style={{ width: '1.5rem', height: '1.5rem', flexShrink: 0 }}
/>
<Flex align="center" gap="sm" minWidth="0" width="100%">
<Text truncate as="div" style={{ width: '100%', minWidth: 0 }}>
{inputLabel}
</Text>
{required && <span style={{ color: '#ef4444' }}>*</span>}
</Flex>
</Flex>
</Text>
<Flex align="center" gap="sm">
<span>{value}</span>
<Text color="bg-500" style={{ fontSize: '0.75rem' }}>
/{max}
</Text>
</Flex>
</Flex>
)}
<Box width="100%">
<Box
asChild
shadow
bg={{ base: 'bg-200', dark: 'bg-800' }}
width="100%"
>
<input
className={clsx('range range-primary', className)}
disabled={disabled}
max={max}
min={min}
step={step}
type="range"
value={value}
onChange={e => {
onChange(parseFloat(e.target.value))
}}
/>
</Box>
<Flex
justify="between"
mb="md"
style={{
fontSize: '0.75rem',
paddingLeft: '0.625rem',
paddingRight: '0.625rem'
}}
width="100%"
>
{[min, ((min + max) / 2).toFixed(1), max].map((label, index) => (
<Box
key={`title-${label}-${index}`}
bg={{ base: 'bg-300', dark: 'bg-700' }}
position="relative"
rounded="full"
style={{ height: '0.5rem', width: '0.125rem' }}
>
<Text
asChild
color={{ base: 'bg-400', dark: 'bg-600' }}
weight="medium"
>
<Box
position="absolute"
style={{
bottom: '-1.25rem',
left: '50%',
transform: 'translateX(-50%)'
}}
>
{label}
</Box>
</Text>
</Box>
))}
</div>
</div>
</div>
</Flex>
</Box>
</Box>
)
}

View File

@@ -1,47 +0,0 @@
import { recipe } from '@vanilla-extract/recipes'
export const textareaRecipe = recipe({
base: {
maxHeight: '32rem', // 128
minHeight: '2rem', // 8
width: '100%',
resize: 'none',
borderRadius: 'var(--radius-lg)',
backgroundColor: 'transparent',
letterSpacing: '0.025em', // tracking-wide
outline: 'none',
selectors: {
'&:focus': {
outline: 'none'
},
'&:focus::placeholder': {
color: 'var(--color-bg-400)'
},
'.dark &:focus::placeholder': {
color: 'var(--color-bg-600)'
}
}
},
variants: {
variant: {
classic: {
marginTop: '2.25rem', // 9
paddingLeft: '1.5rem', // 6
paddingRight: '1rem', // 4
paddingBottom: '0.75rem', // 3
paddingTop: 0,
selectors: {
'&::placeholder': {
color: 'transparent'
}
}
},
plain: {
padding: 0
}
}
},
defaultVariants: {
variant: 'classic'
}
})

View File

@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { Box } from '@components/primitives'
import TextAreaInput from './TextAreaInput'
const meta = {
@@ -23,13 +25,39 @@ export const Default: Story = {
const [value, onChange] = useState(args.value)
return (
<TextAreaInput
{...args}
className="w-128"
disabled={false}
onChange={onChange}
value={value}
/>
<Box width="32rem">
<TextAreaInput
{...args}
disabled={false}
onChange={onChange}
value={value}
/>
</Box>
)
}
}
export const PlaintVariant: Story = {
args: {
icon: 'tabler:text-size',
label: 'Description',
placeholder: 'Something amazing about yourself...',
value: '',
variant: 'plain'
},
render: args => {
const [value, onChange] = useState(args.value)
return (
<Box width="32rem">
<TextAreaInput
{...args}
disabled={false}
onChange={onChange}
value={value}
/>
</Box>
)
}
}

View File

@@ -1,12 +1,15 @@
import clsx from 'clsx'
import React, { useEffect, useRef } from 'react'
import { useEffect, useRef } from 'react'
import { Box, Flex, Text } from '@components/primitives'
import { vars } from '@/system'
import InputIcon from '../shared/components/InputIcon'
import InputLabel from '../shared/components/InputLabel'
import InputWrapper from '../shared/components/InputWrapper'
import Placeholder from '../shared/components/Placeholder'
import useInputLabel from '../shared/hooks/useInputLabel'
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
import { textareaRecipe } from './TextAreaInput.css'
export interface TextAreaInputProps {
/** The style type of the input field. 'classic' shows label and icon with underline, 'plain' is a simple rounded box. */
@@ -75,7 +78,7 @@ function TextAreaInput({
icon={icon}
/>
)}
<div className="flex w-full items-center gap-2">
<Flex align="center" gap="sm" position="relative" width="100%">
{variant === 'classic' && label && (
<InputLabel
active={!!value && String(value).length > 0}
@@ -84,36 +87,61 @@ function TextAreaInput({
required={required === true}
/>
)}
<textarea
ref={autoFocusableRef(autoFocus, ref)}
className={clsx(textareaRecipe({ variant }))}
placeholder={placeholder}
value={value}
onInput={e => {
onChange(e.currentTarget.value)
}}
onKeyDown={e => {
if (e.key === 'Enter') {
const cursorPosition = e.currentTarget.selectionStart
const text = e.currentTarget.value
const newText =
text.slice(0, cursorPosition) +
'\n' +
text.slice(cursorPosition)
onChange(newText)
e.currentTarget.value = newText
e.currentTarget.setSelectionRange(
cursorPosition + 1,
cursorPosition + 1
)
e.preventDefault()
<Placeholder
color={variant === 'classic' ? 'transparent' : 'default'}
focusColor="default"
>
<Box
asChild
bg="transparent"
maxHeight="32rem"
minHeight="4rem"
p={variant === 'classic' ? 'xl' : 'none'}
pl={variant === 'classic' ? 'none' : undefined}
rounded="lg"
style={
variant === 'classic'
? { paddingBottom: vars.radii.md }
: undefined
}
}}
/>
</div>
width="100%"
>
<Text asChild tracking="wide">
<textarea
ref={autoFocusableRef(autoFocus, ref)}
placeholder={placeholder}
style={{
resize: 'none'
}}
value={value}
onInput={e => {
onChange(e.currentTarget.value)
}}
onKeyDown={e => {
if (e.key === 'Enter') {
const cursorPosition = e.currentTarget.selectionStart
const text = e.currentTarget.value
const newText =
text.slice(0, cursorPosition) +
'\n' +
text.slice(cursorPosition)
onChange(newText)
e.currentTarget.value = newText
e.currentTarget.setSelectionRange(
cursorPosition + 1,
cursorPosition + 1
)
e.preventDefault()
}
}}
/>
</Text>
</Box>
</Placeholder>
</Flex>
</InputWrapper>
)
}

View File

@@ -83,7 +83,7 @@ function TextInput({
icon={icon}
/>
)}
<Flex align="center" gap="sm" pr="md" width="100%">
<Flex align="center" gap="sm" position="relative" width="100%">
{variant === 'classic' && label && (
<InputLabel
active={!!value && String(value).length > 0}

View File

@@ -1,6 +1,6 @@
import { recipe } from '@vanilla-extract/recipes'
import { bg, custom } from '@/system'
import { custom, vars } from '@/system'
export const textInputBoxRecipe = recipe({
base: {
@@ -9,25 +9,15 @@ export const textInputBoxRecipe = recipe({
backgroundColor: 'transparent',
letterSpacing: '0.05em',
borderRadius: 'var(--radius-lg)',
outline: 'none',
selectors: {
'&:focus::placeholder': {
color: bg[500]
}
}
outline: 'none'
},
variants: {
variant: {
classic: {
marginTop: 'calc(var(--spacing) * 6)',
height: '3.25rem',
padding: 'calc(var(--spacing) * 6)',
paddingLeft: 'calc(var(--spacing) * 4)',
selectors: {
'&::placeholder': {
color: 'transparent'
}
}
marginTop: vars.space.md,
padding: vars.space.md,
paddingLeft: 0,
paddingBottom: vars.space.sm
},
plain: {
padding: 0,

View File

@@ -1,5 +1,6 @@
import clsx from 'clsx'
import Placeholder from '@components/inputs/shared/components/Placeholder'
import { autoFocusableRef } from '@components/inputs/shared/utils/autoFocusableRef'
import { textInputBoxRecipe } from './TextInputBox.css'
@@ -51,24 +52,29 @@ function TextInputBox({
{isPassword && (
<input hidden type="password" value="" onChange={() => {}} />
)}
<input
ref={autoFocusableRef(autoFocus, inputRef)}
aria-label={placeholder}
autoComplete="off"
className={clsx(inputClassName, className)}
disabled={disabled}
inputMode={inputMode}
placeholder={placeholder}
style={
isPassword && showPassword !== true ? { fontFamily: 'Arial' } : {}
}
type={isPassword && showPassword !== true ? 'password' : 'text'}
value={value}
onChange={e => {
onChange(e.target.value)
}}
{...inputProps}
/>
<Placeholder
color={variant === 'classic' ? 'transparent' : 'default'}
focusColor="default"
>
<input
ref={autoFocusableRef(autoFocus, inputRef)}
aria-label={placeholder}
autoComplete="off"
className={clsx(inputClassName, className)}
disabled={disabled}
inputMode={inputMode}
placeholder={placeholder}
style={
isPassword && showPassword !== true ? { fontFamily: 'Arial' } : {}
}
type={isPassword && showPassword !== true ? 'password' : 'text'}
value={value}
onChange={e => {
onChange(e.target.value)
}}
{...inputProps}
/>
</Placeholder>
</>
)
}

View File

@@ -9,8 +9,11 @@ export const root = style({
color: bg[500],
transition: 'all 0.2s',
selectors: {
'&:hover:not(:disabled)': { color: bg[800] },
'.dark &:hover:not(:disabled)': { color: bg[200] },
'&:hover:not(:disabled)': { color: bg[800], backgroundColor: bg[300] },
'.dark &:hover:not(:disabled)': {
color: bg[200],
backgroundColor: bg[700]
},
'&:disabled': { cursor: 'not-allowed', opacity: 0.5 }
}
})

View File

@@ -1,6 +1,6 @@
import { Icon } from '@iconify/react'
import { clsx } from 'clsx'
import { type ComponentPropsWithoutRef } from 'react'
import { type ComponentPropsWithoutRef, type ReactNode } from 'react'
import { Flex } from '@components/primitives'
@@ -12,6 +12,11 @@ interface InputActionButtonProps extends Omit<
> {
/** Iconify icon name to display inside the button. */
icon: string
/** Visual style variant of the button. */
variant?: 'classic' | 'plain'
/** Whether to merge the button styles into a single child element instead of rendering a native button. */
asChild?: boolean
children?: ReactNode
}
/**
@@ -21,23 +26,37 @@ interface InputActionButtonProps extends Omit<
*/
function InputActionButton({
icon,
variant = 'classic',
className,
style,
asChild = false,
children,
...rest
}: InputActionButtonProps) {
return (
<Flex
asChild
align="center"
asChild={asChild}
bg={{ base: 'transparent', hover: 'bg-200', darkHover: 'bg-800' }}
className={clsx(styles.root, className)}
flexShrink="0"
justify="center"
p="sm"
position="absolute"
right="0"
rounded="lg"
style={{
...style,
marginRight: variant === 'classic' ? '1rem' : '0.75rem'
}}
>
<button type="button" {...rest}>
<Icon icon={icon} style={{ width: '1.5rem', height: '1.5rem' }} />
</button>
{asChild ? (
children
) : (
<button type="button" {...rest}>
<Icon icon={icon} style={{ width: '1.25em', height: '1.25em' }} />
</button>
)}
</Flex>
)
}

View File

@@ -31,6 +31,7 @@ function InputIcon({
<Box
asChild
flexShrink="0"
mx="md"
style={{
transition: 'all 0.2s',
pointerEvents: 'none'

View File

@@ -5,7 +5,7 @@ import { bg, custom } from '@/system'
export const inputLabelBaseStyle = style({
pointerEvents: 'none',
position: 'absolute',
left: 'calc(var(--spacing) * 16)',
left: 0,
width: 'calc(100% - 5.75rem)',
minWidth: 0,
overflow: 'hidden',

View File

@@ -1,5 +1,8 @@
import { Icon } from '@iconify/react'
import clsx from 'clsx'
import { memo } from 'react'
import { type CSSProperties, memo } from 'react'
import { Flex, Text } from '@components/primitives'
import {
inputLabelActiveStyle,
@@ -19,6 +22,8 @@ interface InputLabelProps {
isListboxOrCombobox?: boolean
required?: boolean
hasError?: boolean
className?: string
style?: CSSProperties
}
function InputLabel({
@@ -26,10 +31,13 @@ function InputLabel({
active,
focused = false,
required = false,
hasError = false
hasError = false,
className,
style
}: InputLabelProps) {
return (
<span
<Flex
align="center"
className={clsx(
inputLabelBaseStyle,
active ? inputLabelActiveStyle : inputLabelInactiveStyle,
@@ -37,12 +45,29 @@ function InputLabel({
? inputLabelErrorStyle
: focused
? inputLabelFocusedStyle
: inputLabelNormalStyle
: inputLabelNormalStyle,
className
)}
gap="xs"
style={style}
>
{label}
{required && <span className={inputLabelRequiredStyle}> *</span>}
</span>
<Text>{label}</Text>
{required && (
<Text
className={inputLabelRequiredStyle}
color="dangerous"
display="block"
>
<Icon
icon="tabler:asterisk"
style={{
width: '0.625em',
height: '0.625em'
}}
/>
</Text>
)}
</Flex>
)
}

View File

@@ -1,51 +1,35 @@
import { style } from '@vanilla-extract/css'
import { recipe } from '@vanilla-extract/recipes'
import { bg, custom, withOpacity } from '@/system'
import { bg, custom, vars, withOpacity } from '@/system'
export const inputWrapperRecipe = recipe({
base: {
transition: 'all 0.2s',
backgroundColor: withOpacity(bg[200], 0.5),
selectors: {
'.dark &': { backgroundColor: withOpacity(bg[800], 0.7) },
'&:hover': { backgroundColor: bg[200] },
'.dark &:hover': { backgroundColor: bg[800] }
}
},
variants: {
variant: {
classic: {
backgroundColor: withOpacity(bg[200], 0.5),
boxShadow: 'var(--custom-shadow)',
borderTopLeftRadius: 'var(--radius-lg)',
borderTopRightRadius: 'var(--radius-lg)',
borderTopLeftRadius: vars.radii.lg,
borderTopRightRadius: vars.radii.lg,
borderBottomWidth: '2px',
borderBottomStyle: 'solid',
paddingLeft: 'calc(var(--spacing) * 6)',
selectors: {
'.dark &': {
backgroundColor: withOpacity(bg[800], 0.7)
},
'&:hover': {
backgroundColor: bg[200]
},
'.dark &:hover': {
backgroundColor: bg[800]
},
'.bordered &': {
borderRadius: 'var(--radius-lg)',
borderRadius: vars.radii.lg,
borderWidth: '2px',
borderStyle: 'solid'
}
}
},
plain: {
backgroundColor: withOpacity(bg[200], 0.5),
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--custom-shadow)',
selectors: {
'.dark &': {
backgroundColor: withOpacity(bg[800], 0.7)
},
'&:hover': {
backgroundColor: bg[200]
},
'.dark &:hover': {
backgroundColor: bg[800]
}
}
borderRadius: vars.radii.lg
}
},
size: {
@@ -87,17 +71,13 @@ export const inputWrapperRecipe = recipe({
{
variants: { variant: 'plain', size: 'default' },
style: {
padding: 'calc(var(--spacing) * 4)',
paddingLeft: 'calc(var(--spacing) * 5)',
paddingRight: 'calc(var(--spacing) * 5)'
padding: vars.space.md
}
},
{
variants: { variant: 'plain', size: 'small' },
style: {
padding: 'calc(var(--spacing) * 2)',
paddingLeft: 'calc(var(--spacing) * 3)',
paddingRight: 'calc(var(--spacing) * 3)'
padding: vars.space.sm
}
}
],
@@ -110,8 +90,5 @@ export const inputWrapperRecipe = recipe({
})
export const inputWrapperErrorTextStyle = style({
paddingLeft: 'calc(var(--spacing) * 6)',
paddingRight: 'calc(var(--spacing) * 6)',
fontSize: 'var(--text-sm)',
color: 'var(--color-red-500)'
})

View File

@@ -3,7 +3,7 @@ import { Icon } from '@iconify/react'
import clsx from 'clsx'
import { useCallback } from 'react'
import { Box, Flex } from '@components/primitives'
import { Box, Flex, Text } from '@components/primitives'
import {
inputWrapperErrorTextStyle,
@@ -74,17 +74,14 @@ function InputWrapper({
})
return (
<Flex className={className} width="100%" direction="column" gap="sm">
<Flex className={className} direction="column" gap="sm" width="100%">
<Flex
shadow
align="center"
className={clsx('group', wrapperClassName)}
flexShrink="0"
gap="sm"
position="relative"
role="button"
style={{
transition: 'all 0.2s'
}}
tabIndex={0}
width="100%"
onClick={focusInput}
@@ -103,7 +100,17 @@ function InputWrapper({
</Box>
)}
</Flex>
{errorMsg && <div className={inputWrapperErrorTextStyle}>{errorMsg}</div>}
{errorMsg && (
<Text
className={inputWrapperErrorTextStyle}
color="dangerous"
display="block"
px="lg"
size="sm"
>
{errorMsg}
</Text>
)}
</Flex>
)
}

View File

@@ -0,0 +1,28 @@
import { recipe } from '@vanilla-extract/recipes'
import { bg } from '@/system'
export const placeholderRecipe = recipe({
variants: {
color: {
transparent: {
selectors: { '&::placeholder': { color: 'transparent' } }
},
default: {
selectors: { '&::placeholder': { color: bg[500] } }
}
},
focusColor: {
transparent: {
selectors: { '&:focus::placeholder': { color: 'transparent' } }
},
default: {
selectors: { '&:focus::placeholder': { color: bg[500] } }
}
}
},
defaultVariants: {
color: 'default',
focusColor: 'default'
}
})

View File

@@ -0,0 +1,33 @@
import { clsx } from 'clsx'
import { type CSSProperties, type ReactNode } from 'react'
import { Slot } from '@components/primitives'
import { placeholderRecipe } from './Placeholder.css'
interface PlaceholderProps {
color?: 'transparent' | 'default'
focusColor?: 'transparent' | 'default'
className?: string
style?: CSSProperties
children: ReactNode
}
function Placeholder({
color = 'default',
focusColor = 'default',
className,
style,
children
}: PlaceholderProps) {
return (
<Slot
className={clsx(placeholderRecipe({ color, focusColor }), className)}
style={style}
>
{children}
</Slot>
)
}
export default Placeholder

View File

@@ -0,0 +1 @@
export { default } from './Placeholder'

View File

@@ -66,6 +66,14 @@ const meta = {
overflowWrap: {
control: { type: 'select' },
options: ['normal', 'break-word', 'anywhere']
},
tracking: {
control: { type: 'select' },
options: ['tighter', 'tight', 'normal', 'wide', 'wider', 'widest']
},
leading: {
control: { type: 'select' },
options: ['none', 'tight', 'snug', 'normal', 'relaxed', 'loose']
}
}
} satisfies Meta<typeof Text>
@@ -519,3 +527,71 @@ export const Composition: Story = {
</Flex>
)
}
/**
* `tracking` controls `letter-spacing` using a named scale from `'tighter'`
* (-0.05em) to `'widest'` (0.1em).
*/
export const Tracking: Story = {
args: {},
render: () => (
<ScrollableStory>
{(['tighter', 'tight', 'normal', 'wide', 'wider', 'widest'] as const).map(
tracking => (
<Flex key={tracking} align="baseline" gap="md">
<Text
as="code"
color={{ base: 'bg-400', dark: 'bg-500' }}
size="sm"
style={{ width: '5rem', flexShrink: 0 }}
>
{tracking}
</Text>
<Text size="lg" tracking={tracking}>
The quick brown fox jumps over the lazy dog.
</Text>
</Flex>
)
)}
</ScrollableStory>
)
}
/**
* `leading` overrides `line-height` with a named scale from `'none'` (1)
* to `'loose'` (2), independent of the `size` prop.
*/
export const Leading: Story = {
args: {},
render: () => (
<ScrollableStory>
{(['none', 'tight', 'snug', 'normal', 'relaxed', 'loose'] as const).map(
leading => (
<Box
key={leading}
bg={{ base: 'bg-50', dark: 'bg-800' }}
p="md"
rounded="lg"
width="100%"
>
<Text
as="code"
color={{ base: 'bg-400', dark: 'bg-500' }}
display="block"
mb="xs"
size="sm"
>
leading=&quot;{leading}&quot;
</Text>
<Text as="p" leading={leading} size="base">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
</Text>
</Box>
)
)}
</ScrollableStory>
)
}

View File

@@ -37,7 +37,9 @@ type TextSize =
| '8xl'
| '9xl'
type TextColor = TextColorValues
type TextTracking = 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest'
type TextLeading = 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose'
type FontWeight = 'normal' | 'medium' | 'semibold' | 'bold'
@@ -69,8 +71,8 @@ interface TextOwnProps<T extends ElementType = 'span'>
asChild?: boolean
ref?: Ref<HTMLElement>
size?: ResponsiveProp<TextSize>
color?: ThemeConditionProp<TextColor>
bg?: ThemeConditionProp<TextColor>
color?: ThemeConditionProp<TextColorValues>
bg?: ThemeConditionProp<TextColorValues>
weight?: ResponsiveProp<FontWeight>
align?: ResponsiveProp<TextAlign>
decoration?: ResponsiveProp<TextDecoration>
@@ -91,6 +93,8 @@ interface TextOwnProps<T extends ElementType = 'span'>
trim?: ResponsiveProp<TextTrim>
truncate?: boolean
lineClamp?: number
tracking?: ResponsiveProp<TextTracking>
leading?: ResponsiveProp<TextLeading>
className?: string
children?: ReactNode
}
@@ -125,6 +129,8 @@ export function Text<T extends ElementType = 'span'>({
trim,
truncate,
lineClamp,
tracking,
leading,
// Margin props
m,
mx,
@@ -148,7 +154,9 @@ export function Text<T extends ElementType = 'span'>({
}: TextProps<T> & { style?: CSSProperties }) {
const sprinklesClassName = textSprinkles({
fontSize: normalizeResponsiveProp(size) as TextSprinkles['fontSize'],
lineHeight: normalizeResponsiveProp(size) as TextSprinkles['lineHeight'],
lineHeight: normalizeResponsiveProp(
leading ?? size
) as TextSprinkles['lineHeight'],
color: color as TextSprinkles['color'],
backgroundColor: bg as TextSprinkles['backgroundColor'],
fontWeight: normalizeResponsiveProp(weight) as TextSprinkles['fontWeight'],
@@ -168,6 +176,9 @@ export function Text<T extends ElementType = 'span'>({
overflowWrap: normalizeResponsiveProp(
overflowWrap
) as TextSprinkles['overflowWrap'],
letterSpacing: normalizeResponsiveProp(
tracking
) as TextSprinkles['letterSpacing'],
margin: normalizeResponsiveProp(m) as TextSprinkles['margin'],
marginTop: normalizeResponsiveProp(mt ?? my) as TextSprinkles['marginTop'],
marginBottom: normalizeResponsiveProp(

View File

@@ -12,7 +12,8 @@ const textColorValues = {
inherit: 'inherit',
default: 'var(--color-bg-900)',
muted: 'var(--color-bg-500)',
primary: 'var(--color-custom-500)'
primary: 'var(--color-custom-500)',
dangerous: 'var(--color-red-500)'
} as const
/** Theme-aware color/backgroundColor for Text — supports dark/hover/hasBgImage conditions. */
@@ -37,8 +38,25 @@ const textProperties = defineProperties({
defaultCondition: 'base',
properties: {
fontSize: vars.fontSize,
lineHeight: vars.lineHeight,
lineHeight: {
...vars.lineHeight,
// Named leading scale (overrides size-based lineHeight when 'leading' prop is used)
none: '1',
tight: '1.25',
snug: '1.375',
normal: '1.5',
relaxed: '1.625',
loose: '2'
},
fontWeight: vars.fontWeight,
letterSpacing: {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em'
},
textAlign: ['left', 'center', 'right'],
textDecoration: ['underline', 'line-through', 'none'],
textTransform: ['uppercase', 'lowercase', 'capitalize', 'none'],