Compare commits
162 Commits
Author | SHA1 | Date | |
---|---|---|---|
f122310c37 | |||
ef2bdbf3e0 | |||
a4da53a890 | |||
90a458268f | |||
fe1a9afeb0 | |||
a6af5622dd | |||
81854841ce | |||
57f9a9b118 | |||
a90cec770c | |||
371453b544 | |||
054568a2ec | |||
4b6724ed90 | |||
8728df1f60 | |||
3ccfa3b519 | |||
d10fec90ad | |||
3d55a98821 | |||
96d64e57bb | |||
e1c5e09872 | |||
67207d7daf | |||
617b7be249 | |||
bfbce754d9 | |||
00ebabe8b3 | |||
71751f7e22 | |||
878016d3cc | |||
79b41e45ff | |||
c2178ef473 | |||
086cbe77a9 | |||
c8f1912bae | |||
05953bbebe | |||
ad62a4ed86 | |||
d0af00db63 | |||
6dcf44fc60 | |||
9113b8e095 | |||
13cc43de2e | |||
7251bf2400 | |||
ef17f6f1ce | |||
bef29ebf19 | |||
bc2020f560 | |||
6c14cb1e51 | |||
14619f5311 | |||
b56242c497 | |||
c08a81badd | |||
b5e35aa905 | |||
61a263addb | |||
13f1d9def3 | |||
8c2986700e | |||
db5c36a3bf | |||
8465837f63 | |||
366ac90eb8 | |||
ec6192d32b | |||
2f9e155cbe | |||
508d58fa71 | |||
9d0188eacc | |||
2e441ff1e5 | |||
80c7542803 | |||
ec574d3841 | |||
52996d3a76 | |||
ce8dc422a4 | |||
952e235fda | |||
0916c483d4 | |||
321b47ab3f | |||
985a2b5297 | |||
e9d9bef4bf | |||
232c832b25 | |||
98dd58febb | |||
da628710a4 | |||
eb8ea83336 | |||
78ea14c568 | |||
46430bec41 | |||
0e73321c3a | |||
9573fd1e1a | |||
5276eb8ba9 | |||
7aa0618ae3 | |||
c52a8843e8 | |||
c1289b63ea | |||
113366d27c | |||
c1e930ba59 | |||
7228ab794f | |||
692c5fb7b3 | |||
ba04aa7cf5 | |||
780eed20d5 | |||
4d33e78454 | |||
8e9b178f5e | |||
2414b70ca5 | |||
6ed20835f6 | |||
65a6597a8e | |||
8e6aa36dab | |||
a47e9b8427 | |||
f4f2f2b820 | |||
fd7317e597 | |||
180cae69af | |||
5c12c00cec | |||
21a2bfc3d0 | |||
d9d6d033f9 | |||
5debb80330 | |||
3b88ad4e51 | |||
ffba99a229 | |||
d930c44bb0 | |||
a1e7baa6c4 | |||
871e1b8aed | |||
a5aa709656 | |||
![]() |
5c8fef9b24 | ||
bfb044fd43 | |||
7f2628eedc | |||
02b2c1ac2d | |||
024ae50738 | |||
ea9b70bcc6 | |||
78fe5d9b0f | |||
17ea42fe48 | |||
ab95442de1 | |||
ecaba351a3 | |||
e9c7281c33 | |||
76e2866bc9 | |||
6d3f29a614 | |||
9b0b37ec01 | |||
217410a507 | |||
6f637e51ba | |||
9f28779745 | |||
5a41b84c9a | |||
bba1a80550 | |||
1902b9606a | |||
d721aa290f | |||
272fc89a92 | |||
66232b2b9a | |||
2d68a5051f | |||
36da69240c | |||
4148b903e3 | |||
46bdb3df98 | |||
0be21e2a8d | |||
f6d2e2335d | |||
de8a1129da | |||
9709f0e381 | |||
b3ebcb45ee | |||
28d5f409f8 | |||
7d2453b4cf | |||
c1d5c5d06b | |||
0072836bfc | |||
1cad20eaa2 | |||
7dd3bf7d9e | |||
6c35e54875 | |||
22ab752b75 | |||
2a53a2d3e9 | |||
89950524f4 | |||
395c2f8ed1 | |||
1f1ca76b6d | |||
139d02eb9b | |||
c201df67ce | |||
27fcddcc1f | |||
0f8c999de7 | |||
47cfd907b9 | |||
6263a99b9a | |||
c46163f525 | |||
d72fd9cd91 | |||
482603c378 | |||
ebfcd60594 | |||
e15fe48ec8 | |||
5254a49ebe | |||
aa074d16c1 | |||
bf13cf9e57 | |||
fc525d737c | |||
3edabaddf9 | |||
1d6e892596 |
62
.github/workflows/lint.yml
vendored
Normal file
62
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
name: lint-and-check
|
||||
|
||||
on: [ pull_request,push]
|
||||
|
||||
jobs:
|
||||
cli-check:
|
||||
runs-on: ubuntu-latest
|
||||
name: CLI Check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: ${{ steps.detect-package-manager.outputs.manager }}
|
||||
- name: Enable Corepack
|
||||
run: |
|
||||
corepack enable
|
||||
- name: Restore cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .yarn/cache
|
||||
key: ${{ runner.os }}-ui-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn install
|
||||
- name: Lint
|
||||
run: yarn cli lint --write
|
||||
- name: Build
|
||||
run: yarn cli build
|
||||
|
||||
component-check:
|
||||
runs-on: ubuntu-latest
|
||||
name: Component Check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: ${{ steps.detect-package-manager.outputs.manager }}
|
||||
- name: Enable Corepack
|
||||
run: |
|
||||
corepack enable
|
||||
- name: Restore cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .yarn/cache
|
||||
key: ${{ runner.os }}-ui-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ui-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn install
|
||||
- name: Lint
|
||||
run: yarn react lint --write
|
||||
- name: TypeScript Compile
|
||||
run: yarn react tsc
|
7
.idea/biome.xml
generated
Normal file
7
.idea/biome.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="BiomeSettings">
|
||||
<option name="applySafeFixesOnSave" value="true" />
|
||||
<option name="formatOnSave" value="true" />
|
||||
</component>
|
||||
</project>
|
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@ -1,7 +1,6 @@
|
||||
{
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["[cC]olors\\s*\\=\\s*{([^]*(?=}))}", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||
]
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["[cC]olors\\s*\\=\\s*{([^]*(?=}))}", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||
]
|
||||
}
|
||||
|
||||
|
20
.zed/settings.json
Normal file
20
.zed/settings.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"lsp": {
|
||||
"tailwindcss-language-server": {
|
||||
"settings": {
|
||||
"experimental": {
|
||||
"classRegex": [
|
||||
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["[cC]olors\\s*\\=\\s*{([^]*(?=}))}", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"format_on_save": "on"
|
||||
}
|
83
README.md
83
README.md
@ -1,11 +1,58 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/pswui/.github/main/Square%20Logo%20Rounded.png" width="200" height="200" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Meet our beautiful <a href="https://ui.psw.kr">web documentation</a>.
|
||||
</p>
|
||||
|
||||
# PSW-UI
|
||||
|
||||
Shared UI Component Repository.
|
||||
> Build your components in isolation
|
||||
|
||||
My goal is to create **fully typesafe**, **highly customizable** component with **minimum complexity**.
|
||||
**There are a lot of component libraries out there, but why it install so many things?**
|
||||
|
||||
Meet our [web documentation](https://ui.psw.kr)!
|
||||
## Introduction
|
||||
|
||||
> Beautifully designed, easily copy & pastable, fully customizable - that means it only depends on few dependencies.
|
||||
|
||||
This is **UI kit**, a collection of re-usable components that you can copy and paste into your apps.
|
||||
This UI kit is inspired by [shadcn/ui](https://ui.shadcn.com/) - but it is more customizable.
|
||||
|
||||
**More customizable?**
|
||||
|
||||
shadcn/ui depends on a lot of packages to provide functionality to its components.
|
||||
Radix UI, React DayPicker, Embla Carousel...
|
||||
|
||||
I'm not saying it's a bad thing.
|
||||
|
||||
But when you depends on a lot of package - you easily mess up your package.json, and you can't even edit a single feature.
|
||||
The only thing you can customize is **style**.
|
||||
|
||||
If there's a bug that needs to be fixed quickly, or a feature that doesn't work the way you want it to,
|
||||
you'll want to tweak it to your liking. This is where relying on a lot of packages can be poison.
|
||||
|
||||
PSW/UI solves this - by **NOT** depending on any other packages than framework like React, TailwindCSS, and tailwind-merge.
|
||||
|
||||
You can just copy it, and all functionality is contained in a single file.
|
||||
|
||||
## Roadmap
|
||||
|
||||
First of all, this project is alpha.
|
||||
|
||||
You can see a lot of components are missing, and some component is buggy.
|
||||
|
||||
I'm working with this priority:
|
||||
|
||||
1. Adding new component, with stable features.
|
||||
2. Fixing bugs with existing components.
|
||||
3. Make it looks nice.
|
||||
|
||||
Also, there is a [Github README](https://github.com/pswui/ui) for component implementation plans.
|
||||
|
||||
You can see what component is implemented, or planned to be implemented.
|
||||
|
||||
If you have any ideas or suggestions, please let me know in [Github Issues](https://github.com/pswui/ui/issues).
|
||||
|
||||
## Milestones
|
||||
|
||||
@ -15,11 +62,6 @@ Meet our [web documentation](https://ui.psw.kr)!
|
||||
- [ ] FileInput
|
||||
- [ ] ImageInput
|
||||
- [ ] Form
|
||||
- [ ] FormItem
|
||||
- [ ] FormLabel
|
||||
- [ ] FormControl
|
||||
- [ ] FormDescription
|
||||
- [ ] FormMessage
|
||||
- [ ] Textarea
|
||||
- [ ] Accordion
|
||||
- [ ] Alert
|
||||
@ -53,24 +95,35 @@ Meet our [web documentation](https://ui.psw.kr)!
|
||||
- [ ] Toggle
|
||||
- [ ] Toggle Group
|
||||
- [x] Tooltip
|
||||
- Library/Framework Support
|
||||
- [ ] React
|
||||
- [ ] Svelte
|
||||
- CLI
|
||||
- [x] Add
|
||||
- [x] List
|
||||
- [x] Search
|
||||
|
||||
## Building local development environment
|
||||
|
||||
```bash
|
||||
# Corepack - Yarn 4.2.2
|
||||
corepack enable
|
||||
corepack install yarn@4.2.2
|
||||
corepack use yarn@4.2.2
|
||||
|
||||
# Install Packages
|
||||
yarn install
|
||||
|
||||
# Run Storybook
|
||||
yarn workspace react storybook
|
||||
# Script running in workspace
|
||||
yarn react dev # `yarn dev` in react workspace
|
||||
yarn cli build # `yarn build` in cli workspace
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
* `registry.json` - for CLI component registry
|
||||
* `packages/react` - React components
|
||||
* `components` - component files & directories
|
||||
* `lib` - shared utility used by component
|
||||
* `src` - small test area
|
||||
* `packages/cli` - CLI package using [oclif](https://oclif.io)
|
||||
* `src/commands` - command class declaration
|
||||
* `src/components` - React component used by CLI via [ink](https://www.npmjs.com/package/ink)
|
||||
* `src/helpers` - utility functions that helps CLI
|
||||
* `const.ts` - constant & small value builder (like URL) & types & interfaces
|
||||
* `public.ts` - will be exported
|
||||
|
27
biome.json
Normal file
27
biome.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"attributePosition": "multiline",
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space",
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
18
lefthook.yml
Normal file
18
lefthook.yml
Normal file
@ -0,0 +1,18 @@
|
||||
pre-commit:
|
||||
commands:
|
||||
biome_write:
|
||||
glob: "*.{ts,tsx,json}"
|
||||
run: yarn biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} && git update-index --again
|
||||
pre-push:
|
||||
commands:
|
||||
biome_check:
|
||||
glob: "*.{ts,tsx,json}"
|
||||
run: yarn biome check --no-errors-on-unmatched --files-ignore-unknown=true {all_files}
|
||||
react_tsc:
|
||||
glob: "packages/react/*.{ts,tsx}"
|
||||
root: "packages/react/"
|
||||
run: yarn tsc
|
||||
cli_tsc:
|
||||
glob: "packages/cli/*.{ts,tsx}"
|
||||
root: "packages/cli/"
|
||||
run: yarn tsc
|
12
package.json
12
package.json
@ -5,11 +5,9 @@
|
||||
"repository": "https://github.com/pswui/ui",
|
||||
"author": "p-sw <shinwoo.park@psw.kr>",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"components"
|
||||
],
|
||||
"workspaces": ["packages/*", "components"],
|
||||
"scripts": {
|
||||
"postinstall": "lefthook install",
|
||||
"react": "yarn workspace react",
|
||||
"cli": "yarn workspace @psw-ui/cli",
|
||||
"react:build": "yarn workspace react build",
|
||||
@ -17,5 +15,9 @@
|
||||
"cli:build": "yarn workspace @psw-ui/cli build"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha512.c44e283c54e02de9d1da8687025b030078c1b9648d2895a65aab8e64225bfb7becba87e1809fc0b4b6778bbd47a1e2ab6ac647de4c5e383a53a7c17db6c3ff4b"
|
||||
"packageManager": "yarn@4.4.0+sha512.91d93b445d9284e7ed52931369bc89a663414e5582d00eea45c67ddc459a2582919eece27c412d6ffd1bd0793ff35399381cb229326b961798ce4f4cc60ddfdb",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.8.3",
|
||||
"lefthook": "^1.6.18"
|
||||
}
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
/dist
|
@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["oclif", "oclif-typescript", "prettier"]
|
||||
}
|
@ -1,10 +1,6 @@
|
||||
{
|
||||
"require": [
|
||||
"ts-node/register"
|
||||
],
|
||||
"watch-extensions": [
|
||||
"ts"
|
||||
],
|
||||
"require": ["ts-node/register"],
|
||||
"watch-extensions": ["ts"],
|
||||
"recursive": true,
|
||||
"reporter": "spec",
|
||||
"timeout": 60000,
|
||||
|
@ -1 +0,0 @@
|
||||
"@oclif/prettier-config"
|
@ -20,7 +20,7 @@ $ npm install -g @psw-ui/cli
|
||||
$ pswui COMMAND
|
||||
running command...
|
||||
$ pswui (--version)
|
||||
@psw-ui/cli/0.2.0 linux-x64 node-v20.13.1
|
||||
@psw-ui/cli/0.5.0 linux-x64 node-v20.13.1
|
||||
$ pswui --help [COMMAND]
|
||||
USAGE
|
||||
$ pswui COMMAND
|
||||
@ -49,7 +49,7 @@ FLAGS
|
||||
-c, --components=<value> place for installation of components
|
||||
-f, --force override the existing file
|
||||
-p, --config=<value> path to config
|
||||
-r, --registry=<value> override registry ur
|
||||
-r, --branch=<value> use other branch instead of main
|
||||
-s, --shared=<value> place for installation of shared.ts
|
||||
|
||||
DESCRIPTION
|
||||
@ -59,7 +59,7 @@ EXAMPLES
|
||||
$ pswui add
|
||||
```
|
||||
|
||||
_See code: [packages/cli/src/commands/add.tsx](https://github.com/pswui/ui/blob/cli@0.4.1/packages/cli/src/commands/add.tsx)_
|
||||
_See code: [packages/cli/src/commands/add.tsx](https://github.com/pswui/ui/blob/cli@0.5.0/packages/cli/src/commands/add.tsx)_
|
||||
|
||||
## `pswui help [COMMAND]`
|
||||
|
||||
@ -91,7 +91,7 @@ USAGE
|
||||
|
||||
FLAGS
|
||||
-p, --config=<value> path to config
|
||||
-r, --registry=<value> override registry url
|
||||
-r, --branch=<value> use other branch instead of main
|
||||
-u, --url include component file URL
|
||||
|
||||
DESCRIPTION
|
||||
@ -101,7 +101,7 @@ EXAMPLES
|
||||
$ pswui list
|
||||
```
|
||||
|
||||
_See code: [packages/cli/src/commands/list.ts](https://github.com/pswui/ui/blob/cli@0.4.1/packages/cli/src/commands/list.ts)_
|
||||
_See code: [packages/cli/src/commands/list.ts](https://github.com/pswui/ui/blob/cli@0.5.0/packages/cli/src/commands/list.ts)_
|
||||
|
||||
## `pswui search`
|
||||
|
||||
@ -117,7 +117,7 @@ ARGUMENTS
|
||||
QUERY search query
|
||||
|
||||
FLAGS
|
||||
-r, --registry=<value> override registry url
|
||||
-r, --branch=<value> use other branch instead of main
|
||||
|
||||
DESCRIPTION
|
||||
Search components.
|
||||
@ -126,5 +126,5 @@ EXAMPLES
|
||||
$ pswui search
|
||||
```
|
||||
|
||||
_See code: [packages/cli/src/commands/search.tsx](https://github.com/pswui/ui/blob/cli@0.4.1/packages/cli/src/commands/search.tsx)_
|
||||
_See code: [packages/cli/src/commands/search.tsx](https://github.com/pswui/ui/blob/cli@0.5.0/packages/cli/src/commands/search.tsx)_
|
||||
<!-- commandsstop -->
|
||||
|
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
|
||||
|
||||
// eslint-disable-next-line n/shebang
|
||||
import {execute} from '@oclif/core'
|
||||
import { execute } from "@oclif/core";
|
||||
|
||||
await execute({development: true, dir: import.meta.url})
|
||||
await execute({ development: true, dir: import.meta.url });
|
||||
|
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {execute} from '@oclif/core'
|
||||
import { execute } from "@oclif/core";
|
||||
|
||||
await execute({dir: import.meta.url})
|
||||
await execute({ dir: import.meta.url });
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@psw-ui/cli",
|
||||
"description": "CLI for PSW/UI",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.1",
|
||||
"author": "p-sw",
|
||||
"bin": {
|
||||
"pswui": "./bin/run.js"
|
||||
@ -20,16 +20,11 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oclif/prettier-config": "^0.2.1",
|
||||
"@oclif/test": "^4",
|
||||
"@types/chai": "^4",
|
||||
"@types/ink-divider": "^2.0.4",
|
||||
"@types/node": "^18",
|
||||
"chai": "^4",
|
||||
"eslint": "^8",
|
||||
"eslint-config-oclif": "^5",
|
||||
"eslint-config-oclif-typescript": "^3",
|
||||
"eslint-config-prettier": "^9",
|
||||
"oclif": "^4",
|
||||
"shx": "^0.3.3",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
@ -39,15 +34,9 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"files": [
|
||||
"/bin",
|
||||
"/dist",
|
||||
"/oclif.manifest.json"
|
||||
],
|
||||
"files": ["/bin", "/dist", "/oclif.manifest.json"],
|
||||
"homepage": "https://ui.psw.kr",
|
||||
"keywords": [
|
||||
"oclif"
|
||||
],
|
||||
"keywords": ["oclif"],
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@ -55,9 +44,7 @@
|
||||
"bin": "pswui",
|
||||
"dirname": "pswui",
|
||||
"commands": "./dist/commands",
|
||||
"plugins": [
|
||||
"@oclif/plugin-help"
|
||||
],
|
||||
"plugins": ["@oclif/plugin-help"],
|
||||
"topicSeparator": " ",
|
||||
"topics": {
|
||||
"hello": {
|
||||
@ -68,7 +55,7 @@
|
||||
"repository": "pswui/ui",
|
||||
"scripts": {
|
||||
"build": "shx rm -rf dist && tsc",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint": "biome check --no-errors-on-unmatched",
|
||||
"prepack": "yarn build"
|
||||
},
|
||||
"types": "dist/index.d.ts"
|
||||
|
@ -1,35 +1,43 @@
|
||||
import {Args, Command, Flags} from '@oclif/core'
|
||||
import {loadConfig, validateConfig} from '../helpers/config.js'
|
||||
import {existsSync} from 'node:fs'
|
||||
import {mkdir, writeFile} from 'node:fs/promises'
|
||||
import {join, dirname} from 'node:path'
|
||||
import {getAvailableComponentNames, getComponentRealname, getComponentURL, getRegistry} from '../helpers/registry.js'
|
||||
import ora from 'ora'
|
||||
import React, {ComponentPropsWithoutRef} from 'react'
|
||||
import {render, Box} from 'ink'
|
||||
import {SearchBox} from '../components/SearchBox.js'
|
||||
import {getComponentsInstalled} from '../helpers/path.js'
|
||||
import {Choice} from '../components/Choice.js'
|
||||
import {colorize} from '@oclif/core/ux'
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Args, Command, Flags } from "@oclif/core";
|
||||
import { colorize } from "@oclif/core/ux";
|
||||
import { Box, render } from "ink";
|
||||
import ora from "ora";
|
||||
import React, { type ComponentPropsWithoutRef } from "react";
|
||||
import { Choice } from "../components/Choice.js";
|
||||
import { SearchBox } from "../components/SearchBox.js";
|
||||
import { loadConfig, validateConfig } from "../helpers/config.js";
|
||||
import {
|
||||
checkComponentInstalled,
|
||||
getDirComponentRequiredFiles,
|
||||
} from "../helpers/path.js";
|
||||
import {
|
||||
getComponentURL,
|
||||
getDirComponentURL,
|
||||
getRegistry,
|
||||
} from "../helpers/registry.js";
|
||||
import { safeFetch } from "../helpers/safe-fetcher.js";
|
||||
|
||||
function Generator() {
|
||||
let complete: boolean = false
|
||||
let complete = false;
|
||||
|
||||
function ComponentSelector<T extends {displayName: string; key: string; installed: boolean}>(
|
||||
props: Omit<ComponentPropsWithoutRef<typeof SearchBox<T>>, 'helper'>,
|
||||
) {
|
||||
function ComponentSelector<
|
||||
T extends { displayName: string; key: string; installed: boolean },
|
||||
>(props: Omit<ComponentPropsWithoutRef<typeof SearchBox<T>>, "helper">) {
|
||||
return (
|
||||
<Box>
|
||||
<SearchBox
|
||||
helper={'Press Enter to select component.'}
|
||||
helper={"Press Enter to select component."}
|
||||
{...props}
|
||||
onSubmit={(value) => {
|
||||
complete = true
|
||||
props.onSubmit?.(value)
|
||||
complete = true;
|
||||
props.onSubmit?.(value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
@ -37,29 +45,31 @@ function Generator() {
|
||||
new Promise<void>((r) => {
|
||||
const i = setInterval(() => {
|
||||
if (complete) {
|
||||
r()
|
||||
clearInterval(i)
|
||||
r();
|
||||
clearInterval(i);
|
||||
}
|
||||
}, 100)
|
||||
}, 100);
|
||||
}),
|
||||
] as const
|
||||
] as const;
|
||||
}
|
||||
|
||||
function Generator2() {
|
||||
let complete = false
|
||||
let complete = false;
|
||||
|
||||
function ForceSelector({onComplete}: {onComplete: (value: 'yes' | 'no') => void}) {
|
||||
function ForceSelector({
|
||||
onComplete,
|
||||
}: { onComplete: (value: "yes" | "no") => void }) {
|
||||
return (
|
||||
<Choice
|
||||
question={'You already installed this component. Overwrite?'}
|
||||
yes={'Yes, overwrite existing file and install it.'}
|
||||
no={'No, cancel the action.'}
|
||||
question={"You already installed this component. Overwrite?"}
|
||||
yes={"Yes, overwrite existing file and install it."}
|
||||
no={"No, cancel the action."}
|
||||
onSubmit={(value) => {
|
||||
complete = true
|
||||
onComplete(value)
|
||||
complete = true;
|
||||
onComplete(value);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
@ -67,157 +77,237 @@ function Generator2() {
|
||||
new Promise<void>((r) => {
|
||||
const i = setInterval(() => {
|
||||
if (complete) {
|
||||
r()
|
||||
clearInterval(i)
|
||||
r();
|
||||
clearInterval(i);
|
||||
}
|
||||
}, 100)
|
||||
}, 100);
|
||||
}),
|
||||
] as const
|
||||
] as const;
|
||||
}
|
||||
|
||||
export default class Add extends Command {
|
||||
static override args = {
|
||||
name: Args.string({description: 'name of component to install'}),
|
||||
}
|
||||
name: Args.string({ description: "name of component to install" }),
|
||||
};
|
||||
|
||||
static override description = 'Add a component to the project.'
|
||||
static override description = "Add a component to the project.";
|
||||
|
||||
static override examples = ['<%= config.bin %> <%= command.id %>']
|
||||
static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
static override flags = {
|
||||
registry: Flags.string({char: 'r', description: 'override registry url'}),
|
||||
force: Flags.boolean({char: 'f', description: 'override the existing file'}),
|
||||
config: Flags.string({char: 'p', description: 'path to config'}),
|
||||
shared: Flags.string({char: 's', description: 'place for installation of shared.ts'}),
|
||||
components: Flags.string({char: 'c', description: 'place for installation of components'}),
|
||||
}
|
||||
branch: Flags.string({
|
||||
char: "r",
|
||||
description: "use other branch instead of main",
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
char: "f",
|
||||
description: "override the existing file",
|
||||
}),
|
||||
config: Flags.string({ char: "p", description: "path to config" }),
|
||||
shared: Flags.string({
|
||||
char: "s",
|
||||
description: "place for installation of shared.ts",
|
||||
}),
|
||||
components: Flags.string({
|
||||
char: "c",
|
||||
description: "place for installation of components",
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
let {
|
||||
args,
|
||||
flags: {force, ...flags},
|
||||
} = await this.parse(Add)
|
||||
flags: { force, ...flags },
|
||||
} = await this.parse(Add);
|
||||
|
||||
const resolvedConfig = await validateConfig((message: string) => this.log(message), await loadConfig(flags.config))
|
||||
const componentFolder = join(process.cwd(), resolvedConfig.paths.components)
|
||||
const libFile = join(process.cwd(), resolvedConfig.paths.lib)
|
||||
const resolvedConfig = await validateConfig(
|
||||
(message: string) => this.log(message),
|
||||
await loadConfig(flags.config),
|
||||
);
|
||||
const componentFolder = join(
|
||||
process.cwd(),
|
||||
resolvedConfig.paths.components,
|
||||
);
|
||||
const libFolder = join(process.cwd(), resolvedConfig.paths.lib);
|
||||
if (!existsSync(componentFolder)) {
|
||||
await mkdir(componentFolder, {recursive: true})
|
||||
await mkdir(componentFolder, { recursive: true });
|
||||
}
|
||||
if (!existsSync(dirname(libFile))) {
|
||||
await mkdir(dirname(libFile), {recursive: true})
|
||||
if (!existsSync(libFolder)) {
|
||||
await mkdir(libFolder, { recursive: true });
|
||||
}
|
||||
|
||||
const loadRegistryOra = ora('Fetching registry...').start()
|
||||
const loadRegistryOra = ora("Fetching registry...").start();
|
||||
if (flags.registry) {
|
||||
this.log(`Using ${flags.registry} for registry.`)
|
||||
this.log(`Using ${flags.branch} for branch.`);
|
||||
}
|
||||
const unsafeRegistry = await getRegistry(flags.registry)
|
||||
const unsafeRegistry = await getRegistry(flags.branch);
|
||||
if (!unsafeRegistry.ok) {
|
||||
loadRegistryOra.fail(unsafeRegistry.message)
|
||||
return
|
||||
loadRegistryOra.fail(unsafeRegistry.message);
|
||||
return;
|
||||
}
|
||||
const registry = unsafeRegistry.registry;
|
||||
const componentNames = Object.keys(registry.components);
|
||||
loadRegistryOra.succeed(
|
||||
`Successfully fetched registry! (${componentNames.length} components)`,
|
||||
);
|
||||
const searchBoxComponent: {
|
||||
displayName: string;
|
||||
key: string;
|
||||
installed: boolean;
|
||||
}[] = [];
|
||||
for await (const name of componentNames) {
|
||||
const installed = await checkComponentInstalled(
|
||||
registry.components[name],
|
||||
resolvedConfig,
|
||||
);
|
||||
searchBoxComponent.push({
|
||||
displayName: installed ? `${name} (installed)` : name,
|
||||
key: name,
|
||||
installed,
|
||||
});
|
||||
}
|
||||
const registry = unsafeRegistry.registry
|
||||
const componentNames = await getAvailableComponentNames(registry)
|
||||
loadRegistryOra.succeed(`Successfully fetched registry! (${componentNames.length} components)`)
|
||||
const componentRealNames = await Promise.all(
|
||||
componentNames.map(async (name) => await getComponentRealname(registry, name)),
|
||||
)
|
||||
const installed = await getComponentsInstalled(componentRealNames, resolvedConfig)
|
||||
const searchBoxComponent = componentNames.map((name, index) => ({
|
||||
displayName: installed.includes(componentRealNames[index]) ? `${name} (installed)` : name,
|
||||
key: name,
|
||||
installed: installed.includes(componentRealNames[index]),
|
||||
}))
|
||||
|
||||
let name: string | undefined = args.name?.toLowerCase?.()
|
||||
let name: string | undefined = args.name?.toLowerCase?.();
|
||||
let requireForce: boolean =
|
||||
!name || !componentNames.includes(name.toLowerCase())
|
||||
? false
|
||||
: searchBoxComponent.find(({key}) => key === name)?.installed
|
||||
: searchBoxComponent.find(({ key }) => key === name)?.installed
|
||||
? !force
|
||||
: false
|
||||
: false;
|
||||
|
||||
if (!name || !componentNames.includes(name.toLowerCase())) {
|
||||
const [ComponentSelector, waitForComplete] = Generator()
|
||||
const [ComponentSelector, waitForComplete] = Generator();
|
||||
|
||||
const inkInstance = render(
|
||||
<ComponentSelector
|
||||
components={searchBoxComponent}
|
||||
initialQuery={args.name}
|
||||
onSubmit={(comp) => {
|
||||
name = comp.key
|
||||
requireForce = comp.installed
|
||||
inkInstance.clear()
|
||||
name = comp.key;
|
||||
requireForce = comp.installed;
|
||||
inkInstance.clear();
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
await waitForComplete
|
||||
inkInstance.unmount()
|
||||
);
|
||||
await waitForComplete;
|
||||
inkInstance.unmount();
|
||||
}
|
||||
|
||||
let quit = false
|
||||
let quit = false;
|
||||
|
||||
if (requireForce) {
|
||||
const [ForceSelector, waitForComplete] = Generator2()
|
||||
const [ForceSelector, waitForComplete] = Generator2();
|
||||
|
||||
const inkInstance = render(
|
||||
<ForceSelector
|
||||
onComplete={(value) => {
|
||||
force = value === 'yes'
|
||||
quit = value === 'no'
|
||||
inkInstance.clear()
|
||||
force = value === "yes";
|
||||
quit = value === "no";
|
||||
inkInstance.clear();
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
await waitForComplete
|
||||
inkInstance.unmount()
|
||||
);
|
||||
await waitForComplete;
|
||||
inkInstance.unmount();
|
||||
if (quit) {
|
||||
this.log(colorize('redBright', 'Installation canceled by user.'))
|
||||
return
|
||||
this.log(colorize("redBright", "Installation canceled by user."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!name || !componentNames.includes(name.toLowerCase())) {
|
||||
this.error('Component name is not provided, or not selected.')
|
||||
this.error("Component name is not provided, or not selected.");
|
||||
}
|
||||
|
||||
const libFileOra = ora('Installing required library...').start()
|
||||
if (!existsSync(libFile)) {
|
||||
const libFileContentResponse = await fetch(registry.base + registry.paths.lib)
|
||||
if (!libFileContentResponse.ok) {
|
||||
libFileOra.fail(
|
||||
`Error while fetching library content: ${libFileContentResponse.status} ${libFileContentResponse.statusText}`,
|
||||
)
|
||||
return
|
||||
const libFileOra = ora("Installing required library...").start();
|
||||
let successCount = 0;
|
||||
for await (const libFile of registry.lib) {
|
||||
const filePath = join(libFolder, libFile);
|
||||
if (!existsSync(filePath)) {
|
||||
const libFileContentResponse = await safeFetch(
|
||||
registry.base + registry.paths.lib.replace("{libName}", libFile),
|
||||
);
|
||||
if (!libFileContentResponse.ok) {
|
||||
libFileOra.fail(libFileContentResponse.message);
|
||||
return;
|
||||
}
|
||||
const libFileContent = await libFileContentResponse.response.text();
|
||||
await writeFile(filePath, libFileContent);
|
||||
successCount++;
|
||||
}
|
||||
const libFileContent = await libFileContentResponse.text()
|
||||
await writeFile(libFile, libFileContent)
|
||||
libFileOra.succeed('Library is successfully installed!')
|
||||
}
|
||||
if (successCount > 1) {
|
||||
libFileOra.succeed("Successfully installed library files!");
|
||||
} else {
|
||||
libFileOra.succeed('Library is already installed!')
|
||||
libFileOra.succeed("Library files are already installed!");
|
||||
}
|
||||
|
||||
const componentFileOra = ora(`Installing ${name} component...`).start()
|
||||
const componentFile = join(componentFolder, registry.components[name].name)
|
||||
if (existsSync(componentFile) && !force) {
|
||||
componentFileOra.succeed(`Component is already installed! (${componentFile})`)
|
||||
} else {
|
||||
const componentFileContentResponse = await fetch(await getComponentURL(registry, name))
|
||||
if (!componentFileContentResponse.ok) {
|
||||
componentFileOra.fail(
|
||||
`Error while fetching component file content: ${componentFileContentResponse.status} ${componentFileContentResponse.statusText}`,
|
||||
)
|
||||
return
|
||||
const componentFileOra = ora(`Installing ${name} component...`).start();
|
||||
const componentObject = registry.components[name];
|
||||
if (componentObject.type === "file") {
|
||||
const componentFile = join(
|
||||
componentFolder,
|
||||
registry.components[name].name,
|
||||
);
|
||||
if (existsSync(componentFile) && !force) {
|
||||
componentFileOra.succeed(
|
||||
`Component is already installed! (${componentFile})`,
|
||||
);
|
||||
} else {
|
||||
const componentFileContentResponse = await safeFetch(
|
||||
await getComponentURL(registry, componentObject),
|
||||
);
|
||||
if (!componentFileContentResponse.ok) {
|
||||
componentFileOra.fail(componentFileContentResponse.message);
|
||||
return;
|
||||
}
|
||||
const componentFileContent = (
|
||||
await componentFileContentResponse.response.text()
|
||||
).replaceAll(/import\s+{[^}]*}\s+from\s+"@pswui-lib"/g, (match) =>
|
||||
match.replace(/@pswui-lib/, resolvedConfig.import.lib),
|
||||
);
|
||||
await writeFile(componentFile, componentFileContent);
|
||||
componentFileOra.succeed("Component is successfully installed!");
|
||||
}
|
||||
} else if (componentObject.type === "dir") {
|
||||
const componentDir = join(componentFolder, componentObject.name);
|
||||
if (!existsSync(componentDir)) {
|
||||
await mkdir(componentDir, { recursive: true });
|
||||
}
|
||||
const requiredFiles = await getDirComponentRequiredFiles(
|
||||
componentObject,
|
||||
resolvedConfig,
|
||||
);
|
||||
if (requiredFiles.length === 0 && !force) {
|
||||
componentFileOra.succeed(
|
||||
`Component is already installed! (${componentDir})`,
|
||||
);
|
||||
} else {
|
||||
const requiredFilesURLs = await getDirComponentURL(
|
||||
registry,
|
||||
componentObject,
|
||||
requiredFiles,
|
||||
);
|
||||
for await (const [filename, url] of requiredFilesURLs) {
|
||||
const componentFile = join(componentDir, filename);
|
||||
if (!existsSync(componentFile) || force) {
|
||||
const componentFileContentResponse = await safeFetch(url);
|
||||
if (!componentFileContentResponse.ok) {
|
||||
componentFileOra.fail(componentFileContentResponse.message);
|
||||
return;
|
||||
}
|
||||
const componentFileContent = (
|
||||
await componentFileContentResponse.response.text()
|
||||
).replaceAll(/import\s+{[^}]*}\s+from\s+"@pswui-lib"/g, (match) =>
|
||||
match.replace(/@pswui-lib/, resolvedConfig.import.lib),
|
||||
);
|
||||
await writeFile(componentFile, componentFileContent);
|
||||
}
|
||||
}
|
||||
componentFileOra.succeed("Component is successfully installed!");
|
||||
}
|
||||
const componentFileContent = (await componentFileContentResponse.text()).replaceAll(
|
||||
/import\s+{[^}]*}\s+from\s+"@pswui-lib"/g,
|
||||
(match) => match.replace(/@pswui-lib/, resolvedConfig.import.lib),
|
||||
)
|
||||
await writeFile(componentFile, componentFileContent)
|
||||
componentFileOra.succeed('Component is successfully installed!')
|
||||
}
|
||||
|
||||
this.log('Now you can import the component.')
|
||||
this.log("Now you can import the component.");
|
||||
}
|
||||
}
|
||||
|
@ -1,62 +1,89 @@
|
||||
import {Command, Flags} from '@oclif/core'
|
||||
import {getAvailableComponentNames, getRegistry, getComponentURL, getComponentRealname} from '../helpers/registry.js'
|
||||
import ora from 'ora'
|
||||
import treeify from 'treeify'
|
||||
import {loadConfig, validateConfig} from '../helpers/config.js'
|
||||
import {getComponentsInstalled} from '../helpers/path.js'
|
||||
import { Command, Flags } from "@oclif/core";
|
||||
import ora from "ora";
|
||||
import treeify from "treeify";
|
||||
|
||||
import { loadConfig, validateConfig } from "../helpers/config.js";
|
||||
import { checkComponentInstalled } from "../helpers/path.js";
|
||||
import {
|
||||
getComponentURL,
|
||||
getDirComponentURL,
|
||||
getRegistry,
|
||||
} from "../helpers/registry.js";
|
||||
|
||||
export default class List extends Command {
|
||||
static override description = 'Prints all available components in registry and components installed in this project.'
|
||||
static override description =
|
||||
"Prints all available components in registry and components installed in this project.";
|
||||
|
||||
static override examples = ['<%= config.bin %> <%= command.id %>']
|
||||
static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
static override flags = {
|
||||
registry: Flags.string({char: 'r', description: 'override registry url'}),
|
||||
url: Flags.boolean({char: 'u', description: 'include component file URL'}),
|
||||
config: Flags.string({char: 'p', description: 'path to config'}),
|
||||
}
|
||||
branch: Flags.string({
|
||||
char: "r",
|
||||
description: "use other branch instead of main",
|
||||
}),
|
||||
config: Flags.string({ char: "p", description: "path to config" }),
|
||||
url: Flags.boolean({
|
||||
char: "u",
|
||||
description: "include component file URL",
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const {flags} = await this.parse(List)
|
||||
const { flags } = await this.parse(List);
|
||||
|
||||
const registrySpinner = ora('Fetching registry...')
|
||||
const getInstalledSpinner = ora('Getting installed components...')
|
||||
const registrySpinner = ora("Fetching registry...");
|
||||
|
||||
const loadedConfig = await validateConfig((message: string) => this.log(message), await loadConfig(flags.config))
|
||||
const loadedConfig = await validateConfig(
|
||||
(message: string) => this.log(message),
|
||||
await loadConfig(flags.config),
|
||||
);
|
||||
|
||||
registrySpinner.start()
|
||||
if (flags.registry) {
|
||||
this.log(`Using ${flags.registry} for registry.`)
|
||||
registrySpinner.start();
|
||||
if (flags.branch) {
|
||||
this.log(`Using ${flags.branch} for registry.`);
|
||||
}
|
||||
const unsafeRegistry = await getRegistry(flags.registry)
|
||||
|
||||
const unsafeRegistry = await getRegistry(flags.branch);
|
||||
if (!unsafeRegistry.ok) {
|
||||
registrySpinner.fail(unsafeRegistry.message)
|
||||
return
|
||||
registrySpinner.fail(unsafeRegistry.message);
|
||||
return;
|
||||
}
|
||||
const registry = unsafeRegistry.registry
|
||||
registrySpinner.succeed(`Fetched ${Object.keys(registry.components).length} components.`)
|
||||
|
||||
const names = await getAvailableComponentNames(registry)
|
||||
const { registry } = unsafeRegistry;
|
||||
const names = Object.keys(registry.components);
|
||||
|
||||
getInstalledSpinner.start()
|
||||
const installedNames = await getComponentsInstalled(
|
||||
await Promise.all(names.map(async (name) => await getComponentRealname(registry, name))),
|
||||
loadedConfig,
|
||||
)
|
||||
getInstalledSpinner.succeed(`Got ${installedNames.length} installed components.`)
|
||||
registrySpinner.succeed(`Fetched ${names.length} components.`);
|
||||
|
||||
let final: Record<string, {URL?: string; installed: 'yes' | 'no'}> = {}
|
||||
for (const name of names) {
|
||||
const installed = installedNames.includes(await getComponentRealname(registry, name)) ? 'yes' : 'no'
|
||||
let final: Record<
|
||||
string,
|
||||
{ URL?: Record<string, string>; installed: "no" | "yes" }
|
||||
> = {};
|
||||
for await (const name of names) {
|
||||
const componentObject = registry.components[name];
|
||||
const installed = (await checkComponentInstalled(
|
||||
componentObject,
|
||||
loadedConfig,
|
||||
))
|
||||
? "yes"
|
||||
: "no";
|
||||
if (flags.url) {
|
||||
const url = await getComponentURL(registry, name)
|
||||
final = {...final, [name]: {URL: url, installed}}
|
||||
let url: Record<string, string> = {};
|
||||
|
||||
if (componentObject.type === "file") {
|
||||
url[name] = await getComponentURL(registry, componentObject);
|
||||
} else if (componentObject.type === "dir") {
|
||||
url = Object.fromEntries(
|
||||
await getDirComponentURL(registry, componentObject),
|
||||
);
|
||||
}
|
||||
|
||||
final = { ...final, [name]: { URL: url, installed } };
|
||||
} else {
|
||||
final = {...final, [name]: {installed}}
|
||||
final = { ...final, [name]: { installed } };
|
||||
}
|
||||
}
|
||||
|
||||
this.log('AVAILABLE COMPONENTS')
|
||||
this.log(treeify.asTree(final, true, true))
|
||||
this.log("AVAILABLE COMPONENTS");
|
||||
this.log(treeify.asTree(final, true, true));
|
||||
}
|
||||
}
|
||||
|
@ -1,42 +1,45 @@
|
||||
import {Command, Args, Flags} from '@oclif/core'
|
||||
import {render} from 'ink'
|
||||
import {SearchBox} from '../components/SearchBox.js'
|
||||
import {getAvailableComponentNames, getRegistry} from '../helpers/registry.js'
|
||||
import React from 'react'
|
||||
import { Args, Command, Flags } from "@oclif/core";
|
||||
import { render } from "ink";
|
||||
import React from "react";
|
||||
import { SearchBox } from "../components/SearchBox.js";
|
||||
import { getRegistry } from "../helpers/registry.js";
|
||||
|
||||
export default class Search extends Command {
|
||||
static override args = {
|
||||
query: Args.string({description: 'search query'}),
|
||||
}
|
||||
query: Args.string({ description: "search query" }),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
registry: Flags.string({char: 'r', description: 'override registry url'})
|
||||
}
|
||||
branch: Flags.string({
|
||||
char: "r",
|
||||
description: "use other branch instead of main",
|
||||
}),
|
||||
};
|
||||
|
||||
static override description = 'Search components.'
|
||||
static override description = "Search components.";
|
||||
|
||||
static override examples = ['<%= config.bin %> <%= command.id %>']
|
||||
static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const {args, flags} = await this.parse(Search)
|
||||
const { args, flags } = await this.parse(Search);
|
||||
|
||||
if (flags.registry) {
|
||||
this.log(`Using ${flags.registry} for registry.`)
|
||||
if (flags.branch) {
|
||||
this.log(`Using ${flags.branch} for registry.`);
|
||||
}
|
||||
const registryResult = await getRegistry(flags.registry)
|
||||
const registryResult = await getRegistry(flags.branch);
|
||||
if (!registryResult.ok) {
|
||||
this.error(registryResult.message)
|
||||
this.error(registryResult.message);
|
||||
}
|
||||
const registry = registryResult.registry
|
||||
const componentNames = await getAvailableComponentNames(registry)
|
||||
const registry = registryResult.registry;
|
||||
const componentNames = Object.keys(registry.components);
|
||||
|
||||
await render(
|
||||
<SearchBox
|
||||
components={componentNames.map((v) => ({key: v, displayName: v}))}
|
||||
components={componentNames.map((v) => ({ key: v, displayName: v }))}
|
||||
initialQuery={args.query}
|
||||
helper={'Press ESC to quit'}
|
||||
helper={"Press ESC to quit"}
|
||||
onKeyDown={(_, k, app) => k.escape && app.exit()}
|
||||
/>,
|
||||
).waitUntilExit()
|
||||
).waitUntilExit();
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +1,26 @@
|
||||
import React, {useState} from 'react'
|
||||
import {Box, Text, useInput} from 'ink'
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import React, { useState } from "react";
|
||||
|
||||
function isUnicodeSupported() {
|
||||
if (process.platform !== 'win32') {
|
||||
return process.env['TERM'] !== 'linux' // Linux console (kernel)
|
||||
if (process.platform !== "win32") {
|
||||
return process.env.TERM !== "linux"; // Linux console (kernel)
|
||||
}
|
||||
|
||||
return (
|
||||
Boolean(process.env['WT_SESSION']) || // Windows Terminal
|
||||
Boolean(process.env['TERMINUS_SUBLIME']) || // Terminus (<0.2.27)
|
||||
process.env['ConEmuTask'] === '{cmd::Cmder}' || // ConEmu and cmder
|
||||
process.env['TERM_PROGRAM'] === 'Terminus-Sublime' ||
|
||||
process.env['TERM_PROGRAM'] === 'vscode' ||
|
||||
process.env['TERM'] === 'xterm-256color' ||
|
||||
process.env['TERM'] === 'alacritty' ||
|
||||
process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'
|
||||
)
|
||||
Boolean(process.env.WT_SESSION) || // Windows Terminal
|
||||
Boolean(process.env.TERMINUS_SUBLIME) || // Terminus (<0.2.27)
|
||||
process.env.ConEmuTask === "{cmd::Cmder}" || // ConEmu and cmder
|
||||
process.env.TERM_PROGRAM === "Terminus-Sublime" ||
|
||||
process.env.TERM_PROGRAM === "vscode" ||
|
||||
process.env.TERM === "xterm-256color" ||
|
||||
process.env.TERM === "alacritty" ||
|
||||
process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
|
||||
);
|
||||
}
|
||||
|
||||
const shouldUseMain = isUnicodeSupported()
|
||||
const SELECTED: string = shouldUseMain ? '◉' : '(*)'
|
||||
const UNSELECTED: string = shouldUseMain ? '◯' : '( )'
|
||||
const shouldUseMain = isUnicodeSupported();
|
||||
const SELECTED: string = shouldUseMain ? "◉" : "(*)";
|
||||
const UNSELECTED: string = shouldUseMain ? "◯" : "( )";
|
||||
|
||||
export function Choice({
|
||||
question,
|
||||
@ -29,35 +29,38 @@ export function Choice({
|
||||
onSubmit,
|
||||
initial,
|
||||
}: {
|
||||
question: string
|
||||
yes: string
|
||||
no: string
|
||||
onSubmit?: (vaule: 'yes' | 'no') => void
|
||||
initial?: 'yes' | 'no'
|
||||
question: string;
|
||||
yes: string;
|
||||
no: string;
|
||||
onSubmit?: (vaule: "yes" | "no") => void;
|
||||
initial?: "yes" | "no";
|
||||
}) {
|
||||
const [state, setState] = useState<'yes' | 'no'>(initial ?? 'yes')
|
||||
const [state, setState] = useState<"yes" | "no">(initial ?? "yes");
|
||||
|
||||
useInput((_, k) => {
|
||||
if (k.upArrow) {
|
||||
setState('yes')
|
||||
setState("yes");
|
||||
} else if (k.downArrow) {
|
||||
setState('no')
|
||||
setState("no");
|
||||
}
|
||||
|
||||
if (k.return) {
|
||||
onSubmit?.(state)
|
||||
onSubmit?.(state);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Box display={'flex'} flexDirection={'column'}>
|
||||
<Text color={'greenBright'}>{question}</Text>
|
||||
<Text color={state === 'yes' ? undefined : 'gray'}>
|
||||
{state === 'yes' ? SELECTED : UNSELECTED} {yes}
|
||||
<Box
|
||||
display={"flex"}
|
||||
flexDirection={"column"}
|
||||
>
|
||||
<Text color={"greenBright"}>{question}</Text>
|
||||
<Text color={state === "yes" ? undefined : "gray"}>
|
||||
{state === "yes" ? SELECTED : UNSELECTED} {yes}
|
||||
</Text>
|
||||
<Text color={state === 'no' ? undefined : 'gray'}>
|
||||
{state === 'no' ? SELECTED : UNSELECTED} {no}
|
||||
<Text color={state === "no" ? undefined : "gray"}>
|
||||
{state === "no" ? SELECTED : UNSELECTED} {no}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,24 +1,32 @@
|
||||
import React from 'react'
|
||||
import {Box, Text} from 'ink'
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
|
||||
export function Divider({width = 50, padding = 1, title}: {width?: number; padding?: number; title: string}) {
|
||||
const length = Math.floor((width - title.length - padding * 2) / 2)
|
||||
export function Divider({
|
||||
width = 50,
|
||||
padding = 1,
|
||||
title,
|
||||
}: { width?: number; padding?: number; title: string }) {
|
||||
const length = Math.floor((width - title.length - padding * 2) / 2);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{Array.from(Array(length)).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||
<Text key={i}>─</Text>
|
||||
))}
|
||||
{Array.from(Array(padding)).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||
<Text key={i}> </Text>
|
||||
))}
|
||||
<Text>{title}</Text>
|
||||
{Array.from(Array(padding)).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||
<Text key={i}> </Text>
|
||||
))}
|
||||
{Array.from(Array(length)).map((_, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||
<Text key={i}>─</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {getSuggestion} from '../helpers/search.js'
|
||||
import Input from 'ink-text-input'
|
||||
import {Divider} from './Divider.js'
|
||||
import {Box, Text, useInput, useApp, type Key} from 'ink'
|
||||
import { Box, type Key, Text, useApp, useInput } from "ink";
|
||||
import Input from "ink-text-input";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getSuggestion } from "../helpers/search.js";
|
||||
import { Divider } from "./Divider.js";
|
||||
|
||||
export function SearchBox<T extends {key: string; displayName: string}>({
|
||||
export function SearchBox<T extends { key: string; displayName: string }>({
|
||||
components,
|
||||
helper,
|
||||
initialQuery,
|
||||
@ -12,92 +12,115 @@ export function SearchBox<T extends {key: string; displayName: string}>({
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
components: T[]
|
||||
helper: string
|
||||
initialQuery?: string
|
||||
onKeyDown?: (i: string, k: Key, app: ReturnType<typeof useApp>) => void
|
||||
onChange?: (item: T) => void
|
||||
onSubmit?: (item: T) => void
|
||||
components: T[];
|
||||
helper: string;
|
||||
initialQuery?: string;
|
||||
onKeyDown?: (i: string, k: Key, app: ReturnType<typeof useApp>) => void;
|
||||
onChange?: (item: T) => void;
|
||||
onSubmit?: (item: T) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState<string>(initialQuery ?? '')
|
||||
const [queryMode, setQueryMode] = useState<boolean>(true)
|
||||
const [isLoading, setLoading] = useState<boolean>(false)
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
const [selected, setSelected] = useState<number>(-1)
|
||||
const [query, setQuery] = useState<string>(initialQuery ?? "");
|
||||
const [queryMode, setQueryMode] = useState<boolean>(true);
|
||||
const [isLoading, setLoading] = useState<boolean>(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryMode) {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
getSuggestion(
|
||||
components.map(({key}) => key),
|
||||
components.map(({ key }) => key),
|
||||
query,
|
||||
).then((result) => {
|
||||
setSuggestions(result)
|
||||
setSelected(-1)
|
||||
})
|
||||
setSuggestions(result);
|
||||
setSelected(-1);
|
||||
});
|
||||
}
|
||||
}, [query, queryMode])
|
||||
}, [query, queryMode, components]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
const found = components.find(({key}) => key === suggestions[selected])
|
||||
found && onChange(found)
|
||||
const found = components.find(({ key }) => key === suggestions[selected]);
|
||||
found && onChange(found);
|
||||
}
|
||||
}, [selected, suggestions, onChange])
|
||||
}, [selected, suggestions, onChange, components]);
|
||||
|
||||
const app = useApp()
|
||||
const app = useApp();
|
||||
|
||||
useInput((i, k) => {
|
||||
if (k.downArrow) {
|
||||
setSelected((p) => (p >= suggestions.length - 1 ? 0 : p + 1))
|
||||
setQueryMode(false)
|
||||
setSelected((p) => (p >= suggestions.length - 1 ? 0 : p + 1));
|
||||
setQueryMode(false);
|
||||
}
|
||||
if (k.upArrow) {
|
||||
setSelected((p) => (p <= 0 ? suggestions.length - 1 : p - 1))
|
||||
setQueryMode(false)
|
||||
setSelected((p) => (p <= 0 ? suggestions.length - 1 : p - 1));
|
||||
setQueryMode(false);
|
||||
}
|
||||
onKeyDown?.(i, k, app)
|
||||
})
|
||||
onKeyDown?.(i, k, app);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryMode && suggestions[selected]) {
|
||||
setQuery(suggestions[selected])
|
||||
setQuery(suggestions[selected]);
|
||||
}
|
||||
}, [queryMode, selected])
|
||||
}, [queryMode, selected, suggestions]);
|
||||
|
||||
return (
|
||||
<Box width={50} display={'flex'} flexDirection={'column'}>
|
||||
<Text color={'gray'}>{helper}</Text>
|
||||
<Box display={'flex'} flexDirection={'row'}>
|
||||
<Box marginRight={1} display={'flex'} flexDirection={'row'}>
|
||||
<Text color={'greenBright'}>Search?</Text>
|
||||
<Box
|
||||
width={50}
|
||||
display={"flex"}
|
||||
flexDirection={"column"}
|
||||
>
|
||||
<Text color={"gray"}>{helper}</Text>
|
||||
<Box
|
||||
display={"flex"}
|
||||
flexDirection={"row"}
|
||||
>
|
||||
<Box
|
||||
marginRight={1}
|
||||
display={"flex"}
|
||||
flexDirection={"row"}
|
||||
>
|
||||
<Text color={"greenBright"}>Search?</Text>
|
||||
</Box>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
setQueryMode(true)
|
||||
setQuery(v)
|
||||
setQueryMode(true);
|
||||
setQuery(v);
|
||||
}}
|
||||
showCursor
|
||||
placeholder={' query'}
|
||||
placeholder={" query"}
|
||||
onSubmit={() => {
|
||||
const found = components.find(({key}) => key === suggestions[selected])
|
||||
found && onSubmit?.(found)
|
||||
const found = components.find(
|
||||
({ key }) => key === suggestions[selected],
|
||||
);
|
||||
found && onSubmit?.(found);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Divider title={isLoading ? 'Loading...' : `${suggestions.length} components found.`} />
|
||||
<Box display={'flex'} flexDirection={'column'}>
|
||||
<Divider
|
||||
title={
|
||||
isLoading ? "Loading..." : `${suggestions.length} components found.`
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
display={"flex"}
|
||||
flexDirection={"column"}
|
||||
>
|
||||
{suggestions.map((name, index) => {
|
||||
return (
|
||||
<Box key={name}>
|
||||
<Text color={selected === index ? undefined : 'gray'}>
|
||||
{components[components.findIndex(({key}) => key === name)].displayName}
|
||||
<Text color={selected === index ? undefined : "gray"}>
|
||||
{
|
||||
components[components.findIndex(({ key }) => key === name)]
|
||||
.displayName
|
||||
}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,61 +1,75 @@
|
||||
import z from 'zod'
|
||||
import { z } from "zod";
|
||||
|
||||
export const REGISTRY_URL = 'https://raw.githubusercontent.com/pswui/ui/main/registry.json'
|
||||
export const CONFIG_DEFAULT_PATH = 'pswui.config.js'
|
||||
export const registryURL = (branch: string) =>
|
||||
`https://raw.githubusercontent.com/pswui/ui/${branch}/registry.json`;
|
||||
export const CONFIG_DEFAULT_PATH = "pswui.config.js";
|
||||
|
||||
interface RegistryComponent {
|
||||
name: string
|
||||
}
|
||||
export type RegistryComponent =
|
||||
| {
|
||||
files: string[];
|
||||
name: string;
|
||||
type: "dir";
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "file";
|
||||
};
|
||||
|
||||
export interface Registry {
|
||||
base: string
|
||||
base: string;
|
||||
components: Record<string, RegistryComponent>;
|
||||
lib: string[];
|
||||
paths: {
|
||||
components: string
|
||||
lib: string
|
||||
}
|
||||
components: Record<string, RegistryComponent>
|
||||
components: string;
|
||||
lib: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Path that cli will create a file.
|
||||
*/
|
||||
paths?: {
|
||||
components?: 'src/pswui/components' | string
|
||||
lib?: 'src/pswui/lib.tsx' | string
|
||||
}
|
||||
/**
|
||||
* Absolute path that will used for import in component
|
||||
*/
|
||||
import?: {
|
||||
lib?: '@pswui-lib' | string
|
||||
}
|
||||
lib?: "@pswui-lib" | string;
|
||||
};
|
||||
/**
|
||||
* Path that cli will create a file.
|
||||
*/
|
||||
paths?: {
|
||||
components?: "src/pswui/components" | string;
|
||||
lib?: "src/pswui/lib" | string;
|
||||
};
|
||||
}
|
||||
export type ResolvedConfig<T = Config> = {
|
||||
[k in keyof T]-?: NonNullable<T[k]> extends object ? ResolvedConfig<NonNullable<T[k]>> : T[k]
|
||||
}
|
||||
[k in keyof T]-?: NonNullable<T[k]> extends object
|
||||
? ResolvedConfig<NonNullable<T[k]>>
|
||||
: T[k];
|
||||
};
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
paths: {
|
||||
components: 'src/pswui/components',
|
||||
lib: 'src/pswui/lib.tsx',
|
||||
},
|
||||
import: {
|
||||
lib: '@pswui-lib',
|
||||
lib: "@pswui-lib",
|
||||
},
|
||||
}
|
||||
paths: {
|
||||
components: "src/pswui/components",
|
||||
lib: "src/pswui/lib",
|
||||
},
|
||||
};
|
||||
export const configZod = z.object({
|
||||
paths: z
|
||||
.object({
|
||||
components: z.string().optional().default(DEFAULT_CONFIG.paths.components),
|
||||
lib: z.string().optional().default(DEFAULT_CONFIG.paths.lib),
|
||||
})
|
||||
.optional()
|
||||
.default(DEFAULT_CONFIG.paths),
|
||||
import: z
|
||||
.object({
|
||||
lib: z.string().optional().default(DEFAULT_CONFIG.import.lib),
|
||||
})
|
||||
.optional()
|
||||
.default(DEFAULT_CONFIG.import),
|
||||
})
|
||||
paths: z
|
||||
.object({
|
||||
components: z
|
||||
.string()
|
||||
.optional()
|
||||
.default(DEFAULT_CONFIG.paths.components),
|
||||
lib: z.string().optional().default(DEFAULT_CONFIG.paths.lib),
|
||||
})
|
||||
.optional()
|
||||
.default(DEFAULT_CONFIG.paths),
|
||||
});
|
||||
|
@ -1,40 +1,67 @@
|
||||
import {CONFIG_DEFAULT_PATH, DEFAULT_CONFIG, ResolvedConfig} from '../const.js'
|
||||
import {configZod} from '../const.js'
|
||||
import {join} from 'node:path'
|
||||
import {existsSync} from 'node:fs'
|
||||
import {changeExtension} from './path.js'
|
||||
import {colorize} from '@oclif/core/ux'
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { colorize } from "@oclif/core/ux";
|
||||
|
||||
import {
|
||||
CONFIG_DEFAULT_PATH,
|
||||
DEFAULT_CONFIG,
|
||||
type ResolvedConfig,
|
||||
configZod,
|
||||
} from "../const.js";
|
||||
import { changeExtension } from "./path.js";
|
||||
|
||||
export async function loadConfig(config?: string): Promise<unknown> {
|
||||
const userConfigPath = config ? join(process.cwd(), config) : null
|
||||
const defaultConfigPath = join(process.cwd(), CONFIG_DEFAULT_PATH)
|
||||
const cjsConfigPath = join(process.cwd(), await changeExtension(CONFIG_DEFAULT_PATH, '.cjs'))
|
||||
const mjsConfigPath = join(process.cwd(), await changeExtension(CONFIG_DEFAULT_PATH, '.mjs'))
|
||||
const userConfigPath = config ? path.join(process.cwd(), config) : null;
|
||||
const defaultConfigPath = path.join(process.cwd(), CONFIG_DEFAULT_PATH);
|
||||
const cjsConfigPath = path.join(
|
||||
process.cwd(),
|
||||
await changeExtension(CONFIG_DEFAULT_PATH, ".cjs"),
|
||||
);
|
||||
const mjsConfigPath = path.join(
|
||||
process.cwd(),
|
||||
await changeExtension(CONFIG_DEFAULT_PATH, ".mjs"),
|
||||
);
|
||||
|
||||
if (userConfigPath) {
|
||||
if (existsSync(userConfigPath)) {
|
||||
return (await import(userConfigPath)).default
|
||||
} else {
|
||||
throw new Error(`Error: config ${userConfigPath} not found.`)
|
||||
return (await import(userConfigPath)).default;
|
||||
}
|
||||
|
||||
throw new Error(`Error: config ${userConfigPath} not found.`);
|
||||
}
|
||||
|
||||
if (existsSync(defaultConfigPath)) {
|
||||
return (await import(defaultConfigPath)).default
|
||||
return (await import(defaultConfigPath)).default;
|
||||
}
|
||||
|
||||
if (existsSync(cjsConfigPath)) {
|
||||
return (await import(cjsConfigPath)).default
|
||||
return (await import(cjsConfigPath)).default;
|
||||
}
|
||||
|
||||
if (existsSync(mjsConfigPath)) {
|
||||
return (await import(mjsConfigPath)).default
|
||||
return (await import(mjsConfigPath)).default;
|
||||
}
|
||||
return DEFAULT_CONFIG
|
||||
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export async function validateConfig(log: (message: string) => void, config?: unknown): Promise<ResolvedConfig> {
|
||||
const parsedConfig: ResolvedConfig = await configZod.parseAsync(config)
|
||||
log(colorize('gray', `Install component to: ${join(process.cwd(), parsedConfig.paths.components)}`))
|
||||
log(colorize('gray', `Install shared module to: ${join(process.cwd(), parsedConfig.paths.lib)}`))
|
||||
log(colorize('gray', `Import shared with: ${parsedConfig.import.lib}`))
|
||||
return parsedConfig
|
||||
export async function validateConfig(
|
||||
log: (message: string) => void,
|
||||
config?: unknown,
|
||||
): Promise<ResolvedConfig> {
|
||||
const parsedConfig: ResolvedConfig = await configZod.parseAsync(config);
|
||||
log(
|
||||
colorize(
|
||||
"gray",
|
||||
`Install component to: ${path.join(process.cwd(), parsedConfig.paths.components)}`,
|
||||
),
|
||||
);
|
||||
log(
|
||||
colorize(
|
||||
"gray",
|
||||
`Install shared module to: ${path.join(process.cwd(), parsedConfig.paths.lib)}`,
|
||||
),
|
||||
);
|
||||
log(colorize("gray", `Import shared with: ${parsedConfig.import.lib}`));
|
||||
return parsedConfig;
|
||||
}
|
||||
|
@ -1,18 +1,52 @@
|
||||
import {ResolvedConfig} from '../const.js'
|
||||
import {readdir} from 'node:fs/promises'
|
||||
import {existsSync} from 'node:fs'
|
||||
import {basename, dirname, extname, join} from 'node:path'
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export async function getComponentsInstalled(components: string[], config: ResolvedConfig) {
|
||||
const componentPath = join(process.cwd(), config.paths.components)
|
||||
if (existsSync(componentPath)) {
|
||||
const dir = await readdir(componentPath)
|
||||
return dir.reduce((prev, current) => (components.includes(current) ? [...prev, current] : prev), [] as string[])
|
||||
} else {
|
||||
return []
|
||||
import type { RegistryComponent, ResolvedConfig } from "../const.js";
|
||||
|
||||
export async function getDirComponentRequiredFiles<
|
||||
T extends { type: "dir" } & RegistryComponent,
|
||||
>(componentObject: T, config: ResolvedConfig) {
|
||||
const componentPath = path.join(
|
||||
process.cwd(),
|
||||
config.paths.components,
|
||||
componentObject.name,
|
||||
);
|
||||
if (!existsSync(componentPath)) {
|
||||
return componentObject.files;
|
||||
}
|
||||
|
||||
const dir = await readdir(componentPath);
|
||||
|
||||
return componentObject.files.filter((filename) => !dir.includes(filename));
|
||||
}
|
||||
|
||||
export async function changeExtension(path: string, extension: string): Promise<string> {
|
||||
return join(dirname(path), basename(path, extname(path)) + extension)
|
||||
export async function checkComponentInstalled(
|
||||
component: RegistryComponent,
|
||||
config: ResolvedConfig,
|
||||
): Promise<boolean> {
|
||||
const componentDirRoot = path.join(process.cwd(), config.paths.components);
|
||||
if (!existsSync(componentDirRoot)) return false;
|
||||
|
||||
if (component.type === "file") {
|
||||
const dir = await readdir(componentDirRoot);
|
||||
return dir.includes(component.name);
|
||||
}
|
||||
|
||||
const componentDir = path.join(componentDirRoot, component.name);
|
||||
if (!existsSync(componentDir)) return false;
|
||||
const dir = await readdir(componentDir);
|
||||
return (
|
||||
component.files.filter((filename) => !dir.includes(filename)).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function changeExtension(
|
||||
_path: string,
|
||||
extension: string,
|
||||
): Promise<string> {
|
||||
return path.join(
|
||||
path.dirname(_path),
|
||||
path.basename(_path, path.extname(_path)) + extension,
|
||||
);
|
||||
}
|
||||
|
@ -1,32 +1,49 @@
|
||||
import {REGISTRY_URL, Registry} from '../const.js'
|
||||
import {
|
||||
type Registry,
|
||||
type RegistryComponent,
|
||||
registryURL,
|
||||
} from "../const.js";
|
||||
import { safeFetch } from "./safe-fetcher.js";
|
||||
|
||||
export async function getRegistry(REGISTRY_OVERRIDE_URL?: string): Promise<{ok: true; registry: Registry} | {ok: false; message: string}> {
|
||||
const registryResponse = await fetch(REGISTRY_OVERRIDE_URL ?? REGISTRY_URL)
|
||||
export async function getRegistry(
|
||||
branch?: string,
|
||||
): Promise<{ message: string; ok: false } | { ok: true; registry: Registry }> {
|
||||
const registryResponse = await safeFetch(registryURL(branch ?? "main"));
|
||||
|
||||
if (registryResponse.ok) {
|
||||
const registryJson = (await registryResponse.response.json()) as Registry;
|
||||
registryJson.base = registryJson.base.replace("{branch}", branch ?? "main");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
registry: (await registryResponse.json()) as Registry,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Error while fetching registry: ${registryResponse.status} ${registryResponse.statusText}`,
|
||||
}
|
||||
registry: registryJson,
|
||||
};
|
||||
}
|
||||
|
||||
return registryResponse;
|
||||
}
|
||||
|
||||
export async function getAvailableComponentNames(registry: Registry): Promise<string[]> {
|
||||
return Object.keys(registry.components)
|
||||
}
|
||||
|
||||
export async function getComponentURL(registry: Registry, componentName: string): Promise<string> {
|
||||
return registry.base + registry.paths.components.replace('{componentName}', registry.components[componentName].name)
|
||||
}
|
||||
|
||||
export async function getComponentRealname(
|
||||
export async function getComponentURL(
|
||||
registry: Registry,
|
||||
componentName: keyof Registry['components'],
|
||||
component: { type: "file" } & RegistryComponent,
|
||||
): Promise<string> {
|
||||
return registry.components[componentName].name
|
||||
return (
|
||||
registry.base +
|
||||
registry.paths.components.replace("{componentName}", component.name)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDirComponentURL(
|
||||
registry: Registry,
|
||||
component: { type: "dir" } & RegistryComponent,
|
||||
files?: string[],
|
||||
): Promise<[string, string][]> {
|
||||
const base =
|
||||
registry.base +
|
||||
registry.paths.components.replace("{componentName}", component.name);
|
||||
|
||||
return (files ?? component.files).map((filename) => [
|
||||
filename,
|
||||
`${base}/${filename}`,
|
||||
]);
|
||||
}
|
||||
|
20
packages/cli/src/helpers/safe-fetcher.ts
Normal file
20
packages/cli/src/helpers/safe-fetcher.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export async function safeFetch(
|
||||
url: string,
|
||||
): Promise<
|
||||
| { message: string; ok: false; response: Response }
|
||||
| { ok: true; response: Response }
|
||||
> {
|
||||
const response = await fetch(url, { cache: "no-cache" });
|
||||
if (response.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Error while fetching from ${response.url}: ${response.status} ${response.statusText}`,
|
||||
ok: false,
|
||||
response,
|
||||
};
|
||||
}
|
@ -1,60 +1,69 @@
|
||||
export async function jaroWinkler(a: string, b: string): Promise<number> {
|
||||
const p = 0.1
|
||||
const p = 0.1;
|
||||
|
||||
if (!a.length || !b.length) return 0.0
|
||||
if (a === b) return 1.0
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
if (a === b) return 1;
|
||||
|
||||
const range = Math.floor(Math.max(a.length, b.length) / 2) - 1
|
||||
let matches = 0
|
||||
const range = Math.floor(Math.max(a.length, b.length) / 2) - 1;
|
||||
let matches = 0;
|
||||
|
||||
let aMatches = new Array(a.length)
|
||||
let bMatches = new Array(b.length)
|
||||
const aMatches = Array.from({ length: a.length });
|
||||
const bMatches = Array.from({ length: b.length });
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const start = i >= range ? i - range : 0
|
||||
const end = i + range <= b.length - 1 ? i + range : b.length - 1
|
||||
for (const [i, element] of Object.entries(a).map(
|
||||
([index, element]) => [Number.parseInt(index, 10), element] as const,
|
||||
)) {
|
||||
const start = i >= range ? i - range : 0;
|
||||
const end = i + range <= b.length - 1 ? i + range : b.length - 1;
|
||||
|
||||
for (let j = start; j <= end; j++) {
|
||||
if (bMatches[j] !== true && a[i] === b[j]) {
|
||||
++matches
|
||||
aMatches[i] = bMatches[j] = true
|
||||
break
|
||||
if (bMatches[j] !== true && element === b[j]) {
|
||||
++matches;
|
||||
aMatches[i] = true;
|
||||
bMatches[j] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches === 0) return 0.0
|
||||
if (matches === 0) return 0;
|
||||
|
||||
let t = 0
|
||||
let t = 0;
|
||||
|
||||
let point: number
|
||||
let point: number;
|
||||
|
||||
for (point = 0; point < a.length; point++) if (aMatches[point]) break
|
||||
for (point = 0; point < a.length; point++) if (aMatches[point]) break;
|
||||
|
||||
for (let i = point; i < a.length; i++)
|
||||
if (aMatches[i]) {
|
||||
let j
|
||||
let j: number;
|
||||
for (j = point; j < b.length; j++)
|
||||
if (bMatches[j]) {
|
||||
point = j + 1
|
||||
break
|
||||
point = j + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (a[i] !== b[j]) ++t
|
||||
if (a[i] !== b[j]) ++t;
|
||||
}
|
||||
|
||||
t = t / 2.0
|
||||
t /= 2;
|
||||
|
||||
const J = (matches / a.length + matches / b.length + (matches - t) / matches) / 3
|
||||
return J + Math.min((p * t) / matches, 1) * (1 - J)
|
||||
const J =
|
||||
(matches / a.length + matches / b.length + (matches - t) / matches) / 3;
|
||||
return J + Math.min((p * t) / matches, 1) * (1 - J);
|
||||
}
|
||||
|
||||
export async function getSuggestion(componentNames: string[], input: string): Promise<string[]> {
|
||||
export async function getSuggestion(
|
||||
componentNames: string[],
|
||||
input: string,
|
||||
): Promise<string[]> {
|
||||
const componentJw = await Promise.all(
|
||||
componentNames.map(async (name) => [name, await jaroWinkler(name, input)] as const),
|
||||
)
|
||||
componentNames.map(
|
||||
async (name) => [name, await jaroWinkler(name, input)] as const,
|
||||
),
|
||||
);
|
||||
return componentJw
|
||||
.filter(([_, score]) => score > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name]) => name)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
@ -1,2 +1,2 @@
|
||||
export {run} from '@oclif/core'
|
||||
export * from './public.js'
|
||||
export * from "./public.js";
|
||||
export { run } from "@oclif/core";
|
||||
|
@ -1,7 +1,9 @@
|
||||
import {Config} from './const.js'
|
||||
import type { Config } from "./const.js";
|
||||
|
||||
function buildConfig(config: Config): Config {
|
||||
return config
|
||||
return config;
|
||||
}
|
||||
|
||||
export {Config, buildConfig}
|
||||
export { buildConfig };
|
||||
|
||||
export { Config } from "./const.js";
|
||||
|
@ -1,14 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:storybook/recommended'],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
6
packages/react/.gitignore
vendored
6
packages/react/.gitignore
vendored
@ -23,4 +23,8 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*storybook.log
|
||||
*storybook.log
|
||||
|
||||
src
|
||||
src/main.tsx
|
||||
!src/tailwind.css
|
@ -1,7 +1,9 @@
|
||||
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||
import React from "react";
|
||||
import { vcn, VariantProps, Slot, AsChild } from "@pswui-lib";
|
||||
|
||||
const colors = {
|
||||
disabled:
|
||||
"disabled:brightness-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:saturate-50",
|
||||
outline: {
|
||||
focus: "dark:focus-visible:outline-white/20 focus-visible:outline-black/10",
|
||||
},
|
||||
@ -12,21 +14,18 @@ const colors = {
|
||||
danger: "border-red-400 dark:border-red-600",
|
||||
},
|
||||
background: {
|
||||
default:
|
||||
"bg-white dark:bg-black hover:bg-neutral-200 dark:hover:bg-neutral-800",
|
||||
default: "bg-white dark:bg-black",
|
||||
ghost:
|
||||
"bg-black/0 dark:bg-white/0 hover:bg-black/20 dark:hover:bg-white/20",
|
||||
success:
|
||||
"bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800",
|
||||
warning:
|
||||
"bg-yellow-100 dark:bg-yellow-900 hover:bg-yellow-200 dark:hover:bg-yellow-800",
|
||||
danger: "bg-red-100 dark:bg-red-900 hover:bg-red-200 dark:hover:bg-red-800",
|
||||
success: "bg-green-100 dark:bg-green-900",
|
||||
warning: "bg-yellow-100 dark:bg-yellow-900",
|
||||
danger: "bg-red-100 dark:bg-red-900",
|
||||
},
|
||||
underline: "decoration-current",
|
||||
};
|
||||
|
||||
const [buttonVariants, resolveVariants] = vcn({
|
||||
base: `w-fit flex flex-row items-center justify-between rounded-md outline outline-1 outline-transparent outline-offset-2 ${colors.outline.focus} transition-all`,
|
||||
base: `w-fit flex flex-row items-center justify-between rounded-md outline outline-1 outline-transparent outline-offset-2 hover:brightness-90 dark:hover:brightness-110 ${colors.outline.focus} ${colors.disabled} transition-all cursor-pointer`,
|
||||
variants: {
|
||||
size: {
|
||||
link: "p-0 text-base",
|
||||
@ -109,16 +108,22 @@ export interface ButtonProps
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] = resolveVariants(props);
|
||||
const { asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const { asChild, type, role, ...otherPropsExtracted } =
|
||||
otherPropsCompressed;
|
||||
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const compProps = {
|
||||
...otherPropsExtracted,
|
||||
className: buttonVariants(variantProps),
|
||||
};
|
||||
|
||||
return <Comp ref={ref} {...compProps} />;
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
className={buttonVariants(variantProps)}
|
||||
role={role ?? "button"}
|
||||
{...otherPropsExtracted}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { type VariantProps, vcn } from "@pswui-lib";
|
||||
import React from "react";
|
||||
import { VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
const checkboxColors = {
|
||||
background: {
|
||||
@ -18,12 +18,10 @@ const checkboxColors = {
|
||||
disabledCheckedHover:
|
||||
"has-[input[type='checkbox']:disabled:checked]:hover:bg-neutral-300 dark:has-[input[type='checkbox']:disabled:checked]:hover:bg-neutral-700",
|
||||
},
|
||||
checkmark:
|
||||
"text-black dark:text-white has-[input[type=checkbox]:disabled]:text-neutral-400 dark:has-[input[type=checkbox]:disabled]:text-neutral-500",
|
||||
};
|
||||
|
||||
const [checkboxVariant, resolveCheckboxVariantProps] = vcn({
|
||||
base: `inline-block rounded-md ${checkboxColors.checkmark} ${checkboxColors.background.disabled} ${checkboxColors.background.default} ${checkboxColors.background.hover} ${checkboxColors.background.checked} ${checkboxColors.background.checkedHover} ${checkboxColors.background.disabledChecked} ${checkboxColors.background.disabledCheckedHover} has-[input[type="checkbox"]:disabled]:cursor-not-allowed transition-colors duration-75 ease-in-out`,
|
||||
base: `inline-block rounded-md ${checkboxColors.background.disabled} ${checkboxColors.background.default} ${checkboxColors.background.hover} ${checkboxColors.background.checked} ${checkboxColors.background.checkedHover} ${checkboxColors.background.disabledChecked} ${checkboxColors.background.disabledCheckedHover} has-[input[type="checkbox"]:disabled]:cursor-not-allowed transition-colors duration-150 ease-in-out`,
|
||||
variants: {
|
||||
size: {
|
||||
base: "size-[1em] p-0 [&>svg]:size-[1em]",
|
||||
@ -92,22 +90,11 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
className={`${checked ? "opacity-100" : "opacity-0"} transition-opacity duration-75 ease-in-out`}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"
|
||||
></path>
|
||||
</svg>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = "Checkbox";
|
||||
|
||||
export { Checkbox };
|
||||
|
@ -1,32 +1,21 @@
|
||||
import React, { Dispatch, SetStateAction, useState } from "react";
|
||||
import { Slot, VariantProps, vcn } from "@pswui-lib";
|
||||
import {
|
||||
Slot,
|
||||
type VariantProps,
|
||||
useAnimatedMount,
|
||||
useDocument,
|
||||
vcn,
|
||||
} from "@pswui-lib";
|
||||
import React, { type ReactNode, useId, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* DialogContext
|
||||
* =========================
|
||||
*/
|
||||
|
||||
interface DialogContext {
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
const initialDialogContext: DialogContext = { opened: false };
|
||||
const DialogContext = React.createContext<
|
||||
[DialogContext, Dispatch<SetStateAction<DialogContext>>]
|
||||
>([
|
||||
import {
|
||||
DialogContext,
|
||||
type IDialogContext,
|
||||
InnerDialogContext,
|
||||
initialDialogContext,
|
||||
() => {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"It seems like you're using DialogContext outside of a provider.",
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
const useDialogContext = () => React.useContext(DialogContext);
|
||||
useDialogContext,
|
||||
useInnerDialogContext,
|
||||
} from "./Context";
|
||||
|
||||
/**
|
||||
* =========================
|
||||
@ -39,7 +28,10 @@ interface DialogRootProps {
|
||||
}
|
||||
|
||||
const DialogRoot = ({ children }: DialogRootProps) => {
|
||||
const state = useState<DialogContext>(initialDialogContext);
|
||||
const state = useState<IDialogContext>({
|
||||
...initialDialogContext,
|
||||
ids: { dialog: useId(), title: useId(), description: useId() },
|
||||
});
|
||||
return (
|
||||
<DialogContext.Provider value={state}>{children}</DialogContext.Provider>
|
||||
);
|
||||
@ -56,15 +48,17 @@ interface DialogTriggerProps {
|
||||
}
|
||||
|
||||
const DialogTrigger = ({ children }: DialogTriggerProps) => {
|
||||
const [_, setState] = useDialogContext();
|
||||
const [{ ids }, setState] = useDialogContext();
|
||||
const onClick = () => setState((p) => ({ ...p, opened: true }));
|
||||
|
||||
const slotProps = {
|
||||
onClick,
|
||||
children,
|
||||
};
|
||||
|
||||
return <Slot {...slotProps} />;
|
||||
return (
|
||||
<Slot
|
||||
onClick={onClick}
|
||||
aria-controls={ids.dialog}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -74,33 +68,15 @@ const DialogTrigger = ({ children }: DialogTriggerProps) => {
|
||||
*/
|
||||
|
||||
const [dialogOverlayVariant, resolveDialogOverlayVariant] = vcn({
|
||||
base: "fixed inset-0 z-50 w-full h-full max-w-screen transition-all duration-300 flex flex-col justify-center items-center",
|
||||
base: "fixed inset-0 z-50 w-full h-screen overflow-y-auto max-w-screen transition-all duration-300 backdrop-blur-md backdrop-brightness-75 [&>div]:p-6",
|
||||
variants: {
|
||||
opened: {
|
||||
true: "pointer-events-auto opacity-100",
|
||||
false: "pointer-events-none opacity-0",
|
||||
},
|
||||
blur: {
|
||||
sm: "backdrop-blur-sm",
|
||||
md: "backdrop-blur-md",
|
||||
lg: "backdrop-blur-lg",
|
||||
},
|
||||
darken: {
|
||||
sm: "backdrop-brightness-90",
|
||||
md: "backdrop-brightness-75",
|
||||
lg: "backdrop-brightness-50",
|
||||
},
|
||||
padding: {
|
||||
sm: "p-4",
|
||||
md: "p-6",
|
||||
lg: "p-8",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
opened: false,
|
||||
blur: "md",
|
||||
darken: "md",
|
||||
padding: "md",
|
||||
},
|
||||
});
|
||||
|
||||
@ -112,20 +88,36 @@ interface DialogOverlay
|
||||
|
||||
const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
||||
(props, ref) => {
|
||||
const [{ opened }, setContext] = useDialogContext();
|
||||
const [variantProps, otherPropsCompressed] = resolveDialogOverlayVariant({
|
||||
...props,
|
||||
opened,
|
||||
});
|
||||
const [{ opened, ids }, setContext] = useDialogContext();
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveDialogOverlayVariant(props);
|
||||
const { children, closeOnClick, onClick, ...otherPropsExtracted } =
|
||||
otherPropsCompressed;
|
||||
return (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
|
||||
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { isMounted, isRendered } = useAnimatedMount(opened, internalRef);
|
||||
|
||||
const document = useDocument();
|
||||
if (!document) return null;
|
||||
|
||||
return isMounted
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
{...otherPropsExtracted}
|
||||
ref={ref}
|
||||
className={dialogOverlayVariant(variantProps)}
|
||||
id={ids.dialog}
|
||||
ref={(el) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
className={dialogOverlayVariant({
|
||||
...variantProps,
|
||||
opened: isRendered,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (closeOnClick) {
|
||||
setContext((p) => ({ ...p, opened: false }));
|
||||
@ -133,14 +125,23 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{/* Layer for overflow positioning */}
|
||||
<div
|
||||
className={
|
||||
"w-screen max-w-full min-h-screen flex flex-col justify-center items-center"
|
||||
}
|
||||
>
|
||||
<InnerDialogContext.Provider value={{ isMounted, isRendered }}>
|
||||
{children}
|
||||
</InnerDialogContext.Provider>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
: null;
|
||||
},
|
||||
);
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
/**
|
||||
* =========================
|
||||
@ -149,69 +150,51 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
||||
*/
|
||||
|
||||
const [dialogContentVariant, resolveDialogContentVariant] = vcn({
|
||||
base: "transition-transform duration-300 bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800",
|
||||
base: "transition-transform duration-300 bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800 p-6 w-full max-w-xl rounded-md flex flex-col justify-start items-start gap-6",
|
||||
variants: {
|
||||
opened: {
|
||||
true: "scale-100",
|
||||
false: "scale-50",
|
||||
},
|
||||
size: {
|
||||
fit: "w-fit",
|
||||
fullSm: "w-full max-w-sm",
|
||||
fullMd: "w-full max-w-md",
|
||||
fullLg: "w-full max-w-lg",
|
||||
fullXl: "w-full max-w-xl",
|
||||
full2xl: "w-full max-w-2xl",
|
||||
},
|
||||
rounded: {
|
||||
sm: "rounded-sm",
|
||||
md: "rounded-md",
|
||||
lg: "rounded-lg",
|
||||
xl: "rounded-xl",
|
||||
},
|
||||
padding: {
|
||||
sm: "p-4",
|
||||
md: "p-6",
|
||||
lg: "p-8",
|
||||
},
|
||||
gap: {
|
||||
sm: "space-y-4",
|
||||
md: "space-y-6",
|
||||
lg: "space-y-8",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
opened: false,
|
||||
size: "fit",
|
||||
rounded: "md",
|
||||
padding: "md",
|
||||
gap: "md",
|
||||
},
|
||||
});
|
||||
|
||||
interface DialogContent
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<"div">,
|
||||
Omit<VariantProps<typeof dialogContentVariant>, "opened"> {}
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContent>(
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
(props, ref) => {
|
||||
const [{ opened }] = useDialogContext();
|
||||
const [variantProps, otherPropsCompressed] = resolveDialogContentVariant({
|
||||
...props,
|
||||
opened,
|
||||
});
|
||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const [{ ids }] = useDialogContext();
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveDialogContentVariant(props);
|
||||
const { isRendered } = useInnerDialogContext();
|
||||
const { children, onClick, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
return (
|
||||
<div
|
||||
{...otherPropsExtracted}
|
||||
ref={ref}
|
||||
className={dialogContentVariant(variantProps)}
|
||||
role="dialog"
|
||||
aria-labelledby={ids.title}
|
||||
aria-describedby={ids.description}
|
||||
className={dialogContentVariant({
|
||||
...variantProps,
|
||||
opened: isRendered,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
/**
|
||||
* =========================
|
||||
@ -242,17 +225,9 @@ const DialogClose = ({ children }: DialogCloseProps) => {
|
||||
*/
|
||||
|
||||
const [dialogHeaderVariant, resolveDialogHeaderVariant] = vcn({
|
||||
base: "flex flex-col",
|
||||
variants: {
|
||||
gap: {
|
||||
sm: "gap-2",
|
||||
md: "gap-4",
|
||||
lg: "gap-6",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
gap: "sm",
|
||||
},
|
||||
base: "flex flex-col gap-2",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface DialogHeaderProps
|
||||
@ -276,6 +251,8 @@ const DialogHeader = React.forwardRef<HTMLElement, DialogHeaderProps>(
|
||||
},
|
||||
);
|
||||
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* DialogTitle / DialogSubtitle
|
||||
@ -283,91 +260,69 @@ const DialogHeader = React.forwardRef<HTMLElement, DialogHeaderProps>(
|
||||
*/
|
||||
|
||||
const [dialogTitleVariant, resolveDialogTitleVariant] = vcn({
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-lg",
|
||||
md: "text-xl",
|
||||
lg: "text-2xl",
|
||||
},
|
||||
weight: {
|
||||
sm: "font-medium",
|
||||
md: "font-semibold",
|
||||
lg: "font-bold",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
size: "md",
|
||||
weight: "lg",
|
||||
},
|
||||
base: "text-xl font-bold",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface DialogTitleProps
|
||||
extends React.ComponentPropsWithoutRef<"h1">,
|
||||
VariantProps<typeof dialogTitleVariant> {}
|
||||
|
||||
const [dialogSubtitleVariant, resolveDialogSubtitleVariant] = vcn({
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-lg",
|
||||
},
|
||||
opacity: {
|
||||
sm: "opacity-60",
|
||||
md: "opacity-70",
|
||||
lg: "opacity-80",
|
||||
},
|
||||
weight: {
|
||||
sm: "font-light",
|
||||
md: "font-normal",
|
||||
lg: "font-medium",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
size: "sm",
|
||||
opacity: "sm",
|
||||
weight: "md",
|
||||
},
|
||||
const [dialogDescriptionVariant, resolveDialogDescriptionVariant] = vcn({
|
||||
base: "text-sm opacity-60 font-normal",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface DialogSubtitleProps
|
||||
interface DialogDescriptionProps
|
||||
extends React.ComponentPropsWithoutRef<"h2">,
|
||||
VariantProps<typeof dialogSubtitleVariant> {}
|
||||
VariantProps<typeof dialogDescriptionVariant> {}
|
||||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||
(props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveDialogTitleVariant(props);
|
||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const [{ ids }] = useDialogContext();
|
||||
return (
|
||||
<h1
|
||||
{...otherPropsExtracted}
|
||||
ref={ref}
|
||||
className={dialogTitleVariant(variantProps)}
|
||||
id={ids.title}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogSubtitle = React.forwardRef<
|
||||
const DialogDescription = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
DialogSubtitleProps
|
||||
DialogDescriptionProps
|
||||
>((props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveDialogSubtitleVariant(props);
|
||||
resolveDialogDescriptionVariant(props);
|
||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const [{ ids }] = useDialogContext();
|
||||
return (
|
||||
<h2
|
||||
{...otherPropsExtracted}
|
||||
ref={ref}
|
||||
className={dialogSubtitleVariant(variantProps)}
|
||||
className={dialogDescriptionVariant(variantProps)}
|
||||
id={ids.description}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
});
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
// renamed DialogSubtitle -> DialogDescription
|
||||
// keep DialogSubtitle for backward compatibility
|
||||
const DialogSubtitle = DialogDescription;
|
||||
|
||||
/**
|
||||
* =========================
|
||||
@ -376,17 +331,9 @@ const DialogSubtitle = React.forwardRef<
|
||||
*/
|
||||
|
||||
const [dialogFooterVariant, resolveDialogFooterVariant] = vcn({
|
||||
base: "flex flex-col items-end sm:flex-row sm:items-center sm:justify-end",
|
||||
variants: {
|
||||
gap: {
|
||||
sm: "gap-2",
|
||||
md: "gap-4",
|
||||
lg: "gap-6",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
gap: "md",
|
||||
},
|
||||
base: "flex w-full flex-col items-end sm:flex-row sm:items-center sm:justify-end gap-2",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface DialogFooterProps
|
||||
@ -399,19 +346,46 @@ const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
||||
resolveDialogFooterVariant(props);
|
||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
return (
|
||||
<div
|
||||
<footer
|
||||
{...otherPropsExtracted}
|
||||
ref={ref}
|
||||
className={dialogFooterVariant(variantProps)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
},
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
interface DialogControllers {
|
||||
context: IDialogContext;
|
||||
|
||||
setContext: React.Dispatch<React.SetStateAction<IDialogContext>>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface DialogControllerProps {
|
||||
children: (controllers: DialogControllers) => ReactNode;
|
||||
}
|
||||
|
||||
const DialogController = (props: DialogControllerProps) => {
|
||||
return (
|
||||
<DialogContext.Consumer>
|
||||
{([context, setContext]) =>
|
||||
props.children({
|
||||
context,
|
||||
setContext,
|
||||
close() {
|
||||
setContext((p) => ({ ...p, opened: false }));
|
||||
},
|
||||
})
|
||||
}
|
||||
</DialogContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
useDialogContext,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
DialogOverlay,
|
||||
@ -420,5 +394,7 @@ export {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogSubtitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogController,
|
||||
};
|
60
packages/react/components/Dialog/Context.ts
Normal file
60
packages/react/components/Dialog/Context.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
createContext,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* DialogContext
|
||||
* =========================
|
||||
*/
|
||||
|
||||
export interface IDialogContext {
|
||||
opened: boolean;
|
||||
ids: {
|
||||
dialog: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const initialDialogContext: IDialogContext = {
|
||||
opened: false,
|
||||
ids: { title: "", dialog: "", description: "" },
|
||||
};
|
||||
export const DialogContext = createContext<
|
||||
[IDialogContext, Dispatch<SetStateAction<IDialogContext>>]
|
||||
>([
|
||||
initialDialogContext,
|
||||
() => {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"It seems like you're using DialogContext outside of a provider.",
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
export const useDialogContext = () => useContext(DialogContext);
|
||||
|
||||
/**
|
||||
* ===================
|
||||
* InnerDialogContext
|
||||
* ===================
|
||||
*/
|
||||
|
||||
export interface IInnerDialogContext {
|
||||
isMounted: boolean;
|
||||
isRendered: boolean;
|
||||
}
|
||||
export const initialInnerDialogContext: IInnerDialogContext = {
|
||||
isMounted: false,
|
||||
isRendered: false,
|
||||
};
|
||||
export const InnerDialogContext = createContext<IInnerDialogContext>(
|
||||
initialInnerDialogContext,
|
||||
);
|
||||
|
||||
export const useInnerDialogContext = () => useContext(InnerDialogContext);
|
2
packages/react/components/Dialog/index.ts
Normal file
2
packages/react/components/Dialog/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./Component";
|
||||
export { useDialogContext } from "./Context";
|
@ -1,13 +1,20 @@
|
||||
import {
|
||||
type AsChild,
|
||||
Slot,
|
||||
type VariantProps,
|
||||
useAnimatedMount,
|
||||
useDocument,
|
||||
vcn,
|
||||
} from "@pswui-lib";
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
type ComponentPropsWithoutRef,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface IDrawerContext {
|
||||
@ -15,6 +22,8 @@ interface IDrawerContext {
|
||||
closeThreshold: number;
|
||||
movePercentage: number;
|
||||
isDragging: boolean;
|
||||
isMounted: boolean;
|
||||
isRendered: boolean;
|
||||
leaveWhileDragging: boolean;
|
||||
}
|
||||
const DrawerContextInitial: IDrawerContext = {
|
||||
@ -22,6 +31,8 @@ const DrawerContextInitial: IDrawerContext = {
|
||||
closeThreshold: 0.3,
|
||||
movePercentage: 0,
|
||||
isDragging: false,
|
||||
isMounted: false,
|
||||
isRendered: false,
|
||||
leaveWhileDragging: false,
|
||||
};
|
||||
const DrawerContext = React.createContext<
|
||||
@ -49,14 +60,15 @@ const DrawerRoot = ({ children, closeThreshold, opened }: DrawerRootProps) => {
|
||||
opened: opened ?? DrawerContextInitial.opened,
|
||||
closeThreshold: closeThreshold ?? DrawerContextInitial.closeThreshold,
|
||||
});
|
||||
const setState = state[1];
|
||||
|
||||
useEffect(() => {
|
||||
state[1]((prev) => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
opened: opened ?? prev.opened,
|
||||
closeThreshold: closeThreshold ?? prev.closeThreshold,
|
||||
}));
|
||||
}, [closeThreshold, opened]);
|
||||
}, [closeThreshold, opened, setState]);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={state}>{children}</DrawerContext.Provider>
|
||||
@ -77,7 +89,7 @@ const [drawerOverlayVariant, resolveDrawerOverlayVariantProps] = vcn({
|
||||
base: "fixed inset-0 transition-[backdrop-filter] duration-75",
|
||||
variants: {
|
||||
opened: {
|
||||
true: "pointer-events-auto select-auto",
|
||||
true: "pointer-events-auto select-auto touch-none", // touch-none to prevent outside scrolling
|
||||
false: "pointer-events-none select-none",
|
||||
},
|
||||
},
|
||||
@ -95,8 +107,14 @@ interface DrawerOverlayProps
|
||||
|
||||
const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
|
||||
(props, ref) => {
|
||||
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||
const [state, setState] = useContext(DrawerContext);
|
||||
|
||||
const { isMounted, isRendered } = useAnimatedMount(
|
||||
state.isDragging ? true : state.opened,
|
||||
internalRef,
|
||||
);
|
||||
|
||||
const [variantProps, restPropsCompressed] =
|
||||
resolveDrawerOverlayVariantProps(props);
|
||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||
@ -118,25 +136,46 @@ const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
|
||||
: 1
|
||||
})`;
|
||||
|
||||
return createPortal(
|
||||
<Comp
|
||||
{...restPropsExtracted}
|
||||
className={drawerOverlayVariant({
|
||||
...variantProps,
|
||||
opened: state.isDragging ? true : state.opened,
|
||||
})}
|
||||
onClick={onOutsideClick}
|
||||
style={{
|
||||
backdropFilter,
|
||||
WebkitBackdropFilter: backdropFilter,
|
||||
transitionDuration: state.isDragging ? "0s" : undefined,
|
||||
}}
|
||||
ref={ref}
|
||||
/>,
|
||||
document.body,
|
||||
const document = useDocument();
|
||||
if (!document) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerContext.Provider
|
||||
value={[{ ...state, isMounted, isRendered }, setState]}
|
||||
>
|
||||
{isMounted
|
||||
? createPortal(
|
||||
<Comp
|
||||
{...restPropsExtracted}
|
||||
className={drawerOverlayVariant({
|
||||
...variantProps,
|
||||
opened: isRendered,
|
||||
})}
|
||||
onClick={onOutsideClick}
|
||||
style={{
|
||||
backdropFilter,
|
||||
WebkitBackdropFilter: backdropFilter,
|
||||
transitionDuration: state.isDragging ? "0s" : undefined,
|
||||
}}
|
||||
ref={(el: HTMLDivElement) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</DrawerContext.Provider>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
DrawerOverlay.displayName = "DrawerOverlay";
|
||||
|
||||
const drawerContentColors = {
|
||||
background: "bg-white dark:bg-black",
|
||||
@ -144,28 +183,55 @@ const drawerContentColors = {
|
||||
};
|
||||
|
||||
const [drawerContentVariant, resolveDrawerContentVariantProps] = vcn({
|
||||
base: `fixed ${drawerContentColors.background} ${drawerContentColors.border} transition-all p-4 flex flex-col justify-between gap-8`,
|
||||
base: `fixed ${drawerContentColors.background} ${drawerContentColors.border} transition-all p-4 flex flex-col justify-between gap-8 overflow-auto`,
|
||||
variants: {
|
||||
position: {
|
||||
top: "top-0 inset-x-0 w-full max-w-screen rounded-t-lg border-b-2",
|
||||
bottom: "bottom-0 inset-x-0 w-full max-w-screen rounded-b-lg border-t-2",
|
||||
left: "left-0 inset-y-0 h-screen rounded-l-lg border-r-2",
|
||||
right: "right-0 inset-y-0 h-screen rounded-r-lg border-l-2",
|
||||
top: "top-0 w-full max-w-screen rounded-t-lg border-b-2",
|
||||
bottom: "bottom-0 w-full max-w-screen rounded-b-lg border-t-2",
|
||||
left: "left-0 h-screen rounded-l-lg border-r-2",
|
||||
right: "right-0 h-screen rounded-r-lg border-l-2",
|
||||
},
|
||||
maxSize: {
|
||||
sm: "[&.left-0]:max-w-sm [&.right-0]:max-w-sm",
|
||||
md: "[&.left-0]:max-w-md [&.right-0]:max-w-md",
|
||||
lg: "[&.left-0]:max-w-lg [&.right-0]:max-w-lg",
|
||||
xl: "[&.left-0]:max-w-xl [&.right-0]:max-w-xl",
|
||||
},
|
||||
opened: {
|
||||
true: "touch-none",
|
||||
true: "",
|
||||
false:
|
||||
"[&.top-0]:-translate-y-full [&.bottom-0]:translate-y-full [&.left-0]:-translate-x-full [&.right-0]:translate-x-full",
|
||||
},
|
||||
internal: {
|
||||
true: "relative",
|
||||
false: "fixed",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
position: "left",
|
||||
opened: false,
|
||||
maxSize: "sm",
|
||||
internal: false,
|
||||
},
|
||||
dynamics: [
|
||||
({ position, internal }) => {
|
||||
if (!internal) {
|
||||
if (["top", "bottom"].includes(position)) {
|
||||
return "inset-x-0";
|
||||
}
|
||||
return "inset-y-0";
|
||||
}
|
||||
|
||||
return "w-fit";
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
interface DrawerContentProps
|
||||
extends Omit<VariantProps<typeof drawerContentVariant>, "opened">,
|
||||
extends Omit<
|
||||
VariantProps<typeof drawerContentVariant>,
|
||||
"opened" | "internal"
|
||||
>,
|
||||
AsChild,
|
||||
ComponentPropsWithoutRef<"div"> {}
|
||||
|
||||
@ -300,15 +366,15 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
||||
window.removeEventListener("touchmove", onMouseMove);
|
||||
window.removeEventListener("touchend", onMouseUp);
|
||||
};
|
||||
}, [state, dragState]);
|
||||
}, [state, setState, dragState, position]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={drawerContentVariant({
|
||||
...variantProps,
|
||||
opened: true,
|
||||
opened: state.isRendered,
|
||||
className: dragState.isDragging
|
||||
? "transition-[width_0ms]"
|
||||
? "transition-[width] duration-0"
|
||||
: variantProps.className,
|
||||
})}
|
||||
style={
|
||||
@ -320,6 +386,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
||||
0) +
|
||||
(position === "top" ? dragState.delta : -dragState.delta),
|
||||
padding: 0,
|
||||
[`padding${position.slice(0, 1).toUpperCase()}${position.slice(1)}`]: `${dragState.delta}px`,
|
||||
}
|
||||
: {
|
||||
width:
|
||||
@ -327,6 +394,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
||||
0) +
|
||||
(position === "left" ? dragState.delta : -dragState.delta),
|
||||
padding: 0,
|
||||
[`padding${position.slice(0, 1).toUpperCase()}${position.slice(1)}`]: `${dragState.delta}px`,
|
||||
}
|
||||
: { width: 0, height: 0, padding: 0 }
|
||||
}
|
||||
@ -335,18 +403,20 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
||||
{...restPropsExtracted}
|
||||
className={drawerContentVariant({
|
||||
...variantProps,
|
||||
opened: state.opened,
|
||||
opened: state.isRendered,
|
||||
internal: true,
|
||||
})}
|
||||
style={{
|
||||
transform: dragState.isDragging
|
||||
? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${
|
||||
dragState.delta
|
||||
}px)`
|
||||
: undefined,
|
||||
transform:
|
||||
dragState.isDragging &&
|
||||
((["top", "left"].includes(position) && dragState.delta < 0) ||
|
||||
(["bottom", "right"].includes(position) && dragState.delta > 0))
|
||||
? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${dragState.delta}px)`
|
||||
: undefined,
|
||||
transitionDuration: dragState.isDragging ? "0s" : undefined,
|
||||
userSelect: dragState.isDragging ? "none" : undefined,
|
||||
}}
|
||||
ref={(el) => {
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
@ -373,6 +443,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
||||
);
|
||||
},
|
||||
);
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerClose = forwardRef<
|
||||
HTMLButtonElement,
|
||||
@ -387,6 +458,7 @@ const DrawerClose = forwardRef<
|
||||
/>
|
||||
);
|
||||
});
|
||||
DrawerClose.displayName = "DrawerClose";
|
||||
|
||||
const [drawerHeaderVariant, resolveDrawerHeaderVariantProps] = vcn({
|
||||
base: "flex flex-col gap-2",
|
||||
@ -404,8 +476,11 @@ const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
|
||||
const [variantProps, restPropsCompressed] =
|
||||
resolveDrawerHeaderVariantProps(props);
|
||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<div
|
||||
<Comp
|
||||
{...restPropsExtracted}
|
||||
className={drawerHeaderVariant(variantProps)}
|
||||
ref={ref}
|
||||
@ -413,9 +488,10 @@ const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
|
||||
);
|
||||
},
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({
|
||||
base: "flex-grow",
|
||||
base: "grow",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
@ -429,14 +505,18 @@ const DrawerBody = forwardRef<HTMLDivElement, DrawerBodyProps>((props, ref) => {
|
||||
const [variantProps, restPropsCompressed] =
|
||||
resolveDrawerBodyVariantProps(props);
|
||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<div
|
||||
<Comp
|
||||
{...restPropsExtracted}
|
||||
className={drawerBodyVariant(variantProps)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DrawerBody.displayName = "DrawerBody";
|
||||
|
||||
const [drawerFooterVariant, resolveDrawerFooterVariantProps] = vcn({
|
||||
base: "flex flex-row justify-end gap-2",
|
||||
@ -454,8 +534,11 @@ const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
|
||||
const [variantProps, restPropsCompressed] =
|
||||
resolveDrawerFooterVariantProps(props);
|
||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<div
|
||||
<Comp
|
||||
{...restPropsExtracted}
|
||||
className={drawerFooterVariant(variantProps)}
|
||||
ref={ref}
|
||||
@ -463,6 +546,7 @@ const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
|
||||
);
|
||||
},
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
export {
|
||||
DrawerRoot,
|
||||
|
@ -0,0 +1,183 @@
|
||||
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
Form Item Context
|
||||
**/
|
||||
interface IFormItemContext {
|
||||
invalid?: string | null | undefined;
|
||||
}
|
||||
const FormItemContext = createContext<IFormItemContext>({});
|
||||
|
||||
/**
|
||||
FormItem
|
||||
**/
|
||||
const [formItemVariant, resolveFormItemVariantProps] = vcn({
|
||||
base: "flex flex-col gap-2 items-start w-full",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface FormItemProps
|
||||
extends VariantProps<typeof formItemVariant>,
|
||||
AsChild,
|
||||
ComponentPropsWithoutRef<"label"> {
|
||||
invalid?: string | null | undefined;
|
||||
}
|
||||
|
||||
const FormItem = forwardRef<HTMLLabelElement, FormItemProps>((props, ref) => {
|
||||
const [variantProps, restPropsCompressed] =
|
||||
resolveFormItemVariantProps(props);
|
||||
const { asChild, children, invalid, ...restPropsExtracted } =
|
||||
restPropsCompressed;
|
||||
const innerRef = useRef<HTMLLabelElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const invalidAsString = invalid ? invalid : "";
|
||||
|
||||
const input = innerRef.current?.querySelector?.("input");
|
||||
if (!input) return;
|
||||
|
||||
input.setCustomValidity(invalidAsString);
|
||||
}, [invalid]);
|
||||
|
||||
const Comp = asChild ? Slot : "label";
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ invalid }}>
|
||||
<Comp
|
||||
ref={(el: HTMLLabelElement | null) => {
|
||||
innerRef.current = el;
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
className={formItemVariant(variantProps)}
|
||||
{...restPropsExtracted}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
/**
|
||||
FormLabel
|
||||
**/
|
||||
const [formLabelVariant, resolveFormLabelVariantProps] = vcn({
|
||||
base: "text-sm font-bold",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface FormLabelProps
|
||||
extends VariantProps<typeof formLabelVariant>,
|
||||
AsChild,
|
||||
ComponentPropsWithoutRef<"span"> {}
|
||||
|
||||
const FormLabel = forwardRef<HTMLSpanElement, FormLabelProps>((props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveFormLabelVariantProps(props);
|
||||
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={formLabelVariant(variantProps)}
|
||||
{...otherPropsExtracted}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
/**
|
||||
FormHelper
|
||||
**/
|
||||
const [formHelperVariant, resolveFormHelperVariantProps] = vcn({
|
||||
base: "opacity-75 text-sm font-light",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface FormHelperProps
|
||||
extends VariantProps<typeof formHelperVariant>,
|
||||
AsChild,
|
||||
ComponentPropsWithoutRef<"span"> {
|
||||
hiddenOnInvalid?: boolean;
|
||||
}
|
||||
|
||||
const FormHelper = forwardRef<HTMLSpanElement, FormHelperProps>(
|
||||
(props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveFormHelperVariantProps(props);
|
||||
const { asChild, children, hiddenOnInvalid, ...otherPropsExtracted } =
|
||||
otherPropsCompressed;
|
||||
const item = useContext(FormItemContext);
|
||||
|
||||
if (item.invalid && hiddenOnInvalid) return null;
|
||||
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={formHelperVariant(variantProps)}
|
||||
{...otherPropsExtracted}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
FormHelper.displayName = "FormHelper";
|
||||
|
||||
/**
|
||||
FormError
|
||||
**/
|
||||
const [formErrorVariant, resolveFormErrorVariantProps] = vcn({
|
||||
base: "text-sm text-red-500",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface FormErrorProps
|
||||
extends VariantProps<typeof formErrorVariant>,
|
||||
AsChild,
|
||||
Omit<ComponentPropsWithoutRef<"span">, "children"> {}
|
||||
|
||||
const FormError = forwardRef<HTMLSpanElement, FormErrorProps>((props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveFormErrorVariantProps(props);
|
||||
const { asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
|
||||
const item = useContext(FormItemContext);
|
||||
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return item.invalid ? (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={formErrorVariant(variantProps)}
|
||||
{...otherPropsExtracted}
|
||||
>
|
||||
{item.invalid}
|
||||
</Comp>
|
||||
) : null;
|
||||
});
|
||||
FormError.displayName = "FormError";
|
||||
|
||||
export { FormItem, FormLabel, FormHelper, FormError };
|
@ -1,37 +1,38 @@
|
||||
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||
import React from "react";
|
||||
import { VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
const inputColors = {
|
||||
background: {
|
||||
default: "bg-neutral-50 dark:bg-neutral-900",
|
||||
hover: "hover:bg-neutral-100 dark:hover:bg-neutral-800",
|
||||
hover:
|
||||
"hover:bg-neutral-100 dark:hover:bg-neutral-800 has-[input:hover]:bg-neutral-100 dark:has-[input:hover]:bg-neutral-800",
|
||||
invalid:
|
||||
"invalid:bg-red-100 invalid:dark:bg-red-900 has-[input:invalid]:bg-red-100 dark:has-[input:invalid]:bg-red-900",
|
||||
"invalid:bg-red-100 dark:invalid:bg-red-900 has-[input:invalid]:bg-red-100 dark:has-[input:invalid]:bg-red-900",
|
||||
invalidHover:
|
||||
"hover:invalid:bg-red-200 dark:hover:invalid:bg-red-800 has-[input:invalid:hover]:bg-red-200 dark:has-[input:invalid:hover]:bg-red-800",
|
||||
},
|
||||
border: {
|
||||
default: "border-neutral-400 dark:border-neutral-600",
|
||||
invalid:
|
||||
"invalid:border-red-400 invalid:dark:border-red-600 has-[input:invalid]:border-red-400 dark:has-[input:invalid]:border-red-600",
|
||||
"invalid:border-red-400 dark:invalid:border-red-600 has-[input:invalid]:border-red-400 dark:has-[input:invalid]:border-red-600",
|
||||
},
|
||||
ring: {
|
||||
default: "ring-transparent focus-within:ring-current",
|
||||
invalid:
|
||||
"invalid:focus-within:ring-red-400 invalid:focus-within:dark:ring-red-600 has-[input:invalid]:focus-within:ring-red-400 dark:has-[input:invalid]:focus-within:ring-red-600",
|
||||
"invalid:focus-within:ring-red-400 dark:invalid:focus-within:ring-red-600 has-[input:invalid]:focus-within:ring-red-400 dark:has-[input:invalid]:focus-within:ring-red-600",
|
||||
},
|
||||
};
|
||||
|
||||
const [inputVariant, resolveInputVariantProps] = vcn({
|
||||
base: `rounded-md p-2 border ring-1 outline-none transition-all duration-200 [appearance:textfield] disabled:brightness-50 disabled:saturate-0 disabled:cursor-not-allowed ${inputColors.background.default} ${inputColors.background.hover} ${inputColors.border.default} ${inputColors.ring.default} ${inputColors.background.invalid} ${inputColors.background.invalidHover} ${inputColors.border.invalid} ${inputColors.ring.invalid} [&:has(input)]:flex [&:has(input)]:w-fit`,
|
||||
base: `rounded-md p-2 border ring-1 outline-hidden transition-all duration-200 [appearance:textfield] disabled:brightness-50 disabled:saturate-0 disabled:cursor-not-allowed ${inputColors.background.default} ${inputColors.background.hover} ${inputColors.border.default} ${inputColors.ring.default} ${inputColors.background.invalid} ${inputColors.background.invalidHover} ${inputColors.border.invalid} ${inputColors.ring.invalid} [&:has(input)]:flex`,
|
||||
variants: {
|
||||
unstyled: {
|
||||
true: "bg-transparent border-none p-0 ring-0 hover:bg-transparent invalid:hover:bg-transparent invalid:focus-within:bg-transparent invalid:focus-within:ring-0",
|
||||
false: "",
|
||||
},
|
||||
full: {
|
||||
true: "w-full",
|
||||
false: "w-fit",
|
||||
true: "[&:has(input)]:w-full w-full",
|
||||
false: "[&:has(input)]:w-fit w-fit",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
@ -42,7 +43,8 @@ const [inputVariant, resolveInputVariantProps] = vcn({
|
||||
|
||||
interface InputFrameProps
|
||||
extends VariantProps<typeof inputVariant>,
|
||||
React.ComponentPropsWithoutRef<"label"> {
|
||||
React.ComponentPropsWithoutRef<"label">,
|
||||
AsChild {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@ -50,19 +52,22 @@ const InputFrame = React.forwardRef<HTMLLabelElement, InputFrameProps>(
|
||||
(props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveInputVariantProps(props);
|
||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
|
||||
const Comp = asChild ? Slot : "label";
|
||||
|
||||
return (
|
||||
<label
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={`group/input-frame ${inputVariant(variantProps)}`}
|
||||
{...otherPropsExtracted}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
InputFrame.displayName = "InputFrame";
|
||||
|
||||
interface InputProps
|
||||
extends VariantProps<typeof inputVariant>,
|
||||
@ -92,7 +97,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
const innerRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (innerRef && innerRef.current) {
|
||||
if (innerRef?.current) {
|
||||
innerRef.current.setCustomValidity(invalid ?? "");
|
||||
}
|
||||
}, [invalid]);
|
||||
@ -113,5 +118,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { InputFrame, Input };
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { type VariantProps, vcn } from "@pswui-lib";
|
||||
import React from "react";
|
||||
import { VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
const [labelVariant, resolveLabelVariantProps] = vcn({
|
||||
base: "has-[input[disabled]]:brightness-75 has-[input[disabled]]:cursor-not-allowed has-[input:invalid]:text-red-500",
|
||||
@ -29,5 +29,6 @@ const Label = React.forwardRef<HTMLLabelElement, LabelProps>((props, ref) => {
|
||||
/>
|
||||
);
|
||||
});
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||
import React, { useContext, useEffect, useRef } from "react";
|
||||
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
interface IPopoverContext {
|
||||
controlled: boolean;
|
||||
opened: boolean;
|
||||
}
|
||||
|
||||
@ -10,6 +11,7 @@ const PopoverContext = React.createContext<
|
||||
>([
|
||||
{
|
||||
opened: false,
|
||||
controlled: false,
|
||||
},
|
||||
() => {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
@ -26,14 +28,23 @@ interface PopoverProps extends AsChild {
|
||||
}
|
||||
|
||||
const Popover = ({ children, opened, asChild }: PopoverProps) => {
|
||||
const state = React.useState<IPopoverContext>({
|
||||
const [state, setState] = React.useState<IPopoverContext>({
|
||||
opened: opened ?? false,
|
||||
controlled: opened !== undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setState((p) => ({
|
||||
...p,
|
||||
controlled: opened !== undefined,
|
||||
opened: opened !== undefined ? opened : p.opened,
|
||||
}));
|
||||
}, [opened]);
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={state}>
|
||||
<PopoverContext.Provider value={[state, setState]}>
|
||||
<Comp className="relative">{children}</Comp>
|
||||
</PopoverContext.Provider>
|
||||
);
|
||||
@ -54,26 +65,25 @@ const popoverColors = {
|
||||
};
|
||||
|
||||
const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
|
||||
base: `absolute transition-all duration-150 border rounded-lg p-0.5 [&>*]:w-full ${popoverColors.background} ${popoverColors.border}`,
|
||||
base: `absolute transition-all duration-150 border rounded-lg p-0.5 z-10 *:w-full ${popoverColors.background} ${popoverColors.border}`,
|
||||
variants: {
|
||||
direction: {
|
||||
row: "",
|
||||
col: "",
|
||||
},
|
||||
anchor: {
|
||||
topLeft:
|
||||
"bottom-[calc(100%+var(--popover-offset))] right-[calc(100%+var(--popover-offset))] origin-bottom-right",
|
||||
topCenter:
|
||||
"bottom-[calc(100%+var(--popover-offset))] left-1/2 -translate-x-1/2 origin-bottom-center",
|
||||
topRight:
|
||||
"bottom-[calc(100%+var(--popover-offset))] left-[calc(100%+var(--popover-offset))] origin-bottom-left",
|
||||
middleLeft: "top-1/2 translate-y-1/2 right-full origin-right",
|
||||
middleCenter:
|
||||
"top-1/2 translate-y-1/2 left-1/2 -translate-x-1/2 origin-center",
|
||||
middleRight:
|
||||
"top-1/2 translate-y-1/2 left-[calc(100%+var(--popover-offset))] origin-left",
|
||||
bottomLeft:
|
||||
"top-[calc(100%+var(--popover-offset))] right-[calc(100%+var(--popover-offset))] origin-top-right",
|
||||
bottomCenter:
|
||||
"top-[calc(100%+var(--popover-offset))] left-1/2 -translate-x-1/2 origin-top-center",
|
||||
bottomRight:
|
||||
"top-[calc(100%+var(--popover-offset))] left-[calc(100%+var(--popover-offset))] origin-top-left",
|
||||
start: "",
|
||||
middle: "",
|
||||
end: "",
|
||||
},
|
||||
align: {
|
||||
start: "",
|
||||
middle: "",
|
||||
end: "",
|
||||
},
|
||||
position: {
|
||||
start: "",
|
||||
end: "",
|
||||
},
|
||||
offset: {
|
||||
sm: "[--popover-offset:2px]",
|
||||
@ -81,15 +91,104 @@ const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
|
||||
lg: "[--popover-offset:8px]",
|
||||
},
|
||||
opened: {
|
||||
true: "opacity-1 scale-100",
|
||||
false: "opacity-0 scale-75",
|
||||
true: "opacity-1 scale-100 pointer-events-auto select-auto touch-auto",
|
||||
false: "opacity-0 scale-75 pointer-events-none select-none touch-none",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
anchor: "bottomCenter",
|
||||
direction: "col",
|
||||
anchor: "middle",
|
||||
align: "middle",
|
||||
position: "end",
|
||||
opened: false,
|
||||
offset: "md",
|
||||
},
|
||||
dynamics: [
|
||||
function originClass({ direction, anchor, position }) {
|
||||
switch (`${direction} ${position} ${anchor}` as const) {
|
||||
// left
|
||||
case "row start start":
|
||||
return "origin-top-right";
|
||||
case "row start middle":
|
||||
return "origin-right";
|
||||
case "row start end":
|
||||
return "origin-bottom-right";
|
||||
// right
|
||||
case "row end start":
|
||||
return "origin-top-left";
|
||||
case "row end middle":
|
||||
return "origin-left";
|
||||
case "row end end":
|
||||
return "origin-bottom-left";
|
||||
// top
|
||||
case "col start start":
|
||||
return "origin-bottom-left";
|
||||
case "col start middle":
|
||||
return "origin-bottom";
|
||||
case "col start end":
|
||||
return "origin-bottom-right";
|
||||
// bottom
|
||||
case "col end start":
|
||||
return "origin-top-left";
|
||||
case "col end middle":
|
||||
return "origin-top";
|
||||
case "col end end":
|
||||
return "origin-top-right";
|
||||
}
|
||||
},
|
||||
function basePositionClass({ position, direction }) {
|
||||
switch (`${direction} ${position}` as const) {
|
||||
case "col start":
|
||||
return "bottom-[calc(100%+var(--popover-offset))]";
|
||||
case "col end":
|
||||
return "top-[calc(100%+var(--popover-offset))]";
|
||||
case "row start":
|
||||
return "right-[calc(100%+var(--popover-offset))]";
|
||||
case "row end":
|
||||
return "left-[calc(100%+var(--popover-offset))]";
|
||||
}
|
||||
},
|
||||
function directionPositionClass({ direction, anchor, align }) {
|
||||
switch (`${direction} ${anchor} ${align}` as const) {
|
||||
case "col start start":
|
||||
return "left-0";
|
||||
case "col start middle":
|
||||
return "left-1/2";
|
||||
case "col start end":
|
||||
return "left-full";
|
||||
case "col middle start":
|
||||
return "left-0 -translate-x-1/2";
|
||||
case "col middle middle":
|
||||
return "left-1/2 -translate-x-1/2";
|
||||
case "col middle end":
|
||||
return "right-0 translate-x-1/2";
|
||||
case "col end start":
|
||||
return "right-full";
|
||||
case "col end middle":
|
||||
return "right-1/2";
|
||||
case "col end end":
|
||||
return "right-0";
|
||||
case "row start start":
|
||||
return "top-0";
|
||||
case "row start middle":
|
||||
return "top-1/2";
|
||||
case "row start end":
|
||||
return "top-full";
|
||||
case "row middle start":
|
||||
return "top-0 -translate-y-1/2";
|
||||
case "row middle middle":
|
||||
return "top-1/2 -translate-y-1/2";
|
||||
case "row middle end":
|
||||
return "bottom-0 translate-y-1/2";
|
||||
case "row end start":
|
||||
return "bottom-full";
|
||||
case "row end middle":
|
||||
return "bottom-1/2";
|
||||
case "row end end":
|
||||
return "bottom-0";
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
interface PopoverContentProps
|
||||
@ -101,39 +200,50 @@ const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
(props, ref) => {
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolvePopoverContentVariantProps(props);
|
||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||
const [state, setState] = useContext(PopoverContext);
|
||||
|
||||
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(e: any) {
|
||||
if (internalRef.current && !internalRef.current.contains(e.target)) {
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (
|
||||
internalRef.current &&
|
||||
!internalRef.current.contains(e.target as Node | null)
|
||||
) {
|
||||
setState((prev) => ({ ...prev, opened: false }));
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
!state.controlled &&
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
};
|
||||
}, [internalRef, setState]);
|
||||
}, [state.controlled, setState]);
|
||||
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<div
|
||||
<Comp
|
||||
{...otherPropsExtracted}
|
||||
className={popoverContentVariant({
|
||||
...variantProps,
|
||||
opened: state.opened,
|
||||
})}
|
||||
ref={(el) => {
|
||||
ref={(el: HTMLDivElement) => {
|
||||
internalRef.current = el;
|
||||
typeof ref === "function" ? ref(el) : ref && (ref.current = el);
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
PopoverContent.displayName = "PopoverContent";
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { type VariantProps, vcn } from "@pswui-lib";
|
||||
import React from "react";
|
||||
import { VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
const switchColors = {
|
||||
background: {
|
||||
@ -80,5 +80,6 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>((props, ref) => {
|
||||
</label>
|
||||
);
|
||||
});
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
||||
|
@ -1,30 +1,7 @@
|
||||
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
|
||||
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||
import React from "react";
|
||||
|
||||
interface Tab {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TabContextBody {
|
||||
tabs: Tab[];
|
||||
active: [number, string] /* index, name */;
|
||||
}
|
||||
|
||||
const TabContext = React.createContext<
|
||||
[TabContextBody, React.Dispatch<React.SetStateAction<TabContextBody>>]
|
||||
>([
|
||||
{
|
||||
tabs: [],
|
||||
active: [0, ""],
|
||||
},
|
||||
() => {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"It seems like you're using TabContext outside of provider.",
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
||||
import { type Tab, TabContext, type TabContextBody } from "./Context";
|
||||
|
||||
interface TabProviderProps {
|
||||
defaultName: string;
|
||||
@ -40,77 +17,6 @@ const TabProvider = ({ defaultName, children }: TabProviderProps) => {
|
||||
return <TabContext.Provider value={state}>{children}</TabContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides current state for tab, using context.
|
||||
* Also provides functions to control state.
|
||||
*/
|
||||
const useTabState = () => {
|
||||
const [state, setState] = React.useContext(TabContext);
|
||||
|
||||
function getActiveTab() {
|
||||
return state.active;
|
||||
}
|
||||
|
||||
function setActiveTab(name: string): void;
|
||||
function setActiveTab(index: number): void;
|
||||
function setActiveTab(param: string | number) {
|
||||
if (typeof param === "number") {
|
||||
if (param < 0 || param >= state.tabs.length) {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.error(
|
||||
`Invalid index passed to setActiveTab: ${param}, valid indices are 0 to ${
|
||||
state.tabs.length - 1
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
active: [param, prev.tabs[param].name],
|
||||
};
|
||||
});
|
||||
} else if (typeof param === "string") {
|
||||
const index = state.tabs.findIndex((tab) => tab.name === param);
|
||||
if (index === -1) {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.error(
|
||||
`Invalid name passed to setActiveTab: ${param}, valid names are ${state.tabs
|
||||
.map((tab) => tab.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTab(index);
|
||||
}
|
||||
}
|
||||
|
||||
function setPreviousActive() {
|
||||
if (state.active[0] === 0) {
|
||||
return;
|
||||
}
|
||||
setActiveTab(state.active[0] - 1);
|
||||
}
|
||||
|
||||
function setNextActive() {
|
||||
if (state.active[0] === state.tabs.length - 1) {
|
||||
return;
|
||||
}
|
||||
setActiveTab(state.active[0] + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
getActiveTab,
|
||||
setActiveTab,
|
||||
setPreviousActive,
|
||||
setNextActive,
|
||||
};
|
||||
};
|
||||
|
||||
const [TabListVariant, resolveTabListVariantProps] = vcn({
|
||||
base: "flex flex-row bg-gray-100 dark:bg-neutral-800 rounded-lg p-1.5 gap-1",
|
||||
variants: {},
|
||||
@ -124,11 +30,16 @@ interface TabListProps
|
||||
const TabList = (props: TabListProps) => {
|
||||
const [variantProps, restProps] = resolveTabListVariantProps(props);
|
||||
|
||||
return <div className={TabListVariant(variantProps)} {...restProps} />;
|
||||
return (
|
||||
<div
|
||||
className={TabListVariant(variantProps)}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const [TabTriggerVariant, resolveTabTriggerVariantProps] = vcn({
|
||||
base: "py-1.5 rounded-md flex-grow transition-all text-sm",
|
||||
base: "py-1.5 rounded-md grow transition-all text-sm",
|
||||
variants: {
|
||||
active: {
|
||||
true: "bg-white/100 dark:bg-black/100 text-black dark:text-white",
|
||||
@ -169,7 +80,8 @@ const TabTrigger = (props: TabTriggerProps) => {
|
||||
};
|
||||
});
|
||||
};
|
||||
}, [name]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [name, setContext]);
|
||||
|
||||
const Comp = props.asChild ? Slot : "button";
|
||||
|
||||
@ -212,18 +124,18 @@ const TabContent = (props: TabContentProps) => {
|
||||
const { name, ...restProps } = restPropsBeforeParse;
|
||||
const [context] = React.useContext(TabContext);
|
||||
|
||||
if (context.active[1] === name) {
|
||||
return (
|
||||
<Slot
|
||||
className={tabContentVariant({
|
||||
...variantProps,
|
||||
})}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
if (context.active[1] !== name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={tabContentVariant({
|
||||
...variantProps,
|
||||
})}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { TabProvider, useTabState, TabList, TabTrigger, TabContent };
|
||||
export { TabProvider, TabList, TabTrigger, TabContent };
|
26
packages/react/components/Tabs/Context.ts
Normal file
26
packages/react/components/Tabs/Context.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
|
||||
export interface Tab {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TabContextBody {
|
||||
tabs: Tab[];
|
||||
active: [number, string] /* index, name */;
|
||||
}
|
||||
|
||||
export const TabContext = React.createContext<
|
||||
[TabContextBody, React.Dispatch<React.SetStateAction<TabContextBody>>]
|
||||
>([
|
||||
{
|
||||
tabs: [],
|
||||
active: [0, ""],
|
||||
},
|
||||
() => {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"It seems like you're using TabContext outside of provider.",
|
||||
);
|
||||
}
|
||||
},
|
||||
]);
|
74
packages/react/components/Tabs/Hook.ts
Normal file
74
packages/react/components/Tabs/Hook.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
|
||||
import { TabContext } from "./Context";
|
||||
|
||||
/**
|
||||
* Provides current state for tab, using context.
|
||||
* Also provides functions to control state.
|
||||
*/
|
||||
export const useTabState = () => {
|
||||
const [state, setState] = React.useContext(TabContext);
|
||||
|
||||
function getActiveTab() {
|
||||
return state.active;
|
||||
}
|
||||
|
||||
function setActiveTab(name: string): void;
|
||||
function setActiveTab(index: number): void;
|
||||
function setActiveTab(param: string | number) {
|
||||
if (typeof param === "number") {
|
||||
if (param < 0 || param >= state.tabs.length) {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.error(
|
||||
`Invalid index passed to setActiveTab: ${param}, valid indices are 0 to ${
|
||||
state.tabs.length - 1
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
active: [param, prev.tabs[param].name],
|
||||
};
|
||||
});
|
||||
} else if (typeof param === "string") {
|
||||
const index = state.tabs.findIndex((tab) => tab.name === param);
|
||||
if (index === -1) {
|
||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||
console.error(
|
||||
`Invalid name passed to setActiveTab: ${param}, valid names are ${state.tabs
|
||||
.map((tab) => tab.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTab(index);
|
||||
}
|
||||
}
|
||||
|
||||
function setPreviousActive() {
|
||||
if (state.active[0] === 0) {
|
||||
return;
|
||||
}
|
||||
setActiveTab(state.active[0] - 1);
|
||||
}
|
||||
|
||||
function setNextActive() {
|
||||
if (state.active[0] === state.tabs.length - 1) {
|
||||
return;
|
||||
}
|
||||
setActiveTab(state.active[0] + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
getActiveTab,
|
||||
setActiveTab,
|
||||
setPreviousActive,
|
||||
setNextActive,
|
||||
};
|
||||
};
|
2
packages/react/components/Tabs/index.ts
Normal file
2
packages/react/components/Tabs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./Component";
|
||||
export * from "./Hook";
|
@ -1,337 +0,0 @@
|
||||
import React, { useEffect, useId, useRef } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
interface ToastOption {
|
||||
closeButton: boolean;
|
||||
closeTimeout: number | null;
|
||||
}
|
||||
|
||||
const defaultToastOption: ToastOption = {
|
||||
closeButton: true,
|
||||
closeTimeout: 3000,
|
||||
};
|
||||
|
||||
const toastColors = {
|
||||
background: "bg-white dark:bg-black",
|
||||
borders: {
|
||||
default: "border-black/10 dark:border-white/20",
|
||||
error: "border-red-500/80",
|
||||
success: "border-green-500/80",
|
||||
warning: "border-yellow-500/80",
|
||||
loading: "border-black/50 dark:border-white/50 animate-pulse",
|
||||
},
|
||||
};
|
||||
|
||||
const [toastVariant] = vcn({
|
||||
base: `flex flex-col gap-2 border p-4 rounded-lg pr-8 pointer-events-auto ${toastColors.background} relative transition-all duration-150`,
|
||||
variants: {
|
||||
status: {
|
||||
default: toastColors.borders.default,
|
||||
error: toastColors.borders.error,
|
||||
success: toastColors.borders.success,
|
||||
warning: toastColors.borders.warning,
|
||||
loading: toastColors.borders.loading,
|
||||
},
|
||||
life: {
|
||||
born: "-translate-y-full md:translate-y-full scale-90 ease-[cubic-bezier(0,.6,.7,1)]",
|
||||
normal: "translate-y-0 scale-100 ease-[cubic-bezier(0,.6,.7,1)]",
|
||||
dead: "-translate-y-full md:translate-y-full scale-90 ease-[cubic-bezier(.6,0,1,.7)]",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
status: "default",
|
||||
life: "born",
|
||||
},
|
||||
});
|
||||
|
||||
interface ToastBody extends Omit<VariantProps<typeof toastVariant>, "preset"> {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
let toasts: Record<
|
||||
`${number}`,
|
||||
ToastBody & Partial<ToastOption> & { subscribers: (() => void)[] }
|
||||
> = {};
|
||||
let subscribers: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* ====
|
||||
* Controls
|
||||
* ====
|
||||
*/
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
subscribers.push(callback);
|
||||
return () => {
|
||||
subscribers = subscribers.filter((subscriber) => subscriber !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return { ...toasts };
|
||||
}
|
||||
|
||||
function subscribeSingle(id: `${number}`) {
|
||||
return (callback: () => void) => {
|
||||
toasts[id].subscribers.push(callback);
|
||||
return () => {
|
||||
toasts[id].subscribers = toasts[id].subscribers.filter(
|
||||
(subscriber) => subscriber !== callback,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getSingleSnapshot(id: `${number}`) {
|
||||
return () => {
|
||||
return {
|
||||
...toasts[id],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function notify() {
|
||||
subscribers.forEach((subscriber) => subscriber());
|
||||
}
|
||||
|
||||
function notifySingle(id: `${number}`) {
|
||||
toasts[id].subscribers.forEach((subscriber) => subscriber());
|
||||
}
|
||||
|
||||
function close(id: `${number}`) {
|
||||
toasts[id] = {
|
||||
...toasts[id],
|
||||
life: "dead",
|
||||
};
|
||||
notifySingle(id);
|
||||
}
|
||||
|
||||
function update(
|
||||
id: `${number}`,
|
||||
toast: Partial<Omit<ToastBody, "life"> & Partial<ToastOption>>,
|
||||
) {
|
||||
toasts[id] = {
|
||||
...toasts[id],
|
||||
...toast,
|
||||
};
|
||||
notifySingle(id);
|
||||
}
|
||||
|
||||
function addToast(toast: Omit<ToastBody, "life"> & Partial<ToastOption>) {
|
||||
const id: `${number}` = `${index}`;
|
||||
toasts[id] = {
|
||||
...toast,
|
||||
subscribers: [],
|
||||
life: "born",
|
||||
};
|
||||
index += 1;
|
||||
notify();
|
||||
|
||||
return {
|
||||
update: (toast: Partial<Omit<ToastBody, "life"> & Partial<ToastOption>>) =>
|
||||
update(id, toast),
|
||||
close: () => close(id),
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
return {
|
||||
toast: addToast,
|
||||
update,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
const ToastTemplate = ({
|
||||
id,
|
||||
globalOption,
|
||||
}: {
|
||||
id: `${number}`;
|
||||
globalOption: ToastOption;
|
||||
}) => {
|
||||
const [toast, setToast] = React.useState<(typeof toasts)[`${number}`]>(
|
||||
toasts[id],
|
||||
);
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
subscribeSingle(id)(() => {
|
||||
setToast(getSingleSnapshot(id)());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toastData = {
|
||||
...globalOption,
|
||||
...toast,
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toastData.life === "born") {
|
||||
requestAnimationFrame(() => {
|
||||
// To make sure that the toast is rendered as "born" state
|
||||
// and then change to "normal" state
|
||||
toasts[id] = {
|
||||
...toasts[id],
|
||||
life: "normal",
|
||||
};
|
||||
notifySingle(id);
|
||||
});
|
||||
}
|
||||
if (toastData.life === "normal" && toastData.closeTimeout !== null) {
|
||||
const timeout = setTimeout(() => {
|
||||
close(id);
|
||||
}, toastData.closeTimeout);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
if (toastData.life === "dead") {
|
||||
let transitionDuration: {
|
||||
value: number;
|
||||
unit: string;
|
||||
} | null;
|
||||
if (!ref.current) {
|
||||
transitionDuration = null;
|
||||
} else if (ref.current.computedStyleMap !== undefined) {
|
||||
transitionDuration = ref.current
|
||||
.computedStyleMap()
|
||||
.get("transition-duration") as { value: number; unit: string };
|
||||
} else {
|
||||
const style = /(\d+(\.\d+)?)(.+)/.exec(
|
||||
window.getComputedStyle(ref.current).transitionDuration,
|
||||
);
|
||||
transitionDuration = style
|
||||
? {
|
||||
value: parseFloat(style[1] ?? "0"),
|
||||
unit: style[3] ?? style[2] ?? "s",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (!transitionDuration) {
|
||||
delete toasts[id];
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const calculatedTransitionDuration =
|
||||
transitionDuration.value *
|
||||
({
|
||||
s: 1000,
|
||||
ms: 1,
|
||||
}[transitionDuration.unit] ?? 1);
|
||||
const timeout = setTimeout(() => {
|
||||
delete toasts[id];
|
||||
notify();
|
||||
}, calculatedTransitionDuration);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [toastData.life, toastData.closeTimeout, toastData.closeButton]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={toastVariant({
|
||||
status: toastData.status,
|
||||
life: toastData.life,
|
||||
})}
|
||||
ref={ref}
|
||||
>
|
||||
{toastData.closeButton && (
|
||||
<button className="absolute top-2 right-2" onClick={() => close(id)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="text-sm font-bold">{toastData.title}</div>
|
||||
<div className="text-sm">{toastData.description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [toasterVariant, resolveToasterVariantProps] = vcn({
|
||||
base: "fixed p-4 flex flex-col gap-4 top-0 right-0 w-full md:max-w-md md:bottom-0 md:top-auto pointer-events-none z-40",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface ToasterProps
|
||||
extends React.ComponentPropsWithoutRef<"div">,
|
||||
VariantProps<typeof toasterVariant> {
|
||||
defaultOption?: Partial<ToastOption>;
|
||||
muteDuplicationWarning?: boolean;
|
||||
}
|
||||
|
||||
const Toaster = React.forwardRef<HTMLDivElement, ToasterProps>((props, ref) => {
|
||||
const id = useId();
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveToasterVariantProps(props);
|
||||
const { defaultOption, muteDuplicationWarning, ...otherPropsExtracted } =
|
||||
otherPropsCompressed;
|
||||
|
||||
const [toastList, setToastList] = React.useState<typeof toasts>(toasts);
|
||||
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(() => {
|
||||
setToastList(getSnapshot());
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const option = React.useMemo(() => {
|
||||
return {
|
||||
...defaultToastOption,
|
||||
...defaultOption,
|
||||
};
|
||||
}, [defaultOption]);
|
||||
|
||||
const toasterInstance = document.querySelector("div[data-toaster-root]");
|
||||
if (toasterInstance && id !== toasterInstance.id) {
|
||||
if (process.env.NODE_ENV === "development" && !muteDuplicationWarning) {
|
||||
console.warn(
|
||||
`Multiple Toaster instances detected. Only one Toaster is allowed.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
<div
|
||||
{...otherPropsExtracted}
|
||||
data-toaster-root
|
||||
className={toasterVariant(variantProps)}
|
||||
ref={(el) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
id={id}
|
||||
>
|
||||
{Object.entries(toastList).map(([id]) => (
|
||||
<ToastTemplate
|
||||
key={id}
|
||||
id={id as `${number}`}
|
||||
globalOption={option}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export { Toaster, useToast };
|
204
packages/react/components/Toast/Component.tsx
Normal file
204
packages/react/components/Toast/Component.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import {
|
||||
type VariantProps,
|
||||
getCalculatedTransitionDuration,
|
||||
useDocument,
|
||||
vcn,
|
||||
} from "@pswui-lib";
|
||||
import React, { type MutableRefObject, useEffect, useId, useRef } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import {
|
||||
type ToastOption,
|
||||
close,
|
||||
defaultToastOption,
|
||||
getSingleSnapshot,
|
||||
getSnapshot,
|
||||
notify,
|
||||
notifySingle,
|
||||
subscribe,
|
||||
subscribeSingle,
|
||||
toasts,
|
||||
} from "./Store";
|
||||
import { toastVariant } from "./Variant";
|
||||
|
||||
const ToastTemplate = ({
|
||||
id,
|
||||
globalOption,
|
||||
}: {
|
||||
id: `${number}`;
|
||||
globalOption: ToastOption;
|
||||
}) => {
|
||||
const [toast, setToast] = React.useState<(typeof toasts)[`${number}`]>(
|
||||
toasts[id],
|
||||
);
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
subscribeSingle(id)(() => {
|
||||
setToast(getSingleSnapshot(id)());
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const toastData = {
|
||||
...globalOption,
|
||||
...toast,
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toastData.life === "born") {
|
||||
requestAnimationFrame(function untilBorn() {
|
||||
/*
|
||||
To confirm that the toast is rendered as "born" state and then change to "normal" state
|
||||
This way will make sure born -> normal stage transition animation will work.
|
||||
*/
|
||||
const elm = document.querySelector(
|
||||
`div[data-toaster-root] > div[data-toast-id="${id}"][data-toast-lifecycle="born"]`,
|
||||
);
|
||||
if (!elm) return requestAnimationFrame(untilBorn);
|
||||
|
||||
toasts[id] = {
|
||||
...toasts[id],
|
||||
life: "normal",
|
||||
};
|
||||
notifySingle(id);
|
||||
});
|
||||
}
|
||||
if (toastData.life === "normal" && toastData.closeTimeout !== null) {
|
||||
const timeout = setTimeout(() => {
|
||||
close(id);
|
||||
}, toastData.closeTimeout);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
if (toastData.life === "dead") {
|
||||
let calculatedTransitionDurationMs = 1;
|
||||
if (ref.current)
|
||||
calculatedTransitionDurationMs = getCalculatedTransitionDuration(
|
||||
ref as MutableRefObject<HTMLDivElement>,
|
||||
);
|
||||
const timeout = setTimeout(() => {
|
||||
delete toasts[id];
|
||||
notify();
|
||||
}, calculatedTransitionDurationMs);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [id, toastData.life, toastData.closeTimeout]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={toastVariant({
|
||||
status: toastData.status,
|
||||
life: toastData.life,
|
||||
})}
|
||||
ref={ref}
|
||||
data-toast-id={id}
|
||||
data-toast-lifecycle={toastData.life}
|
||||
>
|
||||
{toastData.closeButton && (
|
||||
<button
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => close(id)}
|
||||
type={"button"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<title>Close</title>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="text-sm font-bold">{toastData.title}</div>
|
||||
<div className="text-sm">{toastData.description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [toasterVariant, resolveToasterVariantProps] = vcn({
|
||||
base: "fixed p-4 flex flex-col gap-4 top-0 right-0 w-full md:max-w-md md:bottom-0 md:top-auto pointer-events-none z-40",
|
||||
variants: {},
|
||||
defaults: {},
|
||||
});
|
||||
|
||||
interface ToasterProps
|
||||
extends React.ComponentPropsWithoutRef<"div">,
|
||||
VariantProps<typeof toasterVariant> {
|
||||
defaultOption?: Partial<ToastOption>;
|
||||
muteDuplicationWarning?: boolean;
|
||||
}
|
||||
|
||||
const Toaster = React.forwardRef<HTMLDivElement, ToasterProps>((props, ref) => {
|
||||
const id = useId();
|
||||
const [variantProps, otherPropsCompressed] =
|
||||
resolveToasterVariantProps(props);
|
||||
const { defaultOption, muteDuplicationWarning, ...otherPropsExtracted } =
|
||||
otherPropsCompressed;
|
||||
|
||||
const [toastList, setToastList] = React.useState<typeof toasts>(toasts);
|
||||
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribe(() => {
|
||||
setToastList(getSnapshot());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const option = React.useMemo(() => {
|
||||
return {
|
||||
...defaultToastOption,
|
||||
...defaultOption,
|
||||
};
|
||||
}, [defaultOption]);
|
||||
|
||||
const document = useDocument();
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
const toasterInstance = document.querySelector("div[data-toaster-root]");
|
||||
if (toasterInstance && id !== toasterInstance.id) {
|
||||
if (process.env.NODE_ENV === "development" && !muteDuplicationWarning) {
|
||||
console.warn(
|
||||
"Multiple Toaster instances detected. Only one Toaster is allowed.",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ReactDOM.createPortal(
|
||||
<div
|
||||
{...otherPropsExtracted}
|
||||
data-toaster-root={true}
|
||||
className={toasterVariant(variantProps)}
|
||||
ref={(el) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
}}
|
||||
id={id}
|
||||
>
|
||||
{Object.entries(toastList).map(([id]) => (
|
||||
<ToastTemplate
|
||||
key={id}
|
||||
id={id as `${number}`}
|
||||
globalOption={option}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
Toaster.displayName = "Toaster";
|
||||
|
||||
export { Toaster };
|
9
packages/react/components/Toast/Hook.ts
Normal file
9
packages/react/components/Toast/Hook.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { addToast, close, update } from "./Store";
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toast: addToast,
|
||||
update,
|
||||
close,
|
||||
};
|
||||
}
|
100
packages/react/components/Toast/Store.ts
Normal file
100
packages/react/components/Toast/Store.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import type { ToastBody } from "./Variant";
|
||||
|
||||
export interface ToastOption {
|
||||
closeButton: boolean;
|
||||
closeTimeout: number | null;
|
||||
}
|
||||
|
||||
export const defaultToastOption: ToastOption = {
|
||||
closeButton: true,
|
||||
closeTimeout: 3000,
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
export const toasts: Record<
|
||||
`${number}`,
|
||||
ToastBody & Partial<ToastOption> & { subscribers: (() => void)[] }
|
||||
> = {};
|
||||
let subscribers: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* ====
|
||||
* Controls
|
||||
* ====
|
||||
*/
|
||||
|
||||
export function subscribe(callback: () => void) {
|
||||
subscribers.push(callback);
|
||||
return () => {
|
||||
subscribers = subscribers.filter((subscriber) => subscriber !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSnapshot() {
|
||||
return { ...toasts };
|
||||
}
|
||||
|
||||
export function subscribeSingle(id: `${number}`) {
|
||||
return (callback: () => void) => {
|
||||
toasts[id].subscribers.push(callback);
|
||||
return () => {
|
||||
toasts[id].subscribers = toasts[id].subscribers.filter(
|
||||
(subscriber) => subscriber !== callback,
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function getSingleSnapshot(id: `${number}`) {
|
||||
return () => {
|
||||
return {
|
||||
...toasts[id],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function notify() {
|
||||
for (const subscriber of subscribers) subscriber();
|
||||
}
|
||||
|
||||
export function notifySingle(id: `${number}`) {
|
||||
for (const subscriber of toasts[id].subscribers) subscriber();
|
||||
}
|
||||
|
||||
export function close(id: `${number}`) {
|
||||
toasts[id] = {
|
||||
...toasts[id],
|
||||
life: "dead",
|
||||
};
|
||||
notifySingle(id);
|
||||
}
|
||||
|
||||
export function update(
|
||||
id: `${number}`,
|
||||
toast: Partial<Omit<ToastBody, "life"> & Partial<ToastOption>>,
|
||||
) {
|
||||
toasts[id] = {
|
||||
...toasts[id],
|
||||
...toast,
|
||||
};
|
||||
notifySingle(id);
|
||||
}
|
||||
|
||||
export function addToast(
|
||||
toast: Omit<ToastBody, "life"> & Partial<ToastOption>,
|
||||
) {
|
||||
const id: `${number}` = `${index}`;
|
||||
toasts[id] = {
|
||||
...toast,
|
||||
subscribers: [],
|
||||
life: "born",
|
||||
};
|
||||
index += 1;
|
||||
notify();
|
||||
|
||||
return {
|
||||
update: (toast: Partial<Omit<ToastBody, "life"> & Partial<ToastOption>>) =>
|
||||
update(id, toast),
|
||||
close: () => close(id),
|
||||
};
|
||||
}
|
40
packages/react/components/Toast/Variant.ts
Normal file
40
packages/react/components/Toast/Variant.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { type VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
const toastColors = {
|
||||
background: "bg-white dark:bg-black",
|
||||
borders: {
|
||||
default: "border-black/10 dark:border-white/20",
|
||||
error: "border-red-500/80",
|
||||
success: "border-green-500/80",
|
||||
warning: "border-yellow-500/80",
|
||||
loading: "border-black/50 dark:border-white/50 animate-pulse",
|
||||
},
|
||||
};
|
||||
|
||||
export const [toastVariant, resolveToastVariantProps] = vcn({
|
||||
base: `flex flex-col gap-2 border p-4 rounded-lg pr-8 pointer-events-auto ${toastColors.background} relative transition-all duration-150`,
|
||||
variants: {
|
||||
status: {
|
||||
default: toastColors.borders.default,
|
||||
error: toastColors.borders.error,
|
||||
success: toastColors.borders.success,
|
||||
warning: toastColors.borders.warning,
|
||||
loading: toastColors.borders.loading,
|
||||
},
|
||||
life: {
|
||||
born: "-translate-y-full md:translate-y-full scale-90 ease-[cubic-bezier(0,.6,.7,1)]",
|
||||
normal: "translate-y-0 scale-100 ease-[cubic-bezier(0,.6,.7,1)]",
|
||||
dead: "-translate-y-full md:translate-y-full scale-90 ease-[cubic-bezier(.6,0,1,.7)]",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
status: "default",
|
||||
life: "born",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ToastBody
|
||||
extends Omit<VariantProps<typeof toastVariant>, "preset"> {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
2
packages/react/components/Toast/index.ts
Normal file
2
packages/react/components/Toast/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Toaster } from "./Component";
|
||||
export { useToast } from "./Hook";
|
@ -1,5 +1,5 @@
|
||||
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||
import React, { useState } from "react";
|
||||
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
|
||||
|
||||
interface TooltipContextBody {
|
||||
position: "top" | "bottom" | "left" | "right";
|
||||
@ -71,6 +71,7 @@ const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>((props, ref) => {
|
||||
</TooltipContext.Provider>
|
||||
);
|
||||
});
|
||||
Tooltip.displayName = "Tooltip";
|
||||
|
||||
const tooltipContentColors = {
|
||||
variants: {
|
||||
@ -83,10 +84,10 @@ const tooltipContentColors = {
|
||||
};
|
||||
|
||||
const [tooltipContentVariant, resolveTooltipContentVariantProps] = vcn({
|
||||
base: `absolute py-1 px-3 rounded-md border opacity-0 transition-all
|
||||
group-[:not(.controlled):hover]/tooltip:opacity-100 group-[.opened]/tooltip:opacity-100
|
||||
select-none pointer-events-none
|
||||
group-[:not(.controlled):hover]/tooltip:select-auto group-[.opened]/tooltip:select-auto group-[:not(.controlled):hover]/tooltip:pointer-events-auto group-[.opened]/tooltip:pointer-events-auto
|
||||
base: `absolute py-1 px-3 rounded-md border opacity-0 transition-all
|
||||
group-[:not(.controlled):hover]/tooltip:opacity-100 group-[.opened]/tooltip:opacity-100
|
||||
select-none pointer-events-none
|
||||
group-[:not(.controlled):hover]/tooltip:select-auto group-[.opened]/tooltip:select-auto group-[:not(.controlled):hover]/tooltip:pointer-events-auto group-[.opened]/tooltip:pointer-events-auto
|
||||
group-[:not(.controlled):hover]/tooltip:[transition:transform_150ms_ease-out_var(--delay),opacity_150ms_ease-out_var(--delay),background-color_150ms_ease-in-out,color_150ms_ease-in-out,border-color_150ms_ease-in-out]`,
|
||||
variants: {
|
||||
position: {
|
||||
@ -139,10 +140,12 @@ const TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(
|
||||
...variantProps,
|
||||
position: contextState.position,
|
||||
})}
|
||||
role="tooltip"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
TooltipContent.displayName = "TooltipContent";
|
||||
|
||||
export { Tooltip, TooltipContent };
|
||||
|
101
packages/react/lib/Slot.tsx
Normal file
101
packages/react/lib/Slot.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Merges the react props.
|
||||
* Basically childProps will override parentProps.
|
||||
* But if it is a event handler, style, or className, it will be merged.
|
||||
*
|
||||
* @param parentProps - The parent props.
|
||||
* @param childProps - The child props.
|
||||
* @returns The merged props.
|
||||
*/
|
||||
function mergeReactProps(
|
||||
parentProps: Record<string, unknown>,
|
||||
childProps: Record<string, unknown>,
|
||||
) {
|
||||
const overrideProps = { ...childProps };
|
||||
|
||||
for (const propName in childProps) {
|
||||
const parentPropValue = parentProps[propName];
|
||||
const childPropValue = childProps[propName];
|
||||
|
||||
const isHandler = /^on[A-Z]/.test(propName);
|
||||
if (isHandler) {
|
||||
if (
|
||||
childPropValue &&
|
||||
parentPropValue &&
|
||||
typeof childPropValue === "function" &&
|
||||
typeof parentPropValue === "function"
|
||||
) {
|
||||
overrideProps[propName] = (...args: unknown[]) => {
|
||||
childPropValue?.(...args);
|
||||
parentPropValue?.(...args);
|
||||
};
|
||||
} else if (parentPropValue) {
|
||||
overrideProps[propName] = parentPropValue;
|
||||
}
|
||||
} else if (
|
||||
propName === "style" &&
|
||||
typeof parentPropValue === "object" &&
|
||||
typeof childPropValue === "object"
|
||||
) {
|
||||
overrideProps[propName] = { ...parentPropValue, ...childPropValue };
|
||||
} else if (
|
||||
propName === "className" &&
|
||||
typeof parentPropValue === "string" &&
|
||||
typeof childPropValue === "string"
|
||||
) {
|
||||
overrideProps[propName] = twMerge(parentPropValue, childPropValue);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...parentProps, ...overrideProps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array of refs, and returns a single ref.
|
||||
*
|
||||
* @param refs - The array of refs.
|
||||
* @returns The single ref.
|
||||
*/
|
||||
function combinedRef<I>(refs: React.Ref<I | null>[]) {
|
||||
return (instance: I | null) => {
|
||||
for (const ref of refs) {
|
||||
if (ref instanceof Function) {
|
||||
ref(instance);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<I | null>).current = instance;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
export const Slot = React.forwardRef<
|
||||
HTMLElement,
|
||||
SlotProps & Record<string, unknown>
|
||||
>((props, ref) => {
|
||||
const { children, ...slotProps } = props;
|
||||
const { asChild: _1, ...safeSlotProps } = slotProps;
|
||||
if (!React.isValidElement(children)) {
|
||||
console.warn(`given children "${children}" is not valid for asChild`);
|
||||
return null;
|
||||
}
|
||||
return React.cloneElement(children, {
|
||||
...mergeReactProps(
|
||||
safeSlotProps,
|
||||
children.props as Record<string, unknown>,
|
||||
),
|
||||
ref: combinedRef([
|
||||
ref,
|
||||
(children as unknown as { ref: React.Ref<HTMLElement> }).ref,
|
||||
]),
|
||||
} as never);
|
||||
});
|
||||
|
||||
export interface AsChild {
|
||||
asChild?: boolean;
|
||||
}
|
4
packages/react/lib/index.ts
Normal file
4
packages/react/lib/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./vcn";
|
||||
export * from "./Slot";
|
||||
export * from "./useDocument";
|
||||
export * from "./useAnimatedMount";
|
85
packages/react/lib/useAnimatedMount.ts
Normal file
85
packages/react/lib/useAnimatedMount.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { type MutableRefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
function getCalculatedTransitionDuration(
|
||||
ref: MutableRefObject<HTMLElement>,
|
||||
): number {
|
||||
let transitionDuration: {
|
||||
value: number;
|
||||
unit: string;
|
||||
} | null;
|
||||
if (ref.current.computedStyleMap !== undefined) {
|
||||
transitionDuration = ref.current
|
||||
.computedStyleMap()
|
||||
.get("transition-duration") as { value: number; unit: string };
|
||||
} else {
|
||||
const style = /(\d+(\.\d+)?)(.+)/.exec(
|
||||
window.getComputedStyle(ref.current).transitionDuration,
|
||||
);
|
||||
if (!style) return 0;
|
||||
transitionDuration = {
|
||||
value: Number.parseFloat(style[1] ?? "0"),
|
||||
unit: style[3] ?? style[2] ?? "s",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
transitionDuration.value *
|
||||
({
|
||||
s: 1000,
|
||||
ms: 1,
|
||||
}[transitionDuration.unit] ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* isMounted: true isRendered: true isRendered: false isMounted: false
|
||||
* Component Mount Component Appear Component Disappear Component Unmount
|
||||
* v v v v
|
||||
* |-|=================|------------------------|======================|-|
|
||||
*/
|
||||
|
||||
function useAnimatedMount(
|
||||
visible: boolean,
|
||||
ref: MutableRefObject<HTMLElement | null>,
|
||||
callbacks?: { onMount: () => void; onUnmount: () => void },
|
||||
) {
|
||||
const [state, setState] = useState<{
|
||||
isMounted: boolean;
|
||||
isRendered: boolean;
|
||||
}>({ isMounted: visible, isRendered: visible });
|
||||
|
||||
const umountCallback = useCallback(() => {
|
||||
setState((p) => ({ ...p, isRendered: false }));
|
||||
|
||||
const calculatedTransitionDuration = ref.current
|
||||
? getCalculatedTransitionDuration(ref as MutableRefObject<HTMLElement>)
|
||||
: 0;
|
||||
|
||||
setTimeout(() => {
|
||||
setState((p) => ({ ...p, isMounted: false }));
|
||||
callbacks?.onUnmount?.();
|
||||
}, calculatedTransitionDuration);
|
||||
}, [ref, callbacks]);
|
||||
|
||||
const mountCallback = useCallback(() => {
|
||||
setState((p) => ({ ...p, isMounted: true }));
|
||||
callbacks?.onMount?.();
|
||||
requestAnimationFrame(function onMount() {
|
||||
if (!ref.current) return requestAnimationFrame(onMount);
|
||||
setState((p) => ({ ...p, isRendered: true }));
|
||||
});
|
||||
}, [ref.current, callbacks]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(state);
|
||||
if (!visible && state.isRendered) {
|
||||
umountCallback();
|
||||
} else if (visible && !state.isMounted) {
|
||||
mountCallback();
|
||||
}
|
||||
}, [state, visible, mountCallback, umountCallback]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export { getCalculatedTransitionDuration, useAnimatedMount };
|
21
packages/react/lib/useDocument.ts
Normal file
21
packages/react/lib/useDocument.ts
Normal file
@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* This hook allows components to use `document` as like they're always in the client side.
|
||||
* Return undefined if there is no `document` (which represents it's server side) or initial render(to avoid hydration error).
|
||||
*/
|
||||
function useDocument(): undefined | Document {
|
||||
const [initialRender, setInitialState] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setInitialState(false);
|
||||
}, []);
|
||||
|
||||
if (typeof document === "undefined" || initialRender) return undefined;
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
export { useDocument };
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
@ -58,6 +57,31 @@ type VariantKV<V extends VariantType> = {
|
||||
[VariantKey in keyof V]: BooleanString<keyof V[VariantKey] & string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used for safely casting `Object.entries(<VariantKV>)`
|
||||
*/
|
||||
type VariantKVEntry<V extends VariantType> = [
|
||||
keyof V,
|
||||
BooleanString<keyof V[keyof V] & string>,
|
||||
][];
|
||||
|
||||
/**
|
||||
* Takes VariantKV as parameter, return className string.
|
||||
*
|
||||
* @example
|
||||
* vcn({
|
||||
* /* ... *\/
|
||||
* dynamics: [
|
||||
* ({ a, b }) => {
|
||||
* return a === "something" ? "asdf" : b
|
||||
* },
|
||||
* ]
|
||||
* })
|
||||
*/
|
||||
type DynamicClassName<V extends VariantType> = (
|
||||
variantProps: VariantKV<V>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Takes VariantType, and returns a type that represents the preset object.
|
||||
*
|
||||
@ -95,6 +119,7 @@ export function vcn<V extends VariantType>(param: {
|
||||
*/
|
||||
base?: string | undefined;
|
||||
variants: V;
|
||||
dynamics?: DynamicClassName<V>[];
|
||||
defaults: VariantKV<V>;
|
||||
presets?: undefined;
|
||||
}): [
|
||||
@ -109,6 +134,7 @@ export function vcn<V extends VariantType>(param: {
|
||||
/**
|
||||
* Any Props -> Variant Props, Other Props
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: using unknown causes error `Index signature for type 'string' is missing in type --Props`.
|
||||
<AnyPropBeforeResolve extends Record<string, any>>(
|
||||
anyProps: AnyPropBeforeResolve,
|
||||
) => [
|
||||
@ -124,6 +150,7 @@ export function vcn<V extends VariantType, P extends PresetType<V>>(param: {
|
||||
*/
|
||||
base?: string | undefined;
|
||||
variants: V /* VariantType */;
|
||||
dynamics?: DynamicClassName<V>[];
|
||||
defaults: VariantKV<V>;
|
||||
presets: P;
|
||||
}): [
|
||||
@ -139,6 +166,7 @@ export function vcn<V extends VariantType, P extends PresetType<V>>(param: {
|
||||
/**
|
||||
* Any Props -> Variant Props, Other Props
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: using unknown causes error `Index signature for type 'string' is missing in type --Props`.
|
||||
<AnyPropBeforeResolve extends Record<string, any>>(
|
||||
anyProps: AnyPropBeforeResolve,
|
||||
) => [
|
||||
@ -158,19 +186,58 @@ export function vcn<
|
||||
>({
|
||||
base,
|
||||
variants,
|
||||
dynamics = [],
|
||||
defaults,
|
||||
presets,
|
||||
}: {
|
||||
base?: string | undefined;
|
||||
variants: V;
|
||||
dynamics?: DynamicClassName<V>[];
|
||||
defaults: VariantKV<V>;
|
||||
presets?: P;
|
||||
}) {
|
||||
/**
|
||||
* --Internal utility function--
|
||||
* After transforming props to final version (which means "after overriding default, preset, and variant props sent via component props")
|
||||
* It turns final version of variant props to className
|
||||
*/
|
||||
function __transformer__(
|
||||
final: VariantKV<V>,
|
||||
dynamics: string[],
|
||||
propClassName?: string,
|
||||
): string {
|
||||
const classNames: string[] = [];
|
||||
|
||||
for (const [variantName, variantKey] of Object.entries(
|
||||
final,
|
||||
) as VariantKVEntry<V>) {
|
||||
classNames.push(variants[variantName][variantKey.toString()]);
|
||||
}
|
||||
|
||||
return twMerge(base, ...classNames, ...dynamics, propClassName);
|
||||
}
|
||||
|
||||
return [
|
||||
/**
|
||||
* Takes any props (including className), and returns the class name.
|
||||
* If there is no variant specified in props, then it will fallback to preset, and then default.
|
||||
*
|
||||
*
|
||||
* Process priority of variant will be:
|
||||
*
|
||||
* --- Processed as string
|
||||
* 1. Base
|
||||
*
|
||||
* --- Processed as object (it will ignore rest of "not duplicated classname" in lower priority)
|
||||
* 2. Default
|
||||
* 3. Preset (overriding default)
|
||||
* 4. Variant props via component (overriding preset)
|
||||
*
|
||||
* --- Processed as string
|
||||
* 5. Dynamic classNames using variant props
|
||||
* 6. User's className (overriding dynamic)
|
||||
*
|
||||
*
|
||||
* @param variantProps - The variant props including className.
|
||||
* @returns The class name.
|
||||
*/
|
||||
@ -179,42 +246,43 @@ export function vcn<
|
||||
VariantKV<V>
|
||||
>,
|
||||
) => {
|
||||
const { className, preset, ...otherVariantProps } = variantProps;
|
||||
const { className, preset, ..._otherVariantProps } = variantProps;
|
||||
|
||||
const currentPreset: P[keyof P] | null =
|
||||
presets && preset ? (presets as NonNullable<P>)[preset] ?? null : null;
|
||||
const presetVariantKeys: (keyof V)[] = Object.keys(currentPreset ?? {});
|
||||
return twMerge(
|
||||
base,
|
||||
...(
|
||||
Object.entries(defaults) as [keyof V, keyof V[keyof V] & string][]
|
||||
).map<string>(([variantKey, defaultValue]) => {
|
||||
// Omit<Partial<VariantKV<V>> & { className; preset; }, className | preset> = Partial<VariantKV<V>> (safe to cast)
|
||||
// Partial<VariantKV<V>>[keyof V] => { [k in keyof V]?: BooleanString<keyof V[keyof V] & string> } => BooleanString<keyof V[keyof V]>
|
||||
// Omit<Partial<VariantKV<V>> & { className; preset; }, className | preset> = Partial<VariantKV<V>> (safe to cast)
|
||||
// We all know `keyof V` = string, right? (but typescript says it's not, so.. attacking typescript with unknown lol)
|
||||
const otherVariantProps = _otherVariantProps as unknown as Partial<
|
||||
VariantKV<V>
|
||||
>;
|
||||
|
||||
const directVariantValue: (keyof V[keyof V] & string) | undefined = (
|
||||
otherVariantProps as unknown as Partial<VariantKV<V>>
|
||||
)[variantKey]?.toString?.(); // BooleanString<> -> string (safe to index V[keyof V])
|
||||
const kv: VariantKV<V> = { ...defaults };
|
||||
|
||||
const currentPresetVariantValue:
|
||||
| (keyof V[keyof V] & string)
|
||||
| undefined =
|
||||
!!currentPreset && presetVariantKeys.includes(variantKey)
|
||||
? (currentPreset as Partial<VariantKV<V>>)[
|
||||
variantKey
|
||||
]?.toString?.()
|
||||
: undefined;
|
||||
// Preset Processing
|
||||
if (presets && preset && preset in presets) {
|
||||
for (const [variantName, variantKey] of Object.entries(
|
||||
// typescript bug (casting to NonNullable<P> required)
|
||||
(presets as NonNullable<P>)[preset],
|
||||
) as VariantKVEntry<V>) {
|
||||
kv[variantName] = variantKey;
|
||||
}
|
||||
}
|
||||
|
||||
const variantValue: keyof V[keyof V] & string =
|
||||
directVariantValue ?? currentPresetVariantValue ?? defaultValue;
|
||||
return variants[variantKey][variantValue];
|
||||
}),
|
||||
(
|
||||
currentPreset as Partial<VariantKV<V>> | null
|
||||
)?.className?.toString?.(), // preset's classname comes after user's variant props? huh..
|
||||
className,
|
||||
);
|
||||
// VariantProps Processing
|
||||
for (const [variantName, variantKey] of Object.entries(
|
||||
otherVariantProps,
|
||||
) as VariantKVEntry<V>) {
|
||||
if (typeof variantKey === "undefined") continue;
|
||||
kv[variantName] = variantKey;
|
||||
}
|
||||
|
||||
// make dynamics result
|
||||
const dynamicClasses: string[] = [];
|
||||
for (const dynamicFunction of dynamics) {
|
||||
dynamicClasses.push(dynamicFunction(kv));
|
||||
}
|
||||
|
||||
return __transformer__(kv, dynamicClasses, className);
|
||||
},
|
||||
|
||||
/**
|
||||
* Takes any props, parse variant props and other props.
|
||||
* If `options.excludeA` is true, then it will parse `A` as "other" props.
|
||||
@ -223,7 +291,7 @@ export function vcn<
|
||||
* @param anyProps - Any props that have passed to the component.
|
||||
* @returns [variantProps, otherProps]
|
||||
*/
|
||||
<AnyPropBeforeResolve extends Record<string, any>>(
|
||||
<AnyPropBeforeResolve extends Record<string, unknown>>(
|
||||
anyProps: AnyPropBeforeResolve,
|
||||
) => {
|
||||
const variantKeys = Object.keys(variants) as (keyof V)[];
|
||||
@ -268,86 +336,5 @@ export function vcn<
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type VariantProps<F extends (props: any) => string> = F extends (
|
||||
props: infer P,
|
||||
) => string
|
||||
? P
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Merges the react props.
|
||||
* Basically childProps will override parentProps.
|
||||
* But if it is a event handler, style, or className, it will be merged.
|
||||
*
|
||||
* @param parentProps - The parent props.
|
||||
* @param childProps - The child props.
|
||||
* @returns The merged props.
|
||||
*/
|
||||
function mergeReactProps(
|
||||
parentProps: Record<string, any>,
|
||||
childProps: Record<string, any>,
|
||||
) {
|
||||
const overrideProps = { ...childProps };
|
||||
|
||||
for (const propName in childProps) {
|
||||
const parentPropValue = parentProps[propName];
|
||||
const childPropValue = childProps[propName];
|
||||
|
||||
const isHandler = /^on[A-Z]/.test(propName);
|
||||
if (isHandler) {
|
||||
if (childPropValue && parentPropValue) {
|
||||
overrideProps[propName] = (...args: unknown[]) => {
|
||||
childPropValue?.(...args);
|
||||
parentPropValue?.(...args);
|
||||
};
|
||||
} else if (parentPropValue) {
|
||||
overrideProps[propName] = parentPropValue;
|
||||
}
|
||||
} else if (propName === "style") {
|
||||
overrideProps[propName] = { ...parentPropValue, ...childPropValue };
|
||||
} else if (propName === "className") {
|
||||
overrideProps[propName] = twMerge(parentPropValue, childPropValue);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...parentProps, ...overrideProps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array of refs, and returns a single ref.
|
||||
*
|
||||
* @param refs - The array of refs.
|
||||
* @returns The single ref.
|
||||
*/
|
||||
function combinedRef<I>(refs: React.Ref<I | null>[]) {
|
||||
return (instance: I | null) =>
|
||||
refs.forEach((ref) => {
|
||||
if (ref instanceof Function) {
|
||||
ref(instance);
|
||||
} else if (!!ref) {
|
||||
(ref as React.MutableRefObject<I | null>).current = instance;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
export const Slot = React.forwardRef<any, SlotProps & Record<string, any>>(
|
||||
(props, ref) => {
|
||||
const { children, ...slotProps } = props;
|
||||
const { asChild: _1, ...safeSlotProps } = slotProps;
|
||||
if (!React.isValidElement(children)) {
|
||||
console.warn(`given children "${children}" is not valid for asChild`);
|
||||
return null;
|
||||
}
|
||||
return React.cloneElement(children, {
|
||||
...mergeReactProps(safeSlotProps, children.props),
|
||||
ref: combinedRef([ref, (children as any).ref]),
|
||||
} as any);
|
||||
},
|
||||
);
|
||||
|
||||
export interface AsChild {
|
||||
asChild?: boolean;
|
||||
}
|
||||
export type VariantProps<F extends (props: Record<string, unknown>) => string> =
|
||||
F extends (props: infer P) => string ? { [key in keyof P]: P[key] } : never;
|
@ -6,28 +6,21 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && cp ./404.html ./dist",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "biome check --no-errors-on-unmatched",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.3.0"
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^4.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.13",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
import "./tailwind.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
@ -1,7 +1,5 @@
|
||||
@import tailwindcss;
|
||||
@import url("https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css");
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@ -34,4 +32,3 @@
|
||||
@apply transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,14 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./{components,stories,src}/**/*.{js,jsx,ts,tsx,css,mdx}"],
|
||||
darkMode: [
|
||||
"variant",
|
||||
[
|
||||
"@media (prefers-color-scheme: dark) { &:is(.system *) }",
|
||||
"&:is(.dark *)",
|
||||
],
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
};
|
@ -5,6 +5,7 @@
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
@ -25,9 +26,9 @@
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@/*": ["src/*"],
|
||||
"@pswui-lib": ["lib.tsx"]
|
||||
"@pswui-lib": ["lib/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["components", "src", "./lib.tsx"],
|
||||
"include": ["components", "src", "lib"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
@ -1,23 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import { resolve } from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react()
|
||||
],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@components": resolve(__dirname, "./components"),
|
||||
"@": resolve(__dirname, "./src"),
|
||||
"@pswui-lib": resolve(__dirname, "./lib.tsx"),
|
||||
"@pswui-lib": resolve(__dirname, "./lib/index.ts"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -1,20 +1,46 @@
|
||||
{
|
||||
"base": "https://raw.githubusercontent.com/pswui/ui",
|
||||
"base": "https://raw.githubusercontent.com/pswui/ui/{branch}",
|
||||
"paths": {
|
||||
"components": "/main/packages/react/components/{componentName}",
|
||||
"lib": "/main/packages/react/lib.tsx"
|
||||
"components": "/packages/react/components/{componentName}",
|
||||
"lib": "/packages/react/lib/{libName}"
|
||||
},
|
||||
"lib": [
|
||||
"index.ts",
|
||||
"Slot.tsx",
|
||||
"vcn.ts",
|
||||
"useDocument.ts",
|
||||
"useAnimatedMount.ts"
|
||||
],
|
||||
"components": {
|
||||
"button": { "name": "Button.tsx" },
|
||||
"checkbox": { "name": "Checkbox.tsx" },
|
||||
"dialog": { "name": "Dialog.tsx" },
|
||||
"drawer": { "name": "Drawer.tsx" },
|
||||
"input": { "name": "Input.tsx" },
|
||||
"label": { "name": "Label.tsx" },
|
||||
"popover": { "name": "Popover.tsx" },
|
||||
"switch": { "name": "Switch.tsx" },
|
||||
"tabs": { "name": "Tabs.tsx" },
|
||||
"toast": { "name": "Toast.tsx" },
|
||||
"tooltip": {"name": "Tooltip.tsx" }
|
||||
"button": { "type": "file", "name": "Button.tsx" },
|
||||
"checkbox": { "type": "file", "name": "Checkbox.tsx" },
|
||||
"dialog": {
|
||||
"type": "dir",
|
||||
"name": "Dialog",
|
||||
"files": ["index.ts", "Component.tsx", "Context.ts"]
|
||||
},
|
||||
"drawer": { "type": "file", "name": "Drawer.tsx" },
|
||||
"form": { "type": "file", "name": "Form.tsx" },
|
||||
"input": { "type": "file", "name": "Input.tsx" },
|
||||
"label": { "type": "file", "name": "Label.tsx" },
|
||||
"popover": { "type": "file", "name": "Popover.tsx" },
|
||||
"switch": { "type": "file", "name": "Switch.tsx" },
|
||||
"tabs": {
|
||||
"type": "dir",
|
||||
"name": "Tabs",
|
||||
"files": ["index.ts", "Context.ts", "Hook.ts", "Component.tsx"]
|
||||
},
|
||||
"toast": {
|
||||
"type": "dir",
|
||||
"name": "Toast",
|
||||
"files": [
|
||||
"index.ts",
|
||||
"Component.tsx",
|
||||
"Hook.ts",
|
||||
"Store.ts",
|
||||
"Variant.ts"
|
||||
]
|
||||
},
|
||||
"tooltip": { "type": "file", "name": "Tooltip.tsx" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user