Compare commits

..

101 Commits

Author SHA1 Message Date
f122310c37 fix: fix slot type error
Some checks failed
lint-and-check / CLI Check (push) Has been cancelled
lint-and-check / Component Check (push) Has been cancelled
2025-03-12 13:27:23 +09:00
ef2bdbf3e0 fix: remove nonsense use client directive in drawer
Some checks are pending
lint-and-check / CLI Check (push) Waiting to run
lint-and-check / Component Check (push) Waiting to run
2025-03-11 20:24:55 +09:00
a4da53a890 fix: do missing steps for upgrading to tailwindcss v4 2025-03-11 20:12:46 +09:00
90a458268f feat: upgrade tailwindcss v4
Some checks are pending
lint-and-check / CLI Check (push) Waiting to run
lint-and-check / Component Check (push) Waiting to run
2025-03-11 19:59:11 +09:00
fe1a9afeb0 chore: change setting to auto format with biome 2025-03-11 19:33:26 +09:00
a6af5622dd feat: improve stability of drawer using relative and padding 2024-08-09 11:44:18 +09:00
81854841ce feat: use useAnimatedMount in Drawer 2024-08-07 22:08:22 +09:00
57f9a9b118 fix: add useAnimatedMount to registry 2024-08-07 21:46:12 +09:00
a90cec770c feat: make sure toast is rendered as born first 2024-08-07 19:19:27 +09:00
371453b544 refactor: use getCalculatedTransitionDuration on Toast 2024-08-07 19:14:30 +09:00
054568a2ec feat: export getCalculatedTransitionDuration 2024-08-07 19:09:27 +09:00
4b6724ed90 feat: unmount dialog from dom on closed 2024-08-07 18:58:53 +09:00
8728df1f60 docs: enhance useAnimatedMount comment 2024-08-07 18:54:57 +09:00
3ccfa3b519 feat: add useAnimatedMount 2024-08-07 18:32:13 +09:00
d10fec90ad feat: bump cli to 0.5.1 2024-08-04 16:26:14 +09:00
3d55a98821 fix: add cache option no-cache to prevent caching 2024-08-04 16:23:53 +09:00
96d64e57bb fix: remove node-fetch 2024-08-04 16:22:55 +09:00
e1c5e09872 feat: update registry 2024-08-04 16:10:01 +09:00
67207d7daf fix: use invalid message in context instead of children prop 2024-08-04 16:03:28 +09:00
617b7be249 feat: add Form 2024-08-04 16:02:27 +09:00
bfbce754d9 feat: add maxSize prop on DrawerContent 2024-08-04 15:22:41 +09:00
00ebabe8b3 refactor: replace withSSD & ssdFallback with useDocument 2024-08-03 21:35:51 +09:00
71751f7e22 feat: bump yarn to 4.4.0 2024-08-03 21:07:11 +09:00
878016d3cc chore: format package.json 2024-08-03 21:06:18 +09:00
79b41e45ff fix(react/Input): apply full variant on InputFrame 2024-07-24 23:44:45 +09:00
c2178ef473 fix(react/Input): sync color on hover of Input in InputFrame 2024-07-24 23:43:48 +09:00
086cbe77a9 fix(button): add brightness class on button hover
to simplify background color system, and make simple to customize background color
2024-07-23 18:07:39 +09:00
c8f1912bae chore: make dev env dark mode work properly 2024-07-23 18:06:18 +09:00
05953bbebe chore: add zed ide support 2024-07-23 18:05:52 +09:00
ad62a4ed86 fix: give ServerSideDocumentFallback callback 2024-07-20 02:44:36 +09:00
d0af00db63 chore: fix settings.json style 2024-07-20 02:42:52 +09:00
6dcf44fc60 ci: add tsc check on pre-push hook 2024-07-20 02:40:59 +09:00
9113b8e095 fix: make ssrFallback take callback as children 2024-07-20 02:32:34 +09:00
13cc43de2e fix: add displayName on withServerSideDocument 2024-07-20 02:29:10 +09:00
7251bf2400 fix(front): remove unused useId import 2024-07-20 02:12:07 +09:00
ef17f6f1ce style: idk 2024-07-20 02:11:35 +09:00
bef29ebf19 feat(tooltip): add default role for aria 2024-07-19 00:35:30 +09:00
bc2020f560 feat(button): add default role for aria 2024-07-19 00:34:42 +09:00
6c14cb1e51 fix(dialog): fix structure of initialDialogContext to match with type 2024-07-16 19:07:33 +09:00
14619f5311 feat(dialog): add aria properties to improve accessibility 2024-07-16 19:04:35 +09:00
b56242c497 refactor(dialog): move default variants to base 2024-07-12 18:35:14 +09:00
c08a81badd refactor(dialog/DialogFooter): move gap to base 2024-07-12 18:32:23 +09:00
b5e35aa905 fix(dialog/DialogFooter): add w-full on base 2024-07-12 18:31:48 +09:00
61a263addb refactor(dialog/DialogContent): move variants from DialogOverlay to base 2024-07-12 18:28:33 +09:00
13f1d9def3 refactor(dialog/DialogContent): make DialogContent use flex 2024-07-12 18:26:58 +09:00
8c2986700e refactor(dialog/DialogContent): make simple styles to base 2024-07-12 18:21:09 +09:00
db5c36a3bf fix(lib): add undefined check on vcn VariantProps processing 2024-07-12 13:55:41 +09:00
8465837f63 fix(Popover): add z-index on PopoverContent 2024-07-12 02:25:44 +09:00
366ac90eb8 refactor(Checkbox): make transition smoother 2024-07-12 02:15:05 +09:00
ec6192d32b refactor(Checkbox): remove check icon 2024-07-12 02:14:34 +09:00
2f9e155cbe fix(Toast/Component/Toaster): apply withServerSideDocument on Toaster 2024-07-12 01:47:38 +09:00
508d58fa71 fix(Toast/Component/Toaster): add displayName 2024-07-12 01:46:42 +09:00
9d0188eacc fix(popover/PopoverContent): specify ref element type 2024-07-12 01:43:11 +09:00
2e441ff1e5 fix(Popover): apply asChild effect on PopoverContent 2024-07-12 01:40:33 +09:00
80c7542803 feat(input): add asChild on InputFrame 2024-07-12 01:39:02 +09:00
ec574d3841 fix(tooltip): add displayName 2024-07-12 01:37:50 +09:00
52996d3a76 fix(switch): add displayName 2024-07-12 01:37:41 +09:00
ce8dc422a4 fix(popover): add displayName 2024-07-12 01:37:31 +09:00
952e235fda fix(label): add displayName 2024-07-12 01:37:20 +09:00
0916c483d4 fix(input): add displayName 2024-07-12 01:35:27 +09:00
321b47ab3f fix(drawer): apply ServerSideDocumentFallback 2024-07-12 01:34:21 +09:00
985a2b5297 fix(drawer): add displayName 2024-07-12 01:28:51 +09:00
e9d9bef4bf fix(checkbox): add displayName 2024-07-12 01:27:07 +09:00
232c832b25 fix(button): add displayName 2024-07-12 01:26:57 +09:00
98dd58febb feat(lib): add withSSD 2024-07-12 01:25:10 +09:00
da628710a4 feat(dialog): apply ServerSideDocumentFallback 2024-07-12 01:11:56 +09:00
eb8ea83336 feat(lib): add ServerSideDocumentFallback component to support ssr frameworks like nextjs 2024-07-12 01:10:28 +09:00
78ea14c568 fix(dialog): add missing displayName on ref forwarded components 2024-07-12 01:01:38 +09:00
46430bec41 feat(dialog): add dialog controller 2024-07-12 00:56:36 +09:00
0e73321c3a fix: move transition duration of DrawerContent wrapper to outside 2024-07-04 18:17:26 +09:00
9573fd1e1a fix: add type in Button 2024-07-04 14:18:33 +09:00
5276eb8ba9 chore: add biome idea configuration 2024-07-04 14:17:13 +09:00
7aa0618ae3 feat(Popover): add controlled in PopoverContext to disable outside click on controlled 2024-06-30 22:48:52 +09:00
c52a8843e8 feat(Popover): make opened prop update internal opened state in realtime 2024-06-30 22:44:14 +09:00
c1289b63ea fix(Popover): add pointer-events class on opened condition 2024-06-30 14:10:09 +09:00
113366d27c feat: add popover detailed position props 2024-06-30 14:09:01 +09:00
c1e930ba59 ci: add --write flag on lint to avoid easy problems 2024-06-29 22:48:36 +09:00
7228ab794f style: fix errors with biomejs 2024-06-29 22:46:38 +09:00
692c5fb7b3 fix: messing package.json 2024-06-29 22:46:15 +09:00
ba04aa7cf5 fix: include main.tsx in gitignore 2024-06-29 22:36:54 +09:00
780eed20d5 fix: remove main.ts in src 2024-06-29 22:36:20 +09:00
4d33e78454 fix: fix errors with biomejs 2024-06-29 22:33:52 +09:00
8e9b178f5e fix: fix errors with biomejs 2024-06-29 22:28:58 +09:00
2414b70ca5 style: prettier cli package.json 2024-06-29 22:05:52 +09:00
6ed20835f6 ci: change lint script to run biomejs 2024-06-29 22:05:09 +09:00
65a6597a8e ci: replace eslint and prettier to biomejs and lefthook 2024-06-29 22:02:56 +09:00
8e6aa36dab docs: improve comment of className builder of vcn 2024-06-29 21:53:43 +09:00
a47e9b8427 feat: add dynamic className parameter in vcn 2024-06-29 21:53:11 +09:00
f4f2f2b820 refactor: reduce code implementation complexity using transformer 2024-06-29 21:52:36 +09:00
fd7317e597 feat: add disabled color 2024-06-28 12:08:01 +09:00
180cae69af fix: add cursor-pointer in button to change cursor to pointer 2024-06-28 11:53:53 +09:00
5c12c00cec fix: add stopPropagation in DialogContent onClick
To prevent triggering closeOnClick on click of inner content.
2024-06-27 13:24:10 +09:00
21a2bfc3d0 feat: add additional layer to make dialog scrollable when overflowed 2024-06-27 13:22:52 +09:00
d9d6d033f9 fix: rename DialogContent interface to DialogContentProps 2024-06-27 13:14:57 +09:00
5debb80330 fix: add touch-none in drawerOverlay to prevent outside scrolling 2024-06-22 08:00:10 +09:00
3b88ad4e51 fix: fix import alias resolve in vite config 2024-06-22 07:41:22 +09:00
ffba99a229 fix: remove touch-none to support touch scrolling in mobile devices 2024-06-22 07:41:06 +09:00
d930c44bb0 fix: add overflow-auto to support native scrolling 2024-06-22 07:40:50 +09:00
a1e7baa6c4 feat: update docs to ignore all src except core 2024-06-22 07:26:07 +09:00
871e1b8aed docs: update main README 2024-06-15 12:33:26 +09:00
a5aa709656 fix(toast): add export 2024-06-15 04:30:14 +09:00
60 changed files with 2231 additions and 3986 deletions

View File

@ -28,7 +28,7 @@ jobs:
run: |
yarn install
- name: Lint
run: yarn cli lint
run: yarn cli lint --write
- name: Build
run: yarn cli build
@ -57,6 +57,6 @@ jobs:
run: |
yarn install
- name: Lint
run: yarn react lint
run: yarn react lint --write
- name: TypeScript Compile
run: yarn react tsc

7
.idea/biome.xml generated Normal file
View 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>

View File

