Initial commit: @ampeco/ewa-ui v1.0.0

Shared React component library with Tailwind plugin for AMPECO EWAs.
8 components: Button, Card, Input, StatusBadge, Loader, EmptyState, Toast, BottomSheet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan Danchev
2026-03-18 19:34:11 +02:00
commit cdc8829ce7
17 changed files with 3389 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+261
View File
@@ -0,0 +1,261 @@
# @ampeco/ewa-ui
Shared React component library and Tailwind CSS plugin for AMPECO EWA (Embedded Web App) projects. Provides themed, accessible UI primitives driven entirely by CSS custom properties.
## Install
```bash
npm install @ampeco/ewa-ui
```
Peer dependencies: `react >= 18`, `react-dom >= 18`.
## Tailwind Plugin Setup
The package ships a Tailwind plugin at `@ampeco/ewa-ui/plugin` that registers design tokens (colors, spacing, radii, typography, animations) and status-color utilities.
```js
// tailwind.config.js
import { ewaUiPlugin } from '@ampeco/ewa-ui/plugin';
export default {
content: [
'./src/**/*.{ts,tsx}',
'./node_modules/@ampeco/ewa-ui/dist/**/*.{js,cjs}', // scan library classes
],
plugins: [ewaUiPlugin],
};
```
You must define the CSS custom properties the plugin references. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full variable list.
## Components
All components are named exports from `@ampeco/ewa-ui`:
```tsx
import { Button, Card, Input, StatusBadge, Loader, EmptyState, Toast, BottomSheet } from '@ampeco/ewa-ui';
```
Type-only imports are also available (e.g. `import type { ButtonProps } from '@ampeco/ewa-ui'`).
---
### Button
Themed button with four variants and a built-in loading spinner.
```tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'text';
fullWidth?: boolean;
loading?: boolean;
}
```
```tsx
<Button variant="primary" onClick={handleSave}>Save Booking</Button>
<Button variant="secondary" fullWidth>Cancel</Button>
<Button variant="danger" loading={isDeleting}>Delete</Button>
<Button variant="text">Learn more</Button>
```
- Height is fixed at `h-11` (44px) for touch-target compliance.
- `loading` shows an animated spinner overlay and hides children via `invisible`.
- `disabled` and `loading` both set `opacity-50 cursor-not-allowed`.
- Focus ring uses `focus-visible:ring-2` with the primary button color.
---
### Card
Surface container with optional click interactivity.
```tsx
interface CardProps {
className?: string;
onClick?: () => void;
children: React.ReactNode;
}
```
```tsx
<Card>
<p>Static content</p>
</Card>
<Card onClick={() => navigate(`/bookings/${id}`)}>
<p>Clickable card with hover scale and keyboard support</p>
</Card>
```
- When `onClick` is provided, the card renders with `role="button"`, `tabIndex={0}`, and `Enter`/`Space` key handlers.
- Hover applies `scale-[1.01]`, active applies `scale-[0.99]`.
---
### Input
Labeled text input with error and hint support. Forwards refs.
```tsx
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
label: string;
hint?: string;
error?: string;
className?: string;
}
```
```tsx
<Input label="Connector ID" placeholder="e.g. CPI-001" />
<Input label="Email" error="Invalid email address" />
<Input label="Duration" hint="In minutes" type="number" />
```
- Auto-generates `id` from label text if none is provided.
- Error state turns the border red; hint is hidden when error is present.
- Height is `h-11` (44px touch target).
---
### StatusBadge
Colored pill for booking/reservation status display.
```tsx
interface StatusBadgeProps {
status: string;
label: string;
}
```
```tsx
<StatusBadge status="accepted" label="Accepted" />
<StatusBadge status="no_show" label="No Show" />
<StatusBadge status="completed" label="Completed" />
```
Recognized statuses: `accepted`, `reserved`, `completed`, `cancelled`, `no_show` / `no-show`, `failed`. Text color uses the corresponding `--status-*` variable; background uses a 15% opacity `color-mix()` utility (see architecture docs).
---
### Loader
Spinning ring indicator with optional full-screen centering.
```tsx
interface LoaderProps {
fullScreen?: boolean;
}
```
```tsx
<Loader />
<Loader fullScreen />
```
- `fullScreen` wraps the spinner in a `min-h-[60dvh]` flex container.
- Uses `role="status"` and `aria-label="Loading"`.
---
### EmptyState
Centered placeholder for empty lists or zero-data screens.
```tsx
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
}
```
```tsx
<EmptyState
icon={<CalendarIcon />}
title="No bookings yet"
description="Create your first booking to get started."
action={{ label: 'New Booking', onClick: () => navigate('/create') }}
/>
```
- Internally renders a `Button` with `variant="primary"` for the action.
---
### Toast
Auto-dismissing notification banner pinned to the top of the viewport.
```tsx
interface ToastProps {
message: string;
type: 'success' | 'error';
onDismiss: () => void;
duration?: number; // ms, default 3000
}
```
```tsx
<Toast message="Booking created" type="success" onDismiss={() => setToast(null)} />
<Toast message="Failed to save" type="error" onDismiss={() => setToast(null)} duration={5000} />
```
- Uses `animate-slide-in` for entry animation.
- Dismiss button is 44px square for touch accessibility.
- Auto-clears via `setTimeout`; cleans up on unmount.
---
### BottomSheet
Modal drawer that slides up from the bottom with a backdrop overlay.
```tsx
interface BottomSheetProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
```
```tsx
<BottomSheet open={isOpen} onClose={() => setIsOpen(false)}>
<h2>Select a time slot</h2>
{/* ... */}
</BottomSheet>
```
- Locks body scroll when open (`overflow: hidden`), restores on close.
- Overlay click triggers `onClose`.
- Bottom padding accounts for `safe-area-inset-bottom` (notched devices).
- Includes a drag-handle bar at the top.
- Uses `role="dialog"` and `aria-modal="true"`.
## Build
```bash
npm run build # Production build via tsup (ESM + CJS + .d.ts)
npm run dev # Watch mode
npm run typecheck # Type-check without emitting
```
## Source Files
```
src/
index.ts # Public re-exports
plugin.ts # Tailwind CSS plugin (design tokens + utilities)
components/
Button.tsx
Card.tsx
Input.tsx
StatusBadge.tsx
Loader.tsx
EmptyState.tsx
Toast.tsx
BottomSheet.tsx
```
+297
View File
@@ -0,0 +1,297 @@
# Architecture
Technical internals of the `@ampeco/ewa-ui` package.
## Design Philosophy
1. **CSS-variable-driven theming.** Components never hardcode colors. Every visual property maps to a CSS custom property (`--btn-primary-bg`, `--surface-bg`, etc.), enabling white-labeling by swapping variable values at the `:root` level.
2. **44px touch targets.** All interactive elements (buttons, inputs, toast dismiss) use `h-11` (44px) minimum height, meeting WCAG 2.5.8 and Apple HIG touch-target guidelines.
3. **Accessibility first.** Interactive `Card` gets `role="button"`, `tabIndex`, and `Enter`/`Space` key handlers. `Loader` uses `role="status"` + `aria-label`. `Toast` uses `role="alert"`. `BottomSheet` uses `role="dialog"` + `aria-modal`. Focus rings use `focus-visible` to avoid visual clutter on mouse clicks.
4. **Tailwind-native styling.** Components compose Tailwind utility classes directly -- no CSS files, no CSS-in-JS runtime. The Tailwind plugin registers the design tokens so standard utilities like `bg-surface-elevation-1` or `text-body-medium` resolve to the correct CSS variable values.
## Package Entry Points
Defined in `package.json` `exports`:
| Import path | Resolves to | Purpose |
|---|---|---|
| `@ampeco/ewa-ui` | `dist/index.js` / `dist/index.cjs` | React components + type exports |
| `@ampeco/ewa-ui/plugin` | `dist/plugin.js` / `dist/plugin.cjs` | Tailwind CSS plugin (no React dependency) |
Both entry points ship ESM and CJS formats via tsup, with `.d.ts` and `.d.cts` type declarations.
Build config: `tsup.config.ts` -- two entry points, tree-shakeable, React externalized.
## Tailwind Plugin Internals
Source: `src/plugin.ts`
The plugin is built with `tailwindcss/plugin` and does three things:
### 1. Base Styles
```js
addBase({
'*, *::before, *::after': { 'box-sizing': 'border-box' },
'body': {
'font-family': "'Inter', system-ui, -apple-system, sans-serif",
'-webkit-font-smoothing': 'antialiased',
'-moz-osx-font-smoothing': 'grayscale',
},
});
```
Sets universal box-sizing and the Inter font stack with subpixel antialiasing.
### 2. Status Background Utilities
Generates `.bg-status-{name}-subtle` utilities for each status using `color-mix()`:
```js
const statusColors = ['accepted', 'reserved', 'completed', 'cancelled', 'no-show', 'failed'];
// Produces:
// .bg-status-accepted-subtle { background-color: color-mix(in srgb, var(--status-accepted) 15%, transparent); }
// .bg-status-reserved-subtle { background-color: color-mix(in srgb, var(--status-reserved) 15%, transparent); }
// ...
```
This is implemented via `addUtilities()` rather than theme extension because it requires `color-mix()` with a CSS variable -- something Tailwind's color system cannot express natively.
### 3. Theme Extension
Extends Tailwind's default theme with tokens that resolve to CSS custom properties:
#### Colors
| Tailwind class prefix | CSS variable | Usage |
|---|---|---|
| `bg-btn-primary-bg` | `--btn-primary-bg` | Primary button background |
| `text-btn-primary-text` | `--btn-primary-text` | Primary button text |
| `bg-btn-secondary-bg` | `--btn-secondary-bg` | Secondary button hover fill |
| `border-btn-secondary-border` | `--btn-secondary-border` | Secondary button border |
| `text-btn-secondary-text` | `--btn-secondary-text` | Secondary button text |
| `bg-surface-bg` | `--surface-bg` | Page/app background |
| `bg-surface-elevation-1` | `--surface-elevation-1` | Card surfaces |
| `bg-surface-elevation-2` | `--surface-elevation-2` | Dividers, loader track |
| `text-text-base-color` | `--text-base` | Primary text |
| `text-text-support` | `--text-support` | Secondary/muted text |
| `text-text-placeholder` | `--text-placeholder` | Input placeholders |
| `bg-feedback-success` | `--feedback-success` | Success color (solid) |
| `bg-feedback-error` | `--feedback-error` | Error color (solid) |
| `bg-feedback-warning` | `--feedback-warning` | Warning color (solid) |
| `bg-feedback-info` | `--feedback-info` | Info color (solid) |
| `bg-feedback-success-subtle` | `--feedback-success-subtle` | Success background (light) |
| `bg-feedback-error-subtle` | `--feedback-error-subtle` | Error background (light) |
| `bg-feedback-warning-subtle` | `--feedback-warning-subtle` | Warning background (light) |
| `bg-feedback-info-subtle` | `--feedback-info-subtle` | Info background (light) |
| `bg-input-fill` | `--input-fill` | Input background |
| `border-input-border` | `--input-border` | Input border (default) |
| `border-input-border-active` | `--input-border-active` | Input border (focus) |
| `text-input-text` | `--input-text` | Input text |
| `border-separator` | `--separator` | Divider lines |
| `text-status-accepted` | `--status-accepted` | Booking status: accepted |
| `text-status-reserved` | `--status-reserved` | Booking status: reserved |
| `text-status-completed` | `--status-completed` | Booking status: completed |
| `text-status-cancelled` | `--status-cancelled` | Booking status: cancelled |
| `text-status-no-show` | `--status-no-show` | Booking status: no-show |
| `text-status-failed` | `--status-failed` | Booking status: failed |
#### Spacing Scale
| Token | CSS variable | Suggested value |
|---|---|---|
| `xxxs` | `--space-xxxs` | 2px |
| `xxs` | `--space-xxs` | 4px |
| `xs` | `--space-xs` | 8px |
| `s` | `--space-s` | 12px |
| `m` | `--space-m` | 16px |
| `l` | `--space-l` | 20px |
| `xl` | `--space-xl` | 24px |
| `xxl` | `--space-xxl` | 32px |
| `xxxl` | `--space-xxxl` | 48px |
Used as: `p-l`, `gap-xs`, `mt-m`, `mb-xxl`, etc.
#### Border Radius
| Token | CSS variable |
|---|---|
| `s` | `--radius-s` |
| `m` | `--radius-m` |
| `l` | `--radius-l` |
| `xl` | `--radius-xl` |
Used as: `rounded-s`, `rounded-m`, `rounded-l`, `rounded-xl`.
#### Typography Scale
| Utility | Size | Line height | Weight |
|---|---|---|---|
| `text-h1` | 22px | 26px | 600 |
| `text-h2` | 18px | 22px | 600 |
| `text-h3` | 16px | 20px | 500 |
| `text-h4` | 14px | 17px | 500 |
| `text-body-large` | 16px | 20px | 400 |
| `text-body-medium` | 14px | 17px | 400 |
| `text-body-large-heavy` | 16px | 20px | 600 |
| `text-body-medium-heavy` | 14px | 17px | 600 |
| `text-buttons` | 14px | 20px | 600 |
| `text-buttons-small` | 12px | 17px | 700 |
| `text-label` | 12px | 15px | 400 |
| `text-caption` | 12px | 15px | 500 |
Each utility sets `font-size`, `line-height`, and `font-weight` simultaneously via Tailwind's `fontSize` tuple syntax.
#### Animations
| Utility | Keyframes | Duration | Timing |
|---|---|---|---|
| `animate-spin` | `spin` | 1s | linear, infinite |
| `animate-slide-in` | `slideIn` (translateY -100% to 0, fade) | 0.2s | ease-out |
| `animate-slide-up` | `slideUp` (translateY 100% to 0) | 0.3s | ease-out |
| `animate-fade-in` | `fadeIn` (opacity 0 to 1) | 0.2s | ease-out |
## How Status Colors Work
Status colors use a two-layer system:
1. **Solid color** -- `var(--status-accepted)` etc. -- used for text via `text-[var(--status-accepted)]` in `StatusBadge`.
2. **Subtle background** -- generated by the Tailwind plugin as a custom utility class:
```css
.bg-status-accepted-subtle {
background-color: color-mix(in srgb, var(--status-accepted) 15%, transparent);
}
```
`color-mix(in srgb, <color> 15%, transparent)` blends the status color at 15% opacity with transparency. This produces a tinted background that automatically adapts to any status color value without needing a separate `--status-accepted-subtle` variable.
The `StatusBadge` component (`src/components/StatusBadge.tsx`) applies both layers:
```tsx
className={[
`bg-status-${mappedStatus}-subtle`, // plugin-generated utility
`text-[var(--status-${mappedStatus})]`, // arbitrary value for text color
].join(' ')}
```
The `statusMap` normalizes API values (e.g. `no_show` with underscore) to CSS-friendly names (`no-show` with hyphen).
## Component Hierarchy
```
EmptyState
└── Button (renders the optional action CTA)
Toast
└── (self-contained, no child components)
BottomSheet
└── (renders children; scroll-locks body)
Card
└── (renders children; conditionally interactive)
Button
└── (self-contained with inline SVG spinner)
Input
└── (self-contained; forwardRef)
StatusBadge
└── (self-contained)
Loader
└── (self-contained)
```
`EmptyState` is the only component with an internal component dependency (it imports `Button`). All other components are leaf nodes.
## CSS Variable Contract
The consuming application must define these CSS custom properties (typically on `:root` or a theme wrapper element). The plugin does not provide default values -- all colors, spacing, and radii are expected to be injected by the host.
Minimal example:
```css
:root {
/* Buttons */
--btn-primary-bg: #2563eb;
--btn-primary-text: #ffffff;
--btn-secondary-bg: #f1f5f9;
--btn-secondary-border: #cbd5e1;
--btn-secondary-text: #1e293b;
/* Surfaces */
--surface-bg: #ffffff;
--surface-elevation-1: #ffffff;
--surface-elevation-2: #e2e8f0;
/* Text */
--text-base: #0f172a;
--text-support: #64748b;
--text-placeholder: #94a3b8;
/* Feedback */
--feedback-success: #16a34a;
--feedback-error: #dc2626;
--feedback-warning: #d97706;
--feedback-info: #2563eb;
--feedback-success-subtle: #f0fdf4;
--feedback-error-subtle: #fef2f2;
--feedback-warning-subtle: #fffbeb;
--feedback-info-subtle: #eff6ff;
/* Inputs */
--input-fill: #f8fafc;
--input-border: #e2e8f0;
--input-border-active: #2563eb;
--input-text: #0f172a;
/* Separator */
--separator: #e2e8f0;
/* Status */
--status-accepted: #2563eb;
--status-reserved: #7c3aed;
--status-completed: #16a34a;
--status-cancelled: #64748b;
--status-no-show: #d97706;
--status-failed: #dc2626;
/* Spacing */
--space-xxxs: 2px;
--space-xxs: 4px;
--space-xs: 8px;
--space-s: 12px;
--space-m: 16px;
--space-l: 20px;
--space-xl: 24px;
--space-xxl: 32px;
--space-xxxl: 48px;
/* Radii */
--radius-s: 6px;
--radius-m: 10px;
--radius-l: 16px;
--radius-xl: 24px;
}
```
## Safe Area Handling
`BottomSheet` accounts for notched/home-bar devices via:
```css
padding-bottom: calc(var(--space-l) + env(safe-area-inset-bottom));
```
Consuming apps should include `<meta name="viewport" content="..., viewport-fit=cover">` for this to take effect.
## Scroll Locking
`BottomSheet` sets `document.body.style.overflow = 'hidden'` while open and restores the original value on close (via `useEffect` cleanup). This prevents background scroll while the modal is visible.
+2236
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@ampeco/ewa-ui",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./plugin": {
"import": {
"types": "./dist/plugin.d.ts",
"default": "./dist/plugin.js"
},
"require": {
"types": "./dist/plugin.d.cts",
"default": "./dist/plugin.cjs"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"dependencies": {
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tsup": "^8.0.0",
"typescript": "^5.3.0"
}
}
+51
View File
@@ -0,0 +1,51 @@
import React, { useEffect } from 'react';
export interface BottomSheetProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function BottomSheet({ open, onClose, children }: BottomSheetProps) {
useEffect(() => {
if (open) {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-40 animate-fade-in">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Sheet */}
<div
role="dialog"
aria-modal="true"
className={[
'fixed bottom-0 left-0 right-0',
'bg-surface-bg rounded-t-l',
'p-l pb-[calc(var(--space-l)+env(safe-area-inset-bottom))]',
'animate-slide-up',
].join(' ')}
>
{/* Handle bar */}
<div className="flex justify-center mb-m">
<div className="w-10 h-1 rounded-full bg-surface-elevation-2" />
</div>
{children}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'text';
fullWidth?: boolean;
loading?: boolean;
}
const variantClasses: Record<NonNullable<ButtonProps['variant']>, string> = {
primary: [
'bg-[var(--btn-primary-bg)] text-white',
'hover:opacity-90 active:opacity-80',
].join(' '),
secondary: [
'bg-transparent border border-[var(--btn-secondary-border)] text-[var(--btn-secondary-text)]',
'hover:bg-[var(--btn-secondary-bg)] active:opacity-80',
].join(' '),
danger: [
'bg-[var(--feedback-error)] text-white',
'hover:opacity-90 active:opacity-80',
].join(' '),
text: [
'bg-transparent text-[var(--btn-primary-bg)]',
'hover:underline active:opacity-80',
].join(' '),
};
export function Button({
variant = 'primary',
fullWidth = false,
loading = false,
disabled = false,
children,
className = '',
...props
}: ButtonProps) {
const isDisabled = disabled || loading;
return (
<button
{...props}
disabled={isDisabled}
className={[
'relative inline-flex items-center justify-center',
'h-11 px-l rounded-m',
'text-buttons transition-all duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--btn-primary-bg)] focus-visible:ring-offset-2',
variantClasses[variant],
fullWidth ? 'w-full' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
className,
].filter(Boolean).join(' ')}
>
{loading && (
<span className="absolute inset-0 flex items-center justify-center">
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
)}
<span className={loading ? 'invisible' : ''}>{children}</span>
</button>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
export interface CardProps {
className?: string;
onClick?: () => void;
children: React.ReactNode;
}
export function Card({ className = '', onClick, children }: CardProps) {
const isInteractive = typeof onClick === 'function';
const classes = [
'bg-surface-elevation-1 rounded-m p-l',
'shadow-[0_1px_3px_rgba(0,0,0,0.1)]',
isInteractive
? 'cursor-pointer hover:scale-[1.01] active:scale-[0.99] transition-transform duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--btn-primary-bg)] focus-visible:ring-offset-2'
: '',
className,
].filter(Boolean).join(' ');
if (isInteractive) {
return (
<div
role="button"
tabIndex={0}
className={classes}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{children}
</div>
);
}
return <div className={classes}>{children}</div>;
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { Button } from './Button';
export interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center text-center py-xl px-l">
{icon && (
<div className="text-text-support mb-m">{icon}</div>
)}
<h3 className="text-lg font-semibold text-[var(--text-base)]">{title}</h3>
{description && (
<p className="text-sm text-text-support mt-xs">{description}</p>
)}
{action && (
<div className="mt-l">
<Button variant="primary" onClick={action.onClick}>
{action.label}
</Button>
</div>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className'> {
label: string;
hint?: string;
error?: string;
className?: string;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, hint, error, className = '', id, ...props }, ref) => {
const inputId = id || `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
return (
<div className={`flex flex-col gap-xxs ${className}`}>
<label
htmlFor={inputId}
className="text-xs font-medium text-[var(--text-base)]"
>
{label}
</label>
<input
ref={ref}
id={inputId}
{...props}
className={[
'h-11 px-m rounded-s',
'bg-[var(--input-fill)] text-[var(--input-text)]',
'border transition-colors duration-150',
'placeholder:text-[var(--input-placeholder)]',
'focus:outline-none',
error
? 'border-[var(--feedback-error)] focus:border-[var(--feedback-error)]'
: 'border-[var(--input-border)] focus:border-[var(--btn-primary-bg)]',
].join(' ')}
/>
{error && (
<span className="text-xs text-[var(--feedback-error)]">{error}</span>
)}
{!error && hint && (
<span className="text-xs text-text-support">{hint}</span>
)}
</div>
);
}
);
Input.displayName = 'Input';
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
export interface LoaderProps {
fullScreen?: boolean;
}
export function Loader({ fullScreen = false }: LoaderProps) {
const spinner = (
<div
className="w-8 h-8 rounded-full border-4 border-surface-elevation-2 border-t-btn-primary-bg animate-spin"
role="status"
aria-label="Loading"
/>
);
if (fullScreen) {
return (
<div className="min-h-[60dvh] flex items-center justify-center">
{spinner}
</div>
);
}
return spinner;
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
export interface StatusBadgeProps {
status: string;
label: string;
}
const statusMap: Record<string, string> = {
accepted: 'accepted',
reserved: 'reserved',
completed: 'completed',
cancelled: 'cancelled',
no_show: 'no-show',
'no-show': 'no-show',
failed: 'failed',
};
export function StatusBadge({ status, label }: StatusBadgeProps) {
const mappedStatus = statusMap[status] || status;
return (
<span
className={[
'inline-flex items-center px-xs py-xxxs',
'rounded-full text-caption',
`bg-status-${mappedStatus}-subtle`,
`text-[var(--status-${mappedStatus})]`,
].join(' ')}
>
{label}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect } from 'react';
export interface ToastProps {
message: string;
type: 'success' | 'error';
onDismiss: () => void;
duration?: number;
}
const typeClasses: Record<ToastProps['type'], string> = {
success: 'bg-[var(--feedback-success-subtle)] text-[var(--feedback-success)]',
error: 'bg-[var(--feedback-error-subtle)] text-[var(--feedback-error)]',
};
export function Toast({ message, type, onDismiss, duration = 3000 }: ToastProps) {
useEffect(() => {
const timer = setTimeout(onDismiss, duration);
return () => clearTimeout(timer);
}, [onDismiss, duration]);
return (
<div
role="alert"
className={[
'fixed top-4 left-4 right-4 z-50',
'flex items-center justify-between',
'px-l py-m rounded-m',
'animate-slide-in',
typeClasses[type],
].join(' ')}
>
<span className="text-body-medium-heavy">{message}</span>
<button
onClick={onDismiss}
className="flex-shrink-0 w-11 h-11 flex items-center justify-center -mr-s"
aria-label="Dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
export { Button } from './components/Button';
export type { ButtonProps } from './components/Button';
export { Card } from './components/Card';
export type { CardProps } from './components/Card';
export { Input } from './components/Input';
export type { InputProps } from './components/Input';
export { StatusBadge } from './components/StatusBadge';
export type { StatusBadgeProps } from './components/StatusBadge';
export { Loader } from './components/Loader';
export type { LoaderProps } from './components/Loader';
export { EmptyState } from './components/EmptyState';
export type { EmptyStateProps } from './components/EmptyState';
export { Toast } from './components/Toast';
export type { ToastProps } from './components/Toast';
export { BottomSheet } from './components/BottomSheet';
export type { BottomSheetProps } from './components/BottomSheet';
+115
View File
@@ -0,0 +1,115 @@
import plugin from 'tailwindcss/plugin';
export const ewaUiPlugin = plugin(
({ addBase, addUtilities }) => {
addBase({
'*, *::before, *::after': {
'box-sizing': 'border-box',
},
'body': {
'font-family': "'Inter', system-ui, -apple-system, sans-serif",
'-webkit-font-smoothing': 'antialiased',
'-moz-osx-font-smoothing': 'grayscale',
},
});
// Status subtle background utilities
const statusColors = ['accepted', 'reserved', 'completed', 'cancelled', 'no-show', 'failed'];
const statusUtilities: Record<string, Record<string, string>> = {};
for (const status of statusColors) {
statusUtilities[`.bg-status-${status}-subtle`] = {
'background-color': `color-mix(in srgb, var(--status-${status}) 15%, transparent)`,
};
}
addUtilities(statusUtilities);
},
{
theme: {
extend: {
colors: {
'btn-primary-bg': 'var(--btn-primary-bg)',
'btn-primary-text': 'var(--btn-primary-text)',
'btn-secondary-bg': 'var(--btn-secondary-bg)',
'btn-secondary-border': 'var(--btn-secondary-border)',
'btn-secondary-text': 'var(--btn-secondary-text)',
'surface-bg': 'var(--surface-bg)',
'surface-elevation-1': 'var(--surface-elevation-1)',
'surface-elevation-2': 'var(--surface-elevation-2)',
'text-base-color': 'var(--text-base)',
'text-support': 'var(--text-support)',
'text-placeholder': 'var(--text-placeholder)',
'feedback-success': 'var(--feedback-success)',
'feedback-error': 'var(--feedback-error)',
'feedback-warning': 'var(--feedback-warning)',
'feedback-info': 'var(--feedback-info)',
'feedback-success-subtle': 'var(--feedback-success-subtle)',
'feedback-error-subtle': 'var(--feedback-error-subtle)',
'feedback-warning-subtle': 'var(--feedback-warning-subtle)',
'feedback-info-subtle': 'var(--feedback-info-subtle)',
'input-fill': 'var(--input-fill)',
'input-border': 'var(--input-border)',
'input-border-active': 'var(--input-border-active)',
'input-text': 'var(--input-text)',
'separator': 'var(--separator)',
'status-accepted': 'var(--status-accepted)',
'status-reserved': 'var(--status-reserved)',
'status-completed': 'var(--status-completed)',
'status-cancelled': 'var(--status-cancelled)',
'status-no-show': 'var(--status-no-show)',
'status-failed': 'var(--status-failed)',
},
spacing: {
'xxxs': 'var(--space-xxxs)',
'xxs': 'var(--space-xxs)',
'xs': 'var(--space-xs)',
's': 'var(--space-s)',
'm': 'var(--space-m)',
'l': 'var(--space-l)',
'xl': 'var(--space-xl)',
'xxl': 'var(--space-xxl)',
'xxxl': 'var(--space-xxxl)',
},
borderRadius: {
's': 'var(--radius-s)',
'm': 'var(--radius-m)',
'l': 'var(--radius-l)',
'xl': 'var(--radius-xl)',
},
fontSize: {
'h1': ['22px', { lineHeight: '26px', fontWeight: '600' }],
'h2': ['18px', { lineHeight: '22px', fontWeight: '600' }],
'h3': ['16px', { lineHeight: '20px', fontWeight: '500' }],
'h4': ['14px', { lineHeight: '17px', fontWeight: '500' }],
'body-large': ['16px', { lineHeight: '20px', fontWeight: '400' }],
'body-medium': ['14px', { lineHeight: '17px', fontWeight: '400' }],
'body-large-heavy': ['16px', { lineHeight: '20px', fontWeight: '600' }],
'body-medium-heavy': ['14px', { lineHeight: '17px', fontWeight: '600' }],
'buttons': ['14px', { lineHeight: '20px', fontWeight: '600' }],
'buttons-small': ['12px', { lineHeight: '17px', fontWeight: '700' }],
'label': ['12px', { lineHeight: '15px', fontWeight: '400' }],
'caption': ['12px', { lineHeight: '15px', fontWeight: '500' }],
},
animation: {
'spin': 'spin 1s linear infinite',
'slide-in': 'slideIn 0.2s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'fade-in': 'fadeIn 0.2s ease-out',
},
keyframes: {
slideIn: {
from: { transform: 'translateY(-100%)', opacity: '0' },
to: { transform: 'translateY(0)', opacity: '1' },
},
slideUp: {
from: { transform: 'translateY(100%)' },
to: { transform: 'translateY(0)' },
},
fadeIn: {
from: { opacity: '0' },
to: { opacity: '1' },
},
},
},
},
}
);
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/plugin.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
treeshake: true,
});