feat(ui): add PrintArea utility component with Storybook stories

This commit is contained in:
melvinchia3636
2026-06-02 13:06:34 +08:00
parent f94080200f
commit b7ff42e985
7 changed files with 81 additions and 21 deletions

View File

@@ -122,7 +122,7 @@ type ThemeConditionPropName =
| 'darkHasBgImage' // Active in dark mode with a background image
| 'hasBgImageHover'
| 'hasBgImageDarkHover'
```
| 'print' // Active under print media query
##### Example Usage:
@@ -132,9 +132,10 @@ type ThemeConditionPropName =
base: 'bg-50',
dark: 'bg-900',
hover: 'bg-200',
darkHover: 'bg-800'
darkHover: 'bg-800',
print: 'transparent'
}}
color={{ base: 'bg-950', dark: 'bg-50' }}
color={{ base: 'bg-950', dark: 'bg-50', print: 'black' }}
/>
```
@@ -211,6 +212,7 @@ Layout props support responsive values. Breakpoints are defined as:
- `lg`: `@media (min-width: 1024px)`
- `xl`: `@media (min-width: 1280px)`
- `2xl`: `@media (min-width: 1536px)`
- `print`: `@media print` (active under print mode)
Any responsive property accepts a scalar or a responsive configuration object:
@@ -220,6 +222,9 @@ Any responsive property accepts a scalar or a responsive configuration object:
// Responsive width
<Box width={{ base: '100%', md: '50%', lg: '33.33%' }} />
// Hide element during printing
<Box display={{ base: 'block', print: 'none' }} />
```
_How it works under the hood:_ The engine applies `.lf-w` and `.md:lf-w` classes while defining CSS variables (`--lf-w: 100%`, `--lf-w-md: 50%`) inline, keeping output stylesheet sizes extremely small.
@@ -564,16 +569,18 @@ interface WithDivideProps {
}
```
`WithDivide` must wrap **each individual item**, not a parent container. It adds a divider between adjacent `WithDivide` siblings.
#### Example: List Group Dividers
```tsx
<WithDivide axis="y" color={{ base: 'bg-300', dark: 'bg-800' }}>
<Stack gap="none">
<Box p="md">Item 1</Box>
<Box p="md">Item 2</Box>
<Box p="md">Item 3</Box>
</Stack>
</WithDivide>
<Stack gap="none">
{items.map(item => (
<WithDivide key={item.id} axis="y" color={{ base: 'bg-300', dark: 'bg-800' }}>
<Box p="md">{item.name}</Box>
</WithDivide>
))}
</Stack>
```
---
@@ -692,6 +699,27 @@ type ButtonProps<T extends ElementType = 'button'> = ButtonOwnProps &
Since `Button` extends `FlexProps` (which extends `BoxProps`), **any layout prop available on `Flex` or `Box` can be passed directly** — no wrapping `Box` needed. Only reach for `Box asChild` when you need a prop that Flex/Box doesn't support (e.g. CSS properties only available via inline `style`).
> [!TIP]
> **`Card` is already a `Flex` component.** Because `CardProps` extends `FlexProps`, you can pass `align`, `gap`, `justify`, `direction`, and all other layout props **directly to `Card`** — no need to wrap its children in a `<Flex>` container. The only prop `Card` adds beyond `Flex` is `isInteractive`.
>
> ```tsx
> // ❌ Redundant: Card + inner Flex wrapper
> <Card isInteractive>
> <Flex align="center" gap="md" justify="between">
> <Text>Label</Text>
> <ContextMenu>...</ContextMenu>
> </Flex>
> </Card>
>
> // ✅ Correct: layout props on Card directly
> <Card isInteractive align="center" direction="row" gap="md" justify="between">
> <Text>Label</Text>
> <ContextMenu>...</ContextMenu>
> </Card>
> ```
>
> `Card` defaults to `direction="column"`. Override it with `direction="row"` when you need a horizontal layout.
#### Notable Engineering Features:
1. **Dynamic Contrast Matching:** In `useButtonStyleProps`, when `variant="primary"` is set, the button fetches the user's active theme color (`derivedThemeColor`) and runs `getMostReadableColor()` to compute a text color with optimal contrast.
@@ -1116,5 +1144,6 @@ Before submitting a pull request, verify that you have adhered to all core desig
- [ ] **Correct loaders:** Form/button loading states use the pre-animated `svg-spinners:ring-resize` icon and **never** use custom `animate-spin` utilities.
- [ ] **Type safety:** Typescript `any` is never used. All prop overrides and custom handlers are explicitly typed.
- [ ] **Datetime manipulation:** Standard JavaScript `Date` is never used. `day.js` is imported for any date calculations.
- [ ] **List spacing:** For vertical lists (`Stack`), `mb="lg"` is the standard bottom margin — no need to define responsive sizes like `mb={{ base: '6rem', md: 'lg' }}`. The spacing token `lg` is consistent across breakpoints and is sufficient for all list containers.
- [ ] **Component organization:** Components are strictly separated into individual files under their respective `components/` folders instead of being grouped together.
- [ ] **Conventional functions:** All React components use standard function declarations (`export function Component()`) and avoid arrow functions.