@ -1,7 +1,6 @@
{
"tailwindCSS.experimental.classRegex": [
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["[cC]olors\\s*\\=\\s*{([^]*(?=}))}", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
"tailwindCSS.experimental.classRegex": [
["vcn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["[cC]olors\\s*\\=\\s*{([^]*(?=}))}", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
}

20
.zed/settings.json Normal file
View 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"
}

View File

@ -1,11 +1,58 @@
<p align="center">
<img src="https://raw.githubusercontent.com/pswui/.github/main/Square%20Logo%20Rounded.png" width="200" height="200" />
</p>
<p align="center">
Meet our beautiful <a href="https://ui.psw.kr">web documentation</a>.
</p>
# PSW-UI
Shared UI Component Repository.
> Build your components in isolation
My goal is to create **fully typesafe**, **highly customizable** component with **minimum complexity**.
**There are a lot of component libraries out there, but why it install so many things?**
Meet our [web documentation](https://ui.psw.kr)!
## Introduction
> Beautifully designed, easily copy & pastable, fully customizable - that means it only depends on few dependencies.
This is **UI kit**, a collection of re-usable components that you can copy and paste into your apps.
This UI kit is inspired by [shadcn/ui](https://ui.shadcn.com/) - but it is more customizable.
**More customizable?**
shadcn/ui depends on a lot of packages to provide functionality to its components.
Radix UI, React DayPicker, Embla Carousel...
I'm not saying it's a bad thing.
But when you depends on a lot of package - you easily mess up your package.json, and you can't even edit a single feature.
The only thing you can customize is **style**.
If there's a bug that needs to be fixed quickly, or a feature that doesn't work the way you want it to,
you'll want to tweak it to your liking. This is where relying on a lot of packages can be poison.
PSW/UI solves this - by **NOT** depending on any other packages than framework like React, TailwindCSS, and tailwind-merge.
You can just copy it, and all functionality is contained in a single file.
## Roadmap
First of all, this project is alpha.
You can see a lot of components are missing, and some component is buggy.
I'm working with this priority:
1. Adding new component, with stable features.
2. Fixing bugs with existing components.
3. Make it looks nice.
Also, there is a [Github README](https://github.com/pswui/ui) for component implementation plans.
You can see what component is implemented, or planned to be implemented.
If you have any ideas or suggestions, please let me know in [Github Issues](https://github.com/pswui/ui/issues).
## Milestones
@ -15,11 +62,6 @@ Meet our [web documentation](https://ui.psw.kr)!
- [ ] FileInput
- [ ] ImageInput
- [ ] Form
- [ ] FormItem
- [ ] FormLabel
- [ ] FormControl
- [ ] FormDescription
- [ ] FormMessage
- [ ] Textarea
- [ ] Accordion
- [ ] Alert
@ -53,24 +95,35 @@ Meet our [web documentation](https://ui.psw.kr)!
- [ ] Toggle
- [ ] Toggle Group
- [x] Tooltip
- Library/Framework Support
- [ ] React
- [ ] Svelte
- CLI
- [x] Add
- [x] List
- [x] Search
## Building local development environment
```bash
# Corepack - Yarn 4.2.2
corepack enable
corepack install yarn@4.2.2
corepack use yarn@4.2.2
# Install Packages
yarn install
# Run Storybook
yarn workspace react storybook
# Script running in workspace
yarn react dev # `yarn dev` in react workspace
yarn cli build # `yarn build` in cli workspace
```
## Project Structure
* `registry.json` - for CLI component registry
* `packages/react` - React components
* `components` - component files & directories
* `lib` - shared utility used by component
* `src` - small test area
* `packages/cli` - CLI package using [oclif](https://oclif.io)
* `src/commands` - command class declaration
* `src/components` - React component used by CLI via [ink](https://www.npmjs.com/package/ink)
* `src/helpers` - utility functions that helps CLI
* `const.ts` - constant & small value builder (like URL) & types & interfaces
* `public.ts` - will be exported

27
biome.json Normal file
View 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
View 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

View File

@ -5,11 +5,9 @@
"repository": "https://github.com/pswui/ui",
"author": "p-sw <shinwoo.park@psw.kr>",
"license": "MIT",
"workspaces": [
"packages/*",
"components"
],
"workspaces": ["packages/*", "components"],
"scripts": {
"postinstall": "lefthook install",
"react": "yarn workspace react",
"cli": "yarn workspace @psw-ui/cli",
"react:build": "yarn workspace react build",
@ -17,5 +15,9 @@
"cli:build": "yarn workspace @psw-ui/cli build"
},
"private": true,
"packageManager": "yarn@4.2.2+sha512.c44e283c54e02de9d1da8687025b030078c1b9648d2895a65aab8e64225bfb7becba87e1809fc0b4b6778bbd47a1e2ab6ac647de4c5e383a53a7c17db6c3ff4b"
"packageManager": "yarn@4.4.0+sha512.91d93b445d9284e7ed52931369bc89a663414e5582d00eea45c67ddc459a2582919eece27c412d6ffd1bd0793ff35399381cb229326b961798ce4f4cc60ddfdb",
"devDependencies": {
"@biomejs/biome": "1.8.3",
"lefthook": "^1.6.18"
}
}

View File

@ -1 +0,0 @@
/dist

View File

@ -1,93 +0,0 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"xo-space",
"plugin:n/recommended",
"plugin:unicorn/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:perfectionist/recommended-natural",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"n",
"unicorn",
"import"
],
"rules": {
"capitalized-comments": 0,
"comma-dangle": ["error", "always-multiline"],
"default-case": 0,
"no-multi-spaces": 0,
"n/shebang": 0,
"curly": 0,
"quotes": ["error","single",{
"avoidEscape": true
}],
"semi": ["error","never"],
"unicorn/prevent-abbreviations": "off",
"unicorn/no-await-expression-member": "off",
"unicorn/no-null": "off",
"unicorn/prefer-module": "warn",
"logical-assignment-operators": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-var-requires": "off",
"import/no-unresolved": "error",
"import/default": "off",
"import/no-named-as-default-member": "off",
"n/no-missing-import": "off",
"n/no-unsupported-features/es-syntax": "off",
"no-unused-expressions": "off",
"no-useless-constructor": "off",
"perfectionist/sort-classes": [
"error",
{
"groups": [
"index-signature",
"static-property",
"property",
"private-property",
"constructor",
"static-method",
"static-private-method",
["get-method", "set-method"],
"method",
"private-method",
"unknown"
],
"order": "asc",
"type": "alphabetical"
}
],
"valid-jsdoc": ["warn", {
"requireParamType": false,
"requireReturnType": false
}]
},
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [
".ts", ".tsx"
]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"globals": {
"describe": true,
"it": true
}
}

View File

@ -1,10 +1,6 @@
{
"require": [
"ts-node/register"
],
"watch-extensions": [
"ts"
],
"require": ["ts-node/register"],
"watch-extensions": ["ts"],
"recursive": true,
"reporter": "spec",
"timeout": 60000,

View File

@ -1 +0,0 @@
"@oclif/prettier-config"

View File

@ -1,6 +1,6 @@
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
// eslint-disable-next-line n/shebang
import {execute} from '@oclif/core'
import { execute } from "@oclif/core";
await execute({development: true, dir: import.meta.url})
await execute({ development: true, dir: import.meta.url });

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node
import {execute} from '@oclif/core'
import { execute } from "@oclif/core";
await execute({dir: import.meta.url})
await execute({ dir: import.meta.url });

View File

@ -1,7 +1,7 @@
{
"name": "@psw-ui/cli",
"description": "CLI for PSW/UI",
"version": "0.5.0",
"version": "0.5.1",
"author": "p-sw",
"bin": {
"pswui": "./bin/run.js"
@ -14,31 +14,17 @@
"@types/treeify": "^1.0.3",
"ink": "^5.0.1",
"ink-text-input": "^6.0.0",
"node-fetch": "^3.3.2",
"ora": "^8.0.1",
"react": "^18.3.1",
"treeify": "^1.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@oclif/prettier-config": "^0.2.1",
"@oclif/test": "^4",
"@types/chai": "^4",
"@types/ink-divider": "^2.0.4",
"@types/node": "^18",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"chai": "^4",
"eslint": "^8",
"eslint-config-oclif": "^5",
"eslint-config-oclif-typescript": "^3",
"eslint-config-prettier": "^9",
"eslint-config-xo-space": "^0.35.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-n": "^17.9.0",
"eslint-plugin-perfectionist": "^2.11.0",
"eslint-plugin-unicorn": "^54.0.0",
"oclif": "^4",
"shx": "^0.3.3",
"tailwind-scrollbar": "^3.1.0",
@ -48,15 +34,9 @@
"engines": {
"node": ">=18.0.0"
},
"files": [
"/bin",
"/dist",
"/oclif.manifest.json"
],
"files": ["/bin", "/dist", "/oclif.manifest.json"],
"homepage": "https://ui.psw.kr",
"keywords": [
"oclif"
],
"keywords": ["oclif"],
"license": "MIT",
"main": "dist/index.js",
"type": "module",
@ -64,9 +44,7 @@
"bin": "pswui",
"dirname": "pswui",
"commands": "./dist/commands",
"plugins": [
"@oclif/plugin-help"
],
"plugins": ["@oclif/plugin-help"],
"topicSeparator": " ",
"topics": {
"hello": {
@ -77,7 +55,7 @@
"repository": "pswui/ui",
"scripts": {
"build": "shx rm -rf dist && tsc",
"lint": "eslint . --ext .ts",
"lint": "biome check --no-errors-on-unmatched",
"prepack": "yarn build"
},
"types": "dist/index.d.ts"

View File

@ -1,36 +1,43 @@
import {Args, Command, Flags} from '@oclif/core'
import {loadConfig, validateConfig} from '../helpers/config.js'
import {existsSync} from 'node:fs'
import {mkdir, writeFile} from 'node:fs/promises'
import {join} from 'node:path'
import {getComponentURL, getDirComponentURL, getRegistry} from '../helpers/registry.js'
import ora from 'ora'
import React, {ComponentPropsWithoutRef} from 'react'
import {render, Box} from 'ink'
import {SearchBox} from '../components/SearchBox.js'
import {getDirComponentRequiredFiles, checkComponentInstalled} from '../helpers/path.js'
import {Choice} from '../components/Choice.js'
import {colorize} from '@oclif/core/ux'
import {safeFetch} from '../helpers/safe-fetcher.js'
import { existsSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Args, Command, Flags } from "@oclif/core";
import { colorize } from "@oclif/core/ux";
import { Box, render } from "ink";
import ora from "ora";
import React, { type ComponentPropsWithoutRef } from "react";
import { Choice } from "../components/Choice.js";
import { SearchBox } from "../components/SearchBox.js";
import { loadConfig, validateConfig } from "../helpers/config.js";
import {
checkComponentInstalled,
getDirComponentRequiredFiles,
} from "../helpers/path.js";
import {
getComponentURL,
getDirComponentURL,
getRegistry,
} from "../helpers/registry.js";
import { safeFetch } from "../helpers/safe-fetcher.js";
function Generator() {
let complete: boolean = false
let complete = false;
function ComponentSelector<T extends {displayName: string; key: string; installed: boolean}>(
props: Omit<ComponentPropsWithoutRef<typeof SearchBox<T>>, 'helper'>,
) {
function ComponentSelector<
T extends { displayName: string; key: string; installed: boolean },
>(props: Omit<ComponentPropsWithoutRef<typeof SearchBox<T>>, "helper">) {
return (
<Box>
<SearchBox
helper={'Press Enter to select component.'}
helper={"Press Enter to select component."}
{...props}
onSubmit={(value) => {
complete = true
props.onSubmit?.(value)
complete = true;
props.onSubmit?.(value);
}}
/>
</Box>
)
);
}
return [
@ -38,29 +45,31 @@ function Generator() {
new Promise<void>((r) => {
const i = setInterval(() => {
if (complete) {
r()
clearInterval(i)
r();
clearInterval(i);
}
}, 100)
}, 100);
}),
] as const
] as const;
}
function Generator2() {
let complete = false
let complete = false;
function ForceSelector({onComplete}: {onComplete: (value: 'yes' | 'no') => void}) {
function ForceSelector({
onComplete,
}: { onComplete: (value: "yes" | "no") => void }) {
return (
<Choice
question={'You already installed this component. Overwrite?'}
yes={'Yes, overwrite existing file and install it.'}
no={'No, cancel the action.'}
question={"You already installed this component. Overwrite?"}
yes={"Yes, overwrite existing file and install it."}
no={"No, cancel the action."}
onSubmit={(value) => {
complete = true
onComplete(value)
complete = true;
onComplete(value);
}}
/>
)
);
}
return [
@ -68,190 +77,237 @@ function Generator2() {
new Promise<void>((r) => {
const i = setInterval(() => {
if (complete) {
r()
clearInterval(i)
r();
clearInterval(i);
}
}, 100)
}, 100);
}),
] as const
] as const;
}
export default class Add extends Command {
static override args = {
name: Args.string({description: 'name of component to install'}),
}
name: Args.string({ description: "name of component to install" }),
};
static override description = 'Add a component to the project.'
static override description = "Add a component to the project.";
static override examples = ['<%= config.bin %> <%= command.id %>']
static override examples = ["<%= config.bin %> <%= command.id %>"];
static override flags = {
branch: Flags.string({char: 'r', description: 'use other branch instead of main'}),
force: Flags.boolean({char: 'f', description: 'override the existing file'}),
config: Flags.string({char: 'p', description: 'path to config'}),
shared: Flags.string({char: 's', description: 'place for installation of shared.ts'}),
components: Flags.string({char: 'c', description: 'place for installation of components'}),
}
branch: Flags.string({
char: "r",
description: "use other branch instead of main",
}),
force: Flags.boolean({
char: "f",
description: "override the existing file",
}),
config: Flags.string({ char: "p", description: "path to config" }),
shared: Flags.string({
char: "s",
description: "place for installation of shared.ts",
}),
components: Flags.string({
char: "c",
description: "place for installation of components",
}),
};
public async run(): Promise<void> {
let {
args,
flags: {force, ...flags},
} = await this.parse(Add)
flags: { force, ...flags },
} = await this.parse(Add);
const resolvedConfig = await validateConfig((message: string) => this.log(message), await loadConfig(flags.config))
const componentFolder = join(process.cwd(), resolvedConfig.paths.components)
const libFolder = join(process.cwd(), resolvedConfig.paths.lib)
const resolvedConfig = await validateConfig(
(message: string) => this.log(message),
await loadConfig(flags.config),
);
const componentFolder = join(
process.cwd(),
resolvedConfig.paths.components,
);
const libFolder = join(process.cwd(), resolvedConfig.paths.lib);
if (!existsSync(componentFolder)) {
await mkdir(componentFolder, {recursive: true})
await mkdir(componentFolder, { recursive: true });
}
if (!existsSync(libFolder)) {
await mkdir(libFolder, {recursive: true})
await mkdir(libFolder, { recursive: true });
}
const loadRegistryOra = ora('Fetching registry...').start()
const loadRegistryOra = ora("Fetching registry...").start();
if (flags.registry) {
this.log(`Using ${flags.branch} for branch.`)
this.log(`Using ${flags.branch} for branch.`);
}
const unsafeRegistry = await getRegistry(flags.branch)
const unsafeRegistry = await getRegistry(flags.branch);
if (!unsafeRegistry.ok) {
loadRegistryOra.fail(unsafeRegistry.message)
return
loadRegistryOra.fail(unsafeRegistry.message);
return;
}
const registry = unsafeRegistry.registry
const componentNames = Object.keys(registry.components)
loadRegistryOra.succeed(`Successfully fetched registry! (${componentNames.length} components)`)
const searchBoxComponent: {displayName: string; key: string; installed: boolean}[] = []
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)
const installed = await checkComponentInstalled(
registry.components[name],
resolvedConfig,
);
searchBoxComponent.push({
displayName: installed ? `${name} (installed)` : name,
key: name,
installed,
})
});
}
let name: string | undefined = args.name?.toLowerCase?.()
let name: string | undefined = args.name?.toLowerCase?.();
let requireForce: boolean =
!name || !componentNames.includes(name.toLowerCase())
? false
: searchBoxComponent.find(({key}) => key === name)?.installed
: searchBoxComponent.find(({ key }) => key === name)?.installed
? !force
: false
: false;
if (!name || !componentNames.includes(name.toLowerCase())) {
const [ComponentSelector, waitForComplete] = Generator()
const [ComponentSelector, waitForComplete] = Generator();
const inkInstance = render(
<ComponentSelector
components={searchBoxComponent}
initialQuery={args.name}
onSubmit={(comp) => {
name = comp.key
requireForce = comp.installed
inkInstance.clear()
name = comp.key;
requireForce = comp.installed;
inkInstance.clear();
}}
/>,
)
await waitForComplete
inkInstance.unmount()
);
await waitForComplete;
inkInstance.unmount();
}
let quit = false
let quit = false;
if (requireForce) {
const [ForceSelector, waitForComplete] = Generator2()
const [ForceSelector, waitForComplete] = Generator2();
const inkInstance = render(
<ForceSelector
onComplete={(value) => {
force = value === 'yes'
quit = value === 'no'
inkInstance.clear()
force = value === "yes";
quit = value === "no";
inkInstance.clear();
}}
/>,
)
await waitForComplete
inkInstance.unmount()
);
await waitForComplete;
inkInstance.unmount();
if (quit) {
this.log(colorize('redBright', 'Installation canceled by user.'))
return
this.log(colorize("redBright", "Installation canceled by user."));
return;
}
}
if (!name || !componentNames.includes(name.toLowerCase())) {
this.error('Component name is not provided, or not selected.')
this.error("Component name is not provided, or not selected.");
}
const libFileOra = ora('Installing required library...').start()
let successCount = 0
const libFileOra = ora("Installing required library...").start();
let successCount = 0;
for await (const libFile of registry.lib) {
const filePath = join(libFolder, libFile)
const filePath = join(libFolder, libFile);
if (!existsSync(filePath)) {
const libFileContentResponse = await safeFetch(registry.base + registry.paths.lib.replace('{libName}', libFile))
const libFileContentResponse = await safeFetch(
registry.base + registry.paths.lib.replace("{libName}", libFile),
);
if (!libFileContentResponse.ok) {
libFileOra.fail(libFileContentResponse.message)
return
libFileOra.fail(libFileContentResponse.message);
return;
}
const libFileContent = await libFileContentResponse.response.text()
await writeFile(filePath, libFileContent)
successCount++
const libFileContent = await libFileContentResponse.response.text();
await writeFile(filePath, libFileContent);
successCount++;
}
}
if (successCount > 1) {
libFileOra.succeed('Successfully installed library files!')
libFileOra.succeed("Successfully installed library files!");
} else {
libFileOra.succeed('Library files are already installed!')
libFileOra.succeed("Library files are already installed!");
}
const componentFileOra = ora(`Installing ${name} component...`).start()
const componentObject = registry.components[name]
if (componentObject.type === 'file') {
const componentFile = join(componentFolder, registry.components[name].name)
const componentFileOra = ora(`Installing ${name} component...`).start();
const componentObject = registry.components[name];
if (componentObject.type === "file") {
const componentFile = join(
componentFolder,
registry.components[name].name,
);
if (existsSync(componentFile) && !force) {
componentFileOra.succeed(`Component is already installed! (${componentFile})`)
componentFileOra.succeed(
`Component is already installed! (${componentFile})`,
);
} else {
const componentFileContentResponse = await safeFetch(await getComponentURL(registry, componentObject))
const componentFileContentResponse = await safeFetch(
await getComponentURL(registry, componentObject),
);
if (!componentFileContentResponse.ok) {
componentFileOra.fail(componentFileContentResponse.message)
return
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.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)
} else if (componentObject.type === "dir") {
const componentDir = join(componentFolder, componentObject.name);
if (!existsSync(componentDir)) {
await mkdir(componentDir, {recursive: true})
await mkdir(componentDir, { recursive: true });
}
const requiredFiles = await getDirComponentRequiredFiles(componentObject, resolvedConfig)
const requiredFiles = await getDirComponentRequiredFiles(
componentObject,
resolvedConfig,
);
if (requiredFiles.length === 0 && !force) {
componentFileOra.succeed(`Component is already installed! (${componentDir})`)
componentFileOra.succeed(
`Component is already installed! (${componentDir})`,
);
} else {
const requiredFilesURLs = await getDirComponentURL(registry, componentObject, requiredFiles)
const requiredFilesURLs = await getDirComponentURL(
registry,
componentObject,
requiredFiles,
);
for await (const [filename, url] of requiredFilesURLs) {
const componentFile = join(componentDir, filename)
const componentFile = join(componentDir, filename);
if (!existsSync(componentFile) || force) {
const componentFileContentResponse = await safeFetch(url)
const componentFileContentResponse = await safeFetch(url);
if (!componentFileContentResponse.ok) {
componentFileOra.fail(componentFileContentResponse.message)
return
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)
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!')
componentFileOra.succeed("Component is successfully installed!");
}
}
this.log('Now you can import the component.')
this.log("Now you can import the component.");
}
}

View File

@ -1,65 +1,89 @@
import {Command, Flags} from '@oclif/core'
import ora from 'ora'
import treeify from 'treeify'
import { Command, Flags } from "@oclif/core";
import ora from "ora";
import treeify from "treeify";
import {loadConfig, validateConfig} from '../helpers/config.js'
import {checkComponentInstalled} from '../helpers/path.js'
import {getComponentURL, getDirComponentURL, getRegistry} from '../helpers/registry.js'
import { loadConfig, validateConfig } from "../helpers/config.js";
import { checkComponentInstalled } from "../helpers/path.js";
import {
getComponentURL,
getDirComponentURL,
getRegistry,
} from "../helpers/registry.js";
export default class List extends Command {
static override description = 'Prints all available components in registry and components installed in this project.'
static override description =
"Prints all available components in registry and components installed in this project.";
static override examples = ['<%= config.bin %> <%= command.id %>']
static override examples = ["<%= config.bin %> <%= command.id %>"];
static override flags = {
branch: Flags.string({char: 'r', description: 'use other branch instead of main'}),
config: Flags.string({char: 'p', description: 'path to config'}),
url: Flags.boolean({char: 'u', description: 'include component file URL'}),
}
branch: Flags.string({
char: "r",
description: "use other branch instead of main",
}),
config: Flags.string({ char: "p", description: "path to config" }),
url: Flags.boolean({
char: "u",
description: "include component file URL",
}),
};
public async run(): Promise<void> {
const {flags} = await this.parse(List)
const { flags } = await this.parse(List);
const registrySpinner = ora('Fetching registry...')
const registrySpinner = ora("Fetching registry...");
const loadedConfig = await validateConfig((message: string) => this.log(message), await loadConfig(flags.config))
const loadedConfig = await validateConfig(
(message: string) => this.log(message),
await loadConfig(flags.config),
);
registrySpinner.start()
registrySpinner.start();
if (flags.branch) {
this.log(`Using ${flags.branch} for registry.`)
this.log(`Using ${flags.branch} for registry.`);
}
const unsafeRegistry = await getRegistry(flags.branch)
const unsafeRegistry = await getRegistry(flags.branch);
if (!unsafeRegistry.ok) {
registrySpinner.fail(unsafeRegistry.message)
return
registrySpinner.fail(unsafeRegistry.message);
return;
}
const {registry} = unsafeRegistry
const names = Object.keys(registry.components)
const { registry } = unsafeRegistry;
const names = Object.keys(registry.components);
registrySpinner.succeed(`Fetched ${names.length} components.`)
registrySpinner.succeed(`Fetched ${names.length} components.`);
let final: Record<string, {URL?: Record<string, string>; installed: 'no' | 'yes'}> = {}
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'
const componentObject = registry.components[name];
const installed = (await checkComponentInstalled(
componentObject,
loadedConfig,
))
? "yes"
: "no";
if (flags.url) {
let url: Record<string, string> = {}
let url: Record<string, string> = {};
if (componentObject.type === 'file') {
url[name] = await getComponentURL(registry, componentObject)
} else if (componentObject.type === 'dir') {
url = Object.fromEntries(await getDirComponentURL(registry, componentObject))
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}}
final = { ...final, [name]: { URL: url, installed } };
} else {
final = {...final, [name]: {installed}}
final = { ...final, [name]: { installed } };
}
}
this.log('AVAILABLE COMPONENTS')
this.log(treeify.asTree(final, true, true))
this.log("AVAILABLE COMPONENTS");
this.log(treeify.asTree(final, true, true));
}
}

