chore: import upstream snapshot with attribution
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# @openmetadata/ui-core-components
|
||||
|
||||
Shared UI component library for OpenMetadata, built on UntitledUI design system patterns with React Aria Components as the accessibility foundation.
|
||||
|
||||
## Package Overview
|
||||
|
||||
- **Package name**: `@openmetadata/ui-core-components`
|
||||
- **Source root**: `src/main/resources/ui/src/`
|
||||
- **Build tool**: Vite (library mode) with `vite-plugin-dts` for type declarations
|
||||
- **Styling**: Tailwind CSS v4 with `tw:` prefix to avoid conflicts with the main app's Less/Ant Design styles
|
||||
- **Component foundation**: `react-aria-components` (NOT MUI or Ant Design)
|
||||
- **Icons**: `@untitledui/icons`
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
cd openmetadata-ui-core-components/src/main/resources/ui
|
||||
yarn build # Production build to dist/
|
||||
yarn dev # Watch mode build
|
||||
yarn type-check # TypeScript type checking (tsc --noEmit)
|
||||
yarn storybook # Launch Storybook on port 6006
|
||||
yarn clean # Remove dist/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Export Structure (package.json `exports`)
|
||||
|
||||
| Import path | Description |
|
||||
| --------------------------------------------- | -------------------------------------------- |
|
||||
| `@openmetadata/ui-core-components` | Main entry - re-exports everything |
|
||||
| `@openmetadata/ui-core-components/components` | All UI components |
|
||||
| `@openmetadata/ui-core-components/theme` | MUI theme, `createMuiTheme` |
|
||||
| `@openmetadata/ui-core-components/colors` | Color utilities, `generateMuiPalettes` |
|
||||
| `@openmetadata/ui-core-components/utils` | `cx`, `sortCx`, utilities |
|
||||
| `@openmetadata/ui-core-components/styles.css` | Compiled CSS (must be imported by consumers) |
|
||||
|
||||
### Source Directory Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── base/ # Primitive building blocks
|
||||
│ │ ├── avatar/ # Avatar, AvatarLabelGroup, AvatarProfilePhoto
|
||||
│ │ ├── badges/ # Badge, BadgeGroup
|
||||
│ │ ├── buttons/ # Button, CloseButton, ButtonUtility, SocialButton
|
||||
│ │ ├── button-group/ # ButtonGroup
|
||||
│ │ ├── checkbox/ # Checkbox
|
||||
│ │ ├── dropdown/ # Dropdown
|
||||
│ │ ├── form/ # Form, HookForm
|
||||
│ │ ├── input/ # Input, InputGroup, Label, HintText
|
||||
│ │ ├── select/ # Select, MultiSelect, Combobox, Popover
|
||||
│ │ ├── slider/ # Slider
|
||||
│ │ ├── tags/ # Tags
|
||||
│ │ ├── textarea/ # Textarea
|
||||
│ │ ├── toggle/ # Toggle
|
||||
│ │ └── tooltip/ # Tooltip
|
||||
│ ├── application/ # Composite/application-level components
|
||||
│ │ ├── date-picker/ # DatePicker, DateRangePicker, Calendar
|
||||
│ │ ├── modals/ # ModalOverlay, Modal, Dialog, DeleteModal
|
||||
│ │ ├── pagination/ # Pagination
|
||||
│ │ ├── slideout-menus/ # SlideoutMenu
|
||||
│ │ ├── table/ # Table
|
||||
│ │ └── tabs/ # Tabs
|
||||
│ └── foundations/ # Design tokens as components
|
||||
│ ├── dot-icon.tsx
|
||||
│ ├── featured-icon.tsx # FeaturedIcon (light, outline themes)
|
||||
│ └── payment-icons/
|
||||
├── styles/
|
||||
│ ├── globals.css # Shared Tailwind-facing CSS entry (tokens, typography, shared styles)
|
||||
│ └── typography.css # Typography prose rules
|
||||
├── theme/ # MUI theme configuration
|
||||
├── colors/ # Color generation utilities
|
||||
├── utils/ # cx, sortCx, isReactComponent
|
||||
├── types/ # Shared TypeScript types
|
||||
├── constants/ # Button constants, etc.
|
||||
└── hooks/ # Shared hooks
|
||||
```
|
||||
|
||||
### Component Export Registration
|
||||
|
||||
All public components must be exported from `src/components/index.ts`. Add new exports there when creating components.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Tailwind CSS Prefix
|
||||
|
||||
All Tailwind classes use the `tw:` prefix: `tw:flex`, `tw:bg-primary`, `tw:text-sm`, etc. This is mandatory to scope styles and prevent collisions with the main app's existing Less/Ant Design CSS.
|
||||
|
||||
### CSS Custom Properties (Design Tokens)
|
||||
|
||||
Theme colors are defined as CSS custom properties in `src/styles/globals.css` with light and dark mode variants. Components reference these semantic tokens:
|
||||
|
||||
- **Backgrounds**: `bg-primary`, `bg-secondary`, `bg-error-secondary`, `bg-error-solid`
|
||||
- **Text**: `text-primary`, `text-secondary`, `text-tertiary`, `text-fg-error-primary`
|
||||
- **Featured icon**: `text-featured-icon-light-fg-{brand,error,gray,success,warning}`
|
||||
- **Utility colors**: `border-utility-{brand,error,gray,success,warning}-{50-900}`
|
||||
|
||||
### Style Patterns
|
||||
|
||||
- Use `sortCx({})` to define style variant objects (enables Tailwind IntelliSense sorting)
|
||||
- Use `cx()` (from `tailwind-merge`) to merge class names with conflict resolution
|
||||
- Use `isReactComponent()` to type-guard icon props that accept both `FC` and `ReactNode`
|
||||
|
||||
### Button Color Variants
|
||||
|
||||
The `Button` component supports these `color` values:
|
||||
|
||||
- `primary`, `secondary`, `tertiary`, `link-gray`, `link-color`
|
||||
- `primary-destructive`, `secondary-destructive`, `tertiary-destructive`, `link-destructive`
|
||||
|
||||
### Dark Mode
|
||||
|
||||
Dark mode uses a custom variant: `@custom-variant dark (&:where(.dark-mode, .dark-mode *))`. Toggle by adding `.dark-mode` class to a parent element.
|
||||
|
||||
### Externalized Dependencies (Vite rollup)
|
||||
|
||||
These are externalized in the build and must be provided by the consuming app:
|
||||
|
||||
- `react`, `react-dom`, `react/jsx-runtime`
|
||||
- `@mui/material`, `@mui/system`, `@mui/material/styles`, `@mui/material/Chip`, `@mui/icons-material`, `@mui/x-date-pickers`
|
||||
- `@emotion/react`, `@emotion/styled`
|
||||
- `@material/material-color-utilities`
|
||||
- `@untitledui/icons`
|
||||
- `react-aria`, `react-aria-components`, `react-stately`
|
||||
- `react-hook-form`
|
||||
- `tailwind-merge`
|
||||
- `input-otp`
|
||||
- `@react-aria/utils`, `@react-stately/utils`, `@react-types/shared`
|
||||
- `@internationalized/date`
|
||||
- `tailwindcss-react-aria-components`
|
||||
|
||||
## Adding New Components
|
||||
|
||||
1. Create the component file in the appropriate directory (`base/` for primitives, `application/` for composites, `foundations/` for design-token components)
|
||||
2. Use `tw:` prefix on all Tailwind classes
|
||||
3. Use `cx()` for class merging and `sortCx()` for style variant maps
|
||||
4. Build on `react-aria-components` for accessibility
|
||||
5. Export from `src/components/index.ts`
|
||||
6. Run `yarn build` to verify compilation
|
||||
@@ -0,0 +1,141 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>platform</artifactId>
|
||||
<groupId>org.open-metadata</groupId>
|
||||
<version>2.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>openmetadata-ui-core-components</artifactId>
|
||||
<name>OpenMetadata UI Core Components</name>
|
||||
|
||||
<properties>
|
||||
<skipTests>false</skipTests>
|
||||
<yarnInheritsProxyConfigFromMaven>false</yarnInheritsProxyConfigFromMaven>
|
||||
</properties>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>release</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<version>${maven-source-plugin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar-no-fork</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-javadoc-plugin</artifactId>
|
||||
<version>${maven-javadoc-plugin.version}</version>
|
||||
<configuration>
|
||||
<doclint>none</doclint>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-javadocs</id>
|
||||
<goals>
|
||||
<goal>jar</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-gpg-plugin</artifactId>
|
||||
<version>3.0.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>sign-artifacts</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>sign</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<gpgArguments>
|
||||
<arg>--pinentry-mode</arg>
|
||||
<arg>loopback</arg>
|
||||
</gpgArguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources/ui/dist</directory>
|
||||
<targetPath>assets</targetPath>
|
||||
</resource>
|
||||
</resources>
|
||||
|
||||
<plugins>
|
||||
<!-- Handle Compiling Frontend -->
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.12.1</version>
|
||||
|
||||
<!-- optional -->
|
||||
<configuration>
|
||||
<workingDirectory>src/main/resources/ui/</workingDirectory>
|
||||
<installDirectory>target</installDirectory>
|
||||
</configuration>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install node and yarn</id>
|
||||
<goals>
|
||||
<goal>install-node-and-yarn</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<nodeVersion>v22.17.0</nodeVersion>
|
||||
<yarnVersion>v1.22.18</yarnVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>yarn install --frozen-lockfile</id>
|
||||
<goals>
|
||||
<goal>yarn</goal>
|
||||
</goals>
|
||||
<phase>prepare-package</phase>
|
||||
<configuration>
|
||||
<yarnInheritsProxyConfigFromMaven>${yarnInheritsProxyConfigFromMaven}</yarnInheritsProxyConfigFromMaven>
|
||||
<arguments>install --frozen-lockfile</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>yarn run build</id>
|
||||
<goals>
|
||||
<goal>yarn</goal>
|
||||
</goals>
|
||||
<!-- optional: the default phase is "generate-resources" -->
|
||||
<phase>prepare-package</phase>
|
||||
<configuration>
|
||||
<yarnInheritsProxyConfigFromMaven>${yarnInheritsProxyConfigFromMaven}</yarnInheritsProxyConfigFromMaven>
|
||||
<!-- optional: if not specified, it will run gulp's default task
|
||||
(and you can remove this whole <configuration> section.) -->
|
||||
<arguments>run build</arguments>
|
||||
<!--arguments>build</arguments-->
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1 @@
|
||||
v22.17.0
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright 2021 Collate
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Node
|
||||
node/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Production
|
||||
build/
|
||||
dist/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Ignore files (Prettier has trouble parsing files without extension)
|
||||
.gitignore
|
||||
.prettierignore
|
||||
|
||||
# Assets
|
||||
*.gif
|
||||
*.svg
|
||||
*.png
|
||||
*.ico
|
||||
*.ttf
|
||||
*.md
|
||||
*.webp
|
||||
*.jpg
|
||||
*.jpeg
|
||||
|
||||
# Snapshots
|
||||
*.snap
|
||||
@@ -0,0 +1,17 @@
|
||||
#
|
||||
# Copyright 2022 Collate.
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
---
|
||||
tabWidth: 2
|
||||
bracketSameLine: true
|
||||
htmlWhitespaceSensitivity: 'strict'
|
||||
singleQuote: true
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: 'Inter', 'Poppins', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {},
|
||||
viteFinal: async (viteConfig) => {
|
||||
// vite.config.ts externalizes deps for the library build so consumers don't
|
||||
// bundle them twice. Storybook's static output must be fully self-contained,
|
||||
// so we clear those externals here.
|
||||
if (viteConfig.build?.rollupOptions) {
|
||||
viteConfig.build.rollupOptions.external = [];
|
||||
}
|
||||
return viteConfig;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,23 @@
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import '@fontsource/inter/800.css';
|
||||
import '@fontsource/inter/900.css';
|
||||
import type { Preview, StoryFn } from '@storybook/react';
|
||||
import '../src/styles/storybook.css';
|
||||
import './index.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [(Story: StoryFn) => <Story />],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import js from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
prettierConfig,
|
||||
|
||||
{
|
||||
ignores: ['node_modules/**', 'dist/**'],
|
||||
},
|
||||
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
|
||||
rules: {
|
||||
eqeqeq: ['error', 'smart'],
|
||||
'no-console': 'error',
|
||||
'spaced-comment': ['error', 'always'],
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
comments: 120,
|
||||
code: 200,
|
||||
ignoreTrailingComments: true,
|
||||
ignoreUrls: true,
|
||||
ignoreStrings: true,
|
||||
},
|
||||
],
|
||||
curly: ['error', 'all'],
|
||||
'arrow-parens': ['error', 'always'],
|
||||
'padding-line-between-statements': [
|
||||
'error',
|
||||
{ blankLine: 'always', prev: '*', next: 'function' },
|
||||
{ blankLine: 'always', prev: '*', next: 'class' },
|
||||
{ blankLine: 'always', prev: '*', next: 'export' },
|
||||
{ blankLine: 'any', prev: 'export', next: 'export' },
|
||||
{ blankLine: 'always', prev: '*', next: 'return' },
|
||||
{ blankLine: 'always', prev: '*', next: 'break' },
|
||||
{ blankLine: 'always', prev: '*', next: 'continue' },
|
||||
{ blankLine: 'always', prev: '*', next: 'throw' },
|
||||
],
|
||||
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-sort-props': [
|
||||
'error',
|
||||
{
|
||||
callbacksLast: true,
|
||||
shorthandFirst: true,
|
||||
},
|
||||
],
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/self-closing-comp': [
|
||||
'error',
|
||||
{
|
||||
component: true,
|
||||
html: true,
|
||||
},
|
||||
],
|
||||
'react/jsx-pascal-case': 'error',
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-curly-brace-presence': ['error', 'never'],
|
||||
'react/display-name': 'off',
|
||||
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'warn',
|
||||
'no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{ allowShortCircuit: true, allowTernary: true },
|
||||
],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'after-used',
|
||||
caughtErrors: 'none',
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// Override for Storybook files
|
||||
{
|
||||
files: ['src/**/*.stories.{ts,tsx,js,jsx}'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"name": "@openmetadata/ui-core-components",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenMetadata shared UI core components and theme system",
|
||||
"main": "./dist/index.cjs.js",
|
||||
"module": "./dist/index.es.js",
|
||||
"types": "./dist/types/src/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/src/index.d.ts",
|
||||
"import": "./dist/index.es.js",
|
||||
"require": "./dist/index.cjs.js"
|
||||
},
|
||||
"./theme": {
|
||||
"types": "./dist/types/src/theme/index.d.ts",
|
||||
"import": "./dist/theme/index.es.js",
|
||||
"require": "./dist/theme/index.cjs.js"
|
||||
},
|
||||
"./colors": {
|
||||
"types": "./dist/types/src/colors/index.d.ts",
|
||||
"import": "./dist/colors/index.es.js",
|
||||
"require": "./dist/colors/index.cjs.js"
|
||||
},
|
||||
"./components": {
|
||||
"types": "./dist/types/src/components/index.d.ts",
|
||||
"import": "./dist/components/index.es.js",
|
||||
"require": "./dist/components/index.cjs.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/types/src/utils/index.d.ts",
|
||||
"import": "./dist/utils/index.es.js",
|
||||
"require": "./dist/utils/index.cjs.js"
|
||||
},
|
||||
"./globals.css": "./src/styles/globals.css"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src/styles/globals.css",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"dist/**/*.css"
|
||||
],
|
||||
"keywords": [
|
||||
"theme",
|
||||
"mui",
|
||||
"material-ui",
|
||||
"design-system",
|
||||
"core-components",
|
||||
"react",
|
||||
"typescript",
|
||||
"ui-library",
|
||||
"component-library"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"dev": "vite build --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "yarn clean && yarn build",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"lint": "eslint src/",
|
||||
"lint:base": "eslint --no-error-on-unmatched-pattern",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"pretty": "prettier --write src/",
|
||||
"pretty:base": "prettier --config './.prettierrc.yaml' --ignore-path './.prettierignore'",
|
||||
"pretty:check": "prettier --check src/",
|
||||
"ui-checkstyle:changed": "bash scripts/ui-checkstyle-changed.sh"
|
||||
},
|
||||
"author": "OpenMetadata",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@untitledui/file-icons": "^0.0.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": ">=11.0.0",
|
||||
"@emotion/styled": ">=11.0.0",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@mui/material": ">=7.0.0",
|
||||
"@mui/system": ">=7.0.0",
|
||||
"@mui/x-date-pickers": ">=8.0.0",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-stately/utils": "^3.11.0",
|
||||
"@tailwindcss/typography": ">=0.5.0",
|
||||
"@tailwindcss/vite": ">=4.0.0",
|
||||
"@untitledui/icons": "^0.0.21",
|
||||
"input-otp": "^1.4.2",
|
||||
"react": ">=18.0.0",
|
||||
"react-aria": "^3.47.0",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"tailwind-merge": ">=3.0.0",
|
||||
"tailwindcss": ">=4.0.0",
|
||||
"tailwindcss-animate": ">=1.0.0",
|
||||
"tailwindcss-react-aria-components": ">=2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@internationalized/date": "^3.11.0",
|
||||
"@mui/icons-material": "^7.3.4",
|
||||
"@mui/material": "^7.3.1",
|
||||
"@mui/system": "^7.3.1",
|
||||
"@mui/x-date-pickers": "^8.11.0",
|
||||
"@react-aria/utils": "^3.33.0",
|
||||
"@react-stately/utils": "^3.11.0",
|
||||
"@storybook/addon-essentials": "^8.6.18",
|
||||
"@storybook/addon-interactions": "^8.6.18",
|
||||
"@storybook/addon-links": "^8.6.18",
|
||||
"@storybook/blocks": "^8.6.18",
|
||||
"@storybook/react": "^8.6.18",
|
||||
"@storybook/react-vite": "^8.6.18",
|
||||
"@storybook/test": "^8.6.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@untitledui/icons": "^0.0.21",
|
||||
"@vitejs/plugin-react": "^5.0.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "^3.47.0",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"storybook": "^8.6.18",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tailwindcss-react-aria-components": "^2.0.1",
|
||||
"terser": "^5.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^17.4.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"prettier": "2.8.8"
|
||||
},
|
||||
"resolutions": {
|
||||
"fast-uri": "3.1.2",
|
||||
"lodash": "4.18.1",
|
||||
"minimatch": "10.2.3",
|
||||
"postcss": "8.5.10",
|
||||
"rollup": "4.59.0",
|
||||
"uuid": "^14.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "restricted",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.17.0",
|
||||
"yarn": ">=1.22.0"
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2021 Collate
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Runs ui-checkstyle only on changed files (vs origin/main) or on explicitly
|
||||
# passed files. Mirrors what the CI workflow does, making local fixes fast.
|
||||
#
|
||||
# Usage:
|
||||
# yarn ui-checkstyle:changed # auto-detect from git
|
||||
# yarn ui-checkstyle:changed src/components/Foo.tsx # explicit file(s)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
UI_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_ROOT="$(cd "$UI_DIR/../../../../.." && pwd)"
|
||||
UI_PREFIX="openmetadata-ui-core-components/src/main/resources/ui/"
|
||||
|
||||
cd "$UI_DIR"
|
||||
|
||||
declare -a FILES
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
FILES=("$@")
|
||||
else
|
||||
if git -C "$REPO_ROOT" rev-parse --verify origin/main &>/dev/null; then
|
||||
BASE=$(git -C "$REPO_ROOT" merge-base HEAD origin/main)
|
||||
else
|
||||
echo "origin/main not found locally — attempting to fetch..."
|
||||
if git -C "$REPO_ROOT" fetch origin main --depth=1 2>/dev/null; then
|
||||
BASE=$(git -C "$REPO_ROOT" merge-base HEAD origin/main)
|
||||
else
|
||||
echo "Fetch failed. Falling back to HEAD~1."
|
||||
BASE=$(git -C "$REPO_ROOT" rev-parse HEAD~1 2>/dev/null \
|
||||
|| git -C "$REPO_ROOT" rev-parse HEAD)
|
||||
fi
|
||||
fi
|
||||
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
FILES+=("$file")
|
||||
done < <(
|
||||
git -C "$REPO_ROOT" diff --name-only --diff-filter=ACM "$BASE" HEAD \
|
||||
| grep "^${UI_PREFIX}src/" \
|
||||
| grep -E '\.(ts|tsx|js|jsx|json)$' \
|
||||
| sed "s|^${UI_PREFIX}||"
|
||||
)
|
||||
fi
|
||||
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "No changed src files to process."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yarn lint:base --fix "${FILES[@]}"
|
||||
yarn pretty:base --write "${FILES[@]}"
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid hex color
|
||||
* @param color Color string to validate
|
||||
* @returns True if valid hex color
|
||||
*/
|
||||
export const isValidHexColor = (color: string): boolean => {
|
||||
if (!color || typeof color !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove # if present
|
||||
const hex = color.replace('#', '');
|
||||
|
||||
// Check if it's 3 or 6 character hex
|
||||
if (!/^[0-9A-Fa-f]{3}$/.test(hex) && !/^[0-9A-Fa-f]{6}$/.test(hex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes hex color to 6-character format with #
|
||||
* @param color Hex color string
|
||||
* @returns Normalized hex color or null if invalid
|
||||
*/
|
||||
export const normalizeHexColor = (color: string): string | null => {
|
||||
if (!isValidHexColor(color)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hex = color.replace('#', '');
|
||||
|
||||
// Convert 3-char hex to 6-char
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('');
|
||||
}
|
||||
|
||||
return `#${hex.toUpperCase()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates luminance of a color for accessibility checks
|
||||
* @param hex Hex color string
|
||||
* @returns Luminance value (0-1)
|
||||
*/
|
||||
export const getLuminance = (hex: string): number => {
|
||||
const normalizedHex = normalizeHexColor(hex);
|
||||
if (!normalizedHex) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const r = parseInt(normalizedHex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalizedHex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalizedHex.slice(5, 7), 16) / 255;
|
||||
|
||||
// Apply gamma correction
|
||||
const toLinear = (c: number) =>
|
||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates contrast ratio between two colors
|
||||
* @param color1 First hex color
|
||||
* @param color2 Second hex color
|
||||
* @returns Contrast ratio (1-21)
|
||||
*/
|
||||
export const getContrastRatio = (color1: string, color2: string): number => {
|
||||
const lum1 = getLuminance(color1);
|
||||
const lum2 = getLuminance(color2);
|
||||
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if color meets WCAG AA accessibility standards
|
||||
* @param foreground Foreground color hex
|
||||
* @param background Background color hex
|
||||
* @returns True if meets WCAG AA (4.5:1 ratio)
|
||||
*/
|
||||
export const meetsWCAGAA = (
|
||||
foreground: string,
|
||||
background: string
|
||||
): boolean => {
|
||||
return getContrastRatio(foreground, background) >= 4.5;
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview RESTRICTED: This file should only be imported by createMuiTheme.ts
|
||||
* @internal Use theme.palette.allShades for accessing colors in components
|
||||
*
|
||||
* This file contains default static colors that are used as fallbacks when
|
||||
* no custom colors are provided. These should not be used directly in components.
|
||||
* Instead, use the MUI theme system:
|
||||
*
|
||||
* ✅ Correct usage:
|
||||
* const theme = useTheme();
|
||||
* theme.palette.allShades.brand[600]
|
||||
*
|
||||
* ❌ Incorrect usage:
|
||||
* import { defaultColors } from './defaultColors';
|
||||
* defaultColors.brand[600]
|
||||
*/
|
||||
|
||||
import type { ThemeColors } from '../types';
|
||||
|
||||
/**
|
||||
* Default static colors for theme system
|
||||
* Used as fallback when no custom colors are provided
|
||||
* @internal Only for use by createMuiTheme.ts
|
||||
*/
|
||||
export const defaultColors: ThemeColors = {
|
||||
white: '#ffffff',
|
||||
brand: {
|
||||
25: '#F5FAFF',
|
||||
50: '#EFF8FF',
|
||||
100: '#D1E9FF',
|
||||
200: '#B2DDFF',
|
||||
300: '#84CAFF',
|
||||
400: '#53B1FD',
|
||||
500: '#2E90FA',
|
||||
600: '#1570EF',
|
||||
700: '#175CD3',
|
||||
800: '#1849A9',
|
||||
900: '#194185',
|
||||
950: '#102A56',
|
||||
},
|
||||
gray: {
|
||||
25: '#FDFDFD',
|
||||
50: '#FAFAFA',
|
||||
100: '#F5F5F5',
|
||||
200: '#E9EAEB',
|
||||
300: '#D5D7DA',
|
||||
400: '#A4A7AE',
|
||||
500: '#717680',
|
||||
600: '#535862',
|
||||
700: '#414651',
|
||||
725: '#363636', // custom shade for search icon
|
||||
750: '#404040', // custom shade
|
||||
800: '#252B37',
|
||||
900: '#181D27',
|
||||
950: '#0A0D12',
|
||||
},
|
||||
blueGray: {
|
||||
25: '#FCFCFD',
|
||||
40: '#FAFAFA', // custom shade for search field background
|
||||
50: '#F8F9FC',
|
||||
75: '#EFF4FA', // custom shade for chips
|
||||
100: '#EAECF5',
|
||||
125: '#ECEDF7', // custom shade for card header fills
|
||||
150: '#D2DBEA', // custom shade for chip borders
|
||||
200: '#D5D9EB',
|
||||
250: '#DFDFDF', // custom shade for search field borders
|
||||
300: '#AFB5D9',
|
||||
400: '#717BBC',
|
||||
500: '#4E5BA6',
|
||||
600: '#3E4784',
|
||||
700: '#363F72',
|
||||
800: '#293056',
|
||||
900: '#101323',
|
||||
950: '#0C111D',
|
||||
},
|
||||
blue: {
|
||||
25: '#F5FAFF',
|
||||
50: '#EFF8FF',
|
||||
100: '#D1E9FF',
|
||||
200: '#B2DDFF',
|
||||
300: '#84CAFF',
|
||||
400: '#53B1FD',
|
||||
500: '#2E90FA',
|
||||
600: '#1570EF',
|
||||
700: '#175CD3',
|
||||
800: '#1849A9',
|
||||
900: '#194185',
|
||||
950: '#102A56',
|
||||
},
|
||||
blueLight: {
|
||||
25: '#F5FBFF',
|
||||
50: '#F0F9FF',
|
||||
100: '#E0F2FE',
|
||||
200: '#B9E6FE',
|
||||
300: '#7CD4FD',
|
||||
400: '#36BFFA',
|
||||
500: '#0BA5EC',
|
||||
600: '#0086C9',
|
||||
700: '#026AA2',
|
||||
800: '#065986',
|
||||
900: '#0B4A6F',
|
||||
950: '#0B4A6F',
|
||||
},
|
||||
indigo: {
|
||||
25: '#F5F8FF',
|
||||
50: '#EEF4FF',
|
||||
100: '#E0EAFF',
|
||||
200: '#C7D7FE',
|
||||
300: '#A4BCFD',
|
||||
400: '#8098F9',
|
||||
500: '#6172F3',
|
||||
600: '#444CE7',
|
||||
700: '#3538CD',
|
||||
800: '#2D31A6',
|
||||
900: '#2D3282',
|
||||
950: '#2D3282',
|
||||
},
|
||||
purple: {
|
||||
25: '#FAFAFF',
|
||||
50: '#F4F3FF',
|
||||
100: '#EBE9FE',
|
||||
200: '#D9D6FE',
|
||||
300: '#BDB4FE',
|
||||
400: '#9B8AFB',
|
||||
500: '#7A5AF8',
|
||||
600: '#6938EF',
|
||||
700: '#5925DC',
|
||||
800: '#4A1FB8',
|
||||
900: '#3E1C96',
|
||||
950: '#3E1C96',
|
||||
},
|
||||
pink: {
|
||||
25: '#FEF6FB',
|
||||
50: '#FDF2FA',
|
||||
100: '#FCE7F6',
|
||||
200: '#FCCEEE',
|
||||
300: '#FAA7E0',
|
||||
400: '#F670C7',
|
||||
500: '#EE46BC',
|
||||
600: '#DD2590',
|
||||
700: '#C11574',
|
||||
800: '#9E165F',
|
||||
900: '#851651',
|
||||
950: '#851651',
|
||||
},
|
||||
rose: {
|
||||
25: '#FFF5F6',
|
||||
50: '#FFF1F3',
|
||||
100: '#FFE4E8',
|
||||
200: '#FECDD6',
|
||||
300: '#FEA3B4',
|
||||
400: '#FD6F8E',
|
||||
500: '#F63D68',
|
||||
600: '#E31B54',
|
||||
700: '#C01048',
|
||||
800: '#A11043',
|
||||
900: '#89123E',
|
||||
950: '#89123E',
|
||||
},
|
||||
orange: {
|
||||
25: '#FFFAF5',
|
||||
50: '#FFF6ED',
|
||||
100: '#FFEAD5',
|
||||
200: '#FDDCAB',
|
||||
300: '#FEB273',
|
||||
400: '#FD853A',
|
||||
500: '#FB6514',
|
||||
600: '#EC4A0A',
|
||||
700: '#C4320A',
|
||||
800: '#9C2A10',
|
||||
900: '#7E2410',
|
||||
950: '#7E2410',
|
||||
},
|
||||
error: {
|
||||
25: '#FFFBFA',
|
||||
50: '#FEF3F2',
|
||||
100: '#FEE4E2',
|
||||
200: '#FECDCA',
|
||||
300: '#FDA29B',
|
||||
400: '#F97066',
|
||||
500: '#F04438',
|
||||
600: '#D92D20',
|
||||
700: '#B42318',
|
||||
800: '#912018',
|
||||
900: '#7A271A',
|
||||
950: '#55160C',
|
||||
},
|
||||
warning: {
|
||||
25: '#FFFCF5',
|
||||
50: '#FFFAEB',
|
||||
100: '#FEF0C7',
|
||||
200: '#FEDF89',
|
||||
300: '#FEC84B',
|
||||
400: '#FDB022',
|
||||
500: '#F79009',
|
||||
600: '#DC6803',
|
||||
700: '#B54708',
|
||||
800: '#93370D',
|
||||
900: '#7A2E0E',
|
||||
950: '#511C10',
|
||||
},
|
||||
success: {
|
||||
25: '#F6FEF9',
|
||||
50: '#ECFDF3',
|
||||
100: '#D1FADF',
|
||||
200: '#A6F4C5',
|
||||
300: '#6CE9A6',
|
||||
400: '#32D583',
|
||||
500: '#17B26A',
|
||||
600: '#039855',
|
||||
700: '#027A48',
|
||||
800: '#05603A',
|
||||
900: '#054F31',
|
||||
950: '#052E16',
|
||||
},
|
||||
info: {
|
||||
25: '#F5FAFF',
|
||||
50: '#EFF8FF',
|
||||
100: '#D1E9FF',
|
||||
200: '#B2DDFF',
|
||||
300: '#84CAFF',
|
||||
400: '#53B1FD',
|
||||
500: '#2E90FA',
|
||||
600: '#1570EF',
|
||||
700: '#175CD3',
|
||||
800: '#1849A9',
|
||||
900: '#194185',
|
||||
950: '#102A56',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const ENTITY_PALETTE_HEX: string[] = [
|
||||
'#1470EF',
|
||||
'#7D81E9',
|
||||
'#F14C75',
|
||||
'#F689A6',
|
||||
'#05C4EA',
|
||||
'#05A580',
|
||||
'#FFB01A',
|
||||
'#BF4CF1',
|
||||
'#99AADF',
|
||||
'#C0B3F2',
|
||||
'#EDB3B3',
|
||||
'#ECB892',
|
||||
'#90DAE3',
|
||||
'#82E6C4',
|
||||
];
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
argbFromHex,
|
||||
hexFromArgb,
|
||||
themeFromSourceColor,
|
||||
} from '@material/material-color-utilities';
|
||||
import { normalizeHexColor } from './colorValidation';
|
||||
import type {
|
||||
ColorPalette,
|
||||
CustomColors,
|
||||
DynamicPalettes,
|
||||
ThemeColors,
|
||||
} from '../types';
|
||||
|
||||
// No conversion needed - return hex directly
|
||||
function normalizeHex(hex: string): string {
|
||||
return hex.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a color palette using Google's Material Design color utilities
|
||||
* Uses perceptually uniform color science for harmonious color relationships
|
||||
* @param baseColor HEX like "#9E77ED"
|
||||
* @returns UntitledUI-style 12-step color palette
|
||||
*/
|
||||
export const generateMaterialPalette = (baseColor: string): ColorPalette => {
|
||||
const source = argbFromHex(baseColor);
|
||||
const theme = themeFromSourceColor(source);
|
||||
|
||||
const palette = theme.palettes.primary;
|
||||
const tones = [99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 5];
|
||||
|
||||
const shadeKeys: Array<keyof ColorPalette> = [
|
||||
25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
|
||||
];
|
||||
|
||||
const result = {} as ColorPalette;
|
||||
shadeKeys.forEach((key, index) => {
|
||||
if (key === 600) {
|
||||
// Shade 600 should exactly match user input
|
||||
result[key] = normalizeHex(baseColor);
|
||||
} else {
|
||||
// Generate other shades using Material Design
|
||||
result[key] = normalizeHex(hexFromArgb(palette.tone(tones[index])));
|
||||
}
|
||||
});
|
||||
|
||||
return result as ColorPalette;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates MUI color palette from hex color using Material Design color utilities
|
||||
* @param baseColor Hex color string (e.g., "#1570ef")
|
||||
* @returns Object with UntitledUI-style color shades (25-950)
|
||||
*/
|
||||
// Default fallback palette
|
||||
const getDefaultPalette = (): ColorPalette => ({
|
||||
25: '#F8FAFC',
|
||||
50: '#F1F5F9',
|
||||
100: '#E2E8F0',
|
||||
200: '#CBD5E1',
|
||||
300: '#94A3B8',
|
||||
400: '#64748B',
|
||||
500: '#475569',
|
||||
600: '#334155',
|
||||
700: '#1E293B',
|
||||
800: '#0F172A',
|
||||
900: '#020617',
|
||||
950: '#010206',
|
||||
});
|
||||
|
||||
export const generateMuiPalette = (baseColor: string): ColorPalette => {
|
||||
// Validate and normalize input color
|
||||
const normalizedColor = normalizeHexColor(baseColor);
|
||||
if (!normalizedColor) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Invalid hex color provided: ${baseColor}`);
|
||||
|
||||
return getDefaultPalette();
|
||||
}
|
||||
|
||||
try {
|
||||
return generateMaterialPalette(normalizedColor);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Failed to generate palette for ${baseColor}:`, error);
|
||||
|
||||
return getDefaultPalette();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Default theme colors fallback
|
||||
*/
|
||||
const DEFAULT_THEME_FALLBACK = {
|
||||
primaryColor: '#1570ef',
|
||||
hoverColor: '#d1e9ff',
|
||||
selectedColor: '#175cd3',
|
||||
infoColor: '#84caff',
|
||||
successColor: '#039855',
|
||||
warningColor: '#DC6803',
|
||||
errorColor: '#D92D20',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates all color palettes for MUI theme
|
||||
* Uses user customizations or falls back to static colors from theme
|
||||
*/
|
||||
export const generateAllMuiPalettes = (
|
||||
customColors?: CustomColors,
|
||||
staticColors?: ThemeColors,
|
||||
defaultTheme = DEFAULT_THEME_FALLBACK
|
||||
): DynamicPalettes => {
|
||||
let brandPalette: ColorPalette | undefined;
|
||||
|
||||
if (defaultTheme.primaryColor !== customColors?.primaryColor) {
|
||||
brandPalette = generateMuiPalette(
|
||||
customColors?.primaryColor || defaultTheme.primaryColor
|
||||
);
|
||||
} else {
|
||||
brandPalette = staticColors?.brand;
|
||||
}
|
||||
|
||||
if (
|
||||
brandPalette &&
|
||||
customColors?.hoverColor &&
|
||||
defaultTheme.hoverColor !== customColors.hoverColor
|
||||
) {
|
||||
brandPalette = {
|
||||
...brandPalette,
|
||||
100: normalizeHex(customColors.hoverColor),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
brandPalette &&
|
||||
customColors?.selectedColor &&
|
||||
defaultTheme.selectedColor !== customColors.selectedColor
|
||||
) {
|
||||
brandPalette = {
|
||||
...brandPalette,
|
||||
700: normalizeHex(customColors.selectedColor),
|
||||
};
|
||||
}
|
||||
|
||||
const result: DynamicPalettes = {
|
||||
brand: brandPalette,
|
||||
|
||||
info:
|
||||
defaultTheme.infoColor !== customColors?.infoColor
|
||||
? generateMuiPalette(customColors?.infoColor || defaultTheme.infoColor)
|
||||
: staticColors?.info,
|
||||
|
||||
success:
|
||||
defaultTheme.successColor !== customColors?.successColor
|
||||
? generateMuiPalette(
|
||||
customColors?.successColor || defaultTheme.successColor
|
||||
)
|
||||
: staticColors?.success,
|
||||
|
||||
warning:
|
||||
defaultTheme.warningColor !== customColors?.warningColor
|
||||
? generateMuiPalette(
|
||||
customColors?.warningColor || defaultTheme.warningColor
|
||||
)
|
||||
: staticColors?.warning,
|
||||
|
||||
error:
|
||||
defaultTheme.errorColor !== customColors?.errorColor
|
||||
? generateMuiPalette(
|
||||
customColors?.errorColor || defaultTheme.errorColor
|
||||
)
|
||||
: staticColors?.error,
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// Color system exports
|
||||
export { defaultColors } from './defaultColors';
|
||||
export * from './generateMuiPalettes';
|
||||
export * from './colorValidation';
|
||||
export * from './entityPalette';
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { ChevronDown } from '@untitledui/icons';
|
||||
import type {
|
||||
ButtonProps as AriaButtonProps,
|
||||
DisclosureGroupProps as AriaDisclosureGroupProps,
|
||||
DisclosurePanelProps as AriaDisclosurePanelProps,
|
||||
DisclosureProps as AriaDisclosureProps,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Disclosure as AriaDisclosure,
|
||||
DisclosureGroup as AriaDisclosureGroup,
|
||||
DisclosurePanel as AriaDisclosurePanel,
|
||||
DisclosureStateContext,
|
||||
Heading as AriaHeading,
|
||||
} from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export interface AccordionProps extends AriaDisclosureGroupProps {
|
||||
/**
|
||||
* Optional className for the accordion container.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type AccordionItemProps = AriaDisclosureProps;
|
||||
|
||||
export interface AccordionHeaderProps extends AriaButtonProps {
|
||||
/**
|
||||
* The content to display in the accordion header trigger.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* When false, renders children directly without the grow-span wrapper or
|
||||
* built-in chevron — gives children full layout control (e.g. custom grids).
|
||||
* Defaults to true.
|
||||
*/
|
||||
showChevron?: boolean;
|
||||
}
|
||||
|
||||
export interface AccordionPanelProps extends AriaDisclosurePanelProps {
|
||||
/**
|
||||
* The content to display when the accordion item is expanded.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional className for the panel content container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* When true, children are unmounted while the panel is collapsed instead of
|
||||
* just visually hidden. Use for heavy static content; leave false (default)
|
||||
* for content with internal state (e.g. form inputs), which would otherwise
|
||||
* reset on collapse.
|
||||
*/
|
||||
unmountOnCollapse?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion is a grouped set of expandable/collapsible sections, built on
|
||||
* react-aria DisclosureGroup for full accessibility support.
|
||||
*
|
||||
* @example
|
||||
* <Accordion>
|
||||
* <AccordionItem id="item-1">
|
||||
* <AccordionHeader>Section 1</AccordionHeader>
|
||||
* <AccordionPanel>Content for section 1</AccordionPanel>
|
||||
* </AccordionItem>
|
||||
* <AccordionItem id="item-2">
|
||||
* <AccordionHeader>Section 2</AccordionHeader>
|
||||
* <AccordionPanel>Content for section 2</AccordionPanel>
|
||||
* </AccordionItem>
|
||||
* </Accordion>
|
||||
*/
|
||||
export const Accordion = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AccordionProps) => {
|
||||
return (
|
||||
<AriaDisclosureGroup
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:w-full tw:divide-y tw:divide-border-secondary tw:rounded-xl tw:ring-1 tw:ring-border-secondary tw:overflow-hidden',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</AriaDisclosureGroup>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* AccordionItem represents a single collapsible section within an Accordion.
|
||||
* Must receive a unique `id` prop for the DisclosureGroup to track expanded state.
|
||||
*/
|
||||
export const AccordionItem = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AccordionItemProps) => {
|
||||
return (
|
||||
<AriaDisclosure
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:group/item tw:w-full tw:bg-primary',
|
||||
state.isDisabled && 'tw:cursor-not-allowed tw:opacity-50',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{children}
|
||||
</AriaDisclosure>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* AccordionHeader renders the clickable trigger button for an AccordionItem.
|
||||
* It displays the header text and an animated chevron icon.
|
||||
*/
|
||||
export const AccordionHeader = ({
|
||||
children,
|
||||
className,
|
||||
showChevron = true,
|
||||
...props
|
||||
}: AccordionHeaderProps) => {
|
||||
return (
|
||||
<AriaHeading className="tw:m-0">
|
||||
<AriaButton
|
||||
slot="trigger"
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:justify-between tw:gap-3 tw:px-6 tw:py-4 tw:text-left tw:outline-hidden tw:transition tw:duration-200 tw:ease-in-out',
|
||||
'tw:text-sm tw:font-semibold tw:text-primary',
|
||||
'hover:tw:bg-primary_hover',
|
||||
state.isFocusVisible && 'tw:ring-2 tw:ring-inset tw:ring-brand-300',
|
||||
state.isDisabled && 'tw:cursor-not-allowed tw:text-disabled',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{showChevron ? (
|
||||
<>
|
||||
<span className="tw:grow">{children}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="tw:size-5 tw:shrink-0 tw:text-fg-quaternary tw:transition-transform tw:duration-200 tw:ease-in-out tw:group-data-expanded/item:rotate-180"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AriaButton>
|
||||
</AriaHeading>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* AccordionPanel is the collapsible content area of an AccordionItem.
|
||||
*/
|
||||
export const AccordionPanel = ({
|
||||
children,
|
||||
className,
|
||||
unmountOnCollapse = false,
|
||||
...props
|
||||
}: AccordionPanelProps) => {
|
||||
const state = useContext(DisclosureStateContext);
|
||||
const shouldRenderChildren = !unmountOnCollapse || state?.isExpanded;
|
||||
|
||||
return (
|
||||
<AriaDisclosurePanel
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:overflow-hidden tw:border-t tw:border-border-secondary tw:px-6 tw:py-4 tw:text-sm tw:text-secondary',
|
||||
!state?.isExpanded && 'tw:hidden',
|
||||
className
|
||||
)}>
|
||||
{shouldRenderChildren ? children : null}
|
||||
</AriaDisclosurePanel>
|
||||
);
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { X as CloseIcon, Home01, Menu02 } from '@untitledui/icons';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger as AriaDialogTrigger,
|
||||
Modal as AriaModal,
|
||||
ModalOverlay as AriaModalOverlay,
|
||||
} from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export const MobileNavigationHeader = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AriaDialogTrigger>
|
||||
<header className="tw:flex tw:h-16 tw:items-center tw:justify-between tw:border-b tw:border-secondary tw:bg-primary tw:py-3 tw:pr-2 tw:pl-4 tw:lg:hidden">
|
||||
<Home01 className="tw:size-8 tw:text-fg-primary" />
|
||||
|
||||
<AriaButton
|
||||
aria-label="Expand navigation menu"
|
||||
className="tw:group tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-primary tw:p-2 tw:text-fg-secondary tw:outline-focus-ring tw:hover:bg-primary_hover tw:hover:text-fg-secondary_hover tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2">
|
||||
<Menu02 className="tw:size-6 tw:transition tw:duration-200 tw:ease-in-out tw:group-aria-expanded:opacity-0" />
|
||||
<CloseIcon className="tw:absolute tw:size-6 tw:opacity-0 tw:transition tw:duration-200 tw:ease-in-out tw:group-aria-expanded:opacity-100" />
|
||||
</AriaButton>
|
||||
</header>
|
||||
|
||||
<AriaModalOverlay
|
||||
isDismissable
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
'tw:fixed tw:inset-0 tw:z-50 tw:cursor-pointer tw:bg-overlay/70 tw:pr-16 tw:backdrop-blur-md tw:lg:hidden',
|
||||
isEntering &&
|
||||
'tw:duration-300 tw:ease-in-out tw:animate-in tw:fade-in',
|
||||
isExiting &&
|
||||
'tw:duration-200 tw:ease-in-out tw:animate-out tw:fade-out'
|
||||
)
|
||||
}>
|
||||
{({ state }) => (
|
||||
<>
|
||||
<AriaButton
|
||||
aria-label="Close navigation menu"
|
||||
className="tw:fixed tw:top-3 tw:right-2 tw:flex tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-lg tw:p-2 tw:text-fg-white/70 tw:outline-focus-ring tw:hover:bg-white/10 tw:hover:text-fg-white tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2"
|
||||
onPress={() => state.close()}>
|
||||
<CloseIcon className="tw:size-6" />
|
||||
</AriaButton>
|
||||
|
||||
<AriaModal className="tw:w-full tw:cursor-auto tw:will-change-transform">
|
||||
<AriaDialog className="tw:h-dvh tw:outline-hidden tw:focus:outline-hidden">
|
||||
{children}
|
||||
</AriaDialog>
|
||||
</AriaModal>
|
||||
</>
|
||||
)}
|
||||
</AriaModalOverlay>
|
||||
</AriaDialogTrigger>
|
||||
);
|
||||
};
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
import { AvatarLabelGroup } from '@/components/base/avatar/avatar-label-group';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { RadioButtonBase } from '@/components/base/radio-buttons/radio-buttons';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Placement } from '@react-types/overlays';
|
||||
import {
|
||||
BookOpen01,
|
||||
ChevronSelectorVertical,
|
||||
LogOut01,
|
||||
Plus,
|
||||
Settings01,
|
||||
User01,
|
||||
} from '@untitledui/icons';
|
||||
import type { FC, HTMLAttributes } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useFocusManager } from 'react-aria';
|
||||
import type { DialogProps as AriaDialogProps } from 'react-aria-components';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger as AriaDialogTrigger,
|
||||
Popover as AriaPopover,
|
||||
} from 'react-aria-components';
|
||||
|
||||
type NavAccountType = {
|
||||
/** Unique identifier for the nav item. */
|
||||
id: string;
|
||||
/** Name of the account holder. */
|
||||
name: string;
|
||||
/** Email address of the account holder. */
|
||||
email: string;
|
||||
/** Avatar image URL. */
|
||||
avatar: string;
|
||||
/** Online status of the account holder. This is used to display the online status indicator. */
|
||||
status: 'online' | 'offline';
|
||||
};
|
||||
|
||||
const placeholderAccounts: NavAccountType[] = [
|
||||
{
|
||||
id: 'olivia',
|
||||
name: 'Olivia Rhye',
|
||||
email: 'olivia@untitledui.com',
|
||||
avatar:
|
||||
'https://www.untitledui.com/images/avatars/olivia-rhye?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',
|
||||
},
|
||||
];
|
||||
|
||||
const NavAccountCardMenuItem = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortcut,
|
||||
...buttonProps
|
||||
}: {
|
||||
icon?: FC<{ className?: string }>;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
} & HTMLAttributes<HTMLButtonElement>) => {
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={cx(
|
||||
'tw:group/item tw:w-full tw:cursor-pointer tw:px-1.5 tw:focus:outline-hidden',
|
||||
buttonProps.className
|
||||
)}>
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:w-full tw:items-center tw:justify-between tw:gap-3 tw:rounded-md tw:p-2 tw:group-hover/item:bg-primary_hover',
|
||||
'tw:outline-focus-ring tw:group-focus-visible/item:outline-2 tw:group-focus-visible/item:outline-offset-2'
|
||||
)}>
|
||||
<div className="tw:flex tw:gap-2 tw:text-sm tw:font-semibold tw:text-secondary tw:group-hover/item:text-secondary_hover">
|
||||
{Icon && <Icon className="tw:size-5 tw:text-fg-quaternary" />} {label}
|
||||
</div>
|
||||
|
||||
{shortcut && (
|
||||
<kbd className="tw:flex tw:rounded tw:px-1 tw:py-px tw:font-body tw:text-xs tw:font-medium tw:text-tertiary tw:ring-1 tw:ring-secondary tw:ring-inset">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NavAccountMenu = ({
|
||||
className,
|
||||
accounts = placeholderAccounts,
|
||||
selectedAccountId = placeholderAccounts[0].id,
|
||||
...dialogProps
|
||||
}: AriaDialogProps & {
|
||||
className?: string;
|
||||
accounts?: NavAccountType[];
|
||||
selectedAccountId?: string;
|
||||
}) => {
|
||||
const focusManager = useFocusManager();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
focusManager?.focusNext({ tabbable: true, wrap: true });
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
focusManager?.focusPrevious({ tabbable: true, wrap: true });
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
[focusManager]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialogRef.current;
|
||||
if (element) {
|
||||
element.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (element) {
|
||||
element.removeEventListener('keydown', onKeyDown);
|
||||
}
|
||||
};
|
||||
}, [onKeyDown]);
|
||||
|
||||
return (
|
||||
<AriaDialog
|
||||
{...dialogProps}
|
||||
className={cx(
|
||||
'tw:w-66 tw:rounded-xl tw:bg-secondary_alt tw:shadow-lg tw:ring tw:ring-secondary_alt tw:outline-hidden',
|
||||
className
|
||||
)}
|
||||
ref={dialogRef}>
|
||||
<div className="tw:rounded-xl tw:bg-primary tw:ring-1 tw:ring-secondary">
|
||||
<div className="tw:flex tw:flex-col tw:gap-0.5 tw:py-1.5">
|
||||
<NavAccountCardMenuItem
|
||||
icon={User01}
|
||||
label="View profile"
|
||||
shortcut="⌘K->P"
|
||||
/>
|
||||
<NavAccountCardMenuItem
|
||||
icon={Settings01}
|
||||
label="Account settings"
|
||||
shortcut="⌘S"
|
||||
/>
|
||||
<NavAccountCardMenuItem icon={BookOpen01} label="Documentation" />
|
||||
</div>
|
||||
<div className="tw:flex tw:flex-col tw:gap-0.5 tw:border-t tw:border-secondary tw:py-1.5">
|
||||
<div className="tw:px-3 tw:pt-1.5 tw:pb-1 tw:text-xs tw:font-semibold tw:text-tertiary">
|
||||
Switch account
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:flex-col tw:gap-0.5 tw:px-1.5">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
className={cx(
|
||||
'tw:relative tw:w-full tw:cursor-pointer tw:rounded-md tw:px-2 tw:py-1.5 tw:text-left tw:outline-focus-ring tw:hover:bg-primary_hover tw:focus:z-10 tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
account.id === selectedAccountId && 'tw:bg-primary_hover'
|
||||
)}
|
||||
key={account.id}>
|
||||
<AvatarLabelGroup
|
||||
size="md"
|
||||
src={account.avatar}
|
||||
status="online"
|
||||
subtitle={account.email}
|
||||
title={account.name}
|
||||
/>
|
||||
|
||||
<RadioButtonBase
|
||||
className="tw:absolute tw:top-2 tw:right-2"
|
||||
isSelected={account.id === selectedAccountId}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tw:flex tw:flex-col tw:gap-2 tw:px-2 tw:pt-0.5 tw:pb-2">
|
||||
<Button color="secondary" iconLeading={Plus} size="sm">
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tw:pt-1 tw:pb-1.5">
|
||||
<NavAccountCardMenuItem
|
||||
icon={LogOut01}
|
||||
label="Sign out"
|
||||
shortcut="⌥⇧Q"
|
||||
/>
|
||||
</div>
|
||||
</AriaDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const NavAccountCard = ({
|
||||
popoverPlacement,
|
||||
selectedAccountId,
|
||||
items = placeholderAccounts,
|
||||
}: {
|
||||
popoverPlacement?: Placement;
|
||||
selectedAccountId?: string;
|
||||
items?: NavAccountType[];
|
||||
}) => {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const isDesktop = useBreakpoint('lg');
|
||||
|
||||
const resolvedSelectedId = selectedAccountId ?? items[0]?.id;
|
||||
const selectedAccount = items.find(
|
||||
(account) => account.id === resolvedSelectedId
|
||||
);
|
||||
|
||||
if (!selectedAccount) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Account with ID ${resolvedSelectedId} not found in <NavAccountCard />`
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tw:relative tw:flex tw:items-center tw:gap-3 tw:rounded-xl tw:p-3 tw:ring-1 tw:ring-secondary tw:ring-inset"
|
||||
ref={triggerRef}>
|
||||
<AvatarLabelGroup
|
||||
size="md"
|
||||
src={selectedAccount.avatar}
|
||||
status={selectedAccount.status}
|
||||
subtitle={selectedAccount.email}
|
||||
title={selectedAccount.name}
|
||||
/>
|
||||
|
||||
<div className="tw:absolute tw:top-1.5 tw:right-1.5">
|
||||
<AriaDialogTrigger>
|
||||
<AriaButton className="tw:flex tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-md tw:p-1.5 tw:text-fg-quaternary tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:hover:bg-primary_hover tw:hover:text-fg-quaternary_hover tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw:pressed:bg-primary_hover tw:pressed:text-fg-quaternary_hover">
|
||||
<ChevronSelectorVertical className="tw:size-4 tw:shrink-0" />
|
||||
</AriaButton>
|
||||
<AriaPopover
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
'tw:origin-(--trigger-anchor-point) tw:will-change-transform',
|
||||
isEntering &&
|
||||
'tw:duration-150 tw:ease-out tw:animate-in tw:fade-in tw:placement-right:slide-in-from-left-0.5 tw:placement-top:slide-in-from-bottom-0.5 tw:placement-bottom:slide-in-from-top-0.5',
|
||||
isExiting &&
|
||||
'tw:duration-100 tw:ease-in tw:animate-out tw:fade-out tw:placement-right:slide-out-to-left-0.5 tw:placement-top:slide-out-to-bottom-0.5 tw:placement-bottom:slide-out-to-top-0.5'
|
||||
)
|
||||
}
|
||||
offset={8}
|
||||
placement={
|
||||
popoverPlacement ?? (isDesktop ? 'right bottom' : 'top right')
|
||||
}
|
||||
triggerRef={triggerRef}>
|
||||
<NavAccountMenu
|
||||
accounts={items}
|
||||
selectedAccountId={resolvedSelectedId}
|
||||
/>
|
||||
</AriaPopover>
|
||||
</AriaDialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
import { Pressable } from 'react-aria-components';
|
||||
import { Tooltip } from '@/components/base/tooltip/tooltip';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const styles = {
|
||||
md: {
|
||||
root: 'tw:size-10',
|
||||
icon: 'tw:size-5',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:size-12',
|
||||
icon: 'tw:size-6',
|
||||
},
|
||||
};
|
||||
|
||||
interface NavItemButtonProps {
|
||||
/** Whether the collapsible nav item is open. */
|
||||
open?: boolean;
|
||||
/** URL to navigate to when the button is clicked. */
|
||||
href?: string;
|
||||
/** Label text for the button. */
|
||||
label: string;
|
||||
/** Icon component to display. */
|
||||
icon: FC<{ className?: string }>;
|
||||
/** Whether the button is currently active. */
|
||||
current?: boolean;
|
||||
/** Size of the button. */
|
||||
size?: 'md' | 'lg';
|
||||
/** Handler for click events. */
|
||||
onClick?: MouseEventHandler;
|
||||
/** Additional CSS classes to apply to the button. */
|
||||
className?: string;
|
||||
/** Placement of the tooltip. */
|
||||
tooltipPlacement?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
export const NavItemButton = ({
|
||||
current: current,
|
||||
label,
|
||||
href,
|
||||
icon: Icon,
|
||||
size = 'md',
|
||||
className,
|
||||
tooltipPlacement = 'right',
|
||||
onClick,
|
||||
}: NavItemButtonProps) => {
|
||||
return (
|
||||
<Tooltip placement={tooltipPlacement} title={label}>
|
||||
<Pressable>
|
||||
<a
|
||||
aria-label={label}
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-md tw:bg-primary tw:p-2 tw:text-fg-quaternary tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:select-none tw:hover:bg-primary_hover tw:hover:text-fg-quaternary_hover tw:focus-visible:z-10 tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
current &&
|
||||
'tw:bg-active tw:text-fg-quaternary_hover tw:hover:bg-secondary_hover',
|
||||
styles[size].root,
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
onClick={onClick}>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:shrink-0 tw:transition-inherit-all',
|
||||
styles[size].icon
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
</Pressable>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import type { FC, HTMLAttributes, MouseEventHandler, ReactNode } from 'react';
|
||||
import { ChevronDown, Share04 } from '@untitledui/icons';
|
||||
import { Link as AriaLink } from 'react-aria-components';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { cx, sortCx } from '@/utils/cx';
|
||||
|
||||
const styles = sortCx({
|
||||
root: 'tw:group tw:relative tw:flex tw:w-full tw:cursor-pointer tw:items-center tw:rounded-md tw:bg-primary tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:select-none tw:hover:bg-primary_hover tw:focus-visible:z-10 tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
rootSelected: 'tw:bg-active tw:hover:bg-secondary_hover',
|
||||
});
|
||||
|
||||
interface NavItemBaseProps {
|
||||
/** Whether the nav item shows only an icon. */
|
||||
iconOnly?: boolean;
|
||||
/** Whether the collapsible nav item is open. */
|
||||
open?: boolean;
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href?: string;
|
||||
/** Type of the nav item. */
|
||||
type: 'link' | 'collapsible' | 'collapsible-child';
|
||||
/** Icon component to display. */
|
||||
icon?: FC<HTMLAttributes<HTMLOrSVGElement>>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** Whether the nav item is currently active. */
|
||||
current?: boolean;
|
||||
/** Whether to truncate the label text. */
|
||||
truncate?: boolean;
|
||||
/** Handler for click events. */
|
||||
onClick?: MouseEventHandler;
|
||||
/** Content to display. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const NavItemBase = ({
|
||||
current,
|
||||
type,
|
||||
badge,
|
||||
href,
|
||||
icon: Icon,
|
||||
children,
|
||||
truncate = true,
|
||||
onClick,
|
||||
}: NavItemBaseProps) => {
|
||||
const iconElement = Icon && (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="tw:mr-2 tw:size-5 tw:shrink-0 tw:text-fg-quaternary tw:transition-inherit-all"
|
||||
/>
|
||||
);
|
||||
|
||||
const badgeElement =
|
||||
badge && (typeof badge === 'string' || typeof badge === 'number') ? (
|
||||
<Badge className="tw:ml-3" color="gray" size="sm" type="pill-color">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : (
|
||||
badge
|
||||
);
|
||||
|
||||
const labelElement = (
|
||||
<span
|
||||
className={cx(
|
||||
'tw:flex-1 tw:text-md tw:font-semibold tw:text-secondary tw:transition-inherit-all tw:group-hover:text-secondary_hover',
|
||||
truncate && 'tw:truncate',
|
||||
current && 'tw:text-secondary_hover'
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const isExternal = href && href.startsWith('http');
|
||||
const externalIcon = isExternal && (
|
||||
<Share04 className="tw:size-4 tw:stroke-[2.5px] tw:text-fg-quaternary" />
|
||||
);
|
||||
|
||||
if (type === 'collapsible') {
|
||||
return (
|
||||
<summary
|
||||
className={cx(
|
||||
'tw:px-3 tw:py-2',
|
||||
styles.root,
|
||||
current && styles.rootSelected
|
||||
)}
|
||||
onClick={onClick}>
|
||||
{iconElement}
|
||||
|
||||
{labelElement}
|
||||
|
||||
{badgeElement}
|
||||
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="tw:ml-3 tw:size-4 tw:shrink-0 tw:stroke-[2.5px] tw:text-fg-quaternary tw:in-open:-scale-y-100"
|
||||
/>
|
||||
</summary>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'collapsible-child') {
|
||||
return (
|
||||
<AriaLink
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={cx(
|
||||
'tw:py-2 tw:pr-3 tw:pl-10',
|
||||
styles.root,
|
||||
current && styles.rootSelected
|
||||
)}
|
||||
href={href!}
|
||||
rel="noopener noreferrer"
|
||||
target={isExternal ? '_blank' : '_self'}
|
||||
onClick={onClick}>
|
||||
{labelElement}
|
||||
{externalIcon}
|
||||
{badgeElement}
|
||||
</AriaLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaLink
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={cx(
|
||||
'tw:px-3 tw:py-2',
|
||||
styles.root,
|
||||
current && styles.rootSelected
|
||||
)}
|
||||
href={href!}
|
||||
rel="noopener noreferrer"
|
||||
target={isExternal ? '_blank' : '_self'}
|
||||
onClick={onClick}>
|
||||
{iconElement}
|
||||
{labelElement}
|
||||
{externalIcon}
|
||||
{badgeElement}
|
||||
</AriaLink>
|
||||
);
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { NavItemDividerType, NavItemType } from '../config';
|
||||
import { NavItemBase } from './nav-item';
|
||||
|
||||
interface NavListProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** Additional CSS classes to apply to the list. */
|
||||
className?: string;
|
||||
/** List of items to display. */
|
||||
items: (NavItemType | NavItemDividerType)[];
|
||||
}
|
||||
|
||||
export const NavList = ({ activeUrl, items, className }: NavListProps) => {
|
||||
return (
|
||||
<ul
|
||||
className={cx(
|
||||
'tw:mt-4 tw:flex tw:flex-col tw:px-2 tw:lg:px-4',
|
||||
className
|
||||
)}>
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<li className="tw:w-full tw:px-0.5 tw:py-2" key={index}>
|
||||
<hr className="tw:h-px tw:w-full tw:border-none tw:bg-border-secondary" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.items?.length) {
|
||||
const isChildActive = item.items.some(
|
||||
(subItem) => subItem.href === activeUrl
|
||||
);
|
||||
|
||||
return (
|
||||
<details
|
||||
className="tw:appearance-none tw:py-0.5"
|
||||
key={item.label}
|
||||
open={isChildActive}>
|
||||
<NavItemBase
|
||||
badge={item.badge}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
type="collapsible">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
|
||||
<dd>
|
||||
<ul className="tw:py-0.5">
|
||||
{item.items.map((childItem) => (
|
||||
<li className="tw:py-0.5" key={childItem.label}>
|
||||
<NavItemBase
|
||||
badge={childItem.badge}
|
||||
current={activeUrl === childItem.href}
|
||||
href={childItem.href}
|
||||
type="collapsible-child">
|
||||
{childItem.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="tw:py-0.5" key={item.label}>
|
||||
<NavItemBase
|
||||
badge={item.badge}
|
||||
current={activeUrl === item.href}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
type="link">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
export type NavItemType = {
|
||||
/** Label text for the nav item. */
|
||||
label: string;
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href?: string;
|
||||
/** Icon component to display. */
|
||||
icon?: FC<{ className?: string }>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** List of sub-items to display. */
|
||||
items?: {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: FC<{ className?: string }>;
|
||||
badge?: ReactNode;
|
||||
}[];
|
||||
/** Whether this nav item is a divider. */
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export type NavItemDividerType = Omit<
|
||||
NavItemType,
|
||||
'icon' | 'label' | 'divider'
|
||||
> & {
|
||||
/** Label text for the divider. */
|
||||
label?: string;
|
||||
/** Whether this nav item is a divider. */
|
||||
divider: true;
|
||||
};
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Dropdown } from '@/components/base/dropdown/dropdown';
|
||||
import { cx, sortCx } from '@/utils/cx';
|
||||
import { ChevronRight, DotsHorizontal } from '@untitledui/icons';
|
||||
import type { FC, HTMLAttributes, Key, ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Breadcrumb as AriaBreadcrumb,
|
||||
Breadcrumbs as AriaBreadcrumbs,
|
||||
Button as AriaButton,
|
||||
Link as AriaLink,
|
||||
} from 'react-aria-components';
|
||||
|
||||
export type BreadcrumbsType = 'text' | 'button-white' | 'button-gray';
|
||||
|
||||
export type BreadcrumbsDivider = 'chevron' | 'slash';
|
||||
|
||||
export type BreadcrumbsSize = 'xs' | 'sm' | 'md';
|
||||
|
||||
export interface BreadcrumbItemType {
|
||||
/** Unique identifier for the item. */
|
||||
id: Key;
|
||||
/** The text shown for the crumb. */
|
||||
label: ReactNode;
|
||||
/** Accessible label for icon-only crumbs. */
|
||||
ariaLabel?: string;
|
||||
/** Navigation target. Omit on the current (last) page. */
|
||||
href?: string;
|
||||
/** Optional leading icon, e.g. a home icon on the first crumb. */
|
||||
icon?: FC<{ className?: string }>;
|
||||
}
|
||||
|
||||
export interface BreadcrumbsProps extends HTMLAttributes<HTMLElement> {
|
||||
/** Ordered list of crumbs; the last item is treated as the current page. */
|
||||
items: BreadcrumbItemType[];
|
||||
/** Visual style of the crumbs. */
|
||||
type?: BreadcrumbsType;
|
||||
/** Separator rendered between crumbs. */
|
||||
divider?: BreadcrumbsDivider;
|
||||
/** Size of the crumbs (text, icons and button padding). */
|
||||
size?: BreadcrumbsSize;
|
||||
/**
|
||||
* Maximum number of crumbs to render inline. When the list is longer, the
|
||||
* middle crumbs collapse into a `…` menu, keeping the first and last crumbs
|
||||
* visible. Omit to always render every crumb. Ignored when `autoCollapse`
|
||||
* is enabled.
|
||||
*/
|
||||
maxItems?: number;
|
||||
/**
|
||||
* Keep the trail on a single line and automatically collapse the middle
|
||||
* crumbs into a `…` menu when the container is too narrow to fit them all.
|
||||
* Overrides `maxItems`.
|
||||
*/
|
||||
autoCollapse?: boolean;
|
||||
/**
|
||||
* Called with the item id when a non-current crumb is activated. When
|
||||
* provided, native `href` navigation is suppressed so the callback alone
|
||||
* drives navigation (client-side routing) — this avoids a double navigation
|
||||
* when an item supplies both `href` and `onAction`. Provide `href` without
|
||||
* `onAction` for a plain link.
|
||||
*/
|
||||
onAction?: (id: Key) => void;
|
||||
/**
|
||||
* Maximum width in pixels for each crumb's label text. Labels longer than
|
||||
* this truncate with an ellipsis. Omit to never truncate.
|
||||
*/
|
||||
maxItemWidth?: number;
|
||||
}
|
||||
|
||||
const ELLIPSIS_ID = '__breadcrumbs_ellipsis__';
|
||||
|
||||
interface EllipsisItem {
|
||||
id: typeof ELLIPSIS_ID;
|
||||
hidden: BreadcrumbItemType[];
|
||||
}
|
||||
|
||||
type DisplayItem = BreadcrumbItemType | EllipsisItem;
|
||||
|
||||
const isEllipsis = (item: DisplayItem): item is EllipsisItem =>
|
||||
item.id === ELLIPSIS_ID;
|
||||
|
||||
const styles = sortCx({
|
||||
text: {
|
||||
link: 'tw:text-quaternary tw:hover:text-secondary',
|
||||
current: 'tw:text-secondary tw:font-medium',
|
||||
},
|
||||
'button-white': {
|
||||
link: 'tw:rounded-md tw:text-quaternary tw:hover:bg-primary_hover tw:hover:text-secondary',
|
||||
current:
|
||||
'tw:rounded-md tw:bg-primary tw:font-medium tw:text-secondary tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset',
|
||||
},
|
||||
'button-gray': {
|
||||
link: 'tw:rounded-md tw:text-quaternary tw:hover:bg-secondary tw:hover:text-secondary',
|
||||
current: 'tw:rounded-md tw:bg-secondary tw:font-medium tw:text-secondary',
|
||||
},
|
||||
});
|
||||
|
||||
const sizes = sortCx({
|
||||
xs: {
|
||||
gap: 'tw:gap-1',
|
||||
text: 'tw:text-xs',
|
||||
icon: 'tw:size-3.5',
|
||||
dots: 'tw:size-4',
|
||||
padding: 'tw:px-1.5 tw:py-0.5',
|
||||
},
|
||||
sm: {
|
||||
gap: 'tw:gap-1.5',
|
||||
text: 'tw:text-sm',
|
||||
icon: 'tw:size-4',
|
||||
dots: 'tw:size-5',
|
||||
padding: 'tw:px-2 tw:py-1',
|
||||
},
|
||||
md: {
|
||||
gap: 'tw:gap-2',
|
||||
text: 'tw:text-sm',
|
||||
icon: 'tw:size-5',
|
||||
dots: 'tw:size-5',
|
||||
padding: 'tw:px-2.5 tw:py-1.5',
|
||||
},
|
||||
});
|
||||
|
||||
const linkClassName =
|
||||
'tw:flex tw:cursor-pointer tw:items-center tw:rounded-md tw:outline-brand tw:transition tw:duration-100 tw:ease-linear tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2';
|
||||
|
||||
const toText = (label: ReactNode, fallback: string): string =>
|
||||
typeof label === 'string' || typeof label === 'number'
|
||||
? String(label)
|
||||
: fallback;
|
||||
|
||||
const collapseItems = (
|
||||
items: BreadcrumbItemType[],
|
||||
maxItems?: number
|
||||
): DisplayItem[] => {
|
||||
let result: DisplayItem[] = items;
|
||||
if (maxItems && maxItems >= 2 && items.length > maxItems) {
|
||||
const trailingCount = maxItems - 1;
|
||||
result = [
|
||||
items[0],
|
||||
{ id: ELLIPSIS_ID, hidden: items.slice(1, items.length - trailingCount) },
|
||||
...items.slice(items.length - trailingCount),
|
||||
];
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const Divider = ({
|
||||
divider,
|
||||
size,
|
||||
}: {
|
||||
divider: BreadcrumbsDivider;
|
||||
size: BreadcrumbsSize;
|
||||
}) =>
|
||||
divider === 'slash' ? (
|
||||
<span aria-hidden="true" className="tw:px-0.5 tw:text-quaternary">
|
||||
/
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cx('tw:shrink-0 tw:text-fg-quaternary', sizes[size].icon)}
|
||||
/>
|
||||
);
|
||||
|
||||
const CrumbLabel = ({
|
||||
item,
|
||||
size,
|
||||
maxItemWidth,
|
||||
}: {
|
||||
item: BreadcrumbItemType;
|
||||
size: BreadcrumbsSize;
|
||||
maxItemWidth?: number;
|
||||
}) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<span className={cx('tw:flex tw:min-w-0 tw:items-center', sizes[size].gap)}>
|
||||
{Icon && <Icon className={cx('tw:shrink-0', sizes[size].icon)} />}
|
||||
<span
|
||||
className="tw:truncate"
|
||||
style={maxItemWidth ? { maxWidth: maxItemWidth } : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface EllipsisMenuProps {
|
||||
hidden: BreadcrumbItemType[];
|
||||
type: BreadcrumbsType;
|
||||
size: BreadcrumbsSize;
|
||||
padding: string;
|
||||
onAction?: (id: Key) => void;
|
||||
}
|
||||
|
||||
const EllipsisMenu = ({
|
||||
hidden,
|
||||
type,
|
||||
size,
|
||||
padding,
|
||||
onAction,
|
||||
}: EllipsisMenuProps) => (
|
||||
<Dropdown.Root>
|
||||
<AriaButton
|
||||
aria-label="Show hidden breadcrumbs"
|
||||
className={cx(linkClassName, styles[type].link, padding)}>
|
||||
<DotsHorizontal className={cx('tw:shrink-0', sizes[size].dots)} />
|
||||
</AriaButton>
|
||||
<Dropdown.Popover>
|
||||
<Dropdown.Menu aria-label="Hidden breadcrumbs">
|
||||
{hidden.map((item, index) => (
|
||||
<Dropdown.Item
|
||||
href={onAction ? undefined : item.href}
|
||||
icon={item.icon}
|
||||
key={item.id}
|
||||
label={toText(item.label, `Item ${index + 1}`)}
|
||||
onAction={() => onAction?.(item.id)}
|
||||
/>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
|
||||
export const Breadcrumbs = ({
|
||||
items,
|
||||
type = 'text',
|
||||
divider = 'chevron',
|
||||
size = 'xs',
|
||||
maxItems,
|
||||
autoCollapse = false,
|
||||
className,
|
||||
onAction,
|
||||
maxItemWidth,
|
||||
'aria-label': ariaLabel = 'Breadcrumb',
|
||||
...props
|
||||
}: BreadcrumbsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fittedCount, setFittedCount] = useState(items.length);
|
||||
const [measureKey, requestMeasure] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const padding = type === 'text' ? '' : sizes[size].padding;
|
||||
const displayItems = collapseItems(
|
||||
items,
|
||||
autoCollapse ? fittedCount : maxItems
|
||||
);
|
||||
|
||||
// While the trail overflows its container, drop one more crumb into the `…`
|
||||
// menu. Re-runs as `fittedCount` decreases (the convergence loop) and whenever
|
||||
// a re-measure is requested, so it settles once the trail fits (or only the
|
||||
// first + last remain). The `ol` is the measured element — react-aria renders
|
||||
// a 0-width <template> as the wrapper's first child, so the list must be
|
||||
// queried, not read positionally.
|
||||
useLayoutEffect(() => {
|
||||
const list = containerRef.current?.querySelector('ol');
|
||||
if (
|
||||
autoCollapse &&
|
||||
list &&
|
||||
list.scrollWidth > list.clientWidth + 1 &&
|
||||
fittedCount > 2
|
||||
) {
|
||||
setFittedCount(fittedCount - 1);
|
||||
}
|
||||
}, [autoCollapse, fittedCount, items, measureKey]);
|
||||
|
||||
// Reset to the full trail and re-measure on mount, on a container resize, and
|
||||
// once web fonts load (label widths can change after the first layout, which
|
||||
// would otherwise leave a stale measurement). The reset lets the trail
|
||||
// re-expand when there is more room.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!autoCollapse || !el) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remeasure = () => {
|
||||
setFittedCount(items.length);
|
||||
requestMeasure();
|
||||
};
|
||||
const observer = new ResizeObserver(remeasure);
|
||||
observer.observe(el);
|
||||
document.fonts?.ready.then(remeasure).catch(() => undefined);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [autoCollapse, items.length]);
|
||||
|
||||
const list = (
|
||||
<AriaBreadcrumbs
|
||||
aria-label={ariaLabel}
|
||||
className={cx(
|
||||
'tw:flex tw:flex-nowrap tw:items-center',
|
||||
autoCollapse && 'tw:w-full tw:min-w-0 tw:overflow-hidden',
|
||||
sizes[size].gap,
|
||||
sizes[size].text,
|
||||
className
|
||||
)}
|
||||
items={displayItems}
|
||||
{...props}>
|
||||
{(item) => (
|
||||
<AriaBreadcrumb
|
||||
className={cx(
|
||||
'tw:flex tw:shrink-0 tw:items-center',
|
||||
sizes[size].gap
|
||||
)}>
|
||||
{({ isCurrent }) => (
|
||||
<>
|
||||
{isEllipsis(item) ? (
|
||||
<EllipsisMenu
|
||||
hidden={item.hidden}
|
||||
padding={padding}
|
||||
size={size}
|
||||
type={type}
|
||||
onAction={onAction}
|
||||
/>
|
||||
) : !isCurrent && (item.href || onAction) ? (
|
||||
<AriaLink
|
||||
aria-label={item.ariaLabel}
|
||||
className={cx(linkClassName, styles[type].link, padding)}
|
||||
href={onAction ? undefined : item.href}
|
||||
onPress={() => onAction?.(item.id)}>
|
||||
<CrumbLabel
|
||||
item={item}
|
||||
maxItemWidth={maxItemWidth}
|
||||
size={size}
|
||||
/>
|
||||
</AriaLink>
|
||||
) : (
|
||||
<span
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
className={cx(
|
||||
'tw:flex tw:min-w-0 tw:items-center',
|
||||
padding,
|
||||
isCurrent ? styles[type].current : 'tw:text-quaternary'
|
||||
)}>
|
||||
<CrumbLabel
|
||||
item={item}
|
||||
maxItemWidth={maxItemWidth}
|
||||
size={size}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!isCurrent && <Divider divider={divider} size={size} />}
|
||||
</>
|
||||
)}
|
||||
</AriaBreadcrumb>
|
||||
)}
|
||||
</AriaBreadcrumbs>
|
||||
);
|
||||
|
||||
return autoCollapse ? (
|
||||
<div className="tw:w-full tw:min-w-0 tw:overflow-hidden" ref={containerRef}>
|
||||
{list}
|
||||
</div>
|
||||
) : (
|
||||
list
|
||||
);
|
||||
};
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import {
|
||||
type CalendarDate,
|
||||
getLocalTimeZone,
|
||||
today,
|
||||
} from '@internationalized/date';
|
||||
import { ChevronLeft, ChevronRight } from '@untitledui/icons';
|
||||
import type {
|
||||
CalendarProps as AriaCalendarProps,
|
||||
DateValue,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
Calendar as AriaCalendar,
|
||||
CalendarContext as AriaCalendarContext,
|
||||
CalendarGrid as AriaCalendarGrid,
|
||||
CalendarGridBody as AriaCalendarGridBody,
|
||||
CalendarGridHeader as AriaCalendarGridHeader,
|
||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||
CalendarStateContext as AriaCalendarStateContext,
|
||||
Heading as AriaHeading,
|
||||
useSlottedContext,
|
||||
} from 'react-aria-components';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { CalendarCell } from './cell';
|
||||
import { DateInput } from './date-input';
|
||||
|
||||
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
|
||||
const [value, onChange] = useState<DateValue | null>(null);
|
||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||
|
||||
return (
|
||||
<AriaCalendarContext.Provider
|
||||
value={{ value, onChange, focusedValue, onFocusChange }}>
|
||||
{children}
|
||||
</AriaCalendarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const PresetButton = ({
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLButtonElement> & { value: CalendarDate }) => {
|
||||
const context = useContext(AriaCalendarStateContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Preset must be used within a Calendar component');
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
context.setValue(value);
|
||||
context.setFocusedDate(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
// It's important to give `null` explicitly to the `slot` prop
|
||||
// otherwise the button will throw an error due to not using one of
|
||||
// the required slots inside the Calendar component.
|
||||
// Passing `null` will tell the button to not use a slot context.
|
||||
color="secondary"
|
||||
size="md"
|
||||
slot={null}
|
||||
onClick={handleClick}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarProps extends AriaCalendarProps<DateValue> {
|
||||
/** The dates to highlight. */
|
||||
highlightedDates?: DateValue[];
|
||||
}
|
||||
|
||||
export const Calendar = ({
|
||||
highlightedDates,
|
||||
className,
|
||||
...props
|
||||
}: CalendarProps) => {
|
||||
const context = useSlottedContext(AriaCalendarContext)!;
|
||||
|
||||
const ContextWrapper = context ? Fragment : CalendarContextProvider;
|
||||
|
||||
return (
|
||||
<ContextWrapper>
|
||||
<AriaCalendar
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:flex tw:flex-col tw:gap-3',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
<header className="tw:flex tw:items-center tw:justify-between">
|
||||
<Button
|
||||
className="tw:size-8"
|
||||
color="tertiary"
|
||||
iconLeading={ChevronLeft}
|
||||
size="sm"
|
||||
slot="previous"
|
||||
/>
|
||||
<AriaHeading className="tw:text-sm tw:font-semibold tw:text-fg-secondary" />
|
||||
<Button
|
||||
className="tw:size-8"
|
||||
color="tertiary"
|
||||
iconLeading={ChevronRight}
|
||||
size="sm"
|
||||
slot="next"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="tw:flex tw:gap-3">
|
||||
<DateInput className="tw:flex-1" />
|
||||
<PresetButton value={today(getLocalTimeZone())}>Today</PresetButton>
|
||||
</div>
|
||||
|
||||
<AriaCalendarGrid className="tw:w-max" weekdayStyle="short">
|
||||
<AriaCalendarGridHeader className="tw:border-b-4 tw:border-transparent">
|
||||
{(day) => (
|
||||
<AriaCalendarHeaderCell className="tw:p-0">
|
||||
<div className="tw:flex tw:size-10 tw:items-center tw:justify-center tw:text-sm tw:font-medium tw:text-secondary">
|
||||
{day.slice(0, 2)}
|
||||
</div>
|
||||
</AriaCalendarHeaderCell>
|
||||
)}
|
||||
</AriaCalendarGridHeader>
|
||||
<AriaCalendarGridBody className="tw:[&_td]:p-0 tw:[&_tr]:border-b-4 tw:[&_tr]:border-transparent tw:[&_tr:last-of-type]:border-none">
|
||||
{(date) => (
|
||||
<CalendarCell
|
||||
date={date}
|
||||
isHighlighted={highlightedDates?.some(
|
||||
(highlightedDate) => date.compare(highlightedDate) === 0
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</AriaCalendarGridBody>
|
||||
</AriaCalendarGrid>
|
||||
</AriaCalendar>
|
||||
</ContextWrapper>
|
||||
);
|
||||
};
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
getDayOfWeek,
|
||||
getLocalTimeZone,
|
||||
isToday,
|
||||
} from '@internationalized/date';
|
||||
import type { CalendarCellProps as AriaCalendarCellProps } from 'react-aria-components';
|
||||
import {
|
||||
CalendarCell as AriaCalendarCell,
|
||||
RangeCalendarContext,
|
||||
useLocale,
|
||||
useSlottedContext,
|
||||
} from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
interface CalendarCellProps extends AriaCalendarCellProps {
|
||||
/** Whether the calendar is a range calendar. */
|
||||
isRangeCalendar?: boolean;
|
||||
/** Whether the cell is highlighted. */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export const CalendarCell = ({
|
||||
date,
|
||||
isHighlighted,
|
||||
...props
|
||||
}: CalendarCellProps) => {
|
||||
const { locale } = useLocale();
|
||||
const dayOfWeek = getDayOfWeek(date, locale);
|
||||
const rangeCalendarContext = useSlottedContext(RangeCalendarContext);
|
||||
|
||||
const isRangeCalendar = !!rangeCalendarContext;
|
||||
|
||||
const start = rangeCalendarContext?.value?.start;
|
||||
const end = rangeCalendarContext?.value?.end;
|
||||
|
||||
const isAfterStart = start ? date.compare(start) > 0 : true;
|
||||
const isBeforeEnd = end ? date.compare(end) < 0 : true;
|
||||
|
||||
const isAfterOrOnStart = start && date.compare(start) >= 0;
|
||||
const isBeforeOrOnEnd = end && date.compare(end) <= 0;
|
||||
const isInRange = isAfterOrOnStart && isBeforeOrOnEnd;
|
||||
|
||||
const lastDayOfMonth = new Date(date.year, date.month, 0).getDate();
|
||||
const isLastDayOfMonth = date.day === lastDayOfMonth;
|
||||
const isFirstDayOfMonth = date.day === 1;
|
||||
|
||||
const isTodayDate = isToday(date, getLocalTimeZone());
|
||||
|
||||
return (
|
||||
<AriaCalendarCell
|
||||
{...props}
|
||||
className={({
|
||||
isDisabled,
|
||||
isFocusVisible,
|
||||
isSelectionStart,
|
||||
isSelectionEnd,
|
||||
isSelected,
|
||||
isOutsideMonth,
|
||||
}) => {
|
||||
const isRoundedLeft = isSelectionStart || dayOfWeek === 0;
|
||||
const isRoundedRight = isSelectionEnd || dayOfWeek === 6;
|
||||
|
||||
return cx(
|
||||
'tw:relative tw:size-10 tw:focus:outline-hidden',
|
||||
isRoundedLeft && 'tw:rounded-l-full',
|
||||
isRoundedRight && 'tw:rounded-r-full',
|
||||
isInRange && isDisabled && 'tw:bg-active',
|
||||
isSelected && isRangeCalendar && 'tw:bg-active',
|
||||
isDisabled ? 'tw:pointer-events-none' : 'tw:cursor-pointer',
|
||||
isFocusVisible ? 'tw:z-10' : 'tw:z-0',
|
||||
isRangeCalendar && isOutsideMonth && 'tw:hidden',
|
||||
|
||||
// Show gradient on last day of month if it's within the selected range.
|
||||
isLastDayOfMonth &&
|
||||
isSelected &&
|
||||
isBeforeEnd &&
|
||||
isRangeCalendar &&
|
||||
'tw:after:absolute tw:after:inset-0 tw:after:translate-x-full tw:after:bg-gradient-to-l tw:after:from-transparent tw:after:to-bg-active tw:in-[[role=gridcell]:last-child]:after:hidden',
|
||||
|
||||
// Show gradient on first day of month if it's within the selected range.
|
||||
isFirstDayOfMonth &&
|
||||
isSelected &&
|
||||
isAfterStart &&
|
||||
isRangeCalendar &&
|
||||
'tw:after:absolute tw:after:inset-0 tw:after:-translate-x-full tw:after:bg-gradient-to-r tw:after:from-transparent tw:after:to-bg-active tw:in-[[role=gridcell]:first-child]:after:hidden'
|
||||
);
|
||||
}}
|
||||
date={date}>
|
||||
{({
|
||||
isDisabled,
|
||||
isFocusVisible,
|
||||
isSelectionStart,
|
||||
isSelectionEnd,
|
||||
isSelected,
|
||||
formattedDate,
|
||||
}) => {
|
||||
const markedAsSelected =
|
||||
isSelectionStart ||
|
||||
isSelectionEnd ||
|
||||
(isSelected && !isDisabled && !isRangeCalendar);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:size-full tw:items-center tw:justify-center tw:rounded-full tw:text-sm',
|
||||
// Disabled state.
|
||||
isDisabled
|
||||
? 'tw:text-disabled'
|
||||
: 'tw:text-secondary tw:hover:text-secondary_hover',
|
||||
// Focus ring, visible while the cell has keyboard focus.
|
||||
isFocusVisible
|
||||
? 'tw:outline-2 tw:outline-offset-2 tw:outline-focus-ring'
|
||||
: '',
|
||||
// Hover state for cells in the middle of the range.
|
||||
isSelected && !isDisabled && isRangeCalendar
|
||||
? 'tw:font-medium'
|
||||
: '',
|
||||
markedAsSelected &&
|
||||
'tw:bg-brand-solid tw:font-medium tw:text-white tw:hover:bg-brand-solid_hover tw:hover:text-white',
|
||||
// Hover state for non-selected cells.
|
||||
!isSelected && !isDisabled
|
||||
? 'tw:hover:bg-primary_hover tw:hover:font-medium!'
|
||||
: '',
|
||||
!isSelected && isTodayDate
|
||||
? 'tw:bg-active tw:font-medium tw:hover:bg-secondary_hover'
|
||||
: ''
|
||||
)}>
|
||||
{formattedDate}
|
||||
|
||||
{(isHighlighted || isTodayDate) && (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:absolute tw:bottom-1 tw:left-1/2 tw:size-1.25 tw:-translate-x-1/2 tw:rounded-full',
|
||||
isDisabled
|
||||
? 'tw:bg-fg-disabled'
|
||||
: markedAsSelected
|
||||
? 'tw:bg-fg-white'
|
||||
: 'tw:bg-fg-brand-primary'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AriaCalendarCell>
|
||||
);
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { DateInputProps as AriaDateInputProps } from 'react-aria-components';
|
||||
import {
|
||||
DateInput as AriaDateInput,
|
||||
DateSegment as AriaDateSegment,
|
||||
} from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type DateInputProps = Omit<AriaDateInputProps, 'children'>;
|
||||
|
||||
export const DateInput = (props: DateInputProps) => {
|
||||
return (
|
||||
<AriaDateInput
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:rounded-lg tw:bg-primary tw:px-2.5 tw:py-2 tw:text-md tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset tw:focus-within:ring-2 tw:focus-within:ring-brand',
|
||||
typeof props.className === 'string' && props.className
|
||||
)}>
|
||||
{(segment) => (
|
||||
<AriaDateSegment
|
||||
className={cx(
|
||||
'tw:rounded tw:px-0.5 tw:text-primary tw:tabular-nums tw:caret-transparent tw:focus:bg-brand-solid tw:focus:font-medium tw:focus:text-white tw:focus:outline-hidden',
|
||||
// The placeholder segment.
|
||||
segment.isPlaceholder && 'tw:text-placeholder tw:uppercase',
|
||||
// The separator "/" segment.
|
||||
segment.type === 'literal' && 'tw:text-fg-quaternary'
|
||||
)}
|
||||
segment={segment}
|
||||
/>
|
||||
)}
|
||||
</AriaDateInput>
|
||||
);
|
||||
};
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { getLocalTimeZone, today } from '@internationalized/date';
|
||||
import { useControlledState } from '@react-stately/utils';
|
||||
import { Calendar as CalendarIcon } from '@untitledui/icons';
|
||||
import { useDateFormatter } from 'react-aria';
|
||||
import type {
|
||||
DatePickerProps as AriaDatePickerProps,
|
||||
DateValue,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
DatePicker as AriaDatePicker,
|
||||
Dialog as AriaDialog,
|
||||
Group as AriaGroup,
|
||||
Popover as AriaPopover,
|
||||
} from 'react-aria-components';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { Calendar } from './calendar';
|
||||
|
||||
const highlightedDates = [today(getLocalTimeZone())];
|
||||
|
||||
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
||||
/** The function to call when the apply button is clicked. */
|
||||
onApply?: () => void;
|
||||
/** The function to call when the cancel button is clicked. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const DatePicker = ({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onApply,
|
||||
onCancel,
|
||||
...props
|
||||
}: DatePickerProps) => {
|
||||
const formatter = useDateFormatter({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const [value, setValue] = useControlledState(
|
||||
valueProp,
|
||||
defaultValue || null,
|
||||
onChange
|
||||
);
|
||||
|
||||
const formattedDate = value
|
||||
? formatter.format(value.toDate(getLocalTimeZone()))
|
||||
: 'Select date';
|
||||
|
||||
return (
|
||||
<AriaDatePicker
|
||||
shouldCloseOnSelect={false}
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={setValue}>
|
||||
<AriaGroup>
|
||||
<Button color="secondary" iconLeading={CalendarIcon} size="md">
|
||||
{formattedDate}
|
||||
</Button>
|
||||
</AriaGroup>
|
||||
<AriaPopover
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
'tw:origin-(--trigger-anchor-point) tw:will-change-transform',
|
||||
isEntering &&
|
||||
'tw:duration-150 tw:ease-out tw:animate-in tw:fade-in tw:placement-right:slide-in-from-left-0.5 tw:placement-top:slide-in-from-bottom-0.5 tw:placement-bottom:slide-in-from-top-0.5',
|
||||
isExiting &&
|
||||
'tw:duration-100 tw:ease-in tw:animate-out tw:fade-out tw:placement-right:slide-out-to-left-0.5 tw:placement-top:slide-out-to-bottom-0.5 tw:placement-bottom:slide-out-to-top-0.5'
|
||||
)
|
||||
}
|
||||
offset={8}
|
||||
placement="bottom right">
|
||||
<AriaDialog className="tw:rounded-2xl tw:bg-primary tw:shadow-xl tw:ring tw:ring-secondary_alt">
|
||||
{({ close }) => (
|
||||
<>
|
||||
<div className="tw:flex tw:px-6 tw:py-5">
|
||||
<Calendar highlightedDates={highlightedDates} />
|
||||
</div>
|
||||
<div className="tw:grid tw:grid-cols-2 tw:gap-3 tw:border-t tw:border-secondary tw:p-4">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onCancel?.();
|
||||
close();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onApply?.();
|
||||
close();
|
||||
}}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AriaDialog>
|
||||
</AriaPopover>
|
||||
</AriaDatePicker>
|
||||
);
|
||||
};
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
getLocalTimeZone,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
today,
|
||||
} from '@internationalized/date';
|
||||
import { useControlledState } from '@react-stately/utils';
|
||||
import { Calendar as CalendarIcon } from '@untitledui/icons';
|
||||
import { useDateFormatter } from 'react-aria';
|
||||
import type {
|
||||
DateRangePickerProps as AriaDateRangePickerProps,
|
||||
DateValue,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
DateRangePicker as AriaDateRangePicker,
|
||||
Dialog as AriaDialog,
|
||||
Group as AriaGroup,
|
||||
Popover as AriaPopover,
|
||||
useLocale,
|
||||
} from 'react-aria-components';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { DateInput } from './date-input';
|
||||
import { RangeCalendar } from './range-calendar';
|
||||
import { RangePresetButton } from './range-preset';
|
||||
|
||||
const now = today(getLocalTimeZone());
|
||||
|
||||
const highlightedDates = [today(getLocalTimeZone())];
|
||||
|
||||
interface DateRangePickerProps extends AriaDateRangePickerProps<DateValue> {
|
||||
/** The function to call when the apply button is clicked. */
|
||||
onApply?: () => void;
|
||||
/** The function to call when the cancel button is clicked. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const DateRangePicker = ({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onApply,
|
||||
onCancel,
|
||||
...props
|
||||
}: DateRangePickerProps) => {
|
||||
const { locale } = useLocale();
|
||||
const formatter = useDateFormatter({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const [value, setValue] = useControlledState(
|
||||
valueProp,
|
||||
defaultValue || null,
|
||||
onChange
|
||||
);
|
||||
const [focusedValue, setFocusedValue] = useState<DateValue | null>(null);
|
||||
|
||||
const formattedStartDate = value?.start
|
||||
? formatter.format(value.start.toDate(getLocalTimeZone()))
|
||||
: 'Select date';
|
||||
const formattedEndDate = value?.end
|
||||
? formatter.format(value.end.toDate(getLocalTimeZone()))
|
||||
: 'Select date';
|
||||
|
||||
const presets = useMemo(
|
||||
() => ({
|
||||
today: { label: 'Today', value: { start: now, end: now } },
|
||||
yesterday: {
|
||||
label: 'Yesterday',
|
||||
value: {
|
||||
start: now.subtract({ days: 1 }),
|
||||
end: now.subtract({ days: 1 }),
|
||||
},
|
||||
},
|
||||
thisWeek: {
|
||||
label: 'This week',
|
||||
value: { start: startOfWeek(now, locale), end: endOfWeek(now, locale) },
|
||||
},
|
||||
lastWeek: {
|
||||
label: 'Last week',
|
||||
value: {
|
||||
start: startOfWeek(now, locale).subtract({ weeks: 1 }),
|
||||
end: endOfWeek(now, locale).subtract({ weeks: 1 }),
|
||||
},
|
||||
},
|
||||
thisMonth: {
|
||||
label: 'This month',
|
||||
value: { start: startOfMonth(now), end: endOfMonth(now) },
|
||||
},
|
||||
lastMonth: {
|
||||
label: 'Last month',
|
||||
value: {
|
||||
start: startOfMonth(now).subtract({ months: 1 }),
|
||||
end: endOfMonth(now).subtract({ months: 1 }),
|
||||
},
|
||||
},
|
||||
thisYear: {
|
||||
label: 'This year',
|
||||
value: {
|
||||
start: startOfMonth(now.set({ month: 1 })),
|
||||
end: endOfMonth(now.set({ month: 12 })),
|
||||
},
|
||||
},
|
||||
lastYear: {
|
||||
label: 'Last year',
|
||||
value: {
|
||||
start: startOfMonth(now.set({ month: 1 }).subtract({ years: 1 })),
|
||||
end: endOfMonth(now.set({ month: 12 }).subtract({ years: 1 })),
|
||||
},
|
||||
},
|
||||
allTime: {
|
||||
label: 'All time',
|
||||
value: {
|
||||
start: now.set({ year: 2000, month: 1, day: 1 }),
|
||||
end: now,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[locale]
|
||||
);
|
||||
|
||||
return (
|
||||
<AriaDateRangePicker
|
||||
aria-label="Date range picker"
|
||||
shouldCloseOnSelect={false}
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={setValue}>
|
||||
<AriaGroup>
|
||||
<Button color="secondary" iconLeading={CalendarIcon} size="md">
|
||||
{!value ? (
|
||||
<span className="tw:text-placeholder">Select dates</span>
|
||||
) : (
|
||||
`${formattedStartDate} – ${formattedEndDate}`
|
||||
)}
|
||||
</Button>
|
||||
</AriaGroup>
|
||||
<AriaPopover
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
'tw:origin-(--trigger-anchor-point) tw:will-change-transform',
|
||||
isEntering &&
|
||||
'tw:duration-150 tw:ease-out tw:animate-in tw:fade-in tw:placement-right:slide-in-from-left-0.5 tw:placement-top:slide-in-from-bottom-0.5 tw:placement-bottom:slide-in-from-top-0.5',
|
||||
isExiting &&
|
||||
'tw:duration-100 tw:ease-in tw:animate-out tw:fade-out tw:placement-right:slide-out-to-left-0.5 tw:placement-top:slide-out-to-bottom-0.5 tw:placement-bottom:slide-out-to-top-0.5'
|
||||
)
|
||||
}
|
||||
offset={8}
|
||||
placement="bottom right">
|
||||
<AriaDialog className="tw:flex tw:rounded-2xl tw:bg-primary tw:shadow-xl tw:ring tw:ring-secondary_alt tw:focus:outline-hidden">
|
||||
{({ close }) => (
|
||||
<>
|
||||
<div className="tw:hidden tw:w-38 tw:flex-col tw:gap-0.5 tw:border-r tw:border-solid tw:border-secondary tw:p-3 tw:lg:flex">
|
||||
{Object.values(presets).map((preset) => (
|
||||
<RangePresetButton
|
||||
key={preset.label}
|
||||
value={preset.value}
|
||||
onClick={() => {
|
||||
setValue(preset.value);
|
||||
setFocusedValue(preset.value.start);
|
||||
}}>
|
||||
{preset.label}
|
||||
</RangePresetButton>
|
||||
))}
|
||||
</div>
|
||||
<div className="tw:flex tw:flex-col">
|
||||
<RangeCalendar
|
||||
focusedValue={focusedValue}
|
||||
highlightedDates={highlightedDates}
|
||||
presets={{
|
||||
lastWeek: presets.lastWeek,
|
||||
lastMonth: presets.lastMonth,
|
||||
lastYear: presets.lastYear,
|
||||
}}
|
||||
onFocusChange={setFocusedValue}
|
||||
/>
|
||||
<div className="tw:flex tw:justify-between tw:gap-3 tw:border-t tw:border-secondary tw:p-4">
|
||||
<div className="tw:hidden tw:items-center tw:gap-3 tw:md:flex">
|
||||
<DateInput className="tw:w-36" slot="start" />
|
||||
<div className="tw:text-md tw:text-quaternary">–</div>
|
||||
<DateInput className="tw:w-36" slot="end" />
|
||||
</div>
|
||||
<div className="tw:grid tw:w-full tw:grid-cols-2 tw:gap-3 tw:md:flex tw:md:w-auto">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onCancel?.();
|
||||
close();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onApply?.();
|
||||
close();
|
||||
}}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AriaDialog>
|
||||
</AriaPopover>
|
||||
</AriaDateRangePicker>
|
||||
);
|
||||
};
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import type { CalendarDate } from '@internationalized/date';
|
||||
import { ChevronLeft, ChevronRight } from '@untitledui/icons';
|
||||
import { useDateFormatter } from 'react-aria';
|
||||
import type {
|
||||
RangeCalendarProps as AriaRangeCalendarProps,
|
||||
DateValue,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
CalendarGrid as AriaCalendarGrid,
|
||||
CalendarGridBody as AriaCalendarGridBody,
|
||||
CalendarGridHeader as AriaCalendarGridHeader,
|
||||
CalendarHeaderCell as AriaCalendarHeaderCell,
|
||||
RangeCalendar as AriaRangeCalendar,
|
||||
RangeCalendarContext,
|
||||
RangeCalendarStateContext,
|
||||
useSlottedContext,
|
||||
} from 'react-aria-components';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { CalendarCell } from './cell';
|
||||
import { DateInput } from './date-input';
|
||||
|
||||
export const RangeCalendarContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren) => {
|
||||
const [value, onChange] = useState<{
|
||||
start: DateValue;
|
||||
end: DateValue;
|
||||
} | null>(null);
|
||||
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
|
||||
|
||||
return (
|
||||
<RangeCalendarContext.Provider
|
||||
value={{ value, onChange, focusedValue, onFocusChange }}>
|
||||
{children}
|
||||
</RangeCalendarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const RangeCalendarTitle = ({ part }: { part: 'start' | 'end' }) => {
|
||||
const context = useContext(RangeCalendarStateContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'<RangeCalendarTitle /> must be used within a <RangeCalendar /> component.'
|
||||
);
|
||||
}
|
||||
|
||||
const formatter = useDateFormatter({
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
calendar: context.visibleRange.start.calendar.identifier,
|
||||
timeZone: context.timeZone,
|
||||
});
|
||||
|
||||
const text =
|
||||
part === 'start'
|
||||
? formatter.format(context.visibleRange.start.toDate(context.timeZone))
|
||||
: formatter.format(context.visibleRange.end.toDate(context.timeZone));
|
||||
|
||||
return <>{text}</>;
|
||||
};
|
||||
|
||||
const MobilePresetButton = ({
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLButtonElement> & {
|
||||
value: { start: DateValue; end: DateValue };
|
||||
}) => {
|
||||
const context = useContext(RangeCalendarStateContext);
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
color="link-color"
|
||||
size="sm"
|
||||
slot={null}
|
||||
onClick={() => {
|
||||
context?.setValue(value);
|
||||
context?.setFocusedDate(value.start as CalendarDate);
|
||||
}}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface RangeCalendarProps extends AriaRangeCalendarProps<DateValue> {
|
||||
/** The dates to highlight. */
|
||||
highlightedDates?: DateValue[];
|
||||
/** The date presets to display. */
|
||||
presets?: Record<
|
||||
string,
|
||||
{ label: string; value: { start: DateValue; end: DateValue } }
|
||||
>;
|
||||
}
|
||||
|
||||
export const RangeCalendar = ({ presets, ...props }: RangeCalendarProps) => {
|
||||
const isDesktop = useBreakpoint('md');
|
||||
const context = useSlottedContext(RangeCalendarContext);
|
||||
|
||||
const ContextWrapper = context ? Fragment : RangeCalendarContextProvider;
|
||||
|
||||
return (
|
||||
<ContextWrapper>
|
||||
<AriaRangeCalendar
|
||||
className="tw:flex tw:items-start"
|
||||
visibleDuration={{
|
||||
months: isDesktop ? 2 : 1,
|
||||
}}
|
||||
{...props}>
|
||||
<div className="tw:flex tw:flex-col tw:gap-3 tw:px-6 tw:py-5">
|
||||
<header className="tw:relative tw:flex tw:items-center tw:justify-between tw:md:justify-start">
|
||||
<Button
|
||||
className="tw:size-8"
|
||||
color="tertiary"
|
||||
iconLeading={ChevronLeft}
|
||||
size="sm"
|
||||
slot="previous"
|
||||
/>
|
||||
|
||||
<h2 className="tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:text-sm tw:font-semibold tw:text-fg-secondary">
|
||||
<RangeCalendarTitle part="start" />
|
||||
</h2>
|
||||
|
||||
<Button
|
||||
className="tw:size-8 tw:md:hidden"
|
||||
color="tertiary"
|
||||
iconLeading={ChevronRight}
|
||||
size="sm"
|
||||
slot="next"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{!isDesktop && (
|
||||
<div className="tw:flex tw:items-center tw:gap-2 tw:md:hidden">
|
||||
<DateInput className="tw:flex-1" slot="start" />
|
||||
<div className="tw:text-md tw:text-quaternary">–</div>
|
||||
<DateInput className="tw:flex-1" slot="end" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDesktop && presets && (
|
||||
<div className="tw:mt-1 tw:flex tw:justify-between tw:gap-3 tw:px-2 tw:md:hidden">
|
||||
{Object.values(presets).map((preset) => (
|
||||
<MobilePresetButton key={preset.label} value={preset.value}>
|
||||
{preset.label}
|
||||
</MobilePresetButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AriaCalendarGrid className="tw:w-max" weekdayStyle="short">
|
||||
<AriaCalendarGridHeader>
|
||||
{(day) => (
|
||||
<AriaCalendarHeaderCell className="tw:border-b-4 tw:border-transparent tw:p-0">
|
||||
<div className="tw:flex tw:size-10 tw:items-center tw:justify-center tw:text-sm tw:font-medium tw:text-secondary">
|
||||
{day.slice(0, 2)}
|
||||
</div>
|
||||
</AriaCalendarHeaderCell>
|
||||
)}
|
||||
</AriaCalendarGridHeader>
|
||||
<AriaCalendarGridBody className="tw:[&_td]:p-0 tw:[&_tr]:border-b-4 tw:[&_tr]:border-transparent tw:[&_tr:last-of-type]:border-none">
|
||||
{(date) => <CalendarCell date={date} />}
|
||||
</AriaCalendarGridBody>
|
||||
</AriaCalendarGrid>
|
||||
</div>
|
||||
|
||||
{isDesktop && (
|
||||
<div className="tw:flex tw:flex-col tw:gap-3 tw:border-l tw:border-secondary tw:px-6 tw:py-5">
|
||||
<header className="tw:relative tw:flex tw:items-center tw:justify-end">
|
||||
<h2 className="tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:text-sm tw:font-semibold tw:text-fg-secondary">
|
||||
<RangeCalendarTitle part="end" />
|
||||
</h2>
|
||||
|
||||
<Button
|
||||
className="tw:size-8"
|
||||
color="tertiary"
|
||||
iconLeading={ChevronRight}
|
||||
size="sm"
|
||||
slot="next"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<AriaCalendarGrid
|
||||
className="tw:w-max"
|
||||
offset={{ months: 1 }}
|
||||
weekdayStyle="short">
|
||||
<AriaCalendarGridHeader>
|
||||
{(day) => (
|
||||
<AriaCalendarHeaderCell className="tw:border-b-4 tw:border-transparent tw:p-0">
|
||||
<div className="tw:flex tw:size-10 tw:items-center tw:justify-center tw:text-sm tw:font-medium tw:text-secondary">
|
||||
{day.slice(0, 2)}
|
||||
</div>
|
||||
</AriaCalendarHeaderCell>
|
||||
)}
|
||||
</AriaCalendarGridHeader>
|
||||
<AriaCalendarGridBody className="tw:[&_td]:p-0 tw:[&_tr]:border-b-4 tw:[&_tr]:border-transparent tw:[&_tr:last-of-type]:border-none">
|
||||
{(date) => <CalendarCell date={date} />}
|
||||
</AriaCalendarGridBody>
|
||||
</AriaCalendarGrid>
|
||||
</div>
|
||||
)}
|
||||
</AriaRangeCalendar>
|
||||
</ContextWrapper>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { type HTMLAttributes } from 'react';
|
||||
import {
|
||||
type DateValue,
|
||||
RangeCalendarContext,
|
||||
useSlottedContext,
|
||||
} from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
interface RangePresetButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
value: { start: DateValue; end: DateValue };
|
||||
}
|
||||
|
||||
export const RangePresetButton = ({
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: RangePresetButtonProps) => {
|
||||
const context = useSlottedContext(RangeCalendarContext);
|
||||
|
||||
const isSelected =
|
||||
context?.value?.start?.compare(value.start) === 0 &&
|
||||
context?.value?.end?.compare(value.end) === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:cursor-pointer tw:rounded-md tw:px-3 tw:py-2 tw:text-left tw:text-sm tw:font-medium tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
isSelected
|
||||
? 'tw:bg-active tw:text-secondary_hover tw:hover:bg-secondary_hover'
|
||||
: 'tw:text-secondary tw:hover:bg-primary_hover tw:hover:text-secondary_hover',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* Portions of this file are derived from UntitledUI's open-source React
|
||||
* components, licensed under MIT.
|
||||
* Source: https://github.com/untitleduico/react/blob/main/components/application/file-upload/file-upload-base.tsx
|
||||
*/
|
||||
|
||||
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';
|
||||
import { FileIcon as FileIconBase } from '@untitledui/file-icons';
|
||||
import {
|
||||
CheckCircle,
|
||||
Trash01,
|
||||
UploadCloud02,
|
||||
XCircle,
|
||||
} from '@untitledui/icons';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ComponentProps,
|
||||
ComponentPropsWithRef,
|
||||
DragEvent,
|
||||
KeyboardEvent,
|
||||
} from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { MdFileIcon } from './icons';
|
||||
|
||||
type FileIconProps = ComponentProps<typeof FileIconBase>;
|
||||
|
||||
const FileIcon = ({
|
||||
type,
|
||||
variant: _variant,
|
||||
theme: _theme,
|
||||
...svgProps
|
||||
}: FileIconProps) => {
|
||||
if (type === 'md' || type === 'markdown') {
|
||||
return <MdFileIcon {...svgProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileIconBase theme={_theme} type={type} variant={_variant} {...svgProps} />
|
||||
);
|
||||
};
|
||||
|
||||
export { FileIcon };
|
||||
export type { FileIconProps };
|
||||
|
||||
export const getReadableFileSize = (bytes: number): string => {
|
||||
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]}`;
|
||||
};
|
||||
|
||||
export interface FileUploadDropZoneProps {
|
||||
className?: string;
|
||||
hint?: string;
|
||||
isDisabled?: boolean;
|
||||
isInvalid?: boolean;
|
||||
accept?: string;
|
||||
allowsMultiple?: boolean;
|
||||
maxSize?: number;
|
||||
clickToUploadLabel?: string;
|
||||
orDragAndDropLabel?: string;
|
||||
'data-testid'?: string;
|
||||
'input-data-testid'?: string;
|
||||
onDropFiles?: (files: FileList) => void;
|
||||
onDropUnacceptedFiles?: (files: FileList) => void;
|
||||
onSizeLimitExceed?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
const isFileTypeAccepted = (file: File, accept?: string): boolean => {
|
||||
if (!accept) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acceptedTypes = accept.split(',').map((type) => type.trim());
|
||||
|
||||
return acceptedTypes.some((acceptedType) => {
|
||||
if (acceptedType.startsWith('.')) {
|
||||
const extension = `.${file.name.split('.').pop()?.toLowerCase()}`;
|
||||
|
||||
return extension === acceptedType.toLowerCase();
|
||||
}
|
||||
|
||||
if (acceptedType.endsWith('/*')) {
|
||||
const typePrefix = acceptedType.split('/')[0];
|
||||
|
||||
return file.type.startsWith(`${typePrefix}/`);
|
||||
}
|
||||
|
||||
return file.type === acceptedType;
|
||||
});
|
||||
};
|
||||
|
||||
const filesToFileList = (files: File[]): FileList => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
files.forEach((file) => dataTransfer.items.add(file));
|
||||
|
||||
return dataTransfer.files;
|
||||
};
|
||||
|
||||
export const FileUploadDropZone = ({
|
||||
className,
|
||||
hint,
|
||||
isDisabled,
|
||||
isInvalid: isInvalidProp,
|
||||
accept,
|
||||
allowsMultiple = true,
|
||||
maxSize,
|
||||
clickToUploadLabel = 'Click to upload',
|
||||
orDragAndDropLabel = 'or drag and drop',
|
||||
'data-testid': dataTestId,
|
||||
'input-data-testid': inputDataTestId,
|
||||
onDropFiles,
|
||||
onDropUnacceptedFiles,
|
||||
onSizeLimitExceed,
|
||||
}: FileUploadDropZoneProps) => {
|
||||
const id = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isInternalInvalid, setIsInternalInvalid] = useState(false);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const isInvalid = isInvalidProp ?? isInternalInvalid;
|
||||
|
||||
const handleDragIn = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDraggingOver(true);
|
||||
};
|
||||
|
||||
const handleDragOut = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
const processFiles = (files: File[]): void => {
|
||||
setIsInternalInvalid(false);
|
||||
|
||||
const acceptedFiles: File[] = [];
|
||||
const unacceptedFiles: File[] = [];
|
||||
const oversizedFiles: File[] = [];
|
||||
|
||||
const filesToProcess = allowsMultiple ? files : files.slice(0, 1);
|
||||
|
||||
filesToProcess.forEach((file) => {
|
||||
if (maxSize && file.size > maxSize) {
|
||||
oversizedFiles.push(file);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFileTypeAccepted(file, accept)) {
|
||||
acceptedFiles.push(file);
|
||||
} else {
|
||||
unacceptedFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
if (oversizedFiles.length > 0 && typeof onSizeLimitExceed === 'function') {
|
||||
setIsInternalInvalid(true);
|
||||
onSizeLimitExceed(filesToFileList(oversizedFiles));
|
||||
}
|
||||
|
||||
if (acceptedFiles.length > 0 && typeof onDropFiles === 'function') {
|
||||
onDropFiles(filesToFileList(acceptedFiles));
|
||||
}
|
||||
|
||||
if (
|
||||
unacceptedFiles.length > 0 &&
|
||||
typeof onDropUnacceptedFiles === 'function'
|
||||
) {
|
||||
setIsInternalInvalid(true);
|
||||
onDropUnacceptedFiles(filesToFileList(unacceptedFiles));
|
||||
}
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleDragOut(event);
|
||||
processFiles(Array.from(event.dataTransfer.files));
|
||||
};
|
||||
|
||||
const handleInputFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
processFiles(Array.from(event.target.files || []));
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openFilePicker();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-dropzone
|
||||
aria-disabled={isDisabled}
|
||||
aria-labelledby={`${id}-label`}
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:flex-col tw:items-center tw:gap-3 tw:rounded-xl tw:bg-primary tw:px-6 tw:py-4 tw:text-tertiary tw:ring-1 tw:ring-secondary tw:transition tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
isDraggingOver && 'tw:ring-2 tw:ring-brand',
|
||||
isDisabled
|
||||
? 'tw:cursor-not-allowed tw:bg-secondary'
|
||||
: 'tw:cursor-pointer',
|
||||
className
|
||||
)}
|
||||
data-testid={dataTestId}
|
||||
role="button"
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
onClick={openFilePicker}
|
||||
onDragEnd={handleDragOut}
|
||||
onDragEnter={handleDragIn}
|
||||
onDragLeave={handleDragOut}
|
||||
onDragOver={handleDragIn}
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleKeyDown}>
|
||||
<FeaturedIcon
|
||||
className={cx(isDisabled && 'tw:opacity-50')}
|
||||
color="gray"
|
||||
icon={UploadCloud02}
|
||||
size="md"
|
||||
theme="modern"
|
||||
/>
|
||||
|
||||
<div className="tw:flex tw:flex-col tw:gap-1 tw:text-center">
|
||||
<div className="tw:flex tw:justify-center tw:gap-1 tw:text-center">
|
||||
<input
|
||||
aria-hidden
|
||||
accept={accept}
|
||||
className="tw:peer tw:sr-only"
|
||||
data-testid={inputDataTestId}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
multiple={allowsMultiple}
|
||||
ref={inputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
onChange={handleInputFileChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span
|
||||
className={cx(
|
||||
'tw:text-sm tw:font-semibold tw:text-brand-secondary',
|
||||
isDisabled && 'tw:opacity-50'
|
||||
)}
|
||||
id={`${id}-label`}>
|
||||
{clickToUploadLabel}
|
||||
</span>
|
||||
<span className="tw:text-sm">{orDragAndDropLabel}</span>
|
||||
</div>
|
||||
{hint && (
|
||||
<p
|
||||
className={cx(
|
||||
'tw:text-xs tw:transition tw:duration-100 tw:ease-linear',
|
||||
isInvalid && 'tw:text-error-primary'
|
||||
)}>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FileUploadDropZone.displayName = 'FileUploadDropZone';
|
||||
|
||||
export interface FileListItemProps {
|
||||
name: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
failed?: boolean;
|
||||
className?: string;
|
||||
completeLabel?: string;
|
||||
uploadingLabel?: string;
|
||||
failedLabel?: string;
|
||||
tryAgainLabel?: string;
|
||||
deleteLabel?: string;
|
||||
type?: FileIconProps['type'];
|
||||
fileIconVariant?: FileIconProps['variant'];
|
||||
onDelete?: () => void;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const FileListItemProgressBar = ({
|
||||
className,
|
||||
completeLabel = 'Complete',
|
||||
deleteLabel = 'Delete',
|
||||
failed,
|
||||
failedLabel = 'Failed',
|
||||
fileIconVariant,
|
||||
name,
|
||||
onDelete,
|
||||
onRetry,
|
||||
progress,
|
||||
size,
|
||||
tryAgainLabel = 'Try again',
|
||||
type,
|
||||
uploadingLabel = 'Uploading...',
|
||||
}: FileListItemProps) => {
|
||||
const isComplete = progress === 100;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:gap-3 tw:rounded-xl tw:bg-primary tw:p-4 tw:ring-1 tw:ring-secondary tw:transition-shadow tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
failed && 'tw:ring-2 tw:ring-error',
|
||||
className
|
||||
)}>
|
||||
<FileIcon
|
||||
className="tw:size-10 tw:shrink-0 dark:tw:hidden"
|
||||
theme="light"
|
||||
type={type ?? 'empty'}
|
||||
variant={fileIconVariant ?? 'default'}
|
||||
/>
|
||||
<FileIcon
|
||||
className="tw:size-10 tw:shrink-0 tw:not-dark:hidden"
|
||||
theme="dark"
|
||||
type={type ?? 'empty'}
|
||||
variant={fileIconVariant ?? 'default'}
|
||||
/>
|
||||
|
||||
<div className="tw:flex tw:min-w-0 tw:flex-1 tw:flex-col tw:items-start">
|
||||
<div className="tw:flex tw:w-full tw:min-w-0 tw:max-w-full tw:flex-1">
|
||||
<div className="tw:min-w-0 tw:flex-1">
|
||||
<p className="tw:truncate tw:text-sm tw:font-medium tw:text-secondary">
|
||||
{name}
|
||||
</p>
|
||||
<div className="tw:mt-0.5 tw:flex tw:items-center tw:gap-2">
|
||||
<p className="tw:truncate tw:whitespace-nowrap tw:text-sm tw:text-tertiary">
|
||||
{getReadableFileSize(size)}
|
||||
</p>
|
||||
<hr className="tw:h-3 tw:w-px tw:rounded-full tw:border-none tw:bg-border-primary" />
|
||||
<div className="tw:flex tw:items-center tw:gap-1">
|
||||
{isComplete && !failed && (
|
||||
<>
|
||||
<CheckCircle className="tw:size-4 tw:stroke-[2.5px] tw:text-fg-success-primary" />
|
||||
<p className="tw:text-sm tw:font-medium tw:text-success-primary">
|
||||
{completeLabel}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!isComplete && !failed && (
|
||||
<>
|
||||
<UploadCloud02 className="tw:size-4 tw:stroke-[2.5px] tw:text-fg-quaternary" />
|
||||
<p className="tw:text-sm tw:font-medium tw:text-quaternary">
|
||||
{uploadingLabel}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{failed && (
|
||||
<>
|
||||
<XCircle className="tw:size-4 tw:text-fg-error-primary" />
|
||||
<p className="tw:text-sm tw:font-medium tw:text-error-primary">
|
||||
{failedLabel}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonUtility
|
||||
className="tw:-mr-2 tw:-mt-2 tw:self-start"
|
||||
color="tertiary"
|
||||
icon={Trash01}
|
||||
size="xs"
|
||||
tooltip={deleteLabel}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!failed && (
|
||||
<div className="tw:mt-1 tw:w-full">
|
||||
<ProgressBar
|
||||
labelPosition="right"
|
||||
max={100}
|
||||
min={0}
|
||||
value={progress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failed && (
|
||||
<Button
|
||||
className="tw:mt-1.5"
|
||||
color="link-destructive"
|
||||
size="sm"
|
||||
onClick={onRetry}>
|
||||
{tryAgainLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileListItemProgressFill = ({
|
||||
className,
|
||||
deleteLabel = 'Delete',
|
||||
failed,
|
||||
failedLabel = 'Upload failed, please try again',
|
||||
fileIconVariant,
|
||||
name,
|
||||
onDelete,
|
||||
onRetry,
|
||||
progress,
|
||||
size,
|
||||
tryAgainLabel = 'Try again',
|
||||
type,
|
||||
}: FileListItemProps) => {
|
||||
const isComplete = progress === 100;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:gap-3 tw:overflow-hidden tw:rounded-xl tw:bg-primary tw:p-4',
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={progress}
|
||||
className={cx(
|
||||
'tw:absolute tw:inset-0 tw:size-full tw:bg-secondary tw:transition tw:duration-75 tw:ease-linear',
|
||||
isComplete && 'tw:opacity-0'
|
||||
)}
|
||||
role="progressbar"
|
||||
style={{ transform: `translateX(-${100 - progress}%)` }}
|
||||
/>
|
||||
<div
|
||||
className={cx(
|
||||
'tw:absolute tw:inset-0 tw:size-full tw:rounded-[inherit] tw:ring-1 tw:ring-secondary tw:transition tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
failed && 'tw:ring-2 tw:ring-error'
|
||||
)}
|
||||
/>
|
||||
<FileIcon
|
||||
className="tw:relative tw:size-10 tw:shrink-0 dark:tw:hidden"
|
||||
theme="light"
|
||||
type={type ?? 'empty'}
|
||||
variant={fileIconVariant ?? 'solid'}
|
||||
/>
|
||||
<FileIcon
|
||||
className="tw:relative tw:size-10 tw:shrink-0 tw:not-dark:hidden"
|
||||
theme="dark"
|
||||
type={type ?? 'empty'}
|
||||
variant={fileIconVariant ?? 'solid'}
|
||||
/>
|
||||
|
||||
<div className="tw:relative tw:flex tw:min-w-0 tw:flex-1">
|
||||
<div className="tw:relative tw:flex tw:min-w-0 tw:flex-1 tw:flex-col tw:items-start">
|
||||
<div className="tw:w-full tw:min-w-0 tw:flex-1">
|
||||
<p className="tw:truncate tw:text-sm tw:font-medium tw:text-secondary">
|
||||
{name}
|
||||
</p>
|
||||
<div className="tw:mt-0.5 tw:flex tw:items-center tw:gap-2">
|
||||
<p className="tw:text-sm tw:text-tertiary">
|
||||
{failed ? failedLabel : getReadableFileSize(size)}
|
||||
</p>
|
||||
{!failed && (
|
||||
<>
|
||||
<hr className="tw:h-3 tw:w-px tw:rounded-full tw:border-none tw:bg-border-primary" />
|
||||
<div className="tw:flex tw:items-center tw:gap-1">
|
||||
{isComplete ? (
|
||||
<CheckCircle className="tw:size-4 tw:stroke-[2.5px] tw:text-fg-success-primary" />
|
||||
) : (
|
||||
<UploadCloud02 className="tw:size-4 tw:stroke-[2.5px] tw:text-fg-quaternary" />
|
||||
)}
|
||||
<p className="tw:text-sm tw:text-tertiary">{progress}%</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{failed && (
|
||||
<Button
|
||||
className="tw:mt-1.5"
|
||||
color="link-destructive"
|
||||
size="sm"
|
||||
onClick={onRetry}>
|
||||
{tryAgainLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ButtonUtility
|
||||
className="tw:-mr-2 tw:-mt-2 tw:self-start"
|
||||
color="tertiary"
|
||||
icon={Trash01}
|
||||
size="xs"
|
||||
tooltip={deleteLabel}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const FileUploadRoot = (props: ComponentPropsWithRef<'div'>) => (
|
||||
<div
|
||||
{...props}
|
||||
className={cx('tw:flex tw:flex-col tw:gap-4', props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
const FileUploadList = (props: ComponentPropsWithRef<'ul'>) => (
|
||||
<ul
|
||||
{...props}
|
||||
className={cx('tw:flex tw:flex-col tw:gap-3', props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FileUpload = {
|
||||
DropZone: FileUploadDropZone,
|
||||
List: FileUploadList,
|
||||
ListItemProgressBar: FileListItemProgressBar,
|
||||
ListItemProgressFill: FileListItemProgressFill,
|
||||
Root: FileUploadRoot,
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const MdFileIcon = ({
|
||||
width = 40,
|
||||
height = 40,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
fill="none"
|
||||
height={height}
|
||||
viewBox="0 0 40 40"
|
||||
width={width}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<path
|
||||
d="M11 0.75H27C27.1212 0.75 27.2375 0.798089 27.3232 0.883789L38.1162 11.6768C38.2019 11.7625 38.25 11.8788 38.25 12V36C38.25 37.7949 36.7949 39.25 35 39.25H11C9.20507 39.25 7.75 37.7949 7.75 36V4C7.75 2.20507 9.20508 0.75 11 0.75Z"
|
||||
stroke="#D5D7DA"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<path
|
||||
d="M27 0.5V8C27 10.2091 28.7909 12 31 12H38.5"
|
||||
stroke="#D5D7DA"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<rect fill="#414651" height={16} rx={2} width={23} x={3} y={18} />
|
||||
<path
|
||||
d="M6.91921 22.7273H8.81552L10.8184 27.6136H10.9036L12.9064 22.7273H14.8027V30H13.3113V25.2663H13.2509L11.3688 29.9645H10.3532L8.47106 25.2486H8.41069V30H6.91921V22.7273ZM18.6477 30H16.0696V22.7273H18.669C19.4006 22.7273 20.0303 22.8729 20.5582 23.1641C21.0862 23.4529 21.4922 23.8684 21.7763 24.4105C22.0627 24.9527 22.206 25.6013 22.206 26.3565C22.206 27.1141 22.0627 27.7652 21.7763 28.3097C21.4922 28.8542 21.0838 29.272 20.5511 29.5632C20.0208 29.8544 19.3864 30 18.6477 30ZM17.6072 28.6825H18.5838C19.0384 28.6825 19.4207 28.602 19.7308 28.4411C20.0433 28.2777 20.2777 28.0256 20.4339 27.6847C20.5926 27.3414 20.6719 26.8987 20.6719 26.3565C20.6719 25.8191 20.5926 25.38 20.4339 25.0391C20.2777 24.6982 20.0445 24.4472 19.7344 24.2862C19.4242 24.1252 19.0419 24.0447 18.5874 24.0447H17.6072V28.6825Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { XClose } from '@untitledui/icons';
|
||||
import type { ComponentPropsWithRef, FC, ReactNode } from 'react';
|
||||
import { Button as AriaButton } from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
interface FilterBarRootProps extends ComponentPropsWithRef<'div'> {
|
||||
/** Content and actions for the filter bar */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Root layout container for the filter bar. Arranges content and actions horizontally with wrapping. */
|
||||
const FilterBarRoot = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: FilterBarRootProps) => (
|
||||
<div
|
||||
className={cx('tw:flex tw:flex-wrap tw:items-end tw:gap-3', className)}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FilterBarContentProps extends ComponentPropsWithRef<'div'> {
|
||||
/** Filter inputs, tabs, or other content */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Left content area of the filter bar. Grows to fill available space. */
|
||||
const FilterBarContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: FilterBarContentProps) => (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:min-w-0 tw:flex-1 tw:flex-wrap tw:items-end tw:gap-3',
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FilterBarActionsProps extends ComponentPropsWithRef<'div'> {
|
||||
/** Action buttons such as date picker, filters, search */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Right actions area of the filter bar. Shrinks to fit content. */
|
||||
const FilterBarActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: FilterBarActionsProps) => (
|
||||
<div
|
||||
className={cx('tw:flex tw:shrink-0 tw:items-center tw:gap-3', className)}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FilterRowProps extends ComponentPropsWithRef<'div'> {
|
||||
/** Filter field, operator, and value inputs */
|
||||
children: ReactNode;
|
||||
/** Callback when the remove button is clicked */
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/** A single advanced filter row with inputs and a remove button. */
|
||||
const FilterRow = ({
|
||||
children,
|
||||
onRemove,
|
||||
className,
|
||||
...props
|
||||
}: FilterRowProps) => (
|
||||
<div className={cx('tw:flex tw:items-start tw:gap-1', className)} {...props}>
|
||||
<div className="tw:flex tw:items-center tw:gap-3">{children}</div>
|
||||
<AriaButton
|
||||
aria-label="Remove filter"
|
||||
className="tw:flex tw:size-9 tw:shrink-0 tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-lg tw:text-fg-quaternary tw:transition tw:duration-100 tw:ease-linear tw:hover:text-fg-quaternary_hover"
|
||||
onPress={onRemove}>
|
||||
<XClose className="tw:size-5" />
|
||||
</AriaButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FilterIconButtonProps {
|
||||
/** Icon component to render */
|
||||
icon: FC<{ className?: string }>;
|
||||
/** Accessible label */
|
||||
label?: string;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
/** Click handler */
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
/** Icon-only button styled to match filter bar controls (secondary button appearance). */
|
||||
const FilterIconButton = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
onPress,
|
||||
className,
|
||||
}: FilterIconButtonProps) => (
|
||||
<AriaButton
|
||||
aria-label={label}
|
||||
className={cx(
|
||||
'tw:flex tw:size-9 tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-lg tw:border tw:border-primary tw:bg-primary tw:shadow-xs tw:transition tw:duration-100 tw:ease-linear tw:hover:bg-primary_hover',
|
||||
className
|
||||
)}
|
||||
onPress={onPress}>
|
||||
<Icon className="tw:size-5 tw:text-fg-quaternary" />
|
||||
</AriaButton>
|
||||
);
|
||||
|
||||
export const FilterBar = {
|
||||
Root: FilterBarRoot,
|
||||
Content: FilterBarContent,
|
||||
Actions: FilterBarActions,
|
||||
FilterRow,
|
||||
FilterIconButton,
|
||||
};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { FC, FocusEvent, ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface FieldDocEntry {
|
||||
label: ReactNode;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
interface FieldDocRegistry {
|
||||
register: (name: string, entry: FieldDocEntry) => void;
|
||||
unregister: (name: string) => void;
|
||||
setActive: (name?: string) => void;
|
||||
activeName?: string;
|
||||
entries: Map<string, FieldDocEntry>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const FieldDocContext = createContext<FieldDocRegistry | undefined>(undefined);
|
||||
|
||||
export const FieldDocProvider: FC<{
|
||||
enabled?: boolean;
|
||||
children: ReactNode;
|
||||
}> = ({ enabled = false, children }) => {
|
||||
const [entries, setEntries] = useState<Map<string, FieldDocEntry>>(
|
||||
() => new Map()
|
||||
);
|
||||
const [activeName, setActiveName] = useState<string | undefined>(undefined);
|
||||
|
||||
const register = useCallback((name: string, entry: FieldDocEntry) => {
|
||||
setEntries((prev) => new Map(prev).set(name, entry));
|
||||
}, []);
|
||||
|
||||
const unregister = useCallback((name: string) => {
|
||||
setEntries((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(name);
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setActive = useCallback((name?: string) => setActiveName(name), []);
|
||||
|
||||
const value = useMemo<FieldDocRegistry>(
|
||||
() => ({
|
||||
register,
|
||||
unregister,
|
||||
setActive,
|
||||
activeName,
|
||||
entries,
|
||||
enabled,
|
||||
}),
|
||||
[register, unregister, setActive, activeName, enabled, entries]
|
||||
);
|
||||
|
||||
return (
|
||||
<FieldDocContext.Provider value={value}>
|
||||
{children}
|
||||
</FieldDocContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFieldDocRegistry = (): FieldDocRegistry => {
|
||||
const ctx = useContext(FieldDocContext);
|
||||
|
||||
return (
|
||||
ctx ?? {
|
||||
register: () => undefined,
|
||||
unregister: () => undefined,
|
||||
setActive: () => undefined,
|
||||
activeName: undefined,
|
||||
entries: new Map<string, FieldDocEntry>(),
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useActiveFieldDoc = (): {
|
||||
name?: string;
|
||||
entry?: FieldDocEntry;
|
||||
} => {
|
||||
const { activeName, entries } = useFieldDocRegistry();
|
||||
|
||||
return {
|
||||
name: activeName,
|
||||
entry: activeName ? entries.get(activeName) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a field's documentation and mark it active on focus. Spread the
|
||||
* returned props onto the field's wrapping element (`<Box {...fieldDoc}>`).
|
||||
* The `data-field-doc` marker lets the popover re-find the current anchor by
|
||||
* field name on every render, so positioning survives re-renders/remounts
|
||||
* (e.g. when parameter fields appear below the focused field). Use this for
|
||||
* custom fields not rendered through `getField` (card groups, tag pickers).
|
||||
*/
|
||||
export const useFieldDoc = ({
|
||||
name,
|
||||
label,
|
||||
doc,
|
||||
}: {
|
||||
name: string;
|
||||
label: ReactNode;
|
||||
doc?: string;
|
||||
}): {
|
||||
onFocusCapture?: (event: FocusEvent<HTMLElement>) => void;
|
||||
onPointerDownCapture?: () => void;
|
||||
onBlur?: (event: FocusEvent<HTMLElement>) => void;
|
||||
'data-field-doc'?: string;
|
||||
} => {
|
||||
const { enabled, register, unregister, setActive } = useFieldDocRegistry();
|
||||
const hasDoc = enabled && typeof doc === 'string' && doc.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasDoc) {
|
||||
return undefined;
|
||||
}
|
||||
register(name, { label, doc: doc as string });
|
||||
|
||||
return () => unregister(name);
|
||||
}, [hasDoc, name, label, doc, register, unregister]);
|
||||
|
||||
const activate = hasDoc ? () => setActive(name) : undefined;
|
||||
|
||||
// Clear the active hint when focus leaves the documented fields entirely
|
||||
// (tab away to an undocumented field, or click outside). Focus moving to
|
||||
// another documented field is left to that field's onFocusCapture, so the
|
||||
// hint follows focus instead of flickering closed.
|
||||
const deactivate = hasDoc
|
||||
? (event: FocusEvent<HTMLElement>) => {
|
||||
const nextFocused = event.relatedTarget as HTMLElement | null;
|
||||
if (!nextFocused?.closest('[data-field-doc]')) {
|
||||
setActive(undefined);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Activate on focus (keyboard) and on pointer-down (click), so fields whose
|
||||
// control does not take focus on click (e.g. a card group) still show docs.
|
||||
return {
|
||||
onFocusCapture: activate,
|
||||
onPointerDownCapture: activate,
|
||||
onBlur: deactivate,
|
||||
'data-field-doc': hasDoc ? name : undefined,
|
||||
};
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Popover } from 'react-aria-components';
|
||||
import { useActiveFieldDoc } from './field-doc-context';
|
||||
|
||||
export interface FieldDocPopoverProps {
|
||||
renderDoc?: (doc: string) => ReactNode;
|
||||
/** Optional card header (e.g. an icon + "Form Hint"); hidden when omitted. */
|
||||
header?: ReactNode;
|
||||
/**
|
||||
* Distance in px between the popover and the focused field. Consumers tune
|
||||
* this to their layout (e.g. to clear a modal's padding). Defaults to 16.
|
||||
*/
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
const defaultRenderDoc = (doc: string): ReactNode => (
|
||||
<p className="tw:whitespace-pre-wrap tw:text-sm tw:text-secondary">{doc}</p>
|
||||
);
|
||||
|
||||
// CSS.escape is missing in some non-browser/older runtimes; fall back to
|
||||
// escaping the characters that would break the attribute selector so focusing a
|
||||
// documented field never throws.
|
||||
const escapeFieldName = (name: string): string =>
|
||||
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||
? CSS.escape(name)
|
||||
: name.replace(/["\\]/g, '\\$&');
|
||||
|
||||
export const FieldDocPopover: FC<FieldDocPopoverProps> = ({
|
||||
renderDoc,
|
||||
header,
|
||||
offset = 16,
|
||||
}) => {
|
||||
const { entry, name } = useActiveFieldDoc();
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
// Re-find the anchor by field name on every render so positioning survives
|
||||
// the focused field re-rendering/remounting (e.g. param fields appearing).
|
||||
anchorRef.current =
|
||||
name && typeof document !== 'undefined'
|
||||
? document.querySelector<HTMLElement>(
|
||||
`[data-field-doc="${escapeFieldName(name)}"]`
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!entry || !anchorRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
isNonModal
|
||||
isOpen
|
||||
className="tw:flex tw:max-h-[85vh] tw:w-75 tw:flex-col tw:overflow-hidden tw:rounded-xl tw:border tw:border-secondary tw:bg-primary tw:shadow-lg"
|
||||
offset={offset}
|
||||
placement="right top"
|
||||
triggerRef={anchorRef}>
|
||||
{/* A plain container, not a Dialog — the doc popover must never take
|
||||
focus, or it would steal it from the field being edited. */}
|
||||
<div
|
||||
aria-label="Field documentation"
|
||||
className="tw:flex tw:min-h-0 tw:flex-1 tw:flex-col"
|
||||
role="note">
|
||||
{header != null && <div className="tw:px-4 tw:pt-4">{header}</div>}
|
||||
{/* Body scrolls within the capped popover height; the header (if any)
|
||||
stays pinned so long docs never push the popover off-screen. */}
|
||||
<div className="tw:min-h-0 tw:flex-1 tw:overflow-y-auto tw:px-4 tw:pb-4 tw:pt-4">
|
||||
<h4 className="tw:text-md tw:font-medium tw:text-primary tw:mb-1">
|
||||
{entry.label}
|
||||
</h4>
|
||||
{(renderDoc ?? defaultRenderDoc)(entry.doc)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
FieldDocPopover.displayName = 'FieldDocPopover';
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Check } from '@untitledui/icons';
|
||||
import { normalizeHexColor } from '@/colors/colorValidation';
|
||||
import { ENTITY_PALETTE_HEX } from '@/colors/entityPalette';
|
||||
import { Box } from '@/components/base/box/box';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Typography } from '@/components/foundations/typography';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export interface ColorPickerFieldProps {
|
||||
ariaLabel?: string;
|
||||
colors?: string[];
|
||||
'data-testid'?: string;
|
||||
disabled?: boolean;
|
||||
emptyStateLabel?: string;
|
||||
id?: string;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const ColorPickerField = ({
|
||||
ariaLabel,
|
||||
colors,
|
||||
'data-testid': dataTestId,
|
||||
disabled = false,
|
||||
emptyStateLabel,
|
||||
id,
|
||||
onBlur,
|
||||
onChange,
|
||||
value,
|
||||
}: ColorPickerFieldProps) => {
|
||||
const normalizedValue = normalizeHexColor(value);
|
||||
const palette = (Array.isArray(colors) ? colors : ENTITY_PALETTE_HEX)
|
||||
.map((color) => normalizeHexColor(color))
|
||||
.filter((color): color is string => Boolean(color));
|
||||
|
||||
if (normalizedValue && !palette.includes(normalizedValue)) {
|
||||
palette.push(normalizedValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="tw:gap-1.5"
|
||||
data-testid={dataTestId}
|
||||
role="group"
|
||||
wrap="wrap">
|
||||
{palette.map((color, index) => {
|
||||
const isSelected = normalizedValue === color;
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Select color ${color}`}
|
||||
aria-pressed={isSelected}
|
||||
className={cx(
|
||||
'tw:size-[34px] tw:rounded-[10px] tw:p-0! tw:shadow-xs tw:transition tw:duration-150',
|
||||
!disabled && 'tw:hover:scale-[1.02]',
|
||||
disabled && 'tw:opacity-50',
|
||||
isSelected && 'tw:ring-2 tw:ring-white tw:ring-offset-2',
|
||||
!isSelected && 'tw:ring-1 tw:ring-black/5',
|
||||
'tw:focus-visible:ring-2 tw:focus-visible:ring-brand tw:focus-visible:ring-offset-2'
|
||||
)}
|
||||
color="tertiary"
|
||||
data-testid={dataTestId ? `${dataTestId}-${index}` : undefined}
|
||||
iconLeading={
|
||||
isSelected ? (
|
||||
<Check aria-hidden="true" className="tw:size-5 tw:text-white" />
|
||||
) : undefined
|
||||
}
|
||||
id={index === 0 ? id : undefined}
|
||||
isDisabled={disabled}
|
||||
key={color}
|
||||
size="sm"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: isSelected
|
||||
? '0 0 0 1px rgba(16, 24, 40, 0.08)'
|
||||
: undefined,
|
||||
}}
|
||||
onBlur={() => onBlur?.()}
|
||||
onClick={() => onChange?.(color)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!palette.length && (
|
||||
<Typography
|
||||
aria-label={ariaLabel}
|
||||
className="tw:text-tertiary"
|
||||
id={id}
|
||||
size="text-sm">
|
||||
{emptyStateLabel ?? 'No colors available'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RefreshCcw01, Trash01, UploadCloud01 } from '@untitledui/icons';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box } from '@/components/base/box/box';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Typography } from '@/components/foundations/typography';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { FileUploadDropZone } from '../../file-upload/file-upload';
|
||||
|
||||
export type CoverImagePosition = { y: string };
|
||||
|
||||
export type CoverImageUploadValue =
|
||||
| { file: File; position?: CoverImagePosition }
|
||||
| { url: string; position?: CoverImagePosition }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export interface CoverImageUploadRenderPreviewContext {
|
||||
src: string;
|
||||
value:
|
||||
| { file: File; position?: CoverImagePosition }
|
||||
| { url: string; position?: CoverImagePosition };
|
||||
onChange: (next: CoverImageUploadValue) => void;
|
||||
onReplace: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export interface CoverImageUploadValidationMessages {
|
||||
sizeExceeded?: (maxSizeMB: number) => string;
|
||||
dimensionsExceeded?: (maxWidth: number, maxHeight: number) => string;
|
||||
failedToLoad?: string;
|
||||
}
|
||||
|
||||
export interface CoverImageUploadLabels {
|
||||
clickToUpload?: string;
|
||||
orDragAndDrop?: string;
|
||||
formatHint?: (formats: string, maxSizeMB?: number) => string;
|
||||
replace?: string;
|
||||
remove?: string;
|
||||
reposition?: string;
|
||||
savePosition?: string;
|
||||
cancel?: string;
|
||||
resetPosition?: string;
|
||||
dragHint?: string;
|
||||
imageTooSmallToReposition?: string;
|
||||
}
|
||||
|
||||
export interface CoverImageUploadFieldProps {
|
||||
value: CoverImageUploadValue;
|
||||
onChange: (next: CoverImageUploadValue) => void;
|
||||
onBlur?: () => void;
|
||||
acceptedFileTypes?: string[];
|
||||
isInvalid?: boolean;
|
||||
ariaLabel?: string;
|
||||
placeholder?: ReactNode;
|
||||
previewClassName?: string;
|
||||
previewHeight?: number;
|
||||
maxSizeMB?: number;
|
||||
maxDimensions?: { width: number; height: number };
|
||||
validationMessages?: CoverImageUploadValidationMessages;
|
||||
onValidationError?: (message: string) => void;
|
||||
repositionable?: boolean;
|
||||
labels?: CoverImageUploadLabels;
|
||||
renderPreview?: (ctx: CoverImageUploadRenderPreviewContext) => ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: Required<Omit<CoverImageUploadLabels, 'formatHint'>> & {
|
||||
formatHint: NonNullable<CoverImageUploadLabels['formatHint']>;
|
||||
} = {
|
||||
clickToUpload: 'Click to upload',
|
||||
orDragAndDrop: 'or drag and drop',
|
||||
formatHint: (formats, maxSizeMB) =>
|
||||
maxSizeMB
|
||||
? `${formats} (max. ${maxSizeMB}MB)`
|
||||
: formats || 'SVG, PNG, JPG or GIF',
|
||||
replace: 'Replace',
|
||||
remove: 'Remove cover image',
|
||||
reposition: 'Reposition',
|
||||
savePosition: 'Save position',
|
||||
cancel: 'Cancel',
|
||||
resetPosition: 'Reset position',
|
||||
dragHint: 'Drag to adjust position',
|
||||
imageTooSmallToReposition:
|
||||
'Image is too small to be repositioned within the preview area',
|
||||
};
|
||||
|
||||
const DEFAULT_VALIDATION_MESSAGES: Required<CoverImageUploadValidationMessages> =
|
||||
{
|
||||
sizeExceeded: (maxSizeMB) => `File exceeds the ${maxSizeMB}MB size limit`,
|
||||
dimensionsExceeded: (maxWidth, maxHeight) =>
|
||||
`Image dimensions exceed the ${maxWidth}x${maxHeight} pixel limit`,
|
||||
failedToLoad: 'Failed to load image',
|
||||
};
|
||||
|
||||
const DEFAULT_PREVIEW_HEIGHT = 160;
|
||||
|
||||
const hasFile = (
|
||||
value: CoverImageUploadValue
|
||||
): value is { file: File; position?: CoverImagePosition } =>
|
||||
Boolean(value && typeof value === 'object' && 'file' in value && value.file);
|
||||
|
||||
const hasUrl = (
|
||||
value: CoverImageUploadValue
|
||||
): value is { url: string; position?: CoverImagePosition } =>
|
||||
Boolean(value && typeof value === 'object' && 'url' in value && value.url);
|
||||
|
||||
const formatAcceptedTypes = (types: string[] | undefined): string => {
|
||||
if (!types || types.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return types
|
||||
.map((mime) => {
|
||||
const parts = mime.split('/');
|
||||
|
||||
return (parts[1] ?? mime).toUpperCase();
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const measureImage = (file: File): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
export const CoverImageUploadField = ({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
acceptedFileTypes,
|
||||
isInvalid,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
previewClassName,
|
||||
previewHeight = DEFAULT_PREVIEW_HEIGHT,
|
||||
maxSizeMB,
|
||||
maxDimensions,
|
||||
validationMessages,
|
||||
onValidationError,
|
||||
repositionable = true,
|
||||
labels,
|
||||
renderPreview,
|
||||
'data-testid': dataTestId,
|
||||
}: CoverImageUploadFieldProps) => {
|
||||
const replaceInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragStartYRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageNaturalHeight, setImageNaturalHeight] = useState(0);
|
||||
const [imageNaturalWidth, setImageNaturalWidth] = useState(0);
|
||||
const [isRepositioning, setIsRepositioning] = useState(false);
|
||||
const [isRepositionDragging, setIsRepositionDragging] = useState(false);
|
||||
const [tempOffsetY, setTempOffsetY] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
const setContainerNode = useCallback((node: HTMLDivElement | null) => {
|
||||
setContainerWidth(node?.offsetWidth ?? 0);
|
||||
}, []);
|
||||
|
||||
const mergedLabels = useMemo(
|
||||
() => ({ ...DEFAULT_LABELS, ...labels }),
|
||||
[labels]
|
||||
);
|
||||
const mergedValidationMessages = useMemo(
|
||||
() => ({ ...DEFAULT_VALIDATION_MESSAGES, ...validationMessages }),
|
||||
[validationMessages]
|
||||
);
|
||||
|
||||
const [previewSrc, setPreviewSrc] = useState('');
|
||||
|
||||
const file = hasFile(value) ? value.file : null;
|
||||
const url = hasUrl(value) ? value.url : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
setPreviewSrc(blobUrl);
|
||||
|
||||
return () => URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
setPreviewSrc(url ?? '');
|
||||
|
||||
return undefined;
|
||||
}, [file, url]);
|
||||
|
||||
useEffect(() => {
|
||||
setImageError(false);
|
||||
}, [previewSrc]);
|
||||
|
||||
const reportValidationError = useCallback(
|
||||
(message: string) => {
|
||||
if (message && onValidationError) {
|
||||
onValidationError(message);
|
||||
}
|
||||
},
|
||||
[onValidationError]
|
||||
);
|
||||
|
||||
const acceptFile = useCallback(
|
||||
async (file: File) => {
|
||||
let dims: { width: number; height: number };
|
||||
try {
|
||||
dims = await measureImage(file);
|
||||
} catch {
|
||||
reportValidationError(mergedValidationMessages.failedToLoad);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
maxDimensions &&
|
||||
(dims.width > maxDimensions.width || dims.height > maxDimensions.height)
|
||||
) {
|
||||
reportValidationError(
|
||||
mergedValidationMessages.dimensionsExceeded(
|
||||
maxDimensions.width,
|
||||
maxDimensions.height
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setImageNaturalWidth(dims.width);
|
||||
setImageNaturalHeight(dims.height);
|
||||
onChange({ file, position: undefined });
|
||||
},
|
||||
[maxDimensions, mergedValidationMessages, reportValidationError, onChange]
|
||||
);
|
||||
|
||||
const handleDropFiles = useCallback(
|
||||
(files: FileList) => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void acceptFile(files[0]);
|
||||
},
|
||||
[acceptFile]
|
||||
);
|
||||
|
||||
const handleSizeLimitExceed = useCallback(() => {
|
||||
if (maxSizeMB !== undefined) {
|
||||
reportValidationError(mergedValidationMessages.sizeExceeded(maxSizeMB));
|
||||
}
|
||||
}, [maxSizeMB, mergedValidationMessages, reportValidationError]);
|
||||
|
||||
const openReplacePicker = useCallback(() => {
|
||||
if (!replaceInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
replaceInputRef.current.value = '';
|
||||
replaceInputRef.current.click();
|
||||
}, []);
|
||||
|
||||
const handleReplaceInputChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
if (maxSizeMB !== undefined && file.size > maxSizeMB * 1024 * 1024) {
|
||||
reportValidationError(mergedValidationMessages.sizeExceeded(maxSizeMB));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void acceptFile(file);
|
||||
},
|
||||
[maxSizeMB, mergedValidationMessages, reportValidationError, acceptFile]
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onChange(null);
|
||||
}, [onChange]);
|
||||
|
||||
const getScaledImageHeight = useCallback(() => {
|
||||
if (!containerWidth || !imageNaturalWidth || !imageNaturalHeight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (imageNaturalHeight / imageNaturalWidth) * containerWidth;
|
||||
}, [imageNaturalHeight, imageNaturalWidth, containerWidth]);
|
||||
|
||||
const getBounds = useCallback(() => {
|
||||
const scaledHeight = getScaledImageHeight();
|
||||
const minY = Math.min(0, -(scaledHeight - previewHeight));
|
||||
|
||||
return { minY, maxY: 0 };
|
||||
}, [getScaledImageHeight, previewHeight]);
|
||||
|
||||
const isImageRepositionable = useMemo(() => {
|
||||
return getScaledImageHeight() > previewHeight;
|
||||
}, [getScaledImageHeight, previewHeight]);
|
||||
|
||||
const handleImageLoad = useCallback(
|
||||
(event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = event.currentTarget;
|
||||
setImageNaturalHeight(img.naturalHeight);
|
||||
setImageNaturalWidth(img.naturalWidth);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRepositionStart = useCallback(() => {
|
||||
if (!isImageRepositionable) {
|
||||
reportValidationError(mergedLabels.imageTooSmallToReposition);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRepositioning(true);
|
||||
const scaledHeight = getScaledImageHeight();
|
||||
const currentPercentage = value?.position?.y
|
||||
? parseFloat(value.position.y)
|
||||
: 0;
|
||||
setTempOffsetY((currentPercentage / 100) * scaledHeight);
|
||||
}, [
|
||||
isImageRepositionable,
|
||||
reportValidationError,
|
||||
mergedLabels.imageTooSmallToReposition,
|
||||
getScaledImageHeight,
|
||||
value,
|
||||
]);
|
||||
|
||||
const handleRepositionMouseDown = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
event.preventDefault();
|
||||
dragStartYRef.current = event.clientY;
|
||||
dragStartOffsetRef.current = tempOffsetY;
|
||||
setIsRepositionDragging(true);
|
||||
},
|
||||
[tempOffsetY]
|
||||
);
|
||||
|
||||
const handleRepositionTouchStart = useCallback(
|
||||
(event: ReactTouchEvent) => {
|
||||
event.preventDefault();
|
||||
dragStartYRef.current = event.touches[0].clientY;
|
||||
dragStartOffsetRef.current = tempOffsetY;
|
||||
setIsRepositionDragging(true);
|
||||
},
|
||||
[tempOffsetY]
|
||||
);
|
||||
|
||||
const moveReposition = useCallback(
|
||||
(clientY: number) => {
|
||||
const deltaY = clientY - dragStartYRef.current;
|
||||
const newOffset = dragStartOffsetRef.current + deltaY;
|
||||
const { minY, maxY } = getBounds();
|
||||
setTempOffsetY(Math.max(minY, Math.min(maxY, newOffset)));
|
||||
},
|
||||
[getBounds]
|
||||
);
|
||||
|
||||
const handleSaveReposition = useCallback(() => {
|
||||
if (!value) {
|
||||
setIsRepositioning(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const scaledHeight = getScaledImageHeight();
|
||||
const percentage =
|
||||
scaledHeight > 0 ? (tempOffsetY / scaledHeight) * 100 : 0;
|
||||
const newPosition: CoverImagePosition = {
|
||||
y: `${percentage.toFixed(2)}%`,
|
||||
};
|
||||
|
||||
if (hasFile(value)) {
|
||||
onChange({ file: value.file, position: newPosition });
|
||||
} else if (hasUrl(value)) {
|
||||
onChange({ url: value.url, position: newPosition });
|
||||
}
|
||||
setIsRepositioning(false);
|
||||
}, [value, getScaledImageHeight, tempOffsetY, onChange]);
|
||||
|
||||
const handleCancelReposition = useCallback(() => {
|
||||
const scaledHeight = getScaledImageHeight();
|
||||
const currentPercentage = value?.position?.y
|
||||
? parseFloat(value.position.y)
|
||||
: 0;
|
||||
setTempOffsetY((currentPercentage / 100) * scaledHeight);
|
||||
setIsRepositioning(false);
|
||||
}, [value, getScaledImageHeight]);
|
||||
|
||||
const handleResetPosition = useCallback(() => {
|
||||
const { minY } = getBounds();
|
||||
setTempOffsetY(minY / 2);
|
||||
}, [getBounds]);
|
||||
|
||||
const handleRepositionKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent) => {
|
||||
if (!isRepositioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = event.shiftKey ? 1 : 5;
|
||||
const { minY, maxY } = getBounds();
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setTempOffsetY((prev) => Math.min(maxY, prev + step));
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setTempOffsetY((prev) => Math.max(minY, prev - step));
|
||||
} else if (event.key === 'Escape') {
|
||||
handleCancelReposition();
|
||||
} else if (event.key === 'Enter') {
|
||||
handleSaveReposition();
|
||||
}
|
||||
},
|
||||
[isRepositioning, getBounds, handleCancelReposition, handleSaveReposition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRepositionDragging) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => moveReposition(event.clientY);
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
moveReposition(event.touches[0].clientY);
|
||||
};
|
||||
const onEnd = () => setIsRepositionDragging(false);
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
window.addEventListener('touchend', onEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onEnd);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
};
|
||||
}, [isRepositionDragging, moveReposition]);
|
||||
|
||||
const isFilled = hasFile(value) || hasUrl(value);
|
||||
const showDefaultPreview = isFilled && Boolean(previewSrc) && !imageError;
|
||||
|
||||
const acceptString = acceptedFileTypes?.join(',');
|
||||
const formattedTypes = formatAcceptedTypes(acceptedFileTypes);
|
||||
const formatHint = mergedLabels.formatHint(formattedTypes, maxSizeMB);
|
||||
|
||||
const imageTransform: CSSProperties['transform'] = isRepositioning
|
||||
? `translateY(${tempOffsetY}px)`
|
||||
: value?.position?.y
|
||||
? `translateY(${value.position.y})`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box data-testid={dataTestId} direction="col" gap={2} onBlur={onBlur}>
|
||||
<input
|
||||
accept={acceptString}
|
||||
ref={replaceInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
onChange={handleReplaceInputChange}
|
||||
/>
|
||||
|
||||
{isFilled && renderPreview ? (
|
||||
renderPreview({
|
||||
src: previewSrc,
|
||||
value: value as
|
||||
| { file: File; position?: CoverImagePosition }
|
||||
| { url: string; position?: CoverImagePosition },
|
||||
onChange,
|
||||
onReplace: openReplacePicker,
|
||||
onRemove: handleRemove,
|
||||
})
|
||||
) : showDefaultPreview ? (
|
||||
<div
|
||||
aria-invalid={isInvalid || undefined}
|
||||
className={cx(
|
||||
'tw:group tw:relative tw:w-full tw:overflow-hidden tw:rounded-lg tw:ring-1 tw:ring-secondary',
|
||||
isInvalid && 'tw:ring-2 tw:ring-error',
|
||||
previewClassName
|
||||
)}
|
||||
data-testid="cover-image-upload-preview-container"
|
||||
ref={setContainerNode}
|
||||
style={{ height: previewHeight }}
|
||||
tabIndex={isRepositioning ? 0 : -1}
|
||||
onKeyDown={handleRepositionKeyDown}>
|
||||
<img
|
||||
alt={ariaLabel ?? 'Cover image preview'}
|
||||
className="tw:block tw:h-auto tw:w-full tw:object-cover tw:object-top tw:select-none"
|
||||
data-testid="cover-image-upload-preview"
|
||||
src={previewSrc}
|
||||
style={{
|
||||
minHeight: previewHeight,
|
||||
transform: imageTransform,
|
||||
transition: isRepositionDragging ? 'none' : 'transform 0.2s ease',
|
||||
cursor: isRepositioning ? 'ns-resize' : 'default',
|
||||
touchAction: isRepositioning ? 'none' : 'auto',
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
onLoad={handleImageLoad}
|
||||
onMouseDown={
|
||||
isRepositioning ? handleRepositionMouseDown : undefined
|
||||
}
|
||||
onTouchStart={
|
||||
isRepositioning ? handleRepositionTouchStart : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isRepositioning && (
|
||||
<div className="tw:pointer-events-none tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:rounded tw:bg-black/60 tw:px-2 tw:py-1">
|
||||
<Typography
|
||||
className="tw:text-white"
|
||||
size="text-xs"
|
||||
weight="medium">
|
||||
{mergedLabels.dragHint}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:inset-x-0 tw:bottom-0 tw:flex tw:items-center tw:gap-2 tw:p-3 tw:transition tw:duration-150',
|
||||
isRepositioning
|
||||
? 'tw:justify-between'
|
||||
: 'tw:justify-end tw:bg-gradient-to-t tw:from-black/60 tw:to-transparent tw:opacity-0 tw:group-hover:opacity-100 tw:group-focus-within:opacity-100'
|
||||
)}>
|
||||
{isRepositioning ? (
|
||||
<>
|
||||
<Button
|
||||
className="tw:pointer-events-auto"
|
||||
color="secondary"
|
||||
data-testid="cover-image-upload-reset-position"
|
||||
iconLeading={RefreshCcw01}
|
||||
size="sm"
|
||||
onPress={handleResetPosition}>
|
||||
{mergedLabels.resetPosition}
|
||||
</Button>
|
||||
<div className="tw:flex tw:gap-2">
|
||||
<Button
|
||||
className="tw:pointer-events-auto"
|
||||
color="secondary"
|
||||
data-testid="cover-image-upload-save-position"
|
||||
size="sm"
|
||||
onPress={handleSaveReposition}>
|
||||
{mergedLabels.savePosition}
|
||||
</Button>
|
||||
<Button
|
||||
className="tw:pointer-events-auto"
|
||||
color="secondary"
|
||||
data-testid="cover-image-upload-cancel-position"
|
||||
size="sm"
|
||||
onPress={handleCancelReposition}>
|
||||
{mergedLabels.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="tw:pointer-events-auto"
|
||||
color="secondary"
|
||||
data-testid="cover-image-upload-replace"
|
||||
iconLeading={UploadCloud01}
|
||||
size="sm"
|
||||
onPress={openReplacePicker}>
|
||||
{mergedLabels.replace}
|
||||
</Button>
|
||||
{repositionable && (
|
||||
<Button
|
||||
className="tw:pointer-events-auto"
|
||||
color="secondary"
|
||||
data-testid="cover-image-upload-reposition"
|
||||
isDisabled={!isImageRepositionable}
|
||||
size="sm"
|
||||
onPress={handleRepositionStart}>
|
||||
{mergedLabels.reposition}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={mergedLabels.remove}
|
||||
className="tw:pointer-events-auto"
|
||||
color="secondary-destructive"
|
||||
data-testid="cover-image-upload-remove"
|
||||
iconLeading={Trash01}
|
||||
size="sm"
|
||||
onPress={handleRemove}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FileUploadDropZone
|
||||
accept={acceptString}
|
||||
allowsMultiple={false}
|
||||
clickToUploadLabel={
|
||||
typeof placeholder === 'string'
|
||||
? placeholder
|
||||
: mergedLabels.clickToUpload
|
||||
}
|
||||
data-testid="cover-image-upload-empty-zone"
|
||||
hint={formatHint}
|
||||
isInvalid={isInvalid}
|
||||
maxSize={maxSizeMB ? maxSizeMB * 1024 * 1024 : undefined}
|
||||
orDragAndDropLabel={mergedLabels.orDragAndDrop}
|
||||
onDropFiles={handleDropFiles}
|
||||
onSizeLimitExceed={handleSizeLimitExceed}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
CoverImageUploadField.displayName = 'CoverImageUploadField';
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { FC, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
|
||||
import {
|
||||
createElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Key } from 'react-aria-components';
|
||||
import { normalizeHexColor } from '@/colors/colorValidation';
|
||||
import { ENTITY_PALETTE_HEX } from '@/colors/entityPalette';
|
||||
import { Tabs } from '@/components/application/tabs/tabs';
|
||||
import { Box } from '@/components/base/box/box';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Typography } from '@/components/foundations/typography';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
import type {
|
||||
FormSelectItem,
|
||||
IconPickerFieldLabels,
|
||||
} from '../form-field.types';
|
||||
|
||||
const TRIGGER_ICON_CLASSNAME =
|
||||
'tw:block tw:size-5 tw:text-white tw:[stroke-width:1.25]';
|
||||
const GRID_ICON_CLASSNAME =
|
||||
'tw:block tw:size-5 tw:text-primary tw:[stroke-width:1.25]';
|
||||
|
||||
const renderSelectItemIcon = (
|
||||
icon: FormSelectItem['icon'],
|
||||
className: string,
|
||||
{ size = 20 }: { size?: number } = {}
|
||||
) => {
|
||||
if (isReactComponent(icon)) {
|
||||
return createElement(icon, {
|
||||
'aria-hidden': true,
|
||||
className,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
return isValidElement(icon) ? icon : null;
|
||||
};
|
||||
|
||||
const getDefaultIconPreview = (
|
||||
items: FormSelectItem[],
|
||||
defaultIcon?: { component: FC }
|
||||
): ReactNode => {
|
||||
if (defaultIcon && isReactComponent(defaultIcon.component)) {
|
||||
return renderSelectItemIcon(defaultIcon.component, TRIGGER_ICON_CLASSNAME);
|
||||
}
|
||||
|
||||
return renderSelectItemIcon(items[0]?.icon, TRIGGER_ICON_CLASSNAME);
|
||||
};
|
||||
|
||||
export interface IconPickerFieldProps {
|
||||
allowUrl?: boolean;
|
||||
ariaLabel?: string;
|
||||
backgroundColor?: string;
|
||||
'data-testid'?: string;
|
||||
defaultIcon?: { component: FC };
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
items: FormSelectItem[];
|
||||
labels?: IconPickerFieldLabels;
|
||||
name: string;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onSelectionChange?: (key: Key | null) => void;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const IconPickerField = ({
|
||||
allowUrl = false,
|
||||
ariaLabel,
|
||||
backgroundColor: backgroundColorProp,
|
||||
'data-testid': dataTestId,
|
||||
defaultIcon,
|
||||
disabled = false,
|
||||
id,
|
||||
items,
|
||||
labels,
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
onSelectionChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: IconPickerFieldProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'icons' | 'url'>('icons');
|
||||
const selectedItem = items.find((item) => item.id === value);
|
||||
const backgroundColor =
|
||||
(backgroundColorProp ? normalizeHexColor(backgroundColorProp) : null) ??
|
||||
ENTITY_PALETTE_HEX[6];
|
||||
const hasCustomImage = allowUrl && value !== '' && !selectedItem;
|
||||
const onBlurRef = useRef(onBlur);
|
||||
onBlurRef.current = onBlur;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveTab(allowUrl && hasCustomImage ? 'url' : 'icons');
|
||||
}
|
||||
}, [allowUrl, hasCustomImage, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!wrapperRef.current?.contains(event.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
onBlurRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
onBlurRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleIconSelection = (item: FormSelectItem) => {
|
||||
onBlur?.();
|
||||
onChange?.(item.id);
|
||||
onSelectionChange?.(item.id);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const triggerPreview = (() => {
|
||||
if (selectedItem) {
|
||||
return renderSelectItemIcon(selectedItem.icon, TRIGGER_ICON_CLASSNAME);
|
||||
}
|
||||
|
||||
if (hasCustomImage) {
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
className="tw:h-7 tw:w-7 tw:rounded-sm tw:object-contain"
|
||||
src={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
getDefaultIconPreview(items, defaultIcon) ?? (
|
||||
<Typography className="tw:text-white" size="text-sm" weight="semibold">
|
||||
?
|
||||
</Typography>
|
||||
)
|
||||
);
|
||||
})();
|
||||
|
||||
const togglePicker = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTab(allowUrl && hasCustomImage ? 'url' : 'icons');
|
||||
setIsOpen((current) => !current);
|
||||
};
|
||||
|
||||
const iconGrid = (
|
||||
<div className="tw:p-4">
|
||||
{items.length > 0 ? (
|
||||
<div className="tw:grid tw:grid-cols-6 tw:gap-4">
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedItem?.id === item.id;
|
||||
const previewIcon = renderSelectItemIcon(
|
||||
item.icon,
|
||||
GRID_ICON_CLASSNAME
|
||||
);
|
||||
|
||||
const commonButtonProps = {
|
||||
'aria-label': item.label ?? item.id,
|
||||
'aria-pressed': isSelected,
|
||||
className: cx(
|
||||
'tw:size-9 tw:rounded-lg tw:p-0! tw:ring-1 tw:ring-secondary_alt tw:transition tw:duration-150',
|
||||
isSelected
|
||||
? 'tw:bg-primary_hover tw:ring-brand'
|
||||
: 'tw:bg-primary tw:hover:bg-primary_hover',
|
||||
'tw:focus-visible:ring-2 tw:focus-visible:ring-brand'
|
||||
),
|
||||
color: 'tertiary' as const,
|
||||
size: 'sm' as const,
|
||||
onClick: () => handleIconSelection(item),
|
||||
};
|
||||
|
||||
return previewIcon ? (
|
||||
<Button
|
||||
{...commonButtonProps}
|
||||
iconLeading={previewIcon}
|
||||
key={item.id}
|
||||
/>
|
||||
) : (
|
||||
<Button {...commonButtonProps} key={item.id}>
|
||||
<Typography
|
||||
className="tw:text-primary"
|
||||
size="text-sm"
|
||||
weight="semibold">
|
||||
{(item.label ?? item.id).slice(0, 1).toUpperCase()}
|
||||
</Typography>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Typography className="tw:text-tertiary" size="text-sm">
|
||||
{labels?.emptyState ?? 'No icons available'}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const urlPanel = (
|
||||
<Box className="tw:p-4" direction="col" gap={2}>
|
||||
<Typography className="tw:text-tertiary" size="text-xs" weight="medium">
|
||||
{labels?.customIconUrl ?? 'Custom icon URL'}
|
||||
</Typography>
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={placeholder ?? labels?.enterIconUrl ?? 'Enter icon URL'}
|
||||
name={name}
|
||||
placeholder={placeholder ?? labels?.enterIconUrl ?? 'Enter icon URL'}
|
||||
value={selectedItem ? '' : value}
|
||||
onBlur={() => onBlur?.()}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tw:relative tw:w-fit" ref={wrapperRef}>
|
||||
<Button
|
||||
aria-label={
|
||||
ariaLabel ?? placeholder ?? labels?.emptyState ?? 'Select icon'
|
||||
}
|
||||
className={cx(
|
||||
'tw:size-[34px] tw:rounded-[10px] tw:p-0! tw:shadow-xs tw:ring-1 tw:ring-black/5 tw:transition tw:duration-150',
|
||||
!disabled && 'tw:hover:scale-[1.02]',
|
||||
disabled && 'tw:opacity-50',
|
||||
'tw:focus-visible:ring-2 tw:focus-visible:ring-brand tw:focus-visible:ring-offset-2',
|
||||
isOpen && 'tw:ring-2 tw:ring-brand tw:ring-offset-2'
|
||||
)}
|
||||
color="tertiary"
|
||||
data-testid={dataTestId}
|
||||
iconLeading={triggerPreview ?? undefined}
|
||||
id={id}
|
||||
isDisabled={disabled}
|
||||
size="sm"
|
||||
style={{ backgroundColor }}
|
||||
onBlur={() => onBlur?.()}
|
||||
onClick={togglePicker}
|
||||
onKeyDown={(event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!disabled && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
togglePicker();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="tw:absolute tw:top-[calc(100%+8px)] tw:left-0 tw:z-50 tw:w-[22rem] tw:max-w-[calc(100vw-2rem)] tw:rounded-xl tw:bg-primary tw:shadow-lg tw:ring-1 tw:ring-secondary_alt">
|
||||
{allowUrl ? (
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) =>
|
||||
setActiveTab(key === 'url' ? 'url' : 'icons')
|
||||
}>
|
||||
<Tabs.List
|
||||
fullWidth
|
||||
className="tw:border-b tw:border-secondary_alt tw:p-1"
|
||||
size="sm"
|
||||
type="button-minimal">
|
||||
<Tabs.Item id="icons" label={labels?.iconsTab ?? 'Icons'} />
|
||||
<Tabs.Item id="url" label={labels?.urlTab ?? 'URL'} />
|
||||
</Tabs.List>
|
||||
<Tabs.Panel id="icons">{iconGrid}</Tabs.Panel>
|
||||
<Tabs.Panel id="url">{urlPanel}</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
iconGrid
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import type { RegisterOptions } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Alert } from '@/components/base/alert/alert';
|
||||
import { Box } from '@/components/base/box/box';
|
||||
import { Divider } from '@/components/base/divider/divider';
|
||||
import { FormField } from '@/components/base/form/hook-form';
|
||||
import { HintText } from '@/components/base/input/hint-text';
|
||||
import { useFieldDoc } from './field-doc-context';
|
||||
import { type FieldProp, HelperTextType } from './form-field.types';
|
||||
import { FormItemLabel } from './form-item-label';
|
||||
import { renderFieldElement } from './render-field-element';
|
||||
|
||||
export const Field: FC<{ field: FieldProp }> = ({ field }) => {
|
||||
const { control } = useFormContext();
|
||||
const {
|
||||
name,
|
||||
label,
|
||||
required,
|
||||
rules,
|
||||
id,
|
||||
helperText,
|
||||
helperTextType = HelperTextType.ALERT,
|
||||
hasSeparator = false,
|
||||
} = field;
|
||||
|
||||
const effectiveRules: RegisterOptions = { ...rules };
|
||||
if (required && !effectiveRules.required) {
|
||||
effectiveRules.required = true;
|
||||
}
|
||||
|
||||
const fieldDocProps = useFieldDoc({
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
doc: field.doc,
|
||||
});
|
||||
|
||||
const rendered = (
|
||||
<FormField control={control} name={name} rules={effectiveRules}>
|
||||
{(controller) => {
|
||||
const { fieldState } = controller;
|
||||
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
<Box className="tw:gap-[6px]" direction="col" {...fieldDocProps}>
|
||||
<FormItemLabel
|
||||
label={label}
|
||||
required={required}
|
||||
tooltip={
|
||||
helperTextType === HelperTextType.TOOLTIP
|
||||
? helperText
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{renderFieldElement(controller, field)}
|
||||
|
||||
{fieldState.error && (
|
||||
<HintText isInvalid>{fieldState.error.message}</HintText>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{helperTextType === HelperTextType.ALERT && helperText && (
|
||||
<Alert
|
||||
data-testid="form-item-alert"
|
||||
title={typeof helperText === 'string' ? helperText : ''}
|
||||
variant="warning">
|
||||
{typeof helperText === 'string' ? undefined : helperText}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasSeparator && <Divider />}
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
);
|
||||
|
||||
return rendered;
|
||||
};
|
||||
|
||||
Field.displayName = 'Field';
|
||||
|
||||
export const getField = (fieldProp: FieldProp): ReactNode => (
|
||||
<Field field={fieldProp} />
|
||||
);
|
||||
|
||||
export const FormFields: FC<{ fields: FieldProp[] }> = ({ fields }) => (
|
||||
<>
|
||||
{fields.map((f, i) => (
|
||||
<Field field={f} key={f.id ?? f.name ?? i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
FormFields.displayName = 'FormFields';
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SelectItemType } from '@/components/base/select/select';
|
||||
import type { FC, FocusEventHandler, ReactNode } from 'react';
|
||||
import type { Key } from 'react-aria-components';
|
||||
import type { RegisterOptions } from 'react-hook-form';
|
||||
import type {
|
||||
CoverImageUploadLabels,
|
||||
CoverImageUploadRenderPreviewContext,
|
||||
CoverImageUploadValidationMessages,
|
||||
} from './fields/cover-image-upload-field';
|
||||
|
||||
export type {
|
||||
CoverImagePosition,
|
||||
CoverImageUploadLabels,
|
||||
CoverImageUploadRenderPreviewContext,
|
||||
CoverImageUploadValidationMessages,
|
||||
CoverImageUploadValue,
|
||||
} from './fields/cover-image-upload-field';
|
||||
|
||||
export enum HelperTextType {
|
||||
ALERT = 'alert',
|
||||
TOOLTIP = 'tooltip',
|
||||
}
|
||||
|
||||
export enum FormItemLayout {
|
||||
HORIZONTAL = 'horizontal',
|
||||
VERTICAL = 'vertical',
|
||||
}
|
||||
|
||||
export enum FieldTypes {
|
||||
TEXT = 'text',
|
||||
PASSWORD = 'password',
|
||||
NUMBER = 'number',
|
||||
SELECT = 'select',
|
||||
AUTOCOMPLETE = 'autocomplete',
|
||||
MULTI_SELECT = 'multi_select',
|
||||
SWITCH = 'switch',
|
||||
CHECKBOX = 'checkbox',
|
||||
TEXTAREA = 'textarea',
|
||||
DESCRIPTION = 'description',
|
||||
FILTER_PATTERN = 'filter_pattern',
|
||||
SLIDER = 'slider',
|
||||
ASYNC_SELECT = 'async_select',
|
||||
TREE_ASYNC_SELECT = 'tree_async_select',
|
||||
TAG_SUGGESTION = 'tag_suggestion',
|
||||
UT_TAG_SUGGESTION = 'ut_tag_suggestion',
|
||||
GLOSSARY_TAG_SUGGESTION = 'glossary_tag_suggestion',
|
||||
USER_TEAM_SELECT = 'user_team_select',
|
||||
USER_MULTI_SELECT = 'user_multi_select',
|
||||
USER_TEAM_SELECT_INPUT = 'user_team_select_input',
|
||||
COLOR_PICKER = 'color_picker',
|
||||
ICON_PICKER = 'icon_picker',
|
||||
COVER_IMAGE_UPLOAD = 'cover_image_upload',
|
||||
DOMAIN_SELECT = 'domain_select',
|
||||
CRON_EDITOR = 'cron_editor',
|
||||
SELECT_NATIVE = 'select_native',
|
||||
COMPONENT = 'component',
|
||||
}
|
||||
|
||||
export type FormSelectItem = SelectItemType;
|
||||
|
||||
export interface IconPickerFieldLabels {
|
||||
customIconUrl?: string;
|
||||
emptyState?: string;
|
||||
enterIconUrl?: string;
|
||||
iconsTab?: string;
|
||||
urlTab?: string;
|
||||
}
|
||||
|
||||
export interface FieldPropsMap {
|
||||
acceptDirectory?: boolean;
|
||||
acceptedFileTypes?: string[];
|
||||
allowsCreation?: boolean;
|
||||
allowsMultiple?: boolean;
|
||||
hideDropdown?: boolean;
|
||||
allowUrl?: boolean;
|
||||
backgroundColor?: string;
|
||||
'aria-label'?: string;
|
||||
children?: ReactNode;
|
||||
colors?: string[];
|
||||
'data-testid'?: string;
|
||||
defaultCamera?: 'environment' | 'user';
|
||||
defaultIcon?: { component: FC };
|
||||
disabled?: boolean;
|
||||
// react-aria field components (Select, Autocomplete, TextField, ...) use
|
||||
// `isDisabled`; it is forwarded to them via `...rest` in render-field-element.
|
||||
isDisabled?: boolean;
|
||||
filterOption?: (option: FormSelectItem, searchText: string) => boolean;
|
||||
fontSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
emptyStateLabel?: string;
|
||||
initialValue?: string;
|
||||
items?: FormSelectItem[];
|
||||
labels?: IconPickerFieldLabels;
|
||||
multiple?: boolean;
|
||||
onBlur?: () => void;
|
||||
onFocus?: FocusEventHandler;
|
||||
onChange?: (value: string) => void;
|
||||
onItemCleared?: (key: Key) => void;
|
||||
onItemInserted?: (key: Key) => void;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onSelect?: (files: FileList | null) => void;
|
||||
onSelectionChange?: (key: Key | null) => void;
|
||||
options?: FormSelectItem[];
|
||||
coverImageLabels?: CoverImageUploadLabels;
|
||||
maxDimensions?: { width: number; height: number };
|
||||
maxSizeMB?: number;
|
||||
onValidationError?: (message: string) => void;
|
||||
previewClassName?: string;
|
||||
previewHeight?: number;
|
||||
renderItem?: (item: FormSelectItem) => ReactNode;
|
||||
renderPreview?: (ctx: CoverImageUploadRenderPreviewContext) => ReactNode;
|
||||
repositionable?: boolean;
|
||||
selectedItems?: FormSelectItem[];
|
||||
size?: 'sm' | 'md';
|
||||
validationMessages?: CoverImageUploadValidationMessages;
|
||||
}
|
||||
|
||||
export interface FieldProp {
|
||||
name: string;
|
||||
label: ReactNode;
|
||||
type: FieldTypes;
|
||||
required?: boolean;
|
||||
rules?: RegisterOptions;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
props?: FieldPropsMap;
|
||||
helperText?: ReactNode;
|
||||
helperTextType?: HelperTextType;
|
||||
showHelperText?: boolean;
|
||||
/** Markdown documentation for this field, shown in the field doc popover. */
|
||||
doc?: string;
|
||||
hasSeparator?: boolean;
|
||||
formItemLayout?: FormItemLayout;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { HelpCircle } from '@untitledui/icons';
|
||||
import { Tooltip, TooltipTrigger } from '@/components/base/tooltip/tooltip';
|
||||
|
||||
export interface FormItemLabelProps {
|
||||
label: ReactNode;
|
||||
tooltip?: ReactNode;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const FormItemLabel = ({
|
||||
label,
|
||||
tooltip,
|
||||
required = false,
|
||||
}: FormItemLabelProps) => (
|
||||
<span className="tw:inline-flex tw:items-center tw:gap-1">
|
||||
<span data-testid="form-item-label">{label}</span>
|
||||
{required && <span className="tw:text-error-primary">*</span>}
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip}>
|
||||
<TooltipTrigger
|
||||
className="tw:flex tw:items-center tw:cursor-pointer tw:text-fg-quaternary tw:transition tw:duration-200 tw:hover:text-fg-quaternary_hover tw:focus:text-fg-quaternary_hover"
|
||||
isDisabled={false}>
|
||||
<HelpCircle className="tw:size-4" />
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Autocomplete } from '@/components/base/autocomplete/autocomplete';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { NativeSelect } from '@/components/base/select/select-native';
|
||||
import { Slider } from '@/components/base/slider/slider';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import { Toggle } from '@/components/base/toggle/toggle';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Key } from 'react-aria-components';
|
||||
import type { UseControllerReturn } from 'react-hook-form';
|
||||
import { ColorPickerField } from './fields/color-picker-field';
|
||||
import {
|
||||
CoverImageUploadField,
|
||||
type CoverImageUploadValue,
|
||||
} from './fields/cover-image-upload-field';
|
||||
import { IconPickerField } from './fields/icon-picker-field';
|
||||
import {
|
||||
FieldTypes,
|
||||
type FieldProp,
|
||||
type FieldPropsMap,
|
||||
type FormSelectItem,
|
||||
} from './form-field.types';
|
||||
|
||||
const AUTOCOMPLETE_FIELD_TYPES = new Set<FieldTypes>([
|
||||
FieldTypes.AUTOCOMPLETE,
|
||||
FieldTypes.MULTI_SELECT,
|
||||
FieldTypes.ASYNC_SELECT,
|
||||
FieldTypes.TREE_ASYNC_SELECT,
|
||||
FieldTypes.TAG_SUGGESTION,
|
||||
FieldTypes.UT_TAG_SUGGESTION,
|
||||
FieldTypes.GLOSSARY_TAG_SUGGESTION,
|
||||
FieldTypes.USER_TEAM_SELECT,
|
||||
FieldTypes.USER_MULTI_SELECT,
|
||||
FieldTypes.USER_TEAM_SELECT_INPUT,
|
||||
FieldTypes.DOMAIN_SELECT,
|
||||
]);
|
||||
|
||||
const isMultipleSelection = (
|
||||
value: string | string[],
|
||||
props: FieldPropsMap
|
||||
) => {
|
||||
if (typeof props.multiple === 'boolean') {
|
||||
return props.multiple;
|
||||
}
|
||||
|
||||
if (props.multiple !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.isArray(value);
|
||||
};
|
||||
|
||||
const getItems = (props: FieldPropsMap): FormSelectItem[] =>
|
||||
props.items ?? props.options ?? [];
|
||||
|
||||
const getSelectedItems = (
|
||||
value: FormSelectItem | FormSelectItem[]
|
||||
): FormSelectItem[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const getDefaultAutocompleteItems = (items: FormSelectItem[]) =>
|
||||
items.map((item) => (
|
||||
<Autocomplete.Item
|
||||
avatarUrl={item.avatarUrl}
|
||||
icon={item.icon}
|
||||
id={item.id}
|
||||
isDisabled={item.isDisabled}
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
supportingText={item.supportingText}
|
||||
/>
|
||||
));
|
||||
|
||||
export const renderFieldElement = (
|
||||
controller: UseControllerReturn,
|
||||
fieldConfig: FieldProp
|
||||
): ReactNode => {
|
||||
const { field, fieldState } = controller;
|
||||
const { type, id, label, placeholder, props = {} } = fieldConfig;
|
||||
const {
|
||||
children,
|
||||
renderItem,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onSelectionChange,
|
||||
onItemInserted,
|
||||
onItemCleared,
|
||||
onSearchChange,
|
||||
onSelect: _onSelect,
|
||||
size: _size,
|
||||
selectedItems: _selectedItems,
|
||||
options: _options,
|
||||
items: _items,
|
||||
multiple: _multiple,
|
||||
...rest
|
||||
} = props;
|
||||
const isInvalid = fieldState.invalid;
|
||||
const ariaLabel = typeof label === 'string' ? label : undefined;
|
||||
const selectItems = getItems(props);
|
||||
|
||||
if (AUTOCOMPLETE_FIELD_TYPES.has(type)) {
|
||||
const multiple = isMultipleSelection(field.value, props);
|
||||
const selectedAutocompleteItems = getSelectedItems(field.value);
|
||||
|
||||
const handleInsert = (key: Key) => {
|
||||
const keyStr = String(key);
|
||||
const selectedItem =
|
||||
selectItems.find((item) => item.id === keyStr) ??
|
||||
(rest.allowsCreation ? { id: keyStr, label: keyStr } : undefined);
|
||||
|
||||
if (!selectedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
const nextItems = [...selectedAutocompleteItems, selectedItem];
|
||||
|
||||
field.onChange(nextItems);
|
||||
} else {
|
||||
field.onChange(selectedItem);
|
||||
}
|
||||
onItemInserted?.(key);
|
||||
};
|
||||
|
||||
const handleClear = (key: Key) => {
|
||||
const nextItems = selectedAutocompleteItems.filter(
|
||||
(item) => item.id !== String(key)
|
||||
);
|
||||
|
||||
field.onChange(multiple ? nextItems : null);
|
||||
onItemCleared?.(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
items={selectItems}
|
||||
multiple={multiple}
|
||||
placeholder={placeholder}
|
||||
selectedItems={selectedAutocompleteItems}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onItemCleared={handleClear}
|
||||
onItemInserted={handleInsert}
|
||||
onSearchChange={onSearchChange}>
|
||||
{typeof renderItem === 'function'
|
||||
? selectItems.map((item) => renderItem(item))
|
||||
: getDefaultAutocompleteItems(selectItems)}
|
||||
</Autocomplete>
|
||||
);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case FieldTypes.TEXT:
|
||||
return (
|
||||
<Input
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ''}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.PASSWORD:
|
||||
return (
|
||||
<Input
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
type="password"
|
||||
value={field.value ?? ''}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.NUMBER:
|
||||
return (
|
||||
<Input
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
type="number"
|
||||
value={field.value ?? ''}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.TEXTAREA:
|
||||
case FieldTypes.DESCRIPTION:
|
||||
return (
|
||||
<TextArea
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
value={field.value ?? props.initialValue ?? ''}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.FILTER_PATTERN:
|
||||
case FieldTypes.CRON_EDITOR:
|
||||
return (
|
||||
<TextArea
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
textAreaClassName="tw:font-mono"
|
||||
value={field.value ?? props.initialValue ?? ''}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.SWITCH:
|
||||
return (
|
||||
<Toggle
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isSelected={field.value ?? false}
|
||||
name={field.name}
|
||||
onFocus={onFocus}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(String(value));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.CHECKBOX:
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
isSelected={field.value ?? false}
|
||||
name={field.name}
|
||||
onFocus={onFocus}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(String(value));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.SLIDER:
|
||||
return (
|
||||
<Slider
|
||||
id={id}
|
||||
value={field.value ?? 0}
|
||||
{...rest}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(String(value));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.COLOR_PICKER:
|
||||
return (
|
||||
<ColorPickerField
|
||||
ariaLabel={ariaLabel}
|
||||
colors={props.colors}
|
||||
data-testid={props['data-testid']}
|
||||
disabled={props.disabled}
|
||||
emptyStateLabel={props.emptyStateLabel}
|
||||
id={id}
|
||||
value={field.value ?? ''}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.SELECT_NATIVE: {
|
||||
const nativeSelectedItem = field.value as FormSelectItem | null;
|
||||
|
||||
return (
|
||||
<NativeSelect
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
name={field.name}
|
||||
options={selectItems.map((item) => ({
|
||||
label: item.label ?? item.id,
|
||||
value: item.id,
|
||||
disabled: item.isDisabled,
|
||||
}))}
|
||||
value={nativeSelectedItem?.id ?? ''}
|
||||
{...rest}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const nextItem = selectItems.find(
|
||||
(item) => item.id === event.target.value
|
||||
);
|
||||
|
||||
field.onChange(nextItem ?? null);
|
||||
onChange?.(nextItem?.id ?? '');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldTypes.SELECT: {
|
||||
const selectedItem = field.value as FormSelectItem | null;
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={ariaLabel}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
items={selectItems}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
selectedKey={selectedItem?.id ?? null}
|
||||
{...rest}
|
||||
onFocus={onFocus}
|
||||
onSelectionChange={(key) => {
|
||||
const nextItem = selectItems.find(
|
||||
(item) => item.id === String(key)
|
||||
);
|
||||
|
||||
field.onChange(nextItem ?? null);
|
||||
onSelectionChange?.(key);
|
||||
}}>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
avatarUrl={item.avatarUrl}
|
||||
icon={item.icon}
|
||||
id={item.id}
|
||||
isDisabled={item.isDisabled}
|
||||
supportingText={item.supportingText}>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldTypes.ICON_PICKER:
|
||||
return (
|
||||
<IconPickerField
|
||||
allowUrl={props.allowUrl}
|
||||
ariaLabel={ariaLabel}
|
||||
backgroundColor={props.backgroundColor}
|
||||
data-testid={props['data-testid']}
|
||||
defaultIcon={props.defaultIcon}
|
||||
disabled={props.disabled}
|
||||
id={id}
|
||||
items={selectItems}
|
||||
labels={props.labels}
|
||||
name={field.name}
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ''}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.COVER_IMAGE_UPLOAD:
|
||||
return (
|
||||
<CoverImageUploadField
|
||||
acceptedFileTypes={props.acceptedFileTypes}
|
||||
ariaLabel={ariaLabel}
|
||||
data-testid={props['data-testid']}
|
||||
isInvalid={isInvalid}
|
||||
labels={props.coverImageLabels}
|
||||
maxDimensions={props.maxDimensions}
|
||||
maxSizeMB={props.maxSizeMB}
|
||||
placeholder={placeholder}
|
||||
previewClassName={props.previewClassName}
|
||||
previewHeight={props.previewHeight}
|
||||
renderPreview={props.renderPreview}
|
||||
repositionable={props.repositionable}
|
||||
validationMessages={props.validationMessages}
|
||||
value={field.value as CoverImageUploadValue}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
onBlur?.();
|
||||
}}
|
||||
onChange={(next) => {
|
||||
field.onChange(next);
|
||||
}}
|
||||
onValidationError={props.onValidationError}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldTypes.COMPONENT:
|
||||
return children;
|
||||
|
||||
default:
|
||||
return children;
|
||||
}
|
||||
};
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { CloseButton } from '@/components/base/buttons/close-button';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { ReactNode } from 'react';
|
||||
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,
|
||||
Heading,
|
||||
} from 'react-aria-components';
|
||||
|
||||
export const DialogTrigger = AriaDialogTrigger;
|
||||
|
||||
export const ModalOverlay = (props: AriaModalOverlayProps) => {
|
||||
return (
|
||||
<AriaModalOverlay
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:fixed tw:inset-0 tw:z-50 tw:flex tw:min-h-dvh tw:w-full tw:items-end tw:justify-center tw:overflow-y-auto tw:bg-overlay/70 tw:px-4 tw:pt-4 tw:pb-[clamp(16px,8vh,64px)] tw:outline-hidden tw:backdrop-blur-[6px] tw:sm:items-center tw:sm:justify-center tw:sm:p-8',
|
||||
state.isEntering &&
|
||||
'tw:duration-300 tw:ease-out tw:animate-in tw:fade-in',
|
||||
state.isExiting &&
|
||||
'tw:duration-200 tw:ease-in tw:animate-out tw:fade-out',
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Modal = (props: AriaModalOverlayProps) => (
|
||||
<AriaModal
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:max-h-full tw:w-full tw:align-middle tw:outline-hidden tw:max-sm:overflow-y-auto tw:max-sm:rounded-xl',
|
||||
state.isEntering &&
|
||||
'tw:duration-300 tw:ease-out tw:animate-in tw:zoom-in-95',
|
||||
state.isExiting &&
|
||||
'tw:duration-200 tw:ease-in tw:animate-out tw:zoom-out-95',
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Sub-components
|
||||
|
||||
interface DialogHeaderProps {
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DialogHeader = ({ title, children, className }: DialogHeaderProps) => (
|
||||
<div className={cx('tw:px-4 tw:pt-5 tw:sm:px-6 tw:sm:pt-6', className)}>
|
||||
{title && (
|
||||
<Heading
|
||||
className="tw:text-md tw:font-semibold tw:text-primary"
|
||||
slot="title">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DialogContentProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DialogContent = ({ children, className }: DialogContentProps) => (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:flex-col tw:justify-start tw:gap-4 tw:px-4 tw:pt-5 tw:sm:px-6',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DialogFooterProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DialogFooter = ({ children, className }: DialogFooterProps) => (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:z-10 tw:mt-6 tw:sm:mt-8 tw:border-t tw:border-secondary',
|
||||
className
|
||||
)}>
|
||||
<div className="tw:flex tw:flex-1 tw:gap-3 tw:sm:px-6 tw:px-4 tw:py-4 tw:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Main Dialog
|
||||
|
||||
interface DialogProps extends Omit<AriaDialogProps, 'children'> {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
showCloseButton?: boolean;
|
||||
width?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type DialogComponent = ((props: DialogProps) => JSX.Element) & {
|
||||
Header: typeof DialogHeader;
|
||||
Content: typeof DialogContent;
|
||||
Footer: typeof DialogFooter;
|
||||
};
|
||||
|
||||
const DialogBase = ({
|
||||
children,
|
||||
title,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
width = 688,
|
||||
...props
|
||||
}: DialogProps) => (
|
||||
<AriaDialog
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:w-full tw:items-center tw:justify-center tw:outline-hidden',
|
||||
props.className as string | undefined
|
||||
)}>
|
||||
{({ close }) => (
|
||||
<div
|
||||
className="tw:relative tw:w-full tw:rounded-2xl tw:bg-primary tw:shadow-xl"
|
||||
style={{ maxWidth: width }}>
|
||||
<div className="tw:overflow-hidden tw:rounded-2xl">
|
||||
{title && (
|
||||
<>
|
||||
<DialogHeader
|
||||
className={showCloseButton ? 'tw:pr-12' : undefined}
|
||||
title={title}
|
||||
/>
|
||||
<div className="tw:h-5 tw:w-full" />
|
||||
<div className="tw:w-full tw:border-t tw:border-secondary" />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<CloseButton
|
||||
className="tw:absolute tw:top-3 tw:right-3 tw:z-10"
|
||||
size="lg"
|
||||
// If a caller doesn’t pass onClose, fall back to React Aria’s built-in
|
||||
// close() to dismiss the dialog.
|
||||
onPress={onClose ?? close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AriaDialog>
|
||||
);
|
||||
|
||||
export const Dialog = DialogBase as DialogComponent;
|
||||
Dialog.Header = DialogHeader;
|
||||
Dialog.Content = DialogContent;
|
||||
Dialog.Footer = DialogFooter;
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
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 },
|
||||
{
|
||||
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,
|
||||
},
|
||||
{ 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,
|
||||
},
|
||||
{ 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 },
|
||||
{
|
||||
type: 'page',
|
||||
value: total,
|
||||
isCurrent: total === page,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [total, siblingCount, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const paginationItems = createPaginationItems();
|
||||
setPages(paginationItems);
|
||||
}, [createPaginationItems]);
|
||||
|
||||
const onPageChangeHandler = useCallback(
|
||||
(newPage: number) => {
|
||||
onPageChange?.(newPage);
|
||||
},
|
||||
[onPageChange]
|
||||
);
|
||||
|
||||
const paginationContextValue: PaginationContextType = useMemo(
|
||||
() => ({
|
||||
pages,
|
||||
currentPage: page,
|
||||
total,
|
||||
onPageChange: onPageChangeHandler,
|
||||
}),
|
||||
[pages, page, total, onPageChangeHandler]
|
||||
);
|
||||
|
||||
return (
|
||||
<PaginationContext.Provider value={paginationContextValue}>
|
||||
<nav
|
||||
aria-label="Pagination Navigation"
|
||||
className={className}
|
||||
style={style}>
|
||||
{children}
|
||||
</nav>
|
||||
</PaginationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
className={computedClassName}
|
||||
disabled={isDisabled}
|
||||
style={style}
|
||||
onClick={handleClick}>
|
||||
{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
|
||||
aria-current={isCurrent ? 'page' : undefined}
|
||||
aria-label={ariaLabel || `Page ${value}`}
|
||||
className={computedClassName}
|
||||
role="listitem"
|
||||
style={style}
|
||||
onClick={handleClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
interface PaginationEllipsisProps {
|
||||
children?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
className?: string | (() => string);
|
||||
}
|
||||
|
||||
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
}) => {
|
||||
const computedClassName =
|
||||
typeof className === 'function' ? className() : className;
|
||||
|
||||
return (
|
||||
<span aria-hidden="true" className={computedClassName} style={style}>
|
||||
{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,
|
||||
};
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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('tw:gap-3', framed && 'tw:p-2'),
|
||||
button: 'tw:h-2 tw:w-2 tw:after:-inset-x-1.5 tw:after:-inset-y-2',
|
||||
},
|
||||
lg: {
|
||||
root: cx('tw:gap-4', framed && 'tw:p-3'),
|
||||
button: 'tw:h-2.5 tw:w-2.5 tw:after:-inset-x-2 tw:after:-inset-y-3',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination.Root
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:h-max tw:w-max',
|
||||
sizes[size].root,
|
||||
framed && 'tw:rounded-full tw:bg-alpha-white/90 tw:backdrop-blur',
|
||||
className
|
||||
)}>
|
||||
<Pagination.Context>
|
||||
{({ pages }) =>
|
||||
pages.map((page, index) =>
|
||||
page.type === 'page' ? (
|
||||
<Pagination.Item
|
||||
{...page}
|
||||
asChild
|
||||
className={cx(
|
||||
'tw:relative tw:cursor-pointer tw:rounded-full tw:bg-quaternary tw:outline-focus-ring tw:after:absolute tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
sizes[size].button,
|
||||
page.isCurrent && 'tw:bg-fg-brand-primary_alt',
|
||||
isBrand && 'tw:bg-fg-brand-secondary',
|
||||
isBrand && page.isCurrent && 'tw:bg-fg-white'
|
||||
)}
|
||||
key={index}
|
||||
/>
|
||||
) : (
|
||||
<Pagination.Ellipsis {...page} key={index} />
|
||||
)
|
||||
)
|
||||
}
|
||||
</Pagination.Context>
|
||||
</Pagination.Root>
|
||||
);
|
||||
};
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
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('tw:gap-2', framed && 'tw:p-2'),
|
||||
button: 'tw:h-1.5 tw:w-full tw:after:-inset-x-1.5 tw:after:-inset-y-2',
|
||||
},
|
||||
lg: {
|
||||
root: cx('tw:gap-3', framed && 'tw:p-3'),
|
||||
button: 'tw:h-2 tw:w-full tw:after:-inset-x-2 tw:after:-inset-y-3',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination.Root
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:h-max tw:w-max',
|
||||
sizes[size].root,
|
||||
framed && 'tw:rounded-full tw:bg-alpha-white/90 tw:backdrop-blur',
|
||||
className
|
||||
)}>
|
||||
<Pagination.Context>
|
||||
{({ pages }) =>
|
||||
pages.map((page, index) =>
|
||||
page.type === 'page' ? (
|
||||
<Pagination.Item
|
||||
{...page}
|
||||
asChild
|
||||
className={cx(
|
||||
'tw:relative tw:cursor-pointer tw:rounded-full tw:bg-quaternary tw:outline-focus-ring tw:after:absolute tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
sizes[size].button,
|
||||
page.isCurrent && 'tw:bg-fg-brand-primary_alt'
|
||||
)}
|
||||
key={index}
|
||||
/>
|
||||
) : (
|
||||
<Pagination.Ellipsis {...page} key={index} />
|
||||
)
|
||||
)
|
||||
}
|
||||
</Pagination.Context>
|
||||
</Pagination.Root>
|
||||
);
|
||||
};
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
} from '@/components/base/button-group/button-group';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { cx } from '@/utils/cx';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from '@untitledui/icons';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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
|
||||
className={({ isSelected }) =>
|
||||
cx(
|
||||
'tw:flex tw:size-10 tw:cursor-pointer tw:items-center tw:justify-center tw:p-3 tw:text-sm tw:font-medium tw:text-quaternary tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:hover:bg-primary_hover tw:hover:text-secondary tw:focus-visible:z-10 tw:focus-visible:bg-primary_hover tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
rounded ? 'tw:rounded-full' : 'tw:rounded-lg',
|
||||
isSelected && 'tw:bg-primary_hover tw:text-secondary'
|
||||
)
|
||||
}
|
||||
isCurrent={isCurrent}
|
||||
value={value}>
|
||||
{value}
|
||||
</Pagination.Item>
|
||||
);
|
||||
};
|
||||
|
||||
const compactTextClassName =
|
||||
'tw:text-xs tw:font-normal tw:leading-[18px] tw:text-secondary';
|
||||
|
||||
const compactControlClassName =
|
||||
'tw:flex tw:size-6 tw:shrink-0 tw:items-center tw:justify-center tw:rounded-lg';
|
||||
|
||||
const compactPageControlClassName =
|
||||
'tw:flex tw:h-6 tw:min-w-6 tw:shrink-0 tw:items-center tw:justify-center tw:rounded-lg tw:px-1.5';
|
||||
|
||||
const compactIconButtonClassName = cx(
|
||||
compactControlClassName,
|
||||
'tw:cursor-pointer tw:border tw:border-primary tw:bg-primary tw:text-fg-quaternary tw:shadow-xs-skeuomorphic tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear',
|
||||
'tw:hover:bg-primary_hover tw:disabled:cursor-not-allowed tw:disabled:text-fg-disabled'
|
||||
);
|
||||
|
||||
const compactPageItemClassName = ({ isSelected }: { isSelected: boolean }) =>
|
||||
cx(
|
||||
compactPageControlClassName,
|
||||
'tw:cursor-pointer tw:text-xs tw:leading-[18px] tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear',
|
||||
isSelected
|
||||
? 'tw:bg-tertiary tw:font-medium tw:text-primary'
|
||||
: 'tw:bg-transparent tw:font-normal tw:text-quaternary tw:hover:bg-primary_hover tw:hover:text-secondary'
|
||||
);
|
||||
|
||||
const compactPageInputClassName = cx(
|
||||
compactPageControlClassName,
|
||||
'tw:min-w-6 tw:border tw:border-primary tw:bg-primary tw:px-0 tw:text-center tw:text-xs tw:font-normal tw:leading-[18px] tw:text-secondary tw:shadow-xs tw:outline-none',
|
||||
'tw:focus-visible:outline-none'
|
||||
);
|
||||
|
||||
const compactRowsPerPageSelectClassName =
|
||||
'tw:w-[42px] tw:shrink-0 tw:gap-0 tw:[&>button]:h-6 tw:[&>button]:rounded-lg tw:[&>button]:outline-none! tw:[&>button]:focus-visible:outline-none! tw:[&>button]:focus-visible:ring-0 tw:[&>button]:focus-visible:ring-offset-0 tw:[&>button>span]:h-6 tw:[&>button>span]:gap-1 tw:[&>button>span]:px-1 tw:[&>button>span]:py-0 tw:[&>button>span>section]:min-w-0 tw:[&>button>span>section]:gap-0 tw:[&>button>span>section>p]:min-w-4 tw:[&>button>span>section>p]:text-center tw:[&>button>span>section>p]:text-xs tw:[&>button>span>section>p]:leading-[18px] tw:[&>button>span>svg]:size-3';
|
||||
|
||||
const compactRowsPerPageItemClassName =
|
||||
'tw:px-0 tw:[&>div]:h-8 tw:[&>div]:px-2 tw:[&>div]:py-0 tw:[&>div]:outline-none! tw:[&[data-focused]>div]:bg-primary_hover tw:[&[data-selected]>div]:bg-primary tw:[&>div>div]:min-w-0 tw:[&>div>div>span]:text-xs tw:[&>div>div>span]:leading-[18px]';
|
||||
|
||||
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(
|
||||
'tw:flex tw:items-center tw:justify-between tw:md:hidden',
|
||||
className
|
||||
)}>
|
||||
<Button
|
||||
aria-label="Go to previous page"
|
||||
color="secondary"
|
||||
iconLeading={ArrowLeft}
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(Math.max(1, page - 1))}
|
||||
/>
|
||||
|
||||
<span className="tw:text-sm tw:text-fg-secondary">
|
||||
Page <span className="tw:font-medium">{page}</span> of{' '}
|
||||
<span className="tw:font-medium">{total}</span>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
aria-label="Go to next page"
|
||||
color="secondary"
|
||||
iconLeading={ArrowRight}
|
||||
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}
|
||||
className={cx(
|
||||
'tw:flex tw:w-full tw:items-center tw:justify-between tw:gap-3 tw:border-t tw:border-secondary tw:pt-4 tw:md:pt-5',
|
||||
className
|
||||
)}
|
||||
page={page}
|
||||
total={total}>
|
||||
<div className="tw:hidden tw:flex-1 tw:justify-start tw:md:flex">
|
||||
<Pagination.PrevTrigger asChild>
|
||||
<Button color="link-gray" iconLeading={ArrowLeft} size="sm">
|
||||
{isDesktop ? 'Previous' : undefined}{' '}
|
||||
</Button>
|
||||
</Pagination.PrevTrigger>
|
||||
</div>
|
||||
|
||||
<Pagination.PrevTrigger asChild className="tw:md:hidden">
|
||||
<Button color="secondary" iconLeading={ArrowLeft} size="sm">
|
||||
{isDesktop ? 'Previous' : undefined}
|
||||
</Button>
|
||||
</Pagination.PrevTrigger>
|
||||
|
||||
<Pagination.Context>
|
||||
{({ pages, currentPage, total }) => (
|
||||
<>
|
||||
<div className="tw:hidden tw:justify-center tw:gap-0.5 tw:md:flex">
|
||||
{pages.map((page, index) =>
|
||||
page.type === 'page' ? (
|
||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||
) : (
|
||||
<Pagination.Ellipsis
|
||||
className="tw:flex tw:size-10 tw:shrink-0 tw:items-center tw:justify-center tw:text-tertiary"
|
||||
key={index}>
|
||||
…
|
||||
</Pagination.Ellipsis>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:justify-center tw:text-sm tw:whitespace-pre tw:text-fg-secondary tw:md:hidden">
|
||||
Page <span className="tw:font-medium">{currentPage}</span> of{' '}
|
||||
<span className="tw:font-medium">{total}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Pagination.Context>
|
||||
|
||||
<div className="tw:hidden tw:flex-1 tw:justify-end tw:md:flex">
|
||||
<Pagination.NextTrigger asChild>
|
||||
<Button color="link-gray" iconTrailing={ArrowRight} size="sm">
|
||||
{isDesktop ? 'Next' : undefined}
|
||||
</Button>
|
||||
</Pagination.NextTrigger>
|
||||
</div>
|
||||
<Pagination.NextTrigger asChild className="tw:md:hidden">
|
||||
<Button color="secondary" iconTrailing={ArrowRight} 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}
|
||||
className={cx(
|
||||
'tw:flex tw:w-full tw:items-center tw:justify-between tw:gap-3 tw:border-t tw:border-secondary tw:pt-4 tw:md:pt-5',
|
||||
className
|
||||
)}
|
||||
page={page}
|
||||
total={total}>
|
||||
<div className="tw:flex tw:flex-1 tw:justify-start">
|
||||
<Pagination.PrevTrigger asChild>
|
||||
<Button color="secondary" iconLeading={ArrowLeft} size="sm">
|
||||
{isDesktop ? 'Previous' : undefined}
|
||||
</Button>
|
||||
</Pagination.PrevTrigger>
|
||||
</div>
|
||||
|
||||
<Pagination.Context>
|
||||
{({ pages, currentPage, total }) => (
|
||||
<>
|
||||
<div className="tw:hidden tw:justify-center tw:gap-0.5 tw:md:flex">
|
||||
{pages.map((page, index) =>
|
||||
page.type === 'page' ? (
|
||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||
) : (
|
||||
<Pagination.Ellipsis
|
||||
className="tw:flex tw:size-10 tw:shrink-0 tw:items-center tw:justify-center tw:text-tertiary"
|
||||
key={index}>
|
||||
…
|
||||
</Pagination.Ellipsis>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:justify-center tw:text-sm tw:whitespace-pre tw:text-fg-secondary tw:md:hidden">
|
||||
Page <span className="tw:font-medium">{currentPage}</span> of{' '}
|
||||
<span className="tw:font-medium">{total}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Pagination.Context>
|
||||
|
||||
<div className="tw:flex tw:flex-1 tw:justify-end">
|
||||
<Pagination.NextTrigger asChild>
|
||||
<Button color="secondary" iconTrailing={ArrowRight} 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}
|
||||
className="tw:flex tw:w-full tw:items-center tw:justify-between tw:gap-3 tw:border-t tw:border-secondary tw:px-4 tw:py-3 tw:md:px-6 tw:md:pt-3 tw:md:pb-4"
|
||||
page={page}
|
||||
total={total}>
|
||||
<div className="tw:flex tw:flex-1 tw:justify-start">
|
||||
<Pagination.PrevTrigger asChild>
|
||||
<Button color="secondary" iconLeading={ArrowLeft} size="sm">
|
||||
{isDesktop ? 'Previous' : undefined}
|
||||
</Button>
|
||||
</Pagination.PrevTrigger>
|
||||
</div>
|
||||
|
||||
<Pagination.Context>
|
||||
{({ pages, currentPage, total }) => (
|
||||
<>
|
||||
<div className="tw:hidden tw:justify-center tw:gap-0.5 tw:md:flex">
|
||||
{pages.map((page, index) =>
|
||||
page.type === 'page' ? (
|
||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||
) : (
|
||||
<Pagination.Ellipsis
|
||||
className="tw:flex tw:size-10 tw:shrink-0 tw:items-center tw:justify-center tw:text-tertiary"
|
||||
key={index}>
|
||||
…
|
||||
</Pagination.Ellipsis>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:justify-center tw:text-sm tw:whitespace-pre tw:text-fg-secondary tw:md:hidden">
|
||||
Page <span className="tw:font-medium">{currentPage}</span> of{' '}
|
||||
<span className="tw:font-medium">{total}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Pagination.Context>
|
||||
|
||||
<div className="tw:flex tw:flex-1 tw:justify-end">
|
||||
<Pagination.NextTrigger asChild>
|
||||
<Button color="secondary" iconTrailing={ArrowRight} 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 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;
|
||||
}
|
||||
|
||||
export const PaginationCardMinimal = ({
|
||||
page = 1,
|
||||
total = 10,
|
||||
align = 'left',
|
||||
onPageChange,
|
||||
className,
|
||||
}: PaginationCardMinimalProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:border-t tw:border-secondary tw:px-4 tw:py-3 tw:md:px-6 tw:md:pt-3 tw:md:pb-4',
|
||||
className
|
||||
)}>
|
||||
<MobilePagination page={page} total={total} onPageChange={onPageChange} />
|
||||
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className={cx(
|
||||
'tw:hidden tw:items-center tw:gap-3 tw:md:flex',
|
||||
align === 'center' && 'tw:justify-between'
|
||||
)}>
|
||||
<span
|
||||
className={cx(
|
||||
'tw:text-sm tw:font-medium tw:text-fg-secondary',
|
||||
align === 'left' && 'tw:mr-auto',
|
||||
align === 'right' && 'tw:order-last tw:ml-auto'
|
||||
)}>
|
||||
Page {page} of {total}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:items-center tw:gap-3',
|
||||
align === 'center' && 'tw:flex tw:flex-1 tw:justify-end'
|
||||
)}>
|
||||
<Button
|
||||
color="secondary"
|
||||
isDisabled={page === 1}
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(Math.max(1, page - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
isDisabled={page === total}
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(Math.min(total, page + 1))}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PaginationCardWithControlsProps extends PaginationProps {
|
||||
/** The selected rows per page value. */
|
||||
pageSize?: number;
|
||||
/** Available rows per page values. */
|
||||
pageSizeOptions?: number[];
|
||||
/** The function to call when rows per page changes. */
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}
|
||||
|
||||
export const PaginationCardWithControls = ({
|
||||
page = 1,
|
||||
total = 10,
|
||||
pageSize = 10,
|
||||
pageSizeOptions = [10, 25, 50],
|
||||
className,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
...props
|
||||
}: PaginationCardWithControlsProps) => {
|
||||
const totalPages = Math.max(total, 1);
|
||||
const currentPage = Math.min(Math.max(page, 1), totalPages);
|
||||
const [pageInput, setPageInput] = useState(String(currentPage));
|
||||
// Add one character of room so the current input value does not clip.
|
||||
const pageInputWidth = `${Math.max(pageInput.length, 2) + 1}ch`;
|
||||
const pageSizeItems = useMemo(
|
||||
() =>
|
||||
pageSizeOptions.map((option) => ({
|
||||
id: String(option),
|
||||
label: String(option),
|
||||
})),
|
||||
[pageSizeOptions]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageInput(String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
const getValidPage = (nextPage: number) =>
|
||||
Math.min(Math.max(nextPage, 1), totalPages);
|
||||
|
||||
const handlePageChange = (nextPage: number) => {
|
||||
onPageChange?.(getValidPage(nextPage));
|
||||
};
|
||||
|
||||
const handlePageInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target;
|
||||
|
||||
if (value === '') {
|
||||
setPageInput(value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPage = Number(value);
|
||||
if (nextPage < 1 || nextPage > totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPageInput(value);
|
||||
};
|
||||
|
||||
const commitPageInput = () => {
|
||||
if (pageInput === '') {
|
||||
setPageInput(String(currentPage));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPage = getValidPage(Number(pageInput));
|
||||
|
||||
setPageInput(String(nextPage));
|
||||
onPageChange?.(nextPage);
|
||||
};
|
||||
|
||||
const handlePageInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
commitPageInput();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setPageInput(String(currentPage));
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination.Root
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:relative tw:z-[1] tw:m-0 tw:flex tw:w-full tw:rounded-b-xl tw:border-x tw:border-b tw:border-gray-blue-100 tw:bg-primary tw:px-4 tw:py-3 tw:shadow-[0_-1px_4px_0_rgba(0,0,0,0.05)]',
|
||||
className
|
||||
)}
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={handlePageChange}>
|
||||
<div className="tw:m-0 tw:flex tw:w-full tw:max-w-full tw:flex-wrap tw:items-center tw:justify-between tw:gap-x-5 tw:gap-y-3">
|
||||
<div className="tw:flex tw:shrink-0 tw:items-center tw:gap-[5px]">
|
||||
<span className={compactTextClassName}>Page</span>
|
||||
<input
|
||||
aria-label="Current page"
|
||||
className={compactPageInputClassName}
|
||||
inputMode="numeric"
|
||||
max={totalPages}
|
||||
min={1}
|
||||
style={{ width: pageInputWidth }}
|
||||
type="text"
|
||||
value={pageInput}
|
||||
onBlur={commitPageInput}
|
||||
onChange={handlePageInputChange}
|
||||
onKeyDown={handlePageInputKeyDown}
|
||||
/>
|
||||
<span className={compactTextClassName}>of {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:min-w-0 tw:flex-wrap tw:items-center tw:gap-2 tw:px-1">
|
||||
<Pagination.PrevTrigger asChild>
|
||||
<button
|
||||
className={compactIconButtonClassName}
|
||||
data-testid="previous"
|
||||
type="button">
|
||||
<ChevronLeft className="tw:size-3.5" />
|
||||
</button>
|
||||
</Pagination.PrevTrigger>
|
||||
|
||||
<Pagination.Context>
|
||||
{({ pages }) => (
|
||||
<div className="tw:flex tw:min-w-0 tw:flex-wrap tw:items-center tw:gap-1">
|
||||
{pages.map((page, index) =>
|
||||
page.type === 'page' ? (
|
||||
<Pagination.Item
|
||||
className={compactPageItemClassName}
|
||||
key={index}
|
||||
{...page}
|
||||
asChild>
|
||||
<button type="button">{page.value}</button>
|
||||
</Pagination.Item>
|
||||
) : (
|
||||
<Pagination.Ellipsis
|
||||
className="tw:mx-0 tw:flex tw:h-6 tw:w-4 tw:shrink-0 tw:items-center tw:justify-center tw:px-0 tw:text-sm tw:font-medium tw:leading-5 tw:text-quaternary"
|
||||
key={index}>
|
||||
…
|
||||
</Pagination.Ellipsis>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Pagination.Context>
|
||||
|
||||
<Pagination.NextTrigger asChild>
|
||||
<button
|
||||
className={compactIconButtonClassName}
|
||||
data-testid="next"
|
||||
type="button">
|
||||
<ChevronRight className="tw:size-3.5" />
|
||||
</button>
|
||||
</Pagination.NextTrigger>
|
||||
</div>
|
||||
|
||||
<div className="tw:flex tw:shrink-0 tw:items-center tw:gap-[5px]">
|
||||
<span className={compactTextClassName}>Records</span>
|
||||
<Select
|
||||
aria-label="Records"
|
||||
className={compactRowsPerPageSelectClassName}
|
||||
data-testid="rows-per-page-dropdown"
|
||||
fontSize="xs"
|
||||
items={pageSizeItems}
|
||||
placeholder={String(pageSize)}
|
||||
popoverClassName="tw:min-w-16!"
|
||||
selectedKey={String(pageSize)}
|
||||
size="sm"
|
||||
onSelectionChange={(key) => onPageSizeChange?.(Number(key))}>
|
||||
{(item) => (
|
||||
<Select.Item
|
||||
className={compactRowsPerPageItemClassName}
|
||||
data-testid={`rows-per-page-option-${item.id}`}
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
textValue={item.label}>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Pagination.Root>
|
||||
);
|
||||
};
|
||||
|
||||
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(
|
||||
'tw:flex tw:border-t tw:border-secondary tw:px-4 tw:py-3 tw:md:px-6 tw:md:pt-3 tw:md:pb-4',
|
||||
align === 'left' && 'tw:justify-start',
|
||||
align === 'center' && 'tw:justify-center',
|
||||
align === 'right' && 'tw:justify-end'
|
||||
)}>
|
||||
<Pagination.Root {...props} page={page} total={total}>
|
||||
<Pagination.Context>
|
||||
{({ pages }) => (
|
||||
<ButtonGroup size="md">
|
||||
<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
|
||||
className="tw:size-10 tw:items-center tw:justify-center"
|
||||
isSelected={page.isCurrent}>
|
||||
{page.value}
|
||||
</ButtonGroupItem>
|
||||
</Pagination.Item>
|
||||
) : (
|
||||
<Pagination.Ellipsis key={index}>
|
||||
<ButtonGroupItem className="tw:pointer-events-none tw:size-10 tw:items-center tw:justify-center tw:rounded-none!">
|
||||
…
|
||||
</ButtonGroupItem>
|
||||
</Pagination.Ellipsis>
|
||||
)
|
||||
)}
|
||||
|
||||
<Pagination.NextTrigger asChild>
|
||||
<ButtonGroupItem iconTrailing={ArrowRight}>
|
||||
{isDesktop ? 'Next' : undefined}
|
||||
</ButtonGroupItem>
|
||||
</Pagination.NextTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</Pagination.Context>
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import type {
|
||||
DialogTriggerProps as AriaDialogTriggerProps,
|
||||
PopoverProps as AriaPopoverProps,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger as AriaDialogTrigger,
|
||||
OverlayArrow as AriaOverlayArrow,
|
||||
Popover as AriaPopover,
|
||||
} from 'react-aria-components';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export interface PopoverProps extends Omit<AriaPopoverProps, 'children'> {
|
||||
/**
|
||||
* The content to display inside the popover.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Whether to show the arrow pointing toward the trigger element.
|
||||
* @default false
|
||||
*/
|
||||
arrow?: boolean;
|
||||
/**
|
||||
* Optional className applied to the inner content container.
|
||||
*/
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export type PopoverTriggerProps = AriaDialogTriggerProps;
|
||||
|
||||
/**
|
||||
* PopoverTrigger manages the open/close state of a Popover.
|
||||
* Place the trigger element and a Popover as its two children.
|
||||
*
|
||||
* @example
|
||||
* <PopoverTrigger>
|
||||
* <Button>Open</Button>
|
||||
* <Popover>
|
||||
* <p>Popover content</p>
|
||||
* </Popover>
|
||||
* </PopoverTrigger>
|
||||
*/
|
||||
export const PopoverTrigger = (props: PopoverTriggerProps) => (
|
||||
<AriaDialogTrigger {...props} />
|
||||
);
|
||||
|
||||
/**
|
||||
* A general-purpose floating overlay panel built on react-aria Popover.
|
||||
* Must be used as a child of PopoverTrigger.
|
||||
*/
|
||||
export const Popover = ({
|
||||
children,
|
||||
arrow = false,
|
||||
containerClassName,
|
||||
offset = 8,
|
||||
...popoverProps
|
||||
}: PopoverProps) => {
|
||||
return (
|
||||
<AriaPopover
|
||||
offset={offset}
|
||||
{...popoverProps}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:origin-(--trigger-anchor-point) tw:rounded-xl tw:bg-primary tw:shadow-lg tw:ring-1 tw:ring-secondary_alt tw:outline-hidden tw:will-change-transform',
|
||||
state.isEntering &&
|
||||
'tw:duration-150 tw:ease-out tw:animate-in tw:fade-in tw:placement-left:slide-in-from-right-0.5 tw:placement-right:slide-in-from-left-0.5 tw:placement-top:slide-in-from-bottom-0.5 tw:placement-bottom:slide-in-from-top-0.5',
|
||||
state.isExiting &&
|
||||
'tw:duration-100 tw:ease-in tw:animate-out tw:fade-out tw:placement-left:slide-out-to-right-0.5 tw:placement-right:slide-out-to-left-0.5 tw:placement-top:slide-out-to-bottom-0.5 tw:placement-bottom:slide-out-to-top-0.5',
|
||||
typeof popoverProps.className === 'function'
|
||||
? popoverProps.className(state)
|
||||
: popoverProps.className
|
||||
)
|
||||
}>
|
||||
{arrow && (
|
||||
<AriaOverlayArrow>
|
||||
<svg
|
||||
className="tw:fill-bg-primary tw:drop-shadow-sm tw:in-placement-left:-rotate-90 tw:in-placement-right:rotate-90 tw:in-placement-top:rotate-0 tw:in-placement-bottom:rotate-180"
|
||||
height={10}
|
||||
viewBox="0 0 100 100"
|
||||
width={10}>
|
||||
<path d="M0,0 L35.858,35.858 Q50,50 64.142,35.858 L100,0 Z" />
|
||||
</svg>
|
||||
</AriaOverlayArrow>
|
||||
)}
|
||||
<AriaDialog className={cx('tw:outline-hidden', containerClassName)}>
|
||||
{children}
|
||||
</AriaDialog>
|
||||
</AriaPopover>
|
||||
);
|
||||
};
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Check } from '@untitledui/icons';
|
||||
import type { FC, HTMLAttributes, ReactNode } from 'react';
|
||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||
import { cx, sortCx } from '@/utils/cx';
|
||||
|
||||
export type ProgressStepStatus = 'complete' | 'current' | 'incomplete';
|
||||
|
||||
export type ProgressStepType =
|
||||
| 'icon'
|
||||
| 'number'
|
||||
| 'featured-icon'
|
||||
| 'dot'
|
||||
| 'line';
|
||||
|
||||
export type ProgressStepsOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
export type ProgressStepsSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Where the step label sits relative to the indicator on horizontal layouts.
|
||||
*
|
||||
* - `bottom` (default): label is rendered below the indicator and centered —
|
||||
* the standard Untitled UI horizontal stepper.
|
||||
* - `attach`: label sits inline to the right of the indicator. Incomplete
|
||||
* steps render in a disabled style, and connectors collapse to short
|
||||
* horizontal lines between adjacent step groups.
|
||||
*/
|
||||
export type ProgressStepsLabelPlacement = 'bottom' | 'attach';
|
||||
|
||||
export interface ProgressStepItem {
|
||||
/**
|
||||
* Stable identifier for the step. Used as the React key so that reordering,
|
||||
* inserting, or removing steps preserves the correct DOM nodes and
|
||||
* transitions. Falls back to the title, then the index, when omitted.
|
||||
*/
|
||||
id?: string;
|
||||
/** Step title rendered as the primary label. */
|
||||
title?: ReactNode;
|
||||
/** Optional supporting text rendered below the title. */
|
||||
description?: ReactNode;
|
||||
/** Icon component shown inside the indicator for `icon`/`featured-icon` types. */
|
||||
icon?: FC<{ className?: string }>;
|
||||
/**
|
||||
* Explicit status for the step. When omitted, the status is derived from
|
||||
* `currentStep` on the parent `ProgressSteps`.
|
||||
*/
|
||||
status?: ProgressStepStatus;
|
||||
}
|
||||
|
||||
export interface ProgressStepsProps
|
||||
extends Omit<HTMLAttributes<HTMLOListElement>, 'children'> {
|
||||
/** Ordered list of steps to render. */
|
||||
steps: ProgressStepItem[];
|
||||
/**
|
||||
* Zero-based index of the active step. Steps before it become `complete`,
|
||||
* the step at the index becomes `current`, and steps after it stay
|
||||
* `incomplete`. The value is clamped to `[0, steps.length - 1]` so an
|
||||
* out-of-range index still resolves to a valid `current` step. Ignored for
|
||||
* any step that defines its own `status`.
|
||||
*/
|
||||
currentStep?: number;
|
||||
/** Visual style of the step indicator. */
|
||||
type?: ProgressStepType;
|
||||
/** Layout direction of the stepper. */
|
||||
orientation?: ProgressStepsOrientation;
|
||||
/** Size of the indicator and typography. */
|
||||
size?: ProgressStepsSize;
|
||||
/** Render the connecting lines between steps. */
|
||||
showConnector?: boolean;
|
||||
/**
|
||||
* Where the step label sits relative to the indicator on horizontal
|
||||
* layouts. Defaults to `'bottom'`. Use `'attach'` to render the indicator
|
||||
* and label side-by-side with a disabled treatment for incomplete steps.
|
||||
* Only applies when `orientation === 'horizontal'` and `type` is `'number'`
|
||||
* or `'icon'`; other combinations fall back to the standard horizontal
|
||||
* layout to keep the indicator and label palettes consistent.
|
||||
*/
|
||||
labelPlacement?: ProgressStepsLabelPlacement;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizes = sortCx({
|
||||
sm: {
|
||||
circle: 'tw:size-6',
|
||||
icon: 'tw:size-4',
|
||||
dot: 'tw:size-2',
|
||||
dotHalo: 'tw:size-5',
|
||||
number: 'tw:text-xs',
|
||||
title: 'tw:text-sm tw:font-semibold',
|
||||
description: 'tw:text-xs',
|
||||
featured: 'sm' as const,
|
||||
},
|
||||
md: {
|
||||
circle: 'tw:size-8',
|
||||
icon: 'tw:size-4.5',
|
||||
dot: 'tw:size-2.5',
|
||||
dotHalo: 'tw:size-6',
|
||||
number: 'tw:text-sm',
|
||||
title: 'tw:text-sm tw:font-semibold',
|
||||
description: 'tw:text-sm',
|
||||
featured: 'md' as const,
|
||||
},
|
||||
lg: {
|
||||
circle: 'tw:size-10',
|
||||
icon: 'tw:size-5',
|
||||
dot: 'tw:size-3',
|
||||
dotHalo: 'tw:size-7',
|
||||
number: 'tw:text-md',
|
||||
title: 'tw:text-md tw:font-semibold',
|
||||
description: 'tw:text-sm',
|
||||
featured: 'lg' as const,
|
||||
},
|
||||
});
|
||||
|
||||
const circleStatusStyles = sortCx({
|
||||
complete: 'tw:bg-brand-solid tw:text-fg-white',
|
||||
current:
|
||||
'tw:bg-brand-secondary tw:text-fg-brand-primary tw:ring-2 tw:ring-inset tw:ring-bg-brand-solid',
|
||||
incomplete:
|
||||
'tw:bg-primary tw:text-fg-quaternary tw:ring-1 tw:ring-inset tw:ring-primary',
|
||||
});
|
||||
|
||||
const titleStatusStyles = sortCx({
|
||||
complete: 'tw:text-brand-secondary',
|
||||
current: 'tw:text-brand-secondary',
|
||||
incomplete: 'tw:text-secondary',
|
||||
});
|
||||
|
||||
const attachCircleStatusStyles = sortCx({
|
||||
complete: 'tw:bg-success-solid tw:text-fg-white',
|
||||
current: 'tw:bg-brand-solid tw:text-fg-white',
|
||||
incomplete: 'tw:bg-disabled tw:text-disabled',
|
||||
});
|
||||
|
||||
const attachTitleStatusStyles = sortCx({
|
||||
complete: 'tw:text-fg-success-primary',
|
||||
current: 'tw:text-primary',
|
||||
incomplete: 'tw:text-disabled',
|
||||
});
|
||||
|
||||
const resolveStatus = (
|
||||
step: ProgressStepItem,
|
||||
index: number,
|
||||
currentStep?: number
|
||||
): ProgressStepStatus => {
|
||||
let status: ProgressStepStatus = step.status ?? 'incomplete';
|
||||
|
||||
if (!step.status && currentStep !== undefined) {
|
||||
if (index < currentStep) {
|
||||
status = 'complete';
|
||||
} else if (index === currentStep) {
|
||||
status = 'current';
|
||||
} else {
|
||||
status = 'incomplete';
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
const getStepKey = (step: ProgressStepItem, index: number): string => {
|
||||
let key = String(index);
|
||||
|
||||
if (step.id) {
|
||||
key = step.id;
|
||||
} else if (typeof step.title === 'string' || typeof step.title === 'number') {
|
||||
key = String(step.title);
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
interface StepIndicatorProps {
|
||||
type: Exclude<ProgressStepType, 'line'>;
|
||||
status: ProgressStepStatus;
|
||||
size: ProgressStepsSize;
|
||||
index: number;
|
||||
icon?: FC<{ className?: string }>;
|
||||
/**
|
||||
* Render the indicator with the attached-variant treatment (solid brand fill
|
||||
* for the current step, disabled fill for incomplete steps). Defaults to
|
||||
* `false` so the standard Untitled UI palette is used.
|
||||
*/
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
const FeaturedStepIcon = ({
|
||||
status,
|
||||
size,
|
||||
index,
|
||||
icon,
|
||||
}: StepIndicatorProps) => {
|
||||
const isComplete = status === 'complete';
|
||||
const color = status === 'incomplete' ? 'gray' : 'brand';
|
||||
const theme = status === 'incomplete' ? 'modern' : 'light';
|
||||
const Icon = isComplete ? Check : icon;
|
||||
|
||||
return (
|
||||
<FeaturedIcon
|
||||
color={color}
|
||||
icon={Icon}
|
||||
size={sizes[size].featured}
|
||||
theme={theme}>
|
||||
{!Icon && (
|
||||
<span className={cx('tw:font-semibold', sizes[size].number)}>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</FeaturedIcon>
|
||||
);
|
||||
};
|
||||
|
||||
const DotStepIcon = ({ status, size }: StepIndicatorProps) => {
|
||||
const isActive = status === 'complete' || status === 'current';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'tw:flex tw:shrink-0 tw:items-center tw:justify-center tw:rounded-full',
|
||||
sizes[size].dotHalo,
|
||||
status === 'current' && 'tw:bg-brand-secondary'
|
||||
)}>
|
||||
<span
|
||||
className={cx(
|
||||
'tw:rounded-full',
|
||||
sizes[size].dot,
|
||||
isActive ? 'tw:bg-brand-solid' : 'tw:bg-fg-quaternary'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const CircleStepIcon = ({
|
||||
type,
|
||||
status,
|
||||
size,
|
||||
index,
|
||||
icon: Icon,
|
||||
attached,
|
||||
}: StepIndicatorProps) => {
|
||||
const isComplete = status === 'complete';
|
||||
let content: ReactNode;
|
||||
|
||||
if (isComplete) {
|
||||
content = <Check className={sizes[size].icon} />;
|
||||
} else if (type === 'number') {
|
||||
content = (
|
||||
<span className={cx('tw:font-semibold', sizes[size].number)}>
|
||||
{index + 1}
|
||||
</span>
|
||||
);
|
||||
} else if (Icon) {
|
||||
content = <Icon className={sizes[size].icon} />;
|
||||
} else {
|
||||
content = (
|
||||
<span className={cx('tw:rounded-full tw:bg-current', sizes[size].dot)} />
|
||||
);
|
||||
}
|
||||
|
||||
const statusStyles = attached
|
||||
? attachCircleStatusStyles[status]
|
||||
: circleStatusStyles[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'tw:flex tw:shrink-0 tw:items-center tw:justify-center tw:rounded-full tw:transition-colors',
|
||||
sizes[size].circle,
|
||||
statusStyles
|
||||
)}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StepIndicator = (props: StepIndicatorProps) => {
|
||||
let indicator: ReactNode;
|
||||
|
||||
if (props.type === 'featured-icon') {
|
||||
indicator = <FeaturedStepIcon {...props} />;
|
||||
} else if (props.type === 'dot') {
|
||||
indicator = <DotStepIcon {...props} />;
|
||||
} else {
|
||||
indicator = <CircleStepIcon {...props} />;
|
||||
}
|
||||
|
||||
return indicator;
|
||||
};
|
||||
|
||||
interface StepConnectorProps {
|
||||
orientation: ProgressStepsOrientation;
|
||||
isComplete: boolean;
|
||||
isHidden?: boolean;
|
||||
}
|
||||
|
||||
const StepConnector = ({
|
||||
orientation,
|
||||
isComplete,
|
||||
isHidden,
|
||||
}: StepConnectorProps) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'tw:h-0.5 tw:w-full tw:flex-1'
|
||||
: 'tw:w-0.5 tw:flex-1',
|
||||
isComplete ? 'tw:bg-brand-solid' : 'tw:bg-quaternary',
|
||||
isHidden && 'tw:invisible'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
interface StepTextProps {
|
||||
step: ProgressStepItem;
|
||||
status: ProgressStepStatus;
|
||||
size: ProgressStepsSize;
|
||||
align: 'center' | 'left';
|
||||
/**
|
||||
* Apply the attached-variant title palette (primary text for the active
|
||||
* step, disabled text for incomplete steps). Defaults to `false`.
|
||||
*/
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
const StepText = ({ step, status, size, align, attached }: StepTextProps) => {
|
||||
let text: ReactNode = null;
|
||||
|
||||
const titleColor = attached
|
||||
? attachTitleStatusStyles[status]
|
||||
: titleStatusStyles[status];
|
||||
|
||||
if (step.title || step.description) {
|
||||
text = (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:flex-col tw:gap-0.5',
|
||||
align === 'center' ? 'tw:items-center tw:text-center' : 'tw:text-left'
|
||||
)}>
|
||||
{step.title && (
|
||||
<span className={cx(sizes[size].title, titleColor)}>
|
||||
{step.title}
|
||||
</span>
|
||||
)}
|
||||
{step.description && (
|
||||
<span className={cx('tw:text-tertiary', sizes[size].description)}>
|
||||
{step.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
interface RenderArgs {
|
||||
steps: ProgressStepItem[];
|
||||
statuses: ProgressStepStatus[];
|
||||
type: ProgressStepType;
|
||||
size: ProgressStepsSize;
|
||||
showConnector: boolean;
|
||||
}
|
||||
|
||||
const AttachedHorizontalSteps = ({
|
||||
steps,
|
||||
statuses,
|
||||
type,
|
||||
size,
|
||||
showConnector,
|
||||
}: RenderArgs) =>
|
||||
steps.map((step, index) => {
|
||||
const status = statuses[index];
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-current={status === 'current' ? 'step' : undefined}
|
||||
className="tw:flex tw:items-center"
|
||||
key={getStepKey(step, index)}>
|
||||
<div className="tw:flex tw:items-center tw:gap-2">
|
||||
<StepIndicator
|
||||
attached
|
||||
icon={step.icon}
|
||||
index={index}
|
||||
size={size}
|
||||
status={status}
|
||||
type={type as Exclude<ProgressStepType, 'line'>}
|
||||
/>
|
||||
<StepText
|
||||
attached
|
||||
align="left"
|
||||
size={size}
|
||||
status={status}
|
||||
step={step}
|
||||
/>
|
||||
</div>
|
||||
{showConnector && !isLast && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:mx-3 tw:h-px tw:w-8 tw:shrink-0',
|
||||
status === 'complete' ? 'tw:bg-success-solid' : 'tw:bg-quaternary'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const HorizontalSteps = ({
|
||||
steps,
|
||||
statuses,
|
||||
type,
|
||||
size,
|
||||
showConnector,
|
||||
}: RenderArgs) =>
|
||||
steps.map((step, index) => {
|
||||
const status = statuses[index];
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-current={status === 'current' ? 'step' : undefined}
|
||||
className="tw:flex tw:flex-1 tw:flex-col tw:items-center tw:gap-3"
|
||||
key={getStepKey(step, index)}>
|
||||
<div className="tw:flex tw:w-full tw:items-center tw:gap-2">
|
||||
{showConnector && (
|
||||
<StepConnector
|
||||
isComplete={statuses[index - 1] === 'complete'}
|
||||
isHidden={isFirst}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
<StepIndicator
|
||||
icon={step.icon}
|
||||
index={index}
|
||||
size={size}
|
||||
status={status}
|
||||
type={type as Exclude<ProgressStepType, 'line'>}
|
||||
/>
|
||||
{showConnector && (
|
||||
<StepConnector
|
||||
isComplete={status === 'complete'}
|
||||
isHidden={isLast}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<StepText align="center" size={size} status={status} step={step} />
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const VerticalSteps = ({
|
||||
steps,
|
||||
statuses,
|
||||
type,
|
||||
size,
|
||||
showConnector,
|
||||
}: RenderArgs) =>
|
||||
steps.map((step, index) => {
|
||||
const status = statuses[index];
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-current={status === 'current' ? 'step' : undefined}
|
||||
className="tw:flex tw:gap-3"
|
||||
key={getStepKey(step, index)}>
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:flex-col tw:items-center tw:gap-2',
|
||||
!isLast && 'tw:pb-2'
|
||||
)}>
|
||||
<StepIndicator
|
||||
icon={step.icon}
|
||||
index={index}
|
||||
size={size}
|
||||
status={status}
|
||||
type={type as Exclude<ProgressStepType, 'line'>}
|
||||
/>
|
||||
{showConnector && !isLast && (
|
||||
<StepConnector
|
||||
isComplete={status === 'complete'}
|
||||
orientation="vertical"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={cx('tw:pt-1', !isLast && 'tw:pb-6')}>
|
||||
<StepText align="left" size={size} status={status} step={step} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const LineSteps = ({ steps, statuses, size }: RenderArgs) =>
|
||||
steps.map((step, index) => {
|
||||
const status = statuses[index];
|
||||
const isActive = status === 'complete' || status === 'current';
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-current={status === 'current' ? 'step' : undefined}
|
||||
className={cx(
|
||||
'tw:flex tw:flex-1 tw:flex-col tw:gap-2 tw:border-t-2 tw:pt-3',
|
||||
isActive ? 'tw:border-brand-solid' : 'tw:border-secondary'
|
||||
)}
|
||||
key={getStepKey(step, index)}>
|
||||
<StepText align="left" size={size} status={status} step={step} />
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ProgressSteps renders a multi-step progress indicator (a.k.a. stepper),
|
||||
* mirroring the Untitled UI "Progress steps" patterns. It supports several
|
||||
* indicator styles (`icon`, `number`, `featured-icon`, `dot`, `line`),
|
||||
* horizontal and vertical orientations, and three sizes — covering use cases
|
||||
* from compact wizards to onboarding checklists.
|
||||
*
|
||||
* Status can be driven declaratively per step (`step.status`) or derived from
|
||||
* the `currentStep` index.
|
||||
*
|
||||
* @example
|
||||
* <ProgressSteps
|
||||
* currentStep={1}
|
||||
* steps={[
|
||||
* { title: 'Your details', description: 'Name and email' },
|
||||
* { title: 'Company details', description: 'A few details' },
|
||||
* { title: 'Invite team', description: 'Start collaborating' },
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
export const ProgressSteps = ({
|
||||
steps,
|
||||
currentStep,
|
||||
type = 'icon',
|
||||
orientation = 'horizontal',
|
||||
size = 'md',
|
||||
showConnector = true,
|
||||
labelPlacement = 'bottom',
|
||||
className,
|
||||
...props
|
||||
}: ProgressStepsProps) => {
|
||||
const clampedStep =
|
||||
currentStep === undefined
|
||||
? undefined
|
||||
: Math.min(Math.max(currentStep, 0), steps.length - 1);
|
||||
|
||||
const statuses = steps.map((step, index) =>
|
||||
resolveStatus(step, index, clampedStep)
|
||||
);
|
||||
|
||||
const renderArgs: RenderArgs = { steps, statuses, type, size, showConnector };
|
||||
const isAttached =
|
||||
labelPlacement === 'attach' &&
|
||||
orientation === 'horizontal' &&
|
||||
(type === 'number' || type === 'icon');
|
||||
|
||||
let content: ReactNode;
|
||||
let listClassName: string;
|
||||
|
||||
if (type === 'line') {
|
||||
content = <LineSteps {...renderArgs} />;
|
||||
listClassName = 'tw:flex tw:w-full tw:gap-4';
|
||||
} else if (orientation === 'vertical') {
|
||||
content = <VerticalSteps {...renderArgs} />;
|
||||
listClassName = 'tw:flex tw:flex-col';
|
||||
} else if (isAttached) {
|
||||
content = <AttachedHorizontalSteps {...renderArgs} />;
|
||||
listClassName = 'tw:flex tw:items-center';
|
||||
} else {
|
||||
content = <HorizontalSteps {...renderArgs} />;
|
||||
listClassName = 'tw:flex tw:w-full';
|
||||
}
|
||||
|
||||
return (
|
||||
<ol {...props} className={cx(listClassName, className)}>
|
||||
{content}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { CloseButton } from '@/components/base/buttons/close-button';
|
||||
import { cx } from '@/utils/cx';
|
||||
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';
|
||||
|
||||
interface ModalOverlayProps
|
||||
extends AriaModalOverlayProps,
|
||||
RefAttributes<HTMLDivElement> {}
|
||||
|
||||
export const ModalOverlay = (props: ModalOverlayProps) => {
|
||||
return (
|
||||
<AriaModalOverlay
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:fixed tw:inset-0 tw:flex tw:min-h-dvh tw:w-full tw:items-center tw:justify-end tw:bg-overlay/70 tw:pl-6 tw:outline-hidden tw:ease-linear tw:md:pl-10',
|
||||
state.isEntering && 'tw:duration-300 tw:animate-in tw:fade-in',
|
||||
state.isExiting && 'tw:duration-500 tw:animate-out tw: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(
|
||||
'tw:inset-y-0 tw:right-0 tw:h-full tw:w-full tw:max-w-100 tw:shadow-xl tw:transition',
|
||||
state.isEntering &&
|
||||
'tw:duration-300 tw:animate-in tw:slide-in-from-right',
|
||||
state.isExiting &&
|
||||
'tw:duration-500 tw:animate-out tw: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"
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:size-full tw:flex-col tw:items-start tw:gap-6 tw:overflow-y-auto tw:bg-primary tw:ring-1 tw:ring-secondary_alt tw:outline-hidden',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
Dialog.displayName = 'Dialog';
|
||||
|
||||
interface SlideoutMenuProps
|
||||
extends Omit<AriaModalOverlayProps, 'children'>,
|
||||
RefAttributes<HTMLDivElement> {
|
||||
children:
|
||||
| ReactNode
|
||||
| ((children: AriaModalRenderProps & { close: () => void }) => ReactNode);
|
||||
dialogClassName?: string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
const Menu = ({
|
||||
children,
|
||||
dialogClassName,
|
||||
width,
|
||||
...props
|
||||
}: SlideoutMenuProps) => {
|
||||
return (
|
||||
<ModalOverlay {...props}>
|
||||
<Modal
|
||||
className={(state) =>
|
||||
cx(
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}
|
||||
style={width !== undefined ? { maxWidth: width } : undefined}>
|
||||
{(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(
|
||||
'tw:flex tw:size-full tw:flex-col tw:gap-6 tw:overflow-y-auto tw:overscroll-auto tw:px-4 tw: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(
|
||||
'tw:relative tw:z-1 tw:w-full tw:px-4 tw:pt-6 tw:md:px-6 tw:pr-8',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
<CloseButton
|
||||
className="tw:absolute tw:top-1/2 tw:right-3 tw:shrink-0 tw:-translate-y-1/2"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
Header.displayName = 'SlideoutHeader';
|
||||
|
||||
const Footer = (props: ComponentPropsWithRef<'footer'>) => {
|
||||
return (
|
||||
<footer
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:w-full tw:p-4 tw:shadow-[inset_0px_1px_0px_0px] tw:shadow-border-secondary tw: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 };
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
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="tw:w-min">
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item icon={Edit01}>
|
||||
<span className="tw:pr-4">Edit</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item icon={Copy01}>
|
||||
<span className="tw:pr-4">Copy link</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item icon={Trash01}>
|
||||
<span className="tw:pr-4">Delete</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
|
||||
const TableContext = createContext<{
|
||||
size: 'sm' | 'md';
|
||||
stickyHeader: boolean;
|
||||
}>({
|
||||
size: 'md',
|
||||
stickyHeader: false,
|
||||
});
|
||||
|
||||
const TableCardRoot = ({
|
||||
children,
|
||||
className,
|
||||
size = 'md',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { size?: 'sm' | 'md' }) => {
|
||||
return (
|
||||
<TableContext.Provider value={{ size, stickyHeader: false }}>
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:overflow-hidden tw:rounded-xl tw:bg-primary tw:shadow-xs tw:ring-1 tw: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(
|
||||
'tw:relative tw:flex tw:flex-col tw:items-start tw:gap-4 tw:border-b tw:border-secondary tw:bg-primary tw:px-4 tw:md:flex-row',
|
||||
size === 'sm' ? 'tw:py-4 tw:md:px-5' : 'tw:py-5 tw:md:px-6',
|
||||
className
|
||||
)}>
|
||||
<div className="tw:flex tw:flex-1 tw:flex-col tw:gap-0.5">
|
||||
<div className="tw:flex tw:items-center tw:gap-2">
|
||||
<h2
|
||||
className={cx(
|
||||
'tw:font-semibold tw:text-primary',
|
||||
size === 'sm' ? 'tw:text-md' : 'tw:text-lg'
|
||||
)}>
|
||||
{title}
|
||||
</h2>
|
||||
{badge ? (
|
||||
isValidElement(badge) ? (
|
||||
badge
|
||||
) : (
|
||||
<Badge color="brand" size="sm">
|
||||
{badge}
|
||||
</Badge>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="tw:text-sm tw:text-tertiary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{contentTrailing}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableRootProps
|
||||
extends AriaTableProps,
|
||||
Omit<ComponentPropsWithRef<'table'>, 'className' | 'slot' | 'style'> {
|
||||
size?: 'sm' | 'md';
|
||||
stickyHeader?: boolean;
|
||||
containerStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const TableRoot = ({
|
||||
className,
|
||||
size = 'md',
|
||||
stickyHeader = false,
|
||||
containerStyle,
|
||||
...props
|
||||
}: TableRootProps) => {
|
||||
const context = useContext(TableContext);
|
||||
|
||||
return (
|
||||
<TableContext.Provider
|
||||
value={{ size: context?.size ?? size, stickyHeader }}>
|
||||
<div className="tw:overflow-x-auto" style={containerStyle}>
|
||||
<AriaTable
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:w-full tw: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;
|
||||
}
|
||||
|
||||
const TableHeader = <T extends object>({
|
||||
columns,
|
||||
children,
|
||||
bordered = true,
|
||||
className,
|
||||
...props
|
||||
}: TableHeaderProps<T>) => {
|
||||
const { size, stickyHeader } = useContext(TableContext);
|
||||
const { selectionBehavior, selectionMode } = useTableOptions();
|
||||
|
||||
return (
|
||||
<AriaTableHeader
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:bg-secondary',
|
||||
stickyHeader ? 'tw:sticky tw:top-0 tw:z-10' : 'tw:relative',
|
||||
size === 'sm' ? 'tw:h-9' : 'tw:h-11',
|
||||
|
||||
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||
bordered &&
|
||||
'tw:[&>tr>th]:after:pointer-events-none tw:[&>tr>th]:after:absolute tw:[&>tr>th]:after:inset-x-0 tw:[&>tr>th]:after:bottom-0 tw:[&>tr>th]:after:h-px tw:[&>tr>th]:after:bg-border-secondary tw:[&>tr>th]:focus-visible:after:bg-transparent',
|
||||
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{selectionBehavior === 'toggle' && (
|
||||
<AriaColumn
|
||||
className={cx(
|
||||
'tw:relative tw:py-2 tw:pr-0 tw:pl-4',
|
||||
size === 'sm' ? 'tw:w-9 tw:md:pl-5' : 'tw:w-11 tw:md:pl-6'
|
||||
)}>
|
||||
{selectionMode === 'multiple' && (
|
||||
<div className="tw:flex tw:items-start">
|
||||
<Checkbox size={size} slot="selection" />
|
||||
</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(
|
||||
'tw:relative tw:p-0 tw:px-6 tw:py-2 tw:outline-hidden tw:focus-visible:z-1 tw:focus-visible:ring-2 tw:focus-visible:ring-focus-ring tw:focus-visible:ring-offset-bg-primary tw:focus-visible:ring-inset',
|
||||
selectionBehavior === 'toggle' && 'tw:nth-2:pl-3',
|
||||
state.allowsSorting && 'tw:cursor-pointer',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{(state) => (
|
||||
<AriaGroup className="tw:flex tw:items-center tw:gap-1">
|
||||
<div className="tw:flex tw:items-center tw:gap-1">
|
||||
{label && (
|
||||
<span className="tw:text-xs tw:font-semibold tw:whitespace-nowrap tw:text-quaternary">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{typeof children === 'function' ? children(state) : children}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<Tooltip placement="top" title={tooltip}>
|
||||
<TooltipTrigger className="tw:cursor-pointer tw:text-fg-quaternary tw:transition tw:duration-100 tw:ease-linear tw:hover:text-fg-quaternary_hover tw:focus:text-fg-quaternary_hover">
|
||||
<HelpCircle className="tw:size-4" />
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{state.allowsSorting &&
|
||||
(state.sortDirection ? (
|
||||
<ArrowDown
|
||||
className={cx(
|
||||
'tw:size-3 tw:stroke-[3px] tw:text-fg-quaternary',
|
||||
state.sortDirection === 'ascending' && 'tw:rotate-180'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ChevronSelectorVertical
|
||||
className="tw:text-fg-quaternary"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
))}
|
||||
</AriaGroup>
|
||||
)}
|
||||
</AriaColumn>
|
||||
);
|
||||
};
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
interface TableRowProps<T extends object>
|
||||
extends AriaRowProps<T>,
|
||||
Omit<
|
||||
ComponentPropsWithRef<'tr'>,
|
||||
'children' | 'className' | 'onClick' | 'slot' | 'style' | 'id'
|
||||
> {
|
||||
highlightSelectedRow?: boolean;
|
||||
}
|
||||
|
||||
const TableRow = <T extends object>({
|
||||
columns,
|
||||
children,
|
||||
className,
|
||||
highlightSelectedRow = true,
|
||||
...props
|
||||
}: TableRowProps<T>) => {
|
||||
const { size } = useContext(TableContext);
|
||||
const { selectionBehavior } = useTableOptions();
|
||||
|
||||
return (
|
||||
<AriaRow
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:relative tw:outline-focus-ring tw:transition-colors tw:after:pointer-events-none tw:hover:bg-secondary tw:focus-visible:outline-2 tw:focus-visible:-outline-offset-2',
|
||||
size === 'sm' ? 'tw:h-14' : 'tw:h-18',
|
||||
highlightSelectedRow && 'tw:selected:bg-secondary',
|
||||
|
||||
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||
'tw:[&>td]:after:absolute tw:[&>td]:after:inset-x-0 tw:[&>td]:after:bottom-0 tw:[&>td]:after:h-px tw:[&>td]:after:w-full tw:[&>td]:after:bg-border-secondary tw:last:[&>td]:after:hidden tw:[&>td]:focus-visible:after:opacity-0 tw:focus-visible:[&>td]:after:opacity-0',
|
||||
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{selectionBehavior === 'toggle' && (
|
||||
<AriaCell
|
||||
className={cx(
|
||||
'tw:relative tw:py-2 tw:pr-0 tw:pl-4',
|
||||
size === 'sm' ? 'tw:md:pl-5' : 'tw:md:pl-6'
|
||||
)}>
|
||||
<div className="tw:flex tw:items-end">
|
||||
<Checkbox size={size} slot="selection" />
|
||||
</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>;
|
||||
}
|
||||
|
||||
const TableCell = ({ className, children, ...props }: TableCellProps) => {
|
||||
const { size } = useContext(TableContext);
|
||||
const { selectionBehavior } = useTableOptions();
|
||||
|
||||
return (
|
||||
<AriaCell
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:relative tw:text-sm tw:text-tertiary tw:outline-focus-ring tw:focus-visible:z-1 tw:focus-visible:outline-2 tw:focus-visible:-outline-offset-2',
|
||||
size === 'sm' && 'tw:px-5 tw:py-3',
|
||||
size === 'md' && 'tw:px-6 tw:py-4',
|
||||
|
||||
selectionBehavior === 'toggle' && 'tw: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 };
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
import type { BadgeColors, Sizes } from '@/components/base/badges/badge-types';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
||||
import { Fragment, createContext, useContext, useMemo } 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';
|
||||
|
||||
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(
|
||||
'tw:outline-focus-ring',
|
||||
isSelected ? 'tw:font-semibold' : 'tw:font-medium',
|
||||
isFocusVisible && 'tw:outline-2 tw:-outline-offset-2',
|
||||
(isSelected || isHovered) &&
|
||||
'tw:bg-brand-primary_alt tw:text-brand-secondary'
|
||||
),
|
||||
'button-gray': cx(
|
||||
'tw:outline-focus-ring',
|
||||
isSelected ? 'tw:font-semibold' : 'tw:font-medium',
|
||||
isHovered && 'tw:bg-primary_hover tw:text-secondary',
|
||||
isFocusVisible && 'tw:outline-2 tw:-outline-offset-2',
|
||||
isSelected && 'tw:bg-active tw:text-secondary'
|
||||
),
|
||||
'button-border': cx(
|
||||
'tw:outline-focus-ring',
|
||||
isSelected ? 'tw:font-semibold' : 'tw:font-medium',
|
||||
(isSelected || isHovered) &&
|
||||
'tw:bg-primary_alt tw:text-secondary tw:shadow-sm',
|
||||
isFocusVisible && 'tw:outline-2 tw:-outline-offset-2'
|
||||
),
|
||||
'button-minimal': cx(
|
||||
'tw:rounded-lg tw:outline-focus-ring',
|
||||
isSelected ? 'tw:font-semibold' : 'tw:font-medium',
|
||||
isHovered && 'tw:text-secondary',
|
||||
isFocusVisible && 'tw:outline-2 tw:-outline-offset-2',
|
||||
isSelected &&
|
||||
'tw:bg-primary_alt tw:text-secondary tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset'
|
||||
),
|
||||
underline: cx(
|
||||
'tw:rounded-none tw:border-b-2 tw:border-transparent tw:outline-focus-ring',
|
||||
isSelected ? 'tw:font-semibold' : 'tw:font-medium',
|
||||
(isSelected || isHovered) &&
|
||||
'tw:border-fg-brand-primary_alt tw:text-brand-secondary',
|
||||
isFocusVisible && 'tw:outline-2 tw:-outline-offset-2'
|
||||
),
|
||||
line: cx(
|
||||
'tw:rounded-none tw:border-l-2 tw:border-transparent tw:outline-focus-ring',
|
||||
isSelected ? 'tw:font-semibold' : 'tw:font-medium',
|
||||
(isSelected || isHovered) &&
|
||||
'tw:border-fg-brand-primary_alt tw:text-brand-secondary',
|
||||
isFocusVisible && 'tw:outline-2 tw:-outline-offset-2'
|
||||
),
|
||||
});
|
||||
|
||||
const sizes = {
|
||||
sm: {
|
||||
'button-brand': 'tw:text-sm tw:py-2 tw:px-3',
|
||||
'button-gray': 'tw:text-sm tw:py-2 tw:px-3',
|
||||
'button-border': 'tw:text-sm tw:py-2 tw:px-3',
|
||||
'button-minimal': 'tw:text-sm tw:py-2 tw:px-3',
|
||||
underline: 'tw:text-sm tw:px-1 tw:pb-2.5 tw:pt-0',
|
||||
line: 'tw:text-sm tw:pl-2.5 tw:pr-3 tw:py-0.5',
|
||||
},
|
||||
md: {
|
||||
'button-brand': 'tw:text-md tw:py-2.5 tw:px-3',
|
||||
'button-gray': 'tw:text-md tw:py-2.5 tw:px-3',
|
||||
'button-border': 'tw:text-md tw:py-2.5 tw:px-3',
|
||||
'button-minimal': 'tw:text-md tw:py-2.5 tw:px-3',
|
||||
underline: 'tw:text-md tw:px-1 tw:pb-2.5 tw:pt-0',
|
||||
line: 'tw:text-md tw:pr-3.5 tw:pl-3 tw:py-1',
|
||||
},
|
||||
};
|
||||
|
||||
// The badge sizing should be less than the tab size
|
||||
// according to the design system
|
||||
const badgeSizeMapping: Record<keyof typeof sizes, Sizes> = {
|
||||
sm: 'xs',
|
||||
md: 'sm',
|
||||
};
|
||||
|
||||
// Styles for different types of horizontal tabs
|
||||
const getHorizontalStyles = ({
|
||||
size,
|
||||
fullWidth,
|
||||
}: {
|
||||
size?: 'sm' | 'md';
|
||||
fullWidth?: boolean;
|
||||
}) => ({
|
||||
'button-brand': 'tw:gap-1',
|
||||
'button-gray': 'tw:gap-1',
|
||||
'button-border': cx(
|
||||
'tw:gap-1 tw:rounded-[10px] tw:bg-secondary_alt tw:p-1 tw:ring-1 tw:ring-secondary tw:ring-inset',
|
||||
size === 'md' && 'tw:rounded-xl tw:p-1.5'
|
||||
),
|
||||
'button-minimal':
|
||||
'tw:gap-0.5 tw:rounded-lg tw:bg-secondary_alt tw:ring-1 tw:ring-inset tw:ring-secondary',
|
||||
underline: cx('tw:gap-3', fullWidth && 'tw:w-full tw:gap-4'),
|
||||
line: 'tw:gap-2',
|
||||
});
|
||||
|
||||
const getColorStyles = ({
|
||||
isSelected,
|
||||
isHovered,
|
||||
}: Partial<AriaTabRenderProps>) => ({
|
||||
'button-brand': isSelected || isHovered ? 'brand' : 'gray',
|
||||
'button-gray': 'gray',
|
||||
'button-border': 'gray',
|
||||
'button-minimal': 'gray',
|
||||
underline: isSelected || isHovered ? 'brand' : 'gray',
|
||||
line: isSelected || isHovered ? 'brand' : 'gray',
|
||||
});
|
||||
|
||||
type TabListBaseProps<K extends Orientation> = {
|
||||
/** 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;
|
||||
/** Whether the tab list is full width. */
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
/** Dynamic variant: provide an items array and a render function as children. */
|
||||
type TabListWithItemsProps<K extends Orientation> = TabListBaseProps<K> &
|
||||
Omit<AriaTabListProps<TabComponentProps>, 'children'> & {
|
||||
items: TabComponentProps[];
|
||||
children?: (item: TabComponentProps) => ReactNode;
|
||||
};
|
||||
|
||||
/** Static variant: provide children directly as JSX elements. */
|
||||
type TabListWithChildrenProps<K extends Orientation> = TabListBaseProps<K> &
|
||||
Omit<AriaTabListProps<TabComponentProps>, 'items' | 'children'> & {
|
||||
items?: never;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type TabListComponentProps<K extends Orientation> =
|
||||
| TabListWithItemsProps<K>
|
||||
| TabListWithChildrenProps<K>;
|
||||
|
||||
const TabListContext = createContext<
|
||||
TabListBaseProps<Orientation> & { orientation?: Orientation }
|
||||
>({
|
||||
size: 'sm',
|
||||
type: 'button-brand',
|
||||
});
|
||||
|
||||
interface TabComponentProps extends AriaTabProps {
|
||||
/** The label of the tab. */
|
||||
label?: ReactNode;
|
||||
/** The children of the tab. */
|
||||
children?: ReactNode | ((props: AriaTabRenderProps) => ReactNode);
|
||||
/** The badge displayed next to the label. */
|
||||
badge?: number | string;
|
||||
}
|
||||
|
||||
export const Tab = (props: TabComponentProps) => {
|
||||
const { label, children, badge, ...otherProps } = props;
|
||||
const {
|
||||
size = 'sm',
|
||||
type = 'button-brand',
|
||||
fullWidth,
|
||||
} = useContext(TabListContext);
|
||||
|
||||
return (
|
||||
<AriaTab
|
||||
{...otherProps}
|
||||
className={(prop) =>
|
||||
cx(
|
||||
'tw:z-10 tw:flex tw:h-max tw:cursor-pointer tw:items-center tw:justify-center tw:gap-2 tw:rounded-md tw:whitespace-nowrap tw:text-quaternary tw:transition tw:duration-100 tw:ease-linear',
|
||||
'group-orientation-vertical:tw:justify-start',
|
||||
fullWidth && 'tw:w-full tw:flex-1',
|
||||
sizes[size][type],
|
||||
getTabStyles(prop)[type],
|
||||
typeof props.className === 'function'
|
||||
? props.className(prop)
|
||||
: props.className
|
||||
)
|
||||
}>
|
||||
{(state) => (
|
||||
<Fragment>
|
||||
{typeof children === 'function' ? children(state) : children || label}
|
||||
{badge && (
|
||||
<Badge
|
||||
className={cx(
|
||||
'tw:hidden tw:transition-inherit-all tw:md:flex',
|
||||
badgeSizeMapping[size] === 'sm' && 'tw:-my-px'
|
||||
)}
|
||||
color={getColorStyles(state)[type] as BadgeColors}
|
||||
size={badgeSizeMapping[size]}
|
||||
type="pill-color">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</AriaTab>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabList = <K extends Orientation>({
|
||||
size = 'sm',
|
||||
type = 'button-brand',
|
||||
orientation: orientationProp,
|
||||
fullWidth,
|
||||
className,
|
||||
children,
|
||||
...otherProps
|
||||
}: TabListComponentProps<K>) => {
|
||||
const context = useSlottedContext(TabsContext);
|
||||
|
||||
const orientation = orientationProp ?? context?.orientation ?? 'horizontal';
|
||||
|
||||
const contextValues = useMemo(
|
||||
() => ({ size, type, orientation, fullWidth }),
|
||||
[size, type, orientation, fullWidth]
|
||||
);
|
||||
|
||||
return (
|
||||
<TabListContext.Provider value={contextValues}>
|
||||
<AriaTabList
|
||||
{...(otherProps as AriaTabListProps<TabComponentProps>)}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:group tw:flex',
|
||||
|
||||
getHorizontalStyles({
|
||||
size,
|
||||
fullWidth,
|
||||
})[type as HorizontalTypes],
|
||||
|
||||
orientation === 'vertical' && 'tw:w-max tw:flex-col',
|
||||
|
||||
// Only horizontal tabs with underline type have bottom border
|
||||
orientation === 'horizontal' &&
|
||||
type === 'underline' &&
|
||||
'tw:relative tw:before:absolute tw:before:inset-x-0 tw:before:bottom-0 tw:before:h-px tw:before:bg-border-secondary',
|
||||
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{children ?? ((item) => <Tab {...item}>{item.children}</Tab>)}
|
||||
</AriaTabList>
|
||||
</TabListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabPanel = (props: ComponentPropsWithRef<typeof AriaTabPanel>) => {
|
||||
return (
|
||||
<AriaTabPanel
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:outline-focus-ring tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tabs = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof AriaTabs>) => {
|
||||
return (
|
||||
<AriaTabs
|
||||
keyboardActivation="manual"
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:flex tw:w-full tw:flex-col',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Tabs.Panel = TabPanel;
|
||||
Tabs.List = TabList;
|
||||
Tabs.Item = Tab;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './toast';
|
||||
export * from './toast-provider';
|
||||
export { toast, showToast, toastQueue } from './toast-store';
|
||||
export type { ToastContent, ShowToastOptions } from './toast-store';
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
UNSTABLE_ToastList as ToastList,
|
||||
UNSTABLE_ToastRegion as ToastRegion,
|
||||
} from 'react-aria-components';
|
||||
import type { QueuedToast } from 'react-aria-components';
|
||||
import { Toast } from './toast';
|
||||
import { toastQueue, type ToastContent } from './toast-store';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export type ToastPosition =
|
||||
| 'top-right'
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center';
|
||||
|
||||
const positionClasses: Record<ToastPosition, { region: string; list: string }> =
|
||||
{
|
||||
'top-right': {
|
||||
region: 'tw:top-4 tw:right-4 tw:items-end',
|
||||
list: 'tw:flex-col',
|
||||
},
|
||||
'top-left': {
|
||||
region: 'tw:top-4 tw:left-4 tw:items-start',
|
||||
list: 'tw:flex-col',
|
||||
},
|
||||
'top-center': {
|
||||
region: 'tw:top-4 tw:left-1/2 tw:-translate-x-1/2 tw:items-center',
|
||||
list: 'tw:flex-col',
|
||||
},
|
||||
'bottom-right': {
|
||||
region: 'tw:bottom-4 tw:right-4 tw:items-end',
|
||||
list: 'tw:flex-col-reverse',
|
||||
},
|
||||
'bottom-left': {
|
||||
region: 'tw:bottom-4 tw:left-4 tw:items-start',
|
||||
list: 'tw:flex-col-reverse',
|
||||
},
|
||||
'bottom-center': {
|
||||
region: 'tw:bottom-6.5 tw:left-1/2 tw:-translate-x-1/2 tw:items-center',
|
||||
list: 'tw:flex-col-reverse',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ToastProviderProps {
|
||||
position?: ToastPosition;
|
||||
}
|
||||
|
||||
export const ToastProvider = ({
|
||||
position = 'bottom-center',
|
||||
}: ToastProviderProps) => {
|
||||
const classes = positionClasses[position];
|
||||
|
||||
return (
|
||||
<ToastRegion
|
||||
className={cx(
|
||||
'tw:fixed tw:z-[1000] tw:flex tw:flex-col tw:gap-2 tw:outline-none',
|
||||
classes.region
|
||||
)}
|
||||
queue={toastQueue}>
|
||||
<ToastList className={cx('tw:flex tw:gap-2', classes.list)}>
|
||||
{({ toast }) => <Toast toast={toast as QueuedToast<ToastContent>} />}
|
||||
</ToastList>
|
||||
</ToastRegion>
|
||||
);
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { UNSTABLE_ToastQueue as ToastQueue } from 'react-aria-components';
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'warning' | 'info' | 'default';
|
||||
|
||||
export interface ToastContent {
|
||||
message: string | ReactNode;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
export const toastQueue = new ToastQueue<ToastContent>({
|
||||
maxVisibleToasts: 5,
|
||||
});
|
||||
|
||||
export interface ShowToastOptions {
|
||||
/** Whether the toast should dismiss automatically. Defaults to true. */
|
||||
autoDismiss?: boolean;
|
||||
/** Auto-dismiss delay in ms. Defaults to 3500. Pass 0 to keep the toast until manually dismissed. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function add(
|
||||
message: string | ReactNode,
|
||||
variant: ToastVariant,
|
||||
options?: ShowToastOptions
|
||||
): string {
|
||||
const { autoDismiss = true, timeout = 3500 } = options ?? {};
|
||||
|
||||
return toastQueue.add(
|
||||
{ message, variant },
|
||||
{ timeout: autoDismiss ? timeout : 0 }
|
||||
);
|
||||
}
|
||||
|
||||
export function showToast(
|
||||
message: string | ReactNode,
|
||||
options?: ShowToastOptions
|
||||
): string {
|
||||
return add(message, 'default', options);
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
show: showToast,
|
||||
success: (message: string | ReactNode, options?: ShowToastOptions) =>
|
||||
add(message, 'success', options),
|
||||
error: (message: string | ReactNode, options?: ShowToastOptions) =>
|
||||
add(message, 'error', { timeout: 0, ...options }),
|
||||
warning: (message: string | ReactNode, options?: ShowToastOptions) =>
|
||||
add(message, 'warning', options),
|
||||
info: (message: string | ReactNode, options?: ShowToastOptions) =>
|
||||
add(message, 'info', options),
|
||||
dismiss: (key: string) => toastQueue.close(key),
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
InfoCircle,
|
||||
X,
|
||||
} from '@untitledui/icons';
|
||||
import {
|
||||
Button,
|
||||
UNSTABLE_Toast as AriaToast,
|
||||
UNSTABLE_ToastStateContext,
|
||||
} from 'react-aria-components';
|
||||
import type { QueuedToast } from 'react-aria-components';
|
||||
import { useContext } from 'react';
|
||||
import type { ToastContent, ToastVariant } from './toast-store';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const variantConfig: Record<
|
||||
ToastVariant,
|
||||
{ icon: FC<{ className?: string }>; iconClass: string; showClose: boolean }
|
||||
> = {
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
iconClass: 'tw:text-fg-success-secondary',
|
||||
showClose: false,
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
iconClass: 'tw:text-fg-error-secondary',
|
||||
showClose: true,
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
iconClass: 'tw:text-fg-warning-secondary',
|
||||
showClose: false,
|
||||
},
|
||||
info: {
|
||||
icon: InfoCircle,
|
||||
iconClass: 'tw:text-fg-brand-secondary',
|
||||
showClose: false,
|
||||
},
|
||||
default: {
|
||||
icon: CheckCircle,
|
||||
iconClass: 'tw:text-fg-success-secondary',
|
||||
showClose: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface ToastProps {
|
||||
toast: QueuedToast<ToastContent>;
|
||||
}
|
||||
|
||||
export const Toast = ({ toast }: ToastProps) => {
|
||||
const state = useContext(UNSTABLE_ToastStateContext);
|
||||
const { variant = 'default', message: messageOrNode } = toast.content;
|
||||
const config = variantConfig[variant];
|
||||
const Icon = config.icon;
|
||||
const showClose = config.showClose || toast.timeout === 0;
|
||||
|
||||
return (
|
||||
<AriaToast
|
||||
className={cx(
|
||||
'tw:inline-flex tw:items-start tw:gap-2.5 tw:max-w-[600px]',
|
||||
'tw:rounded-[10px] tw:bg-primary-solid tw:px-4 tw:py-2.75',
|
||||
'tw:text-sm tw:font-medium tw:text-fg-white',
|
||||
'tw:shadow-2xl tw:outline-none',
|
||||
'tw:animate-in tw:fade-in tw:slide-in-from-bottom-2 tw:duration-150'
|
||||
)}
|
||||
data-testid="alert-bar"
|
||||
toast={toast}>
|
||||
<span className="tw:mt-0.5 tw:flex tw:shrink-0" data-testid="alert-icon">
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cx('tw:size-4', config.iconClass)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="tw:flex-1 tw:max-h-60 tw:overflow-y-auto tw:wrap-break-word"
|
||||
data-testid="alert-message">
|
||||
{typeof messageOrNode === 'string'
|
||||
? messageOrNode
|
||||
: (messageOrNode as ReactNode)}
|
||||
</span>
|
||||
{showClose && state && (
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="tw:-mr-1 tw:mt-0.5 tw:ml-1 tw:flex tw:shrink-0 tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-md tw:p-0.5 tw:text-fg-white/60 tw:outline-none tw:transition tw:hover:bg-white/10 tw:hover:text-fg-white"
|
||||
data-testid="alert-icon-close"
|
||||
slot="close"
|
||||
onPress={() => state.close(toast.key)}>
|
||||
<X aria-hidden="true" className="tw:size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</AriaToast>
|
||||
);
|
||||
};
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { cx } from '@/utils/cx';
|
||||
import { ChevronRight, RefreshCw01 } from '@untitledui/icons';
|
||||
import type {
|
||||
ComponentPropsWithRef,
|
||||
ComponentType,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import type {
|
||||
TreeItemProps as AriaTreeItemProps,
|
||||
TreeLoadMoreItemProps as AriaTreeLoadMoreItemProps,
|
||||
TreeProps as AriaTreeProps,
|
||||
Key,
|
||||
TreeItemContentRenderProps,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tree as AriaTree,
|
||||
TreeHeader as AriaTreeHeader,
|
||||
TreeItem as AriaTreeItem,
|
||||
TreeItemContent as AriaTreeItemContent,
|
||||
TreeLoadMoreItem as AriaTreeLoadMoreItem,
|
||||
TreeSection as AriaTreeSection,
|
||||
useDragAndDrop,
|
||||
} from 'react-aria-components';
|
||||
|
||||
export type { Key, Selection } from 'react-aria-components';
|
||||
|
||||
// ---- TreeItemMoveEvent --------------------------------------------------
|
||||
|
||||
export interface TreeItemMoveEvent {
|
||||
/** The key of the item being dragged. */
|
||||
sourceKey: Key;
|
||||
/** The key of the target item. */
|
||||
targetKey: Key;
|
||||
/** Drop position relative to the target item. */
|
||||
dropPosition: 'before' | 'after' | 'on';
|
||||
}
|
||||
|
||||
// ---- Tree ---------------------------------------------------------------
|
||||
|
||||
export interface TreeProps<T extends object>
|
||||
extends Omit<AriaTreeProps<T>, 'className' | 'children'> {
|
||||
/** Additional CSS class name for the tree container. */
|
||||
className?: string;
|
||||
/** The contents of the tree. */
|
||||
children?: ReactNode | ((item: T) => ReactNode);
|
||||
/**
|
||||
* When provided, enables drag-and-drop reordering. Called whenever an item
|
||||
* is dropped onto or around another item. The Tree wires `useDragAndDrop`
|
||||
* internally — consumers don't need to import it from react-aria-components.
|
||||
*
|
||||
* When `onItemMove` is set it takes precedence over a manually passed
|
||||
* `dragAndDropHooks` prop.
|
||||
*/
|
||||
onItemMove?: (event: TreeItemMoveEvent) => void;
|
||||
/**
|
||||
* Called when an item is dropped onto the root of the tree (empty space,
|
||||
* outside any existing item). Use this to move the dragged item to the
|
||||
* top level. Receives the source key of the dragged item.
|
||||
* Only active when `onItemMove` is also provided.
|
||||
*/
|
||||
onItemRootDrop?: (sourceKey: Key) => void;
|
||||
}
|
||||
|
||||
const TreeRoot = <T extends object>({
|
||||
className,
|
||||
children,
|
||||
onItemMove,
|
||||
onItemRootDrop,
|
||||
...props
|
||||
}: TreeProps<T>) => {
|
||||
const { dragAndDropHooks: internalHooks } = useDragAndDrop({
|
||||
getItems: onItemMove
|
||||
? (keys) => Array.from(keys).map((key) => ({ 'text/plain': String(key) }))
|
||||
: (undefined as never),
|
||||
onMove: onItemMove
|
||||
? (e) => {
|
||||
const sourceKey = Array.from(e.keys)[0];
|
||||
onItemMove({
|
||||
sourceKey,
|
||||
targetKey: e.target.key,
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onRootDrop: onItemRootDrop
|
||||
? async (e) => {
|
||||
const item = e.items.find((i) => i.kind === 'text');
|
||||
const sourceKey = await item?.getText('text/plain');
|
||||
if (sourceKey) {
|
||||
onItemRootDrop(sourceKey);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<AriaTree
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:outline-hidden tw:w-full tw:flex tw:flex-col tw:gap-0.5',
|
||||
className
|
||||
)}
|
||||
dragAndDropHooks={onItemMove ? internalHooks : props.dragAndDropHooks}>
|
||||
{children as AriaTreeProps<T>['children']}
|
||||
</AriaTree>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- TreeItem -----------------------------------------------------------
|
||||
|
||||
export interface TreeItemProps<T extends object>
|
||||
extends Omit<AriaTreeItemProps<T>, 'children'> {
|
||||
/** The content of this tree item. Typically `Tree.ItemContent`. */
|
||||
children: ReactNode;
|
||||
/** Additional CSS class name applied to the item row. */
|
||||
className?: string | AriaTreeItemProps<T>['className'];
|
||||
}
|
||||
|
||||
const TreeItemComponent = <T extends object>({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TreeItemProps<T>) => {
|
||||
return (
|
||||
<AriaTreeItem
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:group/tree-item tw:outline-hidden tw:rounded-md',
|
||||
'tw:cursor-pointer tw:select-none',
|
||||
state.isDisabled && 'tw:opacity-50 tw:cursor-not-allowed',
|
||||
state.isFocusVisible && 'tw:ring-2 tw:ring-inset tw:ring-brand-300',
|
||||
'data-[dragging]:tw:opacity-50 data-[dragging]:tw:ring-2 data-[dragging]:tw:ring-inset data-[dragging]:tw:ring-brand-300',
|
||||
'data-[drop-target]:tw:bg-brand-primary_alt data-[drop-target]:tw:ring-2 data-[drop-target]:tw:ring-inset data-[drop-target]:tw:ring-brand-300',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{children}
|
||||
</AriaTreeItem>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- TreeExpandButton ---------------------------------------------------
|
||||
|
||||
export interface TreeExpandButtonProps
|
||||
extends Omit<ComponentPropsWithRef<typeof AriaButton>, 'className'> {
|
||||
/** Additional CSS class name. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accessible expand/collapse button for use inside `Tree.ItemContent`.
|
||||
* Render with slot="chevron" so React Aria's Tree handles keyboard & ARIA.
|
||||
*/
|
||||
const TreeExpandButton = ({ className, ...props }: TreeExpandButtonProps) => {
|
||||
return (
|
||||
<AriaButton
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:flex tw:items-center tw:justify-center tw:w-4 tw:h-4 tw:shrink-0',
|
||||
'tw:rounded tw:outline-hidden tw:text-fg-quaternary',
|
||||
'tw:transition-transform tw:duration-200 tw:ease-in-out tw:cursor-pointer',
|
||||
state.isFocusVisible && 'tw:ring-2 tw:ring-brand-300',
|
||||
className
|
||||
)
|
||||
}
|
||||
slot="chevron">
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="tw:w-3.5 tw:h-3.5 tw:group-data-expanded/tree-item:rotate-90 tw:transition-transform tw:duration-200"
|
||||
/>
|
||||
</AriaButton>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- TreeItemContent ----------------------------------------------------
|
||||
|
||||
export interface TreeItemContentProps {
|
||||
/**
|
||||
* The content to render. Can be static ReactNode or a render function
|
||||
* receiving `TreeItemContentRenderProps`.
|
||||
*/
|
||||
children:
|
||||
| ReactNode
|
||||
| ((renderProps: TreeItemContentRenderProps) => ReactNode);
|
||||
/** Additional CSS class name for the content wrapper. */
|
||||
className?: string;
|
||||
/**
|
||||
* Optional icon component rendered between the chevron and the label.
|
||||
* Accepts any `@untitledui/icons`-compatible component.
|
||||
*/
|
||||
icon?: ComponentType<HTMLAttributes<HTMLOrSVGElement>>;
|
||||
/** Additional CSS class name applied to the icon. */
|
||||
iconClassName?: string;
|
||||
/**
|
||||
* When `true`, an animated expand/collapse chevron button is rendered.
|
||||
* Set to `false` to render your own expand indicator. Defaults to `true`.
|
||||
*/
|
||||
showExpandIcon?: boolean;
|
||||
/**
|
||||
* Override whether the item has child items. When provided, this value
|
||||
* is used instead of React Aria's internal `hasChildItems` — useful for
|
||||
* lazy-loaded trees where children aren't in the DOM yet but the node is
|
||||
* known to have children (e.g. from a `childrenCount` field).
|
||||
* When `undefined`, falls back to React Aria's `hasChildItems`.
|
||||
*/
|
||||
hasChildItems?: boolean;
|
||||
/**
|
||||
* When `true`, draws a vertical guide line for nested items (level >= 2).
|
||||
* Rendered as an absolutely-positioned `<span>` using Tailwind classes.
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
showGuideLines?: boolean;
|
||||
}
|
||||
|
||||
const TreeItemContentComponent = ({
|
||||
className,
|
||||
children,
|
||||
icon: Icon,
|
||||
iconClassName,
|
||||
showExpandIcon = true,
|
||||
showGuideLines = false,
|
||||
hasChildItems: hasChildItemsProp,
|
||||
}: TreeItemContentProps) => {
|
||||
return (
|
||||
<AriaTreeItemContent>
|
||||
{(renderProps: TreeItemContentRenderProps) => {
|
||||
const { hasChildItems: hasChildItemsFromAria, level } = renderProps;
|
||||
const hasChildItems = hasChildItemsProp ?? hasChildItemsFromAria;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:items-center tw:gap-3 tw:py-1.5 tw:pr-1.5',
|
||||
'tw:rounded-md tw:text-sm tw:font-medium tw:text-secondary',
|
||||
'tw:hover:bg-primary_hover',
|
||||
'tw:group-selected/tree-item:bg-brand-primary_alt tw:group-selected/tree-item:text-brand-secondary',
|
||||
className
|
||||
)}
|
||||
style={{ marginLeft: `${(level - 1) * 16 + 2}px` }}>
|
||||
{showGuideLines && level >= 2 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="tw:absolute tw:top-0 tw:bottom-0 tw:w-px tw:bg-gray-blue-100 tw:pointer-events-none"
|
||||
style={{ left: '-10px' }}
|
||||
/>
|
||||
)}
|
||||
{showExpandIcon && (
|
||||
<TreeExpandButton
|
||||
className={cx(
|
||||
!hasChildItems && 'tw:invisible tw:pointer-events-none'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{Icon && (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:w-4 tw:h-4 tw:shrink-0 tw:text-fg-quaternary',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{typeof children === 'function' ? children(renderProps) : children}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</AriaTreeItemContent>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- TreeLoadMoreItem ---------------------------------------------------
|
||||
|
||||
export type TreeLoadMoreItemProps = Omit<
|
||||
AriaTreeLoadMoreItemProps,
|
||||
'className' | 'children'
|
||||
> & {
|
||||
/** Additional CSS class name for the load-more row. */
|
||||
className?: string;
|
||||
/** Content shown inside the row. Defaults to a spinner + "Loading…" / "Load more". */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const TreeLoadMoreItemComponent = ({
|
||||
isLoading,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TreeLoadMoreItemProps) => {
|
||||
return (
|
||||
<AriaTreeLoadMoreItem
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:items-center tw:justify-center tw:px-3 tw:py-1.5 tw:text-sm tw:text-tertiary',
|
||||
className
|
||||
)}
|
||||
isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
<span className="tw:flex tw:items-center tw:gap-2">
|
||||
<RefreshCw01
|
||||
aria-hidden="true"
|
||||
className="tw:h-4 tw:w-4 tw:animate-spin"
|
||||
/>
|
||||
{children ?? 'Loading…'}
|
||||
</span>
|
||||
) : (
|
||||
children ?? 'Load more'
|
||||
)}
|
||||
</AriaTreeLoadMoreItem>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- TreeSection --------------------------------------------------------
|
||||
|
||||
export type TreeSectionProps<T extends object> = ComponentPropsWithRef<
|
||||
typeof AriaTreeSection<T>
|
||||
>;
|
||||
|
||||
const TreeSectionComponent = <T extends object>(props: TreeSectionProps<T>) => (
|
||||
<AriaTreeSection {...props} />
|
||||
);
|
||||
|
||||
// ---- TreeHeader ---------------------------------------------------------
|
||||
|
||||
export interface TreeHeaderProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TreeHeaderComponent = ({ className, children }: TreeHeaderProps) => {
|
||||
return (
|
||||
<AriaTreeHeader
|
||||
className={cx(
|
||||
'tw:px-3 tw:py-1 tw:text-xs tw:font-semibold tw:text-tertiary tw:uppercase tw:tracking-wide',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</AriaTreeHeader>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Compound export ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tree renders a hierarchical list of items with keyboard navigation,
|
||||
* selection, expand/collapse, and optional drag-and-drop support.
|
||||
* Built on React Aria's Tree primitives for full accessibility.
|
||||
*
|
||||
* Pass `onItemMove` to enable drag-and-drop without importing useDragAndDrop:
|
||||
* ```tsx
|
||||
* <Tree onItemMove={({ sourceKey, targetKey, dropPosition }) => { ... }}>
|
||||
* ```
|
||||
*
|
||||
* Pass `showGuideLines` on `Tree.ItemContent` to render vertical indent lines:
|
||||
* ```tsx
|
||||
* <Tree.ItemContent showGuideLines>...</Tree.ItemContent>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tree selectionMode="single">
|
||||
* <Tree.Item id="databases" textValue="Databases">
|
||||
* <Tree.ItemContent>Databases</Tree.ItemContent>
|
||||
* <Tree.Item id="postgres" textValue="Postgres">
|
||||
* <Tree.ItemContent>Postgres</Tree.ItemContent>
|
||||
* </Tree.Item>
|
||||
* </Tree.Item>
|
||||
* </Tree>
|
||||
* ```
|
||||
*/
|
||||
const _Tree = TreeRoot as typeof TreeRoot & {
|
||||
Item: typeof TreeItemComponent;
|
||||
ItemContent: typeof TreeItemContentComponent;
|
||||
LoadMoreItem: typeof TreeLoadMoreItemComponent;
|
||||
Section: typeof TreeSectionComponent;
|
||||
Header: typeof TreeHeaderComponent;
|
||||
ExpandButton: typeof TreeExpandButton;
|
||||
};
|
||||
|
||||
_Tree.Item = TreeItemComponent;
|
||||
_Tree.ItemContent = TreeItemContentComponent;
|
||||
_Tree.LoadMoreItem = TreeLoadMoreItemComponent;
|
||||
_Tree.Section = TreeSectionComponent;
|
||||
_Tree.Header = TreeHeaderComponent;
|
||||
_Tree.ExpandButton = TreeExpandButton;
|
||||
|
||||
export {
|
||||
_Tree as Tree,
|
||||
TreeExpandButton,
|
||||
TreeHeaderComponent as TreeHeader,
|
||||
TreeItemComponent as TreeItem,
|
||||
TreeItemContentComponent as TreeItemContent,
|
||||
TreeLoadMoreItemComponent as TreeLoadMoreItem,
|
||||
TreeSectionComponent as TreeSection,
|
||||
};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CloseButton } from '@/components/base/buttons/close-button';
|
||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||
import { cx } from '@/utils/cx';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
InfoCircle,
|
||||
} from '@untitledui/icons';
|
||||
import type { FC, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type AlertVariant = 'success' | 'warning' | 'error' | 'brand' | 'gray';
|
||||
|
||||
type FeaturedIconColor = 'brand' | 'gray' | 'success' | 'warning' | 'error';
|
||||
|
||||
const variantStyles: Record<
|
||||
AlertVariant,
|
||||
{
|
||||
root: string;
|
||||
iconColor: FeaturedIconColor;
|
||||
defaultIcon: FC<{ className?: string }>;
|
||||
}
|
||||
> = {
|
||||
success: {
|
||||
root: 'tw:border-utility-success-300 tw:bg-success-primary',
|
||||
iconColor: 'success',
|
||||
defaultIcon: CheckCircle,
|
||||
},
|
||||
warning: {
|
||||
root: 'tw:border-utility-warning-300 tw:bg-warning-primary',
|
||||
iconColor: 'warning',
|
||||
defaultIcon: AlertTriangle,
|
||||
},
|
||||
error: {
|
||||
root: 'tw:border-utility-error-300 tw:bg-error-primary',
|
||||
iconColor: 'error',
|
||||
defaultIcon: AlertCircle,
|
||||
},
|
||||
brand: {
|
||||
root: 'tw:border-utility-blue-300 tw:bg-utility-blue-50',
|
||||
iconColor: 'brand',
|
||||
defaultIcon: InfoCircle,
|
||||
},
|
||||
gray: {
|
||||
root: 'tw:border-utility-gray-300 tw:bg-primary',
|
||||
iconColor: 'gray',
|
||||
defaultIcon: InfoCircle,
|
||||
},
|
||||
};
|
||||
|
||||
export interface AlertProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
/** success, warning, error, brand or gray */
|
||||
variant: AlertVariant;
|
||||
/** Bold heading text */
|
||||
title: string;
|
||||
/** Body content and optional action buttons */
|
||||
children?: ReactNode;
|
||||
/** Override the default variant icon */
|
||||
icon?: FC<{ className?: string }>;
|
||||
/** Size forwarded to FeaturedIcon — defaults to 'md' */
|
||||
iconSize?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** 'square' renders the icon in a rounded-rect container instead of a circle */
|
||||
iconShape?: 'circle' | 'square';
|
||||
/** Corner radius for a square icon container */
|
||||
iconRadius?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Adds a variant-coloured border around the icon container */
|
||||
iconOutlined?: boolean;
|
||||
/** 'white' overrides the coloured icon background with a plain white fill */
|
||||
iconBgColor?: 'colored' | 'white';
|
||||
/** Node rendered on the far right, always vertically centred */
|
||||
rightContent?: ReactNode;
|
||||
/** Shows the × close button when true. */
|
||||
closable?: boolean;
|
||||
/** Called when the × close button is clicked. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Alert = ({
|
||||
variant,
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
iconSize = 'md',
|
||||
iconShape,
|
||||
iconRadius,
|
||||
iconOutlined,
|
||||
iconBgColor,
|
||||
rightContent,
|
||||
closable = false,
|
||||
onClose,
|
||||
className,
|
||||
...props
|
||||
}: AlertProps) => {
|
||||
const styles = variantStyles[variant];
|
||||
const Icon = icon ?? styles.defaultIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:w-full tw:gap-3 tw:rounded-xl tw:border tw:px-4',
|
||||
children ? 'tw:items-start tw:py-4' : 'tw:items-center tw:py-2',
|
||||
styles.root,
|
||||
className
|
||||
)}
|
||||
role="alert">
|
||||
<FeaturedIcon
|
||||
bgColor={iconBgColor}
|
||||
className={cx('tw:shrink-0', children && 'tw:self-start')}
|
||||
color={styles.iconColor}
|
||||
data-testid="alert-icon"
|
||||
icon={Icon}
|
||||
outlined={iconOutlined}
|
||||
radius={iconRadius}
|
||||
shape={iconShape}
|
||||
size={iconSize}
|
||||
/>
|
||||
|
||||
<div className="tw:flex tw:min-w-0 tw:flex-1 tw:flex-col tw:text-sm">
|
||||
<p
|
||||
className="tw:font-semibold tw:text-secondary"
|
||||
data-testid="alert-title">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{children && (
|
||||
<div className="tw:text-tertiary" data-testid="alert-children">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rightContent && (
|
||||
<div
|
||||
className="tw:shrink-0 tw:self-center"
|
||||
data-testid="alert-right-content">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{closable && (
|
||||
<CloseButton
|
||||
className="tw:shrink-0"
|
||||
data-testid="alert-close-button"
|
||||
label="Close alert"
|
||||
size="sm"
|
||||
onPress={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { SelectContext } from '@/components/base/select/select';
|
||||
import type { SelectItemType } from '@/components/base/select/select';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
import { fontSizeClass } from '@/utils/tailwindClasses';
|
||||
import { Check } from '@untitledui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { isValidElement, useContext } from 'react';
|
||||
import type {
|
||||
ListBoxItemProps as AriaListBoxItemProps,
|
||||
ListBoxItemRenderProps,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
ListBoxItem as AriaListBoxItem,
|
||||
Text as AriaText,
|
||||
} from 'react-aria-components';
|
||||
|
||||
const sizes = {
|
||||
sm: 'tw:p-2 tw:pr-2.5',
|
||||
md: 'tw:p-2.5 tw:pl-2',
|
||||
};
|
||||
|
||||
const itemWrapperBase =
|
||||
'tw:flex tw:cursor-pointer tw:items-center tw:gap-2 tw:rounded-md tw:outline-hidden tw:select-none';
|
||||
|
||||
const itemWrapperClass = (
|
||||
state: ListBoxItemRenderProps,
|
||||
size: 'sm' | 'md',
|
||||
extraClass?: string
|
||||
) =>
|
||||
cx(
|
||||
itemWrapperBase,
|
||||
state.isSelected && 'tw:bg-active',
|
||||
state.isDisabled && 'tw:cursor-not-allowed',
|
||||
state.isFocused && 'tw:bg-primary_hover',
|
||||
state.isFocusVisible && 'tw:ring-2 tw:ring-focus-ring tw:ring-inset',
|
||||
sizes[size],
|
||||
extraClass
|
||||
);
|
||||
|
||||
const checkClass = (size: 'sm' | 'md', isDisabled: boolean) =>
|
||||
cx(
|
||||
'tw:ml-auto tw:shrink-0 tw:text-fg-brand-primary',
|
||||
size === 'sm' ? 'tw:size-4 tw:stroke-[2.5px]' : 'tw:size-5',
|
||||
isDisabled && 'tw:text-fg-disabled'
|
||||
);
|
||||
|
||||
interface AutocompleteItemProps
|
||||
extends Omit<AriaListBoxItemProps<SelectItemType>, 'id' | 'children'>,
|
||||
Omit<SelectItemType, 'id'> {
|
||||
id: string;
|
||||
'data-testid'?: string;
|
||||
children?: ReactNode | ((state: ListBoxItemRenderProps) => ReactNode);
|
||||
}
|
||||
|
||||
const renderItemIcon = (
|
||||
avatarUrl: string | undefined,
|
||||
Icon: SelectItemType['icon'],
|
||||
label: string | undefined
|
||||
) => {
|
||||
if (avatarUrl) {
|
||||
return <Avatar alt={label} aria-hidden="true" size="xs" src={avatarUrl} />;
|
||||
}
|
||||
if (isReactComponent(Icon)) {
|
||||
return <Icon data-icon aria-hidden="true" />;
|
||||
}
|
||||
if (isValidElement(Icon)) {
|
||||
return Icon;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface DefaultItemContentProps {
|
||||
state: ListBoxItemRenderProps;
|
||||
size: 'sm' | 'md';
|
||||
fontSize: keyof typeof fontSizeClass;
|
||||
label: string | undefined;
|
||||
avatarUrl: string | undefined;
|
||||
supportingText: string | undefined;
|
||||
icon: SelectItemType['icon'];
|
||||
dataTestId: string | undefined;
|
||||
stringChild: string;
|
||||
}
|
||||
|
||||
const DefaultItemContent = ({
|
||||
state,
|
||||
size,
|
||||
fontSize,
|
||||
label,
|
||||
avatarUrl,
|
||||
supportingText,
|
||||
icon,
|
||||
dataTestId,
|
||||
stringChild,
|
||||
}: DefaultItemContentProps) => (
|
||||
<div
|
||||
className={itemWrapperClass(
|
||||
state,
|
||||
size,
|
||||
cx(
|
||||
'tw:*:data-icon:size-5 tw:*:data-icon:shrink-0 tw:*:data-icon:text-fg-quaternary',
|
||||
state.isDisabled && 'tw:*:data-icon:text-fg-disabled'
|
||||
)
|
||||
)}
|
||||
data-testid={dataTestId}>
|
||||
{renderItemIcon(avatarUrl, icon, label)}
|
||||
|
||||
<div className="tw:flex tw:w-full tw:min-w-0 tw:flex-1 tw:flex-wrap tw:gap-x-2">
|
||||
<AriaText
|
||||
className={cx(
|
||||
'tw:truncate tw:font-medium tw:whitespace-nowrap tw:text-primary',
|
||||
fontSizeClass[fontSize],
|
||||
state.isDisabled && 'tw:text-disabled'
|
||||
)}
|
||||
slot="label">
|
||||
{label || stringChild}
|
||||
</AriaText>
|
||||
|
||||
{supportingText && (
|
||||
<AriaText
|
||||
className={cx(
|
||||
'tw:basis-full tw:whitespace-normal tw:text-tertiary',
|
||||
fontSizeClass[fontSize],
|
||||
state.isDisabled && 'tw:text-disabled'
|
||||
)}
|
||||
slot="description">
|
||||
{supportingText}
|
||||
</AriaText>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.isSelected && (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={checkClass(size, state.isDisabled)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CustomItemContentProps {
|
||||
state: ListBoxItemRenderProps;
|
||||
size: 'sm' | 'md';
|
||||
dataTestId: string | undefined;
|
||||
children: ReactNode | ((state: ListBoxItemRenderProps) => ReactNode);
|
||||
}
|
||||
|
||||
const CustomItemContent = ({
|
||||
state,
|
||||
size,
|
||||
dataTestId,
|
||||
children,
|
||||
}: CustomItemContentProps) => (
|
||||
<div className={itemWrapperClass(state, size)} data-testid={dataTestId}>
|
||||
{typeof children === 'function' ? children(state) : children}
|
||||
{state.isSelected && (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={checkClass(size, state.isDisabled)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AutocompleteItem = ({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
avatarUrl,
|
||||
supportingText,
|
||||
isDisabled,
|
||||
icon: Icon,
|
||||
className,
|
||||
children,
|
||||
'data-testid': dataTestId,
|
||||
...props
|
||||
}: AutocompleteItemProps) => {
|
||||
const { size, fontSize } = useContext(SelectContext);
|
||||
|
||||
const stringChild = typeof children === 'string' ? children : '';
|
||||
const labelOrChildren = label || stringChild;
|
||||
const textValue = supportingText
|
||||
? labelOrChildren + ' ' + supportingText
|
||||
: labelOrChildren;
|
||||
const hasCustomChildren = Boolean(children) && typeof children !== 'string';
|
||||
|
||||
return (
|
||||
<AriaListBoxItem
|
||||
id={id}
|
||||
isDisabled={isDisabled}
|
||||
textValue={textValue}
|
||||
value={
|
||||
value ?? {
|
||||
id,
|
||||
label: labelOrChildren,
|
||||
avatarUrl,
|
||||
supportingText,
|
||||
isDisabled,
|
||||
icon: Icon,
|
||||
}
|
||||
}
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:w-full tw:px-1.5 tw:py-px tw:outline-hidden',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{(state) =>
|
||||
hasCustomChildren ? (
|
||||
<CustomItemContent dataTestId={dataTestId} size={size} state={state}>
|
||||
{
|
||||
children as
|
||||
| ReactNode
|
||||
| ((state: ListBoxItemRenderProps) => ReactNode)
|
||||
}
|
||||
</CustomItemContent>
|
||||
) : (
|
||||
<DefaultItemContent
|
||||
avatarUrl={avatarUrl}
|
||||
dataTestId={dataTestId}
|
||||
fontSize={fontSize}
|
||||
icon={Icon}
|
||||
label={label}
|
||||
size={size}
|
||||
state={state}
|
||||
stringChild={stringChild}
|
||||
supportingText={supportingText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AriaListBoxItem>
|
||||
);
|
||||
};
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
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,
|
||||
SelectContext,
|
||||
SelectEmptyState,
|
||||
sizes,
|
||||
} from '@/components/base/select/select';
|
||||
import { Typography } from '@/components/foundations/typography';
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
import { SearchLg } from '@untitledui/icons';
|
||||
import type {
|
||||
FocusEventHandler,
|
||||
KeyboardEvent,
|
||||
PointerEventHandler,
|
||||
ReactNode,
|
||||
RefAttributes,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
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 { Avatar } from '../avatar/avatar';
|
||||
import { Badge, BadgeWithButton } from '../badges/badges';
|
||||
import { AutocompleteItem } from './autocomplete-item';
|
||||
|
||||
interface AutocompleteContextValue {
|
||||
size: 'sm' | 'md';
|
||||
selectedKeys: Key[];
|
||||
selectedItems: SelectItemType[];
|
||||
onRemove: (keys: Set<Key>) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
onCreateItem?: (value: string) => void;
|
||||
allowsCreation: boolean;
|
||||
hideDropdown: boolean;
|
||||
renderTag?: (item: SelectItemType, onRemove: () => void) => ReactNode;
|
||||
maxVisibleItems?: number;
|
||||
multiple: boolean;
|
||||
visibleItemCount: number;
|
||||
triggerRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const AutocompleteContext = createContext<AutocompleteContextValue>({
|
||||
size: 'sm',
|
||||
selectedKeys: [],
|
||||
selectedItems: [],
|
||||
onRemove: () => {},
|
||||
onInputChange: () => {},
|
||||
onCreateItem: undefined,
|
||||
allowsCreation: false,
|
||||
hideDropdown: false,
|
||||
maxVisibleItems: undefined,
|
||||
multiple: true,
|
||||
visibleItemCount: 0,
|
||||
triggerRef: { current: null },
|
||||
});
|
||||
|
||||
interface AutocompleteTriggerProps extends AriaGroupProps {
|
||||
size: 'sm' | 'md';
|
||||
isDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
icon?: IconComponentType | null;
|
||||
onFocus?: FocusEventHandler;
|
||||
onPointerEnter?: PointerEventHandler;
|
||||
}
|
||||
|
||||
export interface AutocompleteProps
|
||||
extends Omit<AriaComboBoxProps<SelectItemType>, 'children' | 'items'>,
|
||||
RefAttributes<HTMLDivElement> {
|
||||
hint?: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
placeholder?: string;
|
||||
items?: SelectItemType[];
|
||||
popoverClassName?: string;
|
||||
selectedItems: SelectItemType[] | ListData<SelectItemType>;
|
||||
icon?: IconComponentType | null;
|
||||
children: AriaListBoxProps<SelectItemType>['children'];
|
||||
onItemInserted?: (key: Key) => void;
|
||||
onItemCleared?: (key: Key) => void;
|
||||
onFocus?: FocusEventHandler;
|
||||
renderTag?: (item: SelectItemType, onRemove: () => void) => ReactNode;
|
||||
filterOption?: (item: SelectItemType, filterText: string) => boolean;
|
||||
onSearchChange?: (value: string) => void;
|
||||
maxVisibleItems?: number;
|
||||
multiple?: boolean;
|
||||
allowsCreation?: boolean;
|
||||
hideDropdown?: boolean;
|
||||
}
|
||||
|
||||
const renderChipIcon = (item: SelectItemType) => {
|
||||
if (item.avatarUrl) {
|
||||
return <Avatar alt={item.label} size="xs" src={item.avatarUrl} />;
|
||||
}
|
||||
const Icon = item.icon;
|
||||
if (isReactComponent(Icon)) {
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="tw:size-4 tw:shrink-0 tw:text-fg-quaternary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isValidElement(Icon)) {
|
||||
return Icon;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const InnerAutocomplete = ({
|
||||
isDisabled,
|
||||
placeholder,
|
||||
}: {
|
||||
isDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
}) => {
|
||||
const focusManager = useFocusManager();
|
||||
const context = useContext(AutocompleteContext);
|
||||
const comboBoxStateContext = useContext(ComboBoxStateContext);
|
||||
|
||||
const canCreateItem =
|
||||
context.allowsCreation &&
|
||||
(context.hideDropdown || context.visibleItemCount === 0);
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
const inputValue = event.currentTarget.value;
|
||||
const isCaretAtStart =
|
||||
event.currentTarget.selectionStart === 0 &&
|
||||
event.currentTarget.selectionEnd === 0;
|
||||
|
||||
if (event.key === 'Enter' && canCreateItem && inputValue.trim() !== '') {
|
||||
event.preventDefault();
|
||||
context.onCreateItem?.(inputValue.trim());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCaretAtStart && inputValue !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Backspace':
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: false, tabbable: false });
|
||||
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: false, tabbable: false });
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (comboBoxStateContext && !comboBoxStateContext.isOpen) {
|
||||
comboBoxStateContext.open();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputMouseDown = (_event: React.MouseEvent<HTMLInputElement>) => {
|
||||
if (comboBoxStateContext && !comboBoxStateContext.isOpen) {
|
||||
comboBoxStateContext.open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
value: Key
|
||||
) => {
|
||||
if (event.key === 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const isFirstTag = context?.selectedItems?.[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 });
|
||||
}
|
||||
context.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 = context?.selectedItems?.length === 0;
|
||||
const { maxVisibleItems, multiple } = context;
|
||||
const allSelected = context?.selectedItems ?? [];
|
||||
const visibleSelected =
|
||||
maxVisibleItems === undefined
|
||||
? allSelected
|
||||
: allSelected.slice(0, maxVisibleItems);
|
||||
const overflowCount =
|
||||
maxVisibleItems === undefined
|
||||
? 0
|
||||
: allSelected.length - visibleSelected.length;
|
||||
|
||||
return (
|
||||
<div className="tw:relative tw:flex tw:w-full tw:flex-1 tw:flex-row tw:flex-wrap tw:items-center tw:justify-start tw:gap-1.5">
|
||||
{!isSelectionEmpty &&
|
||||
visibleSelected.map((item) =>
|
||||
context.renderTag ? (
|
||||
context.renderTag(item, () => context.onRemove(new Set([item.id])))
|
||||
) : (
|
||||
<BadgeWithButton
|
||||
color="gray"
|
||||
isDisabled={isDisabled}
|
||||
key={item.id}
|
||||
size="lg"
|
||||
type="modern"
|
||||
onButtonClick={() => context.onRemove(new Set([item.id]))}
|
||||
onButtonKeyDown={(e) => handleTagKeyDown(e, item.id)}>
|
||||
{renderChipIcon(item)}
|
||||
<div className="tw:min-w-0 tw:max-w-40">
|
||||
<Typography ellipsis as="p" weight="medium">
|
||||
{item.label}
|
||||
</Typography>
|
||||
</div>
|
||||
</BadgeWithButton>
|
||||
)
|
||||
)}
|
||||
|
||||
{overflowCount > 0 && (
|
||||
<Badge color="gray" size="lg" type="modern">
|
||||
+{overflowCount}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:min-w-[20%] tw:flex-1 tw:flex-row tw:items-center',
|
||||
!isSelectionEmpty && 'tw:ml-0.5',
|
||||
!multiple && !isSelectionEmpty && 'tw:hidden'
|
||||
)}>
|
||||
<AriaInput
|
||||
className="tw:w-full tw:flex-[1_0_0] tw:appearance-none tw:bg-transparent tw:text-sm tw:text-ellipsis tw:text-primary tw:caret-alpha-black/90 tw:outline-hidden tw:placeholder:text-placeholder tw:focus:outline-hidden tw:disabled:cursor-not-allowed tw:disabled:text-disabled tw:disabled:placeholder:text-disabled"
|
||||
placeholder={placeholder}
|
||||
onBlur={(event) => {
|
||||
const inputValue = event.target.value.trim();
|
||||
const isMovingInsideWidget = context.triggerRef?.current?.contains(
|
||||
event.relatedTarget
|
||||
);
|
||||
if (!isMovingInsideWidget && canCreateItem && inputValue !== '') {
|
||||
context.onCreateItem?.(inputValue);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onMouseDown={handleInputMouseDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutocompleteTrigger = ({
|
||||
size,
|
||||
placeholder,
|
||||
icon: Icon = SearchLg,
|
||||
isDisabled: _isDisabled,
|
||||
isInvalid,
|
||||
...otherProps
|
||||
}: AutocompleteTriggerProps) => {
|
||||
return (
|
||||
<AriaGroup
|
||||
{...otherProps}
|
||||
className={({ isFocusWithin, isDisabled }) =>
|
||||
cx(
|
||||
'tw:relative tw:flex tw:w-full tw:items-center tw:gap-2 tw:rounded-lg tw:bg-primary tw:shadow-xs tw:ring-1 tw:ring-primary tw:outline-hidden tw:transition tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
isDisabled && 'tw:cursor-not-allowed tw:bg-disabled_subtle',
|
||||
isInvalid && 'tw:ring-error_subtle',
|
||||
isFocusWithin && 'tw:ring-2 tw:ring-brand',
|
||||
isFocusWithin && isInvalid && 'tw:ring-2 tw:ring-error',
|
||||
sizes[size].root
|
||||
)
|
||||
}
|
||||
isInvalid={isInvalid}>
|
||||
{({ isDisabled }) => (
|
||||
<>
|
||||
{Icon && (
|
||||
<Icon className="tw:pointer-events-none tw:size-5 tw:shrink-0 tw:text-fg-quaternary" />
|
||||
)}
|
||||
<FocusScope autoFocus={false} contain={false} restoreFocus={false}>
|
||||
<InnerAutocomplete
|
||||
isDisabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</FocusScope>
|
||||
</>
|
||||
)}
|
||||
</AriaGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSelectedItems = (
|
||||
value: SelectItemType[] | ListData<SelectItemType>
|
||||
): SelectItemType[] => (Array.isArray(value) ? value : value.items);
|
||||
|
||||
export const AutocompleteBase = ({
|
||||
items,
|
||||
children,
|
||||
label,
|
||||
tooltip,
|
||||
hint,
|
||||
isInvalid,
|
||||
selectedItems,
|
||||
onItemCleared,
|
||||
onItemInserted,
|
||||
placeholder = 'Search',
|
||||
popoverClassName,
|
||||
renderTag,
|
||||
filterOption,
|
||||
onFocus,
|
||||
multiple = true,
|
||||
onSearchChange,
|
||||
maxVisibleItems,
|
||||
allowsCreation = false,
|
||||
hideDropdown = false,
|
||||
name: _name,
|
||||
className: _className,
|
||||
...props
|
||||
}: AutocompleteProps) => {
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
|
||||
const [internalSelected, setInternalSelected] = useState<SelectItemType[]>(
|
||||
resolveSelectedItems(selectedItems)
|
||||
);
|
||||
const selectedKeys = internalSelected.map((item) => item.id);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalSelected(resolveSelectedItems(selectedItems));
|
||||
}, [selectedItems]);
|
||||
|
||||
const [allItems, setAllItems] = useState<SelectItemType[]>(items ?? []);
|
||||
const [filterText, setFilterText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setAllItems(items ?? []);
|
||||
}, [items]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
return allItems.filter((item) => {
|
||||
if (selectedKeys.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
if (filterOption) {
|
||||
return filterOption(item, filterText);
|
||||
}
|
||||
|
||||
return contains(item.label || item.supportingText || '', filterText);
|
||||
});
|
||||
}, [allItems, filterText, selectedKeys, filterOption, contains]);
|
||||
|
||||
const itemMap = useMemo(
|
||||
() => new Map(allItems.map((item) => [item.id, item])),
|
||||
[allItems]
|
||||
);
|
||||
|
||||
// The ListBox renders the caller's static children. Filter them by the
|
||||
// computed `visibleItems` so the default `contains` filter actually narrows
|
||||
// the list as the user types. Async callers neutralize this by passing
|
||||
// `filterOption={() => true}` (visibleItems === allItems), and any non-item
|
||||
// child (create/footer rows, whose id isn't in the collection) is preserved.
|
||||
const visibleIds = useMemo(
|
||||
() => new Set(visibleItems.map((item) => String(item.id))),
|
||||
[visibleItems]
|
||||
);
|
||||
const visibleChildren = useMemo(() => {
|
||||
if (typeof children === 'function') {
|
||||
return children;
|
||||
}
|
||||
|
||||
return Children.toArray(children).filter((child) => {
|
||||
if (!isValidElement(child)) {
|
||||
return true;
|
||||
}
|
||||
const childId = (child.props as { id?: Key }).id;
|
||||
if (childId == null || !itemMap.has(childId as SelectItemType['id'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return visibleIds.has(String(childId));
|
||||
});
|
||||
}, [children, visibleIds, itemMap]);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(keys: Set<Key>) => {
|
||||
const key = keys.values().next().value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
setInternalSelected((prev) => prev.filter((item) => item.id !== key));
|
||||
onItemCleared?.(key);
|
||||
setFilterText('');
|
||||
},
|
||||
[onItemCleared]
|
||||
);
|
||||
|
||||
const onSelectionChange = (id: Key | null) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (!multiple && internalSelected.length >= 1) {
|
||||
return;
|
||||
}
|
||||
const item = itemMap.get(id as string);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (!selectedKeys.includes(id as string)) {
|
||||
setInternalSelected((prev) => [...prev, item]);
|
||||
onItemInserted?.(id);
|
||||
}
|
||||
setFilterText('');
|
||||
};
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setFilterText(value);
|
||||
onSearchChange?.(value);
|
||||
},
|
||||
[onSearchChange]
|
||||
);
|
||||
|
||||
const onCreateItem = useCallback(
|
||||
(value: string) => {
|
||||
const newItem: SelectItemType = { id: value, label: value };
|
||||
setAllItems((prev) =>
|
||||
prev.some((item) => item.id === value) ? prev : [...prev, newItem]
|
||||
);
|
||||
const alreadySelected = internalSelected.some(
|
||||
(item) => item.id === value
|
||||
);
|
||||
if (!multiple) {
|
||||
setInternalSelected([newItem]);
|
||||
if (!alreadySelected) {
|
||||
onItemInserted?.(value);
|
||||
}
|
||||
} else if (!alreadySelected) {
|
||||
setInternalSelected((prev) => [...prev, newItem]);
|
||||
onItemInserted?.(value);
|
||||
}
|
||||
setFilterText('');
|
||||
},
|
||||
[onItemInserted, multiple, internalSelected]
|
||||
);
|
||||
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Match the popover width to the trigger. The base Popover relies on
|
||||
// `--trigger-width`, but react-aria only sets that on a trigger's own context
|
||||
// popover — a standalone `<Popover triggerRef>` (as used here) never receives
|
||||
// it, so the dropdown would otherwise collapse to its content width. Measure
|
||||
// the trigger and set the width explicitly (same approach as MultiSelect).
|
||||
const [popoverWidth, setPopoverWidth] = useState('');
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (triggerRef.current) {
|
||||
setPopoverWidth(triggerRef.current.getBoundingClientRect().width + 'px');
|
||||
}
|
||||
}, [triggerRef]);
|
||||
|
||||
useResizeObserver({ ref: triggerRef, onResize, box: 'border-box' });
|
||||
|
||||
const selectContextValue = useMemo(
|
||||
() => ({ size: 'sm' as const, fontSize: 'sm' as const }),
|
||||
[]
|
||||
);
|
||||
|
||||
const autocompleteContextValue = useMemo(
|
||||
() => ({
|
||||
size: 'sm' as const,
|
||||
selectedKeys,
|
||||
selectedItems: internalSelected,
|
||||
onInputChange,
|
||||
onRemove,
|
||||
onCreateItem,
|
||||
allowsCreation,
|
||||
hideDropdown,
|
||||
renderTag,
|
||||
maxVisibleItems,
|
||||
multiple,
|
||||
visibleItemCount: visibleItems.length,
|
||||
triggerRef,
|
||||
}),
|
||||
[
|
||||
selectedKeys,
|
||||
internalSelected,
|
||||
onInputChange,
|
||||
onRemove,
|
||||
onCreateItem,
|
||||
allowsCreation,
|
||||
hideDropdown,
|
||||
renderTag,
|
||||
maxVisibleItems,
|
||||
multiple,
|
||||
visibleItems.length,
|
||||
triggerRef,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={selectContextValue}>
|
||||
<AutocompleteContext.Provider value={autocompleteContextValue}>
|
||||
<AriaComboBox
|
||||
allowsEmptyCollection
|
||||
inputValue={filterText}
|
||||
items={visibleItems}
|
||||
menuTrigger="focus"
|
||||
selectedKey={null}
|
||||
onInputChange={onInputChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
{...props}>
|
||||
{(state) => (
|
||||
<div className="tw:flex tw:flex-col tw:gap-1.5">
|
||||
{label && (
|
||||
<Label isRequired={state.isRequired} tooltip={tooltip}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="tw:relative tw:w-full" ref={triggerRef}>
|
||||
<AutocompleteTrigger
|
||||
icon={props.icon}
|
||||
isInvalid={isInvalid}
|
||||
placeholder={placeholder}
|
||||
size="sm"
|
||||
onFocus={(event) => {
|
||||
onResize();
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onPointerEnter={onResize}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hideDropdown && (
|
||||
<Popover
|
||||
className={popoverClassName}
|
||||
size="md"
|
||||
style={{ width: popoverWidth }}
|
||||
triggerRef={triggerRef}>
|
||||
<AriaListBox
|
||||
className="tw:size-full tw:outline-hidden"
|
||||
renderEmptyState={() => <SelectEmptyState />}
|
||||
selectionMode="multiple">
|
||||
{visibleChildren}
|
||||
</AriaListBox>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
|
||||
</div>
|
||||
)}
|
||||
</AriaComboBox>
|
||||
</AutocompleteContext.Provider>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Autocomplete = AutocompleteBase as typeof AutocompleteBase & {
|
||||
Item: typeof AutocompleteItem;
|
||||
};
|
||||
|
||||
Autocomplete.Item = AutocompleteItem;
|
||||
|
||||
export { Autocomplete };
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { Avatar, type AvatarProps } from './avatar';
|
||||
|
||||
const styles = {
|
||||
sm: {
|
||||
root: 'tw:gap-2',
|
||||
title: 'tw:text-sm tw:font-semibold',
|
||||
subtitle: 'tw:text-xs',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:gap-2',
|
||||
title: 'tw:text-sm tw:font-semibold',
|
||||
subtitle: 'tw:text-sm',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:gap-3',
|
||||
title: 'tw:text-md tw:font-semibold',
|
||||
subtitle: 'tw:text-md',
|
||||
},
|
||||
xl: {
|
||||
root: 'tw:gap-4',
|
||||
title: 'tw:text-lg tw:font-semibold',
|
||||
subtitle: 'tw:text-md',
|
||||
},
|
||||
};
|
||||
|
||||
interface AvatarLabelGroupProps extends AvatarProps {
|
||||
size: 'sm' | 'md' | 'lg' | 'xl';
|
||||
title: string | ReactNode;
|
||||
subtitle: string | ReactNode;
|
||||
}
|
||||
|
||||
export const AvatarLabelGroup = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
...props
|
||||
}: AvatarLabelGroupProps) => {
|
||||
return (
|
||||
<figure
|
||||
className={cx(
|
||||
'tw:group tw:flex tw:min-w-0 tw:flex-1 tw:items-center',
|
||||
styles[props.size].root,
|
||||
className
|
||||
)}>
|
||||
<Avatar {...props} />
|
||||
<figcaption className="tw:min-w-0 tw:flex-1">
|
||||
<p className={cx('tw:text-primary', styles[props.size].title)}>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
'tw:truncate tw:text-tertiary',
|
||||
styles[props.size].subtitle
|
||||
)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
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: 'tw:size-18 tw:p-0.75',
|
||||
rootWithPlaceholder: 'tw:p-1',
|
||||
content: '',
|
||||
icon: 'tw:size-9',
|
||||
initials: 'tw:text-display-sm tw:font-semibold',
|
||||
badge: 'tw:bottom-0.5 tw:right-0.5',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:size-24 tw:p-1',
|
||||
rootWithPlaceholder: 'tw:p-1.25',
|
||||
content: 'tw:shadow-xl',
|
||||
icon: 'tw:size-12',
|
||||
initials: 'tw:text-display-md tw:font-semibold',
|
||||
badge: 'tw:bottom-1 tw:right-1',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:size-40 tw:p-1.5',
|
||||
rootWithPlaceholder: 'tw:p-1.75',
|
||||
content: 'tw:shadow-2xl',
|
||||
icon: 'tw:size-20',
|
||||
initials: 'tw:text-display-xl tw:font-semibold',
|
||||
badge: 'tw:bottom-2 tw:right-2',
|
||||
},
|
||||
};
|
||||
|
||||
const tickSizeMap = {
|
||||
sm: '2xl',
|
||||
md: '3xl',
|
||||
lg: '4xl',
|
||||
} as const;
|
||||
|
||||
interface AvatarProfilePhotoProps extends AvatarProps {
|
||||
size: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const AvatarProfilePhoto = ({
|
||||
contrastBorder = true,
|
||||
size = 'md',
|
||||
src,
|
||||
alt,
|
||||
initials,
|
||||
placeholder,
|
||||
placeholderIcon: PlaceholderIcon,
|
||||
verified,
|
||||
badge,
|
||||
status,
|
||||
className,
|
||||
}: AvatarProfilePhotoProps) => {
|
||||
const [isFailed, setIsFailed] = useState(false);
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (src && !isFailed) {
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cx(
|
||||
'tw:size-full tw:rounded-full tw:object-cover',
|
||||
contrastBorder &&
|
||||
'tw:outline-1 tw:-outline-offset-1 tw:outline-avatar-contrast-border',
|
||||
styles[size].content
|
||||
)}
|
||||
src={src}
|
||||
onError={() => setIsFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (initials) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:size-full tw:items-center tw:justify-center tw:rounded-full tw:bg-tertiary tw:ring-1 tw:ring-secondary_alt',
|
||||
styles[size].content
|
||||
)}>
|
||||
<span className={cx('tw:text-quaternary', styles[size].initials)}>
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (PlaceholderIcon) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:size-full tw:items-center tw:justify-center tw:rounded-full tw:bg-tertiary tw:ring-1 tw:ring-secondary_alt',
|
||||
styles[size].content
|
||||
)}>
|
||||
<PlaceholderIcon
|
||||
className={cx('tw:text-fg-quaternary', styles[size].icon)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:flex tw:size-full tw:items-center tw:justify-center tw:rounded-full tw:bg-tertiary tw:ring-1 tw:ring-secondary_alt',
|
||||
styles[size].content
|
||||
)}>
|
||||
{placeholder || (
|
||||
<User01 className={cx('tw:text-fg-quaternary', styles[size].icon)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBadgeContent = () => {
|
||||
if (status) {
|
||||
return (
|
||||
<AvatarOnlineIndicator
|
||||
className={styles[size].badge}
|
||||
size={tickSizeMap[size]}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (verified) {
|
||||
return (
|
||||
<VerifiedTick
|
||||
className={cx('tw:absolute', styles[size].badge)}
|
||||
size={tickSizeMap[size]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:shrink-0 tw:items-center tw:justify-center tw:rounded-full tw:bg-primary tw:ring-1 tw:ring-secondary_alt',
|
||||
styles[size].root,
|
||||
(!src || isFailed) && styles[size].rootWithPlaceholder,
|
||||
className
|
||||
)}>
|
||||
{renderMainContent()}
|
||||
{renderBadgeContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
import { User01 } from '@untitledui/icons';
|
||||
import { type CSSProperties, type FC, type ReactNode, useState } from 'react';
|
||||
import { AvatarOnlineIndicator, VerifiedTick } from './base-components';
|
||||
|
||||
type AvatarSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
|
||||
export interface AvatarProps {
|
||||
size?: AvatarSize;
|
||||
className?: string;
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
/**
|
||||
* Display a contrast border around the avatar.
|
||||
*/
|
||||
contrastBorder?: 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
xxs: {
|
||||
root: 'tw:size-4 tw:outline-[0.5px] tw:-outline-offset-[0.5px]',
|
||||
initials: 'tw:text-xs tw:font-semibold',
|
||||
icon: 'tw:size-3',
|
||||
},
|
||||
xs: {
|
||||
root: 'tw:size-6 tw:outline-[0.5px] tw:-outline-offset-[0.5px]',
|
||||
initials: 'tw:text-xs tw:font-semibold',
|
||||
icon: 'tw:size-4',
|
||||
},
|
||||
sm: {
|
||||
root: 'tw:size-8 tw:outline-[0.75px] tw:-outline-offset-[0.75px]',
|
||||
initials: 'tw:text-sm tw:font-semibold',
|
||||
icon: 'tw:size-5',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:size-10 tw:outline-1 tw:-outline-offset-1',
|
||||
initials: 'tw:text-md tw:font-semibold',
|
||||
icon: 'tw:size-6',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:size-12 tw:outline-1 tw:-outline-offset-1',
|
||||
initials: 'tw:text-lg tw:font-semibold',
|
||||
icon: 'tw:size-7',
|
||||
},
|
||||
xl: {
|
||||
root: 'tw:size-14 tw:outline-1 tw:-outline-offset-1',
|
||||
initials: 'tw:text-xl tw:font-semibold',
|
||||
icon: 'tw:size-8',
|
||||
},
|
||||
'2xl': {
|
||||
root: 'tw:size-16 tw:outline-1 tw:-outline-offset-1',
|
||||
initials: 'tw:text-display-xs tw:font-semibold',
|
||||
icon: 'tw:size-8',
|
||||
},
|
||||
};
|
||||
|
||||
export const Avatar = ({
|
||||
contrastBorder = true,
|
||||
size = 'md',
|
||||
src,
|
||||
alt,
|
||||
initials,
|
||||
placeholder,
|
||||
placeholderIcon: PlaceholderIcon,
|
||||
badge,
|
||||
status,
|
||||
verified,
|
||||
focusable = false,
|
||||
className,
|
||||
style,
|
||||
}: AvatarProps) => {
|
||||
const [isFailed, setIsFailed] = useState(false);
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (src && !isFailed) {
|
||||
return (
|
||||
<img
|
||||
data-avatar-img
|
||||
alt={alt}
|
||||
className="tw:size-full tw:rounded-full tw:object-cover"
|
||||
src={src}
|
||||
onError={() => setIsFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (initials) {
|
||||
return (
|
||||
<span className={cx('tw:text-quaternary', styles[size].initials)}>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (PlaceholderIcon) {
|
||||
return (
|
||||
<PlaceholderIcon className={cx('tw:text-current', styles[size].icon)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
placeholder || (
|
||||
<User01 className={cx('tw:text-fg-quaternary', styles[size].icon)} />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderBadgeContent = () => {
|
||||
if (status) {
|
||||
return (
|
||||
<AvatarOnlineIndicator
|
||||
size={size === 'xxs' ? 'xs' : size}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (verified) {
|
||||
return (
|
||||
<VerifiedTick
|
||||
className={cx(
|
||||
'tw:absolute tw:right-0 tw:bottom-0',
|
||||
(size === 'xxs' || size === 'xs') && 'tw:-right-px tw:-bottom-px'
|
||||
)}
|
||||
size={size === 'xxs' ? 'xs' : size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-avatar
|
||||
className={cx(
|
||||
'tw:relative tw:inline-flex tw:shrink-0 tw:items-center tw:justify-center tw:rounded-full tw:bg-tertiary tw:outline-transparent',
|
||||
// Focus styles
|
||||
focusable &&
|
||||
'tw:group-outline-focus-ring tw:group-focus-visible:outline-2 tw:group-focus-visible:outline-offset-2',
|
||||
contrastBorder && 'tw:outline tw:outline-avatar-contrast-border',
|
||||
styles[size].root,
|
||||
className
|
||||
)}
|
||||
style={style}>
|
||||
{renderMainContent()}
|
||||
{renderBadgeContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
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(
|
||||
'tw:flex tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-dashed tw:border-primary tw:bg-primary tw:text-fg-quaternary tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:hover:bg-primary_hover tw:hover:text-fg-quaternary_hover tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw:disabled:border-gray-200 tw:disabled:bg-secondary tw:disabled:text-gray-200',
|
||||
sizes[size].root,
|
||||
className
|
||||
)}>
|
||||
<Plus
|
||||
className={cx(
|
||||
'tw:text-current tw:transition-inherit-all',
|
||||
sizes[size].icon
|
||||
)}
|
||||
/>
|
||||
</AriaTooltipTrigger>
|
||||
</AriaTooltip>
|
||||
);
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
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
|
||||
alt={alt}
|
||||
className={cx(
|
||||
'tw:bg-primary-25 tw:absolute tw:-right-0.5 tw:-bottom-0.5 tw:rounded-full tw:object-cover tw:ring-[1.5px] tw:ring-bg-primary',
|
||||
sizes[size]
|
||||
)}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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(
|
||||
'tw:absolute tw:right-0 tw:bottom-0 tw:rounded-full tw:ring-[1.5px] tw:ring-bg-primary',
|
||||
status === 'online'
|
||||
? 'tw:bg-fg-success-secondary'
|
||||
: 'tw:bg-fg-disabled_subtle',
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export * from './avatar-add-button';
|
||||
export * from './avatar-company-icon';
|
||||
export * from './avatar-online-indicator';
|
||||
export * from './verified-tick';
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const sizes = {
|
||||
xs: { root: 'tw:size-2.5', tick: 'tw:size-[4.38px]' },
|
||||
sm: { root: 'tw:size-3', tick: 'tw:size-[5.25px]' },
|
||||
md: { root: 'tw:size-3.5', tick: 'tw:size-[6.13px]' },
|
||||
lg: { root: 'tw:size-4', tick: 'tw:size-[7px]' },
|
||||
xl: { root: 'tw:size-4.5', tick: 'tw:size-[7.88px]' },
|
||||
'2xl': { root: 'tw:size-5', tick: 'tw:size-[8.75px]' },
|
||||
'3xl': { root: 'tw:size-6', tick: 'tw:size-[10.5px]' },
|
||||
'4xl': { root: 'tw:size-8', tick: 'tw:size-[14px]' },
|
||||
};
|
||||
|
||||
interface VerifiedTickProps {
|
||||
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VerifiedTick = ({ size, className }: VerifiedTickProps) => (
|
||||
<svg
|
||||
data-verified
|
||||
className={cx(
|
||||
'tw:z-10 tw:text-utility-blue-500',
|
||||
sizes[size].root,
|
||||
className
|
||||
)}
|
||||
fill="none"
|
||||
viewBox="0 0 10 10">
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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) : '');
|
||||
};
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
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: 'tw:rounded-full tw:ring-1 tw:ring-inset',
|
||||
addon: 'tw:rounded-full tw:ring-1 tw:ring-inset',
|
||||
},
|
||||
modern: {
|
||||
root: 'tw:rounded-[10px] tw:bg-primary tw:text-secondary tw:shadow-xs tw:ring-1 tw:ring-inset tw:ring-primary tw:hover:bg-secondary',
|
||||
addon:
|
||||
'tw:flex tw:items-center tw:rounded-md tw:bg-primary tw:shadow-xs tw:ring-1 tw:ring-inset tw:ring-primary',
|
||||
icon: 'tw:text-utility-gray-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(
|
||||
'tw:py-1 tw:pr-2 tw:pl-1 tw:text-xs tw:font-medium',
|
||||
!text && !icon && 'tw:pr-1'
|
||||
),
|
||||
addon: cx(
|
||||
'tw:px-2 tw:py-0.5',
|
||||
theme === 'modern' && 'tw:gap-1 tw:px-1.5',
|
||||
text && 'tw:mr-2'
|
||||
),
|
||||
icon: 'tw:ml-1 tw:size-4',
|
||||
},
|
||||
lg: {
|
||||
root: cx(
|
||||
'tw:py-1 tw:pr-2 tw:pl-1 tw:text-sm tw:font-medium',
|
||||
!text && !icon && 'tw:pr-1'
|
||||
),
|
||||
addon: cx(
|
||||
'tw:px-2.5 tw:py-0.5',
|
||||
theme === 'modern' && 'tw:gap-1.5 tw:px-2',
|
||||
text && 'tw:mr-2'
|
||||
),
|
||||
icon: 'tw:ml-1 tw:size-4',
|
||||
},
|
||||
},
|
||||
trailing: {
|
||||
md: {
|
||||
root: cx(
|
||||
'tw:py-1 tw:pr-1 tw:pl-3 tw:text-xs tw:font-medium',
|
||||
theme === 'modern' && 'tw:pl-2.5'
|
||||
),
|
||||
addon: cx(
|
||||
'tw:py-0.5 tw:pr-1.5 tw:pl-2',
|
||||
theme === 'modern' && 'tw:pr-1.5 tw:pl-2',
|
||||
text && 'tw:ml-2'
|
||||
),
|
||||
icon: 'tw:ml-0.5 tw:size-3 tw:stroke-[3px]',
|
||||
dot: 'tw:mr-1.5',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:py-1 tw:pr-1 tw:pl-3 tw:text-sm tw:font-medium',
|
||||
addon: cx(
|
||||
'tw:py-0.5 tw:pr-2 tw:pl-2.5',
|
||||
theme === 'modern' && 'tw:pr-1.5 tw:pl-2',
|
||||
text && 'tw:ml-2'
|
||||
),
|
||||
icon: 'tw:ml-1 tw:size-3 tw:stroke-[3px]',
|
||||
dot: 'tw:mr-2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const colorClasses: Record<
|
||||
Theme,
|
||||
Record<Color, { root?: string; addon?: string; icon?: string; dot?: string }>
|
||||
> = sortCx({
|
||||
light: {
|
||||
brand: {
|
||||
root: 'tw:bg-utility-brand-50 tw:text-utility-brand-700 tw:ring-utility-brand-200 tw:hover:bg-utility-brand-100',
|
||||
addon: 'tw:bg-primary tw:text-current tw:ring-utility-brand-200',
|
||||
icon: 'tw:text-utility-brand-500',
|
||||
},
|
||||
gray: {
|
||||
root: 'tw:bg-utility-gray-50 tw:text-utility-gray-700 tw:ring-utility-gray-200 tw:hover:bg-utility-gray-100',
|
||||
addon: 'tw:bg-primary tw:text-current tw:ring-utility-gray-200',
|
||||
icon: 'tw:text-utility-gray-500',
|
||||
},
|
||||
error: {
|
||||
root: 'tw:bg-utility-error-50 tw:text-utility-error-700 tw:ring-utility-error-200 tw:hover:bg-utility-error-100',
|
||||
addon: 'tw:bg-primary tw:text-current tw:ring-utility-error-200',
|
||||
icon: 'tw:text-utility-error-500',
|
||||
},
|
||||
warning: {
|
||||
root: 'tw:bg-utility-warning-50 tw:text-utility-warning-700 tw:ring-utility-warning-200 tw:hover:bg-utility-warning-100',
|
||||
addon: 'tw:bg-primary tw:text-current tw:ring-utility-warning-200',
|
||||
icon: 'tw:text-utility-warning-500',
|
||||
},
|
||||
success: {
|
||||
root: 'tw:bg-utility-success-50 tw:text-utility-success-700 tw:ring-utility-success-200 tw:hover:bg-utility-success-100',
|
||||
addon: 'tw:bg-primary tw:text-current tw:ring-utility-success-200',
|
||||
icon: 'tw:text-utility-success-500',
|
||||
},
|
||||
},
|
||||
modern: {
|
||||
brand: {
|
||||
dot: 'tw:bg-utility-brand-500 tw:outline-3 tw:-outline-offset-1 tw:outline-utility-brand-100',
|
||||
},
|
||||
gray: {
|
||||
dot: 'tw:bg-utility-gray-500 tw:outline-3 tw:-outline-offset-1 tw:outline-utility-gray-100',
|
||||
},
|
||||
error: {
|
||||
dot: 'tw:bg-utility-error-500 tw:outline-3 tw:-outline-offset-1 tw:outline-utility-error-100',
|
||||
},
|
||||
warning: {
|
||||
dot: 'tw:bg-utility-warning-500 tw:outline-3 tw:-outline-offset-1 tw:outline-utility-warning-100',
|
||||
},
|
||||
success: {
|
||||
dot: 'tw:bg-utility-success-500 tw:outline-3 tw:-outline-offset-1 tw:outline-utility-success-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(
|
||||
'tw:inline-flex tw:w-max tw:cursor-pointer tw:items-center tw:transition tw:duration-100 tw:ease-linear',
|
||||
baseClasses[theme].root,
|
||||
sizes.root,
|
||||
colors.root,
|
||||
className
|
||||
);
|
||||
const addonClasses = cx(
|
||||
'tw:inline-flex tw:items-center',
|
||||
baseClasses[theme].addon,
|
||||
sizes.addon,
|
||||
colors.addon
|
||||
);
|
||||
const dotClasses = cx(
|
||||
'tw:inline-block tw:size-2 tw:shrink-0 tw: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>
|
||||
);
|
||||
};
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
export type IconComponentType = React.FunctionComponent<{
|
||||
className?: string;
|
||||
strokeWidth?: string | number;
|
||||
}>;
|
||||
|
||||
export type Sizes = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export type BadgeColors =
|
||||
| 'gray'
|
||||
| 'brand'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'gray-blue'
|
||||
| 'blue-light'
|
||||
| 'blue'
|
||||
| 'indigo'
|
||||
| 'purple'
|
||||
| 'pink'
|
||||
| 'orange'
|
||||
| 'blue-dark';
|
||||
|
||||
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];
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
import { Dot } from '@/components/foundations/dot-icon';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { X as CloseX } from '@untitledui/icons';
|
||||
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
|
||||
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: 'tw:bg-utility-gray-50 tw:text-utility-gray-700 tw:ring-utility-gray-200',
|
||||
addon: 'tw:text-utility-gray-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-gray-100 tw:text-utility-gray-400 tw:hover:text-utility-gray-500',
|
||||
},
|
||||
brand: {
|
||||
root: 'tw:bg-utility-brand-50 tw:text-utility-brand-700 tw:ring-utility-brand-200',
|
||||
addon: 'tw:text-utility-brand-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-brand-100 tw:text-utility-brand-400 tw:hover:text-utility-brand-500',
|
||||
},
|
||||
error: {
|
||||
root: 'tw:bg-utility-error-50 tw:text-utility-error-700 tw:ring-utility-error-200',
|
||||
addon: 'tw:text-utility-error-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-error-100 tw:text-utility-error-400 tw:hover:text-utility-error-500',
|
||||
},
|
||||
warning: {
|
||||
root: 'tw:bg-utility-warning-50 tw:text-utility-warning-700 tw:ring-utility-warning-200',
|
||||
addon: 'tw:text-utility-warning-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-warning-100 tw:text-utility-warning-400 tw:hover:text-utility-warning-500',
|
||||
},
|
||||
success: {
|
||||
root: 'tw:bg-utility-success-50 tw:text-utility-success-700 tw:ring-utility-success-200',
|
||||
addon: 'tw:text-utility-success-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-success-100 tw:text-utility-success-400 tw:hover:text-utility-success-500',
|
||||
},
|
||||
'gray-blue': {
|
||||
root: 'tw:bg-utility-gray-blue-50 tw:text-utility-gray-blue-700 tw:ring-utility-gray-blue-200',
|
||||
addon: 'tw:text-utility-gray-blue-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-gray-blue-100 tw:text-utility-gray-blue-400 tw:hover:text-utility-gray-blue-500',
|
||||
},
|
||||
'blue-light': {
|
||||
root: 'tw:bg-utility-blue-light-50 tw:text-utility-blue-light-700 tw:ring-utility-blue-light-200',
|
||||
addon: 'tw:text-utility-blue-light-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-blue-light-100 tw:text-utility-blue-light-400 tw:hover:text-utility-blue-light-500',
|
||||
},
|
||||
blue: {
|
||||
root: 'tw:bg-utility-blue-50 tw:text-utility-blue-700 tw:ring-utility-blue-200',
|
||||
addon: 'tw:text-utility-blue-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-blue-100 tw:text-utility-blue-400 tw:hover:text-utility-blue-500',
|
||||
},
|
||||
indigo: {
|
||||
root: 'tw:bg-utility-indigo-50 tw:text-utility-indigo-700 tw:ring-utility-indigo-200',
|
||||
addon: 'tw:text-utility-indigo-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-indigo-100 tw:text-utility-indigo-400 tw:hover:text-utility-indigo-500',
|
||||
},
|
||||
purple: {
|
||||
root: 'tw:bg-utility-purple-50 tw:text-utility-purple-700 tw:ring-utility-purple-200',
|
||||
addon: 'tw:text-utility-purple-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-purple-100 tw:text-utility-purple-400 tw:hover:text-utility-purple-500',
|
||||
},
|
||||
pink: {
|
||||
root: 'tw:bg-utility-pink-50 tw:text-utility-pink-700 tw:ring-utility-pink-200',
|
||||
addon: 'tw:text-utility-pink-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-pink-100 tw:text-utility-pink-400 tw:hover:text-utility-pink-500',
|
||||
},
|
||||
orange: {
|
||||
root: 'tw:bg-utility-orange-50 tw:text-utility-orange-700 tw:ring-utility-orange-200',
|
||||
addon: 'tw:text-utility-orange-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-orange-100 tw:text-utility-orange-400 tw:hover:text-utility-orange-500',
|
||||
},
|
||||
'blue-dark': {
|
||||
root: 'tw:bg-utility-blue-dark-50 tw:text-utility-gray-700 tw:ring-utility-gray-blue-200',
|
||||
addon: 'tw:text-utility-gray-700',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-blue-dark-100 tw:text-utility-gray-700 tw:hover:text-utility-blue-dark-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:
|
||||
'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-full',
|
||||
styles: filledColors,
|
||||
},
|
||||
[badgeTypes.badgeColor]: {
|
||||
common:
|
||||
'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md',
|
||||
styles: filledColors,
|
||||
},
|
||||
[badgeTypes.badgeModern]: {
|
||||
common:
|
||||
'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md tw:shadow-xs',
|
||||
styles: {
|
||||
gray: {
|
||||
root: 'tw:bg-primary tw:text-secondary tw:ring-primary',
|
||||
addon: 'tw:text-gray-500',
|
||||
addonButton:
|
||||
'tw:hover:bg-utility-gray-100 tw:text-utility-gray-400 tw:hover:text-utility-gray-500',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const withBadgeTypes = {
|
||||
[badgeTypes.pillColor]: {
|
||||
common:
|
||||
'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-full',
|
||||
styles: filledColors,
|
||||
},
|
||||
[badgeTypes.badgeColor]: {
|
||||
common:
|
||||
'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md',
|
||||
styles: filledColors,
|
||||
},
|
||||
[badgeTypes.badgeModern]: {
|
||||
common:
|
||||
'tw:size-max tw:flex tw:items-center tw:whitespace-nowrap tw:rounded-md tw:bg-primary tw:text-secondary tw:ring-primary tw: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;
|
||||
bordered?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export const Badge = <T extends BadgeTypes>(props: BadgeProps<T>) => {
|
||||
const {
|
||||
type = 'pill-color',
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
bordered = true,
|
||||
children,
|
||||
} = props;
|
||||
const colors = withPillTypes[type];
|
||||
|
||||
const pillSizes = {
|
||||
xs: 'tw:py-0.5 tw:px-1.5 tw:text-[10px] tw:leading-4',
|
||||
sm: 'tw:py-0.5 tw:px-2 tw:text-xs',
|
||||
md: 'tw:py-0.5 tw:px-2.5 tw:text-sm',
|
||||
lg: 'tw:py-1 tw:px-3 tw:text-sm',
|
||||
};
|
||||
const badgeSizes = {
|
||||
xs: 'tw:py-0.5 tw:px-1 tw:text-[10px] tw:leading-4',
|
||||
sm: 'tw:py-0.5 tw:px-1.5 tw:text-xs',
|
||||
md: 'tw:py-0.5 tw:px-2 tw:text-sm',
|
||||
lg: 'tw:py-1 tw:px-2.5 tw:text-sm tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset',
|
||||
props.className
|
||||
)}
|
||||
data-testid={props['data-testid']}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface BadgeWithDotProps<T extends BadgeTypes> {
|
||||
type?: T;
|
||||
size?: Sizes;
|
||||
color?: BadgeTypeToColorMap<typeof withBadgeTypes>[T];
|
||||
bordered?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BadgeWithDot = <T extends BadgeTypes>(
|
||||
props: BadgeWithDotProps<T>
|
||||
) => {
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
type = 'pill-color',
|
||||
bordered = true,
|
||||
className,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const colors = withBadgeTypes[type];
|
||||
|
||||
const pillSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-1 tw:pr-1.5 tw:text-[10px] tw:leading-4',
|
||||
sm: 'tw:gap-1 tw:py-0.5 tw:pl-1.5 tw:pr-2 tw:text-xs',
|
||||
md: 'tw:gap-1.5 tw:py-0.5 tw:pl-2 tw:pr-2.5 tw:text-sm',
|
||||
lg: 'tw:gap-1.5 tw:py-1 tw:pl-2.5 tw:pr-3 tw:text-sm',
|
||||
};
|
||||
|
||||
const badgeSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:px-1 tw:text-[10px] tw:leading-4',
|
||||
sm: 'tw:gap-1 tw:py-0.5 tw:px-1.5 tw:text-xs',
|
||||
md: 'tw:gap-1.5 tw:py-0.5 tw:px-2 tw:text-sm',
|
||||
lg: 'tw:gap-1.5 tw:py-1 tw:px-2.5 tw:text-sm tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset',
|
||||
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;
|
||||
bordered?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BadgeWithIcon = <T extends BadgeTypes>(
|
||||
props: BadgeWithIconProps<T>
|
||||
) => {
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
type = 'pill-color',
|
||||
bordered = true,
|
||||
iconLeading: IconLeading,
|
||||
iconTrailing: IconTrailing,
|
||||
children,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const colors = withBadgeTypes[type];
|
||||
|
||||
const icon = IconLeading ? 'leading' : 'trailing';
|
||||
|
||||
const pillSizes = {
|
||||
xs: {
|
||||
trailing:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pl-1.5 tw:pr-1 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
leading:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pr-1.5 tw:pl-1 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
},
|
||||
sm: {
|
||||
trailing:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pl-2 tw:pr-1.5 tw:text-xs tw:font-medium',
|
||||
leading:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pr-2 tw:pl-1.5 tw:text-xs tw:font-medium',
|
||||
},
|
||||
md: {
|
||||
trailing:
|
||||
'tw:gap-1 tw:py-0.5 tw:pl-2.5 tw:pr-2 tw:text-sm tw:font-medium',
|
||||
leading: 'tw:gap-1 tw:py-0.5 tw:pr-2.5 tw:pl-2 tw:text-sm tw:font-medium',
|
||||
},
|
||||
lg: {
|
||||
trailing: 'tw:gap-1 tw:py-1 tw:pl-3 tw:pr-2.5 tw:text-sm tw:font-medium',
|
||||
leading: 'tw:gap-1 tw:py-1 tw:pr-3 tw:pl-2.5 tw:text-sm tw:font-medium',
|
||||
},
|
||||
};
|
||||
const badgeSizes = {
|
||||
xs: {
|
||||
trailing:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pl-1.5 tw:pr-1 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
leading:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pr-1.5 tw:pl-1 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
},
|
||||
sm: {
|
||||
trailing:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pl-2 tw:pr-1.5 tw:text-xs tw:font-medium',
|
||||
leading:
|
||||
'tw:gap-0.5 tw:py-0.5 tw:pr-2 tw:pl-1.5 tw:text-xs tw:font-medium',
|
||||
},
|
||||
md: {
|
||||
trailing:
|
||||
'tw:gap-1 tw:py-0.5 tw:pl-2 tw:pr-1.5 tw:text-sm tw:font-medium',
|
||||
leading: 'tw:gap-1 tw:py-0.5 tw:pr-2 tw:pl-1.5 tw:text-sm tw:font-medium',
|
||||
},
|
||||
lg: {
|
||||
trailing:
|
||||
'tw:gap-1 tw:py-1 tw:pl-2.5 tw:pr-2 tw:text-sm tw:font-medium tw:rounded-lg',
|
||||
leading:
|
||||
'tw:gap-1 tw:py-1 tw:pr-2.5 tw:pl-2 tw:text-sm tw:font-medium tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset',
|
||||
className
|
||||
)}>
|
||||
{IconLeading && (
|
||||
<IconLeading
|
||||
className={cx(colors.styles[color].addon, 'tw:size-3 tw:stroke-3')}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{IconTrailing && (
|
||||
<IconTrailing
|
||||
className={cx(colors.styles[color].addon, 'tw:size-3 tw:stroke-3')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface BadgeWithFlagProps<T extends BadgeTypes> {
|
||||
type?: T;
|
||||
size?: Sizes;
|
||||
flag?: FlagTypes;
|
||||
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||
bordered?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BadgeWithFlag = <T extends BadgeTypes>(
|
||||
props: BadgeWithFlagProps<T>
|
||||
) => {
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
flag = 'AU',
|
||||
type = 'pill-color',
|
||||
bordered = true,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const colors = withPillTypes[type];
|
||||
|
||||
const pillSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-0.5 tw:pr-1.5 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
sm: 'tw:gap-1 tw:py-0.5 tw:pl-0.75 tw:pr-2 tw:text-xs tw:font-medium',
|
||||
md: 'tw:gap-1.5 tw:py-0.5 tw:pl-1 tw:pr-2.5 tw:text-sm tw:font-medium',
|
||||
lg: 'tw:gap-1.5 tw:py-1 tw:pl-1.5 tw:pr-3 tw:text-sm tw:font-medium',
|
||||
};
|
||||
const badgeSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-0.5 tw:pr-1 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
sm: 'tw:gap-1 tw:py-0.5 tw:pl-1 tw:pr-1.5 tw:text-xs tw:font-medium',
|
||||
md: 'tw:gap-1.5 tw:py-0.5 tw:pl-1.5 tw:pr-2 tw:text-sm tw:font-medium',
|
||||
lg: 'tw:gap-1.5 tw:py-1 tw:pl-2 tw:pr-2.5 tw:text-sm tw:font-medium tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset'
|
||||
)}>
|
||||
<img
|
||||
alt={`${flag} flag`}
|
||||
className="tw:size-4 tw:max-w-none tw:rounded-full"
|
||||
src={`https://www.untitledui.com/images/flags/${flag}.svg`}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface BadgeWithImageProps<T extends BadgeTypes> {
|
||||
type?: T;
|
||||
size?: Sizes;
|
||||
imgSrc: string;
|
||||
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||
bordered?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BadgeWithImage = <T extends BadgeTypes>(
|
||||
props: BadgeWithImageProps<T>
|
||||
) => {
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
type = 'pill-color',
|
||||
bordered = true,
|
||||
imgSrc,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const colors = withPillTypes[type];
|
||||
|
||||
const pillSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-0.5 tw:pr-1.5 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
sm: 'tw:gap-1 tw:py-0.5 tw:pl-0.75 tw:pr-2 tw:text-xs tw:font-medium',
|
||||
md: 'tw:gap-1.5 tw:py-0.5 tw:pl-1 tw:pr-2.5 tw:text-sm tw:font-medium',
|
||||
lg: 'tw:gap-1.5 tw:py-1 tw:pl-1.5 tw:pr-3 tw:text-sm tw:font-medium',
|
||||
};
|
||||
const badgeSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-0.5 tw:pr-1 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
sm: 'tw:gap-1 tw:py-0.5 tw:pl-1 tw:pr-1.5 tw:text-xs tw:font-medium',
|
||||
md: 'tw:gap-1.5 tw:py-0.5 tw:pl-1.5 tw:pr-2 tw:text-sm tw:font-medium',
|
||||
lg: 'tw:gap-1.5 tw:py-1 tw:pl-2 tw:pr-2.5 tw:text-sm tw:font-medium tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset'
|
||||
)}>
|
||||
<img
|
||||
alt="Badge"
|
||||
className="tw:size-4 tw:max-w-none tw:rounded-full"
|
||||
src={imgSrc}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface BadgeWithButtonProps<T extends BadgeTypes> {
|
||||
type?: T;
|
||||
size?: Sizes;
|
||||
icon?: IconComponentType;
|
||||
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||
bordered?: boolean;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* The label for the button.
|
||||
*/
|
||||
buttonLabel?: string;
|
||||
/**
|
||||
* The click event handler for the button.
|
||||
*/
|
||||
onButtonClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* The keydown event handler for the button.
|
||||
*/
|
||||
onButtonKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Whether the button is disabled.
|
||||
*/
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const BadgeWithButton = <T extends BadgeTypes>(
|
||||
props: BadgeWithButtonProps<T>
|
||||
) => {
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
type = 'pill-color',
|
||||
bordered = true,
|
||||
icon: Icon = CloseX,
|
||||
buttonLabel,
|
||||
children,
|
||||
isDisabled,
|
||||
onButtonKeyDown,
|
||||
} = props;
|
||||
|
||||
const colors = withPillTypes[type];
|
||||
|
||||
const pillSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-1.5 tw:pr-0.5 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
sm: 'tw:gap-0.5 tw:py-0.5 tw:pl-2 tw:pr-0.75 tw:text-xs tw:font-medium',
|
||||
md: 'tw:gap-0.5 tw:py-0.5 tw:pl-2.5 tw:pr-1 tw:text-sm tw:font-medium',
|
||||
lg: 'tw:gap-0.5 tw:py-1 tw:pl-3 tw:pr-1.5 tw:text-sm tw:font-medium',
|
||||
};
|
||||
const badgeSizes = {
|
||||
xs: 'tw:gap-0.5 tw:py-0.5 tw:pl-1 tw:pr-0.5 tw:text-[10px] tw:leading-4 tw:font-medium',
|
||||
sm: 'tw:gap-0.5 tw:py-0.5 tw:pl-1.5 tw:pr-0.75 tw:text-xs tw:font-medium',
|
||||
md: 'tw:gap-0.5 tw:py-0.5 tw:pl-2 tw:pr-1 tw:text-sm tw:font-medium',
|
||||
lg: 'tw:gap-0.5 tw:py-1 tw:pl-2.5 tw:pr-1.5 tw:text-sm tw:font-medium tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset'
|
||||
)}>
|
||||
{children}
|
||||
<button
|
||||
aria-label={buttonLabel}
|
||||
className={cx(
|
||||
'tw:flex tw:items-center tw:justify-center tw:p-0.5 tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:focus-visible:outline-2',
|
||||
isDisabled
|
||||
? 'tw:cursor-not-allowed tw:opacity-50'
|
||||
: 'tw:cursor-pointer',
|
||||
colors.styles[color].addonButton,
|
||||
type === 'pill-color' ? 'tw:rounded-full' : 'tw:rounded-[3px]'
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
onClick={props.onButtonClick}
|
||||
onKeyDown={onButtonKeyDown}>
|
||||
<Icon className="tw:size-3 tw:stroke-[3px] tw:transition-inherit-all" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface BadgeIconProps<T extends BadgeTypes> {
|
||||
type?: T;
|
||||
size?: Sizes;
|
||||
icon: IconComponentType;
|
||||
color?: BadgeTypeToColorMap<typeof withPillTypes>[T];
|
||||
bordered?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const BadgeIcon = <T extends BadgeTypes>(props: BadgeIconProps<T>) => {
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'gray',
|
||||
type = 'pill-color',
|
||||
bordered = true,
|
||||
icon: Icon,
|
||||
} = props;
|
||||
|
||||
const colors = withPillTypes[type];
|
||||
|
||||
const pillSizes = {
|
||||
xs: 'tw:p-1',
|
||||
sm: 'tw:p-1.25',
|
||||
md: 'tw:p-1.5',
|
||||
lg: 'tw:p-2',
|
||||
};
|
||||
|
||||
const badgeSizes = {
|
||||
xs: 'tw:p-1',
|
||||
sm: 'tw:p-1.25',
|
||||
md: 'tw:p-1.5',
|
||||
lg: 'tw:p-2 tw: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,
|
||||
bordered && 'tw:ring-1 tw:ring-inset'
|
||||
)}>
|
||||
<Icon
|
||||
className={cx('tw:size-3 tw:stroke-[3px]', colors.styles[color].addon)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
colGapClassMapping,
|
||||
gapClassMapping,
|
||||
rowGapClassMapping,
|
||||
} from '@/constants/tailwindClasses.constants';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { forwardRef } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type Direction = 'row' | 'col' | 'row-reverse' | 'col-reverse';
|
||||
type Align = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
||||
type Justify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
|
||||
type Wrap = 'wrap' | 'nowrap' | 'wrap-reverse';
|
||||
|
||||
export type GapValues =
|
||||
| 0
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 14
|
||||
| 16
|
||||
| 20
|
||||
| 24
|
||||
| 28
|
||||
| 32
|
||||
| 36
|
||||
| 40
|
||||
| 44
|
||||
| 48
|
||||
| 52
|
||||
| 56
|
||||
| 60
|
||||
| 64
|
||||
| 72
|
||||
| 80
|
||||
| 96;
|
||||
|
||||
const DIRECTION_CLASS: Record<Direction, string> = {
|
||||
row: 'tw:flex-row',
|
||||
col: 'tw:flex-col',
|
||||
'row-reverse': 'tw:flex-row-reverse',
|
||||
'col-reverse': 'tw:flex-col-reverse',
|
||||
};
|
||||
|
||||
const ALIGN_CLASS: Record<Align, string> = {
|
||||
start: 'tw:items-start',
|
||||
center: 'tw:items-center',
|
||||
end: 'tw:items-end',
|
||||
stretch: 'tw:items-stretch',
|
||||
baseline: 'tw:items-baseline',
|
||||
};
|
||||
|
||||
const JUSTIFY_CLASS: Record<Justify, string> = {
|
||||
start: 'tw:justify-start',
|
||||
center: 'tw:justify-center',
|
||||
end: 'tw:justify-end',
|
||||
between: 'tw:justify-between',
|
||||
around: 'tw:justify-around',
|
||||
evenly: 'tw:justify-evenly',
|
||||
};
|
||||
|
||||
const WRAP_CLASS: Record<Wrap, string> = {
|
||||
wrap: 'tw:flex-wrap',
|
||||
nowrap: 'tw:flex-nowrap',
|
||||
'wrap-reverse': 'tw:flex-wrap-reverse',
|
||||
};
|
||||
|
||||
export interface BoxProps extends HTMLAttributes<HTMLDivElement> {
|
||||
direction?: Direction;
|
||||
align?: Align;
|
||||
justify?: Justify;
|
||||
wrap?: Wrap;
|
||||
gap?: GapValues;
|
||||
rowGap?: GapValues;
|
||||
colGap?: GapValues;
|
||||
inline?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Box = forwardRef<HTMLDivElement, BoxProps>(function Box(
|
||||
{
|
||||
direction,
|
||||
align,
|
||||
justify,
|
||||
wrap,
|
||||
gap,
|
||||
rowGap,
|
||||
colGap,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const gapClassName = gap === undefined ? undefined : gapClassMapping[gap];
|
||||
const rowGapClassName =
|
||||
rowGap === undefined ? undefined : rowGapClassMapping[rowGap];
|
||||
const colGapClassName =
|
||||
colGap === undefined ? undefined : colGapClassMapping[colGap];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cx(
|
||||
inline ? 'tw:inline-flex' : 'tw:flex',
|
||||
direction ? DIRECTION_CLASS[direction] : undefined,
|
||||
align ? ALIGN_CLASS[align] : undefined,
|
||||
justify ? JUSTIFY_CLASS[justify] : undefined,
|
||||
wrap ? WRAP_CLASS[wrap] : undefined,
|
||||
gapClassName,
|
||||
rowGapClassName,
|
||||
colGapClassName,
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Box.displayName = 'Box';
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { cx, sortCx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
import {
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
type RefAttributes,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
ToggleButton as AriaToggleButton,
|
||||
ToggleButtonGroup as AriaToggleButtonGroup,
|
||||
type ToggleButtonGroupProps,
|
||||
type ToggleButtonProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: [
|
||||
'tw:group/button-group tw:inline-flex tw:h-max tw:cursor-pointer tw:items-center tw:bg-primary tw:font-semibold tw:whitespace-nowrap tw:text-secondary tw:shadow-skeuomorphic tw:ring-1 tw:ring-primary tw:outline-brand tw:transition tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
// Hover and focus styles
|
||||
'tw:hover:bg-primary_hover tw:hover:text-secondary_hover tw:focus-visible:z-10 tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
// Disabled styles
|
||||
'tw:disabled:cursor-not-allowed tw:disabled:bg-primary tw:disabled:text-disabled',
|
||||
// Selected styles
|
||||
'tw:selected:bg-active tw:selected:text-secondary_hover tw:selected:disabled:bg-disabled_subtle',
|
||||
].join(' '),
|
||||
icon: 'tw:pointer-events-none tw:text-fg-quaternary tw:transition-[inherit] group-hover/button-group:tw:text-fg-quaternary_hover group-disabled/button-group:tw:text-fg-disabled_subtle',
|
||||
},
|
||||
|
||||
sizes: {
|
||||
sm: {
|
||||
root: 'tw:gap-1.5 tw:px-3.5 tw:py-2 tw:text-sm tw:not-last:pr-[calc(calc(theme(--spacing)*3.5)+1px)] tw:first:rounded-l-lg tw:last:rounded-r-lg tw:data-icon-leading:pl-3 tw:data-icon-only:p-2',
|
||||
icon: 'tw:size-4',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:gap-1.5 tw:px-4 tw:py-2.5 tw:text-sm tw:not-last:pr-[calc(calc(theme(--spacing)*4)+1px)] tw:first:rounded-l-lg tw:last:rounded-r-lg tw:data-icon-leading:pl-3.5 tw:data-icon-only:px-3',
|
||||
icon: 'tw:size-5',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:gap-2 tw:px-4.5 tw:py-2.5 tw:text-md tw:not-last:pr-[calc(calc(theme(--spacing)*4.5)+1px)] tw:first:rounded-l-lg tw:last:rounded-r-lg tw:data-icon-leading:pl-4 tw:data-icon-only:p-3',
|
||||
icon: 'tw: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}
|
||||
className={cx(styles.common.root, styles.sizes[size].root, className)}
|
||||
data-icon-leading={IconLeading ? true : undefined}
|
||||
data-icon-only={isIcon ? true : undefined}>
|
||||
{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) => {
|
||||
const contextValue = useMemo(() => ({ size }), [size]);
|
||||
|
||||
return (
|
||||
<ButtonGroupContext.Provider value={contextValue}>
|
||||
<AriaToggleButtonGroup
|
||||
className={cx(
|
||||
'tw:relative tw:z-0 tw:inline-flex tw:w-max tw:-space-x-px tw:rounded-lg tw:shadow-xs',
|
||||
className
|
||||
)}
|
||||
selectionMode="single"
|
||||
{...otherProps}>
|
||||
{children}
|
||||
</AriaToggleButtonGroup>
|
||||
</ButtonGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
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(
|
||||
'tw:rounded-[7px] tw:text-fg-primary tw:ring-1 tw:ring-fg-primary tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 135 40"
|
||||
width={size === 'md' ? 135 : 149}>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
|
||||
/>
|
||||
<path className="tw:fill-current" d="M59.938 13V7H60.709V13H59.938Z" />
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
d="M59.938 13V7H60.709V13H59.938Z"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:stroke-current"
|
||||
d="M75.5749 13V7H76.513L79.429 11.667H79.4619L79.429 10.511V7H80.1999V13H79.3949L76.344 8.106H76.3109L76.344 9.262V13H75.5749Z"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</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(
|
||||
'tw:rounded-[7px] tw:text-fg-primary tw:ring-1 tw:ring-fg-primary tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 120 40"
|
||||
width={size === 'md' ? 120 : 132}>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M59.0903 14.7H59.9826V8.41626H59.0903V14.7Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
</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(
|
||||
'tw:rounded-[7px] tw:text-fg-primary tw:ring-1 tw:ring-fg-primary tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 147 40"
|
||||
width={size === 'md' ? 147 : 162}>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M64.7516 20.3987H66.7715V31.3885H64.7516V20.3987Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M90.3846 30.9598L86.8206 22.7029H88.9588L91.3796 28.554L93.6663 22.7029H95.7757L90.5926 35.4H88.5582L90.3846 30.9598Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M50.4322 14.6272L51.9962 10.3584H52.8886L50.9106 15.4H49.9354L47.9574 10.3584H48.859L50.4322 14.6272Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M62.7549 8.592V15.4H61.9177V8.592H62.7549Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M77.5454 8.592V15.4H76.7082V8.592H77.5454Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</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(
|
||||
'tw:rounded-[7px] tw:text-fg-primary tw:ring-1 tw:ring-fg-primary tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 133 40"
|
||||
width={size === 'md' ? 133 : 147}>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M41.5933 7.30106H45.1474L45.0474 8.15946H42.6349V9.82186H44.9098V10.6261H42.6349V12.4677H45.1765L45.089 13.3344H41.5933V7.30106Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M56.6015 7.30106H57.6431V12.4427H60.1389L60.0514 13.3344H56.6015V7.30106Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M72.9599 7.30106H76.5141L76.4141 8.15946H74.0015V9.82186H76.2765V10.6261H74.0015V12.4677H76.5431L76.4556 13.3344H72.9599V7.30106Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M80.3807 7.30106H81.4223V13.3344H80.3807V7.30106Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M84.1765 8.172H82.3055L82.3973 7.30106H87.0933L86.9973 8.172H85.218V13.3344H84.1765V8.172Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
d="M19.7881 22.0255H20.8041L20.2945 20.8404L19.7881 22.0255Z"
|
||||
/>
|
||||
<path
|
||||
className="tw:fill-current"
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
+730
@@ -0,0 +1,730 @@
|
||||
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(
|
||||
'tw:rounded-[7px] tw:bg-black tw:ring-1 tw:ring-app-store-badge-border tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 135 40"
|
||||
width={size === 'md' ? 135 : 149}>
|
||||
<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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M52.0281 7.737H49.2961V9.639H51.7601V10.36H49.2961V12.262H52.0281V13H48.5251V7H52.0281V7.737Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M55.279 13H54.508V7.737H52.832V7H56.955V7.737H55.279V13Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M59.938 13V7H60.709V13H59.938Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M64.1281 13H63.3572V7.737H61.6812V7H65.8042V7.737H64.1281V13Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<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
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
height="25.7207"
|
||||
id="filter0_ii_1303_2188"
|
||||
width="23.0894"
|
||||
x="9.97314"
|
||||
y="7.14093">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
mode="normal"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
result="hardAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
/>
|
||||
<feOffset dy="-0.15" />
|
||||
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
|
||||
<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
|
||||
in2="shape"
|
||||
mode="normal"
|
||||
result="effect1_innerShadow_1303_2188"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
result="hardAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
/>
|
||||
<feOffset dy="0.15" />
|
||||
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
|
||||
<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
|
||||
in2="effect1_innerShadow_1303_2188"
|
||||
mode="normal"
|
||||
result="effect2_innerShadow_1303_2188"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
colorInterpolationFilters="sRGB"
|
||||
filterUnits="userSpaceOnUse"
|
||||
height="12.8607"
|
||||
id="filter1_i_1303_2188"
|
||||
width="16.686"
|
||||
x="10.436"
|
||||
y="20.001">
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
mode="normal"
|
||||
result="shape"
|
||||
/>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
result="hardAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
/>
|
||||
<feOffset dy="-0.15" />
|
||||
<feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic" />
|
||||
<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
|
||||
in2="shape"
|
||||
mode="normal"
|
||||
result="effect1_innerShadow_1303_2188"
|
||||
/>
|
||||
</filter>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_1303_2188"
|
||||
x1="21.8009"
|
||||
x2="5.01895"
|
||||
y1="8.70903"
|
||||
y2="25.491">
|
||||
<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
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint1_linear_1303_2188"
|
||||
x1="33.8334"
|
||||
x2="9.63753"
|
||||
y1="20.001"
|
||||
y2="20.001">
|
||||
<stop stopColor="#FFE000" />
|
||||
<stop offset="0.4087" stopColor="#FFBD00" />
|
||||
<stop offset="0.7754" stopColor="#FFA500" />
|
||||
<stop offset="1" stopColor="#FF9C00" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint2_linear_1303_2188"
|
||||
x1="24.8281"
|
||||
x2="2.06964"
|
||||
y1="22.2949"
|
||||
y2="45.0534">
|
||||
<stop stopColor="#FF3A44" />
|
||||
<stop offset="1" stopColor="#C31162" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint3_linear_1303_2188"
|
||||
x1="7.29743"
|
||||
x2="17.4597"
|
||||
y1="0.176806"
|
||||
y2="10.3391">
|
||||
<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(
|
||||
'tw:rounded-[7px] tw:bg-black tw:ring-1 tw:ring-app-store-badge-border tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 135 40"
|
||||
width={size === 'md' ? 135 : 149}>
|
||||
<rect height="39" rx="6.5" stroke="white" width="134" x="0.5" y="0.5" />
|
||||
<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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M52.0281 7.73724H49.2961V9.63924H51.7601V10.3602H49.2961V12.2622H52.0281V13.0002H48.5251V7.00024H52.0281V7.73724Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M55.279 13.0002H54.508V7.73724H52.832V7.00024H56.955V7.73724H55.279V13.0002Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M59.938 13.0002V7.00024H60.709V13.0002H59.938Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M64.1281 13.0002H63.3572V7.73724H61.6812V7.00024H65.8042V7.73724H64.1281V13.0002Z"
|
||||
stroke="white"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<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"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="0.2"
|
||||
/>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</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(
|
||||
'tw:rounded-[7px] tw:bg-black tw:ring-1 tw:ring-app-store-badge-border tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 120 40"
|
||||
width={size === 'md' ? 120 : 132}>
|
||||
<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(
|
||||
'tw:rounded-[7px] tw:bg-black tw:ring-1 tw:ring-app-store-badge-border tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 147 40"
|
||||
width={size === 'md' ? 147 : 162}>
|
||||
<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
|
||||
fill="url(#paint0_angular_1303_2200)"
|
||||
height="26"
|
||||
rx="10"
|
||||
width="26"
|
||||
x="9"
|
||||
y="7"
|
||||
/>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
cx="0"
|
||||
cy="0"
|
||||
gradientTransform="translate(22 20) rotate(88.8309) scale(13.0027)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_angular_1303_2200"
|
||||
r="1">
|
||||
<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(
|
||||
'tw:rounded-[7px] tw:bg-black tw:ring-1 tw:ring-app-store-badge-border tw:outline-focus-ring tw:ring-inset tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
props.className
|
||||
)}>
|
||||
<svg
|
||||
fill="none"
|
||||
height={size === 'md' ? 40 : 44}
|
||||
viewBox="0 0 133 40"
|
||||
width={size === 'md' ? 133 : 147}>
|
||||
<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
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { Tooltip } from '@/components/base/tooltip/tooltip';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
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';
|
||||
|
||||
export const styles = {
|
||||
secondary:
|
||||
'tw:bg-primary tw:text-fg-quaternary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-primary tw:ring-inset tw:hover:bg-primary_hover tw:hover:text-fg-quaternary_hover tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
tertiary:
|
||||
'tw:text-quaternary tw:hover:bg-primary_hover tw:hover:text-tertiary',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(
|
||||
'tw:group tw:relative tw:inline-flex tw:h-max tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-md tw:p-1.5 tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw:disabled:cursor-not-allowed tw:disabled:text-fg-disabled_subtle',
|
||||
styles[color],
|
||||
|
||||
// Icon styles
|
||||
'tw:*:data-icon:pointer-events-none tw:*:data-icon:shrink-0 tw:*:data-icon:text-current tw:*:data-icon:transition-inherit-all',
|
||||
size === 'xs' ? 'tw:*:data-icon:size-4' : 'tw:*:data-icon:size-5',
|
||||
|
||||
className
|
||||
)}>
|
||||
{isReactComponent(Icon) && <Icon data-icon />}
|
||||
{isValidElement(Icon) && Icon}
|
||||
</Component>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
isDisabled={isDisabled}
|
||||
offset={size === 'xs' ? 4 : 6}
|
||||
placement={tooltipPlacement}
|
||||
title={tooltip}>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
import { cx, sortCx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
ButtonHTMLAttributes,
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { 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';
|
||||
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: [
|
||||
'tw:group tw:relative tw:inline-flex tw:h-max tw:cursor-pointer tw:items-center tw:justify-center tw:whitespace-nowrap tw:outline-brand tw:transition tw:duration-100 tw:ease-linear tw:before:absolute tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2',
|
||||
// When button is used within `InputGroup`
|
||||
'tw:in-data-input-wrapper:shadow-xs tw:in-data-input-wrapper:focus:!z-50 tw:in-data-input-wrapper:in-data-leading:-mr-px tw:in-data-input-wrapper:in-data-leading:rounded-r-none tw:in-data-input-wrapper:in-data-leading:before:rounded-r-none tw:in-data-input-wrapper:in-data-trailing:-ml-px tw:in-data-input-wrapper:in-data-trailing:rounded-l-none tw:in-data-input-wrapper:in-data-trailing:before:rounded-l-none',
|
||||
// Disabled styles
|
||||
'tw:disabled:cursor-not-allowed tw:disabled:text-fg-disabled',
|
||||
// Icon styles
|
||||
'tw:disabled:*:data-icon:text-fg-disabled_subtle',
|
||||
// Same as `icon` but for SSR icons that cannot be passed to the client as functions.
|
||||
'tw:*:data-icon:pointer-events-none tw:*:data-icon:size-5 tw:*:data-icon:shrink-0 tw:*:data-icon:transition-inherit-all',
|
||||
].join(' '),
|
||||
icon: 'tw:pointer-events-none tw:size-5 tw:shrink-0 tw:transition-inherit-all',
|
||||
},
|
||||
sizes: {
|
||||
xs: {
|
||||
root: [
|
||||
'tw:gap-0.5 tw:rounded-md tw:px-2 tw:py-1 tw:text-xs tw:font-medium tw:before:rounded-[5px] tw:data-icon-only:p-1',
|
||||
'tw:in-data-input-wrapper:px-2.5 tw:in-data-input-wrapper:py-1.5 tw:in-data-input-wrapper:data-icon-only:p-1.5',
|
||||
'tw:*:data-icon:size-4',
|
||||
].join(' '),
|
||||
linkRoot: 'tw:gap-0.5',
|
||||
},
|
||||
sm: {
|
||||
root: [
|
||||
'tw:gap-1 tw:rounded-lg tw:px-3 tw:py-2 tw:text-sm tw:font-medium tw:before:rounded-[7px] tw:data-icon-only:p-2',
|
||||
'tw:in-data-input-wrapper:px-3.5 tw:in-data-input-wrapper:py-2.5 tw:in-data-input-wrapper:data-icon-only:p-2.5',
|
||||
].join(' '),
|
||||
linkRoot: 'tw:gap-1',
|
||||
},
|
||||
md: {
|
||||
root: [
|
||||
'tw:gap-1 tw:rounded-lg tw:px-3.5 tw:py-2.5 tw:text-sm tw:font-medium tw:before:rounded-[7px] tw:data-icon-only:p-2.5',
|
||||
'tw:in-data-input-wrapper:gap-1.5 tw:in-data-input-wrapper:px-4 tw:in-data-input-wrapper:text-md tw:in-data-input-wrapper:data-icon-only:p-3',
|
||||
].join(' '),
|
||||
linkRoot: 'tw:gap-1',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:gap-1.5 tw:rounded-lg tw:px-4 tw:py-2.5 tw:text-md tw:font-medium tw:before:rounded-[7px] tw:data-icon-only:p-3',
|
||||
linkRoot: 'tw:gap-1.5',
|
||||
},
|
||||
xl: {
|
||||
root: 'tw:gap-1.5 tw:rounded-lg tw:px-4.5 tw:py-3 tw:text-md tw:font-medium tw:before:rounded-[7px] tw:data-icon-only:p-3.5',
|
||||
linkRoot: 'tw:gap-1.5',
|
||||
},
|
||||
},
|
||||
|
||||
colors: {
|
||||
primary: {
|
||||
root: [
|
||||
'tw:bg-brand-solid tw:text-white tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-transparent tw:ring-inset tw:hover:bg-brand-solid_hover tw:data-loading:bg-brand-solid_hover',
|
||||
// Inner border gradient
|
||||
'tw:before:absolute tw:before:inset-px tw:before:border tw:before:border-white/12 tw:before:mask-b-from-0%',
|
||||
// Disabled styles
|
||||
'tw:disabled:bg-disabled tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-button-primary-icon tw:hover:*:data-icon:text-button-primary-icon_hover',
|
||||
].join(' '),
|
||||
},
|
||||
secondary: {
|
||||
root: [
|
||||
'tw:bg-primary tw:text-secondary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-primary tw:ring-inset tw:hover:bg-primary_hover tw:hover:text-secondary_hover tw:data-loading:bg-primary_hover',
|
||||
// Disabled styles
|
||||
'tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-quaternary tw:hover:*:data-icon:text-fg-quaternary_hover',
|
||||
].join(' '),
|
||||
},
|
||||
tertiary: {
|
||||
root: [
|
||||
'tw:text-tertiary tw:hover:bg-primary_hover tw:hover:text-tertiary_hover tw:data-loading:bg-primary_hover',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-quaternary tw:hover:*:data-icon:text-fg-quaternary_hover',
|
||||
].join(' '),
|
||||
},
|
||||
'link-gray': {
|
||||
root: [
|
||||
'tw:justify-normal tw:rounded tw:p-0! tw:text-tertiary tw:hover:text-tertiary_hover',
|
||||
// Inner text underline
|
||||
'tw:*:data-text:underline tw:*:data-text:decoration-transparent tw:*:data-text:underline-offset-2 tw:hover:*:data-text:decoration-current',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-quaternary tw:hover:*:data-icon:text-fg-quaternary_hover',
|
||||
].join(' '),
|
||||
},
|
||||
'link-color': {
|
||||
root: [
|
||||
'tw:justify-normal tw:rounded tw:p-0! tw:text-brand-secondary tw:hover:text-brand-secondary_hover',
|
||||
// Inner text underline
|
||||
'tw:*:data-text:underline tw:*:data-text:decoration-transparent tw:*:data-text:underline-offset-2 tw:hover:*:data-text:decoration-current',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-brand-secondary_alt tw:hover:*:data-icon:text-fg-brand-secondary_hover',
|
||||
].join(' '),
|
||||
},
|
||||
'primary-destructive': {
|
||||
root: [
|
||||
'tw:bg-error-solid tw:text-white tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-transparent tw:outline-error tw:ring-inset tw:hover:bg-error-solid_hover tw:data-loading:bg-error-solid_hover',
|
||||
// Inner border gradient
|
||||
'tw:before:absolute tw:before:inset-px tw:before:border tw:before:border-white/12 tw:before:mask-b-from-0%',
|
||||
// Disabled styles
|
||||
'tw:disabled:bg-disabled tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-button-destructive-primary-icon tw:hover:*:data-icon:text-button-destructive-primary-icon_hover',
|
||||
].join(' '),
|
||||
},
|
||||
'secondary-destructive': {
|
||||
root: [
|
||||
'tw:bg-primary tw:text-error-primary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-error_subtle tw:outline-error tw:ring-inset tw:hover:bg-error-primary tw:hover:text-error-primary_hover tw:data-loading:bg-error-primary',
|
||||
// Disabled styles
|
||||
'tw:disabled:bg-primary tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-error-secondary tw:hover:*:data-icon:text-fg-error-primary',
|
||||
].join(' '),
|
||||
},
|
||||
'tertiary-destructive': {
|
||||
root: [
|
||||
'tw:text-error-primary tw:outline-error tw:hover:bg-error-primary tw:hover:text-error-primary_hover tw:data-loading:bg-error-primary',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-error-secondary tw:hover:*:data-icon:text-fg-error-primary',
|
||||
].join(' '),
|
||||
},
|
||||
'link-destructive': {
|
||||
root: [
|
||||
'tw:justify-normal tw:rounded tw:p-0! tw:text-error-primary tw:outline-error tw:hover:text-error-primary_hover',
|
||||
// Inner text underline
|
||||
'tw:*:data-text:underline tw:*:data-text:decoration-transparent tw:*:data-text:underline-offset-2 tw:hover:*:data-text:decoration-current',
|
||||
// Icon styles
|
||||
'tw:*:data-icon:text-fg-error-secondary tw:hover:*:data-icon:text-fg-error-primary',
|
||||
].join(' '),
|
||||
},
|
||||
'secondary-success': {
|
||||
root: [
|
||||
'tw:bg-primary tw:text-success-primary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-utility-success-300 tw:ring-inset',
|
||||
'tw:hover:bg-success-primary tw:hover:text-success-primary tw:data-loading:bg-success-primary',
|
||||
'tw:disabled:bg-primary tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
'tw:*:data-icon:text-success-primary',
|
||||
].join(' '),
|
||||
},
|
||||
'secondary-warning': {
|
||||
root: [
|
||||
'tw:bg-primary tw:text-warning-primary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-utility-warning-300 tw:ring-inset',
|
||||
'tw:hover:bg-warning-primary tw:hover:text-warning-primary tw:data-loading:bg-warning-primary',
|
||||
'tw:disabled:bg-primary tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
'tw:*:data-icon:text-warning-primary',
|
||||
].join(' '),
|
||||
},
|
||||
'secondary-brand': {
|
||||
root: [
|
||||
'tw:bg-primary tw:text-brand-secondary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-brand tw:ring-inset',
|
||||
'tw:hover:bg-brand-primary tw:hover:text-brand-secondary_hover tw:data-loading:bg-brand-primary',
|
||||
'tw:disabled:bg-primary tw:disabled:shadow-xs tw:disabled:ring-disabled_subtle',
|
||||
'tw:*:data-icon:text-brand-secondary',
|
||||
].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;
|
||||
/** Truncates the button text with an ellipsis when it overflows */
|
||||
ellipsis?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'];
|
||||
/** Handler called when the button is pressed */
|
||||
onPress?: AriaButtonProps['onPress'];
|
||||
/** Handler called when a press interaction starts */
|
||||
onPressStart?: AriaButtonProps['onPressStart'];
|
||||
/** Handler called when a press interaction ends */
|
||||
onPressEnd?: AriaButtonProps['onPressEnd'];
|
||||
/** Handler called when the press state changes */
|
||||
onPressChange?: AriaButtonProps['onPressChange'];
|
||||
/** Handler called when a press is released over the target */
|
||||
onPressUp?: AriaButtonProps['onPressUp'];
|
||||
/** Whether to exclude the button from the sequential tab order */
|
||||
excludeFromTabOrder?: AriaButtonProps['excludeFromTabOrder'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Button = ({
|
||||
size = 'sm',
|
||||
color = 'primary',
|
||||
children,
|
||||
className,
|
||||
noTextPadding,
|
||||
ellipsis,
|
||||
iconLeading: IconLeading,
|
||||
iconTrailing: IconTrailing,
|
||||
isDisabled: disabled,
|
||||
isLoading: loading,
|
||||
showTextWhileLoading,
|
||||
...otherProps
|
||||
}: Props) => {
|
||||
const href = 'href' in otherProps ? otherProps.href : undefined;
|
||||
const Component = href ? AriaLink : AriaButton;
|
||||
|
||||
const isIcon = (IconLeading || IconTrailing) && !children;
|
||||
const isLinkType = ['link-gray', 'link-color', 'link-destructive'].includes(
|
||||
color
|
||||
);
|
||||
noTextPadding = isLinkType || noTextPadding;
|
||||
|
||||
let props = {};
|
||||
|
||||
if (href) {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
href: disabled ? undefined : href,
|
||||
};
|
||||
} else {
|
||||
props = {
|
||||
...otherProps,
|
||||
|
||||
type: otherProps.type || 'button',
|
||||
isPending: loading,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
data-icon-only={isIcon ? true : undefined}
|
||||
data-loading={loading ? true : undefined}
|
||||
{...props}
|
||||
className={cx(
|
||||
styles.common.root,
|
||||
styles.sizes[size].root,
|
||||
styles.colors[color].root,
|
||||
isLinkType && styles.sizes[size].linkRoot,
|
||||
ellipsis && 'tw:min-w-0',
|
||||
(loading || (href && (disabled || loading))) &&
|
||||
'tw:pointer-events-none',
|
||||
// If in `loading` state, hide everything except the loading icon (and text if `showTextWhileLoading` is true).
|
||||
loading &&
|
||||
(showTextWhileLoading
|
||||
? 'tw:[&>*:not([data-icon=loading]):not([data-text])]:hidden'
|
||||
: 'tw:[&>*:not([data-icon=loading])]:invisible'),
|
||||
className
|
||||
)}
|
||||
isDisabled={disabled}>
|
||||
{/* Leading icon */}
|
||||
{isValidElement(IconLeading) && IconLeading}
|
||||
{isReactComponent(IconLeading) && (
|
||||
<IconLeading className={styles.common.icon} data-icon="leading" />
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<svg
|
||||
className={cx(
|
||||
styles.common.icon,
|
||||
!showTextWhileLoading &&
|
||||
'tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2'
|
||||
)}
|
||||
data-icon="loading"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
className="tw:stroke-current tw:opacity-30"
|
||||
cx="10"
|
||||
cy="10"
|
||||
fill="none"
|
||||
r="8"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/* Spinning circle */}
|
||||
<circle
|
||||
className="tw:origin-center tw:animate-spin tw:stroke-current"
|
||||
cx="10"
|
||||
cy="10"
|
||||
fill="none"
|
||||
r="8"
|
||||
strokeDasharray="12.5 50"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<span
|
||||
data-text
|
||||
className={cx(
|
||||
'tw:transition-inherit-all',
|
||||
!noTextPadding && 'tw:px-0.5',
|
||||
ellipsis && 'tw:truncate tw:min-w-0'
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Trailing icon */}
|
||||
{isValidElement(IconTrailing) && IconTrailing}
|
||||
{isReactComponent(IconTrailing) && (
|
||||
<IconTrailing className={styles.common.icon} data-icon="trailing" />
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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: 'tw:size-7', icon: 'tw:size-4' },
|
||||
sm: { root: 'tw:size-9', icon: 'tw:size-5' },
|
||||
md: { root: 'tw:size-10', icon: 'tw:size-5' },
|
||||
lg: { root: 'tw:size-11', icon: 'tw:size-6' },
|
||||
};
|
||||
|
||||
const themes = {
|
||||
light:
|
||||
'tw:text-fg-quaternary tw:hover:bg-primary_hover tw:hover:text-fg-quaternary_hover tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw:outline-focus-ring',
|
||||
dark: 'tw:text-fg-white/70 tw:hover:text-fg-white tw:hover:bg-white/20 tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw: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(
|
||||
'tw:flex tw:cursor-pointer tw:items-center tw:justify-center tw:rounded-lg tw:p-2 tw:transition tw:duration-100 tw:ease-linear tw:focus:outline-hidden',
|
||||
sizes[size].root,
|
||||
themes[theme],
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
<CloseIcon
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:shrink-0 tw:transition-inherit-all',
|
||||
sizes[size].icon
|
||||
)}
|
||||
/>
|
||||
</AriaButton>
|
||||
);
|
||||
};
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
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: "tw:group tw:relative tw:inline-flex tw:h-max tw:cursor-pointer tw:items-center tw:justify-center tw:font-semibold tw:whitespace-nowrap tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear tw:before:content-[''] tw:before:absolute tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw:disabled:cursor-not-allowed tw:disabled:stroke-fg-disabled tw:disabled:text-fg-disabled tw:disabled:*:text-fg-disabled",
|
||||
icon: 'tw:pointer-events-none tw:shrink-0 tw:transition-inherit-all',
|
||||
},
|
||||
|
||||
sizes: {
|
||||
sm: {
|
||||
root: 'tw:gap-2 tw:rounded-lg tw:px-3 tw:py-2 tw:text-sm tw:before:rounded-[7px] tw:data-icon-only:p-2',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:gap-2.5 tw:rounded-lg tw:px-3.5 tw:py-2.5 tw:text-sm tw:before:rounded-[7px] tw:data-icon-only:p-2.5',
|
||||
},
|
||||
lg: {
|
||||
root: 'tw:gap-3 tw:rounded-lg tw:px-4 tw:py-2.5 tw:text-md tw:before:rounded-[7px] tw:data-icon-only:p-2.5',
|
||||
},
|
||||
xl: {
|
||||
root: 'tw:gap-3.5 tw:rounded-lg tw:px-4.5 tw:py-3 tw:text-md tw:before:rounded-[7px] tw:data-icon-only:p-3.5',
|
||||
},
|
||||
'2xl': {
|
||||
root: 'tw:gap-4 tw:rounded-[10px] tw:px-5.5 tw:py-4 tw:text-lg tw:before:rounded-[9px] tw:data-icon-only:p-4',
|
||||
},
|
||||
},
|
||||
|
||||
colors: {
|
||||
gray: {
|
||||
root: 'tw:bg-primary tw:text-secondary tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-primary tw:ring-inset tw:hover:bg-primary_hover tw:hover:text-secondary_hover',
|
||||
icon: 'tw:text-fg-quaternary tw:group-hover:text-fg-quaternary_hover',
|
||||
},
|
||||
black: {
|
||||
root: 'tw:bg-black tw:text-white tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-transparent tw:ring-inset tw:before:absolute tw:before:inset-px tw:before:border tw:before:border-white/12 tw:before:mask-b-from-0%',
|
||||
icon: '',
|
||||
},
|
||||
|
||||
facebook: {
|
||||
root: 'tw:bg-[#1877F2] tw:text-white tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-transparent tw:ring-inset tw:before:absolute tw:before:inset-px tw:before:border tw:before:border-white/12 tw:before:mask-b-from-0% tw:hover:bg-[#0C63D4]',
|
||||
icon: '',
|
||||
},
|
||||
|
||||
dribble: {
|
||||
root: 'tw:bg-[#EA4C89] tw:text-white tw:shadow-xs-skeuomorphic tw:ring-1 tw:ring-transparent tw:ring-inset tw:before:absolute tw:before:inset-px tw:before:border tw:before:border-white/12 tw:before:mask-b-from-0% tw: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}
|
||||
className={cx(
|
||||
styles.common.root,
|
||||
styles.sizes[size].root,
|
||||
colorStyles.root,
|
||||
className
|
||||
)}
|
||||
data-icon-only={isIconOnly ? true : undefined}>
|
||||
<Logo
|
||||
className={cx(
|
||||
styles.common.icon,
|
||||
theme === 'gray'
|
||||
? colorStyles.icon
|
||||
: theme === 'brand' &&
|
||||
(social === 'facebook' ||
|
||||
social === 'apple' ||
|
||||
social === 'twitter')
|
||||
? 'tw:text-white'
|
||||
: theme === 'color' && (social === 'apple' || social === 'twitter')
|
||||
? 'tw:text-alpha-black'
|
||||
: ''
|
||||
)}
|
||||
colorful={
|
||||
(theme === 'brand' && (social === 'google' || social === 'figma')) ||
|
||||
(theme === 'color' &&
|
||||
(social === 'google' ||
|
||||
social === 'facebook' ||
|
||||
social === 'figma' ||
|
||||
social === 'dribble')) ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const GoogleLogo = ({
|
||||
colorful,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||
return (
|
||||
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" {...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 fill="none" height="24" viewBox="0 0 24 24" width="24" {...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 fill="none" height="24" viewBox="0 0 24 24" width="24" {...props}>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const DribbleLogo = ({
|
||||
colorful,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||
return (
|
||||
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" {...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
|
||||
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'}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const FacebookLogo = ({
|
||||
colorful,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { colorful?: boolean }) => {
|
||||
return (
|
||||
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" {...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 fill="none" height="24" viewBox="0 0 24 24" width="24" {...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 fill="none" height="24" viewBox="0 0 24 24" width="24" {...props}>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { cx, sortCx } from '@/utils/cx';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { createContext, forwardRef, useContext, useMemo } from 'react';
|
||||
|
||||
// ─── Size context ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type CardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const CardContext = createContext<{ size: CardSize }>({ size: 'md' });
|
||||
|
||||
// ─── Shared size styles ────────────────────────────────────────────────────────
|
||||
|
||||
const sizes = sortCx({
|
||||
sm: { padding: 'tw:px-4 tw:py-2' },
|
||||
md: { padding: 'tw:px-6 tw:py-3' },
|
||||
lg: { padding: 'tw:px-8 tw:py-4' },
|
||||
});
|
||||
|
||||
// ─── Root card styles ──────────────────────────────────────────────────────────
|
||||
|
||||
export const cardStyles = sortCx({
|
||||
common: {
|
||||
root: 'tw:focus-visible:outline-2 tw:focus-visible:outline-offset-2 tw:relative tw:overflow-hidden tw:rounded-xl tw:transition tw:duration-100',
|
||||
},
|
||||
variants: {
|
||||
default: {
|
||||
root: 'tw:border-1 tw:border-secondary tw:bg-primary',
|
||||
},
|
||||
elevated: {
|
||||
root: 'tw:border-1 tw:border-secondary tw:bg-primary tw:shadow-md',
|
||||
},
|
||||
outlined: { root: 'tw:border-2 tw:border-primary tw:bg-primary' },
|
||||
ghost: { root: 'tw:bg-transparent' },
|
||||
},
|
||||
colors: {
|
||||
default: { root: '' },
|
||||
brand: {
|
||||
root: 'tw:bg-utility-brand-50 tw:border-1 tw:border-utility-brand-200',
|
||||
},
|
||||
brandOutlined: {
|
||||
root: 'tw:border-1 tw:border-utility-brand-200',
|
||||
},
|
||||
error: {
|
||||
root: 'tw:bg-utility-error-50 tw:border-1 tw:border-utility-error-200',
|
||||
},
|
||||
warning: {
|
||||
root: 'tw:bg-utility-warning-50 tw:border-1 tw:border-utility-warning-200',
|
||||
},
|
||||
success: {
|
||||
root: 'tw:bg-utility-success-50 tw:border-1 tw:border-utility-success-200',
|
||||
},
|
||||
},
|
||||
interactive: { root: 'tw:cursor-pointer' },
|
||||
interactiveVariants: {
|
||||
default: { root: 'tw:hover:bg-primary_hover tw:hover:border-primary' },
|
||||
elevated: { root: 'tw:hover:bg-primary_hover tw:hover:shadow-lg' },
|
||||
outlined: { root: 'tw:hover:bg-secondary tw:hover:border-primary' },
|
||||
ghost: {
|
||||
root: 'tw:hover:bg-secondary tw:hover:border-1 tw:hover:border-inset tw:hover:border-secondary',
|
||||
},
|
||||
},
|
||||
selected: { root: 'tw:border-2 tw:border-brand' },
|
||||
});
|
||||
|
||||
// ─── Sub-component interfaces ──────────────────────────────────────────────────
|
||||
|
||||
export interface CardHeaderProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
subtitle,
|
||||
extra,
|
||||
className,
|
||||
...props
|
||||
}: CardHeaderProps) => {
|
||||
const { size } = useContext(CardContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:items-start tw:justify-between tw:gap-4 tw:border-b tw:border-inherit',
|
||||
sizes[size].padding,
|
||||
className
|
||||
)}>
|
||||
{(title || subtitle) && (
|
||||
<div className="tw:flex tw:min-w-0 tw:flex-col tw:gap-0.5">
|
||||
{title && (
|
||||
<div className="tw:text-sm tw:font-semibold tw:text-primary">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="tw:text-sm tw:text-tertiary">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{extra && <div className="tw:shrink-0">{extra}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
CardHeader.displayName = 'Card.Header';
|
||||
|
||||
const CardContent = ({ children, className, ...props }: CardContentProps) => {
|
||||
const { size } = useContext(CardContext);
|
||||
|
||||
return (
|
||||
<div {...props} className={cx(sizes[size].padding, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
CardContent.displayName = 'Card.Content';
|
||||
|
||||
const CardFooter = ({ children, className, ...props }: CardFooterProps) => {
|
||||
const { size } = useContext(CardContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:border-t tw:border-secondary',
|
||||
sizes[size].padding,
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
CardFooter.displayName = 'Card.Footer';
|
||||
|
||||
// ─── Root card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
variant?: keyof typeof cardStyles.variants;
|
||||
color?: keyof typeof cardStyles.colors;
|
||||
size?: CardSize;
|
||||
isClickable?: boolean;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
const CardBase = forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
variant = 'default',
|
||||
color = 'default',
|
||||
size = 'md',
|
||||
isClickable = false,
|
||||
isSelected = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const contextValue = useMemo(() => ({ size }), [size]);
|
||||
|
||||
return (
|
||||
<CardContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cx(
|
||||
cardStyles.common.root,
|
||||
cardStyles.variants[variant].root,
|
||||
cardStyles.colors[color].root,
|
||||
isClickable && cardStyles.interactive.root,
|
||||
isClickable && cardStyles.interactiveVariants[variant].root,
|
||||
isSelected && cardStyles.selected.root,
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</CardContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardBase.displayName = 'Card';
|
||||
|
||||
type CardComponent = typeof CardBase & {
|
||||
Header: typeof CardHeader;
|
||||
Content: typeof CardContent;
|
||||
Footer: typeof CardFooter;
|
||||
};
|
||||
|
||||
export const Card = CardBase as CardComponent;
|
||||
Card.Header = CardHeader;
|
||||
Card.Content = CardContent;
|
||||
Card.Footer = CardFooter;
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
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(
|
||||
'tw:relative tw:flex tw:size-4 tw:shrink-0 tw:cursor-pointer tw:appearance-none tw:items-center tw:justify-center tw:rounded tw:bg-primary tw:ring-1 tw:ring-primary tw:ring-inset',
|
||||
size === 'md' && 'tw:size-5 tw:rounded-md',
|
||||
(isSelected || isIndeterminate) &&
|
||||
'tw:bg-brand-solid tw:ring-brand-solid',
|
||||
isDisabled &&
|
||||
'tw:cursor-not-allowed tw:bg-disabled_subtle tw:ring-disabled',
|
||||
isFocusVisible &&
|
||||
'tw:outline-2 tw:outline-offset-2 tw:outline-focus-ring',
|
||||
className
|
||||
)}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:h-3 tw:w-2.5 tw:text-fg-white tw:opacity-0 tw:transition-inherit-all',
|
||||
size === 'md' && 'tw:size-3.5',
|
||||
isIndeterminate && 'tw:opacity-100',
|
||||
isDisabled && 'tw:text-fg-disabled_subtle'
|
||||
)}
|
||||
fill="none"
|
||||
viewBox="0 0 14 14">
|
||||
<path
|
||||
d="M2.91675 7H11.0834"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:size-3 tw:text-fg-white tw:opacity-0 tw:transition-inherit-all',
|
||||
size === 'md' && 'tw:size-3.5',
|
||||
isSelected && !isIndeterminate && 'tw:opacity-100',
|
||||
isDisabled && 'tw:text-fg-disabled_subtle'
|
||||
)}
|
||||
fill="none"
|
||||
viewBox="0 0 14 14">
|
||||
<path
|
||||
d="M11.6666 3.5L5.24992 9.91667L2.33325 7"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</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: 'tw:gap-2',
|
||||
textWrapper: '',
|
||||
label: 'tw:text-sm tw:font-medium',
|
||||
hint: 'tw:text-sm',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:gap-3',
|
||||
textWrapper: 'tw:gap-0.5',
|
||||
label: 'tw:text-md tw:font-medium',
|
||||
hint: 'tw:text-md',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaCheckbox
|
||||
{...ariaCheckboxProps}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:flex tw:items-start',
|
||||
state.isDisabled && 'tw:cursor-not-allowed',
|
||||
sizes[size].root,
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}>
|
||||
{({ isSelected, isIndeterminate, isDisabled, isFocusVisible }) => (
|
||||
<>
|
||||
<CheckboxBase
|
||||
className={label || hint ? 'tw:mt-0.5' : ''}
|
||||
isDisabled={isDisabled}
|
||||
isFocusVisible={isFocusVisible}
|
||||
isIndeterminate={isIndeterminate}
|
||||
isSelected={isSelected}
|
||||
size={size}
|
||||
/>
|
||||
{(label || hint) && (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:inline-flex tw:flex-col',
|
||||
sizes[size].textWrapper
|
||||
)}>
|
||||
{label && (
|
||||
<p
|
||||
className={cx(
|
||||
'tw:text-secondary tw:select-none',
|
||||
sizes[size].label
|
||||
)}>
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
{hint && (
|
||||
<span
|
||||
className={cx('tw:text-tertiary', sizes[size].hint)}
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
);
|
||||
};
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
export type DividerOrientation = 'horizontal' | 'vertical';
|
||||
export type DividerLabelAlignment = 'start' | 'center' | 'end';
|
||||
|
||||
export interface DividerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: DividerOrientation;
|
||||
label?: ReactNode;
|
||||
labelAlign?: DividerLabelAlignment;
|
||||
}
|
||||
|
||||
export const Divider = ({
|
||||
orientation = 'horizontal',
|
||||
label,
|
||||
labelAlign = 'center',
|
||||
className,
|
||||
...props
|
||||
}: DividerProps) => {
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-orientation="vertical"
|
||||
className={cx(
|
||||
'tw:self-stretch tw:w-px tw:shrink-0 tw:bg-border-secondary',
|
||||
className
|
||||
)}
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-orientation="horizontal"
|
||||
className={cx(
|
||||
'tw:w-full tw:h-px tw:shrink-0 tw:bg-border-secondary',
|
||||
className
|
||||
)}
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
{...props}
|
||||
aria-orientation="horizontal"
|
||||
className={cx('tw:flex tw:items-center tw:w-full tw:gap-2', className)}>
|
||||
{labelAlign !== 'start' && (
|
||||
<div className="tw:h-px tw:flex-1 tw:bg-border-secondary" />
|
||||
)}
|
||||
<span className="tw:shrink-0 tw:text-xs tw:font-medium tw:text-tertiary">
|
||||
{label}
|
||||
</span>
|
||||
{labelAlign !== 'end' && (
|
||||
<div className="tw:h-px tw:flex-1 tw:bg-border-secondary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Divider.displayName = 'Divider';
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import { CheckboxBase } from '@/components/base/checkbox/checkbox';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { DotsVertical } from '@untitledui/icons';
|
||||
import type { FC, HTMLAttributes, RefAttributes } from 'react';
|
||||
import type {
|
||||
ButtonProps as AriaButtonProps,
|
||||
MenuItemProps as AriaMenuItemProps,
|
||||
MenuProps as AriaMenuProps,
|
||||
PopoverProps as AriaPopoverProps,
|
||||
SeparatorProps as AriaSeparatorProps,
|
||||
} 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';
|
||||
|
||||
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 }>;
|
||||
/** If true, shows a checkbox on the left to indicate selection state. */
|
||||
showCheckbox?: boolean;
|
||||
}
|
||||
|
||||
const DropdownItem = ({
|
||||
label,
|
||||
children,
|
||||
addon,
|
||||
icon: Icon,
|
||||
unstyled,
|
||||
showCheckbox,
|
||||
...props
|
||||
}: DropdownItemProps) => {
|
||||
if (unstyled) {
|
||||
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaMenuItem
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:group tw:block tw:cursor-pointer tw:px-1.5 tw:py-px tw:outline-hidden',
|
||||
state.isDisabled && 'tw:cursor-not-allowed',
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}>
|
||||
{(state) => (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:relative tw:flex tw:items-center tw:gap-2 tw:rounded-md tw:px-2.5 tw:py-2 tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear',
|
||||
!state.isDisabled && 'tw:group-hover:bg-primary_hover',
|
||||
state.isFocused && 'tw:bg-primary_hover',
|
||||
state.isFocusVisible && 'tw:outline-2 tw:-outline-offset-2',
|
||||
state.isSelected && 'tw:bg-active'
|
||||
)}>
|
||||
{showCheckbox && (
|
||||
<CheckboxBase
|
||||
isDisabled={state.isDisabled}
|
||||
isFocusVisible={state.isFocusVisible}
|
||||
isSelected={state.isSelected}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{Icon && (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:size-4 tw:shrink-0 tw:stroke-[2.25px]',
|
||||
state.isDisabled
|
||||
? 'tw:text-fg-disabled'
|
||||
: 'tw:text-fg-quaternary'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cx(
|
||||
'tw:grow tw:truncate tw:text-sm',
|
||||
state.isDisabled ? 'tw:text-disabled' : 'tw:text-secondary',
|
||||
state.isFocused && 'tw:text-secondary_hover'
|
||||
)}>
|
||||
{label ||
|
||||
(typeof children === 'function' ? children(state) : children)}
|
||||
</span>
|
||||
|
||||
{addon && (
|
||||
<span
|
||||
className={cx(
|
||||
'tw:ml-auto tw:shrink-0 tw:rounded tw:px-1 tw:py-px tw:text-xs tw:font-medium tw:ring-1 tw:ring-secondary tw:ring-inset',
|
||||
state.isDisabled ? 'tw:text-disabled' : 'tw:text-quaternary'
|
||||
)}>
|
||||
{addon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AriaMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
|
||||
|
||||
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||
return (
|
||||
<AriaMenu
|
||||
disallowEmptySelection
|
||||
selectionMode="single"
|
||||
{...props}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:h-min tw:overflow-y-auto tw:py-1 tw:outline-hidden tw:select-none',
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type DropdownPopoverProps = AriaPopoverProps;
|
||||
|
||||
const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||
const { placement = 'bottom right', ...rest } = props;
|
||||
|
||||
return (
|
||||
<AriaPopover
|
||||
placement={placement}
|
||||
{...rest}
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:w-62 tw:origin-(--trigger-anchor-point) tw:overflow-auto tw:rounded-lg tw:bg-primary tw:shadow-lg tw:ring-1 tw:ring-secondary_alt tw:will-change-transform',
|
||||
state.isEntering &&
|
||||
'tw:duration-150 tw:ease-out tw:animate-in tw:fade-in tw:placement-right:slide-in-from-left-0.5 tw:placement-top:slide-in-from-bottom-0.5 tw:placement-bottom:slide-in-from-top-0.5',
|
||||
state.isExiting &&
|
||||
'tw:duration-100 tw:ease-in tw:animate-out tw:fade-out tw:placement-right:slide-out-to-left-0.5 tw:placement-top:slide-out-to-bottom-0.5 tw: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(
|
||||
'tw:my-1 tw:h-px tw:w-full tw:bg-border-secondary',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownDotsButton = (
|
||||
props: AriaButtonProps & RefAttributes<HTMLButtonElement>
|
||||
) => {
|
||||
return (
|
||||
<AriaButton
|
||||
{...props}
|
||||
aria-label="Open menu"
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:cursor-pointer tw:rounded-md tw:text-fg-quaternary tw:outline-focus-ring tw:transition tw:duration-100 tw:ease-linear',
|
||||
(state.isPressed || state.isHovered) && 'tw:text-fg-quaternary_hover',
|
||||
(state.isPressed || state.isFocusVisible) &&
|
||||
'tw:outline-2 tw:outline-offset-2',
|
||||
typeof props.className === 'function'
|
||||
? props.className(state)
|
||||
: props.className
|
||||
)
|
||||
}>
|
||||
<DotsVertical className="tw:size-5 tw:transition-inherit-all" />
|
||||
</AriaButton>
|
||||
);
|
||||
};
|
||||
|
||||
export type DropdownSectionHeaderProps = HTMLAttributes<HTMLElement> &
|
||||
RefAttributes<HTMLElement>;
|
||||
|
||||
export const Dropdown = {
|
||||
Root: AriaMenuTrigger,
|
||||
Popover: DropdownPopover,
|
||||
Menu: DropdownMenu,
|
||||
Section: AriaMenuSection,
|
||||
SectionHeader: AriaHeader as FC<DropdownSectionHeaderProps>,
|
||||
Item: DropdownItem,
|
||||
Separator: DropdownSeparator,
|
||||
DotsButton: DropdownDotsButton,
|
||||
};
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
DetailedReactHTMLElement,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import React, { cloneElement, useRef } from 'react';
|
||||
import { filterDOMProps } from '@react-aria/utils';
|
||||
|
||||
interface FileTriggerProps {
|
||||
/**
|
||||
* Specifies what mime type of files are allowed.
|
||||
*/
|
||||
acceptedFileTypes?: Array<string>;
|
||||
/**
|
||||
* Whether multiple files can be selected.
|
||||
*/
|
||||
allowsMultiple?: boolean;
|
||||
/**
|
||||
* Specifies the use of a media capture mechanism to capture the media on the spot.
|
||||
*/
|
||||
defaultCamera?: 'user' | 'environment';
|
||||
/**
|
||||
* Handler when a user selects a file.
|
||||
*/
|
||||
onSelect?: (files: FileList | null) => void;
|
||||
/**
|
||||
* The children of the component.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Enables the selection of directories instead of individual files.
|
||||
*/
|
||||
acceptDirectory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A FileTrigger allows a user to access the file system with any pressable React Aria or
|
||||
* React Spectrum component, or custom components built with usePress.
|
||||
*/
|
||||
export const FileTrigger = (props: FileTriggerProps) => {
|
||||
const {
|
||||
children,
|
||||
onSelect,
|
||||
acceptedFileTypes,
|
||||
allowsMultiple,
|
||||
defaultCamera,
|
||||
acceptDirectory,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const domProps = filterDOMProps(rest);
|
||||
|
||||
// Make sure that only one child is passed to the component.
|
||||
const clonableElement = React.Children.only(children);
|
||||
|
||||
// Clone the child element and add an `onClick` handler to open the file dialog.
|
||||
const mainElement = cloneElement(
|
||||
clonableElement as DetailedReactHTMLElement<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>,
|
||||
{
|
||||
onClick: () => {
|
||||
if (inputRef.current?.value) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
inputRef.current?.click();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mainElement}
|
||||
<input
|
||||
{...domProps}
|
||||
accept={acceptedFileTypes?.toString()}
|
||||
capture={defaultCamera}
|
||||
multiple={allowsMultiple}
|
||||
ref={inputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
// @ts-expect-error - webkitdirectory is a non-standard HTML attribute not in React's type definitions
|
||||
webkitdirectory={acceptDirectory ? '' : undefined}
|
||||
onChange={(e) => onSelect?.(e.target.files)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+8
@@ -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';
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import { createContext, useContext, useId } from 'react';
|
||||
import { Form as AriaForm } from 'react-aria-components';
|
||||
import type {
|
||||
Control,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
UseControllerReturn,
|
||||
UseFormReturn,
|
||||
} from 'react-hook-form';
|
||||
import { FormProvider, useController, useFormContext } from 'react-hook-form';
|
||||
import { FieldDocProvider } from '../../application/form-field/field-doc-context';
|
||||
import { FieldDocPopover } from '../../application/form-field/field-doc-popover';
|
||||
|
||||
interface FormProps<TFieldValues extends FieldValues = FieldValues>
|
||||
extends ComponentPropsWithoutRef<typeof AriaForm> {
|
||||
form: UseFormReturn<TFieldValues>;
|
||||
children: ReactNode;
|
||||
showFieldDocs?: boolean;
|
||||
renderFieldDoc?: (doc: string) => ReactNode;
|
||||
fieldDocHeader?: ReactNode;
|
||||
fieldDocOffset?: number;
|
||||
}
|
||||
|
||||
interface FormFieldProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> {
|
||||
name: TName;
|
||||
control: Control<TFieldValues>;
|
||||
rules?: Omit<
|
||||
import('react-hook-form').RegisterOptions<TFieldValues, TName>,
|
||||
'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
|
||||
>;
|
||||
children:
|
||||
| ReactNode
|
||||
| ((control: UseControllerReturn<TFieldValues, TName>) => ReactNode);
|
||||
}
|
||||
|
||||
interface FormFieldContextValues<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> {
|
||||
id: string;
|
||||
name: TName;
|
||||
control?: UseControllerReturn<TFieldValues, TName>;
|
||||
}
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContextValues | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export const useFormFieldContext = () => {
|
||||
const context = useContext(FormFieldContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"The 'useFormFieldContext' hook must be used within a '<FormField />'"
|
||||
);
|
||||
}
|
||||
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
const fieldState = getFieldState(context.name, formState);
|
||||
|
||||
return { ...context, ...fieldState };
|
||||
};
|
||||
|
||||
export const HookForm = <TFieldValues extends FieldValues = FieldValues>({
|
||||
form,
|
||||
showFieldDocs = false,
|
||||
renderFieldDoc,
|
||||
fieldDocHeader,
|
||||
fieldDocOffset,
|
||||
...props
|
||||
}: FormProps<TFieldValues>) => {
|
||||
// Always keep the same tree shape (FormProvider > FieldDocProvider > AriaForm)
|
||||
// so toggling showFieldDocs never remounts the form and resets its state.
|
||||
// FieldDocProvider is a no-op when disabled, and adds no DOM.
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<FieldDocProvider enabled={showFieldDocs}>
|
||||
<AriaForm {...props} />
|
||||
{showFieldDocs ? (
|
||||
<FieldDocPopover
|
||||
header={fieldDocHeader}
|
||||
offset={fieldDocOffset}
|
||||
renderDoc={renderFieldDoc}
|
||||
/>
|
||||
) : null}
|
||||
</FieldDocProvider>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
HookForm.displayName = 'HookForm';
|
||||
|
||||
export const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
children,
|
||||
...props
|
||||
}: FormFieldProps<TFieldValues, TName>) => {
|
||||
const id = 'form-item-' + useId();
|
||||
const control = useController(props);
|
||||
const withValidationBehavior = {
|
||||
...control,
|
||||
field: {
|
||||
...control.field,
|
||||
validationBehavior: 'aria',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldContext.Provider
|
||||
value={{
|
||||
id,
|
||||
name: props.name,
|
||||
control: control as unknown as UseControllerReturn<FieldValues, string>,
|
||||
}}>
|
||||
{children &&
|
||||
(typeof children === 'function'
|
||||
? children(withValidationBehavior)
|
||||
: children)}
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.displayName = 'FormField';
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2025 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type Gap = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '8' | '10' | '12';
|
||||
|
||||
const GAP_CLASS: Record<Gap, string> = {
|
||||
'0': 'tw:gap-0',
|
||||
'1': 'tw:gap-1',
|
||||
'2': 'tw:gap-2',
|
||||
'3': 'tw:gap-3',
|
||||
'4': 'tw:gap-4',
|
||||
'5': 'tw:gap-5',
|
||||
'6': 'tw:gap-6',
|
||||
'8': 'tw:gap-8',
|
||||
'10': 'tw:gap-10',
|
||||
'12': 'tw:gap-12',
|
||||
};
|
||||
|
||||
const ROW_GAP_CLASS: Record<Gap, string> = {
|
||||
'0': 'tw:gap-y-0',
|
||||
'1': 'tw:gap-y-1',
|
||||
'2': 'tw:gap-y-2',
|
||||
'3': 'tw:gap-y-3',
|
||||
'4': 'tw:gap-y-4',
|
||||
'5': 'tw:gap-y-5',
|
||||
'6': 'tw:gap-y-6',
|
||||
'8': 'tw:gap-y-8',
|
||||
'10': 'tw:gap-y-10',
|
||||
'12': 'tw:gap-y-12',
|
||||
};
|
||||
|
||||
const COL_GAP_CLASS: Record<Gap, string> = {
|
||||
'0': 'tw:gap-x-0',
|
||||
'1': 'tw:gap-x-1',
|
||||
'2': 'tw:gap-x-2',
|
||||
'3': 'tw:gap-x-3',
|
||||
'4': 'tw:gap-x-4',
|
||||
'5': 'tw:gap-x-5',
|
||||
'6': 'tw:gap-x-6',
|
||||
'8': 'tw:gap-x-8',
|
||||
'10': 'tw:gap-x-10',
|
||||
'12': 'tw:gap-x-12',
|
||||
};
|
||||
|
||||
export interface GridProps extends HTMLAttributes<HTMLDivElement> {
|
||||
gap?: Gap;
|
||||
rowGap?: Gap;
|
||||
colGap?: Gap;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const GRID_COLUMNS = 24;
|
||||
|
||||
export const Grid = ({
|
||||
gap,
|
||||
rowGap,
|
||||
colGap,
|
||||
className,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: GridProps) => {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:grid',
|
||||
gap ? GAP_CLASS[gap] : undefined,
|
||||
rowGap ? ROW_GAP_CLASS[rowGap] : undefined,
|
||||
colGap ? COL_GAP_CLASS[colGap] : undefined,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
|
||||
...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Grid.displayName = 'Grid';
|
||||
|
||||
export interface GridItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
span?: number;
|
||||
start?: number;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
export const GridItem = ({
|
||||
span = GRID_COLUMNS,
|
||||
start,
|
||||
className,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: GridItemProps) => {
|
||||
const clampedStart =
|
||||
start !== undefined ? clamp(start, 1, GRID_COLUMNS) : undefined;
|
||||
const maxSpan =
|
||||
clampedStart !== undefined ? GRID_COLUMNS - clampedStart + 1 : GRID_COLUMNS;
|
||||
const clampedSpan = clamp(span, 1, maxSpan);
|
||||
|
||||
const gridColumn =
|
||||
clampedStart !== undefined
|
||||
? `${clampedStart} / span ${clampedSpan}`
|
||||
: `span ${clampedSpan}`;
|
||||
|
||||
return (
|
||||
<div {...props} className={cx(className)} style={{ gridColumn, ...style }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
GridItem.displayName = 'Grid.Item';
|
||||
|
||||
Grid.Item = GridItem;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
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>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const HintText = ({ isInvalid, className, ...props }: HintTextProps) => {
|
||||
return (
|
||||
<AriaText
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:text-sm tw:text-tertiary',
|
||||
|
||||
// Invalid state
|
||||
isInvalid && 'tw:text-error-primary',
|
||||
'tw:group-invalid:text-error-primary',
|
||||
|
||||
className
|
||||
)}
|
||||
slot={isInvalid ? 'errorMessage' : 'description'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
HintText.displayName = 'HintText';
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react';
|
||||
import { HintText } from '@/components/base/input/hint-text';
|
||||
import type { InputBaseProps } 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';
|
||||
/** The size of the prefix. */
|
||||
size?: 'sm' | 'md';
|
||||
/** Indicates that the prefix is disabled. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const InputPrefix = ({
|
||||
isDisabled,
|
||||
children,
|
||||
...props
|
||||
}: InputPrefixProps) => (
|
||||
<span
|
||||
{...props}
|
||||
className={cx(
|
||||
'tw:flex tw:text-md tw:text-tertiary tw:shadow-xs tw:ring-1 tw:ring-border-primary tw:ring-inset',
|
||||
// Styles when the prefix is within an `InputGroup`
|
||||
'tw:in-data-input-wrapper:in-data-leading:-mr-px tw:in-data-input-wrapper:in-data-leading:rounded-l-lg',
|
||||
'tw:in-data-input-wrapper:in-data-trailing:-ml-px tw:in-data-input-wrapper:in-data-trailing:rounded-r-lg',
|
||||
// Size styles based on size when within an `InputGroup`
|
||||
'tw:in-data-input-wrapper:in-data-[input-size=md]:py-2.5 tw:in-data-input-wrapper:in-data-[input-size=md]:pr-3 tw:in-data-input-wrapper:in-data-[input-size=md]:pl-3.5 tw:in-data-input-wrapper:in-data-[input-size=sm]:px-3 tw:in-data-input-wrapper:in-data-[input-size=sm]:py-2',
|
||||
// Disabled styles
|
||||
isDisabled && 'tw:border-disabled tw:bg-disabled_subtle tw:text-tertiary',
|
||||
'tw:in-data-input-wrapper:group-disabled:bg-disabled_subtle tw:in-data-input-wrapper:group-disabled:text-disabled tw:in-data-input-wrapper:group-disabled:ring-border-disabled',
|
||||
|
||||
props.className
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
// `${string}ClassName` is used to omit any className prop that ends with a `ClassName` suffix
|
||||
interface InputGroupProps
|
||||
extends Omit<
|
||||
InputBaseProps,
|
||||
| 'type'
|
||||
| 'icon'
|
||||
| 'placeholder'
|
||||
| 'tooltip'
|
||||
| 'shortcut'
|
||||
| `${string}ClassName`
|
||||
> {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export const InputGroup = ({
|
||||
size = 'sm',
|
||||
prefix,
|
||||
leadingAddon,
|
||||
trailingAddon,
|
||||
label,
|
||||
hint,
|
||||
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 &&
|
||||
'tw:group-has-[&>select]:px-2.5 tw:group-has-[&>select]:pl-2.5',
|
||||
hasTrailing &&
|
||||
(prefix
|
||||
? 'tw:group-has-[&>select]:pr-6 tw:group-has-[&>select]:pl-0'
|
||||
: 'tw:group-has-[&>select]:pr-6 tw:group-has-[&>select]:pl-3')
|
||||
),
|
||||
leadingText: 'tw:pl-3',
|
||||
},
|
||||
md: {
|
||||
input: cx(
|
||||
// Apply padding styles when select element is passed as a child
|
||||
hasLeading &&
|
||||
'tw:group-has-[&>select]:px-3 tw:group-has-[&>select]:pl-3',
|
||||
hasTrailing &&
|
||||
(prefix
|
||||
? 'tw:group-has-[&>select]:pr-6 tw:group-has-[&>select]:pl-0'
|
||||
: 'tw:group-has-[&>select]:pr-6 tw:group-has-[&>select]:pl-3')
|
||||
),
|
||||
leadingText: 'tw:pl-3.5',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TextField
|
||||
aria-label={label || undefined}
|
||||
inputClassName={cx(paddings[size].input)}
|
||||
size={size}
|
||||
tooltipClassName={cx(
|
||||
hasTrailing && !hasLeading && 'tw:group-has-[&>select]:right-0'
|
||||
)}
|
||||
wrapperClassName={cx(
|
||||
'tw:z-10',
|
||||
// Apply styles based on the presence of leading or trailing elements
|
||||
hasLeading && 'tw:rounded-l-none',
|
||||
hasTrailing && 'tw:rounded-r-none',
|
||||
// When select element is passed as a child
|
||||
'tw:group-has-[&>select]:bg-transparent tw:group-has-[&>select]:shadow-none tw:group-has-[&>select]:ring-0 tw:group-has-[&>select]:focus-within:ring-0',
|
||||
// In `Input` component, there is "group-disabled" class so here we need to use
|
||||
// "group-disabled:group-has-[&>select]" to avoid conflict
|
||||
'tw:group-disabled:group-has-[&>select]:bg-transparent'
|
||||
)}
|
||||
{...props}>
|
||||
{({ isDisabled, isInvalid, isRequired }) => (
|
||||
<>
|
||||
{label && <Label isRequired={isRequired}>{label}</Label>}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'tw:group tw:relative tw:flex tw:h-max tw:w-full tw:flex-row tw:justify-center tw:rounded-lg tw:bg-primary tw:transition-all tw:duration-100 tw:ease-linear',
|
||||
|
||||
// Only apply focus ring when child is select and input is focused
|
||||
'tw:has-[&>select]:shadow-xs tw:has-[&>select]:ring-1 tw:has-[&>select]:ring-border-primary tw:has-[&>select]:ring-inset tw:has-[&>select]:has-[input:focus]:ring-2 tw:has-[&>select]:has-[input:focus]:ring-border-brand',
|
||||
|
||||
isDisabled &&
|
||||
'tw:cursor-not-allowed tw:has-[&>select]:bg-disabled_subtle tw:has-[&>select]:ring-border-disabled',
|
||||
isInvalid &&
|
||||
'tw:has-[&>select]:ring-border-error_subtle tw:has-[&>select]:has-[input:focus]:ring-border-error'
|
||||
)}
|
||||
data-input-size={size}>
|
||||
{leadingAddon && (
|
||||
<section data-leading={hasLeading || undefined}>
|
||||
{leadingAddon}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{prefix && (
|
||||
<span
|
||||
className={cx(
|
||||
'tw:my-auto tw:grow tw:pr-2',
|
||||
paddings[size].leadingText
|
||||
)}>
|
||||
<p
|
||||
className={cx(
|
||||
'tw:text-md tw:text-tertiary',
|
||||
isDisabled && 'tw:text-disabled'
|
||||
)}>
|
||||
{prefix}
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{trailingAddon && (
|
||||
<section data-trailing={hasTrailing || undefined}>
|
||||
{trailingAddon}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
|
||||
</>
|
||||
)}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
InputGroup.Prefix = InputPrefix;
|
||||
|
||||
InputGroup.displayName = 'InputGroup';
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
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';
|
||||
import { fontSizeClass } from '@/utils/tailwindClasses';
|
||||
import { HelpCircle, InfoCircle } from '@untitledui/icons';
|
||||
import {
|
||||
type ComponentType,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
createContext,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type {
|
||||
InputProps as AriaInputProps,
|
||||
TextFieldProps as AriaTextFieldProps,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
Group as AriaGroup,
|
||||
Input as AriaInput,
|
||||
TextField as AriaTextField,
|
||||
} from 'react-aria-components';
|
||||
|
||||
export interface InputBaseProps extends TextFieldProps {
|
||||
/** Tooltip message on hover. */
|
||||
tooltip?: string;
|
||||
/**
|
||||
* Input size.
|
||||
* @default "sm"
|
||||
*/
|
||||
size?: 'sm' | 'md';
|
||||
/**
|
||||
* Font size of the input text.
|
||||
* @default "md"
|
||||
*/
|
||||
fontSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** 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;
|
||||
/** Class name for the hint text. */
|
||||
hintClassName?: 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>>;
|
||||
/** Optional slot rendered inside the input group, after the input and before trailing icons. */
|
||||
trailingSlot?: ReactNode;
|
||||
/** Passes `data-testid` directly to the inner `<input>` element (not the wrapper). */
|
||||
inputDataTestId?: string;
|
||||
}
|
||||
|
||||
const TextFieldContext = createContext<TextFieldProps>({});
|
||||
|
||||
export const InputBase = ({
|
||||
ref,
|
||||
tooltip,
|
||||
shortcut,
|
||||
groupRef,
|
||||
trailingSlot,
|
||||
inputDataTestId,
|
||||
size = 'sm',
|
||||
fontSize = 'sm',
|
||||
isInvalid,
|
||||
isDisabled,
|
||||
icon: Icon,
|
||||
placeholder,
|
||||
wrapperClassName,
|
||||
tooltipClassName,
|
||||
inputClassName,
|
||||
iconClassName,
|
||||
// Omit this prop to avoid invalid HTML attribute warning
|
||||
isRequired: _isRequired,
|
||||
...inputProps
|
||||
}: Omit<InputBaseProps, '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(TextFieldContext);
|
||||
|
||||
const inputSize = context?.size || size;
|
||||
|
||||
const sizes = sortCx({
|
||||
sm: {
|
||||
root: cx(
|
||||
'tw:px-3 tw:py-2',
|
||||
hasTrailingIcon && 'tw:pr-9',
|
||||
hasLeadingIcon && 'tw:pl-10'
|
||||
),
|
||||
iconLeading: 'tw:left-3',
|
||||
iconTrailing: 'tw:right-3',
|
||||
shortcut: 'tw:pr-2.5',
|
||||
},
|
||||
md: {
|
||||
root: cx(
|
||||
'tw:px-3.5 tw:py-2.5',
|
||||
hasTrailingIcon && 'tw:pr-9.5',
|
||||
hasLeadingIcon && 'tw:pl-10.5'
|
||||
),
|
||||
iconLeading: 'tw:left-3.5',
|
||||
iconTrailing: 'tw:right-3.5',
|
||||
shortcut: 'tw:pr-3',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AriaGroup
|
||||
{...{ isDisabled, isInvalid }}
|
||||
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||
cx(
|
||||
'tw:relative tw:flex tw:w-full tw:flex-row tw:place-content-center tw:place-items-center tw:rounded-lg tw:bg-primary tw:shadow-xs tw:ring-1 tw:ring-primary tw:transition-shadow tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
|
||||
isFocusWithin && !isDisabled && 'tw:ring-2 tw:ring-brand',
|
||||
|
||||
// Disabled state styles
|
||||
isDisabled &&
|
||||
'tw:cursor-not-allowed tw:bg-disabled_subtle tw:ring-disabled',
|
||||
'tw:group-disabled:cursor-not-allowed tw:group-disabled:bg-disabled_subtle tw:group-disabled:ring-disabled',
|
||||
|
||||
// Invalid state styles
|
||||
isInvalid && 'tw:ring-error_subtle',
|
||||
'tw:group-invalid:ring-error_subtle',
|
||||
|
||||
// Invalid state with focus-within styles
|
||||
isInvalid && isFocusWithin && 'tw:ring-2 tw:ring-error',
|
||||
isFocusWithin &&
|
||||
'tw:group-invalid:ring-2 tw:group-invalid:ring-error',
|
||||
|
||||
context?.wrapperClassName,
|
||||
wrapperClassName
|
||||
)
|
||||
}
|
||||
ref={groupRef}>
|
||||
{/* Leading icon and Payment icon */}
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:size-5 tw:text-fg-quaternary',
|
||||
isDisabled && 'tw:text-fg-disabled',
|
||||
sizes[inputSize].iconLeading,
|
||||
context?.iconClassName,
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input field */}
|
||||
<AriaInput
|
||||
{...(inputProps as AriaInputProps)}
|
||||
className={cx(
|
||||
cx(
|
||||
'tw:m-0 tw:w-full tw:bg-transparent tw:text-primary tw:ring-0 tw:outline-hidden tw:placeholder:text-sm tw:placeholder:text-tertiary tw:autofill:rounded-lg tw:autofill:text-primary',
|
||||
fontSizeClass[fontSize]
|
||||
),
|
||||
isDisabled && 'tw:cursor-not-allowed tw:text-disabled',
|
||||
sizes[inputSize].root,
|
||||
context?.inputClassName,
|
||||
inputClassName
|
||||
)}
|
||||
data-testid={inputDataTestId}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
{/* Custom trailing slot (e.g. password reveal button) */}
|
||||
{trailingSlot}
|
||||
|
||||
{/* Tooltip and help icon */}
|
||||
{tooltip && !isInvalid && (
|
||||
<Tooltip placement="top" title={tooltip}>
|
||||
<TooltipTrigger
|
||||
className={cx(
|
||||
'tw:absolute tw:cursor-pointer tw:text-fg-quaternary tw:transition tw:duration-200 tw:hover:text-fg-quaternary_hover tw:focus:text-fg-quaternary_hover',
|
||||
sizes[inputSize].iconTrailing,
|
||||
context?.tooltipClassName,
|
||||
tooltipClassName
|
||||
)}>
|
||||
<HelpCircle className="tw:size-4" />
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Invalid icon */}
|
||||
{isInvalid && (
|
||||
<InfoCircle
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:size-4 tw:text-fg-error-secondary',
|
||||
sizes[inputSize].iconTrailing,
|
||||
context?.tooltipClassName,
|
||||
tooltipClassName
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shortcut */}
|
||||
{shortcut && (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:inset-y-0.5 tw:right-0.5 tw:z-10 tw:flex tw:items-center tw:rounded-r-[inherit] tw:bg-linear-to-r tw:from-transparent tw:to-bg-primary tw:to-40% tw:pl-8',
|
||||
sizes[inputSize].shortcut
|
||||
)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:rounded tw:px-1 tw:py-px tw:text-xs tw:font-medium tw:text-quaternary tw:ring-1 tw:ring-secondary tw:select-none tw:ring-inset',
|
||||
isDisabled && 'tw:bg-transparent tw:text-disabled'
|
||||
)}>
|
||||
{typeof shortcut === 'string' ? shortcut : '⌘K'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</AriaGroup>
|
||||
);
|
||||
};
|
||||
|
||||
InputBase.displayName = 'InputBase';
|
||||
|
||||
interface BaseProps {
|
||||
/** Label text for the input */
|
||||
label?: string;
|
||||
/** Helper text displayed below the input */
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
interface TextFieldProps
|
||||
extends BaseProps,
|
||||
AriaTextFieldProps,
|
||||
Pick<
|
||||
InputBaseProps,
|
||||
| 'size'
|
||||
| 'wrapperClassName'
|
||||
| 'inputClassName'
|
||||
| 'iconClassName'
|
||||
| 'tooltipClassName'
|
||||
> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const TextField = ({ className, ...props }: TextFieldProps) => {
|
||||
return (
|
||||
<TextFieldContext.Provider value={props}>
|
||||
<AriaTextField
|
||||
{...props}
|
||||
data-input-wrapper
|
||||
className={(state) =>
|
||||
cx(
|
||||
'tw:group tw:flex tw:h-max tw:w-full tw:flex-col tw:items-start tw:justify-start tw:gap-1.5',
|
||||
typeof className === 'function' ? className(state) : className
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TextFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
TextField.displayName = 'TextField';
|
||||
|
||||
interface InputProps extends InputBaseProps, BaseProps {
|
||||
/** Whether to hide required indicator from label */
|
||||
hideRequiredIndicator?: boolean;
|
||||
}
|
||||
|
||||
export const Input = ({
|
||||
size = 'sm',
|
||||
fontSize = 'sm',
|
||||
placeholder,
|
||||
icon: Icon,
|
||||
label,
|
||||
hint,
|
||||
shortcut,
|
||||
trailingSlot,
|
||||
inputDataTestId,
|
||||
hideRequiredIndicator,
|
||||
className,
|
||||
ref,
|
||||
groupRef,
|
||||
tooltip,
|
||||
iconClassName,
|
||||
inputClassName,
|
||||
wrapperClassName,
|
||||
tooltipClassName,
|
||||
hintClassName,
|
||||
...props
|
||||
}: InputProps) => {
|
||||
return (
|
||||
<TextField
|
||||
aria-label={label ? undefined : placeholder}
|
||||
{...props}
|
||||
className={className}>
|
||||
{({ isRequired, isInvalid }) => (
|
||||
<>
|
||||
{label && (
|
||||
<Label
|
||||
isRequired={
|
||||
hideRequiredIndicator ? !hideRequiredIndicator : isRequired
|
||||
}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<InputBase
|
||||
{...{
|
||||
ref,
|
||||
groupRef,
|
||||
size,
|
||||
fontSize,
|
||||
placeholder,
|
||||
icon: Icon,
|
||||
shortcut,
|
||||
trailingSlot,
|
||||
inputDataTestId,
|
||||
iconClassName,
|
||||
inputClassName,
|
||||
wrapperClassName,
|
||||
tooltipClassName,
|
||||
tooltip,
|
||||
}}
|
||||
/>
|
||||
|
||||
{hint && (
|
||||
<HintText className={hintClassName} isInvalid={isInvalid}>
|
||||
{hint}
|
||||
</HintText>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TextField>
|
||||
);
|
||||
};
|
||||
|
||||
Input.displayName = 'Input';
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
isRequired?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipDescription?: string;
|
||||
ref?: Ref<HTMLLabelElement>;
|
||||
}
|
||||
|
||||
export const Label = ({
|
||||
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(
|
||||
'tw:flex tw:cursor-default tw:items-center tw:gap-0.5 tw:text-sm tw:font-medium tw:text-secondary',
|
||||
className
|
||||
)}>
|
||||
{props.children}
|
||||
|
||||
<span
|
||||
className={cx(
|
||||
'tw:hidden tw:text-error-primary',
|
||||
isRequired && 'tw:block',
|
||||
typeof isRequired === 'undefined' && 'tw:group-required:block'
|
||||
)}>
|
||||
*
|
||||
</span>
|
||||
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
description={tooltipDescription}
|
||||
placement="top"
|
||||
title={tooltip}>
|
||||
<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.
|
||||
className="tw:flex tw:items-center tw:cursor-pointer tw:text-fg-quaternary tw:transition tw:duration-200 tw:hover:text-fg-quaternary_hover tw:focus:text-fg-quaternary_hover"
|
||||
isDisabled={false}>
|
||||
<HelpCircle className="tw:size-4" />
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
)}
|
||||
</AriaLabel>
|
||||
);
|
||||
};
|
||||
|
||||
Label.displayName = 'Label';
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { HintText } from '@/components/base/input/hint-text';
|
||||
import { Label } from '@/components/base/input/label';
|
||||
import { TextAreaBase } from '@/components/base/textarea/textarea';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { Eye, EyeOff, UploadCloud01 } from '@untitledui/icons';
|
||||
import { useState } from 'react';
|
||||
import { FileTrigger } from 'react-aria-components';
|
||||
import { InputBase, TextField, type InputBaseProps } from './input';
|
||||
|
||||
interface BaseProps {
|
||||
label?: string;
|
||||
hint?: React.ReactNode;
|
||||
hideRequiredIndicator?: boolean;
|
||||
/** When true, shows an upload link-button in the label trailing area. */
|
||||
allowUpload?: boolean;
|
||||
/** Accepted file extensions forwarded to the file picker (e.g. ['.pem', '.key']). */
|
||||
acceptedFileTypes?: string[];
|
||||
/** Label for the upload button. Defaults to "Upload key file". */
|
||||
uploadLabel?: string;
|
||||
/** When true, renders a multiline TextAreaBase instead of single-line InputBase. */
|
||||
multiline?: boolean;
|
||||
/** Number of visible rows for the textarea (only used when multiline=true). */
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface PasswordInputProps
|
||||
extends Omit<InputBaseProps, 'label' | 'hint' | 'trailingSlot'>,
|
||||
BaseProps {}
|
||||
|
||||
export const PasswordInput = ({
|
||||
size = 'sm',
|
||||
fontSize = 'sm',
|
||||
placeholder,
|
||||
label,
|
||||
hint,
|
||||
hideRequiredIndicator,
|
||||
allowUpload = false,
|
||||
acceptedFileTypes,
|
||||
uploadLabel = 'Upload key file',
|
||||
multiline = false,
|
||||
rows,
|
||||
ref,
|
||||
groupRef,
|
||||
iconClassName,
|
||||
inputClassName,
|
||||
wrapperClassName,
|
||||
tooltipClassName,
|
||||
// Everything else (value, onChange, isRequired, isDisabled, isInvalid, id, etc.)
|
||||
// goes into ...props so they reach TextField/AriaTextField
|
||||
...props
|
||||
}: PasswordInputProps) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { onChange, isRequired, isDisabled, isInvalid, value } = props;
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (files?.[0] && onChange) {
|
||||
files[0].text().then((content) => onChange(content));
|
||||
}
|
||||
};
|
||||
|
||||
const revealButton = (
|
||||
<button
|
||||
className={cx(
|
||||
'tw:flex tw:cursor-pointer tw:items-center tw:text-fg-quaternary tw:transition-colors tw:duration-200 hover:tw:text-fg-quaternary_hover tw:border-0 tw:bg-transparent tw:p-0',
|
||||
multiline ? 'tw:absolute tw:right-2 tw:top-2' : 'tw:absolute tw:right-3'
|
||||
)}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
);
|
||||
|
||||
// labelRow is rendered OUTSIDE TextField so FileTrigger's hidden <input type="file">
|
||||
// never enters AriaTextField's InputContext and doesn't receive the controlled value.
|
||||
const labelRow =
|
||||
label || allowUpload ? (
|
||||
<div className="tw:flex tw:w-full tw:items-center tw:justify-between tw:gap-2">
|
||||
{label && (
|
||||
<Label
|
||||
isRequired={
|
||||
hideRequiredIndicator ? !hideRequiredIndicator : isRequired
|
||||
}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{allowUpload && (
|
||||
<FileTrigger
|
||||
acceptedFileTypes={acceptedFileTypes}
|
||||
onSelect={handleFileSelect}>
|
||||
<Button
|
||||
color="link-color"
|
||||
iconLeading={<UploadCloud01 data-icon size={12} />}
|
||||
isDisabled={isDisabled}
|
||||
size="xs"
|
||||
type="button">
|
||||
{uploadLabel}
|
||||
</Button>
|
||||
</FileTrigger>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="tw:flex tw:h-max tw:w-full tw:flex-col tw:items-start tw:justify-start tw:gap-1.5">
|
||||
{labelRow}
|
||||
<TextField
|
||||
aria-label={label ? undefined : placeholder}
|
||||
{...props}
|
||||
value={value ?? ''}>
|
||||
{multiline ? (
|
||||
<div className="tw:relative tw:w-full">
|
||||
<TextAreaBase
|
||||
className={cx(
|
||||
'tw:pr-8',
|
||||
!showPassword && 'tw:[-webkit-text-security:disc]',
|
||||
inputClassName
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
/>
|
||||
{revealButton}
|
||||
</div>
|
||||
) : (
|
||||
<InputBase
|
||||
fontSize={fontSize}
|
||||
groupRef={groupRef}
|
||||
iconClassName={iconClassName}
|
||||
inputClassName={cx('tw:pr-9', inputClassName)}
|
||||
isDisabled={isDisabled}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
size={size}
|
||||
tooltipClassName={tooltipClassName}
|
||||
trailingSlot={revealButton}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
wrapperClassName={wrapperClassName}
|
||||
/>
|
||||
)}
|
||||
</TextField>
|
||||
{hint && <HintText isInvalid={isInvalid}>{hint}</HintText>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PasswordInput.displayName = 'PasswordInput';
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
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: 'tw:text-sm tw:font-semibold tw:text-primary',
|
||||
labelClass: 'tw:text-xs tw:font-medium tw:text-tertiary',
|
||||
halfCircleTextPosition: 'tw:absolute tw:bottom-0.5 tw:text-center',
|
||||
},
|
||||
xs: {
|
||||
strokeWidth: 16,
|
||||
radius: 72,
|
||||
valueClass: 'tw:text-display-xs tw:font-semibold tw:text-primary',
|
||||
labelClass: 'tw:text-xs tw:font-medium tw:text-tertiary',
|
||||
halfCircleTextPosition: 'tw:absolute tw:bottom-0.5 tw:text-center',
|
||||
},
|
||||
sm: {
|
||||
strokeWidth: 20,
|
||||
radius: 90,
|
||||
valueClass: 'tw:text-display-sm tw:font-semibold tw:text-primary',
|
||||
labelClass: 'tw:text-xs tw:font-medium tw:text-tertiary',
|
||||
halfCircleTextPosition: 'tw:absolute tw:bottom-1 tw:text-center',
|
||||
},
|
||||
md: {
|
||||
strokeWidth: 24,
|
||||
radius: 108,
|
||||
valueClass: 'tw:text-display-md tw:font-semibold tw:text-primary',
|
||||
labelClass: 'tw:text-sm tw:font-medium tw:text-tertiary',
|
||||
halfCircleTextPosition: 'tw:absolute tw:bottom-1 tw:text-center',
|
||||
},
|
||||
lg: {
|
||||
strokeWidth: 28,
|
||||
radius: 126,
|
||||
valueClass: 'tw:text-display-lg tw:font-semibold tw:text-primary',
|
||||
labelClass: 'tw:text-sm tw:font-medium tw:text-tertiary',
|
||||
halfCircleTextPosition: 'tw:absolute tw:bottom-0 tw: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="tw:flex tw:flex-col tw:items-center tw:gap-0.5">
|
||||
<div
|
||||
aria-valuemax={max}
|
||||
aria-valuemin={min}
|
||||
aria-valuenow={value}
|
||||
className="tw:relative tw:flex tw:w-max tw:items-center tw:justify-center"
|
||||
role="progressbar">
|
||||
<svg
|
||||
className="tw:-rotate-90"
|
||||
height={height}
|
||||
viewBox={viewBox}
|
||||
width={width}>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
className="tw:stroke-bg-quaternary"
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
fill="none"
|
||||
pathLength="100"
|
||||
r={radius}
|
||||
strokeDasharray="100"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
|
||||
{/* Foreground circle */}
|
||||
<circle
|
||||
className="tw:stroke-fg-brand-primary"
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
fill="none"
|
||||
pathLength="100"
|
||||
r={radius}
|
||||
strokeDasharray="100"
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
{label && size !== 'xxs' ? (
|
||||
<div className="tw:absolute tw: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="tw:flex tw:flex-col tw:items-center tw:gap-0.5">
|
||||
<div
|
||||
aria-valuemax={max}
|
||||
aria-valuemin={min}
|
||||
aria-valuenow={value}
|
||||
className="tw:relative tw:flex tw:w-max tw:items-center tw:justify-center"
|
||||
role="progressbar">
|
||||
<svg height={height} viewBox={viewBox} width={width}>
|
||||
{/* Background half-circle */}
|
||||
<circle
|
||||
className="tw:stroke-bg-quaternary"
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
fill="none"
|
||||
pathLength="100"
|
||||
r={radius}
|
||||
strokeDasharray="100"
|
||||
strokeDashoffset="-50"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
|
||||
{/* Foreground half-circle */}
|
||||
<circle
|
||||
className="tw:origin-center tw:-scale-x-100 tw:stroke-fg-brand-primary"
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
fill="none"
|
||||
pathLength="100"
|
||||
r={radius}
|
||||
strokeDasharray="100"
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
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
|
||||
aria-valuemax={max}
|
||||
aria-valuemin={min}
|
||||
aria-valuenow={value}
|
||||
className={cx(
|
||||
'tw:h-2 tw:w-full tw:overflow-hidden tw:rounded-md tw:bg-quaternary',
|
||||
className
|
||||
)}
|
||||
role="progressbar">
|
||||
<div
|
||||
// Use transform instead of width to avoid layout thrashing (and for smoother animation)
|
||||
className={cx(
|
||||
'tw:size-full tw:rounded-md tw:bg-fg-brand-primary tw:transition tw:duration-75 tw:ease-linear',
|
||||
progressClassName
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||
/>
|
||||
</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
|
||||
className={className}
|
||||
max={max}
|
||||
min={min}
|
||||
progressClassName={progressClassName}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (labelPosition) {
|
||||
case 'right':
|
||||
return (
|
||||
<div className="tw:flex tw:items-center tw:gap-3">
|
||||
{baseProgressBar}
|
||||
<span className="tw:shrink-0 tw:text-sm tw:font-medium tw:text-secondary tw:tabular-nums">
|
||||
{formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'bottom':
|
||||
return (
|
||||
<div className="tw:flex tw:flex-col tw:items-end tw:gap-2">
|
||||
{baseProgressBar}
|
||||
<span className="tw:text-sm tw:font-medium tw:text-secondary tw:tabular-nums">
|
||||
{formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'top-floating':
|
||||
return (
|
||||
<div className="tw:relative tw:flex tw:flex-col tw:items-end tw:gap-2">
|
||||
{baseProgressBar}
|
||||
<div
|
||||
className="tw:absolute tw:-top-2 tw:-translate-x-1/2 tw:-translate-y-full tw:rounded-lg tw:bg-primary_alt tw:px-3 tw:py-2 tw:shadow-lg tw:ring-1 tw:ring-secondary_alt"
|
||||
style={{ left: `${percentage}%` }}>
|
||||
<div className="tw:text-xs tw:font-semibold tw:text-secondary tw:tabular-nums">
|
||||
{formattedValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'bottom-floating':
|
||||
return (
|
||||
<div className="tw:relative tw:flex tw:flex-col tw:items-end tw:gap-2">
|
||||
{baseProgressBar}
|
||||
<div
|
||||
className="tw:absolute tw:-bottom-2 tw:-translate-x-1/2 tw:translate-y-full tw:rounded-lg tw:bg-primary_alt tw:px-3 tw:py-2 tw:shadow-lg tw:ring-1 tw:ring-secondary_alt"
|
||||
style={{ left: `${percentage}%` }}>
|
||||
<div className="tw:text-xs tw:font-semibold tw:text-secondary">
|
||||
{formattedValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
// Fallback or default case, could render the basic progress bar or throw an error
|
||||
return baseProgressBar;
|
||||
}
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
export const CircleProgressBar = (props: {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}) => {
|
||||
const { value, min = 0, max = 100 } = props;
|
||||
const range = max - min;
|
||||
let rawPercentage: number;
|
||||
|
||||
if (range <= 0) {
|
||||
rawPercentage = value >= max ? 100 : 0;
|
||||
} else {
|
||||
rawPercentage = ((value - min) * 100) / range;
|
||||
}
|
||||
|
||||
const percentage = Math.min(100, Math.max(0, rawPercentage));
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-valuemax={max}
|
||||
aria-valuemin={min}
|
||||
aria-valuenow={value}
|
||||
className="tw:relative tw:flex tw:w-max tw:items-center tw:justify-center"
|
||||
role="progressbar">
|
||||
<span className="tw:absolute tw:text-sm tw:font-medium tw:text-primary">
|
||||
{percentage}%
|
||||
</span>
|
||||
<svg className="tw:size-16 tw:-rotate-90" viewBox="0 0 60 60">
|
||||
<circle
|
||||
className="tw:stroke-bg-quaternary"
|
||||
cx="30"
|
||||
cy="30"
|
||||
fill="none"
|
||||
r="26"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
<circle
|
||||
className="tw:stroke-fg-brand-primary"
|
||||
cx="30"
|
||||
cy="30"
|
||||
fill="none"
|
||||
pathLength="100"
|
||||
r="26"
|
||||
strokeDasharray="100"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="6"
|
||||
style={{
|
||||
strokeDashoffset: `calc(100 - ${percentage})`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
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(
|
||||
'tw:flex tw:size-4 tw:min-h-4 tw:min-w-4 tw:cursor-pointer tw:appearance-none tw:items-center tw:justify-center tw:rounded-full tw:bg-primary tw:ring-1 tw:ring-primary tw:ring-inset',
|
||||
size === 'md' && 'tw:size-5 tw:min-h-5 tw:min-w-5',
|
||||
isSelected && !isDisabled && 'tw:bg-brand-solid tw:ring-brand-solid',
|
||||
isDisabled &&
|
||||
'tw:cursor-not-allowed tw:border-disabled tw:bg-disabled_subtle',
|
||||
isFocusVisible &&
|
||||
'tw:outline-2 tw:outline-offset-2 tw:outline-focus-ring',
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={cx(
|
||||
'tw:size-1.5 tw:rounded-full tw:bg-fg-white tw:opacity-0 tw:transition-inherit-all',
|
||||
size === 'md' && 'tw:size-2',
|
||||
isDisabled && 'tw:bg-fg-disabled_subtle',
|
||||
isSelected && 'tw: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: 'tw:gap-2',
|
||||
textWrapper: '',
|
||||
label: 'tw:text-sm tw:font-medium',
|
||||
hint: 'tw:text-sm',
|
||||
},
|
||||
md: {
|
||||
root: 'tw:gap-3',
|
||||
textWrapper: 'tw:gap-0.5',
|
||||
label: 'tw:text-md tw:font-medium',
|
||||
hint: 'tw:text-md',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaRadio
|
||||
{...ariaRadioProps}
|
||||
className={(renderProps) =>
|
||||
cx(
|
||||
'tw:flex tw:items-start',
|
||||
renderProps.isDisabled && 'tw:cursor-not-allowed',
|
||||
sizes[size].root,
|
||||
typeof className === 'function' ? className(renderProps) : className
|
||||
)
|
||||
}>
|
||||
{({ isSelected, isDisabled, isFocusVisible }) => (
|
||||
<>
|
||||
<RadioButtonBase
|
||||
className={label || hint ? 'tw:mt-0.5' : ''}
|
||||
isDisabled={isDisabled}
|
||||
isFocusVisible={isFocusVisible}
|
||||
isSelected={isSelected}
|
||||
size={size}
|
||||
/>
|
||||
{(label || hint) && (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:inline-flex tw:flex-col',
|
||||
sizes[size].textWrapper
|
||||
)}>
|
||||
{label && (
|
||||
<p
|
||||
className={cx(
|
||||
'tw:text-secondary tw:select-none',
|
||||
sizes[size].label
|
||||
)}>
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
{hint && (
|
||||
<span
|
||||
className={cx('tw: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('tw:flex tw:flex-col tw:gap-4', className)}>
|
||||
{children}
|
||||
</AriaRadioGroup>
|
||||
</RadioGroupContext.Provider>
|
||||
);
|
||||
};
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import type { ReactNode, RefObject, RefAttributes } from 'react';
|
||||
import { isValidElement, useContext, useMemo, useRef } from 'react';
|
||||
import { ChevronDown, SearchLg as SearchIcon } from '@untitledui/icons';
|
||||
import type {
|
||||
ComboBoxProps as AriaComboBoxProps,
|
||||
GroupProps as AriaGroupProps,
|
||||
ListBoxProps as AriaListBoxProps,
|
||||
} from 'react-aria-components';
|
||||
import {
|
||||
Button as AriaButton,
|
||||
ComboBox as AriaComboBox,
|
||||
Group as AriaGroup,
|
||||
Input as AriaInput,
|
||||
ListBox as AriaListBox,
|
||||
ComboBoxStateContext,
|
||||
} 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 { Popover } from '@/components/base/select/popover';
|
||||
import {
|
||||
type SelectCommonProps,
|
||||
SelectContext,
|
||||
SelectEmptyState,
|
||||
type SelectItemType,
|
||||
sizes,
|
||||
} from '@/components/base/select/select';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { isReactComponent } from '@/utils/is-react-component';
|
||||
import { fontSizeClass } from '@/utils/tailwindClasses';
|
||||
|
||||
interface ComboBoxProps
|
||||
extends Omit<AriaComboBoxProps<SelectItemType>, 'children' | 'items'>,
|
||||
RefAttributes<HTMLDivElement>,
|
||||
SelectCommonProps {
|
||||
shortcut?: boolean;
|
||||
items?: SelectItemType[];
|
||||
popoverClassName?: string;
|
||||
shortcutClassName?: string;
|
||||
showSearchIcon?: boolean;
|
||||
children: AriaListBoxProps<SelectItemType>['children'];
|
||||
}
|
||||
|
||||
interface ComboBoxValueProps extends AriaGroupProps {
|
||||
size: 'sm' | 'md';
|
||||
fontSize: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
inputRef: RefObject<HTMLInputElement>;
|
||||
triggerRef: RefObject<HTMLDivElement>;
|
||||
showSearchIcon: boolean;
|
||||
shortcut: boolean;
|
||||
placeholder?: string;
|
||||
shortcutClassName?: string;
|
||||
}
|
||||
|
||||
const ComboBoxValue = ({
|
||||
size,
|
||||
fontSize,
|
||||
inputRef,
|
||||
triggerRef,
|
||||
showSearchIcon,
|
||||
shortcut,
|
||||
placeholder,
|
||||
shortcutClassName,
|
||||
onPointerDown,
|
||||
...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];
|
||||
const Icon = value?.icon;
|
||||
const inputPadding = cx(
|
||||
showSearchIcon ? 'tw:pl-10' : size === 'sm' ? 'tw:pl-3' : 'tw:pl-3.5',
|
||||
shortcut ? 'tw:pr-16' : size === 'sm' ? 'tw:pr-9' : 'tw:pr-10'
|
||||
);
|
||||
|
||||
const handlePointerDown: AriaGroupProps['onPointerDown'] = (event) => {
|
||||
onPointerDown?.(event);
|
||||
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
state?.open(null, 'input');
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaGroup
|
||||
{...otherProps}
|
||||
className={({ isFocusWithin, isDisabled }) =>
|
||||
cx(
|
||||
'tw:relative tw:flex tw:w-full tw:cursor-text tw:items-center tw:gap-2 tw:rounded-lg tw:bg-primary tw:shadow-xs tw:ring-1 tw:ring-primary tw:outline-hidden tw:transition-shadow tw:duration-100 tw:ease-linear tw:ring-inset',
|
||||
isDisabled && 'tw:cursor-not-allowed tw:bg-disabled_subtle',
|
||||
isFocusWithin && 'tw:ring-2 tw:ring-brand',
|
||||
size === 'sm' ? 'tw:min-h-9' : 'tw:min-h-10',
|
||||
sizes[size].root
|
||||
)
|
||||
}
|
||||
ref={triggerRef}
|
||||
onPointerDown={handlePointerDown}>
|
||||
{({ isDisabled }) => (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:z-0 tw:flex tw:w-full tw:items-center tw:gap-2 tw:truncate',
|
||||
shortcut ? 'tw:pr-12' : 'tw:pr-5'
|
||||
)}>
|
||||
{showSearchIcon && (
|
||||
<SearchIcon className="tw:size-5 tw:shrink-0 tw:text-fg-quaternary" />
|
||||
)}
|
||||
{inputValue && (
|
||||
<span className="tw:flex tw:w-full tw:items-center tw:gap-2 tw:truncate">
|
||||
{/* Match Select value rendering: avatar wins, then component icons, then rendered React nodes. */}
|
||||
{value?.avatarUrl ? (
|
||||
<Avatar alt={value.label} size="xs" src={value.avatarUrl} />
|
||||
) : isReactComponent(Icon) ? (
|
||||
<Icon data-icon aria-hidden="true" />
|
||||
) : isValidElement(Icon) ? (
|
||||
(Icon as ReactNode)
|
||||
) : null}
|
||||
|
||||
<section className="tw:flex tw:w-full tw:gap-2 tw:truncate">
|
||||
<p
|
||||
className={cx(
|
||||
'tw:truncate tw:text-primary',
|
||||
fontSizeClass[fontSize],
|
||||
isDisabled && 'tw:text-disabled'
|
||||
)}>
|
||||
{first}
|
||||
</p>
|
||||
{last && (
|
||||
<p
|
||||
className={cx(
|
||||
'tw:text-tertiary',
|
||||
fontSizeClass[fontSize],
|
||||
isDisabled && 'tw:text-disabled'
|
||||
)}>
|
||||
{last}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AriaInput
|
||||
className={cx(
|
||||
'tw:absolute tw:inset-0 tw:z-10 tw:size-full tw:appearance-none tw:rounded-[inherit] tw:bg-transparent tw:text-transparent tw:caret-alpha-black/90 tw:placeholder:text-placeholder tw:focus:outline-hidden tw:disabled:cursor-not-allowed tw:disabled:text-disabled tw:disabled:placeholder:text-disabled',
|
||||
inputPadding,
|
||||
fontSizeClass[fontSize]
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
/>
|
||||
|
||||
{shortcut ? (
|
||||
<div
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:absolute tw:inset-y-0.5 tw:right-0.5 tw:z-20 tw:flex tw:items-center tw:rounded-r-[inherit] tw:bg-linear-to-r tw:from-transparent tw:to-bg-primary tw:to-40% tw:pl-8',
|
||||
isDisabled && 'tw:to-bg-disabled_subtle',
|
||||
sizes[size].shortcut,
|
||||
shortcutClassName
|
||||
)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:pointer-events-none tw:rounded tw:px-1 tw:py-px tw:text-xs tw:font-medium tw:text-quaternary tw:ring-1 tw:ring-secondary tw:select-none tw:ring-inset',
|
||||
isDisabled && 'tw:bg-transparent tw:text-disabled'
|
||||
)}>
|
||||
⌘K
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<AriaButton
|
||||
aria-label="Show options"
|
||||
className={cx(
|
||||
'tw:absolute tw:inset-y-0 tw:right-0 tw:z-20 tw:flex tw:items-center tw:justify-center tw:rounded-r-[inherit] tw:text-fg-quaternary tw:outline-hidden tw:disabled:cursor-not-allowed tw:disabled:text-fg-disabled',
|
||||
size === 'sm' ? 'tw:w-9' : 'tw:w-10'
|
||||
)}>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
'tw:shrink-0',
|
||||
size === 'sm' ? 'tw:size-4 tw:stroke-[2.5px]' : 'tw:size-5'
|
||||
)}
|
||||
/>
|
||||
</AriaButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AriaGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComboBox = ({
|
||||
placeholder = 'Search',
|
||||
shortcut = true,
|
||||
size = 'sm',
|
||||
fontSize = 'md',
|
||||
showSearchIcon = true,
|
||||
children,
|
||||
items,
|
||||
shortcutClassName,
|
||||
emptyState,
|
||||
...otherProps
|
||||
}: ComboBoxProps) => {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectContextValue = useMemo(
|
||||
() => ({ fontSize, size }),
|
||||
[fontSize, size]
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={selectContextValue}>
|
||||
<AriaComboBox menuTrigger="focus" {...otherProps}>
|
||||
{(state) => (
|
||||
<div className="tw:flex tw:flex-col tw:gap-1.5">
|
||||
{otherProps.label && (
|
||||
<Label isRequired={state.isRequired} tooltip={otherProps.tooltip}>
|
||||
{otherProps.label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<ComboBoxValue
|
||||
fontSize={fontSize}
|
||||
inputRef={inputRef}
|
||||
placeholder={placeholder}
|
||||
shortcut={shortcut}
|
||||
shortcutClassName={shortcutClassName}
|
||||
showSearchIcon={showSearchIcon}
|
||||
size={size}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
className={otherProps.popoverClassName}
|
||||
size={size}
|
||||
triggerRef={triggerRef}>
|
||||
<AriaListBox
|
||||
className="tw:size-full tw:outline-hidden"
|
||||
items={items}
|
||||
renderEmptyState={() => (
|
||||
<SelectEmptyState emptyState={emptyState} />
|
||||
)}>
|
||||
{children}
|
||||
</AriaListBox>
|
||||
</Popover>
|
||||
|
||||
{otherProps.hint && (
|
||||
<HintText isInvalid={state.isInvalid}>{otherProps.hint}</HintText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AriaComboBox>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user