diff --git a/README.md b/README.md index 0c9533f..633b0f7 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ roles, and domain components. ## Status -`0.x` foundation. `@protocol-works/design-tokens` is published publicly; `@protocol-works/ui` is -prepared independently under `packages/ui/`. +`0.x` foundation. `@protocol-works/design-tokens` and `@protocol-works/ui` are published publicly. ## Install diff --git a/package-lock.json b/package-lock.json index e272fdb..a3904f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,44 @@ "node": ">=20" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@fontsource-variable/dm-sans": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.2.8.tgz", @@ -453,6 +491,415 @@ "resolved": "packages/ui", "link": true }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@tailwindcss/cli": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.2.tgz", @@ -745,7 +1192,7 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -755,7 +1202,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -765,7 +1212,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -1242,8 +1689,11 @@ }, "packages/ui": { "name": "@protocol-works/ui", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", + "dependencies": { + "@radix-ui/react-tooltip": "1.2.8" + }, "engines": { "node": ">=20" }, diff --git a/packages/ui/README.md b/packages/ui/README.md index 8a0a102..b311a5d 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -42,8 +42,13 @@ There is intentionally no root JavaScript barrel. ### Actions and forms - `button` — primary, outline, and ghost actions across light, dark, accent, and signal surfaces +- `checkbox` - `field` — accessible label, helper, required, and error wiring - `input` +- `number-field` +- `segmented-control` +- `slider` +- `switch` - `textarea` ### Layout and surfaces @@ -51,15 +56,23 @@ There is intentionally no root JavaScript barrel. - `card` - `cluster` - `divider` +- `empty-state` - `grid` - `section` +- `section-header` - `stack` - `surface` +### Overlays + +- `modal` — focus-trapped dialog with focus restoration and inert background handling +- `popover` +- `tooltip` — accessible Radix tooltip with optional shortcut presentation + ### Content and feedback - `list` -- `note` +- `note` — informational, success, warning, and error notices with optional action or dismissal - `quote` - `skip-link` - `spinner` diff --git a/packages/ui/package.json b/packages/ui/package.json index 20d607c..2897a38 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@protocol-works/ui", - "version": "0.1.0", + "version": "0.2.0", "description": "Portable React primitives for [protocol]works products.", "type": "module", "sideEffects": [ @@ -29,6 +29,10 @@ "types": "./dist/components/Card.d.ts", "import": "./dist/components/Card.js" }, + "./checkbox": { + "types": "./dist/components/Checkbox.d.ts", + "import": "./dist/components/Checkbox.js" + }, "./cluster": { "types": "./dist/components/Cluster.d.ts", "import": "./dist/components/Cluster.js" @@ -45,6 +49,10 @@ "types": "./dist/components/Divider.d.ts", "import": "./dist/components/Divider.js" }, + "./empty-state": { + "types": "./dist/components/EmptyState.d.ts", + "import": "./dist/components/EmptyState.js" + }, "./field": { "types": "./dist/components/Field.d.ts", "import": "./dist/components/Field.js" @@ -69,10 +77,22 @@ "types": "./dist/components/List.d.ts", "import": "./dist/components/List.js" }, + "./modal": { + "types": "./dist/components/Modal.d.ts", + "import": "./dist/components/Modal.js" + }, "./note": { "types": "./dist/components/Note.d.ts", "import": "./dist/components/Note.js" }, + "./number-field": { + "types": "./dist/components/NumberField.d.ts", + "import": "./dist/components/NumberField.js" + }, + "./popover": { + "types": "./dist/components/Popover.d.ts", + "import": "./dist/components/Popover.js" + }, "./quote": { "types": "./dist/components/Quote.d.ts", "import": "./dist/components/Quote.js" @@ -81,6 +101,14 @@ "types": "./dist/components/Section.d.ts", "import": "./dist/components/Section.js" }, + "./section-header": { + "types": "./dist/components/SectionHeader.d.ts", + "import": "./dist/components/SectionHeader.js" + }, + "./segmented-control": { + "types": "./dist/components/SegmentedControl.d.ts", + "import": "./dist/components/SegmentedControl.js" + }, "./signal": { "types": "./dist/components/typography/Signal.d.ts", "import": "./dist/components/typography/Signal.js" @@ -89,6 +117,10 @@ "types": "./dist/components/SkipLink.d.ts", "import": "./dist/components/SkipLink.js" }, + "./slider": { + "types": "./dist/components/Slider.d.ts", + "import": "./dist/components/Slider.js" + }, "./spinner": { "types": "./dist/components/Spinner.d.ts", "import": "./dist/components/Spinner.js" @@ -105,6 +137,10 @@ "types": "./dist/components/Surface.d.ts", "import": "./dist/components/Surface.js" }, + "./switch": { + "types": "./dist/components/Switch.d.ts", + "import": "./dist/components/Switch.js" + }, "./tag": { "types": "./dist/components/Tag.d.ts", "import": "./dist/components/Tag.js" @@ -113,6 +149,10 @@ "types": "./dist/components/Textarea.d.ts", "import": "./dist/components/Textarea.js" }, + "./tooltip": { + "types": "./dist/components/Tooltip.d.ts", + "import": "./dist/components/Tooltip.js" + }, "./visually-hidden": { "types": "./dist/components/VisuallyHidden.d.ts", "import": "./dist/components/VisuallyHidden.js" @@ -145,5 +185,8 @@ }, "engines": { "node": ">=20" + }, + "dependencies": { + "@radix-ui/react-tooltip": "1.2.8" } } diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx new file mode 100644 index 0000000..ba03b31 --- /dev/null +++ b/packages/ui/src/components/Checkbox.tsx @@ -0,0 +1,59 @@ +import { + forwardRef, + useId, + type InputHTMLAttributes, + type ReactNode, +} from "react"; +import { cn } from "../lib/cn.js"; + +export type CheckboxProps = Omit< + InputHTMLAttributes, + "type" +> & { + label?: ReactNode; + description?: ReactNode; +}; + +export const Checkbox = forwardRef( + function Checkbox( + { label, description, className, disabled, id, ...rest }, + ref, + ) { + const reactId = useId(); + const inputId = id ?? reactId; + + return ( + + ); + }, +); diff --git a/packages/ui/src/components/EmptyState.tsx b/packages/ui/src/components/EmptyState.tsx new file mode 100644 index 0000000..cce3200 --- /dev/null +++ b/packages/ui/src/components/EmptyState.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { Heading } from "./typography/Heading.js"; +import { cn } from "../lib/cn.js"; + +export type EmptyStateProps = { + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + icon?: ReactNode; + className?: string; +}; + +export function EmptyState({ + title, + description, + action, + icon, + className, +}: EmptyStateProps) { + return ( +
+
+ {icon ?
{icon}
: null} + {title} + {description ? ( +

+ {description} +

+ ) : null} + {action ?
{action}
: null} +
+
+ ); +} diff --git a/packages/ui/src/components/Modal.tsx b/packages/ui/src/components/Modal.tsx new file mode 100644 index 0000000..b3841b6 --- /dev/null +++ b/packages/ui/src/components/Modal.tsx @@ -0,0 +1,179 @@ +import { + useEffect, + useId, + useRef, + type ReactNode, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; +import { Button } from "./Button.js"; +import { Heading } from "./typography/Heading.js"; +import { cn } from "../lib/cn.js"; + +export type ModalProps = { + open: boolean; + onClose: () => void; + title: ReactNode; + description?: ReactNode; + children: ReactNode; + footer?: ReactNode; + size?: "sm" | "md" | "lg"; + initialFocusRef?: RefObject; + closeOnBackdrop?: boolean; + busy?: boolean; + className?: string; +}; + +const sizeClass = { + sm: "max-w-sm", + md: "max-w-2xl", + lg: "max-w-4xl", +}; + +const focusableSelector = [ + "button:not([disabled])", + "[href]", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +export function Modal({ + open, + onClose, + title, + description, + children, + footer, + size = "md", + initialFocusRef, + closeOnBackdrop = true, + busy = false, + className, +}: ModalProps) { + const titleId = useId(); + const descriptionId = useId(); + const dialogRef = useRef(null); + const closeRef = useRef(onClose); + const busyRef = useRef(busy); + closeRef.current = onClose; + busyRef.current = busy; + + useEffect(() => { + if (!open) return; + + const previousFocus = document.activeElement as HTMLElement | null; + const root = document.getElementById("root"); + const rootWasInert = root?.inert ?? false; + const previousOverflow = document.body.style.overflow; + if (root) root.inert = true; + document.body.style.overflow = "hidden"; + + const focusTimer = window.setTimeout(() => { + initialFocusRef?.current?.focus(); + if (!initialFocusRef?.current) { + dialogRef.current + ?.querySelector(focusableSelector) + ?.focus(); + } + }, 0); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busyRef.current) { + event.preventDefault(); + closeRef.current(); + return; + } + + if (event.key !== "Tab" || !dialogRef.current) return; + const focusable = Array.from( + dialogRef.current.querySelectorAll(focusableSelector), + ); + if (focusable.length === 0) { + event.preventDefault(); + dialogRef.current.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + window.clearTimeout(focusTimer); + document.removeEventListener("keydown", handleKeyDown); + if (root) root.inert = rootWasInert; + document.body.style.overflow = previousOverflow; + previousFocus?.focus(); + }; + }, [initialFocusRef, open]); + + if (!open || typeof document === "undefined") return null; + + return createPortal( +
{ + if (event.target === event.currentTarget && closeOnBackdrop && !busy) { + onClose(); + } + }} + > +
+
+
+ + {title} + + {description ? ( +

+ {description} +

+ ) : null} +
+ +
+
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+
, + document.body, + ); +} diff --git a/packages/ui/src/components/Note.tsx b/packages/ui/src/components/Note.tsx index b44818f..f5dcac7 100644 --- a/packages/ui/src/components/Note.tsx +++ b/packages/ui/src/components/Note.tsx @@ -1,4 +1,5 @@ -import type { ReactNode } from "react"; +import type { HTMLAttributes, ReactNode } from "react"; +import { Button } from "./Button.js"; import { cn } from "../lib/cn.js"; export type NoteTone = "info" | "success" | "warning" | "error"; @@ -6,9 +7,12 @@ export type NoteTone = "info" | "success" | "warning" | "error"; export type NoteProps = { tone?: NoteTone; title?: ReactNode; + action?: ReactNode; + onDismiss?: () => void; + dismissLabel?: string; className?: string; children: ReactNode; -}; +} & Omit, "children" | "title">; const toneVariable: Record = { info: "var(--pw-color-info)", @@ -17,7 +21,17 @@ const toneVariable: Record = { error: "var(--pw-color-error)", }; -export function Note({ tone = "info", title, className, children }: NoteProps) { +export function Note({ + tone = "info", + title, + action, + onDismiss, + dismissLabel = "Dismiss", + className, + children, + style, + ...rest +}: NoteProps) { const color = toneVariable[tone]; return ( ); } diff --git a/packages/ui/src/components/NumberField.tsx b/packages/ui/src/components/NumberField.tsx new file mode 100644 index 0000000..fa3e527 --- /dev/null +++ b/packages/ui/src/components/NumberField.tsx @@ -0,0 +1,81 @@ +import { useId } from "react"; +import { Button } from "./Button.js"; +import { Input } from "./Input.js"; +import { cn } from "../lib/cn.js"; + +export type NumberFieldProps = { + value: number; + onValueChange: (value: number) => void; + label: string; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + className?: string; +}; + +function clamp(value: number, min?: number, max?: number): number { + return Math.min( + max ?? Number.POSITIVE_INFINITY, + Math.max(min ?? Number.NEGATIVE_INFINITY, value), + ); +} + +export function NumberField({ + value, + onValueChange, + label, + min, + max, + step = 1, + disabled = false, + className, +}: NumberFieldProps) { + const id = useId(); + const update = (next: number) => onValueChange(clamp(next, min, max)); + + return ( +
+ +
+ + { + const next = event.currentTarget.valueAsNumber; + if (Number.isFinite(next)) update(next); + }} + className="min-w-0 border-x-0 text-center" + /> + +
+
+ ); +} diff --git a/packages/ui/src/components/Popover.tsx b/packages/ui/src/components/Popover.tsx new file mode 100644 index 0000000..d6cf23f --- /dev/null +++ b/packages/ui/src/components/Popover.tsx @@ -0,0 +1,123 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { Button } from "./Button.js"; +import { cn } from "../lib/cn.js"; + +export type PopoverProps = { + trigger: ReactNode; + children: ReactNode; + label: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + align?: "left" | "right"; + className?: string; +}; + +export function Popover({ + trigger, + children, + label, + open: controlledOpen, + onOpenChange, + align = "left", + className, +}: PopoverProps) { + const id = useId(); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0, right: 0 }); + const triggerRef = useRef(null); + const contentRef = useRef(null); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = useCallback( + (next: boolean) => { + if (controlledOpen == null) setUncontrolledOpen(next); + onOpenChange?.(next); + }, + [controlledOpen, onOpenChange], + ); + + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 4, + left: rect.left, + right: window.innerWidth - rect.right, + }); + }, [align, open]); + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if ( + !triggerRef.current?.contains(target) && + !contentRef.current?.contains(target) + ) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + triggerRef.current?.querySelector("button")?.focus(); + } + }; + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open, setOpen]); + + return ( + <> + + + + {open && typeof document !== "undefined" + ? createPortal( + , + document.body, + ) + : null} + + ); +} diff --git a/packages/ui/src/components/SectionHeader.tsx b/packages/ui/src/components/SectionHeader.tsx new file mode 100644 index 0000000..b0b9a2f --- /dev/null +++ b/packages/ui/src/components/SectionHeader.tsx @@ -0,0 +1,40 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { Cluster } from "./Cluster.js"; +import { Heading } from "./typography/Heading.js"; + +export type SectionHeaderProps = Omit< + HTMLAttributes, + "title" +> & { + title: ReactNode; + description?: ReactNode; + action?: ReactNode; +}; + +export function SectionHeader({ + title, + description, + action, + className, + ...rest +}: SectionHeaderProps) { + return ( + +
+ {title} + {description ? ( +

+ {description} +

+ ) : null} +
+ {action ?
{action}
: null} +
+ ); +} diff --git a/packages/ui/src/components/SegmentedControl.tsx b/packages/ui/src/components/SegmentedControl.tsx new file mode 100644 index 0000000..aae5329 --- /dev/null +++ b/packages/ui/src/components/SegmentedControl.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; +import { cn } from "../lib/cn.js"; + +export type SegmentedControlOption = { + value: T; + label: ReactNode; + disabled?: boolean; +}; + +export type SegmentedControlProps = { + value: T; + options: SegmentedControlOption[]; + onValueChange: (value: T) => void; + label: string; + className?: string; +}; + +export function SegmentedControl({ + value, + options, + onValueChange, + label, + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => { + const selected = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/packages/ui/src/components/Slider.tsx b/packages/ui/src/components/Slider.tsx new file mode 100644 index 0000000..fda173f --- /dev/null +++ b/packages/ui/src/components/Slider.tsx @@ -0,0 +1,55 @@ +import { useId } from "react"; +import { cn } from "../lib/cn.js"; + +export type SliderProps = { + value: number; + onValueChange: (value: number) => void; + label: string; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + showValue?: boolean; + className?: string; +}; + +export function Slider({ + value, + onValueChange, + label, + min = 0, + max = 100, + step = 1, + disabled = false, + showValue = true, + className, +}: SliderProps) { + const id = useId(); + + return ( +
+
+ + {showValue ? ( + {value} + ) : null} +
+ onValueChange(event.currentTarget.valueAsNumber)} + className="min-h-hit-target w-full cursor-pointer accent-accent disabled:cursor-not-allowed disabled:opacity-[var(--pw-opacity-disabled)]" + /> +
+ ); +} diff --git a/packages/ui/src/components/Switch.tsx b/packages/ui/src/components/Switch.tsx new file mode 100644 index 0000000..af86e81 --- /dev/null +++ b/packages/ui/src/components/Switch.tsx @@ -0,0 +1,79 @@ +import { useId, type ReactNode } from "react"; +import { cn } from "../lib/cn.js"; + +export type SwitchProps = { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + label?: ReactNode; + description?: ReactNode; + disabled?: boolean; + className?: string; + "aria-label"?: string; +}; + +export function Switch({ + checked, + onCheckedChange, + label, + description, + disabled = false, + className, + "aria-label": ariaLabel, +}: SwitchProps) { + const labelId = useId(); + const descriptionId = useId(); + + return ( +
+ + {label || description ? ( + + {label ? ( + + {label} + + ) : null} + {description ? ( + + {description} + + ) : null} + + ) : null} +
+ ); +} diff --git a/packages/ui/src/components/Tooltip.tsx b/packages/ui/src/components/Tooltip.tsx new file mode 100644 index 0000000..631c84b --- /dev/null +++ b/packages/ui/src/components/Tooltip.tsx @@ -0,0 +1,53 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type { ReactElement, ReactNode } from "react"; + +export type TooltipProps = { + content: ReactNode; + children: ReactElement; + side?: "top" | "right" | "bottom" | "left"; + delayDuration?: number; + shortcut?: string; +}; + +export function Tooltip({ + content, + children, + side = "top", + delayDuration = 300, + shortcut, +}: TooltipProps) { + return ( + + {children} + + +
+ {content} + {shortcut ? ( + + {shortcut} + + ) : null} +
+ +
+
+
+ ); +} + +export function TooltipProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/ui/test/contracts.test.mjs b/packages/ui/test/contracts.test.mjs index 63016ed..4f2fb36 100644 --- a/packages/ui/test/contracts.test.mjs +++ b/packages/ui/test/contracts.test.mjs @@ -13,27 +13,37 @@ const componentSubpaths = [ "button", "caption", "card", + "checkbox", "cluster", "code", "display", "divider", + "empty-state", "field", "grid", "heading", "input", "lead", "list", + "modal", "note", + "number-field", + "popover", "quote", "section", + "section-header", + "segmented-control", "signal", "skip-link", + "slider", "spinner", "stack", "status", "surface", + "switch", "tag", "textarea", + "tooltip", "visually-hidden", ]; diff --git a/packages/ui/test/render.test.mjs b/packages/ui/test/render.test.mjs index 5bfd839..36a553b 100644 --- a/packages/ui/test/render.test.mjs +++ b/packages/ui/test/render.test.mjs @@ -11,25 +11,36 @@ import { Lead } from "../dist/components/typography/Lead.js"; import { Signal } from "../dist/components/typography/Signal.js"; import { Button } from "../dist/components/Button.js"; import { Card } from "../dist/components/Card.js"; +import { Checkbox } from "../dist/components/Checkbox.js"; import { Cluster } from "../dist/components/Cluster.js"; import { Divider } from "../dist/components/Divider.js"; +import { EmptyState } from "../dist/components/EmptyState.js"; import { Field } from "../dist/components/Field.js"; import { Grid } from "../dist/components/Grid.js"; import { Input } from "../dist/components/Input.js"; import { List, ListItem } from "../dist/components/List.js"; +import { Modal } from "../dist/components/Modal.js"; import { Note } from "../dist/components/Note.js"; +import { NumberField } from "../dist/components/NumberField.js"; +import { Popover } from "../dist/components/Popover.js"; import { Quote } from "../dist/components/Quote.js"; import { Section } from "../dist/components/Section.js"; +import { SectionHeader } from "../dist/components/SectionHeader.js"; +import { SegmentedControl } from "../dist/components/SegmentedControl.js"; import { SkipLink } from "../dist/components/SkipLink.js"; +import { Slider } from "../dist/components/Slider.js"; import { Spinner } from "../dist/components/Spinner.js"; import { Stack } from "../dist/components/Stack.js"; import { Status } from "../dist/components/Status.js"; import { Surface } from "../dist/components/Surface.js"; +import { Switch } from "../dist/components/Switch.js"; import { Tag } from "../dist/components/Tag.js"; import { Textarea } from "../dist/components/Textarea.js"; +import { Tooltip, TooltipProvider } from "../dist/components/Tooltip.js"; import { VisuallyHidden } from "../dist/components/VisuallyHidden.js"; function gallery(theme) { + const noop = () => {}; return h( "div", { "data-theme": theme }, @@ -54,6 +65,10 @@ function gallery(theme) { h(Button, { variant: "outline" }, "Outline"), h(Button, { variant: "ghost" }, "Ghost"), ), + h(SectionHeader, { + title: "Controls", + description: "Portable interaction primitives.", + }), h( Grid, { cols: 2 }, @@ -66,9 +81,42 @@ function gallery(theme) { h(Input, { defaultValue: "Rabbithole" }), ), h(Textarea, { defaultValue: "Description" }), + h(Checkbox, { label: "Enabled", defaultChecked: true }), + h(Switch, { + label: "Listening", + checked: true, + onCheckedChange: noop, + }), + h(Slider, { + label: "Volume", + value: 50, + onValueChange: noop, + }), + h(NumberField, { + label: "Agents", + value: 2, + onValueChange: noop, + }), + h(SegmentedControl, { + label: "View", + value: "one", + options: [ + { value: "one", label: "One" }, + { value: "two", label: "Two" }, + ], + onValueChange: noop, + }), h(Divider, null), h(List, null, h(ListItem, null, "One"), h(ListItem, null, "Two")), - h(Note, { tone: "warning", title: "Check" }, "Configuration"), + h( + Note, + { tone: "warning", title: "Check", onDismiss: noop }, + "Configuration", + ), + h(EmptyState, { + title: "No sessions", + description: "Create one to begin.", + }), h( Quote, { attribution: "protocol.works" }, @@ -76,6 +124,21 @@ function gallery(theme) { ), h(Status, { tone: "success" }, "Ready"), h(Tag, { tone: "accent" }, "Shared"), + h( + TooltipProvider, + null, + h( + Tooltip, + { content: "Run task", shortcut: "⌘↵" }, + h("button", null, "Run"), + ), + ), + h(Popover, { label: "Details", trigger: "Details" }, "Content"), + h( + Modal, + { open: false, onClose: noop, title: "Hidden modal" }, + "Hidden", + ), h(Spinner, { label: "Loading components" }), h(VisuallyHidden, null, "Hidden context"), h(Surface, { variant: "dark" }, "Dark surface"), @@ -92,6 +155,9 @@ for (const theme of ["light", "dark"]) { assert.match(markup, /Shared primitives/); assert.match(markup, /aria-describedby=/); assert.match(markup, /aria-live="polite"/); + assert.match(markup, /role="switch"/); + assert.match(markup, /role="radiogroup"/); + assert.match(markup, /type="checkbox"/); assert.match(markup, /pw-surface-dark/); assert.match(markup, /Loading components/); });