View File

@ -1,42 +1,45 @@
import {Command, Args, Flags} from '@oclif/core'
import {render} from 'ink'
import {SearchBox} from '../components/SearchBox.js'
import {getRegistry} from '../helpers/registry.js'
import React from 'react'
import { Args, Command, Flags } from "@oclif/core";
import { render } from "ink";
import React from "react";
import { SearchBox } from "../components/SearchBox.js";
import { getRegistry } from "../helpers/registry.js";
export default class Search extends Command {
static override args = {
query: Args.string({description: 'search query'}),
}
query: Args.string({ description: "search query" }),
};
static override flags = {
branch: Flags.string({char: 'r', description: 'use other branch instead of main'}),
}
branch: Flags.string({
char: "r",
description: "use other branch instead of main",
}),
};
static override description = 'Search components.'
static override description = "Search components.";
static override examples = ['<%= config.bin %> <%= command.id %>']
static override examples = ["<%= config.bin %> <%= command.id %>"];
public async run(): Promise<void> {
const {args, flags} = await this.parse(Search)
const { args, flags } = await this.parse(Search);
if (flags.branch) {
this.log(`Using ${flags.branch} for registry.`)
this.log(`Using ${flags.branch} for registry.`);
}
const registryResult = await getRegistry(flags.branch)
const registryResult = await getRegistry(flags.branch);
if (!registryResult.ok) {
this.error(registryResult.message)
this.error(registryResult.message);
}
const registry = registryResult.registry
const componentNames = Object.keys(registry.components)
const registry = registryResult.registry;
const componentNames = Object.keys(registry.components);
await render(
<SearchBox
components={componentNames.map((v) => ({key: v, displayName: v}))}
components={componentNames.map((v) => ({ key: v, displayName: v }))}
initialQuery={args.query}
helper={'Press ESC to quit'}
helper={"Press ESC to quit"}
onKeyDown={(_, k, app) => k.escape && app.exit()}
/>,
).waitUntilExit()
).waitUntilExit();
}
}

View File

@ -1,26 +1,26 @@
import React, {useState} from 'react'
import {Box, Text, useInput} from 'ink'
import { Box, Text, useInput } from "ink";
import React, { useState } from "react";
function isUnicodeSupported() {
if (process.platform !== 'win32') {
return process.env['TERM'] !== 'linux' // Linux console (kernel)
if (process.platform !== "win32") {
return process.env.TERM !== "linux"; // Linux console (kernel)
}
return (
Boolean(process.env['WT_SESSION']) || // Windows Terminal
Boolean(process.env['TERMINUS_SUBLIME']) || // Terminus (<0.2.27)
process.env['ConEmuTask'] === '{cmd::Cmder}' || // ConEmu and cmder
process.env['TERM_PROGRAM'] === 'Terminus-Sublime' ||
process.env['TERM_PROGRAM'] === 'vscode' ||
process.env['TERM'] === 'xterm-256color' ||
process.env['TERM'] === 'alacritty' ||
process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'
)
Boolean(process.env.WT_SESSION) || // Windows Terminal
Boolean(process.env.TERMINUS_SUBLIME) || // Terminus (<0.2.27)
process.env.ConEmuTask === "{cmd::Cmder}" || // ConEmu and cmder
process.env.TERM_PROGRAM === "Terminus-Sublime" ||
process.env.TERM_PROGRAM === "vscode" ||
process.env.TERM === "xterm-256color" ||
process.env.TERM === "alacritty" ||
process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
);
}
const shouldUseMain = isUnicodeSupported()
const SELECTED: string = shouldUseMain ? '◉' : '(*)'
const UNSELECTED: string = shouldUseMain ? '◯' : '( )'
const shouldUseMain = isUnicodeSupported();
const SELECTED: string = shouldUseMain ? "◉" : "(*)";
const UNSELECTED: string = shouldUseMain ? "◯" : "( )";
export function Choice({
question,
@ -29,35 +29,38 @@ export function Choice({
onSubmit,
initial,
}: {
question: string
yes: string
no: string
onSubmit?: (vaule: 'yes' | 'no') => void
initial?: 'yes' | 'no'
question: string;
yes: string;
no: string;
onSubmit?: (vaule: "yes" | "no") => void;
initial?: "yes" | "no";
}) {
const [state, setState] = useState<'yes' | 'no'>(initial ?? 'yes')
const [state, setState] = useState<"yes" | "no">(initial ?? "yes");
useInput((_, k) => {
if (k.upArrow) {
setState('yes')
setState("yes");
} else if (k.downArrow) {
setState('no')
setState("no");
}
if (k.return) {
onSubmit?.(state)
onSubmit?.(state);
}
})
});
return (
<Box display={'flex'} flexDirection={'column'}>
<Text color={'greenBright'}>{question}</Text>
<Text color={state === 'yes' ? undefined : 'gray'}>
{state === 'yes' ? SELECTED : UNSELECTED} {yes}
<Box
display={"flex"}
flexDirection={"column"}
>
<Text color={"greenBright"}>{question}</Text>
<Text color={state === "yes" ? undefined : "gray"}>
{state === "yes" ? SELECTED : UNSELECTED} {yes}
</Text>
<Text color={state === 'no' ? undefined : 'gray'}>
{state === 'no' ? SELECTED : UNSELECTED} {no}
<Text color={state === "no" ? undefined : "gray"}>
{state === "no" ? SELECTED : UNSELECTED} {no}
</Text>
</Box>
)
);
}

View File

@ -1,24 +1,32 @@
import React from 'react'
import {Box, Text} from 'ink'
import { Box, Text } from "ink";
import React from "react";
export function Divider({width = 50, padding = 1, title}: {width?: number; padding?: number; title: string}) {
const length = Math.floor((width - title.length - padding * 2) / 2)
export function Divider({
width = 50,
padding = 1,
title,
}: { width?: number; padding?: number; title: string }) {
const length = Math.floor((width - title.length - padding * 2) / 2);
return (
<Box>
{Array.from(Array(length)).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
<Text key={i}></Text>
))}
{Array.from(Array(padding)).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
<Text key={i}> </Text>
))}
<Text>{title}</Text>
{Array.from(Array(padding)).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
<Text key={i}> </Text>
))}
{Array.from(Array(length)).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: there's nothing to be key except index
<Text key={i}></Text>
))}
</Box>
)
);
}

View File

