first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
*.md
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.env
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 160,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"plugins": [
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"tailwindFunctions": ["sortCx", "cx"],
|
||||||
|
"importOrder": [
|
||||||
|
"^react$",
|
||||||
|
"^react-dom$",
|
||||||
|
"^(?!react$|react-dom$|@/|\\.).*",
|
||||||
|
"^@/.*",
|
||||||
|
"^\\.{1,2}/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": false,
|
||||||
|
"importOrderSortSpecifiers": true,
|
||||||
|
"tailwindStylesheet": "./src/styles/globals.css"
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is an **Untitled UI React** component library project built with:
|
||||||
|
|
||||||
|
- **React 19** with TypeScript
|
||||||
|
- **Tailwind CSS v4.2** for styling
|
||||||
|
- **React Aria Components** as the foundation for accessibility and behavior
|
||||||
|
|
||||||
|
## Key Architecture Principles
|
||||||
|
|
||||||
|
### Component Foundation
|
||||||
|
|
||||||
|
- All components are built on **React Aria Components** for consistent accessibility and behavior
|
||||||
|
- Components follow the compound component pattern with sub-components (e.g., `Select.Item`, `Select.ComboBox`)
|
||||||
|
- TypeScript is used throughout for type safety
|
||||||
|
|
||||||
|
### Import Naming Convention
|
||||||
|
|
||||||
|
**CRITICAL**: All imports from `react-aria-components` must be prefixed with `Aria*` for clarity and consistency:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import { Button as AriaButton, TextField as AriaTextField } from "react-aria-components";
|
||||||
|
// ❌ Incorrect
|
||||||
|
import { Button, TextField } from "react-aria-components";
|
||||||
|
```
|
||||||
|
|
||||||
|
This convention:
|
||||||
|
|
||||||
|
- Prevents naming conflicts with custom components
|
||||||
|
- Makes it clear when using base React Aria components
|
||||||
|
- Maintains consistency across the entire codebase
|
||||||
|
|
||||||
|
### File Naming Convention
|
||||||
|
|
||||||
|
**IMPORTANT**: All files must be named in **kebab-case** for consistency:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Correct:
|
||||||
|
- date-picker.tsx
|
||||||
|
- user-profile.tsx
|
||||||
|
- api-client.ts
|
||||||
|
- auth-context.tsx
|
||||||
|
|
||||||
|
❌ Incorrect:
|
||||||
|
- DatePicker.tsx
|
||||||
|
- userProfile.tsx
|
||||||
|
- apiClient.ts
|
||||||
|
- AuthContext.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all file types including:
|
||||||
|
|
||||||
|
- Component files (.tsx, .jsx)
|
||||||
|
- TypeScript/JavaScript files (.ts, .js)
|
||||||
|
- Style files (.css, .scss)
|
||||||
|
- Test files (.test.ts, .spec.tsx)
|
||||||
|
- Configuration files (when creating new ones)
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start Vite development server (http://localhost:5173)
|
||||||
|
npm run build # Build for production (TypeScript compilation + Vite build)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Application Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── base/ # Core UI components (Button, Input, Select, etc.)
|
||||||
|
│ ├── application/ # Complex application components
|
||||||
|
│ ├── foundations/ # Design tokens and foundational elements
|
||||||
|
│ ├── marketing/ # Marketing-specific components
|
||||||
|
│ └── shared-assets/ # Reusable assets and illustrations
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── pages/ # Route components
|
||||||
|
├── providers/ # React context providers
|
||||||
|
├── styles/ # Global styles and theme
|
||||||
|
├── types/ # TypeScript type definitions
|
||||||
|
└── utils/ # Utility functions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
#### 1. Base Components
|
||||||
|
|
||||||
|
Located in `components/base/`, these are the building blocks:
|
||||||
|
|
||||||
|
- `Button` - All button variants with loading states
|
||||||
|
- `Input` - Text inputs with validation and icons
|
||||||
|
- `Select` - Dropdown selections with complex options
|
||||||
|
- `Checkbox`, `Radio`, `Toggle` - Form controls
|
||||||
|
- `Avatar`, `Badge`, `Tooltip` - Display components
|
||||||
|
|
||||||
|
#### 2. Application Components
|
||||||
|
|
||||||
|
Located in `components/application/`, these are complex UI patterns:
|
||||||
|
|
||||||
|
- `DatePicker` - Calendar-based date selection
|
||||||
|
- `Modal` - Overlay dialogs
|
||||||
|
- `Pagination` - Data navigation
|
||||||
|
- `Table` - Data display with sorting
|
||||||
|
- `Tabs` - Content organization
|
||||||
|
|
||||||
|
#### 3. Styling Architecture
|
||||||
|
|
||||||
|
- Uses a `sortCx` utility for organized style objects
|
||||||
|
- Follows size variants: `sm`, `md`, `lg`, `xl`
|
||||||
|
- Color variants: `primary`, `secondary`, `tertiary`, `destructive`, etc.
|
||||||
|
- Responsive and state-aware styling with Tailwind
|
||||||
|
|
||||||
|
#### 4. Component Props Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CommonProps {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
isDisabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
// ... other common props
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonProps extends CommonProps, HTMLButtonElement {
|
||||||
|
color?: "primary" | "secondary" | "tertiary";
|
||||||
|
iconLeading?: FC | ReactNode;
|
||||||
|
iconTrailing?: FC | ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling Guidelines
|
||||||
|
|
||||||
|
### Tailwind CSS v4.2
|
||||||
|
|
||||||
|
- Uses the latest Tailwind CSS v4.2 features
|
||||||
|
- Custom design tokens defined in theme configuration
|
||||||
|
- Consistent spacing, colors, and typography scales
|
||||||
|
|
||||||
|
### Brand Color Customization
|
||||||
|
|
||||||
|
To change the main brand color across the entire application:
|
||||||
|
|
||||||
|
1. **Update Brand Color Variables**: Edit `src/styles/theme.css` and modify the `--color-brand-*` variables
|
||||||
|
2. **Maintain Color Scale**: Ensure you provide a complete color scale from 25 to 950 with proper contrast ratios
|
||||||
|
3. **Example Brand Color Scale**:
|
||||||
|
```css
|
||||||
|
--color-brand-25: rgb(252 250 255); /* Lightest tint */
|
||||||
|
--color-brand-50: rgb(249 245 255);
|
||||||
|
--color-brand-100: rgb(244 235 255);
|
||||||
|
--color-brand-200: rgb(233 215 254);
|
||||||
|
--color-brand-300: rgb(214 187 251);
|
||||||
|
--color-brand-400: rgb(182 146 246);
|
||||||
|
--color-brand-500: rgb(158 119 237); /* Base brand color */
|
||||||
|
--color-brand-600: rgb(127 86 217); /* Primary interactive color */
|
||||||
|
--color-brand-700: rgb(105 65 198);
|
||||||
|
--color-brand-800: rgb(83 56 158);
|
||||||
|
--color-brand-900: rgb(66 48 125);
|
||||||
|
--color-brand-950: rgb(44 28 95); /* Darkest shade */
|
||||||
|
```
|
||||||
|
|
||||||
|
The color scale automatically adapts to both light and dark modes through the CSS variable system.
|
||||||
|
|
||||||
|
### Style Organization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const styles = sortCx({
|
||||||
|
common: {
|
||||||
|
root: "base-classes-here",
|
||||||
|
icon: "icon-classes-here",
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
sm: { root: "small-size-classes" },
|
||||||
|
md: { root: "medium-size-classes" },
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: { root: "primary-color-classes" },
|
||||||
|
secondary: { root: "secondary-color-classes" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utility Functions
|
||||||
|
|
||||||
|
- `cx()` - Class name utility (from `@/utils/cx`)
|
||||||
|
- `sortCx()` - Organized style objects
|
||||||
|
- `isReactComponent()` - Component type checking
|
||||||
|
|
||||||
|
## Icon Usage
|
||||||
|
|
||||||
|
### Available Libraries
|
||||||
|
|
||||||
|
- `@untitledui/icons` - 1,100+ line-style icons (free)
|
||||||
|
- `@untitledui/file-icons` - File type icons
|
||||||
|
- `@untitledui-pro/icons` - 4,600+ icons in 4 styles (Requires PRO access)
|
||||||
|
|
||||||
|
### Import & Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Recommended: Named imports (tree-shakeable)
|
||||||
|
import { Home01, Settings01, ChevronDown } from "@untitledui/icons";
|
||||||
|
|
||||||
|
// Component props - pass as reference
|
||||||
|
<Button iconLeading={ChevronDown}>Options</Button>
|
||||||
|
|
||||||
|
// Standalone usage
|
||||||
|
<Home01 className="size-5 text-gray-600" />
|
||||||
|
|
||||||
|
// As JSX element - MUST include data-icon
|
||||||
|
<Button iconLeading={<ChevronDown data-icon className="size-4" />}>Options</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Size: use size-4 (16px), size-5 (20px), size-6 (24px)
|
||||||
|
<Home01 className="size-5" />
|
||||||
|
|
||||||
|
// Color: use semantic text colors
|
||||||
|
<Home01 className="size-5 text-brand-600" />
|
||||||
|
|
||||||
|
// Stroke width (line icons only)
|
||||||
|
<Home01 className="size-5" strokeWidth={2} />
|
||||||
|
|
||||||
|
// Accessibility: decorative icons need aria-hidden
|
||||||
|
<Home01 className="size-5" aria-hidden="true" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### PRO Icon Styles
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Home01 } from "@untitledui-pro/icons";
|
||||||
|
// Line
|
||||||
|
import { Home01 } from "@untitledui-pro/icons/duocolor";
|
||||||
|
import { Home01 } from "@untitledui-pro/icons/duotone";
|
||||||
|
import { Home01 } from "@untitledui-pro/icons/solid";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Handling
|
||||||
|
|
||||||
|
### Form Components
|
||||||
|
|
||||||
|
- `Input` - Text inputs with validation
|
||||||
|
- `Select` - Dropdown selections
|
||||||
|
- `Checkbox`, `Radio` - Selection controls
|
||||||
|
- `Textarea` - Multi-line text input
|
||||||
|
- `Form` - Form wrapper with validation
|
||||||
|
|
||||||
|
## Animation and Interactions
|
||||||
|
|
||||||
|
### Animation Libraries
|
||||||
|
|
||||||
|
- `motion` (Framer Motion) for complex animations
|
||||||
|
- `tailwindcss-animate` for utility-based animations
|
||||||
|
- CSS transitions for simple state changes
|
||||||
|
|
||||||
|
### CSS Transitions
|
||||||
|
|
||||||
|
For default small transition actions (hover states, color changes, etc.), use:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
className = "transition duration-100 ease-linear";
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a snappy 100ms linear transition that feels responsive without being jarring.
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
|
||||||
|
- Components support `isLoading` prop
|
||||||
|
- Built-in loading spinners
|
||||||
|
- Proper disabled states during loading
|
||||||
|
|
||||||
|
### Disabled states
|
||||||
|
|
||||||
|
All components use `opacity-50` for disabled states instead of individual disabled color tokens:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Correct (v8)
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
|
||||||
|
// Incorrect (v7 pattern, do not use)
|
||||||
|
"disabled:bg-disabled_subtle disabled:text-disabled disabled:ring-disabled"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Compound Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const Select = SelectComponent as typeof SelectComponent & {
|
||||||
|
Item: typeof SelectItem;
|
||||||
|
ComboBox: typeof ComboBox;
|
||||||
|
};
|
||||||
|
Select.Item = SelectItem;
|
||||||
|
Select.ComboBox = ComboBox;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Rendering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{label && <Label isRequired={isRequired}>{label}</Label>}
|
||||||
|
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Component State
|
||||||
|
|
||||||
|
- Use React Aria's built-in state management
|
||||||
|
- Local state for component-specific data
|
||||||
|
- Context for shared component state (theme, router)
|
||||||
|
|
||||||
|
### Global State
|
||||||
|
|
||||||
|
- Theme context in `src/providers/theme.tsx`
|
||||||
|
- Router context in `src/providers/router-provider.tsx`
|
||||||
|
|
||||||
|
## Key Files and Utilities
|
||||||
|
|
||||||
|
### Core Utilities
|
||||||
|
|
||||||
|
- `src/utils/cx.ts` - Class name utilities
|
||||||
|
- `src/utils/is-react-component.ts` - Component type checking
|
||||||
|
- `src/hooks/` - Custom React hooks
|
||||||
|
|
||||||
|
### Style Configuration
|
||||||
|
|
||||||
|
- `src/styles/globals.css` - Global styles
|
||||||
|
- `src/styles/theme.css` - Theme definitions
|
||||||
|
- `src/styles/typography.css` - Typography styles
|
||||||
|
|
||||||
|
## Best Practices for AI Assistance
|
||||||
|
|
||||||
|
### When Adding New Components
|
||||||
|
|
||||||
|
1. Follow the existing component structure
|
||||||
|
2. Use React Aria Components as foundation
|
||||||
|
3. Implement proper TypeScript types
|
||||||
|
4. Add size and color variants where applicable
|
||||||
|
5. Include accessibility features
|
||||||
|
6. Follow the naming conventions
|
||||||
|
7. Add components to appropriate folders (`base/`, `application/`, etc.)
|
||||||
|
|
||||||
|
## Most Used Components Reference
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
The Button component is the most frequently used interactive element across the library.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `size`: `"xs" | "sm" | "md" | "lg" | "xl"` - Button size (default: `"sm"`)
|
||||||
|
- `color`: `"primary" | "secondary" | "tertiary" | "link-gray" | "link-color" | "primary-destructive" | "secondary-destructive" | "tertiary-destructive" | "link-destructive"` - Button color variant (default: `"primary"`)
|
||||||
|
- `iconLeading`: `FC | ReactNode` - Icon or component to display before text
|
||||||
|
- `iconTrailing`: `FC | ReactNode` - Icon or component to display after text
|
||||||
|
- `isDisabled`: `boolean` - Disabled state
|
||||||
|
- `isLoading`: `boolean` - Loading state with spinner
|
||||||
|
- `showTextWhileLoading`: `boolean` - Keep text visible during loading
|
||||||
|
- `children`: `ReactNode` - Button content
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic button
|
||||||
|
<Button size="md">Save</Button>
|
||||||
|
|
||||||
|
// With leading icon
|
||||||
|
<Button iconLeading={Check} color="primary">Save</Button>
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
<Button isLoading showTextWhileLoading>Submitting...</Button>
|
||||||
|
|
||||||
|
// Destructive action
|
||||||
|
<Button color="primary-destructive" iconLeading={Trash02}>Delete</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
Text input component with extensive customization options.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Input } from "@/components/base/input/input";
|
||||||
|
import { InputGroup } from "@/components/base/input/input-group";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `size`: `"sm" | "md" | "lg"` - Input size (default: `"md"`)
|
||||||
|
- `label`: `string` - Field label
|
||||||
|
- `placeholder`: `string` - Placeholder text
|
||||||
|
- `hint`: `string` - Helper text below input
|
||||||
|
- `tooltip`: `string` - Tooltip text for help icon
|
||||||
|
- `icon`: `FC` - Leading icon component
|
||||||
|
- `isRequired`: `boolean` - Required field indicator
|
||||||
|
- `isDisabled`: `boolean` - Disabled state
|
||||||
|
- `isInvalid`: `boolean` - Error state
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic input with label
|
||||||
|
<Input label="Email" placeholder="olivia@untitledui.com" />
|
||||||
|
|
||||||
|
// With icon and validation
|
||||||
|
<Input
|
||||||
|
icon={Mail01}
|
||||||
|
label="Email"
|
||||||
|
isRequired
|
||||||
|
isInvalid
|
||||||
|
hint="Please enter a valid email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Input group with button
|
||||||
|
<InputGroup label="Website" trailingAddon={<Button>Copy</Button>}>
|
||||||
|
<InputBase placeholder="www.untitledui.com" />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Select
|
||||||
|
|
||||||
|
Dropdown selection component with search and multi-select capabilities.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MultiSelect } from "@/components/base/select/multi-select";
|
||||||
|
import { Select } from "@/components/base/select/select";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `size`: `"sm" | "md" | "lg"` - Select size (default: `"md"`)
|
||||||
|
- `label`: `string` - Field label
|
||||||
|
- `placeholder`: `string` - Placeholder text
|
||||||
|
- `hint`: `string` - Helper text
|
||||||
|
- `tooltip`: `string` - Tooltip text
|
||||||
|
- `items`: `Array` - Data items to display
|
||||||
|
- `isRequired`: `boolean` - Required field
|
||||||
|
- `isDisabled`: `boolean` - Disabled state
|
||||||
|
- `icon`: `FC | ReactNode` - Icon for placeholder
|
||||||
|
|
||||||
|
**Item Props:**
|
||||||
|
|
||||||
|
- `id`: `string` - Unique identifier
|
||||||
|
- `supportingText`: `string` - Secondary text
|
||||||
|
- `icon`: `FC | ReactNode` - Leading icon
|
||||||
|
- `avatarUrl`: `string` - Avatar image URL
|
||||||
|
- `isDisabled`: `boolean` - Disabled item
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic select
|
||||||
|
<Select label="Team member" placeholder="Select member" items={users}>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item id={item.id} supportingText={item.email}>
|
||||||
|
{item.name}
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
// With search (ComboBox)
|
||||||
|
<Select.ComboBox label="Search" placeholder="Search users" items={users}>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.name}</Select.Item>}
|
||||||
|
</Select.ComboBox>
|
||||||
|
|
||||||
|
// With avatars
|
||||||
|
<Select items={users} icon={User01}>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item avatarUrl={item.avatar} supportingText={item.role}>
|
||||||
|
{item.name}
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkbox
|
||||||
|
|
||||||
|
Checkbox component for boolean selections.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `size`: `"sm" | "md"` - Checkbox size (default: `"sm"`)
|
||||||
|
- `label`: `string` - Checkbox label
|
||||||
|
- `hint`: `string` - Helper text below label
|
||||||
|
- `isSelected`: `boolean` - Checked state
|
||||||
|
- `isDisabled`: `boolean` - Disabled state
|
||||||
|
- `isIndeterminate`: `boolean` - Indeterminate state
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic checkbox
|
||||||
|
<Checkbox label="Remember me" />
|
||||||
|
|
||||||
|
// With hint text
|
||||||
|
<Checkbox
|
||||||
|
label="Remember me"
|
||||||
|
hint="Save my login details for next time"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Controlled state
|
||||||
|
<Checkbox isSelected={checked} onChange={setChecked} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
|
||||||
|
Badge components for status indicators and labels.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Badge, BadgeWithDot, BadgeWithIcon } from "@/components/base/badges/badges";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `size`: `"sm" | "md" | "lg"` - Badge size
|
||||||
|
- `color`: `"gray" | "brand" | "error" | "warning" | "success" | "slate" | "sky" | "blue" | "indigo" | "purple" | "pink" | "rose" | "orange"` - Color theme
|
||||||
|
- `type`: `"pill-color" | "color" | "modern"` - Badge style variant
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic badge
|
||||||
|
<Badge color="brand" size="md">New</Badge>
|
||||||
|
|
||||||
|
// With dot indicator
|
||||||
|
<BadgeWithDot color="success" type="pill-color">Active</BadgeWithDot>
|
||||||
|
|
||||||
|
// With icon
|
||||||
|
<BadgeWithIcon iconLeading={ArrowUp} color="success">12%</BadgeWithIcon>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avatar
|
||||||
|
|
||||||
|
Avatar component for user profile images.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `size`: `"xs" | "sm" | "md" | "lg" | "xl" | "2xl"` - Avatar size (note: `"xxs"` was removed in v8)
|
||||||
|
- `src`: `string` - Image URL
|
||||||
|
- `alt`: `string` - Alt text for accessibility
|
||||||
|
- `initials`: `string` - Text initials when no image
|
||||||
|
- `icon`: `FC` - Icon when no image
|
||||||
|
- `status`: `"online" | "offline"` - Status indicator
|
||||||
|
- `verified`: `boolean` - Verification badge
|
||||||
|
- `badge`: `ReactNode` - Custom badge element
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic avatar
|
||||||
|
<Avatar src="/avatar.jpg" alt="User Name" size="md" />
|
||||||
|
|
||||||
|
// With status
|
||||||
|
<Avatar src="/avatar.jpg" status="online" />
|
||||||
|
|
||||||
|
// With initials fallback
|
||||||
|
<Avatar initials="OR" size="lg" />
|
||||||
|
|
||||||
|
// Label group
|
||||||
|
<AvatarLabelGroup
|
||||||
|
src="/avatar.jpg"
|
||||||
|
title="Olivia Rhye"
|
||||||
|
subtitle="olivia@untitledui.com"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### FeaturedIcon
|
||||||
|
|
||||||
|
Decorative icon component with themed backgrounds for emphasis and visual hierarchy.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Props:**
|
||||||
|
|
||||||
|
- `icon`: `FC` - Icon component to display (required)
|
||||||
|
- `size`: `"sm" | "md" | "lg" | "xl"` - Icon container size
|
||||||
|
- `color`: `"brand" | "gray" | "error" | "warning" | "success"` - Color scheme
|
||||||
|
- `theme`: `"light" | "gradient" | "dark" | "modern" | "modern-neue" | "outline"` - Visual theme style
|
||||||
|
|
||||||
|
**Theme Styles:**
|
||||||
|
|
||||||
|
- `light`: Subtle background with colored icon
|
||||||
|
- `gradient`: Gradient background effect
|
||||||
|
- `dark`: Solid colored background with white icon
|
||||||
|
- `modern`: Contemporary gray styling (gray color only)
|
||||||
|
- `modern-neue`: Alternative modern style (gray color only)
|
||||||
|
- `outline`: Border style with transparent background
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic featured icon
|
||||||
|
<FeaturedIcon icon={CheckCircle} color="success" theme="light" size="lg" />
|
||||||
|
|
||||||
|
// With gradient theme
|
||||||
|
<FeaturedIcon icon={AlertCircle} color="warning" theme="gradient" size="xl" />
|
||||||
|
|
||||||
|
// Dark theme for emphasis
|
||||||
|
<FeaturedIcon icon={XCircle} color="error" theme="dark" size="md" />
|
||||||
|
|
||||||
|
// Outline style
|
||||||
|
<FeaturedIcon icon={InfoCircle} color="brand" theme="outline" size="lg" />
|
||||||
|
|
||||||
|
// Modern styles (IMPORTANT: gray only)
|
||||||
|
<FeaturedIcon icon={Settings} color="gray" theme="modern" size="lg" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Link
|
||||||
|
|
||||||
|
**Note**: There is no dedicated Link component. Instead, use the Button component with an `href` prop and link-specific color variants.
|
||||||
|
|
||||||
|
**Import:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Link Colors:**
|
||||||
|
|
||||||
|
- `link-gray` - Gray link styling
|
||||||
|
- `link-color` - Brand color link styling
|
||||||
|
- `link-destructive` - Destructive link styling
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic link
|
||||||
|
<Button href="/dashboard" color="link-color">View Dashboard</Button>
|
||||||
|
|
||||||
|
// With icon
|
||||||
|
<Button href="/settings" color="link-gray" iconLeading={Settings01}>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Destructive link
|
||||||
|
<Button href="/delete" color="link-destructive" iconLeading={Trash02}>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// External link
|
||||||
|
<Button href="https://example.com" color="link-color" iconTrailing={ExternalLink01}>
|
||||||
|
Visit Site
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Component Patterns
|
||||||
|
|
||||||
|
1. **Size Variants**: Most components support `sm`, `md`, `lg` sizes
|
||||||
|
2. **State Props**: `isDisabled`, `isLoading`, `isInvalid`, `isRequired` are common
|
||||||
|
3. **Icon Support**: Components accept icons as both components (`Icon`) or elements (`<Icon />`)
|
||||||
|
4. **Compound Components**: Complex components use dot notation (e.g., `Select.Item`, `Select.ComboBox`)
|
||||||
|
5. **Accessibility**: All components include proper ARIA attributes and keyboard support
|
||||||
|
|
||||||
|
### Icon Usage
|
||||||
|
|
||||||
|
When passing icons to components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// As component reference (preferred)
|
||||||
|
<Button iconLeading={ChevronDown}>Options</Button>
|
||||||
|
|
||||||
|
// As element (must include data-icon)
|
||||||
|
<Button iconLeading={<ChevronDown data-icon className="size-4" />}>Options</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## COLORS
|
||||||
|
|
||||||
|
MUST use color classes to style elements.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
- text-gray-900
|
||||||
|
- text-gray-600
|
||||||
|
- bg-blue-700
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
- text-primary
|
||||||
|
- text-secondary
|
||||||
|
- bg-primary
|
||||||
|
|
||||||
|
### Text Color
|
||||||
|
|
||||||
|
Use text color variables to manage all text fill colors in your designs across light and dark modes.
|
||||||
|
|
||||||
|
| Name | Usage |
|
||||||
|
| :------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| text-primary | Primary text such as page headings. |
|
||||||
|
| text-primary_on-brand | Primary text when used on solid brand color backgrounds. Commonly used for brand theme website sections (e.g. CTA sections). |
|
||||||
|
| text-secondary | Secondary text such as labels and section headings. |
|
||||||
|
| text-secondary_hover | Secondary text when in hover state. |
|
||||||
|
| text-secondary_on-brand | Secondary text when used on solid brand color backgrounds. Commonly used for brand theme website sections (e.g. CTA sections). |
|
||||||
|
| text-tertiary | Tertiary text such as supporting text and paragraph text. |
|
||||||
|
| text-tertiary_hover | Tertiary text when in hover state. |
|
||||||
|
| text-tertiary_on-brand | Tertiary text when used on solid brand color backgrounds. Commonly used for brand theme website sections (e.g. CTA sections). |
|
||||||
|
| text-quaternary | Quaternary text for more subtle and lower-contrast text, such as footer column headings. |
|
||||||
|
| text-quaternary_on-brand | Quaternary text when used on solid brand color backgrounds. Commonly used for brand theme website sections (e.g. footers). |
|
||||||
|
| text-white | Text that is always white, regardless of the mode. |
|
||||||
|
| text-placeholder | Default color for placeholder text such as input field placeholders. This can be changed to gray-400, but gray-500 is more accessible because it is higher contrast. |
|
||||||
|
| text-brand-primary | Primary brand text useful for headings (e.g. cards in pricing page headers). |
|
||||||
|
| text-brand-secondary | Secondary brand text for brand buttons, as well as accented text, highlights, and subheadings (e.g. subheadings in blog post cards). |
|
||||||
|
| text-brand-secondary_hover | Secondary brand text when in hover state (e.g. brand buttons). |
|
||||||
|
| text-brand-tertiary | Tertiary brand text for lighter accented text and highlights (e.g. numbers in metric cards). |
|
||||||
|
| text-brand-tertiary_alt | An alternative to tertiary brand text that is lighter in dark mode (e.g. numbers in metric cards). |
|
||||||
|
| text-error-primary | Default error state semantic text color (e.g. input field error states). |
|
||||||
|
| text-warning-primary | Default warning state semantic text color. |
|
||||||
|
| text-success-primary | Default success state semantic text color. |
|
||||||
|
|
||||||
|
### Border Color
|
||||||
|
|
||||||
|
Use border color variables to manage all stroke colors in your designs across light and dark modes. You can use the same values for `ring-` and `outline-` as well (i.e. `ring-primary` `outline-secondary`).
|
||||||
|
|
||||||
|
| Name | Usage |
|
||||||
|
| :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| border-primary | High contrast borders. These are used for components such as input fields, button groups, and checkboxes. |
|
||||||
|
| border-secondary | Medium contrast borders. This is the most commonly used border color and is the default for most components (e.g. file uploaders), cards (such as tables), and content dividers. |
|
||||||
|
| border-secondary_alt | An alternative to secondary border that uses alpha transparency. This is used exclusively for floating menus such as input dropdowns and notifications to create sharper bottom border. |
|
||||||
|
| border-tertiary | Low contrast borders useful for very subtle dividers and borders such as line and bar chart axis dividers. |
|
||||||
|
| border-brand | Default brand border color. Useful for active states in components such as input fields. |
|
||||||
|
| border-brand_alt | An brand border color that switches to gray when in dark mode. Useful for components such as brand-style variants of banners and footers. |
|
||||||
|
| border-error | Default error state semantic border color. Useful for error states in components such as input fields and file uploaders. |
|
||||||
|
| border-error_subtle | A more subtle (lower contrast) alternative for error state semantic borders such as error state input fields. |
|
||||||
|
|
||||||
|
### Foreground Color
|
||||||
|
|
||||||
|
Use foreground color variables to manage all non-text foreground elements in your designs across light and dark modes. Can be used via `text-`, `bg-`, `ring-`, `outline-`, `stroke-`, `fill-`, etc.
|
||||||
|
|
||||||
|
| Name | Usage |
|
||||||
|
| :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| fg-primary | Highest contrast non-text foreground elements such as icons. |
|
||||||
|
| fg-secondary | High contrast non-text foreground elements such as icons. |
|
||||||
|
| fg-secondary_hover | Secondary foreground elements when in hover state. |
|
||||||
|
| fg-tertiary | Medium contrast non-text foreground elements such as icons. |
|
||||||
|
| fg-tertiary_hover | Tertiary foreground elements when in hover state. |
|
||||||
|
| fg-quaternary | Low contrast non-text foreground elements such as icons in buttons, help icons and icons used in input fields. |
|
||||||
|
| fg-quaternary_hover | Quaternary foreground elements when in hover state, such as help icons. |
|
||||||
|
| fg-white | Foreground elements that are always white, regardless of the mode. |
|
||||||
|
| fg-brand-primary | Primary brand color non-text foreground elements such as featured icons and progress bars. |
|
||||||
|
| fg-brand-primary_alt | An alternative for primary brand color non-text foreground elements that switches to gray when in dark mode such as active horizontal tabs. |
|
||||||
|
| fg-brand-secondary | Secondary brand color non-text foreground elements such as accents and arrows in marketing site sections (e.g. hero header sections). |
|
||||||
|
| fg-brand-secondary_alt | An alternative for secondary brand color non-text foreground elements that switches to gray when in dark mode such as brand buttons. |
|
||||||
|
| fg-error-primary | Primary error state color for non-text foreground elements such as featured icons. |
|
||||||
|
| fg-error-secondary | Secondary error state color for non-text foreground elements such as icons in error state input fields and negative metrics item charts and icons. |
|
||||||
|
| fg-warning-primary | Primary warning state color for non-text foreground elements such as featured icons. |
|
||||||
|
| fg-warning-secondary | Secondary warning state color for non-text foreground elements. |
|
||||||
|
| fg-success-primary | Primary success state color for non-text foreground elements such as featured icons. |
|
||||||
|
| fg-success-secondary | Secondary success state color for non-text foreground elements such as button dots, avatar online indicator dots, and positive metrics item charts and icons. |
|
||||||
|
|
||||||
|
### Background Color
|
||||||
|
|
||||||
|
Use background color variables to manage all fill colors for elements in your designs across light and dark modes.
|
||||||
|
|
||||||
|
| Name | Usage |
|
||||||
|
| :---------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| bg-primary | The primary background color (white) used across all layouts and components. |
|
||||||
|
| bg-primary_alt | An alternative primary background color (white) that switches to bg-secondary when in dark mode. |
|
||||||
|
| bg-primary_hover | Primary background hover color. This acts as the default hover state background color for components with white backgrounds (e.g. input dropdown menu items). |
|
||||||
|
| bg-primary-solid | The primary dark background color used across layouts and components. This switches to bg-secondary when in dark mode and is useful for components such as tooltips and Text editor tooltips. |
|
||||||
|
| bg-secondary | The secondary background color used to create contrast against white backgrounds, such as website section backgrounds. |
|
||||||
|
| bg-secondary_alt | An alternative secondary background color that switches to bg-primary when in dark mode. Useful for components such as border-style horizontal tabs. |
|
||||||
|
| bg-secondary_hover | Secondary background hover color. Useful for hover states for components with gray-50 backgrounds such as active states (e.g. navigation items and date pickers). |
|
||||||
|
| bg-secondary_subtle | An alternative secondary background color that is slightly lighter and more subtle in light mode. This is useful for components such as banners. |
|
||||||
|
| bg-secondary-solid | The secondary dark background color used across layouts and components. This is useful for components such as featured icons. |
|
||||||
|
| bg-tertiary | The tertiary background color used to create contrast against light backgrounds such as toggles. |
|
||||||
|
| bg-quaternary | The quaternary background color used to create contrast against light backgrounds, such as sliders and progress bars. |
|
||||||
|
| bg-active | Default active background color for components such as selected menu items in input dropdowns. |
|
||||||
|
| bg-overlay | Default background color for background overlays. These are useful for overlay components such as modals. |
|
||||||
|
| bg-brand-primary | The primary brand background color. Useful for components such as check icons. |
|
||||||
|
| bg-brand-primary_alt | An alternative primary brand background color that switches to bg-secondary when in dark mode. Useful for components such as active horizontal tabs. |
|
||||||
|
| bg-brand-secondary | The secondary brand background color. Useful for components such as featured icons. |
|
||||||
|
| bg-brand-solid | Default solid (dark) brand background color. Useful for components such as toggles and messages. |
|
||||||
|
| bg-brand-solid_hover | Solid brand background color when in hover state. Useful for components such as toggles. |
|
||||||
|
| bg-brand-section | This is the default dark brand color background used for website sections such as CTA sections and testimonials. Switches to bg-secondary when in dark mode. |
|
||||||
|
| bg-brand-section_subtle | An alternative brand section background color to provide contrast for website sections such as FAQ sections. Switches to bg-primary when in dark mode. |
|
||||||
|
| bg-error-primary | Primary error state background color for components such as buttons. |
|
||||||
|
| bg-error-secondary | Secondary error state background color for components such as featured icons. |
|
||||||
|
| bg-error-solid | Default solid (dark) error state background color for components such as buttons, featured icons and metric items. |
|
||||||
|
| bg-error-solid_hover | Default solid (dark) error hover state background color for components such as buttons. |
|
||||||
|
| bg-warning-primary | Primary warning state background color for components. |
|
||||||
|
| bg-warning-secondary | Secondary warning state background color for components such as featured icons. |
|
||||||
|
| bg-warning-solid | Default solid (dark) warning state background color for components such as featured icons. |
|
||||||
|
| bg-success-primary | Primary success state background color for components. |
|
||||||
|
| bg-success-secondary | Secondary success state background color for components such as featured icons. |
|
||||||
|
| bg-success-solid | Default solid (dark) success state background color for components such as featured icons and metric items. |
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# npm ci fails here: lockfiles written on macOS omit the @emnapi/* deps of
|
||||||
|
# optional wasm32-wasi packages (long-standing npm bug), so use install.
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY . .
|
||||||
|
# Public URL baked into canonical/OG/JSON-LD tags; pass your domain at build
|
||||||
|
# time (or set it in .env). An empty arg must not shadow the .env value, so
|
||||||
|
# unset it when blank — process.env beats .env files in Vite.
|
||||||
|
ARG VITE_SITE_URL=
|
||||||
|
RUN if [ -z "$VITE_SITE_URL" ]; then unset VITE_SITE_URL; fi && npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Markdown Studio
|
||||||
|
|
||||||
|
A self-contained web app for working with markdown files: upload a `.md` file (or start blank), edit it with live syntax highlighting, see a rendered preview, and export the result as **PDF**, **standalone HTML**, or **Markdown**.
|
||||||
|
|
||||||
|
Built with React + Vite + Tailwind CSS and [Untitled UI React](https://www.untitledui.com/react) components. No backend — everything runs in the browser, and your document auto-saves to localStorage.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Upload** — drag & drop or browse for `.md` / `.markdown` / `.txt` files (drop works anywhere in the app)
|
||||||
|
- **Edit** — CodeMirror editor with markdown + fenced-code syntax highlighting
|
||||||
|
- **Preview** — GitHub-flavored markdown (tables, task lists, strikethrough) with highlighted code blocks
|
||||||
|
- **Layouts** — editor only, side-by-side split (draggable divider), or preview only
|
||||||
|
- **Export** — Markdown download (`⌘S` / `Ctrl+S`), self-contained HTML file, or PDF via the browser's print dialog
|
||||||
|
- **Extras** — light/dark theme, autosave to localStorage, unsaved-changes indicator
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm run build # typecheck + production build into dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build
|
||||||
|
# → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Or without compose:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t markdown-studio .
|
||||||
|
docker run -p 8080:80 markdown-studio
|
||||||
|
```
|
||||||
|
|
||||||
|
The image is a multi-stage build: Node builds the static bundle, nginx serves it (SPA fallback + gzip + long-lived caching for hashed assets).
|
||||||
|
|
||||||
|
## SEO
|
||||||
|
|
||||||
|
The app ships with full on-page SEO: keyword-rich title/description, canonical URL, Open Graph + Twitter cards with a branded share image (`public/og-image.png`), JSON-LD `WebApplication` structured data, favicons + web manifest, `robots.txt`, and a `<noscript>` fallback for crawlers.
|
||||||
|
|
||||||
|
Absolute URLs (canonical, `og:url`, `og:image`) are stamped at build time from `VITE_SITE_URL`. Set it to your public domain before building:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# .env file
|
||||||
|
VITE_SITE_URL=https://markdown.yourdomain.com
|
||||||
|
|
||||||
|
# or as a Docker build arg
|
||||||
|
VITE_SITE_URL=https://markdown.yourdomain.com docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
After deploying, verify the share card with https://www.opengraph.xyz and submit the site in Google Search Console. Ranking beyond on-page factors depends on your domain, backlinks, and content — consider a landing/blog section if you want to target more queries.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # Untitled UI components (installed via `npx untitledui add`)
|
||||||
|
├── features/ # App UI: header, editor pane, preview pane, upload zone
|
||||||
|
├── hooks/ # use-document (state + autosave), use-resolved-theme
|
||||||
|
├── lib/ # markdown pipeline, export (md / html / pdf), sample doc
|
||||||
|
└── pages/ # markdown-studio (main screen)
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
markdown-studio:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
# Set to your public domain so canonical/OG/social tags use absolute URLs:
|
||||||
|
# VITE_SITE_URL: https://markdown.yourdomain.com
|
||||||
|
VITE_SITE_URL: ${VITE_SITE_URL:-}
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<!-- Primary SEO -->
|
||||||
|
<title>Markdown Studio — Free Online Markdown Editor with Live Preview & PDF Export</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Free online markdown editor with live side-by-side preview. Upload .md files, edit with syntax highlighting, and export to PDF, HTML, or Markdown. No sign-up, no server — your files never leave your browser."
|
||||||
|
/>
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<meta name="theme-color" content="#7f56d9" />
|
||||||
|
<link rel="canonical" href="%VITE_SITE_URL%/" />
|
||||||
|
|
||||||
|
<!-- Icons & manifest -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Markdown Studio" />
|
||||||
|
<meta property="og:title" content="Markdown Studio — Free Online Markdown Editor with Live Preview" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Edit markdown with a live preview and export to PDF, HTML, or Markdown. Free, private, no sign-up — everything runs in your browser."
|
||||||
|
/>
|
||||||
|
<meta property="og:url" content="%VITE_SITE_URL%/" />
|
||||||
|
<meta property="og:image" content="%VITE_SITE_URL%/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Markdown Studio — edit markdown, preview live, export anywhere" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Markdown Studio — Free Online Markdown Editor with Live Preview" />
|
||||||
|
<meta name="twitter:description" content="Edit markdown with a live preview and export to PDF, HTML, or Markdown. Free, private, no sign-up." />
|
||||||
|
<meta name="twitter:image" content="%VITE_SITE_URL%/og-image.png" />
|
||||||
|
|
||||||
|
<!-- Structured data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"name": "Markdown Studio",
|
||||||
|
"url": "%VITE_SITE_URL%/",
|
||||||
|
"description": "Free online markdown editor with live side-by-side preview. Upload .md files, edit with syntax highlighting, and export to PDF, HTML, or Markdown.",
|
||||||
|
"applicationCategory": "UtilitiesApplication",
|
||||||
|
"operatingSystem": "Any",
|
||||||
|
"browserRequirements": "Requires JavaScript",
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
|
||||||
|
"featureList": [
|
||||||
|
"Upload and edit .md files",
|
||||||
|
"Live side-by-side markdown preview",
|
||||||
|
"GitHub-flavored markdown: tables, task lists, code highlighting",
|
||||||
|
"Export to PDF",
|
||||||
|
"Export to standalone HTML",
|
||||||
|
"Download edited Markdown",
|
||||||
|
"Dark mode",
|
||||||
|
"Local autosave — files never leave the browser"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="size-full bg-primary antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<main style="max-width: 640px; margin: 80px auto; font-family: sans-serif; padding: 0 24px">
|
||||||
|
<h1>Markdown Studio — Online Markdown Editor with Live Preview</h1>
|
||||||
|
<p>
|
||||||
|
Markdown Studio is a free, browser-based markdown editor. Upload a .md file, edit it with syntax highlighting and a live rendered
|
||||||
|
preview, then export your document as PDF, standalone HTML, or Markdown. Your files are processed entirely in your browser and
|
||||||
|
never uploaded to a server.
|
||||||
|
</p>
|
||||||
|
<p>JavaScript is required to run the editor — please enable it and reload.</p>
|
||||||
|
</main>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script>
|
||||||
|
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||||
|
// Must match the ThemeProvider's "ui-theme" storage key.
|
||||||
|
if (localStorage.getItem("ui-theme") === "dark" || (!localStorage.getItem("ui-theme") && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||||
|
document.documentElement.classList.add("dark-mode");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark-mode");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml font/woff2;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Hashed build assets can be cached forever.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "md-studio",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/language-data": "^6.5.2",
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@uiw/codemirror-theme-github": "^4.25.10",
|
||||||
|
"@uiw/react-codemirror": "^4.25.10",
|
||||||
|
"@untitledui/file-icons": "^0.0.9",
|
||||||
|
"@untitledui/icons": "^0.0.22",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"motion": "^12.42.2",
|
||||||
|
"qr-code-styling": "^1.9.2",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-aria": "^3.50.0",
|
||||||
|
"react-aria-components": "^1.19.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hotkeys-hook": "^5.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router": "^7.13.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tailwindcss-react-aria-components": "^2.0.1",
|
||||||
|
"unified": "^11.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.53.1",
|
||||||
|
"vite": "^8.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4102
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#9e77ed"/>
|
||||||
|
<stop offset="1" stop-color="#6941c6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||||
|
<text x="32" y="42" font-family="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="28" font-weight="700" fill="#ffffff" text-anchor="middle">M↓</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 486 B |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Markdown Studio — Online Markdown Editor with Live Preview",
|
||||||
|
"short_name": "Markdown Studio",
|
||||||
|
"description": "Free online markdown editor with live preview. Upload .md files, edit side by side, and export to PDF, HTML, or Markdown. No sign-up, works offline.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#7f56d9",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import type { CSSProperties, ComponentPropsWithRef, HTMLAttributes, KeyboardEvent, ReactNode, Ref } from "react";
|
||||||
|
import { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
/** The options for the Embla carousel. */
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
/** The plugins for the Embla carousel. */
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
/** The orientation of the carousel. */
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
/** The function to set the API for the carousel. */
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselContextProps = CarouselProps & {
|
||||||
|
/** The ref of the carousel. */
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
/** The API of the carousel. */
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
/** The function to scroll the carousel to the previous slide. */
|
||||||
|
scrollPrev: () => void;
|
||||||
|
/** The function to scroll the carousel to the next slide. */
|
||||||
|
scrollNext: () => void;
|
||||||
|
/** Whether the carousel can scroll to the previous slide. */
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
/** Whether the carousel can scroll to the next slide. */
|
||||||
|
canScrollNext: boolean;
|
||||||
|
/** The index of the selected slide. */
|
||||||
|
selectedIndex: number;
|
||||||
|
/** The scroll snaps of the carousel. */
|
||||||
|
scrollSnaps: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CarouselContext = createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
export const useCarousel = () => {
|
||||||
|
const context = useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("The `useCarousel` hook must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselRoot = ({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }: ComponentPropsWithRef<"div"> & CarouselProps) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const onInit = useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
setScrollSnaps(api.scrollSnapList());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSelect = useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
setSelectedIndex(api.selectedScrollSnap());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !setApi) return;
|
||||||
|
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
onInit(api);
|
||||||
|
onSelect(api);
|
||||||
|
|
||||||
|
api.on("reInit", onInit);
|
||||||
|
api.on("reInit", onSelect);
|
||||||
|
api.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onInit, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
selectedIndex,
|
||||||
|
scrollSnaps,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div onKeyDownCapture={handleKeyDown} className={cx("relative", className)} role="region" aria-roledescription="carousel" {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CarouselContentProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
/** The class name of the content. */
|
||||||
|
className?: string;
|
||||||
|
/** Whether to hide the overflow. */
|
||||||
|
overflowHidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselContent = ({ className, overflowHidden = true, ...props }: CarouselContentProps) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className={cx("h-full w-full", overflowHidden && "overflow-hidden")}>
|
||||||
|
<div className={cx("flex max-h-full", orientation === "horizontal" ? "" : "flex-col", className)} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselItem = ({ className, ...props }: ComponentPropsWithRef<"div">) => {
|
||||||
|
return <div role="group" aria-roledescription="slide" className={cx("min-w-0 shrink-0 grow-0 basis-full", className)} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TriggerRenderProps {
|
||||||
|
isDisabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerProps {
|
||||||
|
/** The ref of the trigger. */
|
||||||
|
ref?: Ref<HTMLButtonElement>;
|
||||||
|
/** If true, the child element will be cloned and passed down the prop of the trigger. */
|
||||||
|
asChild?: boolean;
|
||||||
|
/** The direction of the trigger. */
|
||||||
|
direction: "prev" | "next";
|
||||||
|
/** The children of the trigger. Can be a render prop or a valid element. */
|
||||||
|
children: ReactNode | ((props: TriggerRenderProps) => ReactNode);
|
||||||
|
/** The style of the trigger. */
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** The class name of the trigger. */
|
||||||
|
className?: string | ((args: { isDisabled: boolean }) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Trigger = ({ className, children, asChild, direction, style, ...props }: TriggerProps) => {
|
||||||
|
const { scrollPrev, canScrollNext, scrollNext, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
const isDisabled = direction === "prev" ? !canScrollPrev : !canScrollNext;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
direction === "prev" ? scrollPrev() : scrollNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const computedClassName = typeof className === "function" ? className({ isDisabled }) : className;
|
||||||
|
|
||||||
|
const defaultAriaLabel = direction === "prev" ? "Previous slide" : "Next slide";
|
||||||
|
|
||||||
|
// If the children is a render prop, we need to pass the necessary props to the render prop.
|
||||||
|
if (typeof children === "function") {
|
||||||
|
return <>{children({ isDisabled, onClick: handleClick })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the children is a valid element, we need to clone it and pass the necessary props to the cloned element.
|
||||||
|
if (asChild && isValidElement(children)) {
|
||||||
|
return cloneElement(children, {
|
||||||
|
onClick: handleClick,
|
||||||
|
disabled: isDisabled,
|
||||||
|
"aria-label": defaultAriaLabel,
|
||||||
|
style: { ...(children.props as HTMLAttributes<HTMLElement>).style, ...style },
|
||||||
|
className: [computedClassName, (children.props as HTMLAttributes<HTMLElement>).className].filter(Boolean).join(" ") || undefined,
|
||||||
|
} as HTMLAttributes<HTMLElement>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button aria-label={defaultAriaLabel} disabled={isDisabled} className={computedClassName} onClick={handleClick} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarouselPrevTrigger = (props: Omit<TriggerProps, "direction">) => <Trigger {...props} direction="prev" />;
|
||||||
|
|
||||||
|
const CarouselNextTrigger = (props: Omit<TriggerProps, "direction">) => <Trigger {...props} direction="next" />;
|
||||||
|
|
||||||
|
interface CarouselIndicatorRenderProps {
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselIndicatorProps {
|
||||||
|
/** The index of the indicator. */
|
||||||
|
index: number;
|
||||||
|
/** If true, the child element will be cloned and passed down the prop of the indicator. */
|
||||||
|
asChild?: boolean;
|
||||||
|
/** If true, the indicator will be selected. */
|
||||||
|
isSelected?: boolean;
|
||||||
|
/** The children of the indicator. Can be a render prop or a valid element. */
|
||||||
|
children?: ReactNode | ((props: CarouselIndicatorRenderProps) => ReactNode);
|
||||||
|
/** The style of the indicator. */
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** The class name of the indicator. */
|
||||||
|
className?: string | ((args: { isSelected: boolean }) => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselIndicator = ({ index, isSelected = false, children, asChild, className, style }: CarouselIndicatorProps) => {
|
||||||
|
const { api, selectedIndex } = useCarousel();
|
||||||
|
|
||||||
|
isSelected = isSelected || selectedIndex === index;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
api?.scrollTo(index);
|
||||||
|
};
|
||||||
|
const computedClassName = typeof className === "function" ? className({ isSelected }) : className;
|
||||||
|
|
||||||
|
const defaultAriaLabel = "Go to slide" + (index + 1);
|
||||||
|
|
||||||
|
// If the children is a render prop, we need to pass the necessary props to the render prop.
|
||||||
|
if (typeof children === "function") {
|
||||||
|
return <>{children({ isSelected, onClick: handleClick })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the children is a valid element, we need to clone it and pass the necessary props to the cloned element.
|
||||||
|
if (asChild && isValidElement(children)) {
|
||||||
|
return cloneElement(children, {
|
||||||
|
onClick: handleClick,
|
||||||
|
"aria-label": defaultAriaLabel,
|
||||||
|
"aria-current": isSelected ? "true" : undefined,
|
||||||
|
style: { ...(children.props as HTMLAttributes<HTMLElement>).style, ...style },
|
||||||
|
className: [computedClassName, (children.props as HTMLAttributes<HTMLElement>).className].filter(Boolean).join(" ") || undefined,
|
||||||
|
} as HTMLAttributes<HTMLElement>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button aria-label={defaultAriaLabel} aria-current={isSelected ? "true" : undefined} className={computedClassName} onClick={handleClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CarouselIndicatorGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
||||||
|
children: ReactNode | ((props: { index: number }) => ReactNode);
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselIndicatorGroup = ({ children, ...props }: CarouselIndicatorGroupProps) => {
|
||||||
|
const { scrollSnaps } = useCarousel();
|
||||||
|
|
||||||
|
// If the children is a render prop, we need to pass the index to the render prop.
|
||||||
|
if (typeof children === "function") {
|
||||||
|
return <nav {...props}>{scrollSnaps.map((index) => children({ index }))}</nav>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <nav {...props}>{children}</nav>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Carousel = {
|
||||||
|
Root: CarouselRoot,
|
||||||
|
Content: CarouselContent,
|
||||||
|
Item: CarouselItem,
|
||||||
|
PrevTrigger: CarouselPrevTrigger,
|
||||||
|
NextTrigger: CarouselNextTrigger,
|
||||||
|
IndicatorGroup: CarouselIndicatorGroup,
|
||||||
|
Indicator: CarouselIndicator,
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { TooltipProps } from "recharts";
|
||||||
|
import type { Props as LegendContentProps } from "recharts/types/component/DefaultLegendContent";
|
||||||
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
import type { Props as DotProps } from "recharts/types/shape/Dot";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects evenly spaced items from an array. Used for rendering
|
||||||
|
* certain number of x-axis labels.
|
||||||
|
* @param dataArray - The array of items to select from.
|
||||||
|
* @param count - The number of items to select.
|
||||||
|
* @returns The selected items.
|
||||||
|
*/
|
||||||
|
export const selectEvenlySpacedItems = <T extends readonly unknown[]>(dataArray: T, count: number): Array<T[number]> => {
|
||||||
|
if (!dataArray || dataArray.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItems: Array<T[number]> = [];
|
||||||
|
|
||||||
|
if (dataArray.length === 1) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
selectedItems.push(dataArray[0]);
|
||||||
|
}
|
||||||
|
return selectedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const targetIndex = Math.round((i * (dataArray.length - 1)) / (count - 1));
|
||||||
|
const boundedIndex = Math.max(0, Math.min(targetIndex, dataArray.length - 1));
|
||||||
|
selectedItems.push(dataArray[boundedIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the legend content for a chart.
|
||||||
|
* @param reversed - Whether to reverse the payload.
|
||||||
|
* @param payload - The payload of the legend.
|
||||||
|
* @param align - The alignment of the legend.
|
||||||
|
* @param layout - The layout of the legend.
|
||||||
|
* @param className - The class name of the legend.
|
||||||
|
* @returns The legend content.
|
||||||
|
*/
|
||||||
|
export const ChartLegendContent = ({ reversed, payload, align, layout, className }: LegendContentProps & { reversed?: boolean; className?: string }) => {
|
||||||
|
payload = reversed ? payload?.toReversed() : payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cx(
|
||||||
|
"flex",
|
||||||
|
layout === "vertical"
|
||||||
|
? `flex-col gap-1 pl-4 ${align === "center" ? "items-center" : align === "right" ? "items-start" : "items-start"}`
|
||||||
|
: `flex-row gap-3 ${align === "center" ? "justify-center" : align === "right" ? "justify-end" : "justify-start"}`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload?.map((entry, index) => (
|
||||||
|
<li className="flex items-center gap-2 text-sm text-tertiary" key={index}>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"block size-2 rounded-full bg-current ring-[0.5px] ring-black/10 ring-inset",
|
||||||
|
(entry.payload as { className?: string })?.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{entry.value}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChartTooltipContentProps extends TooltipProps<ValueType, NameType> {
|
||||||
|
isRadialChart?: boolean;
|
||||||
|
isPieChart?: boolean;
|
||||||
|
label?: string;
|
||||||
|
// We have to use `any` here because the `payload` prop is not typed correctly in the `recharts` library.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
payload?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartTooltipContent = ({ active, payload, label, isRadialChart, isPieChart, formatter, labelFormatter }: ChartTooltipContentProps) => {
|
||||||
|
const canRender = active && payload && payload.length;
|
||||||
|
|
||||||
|
if (!canRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSingleDataPoint = payload.length === 1;
|
||||||
|
|
||||||
|
// If it's a single data point, we use the value as the title and
|
||||||
|
// the name as the secondary title.
|
||||||
|
let title = isSingleDataPoint ? (isRadialChart ? payload[0].value : isPieChart ? payload[0].value : payload[0].value) : label;
|
||||||
|
let secondaryTitle = isSingleDataPoint ? (isRadialChart ? payload[0].payload.name : isPieChart ? payload[0].name : label) : payload;
|
||||||
|
|
||||||
|
title =
|
||||||
|
isSingleDataPoint && formatter
|
||||||
|
? formatter(title, payload?.[0].name || label, payload[0], 0, payload)
|
||||||
|
: labelFormatter
|
||||||
|
? labelFormatter(title, payload)
|
||||||
|
: title;
|
||||||
|
secondaryTitle = isSingleDataPoint && labelFormatter ? labelFormatter(secondaryTitle, payload) : secondaryTitle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 rounded-lg bg-primary-solid px-3 py-2 shadow-lg">
|
||||||
|
<p className="text-xs font-semibold text-white">{title}</p>
|
||||||
|
|
||||||
|
{!secondaryTitle ? null : Array.isArray(secondaryTitle) ? (
|
||||||
|
<div>
|
||||||
|
{secondaryTitle.map((entry, index) => (
|
||||||
|
<p key={index} className={cx("text-xs text-tooltip-supporting-text")}>
|
||||||
|
{`${entry.name}: ${formatter ? formatter(entry.value, entry.name, entry, index, entry.payload) : entry.value}`}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-tooltip-supporting-text">{secondaryTitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChartActiveDotProps extends DotProps {
|
||||||
|
// We have to use `any` here because the `payload` prop is not typed correctly in the `recharts` library.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
payload?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartActiveDot = ({ cx = 0, cy = 0 }: ChartActiveDotProps) => {
|
||||||
|
const size = 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg x={cx - size / 2} y={cy - size / 2} width={size} height={size} viewBox="0 0 12 12" fill="none">
|
||||||
|
<rect x="2" y="2" width="8" height="8" rx="6" className="fill-bg-primary stroke-utility-brand-600" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react";
|
||||||
|
import { Children, createContext, isValidElement, useContext } from "react";
|
||||||
|
import { FileIcon } from "@untitledui/file-icons";
|
||||||
|
import { SearchLg } from "@untitledui/icons";
|
||||||
|
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
|
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
|
||||||
|
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
|
||||||
|
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface RootContextProps {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const RootContext = createContext<RootContextProps>({ size: "lg" });
|
||||||
|
|
||||||
|
interface RootProps extends ComponentPropsWithRef<"div">, RootContextProps {}
|
||||||
|
|
||||||
|
const Root = ({ size = "lg", ...props }: RootProps) => {
|
||||||
|
return (
|
||||||
|
<RootContext.Provider value={{ size }}>
|
||||||
|
<div {...props} className={cx("mx-auto flex w-full max-w-lg flex-col items-center justify-center", props.className)} />
|
||||||
|
</RootContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeaturedIcon = ({ color = "gray", theme = "modern", icon = SearchLg, size, ...props }: ComponentPropsWithRef<typeof FeaturedIconbase>) => {
|
||||||
|
const { size: rootSize } = useContext(RootContext);
|
||||||
|
|
||||||
|
return <FeaturedIconbase {...props} {...{ color, theme, icon }} size={!size && rootSize === "lg" ? "xl" : size || "lg"} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Illustration = ({ type = "cloud", color = "gray", size = "lg", ...props }: ComponentPropsWithRef<typeof Illustrations>) => {
|
||||||
|
const { size: rootSize } = useContext(RootContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Illustrations
|
||||||
|
role="img"
|
||||||
|
{...props}
|
||||||
|
{...{ type, color }}
|
||||||
|
size={rootSize === "sm" ? "sm" : rootSize === "md" ? "md" : size}
|
||||||
|
className={cx("z-10", props.className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FileTypeIconProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
type?: ComponentProps<typeof FileIcon>["type"];
|
||||||
|
theme?: ComponentProps<typeof FileIcon>["variant"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTypeIcon = ({ type = "folder", theme = "solid", ...props }: FileTypeIconProps) => {
|
||||||
|
return (
|
||||||
|
<div {...props} className={cx("relative z-10 flex rounded-full bg-linear-to-b from-neutral-50 to-neutral-200 p-8", props.className)}>
|
||||||
|
<FileIcon type={type} variant={theme} className="size-10 drop-shadow-sm" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeaderProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
pattern?: "none" | BackgroundPatternProps["pattern"];
|
||||||
|
patternSize?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = ({ pattern = "circle", patternSize = "md", ...props }: HeaderProps) => {
|
||||||
|
const { size } = useContext(RootContext);
|
||||||
|
// Whether we are passing `Illustration` component as children.
|
||||||
|
const hasIllustration = Children.toArray(props.children).some((headerChild) => isValidElement(headerChild) && headerChild.type === Illustration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
{...props}
|
||||||
|
className={cx("relative mb-4", (size === "md" || size === "lg") && "mb-5", hasIllustration && size === "lg" && "mb-6!", props.className)}
|
||||||
|
>
|
||||||
|
{pattern !== "none" && (
|
||||||
|
<BackgroundPattern size={patternSize} pattern={pattern} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Content = (props: ComponentPropsWithRef<"div">) => {
|
||||||
|
const { size } = useContext(RootContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"z-10 mb-6 flex w-full max-w-88 flex-col items-center justify-center gap-1",
|
||||||
|
(size === "md" || size === "lg") && "mb-8 gap-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Footer = (props: ComponentPropsWithRef<"div">) => {
|
||||||
|
return <footer {...props} className={cx("z-10 flex gap-3", props.className)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Title = (props: ComponentPropsWithRef<"h1">) => {
|
||||||
|
const { size } = useContext(RootContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"text-md font-semibold text-primary",
|
||||||
|
size === "md" && "text-lg font-semibold",
|
||||||
|
size === "lg" && "text-xl font-semibold",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Description = (props: ComponentPropsWithRef<"p">) => {
|
||||||
|
const { size } = useContext(RootContext);
|
||||||
|
|
||||||
|
return <p {...props} className={cx("text-center text-sm text-tertiary", size === "lg" && "text-md", props.className)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarRadiusProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
avatars?: Array<{ src: string; alt?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined avatar slots positioned on concentric circle rings.
|
||||||
|
* Each slot defines: ring radius, CSS angle (0°=top, clockwise), and avatar size class.
|
||||||
|
*/
|
||||||
|
const avatarSlots = [
|
||||||
|
{ ring: 80, angle: 330, size: "size-8" },
|
||||||
|
{ ring: 80, angle: 56, size: "size-8" },
|
||||||
|
{ ring: 144, angle: 82, size: "size-7" },
|
||||||
|
{ ring: 144, angle: 299, size: "size-7" },
|
||||||
|
{ ring: 144, angle: 241, size: "size-7" },
|
||||||
|
{ ring: 176, angle: 64, size: "size-6" },
|
||||||
|
{ ring: 176, angle: 270, size: "size-6" },
|
||||||
|
{ ring: 144, angle: 112, size: "size-7" },
|
||||||
|
{ ring: 80, angle: 249, size: "size-8" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RING_RADII = [48, 80, 112, 144, 176];
|
||||||
|
|
||||||
|
const AvatarRadius = ({ avatars = [], ...props }: AvatarRadiusProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
className={cx("pointer-events-none absolute top-1/2 left-1/2 size-120 -translate-x-1/2 -translate-y-1/2", props.className)}
|
||||||
|
style={{
|
||||||
|
maskImage: "radial-gradient(circle, black 10%, transparent 70%)",
|
||||||
|
WebkitMaskImage: "radial-gradient(circle, black 10%, transparent 70%)",
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RING_RADII.map((radius) => (
|
||||||
|
<div
|
||||||
|
key={radius}
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-secondary"
|
||||||
|
style={{ width: radius * 2, height: radius * 2 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{avatars.slice(0, avatarSlots.length).map((avatar, i) => {
|
||||||
|
const slot = avatarSlots[i];
|
||||||
|
const rad = (slot.angle * Math.PI) / 180;
|
||||||
|
const x = Math.sin(rad) * slot.ring;
|
||||||
|
const y = -Math.cos(rad) * slot.ring;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cx("absolute top-1/2 left-1/2 rounded-full bg-primary p-px shadow-xs ring-[0.5px] ring-black/10", slot.size)}
|
||||||
|
style={{ transform: `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))` }}
|
||||||
|
>
|
||||||
|
<img src={avatar.src} alt="" className="size-full rounded-full object-cover outline-[0.5px] -outline-offset-[0.5px] outline-black/16" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarRowProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
avatars?: Array<{ src: string; alt?: string }>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avatar sizes per root size — ordered smallest to largest (left edge → center). */
|
||||||
|
const rowStyles = {
|
||||||
|
sm: ["size-8 rounded-md", "size-9 rounded-[7px]", "size-10 rounded-lg", "size-11 rounded-[9px]"],
|
||||||
|
md: ["size-10 rounded-lg", "size-11 rounded-[9px]", "size-12 rounded-[10px]"],
|
||||||
|
lg: ["size-10 rounded-lg", "size-11 rounded-[9px]", "size-12 rounded-[10px]"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const AvatarRow = ({ avatars = [], children, ...props }: AvatarRowProps) => {
|
||||||
|
const { size: rootSize } = useContext(RootContext);
|
||||||
|
const sizeKey = rootSize || "md";
|
||||||
|
const sizes = rowStyles[sizeKey];
|
||||||
|
const count = sizes.length;
|
||||||
|
const leftAvatars = avatars.slice(0, count);
|
||||||
|
const rightAvatars = avatars.slice(count, count * 2);
|
||||||
|
|
||||||
|
const renderAvatar = (avatar: { src: string; alt?: string }, sizeClass: string, key: number) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cx(
|
||||||
|
"relative shrink-0 overflow-hidden outline-[0.5px] -outline-offset-[0.5px] outline-black/16 before:absolute before:inset-0 before:rounded-[inherit] before:border before:border-white/32 before:mask-[linear-gradient(to_bottom,black_0%,transparent_25%,transparent_75%,black_100%)]",
|
||||||
|
sizeClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src={avatar.src} alt={avatar.alt || ""} className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
className={cx("-m-1 flex items-center justify-center p-1", sizeKey === "sm" ? "gap-4" : sizeKey === "md" ? "gap-5" : "gap-6", props.className)}
|
||||||
|
style={{
|
||||||
|
maskImage: "radial-gradient(circle, black 5%, transparent 105%)",
|
||||||
|
WebkitMaskImage: "radial-gradient(circle, black 5%, transparent 105%)",
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cx("flex items-center", sizeKey === "sm" ? "gap-3" : "gap-4")}>
|
||||||
|
{leftAvatars.map((avatar, i) => renderAvatar(avatar, sizes[i], i))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<div className={cx("flex items-center", sizeKey === "sm" ? "gap-3" : "gap-4")}>
|
||||||
|
{rightAvatars.map((avatar, i) => renderAvatar(avatar, sizes[count - 1 - i], i + count))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarGridProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
avatars?: Array<{ src: string; alt?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridStyles = {
|
||||||
|
sm: { avatar: "size-8 rounded-md", inner: "rounded-[5px]", gap: "gap-2 pl-2", rowGap: "gap-2" },
|
||||||
|
md: { avatar: "size-10 rounded-lg", inner: "rounded-[7px]", gap: "gap-3 pl-3", rowGap: "gap-3" },
|
||||||
|
lg: { avatar: "size-12 rounded-[10px]", inner: "rounded-[9px]", gap: "gap-3 pl-3", rowGap: "gap-3" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const AvatarGrid = ({ avatars = [], ...props }: AvatarGridProps) => {
|
||||||
|
const { size: rootSize } = useContext(RootContext);
|
||||||
|
const sizeKey = rootSize || "md";
|
||||||
|
const config = gridStyles[sizeKey];
|
||||||
|
const mid = Math.ceil(avatars.length / 2);
|
||||||
|
const row1Base = avatars.slice(0, mid);
|
||||||
|
const row2Base = avatars.slice(mid);
|
||||||
|
|
||||||
|
const row1 = [...row1Base, ...row1Base, ...row1Base];
|
||||||
|
const row2 = [...row2Base, ...row2Base, ...row2Base];
|
||||||
|
|
||||||
|
const renderGridAvatar = (avatar: { src: string; alt?: string }, key: number) => (
|
||||||
|
<div key={key} className={cx("shrink-0 bg-primary p-px shadow-xs ring-[0.75px] ring-black/10", config.avatar)}>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative size-full overflow-hidden outline-[0.5px] -outline-offset-[0.5px] outline-black/16 before:absolute before:inset-0 before:rounded-[inherit] before:border before:border-white/32 before:mask-[linear-gradient(to_bottom,black_0%,transparent_25%,transparent_75%,black_100%)]",
|
||||||
|
config.inner,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src={avatar.src} alt={avatar.alt || ""} className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
className={cx("-m-1 flex max-w-lg flex-col items-center overflow-x-clip p-1", config.rowGap, props.className)}
|
||||||
|
style={{
|
||||||
|
maskImage: "radial-gradient(circle, black 10%, transparent 100%)",
|
||||||
|
WebkitMaskImage: "radial-gradient(circle, black 10%, transparent 100%)",
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<div className={cx("flex w-auto max-w-none shrink-0 animate-marquee [animation-duration:240s] motion-reduce:animate-none", config.gap)}>
|
||||||
|
{row1.map((avatar, i) => renderGridAvatar(avatar, i))}
|
||||||
|
</div>
|
||||||
|
<div className={cx("flex w-auto max-w-none shrink-0 animate-marquee [animation-duration:240s] motion-reduce:animate-none", config.gap)}>
|
||||||
|
{row1.map((avatar, i) => renderGridAvatar(avatar, i))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex w-auto max-w-none shrink-0 animate-marquee [animation-delay:-120s] [animation-duration:240s] direction-reverse motion-reduce:-translate-x-1/2 motion-reduce:animate-none",
|
||||||
|
config.gap,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row2.map((avatar, i) => renderGridAvatar(avatar, i + mid))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex w-auto max-w-none shrink-0 animate-marquee [animation-delay:-120s] [animation-duration:240s] direction-reverse motion-reduce:-translate-x-1/2 motion-reduce:animate-none",
|
||||||
|
config.gap,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row2.map((avatar, i) => renderGridAvatar(avatar, i + mid))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyState = Root as typeof Root & {
|
||||||
|
Title: typeof Title;
|
||||||
|
Header: typeof Header;
|
||||||
|
Footer: typeof Footer;
|
||||||
|
Content: typeof Content;
|
||||||
|
Description: typeof Description;
|
||||||
|
Illustration: typeof Illustration;
|
||||||
|
FeaturedIcon: typeof FeaturedIcon;
|
||||||
|
FileTypeIcon: typeof FileTypeIcon;
|
||||||
|
AvatarRadius: typeof AvatarRadius;
|
||||||
|
AvatarRow: typeof AvatarRow;
|
||||||
|
AvatarGrid: typeof AvatarGrid;
|
||||||
|
};
|
||||||
|
|
||||||
|
EmptyState.Title = Title;
|
||||||
|
EmptyState.Header = Header;
|
||||||
|
EmptyState.Footer = Footer;
|
||||||
|
EmptyState.Content = Content;
|
||||||
|
EmptyState.Description = Description;
|
||||||
|
EmptyState.Illustration = Illustration;
|
||||||
|
EmptyState.FeaturedIcon = FeaturedIcon;
|
||||||
|
EmptyState.FileTypeIcon = FileTypeIcon;
|
||||||
|
EmptyState.AvatarRadius = AvatarRadius;
|
||||||
|
EmptyState.AvatarRow = AvatarRow;
|
||||||
|
EmptyState.AvatarGrid = AvatarGrid;
|
||||||
|
|
||||||
|
export { EmptyState };
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import type { ComponentProps, ComponentPropsWithRef } from "react";
|
||||||
|
import { useId, useRef, useState } from "react";
|
||||||
|
import type { FileIcon } from "@untitledui/file-icons";
|
||||||
|
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
|
||||||
|
import { CheckCircle, Trash01, UploadCloud02, XCircle } from "@untitledui/icons";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { ButtonUtility } from "@/components/base/buttons/button-utility";
|
||||||
|
import { ProgressBar } from "@/components/base/progress-indicators/progress-indicators";
|
||||||
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable file size.
|
||||||
|
* @param bytes - The size of the file in bytes.
|
||||||
|
* @returns A string representing the file size in a human-readable format.
|
||||||
|
*/
|
||||||
|
export const getReadableFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return "0 KB";
|
||||||
|
|
||||||
|
const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
|
||||||
|
return Math.floor(bytes / Math.pow(1024, i)) + " " + suffixes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FileUploadDropZoneProps {
|
||||||
|
/** The class name of the drop zone. */
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* A hint text explaining what files can be dropped.
|
||||||
|
*/
|
||||||
|
hint?: string;
|
||||||
|
/**
|
||||||
|
* Disables dropping or uploading files.
|
||||||
|
*/
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Specifies the types of files that the server accepts.
|
||||||
|
* Examples: "image/*", ".pdf,image/*", "image/*,video/mpeg,application/pdf"
|
||||||
|
*/
|
||||||
|
accept?: string;
|
||||||
|
/**
|
||||||
|
* Allows multiple file uploads.
|
||||||
|
*/
|
||||||
|
allowsMultiple?: boolean;
|
||||||
|
/**
|
||||||
|
* Maximum file size in bytes.
|
||||||
|
*/
|
||||||
|
maxSize?: number;
|
||||||
|
/**
|
||||||
|
* Callback function that is called with the list of dropped files
|
||||||
|
* when files are dropped on the drop zone.
|
||||||
|
*/
|
||||||
|
onDropFiles?: (files: FileList) => void;
|
||||||
|
/**
|
||||||
|
* Callback function that is called with the list of unaccepted files
|
||||||
|
* when files are dropped on the drop zone.
|
||||||
|
*/
|
||||||
|
onDropUnacceptedFiles?: (files: FileList) => void;
|
||||||
|
/**
|
||||||
|
* Callback function that is called with the list of files that exceed
|
||||||
|
* the size limit when files are dropped on the drop zone.
|
||||||
|
*/
|
||||||
|
onSizeLimitExceed?: (files: FileList) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileUploadDropZone = ({
|
||||||
|
className,
|
||||||
|
hint,
|
||||||
|
isDisabled,
|
||||||
|
accept,
|
||||||
|
allowsMultiple = true,
|
||||||
|
maxSize,
|
||||||
|
onDropFiles,
|
||||||
|
onDropUnacceptedFiles,
|
||||||
|
onSizeLimitExceed,
|
||||||
|
}: FileUploadDropZoneProps) => {
|
||||||
|
const id = useId();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isInvalid, setIsInvalid] = useState(false);
|
||||||
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
|
|
||||||
|
const isFileTypeAccepted = (file: File): boolean => {
|
||||||
|
if (!accept) return true;
|
||||||
|
|
||||||
|
// Split the accept string into individual types
|
||||||
|
const acceptedTypes = accept.split(",").map((type) => type.trim());
|
||||||
|
|
||||||
|
return acceptedTypes.some((acceptedType) => {
|
||||||
|
// Handle file extensions (e.g., .pdf, .doc)
|
||||||
|
if (acceptedType.startsWith(".")) {
|
||||||
|
const extension = `.${file.name.split(".").pop()?.toLowerCase()}`;
|
||||||
|
return extension === acceptedType.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcards (e.g., image/*)
|
||||||
|
if (acceptedType.endsWith("/*")) {
|
||||||
|
const typePrefix = acceptedType.split("/")[0];
|
||||||
|
return file.type.startsWith(`${typePrefix}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exact MIME types (e.g., application/pdf)
|
||||||
|
return file.type === acceptedType;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragIn = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDraggingOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOut = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDraggingOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFiles = (files: File[]): void => {
|
||||||
|
// Reset the invalid state when processing files.
|
||||||
|
setIsInvalid(false);
|
||||||
|
|
||||||
|
const acceptedFiles: File[] = [];
|
||||||
|
const unacceptedFiles: File[] = [];
|
||||||
|
const oversizedFiles: File[] = [];
|
||||||
|
|
||||||
|
// If multiple files are not allowed, only process the first file
|
||||||
|
const filesToProcess = allowsMultiple ? files : files.slice(0, 1);
|
||||||
|
|
||||||
|
filesToProcess.forEach((file) => {
|
||||||
|
// Check file size first
|
||||||
|
if (maxSize && file.size > maxSize) {
|
||||||
|
oversizedFiles.push(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check file type
|
||||||
|
if (isFileTypeAccepted(file)) {
|
||||||
|
acceptedFiles.push(file);
|
||||||
|
} else {
|
||||||
|
unacceptedFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle oversized files
|
||||||
|
if (oversizedFiles.length > 0 && typeof onSizeLimitExceed === "function") {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
oversizedFiles.forEach((file) => dataTransfer.items.add(file));
|
||||||
|
|
||||||
|
setIsInvalid(true);
|
||||||
|
onSizeLimitExceed(dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle accepted files
|
||||||
|
if (acceptedFiles.length > 0 && typeof onDropFiles === "function") {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
acceptedFiles.forEach((file) => dataTransfer.items.add(file));
|
||||||
|
onDropFiles(dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unaccepted files
|
||||||
|
if (unacceptedFiles.length > 0 && typeof onDropUnacceptedFiles === "function") {
|
||||||
|
const unacceptedDataTransfer = new DataTransfer();
|
||||||
|
unacceptedFiles.forEach((file) => unacceptedDataTransfer.items.add(file));
|
||||||
|
|
||||||
|
setIsInvalid(true);
|
||||||
|
onDropUnacceptedFiles(unacceptedDataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the input value to ensure the same file can be selected again
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
handleDragOut(event);
|
||||||
|
processFiles(Array.from(event.dataTransfer.files));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
processFiles(Array.from(event.target.files || []));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-dropzone
|
||||||
|
onDragOver={handleDragIn}
|
||||||
|
onDragEnter={handleDragIn}
|
||||||
|
onDragLeave={handleDragOut}
|
||||||
|
onDragEnd={handleDragOut}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cx(
|
||||||
|
"relative flex flex-col items-center gap-3 rounded-xl bg-primary px-6 py-4 text-tertiary ring-1 ring-secondary transition duration-100 ease-linear ring-inset",
|
||||||
|
isDraggingOver && "ring-2 ring-brand",
|
||||||
|
isDisabled && "cursor-not-allowed bg-secondary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FeaturedIcon icon={UploadCloud02} color="gray" theme="modern" size="md" className={cx(isDisabled && "opacity-50")} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 text-center">
|
||||||
|
<div className="flex justify-center gap-1 text-center">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id={id}
|
||||||
|
type="file"
|
||||||
|
className="peer sr-only"
|
||||||
|
disabled={isDisabled}
|
||||||
|
accept={accept}
|
||||||
|
multiple={allowsMultiple}
|
||||||
|
onChange={handleInputFileChange}
|
||||||
|
/>
|
||||||
|
<label htmlFor={id} className="flex cursor-pointer">
|
||||||
|
<Button color="link-color" size="md" isDisabled={isDisabled} onClick={() => inputRef.current?.click()}>
|
||||||
|
Click to upload <span className="md:hidden">and attach files</span>
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm max-md:hidden">or drag and drop</span>
|
||||||
|
</div>
|
||||||
|
<p className={cx("text-xs transition duration-100 ease-linear", isInvalid && "text-error-primary")}>
|
||||||
|
{hint || "SVG, PNG, JPG or GIF (max. 800x400px)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FileListItemProps {
|
||||||
|
/** The name of the file. */
|
||||||
|
name: string;
|
||||||
|
/** The size of the file. */
|
||||||
|
size: number;
|
||||||
|
/** The upload progress of the file. */
|
||||||
|
progress: number;
|
||||||
|
/** Whether the file failed to upload. */
|
||||||
|
failed?: boolean;
|
||||||
|
/** The type of the file. */
|
||||||
|
type?: ComponentProps<typeof FileIcon>["type"];
|
||||||
|
/** The class name of the file list item. */
|
||||||
|
className?: string;
|
||||||
|
/** The variant of the file icon. */
|
||||||
|
fileIconVariant?: ComponentProps<typeof FileTypeIcon>["variant"];
|
||||||
|
/** The function to call when the file is deleted. */
|
||||||
|
onDelete?: () => void;
|
||||||
|
/** The function to call when the file upload is retried. */
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileListItemProgressBar = ({ name, size, progress, failed, type, fileIconVariant, onDelete, onRetry, className }: FileListItemProps) => {
|
||||||
|
const isComplete = progress === 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.li
|
||||||
|
layout="position"
|
||||||
|
className={cx(
|
||||||
|
"relative flex gap-3 rounded-xl bg-primary p-4 ring-1 ring-secondary transition-shadow duration-100 ease-linear ring-inset",
|
||||||
|
failed && "ring-2 ring-error",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileTypeIcon className="size-10 shrink-0 dark:hidden" type={type ?? "empty"} theme="light" variant={fileIconVariant ?? "default"} />
|
||||||
|
<FileTypeIcon className="size-10 shrink-0 not-dark:hidden" type={type ?? "empty"} theme="dark" variant={fileIconVariant ?? "default"} />
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||||
|
<div className="flex w-full max-w-full min-w-0 flex-1">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-secondary">{name}</p>
|
||||||
|
|
||||||
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
|
<p className="truncate text-sm whitespace-nowrap text-tertiary">{getReadableFileSize(size)}</p>
|
||||||
|
|
||||||
|
<hr className="h-3 w-px rounded-t-full rounded-b-full border-none bg-border-primary" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isComplete && <CheckCircle className="size-4 stroke-[2.5px] text-fg-success-primary" />}
|
||||||
|
{isComplete && <p className="text-sm font-medium text-success-primary">Complete</p>}
|
||||||
|
|
||||||
|
{!isComplete && !failed && <UploadCloud02 className="size-4 stroke-[2.5px] text-fg-quaternary" />}
|
||||||
|
{!isComplete && !failed && <p className="text-sm font-medium text-quaternary">Uploading...</p>}
|
||||||
|
|
||||||
|
{failed && <XCircle className="size-4 text-fg-error-primary" />}
|
||||||
|
{failed && <p className="text-sm font-medium text-error-primary">Failed</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonUtility color="tertiary" tooltip="Delete" icon={Trash01} size="xs" className="-mt-2 -mr-2 self-start" onClick={onDelete} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!failed && (
|
||||||
|
<div className="mt-1 w-full">
|
||||||
|
<ProgressBar labelPosition="right" max={100} min={0} value={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<Button color="link-destructive" size="sm" onClick={onRetry} className="mt-1.5">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileListItemProgressFill = ({ name, size, progress, failed, type, fileIconVariant, onDelete, onRetry, className }: FileListItemProps) => {
|
||||||
|
const isComplete = progress === 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.li layout="position" className={cx("relative flex gap-3 overflow-hidden rounded-xl bg-primary p-4", className)}>
|
||||||
|
{/* Progress fill. */}
|
||||||
|
<div
|
||||||
|
style={{ transform: `translateX(-${100 - progress}%)` }}
|
||||||
|
className={cx("absolute inset-0 size-full bg-secondary transition duration-75 ease-linear", isComplete && "opacity-0")}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={progress}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
/>
|
||||||
|
{/* Inner ring. */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"absolute inset-0 size-full rounded-[inherit] ring-1 ring-secondary transition duration-100 ease-linear ring-inset",
|
||||||
|
failed && "ring-2 ring-error",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FileTypeIcon className="relative size-10 shrink-0 dark:hidden" type={type ?? "empty"} theme="light" variant={fileIconVariant ?? "solid"} />
|
||||||
|
<FileTypeIcon className="relative size-10 shrink-0 not-dark:hidden" type={type ?? "empty"} theme="dark" variant={fileIconVariant ?? "solid"} />
|
||||||
|
|
||||||
|
<div className="relative flex min-w-0 flex-1">
|
||||||
|
<div className="relative flex min-w-0 flex-1 flex-col items-start">
|
||||||
|
<div className="w-full min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-secondary">{name}</p>
|
||||||
|
|
||||||
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
|
<p className="text-sm text-tertiary">{failed ? "Upload failed, please try again" : getReadableFileSize(size)}</p>
|
||||||
|
|
||||||
|
{!failed && (
|
||||||
|
<>
|
||||||
|
<hr className="h-3 w-px rounded-t-full rounded-b-full border-none bg-border-primary" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isComplete && <CheckCircle className="size-4 stroke-[2.5px] text-fg-success-primary" />}
|
||||||
|
{!isComplete && <UploadCloud02 className="size-4 stroke-[2.5px] text-fg-quaternary" />}
|
||||||
|
|
||||||
|
<p className="text-sm text-tertiary">{progress}%</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<Button color="link-destructive" size="sm" onClick={onRetry} className="mt-1.5">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonUtility color="tertiary" tooltip="Delete" icon={Trash01} size="xs" className="-mt-2 -mr-2 self-start" onClick={onDelete} />
|
||||||
|
</div>
|
||||||
|
</motion.li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileUploadRoot = (props: ComponentPropsWithRef<"div">) => (
|
||||||
|
<div {...props} className={cx("flex flex-col gap-4", props.className)}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
|
||||||
|
<ul {...props} className={cx("flex flex-col gap-3", props.className)}>
|
||||||
|
<AnimatePresence initial={false}>{props.children}</AnimatePresence>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FileUpload = {
|
||||||
|
Root: FileUploadRoot,
|
||||||
|
List: FileUploadList,
|
||||||
|
DropZone: FileUploadDropZone,
|
||||||
|
ListItemProgressBar: FileListItemProgressBar,
|
||||||
|
ListItemProgressFill: FileListItemProgressFill,
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: {
|
||||||
|
root: "gap-4",
|
||||||
|
label: "text-sm font-medium",
|
||||||
|
spinner: "size-8",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "gap-4",
|
||||||
|
label: "text-sm font-medium",
|
||||||
|
spinner: "size-12",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "gap-4",
|
||||||
|
label: "text-lg font-medium",
|
||||||
|
spinner: "size-14",
|
||||||
|
},
|
||||||
|
xl: {
|
||||||
|
root: "gap-5",
|
||||||
|
label: "text-lg font-medium",
|
||||||
|
spinner: "size-16",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoadingIndicatorProps {
|
||||||
|
/**
|
||||||
|
* The visual style of the loading indicator.
|
||||||
|
* @default 'line-simple'
|
||||||
|
*/
|
||||||
|
type?: "line-simple" | "line-spinner" | "dot-circle";
|
||||||
|
/**
|
||||||
|
* The size of the loading indicator.
|
||||||
|
* @default 'sm'
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg" | "xl";
|
||||||
|
/**
|
||||||
|
* Optional text label displayed below the indicator.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingIndicator = ({ type = "line-simple", size = "sm", label }: LoadingIndicatorProps) => {
|
||||||
|
const renderSpinner = () => {
|
||||||
|
if (type === "line-spinner") {
|
||||||
|
return (
|
||||||
|
<svg className={cx("animate-spin", styles[size].spinner)} viewBox="0 0 32 32" fill="none">
|
||||||
|
<circle
|
||||||
|
className="stroke-fg-brand-primary"
|
||||||
|
cx="16"
|
||||||
|
cy="16"
|
||||||
|
r="14"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeDashoffset="40"
|
||||||
|
strokeDasharray="100"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "dot-circle") {
|
||||||
|
return (
|
||||||
|
<svg className={cx("animate-spin text-fg-brand-primary", styles[size].spinner)} viewBox="0 0 36 36" fill="none">
|
||||||
|
<path
|
||||||
|
d="M34 18C34 15.8989 33.5861 13.8183 32.7821 11.8771C31.978 9.93586 30.7994 8.17203 29.3137 6.68629C27.828 5.20055 26.0641 4.022 24.1229 3.21793C22.1817 2.41385 20.1011 2 18 2C15.8988 2 13.8183 2.41385 11.8771 3.21793C9.93585 4.022 8.17203 5.20055 6.68629 6.68629C5.20055 8.17203 4.022 9.93586 3.21793 11.8771C2.41385 13.8183 2 15.8989 2 18"
|
||||||
|
stroke="url(#paint0)"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeDasharray="0.1 8"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.21793 24.1229C4.022 26.0641 5.20055 27.828 6.68629 29.3137C8.17203 30.7994 9.93585 31.978 11.8771 32.7821C13.8183 33.5861 15.8988 34 18 34C20.1011 34 22.1817 33.5861 24.1229 32.7821C26.0641 31.978 27.828 30.7994 29.3137 29.3137C30.7994 27.828 31.978 26.0641 32.7821 24.1229"
|
||||||
|
stroke="url(#paint1)"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeDasharray="0.1 8"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0" x1="34" y1="18" x2="2" y2="18" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="currentColor" />
|
||||||
|
<stop offset="1" stopColor="currentColor" stopOpacity="0.5" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1" x1="33" y1="23.5" x2="3" y2="24" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopOpacity="0" stopColor="currentColor" />
|
||||||
|
<stop offset="1" stopColor="currentColor" stopOpacity="0.48" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default case: type === "line-simple"
|
||||||
|
return (
|
||||||
|
<svg className={cx("animate-spin", styles[size].spinner)} viewBox="0 0 32 32" fill="none">
|
||||||
|
<circle className="text-bg-tertiary" cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<circle
|
||||||
|
className="stroke-fg-brand-primary"
|
||||||
|
cx="16"
|
||||||
|
cy="16"
|
||||||
|
r="14"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeDashoffset="75"
|
||||||
|
strokeDasharray="100"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("flex flex-col items-center justify-center", styles[size].root)}>
|
||||||
|
{renderSpinner()}
|
||||||
|
{label && <span className={cx("text-secondary", styles[size].label)}>{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
|
||||||
|
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const DialogTrigger = AriaDialogTrigger;
|
||||||
|
|
||||||
|
export const ModalOverlay = (props: AriaModalOverlayProps) => {
|
||||||
|
return (
|
||||||
|
<AriaModalOverlay
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"fixed inset-0 z-50 flex min-h-dvh w-full items-end justify-center overflow-y-auto bg-overlay/70 px-4 pt-4 pb-[clamp(16px,8vh,64px)] outline-hidden backdrop-blur-[6px] sm:items-center sm:justify-center sm:p-8",
|
||||||
|
state.isEntering && "duration-300 ease-out animate-in fade-in",
|
||||||
|
state.isExiting && "duration-200 ease-in animate-out fade-out",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal = (props: AriaModalOverlayProps) => (
|
||||||
|
<AriaModal
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"max-h-full w-full align-middle outline-hidden max-sm:overflow-y-auto max-sm:rounded-xl",
|
||||||
|
state.isEntering && "duration-300 ease-out animate-in zoom-in-95",
|
||||||
|
state.isExiting && "duration-200 ease-in animate-out zoom-out-95",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Dialog = (props: AriaDialogProps) => (
|
||||||
|
<AriaDialog {...props} className={cx("flex w-full items-center justify-center outline-hidden", props.className)} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type PaginationPage = {
|
||||||
|
/** The type of the pagination item. */
|
||||||
|
type: "page";
|
||||||
|
/** The value of the pagination item. */
|
||||||
|
value: number;
|
||||||
|
/** Whether the pagination item is the current page. */
|
||||||
|
isCurrent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaginationEllipsisType = {
|
||||||
|
type: "ellipsis";
|
||||||
|
key: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaginationItemType = PaginationPage | PaginationEllipsisType;
|
||||||
|
|
||||||
|
interface PaginationContextType {
|
||||||
|
/** The pages of the pagination. */
|
||||||
|
pages: PaginationItemType[];
|
||||||
|
/** The current page of the pagination. */
|
||||||
|
currentPage: number;
|
||||||
|
/** The total number of pages. */
|
||||||
|
total: number;
|
||||||
|
/** The function to call when the page changes. */
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationContext = createContext<PaginationContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export interface PaginationRootProps {
|
||||||
|
/** Number of sibling pages to show on each side of the current page */
|
||||||
|
siblingCount?: number;
|
||||||
|
/** Current active page number */
|
||||||
|
page: number;
|
||||||
|
/** Total number of pages */
|
||||||
|
total: number;
|
||||||
|
children: ReactNode;
|
||||||
|
/** The style of the pagination root. */
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** The class name of the pagination root. */
|
||||||
|
className?: string;
|
||||||
|
/** Callback function that's called when the page changes with the new page number. */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
||||||
|
const [pages, setPages] = useState<PaginationItemType[]>([]);
|
||||||
|
|
||||||
|
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
||||||
|
const items: PaginationItemType[] = [];
|
||||||
|
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
|
||||||
|
const totalPageNumbers = siblingCount * 2 + 5;
|
||||||
|
|
||||||
|
// If the total number of items to show is greater than or equal to the total pages,
|
||||||
|
// we can simply list all pages without needing to collapse with ellipsis
|
||||||
|
if (totalPageNumbers >= total) {
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: i,
|
||||||
|
isCurrent: i === page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Calculate left and right sibling boundaries around the current page
|
||||||
|
const leftSiblingIndex = Math.max(page - siblingCount, 1);
|
||||||
|
const rightSiblingIndex = Math.min(page + siblingCount, total);
|
||||||
|
|
||||||
|
// Determine if we need to show ellipsis on either side
|
||||||
|
const showLeftEllipsis = leftSiblingIndex > 2;
|
||||||
|
const showRightEllipsis = rightSiblingIndex < total - 1;
|
||||||
|
|
||||||
|
// Case 1: No left ellipsis, but right ellipsis is needed
|
||||||
|
if (!showLeftEllipsis && showRightEllipsis) {
|
||||||
|
// Calculate how many page numbers to show starting from the beginning
|
||||||
|
const leftItemCount = siblingCount * 2 + 3;
|
||||||
|
const leftRange = range(1, leftItemCount);
|
||||||
|
|
||||||
|
leftRange.forEach((pageNum) =>
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: pageNum,
|
||||||
|
isCurrent: pageNum === page,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert ellipsis after the left range and add the last page
|
||||||
|
items.push({ type: "ellipsis", key: leftItemCount + 1 });
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: total,
|
||||||
|
isCurrent: total === page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Case 2: Left ellipsis needed, but right ellipsis is not needed
|
||||||
|
else if (showLeftEllipsis && !showRightEllipsis) {
|
||||||
|
// Determine how many items from the end should be shown
|
||||||
|
const rightItemCount = siblingCount * 2 + 3;
|
||||||
|
const rightRange = range(total - rightItemCount + 1, total);
|
||||||
|
|
||||||
|
// Always show the first page, then add an ellipsis to indicate skipped pages
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: 1,
|
||||||
|
isCurrent: page === 1,
|
||||||
|
});
|
||||||
|
items.push({ type: "ellipsis", key: total - rightItemCount });
|
||||||
|
rightRange.forEach((pageNum) =>
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: pageNum,
|
||||||
|
isCurrent: pageNum === page,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Case 3: Both left and right ellipsis are needed
|
||||||
|
else if (showLeftEllipsis && showRightEllipsis) {
|
||||||
|
// Always show the first page
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: 1,
|
||||||
|
isCurrent: page === 1,
|
||||||
|
});
|
||||||
|
// Insert left ellipsis after the first page
|
||||||
|
items.push({ type: "ellipsis", key: leftSiblingIndex - 1 });
|
||||||
|
|
||||||
|
// Show a range of pages around the current page
|
||||||
|
const middleRange = range(leftSiblingIndex, rightSiblingIndex);
|
||||||
|
middleRange.forEach((pageNum) =>
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: pageNum,
|
||||||
|
isCurrent: pageNum === page,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert right ellipsis and finally the last page
|
||||||
|
items.push({ type: "ellipsis", key: rightSiblingIndex + 1 });
|
||||||
|
items.push({
|
||||||
|
type: "page",
|
||||||
|
value: total,
|
||||||
|
isCurrent: total === page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [total, siblingCount, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const paginationItems = createPaginationItems();
|
||||||
|
setPages(paginationItems);
|
||||||
|
}, [createPaginationItems]);
|
||||||
|
|
||||||
|
const onPageChangeHandler = (newPage: number) => {
|
||||||
|
onPageChange?.(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const paginationContextValue: PaginationContextType = {
|
||||||
|
pages,
|
||||||
|
currentPage: page,
|
||||||
|
total,
|
||||||
|
onPageChange: onPageChangeHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaginationContext.Provider value={paginationContextValue}>
|
||||||
|
<nav aria-label="Pagination Navigation" style={style} className={className}>
|
||||||
|
{children}
|
||||||
|
</nav>
|
||||||
|
</PaginationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an array of numbers from start to end.
|
||||||
|
* @param start - The start number.
|
||||||
|
* @param end - The end number.
|
||||||
|
* @returns An array of numbers from start to end.
|
||||||
|
*/
|
||||||
|
const range = (start: number, end: number): number[] => {
|
||||||
|
const length = end - start + 1;
|
||||||
|
|
||||||
|
return Array.from({ length }, (_, index) => index + start);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TriggerRenderProps {
|
||||||
|
isDisabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerProps {
|
||||||
|
/** The children of the trigger. Can be a render prop or a valid element. */
|
||||||
|
children: ReactNode | ((props: TriggerRenderProps) => ReactNode);
|
||||||
|
/** The style of the trigger. */
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** The class name of the trigger. */
|
||||||
|
className?: string | ((args: { isDisabled: boolean }) => string);
|
||||||
|
/** If true, the child element will be cloned and passed down the prop of the trigger. */
|
||||||
|
asChild?: boolean;
|
||||||
|
/** The direction of the trigger. */
|
||||||
|
direction: "prev" | "next";
|
||||||
|
/** The aria label of the trigger. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
||||||
|
const context = useContext(PaginationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Pagination components must be used within a Pagination.Root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPage, total, onPageChange } = context;
|
||||||
|
|
||||||
|
const isDisabled = direction === "prev" ? currentPage <= 1 : currentPage >= total;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
const newPage = direction === "prev" ? currentPage - 1 : currentPage + 1;
|
||||||
|
onPageChange?.(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const computedClassName = typeof className === "function" ? className({ isDisabled }) : className;
|
||||||
|
|
||||||
|
const defaultAriaLabel = direction === "prev" ? "Previous Page" : "Next Page";
|
||||||
|
|
||||||
|
// If the children is a render prop, we need to pass the isDisabled and onClick to the render prop.
|
||||||
|
if (typeof children === "function") {
|
||||||
|
return <>{children({ isDisabled, onClick: handleClick })}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the children is a valid element, we need to clone it and pass the isDisabled and onClick to the cloned element.
|
||||||
|
if (asChild && isValidElement(children)) {
|
||||||
|
return cloneElement(children, {
|
||||||
|
onClick: handleClick,
|
||||||
|
disabled: isDisabled,
|
||||||
|
isDisabled,
|
||||||
|
"aria-label": ariaLabel || defaultAriaLabel,
|
||||||
|
style: { ...(children.props as HTMLAttributes<HTMLElement>).style, ...style },
|
||||||
|
className: [computedClassName, (children.props as HTMLAttributes<HTMLElement>).className].filter(Boolean).join(" ") || undefined,
|
||||||
|
} as HTMLAttributes<HTMLElement>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button aria-label={ariaLabel || defaultAriaLabel} onClick={handleClick} disabled={isDisabled} style={style} className={computedClassName}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
|
||||||
|
|
||||||
|
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
||||||
|
|
||||||
|
interface PaginationItemRenderProps {
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
value: number;
|
||||||
|
"aria-current"?: "page";
|
||||||
|
"aria-label"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationItemProps {
|
||||||
|
/** The value of the pagination item. */
|
||||||
|
value: number;
|
||||||
|
/** Whether the pagination item is the current page. */
|
||||||
|
isCurrent: boolean;
|
||||||
|
/** The children of the pagination item. Can be a render prop or a valid element. */
|
||||||
|
children?: ReactNode | ((props: PaginationItemRenderProps) => ReactNode);
|
||||||
|
/** The style object of the pagination item. */
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** The class name of the pagination item. */
|
||||||
|
className?: string | ((args: { isSelected: boolean }) => string);
|
||||||
|
/** The aria label of the pagination item. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** If true, the child element will be cloned and passed down the prop of the item. */
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
||||||
|
const context = useContext(PaginationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Pagination components must be used within a <Pagination.Root />");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onPageChange } = context;
|
||||||
|
|
||||||
|
const isSelected = isCurrent;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onPageChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const computedClassName = typeof className === "function" ? className({ isSelected }) : className;
|
||||||
|
|
||||||
|
// If the children is a render prop, we need to pass the necessary props to the render prop.
|
||||||
|
if (typeof children === "function") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children({
|
||||||
|
isSelected,
|
||||||
|
onClick: handleClick,
|
||||||
|
value,
|
||||||
|
"aria-current": isCurrent ? "page" : undefined,
|
||||||
|
"aria-label": ariaLabel || `Page ${value}`,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the children is a valid element, we need to clone it and pass the necessary props to the cloned element.
|
||||||
|
if (asChild && isValidElement(children)) {
|
||||||
|
return cloneElement(children, {
|
||||||
|
onClick: handleClick,
|
||||||
|
"aria-current": isCurrent ? "page" : undefined,
|
||||||
|
"aria-label": ariaLabel || `Page ${value}`,
|
||||||
|
style: { ...(children.props as HTMLAttributes<HTMLElement>).style, ...style },
|
||||||
|
className: [computedClassName, (children.props as HTMLAttributes<HTMLElement>).className].filter(Boolean).join(" ") || undefined,
|
||||||
|
} as HTMLAttributes<HTMLElement>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
style={style}
|
||||||
|
className={computedClassName}
|
||||||
|
aria-current={isCurrent ? "page" : undefined}
|
||||||
|
aria-label={ariaLabel || `Page ${value}`}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
interface PaginationEllipsisProps {
|
||||||
|
key: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
style?: CSSProperties;
|
||||||
|
className?: string | (() => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
||||||
|
const computedClassName = typeof className === "function" ? className() : className;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={style} className={computedClassName} aria-hidden="true">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaginationContextComponentProps {
|
||||||
|
children: (pagination: PaginationContextType) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
||||||
|
const context = useContext(PaginationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Pagination components must be used within a Pagination.Root");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children(context)}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Pagination = {
|
||||||
|
Root: PaginationRoot,
|
||||||
|
PrevTrigger: PaginationPrevTrigger,
|
||||||
|
NextTrigger: PaginationNextTrigger,
|
||||||
|
Item: PaginationItem,
|
||||||
|
Ellipsis: PaginationEllipsis,
|
||||||
|
Context: PaginationContextComponent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
|
import { Pagination } from "./pagination-base";
|
||||||
|
|
||||||
|
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
|
||||||
|
/** The size of the pagination dot. */
|
||||||
|
size?: "md" | "lg";
|
||||||
|
/** Whether the pagination uses brand colors. */
|
||||||
|
isBrand?: boolean;
|
||||||
|
/** Whether the pagination is displayed in a card. */
|
||||||
|
framed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginationDot = ({ framed, className, size = "md", isBrand, ...props }: PaginationDotProps) => {
|
||||||
|
const sizes = {
|
||||||
|
md: {
|
||||||
|
root: cx("gap-3", framed && "p-2"),
|
||||||
|
button: "h-2 w-2 after:-inset-x-1.5 after:-inset-y-2",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: cx("gap-4", framed && "p-3"),
|
||||||
|
button: "h-2.5 w-2.5 after:-inset-x-2 after:-inset-y-3",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination.Root {...props} className={cx("flex h-max w-max", sizes[size].root, framed && "rounded-full bg-alpha-white/90 backdrop-blur", className)}>
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages }) =>
|
||||||
|
pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<Pagination.Item
|
||||||
|
{...page}
|
||||||
|
asChild
|
||||||
|
key={index}
|
||||||
|
className={cx(
|
||||||
|
"relative cursor-pointer rounded-full bg-quaternary outline-focus-ring after:absolute focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
sizes[size].button,
|
||||||
|
page.isCurrent && "bg-fg-brand-primary_alt",
|
||||||
|
isBrand && "bg-fg-brand-secondary",
|
||||||
|
isBrand && page.isCurrent && "bg-fg-white",
|
||||||
|
)}
|
||||||
|
></Pagination.Item>
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis {...page} key={index} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Pagination.Context>
|
||||||
|
</Pagination.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
|
import { Pagination } from "./pagination-base";
|
||||||
|
|
||||||
|
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
|
||||||
|
/** The size of the pagination line. */
|
||||||
|
size?: "md" | "lg";
|
||||||
|
/** Whether the pagination is displayed in a card. */
|
||||||
|
framed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginationLine = ({ framed, className, size = "md", ...props }: PaginationLineProps) => {
|
||||||
|
const sizes = {
|
||||||
|
md: {
|
||||||
|
root: cx("gap-2", framed && "p-2"),
|
||||||
|
button: "h-1.5 w-full after:-inset-x-1.5 after:-inset-y-2",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: cx("gap-3", framed && "p-3"),
|
||||||
|
button: "h-2 w-full after:-inset-x-2 after:-inset-y-3",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination.Root {...props} className={cx("flex h-max w-max", sizes[size].root, framed && "rounded-full bg-alpha-white/90 backdrop-blur", className)}>
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages }) =>
|
||||||
|
pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<Pagination.Item
|
||||||
|
{...page}
|
||||||
|
asChild
|
||||||
|
key={index}
|
||||||
|
className={cx(
|
||||||
|
"relative cursor-pointer rounded-full bg-quaternary outline-focus-ring after:absolute focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
sizes[size].button,
|
||||||
|
page.isCurrent && "bg-fg-brand-primary_alt",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis {...page} key={index} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Pagination.Context>
|
||||||
|
</Pagination.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { ArrowLeft, ArrowRight, ChevronLeft, ChevronLeftDouble, ChevronRight, ChevronRightDouble } from "@untitledui/icons";
|
||||||
|
import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { InputBase } from "@/components/base/input/input";
|
||||||
|
import { Select } from "@/components/base/select/select";
|
||||||
|
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
|
import { Pagination } from "./pagination-base";
|
||||||
|
|
||||||
|
interface PaginationProps extends Partial<Omit<PaginationRootProps, "children">> {
|
||||||
|
/** Whether the pagination buttons are rounded. */
|
||||||
|
rounded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?: boolean; isCurrent: boolean }) => {
|
||||||
|
return (
|
||||||
|
<Pagination.Item
|
||||||
|
value={value}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
className={({ isSelected }) =>
|
||||||
|
cx(
|
||||||
|
"flex size-9 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
rounded ? "rounded-full" : "rounded-lg",
|
||||||
|
isSelected && "bg-primary_hover text-secondary",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Pagination.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MobilePaginationProps {
|
||||||
|
/** The current page. */
|
||||||
|
page?: number;
|
||||||
|
/** The total number of pages. */
|
||||||
|
total?: number;
|
||||||
|
/** The class name of the pagination component. */
|
||||||
|
className?: string;
|
||||||
|
/** The function to call when the page changes. */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Pagination" className={cx("flex items-center justify-between md:hidden", className)}>
|
||||||
|
<Button
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
iconLeading={ArrowLeft}
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange?.(Math.max(0, page - 1))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="text-sm text-fg-secondary">
|
||||||
|
Page <span className="font-medium">{page}</span> of <span className="font-medium">{total}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
aria-label="Go to next page"
|
||||||
|
iconLeading={ArrowRight}
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange?.(Math.min(total, page + 1))}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
||||||
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination.Root
|
||||||
|
{...props}
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
className={cx("flex w-full items-center justify-between gap-3 border-t border-secondary pt-4 md:pt-5", className)}
|
||||||
|
>
|
||||||
|
<div className="hidden flex-1 justify-start md:flex">
|
||||||
|
<Pagination.PrevTrigger asChild>
|
||||||
|
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
||||||
|
{isDesktop ? "Previous" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.PrevTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination.PrevTrigger asChild className="md:hidden">
|
||||||
|
<Button iconLeading={ArrowLeft} color="secondary" size="sm">
|
||||||
|
{isDesktop ? "Previous" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.PrevTrigger>
|
||||||
|
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages, currentPage, total }) => (
|
||||||
|
<>
|
||||||
|
<div className="hidden justify-center gap-0.5 md:flex">
|
||||||
|
{pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
|
…
|
||||||
|
</Pagination.Ellipsis>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
|
||||||
|
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pagination.Context>
|
||||||
|
|
||||||
|
<div className="hidden flex-1 justify-end md:flex">
|
||||||
|
<Pagination.NextTrigger asChild>
|
||||||
|
<Button iconTrailing={ArrowRight} color="link-gray" size="sm">
|
||||||
|
{isDesktop ? "Next" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.NextTrigger>
|
||||||
|
</div>
|
||||||
|
<Pagination.NextTrigger asChild className="md:hidden">
|
||||||
|
<Button iconTrailing={ArrowRight} color="secondary" size="sm">
|
||||||
|
{isDesktop ? "Next" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.NextTrigger>
|
||||||
|
</Pagination.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
||||||
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination.Root
|
||||||
|
{...props}
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
className={cx("flex w-full items-center justify-between gap-3 border-t border-secondary pt-4 md:pt-5", className)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 justify-start">
|
||||||
|
<Pagination.PrevTrigger asChild>
|
||||||
|
<Button iconLeading={ArrowLeft} color="secondary" size="sm">
|
||||||
|
{isDesktop ? "Previous" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.PrevTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages, currentPage, total }) => (
|
||||||
|
<>
|
||||||
|
<div className="hidden justify-center gap-0.5 md:flex">
|
||||||
|
{pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
|
…
|
||||||
|
</Pagination.Ellipsis>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
|
||||||
|
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pagination.Context>
|
||||||
|
|
||||||
|
<div className="flex flex-1 justify-end">
|
||||||
|
<Pagination.NextTrigger asChild>
|
||||||
|
<Button iconTrailing={ArrowRight} color="secondary" size="sm">
|
||||||
|
{isDesktop ? "Next" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.NextTrigger>
|
||||||
|
</div>
|
||||||
|
</Pagination.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props }: PaginationProps) => {
|
||||||
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination.Root
|
||||||
|
{...props}
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
className="flex w-full items-center justify-between gap-3 border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 justify-start">
|
||||||
|
<Pagination.PrevTrigger asChild>
|
||||||
|
<Button iconLeading={ArrowLeft} color="secondary" size="sm">
|
||||||
|
{isDesktop ? "Previous" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.PrevTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages, currentPage, total }) => (
|
||||||
|
<>
|
||||||
|
<div className="hidden justify-center gap-0.5 md:flex">
|
||||||
|
{pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
|
…
|
||||||
|
</Pagination.Ellipsis>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
|
||||||
|
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pagination.Context>
|
||||||
|
|
||||||
|
<div className="flex flex-1 justify-end">
|
||||||
|
<Pagination.NextTrigger asChild>
|
||||||
|
<Button iconTrailing={ArrowRight} color="secondary" size="sm">
|
||||||
|
{isDesktop ? "Next" : undefined}
|
||||||
|
</Button>
|
||||||
|
</Pagination.NextTrigger>
|
||||||
|
</div>
|
||||||
|
</Pagination.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaginationCardMinimalProps {
|
||||||
|
/** The current page. */
|
||||||
|
page?: number;
|
||||||
|
/** The total number of pages. */
|
||||||
|
total?: number;
|
||||||
|
/** The number of items per page. */
|
||||||
|
pageSize?: number;
|
||||||
|
/** The alignment of the pagination. */
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
/** The class name of the pagination component. */
|
||||||
|
className?: string;
|
||||||
|
/** The function to call when the page changes. */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
/** The function to call when the page size changes. */
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginationCardMinimal = ({
|
||||||
|
page = 1,
|
||||||
|
total = 10,
|
||||||
|
pageSize = 10,
|
||||||
|
align = "left",
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: PaginationCardMinimalProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
|
||||||
|
<MobilePagination page={page} total={total} onPageChange={onPageChange} />
|
||||||
|
|
||||||
|
<nav aria-label="Pagination" className={cx("hidden items-center gap-3 md:flex", align === "center" && "justify-between")}>
|
||||||
|
<div className={cx(align === "center" && "flex flex-1 justify-start")}>
|
||||||
|
<Button isDisabled={page === 1} color="secondary" size="sm" onClick={() => onPageChange?.(Math.max(0, page - 1))}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex items-center gap-3",
|
||||||
|
align === "right" && "order-first mr-auto",
|
||||||
|
align === "left" && "order-last ml-auto flex-row-reverse",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-fg-secondary">
|
||||||
|
Page {page} of {total}
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
aria-label="Page Size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(value) => onPageSizeChange?.(value as number)}
|
||||||
|
size="sm"
|
||||||
|
items={[
|
||||||
|
{ label: "10 per page", id: 10 },
|
||||||
|
{ label: "25 per page", id: 25 },
|
||||||
|
{ label: "50 per page", id: 50 },
|
||||||
|
{ label: "100 per page", id: 100 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item id={item.id} key={item.id}>
|
||||||
|
{item.label?.split(" ")[0]}
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className={cx(align === "center" && "flex flex-1 justify-end")}>
|
||||||
|
<Button isDisabled={page === total} color="secondary" size="sm" onClick={() => onPageChange?.(Math.min(total, page + 1))}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaginationButtonGroupProps extends Partial<Omit<PaginationRootProps, "children">> {
|
||||||
|
/** The alignment of the pagination. */
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => {
|
||||||
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4",
|
||||||
|
align === "left" && "justify-start",
|
||||||
|
align === "center" && "justify-center",
|
||||||
|
align === "right" && "justify-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pagination.Root {...props} page={page} total={total}>
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages }) => (
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
<Pagination.PrevTrigger asChild>
|
||||||
|
<ButtonGroupItem iconLeading={ArrowLeft}>{isDesktop ? "Previous" : undefined}</ButtonGroupItem>
|
||||||
|
</Pagination.PrevTrigger>
|
||||||
|
|
||||||
|
{pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<Pagination.Item key={index} {...page} asChild>
|
||||||
|
<ButtonGroupItem isSelected={page.isCurrent} className="size-9 items-center justify-center">
|
||||||
|
{page.value}
|
||||||
|
</ButtonGroupItem>
|
||||||
|
</Pagination.Item>
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis key={index}>
|
||||||
|
<ButtonGroupItem className="pointer-events-none size-9 items-center justify-center rounded-none!">
|
||||||
|
…
|
||||||
|
</ButtonGroupItem>
|
||||||
|
</Pagination.Ellipsis>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination.NextTrigger asChild>
|
||||||
|
<ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
|
||||||
|
</Pagination.NextTrigger>
|
||||||
|
</ButtonGroup>
|
||||||
|
)}
|
||||||
|
</Pagination.Context>
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaginationCardAdvancedProps {
|
||||||
|
/** The current page. */
|
||||||
|
page?: number;
|
||||||
|
/** The total number of pages. */
|
||||||
|
total?: number;
|
||||||
|
/** The number of items per page. */
|
||||||
|
pageSize?: number;
|
||||||
|
/** The alignment of the pagination. */
|
||||||
|
align?: "space-between" | "center";
|
||||||
|
/** The class name of the pagination component. */
|
||||||
|
className?: string;
|
||||||
|
/** The function to call when the page changes. */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
/** The function to call when the page size changes. */
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaginationCardAdvanced = ({
|
||||||
|
page = 1,
|
||||||
|
total = 10,
|
||||||
|
pageSize = 10,
|
||||||
|
align = "space-between",
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: PaginationCardAdvancedProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
|
||||||
|
<Pagination.Root
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
className={cx("flex items-center gap-3", align === "center" && "justify-between")}
|
||||||
|
>
|
||||||
|
<div className="hidden items-center gap-2 text-sm font-medium whitespace-nowrap text-fg-secondary md:flex">
|
||||||
|
Page
|
||||||
|
<InputBase
|
||||||
|
aria-label="Page"
|
||||||
|
value={page.toString()}
|
||||||
|
onChange={(event) => onPageChange?.(Number(event.target.value))}
|
||||||
|
size="sm"
|
||||||
|
wrapperClassName="min-w-9"
|
||||||
|
inputClassName="text-center min-w-9 field-sizing-content"
|
||||||
|
/>
|
||||||
|
of {total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className={cx("mx-1 h-4 w-px border-l border-primary max-md:hidden", align === "center" && "hidden")} />
|
||||||
|
|
||||||
|
<div className={cx("hidden items-center gap-2 md:flex", align === "center" && "order-last")}>
|
||||||
|
<span className="text-sm font-medium whitespace-nowrap text-secondary">Rows per page</span>
|
||||||
|
<Select
|
||||||
|
aria-label="Page Size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(value) => onPageSizeChange?.(value as number)}
|
||||||
|
size="sm"
|
||||||
|
items={[
|
||||||
|
{ label: "10", id: 10 },
|
||||||
|
{ label: "25", id: 25 },
|
||||||
|
{ label: "50", id: 50 },
|
||||||
|
{ label: "100", id: 100 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item selectionIndicator="none" id={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cx("flex flex-1 items-center gap-4 md:ml-auto md:justify-end", align === "center" && "md:justify-center")}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button iconLeading={ChevronLeftDouble} color="secondary" size="sm" isDisabled={page === 1} onClick={() => onPageChange?.(1)} />
|
||||||
|
<Pagination.PrevTrigger asChild>
|
||||||
|
<Button iconLeading={ChevronLeft} color="secondary" size="sm" />
|
||||||
|
</Pagination.PrevTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination.Context>
|
||||||
|
{({ pages, currentPage, total }) => (
|
||||||
|
<>
|
||||||
|
<div className="hidden justify-center gap-0.5 md:flex">
|
||||||
|
{pages.map((page, index) =>
|
||||||
|
page.type === "page" ? (
|
||||||
|
<PaginationItem key={index} {...page} />
|
||||||
|
) : (
|
||||||
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
|
…
|
||||||
|
</Pagination.Ellipsis>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
|
||||||
|
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pagination.Context>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
iconTrailing={ChevronRightDouble}
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={page === total}
|
||||||
|
onClick={() => onPageChange?.(total)}
|
||||||
|
/>
|
||||||
|
<Pagination.NextTrigger asChild>
|
||||||
|
<Button iconTrailing={ChevronRight} color="secondary" size="sm" />
|
||||||
|
</Pagination.NextTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
|
||||||
|
import type {
|
||||||
|
DialogProps as AriaDialogProps,
|
||||||
|
ModalOverlayProps as AriaModalOverlayProps,
|
||||||
|
ModalRenderProps as AriaModalRenderProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
|
||||||
|
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface ModalOverlayProps extends AriaModalOverlayProps, RefAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export const ModalOverlay = (props: ModalOverlayProps) => {
|
||||||
|
return (
|
||||||
|
<AriaModalOverlay
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
|
||||||
|
state.isEntering && "duration-300 animate-in fade-in",
|
||||||
|
state.isExiting && "duration-500 animate-out fade-out",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ModalOverlay.displayName = "ModalOverlay";
|
||||||
|
|
||||||
|
interface ModalProps extends AriaModalOverlayProps, RefAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export const Modal = (props: ModalProps) => (
|
||||||
|
<AriaModal
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"inset-y-0 right-0 h-full w-full max-w-100 shadow-xl transition",
|
||||||
|
state.isEntering && "duration-300 animate-in slide-in-from-right",
|
||||||
|
state.isExiting && "duration-500 animate-out slide-out-to-right",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
Modal.displayName = "Modal";
|
||||||
|
|
||||||
|
interface DialogProps extends AriaDialogProps, RefAttributes<HTMLElement> {}
|
||||||
|
|
||||||
|
export const Dialog = (props: DialogProps) => (
|
||||||
|
<AriaDialog
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Slideout menu"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"relative flex size-full flex-col items-start gap-6 overflow-y-auto bg-primary ring-1 ring-secondary_alt outline-hidden",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
Dialog.displayName = "Dialog";
|
||||||
|
|
||||||
|
interface SlideoutMenuProps extends Omit<AriaModalOverlayProps, "children">, RefAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode | ((children: AriaModalRenderProps & { close: () => void }) => ReactNode);
|
||||||
|
dialogClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => {
|
||||||
|
return (
|
||||||
|
<ModalOverlay {...props}>
|
||||||
|
<Modal className={(state) => cx(typeof props.className === "function" ? props.className(state) : props.className)}>
|
||||||
|
{(state) => (
|
||||||
|
<Dialog className={dialogClassName}>
|
||||||
|
{({ close }) => {
|
||||||
|
return typeof children === "function" ? children({ ...state, close }) : children;
|
||||||
|
}}
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Menu.displayName = "SlideoutMenu";
|
||||||
|
|
||||||
|
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
|
||||||
|
return <div role={role} {...props} className={cx("flex size-full flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
|
||||||
|
};
|
||||||
|
Content.displayName = "SlideoutContent";
|
||||||
|
|
||||||
|
interface SlideoutHeaderProps extends ComponentPropsWithRef<"header"> {
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = ({ className, children, onClose, ...props }: SlideoutHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<header {...props} className={cx("relative z-1 w-full px-4 pt-6 md:px-6", className)}>
|
||||||
|
{children}
|
||||||
|
<CloseButton size="sm" className="absolute top-3 right-3 shrink-0" onClick={onClose} />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Header.displayName = "SlideoutHeader";
|
||||||
|
|
||||||
|
const Footer = (props: ComponentPropsWithRef<"footer">) => {
|
||||||
|
return <footer {...props} className={cx("w-full p-4 shadow-[inset_0px_1px_0px_0px] shadow-border-secondary md:px-6", props.className)} />;
|
||||||
|
};
|
||||||
|
Footer.displayName = "SlideoutFooter";
|
||||||
|
|
||||||
|
const SlideoutMenu = Menu as typeof Menu & {
|
||||||
|
Trigger: typeof AriaDialogTrigger;
|
||||||
|
Content: typeof Content;
|
||||||
|
Header: typeof Header;
|
||||||
|
Footer: typeof Footer;
|
||||||
|
};
|
||||||
|
SlideoutMenu.displayName = "SlideoutMenu";
|
||||||
|
|
||||||
|
SlideoutMenu.Trigger = AriaDialogTrigger;
|
||||||
|
SlideoutMenu.Content = Content;
|
||||||
|
SlideoutMenu.Header = Header;
|
||||||
|
SlideoutMenu.Footer = Footer;
|
||||||
|
|
||||||
|
export { SlideoutMenu };
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import type { ComponentPropsWithRef, HTMLAttributes, ReactNode, Ref, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||||
|
import { createContext, isValidElement, useContext } from "react";
|
||||||
|
import { ArrowDown, ChevronSelectorVertical, Copy01, Edit01, HelpCircle, Trash01 } from "@untitledui/icons";
|
||||||
|
import type {
|
||||||
|
CellProps as AriaCellProps,
|
||||||
|
ColumnProps as AriaColumnProps,
|
||||||
|
RowProps as AriaRowProps,
|
||||||
|
TableHeaderProps as AriaTableHeaderProps,
|
||||||
|
TableProps as AriaTableProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import {
|
||||||
|
Cell as AriaCell,
|
||||||
|
Collection as AriaCollection,
|
||||||
|
Column as AriaColumn,
|
||||||
|
Group as AriaGroup,
|
||||||
|
Row as AriaRow,
|
||||||
|
Table as AriaTable,
|
||||||
|
TableBody as AriaTableBody,
|
||||||
|
TableHeader as AriaTableHeader,
|
||||||
|
useTableOptions,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
|
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const TableRowActionsDropdown = () => (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Dropdown.DotsButton />
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-min">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item icon={Edit01}>
|
||||||
|
<span className="pr-4">Edit</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Copy01}>
|
||||||
|
<span className="pr-4">Copy link</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Trash01}>
|
||||||
|
<span className="pr-4">Delete</span>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
|
||||||
|
|
||||||
|
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
||||||
|
return (
|
||||||
|
<TableContext.Provider value={{ size }}>
|
||||||
|
<div {...props} className={cx("overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TableContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TableCardHeaderProps {
|
||||||
|
/** The title of the table card header. */
|
||||||
|
title: string;
|
||||||
|
/** The badge displayed next to the title. */
|
||||||
|
badge?: ReactNode;
|
||||||
|
/** The description of the table card header. */
|
||||||
|
description?: string;
|
||||||
|
/** The content displayed after the title and badge. */
|
||||||
|
contentTrailing?: ReactNode;
|
||||||
|
/** The class name of the table card header. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableCardHeader = ({ title, badge, description, contentTrailing, className }: TableCardHeaderProps) => {
|
||||||
|
const { size } = useContext(TableContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
|
||||||
|
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-md font-semibold text-primary">{title}</h2>
|
||||||
|
{badge ? (
|
||||||
|
isValidElement(badge) ? (
|
||||||
|
badge
|
||||||
|
) : (
|
||||||
|
<Badge color="gray" size="sm" type="modern">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{description && <p className="text-sm text-tertiary">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{contentTrailing}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TableRootProps extends AriaTableProps, Omit<ComponentPropsWithRef<"table">, "className" | "slot" | "style"> {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
|
||||||
|
const context = useContext(TableContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<AriaTable className={(state) => cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} />
|
||||||
|
</div>
|
||||||
|
</TableContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
TableRoot.displayName = "Table";
|
||||||
|
|
||||||
|
interface TableHeaderProps<T extends object>
|
||||||
|
extends AriaTableHeaderProps<T>, Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
|
||||||
|
bordered?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableHeader = <T extends object>({ columns, children, bordered = true, className, size: sizeProp, ...props }: TableHeaderProps<T>) => {
|
||||||
|
const context = useContext(TableContext);
|
||||||
|
const { selectionBehavior, selectionMode } = useTableOptions();
|
||||||
|
|
||||||
|
const size = sizeProp ?? context.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaTableHeader
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative bg-secondary",
|
||||||
|
size === "sm" ? "h-9" : "h-11",
|
||||||
|
|
||||||
|
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||||
|
bordered &&
|
||||||
|
"[&>tr>th]:after:pointer-events-none [&>tr>th]:after:absolute [&>tr>th]:after:inset-x-0 [&>tr>th]:after:bottom-0 [&>tr>th]:after:h-px [&>tr>th]:after:bg-border-secondary [&>tr>th]:focus-visible:after:bg-transparent",
|
||||||
|
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectionBehavior === "toggle" && (
|
||||||
|
<AriaColumn className={cx("relative py-2 pr-0 pl-4", size === "sm" ? "w-9 md:pl-5" : "w-11 md:pl-6")}>
|
||||||
|
{selectionMode === "multiple" && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Checkbox slot="selection" size="md" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaColumn>
|
||||||
|
)}
|
||||||
|
<AriaCollection items={columns}>{children}</AriaCollection>
|
||||||
|
</AriaTableHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
|
||||||
|
label?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadProps) => {
|
||||||
|
const { selectionBehavior } = useTableOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaColumn
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative p-0 px-6 py-2 outline-hidden focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-bg-primary focus-visible:ring-inset",
|
||||||
|
selectionBehavior === "toggle" && "nth-2:pl-3",
|
||||||
|
state.allowsSorting && "cursor-pointer",
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<AriaGroup className="flex items-center gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
|
||||||
|
{typeof children === "function" ? children(state) : children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip title={tooltip} placement="top">
|
||||||
|
<TooltipTrigger className="cursor-pointer text-fg-quaternary transition duration-100 ease-linear hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover">
|
||||||
|
<HelpCircle className="size-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.allowsSorting &&
|
||||||
|
(state.sortDirection ? (
|
||||||
|
<ArrowDown className={cx("size-3 stroke-[3px] text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")} />
|
||||||
|
) : (
|
||||||
|
<ChevronSelectorVertical size={12} strokeWidth={3} className="text-fg-quaternary" />
|
||||||
|
))}
|
||||||
|
</AriaGroup>
|
||||||
|
)}
|
||||||
|
</AriaColumn>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
interface TableRowProps<T extends object>
|
||||||
|
extends AriaRowProps<T>, Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
|
||||||
|
highlightSelectedRow?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRow = <T extends object>({ columns, children, className, highlightSelectedRow = true, size: sizeProp, ...props }: TableRowProps<T>) => {
|
||||||
|
const context = useContext(TableContext);
|
||||||
|
const { selectionBehavior } = useTableOptions();
|
||||||
|
|
||||||
|
const size = sizeProp ?? context.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaRow
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative outline-focus-ring transition-colors after:pointer-events-none hover:bg-secondary focus-visible:outline-2 focus-visible:-outline-offset-2",
|
||||||
|
size === "sm" ? "h-14" : "h-18",
|
||||||
|
highlightSelectedRow && "selected:bg-secondary",
|
||||||
|
|
||||||
|
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||||
|
"[&>td]:after:absolute [&>td]:after:inset-x-0 [&>td]:after:bottom-0 [&>td]:after:h-px [&>td]:after:w-full [&>td]:after:bg-border-secondary last:[&>td]:after:hidden [&>td]:focus-visible:after:opacity-0 focus-visible:[&>td]:after:opacity-0",
|
||||||
|
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectionBehavior === "toggle" && (
|
||||||
|
<AriaCell className={cx("relative py-2 pr-0 pl-4", size === "sm" ? "md:pl-5" : "md:pl-6")}>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Checkbox slot="selection" size="md" />
|
||||||
|
</div>
|
||||||
|
</AriaCell>
|
||||||
|
)}
|
||||||
|
<AriaCollection items={columns}>{children}</AriaCollection>
|
||||||
|
</AriaRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
interface TableCellProps extends AriaCellProps, Omit<TdHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
|
||||||
|
ref?: Ref<HTMLTableCellElement>;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableCell = ({ className, children, size: sizeProp, ...props }: TableCellProps) => {
|
||||||
|
const context = useContext(TableContext);
|
||||||
|
const { selectionBehavior } = useTableOptions();
|
||||||
|
|
||||||
|
const size = sizeProp ?? context.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaCell
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative text-sm text-tertiary outline-focus-ring focus-visible:z-1 focus-visible:outline-2 focus-visible:-outline-offset-2",
|
||||||
|
size === "sm" && "px-5 py-3",
|
||||||
|
size === "md" && "px-6 py-4",
|
||||||
|
|
||||||
|
selectionBehavior === "toggle" && "nth-2:pl-3",
|
||||||
|
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AriaCell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCard = {
|
||||||
|
Root: TableCardRoot,
|
||||||
|
Header: TableCardHeader,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Table = TableRoot as typeof TableRoot & {
|
||||||
|
Body: typeof AriaTableBody;
|
||||||
|
Cell: typeof TableCell;
|
||||||
|
Head: typeof TableHead;
|
||||||
|
Header: typeof TableHeader;
|
||||||
|
Row: typeof TableRow;
|
||||||
|
};
|
||||||
|
Table.Body = AriaTableBody;
|
||||||
|
Table.Cell = TableCell;
|
||||||
|
Table.Head = TableHead;
|
||||||
|
Table.Header = TableHeader;
|
||||||
|
Table.Row = TableRow;
|
||||||
|
|
||||||
|
export { Table, TableCard };
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import type { ComponentPropsWithRef, FC, ReactNode } from "react";
|
||||||
|
import { createContext, isValidElement, useContext } from "react";
|
||||||
|
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
|
||||||
|
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
|
||||||
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
type Orientation = "horizontal" | "vertical";
|
||||||
|
|
||||||
|
// Types for different orientations
|
||||||
|
type HorizontalTypes = "button-brand" | "button-gray" | "button-border" | "button-minimal" | "underline";
|
||||||
|
type VerticalTypes = "button-brand" | "button-gray" | "button-border" | "button-minimal" | "line";
|
||||||
|
type TabTypeColors<T> = T extends "horizontal" ? HorizontalTypes : VerticalTypes;
|
||||||
|
|
||||||
|
// Styles for different types of tab
|
||||||
|
const getTabStyles = ({ isFocusVisible, isSelected, isHovered }: AriaTabRenderProps) => ({
|
||||||
|
"button-brand": cx(
|
||||||
|
"outline-focus-ring *:data-icon:text-fg-quaternary",
|
||||||
|
isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
(isSelected || isHovered) && "bg-brand-primary_alt text-brand-secondary *:data-icon:text-fg-brand-secondary_hover",
|
||||||
|
),
|
||||||
|
"button-gray": cx(
|
||||||
|
"outline-focus-ring *:data-icon:text-fg-quaternary",
|
||||||
|
isHovered && "bg-primary_hover text-secondary *:data-icon:text-fg-secondary_hover",
|
||||||
|
isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
isSelected && "bg-primary_hover text-secondary *:data-icon:text-fg-secondary_hover",
|
||||||
|
),
|
||||||
|
"button-border": cx(
|
||||||
|
"outline-focus-ring *:data-icon:text-fg-quaternary",
|
||||||
|
isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
(isSelected || isHovered) && "bg-primary_alt text-secondary shadow-sm *:data-icon:text-fg-secondary_hover",
|
||||||
|
),
|
||||||
|
"button-minimal": cx(
|
||||||
|
"rounded-lg outline-focus-ring *:data-icon:text-fg-quaternary",
|
||||||
|
isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
(isSelected || isHovered) && "bg-primary_alt text-secondary shadow-xs ring-1 ring-primary ring-inset *:data-icon:text-fg-secondary_hover",
|
||||||
|
),
|
||||||
|
underline: cx(
|
||||||
|
"rounded-none border-b-2 border-transparent outline-focus-ring *:data-icon:text-fg-quaternary",
|
||||||
|
isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
(isSelected || isHovered) && "border-fg-brand-primary_alt text-brand-secondary *:data-icon:text-fg-brand-secondary_hover",
|
||||||
|
),
|
||||||
|
line: cx(
|
||||||
|
"rounded-none border-l-2 border-transparent outline-focus-ring *:data-icon:text-fg-quaternary",
|
||||||
|
isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
(isSelected || isHovered) && "border-fg-brand-primary_alt text-brand-secondary *:data-icon:text-fg-brand-secondary_hover",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: {
|
||||||
|
base: "text-sm font-semibold gap-1 *:data-icon:size-4",
|
||||||
|
"button-brand": "py-2 px-2.5",
|
||||||
|
"button-gray": "py-2 px-2.5",
|
||||||
|
"button-border": "py-2 px-2.5",
|
||||||
|
"button-minimal": "py-2 px-2.5",
|
||||||
|
underline: "px-0.5 pb-2.5 pt-0",
|
||||||
|
line: "pl-2.5 pr-3 py-0.5",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
base: "text-md font-semibold gap-1.5 *:data-icon:size-5",
|
||||||
|
"button-brand": "py-2.5 px-2.5",
|
||||||
|
"button-gray": "py-2.5 px-2.5",
|
||||||
|
"button-border": "py-2.5 px-2.5",
|
||||||
|
"button-minimal": "py-2.5 px-2.5",
|
||||||
|
underline: "px-0.5 pb-2.5 pt-0",
|
||||||
|
line: "pr-3.5 pl-3 py-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styles for different types of horizontal tabs
|
||||||
|
const getHorizontalStyles = ({ size, fullWidth }: { size?: "sm" | "md"; fullWidth?: boolean }) => ({
|
||||||
|
"button-brand": "gap-1",
|
||||||
|
"button-gray": "gap-1",
|
||||||
|
"button-border": cx("gap-1 rounded-[10px] bg-secondary_alt p-1 ring-1 ring-secondary ring-inset", size === "md" && "rounded-xl p-1.5"),
|
||||||
|
"button-minimal": "gap-0.5 rounded-lg bg-secondary_alt ring-1 ring-inset ring-secondary",
|
||||||
|
underline: cx("gap-3", fullWidth && "w-full gap-4"),
|
||||||
|
line: "gap-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
interface TabListComponentProps<T extends object, K extends Orientation> extends Omit<AriaTabListProps<T>, "items"> {
|
||||||
|
/** The size of the tab list. */
|
||||||
|
size?: keyof typeof sizes;
|
||||||
|
/** The type of the tab list. */
|
||||||
|
type?: TabTypeColors<K>;
|
||||||
|
/** The orientation of the tab list. */
|
||||||
|
orientation?: K;
|
||||||
|
/** The items of the tab list. When provided, tabs are rendered automatically via the render function in children. */
|
||||||
|
items?: T[];
|
||||||
|
/** Whether the tab list is full width. */
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabListContext = createContext<Omit<TabListComponentProps<TabComponentProps, Orientation>, "items">>({
|
||||||
|
size: "sm",
|
||||||
|
type: "button-brand",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TabList = <T extends Orientation>({
|
||||||
|
size = "sm",
|
||||||
|
type = "button-brand",
|
||||||
|
orientation: orientationProp,
|
||||||
|
fullWidth,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...otherProps
|
||||||
|
}: TabListComponentProps<TabComponentProps, T>) => {
|
||||||
|
const context = useSlottedContext(TabsContext);
|
||||||
|
|
||||||
|
const orientation = orientationProp ?? context?.orientation ?? "horizontal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabListContext.Provider value={{ size, type, orientation, fullWidth }}>
|
||||||
|
<AriaTabList
|
||||||
|
{...otherProps}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"group flex",
|
||||||
|
|
||||||
|
getHorizontalStyles({
|
||||||
|
size,
|
||||||
|
fullWidth,
|
||||||
|
})[type as HorizontalTypes],
|
||||||
|
|
||||||
|
orientation === "vertical" && "w-max flex-col",
|
||||||
|
|
||||||
|
// Only horizontal tabs with underline type have bottom border
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
type === "underline" &&
|
||||||
|
"relative before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-border-secondary",
|
||||||
|
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children ?? (otherProps.items ? (item) => <Tab {...item}>{item.children}</Tab> : undefined)}
|
||||||
|
</AriaTabList>
|
||||||
|
</TabListContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TabPanel = (props: ComponentPropsWithRef<typeof AriaTabPanel>) => {
|
||||||
|
return (
|
||||||
|
<AriaTabPanel
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"outline-focus-ring focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TabComponentProps extends AriaTabProps {
|
||||||
|
/** The label of the tab. */
|
||||||
|
label?: ReactNode;
|
||||||
|
/** The children of the tab. */
|
||||||
|
children?: ReactNode | ((props: AriaTabRenderProps) => ReactNode);
|
||||||
|
/** Icon component or element to show before the text */
|
||||||
|
icon?: FC<{ className?: string }> | ReactNode;
|
||||||
|
/** The badge displayed next to the label. */
|
||||||
|
badge?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tab = ({ label, children, badge, icon: Icon, className, ...otherProps }: TabComponentProps) => {
|
||||||
|
const { size = "sm", type = "button-brand", fullWidth } = useContext(TabListContext);
|
||||||
|
|
||||||
|
const showPillColorBadge = type === "underline" || type === "line" || type === "button-brand";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaTab
|
||||||
|
{...otherProps}
|
||||||
|
className={(prop) =>
|
||||||
|
cx(
|
||||||
|
"z-10 flex h-max cursor-pointer items-center justify-center gap-2 rounded-md whitespace-nowrap text-quaternary transition duration-100 ease-linear",
|
||||||
|
"group-orientation-vertical:justify-start",
|
||||||
|
fullWidth && "w-full flex-1",
|
||||||
|
sizes[size].base,
|
||||||
|
sizes[size][type],
|
||||||
|
getTabStyles(prop)[type],
|
||||||
|
typeof className === "function" ? className(prop) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
{/* Icon */}
|
||||||
|
{isValidElement(Icon) && Icon}
|
||||||
|
{isReactComponent(Icon) && <Icon data-icon className="transition-inherit-all" />}
|
||||||
|
|
||||||
|
<span className={cx("flex items-center gap-1.5", type !== "line" && "px-0.5")}>
|
||||||
|
{typeof children === "function" ? children(state) : children || label}
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
{badge && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
type={showPillColorBadge ? "pill-color" : "modern"}
|
||||||
|
color={showPillColorBadge && (state.isHovered || state.isSelected) ? "brand" : "gray"}
|
||||||
|
className={cx("hidden transition-inherit-all md:flex", size === "sm" && "-my-px")}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaTab>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tabs = ({ className, ...props }: ComponentPropsWithRef<typeof AriaTabs>) => {
|
||||||
|
return (
|
||||||
|
<AriaTabs
|
||||||
|
keyboardActivation="manual"
|
||||||
|
{...props}
|
||||||
|
className={(state) => cx("flex w-full flex-col", typeof className === "function" ? className(state) : className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Tabs.Panel = TabPanel;
|
||||||
|
Tabs.List = TabList;
|
||||||
|
Tabs.Item = Tab;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { Avatar, type AvatarProps } from "./avatar";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: { title: "text-sm ", subtitle: "text-xs" },
|
||||||
|
md: { title: "text-sm ", subtitle: "text-sm" },
|
||||||
|
lg: { title: "text-md ", subtitle: "text-md" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarLabelGroupProps extends AvatarProps {
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
rounded?: boolean;
|
||||||
|
title: string | ReactNode;
|
||||||
|
subtitle: string | ReactNode;
|
||||||
|
avatarClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarLabelGroup = ({ title, subtitle, className, rounded, avatarClassName, ...props }: AvatarLabelGroupProps) => {
|
||||||
|
return (
|
||||||
|
<figure className={cx("group flex min-w-0 flex-1 items-center gap-2", className)}>
|
||||||
|
<Avatar border rounded={rounded} className={avatarClassName} {...props} />
|
||||||
|
<figcaption className="min-w-0 flex-1">
|
||||||
|
<p className={cx("font-semibold text-primary", styles[props.size].title)}>{title}</p>
|
||||||
|
<p className={cx("truncate text-tertiary", styles[props.size].subtitle)}>{subtitle}</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { User01 } from "@untitledui/icons";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { type AvatarProps } from "./avatar";
|
||||||
|
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: {
|
||||||
|
root: "size-18 p-0.75",
|
||||||
|
rootWithPlaceholder: "p-1",
|
||||||
|
content: "outline-[0.5px] -outline-offset-[0.5px] before:border",
|
||||||
|
icon: "size-9",
|
||||||
|
initials: "text-display-sm font-semibold",
|
||||||
|
badge: "bottom-0.5 right-0.5",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "size-24 p-1",
|
||||||
|
rootWithPlaceholder: "p-1.25",
|
||||||
|
content: "shadow-xl outline-[0.75px] -outline-offset-[0.75px] before:border-[1.5px]",
|
||||||
|
icon: "size-12",
|
||||||
|
initials: "text-display-md font-semibold",
|
||||||
|
badge: "bottom-1 right-1",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "size-40 p-1.5",
|
||||||
|
rootWithPlaceholder: "p-1.75",
|
||||||
|
content: "shadow-2xl outline-[0.75px] -outline-offset-[0.75px] before:border-[1.5px]",
|
||||||
|
icon: "size-20",
|
||||||
|
initials: "text-display-xl font-semibold",
|
||||||
|
badge: "bottom-2 right-2",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickSizeMap = {
|
||||||
|
sm: "2xl",
|
||||||
|
md: "3xl",
|
||||||
|
lg: "4xl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface AvatarProfilePhotoProps extends AvatarProps {
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarProfilePhoto = ({
|
||||||
|
size = "md",
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
initials,
|
||||||
|
placeholder,
|
||||||
|
placeholderIcon: PlaceholderIcon,
|
||||||
|
verified,
|
||||||
|
badge,
|
||||||
|
status,
|
||||||
|
className,
|
||||||
|
}: AvatarProfilePhotoProps) => {
|
||||||
|
const [isFailed, setIsFailed] = useState(false);
|
||||||
|
|
||||||
|
const renderMainContent = () => {
|
||||||
|
if (src && !isFailed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative size-full overflow-hidden rounded-full outline-black/16 before:absolute before:inset-0 before:rounded-full before:border-white/32 before:mask-[linear-gradient(to_bottom,black_0%,transparent_25%,transparent_75%,black_100%)]",
|
||||||
|
styles[size].content,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src={src} alt={alt} onError={() => setIsFailed(true)} className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initials) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-tertiary ring-1 ring-secondary_alt outline-transparent before:hidden",
|
||||||
|
styles[size].content,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cx("text-quaternary", styles[size].initials)}>{initials}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PlaceholderIcon) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-tertiary ring-1 ring-secondary_alt outline-transparent before:hidden",
|
||||||
|
styles[size].content,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlaceholderIcon className={cx("text-fg-quaternary", styles[size].icon)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-tertiary ring-1 ring-secondary_alt outline-transparent before:hidden",
|
||||||
|
styles[size].content,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{placeholder || <User01 className={cx("text-fg-quaternary", styles[size].icon)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBadgeContent = () => {
|
||||||
|
if (status) {
|
||||||
|
return <AvatarOnlineIndicator status={status} size={tickSizeMap[size]} className={styles[size].badge} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
return <VerifiedTick size={tickSizeMap[size]} className={cx("absolute", styles[size].badge)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative flex shrink-0 items-center justify-center rounded-full bg-primary ring-1 ring-secondary_alt",
|
||||||
|
styles[size].root,
|
||||||
|
(!src || isFailed) && styles[size].rootWithPlaceholder,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderMainContent()}
|
||||||
|
{renderBadgeContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { type FC, type ReactNode, useState } from "react";
|
||||||
|
import { User01 } from "@untitledui/icons";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";
|
||||||
|
import { AvatarCount } from "./base-components/avatar-count";
|
||||||
|
|
||||||
|
export interface AvatarProps {
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* The class name for the main child of the avatar.
|
||||||
|
*/
|
||||||
|
contentClassName?: string;
|
||||||
|
src?: string | null;
|
||||||
|
alt?: string;
|
||||||
|
/**
|
||||||
|
* Display an inner contrast border around the avatar image.
|
||||||
|
*/
|
||||||
|
contrastBorder?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the avatar should be rounded.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
rounded?: boolean;
|
||||||
|
/**
|
||||||
|
* Display an outer border around the avatar.
|
||||||
|
*/
|
||||||
|
border?: boolean;
|
||||||
|
/**
|
||||||
|
* Display a badge (i.e. company logo).
|
||||||
|
*/
|
||||||
|
badge?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Display a status indicator.
|
||||||
|
*/
|
||||||
|
status?: "online" | "offline";
|
||||||
|
/**
|
||||||
|
* Display a verified tick icon.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
verified?: boolean;
|
||||||
|
/**
|
||||||
|
* Display a count badge.
|
||||||
|
*/
|
||||||
|
count?: number;
|
||||||
|
/**
|
||||||
|
* The initials of the user to display if no image is available.
|
||||||
|
*/
|
||||||
|
initials?: string;
|
||||||
|
/**
|
||||||
|
* An icon to display if no image is available.
|
||||||
|
*/
|
||||||
|
placeholderIcon?: FC<{ className?: string }>;
|
||||||
|
/**
|
||||||
|
* A placeholder to display if no image is available.
|
||||||
|
*/
|
||||||
|
placeholder?: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the avatar should show a focus ring when the parent group is in focus.
|
||||||
|
* For example, when the avatar is wrapped inside a link.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
focusable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
xs: { root: "size-6", rootWithBorder: "p-px", initials: "text-xs font-semibold", icon: "size-4" },
|
||||||
|
sm: { root: "size-8", rootWithBorder: "p-px", initials: "text-sm font-semibold", icon: "size-5" },
|
||||||
|
md: { root: "size-10", rootWithBorder: "p-px", initials: "text-md font-semibold", icon: "size-6" },
|
||||||
|
lg: { root: "size-12", rootWithBorder: "p-[1.5px]", initials: "text-lg font-semibold", icon: "size-7" },
|
||||||
|
xl: { root: "size-14", rootWithBorder: "p-0.5", initials: "text-xl font-semibold", icon: "size-8" },
|
||||||
|
"2xl": { root: "size-16", rootWithBorder: "p-0.5", initials: "text-display-xs font-semibold", icon: "size-8" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Avatar = ({
|
||||||
|
size = "md",
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
initials,
|
||||||
|
placeholder,
|
||||||
|
placeholderIcon: PlaceholderIcon,
|
||||||
|
border,
|
||||||
|
badge,
|
||||||
|
status,
|
||||||
|
verified,
|
||||||
|
count,
|
||||||
|
focusable = false,
|
||||||
|
rounded = true,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
}: AvatarProps) => {
|
||||||
|
const [isFailed, setIsFailed] = useState(false);
|
||||||
|
|
||||||
|
const canShowImage = src && !isFailed;
|
||||||
|
|
||||||
|
const renderMainContent = () => {
|
||||||
|
if (canShowImage) {
|
||||||
|
return <img data-avatar-img className="size-full object-cover" src={src} alt={alt} onError={() => setIsFailed(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initials) {
|
||||||
|
return <span className={cx("text-quaternary", styles[size].initials)}>{initials}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PlaceholderIcon) {
|
||||||
|
return <PlaceholderIcon className={cx("text-fg-quaternary", styles[size].icon)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder || <User01 className={cx("text-fg-quaternary", styles[size].icon)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBadgeContent = () => {
|
||||||
|
if (status) {
|
||||||
|
return <AvatarOnlineIndicator status={status} size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
return <VerifiedTick size={size} className={cx("absolute right-0 bottom-0", size === "xs" && "-right-px -bottom-px")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count) {
|
||||||
|
return <AvatarCount count={count} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-avatar
|
||||||
|
className={cx(
|
||||||
|
"relative inline-flex shrink-0 rounded-[7px]",
|
||||||
|
rounded && "rounded-full",
|
||||||
|
// Focus styles
|
||||||
|
focusable && "outline-transparent group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-focus-ring",
|
||||||
|
border && "ring-1 ring-secondary_alt",
|
||||||
|
border && styles[size].rootWithBorder,
|
||||||
|
styles[size].root,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative inline-flex size-full shrink-0 items-center justify-center overflow-hidden rounded-md bg-tertiary outline-[0.5px] -outline-offset-[0.5px] outline-black/16 before:inset-[0.5px]",
|
||||||
|
rounded && "rounded-full",
|
||||||
|
canShowImage &&
|
||||||
|
size !== "xs" &&
|
||||||
|
"before:absolute before:inset-0 before:rounded-[inherit] before:border before:border-white/32 before:mask-[linear-gradient(to_bottom,black_0%,transparent_25%,transparent_75%,black_100%)]",
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderMainContent()}
|
||||||
|
</div>
|
||||||
|
{renderBadgeContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Plus } from "@untitledui/icons";
|
||||||
|
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
|
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: { root: "size-6", icon: "size-4" },
|
||||||
|
sm: { root: "size-8", icon: "size-4" },
|
||||||
|
md: { root: "size-10", icon: "size-5" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarAddButtonProps extends AriaButtonProps {
|
||||||
|
size: "xs" | "sm" | "md";
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarAddButton = ({ size, className, title = "Add user", ...props }: AvatarAddButtonProps) => (
|
||||||
|
<AriaTooltip title={title}>
|
||||||
|
<AriaTooltipTrigger
|
||||||
|
{...props}
|
||||||
|
aria-label={title}
|
||||||
|
className={cx(
|
||||||
|
"flex cursor-pointer items-center justify-center rounded-full border border-dashed border-primary bg-primary text-fg-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50",
|
||||||
|
sizes[size].root,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className={cx("text-current transition-inherit-all", sizes[size].icon)} />
|
||||||
|
</AriaTooltipTrigger>
|
||||||
|
</AriaTooltip>
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: "size-2",
|
||||||
|
sm: "size-3",
|
||||||
|
md: "size-3.5",
|
||||||
|
lg: "size-4",
|
||||||
|
xl: "size-4.5",
|
||||||
|
"2xl": "size-5 ring-[1.67px]",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarCompanyIconProps {
|
||||||
|
size: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarCompanyIcon = ({ size, src, alt }: AvatarCompanyIconProps) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={cx("absolute -right-0.5 -bottom-0.5 rounded-full bg-brand-50 object-cover ring-[1.5px] ring-bg-primary", sizes[size])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface AvatarCountProps {
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarCount = ({ count, className }: AvatarCountProps) => (
|
||||||
|
<div className={cx("absolute right-0 bottom-0 p-px", className)}>
|
||||||
|
<div className="flex size-3.5 items-center justify-center rounded-full bg-fg-error-primary text-center text-[10px] leading-[13px] font-bold text-white">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: "size-1.5",
|
||||||
|
sm: "size-2",
|
||||||
|
md: "size-2.5",
|
||||||
|
lg: "size-3",
|
||||||
|
xl: "size-3.5",
|
||||||
|
"2xl": "size-4",
|
||||||
|
"3xl": "size-4.5",
|
||||||
|
"4xl": "size-5",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarOnlineIndicatorProps {
|
||||||
|
size: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||||
|
status: "online" | "offline";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarOnlineIndicator = ({ size, status, className }: AvatarOnlineIndicatorProps) => (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"absolute right-0 bottom-0 flex justify-center rounded-full ring-[1.5px] ring-bg-primary",
|
||||||
|
status === "online" ? "bg-fg-success-secondary" : "bg-utility-neutral-300",
|
||||||
|
sizes[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(43.75% 43.75% at 50% 28.75%, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.00) 74.66%, rgba(255, 255, 255, 0.18) 100%), radial-gradient(75% 75% at 50% 0%, rgba(255, 255, 255, 0.00) 0%, rgba(255, 255, 255, 0.00) 50%, rgba(255, 255, 255, 0.08) 99%, rgba(255, 255, 255, 0.00) 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Reflection */}
|
||||||
|
<svg viewBox="0 0 7.2 2.85" fill="none" className="mt-[10%] h-[20%] w-[60%]">
|
||||||
|
<path
|
||||||
|
d="M7.2 1.83107C7.2 2.84235 5.58823 2.19729 3.6 2.19729C1.61177 2.19729 0 2.84235 0 1.83107C0 0.8198 1.61177 0 3.6 0C5.58823 0 7.2 0.8198 7.2 1.83107Z"
|
||||||
|
fill="url(#reflection-gradient)"
|
||||||
|
fillOpacity="0.4"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="reflection-gradient" x1="3.6" y1="0" x2="3.6" y2="2.4" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="white" />
|
||||||
|
<stop offset="1" stopColor="white" stopOpacity="0.1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./avatar-add-button";
|
||||||
|
export * from "./avatar-company-icon";
|
||||||
|
export * from "./avatar-online-indicator";
|
||||||
|
export * from "./verified-tick";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: "size-2.5",
|
||||||
|
sm: "size-3",
|
||||||
|
md: "size-3.5",
|
||||||
|
lg: "size-4",
|
||||||
|
xl: "size-4.5",
|
||||||
|
"2xl": "size-5",
|
||||||
|
"3xl": "size-6",
|
||||||
|
"4xl": "size-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VerifiedTickProps {
|
||||||
|
size: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerifiedTick = ({ size, className }: VerifiedTickProps) => (
|
||||||
|
<svg className={cx("z-10 text-utility-blue-500", sizes[size], className)} viewBox="0 0 10 10" fill="none">
|
||||||
|
<path
|
||||||
|
d="M7.72237 1.77098C7.81734 2.00068 7.99965 2.18326 8.2292 2.27858L9.03413 2.61199C9.26384 2.70714 9.44635 2.88965 9.5415 3.11936C9.63665 3.34908 9.63665 3.60718 9.5415 3.83689L9.20833 4.64125C9.11313 4.87106 9.113 5.12943 9.20863 5.35913L9.54122 6.16325C9.58839 6.27702 9.61268 6.39897 9.6127 6.52214C9.61272 6.6453 9.58847 6.76726 9.54134 6.88105C9.4942 6.99484 9.42511 7.09823 9.33801 7.18531C9.2509 7.27238 9.14749 7.34144 9.03369 7.38854L8.22934 7.72171C7.99964 7.81669 7.81706 7.99899 7.72174 8.22855L7.38833 9.03348C7.29318 9.26319 7.11067 9.4457 6.88096 9.54085C6.65124 9.636 6.39314 9.636 6.16343 9.54085L5.35907 9.20767C5.12935 9.11276 4.87134 9.11295 4.64177 9.20821L3.83684 9.54115C3.60725 9.63608 3.34937 9.636 3.11984 9.54092C2.89032 9.44585 2.70791 9.26356 2.6127 9.03409L2.27918 8.22892C2.18421 7.99923 2.0019 7.81665 1.77235 7.72133L0.967421 7.38792C0.737807 7.29281 0.555355 7.11041 0.460169 6.88083C0.364983 6.65125 0.364854 6.39327 0.45981 6.16359L0.792984 5.35924C0.8879 5.12952 0.887707 4.87151 0.792445 4.64193L0.459749 3.83642C0.41258 3.72265 0.388291 3.60069 0.388272 3.47753C0.388252 3.35436 0.412501 3.2324 0.459634 3.11861C0.506767 3.00482 0.57586 2.90144 0.662965 2.81436C0.75007 2.72728 0.853479 2.65822 0.967283 2.61113L1.77164 2.27795C2.00113 2.18306 2.1836 2.00099 2.27899 1.7717L2.6124 0.966768C2.70755 0.737054 2.89006 0.554547 3.11978 0.459397C3.34949 0.364246 3.60759 0.364246 3.83731 0.459397L4.64166 0.792571C4.87138 0.887487 5.12939 0.887293 5.35897 0.792031L6.16424 0.459913C6.39392 0.364816 6.65197 0.364836 6.88164 0.459968C7.11131 0.555099 7.29379 0.737554 7.38895 0.967208L7.72247 1.77238L7.72237 1.77098Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M6.95829 3.68932C7.02509 3.58439 7.04747 3.45723 7.02051 3.3358C6.99356 3.21437 6.91946 3.10862 6.81454 3.04182C6.70961 2.97502 6.58245 2.95264 6.46102 2.97959C6.33959 3.00655 6.23384 3.08064 6.16704 3.18557L4.33141 6.06995L3.49141 5.01995C3.41375 4.92281 3.30069 4.8605 3.17709 4.84673C3.05349 4.83296 2.92949 4.86885 2.83235 4.94651C2.73522 5.02417 2.67291 5.13723 2.65914 5.26083C2.64536 5.38443 2.68125 5.50843 2.75891 5.60557L4.00891 7.16807C4.0555 7.22638 4.11533 7.27271 4.18344 7.30323C4.25154 7.33375 4.32595 7.34757 4.40047 7.34353C4.47499 7.3395 4.54747 7.31773 4.61188 7.28004C4.67629 7.24234 4.73077 7.18981 4.77079 7.12682L6.95829 3.68932Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the initials from a full name.
|
||||||
|
*
|
||||||
|
* @param name - The full name from which to extract initials.
|
||||||
|
* @returns The initials of the provided name. If the name contains only one word,
|
||||||
|
* it returns the first character of that word. If the name contains two words,
|
||||||
|
* it returns the first character of each word.
|
||||||
|
*/
|
||||||
|
export const getInitials = (name: string) => {
|
||||||
|
const [firstName, lastName] = name.split(" ");
|
||||||
|
return firstName.charAt(0) + (lastName ? lastName.charAt(0) : "");
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { ArrowRight } from "@untitledui/icons";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
type Size = "md" | "lg";
|
||||||
|
type Color = "brand" | "warning" | "error" | "gray" | "success";
|
||||||
|
type Theme = "light" | "modern";
|
||||||
|
type Align = "leading" | "trailing";
|
||||||
|
|
||||||
|
const baseClasses: Record<Theme, { root?: string; addon?: string; icon?: string }> = {
|
||||||
|
light: {
|
||||||
|
root: "rounded-full ring-1 ring-inset",
|
||||||
|
addon: "rounded-full ring-1 ring-inset",
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
root: "rounded-[10px] bg-primary text-secondary shadow-xs ring-1 ring-inset ring-primary hover:bg-secondary",
|
||||||
|
addon: "flex items-center rounded-md bg-primary shadow-xs ring-1 ring-inset ring-primary",
|
||||||
|
icon: "text-utility-neutral-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSizeClasses = (
|
||||||
|
theme?: Theme,
|
||||||
|
text?: boolean,
|
||||||
|
icon?: boolean,
|
||||||
|
): Record<Align, Record<Size, { root?: string; addon?: string; icon?: string; dot?: string }>> => ({
|
||||||
|
leading: {
|
||||||
|
md: {
|
||||||
|
root: cx("py-1 pr-2 pl-1 text-xs font-medium", !text && !icon && "pr-1"),
|
||||||
|
addon: cx("px-2 py-0.5", theme === "modern" && "gap-1 px-1.5", text && "mr-2"),
|
||||||
|
icon: "ml-1 size-4",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: cx("py-1 pr-2 pl-1 text-sm font-medium", !text && !icon && "pr-1"),
|
||||||
|
addon: cx("px-2.5 py-0.5", theme === "modern" && "gap-1.5 px-2", text && "mr-2"),
|
||||||
|
icon: "ml-1 size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trailing: {
|
||||||
|
md: {
|
||||||
|
root: cx("py-1 pr-1 pl-3 text-xs font-medium", theme === "modern" && "pl-2.5"),
|
||||||
|
addon: cx("py-0.5 pr-1.5 pl-2", theme === "modern" && "pr-1.5 pl-2", text && "ml-2"),
|
||||||
|
icon: "ml-0.5 size-3 stroke-[3px]",
|
||||||
|
dot: "mr-1.5",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "py-1 pr-1 pl-3 text-sm font-medium",
|
||||||
|
addon: cx("py-0.5 pr-2 pl-2.5", theme === "modern" && "pr-1.5 pl-2", text && "ml-2"),
|
||||||
|
icon: "ml-1 size-3 stroke-[3px]",
|
||||||
|
dot: "mr-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorClasses: Record<Theme, Record<Color, { root?: string; addon?: string; icon?: string; dot?: string }>> = sortCx({
|
||||||
|
light: {
|
||||||
|
brand: {
|
||||||
|
root: "bg-utility-brand-50 text-utility-brand-700 ring-utility-brand-200 hover:bg-utility-brand-100",
|
||||||
|
addon: "bg-primary text-current ring-utility-brand-200",
|
||||||
|
icon: "text-utility-brand-500",
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
root: "bg-utility-neutral-50 text-utility-neutral-700 ring-utility-neutral-200 hover:bg-utility-neutral-100",
|
||||||
|
addon: "bg-primary text-current ring-utility-neutral-200",
|
||||||
|
icon: "text-utility-neutral-500",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
root: "bg-utility-red-50 text-utility-red-700 ring-utility-red-200 hover:bg-utility-red-100",
|
||||||
|
addon: "bg-primary text-current ring-utility-red-200",
|
||||||
|
icon: "text-utility-red-500",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
root: "bg-utility-yellow-50 text-utility-yellow-700 ring-utility-yellow-200 hover:bg-utility-yellow-100",
|
||||||
|
addon: "bg-primary text-current ring-utility-yellow-200",
|
||||||
|
icon: "text-utility-yellow-500",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
root: "bg-utility-green-50 text-utility-green-700 ring-utility-green-200 hover:bg-utility-green-100",
|
||||||
|
addon: "bg-primary text-current ring-utility-green-200",
|
||||||
|
icon: "text-utility-green-500",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
brand: {
|
||||||
|
dot: "bg-utility-brand-500 outline-3 -outline-offset-1 outline-utility-brand-100",
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
dot: "bg-utility-neutral-500 outline-3 -outline-offset-1 outline-utility-neutral-100",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
dot: "bg-utility-red-500 outline-3 -outline-offset-1 outline-utility-red-100",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
dot: "bg-utility-yellow-500 outline-3 -outline-offset-1 outline-utility-yellow-100",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
dot: "bg-utility-green-500 outline-3 -outline-offset-1 outline-utility-green-100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BadgeGroupProps {
|
||||||
|
children?: string | ReactNode;
|
||||||
|
addonText: string;
|
||||||
|
size?: Size;
|
||||||
|
color: Color;
|
||||||
|
theme?: Theme;
|
||||||
|
/**
|
||||||
|
* Alignment of the badge addon element.
|
||||||
|
*/
|
||||||
|
align?: Align;
|
||||||
|
iconTrailing?: FC<{ className?: string }> | ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeGroup = ({
|
||||||
|
children,
|
||||||
|
addonText,
|
||||||
|
size = "md",
|
||||||
|
color = "brand",
|
||||||
|
theme = "light",
|
||||||
|
align = "leading",
|
||||||
|
className,
|
||||||
|
iconTrailing: IconTrailing = ArrowRight,
|
||||||
|
}: BadgeGroupProps) => {
|
||||||
|
const colors = colorClasses[theme][color];
|
||||||
|
const sizes = getSizeClasses(theme, !!children, !!IconTrailing)[align][size];
|
||||||
|
|
||||||
|
const rootClasses = cx(
|
||||||
|
"inline-flex w-max cursor-pointer items-center transition duration-100 ease-linear",
|
||||||
|
baseClasses[theme].root,
|
||||||
|
sizes.root,
|
||||||
|
colors.root,
|
||||||
|
className,
|
||||||
|
);
|
||||||
|
const addonClasses = cx("inline-flex items-center", baseClasses[theme].addon, sizes.addon, colors.addon);
|
||||||
|
const dotClasses = cx("inline-block size-2 shrink-0 rounded-full", sizes.dot, colors.dot);
|
||||||
|
const iconClasses = cx(baseClasses[theme].icon, sizes.icon, colors.icon);
|
||||||
|
|
||||||
|
if (align === "trailing") {
|
||||||
|
return (
|
||||||
|
<div className={rootClasses}>
|
||||||
|
{theme === "modern" && <span className={dotClasses} />}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<span className={addonClasses}>
|
||||||
|
{addonText}
|
||||||
|
|
||||||
|
{/* Trailing icon */}
|
||||||
|
{isReactComponent(IconTrailing) && <IconTrailing className={iconClasses} />}
|
||||||
|
{isValidElement(IconTrailing) && IconTrailing}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClasses}>
|
||||||
|
<span className={addonClasses}>
|
||||||
|
{theme === "modern" && <span className={dotClasses} />}
|
||||||
|
{addonText}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Trailing icon */}
|
||||||
|
{isReactComponent(IconTrailing) && <IconTrailing className={iconClasses} />}
|
||||||
|
{isValidElement(IconTrailing) && IconTrailing}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
export type IconComponentType = React.FunctionComponent<{ className?: string; strokeWidth?: string | number }>;
|
||||||
|
|
||||||
|
export type Sizes = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export type BadgeColors = "gray" | "brand" | "error" | "warning" | "success" | "slate" | "sky" | "blue" | "indigo" | "purple" | "pink" | "orange";
|
||||||
|
|
||||||
|
export type FlagTypes =
|
||||||
|
| "AD"
|
||||||
|
| "AE"
|
||||||
|
| "AF"
|
||||||
|
| "AG"
|
||||||
|
| "AI"
|
||||||
|
| "AL"
|
||||||
|
| "AM"
|
||||||
|
| "AO"
|
||||||
|
| "AR"
|
||||||
|
| "AS"
|
||||||
|
| "AT"
|
||||||
|
| "AU"
|
||||||
|
| "AW"
|
||||||
|
| "AX"
|
||||||
|
| "AZ"
|
||||||
|
| "BA"
|
||||||
|
| "BB"
|
||||||
|
| "BD"
|
||||||
|
| "BE"
|
||||||
|
| "BF"
|
||||||
|
| "BG"
|
||||||
|
| "BH"
|
||||||
|
| "BI"
|
||||||
|
| "BJ"
|
||||||
|
| "BL"
|
||||||
|
| "BM"
|
||||||
|
| "BN"
|
||||||
|
| "BO"
|
||||||
|
| "BQ-1"
|
||||||
|
| "BQ-2"
|
||||||
|
| "BQ"
|
||||||
|
| "BR"
|
||||||
|
| "BS"
|
||||||
|
| "BT"
|
||||||
|
| "BW"
|
||||||
|
| "BY"
|
||||||
|
| "BZ"
|
||||||
|
| "CA"
|
||||||
|
| "CC"
|
||||||
|
| "CD-1"
|
||||||
|
| "CD"
|
||||||
|
| "CF"
|
||||||
|
| "CH"
|
||||||
|
| "CK"
|
||||||
|
| "CL"
|
||||||
|
| "CM"
|
||||||
|
| "CN"
|
||||||
|
| "CO"
|
||||||
|
| "CR"
|
||||||
|
| "CU"
|
||||||
|
| "CW"
|
||||||
|
| "CX"
|
||||||
|
| "CY"
|
||||||
|
| "CZ"
|
||||||
|
| "DE"
|
||||||
|
| "DJ"
|
||||||
|
| "DK"
|
||||||
|
| "DM"
|
||||||
|
| "DO"
|
||||||
|
| "DS"
|
||||||
|
| "DZ"
|
||||||
|
| "earth"
|
||||||
|
| "EC"
|
||||||
|
| "EE"
|
||||||
|
| "EG"
|
||||||
|
| "EH"
|
||||||
|
| "ER"
|
||||||
|
| "ES"
|
||||||
|
| "ET"
|
||||||
|
| "FI"
|
||||||
|
| "FJ"
|
||||||
|
| "FK"
|
||||||
|
| "FM"
|
||||||
|
| "FO"
|
||||||
|
| "FR"
|
||||||
|
| "GA"
|
||||||
|
| "GB-2"
|
||||||
|
| "GB"
|
||||||
|
| "GD"
|
||||||
|
| "GE"
|
||||||
|
| "GG"
|
||||||
|
| "GH"
|
||||||
|
| "GI"
|
||||||
|
| "GL"
|
||||||
|
| "GM"
|
||||||
|
| "GN"
|
||||||
|
| "GQ"
|
||||||
|
| "GR"
|
||||||
|
| "GT"
|
||||||
|
| "GU"
|
||||||
|
| "GW"
|
||||||
|
| "GY"
|
||||||
|
| "HK"
|
||||||
|
| "HN"
|
||||||
|
| "HR"
|
||||||
|
| "HT"
|
||||||
|
| "HU"
|
||||||
|
| "ID"
|
||||||
|
| "IE"
|
||||||
|
| "IL"
|
||||||
|
| "IM"
|
||||||
|
| "IN"
|
||||||
|
| "IO"
|
||||||
|
| "IQ"
|
||||||
|
| "IR"
|
||||||
|
| "IS"
|
||||||
|
| "IT"
|
||||||
|
| "JE"
|
||||||
|
| "JM"
|
||||||
|
| "JO"
|
||||||
|
| "JP"
|
||||||
|
| "KE"
|
||||||
|
| "KG"
|
||||||
|
| "KH"
|
||||||
|
| "KI"
|
||||||
|
| "KM"
|
||||||
|
| "KN"
|
||||||
|
| "KP"
|
||||||
|
| "KR"
|
||||||
|
| "KW"
|
||||||
|
| "KY"
|
||||||
|
| "KZ"
|
||||||
|
| "LA"
|
||||||
|
| "LB"
|
||||||
|
| "LC"
|
||||||
|
| "LI"
|
||||||
|
| "LK"
|
||||||
|
| "LR"
|
||||||
|
| "LS"
|
||||||
|
| "LT"
|
||||||
|
| "LU"
|
||||||
|
| "LV"
|
||||||
|
| "LY"
|
||||||
|
| "MA"
|
||||||
|
| "MC"
|
||||||
|
| "MD"
|
||||||
|
| "ME"
|
||||||
|
| "MG"
|
||||||
|
| "MH"
|
||||||
|
| "MK"
|
||||||
|
| "ML"
|
||||||
|
| "MM"
|
||||||
|
| "MN"
|
||||||
|
| "MO"
|
||||||
|
| "MP"
|
||||||
|
| "MQ"
|
||||||
|
| "MR"
|
||||||
|
| "MS"
|
||||||
|
| "MT"
|
||||||
|
| "MU"
|
||||||
|
| "MV"
|
||||||
|
| "MW"
|
||||||
|
| "MX"
|
||||||
|
| "MY"
|
||||||
|
| "MZ"
|
||||||
|
| "NA"
|
||||||
|
| "NE"
|
||||||
|
| "NF"
|
||||||
|
| "NG"
|
||||||
|
| "NI"
|
||||||
|
| "NL"
|
||||||
|
| "NO"
|
||||||
|
| "NP"
|
||||||
|
| "NR"
|
||||||
|
| "NU"
|
||||||
|
| "NZ"
|
||||||
|
| "OM"
|
||||||
|
| "PA"
|
||||||
|
| "PE"
|
||||||
|
| "PF"
|
||||||
|
| "PG"
|
||||||
|
| "PH"
|
||||||
|
| "PK"
|
||||||
|
| "PL"
|
||||||
|
| "PM"
|
||||||
|
| "PN"
|
||||||
|
| "PR"
|
||||||
|
| "PT"
|
||||||
|
| "PW"
|
||||||
|
| "PY"
|
||||||
|
| "QA"
|
||||||
|
| "RE"
|
||||||
|
| "RO"
|
||||||
|
| "RS"
|
||||||
|
| "RU"
|
||||||
|
| "RW"
|
||||||
|
| "SA"
|
||||||
|
| "SB"
|
||||||
|
| "SC"
|
||||||
|
| "SD"
|
||||||
|
| "SE"
|
||||||
|
| "SG"
|
||||||
|
| "SH"
|
||||||
|
| "SI"
|
||||||
|
| "SJ"
|
||||||
|
| "SK"
|
||||||
|
| "SL"
|
||||||
|
| "SM"
|
||||||
|
| "SN"
|
||||||
|
| "SO"
|
||||||
|
| "SR"
|
||||||
|
| "SS"
|
||||||
|
| "ST"
|
||||||
|
| "SV"
|
||||||
|
| "SX"
|
||||||
|
| "SY"
|
||||||
|
| "SZ"
|
||||||
|
| "TC"
|
||||||
|
| "TD"
|
||||||
|
| "TF"
|
||||||
|
| "TG"
|
||||||
|
| "TH"
|
||||||
|
| "TJ"
|
||||||
|
| "TK"
|
||||||
|
| "TL"
|
||||||
|
| "TM"
|
||||||
|
| "TN"
|
||||||
|
| "TO"
|
||||||
|
| "TR"
|
||||||
|
| "TT"
|
||||||
|
| "TV"
|
||||||
|
| "TZ"
|
||||||
|
| "UA"
|
||||||
|
| "UG"
|
||||||
|
| "UM"
|
||||||
|
| "US"
|
||||||
|
| "UY"
|
||||||
|
| "UZ"
|
||||||
|
| "VA"
|
||||||
|
| "VC"
|
||||||
|
| "VE"
|
||||||
|
| "VG"
|
||||||
|
| "VI"
|
||||||
|
| "VN"
|
||||||
|
| "VU"
|
||||||
|
| "WF"
|
||||||
|
| "WS"
|
||||||
|
| "YE"
|
||||||
|
| "YT"
|
||||||
|
| "ZA"
|
||||||
|
| "ZM"
|
||||||
|
| "ZW";
|
||||||
|
|
||||||
|
export type ExtractColorKeys<T> = T extends { styles: infer C } ? keyof C : never;
|
||||||
|
export type ExtractBadgeKeys<T> = keyof T;
|
||||||
|
export type BadgeTypeToColorMap<T> = {
|
||||||
|
[K in ExtractBadgeKeys<T>]: ExtractColorKeys<T[K]>;
|
||||||
|
};
|
||||||
|
export type BadgeTypeColors<T> = ExtractColorKeys<T[keyof T]>;
|
||||||
|
|
||||||
|
export const badgeTypes = {
|
||||||
|
pillColor: "pill-color",
|
||||||
|
badgeColor: "color",
|
||||||
|
badgeModern: "modern",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BadgeTypes = (typeof badgeTypes)[keyof typeof badgeTypes];
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import type { MouseEventHandler, ReactNode } from "react";
|
||||||
|
import { X as CloseX } from "@untitledui/icons";
|
||||||
|
import { Dot } from "@/components/foundations/dot-icon";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import type { BadgeColors, BadgeTypeToColorMap, BadgeTypes, FlagTypes, IconComponentType, Sizes } from "./badge-types";
|
||||||
|
import { badgeTypes } from "./badge-types";
|
||||||
|
|
||||||
|
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
||||||
|
gray: {
|
||||||
|
root: "bg-utility-neutral-50 text-utility-neutral-700 ring-utility-neutral-200",
|
||||||
|
addon: "text-utility-neutral-500",
|
||||||
|
addonButton: "hover:bg-utility-neutral-100 text-utility-neutral-400 hover:text-utility-neutral-500",
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
root: "bg-utility-brand-50 text-utility-brand-700 ring-utility-brand-200",
|
||||||
|
addon: "text-utility-brand-500",
|
||||||
|
addonButton: "hover:bg-utility-brand-100 text-utility-brand-400 hover:text-utility-brand-500",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
root: "bg-utility-red-50 text-utility-red-700 ring-utility-red-200",
|
||||||
|
addon: "text-utility-red-500",
|
||||||
|
addonButton: "hover:bg-utility-red-100 text-utility-red-400 hover:text-utility-red-500",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
root: "bg-utility-yellow-50 text-utility-yellow-700 ring-utility-yellow-200",
|
||||||
|
addon: "text-utility-yellow-500",
|
||||||
|
addonButton: "hover:bg-utility-yellow-100 text-utility-yellow-400 hover:text-utility-yellow-500",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
root: "bg-utility-green-50 text-utility-green-700 ring-utility-green-200",
|
||||||
|
addon: "text-utility-green-500",
|
||||||
|
addonButton: "hover:bg-utility-green-100 text-utility-green-400 hover:text-utility-green-500",
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
root: "bg-utility-slate-50 text-utility-slate-700 ring-utility-slate-200",
|
||||||
|
addon: "text-utility-slate-500",
|
||||||
|
addonButton: "hover:bg-utility-slate-100 text-utility-slate-400 hover:text-utility-slate-500",
|
||||||
|
},
|
||||||
|
sky: {
|
||||||
|
root: "bg-utility-sky-50 text-utility-sky-700 ring-utility-sky-200",
|
||||||
|
addon: "text-utility-sky-500",
|
||||||
|
addonButton: "hover:bg-utility-sky-100 text-utility-sky-400 hover:text-utility-sky-500",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
root: "bg-utility-blue-50 text-utility-blue-700 ring-utility-blue-200",
|
||||||
|
addon: "text-utility-blue-500",
|
||||||
|
addonButton: "hover:bg-utility-blue-100 text-utility-blue-400 hover:text-utility-blue-500",
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
root: "bg-utility-indigo-50 text-utility-indigo-700 ring-utility-indigo-200",
|
||||||
|
addon: "text-utility-indigo-500",
|
||||||
|
addonButton: "hover:bg-utility-indigo-100 text-utility-indigo-400 hover:text-utility-indigo-500",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
root: "bg-utility-purple-50 text-utility-purple-700 ring-utility-purple-200",
|
||||||
|
addon: "text-utility-purple-500",
|
||||||
|
addonButton: "hover:bg-utility-purple-100 text-utility-purple-400 hover:text-utility-purple-500",
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
root: "bg-utility-pink-50 text-utility-pink-700 ring-utility-pink-200",
|
||||||
|
addon: "text-utility-pink-500",
|
||||||
|
addonButton: "hover:bg-utility-pink-100 text-utility-pink-400 hover:text-utility-pink-500",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
root: "bg-utility-orange-50 text-utility-orange-700 ring-utility-orange-200",
|
||||||
|
addon: "text-utility-orange-500",
|
||||||
|
addonButton: "hover:bg-utility-orange-100 text-utility-orange-400 hover:text-utility-orange-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const addonOnlyColors = Object.fromEntries(Object.entries(filledColors).map(([key, value]) => [key, { root: "", addon: value.addon }])) as Record<
|
||||||
|
BadgeColors,
|
||||||
|
{ root: string; addon: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const withPillTypes = {
|
||||||
|
[badgeTypes.pillColor]: {
|
||||||
|
common: "size-max flex items-center whitespace-nowrap rounded-full ring-1 ring-inset",
|
||||||
|
styles: filledColors,
|
||||||
|
},
|
||||||
|
[badgeTypes.badgeColor]: {
|
||||||
|
common: "size-max flex items-center whitespace-nowrap rounded-md ring-1 ring-inset",
|
||||||
|
styles: filledColors,
|
||||||
|
},
|
||||||
|
[badgeTypes.badgeModern]: {
|
||||||
|
common: "size-max flex items-center whitespace-nowrap rounded-md ring-1 ring-inset shadow-xs",
|
||||||
|
styles: {
|
||||||
|
gray: {
|
||||||
|
root: "bg-primary text-secondary ring-primary",
|
||||||
|
addon: "text-neutral-500",
|
||||||
|
addonButton: "hover:bg-utility-neutral-100 text-utility-neutral-400 hover:text-utility-neutral-500",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const withBadgeTypes = {
|
||||||
|
[badgeTypes.pillColor]: {
|
||||||
|
common: "size-max flex items-center whitespace-nowrap rounded-full ring-1 ring-inset",
|
||||||
|
styles: filledColors,
|
||||||
|
},
|
||||||
|
[badgeTypes.badgeColor]: {
|
||||||
|
common: "size-max flex items-center whitespace-nowrap rounded-md ring-1 ring-inset",
|
||||||
|
styles: filledColors,
|
||||||
|
},
|
||||||
|
[badgeTypes.badgeModern]: {
|
||||||
|
common: "size-max flex items-center whitespace-nowrap rounded-md ring-1 ring-inset bg-primary text-secondary ring-primary shadow-xs",
|
||||||
|
styles: addonOnlyColors,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BadgeColor<T extends BadgeTypes> = BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||||
|
|
||||||
|
interface BadgeProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
color?: BadgeColor<T>;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge = <T extends BadgeTypes>(props: BadgeProps<T>) => {
|
||||||
|
const { type = "pill-color", size = "md", color = "gray", children } = props;
|
||||||
|
const colors = withPillTypes[type];
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: "py-0.5 px-2 text-xs font-medium",
|
||||||
|
md: "py-0.5 px-2.5 text-sm font-medium",
|
||||||
|
lg: "py-1 px-3 text-sm font-medium",
|
||||||
|
};
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: "py-0.5 px-1.5 text-xs font-medium",
|
||||||
|
md: "py-0.5 px-2 text-sm font-medium",
|
||||||
|
lg: "py-1 px-2.5 text-sm font-medium rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <span className={cx(colors.common, sizes[type][size], colors.styles[color].root, props.className)}>{children}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeWithDotProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
color?: BadgeTypeToColorMap<typeof withBadgeTypes>[T];
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeWithDot = <T extends BadgeTypes>(props: BadgeWithDotProps<T>) => {
|
||||||
|
const { size = "md", color = "gray", type = "pill-color", className, children } = props;
|
||||||
|
|
||||||
|
const colors = withBadgeTypes[type];
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: "gap-1 py-0.5 pl-1.5 pr-2 text-xs font-medium",
|
||||||
|
md: "gap-1.5 py-0.5 pl-2 pr-2.5 text-sm font-medium",
|
||||||
|
lg: "gap-1.5 py-1 pl-2.5 pr-3 text-sm font-medium",
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: "gap-1 py-0.5 px-1.5 text-xs font-medium",
|
||||||
|
md: "gap-1.5 py-0.5 px-2 text-sm font-medium",
|
||||||
|
lg: "gap-1.5 py-1 px-2.5 text-sm font-medium rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(colors.common, sizes[type][size], colors.styles[color].root, className)}>
|
||||||
|
<Dot className={colors.styles[color].addon} size="sm" />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeWithIconProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
color?: BadgeTypeToColorMap<typeof withBadgeTypes>[T];
|
||||||
|
iconLeading?: IconComponentType;
|
||||||
|
iconTrailing?: IconComponentType;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeWithIcon = <T extends BadgeTypes>(props: BadgeWithIconProps<T>) => {
|
||||||
|
const { size = "md", color = "gray", type = "pill-color", iconLeading: IconLeading, iconTrailing: IconTrailing, children, className } = props;
|
||||||
|
|
||||||
|
const colors = withBadgeTypes[type];
|
||||||
|
|
||||||
|
const icon = IconLeading ? "leading" : "trailing";
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: {
|
||||||
|
trailing: "gap-0.5 py-0.5 pl-2 pr-1.5 text-xs font-medium",
|
||||||
|
leading: "gap-0.5 py-0.5 pr-2 pl-1.5 text-xs font-medium",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
trailing: "gap-1 py-0.5 pl-2.5 pr-2 text-sm font-medium",
|
||||||
|
leading: "gap-1 py-0.5 pr-2.5 pl-2 text-sm font-medium",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
trailing: "gap-1 py-1 pl-3 pr-2.5 text-sm font-medium",
|
||||||
|
leading: "gap-1 py-1 pr-3 pl-2.5 text-sm font-medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: {
|
||||||
|
trailing: "gap-0.5 py-0.5 pl-2 pr-1.5 text-xs font-medium",
|
||||||
|
leading: "gap-0.5 py-0.5 pr-2 pl-1.5 text-xs font-medium",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
trailing: "gap-1 py-0.5 pl-2 pr-1.5 text-sm font-medium",
|
||||||
|
leading: "gap-1 py-0.5 pr-2 pl-1.5 text-sm font-medium",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
trailing: "gap-1 py-1 pl-2.5 pr-2 text-sm font-medium rounded-lg",
|
||||||
|
leading: "gap-1 py-1 pr-2.5 pl-2 text-sm font-medium rounded-lg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(colors.common, sizes[type][size][icon], colors.styles[color].root, className)}>
|
||||||
|
{IconLeading && <IconLeading className={cx(colors.styles[color].addon, "size-3 stroke-3")} />}
|
||||||
|
{children}
|
||||||
|
{IconTrailing && <IconTrailing className={cx(colors.styles[color].addon, "size-3 stroke-3")} />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeWithFlagProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
flag?: FlagTypes;
|
||||||
|
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeWithFlag = <T extends BadgeTypes>(props: BadgeWithFlagProps<T>) => {
|
||||||
|
const { size = "md", color = "gray", flag = "AU", type = "pill-color", children } = props;
|
||||||
|
|
||||||
|
const colors = withPillTypes[type];
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: "gap-1 py-0.5 pl-0.75 pr-2 text-xs font-medium",
|
||||||
|
md: "gap-1.5 py-0.5 pl-1 pr-2.5 text-sm font-medium",
|
||||||
|
lg: "gap-1.5 py-1 pl-1.5 pr-3 text-sm font-medium",
|
||||||
|
};
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: "gap-1 py-0.5 pl-1 pr-1.5 text-xs font-medium",
|
||||||
|
md: "gap-1.5 py-0.5 pl-1.5 pr-2 text-sm font-medium",
|
||||||
|
lg: "gap-1.5 py-1 pl-2 pr-2.5 text-sm font-medium rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(colors.common, sizes[type][size], colors.styles[color].root)}>
|
||||||
|
<img src={`https://www.untitledui.com/images/flags/${flag}.svg`} className="size-4 max-w-none rounded-full" alt={`${flag} flag`} />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeWithImageProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
imgSrc: string;
|
||||||
|
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeWithImage = <T extends BadgeTypes>(props: BadgeWithImageProps<T>) => {
|
||||||
|
const { size = "md", color = "gray", type = "pill-color", imgSrc, children } = props;
|
||||||
|
|
||||||
|
const colors = withPillTypes[type];
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: "gap-1 py-0.5 pl-0.75 pr-2 text-xs font-medium",
|
||||||
|
md: "gap-1.5 py-0.5 pl-1 pr-2.5 text-sm font-medium",
|
||||||
|
lg: "gap-1.5 py-1 pl-1.5 pr-3 text-sm font-medium",
|
||||||
|
};
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: "gap-1 py-0.5 pl-1 pr-1.5 text-xs font-medium",
|
||||||
|
md: "gap-1.5 py-0.5 pl-1.5 pr-2 text-sm font-medium",
|
||||||
|
lg: "gap-1.5 py-1 pl-2 pr-2.5 text-sm font-medium rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(colors.common, sizes[type][size], colors.styles[color].root)}>
|
||||||
|
<img src={imgSrc} className="size-4 max-w-none rounded-full" alt="Badge image" />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeWithButtonProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
icon?: IconComponentType;
|
||||||
|
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* The label for the button.
|
||||||
|
*/
|
||||||
|
buttonLabel?: string;
|
||||||
|
/**
|
||||||
|
* The click event handler for the button.
|
||||||
|
*/
|
||||||
|
onButtonClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeWithButton = <T extends BadgeTypes>(props: BadgeWithButtonProps<T>) => {
|
||||||
|
const { size = "md", color = "gray", type = "pill-color", icon: Icon = CloseX, buttonLabel, children } = props;
|
||||||
|
|
||||||
|
const colors = withPillTypes[type];
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: "gap-0.5 py-0.5 pl-2 pr-0.75 text-xs font-medium",
|
||||||
|
md: "gap-0.5 py-0.5 pl-2.5 pr-1 text-sm font-medium",
|
||||||
|
lg: "gap-0.5 py-1 pl-3 pr-1.5 text-sm font-medium",
|
||||||
|
};
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: "gap-0.5 py-0.5 pl-1.5 pr-0.75 text-xs font-medium",
|
||||||
|
md: "gap-0.5 py-0.5 pl-2 pr-1 text-sm font-medium",
|
||||||
|
lg: "gap-0.5 py-1 pl-2.5 pr-1.5 text-sm font-medium rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(colors.common, sizes[type][size], colors.styles[color].root)}>
|
||||||
|
{children}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={buttonLabel}
|
||||||
|
onClick={props.onButtonClick}
|
||||||
|
className={cx(
|
||||||
|
"flex cursor-pointer items-center justify-center p-0.5 outline-focus-ring transition duration-100 ease-linear focus-visible:outline-2",
|
||||||
|
colors.styles[color].addonButton,
|
||||||
|
type === "pill-color" ? "rounded-full" : "rounded-[3px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3 stroke-[3px] transition-inherit-all" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeIconProps<T extends BadgeTypes> {
|
||||||
|
type?: T;
|
||||||
|
size?: Sizes;
|
||||||
|
icon: IconComponentType;
|
||||||
|
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeIcon = <T extends BadgeTypes>(props: BadgeIconProps<T>) => {
|
||||||
|
const { size = "md", color = "gray", type = "pill-color", icon: Icon } = props;
|
||||||
|
|
||||||
|
const colors = withPillTypes[type];
|
||||||
|
|
||||||
|
const pillSizes = {
|
||||||
|
sm: "p-1.25",
|
||||||
|
md: "p-1.5",
|
||||||
|
lg: "p-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeSizes = {
|
||||||
|
sm: "p-1.25",
|
||||||
|
md: "p-1.5",
|
||||||
|
lg: "p-2 rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
[badgeTypes.pillColor]: pillSizes,
|
||||||
|
[badgeTypes.badgeColor]: badgeSizes,
|
||||||
|
[badgeTypes.badgeModern]: badgeSizes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cx(colors.common, sizes[type][size], colors.styles[color].root)}>
|
||||||
|
<Icon className={cx("size-3 stroke-[3px]", colors.styles[color].addon)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { type FC, type PropsWithChildren, type ReactNode, type RefAttributes, createContext, isValidElement, useContext } from "react";
|
||||||
|
import {
|
||||||
|
ToggleButton as AriaToggleButton,
|
||||||
|
ToggleButtonGroup as AriaToggleButtonGroup,
|
||||||
|
type ToggleButtonGroupProps,
|
||||||
|
type ToggleButtonProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
export const styles = sortCx({
|
||||||
|
common: {
|
||||||
|
root: [
|
||||||
|
"group/button-group inline-flex h-max cursor-pointer items-center bg-primary font-semibold whitespace-nowrap text-secondary shadow-skeuomorphic ring-1 ring-primary outline-brand transition duration-100 ease-linear ring-inset",
|
||||||
|
// Hover and focus styles
|
||||||
|
"hover:bg-primary_hover hover:text-secondary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
// Disabled styles
|
||||||
|
"disabled:cursor-not-allowed disabled:text-secondary/50 disabled:*:opacity-50",
|
||||||
|
// Selected styles
|
||||||
|
"selected:bg-primary_hover selected:text-secondary_hover",
|
||||||
|
].join(" "),
|
||||||
|
icon: "pointer-events-none text-fg-quaternary transition-[inherit] group-hover/button-group:text-fg-quaternary_hover group-selected/button-group:text-fg-quaternary_hover",
|
||||||
|
},
|
||||||
|
|
||||||
|
sizes: {
|
||||||
|
sm: {
|
||||||
|
root: "gap-1.5 px-3.5 py-2 text-sm not-last:pr-[calc(calc(var(--spacing)*3.5)+1px)] first:rounded-l-lg last:rounded-r-lg data-icon-leading:pl-3 data-icon-only:px-2.5",
|
||||||
|
icon: "size-5",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "gap-1.5 px-4 py-2.5 text-sm not-last:pr-[calc(calc(var(--spacing)*4)+1px)] first:rounded-l-lg last:rounded-r-lg data-icon-leading:pl-3.5 data-icon-only:px-3",
|
||||||
|
icon: "size-5",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "gap-2 px-4.5 py-2.5 text-md not-last:pr-[calc(calc(var(--spacing)*4.5)+1px)] first:rounded-l-lg last:rounded-r-lg data-icon-leading:pl-4 data-icon-only:px-3.5",
|
||||||
|
icon: "size-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type ButtonSize = keyof typeof styles.sizes;
|
||||||
|
|
||||||
|
const ButtonGroupContext = createContext<{ size: ButtonSize }>({ size: "md" });
|
||||||
|
|
||||||
|
interface ButtonGroupItemProps extends ToggleButtonProps, RefAttributes<HTMLButtonElement> {
|
||||||
|
iconLeading?: FC<{ className?: string }> | ReactNode;
|
||||||
|
iconTrailing?: FC<{ className?: string }> | ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonGroupItem = ({
|
||||||
|
iconLeading: IconLeading,
|
||||||
|
iconTrailing: IconTrailing,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...otherProps
|
||||||
|
}: PropsWithChildren<ButtonGroupItemProps>) => {
|
||||||
|
const context = useContext(ButtonGroupContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("ButtonGroupItem must be used within a ButtonGroup component");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { size } = context;
|
||||||
|
|
||||||
|
const isIcon = (IconLeading || IconTrailing) && !children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaToggleButton
|
||||||
|
{...otherProps}
|
||||||
|
data-icon-only={isIcon ? true : undefined}
|
||||||
|
data-icon-leading={IconLeading ? true : undefined}
|
||||||
|
className={cx(styles.common.root, styles.sizes[size].root, className)}
|
||||||
|
>
|
||||||
|
{isReactComponent(IconLeading) && <IconLeading className={cx(styles.common.icon, styles.sizes[size].icon)} />}
|
||||||
|
{isValidElement(IconLeading) && IconLeading}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{isReactComponent(IconTrailing) && <IconTrailing className={cx(styles.common.icon, styles.sizes[size].icon)} />}
|
||||||
|
{isValidElement(IconTrailing) && IconTrailing}
|
||||||
|
</AriaToggleButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ButtonGroupProps extends Omit<ToggleButtonGroupProps, "orientation">, RefAttributes<HTMLDivElement> {
|
||||||
|
size?: ButtonSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonGroup = ({ children, size = "md", className, ...otherProps }: ButtonGroupProps) => {
|
||||||
|
return (
|
||||||
|
<ButtonGroupContext.Provider value={{ size }}>
|
||||||
|
<AriaToggleButtonGroup
|
||||||
|
selectionMode="single"
|
||||||
|
className={cx("relative z-0 inline-flex w-max -space-x-px rounded-lg shadow-xs", className)}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AriaToggleButtonGroup>
|
||||||
|
</ButtonGroupContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import type { AnchorHTMLAttributes } from "react";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const GooglePlayButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Get it on Google Play"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] text-fg-primary ring-1 ring-fg-primary outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 135 : 149} height={size === "md" ? 40 : 44} viewBox="0 0 135 40" fill="none">
|
||||||
|
<path
|
||||||
|
d="M68.136 21.7511C65.784 21.7511 63.867 23.5401 63.867 26.0041C63.867 28.4531 65.784 30.2571 68.136 30.2571C70.489 30.2571 72.406 28.4531 72.406 26.0041C72.405 23.5401 70.488 21.7511 68.136 21.7511ZM68.136 28.5831C66.847 28.5831 65.736 27.5201 65.736 26.0051C65.736 24.4741 66.848 23.4271 68.136 23.4271C69.425 23.4271 70.536 24.4741 70.536 26.0051C70.536 27.5191 69.425 28.5831 68.136 28.5831ZM58.822 21.7511C56.47 21.7511 54.553 23.5401 54.553 26.0041C54.553 28.4531 56.47 30.2571 58.822 30.2571C61.175 30.2571 63.092 28.4531 63.092 26.0041C63.092 23.5401 61.175 21.7511 58.822 21.7511ZM58.822 28.5831C57.533 28.5831 56.422 27.5201 56.422 26.0051C56.422 24.4741 57.534 23.4271 58.822 23.4271C60.111 23.4271 61.222 24.4741 61.222 26.0051C61.223 27.5191 60.111 28.5831 58.822 28.5831ZM47.744 23.0571V24.8611H52.062C51.933 25.8761 51.595 26.6171 51.079 27.1321C50.451 27.7601 49.468 28.4531 47.744 28.4531C45.086 28.4531 43.008 26.3101 43.008 23.6521C43.008 20.9941 45.086 18.8511 47.744 18.8511C49.178 18.8511 50.225 19.4151 50.998 20.1401L52.271 18.8671C51.191 17.8361 49.758 17.0471 47.744 17.0471C44.103 17.0471 41.042 20.0111 41.042 23.6521C41.042 27.2931 44.103 30.2571 47.744 30.2571C49.709 30.2571 51.192 29.6121 52.351 28.4041C53.543 27.2121 53.914 25.5361 53.914 24.1831C53.914 23.7651 53.882 23.3781 53.817 23.0561H47.744V23.0571ZM93.052 24.4581C92.698 23.5081 91.618 21.7511 89.411 21.7511C87.22 21.7511 85.399 23.4751 85.399 26.0041C85.399 28.3881 87.204 30.2571 89.62 30.2571C91.569 30.2571 92.697 29.0651 93.165 28.3721L91.715 27.4051C91.232 28.1141 90.571 28.5811 89.62 28.5811C88.67 28.5811 87.993 28.1461 87.558 27.2921L93.245 24.9401L93.052 24.4581ZM87.252 25.8761C87.204 24.2321 88.525 23.3951 89.476 23.3951C90.217 23.3951 90.845 23.7661 91.055 24.2971L87.252 25.8761ZM82.629 30.0001H84.497V17.4991H82.629V30.0001ZM79.567 22.7021H79.503C79.084 22.2021 78.278 21.7511 77.264 21.7511C75.137 21.7511 73.188 23.6201 73.188 26.0211C73.188 28.4051 75.137 30.2581 77.264 30.2581C78.279 30.2581 79.084 29.8071 79.503 29.2921H79.567V29.9041C79.567 31.5311 78.697 32.4011 77.296 32.4011C76.152 32.4011 75.443 31.5801 75.153 30.8871L73.526 31.5641C73.993 32.6911 75.233 34.0771 77.296 34.0771C79.487 34.0771 81.34 32.7881 81.34 29.6461V22.0101H79.568V22.7021H79.567ZM77.425 28.5831C76.136 28.5831 75.057 27.5031 75.057 26.0211C75.057 24.5221 76.136 23.4271 77.425 23.4271C78.697 23.4271 79.696 24.5221 79.696 26.0211C79.696 27.5031 78.697 28.5831 77.425 28.5831ZM101.806 17.4991H97.335V30.0001H99.2V25.2641H101.805C103.873 25.2641 105.907 23.7671 105.907 21.3821C105.907 18.9971 103.874 17.4991 101.806 17.4991ZM101.854 23.5241H99.2V19.2391H101.854C103.249 19.2391 104.041 20.3941 104.041 21.3821C104.041 22.3501 103.249 23.5241 101.854 23.5241ZM113.386 21.7291C112.035 21.7291 110.636 22.3241 110.057 23.6431L111.713 24.3341C112.067 23.6431 112.727 23.4171 113.418 23.4171C114.383 23.4171 115.364 23.9961 115.38 25.0251V25.1541C115.042 24.9611 114.318 24.6721 113.434 24.6721C111.649 24.6721 109.831 25.6531 109.831 27.4861C109.831 29.1591 111.295 30.2361 112.935 30.2361C114.189 30.2361 114.881 29.6731 115.315 29.0131H115.379V29.9781H117.181V25.1851C117.182 22.9671 115.524 21.7291 113.386 21.7291ZM113.16 28.5801C112.55 28.5801 111.697 28.2741 111.697 27.5181C111.697 26.5531 112.759 26.1831 113.676 26.1831C114.495 26.1831 114.882 26.3601 115.38 26.6011C115.235 27.7601 114.238 28.5801 113.16 28.5801ZM123.743 22.0021L121.604 27.4221H121.54L119.32 22.0021H117.31L120.639 29.5771L118.741 33.7911H120.687L125.818 22.0021H123.743ZM106.937 30.0001H108.802V17.4991H106.937V30.0001Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z" className="fill-current" />
|
||||||
|
<path d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z" className="fill-current" />
|
||||||
|
<path d="M59.938 13V7H60.709V13H59.938Z" className="fill-current" />
|
||||||
|
<path d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
|
||||||
|
className="stroke-current"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||||
|
className="stroke-current"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z" className="stroke-current" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path d="M59.938 13V7H60.709V13H59.938Z" className="stroke-current" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z" className="stroke-current" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path
|
||||||
|
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
|
||||||
|
className="stroke-current"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||||
|
className="stroke-current"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10.1565 7.96648C10.0384 8.23398 9.97314 8.56174 9.97314 8.943V31.059C9.97314 31.4411 10.0385 31.7689 10.1567 32.0363L22.1907 20.0006L10.1565 7.96648ZM10.8517 32.7555C11.2978 32.9464 11.8797 32.8858 12.5141 32.526L26.6712 24.4812L22.8978 20.7077L10.8517 32.7555ZM27.5737 23.9695L32.0151 21.446C33.4121 20.651 33.4121 19.352 32.0151 18.558L27.5717 16.0331L23.6048 20.0006L27.5737 23.9695ZM26.6699 15.5207L12.5141 7.47701C11.8796 7.11643 11.2977 7.05626 10.8516 7.2474L22.8977 19.2935L26.6699 15.5207Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppStoreButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Download on the App Store"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] text-fg-primary ring-1 ring-fg-primary outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 120 : 132} height={size === "md" ? 40 : 44} viewBox="0 0 120 40" fill="none">
|
||||||
|
<path
|
||||||
|
d="M81.5257 19.2009V21.4919H80.0896V22.9944H81.5257V28.0994C81.5257 29.8425 82.3143 30.5398 84.2981 30.5398C84.6468 30.5398 84.9788 30.4983 85.2693 30.4485V28.9626C85.0203 28.9875 84.8626 29.0041 84.5887 29.0041C83.7005 29.0041 83.3104 28.5891 83.3104 27.6428V22.9944H85.2693V21.4919H83.3104V19.2009H81.5257Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M90.3232 30.6643C92.9628 30.6643 94.5815 28.8962 94.5815 25.9661C94.5815 23.0525 92.9545 21.2761 90.3232 21.2761C87.6835 21.2761 86.0566 23.0525 86.0566 25.9661C86.0566 28.8962 87.6752 30.6643 90.3232 30.6643ZM90.3232 29.0789C88.7709 29.0789 87.8994 27.9416 87.8994 25.9661C87.8994 24.0071 88.7709 22.8616 90.3232 22.8616C91.8671 22.8616 92.747 24.0071 92.747 25.9661C92.747 27.9333 91.8671 29.0789 90.3232 29.0789Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.9664 30.49H97.7511V25.1526C97.7511 23.8826 98.7056 23.0276 100.059 23.0276C100.374 23.0276 100.905 23.0857 101.055 23.1355V21.3757C100.864 21.3259 100.524 21.301 100.258 21.301C99.0792 21.301 98.0748 21.9485 97.8175 22.8367H97.6846V21.4504H95.9664V30.49Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M105.486 22.7952C106.806 22.7952 107.669 23.7165 107.711 25.136H103.145C103.245 23.7248 104.166 22.7952 105.486 22.7952ZM107.702 28.0496C107.37 28.7551 106.632 29.1453 105.552 29.1453C104.125 29.1453 103.203 28.1409 103.145 26.5554V26.4558H109.529V25.8332C109.529 22.9944 108.009 21.2761 105.494 21.2761C102.946 21.2761 101.327 23.1106 101.327 25.9993C101.327 28.8879 102.913 30.6643 105.503 30.6643C107.57 30.6643 109.014 29.6682 109.421 28.0496H107.702Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M69.8221 27.1518C69.9598 29.3715 71.8095 30.7911 74.5626 30.7911C77.505 30.7911 79.3462 29.3027 79.3462 26.9281C79.3462 25.0612 78.2966 24.0287 75.7499 23.4351L74.382 23.0996C72.7645 22.721 72.1106 22.2134 72.1106 21.3272C72.1106 20.2088 73.1259 19.4775 74.6487 19.4775C76.0941 19.4775 77.0921 20.1916 77.2727 21.3358H79.1483C79.0365 19.2452 77.1953 17.774 74.6745 17.774C71.9644 17.774 70.1576 19.2452 70.1576 21.4563C70.1576 23.2802 71.1815 24.3643 73.427 24.8891L75.0272 25.2763C76.6705 25.6634 77.3932 26.2312 77.3932 27.1776C77.3932 28.2789 76.2575 29.079 74.7089 29.079C73.0484 29.079 71.8955 28.3305 71.7321 27.1518H69.8221Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M51.3348 21.301C50.1063 21.301 49.0437 21.9153 48.4959 22.9446H48.3631V21.4504H46.6448V33.4949H48.4295V29.1204H48.5706C49.0437 30.0749 50.0647 30.6394 51.3514 30.6394C53.6341 30.6394 55.0867 28.8381 55.0867 25.9661C55.0867 23.094 53.6341 21.301 51.3348 21.301ZM50.8284 29.0373C49.3343 29.0373 48.3963 27.8586 48.3963 25.9744C48.3963 24.0818 49.3343 22.9031 50.8367 22.9031C52.3475 22.9031 53.2522 24.0569 53.2522 25.9661C53.2522 27.8835 52.3475 29.0373 50.8284 29.0373Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M61.3316 21.301C60.103 21.301 59.0405 21.9153 58.4927 22.9446H58.3599V21.4504H56.6416V33.4949H58.4263V29.1204H58.5674C59.0405 30.0749 60.0615 30.6394 61.3482 30.6394C63.6309 30.6394 65.0835 28.8381 65.0835 25.9661C65.0835 23.094 63.6309 21.301 61.3316 21.301ZM60.8252 29.0373C59.3311 29.0373 58.3931 27.8586 58.3931 25.9744C58.3931 24.0818 59.3311 22.9031 60.8335 22.9031C62.3443 22.9031 63.249 24.0569 63.249 25.9661C63.249 27.8835 62.3443 29.0373 60.8252 29.0373Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M43.4428 30.49H45.4905L41.008 18.0751H38.9346L34.4521 30.49H36.431L37.5752 27.1948H42.3072L43.4428 30.49ZM39.8724 20.3292H40.0186L41.8168 25.5774H38.0656L39.8724 20.3292Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M35.6514 8.71094V14.7H37.8137C39.5984 14.7 40.6318 13.6001 40.6318 11.6868C40.6318 9.80249 39.5901 8.71094 37.8137 8.71094H35.6514ZM36.5811 9.55762H37.71C38.9509 9.55762 39.6855 10.3462 39.6855 11.6992C39.6855 13.073 38.9634 13.8533 37.71 13.8533H36.5811V9.55762Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M43.7969 14.7871C45.1167 14.7871 45.9261 13.9031 45.9261 12.438C45.9261 10.9812 45.1126 10.093 43.7969 10.093C42.4771 10.093 41.6636 10.9812 41.6636 12.438C41.6636 13.9031 42.4729 14.7871 43.7969 14.7871ZM43.7969 13.9944C43.0208 13.9944 42.585 13.4258 42.585 12.438C42.585 11.4585 43.0208 10.8857 43.7969 10.8857C44.5689 10.8857 45.0088 11.4585 45.0088 12.438C45.0088 13.4216 44.5689 13.9944 43.7969 13.9944Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M52.8182 10.1802H51.9259L51.1207 13.6292H51.0501L50.1205 10.1802H49.2655L48.3358 13.6292H48.2694L47.4601 10.1802H46.5553L47.8004 14.7H48.7176L49.6473 11.3713H49.7179L50.6517 14.7H51.5772L52.8182 10.1802Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M53.8458 14.7H54.7382V12.0562C54.7382 11.3506 55.1574 10.9106 55.8173 10.9106C56.4772 10.9106 56.7926 11.2717 56.7926 11.998V14.7H57.685V11.7739C57.685 10.699 57.1288 10.093 56.1203 10.093C55.4396 10.093 54.9914 10.396 54.7714 10.8982H54.705V10.1802H53.8458V14.7Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M59.0903 14.7H59.9826V8.41626H59.0903V14.7Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M63.3386 14.7871C64.6584 14.7871 65.4678 13.9031 65.4678 12.438C65.4678 10.9812 64.6543 10.093 63.3386 10.093C62.0188 10.093 61.2053 10.9812 61.2053 12.438C61.2053 13.9031 62.0146 14.7871 63.3386 14.7871ZM63.3386 13.9944C62.5625 13.9944 62.1267 13.4258 62.1267 12.438C62.1267 11.4585 62.5625 10.8857 63.3386 10.8857C64.1106 10.8857 64.5505 11.4585 64.5505 12.438C64.5505 13.4216 64.1106 13.9944 63.3386 13.9944Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M68.1265 14.0234C67.6409 14.0234 67.2881 13.7869 67.2881 13.3801C67.2881 12.9817 67.5704 12.77 68.1929 12.7285L69.2969 12.658V13.0356C69.2969 13.5959 68.7989 14.0234 68.1265 14.0234ZM67.8982 14.7747C68.4917 14.7747 68.9856 14.5173 69.2554 14.0649H69.326V14.7H70.1851V11.6121C70.1851 10.6575 69.5459 10.093 68.4129 10.093C67.3877 10.093 66.6573 10.5911 66.566 11.3672H67.4292C67.5289 11.0476 67.8733 10.865 68.3714 10.865C68.9815 10.865 69.2969 11.1348 69.2969 11.6121V12.0022L68.0726 12.0728C66.9976 12.1392 66.3916 12.6082 66.3916 13.4216C66.3916 14.2476 67.0267 14.7747 67.8982 14.7747Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M73.2132 14.7747C73.8358 14.7747 74.3629 14.48 74.6327 13.9861H74.7032V14.7H75.5582V8.41626H74.6659V10.8982H74.5995C74.3546 10.4001 73.8316 10.1055 73.2132 10.1055C72.0719 10.1055 71.3373 11.0103 71.3373 12.438C71.3373 13.8699 72.0636 14.7747 73.2132 14.7747ZM73.4664 10.9065C74.2135 10.9065 74.6825 11.5 74.6825 12.4421C74.6825 13.3884 74.2176 13.9736 73.4664 13.9736C72.711 13.9736 72.2586 13.3967 72.2586 12.438C72.2586 11.4875 72.7152 10.9065 73.4664 10.9065Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M81.3447 14.7871C82.6645 14.7871 83.4738 13.9031 83.4738 12.438C83.4738 10.9812 82.6604 10.093 81.3447 10.093C80.0249 10.093 79.2114 10.9812 79.2114 12.438C79.2114 13.9031 80.0207 14.7871 81.3447 14.7871ZM81.3447 13.9944C80.5686 13.9944 80.1328 13.4258 80.1328 12.438C80.1328 11.4585 80.5686 10.8857 81.3447 10.8857C82.1166 10.8857 82.5566 11.4585 82.5566 12.438C82.5566 13.4216 82.1166 13.9944 81.3447 13.9944Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M84.655 14.7H85.5474V12.0562C85.5474 11.3506 85.9666 10.9106 86.6265 10.9106C87.2864 10.9106 87.6018 11.2717 87.6018 11.998V14.7H88.4941V11.7739C88.4941 10.699 87.938 10.093 86.9294 10.093C86.2488 10.093 85.8005 10.396 85.5806 10.8982H85.5142V10.1802H84.655V14.7Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M92.6039 9.05542V10.2009H91.8858V10.9521H92.6039V13.5046C92.6039 14.3762 92.9981 14.7249 93.9901 14.7249C94.1644 14.7249 94.3304 14.7041 94.4757 14.6792V13.9363C94.3512 13.9487 94.2723 13.957 94.1353 13.957C93.6913 13.957 93.4962 13.7495 93.4962 13.2764V10.9521H94.4757V10.2009H93.4962V9.05542H92.6039Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.6735 14.7H96.5658V12.0603C96.5658 11.3755 96.9726 10.9148 97.703 10.9148C98.3339 10.9148 98.6701 11.28 98.6701 12.0022V14.7H99.5624V11.7822C99.5624 10.7073 98.9689 10.0972 98.006 10.0972C97.3253 10.0972 96.848 10.4001 96.6281 10.9065H96.5575V8.41626H95.6735V14.7Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M102.781 10.8525C103.441 10.8525 103.873 11.3132 103.894 12.0229H101.611C101.661 11.3174 102.122 10.8525 102.781 10.8525ZM103.89 13.4797C103.724 13.8325 103.354 14.0276 102.815 14.0276C102.101 14.0276 101.64 13.5254 101.611 12.7327V12.6829H104.803V12.3716C104.803 10.9521 104.043 10.093 102.786 10.093C101.511 10.093 100.702 11.0103 100.702 12.4546C100.702 13.8989 101.495 14.7871 102.79 14.7871C103.823 14.7871 104.545 14.2891 104.749 13.4797H103.89Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M24.769 20.3008C24.7907 18.6198 25.6934 17.0292 27.1256 16.1488C26.2221 14.8584 24.7088 14.0403 23.1344 13.9911C21.4552 13.8148 19.8272 14.9959 18.9715 14.9959C18.0992 14.9959 16.7817 14.0086 15.363 14.0378C13.5137 14.0975 11.7898 15.1489 10.8901 16.7656C8.95607 20.1141 10.3987 25.0351 12.2513 27.7417C13.1782 29.0671 14.2615 30.5475 15.6789 30.495C17.066 30.4375 17.584 29.6105 19.2583 29.6105C20.9171 29.6105 21.4031 30.495 22.8493 30.4616C24.3377 30.4375 25.2754 29.1304 26.1698 27.7925C26.8358 26.8481 27.3483 25.8044 27.6882 24.7C25.9391 23.9602 24.771 22.2 24.769 20.3008Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22.0373 12.2111C22.8489 11.2369 23.2487 9.98469 23.1518 8.72046C21.912 8.85068 20.7668 9.44324 19.9443 10.3801C19.14 11.2954 18.7214 12.5255 18.8006 13.7415C20.0408 13.7542 21.2601 13.1777 22.0373 12.2111Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GalaxyStoreButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Available on Galaxy Store"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] text-fg-primary ring-1 ring-fg-primary outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 147 : 162} height={size === "md" ? 40 : 44} viewBox="0 0 147 40" fill="none">
|
||||||
|
<path d="M64.7516 20.3987H66.7715V31.3885H64.7516V20.3987Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M42.5 25.9699C42.5 22.8811 44.8314 20.4009 48.039 20.4009C50.2816 20.4009 52.0489 21.5444 52.9695 23.1779L51.1875 24.2473C50.5193 23.0146 49.4799 22.3611 48.054 22.3611C46.0343 22.3611 44.5047 23.9946 44.5047 25.9699C44.5047 27.9745 46.0196 29.5786 48.1281 29.5786C49.7469 29.5786 50.8757 28.6578 51.2616 27.2322H47.8017V25.3017H53.4298V26.1037C53.4298 29.0289 51.3657 31.5392 48.1281 31.5392C44.7423 31.5392 42.5 28.9695 42.5 25.9699Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M54.3525 27.0543C54.3525 24.1732 56.4613 22.525 58.6592 22.525C59.8027 22.525 60.8274 22.9999 61.4512 23.7426V22.7032H63.4558V31.3907H61.4512V30.2616C60.8124 31.0636 59.773 31.5689 58.6295 31.5689C56.5354 31.5689 54.3525 29.8904 54.3525 27.0543ZM61.555 27.0246C61.555 25.5543 60.4412 24.3514 58.9562 24.3514C57.4713 24.3514 56.3278 25.5249 56.3278 27.0246C56.3278 28.539 57.4713 29.7272 58.9562 29.7272C60.4412 29.7272 61.555 28.5243 61.555 27.0246Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M67.7938 27.0543C67.7938 24.1732 69.9026 22.525 72.1005 22.525C73.244 22.525 74.2686 22.9999 74.8925 23.7426V22.7032H76.8971V31.3907H74.8925V30.2616C74.2539 31.0636 73.2146 31.5689 72.0707 31.5689C69.977 31.5689 67.7938 29.8904 67.7938 27.0543ZM74.9966 27.0246C74.9966 25.5543 73.8828 24.3514 72.3981 24.3514C70.9128 24.3514 69.7693 25.5249 69.7693 27.0246C69.7693 28.539 70.9128 29.7272 72.3981 29.7272C73.8825 29.7272 74.9966 28.5243 74.9966 27.0246Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M80.8048 26.9652L77.6566 22.7032H80.1072L82.0818 25.4652L84.0424 22.7032H86.4182L83.2994 26.9949L86.6261 31.3907H84.1759L82.0524 28.4949L79.988 31.3907H77.5375L80.8048 26.9652Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M90.3846 30.9598L86.8206 22.7029H88.9588L91.3796 28.554L93.6663 22.7029H95.7757L90.5926 35.4H88.5582L90.3846 30.9598Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M99.8907 29.5936L101.732 28.1384C102.282 29.074 103.128 29.6083 104.108 29.6083C105.178 29.6083 105.757 28.911 105.757 28.1531C105.757 27.2325 104.658 26.9505 103.499 26.594C102.044 26.1334 100.426 25.5693 100.426 23.5049C100.426 21.7676 101.94 20.4012 104.019 20.4012C105.772 20.4012 106.781 21.0697 107.658 21.9756L105.994 23.2376C105.534 22.5547 104.895 22.1982 104.034 22.1982C103.054 22.1982 102.519 22.7329 102.519 23.4305C102.519 24.292 103.559 24.5743 104.732 24.9605C106.203 25.4355 107.866 26.089 107.866 28.1681C107.866 29.8757 106.499 31.5395 104.123 31.5395C102.163 31.5392 100.871 30.7071 99.8907 29.5936Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M108.03 22.7029H109.515V21.3812L111.535 20V22.7029H113.302V24.4999H111.535V27.7519C111.535 29.2669 111.743 29.5045 113.302 29.5045V31.3904H113.02C110.332 31.3904 109.515 30.5292 109.515 27.7672V24.4999H108.03L108.03 22.7029Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M113.838 27.0543C113.838 24.5296 115.827 22.5247 118.337 22.5247C120.832 22.5247 122.837 24.5296 122.837 27.0543C122.837 29.5639 120.832 31.5689 118.337 31.5689C115.827 31.5689 113.838 29.5639 113.838 27.0543ZM120.862 27.0543C120.862 25.599 119.747 24.4108 118.337 24.4108C116.896 24.4108 115.812 25.599 115.812 27.0543C115.812 28.4949 116.896 29.6828 118.337 29.6828C119.747 29.6828 120.862 28.4949 120.862 27.0543Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M125.853 22.7029V23.9949C126.254 23.1335 126.981 22.7029 128.08 22.7029C128.704 22.7029 129.223 22.8514 129.61 23.0741L128.852 24.9749C128.555 24.782 128.214 24.6334 127.649 24.6334C126.491 24.6334 125.867 25.2573 125.867 26.7569V31.3904H123.862V22.7029H125.853Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M129.834 27.069C129.834 24.5296 131.808 22.5247 134.348 22.5247C136.932 22.5247 138.818 24.4255 138.818 26.9949V27.7519H131.749C132.017 28.9701 132.997 29.8016 134.423 29.8016C135.536 29.8016 136.413 29.1928 136.828 28.2719L138.477 29.2222C137.719 30.6186 136.353 31.5686 134.423 31.5686C131.69 31.5686 129.834 29.5786 129.834 27.069ZM131.853 26.074H136.784C136.487 24.9158 135.596 24.292 134.348 24.292C133.145 24.292 132.21 25.0196 131.853 26.074Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M46.6986 13.974H43.9018L43.3866 15.4H42.5034L44.8218 9.0244H45.7878L48.097 15.4H47.2138L46.6986 13.974ZM46.4594 13.2932L45.3002 10.0548L44.141 13.2932H46.4594Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M50.4322 14.6272L51.9962 10.3584H52.8886L50.9106 15.4H49.9354L47.9574 10.3584H48.859L50.4322 14.6272Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M53.4917 12.8608C53.4917 12.3456 53.5959 11.8948 53.8045 11.5084C54.013 11.1159 54.2982 10.8123 54.6601 10.5976C55.0281 10.3829 55.4359 10.2756 55.8837 10.2756C56.3253 10.2756 56.7086 10.3707 57.0337 10.5608C57.3587 10.7509 57.601 10.9901 57.7605 11.2784V10.3584H58.6069V15.4H57.7605V14.4616C57.5949 14.756 57.3465 15.0013 57.0153 15.1976C56.6902 15.3877 56.3099 15.4828 55.8745 15.4828C55.4267 15.4828 55.0219 15.3724 54.6601 15.1516C54.2982 14.9308 54.013 14.6211 53.8045 14.2224C53.5959 13.8237 53.4917 13.3699 53.4917 12.8608ZM57.7605 12.87C57.7605 12.4897 57.6838 12.1585 57.5305 11.8764C57.3771 11.5943 57.1686 11.3796 56.9049 11.2324C56.6473 11.0791 56.3621 11.0024 56.0493 11.0024C55.7365 11.0024 55.4513 11.076 55.1937 11.2232C54.9361 11.3704 54.7306 11.5851 54.5773 11.8672C54.4239 12.1493 54.3473 12.4805 54.3473 12.8608C54.3473 13.2472 54.4239 13.5845 54.5773 13.8728C54.7306 14.1549 54.9361 14.3727 55.1937 14.526C55.4513 14.6732 55.7365 14.7468 56.0493 14.7468C56.3621 14.7468 56.6473 14.6732 56.9049 14.526C57.1686 14.3727 57.3771 14.1549 57.5305 13.8728C57.6838 13.5845 57.7605 13.2503 57.7605 12.87Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M60.2701 9.5396C60.1106 9.5396 59.9757 9.4844 59.8653 9.374C59.7549 9.2636 59.6997 9.12867 59.6997 8.9692C59.6997 8.80974 59.7549 8.6748 59.8653 8.5644C59.9757 8.454 60.1106 8.3988 60.2701 8.3988C60.4234 8.3988 60.5522 8.454 60.6565 8.5644C60.7669 8.6748 60.8221 8.80974 60.8221 8.9692C60.8221 9.12867 60.7669 9.2636 60.6565 9.374C60.5522 9.4844 60.4234 9.5396 60.2701 9.5396ZM60.6749 10.3584V15.4H59.8377V10.3584H60.6749Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M62.7549 8.592V15.4H61.9177V8.592H62.7549Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M63.869 12.8608C63.869 12.3456 63.9732 11.8948 64.1818 11.5084C64.3903 11.1159 64.6755 10.8123 65.0374 10.5976C65.4054 10.3829 65.8132 10.2756 66.261 10.2756C66.7026 10.2756 67.0859 10.3707 67.411 10.5608C67.736 10.7509 67.9783 10.9901 68.1378 11.2784V10.3584H68.9842V15.4H68.1378V14.4616C67.9722 14.756 67.7238 15.0013 67.3926 15.1976C67.0675 15.3877 66.6872 15.4828 66.2518 15.4828C65.804 15.4828 65.3992 15.3724 65.0374 15.1516C64.6755 14.9308 64.3903 14.6211 64.1818 14.2224C63.9732 13.8237 63.869 13.3699 63.869 12.8608ZM68.1378 12.87C68.1378 12.4897 68.0611 12.1585 67.9078 11.8764C67.7544 11.5943 67.5459 11.3796 67.2822 11.2324C67.0246 11.0791 66.7394 11.0024 66.4266 11.0024C66.1138 11.0024 65.8286 11.076 65.571 11.2232C65.3134 11.3704 65.1079 11.5851 64.9546 11.8672C64.8012 12.1493 64.7246 12.4805 64.7246 12.8608C64.7246 13.2472 64.8012 13.5845 64.9546 13.8728C65.1079 14.1549 65.3134 14.3727 65.571 14.526C65.8286 14.6732 66.1138 14.7468 66.4266 14.7468C66.7394 14.7468 67.0246 14.6732 67.2822 14.526C67.5459 14.3727 67.7544 14.1549 67.9078 13.8728C68.0611 13.5845 68.1378 13.2503 68.1378 12.87Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M71.3282 11.2968C71.4999 10.9963 71.7514 10.7509 72.0826 10.5608C72.4138 10.3707 72.791 10.2756 73.2142 10.2756C73.668 10.2756 74.0759 10.3829 74.4378 10.5976C74.7996 10.8123 75.0848 11.1159 75.2934 11.5084C75.5019 11.8948 75.6062 12.3456 75.6062 12.8608C75.6062 13.3699 75.5019 13.8237 75.2934 14.2224C75.0848 14.6211 74.7966 14.9308 74.4286 15.1516C74.0667 15.3724 73.6619 15.4828 73.2142 15.4828C72.7787 15.4828 72.3954 15.3877 72.0642 15.1976C71.7391 15.0075 71.4938 14.7652 71.3282 14.4708V15.4H70.491V8.592H71.3282V11.2968ZM74.7506 12.8608C74.7506 12.4805 74.6739 12.1493 74.5206 11.8672C74.3672 11.5851 74.1587 11.3704 73.895 11.2232C73.6374 11.076 73.3522 11.0024 73.0394 11.0024C72.7327 11.0024 72.4475 11.0791 72.1838 11.2324C71.9262 11.3796 71.7176 11.5973 71.5582 11.8856C71.4048 12.1677 71.3282 12.4959 71.3282 12.87C71.3282 13.2503 71.4048 13.5845 71.5582 13.8728C71.7176 14.1549 71.9262 14.3727 72.1838 14.526C72.4475 14.6732 72.7327 14.7468 73.0394 14.7468C73.3522 14.7468 73.6374 14.6732 73.895 14.526C74.1587 14.3727 74.3672 14.1549 74.5206 13.8728C74.6739 13.5845 74.7506 13.2472 74.7506 12.8608Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M77.5454 8.592V15.4H76.7082V8.592H77.5454Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M83.6642 12.686C83.6642 12.8455 83.655 13.0141 83.6366 13.192H79.607C79.6377 13.6888 79.8064 14.0783 80.113 14.3604C80.4258 14.6364 80.803 14.7744 81.2446 14.7744C81.6065 14.7744 81.907 14.6916 82.1462 14.526C82.3916 14.3543 82.5633 14.1273 82.6614 13.8452H83.563C83.4281 14.3297 83.1582 14.7253 82.7534 15.032C82.3486 15.3325 81.8457 15.4828 81.2446 15.4828C80.7662 15.4828 80.3369 15.3755 79.9566 15.1608C79.5825 14.9461 79.2881 14.6425 79.0734 14.25C78.8587 13.8513 78.7514 13.3913 78.7514 12.87C78.7514 12.3487 78.8557 11.8917 79.0642 11.4992C79.2728 11.1067 79.5641 10.8061 79.9382 10.5976C80.3185 10.3829 80.754 10.2756 81.2446 10.2756C81.723 10.2756 82.1462 10.3799 82.5142 10.5884C82.8822 10.7969 83.1643 11.0852 83.3606 11.4532C83.563 11.8151 83.6642 12.226 83.6642 12.686ZM82.7994 12.5112C82.7994 12.1923 82.7289 11.9193 82.5878 11.6924C82.4467 11.4593 82.2536 11.2845 82.0082 11.168C81.769 11.0453 81.5022 10.984 81.2078 10.984C80.7846 10.984 80.4227 11.1189 80.1222 11.3888C79.8278 11.6587 79.6591 12.0328 79.6162 12.5112H82.7994Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M89.6048 15.4828C89.1326 15.4828 88.7032 15.3755 88.3168 15.1608C87.9366 14.9461 87.636 14.6425 87.4152 14.25C87.2006 13.8513 87.0932 13.3913 87.0932 12.87C87.0932 12.3548 87.2036 11.9009 87.4244 11.5084C87.6514 11.1097 87.958 10.8061 88.3444 10.5976C88.7308 10.3829 89.1632 10.2756 89.6416 10.2756C90.12 10.2756 90.5524 10.3829 90.9388 10.5976C91.3252 10.8061 91.6288 11.1067 91.8496 11.4992C92.0766 11.8917 92.19 12.3487 92.19 12.87C92.19 13.3913 92.0735 13.8513 91.8404 14.25C91.6135 14.6425 91.3038 14.9461 90.9112 15.1608C90.5187 15.3755 90.0832 15.4828 89.6048 15.4828ZM89.6048 14.7468C89.9054 14.7468 90.1875 14.6763 90.4512 14.5352C90.715 14.3941 90.9266 14.1825 91.086 13.9004C91.2516 13.6183 91.3344 13.2748 91.3344 12.87C91.3344 12.4652 91.2547 12.1217 91.0952 11.8396C90.9358 11.5575 90.7272 11.3489 90.4696 11.214C90.212 11.0729 89.933 11.0024 89.6324 11.0024C89.3258 11.0024 89.0436 11.0729 88.786 11.214C88.5346 11.3489 88.3322 11.5575 88.1788 11.8396C88.0255 12.1217 87.9488 12.4652 87.9488 12.87C87.9488 13.2809 88.0224 13.6275 88.1696 13.9096C88.323 14.1917 88.5254 14.4033 88.7768 14.5444C89.0283 14.6793 89.3043 14.7468 89.6048 14.7468Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.9312 10.2664C96.5445 10.2664 97.0413 10.4535 97.4216 10.8276C97.8019 11.1956 97.992 11.7292 97.992 12.4284V15.4H97.164V12.548C97.164 12.0451 97.0383 11.6617 96.7868 11.398C96.5353 11.1281 96.1919 10.9932 95.7564 10.9932C95.3148 10.9932 94.9621 11.1312 94.6984 11.4072C94.4408 11.6832 94.312 12.0849 94.312 12.6124V15.4H93.4748V10.3584H94.312V11.076C94.4776 10.8184 94.7015 10.6191 94.9836 10.478C95.2719 10.3369 95.5877 10.2664 95.9312 10.2664Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M9.2763 14.6655C9 15.8164 9 17.2109 9 20C9 22.7891 9 24.1836 9.2763 25.3345C10.1541 28.9909 13.0091 31.8459 16.6655 32.7237C17.8164 33 19.2109 33 22 33C24.7891 33 26.1836 33 27.3345 32.7237C30.9909 31.8459 33.8459 28.9909 34.7237 25.3345C35 24.1836 35 22.7891 35 20C35 17.2109 35 15.8164 34.7237 14.6655C33.8459 11.0091 30.9909 8.15415 27.3345 7.2763C26.1836 7 24.7891 7 22 7C19.2109 7 17.8164 7 16.6655 7.2763C13.0091 8.15415 10.1541 11.0091 9.2763 14.6655ZM25.738 16.3341L25.7914 16.5695H28.8609L28.0334 24.2828C27.8677 25.8515 26.5446 27.0421 24.9669 27.0421H19.0326C17.4549 27.0421 16.1318 25.8515 15.966 24.2826L15.1387 16.5695H18.2081L18.2617 16.3341C18.2617 14.2733 19.9385 12.5968 21.9997 12.5968C24.0609 12.5968 25.738 14.2733 25.738 16.3341ZM19.8934 16.3341L19.8331 16.5695H24.1664L24.1063 16.3341C24.1063 15.1729 23.1613 14.2281 21.9997 14.2281C20.8383 14.2281 19.8934 15.1729 19.8934 16.3341Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppGalleryButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Explore it on AppGallery"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] text-fg-primary ring-1 ring-fg-primary outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 133 : 147} height={size === "md" ? 40 : 44} viewBox="0 0 133 40" fill="none">
|
||||||
|
<path
|
||||||
|
d="M45.3962 25.4116H48.8919L47.6404 22.0615C47.4682 21.5986 47.2989 21.0875 47.1319 20.5276C46.9813 21.0229 46.817 21.5286 46.6394 22.0453L45.3962 25.4116ZM49.4893 27.0021H44.8068L43.6607 30.1344H41.6021L46.1874 18.4368H48.133L52.8234 30.1344H50.6599L49.4893 27.0021Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M58.8962 28.0072C59.3026 27.461 59.5058 26.663 59.5058 25.6135C59.5058 24.6396 59.3375 23.933 59.0013 23.4942C58.6647 23.0557 58.2167 22.8364 57.657 22.8364C57.2695 22.8364 56.9117 22.9281 56.5834 23.1109C56.2551 23.2939 55.9429 23.5387 55.6469 23.8455V28.5117C55.8461 28.6085 56.0775 28.6853 56.3413 28.7416C56.605 28.7984 56.8661 28.8266 57.1242 28.8266C57.8994 28.8266 58.4898 28.5533 58.8962 28.0072ZM53.653 23.555C53.653 22.9092 53.6314 22.1986 53.5885 21.4237H55.4613C55.5311 21.7844 55.5797 22.1531 55.6066 22.5297C56.3815 21.6849 57.2695 21.2622 58.2706 21.2622C58.8519 21.2622 59.3901 21.4088 59.8853 21.7021C60.3802 21.9955 60.7799 22.4583 61.0839 23.0906C61.3882 23.7231 61.5402 24.5265 61.5402 25.5005C61.5402 26.5177 61.3666 27.387 61.0194 28.108C60.6722 28.8294 60.1866 29.3754 59.5623 29.747C58.9381 30.1181 58.2167 30.3039 57.3989 30.3039C56.8066 30.3039 56.2226 30.2042 55.6469 30.0053V33.6056L53.653 33.7752V23.555Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M67.9935 28.0072C68.3999 27.461 68.6031 26.663 68.6031 25.6135C68.6031 24.6396 68.4349 23.933 68.0986 23.4942C67.7621 23.0557 67.3141 22.8364 66.7543 22.8364C66.3669 22.8364 66.009 22.9281 65.6807 23.1109C65.3522 23.2939 65.0402 23.5387 64.7442 23.8455V28.5117C64.9434 28.6085 65.1746 28.6853 65.4386 28.7416C65.7021 28.7984 65.9631 28.8266 66.2215 28.8266C66.9964 28.8266 67.5871 28.5533 67.9935 28.0072ZM62.7501 23.555C62.7501 22.9092 62.7285 22.1986 62.6855 21.4237H64.5586C64.6285 21.7844 64.677 22.1531 64.7039 22.5297C65.4789 21.6849 66.3668 21.2622 67.3679 21.2622C67.9493 21.2622 68.4871 21.4088 68.9826 21.7021C69.4775 21.9955 69.8772 22.4583 70.1815 23.0906C70.4852 23.7231 70.6375 24.5265 70.6375 25.5005C70.6375 26.5177 70.4637 27.387 70.1167 28.108C69.7695 28.8294 69.2837 29.3754 68.6597 29.747C68.0351 30.1181 67.314 30.3039 66.4959 30.3039C65.9039 30.3039 65.3199 30.2042 64.7442 30.0053V33.6056L62.7501 33.7752V23.555Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M74.3005 29.5813C73.4391 29.1053 72.7773 28.4229 72.3146 27.5349C71.8514 26.6469 71.6202 25.5974 71.6202 24.3864C71.6202 23.0734 71.8866 21.9578 72.4194 21.0401C72.9522 20.1227 73.6775 19.4337 74.5949 18.9735C75.5127 18.5134 76.5418 18.2833 77.6831 18.2833C78.3557 18.2833 78.9975 18.3574 79.6085 18.5053C80.2191 18.6534 80.7882 18.8564 81.3159 19.1149L80.8071 20.6486C79.7468 20.1428 78.7351 19.8898 77.7719 19.8898C76.9591 19.8898 76.2474 20.0634 75.6367 20.4106C75.0258 20.7578 74.5506 21.2677 74.2117 21.9404C73.8725 22.6132 73.7031 23.4258 73.7031 24.3783C73.7031 25.2128 73.8335 25.9526 74.0943 26.5984C74.3557 27.2442 74.7674 27.7557 75.3298 28.1323C75.8922 28.509 76.6013 28.6973 77.457 28.6973C77.8445 28.6973 78.2319 28.6651 78.6194 28.6005C79.0069 28.536 79.3703 28.4418 79.7093 28.3179V25.9526H77.005V24.4026H81.6469V29.3272C80.9794 29.6392 80.2783 29.8789 79.5439 30.0456C78.809 30.2123 78.0786 30.2957 77.3519 30.2957C76.1786 30.2957 75.1613 30.0579 74.3005 29.5813Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M87.5381 28.5197C87.9522 28.3208 88.2914 28.073 88.5551 27.7771V26.1625C88.0114 26.1034 87.5674 26.0737 87.2231 26.0737C86.3997 26.0737 85.8303 26.2068 85.5159 26.4734C85.2007 26.7394 85.0434 27.0989 85.0434 27.5509C85.0434 27.9818 85.1578 28.3005 85.3866 28.5077C85.6154 28.7149 85.9261 28.8184 86.3189 28.8184C86.717 28.8184 87.1234 28.7189 87.5381 28.5197ZM88.7327 30.1344C88.6626 29.7952 88.6167 29.4106 88.5954 28.98C88.2887 29.3461 87.8893 29.6568 87.3965 29.9125C86.9045 30.168 86.3485 30.2957 85.7295 30.2957C85.229 30.2957 84.773 30.1976 84.361 30.001C83.9498 29.8048 83.6226 29.5087 83.3805 29.113C83.1383 28.7176 83.017 28.2347 83.017 27.6639C83.017 26.8192 83.321 26.145 83.9293 25.6416C84.5375 25.1385 85.5519 24.887 86.9727 24.887C87.5055 24.887 88.033 24.9248 88.5551 25V24.8305C88.5551 24.0609 88.3909 23.5187 88.0626 23.2036C87.7343 22.889 87.2634 22.7316 86.6501 22.7316C86.2247 22.7316 85.7701 22.7935 85.2855 22.9172C84.8013 23.0411 84.3759 23.189 84.0101 23.3612L83.6951 21.9081C84.0503 21.7465 84.5186 21.5986 85.0999 21.464C85.6813 21.3297 86.2946 21.2622 86.9405 21.2622C87.6941 21.2622 88.3343 21.3765 88.8618 21.6052C89.3893 21.834 89.801 22.2271 90.097 22.7838C90.393 23.3411 90.541 24.0906 90.541 25.0323V28.4954C90.541 28.8562 90.5623 29.4027 90.6055 30.1344H88.7327Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M92.0709 28.0434V17.7505L94.0567 17.5891V27.6882C94.0567 28.0597 94.1199 28.3218 94.2463 28.4754C94.3727 28.6285 94.5732 28.7056 94.8479 28.7056C94.9716 28.7056 95.1466 28.676 95.3724 28.6168L95.6066 30.0456C95.418 30.121 95.1882 30.1816 94.9167 30.2272C94.6447 30.2728 94.3876 30.2957 94.1455 30.2957C92.762 30.2957 92.0709 29.5451 92.0709 28.0434Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M97.0356 28.0434V17.7505L99.0215 17.5891V27.6882C99.0215 28.0597 99.0847 28.3218 99.2111 28.4754C99.3378 28.6285 99.5383 28.7056 99.8127 28.7056C99.9365 28.7056 100.111 28.676 100.338 28.6168L100.572 30.0456C100.383 30.121 100.153 30.1816 99.8815 30.2272C99.6095 30.2728 99.3524 30.2957 99.1103 30.2957C97.7271 30.2957 97.0356 29.5451 97.0356 28.0434Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M106.795 24.7659C106.755 24.0716 106.581 23.551 106.269 23.2036C105.957 22.8568 105.539 22.683 105.019 22.683C104.512 22.683 104.091 22.8581 103.755 23.2078C103.419 23.5578 103.197 24.0771 103.096 24.7659H106.795ZM108.739 26.0333H103.04C103.131 27.8579 104 28.7701 105.648 28.7701C106.056 28.7701 106.475 28.7203 106.904 28.6208C107.331 28.521 107.741 28.388 108.133 28.221L108.571 29.5853C107.595 30.0592 106.501 30.2957 105.285 30.2957C104.357 30.2957 103.579 30.121 102.944 29.7711C102.307 29.4213 101.829 28.9181 101.509 28.2613C101.19 27.6051 101.03 26.8138 101.03 25.888C101.03 24.9248 101.199 24.0958 101.539 23.4016C101.877 22.7073 102.349 22.1773 102.955 21.8112C103.56 21.4453 104.259 21.2622 105.051 21.2622C105.875 21.2622 106.56 21.4547 107.112 21.8396C107.664 22.2241 108.072 22.737 108.339 23.3773C108.605 24.018 108.739 24.7255 108.739 25.5005V26.0333Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M110.248 23.6115C110.248 23.1326 110.224 22.4034 110.181 21.4237H112.048C112.08 21.6659 112.109 21.9552 112.141 22.2916C112.171 22.6279 112.189 22.901 112.2 23.1109C112.432 22.7288 112.659 22.4073 112.883 22.1463C113.107 21.8851 113.368 21.6727 113.667 21.5083C113.965 21.3443 114.304 21.2622 114.688 21.2622C114.995 21.2622 115.256 21.2946 115.477 21.3592L115.227 23.0868C115.035 23.0276 114.819 22.9979 114.581 22.9979C114.115 22.9979 113.704 23.1177 113.355 23.3573C113.005 23.5965 112.632 23.9908 112.232 24.5398V30.1344H110.248V23.6115Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M116.597 33.6984C116.307 33.6475 116.067 33.5896 115.88 33.5251L116.243 32.072C116.376 32.1094 116.547 32.1458 116.752 32.1808C116.955 32.216 117.149 32.2333 117.333 32.2333C118.216 32.2333 118.877 31.6653 119.317 30.5299L119.448 30.207L116.235 21.4237H118.373L119.989 26.332C120.251 27.1717 120.421 27.8149 120.496 28.2613C120.648 27.6317 120.824 27.0021 121.029 26.3724L122.669 21.4237H124.677L121.475 30.2475C121.173 31.0816 120.845 31.7541 120.496 32.2656C120.147 32.7768 119.733 33.1562 119.259 33.4039C118.781 33.6512 118.208 33.7752 117.533 33.7752C117.2 33.7752 116.888 33.7499 116.597 33.6984Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M41.5933 7.30106H45.1474L45.0474 8.15946H42.6349V9.82186H44.9098V10.6261H42.6349V12.4677H45.1765L45.089 13.3344H41.5933V7.30106Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.5973 10.2552L45.7223 7.30106H46.9055L48.1765 9.472L49.514 7.30106H50.6639L48.8055 10.2176L50.8181 13.3344H49.6181L48.1807 10.9595L46.7181 13.3344H45.5682L47.5973 10.2552Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M54.1973 9.99466C54.4194 9.7936 54.5306 9.50933 54.5306 9.14266C54.5306 8.7704 54.4172 8.50266 54.1911 8.33866C53.9647 8.17466 53.6319 8.0928 53.1932 8.0928H52.6266V10.2344C52.8876 10.276 53.0876 10.2968 53.2266 10.2968C53.6514 10.2968 53.9751 10.1963 54.1973 9.99466ZM51.5847 7.30106H53.2098C53.9735 7.30106 54.5583 7.4568 54.9639 7.76773C55.3695 8.07893 55.5722 8.5288 55.5722 9.1176C55.5722 9.5176 55.4813 9.8672 55.2994 10.1656C55.1173 10.4643 54.8639 10.6936 54.5388 10.8531C54.214 11.0131 53.8402 11.0928 53.418 11.0928C53.1876 11.0928 52.9236 11.0651 52.6266 11.0093V13.3344H51.5847V7.30106Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M56.6015 7.30106H57.6431V12.4427H60.1389L60.0514 13.3344H56.6015V7.30106Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M64.4223 12.2989C64.714 12.1032 64.9319 11.8339 65.0764 11.4907C65.221 11.1477 65.2932 10.7552 65.2932 10.3136C65.2932 9.88026 65.2292 9.49439 65.1015 9.15519C64.9735 8.81625 64.7695 8.54639 64.489 8.34479C64.2084 8.14346 63.8474 8.04266 63.4055 8.04266C62.9834 8.04266 62.625 8.14479 62.3306 8.34906C62.0362 8.55306 61.8154 8.82692 61.6682 9.16986C61.521 9.51306 61.4474 9.89145 61.4474 10.3053C61.4474 10.7413 61.5167 11.1317 61.6556 11.476C61.7943 11.8205 62.0068 12.0928 62.2932 12.2928C62.5791 12.4928 62.9332 12.5928 63.3556 12.5928C63.7751 12.5928 64.1306 12.4947 64.4223 12.2989ZM61.7223 13.0387C61.2807 12.7859 60.9431 12.4309 60.7098 11.9739C60.4764 11.5171 60.3599 10.9859 60.3599 10.3803C60.3599 9.74426 60.4839 9.18799 60.7327 8.71146C60.9813 8.23519 61.3396 7.86719 61.8076 7.60719C62.2756 7.34772 62.8277 7.21759 63.4639 7.21759C64.0722 7.21759 64.5959 7.34346 65.0348 7.59466C65.4735 7.84639 65.8084 8.19972 66.0388 8.65519C66.2695 9.11092 66.3847 9.63865 66.3847 10.2387C66.3847 10.8859 66.2591 11.4485 66.0076 11.9261C65.7562 12.4037 65.3981 12.772 64.9327 13.0301C64.4674 13.2885 63.921 13.4177 63.2932 13.4177C62.6876 13.4177 62.1639 13.2915 61.7223 13.0387Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M69.1807 10.1552C69.4333 10.1552 69.657 10.1061 69.8514 10.0072C70.0458 9.9088 70.1973 9.76986 70.3055 9.59066C70.4141 9.41146 70.4682 9.20373 70.4682 8.96773C70.4682 8.66506 70.3722 8.44346 70.1807 8.3032C69.989 8.16293 69.7098 8.0928 69.3431 8.0928H68.589V10.1552H69.1807ZM67.5474 7.30106H69.4349C70.1237 7.30106 70.6453 7.43866 70.9994 7.7136C71.3535 7.98853 71.5306 8.38186 71.5306 8.8928C71.5306 9.21226 71.4666 9.4936 71.3389 9.73653C71.2111 9.97973 71.0527 10.1776 70.864 10.3301C70.6749 10.4829 70.4807 10.5968 70.2807 10.672L72.1349 13.3344H70.9266L69.3557 10.9427H68.589V13.3344H67.5474V7.30106Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M72.9599 7.30106H76.5141L76.4141 8.15946H74.0015V9.82186H76.2765V10.6261H74.0015V12.4677H76.5431L76.4556 13.3344H72.9599V7.30106Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M80.3807 7.30106H81.4223V13.3344H80.3807V7.30106Z" className="fill-current" />
|
||||||
|
<path d="M84.1765 8.172H82.3055L82.3973 7.30106H87.0933L86.9973 8.172H85.218V13.3344H84.1765V8.172Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
d="M94.3141 12.2989C94.6055 12.1032 94.8237 11.8339 94.9682 11.4907C95.1125 11.1477 95.1847 10.7552 95.1847 10.3136C95.1847 9.88026 95.1207 9.49439 94.9933 9.15519C94.8653 8.81625 94.661 8.54639 94.3807 8.34479C94.0999 8.14346 93.7389 8.04266 93.2973 8.04266C92.8749 8.04266 92.5167 8.14479 92.2223 8.34906C91.9277 8.55306 91.7069 8.82692 91.5599 9.16986C91.4125 9.51306 91.3389 9.89145 91.3389 10.3053C91.3389 10.7413 91.4082 11.1317 91.5474 11.476C91.6861 11.8205 91.8986 12.0928 92.1847 12.2928C92.4709 12.4928 92.825 12.5928 93.2474 12.5928C93.6666 12.5928 94.0223 12.4947 94.3141 12.2989ZM91.6141 13.0387C91.1722 12.7859 90.8349 12.4309 90.6015 11.9739C90.3682 11.5171 90.2514 10.9859 90.2514 10.3803C90.2514 9.74426 90.3757 9.18799 90.6245 8.71146C90.8727 8.23519 91.2311 7.86719 91.6994 7.60719C92.1674 7.34772 92.7194 7.21759 93.3557 7.21759C93.9639 7.21759 94.4874 7.34346 94.9266 7.59466C95.3653 7.84639 95.6999 8.19972 95.9306 8.65519C96.161 9.11092 96.2765 9.63865 96.2765 10.2387C96.2765 10.8859 96.1506 11.4485 95.8994 11.9261C95.6479 12.4037 95.2895 12.772 94.8245 13.0301C94.3591 13.2885 93.8125 13.4177 93.1847 13.4177C92.5791 13.4177 92.0557 13.2915 91.6141 13.0387Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M97.4389 7.30106H98.6349L101.619 11.976C101.592 11.5317 101.581 11.1219 101.581 10.7469V7.30106H102.547V13.3344H101.389L98.3599 8.58426C98.3903 9.12346 98.4055 9.60106 98.4055 10.0176V13.3344H97.4389V7.30106Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
<path d="M19.7881 22.0255H20.8041L20.2945 20.8404L19.7881 22.0255Z" className="fill-current" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M26.9417 7.33331H15.8633C10.6446 7.33331 8.73584 9.24211 8.73584 14.4608V25.5394C8.73584 30.7579 10.6446 32.6666 15.8633 32.6666H26.9382C32.1569 32.6666 34.0692 30.7579 34.0692 25.5394V14.4608C34.0692 9.24211 32.1604 7.33331 26.9417 7.33331ZM19.538 22.6229L19.2366 23.3125H18.5505L20.0097 20.0022H20.6025L22.0558 23.3125H21.3513L21.0537 22.6229H19.538ZM30.5787 23.3102H31.242V20.0022H30.5787V23.3102ZM27.9396 21.8887H29.1617V21.2859H27.9396V20.6078H29.7137V20.0042H27.2766V23.3122H29.7777V22.7088H27.9396V21.8887ZM25.3046 22.2797L24.5526 20.0021H24.0043L23.2524 22.2797L22.5206 20.0036H21.8054L22.9598 23.314H23.5161L24.2692 21.1396L25.022 23.314H25.5833L26.7348 20.0036H26.038L25.3046 22.2797ZM17.5382 21.8979C17.5382 22.4364 17.2708 22.7241 16.7852 22.7241C16.2969 22.7241 16.0281 22.4283 16.0281 21.875V20.004H15.3561V21.8979C15.3561 22.8297 15.8737 23.3638 16.7761 23.3638C17.6873 23.3638 18.2099 22.8194 18.2099 21.8705V20.0021H17.5382V21.8979ZM13.7526 20.0022H14.4243V23.3142H13.7526V21.9693H12.235V23.3142H11.563V20.0022H12.235V21.3383H13.7526V20.0022ZM17.1857 11.5683C17.1857 13.8933 19.0774 15.7851 21.4025 15.7851C23.7276 15.7851 25.6193 13.8933 25.6193 11.5683H25.0236C25.0236 13.5649 23.3993 15.1894 21.4025 15.1894C19.4057 15.1894 17.7814 13.5649 17.7814 11.5683H17.1857Z"
|
||||||
|
className="fill-current"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import type { AnchorHTMLAttributes } from "react";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const GooglePlayButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Get it on Google Play"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] bg-black ring-1 ring-app-store-badge-border outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 135 : 149} height={size === "md" ? 40 : 44} viewBox="0 0 135 40" fill="none">
|
||||||
|
<path
|
||||||
|
d="M68.136 21.7511C65.784 21.7511 63.867 23.5401 63.867 26.0041C63.867 28.4531 65.784 30.2571 68.136 30.2571C70.489 30.2571 72.406 28.4531 72.406 26.0041C72.405 23.5401 70.488 21.7511 68.136 21.7511ZM68.136 28.5831C66.847 28.5831 65.736 27.5201 65.736 26.0051C65.736 24.4741 66.848 23.4271 68.136 23.4271C69.425 23.4271 70.536 24.4741 70.536 26.0051C70.536 27.5191 69.425 28.5831 68.136 28.5831ZM58.822 21.7511C56.47 21.7511 54.553 23.5401 54.553 26.0041C54.553 28.4531 56.47 30.2571 58.822 30.2571C61.175 30.2571 63.092 28.4531 63.092 26.0041C63.092 23.5401 61.175 21.7511 58.822 21.7511ZM58.822 28.5831C57.533 28.5831 56.422 27.5201 56.422 26.0051C56.422 24.4741 57.534 23.4271 58.822 23.4271C60.111 23.4271 61.222 24.4741 61.222 26.0051C61.223 27.5191 60.111 28.5831 58.822 28.5831ZM47.744 23.0571V24.8611H52.062C51.933 25.8761 51.595 26.6171 51.079 27.1321C50.451 27.7601 49.468 28.4531 47.744 28.4531C45.086 28.4531 43.008 26.3101 43.008 23.6521C43.008 20.9941 45.086 18.8511 47.744 18.8511C49.178 18.8511 50.225 19.4151 50.998 20.1401L52.271 18.8671C51.191 17.8361 49.758 17.0471 47.744 17.0471C44.103 17.0471 41.042 20.0111 41.042 23.6521C41.042 27.2931 44.103 30.2571 47.744 30.2571C49.709 30.2571 51.192 29.6121 52.351 28.4041C53.543 27.2121 53.914 25.5361 53.914 24.1831C53.914 23.7651 53.882 23.3781 53.817 23.0561H47.744V23.0571ZM93.052 24.4581C92.698 23.5081 91.618 21.7511 89.411 21.7511C87.22 21.7511 85.399 23.4751 85.399 26.0041C85.399 28.3881 87.204 30.2571 89.62 30.2571C91.569 30.2571 92.697 29.0651 93.165 28.3721L91.715 27.4051C91.232 28.1141 90.571 28.5811 89.62 28.5811C88.67 28.5811 87.993 28.1461 87.558 27.2921L93.245 24.9401L93.052 24.4581ZM87.252 25.8761C87.204 24.2321 88.525 23.3951 89.476 23.3951C90.217 23.3951 90.845 23.7661 91.055 24.2971L87.252 25.8761ZM82.629 30.0001H84.497V17.4991H82.629V30.0001ZM79.567 22.7021H79.503C79.084 22.2021 78.278 21.7511 77.264 21.7511C75.137 21.7511 73.188 23.6201 73.188 26.0211C73.188 28.4051 75.137 30.2581 77.264 30.2581C78.279 30.2581 79.084 29.8071 79.503 29.2921H79.567V29.9041C79.567 31.5311 78.697 32.4011 77.296 32.4011C76.152 32.4011 75.443 31.5801 75.153 30.8871L73.526 31.5641C73.993 32.6911 75.233 34.0771 77.296 34.0771C79.487 34.0771 81.34 32.7881 81.34 29.6461V22.0101H79.568V22.7021H79.567ZM77.425 28.5831C76.136 28.5831 75.057 27.5031 75.057 26.0211C75.057 24.5221 76.136 23.4271 77.425 23.4271C78.697 23.4271 79.696 24.5221 79.696 26.0211C79.696 27.5031 78.697 28.5831 77.425 28.5831ZM101.806 17.4991H97.335V30.0001H99.2V25.2641H101.805C103.873 25.2641 105.907 23.7671 105.907 21.3821C105.907 18.9971 103.874 17.4991 101.806 17.4991ZM101.854 23.5241H99.2V19.2391H101.854C103.249 19.2391 104.041 20.3941 104.041 21.3821C104.041 22.3501 103.249 23.5241 101.854 23.5241ZM113.386 21.7291C112.035 21.7291 110.636 22.3241 110.057 23.6431L111.713 24.3341C112.067 23.6431 112.727 23.4171 113.418 23.4171C114.383 23.4171 115.364 23.9961 115.38 25.0251V25.1541C115.042 24.9611 114.318 24.6721 113.434 24.6721C111.649 24.6721 109.831 25.6531 109.831 27.4861C109.831 29.1591 111.295 30.2361 112.935 30.2361C114.189 30.2361 114.881 29.6731 115.315 29.0131H115.379V29.9781H117.181V25.1851C117.182 22.9671 115.524 21.7291 113.386 21.7291ZM113.16 28.5801C112.55 28.5801 111.697 28.2741 111.697 27.5181C111.697 26.5531 112.759 26.1831 113.676 26.1831C114.495 26.1831 114.882 26.3601 115.38 26.6011C115.235 27.7601 114.238 28.5801 113.16 28.5801ZM123.743 22.0021L121.604 27.4221H121.54L119.32 22.0021H117.31L120.639 29.5771L118.741 33.7911H120.687L125.818 22.0021H123.743ZM106.937 30.0001H108.802V17.4991H106.937V30.0001Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z" fill="white" />
|
||||||
|
<path d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z" fill="white" />
|
||||||
|
<path d="M59.938 13V7H60.709V13H59.938Z" fill="white" />
|
||||||
|
<path d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.418 10.2429C47.418 11.0809 47.1701 11.7479 46.673 12.2459C46.109 12.8379 45.3731 13.1339 44.4691 13.1339C43.6031 13.1339 42.8661 12.8339 42.2611 12.2339C41.6551 11.6329 41.3521 10.8889 41.3521 10.0009C41.3521 9.11194 41.6551 8.36794 42.2611 7.76794C42.8661 7.16694 43.6031 6.86694 44.4691 6.86694C44.8991 6.86694 45.3101 6.95094 45.7001 7.11794C46.0911 7.28594 46.404 7.50894 46.6381 7.78794L46.111 8.31594C45.714 7.84094 45.167 7.60394 44.468 7.60394C43.836 7.60394 43.29 7.82594 42.829 8.26994C42.368 8.71394 42.1381 9.29094 42.1381 9.99994C42.1381 10.7089 42.368 11.2859 42.829 11.7299C43.29 12.1739 43.836 12.3959 44.468 12.3959C45.138 12.3959 45.6971 12.1729 46.1441 11.7259C46.4341 11.4349 46.602 11.0299 46.647 10.5109H44.468V9.78994H47.375C47.405 9.94694 47.418 10.0979 47.418 10.2429Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z" stroke="white" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path d="M59.938 13V7H60.709V13H59.938Z" stroke="white" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z" stroke="white" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path
|
||||||
|
d="M73.6089 12.225C73.0189 12.831 72.2859 13.134 71.4089 13.134C70.5319 13.134 69.7989 12.831 69.2099 12.225C68.6199 11.619 68.3259 10.877 68.3259 9.99999C68.3259 9.12299 68.6199 8.38099 69.2099 7.77499C69.7989 7.16899 70.5319 6.86499 71.4089 6.86499C72.2809 6.86499 73.0129 7.16999 73.6049 7.77899C74.1969 8.38799 74.4929 9.12799 74.4929 9.99999C74.4929 10.877 74.1979 11.619 73.6089 12.225ZM69.7789 11.722C70.2229 12.172 70.7659 12.396 71.4089 12.396C72.0519 12.396 72.5959 12.171 73.0389 11.722C73.4829 11.272 73.7059 10.698 73.7059 9.99999C73.7059 9.30199 73.4829 8.72799 73.0389 8.27799C72.5959 7.82799 72.0519 7.60399 71.4089 7.60399C70.7659 7.60399 70.2229 7.82899 69.7789 8.27799C69.3359 8.72799 69.1129 9.30199 69.1129 9.99999C69.1129 10.698 69.3359 11.272 69.7789 11.722Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<g filter="url(#filter0_ii_1303_2188)">
|
||||||
|
<path
|
||||||
|
d="M10.4361 7.53803C10.1451 7.84603 9.97314 8.32403 9.97314 8.94303V31.059C9.97314 31.679 10.1451 32.156 10.4361 32.464L10.5101 32.536L22.8991 20.147V20.001V19.855L10.5101 7.46503L10.4361 7.53803Z"
|
||||||
|
fill="url(#paint0_linear_1303_2188)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M27.0279 24.278L22.8989 20.147V20.001V19.855L27.0289 15.725L27.1219 15.778L32.0149 18.558C33.4119 19.352 33.4119 20.651 32.0149 21.446L27.1219 24.226L27.0279 24.278Z"
|
||||||
|
fill="url(#paint1_linear_1303_2188)"
|
||||||
|
/>
|
||||||
|
<g filter="url(#filter1_i_1303_2188)">
|
||||||
|
<path
|
||||||
|
d="M27.122 24.225L22.898 20.001L10.436 32.464C10.896 32.952 11.657 33.012 12.514 32.526L27.122 24.225Z"
|
||||||
|
fill="url(#paint2_linear_1303_2188)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M27.122 15.777L12.514 7.47701C11.657 6.99001 10.896 7.05101 10.436 7.53901L22.899 20.002L27.122 15.777Z"
|
||||||
|
fill="url(#paint3_linear_1303_2188)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="filter0_ii_1303_2188"
|
||||||
|
x="9.97314"
|
||||||
|
y="7.14093"
|
||||||
|
width="23.0894"
|
||||||
|
height="25.7207"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||||
|
<feOffset dy="-0.15" />
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0" />
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1303_2188" />
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||||
|
<feOffset dy="0.15" />
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0" />
|
||||||
|
<feBlend mode="normal" in2="effect1_innerShadow_1303_2188" result="effect2_innerShadow_1303_2188" />
|
||||||
|
</filter>
|
||||||
|
<filter
|
||||||
|
id="filter1_i_1303_2188"
|
||||||
|
x="10.436"
|
||||||
|
y="20.001"
|
||||||
|
width="16.686"
|
||||||
|
height="12.8607"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||||
|
<feOffset dy="-0.15" />
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0" />
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1303_2188" />
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_1303_2188" x1="21.8009" y1="8.70903" x2="5.01895" y2="25.491" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#00A0FF" />
|
||||||
|
<stop offset="0.0066" stopColor="#00A1FF" />
|
||||||
|
<stop offset="0.2601" stopColor="#00BEFF" />
|
||||||
|
<stop offset="0.5122" stopColor="#00D2FF" />
|
||||||
|
<stop offset="0.7604" stopColor="#00DFFF" />
|
||||||
|
<stop offset="1" stopColor="#00E3FF" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_1303_2188" x1="33.8334" y1="20.001" x2="9.63753" y2="20.001" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#FFE000" />
|
||||||
|
<stop offset="0.4087" stopColor="#FFBD00" />
|
||||||
|
<stop offset="0.7754" stopColor="#FFA500" />
|
||||||
|
<stop offset="1" stopColor="#FF9C00" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_1303_2188" x1="24.8281" y1="22.2949" x2="2.06964" y2="45.0534" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#FF3A44" />
|
||||||
|
<stop offset="1" stopColor="#C31162" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_1303_2188" x1="7.29743" y1="0.176806" x2="17.4597" y2="10.3391" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#32A071" />
|
||||||
|
<stop offset="0.0685" stopColor="#2DA771" />
|
||||||
|
<stop offset="0.4762" stopColor="#15CF74" />
|
||||||
|
<stop offset="0.8009" stopColor="#06E775" />
|
||||||
|
<stop offset="1" stopColor="#00F076" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GooglePlayWhiteButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Get it on Google Play"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] bg-black ring-1 ring-app-store-badge-border outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 135 : 149} height={size === "md" ? 40 : 44} viewBox="0 0 135 40" fill="none">
|
||||||
|
<rect x="0.5" y="0.5" width="134" height="39" rx="6.5" stroke="white" />
|
||||||
|
<path
|
||||||
|
d="M68.136 21.7509C65.784 21.7509 63.867 23.5399 63.867 26.0039C63.867 28.4529 65.784 30.2569 68.136 30.2569C70.489 30.2569 72.406 28.4529 72.406 26.0039C72.405 23.5399 70.488 21.7509 68.136 21.7509ZM68.136 28.5829C66.847 28.5829 65.736 27.5199 65.736 26.0049C65.736 24.4739 66.848 23.4269 68.136 23.4269C69.425 23.4269 70.536 24.4739 70.536 26.0049C70.536 27.5189 69.425 28.5829 68.136 28.5829ZM58.822 21.7509C56.47 21.7509 54.553 23.5399 54.553 26.0039C54.553 28.4529 56.47 30.2569 58.822 30.2569C61.175 30.2569 63.092 28.4529 63.092 26.0039C63.092 23.5399 61.175 21.7509 58.822 21.7509ZM58.822 28.5829C57.533 28.5829 56.422 27.5199 56.422 26.0049C56.422 24.4739 57.534 23.4269 58.822 23.4269C60.111 23.4269 61.222 24.4739 61.222 26.0049C61.223 27.5189 60.111 28.5829 58.822 28.5829ZM47.744 23.0569V24.8609H52.062C51.933 25.8759 51.595 26.6169 51.079 27.1319C50.451 27.7599 49.468 28.4529 47.744 28.4529C45.086 28.4529 43.008 26.3099 43.008 23.6519C43.008 20.9939 45.086 18.8509 47.744 18.8509C49.178 18.8509 50.225 19.4149 50.998 20.1399L52.271 18.8669C51.191 17.8359 49.758 17.0469 47.744 17.0469C44.103 17.0469 41.042 20.0109 41.042 23.6519C41.042 27.2929 44.103 30.2569 47.744 30.2569C49.709 30.2569 51.192 29.6119 52.351 28.4039C53.543 27.2119 53.914 25.5359 53.914 24.1829C53.914 23.7649 53.882 23.3779 53.817 23.0559H47.744V23.0569ZM93.052 24.4579C92.698 23.5079 91.618 21.7509 89.411 21.7509C87.22 21.7509 85.399 23.4749 85.399 26.0039C85.399 28.3879 87.204 30.2569 89.62 30.2569C91.569 30.2569 92.697 29.0649 93.165 28.3719L91.715 27.4049C91.232 28.1139 90.571 28.5809 89.62 28.5809C88.67 28.5809 87.993 28.1459 87.558 27.2919L93.245 24.9399L93.052 24.4579ZM87.252 25.8759C87.204 24.2319 88.525 23.3949 89.476 23.3949C90.217 23.3949 90.845 23.7659 91.055 24.2969L87.252 25.8759ZM82.629 29.9999H84.497V17.4989H82.629V29.9999ZM79.567 22.7019H79.503C79.084 22.2019 78.278 21.7509 77.264 21.7509C75.137 21.7509 73.188 23.6199 73.188 26.0209C73.188 28.4049 75.137 30.2579 77.264 30.2579C78.279 30.2579 79.084 29.8069 79.503 29.2919H79.567V29.9039C79.567 31.5309 78.697 32.4009 77.296 32.4009C76.152 32.4009 75.443 31.5799 75.153 30.8869L73.526 31.5639C73.993 32.6909 75.233 34.0769 77.296 34.0769C79.487 34.0769 81.34 32.7879 81.34 29.6459V22.0099H79.568V22.7019H79.567ZM77.425 28.5829C76.136 28.5829 75.057 27.5029 75.057 26.0209C75.057 24.5219 76.136 23.4269 77.425 23.4269C78.697 23.4269 79.696 24.5219 79.696 26.0209C79.696 27.5029 78.697 28.5829 77.425 28.5829ZM101.806 17.4989H97.335V29.9999H99.2V25.2639H101.805C103.873 25.2639 105.907 23.7669 105.907 21.3819C105.907 18.9969 103.874 17.4989 101.806 17.4989ZM101.854 23.5239H99.2V19.2389H101.854C103.249 19.2389 104.041 20.3939 104.041 21.3819C104.041 22.3499 103.249 23.5239 101.854 23.5239ZM113.386 21.7289C112.035 21.7289 110.636 22.3239 110.057 23.6429L111.713 24.3339C112.067 23.6429 112.727 23.4169 113.418 23.4169C114.383 23.4169 115.364 23.9959 115.38 25.0249V25.1539C115.042 24.9609 114.318 24.6719 113.434 24.6719C111.649 24.6719 109.831 25.6529 109.831 27.4859C109.831 29.1589 111.295 30.2359 112.935 30.2359C114.189 30.2359 114.881 29.6729 115.315 29.0129H115.379V29.9779H117.181V25.1849C117.182 22.9669 115.524 21.7289 113.386 21.7289ZM113.16 28.5799C112.55 28.5799 111.697 28.2739 111.697 27.5179C111.697 26.5529 112.759 26.1829 113.676 26.1829C114.495 26.1829 114.882 26.3599 115.38 26.6009C115.235 27.7599 114.238 28.5799 113.16 28.5799ZM123.743 22.0019L121.604 27.4219H121.54L119.32 22.0019H117.31L120.639 29.5769L118.741 33.7909H120.687L125.818 22.0019H123.743ZM106.937 29.9999H108.802V17.4989H106.937V29.9999Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.418 10.2432C47.418 11.0812 47.1701 11.7482 46.673 12.2462C46.109 12.8382 45.3731 13.1342 44.4691 13.1342C43.6031 13.1342 42.8661 12.8342 42.2611 12.2342C41.6551 11.6332 41.3521 10.8892 41.3521 10.0012C41.3521 9.11219 41.6551 8.36819 42.2611 7.76819C42.8661 7.16719 43.6031 6.86719 44.4691 6.86719C44.8991 6.86719 45.3101 6.95119 45.7001 7.11819C46.0911 7.28619 46.404 7.50919 46.6381 7.78819L46.111 8.31619C45.714 7.84119 45.167 7.60419 44.468 7.60419C43.836 7.60419 43.29 7.82619 42.829 8.27019C42.368 8.71419 42.1381 9.29119 42.1381 10.0002C42.1381 10.7092 42.368 11.2862 42.829 11.7302C43.29 12.1742 43.836 12.3962 44.468 12.3962C45.138 12.3962 45.6971 12.1732 46.1441 11.7262C46.4341 11.4352 46.602 11.0302 46.647 10.5112H44.468V9.79019H47.375C47.405 9.94719 47.418 10.0982 47.418 10.2432Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M52.0281 7.73724H49.2961V9.63924H51.7601V10.3602H49.2961V12.2622H52.0281V13.0002H48.5251V7.00024H52.0281V7.73724Z" fill="white" />
|
||||||
|
<path d="M55.279 13.0002H54.508V7.73724H52.832V7.00024H56.955V7.73724H55.279V13.0002Z" fill="white" />
|
||||||
|
<path d="M59.938 13.0002V7.00024H60.709V13.0002H59.938Z" fill="white" />
|
||||||
|
<path d="M64.1281 13.0002H63.3572V7.73724H61.6812V7.00024H65.8042V7.73724H64.1281V13.0002Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M73.6089 12.2252C73.0189 12.8312 72.2859 13.1342 71.4089 13.1342C70.5319 13.1342 69.7989 12.8312 69.2099 12.2252C68.6199 11.6192 68.3259 10.8772 68.3259 10.0002C68.3259 9.12323 68.6199 8.38123 69.2099 7.77523C69.7989 7.16923 70.5319 6.86523 71.4089 6.86523C72.2809 6.86523 73.0129 7.17023 73.6049 7.77923C74.1969 8.38823 74.4929 9.12823 74.4929 10.0002C74.4929 10.8772 74.1979 11.6192 73.6089 12.2252ZM69.7789 11.7222C70.2229 12.1722 70.7659 12.3962 71.4089 12.3962C72.0519 12.3962 72.5959 12.1712 73.0389 11.7222C73.4829 11.2722 73.7059 10.6982 73.7059 10.0002C73.7059 9.30223 73.4829 8.72823 73.0389 8.27823C72.5959 7.82823 72.0519 7.60423 71.4089 7.60423C70.7659 7.60423 70.2229 7.82923 69.7789 8.27823C69.3359 8.72823 69.1129 9.30223 69.1129 10.0002C69.1129 10.6982 69.3359 11.2722 69.7789 11.7222Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M75.5749 13.0002V7.00024H76.513L79.429 11.6672H79.4619L79.429 10.5112V7.00024H80.1999V13.0002H79.3949L76.344 8.10625H76.3109L76.344 9.26224V13.0002H75.5749Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.418 10.2432C47.418 11.0812 47.1701 11.7482 46.673 12.2462C46.109 12.8382 45.3731 13.1342 44.4691 13.1342C43.6031 13.1342 42.8661 12.8342 42.2611 12.2342C41.6551 11.6332 41.3521 10.8892 41.3521 10.0012C41.3521 9.11219 41.6551 8.36819 42.2611 7.76819C42.8661 7.16719 43.6031 6.86719 44.4691 6.86719C44.8991 6.86719 45.3101 6.95119 45.7001 7.11819C46.0911 7.28619 46.404 7.50919 46.6381 7.78819L46.111 8.31619C45.714 7.84119 45.167 7.60419 44.468 7.60419C43.836 7.60419 43.29 7.82619 42.829 8.27019C42.368 8.71419 42.1381 9.29119 42.1381 10.0002C42.1381 10.7092 42.368 11.2862 42.829 11.7302C43.29 12.1742 43.836 12.3962 44.468 12.3962C45.138 12.3962 45.6971 12.1732 46.1441 11.7262C46.4341 11.4352 46.602 11.0302 46.647 10.5112H44.468V9.79019H47.375C47.405 9.94719 47.418 10.0982 47.418 10.2432Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M52.0281 7.73724H49.2961V9.63924H51.7601V10.3602H49.2961V12.2622H52.0281V13.0002H48.5251V7.00024H52.0281V7.73724Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path d="M55.279 13.0002H54.508V7.73724H52.832V7.00024H56.955V7.73724H55.279V13.0002Z" stroke="white" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path d="M59.938 13.0002V7.00024H60.709V13.0002H59.938Z" stroke="white" strokeWidth="0.2" strokeMiterlimit="10" />
|
||||||
|
<path
|
||||||
|
d="M64.1281 13.0002H63.3572V7.73724H61.6812V7.00024H65.8042V7.73724H64.1281V13.0002Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M73.6089 12.2252C73.0189 12.8312 72.2859 13.1342 71.4089 13.1342C70.5319 13.1342 69.7989 12.8312 69.2099 12.2252C68.6199 11.6192 68.3259 10.8772 68.3259 10.0002C68.3259 9.12323 68.6199 8.38123 69.2099 7.77523C69.7989 7.16923 70.5319 6.86523 71.4089 6.86523C72.2809 6.86523 73.0129 7.17023 73.6049 7.77923C74.1969 8.38823 74.4929 9.12823 74.4929 10.0002C74.4929 10.8772 74.1979 11.6192 73.6089 12.2252ZM69.7789 11.7222C70.2229 12.1722 70.7659 12.3962 71.4089 12.3962C72.0519 12.3962 72.5959 12.1712 73.0389 11.7222C73.4829 11.2722 73.7059 10.6982 73.7059 10.0002C73.7059 9.30223 73.4829 8.72823 73.0389 8.27823C72.5959 7.82823 72.0519 7.60423 71.4089 7.60423C70.7659 7.60423 70.2229 7.82923 69.7789 8.27823C69.3359 8.72823 69.1129 9.30223 69.1129 10.0002C69.1129 10.6982 69.3359 11.2722 69.7789 11.7222Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M75.5749 13.0002V7.00024H76.513L79.429 11.6672H79.4619L79.429 10.5112V7.00024H80.1999V13.0002H79.3949L76.344 8.10625H76.3109L76.344 9.26224V13.0002H75.5749Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10.1565 7.96617C10.0384 8.23367 9.97314 8.56144 9.97314 8.94269V31.0587C9.97314 31.4408 10.0385 31.7686 10.1567 32.036L22.1907 20.0003L10.1565 7.96617ZM10.8517 32.7552C11.2978 32.9461 11.8797 32.8855 12.5141 32.5257L26.6712 24.4809L22.8978 20.7074L10.8517 32.7552ZM27.5737 23.9691L32.0151 21.4457C33.4121 20.6507 33.4121 19.3517 32.0151 18.5577L27.5717 16.0328L23.6048 20.0003L27.5737 23.9691ZM26.6699 15.5204L12.5141 7.4767C11.8796 7.11612 11.2977 7.05596 10.8516 7.2471L22.8977 19.2932L26.6699 15.5204Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppStoreButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Download on the App Store"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] bg-black ring-1 ring-app-store-badge-border outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 120 : 132} height={size === "md" ? 40 : 44} viewBox="0 0 120 40" fill="none">
|
||||||
|
<path
|
||||||
|
d="M81.5257 19.2009V21.4919H80.0896V22.9944H81.5257V28.0994C81.5257 29.8425 82.3143 30.5398 84.2981 30.5398C84.6468 30.5398 84.9788 30.4983 85.2693 30.4485V28.9626C85.0203 28.9875 84.8626 29.0041 84.5887 29.0041C83.7005 29.0041 83.3104 28.5891 83.3104 27.6428V22.9944H85.2693V21.4919H83.3104V19.2009H81.5257Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M90.3232 30.6643C92.9628 30.6643 94.5815 28.8962 94.5815 25.9661C94.5815 23.0525 92.9545 21.2761 90.3232 21.2761C87.6835 21.2761 86.0566 23.0525 86.0566 25.9661C86.0566 28.8962 87.6752 30.6643 90.3232 30.6643ZM90.3232 29.0789C88.7709 29.0789 87.8994 27.9416 87.8994 25.9661C87.8994 24.0071 88.7709 22.8616 90.3232 22.8616C91.8671 22.8616 92.747 24.0071 92.747 25.9661C92.747 27.9333 91.8671 29.0789 90.3232 29.0789Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.9664 30.49H97.7511V25.1526C97.7511 23.8826 98.7056 23.0276 100.059 23.0276C100.374 23.0276 100.905 23.0857 101.055 23.1355V21.3757C100.864 21.3259 100.524 21.301 100.258 21.301C99.0792 21.301 98.0748 21.9485 97.8175 22.8367H97.6846V21.4504H95.9664V30.49Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M105.486 22.7952C106.806 22.7952 107.669 23.7165 107.711 25.136H103.145C103.245 23.7248 104.166 22.7952 105.486 22.7952ZM107.702 28.0496C107.37 28.7551 106.632 29.1453 105.552 29.1453C104.125 29.1453 103.203 28.1409 103.145 26.5554V26.4558H109.529V25.8332C109.529 22.9944 108.009 21.2761 105.494 21.2761C102.946 21.2761 101.327 23.1106 101.327 25.9993C101.327 28.8879 102.913 30.6643 105.503 30.6643C107.57 30.6643 109.014 29.6682 109.421 28.0496H107.702Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M69.8221 27.1518C69.9598 29.3715 71.8095 30.7911 74.5626 30.7911C77.505 30.7911 79.3462 29.3027 79.3462 26.9281C79.3462 25.0612 78.2966 24.0287 75.7499 23.4351L74.382 23.0996C72.7645 22.721 72.1106 22.2134 72.1106 21.3272C72.1106 20.2088 73.1259 19.4775 74.6487 19.4775C76.0941 19.4775 77.0921 20.1916 77.2727 21.3358H79.1483C79.0365 19.2452 77.1953 17.774 74.6745 17.774C71.9644 17.774 70.1576 19.2452 70.1576 21.4563C70.1576 23.2802 71.1815 24.3643 73.427 24.8891L75.0272 25.2763C76.6705 25.6634 77.3932 26.2312 77.3932 27.1776C77.3932 28.2789 76.2575 29.079 74.7089 29.079C73.0484 29.079 71.8955 28.3305 71.7321 27.1518H69.8221Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M51.3348 21.301C50.1063 21.301 49.0437 21.9153 48.4959 22.9446H48.3631V21.4504H46.6448V33.4949H48.4295V29.1204H48.5706C49.0437 30.0749 50.0647 30.6394 51.3514 30.6394C53.6341 30.6394 55.0867 28.8381 55.0867 25.9661C55.0867 23.094 53.6341 21.301 51.3348 21.301ZM50.8284 29.0373C49.3343 29.0373 48.3963 27.8586 48.3963 25.9744C48.3963 24.0818 49.3343 22.9031 50.8367 22.9031C52.3475 22.9031 53.2522 24.0569 53.2522 25.9661C53.2522 27.8835 52.3475 29.0373 50.8284 29.0373Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M61.3316 21.301C60.103 21.301 59.0405 21.9153 58.4927 22.9446H58.3599V21.4504H56.6416V33.4949H58.4263V29.1204H58.5674C59.0405 30.0749 60.0615 30.6394 61.3482 30.6394C63.6309 30.6394 65.0835 28.8381 65.0835 25.9661C65.0835 23.094 63.6309 21.301 61.3316 21.301ZM60.8252 29.0373C59.3311 29.0373 58.3931 27.8586 58.3931 25.9744C58.3931 24.0818 59.3311 22.9031 60.8335 22.9031C62.3443 22.9031 63.249 24.0569 63.249 25.9661C63.249 27.8835 62.3443 29.0373 60.8252 29.0373Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M43.4428 30.49H45.4905L41.008 18.0751H38.9346L34.4521 30.49H36.431L37.5752 27.1948H42.3072L43.4428 30.49ZM39.8724 20.3292H40.0186L41.8168 25.5774H38.0656L39.8724 20.3292Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M35.6514 8.71094V14.7H37.8137C39.5984 14.7 40.6318 13.6001 40.6318 11.6868C40.6318 9.80249 39.5901 8.71094 37.8137 8.71094H35.6514ZM36.5811 9.55762H37.71C38.9509 9.55762 39.6855 10.3462 39.6855 11.6992C39.6855 13.073 38.9634 13.8533 37.71 13.8533H36.5811V9.55762Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M43.7969 14.7871C45.1167 14.7871 45.9261 13.9031 45.9261 12.438C45.9261 10.9812 45.1126 10.093 43.7969 10.093C42.4771 10.093 41.6636 10.9812 41.6636 12.438C41.6636 13.9031 42.4729 14.7871 43.7969 14.7871ZM43.7969 13.9944C43.0208 13.9944 42.585 13.4258 42.585 12.438C42.585 11.4585 43.0208 10.8857 43.7969 10.8857C44.5689 10.8857 45.0088 11.4585 45.0088 12.438C45.0088 13.4216 44.5689 13.9944 43.7969 13.9944Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M52.8182 10.1802H51.9259L51.1207 13.6292H51.0501L50.1205 10.1802H49.2655L48.3358 13.6292H48.2694L47.4601 10.1802H46.5553L47.8004 14.7H48.7176L49.6473 11.3713H49.7179L50.6517 14.7H51.5772L52.8182 10.1802Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M53.8458 14.7H54.7382V12.0562C54.7382 11.3506 55.1574 10.9106 55.8173 10.9106C56.4772 10.9106 56.7926 11.2717 56.7926 11.998V14.7H57.685V11.7739C57.685 10.699 57.1288 10.093 56.1203 10.093C55.4396 10.093 54.9914 10.396 54.7714 10.8982H54.705V10.1802H53.8458V14.7Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M59.0903 14.7H59.9826V8.41626H59.0903V14.7Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M63.3386 14.7871C64.6584 14.7871 65.4678 13.9031 65.4678 12.438C65.4678 10.9812 64.6543 10.093 63.3386 10.093C62.0188 10.093 61.2053 10.9812 61.2053 12.438C61.2053 13.9031 62.0146 14.7871 63.3386 14.7871ZM63.3386 13.9944C62.5625 13.9944 62.1267 13.4258 62.1267 12.438C62.1267 11.4585 62.5625 10.8857 63.3386 10.8857C64.1106 10.8857 64.5505 11.4585 64.5505 12.438C64.5505 13.4216 64.1106 13.9944 63.3386 13.9944Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M68.1265 14.0234C67.6409 14.0234 67.2881 13.7869 67.2881 13.3801C67.2881 12.9817 67.5704 12.77 68.1929 12.7285L69.2969 12.658V13.0356C69.2969 13.5959 68.7989 14.0234 68.1265 14.0234ZM67.8982 14.7747C68.4917 14.7747 68.9856 14.5173 69.2554 14.0649H69.326V14.7H70.1851V11.6121C70.1851 10.6575 69.5459 10.093 68.4129 10.093C67.3877 10.093 66.6573 10.5911 66.566 11.3672H67.4292C67.5289 11.0476 67.8733 10.865 68.3714 10.865C68.9815 10.865 69.2969 11.1348 69.2969 11.6121V12.0022L68.0726 12.0728C66.9976 12.1392 66.3916 12.6082 66.3916 13.4216C66.3916 14.2476 67.0267 14.7747 67.8982 14.7747Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M73.2132 14.7747C73.8358 14.7747 74.3629 14.48 74.6327 13.9861H74.7032V14.7H75.5582V8.41626H74.6659V10.8982H74.5995C74.3546 10.4001 73.8316 10.1055 73.2132 10.1055C72.0719 10.1055 71.3373 11.0103 71.3373 12.438C71.3373 13.8699 72.0636 14.7747 73.2132 14.7747ZM73.4664 10.9065C74.2135 10.9065 74.6825 11.5 74.6825 12.4421C74.6825 13.3884 74.2176 13.9736 73.4664 13.9736C72.711 13.9736 72.2586 13.3967 72.2586 12.438C72.2586 11.4875 72.7152 10.9065 73.4664 10.9065Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M81.3447 14.7871C82.6645 14.7871 83.4738 13.9031 83.4738 12.438C83.4738 10.9812 82.6604 10.093 81.3447 10.093C80.0249 10.093 79.2114 10.9812 79.2114 12.438C79.2114 13.9031 80.0207 14.7871 81.3447 14.7871ZM81.3447 13.9944C80.5686 13.9944 80.1328 13.4258 80.1328 12.438C80.1328 11.4585 80.5686 10.8857 81.3447 10.8857C82.1166 10.8857 82.5566 11.4585 82.5566 12.438C82.5566 13.4216 82.1166 13.9944 81.3447 13.9944Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M84.655 14.7H85.5474V12.0562C85.5474 11.3506 85.9666 10.9106 86.6265 10.9106C87.2864 10.9106 87.6018 11.2717 87.6018 11.998V14.7H88.4941V11.7739C88.4941 10.699 87.938 10.093 86.9294 10.093C86.2488 10.093 85.8005 10.396 85.5806 10.8982H85.5142V10.1802H84.655V14.7Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M92.6039 9.05542V10.2009H91.8858V10.9521H92.6039V13.5046C92.6039 14.3762 92.9981 14.7249 93.9901 14.7249C94.1644 14.7249 94.3304 14.7041 94.4757 14.6792V13.9363C94.3512 13.9487 94.2723 13.957 94.1353 13.957C93.6913 13.957 93.4962 13.7495 93.4962 13.2764V10.9521H94.4757V10.2009H93.4962V9.05542H92.6039Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.6735 14.7H96.5658V12.0603C96.5658 11.3755 96.9726 10.9148 97.703 10.9148C98.3339 10.9148 98.6701 11.28 98.6701 12.0022V14.7H99.5624V11.7822C99.5624 10.7073 98.9689 10.0972 98.006 10.0972C97.3253 10.0972 96.848 10.4001 96.6281 10.9065H96.5575V8.41626H95.6735V14.7Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M102.781 10.8525C103.441 10.8525 103.873 11.3132 103.894 12.0229H101.611C101.661 11.3174 102.122 10.8525 102.781 10.8525ZM103.89 13.4797C103.724 13.8325 103.354 14.0276 102.815 14.0276C102.101 14.0276 101.64 13.5254 101.611 12.7327V12.6829H104.803V12.3716C104.803 10.9521 104.043 10.093 102.786 10.093C101.511 10.093 100.702 11.0103 100.702 12.4546C100.702 13.8989 101.495 14.7871 102.79 14.7871C103.823 14.7871 104.545 14.2891 104.749 13.4797H103.89Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M24.769 20.3008C24.7907 18.6198 25.6934 17.0292 27.1256 16.1488C26.2221 14.8584 24.7088 14.0403 23.1344 13.9911C21.4552 13.8148 19.8272 14.9959 18.9715 14.9959C18.0992 14.9959 16.7817 14.0086 15.363 14.0378C13.5137 14.0975 11.7898 15.1489 10.8901 16.7656C8.95607 20.1141 10.3987 25.0351 12.2513 27.7417C13.1782 29.0671 14.2615 30.5475 15.6789 30.495C17.066 30.4375 17.584 29.6105 19.2583 29.6105C20.9171 29.6105 21.4031 30.495 22.8493 30.4616C24.3377 30.4375 25.2754 29.1304 26.1698 27.7925C26.8358 26.8481 27.3483 25.8044 27.6882 24.7C25.9391 23.9602 24.771 22.2 24.769 20.3008Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22.0373 12.2111C22.8489 11.2369 23.2487 9.98469 23.1518 8.72046C21.912 8.85068 20.7668 9.44324 19.9443 10.3801C19.14 11.2954 18.7214 12.5255 18.8006 13.7415C20.0408 13.7542 21.2601 13.1777 22.0373 12.2111Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GalaxyStoreButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Available on Galaxy Store"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] bg-black ring-1 ring-app-store-badge-border outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 147 : 162} height={size === "md" ? 40 : 44} viewBox="0 0 147 40" fill="none">
|
||||||
|
<path d="M64.7516 20.3987H66.7715V31.3885H64.7516V20.3987Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M42.5 25.9699C42.5 22.8811 44.8314 20.4009 48.039 20.4009C50.2816 20.4009 52.0489 21.5444 52.9695 23.1779L51.1875 24.2473C50.5193 23.0146 49.4799 22.3611 48.054 22.3611C46.0343 22.3611 44.5047 23.9946 44.5047 25.9699C44.5047 27.9745 46.0196 29.5786 48.1281 29.5786C49.7469 29.5786 50.8757 28.6578 51.2616 27.2322H47.8017V25.3017H53.4298V26.1037C53.4298 29.0289 51.3657 31.5392 48.1281 31.5392C44.7423 31.5392 42.5 28.9695 42.5 25.9699Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M54.3525 27.0543C54.3525 24.1732 56.4613 22.525 58.6592 22.525C59.8027 22.525 60.8274 22.9999 61.4512 23.7426V22.7032H63.4558V31.3907H61.4512V30.2616C60.8124 31.0636 59.773 31.5689 58.6295 31.5689C56.5354 31.5689 54.3525 29.8904 54.3525 27.0543ZM61.555 27.0246C61.555 25.5543 60.4412 24.3514 58.9562 24.3514C57.4713 24.3514 56.3278 25.5249 56.3278 27.0246C56.3278 28.539 57.4713 29.7272 58.9562 29.7272C60.4412 29.7272 61.555 28.5243 61.555 27.0246Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M67.7938 27.0543C67.7938 24.1732 69.9026 22.525 72.1005 22.525C73.244 22.525 74.2686 22.9999 74.8925 23.7426V22.7032H76.8971V31.3907H74.8925V30.2616C74.2539 31.0636 73.2146 31.5689 72.0707 31.5689C69.977 31.5689 67.7938 29.8904 67.7938 27.0543ZM74.9966 27.0246C74.9966 25.5543 73.8828 24.3514 72.3981 24.3514C70.9128 24.3514 69.7693 25.5249 69.7693 27.0246C69.7693 28.539 70.9128 29.7272 72.3981 29.7272C73.8825 29.7272 74.9966 28.5243 74.9966 27.0246Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M80.8048 26.9652L77.6566 22.7032H80.1072L82.0818 25.4652L84.0424 22.7032H86.4182L83.2994 26.9949L86.6261 31.3907H84.1759L82.0524 28.4949L79.988 31.3907H77.5375L80.8048 26.9652Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M90.3846 30.9598L86.8206 22.7029H88.9588L91.3796 28.554L93.6663 22.7029H95.7757L90.5926 35.4H88.5582L90.3846 30.9598Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M99.8907 29.5936L101.732 28.1384C102.282 29.074 103.128 29.6083 104.108 29.6083C105.178 29.6083 105.757 28.911 105.757 28.1531C105.757 27.2325 104.658 26.9505 103.499 26.594C102.044 26.1334 100.426 25.5693 100.426 23.5049C100.426 21.7676 101.94 20.4012 104.019 20.4012C105.772 20.4012 106.781 21.0697 107.658 21.9756L105.994 23.2376C105.534 22.5547 104.895 22.1982 104.034 22.1982C103.054 22.1982 102.519 22.7329 102.519 23.4305C102.519 24.292 103.559 24.5743 104.732 24.9605C106.203 25.4355 107.866 26.089 107.866 28.1681C107.866 29.8757 106.499 31.5395 104.123 31.5395C102.163 31.5392 100.871 30.7071 99.8907 29.5936Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M108.03 22.7029H109.515V21.3812L111.535 20V22.7029H113.302V24.4999H111.535V27.7519C111.535 29.2669 111.743 29.5045 113.302 29.5045V31.3904H113.02C110.332 31.3904 109.515 30.5292 109.515 27.7672V24.4999H108.03L108.03 22.7029Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M113.838 27.0543C113.838 24.5296 115.827 22.5247 118.337 22.5247C120.832 22.5247 122.837 24.5296 122.837 27.0543C122.837 29.5639 120.832 31.5689 118.337 31.5689C115.827 31.5689 113.838 29.5639 113.838 27.0543ZM120.862 27.0543C120.862 25.599 119.747 24.4108 118.337 24.4108C116.896 24.4108 115.812 25.599 115.812 27.0543C115.812 28.4949 116.896 29.6828 118.337 29.6828C119.747 29.6828 120.862 28.4949 120.862 27.0543Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M125.853 22.7029V23.9949C126.254 23.1335 126.981 22.7029 128.08 22.7029C128.704 22.7029 129.223 22.8514 129.61 23.0741L128.852 24.9749C128.555 24.782 128.214 24.6334 127.649 24.6334C126.491 24.6334 125.867 25.2573 125.867 26.7569V31.3904H123.862V22.7029H125.853Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M129.834 27.069C129.834 24.5296 131.808 22.5247 134.348 22.5247C136.932 22.5247 138.818 24.4255 138.818 26.9949V27.7519H131.749C132.017 28.9701 132.997 29.8016 134.423 29.8016C135.536 29.8016 136.413 29.1928 136.828 28.2719L138.477 29.2222C137.719 30.6186 136.353 31.5686 134.423 31.5686C131.69 31.5686 129.834 29.5786 129.834 27.069ZM131.853 26.074H136.784C136.487 24.9158 135.596 24.292 134.348 24.292C133.145 24.292 132.21 25.0196 131.853 26.074Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M46.6986 13.974H43.9018L43.3866 15.4H42.5034L44.8218 9.0244H45.7878L48.097 15.4H47.2138L46.6986 13.974ZM46.4594 13.2932L45.3002 10.0548L44.141 13.2932H46.4594Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M50.4322 14.6272L51.9962 10.3584H52.8886L50.9106 15.4H49.9354L47.9574 10.3584H48.859L50.4322 14.6272Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M53.4917 12.8608C53.4917 12.3456 53.5959 11.8948 53.8045 11.5084C54.013 11.1159 54.2982 10.8123 54.6601 10.5976C55.0281 10.3829 55.4359 10.2756 55.8837 10.2756C56.3253 10.2756 56.7086 10.3707 57.0337 10.5608C57.3587 10.7509 57.601 10.9901 57.7605 11.2784V10.3584H58.6069V15.4H57.7605V14.4616C57.5949 14.756 57.3465 15.0013 57.0153 15.1976C56.6902 15.3877 56.3099 15.4828 55.8745 15.4828C55.4267 15.4828 55.0219 15.3724 54.6601 15.1516C54.2982 14.9308 54.013 14.6211 53.8045 14.2224C53.5959 13.8237 53.4917 13.3699 53.4917 12.8608ZM57.7605 12.87C57.7605 12.4897 57.6838 12.1585 57.5305 11.8764C57.3771 11.5943 57.1686 11.3796 56.9049 11.2324C56.6473 11.0791 56.3621 11.0024 56.0493 11.0024C55.7365 11.0024 55.4513 11.076 55.1937 11.2232C54.9361 11.3704 54.7306 11.5851 54.5773 11.8672C54.4239 12.1493 54.3473 12.4805 54.3473 12.8608C54.3473 13.2472 54.4239 13.5845 54.5773 13.8728C54.7306 14.1549 54.9361 14.3727 55.1937 14.526C55.4513 14.6732 55.7365 14.7468 56.0493 14.7468C56.3621 14.7468 56.6473 14.6732 56.9049 14.526C57.1686 14.3727 57.3771 14.1549 57.5305 13.8728C57.6838 13.5845 57.7605 13.2503 57.7605 12.87Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M60.2701 9.5396C60.1106 9.5396 59.9757 9.4844 59.8653 9.374C59.7549 9.2636 59.6997 9.12867 59.6997 8.9692C59.6997 8.80974 59.7549 8.6748 59.8653 8.5644C59.9757 8.454 60.1106 8.3988 60.2701 8.3988C60.4234 8.3988 60.5522 8.454 60.6565 8.5644C60.7669 8.6748 60.8221 8.80974 60.8221 8.9692C60.8221 9.12867 60.7669 9.2636 60.6565 9.374C60.5522 9.4844 60.4234 9.5396 60.2701 9.5396ZM60.6749 10.3584V15.4H59.8377V10.3584H60.6749Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M62.7549 8.592V15.4H61.9177V8.592H62.7549Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M63.869 12.8608C63.869 12.3456 63.9732 11.8948 64.1818 11.5084C64.3903 11.1159 64.6755 10.8123 65.0374 10.5976C65.4054 10.3829 65.8132 10.2756 66.261 10.2756C66.7026 10.2756 67.0859 10.3707 67.411 10.5608C67.736 10.7509 67.9783 10.9901 68.1378 11.2784V10.3584H68.9842V15.4H68.1378V14.4616C67.9722 14.756 67.7238 15.0013 67.3926 15.1976C67.0675 15.3877 66.6872 15.4828 66.2518 15.4828C65.804 15.4828 65.3992 15.3724 65.0374 15.1516C64.6755 14.9308 64.3903 14.6211 64.1818 14.2224C63.9732 13.8237 63.869 13.3699 63.869 12.8608ZM68.1378 12.87C68.1378 12.4897 68.0611 12.1585 67.9078 11.8764C67.7544 11.5943 67.5459 11.3796 67.2822 11.2324C67.0246 11.0791 66.7394 11.0024 66.4266 11.0024C66.1138 11.0024 65.8286 11.076 65.571 11.2232C65.3134 11.3704 65.1079 11.5851 64.9546 11.8672C64.8012 12.1493 64.7246 12.4805 64.7246 12.8608C64.7246 13.2472 64.8012 13.5845 64.9546 13.8728C65.1079 14.1549 65.3134 14.3727 65.571 14.526C65.8286 14.6732 66.1138 14.7468 66.4266 14.7468C66.7394 14.7468 67.0246 14.6732 67.2822 14.526C67.5459 14.3727 67.7544 14.1549 67.9078 13.8728C68.0611 13.5845 68.1378 13.2503 68.1378 12.87Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M71.3282 11.2968C71.4999 10.9963 71.7514 10.7509 72.0826 10.5608C72.4138 10.3707 72.791 10.2756 73.2142 10.2756C73.668 10.2756 74.0759 10.3829 74.4378 10.5976C74.7996 10.8123 75.0848 11.1159 75.2934 11.5084C75.5019 11.8948 75.6062 12.3456 75.6062 12.8608C75.6062 13.3699 75.5019 13.8237 75.2934 14.2224C75.0848 14.6211 74.7966 14.9308 74.4286 15.1516C74.0667 15.3724 73.6619 15.4828 73.2142 15.4828C72.7787 15.4828 72.3954 15.3877 72.0642 15.1976C71.7391 15.0075 71.4938 14.7652 71.3282 14.4708V15.4H70.491V8.592H71.3282V11.2968ZM74.7506 12.8608C74.7506 12.4805 74.6739 12.1493 74.5206 11.8672C74.3672 11.5851 74.1587 11.3704 73.895 11.2232C73.6374 11.076 73.3522 11.0024 73.0394 11.0024C72.7327 11.0024 72.4475 11.0791 72.1838 11.2324C71.9262 11.3796 71.7176 11.5973 71.5582 11.8856C71.4048 12.1677 71.3282 12.4959 71.3282 12.87C71.3282 13.2503 71.4048 13.5845 71.5582 13.8728C71.7176 14.1549 71.9262 14.3727 72.1838 14.526C72.4475 14.6732 72.7327 14.7468 73.0394 14.7468C73.3522 14.7468 73.6374 14.6732 73.895 14.526C74.1587 14.3727 74.3672 14.1549 74.5206 13.8728C74.6739 13.5845 74.7506 13.2472 74.7506 12.8608Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M77.5454 8.592V15.4H76.7082V8.592H77.5454Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M83.6642 12.686C83.6642 12.8455 83.655 13.0141 83.6366 13.192H79.607C79.6377 13.6888 79.8064 14.0783 80.113 14.3604C80.4258 14.6364 80.803 14.7744 81.2446 14.7744C81.6065 14.7744 81.907 14.6916 82.1462 14.526C82.3916 14.3543 82.5633 14.1273 82.6614 13.8452H83.563C83.4281 14.3297 83.1582 14.7253 82.7534 15.032C82.3486 15.3325 81.8457 15.4828 81.2446 15.4828C80.7662 15.4828 80.3369 15.3755 79.9566 15.1608C79.5825 14.9461 79.2881 14.6425 79.0734 14.25C78.8587 13.8513 78.7514 13.3913 78.7514 12.87C78.7514 12.3487 78.8557 11.8917 79.0642 11.4992C79.2728 11.1067 79.5641 10.8061 79.9382 10.5976C80.3185 10.3829 80.754 10.2756 81.2446 10.2756C81.723 10.2756 82.1462 10.3799 82.5142 10.5884C82.8822 10.7969 83.1643 11.0852 83.3606 11.4532C83.563 11.8151 83.6642 12.226 83.6642 12.686ZM82.7994 12.5112C82.7994 12.1923 82.7289 11.9193 82.5878 11.6924C82.4467 11.4593 82.2536 11.2845 82.0082 11.168C81.769 11.0453 81.5022 10.984 81.2078 10.984C80.7846 10.984 80.4227 11.1189 80.1222 11.3888C79.8278 11.6587 79.6591 12.0328 79.6162 12.5112H82.7994Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M89.6048 15.4828C89.1326 15.4828 88.7032 15.3755 88.3168 15.1608C87.9366 14.9461 87.636 14.6425 87.4152 14.25C87.2006 13.8513 87.0932 13.3913 87.0932 12.87C87.0932 12.3548 87.2036 11.9009 87.4244 11.5084C87.6514 11.1097 87.958 10.8061 88.3444 10.5976C88.7308 10.3829 89.1632 10.2756 89.6416 10.2756C90.12 10.2756 90.5524 10.3829 90.9388 10.5976C91.3252 10.8061 91.6288 11.1067 91.8496 11.4992C92.0766 11.8917 92.19 12.3487 92.19 12.87C92.19 13.3913 92.0735 13.8513 91.8404 14.25C91.6135 14.6425 91.3038 14.9461 90.9112 15.1608C90.5187 15.3755 90.0832 15.4828 89.6048 15.4828ZM89.6048 14.7468C89.9054 14.7468 90.1875 14.6763 90.4512 14.5352C90.715 14.3941 90.9266 14.1825 91.086 13.9004C91.2516 13.6183 91.3344 13.2748 91.3344 12.87C91.3344 12.4652 91.2547 12.1217 91.0952 11.8396C90.9358 11.5575 90.7272 11.3489 90.4696 11.214C90.212 11.0729 89.933 11.0024 89.6324 11.0024C89.3258 11.0024 89.0436 11.0729 88.786 11.214C88.5346 11.3489 88.3322 11.5575 88.1788 11.8396C88.0255 12.1217 87.9488 12.4652 87.9488 12.87C87.9488 13.2809 88.0224 13.6275 88.1696 13.9096C88.323 14.1917 88.5254 14.4033 88.7768 14.5444C89.0283 14.6793 89.3043 14.7468 89.6048 14.7468Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M95.9312 10.2664C96.5445 10.2664 97.0413 10.4535 97.4216 10.8276C97.8019 11.1956 97.992 11.7292 97.992 12.4284V15.4H97.164V12.548C97.164 12.0451 97.0383 11.6617 96.7868 11.398C96.5353 11.1281 96.1919 10.9932 95.7564 10.9932C95.3148 10.9932 94.9621 11.1312 94.6984 11.4072C94.4408 11.6832 94.312 12.0849 94.312 12.6124V15.4H93.4748V10.3584H94.312V11.076C94.4776 10.8184 94.7015 10.6191 94.9836 10.478C95.2719 10.3369 95.5877 10.2664 95.9312 10.2664Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect x="9" y="7" width="26" height="26" rx="10" fill="url(#paint0_angular_1303_2200)" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M25.7914 16.5695L25.738 16.3341C25.738 14.2733 24.0609 12.5968 21.9997 12.5968C19.9385 12.5968 18.2617 14.2733 18.2617 16.3341L18.2081 16.5695H15.1387L15.966 24.2826C16.1318 25.8515 17.4549 27.0421 19.0326 27.0421H24.9669C26.5446 27.0421 27.8677 25.8515 28.0334 24.2828L28.8609 16.5695H25.7914ZM19.8331 16.5695L19.8934 16.3341C19.8934 15.1729 20.8383 14.2281 21.9997 14.2281C23.1613 14.2281 24.1063 15.1729 24.1063 16.3341L24.1664 16.5695H19.8331Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient
|
||||||
|
id="paint0_angular_1303_2200"
|
||||||
|
cx="0"
|
||||||
|
cy="0"
|
||||||
|
r="1"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="translate(22 20) rotate(88.8309) scale(13.0027)"
|
||||||
|
>
|
||||||
|
<stop offset="0.000415415" stopColor="#F4605E" />
|
||||||
|
<stop offset="0.0642377" stopColor="#E94B80" />
|
||||||
|
<stop offset="0.128396" stopColor="#DE33A4" />
|
||||||
|
<stop offset="0.186654" stopColor="#D41AC8" />
|
||||||
|
<stop offset="0.250949" stopColor="#CB06E5" />
|
||||||
|
<stop offset="0.281719" stopColor="#C902EC" />
|
||||||
|
<stop offset="0.316858" stopColor="#CB04E5" />
|
||||||
|
<stop offset="0.371347" stopColor="#D108D3" />
|
||||||
|
<stop offset="0.43351" stopColor="#D80DBA" />
|
||||||
|
<stop offset="0.504024" stopColor="#E1139E" />
|
||||||
|
<stop offset="0.59576" stopColor="#EC1E7B" />
|
||||||
|
<stop offset="0.673288" stopColor="#F22A65" />
|
||||||
|
<stop offset="0.713795" stopColor="#F5355B" />
|
||||||
|
<stop offset="0.754495" stopColor="#F74452" />
|
||||||
|
<stop offset="0.818581" stopColor="#F75651" />
|
||||||
|
<stop offset="0.878478" stopColor="#F76051" />
|
||||||
|
<stop offset="0.938046" stopColor="#F76551" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppGalleryButton = ({ size = "md", ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { size?: "md" | "lg" }) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-label="Explore it on AppGallery"
|
||||||
|
href="#"
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"rounded-[7px] bg-black ring-1 ring-app-store-badge-border outline-focus-ring ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg width={size === "md" ? 133 : 147} height={size === "md" ? 40 : 44} viewBox="0 0 133 40" fill="none">
|
||||||
|
<path
|
||||||
|
d="M45.3962 25.4116H48.8919L47.6404 22.0615C47.4682 21.5986 47.2989 21.0875 47.1319 20.5276C46.9813 21.0229 46.817 21.5286 46.6394 22.0453L45.3962 25.4116ZM49.4893 27.0021H44.8068L43.6607 30.1344H41.6021L46.1874 18.4368H48.133L52.8234 30.1344H50.6599L49.4893 27.0021Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M58.8962 28.0072C59.3026 27.461 59.5058 26.663 59.5058 25.6135C59.5058 24.6396 59.3375 23.933 59.0013 23.4942C58.6647 23.0557 58.2167 22.8364 57.657 22.8364C57.2695 22.8364 56.9117 22.9281 56.5834 23.1109C56.2551 23.2939 55.9429 23.5387 55.6469 23.8455V28.5117C55.8461 28.6085 56.0775 28.6853 56.3413 28.7416C56.605 28.7984 56.8661 28.8266 57.1242 28.8266C57.8994 28.8266 58.4898 28.5533 58.8962 28.0072ZM53.653 23.555C53.653 22.9092 53.6314 22.1986 53.5885 21.4237H55.4613C55.5311 21.7844 55.5797 22.1531 55.6066 22.5297C56.3815 21.6849 57.2695 21.2622 58.2706 21.2622C58.8519 21.2622 59.3901 21.4088 59.8853 21.7021C60.3802 21.9955 60.7799 22.4583 61.0839 23.0906C61.3882 23.7231 61.5402 24.5265 61.5402 25.5005C61.5402 26.5177 61.3666 27.387 61.0194 28.108C60.6722 28.8294 60.1866 29.3754 59.5623 29.747C58.9381 30.1181 58.2167 30.3039 57.3989 30.3039C56.8066 30.3039 56.2226 30.2042 55.6469 30.0053V33.6056L53.653 33.7752V23.555Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M67.9935 28.0072C68.3999 27.461 68.6031 26.663 68.6031 25.6135C68.6031 24.6396 68.4349 23.933 68.0986 23.4942C67.7621 23.0557 67.3141 22.8364 66.7543 22.8364C66.3669 22.8364 66.009 22.9281 65.6807 23.1109C65.3522 23.2939 65.0402 23.5387 64.7442 23.8455V28.5117C64.9434 28.6085 65.1746 28.6853 65.4386 28.7416C65.7021 28.7984 65.9631 28.8266 66.2215 28.8266C66.9964 28.8266 67.5871 28.5533 67.9935 28.0072ZM62.7501 23.555C62.7501 22.9092 62.7285 22.1986 62.6855 21.4237H64.5586C64.6285 21.7844 64.677 22.1531 64.7039 22.5297C65.4789 21.6849 66.3669 21.2622 67.3679 21.2622C67.9493 21.2622 68.4871 21.4088 68.9826 21.7021C69.4775 21.9955 69.8772 22.4583 70.1815 23.0906C70.4852 23.7231 70.6375 24.5265 70.6375 25.5005C70.6375 26.5177 70.4637 27.387 70.1167 28.108C69.7695 28.8294 69.2837 29.3754 68.6597 29.747C68.0351 30.1181 67.314 30.3039 66.4959 30.3039C65.9039 30.3039 65.3199 30.2042 64.7442 30.0053V33.6056L62.7501 33.7752V23.555Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M74.3005 29.5813C73.4391 29.1053 72.7773 28.4229 72.3146 27.5349C71.8514 26.6469 71.6202 25.5974 71.6202 24.3864C71.6202 23.0734 71.8866 21.9578 72.4194 21.0401C72.9522 20.1227 73.6775 19.4337 74.5949 18.9735C75.5127 18.5134 76.5418 18.2833 77.6831 18.2833C78.3557 18.2833 78.9975 18.3574 79.6085 18.5053C80.2191 18.6534 80.7882 18.8564 81.3159 19.1149L80.8071 20.6486C79.7469 20.1428 78.7351 19.8898 77.7719 19.8898C76.9591 19.8898 76.2474 20.0634 75.6367 20.4106C75.0258 20.7578 74.5506 21.2677 74.2117 21.9404C73.8725 22.6132 73.7031 23.4258 73.7031 24.3783C73.7031 25.2128 73.8335 25.9526 74.0943 26.5984C74.3557 27.2442 74.7674 27.7557 75.3298 28.1323C75.8922 28.509 76.6013 28.6973 77.457 28.6973C77.8445 28.6973 78.2319 28.6651 78.6194 28.6005C79.0069 28.536 79.3703 28.4418 79.7093 28.3179V25.9526H77.005V24.4026H81.6469V29.3272C80.9794 29.6392 80.2783 29.8789 79.5439 30.0456C78.809 30.2123 78.0786 30.2957 77.3519 30.2957C76.1786 30.2957 75.1613 30.0579 74.3005 29.5813Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M87.5381 28.5197C87.9522 28.3208 88.2914 28.073 88.5551 27.7771V26.1625C88.0114 26.1034 87.5674 26.0737 87.2231 26.0737C86.3997 26.0737 85.8303 26.2068 85.5159 26.4734C85.2007 26.7394 85.0434 27.0989 85.0434 27.5509C85.0434 27.9818 85.1578 28.3005 85.3866 28.5077C85.6154 28.7149 85.9261 28.8184 86.3189 28.8184C86.717 28.8184 87.1234 28.7189 87.5381 28.5197ZM88.7327 30.1344C88.6626 29.7952 88.6167 29.4106 88.5954 28.98C88.2887 29.3461 87.8893 29.6568 87.3965 29.9125C86.9045 30.168 86.3485 30.2957 85.7295 30.2957C85.229 30.2957 84.773 30.1976 84.361 30.001C83.9498 29.8048 83.6226 29.5087 83.3805 29.113C83.1383 28.7176 83.017 28.2347 83.017 27.6639C83.017 26.8192 83.321 26.145 83.9293 25.6416C84.5375 25.1385 85.5519 24.887 86.9727 24.887C87.5055 24.887 88.033 24.9248 88.5551 25V24.8305C88.5551 24.0609 88.3909 23.5187 88.0626 23.2036C87.7343 22.889 87.2634 22.7316 86.6501 22.7316C86.2247 22.7316 85.7701 22.7935 85.2855 22.9172C84.8013 23.0411 84.3759 23.189 84.0101 23.3612L83.6951 21.9081C84.0503 21.7465 84.5186 21.5986 85.0999 21.464C85.6813 21.3297 86.2946 21.2622 86.9405 21.2622C87.6941 21.2622 88.3343 21.3765 88.8618 21.6052C89.3893 21.834 89.801 22.2271 90.097 22.7838C90.393 23.3411 90.541 24.0906 90.541 25.0323V28.4954C90.541 28.8562 90.5623 29.4027 90.6055 30.1344H88.7327Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M92.0709 28.0434V17.7505L94.0567 17.5891V27.6882C94.0567 28.0597 94.1199 28.3218 94.2463 28.4754C94.3727 28.6285 94.5732 28.7056 94.8479 28.7056C94.9716 28.7056 95.1466 28.676 95.3724 28.6168L95.6066 30.0456C95.418 30.121 95.1882 30.1816 94.9167 30.2272C94.6447 30.2728 94.3876 30.2957 94.1455 30.2957C92.762 30.2957 92.0709 29.5451 92.0709 28.0434Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M97.0356 28.0434V17.7505L99.0215 17.5891V27.6882C99.0215 28.0597 99.0847 28.3218 99.2111 28.4754C99.3378 28.6285 99.5383 28.7056 99.8127 28.7056C99.9365 28.7056 100.111 28.676 100.338 28.6168L100.572 30.0456C100.383 30.121 100.153 30.1816 99.8815 30.2272C99.6095 30.2728 99.3524 30.2957 99.1103 30.2957C97.7271 30.2957 97.0356 29.5451 97.0356 28.0434Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M106.795 24.7659C106.755 24.0716 106.581 23.551 106.269 23.2036C105.957 22.8568 105.539 22.683 105.019 22.683C104.512 22.683 104.091 22.8581 103.755 23.2078C103.419 23.5578 103.197 24.0771 103.096 24.7659H106.795ZM108.739 26.0333H103.04C103.131 27.8579 104 28.7701 105.648 28.7701C106.056 28.7701 106.475 28.7203 106.904 28.6208C107.331 28.521 107.741 28.388 108.133 28.221L108.571 29.5853C107.595 30.0592 106.501 30.2957 105.285 30.2957C104.357 30.2957 103.579 30.121 102.944 29.7711C102.307 29.4213 101.829 28.9181 101.509 28.2613C101.19 27.6051 101.03 26.8138 101.03 25.888C101.03 24.9248 101.199 24.0958 101.539 23.4016C101.877 22.7073 102.349 22.1773 102.955 21.8112C103.56 21.4453 104.259 21.2622 105.051 21.2622C105.875 21.2622 106.56 21.4547 107.112 21.8396C107.664 22.2241 108.072 22.737 108.339 23.3773C108.605 24.018 108.739 24.7255 108.739 25.5005V26.0333Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M110.248 23.6115C110.248 23.1326 110.224 22.4034 110.181 21.4237H112.048C112.08 21.6659 112.109 21.9552 112.141 22.2916C112.171 22.6279 112.189 22.901 112.2 23.1109C112.432 22.7288 112.659 22.4073 112.883 22.1463C113.107 21.8851 113.368 21.6727 113.667 21.5083C113.965 21.3443 114.304 21.2622 114.688 21.2622C114.995 21.2622 115.256 21.2946 115.477 21.3592L115.227 23.0868C115.035 23.0276 114.819 22.9979 114.581 22.9979C114.115 22.9979 113.704 23.1177 113.355 23.3573C113.005 23.5965 112.632 23.9908 112.232 24.5398V30.1344H110.248V23.6115Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M116.597 33.6984C116.307 33.6475 116.067 33.5896 115.88 33.5251L116.243 32.072C116.376 32.1094 116.547 32.1458 116.752 32.1808C116.955 32.216 117.149 32.2333 117.333 32.2333C118.216 32.2333 118.877 31.6653 119.317 30.5299L119.448 30.207L116.235 21.4237H118.373L119.989 26.332C120.251 27.1717 120.421 27.8149 120.496 28.2613C120.648 27.6317 120.824 27.0021 121.029 26.3724L122.669 21.4237H124.677L121.475 30.2475C121.173 31.0816 120.845 31.7541 120.496 32.2656C120.147 32.7768 119.733 33.1562 119.259 33.4039C118.781 33.6512 118.208 33.7752 117.533 33.7752C117.2 33.7752 116.888 33.7499 116.597 33.6984Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M41.5933 7.30106H45.1474L45.0474 8.15946H42.6349V9.82186H44.9098V10.6261H42.6349V12.4677H45.1765L45.089 13.3344H41.5933V7.30106Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M47.5973 10.2552L45.7223 7.30106H46.9055L48.1765 9.472L49.514 7.30106H50.6639L48.8055 10.2176L50.8181 13.3344H49.6181L48.1807 10.9595L46.7181 13.3344H45.5682L47.5973 10.2552Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M54.1973 9.99466C54.4194 9.7936 54.5306 9.50933 54.5306 9.14266C54.5306 8.7704 54.4172 8.50266 54.1911 8.33866C53.9647 8.17466 53.6319 8.0928 53.1932 8.0928H52.6266V10.2344C52.8876 10.276 53.0876 10.2968 53.2266 10.2968C53.6514 10.2968 53.9751 10.1963 54.1973 9.99466ZM51.5847 7.30106H53.2098C53.9735 7.30106 54.5583 7.4568 54.9639 7.76773C55.3695 8.07893 55.5722 8.5288 55.5722 9.1176C55.5722 9.5176 55.4813 9.8672 55.2994 10.1656C55.1173 10.4643 54.8639 10.6936 54.5388 10.8531C54.214 11.0131 53.8402 11.0928 53.418 11.0928C53.1876 11.0928 52.9236 11.0651 52.6266 11.0093V13.3344H51.5847V7.30106Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M56.6015 7.30106H57.6431V12.4427H60.1389L60.0514 13.3344H56.6015V7.30106Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M64.4223 12.2989C64.714 12.1032 64.9319 11.8339 65.0764 11.4907C65.221 11.1477 65.2932 10.7552 65.2932 10.3136C65.2932 9.88026 65.2292 9.49439 65.1015 9.15519C64.9735 8.81625 64.7695 8.54639 64.489 8.34479C64.2084 8.14346 63.8474 8.04266 63.4055 8.04266C62.9834 8.04266 62.625 8.14479 62.3306 8.34906C62.0362 8.55306 61.8154 8.82692 61.6682 9.16986C61.521 9.51306 61.4474 9.89145 61.4474 10.3053C61.4474 10.7413 61.5167 11.1317 61.6556 11.476C61.7943 11.8205 62.0068 12.0928 62.2932 12.2928C62.5791 12.4928 62.9332 12.5928 63.3556 12.5928C63.7751 12.5928 64.1306 12.4947 64.4223 12.2989ZM61.7223 13.0387C61.2807 12.7859 60.9431 12.4309 60.7098 11.9739C60.4764 11.5171 60.3599 10.9859 60.3599 10.3803C60.3599 9.74426 60.4839 9.18799 60.7327 8.71146C60.9813 8.23519 61.3396 7.86719 61.8076 7.60719C62.2756 7.34772 62.8277 7.21759 63.4639 7.21759C64.0722 7.21759 64.5959 7.34346 65.0348 7.59466C65.4735 7.84639 65.8084 8.19972 66.0388 8.65519C66.2695 9.11092 66.3847 9.63865 66.3847 10.2387C66.3847 10.8859 66.2591 11.4485 66.0076 11.9261C65.7562 12.4037 65.3981 12.772 64.9327 13.0301C64.4674 13.2885 63.921 13.4177 63.2932 13.4177C62.6876 13.4177 62.1639 13.2915 61.7223 13.0387Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M69.1807 10.1552C69.4333 10.1552 69.657 10.1061 69.8514 10.0072C70.0458 9.9088 70.1973 9.76986 70.3055 9.59066C70.4141 9.41146 70.4682 9.20373 70.4682 8.96773C70.4682 8.66506 70.3722 8.44346 70.1807 8.3032C69.989 8.16293 69.7098 8.0928 69.3431 8.0928H68.589V10.1552H69.1807ZM67.5474 7.30106H69.4349C70.1237 7.30106 70.6453 7.43866 70.9994 7.7136C71.3536 7.98853 71.5306 8.38186 71.5306 8.8928C71.5306 9.21226 71.4666 9.4936 71.3389 9.73653C71.2111 9.97973 71.0528 10.1776 70.864 10.3301C70.6749 10.4829 70.4807 10.5968 70.2807 10.672L72.1349 13.3344H70.9266L69.3557 10.9427H68.589V13.3344H67.5474V7.30106Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M72.9599 7.30106H76.5141L76.4141 8.15946H74.0015V9.82186H76.2765V10.6261H74.0015V12.4677H76.5431L76.4556 13.3344H72.9599V7.30106Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path d="M80.3807 7.30106H81.4223V13.3344H80.3807V7.30106Z" fill="white" />
|
||||||
|
<path d="M84.1765 8.172H82.3055L82.3973 7.30106H87.0933L86.9973 8.172H85.218V13.3344H84.1765V8.172Z" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M94.3141 12.2989C94.6055 12.1032 94.8237 11.8339 94.9682 11.4907C95.1125 11.1477 95.1847 10.7552 95.1847 10.3136C95.1847 9.88026 95.1207 9.49439 94.9933 9.15519C94.8653 8.81625 94.661 8.54639 94.3807 8.34479C94.0999 8.14346 93.7389 8.04266 93.2973 8.04266C92.8749 8.04266 92.5167 8.14479 92.2223 8.34906C91.9277 8.55306 91.7069 8.82692 91.5599 9.16986C91.4125 9.51306 91.3389 9.89145 91.3389 10.3053C91.3389 10.7413 91.4082 11.1317 91.5474 11.476C91.6861 11.8205 91.8986 12.0928 92.1847 12.2928C92.4709 12.4928 92.825 12.5928 93.2474 12.5928C93.6666 12.5928 94.0223 12.4947 94.3141 12.2989ZM91.6141 13.0387C91.1722 12.7859 90.8349 12.4309 90.6015 11.9739C90.3682 11.5171 90.2514 10.9859 90.2514 10.3803C90.2514 9.74426 90.3757 9.18799 90.6245 8.71146C90.8727 8.23519 91.2311 7.86719 91.6994 7.60719C92.1674 7.34772 92.7194 7.21759 93.3557 7.21759C93.9639 7.21759 94.4874 7.34346 94.9266 7.59466C95.3653 7.84639 95.6999 8.19972 95.9306 8.65519C96.161 9.11092 96.2765 9.63865 96.2765 10.2387C96.2765 10.8859 96.1506 11.4485 95.8994 11.9261C95.6479 12.4037 95.2895 12.772 94.8245 13.0301C94.3591 13.2885 93.8125 13.4177 93.1847 13.4177C92.5791 13.4177 92.0557 13.2915 91.6141 13.0387Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M97.4389 7.30106H98.6349L101.619 11.976C101.592 11.5317 101.581 11.1219 101.581 10.7469V7.30106H102.547V13.3344H101.389L98.3599 8.58426C98.3903 9.12346 98.4055 9.60106 98.4055 10.0176V13.3344H97.4389V7.30106Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M26.9417 7.33329H15.8633C10.6446 7.33329 8.73584 9.24209 8.73584 14.4608V25.5393C8.73584 30.7579 10.6446 32.6666 15.8633 32.6666H26.9382C32.1569 32.6666 34.0692 30.7579 34.0692 25.5393V14.4608C34.0692 9.24209 32.1604 7.33329 26.9417 7.33329"
|
||||||
|
fill="#C91C2E"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.7881 22.0255H20.8041L20.2944 20.8404L19.7881 22.0255ZM19.5379 22.6229L19.2366 23.3125H18.5504L20.0097 20.0021H20.6025L22.0558 23.3125H21.3513L21.0537 22.6229H19.5379ZM30.5787 23.3102H31.2419V20.0021H30.5787V23.3102ZM27.9395 21.8887H29.1616V21.2859H27.9395V20.6078H29.7137V20.0042H27.2766V23.3121H29.7777V22.7088H27.9395V21.8887ZM25.3046 22.2796L24.5526 20.002H24.0043L23.2523 22.2796L22.5206 20.0036H21.8054L22.9598 23.314H23.516L24.2691 21.1395L25.0219 23.314H25.5832L26.7347 20.0036H26.0379L25.3046 22.2796ZM17.5382 21.8979C17.5382 22.4364 17.2707 22.7241 16.7851 22.7241C16.2969 22.7241 16.0281 22.4283 16.0281 21.875V20.004H15.356V21.8979C15.356 22.8296 15.8736 23.3638 16.776 23.3638C17.6872 23.3638 18.2099 22.8194 18.2099 21.8705V20.002H17.5382V21.8979ZM13.7526 20.0021H14.4243V23.3142H13.7526V21.9692H12.235V23.3142H11.563V20.0021H12.235V21.3383H13.7526V20.0021Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21.4023 15.7851C19.0773 15.7851 17.1855 13.8933 17.1855 11.5683H17.7813C17.7813 13.5649 19.4055 15.1894 21.4023 15.1894C23.3991 15.1894 25.0234 13.5649 25.0234 11.5683H25.6191C25.6191 13.8933 23.7274 15.7851 21.4023 15.7851"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import type { Placement } from "react-aria";
|
||||||
|
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||||
|
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
export const styles = {
|
||||||
|
secondary:
|
||||||
|
"bg-primary text-fg-quaternary shadow-xs-skeuomorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs",
|
||||||
|
tertiary: "text-fg-quaternary hover:bg-primary_hover hover:text-fg-quaternary_hover",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common props shared between button and anchor variants
|
||||||
|
*/
|
||||||
|
export interface CommonProps {
|
||||||
|
/** Disables the button and shows a disabled state */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** The size variant of the button */
|
||||||
|
size?: "xs" | "sm";
|
||||||
|
/** The color variant of the button */
|
||||||
|
color?: "secondary" | "tertiary";
|
||||||
|
/** The icon to display in the button */
|
||||||
|
icon?: FC<{ className?: string }> | ReactNode;
|
||||||
|
/** The tooltip to display when hovering over the button */
|
||||||
|
tooltip?: string;
|
||||||
|
/** The placement of the tooltip */
|
||||||
|
tooltipPlacement?: Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the button variant (non-link)
|
||||||
|
*/
|
||||||
|
export interface ButtonProps extends CommonProps, DetailedHTMLProps<Omit<ButtonHTMLAttributes<HTMLButtonElement>, "color" | "slot">, HTMLButtonElement> {
|
||||||
|
/** Slot name for react-aria component */
|
||||||
|
slot?: AriaButtonProps["slot"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the link variant (anchor tag)
|
||||||
|
*/
|
||||||
|
interface LinkProps extends CommonProps, DetailedHTMLProps<Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "color">, HTMLAnchorElement> {
|
||||||
|
/** Options for the configured client side router. */
|
||||||
|
routerOptions?: AriaLinkProps["routerOptions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union type of button and link props */
|
||||||
|
export type Props = ButtonProps | LinkProps;
|
||||||
|
|
||||||
|
export const ButtonUtility = ({
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
isDisabled,
|
||||||
|
icon: Icon,
|
||||||
|
size = "sm",
|
||||||
|
color = "secondary",
|
||||||
|
tooltipPlacement = "top",
|
||||||
|
...otherProps
|
||||||
|
}: Props) => {
|
||||||
|
const href = "href" in otherProps ? otherProps.href : undefined;
|
||||||
|
const Component = href ? AriaLink : AriaButton;
|
||||||
|
|
||||||
|
let props = {};
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
href: isDisabled ? undefined : href,
|
||||||
|
|
||||||
|
// Since anchor elements do not support the `disabled` attribute and state,
|
||||||
|
// we need to specify `data-rac` and `data-disabled` in order to be able
|
||||||
|
// to use the `disabled:` selector in classes.
|
||||||
|
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
type: otherProps.type || "button",
|
||||||
|
isDisabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Component
|
||||||
|
aria-label={tooltip}
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"group relative inline-flex h-max cursor-pointer items-center justify-center rounded-md p-1.5 outline-focus-ring transition duration-100 ease-linear focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
styles[color],
|
||||||
|
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:pointer-events-none *:data-icon:shrink-0 *:data-icon:text-current *:data-icon:transition-inherit-all",
|
||||||
|
size === "xs" ? "*:data-icon:size-4" : "*:data-icon:size-5",
|
||||||
|
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isReactComponent(Icon) && <Icon data-icon />}
|
||||||
|
{isValidElement(Icon) && Icon}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip} placement={tooltipPlacement} isDisabled={isDisabled} offset={size === "xs" ? 4 : 6}>
|
||||||
|
{content}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { FC, ReactElement, ReactNode } from "react";
|
||||||
|
import React, { isValidElement } from "react";
|
||||||
|
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
export const styles = sortCx({
|
||||||
|
common: {
|
||||||
|
root: [
|
||||||
|
"group relative inline-flex h-max cursor-pointer items-center justify-center whitespace-nowrap outline-brand transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
// When button is used within `InputGroup`
|
||||||
|
"in-data-input-wrapper:shadow-xs in-data-input-wrapper:focus:!z-50 in-data-input-wrapper:in-data-leading:-mr-px in-data-input-wrapper:in-data-leading:rounded-r-none in-data-input-wrapper:in-data-leading:before:rounded-r-none in-data-input-wrapper:in-data-trailing:-ml-px in-data-input-wrapper:in-data-trailing:rounded-l-none in-data-input-wrapper:in-data-trailing:before:rounded-l-none",
|
||||||
|
// Disabled styles
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50 in-data-input-wrapper:disabled:opacity-100",
|
||||||
|
// Same as `icon` but for SSR icons that cannot be passed to the client as functions.
|
||||||
|
"*:data-icon:pointer-events-none *:data-icon:size-5 *:data-icon:shrink-0 *:data-icon:transition-inherit-all",
|
||||||
|
].join(" "),
|
||||||
|
icon: "pointer-events-none size-5 shrink-0 transition-inherit-all",
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
xs: {
|
||||||
|
root: [
|
||||||
|
"gap-1 rounded-lg px-2.5 py-1.5 text-sm font-semibold before:rounded-[7px] data-icon-only:p-2",
|
||||||
|
"in-data-input-wrapper:px-3.5 in-data-input-wrapper:py-2.5 in-data-input-wrapper:data-icon-only:p-2.5",
|
||||||
|
"*:data-icon:size-4 *:data-icon:stroke-[2.25px]",
|
||||||
|
].join(" "),
|
||||||
|
linkRoot: "gap-1 *:data-text:underline-offset-3",
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
root: [
|
||||||
|
"gap-1 rounded-lg px-3 py-2 text-sm font-semibold before:rounded-[7px] data-icon-only:p-2",
|
||||||
|
"in-data-input-wrapper:px-3.5 in-data-input-wrapper:py-2.5 in-data-input-wrapper:data-icon-only:p-2.5",
|
||||||
|
].join(" "),
|
||||||
|
linkRoot: "gap-1 *:data-text:underline-offset-3",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: [
|
||||||
|
"gap-1 rounded-lg px-3.5 py-2.5 text-sm font-semibold before:rounded-[7px] data-icon-only:p-2.5",
|
||||||
|
"in-data-input-wrapper:gap-1.5 in-data-input-wrapper:px-4 in-data-input-wrapper:text-md in-data-input-wrapper:data-icon-only:p-3",
|
||||||
|
].join(" "),
|
||||||
|
linkRoot: "gap-1 *:data-text:underline-offset-4",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "gap-1.5 rounded-lg px-4 py-2.5 text-md font-semibold before:rounded-[7px] data-icon-only:p-3",
|
||||||
|
linkRoot: "gap-1.5 *:data-text:underline-offset-4",
|
||||||
|
},
|
||||||
|
xl: {
|
||||||
|
root: "gap-1.5 rounded-lg px-4.5 py-3 text-md font-semibold before:rounded-[7px] data-icon-only:p-3.5",
|
||||||
|
linkRoot: "gap-1.5 *:data-text:underline-offset-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
root: [
|
||||||
|
"bg-brand-solid text-white shadow-xs-skeuomorphic ring-1 ring-transparent ring-inset hover:bg-brand-solid_hover data-loading:bg-brand-solid_hover",
|
||||||
|
// Inner border gradient
|
||||||
|
"before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0%",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-white/60 hover:*:data-icon:text-white/70",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
root: [
|
||||||
|
"bg-primary text-secondary shadow-xs-skeuomorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-secondary_hover data-loading:bg-primary_hover",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-quaternary hover:*:data-icon:text-fg-quaternary_hover",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
tertiary: {
|
||||||
|
root: [
|
||||||
|
"text-tertiary hover:bg-primary_hover hover:text-tertiary_hover data-loading:bg-primary_hover",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-quaternary hover:*:data-icon:text-fg-quaternary_hover",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
"link-color": {
|
||||||
|
root: [
|
||||||
|
"justify-normal rounded p-0! text-brand-secondary hover:text-brand-secondary_hover",
|
||||||
|
// Inner text underline
|
||||||
|
"*:data-text:underline *:data-text:decoration-transparent hover:*:data-text:decoration-fg-brand-secondary_alt",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-brand-secondary_alt hover:*:data-icon:text-fg-brand-secondary_hover",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
"link-gray": {
|
||||||
|
root: [
|
||||||
|
"justify-normal rounded p-0! text-tertiary hover:text-tertiary_hover",
|
||||||
|
// Inner text underline
|
||||||
|
"*:data-text:underline *:data-text:decoration-transparent hover:*:data-text:decoration-fg-quaternary",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-quaternary hover:*:data-icon:text-fg-quaternary_hover",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
"primary-destructive": {
|
||||||
|
root: [
|
||||||
|
"bg-error-solid text-white shadow-xs-skeuomorphic ring-1 ring-transparent outline-error ring-inset hover:bg-error-solid_hover data-loading:bg-error-solid_hover",
|
||||||
|
// Inner border gradient
|
||||||
|
"before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0%",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-white/60 hover:*:data-icon:text-white/70",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
"secondary-destructive": {
|
||||||
|
root: [
|
||||||
|
"bg-primary text-error-primary shadow-xs-skeuomorphic ring-1 ring-error_subtle outline-error ring-inset hover:bg-error-primary hover:text-error-primary_hover data-loading:bg-error-primary",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-error-secondary hover:*:data-icon:text-fg-error-primary",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
"tertiary-destructive": {
|
||||||
|
root: [
|
||||||
|
"text-error-primary outline-error hover:bg-error-primary hover:text-error-primary_hover data-loading:bg-error-primary",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-error-secondary hover:*:data-icon:text-fg-error-primary",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
"link-destructive": {
|
||||||
|
root: [
|
||||||
|
"justify-normal rounded p-0! text-error-primary outline-error hover:text-error-primary_hover",
|
||||||
|
// Inner text underline
|
||||||
|
"*:data-text:underline *:data-text:decoration-transparent *:data-text:underline-offset-2 hover:*:data-text:decoration-current",
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:text-fg-error-secondary hover:*:data-icon:text-fg-error-primary",
|
||||||
|
].join(" "),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common props shared between button and anchor variants
|
||||||
|
*/
|
||||||
|
export interface CommonProps {
|
||||||
|
/** Disables the button and shows a disabled state */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Shows a loading spinner and disables the button */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** The size variant of the button */
|
||||||
|
size?: keyof typeof styles.sizes;
|
||||||
|
/** The color variant of the button */
|
||||||
|
color?: keyof typeof styles.colors;
|
||||||
|
/** Icon component or element to show before the text */
|
||||||
|
iconLeading?: FC<{ className?: string }> | ReactNode;
|
||||||
|
/** Icon component or element to show after the text */
|
||||||
|
iconTrailing?: FC<{ className?: string }> | ReactNode;
|
||||||
|
/** Removes horizontal padding from the text content */
|
||||||
|
noTextPadding?: boolean;
|
||||||
|
/** When true, keeps the text visible during loading state */
|
||||||
|
showTextWhileLoading?: boolean;
|
||||||
|
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the button variant (non-link)
|
||||||
|
*/
|
||||||
|
export interface ButtonProps extends CommonProps, Omit<AriaButtonProps, "children" | "className"> {}
|
||||||
|
/**
|
||||||
|
* Props for the link variant (anchor tag)
|
||||||
|
*/
|
||||||
|
interface LinkProps extends CommonProps, Omit<AriaLinkProps, "children" | "className"> {
|
||||||
|
href: NonNullable<AriaLinkProps["href"]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union type of button and link props */
|
||||||
|
export type Props = ButtonProps | LinkProps;
|
||||||
|
|
||||||
|
export const Button: {
|
||||||
|
(props: LinkProps): ReactElement<LinkProps>;
|
||||||
|
(props: ButtonProps): ReactElement<ButtonProps>;
|
||||||
|
} = ({
|
||||||
|
size = "sm",
|
||||||
|
color = "primary",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
noTextPadding,
|
||||||
|
iconLeading: IconLeading,
|
||||||
|
iconTrailing: IconTrailing,
|
||||||
|
isDisabled: disabled,
|
||||||
|
isLoading: loading,
|
||||||
|
showTextWhileLoading,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const href = "href" in props ? props.href : undefined;
|
||||||
|
|
||||||
|
const isIcon = (IconLeading || IconTrailing) && !children;
|
||||||
|
const isLinkType = ["link-gray", "link-color", "link-destructive"].includes(color);
|
||||||
|
|
||||||
|
noTextPadding = isLinkType || noTextPadding;
|
||||||
|
|
||||||
|
const commonChildren = (
|
||||||
|
<>
|
||||||
|
{/* Leading icon */}
|
||||||
|
{isValidElement(IconLeading) && IconLeading}
|
||||||
|
{isReactComponent(IconLeading) && <IconLeading data-icon="leading" className={styles.common.icon} />}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
data-icon="loading"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className={cx(styles.common.icon, !showTextWhileLoading && "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2")}
|
||||||
|
>
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle className="stroke-current opacity-30" cx="10" cy="10" r="8" fill="none" strokeWidth="2" />
|
||||||
|
{/* Spinning circle */}
|
||||||
|
<circle
|
||||||
|
className="origin-center animate-spin stroke-current"
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="8"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="12.5 50"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<span data-text className={cx("transition-inherit-all", !noTextPadding && "px-0.5")}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trailing icon */}
|
||||||
|
{isValidElement(IconTrailing) && IconTrailing}
|
||||||
|
{isReactComponent(IconTrailing) && <IconTrailing data-icon="trailing" className={styles.common.icon} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
"data-loading": loading ? true : undefined,
|
||||||
|
"data-icon-only": isIcon ? true : undefined,
|
||||||
|
...props,
|
||||||
|
isDisabled: disabled,
|
||||||
|
className: cx(
|
||||||
|
styles.common.root,
|
||||||
|
styles.sizes[size].root,
|
||||||
|
styles.colors[color].root,
|
||||||
|
isLinkType && styles.sizes[size].linkRoot,
|
||||||
|
(loading || (href && (disabled || loading))) && "pointer-events-none",
|
||||||
|
// If in `loading` state, hide everything except the loading icon (and text if `showTextWhileLoading` is true).
|
||||||
|
loading && (showTextWhileLoading ? "[&>*:not([data-icon=loading]):not([data-text])]:hidden" : "[&>*:not([data-icon=loading])]:invisible"),
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
children: commonChildren,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ("href" in commonProps) {
|
||||||
|
return <AriaLink {...commonProps} href={disabled ? undefined : href} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AriaButton {...commonProps} type={commonProps.type || "button"} isPending={loading} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { X as CloseIcon } from "@untitledui/icons";
|
||||||
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: { root: "size-7", icon: "size-4" },
|
||||||
|
sm: { root: "size-9", icon: "size-5" },
|
||||||
|
md: { root: "size-10", icon: "size-5" },
|
||||||
|
lg: { root: "size-11", icon: "size-6" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const themes = {
|
||||||
|
light: "text-fg-quaternary hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:outline-2 focus-visible:outline-offset-2 outline-focus-ring",
|
||||||
|
dark: "text-fg-white/70 hover:text-fg-white hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-offset-2 outline-focus-ring",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CloseButtonProps extends AriaButtonProps {
|
||||||
|
theme?: "light" | "dark";
|
||||||
|
size?: "xs" | "sm" | "md" | "lg";
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseButton = ({ label, className, size = "sm", theme = "light", ...otherProps }: CloseButtonProps) => {
|
||||||
|
return (
|
||||||
|
<AriaButton
|
||||||
|
{...otherProps}
|
||||||
|
aria-label={label || "Close"}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"flex cursor-pointer items-center justify-center rounded-lg p-2 transition duration-100 ease-linear focus:outline-hidden",
|
||||||
|
sizes[size].root,
|
||||||
|
themes[theme],
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CloseIcon aria-hidden="true" className={cx("shrink-0 transition-inherit-all", sizes[size].icon)} />
|
||||||
|
</AriaButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
|
||||||
|
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
||||||
|
|
||||||
|
export const styles = sortCx({
|
||||||
|
common: {
|
||||||
|
root: "group disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed",
|
||||||
|
icon: "pointer-events-none shrink-0 transition-inherit-all",
|
||||||
|
},
|
||||||
|
|
||||||
|
sizes: {
|
||||||
|
md: {
|
||||||
|
root: "gap-2 rounded-lg px-3.5 py-2.5 text-sm before:rounded-[7px] data-icon-only:p-3",
|
||||||
|
icon: "size-4",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "gap-2.5 rounded-lg px-4 py-2.5 text-md before:rounded-[7px] data-icon-only:p-3",
|
||||||
|
icon: "size-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
colors: {
|
||||||
|
gray: {
|
||||||
|
root: "bg-primary text-secondary shadow-xs-skeuomorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-secondary_hover",
|
||||||
|
icon: "text-fg-quaternary group-hover:text-fg-quaternary_hover",
|
||||||
|
},
|
||||||
|
black: {
|
||||||
|
root: "bg-black text-white shadow-xs-skeuomorphic ring-1 ring-transparent ring-inset before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0%",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
facebook: {
|
||||||
|
root: "bg-[#1877F2] text-white shadow-xs-skeuomorphic ring-1 ring-transparent ring-inset before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0% hover:bg-[#0C63D4]",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
dribble: {
|
||||||
|
root: "bg-[#EA4C89] text-white shadow-xs-skeuomorphic ring-1 ring-transparent ring-inset before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0% hover:bg-[#E62872]",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CommonProps {
|
||||||
|
social: "google" | "facebook" | "apple" | "twitter" | "figma" | "dribble";
|
||||||
|
disabled?: boolean;
|
||||||
|
theme?: "brand" | "color" | "gray";
|
||||||
|
size?: keyof typeof styles.sizes;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonProps extends CommonProps, DetailedHTMLProps<Omit<ButtonHTMLAttributes<HTMLButtonElement>, "color" | "slot">, HTMLButtonElement> {
|
||||||
|
slot?: AriaButtonProps["slot"];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkProps extends CommonProps, DetailedHTMLProps<Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "color">, HTMLAnchorElement> {
|
||||||
|
/** Options for the configured client side router. */
|
||||||
|
routerOptions?: AriaLinkProps["routerOptions"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocialButtonProps = ButtonProps | LinkProps;
|
||||||
|
|
||||||
|
export const SocialButton = ({ size = "lg", theme = "brand", social, className, children, disabled, ...otherProps }: SocialButtonProps) => {
|
||||||
|
const href = "href" in otherProps ? otherProps.href : undefined;
|
||||||
|
const Component = href ? AriaLink : AriaButton;
|
||||||
|
|
||||||
|
const isIconOnly = !children;
|
||||||
|
|
||||||
|
const socialToColor = {
|
||||||
|
google: "gray",
|
||||||
|
facebook: "facebook",
|
||||||
|
apple: "black",
|
||||||
|
twitter: "black",
|
||||||
|
figma: "black",
|
||||||
|
dribble: "dribble",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const colorStyles = theme === "brand" ? styles.colors[socialToColor[social]] : styles.colors.gray;
|
||||||
|
|
||||||
|
const logos = {
|
||||||
|
google: GoogleLogo,
|
||||||
|
facebook: FacebookLogo,
|
||||||
|
apple: AppleLogo,
|
||||||
|
twitter: TwitterLogo,
|
||||||
|
figma: theme === "gray" ? FigmaLogoOutlined : FigmaLogo,
|
||||||
|
dribble: DribbleLogo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Logo = logos[social];
|
||||||
|
|
||||||
|
let props = {};
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
href: disabled ? undefined : href,
|
||||||
|
|
||||||
|
// Since anchor elements do not support the `disabled` attribute and state,
|
||||||
|
// we need to specify `data-rac` and `data-disabled` in order to be able
|
||||||
|
// to use the `disabled:` selector in classes.
|
||||||
|
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
props = {
|
||||||
|
...otherProps,
|
||||||
|
|
||||||
|
type: otherProps.type || "button",
|
||||||
|
isDisabled: disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
isDisabled={disabled}
|
||||||
|
{...props}
|
||||||
|
data-icon-only={isIconOnly ? true : undefined}
|
||||||
|
className={cx(styles.common.root, styles.sizes[size].root, colorStyles.root, className)}
|
||||||
|
>
|
||||||
|
<Logo
|
||||||
|
className={cx(
|
||||||
|
styles.common.icon,
|
||||||
|
styles.sizes[size].icon,
|
||||||
|
theme === "gray"
|
||||||
|
? colorStyles.icon
|
||||||
|
: theme === "brand" && (social === "facebook" || social === "apple" || social === "twitter")
|
||||||
|
? "text-white"
|
||||||
|
: theme === "color" && (social === "apple" || social === "twitter")
|
||||||
|
? "text-alpha-black"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
colorful={
|
||||||
|
(theme === "brand" && (social === "google" || social === "figma")) ||
|
||||||
|
(theme === "color" && (social === "google" || social === "facebook" || social === "figma" || social === "dribble")) ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const GoogleLogo = ({ colorful, ...props }: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M23.766 12.2764C23.766 11.4607 23.6999 10.6406 23.5588 9.83807H12.24V14.4591H18.7217C18.4528 15.9494 17.5885 17.2678 16.323 18.1056V21.1039H20.19C22.4608 19.0139 23.766 15.9274 23.766 12.2764Z"
|
||||||
|
fill={colorful ? "#4285F4" : "currentColor"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.24 24.0008C15.4764 24.0008 18.2058 22.9382 20.1944 21.1039L16.3274 18.1055C15.2516 18.8375 13.8626 19.252 12.2444 19.252C9.11376 19.252 6.45934 17.1399 5.50693 14.3003H1.51648V17.3912C3.55359 21.4434 7.70278 24.0008 12.24 24.0008Z"
|
||||||
|
fill={colorful ? "#34A853" : "currentColor"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.50253 14.3003C4.99987 12.8099 4.99987 11.1961 5.50253 9.70575V6.61481H1.51649C-0.18551 10.0056 -0.18551 14.0004 1.51649 17.3912L5.50253 14.3003Z"
|
||||||
|
fill={colorful ? "#FBBC04" : "currentColor"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.24 4.74966C13.9508 4.7232 15.6043 5.36697 16.8433 6.54867L20.2694 3.12262C18.1 1.0855 15.2207 -0.034466 12.24 0.000808666C7.70277 0.000808666 3.55359 2.55822 1.51648 6.61481L5.50252 9.70575C6.45052 6.86173 9.10935 4.74966 12.24 4.74966Z"
|
||||||
|
fill={colorful ? "#EA4335" : "currentColor"}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FigmaLogo = ({ colorful, ...props }: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M8.00006 24.0001C10.2081 24.0001 12.0001 22.208 12.0001 20V16H8.00006C5.79205 16 4 17.792 4 20C4 22.208 5.79205 24.0001 8.00006 24.0001Z"
|
||||||
|
fill={colorful ? "#24CB71" : "currentColor"}
|
||||||
|
/>
|
||||||
|
<path d="M4 12C4 9.79203 5.79205 8 8.00006 8H12.0001V16H8.00006C5.79205 16.0001 4 14.208 4 12Z" fill={colorful ? "#874FFF" : "currentColor"} />
|
||||||
|
<path
|
||||||
|
d="M4 4.00003C4 1.79203 5.79205 0 8.00006 0H12.0001V7.99997H8.00006C5.79205 7.99997 4 6.20803 4 4.00003Z"
|
||||||
|
fill={colorful ? "#FF3737" : "currentColor"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 0H16.0001C18.2081 0 20.0001 1.79203 20.0001 4.00003C20.0001 6.20803 18.2081 7.99997 16.0001 7.99997H12V0Z"
|
||||||
|
fill={colorful ? "#FF7237" : "currentColor"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M20.0001 12C20.0001 14.208 18.2081 16.0001 16.0001 16.0001C13.792 16.0001 12 14.208 12 12C12 9.79203 13.792 8 16.0001 8C18.2081 8 20.0001 9.79203 20.0001 12Z"
|
||||||
|
fill={colorful ? "#00B6FF" : "currentColor"}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FigmaLogoOutlined = (props: SVGProps<SVGSVGElement>) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M8.25 2C7.51349 2 6.81155 2.28629 6.29747 2.78895C5.78414 3.29087 5.5 3.96677 5.5 4.66667C5.5 5.36657 5.78414 6.04247 6.29747 6.54438C6.81155 7.04705 7.51349 7.33333 8.25 7.33333H11V2H8.25ZM13 2V7.33333H15.75C16.1142 7.33333 16.4744 7.26316 16.8097 7.12736C17.145 6.99157 17.4482 6.79311 17.7025 6.54438C17.9569 6.29571 18.1574 6.00171 18.2938 5.67977C18.4301 5.35788 18.5 5.0137 18.5 4.66667C18.5 4.31964 18.4301 3.97545 18.2938 3.65356C18.1574 3.33162 17.9569 3.03763 17.7025 2.78895C17.4482 2.54022 17.145 2.34177 16.8097 2.20598C16.4744 2.07017 16.1142 2 15.75 2H13ZM18.6884 8.33334C18.8324 8.22191 18.9702 8.10211 19.1008 7.9744C19.5429 7.54211 19.8948 7.02769 20.1353 6.45991C20.3759 5.89208 20.5 5.28266 20.5 4.66667C20.5 4.05067 20.3759 3.44126 20.1353 2.87342C19.8948 2.30564 19.5429 1.79122 19.1008 1.35894C18.6587 0.926696 18.1351 0.584984 17.5605 0.352241C16.9858 0.119512 16.3707 0 15.75 0H8.25C6.99738 0 5.79167 0.486331 4.89923 1.35894C4.00603 2.23228 3.5 3.42165 3.5 4.66667C3.5 5.91169 4.00603 7.10105 4.89923 7.9744C5.03021 8.10247 5.16794 8.22222 5.31158 8.33333C5.16794 8.44445 5.03021 8.5642 4.89923 8.69227C4.00603 9.56562 3.5 10.755 3.5 12C3.5 13.245 4.00603 14.4344 4.89923 15.3077C5.03022 15.4358 5.16795 15.5556 5.31159 15.6667C5.16795 15.7778 5.03022 15.8975 4.89923 16.0256C4.00603 16.899 3.5 18.0883 3.5 19.3333C3.5 20.5784 4.00603 21.7677 4.89923 22.6411C5.79167 23.5137 6.99738 24 8.25 24C9.5026 24 10.7083 23.5137 11.6008 22.6411C12.494 21.7677 13 20.5784 13 19.3333V15.8051C13.2922 16.0089 13.6073 16.1799 13.9395 16.3144C14.5142 16.5472 15.1293 16.6667 15.75 16.6667C16.3707 16.6667 16.9858 16.5472 17.5605 16.3144C18.1351 16.0817 18.6587 15.74 19.1008 15.3077C19.5429 14.8754 19.8948 14.361 20.1353 13.7932C20.3759 13.2254 20.5 12.616 20.5 12C20.5 11.384 20.3759 10.7746 20.1353 10.2068C19.8948 9.63898 19.5429 9.12456 19.1008 8.69227C18.9702 8.56456 18.8324 8.44476 18.6884 8.33334ZM11 14.6667V9.33333H8.25C7.51349 9.33333 6.81155 9.61962 6.29747 10.1223C5.78414 10.6242 5.5 11.3001 5.5 12C5.5 12.6999 5.78414 13.3758 6.29747 13.8777C6.81155 14.3804 7.51349 14.6667 8.25 14.6667H11ZM11 16.6667H8.25C7.51349 16.6667 6.81155 16.953 6.29747 17.4556C5.78414 17.9575 5.5 18.6334 5.5 19.3333C5.5 20.0332 5.78414 20.7091 6.29747 21.2111C6.81155 21.7137 7.51349 22 8.25 22C8.98651 22 9.6884 21.7137 10.2025 21.2111C10.7159 20.7091 11 20.0332 11 19.3333V16.6667ZM15.75 9.33333C15.3858 9.33333 15.0256 9.4035 14.6903 9.53931C14.355 9.6751 14.0518 9.87356 13.7975 10.1223C13.5431 10.371 13.3426 10.665 13.2062 10.9869C13.0699 11.3088 13 11.653 13 12C13 12.347 13.0699 12.6912 13.2062 13.0131C13.3426 13.335 13.5431 13.629 13.7975 13.8777C14.0518 14.1264 14.355 14.3249 14.6903 14.4607C15.0256 14.5965 15.3858 14.6667 15.75 14.6667C16.1142 14.6667 16.4744 14.5965 16.8097 14.4607C17.145 14.3249 17.4482 14.1264 17.7025 13.8777C17.9569 13.629 18.1574 13.335 18.2938 13.0131C18.4301 12.6912 18.5 12.347 18.5 12C18.5 11.653 18.4301 11.3088 18.2938 10.9869C18.1574 10.665 17.9569 10.371 17.7025 10.1223C17.4482 9.87356 17.145 9.6751 16.8097 9.53931C16.4744 9.4035 16.1142 9.33333 15.75 9.33333Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DribbleLogo = ({ colorful, ...props }: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M12 23.625C18.4203 23.625 23.625 18.4203 23.625 12C23.625 5.57969 18.4203 0.375 12 0.375C5.57969 0.375 0.375 5.57969 0.375 12C0.375 18.4203 5.57969 23.625 12 23.625Z"
|
||||||
|
fill={colorful ? "#EA4C89" : "none"}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 0C5.37527 0 0 5.37527 0 12C0 18.6248 5.37527 24 12 24C18.6117 24 24 18.6248 24 12C24 5.37527 18.6117 0 12 0ZM19.9262 5.53145C21.3579 7.27549 22.217 9.50107 22.243 11.9089C21.9046 11.8439 18.5206 11.154 15.1106 11.5835C15.0325 11.4143 14.9675 11.2321 14.8894 11.0499C14.6811 10.5554 14.4469 10.0477 14.2126 9.56618C17.9869 8.0304 19.705 5.81779 19.9262 5.53145ZM12 1.77007C14.603 1.77007 16.9848 2.74621 18.7939 4.34707C18.6117 4.60738 17.0629 6.67679 13.4186 8.04338C11.7397 4.95878 9.87855 2.43384 9.5922 2.04338C10.3601 1.86117 11.1671 1.77007 12 1.77007ZM7.63995 2.73319C7.91325 3.09761 9.73538 5.63558 11.4404 8.65508C6.65076 9.9306 2.42083 9.90458 1.96529 9.90458C2.62907 6.72885 4.77657 4.08676 7.63995 2.73319ZM1.74404 12.0131C1.74404 11.9089 1.74404 11.8048 1.74404 11.7007C2.18655 11.7136 7.15835 11.7787 12.2733 10.243C12.5727 10.8156 12.846 11.4013 13.1063 11.9869C12.9761 12.026 12.8329 12.0651 12.7028 12.1041C7.41865 13.8091 4.60738 18.4685 4.3731 18.859C2.7462 17.0499 1.74404 14.6421 1.74404 12.0131ZM12 22.256C9.6312 22.256 7.44469 21.449 5.71367 20.0954C5.89588 19.718 7.97827 15.7094 13.757 13.692C13.783 13.679 13.7961 13.679 13.8221 13.666C15.2668 17.4013 15.8525 20.5379 16.0087 21.436C14.7722 21.9696 13.4186 22.256 12 22.256ZM17.7136 20.4989C17.6096 19.8742 17.0629 16.8807 15.7223 13.1974C18.9371 12.6898 21.7484 13.5228 22.0998 13.6399C21.6573 16.4902 20.0173 18.9501 17.7136 20.4989Z"
|
||||||
|
fill={colorful ? "#C32361" : "currentColor"}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FacebookLogo = ({ colorful, ...props }: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 17.9895 4.3882 22.954 10.125 23.8542V15.4688H7.07812V12H10.125V9.35625C10.125 6.34875 11.9166 4.6875 14.6576 4.6875C15.9701 4.6875 17.3438 4.92188 17.3438 4.92188V7.875H15.8306C14.34 7.875 13.875 8.80008 13.875 9.75V12H17.2031L16.6711 15.4688H13.875V23.8542C19.6118 22.954 24 17.9895 24 12Z"
|
||||||
|
fill={colorful ? "#1877F2" : "currentColor"}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppleLogo = (props: SVGProps<SVGSVGElement>) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
d="M20.8426 17.1449C20.5099 17.9135 20.1161 18.6211 19.6598 19.2715C19.0379 20.1583 18.5286 20.7721 18.1362 21.113C17.5278 21.6724 16.876 21.959 16.178 21.9753C15.6769 21.9753 15.0726 21.8327 14.3691 21.5434C13.6634 21.2555 13.0148 21.113 12.4218 21.113C11.7998 21.113 11.1328 21.2555 10.4193 21.5434C9.70475 21.8327 9.1291 21.9834 8.68898 21.9984C8.01963 22.0269 7.35246 21.7322 6.6865 21.113C6.26145 20.7422 5.7298 20.1067 5.09291 19.2063C4.40957 18.2449 3.84778 17.13 3.40766 15.8589C2.9363 14.486 2.70001 13.1565 2.70001 11.8694C2.70001 10.3951 3.01859 9.12345 3.65671 8.05784C4.15821 7.20191 4.82539 6.52672 5.66041 6.03105C6.49543 5.53539 7.39768 5.2828 8.36931 5.26664C8.90096 5.26664 9.59815 5.43109 10.4645 5.75429C11.3285 6.07858 11.8832 6.24303 12.1264 6.24303C12.3083 6.24303 12.9245 6.05074 13.9692 5.66738C14.9571 5.31186 15.7909 5.16466 16.474 5.22264C18.3249 5.37202 19.7155 6.10167 20.6403 7.41619C18.9849 8.4192 18.1661 9.82403 18.1824 11.6262C18.1973 13.03 18.7065 14.1981 19.7074 15.1256C20.1609 15.5561 20.6675 15.8888 21.231 16.1251C21.1088 16.4795 20.9798 16.819 20.8426 17.1449ZM16.5976 0.440369C16.5976 1.54062 16.1956 2.56792 15.3944 3.51878C14.4275 4.64917 13.258 5.30236 11.9898 5.19929C11.9737 5.06729 11.9643 4.92837 11.9643 4.78239C11.9643 3.72615 12.4241 2.59576 13.2407 1.67152C13.6483 1.20356 14.1668 0.814453 14.7955 0.504058C15.4229 0.198295 16.0164 0.0292007 16.5745 0.000244141C16.5908 0.147331 16.5976 0.294426 16.5976 0.440355V0.440369Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TwitterLogo = (props: SVGProps<SVGSVGElement>) => {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" {...props}>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M15.9455 23L10.396 15.0901L3.44886 23H0.509766L9.09209 13.2311L0.509766 1H8.05571L13.286 8.45502L19.8393 1H22.7784L14.5943 10.3165L23.4914 23H15.9455ZM19.2185 20.77H17.2398L4.71811 3.23H6.6971L11.7121 10.2532L12.5793 11.4719L19.2185 20.77Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import { Checkbox as AriaCheckbox, type CheckboxProps as AriaCheckboxProps } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export interface CheckboxBaseProps {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
isFocusVisible?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
isIndeterminate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CheckboxBase = ({ className, isSelected, isDisabled, isIndeterminate, size = "sm", isFocusVisible = false }: CheckboxBaseProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative flex size-4 shrink-0 cursor-pointer appearance-none items-center justify-center rounded bg-primary ring-1 ring-primary ring-inset",
|
||||||
|
size === "md" && "size-5 rounded-md",
|
||||||
|
(isSelected || isIndeterminate) && "bg-brand-solid ring-brand-solid",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isDisabled && !(isSelected || isIndeterminate) && "bg-tertiary",
|
||||||
|
isFocusVisible && "outline-2 outline-offset-2 outline-focus-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute h-3 w-2.5 text-fg-white opacity-0 transition-inherit-all",
|
||||||
|
size === "md" && "size-3.5",
|
||||||
|
isIndeterminate && "opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<path d="M2.91675 7H11.0834" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute size-3 text-fg-white opacity-0 transition-inherit-all",
|
||||||
|
size === "md" && "size-3.5",
|
||||||
|
isSelected && !isIndeterminate && "opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<path d="M11.6666 3.5L5.24992 9.91667L2.33325 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
CheckboxBase.displayName = "CheckboxBase";
|
||||||
|
|
||||||
|
interface CheckboxProps extends AriaCheckboxProps {
|
||||||
|
ref?: Ref<HTMLLabelElement>;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
label?: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox = ({ label, hint, size = "sm", className, ...ariaCheckboxProps }: CheckboxProps) => {
|
||||||
|
const sizes = {
|
||||||
|
sm: {
|
||||||
|
root: "gap-2",
|
||||||
|
textWrapper: "",
|
||||||
|
label: "text-sm font-medium",
|
||||||
|
hint: "text-sm",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "gap-3",
|
||||||
|
textWrapper: "gap-0.5",
|
||||||
|
label: "text-md font-medium",
|
||||||
|
hint: "text-md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaCheckbox
|
||||||
|
{...ariaCheckboxProps}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative flex items-start",
|
||||||
|
state.isDisabled && "cursor-not-allowed",
|
||||||
|
sizes[size].root,
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSelected, isIndeterminate, isDisabled, isFocusVisible }) => (
|
||||||
|
<>
|
||||||
|
<CheckboxBase
|
||||||
|
size={size}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isIndeterminate={isIndeterminate}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isFocusVisible={isFocusVisible}
|
||||||
|
className={label || hint ? "mt-0.5" : ""}
|
||||||
|
/>
|
||||||
|
{(label || hint) && (
|
||||||
|
<div className={cx("inline-flex flex-col", sizes[size].textWrapper)}>
|
||||||
|
{label && <p className={cx("text-secondary select-none", sizes[size].label)}>{label}</p>}
|
||||||
|
{hint && (
|
||||||
|
<span className={cx("text-tertiary", sizes[size].hint)} onClick={(event) => event.stopPropagation()}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaCheckbox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronSelectorVertical } from "@untitledui/icons";
|
||||||
|
import { Button as AriaButton, MenuItem as AriaMenuItem } from "react-aria-components";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { RadioButtonBase } from "../radio-buttons/radio-buttons";
|
||||||
|
|
||||||
|
const accounts = [
|
||||||
|
{
|
||||||
|
id: "caitlyn",
|
||||||
|
name: "Caitlyn King",
|
||||||
|
email: "caitlyn@untitledui.com",
|
||||||
|
avatar: "https://www.untitledui.com/images/avatars/caitlyn-king?fm=webp&q=80",
|
||||||
|
status: "online",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sienna",
|
||||||
|
name: "Sienna Hewitt",
|
||||||
|
email: "sienna@untitledui.com",
|
||||||
|
avatar: "https://www.untitledui.com/images/avatars/transparent/sienna-hewitt?bg=%23E0E0E0",
|
||||||
|
status: "online",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DropdownAccountBreadcrumb = () => {
|
||||||
|
const [selectedAccountKey, setSelectedAccountKey] = useState<string>("caitlyn");
|
||||||
|
|
||||||
|
const selectedAccount = accounts.find((account) => account.id === selectedAccountKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<AriaButton
|
||||||
|
className={({ isPressed, isFocusVisible }) =>
|
||||||
|
cx(
|
||||||
|
"flex cursor-pointer items-center gap-1.5 rounded-lg outline-0 outline-offset-2 outline-focus-ring",
|
||||||
|
(isPressed || isFocusVisible) && "outline-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex rounded-lg bg-primary p-0.5 ring-[0.5px] ring-secondary ring-inset">
|
||||||
|
<Avatar size="xs" src={selectedAccount?.avatar} className="shadow-md" contentClassName="rounded-md before:hidden" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-primary">{selectedAccount?.name}</span>
|
||||||
|
|
||||||
|
<ChevronSelectorVertical className="size-3 shrink-0 stroke-3 text-fg-quaternary" />
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-62" placement="bottom left">
|
||||||
|
<Dropdown.Menu
|
||||||
|
disallowEmptySelection
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={[selectedAccountKey]}
|
||||||
|
onSelectionChange={(keys) => setSelectedAccountKey(Array.from(keys).join())}
|
||||||
|
className="flex flex-col gap-1 px-1.5 py-1.5"
|
||||||
|
>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<AriaMenuItem
|
||||||
|
id={account.id}
|
||||||
|
key={account.name}
|
||||||
|
textValue={account.name}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative w-full cursor-pointer rounded-md px-2 py-2 text-left outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover focus:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
state.isSelected && "bg-primary_hover",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSelected }) => (
|
||||||
|
<>
|
||||||
|
<figure className="group flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
|
<div className="flex rounded-[10px] bg-primary p-0.5 ring-[0.5px] ring-secondary ring-inset">
|
||||||
|
<Avatar size="sm" src={account.avatar} className="shadow-md" contentClassName="rounded-lg before:hidden" />
|
||||||
|
</div>
|
||||||
|
<figcaption className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold text-primary">{account.name}</p>
|
||||||
|
<p className="truncate text-sm text-tertiary">{account.email}</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
<RadioButtonBase isSelected={isSelected} className="absolute top-2 right-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaMenuItem>
|
||||||
|
))}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, HelpCircle, LogOut01, Moon01, Plus, Settings01, User01 } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
|
||||||
|
export const DropdownAccountButton = () => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<Selection>(new Set(["olivia"]));
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<Selection>(new Set(["light-mode"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="group"
|
||||||
|
color="secondary"
|
||||||
|
iconTrailing={(props) => <ChevronDown data-icon="trailing" {...props} className="size-4! stroke-[2.25px]!" />}
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60 rounded-b-xl bg-secondary_alt">
|
||||||
|
<Dropdown.Menu className="rounded-b-xl bg-primary ring-1 ring-secondary">
|
||||||
|
<Dropdown.Item icon={User01} addon="⌘K->P">
|
||||||
|
View profile
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Settings01} addon="⌘S">
|
||||||
|
Settings
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedTheme} onSelectionChange={setSelectedTheme}>
|
||||||
|
<Dropdown.Item id="dark-mode" icon={Moon01} selectionIndicator="toggle">
|
||||||
|
Dark mode
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={HelpCircle}>Support</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Help center</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Contact support</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Send feedback</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedAccount} onSelectionChange={setSelectedAccount}>
|
||||||
|
<Dropdown.SectionHeader className="px-4 pt-1.5 pb-0.5 text-xs font-semibold text-brand-secondary">
|
||||||
|
Switch Account
|
||||||
|
</Dropdown.SectionHeader>
|
||||||
|
|
||||||
|
<Dropdown.Item id="olivia" avatarUrl="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" selectionIndicator="radio">
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="sienna" avatarUrl="https://www.untitledui.com/images/avatars/sienna-hewitt?fm=webp&q=80" selectionIndicator="radio">
|
||||||
|
Sienna Hewitt
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={Plus}>Add account</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<Button size="xs" color="secondary" iconLeading={LogOut01} className="text-center">
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronSelectorVertical, HelpCircle, LogOut01, Moon01, Plus, Settings01, User01 } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { AvatarLabelGroup } from "../avatar/avatar-label-group";
|
||||||
|
|
||||||
|
export const DropdownAccountCardMD = () => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<Selection>(new Set(["untitledui"]));
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<Selection>(new Set(["light-mode"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<AriaButton
|
||||||
|
className={({ isPressed, isFocusVisible }) =>
|
||||||
|
cx(
|
||||||
|
"relative w-60 cursor-pointer rounded-lg bg-primary_alt p-2 text-left inset-ring-1 inset-ring-border-secondary outline-offset-2 outline-focus-ring",
|
||||||
|
(isPressed || isFocusVisible) && "outline-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AvatarLabelGroup
|
||||||
|
size="md"
|
||||||
|
src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80"
|
||||||
|
status="online"
|
||||||
|
title="Olivia Rhye"
|
||||||
|
subtitle="olivia@untitledui.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 flex size-7 items-center justify-center rounded-md">
|
||||||
|
<ChevronSelectorVertical className="size-4 shrink-0 stroke-[2.25px] text-fg-quaternary" />
|
||||||
|
</div>
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<div className="flex flex-col border-b border-secondary px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-primary">PRO account</p>
|
||||||
|
<p className="text-sm text-tertiary">Renews 10 August 2028</p>
|
||||||
|
</div>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item icon={User01} addon="⌘K->P">
|
||||||
|
View profile
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Settings01} addon="⌘S">
|
||||||
|
Settings
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedTheme} onSelectionChange={setSelectedTheme}>
|
||||||
|
<Dropdown.Item id="dark-mode" icon={Moon01} selectionIndicator="toggle">
|
||||||
|
Dark mode
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={HelpCircle}>Support</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Help center</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Contact support</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Send feedback</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedAccount} onSelectionChange={setSelectedAccount}>
|
||||||
|
<Dropdown.SectionHeader className="px-4 pt-1.5 pb-0.5 text-xs font-semibold text-brand-secondary">Company</Dropdown.SectionHeader>
|
||||||
|
|
||||||
|
<Dropdown.Item id="untitledui">Untitled UI</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="sisyphus">Sisyphus Ventures</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Item icon={Plus}>New company</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={LogOut01}>Sign out</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Current device</Dropdown.Item>
|
||||||
|
<Dropdown.Item>All devices</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
<div className="flex justify-between border-t border-secondary px-4 py-3">
|
||||||
|
<span className="truncate text-sm text-quaternary">© Untitled UI</span>
|
||||||
|
<span className="text-sm text-quaternary">v12.6.8</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, HelpCircle, LogOut01, Moon01, Plus, Settings01, User01 } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const DropdownAccountCardSM = () => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<Selection>(new Set(["olivia"]));
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<Selection>(new Set(["light-mode"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<AriaButton
|
||||||
|
className={({ isPressed, isFocused }) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-42 cursor-pointer items-center gap-2 rounded-lg bg-primary_alt p-1.5 text-left inset-ring-1 inset-ring-border-secondary outline-offset-2 outline-focus-ring",
|
||||||
|
(isPressed || isFocused) && "outline-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar border size="sm" src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" status="online" />
|
||||||
|
|
||||||
|
<p className="text-sm font-semibold text-primary">Olivia Rhye</p>
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 flex size-7 items-center justify-center rounded-md">
|
||||||
|
<ChevronDown className="size-4 shrink-0 stroke-[2.25px] text-fg-quaternary" />
|
||||||
|
</div>
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<div className="flex flex-col border-b border-secondary px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-primary">PRO account</p>
|
||||||
|
<p className="text-sm text-tertiary">olivia@untitledui.com</p>
|
||||||
|
</div>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item icon={User01} addon="⌘K->P">
|
||||||
|
View profile
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Settings01} addon="⌘S">
|
||||||
|
Settings
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedTheme} onSelectionChange={setSelectedTheme}>
|
||||||
|
<Dropdown.Item id="dark-mode" icon={Moon01} selectionIndicator="toggle">
|
||||||
|
Dark mode
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={HelpCircle}>Support</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Help center</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Contact support</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Send feedback</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedAccount} onSelectionChange={setSelectedAccount}>
|
||||||
|
<Dropdown.SectionHeader className="px-4 pt-1.5 pb-0.5 text-xs font-semibold text-brand-secondary">
|
||||||
|
Switch Account
|
||||||
|
</Dropdown.SectionHeader>
|
||||||
|
|
||||||
|
<Dropdown.Item id="olivia" avatarUrl="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" selectionIndicator="radio">
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="sienna" avatarUrl="https://www.untitledui.com/images/avatars/sienna-hewitt?fm=webp&q=80" selectionIndicator="radio">
|
||||||
|
Sienna Hewitt
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Item icon={Plus}>Add account</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={LogOut01}>Sign out</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Current device</Dropdown.Item>
|
||||||
|
<Dropdown.Item>All devices</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
<div className="flex justify-between border-t border-secondary px-4 py-3">
|
||||||
|
<span className="truncate text-sm text-quaternary">© Untitled UI</span>
|
||||||
|
<span className="text-sm text-quaternary">v12.6.8</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, LogOut01, Moon01, Plus, Settings01 } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const DropdownAccountCardXS = () => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<Selection>(new Set(["olivia"]));
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<Selection>(new Set(["light-mode"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<AriaButton
|
||||||
|
className={({ isPressed, isFocused }) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-38 cursor-pointer items-center gap-1.5 rounded-lg bg-primary_alt p-2 text-left inset-ring-1 inset-ring-border-secondary outline-offset-2 outline-focus-ring",
|
||||||
|
(isPressed || isFocused) && "outline-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar size="xs" src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" className="size-5" />
|
||||||
|
|
||||||
|
<p className="text-sm font-semibold text-primary">Olivia Rhye</p>
|
||||||
|
|
||||||
|
<div className="absolute top-1 right-1 flex size-7 items-center justify-center rounded-md">
|
||||||
|
<ChevronDown className="size-4 shrink-0 stroke-[2.25px] text-fg-quaternary" />
|
||||||
|
</div>
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-50">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item icon={Settings01} addon="⌘S">
|
||||||
|
Settings
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedTheme} onSelectionChange={setSelectedTheme}>
|
||||||
|
<Dropdown.Item id="dark-mode" icon={Moon01} selectionIndicator="toggle">
|
||||||
|
Dark mode
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedAccount} onSelectionChange={setSelectedAccount}>
|
||||||
|
<Dropdown.SectionHeader className="px-4 pt-1.5 pb-0.5 text-xs font-semibold text-brand-secondary">
|
||||||
|
Switch Account
|
||||||
|
</Dropdown.SectionHeader>
|
||||||
|
|
||||||
|
<Dropdown.Item id="olivia" avatarUrl="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" selectionIndicator="radio">
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="sienna" avatarUrl="https://www.untitledui.com/images/avatars/sienna-hewitt?fm=webp&q=80" selectionIndicator="radio">
|
||||||
|
Sienna Hewitt
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Item icon={Plus}>Add account</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={LogOut01}>Sign out</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Current device</Dropdown.Item>
|
||||||
|
<Dropdown.Item>All devices</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Container, HelpCircle, LayersTwo01, LogOut01, Moon01, Settings01, User01 } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { AvatarLabelGroup } from "../avatar/avatar-label-group";
|
||||||
|
|
||||||
|
export const DropdownAvatar = () => {
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<Selection>(new Set(["light-mode"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<AriaButton
|
||||||
|
className={({ isPressed, isFocusVisible }) =>
|
||||||
|
cx(
|
||||||
|
"group relative inline-flex cursor-pointer rounded-full outline-offset-2 outline-focus-ring",
|
||||||
|
(isPressed || isFocusVisible) && "outline-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar alt="Olivia Rhye" src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" size="sm" />
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<div className="flex gap-3 border-b border-secondary p-3">
|
||||||
|
<AvatarLabelGroup
|
||||||
|
size="md"
|
||||||
|
src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80"
|
||||||
|
status="online"
|
||||||
|
title="Olivia Rhye"
|
||||||
|
subtitle="olivia@untitledui.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item icon={User01} addon="⌘K->P">
|
||||||
|
View profile
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Settings01} addon="⌘S">
|
||||||
|
Settings
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Section selectionMode="single" selectedKeys={selectedTheme} onSelectionChange={setSelectedTheme}>
|
||||||
|
<Dropdown.Item id="dark-mode" icon={Moon01} selectionIndicator="toggle">
|
||||||
|
Dark mode
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Item icon={LayersTwo01} addon="⌘S">
|
||||||
|
Changelog
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={HelpCircle}>Support</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6}>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Help center</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Contact support</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Send feedback</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
|
||||||
|
<Dropdown.Item icon={Container}>API</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
<div className="flex flex-col gap-3 border-t border-secondary p-3">
|
||||||
|
<Button size="xs" color="secondary" iconLeading={LogOut01} className="text-center">
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowNarrowLeft,
|
||||||
|
ArrowNarrowRight,
|
||||||
|
ChevronDown,
|
||||||
|
Code02,
|
||||||
|
Copy01,
|
||||||
|
Cube01,
|
||||||
|
Download01,
|
||||||
|
Edit04,
|
||||||
|
RefreshCcw02,
|
||||||
|
Scissors01,
|
||||||
|
Star01,
|
||||||
|
} from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const StatusDot = ({ status }: { status: "online" | "offline" }) => (
|
||||||
|
<span className="mr-2 inline-flex shrink-0 items-center justify-center p-[5px]">
|
||||||
|
<span className={cx("inline-block size-1.5 rounded-full", status === "online" ? "bg-fg-success-secondary" : "bg-utility-neutral-300")} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DropdownButtonAdvanced = () => {
|
||||||
|
const [viewOptions, setViewOptions] = useState<Selection>(new Set(["show-bookmarks"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="group"
|
||||||
|
color="secondary"
|
||||||
|
iconTrailing={(props) => <ChevronDown data-icon="trailing" {...props} className="size-4! stroke-[2.25px]!" />}
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={ArrowNarrowLeft}>Back</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={ArrowNarrowRight}>Forward</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘R" icon={RefreshCcw02}>
|
||||||
|
Reload
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Edit04}>Edit page</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Star01}>Add to favorites</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section selectionMode="multiple" selectedKeys={viewOptions} onSelectionChange={setViewOptions}>
|
||||||
|
<Dropdown.Item id="show-bookmarks">Show bookmarks</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="show-urls">Show full URLs</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item id="olivia" icon={() => <StatusDot status="online" />}>
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="sienna" icon={() => <StatusDot status="offline" />}>
|
||||||
|
Sienna Hewitt
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section>
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={Cube01}>More tools</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={Download01}>Save as</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Item>PDF</Dropdown.Item>
|
||||||
|
<Dropdown.Item>HTML</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Markdown</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
<Dropdown.Item addon="⌘X" icon={Scissors01}>
|
||||||
|
Cut
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘C" icon={Copy01}>
|
||||||
|
Copy
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={Code02}>Developer</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Item>View source</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Developer tools</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Inspect elements</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Section>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, Trash01 } from "@untitledui/icons";
|
||||||
|
import { Button as AriaButton } from "react-aria-components";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const permissions = [
|
||||||
|
{ id: "owner", label: "Owner" },
|
||||||
|
{ id: "can-edit", label: "Can edit" },
|
||||||
|
{ id: "can-view", label: "Can view" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DropdownButtonLink = () => {
|
||||||
|
const [selectedPermission, setSelectedPermission] = useState<string>(permissions[1].id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<AriaButton
|
||||||
|
className={({ isPressed, isFocusVisible }) =>
|
||||||
|
cx(
|
||||||
|
"flex cursor-pointer items-center gap-1 rounded text-sm font-semibold text-tertiary outline-0 outline-offset-2 outline-focus-ring",
|
||||||
|
(isPressed || isFocusVisible) && "outline-2",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{permissions.find((permission) => permission.id === selectedPermission.toString())?.label}
|
||||||
|
<ChevronDown className="size-3 shrink-0 stroke-3 text-fg-quaternary" />
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-40">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Section
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={[selectedPermission]}
|
||||||
|
onSelectionChange={(keys) => setSelectedPermission(typeof keys === "string" ? keys : (keys.keys().toArray()[0] as string))}
|
||||||
|
>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<Dropdown.Item key={permission.id} id={permission.id}>
|
||||||
|
{permission.label}
|
||||||
|
</Dropdown.Item>
|
||||||
|
))}
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={Trash01}>Delete</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ChevronDown } from "@untitledui/icons";
|
||||||
|
import { SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
|
||||||
|
export const DropdownButtonSimple = () => (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Button size="sm" color="secondary" iconTrailing={ChevronDown} className="group *:data-icon:size-4 *:data-icon:stroke-[2.25px]!">
|
||||||
|
Account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-54">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item addon="⌘X">Cut</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘C">Copy</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘V">Paste</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item>Edit</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Duplicate</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Delete</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
<Dropdown.Section>
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item>View details</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Share</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Save as</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Archive</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Section>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ArrowNarrowLeft, ArrowNarrowRight, Code02, Copy01, Cube01, Download01, Edit04, RefreshCcw02, Scissors01, Star01 } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const StatusDot = ({ status }: { status: "online" | "offline" }) => (
|
||||||
|
<span className="mr-2 inline-flex shrink-0 items-center justify-center p-[5px]">
|
||||||
|
<span className={cx("inline-block size-1.5 rounded-full", status === "online" ? "bg-fg-success-secondary" : "bg-utility-neutral-300")} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DropdownIconAdvanced = () => {
|
||||||
|
const [viewOptions, setViewOptions] = useState<Selection>(new Set(["show-bookmarks"]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Dropdown.DotsButton />
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={ArrowNarrowLeft}>Back</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={ArrowNarrowRight}>Forward</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘R" icon={RefreshCcw02}>
|
||||||
|
Reload
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={Edit04}>Edit page</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Star01}>Add to favorites</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section selectionMode="multiple" selectedKeys={viewOptions} onSelectionChange={setViewOptions}>
|
||||||
|
<Dropdown.Item id="show-bookmarks">Show bookmarks</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="show-urls">Show full URLs</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item id="olivia" icon={() => <StatusDot status="online" />}>
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="sienna" icon={() => <StatusDot status="offline" />}>
|
||||||
|
Sienna Hewitt
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section>
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={Cube01}>More tools</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={Download01}>Save as</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Item>PDF</Dropdown.Item>
|
||||||
|
<Dropdown.Item>HTML</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Markdown</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
<Dropdown.Item addon="⌘X" icon={Scissors01}>
|
||||||
|
Cut
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘C" icon={Copy01}>
|
||||||
|
Copy
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item icon={Code02}>Developer</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Item>View source</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Developer tools</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Inspect elements</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Section>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { SubmenuTrigger } from "react-aria-components";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
|
||||||
|
export const DropdownIconSimple = () => (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Dropdown.DotsButton />
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-54">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item addon="⌘X">Cut</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘C">Copy</Dropdown.Item>
|
||||||
|
<Dropdown.Item addon="⌘V">Paste</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item>Edit</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Duplicate</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Delete</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
<Dropdown.Separator />
|
||||||
|
<Dropdown.Section>
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item>View details</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item>Share</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Save as</Dropdown.Item>
|
||||||
|
<Dropdown.Item>Archive</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
</Dropdown.Section>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ChevronDown, Copy01, TerminalSquare } from "@untitledui/icons";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import {
|
||||||
|
BoltIcon,
|
||||||
|
ChatGPTIcon,
|
||||||
|
ClaudeIcon,
|
||||||
|
CursorIcon,
|
||||||
|
FigmaIcon,
|
||||||
|
GeminiIcon,
|
||||||
|
GitHubIcon,
|
||||||
|
LovableIcon,
|
||||||
|
PerplexityIcon,
|
||||||
|
V0Icon,
|
||||||
|
} from "@/components/foundations/integration-icons";
|
||||||
|
|
||||||
|
export const DropdownIntegration = () => {
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="group"
|
||||||
|
color="secondary"
|
||||||
|
iconTrailing={(props) => <ChevronDown data-icon="trailing" {...props} className="size-4! stroke-[2.25px]!" />}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-54">
|
||||||
|
<Dropdown.Menu selectionMode="none">
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={TerminalSquare}>View as markdown</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={Copy01}>Copy as markdown</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={() => <V0Icon grayscale className="mr-2 size-4 shrink-0" />}>Open in v0</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <ClaudeIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Claude</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <BoltIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Bolt</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <LovableIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Lovable</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <CursorIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Cursor</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <ChatGPTIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in ChatGPT</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <PerplexityIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Perplexity</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <GeminiIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Gemini</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
|
||||||
|
<Dropdown.Separator />
|
||||||
|
|
||||||
|
<Dropdown.Section>
|
||||||
|
<Dropdown.Item icon={() => <FigmaIcon grayscale className="mr-2 size-4 shrink-0" />}>Open in Figma</Dropdown.Item>
|
||||||
|
<Dropdown.Item icon={() => <GitHubIcon grayscale className="mr-2 size-4 shrink-0" />}>Create GitHub Gist</Dropdown.Item>
|
||||||
|
</Dropdown.Section>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, Plus, SearchLg } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { Autocomplete, SearchField, SubmenuTrigger, useFilter } from "react-aria-components";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { InputBase } from "../input/input";
|
||||||
|
|
||||||
|
export const DropdownSearchAdvanced = () => {
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<Selection>(new Set(["untitledui", "shutterframe"]));
|
||||||
|
let { contains } = useFilter({ sensitivity: "base" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="group"
|
||||||
|
color="secondary"
|
||||||
|
iconTrailing={(props) => <ChevronDown data-icon="trailing" {...props} className="size-4! stroke-[2.25px]!" />}
|
||||||
|
>
|
||||||
|
Manage access
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<Autocomplete filter={contains}>
|
||||||
|
<SearchField className="flex gap-3 border-b border-secondary p-3">
|
||||||
|
<InputBase size="md" placeholder="Search" icon={SearchLg} />
|
||||||
|
</SearchField>
|
||||||
|
<Dropdown.Menu selectionMode="multiple" selectedKeys={selectedUsers} onSelectionChange={setSelectedUsers}>
|
||||||
|
<SubmenuTrigger>
|
||||||
|
<Dropdown.Item id="untitledui" textValue="Olivia Rhye" selectionIndicator="checkbox">
|
||||||
|
Untitled UI
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Popover placement="right top" offset={-6} className="w-50">
|
||||||
|
<Dropdown.Menu selectionMode="multiple">
|
||||||
|
<Dropdown.Item
|
||||||
|
id="olivia"
|
||||||
|
selectionIndicator="checkbox"
|
||||||
|
avatarUrl="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80"
|
||||||
|
>
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
id="phoenix"
|
||||||
|
selectionIndicator="checkbox"
|
||||||
|
avatarUrl="https://www.untitledui.com/images/avatars/phoenix-baker?fm=webp&q=80"
|
||||||
|
>
|
||||||
|
Phoenix Baker
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
id="lana"
|
||||||
|
selectionIndicator="checkbox"
|
||||||
|
avatarUrl="https://www.untitledui.com/images/avatars/lana-steiner?fm=webp&q=80"
|
||||||
|
>
|
||||||
|
Lana Steiner
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
id="demi"
|
||||||
|
selectionIndicator="checkbox"
|
||||||
|
avatarUrl="https://www.untitledui.com/images/avatars/demi-wilkinson?fm=webp&q=80"
|
||||||
|
>
|
||||||
|
Demi Wilkinson
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</SubmenuTrigger>
|
||||||
|
|
||||||
|
<Dropdown.Item id="shutterframe" textValue="Phoenix Baker" selectionIndicator="checkbox">
|
||||||
|
Shutterframe
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="warpspeed" textValue="Lana Steiner" selectionIndicator="checkbox">
|
||||||
|
Warpspeed
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="contrastai" textValue="Demi Wilkinson" selectionIndicator="checkbox">
|
||||||
|
ContrastAI
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="launchsimple" textValue="Candice Wu" selectionIndicator="checkbox">
|
||||||
|
LaunchSimple
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="elasticware" textValue="Natali Craig" selectionIndicator="checkbox">
|
||||||
|
Elasticware
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
<div className="flex flex-col gap-3 border-t border-secondary p-3">
|
||||||
|
<Button size="xs" color="secondary" iconLeading={Plus}>
|
||||||
|
Create team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Autocomplete>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown, SearchLg } from "@untitledui/icons";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import { Autocomplete, SearchField, useFilter } from "react-aria-components";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { Dropdown } from "@/components/base/dropdown/dropdown";
|
||||||
|
import { InputBase } from "../input/input";
|
||||||
|
|
||||||
|
export const DropdownSearchSimple = () => {
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<Selection>(new Set(["olivia", "phoenix"]));
|
||||||
|
let { contains } = useFilter({ sensitivity: "base" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="group"
|
||||||
|
color="secondary"
|
||||||
|
iconTrailing={(props) => <ChevronDown data-icon="trailing" {...props} className="size-4! stroke-[2.25px]!" />}
|
||||||
|
>
|
||||||
|
Manage access
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dropdown.Popover className="w-60">
|
||||||
|
<Autocomplete filter={contains}>
|
||||||
|
<SearchField className="flex gap-3 border-b border-secondary p-3">
|
||||||
|
<InputBase size="md" placeholder="Search" icon={SearchLg} />
|
||||||
|
</SearchField>
|
||||||
|
<Dropdown.Menu selectionMode="multiple" selectedKeys={selectedUsers} onSelectionChange={setSelectedUsers}>
|
||||||
|
<Dropdown.Item id="olivia" textValue="Olivia Rhye" selectionIndicator="checkbox">
|
||||||
|
Olivia Rhye
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="phoenix" textValue="Phoenix Baker" selectionIndicator="checkbox">
|
||||||
|
Phoenix Baker
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="lana" textValue="Lana Steiner" selectionIndicator="checkbox">
|
||||||
|
Lana Steiner
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="demi" textValue="Demi Wilkinson" selectionIndicator="checkbox">
|
||||||
|
Demi Wilkinson
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="candice" textValue="Candice Wu" selectionIndicator="checkbox">
|
||||||
|
Candice Wu
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="natali" textValue="Natali Craig" selectionIndicator="checkbox">
|
||||||
|
Natali Craig
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="drew" textValue="Drew Cano" selectionIndicator="checkbox">
|
||||||
|
Drew Cano
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="orlando" textValue="Orlando Diggs" selectionIndicator="checkbox">
|
||||||
|
Orlando Diggs
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item id="andi" textValue="Andi Lane" selectionIndicator="checkbox">
|
||||||
|
Andi Lane
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Autocomplete>
|
||||||
|
</Dropdown.Popover>
|
||||||
|
</Dropdown.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { type FC, type RefAttributes, useCallback } from "react";
|
||||||
|
import { Check, ChevronRight, DotsVertical } from "@untitledui/icons";
|
||||||
|
import type {
|
||||||
|
ButtonProps as AriaButtonProps,
|
||||||
|
MenuItemProps as AriaMenuItemProps,
|
||||||
|
MenuProps as AriaMenuProps,
|
||||||
|
PopoverProps as AriaPopoverProps,
|
||||||
|
SeparatorProps as AriaSeparatorProps,
|
||||||
|
MenuItemRenderProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import {
|
||||||
|
Button as AriaButton,
|
||||||
|
Header as AriaHeader,
|
||||||
|
Menu as AriaMenu,
|
||||||
|
MenuItem as AriaMenuItem,
|
||||||
|
MenuSection as AriaMenuSection,
|
||||||
|
MenuTrigger as AriaMenuTrigger,
|
||||||
|
Popover as AriaPopover,
|
||||||
|
Separator as AriaSeparator,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { Avatar } from "../avatar/avatar";
|
||||||
|
import { CheckboxBase } from "../checkbox/checkbox";
|
||||||
|
import { RadioButtonBase } from "../radio-buttons/radio-buttons";
|
||||||
|
import { ToggleBase } from "../toggle/toggle";
|
||||||
|
|
||||||
|
interface DropdownItemProps extends AriaMenuItemProps {
|
||||||
|
/** The label of the item to be displayed. */
|
||||||
|
label?: string;
|
||||||
|
/** An addon to be displayed on the right side of the item. */
|
||||||
|
addon?: string;
|
||||||
|
/** If true, the item will not have any styles. */
|
||||||
|
unstyled?: boolean;
|
||||||
|
/** An icon to be displayed on the left side of the item. */
|
||||||
|
icon?: FC<{ className?: string }>;
|
||||||
|
/** Avatar URL to be displayed on the left side of the item. */
|
||||||
|
avatarUrl?: string;
|
||||||
|
/** The selection indicator to be displayed on the item. */
|
||||||
|
selectionIndicator?: "checkmark" | "checkbox" | "radio" | "toggle" | "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownItem = ({ label, children, addon, icon: Icon, avatarUrl, unstyled, selectionIndicator = "checkmark", ...props }: DropdownItemProps) => {
|
||||||
|
const SelectionIndicator = useCallback(
|
||||||
|
(state: MenuItemRenderProps & { className?: string }) => {
|
||||||
|
if (selectionIndicator === "checkmark") {
|
||||||
|
return (
|
||||||
|
<Check
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx("size-4 shrink-0 stroke-[2.25px] text-fg-brand-primary", !state.isSelected && "invisible", state.className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectionIndicator === "checkbox") {
|
||||||
|
return (
|
||||||
|
<CheckboxBase
|
||||||
|
isSelected={state.isSelected && !state.hasSubmenu}
|
||||||
|
isIndeterminate={state.isSelected && state.hasSubmenu}
|
||||||
|
size="sm"
|
||||||
|
className={cx("shrink-0", state.className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectionIndicator === "radio") {
|
||||||
|
return <RadioButtonBase isSelected={state.isSelected} className={cx("shrink-0", state.className)} />;
|
||||||
|
}
|
||||||
|
if (selectionIndicator === "toggle") {
|
||||||
|
return <ToggleBase slim size="sm" isSelected={state.isSelected} className={cx("shrink-0", state.className)} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[selectionIndicator],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unstyled) {
|
||||||
|
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaMenuItem
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"group block cursor-pointer px-1.5 py-px outline-hidden",
|
||||||
|
state.isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative flex items-center rounded-md px-2.5 py-2 outline-focus-ring transition duration-100 ease-linear",
|
||||||
|
!state.isDisabled && "group-hover:bg-primary_hover",
|
||||||
|
state.isFocused && "bg-primary_hover",
|
||||||
|
state.isFocusVisible && "outline-2 -outline-offset-2",
|
||||||
|
state.hasSubmenu && "pr-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{state.selectionMode !== "none" && !avatarUrl && !Icon && <SelectionIndicator {...state} className="mr-2" />}
|
||||||
|
|
||||||
|
{avatarUrl && (
|
||||||
|
<div className="mr-2 flex size-4 items-center justify-center">
|
||||||
|
<Avatar aria-hidden="true" size="xs" src={avatarUrl} alt={label} className="size-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Icon && <Icon aria-hidden="true" className="mr-2 size-4 shrink-0 stroke-[2.25px] text-fg-quaternary" />}
|
||||||
|
|
||||||
|
<span className={cx("grow truncate text-sm font-semibold text-secondary", state.isFocused && "text-secondary_hover")}>
|
||||||
|
{label || (typeof children === "function" ? children(state) : children)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{addon && <span className="ml-1 shrink-0 pr-1 text-xs font-medium text-quaternary">{addon}</span>}
|
||||||
|
|
||||||
|
{state.selectionMode !== "none" && (avatarUrl || Icon) && <SelectionIndicator {...state} className="ml-1" />}
|
||||||
|
|
||||||
|
{state.hasSubmenu && <ChevronRight aria-hidden="true" className="ml-auto size-4 shrink-0 stroke-[2.25px] text-fg-quaternary" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaMenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DropdownMenuProps<T extends object> extends AriaMenuProps<T> {}
|
||||||
|
|
||||||
|
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||||
|
return (
|
||||||
|
<AriaMenu
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx("h-min overflow-y-auto py-1 outline-hidden select-none", typeof props.className === "function" ? props.className(state) : props.className)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DropdownPopoverProps extends AriaPopoverProps {}
|
||||||
|
|
||||||
|
const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||||
|
return (
|
||||||
|
<AriaPopover
|
||||||
|
placement="bottom right"
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"w-62 origin-(--trigger-anchor-point) overflow-auto rounded-lg bg-primary shadow-lg ring-1 ring-secondary_alt will-change-transform",
|
||||||
|
state.isEntering &&
|
||||||
|
"duration-150 ease-out animate-in fade-in placement-right:slide-in-from-left-0.5 placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
|
||||||
|
state.isExiting &&
|
||||||
|
"duration-100 ease-in animate-out fade-out placement-right:slide-out-to-left-0.5 placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</AriaPopover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
||||||
|
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
||||||
|
return (
|
||||||
|
<AriaButton
|
||||||
|
{...props}
|
||||||
|
aria-label="Open menu"
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"cursor-pointer rounded-md text-fg-quaternary outline-focus-ring transition duration-100 ease-linear",
|
||||||
|
(state.isPressed || state.isHovered) && "text-fg-quaternary_hover",
|
||||||
|
(state.isPressed || state.isFocusVisible) && "outline-2 outline-offset-2",
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DotsVertical className="size-5 transition-inherit-all" />
|
||||||
|
</AriaButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dropdown = {
|
||||||
|
Root: AriaMenuTrigger,
|
||||||
|
Popover: DropdownPopover,
|
||||||
|
Menu: DropdownMenu,
|
||||||
|
Section: AriaMenuSection,
|
||||||
|
SectionHeader: AriaHeader,
|
||||||
|
Item: DropdownItem,
|
||||||
|
Separator: DropdownSeparator,
|
||||||
|
DotsButton: DropdownDotsButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ComponentPropsWithRef } from "react";
|
||||||
|
import { Form as AriaForm } from "react-aria-components";
|
||||||
|
|
||||||
|
export const Form = (props: ComponentPropsWithRef<typeof AriaForm>) => {
|
||||||
|
return <AriaForm {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
Form.displayName = "Form";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import type { TextProps as AriaTextProps } from "react-aria-components";
|
||||||
|
import { Text as AriaText } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface HintTextProps extends AriaTextProps {
|
||||||
|
/** Indicates that the hint text is an error message. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
ref?: Ref<HTMLElement>;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HintText = ({ isInvalid, className, size = "md", ...props }: HintTextProps) => {
|
||||||
|
return (
|
||||||
|
<AriaText
|
||||||
|
{...props}
|
||||||
|
slot={isInvalid ? "errorMessage" : "description"}
|
||||||
|
className={cx(
|
||||||
|
"text-sm text-tertiary",
|
||||||
|
|
||||||
|
// Size
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
"in-data-[input-size=sm]:text-xs",
|
||||||
|
|
||||||
|
// Invalid state
|
||||||
|
isInvalid && "text-error-primary",
|
||||||
|
"group-invalid:text-error-primary",
|
||||||
|
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
HintText.displayName = "HintText";
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
|
||||||
|
import { HelpCircle, InfoCircle } from "@untitledui/icons";
|
||||||
|
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
|
||||||
|
import {
|
||||||
|
DateField as AriaDateField,
|
||||||
|
type DateFieldProps as AriaDateFieldProps,
|
||||||
|
DateInput as AriaDateInput,
|
||||||
|
DateSegment as AriaDateSegment,
|
||||||
|
Group as AriaGroup,
|
||||||
|
type DateValue,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { Tooltip, TooltipTrigger } from "../tooltip/tooltip";
|
||||||
|
import { HintText } from "./hint-text";
|
||||||
|
import { Label } from "./label";
|
||||||
|
|
||||||
|
const DateFieldContext = createContext<{
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
wrapperClassName?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
tooltipClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
export interface InputDateBaseProps extends Omit<AriaDateInputProps, "children"> {
|
||||||
|
/** Tooltip message on hover. */
|
||||||
|
tooltip?: string;
|
||||||
|
/**
|
||||||
|
* Input size.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Class name for the icon. */
|
||||||
|
iconClassName?: string;
|
||||||
|
/** Class name for the input wrapper. */
|
||||||
|
wrapperClassName?: string;
|
||||||
|
/** Class name for the tooltip. */
|
||||||
|
tooltipClassName?: string;
|
||||||
|
/** Keyboard shortcut to display. */
|
||||||
|
shortcut?: string | boolean;
|
||||||
|
ref?: Ref<HTMLInputElement>;
|
||||||
|
groupRef?: Ref<HTMLDivElement>;
|
||||||
|
/** Icon component to display on the left side of the input. */
|
||||||
|
icon?: ComponentType<HTMLAttributes<HTMLOrSVGElement>>;
|
||||||
|
isInvalid?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputDateBase = ({
|
||||||
|
tooltip,
|
||||||
|
shortcut,
|
||||||
|
groupRef,
|
||||||
|
size = "md",
|
||||||
|
isInvalid,
|
||||||
|
isDisabled,
|
||||||
|
icon: Icon,
|
||||||
|
wrapperClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
iconClassName,
|
||||||
|
...inputProps
|
||||||
|
}: Omit<InputDateBaseProps, "label" | "hint">) => {
|
||||||
|
// Check if the input has a leading icon or tooltip
|
||||||
|
const hasTrailingIcon = tooltip || isInvalid;
|
||||||
|
const hasLeadingIcon = Icon;
|
||||||
|
|
||||||
|
// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
|
||||||
|
const context = useContext(DateFieldContext);
|
||||||
|
|
||||||
|
const inputSize = context?.size || size;
|
||||||
|
|
||||||
|
const sizes = sortCx({
|
||||||
|
sm: {
|
||||||
|
root: cx("px-3 py-2 text-sm", hasTrailingIcon && "pr-9", hasLeadingIcon && "pl-8.5"),
|
||||||
|
iconLeading: "left-3 size-4 stroke-[2.25px]",
|
||||||
|
iconTrailing: "right-3",
|
||||||
|
shortcut: "pr-2.5",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: cx("px-3 py-2 text-md", hasTrailingIcon && "pr-9", hasLeadingIcon && "pl-10"),
|
||||||
|
iconLeading: "left-3 size-5",
|
||||||
|
iconTrailing: "right-3",
|
||||||
|
shortcut: "pr-2.5",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: cx("px-3.5 py-2.5 text-md", hasTrailingIcon && "pr-9.5", hasLeadingIcon && "pl-10.5"),
|
||||||
|
iconLeading: "left-3.5 size-5",
|
||||||
|
iconTrailing: "right-3.5",
|
||||||
|
shortcut: "pr-3",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaGroup
|
||||||
|
{...{ isDisabled, isInvalid }}
|
||||||
|
ref={groupRef}
|
||||||
|
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||||
|
cx(
|
||||||
|
"group/input relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary transition-shadow duration-100 ease-linear ring-inset",
|
||||||
|
|
||||||
|
isFocusWithin && !isDisabled && "ring-2 ring-brand",
|
||||||
|
|
||||||
|
// Disabled state styles
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50 in-data-input-wrapper:opacity-100",
|
||||||
|
"group-disabled:cursor-not-allowed group-disabled:opacity-50 in-data-input-wrapper:group-disabled:opacity-100",
|
||||||
|
|
||||||
|
// Invalid state styles
|
||||||
|
isInvalid && "ring-error_subtle",
|
||||||
|
"group-invalid:ring-error_subtle",
|
||||||
|
|
||||||
|
// Invalid state with focus-within styles
|
||||||
|
isInvalid && isFocusWithin && "ring-2 ring-error",
|
||||||
|
isFocusWithin && "group-invalid:ring-2 group-invalid:ring-error",
|
||||||
|
|
||||||
|
context?.wrapperClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Leading icon and Payment icon */}
|
||||||
|
{Icon && (
|
||||||
|
<Icon className={cx("pointer-events-none absolute text-fg-quaternary", sizes[inputSize].iconLeading, context?.iconClassName, iconClassName)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input field */}
|
||||||
|
<AriaDateInput {...inputProps} className={cx("flex w-full", sizes[size].root, typeof inputProps.className === "string" && inputProps.className)}>
|
||||||
|
{(segment) => (
|
||||||
|
<AriaDateSegment
|
||||||
|
segment={segment}
|
||||||
|
className={cx(
|
||||||
|
"rounded px-0.5 text-primary tabular-nums caret-transparent focus:bg-brand-solid focus:font-medium focus:text-white focus:outline-hidden",
|
||||||
|
// The placeholder segment.
|
||||||
|
segment.isPlaceholder && "text-placeholder uppercase",
|
||||||
|
// The separator "/" segment.
|
||||||
|
segment.type === "literal" && "text-fg-quaternary",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AriaDateInput>
|
||||||
|
|
||||||
|
{/* Tooltip and help icon */}
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip title={tooltip} placement="top">
|
||||||
|
<TooltipTrigger
|
||||||
|
className={cx(
|
||||||
|
"absolute cursor-pointer text-fg-quaternary transition duration-200 group-invalid/input:hidden hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover",
|
||||||
|
sizes[inputSize].iconTrailing,
|
||||||
|
context?.tooltipClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="size-4 stroke-[2.25px]" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invalid icon */}
|
||||||
|
<InfoCircle
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
|
||||||
|
sizes[inputSize].iconTrailing,
|
||||||
|
context?.tooltipClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shortcut */}
|
||||||
|
{shortcut && (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute inset-y-0.5 right-0.5 z-10 flex items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8",
|
||||||
|
sizes[inputSize].shortcut,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset"
|
||||||
|
>
|
||||||
|
{typeof shortcut === "string" ? shortcut : "⌘K"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InputProps
|
||||||
|
extends
|
||||||
|
AriaDateFieldProps<DateValue>,
|
||||||
|
Pick<
|
||||||
|
InputDateBaseProps,
|
||||||
|
"ref" | "size" | "placeholder" | "icon" | "shortcut" | "tooltip" | "groupRef" | "iconClassName" | "wrapperClassName" | "tooltipClassName"
|
||||||
|
> {
|
||||||
|
/** Label text for the input */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the input */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Whether to hide required indicator from label */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
/** Class name for the input. */
|
||||||
|
inputClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputDate = ({
|
||||||
|
size = "md",
|
||||||
|
placeholder,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
shortcut,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
className,
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
tooltip,
|
||||||
|
iconClassName,
|
||||||
|
inputClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
...props
|
||||||
|
}: InputProps) => {
|
||||||
|
return (
|
||||||
|
<AriaDateField
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isInvalid, state }) => (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : state.isRequired} isInvalid={isInvalid}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputDateBase
|
||||||
|
className={inputClassName}
|
||||||
|
{...{
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
size,
|
||||||
|
placeholder,
|
||||||
|
icon: Icon,
|
||||||
|
shortcut,
|
||||||
|
iconClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
tooltip,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaDateField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { InputBase } from "@/components/base/input/input";
|
||||||
|
import { InputGroup } from "@/components/base/input/input-group";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface InputFileProps {
|
||||||
|
/**
|
||||||
|
* The size of the input.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Label text for the input. */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the input. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Placeholder text displayed when no file is selected. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the input is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Whether the input is invalid. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/** Whether the input is required. */
|
||||||
|
isRequired?: boolean;
|
||||||
|
/** Whether to hide the required indicator from the label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
/** Specifies what mime type of files are allowed. */
|
||||||
|
acceptedFileTypes?: string[];
|
||||||
|
/** Whether multiple files can be selected. */
|
||||||
|
allowsMultiple?: boolean;
|
||||||
|
/** Whether the file is currently uploading. */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Handler when a user selects files. */
|
||||||
|
onChange?: (files: FileList | null) => void;
|
||||||
|
/** The class name for the root element. */
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* The text of the upload button.
|
||||||
|
* @default "Upload"
|
||||||
|
*/
|
||||||
|
buttonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputFile = ({
|
||||||
|
size = "sm",
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
placeholder = "Choose a file",
|
||||||
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
|
isRequired,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
isLoading,
|
||||||
|
acceptedFileTypes,
|
||||||
|
allowsMultiple,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
buttonText = "Upload",
|
||||||
|
}: InputFileProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [fileNames, setFileNames] = useState("");
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (inputRef.current?.value) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
inputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
const files = inputRef.current?.files ?? null;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
setFileNames(
|
||||||
|
Array.from(files)
|
||||||
|
.map((f) => f.name)
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setFileNames("");
|
||||||
|
}
|
||||||
|
onChange?.(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InputGroup
|
||||||
|
size={size}
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isInvalid={isInvalid}
|
||||||
|
isRequired={isRequired}
|
||||||
|
hideRequiredIndicator={hideRequiredIndicator}
|
||||||
|
className={className}
|
||||||
|
trailingAddon={
|
||||||
|
<Button size={size} color="secondary" onClick={handleClick} isDisabled={isDisabled}>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative flex min-w-0 flex-1">
|
||||||
|
<InputBase
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={fileNames}
|
||||||
|
readOnly
|
||||||
|
inputClassName={cx("cursor-pointer", isLoading && "pr-9")}
|
||||||
|
wrapperClassName="cursor-pointer"
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<svg fill="none" viewBox="0 0 16 16" className="pointer-events-none absolute top-1/2 right-3 z-20 size-4 -translate-y-1/2 text-fg-quaternary">
|
||||||
|
<circle className="stroke-current opacity-30" cx="8" cy="8" r="6.5" strokeWidth="1.5" />
|
||||||
|
<circle
|
||||||
|
className="origin-center animate-spin stroke-current"
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6.5"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeDasharray="10 40"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
disabled={isDisabled}
|
||||||
|
accept={acceptedFileTypes?.toString()}
|
||||||
|
multiple={allowsMultiple}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
InputFile.displayName = "InputFile";
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { type HTMLAttributes, type ReactNode } from "react";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import type { TextFieldProps } from "@/components/base/input/input";
|
||||||
|
import { TextField } from "@/components/base/input/input";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface InputPrefixProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** The position of the prefix. */
|
||||||
|
position?: "leading" | "trailing";
|
||||||
|
/** Indicates that the prefix is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputPrefix = ({ children, ...props }: InputPrefixProps) => (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={cx(
|
||||||
|
"flex text-tertiary shadow-xs ring-1 ring-border-primary ring-inset",
|
||||||
|
// Styles when the prefix is within an `InputGroup`
|
||||||
|
"in-data-input-wrapper:in-data-leading:-mr-px in-data-input-wrapper:in-data-leading:rounded-l-lg",
|
||||||
|
"in-data-input-wrapper:in-data-trailing:-ml-px in-data-input-wrapper:in-data-trailing:rounded-r-lg",
|
||||||
|
// Default size styles
|
||||||
|
"px-3 py-2 text-md",
|
||||||
|
// Small size styles
|
||||||
|
"in-data-input-wrapper:in-data-[input-size=sm]:px-3 in-data-input-wrapper:in-data-[input-size=sm]:py-2 in-data-input-wrapper:in-data-[input-size=sm]:text-sm",
|
||||||
|
// Large size styles
|
||||||
|
"in-data-input-wrapper:in-data-[input-size=lg]:py-2.5 in-data-input-wrapper:in-data-[input-size=lg]:pr-3 in-data-input-wrapper:in-data-[input-size=lg]:pl-3.5",
|
||||||
|
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// `${string}ClassName` is used to omit any className prop that ends with a `ClassName` suffix
|
||||||
|
interface InputGroupProps extends TextFieldProps {
|
||||||
|
/** A prefix text that is displayed in the same box as the input.*/
|
||||||
|
prefix?: string;
|
||||||
|
/** A leading addon that is displayed with visual separation from the input. */
|
||||||
|
leadingAddon?: ReactNode;
|
||||||
|
/** A trailing addon that is displayed with visual separation from the input. */
|
||||||
|
trailingAddon?: ReactNode;
|
||||||
|
/** The class name to apply to the input group. */
|
||||||
|
className?: string;
|
||||||
|
/** The children of the input group (i.e `<InputBase />`) */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Label text for the input */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the input */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Whether to hide the required indicator from the label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputGroup = ({ size = "md", prefix, leadingAddon, trailingAddon, label, hint, hideRequiredIndicator, children, ...props }: InputGroupProps) => {
|
||||||
|
const hasLeading = !!leadingAddon;
|
||||||
|
const hasTrailing = !!trailingAddon;
|
||||||
|
|
||||||
|
const paddings = sortCx({
|
||||||
|
sm: {
|
||||||
|
input: cx(
|
||||||
|
// Apply padding styles when select element is passed as a child
|
||||||
|
hasLeading && "group-has-[&>select]:pr-9 group-has-[&>select]:pl-2",
|
||||||
|
hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
|
||||||
|
),
|
||||||
|
leadingText: "pr-1.5 pl-3",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
input: cx(
|
||||||
|
// Apply padding styles when select element is passed as a child
|
||||||
|
hasLeading && "group-has-[&>select]:pr-9 group-has-[&>select]:pl-2.5",
|
||||||
|
hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
|
||||||
|
),
|
||||||
|
leadingText: "pr-2 pl-3",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
input: cx(
|
||||||
|
// Apply padding styles when select element is passed as a child
|
||||||
|
hasLeading && "group-has-[&>select]:pr-9.5 group-has-[&>select]:pl-3",
|
||||||
|
hasTrailing && (prefix ? "group-has-[&>select]:pr-6 group-has-[&>select]:pl-0" : "group-has-[&>select]:pr-6 group-has-[&>select]:pl-3"),
|
||||||
|
),
|
||||||
|
leadingText: "pr-2 pl-3.5",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
size={size}
|
||||||
|
aria-label={label || undefined}
|
||||||
|
inputClassName={cx(paddings[size].input)}
|
||||||
|
tooltipClassName={cx(hasTrailing && !hasLeading && "group-has-[&>select]:right-0")}
|
||||||
|
wrapperClassName={cx(
|
||||||
|
"z-10",
|
||||||
|
// Apply styles based on the presence of leading or trailing elements
|
||||||
|
hasLeading && "rounded-l-none",
|
||||||
|
hasTrailing && "rounded-r-none",
|
||||||
|
// When select element is passed as a child
|
||||||
|
"group-has-[&>select]:bg-transparent group-has-[&>select]:shadow-none group-has-[&>select]:ring-0 group-has-[&>select]:focus-within:ring-0",
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{({ isDisabled, isInvalid, isRequired }) => (
|
||||||
|
<>
|
||||||
|
{label && <Label isRequired={hideRequiredIndicator ? false : isRequired}>{label}</Label>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
// Used to apply styles based on the size of the input group within children
|
||||||
|
data-input-size={size}
|
||||||
|
className={cx(
|
||||||
|
"group relative flex h-max w-full flex-row justify-center rounded-lg bg-primary transition-all duration-100 ease-linear",
|
||||||
|
|
||||||
|
// Only apply focus ring when child is select and input is focused
|
||||||
|
"has-[&>select]:shadow-xs has-[&>select]:ring-1 has-[&>select]:ring-border-primary has-[&>select]:ring-inset has-[&>select]:has-[input:focus]:ring-2 has-[&>select]:has-[input:focus]:ring-border-brand",
|
||||||
|
|
||||||
|
isDisabled && "cursor-not-allowed",
|
||||||
|
isInvalid && "has-[&>select]:ring-border-error_subtle has-[&>select]:has-[input:focus]:ring-border-error",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leadingAddon && (
|
||||||
|
<section data-leading={hasLeading || undefined} className="group-disabled:opacity-50">
|
||||||
|
{leadingAddon}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefix && (
|
||||||
|
<span className={cx("my-auto grow group-disabled:opacity-50", paddings[size].leadingText)}>
|
||||||
|
<p className={cx("text-md text-tertiary", size === "sm" && "text-sm")}>{prefix}</p>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{trailingAddon && (
|
||||||
|
<section data-trailing={hasTrailing || undefined} className="group-disabled:opacity-50">
|
||||||
|
{trailingAddon}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
InputGroup.Prefix = InputPrefix;
|
||||||
|
|
||||||
|
InputGroup.displayName = "InputGroup";
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { type ReactNode, type Ref, createContext, useContext } from "react";
|
||||||
|
import { ChevronDown, ChevronUp, Minus, Plus } from "@untitledui/icons";
|
||||||
|
import {
|
||||||
|
Button as AriaButton,
|
||||||
|
type DateFieldProps as AriaDateFieldProps,
|
||||||
|
Group as AriaGroup,
|
||||||
|
Input as AriaInput,
|
||||||
|
type InputProps as AriaInputProps,
|
||||||
|
NumberField as AriaNumberField,
|
||||||
|
type NumberFieldProps as AriaNumberFieldProps,
|
||||||
|
type DateValue,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { Button } from "../buttons/button";
|
||||||
|
import { HintText } from "./hint-text";
|
||||||
|
import { Label } from "./label";
|
||||||
|
|
||||||
|
const NumberFieldContext = createContext<{
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
wrapperClassName?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
tooltipClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: "px-3 py-2 text-sm",
|
||||||
|
md: "px-3 py-2 text-md",
|
||||||
|
lg: "px-3.5 py-2.5 text-md",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InputNumberBaseProps extends AriaNumberFieldProps {
|
||||||
|
/**
|
||||||
|
* Input size.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Class name for the input. */
|
||||||
|
inputClassName?: string;
|
||||||
|
/** Class name for the input wrapper. */
|
||||||
|
wrapperClassName?: string;
|
||||||
|
ref?: Ref<HTMLInputElement>;
|
||||||
|
groupRef?: Ref<HTMLDivElement>;
|
||||||
|
/** Orientation of buttons. */
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputNumberBase = ({
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
size = "md",
|
||||||
|
isInvalid,
|
||||||
|
isDisabled,
|
||||||
|
placeholder,
|
||||||
|
wrapperClassName,
|
||||||
|
inputClassName,
|
||||||
|
orientation = "vertical",
|
||||||
|
// Omit this prop to avoid invalid HTML attribute warning
|
||||||
|
isRequired: _isRequired,
|
||||||
|
...inputProps
|
||||||
|
}: Omit<InputNumberBaseProps, "label" | "hint">) => {
|
||||||
|
// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
|
||||||
|
const context = useContext(NumberFieldContext);
|
||||||
|
|
||||||
|
const inputSize = context?.size || size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaGroup
|
||||||
|
{...{ isDisabled, isInvalid }}
|
||||||
|
ref={groupRef}
|
||||||
|
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-full flex-row items-stretch rounded-lg bg-primary shadow-xs outline-1 -outline-offset-1 outline-primary transition-all duration-100 ease-linear",
|
||||||
|
|
||||||
|
isFocusWithin && !isDisabled && "outline-2 -outline-offset-2 outline-brand",
|
||||||
|
|
||||||
|
// Disabled state styles
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50 in-data-input-wrapper:opacity-100",
|
||||||
|
"group-disabled:cursor-not-allowed group-disabled:opacity-50 in-data-input-wrapper:group-disabled:opacity-100",
|
||||||
|
|
||||||
|
// Invalid state styles
|
||||||
|
isInvalid && "outline-error_subtle",
|
||||||
|
"group-invalid:outline-error_subtle",
|
||||||
|
|
||||||
|
// Invalid state with focus-within styles
|
||||||
|
isInvalid && isFocusWithin && "outline-2 -outline-offset-2 outline-error",
|
||||||
|
isFocusWithin && "group-invalid:outline-2 group-invalid:-outline-offset-2 group-invalid:outline-error",
|
||||||
|
|
||||||
|
context?.wrapperClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{orientation === "horizontal" && (
|
||||||
|
<Button size={size} iconLeading={Minus} slot="decrement" color="tertiary" className="static h-full rounded-r-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input field */}
|
||||||
|
<AriaInput
|
||||||
|
{...(inputProps as AriaInputProps)}
|
||||||
|
ref={ref}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cx(
|
||||||
|
"m-0 w-full bg-transparent text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary disabled:cursor-not-allowed",
|
||||||
|
styles[inputSize],
|
||||||
|
context?.inputClassName,
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{orientation === "horizontal" && (
|
||||||
|
<Button size={size} iconLeading={Plus} slot="increment" color="tertiary" className="static h-full rounded-l-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orientation === "vertical" && (
|
||||||
|
<div className={cx("flex w-7 shrink-0 flex-col border-l border-primary", size === "lg" && "w-7.5")}>
|
||||||
|
<AriaButton
|
||||||
|
slot="increment"
|
||||||
|
className="flex flex-1 cursor-pointer items-center justify-center text-fg-quaternary outline-brand transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronUp className={cx("size-3 stroke-3", size === "lg" && "size-3.5 stroke-[2.57px]")} />
|
||||||
|
</AriaButton>
|
||||||
|
<AriaButton
|
||||||
|
slot="decrement"
|
||||||
|
className="flex flex-1 cursor-pointer items-center justify-center border-t border-primary text-fg-quaternary outline-brand transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cx("size-3 stroke-3", size === "lg" && "size-3.5 stroke-[2.57px]")} />
|
||||||
|
</AriaButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InputProps extends InputNumberBaseProps, Pick<AriaDateFieldProps<DateValue>, "granularity"> {
|
||||||
|
/** Label text for the input */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the input */
|
||||||
|
hint?: ReactNode;
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputNumber = ({
|
||||||
|
size = "md",
|
||||||
|
placeholder,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
className,
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
inputClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: InputProps) => {
|
||||||
|
return (
|
||||||
|
<AriaNumberField
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isInvalid, isRequired }) => (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : isRequired} isInvalid={isInvalid}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputNumberBase
|
||||||
|
{...{
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
size,
|
||||||
|
placeholder,
|
||||||
|
inputClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
orientation,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaNumberField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { Key, KeyboardEvent, ReactNode } from "react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { InputBase } from "@/components/base/input/input";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { Tag, TagGroup, TagList } from "@/components/base/tags/tags";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface TagEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputTagsOuterProps {
|
||||||
|
/** Label text displayed above the input. */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the tags. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Tooltip message displayed via a help icon inside the input. */
|
||||||
|
tooltip?: string;
|
||||||
|
/**
|
||||||
|
* Input size variant.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text for the input field. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the field is required. */
|
||||||
|
isRequired?: boolean;
|
||||||
|
/** Whether the field is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Whether the field is in an invalid/error state. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to allow duplicate tag values.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowDuplicates?: boolean;
|
||||||
|
/** Maximum number of tags allowed. */
|
||||||
|
maxTags?: number;
|
||||||
|
/** Controlled value: array of tag strings. */
|
||||||
|
value?: string[];
|
||||||
|
/** Default tags for uncontrolled mode. */
|
||||||
|
defaultValue?: string[];
|
||||||
|
/** Called when the tags array changes. */
|
||||||
|
onChange?: (tags: string[]) => void;
|
||||||
|
/** Called when a tag is added. */
|
||||||
|
onTagAdded?: (tag: string) => void;
|
||||||
|
/** Called when a tag is removed. */
|
||||||
|
onTagRemoved?: (tag: string) => void;
|
||||||
|
/**
|
||||||
|
* Validation function for new tags.
|
||||||
|
* Return `true` to accept, `false` to reject.
|
||||||
|
*/
|
||||||
|
validate?: (value: string) => boolean;
|
||||||
|
/** Optional className for the outer container. */
|
||||||
|
className?: string;
|
||||||
|
/** Whether to hide the required indicator from the label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputTagsOuter = ({
|
||||||
|
size = "md",
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
tooltip,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
|
allowDuplicates = false,
|
||||||
|
maxTags,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
onTagAdded,
|
||||||
|
onTagRemoved,
|
||||||
|
validate,
|
||||||
|
className,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
}: InputTagsOuterProps) => {
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const idCounter = useRef(0);
|
||||||
|
const nextId = () => `tag-${idCounter.current++}`;
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
|
const [internalEntries, setInternalEntries] = useState<TagEntry[]>(() => (defaultValue ?? []).map((label) => ({ id: nextId(), label })));
|
||||||
|
|
||||||
|
const prevControlledValue = useRef<string[]>([]);
|
||||||
|
const controlledEntries = useRef<TagEntry[]>([]);
|
||||||
|
|
||||||
|
const entries = (() => {
|
||||||
|
if (!isControlled) return internalEntries;
|
||||||
|
|
||||||
|
const prev = prevControlledValue.current;
|
||||||
|
if (prev === value) return controlledEntries.current;
|
||||||
|
|
||||||
|
const oldEntries = controlledEntries.current;
|
||||||
|
const newEntries: TagEntry[] = [];
|
||||||
|
const usedOldIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (const label of value) {
|
||||||
|
const oldIndex = oldEntries.findIndex((e, i) => e.label === label && !usedOldIndices.has(i));
|
||||||
|
if (oldIndex !== -1) {
|
||||||
|
usedOldIndices.add(oldIndex);
|
||||||
|
newEntries.push(oldEntries[oldIndex]);
|
||||||
|
} else {
|
||||||
|
newEntries.push({ id: nextId(), label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevControlledValue.current = value;
|
||||||
|
controlledEntries.current = newEntries;
|
||||||
|
return newEntries;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const tags = entries.map((e) => e.label);
|
||||||
|
|
||||||
|
const addTag = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (!allowDuplicates && tags.includes(trimmed)) return false;
|
||||||
|
if (maxTags && tags.length >= maxTags) return false;
|
||||||
|
if (validate && !validate(trimmed)) return false;
|
||||||
|
|
||||||
|
const newEntry: TagEntry = { id: nextId(), label: trimmed };
|
||||||
|
const newEntries = [...entries, newEntry];
|
||||||
|
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalEntries(newEntries);
|
||||||
|
}
|
||||||
|
onChange?.(newEntries.map((e) => e.label));
|
||||||
|
onTagAdded?.(trimmed);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[tags, entries, isControlled, allowDuplicates, maxTags, validate, onChange, onTagAdded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeTag = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const newEntries = entries.filter((e) => e.id !== id);
|
||||||
|
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalEntries(newEntries);
|
||||||
|
}
|
||||||
|
onChange?.(newEntries.map((e) => e.label));
|
||||||
|
onTagRemoved?.(entry.label);
|
||||||
|
},
|
||||||
|
[entries, isControlled, onChange, onTagRemoved],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(keys: Set<Key>) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
removeTag(key.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (addTag(inputValue)) {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("flex flex-col", size === "sm" ? "gap-1.5" : "gap-2", className)}>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && <Label isRequired={hideRequiredIndicator ? false : isRequired}>{label}</Label>}
|
||||||
|
|
||||||
|
<InputBase
|
||||||
|
size={size}
|
||||||
|
tooltip={tooltip}
|
||||||
|
placeholder={placeholder}
|
||||||
|
isInvalid={isInvalid}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<TagGroup label={label || "Tags"} size={size === "lg" ? "md" : size} onRemove={handleRemove}>
|
||||||
|
<TagList className="flex flex-wrap gap-1.5 focus:outline-hidden" items={entries}>
|
||||||
|
{(item) => (
|
||||||
|
<Tag id={item.id} isDisabled={isDisabled}>
|
||||||
|
{item.label}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</TagList>
|
||||||
|
</TagGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hint && tags.length === 0 && (
|
||||||
|
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import type { Key, KeyboardEvent, ReactNode } from "react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { HelpCircle, InfoCircle } from "@untitledui/icons";
|
||||||
|
import { Group as AriaGroup, Input as AriaInput } from "react-aria-components";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { Tag, TagGroup, TagList } from "@/components/base/tags/tags";
|
||||||
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface TagEntry {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputTagsProps {
|
||||||
|
/** Label text displayed above the input. */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the input. */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Tooltip message displayed via a help icon inside the input. */
|
||||||
|
tooltip?: string;
|
||||||
|
/**
|
||||||
|
* Input size variant.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text for the input field. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the field is required. */
|
||||||
|
isRequired?: boolean;
|
||||||
|
/** Whether the field is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Whether the field is in an invalid/error state. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to allow duplicate tag values.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowDuplicates?: boolean;
|
||||||
|
/** Maximum number of tags allowed. */
|
||||||
|
maxTags?: number;
|
||||||
|
/** Controlled value: array of tag strings. */
|
||||||
|
value?: string[];
|
||||||
|
/** Default tags for uncontrolled mode. */
|
||||||
|
defaultValue?: string[];
|
||||||
|
/** Called when the tags array changes. */
|
||||||
|
onChange?: (tags: string[]) => void;
|
||||||
|
/** Called when a tag is added. */
|
||||||
|
onTagAdded?: (tag: string) => void;
|
||||||
|
/** Called when a tag is removed. */
|
||||||
|
onTagRemoved?: (tag: string) => void;
|
||||||
|
/**
|
||||||
|
* Validation function for new tags.
|
||||||
|
* Return `true` to accept, `false` to reject.
|
||||||
|
*/
|
||||||
|
validate?: (value: string) => boolean;
|
||||||
|
/** Optional className for the outer container. */
|
||||||
|
className?: string;
|
||||||
|
/** Whether to hide the required indicator from the label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputTags = ({
|
||||||
|
size = "md",
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
tooltip,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
|
allowDuplicates = false,
|
||||||
|
maxTags,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
onTagAdded,
|
||||||
|
onTagRemoved,
|
||||||
|
validate,
|
||||||
|
className,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
}: InputTagsProps) => {
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const idCounter = useRef(0);
|
||||||
|
const nextId = () => idCounter.current++;
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagGroupRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
|
||||||
|
const [internalEntries, setInternalEntries] = useState<TagEntry[]>(() => (defaultValue ?? []).map((label) => ({ id: nextId(), label })));
|
||||||
|
|
||||||
|
// For controlled mode, maintain stable IDs across renders so React keys don't shift
|
||||||
|
const prevControlledValue = useRef<string[]>([]);
|
||||||
|
const controlledEntries = useRef<TagEntry[]>([]);
|
||||||
|
|
||||||
|
const entries = (() => {
|
||||||
|
if (!isControlled) return internalEntries;
|
||||||
|
|
||||||
|
const prev = prevControlledValue.current;
|
||||||
|
if (prev === value) return controlledEntries.current;
|
||||||
|
|
||||||
|
// Reconcile: reuse existing IDs for tags that haven't changed position,
|
||||||
|
// assign new IDs only for genuinely new entries
|
||||||
|
const oldEntries = controlledEntries.current;
|
||||||
|
const newEntries: TagEntry[] = [];
|
||||||
|
const usedOldIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (const label of value) {
|
||||||
|
// Try to find a matching old entry (same label, not yet used)
|
||||||
|
const oldIndex = oldEntries.findIndex((e, i) => e.label === label && !usedOldIndices.has(i));
|
||||||
|
if (oldIndex !== -1) {
|
||||||
|
usedOldIndices.add(oldIndex);
|
||||||
|
newEntries.push(oldEntries[oldIndex]);
|
||||||
|
} else {
|
||||||
|
newEntries.push({ id: nextId(), label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevControlledValue.current = value;
|
||||||
|
controlledEntries.current = newEntries;
|
||||||
|
return newEntries;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const tags = entries.map((e) => e.label);
|
||||||
|
|
||||||
|
const addTag = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (!allowDuplicates && tags.includes(trimmed)) return false;
|
||||||
|
if (maxTags && tags.length >= maxTags) return false;
|
||||||
|
if (validate && !validate(trimmed)) return false;
|
||||||
|
|
||||||
|
const newEntry: TagEntry = { id: nextId(), label: trimmed };
|
||||||
|
const newEntries = [...entries, newEntry];
|
||||||
|
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalEntries(newEntries);
|
||||||
|
}
|
||||||
|
onChange?.(newEntries.map((e) => e.label));
|
||||||
|
onTagAdded?.(trimmed);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[tags, entries, isControlled, allowDuplicates, maxTags, validate, onChange, onTagAdded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeTag = useCallback(
|
||||||
|
(id: number) => {
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const newEntries = entries.filter((e) => e.id !== id);
|
||||||
|
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalEntries(newEntries);
|
||||||
|
}
|
||||||
|
onChange?.(newEntries.map((e) => e.label));
|
||||||
|
onTagRemoved?.(entry.label);
|
||||||
|
},
|
||||||
|
[entries, isControlled, onChange, onTagRemoved],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(keys: Set<Key>) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
removeTag(key as number);
|
||||||
|
}
|
||||||
|
if (entries.length - keys.size <= 0) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeTag, entries.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusLastTag = useCallback(() => {
|
||||||
|
const tagEls = tagGroupRef.current?.querySelectorAll<HTMLElement>('[role="row"]');
|
||||||
|
if (tagEls && tagEls.length > 0) {
|
||||||
|
tagEls[tagEls.length - 1].focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const isCaretAtStart = input.selectionStart === 0 && input.selectionEnd === 0;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "Enter":
|
||||||
|
event.preventDefault();
|
||||||
|
if (addTag(inputValue)) {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Backspace":
|
||||||
|
if (isCaretAtStart && inputValue === "") {
|
||||||
|
focusLastTag();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (isCaretAtStart) {
|
||||||
|
focusLastTag();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagGroupKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
const tagEls = tagGroupRef.current?.querySelectorAll<HTMLElement>('[role="row"]');
|
||||||
|
if (tagEls && tagEls.length > 0) {
|
||||||
|
const lastTag = tagEls[tagEls.length - 1];
|
||||||
|
if (document.activeElement === lastTag || lastTag.contains(document.activeElement)) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = entries.length === 0;
|
||||||
|
const hasTrailingIcon = tooltip || isInvalid;
|
||||||
|
|
||||||
|
const sizes = sortCx({
|
||||||
|
sm: {
|
||||||
|
root: cx("gap-2 px-3 py-2 text-sm", !isEmpty && "py-1.5 pl-2", hasTrailingIcon && "pr-9"),
|
||||||
|
iconTrailing: "right-3",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: cx("gap-2 px-3 py-2 text-md", !isEmpty && "pl-2", hasTrailingIcon && "pr-9"),
|
||||||
|
iconTrailing: "right-3",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: cx("gap-2 px-3.5 py-2.5 text-md", !isEmpty && "pl-2.5", hasTrailingIcon && "pr-9.5"),
|
||||||
|
iconTrailing: "right-3.5",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("flex flex-col gap-1.5", className)}>
|
||||||
|
{label && <Label isRequired={hideRequiredIndicator ? false : isRequired}>{label}</Label>}
|
||||||
|
|
||||||
|
<AriaGroup
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isInvalid={isInvalid}
|
||||||
|
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||||
|
cx(
|
||||||
|
"group/input relative flex w-full items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isFocusWithin && !isDisabled && "ring-2 ring-brand",
|
||||||
|
isInvalid && !isFocusWithin && "ring-error_subtle",
|
||||||
|
isInvalid && isFocusWithin && "ring-2 ring-error",
|
||||||
|
sizes[size].root,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isDisabled }) => (
|
||||||
|
<>
|
||||||
|
<div className={cx("relative flex w-full flex-1 flex-row flex-wrap items-center justify-start", size === "sm" ? "gap-1.5" : "gap-2")}>
|
||||||
|
{!isEmpty && (
|
||||||
|
<div ref={tagGroupRef} onKeyDown={handleTagGroupKeyDown} className="contents">
|
||||||
|
<TagGroup label={label || "Tags"} size={size === "lg" ? "md" : size} onRemove={handleRemove} className="contents">
|
||||||
|
<TagList className="flex flex-wrap gap-1.5 focus:outline-hidden" items={entries}>
|
||||||
|
{(item) => (
|
||||||
|
<Tag
|
||||||
|
id={item.id}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
className="focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-[-2px] focus-visible:outline-hidden"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</TagList>
|
||||||
|
</TagGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex min-w-[20%] flex-1 flex-row items-center">
|
||||||
|
<AriaInput
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder={isEmpty ? placeholder : undefined}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
className="w-full flex-[1_0_0] appearance-none bg-transparent text-ellipsis text-primary caret-alpha-black/90 outline-hidden placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip title={tooltip} placement="top">
|
||||||
|
<TooltipTrigger
|
||||||
|
className={cx(
|
||||||
|
"absolute cursor-pointer text-fg-quaternary transition duration-100 ease-linear group-invalid/input:hidden hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover",
|
||||||
|
sizes[size].iconTrailing,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="size-4 stroke-[2.25px]" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InfoCircle
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
|
||||||
|
sizes[size].iconTrailing,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaGroup>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext, useState } from "react";
|
||||||
|
import { Eye, EyeOff, HelpCircle, InfoCircle } from "@untitledui/icons";
|
||||||
|
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export interface InputBaseProps extends Omit<AriaInputProps, "size"> {
|
||||||
|
/** Tooltip message on hover. */
|
||||||
|
tooltip?: string;
|
||||||
|
/** Whether the input is invalid. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/** Whether the input is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Whether the input is required. */
|
||||||
|
isRequired?: boolean;
|
||||||
|
/**
|
||||||
|
* Input size.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Class name for the icon. */
|
||||||
|
iconClassName?: string;
|
||||||
|
/** Class name for the input. */
|
||||||
|
inputClassName?: string;
|
||||||
|
/** Class name for the input wrapper. */
|
||||||
|
wrapperClassName?: string;
|
||||||
|
/** Class name for the tooltip. */
|
||||||
|
tooltipClassName?: string;
|
||||||
|
/** Keyboard shortcut to display. */
|
||||||
|
shortcut?: string | boolean;
|
||||||
|
ref?: Ref<HTMLInputElement>;
|
||||||
|
groupRef?: Ref<HTMLDivElement>;
|
||||||
|
/** Icon component to display on the left side of the input. */
|
||||||
|
icon?: ComponentType<HTMLAttributes<HTMLOrSVGElement>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InputBase = ({
|
||||||
|
ref,
|
||||||
|
tooltip,
|
||||||
|
shortcut,
|
||||||
|
groupRef,
|
||||||
|
size = "md",
|
||||||
|
isInvalid,
|
||||||
|
isDisabled,
|
||||||
|
isRequired,
|
||||||
|
icon: Icon,
|
||||||
|
placeholder,
|
||||||
|
wrapperClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
inputClassName,
|
||||||
|
iconClassName,
|
||||||
|
type = "text",
|
||||||
|
...inputProps
|
||||||
|
}: InputBaseProps) => {
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
// Check if the input has a leading icon or tooltip
|
||||||
|
const hasTrailingIcon = tooltip || isInvalid;
|
||||||
|
const hasLeadingIcon = Icon;
|
||||||
|
|
||||||
|
// If the input is inside a `TextFieldContext`, use its context to simplify applying styles
|
||||||
|
const context = useContext(TextFieldContext);
|
||||||
|
|
||||||
|
const inputSize = context?.size || size;
|
||||||
|
|
||||||
|
const sizes = sortCx({
|
||||||
|
sm: {
|
||||||
|
root: cx("px-3 py-2 text-sm", hasLeadingIcon && "pl-9", hasTrailingIcon && "pr-9"),
|
||||||
|
iconLeading: "left-3 size-4 stroke-[2.25px]",
|
||||||
|
iconTrailing: "right-3",
|
||||||
|
shortcut: "pr-1.5",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: cx("px-3 py-2 text-md", hasLeadingIcon && "pl-10", hasTrailingIcon && "pr-9"),
|
||||||
|
iconLeading: "left-3 size-5",
|
||||||
|
iconTrailing: "right-3",
|
||||||
|
shortcut: "pr-2",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: cx("px-3.5 py-2.5 text-md", hasLeadingIcon && "pl-10.5", hasTrailingIcon && "pr-9.5"),
|
||||||
|
iconLeading: "left-3.5 size-5",
|
||||||
|
iconTrailing: "right-3.5",
|
||||||
|
shortcut: "pr-2.5",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaGroup
|
||||||
|
{...{ isDisabled, isInvalid }}
|
||||||
|
ref={groupRef}
|
||||||
|
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||||
|
cx(
|
||||||
|
"group/input relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary transition-shadow duration-100 ease-linear ring-inset",
|
||||||
|
|
||||||
|
isFocusWithin && !isDisabled && "ring-2 ring-brand",
|
||||||
|
|
||||||
|
// Disabled state styles
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
"group-disabled:cursor-not-allowed group-disabled:opacity-50",
|
||||||
|
|
||||||
|
// Invalid state styles
|
||||||
|
isInvalid && "ring-error_subtle",
|
||||||
|
"group-invalid:ring-error_subtle",
|
||||||
|
|
||||||
|
// Invalid state with focus-within styles
|
||||||
|
isInvalid && isFocusWithin && "ring-2 ring-error",
|
||||||
|
isFocusWithin && "group-invalid:ring-2 group-invalid:ring-error",
|
||||||
|
|
||||||
|
context?.wrapperClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Leading icon and Payment icon */}
|
||||||
|
{Icon && (
|
||||||
|
<Icon className={cx("pointer-events-none absolute text-fg-quaternary", sizes[inputSize].iconLeading, context?.iconClassName, iconClassName)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input field */}
|
||||||
|
<AriaInput
|
||||||
|
{...(inputProps as AriaInputProps)}
|
||||||
|
ref={ref}
|
||||||
|
required={isRequired}
|
||||||
|
type={type === "password" && isPasswordVisible ? "text" : type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cx(
|
||||||
|
"m-0 w-full bg-transparent text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary disabled:cursor-not-allowed",
|
||||||
|
sizes[inputSize].root,
|
||||||
|
context?.inputClassName,
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tooltip and help icon */}
|
||||||
|
{tooltip && type !== "password" && (
|
||||||
|
<Tooltip title={tooltip} placement="top">
|
||||||
|
<TooltipTrigger
|
||||||
|
className={cx(
|
||||||
|
"absolute cursor-pointer text-fg-quaternary transition duration-100 ease-linear group-invalid/input:hidden hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover",
|
||||||
|
sizes[inputSize].iconTrailing,
|
||||||
|
context?.tooltipClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="size-4 stroke-[2.25px]" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invalid icon */}
|
||||||
|
{type !== "password" && (
|
||||||
|
<InfoCircle
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute hidden size-4 stroke-[2.25px] text-fg-error-secondary group-invalid/input:block",
|
||||||
|
sizes[inputSize].iconTrailing,
|
||||||
|
context?.tooltipClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password visibility toggle */}
|
||||||
|
{type === "password" && (
|
||||||
|
<AriaButton
|
||||||
|
aria-label="Toggle password visibility"
|
||||||
|
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||||
|
className={cx(
|
||||||
|
"absolute flex cursor-pointer items-center justify-center text-fg-quaternary transition duration-100 ease-linear hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover focus:outline-hidden",
|
||||||
|
sizes[inputSize].iconTrailing,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeOff className="size-4 stroke-[2.25px]" /> : <Eye className="size-4 stroke-[2.25px]" />}
|
||||||
|
</AriaButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shortcut */}
|
||||||
|
{shortcut && (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute inset-y-0.5 right-0.5 z-10 hidden items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8 md:flex",
|
||||||
|
sizes[inputSize].shortcut,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset"
|
||||||
|
>
|
||||||
|
{typeof shortcut === "string" ? shortcut : "⌘K"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
InputBase.displayName = "InputBase";
|
||||||
|
|
||||||
|
interface TextFieldContextProps extends Partial<Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName">> {}
|
||||||
|
|
||||||
|
const TextFieldContext = createContext<TextFieldContextProps>({});
|
||||||
|
|
||||||
|
export interface TextFieldProps extends AriaTextFieldProps, TextFieldContextProps {}
|
||||||
|
|
||||||
|
export const TextField = ({ className, size = "md", inputClassName, wrapperClassName, iconClassName, tooltipClassName, ...props }: TextFieldProps) => {
|
||||||
|
return (
|
||||||
|
<TextFieldContext.Provider value={{ inputClassName, wrapperClassName, iconClassName, tooltipClassName, size }}>
|
||||||
|
<AriaTextField
|
||||||
|
{...props}
|
||||||
|
data-input-wrapper
|
||||||
|
data-input-size={size}
|
||||||
|
className={(state) =>
|
||||||
|
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TextFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TextField.displayName = "TextField";
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends
|
||||||
|
AriaTextFieldProps,
|
||||||
|
Pick<
|
||||||
|
InputBaseProps,
|
||||||
|
| "ref"
|
||||||
|
| "placeholder"
|
||||||
|
| "icon"
|
||||||
|
| "shortcut"
|
||||||
|
| "tooltip"
|
||||||
|
| "groupRef"
|
||||||
|
| "size"
|
||||||
|
| "wrapperClassName"
|
||||||
|
| "inputClassName"
|
||||||
|
| "iconClassName"
|
||||||
|
| "tooltipClassName"
|
||||||
|
> {
|
||||||
|
/** Label text for the input */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the input */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Whether to hide required indicator from label */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = ({
|
||||||
|
size = "md",
|
||||||
|
placeholder,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
shortcut,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
className,
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
tooltip,
|
||||||
|
iconClassName,
|
||||||
|
inputClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
type = "text",
|
||||||
|
...props
|
||||||
|
}: InputProps) => {
|
||||||
|
return (
|
||||||
|
<TextField aria-label={!label ? placeholder : undefined} {...props} size={size} className={className}>
|
||||||
|
{({ isRequired, isInvalid }) => (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : isRequired} isInvalid={isInvalid}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputBase
|
||||||
|
{...{
|
||||||
|
ref,
|
||||||
|
groupRef,
|
||||||
|
size,
|
||||||
|
placeholder,
|
||||||
|
icon: Icon,
|
||||||
|
shortcut,
|
||||||
|
iconClassName,
|
||||||
|
inputClassName,
|
||||||
|
wrapperClassName,
|
||||||
|
tooltipClassName,
|
||||||
|
tooltip,
|
||||||
|
type,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import { HelpCircle } from "@untitledui/icons";
|
||||||
|
import type { LabelProps as AriaLabelProps } from "react-aria-components";
|
||||||
|
import { Label as AriaLabel } from "react-aria-components";
|
||||||
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface LabelProps extends AriaLabelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
isInvalid?: boolean;
|
||||||
|
isRequired?: boolean;
|
||||||
|
tooltip?: string;
|
||||||
|
tooltipDescription?: string;
|
||||||
|
ref?: Ref<HTMLLabelElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Label = ({ isInvalid, isRequired, tooltip, tooltipDescription, className, ...props }: LabelProps) => {
|
||||||
|
return (
|
||||||
|
<AriaLabel
|
||||||
|
// Used for conditionally hiding/showing the label element via CSS:
|
||||||
|
// <Input label="Visible only on mobile" className="lg:**:data-label:hidden" />
|
||||||
|
// or
|
||||||
|
// <Input label="Visible only on mobile" className="lg:label:hidden" />
|
||||||
|
data-label="true"
|
||||||
|
{...props}
|
||||||
|
className={cx("flex cursor-default items-center gap-0.5 text-sm font-medium text-secondary", className)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"hidden text-brand-tertiary",
|
||||||
|
isRequired && "block",
|
||||||
|
typeof isRequired === "undefined" && "group-required:block",
|
||||||
|
|
||||||
|
isInvalid && "text-error-primary",
|
||||||
|
typeof isInvalid === "undefined" && "group-invalid:text-error-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{tooltip && (
|
||||||
|
<Tooltip title={tooltip} description={tooltipDescription} placement="top">
|
||||||
|
<TooltipTrigger
|
||||||
|
// `TooltipTrigger` inherits the disabled state from the parent form field
|
||||||
|
// but we don't that. We want the tooltip be enabled even if the parent
|
||||||
|
// field is disabled.
|
||||||
|
isDisabled={false}
|
||||||
|
className="cursor-pointer text-fg-quaternary transition duration-200 hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover"
|
||||||
|
>
|
||||||
|
<HelpCircle className="size-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</AriaLabel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Label.displayName = "Label";
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { ComponentPropsWithRef } from "react";
|
||||||
|
import { createContext, useContext, useId } from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label as LabelBase } from "@/components/base/input/label";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
type PinInputSize = "xxxs" | "xxs" | "xs" | "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
type PinInputContextType = {
|
||||||
|
size: PinInputSize;
|
||||||
|
disabled: boolean;
|
||||||
|
id: string;
|
||||||
|
invalid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinInputContext = createContext<PinInputContextType>({
|
||||||
|
size: "sm",
|
||||||
|
id: "",
|
||||||
|
disabled: false,
|
||||||
|
invalid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePinInputContext = () => {
|
||||||
|
const context = useContext(PinInputContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("The 'usePinInputContext' hook must be used within a '<PinInput />'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RootProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
size?: PinInputSize;
|
||||||
|
disabled?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Root = ({ className, size = "md", disabled = false, invalid = false, ...props }: RootProps) => {
|
||||||
|
const id = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PinInputContext.Provider value={{ size, disabled, id, invalid }}>
|
||||||
|
<div role="group" className={cx("flex h-max flex-col gap-1.5", className)} {...props} />
|
||||||
|
</PinInputContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Root.displayName = "Root";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
xxxs: { group: "gap-1.5 h-9", slot: "size-9 px-3 py-2 text-sm rounded-lg font-medium text-placeholder/50", caret: "text-sm font-medium" },
|
||||||
|
xxs: { group: "gap-2 h-10", slot: "size-10 px-3 py-2 text-md rounded-lg font-medium text-placeholder/50", caret: "text-md font-medium" },
|
||||||
|
xs: { group: "gap-2 h-11", slot: "size-11 px-3.5 py-2.5 text-md rounded-lg font-medium text-placeholder/50", caret: "text-md font-medium" },
|
||||||
|
sm: { group: "gap-2 h-16.5", slot: "size-16 px-2 py-0.5 text-display-lg font-medium", caret: "text-display-lg font-medium" },
|
||||||
|
md: { group: "gap-3 h-20.5", slot: "size-20 px-2 py-2.5 text-display-lg font-medium", caret: "text-display-lg font-medium" },
|
||||||
|
lg: { group: "gap-3 h-24.5", slot: "size-24 px-2 py-3 text-display-xl font-medium", caret: "text-display-xl font-medium" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupProps = ComponentPropsWithRef<typeof OTPInput> & {
|
||||||
|
width?: number;
|
||||||
|
inputClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Group = ({ inputClassName, containerClassName, width, maxLength = 4, ...props }: GroupProps) => {
|
||||||
|
const { id, size, disabled } = usePinInputContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
{...props}
|
||||||
|
size={width}
|
||||||
|
maxLength={maxLength}
|
||||||
|
disabled={disabled}
|
||||||
|
id={"pin-input-" + id}
|
||||||
|
aria-label="Enter your pin"
|
||||||
|
aria-labelledby={"pin-input-label-" + id}
|
||||||
|
aria-describedby={"pin-input-description-" + id}
|
||||||
|
containerClassName={cx("flex flex-row", styles[size].group, containerClassName)}
|
||||||
|
className={cx("disabled:cursor-not-allowed", inputClassName)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Group.displayName = "Group";
|
||||||
|
|
||||||
|
const Slot = ({ index, className, ...props }: ComponentPropsWithRef<"div"> & { index: number }) => {
|
||||||
|
const { size, disabled, invalid } = usePinInputContext();
|
||||||
|
const { slots, isFocused } = useContext(OTPInputContext);
|
||||||
|
|
||||||
|
const slot = slots[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
aria-label={"Enter digit " + (index + 1) + " of " + slots.length}
|
||||||
|
className={cx(
|
||||||
|
"relative flex items-center justify-center rounded-xl bg-primary text-center text-placeholder/40 shadow-xs ring-1 ring-primary transition-[box-shadow,background-color] duration-100 ease-linear ring-inset",
|
||||||
|
styles[size].slot,
|
||||||
|
isFocused && slot?.isActive && "ring-2 ring-brand outline-2 outline-offset-2 outline-brand",
|
||||||
|
slot?.char && "text-brand-tertiary_alt ring-2 ring-brand",
|
||||||
|
disabled && "opacity-50",
|
||||||
|
invalid && "text-error-primary ring-error_subtle",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{slot?.char ? slot.char : slot?.hasFakeCaret ? <FakeCaret size={size} /> : 0}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Slot.displayName = "Slot";
|
||||||
|
|
||||||
|
const FakeCaret = ({ size = "md" }: { size?: PinInputSize }) => {
|
||||||
|
return <div className={cx("pointer-events-none h-[1em] w-0.5 animate-caret-blink bg-fg-brand-primary", styles[size].caret)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Separator = (props: ComponentPropsWithRef<"div">) => {
|
||||||
|
return (
|
||||||
|
<div role="separator" {...props} className={cx("text-center text-display-xl font-medium text-utility-neutral-300", props.className)}>
|
||||||
|
-
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Separator.displayName = "Separator";
|
||||||
|
|
||||||
|
const Label = (props: ComponentPropsWithRef<typeof LabelBase>) => {
|
||||||
|
const { id } = usePinInputContext();
|
||||||
|
|
||||||
|
return <LabelBase {...props} htmlFor={"pin-input-" + id} id={"pin-input-label-" + id} />;
|
||||||
|
};
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
const Description = (props: ComponentPropsWithRef<typeof HintText>) => {
|
||||||
|
const { id, size } = usePinInputContext();
|
||||||
|
|
||||||
|
return <HintText {...props} id={"pin-input-description-" + id} role="description" className={cx(size === "xxxs" && "text-xs")} />;
|
||||||
|
};
|
||||||
|
Description.displayName = "Description";
|
||||||
|
|
||||||
|
const PinInput = Root as typeof Root & {
|
||||||
|
Slot: typeof Slot;
|
||||||
|
Label: typeof Label;
|
||||||
|
Group: typeof Group;
|
||||||
|
Separator: typeof Separator;
|
||||||
|
Description: typeof Description;
|
||||||
|
};
|
||||||
|
PinInput.Slot = Slot;
|
||||||
|
PinInput.Label = Label;
|
||||||
|
PinInput.Group = Group;
|
||||||
|
PinInput.Separator = Separator;
|
||||||
|
PinInput.Description = Description;
|
||||||
|
|
||||||
|
export { PinInput };
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { cx as clx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
size: "xxs" | "xs" | "sm" | "md" | "lg";
|
||||||
|
label?: string;
|
||||||
|
valueFormatter?: (value: number, valueInPercentage: number) => string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = sortCx({
|
||||||
|
xxs: {
|
||||||
|
strokeWidth: 6,
|
||||||
|
radius: 29,
|
||||||
|
valueClass: "text-sm font-semibold text-primary",
|
||||||
|
labelClass: "text-xs font-medium text-tertiary",
|
||||||
|
halfCircleTextPosition: "absolute bottom-0.5 text-center",
|
||||||
|
},
|
||||||
|
xs: {
|
||||||
|
strokeWidth: 16,
|
||||||
|
radius: 72,
|
||||||
|
valueClass: "text-display-xs font-semibold text-primary",
|
||||||
|
labelClass: "text-xs font-medium text-tertiary",
|
||||||
|
halfCircleTextPosition: "absolute bottom-0.5 text-center",
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
strokeWidth: 20,
|
||||||
|
radius: 90,
|
||||||
|
valueClass: "text-display-sm font-semibold text-primary",
|
||||||
|
labelClass: "text-xs font-medium text-tertiary",
|
||||||
|
halfCircleTextPosition: "absolute bottom-2 text-center",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
strokeWidth: 24,
|
||||||
|
radius: 108,
|
||||||
|
valueClass: "text-display-md font-semibold text-primary",
|
||||||
|
labelClass: "text-sm font-medium text-tertiary",
|
||||||
|
halfCircleTextPosition: "absolute bottom-1 text-center",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
strokeWidth: 28,
|
||||||
|
radius: 126,
|
||||||
|
valueClass: "text-display-lg font-semibold text-primary",
|
||||||
|
labelClass: "text-sm font-medium text-tertiary",
|
||||||
|
halfCircleTextPosition: "absolute bottom-0 text-center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProgressBarCircle = ({ value, min = 0, max = 100, size, label, valueFormatter }: ProgressBarProps) => {
|
||||||
|
const percentage = Math.round(((value - min) * 100) / (max - min));
|
||||||
|
|
||||||
|
const sizeConfig = sizes[size];
|
||||||
|
|
||||||
|
const { strokeWidth, radius, valueClass, labelClass } = sizeConfig;
|
||||||
|
|
||||||
|
const diameter = 2 * (radius + strokeWidth / 2);
|
||||||
|
const width = diameter;
|
||||||
|
const height = diameter;
|
||||||
|
const viewBox = `0 0 ${width} ${height}`;
|
||||||
|
const cx = diameter / 2;
|
||||||
|
const cy = diameter / 2;
|
||||||
|
|
||||||
|
const textPosition = label ? "absolute text-center" : "absolute text-primary";
|
||||||
|
const strokeDashoffset = 100 - percentage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<div role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} className="relative flex w-max items-center justify-center">
|
||||||
|
<svg className="-rotate-90" width={width} height={height} viewBox={viewBox}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle
|
||||||
|
className="stroke-bg-quaternary"
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
pathLength="100"
|
||||||
|
strokeDasharray="100"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Foreground circle */}
|
||||||
|
<circle
|
||||||
|
className="stroke-fg-brand-primary"
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
pathLength="100"
|
||||||
|
strokeDasharray="100"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{label && size !== "xxs" ? (
|
||||||
|
<div className="absolute text-center">
|
||||||
|
<div className={labelClass}>{label}</div>
|
||||||
|
<div className={valueClass}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={clx(textPosition, valueClass)}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{label && size === "xxs" && <div className={labelClass}>{label}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProgressBarHalfCircle = ({ value, min = 0, max = 100, size, label, valueFormatter }: ProgressBarProps) => {
|
||||||
|
const percentage = Math.round(((value - min) * 100) / (max - min));
|
||||||
|
|
||||||
|
const sizeConfig = sizes[size];
|
||||||
|
|
||||||
|
const { strokeWidth, radius, valueClass, labelClass, halfCircleTextPosition } = sizeConfig;
|
||||||
|
|
||||||
|
const width = 2 * (radius + strokeWidth / 2);
|
||||||
|
const height = radius + strokeWidth;
|
||||||
|
const viewBox = `0 0 ${width} ${height}`;
|
||||||
|
const cx = "50%";
|
||||||
|
const cy = radius + strokeWidth / 2;
|
||||||
|
|
||||||
|
const strokeDashoffset = -50 - (100 - percentage) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<div role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} className="relative flex w-max items-center justify-center">
|
||||||
|
<svg width={width} height={height} viewBox={viewBox}>
|
||||||
|
{/* Background half-circle */}
|
||||||
|
<circle
|
||||||
|
className="stroke-bg-quaternary"
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
pathLength="100"
|
||||||
|
strokeDasharray="100"
|
||||||
|
strokeDashoffset="-50"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Foreground half-circle */}
|
||||||
|
<circle
|
||||||
|
className="origin-center -scale-x-100 stroke-fg-brand-primary"
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
pathLength="100"
|
||||||
|
strokeDasharray="100"
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{label && size !== "xxs" ? (
|
||||||
|
<div className={halfCircleTextPosition}>
|
||||||
|
<div className={labelClass}>{label}</div>
|
||||||
|
<div className={valueClass}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={clx(halfCircleTextPosition, valueClass)}>{valueFormatter ? valueFormatter(value, percentage) : `${percentage}%`}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{label && size === "xxs" && <div className={labelClass}>{label}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export interface ProgressBarProps {
|
||||||
|
/**
|
||||||
|
* The current value of the progress bar.
|
||||||
|
*/
|
||||||
|
value: number;
|
||||||
|
/**
|
||||||
|
* The minimum value of the progress bar.
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
min?: number;
|
||||||
|
/**
|
||||||
|
* The maximum value of the progress bar.
|
||||||
|
* @default 100
|
||||||
|
*/
|
||||||
|
max?: number;
|
||||||
|
/**
|
||||||
|
* Optional additional CSS class names for the progress bar container.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Optional additional CSS class names for the progress bar indicator element.
|
||||||
|
*/
|
||||||
|
progressClassName?: string;
|
||||||
|
/**
|
||||||
|
* Optional function to format the displayed value.
|
||||||
|
* It receives the raw value and the calculated percentage.
|
||||||
|
*/
|
||||||
|
valueFormatter?: (value: number, valueInPercentage: number) => string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic progress bar component.
|
||||||
|
*/
|
||||||
|
export const ProgressBarBase = ({ value, min = 0, max = 100, className, progressClassName }: ProgressBarProps) => {
|
||||||
|
const percentage = ((value - min) * 100) / (max - min);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
className={cx("h-2 w-full overflow-hidden rounded-md bg-quaternary", className)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
// Use transform instead of width to avoid layout thrashing (and for smoother animation)
|
||||||
|
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||||
|
className={cx("size-full rounded-md bg-fg-brand-primary transition duration-75 ease-linear", progressClassName)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressBarLabelPosition = "right" | "bottom" | "top-floating" | "bottom-floating";
|
||||||
|
|
||||||
|
export interface ProgressIndicatorWithTextProps extends ProgressBarProps {
|
||||||
|
/**
|
||||||
|
* Specifies the layout of the text relative to the progress bar.
|
||||||
|
* - `right`: Text is displayed to the right of the progress bar.
|
||||||
|
* - `bottom`: Text is displayed below the progress bar, aligned to the right.
|
||||||
|
* - `top-floating`: Text is displayed in a floating tooltip above the progress indicator.
|
||||||
|
* - `bottom-floating`: Text is displayed in a floating tooltip below the progress indicator.
|
||||||
|
*/
|
||||||
|
labelPosition?: ProgressBarLabelPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A progress bar component that displays the value text in various configurable layouts.
|
||||||
|
*/
|
||||||
|
export const ProgressBar = ({ value, min = 0, max = 100, valueFormatter, labelPosition, className, progressClassName }: ProgressIndicatorWithTextProps) => {
|
||||||
|
const percentage = ((value - min) * 100) / (max - min);
|
||||||
|
const formattedValue = valueFormatter ? valueFormatter(value, percentage) : `${percentage.toFixed(0)}%`; // Default to rounded percentage
|
||||||
|
|
||||||
|
const baseProgressBar = <ProgressBarBase min={min} max={max} value={value} className={className} progressClassName={progressClassName} />;
|
||||||
|
|
||||||
|
switch (labelPosition) {
|
||||||
|
case "right":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{baseProgressBar}
|
||||||
|
<span className="shrink-0 text-sm font-medium text-secondary tabular-nums">{formattedValue}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "bottom":
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{baseProgressBar}
|
||||||
|
<span className="text-sm font-medium text-secondary tabular-nums">{formattedValue}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "top-floating":
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-end gap-2">
|
||||||
|
{baseProgressBar}
|
||||||
|
<div
|
||||||
|
style={{ left: `${percentage}%` }}
|
||||||
|
className="absolute -top-2 -translate-x-1/2 -translate-y-full rounded-lg bg-primary_alt px-3 py-2 shadow-lg ring-1 ring-secondary_alt"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-secondary tabular-nums">{formattedValue}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "bottom-floating":
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-end gap-2">
|
||||||
|
{baseProgressBar}
|
||||||
|
<div
|
||||||
|
style={{ left: `${percentage}%` }}
|
||||||
|
className="absolute -bottom-2 -translate-x-1/2 translate-y-full rounded-lg bg-primary_alt px-3 py-2 shadow-lg ring-1 ring-secondary_alt"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-secondary">{formattedValue}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
// Fallback or default case, could render the basic progress bar or throw an error
|
||||||
|
return baseProgressBar;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const CircleProgressBar = (props: { value: number; min?: 0; max?: 100 }) => {
|
||||||
|
const { value, min = 0, max = 100 } = props;
|
||||||
|
const percentage = ((value - min) * 100) / (max - min);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} className="relative flex w-max items-center justify-center">
|
||||||
|
<span className="absolute text-sm font-medium text-primary">{percentage}%</span>
|
||||||
|
<svg className="size-16 -rotate-90" viewBox="0 0 60 60">
|
||||||
|
<circle className="stroke-bg-quaternary" cx="30" cy="30" r="26" fill="none" strokeWidth="6" />
|
||||||
|
<circle
|
||||||
|
className="stroke-fg-brand-primary"
|
||||||
|
style={{
|
||||||
|
strokeDashoffset: `calc(100 - ${percentage})`,
|
||||||
|
}}
|
||||||
|
cx="30"
|
||||||
|
cy="30"
|
||||||
|
r="26"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeDasharray="100"
|
||||||
|
pathLength="100"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { type ReactNode, type Ref, createContext, useContext } from "react";
|
||||||
|
import {
|
||||||
|
Radio as AriaRadio,
|
||||||
|
RadioGroup as AriaRadioGroup,
|
||||||
|
type RadioGroupProps as AriaRadioGroupProps,
|
||||||
|
type RadioProps as AriaRadioProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export interface RadioGroupContextType {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioGroupContext = createContext<RadioGroupContextType | null>(null);
|
||||||
|
|
||||||
|
export interface RadioButtonBaseProps {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
isFocusVisible?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioButtonBase = ({ className, isFocusVisible, isSelected, isDisabled, size = "sm" }: RadioButtonBaseProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex size-4 shrink-0 cursor-pointer appearance-none items-center justify-center rounded-full bg-primary ring-1 ring-primary ring-inset",
|
||||||
|
size === "md" && "size-5",
|
||||||
|
isSelected && "bg-brand-solid ring-brand-solid",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isDisabled && !isSelected && "bg-tertiary",
|
||||||
|
isFocusVisible && "outline-2 outline-offset-2 outline-focus-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cx("size-1.5 rounded-full bg-fg-white opacity-0 transition-inherit-all", size === "md" && "size-2", isSelected && "opacity-100")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
RadioButtonBase.displayName = "RadioButtonBase";
|
||||||
|
|
||||||
|
interface RadioButtonProps extends AriaRadioProps {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
label?: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
ref?: Ref<HTMLLabelElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioButton = ({ label, hint, className, size = "sm", ...ariaRadioProps }: RadioButtonProps) => {
|
||||||
|
const context = useContext(RadioGroupContext);
|
||||||
|
|
||||||
|
size = context?.size ?? size;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: {
|
||||||
|
root: "gap-2",
|
||||||
|
textWrapper: "",
|
||||||
|
label: "text-sm font-medium",
|
||||||
|
hint: "text-sm",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "gap-3",
|
||||||
|
textWrapper: "gap-0.5",
|
||||||
|
label: "text-md font-medium",
|
||||||
|
hint: "text-md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaRadio
|
||||||
|
{...ariaRadioProps}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative flex items-start",
|
||||||
|
state.isDisabled && "cursor-not-allowed",
|
||||||
|
sizes[size].root,
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSelected, isDisabled, isFocusVisible }) => (
|
||||||
|
<>
|
||||||
|
<RadioButtonBase
|
||||||
|
size={size}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isFocusVisible={isFocusVisible}
|
||||||
|
className={label || hint ? "mt-0.5" : ""}
|
||||||
|
/>
|
||||||
|
{(label || hint) && (
|
||||||
|
<div className={cx("inline-flex flex-col", sizes[size].textWrapper)}>
|
||||||
|
{label && <p className={cx("text-secondary select-none", sizes[size].label)}>{label}</p>}
|
||||||
|
{hint && (
|
||||||
|
<span className={cx("text-tertiary", sizes[size].hint)} onClick={(event) => event.stopPropagation()}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaRadio>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
RadioButton.displayName = "RadioButton";
|
||||||
|
|
||||||
|
interface RadioGroupProps extends RadioGroupContextType, AriaRadioGroupProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioGroup = ({ children, className, size = "sm", ...props }: RadioGroupProps) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupContext.Provider value={{ size }}>
|
||||||
|
<AriaRadioGroup {...props} className={cx("flex flex-col gap-4", className)}>
|
||||||
|
{children}
|
||||||
|
</AriaRadioGroup>
|
||||||
|
</RadioGroupContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { FC, FocusEventHandler, PointerEventHandler, ReactNode, RefAttributes, RefObject } from "react";
|
||||||
|
import { isValidElement, useCallback, useContext, useRef, useState } from "react";
|
||||||
|
import { SearchLg } from "@untitledui/icons";
|
||||||
|
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
|
||||||
|
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { Popover } from "@/components/base/select/popover";
|
||||||
|
import { type CommonProps, SelectContext, type SelectItemType, sizes } from "@/components/base/select/select-shared";
|
||||||
|
import { useResizeObserver } from "@/hooks/use-resize-observer";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
interface ComboBoxProps extends Omit<AriaComboBoxProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
|
||||||
|
shortcut?: boolean;
|
||||||
|
items?: SelectItemType[];
|
||||||
|
popoverClassName?: string;
|
||||||
|
shortcutClassName?: string;
|
||||||
|
/** Leading icon component displayed before the input. */
|
||||||
|
icon?: FC | ReactNode;
|
||||||
|
children: AriaListBoxProps<SelectItemType>["children"];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComboBoxValueProps extends AriaGroupProps {
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
shortcut: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
shortcutClassName?: string;
|
||||||
|
icon?: FC | ReactNode;
|
||||||
|
onFocus?: FocusEventHandler;
|
||||||
|
onPointerEnter?: PointerEventHandler;
|
||||||
|
ref?: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComboBoxValue = ({ size, shortcut, placeholder, shortcutClassName, icon: IconProp, ...otherProps }: ComboBoxValueProps) => {
|
||||||
|
const state = useContext(ComboBoxStateContext);
|
||||||
|
|
||||||
|
const value = state?.selectedItem?.value || null;
|
||||||
|
const inputValue = state?.inputValue || null;
|
||||||
|
|
||||||
|
const first = inputValue?.split(value?.supportingText)?.[0] || "";
|
||||||
|
const last = inputValue?.split(first)[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaGroup
|
||||||
|
{...otherProps}
|
||||||
|
className={({ isFocusWithin, isDisabled }) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition-shadow duration-100 ease-linear ring-inset",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isFocusWithin && "ring-2 ring-brand",
|
||||||
|
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:shrink-0 *:data-icon:text-fg-quaternary",
|
||||||
|
|
||||||
|
sizes[size].root,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isReactComponent(IconProp) ? (
|
||||||
|
<IconProp data-icon className="pointer-events-none" aria-hidden="true" />
|
||||||
|
) : isValidElement(IconProp) ? (
|
||||||
|
IconProp
|
||||||
|
) : (
|
||||||
|
<SearchLg data-icon className="pointer-events-none" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex w-full items-center">
|
||||||
|
{inputValue && (
|
||||||
|
<span className={cx("absolute top-1/2 z-0 inline-flex w-full -translate-y-1/2 truncate", sizes[size].textContainer)} aria-hidden="true">
|
||||||
|
<p className={cx("font-medium text-primary", sizes[size].text)}>{first}</p>
|
||||||
|
{last && <p className={cx("-ml-0.75 text-tertiary", sizes[size].text)}>{last}</p>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AriaInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cx(
|
||||||
|
"z-10 w-full appearance-none bg-transparent text-transparent caret-alpha-black/90 placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed",
|
||||||
|
sizes[size].text,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shortcut && (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"absolute inset-y-0.5 right-0.5 z-10 hidden items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8 md:flex",
|
||||||
|
sizes[size].shortcut,
|
||||||
|
shortcutClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComboBox = ({
|
||||||
|
placeholder = "Search",
|
||||||
|
shortcut = true,
|
||||||
|
size = "md",
|
||||||
|
children,
|
||||||
|
items,
|
||||||
|
shortcutClassName,
|
||||||
|
icon,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
...otherProps
|
||||||
|
}: ComboBoxProps) => {
|
||||||
|
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState("");
|
||||||
|
|
||||||
|
// Resize observer for popover width
|
||||||
|
const onResize = useCallback(() => {
|
||||||
|
if (!placeholderRef.current) return;
|
||||||
|
|
||||||
|
const divRect = placeholderRef.current?.getBoundingClientRect();
|
||||||
|
|
||||||
|
setPopoverWidth(divRect.width + "px");
|
||||||
|
}, [placeholderRef, setPopoverWidth]);
|
||||||
|
|
||||||
|
useResizeObserver({
|
||||||
|
ref: placeholderRef,
|
||||||
|
box: "border-box",
|
||||||
|
onResize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectContext.Provider value={{ size }}>
|
||||||
|
<AriaComboBox menuTrigger="focus" {...otherProps}>
|
||||||
|
{(state) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{otherProps.label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? false : state.isRequired} tooltip={otherProps.tooltip}>
|
||||||
|
{otherProps.label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ComboBoxValue
|
||||||
|
ref={placeholderRef}
|
||||||
|
placeholder={placeholder}
|
||||||
|
shortcut={shortcut}
|
||||||
|
shortcutClassName={shortcutClassName}
|
||||||
|
icon={icon}
|
||||||
|
size={size}
|
||||||
|
// This is a workaround to correctly calculating the trigger width
|
||||||
|
// while using ResizeObserver wasn't 100% reliable.
|
||||||
|
onFocus={onResize}
|
||||||
|
onPointerEnter={onResize}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover size={size} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={otherProps.popoverClassName}>
|
||||||
|
<AriaListBox items={items} className="size-full outline-hidden">
|
||||||
|
{children}
|
||||||
|
</AriaListBox>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{otherProps.hint && (
|
||||||
|
<HintText isInvalid={state.isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{otherProps.hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaComboBox>
|
||||||
|
</SelectContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import type { ReactNode, RefAttributes } from "react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { ChevronDown, SearchLg } from "@untitledui/icons";
|
||||||
|
import { useFilter } from "react-aria";
|
||||||
|
import type { Selection } from "react-aria-components";
|
||||||
|
import {
|
||||||
|
Autocomplete as AriaAutocomplete,
|
||||||
|
Button as AriaButton,
|
||||||
|
Dialog as AriaDialog,
|
||||||
|
DialogTrigger as AriaDialogTrigger,
|
||||||
|
Input as AriaInput,
|
||||||
|
ListBox as AriaListBox,
|
||||||
|
Popover as AriaPopover,
|
||||||
|
SearchField as AriaSearchField,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { SelectItem } from "./select-item";
|
||||||
|
import { type CommonProps, SelectContext, type SelectItemType, sizes } from "./select-shared";
|
||||||
|
|
||||||
|
const searchSizes = {
|
||||||
|
sm: { wrapper: "py-1", root: "px-3 py-2 gap-2 *:data-icon:size-4 *:data-icon:stroke-[2.25px]", text: "text-sm" },
|
||||||
|
md: { wrapper: "py-0.5", root: "px-3 py-2 gap-2 *:data-icon:size-5", text: "text-md" },
|
||||||
|
lg: { wrapper: "py-0.5", root: "px-3.5 py-2.5 gap-2 *:data-icon:size-5", text: "text-md" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerButtonSize = {
|
||||||
|
sm: "xs" as const,
|
||||||
|
md: "sm" as const,
|
||||||
|
lg: "sm" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const popoverMaxHeights = {
|
||||||
|
sm: "max-h-68",
|
||||||
|
md: "max-h-76",
|
||||||
|
lg: "max-h-92",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MultiSelectFooterProps {
|
||||||
|
/**
|
||||||
|
* The size of the footer buttons.
|
||||||
|
* @default "sm"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Handler that is called when the reset button is clicked. */
|
||||||
|
onReset?: () => void;
|
||||||
|
/** Handler that is called when the select all button is clicked. */
|
||||||
|
onSelectAll?: () => void;
|
||||||
|
/** Additional class name. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectFooter = ({ size = "sm", onReset, onSelectAll, className }: MultiSelectFooterProps) => {
|
||||||
|
const btnSize = footerButtonSize[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("flex items-center justify-between border-t border-secondary p-3", className)}>
|
||||||
|
<Button size={btnSize} color="secondary" onClick={onReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button size={btnSize} color="secondary" onClick={onSelectAll}>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MultiSelectEmptyStateProps {
|
||||||
|
/**
|
||||||
|
* The title to display.
|
||||||
|
* @default "No results found"
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* The description to display.
|
||||||
|
* @default "Please try a different search term."
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
/** Handler that is called when the clear search button is clicked. */
|
||||||
|
onClearSearch?: () => void;
|
||||||
|
/** Additional class name. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectEmptyState = ({
|
||||||
|
title = "No results found",
|
||||||
|
description = "Please try a different search term.",
|
||||||
|
onClearSearch,
|
||||||
|
className,
|
||||||
|
}: MultiSelectEmptyStateProps) => (
|
||||||
|
<div className={cx("flex flex-col items-center gap-3 px-4 py-4", className)}>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<FeaturedIcon icon={SearchLg} size="sm" color="gray" theme="modern" />
|
||||||
|
<div className="flex flex-col items-center gap-0.5 text-center text-sm">
|
||||||
|
<p className="font-semibold text-primary">{title}</p>
|
||||||
|
<p className="text-tertiary">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onClearSearch && (
|
||||||
|
<Button size="sm" color="link-color" onClick={onClearSearch}>
|
||||||
|
Clear search
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface MultiSelectProps extends RefAttributes<HTMLDivElement>, CommonProps {
|
||||||
|
/** The items to display in the listbox. */
|
||||||
|
items?: SelectItemType[];
|
||||||
|
/** The children to render for each item. */
|
||||||
|
children: ReactNode | ((item: SelectItemType) => ReactNode);
|
||||||
|
/** The currently selected keys (controlled). */
|
||||||
|
selectedKeys?: Selection;
|
||||||
|
/** The initial selected keys (uncontrolled). */
|
||||||
|
defaultSelectedKeys?: Selection;
|
||||||
|
/** Handler that is called when the selection changes. */
|
||||||
|
onSelectionChange?: (keys: Selection) => void;
|
||||||
|
/** Whether the select is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Whether the select is required. */
|
||||||
|
isRequired?: boolean;
|
||||||
|
/** Whether the select is in an invalid state. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/** Additional class name for the popover. */
|
||||||
|
popoverClassName?: string;
|
||||||
|
/** Additional class name for the root element. */
|
||||||
|
className?: string;
|
||||||
|
/** Handler that is called when the reset button is clicked. */
|
||||||
|
onReset?: () => void;
|
||||||
|
/** Handler that is called when the select all button is clicked. */
|
||||||
|
onSelectAll?: () => void;
|
||||||
|
/**
|
||||||
|
* Whether to show the footer with reset and select all buttons.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showFooter?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to show the search input in the popover.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showSearch?: boolean;
|
||||||
|
/** The title to display when no items match the search. */
|
||||||
|
emptyStateTitle?: string;
|
||||||
|
/** The description to display when no items match the search. */
|
||||||
|
emptyStateDescription?: string;
|
||||||
|
/** Custom formatter for the selected count text in the trigger. */
|
||||||
|
selectedCountFormatter?: (count: number) => ReactNode;
|
||||||
|
/** Supporting text displayed next to the selected count in the trigger. */
|
||||||
|
supportingText?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectRoot = ({
|
||||||
|
items,
|
||||||
|
children,
|
||||||
|
size = "md",
|
||||||
|
selectedKeys,
|
||||||
|
defaultSelectedKeys,
|
||||||
|
onSelectionChange,
|
||||||
|
isDisabled,
|
||||||
|
isRequired,
|
||||||
|
isInvalid,
|
||||||
|
placeholder = "Select",
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
tooltip,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
popoverClassName,
|
||||||
|
className,
|
||||||
|
onReset,
|
||||||
|
onSelectAll,
|
||||||
|
showFooter = true,
|
||||||
|
showSearch = true,
|
||||||
|
emptyStateTitle,
|
||||||
|
emptyStateDescription,
|
||||||
|
selectedCountFormatter,
|
||||||
|
supportingText,
|
||||||
|
}: MultiSelectProps) => {
|
||||||
|
const { contains } = useFilter({ sensitivity: "base" });
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState("");
|
||||||
|
|
||||||
|
const onResize = useCallback(() => {
|
||||||
|
if (!triggerRef.current) return;
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setPopoverWidth(rect.width + "px");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCount = selectedKeys instanceof Set ? selectedKeys.size : selectedKeys === "all" ? (items?.length ?? 0) : 0;
|
||||||
|
const hasSelection = selectedCount > 0;
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchValue("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectContext.Provider value={{ size }}>
|
||||||
|
<div className={cx("flex flex-col gap-1.5", className)}>
|
||||||
|
{label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? false : isRequired} isInvalid={isInvalid} tooltip={tooltip}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AriaDialogTrigger>
|
||||||
|
<AriaButton
|
||||||
|
ref={triggerRef}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onClick={onResize}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
|
||||||
|
(state.isFocusVisible || state.isPressed) && "ring-2 ring-brand",
|
||||||
|
state.isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"flex w-full items-center truncate text-left",
|
||||||
|
sizes[size].root,
|
||||||
|
"*:data-icon:shrink-0 *:data-icon:text-fg-quaternary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSelection ? (
|
||||||
|
<span className={cx("flex items-center", sizes[size].textContainer)}>
|
||||||
|
<span className={cx("font-medium text-primary", sizes[size].text)}>
|
||||||
|
{selectedCountFormatter ? selectedCountFormatter(selectedCount) : `${selectedCount} selected`}
|
||||||
|
</span>
|
||||||
|
{supportingText && <span className={cx("text-tertiary", sizes[size].text)}>{supportingText}</span>}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cx("text-placeholder", sizes[size].text)}>{placeholder}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx("ml-auto shrink-0 text-fg-quaternary", size === "lg" ? "size-5" : "size-4 stroke-[2.25px]")}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</AriaButton>
|
||||||
|
|
||||||
|
<AriaPopover
|
||||||
|
placement="bottom"
|
||||||
|
offset={4}
|
||||||
|
containerPadding={0}
|
||||||
|
style={{ width: popoverWidth || undefined }}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"w-(--trigger-width) origin-(--trigger-anchor-point) overflow-hidden rounded-lg bg-primary shadow-lg ring-1 ring-secondary_alt outline-hidden will-change-transform",
|
||||||
|
state.isEntering &&
|
||||||
|
"duration-150 ease-out animate-in fade-in placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
|
||||||
|
state.isExiting &&
|
||||||
|
"duration-100 ease-in animate-out fade-out placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
|
||||||
|
popoverClassName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AriaDialog className="outline-hidden">
|
||||||
|
<AriaAutocomplete filter={contains} inputValue={searchValue} onInputChange={setSearchValue}>
|
||||||
|
{showSearch && (
|
||||||
|
<div className={cx("border-b border-secondary", searchSizes[size].wrapper)}>
|
||||||
|
<AriaSearchField aria-label="Search" value={searchValue} onChange={setSearchValue} autoFocus>
|
||||||
|
<div className={cx("flex items-center", searchSizes[size].root)}>
|
||||||
|
<SearchLg data-icon aria-hidden="true" className="shrink-0 text-fg-quaternary" />
|
||||||
|
<AriaInput
|
||||||
|
placeholder="Search"
|
||||||
|
className={cx(
|
||||||
|
"w-full appearance-none bg-transparent text-primary caret-alpha-black/90 outline-hidden placeholder:text-placeholder",
|
||||||
|
searchSizes[size].text,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AriaSearchField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AriaListBox
|
||||||
|
aria-label={label || "Options"}
|
||||||
|
items={items}
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
defaultSelectedKeys={defaultSelectedKeys}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
renderEmptyState={() => (
|
||||||
|
<MultiSelectEmptyState
|
||||||
|
title={emptyStateTitle}
|
||||||
|
description={emptyStateDescription}
|
||||||
|
onClearSearch={searchValue ? handleClearSearch : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
className={cx("overflow-y-auto py-1 outline-hidden", popoverMaxHeights[size])}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AriaListBox>
|
||||||
|
</AriaAutocomplete>
|
||||||
|
|
||||||
|
{showFooter && <MultiSelectFooter size={size} onReset={onReset} onSelectAll={onSelectAll} />}
|
||||||
|
</AriaDialog>
|
||||||
|
</AriaPopover>
|
||||||
|
</AriaDialogTrigger>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiSelect = MultiSelectRoot as typeof MultiSelectRoot & {
|
||||||
|
Item: typeof SelectItem;
|
||||||
|
Footer: typeof MultiSelectFooter;
|
||||||
|
EmptyState: typeof MultiSelectEmptyState;
|
||||||
|
};
|
||||||
|
|
||||||
|
MultiSelect.Item = SelectItem;
|
||||||
|
MultiSelect.Footer = MultiSelectFooter;
|
||||||
|
MultiSelect.EmptyState = MultiSelectEmptyState;
|
||||||
|
|
||||||
|
export { MultiSelect };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { RefAttributes } from "react";
|
||||||
|
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
|
||||||
|
import { Popover as AriaPopover } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Popover = (props: PopoverProps) => {
|
||||||
|
return (
|
||||||
|
<AriaPopover
|
||||||
|
placement="bottom"
|
||||||
|
containerPadding={0}
|
||||||
|
offset={4}
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"w-(--trigger-width) origin-(--trigger-anchor-point) overflow-x-hidden overflow-y-auto rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary_alt outline-hidden will-change-transform",
|
||||||
|
|
||||||
|
state.isEntering &&
|
||||||
|
"duration-150 ease-out animate-in fade-in placement-right:slide-in-from-left-0.5 placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
|
||||||
|
state.isExiting &&
|
||||||
|
"duration-100 ease-in animate-out fade-out placement-right:slide-out-to-left-0.5 placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
|
||||||
|
|
||||||
|
props.size === "sm" && "max-h-56!",
|
||||||
|
props.size === "md" && "max-h-64!",
|
||||||
|
props.size === "lg" && "max-h-80!",
|
||||||
|
|
||||||
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { isValidElement, useContext } from "react";
|
||||||
|
import { Check } from "@untitledui/icons";
|
||||||
|
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
|
||||||
|
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { CheckboxBase } from "@/components/base/checkbox/checkbox";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
import type { SelectItemType } from "./select-shared";
|
||||||
|
import { SelectContext } from "./select-shared";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: {
|
||||||
|
root: "p-2 pr-2.5 gap-2 *:data-icon:size-4 *:data-icon:stroke-[2.25px]",
|
||||||
|
text: "text-sm",
|
||||||
|
textContainer: "gap-x-1.5",
|
||||||
|
check: "size-4 stroke-[2.25px]",
|
||||||
|
checkbox: "sm" as const,
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "p-2 pr-2.5 gap-2 *:data-icon:size-5",
|
||||||
|
text: "text-md",
|
||||||
|
textContainer: "gap-x-2",
|
||||||
|
check: "size-5",
|
||||||
|
checkbox: "sm" as const,
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "p-2.5 pl-2 gap-2 *:data-icon:size-5",
|
||||||
|
text: "text-md",
|
||||||
|
textContainer: "gap-x-2",
|
||||||
|
check: "size-5",
|
||||||
|
checkbox: "md" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SelectItemProps extends Omit<AriaListBoxItemProps<SelectItemType>, "id">, SelectItemType {
|
||||||
|
/** The selection indicator to be displayed on the item. */
|
||||||
|
selectionIndicator?: "checkmark" | "checkbox" | "none";
|
||||||
|
/** The alignment of the selection indicator. */
|
||||||
|
selectionIndicatorAlign?: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectItem = ({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
avatarUrl,
|
||||||
|
supportingText,
|
||||||
|
isDisabled,
|
||||||
|
icon: Icon,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
selectionIndicator = "checkmark",
|
||||||
|
selectionIndicatorAlign = "right",
|
||||||
|
...props
|
||||||
|
}: SelectItemProps) => {
|
||||||
|
const { size } = useContext(SelectContext);
|
||||||
|
|
||||||
|
const labelOrChildren = label || (typeof children === "string" ? children : "");
|
||||||
|
const textValue = supportingText ? labelOrChildren + " " + supportingText : labelOrChildren;
|
||||||
|
|
||||||
|
const isLeft = selectionIndicatorAlign === "left";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaListBoxItem
|
||||||
|
id={id}
|
||||||
|
value={
|
||||||
|
value ?? {
|
||||||
|
id,
|
||||||
|
label: labelOrChildren,
|
||||||
|
avatarUrl,
|
||||||
|
supportingText,
|
||||||
|
isDisabled,
|
||||||
|
icon: Icon,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textValue={textValue}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx("w-full py-px outline-hidden", size === "sm" ? "px-1" : "px-1.5", typeof className === "function" ? className(state) : className)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex cursor-pointer items-center rounded-md outline-hidden select-none",
|
||||||
|
(state.isFocused || state.isHovered || (state.isSelected && selectionIndicator !== "checkbox")) && "bg-primary_hover",
|
||||||
|
state.isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
state.isFocusVisible && "ring-2 ring-focus-ring ring-inset",
|
||||||
|
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:shrink-0 *:data-icon:text-fg-quaternary",
|
||||||
|
|
||||||
|
sizes[size].root,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLeft && selectionIndicator === "checkbox" && (
|
||||||
|
<CheckboxBase size={sizes[size].checkbox} isSelected={state.isSelected} isDisabled={state.isDisabled} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avatarUrl ? (
|
||||||
|
<Avatar aria-hidden="true" size="xs" src={avatarUrl} alt={label} className={cx(size === "sm" && "size-5")} />
|
||||||
|
) : isReactComponent(Icon) ? (
|
||||||
|
<Icon data-icon aria-hidden="true" />
|
||||||
|
) : isValidElement(Icon) ? (
|
||||||
|
Icon
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={cx("flex w-full min-w-0 flex-1 flex-wrap", sizes[size].textContainer)}>
|
||||||
|
<AriaText slot="label" className={cx("truncate font-medium whitespace-nowrap text-primary", sizes[size].text)}>
|
||||||
|
{label || (typeof children === "function" ? children(state) : children)}
|
||||||
|
</AriaText>
|
||||||
|
|
||||||
|
{supportingText && (
|
||||||
|
<AriaText slot="description" className={cx("whitespace-nowrap text-tertiary", sizes[size].text)}>
|
||||||
|
{supportingText}
|
||||||
|
</AriaText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.isSelected && selectionIndicator === "checkmark" && (
|
||||||
|
<Check aria-hidden="true" className={cx("ml-auto text-fg-brand-primary", sizes[size].check)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLeft && selectionIndicator === "checkbox" && (
|
||||||
|
<CheckboxBase size={sizes[size].checkbox} isSelected={state.isSelected} isDisabled={state.isDisabled} className="ml-auto" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaListBoxItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { type SelectHTMLAttributes, useId } from "react";
|
||||||
|
import { ChevronDown } from "@untitledui/icons";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface NativeSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
selectClassName?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
options: { label: string; value: string; disabled?: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: {
|
||||||
|
root: "py-2 pl-3 text-sm",
|
||||||
|
icon: "size-4 right-2.5 stroke-[2.25px]",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "py-2 pl-3 text-md",
|
||||||
|
icon: "size-4 stroke-[2.25px] right-3",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: "py-2.5 px-3.5 text-md",
|
||||||
|
icon: "size-5 right-3",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NativeSelect = ({ label, hint, options, className, selectClassName, size = "md", ...props }: NativeSelectProps) => {
|
||||||
|
const id = useId();
|
||||||
|
const selectId = `select-native-${id}`;
|
||||||
|
const hintId = `select-native-hint-${id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx("w-full in-data-input-wrapper:w-max", className)}>
|
||||||
|
{label && (
|
||||||
|
<Label htmlFor={selectId} id={selectId} className="mb-1.5">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative grid w-full items-center">
|
||||||
|
<select
|
||||||
|
{...props}
|
||||||
|
id={selectId}
|
||||||
|
aria-describedby={hintId}
|
||||||
|
aria-labelledby={selectId}
|
||||||
|
className={cx(
|
||||||
|
"appearance-none rounded-lg bg-primary font-medium text-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset placeholder:text-fg-quaternary focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
|
||||||
|
styles[size].root,
|
||||||
|
|
||||||
|
// Styles when the select is within an `InputGroup`
|
||||||
|
"in-data-input-wrapper:flex in-data-input-wrapper:h-full in-data-input-wrapper:gap-1 in-data-input-wrapper:bg-inherit in-data-input-wrapper:px-3 in-data-input-wrapper:py-2 in-data-input-wrapper:font-normal in-data-input-wrapper:text-tertiary in-data-input-wrapper:shadow-none in-data-input-wrapper:ring-transparent in-data-input-wrapper:in-data-[size=sm]:text-sm",
|
||||||
|
// Styles for the select when `TextField` is disabled
|
||||||
|
"in-data-input-wrapper:group-disabled:pointer-events-none in-data-input-wrapper:group-disabled:cursor-not-allowed in-data-input-wrapper:group-disabled:bg-transparent",
|
||||||
|
// Common styles for sizes and border radius within `InputGroup`
|
||||||
|
"in-data-input-wrapper:in-data-leading:rounded-r-none in-data-input-wrapper:in-data-trailing:rounded-l-none in-data-input-wrapper:in-data-[input-size=lg]:py-2.5 in-data-input-wrapper:in-data-[input-size=md]:py-2 in-data-input-wrapper:in-data-[input-size=md]:pl-3 in-data-input-wrapper:in-data-[input-size=sm]:text-sm",
|
||||||
|
// For "leading" dropdown within `InputGroup`
|
||||||
|
"in-data-input-wrapper:in-data-leading:pr-4.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=lg]:pl-3.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pr-4.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pl-3 in-data-input-wrapper:in-data-leading:in-data-[input-size=sm]:pr-3.5",
|
||||||
|
// For "trailing" dropdown within `InputGroup`
|
||||||
|
"in-data-input-wrapper:in-data-trailing:in-data-[input-size=lg]:pr-8 in-data-input-wrapper:in-data-trailing:in-data-[input-size=md]:pr-7.5 in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:pr-6.5",
|
||||||
|
selectClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute text-fg-quaternary",
|
||||||
|
|
||||||
|
styles[size].icon,
|
||||||
|
// Styles for the icon when the select is within an `InputGroup`
|
||||||
|
"in-data-input-wrapper:right-0 in-data-input-wrapper:size-4 in-data-input-wrapper:stroke-[2.625px]",
|
||||||
|
// For "trailing" dropdown within `InputGroup`
|
||||||
|
"in-data-input-wrapper:in-data-trailing:in-data-[input-size=md]:right-3 in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:right-3",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText className="mt-2" id={hintId}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export type SelectItemType = {
|
||||||
|
/** Unique identifier for the item. */
|
||||||
|
id: string | number;
|
||||||
|
/** The primary display text. */
|
||||||
|
label?: string;
|
||||||
|
/** Avatar image URL. */
|
||||||
|
avatarUrl?: string;
|
||||||
|
/** Whether the item is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Secondary text displayed alongside the label. */
|
||||||
|
supportingText?: string;
|
||||||
|
/** Leading icon component or element. */
|
||||||
|
icon?: FC | ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CommonProps {
|
||||||
|
/** Helper text displayed below the input. */
|
||||||
|
hint?: string;
|
||||||
|
/** Field label displayed above the input. */
|
||||||
|
label?: string;
|
||||||
|
/** Tooltip text for the help icon next to the label. */
|
||||||
|
tooltip?: string;
|
||||||
|
/**
|
||||||
|
* The size of the component.
|
||||||
|
* @default "md"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text when no value is selected. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether to hide the required indicator from the label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sizes = {
|
||||||
|
sm: {
|
||||||
|
root: "py-2 pl-3 pr-2.5 gap-2 *:data-icon:size-4 *:data-icon:stroke-[2.25px]",
|
||||||
|
withIcon: "",
|
||||||
|
text: "text-sm",
|
||||||
|
textContainer: "gap-x-1.5",
|
||||||
|
shortcut: "pr-2.5",
|
||||||
|
},
|
||||||
|
md: { root: "py-2 px-3 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-2.5" },
|
||||||
|
lg: { root: "py-2.5 px-3.5 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-3" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectContext = createContext<{ size: "sm" | "md" | "lg" }>({ size: "md" });
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { FC, ReactNode, Ref, RefAttributes } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { ChevronDown } from "@untitledui/icons";
|
||||||
|
import type { SelectProps as AriaSelectProps } from "react-aria-components";
|
||||||
|
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
import { ComboBox } from "./combobox";
|
||||||
|
import { Popover } from "./popover";
|
||||||
|
import { SelectItem } from "./select-item";
|
||||||
|
import { type CommonProps, SelectContext, type SelectItemType, sizes } from "./select-shared";
|
||||||
|
|
||||||
|
export { SelectContext, sizes, type CommonProps, type SelectItemType } from "./select-shared";
|
||||||
|
|
||||||
|
export interface SelectProps extends Omit<AriaSelectProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
|
||||||
|
items?: SelectItemType[];
|
||||||
|
popoverClassName?: string;
|
||||||
|
icon?: FC | ReactNode;
|
||||||
|
children: ReactNode | ((item: SelectItemType) => ReactNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectValueProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
isFocused: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
ref?: Ref<HTMLButtonElement>;
|
||||||
|
icon?: FC | ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, icon, ref }: SelectValueProps) => {
|
||||||
|
return (
|
||||||
|
<AriaButton
|
||||||
|
ref={ref}
|
||||||
|
className={cx(
|
||||||
|
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
|
||||||
|
(isFocused || isOpen) && "ring-2 ring-brand",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AriaSelectValue<SelectItemType>
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"flex h-max w-full items-center justify-start truncate text-left align-middle",
|
||||||
|
|
||||||
|
sizes[size].root,
|
||||||
|
|
||||||
|
// With icon
|
||||||
|
(state.selectedItems[0]?.icon || icon) && sizes[size].withIcon,
|
||||||
|
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:shrink-0 *:data-icon:text-fg-quaternary",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(state) => {
|
||||||
|
const selectedItem = state.selectedItems[0];
|
||||||
|
const Icon = selectedItem?.icon || icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedItem?.avatarUrl ? (
|
||||||
|
<Avatar size="xs" src={selectedItem.avatarUrl} alt={selectedItem.label} className={cx(size === "sm" && "size-5")} />
|
||||||
|
) : isReactComponent(Icon) ? (
|
||||||
|
<Icon data-icon aria-hidden="true" />
|
||||||
|
) : isValidElement(Icon) ? (
|
||||||
|
Icon
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedItem ? (
|
||||||
|
<section className={cx("flex w-full truncate", sizes[size].textContainer)}>
|
||||||
|
<p className={cx("truncate font-medium text-primary", sizes[size].text)}>{selectedItem?.label}</p>
|
||||||
|
{selectedItem?.supportingText && <p className={cx("text-tertiary", sizes[size].text)}>{selectedItem?.supportingText}</p>}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<p className={cx("text-placeholder", sizes[size].text)}>{placeholder}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx("ml-auto shrink-0 text-fg-quaternary", size === "lg" ? "size-5" : "size-4 stroke-[2.25px]")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AriaSelectValue>
|
||||||
|
</AriaButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Select = ({ placeholder = "Select", icon, size = "md", children, items, label, hint, tooltip, hideRequiredIndicator, className, ...rest }: SelectProps) => {
|
||||||
|
return (
|
||||||
|
<SelectContext.Provider value={{ size }}>
|
||||||
|
<AriaSelect {...rest} className={(state) => cx("flex flex-col gap-1.5", typeof className === "function" ? className(state) : className)}>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? false : state.isRequired} tooltip={tooltip}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SelectValue {...state} {...{ size, placeholder }} icon={icon} />
|
||||||
|
|
||||||
|
<Popover size={size} className={rest.popoverClassName}>
|
||||||
|
<AriaListBox items={items} className="size-full outline-hidden">
|
||||||
|
{children}
|
||||||
|
</AriaListBox>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={state.isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaSelect>
|
||||||
|
</SelectContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _Select = Select as typeof Select & {
|
||||||
|
ComboBox: typeof ComboBox;
|
||||||
|
Item: typeof SelectItem;
|
||||||
|
};
|
||||||
|
_Select.ComboBox = ComboBox;
|
||||||
|
_Select.Item = SelectItem;
|
||||||
|
|
||||||
|
export { _Select as Select };
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import type { FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
|
||||||
|
import React, { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
|
import { SearchLg } from "@untitledui/icons";
|
||||||
|
import { FocusScope, useFilter, useFocusManager } from "react-aria";
|
||||||
|
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
|
||||||
|
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
|
||||||
|
import type { ListData } from "react-stately";
|
||||||
|
import { useListData } from "react-stately";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import type { IconComponentType } from "@/components/base/badges/badge-types";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { Popover } from "@/components/base/select/popover";
|
||||||
|
import { type SelectItemType, sizes } from "@/components/base/select/select-shared";
|
||||||
|
import { TagCloseX } from "@/components/base/tags/base-components/tag-close-x";
|
||||||
|
import { useResizeObserver } from "@/hooks/use-resize-observer";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { SelectItem } from "./select-item";
|
||||||
|
|
||||||
|
interface TagSelectValueProps extends AriaGroupProps {
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
shortcut?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
shortcutClassName?: string;
|
||||||
|
icon?: IconComponentType | null;
|
||||||
|
ref?: RefObject<HTMLDivElement | null>;
|
||||||
|
onFocus?: FocusEventHandler;
|
||||||
|
onPointerEnter?: PointerEventHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagSelectContext = createContext<{
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
selectedKeys: Key[];
|
||||||
|
selectedItems: ListData<SelectItemType>;
|
||||||
|
onRemove: (keys: Set<Key>) => void;
|
||||||
|
onInputChange: (value: string) => void;
|
||||||
|
valueFormatter?: (item: SelectItemType) => string;
|
||||||
|
}>({
|
||||||
|
size: "sm",
|
||||||
|
selectedKeys: [],
|
||||||
|
selectedItems: {} as ListData<SelectItemType>,
|
||||||
|
onRemove: () => {},
|
||||||
|
onInputChange: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TagSelectProps extends Omit<AriaComboBoxProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement> {
|
||||||
|
hint?: string;
|
||||||
|
label?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
placeholder?: string;
|
||||||
|
shortcut?: boolean;
|
||||||
|
items?: SelectItemType[];
|
||||||
|
popoverClassName?: string;
|
||||||
|
shortcutClassName?: string;
|
||||||
|
selectedItems: ListData<SelectItemType>;
|
||||||
|
icon?: IconComponentType | null;
|
||||||
|
children: AriaListBoxProps<SelectItemType>["children"];
|
||||||
|
onItemCleared?: (key: Key) => void;
|
||||||
|
onItemInserted?: (key: Key) => void;
|
||||||
|
valueFormatter?: (item: SelectItemType) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagSelectBase = ({
|
||||||
|
items,
|
||||||
|
children,
|
||||||
|
size = "sm",
|
||||||
|
selectedItems,
|
||||||
|
onItemCleared,
|
||||||
|
onItemInserted,
|
||||||
|
valueFormatter,
|
||||||
|
shortcut,
|
||||||
|
placeholder = "Search",
|
||||||
|
icon,
|
||||||
|
// Omit name to avoid conflicts with the `Select` component
|
||||||
|
name: _name,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: TagSelectProps) => {
|
||||||
|
const { contains } = useFilter({ sensitivity: "base" });
|
||||||
|
const selectedKeys = selectedItems.items.map((item) => item.id);
|
||||||
|
|
||||||
|
const filter = useCallback(
|
||||||
|
(item: SelectItemType, filterText: string) => {
|
||||||
|
return !selectedKeys.includes(item.id) && contains(item.label || item.supportingText || "", filterText);
|
||||||
|
},
|
||||||
|
[contains, selectedKeys],
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessibleList = useListData({
|
||||||
|
initialItems: items,
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onRemove = useCallback(
|
||||||
|
(keys: Set<Key>) => {
|
||||||
|
const key = keys.values().next().value;
|
||||||
|
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
selectedItems.remove(key);
|
||||||
|
onItemCleared?.(key);
|
||||||
|
},
|
||||||
|
[selectedItems, onItemCleared],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSelectionChange = (id: Key | null) => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = accessibleList.getItem(id);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedKeys.includes(id as string)) {
|
||||||
|
selectedItems.append(item);
|
||||||
|
onItemInserted?.(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
accessibleList.setFilterText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInputChange = (value: string) => {
|
||||||
|
accessibleList.setFilterText(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState("");
|
||||||
|
|
||||||
|
// Resize observer for popover width
|
||||||
|
const onResize = useCallback(() => {
|
||||||
|
if (!placeholderRef.current) return;
|
||||||
|
let divRect = placeholderRef.current?.getBoundingClientRect();
|
||||||
|
setPopoverWidth(divRect.width + "px");
|
||||||
|
}, [placeholderRef, setPopoverWidth]);
|
||||||
|
|
||||||
|
useResizeObserver({
|
||||||
|
ref: placeholderRef,
|
||||||
|
onResize: onResize,
|
||||||
|
box: "border-box",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagSelectContext.Provider
|
||||||
|
value={{
|
||||||
|
size,
|
||||||
|
selectedKeys,
|
||||||
|
selectedItems,
|
||||||
|
onInputChange,
|
||||||
|
onRemove,
|
||||||
|
valueFormatter,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AriaComboBox
|
||||||
|
allowsEmptyCollection
|
||||||
|
menuTrigger="focus"
|
||||||
|
items={accessibleList.items}
|
||||||
|
onInputChange={onInputChange}
|
||||||
|
inputValue={accessibleList.filterText}
|
||||||
|
// This keeps the combobox popover open and the input value unchanged when an item is selected.
|
||||||
|
value={null}
|
||||||
|
onChange={onSelectionChange}
|
||||||
|
className={(state) => cx("flex flex-col gap-1.5", typeof className === "function" ? className(state) : className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
{props.label && (
|
||||||
|
<Label isRequired={state.isRequired} tooltip={props.tooltip}>
|
||||||
|
{props.label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TagSelectTagsValue
|
||||||
|
size={size}
|
||||||
|
shortcut={shortcut}
|
||||||
|
ref={placeholderRef}
|
||||||
|
placeholder={placeholder}
|
||||||
|
icon={icon}
|
||||||
|
// This is a workaround to correctly calculating the trigger width
|
||||||
|
// while using ResizeObserver wasn't 100% reliable.
|
||||||
|
onFocus={onResize}
|
||||||
|
onPointerEnter={onResize}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover size={size} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={props?.popoverClassName}>
|
||||||
|
<AriaListBox selectionMode="multiple" className="size-full outline-hidden">
|
||||||
|
{children}
|
||||||
|
</AriaListBox>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{props.hint && (
|
||||||
|
<HintText isInvalid={state.isInvalid} className={cx(size === "sm" && "text-xs")}>
|
||||||
|
{props.hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaComboBox>
|
||||||
|
</TagSelectContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InnerTagSelect = ({ isDisabled, shortcut, shortcutClassName, placeholder, size = "sm" }: Omit<TagSelectProps, "selectedItems" | "children">) => {
|
||||||
|
const focusManager = useFocusManager();
|
||||||
|
const tagSelectContext = useContext(TagSelectContext);
|
||||||
|
const comboBoxStateContext = useContext(ComboBoxStateContext);
|
||||||
|
|
||||||
|
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const isCaretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
|
||||||
|
|
||||||
|
if (!isCaretAtStart && event.currentTarget.value !== "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "Backspace":
|
||||||
|
case "ArrowLeft":
|
||||||
|
focusManager?.focusPrevious({ wrap: false, tabbable: false });
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
focusManager?.focusNext({ wrap: false, tabbable: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure dropdown opens on click even if input is already focused
|
||||||
|
const handleInputMouseDown = (_event: React.MouseEvent<HTMLInputElement>) => {
|
||||||
|
if (comboBoxStateContext && !comboBoxStateContext.isOpen) {
|
||||||
|
comboBoxStateContext.open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagKeyDown = (event: KeyboardEvent<HTMLButtonElement>, value: Key) => {
|
||||||
|
// Do nothing when tab is clicked to move focus from the tag to the input element.
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const isFirstTag = tagSelectContext?.selectedItems?.items?.[0]?.id === value;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case " ":
|
||||||
|
case "Enter":
|
||||||
|
case "Backspace":
|
||||||
|
if (isFirstTag) {
|
||||||
|
focusManager?.focusNext({ wrap: false, tabbable: false });
|
||||||
|
} else {
|
||||||
|
focusManager?.focusPrevious({ wrap: false, tabbable: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
tagSelectContext.onRemove(new Set([value]));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowLeft":
|
||||||
|
focusManager?.focusPrevious({ wrap: false, tabbable: false });
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
focusManager?.focusNext({ wrap: false, tabbable: false });
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
comboBoxStateContext?.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelectionEmpty = tagSelectContext?.selectedItems?.items?.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full min-w-0 flex-1 flex-row flex-wrap items-center justify-start gap-1.5">
|
||||||
|
{!isSelectionEmpty &&
|
||||||
|
tagSelectContext?.selectedItems?.items?.map((value) => (
|
||||||
|
<span
|
||||||
|
key={value.id}
|
||||||
|
className={cx(
|
||||||
|
"flex min-w-0 items-center rounded-md bg-primary ring-1 ring-primary ring-inset",
|
||||||
|
size === "sm" ? "px-1 py-0.75" : "py-0.5 pr-1 pl-1.25",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar size="xs" alt={value?.label} src={value?.avatarUrl} className="size-4" />
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={cx(
|
||||||
|
"truncate font-medium whitespace-nowrap text-secondary select-none",
|
||||||
|
size === "sm" ? "ml-1 text-xs" : "ml-1.25 text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tagSelectContext.valueFormatter ? tagSelectContext.valueFormatter(value) : value?.label}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<TagCloseX
|
||||||
|
size={size === "sm" ? "sm" : "md"}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
className="ml-0.75"
|
||||||
|
// For workaround, onKeyDown is added to the button
|
||||||
|
onKeyDown={(event) => handleTagKeyDown(event, value.id)}
|
||||||
|
onPress={() => tagSelectContext.onRemove(new Set([value.id]))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className={cx("relative flex min-w-12 flex-1 flex-row items-center", !isSelectionEmpty && "ml-0.5", shortcut && "min-w-[30%]")}>
|
||||||
|
<AriaInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
onMouseDown={handleInputMouseDown}
|
||||||
|
className={cx(
|
||||||
|
"w-full flex-[1_0_0] appearance-none bg-transparent text-ellipsis text-primary caret-alpha-black/90 outline-hidden placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed",
|
||||||
|
sizes[size].text,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shortcut && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx(
|
||||||
|
"absolute inset-y-0.5 right-0.5 z-10 hidden items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8 md:flex",
|
||||||
|
shortcutClassName,
|
||||||
|
sizes[size].shortcut,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset",
|
||||||
|
isDisabled && "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagSelectTagsValue = ({
|
||||||
|
size = "sm",
|
||||||
|
shortcut,
|
||||||
|
placeholder,
|
||||||
|
shortcutClassName,
|
||||||
|
icon: Icon = SearchLg,
|
||||||
|
// Omit this prop to avoid invalid HTML attribute warning
|
||||||
|
isDisabled: _isDisabled,
|
||||||
|
...otherProps
|
||||||
|
}: TagSelectValueProps) => {
|
||||||
|
const tagSelectContext = useContext(TagSelectContext);
|
||||||
|
|
||||||
|
const selectedItemsCount = tagSelectContext.selectedKeys.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaGroup
|
||||||
|
{...otherProps}
|
||||||
|
className={({ isFocusWithin, isDisabled }) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-full items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isFocusWithin && "ring-2 ring-brand",
|
||||||
|
|
||||||
|
// Icon styles
|
||||||
|
"*:data-icon:shrink-0 *:data-icon:text-fg-quaternary",
|
||||||
|
|
||||||
|
sizes[size].root,
|
||||||
|
|
||||||
|
// Overwrite vertical padding for small size when there are selected items
|
||||||
|
// to prevent height jump because the tags are taller than the input text.
|
||||||
|
size === "sm" && selectedItemsCount > 0 && "py-1.5",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isDisabled }) => (
|
||||||
|
<>
|
||||||
|
{Icon && <Icon data-icon className="pointer-events-none" />}
|
||||||
|
<FocusScope contain={false} autoFocus={false} restoreFocus={false}>
|
||||||
|
<InnerTagSelect
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
size={size}
|
||||||
|
shortcut={shortcut}
|
||||||
|
shortcutClassName={shortcutClassName}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</FocusScope>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagSelect = TagSelectBase as typeof TagSelectBase & {
|
||||||
|
Item: typeof SelectItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
TagSelect.Item = SelectItem;
|
||||||
|
|
||||||
|
export { TagSelect };
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { SliderProps as AriaSliderProps } from "react-aria-components";
|
||||||
|
import {
|
||||||
|
Label as AriaLabel,
|
||||||
|
Slider as AriaSlider,
|
||||||
|
SliderOutput as AriaSliderOutput,
|
||||||
|
SliderThumb as AriaSliderThumb,
|
||||||
|
SliderTrack as AriaSliderTrack,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const styles = sortCx({
|
||||||
|
default: "hidden",
|
||||||
|
bottom: "absolute top-2 left-1/2 -translate-x-1/2 translate-y-full text-md font-medium text-primary",
|
||||||
|
"top-floating":
|
||||||
|
"absolute -top-2 left-1/2 -translate-x-1/2 -translate-y-full rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-secondary shadow-lg ring-1 ring-secondary_alt",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SliderProps extends AriaSliderProps {
|
||||||
|
labelPosition?: keyof typeof styles;
|
||||||
|
labelFormatter?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slider = ({ labelPosition = "default", minValue = 0, maxValue = 100, labelFormatter, formatOptions, ...rest }: SliderProps) => {
|
||||||
|
// Format thumb value as percentage by default.
|
||||||
|
const defaultFormatOptions: Intl.NumberFormatOptions = {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaSlider {...rest} {...{ minValue, maxValue }} formatOptions={formatOptions ?? defaultFormatOptions}>
|
||||||
|
<AriaLabel />
|
||||||
|
<AriaSliderTrack className="relative h-6 w-full">
|
||||||
|
{({ state: { values, getThumbValue, getThumbPercent, getFormattedValue } }) => {
|
||||||
|
const left = values.length === 1 ? 0 : getThumbPercent(0);
|
||||||
|
const width = values.length === 1 ? getThumbPercent(0) : getThumbPercent(1) - left;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-quaternary" />
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-brand-solid"
|
||||||
|
style={{
|
||||||
|
left: `${left * 100}%`,
|
||||||
|
width: `${width * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{values.map((_, index) => {
|
||||||
|
return (
|
||||||
|
<AriaSliderThumb
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
className={({ isFocusVisible, isDragging }) =>
|
||||||
|
cx(
|
||||||
|
"top-1/2 box-border size-6 cursor-grab rounded-full bg-slider-handle-bg shadow-md ring-2 ring-slider-handle-border ring-inset",
|
||||||
|
isFocusVisible && "outline-2 outline-offset-2 outline-focus-ring",
|
||||||
|
isDragging && "cursor-grabbing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AriaSliderOutput className={cx("whitespace-nowrap", styles[labelPosition])}>
|
||||||
|
{labelFormatter ? labelFormatter(getThumbValue(index)) : getFormattedValue(getThumbValue(index) / 100)}
|
||||||
|
</AriaSliderOutput>
|
||||||
|
</AriaSliderThumb>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AriaSliderTrack>
|
||||||
|
</AriaSlider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface TagCheckboxProps {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
isFocused?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagCheckbox = ({ className, isFocused, isSelected, isDisabled, size = "sm" }: TagCheckboxProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex cursor-pointer appearance-none items-center justify-center rounded bg-primary ring-1 ring-primary ring-inset",
|
||||||
|
size === "sm" && "size-3.5",
|
||||||
|
size === "md" && "size-4",
|
||||||
|
size === "lg" && "size-4.5",
|
||||||
|
isSelected && "bg-brand-solid ring-brand-solid",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isDisabled && !isSelected && "bg-tertiary",
|
||||||
|
isFocused && "outline-2 outline-offset-2 outline-focus-ring",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none absolute text-fg-white opacity-0 transition-inherit-all",
|
||||||
|
size === "sm" && "size-2.5",
|
||||||
|
size === "md" && "size-3",
|
||||||
|
size === "lg" && "size-3.5",
|
||||||
|
isSelected && "opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<path d="M11.6666 3.5L5.24992 9.91667L2.33325 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
TagCheckbox.displayName = "TagCheckbox";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { RefAttributes } from "react";
|
||||||
|
import { XClose } from "@untitledui/icons";
|
||||||
|
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface TagCloseXProps extends AriaButtonProps, RefAttributes<HTMLButtonElement> {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: { root: "p-0.5", icon: "size-2.5 stroke-[3.6px]" },
|
||||||
|
md: { root: "p-0.5", icon: "size-3 stroke-[2.86px]" },
|
||||||
|
lg: { root: "p-0.75", icon: "size-3.5 stroke-3" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagCloseX = ({ size = "md", className, ...otherProps }: TagCloseXProps) => {
|
||||||
|
return (
|
||||||
|
<AriaButton
|
||||||
|
slot="remove"
|
||||||
|
aria-label="Remove this tag"
|
||||||
|
className={cx(
|
||||||
|
"flex cursor-pointer rounded-[3px] text-fg-quaternary outline-transparent transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring disabled:cursor-not-allowed",
|
||||||
|
styles[size].root,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<XClose className={cx("transition-inherit-all", styles[size].icon)} />
|
||||||
|
</AriaButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { type ImgHTMLAttributes, type PropsWithChildren, type RefAttributes, createContext, useContext, useState } from "react";
|
||||||
|
import { User01 } from "@untitledui/icons";
|
||||||
|
import {
|
||||||
|
Tag as AriaTag,
|
||||||
|
TagGroup as AriaTagGroup,
|
||||||
|
type TagGroupProps as AriaTagGroupProps,
|
||||||
|
TagList as AriaTagList,
|
||||||
|
type TagProps as AriaTagProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { Dot } from "@/components/foundations/dot-icon";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import { TagCheckbox } from "./base-components/tag-checkbox";
|
||||||
|
import { TagCloseX } from "./base-components/tag-close-x";
|
||||||
|
|
||||||
|
export const TagAvatar = ({ src, alt, contrastBorder = true, className }: ImgHTMLAttributes<HTMLImageElement> & { contrastBorder?: boolean }) => {
|
||||||
|
const [isFailed, setIsFailed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"relative inline-flex size-4 shrink-0 items-center justify-center overflow-hidden rounded-full bg-tertiary",
|
||||||
|
contrastBorder && "outline-[0.5px] -outline-offset-[0.5px] outline-black/16",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{src && !isFailed ? (
|
||||||
|
<img data-avatar-img className="size-full object-cover" src={src} alt={alt} onError={() => setIsFailed(true)} />
|
||||||
|
) : (
|
||||||
|
<User01 className="size-3 stroke-[2.25px] text-fg-quaternary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TagItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
avatarSrc?: string;
|
||||||
|
avatarContrastBorder?: boolean;
|
||||||
|
dot?: boolean;
|
||||||
|
dotClassName?: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
onClose?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagGroupContext = createContext<{
|
||||||
|
selectionMode: "none" | "single" | "multiple";
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
}>({
|
||||||
|
selectionMode: "none",
|
||||||
|
size: "sm",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TagGroupProps extends AriaTagGroupProps, RefAttributes<HTMLDivElement> {
|
||||||
|
label: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagGroup = ({ label, selectionMode = "none", size = "sm", children, ...otherProps }: TagGroupProps) => {
|
||||||
|
return (
|
||||||
|
<TagGroupContext.Provider value={{ selectionMode, size }}>
|
||||||
|
<AriaTagGroup aria-label={label} selectionMode={selectionMode} disallowEmptySelection={selectionMode === "single"} {...otherProps}>
|
||||||
|
{children}
|
||||||
|
</AriaTagGroup>
|
||||||
|
</TagGroupContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TagList = AriaTagList;
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: {
|
||||||
|
root: {
|
||||||
|
base: "px-2 py-0.75 text-xs font-medium",
|
||||||
|
withCheckbox: "pl-1.25",
|
||||||
|
withAvatar: "pl-1",
|
||||||
|
withDot: "pl-1.5",
|
||||||
|
withCount: "pr-1",
|
||||||
|
withClose: "pr-1",
|
||||||
|
},
|
||||||
|
content: "gap-1",
|
||||||
|
count: "px-1 text-xs font-medium",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: {
|
||||||
|
base: "px-2.25 py-0.5 text-sm font-medium",
|
||||||
|
withCheckbox: "pl-1",
|
||||||
|
withAvatar: "pl-1.25",
|
||||||
|
withDot: "pl-1.75",
|
||||||
|
withCount: "pr-0.75",
|
||||||
|
withClose: "pr-1",
|
||||||
|
},
|
||||||
|
content: "gap-1.25",
|
||||||
|
count: "px-1.25 text-xs font-medium",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
root: {
|
||||||
|
base: "px-2.5 py-1 text-sm font-medium",
|
||||||
|
withCheckbox: "pl-1.25",
|
||||||
|
withAvatar: "pl-1.75",
|
||||||
|
withDot: "pl-2.25",
|
||||||
|
withCount: "pr-1",
|
||||||
|
withClose: "pr-1",
|
||||||
|
},
|
||||||
|
content: "gap-1.5",
|
||||||
|
count: "px-1.5 text-sm font-medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TagProps extends AriaTagProps, RefAttributes<object>, Omit<TagItem, "label" | "id"> {}
|
||||||
|
|
||||||
|
export const Tag = ({
|
||||||
|
id,
|
||||||
|
avatarSrc,
|
||||||
|
avatarContrastBorder = true,
|
||||||
|
dot,
|
||||||
|
dotClassName,
|
||||||
|
isDisabled,
|
||||||
|
count,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: PropsWithChildren<TagProps>) => {
|
||||||
|
const context = useContext(TagGroupContext);
|
||||||
|
|
||||||
|
const leadingContent = avatarSrc ? (
|
||||||
|
<TagAvatar src={avatarSrc} alt="Avatar" contrastBorder={avatarContrastBorder} />
|
||||||
|
) : dot ? (
|
||||||
|
<Dot className={cx("text-fg-success-secondary", dotClassName)} size="sm" />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaTag
|
||||||
|
id={id}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
textValue={typeof children === "string" ? children : undefined}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"flex cursor-default items-center gap-0.75 rounded-md bg-primary text-secondary ring-1 ring-primary outline-focus-ring transition duration-50 ease-linear ring-inset focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
styles[context.size].root.base,
|
||||||
|
|
||||||
|
// With avatar
|
||||||
|
avatarSrc && styles[context.size].root.withAvatar,
|
||||||
|
// With X button
|
||||||
|
(onClose || state.allowsRemoving) && styles[context.size].root.withClose,
|
||||||
|
// With dot
|
||||||
|
dot && styles[context.size].root.withDot,
|
||||||
|
// With count
|
||||||
|
typeof count === "number" && styles[context.size].root.withCount,
|
||||||
|
// With checkbox
|
||||||
|
context.selectionMode !== "none" && styles[context.size].root.withCheckbox,
|
||||||
|
// Disabled
|
||||||
|
isDisabled && "cursor-not-allowed",
|
||||||
|
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSelected, isDisabled, allowsRemoving }) => (
|
||||||
|
<>
|
||||||
|
<div className={cx("flex items-center gap-1", styles[context.size].content)}>
|
||||||
|
{context.selectionMode !== "none" && <TagCheckbox size={context.size} isSelected={isSelected} isDisabled={isDisabled} />}
|
||||||
|
|
||||||
|
{leadingContent}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{typeof count === "number" && (
|
||||||
|
<span className={cx("flex items-center justify-center rounded-[3px] bg-tertiary text-center", styles[context.size].count)}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(onClose || allowsRemoving) && (
|
||||||
|
<TagCloseX size={context.size} excludeFromTabOrder={allowsRemoving} onPress={() => id && onClose?.(id.toString())} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaTag>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ReactNode, Ref } from "react";
|
||||||
|
import React from "react";
|
||||||
|
import type { TextAreaProps as AriaTextAreaProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
|
||||||
|
import { TextArea as AriaTextArea, TextField as AriaTextField } from "react-aria-components";
|
||||||
|
import { HintText } from "@/components/base/input/hint-text";
|
||||||
|
import { Label } from "@/components/base/input/label";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
// Creates a data URL for an SVG resize handle with a given color.
|
||||||
|
const getResizeHandleBg = (color: string) => {
|
||||||
|
return `url(data:image/svg+xml;base64,${btoa(`<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 2L2 10" stroke="${color}" stroke-linecap="round"/><path d="M11 7L7 11" stroke="${color}" stroke-linecap="round"/></svg>`)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TextAreaBaseProps extends AriaTextAreaProps {
|
||||||
|
ref?: Ref<HTMLTextAreaElement>;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextAreaBase = ({ className, size = "md", ...props }: TextAreaBaseProps) => {
|
||||||
|
return (
|
||||||
|
<AriaTextArea
|
||||||
|
{...props}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--resize-handle-bg": getResizeHandleBg("#D5D7DA"),
|
||||||
|
"--resize-handle-bg-dark": getResizeHandleBg("#373A41"),
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"w-full scroll-py-3 rounded-lg bg-primary text-primary shadow-xs ring-1 ring-primary transition duration-100 ease-linear ring-inset placeholder:text-placeholder autofill:rounded-lg autofill:text-primary focus:outline-hidden",
|
||||||
|
|
||||||
|
size === "sm" && "p-3 text-sm",
|
||||||
|
size === "md" && "px-3.5 py-3 text-md",
|
||||||
|
|
||||||
|
// Resize handle
|
||||||
|
"[&::-webkit-resizer]:bg-(image:--resize-handle-bg) [&::-webkit-resizer]:bg-contain dark:[&::-webkit-resizer]:bg-(image:--resize-handle-bg-dark)",
|
||||||
|
|
||||||
|
state.isFocused && !state.isDisabled && "ring-2 ring-brand",
|
||||||
|
state.isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
state.isInvalid && "ring-error_subtle",
|
||||||
|
state.isInvalid && state.isFocused && "ring-2 ring-error",
|
||||||
|
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TextAreaBase.displayName = "TextAreaBase";
|
||||||
|
|
||||||
|
interface TextFieldProps extends AriaTextFieldProps {
|
||||||
|
/** Label text for the textarea */
|
||||||
|
label?: string;
|
||||||
|
/** Helper text displayed below the textarea */
|
||||||
|
hint?: ReactNode;
|
||||||
|
/** Tooltip message displayed after the label. */
|
||||||
|
tooltip?: string;
|
||||||
|
/** Textarea size. */
|
||||||
|
size?: TextAreaBaseProps["size"];
|
||||||
|
/** Class name for the textarea wrapper */
|
||||||
|
textAreaClassName?: TextAreaBaseProps["className"];
|
||||||
|
/** Ref for the textarea wrapper */
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
/** Ref for the textarea */
|
||||||
|
textAreaRef?: TextAreaBaseProps["ref"];
|
||||||
|
/** Whether to hide required indicator from label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
/** Placeholder text. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Visible height of textarea in rows . */
|
||||||
|
rows?: number;
|
||||||
|
/** Visible width of textarea in columns. */
|
||||||
|
cols?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextArea = ({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
tooltip,
|
||||||
|
textAreaRef,
|
||||||
|
hideRequiredIndicator,
|
||||||
|
textAreaClassName,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
size = "md",
|
||||||
|
...props
|
||||||
|
}: TextFieldProps) => {
|
||||||
|
return (
|
||||||
|
<AriaTextField
|
||||||
|
{...props}
|
||||||
|
className={(state) =>
|
||||||
|
cx("group flex h-max w-full flex-col items-start justify-start gap-1.5", typeof className === "function" ? className(state) : className)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isInvalid, isRequired }) => (
|
||||||
|
<>
|
||||||
|
{label && (
|
||||||
|
<Label isRequired={hideRequiredIndicator ? !hideRequiredIndicator : isRequired} tooltip={tooltip}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextAreaBase placeholder={placeholder} className={textAreaClassName} ref={textAreaRef} rows={rows} cols={cols} size={size} />
|
||||||
|
|
||||||
|
{hint && (
|
||||||
|
<HintText isInvalid={isInvalid} size={size}>
|
||||||
|
{hint}
|
||||||
|
</HintText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaTextField>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TextArea.displayName = "TextArea";
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
|
||||||
|
import { Switch as AriaSwitch } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface ToggleBaseProps {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
slim?: boolean;
|
||||||
|
className?: string;
|
||||||
|
isHovered?: boolean;
|
||||||
|
isFocusVisible?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToggleBase = ({ className, isHovered, isDisabled, isFocusVisible, isSelected, slim, size = "sm" }: ToggleBaseProps) => {
|
||||||
|
const styles = {
|
||||||
|
default: {
|
||||||
|
sm: {
|
||||||
|
root: "h-5 w-9 p-0.5",
|
||||||
|
switch: cx("size-4", isSelected && "translate-x-4"),
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "h-6 w-11 p-0.5",
|
||||||
|
switch: cx("size-5", isSelected && "translate-x-5"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slim: {
|
||||||
|
sm: {
|
||||||
|
root: "h-4 w-8",
|
||||||
|
switch: cx("size-4", isSelected && "translate-x-4"),
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "h-5 w-10",
|
||||||
|
switch: cx("size-5", isSelected && "translate-x-5"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const classes = slim ? styles.slim[size] : styles.default[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"cursor-pointer rounded-full bg-tertiary ring-[0.5px] ring-secondary outline-focus-ring transition duration-150 ease-linear ring-inset",
|
||||||
|
isSelected && "bg-brand-solid",
|
||||||
|
isSelected && isHovered && "bg-brand-solid_hover",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
|
isFocusVisible && "outline-2 outline-offset-2",
|
||||||
|
|
||||||
|
slim && "ring-1",
|
||||||
|
slim && isSelected && "ring-transparent",
|
||||||
|
classes.root,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transition: "transform 0.15s ease-in-out, translate 0.15s ease-in-out, border-color 0.1s linear, background-color 0.1s linear",
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
"rounded-full bg-fg-white shadow-sm",
|
||||||
|
|
||||||
|
slim && "shadow-xs",
|
||||||
|
slim && "border border-toggle-border",
|
||||||
|
slim && isSelected && "border-toggle-slim-border_pressed",
|
||||||
|
slim && isSelected && isHovered && "border-toggle-slim-border_pressed-hover",
|
||||||
|
|
||||||
|
classes.switch,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
sm: {
|
||||||
|
root: "gap-2",
|
||||||
|
textWrapper: "",
|
||||||
|
label: "text-sm font-medium",
|
||||||
|
hint: "text-sm",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
root: "gap-3",
|
||||||
|
textWrapper: "gap-0.5",
|
||||||
|
label: "text-md font-medium",
|
||||||
|
hint: "text-md",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToggleProps extends AriaSwitchProps {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
label?: string;
|
||||||
|
hint?: ReactNode;
|
||||||
|
slim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Toggle = ({ label, hint, className, size = "sm", slim, ...ariaSwitchProps }: ToggleProps) => {
|
||||||
|
return (
|
||||||
|
<AriaSwitch
|
||||||
|
{...ariaSwitchProps}
|
||||||
|
className={(state) =>
|
||||||
|
cx(
|
||||||
|
"relative flex w-max items-start",
|
||||||
|
state.isDisabled && "cursor-not-allowed",
|
||||||
|
styles[size].root,
|
||||||
|
typeof className === "function" ? className(state) : className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isSelected, isDisabled, isFocusVisible, isHovered }) => (
|
||||||
|
<>
|
||||||
|
<ToggleBase
|
||||||
|
slim={slim}
|
||||||
|
size={size}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isFocusVisible={isFocusVisible}
|
||||||
|
isSelected={isSelected}
|
||||||
|
className={slim ? "mt-0.5" : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(label || hint) && (
|
||||||
|
<div className={cx("flex flex-col", styles[size].textWrapper)}>
|
||||||
|
{label && <p className={cx("text-secondary select-none", styles[size].label)}>{label}</p>}
|
||||||
|
{hint && (
|
||||||
|
<span className={cx("text-tertiary", styles[size].hint)} onClick={(event) => event.stopPropagation()}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaSwitch>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type {
|
||||||
|
ButtonProps as AriaButtonProps,
|
||||||
|
TooltipProps as AriaTooltipProps,
|
||||||
|
TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
|
||||||
|
} from "react-aria-components";
|
||||||
|
import { Button as AriaButton, OverlayArrow as AriaOverlayArrow, Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "react-aria-components";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
|
||||||
|
/**
|
||||||
|
* The title of the tooltip.
|
||||||
|
*/
|
||||||
|
title: ReactNode;
|
||||||
|
/**
|
||||||
|
* The description of the tooltip.
|
||||||
|
*/
|
||||||
|
description?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Whether to show the arrow on the tooltip.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
arrow?: boolean;
|
||||||
|
/**
|
||||||
|
* Delay in milliseconds before the tooltip is shown.
|
||||||
|
*
|
||||||
|
* @default 300
|
||||||
|
*/
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
arrow = false,
|
||||||
|
delay = 300,
|
||||||
|
closeDelay = 0,
|
||||||
|
trigger,
|
||||||
|
isDisabled,
|
||||||
|
isOpen,
|
||||||
|
defaultOpen,
|
||||||
|
offset = 6,
|
||||||
|
crossOffset,
|
||||||
|
placement = "top",
|
||||||
|
onOpenChange,
|
||||||
|
...tooltipProps
|
||||||
|
}: TooltipProps) => {
|
||||||
|
const isTopOrBottomLeft = ["top left", "top end", "bottom left", "bottom end"].includes(placement);
|
||||||
|
const isTopOrBottomRight = ["top right", "top start", "bottom right", "bottom start"].includes(placement);
|
||||||
|
// Set negative cross offset for left and right placement to visually balance the tooltip.
|
||||||
|
const calculatedCrossOffset = isTopOrBottomLeft ? -12 : isTopOrBottomRight ? 12 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AriaTooltipTrigger {...{ trigger, delay, closeDelay, isDisabled, isOpen, defaultOpen, onOpenChange }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<AriaTooltip
|
||||||
|
{...tooltipProps}
|
||||||
|
offset={offset}
|
||||||
|
placement={placement}
|
||||||
|
crossOffset={crossOffset ?? calculatedCrossOffset}
|
||||||
|
className={({ isEntering, isExiting }) => cx(isEntering && "ease-out animate-in", isExiting && "ease-in animate-out")}
|
||||||
|
>
|
||||||
|
{({ isEntering, isExiting }) => (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"z-50 flex max-w-xs origin-(--trigger-anchor-point) flex-col items-start gap-1 rounded-lg bg-primary-solid px-3 shadow-lg will-change-transform",
|
||||||
|
description ? "py-3" : "py-2",
|
||||||
|
|
||||||
|
isEntering &&
|
||||||
|
"ease-out animate-in fade-in zoom-in-95 in-placement-left:slide-in-from-right-0.5 in-placement-right:slide-in-from-left-0.5 in-placement-top:slide-in-from-bottom-0.5 in-placement-bottom:slide-in-from-top-0.5",
|
||||||
|
isExiting &&
|
||||||
|
"ease-in animate-out fade-out zoom-out-95 in-placement-left:slide-out-to-right-0.5 in-placement-right:slide-out-to-left-0.5 in-placement-top:slide-out-to-bottom-0.5 in-placement-bottom:slide-out-to-top-0.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold text-white">{title}</span>
|
||||||
|
|
||||||
|
{description && <span className="text-xs font-medium text-tooltip-supporting-text">{description}</span>}
|
||||||
|
|
||||||
|
{arrow && (
|
||||||
|
<AriaOverlayArrow>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
className="size-2.5 fill-bg-primary-solid in-placement-left:-rotate-90 in-placement-right:rotate-90 in-placement-top:rotate-0 in-placement-bottom:rotate-180"
|
||||||
|
>
|
||||||
|
<path d="M0,0 L35.858,35.858 Q50,50 64.142,35.858 L100,0 Z" />
|
||||||
|
</svg>
|
||||||
|
</AriaOverlayArrow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AriaTooltip>
|
||||||
|
</AriaTooltipTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TooltipTriggerProps extends AriaButtonProps {}
|
||||||
|
|
||||||
|
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
|
||||||
|
return (
|
||||||
|
<AriaButton {...buttonProps} className={(values) => cx("h-max w-max outline-hidden", typeof className === "function" ? className(values) : className)}>
|
||||||
|
{children}
|
||||||
|
</AriaButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: {
|
||||||
|
wh: 8,
|
||||||
|
c: 4,
|
||||||
|
r: 2.5,
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
wh: 10,
|
||||||
|
c: 5,
|
||||||
|
r: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dot = ({ size = "md", ...props }: HTMLAttributes<HTMLOrSVGElement> & { size?: "sm" | "md" }) => {
|
||||||
|
return (
|
||||||
|
<svg width={sizes[size].wh} height={sizes[size].wh} viewBox={`0 0 ${sizes[size].wh} ${sizes[size].wh}`} fill="none" {...props}>
|
||||||
|
<circle cx={sizes[size].c} cy={sizes[size].c} r={sizes[size].r} fill="currentColor" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { FC, ReactNode, Ref } from "react";
|
||||||
|
import { isValidElement } from "react";
|
||||||
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
import { isReactComponent } from "@/utils/is-react-component";
|
||||||
|
|
||||||
|
const iconsSizes = {
|
||||||
|
sm: "*:data-icon:size-4 *:data-icon:stroke-[2.25px]",
|
||||||
|
md: "*:data-icon:size-5",
|
||||||
|
lg: "*:data-icon:size-6",
|
||||||
|
xl: "*:data-icon:size-7",
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = sortCx({
|
||||||
|
light: {
|
||||||
|
base: "rounded-full",
|
||||||
|
sizes: {
|
||||||
|
sm: "size-8",
|
||||||
|
md: "size-10",
|
||||||
|
lg: "size-12",
|
||||||
|
xl: "size-14",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: "bg-brand-secondary text-featured-icon-light-fg-brand",
|
||||||
|
gray: "bg-tertiary text-featured-icon-light-fg-gray",
|
||||||
|
error: "bg-error-secondary text-featured-icon-light-fg-error",
|
||||||
|
warning: "bg-warning-secondary text-featured-icon-light-fg-warning",
|
||||||
|
success: "bg-success-secondary text-featured-icon-light-fg-success",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
gradient: {
|
||||||
|
base: "rounded-full text-fg-white before:absolute before:inset-0 before:size-full before:rounded-full before:border before:mask-b-from-0% after:absolute after:block after:rounded-full",
|
||||||
|
sizes: {
|
||||||
|
sm: "size-8 after:size-6 *:data-icon:size-4",
|
||||||
|
md: "size-10 after:size-7 *:data-icon:size-4",
|
||||||
|
lg: "size-12 after:size-8 *:data-icon:size-5",
|
||||||
|
xl: "size-14 after:size-10 *:data-icon:size-5",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: "before:border-utility-brand-200 before:bg-utility-brand-50 after:bg-brand-solid",
|
||||||
|
gray: "before:border-utility-neutral-200 before:bg-utility-neutral-50 after:bg-secondary-solid",
|
||||||
|
error: "before:border-utility-red-200 before:bg-utility-red-50 after:bg-error-solid",
|
||||||
|
warning: "before:border-utility-yellow-200 before:bg-utility-yellow-50 after:bg-warning-solid",
|
||||||
|
success: "before:border-utility-green-200 before:bg-utility-green-50 after:bg-success-solid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dark: {
|
||||||
|
base: "text-fg-white shadow-xs-skeuomorphic before:absolute before:inset-px before:border before:border-white/12 before:mask-b-from-0%",
|
||||||
|
sizes: {
|
||||||
|
sm: "size-8 rounded-md before:rounded-[5px]",
|
||||||
|
md: "size-10 rounded-lg before:rounded-[7px]",
|
||||||
|
lg: "size-12 rounded-[10px] before:rounded-[9px]",
|
||||||
|
xl: "size-14 rounded-xl before:rounded-[11px]",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: "bg-brand-solid before:border-utility-brand-200/12",
|
||||||
|
gray: "bg-secondary-solid before:border-utility-neutral-200/12",
|
||||||
|
error: "bg-error-solid before:border-utility-red-200/12",
|
||||||
|
warning: "bg-warning-solid before:border-utility-yellow-200/12",
|
||||||
|
success: "bg-success-solid before:border-utility-green-200/12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
modern: {
|
||||||
|
base: "bg-primary shadow-xs-skeuomorphic ring-1 ring-primary ring-inset",
|
||||||
|
sizes: {
|
||||||
|
sm: "size-8 rounded-md",
|
||||||
|
md: "size-10 rounded-lg",
|
||||||
|
lg: "size-12 rounded-[10px]",
|
||||||
|
xl: "size-14 rounded-xl",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: "text-fg-brand-primary",
|
||||||
|
gray: "text-fg-secondary",
|
||||||
|
error: "text-fg-error-primary",
|
||||||
|
warning: "text-fg-warning-primary",
|
||||||
|
success: "text-fg-success-primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"modern-neue": {
|
||||||
|
base: [
|
||||||
|
"bg-primary_alt ring-1 ring-inset before:absolute before:inset-1",
|
||||||
|
// Shadow
|
||||||
|
"before:shadow-[0px_1px_2px_0px_rgba(0,0,0,0.1),0px_3px_3px_0px_rgba(0,0,0,0.09),1px_8px_5px_0px_rgba(0,0,0,0.05),2px_21px_6px_0px_rgba(0,0,0,0),0px_0px_0px_1px_rgba(0,0,0,0.08),1px_13px_5px_0px_rgba(0,0,0,0.01),0px_-2px_2px_0px_rgba(0,0,0,0.13)_inset] before:ring-1 before:ring-secondary_alt",
|
||||||
|
].join(" "),
|
||||||
|
sizes: {
|
||||||
|
sm: "size-8 rounded-[8px] before:rounded-[4px]",
|
||||||
|
md: "size-10 rounded-[10px] before:rounded-[6px]",
|
||||||
|
lg: "size-12 rounded-[12px] before:rounded-[8px]",
|
||||||
|
xl: "size-14 rounded-[14px] before:rounded-[10px]",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: "",
|
||||||
|
gray: "text-fg-secondary ring-primary",
|
||||||
|
error: "",
|
||||||
|
warning: "",
|
||||||
|
success: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
outline: {
|
||||||
|
base: "before:absolute before:rounded-full before:border-2 after:absolute after:rounded-full after:border-2",
|
||||||
|
sizes: {
|
||||||
|
sm: "size-4 before:size-6 after:size-8.5",
|
||||||
|
md: "size-5 before:size-7 after:size-9.5",
|
||||||
|
lg: "size-6 before:size-8 after:size-10.5",
|
||||||
|
xl: "size-7 before:size-9 after:size-11.5",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: "text-fg-brand-primary before:border-fg-brand-primary/30 after:border-fg-brand-primary/10",
|
||||||
|
gray: "text-fg-tertiary before:border-fg-tertiary/30 after:border-fg-tertiary/10",
|
||||||
|
error: "text-fg-error-primary before:border-fg-error-primary/30 after:border-fg-error-primary/10",
|
||||||
|
warning: "text-fg-warning-primary before:border-fg-warning-primary/30 after:border-fg-warning-primary/10",
|
||||||
|
success: "text-fg-success-primary before:border-fg-success-primary/30 after:border-fg-success-primary/10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FeaturedIconProps {
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
icon?: FC<{ className?: string }> | ReactNode;
|
||||||
|
size?: "sm" | "md" | "lg" | "xl";
|
||||||
|
color: "brand" | "gray" | "success" | "warning" | "error";
|
||||||
|
theme?: "light" | "gradient" | "dark" | "outline" | "modern" | "modern-neue";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeaturedIcon = (props: FeaturedIconProps) => {
|
||||||
|
const { size = "sm", theme: variant = "light", color = "brand", icon: Icon, ...otherProps } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...otherProps}
|
||||||
|
data-featured-icon
|
||||||
|
className={cx(
|
||||||
|
"relative flex shrink-0 items-center justify-center",
|
||||||
|
|
||||||
|
iconsSizes[size],
|
||||||
|
styles[variant].base,
|
||||||
|
styles[variant].sizes[size],
|
||||||
|
styles[variant].colors[color],
|
||||||
|
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isReactComponent(Icon) && <Icon data-icon className="z-1" />}
|
||||||
|
{isValidElement(Icon) && <div className="z-1">{Icon}</div>}
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user