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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
Generated
+2236
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user