@ -1,10 +1,10 @@
import React, {useEffect, useState} from 'react'
import {getSuggestion} from '../helpers/search.js'
import Input from 'ink-text-input'
import {Divider} from './Divider.js'
import {Box, Text, useInput, useApp, type Key} from 'ink'
import { Box, type Key, Text, useApp, useInput } from "ink";
import Input from "ink-text-input";
import React, { useEffect, useState } from "react";
import { getSuggestion } from "../helpers/search.js";
import { Divider } from "./Divider.js";
export function SearchBox<T extends {key: string; displayName: string}>({
export function SearchBox<T extends { key: string; displayName: string }>({
components,
helper,
initialQuery,
@ -12,92 +12,115 @@ export function SearchBox<T extends {key: string; displayName: string}>({
onChange,
onSubmit,
}: {
components: T[]
helper: string
initialQuery?: string
onKeyDown?: (i: string, k: Key, app: ReturnType<typeof useApp>) => void
onChange?: (item: T) => void
onSubmit?: (item: T) => void
components: T[];
helper: string;
initialQuery?: string;
onKeyDown?: (i: string, k: Key, app: ReturnType<typeof useApp>) => void;
onChange?: (item: T) => void;
onSubmit?: (item: T) => void;
}) {
const [query, setQuery] = useState<string>(initialQuery ?? '')
const [queryMode, setQueryMode] = useState<boolean>(true)
const [isLoading, setLoading] = useState<boolean>(false)
const [suggestions, setSuggestions] = useState<string[]>([])
const [selected, setSelected] = useState<number>(-1)
const [query, setQuery] = useState<string>(initialQuery ?? "");
const [queryMode, setQueryMode] = useState<boolean>(true);
const [isLoading, setLoading] = useState<boolean>(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [selected, setSelected] = useState<number>(-1);
useEffect(() => {
if (queryMode) {
setLoading(true)
setLoading(true);
getSuggestion(
components.map(({key}) => key),
components.map(({ key }) => key),
query,
).then((result) => {
setSuggestions(result)
setSelected(-1)
})
setSuggestions(result);
setSelected(-1);
});
}
}, [query, queryMode])
}, [query, queryMode, components]);
useEffect(() => {
if (onChange) {
const found = components.find(({key}) => key === suggestions[selected])
found && onChange(found)
const found = components.find(({ key }) => key === suggestions[selected]);
found && onChange(found);
}
}, [selected, suggestions, onChange])
}, [selected, suggestions, onChange, components]);
const app = useApp()
const app = useApp();
useInput((i, k) => {
if (k.downArrow) {
setSelected((p) => (p >= suggestions.length - 1 ? 0 : p + 1))
setQueryMode(false)
setSelected((p) => (p >= suggestions.length - 1 ? 0 : p + 1));
setQueryMode(false);
}
if (k.upArrow) {
setSelected((p) => (p <= 0 ? suggestions.length - 1 : p - 1))
setQueryMode(false)
setSelected((p) => (p <= 0 ? suggestions.length - 1 : p - 1));
setQueryMode(false);
}
onKeyDown?.(i, k, app)
})
onKeyDown?.(i, k, app);
});
useEffect(() => {
if (!queryMode && suggestions[selected]) {
setQuery(suggestions[selected])
setQuery(suggestions[selected]);
}
}, [queryMode, selected])
}, [queryMode, selected, suggestions]);
return (
<Box width={50} display={'flex'} flexDirection={'column'}>
<Text color={'gray'}>{helper}</Text>
<Box display={'flex'} flexDirection={'row'}>
<Box marginRight={1} display={'flex'} flexDirection={'row'}>
<Text color={'greenBright'}>Search?</Text>
<Box
width={50}
display={"flex"}
flexDirection={"column"}
>
<Text color={"gray"}>{helper}</Text>
<Box
display={"flex"}
flexDirection={"row"}
>
<Box
marginRight={1}
display={"flex"}
flexDirection={"row"}
>
<Text color={"greenBright"}>Search?</Text>
</Box>
<Input
value={query}
onChange={(v) => {
setQueryMode(true)
setQuery(v)
setQueryMode(true);
setQuery(v);
}}
showCursor
placeholder={' query'}
placeholder={" query"}
onSubmit={() => {
const found = components.find(({key}) => key === suggestions[selected])
found && onSubmit?.(found)
const found = components.find(
({ key }) => key === suggestions[selected],
);
found && onSubmit?.(found);
}}
/>
</Box>
<Divider title={isLoading ? 'Loading...' : `${suggestions.length} components found.`} />
<Box display={'flex'} flexDirection={'column'}>
<Divider
title={
isLoading ? "Loading..." : `${suggestions.length} components found.`
}
/>
<Box
display={"flex"}
flexDirection={"column"}
>
{suggestions.map((name, index) => {
return (
<Box key={name}>
<Text color={selected === index ? undefined : 'gray'}>
{components[components.findIndex(({key}) => key === name)].displayName}
<Text color={selected === index ? undefined : "gray"}>
{
components[components.findIndex(({ key }) => key === name)]
.displayName
}
</Text>
</Box>
)
);
})}
</Box>
</Box>
)
);
}

View File

