diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3259942 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Dependencies +node_modules/ + +# Builds +dist/ +build/ +coverage/ + +# Vite / tooling caches +.vite/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +bun-debug.log* + +# Env files (keep .env.example committed) +.env +.env.* +!.env.example + +# TypeScript incremental build info +*.tsbuildinfo + +# OS / editor cruft +.DS_Store +Thumbs.db + +# JetBrains (either ignore all, or see "optional" note below) +.idea/ +*.iml + +# CMake +cmake-build-*/ + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based HTTP Client +http-client.private.env.json \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a06c222 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +registry=https://nexus.beatrice.wtf/repository/npm-group/ +@panic:registry=https://nexus.beatrice.wtf/repository/npm-hosted/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..145448e --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "@panic/web-ui", + "version": "0.1.2", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./components/MDXEditorField": { + "types": "./dist/components/MDXEditorField.d.ts", + "import": "./dist/components/MDXEditorField.js" + }, + "./styles/base.css": "./dist/styles/base.css", + "./styles/components.css": "./dist/styles/components.css", + "./styles/utilities.css": "./dist/styles/utilities.css", + "./tailwind-preset": "./dist/tailwind-preset.cjs" + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "yarn clean && vite build && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/base.css dist/styles/base.css && tailwindcss -c tailwind.build.config.cjs -i src/styles/components.css -o dist/styles/components.css --minify && tailwindcss -c tailwind.build.config.cjs -i src/styles/utilities.css -o dist/styles/utilities.css --minify && cp tailwind-preset.cjs dist/tailwind-preset.cjs", + "prepublishOnly": "yarn build", + "publish:nexus": "npm publish --registry ${NEXUS_NPM_REGISTRY:-https://nexus.beatrice.wtf/repository/npm-hosted/}" + }, + "publishConfig": { + "registry": "https://nexus.beatrice.wtf/repository/npm-hosted/", + "access": "restricted" + }, + "peerDependencies": { + "@heroicons/react": "^2.2.0", + "@mdxeditor/editor": "^3.52.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0" + }, + "peerDependenciesMeta": { + "@mdxeditor/editor": { + "optional": true + } + }, + "devDependencies": { + "@heroicons/react": "^2.2.0", + "@mdxeditor/editor": "^3.52.4", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "tailwindcss": "^3.4.16", + "typescript": "^5.6.2", + "vite": "^7.0.0" + } +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..0b04eea --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,137 @@ +import type { ElementType, MouseEventHandler } from 'react'; +import { Link } from 'react-router-dom'; +import type { ComponentSize } from './types'; + +type ButtonType = 'solid' | 'outlined' | 'noborder'; +type ButtonVariant = 'primary' | 'secondary' | 'important'; +type NativeButtonType = 'button' | 'submit' | 'reset'; + +type ButtonProps = { + label?: string; + type: ButtonType; + variant?: ButtonVariant; + size?: ComponentSize; + to?: string; + htmlType?: NativeButtonType; + onClick?: MouseEventHandler; + disabled?: boolean; + icon?: ElementType; + ariaLabel?: string; + className?: string; +}; + +const SIZE_CLASS: Record = { + sm: 'h-8 px-3 text-xs', + md: 'h-10 px-4 text-sm', + lg: 'h-12 px-5 text-base', + full: 'h-10 w-full px-4 text-sm' +}; + +const ICON_ONLY_SIZE_CLASS: Record = { + sm: 'h-8 w-8 !p-0', + md: 'h-10 w-10 !p-0', + lg: 'h-12 w-12 !p-0', + full: 'h-10 w-full !p-0' +}; + +const ICON_CLASS: Record = { + sm: 'h-4 w-4', + md: 'h-4 w-4', + lg: 'h-5 w-5', + full: 'h-4 w-4' +}; + +const ICON_ONLY_CLASS: Record = { + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-6 w-6', + full: 'h-5 w-5' +}; + +const TYPE_CLASS: Record = { + solid: 'btn-solid', + outlined: 'btn-outlined', + noborder: 'btn-noborder' +}; + +const VARIANT_CLASS: Record = { + primary: 'btn-primary', + secondary: 'btn-secondary', + important: 'btn-important' +}; + +function resolveVariant(type: ButtonType, variant?: ButtonVariant): ButtonVariant { + if (variant) { + return variant; + } + + return type === 'solid' ? 'primary' : 'secondary'; +} + +export function Button({ + label, + type, + variant, + size = 'md', + to, + htmlType = 'button', + onClick, + disabled = false, + icon: Icon, + ariaLabel, + className = '' +}: Readonly) { + const isIconOnly = Icon != null && !label; + const resolvedVariant = resolveVariant(type, variant); + const composedClassName = [ + TYPE_CLASS[type], + VARIANT_CLASS[resolvedVariant], + isIconOnly ? ICON_ONLY_SIZE_CLASS[size] : SIZE_CLASS[size], + Icon && label ? 'gap-1.5' : '', + disabled ? 'pointer-events-none cursor-not-allowed opacity-45 saturate-50' : '', + className + ].join(' ').trim(); + const computedAriaLabel = ariaLabel ?? label; + const iconClass = `${isIconOnly ? ICON_ONLY_CLASS[size] : ICON_CLASS[size]} shrink-0`; + const content = ( + <> + {Icon ?