Compare commits
227 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 | |||
![]() |
05dc15d04e | ||
![]() |
4523e6a419 | ||
![]() |
47160748b6 | ||
![]() |
d8d61aceaa | ||
![]() |
a966a85f62 | ||
![]() |
fefe19c534 | ||
![]() |
447b2249c8 | ||
![]() |
8eb378b1cc | ||
![]() |
643e607eb4 | ||
![]() |
fc5c5ba4f5 | ||
8de1a433c1 | |||
5dd74e4b3f | |||
ffb8504b09 | |||
a329eee279 | |||
5db9be1eb3 | |||
90960ff800 | |||
ca90f3355a | |||
46ec2e2c52 | |||
937670bfea | |||
8e6e691308 | |||
300c7fc8c3 | |||
ea987ad590 | |||
b28e5d1c8d | |||
c27e7bd2c5 | |||
d23360887d | |||
45082d4587 | |||
b9583a43f6 | |||
36a4cc605d | |||
99773f11cc | |||
89776267ad | |||
dd63fcb753 | |||
670fa9d1bc | |||
07d9306dde | |||
6f8d944f47 | |||
76ec211d81 | |||
f07d496792 | |||
31659c0b6c | |||
511b778fd0 | |||
35603cadaf | |||
643dc6eafd | |||
7bf2578d86 | |||
8062f02a78 | |||
2deae4af79 | |||
edfaef2c75 | |||
fcc35223d3 | |||
556556251f | |||
6f5ec5042d | |||
7c3459076a | |||
397210462f | |||
090fada7cd | |||
303d6b31e7 | |||
ec4e811219 | |||
8a8508c8fb | |||
7a82c284fc | |||
fb0c33a6cb | |||
647e5c311d | |||
274016035f | |||
946db4efd0 | |||
4421adfe7d | |||
b962b02690 | |||
7d2aa1d7f0 | |||
![]() |
e1999266dd | ||
1337892afe | |||
a096ced86e | |||
![]() |
1c5b97f93e |
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": [
|
"tailwindCSS.experimental.classRegex": [
|
||||||
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||||
["[cC]olors\\s*\\=\\s*{([^]*(?=}))}", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
["[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
|
# 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
|
## Milestones
|
||||||
|
|
||||||
@ -15,11 +62,6 @@ Meet our [web documentation](https://ui.psw.kr)!
|
|||||||
- [ ] FileInput
|
- [ ] FileInput
|
||||||
- [ ] ImageInput
|
- [ ] ImageInput
|
||||||
- [ ] Form
|
- [ ] Form
|
||||||
- [ ] FormItem
|
|
||||||
- [ ] FormLabel
|
|
||||||
- [ ] FormControl
|
|
||||||
- [ ] FormDescription
|
|
||||||
- [ ] FormMessage
|
|
||||||
- [ ] Textarea
|
- [ ] Textarea
|
||||||
- [ ] Accordion
|
- [ ] Accordion
|
||||||
- [ ] Alert
|
- [ ] Alert
|
||||||
@ -53,24 +95,35 @@ Meet our [web documentation](https://ui.psw.kr)!
|
|||||||
- [ ] Toggle
|
- [ ] Toggle
|
||||||
- [ ] Toggle Group
|
- [ ] Toggle Group
|
||||||
- [x] Tooltip
|
- [x] Tooltip
|
||||||
- Library/Framework Support
|
|
||||||
- [ ] React
|
|
||||||
- [ ] Svelte
|
|
||||||
- CLI
|
- CLI
|
||||||
- [x] Add
|
- [x] Add
|
||||||
- [x] List
|
- [x] List
|
||||||
|
- [x] Search
|
||||||
|
|
||||||
## Building local development environment
|
## Building local development environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Corepack - Yarn 4.2.2
|
# Corepack - Yarn 4.2.2
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack install yarn@4.2.2
|
|
||||||
corepack use yarn@4.2.2
|
|
||||||
|
|
||||||
# Install Packages
|
# Install Packages
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
# Run Storybook
|
# Script running in workspace
|
||||||
yarn workspace react storybook
|
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",
|
"repository": "https://github.com/pswui/ui",
|
||||||
"author": "p-sw <shinwoo.park@psw.kr>",
|
"author": "p-sw <shinwoo.park@psw.kr>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": ["packages/*", "components"],
|
||||||
"packages/*",
|
|
||||||
"components"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "lefthook install",
|
||||||
"react": "yarn workspace react",
|
"react": "yarn workspace react",
|
||||||
"cli": "yarn workspace @psw-ui/cli",
|
"cli": "yarn workspace @psw-ui/cli",
|
||||||
"react:build": "yarn workspace react build",
|
"react:build": "yarn workspace react build",
|
||||||
@ -17,5 +15,9 @@
|
|||||||
"cli:build": "yarn workspace @psw-ui/cli build"
|
"cli:build": "yarn workspace @psw-ui/cli build"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
"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": [
|
"require": ["ts-node/register"],
|
||||||
"ts-node/register"
|
"watch-extensions": ["ts"],
|
||||||
],
|
|
||||||
"watch-extensions": [
|
|
||||||
"ts"
|
|
||||||
],
|
|
||||||
"recursive": true,
|
"recursive": true,
|
||||||
"reporter": "spec",
|
"reporter": "spec",
|
||||||
"timeout": 60000,
|
"timeout": 60000,
|
||||||
|
@ -1 +0,0 @@
|
|||||||
"@oclif/prettier-config"
|
|
@ -20,7 +20,7 @@ $ npm install -g @psw-ui/cli
|
|||||||
$ pswui COMMAND
|
$ pswui COMMAND
|
||||||
running command...
|
running command...
|
||||||
$ pswui (--version)
|
$ 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]
|
$ pswui --help [COMMAND]
|
||||||
USAGE
|
USAGE
|
||||||
$ pswui COMMAND
|
$ pswui COMMAND
|
||||||
@ -32,6 +32,7 @@ USAGE
|
|||||||
* [`pswui add [NAME]`](#pswui-add-name)
|
* [`pswui add [NAME]`](#pswui-add-name)
|
||||||
* [`pswui help [COMMAND]`](#pswui-help-command)
|
* [`pswui help [COMMAND]`](#pswui-help-command)
|
||||||
* [`pswui list`](#pswui-list)
|
* [`pswui list`](#pswui-list)
|
||||||
|
* [`pswui search`](#pswui-search)
|
||||||
|
|
||||||
## `pswui add [NAME]`
|
## `pswui add [NAME]`
|
||||||
|
|
||||||
@ -45,10 +46,10 @@ ARGUMENTS
|
|||||||
NAME name of component to install
|
NAME name of component to install
|
||||||
|
|
||||||
FLAGS
|
FLAGS
|
||||||
-F, --forceShared override the existing shared.ts and update it to latest
|
|
||||||
-c, --components=<value> place for installation of components
|
-c, --components=<value> place for installation of components
|
||||||
-f, --force override the existing file
|
-f, --force override the existing file
|
||||||
-p, --config=<value> path to config
|
-p, --config=<value> path to config
|
||||||
|
-r, --branch=<value> use other branch instead of main
|
||||||
-s, --shared=<value> place for installation of shared.ts
|
-s, --shared=<value> place for installation of shared.ts
|
||||||
|
|
||||||
DESCRIPTION
|
DESCRIPTION
|
||||||
@ -58,7 +59,7 @@ EXAMPLES
|
|||||||
$ pswui add
|
$ pswui add
|
||||||
```
|
```
|
||||||
|
|
||||||
_See code: [packages/cli/src/commands/add.tsx](https://github.com/pswui/ui/blob/cli@0.2.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]`
|
## `pswui help [COMMAND]`
|
||||||
|
|
||||||
@ -90,6 +91,7 @@ USAGE
|
|||||||
|
|
||||||
FLAGS
|
FLAGS
|
||||||
-p, --config=<value> path to config
|
-p, --config=<value> path to config
|
||||||
|
-r, --branch=<value> use other branch instead of main
|
||||||
-u, --url include component file URL
|
-u, --url include component file URL
|
||||||
|
|
||||||
DESCRIPTION
|
DESCRIPTION
|
||||||
@ -99,7 +101,7 @@ EXAMPLES
|
|||||||
$ pswui list
|
$ pswui list
|
||||||
```
|
```
|
||||||
|
|
||||||
_See code: [packages/cli/src/commands/list.ts](https://github.com/pswui/ui/blob/cli@0.2.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`
|
## `pswui search`
|
||||||
|
|
||||||
@ -114,6 +116,9 @@ USAGE
|
|||||||
ARGUMENTS
|
ARGUMENTS
|
||||||
QUERY search query
|
QUERY search query
|
||||||
|
|
||||||
|
FLAGS
|
||||||
|
-r, --branch=<value> use other branch instead of main
|
||||||
|
|
||||||
DESCRIPTION
|
DESCRIPTION
|
||||||
Search components.
|
Search components.
|
||||||
|
|
||||||
@ -121,5 +126,5 @@ EXAMPLES
|
|||||||
$ pswui search
|
$ pswui search
|
||||||
```
|
```
|
||||||
|
|
||||||
_See code: [packages/cli/src/commands/search.tsx](https://github.com/pswui/ui/blob/cli@0.2.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 -->
|
<!-- commandsstop -->
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
|
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
|
||||||
|
|
||||||
// eslint-disable-next-line n/shebang
|
// 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
|
#!/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",
|
"name": "@psw-ui/cli",
|
||||||
"description": "CLI for PSW/UI",
|
"description": "CLI for PSW/UI",
|
||||||
"version": "0.2.1",
|
"version": "0.5.1",
|
||||||
"author": "p-sw",
|
"author": "p-sw",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pswui": "./bin/run.js"
|
"pswui": "./bin/run.js"
|
||||||
@ -20,33 +20,23 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oclif/prettier-config": "^0.2.1",
|
|
||||||
"@oclif/test": "^4",
|
"@oclif/test": "^4",
|
||||||
"@types/chai": "^4",
|
"@types/chai": "^4",
|
||||||
"@types/ink-divider": "^2.0.4",
|
"@types/ink-divider": "^2.0.4",
|
||||||
"@types/node": "^18",
|
"@types/node": "^18",
|
||||||
"chai": "^4",
|
"chai": "^4",
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-oclif": "^5",
|
|
||||||
"eslint-config-oclif-typescript": "^3",
|
|
||||||
"eslint-config-prettier": "^9",
|
|
||||||
"oclif": "^4",
|
"oclif": "^4",
|
||||||
"shx": "^0.3.3",
|
"shx": "^0.3.3",
|
||||||
|
"tailwind-scrollbar": "^3.1.0",
|
||||||
"ts-node": "^10",
|
"ts-node": "^10",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": ["/bin", "/dist", "/oclif.manifest.json"],
|
||||||
"/bin",
|
|
||||||
"/dist",
|
|
||||||
"/oclif.manifest.json"
|
|
||||||
],
|
|
||||||
"homepage": "https://ui.psw.kr",
|
"homepage": "https://ui.psw.kr",
|
||||||
"keywords": [
|
"keywords": ["oclif"],
|
||||||
"oclif"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -54,9 +44,7 @@
|
|||||||
"bin": "pswui",
|
"bin": "pswui",
|
||||||
"dirname": "pswui",
|
"dirname": "pswui",
|
||||||
"commands": "./dist/commands",
|
"commands": "./dist/commands",
|
||||||
"plugins": [
|
"plugins": ["@oclif/plugin-help"],
|
||||||
"@oclif/plugin-help"
|
|
||||||
],
|
|
||||||
"topicSeparator": " ",
|
"topicSeparator": " ",
|
||||||
"topics": {
|
"topics": {
|
||||||
"hello": {
|
"hello": {
|
||||||
@ -67,7 +55,7 @@
|
|||||||
"repository": "pswui/ui",
|
"repository": "pswui/ui",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "shx rm -rf dist && tsc",
|
"build": "shx rm -rf dist && tsc",
|
||||||
"lint": "eslint . --ext .ts",
|
"lint": "biome check --no-errors-on-unmatched",
|
||||||
"prepack": "yarn build"
|
"prepack": "yarn build"
|
||||||
},
|
},
|
||||||
"types": "dist/index.d.ts"
|
"types": "dist/index.d.ts"
|
||||||
|
@ -1,35 +1,43 @@
|
|||||||
import {Args, Command, Flags} from '@oclif/core'
|
import { existsSync } from "node:fs";
|
||||||
import {loadConfig, validateConfig} from '../helpers/config.js'
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import {existsSync} from 'node:fs'
|
import { join } from "node:path";
|
||||||
import {mkdir, writeFile} from 'node:fs/promises'
|
import { Args, Command, Flags } from "@oclif/core";
|
||||||
import {join} from 'node:path'
|
import { colorize } from "@oclif/core/ux";
|
||||||
import {getAvailableComponentNames, getComponentRealname, getComponentURL, getRegistry} from '../helpers/registry.js'
|
import { Box, render } from "ink";
|
||||||
import ora from 'ora'
|
import ora from "ora";
|
||||||
import React, {ComponentPropsWithoutRef} from 'react'
|
import React, { type ComponentPropsWithoutRef } from "react";
|
||||||
import {render, Box} from 'ink'
|
import { Choice } from "../components/Choice.js";
|
||||||
import {SearchBox} from '../components/SearchBox.js'
|
import { SearchBox } from "../components/SearchBox.js";
|
||||||
import {getComponentsInstalled} from '../helpers/path.js'
|
import { loadConfig, validateConfig } from "../helpers/config.js";
|
||||||
import {Choice} from '../components/Choice.js'
|
import {
|
||||||
import {colorize} from '@oclif/core/ux'
|
checkComponentInstalled,
|
||||||
|
getDirComponentRequiredFiles,
|
||||||
|
} from "../helpers/path.js";
|
||||||
|
import {
|
||||||
|
getComponentURL,
|
||||||
|
getDirComponentURL,
|
||||||
|
getRegistry,
|
||||||
|
} from "../helpers/registry.js";
|
||||||
|
import { safeFetch } from "../helpers/safe-fetcher.js";
|
||||||
|
|
||||||
function Generator() {
|
function Generator() {
|
||||||
let complete: boolean = false
|
let complete = false;
|
||||||
|
|
||||||
function ComponentSelector<T extends {displayName: string; key: string; installed: boolean}>(
|
function ComponentSelector<
|
||||||
props: Omit<ComponentPropsWithoutRef<typeof SearchBox<T>>, 'helper'>,
|
T extends { displayName: string; key: string; installed: boolean },
|
||||||
) {
|
>(props: Omit<ComponentPropsWithoutRef<typeof SearchBox<T>>, "helper">) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
helper={'Press Enter to select component.'}
|
helper={"Press Enter to select component."}
|
||||||
{...props}
|
{...props}
|
||||||
onSubmit={(value) => {
|
onSubmit={(value) => {
|
||||||
complete = true
|
complete = true;
|
||||||
props.onSubmit?.(value)
|
props.onSubmit?.(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -37,29 +45,31 @@ function Generator() {
|
|||||||
new Promise<void>((r) => {
|
new Promise<void>((r) => {
|
||||||
const i = setInterval(() => {
|
const i = setInterval(() => {
|
||||||
if (complete) {
|
if (complete) {
|
||||||
r()
|
r();
|
||||||
clearInterval(i)
|
clearInterval(i);
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100);
|
||||||
}),
|
}),
|
||||||
] as const
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Generator2() {
|
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 (
|
return (
|
||||||
<Choice
|
<Choice
|
||||||
question={'You already installed this component. Overwrite?'}
|
question={"You already installed this component. Overwrite?"}
|
||||||
yes={'Yes, overwrite existing file and install it.'}
|
yes={"Yes, overwrite existing file and install it."}
|
||||||
no={'No, cancel the action.'}
|
no={"No, cancel the action."}
|
||||||
onSubmit={(value) => {
|
onSubmit={(value) => {
|
||||||
complete = true
|
complete = true;
|
||||||
onComplete(value)
|
onComplete(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -67,152 +77,237 @@ function Generator2() {
|
|||||||
new Promise<void>((r) => {
|
new Promise<void>((r) => {
|
||||||
const i = setInterval(() => {
|
const i = setInterval(() => {
|
||||||
if (complete) {
|
if (complete) {
|
||||||
r()
|
r();
|
||||||
clearInterval(i)
|
clearInterval(i);
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100);
|
||||||
}),
|
}),
|
||||||
] as const
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Add extends Command {
|
export default class Add extends Command {
|
||||||
static override args = {
|
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 = {
|
static override flags = {
|
||||||
force: Flags.boolean({char: 'f', description: 'override the existing file'}),
|
branch: Flags.string({
|
||||||
// WARNING: forceShared could break your components!
|
char: "r",
|
||||||
forceShared: Flags.boolean({char: 'F', description: 'override the existing shared.ts and update it to latest'}),
|
description: "use other branch instead of main",
|
||||||
config: Flags.string({char: 'p', description: 'path to config'}),
|
}),
|
||||||
shared: Flags.string({char: 's', description: 'place for installation of shared.ts'}),
|
force: Flags.boolean({
|
||||||
components: Flags.string({char: 'c', description: 'place for installation of components'}),
|
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> {
|
public async run(): Promise<void> {
|
||||||
let {
|
let {
|
||||||
args,
|
args,
|
||||||
flags: {force, ...flags},
|
flags: { force, ...flags },
|
||||||
} = await this.parse(Add)
|
} = await this.parse(Add);
|
||||||
|
|
||||||
const resolvedConfig = await validateConfig((message: string) => this.log(message), await loadConfig(flags.config))
|
const resolvedConfig = await validateConfig(
|
||||||
const componentFolder = join(process.cwd(), resolvedConfig.paths.components)
|
(message: string) => this.log(message),
|
||||||
const sharedFile = join(process.cwd(), resolvedConfig.paths.shared)
|
await loadConfig(flags.config),
|
||||||
|
);
|
||||||
|
const componentFolder = join(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedConfig.paths.components,
|
||||||
|
);
|
||||||
|
const libFolder = join(process.cwd(), resolvedConfig.paths.lib);
|
||||||
if (!existsSync(componentFolder)) {
|
if (!existsSync(componentFolder)) {
|
||||||
await mkdir(componentFolder, {recursive: true})
|
await mkdir(componentFolder, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!existsSync(libFolder)) {
|
||||||
|
await mkdir(libFolder, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadRegistryOra = ora('Fetching registry...').start()
|
const loadRegistryOra = ora("Fetching registry...").start();
|
||||||
const unsafeRegistry = await getRegistry()
|
if (flags.registry) {
|
||||||
|
this.log(`Using ${flags.branch} for branch.`);
|
||||||
|
}
|
||||||
|
const unsafeRegistry = await getRegistry(flags.branch);
|
||||||
if (!unsafeRegistry.ok) {
|
if (!unsafeRegistry.ok) {
|
||||||
loadRegistryOra.fail(unsafeRegistry.message)
|
loadRegistryOra.fail(unsafeRegistry.message);
|
||||||
return
|
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 =
|
let requireForce: boolean =
|
||||||
!name || !componentNames.includes(name.toLowerCase())
|
!name || !componentNames.includes(name.toLowerCase())
|
||||||
? false
|
? false
|
||||||
: searchBoxComponent.find(({key}) => key === name)?.installed
|
: searchBoxComponent.find(({ key }) => key === name)?.installed
|
||||||
? !force
|
? !force
|
||||||
: false
|
: false;
|
||||||
|
|
||||||
if (!name || !componentNames.includes(name.toLowerCase())) {
|
if (!name || !componentNames.includes(name.toLowerCase())) {
|
||||||
const [ComponentSelector, waitForComplete] = Generator()
|
const [ComponentSelector, waitForComplete] = Generator();
|
||||||
|
|
||||||
const inkInstance = render(
|
const inkInstance = render(
|
||||||
<ComponentSelector
|
<ComponentSelector
|
||||||
components={searchBoxComponent}
|
components={searchBoxComponent}
|
||||||
initialQuery={args.name}
|
initialQuery={args.name}
|
||||||
onSubmit={(comp) => {
|
onSubmit={(comp) => {
|
||||||
name = comp.key
|
name = comp.key;
|
||||||
requireForce = comp.installed
|
requireForce = comp.installed;
|
||||||
inkInstance.clear()
|
inkInstance.clear();
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
);
|
||||||
await waitForComplete
|
await waitForComplete;
|
||||||
inkInstance.unmount()
|
inkInstance.unmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
let quit = false
|
let quit = false;
|
||||||
|
|
||||||
if (requireForce) {
|
if (requireForce) {
|
||||||
const [ForceSelector, waitForComplete] = Generator2()
|
const [ForceSelector, waitForComplete] = Generator2();
|
||||||
|
|
||||||
const inkInstance = render(
|
const inkInstance = render(
|
||||||
<ForceSelector
|
<ForceSelector
|
||||||
onComplete={(value) => {
|
onComplete={(value) => {
|
||||||
force = value === 'yes'
|
force = value === "yes";
|
||||||
quit = value === 'no'
|
quit = value === "no";
|
||||||
inkInstance.clear()
|
inkInstance.clear();
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
);
|
||||||
await waitForComplete
|
await waitForComplete;
|
||||||
inkInstance.unmount()
|
inkInstance.unmount();
|
||||||
if (quit) {
|
if (quit) {
|
||||||
this.log(colorize('redBright', 'Installation canceled by user.'))
|
this.log(colorize("redBright", "Installation canceled by user."));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name) {
|
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 sharedFileOra = ora('Installing shared module...').start()
|
const libFileOra = ora("Installing required library...").start();
|
||||||
if (!existsSync(sharedFile) || flags.forceShared) {
|
let successCount = 0;
|
||||||
const sharedFileContentResponse = await fetch(registry.shared)
|
for await (const libFile of registry.lib) {
|
||||||
if (!sharedFileContentResponse.ok) {
|
const filePath = join(libFolder, libFile);
|
||||||
sharedFileOra.fail(
|
if (!existsSync(filePath)) {
|
||||||
`Error while fetching shared module content: ${sharedFileContentResponse.status} ${sharedFileContentResponse.statusText}`,
|
const libFileContentResponse = await safeFetch(
|
||||||
)
|
registry.base + registry.paths.lib.replace("{libName}", libFile),
|
||||||
return
|
);
|
||||||
|
if (!libFileContentResponse.ok) {
|
||||||
|
libFileOra.fail(libFileContentResponse.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const libFileContent = await libFileContentResponse.response.text();
|
||||||
|
await writeFile(filePath, libFileContent);
|
||||||
|
successCount++;
|
||||||
}
|
}
|
||||||
const sharedFileContent = await sharedFileContentResponse.text()
|
}
|
||||||
await writeFile(sharedFile, sharedFileContent)
|
if (successCount > 1) {
|
||||||
sharedFileOra.succeed('Shared module is successfully installed!')
|
libFileOra.succeed("Successfully installed library files!");
|
||||||
} else {
|
} else {
|
||||||
sharedFileOra.succeed('Shared module is already installed!')
|
libFileOra.succeed("Library files are already installed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentFileOra = ora(`Installing ${name} component...`).start()
|
const componentFileOra = ora(`Installing ${name} component...`).start();
|
||||||
const componentFile = join(componentFolder, registry.components[name])
|
const componentObject = registry.components[name];
|
||||||
if (existsSync(componentFile) && !force) {
|
if (componentObject.type === "file") {
|
||||||
componentFileOra.succeed(`Component is already installed! (${componentFile})`)
|
const componentFile = join(
|
||||||
} else {
|
componentFolder,
|
||||||
const componentFileContentResponse = await fetch(await getComponentURL(registry, name))
|
registry.components[name].name,
|
||||||
if (!componentFileContentResponse.ok) {
|
);
|
||||||
componentFileOra.fail(
|
if (existsSync(componentFile) && !force) {
|
||||||
`Error while fetching component file content: ${componentFileContentResponse.status} ${componentFileContentResponse.statusText}`,
|
componentFileOra.succeed(
|
||||||
)
|
`Component is already installed! (${componentFile})`,
|
||||||
return
|
);
|
||||||
|
} 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+"..\/shared"/g,
|
|
||||||
(match) => match.replace(/..\/shared/, resolvedConfig.import.shared),
|
|
||||||
)
|
|
||||||
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,59 +1,89 @@
|
|||||||
import {Command, Flags} from '@oclif/core'
|
import { Command, Flags } from "@oclif/core";
|
||||||
import {getAvailableComponentNames, getRegistry, getComponentURL, getComponentRealname} from '../helpers/registry.js'
|
import ora from "ora";
|
||||||
import ora from 'ora'
|
import treeify from "treeify";
|
||||||
import treeify from 'treeify'
|
|
||||||
import {CONFIG_DEFAULT_PATH} from '../const.js'
|
import { loadConfig, validateConfig } from "../helpers/config.js";
|
||||||
import {loadConfig, validateConfig} from '../helpers/config.js'
|
import { checkComponentInstalled } from "../helpers/path.js";
|
||||||
import {getComponentsInstalled} from '../helpers/path.js'
|
import {
|
||||||
|
getComponentURL,
|
||||||
|
getDirComponentURL,
|
||||||
|
getRegistry,
|
||||||
|
} from "../helpers/registry.js";
|
||||||
|
|
||||||
export default class List extends Command {
|
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 = {
|
static override flags = {
|
||||||
url: Flags.boolean({char: 'u', description: 'include component file URL'}),
|
branch: Flags.string({
|
||||||
config: Flags.string({char: 'p', description: 'path to config'}),
|
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> {
|
public async run(): Promise<void> {
|
||||||
const {flags} = await this.parse(List)
|
const { flags } = await this.parse(List);
|
||||||
|
|
||||||
const registrySpinner = ora('Fetching registry...')
|
const registrySpinner = ora("Fetching registry...");
|
||||||
const getInstalledSpinner = ora('Getting installed components...')
|
|
||||||
|
|
||||||
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()
|
registrySpinner.start();
|
||||||
const unsafeRegistry = await getRegistry()
|
if (flags.branch) {
|
||||||
if (!unsafeRegistry.ok) {
|
this.log(`Using ${flags.branch} for registry.`);
|
||||||
registrySpinner.fail(unsafeRegistry.message)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const registry = unsafeRegistry.registry
|
|
||||||
registrySpinner.succeed(`Fetched ${Object.keys(registry.components).length} components.`)
|
|
||||||
|
|
||||||
const names = await getAvailableComponentNames(registry)
|
const unsafeRegistry = await getRegistry(flags.branch);
|
||||||
|
if (!unsafeRegistry.ok) {
|
||||||
|
registrySpinner.fail(unsafeRegistry.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
getInstalledSpinner.start()
|
const { registry } = unsafeRegistry;
|
||||||
const installedNames = await getComponentsInstalled(
|
const names = Object.keys(registry.components);
|
||||||
await Promise.all(names.map(async (name) => await getComponentRealname(registry, name))),
|
|
||||||
loadedConfig,
|
|
||||||
)
|
|
||||||
getInstalledSpinner.succeed(`Got ${installedNames.length} installed components.`)
|
|
||||||
|
|
||||||
let final: Record<string, {URL?: string; installed: 'yes' | 'no'}> = {}
|
registrySpinner.succeed(`Fetched ${names.length} components.`);
|
||||||
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) {
|
if (flags.url) {
|
||||||
const url = await getComponentURL(registry, name)
|
let url: Record<string, string> = {};
|
||||||
final = {...final, [name]: {URL: url, installed}}
|
|
||||||
|
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 {
|
} else {
|
||||||
final = {...final, [name]: {installed}}
|
final = { ...final, [name]: { installed } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log('AVAILABLE COMPONENTS')
|
this.log("AVAILABLE COMPONENTS");
|
||||||
this.log(treeify.asTree(final, true, true))
|
this.log(treeify.asTree(final, true, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,35 +1,45 @@
|
|||||||
import {Command, Args} from '@oclif/core'
|
import { Args, Command, Flags } from "@oclif/core";
|
||||||
import {render} from 'ink'
|
import { render } from "ink";
|
||||||
import {SearchBox} from '../components/SearchBox.js'
|
import React from "react";
|
||||||
import {getAvailableComponentNames, getRegistry} from '../helpers/registry.js'
|
import { SearchBox } from "../components/SearchBox.js";
|
||||||
import React from 'react'
|
import { getRegistry } from "../helpers/registry.js";
|
||||||
|
|
||||||
export default class Search extends Command {
|
export default class Search extends Command {
|
||||||
static override args = {
|
static override args = {
|
||||||
query: Args.string({description: 'search query'}),
|
query: Args.string({ description: "search query" }),
|
||||||
}
|
};
|
||||||
|
|
||||||
static override description = 'Search components.'
|
static override flags = {
|
||||||
|
branch: Flags.string({
|
||||||
|
char: "r",
|
||||||
|
description: "use other branch instead of main",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
static override examples = ['<%= config.bin %> <%= command.id %>']
|
static override description = "Search components.";
|
||||||
|
|
||||||
|
static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||||
|
|
||||||
public async run(): Promise<void> {
|
public async run(): Promise<void> {
|
||||||
const {args} = await this.parse(Search)
|
const { args, flags } = await this.parse(Search);
|
||||||
|
|
||||||
const registryResult = await getRegistry()
|
if (flags.branch) {
|
||||||
if (!registryResult.ok) {
|
this.log(`Using ${flags.branch} for registry.`);
|
||||||
this.error(registryResult.message)
|
|
||||||
}
|
}
|
||||||
const registry = registryResult.registry
|
const registryResult = await getRegistry(flags.branch);
|
||||||
const componentNames = await getAvailableComponentNames(registry)
|
if (!registryResult.ok) {
|
||||||
|
this.error(registryResult.message);
|
||||||
|
}
|
||||||
|
const registry = registryResult.registry;
|
||||||
|
const componentNames = Object.keys(registry.components);
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<SearchBox
|
<SearchBox
|
||||||
components={componentNames.map((v) => ({key: v, displayName: v}))}
|
components={componentNames.map((v) => ({ key: v, displayName: v }))}
|
||||||
initialQuery={args.query}
|
initialQuery={args.query}
|
||||||
helper={'Press ESC to quit'}
|
helper={"Press ESC to quit"}
|
||||||
onKeyDown={(_, k, app) => k.escape && app.exit()}
|
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() {
|
function isUnicodeSupported() {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== "win32") {
|
||||||
return process.env['TERM'] !== 'linux' // Linux console (kernel)
|
return process.env.TERM !== "linux"; // Linux console (kernel)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Boolean(process.env['WT_SESSION']) || // Windows Terminal
|
Boolean(process.env.WT_SESSION) || // Windows Terminal
|
||||||
Boolean(process.env['TERMINUS_SUBLIME']) || // Terminus (<0.2.27)
|
Boolean(process.env.TERMINUS_SUBLIME) || // Terminus (<0.2.27)
|
||||||
process.env['ConEmuTask'] === '{cmd::Cmder}' || // ConEmu and cmder
|
process.env.ConEmuTask === "{cmd::Cmder}" || // ConEmu and cmder
|
||||||
process.env['TERM_PROGRAM'] === 'Terminus-Sublime' ||
|
process.env.TERM_PROGRAM === "Terminus-Sublime" ||
|
||||||
process.env['TERM_PROGRAM'] === 'vscode' ||
|
process.env.TERM_PROGRAM === "vscode" ||
|
||||||
process.env['TERM'] === 'xterm-256color' ||
|
process.env.TERM === "xterm-256color" ||
|
||||||
process.env['TERM'] === 'alacritty' ||
|
process.env.TERM === "alacritty" ||
|
||||||
process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'
|
process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldUseMain = isUnicodeSupported()
|
const shouldUseMain = isUnicodeSupported();
|
||||||
const SELECTED: string = shouldUseMain ? '◉' : '(*)'
|
const SELECTED: string = shouldUseMain ? "◉" : "(*)";
|
||||||
const UNSELECTED: string = shouldUseMain ? '◯' : '( )'
|
const UNSELECTED: string = shouldUseMain ? "◯" : "( )";
|
||||||
|
|
||||||
export function Choice({
|
export function Choice({
|
||||||
question,
|
question,
|
||||||
@ -29,35 +29,38 @@ export function Choice({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
initial,
|
initial,
|
||||||
}: {
|
}: {
|
||||||
question: string
|
question: string;
|
||||||
yes: string
|
yes: string;
|
||||||
no: string
|
no: string;
|
||||||
onSubmit?: (vaule: 'yes' | 'no') => void
|
onSubmit?: (vaule: "yes" | "no") => void;
|
||||||
initial?: 'yes' | 'no'
|
initial?: "yes" | "no";
|
||||||
}) {
|
}) {
|
||||||
const [state, setState] = useState<'yes' | 'no'>(initial ?? 'yes')
|
const [state, setState] = useState<"yes" | "no">(initial ?? "yes");
|
||||||
|
|
||||||
useInput((_, k) => {
|
useInput((_, k) => {
|
||||||
if (k.upArrow) {
|
if (k.upArrow) {
|
||||||
setState('yes')
|
setState("yes");
|
||||||
} else if (k.downArrow) {
|
} else if (k.downArrow) {
|
||||||
setState('no')
|
setState("no");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (k.return) {
|
if (k.return) {
|
||||||
onSubmit?.(state)
|
onSubmit?.(state);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box display={'flex'} flexDirection={'column'}>
|
<Box
|
||||||
<Text color={'greenBright'}>{question}</Text>
|
display={"flex"}
|
||||||
<Text color={state === 'yes' ? undefined : 'gray'}>
|
flexDirection={"column"}
|
||||||
{state === 'yes' ? SELECTED : UNSELECTED} {yes}
|
>
|
||||||
|
<Text color={"greenBright"}>{question}</Text>
|
||||||
|
<Text color={state === "yes" ? undefined : "gray"}>
|
||||||
|
{state === "yes" ? SELECTED : UNSELECTED} {yes}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={state === 'no' ? undefined : 'gray'}>
|
<Text color={state === "no" ? undefined : "gray"}>
|
||||||
{state === 'no' ? SELECTED : UNSELECTED} {no}
|
{state === "no" ? SELECTED : UNSELECTED} {no}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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}) {
|
export function Divider({
|
||||||
const length = Math.floor((width - title.length - padding * 2) / 2)
|
width = 50,
|
||||||
|
padding = 1,
|
||||||
|
title,
|
||||||
|
}: { width?: number; padding?: number; title: string }) {
|
||||||
|
const length = Math.floor((width - title.length - padding * 2) / 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{Array.from(Array(length)).map((_, i) => (
|
{Array.from(Array(length)).map((_, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||||
<Text key={i}>─</Text>
|
<Text key={i}>─</Text>
|
||||||
))}
|
))}
|
||||||
{Array.from(Array(padding)).map((_, i) => (
|
{Array.from(Array(padding)).map((_, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||||
<Text key={i}> </Text>
|
<Text key={i}> </Text>
|
||||||
))}
|
))}
|
||||||
<Text>{title}</Text>
|
<Text>{title}</Text>
|
||||||
{Array.from(Array(padding)).map((_, i) => (
|
{Array.from(Array(padding)).map((_, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||||
<Text key={i}> </Text>
|
<Text key={i}> </Text>
|
||||||
))}
|
))}
|
||||||
{Array.from(Array(length)).map((_, i) => (
|
{Array.from(Array(length)).map((_, i) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
|
||||||
<Text key={i}>─</Text>
|
<Text key={i}>─</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React, {useEffect, useState} from 'react'
|
import { Box, type Key, Text, useApp, useInput } from "ink";
|
||||||
import {getSuggestion} from '../helpers/search.js'
|
import Input from "ink-text-input";
|
||||||
import Input from 'ink-text-input'
|
import React, { useEffect, useState } from "react";
|
||||||
import {Divider} from './Divider.js'
|
import { getSuggestion } from "../helpers/search.js";
|
||||||
import {Box, Text, useInput, useApp, type Key} from 'ink'
|
import { Divider } from "./Divider.js";
|
||||||
|
|
||||||
export function SearchBox<T extends {key: string; displayName: string}>({
|
export function SearchBox<T extends { key: string; displayName: string }>({
|
||||||
components,
|
components,
|
||||||
helper,
|
helper,
|
||||||
initialQuery,
|
initialQuery,
|
||||||
@ -12,92 +12,115 @@ export function SearchBox<T extends {key: string; displayName: string}>({
|
|||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
components: T[]
|
components: T[];
|
||||||
helper: string
|
helper: string;
|
||||||
initialQuery?: string
|
initialQuery?: string;
|
||||||
onKeyDown?: (i: string, k: Key, app: ReturnType<typeof useApp>) => void
|
onKeyDown?: (i: string, k: Key, app: ReturnType<typeof useApp>) => void;
|
||||||
onChange?: (item: T) => void
|
onChange?: (item: T) => void;
|
||||||
onSubmit?: (item: T) => void
|
onSubmit?: (item: T) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState<string>(initialQuery ?? '')
|
const [query, setQuery] = useState<string>(initialQuery ?? "");
|
||||||
const [queryMode, setQueryMode] = useState<boolean>(true)
|
const [queryMode, setQueryMode] = useState<boolean>(true);
|
||||||
const [isLoading, setLoading] = useState<boolean>(false)
|
const [isLoading, setLoading] = useState<boolean>(false);
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
const [selected, setSelected] = useState<number>(-1)
|
const [selected, setSelected] = useState<number>(-1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queryMode) {
|
if (queryMode) {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
getSuggestion(
|
getSuggestion(
|
||||||
components.map(({key}) => key),
|
components.map(({ key }) => key),
|
||||||
query,
|
query,
|
||||||
).then((result) => {
|
).then((result) => {
|
||||||
setSuggestions(result)
|
setSuggestions(result);
|
||||||
setSelected(-1)
|
setSelected(-1);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [query, queryMode])
|
}, [query, queryMode, components]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
const found = components.find(({key}) => key === suggestions[selected])
|
const found = components.find(({ key }) => key === suggestions[selected]);
|
||||||
found && onChange(found)
|
found && onChange(found);
|
||||||
}
|
}
|
||||||
}, [selected, suggestions, onChange])
|
}, [selected, suggestions, onChange, components]);
|
||||||
|
|
||||||
const app = useApp()
|
const app = useApp();
|
||||||
|
|
||||||
useInput((i, k) => {
|
useInput((i, k) => {
|
||||||
if (k.downArrow) {
|
if (k.downArrow) {
|
||||||
setSelected((p) => (p >= suggestions.length - 1 ? 0 : p + 1))
|
setSelected((p) => (p >= suggestions.length - 1 ? 0 : p + 1));
|
||||||
setQueryMode(false)
|
setQueryMode(false);
|
||||||
}
|
}
|
||||||
if (k.upArrow) {
|
if (k.upArrow) {
|
||||||
setSelected((p) => (p <= 0 ? suggestions.length - 1 : p - 1))
|
setSelected((p) => (p <= 0 ? suggestions.length - 1 : p - 1));
|
||||||
setQueryMode(false)
|
setQueryMode(false);
|
||||||
}
|
}
|
||||||
onKeyDown?.(i, k, app)
|
onKeyDown?.(i, k, app);
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queryMode && suggestions[selected]) {
|
if (!queryMode && suggestions[selected]) {
|
||||||
setQuery(suggestions[selected])
|
setQuery(suggestions[selected]);
|
||||||
}
|
}
|
||||||
}, [queryMode, selected])
|
}, [queryMode, selected, suggestions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box width={50} display={'flex'} flexDirection={'column'}>
|
<Box
|
||||||
<Text color={'gray'}>{helper}</Text>
|
width={50}
|
||||||
<Box display={'flex'} flexDirection={'row'}>
|
display={"flex"}
|
||||||
<Box marginRight={1} display={'flex'} flexDirection={'row'}>
|
flexDirection={"column"}
|
||||||
<Text color={'greenBright'}>Search?</Text>
|
>
|
||||||
|
<Text color={"gray"}>{helper}</Text>
|
||||||
|
<Box
|
||||||
|
display={"flex"}
|
||||||
|
flexDirection={"row"}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
marginRight={1}
|
||||||
|
display={"flex"}
|
||||||
|
flexDirection={"row"}
|
||||||
|
>
|
||||||
|
<Text color={"greenBright"}>Search?</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setQueryMode(true)
|
setQueryMode(true);
|
||||||
setQuery(v)
|
setQuery(v);
|
||||||
}}
|
}}
|
||||||
showCursor
|
showCursor
|
||||||
placeholder={' query'}
|
placeholder={" query"}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
const found = components.find(({key}) => key === suggestions[selected])
|
const found = components.find(
|
||||||
found && onSubmit?.(found)
|
({ key }) => key === suggestions[selected],
|
||||||
|
);
|
||||||
|
found && onSubmit?.(found);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider title={isLoading ? 'Loading...' : `${suggestions.length} components found.`} />
|
<Divider
|
||||||
<Box display={'flex'} flexDirection={'column'}>
|
title={
|
||||||
|
isLoading ? "Loading..." : `${suggestions.length} components found.`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
display={"flex"}
|
||||||
|
flexDirection={"column"}
|
||||||
|
>
|
||||||
{suggestions.map((name, index) => {
|
{suggestions.map((name, index) => {
|
||||||
return (
|
return (
|
||||||
<Box key={name}>
|
<Box key={name}>
|
||||||
<Text color={selected === index ? undefined : 'gray'}>
|
<Text color={selected === index ? undefined : "gray"}>
|
||||||
{components[components.findIndex(({key}) => key === name)].displayName}
|
{
|
||||||
|
components[components.findIndex(({ key }) => key === name)]
|
||||||
|
.displayName
|
||||||
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,54 +1,75 @@
|
|||||||
import z from 'zod'
|
import { z } from "zod";
|
||||||
|
|
||||||
export const REGISTRY_URL = 'https://ui.psw.kr/registry.json'
|
export const registryURL = (branch: string) =>
|
||||||
export const CONFIG_DEFAULT_PATH = 'pswui.config.js'
|
`https://raw.githubusercontent.com/pswui/ui/${branch}/registry.json`;
|
||||||
|
export const CONFIG_DEFAULT_PATH = "pswui.config.js";
|
||||||
|
|
||||||
|
export type RegistryComponent =
|
||||||
|
| {
|
||||||
|
files: string[];
|
||||||
|
name: string;
|
||||||
|
type: "dir";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
type: "file";
|
||||||
|
};
|
||||||
|
|
||||||
export interface Registry {
|
export interface Registry {
|
||||||
base: string
|
base: string;
|
||||||
shared: string
|
components: Record<string, RegistryComponent>;
|
||||||
components: Record<string, string>
|
lib: string[];
|
||||||
|
paths: {
|
||||||
|
components: string;
|
||||||
|
lib: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
|
||||||
* Path that cli will create a file.
|
|
||||||
*/
|
|
||||||
paths?: {
|
|
||||||
components?: 'src/pswui/components' | string
|
|
||||||
shared?: 'src/pswui/shared.tsx' | string
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Absolute path that will used for import in component
|
* Absolute path that will used for import in component
|
||||||
*/
|
*/
|
||||||
import?: {
|
import?: {
|
||||||
shared?: '@pswui-shared' | 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> = {
|
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 = {
|
export const DEFAULT_CONFIG = {
|
||||||
paths: {
|
|
||||||
components: 'src/pswui/components',
|
|
||||||
shared: 'src/pswui/shared.tsx',
|
|
||||||
},
|
|
||||||
import: {
|
import: {
|
||||||
shared: '@pswui-shared',
|
lib: "@pswui-lib",
|
||||||
},
|
},
|
||||||
}
|
paths: {
|
||||||
|
components: "src/pswui/components",
|
||||||
|
lib: "src/pswui/lib",
|
||||||
|
},
|
||||||
|
};
|
||||||
export const configZod = z.object({
|
export const configZod = z.object({
|
||||||
paths: z
|
|
||||||
.object({
|
|
||||||
components: z.string().optional().default(DEFAULT_CONFIG.paths.components),
|
|
||||||
shared: z.string().optional().default(DEFAULT_CONFIG.paths.shared),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.default(DEFAULT_CONFIG.paths),
|
|
||||||
import: z
|
import: z
|
||||||
.object({
|
.object({
|
||||||
shared: z.string().optional().default(DEFAULT_CONFIG.import.shared),
|
lib: z.string().optional().default(DEFAULT_CONFIG.import.lib),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default(DEFAULT_CONFIG.import),
|
.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 { existsSync } from "node:fs";
|
||||||
import {configZod} from '../const.js'
|
import path from "node:path";
|
||||||
import {join} from 'node:path'
|
import { colorize } from "@oclif/core/ux";
|
||||||
import {existsSync} from 'node:fs'
|
|
||||||
import {changeExtension} from './path.js'
|
import {
|
||||||
import {colorize} from '@oclif/core/ux'
|
CONFIG_DEFAULT_PATH,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
type ResolvedConfig,
|
||||||
|
configZod,
|
||||||
|
} from "../const.js";
|
||||||
|
import { changeExtension } from "./path.js";
|
||||||
|
|
||||||
export async function loadConfig(config?: string): Promise<unknown> {
|
export async function loadConfig(config?: string): Promise<unknown> {
|
||||||
const userConfigPath = config ? join(process.cwd(), config) : null
|
const userConfigPath = config ? path.join(process.cwd(), config) : null;
|
||||||
const defaultConfigPath = join(process.cwd(), CONFIG_DEFAULT_PATH)
|
const defaultConfigPath = path.join(process.cwd(), CONFIG_DEFAULT_PATH);
|
||||||
const cjsConfigPath = join(process.cwd(), await changeExtension(CONFIG_DEFAULT_PATH, '.cjs'))
|
const cjsConfigPath = path.join(
|
||||||
const mjsConfigPath = join(process.cwd(), await changeExtension(CONFIG_DEFAULT_PATH, '.mjs'))
|
process.cwd(),
|
||||||
|
await changeExtension(CONFIG_DEFAULT_PATH, ".cjs"),
|
||||||
|
);
|
||||||
|
const mjsConfigPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
await changeExtension(CONFIG_DEFAULT_PATH, ".mjs"),
|
||||||
|
);
|
||||||
|
|
||||||
if (userConfigPath) {
|
if (userConfigPath) {
|
||||||
if (existsSync(userConfigPath)) {
|
if (existsSync(userConfigPath)) {
|
||||||
return (await import(userConfigPath)).default
|
return (await import(userConfigPath)).default;
|
||||||
} else {
|
|
||||||
throw new Error(`Error: config ${userConfigPath} not found.`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error(`Error: config ${userConfigPath} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(defaultConfigPath)) {
|
if (existsSync(defaultConfigPath)) {
|
||||||
return (await import(defaultConfigPath)).default
|
return (await import(defaultConfigPath)).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(cjsConfigPath)) {
|
if (existsSync(cjsConfigPath)) {
|
||||||
return (await import(cjsConfigPath)).default
|
return (await import(cjsConfigPath)).default;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(mjsConfigPath)) {
|
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> {
|
export async function validateConfig(
|
||||||
const parsedConfig: ResolvedConfig = await configZod.parseAsync(config)
|
log: (message: string) => void,
|
||||||
log(colorize('gray', `Install component to: ${join(process.cwd(), parsedConfig.paths.components)}`))
|
config?: unknown,
|
||||||
log(colorize('gray', `Install shared module to: ${join(process.cwd(), parsedConfig.paths.shared)}`))
|
): Promise<ResolvedConfig> {
|
||||||
log(colorize('gray', `Import shared with: ${parsedConfig.import.shared}`))
|
const parsedConfig: ResolvedConfig = await configZod.parseAsync(config);
|
||||||
return parsedConfig
|
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 { existsSync } from "node:fs";
|
||||||
import {readdir} from 'node:fs/promises'
|
import { readdir } from "node:fs/promises";
|
||||||
import {existsSync} from 'node:fs'
|
import path from "node:path";
|
||||||
import {basename, dirname, extname, join} from 'node:path'
|
|
||||||
|
|
||||||
export async function getComponentsInstalled(components: string[], config: ResolvedConfig) {
|
import type { RegistryComponent, ResolvedConfig } from "../const.js";
|
||||||
const componentPath = join(process.cwd(), config.paths.components)
|
|
||||||
if (existsSync(componentPath)) {
|
export async function getDirComponentRequiredFiles<
|
||||||
const dir = await readdir(componentPath)
|
T extends { type: "dir" } & RegistryComponent,
|
||||||
return dir.reduce((prev, current) => (components.includes(current) ? [...prev, current] : prev), [] as string[])
|
>(componentObject: T, config: ResolvedConfig) {
|
||||||
} else {
|
const componentPath = path.join(
|
||||||
return []
|
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> {
|
export async function checkComponentInstalled(
|
||||||
return join(dirname(path), basename(path, extname(path)) + extension)
|
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(): Promise<{ok: true; registry: Registry} | {ok: false; message: string}> {
|
export async function getRegistry(
|
||||||
const registryResponse = await fetch(REGISTRY_URL)
|
branch?: string,
|
||||||
|
): Promise<{ message: string; ok: false } | { ok: true; registry: Registry }> {
|
||||||
|
const registryResponse = await safeFetch(registryURL(branch ?? "main"));
|
||||||
|
|
||||||
if (registryResponse.ok) {
|
if (registryResponse.ok) {
|
||||||
|
const registryJson = (await registryResponse.response.json()) as Registry;
|
||||||
|
registryJson.base = registryJson.base.replace("{branch}", branch ?? "main");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
registry: (await registryResponse.json()) as Registry,
|
registry: registryJson,
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: `Error while fetching registry: ${registryResponse.status} ${registryResponse.statusText}`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return registryResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAvailableComponentNames(registry: Registry): Promise<string[]> {
|
export async function getComponentURL(
|
||||||
return Object.keys(registry.components)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getComponentURL(registry: Registry, componentName: string): Promise<string> {
|
|
||||||
return registry.base.replace('{componentName}', registry.components[componentName])
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getComponentRealname(
|
|
||||||
registry: Registry,
|
registry: Registry,
|
||||||
componentName: keyof Registry['components'],
|
component: { type: "file" } & RegistryComponent,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return registry.components[componentName].split('/').pop() ?? ''
|
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> {
|
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.length === 0 || b.length === 0) return 0;
|
||||||
if (a === b) return 1.0
|
if (a === b) return 1;
|
||||||
|
|
||||||
const range = Math.floor(Math.max(a.length, b.length) / 2) - 1
|
const range = Math.floor(Math.max(a.length, b.length) / 2) - 1;
|
||||||
let matches = 0
|
let matches = 0;
|
||||||
|
|
||||||
let aMatches = new Array(a.length)
|
const aMatches = Array.from({ length: a.length });
|
||||||
let bMatches = new Array(b.length)
|
const bMatches = Array.from({ length: b.length });
|
||||||
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (const [i, element] of Object.entries(a).map(
|
||||||
const start = i >= range ? i - range : 0
|
([index, element]) => [Number.parseInt(index, 10), element] as const,
|
||||||
const end = i + range <= b.length - 1 ? i + range : b.length - 1
|
)) {
|
||||||
|
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++) {
|
for (let j = start; j <= end; j++) {
|
||||||
if (bMatches[j] !== true && a[i] === b[j]) {
|
if (bMatches[j] !== true && element === b[j]) {
|
||||||
++matches
|
++matches;
|
||||||
aMatches[i] = bMatches[j] = true
|
aMatches[i] = true;
|
||||||
break
|
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++)
|
for (let i = point; i < a.length; i++)
|
||||||
if (aMatches[i]) {
|
if (aMatches[i]) {
|
||||||
let j
|
let j: number;
|
||||||
for (j = point; j < b.length; j++)
|
for (j = point; j < b.length; j++)
|
||||||
if (bMatches[j]) {
|
if (bMatches[j]) {
|
||||||
point = j + 1
|
point = j + 1;
|
||||||
break
|
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
|
const J =
|
||||||
return J + Math.min((p * t) / matches, 1) * (1 - 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(
|
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
|
return componentJw
|
||||||
.filter(([_, score]) => score > 0)
|
.filter(([_, score]) => score > 0)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.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 {
|
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
|
*.sln
|
||||||
*.sw?
|
*.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 React from "react";
|
||||||
import { vcn, VariantProps, Slot, AsChild } from "../shared";
|
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
|
disabled:
|
||||||
|
"disabled:brightness-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:saturate-50",
|
||||||
outline: {
|
outline: {
|
||||||
focus: "dark:focus-visible:outline-white/20 focus-visible:outline-black/10",
|
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",
|
danger: "border-red-400 dark:border-red-600",
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default:
|
default: "bg-white dark:bg-black",
|
||||||
"bg-white dark:bg-black hover:bg-neutral-200 dark:hover:bg-neutral-800",
|
|
||||||
ghost:
|
ghost:
|
||||||
"bg-black/0 dark:bg-white/0 hover:bg-black/20 dark:hover:bg-white/20",
|
"bg-black/0 dark:bg-white/0 hover:bg-black/20 dark:hover:bg-white/20",
|
||||||
success:
|
success: "bg-green-100 dark:bg-green-900",
|
||||||
"bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800",
|
warning: "bg-yellow-100 dark:bg-yellow-900",
|
||||||
warning:
|
danger: "bg-red-100 dark:bg-red-900",
|
||||||
"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",
|
|
||||||
},
|
},
|
||||||
underline: "decoration-current",
|
underline: "decoration-current",
|
||||||
};
|
};
|
||||||
|
|
||||||
const [buttonVariants, resolveVariants] = vcn({
|
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: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
link: "p-0 text-base",
|
link: "p-0 text-base",
|
||||||
@ -109,16 +108,22 @@ export interface ButtonProps
|
|||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [variantProps, otherPropsCompressed] = resolveVariants(props);
|
const [variantProps, otherPropsCompressed] = resolveVariants(props);
|
||||||
const { asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
const { asChild, type, role, ...otherPropsExtracted } =
|
||||||
|
otherPropsCompressed;
|
||||||
|
|
||||||
const Comp = asChild ? Slot : "button";
|
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 };
|
export { Button };
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { type VariantProps, vcn } from "@pswui-lib";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { VariantProps, vcn } from "../shared";
|
|
||||||
|
|
||||||
const checkboxColors = {
|
const checkboxColors = {
|
||||||
background: {
|
background: {
|
||||||
@ -18,12 +18,10 @@ const checkboxColors = {
|
|||||||
disabledCheckedHover:
|
disabledCheckedHover:
|
||||||
"has-[input[type='checkbox']:disabled:checked]:hover:bg-neutral-300 dark:has-[input[type='checkbox']:disabled:checked]:hover:bg-neutral-700",
|
"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({
|
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: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
base: "size-[1em] p-0 [&>svg]:size-[1em]",
|
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>
|
</label>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
@ -1,32 +1,21 @@
|
|||||||
import React, { Dispatch, SetStateAction, useState } from "react";
|
import {
|
||||||
import { Slot, VariantProps, vcn } from "../shared";
|
Slot,
|
||||||
|
type VariantProps,
|
||||||
|
useAnimatedMount,
|
||||||
|
useDocument,
|
||||||
|
vcn,
|
||||||
|
} from "@pswui-lib";
|
||||||
|
import React, { type ReactNode, useId, useRef, useState } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* =========================
|
DialogContext,
|
||||||
* DialogContext
|
type IDialogContext,
|
||||||
* =========================
|
InnerDialogContext,
|
||||||
*/
|
|
||||||
|
|
||||||
interface DialogContext {
|
|
||||||
opened: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialDialogContext: DialogContext = { opened: false };
|
|
||||||
const DialogContext = React.createContext<
|
|
||||||
[DialogContext, Dispatch<SetStateAction<DialogContext>>]
|
|
||||||
>([
|
|
||||||
initialDialogContext,
|
initialDialogContext,
|
||||||
() => {
|
useDialogContext,
|
||||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
useInnerDialogContext,
|
||||||
console.warn(
|
} from "./Context";
|
||||||
"It seems like you're using DialogContext outside of a provider."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const useDialogContext = () => React.useContext(DialogContext);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* =========================
|
* =========================
|
||||||
@ -39,7 +28,10 @@ interface DialogRootProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DialogRoot = ({ children }: DialogRootProps) => {
|
const DialogRoot = ({ children }: DialogRootProps) => {
|
||||||
const state = useState<DialogContext>(initialDialogContext);
|
const state = useState<IDialogContext>({
|
||||||
|
...initialDialogContext,
|
||||||
|
ids: { dialog: useId(), title: useId(), description: useId() },
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={state}>{children}</DialogContext.Provider>
|
<DialogContext.Provider value={state}>{children}</DialogContext.Provider>
|
||||||
);
|
);
|
||||||
@ -56,15 +48,17 @@ interface DialogTriggerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DialogTrigger = ({ children }: DialogTriggerProps) => {
|
const DialogTrigger = ({ children }: DialogTriggerProps) => {
|
||||||
const [_, setState] = useDialogContext();
|
const [{ ids }, setState] = useDialogContext();
|
||||||
const onClick = () => setState((p) => ({ ...p, opened: true }));
|
const onClick = () => setState((p) => ({ ...p, opened: true }));
|
||||||
|
|
||||||
const slotProps = {
|
return (
|
||||||
onClick,
|
<Slot
|
||||||
children,
|
onClick={onClick}
|
||||||
};
|
aria-controls={ids.dialog}
|
||||||
|
>
|
||||||
return <Slot {...slotProps} />;
|
{children}
|
||||||
|
</Slot>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -74,33 +68,15 @@ const DialogTrigger = ({ children }: DialogTriggerProps) => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const [dialogOverlayVariant, resolveDialogOverlayVariant] = vcn({
|
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: {
|
variants: {
|
||||||
opened: {
|
opened: {
|
||||||
true: "pointer-events-auto opacity-100",
|
true: "pointer-events-auto opacity-100",
|
||||||
false: "pointer-events-none opacity-0",
|
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: {
|
defaults: {
|
||||||
opened: false,
|
opened: false,
|
||||||
blur: "md",
|
|
||||||
darken: "md",
|
|
||||||
padding: "md",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,20 +88,36 @@ interface DialogOverlay
|
|||||||
|
|
||||||
const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [{ opened }, setContext] = useDialogContext();
|
const [{ opened, ids }, setContext] = useDialogContext();
|
||||||
const [variantProps, otherPropsCompressed] = resolveDialogOverlayVariant({
|
const [variantProps, otherPropsCompressed] =
|
||||||
...props,
|
resolveDialogOverlayVariant(props);
|
||||||
opened,
|
|
||||||
});
|
|
||||||
const { children, closeOnClick, onClick, ...otherPropsExtracted } =
|
const { children, closeOnClick, onClick, ...otherPropsExtracted } =
|
||||||
otherPropsCompressed;
|
otherPropsCompressed;
|
||||||
return (
|
|
||||||
<>
|
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||||
{ReactDOM.createPortal(
|
|
||||||
|
const { isMounted, isRendered } = useAnimatedMount(opened, internalRef);
|
||||||
|
|
||||||
|
const document = useDocument();
|
||||||
|
if (!document) return null;
|
||||||
|
|
||||||
|
return isMounted
|
||||||
|
? ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
ref={ref}
|
id={ids.dialog}
|
||||||
className={dialogOverlayVariant(variantProps)}
|
ref={(el) => {
|
||||||
|
internalRef.current = el;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(el);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={dialogOverlayVariant({
|
||||||
|
...variantProps,
|
||||||
|
opened: isRendered,
|
||||||
|
})}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (closeOnClick) {
|
if (closeOnClick) {
|
||||||
setContext((p) => ({ ...p, opened: false }));
|
setContext((p) => ({ ...p, opened: false }));
|
||||||
@ -133,14 +125,23 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
|||||||
onClick?.(e);
|
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>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)
|
||||||
</>
|
: null;
|
||||||
);
|
},
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
DialogOverlay.displayName = "DialogOverlay";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* =========================
|
* =========================
|
||||||
@ -149,69 +150,51 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const [dialogContentVariant, resolveDialogContentVariant] = vcn({
|
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: {
|
variants: {
|
||||||
opened: {
|
opened: {
|
||||||
true: "scale-100",
|
true: "scale-100",
|
||||||
false: "scale-50",
|
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: {
|
defaults: {
|
||||||
opened: false,
|
opened: false,
|
||||||
size: "fit",
|
|
||||||
rounded: "md",
|
|
||||||
padding: "md",
|
|
||||||
gap: "md",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DialogContent
|
interface DialogContentProps
|
||||||
extends React.ComponentPropsWithoutRef<"div">,
|
extends React.ComponentPropsWithoutRef<"div">,
|
||||||
Omit<VariantProps<typeof dialogContentVariant>, "opened"> {}
|
Omit<VariantProps<typeof dialogContentVariant>, "opened"> {}
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<HTMLDivElement, DialogContent>(
|
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [{ opened }] = useDialogContext();
|
const [{ ids }] = useDialogContext();
|
||||||
const [variantProps, otherPropsCompressed] = resolveDialogContentVariant({
|
const [variantProps, otherPropsCompressed] =
|
||||||
...props,
|
resolveDialogContentVariant(props);
|
||||||
opened,
|
const { isRendered } = useInnerDialogContext();
|
||||||
});
|
const { children, onClick, ...otherPropsExtracted } = otherPropsCompressed;
|
||||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
ref={ref}
|
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
DialogContent.displayName = "DialogContent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* =========================
|
* =========================
|
||||||
@ -242,17 +225,9 @@ const DialogClose = ({ children }: DialogCloseProps) => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const [dialogHeaderVariant, resolveDialogHeaderVariant] = vcn({
|
const [dialogHeaderVariant, resolveDialogHeaderVariant] = vcn({
|
||||||
base: "flex flex-col",
|
base: "flex flex-col gap-2",
|
||||||
variants: {
|
variants: {},
|
||||||
gap: {
|
defaults: {},
|
||||||
sm: "gap-2",
|
|
||||||
md: "gap-4",
|
|
||||||
lg: "gap-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
gap: "sm",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DialogHeaderProps
|
interface DialogHeaderProps
|
||||||
@ -273,9 +248,11 @@ const DialogHeader = React.forwardRef<HTMLElement, DialogHeaderProps>(
|
|||||||
{children}
|
{children}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* =========================
|
* =========================
|
||||||
* DialogTitle / DialogSubtitle
|
* DialogTitle / DialogSubtitle
|
||||||
@ -283,91 +260,69 @@ const DialogHeader = React.forwardRef<HTMLElement, DialogHeaderProps>(
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const [dialogTitleVariant, resolveDialogTitleVariant] = vcn({
|
const [dialogTitleVariant, resolveDialogTitleVariant] = vcn({
|
||||||
variants: {
|
base: "text-xl font-bold",
|
||||||
size: {
|
variants: {},
|
||||||
sm: "text-lg",
|
defaults: {},
|
||||||
md: "text-xl",
|
|
||||||
lg: "text-2xl",
|
|
||||||
},
|
|
||||||
weight: {
|
|
||||||
sm: "font-medium",
|
|
||||||
md: "font-semibold",
|
|
||||||
lg: "font-bold",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
size: "md",
|
|
||||||
weight: "lg",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DialogTitleProps
|
interface DialogTitleProps
|
||||||
extends React.ComponentPropsWithoutRef<"h1">,
|
extends React.ComponentPropsWithoutRef<"h1">,
|
||||||
VariantProps<typeof dialogTitleVariant> {}
|
VariantProps<typeof dialogTitleVariant> {}
|
||||||
|
|
||||||
const [dialogSubtitleVariant, resolveDialogSubtitleVariant] = vcn({
|
const [dialogDescriptionVariant, resolveDialogDescriptionVariant] = vcn({
|
||||||
variants: {
|
base: "text-sm opacity-60 font-normal",
|
||||||
size: {
|
variants: {},
|
||||||
sm: "text-sm",
|
defaults: {},
|
||||||
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",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DialogSubtitleProps
|
interface DialogDescriptionProps
|
||||||
extends React.ComponentPropsWithoutRef<"h2">,
|
extends React.ComponentPropsWithoutRef<"h2">,
|
||||||
VariantProps<typeof dialogSubtitleVariant> {}
|
VariantProps<typeof dialogDescriptionVariant> {}
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [variantProps, otherPropsCompressed] =
|
const [variantProps, otherPropsCompressed] =
|
||||||
resolveDialogTitleVariant(props);
|
resolveDialogTitleVariant(props);
|
||||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||||
|
const [{ ids }] = useDialogContext();
|
||||||
return (
|
return (
|
||||||
<h1
|
<h1
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={dialogTitleVariant(variantProps)}
|
className={dialogTitleVariant(variantProps)}
|
||||||
|
id={ids.title}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</h1>
|
</h1>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
DialogTitle.displayName = "DialogTitle";
|
||||||
|
|
||||||
const DialogSubtitle = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
HTMLHeadingElement,
|
HTMLHeadingElement,
|
||||||
DialogSubtitleProps
|
DialogDescriptionProps
|
||||||
>((props, ref) => {
|
>((props, ref) => {
|
||||||
const [variantProps, otherPropsCompressed] =
|
const [variantProps, otherPropsCompressed] =
|
||||||
resolveDialogSubtitleVariant(props);
|
resolveDialogDescriptionVariant(props);
|
||||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||||
|
const [{ ids }] = useDialogContext();
|
||||||
return (
|
return (
|
||||||
<h2
|
<h2
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={dialogSubtitleVariant(variantProps)}
|
className={dialogDescriptionVariant(variantProps)}
|
||||||
|
id={ids.description}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</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({
|
const [dialogFooterVariant, resolveDialogFooterVariant] = vcn({
|
||||||
base: "flex flex-col items-end sm:flex-row sm:items-center sm:justify-end",
|
base: "flex w-full flex-col items-end sm:flex-row sm:items-center sm:justify-end gap-2",
|
||||||
variants: {
|
variants: {},
|
||||||
gap: {
|
defaults: {},
|
||||||
sm: "gap-2",
|
|
||||||
md: "gap-4",
|
|
||||||
lg: "gap-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
gap: "md",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface DialogFooterProps
|
interface DialogFooterProps
|
||||||
@ -399,19 +346,46 @@ const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
|
|||||||
resolveDialogFooterVariant(props);
|
resolveDialogFooterVariant(props);
|
||||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
||||||
return (
|
return (
|
||||||
<div
|
<footer
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={dialogFooterVariant(variantProps)}
|
className={dialogFooterVariant(variantProps)}
|
||||||
>
|
>
|
||||||
{children}
|
{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 {
|
export {
|
||||||
useDialogContext,
|
|
||||||
DialogRoot,
|
DialogRoot,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
@ -420,5 +394,7 @@ export {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogSubtitle,
|
DialogSubtitle,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
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, {
|
import React, {
|
||||||
ComponentPropsWithoutRef,
|
type ComponentPropsWithoutRef,
|
||||||
TouchEvent as ReactTouchEvent,
|
type TouchEvent as ReactTouchEvent,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AsChild, Slot, VariantProps, vcn } from "../shared";
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
interface IDrawerContext {
|
interface IDrawerContext {
|
||||||
@ -15,6 +22,8 @@ interface IDrawerContext {
|
|||||||
closeThreshold: number;
|
closeThreshold: number;
|
||||||
movePercentage: number;
|
movePercentage: number;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
isMounted: boolean;
|
||||||
|
isRendered: boolean;
|
||||||
leaveWhileDragging: boolean;
|
leaveWhileDragging: boolean;
|
||||||
}
|
}
|
||||||
const DrawerContextInitial: IDrawerContext = {
|
const DrawerContextInitial: IDrawerContext = {
|
||||||
@ -22,6 +31,8 @@ const DrawerContextInitial: IDrawerContext = {
|
|||||||
closeThreshold: 0.3,
|
closeThreshold: 0.3,
|
||||||
movePercentage: 0,
|
movePercentage: 0,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
|
isMounted: false,
|
||||||
|
isRendered: false,
|
||||||
leaveWhileDragging: false,
|
leaveWhileDragging: false,
|
||||||
};
|
};
|
||||||
const DrawerContext = React.createContext<
|
const DrawerContext = React.createContext<
|
||||||
@ -31,7 +42,7 @@ const DrawerContext = React.createContext<
|
|||||||
() => {
|
() => {
|
||||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||||
console.warn(
|
console.warn(
|
||||||
"It seems like you're using DrawerContext outside of a provider."
|
"It seems like you're using DrawerContext outside of a provider.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -49,14 +60,15 @@ const DrawerRoot = ({ children, closeThreshold, opened }: DrawerRootProps) => {
|
|||||||
opened: opened ?? DrawerContextInitial.opened,
|
opened: opened ?? DrawerContextInitial.opened,
|
||||||
closeThreshold: closeThreshold ?? DrawerContextInitial.closeThreshold,
|
closeThreshold: closeThreshold ?? DrawerContextInitial.closeThreshold,
|
||||||
});
|
});
|
||||||
|
const setState = state[1];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
state[1]((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
opened: opened ?? prev.opened,
|
opened: opened ?? prev.opened,
|
||||||
closeThreshold: closeThreshold ?? prev.closeThreshold,
|
closeThreshold: closeThreshold ?? prev.closeThreshold,
|
||||||
}));
|
}));
|
||||||
}, [closeThreshold, opened]);
|
}, [closeThreshold, opened, setState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DrawerContext.Provider value={state}>{children}</DrawerContext.Provider>
|
<DrawerContext.Provider value={state}>{children}</DrawerContext.Provider>
|
||||||
@ -77,7 +89,7 @@ const [drawerOverlayVariant, resolveDrawerOverlayVariantProps] = vcn({
|
|||||||
base: "fixed inset-0 transition-[backdrop-filter] duration-75",
|
base: "fixed inset-0 transition-[backdrop-filter] duration-75",
|
||||||
variants: {
|
variants: {
|
||||||
opened: {
|
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",
|
false: "pointer-events-none select-none",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -95,8 +107,14 @@ interface DrawerOverlayProps
|
|||||||
|
|
||||||
const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
|
const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
|
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [state, setState] = useContext(DrawerContext);
|
const [state, setState] = useContext(DrawerContext);
|
||||||
|
|
||||||
|
const { isMounted, isRendered } = useAnimatedMount(
|
||||||
|
state.isDragging ? true : state.opened,
|
||||||
|
internalRef,
|
||||||
|
);
|
||||||
|
|
||||||
const [variantProps, restPropsCompressed] =
|
const [variantProps, restPropsCompressed] =
|
||||||
resolveDrawerOverlayVariantProps(props);
|
resolveDrawerOverlayVariantProps(props);
|
||||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||||
@ -114,29 +132,50 @@ const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
|
|||||||
state.isDragging
|
state.isDragging
|
||||||
? state.movePercentage + DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS
|
? state.movePercentage + DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS
|
||||||
: state.opened
|
: state.opened
|
||||||
? DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS
|
? DRAWER_OVERLAY_BACKDROP_FILTER_BRIGHTNESS
|
||||||
: 1
|
: 1
|
||||||
})`;
|
})`;
|
||||||
|
|
||||||
return createPortal(
|
const document = useDocument();
|
||||||
<Comp
|
if (!document) return null;
|
||||||
{...restPropsExtracted}
|
|
||||||
className={drawerOverlayVariant({
|
return (
|
||||||
...variantProps,
|
<>
|
||||||
opened: state.isDragging ? true : state.opened,
|
<DrawerContext.Provider
|
||||||
})}
|
value={[{ ...state, isMounted, isRendered }, setState]}
|
||||||
onClick={onOutsideClick}
|
>
|
||||||
style={{
|
{isMounted
|
||||||
backdropFilter,
|
? createPortal(
|
||||||
WebkitBackdropFilter: backdropFilter,
|
<Comp
|
||||||
transitionDuration: state.isDragging ? "0s" : undefined,
|
{...restPropsExtracted}
|
||||||
}}
|
className={drawerOverlayVariant({
|
||||||
ref={ref}
|
...variantProps,
|
||||||
/>,
|
opened: isRendered,
|
||||||
document.body
|
})}
|
||||||
|
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 = {
|
const drawerContentColors = {
|
||||||
background: "bg-white dark:bg-black",
|
background: "bg-white dark:bg-black",
|
||||||
@ -144,28 +183,55 @@ const drawerContentColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [drawerContentVariant, resolveDrawerContentVariantProps] = vcn({
|
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: {
|
variants: {
|
||||||
position: {
|
position: {
|
||||||
top: "top-0 inset-x-0 w-full max-w-screen rounded-t-lg border-b-2",
|
top: "top-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",
|
bottom: "bottom-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",
|
left: "left-0 h-screen rounded-l-lg border-r-2",
|
||||||
right: "right-0 inset-y-0 h-screen rounded-r-lg border-l-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: {
|
opened: {
|
||||||
true: "touch-none",
|
true: "",
|
||||||
false:
|
false:
|
||||||
"[&.top-0]:-translate-y-full [&.bottom-0]:translate-y-full [&.left-0]:-translate-x-full [&.right-0]:translate-x-full",
|
"[&.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: {
|
defaults: {
|
||||||
position: "left",
|
position: "left",
|
||||||
opened: false,
|
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
|
interface DrawerContentProps
|
||||||
extends Omit<VariantProps<typeof drawerContentVariant>, "opened">,
|
extends Omit<
|
||||||
|
VariantProps<typeof drawerContentVariant>,
|
||||||
|
"opened" | "internal"
|
||||||
|
>,
|
||||||
AsChild,
|
AsChild,
|
||||||
ComponentPropsWithoutRef<"div"> {}
|
ComponentPropsWithoutRef<"div"> {}
|
||||||
|
|
||||||
@ -250,8 +316,8 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
? e.movementY
|
? e.movementY
|
||||||
: e.touches[0].pageY - prev.prevTouch.y
|
: e.touches[0].pageY - prev.prevTouch.y
|
||||||
: "movementX" in e
|
: "movementX" in e
|
||||||
? e.movementX
|
? e.movementX
|
||||||
: e.touches[0].pageX - prev.prevTouch.x;
|
: e.touches[0].pageX - prev.prevTouch.x;
|
||||||
if (
|
if (
|
||||||
(["top", "left"].includes(position) &&
|
(["top", "left"].includes(position) &&
|
||||||
dragState.delta >= 0 &&
|
dragState.delta >= 0 &&
|
||||||
@ -261,7 +327,8 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
movement < 0)
|
movement < 0)
|
||||||
) {
|
) {
|
||||||
movement =
|
movement =
|
||||||
movement / Math.abs(dragState.delta === 0 ? 1 : dragState.delta);
|
movement /
|
||||||
|
Math.abs(dragState.delta === 0 ? 1 : dragState.delta);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@ -299,15 +366,15 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
window.removeEventListener("touchmove", onMouseMove);
|
window.removeEventListener("touchmove", onMouseMove);
|
||||||
window.removeEventListener("touchend", onMouseUp);
|
window.removeEventListener("touchend", onMouseUp);
|
||||||
};
|
};
|
||||||
}, [state, dragState]);
|
}, [state, setState, dragState, position]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={drawerContentVariant({
|
className={drawerContentVariant({
|
||||||
...variantProps,
|
...variantProps,
|
||||||
opened: true,
|
opened: state.isRendered,
|
||||||
className: dragState.isDragging
|
className: dragState.isDragging
|
||||||
? "transition-[width_0ms]"
|
? "transition-[width] duration-0"
|
||||||
: variantProps.className,
|
: variantProps.className,
|
||||||
})}
|
})}
|
||||||
style={
|
style={
|
||||||
@ -319,6 +386,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
0) +
|
0) +
|
||||||
(position === "top" ? dragState.delta : -dragState.delta),
|
(position === "top" ? dragState.delta : -dragState.delta),
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
[`padding${position.slice(0, 1).toUpperCase()}${position.slice(1)}`]: `${dragState.delta}px`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
width:
|
width:
|
||||||
@ -326,6 +394,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
0) +
|
0) +
|
||||||
(position === "left" ? dragState.delta : -dragState.delta),
|
(position === "left" ? dragState.delta : -dragState.delta),
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
[`padding${position.slice(0, 1).toUpperCase()}${position.slice(1)}`]: `${dragState.delta}px`,
|
||||||
}
|
}
|
||||||
: { width: 0, height: 0, padding: 0 }
|
: { width: 0, height: 0, padding: 0 }
|
||||||
}
|
}
|
||||||
@ -334,18 +403,20 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
{...restPropsExtracted}
|
{...restPropsExtracted}
|
||||||
className={drawerContentVariant({
|
className={drawerContentVariant({
|
||||||
...variantProps,
|
...variantProps,
|
||||||
opened: state.opened,
|
opened: state.isRendered,
|
||||||
|
internal: true,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
transform: dragState.isDragging
|
transform:
|
||||||
? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${
|
dragState.isDragging &&
|
||||||
dragState.delta
|
((["top", "left"].includes(position) && dragState.delta < 0) ||
|
||||||
}px)`
|
(["bottom", "right"].includes(position) && dragState.delta > 0))
|
||||||
: undefined,
|
? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${dragState.delta}px)`
|
||||||
|
: undefined,
|
||||||
transitionDuration: dragState.isDragging ? "0s" : undefined,
|
transitionDuration: dragState.isDragging ? "0s" : undefined,
|
||||||
userSelect: dragState.isDragging ? "none" : undefined,
|
userSelect: dragState.isDragging ? "none" : undefined,
|
||||||
}}
|
}}
|
||||||
ref={(el) => {
|
ref={(el: HTMLDivElement | null) => {
|
||||||
internalRef.current = el;
|
internalRef.current = el;
|
||||||
if (typeof ref === "function") {
|
if (typeof ref === "function") {
|
||||||
ref(el);
|
ref(el);
|
||||||
@ -370,8 +441,9 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
DrawerContent.displayName = "DrawerContent";
|
||||||
|
|
||||||
const DrawerClose = forwardRef<
|
const DrawerClose = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
@ -386,6 +458,7 @@ const DrawerClose = forwardRef<
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
DrawerClose.displayName = "DrawerClose";
|
||||||
|
|
||||||
const [drawerHeaderVariant, resolveDrawerHeaderVariantProps] = vcn({
|
const [drawerHeaderVariant, resolveDrawerHeaderVariantProps] = vcn({
|
||||||
base: "flex flex-col gap-2",
|
base: "flex flex-col gap-2",
|
||||||
@ -403,18 +476,22 @@ const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
|
|||||||
const [variantProps, restPropsCompressed] =
|
const [variantProps, restPropsCompressed] =
|
||||||
resolveDrawerHeaderVariantProps(props);
|
resolveDrawerHeaderVariantProps(props);
|
||||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||||
|
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Comp
|
||||||
{...restPropsExtracted}
|
{...restPropsExtracted}
|
||||||
className={drawerHeaderVariant(variantProps)}
|
className={drawerHeaderVariant(variantProps)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
DrawerHeader.displayName = "DrawerHeader";
|
||||||
|
|
||||||
const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({
|
const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({
|
||||||
base: "flex-grow",
|
base: "grow",
|
||||||
variants: {},
|
variants: {},
|
||||||
defaults: {},
|
defaults: {},
|
||||||
});
|
});
|
||||||
@ -428,14 +505,18 @@ const DrawerBody = forwardRef<HTMLDivElement, DrawerBodyProps>((props, ref) => {
|
|||||||
const [variantProps, restPropsCompressed] =
|
const [variantProps, restPropsCompressed] =
|
||||||
resolveDrawerBodyVariantProps(props);
|
resolveDrawerBodyVariantProps(props);
|
||||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||||
|
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Comp
|
||||||
{...restPropsExtracted}
|
{...restPropsExtracted}
|
||||||
className={drawerBodyVariant(variantProps)}
|
className={drawerBodyVariant(variantProps)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
DrawerBody.displayName = "DrawerBody";
|
||||||
|
|
||||||
const [drawerFooterVariant, resolveDrawerFooterVariantProps] = vcn({
|
const [drawerFooterVariant, resolveDrawerFooterVariantProps] = vcn({
|
||||||
base: "flex flex-row justify-end gap-2",
|
base: "flex flex-row justify-end gap-2",
|
||||||
@ -453,15 +534,19 @@ const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
|
|||||||
const [variantProps, restPropsCompressed] =
|
const [variantProps, restPropsCompressed] =
|
||||||
resolveDrawerFooterVariantProps(props);
|
resolveDrawerFooterVariantProps(props);
|
||||||
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
const { asChild, ...restPropsExtracted } = restPropsCompressed;
|
||||||
|
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Comp
|
||||||
{...restPropsExtracted}
|
{...restPropsExtracted}
|
||||||
className={drawerFooterVariant(variantProps)}
|
className={drawerFooterVariant(variantProps)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
DrawerFooter.displayName = "DrawerFooter";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DrawerRoot,
|
DrawerRoot,
|
||||||
|
183
packages/react/components/Form.tsx
Normal file
183
packages/react/components/Form.tsx
Normal file
@ -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 React from "react";
|
||||||
import { VariantProps, vcn } from "../shared";
|
|
||||||
|
|
||||||
const inputColors = {
|
const inputColors = {
|
||||||
background: {
|
background: {
|
||||||
default: "bg-neutral-50 dark:bg-neutral-900",
|
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:
|
||||||
"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:
|
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",
|
"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: {
|
border: {
|
||||||
default: "border-neutral-400 dark:border-neutral-600",
|
default: "border-neutral-400 dark:border-neutral-600",
|
||||||
invalid:
|
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: {
|
ring: {
|
||||||
default: "ring-transparent focus-within:ring-current",
|
default: "ring-transparent focus-within:ring-current",
|
||||||
invalid:
|
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({
|
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: {
|
variants: {
|
||||||
unstyled: {
|
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",
|
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: "",
|
false: "",
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
true: "w-full",
|
true: "[&:has(input)]:w-full w-full",
|
||||||
false: "w-fit",
|
false: "[&:has(input)]:w-fit w-fit",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
@ -42,7 +43,8 @@ const [inputVariant, resolveInputVariantProps] = vcn({
|
|||||||
|
|
||||||
interface InputFrameProps
|
interface InputFrameProps
|
||||||
extends VariantProps<typeof inputVariant>,
|
extends VariantProps<typeof inputVariant>,
|
||||||
React.ComponentPropsWithoutRef<"label"> {
|
React.ComponentPropsWithoutRef<"label">,
|
||||||
|
AsChild {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,19 +52,22 @@ const InputFrame = React.forwardRef<HTMLLabelElement, InputFrameProps>(
|
|||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [variantProps, otherPropsCompressed] =
|
const [variantProps, otherPropsCompressed] =
|
||||||
resolveInputVariantProps(props);
|
resolveInputVariantProps(props);
|
||||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||||
|
|
||||||
|
const Comp = asChild ? Slot : "label";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<Comp
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`group/input-frame ${inputVariant(variantProps)}`}
|
className={`group/input-frame ${inputVariant(variantProps)}`}
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
InputFrame.displayName = "InputFrame";
|
||||||
|
|
||||||
interface InputProps
|
interface InputProps
|
||||||
extends VariantProps<typeof inputVariant>,
|
extends VariantProps<typeof inputVariant>,
|
||||||
@ -92,7 +97,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
|||||||
const innerRef = React.useRef<HTMLInputElement | null>(null);
|
const innerRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (innerRef && innerRef.current) {
|
if (innerRef?.current) {
|
||||||
innerRef.current.setCustomValidity(invalid ?? "");
|
innerRef.current.setCustomValidity(invalid ?? "");
|
||||||
}
|
}
|
||||||
}, [invalid]);
|
}, [invalid]);
|
||||||
@ -113,5 +118,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { InputFrame, Input };
|
export { InputFrame, Input };
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { type VariantProps, vcn } from "@pswui-lib";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { VariantProps, vcn } from "../shared";
|
|
||||||
|
|
||||||
const [labelVariant, resolveLabelVariantProps] = vcn({
|
const [labelVariant, resolveLabelVariantProps] = vcn({
|
||||||
base: "has-[input[disabled]]:brightness-75 has-[input[disabled]]:cursor-not-allowed has-[input:invalid]:text-red-500",
|
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 };
|
export { Label };
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||||
import React, { useContext, useEffect, useRef } from "react";
|
import React, { useContext, useEffect, useRef } from "react";
|
||||||
import { AsChild, Slot, VariantProps, vcn } from "../shared";
|
|
||||||
|
|
||||||
interface IPopoverContext {
|
interface IPopoverContext {
|
||||||
|
controlled: boolean;
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10,11 +11,12 @@ const PopoverContext = React.createContext<
|
|||||||
>([
|
>([
|
||||||
{
|
{
|
||||||
opened: false,
|
opened: false,
|
||||||
|
controlled: false,
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||||
console.warn(
|
console.warn(
|
||||||
"It seems like you're using PopoverContext outside of a provider."
|
"It seems like you're using PopoverContext outside of a provider.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -26,14 +28,23 @@ interface PopoverProps extends AsChild {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Popover = ({ children, opened, asChild }: PopoverProps) => {
|
const Popover = ({ children, opened, asChild }: PopoverProps) => {
|
||||||
const state = React.useState<IPopoverContext>({
|
const [state, setState] = React.useState<IPopoverContext>({
|
||||||
opened: opened ?? false,
|
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";
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverContext.Provider value={state}>
|
<PopoverContext.Provider value={[state, setState]}>
|
||||||
<Comp className="relative">{children}</Comp>
|
<Comp className="relative">{children}</Comp>
|
||||||
</PopoverContext.Provider>
|
</PopoverContext.Provider>
|
||||||
);
|
);
|
||||||
@ -54,26 +65,25 @@ const popoverColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
|
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: {
|
variants: {
|
||||||
|
direction: {
|
||||||
|
row: "",
|
||||||
|
col: "",
|
||||||
|
},
|
||||||
anchor: {
|
anchor: {
|
||||||
topLeft:
|
start: "",
|
||||||
"bottom-[calc(100%+var(--popover-offset))] right-[calc(100%+var(--popover-offset))] origin-bottom-right",
|
middle: "",
|
||||||
topCenter:
|
end: "",
|
||||||
"bottom-[calc(100%+var(--popover-offset))] left-1/2 -translate-x-1/2 origin-bottom-center",
|
},
|
||||||
topRight:
|
align: {
|
||||||
"bottom-[calc(100%+var(--popover-offset))] left-[calc(100%+var(--popover-offset))] origin-bottom-left",
|
start: "",
|
||||||
middleLeft: "top-1/2 translate-y-1/2 right-full origin-right",
|
middle: "",
|
||||||
middleCenter:
|
end: "",
|
||||||
"top-1/2 translate-y-1/2 left-1/2 -translate-x-1/2 origin-center",
|
},
|
||||||
middleRight:
|
position: {
|
||||||
"top-1/2 translate-y-1/2 left-[calc(100%+var(--popover-offset))] origin-left",
|
start: "",
|
||||||
bottomLeft:
|
end: "",
|
||||||
"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",
|
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
sm: "[--popover-offset:2px]",
|
sm: "[--popover-offset:2px]",
|
||||||
@ -81,15 +91,104 @@ const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
|
|||||||
lg: "[--popover-offset:8px]",
|
lg: "[--popover-offset:8px]",
|
||||||
},
|
},
|
||||||
opened: {
|
opened: {
|
||||||
true: "opacity-1 scale-100",
|
true: "opacity-1 scale-100 pointer-events-auto select-auto touch-auto",
|
||||||
false: "opacity-0 scale-75",
|
false: "opacity-0 scale-75 pointer-events-none select-none touch-none",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
anchor: "bottomCenter",
|
direction: "col",
|
||||||
|
anchor: "middle",
|
||||||
|
align: "middle",
|
||||||
|
position: "end",
|
||||||
opened: false,
|
opened: false,
|
||||||
offset: "md",
|
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
|
interface PopoverContentProps
|
||||||
@ -101,39 +200,50 @@ const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
|||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [variantProps, otherPropsCompressed] =
|
const [variantProps, otherPropsCompressed] =
|
||||||
resolvePopoverContentVariantProps(props);
|
resolvePopoverContentVariantProps(props);
|
||||||
const { children, ...otherPropsExtracted } = otherPropsCompressed;
|
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
|
||||||
const [state, setState] = useContext(PopoverContext);
|
const [state, setState] = useContext(PopoverContext);
|
||||||
|
|
||||||
const internalRef = useRef<HTMLDivElement | null>(null);
|
const internalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleOutsideClick(e: any) {
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
if (internalRef.current && !internalRef.current.contains(e.target)) {
|
if (
|
||||||
|
internalRef.current &&
|
||||||
|
!internalRef.current.contains(e.target as Node | null)
|
||||||
|
) {
|
||||||
setState((prev) => ({ ...prev, opened: false }));
|
setState((prev) => ({ ...prev, opened: false }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", handleOutsideClick);
|
!state.controlled &&
|
||||||
|
document.addEventListener("mousedown", handleOutsideClick);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleOutsideClick);
|
document.removeEventListener("mousedown", handleOutsideClick);
|
||||||
};
|
};
|
||||||
}, [internalRef, setState]);
|
}, [state.controlled, setState]);
|
||||||
|
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Comp
|
||||||
{...otherPropsExtracted}
|
{...otherPropsExtracted}
|
||||||
className={popoverContentVariant({
|
className={popoverContentVariant({
|
||||||
...variantProps,
|
...variantProps,
|
||||||
opened: state.opened,
|
opened: state.opened,
|
||||||
})}
|
})}
|
||||||
ref={(el) => {
|
ref={(el: HTMLDivElement) => {
|
||||||
internalRef.current = el;
|
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}
|
{children}
|
||||||
</div>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
PopoverContent.displayName = "PopoverContent";
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent };
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { type VariantProps, vcn } from "@pswui-lib";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { VariantProps, vcn } from "../shared";
|
|
||||||
|
|
||||||
const switchColors = {
|
const switchColors = {
|
||||||
background: {
|
background: {
|
||||||
@ -80,5 +80,6 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>((props, ref) => {
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
Switch.displayName = "Switch";
|
||||||
|
|
||||||
export { Switch };
|
export { Switch };
|
||||||
|
@ -1,30 +1,7 @@
|
|||||||
import { AsChild, Slot, VariantProps, vcn } from "../shared";
|
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface Tab {
|
import { type Tab, TabContext, type TabContextBody } from "./Context";
|
||||||
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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
interface TabProviderProps {
|
interface TabProviderProps {
|
||||||
defaultName: string;
|
defaultName: string;
|
||||||
@ -40,77 +17,6 @@ const TabProvider = ({ defaultName, children }: TabProviderProps) => {
|
|||||||
return <TabContext.Provider value={state}>{children}</TabContext.Provider>;
|
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({
|
const [TabListVariant, resolveTabListVariantProps] = vcn({
|
||||||
base: "flex flex-row bg-gray-100 dark:bg-neutral-800 rounded-lg p-1.5 gap-1",
|
base: "flex flex-row bg-gray-100 dark:bg-neutral-800 rounded-lg p-1.5 gap-1",
|
||||||
variants: {},
|
variants: {},
|
||||||
@ -124,11 +30,16 @@ interface TabListProps
|
|||||||
const TabList = (props: TabListProps) => {
|
const TabList = (props: TabListProps) => {
|
||||||
const [variantProps, restProps] = resolveTabListVariantProps(props);
|
const [variantProps, restProps] = resolveTabListVariantProps(props);
|
||||||
|
|
||||||
return <div className={TabListVariant(variantProps)} {...restProps} />;
|
return (
|
||||||
|
<div
|
||||||
|
className={TabListVariant(variantProps)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [TabTriggerVariant, resolveTabTriggerVariantProps] = vcn({
|
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: {
|
variants: {
|
||||||
active: {
|
active: {
|
||||||
true: "bg-white/100 dark:bg-black/100 text-black dark:text-white",
|
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";
|
const Comp = props.asChild ? Slot : "button";
|
||||||
|
|
||||||
@ -212,18 +124,18 @@ const TabContent = (props: TabContentProps) => {
|
|||||||
const { name, ...restProps } = restPropsBeforeParse;
|
const { name, ...restProps } = restPropsBeforeParse;
|
||||||
const [context] = React.useContext(TabContext);
|
const [context] = React.useContext(TabContext);
|
||||||
|
|
||||||
if (context.active[1] === name) {
|
if (context.active[1] !== name) {
|
||||||
return (
|
|
||||||
<Slot
|
|
||||||
className={tabContentVariant({
|
|
||||||
...variantProps,
|
|
||||||
})}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return null;
|
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 "../shared";
|
|
||||||
|
|
||||||
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 React, { useState } from "react";
|
||||||
import { VariantProps, vcn } from "../shared";
|
|
||||||
|
|
||||||
interface TooltipContextBody {
|
interface TooltipContextBody {
|
||||||
position: "top" | "bottom" | "left" | "right";
|
position: "top" | "bottom" | "left" | "right";
|
||||||
@ -15,7 +15,7 @@ const TooltipContext = React.createContext<
|
|||||||
() => {
|
() => {
|
||||||
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
|
||||||
console.warn(
|
console.warn(
|
||||||
"It seems like you're using TooltipContext outside of a provider."
|
"It seems like you're using TooltipContext outside of a provider.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -30,55 +30,97 @@ const [tooltipVariant, resolveTooltipVariantProps] = vcn({
|
|||||||
left: "",
|
left: "",
|
||||||
right: "",
|
right: "",
|
||||||
},
|
},
|
||||||
|
controlled: {
|
||||||
|
true: "controlled",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
opened: {
|
||||||
|
true: "opened",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
position: "top",
|
position: "top",
|
||||||
|
controlled: false,
|
||||||
|
opened: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TooltipProps
|
interface TooltipProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
VariantProps<typeof tooltipVariant> {}
|
VariantProps<typeof tooltipVariant>,
|
||||||
|
AsChild {}
|
||||||
|
|
||||||
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>((props, ref) => {
|
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>((props, ref) => {
|
||||||
const [variantProps, rest] = resolveTooltipVariantProps(props);
|
const [variantProps, rest] = resolveTooltipVariantProps(props);
|
||||||
|
const { asChild, ...extractedRest } = rest;
|
||||||
const contextState = useState<TooltipContextBody>({
|
const contextState = useState<TooltipContextBody>({
|
||||||
...tooltipContextInitial,
|
...tooltipContextInitial,
|
||||||
...variantProps,
|
...variantProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipContext.Provider value={contextState}>
|
<TooltipContext.Provider value={contextState}>
|
||||||
<div ref={ref} className={tooltipVariant(variantProps)} {...rest} />
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={tooltipVariant(variantProps)}
|
||||||
|
{...extractedRest}
|
||||||
|
/>
|
||||||
</TooltipContext.Provider>
|
</TooltipContext.Provider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
Tooltip.displayName = "Tooltip";
|
||||||
|
|
||||||
const tooltipContentColors = {
|
const tooltipContentColors = {
|
||||||
background: "bg-white dark:bg-black",
|
variants: {
|
||||||
border: "border-neutral-200 dark:border-neutral-700",
|
default:
|
||||||
|
"bg-white dark:bg-black border-neutral-200 dark:border-neutral-700",
|
||||||
|
error: "bg-red-400 dark:bg-red-800 border-red-500 text-white",
|
||||||
|
success: "bg-green-400 dark:bg-green-800 border-green-500 text-white",
|
||||||
|
warning: "bg-yellow-400 dark:bg-yellow-800 border-yellow-500",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const [tooltipContentVariant, resolveTooltipContentVariantProps] = vcn({
|
const [tooltipContentVariant, resolveTooltipContentVariantProps] = vcn({
|
||||||
base: `absolute py-1 px-3 ${tooltipContentColors.background} border ${tooltipContentColors.border} [--tooltip-offset:2px] opacity-0 group-hover/tooltip:opacity-100 select-none pointer-events-none group-hover/tooltip:select-auto group-hover/tooltip:pointer-events-auto transition-all rounded-md`,
|
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: {
|
variants: {
|
||||||
position: {
|
position: {
|
||||||
top: "bottom-[calc(100%+var(--tooltip-offset))] left-1/2 -translate-x-1/2 group-hover/tooltip:translate-y-0 translate-y-[10px]",
|
top: "bottom-[calc(100%+var(--tooltip-offset))] left-1/2 -translate-x-1/2 group-[:not(.controlled):hover]/tooltip:translate-y-0 group-[.opened]/tooltip:translate-y-0 translate-y-[10px]",
|
||||||
bottom:
|
bottom:
|
||||||
"top-[calc(100%+var(--tooltip-offset))] left-1/2 -translate-x-1/2 group-hover/tooltip:translate-y-0 translate-y-[-10px]",
|
"top-[calc(100%+var(--tooltip-offset))] left-1/2 -translate-x-1/2 group-[:not(.controlled):hover]/tooltip:translate-y-0 group-[.opened]/tooltip:translate-y-0 translate-y-[-10px]",
|
||||||
left: "right-[calc(100%+var(--tooltip-offset))] top-1/2 -translate-y-1/2 group-hover/tooltip:translate-x-0 translate-x-[10px]",
|
left: "right-[calc(100%+var(--tooltip-offset))] top-1/2 -translate-y-1/2 group-[:not(.controlled):hover]/tooltip:translate-x-0 group-[.opened]/tooltip:translate-x-0 translate-x-[10px]",
|
||||||
right:
|
right:
|
||||||
"left-[calc(100%+var(--tooltip-offset))] top-1/2 -translate-y-1/2 group-hover/tooltip:translate-x-0 translate-x-[-10px]",
|
"left-[calc(100%+var(--tooltip-offset))] top-1/2 -translate-y-1/2 group-[:not(.controlled):hover]/tooltip:translate-x-0 group-[.opened]/tooltip:translate-x-0 translate-x-[-10px]",
|
||||||
|
},
|
||||||
|
delay: {
|
||||||
|
none: "[--delay:0ms]",
|
||||||
|
early: "[--delay:150ms]",
|
||||||
|
normal: "[--delay:500ms]",
|
||||||
|
late: "[--delay:1000ms]",
|
||||||
},
|
},
|
||||||
offset: {
|
offset: {
|
||||||
sm: "[--tooltip-offset:2px]",
|
sm: "[--tooltip-offset:2px]",
|
||||||
md: "[--tooltip-offset:4px]",
|
md: "[--tooltip-offset:4px]",
|
||||||
lg: "[--tooltip-offset:8px]",
|
lg: "[--tooltip-offset:8px]",
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
normal: tooltipContentColors.variants.default,
|
||||||
|
error: tooltipContentColors.variants.error,
|
||||||
|
success: tooltipContentColors.variants.success,
|
||||||
|
warning: tooltipContentColors.variants.warning,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
position: "top",
|
position: "top",
|
||||||
offset: "md",
|
offset: "md",
|
||||||
|
delay: "normal",
|
||||||
|
status: "normal",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -98,10 +140,12 @@ const TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(
|
|||||||
...variantProps,
|
...variantProps,
|
||||||
position: contextState.position,
|
position: contextState.position,
|
||||||
})}
|
})}
|
||||||
|
role="tooltip"
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
TooltipContent.displayName = "TooltipContent";
|
||||||
|
|
||||||
export { Tooltip, TooltipContent };
|
export { Tooltip, TooltipContent };
|
||||||
|
@ -3,23 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
<title>Dev Environment</title>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
|
||||||
<meta name="description" content="PSW/UI is a UI library with minimum dependency." />
|
|
||||||
<meta name="keywords" content="UI, library, PSW, React, components" />
|
|
||||||
<meta name="author" content="Shinwoo PARK" />
|
|
||||||
<meta property="og:title" content="PSW/UI" />
|
|
||||||
<meta property="og:description" content="PSW/UI is a UI library with minimum dependency." />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:url" content="https://ui.psw.kr" />
|
|
||||||
<meta property="og:image" content="https://ui.psw.kr/android-chrome-512x512.png" />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content="PSW/UI" />
|
|
||||||
<meta name="twitter:description" content="PSW/UI is a UI library with minimum dependency." />
|
|
||||||
<meta name="twitter:image" content="https://ui.psw.kr/android-chrome-512x512.png" />
|
|
||||||
<title>PSW/UI</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
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";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,6 +57,31 @@ type VariantKV<V extends VariantType> = {
|
|||||||
[VariantKey in keyof V]: BooleanString<keyof V[VariantKey] & string>;
|
[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.
|
* 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;
|
base?: string | undefined;
|
||||||
variants: V;
|
variants: V;
|
||||||
|
dynamics?: DynamicClassName<V>[];
|
||||||
defaults: VariantKV<V>;
|
defaults: VariantKV<V>;
|
||||||
presets?: undefined;
|
presets?: undefined;
|
||||||
}): [
|
}): [
|
||||||
@ -109,6 +134,7 @@ export function vcn<V extends VariantType>(param: {
|
|||||||
/**
|
/**
|
||||||
* Any Props -> Variant Props, Other Props
|
* 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>>(
|
<AnyPropBeforeResolve extends Record<string, any>>(
|
||||||
anyProps: AnyPropBeforeResolve,
|
anyProps: AnyPropBeforeResolve,
|
||||||
) => [
|
) => [
|
||||||
@ -124,6 +150,7 @@ export function vcn<V extends VariantType, P extends PresetType<V>>(param: {
|
|||||||
*/
|
*/
|
||||||
base?: string | undefined;
|
base?: string | undefined;
|
||||||
variants: V /* VariantType */;
|
variants: V /* VariantType */;
|
||||||
|
dynamics?: DynamicClassName<V>[];
|
||||||
defaults: VariantKV<V>;
|
defaults: VariantKV<V>;
|
||||||
presets: P;
|
presets: P;
|
||||||
}): [
|
}): [
|
||||||
@ -139,6 +166,7 @@ export function vcn<V extends VariantType, P extends PresetType<V>>(param: {
|
|||||||
/**
|
/**
|
||||||
* Any Props -> Variant Props, Other Props
|
* 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>>(
|
<AnyPropBeforeResolve extends Record<string, any>>(
|
||||||
anyProps: AnyPropBeforeResolve,
|
anyProps: AnyPropBeforeResolve,
|
||||||
) => [
|
) => [
|
||||||
@ -158,19 +186,58 @@ export function vcn<
|
|||||||
>({
|
>({
|
||||||
base,
|
base,
|
||||||
variants,
|
variants,
|
||||||
|
dynamics = [],
|
||||||
defaults,
|
defaults,
|
||||||
presets,
|
presets,
|
||||||
}: {
|
}: {
|
||||||
base?: string | undefined;
|
base?: string | undefined;
|
||||||
variants: V;
|
variants: V;
|
||||||
|
dynamics?: DynamicClassName<V>[];
|
||||||
defaults: VariantKV<V>;
|
defaults: VariantKV<V>;
|
||||||
presets?: P;
|
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 [
|
return [
|
||||||
/**
|
/**
|
||||||
* Takes any props (including className), and returns the class name.
|
* 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.
|
* 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.
|
* @param variantProps - The variant props including className.
|
||||||
* @returns The class name.
|
* @returns The class name.
|
||||||
*/
|
*/
|
||||||
@ -179,42 +246,43 @@ export function vcn<
|
|||||||
VariantKV<V>
|
VariantKV<V>
|
||||||
>,
|
>,
|
||||||
) => {
|
) => {
|
||||||
const { className, preset, ...otherVariantProps } = variantProps;
|
const { className, preset, ..._otherVariantProps } = variantProps;
|
||||||
|
|
||||||
const currentPreset: P[keyof P] | null =
|
// Omit<Partial<VariantKV<V>> & { className; preset; }, className | preset> = Partial<VariantKV<V>> (safe to cast)
|
||||||
presets && preset ? (presets as NonNullable<P>)[preset] ?? null : null;
|
// We all know `keyof V` = string, right? (but typescript says it's not, so.. attacking typescript with unknown lol)
|
||||||
const presetVariantKeys: (keyof V)[] = Object.keys(currentPreset ?? {});
|
const otherVariantProps = _otherVariantProps as unknown as Partial<
|
||||||
return twMerge(
|
VariantKV<V>
|
||||||
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]>
|
|
||||||
|
|
||||||
const directVariantValue: (keyof V[keyof V] & string) | undefined = (
|
const kv: VariantKV<V> = { ...defaults };
|
||||||
otherVariantProps as unknown as Partial<VariantKV<V>>
|
|
||||||
)[variantKey]?.toString?.(); // BooleanString<> -> string (safe to index V[keyof V])
|
|
||||||
|
|
||||||
const currentPresetVariantValue:
|
// Preset Processing
|
||||||
| (keyof V[keyof V] & string)
|
if (presets && preset && preset in presets) {
|
||||||
| undefined =
|
for (const [variantName, variantKey] of Object.entries(
|
||||||
!!currentPreset && presetVariantKeys.includes(variantKey)
|
// typescript bug (casting to NonNullable<P> required)
|
||||||
? (currentPreset as Partial<VariantKV<V>>)[
|
(presets as NonNullable<P>)[preset],
|
||||||
variantKey
|
) as VariantKVEntry<V>) {
|
||||||
]?.toString?.()
|
kv[variantName] = variantKey;
|
||||||
: undefined;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const variantValue: keyof V[keyof V] & string =
|
// VariantProps Processing
|
||||||
directVariantValue ?? currentPresetVariantValue ?? defaultValue;
|
for (const [variantName, variantKey] of Object.entries(
|
||||||
return variants[variantKey][variantValue];
|
otherVariantProps,
|
||||||
}),
|
) as VariantKVEntry<V>) {
|
||||||
(
|
if (typeof variantKey === "undefined") continue;
|
||||||
currentPreset as Partial<VariantKV<V>> | null
|
kv[variantName] = variantKey;
|
||||||
)?.className?.toString?.(), // preset's classname comes after user's variant props? huh..
|
}
|
||||||
className,
|
|
||||||
);
|
// 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.
|
* Takes any props, parse variant props and other props.
|
||||||
* If `options.excludeA` is true, then it will parse `A` as "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.
|
* @param anyProps - Any props that have passed to the component.
|
||||||
* @returns [variantProps, otherProps]
|
* @returns [variantProps, otherProps]
|
||||||
*/
|
*/
|
||||||
<AnyPropBeforeResolve extends Record<string, any>>(
|
<AnyPropBeforeResolve extends Record<string, unknown>>(
|
||||||
anyProps: AnyPropBeforeResolve,
|
anyProps: AnyPropBeforeResolve,
|
||||||
) => {
|
) => {
|
||||||
const variantKeys = Object.keys(variants) as (keyof V)[];
|
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 (
|
export type VariantProps<F extends (props: Record<string, unknown>) => string> =
|
||||||
props: infer P,
|
F extends (props: infer P) => string ? { [key in keyof P]: P[key] } : never;
|
||||||
) => 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;
|
|
||||||
}
|
|
@ -5,43 +5,23 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdx-js/react": "^3.0.1",
|
"@tailwindcss/vite": "^4.0.12",
|
||||||
"@stefanprobst/rehype-extract-toc": "^2.2.0",
|
|
||||||
"highlight.js": "^11.9.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.23.1",
|
"tailwind-merge": "^2.3.0",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"tailwindcss": "^4.0.12"
|
||||||
"rehype-slug": "^6.0.0",
|
|
||||||
"remark-gfm": "^4.0.0",
|
|
||||||
"tailwind-merge": "^2.3.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mdx-js/rollup": "^3.0.1",
|
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
|
||||||
"@types/mdx": "^2.0.13",
|
|
||||||
"@types/node": "^20.12.13",
|
"@types/node": "^20.12.13",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@types/react-syntax-highlighter": "^15",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
|
||||||
"@typescript-eslint/parser": "^7.2.0",
|
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@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",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0"
|
||||||
"vite-plugin-dynamic-import": "^1.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
Before Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
Before Width: | Height: | Size: 280 B |
Binary file not shown.
Before Width: | Height: | Size: 564 B |
Binary file not shown.
Before Width: | Height: | Size: 15 KiB |
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"base": "https://raw.githubusercontent.com/p-sw/ui/main/packages/react/components/{componentName}",
|
|
||||||
"shared": "https://raw.githubusercontent.com/p-sw/ui/main/packages/react/shared.tsx",
|
|
||||||
"components": {
|
|
||||||
"button": "Button.tsx",
|
|
||||||
"checkbox": "Checkbox.tsx",
|
|
||||||
"dialog": "Dialog.tsx",
|
|
||||||
"drawer": "Drawer.tsx",
|
|
||||||
"input": "Input.tsx",
|
|
||||||
"label": "Label.tsx",
|
|
||||||
"popover": "Popover.tsx",
|
|
||||||
"switch": "Switch.tsx",
|
|
||||||
"tabs": "Tabs.tsx",
|
|
||||||
"toast": "Toast.tsx",
|
|
||||||
"tooltip": "Tooltip.tsx"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "PSW/UI",
|
|
||||||
"short_name": "PSWUI",
|
|
||||||
"description": "PSW/UI is a UI library with minimum dependency.",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
@ -1,187 +0,0 @@
|
|||||||
import {
|
|
||||||
Route,
|
|
||||||
createBrowserRouter,
|
|
||||||
createRoutesFromElements,
|
|
||||||
RouterProvider,
|
|
||||||
redirect,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import MainLayout from "./MainLayout";
|
|
||||||
import Home from "./Home";
|
|
||||||
import DocsLayout from "./DocsLayout";
|
|
||||||
import ErrorBoundary from "./ErrorHandler";
|
|
||||||
import DynamicLayout from "./DynamicLayout";
|
|
||||||
import { Code } from "./components/LoadedCode";
|
|
||||||
|
|
||||||
import DocsIntroduction, {
|
|
||||||
tableOfContents as docsIntroductionToc,
|
|
||||||
} from "./docs/introduction.mdx";
|
|
||||||
import DocsInstallation, {
|
|
||||||
tableOfContents as docsInstallationToc,
|
|
||||||
} from "./docs/installation.mdx";
|
|
||||||
|
|
||||||
import { HeadingContext } from "./HeadingContext";
|
|
||||||
import React, {
|
|
||||||
ForwardedRef,
|
|
||||||
forwardRef,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
function buildThresholdList() {
|
|
||||||
const thresholds: number[] = [];
|
|
||||||
const numSteps = 20;
|
|
||||||
|
|
||||||
for (let i = 1.0; i <= numSteps; i++) {
|
|
||||||
const ratio = i / numSteps;
|
|
||||||
thresholds.push(ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
thresholds.push(0);
|
|
||||||
return thresholds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HashedHeaders(Level: `h${1 | 2 | 3 | 4 | 5 | 6}`) {
|
|
||||||
return (prop: any, ref: ForwardedRef<HTMLHeadingElement>) => {
|
|
||||||
const internalRef = useRef<HTMLHeadingElement | null>(null);
|
|
||||||
const [_, setActiveHeadings] = useContext(HeadingContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([{ target, intersectionRatio }]) => {
|
|
||||||
if (intersectionRatio > 0.5) {
|
|
||||||
setActiveHeadings((prev) => [...prev, target.id]);
|
|
||||||
} else {
|
|
||||||
setActiveHeadings((prev) => prev.filter((id) => id !== target.id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: null,
|
|
||||||
rootMargin: "0px",
|
|
||||||
threshold: buildThresholdList(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (internalRef.current) {
|
|
||||||
observer.observe(internalRef.current);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [internalRef.current]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Level
|
|
||||||
{...prop}
|
|
||||||
className={`${prop.className}`}
|
|
||||||
ref={(el) => {
|
|
||||||
internalRef.current = el;
|
|
||||||
if (typeof ref === "function") {
|
|
||||||
ref(el);
|
|
||||||
} else if (el && ref) {
|
|
||||||
ref.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const overrideComponents = {
|
|
||||||
pre: forwardRef<HTMLDivElement, { children: React.ReactElement }>((props, ref) => {
|
|
||||||
const { props: { children, className } } = React.cloneElement(React.Children.only(props.children));
|
|
||||||
|
|
||||||
const language = (typeof className !== "string" || !className.includes("language-") ? "typescript" : /language-([a-z]+)/.exec(className)![1]) ?? "typescript"
|
|
||||||
|
|
||||||
return <Code ref={ref} language={language}>{children as string}</Code>;
|
|
||||||
}),
|
|
||||||
code: forwardRef<HTMLElement, any>((props: any, ref) => (
|
|
||||||
<code
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
className={`${props.className} rounded-md bg-neutral-800 text-orange-500 font-light p-1 before:content-none after:content-none`}
|
|
||||||
/>
|
|
||||||
)),
|
|
||||||
table: forwardRef<HTMLTableElement, any>((props: any, ref) => (
|
|
||||||
<div className="overflow-auto">
|
|
||||||
<table ref={ref} {...props} className={`${props.className}`} />
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
h1: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h1")),
|
|
||||||
h2: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h2")),
|
|
||||||
h3: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h3")),
|
|
||||||
h4: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h4")),
|
|
||||||
h5: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h5")),
|
|
||||||
h6: forwardRef<HTMLHeadingElement, any>(HashedHeaders("h6")),
|
|
||||||
};
|
|
||||||
|
|
||||||
const docsModules = import.meta.glob("./docs/components/*.mdx");
|
|
||||||
|
|
||||||
const routes = Object.keys(docsModules).map((path) => {
|
|
||||||
const sfPath = path.split("/").pop()?.replace(".mdx", "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Route
|
|
||||||
key={path}
|
|
||||||
path={sfPath}
|
|
||||||
lazy={async () => {
|
|
||||||
const { default: C, tableOfContents } = await import(
|
|
||||||
`./docs/components/${sfPath}.mdx`
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
Component: () => (
|
|
||||||
<DynamicLayout toc={tableOfContents}>
|
|
||||||
<C components={overrideComponents} />
|
|
||||||
</DynamicLayout>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = createBrowserRouter(
|
|
||||||
createRoutesFromElements(
|
|
||||||
<Route path="/" element={<MainLayout />} errorElement={<ErrorBoundary />}>
|
|
||||||
<Route index element={<Home />} />
|
|
||||||
<Route path="docs" element={<DocsLayout />}>
|
|
||||||
<Route index loader={() => redirect("/docs/introduction")} />
|
|
||||||
<Route
|
|
||||||
path="introduction"
|
|
||||||
element={
|
|
||||||
<DynamicLayout toc={docsIntroductionToc}>
|
|
||||||
<DocsIntroduction />
|
|
||||||
</DynamicLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="installation"
|
|
||||||
element={
|
|
||||||
<DynamicLayout toc={docsInstallationToc}>
|
|
||||||
<DocsInstallation />
|
|
||||||
</DynamicLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="components">
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
loader={() =>
|
|
||||||
redirect(
|
|
||||||
`/docs/components/${Object.keys(docsModules)[0]
|
|
||||||
.split("/")
|
|
||||||
.pop()
|
|
||||||
?.replace(".mdx", "")}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{routes}
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return <RouterProvider router={router} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,43 +0,0 @@
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
|
||||||
import { Outlet } from "react-router-dom";
|
|
||||||
import RouteObject from "./RouteObject";
|
|
||||||
|
|
||||||
function SideNav() {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="sticky top-16 overflow-auto max-h-[calc(100vh-4rem)] md:flex flex-col justify-start items-start gap-8 p-8 hidden">
|
|
||||||
{Object.entries(RouteObject.sideNav).map(([categoryName, links]) => {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="flex flex-col gap-2 justify-center items-start"
|
|
||||||
key={categoryName}
|
|
||||||
>
|
|
||||||
<span className="font-bold">{categoryName}</span>
|
|
||||||
{links.map((link) => (
|
|
||||||
<Link
|
|
||||||
to={link.path}
|
|
||||||
key={link.path}
|
|
||||||
className="text-sm text-neutral-500 hover:text-neutral-700 data-[active=true]:text-current"
|
|
||||||
data-active={link.eq(location.pathname)}
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocsLayout() {
|
|
||||||
return (
|
|
||||||
<div className="flex-grow grid grid-cols-1 md:grid-cols-[12rem_1fr] lg:grid-cols-[12rem_1fr_10rem] w-full max-w-5xl mx-auto">
|
|
||||||
<SideNav />
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DocsLayout;
|
|
@ -1,63 +0,0 @@
|
|||||||
import { ReactNode, Fragment, useState, useContext } from "react";
|
|
||||||
import { type Toc } from "@stefanprobst/rehype-extract-toc";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { HeadingContext } from "./HeadingContext";
|
|
||||||
|
|
||||||
function RecursivelyToc({ toc }: { toc: Toc }) {
|
|
||||||
const location = useLocation();
|
|
||||||
const [activeHeadings] = useContext(HeadingContext);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul>
|
|
||||||
{toc.map((tocEntry) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={tocEntry.id}>
|
|
||||||
<li
|
|
||||||
key={tocEntry.id}
|
|
||||||
data-id={tocEntry.id}
|
|
||||||
className="text-neutral-500 data-[active='true']:text-black dark:data-[active='true']:text-white text-sm font-medium"
|
|
||||||
style={{ paddingLeft: `${tocEntry.depth - 1}rem` }}
|
|
||||||
data-active={
|
|
||||||
activeHeadings.includes(tocEntry.id ?? "")
|
|
||||||
? true
|
|
||||||
: location.hash.length > 0
|
|
||||||
? location.hash === `#${tocEntry.id}`
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<a href={`#${tocEntry.id}`}>{tocEntry.value}</a>
|
|
||||||
</li>
|
|
||||||
{Array.isArray(tocEntry.children) && (
|
|
||||||
<RecursivelyToc toc={tocEntry.children} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DynamicLayout({
|
|
||||||
children,
|
|
||||||
toc,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
toc: Toc;
|
|
||||||
}) {
|
|
||||||
const [activeHeadings, setActiveHeadings] = useState<string[]>([]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HeadingContext.Provider value={[activeHeadings, setActiveHeadings]}>
|
|
||||||
<div className="w-full flex flex-col items-center">
|
|
||||||
<main className="w-full [:not(:where([class~='not-prose'],[class~='not-prose']_*))]:prose-sm prose lg:[:not(:where([class~='not-prose'],_[class~='not-prose']_*))]:prose-lg p-8 dark:prose-invert">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<nav className="hidden lg:flex flex-col gap-2 py-8 px-4 sticky top-16 overflow-auto max-h-[calc(100vh-4rem)]">
|
|
||||||
<span className="font-bold text-sm">On This Page</span>
|
|
||||||
|
|
||||||
<RecursivelyToc toc={toc} />
|
|
||||||
</nav>
|
|
||||||
</HeadingContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import { isRouteErrorResponse, useRouteError } from "react-router-dom";
|
|
||||||
import UnexpectedError from "./errors/Unexpected";
|
|
||||||
import PageNotFound from "./errors/PageNotFound";
|
|
||||||
|
|
||||||
function ErrorBoundary() {
|
|
||||||
const error = useRouteError();
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
|
||||||
if (error.status === 404) {
|
|
||||||
return <PageNotFound />;
|
|
||||||
} else {
|
|
||||||
return <UnexpectedError />;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return <UnexpectedError />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
@ -1,12 +0,0 @@
|
|||||||
import { Dispatch, SetStateAction, createContext } from "react";
|
|
||||||
|
|
||||||
export const HeadingContext = createContext<
|
|
||||||
[string[], Dispatch<SetStateAction<string[]>>]
|
|
||||||
>([
|
|
||||||
[],
|
|
||||||
() => {
|
|
||||||
if (process.env && process.env.NODE_ENV === "development") {
|
|
||||||
console.log("HeadingContext outside");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
@ -1,30 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Button } from "../components/Button";
|
|
||||||
|
|
||||||
function Home() {
|
|
||||||
return (
|
|
||||||
<main className="flex-grow h-full flex flex-col p-4 justify-center items-center">
|
|
||||||
<section className="h-full flex flex-col justify-center items-center text-center gap-8">
|
|
||||||
<header className="flex flex-col justify-center items-center gap-2">
|
|
||||||
<h1 className="text-4xl font-bold">
|
|
||||||
Build your components in isolation
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl max-w-xl">
|
|
||||||
There are a lot of component libraries out there, but why it install
|
|
||||||
so many things?
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div className="flex flex-row justify-center items-center gap-2">
|
|
||||||
<Button asChild preset="default">
|
|
||||||
<Link to="/docs">Get Started</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild preset="ghost">
|
|
||||||
<Link to="/docs/components">Components</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home;
|
|
@ -1,215 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
|
||||||
import { Button } from "../components/Button";
|
|
||||||
import RouteObject from "./RouteObject";
|
|
||||||
import { Toaster } from "@components/Toast";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
|
||||||
import {
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerRoot,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from "@components/Drawer";
|
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
|
||||||
|
|
||||||
function ThemeButton() {
|
|
||||||
const [theme, setTheme] = useState<Theme>(
|
|
||||||
(localStorage.getItem("theme") as Theme) || "system"
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
|
||||||
document.documentElement.classList.toggle("system", theme === "system");
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger>
|
|
||||||
<Button preset="ghost" size="icon">
|
|
||||||
{/* material-symbols:light-mode */}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.2em"
|
|
||||||
height="1.2em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="dark:hidden"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 17q-2.075 0-3.537-1.463T7 12t1.463-3.537T12 7t3.538 1.463T17 12t-1.463 3.538T12 17m-7-4H1v-2h4zm18 0h-4v-2h4zM11 5V1h2v4zm0 18v-4h2v4zM6.4 7.75L3.875 5.325L5.3 3.85l2.4 2.5zm12.3 12.4l-2.425-2.525L17.6 16.25l2.525 2.425zM16.25 6.4l2.425-2.525L20.15 5.3l-2.5 2.4zM3.85 18.7l2.525-2.425L7.75 17.6l-2.425 2.525z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/* material-symbols:dark-mode */}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.2em"
|
|
||||||
height="1.2em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="hidden dark:block"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 21q-3.75 0-6.375-2.625T3 12t2.625-6.375T12 3q.35 0 .688.025t.662.075q-1.025.725-1.638 1.888T11.1 7.5q0 2.25 1.575 3.825T16.5 12.9q1.375 0 2.525-.613T20.9 10.65q.05.325.075.662T21 12q0 3.75-2.625 6.375T12 21"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent anchor="bottomLeft" className="w-32">
|
|
||||||
<Button
|
|
||||||
preset="ghost"
|
|
||||||
onClick={() => setTheme("light")}
|
|
||||||
className="w-full px-2 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
Light
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
preset="ghost"
|
|
||||||
onClick={() => setTheme("dark")}
|
|
||||||
className="w-full px-2 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
Dark
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
preset="ghost"
|
|
||||||
onClick={() => setTheme("system")}
|
|
||||||
className="w-full px-2 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
System
|
|
||||||
</Button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopNav() {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<nav className="sticky top-0 z-20 bg-transparent backdrop-blur-lg border-b border-neutral-200 dark:border-neutral-800 w-full max-w-screen px-8 flex flex-row justify-center items-center h-16">
|
|
||||||
<div
|
|
||||||
data-role="wrapper"
|
|
||||||
className="flex flex-row items-center justify-between w-full max-w-6xl text-lg"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-role="links"
|
|
||||||
className="hidden md:flex flex-row items-center gap-3"
|
|
||||||
>
|
|
||||||
<Link to="/" className="font-bold">
|
|
||||||
PSW/UI
|
|
||||||
</Link>
|
|
||||||
{RouteObject.mainNav.map((link) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={link.path}
|
|
||||||
to={link.path}
|
|
||||||
data-active={link.eq(location.pathname)}
|
|
||||||
className="font-light text-base data-[active=true]:text-current text-neutral-500 hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div data-role="mobile-links" className="flex md:hidden">
|
|
||||||
<DrawerRoot>
|
|
||||||
<DrawerTrigger>
|
|
||||||
<Button preset="ghost" size="icon">
|
|
||||||
{/* mdi:menu */}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.2em"
|
|
||||||
height="1.2em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerOverlay className="z-[99]">
|
|
||||||
<DrawerContent className="w-[300px] overflow-auto">
|
|
||||||
<DrawerClose className="absolute top-4 right-4">
|
|
||||||
<Button preset="ghost" size="icon">
|
|
||||||
{/* mdi:close */}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.2em"
|
|
||||||
height="1.2em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M19 6.41L17.59 5 12 9.27 6.41 5 5 6.41 9.27 11 5 17.59 6.41 19 12 14.73 17.59 19 19 17.59 13.41 12 19 6.41"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
<div className="flex flex-col justify-start items-start gap-6 text-lg">
|
|
||||||
<div className="flex flex-col justify-start items-start gap-3">
|
|
||||||
<DrawerClose>
|
|
||||||
<Link to="/" className="font-extrabold">
|
|
||||||
PSW/UI
|
|
||||||
</Link>
|
|
||||||
</DrawerClose>
|
|
||||||
{RouteObject.mainNav.map((link) => {
|
|
||||||
return (
|
|
||||||
<DrawerClose key={link.path}>
|
|
||||||
<Link to={link.path}>{link.name}</Link>
|
|
||||||
</DrawerClose>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{Object.entries(RouteObject.sideNav).map(
|
|
||||||
([categoryName, links]) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex flex-col justify-start items-start gap-3"
|
|
||||||
key={categoryName}
|
|
||||||
>
|
|
||||||
<h2 className="font-bold">{categoryName}</h2>
|
|
||||||
{links.map((link) => {
|
|
||||||
return (
|
|
||||||
<DrawerClose key={link.path}>
|
|
||||||
<Link
|
|
||||||
to={link.path}
|
|
||||||
className="text-base opacity-75"
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
</Link>
|
|
||||||
</DrawerClose>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</DrawerOverlay>
|
|
||||||
</DrawerRoot>
|
|
||||||
</div>
|
|
||||||
<div data-role="controls" className="flex flex-row items-center">
|
|
||||||
<ThemeButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MainLayout() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toaster className="top-16" />
|
|
||||||
<TopNav />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MainLayout;
|
|
@ -1,49 +0,0 @@
|
|||||||
const docsModules = import.meta.glob('./docs/components/*.mdx');
|
|
||||||
|
|
||||||
const mainNav = [
|
|
||||||
{
|
|
||||||
path: "/docs",
|
|
||||||
name: "Docs",
|
|
||||||
eq: (pathname: string) => pathname.startsWith("/docs") && !pathname.startsWith("/docs/components")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/docs/components",
|
|
||||||
name: "Components",
|
|
||||||
eq: (pathname: string) => pathname.startsWith("/docs/components")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "https://github.com/p-sw/ui",
|
|
||||||
name: "Github",
|
|
||||||
eq: () => false
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const sideNav: Record<string, ({ path: string; name: string; eq: (path: string) => boolean })[]> = {
|
|
||||||
"Documents": [
|
|
||||||
{
|
|
||||||
path: "/docs/introduction",
|
|
||||||
name: "Introduction",
|
|
||||||
eq: (pathname: string) => pathname === "/docs/introduction"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/docs/installation",
|
|
||||||
name: "Installation",
|
|
||||||
eq: (pathname: string) => pathname === "/docs/installation"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Components": []
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.keys(docsModules).forEach((path) => {
|
|
||||||
const name = (path.split('/').pop() ?? '').replace('.mdx', '');
|
|
||||||
sideNav["Components"].push({
|
|
||||||
path: path.replace('./docs', '/docs').replace('.mdx', ''),
|
|
||||||
name: name.charAt(0).toUpperCase() + name.slice(1),
|
|
||||||
eq: (pathname: string) => pathname === path.replace('./docs', '/docs').replace('.mdx', '')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mainNav,
|
|
||||||
sideNav
|
|
||||||
};
|
|
@ -1,119 +0,0 @@
|
|||||||
import {forwardRef, useEffect, useState} from "react";
|
|
||||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
|
||||||
import { gruvboxDark } from "react-syntax-highlighter/dist/cjs/styles/hljs";
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { useToast } from "@components/Toast";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export const GITHUB = "https://raw.githubusercontent.com/p-sw/ui/main";
|
|
||||||
|
|
||||||
export const LoadedCode = ({
|
|
||||||
from,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
from: string;
|
|
||||||
className?: string;
|
|
||||||
}) => {
|
|
||||||
const [state, setState] = useState<string | undefined | null>();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const res = await fetch(from);
|
|
||||||
const text = await res.text();
|
|
||||||
setState(text);
|
|
||||||
})();
|
|
||||||
}, [from]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={twMerge("relative", className)}>
|
|
||||||
<Button
|
|
||||||
preset="default"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-4 right-4 text-black dark:text-white z-10"
|
|
||||||
onClick={() => {
|
|
||||||
if (state && state.length > 0) {
|
|
||||||
void navigator.clipboard.writeText(state ?? "");
|
|
||||||
toast({
|
|
||||||
title: "Copied",
|
|
||||||
description: "The code has been copied to your clipboard.",
|
|
||||||
status: "success",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "It seems like code is not loaded yet.",
|
|
||||||
status: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.2em"
|
|
||||||
height="1.2em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 7v14h14v2H4c-1.1 0-2-.9-2-2V7zm16-4c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H8c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h3.18C11.6 1.84 12.7 1 14 1s2.4.84 2.82 2zm-6 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m-4 4V5H8v12h12V5h-2v2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<SyntaxHighlighter
|
|
||||||
language="typescript"
|
|
||||||
style={gruvboxDark}
|
|
||||||
className={`w-full h-64 rounded-lg ${!state ? "animate-pulse" : ""}`}
|
|
||||||
customStyle={{ padding: "1rem" }}
|
|
||||||
>
|
|
||||||
{state ?? ""}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Code = forwardRef<HTMLDivElement, { children: string; className?: string; language: string }>(({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
language,
|
|
||||||
}, ref) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={twMerge("relative", className)} ref={ref}>
|
|
||||||
<Button
|
|
||||||
preset="default"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-4 right-4 text-black dark:text-white z-10"
|
|
||||||
onClick={() => {
|
|
||||||
void navigator.clipboard.writeText(children ?? "");
|
|
||||||
toast({
|
|
||||||
title: "Copied",
|
|
||||||
description: "The code has been copied to your clipboard.",
|
|
||||||
status: "success",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="1.2em"
|
|
||||||
height="1.2em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 7v14h14v2H4c-1.1 0-2-.9-2-2V7zm16-4c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H8c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h3.18C11.6 1.84 12.7 1 14 1s2.4.84 2.82 2zm-6 0c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m-4 4V5H8v12h12V5h-2v2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
<SyntaxHighlighter
|
|
||||||
language={language}
|
|
||||||
style={gruvboxDark}
|
|
||||||
className={`w-full h-auto max-h-64 rounded-lg`}
|
|
||||||
customStyle={{ padding: "1rem" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,32 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
const layoutClasses = {
|
|
||||||
default: "",
|
|
||||||
centered: "flex items-center justify-center",
|
|
||||||
};
|
|
||||||
|
|
||||||
const Story = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{
|
|
||||||
layout?: keyof typeof layoutClasses;
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
>(({ layout = "default", children, className, id }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
`bg-white dark:bg-black border border-neutral-300 dark:border-neutral-700 rounded-lg w-full p-4 min-h-48 h-auto my-8 not-prose ${layoutClasses[layout]}`,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
id={id}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export { Story };
|
|
@ -1,162 +0,0 @@
|
|||||||
import { TabProvider, TabTrigger, TabContent, TabList } from "@components/Tabs";
|
|
||||||
import { Story } from "@/components/Story";
|
|
||||||
import { LoadedCode, GITHUB } from "@/components/LoadedCode";
|
|
||||||
import { ButtonDemo } from "./ButtonBlocks/Preview";
|
|
||||||
import Examples from "./ButtonBlocks/Examples";
|
|
||||||
|
|
||||||
# Button
|
|
||||||
Displays a button or a component that looks like a button.
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<ButtonDemo />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Preview.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Create a new file `Button.tsx` in your component folder.
|
|
||||||
2. Copy and paste the following code into the file.
|
|
||||||
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/components/Button.tsx`} />
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
```
|
|
||||||
|
|
||||||
```html
|
|
||||||
<Button>Button</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:-------------|:------------------------------------------------------------------------------|:------------|:----------------------------------------|
|
|
||||||
| `size` | `"link" \| "sm" \| "md" \| "lg" \| "icon"` | `"md"` | The size of the button |
|
|
||||||
| `border` | `"none" \| "solid" \| "success" \| "warning" \| "danger"` | `"solid"` | The border color of the button |
|
|
||||||
| `background` | `"default" \| "ghost" \| "success" \| "warning" \| "danger" \| "transparent"` | `"default"` | The background color of the button |
|
|
||||||
| `decoration` | `"none" \| "link"` | `"none"` | The inner text decoration of the button |
|
|
||||||
| `presets` | `"default" \| "ghost" \| "link" \| "success" \| "warning" \| "danger"` | `"default"` | The preset of the variant props |
|
|
||||||
|
|
||||||
### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:----------|:--------|:---------------------------------------------------------|
|
|
||||||
| `asChild` | `boolean` | `false` | Whether the button is rendered as a child of a component |
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Default
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Default />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Examples/Default.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Ghost
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Ghost />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Examples/Ghost.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Link
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Link />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Examples/Link.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Success
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Success />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Examples/Success.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Warning
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Warning />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Examples/Warning.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Danger
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Danger />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/ButtonBlocks/Examples/Danger.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Danger = () => {
|
|
||||||
return <Button preset="danger">Danger</Button>;
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Default = () => {
|
|
||||||
return <Button preset="default">Default</Button>;
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Ghost = () => {
|
|
||||||
return <Button preset="ghost">Ghost</Button>;
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Link = () => {
|
|
||||||
return <Button preset="link">Link</Button>;
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Success = () => {
|
|
||||||
return <Button preset="success">Success</Button>;
|
|
||||||
};
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Warning = () => {
|
|
||||||
return <Button preset="warning">Warning</Button>;
|
|
||||||
};
|
|
@ -1,16 +0,0 @@
|
|||||||
import { Danger } from "./Danger";
|
|
||||||
import { Warning } from "./Warning";
|
|
||||||
import { Success } from "./Success";
|
|
||||||
import { Link } from "./Link";
|
|
||||||
import { Ghost } from "./Ghost";
|
|
||||||
import { Default } from "./Default";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Danger,
|
|
||||||
Warning,
|
|
||||||
Success,
|
|
||||||
Link,
|
|
||||||
Ghost,
|
|
||||||
Default,
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export function ButtonDemo() {
|
|
||||||
return <Button>Button</Button>;
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
import { TabProvider, TabTrigger, TabContent, TabList } from "@components/Tabs";
|
|
||||||
import { Story } from "@/components/Story";
|
|
||||||
import { LoadedCode, GITHUB } from "@/components/LoadedCode";
|
|
||||||
import { CheckboxDemo } from "./CheckboxBlocks/Preview";
|
|
||||||
import Examples from "./CheckboxBlocks/Examples";
|
|
||||||
|
|
||||||
# Checkbox
|
|
||||||
A control that allows the user to toggle between checked and not checked.
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<CheckboxDemo />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/CheckboxBlocks/Preview.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Create a new file `Checkbox.mdx` in your component folder.
|
|
||||||
2. Copy and paste the following code into the file.
|
|
||||||
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/components/Checkbox.tsx`} />
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Checkbox } from "@components/Checkbox";
|
|
||||||
```
|
|
||||||
|
|
||||||
```html
|
|
||||||
<Checkbox />
|
|
||||||
```
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:-------|:-------------------------|:--------|:-------------------------|
|
|
||||||
| `size` | `"base" \| "md" \| "lg"` | `"md"` | The size of the checkbox |
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Text
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Text />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/CheckboxBlocks/Examples/Text.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Disabled
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Disabled />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/CheckboxBlocks/Examples/Disabled.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
@ -1,11 +0,0 @@
|
|||||||
import { Label } from "@components/Label";
|
|
||||||
import { Checkbox } from "@components/Checkbox";
|
|
||||||
|
|
||||||
export function Disabled() {
|
|
||||||
return (
|
|
||||||
<Label direction="horizontal">
|
|
||||||
<Checkbox size="base" disabled />
|
|
||||||
<span>Agree terms and conditions</span>
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import { Label } from "@components/Label";
|
|
||||||
import { Checkbox } from "@components/Checkbox";
|
|
||||||
|
|
||||||
export function Text() {
|
|
||||||
return (
|
|
||||||
<Label direction="horizontal">
|
|
||||||
<Checkbox size="base" />
|
|
||||||
<span>Agree terms and conditions</span>
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Text } from "./Text";
|
|
||||||
import { Disabled } from "./Disabled";
|
|
||||||
|
|
||||||
export default { Text, Disabled };
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
|||||||
import { Checkbox } from "@components/Checkbox";
|
|
||||||
import { Label } from "@components/Label";
|
|
||||||
|
|
||||||
export function CheckboxDemo() {
|
|
||||||
return (
|
|
||||||
<Label direction="horizontal">
|
|
||||||
<Checkbox />
|
|
||||||
<span>Checkbox</span>
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,177 +0,0 @@
|
|||||||
import { TabProvider, TabTrigger, TabContent, TabList } from "@components/Tabs";
|
|
||||||
import { Story } from "@/components/Story";
|
|
||||||
import { LoadedCode, GITHUB } from "@/components/LoadedCode";
|
|
||||||
import { DialogDemo } from "./DialogBlocks/Preview";
|
|
||||||
import Examples from "./DialogBlocks/Examples";
|
|
||||||
|
|
||||||
# Dialog
|
|
||||||
A modal window that prompts the user to take an action or provides critical information.
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<DialogDemo />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DialogBlocks/Preview.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Create a new file `Dialog.tsx` in your component folder.
|
|
||||||
2. Copy and paste the following code into the file.
|
|
||||||
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/components/Dialog.tsx`} />
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {
|
|
||||||
DialogRoot,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogSubtitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
} from "@components/Dialog";
|
|
||||||
```
|
|
||||||
|
|
||||||
```html
|
|
||||||
<DialogRoot>
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button>Open Dialog</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogOverlay>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Dialog Title</DialogTitle>
|
|
||||||
<DialogSubtitle>Dialog Subtitle</DialogSubtitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{/* Main Contents */}
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose>
|
|
||||||
<Button>Close</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogOverlay>
|
|
||||||
</DialogRoot>
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note:
|
|
||||||
>
|
|
||||||
> DialogTrigger and DialogClose will merge its onClick event handler to its children.
|
|
||||||
> Also, there is no default element for those.
|
|
||||||
> So you always have to provide the clickable children for DialogTrigger and DialogClose.
|
|
||||||
>
|
|
||||||
> It is easier to understand if you think of this component as always having the `asChild` prop applied to it.
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### DialogOverlay
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:-----------------------|:--------|:---------------------------------------------|
|
|
||||||
| `blur` | `"sm" \| "md" \| "lg"` | `md` | Whether the background of dialog is blurred |
|
|
||||||
| `darken` | `"sm" \| "md" \| "lg"` | `md` | Whether the background of dialog is darkened |
|
|
||||||
| `padding` | `"sm" \| "md" \| "lg"` | `md` | Minimum margin of the dialog |
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:---------------|:----------|:--------|:-----------------------------------------------|
|
|
||||||
| `closeOnClick` | `boolean` | `false` | Whether the dialog will be closed when clicked |
|
|
||||||
|
|
||||||
### DialogContent
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:---------------------------------------------------------------------|:--------|:-----------------------------------------------|
|
|
||||||
| `size` | `"fit" \| "fullSm" \| "fullMd" \| "fullLg" \| "fullXl" \| "full2xl"` | `fit` | Size of the dialog - width and max width |
|
|
||||||
| `rounded` | `"sm" \| "md" \| "lg" \| "xl"` | `md` | Roundness of the dialog |
|
|
||||||
| `padding` | `"sm" \| "md" \| "lg"` | `md` | Padding of the dialog |
|
|
||||||
| `gap` | `"sm" \| "md" \| "lg"` | `md` | Works like flex's gap - space between children |
|
|
||||||
|
|
||||||
### DialogHeader
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:------|:-----------------------|:--------|:----------------------------------------------|
|
|
||||||
| `gap` | `"sm" \| "md" \| "lg"` | `sm` | Gap between the children - title and subtitle |
|
|
||||||
|
|
||||||
### DialogTitle
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:---------|:-----------------------|:--------|:--------------------|
|
|
||||||
| `size` | `"sm" \| "md" \| "lg"` | `md` | Size of the title |
|
|
||||||
| `weight` | `"sm" \| "md" \| "lg"` | `lg` | Weight of the title |
|
|
||||||
|
|
||||||
### DialogSubtitle
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:-----------------------|:--------|:------------------------|
|
|
||||||
| `size` | `"sm" \| "md" \| "lg"` | `sm` | Size of the subtitle |
|
|
||||||
| `weight` | `"sm" \| "md" \| "lg"` | `md` | Weight of the subtitle |
|
|
||||||
| `opacity` | `"sm" \| "md" \| "lg"` | `sm` | Opacity of the subtitle |
|
|
||||||
|
|
||||||
### DialogFooter
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:------|:-----------------------|:--------|:-------------------------|
|
|
||||||
| `gap` | `"sm" \| "md" \| "lg"` | `sm` | Gap between the children |
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Basic Informational Dialog
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.BasicInformationalDialog />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DialogBlocks/Examples/BasicInformationalDialog.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Deleting Item
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.DeletingItem />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DialogBlocks/Examples/DeletingItem.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
|||||||
import { Button } from "@components/Button";
|
|
||||||
import {
|
|
||||||
DialogRoot,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogSubtitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
} from "@components/Dialog";
|
|
||||||
|
|
||||||
export function BasicInformationalDialog() {
|
|
||||||
return (
|
|
||||||
<DialogRoot>
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button preset="default">What is this?</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogOverlay>
|
|
||||||
<DialogContent size={"fullMd"}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Dialog Title</DialogTitle>
|
|
||||||
<DialogSubtitle>Dialog Subtitle</DialogSubtitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<p>This is a dialog. You can put the information you want to show.</p>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose>
|
|
||||||
<Button preset="default">Ok!</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogOverlay>
|
|
||||||
</DialogRoot>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
DialogRoot,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogSubtitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
} from "@components/Dialog";
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import { useToast } from "@components/Toast";
|
|
||||||
|
|
||||||
export function DeletingItem() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogRoot>
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button preset="danger">Delete Item</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogOverlay>
|
|
||||||
<DialogContent size={"fullMd"}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Item</DialogTitle>
|
|
||||||
<DialogSubtitle>
|
|
||||||
Are you sure you want to delete this item?
|
|
||||||
</DialogSubtitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<ul className="list-disc list-inside">
|
|
||||||
<li>This action will delete the item, and related data</li>
|
|
||||||
<li>This action cannot be undone</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose>
|
|
||||||
<Button
|
|
||||||
preset="danger"
|
|
||||||
onClick={async () => {
|
|
||||||
const toasted = toast({
|
|
||||||
title: "Deleting Item",
|
|
||||||
description: "Item deletion is requested",
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
|
|
||||||
toasted.update({
|
|
||||||
title: "Item Deleted",
|
|
||||||
description: "The item has been deleted",
|
|
||||||
status: "success",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<DialogClose>
|
|
||||||
<Button
|
|
||||||
preset="default"
|
|
||||||
onClick={() => {
|
|
||||||
toast({
|
|
||||||
title: "Action Canceled",
|
|
||||||
description: "The delete action has been canceled",
|
|
||||||
status: "error",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogOverlay>
|
|
||||||
</DialogRoot>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
import { BasicInformationalDialog } from "./BasicInformationalDialog";
|
|
||||||
import { DeletingItem } from "./DeletingItem";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
BasicInformationalDialog,
|
|
||||||
DeletingItem,
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
|||||||
import {
|
|
||||||
DialogRoot,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogSubtitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogClose,
|
|
||||||
} from "@components/Dialog";
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export function DialogDemo() {
|
|
||||||
return (
|
|
||||||
<DialogRoot>
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button preset="default">Open Dialog</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogOverlay>
|
|
||||||
<DialogContent size={"fullMd"}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Dialog Title</DialogTitle>
|
|
||||||
<DialogSubtitle>Dialog Subtitle</DialogSubtitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<p>
|
|
||||||
Laborum non adipisicing enim enim culpa esse anim esse consequat
|
|
||||||
Lorem incididunt. Enim mollit laborum sunt cillum voluptate est
|
|
||||||
officia nostrud non consequat adipisicing cupidatat aliquip magna.
|
|
||||||
Voluptate nisi cupidatat qui nisi in pariatur. Sint consequat labore
|
|
||||||
pariatur mollit sint nostrud tempor commodo pariatur ea laboris.
|
|
||||||
</p>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose>
|
|
||||||
<Button preset="default">Close</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogOverlay>
|
|
||||||
</DialogRoot>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,203 +0,0 @@
|
|||||||
import { TabProvider, TabTrigger, TabContent, TabList } from "@components/Tabs";
|
|
||||||
import { Story } from "@/components/Story";
|
|
||||||
import { LoadedCode, GITHUB } from '@/components/LoadedCode';
|
|
||||||
import { DrawerDemo } from "./DrawerBlocks/Preview";
|
|
||||||
import Examples from "./DrawerBlocks/Examples";
|
|
||||||
|
|
||||||
# Drawer
|
|
||||||
Displays a panel that slides out from the edge of the screen, typically used for navigation or additional content.
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<DrawerDemo />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DrawerBlocks/Preview.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Create a new file `Drawer.tsx` in your component folder.
|
|
||||||
2. Copy and paste the following code into the file.
|
|
||||||
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/components/Drawer.tsx`} />
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import {
|
|
||||||
DrawerRoot,
|
|
||||||
DrawerTrigger,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerBody,
|
|
||||||
DrawerFooter,
|
|
||||||
} from "@components/Drawer";
|
|
||||||
```
|
|
||||||
|
|
||||||
```html
|
|
||||||
<DrawerRoot>
|
|
||||||
<DrawerTrigger>
|
|
||||||
<Button>Open Drawer</Button>
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerOverlay>
|
|
||||||
<DrawerContent>
|
|
||||||
<DrawerHeader>
|
|
||||||
<h1 className="text-2xl font-bold">Drawer</h1>
|
|
||||||
</DrawerHeader>
|
|
||||||
<DrawerBody>
|
|
||||||
{/* Main Contents */}
|
|
||||||
</DrawerBody>
|
|
||||||
<DrawerFooter>
|
|
||||||
<DrawerClose>
|
|
||||||
<Button>Close Drawer</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
|
||||||
</DrawerOverlay>
|
|
||||||
</DrawerRoot>
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note:
|
|
||||||
>
|
|
||||||
> DrawerTrigger and DrawerClose will merge its onClick event handler to its children.
|
|
||||||
> Also, there is no default element for those.
|
|
||||||
> So you always have to provide the clickable children for DialogTrigger and DialogClose.
|
|
||||||
>
|
|
||||||
> It is easier to understand if you think of this component as always having the `asChild` prop applied to it.
|
|
||||||
|
|
||||||
## Props
|
|
||||||
|
|
||||||
### DrawerRoot
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:-----------------|:----------|:---------------|:----------------------------------------------------------|
|
|
||||||
| `closeThreshold` | `number` | `0.3` | The threshold for the drawer to close with swipe or drag. |
|
|
||||||
| `opened` | `boolean` | - (Controlled) | Whether the drawer is opened. |
|
|
||||||
|
|
||||||
### DrawerOverlay
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:----------|:--------|:------------------------------------------------------------|
|
|
||||||
| `asChild` | `boolean` | `false` | Whether the component is rendered as a child of a component |
|
|
||||||
|
|
||||||
### DrawerContent
|
|
||||||
|
|
||||||
#### Variants
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:-----------|:-----------------------------------------|:---------|:---------------------------|
|
|
||||||
| `position` | `"top" \| "bottom" \| "left" \| "right"` | `"left"` | The position of the drawer |
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:----------|:--------|:------------------------------------------------------------|
|
|
||||||
| `asChild` | `boolean` | `false` | Whether the component is rendered as a child of a component |
|
|
||||||
|
|
||||||
### DrawerHeader
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:----------|:--------|:------------------------------------------------------------|
|
|
||||||
| `asChild` | `boolean` | `false` | Whether the component is rendered as a child of a component |
|
|
||||||
|
|
||||||
### DrawerBody
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:----------|:--------|:------------------------------------------------------------|
|
|
||||||
| `asChild` | `boolean` | `false` | Whether the component is rendered as a child of a component |
|
|
||||||
|
|
||||||
### DrawerFooter
|
|
||||||
|
|
||||||
#### Special
|
|
||||||
|
|
||||||
| Prop | Type | Default | Description |
|
|
||||||
|:----------|:----------|:--------|:------------------------------------------------------------|
|
|
||||||
| `asChild` | `boolean` | `false` | Whether the component is rendered as a child of a component |
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Left
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Left />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DrawerBlocks/Examples/Left.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Right
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Right />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DrawerBlocks/Examples/Right.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Top
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Top />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DrawerBlocks/Examples/Top.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
||||||
|
|
||||||
### Bottom
|
|
||||||
|
|
||||||
<TabProvider defaultName="preview">
|
|
||||||
<TabList>
|
|
||||||
<TabTrigger name="preview">Preview</TabTrigger>
|
|
||||||
<TabTrigger name="code">Code</TabTrigger>
|
|
||||||
</TabList>
|
|
||||||
<TabContent name="preview">
|
|
||||||
<Story layout="centered">
|
|
||||||
<Examples.Bottom />
|
|
||||||
</Story>
|
|
||||||
</TabContent>
|
|
||||||
<TabContent name="code">
|
|
||||||
<LoadedCode from={`${GITHUB}/packages/react/src/docs/components/DrawerBlocks/Examples/Bottom.tsx`} />
|
|
||||||
</TabContent>
|
|
||||||
</TabProvider>
|
|
@ -1,40 +0,0 @@
|
|||||||
import {
|
|
||||||
DrawerRoot,
|
|
||||||
DrawerTrigger,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerBody,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerClose,
|
|
||||||
} from "@components/Drawer";
|
|
||||||
import { Button } from "@components/Button";
|
|
||||||
|
|
||||||
export const Bottom = () => {
|
|
||||||
return (
|
|
||||||
<DrawerRoot>
|
|
||||||
<DrawerTrigger>
|
|
||||||
<Button>Open Drawer</Button>
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerOverlay className="z-[99]">
|
|
||||||
<DrawerContent position="bottom">
|
|
||||||
<DrawerHeader>
|
|
||||||
<h1 className="text-2xl font-bold">Drawer</h1>
|
|
||||||
</DrawerHeader>
|
|
||||||
<DrawerBody>
|
|
||||||
<p>
|
|
||||||
Drawers are a type of overlay that slides in from the edge of the
|
|
||||||
screen. They are typically used for navigation or additional
|
|
||||||
content.
|
|
||||||
</p>
|
|
||||||
</DrawerBody>
|
|
||||||
<DrawerFooter>
|
|
||||||
<DrawerClose>
|
|
||||||
<Button>Done</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
|
||||||
</DrawerOverlay>
|
|
||||||
</DrawerRoot>
|
|
||||||
);
|
|
||||||
};
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user