@ -1,27 +1,28 @@
import {z} from 'zod'
import { z } from "zod";
export const registryURL = (branch: string) => `https://raw.githubusercontent.com/pswui/ui/${branch}/registry.json`
export const CONFIG_DEFAULT_PATH = 'pswui.config.js'
export const registryURL = (branch: string) =>
`https://raw.githubusercontent.com/pswui/ui/${branch}/registry.json`;
export const CONFIG_DEFAULT_PATH = "pswui.config.js";
export type RegistryComponent =
| {
files: string[]
name: string
type: 'dir'
files: string[];
name: string;
type: "dir";
}
| {
name: string
type: 'file'
}
name: string;
type: "file";
};
export interface Registry {
base: string
components: Record<string, RegistryComponent>
lib: string[]
base: string;
components: Record<string, RegistryComponent>;
lib: string[];
paths: {
components: string
lib: string
}
components: string;
lib: string;
};
}
export interface Config {
@ -29,29 +30,31 @@ export interface Config {
* Absolute path that will used for import in component
*/
import?: {
lib?: '@pswui-lib' | string
}
lib?: "@pswui-lib" | string;
};
/**
* Path that cli will create a file.
*/
paths?: {
components?: 'src/pswui/components' | string
lib?: 'src/pswui/lib' | string
}
components?: "src/pswui/components" | string;
lib?: "src/pswui/lib" | string;
};
}
export type ResolvedConfig<T = Config> = {
[k in keyof T]-?: NonNullable<T[k]> extends object ? ResolvedConfig<NonNullable<T[k]>> : T[k]
}
[k in keyof T]-?: NonNullable<T[k]> extends object
? ResolvedConfig<NonNullable<T[k]>>
: T[k];
};
export const DEFAULT_CONFIG = {
import: {
lib: '@pswui-lib',
lib: "@pswui-lib",
},
paths: {
components: 'src/pswui/components',
lib: 'src/pswui/lib',
components: "src/pswui/components",
lib: "src/pswui/lib",
},
}
};
export const configZod = z.object({
import: z
.object({
@ -61,9 +64,12 @@ export const configZod = z.object({
.default(DEFAULT_CONFIG.import),
paths: z
.object({
components: z.string().optional().default(DEFAULT_CONFIG.paths.components),
components: z
.string()
.optional()
.default(DEFAULT_CONFIG.paths.components),
lib: z.string().optional().default(DEFAULT_CONFIG.paths.lib),
})
.optional()
.default(DEFAULT_CONFIG.paths),
})
});

View File

@ -1,43 +1,67 @@
import {colorize} from '@oclif/core/ux'
import {existsSync} from 'node:fs'
import path from 'node:path'
import { existsSync } from "node:fs";
import path from "node:path";
import { colorize } from "@oclif/core/ux";
import {CONFIG_DEFAULT_PATH, DEFAULT_CONFIG, ResolvedConfig, configZod} from '../const.js'
import {changeExtension} from './path.js'
import {
CONFIG_DEFAULT_PATH,
DEFAULT_CONFIG,
type ResolvedConfig,
configZod,
} from "../const.js";
import { changeExtension } from "./path.js";
export async function loadConfig(config?: string): Promise<unknown> {
const userConfigPath = config ? path.join(process.cwd(), config) : null
const defaultConfigPath = path.join(process.cwd(), CONFIG_DEFAULT_PATH)
const cjsConfigPath = path.join(process.cwd(), await changeExtension(CONFIG_DEFAULT_PATH, '.cjs'))
const mjsConfigPath = path.join(process.cwd(), await changeExtension(CONFIG_DEFAULT_PATH, '.mjs'))
const userConfigPath = config ? path.join(process.cwd(), config) : null;
const defaultConfigPath = path.join(process.cwd(), CONFIG_DEFAULT_PATH);
const cjsConfigPath = path.join(
process.cwd(),
await changeExtension(CONFIG_DEFAULT_PATH, ".cjs"),
);
const mjsConfigPath = path.join(
process.cwd(),
await changeExtension(CONFIG_DEFAULT_PATH, ".mjs"),
);
if (userConfigPath) {
if (existsSync(userConfigPath)) {
return (await import(userConfigPath)).default
return (await import(userConfigPath)).default;
}
throw new Error(`Error: config ${userConfigPath} not found.`)
throw new Error(`Error: config ${userConfigPath} not found.`);
}
if (existsSync(defaultConfigPath)) {
return (await import(defaultConfigPath)).default
return (await import(defaultConfigPath)).default;
}
if (existsSync(cjsConfigPath)) {
return (await import(cjsConfigPath)).default
return (await import(cjsConfigPath)).default;
}
if (existsSync(mjsConfigPath)) {
return (await import(mjsConfigPath)).default
return (await import(mjsConfigPath)).default;
}
return DEFAULT_CONFIG
return DEFAULT_CONFIG;
}
export async function validateConfig(log: (message: string) => void, config?: unknown): Promise<ResolvedConfig> {
const parsedConfig: ResolvedConfig = await configZod.parseAsync(config)
log(colorize('gray', `Install component to: ${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
export async function validateConfig(
log: (message: string) => void,
config?: unknown,
): Promise<ResolvedConfig> {
const parsedConfig: ResolvedConfig = await configZod.parseAsync(config);
log(
colorize(
"gray",
`Install component to: ${path.join(process.cwd(), parsedConfig.paths.components)}`,
),
);
log(
colorize(
"gray",
`Install shared module to: ${path.join(process.cwd(), parsedConfig.paths.lib)}`,
),
);
log(colorize("gray", `Import shared with: ${parsedConfig.import.lib}`));
return parsedConfig;
}

View File

@ -1,38 +1,52 @@
import {existsSync} from 'node:fs'
import {readdir} from 'node:fs/promises'
import path from 'node:path'
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import path from "node:path";
import {RegistryComponent, ResolvedConfig} from '../const.js'
import type { RegistryComponent, ResolvedConfig } from "../const.js";
export async function getDirComponentRequiredFiles<T extends {type: 'dir'} & RegistryComponent>(
componentObject: T,
config: ResolvedConfig,
) {
const componentPath = path.join(process.cwd(), config.paths.components, componentObject.name)
export async function getDirComponentRequiredFiles<
T extends { type: "dir" } & RegistryComponent,
>(componentObject: T, config: ResolvedConfig) {
const componentPath = path.join(
process.cwd(),
config.paths.components,
componentObject.name,
);
if (!existsSync(componentPath)) {
return componentObject.files
return componentObject.files;
}
const dir = await readdir(componentPath)
const dir = await readdir(componentPath);
return componentObject.files.filter((filename) => !dir.includes(filename))
return componentObject.files.filter((filename) => !dir.includes(filename));
}
export async function checkComponentInstalled(component: RegistryComponent, config: ResolvedConfig): Promise<boolean> {
const componentDirRoot = path.join(process.cwd(), config.paths.components)
if (!existsSync(componentDirRoot)) return false
export async function checkComponentInstalled(
component: RegistryComponent,
config: ResolvedConfig,
): Promise<boolean> {
const componentDirRoot = path.join(process.cwd(), config.paths.components);
if (!existsSync(componentDirRoot)) return false;
if (component.type === 'file') {
const dir = await readdir(componentDirRoot)
return dir.includes(component.name)
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
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)
export async function changeExtension(
_path: string,
extension: string,
): Promise<string> {
return path.join(
path.dirname(_path),
path.basename(_path, path.extname(_path)) + extension,
);
}

View File

@ -1,37 +1,49 @@
import {Registry, RegistryComponent, registryURL} from '../const.js'
import {safeFetch} from './safe-fetcher.js'
import {
type Registry,
type RegistryComponent,
registryURL,
} from "../const.js";
import { safeFetch } from "./safe-fetcher.js";
export async function getRegistry(
branch?: string,
): Promise<{message: string; ok: false} | {ok: true; registry: Registry}> {
const registryResponse = await safeFetch(registryURL(branch ?? 'main'))
): Promise<{ message: string; ok: false } | { ok: true; registry: Registry }> {
const registryResponse = await safeFetch(registryURL(branch ?? "main"));
if (registryResponse.ok) {
const registryJson = (await registryResponse.response.json()) as Registry
registryJson.base = registryJson.base.replace('{branch}', branch ?? 'main')
const registryJson = (await registryResponse.response.json()) as Registry;
registryJson.base = registryJson.base.replace("{branch}", branch ?? "main");
return {
ok: true,
registry: registryJson,
}
};
}
return registryResponse
return registryResponse;
}
export async function getComponentURL(
registry: Registry,
component: {type: 'file'} & RegistryComponent,
component: { type: "file" } & RegistryComponent,
): Promise<string> {
return registry.base + registry.paths.components.replace('{componentName}', component.name)
return (
registry.base +
registry.paths.components.replace("{componentName}", component.name)
);
}
export async function getDirComponentURL(
registry: Registry,
component: {type: 'dir'} & RegistryComponent,
component: { type: "dir" } & RegistryComponent,
files?: string[],
): Promise<[string, string][]> {
const base = registry.base + registry.paths.components.replace('{componentName}', component.name)
const base =
registry.base +
registry.paths.components.replace("{componentName}", component.name);
return (files ?? component.files).map((filename) => [filename, base + '/' + filename])
return (files ?? component.files).map((filename) => [
filename,
`${base}/${filename}`,
]);
}

View File

@ -1,19 +1,20 @@
import fetch, {Response} from 'node-fetch'
export async function safeFetch(
url: string,
): Promise<{message: string; ok: false; response: Response} | {ok: true; response: Response}> {
const response = await fetch(url)
): 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,
}
};
}

View File

@ -1,63 +1,69 @@
export async function jaroWinkler(a: string, b: string): Promise<number> {
const p = 0.1
const p = 0.1;
if (a.length === 0 || b.length === 0) return 0
if (a === b) return 1
if (a.length === 0 || b.length === 0) return 0;
if (a === b) return 1;
const range = Math.floor(Math.max(a.length, b.length) / 2) - 1
let matches = 0
const range = Math.floor(Math.max(a.length, b.length) / 2) - 1;
let matches = 0;
const aMatches = Array.from({length: a.length})
const bMatches = Array.from({length: b.length})
const aMatches = Array.from({ length: a.length });
const bMatches = Array.from({ length: b.length });
for (const [i, element] of Object.entries(a).map(
([index, element]) => [Number.parseInt(index, 10), element] as const,
)) {
const start = i >= range ? i - range : 0
const end = i + range <= b.length - 1 ? i + range : b.length - 1
const start = i >= range ? i - range : 0;
const end = i + range <= b.length - 1 ? i + range : b.length - 1;
for (let j = start; j <= end; j++) {
if (bMatches[j] !== true && element === b[j]) {
++matches
aMatches[i] = true
bMatches[j] = true
break
++matches;
aMatches[i] = true;
bMatches[j] = true;
break;
}
}
}
if (matches === 0) return 0
if (matches === 0) return 0;
let t = 0
let t = 0;
let point: number
let point: number;
for (point = 0; point < a.length; point++) if (aMatches[point]) break
for (point = 0; point < a.length; point++) if (aMatches[point]) break;
for (let i = point; i < a.length; i++)
if (aMatches[i]) {
let j
let j: number;
for (j = point; j < b.length; j++)
if (bMatches[j]) {
point = j + 1
break
point = j + 1;
break;
}
if (a[i] !== b[j]) ++t
if (a[i] !== b[j]) ++t;
}
t /= 2
t /= 2;
const J = (matches / a.length + matches / b.length + (matches - t) / matches) / 3
return J + Math.min((p * t) / matches, 1) * (1 - J)
const J =
(matches / a.length + matches / b.length + (matches - t) / matches) / 3;
return J + Math.min((p * t) / matches, 1) * (1 - J);
}
export async function getSuggestion(componentNames: string[], input: string): Promise<string[]> {
export async function getSuggestion(
componentNames: string[],
input: string,
): Promise<string[]> {
const componentJw = await Promise.all(
componentNames.map(async (name) => [name, await jaroWinkler(name, input)] as const),
)
componentNames.map(
async (name) => [name, await jaroWinkler(name, input)] as const,
),
);
return componentJw
.filter(([_, score]) => score > 0)
.sort((a, b) => b[1] - a[1])
.map(([name]) => name)
.map(([name]) => name);
}

View File

@ -1,2 +1,2 @@
export * from './public.js'
export {run} from '@oclif/core'
export * from "./public.js";
export { run } from "@oclif/core";

View File

@ -1,9 +1,9 @@
import {Config} from './const.js'
import type { Config } from "./const.js";
function buildConfig(config: Config): Config {
return config
return config;
}
export {buildConfig}
export { buildConfig };
export {Config} from './const.js'
export { Config } from "./const.js";

View File

@ -1,18 +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 },
],
'@typescript-eslint/no-unused-vars': [
"error",
{ varsIgnorePattern: '^_' }
]
},
}

View File

@ -23,4 +23,8 @@ dist-ssr
*.sln
*.sw?
*storybook.log
*storybook.log
src
src/main.tsx
!src/tailwind.css

View File

@ -1,7 +1,9 @@
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
import React from "react";
import { vcn, VariantProps, Slot, AsChild } from "@pswui-lib";
const colors = {
disabled:
"disabled:brightness-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:saturate-50",
outline: {
focus: "dark:focus-visible:outline-white/20 focus-visible:outline-black/10",
},
@ -12,21 +14,18 @@ const colors = {
danger: "border-red-400 dark:border-red-600",
},
background: {
default:
"bg-white dark:bg-black hover:bg-neutral-200 dark:hover:bg-neutral-800",
default: "bg-white dark:bg-black",
ghost:
"bg-black/0 dark:bg-white/0 hover:bg-black/20 dark:hover:bg-white/20",
success:
"bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800",
warning:
"bg-yellow-100 dark:bg-yellow-900 hover:bg-yellow-200 dark:hover:bg-yellow-800",
danger: "bg-red-100 dark:bg-red-900 hover:bg-red-200 dark:hover:bg-red-800",
success: "bg-green-100 dark:bg-green-900",
warning: "bg-yellow-100 dark:bg-yellow-900",
danger: "bg-red-100 dark:bg-red-900",
},
underline: "decoration-current",
};
const [buttonVariants, resolveVariants] = vcn({
base: `w-fit flex flex-row items-center justify-between rounded-md outline outline-1 outline-transparent outline-offset-2 ${colors.outline.focus} transition-all`,
base: `w-fit flex flex-row items-center justify-between rounded-md outline outline-1 outline-transparent outline-offset-2 hover:brightness-90 dark:hover:brightness-110 ${colors.outline.focus} ${colors.disabled} transition-all cursor-pointer`,
variants: {
size: {
link: "p-0 text-base",
@ -109,16 +108,22 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const [variantProps, otherPropsCompressed] = resolveVariants(props);
const { asChild, ...otherPropsExtracted } = otherPropsCompressed;
const { asChild, type, role, ...otherPropsExtracted } =
otherPropsCompressed;
const Comp = asChild ? Slot : "button";
const compProps = {
...otherPropsExtracted,
className: buttonVariants(variantProps),
};
return <Comp ref={ref} {...compProps} />;
return (
<Comp
ref={ref}
type={type ?? "button"}
className={buttonVariants(variantProps)}
role={role ?? "button"}
{...otherPropsExtracted}
/>
);
},
);
Button.displayName = "Button";
export { Button };

View File

@ -1,5 +1,5 @@
import { type VariantProps, vcn } from "@pswui-lib";
import React from "react";
import { VariantProps, vcn } from "@pswui-lib";
const checkboxColors = {
background: {
@ -18,12 +18,10 @@ const checkboxColors = {
disabledCheckedHover:
"has-[input[type='checkbox']:disabled:checked]:hover:bg-neutral-300 dark:has-[input[type='checkbox']:disabled:checked]:hover:bg-neutral-700",
},
checkmark:
"text-black dark:text-white has-[input[type=checkbox]:disabled]:text-neutral-400 dark:has-[input[type=checkbox]:disabled]:text-neutral-500",
};
const [checkboxVariant, resolveCheckboxVariantProps] = vcn({
base: `inline-block rounded-md ${checkboxColors.checkmark} ${checkboxColors.background.disabled} ${checkboxColors.background.default} ${checkboxColors.background.hover} ${checkboxColors.background.checked} ${checkboxColors.background.checkedHover} ${checkboxColors.background.disabledChecked} ${checkboxColors.background.disabledCheckedHover} has-[input[type="checkbox"]:disabled]:cursor-not-allowed transition-colors duration-75 ease-in-out`,
base: `inline-block rounded-md ${checkboxColors.background.disabled} ${checkboxColors.background.default} ${checkboxColors.background.hover} ${checkboxColors.background.checked} ${checkboxColors.background.checkedHover} ${checkboxColors.background.disabledChecked} ${checkboxColors.background.disabledCheckedHover} has-[input[type="checkbox"]:disabled]:cursor-not-allowed transition-colors duration-150 ease-in-out`,
variants: {
size: {
base: "size-[1em] p-0 [&>svg]:size-[1em]",
@ -92,22 +90,11 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
}
}}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
className={`${checked ? "opacity-100" : "opacity-0"} transition-opacity duration-75 ease-in-out`}
>
<path
fill="currentColor"
d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"
></path>
</svg>
</label>
</>
);
},
);
Checkbox.displayName = "Checkbox";
export { Checkbox };

View File

@ -1,12 +1,20 @@
import React, { useState } from "react";
import { Slot, VariantProps, vcn } from "@pswui-lib";
import {
Slot,
type VariantProps,
useAnimatedMount,
useDocument,
vcn,
} from "@pswui-lib";
import React, { type ReactNode, useId, useRef, useState } from "react";
import ReactDOM from "react-dom";
import {
DialogContext,
type IDialogContext,
InnerDialogContext,
initialDialogContext,
useDialogContext,
IDialogContext,
useInnerDialogContext,
} from "./Context";
/**
@ -20,7 +28,10 @@ interface DialogRootProps {
}
const DialogRoot = ({ children }: DialogRootProps) => {
const state = useState<IDialogContext>(initialDialogContext);
const state = useState<IDialogContext>({
...initialDialogContext,
ids: { dialog: useId(), title: useId(), description: useId() },
});
return (
<DialogContext.Provider value={state}>{children}</DialogContext.Provider>
);
@ -37,15 +48,17 @@ interface DialogTriggerProps {
}
const DialogTrigger = ({ children }: DialogTriggerProps) => {
const [_, setState] = useDialogContext();
const [{ ids }, setState] = useDialogContext();
const onClick = () => setState((p) => ({ ...p, opened: true }));
const slotProps = {
onClick,
children,
};
return <Slot {...slotProps} />;
return (
<Slot
onClick={onClick}
aria-controls={ids.dialog}
>
{children}
</Slot>
);
};
/**
@ -55,33 +68,15 @@ const DialogTrigger = ({ children }: DialogTriggerProps) => {
*/
const [dialogOverlayVariant, resolveDialogOverlayVariant] = vcn({
base: "fixed inset-0 z-50 w-full h-full max-w-screen transition-all duration-300 flex flex-col justify-center items-center",
base: "fixed inset-0 z-50 w-full h-screen overflow-y-auto max-w-screen transition-all duration-300 backdrop-blur-md backdrop-brightness-75 [&>div]:p-6",
variants: {
opened: {
true: "pointer-events-auto opacity-100",
false: "pointer-events-none opacity-0",
},
blur: {
sm: "backdrop-blur-sm",
md: "backdrop-blur-md",
lg: "backdrop-blur-lg",
},
darken: {
sm: "backdrop-brightness-90",
md: "backdrop-brightness-75",
lg: "backdrop-brightness-50",
},
padding: {
sm: "p-4",
md: "p-6",
lg: "p-8",
},
},
defaults: {
opened: false,
blur: "md",
darken: "md",
padding: "md",
},
});
@ -93,20 +88,36 @@ interface DialogOverlay
const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
(props, ref) => {
const [{ opened }, setContext] = useDialogContext();
const [variantProps, otherPropsCompressed] = resolveDialogOverlayVariant({
...props,
opened,
});
const [{ opened, ids }, setContext] = useDialogContext();
const [variantProps, otherPropsCompressed] =
resolveDialogOverlayVariant(props);
const { children, closeOnClick, onClick, ...otherPropsExtracted } =
otherPropsCompressed;
return (
<>
{ReactDOM.createPortal(
const internalRef = useRef<HTMLDivElement | null>(null);
const { isMounted, isRendered } = useAnimatedMount(opened, internalRef);
const document = useDocument();
if (!document) return null;
return isMounted
? ReactDOM.createPortal(
<div
{...otherPropsExtracted}
ref={ref}
className={dialogOverlayVariant(variantProps)}
id={ids.dialog}
ref={(el) => {
internalRef.current = el;
if (typeof ref === "function") {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
className={dialogOverlayVariant({
...variantProps,
opened: isRendered,
})}
onClick={(e) => {
if (closeOnClick) {
setContext((p) => ({ ...p, opened: false }));
@ -114,14 +125,23 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
onClick?.(e);
}}
>
{children}
{/* Layer for overflow positioning */}
<div
className={
"w-screen max-w-full min-h-screen flex flex-col justify-center items-center"
}
>
<InnerDialogContext.Provider value={{ isMounted, isRendered }}>
{children}
</InnerDialogContext.Provider>
</div>
</div>,
document.body,
)}
</>
);
)
: null;
},
);
DialogOverlay.displayName = "DialogOverlay";
/**
* =========================
@ -130,69 +150,51 @@ const DialogOverlay = React.forwardRef<HTMLDivElement, DialogOverlay>(
*/
const [dialogContentVariant, resolveDialogContentVariant] = vcn({
base: "transition-transform duration-300 bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800",
base: "transition-transform duration-300 bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800 p-6 w-full max-w-xl rounded-md flex flex-col justify-start items-start gap-6",
variants: {
opened: {
true: "scale-100",
false: "scale-50",
},
size: {
fit: "w-fit",
fullSm: "w-full max-w-sm",
fullMd: "w-full max-w-md",
fullLg: "w-full max-w-lg",
fullXl: "w-full max-w-xl",
full2xl: "w-full max-w-2xl",
},
rounded: {
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
xl: "rounded-xl",
},
padding: {
sm: "p-4",
md: "p-6",
lg: "p-8",
},
gap: {
sm: "space-y-4",
md: "space-y-6",
lg: "space-y-8",
},
},
defaults: {
opened: false,
size: "fit",
rounded: "md",
padding: "md",
gap: "md",
},
});
interface DialogContent
interface DialogContentProps
extends React.ComponentPropsWithoutRef<"div">,
Omit<VariantProps<typeof dialogContentVariant>, "opened"> {}
const DialogContent = React.forwardRef<HTMLDivElement, DialogContent>(
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
(props, ref) => {
const [{ opened }] = useDialogContext();
const [variantProps, otherPropsCompressed] = resolveDialogContentVariant({
...props,
opened,
});
const { children, ...otherPropsExtracted } = otherPropsCompressed;
const [{ ids }] = useDialogContext();
const [variantProps, otherPropsCompressed] =
resolveDialogContentVariant(props);
const { isRendered } = useInnerDialogContext();
const { children, onClick, ...otherPropsExtracted } = otherPropsCompressed;
return (
<div
{...otherPropsExtracted}
ref={ref}
className={dialogContentVariant(variantProps)}
role="dialog"
aria-labelledby={ids.title}
aria-describedby={ids.description}
className={dialogContentVariant({
...variantProps,
opened: isRendered,
})}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
>
{children}
</div>
);
},
);
DialogContent.displayName = "DialogContent";
/**
* =========================
@ -223,17 +225,9 @@ const DialogClose = ({ children }: DialogCloseProps) => {
*/
const [dialogHeaderVariant, resolveDialogHeaderVariant] = vcn({
base: "flex flex-col",
variants: {
gap: {
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
},
},
defaults: {
gap: "sm",
},
base: "flex flex-col gap-2",
variants: {},
defaults: {},
});
interface DialogHeaderProps
@ -257,6 +251,8 @@ const DialogHeader = React.forwardRef<HTMLElement, DialogHeaderProps>(
},
);
DialogHeader.displayName = "DialogHeader";
/**
* =========================
* DialogTitle / DialogSubtitle
@ -264,91 +260,69 @@ const DialogHeader = React.forwardRef<HTMLElement, DialogHeaderProps>(
*/
const [dialogTitleVariant, resolveDialogTitleVariant] = vcn({
variants: {
size: {
sm: "text-lg",
md: "text-xl",
lg: "text-2xl",
},
weight: {
sm: "font-medium",
md: "font-semibold",
lg: "font-bold",
},
},
defaults: {
size: "md",
weight: "lg",
},
base: "text-xl font-bold",
variants: {},
defaults: {},
});
interface DialogTitleProps
extends React.ComponentPropsWithoutRef<"h1">,
VariantProps<typeof dialogTitleVariant> {}
const [dialogSubtitleVariant, resolveDialogSubtitleVariant] = vcn({
variants: {
size: {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
opacity: {
sm: "opacity-60",
md: "opacity-70",
lg: "opacity-80",
},
weight: {
sm: "font-light",
md: "font-normal",
lg: "font-medium",
},
},
defaults: {
size: "sm",
opacity: "sm",
weight: "md",
},
const [dialogDescriptionVariant, resolveDialogDescriptionVariant] = vcn({
base: "text-sm opacity-60 font-normal",
variants: {},
defaults: {},
});
interface DialogSubtitleProps
interface DialogDescriptionProps
extends React.ComponentPropsWithoutRef<"h2">,
VariantProps<typeof dialogSubtitleVariant> {}
VariantProps<typeof dialogDescriptionVariant> {}
const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
(props, ref) => {
const [variantProps, otherPropsCompressed] =
resolveDialogTitleVariant(props);
const { children, ...otherPropsExtracted } = otherPropsCompressed;
const [{ ids }] = useDialogContext();
return (
<h1
{...otherPropsExtracted}
ref={ref}
className={dialogTitleVariant(variantProps)}
id={ids.title}
>
{children}
</h1>
);
},
);
DialogTitle.displayName = "DialogTitle";
const DialogSubtitle = React.forwardRef<
const DialogDescription = React.forwardRef<
HTMLHeadingElement,
DialogSubtitleProps
DialogDescriptionProps
>((props, ref) => {
const [variantProps, otherPropsCompressed] =
resolveDialogSubtitleVariant(props);
resolveDialogDescriptionVariant(props);
const { children, ...otherPropsExtracted } = otherPropsCompressed;
const [{ ids }] = useDialogContext();
return (
<h2
{...otherPropsExtracted}
ref={ref}
className={dialogSubtitleVariant(variantProps)}
className={dialogDescriptionVariant(variantProps)}
id={ids.description}
>
{children}
</h2>
);
});
DialogDescription.displayName = "DialogDescription";
// renamed DialogSubtitle -> DialogDescription
// keep DialogSubtitle for backward compatibility
const DialogSubtitle = DialogDescription;
/**
* =========================
@ -357,17 +331,9 @@ const DialogSubtitle = React.forwardRef<
*/
const [dialogFooterVariant, resolveDialogFooterVariant] = vcn({
base: "flex flex-col items-end sm:flex-row sm:items-center sm:justify-end",
variants: {
gap: {
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
},
},
defaults: {
gap: "md",
},
base: "flex w-full flex-col items-end sm:flex-row sm:items-center sm:justify-end gap-2",
variants: {},
defaults: {},
});
interface DialogFooterProps
@ -380,16 +346,44 @@ const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
resolveDialogFooterVariant(props);
const { children, ...otherPropsExtracted } = otherPropsCompressed;
return (
<div
<footer
{...otherPropsExtracted}
ref={ref}
className={dialogFooterVariant(variantProps)}
>
{children}
</div>
</footer>
);
},
);
DialogFooter.displayName = "DialogFooter";
interface DialogControllers {
context: IDialogContext;
setContext: React.Dispatch<React.SetStateAction<IDialogContext>>;
close: () => void;
}
interface DialogControllerProps {
children: (controllers: DialogControllers) => ReactNode;
}
const DialogController = (props: DialogControllerProps) => {
return (
<DialogContext.Consumer>
{([context, setContext]) =>
props.children({
context,
setContext,
close() {
setContext((p) => ({ ...p, opened: false }));
},
})
}
</DialogContext.Consumer>
);
};
export {
DialogRoot,
@ -400,5 +394,7 @@ export {
DialogHeader,
DialogTitle,
DialogSubtitle,
DialogDescription,
DialogFooter,
DialogController,
};

View File

@ -1,4 +1,9 @@
import { Dispatch, SetStateAction, useContext, createContext } from "react";
import {
type Dispatch,
type SetStateAction,
createContext,
useContext,
} from "react";
/**
* =========================
@ -8,9 +13,17 @@ import { Dispatch, SetStateAction, useContext, createContext } from "react";
export interface IDialogContext {
opened: boolean;
ids: {
dialog: string;
title: string;
description: string;
};
}
export const initialDialogContext: IDialogContext = { opened: false };
export const initialDialogContext: IDialogContext = {
opened: false,
ids: { title: "", dialog: "", description: "" },
};
export const DialogContext = createContext<
[IDialogContext, Dispatch<SetStateAction<IDialogContext>>]
>([
@ -25,3 +38,23 @@ export const DialogContext = createContext<
]);
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);

View File

@ -1,13 +1,20 @@
import {
type AsChild,
Slot,
type VariantProps,
useAnimatedMount,
useDocument,
vcn,
} from "@pswui-lib";
import React, {
ComponentPropsWithoutRef,
TouchEvent as ReactTouchEvent,
type ComponentPropsWithoutRef,
type TouchEvent as ReactTouchEvent,
forwardRef,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
import { createPortal } from "react-dom";
interface IDrawerContext {
@ -15,6 +22,8 @@ interface IDrawerContext {
closeThreshold: number;
movePercentage: number;
isDragging: boolean;
isMounted: boolean;
isRendered: boolean;
leaveWhileDragging: boolean;
}
const DrawerContextInitial: IDrawerContext = {
@ -22,6 +31,8 @@ const DrawerContextInitial: IDrawerContext = {
closeThreshold: 0.3,
movePercentage: 0,
isDragging: false,
isMounted: false,
isRendered: false,
leaveWhileDragging: false,
};
const DrawerContext = React.createContext<
@ -57,8 +68,7 @@ const DrawerRoot = ({ children, closeThreshold, opened }: DrawerRootProps) => {
opened: opened ?? prev.opened,
closeThreshold: closeThreshold ?? prev.closeThreshold,
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [closeThreshold, opened]);
}, [closeThreshold, opened, setState]);
return (
<DrawerContext.Provider value={state}>{children}</DrawerContext.Provider>
@ -79,7 +89,7 @@ const [drawerOverlayVariant, resolveDrawerOverlayVariantProps] = vcn({
base: "fixed inset-0 transition-[backdrop-filter] duration-75",
variants: {
opened: {
true: "pointer-events-auto select-auto",
true: "pointer-events-auto select-auto touch-none", // touch-none to prevent outside scrolling
false: "pointer-events-none select-none",
},
},
@ -97,8 +107,14 @@ interface DrawerOverlayProps
const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
(props, ref) => {
const internalRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useContext(DrawerContext);
const { isMounted, isRendered } = useAnimatedMount(
state.isDragging ? true : state.opened,
internalRef,
);
const [variantProps, restPropsCompressed] =
resolveDrawerOverlayVariantProps(props);
const { asChild, ...restPropsExtracted } = restPropsCompressed;
@ -120,25 +136,46 @@ const DrawerOverlay = forwardRef<HTMLDivElement, DrawerOverlayProps>(
: 1
})`;
return createPortal(
<Comp
{...restPropsExtracted}
className={drawerOverlayVariant({
...variantProps,
opened: state.isDragging ? true : state.opened,
})}
onClick={onOutsideClick}
style={{
backdropFilter,
WebkitBackdropFilter: backdropFilter,
transitionDuration: state.isDragging ? "0s" : undefined,
}}
ref={ref}
/>,
document.body,
const document = useDocument();
if (!document) return null;
return (
<>
<DrawerContext.Provider
value={[{ ...state, isMounted, isRendered }, setState]}
>
{isMounted
? createPortal(
<Comp
{...restPropsExtracted}
className={drawerOverlayVariant({
...variantProps,
opened: isRendered,
})}
onClick={onOutsideClick}
style={{
backdropFilter,
WebkitBackdropFilter: backdropFilter,
transitionDuration: state.isDragging ? "0s" : undefined,
}}
ref={(el: HTMLDivElement) => {
internalRef.current = el;
if (typeof ref === "function") {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
/>,
document.body,
)
: null}
</DrawerContext.Provider>
</>
);
},
);
DrawerOverlay.displayName = "DrawerOverlay";
const drawerContentColors = {
background: "bg-white dark:bg-black",
@ -146,28 +183,55 @@ const drawerContentColors = {
};
const [drawerContentVariant, resolveDrawerContentVariantProps] = vcn({
base: `fixed ${drawerContentColors.background} ${drawerContentColors.border} transition-all p-4 flex flex-col justify-between gap-8`,
base: `fixed ${drawerContentColors.background} ${drawerContentColors.border} transition-all p-4 flex flex-col justify-between gap-8 overflow-auto`,
variants: {
position: {
top: "top-0 inset-x-0 w-full max-w-screen rounded-t-lg border-b-2",
bottom: "bottom-0 inset-x-0 w-full max-w-screen rounded-b-lg border-t-2",
left: "left-0 inset-y-0 h-screen rounded-l-lg border-r-2",
right: "right-0 inset-y-0 h-screen rounded-r-lg border-l-2",
top: "top-0 w-full max-w-screen rounded-t-lg border-b-2",
bottom: "bottom-0 w-full max-w-screen rounded-b-lg border-t-2",
left: "left-0 h-screen rounded-l-lg border-r-2",
right: "right-0 h-screen rounded-r-lg border-l-2",
},
maxSize: {
sm: "[&.left-0]:max-w-sm [&.right-0]:max-w-sm",
md: "[&.left-0]:max-w-md [&.right-0]:max-w-md",
lg: "[&.left-0]:max-w-lg [&.right-0]:max-w-lg",
xl: "[&.left-0]:max-w-xl [&.right-0]:max-w-xl",
},
opened: {
true: "touch-none",
true: "",
false:
"[&.top-0]:-translate-y-full [&.bottom-0]:translate-y-full [&.left-0]:-translate-x-full [&.right-0]:translate-x-full",
},
internal: {
true: "relative",
false: "fixed",
},
},
defaults: {
position: "left",
opened: false,
maxSize: "sm",
internal: false,
},
dynamics: [
({ position, internal }) => {
if (!internal) {
if (["top", "bottom"].includes(position)) {
return "inset-x-0";
}
return "inset-y-0";
}
return "w-fit";
},
],
});
interface DrawerContentProps
extends Omit<VariantProps<typeof drawerContentVariant>, "opened">,
extends Omit<
VariantProps<typeof drawerContentVariant>,
"opened" | "internal"
>,
AsChild,
ComponentPropsWithoutRef<"div"> {}
@ -302,16 +366,15 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
window.removeEventListener("touchmove", onMouseMove);
window.removeEventListener("touchend", onMouseUp);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, dragState, position]);
}, [state, setState, dragState, position]);
return (
<div
className={drawerContentVariant({
...variantProps,
opened: true,
opened: state.isRendered,
className: dragState.isDragging
? "transition-[width_0ms]"
? "transition-[width] duration-0"
: variantProps.className,
})}
style={
@ -323,6 +386,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
0) +
(position === "top" ? dragState.delta : -dragState.delta),
padding: 0,
[`padding${position.slice(0, 1).toUpperCase()}${position.slice(1)}`]: `${dragState.delta}px`,
}
: {
width:
@ -330,6 +394,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
0) +
(position === "left" ? dragState.delta : -dragState.delta),
padding: 0,
[`padding${position.slice(0, 1).toUpperCase()}${position.slice(1)}`]: `${dragState.delta}px`,
}
: { width: 0, height: 0, padding: 0 }
}
@ -338,14 +403,16 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
{...restPropsExtracted}
className={drawerContentVariant({
...variantProps,
opened: state.opened,
opened: state.isRendered,
internal: true,
})}
style={{
transform: dragState.isDragging
? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${
dragState.delta
}px)`
: undefined,
transform:
dragState.isDragging &&
((["top", "left"].includes(position) && dragState.delta < 0) ||
(["bottom", "right"].includes(position) && dragState.delta > 0))
? `translate${["top", "bottom"].includes(position) ? "Y" : "X"}(${dragState.delta}px)`
: undefined,
transitionDuration: dragState.isDragging ? "0s" : undefined,
userSelect: dragState.isDragging ? "none" : undefined,
}}
@ -376,6 +443,7 @@ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
);
},
);
DrawerContent.displayName = "DrawerContent";
const DrawerClose = forwardRef<
HTMLButtonElement,
@ -390,6 +458,7 @@ const DrawerClose = forwardRef<
/>
);
});
DrawerClose.displayName = "DrawerClose";
const [drawerHeaderVariant, resolveDrawerHeaderVariantProps] = vcn({
base: "flex flex-col gap-2",
@ -419,9 +488,10 @@ const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
);
},
);
DrawerHeader.displayName = "DrawerHeader";
const [drawerBodyVariant, resolveDrawerBodyVariantProps] = vcn({
base: "flex-grow",
base: "grow",
variants: {},
defaults: {},
});
@ -446,6 +516,7 @@ const DrawerBody = forwardRef<HTMLDivElement, DrawerBodyProps>((props, ref) => {
/>
);
});
DrawerBody.displayName = "DrawerBody";
const [drawerFooterVariant, resolveDrawerFooterVariantProps] = vcn({
base: "flex flex-row justify-end gap-2",
@ -475,6 +546,7 @@ const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
);
},
);
DrawerFooter.displayName = "DrawerFooter";
export {
DrawerRoot,

View 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 };

View File

@ -1,37 +1,38 @@
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
import React from "react";
import { VariantProps, vcn } from "@pswui-lib";
const inputColors = {
background: {
default: "bg-neutral-50 dark:bg-neutral-900",
hover: "hover:bg-neutral-100 dark:hover:bg-neutral-800",
hover:
"hover:bg-neutral-100 dark:hover:bg-neutral-800 has-[input:hover]:bg-neutral-100 dark:has-[input:hover]:bg-neutral-800",
invalid:
"invalid:bg-red-100 invalid:dark:bg-red-900 has-[input:invalid]:bg-red-100 dark:has-[input:invalid]:bg-red-900",
"invalid:bg-red-100 dark:invalid:bg-red-900 has-[input:invalid]:bg-red-100 dark:has-[input:invalid]:bg-red-900",
invalidHover:
"hover:invalid:bg-red-200 dark:hover:invalid:bg-red-800 has-[input:invalid:hover]:bg-red-200 dark:has-[input:invalid:hover]:bg-red-800",
},
border: {
default: "border-neutral-400 dark:border-neutral-600",
invalid:
"invalid:border-red-400 invalid:dark:border-red-600 has-[input:invalid]:border-red-400 dark:has-[input:invalid]:border-red-600",
"invalid:border-red-400 dark:invalid:border-red-600 has-[input:invalid]:border-red-400 dark:has-[input:invalid]:border-red-600",
},
ring: {
default: "ring-transparent focus-within:ring-current",
invalid:
"invalid:focus-within:ring-red-400 invalid:focus-within:dark:ring-red-600 has-[input:invalid]:focus-within:ring-red-400 dark:has-[input:invalid]:focus-within:ring-red-600",
"invalid:focus-within:ring-red-400 dark:invalid:focus-within:ring-red-600 has-[input:invalid]:focus-within:ring-red-400 dark:has-[input:invalid]:focus-within:ring-red-600",
},
};
const [inputVariant, resolveInputVariantProps] = vcn({
base: `rounded-md p-2 border ring-1 outline-none transition-all duration-200 [appearance:textfield] disabled:brightness-50 disabled:saturate-0 disabled:cursor-not-allowed ${inputColors.background.default} ${inputColors.background.hover} ${inputColors.border.default} ${inputColors.ring.default} ${inputColors.background.invalid} ${inputColors.background.invalidHover} ${inputColors.border.invalid} ${inputColors.ring.invalid} [&:has(input)]:flex [&:has(input)]:w-fit`,
base: `rounded-md p-2 border ring-1 outline-hidden transition-all duration-200 [appearance:textfield] disabled:brightness-50 disabled:saturate-0 disabled:cursor-not-allowed ${inputColors.background.default} ${inputColors.background.hover} ${inputColors.border.default} ${inputColors.ring.default} ${inputColors.background.invalid} ${inputColors.background.invalidHover} ${inputColors.border.invalid} ${inputColors.ring.invalid} [&:has(input)]:flex`,
variants: {
unstyled: {
true: "bg-transparent border-none p-0 ring-0 hover:bg-transparent invalid:hover:bg-transparent invalid:focus-within:bg-transparent invalid:focus-within:ring-0",
false: "",
},
full: {
true: "w-full",
false: "w-fit",
true: "[&:has(input)]:w-full w-full",
false: "[&:has(input)]:w-fit w-fit",
},
},
defaults: {
@ -42,7 +43,8 @@ const [inputVariant, resolveInputVariantProps] = vcn({
interface InputFrameProps
extends VariantProps<typeof inputVariant>,
React.ComponentPropsWithoutRef<"label"> {
React.ComponentPropsWithoutRef<"label">,
AsChild {
children?: React.ReactNode;
}
@ -50,19 +52,22 @@ const InputFrame = React.forwardRef<HTMLLabelElement, InputFrameProps>(
(props, ref) => {
const [variantProps, otherPropsCompressed] =
resolveInputVariantProps(props);
const { children, ...otherPropsExtracted } = otherPropsCompressed;
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
const Comp = asChild ? Slot : "label";
return (
<label
<Comp
ref={ref}
className={`group/input-frame ${inputVariant(variantProps)}`}
{...otherPropsExtracted}
>
{children}
</label>
</Comp>
);
},
);
InputFrame.displayName = "InputFrame";
interface InputProps
extends VariantProps<typeof inputVariant>,
@ -92,7 +97,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const innerRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
if (innerRef && innerRef.current) {
if (innerRef?.current) {
innerRef.current.setCustomValidity(invalid ?? "");
}
}, [invalid]);
@ -113,5 +118,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
/>
);
});
Input.displayName = "Input";
export { InputFrame, Input };

View File

@ -1,5 +1,5 @@
import { type VariantProps, vcn } from "@pswui-lib";
import React from "react";
import { VariantProps, vcn } from "@pswui-lib";
const [labelVariant, resolveLabelVariantProps] = vcn({
base: "has-[input[disabled]]:brightness-75 has-[input[disabled]]:cursor-not-allowed has-[input:invalid]:text-red-500",
@ -29,5 +29,6 @@ const Label = React.forwardRef<HTMLLabelElement, LabelProps>((props, ref) => {
/>
);
});
Label.displayName = "Label";
export { Label };

View File

@ -1,7 +1,8 @@
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
import React, { useContext, useEffect, useRef } from "react";
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
interface IPopoverContext {
controlled: boolean;
opened: boolean;
}
@ -10,6 +11,7 @@ const PopoverContext = React.createContext<
>([
{
opened: false,
controlled: false,
},
() => {
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
@ -26,14 +28,23 @@ interface PopoverProps extends AsChild {
}
const Popover = ({ children, opened, asChild }: PopoverProps) => {
const state = React.useState<IPopoverContext>({
const [state, setState] = React.useState<IPopoverContext>({
opened: opened ?? false,
controlled: opened !== undefined,
});
useEffect(() => {
setState((p) => ({
...p,
controlled: opened !== undefined,
opened: opened !== undefined ? opened : p.opened,
}));
}, [opened]);
const Comp = asChild ? Slot : "div";
return (
<PopoverContext.Provider value={state}>
<PopoverContext.Provider value={[state, setState]}>
<Comp className="relative">{children}</Comp>
</PopoverContext.Provider>
);
@ -54,26 +65,25 @@ const popoverColors = {
};
const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
base: `absolute transition-all duration-150 border rounded-lg p-0.5 [&>*]:w-full ${popoverColors.background} ${popoverColors.border}`,
base: `absolute transition-all duration-150 border rounded-lg p-0.5 z-10 *:w-full ${popoverColors.background} ${popoverColors.border}`,
variants: {
direction: {
row: "",
col: "",
},
anchor: {
topLeft:
"bottom-[calc(100%+var(--popover-offset))] right-[calc(100%+var(--popover-offset))] origin-bottom-right",
topCenter:
"bottom-[calc(100%+var(--popover-offset))] left-1/2 -translate-x-1/2 origin-bottom-center",
topRight:
"bottom-[calc(100%+var(--popover-offset))] left-[calc(100%+var(--popover-offset))] origin-bottom-left",
middleLeft: "top-1/2 translate-y-1/2 right-full origin-right",
middleCenter:
"top-1/2 translate-y-1/2 left-1/2 -translate-x-1/2 origin-center",
middleRight:
"top-1/2 translate-y-1/2 left-[calc(100%+var(--popover-offset))] origin-left",
bottomLeft:
"top-[calc(100%+var(--popover-offset))] right-[calc(100%+var(--popover-offset))] origin-top-right",
bottomCenter:
"top-[calc(100%+var(--popover-offset))] left-1/2 -translate-x-1/2 origin-top-center",
bottomRight:
"top-[calc(100%+var(--popover-offset))] left-[calc(100%+var(--popover-offset))] origin-top-left",
start: "",
middle: "",
end: "",
},
align: {
start: "",
middle: "",
end: "",
},
position: {
start: "",
end: "",
},
offset: {
sm: "[--popover-offset:2px]",
@ -81,15 +91,104 @@ const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
lg: "[--popover-offset:8px]",
},
opened: {
true: "opacity-1 scale-100",
false: "opacity-0 scale-75",
true: "opacity-1 scale-100 pointer-events-auto select-auto touch-auto",
false: "opacity-0 scale-75 pointer-events-none select-none touch-none",
},
},
defaults: {
anchor: "bottomCenter",
direction: "col",
anchor: "middle",
align: "middle",
position: "end",
opened: false,
offset: "md",
},
dynamics: [
function originClass({ direction, anchor, position }) {
switch (`${direction} ${position} ${anchor}` as const) {
// left
case "row start start":
return "origin-top-right";
case "row start middle":
return "origin-right";
case "row start end":
return "origin-bottom-right";
// right
case "row end start":
return "origin-top-left";
case "row end middle":
return "origin-left";
case "row end end":
return "origin-bottom-left";
// top
case "col start start":
return "origin-bottom-left";
case "col start middle":
return "origin-bottom";
case "col start end":
return "origin-bottom-right";
// bottom
case "col end start":
return "origin-top-left";
case "col end middle":
return "origin-top";
case "col end end":
return "origin-top-right";
}
},
function basePositionClass({ position, direction }) {
switch (`${direction} ${position}` as const) {
case "col start":
return "bottom-[calc(100%+var(--popover-offset))]";
case "col end":
return "top-[calc(100%+var(--popover-offset))]";
case "row start":
return "right-[calc(100%+var(--popover-offset))]";
case "row end":
return "left-[calc(100%+var(--popover-offset))]";
}
},
function directionPositionClass({ direction, anchor, align }) {
switch (`${direction} ${anchor} ${align}` as const) {
case "col start start":
return "left-0";
case "col start middle":
return "left-1/2";
case "col start end":
return "left-full";
case "col middle start":
return "left-0 -translate-x-1/2";
case "col middle middle":
return "left-1/2 -translate-x-1/2";
case "col middle end":
return "right-0 translate-x-1/2";
case "col end start":
return "right-full";
case "col end middle":
return "right-1/2";
case "col end end":
return "right-0";
case "row start start":
return "top-0";
case "row start middle":
return "top-1/2";
case "row start end":
return "top-full";
case "row middle start":
return "top-0 -translate-y-1/2";
case "row middle middle":
return "top-1/2 -translate-y-1/2";
case "row middle end":
return "bottom-0 translate-y-1/2";
case "row end start":
return "bottom-full";
case "row end middle":
return "bottom-1/2";
case "row end end":
return "bottom-0";
}
},
],
});
interface PopoverContentProps
@ -101,7 +200,7 @@ const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
(props, ref) => {
const [variantProps, otherPropsCompressed] =
resolvePopoverContentVariantProps(props);
const { children, ...otherPropsExtracted } = otherPropsCompressed;
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
const [state, setState] = useContext(PopoverContext);
const internalRef = useRef<HTMLDivElement | null>(null);
@ -115,28 +214,36 @@ const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
setState((prev) => ({ ...prev, opened: false }));
}
}
document.addEventListener("mousedown", handleOutsideClick);
!state.controlled &&
document.addEventListener("mousedown", handleOutsideClick);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [internalRef, setState]);
}, [state.controlled, setState]);
const Comp = asChild ? Slot : "div";
return (
<div
<Comp
{...otherPropsExtracted}
className={popoverContentVariant({
...variantProps,
opened: state.opened,
})}
ref={(el) => {
ref={(el: HTMLDivElement) => {
internalRef.current = el;
typeof ref === "function" ? ref(el) : ref && (ref.current = el);
if (typeof ref === "function") {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
>
{children}
</div>
</Comp>
);
},
);
PopoverContent.displayName = "PopoverContent";
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -1,5 +1,5 @@
import { type VariantProps, vcn } from "@pswui-lib";
import React from "react";
import { VariantProps, vcn } from "@pswui-lib";
const switchColors = {
background: {
@ -80,5 +80,6 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>((props, ref) => {
</label>
);
});
Switch.displayName = "Switch";
export { Switch };

View File

@ -1,7 +1,7 @@
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
import React from "react";
import { TabContextBody, TabContext, Tab } from "./Context";
import { type Tab, TabContext, type TabContextBody } from "./Context";
interface TabProviderProps {
defaultName: string;
@ -30,11 +30,16 @@ interface TabListProps
const TabList = (props: TabListProps) => {
const [variantProps, restProps] = resolveTabListVariantProps(props);
return <div className={TabListVariant(variantProps)} {...restProps} />;
return (
<div
className={TabListVariant(variantProps)}
{...restProps}
/>
);
};
const [TabTriggerVariant, resolveTabTriggerVariantProps] = vcn({
base: "py-1.5 rounded-md flex-grow transition-all text-sm",
base: "py-1.5 rounded-md grow transition-all text-sm",
variants: {
active: {
true: "bg-white/100 dark:bg-black/100 text-black dark:text-white",
@ -76,7 +81,7 @@ const TabTrigger = (props: TabTriggerProps) => {
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name]);
}, [name, setContext]);
const Comp = props.asChild ? Slot : "button";
@ -119,18 +124,18 @@ const TabContent = (props: TabContentProps) => {
const { name, ...restProps } = restPropsBeforeParse;
const [context] = React.useContext(TabContext);
if (context.active[1] === name) {
return (
<Slot
className={tabContentVariant({
...variantProps,
})}
{...restProps}
/>
);
} else {
if (context.active[1] !== name) {
return null;
}
return (
<Slot
className={tabContentVariant({
...variantProps,
})}
{...restProps}
/>
);
};
export { TabProvider, TabList, TabTrigger, TabContent };

View File

@ -1,20 +1,25 @@
import React, { useEffect, useId, useRef } from "react";
import ReactDOM from "react-dom";
import { VariantProps, vcn } from "@pswui-lib";
import { toastVariant } from "./Variant";
import {
ToastOption,
toasts,
subscribeSingle,
getSingleSnapshot,
notifySingle,
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,
notify,
defaultToastOption,
subscribe,
getSingleSnapshot,
getSnapshot,
notify,
notifySingle,
subscribe,
subscribeSingle,
toasts,
} from "./Store";
import { toastVariant } from "./Variant";
const ToastTemplate = ({
id,
@ -41,9 +46,16 @@ const ToastTemplate = ({
React.useEffect(() => {
if (toastData.life === "born") {
requestAnimationFrame(() => {
// To make sure that the toast is rendered as "born" state
// and then change to "normal" state
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",
@ -58,45 +70,18 @@ const ToastTemplate = ({
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,
let calculatedTransitionDurationMs = 1;
if (ref.current)
calculatedTransitionDurationMs = getCalculatedTransitionDuration(
ref as MutableRefObject<HTMLDivElement>,
);
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);
}, calculatedTransitionDurationMs);
return () => clearTimeout(timeout);
}
}, [id, toastData.life, toastData.closeTimeout, toastData.closeButton]);
}, [id, toastData.life, toastData.closeTimeout]);
return (
<div
@ -105,15 +90,22 @@ const ToastTemplate = ({
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)}>
<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"
@ -163,11 +155,15 @@ const Toaster = React.forwardRef<HTMLDivElement, ToasterProps>((props, ref) => {
};
}, [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.`,
"Multiple Toaster instances detected. Only one Toaster is allowed.",
);
}
return null;
@ -203,5 +199,6 @@ const Toaster = React.forwardRef<HTMLDivElement, ToasterProps>((props, ref) => {
</>
);
});
Toaster.displayName = "Toaster";
export { Toaster };

