diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f26e706..2f29850 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,9 @@ jobs: # but skips the actual `pnpm run build`, so pnpm's # syncInjectedDepsAfterScripts hook never refreshes the injected # copies created by the scenario install - run: pnpm turbo test --force + # scenarios only vary test-app's dependencies; docs-app tests run + # in the Default Tests job + run: pnpm turbo test --filter test-app --force env: ${{ matrix.env }} build_docs: diff --git a/docs-app/.gitignore b/docs-app/.gitignore index 8b8a365..cd63079 100644 --- a/docs-app/.gitignore +++ b/docs-app/.gitignore @@ -4,3 +4,6 @@ node_modules/ .env* !.env.example tmp/ + +# a11y middleware reporter output (test artifact) +ember-a11y-report/ diff --git a/docs-app/.prettierignore b/docs-app/.prettierignore index 29c69b2..5bc605d 100644 --- a/docs-app/.prettierignore +++ b/docs-app/.prettierignore @@ -1,3 +1,4 @@ dist/ node_modules/ pnpm-lock.yaml +ember-a11y-report/ diff --git a/docs-app/package.json b/docs-app/package.json index afe208e..ad750c5 100644 --- a/docs-app/package.json +++ b/docs-app/package.json @@ -33,7 +33,10 @@ "lint:prettier": "prettier -c .", "lint:prettier:fix": "prettier -w .", "lint:types": "ember-tsc --noEmit", - "start": "vite" + "start": "vite", + "build:test": "NODE_ENV=development vite build --mode development", + "test": "pnpm build:test && pnpm test:ci", + "test:ci": "testem ci --port 0" }, "dependencies": { "@ember/test-waiters": "^4.1.1", @@ -65,6 +68,7 @@ "@babel/plugin-transform-typescript": "^7.29.7", "@babel/runtime": "^7.29.7", "@ember/app-tsconfig": "^2.0.0", + "@ember/test-helpers": "^5.4.3", "@embroider/core": "^4.4.3", "@embroider/macros": "^1.19.7", "@embroider/vite": "^1.5.2", @@ -74,15 +78,22 @@ "@glint/tsserver-plugin": "^2.5.18", "@nullvoxpopuli/eslint-configs": "^6.0.0", "@rollup/plugin-babel": "^7.1.0", + "@types/qunit": "^2.19.14", "@warp-drive/build-config": "^5.8.2", + "axe-core": "^4.12.1", "babel-plugin-ember-template-compilation": "^4.0.0", "concurrently": "^9.2.1", "decorator-transforms": "^2.3.1", + "ember-a11y-testing": "^8.0.1", + "ember-qunit": "^9.1.0", "ember-source": "^7.1.0", "ember-template-lint": "^5.2.0", "eslint": "^9.22.0", "prettier": "^3.8.1", "prettier-plugin-ember-template-tag": "^2.1.3", + "qunit": "^2.26.0", + "qunit-dom": "^3.6.0", + "testem": "^3.20.1", "typescript": "~6.0.0", "vite": "^8.1.5" }, diff --git a/docs-app/src/config.ts b/docs-app/src/config.ts index 5933244..429a69a 100644 --- a/docs-app/src/config.ts +++ b/docs-app/src/config.ts @@ -4,7 +4,16 @@ const ENV = { rootURL: '/', locationType: 'history', EmberENV: {}, - APP: {} as Record, + APP: {} as Record & { + rootElement?: string; + autoboot?: boolean; + }, }; export default ENV; + +export function enterTestMode() { + ENV.locationType = 'none'; + ENV.APP.rootElement = '#ember-testing'; + ENV.APP.autoboot = false; +} diff --git a/docs-app/src/styles/app.css b/docs-app/src/styles/app.css index 1eabe50..dbc69b8 100644 --- a/docs-app/src/styles/app.css +++ b/docs-app/src/styles/app.css @@ -1,5 +1,125 @@ @import './docs-support.css'; +/* + * CSS Reset (modern minimal) + */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +ol, +ul { + list-style: none; + margin: 0; + padding: 0; +} + +/* + * Custom properties + */ +:root { + --font-sans: + 'InterVariable', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-display: 'Helvetica', 'Arial', sans-serif; + --max-width-8xl: 88rem; +} + +body { + font-family: var(--font-sans); +} + +/* + * Theme variables + */ +.theme-light { + --color-page-background: white; + --color-foreground: black; +} + +.theme-dark { + --color-page-background: #252525; + --color-foreground: white; +} + +/* + * Prose overrides + */ +.prose + :where(code):not( + :where([class~='not-prose'], [class~='not-prose'] *) + )::before { + content: '' !important; +} + +.prose + :where(code):not( + :where([class~='not-prose'], [class~='not-prose'] *) + )::after { + content: '' !important; +} + +.prose .demo details { + border: 0; + margin: 0; + padding: 0; +} + +.prose .featured-demo input { + color: black; +} + +/* + * TypeDoc overrides + */ +.typedoc__declaration-children { + max-width: 800px; +} + +.typedoc__component-signature__block + > .typedoc__property + > .typedoc__declaration + .typedoc__declaration-children { + display: block; +} + /* * Small utility set used by the doc pages' demos * (kept intentionally tiny -- this app does not use tailwind) diff --git a/docs-app/testem.cjs b/docs-app/testem.cjs new file mode 100644 index 0000000..2d9d25a --- /dev/null +++ b/docs-app/testem.cjs @@ -0,0 +1,34 @@ +'use strict'; + +if (typeof module !== 'undefined') { + const { a11yMiddleware } = require('ember-a11y-testing/middleware'); + + module.exports = { + test_page: 'tests/index.html?hidepassed', + cwd: 'dist', + middleware: [a11yMiddleware], + disable_watching: true, + launch_in_ci: ['Chrome'], + launch_in_dev: ['Chrome'], + browser_start_timeout: 120, + // The Pages a11y test walks every docs page and runs axe-core + // three times per page (default/dark/light theme); give the + // disconnect timeout enough headroom that a slow audit isn't + // mistaken for a dead browser. + browser_disconnect_timeout: 240, + browser_args: { + Chrome: { + ci: [ + // --no-sandbox is needed when running Chrome inside a container + process.env.CI ? '--no-sandbox' : null, + '--headless=new', + '--disable-dev-shm-usage', + '--disable-software-rasterizer', + '--mute-audio', + '--remote-debugging-port=0', + '--window-size=1440,900', + ].filter(Boolean), + }, + }, + }; +} diff --git a/docs-app/tests/application/pages-test.ts b/docs-app/tests/application/pages-test.ts new file mode 100644 index 0000000..9c27741 --- /dev/null +++ b/docs-app/tests/application/pages-test.ts @@ -0,0 +1,108 @@ +import { findAll, settled, visit, waitUntil } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; + +import { colorScheme } from 'ember-primitives/color-scheme'; +import { docsManager } from 'kolay'; + +import { a11yAudit } from 'ember-a11y-testing/test-support'; + +/** + * per-page a11y rule overrides + */ +const a11yChecks: { + [url: string]: { + [checkName: string]: Record; + }; +} = {}; + +/** + * a11yAudit halts tests, this gets around that + */ +async function checkA11y( + assert: Assert, + path: string, + theme: string, + settings: object, +) { + await settled(); + + try { + await a11yAudit({ + rules: { + // TODO: find a syntax highlighting theme + // with better contrast + 'color-contrast': { + enabled: false, + }, + ...settings, + }, + }); + assert.ok( + true, + `no a11y errors found for ${path} using the ${theme} theme`, + ); + } catch (e) { + let errorText = ''; + + if (typeof e === 'object') { + if (e && 'message' in e && typeof e.message === 'string') { + errorText = e.message; + } + } + + const message = + `${path}: no a11y errors found using the ${theme} theme` + + `\n\n` + + errorText; + + if (window.location.search.includes('debugA11yAudit')) { + console.error(errorText); + } + + assert.ok(false, message); + } +} + +module('Application | Pages', function (hooks) { + setupApplicationTest(hooks); + + test('Pages all render and fit a11y criteria', async function (assert) { + // every page is visited and audited three times + // (default / dark / light theme) + assert.timeout(600_000); + + await visit('/'); + + const pages: { path: string }[] = []; + + const docsService = docsManager(this); + const groups = docsService.manifest.groups; + + for (const group of groups) { + for (const page of group.list) { + pages.push(page); + } + } + + assert.ok(pages.length > 10, `There are at least a few pages`); + + for (const page of pages) { + const path = page.path.replace('.gjs.md', '').replace('.md', ''); + const settings: object = a11yChecks[page.path] ?? {}; + + await visit(path); + await waitUntil(() => findAll('nav a').length !== 0); + + for (const theme of ['default', 'dark', 'light'] as const) { + if (theme === 'dark') colorScheme.update('dark'); + if (theme === 'light') colorScheme.update('light'); + await checkA11y(assert, path, theme, settings); + } + + assert + .dom('[data-page-error]') + .doesNotExist(`${page.path}: does not contain [data-page-error]`); + } + }); +}); diff --git a/docs-app/tests/index.html b/docs-app/tests/index.html new file mode 100644 index 0000000..3332b0a --- /dev/null +++ b/docs-app/tests/index.html @@ -0,0 +1,27 @@ + + + + + docs-app Tests + + + +
+
+
+
+
+
+ + + + + + + diff --git a/docs-app/tests/test-helper.ts b/docs-app/tests/test-helper.ts new file mode 100644 index 0000000..d6d7d5f --- /dev/null +++ b/docs-app/tests/test-helper.ts @@ -0,0 +1,24 @@ +import { setApplication } from '@ember/test-helpers'; +import * as QUnit from 'qunit'; +import { setup } from 'qunit-dom'; +import { start as qunitStart } from 'ember-qunit'; + +import Application from 'docs-app/app'; +import config, { enterTestMode } from 'docs-app/config'; + +import { setupMiddlewareReporter } from 'ember-a11y-testing/test-support'; + +QUnit.config.urlConfig.push({ + id: 'debugA11yAudit', + label: 'Log a11y violations', +}); + +export function start() { + enterTestMode(); + setupMiddlewareReporter(); + setApplication(Application.create(config.APP)); + + setup(QUnit.assert); + + qunitStart(); +} diff --git a/docs-app/tsconfig.json b/docs-app/tsconfig.json index 15dbdb7..3369993 100644 --- a/docs-app/tsconfig.json +++ b/docs-app/tsconfig.json @@ -7,7 +7,11 @@ "kolay/virtual", "@embroider/core/virtual", "@glint/ember-tsc/types" - ] + ], + "paths": { + "docs-app/tests/*": ["./tests/*"], + "docs-app/*": ["./src/*"] + } }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*"] } diff --git a/package.json b/package.json index 1126dd7..d54f538 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "start": "pnpm --filter './packages/*' build; concurrently 'npm:_start:*' --names '_start:'", "start:docs-app": "pnpm --filter './packages/*' build; pnpm --stream --parallel --filter docs-app... start", "start:test-app": "pnpm --filter './packages/*' build; pnpm --stream --parallel --filter test-app... start", - "test": "pnpm turbo --filter test-app test" + "test": "pnpm turbo test" }, "devDependencies": { "concurrently": "^9.0.0", diff --git a/packages/form/LICENSE.md b/packages/form/LICENSE.md new file mode 100644 index 0000000..9438560 --- /dev/null +++ b/packages/form/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2023 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/form/README.md b/packages/form/README.md new file mode 100644 index 0000000..5fecfed --- /dev/null +++ b/packages/form/README.md @@ -0,0 +1,42 @@ +# @universal-ember/form + +[![CI](https://github.com/universal-ember/form/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/universal-ember/form/actions/workflows/ci.yml) + +The Ember.js library that distills the common behavior and accessibility best practices of forms into reusable components, without any opinions on specific markup or styling. Use it to build your forms directly, or to build your opinionated forms component kit on top of it. + +## Key Features + +- Semantic form markup with accessibility best practices baked in +- Integrates native HTML5 validation with custom error rendering +- Extension points for custom JavaScript-based validation +- Optional ready-to-use integrations for `ember-changeset` and `yup` based validation +- Support for async state +- TypeScript / Glint support with tight types +- Fully tested + +## Compatibility + +- Ember.js v4.4 or above (CI-verified on v5.8+) +- a modern (vite) app, or a classic build via ember-auto-import v2 / Embroider + +## Installation + +``` +pnpm add @universal-ember/form +``` + +## Usage + +Visit our [documentation website](https://ue-form.pages.dev/). + +## Contributing + +See the [Contributing](CONTRIBUTING.md) guide for details. + +## Credits + +Originally from [CrowdStrike's ember-headless-form](https://github.com/crowdstrike/ember-headless-form) + +## License + +This project is licensed under the [MIT License](LICENSE.md). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 752e6e8..7ee3ccf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@ember/app-tsconfig': specifier: ^2.0.0 version: 2.0.0 + '@ember/test-helpers': + specifier: ^5.4.3 + version: 5.4.3(e27f1fbe8274ab813e761daf623f1bc2) '@embroider/core': specifier: ^4.4.3 version: 4.6.2(6846b34809607f33d3b82fac81bfadd9) @@ -144,9 +147,15 @@ importers: '@rollup/plugin-babel': specifier: ^7.1.0 version: 7.1.0(52ab2595d8ec294d960b34d56caae962) + '@types/qunit': + specifier: ^2.19.14 + version: 2.19.14 '@warp-drive/build-config': specifier: ^5.8.2 version: 5.8.2(441692fd1e7dbc4a7b3cba7430ac7990) + axe-core: + specifier: ^4.12.1 + version: 4.12.1 babel-plugin-ember-template-compilation: specifier: ^4.0.0 version: 4.0.0 @@ -156,6 +165,12 @@ importers: decorator-transforms: specifier: ^2.3.1 version: 2.4.0(@babel/core@7.29.7(supports-color@8.1.1)) + ember-a11y-testing: + specifier: ^8.0.1 + version: 8.0.1(a4953075f03af2f5b5e23c4c2adbcb38) + ember-qunit: + specifier: ^9.1.0 + version: 9.1.0(12446d7b9d303199558330a1329a9ebd) ember-source: specifier: ^7.1.0 version: 7.1.0(07d12e0a83666cb1162306c6a1884631) @@ -171,6 +186,15 @@ importers: prettier-plugin-ember-template-tag: specifier: ^2.1.3 version: 2.1.7(prettier@3.9.5)(supports-color@8.1.1) + qunit: + specifier: ^2.26.0 + version: 2.26.0 + qunit-dom: + specifier: ^3.6.0 + version: 3.6.0 + testem: + specifier: ^3.20.1 + version: 3.20.1(b4cd23e5c9ba151dcc5b9e2687011ab1) typescript: specifier: ~6.0.0 version: 6.0.3