diff --git a/README.md b/README.md index 13ef9cade..3234bfa74 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,27 @@ Finally, if you're going to use a component that requires global state (like ` ``` +### Widget navigation icons + +Resource navigation links in widget rows and cards use the imported Lucide +`square-arrow-out-up-right` component in neutral gray, sized to match remove +icons. The `linkIcon` helper still accepts a custom image URL as its third +argument; explicit custom icons retain their existing image rendering. + +### Attachment-list delete confirmations + +`widgets.attachmentList(dom, subject, container, options)` accepts an optional +`options.confirmationContainer` HTML element. When provided, delete confirmations +are rendered in normal flow in that element, outside the list's rows, with Cancel +focused. Only one confirmation is shown per confirmation container; unrelated +children are preserved. Mount the container in a visible location above or beside +the list. + +Omitting this option preserves the existing row-local popup and positioning. +`personTR` / `renderAsRow` and `renderAsDiv` accept the same option. +`deleteButtonWithCheck` accepts the container as an optional fifth argument; +existing four-argument calls are unchanged. + ## Use directly in a browser Solid-UI provides **ESM** bundles for direct browser usage. You'll also need to include the CSS styles: diff --git a/package-lock.json b/package-lock.json index b7a9de0e9..618cf0f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1954,9 +1954,9 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", - "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz", + "integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -2112,9 +2112,9 @@ "license": "MIT" }, "node_modules/@codemirror/view": { - "version": "6.43.14", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz", - "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==", + "version": "6.43.15", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz", + "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -3333,9 +3333,9 @@ } }, "node_modules/@lezer/markdown": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz", - "integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.1.tgz", + "integrity": "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.5.0", @@ -5411,17 +5411,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", - "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz", + "integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.2", - "@typescript-eslint/type-utils": "8.71.2", - "@typescript-eslint/utils": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/type-utils": "8.72.0", + "@typescript-eslint/utils": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5434,15 +5434,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.2", + "@typescript-eslint/parser": "^8.72.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", - "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz", + "integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==", "dev": true, "license": "MIT", "engines": { @@ -5450,16 +5450,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", - "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz", + "integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.2", - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2", + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3" }, "engines": { @@ -5475,14 +5475,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", - "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz", + "integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.2", - "@typescript-eslint/types": "^8.71.2", + "@typescript-eslint/tsconfig-utils": "^8.72.0", + "@typescript-eslint/types": "^8.72.0", "debug": "^4.4.3" }, "engines": { @@ -5497,14 +5497,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", - "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz", + "integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2" + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5515,9 +5515,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", - "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz", + "integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==", "dev": true, "license": "MIT", "engines": { @@ -5532,15 +5532,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", - "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz", + "integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2", - "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5557,9 +5557,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", - "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz", + "integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==", "dev": true, "license": "MIT", "engines": { @@ -5571,16 +5571,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", - "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz", + "integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.2", - "@typescript-eslint/tsconfig-utils": "8.71.2", - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/visitor-keys": "8.71.2", + "@typescript-eslint/project-service": "8.72.0", + "@typescript-eslint/tsconfig-utils": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5612,16 +5612,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", - "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz", + "integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.2", - "@typescript-eslint/types": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2" + "@typescript-eslint/scope-manager": "8.72.0", + "@typescript-eslint/types": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5636,13 +5636,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", - "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz", + "integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/types": "8.72.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6795,9 +6795,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", - "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", "dev": true, "funding": [ { @@ -6815,11 +6815,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.26", - "caniuse-lite": "^1.0.30001813", - "electron-to-chromium": "^1.5.439", - "node-releases": "^2.0.57", - "update-browserslist-db": "^1.3.3" + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -7147,13 +7147,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", - "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.51.0.tgz", + "integrity": "sha512-52r6fkXgxv4GLMjmFcMWRx7X9bw071xTTdrDcdyPGRqU2oE+9pCG0bAOMifSBdLw0JRBZKEF0q3B55qPlLuE8g==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.28.7" + "browserslist": "^4.29.3" }, "engines": { "node": ">=6.4.0" @@ -7542,9 +7542,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.456", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.456.tgz", - "integrity": "sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==", + "version": "1.5.459", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", "dev": true, "license": "ISC" }, @@ -8178,9 +8178,9 @@ } }, "node_modules/eslint-plugin-n": { - "version": "18.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.4.1.tgz", - "integrity": "sha512-2sBReWOvCbzgkPri9M5SFmD5EbIwZLa1xz6B5RZsrI19WSFKpyx1sOMr+pdk8puCiXuEgCbErEHy1PuaUrc/4w==", + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.5.0.tgz", + "integrity": "sha512-M7uF6MTN/An+AXqvyIbXq7npJIZOkd5eKo8iMMvWrcDVNyZVdA6TF2lw01aKQfCEidRfpnR78GhAjZorlXNHiw==", "dev": true, "license": "MIT", "dependencies": { @@ -10880,9 +10880,9 @@ } }, "node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "dev": true, "funding": [ { @@ -11115,9 +11115,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.5.tgz", + "integrity": "sha512-NFMjXXkyodBJnRrM0A6qxFO1OLUbrYLDhP1ZLetlXnyx41gKOAmVyROibq85j7I8dda4aZMs9L7kmRvvjNf7Cg==", "dev": true, "license": "MIT", "bin": { @@ -11296,9 +11296,9 @@ } }, "node_modules/nock": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/nock/-/nock-15.0.1.tgz", - "integrity": "sha512-Mjx5Xl4t9vP13eUZPWqwwpjjXNjvhUTo1qTdryVM3+7t6Z2w6WHZ9GVZk/zQ8Ym1vbmloWaglye3cloyHAJ8FA==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/nock/-/nock-15.0.3.tgz", + "integrity": "sha512-VowZEG2zMsqSAkS8OkJrBld3k+ddVdI+lPuuPWS+YoVrdgcp1tN8fWgIqBPWyfV0PRtxYid/IUTthxeu5RLACg==", "dev": true, "license": "MIT", "dependencies": { @@ -11973,9 +11973,9 @@ } }, "node_modules/postcss": { - "version": "8.5.30", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", - "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -13589,16 +13589,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.71.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", - "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "version": "8.72.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz", + "integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.71.2", - "@typescript-eslint/parser": "8.71.2", - "@typescript-eslint/typescript-estree": "8.71.2", - "@typescript-eslint/utils": "8.71.2" + "@typescript-eslint/eslint-plugin": "8.72.0", + "@typescript-eslint/parser": "8.72.0", + "@typescript-eslint/typescript-estree": "8.72.0", + "@typescript-eslint/utils": "8.72.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/src/components/combobox/Combobox.styles.css b/src/components/combobox/Combobox.styles.css index a09eb8109..1efdc1897 100644 --- a/src/components/combobox/Combobox.styles.css +++ b/src/components/combobox/Combobox.styles.css @@ -36,6 +36,14 @@ padding: 10px; padding-right: 40px; width: 100%; + + /* Global focus styles don't reach into the shadow DOM, so mirror them here. */ + &:focus-visible { + outline: var(--focus-indicator-width, 2px) solid var(--color-primary, var(--solid-ui-color-primary, #7C4DFF)); + outline-offset: 2px; + box-shadow: 0 0 0 1px var(--color-background, #F8F9FB), 0 0 0 4px rgba(124, 77, 255, 0.2); + transition: none; + } } icon-lucide-chevron-down { diff --git a/src/widgets/buttons.ts b/src/widgets/buttons.ts index 8a0e18e2b..a6b3779c4 100644 --- a/src/widgets/buttons.ts +++ b/src/widgets/buttons.ts @@ -42,6 +42,7 @@ export type RenderAsDivOptions = { image?: HTMLImageElement, title?: string, deleteFunction?: () => void, + confirmationContainer?: HTMLElement, link?: boolean, noun?: string, draggable?: boolean, @@ -464,16 +465,21 @@ export function faviconOrDefault (dom: HTMLDocument, x: NamedNode) { /* Two-option dialog pop-up */ -function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFunction) { +function renderDeleteConfirmPopup (dom: HTMLDocument, refererenceElement: HTMLElement, prompt: string, deleteFunction: () => any, restoreFocus: () => void) { function removePopup () { - refererenceElement.parentElement.removeChild(refererenceElement) + refererenceElement.remove() + restoreFocus() } function removePopupAndDoDeletion () { removePopup() - deleteFunction() + const result = deleteFunction() + if (result && typeof result.then === 'function') { + return result.then(restoreFocus) + } + restoreFocus() } const popup = dom.createElement('div') - popup.style = style.confirmPopupStyle + popup.style.cssText = style.confirmPopupStyle popup.style.position = 'absolute' popup.style.top = '-1em' // try leaving original button clear @@ -492,7 +498,7 @@ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFuncti xButton.style.gridRow = '2' const cancelPrompt = popup.appendChild(dom.createElement('button')) - cancelPrompt.style = style.buttonStyle + cancelPrompt.style.cssText = style.buttonStyle cancelPrompt.style.gridRow = '2' cancelPrompt.style.gridColumn = '2' cancelPrompt.textContent = 'Cancel' // @@ I18n @@ -503,7 +509,7 @@ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFuncti affirmIcon.style.gridColumn = '1' const sureButtonElt = popup.appendChild(dom.createElement('button')) - sureButtonElt.style = style.buttonStyle + sureButtonElt.style.cssText = style.buttonStyle sureButtonElt.style.gridRow = '1' sureButtonElt.style.gridColumn = '2' sureButtonElt.textContent = prompt @@ -519,33 +525,87 @@ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFuncti } /** * Delete button with a check you really mean it + * Return a promise from deleteFunction for focus restoration after asynchronous deletion. * @@ Supress check if command key held down? */ export function deleteButtonWithCheck ( dom: HTMLDocument, container: HTMLElement, noun: string, - deleteFunction: () => any + deleteFunction: () => any, + confirmationContainer?: HTMLElement ) { function createPopup () { + const focusableSelector = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + const focusScopes: { element: HTMLElement, index: number }[] = [] + if (confirmationContainer) { + for (let ancestor: HTMLElement | null = container; ancestor; ancestor = ancestor.parentElement) { + const controls = Array.from(ancestor.querySelectorAll(focusableSelector)) + focusScopes.push({ element: ancestor, index: controls.indexOf(deleteButton) }) + } + } + function restoreFocus () { + if (!confirmationContainer) return + if (deleteButton.isConnected) { + deleteButton.focus() + return + } + for (const scope of focusScopes) { + if (!scope.element.isConnected) continue + const controls = scope.element.querySelectorAll(focusableSelector) + if (controls.length) { + controls[Math.min(Math.max(scope.index, 0), controls.length - 1)].focus() + return + } + } + const survivingScope = focusScopes.find(scope => scope.element.isConnected) + if (survivingScope) { + survivingScope.element.tabIndex = -1 + survivingScope.element.focus() + } + } const refererenceElement = dom.createElement('div') - container.insertBefore(refererenceElement, deleteButton) - refererenceElement.style.position = 'relative' // Needed as reference for popup - refererenceElement.appendChild(renderDeleteConfirmPopup(dom, refererenceElement, prompt, deleteFunction)) + if (confirmationContainer) { + confirmationContainer.querySelector(':scope > [data-delete-confirmation]')?.remove() + refererenceElement.setAttribute('data-delete-confirmation', '') + refererenceElement.setAttribute('role', 'dialog') + refererenceElement.setAttribute('aria-label', prompt) + confirmationContainer.appendChild(refererenceElement) + } else { + container.insertBefore(refererenceElement, deleteButton) + refererenceElement.style.position = 'relative' // Needed as reference for popup + } + const popup = renderDeleteConfirmPopup(dom, refererenceElement, prompt, deleteFunction, restoreFocus) + refererenceElement.appendChild(popup) + if (confirmationContainer) { + popup.style.position = 'static' + popup.style.top = '' + Array.from(popup.querySelectorAll('button')) + .find(button => button.textContent === 'Cancel')?.focus() + } } const minusIconURI = iconBase + 'noun_2188_red.svg' // white minus in red #cc0000 circle const deleteButton = dom.createElement('img') deleteButton.setAttribute('src', minusIconURI) + deleteButton.tabIndex = 0 + deleteButton.setAttribute('role', 'button') deleteButton.setAttribute('style', style.smallButtonStyle) // @@tsc - would set deleteButton.style deleteButton.style.float = 'right' // Historically this has alwaus floated right const prompt = 'Remove this ' + noun deleteButton.title = prompt + deleteButton.setAttribute('aria-label', prompt) // @@ In an ideal world, make use of hover an accessibility option deleteButton.classList.add('hoverControlHide') deleteButton.addEventListener('click', createPopup) + deleteButton.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + deleteButton.click() + } + }) container.classList.add('hoverControl') container.appendChild(deleteButton) @@ -769,7 +829,7 @@ export function renderAsRow (dom: HTMLDocument, pred: NamedNode, obj: NamedNode, } if (options.deleteFunction) { - deleteButtonWithCheck(dom, td3, options.noun || 'one', options.deleteFunction) + deleteButtonWithCheck(dom, td3, options.noun || 'one', options.deleteFunction, options.confirmationContainer) } if (obj.uri) { // blank nodes need not apply @@ -813,7 +873,7 @@ export function createLinkDiv (dom: HTMLDocument, div: HTMLDivElement, obj: Name linkDiv.setAttribute('style', style.linkDivStyle) if (options.deleteFunction) { - deleteButtonWithCheck(dom, linkDiv, options.noun || 'one', options.deleteFunction) + deleteButtonWithCheck(dom, linkDiv, options.noun || 'one', options.deleteFunction, options.confirmationContainer) } if (obj.uri) { @@ -878,6 +938,8 @@ export type attachmentListOptions = { predicate?: NamedNode uploadFolder?: NamedNode noun?: string + /** Render delete confirmations here in normal flow, restoring focus to the list when closed. */ + confirmationContainer?: HTMLElement renderSupportingInfo?: RenderSupportingInfo renderNameSuffix?: RenderNameSuffix refreshOnDocumentLoad?: boolean @@ -900,11 +962,12 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML const refreshOnDocumentLoad = options.refreshOnDocumentLoad ?? true const deleteAttachment = function (target) { - if (!kb.updater) { + const updater = kb.updater + if (!updater) { throw new Error('kb has no updater') } - kb.updater.update(st(subject, predicate, target, doc) as any, [], function ( + return new Promise(resolve => updater.update(st(subject, predicate, target, doc) as any, [], function ( uri, ok, errorBody, @@ -915,7 +978,8 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML } else { complain(undefined, 'Error deleting one: ' + errorBody) } - }) + resolve() + })) } function createNewRow (target) { @@ -923,6 +987,7 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML const opt: any = { noun } opt.renderSupportingInfo = options.renderSupportingInfo opt.renderNameSuffix = options.renderNameSuffix + opt.confirmationContainer = options.confirmationContainer if (hasAsyncEnrichedRowOptions && refreshOnDocumentLoad && target?.uri && kb.fetcher) { const targetDoc = target.doc() @@ -943,7 +1008,7 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML if (modify) { opt.deleteFunction = function () { - deleteAttachment(theTarget) + return deleteAttachment(theTarget) } } return personTR(dom, predicate, target, opt) diff --git a/src/widgets/buttons/iconLinks.ts b/src/widgets/buttons/iconLinks.ts index d773987d5..91d4d160b 100644 --- a/src/widgets/buttons/iconLinks.ts +++ b/src/widgets/buttons/iconLinks.ts @@ -1,5 +1,5 @@ import { NamedNode } from 'rdflib' -import { originalIconBase } from '../../lib/iconBase' +import '~icons/lucide/square-arrow-out-up-right' /** * Creates an anchor tag for a NamedNode @@ -21,12 +21,16 @@ export function linkIcon (dom: HTMLDocument, subject: NamedNode, iconURI?: strin // If diff web page anchor.setAttribute('target', '_blank') // open in a new tab or window } // as mailboxes and mail messages do not need new browser window - const img = anchor.appendChild(dom.createElement('img')) - img.setAttribute( - 'src', - iconURI || originalIconBase + 'go-to-this.png' - ) - img.setAttribute('style', 'margin: 0.3em;') + if (iconURI) { + const img = anchor.appendChild(dom.createElement('img')) + img.setAttribute('src', iconURI) + img.setAttribute('style', 'margin: 0.3em;') + } else { + anchor.setAttribute('aria-label', 'Open resource') + const icon = anchor.appendChild(dom.createElement('icon-lucide-square-arrow-out-up-right')) + icon.setAttribute('aria-hidden', 'true') + icon.setAttribute('style', 'display: inline-block; width: 1em; height: 1em; margin: 0.2em; color: var(--color-text-secondary, #4A5565);') + } return anchor } /** diff --git a/test/unit/widgets/buttons.test.ts b/test/unit/widgets/buttons.test.ts index 71bb1a640..41b9dff2d 100644 --- a/test/unit/widgets/buttons.test.ts +++ b/test/unit/widgets/buttons.test.ts @@ -123,6 +123,94 @@ describe('attachmentList', () => { expect(attachmentList(dom, subject, div, options)).toBeTruthy() }) + it('keeps delete confirmations inside the row by default', () => { + const subject = sym('https://subject.example/profile/card#me') + const target = sym('https://friend.example/profile/card#me') + store.add(subject, ns.foaf('knows'), target, subject.doc()) + const list = attachmentList(dom, subject, element, { predicate: ns.foaf('knows') }) + const remove = list.querySelector('[title="Remove this attachment"]')! + remove.click() + + const reference = remove.previousElementSibling as HTMLElement + expect(reference.parentElement).toBe(remove.parentElement) + expect(reference.style.position).toBe('relative') + expect((reference.firstElementChild as HTMLElement).style.position).toBe('absolute') + expect(getByRole(reference, 'button', { name: 'Remove this attachment' })).toBeTruthy() + }) + + it('places confirmations in the optional container, including after a refresh', () => { + dom.body.appendChild(element) + const subject = sym('https://subject.example/profile/card#me') + const target = sym('https://friend.example/profile/card#me') + const host = dom.body.appendChild(dom.createElement('div')) + const sentinel = host.appendChild(dom.createElement('p')) + store.add(subject, ns.foaf('knows'), target, subject.doc()) + const list = attachmentList(dom, subject, element, { + predicate: ns.foaf('knows'), + noun: 'friend', + confirmationContainer: host, + renderSupportingInfo: () => null, + refreshOnDocumentLoad: false + }) + const open = () => list.querySelector('[title="Remove this friend"]')!.click() + open() + open() + + expect(host.querySelectorAll('[role="dialog"]')).toHaveLength(1) + const dialog = host.querySelector('[role="dialog"]')! + expect(dialog.parentElement).toBe(host) + expect(host.contains(sentinel)).toBe(true) + expect(list.contains(dialog)).toBe(false) + expect((dialog.firstElementChild as HTMLElement).style.position).toBe('static') + const cancel = Array.from(dialog.querySelectorAll('button')) + .find(button => button.textContent === 'Cancel')! + expect(dom.activeElement).toBe(cancel) + cancel.click() + expect(host.querySelector('[role="dialog"]')).toBeNull() + expect(store.holds(subject, ns.foaf('knows'), target)).toBe(true) + expect(dom.activeElement).toBe(list.querySelector('[title="Remove this friend"]')) + + refreshTree(list) + open() + expect(host.querySelectorAll('[role="dialog"]')).toHaveLength(1) + }) + + it('restores focus after asynchronous deletion and when the list becomes empty', async () => { + dom.body.appendChild(element) + const host = dom.body.appendChild(dom.createElement('div')) + const subject = sym('https://subject.example/profile/card#me') + const targets = [ + sym('https://first.example/profile/card#me'), + sym('https://second.example/profile/card#me') + ] + const predicate = ns.foaf('knows') + targets.forEach(target => store.add(subject, predicate, target, subject.doc())) + const update = vi.spyOn(store.updater!, 'update').mockImplementation(() => Promise.resolve()) + const list = attachmentList(dom, subject, element, { + predicate, + confirmationContainer: host, + renderSupportingInfo: () => null, + refreshOnDocumentLoad: false + }) + + for (const target of targets) { + const remove = list.querySelector('[data-testid="deleteButtonWithCheck"]')! + remove.click() + getByRole(host, 'button', { name: 'Remove this attachment' }).click() + expect(host.querySelector('[role="dialog"]')).toBeNull() + expect(dom.activeElement).toBe(remove) + + const callback = update.mock.calls[update.mock.calls.length - 1][2]! + store.removeMatches(subject, predicate, target) + callback(subject.doc().uri, true, '') + await Promise.resolve() + + const remainingRemove = list.querySelector('[data-testid="deleteButtonWithCheck"]') + expect(dom.activeElement).toBe(remainingRemove || list.querySelector('button')) + expect(dom.activeElement?.isConnected).toBe(true) + } + }) + it('refreshes rows after pending profile fetches by default', () => { const subject = sym('https://subject.example/profile/card#me') const predicate = ns.foaf('knows') @@ -324,6 +412,83 @@ describe('deleteButtonWithCheck', () => { const deleteFunction = () => {} expect(deleteButtonWithCheck(dom, container, noun, deleteFunction)).toBeTruthy() }) + + it.each([false, true])('preserves cancel and confirm handlers with an external container: %s', external => { + const host = external ? dom.createElement('div') : undefined + const deleteFunction = vi.fn() + const remove = deleteButtonWithCheck(dom, element, 'friend', deleteFunction, host) + const popupContainer = host || element + remove.click() + const cancel = Array.from(popupContainer.querySelectorAll('button')) + .find(button => button.textContent === 'Cancel')! + cancel.click() + expect(popupContainer.querySelector('button')).toBeNull() + expect(deleteFunction).not.toHaveBeenCalled() + + remove.click() + const popup = external ? host! : remove.previousElementSibling as HTMLElement + getByRole(popup, 'button', { name: 'Remove this friend' }).click() + expect(deleteFunction).toHaveBeenCalledTimes(1) + expect(popupContainer.querySelector('button')).toBeNull() + }) + + it.each(['text', 'icon'])('restores focus to the originating control after %s cancellation', control => { + dom.body.appendChild(element) + const host = dom.body.appendChild(dom.createElement('div')) + const deleteFunction = vi.fn() + const remove = deleteButtonWithCheck(dom, element, 'friend', deleteFunction, host) + remove.focus() + expect(dom.activeElement).toBe(remove) + expect(getByRole(element, 'button', { name: 'Remove this friend' })).toBe(remove) + + remove.click() + const cancel = Array.from(host.querySelectorAll('button')) + .find(button => button.textContent === 'Cancel')! + expect(dom.activeElement).toBe(cancel) + if (control === 'text') { + cancel.click() + } else { + host.querySelector('button')!.click() + } + + expect(host.querySelector('[role="dialog"]')).toBeNull() + expect(dom.activeElement).toBe(remove) + expect(deleteFunction).not.toHaveBeenCalled() + }) + + it.each(['Enter', ' '])('opens confirmation with the %s key', key => { + dom.body.appendChild(element) + const host = dom.body.appendChild(dom.createElement('div')) + const remove = deleteButtonWithCheck(dom, element, 'friend', vi.fn(), host) + remove.focus() + remove.dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true })) + + const cancel = Array.from(host.querySelectorAll('button')) + .find(button => button.textContent === 'Cancel')! + expect(dom.activeElement).toBe(cancel) + }) + + it.each(['first', 'last'])('focuses a surviving control after deleting the %s row', position => { + dom.body.appendChild(element) + const host = dom.body.appendChild(dom.createElement('div')) + const firstRow = element.appendChild(dom.createElement('div')) + const lastRow = element.appendChild(dom.createElement('div')) + const firstRemove = deleteButtonWithCheck(dom, firstRow, 'friend', () => firstRow.remove(), host) + const lastRemove = deleteButtonWithCheck(dom, lastRow, 'friend', () => lastRow.remove(), host) + const remove = position === 'first' ? firstRemove : lastRemove + const survivor = position === 'first' ? lastRemove : firstRemove + + remove.click() + if (position === 'first') { + getByRole(host, 'button', { name: 'Remove this friend' }).click() + } else { + host.querySelector('img[title="Delete it"]')!.parentElement!.click() + } + + expect(host.querySelector('[role="dialog"]')).toBeNull() + expect(remove.isConnected).toBe(false) + expect(dom.activeElement).toBe(survivor) + }) }) describe('extractLogURI', () => { diff --git a/test/unit/widgets/buttons/iconLink.test.ts b/test/unit/widgets/buttons/iconLink.test.ts index 13c5d2fdd..a3eba1ec7 100644 --- a/test/unit/widgets/buttons/iconLink.test.ts +++ b/test/unit/widgets/buttons/iconLink.test.ts @@ -28,6 +28,29 @@ describe('linkIcon', () => { const iconURI = '' expect(linkIcon(dom, subject, iconURI)).toBeTruthy() }) + + it('renders the shared Lucide component with a neutral color and accessible link name', () => { + const anchor = linkIcon(dom, obj) + const icon = anchor.querySelector('icon-lucide-square-arrow-out-up-right')! + expect(icon).not.toBeNull() + expect(icon.getAttribute('aria-hidden')).toBe('true') + expect(icon.style.width).toBe('1em') + expect(icon.style.height).toBe('1em') + expect(icon.style.margin).toBe('0.2em') + expect(icon.style.color).toBe('var(--color-text-secondary, #4A5565)') + expect(anchor.getAttribute('href')).toBe(obj.value) + expect(anchor.getAttribute('target')).toBe('_blank') + expect(anchor.getAttribute('aria-label')).toBe('Open resource') + expect(anchor.querySelector('img')).toBeNull() + }) + + it('preserves explicitly supplied image icons and non-HTTP link behavior', () => { + const anchor = linkIcon(dom, new NamedNode('mailto:friend@example.com'), 'https://example.com/custom.svg') + expect(anchor.querySelector('img')?.getAttribute('src')).toBe('https://example.com/custom.svg') + expect(anchor.querySelector('img')?.style.margin).toBe('0.3em') + expect(anchor.querySelector('icon-lucide-square-arrow-out-up-right')).toBeNull() + expect(anchor.hasAttribute('target')).toBe(false) + }) }) describe('createLinkForURI', () => { diff --git a/test/unit/widgets/forms/autocomplete/__snapshots__/autocomplete.test.ts.snap b/test/unit/widgets/forms/autocomplete/__snapshots__/autocomplete.test.ts.snap index 903baa0e4..2adf7068a 100644 --- a/test/unit/widgets/forms/autocomplete/__snapshots__/autocomplete.test.ts.snap +++ b/test/unit/widgets/forms/autocomplete/__snapshots__/autocomplete.test.ts.snap @@ -99,10 +99,13 @@ exports[`autocompleteField > clicking on row of greenn table then accecpt button class="hoverControl" > @@ -190,10 +193,13 @@ exports[`autocompleteField > creates a autocomplete field 1`] = ` class="hoverControl" > @@ -316,10 +322,13 @@ exports[`autocompleteField > typing more search term till unique selects the who class="hoverControl" >