View File

@@ -1678,9 +1678,15 @@ export const contract = {
"output": {
"OK": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"NOT_FOUND": true,
"FORBIDDEN": true
}
},

View File

@@ -5,17 +5,32 @@ import { COLOR_PROP_DEFS, THEME_CONDITIONS } from './constants'
for (const { className, cssProperty, varPrefix } of Object.values(
COLOR_PROP_DEFS
)) {
for (const { suffix, varSuffix, selectorTemplate } of Object.values(
for (const { suffix, varSuffix, selectorTemplate, media } of Object.values(
THEME_CONDITIONS
)) {
) as Array<{
suffix: string
varSuffix: string
selectorTemplate: string
media?: string
}>) {
const fullClassName = `${className}${suffix}`
const fullVar = `${varPrefix}${varSuffix}`
const selector = selectorTemplate.replace('{cls}', fullClassName)
globalStyle(selector, {
[cssProperty]: `var(${fullVar})`
})
if (media) {
globalStyle(selector, {
'@media': {
[media]: {
[cssProperty]: `var(${fullVar}) !important`
}
}
})
} else {
globalStyle(selector, {
[cssProperty]: `var(${fullVar})`
})
}
}
}

View File

@@ -7,6 +7,7 @@ export type ThemeConditionPropName =
| 'darkHasBgImage'
| 'hasBgImageHover'
| 'hasBgImageDarkHover'
| 'print'
export type ThemeConditionProp<T> =
| T
@@ -52,6 +53,12 @@ export const THEME_CONDITIONS = {
suffix: '-has-bg-image-dark-hover',
varSuffix: '-has-bg-image-dark-hover',
selectorTemplate: '.dark .has-bg-image .{cls}:hover'
},
print: {
suffix: '-print',
varSuffix: '-print',
selectorTemplate: '.{cls}',
media: 'print'
}
} as const satisfies Record<
ThemeConditionPropName,
@@ -59,5 +66,6 @@ export const THEME_CONDITIONS = {
suffix: string
varSuffix: string
selectorTemplate: string
media?: string
}
>

View File

@@ -6,7 +6,8 @@ export const RESPONSIVE_CONDITIONS = {
md: { '@media': '(min-width: 768px)' },
lg: { '@media': '(min-width: 1024px)' },
xl: { '@media': '(min-width: 1280px)' },
'2xl': { '@media': '(min-width: 1536px)' }
'2xl': { '@media': '(min-width: 1536px)' },
print: { '@media': 'print' }
} as const satisfies Record<Breakpoint, object>
export const LAYOUT_PROP_DEFS = {

View File

@@ -45,7 +45,8 @@ const breakpointMediaQueries = {
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
'2xl': '(min-width: 1536px)'
'2xl': '(min-width: 1536px)',
print: 'print'
} as const
// Generate base styles (no breakpoint = base/mobile)
@@ -71,7 +72,7 @@ for (const { className, property, customProp } of RESPONSIVE_PROPS) {
globalStyle(`.${bpClassName}`, {
'@media': {
[mediaQuery]: {
[property]: `var(${bpCustomProp})`
[property]: bp === 'print' ? `var(${bpCustomProp}) !important` : `var(${bpCustomProp})`
}
}
})

View File

@@ -1,4 +1,4 @@
export type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'print'
export interface PropDef {
className: string