mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 06:46:24 +00:00
refactor(ui): huge progress on detailwinding
This commit is contained in:
661
.github/agents/de-tailwind.agent.md
vendored
661
.github/agents/de-tailwind.agent.md
vendored
@@ -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.
|
||||
|
||||
|
||||
609
.github/instructions/de-tailwind.instructions.md
vendored
609
.github/instructions/de-tailwind.instructions.md
vendored
@@ -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"
|
||||
>
|
||||
```
|
||||
@@ -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%**|
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
})
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)`
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css'
|
||||
|
||||
import { custom } from '@/system'
|
||||
|
||||
export const dataOpen = style({
|
||||
selectors: {
|
||||
'&[data-open]': { borderColor: custom[500] }
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 'md' breakpoint
|
||||
and below.
|
||||
</p>
|
||||
<Fab {...props} className="fixed right-6 bottom-6" />
|
||||
</div>
|
||||
</Text>
|
||||
<Fab {...props} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,7 +15,7 @@ export const Default: Story = {
|
||||
value: 0,
|
||||
onChange: () => {},
|
||||
|
||||
label: 'Price',
|
||||
icon: 'tabler:currency-dollar'
|
||||
label: 'Age',
|
||||
icon: 'tabler:calendar'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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%'
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function InputIcon({
|
||||
<Box
|
||||
asChild
|
||||
flexShrink="0"
|
||||
mx="md"
|
||||
style={{
|
||||
transition: 'all 0.2s',
|
||||
pointerEvents: 'none'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Placeholder'
|
||||
@@ -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="{leading}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user