View File

@ -1,4 +1,4 @@
import { addToast, update, close } from "./Store";
import { addToast, close, update } from "./Store";
export function useToast() {
return {

View File

@ -1,4 +1,4 @@
import { ToastBody } from "./Variant";
import type { ToastBody } from "./Variant";
export interface ToastOption {
closeButton: boolean;
@ -54,11 +54,11 @@ export function getSingleSnapshot(id: `${number}`) {
}
export function notify() {
subscribers.forEach((subscriber) => subscriber());
for (const subscriber of subscribers) subscriber();
}
export function notifySingle(id: `${number}`) {
toasts[id].subscribers.forEach((subscriber) => subscriber());
for (const subscriber of toasts[id].subscribers) subscriber();
}
export function close(id: `${number}`) {

View File

@ -1,4 +1,4 @@
import { VariantProps, vcn } from "@pswui-lib";
import { type VariantProps, vcn } from "@pswui-lib";
const toastColors = {
background: "bg-white dark:bg-black",

View File

@ -0,0 +1,2 @@
export { Toaster } from "./Component";
export { useToast } from "./Hook";

View File

@ -1,5 +1,5 @@
import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
import React, { useState } from "react";
import { AsChild, Slot, VariantProps, vcn } from "@pswui-lib";
interface TooltipContextBody {
position: "top" | "bottom" | "left" | "right";
@ -71,6 +71,7 @@ const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>((props, ref) => {
</TooltipContext.Provider>
);
});
Tooltip.displayName = "Tooltip";
const tooltipContentColors = {
variants: {
@ -83,10 +84,10 @@ const tooltipContentColors = {
};
const [tooltipContentVariant, resolveTooltipContentVariantProps] = vcn({
base: `absolute py-1 px-3 rounded-md border opacity-0 transition-all
group-[:not(.controlled):hover]/tooltip:opacity-100 group-[.opened]/tooltip:opacity-100
select-none pointer-events-none
group-[:not(.controlled):hover]/tooltip:select-auto group-[.opened]/tooltip:select-auto group-[:not(.controlled):hover]/tooltip:pointer-events-auto group-[.opened]/tooltip:pointer-events-auto
base: `absolute py-1 px-3 rounded-md border opacity-0 transition-all
group-[:not(.controlled):hover]/tooltip:opacity-100 group-[.opened]/tooltip:opacity-100
select-none pointer-events-none
group-[:not(.controlled):hover]/tooltip:select-auto group-[.opened]/tooltip:select-auto group-[:not(.controlled):hover]/tooltip:pointer-events-auto group-[.opened]/tooltip:pointer-events-auto
group-[:not(.controlled):hover]/tooltip:[transition:transform_150ms_ease-out_var(--delay),opacity_150ms_ease-out_var(--delay),background-color_150ms_ease-in-out,color_150ms_ease-in-out,border-color_150ms_ease-in-out]`,
variants: {
position: {
@ -139,10 +140,12 @@ const TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(
...variantProps,
position: contextState.position,
})}
role="tooltip"
{...rest}
/>
);
},
);
TooltipContent.displayName = "TooltipContent";
export { Tooltip, TooltipContent };

View File

@ -1,5 +1,5 @@
import { twMerge } from "tailwind-merge";
import React from "react";
import { twMerge } from "tailwind-merge";
/**
* Merges the react props.
@ -60,14 +60,15 @@ function mergeReactProps(
* @returns The single ref.
*/
function combinedRef<I>(refs: React.Ref<I | null>[]) {
return (instance: I | null) =>
refs.forEach((ref) => {
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 {
@ -84,7 +85,10 @@ export const Slot = React.forwardRef<
return null;
}
return React.cloneElement(children, {
...mergeReactProps(safeSlotProps, children.props),
...mergeReactProps(
safeSlotProps,
children.props as Record<string, unknown>,
),
ref: combinedRef([
ref,
(children as unknown as { ref: React.Ref<HTMLElement> }).ref,

View File

@ -1,2 +1,4 @@
export * from "./vcn";
export * from "./Slot";
export * from "./useDocument";
export * from "./useAnimatedMount";

View 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 };

View 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 };

View File

@ -57,6 +57,31 @@ type VariantKV<V extends VariantType> = {
[VariantKey in keyof V]: BooleanString<keyof V[VariantKey] & string>;
};
/**
* Used for safely casting `Object.entries(<VariantKV>)`
*/
type VariantKVEntry<V extends VariantType> = [
keyof V,
BooleanString<keyof V[keyof V] & string>,
][];
/**
* Takes VariantKV as parameter, return className string.
*
* @example
* vcn({
* /* ... *\/
* dynamics: [
* ({ a, b }) => {
* return a === "something" ? "asdf" : b
* },
* ]
* })
*/
type DynamicClassName<V extends VariantType> = (
variantProps: VariantKV<V>,
) => string;
/**
* Takes VariantType, and returns a type that represents the preset object.
*
@ -94,6 +119,7 @@ export function vcn<V extends VariantType>(param: {
*/
base?: string | undefined;
variants: V;
dynamics?: DynamicClassName<V>[];
defaults: VariantKV<V>;
presets?: undefined;
}): [
@ -108,7 +134,7 @@ export function vcn<V extends VariantType>(param: {
/**
* Any Props -> Variant Props, Other Props
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: using unknown causes error `Index signature for type 'string' is missing in type --Props`.
<AnyPropBeforeResolve extends Record<string, any>>(
anyProps: AnyPropBeforeResolve,
) => [
@ -124,6 +150,7 @@ export function vcn<V extends VariantType, P extends PresetType<V>>(param: {
*/
base?: string | undefined;
variants: V /* VariantType */;
dynamics?: DynamicClassName<V>[];
defaults: VariantKV<V>;
presets: P;
}): [
@ -139,7 +166,7 @@ export function vcn<V extends VariantType, P extends PresetType<V>>(param: {
/**
* Any Props -> Variant Props, Other Props
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: using unknown causes error `Index signature for type 'string' is missing in type --Props`.
<AnyPropBeforeResolve extends Record<string, any>>(
anyProps: AnyPropBeforeResolve,
) => [
@ -159,19 +186,58 @@ export function vcn<
>({
base,
variants,
dynamics = [],
defaults,
presets,
}: {
base?: string | undefined;
variants: V;
dynamics?: DynamicClassName<V>[];
defaults: VariantKV<V>;
presets?: P;
}) {
/**
* --Internal utility function--
* After transforming props to final version (which means "after overriding default, preset, and variant props sent via component props")
* It turns final version of variant props to className
*/
function __transformer__(
final: VariantKV<V>,
dynamics: string[],
propClassName?: string,
): string {
const classNames: string[] = [];
for (const [variantName, variantKey] of Object.entries(
final,
) as VariantKVEntry<V>) {
classNames.push(variants[variantName][variantKey.toString()]);
}
return twMerge(base, ...classNames, ...dynamics, propClassName);
}
return [
/**
* Takes any props (including className), and returns the class name.
* If there is no variant specified in props, then it will fallback to preset, and then default.
*
*
* Process priority of variant will be:
*
* --- Processed as string
* 1. Base
*
* --- Processed as object (it will ignore rest of "not duplicated classname" in lower priority)
* 2. Default
* 3. Preset (overriding default)
* 4. Variant props via component (overriding preset)
*
* --- Processed as string
* 5. Dynamic classNames using variant props
* 6. User's className (overriding dynamic)
*
*
* @param variantProps - The variant props including className.
* @returns The class name.
*/
@ -180,42 +246,43 @@ export function vcn<
VariantKV<V>
>,
) => {
const { className, preset, ...otherVariantProps } = variantProps;
const { className, preset, ..._otherVariantProps } = variantProps;
const currentPreset: P[keyof P] | null =
presets && preset ? (presets as NonNullable<P>)[preset] ?? null : null;
const presetVariantKeys: (keyof V)[] = Object.keys(currentPreset ?? {});
return twMerge(
base,
...(
Object.entries(defaults) as [keyof V, keyof V[keyof V] & string][]
).map<string>(([variantKey, defaultValue]) => {
// Omit<Partial<VariantKV<V>> & { className; preset; }, className | preset> = Partial<VariantKV<V>> (safe to cast)
// Partial<VariantKV<V>>[keyof V] => { [k in keyof V]?: BooleanString<keyof V[keyof V] & string> } => BooleanString<keyof V[keyof V]>
// Omit<Partial<VariantKV<V>> & { className; preset; }, className | preset> = Partial<VariantKV<V>> (safe to cast)
// We all know `keyof V` = string, right? (but typescript says it's not, so.. attacking typescript with unknown lol)
const otherVariantProps = _otherVariantProps as unknown as Partial<
VariantKV<V>
>;
const directVariantValue: (keyof V[keyof V] & string) | undefined = (
otherVariantProps as unknown as Partial<VariantKV<V>>
)[variantKey]?.toString?.(); // BooleanString<> -> string (safe to index V[keyof V])
const kv: VariantKV<V> = { ...defaults };
const currentPresetVariantValue:
| (keyof V[keyof V] & string)
| undefined =
!!currentPreset && presetVariantKeys.includes(variantKey)
? (currentPreset as Partial<VariantKV<V>>)[
variantKey
]?.toString?.()
: undefined;
// Preset Processing
if (presets && preset && preset in presets) {
for (const [variantName, variantKey] of Object.entries(
// typescript bug (casting to NonNullable<P> required)
(presets as NonNullable<P>)[preset],
) as VariantKVEntry<V>) {
kv[variantName] = variantKey;
}
}
const variantValue: keyof V[keyof V] & string =
directVariantValue ?? currentPresetVariantValue ?? defaultValue;
return variants[variantKey][variantValue];
}),
(
currentPreset as Partial<VariantKV<V>> | null
)?.className?.toString?.(), // preset's classname comes after user's variant props? huh..
className,
);
// VariantProps Processing
for (const [variantName, variantKey] of Object.entries(
otherVariantProps,
) as VariantKVEntry<V>) {
if (typeof variantKey === "undefined") continue;
kv[variantName] = variantKey;
}
// make dynamics result
const dynamicClasses: string[] = [];
for (const dynamicFunction of dynamics) {
dynamicClasses.push(dynamicFunction(kv));
}
return __transformer__(kv, dynamicClasses, className);
},
/**
* Takes any props, parse variant props and other props.
* If `options.excludeA` is true, then it will parse `A` as "other" props.

View File

@ -6,28 +6,21 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build && cp ./404.html ./dist",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "biome check --no-errors-on-unmatched",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.12",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.3.0"
"tailwind-merge": "^2.3.0",
"tailwindcss": "^4.0.12"
},
"devDependencies": {
"@types/node": "^20.12.13",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-storybook": "^0.8.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.0"
}

View File

@ -1,7 +0,0 @@
import "./tailwind.css";
import React from "react";
import ReactDOM from "react-dom/client";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode></React.StrictMode>,
);

View File

@ -1,7 +1,5 @@
@import tailwindcss;
@import url("https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
@ -34,4 +32,3 @@
@apply transition-colors;
}
}

View File

@ -1,14 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./{components,stories,src}/**/*.{js,jsx,ts,tsx,css,mdx}"],
darkMode: [
"variant",
[
"@media (prefers-color-scheme: dark) { &:is(.system *) }",
"&:is(.dark *)",
],
],
theme: {
extend: {},
},
};

View File

@ -1,21 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "tailwindcss";
import { resolve } from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
css: {
postcss: {
plugins: [tailwindcss()],
},
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@components": resolve(__dirname, "./components"),
"@": resolve(__dirname, "./src"),
"@pswui-lib": resolve(__dirname, "./vcn.ts"),
"@pswui-lib": resolve(__dirname, "./lib/index.ts"),
},
},
});

View File

@ -7,19 +7,40 @@
"lib": [
"index.ts",
"Slot.tsx",
"vcn.ts"
"vcn.ts",
"useDocument.ts",
"useAnimatedMount.ts"
],
"components": {
"button": { "type": "file", "name": "Button.tsx" },
"checkbox": { "type": "file", "name": "Checkbox.tsx" },
"dialog": { "type": "dir", "name": "Dialog", "files": ["index.ts", "Component.tsx", "Context.ts"] },
"dialog": {
"type": "dir",
"name": "Dialog",
"files": ["index.ts", "Component.tsx", "Context.ts"]
},
"drawer": { "type": "file", "name": "Drawer.tsx" },
"form": { "type": "file", "name": "Form.tsx" },
"input": { "type": "file", "name": "Input.tsx" },
"label": { "type": "file", "name": "Label.tsx" },
"popover": { "type": "file", "name": "Popover.tsx" },
"switch": { "type": "file", "name": "Switch.tsx" },
"tabs": { "type": "dir", "name": "Tabs", "files": ["index.ts", "Context.ts", "Hook.ts", "Component.tsx"] },
"toast": { "type": "dir", "name": "Toast", "files": ["index.ts", "Component.tsx", "Hook.ts", "Store.ts", "Variant.ts"] },
"tabs": {
"type": "dir",
"name": "Tabs",
"files": ["index.ts", "Context.ts", "Hook.ts", "Component.tsx"]
},
"toast": {
"type": "dir",
"name": "Toast",
"files": [
"index.ts",
"Component.tsx",
"Hook.ts",
"Store.ts",
"Variant.ts"
]
},
"tooltip": { "type": "file", "name": "Tooltip.tsx" }
}
}
}

3462
yarn.lock

File diff suppressed because it is too large Load Diff