diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 865b040f..ec9c1133 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -7,5 +7,6 @@ "packages/ui-styles": "1.9.7", "packages/ui-form": "1.3.12", "packages/ui-fingerprint": "1.0.1", - "packages/ui-button": "1.0.1" + "packages/ui-button": "1.0.1", + "packages/ui-anchor": "0.0.0" } diff --git a/packages/ui-anchor/README.md b/packages/ui-anchor/README.md new file mode 100644 index 00000000..23230726 --- /dev/null +++ b/packages/ui-anchor/README.md @@ -0,0 +1,3 @@ +# @versini/ui-anchor + +A simple anchor component for React. diff --git a/packages/ui-anchor/package.json b/packages/ui-anchor/package.json new file mode 100644 index 00000000..4a433e94 --- /dev/null +++ b/packages/ui-anchor/package.json @@ -0,0 +1,52 @@ +{ + "name": "@versini/ui-anchor", + "version": "0.0.0", + "license": "MIT", + "author": "Arno Versini", + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/aversini/ui-components", + "repository": { + "type": "git", + "url": "git@github.com:aversini/ui-components.git" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build:check": "tsc", + "build:js": "vite build", + "build:types": "tsup", + "build": "npm-run-all --serial clean build:check build:js build:types", + "clean": "rimraf dist tmp", + "dev:js": "vite build --watch --mode development", + "dev:types": "tsup --watch src", + "dev": "npm-run-all clean --parallel dev:js dev:types", + "lint": "biome lint src", + "start": "static-server dist --port 5173", + "test:coverage:ui": "vitest --coverage --ui", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "test": "vitest run" + }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@versini/ui-styles": "workspace:../ui-styles" + }, + "dependencies": { + "@floating-ui/react": "0.26.24", + "@tailwindcss/typography": "0.5.15", + "@versini/ui-button": "workspace:../ui-button", + "@versini/ui-hooks": "workspace:../ui-hooks", + "@versini/ui-icons": "workspace:../ui-icons", + "@versini/ui-private": "workspace:../ui-private", + "clsx": "2.1.1", + "tailwindcss": "3.4.11" + }, + "sideEffects": ["**/*.css"] +} diff --git a/packages/ui-anchor/postcss.config.cjs b/packages/ui-anchor/postcss.config.cjs new file mode 100644 index 00000000..e873f1a4 --- /dev/null +++ b/packages/ui-anchor/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/ui-anchor/src/components/Anchor/Anchor.tsx b/packages/ui-anchor/src/components/Anchor/Anchor.tsx new file mode 100644 index 00000000..15adeaa4 --- /dev/null +++ b/packages/ui-anchor/src/components/Anchor/Anchor.tsx @@ -0,0 +1,12 @@ +import React from "react"; + +import { ButtonLink } from "@versini/ui-button"; +import type { AnchorProps } from "./AnchorTypes"; + +export const Anchor = React.forwardRef( + ({ ...otherProps }, ref) => { + return ; + }, +); + +Anchor.displayName = "Anchor"; diff --git a/packages/ui-anchor/src/components/Anchor/AnchorTypes.d.ts b/packages/ui-anchor/src/components/Anchor/AnchorTypes.d.ts new file mode 100644 index 00000000..6012312b --- /dev/null +++ b/packages/ui-anchor/src/components/Anchor/AnchorTypes.d.ts @@ -0,0 +1,4 @@ +import type { ButtonLinkProps } from "@versini/ui-button"; + +export type AnchorProps = ButtonLinkProps & + React.AnchorHTMLAttributes; diff --git a/packages/ui-anchor/src/components/Anchor/__tests__/Anchor.test.tsx b/packages/ui-anchor/src/components/Anchor/__tests__/Anchor.test.tsx new file mode 100644 index 00000000..6dd729bc --- /dev/null +++ b/packages/ui-anchor/src/components/Anchor/__tests__/Anchor.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from "@testing-library/react"; + +import { Anchor } from "../.."; +import { expectToHaveClasses } from "../../../../../../configuration/tests-helpers"; + +describe("Anchor (exceptions)", () => { + it("should be able to require/import from root", () => { + expect(Anchor).toBeDefined(); + }); +}); + +describe("Anchor modifiers", () => { + it("should render a default anchor", async () => { + render(hello); + const button = await screen.findByRole("link"); + expect(button.className).toContain("py-0"); + }); + + it("should render a size small anchor", async () => { + render( + + hello + , + ); + const button = await screen.findByRole("link"); + expectToHaveClasses(button, ["px-4", "py-0", "max-h-8"]); + }); + + it("should render a size medium anchor", async () => { + render( + + hello + , + ); + const button = await screen.findByRole("link"); + expectToHaveClasses(button, ["px-4", "py-1", "max-h-9"]); + }); + + it("should render a size large anchor", async () => { + render( + + hello + , + ); + const button = await screen.findByRole("link"); + expectToHaveClasses(button, ["px-4", "py-2", "max-h-12"]); + }); + + it("should render a dark link", async () => { + render( + + hello + , + ); + const button = await screen.findByRole("link"); + const buttonClass = button.className; + expect(buttonClass).toContain("text-copy-light"); + expect(buttonClass).toContain("bg-action-dark"); + }); + + it("should render a light anchor", async () => { + render( + + hello + , + ); + const button = await screen.findByRole("link"); + const buttonClass = button.className; + expect(buttonClass).toContain("text-copy-light"); + expect(buttonClass).toContain("bg-action-light"); + }); + + it("should render a fullWidth link", async () => { + render( + + hello + , + ); + const button = await screen.findByRole("link"); + expect(button.className).toContain("w-full"); + }); + + it("should render an anchor with truncated text", async () => { + render( + + hello world + , + ); + const button = await screen.findByRole("link"); + expect(button.className).toContain("py-0"); + const label = await screen.findByText("hello world"); + expect(label).toBeDefined(); + expect(label.className).toContain("truncate"); + }); + + it("should render an anchor element with a special rel value", async () => { + render( + + Hello World + , + ); + const button = await screen.findByRole("link"); + expect(button).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render an anchor with full text but with truncated class", async () => { + render(hello world); + const button = await screen.findByRole("link"); + expect(button.className).toContain("py-0"); + const label = await screen.findByText("hello world"); + expect(label).toBeDefined(); + expect(label.className).toContain("truncate"); + }); + + it("should render an anchor with full text without a truncated class", async () => { + render( + + hello world + , + ); + const button = await screen.findByRole("link"); + expect(button.className).toContain("py-0"); + const label = await screen.findByText("hello world"); + expect(label).toBeDefined(); + expect(label.className).not.toContain("truncate"); + }); +}); diff --git a/packages/ui-anchor/src/components/index.ts b/packages/ui-anchor/src/components/index.ts new file mode 100644 index 00000000..3dc34ff6 --- /dev/null +++ b/packages/ui-anchor/src/components/index.ts @@ -0,0 +1 @@ +export { Anchor } from "./Anchor/Anchor"; diff --git a/packages/ui-anchor/src/index.css b/packages/ui-anchor/src/index.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/packages/ui-anchor/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/ui-anchor/src/style.ts b/packages/ui-anchor/src/style.ts new file mode 100644 index 00000000..3a29aed1 --- /dev/null +++ b/packages/ui-anchor/src/style.ts @@ -0,0 +1 @@ +import "./index.css"; diff --git a/packages/ui-anchor/src/vite-env.d.ts b/packages/ui-anchor/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/ui-anchor/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/ui-anchor/tailwind.config.js b/packages/ui-anchor/tailwind.config.js new file mode 100644 index 00000000..d9c6afd4 --- /dev/null +++ b/packages/ui-anchor/tailwind.config.js @@ -0,0 +1,7 @@ +/** @type {import('tailwindcss').Config} */ + +import { twPlugin } from "@versini/ui-styles"; + +export default twPlugin.merge({ + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], +}); diff --git a/packages/ui-anchor/tsconfig.json b/packages/ui-anchor/tsconfig.json new file mode 100644 index 00000000..ffbdf148 --- /dev/null +++ b/packages/ui-anchor/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["vitest/globals", "@testing-library/jest-dom"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/ui-anchor/tsconfig.node.json b/packages/ui-anchor/tsconfig.node.json new file mode 100644 index 00000000..f7d9f9ea --- /dev/null +++ b/packages/ui-anchor/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["./vite.config.ts", "./vitest.setup.ts"] +} diff --git a/packages/ui-anchor/tsup.config.ts b/packages/ui-anchor/tsup.config.ts new file mode 100644 index 00000000..30c94257 --- /dev/null +++ b/packages/ui-anchor/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + format: "esm", + entry: { + index: "src/components/index.ts", + }, + outDir: "dist", + dts: { + only: true, + }, +}); diff --git a/packages/ui-anchor/vite.config.ts b/packages/ui-anchor/vite.config.ts new file mode 100644 index 00000000..46751de1 --- /dev/null +++ b/packages/ui-anchor/vite.config.ts @@ -0,0 +1,113 @@ +import path from "node:path"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import fs from "fs-extra"; +import { glob } from "glob"; +import { defineConfig } from "vite"; + +import { externalDependencies } from "../../configuration/vite.common"; + +const packageJson = fs.readJSONSync("package.json"); +const copyrightYear = new Date(Date.now()).getFullYear(); +const buildTime = new Date() + .toLocaleString("en-US", { + timeZone: "America/New_York", + timeZoneName: "short", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + .replace(/,/g, ""); +const banner = `/*! + ${packageJson.name} v${packageJson.version} + © ${copyrightYear} gizmette.com +*/ +try { + if (!window.__VERSINI_UI_ANCHOR__) { + window.__VERSINI_UI_ANCHOR__ = { + version: "${packageJson.version}", + buildTime: "${buildTime}", + homepage: "${packageJson.homepage}", + license: "${packageJson.license}", + }; + } +} catch (error) { + // nothing to declare officer +} +`; + +export default defineConfig(({ mode }) => { + const isDev = mode === "development"; + /** + * Build a list of public files, which means all files in the + * src/components/ComponentName folders. + * Everything else will be moved to the chunk folder. + */ + const input = isDev + ? {} + : Object.fromEntries( + glob + .sync("src/**/*.{ts,tsx}") + .filter((file) => { + return file.match( + /src\/components\/[A-Z][a-zA-Z]*\/[A-Z][a-zA-Z]*\.tsx/, + ) + ? file + : null; + }) + .map((file) => { + return [ + // This remove `src/` as well as the file extension from each + // file, so e.g. src/nested/foo.js becomes nested/foo + path.relative( + "src", + file.slice(0, file.length - path.extname(file).length), + ), + // This expands the relative paths to absolute paths, so e.g. + // src/nested/foo becomes /project/src/nested/foo.js + fileURLToPath(new URL(file, import.meta.url)), + ]; + }), + ); + + return { + build: { + copyPublicDir: false, + lib: { + entry: resolve(__dirname, "src/components/index.ts"), + formats: ["es"], + name: "UIAnchor", + }, + rollupOptions: { + input: { + index: resolve(__dirname, "src/components/index.ts"), + style: resolve(__dirname, "src/style.ts"), + ...input, + }, + treeshake: "smallest", + external: externalDependencies, + output: { + compact: true, + minifyInternalExports: false, + assetFileNames: "style[extname]", + entryFileNames: "[name].js", + chunkFileNames: "chunks/[name].[hash].js", + banner: (module) => { + if (module?.facadeModuleId?.endsWith("src/components/index.ts")) { + return banner; + } + }, + }, + }, + }, + esbuild: { + supported: { + "top-level-await": true, + }, + }, + plugins: [], + }; +}); diff --git a/packages/ui-anchor/vitest.config.ts b/packages/ui-anchor/vitest.config.ts new file mode 100644 index 00000000..10f38968 --- /dev/null +++ b/packages/ui-anchor/vitest.config.ts @@ -0,0 +1,28 @@ +/// + +import { defineConfig, mergeConfig } from "vitest/config"; + +import viteConfig from "./vite.config"; + +export default defineConfig((configEnv) => + mergeConfig( + viteConfig(configEnv), + defineConfig({ + test: { + globals: true, + setupFiles: ["./vitest.setup.ts"], + environment: "happy-dom", + coverage: { + include: ["src/**/*.ts", "src/**/*.tsx", "!src/style.ts"], + provider: "v8", + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, + }), + ), +); diff --git a/packages/ui-anchor/vitest.setup.ts b/packages/ui-anchor/vitest.setup.ts new file mode 100644 index 00000000..1a4fd1d2 --- /dev/null +++ b/packages/ui-anchor/vitest.setup.ts @@ -0,0 +1,17 @@ +import "@testing-library/jest-dom/vitest"; + +import util from "node:util"; + +const originalConsoleError = console.error; +console.error = (...args: any) => { + const message = util.format(...args); + if ( + /(Warning: validateDOMNesting|Invalid prop|Failed prop type|React does not recognize|Unknown event handler property)/gi.test( + message, + ) + ) { + throw new Error(message); + } else { + originalConsoleError.apply(console, [...args]); + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4aaa7ab..8f97df5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,43 @@ importers: specifier: 4.1.1 version: 4.1.1(@swc/helpers@0.5.13)(@types/node@22.5.5)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(terser@5.29.1)(typescript@5.6.2) + packages/ui-anchor: + dependencies: + '@floating-ui/react': + specifier: 0.26.24 + version: 0.26.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tailwindcss/typography': + specifier: 0.5.15 + version: 0.5.15(tailwindcss@3.4.11) + '@versini/ui-button': + specifier: workspace:../ui-button + version: link:../ui-button + '@versini/ui-hooks': + specifier: workspace:../ui-hooks + version: link:../ui-hooks + '@versini/ui-icons': + specifier: workspace:../ui-icons + version: link:../ui-icons + '@versini/ui-private': + specifier: workspace:../ui-private + version: link:../ui-private + clsx: + specifier: 2.1.1 + version: 2.1.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + tailwindcss: + specifier: 3.4.11 + version: 3.4.11 + devDependencies: + '@versini/ui-styles': + specifier: workspace:../ui-styles + version: link:../ui-styles + packages/ui-button: dependencies: '@tailwindcss/typography': @@ -4541,10 +4578,6 @@ packages: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} - engines: {node: '>=8.6'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -10094,7 +10127,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-json-stable-stringify@2.1.0: {} @@ -11931,11 +11964,6 @@ snapshots: braces: 3.0.2 picomatch: 2.3.1 - micromatch@4.0.7: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -13579,7 +13607,7 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.0.1 + picocolors: 1.1.0 postcss: 8.4.47 postcss-import: 15.1.0(postcss@8.4.47) postcss-js: 4.0.1(postcss@8.4.47) @@ -13984,7 +14012,7 @@ snapshots: cac: 6.7.14 debug: 4.3.6(supports-color@5.5.0) pathe: 1.1.2 - picocolors: 1.0.1 + picocolors: 1.1.0 vite: 5.4.5(@types/node@22.5.5)(terser@5.29.1) transitivePeerDependencies: - '@types/node' diff --git a/release-please-config.json b/release-please-config.json index c5d803ab..e43065b9 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,7 +8,8 @@ "packages/ui-styles": {}, "packages/ui-form": {}, "packages/ui-fingerprint": {}, - "packages/ui-button": {} + "packages/ui-button": {}, + "packages/ui-anchor": {} }, "plugins": ["node-workspace"], "pull-request-header": ":rocket: Automated Release"